Skip to content

Commit fa4111f

Browse files
feat: Add initial structured output for agent (#1466)
1 parent ae5043d commit fa4111f

12 files changed

Lines changed: 95 additions & 13 deletions

File tree

apps/code/src/main/services/agent/schemas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export const startSessionInput = z.object({
5050
customInstructions: z.string().max(2000).optional(),
5151
effort: effortLevelSchema.optional(),
5252
model: z.string().optional(),
53+
jsonSchema: z.record(z.string(), z.unknown()).nullish(),
5354
});
5455

5556
export type StartSessionInput = z.infer<typeof startSessionInput>;
@@ -173,6 +174,7 @@ export const reconnectSessionInput = z.object({
173174
permissionMode: z.string().optional(),
174175
customInstructions: z.string().max(2000).optional(),
175176
effort: effortLevelSchema.optional(),
177+
jsonSchema: z.record(z.string(), z.unknown()).nullish(),
176178
});
177179

178180
export type ReconnectSessionInput = z.infer<typeof reconnectSessionInput>;

apps/code/src/main/services/agent/service.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,8 @@ interface SessionConfig {
255255
effort?: EffortLevel;
256256
/** Model to use for the session (e.g. "claude-sonnet-4-6") */
257257
model?: string;
258+
/** JSON Schema for structured task output — when set, the agent gets a create_output tool */
259+
jsonSchema?: Record<string, unknown> | null;
258260
}
259261

260262
interface ManagedSession {
@@ -561,6 +563,7 @@ When creating pull requests, add the following footer at the end of the PR descr
561563
customInstructions,
562564
effort,
563565
model,
566+
jsonSchema,
564567
} = config;
565568

566569
// Preview config doesn't need a real repo — use a temp directory
@@ -623,6 +626,14 @@ When creating pull requests, add the following footer at the end of the PR descr
623626
codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined,
624627
model,
625628
instructions: adapter === "codex" ? systemPrompt.append : undefined,
629+
onStructuredOutput: jsonSchema
630+
? async (output) => {
631+
const posthogAPI = agent.getPosthogAPI();
632+
if (posthogAPI) {
633+
await posthogAPI.updateTaskRun(taskId, taskRunId, { output });
634+
}
635+
}
636+
: undefined,
626637
processCallbacks: {
627638
onProcessSpawned: (info) => {
628639
this.processTracking.register(
@@ -758,6 +769,7 @@ When creating pull requests, add the following footer at the end of the PR descr
758769
systemPrompt,
759770
...(permissionMode && { permissionMode }),
760771
...(model != null && { model }),
772+
...(jsonSchema && { jsonSchema }),
761773
claudeCode: {
762774
options: claudeCodeOptions,
763775
},
@@ -780,6 +792,7 @@ When creating pull requests, add the following footer at the end of the PR descr
780792
systemPrompt,
781793
...(permissionMode && { permissionMode }),
782794
...(model != null && { model }),
795+
...(jsonSchema && { jsonSchema }),
783796
claudeCode: {
784797
options: claudeCodeOptions,
785798
},
@@ -1470,6 +1483,7 @@ For git operations while detached:
14701483
"customInstructions" in params ? params.customInstructions : undefined,
14711484
effort: "effort" in params ? params.effort : undefined,
14721485
model: "model" in params ? params.model : undefined,
1486+
jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined,
14731487
};
14741488
}
14751489

packages/agent/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@
9696
},
9797
"dependencies": {
9898
"@agentclientprotocol/sdk": "0.16.1",
99+
"ajv": "^8.17.1",
99100
"@anthropic-ai/claude-agent-sdk": "0.2.76",
100101
"@anthropic-ai/sdk": "^0.78.0",
101102
"@hono/node-server": "^1.19.9",

packages/agent/src/adapters/acp-connection.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export type AcpConnectionConfig = {
2424
processCallbacks?: ProcessSpawnedCallback;
2525
codexOptions?: CodexProcessOptions;
2626
allowedModelIds?: Set<string>;
27+
/** Callback invoked when the agent calls the create_output tool for structured output */
28+
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
2729
};
2830

2931
export type AcpConnection = {
@@ -97,7 +99,10 @@ function createClaudeConnection(config: AcpConnectionConfig): AcpConnection {
9799

98100
let agent: ClaudeAcpAgent | null = null;
99101
const agentConnection = new AgentSideConnection((client) => {
100-
agent = new ClaudeAcpAgent(client, config.processCallbacks);
102+
agent = new ClaudeAcpAgent(client, {
103+
...config.processCallbacks,
104+
onStructuredOutput: config.onStructuredOutput,
105+
});
101106
logger.info(`Created ${agent.adapterName} agent`);
102107
return agent;
103108
}, agentStream);

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ export interface ClaudeAcpAgentOptions {
109109
onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
110110
onProcessExited?: (pid: number) => void;
111111
onMcpServersReady?: (serverNames: string[]) => void;
112+
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
112113
}
113114

114115
export class ClaudeAcpAgent extends BaseAcpAgent {
@@ -483,6 +484,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
483484
const result = handleResultMessage(message);
484485
if (result.error) throw result.error;
485486

487+
// Deliver structured output from SDK's native outputFormat
488+
if (
489+
message.subtype === "success" &&
490+
message.structured_output != null &&
491+
this.options?.onStructuredOutput
492+
) {
493+
await this.options.onStructuredOutput(
494+
message.structured_output as Record<string, unknown>,
495+
);
496+
}
497+
486498
// For local-only commands, forward the result text to the client
487499
if (
488500
isLocalOnlyCommand &&
@@ -825,6 +837,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
825837
: {};
826838
const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
827839

840+
// Configure structured output via SDK's native outputFormat
841+
const outputFormat =
842+
meta?.jsonSchema && this.options?.onStructuredOutput
843+
? { type: "json_schema" as const, schema: meta.jsonSchema }
844+
: undefined;
845+
828846
this.logger.info(isResume ? "Resuming session" : "Creating new session", {
829847
sessionId,
830848
taskId,
@@ -854,6 +872,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
854872
...(meta?.additionalRoots ?? []),
855873
],
856874
disableBuiltInTools: meta?.disableBuiltInTools,
875+
outputFormat,
857876
settingsManager,
858877
onModeChange: this.createOnModeChange(),
859878
onProcessSpawned: this.options?.onProcessSpawned,

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type {
66
CanUseTool,
77
McpServerConfig,
88
Options,
9+
OutputFormat,
910
SpawnedProcess,
1011
SpawnOptions,
1112
} from "@anthropic-ai/claude-agent-sdk";
@@ -42,6 +43,7 @@ export interface BuildOptionsParams {
4243
forkSession?: boolean;
4344
additionalDirectories?: string[];
4445
disableBuiltInTools?: boolean;
46+
outputFormat?: OutputFormat;
4547
settingsManager: SettingsManager;
4648
onModeChange?: OnModeChange;
4749
onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
@@ -268,6 +270,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
268270
params.settingsManager,
269271
params.logger,
270272
),
273+
outputFormat: params.outputFormat,
271274
abortController: getAbortController(
272275
params.userProvidedOptions?.abortController,
273276
),

packages/agent/src/adapters/claude/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export type NewSessionMeta = {
110110
allowedDomains?: string[];
111111
/** Model ID to use for this session (e.g. "claude-sonnet-4-6") */
112112
model?: string;
113+
jsonSchema?: Record<string, unknown> | null;
113114
claudeCode?: {
114115
options?: Options;
115116
};

packages/agent/src/agent.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export class Agent {
122122
deviceType: "local",
123123
logger: this.logger,
124124
processCallbacks: options.processCallbacks,
125+
onStructuredOutput: options.onStructuredOutput,
125126
allowedModelIds,
126127
codexOptions:
127128
options.adapter === "codex" && gatewayConfig

packages/agent/src/posthog-api.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,20 @@ export class PostHogAPIClient {
158158
);
159159
}
160160

161+
async setTaskRunOutput(
162+
taskId: string,
163+
runId: string,
164+
output: Record<string, unknown>,
165+
): Promise<TaskRun> {
166+
return this.apiRequest(
167+
`/api/projects/${this.getTeamId()}/tasks/${taskId}/runs/${runId}/set_output/`,
168+
{
169+
method: "PATCH",
170+
body: JSON.stringify(output),
171+
},
172+
);
173+
}
174+
161175
async appendTaskRunLog(
162176
taskId: string,
163177
runId: string,

packages/agent/src/server/agent-server.ts

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,15 @@ export class AgentServer {
665665
taskId: payload.task_id,
666666
deviceType: deviceInfo.type,
667667
logWriter,
668+
onStructuredOutput: async (output) => {
669+
await this.posthogAPI.setTaskRunOutput(
670+
payload.task_id,
671+
payload.run_id,
672+
{
673+
output,
674+
},
675+
);
676+
},
668677
});
669678

670679
// Tap both streams to broadcast all ACP messages via SSE (mimics local transport)
@@ -700,18 +709,25 @@ export class AgentServer {
700709
clientCapabilities: {},
701710
});
702711

703-
let preTaskRun: TaskRun | null = null;
704-
try {
705-
preTaskRun = await this.posthogAPI.getTaskRun(
706-
payload.task_id,
707-
payload.run_id,
708-
);
709-
} catch {
710-
this.logger.warn("Failed to fetch task run for session context", {
711-
taskId: payload.task_id,
712-
runId: payload.run_id,
713-
});
714-
}
712+
const [preTaskRun, preTask] = await Promise.all([
713+
this.posthogAPI
714+
.getTaskRun(payload.task_id, payload.run_id)
715+
.catch((err) => {
716+
this.logger.warn("Failed to fetch task run for session context", {
717+
taskId: payload.task_id,
718+
runId: payload.run_id,
719+
error: err,
720+
});
721+
return null;
722+
}),
723+
this.posthogAPI.getTask(payload.task_id).catch((err) => {
724+
this.logger.warn("Failed to fetch task for session context", {
725+
taskId: payload.task_id,
726+
error: err,
727+
});
728+
return null;
729+
}),
730+
]);
715731

716732
const prUrl =
717733
typeof (preTaskRun?.state as Record<string, unknown>)
@@ -732,6 +748,7 @@ export class AgentServer {
732748
taskRunId: payload.run_id,
733749
systemPrompt: this.buildSessionSystemPrompt(prUrl),
734750
allowedDomains: this.config.allowedDomains,
751+
jsonSchema: preTask?.json_schema ?? null,
735752
...(this.config.claudeCode?.plugins?.length && {
736753
claudeCode: {
737754
options: {

0 commit comments

Comments
 (0)