AI agents

Designing Reason Codes for Agent Refusals: A Taxonomy That Scales

Antonio Evans9 min read

A clean flowchart on a whiteboard: three columns labeled 'Policy', 'Capability', 'Context', each with a list of short reason codes (e.g., 'POL-01: Restricted content', 'CAP-03: Tool unavailable'.
Key points

Key points

  1. 1A reason code is only useful if it maps to exactly one downstream action: retry, escalate, or terminate.
  2. 2A three-level taxonomy (policy, capability, context) keeps codes mutually exclusive and gives you a testable coverage rule.
  3. 3Codes belong in a typed schema with a fixed enum, so a malformed refusal fails validation instead of reaching your routing logic.
  4. 4Validating the taxonomy against a fixture set of refusal scenarios catches overlap and gaps before production traffic does.

Start with the action, not the label

Most teams write reason codes the way they write error messages: as prose. An agent declines a request and the log says something like "I can't do that" or "outside my scope." Those strings are readable and useless. Nothing downstream can branch on them.

The design fix is to invert the order. Decide what your system is allowed to do with a refusal before you name anything. In practice that set is small: retry the step (possibly with a repaired input), escalate to a human or a different agent, or terminate the task. Everything else is noise.

This matches how distributed systems already treat failures. Temporal's non-retryable error pattern exists exactly because retrying a permanent failure wastes time and burns backoff delays for no gain: an invalid input or a missing record fails the same way on attempt one, two, and three (Temporal docs). AWS's guidance on retries says the same thing from the network side: retries help with partial and transient failures, and hurt when the failure is structural (AWS Builder Center).

So the decision rule is: a refusal gets a reason code only if the code changes what happens next. If two codes always produce the same action with the same parameters, they are one code.

A worked comparison makes the cost concrete. Suppose your agent handles 10,000 tasks a day and declines 4% of steps, so 400 refusals. If 60% of those are labeled with a generic "unable to proceed," you have 240 cases a day that a human must read to decide the action. If the taxonomy splits those into retryable (say 70%), policy blocks (20%) and true unknowns (10%), only 40 cases a day reach a queue, and the other 200 resolve automatically.

What makes a reason code good

Four properties carry the weight.

Actionable. Each code resolves to one action with fixed parameters. CONTEXT_AMBIGUOUS means "re-ask with a clarifying prompt, once." CAPABILITY_TOOL_UNAVAILABLE means "retry with backoff, then escalate if the budget is spent." A code that could mean either is broken.

Mutually exclusive. Exactly one code applies to any given refusal. If a request breaks a policy rule and is also ambiguous, you need a precedence order, stated in the taxonomy, so two reviewers classify it identically.

Exhaustive within scope. Every refusal your agent can produce maps to a code. Gaps show up as a fallback bucket, and a growing fallback bucket is a signal the taxonomy has drifted.

Stable. Codes are identifiers, not sentences. Changing the wording of a human-readable description must never change the code. Version the enum, not the string.

The structured-output literature explains why this matters mechanically. A schema-valid object can still violate domain rules; validation belongs after generation, and a typed contract is what separates a parseable response from a correct one (TrueFoundry). A refusal object is no different: shape is not truth, but without shape you cannot even start checking truth.

The three-level taxonomy

Split every refusal into one of three levels. The level tells you where the fix lives, which is what makes the action mapping deterministic.

LevelQuestion it answersTypical codesDefault action
PolicyDoes a rule forbid this, regardless of capability?POLICY_RESTRICTED_DATA, POLICY_UNAUTHORIZED_ACTION, POLICY_RATE_LIMITEDTerminate (or escalate for an owner override)
CapabilityCan the agent do this with the tools and knowledge it has?CAPABILITY_TOOL_UNAVAILABLE, CAPABILITY_TOOL_ERROR, CAPABILITY_KNOWLEDGE_GAPRetry, then escalate
ContextIs the input clear and complete enough to act on?CONTEXT_AMBIGUOUS, CONTEXT_MISSING_FIELD, CONTEXT_CONFLICTING_INSTRUCTIONSRetry with repair, then escalate

Policy refusals are the cheapest to handle and the most dangerous to retry. Retrying a forbidden action is at best wasted work and at worst a privilege-escalation pattern. The security framing is blunt: an agent that keeps searching for alternate paths after a blocked request has crossed from recovery into probing (NHI Mgmt Group). Terminate, log, and stop.

Capability refusals are where retries earn their keep. A tool that timed out may succeed on the next call. A tool that does not exist will never succeed, so CAPABILITY_TOOL_UNAVAILABLE needs its own retry budget rather than the general one. Note the failure mode documented in agent benchmarks: in one ReAct-style evaluation, 466 of 513 retry attempts were spent on hallucinated or nonexistent tool names (tianpan.co). Those retries were not recovery; they were the failure wearing a disguise. A distinct code lets you cap them.

Context refusals are repair problems. The input was ambiguous, a required field was missing, or the instructions contradicted each other. Rewriting the prompt or asking a clarifying question is the action. Retrying the identical call is not.

The precedence rule that keeps these exclusive: evaluate policy first, then capability, then context. A request that is both forbidden and ambiguous is a policy refusal, because no amount of clarification makes it allowed.

Preventing overlap and gaps

Two failure modes wreck taxonomies: codes that overlap and codes that are missing.

Overlap shows up as disagreement between reviewers. Give two engineers the same 50 refusal transcripts and see whether they assign the same code. If they agree on fewer than, say, 90%, you have overlapping definitions, not a training problem.

Gaps show up as the fallback bucket. Instrument it. If UNCLASSIFIED exceeds a few percent of refusals, you have a code to add.

The mechanism that prevents both is the typed contract. Define the codes as an enum in a JSON schema so a malformed refusal fails validation at the boundary, not in your router. The structured-output guidance is explicit that native schema enforcement narrows the interface but does not eliminate refusal, truncation, or fallback behavior, so validate on your side too (Agenta). Grammar-constrained decoding nearly eliminates syntax errors but leaves structural and semantic errors intact, which is why the enum helps and still needs a semantic check (arXiv).

{
  "type": "object",
  "required": ["reason_code", "level", "detail"],
  "additionalProperties": false,
  "properties": {
    "reason_code": {
      "type": "string",
      "enum": [
        "POLICY_RESTRICTED_DATA",
        "POLICY_UNAUTHORIZED_ACTION",
        "POLICY_RATE_LIMITED",
        "CAPABILITY_TOOL_UNAVAILABLE",
        "CAPABILITY_TOOL_ERROR",
        "CAPABILITY_KNOWLEDGE_GAP",
        "CONTEXT_AMBIGUOUS",
        "CONTEXT_MISSING_FIELD",
        "CONTEXT_CONFLICTING_INSTRUCTIONS"
      ]
    },
    "level": { "type": "string", "enum": ["policy", "capability", "context"] },
    "detail": { "type": "string", "maxLength": 280 }
  }
}

Reject additionalProperties and free-form codes. A model that invents POLICY_TOO_HARD should fail the contract and route to the unclassified bucket, where you can see it, rather than silently reaching your action table.

The orchestration literature makes the same point at the agent-to-agent boundary: define typed schemas for every handoff so a malformed payload fails validation instead of silently corrupting downstream state, and treat schema violations as contract failures routed to retry, repair, or escalation (MLflow). A refusal is a handoff.

Mapping codes to deterministic actions

With the taxonomy fixed, the action table is a pure function: code plus attempt count plus budget state in, one action out. Keep it in code, not in a prompt.

CodeFirst occurrenceAfter retry budgetNotes
POLICY_RESTRICTED_DATATerminateTerminateNever retry; log for audit
POLICY_UNAUTHORIZED_ACTIONEscalateTerminateOwner may approve once
POLICY_RATE_LIMITEDRetry with backoffEscalateHonor Retry-After when present
CAPABILITY_TOOL_ERRORRetry with backoffEscalateRetry only if idempotent
CAPABILITY_TOOL_UNAVAILABLERetry, capped lowEscalateSuspect hallucinated tool name
CAPABILITY_KNOWLEDGE_GAPEscalateTerminateRoute to a specialist or human
CONTEXT_AMBIGUOUSRetry with clarificationEscalateOne clarification attempt only
CONTEXT_MISSING_FIELDRetry with repair promptEscalateName the field in detail
CONTEXT_CONFLICTING_INSTRUCTIONSEscalateTerminateHuman must pick a side

The retry path needs its own discipline. Exponential backoff with jitter is the standard prescription: spread retries so many clients do not retry in lockstep, and cap attempts and elapsed time (web-alert.io). PostGrid's documentation of the same pattern recommends retrying up to five times over roughly a 30-minute window with an idempotency key so a retried create does not duplicate (PostGrid).

The subtlety specific to LLM agents is that retries are not free. Agent frameworks commonly retry by replaying the conversation history, so a retry costs the cumulative tokens so far, not one step's worth. On a three-step agent with a 20% per-step retry rate, that pushes token cost to roughly 1.7 to 1.9 times baseline; on a five-step agent, to 2.2 to 2.5 times (tianpan.co). Denominate your retry budget in tokens, not attempts, or a cheap-looking retry rate becomes a doubled bill.

The tri-state distinction matters here too. Confirmed failure (explicit rejection before any state change) is safe to classify and often safe to retry. Indeterminate outcome (a timeout after the request may have been applied) is not a refusal at all; it is an unknown that needs a reconciliation query before any retry (exemplary.ai). If your taxonomy tries to code a timeout as CAPABILITY_TOOL_ERROR, you will retry a write that may already have landed.

Validating the taxonomy against a fixture set

A taxonomy is a hypothesis. The fixture set is the test.

Build the fixtures. Collect 100 to 200 refusal transcripts from real traffic, staging runs, and adversarial prompts. Include the boring cases (a tool that returns 503), the policy cases (a request for restricted data), the ambiguity cases (a form with a missing account number), and the nasty ones (a request that is forbidden and ambiguous).

Label them twice. Two people label independently, then reconcile. Disagreements reveal overlapping codes. Write the precedence rule into the taxonomy document and re-label.

Run the classifier. Feed each fixture through the real agent and check two things: that the output validates against the schema, and that it carries the label your team assigned. Track three numbers:

MetricWhat it testsFailing signal
Agreement rateMutual exclusivityBelow 90%: overlapping definitions
Unclassified rateCoverageAbove 5%: missing code
Action correctnessThe mapping itselfAny mismatch: action table bug

Test the actions, not just the codes. For each fixture, assert that the resolved action matches the table. This catches the case where a code is right but the retry budget is miswired. For retry fixtures, advance a mock clock and confirm the backoff schedule fires and the budget stops the loop; testing retry logic without controlling time is guesswork, which is why controllable failure injection belongs in the fixture harness (SystemDR).

Re-run on every prompt or model change. The refusal distribution shifts when the model changes, and a code that was rare becomes common. Regression-test the taxonomy the same way you test any other output contract. This is exactly the operational discipline implied by treating schema violations as contract failures with explicit retry, repair, and escalation routes (MLflow).

One more fixture class pays for itself: the wrong-looking-but-correct case. An agent that declines a legitimate request because it misread the policy should map to a code that surfaces in a review queue, not to a silent terminate. Otherwise your metrics look clean while users quietly leave.

Where this connects to your stack

If you are running agents across several providers, the refusal object is one of the few things that must stay identical everywhere. Quanta Inference puts text, image, video, audio and embedding models behind one OpenAI-compatible API key with prepaid pricing in US dollars (https://quantaintelligence.ai/inference), which means one schema and one action table rather than a per-provider translation layer.

Two practical follow-ons. First, keep the action table outside the model. Whether the model is a frontier reasoning system or a small fast one, the routing decision is deterministic code and should be reviewed like code. Second, when the taxonomy keeps growing because your agents cover more business processes, that is a sign the refusal logic has become a governance artifact, and governance artifacts deserve owners rather than a solo maintainer.

A short checklist before you ship

  • Every code maps to exactly one action with fixed parameters.
  • Every code has a stated level: policy, capability, or context.
  • Precedence rule is written down: policy, then capability, then context.
  • The enum is enforced by a schema at the boundary and rejects unknown codes.
  • The fallback bucket is instrumented and alerted on.
  • Retry budgets are denominated in tokens or context, not just attempts.
  • Indeterminate outcomes are not coded as refusals.
  • The fixture set covers all three levels, plus at least one doubly-qualified case.
  • Agreement, unclassified and action-correctness metrics run in CI.
FAQ

FAQ

How many reason codes should a first taxonomy have?

Aim for nine to twelve. Fewer and your action table hides real distinctions; more and reviewers stop agreeing. Start with three codes per level and add only when the unclassified or disagreement metric forces it.

Can a refusal carry more than one reason code?

No, if the code drives an action. Two codes make the action ambiguous, which defeats the purpose. Record secondary details in a free-text field, but resolve to one canonical code using the policy-capability-context precedence order.

What should happen when the model returns a code that is not in the enum?

Treat it as a contract failure and route to escalation, not to the model again. Retrying with an invalid code usually produces another invalid code, and the original refusal may already indicate a policy block that should not be re-attempted.

How do I handle a timeout where I do not know whether the action ran?

Do not code it as a refusal. Run a read-only reconciliation query using an idempotency key before deciding, and only then classify the outcome as a confirmed failure worth retrying or a success to commit (exemplary.ai).

Do reason codes apply to sub-agent handoffs too?

Yes, and they matter more there. A sub-agent that returns free-form text instead of a typed refusal forces the orchestrator to guess, and small ambiguities compound across a chain (MLflow). Use the same enum at every boundary.

Sources

Sources

  1. Non-Retryable Errors
  2. Timeouts, retries, and backoff with jitter
  3. LLM Structured Outputs with JSON Schema
  4. What Is Prompt-Failure Escalation? Definition & Examples
  5. Retry Budgets for LLM Agents: Why 20% Per-Step Failure ...
  6. The guide to structured outputs and function calling with ...
  7. Empirical Study for Structured Output Control in LLMs for ...
  8. One post tagged with "agent orchestration patterns"
  9. Exponential Backoff & Jitter: Stop Retry Storms (Code & Math)
  10. Response Codes and Retry Logic - Getting Started - ReadMe
  11. AI agent error handling: retry, resume, or escalate?
  12. How to Implement Exponential Backoff & Retry Logic (Rate- ...
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