AI developer tooling / Tutorial

Function Calling That Survives Bad Arguments: Validation, Retries, and Repair

Build a validation, retry, and repair layer for LLM function calling that rejects bad arguments, returns useful errors, and protects side effects.

You will build a defensive execution layer for function calling: a typed tool registry that validates every argument before anything runs, turns validation failures into errors a model can repair, retries only when retrying is safe, and protects side effects with idempotency keys and timeouts. By the end, you will be able to decide which failures to send back for repair, which to reject outright, and which to never retry.

The core principle is simple. A model tool call is untrusted input, just like a request body from a public API client. You would never pass a raw HTTP payload straight into a database write. Do not pass model-generated arguments straight into a function either.

What actually goes wrong

Most failures fall into a small set of classes, each needing a different response. Treating them all as “try again” wastes turns; treating them all as fatal throws away calls the model could easily fix.

Failure classExampleRetry?Response
Malformed JSONTruncated or unparseable argumentsVia model repairDescribe the parse error; ask for valid JSON only
Wrong typesString where a number or array belongsVia model repairName the field, expected type, and value received
Missing or invalid valuesAbsent required field, invented enum, wrong formatVia model repairReturn field-level validator issues
Hallucinated parametersFields your function never definedNever silentlyReject under strict objects; name the unknown fields
Unknown toolName not in your registryVia model repairReject; list the tools that do exist
Downstream failureAPI, database, or network failureOnly if idempotentRetry only with an idempotency key
Partial side effectWrite succeeded, response lostNever blindlyLook up the result by idempotency key first

Argument problems are the model’s to fix, so describe them precisely. Execution problems are yours, so make execution safe to repeat, or refuse to repeat it.

Design schemas the model can actually follow

Your JSON Schema is both a contract and a prompt: the model reads it to decide what to produce, and your validator enforces it afterwards. Weak schemas produce a steady stream of almost-right calls. Invest in the schema before you invest in retries.

  • Use strict objects. Set additional properties to false, or use your library’s strict mode. Without this, hallucinated parameters pass silently and surface later as confusing handler behaviour.
  • Make required mean required. If a field can be sensibly defaulted, default it in code and leave it optional. If it cannot, mark it required.
  • Prefer enums and patterns over free text. Encode fixed sets as enums and known identifier shapes as patterns or formats. Every schema constraint is a correction you skip in a repair loop.
  • Describe fields, not just types. Say what a value is and give a good example. “City and country” produces better arguments than “location string”.
  • Keep tools narrow. One tool doing one thing with a few clear arguments beats one tool with a mode flag and many conditional arguments. Split conditional tools.
  • Avoid nested optionality. Deep objects with optional branches at every level are hard to produce and hard to validate usefully. Flatten where you can.

Strictness has a limit: an over-constrained schema rejects substantively fine calls. Keep hard validation to what your handler needs, and handle softer preferences in code.

Validate before you execute anything

Use a schema library as the single source of truth, and derive both artefacts from it: the JSON Schema you send to the model, and the runtime validator you run on the way in. Defining the two separately guarantees drift.

The implementation below uses Zod. If you prefer hand-written JSON Schema validated with Ajv, the architecture is identical: one registry, validation at the boundary, typed handlers behind it. Choose Ajv when schemas arrive as data or you need its format and custom keyword support. Choose Zod when you want TypeScript argument types inferred for free.

tools.ts
import { z } from "zod";
 
export interface ToolContext {
  idempotencyKey: string;
  signal: AbortSignal;
}
 
interface StoredTool {
  name: string;
  description: string;
  jsonSchema: Record<string, unknown>;
  parse: (raw: unknown) => unknown;
  handler: (args: never, ctx: ToolContext) => Promise<unknown>;
}
 
function defineTool<A extends z.ZodType>(def: {
  name: string;
  description: string;
  schema: A;
  handler: (args: z.infer<A>, ctx: ToolContext) => Promise<unknown>;
}): StoredTool {
  return {
    name: def.name,
    description: def.description,
    jsonSchema: z.toJSONSchema(def.schema) as Record<string, unknown>,
    parse: (raw: unknown) => def.schema.parse(raw),
    handler: def.handler as StoredTool["handler"],
  };
}
 
export const getWeather = defineTool({
  name: "get_weather",
  description: "Get current weather conditions for a place.",
  schema: z
    .object({
      location: z.string().min(1).describe("City and country, for example 'Lagos, Nigeria'"),
      units: z.enum(["metric", "imperial"]).default("metric"),
    })
    .strict(),
  handler: async (args) => {
    return { location: args.location, units: args.units, conditions: "clear" };
  },
});
 
