Skip to content
Context Windowby Alex Janjic
Menu

Tool calling that holds up in production

Most tool-calling failures are not model failures. Four things break in practice, and three of them are handled by one schema repair pass plus a single retry.

AI systemsReliability
ajanjic/tool-loop

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.

lib/agent/tool-loop.ts
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;
}
The repair pass runs once. After that the call is surfaced as a failure the product can explain.View on GitHub

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.

Terminal~/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
One eval run against the retries suite.

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.

Repair promptclaude-sonnet-4-6 · temp 0 · 380 tokens
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

Assistant turntool_use block

Loop

Validate argszod schema
Repair passone attempt

Execution

Dispatch8s timeout
Hard failuresurfaced to product
  1. Assistant turnargsValidate args
  2. Validate argsinvalidRepair pass
  3. Repair passretryValidate args
  4. Validate argsvalidDispatch
  5. 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.

Only one edge loops back. Two repair attempts bought 1.5 points of pass rate and 900 ms.
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.

lib/agent/repair.ts
const MAX_REPAIRS = 1; // measured on 200 traces, not guessed

What 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

Same 200 traces, three policies.
MeasureNo repairOne repairwhat shipsThree repairs
Pass rate183.5%95.0%
p50 latencyend-to-end, warm cache740 ms780 ms
p95 latency1.9 s4.4 s
Token cost / 1k calls$1.10$1.71
Hard failures3310
  1. 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.

Walking through the retry path

Loading this youtube embed sends data to that provider. The article works without it.

The same loop, narrated against live traces. 18 min. Source: Context Window on YouTube

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
Tool calling that holds up in production | Context Window