03 · The model can only talk
Turn the crank
The bug is three files deep. Your chat makes two model calls, reads one file, and hands back an empty answer.
This lesson builds on lesson 02. New here? Start at lesson 01, or carry on: every lab is self-contained.
A tool you build is an ordinary dict, so nothing stops you adding keys to it. Here is the count_words tool with a fifth key, timeout, that only your own code would ever read. The cell prints the keys of your dict, and then the keys of the tool as it arrived in the model's request. What does the second line say?
execute is a function, and there is no way to write a function down in a request; timeout is yours, and nothing outside your process has ever heard of it.tool_specs decides what goes out, and it names those three. Everything else in the dict stays home, wanted or not.timeout means. What travels is what you chose to send, not what happens to be in the dict.Your dict has five keys and the request carried three. The tool's spec is a thing you build for sending, not the tool itself. Today you find out how many times that spec goes out during one job.
import harness, lab
def count_words(arguments):
return str(len(arguments["text"].split()))
tool = {"name": "count_words",
"description": "Count the words in a text.",
"parameters": {"type": "object",
"properties": {"text": {"type": "string"}},
"required": ["text"]},
"execute": count_words,
"timeout": 30} # a fifth key, for your own use
model = lab.ScriptedModel([lab.say("Noted.")])
harness.chat(model, [], "How long is this sentence?", [tool])
print("your dict has: ", list(tool))
print("the request held:", list(model.calls[0].tools[0]))
your dict has: ['name', 'description', 'parameters', 'execute', 'timeout'] the request held: ['name', 'description', 'parameters']
Someone puts your lesson 1 chat behind a web server, with one messages list created when the process starts. User A types "My name is Ada." User B, in another browser, on another continent, asks "What is my name?" What is user B told?
Three messages went out, and Ada was in the first. Whoever holds the list holds the conversation, which is convenient until two conversations share one. Lesson 8 is about the ownership; today the list grows much faster than it did in lesson 1.
import harness, lab
model = lab.ScriptedModel([lab.forgetful()])
messages = [] # one list for the whole server
# two people, two browser tabs, one process
harness.chat(model, messages, "My name is Ada.") # user A
answer = harness.chat(model, messages, "What is my name?") # user B
print("user B is told:", answer)
print("user B's request carried", len(model.calls[-1].messages),
"messages")
user B is told: Your name is Ada. user B's request carried 3 messages
Three files deep
main.py prints 0 where it should print 42. It imports scale from util.py, which imports FACTOR from const.py, and const.py is where the bug is. Nobody can see that from main.py: you have to read one file to learn the name of the next.
Your chat from lesson 2 has everything it needs. The three files are on the workspace, the read tool is in the tool list, and the model is scripted to work the way a debugger works: look at main.py, then util.py, then const.py, then say what is wrong.
You call chat(model, messages, "scale(21) prints 0. Find the bug.", tools) once. How many times does chat call the model?
Two. That is what you built: one call, and if the reply asks for tools, run them and call once more. It was one round trip when you wrote it and it is one round trip now, whatever the job needs. The cell below runs it and then turns the crank by hand, twice, so that you can see what the job actually costs.
Two calls, one file read, and the answer that comes back is the empty string. The job needs three files. Somebody has to decide that the work is finished. Who?
stop_reason: each reply is labelled, and the label says whether there is more to come
max_rounds=5, which is the sort of setting a library would hand you
The cell cranks the handle twice, and the model needed exactly that: util.py, then const.py, then the verdict. Nobody could have known in advance that three files was the number. The four requests cost 85, 162, 233 and 295 input tokens, because each one resends the whole list, as it did in lesson 1, with the tool spec riding along every time. And look at where chat left the record — U A[c1] R(c1) A[c2], ending on a call that nobody answered.
import harness, lab
ws = lab.Workspace({
"main.py": "from util import scale\n\nprint(scale(21))\n",
"util.py": "from const import FACTOR\n\n"
"def scale(n):\n return n * FACTOR\n",
"const.py": "FACTOR = 0 # should be 2\n"})
tools = [harness.make_read_tool(ws)]
model = lab.ScriptedModel([ # one step per model call
lab.reply(lab.text("Let me look."),
lab.call("read", {"path": "main.py"})),
lab.reply(lab.call("read", {"path": "util.py"})),
lab.reply(lab.call("read", {"path": "const.py"})),
lab.say("The bug is in const.py: FACTOR is 0.")])
messages = []
answer = harness.chat(model, messages,
"scale(21) prints 0. Find the bug.", tools)
print("chat returned", repr(answer), "after", len(model.calls),
"model calls. Files read:", ws.reads)
print("the list ends on:", lab.show(messages[-1:]))
def crank(): # one more round trip, by hand
for call in harness.tool_calls(messages[-1]):
messages.append(harness.run_tool(tools, call))
messages.append(
model.complete(harness.SYSTEM, messages,
harness.tool_specs(tools)))
print("cranked by hand: ", lab.show(messages[-1:]))
crank()
crank()
print("model calls:", len(model.calls), "| input tokens per call:",
[m["usage"]["input"] for m in messages if "usage" in m])
chat returned '' after 2 model calls. Files read: ['main.py']
the list ends on: assistant -> toolCall c2 read({"path": "util.py"})
cranked by hand: assistant -> toolCall c3 read({"path": "const.py"})
cranked by hand: assistant -> "The bug is in const.py: FACTOR is 0."
model calls: 4 | input tokens per call: [85, 162, 233, 295]
What makes it go round again
So the shape is not in doubt. Call the model; if the reply asks for tools, run them, append the results, and call again — and again, and again, until it stops asking. That is a while loop, and the only interesting line in it is the condition.
Write that condition as one expression over the last assistant message. You have reply, the message the model just sent. What goes after return?
You wrote a function in lesson 2 that answers exactly this question about a reply. What did it return when the model asked for nothing?
return bool(tool_calls(reply))
Go round again if, and only if, the reply contains tool calls. tool_calls works them out from content every time it is asked, which is why nothing can go stale between asking and running. Put your line into the cell below: it ships with a guess that always says no, and the last column tells you where the guess and the job disagree.
Common answers, and what each one misses
- "
reply["stop_reason"] == "toolUse"." The tempting one, because the label is right there and it is shorter to type. Whether it is true is the next two questions. - "
len(messages) < 10", or a counter. That answers "have I gone round enough times", which is a question about your patience, not about the job. The model asked for three files here and could have asked for eleven. - "The reply has no text yet", or "the model has not said anything final". Run the cell: its very first reply is "Let me look." and a request for
main.py. Replies talk and act in the same breath.
Three replies from the debugging script, and a go_on() that always says no. Put your one line in and run it: all three rows should agree.
import harness, lab
def go_on(reply):
"""True if the run must go round again. `reply` is the
assistant message the model just sent. One expression."""
return False # <- your one line goes here
model = lab.ScriptedModel([
lab.reply(lab.text("Let me look."),
lab.call("read", {"path": "main.py"})),
lab.reply(lab.call("read", {"path": "util.py"}),
lab.call("read", {"path": "const.py"})),
lab.say("The bug is in const.py: FACTOR is 0.")])
for needed in [True, True, False]:
reply = model.complete(harness.SYSTEM, [lab.user("Find the bug.")])
said = bool(go_on(reply))
print(lab.show([reply], stop_reason=True))
note = "" if said == needed else " <- they differ"
print(f" go_on says {said}, the job needs {needed}{note}")
assistant -> "Let me look." + toolCall c1 read({"path": "main.py"}) [stop_reason=toolUse]
go_on says False, the job needs True <- they differ
assistant -> toolCall c2 read({"path": "util.py"}) + toolCall c3 read({"path": "const.py"}) [stop_reason=toolUse]
go_on says False, the job needs True <- they differ
assistant -> "The bug is in const.py: FACTOR is 0." [stop_reason=stop]
go_on says False, the job needs False
The label on the box
Every reply arrives with a toolUse and stop went past in the last cell. It is right there in the message, it is one comparison to write, and it reads like an instruction meant for you.
The next two cells are replies whose label disagrees with what the reply contains — one lie in each direction. The scripts are ours; whether the disagreement matters is what you are about to decide.
The model asks to read const.py — and the reply is labelled stop. Your code trusts the label, so it does not run the tool and does not call again. A minute later the user asks "Well? What is it?". You append their question to the same list and send it. What comes back?
stop, so that call was never a live request — the model had already finished with it
const.py and never got it, so it asks again
400 invalid_request: messages[1]: toolCall c1 has no toolResult. You did not break the record by sending the second prompt; you broke it when you stopped without running the tool, and every later prompt on that list gets the same answer. Lesson 2's rule — one call in, exactly one result out — turns out to be a rule about when your loop is allowed to stop.
Where this refusal comes from: [general] hosted providers reject a request in which a call has no result. Tau keeps a function whose whole job is to guarantee that "every tool call has exactly one adjacent result" before a provider sees the list (src/tau_agent/tool_history.py:40-47).
import harness, lab
ws = lab.Workspace({"const.py": "FACTOR = 0 # should be 2\n"})
tools = [harness.make_read_tool(ws)]
specs = harness.tool_specs(tools)
model = lab.ScriptedModel([
lab.reply(lab.call("read", {"path": "const.py"}),
stop_reason="stop"), # the label lies
lab.say("FACTOR is 0.")])
messages = [lab.user("What is FACTOR in const.py?")]
reply = model.complete(harness.SYSTEM, messages, specs)
messages.append(reply)
print(lab.show([reply], stop_reason=True))
if reply["stop_reason"] == "toolUse": # trust the label
for call in harness.tool_calls(reply):
messages.append(harness.run_tool(tools, call))
print("the run is over. You hold:", lab.shape(messages))
# a minute later, the user asks again
messages.append(lab.user("Well? What is it?"))
reply = model.complete(harness.SYSTEM, messages, specs)
print(lab.show([reply], stop_reason=True))
assistant -> toolCall c1 read({"path": "const.py"}) [stop_reason=stop]
the run is over. You hold: U A[c1]
assistant -> (nothing) [stop_reason=error] [error: 400 invalid_request: messages[1]: toolCall c1 has no toolResult]
Now the lie the other way. The reply is one plain sentence, "FACTOR is 0.", with no call in it anywhere — and the label says toolUse. Your code trusts the label again. What does it do, and what does that cost?
tool_calls looks in content and returns a list, which is empty here; nothing digs and nothing raises.tools to run: [], and then a second request carrying U A — the same conversation with the model's own answer on the end. It cost 77 input tokens across the two calls, and all it bought was the model remarking that its answer had come back with nothing new after it. Content beats label, in both directions: the content is what the model produced, the label is a note about how it stopped producing. From here on your loop never reads it.
Where this comes from: Tau does not trust the provider's word either. Before a reply reaches the loop, its stop reason is recomputed, and a reply holding calls is relabelled toolUse whatever arrived on the wire (src/tau_ai/stream.py:80-85, applied at src/tau_ai/stream.py:208-211).
import harness, lab
def puzzled(request): # it can only react to what it is sent
return lab.say("My own answer came back to me with nothing "
"new after it. FACTOR is still 0.")
model = lab.ScriptedModel([
lab.reply(lab.text("FACTOR is 0."),
stop_reason="toolUse"), # the opposite lie
puzzled])
messages = [lab.user("What is FACTOR in const.py?")]
reply = model.complete(harness.SYSTEM, messages)
messages.append(reply)
print(lab.show([reply], stop_reason=True))
if reply["stop_reason"] == "toolUse": # trust the label
print("tools to run:", harness.tool_calls(reply))
reply = model.complete(harness.SYSTEM, messages)
messages.append(reply)
print(lab.show([reply], stop_reason=True))
print("model calls:", len(model.calls), "| input tokens paid:",
model.bill, "| request 2 was", lab.shape(model.calls[-1].messages))
assistant -> "FACTOR is 0." [stop_reason=toolUse] tools to run: [] assistant -> "My own answer came back to me with nothing new after it. FACTOR is still 0." [stop_reason=stop] model calls: 2 | input tokens paid: 77 | request 2 was U A
Which leaves the question of what the label is for. Here is one that is not a lie at all. [general] A model writes until it is done or until it hits the cap on its output, whichever comes first, and the label is how you find out which happened.
Here the model hit the cap: the reply is the stump "The bug is in", no call, labelled length. The loop in the cell is the one you are about to write, and it continues on calls, not on labels. What does it do?
length plainly means "unfinished", so the loop asks for the rest
One call, U A, run over. The last two lines of the cell are the caller doing what the loop refuses to do: reading the label and saying so. That is the division of work — the loop decides whether to go round, the caller decides what to tell the user about how it ended. A screen that prints that stump without a word about length is lying by omission.
import harness, lab
model = lab.ScriptedModel([
lab.reply(lab.text("The bug is in"), # cut off mid-sentence
stop_reason="length"),
lab.say("This reply is here in case anyone asks again.")])
messages = []
final = harness.run_agent(model, "You are a careful debugger.",
messages, [], "Find the bug.")
print("model calls:", len(model.calls), "| you hold:",
lab.shape(messages))
print(lab.show([final], stop_reason=True))
if final["stop_reason"] == "length": # the caller's business
print("(cut short: the model hit its output limit)")
model calls: 1 | you hold: U A assistant -> "The bug is in" [stop_reason=length] (cut short: the model hit its output limit)
What the run leaves behind
The loop hands back one message: the final reply, the one that asked for nothing. The first cell's job, once you had cranked it to the end, made eight. So where are the other seven, and what happens to them on a day when the job does not finish?
In the cell below, const.py is missing from the workspace. On the third read tool raises FileNotFoundError, nothing catches it inside the loop, and run_agent never returns at all.
The tool raises on turn 3. The exception comes out of run_agent and the caller catches it. What does the caller have afterwards?
run_agent raised instead of returning, so there is no result, and two turns of work are gone
Read the second line of the output carefully: final is still the None it was initialised to, because run_agent never returned. And the caller's list holds U A[c1] R(c1) A[c2] R(c2) A[c3], the whole
import harness, lab
ws = lab.Workspace({ # const.py is not on this disk
"main.py": "from util import scale\n\nprint(scale(21))\n",
"util.py": "from const import FACTOR\n\n"
"def scale(n):\n return n * FACTOR\n"})
tools = [harness.make_read_tool(ws)]
model = lab.ScriptedModel([
lab.reply(lab.call("read", {"path": "main.py"})),
lab.reply(lab.call("read", {"path": "util.py"})),
lab.reply(lab.call("read", {"path": "const.py"})),
lab.say("The bug is in const.py: FACTOR is 0.")])
messages = []
final = None
try:
final = harness.run_agent(model, "You are a careful debugger.",
messages, tools, "Find the bug.")
except FileNotFoundError:
print("the run died on turn 3, inside the read tool")
print("run_agent returned:", final)
print("the caller holds: ", lab.shape(messages))
the run died on turn 3, inside the read tool run_agent returned: None the caller holds: U A[c1] R(c1) A[c2] R(c2) A[c3]
Build: turn the crank
Everything is decided. Call the model with the whole list. Append the reply. If it asks for nothing, stop and hand it back. Otherwise run what it asked for, append each result, and go round. Here is the same sentence as a machine, one step at a time, on a one-file job.
Figure 3.1 The crank. The job in the figure needs one file, so the handle turns once; the job in your cells needed three. Nothing in the machine knows the difference.
- The prompt goes on the record first:
messagesis the caller's list, and it grows downward from here. - The whole list goes to
model.complete()— one message, this first time. - The reply is appended: a sentence and a request for
config.py, tagged c1. tool_calls(reply)finds one, sorun_tool()runs it and its result is appended, tied to c1.- The crank: round again with the same two lines. The request now carries three messages, and you pay for all three.
- This reply asks for nothing. That is the exit: return it.
The gap is at the bottom of harness.py, inside run_agent. Step 1 is done for you: the prompt is already on the record. About eight lines for the rest.
- Call the model with
system, the whole list andtool_specs(tools), and append the reply. Use thesystemyou were handed, not theSYSTEMconstant at the top of the file. - If the reply holds no tool calls, the run is over: return that message.
- Otherwise run every call, in the order the model wrote them, appending each result, and go round again.
messagesis the caller's list. Append to it. Do not copy it, do not rebind the name, and do not keep a second list of your own.- No turn limit. The model decides how long the job is, and a model that never stops is lesson 5's problem.
- When the tests pass, delete
chat. Nothing needs it any more, which is the last thing this lesson has to show you.
Ten hidden tests. They script a model that reads three files, a model that lies about its label in both directions, a model that asks for two files in one reply, and a model that wants twelve turns; they also check what your list looks like after a second run_agent on it, and what each request carried.
- Look at your
chat. Two of its lines make a model call and append the reply; the same two lines appear again further down, which is the hand-crank. What surrounds a pair of lines you want to run an unknown number of times, and what would make it stop? while True:around the call and the append. Thencalls = tool_calls(reply): if it is empty,return replyand you are out; otherwisefor call in calls:appendrun_tool(tools, call), and let thewhilecarry you back to the top. Everything the next request needs is already inmessages, so there is nothing to pass along and nothing to remember between rounds — no counter, no flag, nostop_reason.- Nearly the code, all of it inside the gap:
while True: reply = model.complete(...) # system, the list, the specs messages.append(reply) calls = tool_calls(reply) if not calls: return reply # the only way out for call in calls: messages.append(...) # one result per call
Eight lines. Every test that mentioned a label passed without your code reading one, and the twelve-turn test passed without a counter. Now delete chat and run Check again: run_agent with an empty tool list makes one model call and returns the answer, which is all chat ever did.
What changed since lesson 02
@@ -74,2 +74,16 @@
messages.append(reply)
return text_of(reply)
+
+
+def run_agent(model, system, messages, tools, prompt):
+ """Put `prompt` to the model and keep going until it stops asking for tools.
+ Returns the final assistant message. `messages` is the caller's list: everything that
+ happens is appended to it, so the caller holds the record even if the run dies."""
+ # 1. the prompt goes on the record first (this step is done for you)
+ messages.append(user_message(prompt))
+ # --- your code ---
+ # 2. loop: call the model with `system`, the whole list and the tool specs; append the reply
+ # 3. if the reply holds no tool calls, the run is over: return the reply
+ # 4. otherwise run each call, in order, append each result, and go round again
+ # 5. when the tests pass, delete chat() above: run_agent with no tools does its job
+ # --- end ---
- A job that needs three files takes four model calls, and nobody cranks by hand.
- Every request is the whole list as it stood: two messages longer than the one before.
- Everything that happened is in the list the caller passed in, and in no other.
- A reply that holds a call gets its result and another turn, even when labelled "stop".
- After a run, the same list takes another prompt and the strict model accepts it.
- A reply with no calls ends the run, even when it is labelled "toolUse" or "length".
- One reply asks for two files: both results follow it, in call order, before the next turn.
- The loop has no turn count of its own: twelve requests in a row get twelve results.
- With no tools and a plain answer, the run is one model call: what chat used to do.
- run_agent returns the final assistant message, the one that asked for nothing more.
Four files, one of them a red herring, and a reply that asks for two at once. No tests: read the transcript, and the bill under it. Once the lab has passed, this runs against your code.
import harness, lab
ws = lab.Workspace({
"main.py": "from util import scale\nfrom fmt import show\n\n"
"show(scale(21))\n",
"util.py": "from const import FACTOR\n\n"
"def scale(n):\n return n * FACTOR\n",
"fmt.py": "def show(n):\n print(f'{n:>6}')\n",
"const.py": "FACTOR = 0 # should be 2\n"})
tools = [harness.make_read_tool(ws)]
def after_seeing(clue, reply):
"""This reply, but only if some result in the request shows
`clue`. The model cannot know what it was never sent."""
def step(request):
shown = [m["content"] for m in request.messages
if m["role"] == "toolResult"]
if any(clue in content for content in shown):
return reply
return lab.say("I cannot go on: nobody showed me "
+ repr(clue) + ".")
return step
model = lab.ScriptedModel([
lab.reply(lab.text("Let me look."),
lab.call("read", {"path": "main.py"})),
after_seeing("from fmt import", lab.reply(
lab.text("Two imports. Both, please."),
lab.call("read", {"path": "util.py"}),
lab.call("read", {"path": "fmt.py"}))),
after_seeing("from const import", lab.reply(
lab.text("fmt.py is fine. util.py leans on const.py."),
lab.call("read", {"path": "const.py"}))),
after_seeing("FACTOR = 0", lab.say(
"Found it. const.py sets FACTOR = 0, so scale(21) is 0. "
"The comment beside it says it should be 2."))])
messages = []
final = harness.run_agent(model, "You are a careful debugger.",
messages, tools,
"show(scale(21)) prints 0. Find the bug.")
print(lab.show(messages, stop_reason=True))
print()
print("files read:", ws.reads)
print("model calls:", len(model.calls), "| input tokens per call:",
[m["usage"]["input"] for m in messages if "usage" in m],
"| bill:", model.bill)
user -> "show(scale(21)) prints 0. Find the bug."
assistant -> "Let me look." + toolCall c1 read({"path": "main.py"}) [stop_reason=toolUse]
toolResult c1 -> "from util import scale\nfrom fmt import show\n\nshow(scale(21))\n"
assistant -> "Two imports. Both, please." + toolCall c2 read({"path": "util.py"}) + toolCall c3 read({"path": "fmt.py"}) [stop_reason=toolUse]
toolResult c2 -> "from const import FACTOR\n\ndef scale(n):\n return n * FACTOR\n"
toolResult c3 -> "def show(n):\n print(f'{n:>6}')\n"
assistant -> "fmt.py is fine. util.py leans on const.py." + toolCall c4 read({"path": "const.py"}) [stop_reason=toolUse]
toolResult c4 -> "FACTOR = 0 # should be 2\n"
assistant -> "Found it. const.py sets FACTOR = 0, so scale(21) is 0. The comment beside it say... (97 characters)" [stop_reason=stop]
files read: ['main.py', 'util.py', 'fmt.py', 'const.py']
model calls: 4 | input tokens per call: [87, 168, 308, 388] | bill: 951
Four model calls, four files, and a verdict that quotes the comment in const.py. The input token count climbs 87, 168, 308, 388: each request carries everything before it, so the last round paid for all four file contents again. That is 951 input tokens for a job worth one line of an answer, and lesson 6 is about the day one of those files is a 3,000-line log.
- 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.
Say it in your own words
People say "the agent worked out that the bug was in const.py". In your own words, a sentence or two: what is the agent in that sentence, and what did it work out?
Two things are in the room: a loop and a model. Which of them chose util.py as the second file to read, and how did the other one find out?
The model chose every file, because it was the only party that could read the previous one. Your loop chose nothing: it asked the content whether anything had been requested, ran what had, wrote the answer down and went round. That is the whole of it, and it is nine lines — which is why the thing you built has a name.
Common answers, and what each one misses
- "The agent is the model." Then the agent read no files, since the model only ever writes. The reading, the going round and the record are all on your side of the wire.
- "The agent is the framework doing the orchestration." Open
harness.pyand look for the framework. There is awhile, anifand afor. - "It is an agent because it uses tools." Your lesson 2
chatused tools and could not get past the first file. What changed today was that the loop goes round as often as the model asks, and stops when it stops asking.
An
Tau's loop continues on the presence of tool calls in the reply, not on the label the provider sent with it.
calls = tool_calls(reply)
if not calls:
return reply
for call in calls:
messages.append(run_tool(tools, call))
tool_results: list[ToolResultMessage] = []
calls = list(assistant.tool_calls)
has_more_tools = bool(calls)
for call in calls:
Tau is async: read async for as for and await f(x) as f(x) until lesson 15.
The same shapes.
- The condition is the variable that drives Tau's loop:
has_more_tools = Trueabove awhile has_more_tools or pending:(src/tau_agent/loop.py:98-100). Yourif not calls: returnis the same test written as an exit. Thependinghalf and the outerwhile True:are lesson 9's, for messages that arrive mid-run. - Tool calls run one at a time, in the order the model wrote them (
src/tau_agent/loop.py:156), and each result is appended as its own message. - The caller's list is the product. Tau's test for a plain turn asserts on the list the caller passed in, after the loop has appended to it (
tests/test_agent_loop.py:85).
What Tau adds. Its loop is an async generator: instead of returning one message it yields events as it goes — turn_start, message_end, turn_end (src/tau_agent/loop.py:102), which is lesson 7 — and it appends every message to a second list of what this run added, so a consumer can ask what is new (src/tau_agent/loop.py:146-151). It takes a max_turns and leaves a readable error message in the record when it trips (src/tau_agent/loop.py:112-120, lesson 5), ends the run when a reply comes back error or aborted (src/tau_agent/loop.py:148-151, lessons 5 and 15), and carries a cancellation signal into every tool call.
Declared in Tau, read by nothing. An AgentTool has an execution_mode that defaults to "parallel" (src/tau_agent/tools.py:88), and the loop above never looks at it: every call goes through the same sequential for. Yours is sequential too, and says so rather than pretending.
Where the label is fixed, and where it is not. Tau overwrites the provider's finish reason with toolUse whenever the reply holds calls, so the first lie in this lesson cannot reach its loop (src/tau_ai/stream.py:80-85). The second lie can: a reply with no calls whose provider said tool_calls is still labelled toolUse there. It does no harm, because nothing downstream asks.
Where yours is weaker. Every model in this course is a script, and so is Tau's FakeProvider, which records the messages of each call the same way (src/tau_ai/fake.py:31). [general] A real reply arrives a fragment at a time and has to be assembled before any of this can run, nobody promises the arguments are the shape you asked for, and the network is between you and the answer. Those are lessons 4 and 5, and then the capstone.
src/tau_agent/loop.py:153-156 · pinned to commit 9fe6a71 · view on GitHub
One more case
A reply arrives holding the words "All done!" and a read call for const.py. The cell runs your loop on it twice: once with the reply labelled toolUse, once labelled stop. Does the run stop? Pick, and give your reason in one line.
read and goes round again, both times: two model calls each, and const.py in ws.reads
stop run and continues on the other. When the words and the content disagree, the label breaks the tie
Two lines that differ only in the label you set, and const.py was read both times. The words are words, the call is a request, and the label is a note about how the writing ended. Your loop reads exactly one of the three, which is why the two runs cannot come out differently.
import harness, lab
for label in ["toolUse", "stop"]:
ws = lab.Workspace({"const.py": "FACTOR = 0 # should be 2\n"})
model = lab.ScriptedModel([
lab.reply(lab.text("All done!"),
lab.call("read", {"path": "const.py"}),
stop_reason=label),
lab.say("Now I am done: FACTOR is 0.")])
messages = []
harness.run_agent(model, "You are a careful debugger.", messages,
[harness.make_read_tool(ws)], "Find the bug.")
print(f"labelled {label!r}:", len(model.calls), "model calls,",
lab.shape(messages), "| files read:", ws.reads)
labelled 'toolUse': 2 model calls, U A[c1] R(c1) A | files read: ['const.py'] labelled 'stop': 2 model calls, U A[c1] R(c1) A | files read: ['const.py']
Every model you have met on this site is a script you could read. Your ten tests pass against them. In a sentence: what have those green tests not proved?
Two things could be wrong in an agent: your loop, or the model's judgement. Which of them did the scripts hold still?
They proved the loop, and thoroughly: given a reply with calls it runs them in order and goes round; given none it stops; given a lie about the label it does neither differently. They proved nothing whatever about a model, because the model's side was written by us. [general] A real one may ask for a tool that does not exist, hand you arguments of the wrong shape, read the same file six times, or answer without looking at all — and your loop will do exactly what it is told with every one of those. Each is a lesson from here on, and the capstone is where you point this at a model nobody scripted.
Common answers, and what each one misses
- "That it works against a real provider." No request has left this page. The shape of a request, the streaming, the retries and the wire format are all still ahead of you.
- "That the answers are correct." The script says
const.pyis the bug because we typed that sentence. A scripted model is a way of making your code's behaviour repeatable, not a way of judging anybody's reasoning. - "That the loop handles failure." It handles none. The one failure you saw today came out as an exception and killed the run, and the record it left ends on an unanswered call.
- You hit
- a bug three files deep, and a
chatthat stopped after one round trip with an empty answer. - You built
run_agent(): awhileloop that continues on tool calls, appends to the caller's list, and returns the final message.chatis gone; it was this loop with the crank removed.- The principle
- An agent is a model call in a while loop. The model steers; the loop only turns.
- Your harness now
- SYSTEM
- user_message
- text_of
- tool_calls
- tool_specs
- run_tool
- make_read_tool
- run_agent
- Your answers
- Still open
- The model asks for a tool called
raed.tools_by_name[call["name"]]raises, the run dies, and the record ends on a call nobody answered. Lesson 4.