Error as Observation
Also known as: recoverable tool failures, self-healing tool loop
Feed tool failures back into the loop as structured, actionable text so the model can correct course instead of the run dying.
The problem
The default failure mode of a tool is an exception, and an exception in an agent loop ends the run. That is a poor trade: most tool failures are recoverable — a malformed argument, a missing record, a rate limit, a transient timeout — and the model is capable of fixing them if it is told what happened. Instead the user sees a generic error after the system has already spent real money on the turns leading up to it.
The pattern
Catch failures at the tool boundary and return them as structured observations describing what failed, why, and what a successful call would look like. The model reads the observation on the next turn and adjusts. Pair this with a retry budget per tool and a distinction between recoverable failures, which go back to the model, and terminal ones, which stop the run cleanly.
Use when
- Any agent loop with tools that can fail — which is all of them
- Tools take model-generated arguments that may be malformed or out of range
- Downstream services are flaky, rate-limited, or occasionally slow
- You are seeing runs die on failures a human would trivially recover from
Avoid when
- The failure is genuinely terminal — revoked credentials, a deleted resource, a hard permission denial
- Retrying costs real money or has side effects, where a blind retry is worse than a clean stop
- The tool has already failed identically twice; a third attempt is a loop, not a recovery
Tradeoffs
GainRuns survive the ordinary failures that make up most production tool errors
CostFailed attempts consume turns, tokens, and latency before the eventual success
GainError text becomes a teaching channel — the model learns your API within the run
CostPoorly worded errors send the model down worse paths than an honest failure would
GainTerminal-vs-recoverable classification gives you clean, attributable stop reasons
CostEvery tool now needs its failure modes enumerated and classified, not just handled
An agent loop is a control loop, and a control loop needs feedback. An exception is feedback that goes to your logs; an observation is feedback that goes to the thing that can act on it.
Classify before you return
Not every failure should go back to the model. Split them into three classes. Recoverable failures — bad arguments, empty results, transient timeouts — return as observations with a hint. Retryable-in-place failures — rate limits, 503s — are retried inside the tool with backoff and only surface if they exhaust the budget. Terminal failures — auth revoked, resource permanently gone, permission denied — stop the run, because no rephrasing by the model will fix them.
class Terminal(Exception):
"""No amount of model retrying will help. Stop the run."""
def safe_dispatch(name: str, raw_args: str, attempt_log: Counter) -> str:
key = f"{name}:{hashlib.sha1(raw_args.encode()).hexdigest()[:8]}"
if attempt_log[key] >= 2:
# Same tool, same arguments, third time. The model is stuck in a loop.
return json.dumps({
"error": "repeated_failure",
"message": f"{name} failed twice with these arguments. Try a different approach or ask the user.",
})
attempt_log[key] += 1
try:
return dispatch(name, raw_args)
except ValidationError as e:
return json.dumps({"error": "invalid_arguments", "details": format_errors(e)})
except NotFound as e:
return json.dumps({"error": "not_found", "message": str(e), "hint": e.hint})
except RateLimited as e:
return json.dumps({"error": "rate_limited", "retry_after_s": e.retry_after})
except (AuthRevoked, PermissionDenied):
raise Terminal(f"{name} is not authorized for this user.")Write errors the model can act on
A good tool error has three parts: a stable machine-readable code, a human-readable explanation, and a hint stating what a working call would look like. The hint carries most of the value. "not_found" tells the model it failed; "no order matches ORD-9912; order ids look like ORD-2026-NNNNN, or search by customer email instead" tells it what to do next.
# Each of these is a next action the model can actually take.
{"error": "invalid_arguments",
"details": ["earliest: must include a UTC offset"],
"hint": "Use 2026-03-04T09:00:00-05:00 rather than 2026-03-04 09:00."}
{"error": "no_results",
"message": "No orders in the last 30 days for that email.",
"hint": "Widen days_back, or confirm the email with the user."}
{"error": "ambiguous",
"message": "3 customers match 'J. Chen'.",
"candidates": [{"id": "c_18", "email": "j.chen@…"}, "…"],
"hint": "Call again with customer_id, or ask the user which one."}Ambiguity is a failure worth designing for explicitly. Returning candidates plus a hint turns a dead end into either a correct second call or a good clarifying question to the user — both far better than the tool silently picking the first match.
Detecting the stuck loop
The characteristic failure of this pattern is an agent that retries the same call forever, each time reading the same error and making the same mistake. Track a fingerprint of tool name plus arguments and cut it off after two identical failures with an observation that explicitly tells the model to change strategy or escalate to the user. That single guard prevents most runaway-cost incidents in tool-heavy agents.
- Three classes: recoverable (observe), retryable (backoff inside the tool), terminal (stop)
- Every error carries a code, a message, and a hint at the corrected call
- Fingerprint call attempts and break the loop after two identical failures
- Cap error payload size — a 5k-token error is a context leak, not a hint
- Count recoverable errors per tool as a metric; a spike is a schema or upstream problem
Do not return raw upstream error bodies. They are verbose, they leak internal hostnames and identifiers into the model's context, and they are frequently phrased in ways that mislead the model about what it can retry.