Resources / agentic system design 04

What your agent should do after a checker says no.

A refund agent retried three times, escalated, and then refunded one customer three times over. No component had a bug. The system used one word, “error”, for three situations that need different responses.

3 failure classes · 4 ordered questions · 12 failure-injection tests · 45 minutes

Opens your browser’s print dialogue. Choose “Save as PDF”.

The page is the resource. Free to read, print, and run against a system you already own. There is a runnable simulation behind it: two orchestrators, the same injected faults, and the twelve tests below.

Published 16 September 2026

01 The story, in sixty seconds

Six steps, and not one of them is a bug.

An illustrative case, not a real incident. A customer uploads a receipt and asks for a refund of ₹1,200.

  1. The receipt lookup times out and returns an empty result. The system records “no receipt”.
  2. The agent recommends a refund. The checker rejects it: missing evidence.
  3. The orchestrator retries the model three times. Each attempt is more confident. All three are rejected and the task escalates. The lookup itself is never retried.
  4. On Monday a human opens the case, finds the receipt was there all along, and approves.
  5. The payment call times out. The same retry policy fires three times. Every call reached the provider.
  6. The customer is refunded ₹3,600. The orchestrator reports that the refund failed.

There was no bug.

No component misbehaved. The model never hallucinated. The system failed because it used one word, “error”, for three situations that need different responses.

02 One word, three situations

Three failures that need opposite responses.

missing_evidence

Something required to decide is genuinely absent. The read completed, and the answer is that there is nothing there.

Ask for it. Do not regenerate the answer.

temporary_failure

A dependency could not be reached, and no side effect occurred. Nothing was learned about the world.

Bounded retry of the tool, not of the model.

uncertain_action

An action with side effects may or may not have executed. A timeout on a write is always this.

Do not retry. Reconcile state first, then decide.

Plus two terminal outcomes. escalate — a named human or other owner must decide. stop — no justified path remains.

The root cause is a tool contract. The receipt lookup returned an empty value for both “there is no receipt” and “I could not look”. One return value, two different facts about the world, opposite correct responses. Every other failure in the story follows from that single collapse.

03 The triage tree

The order is the whole design.

  1. Q1

    Could an action with side effects already have executed?

    yes → uncertain_action

    Reconcile by idempotency key. Re-issue nothing.

  2. Q2

    Is required evidence genuinely absent?

    yes → missing_evidence

    Ask the requester once. Do not regenerate.

  3. Q3

    Is a dependency temporarily unavailable?

    yes → temporary_failure

    Retry the tool, inside the task budget.

  4. Q4

    Can a named owner resolve it?

    yes → escalate / stop

    An owner, or an honest halt. Never a queue nobody reads.

Question 1 has two halves. A confirmed prior execution is not uncertainty — it is a policy conflict, and confirmed state beats any approval. Only an unconfirmed outcome is uncertain_action.

Side effects are asked about first, and the asymmetry is the argument. Misclassifying an uncertain action as a temporary failure invites a bounded retry, and retrying something that already ran is how one refund becomes three. The mistake in the other direction costs you a person looking at a case that did not need one. One is expensive and silent. The other is slow and visible.

04 The response playbook

One row per class, including the wrong answer that looks right.

Class Signal Tempting wrong response Correct response Owner Stop condition
missing_evidence A read completed and returned nothing. A checker names an item it could not find. Ask the model again, with a firmer instruction. Ask the requester for the named item, once, saying exactly what and how. The requester, until they answer. Then the task. Asked once and no answer inside the response window.
temporary_failure On a read: a timeout, a refused connection, a 429, a 503. On a write: only a refusal the provider confirms never ran, such as a connection refused or a rate limit returned before processing. A timed-out write is never this. Record the read as empty and carry on with what you have. Retry the same call, with backoff and jitter, honouring any Retry-After, inside the task budget. The task, while budget remains. Budget spent, or the wait exceeds the task deadline.
uncertain_action A call that can change something outside the system ended without a confirmed outcome. Treat it as a failure and try again. Query provider state by idempotency key. Decide on the confirmed answer, and only then. The task while state is readable. A named human the moment it is not. State cannot be read. Escalate carrying the key; never guess in either direction.

The tempting column is not a straw man. Each one is the shortest correct-looking line of code at that branch, which is why it is what gets written.

05 The rejection record

What a rejection has to carry to be worth reading.

A rejection that says “error” tells the next reader nothing, and the next reader is usually a retry loop. A record is what turns a rejection into a decision somebody else can make.

Field What it holds
stage Where in the workflow it happened, not which function threw.
failure_class One of the five. This is what decides the response.
detected_by A rule, a model, a tool or a human — and which one.
missing_evidence[] Each item, what requires it, and how to obtain it.
side_effects[] Each action, its target, its idempotency key, and its state: executed, not_executed or unknown. Never a guess.
permitted_next_actions[] What may happen next.
prohibited_actions[] What must not. For an uncertain action this always includes re-issuing it until reconciliation completes.
retry.what_changes_next_attempt Required whenever another attempt is planned. A retry with this blank is a retry nobody can justify.
next_step ask_user, retry_tool, reconcile, escalate or stop.
owner Who is accountable for that next step.

