feat: enforce Claude structured output schemas

This commit is contained in:
抱月 2026-07-13 05:56:06 +08:00
parent 14dfb7ae5e
commit a7f203b04e
12 changed files with 1377 additions and 52 deletions

View File

@ -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.

View File

@ -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 <workspace> && <read command> >/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;

294
rust/Cargo.lock generated
View File

@ -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]]

View File

@ -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

View File

@ -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",
}
}

View File

@ -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"] }

View File

@ -70,6 +70,7 @@ pub struct AgentJobCreate {
pub agent: Option<String>,
pub permission_mode: Option<String>,
pub reasoning_effort: Option<String>,
pub json_schema: Option<Value>,
}
#[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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_schema: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output_tail: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub exit_code: Option<i32>,
@ -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");

View File

@ -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<String>,
pub messages: Vec<ConversationMessage>,
pub structured_output_schema: Option<Value>,
}
struct StructuredOutputState {
schema: Value,
validator: jsonschema::Validator,
max_attempts: usize,
}
impl StructuredOutputState {
fn new(schema: Value, max_attempts: usize) -> Result<Self, RuntimeError> {
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::<Vec<_>>();
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<ConversationMessage>,
pub tool_results: Vec<ConversationMessage>,
pub structured_output: Option<Value>,
pub prompt_cache_events: Vec<PromptCacheEvent>,
pub iterations: usize,
pub usage: TokenUsage,
@ -143,6 +227,7 @@ pub struct ConversationRuntime<C, T> {
hook_abort_signal: HookAbortSignal,
hook_progress_reporter: Option<Box<dyn HookProgressReporter>>,
session_tracer: Option<SessionTracer>,
structured_output: Option<StructuredOutputState>,
}
impl<C, T> ConversationRuntime<C, T>
@ -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, RuntimeError> {
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::<Value>(&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<Vec<AssistantEvent>, 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<AtomicUsize>,
}
impl ApiClient for AlwaysInvalidStructuredOutputApi {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, 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 };

View File

@ -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,

View File

@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<String>,
json_schema: Option<Value>,
},
AgentAttach {
id: String,
@ -1356,6 +1365,7 @@ enum CliAction {
compact: bool,
base_commit: Option<String>,
reasoning_effort: Option<String>,
json_schema: Option<Value>,
allow_broad_cwd: bool,
},
Doctor {
@ -1632,6 +1642,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
let mut compact = false;
let mut base_commit: Option<String> = None;
let mut reasoning_effort: Option<String> = None;
let mut json_schema: Option<Value> = None;
let mut allow_broad_cwd = false;
let mut background = false;
let mut background_exec: Option<String> = None;
@ -1713,6 +1724,17 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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 '<inline JSON object>'"
.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<CliAction, String> {
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<CliAction, String> {
.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 '<prompt>' --json-schema '<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<CliAction, String> {
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<CliAction, String> {
compact,
base_commit,
reasoning_effort,
json_schema,
allow_broad_cwd,
});
}
@ -2070,6 +2107,7 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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<CliAction, String> {
compact,
base_commit,
reasoning_effort,
json_schema,
allow_broad_cwd,
});
}
@ -2112,6 +2151,12 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
// #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 <prompt> --json-schema '<schema>'"
.to_string(),
);
}
return Ok(CliAction::Repl {
model,
allowed_tools,
@ -2121,6 +2166,18 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
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 <prompt> --json-schema '<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<CliAction, String> {
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 <skill> [args] --json-schema '<schema>'"
.to_string(),
),
SkillSlashDispatch::Local => Ok(CliAction::Skills { args, output_format }),
}
}
"settings" => {
@ -2468,6 +2527,7 @@ Usage: claw prompt <text> or echo '<text>' | 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 <text> or echo '<text>' | claw prompt".to_string());
compact,
base_commit,
reasoning_effort,
json_schema.clone(),
allow_broad_cwd,
),
other => {
@ -2529,6 +2590,7 @@ Usage: claw prompt <text> or echo '<text>' | 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<Value, String> {
let schema = serde_json::from_str::<Value>(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::<usize>().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<String>,
reasoning_effort: Option<String>,
json_schema: Option<Value>,
allow_broad_cwd: bool,
) -> Result<CliAction, String> {
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 <skill> [args] --json-schema '<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<ToolDefinition>, 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<Value>,
) -> Result<(), Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<Vec<String>, 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 <agent-id>")?;
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<dyn std::error::
#[cfg(test)]
mod tests {
use super::{
acp_status_json, build_runtime_plugin_state_with_loader, build_runtime_with_plugin_state,
classify_error_kind, classify_session_lifecycle_from_panes, collect_session_prompt_history,
acp_status_json, agent_prompt_worker_args, build_runtime_plugin_state_with_loader,
build_runtime_with_plugin_state, classify_error_kind,
classify_session_lifecycle_from_panes, collect_session_prompt_history,
create_managed_session_handle, describe_tool_progress, filter_tool_specs,
format_bughunter_report, format_commit_preflight_report, format_commit_skipped_report,
format_compact_report, format_connected_line, format_cost_report, format_history_timestamp,
@ -16798,13 +17021,14 @@ mod tests {
resolve_repl_model, resolve_session_reference, response_to_events,
resume_supported_slash_commands, run_resume_command, short_tool_id,
slash_command_completion_candidates_with_sessions, split_error_hint, status_context,
status_json_value, summarize_tool_payload_for_markdown, try_resolve_bare_skill_prompt,
validate_no_args, write_mcp_server_fixture, AgentStopSignal, AgentViewTuiOptions,
AgentViewTuiState, CliAction, CliOutputFormat, CliToolExecutor, GitOperation,
GitWorkspaceSummary, InternalPromptProgressEvent, InternalPromptProgressState, LiveCli,
LocalHelpTopic, PermissionModeProvenance, PromptHistoryEntry, SessionHandle,
SessionLifecycleKind, SessionLifecycleSummary, SlashCommand, StatusUsage, TmuxPaneSnapshot,
DEFAULT_MODEL, LATEST_SESSION_REFERENCE, STUB_COMMANDS,
status_json_value, structured_output_max_attempts, summarize_tool_payload_for_markdown,
try_resolve_bare_skill_prompt, validate_no_args, write_mcp_server_fixture, AgentStopSignal,
AgentViewTuiOptions, AgentViewTuiState, CliAction, CliOutputFormat, CliToolExecutor,
GitOperation, GitWorkspaceSummary, InternalPromptProgressEvent,
InternalPromptProgressState, LiveCli, LocalHelpTopic, PermissionModeProvenance,
PromptHistoryEntry, SessionHandle, SessionLifecycleKind, SessionLifecycleSummary,
SlashCommand, StatusUsage, TmuxPaneSnapshot, DEFAULT_MODEL, LATEST_SESSION_REFERENCE,
STUB_COMMANDS,
};
use api::{ApiError, MessageResponse, OutputContentBlock, Usage};
use plugins::{
@ -17287,11 +17511,83 @@ mod tests {
compact: false,
base_commit: None,
reasoning_effort: None,
json_schema: None,
allow_broad_cwd: false,
}
);
}
#[test]
fn parses_inline_json_schema_forms_and_rejects_invalid_schemas_before_dispatch() {
let _guard = env_lock();
std::env::remove_var("RUSTY_CLAUDE_PERMISSION_MODE");
let schema = json!({
"type": "object",
"properties": {"email": {"type": "string", "format": "email"}},
"required": ["email"]
});
for args in [
vec![
"--json-schema".to_string(),
schema.to_string(),
"prompt".to_string(),
"return email".to_string(),
],
vec![
format!("--json-schema={schema}"),
"prompt".to_string(),
"return email".to_string(),
],
] {
let action = parse_args(&args).expect("valid inline schema should parse");
let CliAction::Prompt { json_schema, .. } = action else {
panic!("expected prompt action")
};
assert_eq!(json_schema, Some(schema.clone()));
}
for (value, 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 error = parse_args(&[
"--json-schema".to_string(),
value.to_string(),
"prompt".to_string(),
"return data".to_string(),
])
.expect_err("invalid schema should fail before dispatch");
assert!(error.starts_with("invalid_json_schema:"), "{error}");
assert!(error.contains(expected), "{error}");
assert_eq!(classify_error_kind(&error), "invalid_json_schema");
}
}
#[test]
fn structured_output_retry_cap_uses_positive_environment_override() {
let _guard = env_lock();
let previous = std::env::var_os("MAX_STRUCTURED_OUTPUT_RETRIES");
std::env::set_var("MAX_STRUCTURED_OUTPUT_RETRIES", "2");
assert_eq!(structured_output_max_attempts(), 2);
std::env::set_var("MAX_STRUCTURED_OUTPUT_RETRIES", "0");
assert_eq!(
structured_output_max_attempts(),
runtime::DEFAULT_MAX_STRUCTURED_OUTPUT_ATTEMPTS
);
match previous {
Some(value) => 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::<serde_json::Value>(&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,
}
);

View File

@ -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::<Vec<_>>();
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
}

View File

@ -6799,7 +6799,7 @@ fn load_provider_fallback_config() -> ProviderFallbackConfig {
impl ApiClient for ProviderRuntimeClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, 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::<Vec<_>>();
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<ToolDefinition>, 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