08 · Watching and steering a run
Who holds the list?
Your chat REPL starts every line with an empty list, so it forgets everything, and this time it is your bug. Keep one list instead, and a double-click on Send puts two runs on it.
This lesson builds on lesson 07. New here? Start at lesson 01, or carry on: every lab is self-contained.
A web handler takes one line of chat, makes an empty messages list, calls run_agent(model, system, messages, tools, line), keeps what comes back in a variable, and writes a log line before it does anything else: print(len(messages)). Nobody has iterated anything yet. What does the log say?
run_agent is a Zero messages and zero model calls; the moment somebody iterates, two and one. A run that nobody drives has not happened yet. Hold on to that: today it decides which function one if goes in.
import harness, lab
model = lab.ScriptedModel([lab.say("Port 9090.")])
messages = []
events = harness.run_agent(model, harness.SYSTEM, messages, [],
"Which port is live?")
print("run_agent handed back a", type(events).__name__)
print(" messages on the record:", len(messages))
print(" model calls made: ", len(model.calls))
print()
print("and now somebody iterates it")
for event in events:
pass
print(" messages on the record:", len(messages))
print(" model calls made: ", len(model.calls))
run_agent handed back a generator messages on the record: 0 model calls made: 0 and now somebody iterates it messages on the record: 2 model calls made: 1
Two lines at a prompt
Here is the smallest useful thing anyone builds on top of what you have: a chat REPL. Read a line, run the agent, print the answer, go round. A handful of lines around the loop you already own.
The model is lesson 1's lab.forgetful(), which follows a printed rule instead of thinking: it answers out of the request in front of it and keeps nothing between calls. Two lines are typed. The first says a name.
The REPL's loop body starts with messages = []. You type "My name is Ada.", and then "What is my name?". What does the second line get back?
run_agent has been appending to messages since lesson 3, so by the second line it holds the first exchange
Two requests, each carrying a single U. Then the second half of the cell changes one line — the list is made once, above the loop — and the same two lines get "Your name is Ada." from a request carrying U A U. The loop is not stateless by accident; it is stateless because you made it so in lesson 3, and somebody outside has to hold what it does not.
import harness, lab
def repl(lines, *, one_list):
"""The lines typed at a chat REPL. Each line is one whole run of your agent."""
model = lab.ScriptedModel([lab.forgetful()])
kept = [] # made once, outside the loop
for typed in lines:
messages = kept if one_list else [] # ... or made again for every line
print(f"you> {typed}")
answer = harness.FinalTextRenderer()
print("bot> ", end="")
for event in harness.run_agent(model, harness.SYSTEM, messages, [], typed):
answer.render(event)
answer.finish()
shapes = ", ".join(f"{lab.shape(c.messages)!r}" for c in model.calls)
print(f" the {len(model.calls)} requests carried: {shapes}")
TYPED = ["My name is Ada.", "What is my name?"]
print("### the REPL as written: messages=[] on every line")
repl(TYPED, one_list=False)
print()
print("### one line changed: the list is made once, outside the loop")
repl(TYPED, one_list=True)
### the REPL as written: messages=[] on every line
you> My name is Ada.
bot> Noted.
you> What is my name?
bot> I don't know your name.
the 2 requests carried: 'U', 'U'
### one line changed: the list is made once, outside the loop
you> My name is Ada.
bot> Noted.
you> What is my name?
bot> Your name is Ada.
the 2 requests carried: 'U', 'U A U'
Two fingers on one Send
So: one list, made once, outside the loop. That is the whole fix, and it holds until two runs are alive at the same time.
It does not take a server to arrange that. A slow job, an impatient user, a second click on Send, and your handler calls run_agent again on the list the first call is still using. Both runs are generators now, so both of them move only when somebody asks them for an event — and there are two somebodies.
Below, the first Send gets as far as starting read on config.py. The second Send then runs from its first event to its last. Only after that does the first run pick up where it left off.
Here are the first five messages that land on the one list. Put them in the order they arrive.
user· "Which port is live?" — the first Sendassistant· "Let me look." and a call toread, tagged c1 — the first Senduser· "Are you there?" — the second Sendassistant· whatever the second Send gets backtoolResult· c1 · the contents ofconfig.py— the first Send, resuming
Nobody wrote a bug. Each run did exactly what lesson 3 told it to: append the prompt, append the reply, run the tools, append each result. What the two of them make together is a list where a
Run the cell and find the two assistant lines with no words in them: both carry 400 invalid_request: messages[1]: toolCall c1 has no toolResult. Neither Send got an answer. The second run's request was refused because the first run had left a call open; the first run's next request was refused because by then the second run's question stood between that call and its result. The last two lines of the output are the finished list read by the same rules: a call with no result, and a result that cannot be where it is. Two clicks, and neither run can send that list again; nothing you have built can mend it.
Where this refusal comes from: [general] hosted providers reject a request in which a call has no result, and 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).
Your lesson 7 loop, twice, over one list. The cell prints the lab.validate — the strict rules a provider applies, the ones the scripted model has been enforcing since lesson 2.
import harness, lab
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
tools = [harness.make_read_tool(ws)]
messages = [] # one list, kept outside the loop
port = lab.ScriptedModel([lab.reply(lab.text("Let me look."),
lab.call("read", {"path": "config.py"})),
lab.say("Port 9090.")])
hello = lab.ScriptedModel([lab.say("Still here.")])
def until(run, kind):
"""Take events from `run` up to and including the first one of type `kind`."""
for event in run:
if event["type"] == kind:
return
first = harness.run_agent(port, harness.SYSTEM, messages, tools,
"Which port is live?")
second = harness.run_agent(hello, harness.SYSTEM, messages, tools, "Are you there?")
until(first, "tool_execution_start") # Send: read(config.py) starts
until(second, "agent_end") # Send again, while read is still running
until(first, "agent_end") # the first run finishes what it started
print(lab.show(messages, stop_reason=True))
print("shape:", lab.shape(messages))
print()
for complaint in lab.validate(messages, record=True):
print("a provider reading that list:", complaint)
user -> "Which port is live?"
assistant -> "Let me look." + toolCall c1 read({"path": "config.py"}) [stop_reason=toolUse]
user -> "Are you there?"
assistant -> (nothing) [stop_reason=error] [error: 400 invalid_request: messages[1]: toolCall c1 has no toolResult]
toolResult c1 -> "PORT = 9090\n"
assistant -> (nothing) [stop_reason=error] [error: 400 invalid_request: messages[1]: toolCall c1 has no toolResult]
shape: U A[c1] U A(error) R(c1) A(error)
a provider reading that list: messages[1]: toolCall c1 has no toolResult
a provider reading that list: messages[4]: toolResult c1 must directly follow its assistant message, in call order
Notice what is not wrong here. No message was lost. Nothing raised. Both runs finished. Every line in that list is a true record of something that happened, which is why no amount of care inside run_agent could have saved it: the loop can only see its own turn, and the damage is between two loops.
So the second Send has to be dealt with before it becomes a run. There are three ways to deal with it, and they are not equally kind.
A user is watching the agent write three files. After the first one they change their mind and type "Actually, put them in src/." What should the second Send do? Pick, and give your reason in one line.
Five model calls. Three files at the top level, which is where the user did not want them. The held line arrives as its own job, after the work it was meant to change, and the best the agent can do with it is offer to move three files it has just written. Silent waiting is a decision — "your correction is a follow-on question" — taken on the user's behalf by a piece of code that cannot read.
Where this design comes from: Tau raises rather than serialising, and its refusal names the two things a person can do instead (src/tau_agent/harness.py:213-217). Those two doors are lesson 9. Today the door is shut, and a shut door you can see is worth more than a queue you cannot.
import harness, lab
FILES = ["a.py", "b.py", "c.py"]
def writer(request):
"""Writes whichever of a.py, b.py and c.py the transcript shows no write call
for. Once all three are there it answers the user's last line."""
asked = [block["arguments"].get("path")
for message in request.messages if message["role"] == "assistant"
for block in message["content"] if block.get("type") == "toolCall"]
todo = [name for name in FILES if name not in asked]
if todo:
return lab.reply(lab.text(f"Writing {todo[0]}."),
lab.call("write", {"path": todo[0],
"content": "print('hi')\n"}))
if "src/" in request.user_text:
return lab.say("All three are already at the top level. Shall I move "
"them into src/?")
return lab.say("Done: a.py, b.py and c.py.")
ws = lab.Workspace()
model = lab.ScriptedModel([lab.forever(writer)])
tools = [harness.make_write_tool(ws)]
messages = []
def run(typed):
for event in harness.run_agent(model, harness.SYSTEM, messages, tools, typed):
if event["type"] == "tool_execution_end":
call = event["call"]
print(f"bot> {call['name']}({call['arguments']['path']})")
if call["arguments"]["path"] == "a.py":
print("you> Actually, put them in src/."
" <- typed here, and held")
elif event["type"] == "turn_end":
answer = event["message"]
if not harness.tool_calls(answer):
print(f"bot> {harness.text_of(answer)}")
print("you> Write a.py, b.py and c.py.")
run("Write a.py, b.py and c.py.")
print(" [the run is over, so the held line goes in now, as a job of its own]")
print("you> Actually, put them in src/.")
run("Actually, put them in src/.")
print()
print("files on disk:", ", ".join(ws.listdir(".")))
print("model calls: ", len(model.calls))
print("record: ", lab.shape(messages))
you> Write a.py, b.py and c.py.
bot> write(a.py)
you> Actually, put them in src/. <- typed here, and held
bot> write(b.py)
bot> write(c.py)
bot> Done: a.py, b.py and c.py.
[the run is over, so the held line goes in now, as a job of its own]
you> Actually, put them in src/.
bot> All three are already at the top level. Shall I move them into src/?
files on disk: a.py, b.py, c.py
model calls: 5
record: U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A U A
Where the refusal has to land
"Refuse it" is one if and one raise. The only question left is which function they go in — and the recall question at the top of this page has already decided it, if you put the two facts together.
Your prompt(text) has to hand back the run, because a prompt is either a generator function itself, with the check in its body, or a plain def that checks and then returns a generator made somewhere else.
Two lines, run against each version in turn: g1 = start("first"), then g2 = start("second"). Neither has been iterated. Which line raises?
g2 line, both times. The check runs when the function is called, and the function is called on that line either way
def. Not true of the generator function: calling it builds an object and runs no line of the body, check included. Where the check sits is not a matter of taste; it is the difference between an exception and a silence.def: the g2 line
next(g1) raises: by then both generators exist, so the first one to start finds the flag already set
g1's is the first to start. It sets the flag itself, and finds it clear, because it got there first.With the check in the body, two runs are in flight and the program is perfectly happy about it; the RuntimeError arrives at next(g2), in whatever code happens to be drawing events, pages away from the line that made the mistake. With the check in a plain def, the second Send raises on the second Send's own line, which is where somebody can catch it and put a sentence on the screen. Same check, same message, same flag. The difference is only which function it lives in.
busy = False
def guard_inside(name):
"""The check and the flag live in the generator's own body."""
global busy
if busy:
raise RuntimeError("already running")
busy = True
try:
yield f"{name}: event 1"
yield f"{name}: event 2"
finally:
busy = False
def guard_outside(name):
"""The check and the flag live in a plain def; the body is a second function."""
global busy
if busy:
raise RuntimeError("already running")
busy = True
return _body(name)
def _body(name):
global busy
try:
yield f"{name}: event 1"
yield f"{name}: event 2"
finally:
busy = False
def double_click(start, label):
"""Press Send twice before anything is drawn, then iterate what came back."""
global busy
busy, runs = False, []
print(label)
for number, prompt in ((1, "first"), (2, "second")):
line = f"g{number} = start({prompt!r})"
try:
runs.append(start(prompt))
print(f" {line:<24} ok")
except RuntimeError as error:
print(f" {line:<24} RuntimeError: {error}")
print(f" runs now in flight: {len(runs)}")
for number, run in enumerate(runs, 1):
line = f"next(g{number})"
try:
print(f" {line:<24} {next(run)!r}")
except RuntimeError as error:
print(f" {line:<24} RuntimeError: {error}")
for run in runs:
run.close()
double_click(guard_inside, "### the guard inside the generator body")
print()
double_click(guard_outside,
"### the guard in a plain def that returns the generator")
### the guard inside the generator body
g1 = start('first') ok
g2 = start('second') ok
runs now in flight: 2
next(g1) 'first: event 1'
next(g2) RuntimeError: already running
### the guard in a plain def that returns the generator
g1 = start('first') ok
g2 = start('second') RuntimeError: already running
runs now in flight: 1
next(g1) 'first: event 1'
What the screen is allowed to have
One thing is left to decide, and it is the one thing an object can do that a list in a global cannot. A screen wants the transcript: to draw it, to count it, to scroll it. So the object has a messages that hands it over. Then somebody writes an undo button.
UI code calls h.messages.pop() to take back the line the user just typed, and then h.messages.append(...) to show a "Thinking..." placeholder. Three candidates for what messages hands out. Which one should you write?
Note what the tuple does not protect. It is the list that is frozen, not the dicts inside it: h.messages[0]["content"] = "x" still edits the record, and nothing on this page will stop it. A snapshot is a lock on the shape of the conversation — how many messages there are and in what order — which is exactly what two writers destroy, and exactly what a provider checks. In the cell, messages is a @property, which is why it is read as h.messages and not h.messages().
import lab
class Holder:
"""Three objects, each holding the same three messages in a private list.
They differ in one line: what the `messages` property hands out."""
def __init__(self, messages):
self._messages = list(messages)
class Live(Holder):
"""the list itself"""
@property
def messages(self):
return self._messages
class Copy(Holder):
"""a fresh list each time"""
@property
def messages(self):
return list(self._messages)
class Snapshot(Holder):
"""a tuple"""
@property
def messages(self):
return tuple(self._messages)
START = [lab.user("My name is Ada."), lab.say("Noted."),
lab.user("What is my name?")]
EDITS = [("pop()", lambda seq: seq.pop()),
('append(assistant "Thinking...")',
lambda seq: seq.append(lab.say("Thinking...")))]
def a_well_meaning_screen(holder):
"""UI code: take back the line the user just sent, then show a placeholder."""
for what, edit in EDITS:
try:
edit(holder.messages)
told = "no complaint"
except AttributeError:
told = "AttributeError"
print(f" h.messages.{what:<32} {told:<14} transcript: "
f"{lab.shape(holder._messages)!r}")
for kind in (Live, Copy, Snapshot):
print(f"### messages hands out {kind.__doc__ or ''}".rstrip())
a_well_meaning_screen(kind(START))
### messages hands out the list itself h.messages.pop() no complaint transcript: 'U A' h.messages.append(assistant "Thinking...") no complaint transcript: 'U A A' ### messages hands out a fresh list each time h.messages.pop() no complaint transcript: 'U A U' h.messages.append(assistant "Thinking...") no complaint transcript: 'U A U' ### messages hands out a tuple h.messages.pop() AttributeError transcript: 'U A U' h.messages.append(assistant "Thinking...") AttributeError transcript: 'U A U'
Build: the thing that holds the list
Every decision is made. One object — the
Figure 8.1 The UI never touches the list. prompt() passes the messages for the length of that run. run_agent is unchanged and still owns nothing.
The gap is at the bottom of the new region 4 of harness.py. The class line and the docstring that states the contract are given; about 25 lines go inside, and nothing else in the file changes.
__init__(self, model, system, tools, messages=(), max_turns=None). Keep all five.toolsandmessagesare sequences somebody else owns: copy each into a list of your own, so that nothing the harness does reaches back out, and nothing outside can reach in. The default formessagesis an empty tuple on purpose.messagesandis_runningare read without brackets, ash.messagesandh.is_running, so both are@property.messagesanswers with a tuple, built when asked for.is_runninganswersTruefrom the call ofprompt()until that run is finished with, one way or another.prompt(text)is a plaindef, for the reason the guard-placement cell printed. It raisesRuntimeErrorwhose text containsalready runningif a run is going on, and otherwise sets the flag and returns the run._run(text)is the generator. It makes onerun_agent(...)over the harness's own list — with the system prompt, the tools and themax_turnsthe harness was built with — and passes on every event it gets. However that ends,is_runninghas to beFalseafterwards: the run may be iterated to its end, it may be closed by a consumer who walked away, or the model call may die under it.except Exceptioncatches none of the three.- The harness does not append the prompt.
run_agenthas done that since lesson 3, inside the first turn, and it emits themessage_endthat announces it. Append it here as well and every question is in the list twice.
Eight hidden tests. They ask a second question and check that the model was sent the first exchange; they iterate one whole run and check that what came out was the loop's own six events, ending on an agent_end carrying the harness's list. They press Send twice: once before any event has been drawn, and once from inside a tool that is running. They check is_running on a new harness, after a run ends normally, after a consumer takes four events and closes the run, and after the model call dies with lab.PowerCut — and that the next prompt is accepted each time, and that the PowerCut still reaches the caller. They take a snapshot, run a second prompt, and check the old snapshot did not grow and that what comes back is a tuple. They start a harness from an earlier conversation and check that the caller's own list is untouched, and that of two harnesses made with no messages at all, prompting one leaves the other empty. And they build one with a read tool and max_turns=1, prompt it twice, and check that the system prompt and the tool list still reached the second request and that the limit ended both runs after one turn.
- Five members, and the whole lesson is about which of them may live inside a generator's body. Which member has to do its work on the line the caller wrote, and which one can only do its work while events are being handed over?
- Two functions, not one. The plain
defdoes three things and none of them is yielding: check the flag, set the flag, hand back what the other function returns when you call it. The other function is the generator, and its only job is to sit betweenrun_agentand the consumer — take an event, pass it on — plus the tidying up at the end. That tidying up has to happen on three different endings, two of which are not returns at all but exceptionsexcept Exceptionnever sees; Python has exactly one clause that covers all three, and lesson 7's third warm-up cell is where you met it. - In outline.
__init__: store the model, the system prompt,list(tools),list(messages),max_turns, and a flag that starts false.messages: a property,tupleof the stored list.is_running: a property, the flag.prompt: if the flag is set, raise; set the flag; return the other function called withtext. That other function: make the run withrun_agent, passing the stored list itself and the storedmax_turns; loop over it and yield each event on; and in the clause that runs on every ending, close the inner run and clear the flag. Nothing anywhere in the class appends to the list.
Twenty-five lines, one flag, and the double Send now lands on a sentence instead of on your transcript. Look at what did not change: run_agent is the same function it was this morning, and it still knows nothing about who is calling it or how often.
What changed since lesson 07
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- Two prompts to one harness: the second model call is sent the first question and its answer, and the harness holds all four messages.
- h.prompt(text) returns the run: iterating it yields run_agent's events, agent_start to agent_end.
- While a run exists, even one nobody has iterated yet, h.prompt() raises RuntimeError("already running") at once.
- A tool that calls h.prompt() in the middle of a run gets RuntimeError("already running"), and the transcript stays valid.
- is_running is False before any prompt, and again after a run is exhausted, after the consumer closes it, and after it dies; the next prompt is accepted each time.
- h.messages is a tuple: a snapshot taken when asked for, which cannot be appended to, and which never changes the transcript.
- Harness(..., messages=earlier) starts from those messages and never writes to the caller's list; two new harnesses share nothing.
- system, tools and max_turns given to Harness(...) reach every model call of every run.
Three lines typed at a REPL that has one Harness behind it: a question that needs a file, an instruction that writes one, with Send pressed twice on purpose, and then a question answered out of the transcript with no file read at all. No tests. Once the lab has passed, this runs against your code.
import harness, lab
def recall(request):
"""Answers out of the transcript it was sent: it looks for a read result
holding PORT."""
read = [m["content"] for m in request.messages
if m["role"] == "toolResult" and "PORT" in str(m["content"])]
if not read:
return lab.say("I have nothing on file about a port.")
port = read[-1].split("=")[1].strip()
return lab.say(f"{port}, out of config.py. I can still see it in "
f"the {len(request.messages)} messages I was sent.")
ws = lab.Workspace({"config.py": "PORT = 9090\n"})
model = lab.ScriptedModel([
lab.reply(lab.text("Let me look."), lab.call("read", {"path": "config.py"})),
lab.say("The service listens on port 9090."),
lab.reply(lab.text("Writing it down."),
lab.call("write", {"path": "notes.md", "content": "port: 9090\n"})),
lab.say("Written to notes.md."),
recall,
])
h = harness.Harness(model, harness.SYSTEM, [harness.make_read_tool(ws),
harness.make_write_tool(ws)])
def send(typed, *, twice=False):
"""One line of the REPL: Send starts a run, and the screen draws the events."""
print(f"you> {typed}")
run = h.prompt(typed)
if twice:
print(f" [Send pressed again; is_running is {h.is_running}]")
try:
h.prompt(typed)
except RuntimeError as error:
print(f" [refused on the spot: RuntimeError: {error}]")
for event in run:
if event["type"] == "tool_execution_end":
call = event["call"]
print(f"bot> {call['name']}({call['arguments']['path']})")
elif event["type"] == "turn_end":
answer = event["message"]
if not harness.tool_calls(answer):
print(f"bot> {harness.text_of(answer)}")
send("Which port does the service use?")
send("Put that in notes.md.", twice=True)
send("What port did you say?")
print()
print("one transcript: ", lab.shape(h.messages))
print("still running: ", h.is_running)
print("a provider would accept it:",
lab.validate(list(h.messages), record=True) == [])
print("model calls: ", len(model.calls), "- the last one read no file")
print("notes.md: ", ws.read_text("notes.md").strip())
you> Which port does the service use?
bot> read(config.py)
bot> The service listens on port 9090.
you> Put that in notes.md.
[Send pressed again; is_running is True]
[refused on the spot: RuntimeError: already running]
bot> write(notes.md)
bot> Written to notes.md.
you> What port did you say?
bot> 9090, out of config.py. I can still see it in the 9 messages I was sent.
one transcript: U A[c1] R(c1) A U A[c2] R(c2) A U A
still running: False
a provider would accept it: True
model calls: 5 - the last one read no file
notes.md: port: 9090
Five model calls, one transcript, and the third line was answered out of the first line's tool result, which was still in the request because the object kept it. No file was read for it. The double Send was refused in the time it took to say so, and the run it interrupted was not touched; the record ends valid, which is the thing that two runs on one list could not manage.
- 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.
- Keep every tool result within a budget.
- Report what it is doing, as events, to any frontend.
- Own its transcript: one writer at a time.
Go deeper: the run nobody started
The flag goes up on the line that makes the run, and it comes down in the clause at the bottom of the generator's body. Put those two sentences next to the recall question at the top of this page and there is an obvious hole: a body that never starts has no clause that ever runs.
So a caller who asks for a run and then walks away without taking a single event — a web handler that returns the generator and whose request is cancelled, a screen that is closed between the click and the first repaint — leaves the harness saying True for ever. Closing the run does not help: closing a generator raises GeneratorExit at its current yield, and an unstarted generator has no current yield to raise it at.
A run made and never iterated, then closed. Nothing is running, nothing was paid for, and every later prompt is refused.
import harness, lab
model = lab.ScriptedModel([lab.say("One."), lab.say("Two.")])
h = harness.Harness(model, harness.SYSTEM, [])
run = h.prompt("Are you there?") # made, and nobody ever asks it for an event
print("is_running right after prompt():", h.is_running)
run.close() # the consumer changes its mind
print("is_running after close(): ", h.is_running)
print("the run's frame: ", run.gi_frame)
print("model calls so far: ", len(model.calls))
try:
h.prompt("Hello?")
except RuntimeError as error:
print("every later prompt: RuntimeError:", error)
is_running right after prompt(): True is_running after close(): True the run's frame: None model calls so far: 0 every later prompt: RuntimeError: already running
This is the price of the plain def, and Tau pays it too: the flag is set at call time (src/tau_agent/harness.py:147-150) and cleared in the finally of a body that has to have started (src/tau_agent/harness.py:192-205). What both are really saying is a contract: a run you have asked for must be driven to its end or closed after it has started, never abandoned before it.
There is a version that cannot wedge, and it is one line. Keep the generator instead of a flag and work the answer out from it. A generator carries its suspended body as gi_frame, and Python drops that frame — sets it to None — the moment the generator is finished or closed, which is why the cell above prints None. So is_running could be self._current is not None and self._current.gi_frame is not None, over a generator the harness keeps hold of: true for a run that is pending or suspended, false once it has ended or been closed, started or not. Nothing to reset, nothing to get wrong, and no wedge. The stored flag is what Tau has and what you will read there, so it is what this course builds; now you know what it costs.
Say it in your own words
A colleague reads your file and says: "So Harness is the agent, and run_agent is a helper it uses." In a sentence or two: what would you correct, and what is the object actually for?
Count the decisions each one makes during a run. Then count the things each one still knows after the run is over.
The agent is still the loop: it decides whether to go round, what to run, what to append. The object decides nothing about a run. It is there to answer one question — who holds the list — and its three answers are: I do, you may look at a copy, and one of you at a time may write.
Common answers, and what each one misses
- "It is the state, and the loop is the behaviour." A good sentence with one word wrong: the state. The loop has state too — its turn counter, the reply it is holding — and it is right that it does, because that state dies with the run. What the object holds is the state that has to outlive a run, which is a much shorter list than "everything".
- "It makes the loop reusable." The loop was already reusable; that is what handing it a list and a model bought you in lesson 3. What was not reusable was the caller: every call site had to remember the same five arguments and the same list, and get it right every time. The object is where remembering that stops being anyone else's job.
- "It stops concurrency bugs." It stops exactly one, and it stops it by refusing to be clever. There is no lock, no queue and no thread in the twenty-five lines: there is a flag, and a sentence for the person who pressed Send twice.
One transcript, one writer. The harness is just the thing that holds the list.
Tau's harness starts a run from a plain def, so the overlap guard raises on the caller's line, and the generator it returns is a second function.
def prompt(self, text):
if self._running:
raise RuntimeError("already running")
self._running = True
return self._run(text)
def prompt_message(self, message: AgentMessage) -> AsyncIterator[AgentEvent]:
self._ensure_not_running()
self._running = True
return self._run(prompts=(message,))
...
def _ensure_not_running(self) -> None:
if self._running:
raise RuntimeError(
"AgentHarness is already running; use steer() or follow_up() to queue messages."
)
Tau is async: read async for as for and await f(x) as f(x) until lesson 15.
The same four decisions. Tau's class docstring is the whole thesis in one line: "Reusable stateful agent brain independent of coding/UI policy" (src/tau_agent/harness.py:62-63).
- Own the list.
self._messages = list(messages), so the sequence a caller passes in is copied and never aliased (src/tau_agent/harness.py:65-77). That is how a session restored from disk is loaded, and it is yourmessages=()argument. - Hand out snapshots.
return tuple(self._messages), a new tuple on every access (src/tau_agent/harness.py:79-81). - Guard the run, at call time, in a plain
def— the excerpt above. Its test presses Send from inside the first run's ownforloop and asserts the exception, as one of your hidden tests does from inside a running tool (tests/test_agent_harness.py:100-108). - Lend the list to a stateless loop. The run wrapper passes
messages=self._messagesstraight into the loop and clears the flag in afinally(src/tau_agent/harness.py:192-205). And the loop, not the harness, appends the prompt (src/tau_agent/loop.py:70-72), which is why neither of your two functions may.
What Tau adds. Its settings are a dataclass of nine fields, of which you keep four. The nine: provider, model, system, tools, max_turns, a queue mode, a session id and two tool hooks (src/tau_agent/harness.py:38-48). It has continue_(), a run with no new user message, for retrying after an error or picking a restored session back up (src/tau_agent/harness.py:155-158); your toy has no such thing, so every run of yours starts with something a person typed. It has two named doors for changing the transcript from outside — append_message and replace_messages (src/tau_agent/harness.py:102-106) — which is how compaction and branch navigation put a new list in place; you meet the second of those in lesson 12. And it holds three things you have not needed yet: the two queues behind that refusal message (lesson 9), a list of listeners that every event is pushed to (lesson 11), and a cancellation token with the dangling-call repair that a cancelled run leaves behind (lessons 10 and 15).
What Tau reads once per run. The wrapper copies the config's fields into the loop call when the run starts and never looks at them again (src/tau_agent/harness.py:174-188). That is a Tau mechanism, so the question about it is optional and nothing waits on it.
A user types /model to switch to a bigger model while the agent is in the middle of a job. Which run uses the new one?
/model safe to type while the agent is working./model is typed (src/tau_coding/session.py:1533-1538).The next one. The values are read when the run is built and are then arguments of a function that is already going; the object they came from can be rewritten in the meantime and the running loop will not notice. Yours behaves the same way for the same reason, without anyone deciding it: _run reads self._system and self._tools once, on its first line.
Where both are weaker than they look. The wedge from the "Go deeper" aside is Tau's too, line for line, and for the same reason. Neither harness protects the messages themselves, either: Tau's snapshot freezes the tuple, and the messages inside are pydantic models that are not frozen (src/tau_agent/messages.py:24-33), so harness.messages[0].content = "x" edits Tau's record as surely as the dict version edits yours. What a snapshot buys, in both, is the shape of the conversation.
Where yours is weaker. Tau's replace_messages does not check whether a run is going on (src/tau_agent/harness.py:105-106); it trusts its caller, and the callers that matter ask is_running themselves first (src/tau_coding/session.py:1012-1013). You have no such door yet, so you have nothing to leave open. [general] Neither design survives two processes: a flag in memory says nothing about a second server holding the same session file, and every harness that has to answer for that ends up with a lock somewhere outside itself. Lesson 11 gives the transcript a home on disk, which is where that question starts.
src/tau_agent/harness.py:147-150,213-217 · pinned to commit 9fe6a71 · view on GitHub
One more case
You put the harness behind a web server, one object per conversation, and email somebody the link. They open it, read half of it, and open a second tab on the same link to try something without losing their place.
Tab A sends "My name is Ada." Tab B, a second later and before A's answer has come back, sends "What is my name?" What does each tab see? Pick, and give your reason in one line.
"Your name is Ada." — to the tab that never said it. The guard protects the shape of the list and nothing else; it is not a session, and it was never asked to be one. One transcript, one writer is a statement about how many runs may write, not about how many people may. If two people are meant to have two conversations, that is two harnesses, and something above them has to decide which is whose.
import harness, lab
model = lab.ScriptedModel([lab.forgetful()])
served = harness.Harness(model, harness.SYSTEM, []) # one harness, on the server
def answer(tab, run):
for event in run:
if event["type"] == "turn_end":
print(f"tab {tab} <- {harness.text_of(event['message'])}")
print("tab A -> My name is Ada.")
first = served.prompt("My name is Ada.") # tab A's request handler
print("tab B -> What is my name?")
try:
second = served.prompt("What is my name?") # tab B's, while A's run is alive
except RuntimeError as error:
second = None
print(f"tab B <- 500 {error}")
answer("A", first)
print("tab B -> What is my name? (the visitor presses Send again)")
answer("B", served.prompt("What is my name?"))
print()
print(lab.show(served.messages))
print("one transcript:", lab.shape(served.messages),
"- and tab A never saw the last two lines")
tab A -> My name is Ada. tab B -> What is my name? tab B <- 500 already running tab A <- Noted. tab B -> What is my name? (the visitor presses Send again) tab B <- Your name is Ada. user -> "My name is Ada." assistant -> "Noted." user -> "What is my name?" assistant -> "Your name is Ada." one transcript: U A U A - and tab A never saw the last two lines
- You hit
- a REPL that forgot every line, and then, once it remembered, two runs scribbling over one list until no request could be sent at all
- You built
Harness: the object that owns the transcript and the settings, hands out tuple snapshots, and refuses a second run on the caller's own line, in a plaindef- The principle
- one transcript, one writer; the harness is just the thing that holds the list
- Your harness now
- error_message
- str_arg
- int_arg
- tool_specs
- run_tool
- truncate_head
- truncate_tail
- make_read_tool
- make_write_tool
- make_bash_tool
- run_agent
- context_for_model
- FinalTextRenderer
- JsonRenderer
- Harness
- Your answers
- Still open
- The refusal is correct and it is not enough. The agent is about to write five files with tabs and you want spaces: a second run is forbidden, and appending your line straight into the list lands it between a call and its result. So your line has to wait — and something has to know when the list is ready for it. Lesson 9.