09 · Watching and steering a run
But I had something to say
The agent is writing five files with tabs. You want spaces, now. Your harness refuses a second prompt, and dropping your sentence into the list can land it between a call and its result.
This lesson builds on lesson 08. New here? Start at lesson 01, or carry on: every lab is self-contained.
Your lesson 8 harness is behind a web server. The handler calls h.prompt(text) and hands the generator to the framework, which will iterate it later to stream the events out; at the moment the handler returns, nothing has been iterated and no model call has been made. The user clicks Send a second time. What happens?
prompt only builds a generator, and a generator does no work until somebody iterates it, so the first run has not begun and the second Send simply starts one
prompt itself, a plain def, exactly so that the answer does not depend on when the framework gets round to iterating.RuntimeError: already running, raised on the handler's line, with nothing on the record and nothing sent to the model
prompt was called, not at the first next(). Note what happened to the user's sentence.The
import harness, lab
model = lab.ScriptedModel([lab.say("Reformatted."), lab.say("Done.")])
h = harness.Harness(model, harness.SYSTEM, [])
# The web handler: start the run and hand the generator to the framework,
# which will iterate it later to stream the events out.
def send(text):
return h.prompt(text)
first = send("Reformat main.py.")
print("is_running:", h.is_running)
print("model calls so far:", len(model.calls))
print("messages so far:", len(h.messages))
try:
send("Actually, use spaces.")
except Exception as exc:
print(f"second Send: {type(exc).__name__}: {exc}")
first.close()
is_running: True model calls so far: 0 messages so far: 0 second Send: RuntimeError: already running
A two-turn job. Turn 1 reads main.py. On turn 2 the provider answers 503 overloaded, and your loop writes that into the record the way lesson 5 taught it. Afterwards you type "Try again." What does that next request carry?
context_for_model is the only thing that decides what a request looks like.content is true, worth keeping, and refused by a provider.The record says U A[c1] R(c1) A(error) and the request says U A[c1] R(c1) U. Every exit leaves a transcript you can carry on from, and the carrying on is done by a new prompt, because that is the only door in. Today somebody wants to speak while the door is shut.
import harness, lab
ws = lab.Workspace({"main.py": "def scale(n):\n\treturn n * FACTOR\n"})
model = lab.ScriptedModel([lab.reply(lab.call("read", {"path": "main.py"})),
lab.fail("503 overloaded")])
h = harness.Harness(model, harness.SYSTEM, [harness.make_read_tool(ws)])
for event in h.prompt("What does main.py do?"):
pass
print("the record:", lab.shape(h.messages))
print(lab.show(list(h.messages)))
print()
next_request = harness.context_for_model(
list(h.messages) + [harness.user_message("Try again.")])
print("what the next prompt would send:", lab.shape(next_request))
the record: U A[c1] R(c1) A(error)
user -> "What does main.py do?"
assistant -> toolCall c1 read({"path": "main.py"})
toolResult c1 -> "def scale(n):\n\treturn n * FACTOR\n"
assistant -> (nothing) [error: 503 overloaded]
what the next prompt would send: U A[c1] R(c1) U
Five files, all with tabs
The job is a chore: five stub modules, one write each. You watch a.py appear in the workspace and it is indented with tabs. The house style here is spaces. The model is going to do the same thing four more times and it is not going to ask.
You are at the keyboard with the run in front of you. Your harness gives you two things to do with a sentence: hand it to prompt, or wait until the run is over. There is a third thing, and it is the one everyone tries: run_agent still takes a list you own, so you can drive the run yourself and put the sentence in by hand.
Your lesson 8 harness on the five-file job. A: you type the moment a.py lands. B: you drive run_agent yourself, as you did until lesson 8, and append your sentence to the list at the moment you want it there.
import harness, lab
FILES = ["a.py", "b.py", "c.py", "d.py", "e.py"]
def formatter(request):
"""The model. It writes the files one at a time and indents the way the
newest user message asks. Nobody has said anything about spaces, so: tabs."""
indent = " " if "spaces" in request.user_text else "\t"
written = [m["content"].rsplit(" ", 1)[-1].rstrip(".") # "... wrote to a.py."
for m in request.messages if m["role"] == "toolResult"]
left = [name for name in FILES if name not in written]
if not left:
return lab.say(f"{len(written)} files written.")
return lab.reply(lab.call("write", {"path": left[0],
"content": f"def f():\n{indent}return 1\n"}))
def indents(ws):
return [("tabs" if ws.read_text(name).count("\t") else "spaces") for name in FILES]
print("A. You type while the run is going on.")
ws = lab.Workspace()
model = lab.ScriptedModel([lab.forever(formatter)])
h = harness.Harness(model, harness.SYSTEM, [harness.make_write_tool(ws)])
spoken = False
for event in h.prompt("Write five stub modules."):
if event["type"] == "tool_execution_end" and not spoken:
spoken = True
try:
h.prompt("Actually, use spaces.")
except Exception as exc:
print(f" after a.py: {type(exc).__name__}: {exc}")
print(" five files:", indents(ws))
print(" your words in the record:", "Actually, use spaces." in lab.show(list(h.messages)))
print()
print("B. You hold the list yourself and append when you like.")
ws = lab.Workspace()
model = lab.ScriptedModel(
[lab.reply(lab.call("write", {"path": "a.py", "content": "def f():\n\treturn 1\n"}),
lab.call("write", {"path": "b.py", "content": "def g():\n\treturn 2\n"})),
lab.say("Both written.")])
messages = []
spoken = False
for event in harness.run_agent(model, harness.SYSTEM, messages,
[harness.make_write_tool(ws)], "Write a.py and b.py."):
if event["type"] == "tool_execution_start" and not spoken:
spoken = True
messages.append(harness.user_message("Actually, use spaces."))
print(" the record:", lab.shape(messages))
print(" ", lab.show(messages[-1:]))
A. You type while the run is going on. after a.py: RuntimeError: already running five files: ['tabs', 'tabs', 'tabs', 'tabs', 'tabs'] your words in the record: False B. You hold the list yourself and append when you like. the record: U A[c1,c2] U R(c1) R(c2) A(error) assistant -> (nothing) [error: 400 invalid_request: messages[1]: toolCall c1 has no toolResult]
A is the guard, doing exactly what you built it to do, and the five files are still tabs. B is worse, and it is worse in a way that takes a moment to see. Nothing refused it. Nothing raised. The list took the message without complaint, the two write calls ran and their results went in after it, and the next request came back 400 invalid_request: messages[1]: toolCall c1 has no toolResult. One sentence, typed in good faith, and the session is now one that no provider will read.
One call in, exactly one result out, right after it. That rule has no exception for the person who is paying.
Where this refusal comes from: [general] hosted providers reject a request in which a tool call has no result beside it. Tau keeps one 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), and its loop appends a user message at one place only, the top of a turn (src/tau_agent/loop.py:105-110).
So the question is not whether you may speak. It is when. Here is one run of two writes, moment by moment, starting just before you press Send. Tap every moment at which your sentence could be slipped into the list without making the transcript invalid.
- Before you press Send: nothing is running
- The prompt has gone out; the model is deciding
- The reply asked for two writes; the first one is running
- The first write has answered; the second is running
- Both writes have answered; the next model call has not been made
- The model has answered in words; the run has not stopped
Four of the six survive lab.validate. The two that do not are the two inside the batch, and they fail for the same reason, named in the complaint: a call whose result no longer sits beside it. If one of those two looked safe, the belief underneath is that a list will take a message whenever one arrives — the list will, and the provider then refuses the lot. The cell below splices your sentence in at each moment and prints the verdict.
The same five-message run, six times, with Actually, use spaces. spliced in at a different moment each time. The shape, then what lab.validate says about it.
import lab
w1 = lab.call("write", {"path": "a.py"}, id="c1")
w2 = lab.call("write", {"path": "b.py"}, id="c2")
run = [lab.user("Write five stub modules."),
lab.reply(w1, w2),
lab.tool_result(w1, "Successfully wrote to a.py."),
lab.tool_result(w2, "Successfully wrote to b.py."),
lab.say("Two files written.")]
WORD = lab.user("Actually, use spaces.")
MOMENTS = [
(0, "1 before you press Send: nothing is running"),
(1, "2 the prompt has gone out; the model is deciding"),
(2, "3 the reply asked for two writes; the first one is running"),
(3, "4 the first write has answered; the second is running"),
(4, "5 both writes have answered; the next model call has not been made"),
(5, "6 the model has answered in words; the run has not stopped"),
]
for where, label in MOMENTS:
spliced = run[:where] + [WORD] + run[where:]
problems = lab.validate(spliced)
print(label)
print(" ", lab.shape(spliced))
print(" ", problems[0] if problems else "lab.validate: valid")
1 before you press Send: nothing is running
U U A[c1,c2] R(c1) R(c2) A
lab.validate: valid
2 the prompt has gone out; the model is deciding
U U A[c1,c2] R(c1) R(c2) A
lab.validate: valid
3 the reply asked for two writes; the first one is running
U A[c1,c2] U R(c1) R(c2) A
messages[1]: toolCall c1 has no toolResult
4 the first write has answered; the second is running
U A[c1,c2] R(c1) U R(c2) A
messages[1]: toolCall c2 has no toolResult
5 both writes have answered; the next model call has not been made
U A[c1,c2] R(c1) R(c2) U A
lab.validate: valid
6 the model has answered in words; the run has not stopped
U A[c1,c2] R(c1) R(c2) A U
lab.validate: valid
Look at the first two moments. They produce the same list. The transcript cannot tell "before you pressed Send" from "while the request was in flight", which is fine for moment 1 and a lie for moment 2: by then the model had already been sent the request, and a record that shows your sentence above the reply claims it was read when it was not. Nothing in the file could ever catch that, which is why it has to be a rule about where the loop is allowed to look.
Moment 1 is honest as well, but nothing is running then: typing before you press Send is just a prompt waiting to be sent. Inside a run, two moments are left that are both valid and honest: after a turn's whole batch of calls, and when the model has stopped asking for anything. Two gaps. Now the awkward part.
You typed Actually, use spaces. while a.py was being written. Which of the two gaps is it for? The cell runs the same sentence into each.
Asked for after the batch, the sentence lands as message 4 and four of the five files come out with spaces. Asked for at the end, it lands after the last one and nothing is left to correct. Same words, same list, same loop: only the moment differs.
import harness, lab
FILES = ["a.py", "b.py", "c.py", "d.py", "e.py"]
def formatter(request):
"""The model: writes the next file nobody has asked for yet, indenting the
way the newest user message asks; when there is none left, it says so."""
newest = request.user_text.splitlines()[-1]
asked = [block["arguments"]["path"] for m in request.messages
if m["role"] == "assistant" for block in m["content"]
if block["type"] == "toolCall"]
todo = [name for name in FILES if name not in asked]
if not todo:
return lab.say("All five are written.")
indent = " " if "spaces" in newest else "\t"
return lab.reply(lab.call("write", {"path": todo[0],
"content": f"def f():\n{indent}return 1\n"}))
def go(slot):
"""One run. `slot` says which of the loop's two questions your words answer."""
ws, said, messages = lab.Workspace(), [], []
def typed():
"""You are watching the files appear. The moment a.py lands, you type."""
if not said and ws.exists("a.py"):
said.append(1)
return [harness.user_message("Actually, use spaces.")]
return []
for event in harness.run_agent(lab.ScriptedModel([lab.forever(formatter)]), harness.SYSTEM,
messages, [harness.make_write_tool(ws)],
"Write five stub modules.", **{slot: typed}):
pass
heard = [m["role"] for m in messages].index("user", 1)
print(" ", lab.shape(messages))
print(" files written after the model read it:",
sum(m["role"] == "toolResult" for m in messages[heard:]), "of 5")
print(" on disk:", [("tabs" if "\t" in ws.read_text(f) else "spaces") for f in FILES])
print("A. Asked for once the turn's whole batch of calls has been answered.")
go("get_steering")
print()
print("B. Asked for only when the run would otherwise end.")
go("get_follow_ups")
A. Asked for once the turn's whole batch of calls has been answered.
U A[c1] R(c1) U A[c2] R(c2) A[c3] R(c3) A[c4] R(c4) A[c5] R(c5) A
files written after the model read it: 4 of 5
on disk: ['tabs', 'spaces', 'spaces', 'spaces', 'spaces']
B. Asked for only when the run would otherwise end.
U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A[c4] R(c4) A[c5] R(c5) A U A
files written after the model read it: 0 of 5
on disk: ['tabs', 'tabs', 'tabs', 'tabs', 'tabs']
Now the other sentence, typed at the same instant: When you are done, run the tests. Same two gaps. Which one?
Taken at the first safe gap, the tests ran with one of five files written and came back Command exited with code 1; taken at the end, with five, and passed. So the two sentences are not one kind of thing in one queue. One is
Where the failing tests come from: lab.Shell is a simulator, not a shell, and its pytest is canned — it fails until every file it was told to expect exists. The interesting part of that run is not the exit code but when the command was issued.
import harness, lab
FILES = ["a.py", "b.py", "c.py", "d.py", "e.py"]
def formatter(request):
"""The model: if the newest user message asks for tests and they have not
been run, run them. Otherwise write the next file nobody has asked for yet;
when there is none left, report the tests or say it is finished."""
newest = request.user_text.splitlines()[-1]
calls = [block for m in request.messages if m["role"] == "assistant"
for block in m["content"] if block["type"] == "toolCall"]
if "tests" in newest and not any(call["name"] == "bash" for call in calls):
return lab.reply(lab.call("bash", {"command": "pytest"}))
todo = [name for name in FILES
if name not in [call["arguments"].get("path") for call in calls]]
if not todo:
last = request.last_result
if last and last["tool_name"] == "bash":
return lab.say("Tests: " + last["content"].splitlines()[-1])
return lab.say("All five are written.")
return lab.reply(lab.call("write", {"path": todo[0], "content": "def f():\n\treturn 1\n"}))
def go(slot):
"""One run. `slot` says which of the loop's two questions your words answer."""
ws, said, messages = lab.Workspace(), [], []
shell = lab.Shell(ws, tests_pass_when=lambda w: len(w.listdir(".")) == 5)
def typed():
"""You are watching the files appear. The moment a.py lands, you type."""
if not said and ws.exists("a.py"):
said.append(1)
return [harness.user_message("When you are done, run the tests.")]
return []
tools = [harness.make_write_tool(ws), harness.make_bash_tool(shell)]
for event in harness.run_agent(lab.ScriptedModel([lab.forever(formatter)]), harness.SYSTEM,
messages, tools, "Write five stub modules.", **{slot: typed}):
if event["type"] == "tool_execution_start" and event["call"]["name"] == "bash":
print(" the tests ran with", len(ws.listdir(".")), "of 5 files written")
verdict = next(m for m in messages if m.get("tool_name") == "bash")
print(" ", lab.shape(messages))
print(" the tests said:", verdict["content"].splitlines()[-1])
print("A. Asked for once the turn's whole batch of calls has been answered.")
go("get_steering")
print()
print("B. Asked for only when the run would otherwise end.")
go("get_follow_ups")
A. Asked for once the turn's whole batch of calls has been answered.
the tests ran with 1 of 5 files written
U A[c1] R(c1) U A[c2] R(c2) A[c3] R(c3) A[c4] R(c4) A[c5] R(c5) A[c6] R(c6) A
the tests said: Command exited with code 1
B. Asked for only when the run would otherwise end.
the tests ran with 5 of 5 files written
U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A[c4] R(c4) A[c5] R(c5) A U A[c6] R(c6) A
the tests said: ===== 2992 passed =====
Who reaches into the list?
Two gaps and two kinds of message. Neither attempt in the opening cell got as far as a gap: one was refused, the other put the sentence where no provider would read it. A sentence still has to travel from a keyboard into a list, and something has to carry it.
Two candidates. The UI, which knows what you typed and the instant you typed it. The loop, which knows where in a turn it is. Which of them should put the message into the list, and what does the other one do instead? A sentence or two.
Only one of them can name the moment. Which one knows that a turn's whole batch of calls has just been answered?
The UI knows what you said; the loop knows where it is; neither knows both, and the moment is the hard half. So the UI does not touch the list at all. It puts your message somewhere and the loop asks for it, at the two moments it knows are safe.
That is two new parameters on run_agent, get_steering and get_follow_ups: functions of no arguments that return a list of user messages, usually empty. Whatever comes back is appended and announced like any other message. The loop is handed two questions it is allowed to ask, and never learns that a queue, a harness or a person exists.
Common answers, and what each one misses
- "The UI pushes, but only while
is_runningis false." That makes the flag mean something it does not mean.is_runningis false when there is no run at all; it is never false between the turns of one. A sentence typed during the run still has nowhere to go, and the user is back to waiting. - "The UI pushes wherever, and the loop tidies up afterwards." Then every mid-run sentence is a small act of damage with a repair budgeted against it. Lesson 10 is about damage nobody chose; choosing it on purpose, once a turn, is a strange place to start.
- "Pass the loop the queue." Then
run_agentknows what a queue is, which end you take from, and who owns it — and the loop's one virtue since lesson 3 is that it owns nothing that outlives the run. A function of no arguments is the smallest thing that carries the answer, and a test can hand it a plainlambda.
Figure 9.1 The two gaps, on the crank you built in lesson 3. The list is the caller's; the loop pulls, and only here.
get_steering(), asked once the turn's whole batch is answered: slip 4, the user'sActually, use spaces., lands after both results, not between them.get_follow_ups(), asked where the crank would let go — a reply with no tool calls — so slip 6,Now run the tests., opens one more turn instead of ending the run.- The push, crossed out: a message put into the list from outside can arrive between a
toolCalland itstoolResult, which is moments 3 and 4 of the tap, and the 400 in the opening cell.
Two follow-ups are waiting when the run would end: Run the tests. and Then commit. The loop asks once. What should it be handed? Pick, and give your reason in one line.
U A U A U A
U A U U A
One at a time gives three model calls and three answers; the whole queue at once gives two, and the one answer addressed only the last line it was sent. Neither is wrong in general, which is why Tau makes it a setting and not a rule. Your harness does one per ask, because that is the version in which every sentence gets an answer of its own.
Where the unanswered sentence comes from: the model in this cell is scripted to answer the newest line of user text it was sent, so run B shows the risk at its plainest. [general] Two user messages in one turn get one reply, and which of them it addresses is the model's choice, not yours.
import harness, lab
def waiting(*texts, together):
"""Two messages are queued. `together` decides how many one ask hands over."""
queued = [harness.user_message(text) for text in texts]
def ask():
if not queued:
return []
if together:
taken, queued[:] = list(queued), []
return taken
return [queued.pop(0)]
return ask
def answerer(request):
"""The model: answers the last line of user text it was sent."""
return lab.say("Doing: " + request.user_text.splitlines()[-1])
def go(together):
model, messages = lab.ScriptedModel([lab.forever(answerer)]), []
for event in harness.run_agent(model, harness.SYSTEM, messages, [], "Reformat main.py.",
get_follow_ups=waiting("Run the tests.", "Then commit.",
together=together)):
pass
print(" ", lab.shape(messages), " in", len(model.calls), "model calls")
print(" ", lab.show(messages).replace("\n", "\n "))
print("A. One per ask.")
go(together=False)
print()
print("B. The whole queue per ask.")
go(together=True)
A. One per ask.
U A U A U A in 3 model calls
user -> "Reformat main.py."
assistant -> "Doing: Reformat main.py."
user -> "Run the tests."
assistant -> "Doing: Run the tests."
user -> "Then commit."
assistant -> "Doing: Then commit."
B. The whole queue per ask.
U A U U A in 2 model calls
user -> "Reformat main.py."
assistant -> "Doing: Reformat main.py."
user -> "Run the tests."
user -> "Then commit."
assistant -> "Doing: Then commit."
Nothing is running. You say h.steer("Actually, use spaces.") to an idle harness, and a moment later h.prompt("Reformat main.py."). What does the transcript look like?
U U A, and it is one turn: the prompt, then the waiting sentence, then one reply that has read both
U A. The queue belongs to the run, so a message queued when there is no run to queue it for is dropped
U U A, but with your sentence first: it was typed first, and a transcript is a record of what was said in the order it was said
One turn, one model call, and the request carried both user messages. A queue that empties when a run ends would have made the user say it twice; a queue that is only read after the first turn would have spent a model call before reading it. The harness holds the queues, so neither happens.
import harness, lab
model = lab.ScriptedModel([lab.say("Spaces it is.")])
h = harness.Harness(model, harness.SYSTEM, [])
h.steer("Actually, use spaces.")
print("after steer(): is_running", h.is_running,
"| messages on the record:", len(h.messages))
print()
for event in h.prompt("Reformat main.py."):
if event["type"] == "message_end":
print(" message_end ", lab.show([event["message"]]))
else:
print(" ", event["type"])
print()
print("the record:", lab.shape(h.messages))
print("what the one model call was sent:", lab.shape(model.calls[0].messages))
after steer(): is_running False | messages on the record: 0 agent_start turn_start message_end user -> "Reformat main.py." message_end user -> "Actually, use spaces." message_end assistant -> "Spaces it is." turn_end agent_end the record: U U A what the one model call was sent: U U
Last one. write is running and you steer in the middle of it. Does the tool stop?
a.py is written with tabs, its result is recorded and announced, and only then does your sentence open a turn
run_tool called it and is waiting for it to return. Wanting something to stop is not a mechanism, and building one is lesson 15.a.py really was written with tabs; a model that is told so can fix it.Read the event stream: tool_execution_start, the result's message_end, tool_execution_end, turn_end, and then a new turn_start with your sentence as its first message. Steering is not a stop button. It is a message that takes the next gap.
import harness, lab
ws = lab.Workspace()
def write_with_tabs(arguments):
"""The tool, already running. Halfway through it, you change your mind."""
path = harness.str_arg(arguments, "path")
h.steer("Actually, use spaces.")
ws.write_text(path, "def f():\n\treturn 1\n")
return f"Successfully wrote to {path}."
slow = {"name": "write", "description": "Write a stub module.",
"parameters": {"type": "object", "required": ["path"],
"properties": {"path": {"type": "string", "description": "Path."}}},
"execute": write_with_tabs}
model = lab.ScriptedModel([lab.reply(lab.call("write", {"path": "a.py"})),
lab.say("Spaces from now on.")])
h = harness.Harness(model, harness.SYSTEM, [slow])
def line(event):
if event["type"] == "message_end":
return f"{event['type']:<21} {lab.show([event['message']])}"
if event["type"].startswith("tool_execution"):
return f"{event['type']:<21} {event['call']['id']}"
return event["type"]
for event in h.prompt("Write a.py."):
print(line(event))
print()
print("a.py on disk:", repr(ws.read_text("a.py")))
agent_start
turn_start
message_end user -> "Write a.py."
message_end assistant -> toolCall c1 write({"path": "a.py"})
tool_execution_start c1
message_end toolResult c1 -> "Successfully wrote to a.py."
tool_execution_end c1
turn_end
turn_start
message_end user -> "Actually, use spaces."
message_end assistant -> "Spaces from now on."
turn_end
agent_end
a.py on disk: 'def f():\n\treturn 1\n'
Build: ask twice a turn
The rungs took the designs away one at a time: a sentence pushed straight into the list, one queue for both kinds of message, a queue that dies with the run, a loop handed the queue itself, a steer that stops the tool it interrupts. What is left is small, and it is shaped by the one thing that cannot move: a user message may only open a turn.
That is what makes run_agent grow a second loop. The outer one runs while somebody is waiting to speak; the inner one runs turns. The skeleton is given, because this is the hardest control flow in the course, and lesson 8's turn sits inside it unchanged.
Figure 9.2 The harness after today. Two more things it owns and lends out: steer() and follow_up() fill the queues, and the loop drains them through two callables. run_agent is as stateless as it was in lesson 3.
Four gaps, about 25 lines in all. The skeleton numbers three of them, "blank 1 of 3" and so on, and each of those is one line; the fourth is the body of Harness.
- Blank 1: the inner condition. One pass of the inner loop is one turn. When it is reached,
callsholds the tool calls the last turn ran andpendingholds the user messages waiting to open the next one. It sayswhile False:today, so an unfilled skeleton stops at once instead of spinning. - Blanks 2 and 3: two
pending = ...lines. Their comments name a moment, not a queue. One sits where every call of the turn has its result and the model has not been asked anything since; the other is reached only when there are no calls and nobody waiting. Which callable is asked at which moment is your decision.ask(get)is written for you at the top of the function: it returnslist(get()), or[]when nobody was passed, so a caller who wants none of this passes neither. - The fourth gap: the body of
Harness. Lesson 8's class is inside it, and about fifteen lines go into five places. Twodeque()s in__init__, one per kind.steer(text)andfollow_up(text), which putuser_message(text)on a queue and do nothing else — no appending, no checking whether a run is on._drain(queue), what the loop is handed when it asks: a list holding the oldest queued message, taken off the queue, or[]._run, which passesrun_agentthe two zero-argument callables. And the refusal inprompt, which becomesalready running; use steer() or follow_up(): whoever hits it wants to know what they can do.
Twelve hidden tests. Their tool does what a person at the keyboard does, from inside the run: while it runs as c1 of a two-call batch it calls h.steer() and h.follow_up(), and the run has to come out U A[c1,c2] R(c1) R(c2) U A U A in three model calls. They also check the events of a steered run and of a follow-up run, that steering is never asked for while a call is unanswered, that two queued messages get a turn each, oldest first, that a failed reply and a max_turns stop ask nobody and leave the queue waiting for the next run, that a refused prompt leaves no trace, and that run_agent with neither callable behaves exactly as it did in lesson 8.
- Start with blank 1. There are only two reasons the loop would need to run another turn, and both of them already have names in scope at that line. Then the two
pendinglines: one of them is reached at the gap you argued about in the first prediction, the other at the gap you argued about in the second. Which sentence was each gap for? - Blank 1: a turn is needed when the last one left tool calls whose results the model has not seen, or when somebody's words are waiting to open a turn. Blank 2 is the line right after
turn_end, so the whole batch is answered and nothing has been sent since: the gap for words that cannot wait. Blank 3 is reached only once the inner loop has stopped, which means no calls and nothing pending — the run is over unless somebody speaks — so it is the other queue. Both lines look like the given one above the loops. In theHarnessnothing is clever:steerandfollow_upare a line each and never touchself._messages;_draintakes from the left of adeque, so the oldest goes first;_runhandsrun_agenttwolambdas, each draining one queue, so the loop is given functions and never a queue. - In outline.
run_agent while pending: while <the last turn left calls to answer, or somebody is waiting to speak>: ... lesson 8's turn, given, with the drain of `pending` at the top ... if not failed: pending = ask(<the callable for words that cannot wait>) if not failed: pending = ask(<the callable for words that waited for the end>) Harness __init__ self._steering, self._follow_ups = deque(), deque() steer self._steering.append(user_message(text)) follow_up the same line, on the other queue _drain(q) [q.popleft()] if q else [] _run run_agent(..., get_steering=lambda: self._drain(self._steering), get_follow_ups=lambda: ...) prompt the refusal gains "; use steer() or follow_up()"
Three blanks and fifteen lines. The user spoke twice while a tool was running and the record came out U A[c1,c2] R(c1) R(c2) U A U A: valid, in order, each sentence with a turn of its own. Now look at what run_agent still does not know. Not that a queue exists, not that a Harness exists, not that anybody is at a keyboard. It was handed two functions and it asked them twice.
What changed since lesson 08
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- While c1 of a two-call batch runs, the user steers and queues a follow-up: the record is U A[c1,c2] R(c1) R(c2) U A U A after three model calls, and it is valid.
- A steering message opens the next turn: turn_start, then its message_end, then the assistant's; every message on the record is announced exactly once.
- A follow-up opens a new turn inside the same run: ... turn_end, turn_start, its message_end, the answer, turn_end, and only then agent_end, carrying all four messages.
- run_agent asks get_steering() only at moments when the record is a valid transcript, never between two tool calls of one batch, and what it is handed opens the next turn.
- A follow-up queued at the very start of a three-turn job is asked for only after the final answer, never while the model still has tool calls to make.
- Two steering messages, one from each tool of a batch, are answered one at a time, oldest first; so are two follow-ups: x A f1 A f2 A.
- A steering message and a follow-up both queued while idle: the steer goes in with the prompt, the follow-up after the answer.
- One tool steers twice and queues a follow-up. The first steer is answered in plain text with the second still waiting: the second goes next, alone, and the follow-up only after its answer.
- A prompt during a run is still refused, and the RuntimeError now reads "already running; use steer() or follow_up()".
- A run that stops on max_turns or a provider failure ends there: a queued follow-up is not drained into the failed run, and it is still waiting for the next one.
- With max_turns=1 and two steers from the first turn's tool, the second turn records one steer and then the stop; no second model call is made, and the other steer waits for the next run.
- run_agent called with no get_steering and no get_follow_ups, or with callables that never have anything, runs exactly as in lesson 8.
The job from the opening, and you at the keyboard. The moment a.py lands you steer to spaces and queue the tests for the end. No tests here: read the transcript, then the files on disk. Once the lab has passed, this runs against your code.
import harness, lab
FILES = ["a.py", "b.py", "c.py", "d.py", "e.py"]
ws = lab.Workspace()
shell = lab.Shell(ws, tests_pass_when=lambda w: len(w.listdir(".")) == 5)
def formatter(request):
"""The model: if the newest user message asks for tests and they have not
been run, run them. Otherwise write the next file nobody has asked for yet;
when there is none left, say so."""
newest = request.user_text.splitlines()[-1]
calls = [block for m in request.messages if m["role"] == "assistant"
for block in m["content"] if block["type"] == "toolCall"]
if "tests" in newest and not any(call["name"] == "bash" for call in calls):
return lab.reply(lab.call("bash", {"command": "pytest"}))
todo = [name for name in FILES
if name not in [call["arguments"].get("path") for call in calls]]
if not todo:
last = request.last_result
if last and last["tool_name"] == "bash":
return lab.say("Tests: " + last["content"].splitlines()[-1])
return lab.say("All five are written.")
indent = " " if "spaces" in newest else "\t"
return lab.reply(lab.call("write", {"path": todo[0],
"content": f"def f():\n{indent}return 1\n"}))
model = lab.ScriptedModel([lab.forever(formatter)])
h = harness.Harness(model, harness.SYSTEM,
[harness.make_write_tool(ws), harness.make_bash_tool(shell)])
spoken = False
for event in h.prompt("Write five stub modules."):
if event["type"] == "tool_execution_end" and not spoken:
spoken = True # a.py has just landed, and you type
h.steer("Actually, use spaces.")
h.follow_up("When you are done, run the tests.")
if event["type"] == "message_end":
print(lab.show([event["message"]]))
print()
print("the record:", lab.shape(h.messages))
print("valid:", lab.validate(list(h.messages)) == [])
print("on disk:", [("tabs" if "\t" in ws.read_text(f) else "spaces") for f in FILES])
print("model calls:", len(model.calls))
user -> "Write five stub modules."
assistant -> toolCall c1 write({"path": "a.py", "content": "def f():\n\treturn 1\n"})
toolResult c1 -> "Successfully wrote to a.py."
user -> "Actually, use spaces."
assistant -> toolCall c2 write({"path": "b.py", "content": "def f():\n return 1\n"})
toolResult c2 -> "Successfully wrote to b.py."
assistant -> toolCall c3 write({"path": "c.py", "content": "def f():\n return 1\n"})
toolResult c3 -> "Successfully wrote to c.py."
assistant -> toolCall c4 write({"path": "d.py", "content": "def f():\n return 1\n"})
toolResult c4 -> "Successfully wrote to d.py."
assistant -> toolCall c5 write({"path": "e.py", "content": "def f():\n return 1\n"})
toolResult c5 -> "Successfully wrote to e.py."
assistant -> "All five are written."
user -> "When you are done, run the tests."
assistant -> toolCall c6 bash({"command": "pytest"})
toolResult c6 -> "collected 2992 items\n\n===== 2992 passed =====\n"
assistant -> "Tests: ===== 2992 passed ====="
the record: U A[c1] R(c1) U A[c2] R(c2) A[c3] R(c3) A[c4] R(c4) A[c5] R(c5) A U A[c6] R(c6) A
valid: True
on disk: ['tabs', 'spaces', 'spaces', 'spaces', 'spaces']
model calls: 8
a.py has tabs and the other four have spaces, because your sentence landed after the first write and before the second. The tests ran once, at the end, against all five files, and passed. Eight model calls, one record, and lab.validate has nothing to say about it. Read the transcript again and notice that there is no mark anywhere saying which two messages you typed mid-run: they are user messages, and they are exactly as much a part of the conversation as the prompt that started it.
- 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.
- Take your input mid-run, at a safe point.
Say it in your own words
A colleague looks at your two queues and asks why one would not do. In your own words, a sentence or two: what is the difference between the two kinds of message, and who decides when each of them lands?
Both are sentences a user typed. What differs is the moment each one is for — and only one party in the room can tell when that moment has arrived.
One kind cannot wait and takes the next gap there is; the other is for the end and waits for it. Neither of them decides when it lands: the loop does, because only the loop knows that a batch is answered or that a reply asked for nothing. You can speak at any time. The transcript listens twice a turn.
Common answers, and what each one misses
- "One is urgent and one is not." Close, and it makes it sound like a priority. It is not a priority, it is a condition: "when you are done" names a moment in the run, and a queue that sorts by urgency has nothing to test that against.
- "One is for the current job and one starts a new one." They are the same run and the same transcript either way — the follow-up gets a turn inside the run that was going on, and
agent_endcarries all of it. Starting a second run would be the design that lesson 8 forbids. - "The harness decides, since it owns the queues." The harness owns them and hands over one message when asked. If it decided when, it would have to know what the loop was in the middle of, and then two objects would be keeping track of one turn.
The user can speak at any time; the transcript only listens between turns.
Tau's loop has the same two pull points, in the same two places, expressed as the same two zero-argument callables — and the follow-up branch is what turns its outer loop round again.
if not failed:
pending = ask(get_steering)
if not failed:
pending = ask(get_follow_ups)
yield TurnEndEvent(message=assistant, tool_results=tool_results)
turn += 1
pending = tuple(get_steering_messages() if get_steering_messages else ())
follow_ups = tuple(get_follow_up_messages() if get_follow_up_messages else ())
if follow_ups:
pending = follow_ups
continue
break
Tau is async; read async for as for and await f(x) as f(x) until lesson 15.
The same shapes.
- The inner condition is
while has_more_tools or pending:(src/tau_agent/loop.py:100) — yours with the calls counted as a flag rather than a list. - Steering is asked for in two places, once before the first turn (
src/tau_agent/loop.py:96) and once after each one (src/tau_agent/loop.py:174, the third line of the excerpt). The first is why an idle steer goes in with the prompt. - Queued messages are appended to the caller's list and announced before the next provider call, at the top of a turn and nowhere else (
src/tau_agent/loop.py:105-110). - The queues are two
deques on the harness, not on the run (src/tau_agent/harness.py:76-77), drained one message at a time from the left (src/tau_agent/harness.py:225-232) and handed to the loop as two bound methods (src/tau_agent/harness.py:185-186). - The refusal your lab writes is the middle of Tau's, which names the class and says what queueing is for:
AgentHarness is already running; use steer() or follow_up() to queue messages.
(src/tau_agent/harness.py:213-217). - The test that pins all of it scripts three replies, pops one message per ask from two lists, and asserts the user messages came out
start,steer,follow upin three provider calls (tests/test_agent_loop.py:415-461).
What Tau adds. A frontend needs to show what is waiting, and let you take it back. steer, follow_up and clear_queues each hand back a snapshot of both queues (src/tau_agent/harness.py:28-35), clear_queues empties both and a pop_latest_ takes the newest back off either one (src/tau_agent/harness.py:135-145), and the Textual frontend spends the Up arrow on exactly that: in an empty prompt, during a run, it moves "the latest queued message back into the prompt for editing" (src/tau_coding/tui/app.py:6321-6334). Any message type can be queued, not only a user message (src/tau_agent/harness.py:124-126). And how much one ask hands over is a setting, queue_mode, one_at_a_time or all (src/tau_agent/harness.py:225-232) — the two runs of the two-follow-ups prediction, as a config field.
Tau's steer() returns something; yours returns None. What, and what is it for?
run_agent once did. Nothing in Tau looks a queued message up again by identity.An immutable QueuedMessages with the two tuples and a count (src/tau_agent/harness.py:28-35), returned by steer, follow_up and clear_queues alike. Your harness has no such thing, so a frontend built on it cannot show you what is waiting — which is also why it cannot offer to take it back.
Declared in Tau, read by nothing. queue_mode is a real switch and nothing in Tau's own source ever sets it: the default is one_at_a_time (src/tau_agent/harness.py:45) and the only place all is chosen is a test (tests/test_agent_harness.py:120-150). It is a setting waiting for a frontend to have an opinion.
Where the two differ. Tau yields an assistant message's message_end before appending it (src/tau_agent/loop.py:136-147), where yours appends first and announces after; for a queued user message the order is the same as yours, appended and then announced. Tau also emits a message_start beside every message_end, which is lesson 16. And its harness lives one layer further out than yours: queue ownership is in tau_agent, the decision to call steer rather than follow_up belongs to the coding session (src/tau_coding/session.py:1394-1408), and the event a frontend draws its queue counter from, QueueUpdateEvent, is not the harness's at all (src/tau_coding/events.py:24).
Where yours is weaker. Nothing can look at your queues, so nothing can show them or undo them. Only a user message can be queued. There is no way to say "answer these two together" even when they belong together. And a queued message is never written down anywhere until it drains, so a session that ends before the drain loses it without a trace — which is true of Tau as well, deliberately: its session layer "does not persist a queued message at queue time" (dev-notes/architecture/queued-steering-follow-ups.md:37-40).
src/tau_agent/loop.py:172-180 · pinned to commit 9fe6a71 · view on GitHub
One more case
A frontend with the "take it back" key Tau has. You steer Actually, use spaces. during a run, change your mind, and pull it out of the queue before the loop's next ask. Is it in the transcript?
steer() put it in the transcript there and then; the two gaps only decide when the model is shown it. Taking it back means deleting a message from history
steer() is called inside the tool, and no user message joins the record until the next turn opens.context_for_model may leave things out; nothing anywhere puts something in.Your harness has no take-back, so the cell runs the two halves of the answer instead. Run A prints the record after every event: it gains your sentence at one instant, the message_end that announces it, several events after steer() returned. In run B a follow-up is queued while the tool runs and max_turns ends the run before the loop ever asks for one, so the record holds no trace of it anywhere — which is the same outcome as taking it back, because it is the same situation. A queue is a thing that has not happened yet.
import harness, lab
def write_tool(while_it_runs):
"""A `write` tool that, halfway through, does what a user at the keyboard
might: `while_it_runs` is called with nothing and returns nothing."""
def execute(arguments):
while_it_runs()
return f"Successfully wrote to {harness.str_arg(arguments, 'path')}."
return {"name": "write", "description": "Write a stub module.", "execute": execute,
"parameters": {"type": "object", "required": ["path"],
"properties": {"path": {"type": "string", "description": "Path."}}}}
print("A. Queued from inside the tool, and the run carries on.")
model = lab.ScriptedModel([lab.reply(lab.call("write", {"path": "a.py"})), lab.say("Noted.")])
h = harness.Harness(model, harness.SYSTEM,
[write_tool(lambda: h.steer("Actually, use spaces."))])
for event in h.prompt("Write a.py."):
print(f" {event['type']:<21} the record: {lab.shape(h.messages) or '(empty)'}")
print()
print("B. Queued from inside the tool, and the run is stopped before the next gap.")
model = lab.ScriptedModel([lab.reply(lab.call("write", {"path": "a.py"})), lab.say("Never sent.")])
h = harness.Harness(model, harness.SYSTEM,
[write_tool(lambda: h.follow_up("Run the tests."))], max_turns=1)
for event in h.prompt("Write a.py."):
pass
print(" the record:", lab.shape(h.messages))
print(" your words anywhere in it:", "Run the tests." in lab.show(list(h.messages)))
A. Queued from inside the tool, and the run carries on.
agent_start the record: (empty)
turn_start the record: (empty)
message_end the record: U
message_end the record: U A[c1]
tool_execution_start the record: U A[c1]
message_end the record: U A[c1] R(c1)
tool_execution_end the record: U A[c1] R(c1)
turn_end the record: U A[c1] R(c1)
turn_start the record: U A[c1] R(c1)
message_end the record: U A[c1] R(c1) U
message_end the record: U A[c1] R(c1) U A
turn_end the record: U A[c1] R(c1) U A
agent_end the record: U A[c1] R(c1) U A
B. Queued from inside the tool, and the run is stopped before the next gap.
the record: U A[c1] R(c1) A(error)
your words anywhere in it: False
- You hit
- five files being written with tabs, a guard that refused your correction, and a list that took it and became unsendable.
- You built
Harness.steer,.follow_upand._drainover two queues, and the nested-whilerun_agentthat asks for them throughget_steeringandget_follow_ups.- The principle
- The user can speak at any time; the transcript only listens between turns.
- Your harness now
- run_tool
- run_agent
- context_for_model
- Harness
- FinalTextRenderer
- steer
- follow_up
- _drain
- Your answers
- Still open
- The user does not steer. They walk away, and the consumer closes the run while
bashis starting. The transcript ends on a call with no result, and every prompt after that comes back 400 — including the ones that have nothing to do with it. Lesson 10.