Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/code/src/main/services/agent/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const startSessionInput = z.object({
customInstructions: z.string().max(2000).optional(),
effort: effortLevelSchema.optional(),
model: z.string().optional(),
jsonSchema: z.record(z.string(), z.unknown()).nullish(),
});

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

export type ReconnectSessionInput = z.infer<typeof reconnectSessionInput>;
Expand Down
14 changes: 14 additions & 0 deletions apps/code/src/main/services/agent/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,8 @@ interface SessionConfig {
effort?: EffortLevel;
/** Model to use for the session (e.g. "claude-sonnet-4-6") */
model?: string;
/** JSON Schema for structured task output — when set, the agent gets a create_output tool */
jsonSchema?: Record<string, unknown> | null;
}

interface ManagedSession {
Expand Down Expand Up @@ -561,6 +563,7 @@ When creating pull requests, add the following footer at the end of the PR descr
customInstructions,
effort,
model,
jsonSchema,
} = config;

// Preview config doesn't need a real repo — use a temp directory
Expand Down Expand Up @@ -620,6 +623,14 @@ When creating pull requests, add the following footer at the end of the PR descr
codexBinaryPath: adapter === "codex" ? getCodexBinaryPath() : undefined,
model,
instructions: adapter === "codex" ? systemPrompt.append : undefined,
onStructuredOutput: jsonSchema
? async (output) => {
const posthogAPI = agent.getPosthogAPI();
if (posthogAPI) {
await posthogAPI.updateTaskRun(taskId, taskRunId, { output });
}
}
: undefined,
processCallbacks: {
onProcessSpawned: (info) => {
this.processTracking.register(
Expand Down Expand Up @@ -741,6 +752,7 @@ When creating pull requests, add the following footer at the end of the PR descr
systemPrompt,
...(permissionMode && { permissionMode }),
...(model != null && { model }),
...(jsonSchema && { jsonSchema }),
claudeCode: {
options: claudeCodeOptions,
},
Expand All @@ -763,6 +775,7 @@ When creating pull requests, add the following footer at the end of the PR descr
systemPrompt,
...(permissionMode && { permissionMode }),
...(model != null && { model }),
...(jsonSchema && { jsonSchema }),
claudeCode: {
options: claudeCodeOptions,
},
Expand Down Expand Up @@ -1453,6 +1466,7 @@ For git operations while detached:
"customInstructions" in params ? params.customInstructions : undefined,
effort: "effort" in params ? params.effort : undefined,
model: "model" in params ? params.model : undefined,
jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined,
};
}

Expand Down
1 change: 1 addition & 0 deletions packages/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "0.16.1",
"ajv": "^8.17.1",
"@anthropic-ai/claude-agent-sdk": "0.2.76",
"@anthropic-ai/sdk": "^0.78.0",
"@hono/node-server": "^1.19.9",
Expand Down
7 changes: 6 additions & 1 deletion packages/agent/src/adapters/acp-connection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type AcpConnectionConfig = {
processCallbacks?: ProcessSpawnedCallback;
codexOptions?: CodexProcessOptions;
allowedModelIds?: Set<string>;
/** Callback invoked when the agent calls the create_output tool for structured output */
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
};

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

let agent: ClaudeAcpAgent | null = null;
const agentConnection = new AgentSideConnection((client) => {
agent = new ClaudeAcpAgent(client, config.processCallbacks);
agent = new ClaudeAcpAgent(client, {
...config.processCallbacks,
onStructuredOutput: config.onStructuredOutput,
});
logger.info(`Created ${agent.adapterName} agent`);
return agent;
}, agentStream);
Expand Down
19 changes: 19 additions & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface ClaudeAcpAgentOptions {
onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
onProcessExited?: (pid: number) => void;
onMcpServersReady?: (serverNames: string[]) => void;
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
}

export class ClaudeAcpAgent extends BaseAcpAgent {
Expand Down Expand Up @@ -483,6 +484,17 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
const result = handleResultMessage(message);
if (result.error) throw result.error;

// Deliver structured output from SDK's native outputFormat
if (
message.subtype === "success" &&
message.structured_output != null &&
this.options?.onStructuredOutput
) {
await this.options.onStructuredOutput(
message.structured_output as Record<string, unknown>,
);
}

// For local-only commands, forward the result text to the client
if (
isLocalOnlyCommand &&
Expand Down Expand Up @@ -825,6 +837,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
: {};
const systemPrompt = buildSystemPrompt(meta?.systemPrompt);

// Configure structured output via SDK's native outputFormat
const outputFormat =
meta?.jsonSchema && this.options?.onStructuredOutput
? { type: "json_schema" as const, schema: meta.jsonSchema }
: undefined;

this.logger.info(isResume ? "Resuming session" : "Creating new session", {
sessionId,
taskId,
Expand Down Expand Up @@ -854,6 +872,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
...(meta?.additionalRoots ?? []),
],
disableBuiltInTools: meta?.disableBuiltInTools,
outputFormat,
settingsManager,
onModeChange: this.createOnModeChange(),
onProcessSpawned: this.options?.onProcessSpawned,
Expand Down
3 changes: 3 additions & 0 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
CanUseTool,
McpServerConfig,
Options,
OutputFormat,
SpawnedProcess,
SpawnOptions,
} from "@anthropic-ai/claude-agent-sdk";
Expand Down Expand Up @@ -42,6 +43,7 @@ export interface BuildOptionsParams {
forkSession?: boolean;
additionalDirectories?: string[];
disableBuiltInTools?: boolean;
outputFormat?: OutputFormat;
settingsManager: SettingsManager;
onModeChange?: OnModeChange;
onProcessSpawned?: (info: ProcessSpawnedInfo) => void;
Expand Down Expand Up @@ -268,6 +270,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options {
params.settingsManager,
params.logger,
),
outputFormat: params.outputFormat,
abortController: getAbortController(
params.userProvidedOptions?.abortController,
),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const OUTPUT_SERVER_NAME = "posthog_output";
export const OUTPUT_TOOL_NAME = "create_output";
export const OUTPUT_TOOL_FULL_NAME = `mcp__${OUTPUT_SERVER_NAME}__${OUTPUT_TOOL_NAME}`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import {
createSdkMcpServer,
type McpSdkServerConfigWithInstance,
tool,
} from "@anthropic-ai/claude-agent-sdk";
import Ajv from "ajv";
import * as z from "zod";
import type { Logger } from "../../../utils/logger";
import { OUTPUT_SERVER_NAME, OUTPUT_TOOL_NAME } from "./constants";

export {
OUTPUT_SERVER_NAME,
OUTPUT_TOOL_FULL_NAME,
OUTPUT_TOOL_NAME,
} from "./constants";

export interface CreateOutputServerOptions {
jsonSchema: Record<string, unknown>;
onOutput: (output: Record<string, unknown>) => Promise<void>;
logger: Logger;
}

export function createOutputMcpServer(
options: CreateOutputServerOptions,
): McpSdkServerConfigWithInstance {
const { jsonSchema, onOutput, logger } = options;

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(jsonSchema);
const zodType: z.ZodType = z.fromJSONSchema(jsonSchema); // Validate that the JSON schema can be converted to Zod schema, will throw if invalid
if (!(zodType instanceof z.ZodObject)) {
throw new Error(
"Only JSON schemas that correspond to Zod objects are supported",
);
}
const outputTool = tool(
OUTPUT_TOOL_NAME,
"Submit the structured output for this task. Call this tool with the required fields to deliver your final result. The output must conform to the task's JSON schema.",
zodType.shape,
async (args) => {
const valid = validate(args);
if (!valid) {
const errors = validate.errors
?.map((e) => `${e.instancePath || "/"}: ${e.message}`)
.join("; ");
logger.warn("Structured output validation failed", { errors });
return {
content: [
{
type: "text" as const,
text: `Validation failed: ${errors}. Please fix the output and try again.`,
},
],
isError: true,
};
}

try {
await onOutput(args as Record<string, unknown>);
logger.info("Structured output persisted successfully");
return {
content: [
{
type: "text" as const,
text: "Output submitted successfully.",
},
],
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error("Failed to persist structured output", { error: message });
return {
content: [
{
type: "text" as const,
text: `Failed to submit output: ${message}`,
},
],
isError: true,
};
}
},
);

return createSdkMcpServer({
name: OUTPUT_SERVER_NAME,
tools: [outputTool],
});
}
1 change: 1 addition & 0 deletions packages/agent/src/adapters/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export type NewSessionMeta = {
allowedDomains?: string[];
/** Model ID to use for this session (e.g. "claude-sonnet-4-6") */
model?: string;
jsonSchema?: Record<string, unknown> | null;
claudeCode?: {
options?: Options;
};
Expand Down
1 change: 1 addition & 0 deletions packages/agent/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export class Agent {
deviceType: "local",
logger: this.logger,
processCallbacks: options.processCallbacks,
onStructuredOutput: options.onStructuredOutput,
allowedModelIds,
codexOptions:
options.adapter === "codex" && gatewayConfig
Expand Down
14 changes: 14 additions & 0 deletions packages/agent/src/posthog-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,20 @@ export class PostHogAPIClient {
);
}

async setTaskRunOutput(
taskId: string,
runId: string,
output: Record<string, unknown>,
): Promise<TaskRun> {
return this.apiRequest(
`/api/projects/${this.getTeamId()}/tasks/${taskId}/runs/${runId}/set_output/`,
{
method: "PATCH",
body: JSON.stringify(output),
},
);
}

async appendTaskRunLog(
taskId: string,
runId: string,
Expand Down
41 changes: 29 additions & 12 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -665,6 +665,15 @@ export class AgentServer {
taskId: payload.task_id,
deviceType: deviceInfo.type,
logWriter,
onStructuredOutput: async (output) => {
await this.posthogAPI.setTaskRunOutput(
payload.task_id,
payload.run_id,
{
output,
},
);
},
});

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

let preTaskRun: TaskRun | null = null;
try {
preTaskRun = await this.posthogAPI.getTaskRun(
payload.task_id,
payload.run_id,
);
} catch {
this.logger.warn("Failed to fetch task run for session context", {
taskId: payload.task_id,
runId: payload.run_id,
});
}
const [preTaskRun, preTask] = await Promise.all([
this.posthogAPI
.getTaskRun(payload.task_id, payload.run_id)
.catch((err) => {
this.logger.warn("Failed to fetch task run for session context", {
taskId: payload.task_id,
runId: payload.run_id,
error: err,
});
return null;
}),
this.posthogAPI.getTask(payload.task_id).catch((err) => {
this.logger.warn("Failed to fetch task for session context", {
taskId: payload.task_id,
error: err,
});
return null;
}),
]);

const prUrl =
typeof (preTaskRun?.state as Record<string, unknown>)
Expand All @@ -732,6 +748,7 @@ export class AgentServer {
taskRunId: payload.run_id,
systemPrompt: this.buildSessionSystemPrompt(prUrl),
allowedDomains: this.config.allowedDomains,
jsonSchema: preTask?.json_schema ?? null,
...(this.config.claudeCode?.plugins?.length && {
claudeCode: {
options: {
Expand Down
2 changes: 2 additions & 0 deletions packages/agent/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,8 @@ export interface TaskExecutionOptions {
codexBinaryPath?: string;
instructions?: string;
processCallbacks?: ProcessSpawnedCallback;
/** Callback invoked when the agent calls the create_output tool for structured output */
onStructuredOutput?: (output: Record<string, unknown>) => Promise<void>;
}

export type LogLevel = "debug" | "info" | "warn" | "error";
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading