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.

~55 min · 1 lab · builds on 08 Who holds the list?

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?

Nothing out of the ordinary. 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
True about the generator, wrong about the guard. Lesson 8 put the check in 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
The flag went up when prompt was called, not at the first next(). Note what happened to the user's sentence.
Both Sends are accepted and the two runs share the one list, which is what a web server does: the second prompt is appended while the first run is mid-turn
That is the bug lesson 8 closed, and the belief under it is that a transcript can take a message whenever one arrives. It cannot, and today you find out where it can.

The run guard did its job: one writer, refused on the caller's line, before any money was spent. And the user's sentence is nowhere. It was not queued, not recorded, not sent; it was only refused. Today that refusal gets something to offer instead.

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?

Four messages on the record, but only three of them go out — the prompt, the call, its result — and your new prompt after them. The empty failed reply is left behind
Two lists, one computed from the other. context_for_model is the only thing that decides what a request looks like.
All four, and then the new prompt: the record is the request, so the failed reply goes out with everything else
That has the stored list and the sent list being one list. They stopped being one in lesson 5, for this exact message: an assistant turn with nothing in its content is true, worth keeping, and refused by a provider.
Nothing usable: a 503 is a failure, so the run raised and the list is whatever it was before turn 2
That makes a provider failure the program's problem rather than the conversation's. It comes back as a message, in band, and the run ends with a transcript you could send again.

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.

The gap right after the turn's whole batch of calls: the first moment after you typed it at which the list will take it
Four files have not been written yet. The value of the sentence is entirely in getting there before they are.
The gap where the run would otherwise end. That is what a queue is for: the agent finishes the job it was given, and then reads what came in
One queue, drained at the polite moment. Watch what "the job it was given" turns out to include.
Either. Your sentence is in the list before the next model call in both cases, so the run comes out the same and the choice is bookkeeping
"Before the next model call" is true of both, and the next model call is four files apart. Where the loop asks is not cosmetic: it is the difference between a correction and a post-mortem.

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?

The same gap as the last one, and the same queue. There is one place the user's words wait, and the loop takes them at the first moment it safely can
One queue serves whichever message is on top. Read what the tests were run against.
The gap where the run would otherwise end: the words say so themselves
"When you are done" is a condition, and only the loop can see when it is met — a turn in which the model asked for nothing.
After the batch, like the other one, but the loop holds it back and only shows it to the model later. Where the words are put and when the model reads them are separate decisions
Put in the list, they are what the model reads: the list is the request. To hold one back you would need somewhere else to keep it until its moment comes, which is precisely the thing this option is trying to avoid building.

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 steering: it cannot wait, and it goes in at the next gap there is. The other is a follow-up: it is for the moment the run would otherwise end.

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_running is false." That makes the flag mean something it does not mean. is_running is 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_agent knows 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 plain lambda.
Safe injection points: the two gaps where run_agent pulls in a queued user message The crank of run_agent with the only two places where a mid-run user message may join the list. The loop pulls with get_steering() after a turn's whole tool batch, so the steering message lands after toolResult c1 and toolResult c2, never between them. It pulls with get_follow_ups() when the run would otherwise end, after an assistant reply with no tool calls. Pushing a message into the list from outside is crossed out: it can land between a toolCall and its toolResult. The list reads user, assistant with c1 and c2, toolResult c1, toolResult c2, user steering, assistant, user follow-up. messagesthe caller's list 0 userWrite the five modules. 1 assistant write(path="a.py")c1 write(path="b.py")c2 never between call and result 2 toolResult · c1wrote a.py 3 toolResult · c2wrote b.py 4 user· steeringActually, use spaces. 5 assistantSwitched to spaces. Done. 6 user· follow-upNow run the tests. model.complete()ScriptedModel run_tool()tools: write run_agentgo again whole list, every call append reply reply tool_calls(reply)? none:return reply some:run each append result get_steering()after the batch get_follow_ups()when it would end pushed in no tool calls: return reply get_follow_ups()when it would end whole list,every call appendreply appendresult get_steering()after the batch pushedin

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.

  1. get_steering(), asked once the turn's whole batch is answered: slip 4, the user's Actually, use spaces., lands after both results, not between them.
  2. 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.
  3. The push, crossed out: a message put into the list from outside can arrive between a toolCall and its toolResult, 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.

