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.

~50 min · 1 lab · builds on 03 Turn the crank

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?

Once. The label says stop and the words say "Done.", so there is nothing left to do
That is the label deciding the run again. Your loop never reads it: it asks the content whether anything was requested, and something was.
Twice. There is a call in the content, so the loop runs it and goes round
One question, asked of one place. Watch which line of the output the loop could possibly have acted on.
Twice, because "Done." is not a real ending: the loop reads the sentence, decides it is not final, and carries on
That has the loop judging prose. Your loop holds no comparison against any word the model wrote; the sentence could have said "Goodbye forever" and nothing about the run would change.

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?

Position. The first result answers the first call, which is why results have to be kept in order
Order is a convention; an id is a fact. Read the two results in the output, then read the sentence the model wrote about them.
The id. Each result carries tool_call_id, and that is the only thing tying it to a call
Which is why a result can be written down at all: it is not "the answer to the last thing", it is the answer to c1.
The contents. One says 9090 and the other says 7070, and the model can work out from the text which file it came out of
Read the two results in the output. Both are the line 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?

The model is told the tool does not exist, and its second step retries with read
Open 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.
The lookup raises, the exception comes out of run_agent, and the run ends on a traceback
A missing key in a dict is an exception, and nothing between that line and your terminal catches it.
The call is quietly skipped. The loop finds no tool, moves on to the next call, and the run carries on without it
That is the belief that a harness can skip what it cannot do and carry on — that a gap in the record costs nothing. It is the kinder design and the worse one; hold the thought for two minutes, and watch what the list looks like afterwards.

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_tool crashed — it needs a try around 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.

You were. A typo in a tool name is a bug, the traceback says which one, and that is what tracebacks are for
You cannot fix this bug. It is not in your code: your tool list is right, your loop is right, and the next run may mistype a different name. The party that wrote raed is the only party that can write read instead.
The model was, and it never found out: not one request it received mentions raed
It wrote the name, it had the correction ready, and the news went to the one reader who could do nothing with it.
The model was, and it did find out: the bad call is in the list, so the next request carries it and the model can see its own mistake
Count the requests in the output. There was no next request — the run died before one could be made — and if there had been, that list is the one a provider refuses.

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.

Both, and A is better. The tool that failed knows more about the failure than anything outside it, and a failure nobody foresaw is a genuine bug that ought to stop the program
Half of that is true: the tool does know most. The other half is the trap. "A failure nobody foresaw" is not a rare case in an agent — it is the normal case, because the model chooses the inputs, and it will hand a text tool a PNG.
Only B. A handles the failure its author listed and lets the other one through, and the other one ends the run
B never asks what went wrong before catching it, which is the only reason it can catch something nobody named.
Both, if each tool wraps its own body in try / except Exception as well as returning the flag. Then nothing can escape, and the failure is handled where the knowledge is
That works, and you now have the same four lines in every tool you will ever write, each free to drift. Worse, each one decides privately what a failure looks like, so the shape of a result becomes a matter of who wrote which tool. Safety that lives inside each tool is safety you have to get right once per tool.

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 tool boundary, and in your harness it is 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.

  1. Error
  2. the traceback, all forty lines of it
  3. 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?

(1) Error. The flag already says the call failed, and that is the part that matters; the text is for whoever reads the logs afterwards
That is error text as a line in a file somebody greps next week. Nobody greps this one. It is read within the second, by the party that is still mid-job, whose only two moves are to repeat the call or to stop.
(2) The traceback. It is the most information of the three, it is what Python already gives you, and models are good at reading code
Models are good at reading code, and that is the danger: the code in a traceback is yours. Your file, your line numbers, your variable names — an invitation to go and debug a program it cannot see, instead of fixing the one letter it left out of a filename.
(3) The sentence that says which file was missing and which files are there
Longer than (1), far shorter than (2), and the only one of the three that answers the question the model is about to ask.

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?

Neither call reaches your code. A missing required argument is caught against the schema before the reply is even handed to you
Nothing between the model and your 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.
Two error results, and neither one tells the model what to send instead
Both fail. What matters is where they fail and what they can say about it by the time they do.
One error result. bash({}) fails, and read({"path": 7}) goes through, because arguments["path"] hands back the 7 and nothing downstream minds
That treats 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 shell simulator, 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.

