05 · Things go wrong
Knowing when to stop
A confused model asks to read the same missing file fifty times running; then the provider falls over, and the run ends leaving no sign that anything happened.
This lesson builds on lesson 04. New here? Start at lesson 01, or carry on: every lab is self-contained.
A tool called average divides the total by how many numbers there were. The column is empty, so it divides by zero and a ZeroDivisionError comes out of it — a kind of failure nobody wrote a line about. Your lesson 4 loop runs it. What does the next request hold?
ZeroDivisionError is a bug, so the run ends and the traceback goes to whoever is watching
toolResult for it flagged is_error, carrying the exception's own words
Exception, not one hand-picked type, which is why a failure nobody predicted still comes back as a result.The model asked, the tool failed, the failure came back as its answer, and the model wrote a sentence a person can read. Nothing raised, and nothing was lost. Hold on to the shape of that: today two more things go wrong, and neither of them is a tool.
import harness, lab
def average(arguments):
numbers = [int(word) for word in harness.str_arg(arguments, "numbers").split()]
return str(sum(numbers) / len(numbers)) # nobody checked that there are any
mean = {"name": "average", "description": "Average a space-separated list of numbers.",
"parameters": {"type": "object", "required": ["numbers"],
"properties": {"numbers": {"type": "string",
"description": "The numbers."}}},
"execute": average}
model = lab.ScriptedModel([
lab.reply(lab.call("average", {"numbers": ""})),
lab.say("That column is empty, so there is no average to report."),
])
messages = []
harness.run_agent(model, harness.SYSTEM, messages, [mean], "Average the timings column.")
print("requests made:", len(model.calls))
print("request 1 held:", lab.shape(model.calls[0].messages))
print("request 2 held:", lab.shape(model.calls[1].messages))
arrived = model.calls[1].messages[-1]
print("the message that arrived: role", arrived["role"],
"| tool_name", arrived["tool_name"], "| is_error", arrived["is_error"])
print("the model then said:", harness.text_of(messages[-1]))
requests made: 2 request 1 held: U request 2 held: U A[c1] R(c1) the message that arrived: role toolResult | tool_name average | is_error True the model then said: That column is empty, so there is no average to report.
Two support chats, same sort of messages, same sort of replies. One has run 10 turns, the other 30. Counting only the input side, roughly how many times more has the 30-turn chat cost in total than the 10-turn one?
About nine. Every turn resends everything before it, so the running total grows with the square of the length: three times as long is about nine times the bill. That is lesson 1, still true and still compounding. Today you will watch the same arithmetic run fifty turns deep without anybody asking it to.
The model that will not stop
Your lesson 4 harness does not crash. A tool that raises comes back as a result the model can read; a tool name nobody ever defined comes back as a sentence. The loop keeps going until the model stops asking for tools, and that has been the whole of "when does a run end" since lesson 3.
Below is a model that does not stop asking. It wants to read missing.py. The file is not there, so read says so and lists the files that are. The model reads that, and asks for missing.py again. [general] Real models get stuck this way: one that has locked on to a wrong idea repeats it, and so does one being handed a tool result it cannot make sense of.
The lab's model has a guard on it, because this page has to load. A real one does not.
Your loop, unchanged, against that model. What stops this run?
while.Nothing in the loop was ever going to stop it: the one exit is "the reply has no calls", and every one of those replies had one. Fifty requests answered, 87,125 input tokens, for a file that does not exist. [general] Nothing at the far end knows these fifty belong together: each arrives as its own valid request, and each is priced and answered on its own. Request 51 was refused by the lab, which told you what the run had cost — the one thing it does here that a real provider will not do for you.
Where this comes from: Tau pins the cure with a regression test. It scripts one reply that asks for a tool, runs the loop with max_turns=1, and asserts that exactly one request was made and that the run ends on an assistant message reading Agent stopped after max_turns=1 (tests/test_agent_loop.py:465-488).
import harness, lab
# The model: it asks to read missing.py, and it asks again, and again.
model = lab.ScriptedModel([lab.stuck(lab.call("read", {"path": "missing.py"}))])
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
tools = [harness.make_read_tool(ws)]
messages = []
try:
harness.run_agent(model, harness.SYSTEM, messages, tools, "Find the port.")
print("the run ended by itself")
except lab.Runaway as stop:
print(stop)
print("model calls:", len(model.calls))
print("messages on the record:", len(messages))
print("the first four:", lab.shape(messages[:4]))
labels = sorted({m["stop_reason"] for m in messages if m["role"] == "assistant"})
print("stop_reason on every one of those replies:", ", ".join(labels))
print("the tool said so every time, is_error:", messages[2]["is_error"])
model call 51 refused: max_calls=50. This run has already cost 87125 input tokens, and nothing in your loop was going to stop it. model calls: 51 messages on the record: 101 the first four: U A[c1] R(c1) A[c2] stop_reason on every one of those replies: toolUse the tool said so every time, is_error: True
A number, and where you look at it
The fix is a number: at most N model calls in one run, passed by whoever started the run. That part is not interesting. Where you look at it is.
You give run_agent a max_turns=1, so: one model call. The model's one reply asks to read missing.py. Here are the four messages the run leaves on the caller's list when the limit is checked at the top of a turn, before the model is called. Put them in the order the loop appends them.
user· Find the port.assistant·read(path="missing.py")· c1toolResult· c1 · File not found: missing.py. Files here: config.pyassistant· (nothing) · error: Agent stopped after max_turns=1
The stop is last, after the result of the call the model had already asked for. That is not luck: by the top of a turn, every call of the turn before it has been answered, so a limit checked there cannot leave one hanging. Move the check one line earlier — look at it as soon as the reply arrives, which reads perfectly sensibly — and the run ends with a call nobody answered. The cell runs both and asks the strict checker what it makes of each record.
Two loops, identical except for one line: design A looks at the limit as soon as the model has replied, design B looks at it at the top of a turn. Same model, same max_turns=1. Read the two records, and the last line under each.
import harness, lab
def stop_message(text):
"""An assistant message with nothing in it, saying why the run stopped."""
return {"role": "assistant", "content": [], "stop_reason": "error", "error_message": text}
def design_a(model, system, messages, tools, prompt, *, max_turns):
"""A: check the limit as soon as the model has replied."""
turn = 0
messages.append(harness.user_message(prompt))
while True:
reply = model.complete(system, messages, harness.tool_specs(tools))
messages.append(reply)
turn += 1
if turn >= max_turns:
stop = stop_message(f"Agent stopped after max_turns={max_turns}")
messages.append(stop)
return stop
calls = harness.tool_calls(reply)
if not calls:
return reply
for call in calls:
messages.append(harness.run_tool(tools, call))
def design_b(model, system, messages, tools, prompt, *, max_turns):
"""B: check the limit at the top of a turn, before the model is called."""
turn = 0
messages.append(harness.user_message(prompt))
while True:
turn += 1
if turn > max_turns:
stop = stop_message(f"Agent stopped after max_turns={max_turns}")
messages.append(stop)
return stop
reply = model.complete(system, messages, harness.tool_specs(tools))
messages.append(reply)
calls = harness.tool_calls(reply)
if not calls:
return reply
for call in calls:
messages.append(harness.run_tool(tools, call))
for name, run in (("A", design_a), ("B", design_b)):
model = lab.ScriptedModel([lab.stuck(lab.call("read", {"path": "missing.py"}))])
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
messages = []
run(model, harness.SYSTEM, messages, [harness.make_read_tool(ws)],
"Find the port.", max_turns=1)
asked = sum(len(harness.tool_calls(m)) for m in messages if m["role"] == "assistant")
answered = sum(1 for m in messages if m["role"] == "toolResult")
print(f"design {name}: model calls: {len(model.calls)} record: {lab.shape(messages)}")
print(f" calls asked for: {asked} results recorded: {answered}")
print(" anything left open in the record:",
lab.validate(messages, record=True) or "nothing")
design A: model calls: 1 record: U A[c1] A(error)
calls asked for: 1 results recorded: 0
anything left open in the record: ['messages[1]: toolCall c1 has no toolResult']
design B: model calls: 1 record: U A[c1] R(c1) A(error)
calls asked for: 1 results recorded: 1
anything left open in the record: nothing
Design A is not slower and not more expensive. It made exactly one model call, like B, and paid exactly the same. It just left a hole, and the checker names it: toolCall c1 has no toolResult.
So: the cap is max_turns
Now the other half. The order cards put a message on the list when the limit was reached; this is where you find out what that choice buys, and what the two obvious alternatives cost. Three ways to end a run without an answer, all easy to write. Before you pick, hold three things in mind: what the caller is holding when it is over, what tomorrow's prompt would send, and what somebody reading a saved copy of this session next year would be able to tell.
MaxTurnsExceeded. It is exactly what exceptions are for, and the caller decides what to do about it
error, carrying the reason, and return it
All three leave the caller the same five messages, because run_agent appends as it goes; only one leaves a sixth that says why there is no answer. The first two hand tomorrow's prompt a transcript in which nothing went wrong: it simply stops after a tool result, exactly as a transcript looks when the model has read the file and is about to say something useful, so nothing the model is sent tells it the job was cut short. The raise costs one thing more: every caller must now wrap the run, and a caller that forgets is killed by the very thing it was meant to survive. A stop written into the transcript rather than thrown is an
import harness, lab
class MaxTurnsExceeded(Exception):
"""What the raising design raises."""
def on_limit_raise(messages, max_turns):
raise MaxTurnsExceeded(f"Agent stopped after max_turns={max_turns}")
def on_limit_quiet(messages, max_turns):
return None # the run just ends
def on_limit_in_band(messages, max_turns):
stop = {"role": "assistant", "content": [], "stop_reason": "error",
"error_message": f"Agent stopped after max_turns={max_turns}"}
messages.append(stop)
return stop
def loop(model, system, messages, tools, prompt, *, max_turns, on_limit):
"""Lesson 3's loop, with the limit checked at the top of a turn."""
turn = 0
messages.append(harness.user_message(prompt))
while True:
turn += 1
if turn > max_turns:
return on_limit(messages, max_turns)
reply = model.complete(system, messages, harness.tool_specs(tools))
messages.append(reply)
calls = harness.tool_calls(reply)
if not calls:
return reply
for call in calls:
messages.append(harness.run_tool(tools, call))
for label, on_limit in (("raise MaxTurnsExceeded", on_limit_raise),
("return quietly", on_limit_quiet),
("append an assistant message", on_limit_in_band)):
model = lab.ScriptedModel([lab.stuck(lab.call("read", {"path": "missing.py"}))])
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
messages = []
print(label)
try:
returned = loop(model, harness.SYSTEM, messages, [harness.make_read_tool(ws)],
"Find the port.", max_turns=2, on_limit=on_limit)
print(" run_agent returned:", "an assistant message" if returned else repr(returned))
except MaxTurnsExceeded as exc:
print(f" run_agent raised: MaxTurnsExceeded({str(exc)!r})")
print(" the caller's list:", lab.shape(messages))
print(" it ends on:", lab.show(messages[-1:]).replace("\n", " "))
print(" tomorrow's prompt would send:",
lab.shape(messages + [harness.user_message("Look again.")]))
raise MaxTurnsExceeded
run_agent raised: MaxTurnsExceeded('Agent stopped after max_turns=2')
the caller's list: U A[c1] R(c1) A[c2] R(c2)
it ends on: toolResult c2 -> "File not found: missing.py. Files here: config.py" [is_error]
tomorrow's prompt would send: U A[c1] R(c1) A[c2] R(c2) U
return quietly
run_agent returned: None
the caller's list: U A[c1] R(c1) A[c2] R(c2)
it ends on: toolResult c2 -> "File not found: missing.py. Files here: config.py" [is_error]
tomorrow's prompt would send: U A[c1] R(c1) A[c2] R(c2) U
append an assistant message
run_agent returned: an assistant message
the caller's list: U A[c1] R(c1) A[c2] R(c2) A(error)
it ends on: assistant -> (nothing) [error: Agent stopped after max_turns=2]
tomorrow's prompt would send: U A[c1] R(c1) A[c2] R(c2) A(error) U
Something else can end a run, and it is not your fault or the model's. Your request goes out; the provider answers 503 overloaded and nothing else. No text was generated, there is no reply to append, and the transcript stops mid-air. Same principle as the limit: what goes in the transcript, and what does the loop do next? Two or three sentences.
You have just decided what a stop should look like. Ask what is genuinely different about this one — and in particular, whether any of the difference is visible to the person who types the next prompt.
Nothing is different. It is a fact about this conversation that the conversation has to carry, so it is an assistant message with nothing in it, labelled error, holding the provider's own words, appended like any other message; and then the run is over, before any tool runs. Two ways to end badly, one door out. The reason the door is the same is worth saying plainly: whatever ends a run, the next prompt is going to be sent on top of whatever the run left behind.
Common answers, and what each one misses
- "Retry it. A 503 usually clears in a second." Often true, and retrying belongs somewhere — just not here. Retries run out, and when they do something has to happen, and this is that something. [general] A provider adapter that retries hides the failures it recovers from; the loop only ever sees the final one.
- "Raise it. The caller asked for an answer and there is no answer." The rung above priced that: the caller keeps the list either way, and nobody is left holding the reason.
- "Put in a
usermessage saying the provider failed." Then the transcript says you said it. A role tag is a claim about who spoke, and a model that takes it at face value may well apologise to you for its own provider's outage.
So you write it down, faithfully. The list is now U A(error): the prompt, and an assistant message with nothing in it holding 503 overloaded. The user, who has been watching a spinner, types "try again". Your loop appends it and sends the list. What comes back?
A provider will not take an assistant turn with nothing in it, so one 503 poisons the list for good: the same 400, prompt after prompt, and the script's remaining answer never gets used. Deleting the empty message clears it and throws away the only record that the run was interrupted. The last block does neither. It builds a different list for the request, leaving that message out, and leaves the list alone — and the answer comes straight back.
That is the split, and it will not go away again: the record is what happened; the
Where this comes from: Tau's loop keeps failures in its durable history and builds a separate list for the provider, because "an empty failed or aborted turn is not model context and must not poison the next request" (src/tau_agent/loop.py:186-191). The behaviour is pinned by a test that asserts the failed message is still in the transcript and not in the next request (tests/test_agent_loop.py:341-380).
import harness, lab
model = lab.ScriptedModel([lab.fail("503 overloaded"), lab.say("The port is 9090.")])
messages = []
def answer(reply):
"""What came back: the model's words, or the reason the reply failed."""
return reply.get("error_message") or harness.text_of(reply)
harness.run_agent(model, harness.SYSTEM, messages, [], "Find the port.")
print("prompt 1 'Find the port.'")
print(" the record now:", lab.shape(messages))
print(" it ends on:", lab.show(messages[-1:]))
for prompt in ("try again", "please, the port"):
reply = harness.run_agent(model, harness.SYSTEM, messages, [], prompt)
print(f"prompt {len(model.calls)} {prompt!r}")
print(" sent:", lab.shape(model.calls[-1].messages))
print(" back:", answer(reply))
print("script steps never used:", model.remaining)
# The same conversation, in a list built for the request: the message with
# nothing in it is left out. The record is not touched.
view = [m for m in messages if m["content"] or m["role"] != "assistant"]
print()
print("sending", lab.shape(view), "instead of", lab.shape(messages))
print(" back:", answer(model.complete(harness.SYSTEM, view, [])))
print(" the record still holds:", lab.shape(messages))
prompt 1 'Find the port.' the record now: U A(error) it ends on: assistant -> (nothing) [error: 503 overloaded] prompt 2 'try again' sent: U A(error) U back: 400 invalid_request: messages[1]: assistant message has empty content prompt 3 'please, the port' sent: U A(error) U A(error) U back: 400 invalid_request: messages[1]: assistant message has empty content script steps never used: 1 sending U U U instead of U A(error) U A(error) U A(error) back: The port is 9090. the record still holds: U A(error) U A(error) U A(error)
One opinion, and there is no right answer to it. You are shipping run_agent as a library. With max_turns=None as the default, an agent runs uncapped unless the caller asks for a cap. Tau ships that way. Tap every consequence that is actually true of that default, then, in the line below, say what you would ship.
- A caller who never passes
max_turnscan be billed for a run that never ends. - A long job that legitimately needs forty turns is never cut short by a number somebody else guessed.
- A caller who wants a cap has to edit the library to get one.
- With a default cap in place, a runaway becomes impossible.
The first two are both true at once, which is why this is a judgement and not a bug. The third is not: the argument is right there in the signature. The fourth is the one worth arguing about — a cap bounds turns, not money, and one turn that reads a 5,000-line log can cost more than forty small ones. That is lesson 6.
[general] Harnesses that run unattended usually carry a cap, a spend limit, or both: the price of guessing the number too low is an annoyed user, and the price of not guessing is an invoice. Tau declares the argument and defaults it to None (src/tau_agent/harness.py:44), and no code in Tau's own source ever sets it, so the coding agent it ships is uncapped.
Build: three exits, one door
No new Python today. One thing worth naming: message.get("stop_reason") returns None instead of raising when a message has no such key, and some of the messages on the list — a toolResult, a user message — do not have one.
Three gaps in harness.py, about 20 lines in total. The signatures and docstrings are there; the bodies are yours.
error_message(text), in region 1: an assistant message withcontentempty,stop_reason"error", andtextinerror_message. The words go in that key, not in a content block: the model never said them.run_agenthas gainedmax_turns=None. Three changes to lesson 3's body. At the top of a turn, before the model is called: if this run has already mademax_turnsmodel calls, the reply iserror_message(f"Agent stopped after max_turns={max_turns}")instead of a real one. A reply whosestop_reasonis"error"is appended like any other and ends the run, before any tool runs. And the model is sentcontext_for_model(messages), never the list itself.- Count this run's model calls in a local counter. The caller's list may already hold three earlier runs, and those are not yours to count.
max_turns=Nonemeans no limit at all. context_for_model(messages), at the bottom of region 3: a new list holding everything except assistant messages that failed and are empty. A failed reply that got some text out stays in. An emptytoolResultstays in: it is still the answer to its call. The list it was given comes back untouched.
The tests run two prompts on one list, let a thirteen-call job run to the end with no cap, cut a reply off half way through a sentence, and check the record after every exit. Nothing is allowed to raise.
- Two of your three exits end in exactly the same two lines: a message goes on the list, and the run is over. Which lines of your loop already do that for an ordinary answer? And if the limit were looked at after the model replied rather than before, who would be left to answer the calls that reply asked for?
- Give the run a counter of its own, starting at zero, and raise it at the top of the
while, before anything else happens. There, decide where the reply comes from: the model, orerror_message. Append it either way — and then the limit and the 503 are the same case, because both are a reply whosestop_reasonis"error", and oneifends the run for both, above the line that looks for tool calls.context_for_modelis one list comprehension overmessagesthat returns a new list. Readstop_reasonwith.get, so a message that has no such key is kept. Call it on the line that calls the model, inside the loop, so the view is rebuilt for every request: the model has to see the result of the tool it just asked for. - Nearly the code:
run_agent(..., max_turns=None): turn = 0 append the prompt to messages loop for ever: turn = turn + 1 if there is a limit and turn is past it: reply = error_message("Agent stopped after max_turns=N") else: reply = the model, sent the view of messages append reply if reply's stop_reason is "error": return reply calls = the reply's tool calls if there are none: return reply for each call: append run_tool(tools, call) context_for_model(messages): a new list of every message except the ones that are role assistant AND content empty AND stop_reason "error"
Three ways out, and all three leave a transcript the next prompt can be sent on top of. The limit and the 503 needed one exit between them, not two: the limit is a failed reply that never went over the wire, so the same two lines end the run for both. The last test stopped a run at the limit, took a new prompt on the same list, and got a real answer back.
What changed since lesson 04
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- error_message(text) is an assistant message with no content, stop_reason "error", and the text in error_message.
- max_turns=1 against a stuck model: one model call, its tool call answered, then an in-band stop.
- max_turns=3 allows exactly three model calls, in every run, however long the list already is.
- max_turns=None means no limit: twelve requests in a row are all served, and a limit of 20 changes nothing.
- The view is worked out again for every model call: the model sees the result of the tool it just asked for.
- The provider answers 503: the failed reply goes on the record and the run ends without raising.
- After a 503 the user says "try again": the failed message stays on the record and is not sent.
- A reply that failed after producing text is sent back to the model like any other message.
- context_for_model drops assistant messages that failed and are empty, keeps the rest, and never edits the record.
- A run stopped by max_turns leaves a list the next prompt is accepted on, and the model recovers.
Your harness, a workspace with three real files, and a model convinced the port is in settings.py, which does not exist. Three turns, then the limit. Then the user says the file is not there and to try something else — on the same list, which still holds the stop. Watch what the second run is allowed to send.
import harness, lab
FILES = {"config.py": "PORT = 9090\nDEBUG = False\n",
"README.md": "Start it with python -m app.\n",
"app/main.py": "from config import PORT\n"}
def confused(request):
"""Asks for settings.py for ever. Once a user message says to try something
else, it looks at config.py instead, and answers from what it reads."""
if "try something else" in request.user_text.lower():
if request.last_result and "PORT" in request.last_result["content"]:
return lab.say("The port is 9090. It is set in config.py, not settings.py.")
return lab.reply(lab.call("read", {"path": "config.py"}))
return lab.reply(lab.call("read", {"path": "settings.py"}))
ws = lab.Workspace(FILES)
model = lab.ScriptedModel([lab.forever(confused)])
tools = [harness.make_read_tool(ws)]
messages = []
harness.run_agent(model, harness.SYSTEM, messages, tools,
"What port does this app use?", max_turns=3)
print("run 1 model calls:", len(model.calls), " record:", lab.shape(messages))
print(" ends on:", lab.show(messages[-1:]))
reply = harness.run_agent(model, harness.SYSTEM, messages, tools,
"settings.py does not exist. Try something else.", max_turns=3)
print("run 2 model calls:", len(model.calls), " record:", lab.shape(messages))
print(" the agent says:", harness.text_of(reply))
print()
print("the request that got that answer:", lab.shape(model.calls[-1].messages))
print("the record it was built from: ", lab.shape(messages[:-1]))
print("anything wrong with the record:", lab.validate(messages, record=True))
print("paid for, in input tokens:", model.bill)
run 1 model calls: 3 record: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A(error)
ends on: assistant -> (nothing) [error: Agent stopped after max_turns=3]
run 2 model calls: 5 record: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A(error) U A[c4] R(c4) A
the agent says: The port is 9090. It is set in config.py, not settings.py.
the request that got that answer: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) U A[c4] R(c4)
the record it was built from: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A(error) U A[c4] R(c4)
anything wrong with the record: []
paid for, in input tokens: 1170
The stop message is on the record and is not in the request. Nobody deleted anything, nobody mended anything, and the second run needed no special case for "the last one was cut off": it sent the view, and the view is a list a provider will take. Under twelve hundred input tokens in all, and the user got an answer.
You have two lists now where you had one. One is added to and never edited; the other is worked out from it afresh for every request, and is thrown away as soon as the request is sent. Today it leaves out one kind of message. It will learn more rules, and the first list will not change by a line: lesson 10 gives that split its name.
Figure 5.1 Lesson 3's machine with every way out drawn on it. Under each exit: the last message it leaves on the list.
- Exit 1, the one you have had since lesson 3: the reply asks for no tools, so return it. The list ends on an ordinary
assistantmessage. - Exit 2, the limit. It leaves from the top of the crank, before the model call, and the list ends on
assistant · error,Agent stopped after max_turns=1. - Exit 3, the failed reply. It is appended, and then the same
ifthat ends the run for the limit ends it here: the list ends onassistant · error,503 overloaded. - Not an exit: a tool name that does not exist. That is a result with
is_errorset, the loop goes round again, and the model gets to fix it. Lesson 4's rule, unchanged. - The exit you must never build, crossed out: a limit checked between the reply and its tools. It leaves c1 open — the slot where
toolResult · c1belongs stays empty — and a record with a hole in it can never be sent again.
- 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.
- Stop a runaway, and survive a provider failure.
In your own words, a sentence or two. Your run can now end three ways: the model stops asking, the limit is reached, the provider falls over. Say what the three have in common — and what it costs you when an exit breaks the pattern.
The first cell on this page ran an exit that broke it. Name what the caller could not do afterwards, and for how long.
However a run ends, the last thing it does is write down what happened, in a list the next prompt can be sent on top of. One rule, not three, which is why the limit and the 503 leave by one door, and why a token budget would leave by the same one. Break it — leave a call unanswered, or keep the reason in an exception nobody stores — and the damage is not to this run, which is over in any case. It is to every conversation that follows on that list, which is all of them.
Common answers, and what each one misses
- "All three return the final message." True of your function, and it is the part that matters least. The caller held the list all along; lesson 3 settled that the return value is a convenience and not the product of a run.
- "All three append an assistant message." Close, and it misses the test. The question is never "did you append something", it is "would a provider take this list tomorrow" — which is why the limit is looked at before the model call and not after. The appending was never the hard part.
- "All three leave the caller to check what came back." That is the raise in a quieter coat. A rule that every caller has to remember is a rule some caller will not, and the one who forgets is the one who most needed it.
Stopping is not an accident to throw; it is a fact to write down.
Tau checks the limit at the top of a turn, writes the stop into the transcript as an assistant message with no content, and ends a failed reply with the same two lines. Word for word the same stop text as yours.
turn += 1
if max_turns is not None and turn > max_turns:
reply = error_message(f"Agent stopped after max_turns={max_turns}")
else:
reply = model.complete(system, context_for_model(messages), tool_specs(tools))
messages.append(reply)
if reply["stop_reason"] == "error":
return reply
if max_turns is not None and turn > max_turns:
error = _error_message(model, f"Agent stopped after max_turns={max_turns}")
messages.append(error)
...
yield TurnEndEvent(message=error)
yield AgentEndEvent(messages=new_messages)
return
...
messages.append(assistant)
if assistant.stop_reason in {"error", "aborted"}:
yield TurnEndEvent(message=assistant)
yield AgentEndEvent(messages=new_messages)
return
The same shapes.
- The stop message is built by one small function that puts the words in
error_messageand leavescontentempty, exactly as yours does (src/tau_agent/loop.py:370-376). - The turn counter is local to the run and starts at 1, so a transcript that already holds ten turns does not eat this run's budget (
src/tau_agent/loop.py:94). - The view is a separate function, called on every request, that drops assistant messages that failed and have no content (
src/tau_agent/loop.py:185-201). Its docstring is the other half of this lesson in Tau's words: failures are kept for diagnostics, and an empty failed turn "is not model context".
Tau is async: until lesson 15, read async for as for and await f(x) as f(x).
What Tau adds. Two more ways for a run to end without an answer. A max_turns below 1 ends the run at once with a message about the argument, max_turns must be at least 1 (src/tau_agent/loop.py:83-91). Yours also makes no model call for max_turns=0 — it counts before it calls — but it writes down Agent stopped after max_turns=0, which is true and says nothing about the argument being nonsense; hand it -1 and it does the same. And a provider that streams nothing at all — no message, no error — gets an assistant message invented for it, Provider produced no assistant message, so that even a provider that simply stops talking leaves a transcript (src/tau_agent/loop.py:141-144).
Both of Tau's checks — the one that ends the run, and the one that filters the view — cover aborted beside error (src/tau_agent/loop.py:148, src/tau_agent/loop.py:192-199). Nothing in your code produces an aborted reply yet. In lesson 15 something will, and those same two lines of yours — the exit and the filter — will each gain a word. Tau's view does one more job than yours, on the same request: it repairs the pairing of calls and results before handing the list over (src/tau_agent/loop.py:201). That is lesson 10, and it is the second rule the view learns.
What Tau declares and does not use. max_turns is a configuration field that defaults to None (src/tau_agent/harness.py:44), and no code in Tau's own source ever sets it. The cap exists for whoever embeds the harness; the coding agent Tau ships is uncapped, which is the answer the opinion rung was really about.
Where yours is weaker. A 503 that reaches Tau's loop has already been given up on. Retries with backoff sit inside the provider adapter, below the loop (src/tau_ai/retry.py:15-20), and the Anthropic adapter retries a 429 and anything from 500 up until it runs out of attempts (src/tau_ai/anthropic.py:377-380). Yours has no retry and no cost budget; the Transfer question below is the shape the second one would take. And your record and your view are two plain lists holding the same dicts, so a caller who edits a message in one edits it in both. Tau's view shares its messages exactly the same way; what it does not share is the shape, because a Tau message is a declared model that refuses a field nobody declared, where a dict will take any key you hand it.
src/tau_agent/loop.py:112-120,146-151 · pinned to commit 9fe6a71 · view on GitHub
Transfer. A turn cap is a poor proxy for what you actually care about, which is money. So replace it: the run may spend at most BUDGET input tokens, and every reply tells you what its request cost, in reply["usage"]["input"]. Where does the check go, and what does it leave behind? Pick a placement, give your reason in one line, then run all three.
BudgetExceeded when the run has spent too much. One place, and no loop to change
MaxTurnsExceeded had.Checked after the reply, the budget runs out on a reply that had just asked for c3, and c3 is never answered: three calls, two results, and the strict validator names the hole before the next prompt can even go out. Checked at the top of a turn, the record is balanced, and the next prompt is valid. The wrapper stops at the right moment and leaves nothing behind, so the transcript ends mid-conversation with no sign that a budget was ever involved. The number changed; where you look at it, and what you write down when you do, did not.
import harness, lab
BUDGET = 400 # input tokens this run may spend
class BudgetExceeded(Exception):
"""What the wrapper raises."""
def loop(model, system, messages, tools, prompt, *, placement):
"""Your lesson 5 loop with max_turns replaced by a token budget."""
spent = 0
messages.append(harness.user_message(prompt))
while True:
if placement == "wrapper" and spent > BUDGET:
raise BudgetExceeded(f"spent {spent} of {BUDGET} input tokens")
if placement == "top" and spent > BUDGET:
stop = harness.error_message(
f"Agent stopped after spending {spent} of {BUDGET} input tokens")
messages.append(stop)
return stop
reply = model.complete(system, harness.context_for_model(messages),
harness.tool_specs(tools))
messages.append(reply)
spent += reply["usage"]["input"]
if placement == "after" and spent > BUDGET:
stop = harness.error_message(
f"Agent stopped after spending {spent} of {BUDGET} input tokens")
messages.append(stop)
return stop
calls = harness.tool_calls(reply)
if not calls:
return reply
for call in calls:
messages.append(harness.run_tool(tools, call))
for placement in ("after", "top", "wrapper"):
model = lab.ScriptedModel([lab.stuck(lab.call("read", {"path": "missing.py"}))])
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
messages = []
print("checked", placement)
try:
loop(model, harness.SYSTEM, messages, [harness.make_read_tool(ws)],
"Find the port.", placement=placement)
except BudgetExceeded as exc:
print(f" raised BudgetExceeded({str(exc)!r})")
asked = sum(len(harness.tool_calls(m)) for m in messages if m["role"] == "assistant")
answered = sum(1 for m in messages if m["role"] == "toolResult")
print(" record:", lab.shape(messages))
print(" calls asked for:", asked, " results recorded:", answered)
following = harness.context_for_model(messages) + [harness.user_message("Look again.")]
print(" the next prompt would send:", lab.shape(following))
print(" a strict provider says:", lab.validate(following) or "nothing; it is valid")
checked after
record: U A[c1] R(c1) A[c2] R(c2) A[c3] A(error)
calls asked for: 3 results recorded: 2
the next prompt would send: U A[c1] R(c1) A[c2] R(c2) A[c3] U
a strict provider says: ['messages[5]: toolCall c3 has no toolResult']
checked top
record: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A(error)
calls asked for: 3 results recorded: 3
the next prompt would send: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) U
a strict provider says: nothing; it is valid
checked wrapper
raised BudgetExceeded('spent 445 of 400 input tokens')
record: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3)
calls asked for: 3 results recorded: 3
the next prompt would send: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) U
a strict provider says: nothing; it is valid
- You hit
- a model that asked for the same missing file fifty times running, and a provider whose one failure made every later prompt fail too
- You built
error_message, themax_turnscheck at the top of a turn, one exit for failed replies, andcontext_for_model- The principle
- every exit leaves a transcript you could send again, because a stop is a fact to write down, not an accident to throw
- Your harness now
- user_message
- tool_calls
- str_arg
- run_tool
- run_agent
- error_message
- context_for_model
- Still open
- Nothing stops one tool result being a 5,000-line file, and every turn after it pays for that file again. A cap on turns does not help. Lesson 6.