fix(tools): semantic input coercion + formatZodValidationError-parity validation errors#700
Merged
Merged
Conversation
… messages Live incident: a model sent Grep the key `n` instead of `-n` and got the port's terse 'Grep.n: unexpected field'. The rejection is parity-correct (z.strictObject refuses unknown keys), but the incident exposed two gaps in the validation path vs the original: - Error text now mirrors formatZodValidationError (toolErrors.ts:68): 'Grep failed due to the following issue: An unexpected parameter `n` was provided' with the missing / unexpected / type-mismatch categories grouped in the original's order and JS-flavored received-type names. Uncategorized issues (enum etc.) keep the port's readable fallback (the original dumps zod's raw error.message there — deliberate deviation). - Semantic coercion (semanticBoolean.ts / semanticNumber.ts): string 'true'/'false' for boolean fields and decimal-literal strings for number fields now coerce BEFORE validation, and the coerced input is what flows to hooks/permissions/call (TS carries parsedInput.data forward). Previously the validator hard-rejected quoted scalars the original accepts, e.g. '-n': 'true' or head_limit: '30' — the most common third-party-model flake — and the tools' call-time coercion helpers never ran. Type-driven at the boundary (every boolean/number/ integer schema node), never inside anyOf/oneOf unions; copy-on-write so the assistant message's recorded tool_use input stays raw. validate_json_schema keeps its exact old rendering and strictness for generic callers (workflow structured output, advisor). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e the union TS wraps approve in semanticBoolean() inside the message union branches (SendMessageTool.ts:55,61) — the boundary coercion deliberately skips anyOf/oneOf, and the port's runtime bool() JS-truthied approve:"false" into an approval. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
approve is a required semanticBoolean inside the union — junk strings/ numbers/missing must reject the message, not JS-truthy into approving a shutdown or plan. Also: ASCII-gate the number-literal regex (JS \d) and refresh two stale 'model-original input' comments. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Incident
A live session (third-party model) called Grep with a parameter literally named
ninstead of the schema's-n:The rejection itself is parity-correct — the original's GrepTool schema is
z.strictObject(GrepTool.ts:34) and the advertised schema correctly names the field-non every provider path — but the incident exposed two real gaps in the same validation path vs the original.Gap 1 — error text (recovery hint quality)
The original renders failed parses via
formatZodValidationError(typescript/src/utils/toolErrors.ts:68-134). For the same input it replies:The port said
Grep.n: unexpected field. This PR ports the original's format: the three curated categories (missing / unexpected / type-mismatch) grouped in the original's order, JS-flavored received-type names (number, neverinteger), singular/plural header. Uncategorized issues (enum, oneOf/anyOf) keep the port's readable per-issue fallback — the original dumps zod's rawerror.messageJSON there, which is strictly worse (deliberate, documented deviation).Gap 2 — semantic coercion (behavioral parity bug)
The original wraps model-facing boolean/number fields in
semanticBoolean/semanticNumber(typescript/src/utils/semanticBoolean.ts, semanticNumber.ts) — zodpreprocesssteps inside the validation schema used by Bash, FileEdit, FileRead, Grep, PowerShell, CronCreate, SendMessage and TaskOutput. Quoted scalars —"-n": "true","head_limit": "30","replace_all": "false"— the most common third-party-model flake — validate and coerce there, and the coerced data (parsedInput.data) is what flows to hooks/permissions/call (toolExecution.ts:669→821).The port validated the raw JSON schema first, hard-rejecting all of those (
expected boolean, got string), which also made the tools' call-time coercion helpers (_semantic_bool/_semantic_intin grep.py) dead code.Fix:
semantic_coerce()runs before validation in the newvalidate_tool_input()entrypoint, and both dispatch paths (services lanetool_execution.pyStep 3;registry.dispatchfor mcp-serve) carry the coerced input forward. Semantics mirror the TS wrappers exactly:"true"/"false"(JS-truthiness would turn"false"intoTrue)/^-?\d+(\.\d+)?$/literals, gated on JS-finiteness (Number("9"×400)isInfinityin JS and is not coerced there — mirrored)anyOf/oneOfnodes never coerce (the original's unwrapped unions — ConfigTool.value keeps"true"a string)tool_useblock shares it)Deviation: coercion is type-driven at the boundary (every
boolean/number/integerschema node) instead of replicating the per-field wrapper list — covering the wrapped fields exactly, plus tolerant-direction coverage of the few unwrapped fields (run_in_background,multiSelect) and MCP tool schemas, without a per-tool list to keep in sync.validate_json_schemakeeps byte-identical behavior for generic callers (workflow structured output, advisor).Bonus: SendMessage
approvetruthiness trap (critic catch)TS wraps
approve: semanticBoolean()inside the message union branches (SendMessageTool.ts:55,61), which the boundary coercion deliberately skips (anyOf/oneOfrule), and the port's looseoneOfnever type-checks union internals. The runtime read wasbool(message_obj.get("approve"))— soapprove: "false","no","0", or1all counted as an approval on control-plane messages (shutdown / plan-approval, the latter applying itspermission_mode)._approve_flag()now mirrorssemanticBoolean→z.booleanexactly:"true"/"false"coerce, anything else (junk strings, numbers, missing) raisesToolInputErrorand rejects the message.Tests
tests/test_tool_input_validation.py(28 tests): byte-exact live-repro message, missing/type/multi-issue grouping + pluralization, the full coercion matrix (accepted and refused literals, JS-Infinity gate, ASCII-only digits like JS\d, unions, nested arrays, copy-on-write/non-mutation), generic-path stability, registry + services-lane integration (coerced input reachescall(); wrapped<tool_use_error>InputValidationError: …text),_approve_flagstrict semantics.Full suite: 8506 passed / 0 failed. Critic-reviewed: REVISE (junk-string
approvetruthy-approval, fixed as above) → APPROVE; critic re-verified every other semantic-wrapped field is a top-level typed node with no repeat of the union-nesting failure mode.🤖 Generated with Claude Code