feat: enforce Claude structured output schemas
This commit is contained in:
parent
14dfb7ae5e
commit
a7f203b04e
23
USAGE.md
23
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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue