AI agents

A Tool Schema That Tells the Model Which Retry to Take

Antonio Evans8 min read

A flat-line technical diagram of a tool schema card on the left, with a highlighted enum field listing four error class names, a short description line under each, and a toggle-labeled retryable flag.
Key points

Key points

  1. 1Put the error class in the tool's return schema as an enum, not in a free-text error string the model has to interpret.
  2. 2Describe each enum value in one sentence that names the intended next action, so the model maps class to behavior without guessing.
  3. 3Add a boolean retryable flag and make the model check it before any retry attempt.
  4. 4Prove the design with an eval that counts how often each error class lands on its intended action across a fixed set of seeded failures.

Why free-text errors make agents guess

A tool that fails by returning {"error": "could not complete request"} forces the model to infer what to do next from prose. That inference is unreliable. Agents fail most often because the tool interface is unclear, not because the model is weak: when naming, schemas and error handling are vague, failures become predictable rather than accidental machinelearningmastery.com. The same article treats "structured error returns" as one of the concrete patterns that reduces hallucination at the tool boundary.

There is a second problem. Many failures are silent. A call returns HTTP 200, the action never committed, and the agent moves on as if it succeeded scalekit.com. Scalekit's taxonomy separates identity, connector, upstream API and execution-semantics failures precisely because a single status code means structurally different things depending on which layer failed. A prose string cannot carry that distinction; a named field can.

The third problem is cost. Frameworks default to "retry with exponential backoff" for every error, but that reflex was imported from stateless HTTP clients into stateful agent loops where it is often wrong tianpan.co. Retrying a calendar conflict that will never clear burns latency and proves nothing. The fix is to make the model read a class, not a paragraph.

The decision rule: three axes, three outcomes

Before writing schema, settle the policy. Every error a tool can return should resolve to exactly one of three actions.

Intended actionMeaningTypical classesRetryable flag
RepairThe model can fix the call itself and try again with a corrected payloadbad argument format, missing required field, schema mismatchfalse
RetryThe same call may succeed after a wait, handled by the orchestratorrate limit, timeout, upstream 503true
EscalateNo plan from this credential or context will work; hand off to a human or a different flowauth failure, revoked token, permission denied, permanent conflictfalse

Two independent axes explain why: transient versus persistent, and infrastructural versus semantic tianpan.co. A 503 is transient and infrastructural, so retry is correct. A 409 is persistent and semantic, so the model must re-observe and replan. A 401 is persistent and infrastructural, so neither retry nor replan helps: escalate. Transport and service errors belong to the orchestration layer, while input validation and logic errors belong to the model n8n. That split is the whole design in one sentence.

HTTP status semantics support the same mapping. Client errors in the 4xx class mean the caller must change something and should not be retried, while 5xx means the server must fix something and can be retried; the class is the contract milanjovanovic.tech. MDN groups responses into exactly these five classes developer.mozilla.org. Temporal's retry recipe publishes a concrete retryable list (408, 429, 500, 502, 503, 504) and a non-retryable list (400, 401, 403, 404, 422) docs.temporal.io. Start from those lists and add your domain's own classes.

Writing the enum into the return shape

Most teams document outputs in prose and leave the machine-readable schema to inputs only. That is backwards if the model must act on the result. A strong tool schema includes five parts: name, description, input schema, output schema and error schema, and the error schema should be structured so the model can retry, ask for missing data or escalate aiquinta.ai. Here is a return envelope that follows that advice.

{
  "ok": false,
  "error": {
    "class": "invalid_argument",
    "message": "guest_count must be a positive integer; received 'two'",
    "retryable": false,
    "field": "guest_count"
  }
}

And here is the same shape expressed as a JSON Schema fragment you can attach to the tool definition. The enum constrains the class; the descriptions carry the policy.

