Never Raise: Error Handling Patterns for Agent Tools
The model is your error handler
Normal error handling assumes the recipient is a developer reading a stack trace, or a retry policy that will try the identical call again. Neither applies inside an agent loop. The recipient is a language model that is mid-task, holds context you do not, and can take a different action if you tell it what went wrong.
That makes an error a message, not an exception. A tool that raises removes the model's chance to respond — the loop unwinds, the conversation is lost, and the user sees a failure for something the agent could very often have worked around. A tool that returns a structured error keeps the turn alive: the result goes into the message array, the model reads it, and it adjusts.
So the contract is: a tool never raises past the dispatch boundary. Every path returns a value. The distinction that matters is not success versus exception, it is 'here is your data' versus 'here is why you cannot have it and what to do about it'.
The shape of a good error
Three fields do almost all the work. A boolean the model can branch on. A statement of what went wrong in plain language. And — the field that separates useful errors from decorative ones — a hint saying what a reasonable next action would be.
The hint is the whole trick. 'Query failed' tells the model nothing and it will usually retry the same query. 'Query failed: no column named customer_name. Available columns: id, account_id, full_name, email' tells it exactly how to fix the call, and it will.
# Decorative
{"ok": False, "error": "ValidationError"}
# Useful
{
"ok": False,
"error": "No column named 'customer_name' in table 'orders'.",
"hint": "Available columns: id, account_id, full_name, email, total_cents, "
"placed_at. Did you mean 'full_name'?",
}Four error classes, four different responses
Not every failure should produce the same behaviour, and lumping them together is why some agents retry forever and others give up too early. It is worth classifying explicitly.
Recoverable-by-the-model. Bad arguments, a malformed query, a filter that matched nothing. The model can fix these itself given a good hint, and it usually will on the next turn. Return the error with the correction path and let the loop continue.
Recoverable-by-retry. A timeout, a 503, a rate limit. Retrying the same call may genuinely work, but the model is a bad retry controller — it costs a full turn and it cannot sleep. Handle these inside the tool with bounded backoff, and only surface an error if the retries are exhausted. When you do surface it, say so, because 'the service is down after three attempts' should stop the agent trying a fourth time through a different phrasing.
Not recoverable, but continuable. Permission denied, a record that does not exist, a feature the account does not have. The model must not retry, but it can still complete the task by another route or report a specific limitation to the user. Say plainly that retrying will not help.
Not recoverable, stop now. A missing credential, a misconfigured endpoint, a bug in your own code. These are operator problems, not model problems. Return an error that is honest about it and, if your framework supports it, set a flag that halts the loop rather than letting the agent flail for eleven more turns.
def call_billing_api(account_id: str) -> dict:
for attempt in range(3): # retry class, handled here
try:
r = http.get(f"/billing/{account_id}", timeout=10)
except TimeoutError:
time.sleep(2 ** attempt)
continue
if r.status_code == 404: # continuable
return {"ok": False,
"error": f"No billing record for account {account_id}.",
"hint": "The account may be on a legacy plan. Retrying "
"will not help; try get_account_profile instead."}
if r.status_code == 403: # continuable
return {"ok": False,
"error": "This API key cannot read billing records.",
"hint": "Do not retry. Tell the user billing data is "
"outside this assistant's permissions."}
if r.status_code == 401: # operator problem
return {"ok": False, "fatal": True,
"error": "Billing API credentials are invalid.",
"hint": "This is a configuration fault. Stop and report it."}
if r.ok:
return {"ok": True, "billing": shape(r.json())}
return {"ok": False,
"error": "Billing API did not respond after 3 attempts.",
"hint": "The service appears to be down. Do not retry this turn."}Validate arguments before you act
Models produce arguments that are well-formed but wrong: a date in the future when the tool wants a past one, a limit of 10000 against a cap of 50, an ID from a different tenant. Validating first and returning a precise complaint is far better than letting the underlying system produce a generic error you then have to translate.
Clamp when clamping is obviously right, and say that you did — silently returning 50 rows when the model asked for 10000 makes it believe it has the full set. Reject when the value is genuinely ambiguous. The rule is that the model must never end a turn with a false belief about what happened.
def list_events(since: str, limit: int = 50) -> dict:
try:
start = date.fromisoformat(since)
except ValueError:
return {"ok": False,
"error": f"'{since}' is not a valid date.",
"hint": "Use ISO-8601 date only, e.g. '2026-08-01'."}
if start > date.today():
return {"ok": False,
"error": f"'{since}' is in the future; no events exist yet.",
"hint": "Pick a date on or before today."}
capped = min(limit, 200)
rows = db.events(start, capped)
out = {"ok": True, "returned": len(rows), "events": rows}
if capped < limit: # tell the truth about the clamp
out["note"] = f"limit was reduced from {limit} to the maximum of 200."
return outStop the loop from spinning on the same error
Even with good hints, agents sometimes get stuck: the model tries a variation, gets the same error, tries another variation, and repeats. A turn budget eventually stops it, but it stops it expensively and without a useful answer.
A cheap and effective guard is to track repeated identical failures in the dispatch layer. If the same tool returns the same error class three times in a run, escalate the message. The change in wording is enough to break the pattern — the model stops trying to reformulate and starts telling the user what it could not do.
This is also the highest-value thing to log. A dashboard of repeated-error events, grouped by tool, is the shortest path to knowing which of your tools has a bad interface. In practice one or two tools generate the majority of these, and fixing their schemas fixes the agent.
seen = collections.Counter()
def dispatch(name, args):
result = TOOLS[name]["fn"](**args)
if not result.get("ok"):
key = (name, result.get("error", "")[:80])
seen[key] += 1
if seen[key] >= 3:
result["hint"] = (
"This exact call has now failed three times. Do not try it "
"again \u2014 either use a different tool or explain to the "
"user what could not be completed and why."
)
return resultWhat this buys you
An agent whose tools obey this contract behaves qualitatively differently from one whose tools raise. It recovers from a mistyped column name without the user seeing anything. It says 'I cannot read billing records, that is outside my permissions' instead of returning a stack trace. It gives up on a downed service in one turn rather than twelve.
None of that comes from a better model. It comes from treating the tool boundary as a conversation with a capable but under-informed collaborator — tell it what happened, tell it what to do next, and let it decide. In interviews, this is the answer to 'how do you make an agent reliable' that distinguishes people who have run one in production from people who have read about them.