Argument Validation and Repair: Making Agent Tool Calls Safe to Be Wrong
Learn how to make AI agent tool calls safe to be wrong: validation at the dispatcher boundary so nothing side-effectful ever sees unvalidated arguments, repair loops that feed structured JSON Pointer errors back to the model instead of generic rejections, a strict recorded coercion allowlist, references instead of hallucinated IDs, and a CI test that proves no tool ever received a malformed call.
The invoice tool receives "amount": "1,299.00" — a string, with a thousands separator. One row below it, "currency": "eur". The payload is valid JSON, the request reaches the backend in nine milliseconds, and every reliability layer you built from this series reports green: the timeout never fired, the breaker stayed closed, the bulkhead had capacity to spare. Then the tool does what most tools do with almost-valid input — it coerces, guesses, or explodes. The invoice lands for 1.299 euros, the float parser having stopped at the comma, or the request dies in a 500 whose stack trace names everything except the real defect: the model emitted arguments that were never valid, and nothing between the model and the tool was checking.
This is the failure class the availability chapters don't touch. Timeouts, breakers, bulkheads, and fallback chains all assume the call was right and the world was wrong. There is a second axis: the world is fine and the call is wrong — structurally (a string where the schema wants a number), referentially (a customer ID that belongs to nobody), or semantically (a refund for minus fifty euros). No containment layer can help, because the failure happened before the network did.
Models will always sometimes be wrong about arguments. That is not a defect you fix; it is a property you design around — the same way this series treated retries as inevitable and made them safe with idempotency keys.
Name the failure class first
Argument failures come in three classes, and each has a different remedy. The industry instinct is to lump them into "model made a mistake, retry," which produces thrash: the retry for a truncated payload (send less) is the opposite of the retry for a wrong enum member (send the right one), and the remedy for a hallucinated ID is not a retry at all.
- Syntactic — the payload is not valid JSON: truncated by an output-token ceiling, single-quoted keys, a trailing comma. Rarer every year as providers harden structured output, and back the moment you route to a cheaper model, grow an argument list past a few hundred tokens, or leave the JSON lane entirely.
- Structural — valid JSON that violates the schema: wrong types, missing required fields, unknown enum members, extra properties. This is the class the model can repair itself, quickly, if you tell it precisely what failed.
- Semantic — schema-valid and still wrong: an entity ID that does not exist,
quantity: -3,limit: 50000against a page cap of 100, a due date in the past. Repair cannot fix what the model never knew; only the contract can.
Validate at the boundary
The rule: nothing side-effectful ever sees unvalidated arguments. Validation lives in the dispatcher — the same layer that owns timeouts and breakers — and it runs before any of them matter:
async def call_tool(tool, raw_args):
args, coercions = coerce(tool, raw_args) # strict allowlist, recorded
errors = validate(tool.schema, args) # JSON Schema, ajv or jsonschema
if errors:
return await repair_or_refuse(tool, raw_args, errors)
return await dispatch(tool, args) # breakers, budgets, ladder
Two properties of that shape do the work. A validation failure is instant and free: no timeout budget burned, no breaker ticked, no dependency touched — a malformed call costs microseconds instead of a round trip into your outage story. And the tool contract hardens: a tool that can assume schema-valid input deletes its coercion shims, stops defending against model output, and starts rejecting with precise 422s. A tool that silently coerces "1,299.00" is teaching the model that the schema is advisory.
The inverse mistake is validating inside the tool. Then every tool reimplements parsing, coercion, and diagnosis at different strictness, with error strings shaped for humans instead of models — and the dispatcher cannot distinguish "the dependency is broken" from "the arguments were," which matters in the next section.
The error is a prompt; design it
When validation fails, the model receives the failure as a structured tool result. Models treat error text as instructions whether you wrote it as instructions or not, so an error like Invalid arguments does real damage: it invites a loop where the model changes something — anything — and resends. The error is the steering surface, the same way degraded-result wording was in the fallback chapter. Write it that way:
{
"status": "validation_failed",
"tool": "create_invoice",
"errors": [
{
"path": "/amount",
"rule": "type",
"message": "expected number, got string \"1,299.00\"; send 1299.0 — no thousands separators, no currency symbols"
},
{
"path": "/currency",
"rule": "enum",
"message": "must be one of [USD, EUR, GBP]; uppercase ISO 4217"
}
],
"detail": "Fix every field listed in errors and call create_invoice again with the complete argument object. Do not send a partial patch."
}
JSON Pointer paths (/amount, /line_items/0/quantity) so the model knows exactly where; the violated rule; the allowed values when the set is small; one canonical example when it is not. The detail line pins the protocol — complete object, not a diff — because a model left to guess will happily send just the two fixed fields and compound the failure.
Then bound the loop. One repair attempt; a second only when the first failure was syntactic truncation, which genuinely deserves a re-emit with tighter output. Past the budget, the harness answers the conversation with a structured refusal — arguments rejected twice; ask the user for the missing fact — instead of a third identical round trip. A repair loop without a bound is a spend violation with extra steps.
Every repair attempt is a ledger entry, same discipline as the command-ledger chapter: original arguments, validation errors, repaired arguments, verdict. Repairs are invisible in the provider's transcript view; they are the most common silent quality failure in agent systems, and the ledger is how they stop being silent.
Coerce strictly, never default
A short list of coercions are so deterministic they are not judgment calls, and the harness should apply them — and record every one:
ALLOWED_COERCIONS = {
("string", "boolean"): lambda v: {"true": True, "false": False}[v.strip().lower()],
("integer", "number"): float, # 3 -> 3.0, always safe
("string", "number"): lambda v: float(v.strip()), # bare numerics ONLY
}
# "49.90" -> 49.9 coerced, recorded
# "1,299.00" -> rejected not a bare numeric; NOT silently "fixed"
The line between the two string cases is the whole point. "49.90" has one honest reading; "1,299.00" has several (1299? 1.299? a locale bug?), and a harness that guesses trains the model to keep sending locale-formatted money forever, because it keeps working. Coercions that mask a defect manufacture more of the defect.
What never happens: defaulting a missing required field. When currency is absent, filling "USD" feels helpful and is poison — the call the model made is not the call that executed, the transcript cannot explain the ledger, and a replay of the same turn produces a different action than the original run. If the product genuinely has a default, the schema declares the field optional with the default visible in the description, so the model decides to omit it with full information. A default applied below the model's sightline is a decision the model never got to make.
Stop asking the model for IDs
The semantic class deserves its own remedy, because repair loops cannot address it: a hallucinated customer_id either belongs to nobody (a clean failure) or to somebody else (a catastrophe — the wrong customer's invoice). The model is not retrieving these values; it is sampling plausible-looking strings from latent space.
The fix is contract-side. Tools take references the model can honestly produce and resolve them authoritatively inside the tool, and ambiguity returns real candidates instead of an error:
{
"status": "ambiguous_reference",
"tool": "create_invoice",
"field": "customer_ref",
"candidates": [
{"customer_id": "cus_00181", "label": "Acme BV (Rotterdam)"},
{"customer_id": "cus_00922", "label": "Acme Holding BV (Antwerp)"}
],
"detail": "Two customers match 'Acme'. Call again with customer_id set to exactly one candidate."
}
Now the model chooses between real options the system produced — which models are good at — instead of guessing an identifier, which they are structurally bad at. Range and sanity rules (minimum: 0, maximum on page sizes, format: date-time) stay in the schema's hands: they document the contract and enforce it with the same bytes, which is one implementation instead of two.
The failure rate is a schema-quality signal
Track validation_failed per tool, by rule, per thousand calls — and read it as product telemetry about your tools, not as a complaint about the model. This is the measurement loop the tool-schema chapter was missing:
- Wrong-enum failures say the description never made the allowed set legible — or the values are unintuitive and want aliases.
- Missing-required failures say the field has no business being required, or its name is unclear.
- Truncation failures say the argument surface is too large for the model's output budget — split the tool.
- Constant referential failures say the tool demands IDs the model cannot know — switch it to references.
A tool under roughly one failed call per hundred is well-designed. Over five, every failure is burning a full model round trip in the repair loop, and your users are paying that latency for a schema you could fix in an afternoon.
How it composes with the series
- With idempotency keys: mint the key after validation passes, not when the model first emits the call. A repair changes the payload, and the fingerprint-checked ledger from the idempotency chapter reads same-key-different-payload as an alarm — a loud 422 — not a feature. The key follows the validated intent.
- With timeouts and cancellation: validation is instant and touches no dependency — it never spends the timeout budget. The repair loop carries its own budget: one round trip, two for truncation.
- With fallback chains: a validation failure must never advance the ladder. The dependency is not down; serving a stale cached answer for a malformed-arguments problem masks the defect and teaches the model nothing. Ladders advance on dependency pain only.
- With breakers: same rule — a rejected call does not tick the breaker. Conflating "the model sent garbage" with "the backend is failing" poisons the failure-rate signal the breaker opens on.
- With write serialization: a repaired write re-enters the serialized queue at its validation-pass moment; ordering follows validated intent, not first emission.
- With observability:
validation_failed,repair_succeeded,repair_exhaustedcounters per tool. A model or routing change that quietly doubles structural failures shows up here a week before it shows up in user complaints.
A validator you can test
The invariant is testable in one sentence — no tool invocation ever receives arguments that fail its schema — so spy on the boundary and prove it in CI:
async def test_malformed_arguments_never_reach_the_tool(fake_model, spy_tools):
fake_model.emit({"amount": "1,299.00", "currency": "eur"}) # invalid on purpose
result = await harness.run_turn("invoice Acme for 1299 euro")
assert spy_tools.invocations_of("create_invoice") == [
{"amount": 1299.0, "currency": "EUR"} # the repaired call, only
]
assert result["status"] == "ok"
entry = harness.last_ledger_entry()
assert entry.repairs == 1 and entry.coercions == [] # "1,299.00" rejected, not coerced
async def test_repair_budget_is_bounded(fake_model):
fake_model.emit_invalid_forever()
result = await harness.run_turn("invoice Acme for next month")
assert result["status"] == "arguments_rejected" # structured refusal
assert fake_model.attempts == 2 # one repair, then stop
Then the negative tests, because a validation suite that cannot fail vouches for nothing: delete the validator and the first test must go red as the raw arguments reach the tool; unbound the repair budget and the second must go red on the runaway loop; make the coercion table accept "1,299.00" and the ledger assertion must catch the silent fix. Plant each defect once, watch it fail, restore.
What this does not fix
Validation reads the contract, not the intent. A model can send schema-perfect, referentially-valid arguments that are simply not what the user asked for — catching that is the job of approvals, ledgers, and replayable transcripts; this layer is the floor, not the ceiling. Semantic rules also rot into double bookkeeping when schemas start restating business logic — keep validation at the contract level (types, ranges, formats, references) and leave pricing and policy inside the tool, where they have one implementation. And strictness has a cost curve: a schema tightened against every phrasing you fear also rejects phrasings a better model would have handled — tighten where the failure-rate data says so, never preemptively.
The checklist
- Validation lives in the dispatcher; nothing side-effectful ever sees unvalidated arguments; failures are instant and touch no dependency.
- Errors are prompts: JSON Pointer path, violated rule, allowed values, one canonical example, and a
detailthat pins the resend protocol — complete object, not a patch. - Repair is bounded at one attempt (two for syntactic truncation); exhaustion returns a structured refusal the conversation can act on.
- Coercions are a strict, recorded allowlist;
"1,299.00"fails rather than being "fixed"; no silent defaults — defaults live in the schema, in the model's sightline. - Tools take references the model can honestly produce; ambiguity returns real candidates; raw IDs stop being the model's job.
- Idempotency keys are minted after validation; a repair never changes the payload under an existing key.
- Validation failures never tick breakers and never advance fallback ladders.
validation_failedby tool and rule is product telemetry — a rising rate is a schema defect, not a model complaint.
The availability chapters made your agent safe when the world is broken. This one makes it safe when the model is — and the model will be, a predictable percent of the time, forever. Decide now which part of that percent your users never feel: validate at the boundary, repair once with an error worth reading, and refuse honestly when the arguments will not come right. The alternative is not a model that never errs; it is a tool that quietly executed "1,299.00".
About this story
This article was written by Gen-AI using GPT 5.5 or Opus 4.7. Verify technical guidance before using it in production systems.