export const createReminder = defineTool({
  name: "create_reminder",
  description: "Create a reminder for the current user.",
  schema: z
    .object({
      text: z.string().min(1).max(500),
      dueAt: z.string().datetime({ offset: true }),
    })
    .strict(),
  handler: async (args, ctx) => {
    return { text: args.text, dueAt: args.dueAt, key: ctx.idempotencyKey };
  },
});
 
export const toolRegistry = new Map<string, StoredTool>(
  [getWeather, createReminder].map((tool) => [tool.name, tool]),
);

Three details carry the value: strict objects make unknown fields fail, handlers receive inferred types instead of cast unvalidated data, and every handler gets an idempotency key and abort signal — retrofitting those later is painful.

Turn validation errors into feedback the model can use

A repair loop is only as good as its error messages. “Invalid arguments” teaches the model nothing, so it guesses — often the same wrong value again. A useful message names the tool, the field, what was expected, and what was received, with sensitive values redacted.

Zod and Ajv both return structured issues. Format them deliberately instead of forwarding a raw library error, which may be verbose or full of internal detail you do not want in a prompt.

errors.ts
import { z } from "zod";
 
export type FailureKind =
  | "malformed_json"
  | "unknown_tool"
  | "invalid_arguments"
  | "timeout"
  | "downstream";
 
export interface ToolFailure {
  kind: FailureKind;
  tool: string;
  message: string;
  retryableByModel: boolean;
}
 
export function parseArguments(
  tool: string,
  rawText: string,
): { ok: true; value: unknown } | { ok: false; failure: ToolFailure } {
  try {
    return { ok: true, value: JSON.parse(rawText) as unknown };
  } catch (error) {
    return {
      ok: false,
      failure: {
        kind: "malformed_json",
        tool,
        message:
          `Arguments for ${tool} were not valid JSON: ` +
          `${error instanceof Error ? error.message : "parse error"}. ` +
          `Return a single JSON object and nothing else.`,
        retryableByModel: true,
      },
    };
  }
}
 
export function formatZodError(tool: string, error: z.ZodError): ToolFailure {
  const issues = error.issues.map((issue) => {
    const path = issue.path.length > 0 ? issue.path.join(".") : "(root)";
    return `- ${path}: ${issue.message}`;
  });
  return {
    kind: "invalid_arguments",
    tool,
    message:
      `Arguments for ${tool} failed validation:\n${issues.join("\n")}\n` +
      `Fix only the fields listed and call ${tool} again.`,
    retryableByModel: true,
  };
}

Send a failure back as a tool result tied to the original call, not as a new user message. That lets the model associate the error with its own call. Trim the rejected payload to the offending field: echoing it back encourages an unchanged resend, and copies sensitive values where they do not need to be.

Build a bounded retry and repair loop

Retries need three limits: a maximum attempt count, a distinction between model-repairable and non-repairable failures, and a hard stop that returns a normal error to your application. An unbounded loop will eventually succeed only at spending your budget.

runner.ts
import { toolRegistry, type ToolContext } from "./tools";
import { formatZodError, parseArguments, type ToolFailure } from "./errors";
import { z } from "zod";
 
export interface ToolCallRequest {
  id: string;
  name: string;
  argumentsText: string;
}
 
export type ToolOutcome =
  | { status: "ok"; callId: string; result: unknown }
  | { status: "failed"; callId: string; failure: ToolFailure };
 
const TIMEOUT_MS = 15_000;
 
export async function executeToolCall(
  call: ToolCallRequest,
  attemptKey: string,
): Promise<ToolOutcome> {
  const tool = toolRegistry.get(call.name);
  if (!tool) {
    return {
      status: "failed",
      callId: call.id,
      failure: {
        kind: "unknown_tool",
        tool: call.name,
        message:
          `Unknown tool "${call.name}". Available tools: ` +
          `${[...toolRegistry.keys()].join(", ")}.`,
        retryableByModel: true,
      },
    };
  }
 
  const parsed = parseArguments(call.name, call.argumentsText);
  if (!parsed.ok) {
    return { status: "failed", callId: call.id, failure: parsed.failure };
  }
 
  let args: unknown;
  try {
    args = tool.parse(parsed.value);
  } catch (error) {
    if (error instanceof z.ZodError) {
      return { status: "failed", callId: call.id, failure: formatZodError(call.name, error) };
    }
    throw error;
  }
 
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
  const ctx: ToolContext = { idempotencyKey: attemptKey, signal: controller.signal };
 
  try {
    const result = await tool.handler(args as never, ctx);
    return { status: "ok", callId: call.id, result };
  } catch {
    const failure: ToolFailure = controller.signal.aborted
      ? { kind: "timeout", tool: call.name, message: `${call.name} did not finish in time.`, retryableByModel: false }
      : { kind: "downstream", tool: call.name, message: `${call.name} failed while running.`, retryableByModel: false };
    return { status: "failed", callId: call.id, failure };
  } finally {
    clearTimeout(timer);
  }
}
 
