diff --git a/USAGE.md b/USAGE.md index 73bbd5fe..b11126cd 100644 --- a/USAGE.md +++ b/USAGE.md @@ -113,6 +113,29 @@ cd rust ./target/debug/claw --output-format json prompt "status" ``` +### Structured output with `--json-schema` + +Pass an inline JSON Schema object to require the model to finish with a validated `StructuredOutput` tool call: + +```bash +./target/debug/claw \ + --output-format json \ + --json-schema '{"type":"object","properties":{"summary":{"type":"string"}},"required":["summary"]}' \ + prompt "summarize this repository" +``` + +JSON mode includes both the JSON-stringified `message` and the independent `structured_output` value. Text mode prints the compact JSON value directly. Schema `format` keywords are accepted as annotations, matching Claude Code's `validateFormats: false` behavior. Invalid JSON, non-object values, and invalid JSON Schemas fail before credentials or provider startup. + +The runtime retries schema mismatches up to five attempts by default. Set `MAX_STRUCTURED_OUTPUT_RETRIES` to a positive integer to change the cap. Background model sessions preserve and forward the schema as well: + +```bash +./target/debug/claw --bg \ + --json-schema '{"type":"object","properties":{"result":{"type":"string"}},"required":["result"]}' \ + "analyze the parser regression" +``` + +`--json-schema` cannot be combined with `--bg --exec`, because shell-command jobs do not produce model tool calls. + ### Inspect worker state The `claw state` command reads `.claw/worker-state.json`, which is written by the interactive REPL or a one-shot prompt when a worker executes a task. This file contains the worker ID, session reference, model, and permission mode. diff --git a/docs/CLAUDE_CODE_2_1_207_MIGRATION.md b/docs/CLAUDE_CODE_2_1_207_MIGRATION.md index 90d93eb9..f4502521 100644 --- a/docs/CLAUDE_CODE_2_1_207_MIGRATION.md +++ b/docs/CLAUDE_CODE_2_1_207_MIGRATION.md @@ -21,6 +21,7 @@ This document records implemented behavior separately from compatibility surface | 2.1.206 | Per-server MCP `request_timeout_ms` is honored, with camelCase and legacy timeout aliases | `McpStdioServerConfig.tool_call_timeout_ms`, bootstrap/manager path, config tests | | 2.1.207 | Shell-form hooks and MCP `headersHelper` reject `${user_config.*}` interpolation | partial config validation records unsafe entries without executing them | | 2.1.207 | Safe `cd && >/dev/null` is no longer classified as unrestricted shell access | bash permission classifier regression tests | +| 2.1.207 | `--json-schema` accepts inline object schemas, installs the requested `StructuredOutput.input_schema`, retries validation failures, ignores `format` semantics, and emits `structured_output` | runtime retry/format tests, mock-provider request capture, CLI foreground/background contract tests | The former v2.1.201 host-only surfaces also moved forward: @@ -42,7 +43,6 @@ The following upstream areas remain unproven or incomplete and must stay on the - full background daemon upgrade/recovery, cold-resume, cross-agent TaskStop/TaskOutput, and remote-control synchronization; - additional working directories in MCP `roots/list` plus `roots/list_changed` notifications; -- `--json-schema` request/output enforcement, including accepted JSON Schema `format` keywords and invalid-schema failures; - complete auto-mode classifier behavior, provider entitlement/default handling, transcript tamper protection, and unresolved-variable `rm -rf` prompting; - Bedrock, Vertex AI, Foundry, Claude Platform on AWS, gateway login, SSO refresh, and provider-specific model picker behavior; - updater/installer streaming, launcher/symlink ownership detection, retry behavior, and Homebrew channel diagnostics; diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 16d2e42e..4e08fcea 100755 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -15,7 +15,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -29,6 +31,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anes" version = "0.1.6" @@ -304,6 +312,21 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "2.11.0" @@ -332,6 +355,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "bstr" version = "1.12.1" @@ -348,6 +377,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytes" version = "1.11.1" @@ -706,6 +741,12 @@ dependencies = [ "syn", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "deranged" version = "0.3.11" @@ -779,6 +820,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "endian-type" version = "0.1.2" @@ -798,7 +848,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -819,6 +869,17 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -852,12 +913,29 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -867,6 +945,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + [[package]] name = "futures" version = "0.3.32" @@ -1071,6 +1159,17 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashlink" version = "0.9.1" @@ -1389,7 +1488,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1425,6 +1524,48 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "281c43ff06dcb331e9356d30e38853d559ce3d0a3f693e0b0e102667dec14fb1" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee0b351864e7ffbc5db9273daf7fa1b4d5177b0946713d667ca571b83c0b4045" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.183" @@ -1505,6 +1646,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -1563,12 +1710,81 @@ dependencies = [ "libc", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1624,6 +1840,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1901,7 +2123,7 @@ dependencies = [ "once_cell", "socket2 0.6.3", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2017,6 +2239,43 @@ dependencies = [ "bitflags", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "referencing" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "348e860aeb0b7bd035778fd11dd9cd5290d32e4aed3b8f2274a00287a9fd362b" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.17.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -2108,6 +2367,7 @@ name = "runtime" version = "0.1.3" dependencies = [ "glob", + "jsonschema", "plugins", "regex", "serde", @@ -2162,7 +2422,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2578,7 +2838,7 @@ dependencies = [ "getrandom 0.3.4", "once_cell", "rustix 1.1.4", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2958,6 +3218,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -3006,6 +3272,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "vcpkg" version = "0.2.15" @@ -3018,6 +3294,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -3171,7 +3453,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/rust/crates/commands/src/lib.rs b/rust/crates/commands/src/lib.rs index 5caa244f..f098f6cd 100644 --- a/rust/crates/commands/src/lib.rs +++ b/rust/crates/commands/src/lib.rs @@ -7005,6 +7005,7 @@ mod tests { agent: None, permission_mode: None, reasoning_effort: None, + json_schema: None, }) .expect("old job"); supervisor @@ -7021,6 +7022,7 @@ mod tests { agent: Some("Explore".to_string()), permission_mode: Some("manual".to_string()), reasoning_effort: Some("medium".to_string()), + json_schema: None, }) .expect("new job"); supervisor diff --git a/rust/crates/mock-anthropic-service/src/lib.rs b/rust/crates/mock-anthropic-service/src/lib.rs index c2b406f7..d491611a 100644 --- a/rust/crates/mock-anthropic-service/src/lib.rs +++ b/rust/crates/mock-anthropic-service/src/lib.rs @@ -100,6 +100,7 @@ enum Scenario { PluginToolRoundtrip, AutoCompactTriggered, TokenCostReporting, + StructuredOutputRetry, } impl Scenario { @@ -117,6 +118,7 @@ impl Scenario { "plugin_tool_roundtrip" => Some(Self::PluginToolRoundtrip), "auto_compact_triggered" => Some(Self::AutoCompactTriggered), "token_cost_reporting" => Some(Self::TokenCostReporting), + "structured_output_retry" => Some(Self::StructuredOutputRetry), _ => None, } } @@ -135,6 +137,7 @@ impl Scenario { Self::PluginToolRoundtrip => "plugin_tool_roundtrip", Self::AutoCompactTriggered => "auto_compact_triggered", Self::TokenCostReporting => "token_cost_reporting", + Self::StructuredOutputRetry => "structured_output_retry", } } } @@ -464,6 +467,19 @@ fn build_stream_body(request: &MessageRequest, scenario: Scenario) -> String { Scenario::TokenCostReporting => { final_text_sse_with_usage("token cost reporting parity complete.", 1_000, 500) } + Scenario::StructuredOutputRetry => match latest_tool_result(request) { + Some((_tool_output, true)) => tool_use_sse( + "toolu_structured_valid", + "StructuredOutput", + &[r#"{"name":"Ada"}"#], + ), + Some((_tool_output, false)) => final_text_sse("structured output was already accepted"), + None => tool_use_sse( + "toolu_structured_invalid", + "StructuredOutput", + &[r#"{"name":42}"#], + ), + }, } } @@ -634,6 +650,24 @@ fn build_message_response(request: &MessageRequest, scenario: Scenario) -> Messa 1_000, 500, ), + Scenario::StructuredOutputRetry => match latest_tool_result(request) { + Some((_tool_output, true)) => tool_message_response( + "msg_structured_valid", + "toolu_structured_valid", + "StructuredOutput", + json!({"name": "Ada"}), + ), + Some((_tool_output, false)) => text_message_response( + "msg_structured_already_accepted", + "structured output was already accepted", + ), + None => tool_message_response( + "msg_structured_invalid", + "toolu_structured_invalid", + "StructuredOutput", + json!({"name": 42}), + ), + }, } } @@ -651,6 +685,7 @@ fn request_id_for(scenario: Scenario) -> &'static str { Scenario::PluginToolRoundtrip => "req_plugin_tool_roundtrip", Scenario::AutoCompactTriggered => "req_auto_compact_triggered", Scenario::TokenCostReporting => "req_token_cost_reporting", + Scenario::StructuredOutputRetry => "req_structured_output_retry", } } diff --git a/rust/crates/runtime/Cargo.toml b/rust/crates/runtime/Cargo.toml index 38436133..47e0e97e 100644 --- a/rust/crates/runtime/Cargo.toml +++ b/rust/crates/runtime/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true [dependencies] sha2 = "0.10" glob = "0.3" +jsonschema = { version = "0.47", default-features = false } plugins = { path = "../plugins" } regex = "1" serde = { version = "1", features = ["derive"] } diff --git a/rust/crates/runtime/src/agent_supervisor.rs b/rust/crates/runtime/src/agent_supervisor.rs index d1c3cd72..19289121 100644 --- a/rust/crates/runtime/src/agent_supervisor.rs +++ b/rust/crates/runtime/src/agent_supervisor.rs @@ -70,6 +70,7 @@ pub struct AgentJobCreate { pub agent: Option, pub permission_mode: Option, pub reasoning_effort: Option, + pub json_schema: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -128,6 +129,8 @@ pub struct AgentJobRecord { #[serde(skip_serializing_if = "Option::is_none")] pub reasoning_effort: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub json_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub output_tail: Option, #[serde(skip_serializing_if = "Option::is_none")] pub exit_code: Option, @@ -268,6 +271,7 @@ impl AgentSupervisor { agent: request.agent, permission_mode: request.permission_mode, reasoning_effort: request.reasoning_effort, + json_schema: request.json_schema, output_tail: None, exit_code: None, stopped_at: None, @@ -634,6 +638,11 @@ mod tests { agent: None, permission_mode: Some("manual".to_string()), reasoning_effort: Some("medium".to_string()), + json_schema: Some(serde_json::json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + })), }) .expect("job"); @@ -647,6 +656,14 @@ mod tests { .expect("list"); assert_eq!(listed.len(), 1); assert_eq!(listed[0].name.as_deref(), Some("parser-fix")); + assert_eq!( + listed[0].json_schema.as_ref(), + Some(&serde_json::json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + })) + ); assert!(!listed[0].pinned); let pinned = supervisor.set_pinned(&job.id, true).expect("pin"); @@ -683,6 +700,7 @@ mod tests { agent: None, permission_mode: None, reasoning_effort: None, + json_schema: None, }) .expect("job"); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index 57a4d6ba..29dc6b2d 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -17,12 +17,95 @@ use crate::usage::{TokenUsage, UsageTracker}; const DEFAULT_AUTO_COMPACTION_INPUT_TOKENS_THRESHOLD: u32 = 100_000; const AUTO_COMPACTION_THRESHOLD_ENV_VAR: &str = "CLAUDE_CODE_AUTO_COMPACT_INPUT_TOKENS"; +pub const DEFAULT_MAX_STRUCTURED_OUTPUT_ATTEMPTS: usize = 5; +const MAX_STRUCTURED_OUTPUT_SCHEMA_NODES: usize = 100_000; +const MAX_STRUCTURED_OUTPUT_SCHEMA_DEPTH: usize = 10_000; +const STRUCTURED_OUTPUT_TOOL_NAME: &str = "StructuredOutput"; +const STRUCTURED_OUTPUT_INSTRUCTION: &str = "Use the StructuredOutput tool to return your final response in the requested structured format. You MUST call this tool exactly once at the end of your response."; /// Fully assembled request payload sent to the upstream model client. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ApiRequest { pub system_prompt: Vec, pub messages: Vec, + pub structured_output_schema: Option, +} + +struct StructuredOutputState { + schema: Value, + validator: jsonschema::Validator, + max_attempts: usize, +} + +impl StructuredOutputState { + fn new(schema: Value, max_attempts: usize) -> Result { + if !schema.is_object() { + return Err(RuntimeError::new( + "Invalid JSON schema: --json-schema must be a JSON object", + )); + } + validate_structured_output_schema_complexity(&schema)?; + let validator = jsonschema::options() + .should_validate_formats(false) + .build(&schema) + .map_err(|error| RuntimeError::new(format!("Invalid JSON schema: {error}")))?; + Ok(Self { + schema, + validator, + max_attempts: max_attempts.max(1), + }) + } + + fn validate(&self, value: &Value) -> Result<(), String> { + let errors = self + .validator + .iter_errors(value) + .map(|error| { + let path = error.instance_path().to_string(); + let path = if path.is_empty() { + "root" + } else { + path.as_str() + }; + format!("{path}: {error}") + }) + .collect::>(); + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(", ")) + } + } +} + +pub fn validate_structured_output_schema(schema: &Value) -> Result<(), RuntimeError> { + StructuredOutputState::new(schema.clone(), DEFAULT_MAX_STRUCTURED_OUTPUT_ATTEMPTS).map(|_| ()) +} + +fn validate_structured_output_schema_complexity(schema: &Value) -> Result<(), RuntimeError> { + let mut stack = vec![(schema, 0_usize)]; + let mut nodes = 0_usize; + while let Some((value, depth)) = stack.pop() { + nodes = nodes.saturating_add(1); + if nodes > MAX_STRUCTURED_OUTPUT_SCHEMA_NODES || depth > MAX_STRUCTURED_OUTPUT_SCHEMA_DEPTH + { + return Err(RuntimeError::new("Invalid JSON schema: schema too large")); + } + match value { + Value::Array(values) => { + stack.extend(values.iter().map(|value| (value, depth.saturating_add(1)))); + } + Value::Object(values) => { + stack.extend( + values + .values() + .map(|value| (value, depth.saturating_add(1))), + ); + } + _ => {} + } + } + Ok(()) } /// Streamed events emitted while processing a single assistant turn. @@ -117,6 +200,7 @@ impl std::error::Error for RuntimeError {} pub struct TurnSummary { pub assistant_messages: Vec, pub tool_results: Vec, + pub structured_output: Option, pub prompt_cache_events: Vec, pub iterations: usize, pub usage: TokenUsage, @@ -143,6 +227,7 @@ pub struct ConversationRuntime { hook_abort_signal: HookAbortSignal, hook_progress_reporter: Option>, session_tracer: Option, + structured_output: Option, } impl ConversationRuntime @@ -192,6 +277,7 @@ where hook_abort_signal: HookAbortSignal::default(), hook_progress_reporter: None, session_tracer: None, + structured_output: None, } } @@ -201,6 +287,17 @@ where self } + pub fn with_structured_output_schema( + mut self, + schema: Value, + max_attempts: usize, + ) -> Result { + self.structured_output = Some(StructuredOutputState::new(schema, max_attempts)?); + self.system_prompt + .push(STRUCTURED_OUTPUT_INSTRUCTION.to_string()); + Ok(self) + } + #[must_use] pub fn with_auto_compaction_input_tokens_threshold(mut self, threshold: u32) -> Self { self.auto_compaction_input_tokens_threshold = threshold; @@ -353,8 +450,10 @@ where let mut prompt_cache_events = Vec::new(); let mut iterations = 0; let mut auto_compaction = None; + let mut structured_output = None; + let mut structured_output_attempts = 0_usize; - loop { + 'conversation: loop { iterations += 1; if iterations > self.max_iterations { let error = RuntimeError::new( @@ -367,6 +466,10 @@ where let request = ApiRequest { system_prompt: self.system_prompt.clone(), messages: self.session.messages.clone(), + structured_output_schema: self + .structured_output + .as_ref() + .map(|state| state.schema.clone()), }; let events = match self.api_client.stream(request) { Ok(events) => events, @@ -415,10 +518,83 @@ where } if pending_tool_uses.is_empty() { + if let Some(state) = self.structured_output.as_ref() { + structured_output_attempts = structured_output_attempts.saturating_add(1); + if structured_output_attempts >= state.max_attempts { + let error = RuntimeError::new(format!( + "Failed to provide valid structured output after {} attempts", + state.max_attempts + )); + self.record_turn_failed(iterations, &error); + return Err(error); + } + self.session + .push_user_text( + "Your previous response did not call StructuredOutput. Call StructuredOutput exactly once with a value matching the requested JSON Schema.", + ) + .map_err(|error| RuntimeError::new(error.to_string()))?; + continue; + } break; } for (tool_use_id, tool_name, input) in pending_tool_uses { + if tool_name == STRUCTURED_OUTPUT_TOOL_NAME { + if let Some(state) = self.structured_output.as_ref() { + structured_output_attempts = structured_output_attempts.saturating_add(1); + self.record_tool_started(iterations, &tool_name); + let validation = serde_json::from_str::(&input) + .map_err(|error| format!("invalid tool input JSON: {error}")) + .and_then(|value| state.validate(&value).map(|()| value)); + let (result_message, valid_output) = match validation { + Ok(value) => { + let output = serde_json::to_string_pretty(&serde_json::json!({ + "data": "Structured output provided successfully", + "structured_output": value, + })) + .map_err(|error| RuntimeError::new(error.to_string()))?; + ( + ConversationMessage::tool_result( + tool_use_id, + tool_name, + output, + false, + ), + Some(value), + ) + } + Err(error) => ( + ConversationMessage::tool_result( + tool_use_id, + tool_name, + format!("Output does not match required schema: {error}"), + true, + ), + None, + ), + }; + self.session + .push_message(result_message.clone()) + .map_err(|error| RuntimeError::new(error.to_string()))?; + self.record_tool_finished(iterations, &result_message); + tool_results.push(result_message); + + if let Some(value) = valid_output { + structured_output = Some(value); + break 'conversation; + } + if structured_output_attempts >= state.max_attempts { + let error = RuntimeError::new(format!( + "Failed to provide valid structured output after {} attempts", + state.max_attempts + )); + self.record_turn_failed(iterations, &error); + return Err(error); + } + continue; + } + } + let pre_hook_result = self.run_pre_tool_use_hook(&tool_name, &input); let effective_input = pre_hook_result .updated_input() @@ -523,6 +699,7 @@ where let summary = TurnSummary { assistant_messages, tool_results, + structured_output, prompt_cache_events, iterations, usage: self.usage_tracker.cumulative_usage(), @@ -867,8 +1044,10 @@ mod tests { use crate::session::{ContentBlock, MessageRole, Session}; use crate::usage::TokenUsage; use crate::ToolError; + use serde_json::json; use std::fs; use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use telemetry::{MemoryTelemetrySink, SessionTracer, TelemetryEvent}; @@ -942,6 +1121,152 @@ mod tests { } } + #[test] + fn structured_output_retries_schema_mismatch_and_preserves_format_as_annotation() { + struct StructuredOutputApi { + calls: usize, + } + + impl ApiClient for StructuredOutputApi { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + self.calls += 1; + let expected_schema = json!({ + "type": "object", + "properties": { + "email": {"type": "string", "format": "email"} + }, + "required": ["email"], + "additionalProperties": false + }); + assert_eq!( + request.structured_output_schema.as_ref(), + Some(&expected_schema) + ); + assert!(request.system_prompt.iter().any(|section| { + section.contains("StructuredOutput") && section.contains("exactly once") + })); + + match self.calls { + 1 => Ok(vec![ + AssistantEvent::ToolUse { + id: "structured-invalid".to_string(), + name: "StructuredOutput".to_string(), + input: r#"{"email":42}"#.to_string(), + }, + AssistantEvent::MessageStop, + ]), + 2 => { + let last_message = request.messages.last().expect("retry tool result"); + let ContentBlock::ToolResult { + is_error, output, .. + } = &last_message.blocks[0] + else { + panic!("expected structured output validation result") + }; + assert!(*is_error); + assert!(output.starts_with("Output does not match required schema:")); + Ok(vec![ + AssistantEvent::ToolUse { + id: "structured-valid".to_string(), + name: "StructuredOutput".to_string(), + // AJV is configured with validateFormats=false upstream. A + // non-email string must therefore remain valid here too. + input: r#"{"email":"not-an-email"}"#.to_string(), + }, + AssistantEvent::MessageStop, + ]) + } + _ => unreachable!("extra structured output request"), + } + } + } + + let schema = json!({ + "type": "object", + "properties": { + "email": {"type": "string", "format": "email"} + }, + "required": ["email"], + "additionalProperties": false + }); + let mut runtime = ConversationRuntime::new( + Session::new(), + StructuredOutputApi { calls: 0 }, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_structured_output_schema(schema, 5) + .expect("schema should compile"); + + let summary = runtime + .run_turn("return an email object", None) + .expect("second structured output should validate"); + + assert_eq!(summary.iterations, 2); + assert_eq!( + summary.structured_output, + Some(json!({"email": "not-an-email"})) + ); + assert_eq!(summary.tool_results.len(), 2); + let ContentBlock::ToolResult { is_error, .. } = &summary.tool_results[1].blocks[0] else { + panic!("expected successful structured output result") + }; + assert!(!is_error); + } + + #[test] + fn structured_output_stops_after_configured_attempt_cap() { + struct AlwaysInvalidStructuredOutputApi { + calls: Arc, + } + + impl ApiClient for AlwaysInvalidStructuredOutputApi { + fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { + let call = self.calls.fetch_add(1, Ordering::SeqCst) + 1; + assert!(request.structured_output_schema.is_some()); + Ok(vec![ + AssistantEvent::ToolUse { + id: format!("structured-invalid-{call}"), + name: "StructuredOutput".to_string(), + input: r#"{"name":42}"#.to_string(), + }, + AssistantEvent::MessageStop, + ]) + } + } + + let calls = Arc::new(AtomicUsize::new(0)); + let mut runtime = ConversationRuntime::new( + Session::new(), + AlwaysInvalidStructuredOutputApi { + calls: Arc::clone(&calls), + }, + StaticToolExecutor::new(), + PermissionPolicy::new(PermissionMode::DangerFullAccess), + vec!["system".to_string()], + ) + .with_structured_output_schema( + json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + }), + 2, + ) + .expect("schema should compile"); + + let error = runtime + .run_turn("return a name", None) + .expect_err("two invalid attempts should exhaust the cap"); + + assert_eq!(calls.load(Ordering::SeqCst), 2); + assert_eq!( + error.to_string(), + "Failed to provide valid structured output after 2 attempts" + ); + } + #[test] fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() { let api_client = ScriptedApiClient { call_count: 0 }; diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 967966c6..70566fa9 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -87,9 +87,10 @@ pub use config_validate::{ DiagnosticKind, ValidationResult, }; pub use conversation::{ - auto_compaction_threshold_from_env, ApiClient, ApiRequest, AssistantEvent, AutoCompactionEvent, - ConversationRuntime, PromptCacheEvent, RuntimeError, StaticToolExecutor, ToolError, - ToolExecutor, TurnSummary, + auto_compaction_threshold_from_env, validate_structured_output_schema, ApiClient, ApiRequest, + AssistantEvent, AutoCompactionEvent, ConversationRuntime, PromptCacheEvent, RuntimeError, + StaticToolExecutor, ToolError, ToolExecutor, TurnSummary, + DEFAULT_MAX_STRUCTURED_OUTPUT_ATTEMPTS, }; pub use file_ops::{ edit_file, edit_file_in_workspace, glob_search, glob_search_in_workspace, grep_search, diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d2e50bd8..b2caecf8 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -306,6 +306,7 @@ const CLI_OPTION_SUGGESTIONS: &[&str] = &[ "-V", "--model", "--output-format", + "--json-schema", "--permission-mode", "--cwd", "--directory", @@ -502,6 +503,8 @@ fn classify_error_kind(message: &str) -> &'static str { "missing_flag_value" } else if message.starts_with("invalid_permission_mode:") { "invalid_permission_mode" + } else if message.starts_with("invalid_json_schema:") { + "invalid_json_schema" } else if message.starts_with("invalid_flag_value:") { "invalid_flag_value" } else if message.starts_with("invalid_model:") { @@ -862,6 +865,7 @@ fn global_flag_takes_value(flag: &str) -> bool { | "--permission-mode" | "--base-commit" | "--reasoning-effort" + | "--json-schema" | "--allowedTools" | "--allowed-tools" ) @@ -873,6 +877,7 @@ fn global_flag_is_value_inline(flag: &str) -> bool { || flag.starts_with("--permission-mode=") || flag.starts_with("--base-commit=") || flag.starts_with("--reasoning-effort=") + || flag.starts_with("--json-schema=") || flag.starts_with("--allowedTools=") || flag.starts_with("--allowed-tools=") } @@ -1059,6 +1064,7 @@ fn run() -> Result<(), Box> { output_format, permission_mode, reasoning_effort, + json_schema, } => run_background_agent(BackgroundAgentRequest { prompt: prompt.as_deref(), exec: exec.as_deref(), @@ -1068,6 +1074,7 @@ fn run() -> Result<(), Box> { output_format, permission_mode, reasoning_effort: reasoning_effort.as_deref(), + json_schema: json_schema.as_ref(), })?, CliAction::AgentAttach { id, output_format } => run_agent_attach(&id, output_format)?, CliAction::AgentLogs { id, output_format } => run_agent_logs(&id, output_format)?, @@ -1141,6 +1148,7 @@ fn run() -> Result<(), Box> { compact, base_commit, reasoning_effort, + json_schema, allow_broad_cwd, } => { enforce_broad_cwd_policy(allow_broad_cwd, output_format)?; @@ -1159,7 +1167,7 @@ fn run() -> Result<(), Box> { let resolved_model = resolve_repl_model(model)?; let mut cli = LiveCli::new(resolved_model, true, allowed_tools, permission_mode)?; cli.set_reasoning_effort(reasoning_effort); - cli.run_turn_with_output(&effective_prompt, output_format, compact)?; + cli.run_turn_with_output(&effective_prompt, output_format, compact, json_schema)?; } CliAction::Doctor { output_format, @@ -1269,6 +1277,7 @@ enum CliAction { output_format: CliOutputFormat, permission_mode: PermissionMode, reasoning_effort: Option, + json_schema: Option, }, AgentAttach { id: String, @@ -1356,6 +1365,7 @@ enum CliAction { compact: bool, base_commit: Option, reasoning_effort: Option, + json_schema: Option, allow_broad_cwd: bool, }, Doctor { @@ -1632,6 +1642,7 @@ fn parse_args(args: &[String]) -> Result { let mut compact = false; let mut base_commit: Option = None; let mut reasoning_effort: Option = None; + let mut json_schema: Option = None; let mut allow_broad_cwd = false; let mut background = false; let mut background_exec: Option = None; @@ -1713,6 +1724,17 @@ fn parse_args(args: &[String]) -> Result { output_format = apply_output_format_flag(&mut output_format_selection, value)?; index += 2; } + "--json-schema" => { + let value = args.get(index + 1).ok_or_else(|| { + "missing_flag_value: missing value for --json-schema.\nUsage: --json-schema ''" + .to_string() + })?; + if json_schema.is_some() { + push_duplicate_flag("--json-schema (overwriting previous value)"); + } + json_schema = Some(parse_json_schema_flag(value)?); + index += 2; + } "--permission-mode" => { let value = args .get(index + 1) @@ -1730,6 +1752,13 @@ fn parse_args(args: &[String]) -> Result { apply_output_format_flag(&mut output_format_selection, &flag[16..])?; index += 1; } + flag if flag.starts_with("--json-schema=") => { + if json_schema.is_some() { + push_duplicate_flag("--json-schema (overwriting previous value)"); + } + json_schema = Some(parse_json_schema_flag(&flag["--json-schema=".len()..])?); + index += 1; + } flag if flag.starts_with("--permission-mode=") => { permission_mode_override = Some(parse_permission_mode_arg(&flag[18..])?); index += 1; @@ -2020,6 +2049,12 @@ fn parse_args(args: &[String]) -> Result { .to_string(), ); } + if background_exec.is_some() && json_schema.is_some() { + return Err( + "invalid_flag_value: --json-schema cannot be combined with --bg --exec.\nUsage: claw --bg '' --json-schema ''" + .to_string(), + ); + } let prompt = if background_exec.is_none() { let prompt = positional_prompt.trim().to_string(); if prompt.is_empty() { @@ -2041,6 +2076,7 @@ fn parse_args(args: &[String]) -> Result { output_format, permission_mode: permission_mode_override.unwrap_or_else(default_permission_mode), reasoning_effort, + json_schema, }); } @@ -2055,6 +2091,7 @@ fn parse_args(args: &[String]) -> Result { compact, base_commit, reasoning_effort, + json_schema, allow_broad_cwd, }); } @@ -2070,6 +2107,7 @@ fn parse_args(args: &[String]) -> Result { compact, base_commit, reasoning_effort: reasoning_effort.clone(), + json_schema: json_schema.clone(), allow_broad_cwd, }); } @@ -2098,6 +2136,7 @@ fn parse_args(args: &[String]) -> Result { compact, base_commit, reasoning_effort, + json_schema, allow_broad_cwd, }); } @@ -2112,6 +2151,12 @@ fn parse_args(args: &[String]) -> Result { // #746: newline before remediation so split_error_hint populates hint field return Err("interactive_only: claw requires an interactive terminal.\nStdin is not a TTY and no prompt was provided — pipe a prompt with `echo 'task' | claw` or run `claw` in an interactive terminal.".into()); } + if json_schema.is_some() { + return Err( + "invalid_flag_value: --json-schema requires non-interactive prompt mode.\nUsage: claw -p --json-schema ''" + .to_string(), + ); + } return Ok(CliAction::Repl { model, allowed_tools, @@ -2121,6 +2166,18 @@ fn parse_args(args: &[String]) -> Result { allow_broad_cwd, }); } + if json_schema.is_some() + && rest.first().is_some_and(|first| { + (is_known_top_level_subcommand(first) + && !matches!(first.as_str(), "prompt" | "skills" | "skill")) + || (first.starts_with('/') && !matches!(first.as_str(), "/skills" | "/skill")) + }) + { + return Err( + "invalid_flag_value: --json-schema requires non-interactive prompt mode.\nUsage: claw -p --json-schema ''" + .to_string(), + ); + } if let Some(action) = parse_local_help_action(&rest, output_format) { return action; } @@ -2376,12 +2433,14 @@ fn parse_args(args: &[String]) -> Result { compact, base_commit, reasoning_effort: reasoning_effort.clone(), + json_schema: json_schema.clone(), allow_broad_cwd, }), - SkillSlashDispatch::Local => Ok(CliAction::Skills { - args, - output_format, - }), + SkillSlashDispatch::Local if json_schema.is_some() => Err( + "invalid_flag_value: --json-schema requires a skill invocation, not a local skills command.\nUsage: claw skills [args] --json-schema ''" + .to_string(), + ), + SkillSlashDispatch::Local => Ok(CliAction::Skills { args, output_format }), } } "settings" => { @@ -2468,6 +2527,7 @@ Usage: claw prompt or echo '' | claw prompt".to_string()); compact, base_commit: base_commit.clone(), reasoning_effort: reasoning_effort.clone(), + json_schema: json_schema.clone(), allow_broad_cwd, }) } @@ -2480,6 +2540,7 @@ Usage: claw prompt or echo '' | claw prompt".to_string()); compact, base_commit, reasoning_effort, + json_schema.clone(), allow_broad_cwd, ), other => { @@ -2529,6 +2590,7 @@ Usage: claw prompt or echo '' | claw prompt".to_string()); compact, base_commit, reasoning_effort: reasoning_effort.clone(), + json_schema, allow_broad_cwd, }) } @@ -3023,6 +3085,38 @@ fn validate_reasoning_effort_flag(value: &str) -> Result<(), String> { )) } +fn parse_json_schema_flag(value: &str) -> Result { + let schema = serde_json::from_str::(value).map_err(|error| { + format!( + "invalid_json_schema: --json-schema is not valid JSON: {error}\nHint: Pass an inline JSON object, for example {{\"type\":\"object\"}}." + ) + })?; + if !schema.is_object() { + return Err( + "invalid_json_schema: --json-schema must be a JSON object.\nHint: Pass an inline JSON Schema object." + .to_string(), + ); + } + runtime::validate_structured_output_schema(&schema).map_err(|error| { + let message = error.to_string(); + let detail = message + .strip_prefix("Invalid JSON schema: ") + .unwrap_or(&message); + format!( + "invalid_json_schema: --json-schema is not a valid JSON Schema: {detail}\nHint: Fix the schema before retrying; format keywords are accepted as annotations." + ) + })?; + Ok(schema) +} + +fn structured_output_max_attempts() -> usize { + env::var("MAX_STRUCTURED_OUTPUT_RETRIES") + .ok() + .and_then(|value| value.parse::().ok()) + .filter(|value| *value > 0) + .unwrap_or(runtime::DEFAULT_MAX_STRUCTURED_OUTPUT_ATTEMPTS) +} + #[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)] fn parse_direct_slash_cli_action( rest: &[String], @@ -3033,6 +3127,7 @@ fn parse_direct_slash_cli_action( compact: bool, base_commit: Option, reasoning_effort: Option, + json_schema: Option, allow_broad_cwd: bool, ) -> Result { let raw = rest.join(" "); @@ -3091,12 +3186,14 @@ fn parse_direct_slash_cli_action( compact, base_commit, reasoning_effort: reasoning_effort.clone(), + json_schema, allow_broad_cwd, }), - SkillSlashDispatch::Local => Ok(CliAction::Skills { - args, - output_format, - }), + SkillSlashDispatch::Local if json_schema.is_some() => Err( + "invalid_flag_value: --json-schema requires a skill invocation, not a local skills command.\nUsage: claw /skills [args] --json-schema ''" + .to_string(), + ), + SkillSlashDispatch::Local => Ok(CliAction::Skills { args, output_format }), } } Ok(Some(SlashCommand::Unknown(name))) => { @@ -3653,6 +3750,22 @@ fn filter_tool_specs( tool_registry.definitions(allowed_tools) } +fn upsert_structured_output_tool(tools: &mut Vec, schema: Value) { + let definition = ToolDefinition { + name: "StructuredOutput".to_string(), + description: Some( + "Use this tool exactly once at the end of your response to return the requested structured output." + .to_string(), + ), + input_schema: schema, + }; + if let Some(existing) = tools.iter_mut().find(|tool| tool.name == definition.name) { + *existing = definition; + } else { + tools.push(definition); + } +} + fn parse_system_prompt_args( args: &[String], model: String, @@ -8302,6 +8415,7 @@ fn start_agent_view_dispatch( output_format: request.output_format, permission_mode: request.permission_mode, reasoning_effort: request.reasoning_effort, + json_schema: None, })?; state.status = Some(format!("started background session {}", job.id)); state.selected = 0; @@ -9526,7 +9640,11 @@ impl LiveCli { input: &str, output_format: CliOutputFormat, compact: bool, + json_schema: Option, ) -> Result<(), Box> { + if let Some(schema) = json_schema { + return self.run_prompt_structured(input, output_format, compact, schema); + } if let Some(trigger) = self.workflow_trigger_for_input(input) { return self.run_ultracode_workflow_with_output(input, trigger, output_format); } @@ -9538,6 +9656,66 @@ impl LiveCli { } } + fn run_prompt_structured( + &mut self, + input: &str, + output_format: CliOutputFormat, + compact: bool, + schema: Value, + ) -> Result<(), Box> { + let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?; + let conversation = runtime + .runtime + .take() + .ok_or_else(|| io::Error::other("conversation runtime is unavailable"))? + .with_structured_output_schema(schema, structured_output_max_attempts())?; + runtime.runtime = Some(conversation); + let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); + let result = runtime.run_turn(input, Some(&mut permission_prompter)); + hook_abort_monitor.stop(); + let summary = result?; + let structured_output = summary.structured_output.clone().ok_or_else(|| { + io::Error::other("structured output completed without a validated value") + })?; + let message = serde_json::to_string(&structured_output)?; + self.replace_runtime(runtime)?; + self.persist_session()?; + + match output_format { + CliOutputFormat::Text => println!("{message}"), + CliOutputFormat::Json => println!( + "{}", + json!({ + "message": message, + "structured_output": structured_output, + "compact": compact, + "model": self.model, + "iterations": summary.iterations, + "auto_compaction": summary.auto_compaction.map(|event| json!({ + "removed_messages": event.removed_message_count, + "notice": format_auto_compaction_notice(event.removed_message_count), + })), + "tool_uses": collect_tool_uses(&summary), + "tool_results": collect_tool_results(&summary), + "prompt_cache_events": collect_prompt_cache_events(&summary), + "usage": { + "input_tokens": summary.usage.input_tokens, + "output_tokens": summary.usage.output_tokens, + "cache_creation_input_tokens": summary.usage.cache_creation_input_tokens, + "cache_read_input_tokens": summary.usage.cache_read_input_tokens, + }, + "estimated_cost": format_usd( + summary.usage.estimate_cost_usd_with_pricing( + pricing_for_model(&self.model) + .unwrap_or_else(runtime::ModelPricing::default_sonnet_tier) + ).total_cost_usd() + ) + }) + ), + } + Ok(()) + } + fn run_user_input(&mut self, input: &str) -> Result<(), Box> { if let Some(trigger) = self.workflow_trigger_for_input(input) { return self.run_ultracode_workflow_with_output(input, trigger, CliOutputFormat::Text); @@ -11183,6 +11361,7 @@ struct BackgroundAgentRequest<'a> { output_format: CliOutputFormat, permission_mode: PermissionMode, reasoning_effort: Option<&'a str>, + json_schema: Option<&'a Value>, } fn run_background_agent( @@ -11213,6 +11392,7 @@ fn start_background_agent_job( agent: request.agent.map(ToOwned::to_owned), permission_mode: Some(request.permission_mode.as_str().to_string()), reasoning_effort: request.reasoning_effort.map(ToOwned::to_owned), + json_schema: request.json_schema.cloned(), })?; let child = spawn_agent_worker(&job.id, Path::new(&job.cwd))?; let job = supervisor.set_process(&job.id, child.id())?; @@ -11684,24 +11864,35 @@ fn run_agent_prompt_worker( } let executable = env::current_exe()?; let mut command = Command::new(executable); - if let Some(model) = &job.model { - command.arg("--model").arg(model); - } - if let Some(permission_mode) = &job.permission_mode { - command.arg("--permission-mode").arg(permission_mode); - } - if let Some(reasoning_effort) = &job.reasoning_effort { - command.arg("--reasoning-effort").arg(reasoning_effort); - } + command.args(agent_prompt_worker_args(job, prompt)?); command - .arg("prompt") - .arg(prompt) .current_dir(&job.cwd) .env("CLAW_AGENT_JOB_ID", &job.id); let output = command.output()?; Ok(command_output_to_worker_output(output)) } +fn agent_prompt_worker_args( + job: &runtime::AgentJobRecord, + prompt: &str, +) -> Result, serde_json::Error> { + let mut args = Vec::new(); + if let Some(model) = &job.model { + args.extend(["--model".to_string(), model.clone()]); + } + if let Some(permission_mode) = &job.permission_mode { + args.extend(["--permission-mode".to_string(), permission_mode.clone()]); + } + if let Some(reasoning_effort) = &job.reasoning_effort { + args.extend(["--reasoning-effort".to_string(), reasoning_effort.clone()]); + } + if let Some(schema) = &job.json_schema { + args.extend(["--json-schema".to_string(), serde_json::to_string(schema)?]); + } + args.extend(["prompt".to_string(), prompt.to_string()]); + Ok(args) +} + fn run_agent_follow_up_worker( job: &runtime::AgentJobRecord, session_id: &str, @@ -11734,6 +11925,14 @@ fn run_agent_follow_up_worker( permission_mode, None, )?; + if let Some(schema) = &job.json_schema { + let conversation = runtime + .runtime + .take() + .ok_or_else(|| io::Error::other("conversation runtime is unavailable"))? + .with_structured_output_schema(schema.clone(), structured_output_max_attempts())?; + runtime.runtime = Some(conversation); + } let mut permission_prompter = CliPermissionPrompter::new(permission_mode); let summary = runtime.run_turn(prompt, Some(&mut permission_prompter))?; runtime.session().save_to_path(&handle.path)?; @@ -11741,7 +11940,11 @@ fn run_agent_follow_up_worker( text.push_str("User reply:\n"); text.push_str(prompt); text.push_str("\n\nAssistant:\n"); - text.push_str(&final_assistant_text(&summary)); + if let Some(structured_output) = &summary.structured_output { + text.push_str(&serde_json::to_string(structured_output)?); + } else { + text.push_str(&final_assistant_text(&summary)); + } text.push('\n'); Ok(AgentWorkerOutput { exit_code: 0, text }) } @@ -15030,15 +15233,22 @@ impl ApiClient for AnthropicRuntimeClient { progress_reporter.mark_model_phase(); } let is_post_tool = request_ends_with_tool_result(&request); + let mut tools = if self.enable_tools { + filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref()) + } else { + Vec::new() + }; + if let Some(schema) = request.structured_output_schema.clone() { + upsert_structured_output_tool(&mut tools, schema); + } + let has_tools = !tools.is_empty(); let message_request = MessageRequest { model: self.model.clone(), max_tokens: max_tokens_for_model(&self.model), messages: convert_messages(&request.messages), system: (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")), - tools: self - .enable_tools - .then(|| filter_tool_specs(&self.tool_registry, self.allowed_tools.as_ref())), - tool_choice: self.enable_tools.then_some(ToolChoice::Auto), + tools: has_tools.then_some(tools), + tool_choice: has_tools.then_some(ToolChoice::Auto), stream: true, reasoning_effort: self.reasoning_effort.clone(), ..Default::default() @@ -16557,7 +16767,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " Start the interactive REPL")?; writeln!( out, - " claw [--model MODEL] [--output-format text|json] prompt [--stdin] [TEXT]" + " claw [--model MODEL] [--output-format text|json] [--json-schema JSON] prompt [--stdin] [TEXT]" )?; writeln!( out, @@ -16609,7 +16819,7 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { writeln!(out, " claw dump-manifests [--manifests-dir PATH]")?; writeln!(out, " claw bootstrap-plan")?; writeln!(out, " claw agents [--json] [--all] [--cwd PATH]")?; - writeln!(out, " claw --bg \"prompt\"")?; + writeln!(out, " claw --bg \"prompt\" [--json-schema JSON]")?; writeln!(out, " claw --bg --exec \"command\"")?; writeln!(out, " claw attach|logs|stop|kill|respawn|rm ")?; writeln!(out, " claw daemon status")?; @@ -16651,6 +16861,14 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " --compact Strip tool call details; print only the final assistant text (text mode only; useful for piping)" )?; + writeln!( + out, + " --json-schema JSON Validate the final response against an inline JSON Schema object" + )?; + writeln!( + out, + " Adds structured_output in JSON mode; MAX_STRUCTURED_OUTPUT_RETRIES defaults to 5" + )?; writeln!( out, " --bg, --background Start a Claude Code background session" @@ -16714,6 +16932,10 @@ fn print_help_to(out: &mut impl Write) -> io::Result<()> { out, " claw --output-format json prompt \"explain src/main.rs\"" )?; + writeln!( + out, + " claw --output-format json --json-schema '{{\"type\":\"object\"}}' prompt \"summarize src/main.rs\"" + )?; writeln!(out, " claw --compact \"summarize Cargo.toml\" | wc -l")?; writeln!( out, @@ -16778,8 +17000,9 @@ fn print_help(output_format: CliOutputFormat) -> Result<(), Box std::env::set_var("MAX_STRUCTURED_OUTPUT_RETRIES", value), + None => std::env::remove_var("MAX_STRUCTURED_OUTPUT_RETRIES"), + } + } + #[test] fn merge_prompt_with_stdin_returns_prompt_unchanged_when_no_pipe() { // given @@ -17378,6 +17674,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17400,6 +17697,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17416,6 +17714,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17432,6 +17731,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17470,6 +17770,7 @@ mod tests { compact: true, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17485,6 +17786,7 @@ mod tests { compact: true, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17528,6 +17830,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17667,6 +17970,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -17881,6 +18185,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -18145,6 +18450,8 @@ mod tests { "Explore".to_string(), "--reasoning-effort".to_string(), "medium".to_string(), + "--json-schema".to_string(), + r#"{"type":"object","properties":{"name":{"type":"string"}}}"#.to_string(), "fix parser".to_string(), ]) .expect("--bg prompt should parse") @@ -18155,6 +18462,7 @@ mod tests { name, agent, reasoning_effort, + json_schema, .. } => { assert_eq!(prompt.as_deref(), Some("fix parser")); @@ -18162,6 +18470,13 @@ mod tests { assert_eq!(name.as_deref(), Some("parser-fix")); assert_eq!(agent.as_deref(), Some("Explore")); assert_eq!(reasoning_effort.as_deref(), Some("medium")); + assert_eq!( + json_schema, + Some(json!({ + "type": "object", + "properties": {"name": {"type": "string"}} + })) + ); } other => panic!("expected background prompt action, got {other:?}"), } @@ -18190,6 +18505,15 @@ mod tests { .expect_err("--bg --print should reject") .contains("--bg cannot be combined") ); + assert!(parse_args(&[ + "--bg".to_string(), + "--exec".to_string(), + "echo hi".to_string(), + "--json-schema".to_string(), + r#"{"type":"object"}"#.to_string(), + ]) + .expect_err("background exec cannot produce model structured output") + .contains("--json-schema cannot be combined with --bg --exec")); assert_eq!( parse_args(&["attach".to_string(), "abc123".to_string()]).expect("attach"), @@ -18295,6 +18619,7 @@ mod tests { agent: Some("Explore".to_string()), permission_mode: Some("manual".to_string()), reasoning_effort: Some("medium".to_string()), + json_schema: Some(json!({"type": "object"})), output_tail: Some("latest output".to_string()), exit_code: None, stopped_at: None, @@ -18333,6 +18658,7 @@ mod tests { agent: None, permission_mode: Some("manual".to_string()), reasoning_effort: None, + json_schema: None, }) .expect("job should create"); let previous_config = std::env::var("CLAUDE_CONFIG_DIR").ok(); @@ -18359,6 +18685,53 @@ mod tests { let _ = std::fs::remove_dir_all(root); } + #[test] + fn background_prompt_worker_forwards_persisted_json_schema_as_one_inline_argument() { + let root = temp_dir(); + let config = root.join("claude-config"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&workspace).expect("workspace should exist"); + let schema = json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + }); + let supervisor = runtime::AgentSupervisor::from_config_dir(&config); + let job = supervisor + .create_job(runtime::AgentJobCreate { + cwd: workspace, + kind: runtime::AgentJobKind::Claude, + prompt: Some("return a name".to_string()), + command: None, + name: None, + model: Some(DEFAULT_MODEL.to_string()), + agent: None, + permission_mode: Some("manual".to_string()), + reasoning_effort: Some("high".to_string()), + json_schema: Some(schema.clone()), + }) + .expect("job should create"); + + let args = agent_prompt_worker_args(&job, "return a name") + .expect("worker arguments should serialize"); + let schema_index = args + .iter() + .position(|arg| arg == "--json-schema") + .expect("json schema flag should be forwarded"); + + assert_eq!( + serde_json::from_str::(&args[schema_index + 1]) + .expect("schema argument should be inline JSON"), + schema + ); + assert_eq!( + &args[args.len() - 2..], + &["prompt".to_string(), "return a name".to_string()] + ); + + let _ = std::fs::remove_dir_all(root); + } + #[test] fn dump_manifests_subcommand_accepts_explicit_manifest_dir() { assert_eq!( @@ -19602,6 +19975,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -19677,6 +20051,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -19704,6 +20079,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -19839,6 +20215,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -19858,6 +20235,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); @@ -19887,6 +20265,7 @@ mod tests { compact: false, base_commit: None, reasoning_effort: None, + json_schema: None, allow_broad_cwd: false, } ); diff --git a/rust/crates/rusty-claude-cli/tests/structured_output_contract.rs b/rust/crates/rusty-claude-cli/tests/structured_output_contract.rs new file mode 100644 index 00000000..fe241b02 --- /dev/null +++ b/rust/crates/rusty-claude-cli/tests/structured_output_contract.rs @@ -0,0 +1,207 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use mock_anthropic_service::{MockAnthropicService, SCENARIO_PREFIX}; +use serde_json::{json, Value}; + +#[test] +fn json_schema_retries_invalid_tool_input_and_emits_validated_structured_output() { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + let server = runtime + .block_on(MockAnthropicService::spawn()) + .expect("mock service should start"); + let workspace = create_workspace("json"); + let schema = json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"], + "additionalProperties": false + }); + + let output = run_claw( + &workspace, + Some(&server.base_url()), + &[ + "--model", + "sonnet", + "--permission-mode", + "read-only", + "--output-format=json", + "--json-schema", + &schema.to_string(), + "prompt", + &format!("{SCENARIO_PREFIX}structured_output_retry"), + ], + ); + + assert_success(&output); + let response = parse_json_line(&output.stdout); + assert_eq!(response["structured_output"], json!({"name": "Ada"})); + assert_eq!( + response["message"], + Value::String(r#"{"name":"Ada"}"#.to_string()) + ); + assert_eq!(response["iterations"], Value::from(2)); + assert_eq!( + response["tool_results"] + .as_array() + .expect("tool results array") + .len(), + 2 + ); + + let requests = runtime.block_on(server.captured_requests()); + let message_requests = requests + .iter() + .filter(|request| { + request.path == "/v1/messages" && request.scenario == "structured_output_retry" + }) + .collect::>(); + assert_eq!(message_requests.len(), 2); + for request in &message_requests { + let body: Value = serde_json::from_str(&request.raw_body).expect("request body JSON"); + let structured_tool = body["tools"] + .as_array() + .expect("tools array") + .iter() + .find(|tool| tool["name"] == "StructuredOutput") + .expect("StructuredOutput tool should be dynamically installed"); + assert_eq!(structured_tool["input_schema"], schema); + } + let retry_body: Value = + serde_json::from_str(&message_requests[1].raw_body).expect("retry request body JSON"); + assert!(retry_body + .to_string() + .contains("Output does not match required schema:")); + + fs::remove_dir_all(workspace).expect("workspace cleanup"); +} + +#[test] +fn json_schema_equals_form_prints_json_stringify_style_in_text_mode() { + let runtime = tokio::runtime::Runtime::new().expect("tokio runtime"); + let server = runtime + .block_on(MockAnthropicService::spawn()) + .expect("mock service should start"); + let workspace = create_workspace("text"); + let schema = json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + }); + let schema_flag = format!("--json-schema={schema}"); + let prompt = format!("{SCENARIO_PREFIX}structured_output_retry"); + + let output = run_claw( + &workspace, + Some(&server.base_url()), + &[ + "--model", + "sonnet", + "--permission-mode", + "read-only", + &schema_flag, + "prompt", + &prompt, + ], + ); + + assert_success(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout.lines().last(), Some(r#"{"name":"Ada"}"#)); + + fs::remove_dir_all(workspace).expect("workspace cleanup"); +} + +#[test] +fn invalid_json_schemas_fail_before_credentials_or_api_startup() { + let workspace = create_workspace("invalid"); + for (schema, expected) in [ + ("{", "is not valid JSON"), + ("[]", "must be a JSON object"), + ( + r#"{"type":"definitely-not-a-json-schema-type"}"#, + "is not a valid JSON Schema", + ), + ] { + let output = run_claw( + &workspace, + None, + &[ + "--output-format=json", + "--json-schema", + schema, + "prompt", + "return data", + ], + ); + assert!( + !output.status.success(), + "invalid schema unexpectedly succeeded" + ); + let error = parse_json_line(&output.stdout); + assert_eq!(error["error_kind"], "invalid_json_schema"); + assert!( + error["error"] + .as_str() + .is_some_and(|message| message.contains(expected)), + "unexpected error envelope: {error}" + ); + assert!(!error.to_string().contains("missing_credentials")); + } + fs::remove_dir_all(workspace).expect("workspace cleanup"); +} + +fn run_claw(workspace: &Path, base_url: Option<&str>, args: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_claw")); + command + .current_dir(workspace) + .env_clear() + .env("HOME", workspace.join("home")) + .env("CLAW_CONFIG_HOME", workspace.join("config-home")) + .env("CLAUDE_CONFIG_DIR", workspace.join("claude-config")) + .env("NO_COLOR", "1") + .env("PATH", "/usr/bin:/bin") + .args(args); + if let Some(base_url) = base_url { + command + .env("ANTHROPIC_API_KEY", "structured-output-test-key") + .env("ANTHROPIC_BASE_URL", base_url); + } + command.output().expect("claw should launch") +} + +fn parse_json_line(stdout: &[u8]) -> Value { + let stdout = String::from_utf8_lossy(stdout); + stdout + .lines() + .rev() + .find_map(|line| serde_json::from_str(line.trim()).ok()) + .unwrap_or_else(|| panic!("stdout did not contain JSON: {stdout}")) +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "claw failed\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn create_workspace(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + let root = std::env::temp_dir().join(format!( + "claw-structured-output-{label}-{}-{nanos}", + std::process::id() + )); + fs::create_dir_all(root.join("home")).expect("home dir"); + fs::create_dir_all(root.join("config-home")).expect("config dir"); + fs::create_dir_all(root.join("claude-config")).expect("claude config dir"); + root +} diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 9a10d4d8..bf7ceef9 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -6799,7 +6799,7 @@ fn load_provider_fallback_config() -> ProviderFallbackConfig { impl ApiClient for ProviderRuntimeClient { fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { - let tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools)) + let mut tools = tool_specs_for_allowed_tools(Some(&self.allowed_tools)) .into_iter() .map(|spec| ToolDefinition { name: spec.name.to_string(), @@ -6807,10 +6807,13 @@ impl ApiClient for ProviderRuntimeClient { input_schema: spec.input_schema, }) .collect::>(); + if let Some(schema) = request.structured_output_schema.clone() { + upsert_structured_output_tool(&mut tools, schema); + } let messages = convert_messages(&request.messages); let system = (!request.system_prompt.is_empty()).then(|| request.system_prompt.join("\n\n")); - let tool_choice = (!self.allowed_tools.is_empty()).then_some(ToolChoice::Auto); + let tool_choice = (!tools.is_empty()).then_some(ToolChoice::Auto); let runtime = &self.runtime; let chain = &self.chain; @@ -6848,6 +6851,22 @@ impl ApiClient for ProviderRuntimeClient { } } +fn upsert_structured_output_tool(tools: &mut Vec, schema: Value) { + let definition = ToolDefinition { + name: "StructuredOutput".to_string(), + description: Some( + "Use this tool exactly once at the end of your response to return the requested structured output." + .to_string(), + ), + input_schema: schema, + }; + if let Some(existing) = tools.iter_mut().find(|tool| tool.name == definition.name) { + *existing = definition; + } else { + tools.push(definition); + } +} + #[allow(clippy::too_many_lines)] async fn stream_with_provider( client: &ProviderClient, @@ -8477,10 +8496,10 @@ mod tests { final_assistant_text, global_cron_registry, global_mcp_registry, maybe_commit_provenance, mvp_tool_specs, parse_workflow_agent_calls, permission_mode_from_plugin, persist_agent_terminal_state, push_output_block, run_task_packet, run_workflow_with_spawn, - AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass, - ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput, + upsert_structured_output_tool, AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, + LaneFailureClass, ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput, }; - use api::OutputContentBlock; + use api::{OutputContentBlock, ToolDefinition}; use runtime::mcp_tool_bridge::{McpConnectionStatus, McpResourceInfo, McpToolInfo}; use runtime::ProviderFallbackConfig; use runtime::{ @@ -12697,6 +12716,39 @@ printf 'pwsh:%s' "$1" } } + #[test] + fn structured_output_schema_replaces_builtin_tool_contract_without_duplicates() { + let schema = json!({ + "type": "object", + "properties": {"name": {"type": "string"}}, + "required": ["name"] + }); + let mut tools = vec![ToolDefinition { + name: "StructuredOutput".to_string(), + description: Some("old contract".to_string()), + input_schema: json!({"type": "string"}), + }]; + + upsert_structured_output_tool(&mut tools, schema.clone()); + + assert_eq!( + tools + .iter() + .filter(|tool| tool.name == "StructuredOutput") + .count(), + 1 + ); + let tool = tools + .iter() + .find(|tool| tool.name == "StructuredOutput") + .expect("structured output tool"); + assert_eq!(tool.input_schema, schema); + assert!(tool + .description + .as_deref() + .is_some_and(|description| description.contains("exactly once"))); + } + #[test] fn provider_runtime_client_chain_appends_configured_fallbacks_in_order() { // given