checkpoint B
Transcript doctor II
Nothing new today. Five harnesses are each one decision away from yours, not one of them raises anything, and nothing on the page says which lesson each one comes from.
What this page is
The second checkpoint: a page with nothing new on it. Every idea it uses comes from lessons 3 to 10, the five harnesses below are shuffled, and nothing tells you which lesson each one belongs to. Two of them arrive the way this sort of problem usually arrives — as a sentence from somebody who cannot see your code at all.
One thing has changed since Checkpoint A, and it changes what you are looking for. Three of the six problems there ended in a 400: a list a provider would not read, and you could find the fault by asking which request was refused. Since lesson 10 every request is repaired before it goes out, so a broken pair no longer stops anything. Not one of the five runs below is refused. Every one of them finishes, and every one leaves somebody — a customer, an engineer on call, or the model itself — reading a sentence that is not true. What is wrong is in the answer, not in the shape of the list.
Then one lab. You write run_agent back from an empty gap — the evented form this time — against the tests of lessons 3 to 7, which you have already passed. If you opened a solution in one of those lessons, the map still reads built with help against it and will go on reading that way: it records what happened that afternoon. This page is where the same work comes round again with the scaffolding gone.
Five harnesses, and what is missing from each
Each one is a harness somebody wrote, or a report from somebody using one. In every case what differs from the code you have is one decision. Commit an answer first, then run it and read what that decision cost.
One. Your loop with the max_turns check moved: it now sits below the model call, once the reply has been appended and announced, rather than above it. It is counted so that the run still stops on the second model call, and it closes its turn and the run on the way out, so nothing is left hanging open. The job is a test run the model will not let go of, and the limit is two.
Two model calls either way, and neither run is refused by anything. What does moving that one line cost?
if sits only decides whether the last reply is kept or discarded
if decides is whether the tool calls in it are answered, and whether anybody is told the limit was reached — which is why lesson 5 spent a rung on where a check goes.repair_tool_history is for: it fills the hole before every request, and the session carries on
Both requests come out the same shape and only one of them is true. Their second pytest never reached the shell, and the
Where this comes from: Tau checks the limit at the top of a turn, before the provider is called, and the stop it writes closes the turn and then the run — the same in-band message and the same balanced pair as yours (src/tau_agent/loop.py:112-120).
import harness, lab
TESTS = "pytest"
SYSTEM = "You are a careful debugger."
PROMPT = "The tests are failing. Have a look."
class NotingShell:
"""lab.Shell, with a note of every command that really reached it."""
def __init__(self, ws):
self._shell, self.ran = lab.Shell(ws), []
def run(self, command):
self.ran.append(command)
return self._shell.run(command)
def their_loop(model, system, messages, tools, prompt, *, max_turns=None,
get_steering=None, get_follow_ups=None):
"""Yours from lesson 10, line for line, with the max_turns check moved: it sits below the
model call, once the reply has been appended and announced, instead of above it. Counted
with >= so the run still stops on the second model call, and it closes its turn and the run
on the way out. The error_message branch has nowhere left to go, so it is gone."""
new = []
def record(message):
messages.append(message)
new.append(message)
return {"type": "message_end", "message": message}
def ask(get):
return list(get()) if get else []
yield {"type": "agent_start"}
turn, calls, failed = 0, [], False
pending = [harness.user_message(prompt)] + ask(get_steering)
while pending:
while calls or pending:
turn += 1
yield {"type": "turn_start"}
for message in pending:
yield record(message)
pending = []
reply = model.complete(system, harness.context_for_model(messages),
harness.tool_specs(tools))
yield record(reply)
if max_turns is not None and turn >= max_turns: # the moved check
yield {"type": "turn_end", "message": reply}
yield {"type": "agent_end", "messages": new}
return
failed = reply["stop_reason"] == "error"
calls = [] if failed else harness.tool_calls(reply)
for call in calls:
yield {"type": "tool_execution_start", "call": call}
result = harness.run_tool(tools, call)
yield record(result)
yield {"type": "tool_execution_end", "call": call, "result": result}
yield {"type": "turn_end", "message": reply}
if not failed:
pending = ask(get_steering)
if not failed:
pending = ask(get_follow_ups)
yield {"type": "agent_end", "messages": new}
def model():
"""A model that answers every request by asking for the tests again."""
return lab.ScriptedModel([lab.forever(
lab.reply(lab.text("Running the tests."), lab.call("bash", {"command": TESTS})))])
async def run(label, loop, messages):
shell = NotingShell(lab.Workspace({"main.py": "def total(xs):\n return sum(xs) - 1\n"}))
calls = model()
await lab.drive(loop(calls, SYSTEM, messages, [harness.make_bash_tool(shell)],
PROMPT, max_turns=2))
view = harness.context_for_model(messages)
c2 = next(m for m in view if m.get("tool_call_id") == "c2")
print(f"{label}: {len(calls.calls)} model call(s); commands that reached the shell: {shell.ran}")
print(f" the record: {lab.shape(messages)}")
print(f" the next request carries: {lab.shape(view)}")
print(f" the last line of c2's result: {c2['content'].splitlines()[-1]!r}")
theirs, yours = [], []
await run("theirs", their_loop, theirs)
print()
await run("yours ", harness.run_agent, yours)
print()
for label, messages in (("theirs", theirs), ("yours ", yours)):
last = messages[-1]
print(f"{label}: the record's last message says: "
f"{last.get('error_message') or harness.text_of(last) or '(nothing)'}")
theirs: 2 model call(s); commands that reached the shell: ['pytest'] the record: U A[c1] R(c1) A[c2] the next request carries: U A[c1] R(c1) A[c2] R(c2) the last line of c2's result: 'Tool call interrupted: no result was recorded. It may not have run, or may have run partly; check before repeating it.' yours : 2 model call(s); commands that reached the shell: ['pytest', 'pytest'] the record: U A[c1] R(c1) A[c2] R(c2) A(error) the next request carries: U A[c1] R(c1) A[c2] R(c2) the last line of c2's result: 'Command exited with code 1' theirs: the record's last message says: Running the tests. yours : the record's last message says: Agent stopped after max_turns=2
Figure B.1 Lesson 5's three exits, the unknown tool that is not one, and the exit you must never build: a limit checked between a reply and its tools. That crossed-out panel is the loop above.
Two. A message from somebody running an agent built from these lessons as a service, one conversation per customer.
"Every few days a customer sees somebody else's files in their chat. It builds up through the day and it is gone after the nightly restart. We store nothing between conversations — there is no database in this thing at all."
Every conversation in their app starts from one opening: two messages that state the house style, built once when the process starts. Their Harness is yours, one word shorter.
Nothing is persisted, nothing is shared on purpose, and a restart clears it. What is happening?
run_agent appends to the list it is handed, so the loop is what outlives the run. It should copy that list, or make one of its own
One list(...). Customer B's brand-new conversation opened with six messages in it, four of them customer A's; it answered a question about a port out of a file it had never read, and gave A's port instead of its own. Nothing was stored anywhere: the opening is a list in memory, and every run of every conversation has been appending to it since the process started. A restart clears it because a restart builds a new list, which is also why it is so hard to reproduce on a laptop.
Where this comes from: the first thing Tau's harness does with the messages it is handed is copy them into a list of its own, in the constructor, before it sets up anything else (src/tau_agent/harness.py:65-77). That is the line a session restored from disk arrives on, and it is one word.
import re
import harness, lab
SYSTEM = "You are a careful debugger."
QUESTION = "What port do we listen on?"
# The opening every conversation in their app starts from, built once when the process starts.
OPENING = [lab.user("House style: spaces, not tabs. Never edit anything under vendor/."),
lab.say("Understood.")]
class TheirHarness(harness.Harness):
"""Yours, with one line changed: __init__ keeps the sequence it was handed instead of
copying it into a list of its own."""
def __init__(self, model, system, tools, messages=(), max_turns=None):
super().__init__(model, system, tools, messages, max_turns)
self._messages = messages # the changed line: no list(...)
def agent(request):
"""Answers out of the request if a port is anywhere in it; otherwise asks for config.py."""
found = re.search(r"PORT = (\d+)", request.text)
return lab.say(f"The port is {found.group(1)}.") if found else \
lab.reply(lab.call("read", {"path": "config.py"}))
async def conversation(kind, port):
"""One customer's conversation, on its own workspace, over the shared opening."""
ws = lab.Workspace({"config.py": f"PORT = {port}\n"})
model = lab.ScriptedModel([lab.forever(agent)])
h = kind(model, SYSTEM, [harness.make_read_tool(ws)], OPENING)
starts_with = lab.shape(h.messages)
await lab.drive(h.prompt(QUESTION))
return h, model, ws, starts_with
for kind, name in ((TheirHarness, "theirs"), (harness.Harness, "yours ")):
OPENING[:] = OPENING[:2] # back to the two messages the app starts from
a, _, _, _ = await conversation(kind, 9090)
after_a = lab.shape(a.messages)
b, model, ws, starts_with = await conversation(kind, 5432)
print(f"{name}: after customer A's job, A's record is: {after_a}")
print(f" customer B opens a new conversation. It starts with: {starts_with}")
print(f" B's first request carried {len(model.calls[0].messages)} message(s), "
f"and B's own workspace was read: {ws.reads}")
print(f" B was told: {harness.text_of(b.messages[-1])}")
print(f" A's record now reads: {lab.shape(a.messages)}")
print(f" the app's opening list now holds {len(OPENING)} message(s)")
print()
theirs: after customer A's job, A's record is: U A U A[c1] R(c1) A
customer B opens a new conversation. It starts with: U A U A[c1] R(c1) A
B's first request carried 7 message(s), and B's own workspace was read: []
B was told: The port is 9090.
A's record now reads: U A U A[c1] R(c1) A U A
the app's opening list now holds 8 message(s)
yours : after customer A's job, A's record is: U A U A[c1] R(c1) A
customer B opens a new conversation. It starts with: U A
B's first request carried 3 message(s), and B's own workspace was read: ['config.py']
B was told: The port is 5432.
A's record now reads: U A U A[c1] R(c1) A
the app's opening list now holds 2 message(s)
Three. A harness with one queue. steer and follow_up are both there, they take the same kind of message, and their author noticed that they were two names for one line — so there is one deque behind both, and the loop takes whatever is waiting at the first gap it reaches.
The job is five stub modules, one write each. While a.py is being written the user types When you are done, run the tests. and queues it as a
Their agent runs pytest exactly once, and so does yours. On their run, how many of the five modules exist on disk at the moment it runs?
One: a.py, the file whose write was still returning when the sentence was typed. The two queues are not two priorities, they are two conditions: at the next gap and when the run would otherwise end. One queue can only answer the first, so the words "when you are done" are read as "now", and nothing anywhere tests whether it is done. Their agent ran the tests against one module, read Command exited with code 1, wrote the other four and finished by reporting a verdict from before four of the five modules existed. Yours ran them at the end, against five.
Where this comes from: Tau keeps two deques with a drain each (src/tau_agent/harness.py:219-223), and its loop asks them in two different places — steering right after a turn's tool batch, follow-ups only where the outer loop would break (src/tau_agent/loop.py:172-180).
Both harnesses on the five-module job, with the sentence typed at the same instant in each: how many model calls it took, which files existed when pytest ran, and what the agent said at the end. Both records are valid; that is not what went wrong.
import harness, lab
SYSTEM = "You are a careful assistant."
PROMPT = "Write the five stub modules we agreed on."
WHEN_DONE = "When you are done, run the tests."
FILES = ["a.py", "b.py", "c.py", "d.py", "e.py"]
BODY = "def run():\n return None\n"
class TheirHarness(harness.Harness):
"""Yours, with one queue instead of two: follow_up() puts its message where steer() puts
one, so whatever is waiting is taken at the first gap after a turn's tool batch."""
def follow_up(self, text):
self.steer(text)
def write_tool(ws, box):
"""The write tool, and the moment the user types: while a.py is being written they queue
"When you are done, run the tests." on the harness in front of them."""
tool = dict(harness.make_write_tool(ws))
inner = tool["execute"]
def execute(arguments):
written = inner(arguments)
if arguments["path"] == FILES[0]:
box["h"].follow_up(WHEN_DONE)
return written
return {**tool, "execute": execute}
def bash_tool(ws, seen):
"""The bash tool, with a note of which files existed each time a command reached the shell.
The canned pytest passes once all five modules are there."""
shell = lab.Shell(ws, tests_pass_when=lambda w: all(w.exists(f) for f in FILES))
tool = dict(harness.make_bash_tool(shell))
inner = tool["execute"]
def execute(arguments):
seen.append(sorted(ws.listdir(".")))
return inner(arguments)
return {**tool, "execute": execute}
def agent():
"""Writes the five modules one per turn; runs pytest the first time it is asked to; and
repeats the last line of what bash gave back, once it has seen one."""
state = {"written": 0, "verdict": None}
def step(request):
result = request.last_result
if result is not None and result["tool_name"] == "bash":
state["verdict"] = result["content"].splitlines()[-1]
if WHEN_DONE[:20] in request.user_text and state["verdict"] is None:
return lab.reply(lab.text("Running the tests."),
lab.call("bash", {"command": "pytest"}))
if state["written"] < len(FILES):
state["written"] += 1
return lab.reply(lab.call("write", {"path": FILES[state["written"] - 1],
"content": BODY}))
if state["verdict"] is None:
return lab.say("All five modules are written.")
return lab.say(f"All five modules are written, and the tests reported: {state['verdict']}")
return lab.forever(step)
async def run(name, kind):
ws, seen, box = lab.Workspace({}), [], {}
model = lab.ScriptedModel([agent()])
box["h"] = h = kind(model, SYSTEM, [write_tool(ws, box), bash_tool(ws, seen)])
await lab.drive(h.prompt(PROMPT))
print(f"{name}: {len(model.calls)} model call(s), {len(h.messages)} messages, "
f"record valid: {lab.validate(list(h.messages)) == []}")
print(f" pytest ran {len(seen)} time(s); the files that existed then: {seen[0]}")
print(f" the agent finished by saying: {harness.text_of(h.messages[-1])}")
await run("theirs", TheirHarness)
print()
await run("yours ", harness.Harness)
theirs: 7 model call(s), 15 messages, record valid: True
pytest ran 1 time(s); the files that existed then: ['a.py']
the agent finished by saying: All five modules are written, and the tests reported: Command exited with code 1
yours : 8 model call(s), 16 messages, record valid: True
pytest ran 1 time(s); the files that existed then: ['a.py', 'b.py', 'c.py', 'd.py', 'e.py']
the agent finished by saying: All five modules are written, and the tests reported: ===== 2992 passed =====
Four. A bash tool, otherwise yours: the same description, the same budget, the same truncation notice. Its last branch is the one that differs — when the command comes back with a non-zero exit code it raises, so the one boundary that turns a failure into a result marks that result is_error
Their agent gives up after two model calls, with the failing assertion sitting in the very result it was handed. Yours fixes the bug in four. Which idea is missing?
is_error says the tool could not do its job; this tool did its job perfectly, and the news it brought back is the thing the model was asked to act on
is_error is for: their model is being told the truth and deciding to stop
is_error is a claim about the second thing, not the first.Their result is yours with one line off the end, character for character, assert 5 == 6 and all; what differs is the flag, and the flag is part of what the model reads. Theirs stopped to report a broken tool and left cart.py exactly as it found it. Yours came back as an ordinary result ending Command exited with code 1, and the model read the assertion, wrote the fix and ran the suite again. Lesson 4 drew that line once, and it holds on every path through run_tool.
Where this comes from: Tau's bash tool appends a status line saying which code the command exited with and then returns an ordinary result, with no error flag on it (src/tau_coding/tools.py:683-689).
import harness, lab
SYSTEM = "You are a careful debugger."
PROMPT = "The test suite is red. Find out why and fix it."
BROKEN = "def total(xs):\n return sum(xs) - 1\n"
FIXED = "def total(xs):\n return sum(xs)\n"
def their_bash_tool(shell, max_lines=harness.MAX_LINES, max_bytes=harness.MAX_BYTES):
"""Yours, keeping its name, description, parameters and budget: only the last branch
differs. A command that exits non-zero raises, instead of coming back as an ordinary
result with a status line on the end."""
tool = dict(harness.make_bash_tool(shell, max_lines, max_bytes))
def execute(arguments):
output, code = shell.run(harness.str_arg(arguments, "command"))
total = len(output.splitlines())
output, shown = harness.truncate_tail(output, max_lines, max_bytes)
if shown < total:
output += f"\n[Showing the last {shown} of {total} lines. Narrow the command for more.]"
if code != 0:
raise RuntimeError(output) # the changed branch
return output
return {**tool, "execute": execute}
def agent(request):
"""Runs the tests. If the result is flagged as a tool error it stops and says the tool is
broken. Otherwise it reads the failing assertion, writes the fix, and runs the tests again."""
result = request.last_result
if result is None:
return lab.reply(lab.text("Let me run the tests."),
lab.call("bash", {"command": "pytest"}))
if result["is_error"]:
return lab.say("I cannot run the tests: the bash tool reports that it could not do its "
"job. Somebody should look at the tool.")
if "assert 5 == 6" in result["content"]:
return lab.reply(lab.text("total() is off by one."),
lab.call("write", {"path": "cart.py", "content": FIXED}))
if "2992 passed" in result["content"]:
return lab.say("Fixed: total() dropped one item. The suite is green.")
return lab.reply(lab.call("bash", {"command": "pytest"}))
async def run(name, make_bash):
ws = lab.Workspace({"cart.py": BROKEN})
shell = lab.Shell(ws, tests_pass_when=lambda w: "- 1" not in w.read_text("cart.py"))
model, messages = lab.ScriptedModel([lab.forever(agent)]), []
await lab.drive(harness.run_agent(model, SYSTEM, messages,
[make_bash(shell), harness.make_write_tool(ws)], PROMPT))
first = messages[2] # the result of the first pytest call
print(f"{name}: {len(model.calls)} model call(s), record {lab.shape(messages)}")
print(f" the first pytest result: is_error={first['is_error']}, "
f"and it holds the failing assertion: {'assert 5 == 6' in first['content']}")
print(f" its last line: {first['content'].splitlines()[-1]!r}")
print(f" cart.py on disk now: {ws.read_text('cart.py').splitlines()[-1].strip()!r}")
print(f" the model's last word: {harness.text_of(messages[-1])}")
return first["content"]
theirs = await run("theirs", their_bash_tool)
print()
yours = await run("yours ", harness.make_bash_tool)
print()
without_the_status_line = yours.rsplit("\n", 1)[0]
print(f"theirs is yours without its last line, character for character: "
f"{theirs == without_the_status_line}")
theirs: 2 model call(s), record U A[c1] R(c1) A
the first pytest result: is_error=True, and it holds the failing assertion: True
its last line: '[Showing the last 1283 of 3000 lines. Narrow the command for more.]'
cart.py on disk now: 'return sum(xs) - 1'
the model's last word: I cannot run the tests: the bash tool reports that it could not do its job. Somebody should look at the tool.
yours : 4 model call(s), record U A[c1] R(c1) A[c2] R(c2) A[c3] R(c3) A
the first pytest result: is_error=False, and it holds the failing assertion: True
its last line: 'Command exited with code 1'
cart.py on disk now: 'return sum(xs)'
the model's last word: Fixed: total() dropped one item. The suite is green.
theirs is yours without its last line, character for character: True
Five. The last one arrives as a page, at half past two in the morning.
"The agent told the engineer on call that the migration had been applied. It had not. There is no error anywhere: the run finished, the session is valid, and the transcript reads perfectly."
Their repair_tool_history is yours. Two values differ in the result it makes up for a call that has none: the text, and the flag.
The tab had been closed an hour earlier, while bash was starting, so nothing ran. Their repair filled the hole with The tool ran., not marked as an error. What went wrong, and where?
Same record, same hole, and the same request built out of it — U A[c1] R(c1) U A both times. Theirs told the engineer the migration had been applied; yours said it could not confirm it, and to check the database before running it again. Nothing downstream was going to catch the difference: [general] a provider checks the shape of the list it is sent, not the truth of what is in it. A comfortable sentence in a transcript is not a small thing: it is the only thing the model has.
Where this comes from: the result Tau makes up carries is_error=True (src/tau_agent/tool_history.py:104-109), and its text is Tool call interrupted by user (src/tau_agent/tool_history.py:16) — which lesson 10 declined to copy, because the same function is reached when no user did anything at all.
import harness, lab
SYSTEM = "You are a careful release engineer."
PROMPT = "Apply the pending database migration."
LATER = "Did the migration go through?"
COMFORTABLE = "The tool ran."
REFERENCE_REPAIR = harness.repair_tool_history
def their_repair(messages):
"""Yours, with two values changed in the result it makes up for a call that has none: the
text says the tool ran, and it is not flagged as an error."""
return [dict(m, content=COMFORTABLE, is_error=False)
if m.get("content") == harness.INTERRUPTED else m
for m in REFERENCE_REPAIR(messages)]
class MigrationShell:
"""Stands in for a shell: it notes every command it is given, and answers the migration."""
def __init__(self):
self.ran = []
def run(self, command):
self.ran.append(command)
return "applying 3 migrations... done\n", 0
def agent(request):
"""Answers out of the newest tool result: a result that is flagged as an error, or that says
no result was recorded, means it cannot confirm anything; anything else means it went through."""
result = request.last_result
if result is None:
return lab.reply(lab.text("Applying the migration."),
lab.call("bash", {"command": "./migrate.sh"}))
if result["is_error"] or "no result was recorded" in result["content"]:
return lab.say("I cannot confirm it: that call was interrupted and no result was "
"recorded. Check the database before running it again.")
return lab.say("Yes. The migration has been applied.")
async def run(name, repair):
harness.repair_tool_history = repair # the only difference between the two runs
shell = MigrationShell()
model = lab.ScriptedModel([lab.forever(agent)])
h = harness.Harness(model, SYSTEM, [harness.make_bash_tool(shell)])
# The tab is closed while bash is starting: the consumer takes five events and no more.
events = await lab.drive(h.prompt(PROMPT), stop_after=5)
await lab.drive(h.prompt(LATER))
view = harness.context_for_model(list(h.messages))
made_up = next(m for m in view if m.get("tool_call_id") == "c1")
print(f"{name}: the screen's last event was {events[-1]['type']}; "
f"commands that reached the shell: {shell.ran}")
print(f" the record: {lab.shape(h.messages)}")
print(f" the next request carried: {lab.shape(view)}")
print(f" c1's made-up result: {made_up['content']!r} "
f"(is_error={made_up['is_error']})")
print(f" and the user was told: {harness.text_of(h.messages[-1])}")
await run("theirs", their_repair)
print()
await run("yours ", REFERENCE_REPAIR)
theirs: the screen's last event was tool_execution_start; commands that reached the shell: []
the record: U A[c1] U A
the next request carried: U A[c1] R(c1) U A
c1's made-up result: 'The tool ran.' (is_error=False)
and the user was told: Yes. The migration has been applied.
yours : the screen's last event was tool_execution_start; commands that reached the shell: []
the record: U A[c1] U A
the next request carried: U A[c1] R(c1) U A
c1's made-up result: 'Tool call interrupted: no result was recorded. It may not have run, or may have run partly; check before repeating it.' (is_error=True)
and the user was told: I cannot confirm it: that call was interrupted and no result was recorded. Check the database before running it again.
What three of them had in common
Three of the five ended with the model reading a message that nothing in the program supported: a result saying a tool may have run partly, when the loop had chosen not to call it; a result flagged as a tool failure, when the tool had worked; and a result saying a tool ran, when nothing had. Name the rule all three broke, in a sentence. Then say which one of your functions is the place that rule has to be kept, and why it cannot be kept anywhere else.
Every message on the record is a claim about what happened. For each of those three, ask who wrote it — and what that writer had actually seen.
The rule: a message your loop writes is a claim, and the only claims it may write are the ones the program observed. is_error claims the tool could not do its job. A tool result claims this is what that call produced. An in-band stop claims the run ended for this reason.
The place is run_tool: repair_tool_history is looking at a hole and guessing. Which is exactly why its one sentence is written as carefully as it is: it is the only message in your harness written by something that did not see the event it describes, and the first harness on this page got a second one for free by dropping a call on the floor.
Common answers, and what each one misses
- "Never put a made-up message in the transcript." Then lesson 10 had nothing to build: the
repair 's result is a made-up message, and it is the right one. The test is not whether a message was invented. It is whether what it says is something the program can see. - "The loop should raise rather than write anything it is not sure of." None of the three raised, and that was not the bug. Raising moves the same ignorance onto whoever is on the stack and takes the session with it, which lesson 4 settled for tools and lesson 5 for the loop. What was missing was not an exception; it was a true sentence.
- "Use
is_errorwhenever you are not sure." Two of the three did something like that, in opposite directions.is_erroris a specific claim, not a shrug: a command that exits 1 did its job, and a tool nobody ever called has no job to have failed at.
Build: the loop, from a blank page
Your harness.py, with one name cut out of it. No spec paragraph, no step comments, no example test: the gap holds the name, its argument list and one sentence saying that nothing in it is new. That is the difference between reading code you understand and being able to produce it, and it is the only way to find out which of the two you have.
The file in the editor is the one you left at the end of lesson 7, not lesson 10 — no Harness, no queues, no repair — because that is the loop the tests below describe. Figure B.2 is what they check, one run at a time.
Figure B.2 One tool-using run, event by event, beside the list it is describing. Every message on that list has a message_end, and every one of them is already on the list when its event goes out.
Gone from the file: run_agent, from region 3. Everything else is where you left it — the message helpers, the tools, the budgets, run_tool, and context_for_model directly below the gap. JsonRenderer and FinalTextRenderer are both still at the bottom of the file, untouched: the rebuild is the loop. Thirty-nine lines in the reference, docstring included, all of it one function.
Thirty-one hidden tests. Thirty of them are copied out of lessons 3 to 7 word for word — nine, two, eight, two and nine — so that what you rebuild is measured by the ruler that measured what you built. The thirty-first is this checkpoint's own: at an empty gap all the others can report is a missing name, so one test names it and says what it is for.
Lesson 3's nine never mention an event: the three-file job where each file is named inside the last, the request that is the record so far, the caller's list that holds everything that happened, the reply whose label lies in both directions, two calls in one reply, the twelve-turn job, a second prompt on the list the first run left, and the plain chat with no tools at all. Then lesson 4's typo the model recovers from, and its three calls of which the second fails. Then four of lesson 5's eight: max_turns against a model that will not stop, the prompt that is accepted after the limit has fired, the 503 that is written down instead of raised, and the view that drops an empty failed reply and keeps a half-finished one. Then lesson 6's two bills. And nine from lesson 7 that read the events: the exact sequences of a text-only run, a one-tool run, two calls in one reply, a max_turns stop and a provider failure; the run closed after every possible number of events, with the record and the tools checked at each one; what the second agent_end carries when two runs share a list; every event through JsonRenderer; and a listen for anything the loop prints.
The lessons 3 to 6 tests, twenty-one of the thirty-one, are the interesting part. They were written against a loop that returned a message, and they have to pass against one that yields events, unchanged, because nothing they are about has changed.
Nothing on this page has told you how to write it, and that is deliberate. Lessons 3 to 7 are still there if you get stuck, and reading them costs you nothing except the answer to the question this lab is asking.
- Nothing here is new, so start with what the loop owes rather than how it is spelt. Three questions. What are the ways a run can end, and what does every one of them owe a consumer on the way out? When a message joins the record, what else has to happen, and which of the two goes first? And when the last event goes out, where is a caller supposed to find what this run added, given that the list you were handed may already have had messages in it?
- One
while True, and the turn inside it is lesson 5's.turn_startis the first thing in each pass; on the first pass only, the prompt is appended and announced; then themax_turnscheck and the model call exactly as lesson 5 has them; then the reply, appended and announced; then, if the reply did not come back failed, each of its calls in order — the start event, the tool, the result appended and announced, the end event; thenturn_endcarrying the reply; and the one way out of the loop is a reply that asked for nothing. After the loop,agent_end. Two things bite. A nested helper cannotyieldon behalf of the function that called it, so if you want one place that appends a message and makes its announcement, have that helper return the event and writeyield helper(...). Andagent_endcarries this run's messages alone, which the caller's list cannot hand you. - In outline.
run_agent(model, system, messages, tools, prompt, *, max_turns=None): new = [] # this run's messages, for agent_end record(message): # a plain nested function, not a generator append message to messages and to new return {"type": "message_end", "message": message} yield {"type": "agent_start"} turn = 0 forever: turn += 1 yield {"type": "turn_start"} on turn 1 only: yield record(user_message(prompt)) if there is a limit and turn is past it: reply = error_message(f"Agent stopped after max_turns={max_turns}") else: reply = model.complete(system, context_for_model(messages), tool_specs(tools)) yield record(reply) calls = [] if the reply failed else tool_calls(reply) for each call: yield {"type": "tool_execution_start", "call": call} result = run_tool(tools, call) yield record(result) # nothing at all between these two lines yield {"type": "tool_execution_end", "call": call, "result": result} yield {"type": "turn_end", "message": reply} if not calls: leave the loop yield {"type": "agent_end", "messages": new}
Thirty-one tests, written for five different lessons, passed by thirty-nine lines you produced from an empty gap. Twenty-one of them were written before events existed and never mention one, and they passed because lesson 7 changed what the loop says and not what it does. You have now written this loop twice from nothing: once at Checkpoint A, where it returned a message, and once here, where it reports for itself. If lesson 7 is marked built with help on the map, the mark stays — it records what happened that afternoon — and it is now out of date.
- run_agent is in the file. Until it is, every test below can only say so.
- A job that needs three files takes four model calls, and nobody cranks by hand.
- Every request is the whole list as it stood: two messages longer than the one before.
- Everything that happened is in the list the caller passed in, and in no other.
- A reply that holds a call gets its result and another turn, even when labelled "stop".
- After a run, the same list takes another prompt and the strict model accepts it.
- A reply with no calls ends the run, even when it is labelled "toolUse" or "length".
- One reply asks for two files: both results follow it, in call order, before the next turn.
- The loop has no turn count of its own: twelve requests in a row get twelve results.
- With no tools and a plain answer, the run is one model call: what chat used to do.
- The model mistypes a tool name, reads the error, retries with the right name: three model calls.
- Three calls in one reply, the second fails: three results, in call order, only the second an error.
- max_turns=1 against a stuck model: one model call, its tool call answered, then an in-band stop.
- max_turns=3 allows exactly three model calls, in every run, however long the list already is.
- max_turns=None means no limit: twelve requests in a row are all served, and a limit of 20 changes nothing.
- The view is worked out again for every model call: the model sees the result of the tool it just asked for.
- The provider answers 503: the failed reply goes on the record and the run ends without raising.
- After a 503 the user says "try again": the failed message stays on the record and is not sent.
- A reply that failed after producing text is sent back to the model like any other message.
- A run stopped by max_turns leaves a list the next prompt is accepted on, and the model recovers.
- A model that follows the notices finds `def target` on line 2,400 of big.log for under 45,000 tokens.
- Read big.log once, work four more turns: the whole run costs under 80,000 input tokens.
- A run with no tools yields agent_start, turn_start, a message_end for the prompt, one for the answer, turn_end, agent_end.
- One tool call: its result's message_end comes after tool_execution_start and before tool_execution_end, inside the first turn.
- Two calls in one reply: start, result, end for the first call, then start, result, end for the second, in one turn.
- max_turns=2 against a stuck model: the stop is a third turn with its own turn_start, message_end and turn_end, then agent_end.
- The provider answers 500: the failed reply gets its message_end, then turn_end and agent_end still come.
- Stop the run after any number of events: the message just announced is already the last of the caller's list, and every tool that ran has its result there.
- A second run on the same list: its agent_end holds the messages that run appended, and no earlier ones.
- Every event of a tool run and of a failed run can be written as a line of JSON by the given JsonRenderer.
- run_agent itself prints nothing, on a tool run, a max_turns stop or a provider failure: what a run looks like is a frontend's business.
Four runs, then one two-tool run fifteen times over, then the same loop through two frontends. No tests: read the four blocks. Every exit and what it left; the record at each of the fifteen points a screen could have walked away, beside the tools that had really run by then; what agent > answer.txt holds when the run went well and when it did not; and the shortest run of all as JSON. Once the lab has passed, this runs your code.
import harness, lab
SYSTEM = "You are a careful debugger."
FILES = {"config.py": "PORT = 9090\n", "backup.py": "PORT = 8080\n"}
def reader():
"""The read tool over two small files, and the workspace behind it, whose .reads logs every
read the tool really attempted."""
ws = lab.Workspace(FILES)
return [harness.make_read_tool(ws)], ws
def read(path):
return lab.call("read", {"path": path})
runs = [
("no tools at all", lab.ScriptedModel([lab.say("Nothing to look up.")]), [], {}),
("one tool, one answer", lab.ScriptedModel([lab.reply(lab.text("Let me look."),
read("config.py")),
lab.say("The port is 9090.")]), reader()[0], {}),
("a model that will not stop", lab.ScriptedModel([lab.forever(lab.reply(read("config.py")))]),
reader()[0], {"max_turns": 2}),
("a provider that falls over", lab.ScriptedModel([lab.fail("503 overloaded")]), reader()[0], {}),
]
print("Every exit is balanced")
for name, model, tool_list, options in runs:
messages = []
events = await lab.drive(harness.run_agent(model, SYSTEM, messages, tool_list,
"Find the port.", **options))
kinds = [event["type"] for event in events]
print(f" {name:28} {len(events):2} events, {kinds.count('turn_start')} turn_start and "
f"{kinds.count('turn_end')} turn_end, {kinds[0]} ... {kinds[-1]}")
print(f" {'':28} record {lab.shape(messages)}, agent_end carries "
f"{len(events[-1]['messages'])} of them")
print()
print("The record is already true, whenever the screen walks away")
script = [lab.reply(read("config.py"), read("backup.py")), lab.say("They differ.")]
whole = await lab.drive(harness.run_agent(lab.ScriptedModel(script), SYSTEM, [], reader()[0],
"Compare the two ports."))
for k in range(1, len(whole) + 1):
messages, (tool_list, ws) = [], reader()
events = await lab.drive(harness.run_agent(lab.ScriptedModel(script), SYSTEM, messages,
tool_list, "Compare the two ports."), stop_after=k)
recorded = [m for m in messages if m["role"] == "toolResult"]
print(f" after {k:2} event(s), {events[-1]['type']:21} "
f"record {lab.shape(messages) or '(nothing yet)':29} "
f"{len(ws.reads)} tool(s) ran, {len(recorded)} result(s) recorded")
print()
print("What `agent > answer.txt` gets, through FinalTextRenderer")
for name, model in (("an answer", lab.ScriptedModel([lab.reply(lab.text("Let me look."),
read("config.py")),
lab.say("The port is 9090.")])),
("a failure", lab.ScriptedModel([lab.fail("503 overloaded")]))):
renderer = harness.FinalTextRenderer()
for event in await lab.drive(harness.run_agent(model, SYSTEM, [], reader()[0],
"Find the port.")):
renderer.render(event)
print(f" {name}, and the file holds:")
ok = renderer.finish()
print(f" finish() said {ok}, so the shell script exits {0 if ok else 1}")
print()
print("The shortest run of all, through JsonRenderer")
renderer = harness.JsonRenderer()
for event in await lab.drive(harness.run_agent(lab.ScriptedModel([lab.say("Nothing to look up.")]),
SYSTEM, [], [], "Find the port.")):
renderer.render(event)
print(" finish() said", renderer.finish())
Every exit is balanced
no tools at all 6 events, 1 turn_start and 1 turn_end, agent_start ... agent_end
record U A, agent_end carries 2 of them
one tool, one answer 12 events, 2 turn_start and 2 turn_end, agent_start ... agent_end
record U A[c1] R(c1) A, agent_end carries 4 of them
a model that will not stop 18 events, 3 turn_start and 3 turn_end, agent_start ... agent_end
record U A[c1] R(c1) A[c2] R(c2) A(error), agent_end carries 6 of them
a provider that falls over 6 events, 1 turn_start and 1 turn_end, agent_start ... agent_end
record U A(error), agent_end carries 2 of them
The record is already true, whenever the screen walks away
after 1 event(s), agent_start record (nothing yet) 0 tool(s) ran, 0 result(s) recorded
after 2 event(s), turn_start record (nothing yet) 0 tool(s) ran, 0 result(s) recorded
after 3 event(s), message_end record U 0 tool(s) ran, 0 result(s) recorded
after 4 event(s), message_end record U A[c1,c2] 0 tool(s) ran, 0 result(s) recorded
after 5 event(s), tool_execution_start record U A[c1,c2] 0 tool(s) ran, 0 result(s) recorded
after 6 event(s), message_end record U A[c1,c2] R(c1) 1 tool(s) ran, 1 result(s) recorded
after 7 event(s), tool_execution_end record U A[c1,c2] R(c1) 1 tool(s) ran, 1 result(s) recorded
after 8 event(s), tool_execution_start record U A[c1,c2] R(c1) 1 tool(s) ran, 1 result(s) recorded
after 9 event(s), message_end record U A[c1,c2] R(c1) R(c2) 2 tool(s) ran, 2 result(s) recorded
after 10 event(s), tool_execution_end record U A[c1,c2] R(c1) R(c2) 2 tool(s) ran, 2 result(s) recorded
after 11 event(s), turn_end record U A[c1,c2] R(c1) R(c2) 2 tool(s) ran, 2 result(s) recorded
after 12 event(s), turn_start record U A[c1,c2] R(c1) R(c2) 2 tool(s) ran, 2 result(s) recorded
after 13 event(s), message_end record U A[c1,c2] R(c1) R(c2) A 2 tool(s) ran, 2 result(s) recorded
after 14 event(s), turn_end record U A[c1,c2] R(c1) R(c2) A 2 tool(s) ran, 2 result(s) recorded
after 15 event(s), agent_end record U A[c1,c2] R(c1) R(c2) A 2 tool(s) ran, 2 result(s) recorded
What `agent > answer.txt` gets, through FinalTextRenderer
an answer, and the file holds:
The port is 9090.
finish() said True, so the shell script exits 0
a failure, and the file holds:
Error: 503 overloaded
finish() said False, so the shell script exits 1
The shortest run of all, through JsonRenderer
{"type": "agent_start"}
{"type": "turn_start"}
{"type": "message_end", "message": {"role": "user", "content": "Find the port."}}
{"type": "message_end", "message": {"role": "assistant", "content": [{"type": "text", "text": "Nothing to look up."}], "stop_reason": "stop", "usage": {"input": 25, "cache_read": 0, "output": 13}}}
{"type": "turn_end", "message": {"role": "assistant", "content": [{"type": "text", "text": "Nothing to look up."}], "stop_reason": "stop", "usage": {"input": 25, "cache_read": 0, "output": 13}}}
{"type": "agent_end", "messages": [{"role": "user", "content": "Find the port."}, {"role": "assistant", "content": [{"type": "text", "text": "Nothing to look up."}], "stop_reason": "stop", "usage": {"input": 25, "cache_read": 0, "output": 13}}]}
finish() said True
Four ways out, four balanced pairs: every run opens on agent_start, closes on agent_end, and the turns it opened are the turns it closed — the limit and the failed provider included, which are the two that are easiest to leave a spinner running on. The middle block is the one worth reading twice. At every one of the fifteen points, the tools that had run and the results on the record are the same number, and the moment a result is announced it is already there. No line of your loop is watching for that; it is what
Say it in your own words
You have now written run_agent twice from an empty gap: at Checkpoint A, where it returned the final message, and here, where it yields events. Nearly every test you passed there is in this suite too, word for word, and it passed again without knowing that anything had changed. In a sentence or two: what does that tell you about what lesson 7 changed, and about what it did not?
Pick one of those tests — the twelve-turn job, say — and ask what it looks at when it has finished running. Then ask which of the two loops that thing came from.
Those tests are about the record: the list the caller holds when the run is over, the requests that went out on the way, and how many model calls it took. None of that moved. Lesson 7 added a second product, the
Common answers, and what each one misses
- "Nothing really changed; it is the same loop with
yieldinstead ofreturn." Three things did. The return value is gone, soagent_endhas to carry it — which is why exactly one test from Checkpoint A could not come with you: the one that askedrun_agentfor the final assistant message. Theconsumer now sets the pace and can walk away mid-run, which lesson 10 did on purpose. And the order of append and announce became something a test can catch you out on. - "They kept passing because
lab.driveexhausts the generator for them." True, and it is the mechanism rather than the reason. A harness that had changed what it appends, or when, would still have failed every one of them with the driver working perfectly. - "The old tests were too weak to notice." They pin lessons 3 to 6 down hard — shapes, request contents, bills, ids, order. What they never mention is an event, and that is exactly what makes them the right ruler for a rewrite: a test that had to be edited to keep passing would have proved nothing at all.
Before you go on
Five diagnoses and one lab, and not a single new idea: everything on this page was in your hands when you arrived. If one of the five took longer than you liked, its reveal names what was missing, and the lesson it came from has not gone anywhere.
One thing to notice about all five. Not one of them raised, not one was refused, and every one of them finished. Four of them handed somebody a confident answer that was wrong; the first handed nobody anything at all, having paid for a reply and thrown away what it asked for. A harness that cannot be bricked is not the same thing as a harness that can be believed, and from here the course is mostly about the second one.
Your progress export is at the foot of every page: a plain JSON file, living only in this browser, and the only copy. Lesson 11 is about what happens to everything else in this process when somebody trips over the power cable.
- You diagnosed
- a limit checked one line too late, an opening two customers shared, one queue doing the work of two, a failing command dressed up as a broken tool, and a made-up result that claimed a migration had run
- You rebuilt
run_agentin its evented form, from an empty gap, against the tests of lessons 3, 4, 5, 6 and 7- The rule you named
- every message the loop writes is a claim, and the only claims it may write are the ones the program observed
- Your harness now
- exactly what it was at the end of lesson 10; a checkpoint adds nothing to it.
- SYSTEM
- user_message
- text_of
- tool_calls
- error_message
- str_arg
- int_arg
- tool_specs
- run_tool
- truncate_head
- truncate_tail
- make_read_tool
- make_write_tool
- make_bash_tool
- run_agent
- context_for_model
- INTERRUPTED
- repair_tool_history
- Harness
- steer
- follow_up
- FinalTextRenderer
- JsonRenderer
- Your answers
- Still open
- Every record on this page lived in one Python list, in one process, for as long as that process did. The agent writes two files and somebody trips over the power cable. Lesson 11.