Yes. A non-zero exit code is how every program on earth reports failure, and the flag is how a failure gets to the model
Whose failure, though? And note what raising the flag costs: at your boundary the only way a tool can set it is to raise, and an exception carries a message, not three thousand lines of test output. Follow the two runs and watch what the model is left with in each.
No. 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
The flag is about the tool, not about the world the tool reported on.
Not flagged, but the result should be the status line on its own. Three thousand lines of test output will be re-sent in every later request, and the exit code is the part that carries the meaning
The exit code says a test failed. It does not say which, or what the assertion was, which is the entire reason the model ran the tests. You are right about the bill, though, and that bill gets a lesson of its own.

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 is_error means the tool could not do its job, not that the news was bad. The key has been sitting in your results since lesson 2, always 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?

Yes. Anything that comes out of a tool is news for the model, and the whole point of the boundary is that a run never dies inside one
Almost. It is true of everything a tool can fail with. The question is whether everything that comes out of a tool is the tool failing.
No. Some things that come out of a tool are not that tool failing — a tool can raise because the whole process is being told to stop
"The tool could not do its job" and "this program is ending" are different pieces of news, and only one of them is the model's business.
It should catch less, not more: name the exceptions each tool can raise, so that a bug in your own code still reaches you instead of being reported to the model as a tool failure
Back in "Two designs" that list is exactly what cost you the run, and the list is not the new question. The new question is at the other end: is there anything a tool can throw that your loop has no business swallowing?

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: one call in, exactly one result out, right after it. Every 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.

One call in, one result out Every toolCall in an assistant message must be answered by exactly one toolResult with the same id, placed right after that assistant message, in call order. The first panel is valid: calls c1 and c2 are each tethered to one result. Three panels are broken: a call with no result; an orphan result whose call is not in the list; and a result that comes late, after a user message, followed by a duplicate for the same call. validone result per call, right after it 0userWhich ports do they use? 1assistantread(path="config.py")c1read(path="backup.py")c2 2toolResult · c1PORT = 9090 3toolResult · c2PORT = 7070 id: the model names each call, c1 and c2tool_call_id: the result names its callthe dotted tether joins each pair message 2 in your code:{"role": "toolResult","tool_call_id": "c1","tool_name": "read","content": "PORT = 9090\n","is_error": False} missinga call that nobody answered 0userWhat is in config.py? 1assistantread(path="config.py")c1 toolResult · c1no result 2userNever mind, do X. orphana result whose call is not in the list 0userWhat is in config.py? 1assistantI will check the file. 2toolResult · c1DEBUG = Trueorphan lateduplicatenot right after its call; answered twice 0userWhat is in config.py? 1assistantread(path="config.py")c1 2userNever mind, do X. 3toolResult · c1DEBUG = Truelate 4toolResult · c1DEBUG = Trueduplicate

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.

  1. str_arg(arguments, name), four lines. Return the string argument name. If it is missing, or is there but is not a string, raise ValueError("<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.
  2. run_tool(tools, call), about eleven lines, and the part that matters. It returns the toolResult that answers call, and it never raises Exception.
    • A call naming a tool that is not in tools: content Tool <name> not found, is_error True. Nothing is run.
    • A tool that raises: content str(exc) — the message the exception carries, not repr and not a traceback — and is_error True.
    • Anything else: the content the tool returned, is_error False.
    • 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 KeyError of 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 rm is not harmless.
  3. the status line in make_bash_tool, two lines. When code is 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.

  1. Your run_tool can 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 the try begins and ends?
  2. Look the name up in a way that gives you None instead 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 a try around the single line that calls execute, catching Exception — not a bare except, 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_arg is 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. For bash, the status line is added to the output, not put in its place.
  3. In outline, gap by gap:
    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
    The exact texts are in the brief above, and the tests compare them character for character.

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.

  1. A call to a tool that does not exist gets the result "Tool raed not found", flagged as an error.
  2. The model mistypes a tool name, reads the error, retries with the right name: three model calls.
  3. When a tool raises, the model gets the exception's message, no traceback, flagged as an error.
  4. A tool that exists and hits a KeyError of its own is reported as that failure, not as "not found".
  5. A tool that does its job gets an ordinary result: is_error is False.
  6. Three calls in one reply, the second fails: three results, in call order, only the second an error.
  7. Reading a file that is not there tells the model which file was missing and which files exist.
  8. str_arg returns a string argument, and raises ValueError("<name> must be a string") otherwise.
  9. {"path": 7} to read, {} to bash and a write with no content each get "<name> must be a string".
  10. A tool that hits an exception its author never expected still produces one error result.
  11. pytest exits 1: the result keeps the output, ends "Command exited with code 1", and is not an error.
  12. A command that exits 0 returns its output and nothing more.
  13. 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 error message is a prompt.

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 of ValueError whose 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 write tool you were given is Tau's, down to the success sentence Successfully 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_tool catches Exception and answers every call with exactly one result, str_arg checks what the schema only asked for, and bash reports 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.