A record from the worst case in the kit

The refund timed out after the money moved, and the provider’s status endpoint is down. This is what the corrected design wrote.

{
  "record_id": "task-CASE-7731-R02",
  "task_id": "task-CASE-7731",
  "attempt": 2,
  "timestamp": "2026-09-16T09:00:00+05:30",
  "stage": "action.refund",
  "failure_class": "uncertain_action",
  "detected_by": { "type": "rule", "name": "reconciliation" },
  "reason": "the action may have executed and provider state cannot be read",
  "missing_evidence": [],
  "side_effects": [
    {
      "action": "refund",
      "target": "payment-provider",
      "idempotency_key": "task-CASE-7731:refund",
      "state": "unknown"
    }
  ],
  "permitted_next_actions": [
    "escalate with the idempotency key so a human can reconcile"
  ],
  "prohibited_actions": [
    "re-issue the action",
    "report the refund as failed",
    "report the refund as complete"
  ],
  "retry": {
    "attempts_used": 1,
    "max_attempts": 8,
    "deadline": "2026-09-16T09:02:00+05:30"
  },
  "next_step": "escalate",
  "owner": "refunds-duty-officer",
  "escalation": {
    "queue": "refunds-manual-review",
    "reason": "refund outcome unknown and the provider status endpoint is unavailable; reconcile by idempotency key before any re-issue",
    "respond_by": "2026-09-16T10:00:00+05:30"
  }
}
  • The three prohibitions are the useful part. Two of them forbid reporting an outcome, in either direction, because both would be a guess.
  • The idempotency key travels with the record. Without it the person who picks this up has nothing to reconcile against.
  • state is “unknown”, not “failed”. The money did move. The system simply cannot see that yet, and says so.
06 Retry budgets by tool type

What may be retried automatically, and what must not.

Starting points, to be tuned against your own latency and cost budgets. None of these is a universal number, and a kit that handed you one would be lying about how much it knows about your system.

Tool type Automatic retry Notes
Read (idempotent) Yes — a small bounded number Exponential backoff with jitter. Honour Retry-After when the provider sends one. Respect the overall task deadline, which outranks the retry count.
Write with an idempotency key Only under the same key A new key is a new action. This is the single line of code behind the third refund.
Write without an idempotency key No Query state first, then decide. There is nothing else you can safely do.
Irreversible or external (payments, emails, tickets) No Reconcile first, every time. An email cannot be unsent and a ticket cannot be unfiled.
Model generation Only when the input has changed New evidence, or a new instruction. An identical input returns the same kind of answer at extra cost, and usually at higher confidence.

Retries multiply across layers, and nobody writes the product down.

The tool client tries three times. The orchestrator sees one failure and tries three times. Nine calls reach the provider, and no file in the repository contains the number nine. Each layer’s number looks reasonable on its own, which is exactly why it survives review. The fix is not smaller numbers at each layer. It is one budget per task — attempts, wall-clock and cost — that every layer draws from, so an inner retry spends the same allowance as an outer one.

Test T08 in this kit measures it: against a task budget of four attempts, the two-layer design makes nine calls and the shared-budget design makes four.

07 Reconciliation checklist

For an action that may or may not have executed.

Run this the moment a side-effecting call ends without a confirmed outcome. In order, and without skipping the first one.

  1. Do not re-issue the action.

    Nothing you know yet justifies it. This is the step people skip under time pressure, and it is the one that costs money.

  2. Query provider state by idempotency key.

    Not by customer, not by amount, not by timestamp. The key is the only handle on the specific thing that might have run. Most providers expire keys after a day or so, so keep your own reference beside it for anything reconciled later than that.

  3. Decide on the confirmed state, not on the timeout.

    Confirmed executed: close against the existing reference. Confirmed not executed: re-issue under the same key.

  4. If state cannot be read, stay uncertain and escalate.

    An unreadable status does not turn an unknown outcome into a known one. Escalate carrying the key, and report neither success nor failure.

  5. Record the outcome, including the unresolved ones.

    The record is what makes the next attempt at this task, by a machine or a person, start from what is already known.

  6. Make the action idempotent before the next incident.

    Reconciliation is the workaround. An idempotency key on every write is the fix, and it is a smaller change than it looks.

08 A second opinion is not proof

A checker approves the plan. It has not read the world.

A model checker is a second opinion about a recommendation. It sees the same evidence the first model saw, reasons over the same text, and returns a verdict about whether the plan is sound. That is genuinely useful, and it is not the same thing as knowing what has already happened.

Two models agreeing that a refund is justified tells you nothing about whether a refund has already been issued. Neither of them queried the ledger. Adding a third would not help, and the agreement between them reads as confidence while adding no information at all.

