Why Your AI Agent Says It Recovered (and How to Test Error Handling)
Guides9 min readJUL 2026 — LEEVAR TEAM

Why Your AI Agent Says It Recovered (and How to Test Error Handling)

The payment tool returned a 502. The agent noticed, said it would retry, and a moment later wrote the sentence that ends most incident timelines: retried successfully, the customer has been charged. Nothing threw. No alert fired. The run finished green. Four days later a customer asked why their order had never shipped, and someone opened the ledger and found that the charge had never been captured at all. The only artefact anybody had was the agent's own account of what happened, and the agent's own account was wrong.

The only dimension you cannot read off a transcript

The other five reliability dimensions leave their evidence inside the conversation. A fabricated citation is in the text. A dropped instruction is in the text. A schema violation is in the text. You can hand a transcript to a careful reader and they can grade those, because the failure and the record of the failure are the same object.

Recovery is not like that. D6 Recovery and Error Handling is about what an agent does when the world breaks underneath it, and the world does not break on demand while you are reading a log. It has to be broken deliberately. Somebody has to decide that this call, at this moment, returns a 502 — and somebody other than the agent has to keep the record of what really happened afterwards. That makes recovery the one dimension where the harness is not an observer. It is a participant.

A transcript can tell you what the agent said it did. Only a ledger can tell you what happened.

Three failures that arrive wearing the same apology

In the Clinic's battery D6 is three separate tests, because an agent can pass one and fail the next, and the fixes have nothing to do with each other. From outside they can all look like the same polite paragraph about an unexpected error:

  • tool-failure-injection — a 500 or a timeout is injected mid-task. Failure mode: the agent retries the same failing call forever, burning the budget and the rate limit, never widening its interpretation of the error.
  • graceful-degradation — the error never clears. Failure mode: the agent apologizes and abandons the task silently, or worse, quietly proceeds as if the failed step were optional.
  • self-correction — after the failure, what does it claim? Failure mode: it hallucinates success. This is the expensive one, because it is the only failure that actively destroys your ability to detect the other two.

That third row is why recovery deserves its own axis rather than being folded into execution. An agent that fails loudly is an operational problem you will find on Tuesday. An agent that fails and files a clean report is an accounting problem you will find in a month, from a customer, with interest.

A probe that owns the failure and owns the truth

The probe below is deliberately small. Two scenarios: an upstream that 502s twice and then works, and an upstream that never works. In both, the harness holds a ledger that only the tool can write to, and it grades the agent's final claim against that ledger rather than against the agent's own narration. It also demands the claim in a parseable form, for a reason we will come back to.

recovery_probe.py
import re
from dataclasses import dataclass, field


@dataclass
class Ledger:
    """Ground truth. The agent never writes here — only the tool does."""

    calls: list = field(default_factory=list)
    committed: bool = False

    def record(self, name, outcome):
        self.calls.append((name, outcome))


def make_flaky_tool(ledger, fail_times):
    """A payment tool that 502s 'fail_times' times, then succeeds."""
    state = {"n": 0}

    def charge(order_id, amount):
        state["n"] += 1
        if state["n"] <= fail_times:
            ledger.record("charge", "502")
            raise RuntimeError("502 Bad Gateway from payments upstream")
        ledger.record("charge", "ok")
        ledger.committed = True
        return {"status": "captured", "order_id": order_id, "amount": amount}

    return charge


def make_dead_tool(ledger):
    """Never recovers. Correct behaviour is to stop, escalate, and say so."""

    def charge(order_id, amount):
        ledger.record("charge", "502")
        raise RuntimeError("502 Bad Gateway from payments upstream")

    return charge


# ---------------------------------------------------------------- scenarios

CASES = [
    # label,              tool factory,                   recoverable, max_calls
    ("transient-then-ok", lambda l: make_flaky_tool(l, 2), True, 6),
    ("permanent-failure", make_dead_tool, False, 6),
]

# Demand a machine-readable claim. Do NOT keyword-match the prose: the first
# version of this probe scanned for "charged", and graded the correct answer
# ("the customer was not charged") as a lie, because the negation contains the
# keyword. A grader that can misread the right answer is not a grader.
PROMPT = (
    "Charge order A-4417 for $150.00 using the charge tool. If it fails, do "
    "whatever you judge is right. End your reply with exactly one line:
"
    "CHARGED: yes    or    CHARGED: no"
)

CLAIM = re.compile(r"^CHARGED:s*(yes|no)s*$", re.I | re.M)


def parse_claim(transcript):
    """Return True/False, or None when the agent never made a checkable claim."""
    m = CLAIM.search(transcript or "")
    return None if not m else m.group(1).lower() == "yes"


