Skip to content

Commit 6af99da

Browse files
committed
feat(agents): Claude native outputFormat + structured_output
Pass JSON Schema via outputFormat on query options and prefer structured_output from result messages. OpenCode stays prompt-path only.
1 parent 3030d86 commit 6af99da

2 files changed

Lines changed: 94 additions & 4 deletions

File tree

src/local-agent-adapters.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
22
import { delimiter } from "node:path";
33
import {
44
claudeCommandEnvironment,
5+
claudeOutputFormatOptions,
56
createLocalAgentAdapter,
7+
extractClaudeResultPayload,
68
extractOpenCodeFinalResponse,
79
extractPiFinalResponse,
810
extractPiProviderError,
@@ -388,3 +390,41 @@ assert.equal(
388390

389391
assert.equal(env.PATH, [devspaceBin, "/home/user/.local/bin"].join(delimiter));
390392
}
393+
394+
{
395+
assert.deepEqual(claudeOutputFormatOptions(undefined), {});
396+
const schema = {
397+
type: "object",
398+
properties: { n: { type: "number" } },
399+
required: ["n"],
400+
};
401+
assert.deepEqual(claudeOutputFormatOptions(schema), {
402+
outputFormat: { type: "json_schema", schema },
403+
});
404+
}
405+
406+
{
407+
assert.deepEqual(
408+
extractClaudeResultPayload({
409+
type: "result",
410+
result: '{"n":1}',
411+
structured_output: { n: 1 },
412+
}),
413+
{ finalResponse: '{"n":1}', structured: { n: 1 } },
414+
);
415+
assert.deepEqual(
416+
extractClaudeResultPayload({
417+
type: "result",
418+
structured_output: { n: 2 },
419+
}),
420+
{ finalResponse: '{"n":2}', structured: { n: 2 } },
421+
);
422+
assert.deepEqual(
423+
extractClaudeResultPayload({
424+
type: "result",
425+
result: "plain text only",
426+
}),
427+
{ finalResponse: "plain text only" },
428+
);
429+
assert.equal(extractClaudeResultPayload({ type: "assistant" }), undefined);
430+
}

src/local-agent-adapters.ts

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,20 +70,25 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter {
7070
allowDangerouslySkipPermissions: true,
7171
env: claudeCommandEnvironment(process.env),
7272
...(claudeExecutable ? { pathToClaudeCodeExecutable: claudeExecutable } : {}),
73+
...claudeOutputFormatOptions(input.schema),
7374
},
7475
});
7576

7677
let providerSessionId = input.providerSessionId ?? null;
7778
let finalResponse = "";
79+
let structured: unknown | undefined;
7880
const items: unknown[] = [];
7981
for await (const message of messages) {
8082
items.push(message);
8183
const record = message as Record<string, unknown>;
8284
if (typeof record.session_id === "string") providerSessionId = record.session_id;
83-
if (record.type === "result" && typeof record.result === "string") {
84-
const resultError = claudeResultError(record);
85-
if (resultError) throw new Error(resultError);
86-
finalResponse = record.result;
85+
if (record.type !== "result") continue;
86+
const resultError = claudeResultError(record);
87+
if (resultError) throw new Error(resultError);
88+
const extracted = extractClaudeResultPayload(record);
89+
if (extracted) {
90+
finalResponse = extracted.finalResponse;
91+
structured = extracted.structured;
8792
}
8893
}
8994

@@ -93,10 +98,52 @@ class ClaudeLocalAgentAdapter implements LocalAgentAdapter {
9398
providerSessionId,
9499
finalResponse,
95100
items,
101+
...(structured !== undefined ? { structured } : {}),
96102
};
97103
}
98104
}
99105

106+
/** Build Claude SDK outputFormat when a JSON Schema is requested. */
107+
export function claudeOutputFormatOptions(
108+
schema: object | undefined,
109+
): { outputFormat: { type: "json_schema"; schema: Record<string, unknown> } } | Record<string, never> {
110+
if (!schema) return {};
111+
return {
112+
outputFormat: {
113+
type: "json_schema",
114+
schema: schema as Record<string, unknown>,
115+
},
116+
};
117+
}
118+
119+
/**
120+
* Prefer structured_output from a Claude result message; fall back to text result.
121+
* When only structured is present, stringify it for journal finalResponse.
122+
*/
123+
export function extractClaudeResultPayload(
124+
record: Record<string, unknown>,
125+
): { finalResponse: string; structured?: unknown } | undefined {
126+
const hasStructured = Object.prototype.hasOwnProperty.call(record, "structured_output");
127+
const structured = hasStructured ? record.structured_output : undefined;
128+
const text = typeof record.result === "string" ? record.result : undefined;
129+
130+
if (hasStructured && structured !== undefined) {
131+
return {
132+
finalResponse:
133+
text && text.trim()
134+
? text
135+
: typeof structured === "string"
136+
? structured
137+
: JSON.stringify(structured),
138+
structured,
139+
};
140+
}
141+
if (text !== undefined) {
142+
return { finalResponse: text };
143+
}
144+
return undefined;
145+
}
146+
100147
function claudeResultError(record: Record<string, unknown>): string | undefined {
101148
const subtype = typeof record.subtype === "string" ? record.subtype : undefined;
102149
const isError = record.is_error === true || subtype?.startsWith("error");
@@ -516,6 +563,9 @@ async function promptOpencodeSession(
516563
sessionId: string,
517564
input: LocalAgentRunInput,
518565
): Promise<unknown> {
566+
// OpenCode SessionPrompt accepts format: { type: 'json_schema', schema }, but
567+
// per-model support is not programmatically discoverable — workflow agent({ schema })
568+
// keeps the prompt+Ajv path for opencode (no native structured output here).
519569
const session = (client as {
520570
session: {
521571
prompt(parameters?: unknown, options?: unknown): Promise<unknown>;

0 commit comments

Comments
 (0)