Inspect this instead, before any external call

  • Provider state for this idempotency key, immediately before the call.
  • The task’s own record of side effects, including any marked unknown.
  • Whether another task holds a claim on the same case.
  • Whether the action is inside its deadline and its budget.

Test T09 is the one to steal. It approves a refund the ledger already holds, and a design that issues it fails. Confirmed state beats a verdict, every time.

The cost is honest: checking state before every external call is one extra read on every call. Relying on the idempotency key alone is cheaper, safe within a task, and blind to what another task already did.

09 Twelve failure-injection tests

Run against both designs, and these are the real verdicts.

Twelve injections, each stating what it injects, the behaviour a correct design shows, and the smell a failure points at. Run against both designs. Every verdict below is a real run.

ID What is injected Expected behaviour Naive Corrected
T01 The receipt read times out. The receipt is in the store the whole time. temporary_failure. The read is retried, the model is not re-prompted, the requester is not asked. fail pass
T02 The receipt is genuinely absent. The store answers, and the answer is no. missing_evidence. Ask the requester once. Zero model regenerations. fail pass
T03 The checker rejects twice with an identical reason and nothing has changed. No third identical attempt. Escalate with a record. fail pass
T04 The payment times out after the provider commits. uncertain_action. Status shows it executed. No re-issue. Exactly one refund. fail pass
T05 The payment times out before the provider commits. Reconciliation shows not executed. Re-issued under the same key. Exactly one refund. fail pass
T06a Rate limited with Retry-After 30s, against a 120s task deadline. Waits the 30 seconds it was asked for, then re-issues under the same key. fail pass
T06b The same rate limit asking for 300s, against the same 120s deadline. No wait, no re-issue, escalate. The deadline outranks the instruction. fail pass
T07 The document store is down beyond the retry budget. Retrying stops. The work lands in an escalation queue with an owner and a full record. fail pass
T08 The tool client and the orchestrator are both configured to retry. Total attempts never exceed the task budget. No three-by-three amplification. fail pass
T09 The checker approves a refund the ledger shows already happened. The external action is blocked. State wins over the verdict. fail pass
T10 The payment times out and the status query is also unavailable. Stays uncertain_action. No re-issue. Escalates. Never assumes failure. fail pass
T11 The requester uploads the receipt after being asked. Resumes with the new evidence. The attempt counter reflects that the input changed. fail pass
T12 The refund executes but the confirmation email fails. Only the email step is retried. The refund is never re-issued to fix an email. fail pass

The naive design passed none of the thirteen. The corrected design passed all thirteen. T06 is split into two halves because the behaviour differs either side of the task deadline.

The failures are worth reading as assertions rather than as a column. T08 reported nine calls against a budget of four. T06a slept on its own guessed backoff and ignored the Retry-After the provider sent. T09 left two refunds in the ledger where the checker had approved one. T12 never retried the email, and a shared retry wrapper would have re-issued the refund to fix it.

Every assertion reads the payment provider’s own record of what happened, never what the orchestrator believed. The naive design believes it refunded nobody. That gap is the story.

10 Your Monday exercise

Forty-five minutes against a system you already own.

It produces a list, not a refactor, and it needs no planning meeting to start. Bring the page, a whiteboard and whoever knows the tool surface.

  1. 5 min

    Pick one agent and list its tools.

    The one in production, not the one you are proud of. Write every tool it can call.

  2. 10 min

    Classify each tool by type.

    Read, write with an idempotency key, write without one, or irreversible and external. Most lists have more in the last two categories than the team expects.

  3. 15 min

    Check each tool’s contract against three questions.

    Can the caller tell “not found” from “could not look”? Can it tell “did not run” from “may have run”? Does a failure ever arrive as an empty value or a bare exception?

  4. 10 min

    Run T01, T04 and T08 against it.

    A read that times out while the data is present. A write that times out after it commits. A retry configured at two layers. Three injections, whatever your test harness is.

  5. 5 min

    Write down what you found.

    One line per tool that cannot distinguish an outcome, and one line per retry you cannot account for. That list is the work, and it is usually shorter than the team fears.

The most common finding is not a missing retry. It is a tool that has been returning an empty value for two different situations since the day it was written, and a caller that has been treating them the same ever since.

11 What this does not cover

This classifies failures. It does not decide what the agent is allowed to do.

Triage tells you what to do after something went wrong. It says nothing about which steps should have been handed to an agent in the first place, or what an action costs to undo. Those are a separate question, and there is a separate worksheet for them.

The simulation, the JSON Schema for the rejection record, the retry-budget policy and the twelve tests ship as a repository alongside this page. It is not public yet, and this line becomes a link when it is.

The refund case on this page is illustrative. It is not a client engagement, not an incident report, and no organisation, system or figure on this page refers to a real one. The code, the tests and the results are real and reproducible.