export async function runWithRepair(
  initialCall: ToolCallRequest,
  repair: (failure: ToolFailure) => Promise<ToolCallRequest | null>,
  maxAttempts = 3,
): Promise<ToolOutcome> {
  let call = initialCall;
  let last: ToolOutcome | null = null;
 
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    last = await executeToolCall(call, `op-${initialCall.id}`);
    if (last.status === "ok") return last;
    if (!last.failure.retryableByModel) return last;
    if (attempt === maxAttempts) return last;
 
    const next = await repair(last.failure);
    if (!next) return last;
    call = next;
  }
  return last as ToolOutcome;
}

The restraint is the point. Only model-repairable failures consume another attempt; a timeout or downstream failure returns immediately, because rephrasing arguments will not fix your network. The repair callback — in practice, a fresh model call with the failure as a tool result — can return null to stop early.

Add one guard: if the model returns the same tool name and arguments after a repair, stop. A simple hash of name plus arguments catches that loop.

Protect side effects with idempotency

Repair loops are safe for reads and dangerous for writes. If a reminder, order, or message times out after it succeeded, a naive retry creates a second one — and duplicates are harder to undo than failures are to retry.

Solve this at the operation level, not the loop level:

  • Generate one key per logical operation, before the first attempt, and reuse it on every retry and repair. A new key per attempt defeats the purpose — hence the runner derives its key from the initial call, not the attempt number.
  • Store the key with the result. Record it alongside the created resource. When a call arrives with a key that already has a result, return that result instead of executing again.
  • Pass the key onward. If your handler calls a payment or booking API that supports idempotency keys, forward yours or derive a stable child key from it. A chain is only idempotent if every link is.
  • Classify your tools honestly. Mark each as read-only, idempotent write, or non-idempotent write, and let retry policy read that classification. Never automatically retry a non-idempotent write without key support; surface the uncertainty instead.

Handle timeouts without creating duplicates

A timeout tells you that you stopped waiting. It tells you nothing about whether the work finished — which is why timeouts belong in their own failure class, with their own policy.

Set timeouts at two levels. Give each handler a deadline enforced with an abort signal that fetch calls and database clients can observe, so a timed-out handler actually stops. Then set an overall deadline for the whole turn, so individually acceptable calls cannot stretch a request indefinitely.

When a write times out, do not immediately retry or report failure. Reconcile first: look up the idempotency key. If a result was recorded, return it as success. If nothing was recorded and you can confirm no write happened, retrying with the same key is safe. If you can confirm neither, say so in your logs and result rather than guessing.

Reads are simpler: retry them directly, ideally with a short backoff, because repeating a read changes nothing. Keep that budget bounded and shared with the repair budget.

Verdict and recommendation

Build the boring version, in this order. First, make one schema per tool the single source of truth — strict about unknown fields, with enums, formats, and real descriptions — and validate at the boundary. Second, format validation failures as short, field-level instructions, feed them back as tool results, and cap attempts with a repeated-call stop. Third, classify tools by side effects, attach a stable idempotency key to each logical operation, and forbid automatic retries of writes you cannot deduplicate. Fourth, treat timeouts on writes as unknown outcomes, and reconcile by key before retrying or reporting.

Skip elaborate self-correction frameworks and model-graded repairs until this foundation exists. Most broken function calling is not a reasoning problem. It is an unvalidated input problem, an uninformative error problem, and an unsafe retry problem — all solved by the ordinary engineering above.

Written by Joan Ayebola

I’m a software engineer and technical writer with more than four years of experience building software and explaining how it works. I write tested tutorials, migration guides, and documentation that help developers understand a tool quickly and use it with confidence.

Keep reading