A tool call fails in one of four ways, and only one of them is the model's fault. The other three are ours: a schema the model cannot satisfy, a dispatcher that swallows errors, and a retry policy written by optimism. This piece walks through the loop I now ship, the one repair pass that fixed most of it, and the traces that decided where to stop.
What actually breaks
Over 30,000 calls a day in a support agent, malformed arguments accounted for 61% of failures. Timeouts were 22%. Genuinely wrong tool choice — the failure everyone worries about — was 9%. The rest was infrastructure.
Four failure modes
Two of these are visible in the transcript; two only show up in traces. The loop below handles all four, and the article's repository has the eval suite that proves it: $ pnpm eval:tools --suite retries.
export async function runToolCall(call: ToolCall, ctx: Ctx) { const parsed = TOOLS[call.name].args.safeParse(call.args); if (!parsed.success) { const repaired = await repairArgs(call, parsed.error, ctx); if (!repaired) return hardFail(call, "schema"); call = repaired; } const result = await withTimeout(dispatch(call), 8_000); if (!result.ok && result.retryable) { ctx.trace.note("retry", { call: call.id }); return runToolCall({ ...call, attempt: 2 }, ctx); } return result; }
Schema drift
The quiet one. A field changes type in the backend, the tool schema follows a week later, and in between the model keeps sending what used to be right. Validation catches it; logs only catch it if you log the parse error with the call id.
~/tool-loop# 200 recorded traces, replayed $ pnpm eval:tools --suite retries pass 187/200 · repaired 9 · hard-fail 4 schema drift: expected string, got number (call_id 41) wrote traces to .evals/2026-08-18T09-12.jsonl
The repair pass
When validation fails, the model gets one more turn with the parse error attached — not the raw stack, the sentence a human would write. The prompt is deliberately short; long repair prompts made things worse in every run I measured.
System instructions
You repair malformed tool arguments. Return only the corrected arguments object. Never add fields the schema does not define.
Variables
- {{tool}}
- Tool name and its JSON schema.
- {{error}}
- One-sentence validation error.
Your previous call to {{tool}} was rejected:
{{error}}
Return corrected arguments only. If the call cannot be repaired, return {"abort": true}.The shape of the whole path, cache included:
One tool call, with repair
Model
Loop
Execution
- Assistant turnargsValidate args
- Validate argsinvalidRepair pass
- Repair passretryValidate args
- Validate argsvalidDispatch
- Repair passabortHard failure
- Code shown above
- The repair pass
- Product-visible outcome
Read this diagram as text
The assistant emits tool arguments, which are validated against a zod schema. Valid arguments go straight to dispatch with an eight-second timeout. Invalid arguments go to a single repair pass, which either returns corrected arguments for revalidation or aborts into a hard failure that the product surfaces to the user.
Why one repair attempt instead of three
Past the first repair the model tends to restate the same malformed argument with different formatting. I measured attempts 1 through 4 across the same 200 traces; attempt 2 added 1.5 points of pass rate for 900 ms of median latency, and attempts 3 and 4 added nothing at all.
const MAX_REPAIRS = 1; // measured on 200 traces, not guessedWhat one retry costs
Latency, mostly. A repaired call is a second model round-trip, so the p95 on repaired calls is roughly double. That is fine when repairs are 4.5% of traffic and not fine at 30% — at which point the schema is the bug.
The numbers
| Measure | No repair | One repairwhat ships | Three repairs |
|---|---|---|---|
| Pass rate1 | 83.5% | 93.5% | 95.0% |
| p50 latencyend-to-end, warm cache | 740 ms | 760 ms | 780 ms |
| p95 latency | 1.9 s | 2.6 s | 4.4 s |
| Token cost / 1k calls | $1.10 | $1.24 | $1.71 |
| Hard failures | 33 | 13 | 10 |
- Graded by hand on 200 recorded traces; a pass means the tool ran with correct arguments.
What I would ship tomorrow
Validate before dispatch. Repair once, with the error in plain language. Give up loudly, in a way the product can explain to a person. Then go read your traces — the interesting failures are never the ones in the bug reports.
Loading this youtube embed sends data to that provider. The article works without it.
A tool-calling loop with schema repair, one retry, and an explicit hard-failure path. Extracted from a support agent handling ~30k calls a day.
- Language
- TypeScript
- Branch
- main
- Stars
- 412
lib/agent/tool-loop.ts:14Discussed in this article