{
  "type": "object",
  "properties": {
    "ok": { "type": "boolean" },
    "error": {
      "type": "object",
      "required": ["class", "message", "retryable"],
      "properties": {
        "class": {
          "type": "string",
          "enum": ["invalid_argument", "not_found", "conflict", "rate_limited", "transient_upstream", "auth_failed", "permission_denied", "partial_execution"],
          "description": "Category of failure. Read the matching description below to choose repair, retry or escalate."
        },
        "message": { "type": "string", "description": "Human-readable detail. Never the basis for the decision; use class." },
        "retryable": { "type": "boolean", "description": "True only when the identical call may succeed after a delay. If false, do not call this tool again with the same arguments." },
        "field": { "type": "string", "description": "For invalid_argument only: the parameter that must be corrected." }
      }
    }
  }
}

The context-dependent defects you need to look for are in the classes and descriptions, not in the type keywords. A bare enum of names is only marginally better than prose; the value comes from what each name says to do.

One sentence per class, one example per class

The model reads descriptions, so the description of each enum value is where the decision actually lives. The rule: every description states the intended action explicitly. Not "the argument was bad" but "the argument was bad; correct the named field and call again." Example pairs for the seven classes above:

ClassDescription (states action)Example message
invalid_argumentCorrect the named field and call again once.guest_count must be a positive integer; received 'two'
not_foundThe resource does not exist. Do not retry; ask the user or choose another resource.airport code 'ZZZ' not found in the reference table
conflictThe state changed under you. Re-read the resource, then replan; do not repeat the same write.seat 14C was taken between quote and booking
rate_limitedThe orchestrator will retry after a delay. Stop this step and wait.429 received; Retry-After 12 seconds
transient_upstreamThe orchestrator will retry. Do not change the arguments.503 from inventory service
auth_failedCredentials are invalid or revoked. Do not retry; escalate to a human.access token revoked by the account owner
permission_deniedThis identity may never perform this action. Do not retry; escalate or drop the step.scope bookings:write not granted
partial_executionThe call was accepted but the write may not have committed. Re-read state before any further write.200 returned, order row not found on re-read

Two of these are the ones teams forget. partial_execution is the hardest class to detect and returns HTTP 200, so it must exist as an explicit value scalekit.com. And the conflict example above comes straight from the calendar-write case that motivated the replan argument tianpan.co. Note also that retryable is presentation, not policy: the orchestrator, not the model, executes the retry for rate_limited and transient_upstream, so the flag is telling the model to yield rather than to loop.

Enum descriptions follow the same discipline as parameter descriptions, which should define scope and constraints rather than restate the field name machinelearningmastery.com. A description that says "invalid argument" teaches nothing. A description that says "correct the named field and call again once" is a complete instruction.

Why the retryable flag has to be a field, not a footnote

A boolean is the cheapest piece of coordination you can ship. It gives the model a single, checkable gate before it burns a call, and it gives your evaluation a clean variable.

The failure it prevents is measurable. A retry budget of three with 200ms, 400ms and 800ms backoffs adds a guaranteed 1.4 seconds to any call that returns a persistent semantic error, and the latency lands on the user watching a spinner during a conflict the agent could have surfaced in 200 milliseconds tianpan.co. There is a correctness cost too: a payment-create call that timed out may have succeeded server-side, so the retry creates a duplicate charge, and replanning forces the agent to re-observe state before acting tianpan.co. The flag encodes both lessons in one bit.

For upstream services that publish instructions, surface them in the flag. Servers can send a Retry-After header to say when to retry, and the notebook that implements this logic treats a present Retry-After as authoritative over a default delay docs.temporal.io. If your tool wraps a dependency that sends those headers, parse them in the tool and set retryable and any delay hint from the response, not from your guess.

Be careful with idempotency claims. Temporal's recipe lists 409 Conflict as retryable "when resource is temporarily locked or in use" docs.temporal.io, but the agents piece argues a 409 is persistent and semantic and retrying is futile tianpan.co. Both can be true: the difference is whether your tool knows the lock is short-lived. If you do not know, set retryable: false and classify as conflict. Default to the safer bit.

Build the eval: count class-to-action matches

The design is only as good as its measurement. The eval is small and cheap: a fixed set of seeded failures, one per class (plus variants), replayed against a fixed agent loop, counting how often the model's next action matches the intended action for that class. Intended actions come from the table in the second section: repair, retry or escalate.

