Normalize tool arguments at provider projection

Merged
VA
3d ago
+612-113

Problem

Mixed-provider history can contain scalar, array or null tool arguments, while provider inputs require objects. The initial fix still copied Anthropic/Gemini argument trees into JSON values, and the validator misinterpreted literal reserved serde keys in Chat/Responses argument strings.

Design

One borrowed argument serializer preserves objects and wraps other values as { "_raw": value }. Typed call entries retain references to canonical arguments through generation and count-request construction until HTTP serialization. Chat/Responses argument JSON streams directly into an escaped string without an intermediate argument string or tree. Argument validation shares the existing strict literal-safe JSON walker with reasoning validation.

Deletions

Remove owned tool-call JSON construction, the generation-to-count Value conversion, and Value-based Chat call merging. Replace ordinary Value decoding of argument strings with the shared strict walker. Argument delta over merged main: +612/-113, net +499 lines, including properties, negative controls and corpus seeds. The final depth fix is +57/-8, net +49.

Tests run

  • Linux via bs.ps1: 201 provider tests passed in 0.62 s, including native stock execution and local Codex counting. Compilation: 42.28 s.
  • Pinned-nightly provider_stream fuzz: 256 runs in <1 s.
  • Windows via lb.ps1: provider --lib --tests compile check passed in 1m05s, after 645 s queued.
  • No network credentials or live endpoints.

Shrinking properties cover all four adapters, exact preservation (including null and literal reserved keys), idempotence, immutable canonical arguments, actual source-pointer identity in the final wire body, and Chat/Responses validator admission. Negative controls detect missing null, missing or double wrapping, materialized arguments, and default serde reserved-marker interpretation. The fuzz oracle exercises the actual typed call serializers and JSON-string codec, with null, escape/Unicode and reserved-key seeds.

Decisions

  • Main now contains #753. A signed merge updates this published branch without rewriting history; the PR diff contains only argument-projection work.

  • Wrapping is intentionally not injective: [1] and { "_raw": [1] } have the same wire object. This is harmless because wire envelopes are never decoded back into saved history. Replay reads the immutable canonical transcript, which retains the distinction; a newly generated _raw object is ordinary tool arguments and is never automatically unwrapped. A regression checks both original values remain distinct while both project correctly.

  • N-07 applies only at per-request projection. This PR covers argument shapes; remaining media/schema/repair work and #508 browser exports remain separate.

  • The standalone argument fuzz input limit stays at depth 128. Output verification derives three extra levels from the generated Gemini part/functionCall/_raw envelope (the bare wrapper needs one); production request limits are unchanged. The codec oracle now uses that bounded literal-safe output decoder throughout, while actual projected ModelRequests still pass the provider validator in the integration property. A checked-in P seed contains 128 nested arrays around 0; the shrinking property covers depths 0..128, rejects source depth 129, and negative controls detect both the former unchanged output bound and a single-level allowance that misses call framing.