Skip to content
Draft
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
14 changes: 14 additions & 0 deletions .changeset/output-format.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
"a2a-claude": minor
"@a2a-wrapper/core": patch
---

Add `claude.outputFormat` to request structured JSON output from the Claude
session. Maps 1:1 onto the Claude Agent SDK's `Options.outputFormat`
(`{ type: "json_schema", schema }`) and is validated at startup. When set, the
SDK's `structured_output` is published as an `application/json` data part on the
`response` artifact, alongside the existing text part — text-only clients are
unaffected, and behaviour is unchanged when `outputFormat` is omitted.

The core change adds an optional, backward-compatible `structuredData` parameter
to `publishFinalArtifact` / `publishLastChunkMarker`.
12 changes: 11 additions & 1 deletion a2a-claude/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ Fields map 1:1 onto `@anthropic-ai/claude-agent-sdk` `Options` (source of truth:
| `fallbackModel` | `string` | Fallback model when the primary is overloaded/unavailable. |
| `effort` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | Reasoning effort level. Overridable with the `CLAUDE_EFFORT` env var. SDK default when omitted. |
| `thinking` | `{ type: "adaptive" }` \| `{ type: "enabled", budgetTokens?, display? }` \| `{ type: "disabled" }` | Extended thinking behavior. SDK default when omitted. |
| `outputFormat` | `{ type: "json_schema", schema: object }` | Request structured JSON output — see **Structured output** below. SDK default (freeform text) when omitted. |
| `permissionMode` | `"acceptEdits" \| "dontAsk" \| "plan" \| "bypassPermissions"` | Permission mode. `"default"` and `"auto"` are rejected — see **Permission modes** below. Default: `"acceptEdits"`. |
| `allowedTools` | `string[]` | Tools auto-allowed without prompting. |
| `disallowedTools` | `string[]` | Tools removed from the model's context entirely. |
Expand Down Expand Up @@ -134,6 +135,16 @@ Two more things worth knowing:
- `{ "type": "disabled" }` means no `thinking` sideband events can ever fire, regardless of `features.emitThinkingEvents`.
- Both values are validated at startup. An unsupported effort level or a malformed `thinking` object fails `initialize()` with a message naming the allowed values.

### Structured output

- **`claude.outputFormat`** (object, optional) — Request structured JSON output.
Maps 1:1 onto the Claude Agent SDK's `Options.outputFormat`. Shape:
`{ "type": "json_schema", "schema": { ... } }`, where `schema` is a JSON
Schema the model's output is constrained to match. When set, the parsed
object is published as an `application/json` **data part** on the `response`
artifact, alongside the usual text part (the text part is still emitted, so
text-only clients are unaffected). Omit for freeform text (the SDK default).

### Full config reference

> `agentCard.protocolVersion` below is optional and kept only for backward
Expand Down Expand Up @@ -472,7 +483,6 @@ The following are explicitly out of scope for this release and tracked as future
- hooks configuration
- native Claude subagents (`agents` option)
- skills
- structured outputs (`outputFormat`)
- richer usage/cost telemetry
- session forking
- `canUseTool` policy engine
Expand Down
23 changes: 23 additions & 0 deletions a2a-claude/schemas/agent-config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,10 @@
"description": "Model (e.g. \"claude-sonnet-5\"). Supports ${CLAUDE_MODEL}. SDK default when omitted.",
"type": "string"
},
"outputFormat": {
"$ref": "#/definitions/ClaudeOutputFormat",
"description": "Structured JSON output. When set, the model is constrained to return JSON matching `schema`; the parsed object is published as a data part on the `response` artifact alongside the text. SDK default (freeform text) when omitted. Maps 1:1 onto SDK `Options.outputFormat`."
},
"permissionMode": {
"$ref": "#/definitions/ClaudePermissionMode",
"default": "acceptEdits",
Expand Down Expand Up @@ -301,6 +305,25 @@
],
"type": "object"
},
"ClaudeOutputFormat": {
"additionalProperties": false,
"description": "Structured output configuration. Maps 1:1 onto SDK `Options.outputFormat`. `type: \"json_schema\"` is the only value the SDK supports; `schema` is a JSON Schema object the model's output is constrained to match.",
"properties": {
"schema": {
"additionalProperties": {},
"type": "object"
},
"type": {
"const": "json_schema",
"type": "string"
}
},
"required": [
"type",
"schema"
],
"type": "object"
},
"ClaudePermissionMode": {
"enum": [
"acceptEdits",
Expand Down
11 changes: 11 additions & 0 deletions a2a-claude/src/claude/__tests__/client-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ describe("buildQueryOptions", () => {
expect(opts.effort).toBeUndefined();
});

it("passes outputFormat through to the SDK options", () => {
const schema = { type: "object", properties: { answer: { type: "string" } } };
const opts = buildQueryOptions(cfg({ outputFormat: { type: "json_schema", schema } }), {});
expect(opts.outputFormat).toEqual({ type: "json_schema", schema });
});

it("omits outputFormat when unset", () => {
const opts = buildQueryOptions(cfg(), {});
expect(opts.outputFormat).toBeUndefined();
});

// The SDK leaves `display` at "omitted", which yields thinking blocks with an
// empty string — nothing for the sideband thinking events to carry.
describe("thinking display defaulting", () => {
Expand Down
33 changes: 33 additions & 0 deletions a2a-claude/src/claude/__tests__/executor-effort-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,36 @@ describe("thinking validation", () => {
);
});
});

describe("outputFormat validation", () => {
const validSchema = { type: "object", properties: { answer: { type: "string" } } };

it("accepts a valid json_schema outputFormat", async () => {
config.claude.outputFormat = { type: "json_schema", schema: validSchema };
await expect(executor().initialize()).resolves.toBeUndefined();
});

it("accepts a config with no outputFormat set", async () => {
await expect(executor().initialize()).resolves.toBeUndefined();
});

it("rejects a non-object outputFormat", async () => {
config.claude.outputFormat = "json" as never;
await expect(executor().initialize()).rejects.toThrow(/claude\.outputFormat/);
});

it("rejects an unsupported outputFormat type", async () => {
config.claude.outputFormat = { type: "text", schema: validSchema } as never;
await expect(executor().initialize()).rejects.toThrow(/json_schema/);
});

it("rejects an outputFormat missing a schema object", async () => {
config.claude.outputFormat = { type: "json_schema" } as never;
await expect(executor().initialize()).rejects.toThrow(/schema/);
});

it("rejects an outputFormat whose schema is an array", async () => {
config.claude.outputFormat = { type: "json_schema", schema: [] as never } as never;
await expect(executor().initialize()).rejects.toThrow(/schema/);
});
});
16 changes: 15 additions & 1 deletion a2a-claude/src/claude/__tests__/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { ClaudeExecutor } from "../executor.js";
import { SessionManager } from "../session-manager.js";
import { FakeClaudeClient, happyTurn } from "./fake-client.js";
import { FakeClaudeClient, happyTurn, structuredTurn } from "./fake-client.js";
import { DEFAULTS } from "../../config/defaults.js";
import type { AgentConfig } from "../../config/types.js";
import type { RequestContext, ExecutionEventBus } from "@a2a-js/sdk/server";
Expand Down Expand Up @@ -97,6 +97,20 @@ describe("ClaudeExecutor.execute", () => {
expect(client.calls[0].options.resume).toBeUndefined();
});

it("publishes structured_output as a JSON data part on the response artifact", async () => {
const client = new FakeClaudeClient([structuredTurn("s", "{\"answer\":\"42\"}", { answer: "42" })]);
const ex = new ClaudeExecutor(config, () => client);
const { bus, events } = makeBus();

await ex.execute(makeCtx("t1", "ctx-1"), bus);

const artifact = events.find((e) => e.kind === "artifactUpdate") as any;
const parts = artifact.data.artifact.parts;
const dataPart = parts.find((p: any) => p.content?.$case === "data");
expect(dataPart).toBeDefined();
expect(dataPart.content.value).toEqual({ answer: "42" });
});

it("threads the captured session id into the next turn's resume", async () => {
const client = new FakeClaudeClient([happyTurn("sess-1", "one"), happyTurn("sess-1", "two")]);
const ex = new ClaudeExecutor(config, () => client);
Expand Down
19 changes: 19 additions & 0 deletions a2a-claude/src/claude/__tests__/fake-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,3 +99,22 @@ export function happyTurn(sessionId: string, text: string): FakeTurnScript {
],
};
}

/** Happy-path turn whose success result also carries structured_output. */
export function structuredTurn(
sessionId: string,
text: string,
structuredOutput: unknown,
): FakeTurnScript {
return {
messages: [
{ type: "system", subtype: "init", session_id: sessionId, model: "claude-test" },
{ type: "assistant", parent_tool_use_id: null, message: { content: [{ type: "text", text }] } },
{
type: "result", subtype: "success", result: text, structured_output: structuredOutput,
usage: { input_tokens: 1, output_tokens: 1 }, total_cost_usd: 0.01, num_turns: 1,
session_id: sessionId,
},
],
};
}
2 changes: 2 additions & 0 deletions a2a-claude/src/claude/client-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface QueryOptionsLike {
fallbackModel?: string;
effort?: string;
thinking?: { type: string; budgetTokens?: number; display?: string };
outputFormat?: { type: string; schema: Record<string, unknown> };
permissionMode?: string;
allowedTools?: string[];
disallowedTools?: string[];
Expand Down Expand Up @@ -125,6 +126,7 @@ export function buildQueryOptions(
fallbackModel: claude.fallbackModel || undefined,
effort: claude.effort,
thinking: resolveThinking(claude.thinking, config.features.emitThinkingEvents),
outputFormat: claude.outputFormat,
permissionMode: claude.permissionMode ?? "acceptEdits",
allowedTools: claude.allowedTools && claude.allowedTools.length > 0 ? claude.allowedTools : undefined,
disallowedTools: claude.disallowedTools && claude.disallowedTools.length > 0 ? claude.disallowedTools : undefined,
Expand Down
35 changes: 33 additions & 2 deletions a2a-claude/src/claude/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const log = logger.child("executor");
const VALID_PERMISSION_MODES = new Set(["acceptEdits", "dontAsk", "plan", "bypassPermissions"]);
const VALID_EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
const VALID_THINKING_TYPES = new Set(["adaptive", "enabled", "disabled"]);
const VALID_OUTPUT_FORMAT_TYPES = new Set(["json_schema"]);
const VALID_RATE_LIMIT_TASK_STATES = new Set(["input-required", "failed", "auth-required"]);

export class ClaudeExecutor implements AgentExecutor {
Expand Down Expand Up @@ -275,6 +276,7 @@ export class ClaudeExecutor implements AgentExecutor {
// which must still be able to see a rate limit we already detected.
let rateLimited: RateLimitSnapshot | null = null;
let finalText = "";
let structuredOutput: unknown;
let streamArtifactStarted = false;
const streamArtifactId = `response-${taskId}`;
const streaming = this.config.features.streamArtifactChunks === true;
Expand Down Expand Up @@ -349,6 +351,7 @@ export class ClaudeExecutor implements AgentExecutor {
if (msg.type === "result") {
if (msg.subtype === "success" && typeof msg.result === "string") {
finalText = msg.result;
structuredOutput = msg.structured_output;
} else if (msg.subtype !== "success") {
const reasons: Record<string, string> = {
error_max_turns: "Turn limit reached (max_turns).",
Expand All @@ -375,9 +378,9 @@ export class ClaudeExecutor implements AgentExecutor {
}

if (streaming && streamArtifactStarted) {
publishLastChunkMarker(bus, taskId, contextId, streamArtifactId, finalText);
publishLastChunkMarker(bus, taskId, contextId, streamArtifactId, finalText, structuredOutput);
} else {
publishFinalArtifact(bus, taskId, contextId, finalText);
publishFinalArtifact(bus, taskId, contextId, finalText, structuredOutput);
}

publishStatus(bus, taskId, contextId, "completed", undefined, true);
Expand Down Expand Up @@ -585,6 +588,34 @@ export class ClaudeExecutor implements AgentExecutor {
}
}

// Config can arrive from an untyped JSON file, so outputFormat's shape is
// checked structurally rather than trusted from the type declaration.
const outputFormat = claude.outputFormat as
| { type?: unknown; schema?: unknown }
| undefined;
if (outputFormat !== undefined) {
if (
typeof outputFormat !== "object" ||
outputFormat === null ||
Array.isArray(outputFormat) ||
typeof outputFormat.type !== "string" ||
!VALID_OUTPUT_FORMAT_TYPES.has(outputFormat.type)
) {
throw new Error(
'claude.outputFormat must be an object whose "type" is "json_schema".',
);
}
if (
typeof outputFormat.schema !== "object" ||
outputFormat.schema === null ||
Array.isArray(outputFormat.schema)
) {
throw new Error(
"claude.outputFormat.schema must be a JSON Schema object.",
);
}
}

if (claude.settingSources && claude.settingSources.length > 0) {
log.warn("settingSources is non-empty — host/project settings files will be loaded.", {
settingSources: claude.settingSources,
Expand Down
17 changes: 17 additions & 0 deletions a2a-claude/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ export type ClaudeThinkingConfig =
| { type: "enabled"; budgetTokens?: number; display?: "summarized" | "omitted" }
| { type: "disabled" };

/**
* Structured output configuration. Maps 1:1 onto SDK `Options.outputFormat`.
* `type: "json_schema"` is the only value the SDK supports; `schema` is a JSON
* Schema object the model's output is constrained to match.
*/
export type ClaudeOutputFormat = {
type: "json_schema";
schema: Record<string, unknown>;
};

/**
* Claude Agent SDK connection and execution settings.
* Fields map 1:1 onto @anthropic-ai/claude-agent-sdk Options (see spec §3.1).
Expand All @@ -86,6 +96,13 @@ export interface ClaudeConfig {
effort?: ClaudeEffortLevel;
/** Extended thinking behavior. SDK default when omitted. */
thinking?: ClaudeThinkingConfig;
/**
* Structured JSON output. When set, the model is constrained to return JSON
* matching `schema`; the parsed object is published as a data part on the
* `response` artifact alongside the text. SDK default (freeform text) when
* omitted. Maps 1:1 onto SDK `Options.outputFormat`.
*/
outputFormat?: ClaudeOutputFormat;
/**
* Permission mode. "default" and "auto" are rejected — they require an
* interactive approver / classifier, incompatible with headless A2A.
Expand Down
31 changes: 31 additions & 0 deletions packages/core/src/__tests__/events/event-publisher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,37 @@ describe("Property 13: Event publisher structure correctness", () => {
);
});

it("publishFinalArtifact appends a data part when structuredData is an object", () => {
const bus = createMockBus();
const data = { answer: "42", ok: true };
publishFinalArtifact(bus as any, "t1", "c1", "text body", data);

const parts = bus.events[0].data.artifact.parts;
expect(parts).toHaveLength(2);
expect(partText(parts[0])).toBe("text body");
expect(partData(parts[1])).toEqual(data);
});

it("publishFinalArtifact stays text-only when structuredData is omitted", () => {
const bus = createMockBus();
publishFinalArtifact(bus as any, "t1", "c1", "text body");

const parts = bus.events[0].data.artifact.parts;
expect(parts).toHaveLength(1);
expect(partText(parts[0])).toBe("text body");
});

it("publishLastChunkMarker appends a data part when structuredData is an object", () => {
const bus = createMockBus();
const data = { answer: "42" };
publishLastChunkMarker(bus as any, "t1", "c1", "art-1", "full text", data);

const parts = bus.events[0].data.artifact.parts;
expect(parts).toHaveLength(2);
expect(partText(parts[0])).toBe("full text");
expect(partData(parts[1])).toEqual(data);
});

it("publishStreamingChunk produces event with append: true and lastChunk: false", () => {
fc.assert(
fc.property(arbId, arbId, arbId, arbText, (taskId, contextId, artifactId, chunkText) => {
Expand Down
Loading