Errors as Teaching
A tool that fails should return a message the model can act on. ValueError: invalid literal for int() with base 10 teaches nothing — it names a Python type and a numeric base, neither of which is in the model's control. "start_date must be YYYY-MM-DD; received '4th of March'" gets a corrected call on the next turn, at a cost of one model call and about two seconds.
Error text is part of your prompt surface. The model reads it exactly the way it reads the system message and a policy passage, and it decides the next action from it. That text is usually written by whoever was nearest the exception, in a hurry, for an audience of one developer with a debugger — and then it is read a thousand times a week by a component that cannot open a debugger and will keep going regardless.
Errors Are Results, Not Exceptions
An exception that escapes the dispatcher ends the run. The ticket that was going fine stops mid-turn, the customer gets nothing, and a person picks it up from a transcript that breaks off in the middle of a lookup. A returned error costs one extra turn and the loop keeps working. The whole difference is one try block and a decision about what to put in the string.
Mechanically an error is an ordinary result message: same shape, same request id, flagged so the model knows it is reading a failure rather than data. Both halves of that matter. Without the flag the model may read {"error": "not found"} as a record it can quote. And the failure mode worse than either is a swallowed exception that returns an empty object — the model concludes the order does not exist, says so to the buyer with complete confidence, and nothing anywhere logs a problem.
Put the try at the dispatcher rather than inside each tool, so that a tool nobody thought carefully about still fails safely, and count the results by code while you are there. Error rate per tool, split by class, is one of the four or five numbers that say what an agent is really doing in production. A tool whose bad-argument rate jumps the day after a schema edit is a wording regression you can see that same afternoon, rather than a mystery buried in next month's resolution figures (Chapter 13).
Three Kinds of Failure, Three Responses
Every tool failure belongs to one of three classes, and the classes exist because each one demands a different next move from the model. The error text is the only thing that tells it which class it is in.
| Whose fault | At Sundry | What the text must carry | What the model should do |
|---|---|---|---|
| The model's | amount_cents sent as 118.00 | The constraint, the received value, the valid form | Correct the argument and call again |
| The world's | No order matches that id | The true state, stated plainly and completely | Use it — this is a legitimate answer |
| The system's | The carrier API timed out | That the tool is unavailable, and whether waiting helps | Proceed without it, or escalate |
Mislabel a class and the model does the wrong thing competently. A "not found" phrased as a failure sends it retrying a query that already answered correctly. A timeout phrased like a "not found" is the dangerous one: the model concludes the parcel does not exist and tells the buyer their delivery was never dispatched, which is a confident wrong answer rather than a slow one. Say which of the three happened, in words, every time.
The system's-fault class is the one with real work behind it. The tool retries the upstream itself, with backoff and a bounded number of attempts, because a carrier API that drops one request in fifty is noise your code should absorb rather than narrate. Only when those attempts are exhausted does an error reach the model, and then it should say two things: that the tool is unavailable, and what is still possible without it. Tracking is unavailable; the order record still shows a delivery scan on Tuesday keeps a ticket moving where a bare failure ends it.
Writing for the Model's Next Move
An argument error needs three parts: the constraint, the value that was received, and the valid form. The third is the one that gets dropped, and dropping it is why "invalid date" reliably produces a second invalid date. Give the model something to copy — a format, an enum's members, a range — and the correction lands on the next turn rather than the turn after that.
{"tool_use_id": "tu_04",
"is_error": True,
"code": "bad_arguments",
"message": "start_date must be YYYY-MM-DD. Received '4th of March'.",
"retry": "fix_and_retry"} # fix_and_retry | wait_and_retry | do_not_retry
# bad_arguments — constraint, received value, valid form
"amount_cents must be an integer in minor units, 1 to 15000. Received
118.00. For a refund of $118.00, send 11800."
# not_found — a complete statement of the true state, not a failure
"No order SU-88999 exists. Searched all statuses, last 24 months.
Ask the buyer to confirm the id from their confirmation email."
# unavailable — says what is unknown, and what it does not mean
"The carrier did not respond within 8s, after 2 retries. Tracking is
unavailable right now. This does not mean the parcel was not sent."
Read those three as prose written for somebody who has to act. The first hands over a working example of the value it wants, so the next call is right rather than differently wrong. The second closes the question — it says what was searched, so the model does not repeat the same lookup with a wider date range, and it names the useful next step, which is asking the buyer. The third does the job that matters most: it separates "we do not know" from "it did not happen", which is the distinction the model will otherwise collapse, and it does so in the one place the model is guaranteed to read.
Leaks Through the Error Path
A raw exception string is a leak surface. OperationalError: connection to server at "orders-db-primary.internal" (10.4.2.19), port 5432 failed puts an internal hostname, a private address and a port into the context. From there it can reach a buyer, because the model writes the customer's reply out of everything in front of it and has no idea which parts were meant to be private. The same string lands in the stored transcript and in the trace a support engineer later shares in a ticket (Chapter 13).
Sanitize once, at the dispatcher, rather than at every call site where somebody will eventually forget. Map the exception to a code and a written sentence, log the original with the ticket id where your engineers can see it, and let nothing else through. The working rule: nothing enters the context that you would be unhappy to see quoted in an email to a customer — because with a component that writes prose from the context, it eventually will be.
Errors That Must Not Be Retried
Some failures are permanent, and a model that cannot tell will keep trying. Vera watched a ticket about an order whose data had been erased on request: the orders service returned a 500, the error text said "temporarily unavailable, please try again", and the model dutifully did — five get_order calls, two search_orders calls, the twelve-turn limit, and no reply to the buyer at all. Twelve model calls spent on a record that could not exist.
The fix is an explicit signal, not a cleverer prompt. A retry field with three values — fix and retry, wait and retry, do not retry — plus message text that agrees with it. "This order's data has been erased and cannot be retrieved. Do not retry; escalate to a person." ends that run on turn 3 with an escalation the support team can act on. The design of what happens after the escalation, and how a half-finished run is cleaned up, is Chapter 8's subject; the job here is to make sure the loop knows it has hit a wall.
- Letting exceptions propagate out of the dispatcher — one malformed argument ends a ticket that was otherwise going fine, and the buyer waits for a reply that will never be written.
- Returning the raw exception string — it is noise to the model, and it carries hostnames, internal addresses and SQL into a transcript the model writes customer prose from.
- Making every failure look retryable — the model retries a permanently invalid request until the twelve-turn limit, spending twelve model calls to produce nothing.
- Swallowing failures and returning an empty result — the model concludes the order does not exist, tells the customer so with total confidence, and nothing logs an error.
- Writing argument errors without the valid form — "invalid date" produces a second invalid date, and the run burns two more turns discovering that.
- Return a structured error result on every failure: a short code, a human-readable explanation, and an explicit statement of whether a retry could help.
- State the constraint, the received value and a copyable example of the valid form in every argument error.
- Sanitize error text once at the dispatcher boundary, and log the original exception against the ticket id where engineers can read it.
- Mark unrecoverable failures explicitly so the loop escalates on turn 3 instead of iterating to the turn limit (Chapter 8).
Knowledge Check
Why must a tool failure come back as a result rather than as a raised exception?
- A result lets the model correct itself on the next turn, where an exception ends the run outright
- Exceptions raised inside a tool can no longer be caught once the model has already requested that call
- The provider counts a raised exception against the account's error rate and then throttles the run
- A returned error costs no tokens at all, because failure messages are excluded from the billed context
track_parcel times out and returns "no tracking information found for this parcel". What is wrong with that text?
- It reports a system failure as a fact, so the model tells the buyer their parcel was never sent
- It is too short to be useful, and the model needs the full carrier response to interpret it
- It omits the tracking number, so the model cannot tell which parcel the failure refers to
- It invites a retry, and a carrier call that has already timed out should never be attempted a second time
The model sends amount_cents: 118.00. Which error text is most likely to produce a correct call on the very next turn?
- "amount_cents must be an integer in minor units, 1 to 15000. Received 118.00. For $118.00 send 11800."
- "Invalid value for amount_cents: a whole number of cents was expected and a decimal was received here."
- "ValueError: invalid literal for int() with base 10 while parsing the amount_cents field of this call."
- "Bad request. See the tool documentation for the accepted format of the amount_cents parameter."
A record has been permanently erased and the tool keeps returning "temporarily unavailable, please try again". What is the fix?
- Mark the error do-not-retry and say the data is gone, so the loop escalates instead of iterating
- Lower the turn limit so that a run stuck on one tool cannot spend the whole ticket budget
- Add a line to the system prompt telling the model never to call the same tool twice in a row
- Add exponential backoff inside the tool so the repeated attempts are spaced out and cheaper
You got correct