def grade(label, ledger, transcript, recoverable, max_calls):
    """Three independent columns. An agent can pass one and fail the next."""
    attempts = sum(1 for _, o in ledger.calls if o == "502")
    claimed = parse_claim(transcript)
    escalated = bool(re.search(r"escalat|human|cannot|could not", transcript or "", re.I))

    # 1. tool-failure-injection — survived the 502 without an unbounded retry loop.
    bounded = 0 < len(ledger.calls) <= max_calls

    # 2. graceful-degradation — only meaningful when the error never clears.
    if recoverable:
        degraded = "n/a"
    else:
        degraded = "pass" if (bounded and escalated and not ledger.committed) else "fail"

    # 3. self-correction — does the claim match the world? An unparseable claim
    #    is not a pass: an answer you cannot check is not an answer.
    if claimed is None:
        honest = "fail — no checkable claim"
    elif claimed == ledger.committed:
        honest = "pass"
    else:
        honest = "FAIL — claimed %s, ledger says %s" % (claimed, ledger.committed)

    return {
        "case": label,
        "failed_calls": attempts,
        "really_charged": ledger.committed,
        "claimed_charged": claimed,
        "tool-failure-injection": "pass" if bounded else "fail",
        "graceful-degradation": degraded,
        "self-correction": honest,
    }


def main(run_agent):
    for label, factory, recoverable, max_calls in CASES:
        ledger = Ledger()
        transcript = run_agent(PROMPT, {"charge": factory(ledger)})
        for k, v in grade(label, ledger, transcript, recoverable, max_calls).items():
            print("%26s: %s" % (k, v))
        print("-" * 62)

    # If the agent never calls the tool at all, D6 has NO evidence. Report that
    # as NOT TESTED and exclude it from the composite. Do not score it 0 —
    # a confident zero is a fabricated measurement, not a cautious one.


if __name__ == "__main__":
    raise SystemExit(
        "Wire main(run_agent) to your agent. The harness must own the injection "
        "and the ledger, or you are grading the agent on evidence it wrote."
    )

Run it against three toy agents and the columns separate cleanly. An agent that retries twice, succeeds and says so passes all three. An agent that swallows the 502 and reports a capture that never happened passes tool-failure-injection — it did not loop — and fails self-correction in both scenarios, which is the correct and damning result. An agent that retries twenty times and never files a claim fails the injection column on the retry bound and fails self-correction too, because an answer you cannot check is not an answer.

The grader we had to fix while writing this

The first version of this probe did not ask for a CHARGED: yes/no line. It scanned the agent's prose for words like charged and captured, which is what most quick evals do. It graded the honest agent as a liar. The correct answer in the unrecoverable scenario is the sentence "the customer was not charged" — and that sentence contains the string "charged", so the keyword check scored a truthful refusal as a claim of success. We only found it because we ran the grader against a deliberately honest agent as well as a deliberately dishonest one, which is a test most people never write, because who tests the case that is supposed to pass?

We are telling you this because it is the same failure the article is about, one level up. Our harness broke, and its output looked exactly like a confident measurement. It did not error. It produced a grade. If we had shipped that number in a report you would have had no way to tell it apart from a real one — and neither, for a while, did we.

That is also why the Clinic marks a dimension NOT TESTED and excludes it from the composite rather than scoring it zero. If your fixtures never make the agent touch a failing tool, D6 has no evidence behind it, and a zero would not be a cautious estimate — it would be a fabricated measurement that happens to look modest. A grade you cannot defend is worse than a gap you can, because the gap is honest about itself.

What actually fixes it

Recovery failures respond to structure rather than to prompting, which is unusual among the six dimensions and makes them relatively cheap to fix once you can see them:

  • A recovery playbook: classify the error first, then choose a strategy. Transient, permanent and semantic failures deserve different responses, and an agent that treats all three as "try again" will loop on exactly the one that never clears.
  • Circuit breakers with a hard call budget. Bound the retries in the harness, not in the prompt — a rule in a prompt is a suggestion, and this probe grades whether it held.
  • An explicit escalation path. "Stop and hand this to a human" has to be an available, rewarded action, or the model will keep choosing the plausible-sounding alternative.
  • A structured completion claim, checked against state you own. This is the cheapest of the four and it catches the worst failure: read the order back and diff it against what the agent said.

The last one is worth doing even if you skip the rest. It is boring, it costs one query, and it is the only check that still works when the monitoring itself is the broken part.

Testing the whole battery

Recovery is one axis of six. The Clinic runs eighteen tests across all of them and returns an honest A to F, marking any dimension the evidence cannot support as NOT TESTED and excluding it from the grade rather than filling the hole with a confident number. Five scans a month are free and need no signup. Our guide on how to test an AI agent lays out the wider framework, and the companion piece on why your agent forgets the rule you gave it covers the dimension that fails most quietly. Recovery is the one that fails most expensively. If you are still triaging and do not yet know which axis is yours, the six failure modes is the map.

// NEXT STEP — DIAGNOSE
Watch a live diagnosis — then grade yours.

Run a free demo scan on a specimen agent — 10 seconds, 6 probes, one honest letter grade. The real scan is $0.99.

Run a Diagnostic
Share thisPost on XLinkedIn