04 · Things go wrong
Tell the model what went wrong
The model mistypes a tool name. Your lookup raises, the run dies, and the list it leaves behind ends on a call nobody answered.
This lesson builds on lesson 03. New here? Start at lesson 01, or carry on: every lab is self-contained.
A reply comes back holding the word "Done." and a read call for notes.md, and the provider has labelled the whole thing stop. You hand it to your lesson 3 loop. How many times does the model get called?
stop and the words say "Done.", so there is nothing left to do
Two calls, and the file was read in between. The words are words, the label is a note about how the writing stopped, and the call is the only thing your loop asks about. Today the call is for a tool that is not there.
import harness, lab
ws = lab.Workspace({"notes.md": "Ship on Friday.\n"})
model = lab.ScriptedModel([
lab.reply(lab.text("Done."),
lab.call("read", {"path": "notes.md"}),
stop_reason="stop"),
lab.say("We ship on Friday."),
])
messages = []
harness.run_agent(model, "You are helpful.", messages,
[harness.make_read_tool(ws)], "When do we ship?")
print(lab.show(messages, stop_reason=True))
print()
print("model calls:", len(model.calls))
user -> "When do we ship?"
assistant -> "Done." + toolCall c1 read({"path": "notes.md"}) [stop_reason=stop]
toolResult c1 -> "Ship on Friday.\n"
assistant -> "We ship on Friday." [stop_reason=stop]
model calls: 2
One reply asks for two files at once, c1 for config.py and c2 for backup.py. The results come back the other way round: backup.py first. The model then has to say which port belongs to which file. What does it go on?
tool_call_id, and that is the only thing tying it to a call
PORT = and a number, and neither one mentions a file at all. The only thing in a result that says where it came from is the id it is carrying.It looked each result up by the id it was carrying, and got both files right in the wrong order. The pairing is by id, both ways. Today one call gets no result at all, and that turns out to be a different kind of problem.
import lab
c1 = lab.call("read", {"path": "config.py"}, id="c1")
c2 = lab.call("read", {"path": "backup.py"}, id="c2")
messages = [
lab.user("Which ports do the two files use?"),
lab.reply(c1, c2),
lab.tool_result(c2, "PORT = 7070\n"), # backup.py, sent first
lab.tool_result(c1, "PORT = 9090\n"), # config.py, sent second
]
def answer(request): # what the model writes, given those two results
first = request.result_for("c1")["content"].strip()
second = request.result_for("c2")["content"].strip()
return lab.say(f"config.py: {first}. backup.py: {second}.")
# strict=False: this lab's strict model also wants call order
model = lab.ScriptedModel([answer], strict=False)
reply = model.complete("You are helpful.", messages)
print(lab.show(messages[2:]))
print()
print(lab.show([reply]))
toolResult c2 -> "PORT = 7070\n" toolResult c1 -> "PORT = 9090\n" assistant -> "config.py: PORT = 9090. backup.py: PORT = 7070."
raed
Your agent works. Here is a job it should walk through: one file, one question, one tool. The workspace holds config.py, the tool list holds read, and the user asks what port the app listens on.
The model asks for the tool raed.
That is the whole of the trouble. Not a hallucination, not a jailbreak, not an outage: two letters the wrong way round in a name, of the kind you type six times a day. The script below even has the fix ready — its second step corrects the name to read, but only if it is told that raed does not exist.
You call run_agent with that script and that tool list. The first reply asks for raed. Your run_tool from lesson 3 looks the name up in a dict of the tools it was given. What happens?
read
run_tool and look for the line that would tell it. There is one dict lookup and nothing after it: being told is a thing somebody has to write, and nobody has.run_agent, and the run ends on a traceback
KeyError: 'raed'. Nine lines of loop, three lessons of work, and a two-letter typo ends the run. The model's correction was sitting in the script the whole time.
import harness, lab
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
def retry(request): # it corrects a typo it has been told about
result = request.last_result
if result and result["is_error"] and "raed" in result["content"]:
return lab.reply(lab.call("read", {"path": "config.py"}))
return lab.say("I cannot continue.")
model = lab.ScriptedModel([
lab.reply(lab.call("raed", {"path": "config.py"})), # a typo
retry,
lambda request: lab.say("The port is 9090."),
])
messages = []
reply = harness.run_agent(model, "You are helpful.", messages,
[harness.make_read_tool(ws)], "What port?")
print(lab.show([reply]))
KeyError: 'raed'
Two things are now broken. Name both.
One of them is on your screen. The other one is in a variable, and you will not see it until somebody uses that variable again.
The run died. The traceback went to you, the job stopped halfway, and the user got nothing.
The record is poisoned. messages is the caller's list, and the loop appended the reply before it ran anything. So the list now ends U A[c1]: a call that nobody answered. Lesson 2's rule was that a call is paired with exactly one result; a list that breaks it is refused before the model reads a word of it. The cell below runs the same job and then does the ordinary human thing afterwards — shrug, and ask again.
Common answers, and what each one misses
- "The model sent a bad tool name." That is what happened, not what broke. Models mistype, the way people do, and a harness that only works against a model that never mistypes is a harness for a demo.
- "
run_toolcrashed — it needs atryaround that lookup." The right repair for the first half, and it leaves the second half exactly as it is. A call that is caught and then skipped still ends the list on a call with no result. - "Nothing is really broken: the user can start a new conversation." They can, and it costs them everything the agent had worked out so far, plus the requests already paid for. The list you were holding was the only copy.
The run that died, what the caller is still holding afterwards, and what happens when the user asks a second question on that same list. Your lesson 3 code, unchanged.
import harness, lab
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
tools = [harness.make_read_tool(ws)]
model = lab.ScriptedModel([
lab.reply(lab.call("raed", {"path": "config.py"})),
lab.say("The port is 9090."),
])
messages = []
try:
harness.run_agent(model, "You are helpful.", messages, tools,
"What port?")
except KeyError as exc:
print("the run died:", type(exc).__name__, exc)
print()
print("what the caller still holds:", lab.shape(messages))
print(lab.show(messages))
# The user shrugs and asks again, on the same list.
print()
reply = harness.run_agent(model, "You are helpful.", messages, tools,
"Never mind. What port does it use?")
print(lab.show([reply]))
the run died: KeyError 'raed'
what the caller still holds: U A[c1]
user -> "What port?"
assistant -> toolCall c1 raed({"path": "config.py"})
assistant -> (nothing) [error: 400 invalid_request: messages[1]: toolCall c1 has no toolResult]
The second prompt never reaches the model. 400 invalid_request: messages[1]: toolCall c1 has no toolResult — and it will say that to every prompt on this list, for ever, because the damage is in the list and the list only grows. One typo, and the session is over.
Where this failure comes from: Tau keeps a test whose whole job is to pin what a model asking for a tool nobody has must produce — not an exception, but a result the model can read (tests/test_agent_loop.py:284-308).
Somebody was in a position to turn raed into read. Who, and did they ever find out? The cell prints what your terminal was told, and then every request the model received.
raed is the only party that can write read instead.raed
One request went out, and it went out before the mistake existed. Your terminal got KeyError: 'raed'; the model got a polite question about a port. Every line of this lesson comes from that gap: a failure has a reader, and the reader is on the other side of the wire.
import harness, lab
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
model = lab.ScriptedModel([
lab.reply(lab.call("raed", {"path": "config.py"})),
lab.say("The port is 9090."),
])
messages = []
try:
harness.run_agent(model, "You are helpful.", messages,
[harness.make_read_tool(ws)], "What port?")
except KeyError as exc:
print("your terminal got: KeyError:", exc)
print("the model got, in all its requests:")
for number, request in enumerate(model.calls, 1):
print(f" request {number}:", lab.show(request.messages))
print("requests that mention the typo:",
sum("raed" in lab.show(r.messages) for r in model.calls))
your terminal got: KeyError: 'raed' the model got, in all its requests: request 1: user -> "What port?" requests that mention the typo: 0
Two designs
So a failure has to come back as data, in a message, like everything else the model is ever told. There are two ways to arrange that, and both are ordinary engineering.
Design A: every tool reports its own failures. Each execute returns something like {"ok": True, "text": ...}, catching the things that can go wrong with it. The read tool knows about missing files; the bash tool knows about bad commands. Nothing outside a tool has to know anything.
Design B: tools just raise. An execute that cannot do its job raises, exactly like any other Python function, and one place outside every tool catches and converts. Tools get shorter; one function gets a try.
The cell runs both on three paths through the same job: a file that is there, a file that is not, and logo.png, which is a real file and is not text.
Which design comes back with an answer on all three paths? Think about the third one in particular: the author of read wrote an except FileNotFoundError, because that is the failure they had in mind.
try / except Exception as well as returning the flag. Then nothing can escape, and the failure is handled where the knowledge is
logo.png is bytes, so decoding it raises UnicodeDecodeError — a failure the author of read never thought about, which sailed past their except and killed the run. B answered all three, because a try that catches Exception does not need to know the name of what it is catching. That one place has a name: it is the run_tool.
Read B's except branch, though: the text it puts in the result is type(exc).__name__, a class name, which is the next question.
import lab
ws = lab.Workspace({"config.py": "PORT = 9090\n",
"logo.png": b"\x89PNG\xff\xfe"})
# Design A: every tool reports its own failures with a flag.
def read_with_flag(arguments):
try:
return {"ok": True, "text": ws.read_text(arguments["path"])}
except FileNotFoundError: # the failure its author foresaw
return {"ok": False, "text": "File not found."}
# Design B: tools just raise. One boundary, outside every tool.
def read_that_raises(arguments):
return ws.read_text(arguments["path"])
def boundary(execute, arguments):
try:
return {"ok": True, "text": execute(arguments)}
except Exception as exc:
return {"ok": False, "text": type(exc).__name__}
for path in ["config.py", "confg.py", "logo.png"]:
print(path)
try:
print(" A:", read_with_flag({"path": path})["ok"])
except Exception as exc:
print(" A: the run died,", type(exc).__name__)
print(" B:", boundary(read_that_raises, {"path": path})["ok"])
config.py A: True B: True confg.py A: False B: False logo.png A: the run died, UnicodeDecodeError B: False
Who is the message for?
The boundary catches something and has to put text in the result. Here are three texts for the same failure, a read of a file that is not there. All three are things people really write.
Error- the traceback, all forty lines of it
File not found: confg.py. Files here: config.py, main.py
The cell runs the same job three times, once per text, against a model whose rule is printed in its own docstring: after a failed call it looks in the error text for a file name it has not tried yet and tries that; if there is none, it gives up. It has no other source of file names, because there is no other source — the request is all it has.
Pick the text you would write, then read all three runs. Which one lets the job finish?
Error. The flag already says the call failed, and that is the part that matters; the text is for whoever reads the logs afterwards
Error told it nothing it could act on, so it gave up after two calls. The traceback told it about harness.py — so it tried to read your source file, failed again, and gave up. The third names what went wrong and what exists, and the model read config.py on its next turn and answered the question. The error text is the only part of a failure the model will ever see, which makes it a prompt, written by you, at the worst possible moment.
Where this comes from: Tau's edit tool answers a failed match with "Could not find the exact text in <path>. The old text must match exactly including all whitespace and newlines." (src/tau_coding/tools.py:1128-1137). Nobody writes a sentence like that for a log file.
import harness, lab
FILES = {"config.py": "PORT = 9090\n", "main.py": "import config\n"}
TRACEBACK = "\n".join(
["Traceback (most recent call last):"]
+ [line for frame in range(19)
for line in (f' File "harness.py", line {62 + frame}, in run_tool',
' content = tool["execute"](call["arguments"])')]
+ ["FileNotFoundError: confg.py"])
def read_tool(ws, message):
"""The read tool, with the error text under test. `message` takes the path."""
def execute(arguments):
path = harness.str_arg(arguments, "path")
if not ws.exists(path):
raise FileNotFoundError(message(path))
return ws.read_text(path)
return {**harness.make_read_tool(ws), "execute": execute}
def debugger(request):
"""Our script. It asks for confg.py. After a failed call it looks in the error
text for a file name it has not tried yet and tries that; if there is none it
gives up. It has no other source of file names."""
result = request.last_result
if result is None:
return lab.reply(lab.call("read", {"path": "confg.py"}))
if not result["is_error"]:
return lab.say("Found it: " + result["content"].strip())
asked = [block["arguments"]["path"] for message in request.messages
if message["role"] == "assistant"
for block in message["content"] if block["type"] == "toolCall"]
fresh = [word.strip('",.()') for word in result["content"].split()
if word.strip('",.()').endswith(".py")
and word.strip('",.()') not in asked]
if fresh:
return lab.reply(lab.call("read", {"path": fresh[0]}))
return lab.say("I cannot continue: the error does not tell me what to try.")
TEXTS = [
("Error", lambda path: "Error"),
(f"a traceback ({len(TRACEBACK.splitlines())} lines)",
lambda path: TRACEBACK.replace("confg.py", path)),
("File not found: confg.py. Files here: config.py, main.py",
lambda path: f"File not found: {path}. "
f"Files here: {', '.join(lab.Workspace(FILES).listdir('.'))}"),
]
for label, message in TEXTS:
ws = lab.Workspace(FILES)
model = lab.ScriptedModel([lab.forever(debugger)])
messages = []
reply = harness.run_agent(model, "You are a careful debugger.", messages,
[read_tool(ws, message)], "What port does the app use?")
asked = [block["arguments"]["path"] for m in messages if m["role"] == "assistant"
for block in m["content"] if block["type"] == "toolCall"]
print(f'error text: {label}')
print(f' paths it asked for: {", ".join(asked)}')
print(f' model calls: {len(model.calls)}')
print(f' it ended with: {harness.text_of(reply)}')
error text: Error paths it asked for: confg.py model calls: 2 it ended with: I cannot continue: the error does not tell me what to try. error text: a traceback (40 lines) paths it asked for: confg.py, harness.py model calls: 3 it ended with: I cannot continue: the error does not tell me what to try. error text: File not found: confg.py. Files here: config.py, main.py paths it asked for: confg.py, config.py model calls: 3 it ended with: Found it: PORT = 9090
The schema is a request, not a rule
Your read tool has gone out with every request since lesson 2, carrying this:
"parameters": {
"type": "object",
"properties": {"path": {"type": "string", "description": "Path of the file to read."}},
"required": ["path"],
}
It reads like a contract. [general] It is a description, sent to the model along with everything else; some providers offer a strict mode that constrains what the model may emit, plenty of calls are made without one, and real models send odd shapes often enough that harnesses are written expecting it.
So the next two calls are perfectly possible: bash with {}, no command at all, and read with {"path": 7}. Your starter has a helper for reading arguments, str_arg, given to you in a naive version that does exactly what writing arguments["path"] inline would do:
def str_arg(arguments, name):
return arguments[name]
The boundary from the last section is in place, and str_arg is that one line. The model sends bash({}) and read({"path": 7}). What comes back?
run_tool looked at that schema. The reply arrives as content blocks with an arguments dict inside, and whatever is in that dict is what you get.bash({}) fails, and read({"path": 7}) goes through, because arguments["path"] hands back the 7 and nothing downstream minds
arguments as structured data that arrived from an API, where a stray 7 is a detail. It is text a model generated, and the 7 is wrong in precisely the way the schema asked about. It does get further, which is the problem and not the consolation: something downstream minds, several frames away from the argument that was wrong.'command' is the entire message the first failure sent: that is all str(KeyError) ever says, and it names the argument without saying what to do about it. The 7 sailed through str_arg untouched and failed deeper in, inside the workspace, in forty-three characters of CPython's about adding an int to a str — a sentence that never mentions path. Neither text contains the word "string", and neither one says what a good value looks like. Every tool has to check its own arguments by hand, and if they all do it differently the model gets a different kind of nonsense from each one.
import harness, lab
# The str_arg you were given, word for word. It checks nothing.
harness.str_arg = lambda arguments, name: arguments[name]
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
tools = [harness.make_read_tool(ws), harness.make_bash_tool(lab.Shell(ws))]
def row(label, value):
print(f' {label:<31}{value}')
texts = {}
for call, wanted in [(lab.call("bash", {}, id="c1"), "command"),
(lab.call("read", {"path": 7}, id="c2"), "path")]:
result = harness.run_tool(tools, call)
text = texts[call["name"]] = result["content"]
print(f'{call["name"]}({call["arguments"]})')
row("is_error", result["is_error"])
row("length of the text", f"{len(text)} characters")
row(f'does it name "{wanted}"?', wanted in text)
row("does it say what to send?", "string" in text)
print()
print("the whole of what bash's failure told the model:", repr(texts["bash"]))
bash({})
is_error True
length of the text 9 characters
does it name "command"? True
does it say what to send? False
read({'path': 7})
is_error True
length of the text 43 characters
does it name "path"? False
does it say what to send? False
the whole of what bash's failure told the model: "'command'"
Bad news is not an error
One more tool arrives in your starter, and it is the one a coding agent lives on: bash. It runs a command line and brings back what was printed. The thing on the other end is a lab.Shell — a fixed table of commands with scripted output, because nothing real can start a process in your browser. Its pytest is a recording: three thousand lines, the failing assertion near the end, exit code 1.
The model runs it. The command works perfectly. One test fails.
The command exited with code 1. Your bash tool has to turn that into a result. Is it flagged as an error? The cell runs the same model against both answers.
bash did exactly what it was asked: it ran the command and brought the output back. The failing test is the news the model went looking for
Flagged, the model said "The bash tool failed, so I never saw the test output" and stopped — the correct response to a broken tool, and the tool was not broken. Unflagged, it found the failing assertion, 5 == 6, among 3,002 lines, and reported it. So False; from today it means something.
Where this comes from: Tau's bash tool puts the exit code in a status line appended to the output, and returns an ordinary result — the error flag is set by the boundary catching an exception, and nothing here raised (src/tau_coding/tools.py:675-700).
import harness, lab
def bash_tool(shell, *, exit_code_is_an_error):
"""The given bash tool, in two designs, differing only in the three lines below."""
def execute(arguments):
output, code = shell.run(harness.str_arg(arguments, "command"))
if code != 0:
if exit_code_is_an_error:
raise RuntimeError(f"Command exited with code {code}")
output += f"\nCommand exited with code {code}"
return output
return {**harness.make_bash_tool(shell), "execute": execute}
def developer(request):
"""Our script. It runs the tests once. If the result comes back flagged as an
error it stops, because a broken tool is not something it can work around.
Otherwise it reads the output and reports the failing line."""
result = request.last_result
if result is None:
return lab.reply(lab.call("bash", {"command": "pytest"}))
if result["is_error"]:
return lab.say("The bash tool failed, so I never saw the test output. Stopping.")
failed = [line for line in result["content"].splitlines() if line.startswith("E ")]
return lab.say("One test failed: " + failed[-1].split("assert")[-1].strip())
for flags in [True, False]:
ws = lab.Workspace({"calc.py": "def total(xs):\n return sum(xs) + 1\n"})
tool = bash_tool(lab.Shell(ws), exit_code_is_an_error=flags)
model = lab.ScriptedModel([lab.forever(developer)])
messages = []
reply = harness.run_agent(model, "You are a careful developer.", messages,
[tool], "Run the tests and tell me what is wrong.")
result = messages[2]
print(f'exit_code_is_an_error={flags}')
print(f' is_error {result["is_error"]}')
print(f' lines of output {len(result["content"].splitlines())}')
print(f' last line {result["content"].splitlines()[-1]!r}')
print(f' it ended with {harness.text_of(reply)}')
exit_code_is_an_error=True is_error True lines of output 1 last line 'Command exited with code 1' it ended with The bash tool failed, so I never saw the test output. Stopping. exit_code_is_an_error=False is_error False lines of output 3002 last line 'Command exited with code 1' it ended with One test failed: 5 == 6
How wide is that except?
One thing left to settle. The boundary is going to be one try around one call, and you have to say what it catches.
Whatever comes out of tool["execute"](...), your boundary turns it into a result and the run carries on. Should that except catch everything?
except Exception is not except:. Python keeps a few things outside Exception on purpose, for events that are not a failure of the code they interrupt; the lab's stand-in for one is lab.PowerCut, and a hidden test checks that it passes straight out through your boundary untouched.
Saved. Why a tool would ever raise such a thing, and what a harness does about it, comes back in lesson 15.
import lab
# Four things that can come out of tool["execute"](...). Which does `except
# Exception` catch, and which would only a bare `except:` catch?
things = [
FileNotFoundError("File not found: confg.py. Files here: config.py"),
ValueError("path must be a string"),
KeyError("port"),
lab.PowerCut("the power went out inside a tool"),
]
for thing in things:
print(f'{type(thing).__name__:<18} caught by "except Exception": '
f'{isinstance(thing, Exception)}')
FileNotFoundError caught by "except Exception": True ValueError caught by "except Exception": True KeyError caught by "except Exception": True PowerCut caught by "except Exception": False
Build: one call in, one result out
The rungs took the alternatives away one at a time. Not a flag per tool, because the failure that matters is the one nobody listed. Not a class name and not a traceback, because the text is read by the model. Not trust in the schema, because nothing enforced it. Not an error flag on bad news, because the tool did its job. And not a bare except, because not everything coming out of a tool is the tool.
What is left is one rule, and it is the second of the three that carry this course: toolCall the model writes gets exactly one toolResult carrying its id, whether the tool worked, was missing, or blew up in a way nobody had a name for. Your run_tool has exactly one way out.
Figure 4.1 The first panel is what a finished turn looks like: two calls, two results, each tied to its own call by id and sitting right after the message that asked. The other three are lists a provider refuses. Panel two is the shape your raed run left behind — a call with no result, whatever the call asked for — and it took one typo.
The third and fourth panels are not today's work: an orphan result and a late duplicate come from interruptions and from repairs done badly, and lesson 10 is about them. Today you make the second one impossible.
Three gaps, about seventeen lines between them. Two of them already hold a naive version that works until something goes wrong; replace it.
str_arg(arguments, name), four lines. Return the string argumentname. If it is missing, or is there but is not a string, raiseValueError("<name> must be a string")— the argument's own name in the text, because the model has to know which one. An empty string is a string:{"path": "empty.txt", "content": ""}is how a model creates an empty file, and it must not be refused.run_tool(tools, call), about eleven lines, and the part that matters. It returns thetoolResultthat answerscall, and it never raisesException.- A call naming a tool that is not in
tools: contentTool <name> not found,is_errorTrue. Nothing is run. - A tool that raises: content
str(exc)— the message the exception carries, notreprand not a traceback — andis_errorTrue. - Anything else: the content the tool returned,
is_errorFalse. - The lookup and the running are two different steps, and a failure of one is not a failure of the other. A tool that exists and raises
KeyErrorof its own is that tool failing; a model told its tool does not exist stops calling it. - The tool runs once per call. Not once to see whether it raises and again for the answer: a second
rmis not harmless.
- A call naming a tool that is not in
- the status line in
make_bash_tool, two lines. Whencodeis not 0, the result ends with a line of its own, after the output:Command exited with code <code>. It is still an ordinary result — do not raise, do not flag it, do not throw the output away. A command that exits 0 gets its output back untouched, with nothing added.
Given to you, and marked # given in lesson 4 in the diff: str_arg's signature and docstring; make_read_tool, which now reads its argument through str_arg and raises the winning sentence from "Who is the message for?"; make_write_tool(ws), whole-file overwrite, which checks both of its arguments before it touches the disk; and make_bash_tool(shell) without its status line.
Thirteen hidden tests. They script the typo run from the first page and expect it to finish in three model calls; a reply with three calls whose middle one fails, which must still produce three results in order; a tool raising UnicodeDecodeError, which nobody planned for; {"path": 7}, {} and a write with no content; a failing pytest; a command that exits 0; and a tool that raises lab.PowerCut, which must come straight back out through your boundary.
- Your
run_toolcan end three ways: the tool was not there, the tool raised, the tool returned something. Which of those three do you know about before you run anything — and what does that tell you about where thetrybegins and ends? - Look the name up in a way that gives you
Noneinstead of raising, and deal with that case on its own: it is not a failure of any tool, so nothing has been run and there is nothing to catch. Then atryaround the single line that callsexecute, catchingException— not a bareexcept, and not a list of types you had to guess. Decide the content and the flag in each of the three branches and build the result dict once, at the end, out of those two names; one exit cannot disagree with itself, and the tests care that the dict is the same shape every time.str_argis the same idea one size down: fetch it in a way that does not raise, then ask the one question the docstring promises to ask. Forbash, the status line is added to the output, not put in its place. - In outline, gap by gap:
The exact texts are in the brief above, and the tests compare them character for character.str_arg: value = arguments... (fetch without raising) if value is not a string: raise ValueError(...) # the name is in the text return value run_tool: tool = tools_by_name... (look up without raising) if tool is None: content, is_error = ..., True else: try: content, is_error = ..., False except Exception as exc: content, is_error = ..., True return {..., "content": content, "is_error": is_error} bash: if code != 0: output += ... # a line break, then the status sentence
Seventeen lines, and the failure that ended your run on the first page is now a sentence the model reads and acts on. Look at what you did not write: a retry, a list of exception types, or a single if asking which kind of failure this was. Every one of the thirteen scenarios leaves a list a provider would accept, because run_tool has one way out and it always produces a result.
What changed since lesson 03
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- A call to a tool that does not exist gets the result "Tool raed not found", flagged as an error.
- The model mistypes a tool name, reads the error, retries with the right name: three model calls.
- When a tool raises, the model gets the exception's message, no traceback, flagged as an error.
- A tool that exists and hits a KeyError of its own is reported as that failure, not as "not found".
- A tool that does its job gets an ordinary result: is_error is False.
- Three calls in one reply, the second fails: three results, in call order, only the second an error.
- Reading a file that is not there tells the model which file was missing and which files exist.
- str_arg returns a string argument, and raises ValueError("<name> must be a string") otherwise.
- {"path": 7} to read, {} to bash and a write with no content each get "<name> must be a string".
- A tool that hits an exception its author never expected still produces one error result.
- pytest exits 1: the result keeps the output, ends "Command exited with code 1", and is not an error.
- A command that exits 0 returns its output and nothing more.
- A tool that raises lab.PowerCut (the process is dying) is not turned into a result.
A model in a hurry. It mistypes the tool name, then the path, then leaves out an argument — three different mistakes, one after another — and each time it reads what came back and fixes that one thing. No tests: read the transcript. Once the lab has passed, this runs against your code.
import harness, lab
ws = lab.Workspace({"config.py": "PORT = 9090\n", "main.py": "import config\n"})
tools = [harness.make_read_tool(ws), harness.make_write_tool(ws),
harness.make_bash_tool(lab.Shell(ws))]
def careless(request):
"""Our script: a model in a hurry. It mistypes the tool name, then the path,
then leaves out an argument, and each time it reads what came back and fixes
exactly that one thing."""
result = request.last_result
if result is None:
return lab.reply(lab.call("raed", {"path": "confg.py"}))
text = result["content"]
if text.startswith("Tool "):
return lab.reply(lab.call("read", {"path": "confg.py"}))
if text.startswith("File not found"):
return lab.reply(lab.call("read", {"path": "config.py"}))
if "PORT" in text:
return lab.reply(lab.call("write", {"path": "notes.md"}))
if text.endswith("must be a string"):
return lab.reply(lab.call("write", {"path": "notes.md",
"content": "The app listens on 9090.\n"}))
return lab.say("Wrote notes.md. The app listens on port 9090.")
model = lab.ScriptedModel([lab.forever(careless)])
messages = []
harness.run_agent(model, "You are a careful assistant.", messages, tools,
"Note down which port the app listens on.")
print(lab.show(messages))
print()
print("shape: ", lab.shape(messages))
print("model calls:", len(model.calls), " bill:", model.bill, "input tokens")
print("errors: ", sum(1 for m in messages if m.get("is_error")), "of",
sum(1 for m in messages if m["role"] == "toolResult"), "results")
print("valid: ", lab.validate(messages) == [])
print("notes.md: ", ws.read_text("notes.md").strip())
user -> "Note down which port the app listens on."
assistant -> toolCall c1 raed({"path": "confg.py"})
toolResult c1 -> "Tool raed not found" [is_error]
assistant -> toolCall c2 read({"path": "confg.py"})
toolResult c2 -> "File not found: confg.py. Files here: config.py, main.py" [is_error]
assistant -> toolCall c3 read({"path": "config.py"})
toolResult c3 -> "PORT = 9090\n"
assistant -> toolCall c4 write({"path": "notes.md"})
toolResult c4 -> "content must be a string" [is_error]
assistant -> toolCall c5 write({"path": "notes.md", "content": "The app listens on 9090.\n"})
toolResult c5 -> "Successfully wrote to notes.md."
assistant -> "Wrote notes.md. The app listens on port 9090."
shape: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A[c4] R(c4) A[c5] R(c5) A
model calls: 6 bill: 2332 input tokens
errors: 3 of 5 results
valid: True
notes.md: The app listens on 9090.
Six model calls, five results, three of them errors, and a run that ends with the file written and the question answered. Nothing in your code knows anything about typos, missing files or absent arguments; it knows how to hand back a result that says what went wrong. Note the last two lines of the output: the list is valid, so the user can carry on asking questions on it, and notes.md really exists.
2,332 input tokens for a job worth one line of an answer. Six requests, each carrying everything before it, so every one of those three failures was paid for again on every later turn. Error text is text; it is re-sent and re-billed exactly like a file's contents, which is a thought to take into lesson 6.
- Hold a conversation: it knows what was said, and who said it.
- Run a tool the model asks for and show it the result.
- Keep going until the model stops asking.
- Tell the model when a tool fails, and carry on.
Say it in your own words
A sentence or two, in your own words: who is a tool's error message written for, and what does that change about how you write one?
Two readers could be at the other end of a failed read. Only one of them is in a position to type config.py instead of confg.py. Which one, and when do they read it?
The reader is the model, and it reads the message about one second later, as the next thing in its next request, while it is still in the middle of the job. That is why "File not found" on its own is not enough and a traceback is worse: the first leaves it nothing to try, the second sends it off to read your source code. Say what went wrong, in its terms, and leave it a move. You are not reporting a failure. You are writing the next prompt.
Common answers, and what each one misses
- "For the logs, or for me." Then the one party who can retry never hears it. That is the first page of this lesson exactly: your terminal got
KeyError: 'raed', the model got one request that did not mention it, and the run was over. - "For the model, so it should carry as much detail as possible." The traceback was the most detailed of the three texts, and it sent the model to read
harness.py. Detail about your program is not detail about its mistake. - "It should tell the model to try again." It already knows it may try again — it is in a loop that will call it as long as it asks for tools. What it does not know is what to try, and that is the only thing your sentence can add.
When the reader who can fix the mistake is the model, the
Tau runs every tool inside one try, catches Exception, and turns whatever it caught into a result carrying str(exc) with the error flag set. The comment on that line calls tools an isolation boundary.
try:
content, is_error = tool["execute"](call["arguments"]), False
except Exception as exc:
content, is_error = str(exc), True
try:
result = await tool.execute(call.id, call.arguments, signal, on_update)
return result, False, updates
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - tools are an isolation boundary
return _error_result(str(exc)), True, updates
Tau is async: read async for as for and await f(x) as f(x) until lesson 15.
The same shapes.
- The unknown tool is a lookup that returns nothing, answered with a result and a flag rather than an exception, in the same words yours uses:
Tool <name> not found(src/tau_agent/loop.py:308-311), pinned by a test that asserts both the flag and the text (tests/test_agent_loop.py:284-308). - Every string argument a built-in tool reads goes through one shared helper, four lines long, that raises with the argument's name in the message; numbers have a helper of their own, built the same way. Yours is that function (
src/tau_coding/tools.py:1029-1033), and the exception it raises is a subclass ofValueErrorwhose only job is to say "a tool got invalid arguments" (src/tau_coding/tools.py:47-48). It is caught by the boundary above like anything else, so bad arguments and a disk failure come back the same shape. - The
writetool you were given is Tau's, down to the success sentenceSuccessfully wrote to <path>.(src/tau_coding/tools.py:421-470). - A non-zero exit code is a status line appended to the output, and the result is not flagged: nothing raised, so the boundary never saw it (
src/tau_coding/tools.py:675-700).
What Tau adds. The route to an error result is not only the try. Two more branches sit above it, and both produce the same shape — a result and a flag — for a call that was refused by a permission check (src/tau_agent/loop.py:301-303, lesson 14) and for a call that arrives after the run has been cancelled (src/tau_agent/loop.py:304-306, lesson 15). That is the rule you built, holding on paths this lesson has not met yet: whatever happens, the call gets its one result. Tau's results also carry structured details beside the text, and a tool may report progress while it runs; yours returns a string.
Where the two differ in detail. Tau puts a blank line between the output and the status block whenever there is output at all (src/tau_coding/tools.py:764-766); yours appends one newline, so output that already ends in a newline gets a blank line and output that does not gets none. The tests only insist that the status is the last line and stands alone. And Tau's File not found for a missing read is the path and nothing else (src/tau_coding/tools.py:140-144): the listing of what is there, which won the argument in "Who is the message for?", is something your read does and Tau's does not. Tau's coaching sentences live on the edit tool instead, whose ordinary failure is a near miss (src/tau_coding/tools.py:1128-1137).
Where Tau is no stronger than you. Tau's loop does no schema validation either: the arguments the model sent go straight into the tool (src/tau_agent/loop.py:356), and every check is the hand-written kind you just built. It is also deliberately liberal about shapes that arrive wrong — the edit tool will accept its edits as a JSON string and re-parse it, and will assemble an edit out of loose oldText and newText keys (src/tau_coding/tools.py:1062-1081). That code exists because real models send those shapes. [general] Treat a tool's arguments as text a model produced, not as a validated object.
The line you have not written yet. Above the except Exception there is an except asyncio.CancelledError: raise (src/tau_agent/loop.py:358-359). In the Python your harness will be written in from lesson 15, cancelling a running task raises exactly that inside whatever it was waiting on — which here is a tool — and a boundary that swallowed it would answer the model instead of stopping. It is the last rung's question, in the language it really arrives in. Your lab.PowerCut is that shape, borrowed early.
Where yours is weaker. A tool's execute here takes a dict and returns a string, so it cannot report progress, cannot return an image, and cannot say anything a sentence cannot say; Tau's results carry text, structured details and a list of tools the call added. Yours also has no timeout: a tool that never returns hangs the run, and nothing in this lesson can stop it. [general] And every failure in this course is one we scripted. A real run fails in ways nobody wrote down, which is the argument for a boundary that never asks what it is catching.
src/tau_agent/loop.py:343-363 · pinned to commit 9fe6a71 · view on GitHub
One more case
A bash tool that gives every command thirty seconds. The model asks it to run the whole test suite. Thirty seconds pass and the command is still going, so the tool gives up on it: no output, no exit code, nothing to report but the giving up.
Write the exact text the model should get back. Not a description of it — the string, as you would put it in the code.
The model cannot see your clock and does not know the limit exists. What does it need in order to choose its next move, and what moves does it actually have?
Something close to this: Command timed out after 30 seconds. No output was captured. Try a narrower command, or run it in the background and poll for the result. Tau's own status line for this is Command timed out after 30 seconds (src/tau_coding/tools.py:677-680) — the same two moves: say what happened in the tool's terms, and include the number, because the model cannot see it anywhere else.
Two things are worth noticing. The limit belongs in the text: without "30 seconds", "try again" means "run the same command and wait for the same thirty seconds". And the flag is a judgement call here, unlike exit code 1: the tool has nothing to hand back, which is a fair reason to set it, though Tau does not — it appends that status line and returns an ordinary result. Either way the model has to learn both that nothing ran to completion and that something may have been half-done.
Common answers, and what each one misses
- "
TimeoutError", or "Error: timeout". That is rung 3's first text with a longer name on it: the model learns that the call failed, and the only moves left to it are to repeat the call or to give up. - The traceback from whatever raised the timeout. Rung 3's second text, and the run in that cell showed where it leads: the model starts reasoning about your program instead of about its own command.
- "The command took too long. Please try again." Polite and hollow. It withholds the one number that would let the model decide how to try again, and "please" is aimed at a reader who is not there.
- You hit
- a two-letter typo in a tool name, which killed the run and left a transcript that every later prompt would be refused on.
- You built
- the boundary:
run_toolcatchesExceptionand answers every call with exactly one result,str_argchecks what the schema only asked for, andbashreports an exit code as news rather than as a failure. - The principle
- when the reader who can fix the mistake is the model, the error message is a prompt.
- Your harness now
- SYSTEM
- user_message
- text_of
- tool_calls
- str_arg
- tool_specs
- run_tool
- make_read_tool
- make_write_tool
- make_bash_tool
- run_agent
- Your answers
- Still open
- Nothing in your loop can stop. A model that keeps asking for the same missing file is answered politely fifty times and billed for every round; and when the provider itself falls over mid-job, the run ends with nothing to show for it. Lesson 5.