One, the older. The other waits for the gap after the next answer: U A U A U A
Each sentence gets its own turn, so the model answers the first before it reads the second.
Both, as two messages in one turn. They were typed seconds apart and one answer covers them: U A U U A
Defensible, and it is the second run in the cell. Read that transcript and ask what became of the first of the two sentences.
Both, joined into one user message with a newline between them. Two sentences, one thing to read, one turn's worth of framing
That is the lesson 1 belief that a conversation is a string you can staple together. The record then says the user sent one message and they sent two, and nothing downstream can ever take them apart again — not a UI showing what was said, not a log, not you.

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
The loop asks before the first turn as well as after each one, so nothing has to sit out a turn waiting for a gap to come round.
Just U A. The queue belongs to the run, so a message queued when there is no run to queue it for is dropped
That puts the state in the run. Lesson 8 moved it out for good: the queues are the harness's, like the transcript, and they outlive every run that does not drain them.
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
A transcript is a record of what was sent, and the prompt is what opened the run. The order is the loop's: the prompt, then whatever was waiting, then the model's answer to all of it.

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?

No. It runs to the end, a.py is written with tabs, its result is recorded and announced, and only then does your sentence open a turn
The steer changes what happens next. It cannot change what has already been started.
Yes. A new instruction means the old one is wrong, so the tool is abandoned where it stands and the turn starts over with your words
Nothing in this harness can stop a function that is already running: 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.
No, but its result is quietly dropped: it was produced under an instruction you have just withdrawn, and showing it to the model would only confuse it
That treats history as something you tidy. Drop the result and the call above it has none, which is the list a provider refuses — you would have made by hand the damage lesson 10 is about. 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.

Who holds the list: the Harness owns the state, run_agent owns nothing The UI talks only to the Harness. prompt() passes a guard first: if is_running, it raises already running; if not, a run of run_agent starts and is lent the one list, messages, for that run. run_agent is stateless: it works on the list it is given. From lesson 9 the Harness also owns two queues, filled by steer() and follow_up(); the loop never sees them, it pulls from them through get_steering() and get_follow_ups(). From lesson 11 it owns the listeners added by subscribe(), and every event is pushed to them. Harnessholds the state between runs is_running?yes: raisealready running messagesthe one transcript run_agentgo again stateless: works onthe list it is given steeringget_steering() follow-upsget_follow_ups() listenerstold every event the UI callsprompt() no: start a runlent for the run steer()follow_up() pulled by the looppulled by the loop subscribe() pushed: each event the UI callsprompt() no: runlent for the run pulledpulled steer()follow_up() pushed: each eventsubscribe()

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, calls holds the tool calls the last turn ran and pending holds the user messages waiting to open the next one. It says while 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 returns list(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. Two deque()s in __init__, one per kind. steer(text) and follow_up(text), which put user_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 passes run_agent the two zero-argument callables. And the refusal in prompt, which becomes already 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.

  1. 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 pending lines: 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?
  2. 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 the Harness nothing is clever: steer and follow_up are a line each and never touch self._messages; _drain takes from the left of a deque, so the oldest goes first; _run hands run_agent two lambdas, each draining one queue, so the loop is given functions and never a queue.
  3. 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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
  7. A steering message and a follow-up both queued while idle: the steer goes in with the prompt, the follow-up after the answer.
  8. 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.
  9. A prompt during a run is still refused, and the RuntimeError now reads "already running; use steer() or follow_up()".
  10. 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.
  11. 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.
  12. 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_end carries 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.

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?

The message it queued, so the caller can hold on to it and check later whether it landed
That makes the return value the product of the call, as lesson 3's run_agent once did. Nothing in Tau looks a queued message up again by identity.
The index it will have in the transcript once it drains, so a UI can reserve a row for it
Nobody knows that index. Between now and the drain the model may add a reply and a batch of results, or the run may end and never drain it at all.
A snapshot of both queues, so a frontend can redraw its "2 queued" line from the return value without asking a second question
A small API decision with a frontend behind it, which is the whole reason the queues live in the harness and not in the UI.

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?

Yes. 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
Then the list would hold a message the model never read, and there would be a moment at which the transcript was that list. Watch the record in the cell: steer() is called inside the tool, and no user message joins the record until the next turn opens.
No. Nothing was written when you queued it. A queued message becomes history at the moment the loop takes it, and nothing took this one
There is nothing to delete, because there was never anything there.
No — but the model has already seen it. It was in the queue when the request went out, and a request is built from the queue and the list together
A request is built from the list and nothing else. 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_up and ._drain over two queues, and the nested-while run_agent that asks for them through get_steering and get_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 bash is 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.