{"course":{"slug":"context-engineering","title":"Context Engineering","url":"https://addaly.com/learn/context-engineering","level":"advanced","track":"build"},"lesson":{"slug":"errors-that-teach","title":"An error message is context too","url":"https://addaly.com/learn/context-engineering/errors-that-teach","position":63,"of":87,"minutes":8,"answer":"Error text written for a human log tells a model nothing it can act on, so every tool error should state what failed, whether retrying can possibly help, and what to do instead.","keyIdea":"Error text written for a human log tells a model nothing it can act on, so every tool error should state what failed, whether retrying can possibly help, and what to do instead, and the retry cap belongs in code regardless."},"body":{"format":"markdown","text":"## The cheapest teaching signal you have\n\nWhen a tool fails, whatever string you return goes into the window and becomes the model's entire understanding of what happened. It is a prompt written at the worst possible moment, usually by whoever wrote the exception handler two years ago for a different audience.\n\nHere is that audience mismatch in one line:\n\n```\nError: HTTP 500\n```\n\nA human on call reads that, opens the logs, and finds the cause in a minute. A model has no logs. It has five characters of number. It will do the only thing available, which is guess, and the most common guess is that this is transient, so it calls again. And again.\n\nNow the arithmetic. Each retry is a full request carrying the whole accumulated context. Eight retries on a 40,000-token conversation is 320,000 input tokens spent producing nothing, plus eight round trips of latency the user is sitting through.\n\n## Three things an error must carry\n\n**What failed, specifically.** Not \"invalid input\" but which argument and why. `customer_id \"c_99\" is malformed: expected format c_NNNN with exactly four digits.`\n\n**Whether a retry can help.** This is the one that saves the loop, and almost no error message says it. A model cannot distinguish a rate limit from a not-found from a malformed argument unless you tell it, and the correct response to each is completely different. Say it in words: `Retrying with the same arguments will fail.` Or: `Transient; retry once after a short wait.`\n\n**What to do instead.** The model needs a legal next move. Without one it either gives up or invents something. `Use search_orders to find the id first.`\n\nPut together:\n\n```\nget_order failed: no order with id ORD-88213 exists.\nThis is not transient — retrying with the same id will fail identically.\nIf the customer gave this number verbally it may be mistyped; use\nsearch_orders with their customer_id and a date range to find the real id.\n```\n\nAround 60 tokens. It replaces a flail of six or eight calls with one correct one. There is no cheaper intervention anywhere in an agent system.\n\n## Write the error once, in a wrapper\n\nThis does not belong scattered through tool implementations. Put it at the boundary, where every tool result already passes for truncation.\n\n```python\nclass ToolError(Exception):\n    def __init__(self, what, retryable: bool, instead: str = \"\"):\n        self.what, self.retryable, self.instead = what, retryable, instead\n\n    def for_model(self) -> str:\n        lines = [self.what]\n        lines.append(\"Transient; retrying may succeed.\" if self.retryable\n                     else \"Not transient; the same call will fail identically.\")\n        if self.instead:\n            lines.append(self.instead)\n        return \"\\n\".join(lines)\n```\n\nMaking `retryable` a required argument is the whole point of the design. It forces whoever adds a tool to decide, at the time they have the knowledge, rather than leaving the model to infer it later without any.\n\n## Cap the retries in code anyway\n\nEven with perfect error text, the retry limit belongs in your loop, not in the prompt. An instruction not to retry more than twice is a preference the model usually honours. A counter is a fact.\n\n```python\nif attempts[tool_name] >= 2:\n    return (\"This tool has now failed twice in this session. It is unavailable. \"\n            \"Continue without it or tell the user what you could not do.\")\n```\n\nNote what that message does: it closes the door and opens a different one. An error that only closes the door tends to produce a model that keeps knocking in slightly different ways (calling a neighbouring tool, reformatting the same argument) which burns as many turns as the retries did.\n\n## Do not paste the traceback\n\nA Python traceback from a framework is commonly 400 to 800 tokens: a dozen stack frames through library internals, of which one line is informative. Pasting it costs real money on every failure and buries the useful line in the middle of the block, which is the worst position in the window.\n\nExtract the exception type, the message, and the last frame that belongs to your own code. Four lines. Keep the full traceback in your logs, where a human will actually use it.\n\n## Do not leak internals either\n\nWhatever you put in an error message enters the window, and whatever enters the window can be reproduced in the output. A connection string, an internal hostname, a file path revealing a customer's name, a stack frame containing a token: all of these have shipped to end users this way, in systems whose authors would have been careful about it anywhere else.\n\nSanitise at the same boundary where you format. The rule is easy to state: an error string is user-visible until proven otherwise, because the model may well quote it.\n\n## The measurement\n\nLog every tool error with its type, and log how many calls followed it before the agent either succeeded or stopped. That second number is the one to watch. A well-written error has a tail of one or two. A bad one has a tail of six, and the tail length tells you exactly which error messages to rewrite first, which is a far better use of an afternoon than rewriting the ones that merely look untidy."},"check":{"question":"Why does adding \"retrying with the same arguments will fail\" to an error message reduce cost more than shortening the message would?","options":[{"text":"It shortens the error block, which reduces the tokens carried forward in every subsequent turn of the conversation.","correct":false,"tempting":"It lengthens the message; the saving comes from calls not made, which dwarfs any per-token difference."},{"text":"It prevents a chain of retries, each of which resends the whole accumulated context.","correct":true},{"text":"It lets the provider skip billing for the failed call, since the failure is declared non-transient.","correct":false,"tempting":"Providers bill for the request regardless of what your tool returned; the tool result is just text to them."},{"text":"It allows the retry counter to reset, so later genuine transient failures still get their full allowance.","correct":false,"tempting":"Counters are your code's concern and are unaffected by wording; the message changes the model's next move, not the bookkeeping."}]},"prev":{"slug":"tool-results-are-unbounded","title":"The biggest thing in the window is a tool result","url":"https://addaly.com/learn/context-engineering/tool-results-are-unbounded"},"next":{"slug":"too-many-tools","title":"When the toolset outgrows the window","url":"https://addaly.com/learn/context-engineering/too-many-tools"},"authorship":"Founder and series editor: Dr Mustafa Mun. Written by AI models to a written standard. Exam keys checked blind by a second model: 3,665 of 3,666 agreed. Academic review invited.","review":{"status":"Draft, open for academic review"},"cite":"Addaly (2026) 'An error message is context too', in Context Engineering. Edited by M. Mun. Addaly. Available at: https://addaly.com/learn/context-engineering/errors-that-teach (Accessed: 13 October 2026).","licence":{"name":"CC BY-NC-SA 4.0","url":"https://creativecommons.org/licenses/by-nc-sa/4.0/"},"reader":{"schema":1,"title":"An error message is context too","courseTitle":"Context Engineering","module":{"index":7,"slug":"tools-schemas-and-the-agent-window","title":"Tools, schemas and the agent’s window"},"answer":null,"keyIdea":"Error text written for a human log tells a model nothing it can act on, so every tool error should state what failed, whether retrying can possibly help, and what to do instead, and the retry cap belongs in code regardless.","hero":{"kind":"plate","image":null,"alt":null,"caption":null},"blocks":[{"t":"h2","id":"the-cheapest-teaching-signal-you-have","runs":[{"text":"The cheapest teaching signal you have"}]},{"t":"p","runs":[{"text":"When a tool fails, whatever string you return goes into the window and becomes the model’s entire understanding of what happened. It is a prompt written at the worst possible moment, usually by whoever wrote the exception handler two years ago for a different audience."}]},{"t":"p","runs":[{"text":"Here is that audience mismatch in one line:"}]},{"t":"code","lang":"","label":"Text","text":"Error: HTTP 500"},{"t":"p","runs":[{"text":"A human on call reads that, opens the logs, and finds the cause in a minute. A model has no logs. It has five characters of number. It will do the only thing available, which is guess, and the most common guess is that this is transient, so it calls again. And again."}]},{"t":"p","runs":[{"text":"Now the arithmetic. Each retry is a full request carrying the whole accumulated context. Eight retries on a 40,000-token conversation is 320,000 input tokens spent producing nothing, plus eight round trips of latency the user is sitting through."}]},{"t":"h2","id":"three-things-an-error-must-carry","runs":[{"text":"Three things an error must carry"}]},{"t":"p","runs":[{"text":"What failed, specifically.","b":true},{"text":" Not “invalid input” but which argument and why. "},{"text":"customer_id \"c_99\" is malformed: expected format c_NNNN with exactly four digits.","code":true}]},{"t":"p","runs":[{"text":"Whether a retry can help.","b":true},{"text":" This is the one that saves the loop, and almost no error message says it. A model cannot distinguish a rate limit from a not-found from a malformed argument unless you tell it, and the correct response to each is completely different. Say it in words: "},{"text":"Retrying with the same arguments will fail.","code":true},{"text":" Or: "},{"text":"Transient; retry once after a short wait.","code":true}]},{"t":"p","runs":[{"text":"What to do instead.","b":true},{"text":" The model needs a legal next move. Without one it either gives up or invents something. "},{"text":"Use search_orders to find the id first.","code":true}]},{"t":"p","runs":[{"text":"Put together:"}]},{"t":"code","lang":"","label":"Text","text":"get_order failed: no order with id ORD-88213 exists.\nThis is not transient — retrying with the same id will fail identically.\nIf the customer gave this number verbally it may be mistyped; use\nsearch_orders with their customer_id and a date range to find the real id."},{"t":"p","runs":[{"text":"Around 60 tokens. It replaces a flail of six or eight calls with one correct one. There is no cheaper intervention anywhere in an agent system."}]},{"t":"h2","id":"write-the-error-once-in-a-wrapper","runs":[{"text":"Write the error once, in a wrapper"}]},{"t":"p","runs":[{"text":"This does not belong scattered through tool implementations. Put it at the boundary, where every tool result already passes for truncation."}]},{"t":"code","lang":"python","label":"Python","text":"class ToolError(Exception):\n    def __init__(self, what, retryable: bool, instead: str = \"\"):\n        self.what, self.retryable, self.instead = what, retryable, instead\n\n    def for_model(self) -> str:\n        lines = [self.what]\n        lines.append(\"Transient; retrying may succeed.\" if self.retryable\n                     else \"Not transient; the same call will fail identically.\")\n        if self.instead:\n            lines.append(self.instead)\n        return \"\\n\".join(lines)"},{"t":"p","runs":[{"text":"Making "},{"text":"retryable","code":true},{"text":" a required argument is the whole point of the design. It forces whoever adds a tool to decide, at the time they have the knowledge, rather than leaving the model to infer it later without any."}]},{"t":"h2","id":"cap-the-retries-in-code-anyway","runs":[{"text":"Cap the retries in code anyway"}]},{"t":"p","runs":[{"text":"Even with perfect error text, the retry limit belongs in your loop, not in the prompt. An instruction not to retry more than twice is a preference the model usually honours. A counter is a fact."}]},{"t":"code","lang":"python","label":"Python","text":"if attempts[tool_name] >= 2:\n    return (\"This tool has now failed twice in this session. It is unavailable. \"\n            \"Continue without it or tell the user what you could not do.\")"},{"t":"p","runs":[{"text":"Note what that message does: it closes the door and opens a different one. An error that only closes the door tends to produce a model that keeps knocking in slightly different ways (calling a neighbouring tool, reformatting the same argument) which burns as many turns as the retries did."}]},{"t":"h2","id":"do-not-paste-the-traceback","runs":[{"text":"Do not paste the traceback"}]},{"t":"p","runs":[{"text":"A Python traceback from a framework is commonly 400 to 800 tokens: a dozen stack frames through library internals, of which one line is informative. Pasting it costs real money on every failure and buries the useful line in the middle of the block, which is the worst position in the window."}]},{"t":"p","runs":[{"text":"Extract the exception type, the message, and the last frame that belongs to your own code. Four lines. Keep the full traceback in your logs, where a human will actually use it."}]},{"t":"h2","id":"do-not-leak-internals-either","runs":[{"text":"Do not leak internals either"}]},{"t":"p","runs":[{"text":"Whatever you put in an error message enters the window, and whatever enters the window can be reproduced in the output. A connection string, an internal hostname, a file path revealing a customer’s name, a stack frame containing a token: all of these have shipped to end users this way, in systems whose authors would have been careful about it anywhere else."}]},{"t":"p","runs":[{"text":"Sanitise at the same boundary where you format. The rule is easy to state: an error string is user-visible until proven otherwise, because the model may well quote it."}]},{"t":"h2","id":"the-measurement","runs":[{"text":"The measurement"}]},{"t":"p","runs":[{"text":"Log every tool error with its type, and log how many calls followed it before the agent either succeeded or stopped. That second number is the one to watch. A well-written error has a tail of one or two. A bad one has a tail of six, and the tail length tells you exactly which error messages to rewrite first, which is a far better use of an afternoon than rewriting the ones that merely look untidy."}]}],"check":{"question":[{"text":"Why does adding “retrying with the same arguments will fail” to an error message reduce cost more than shortening the message would?"}],"options":[{"text":[{"text":"It shortens the error block, which reduces the tokens carried forward in every subsequent turn of the conversation."}],"correct":false,"tempting":[{"text":"It lengthens the message; the saving comes from calls not made, which dwarfs any per-token difference."}]},{"text":[{"text":"It prevents a chain of retries, each of which resends the whole accumulated context."}],"correct":true,"tempting":null},{"text":[{"text":"It lets the provider skip billing for the failed call, since the failure is declared non-transient."}],"correct":false,"tempting":[{"text":"Providers bill for the request regardless of what your tool returned; the tool result is just text to them."}]},{"text":[{"text":"It allows the retry counter to reset, so later genuine transient failures still get their full allowance."}],"correct":false,"tempting":[{"text":"Counters are your code’s concern and are unaffected by wording; the message changes the model’s next move, not the bookkeeping."}]}],"order":[2,1,3,0],"why":null},"prev":{"slug":"tool-results-are-unbounded","title":"The biggest thing in the window is a tool result","minutes":9},"next":{"slug":"too-many-tools","title":"When the toolset outgrows the window","minutes":9}}}