Recommended procedure:

  1. Seed at least three variants per class, including one edge case, such as a 409 that clears on re-read versus one that does not.
  2. Freeze the user prompt and the tool list so only the schema changes between runs.
  3. Record the model's next action, not its narration. Did it change the argument, yield, or escalate?
  4. Score per class, then overall. A single blended accuracy figure hides the class that breaks.
  5. Re-run the eval whenever you add or rename an enum value.

Use realistic envelopes. A 422 whose message hides the failing field cannot be repaired, and the field that names what to fix is what makes the correction possible: an error body has to tell the client what to fix, while the status tells it whether to fix milanjovanovic.tech. This is exactly the gap the practitioners who wired the Replit domain-buying agent hit, where the agent saw only HTTP Error 422 until the full response body was surfaced to it medium.com.

Treat new enum values like a versioned contract. Schema registries exist to block changes that break consumers, and the compatibility type you choose determines which edits are allowed docs.confluent.io. Adding an enum symbol is the equivalent of adding a union variant or an optional field, and those are governed by explicit compatibility rules rather than left to chance docs.confluent.io. When you retire a class, keep it in the enum and stop emitting it, so prompts and evals written against the old value still parse.

What the eval tells you to change

The numbers rarely come back even. The common pattern is repair-heavy classes scoring well because the message names the field, while escalate classes score badly because the model retries a permission_denied "just in case." When that happens, the fix is almost always the description, not the model. Add the words "do not retry" to the class description and re-run.

A second pattern is the model changing arguments on a transient_upstream. That means the description reads like an input problem. Rewrite it to say "do not change the arguments; the orchestrator will retry." A third is the model retrying partial_execution. This one is dangerous: make the description say "re-read state before any further write," and add a state field to the envelope so the re-read is cheap.

If you want to run the eval across several models without rewriting client code per provider, a single OpenAI-compatible endpoint for text, image, video, audio and embedding models removes that friction; Quanta Inference is one option for that. The schema work itself is portable either way, which is the point: the decision belongs to the tool contract, not to any one model's prompt.

FAQ

FAQ

Why not just put the whole decision in the error message?

Because prose invites interpretation. Models read the message, and two similar sentences can suggest opposite actions. A constrained enum plus a per-value description gives the model one reading per class, and the retryable boolean gives it one check before acting. The message stays for humans and debugging, but the class drives the decision.

What is the difference between retryable false and escalate?

retryable: false only says the identical call will not succeed. The model still has a choice: repair the argument and call again, or stop and escalate. The intended action lives in the enum description, which is why each class description must name repair, yield or escalate explicitly. The flag alone cannot carry that.

How many error classes should a tool expose?

Start from the HTTP retry and non-retry tables plus three domain classes: conflict, partial_execution and your own business-rule rejection. Temporal's recipe is a good baseline for the HTTP set docs.temporal.io. Cap the enum at a size a model can reason over, and split a class only when the intended action differs.

Do I need to change my API to return these classes?

Usually not. The mapping from HTTP status and body to a class belongs in the tool wrapper, where you also set the retryable flag and extract the failing field. Classifying by status code alone is insufficient because the same 401 can mean an expired token or a revoked one, and those need different responses scalekit.com.

How do I keep the eval honest as the schema changes?

Pin the seeded failures to the class names, not to the messages, and version the fixture set alongside the schema. Re-run on every enum edit, and keep a small held-out set of edge cases you do not tune against. Score per class, since a blended number can hide one class regressing while the rest improve.

Sources

Sources

  1. AI Agent Tool Design: What Works and What Doesn't
  2. Tool Call Failures in Production: Debug and Prevent Guide
  3. Replan, Don't Retry: Why Most Agent Errors Aren't Transient
  4. LLM Tool Calling Error Handling: Retries and Fallbacks
  5. Choosing the Right HTTP Status Codes for Your API
  6. HTTP response status codes - MDN Web Docs - Mozilla
  7. Retry policy from HTTP responses | AI Cookbook
  8. LLM Tool Schema Design: Inputs, Outputs & Error Handling
  9. Handling HTTP Errors in AI Agents: Lessons from the Field
  10. Schema Evolution and Compatibility for Schema Registry ...
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles