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.

~50 min · 1 lab · builds on 10 The poisoned transcript

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?

The run ends on a call nobody answered and on a reply that says "Running the tests." Nothing on the record says why it stopped, and the request built from it tells the model a tool may have run partly when the loop simply threw the call away
Three things, and the same line bought all three. Read the two requests side by side: they are the same shape.
Nothing worth a name. Both runs make two model calls and stop at the limit; where the if sits only decides whether the last reply is kept or discarded
The last reply is kept in both. What the 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.
Nothing, since lesson 10. A call with no result is exactly what repair_tool_history is for: it fills the hole before every request, and the session carries on
It does fill the hole, and it fills it with a sentence written for an accident. Look at what that sentence says, and then at what actually happened to that call.

Both requests come out the same shape and only one of them is true. Their second pytest never reached the shell, and the result the model is handed for it says it may have run partly: the repair cannot tell a call the loop dropped from a call a crash interrupted. Yours checks the limit before it spends anything, writes down why it stopped, and leaves every call it made holding the result it really got. A limit that fires between a reply and its tools still pays for that reply and then drops what it asked for.

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
Every exit is balanced: the ways out of run_agent and the last message each one leaves The crank of run_agent with every way out. 1, no tool calls: the reply is returned and the list ends with an assistant message. 2, max_turns reached at the top of a turn: an assistant error message, Agent stopped after max_turns=1, is appended. 3, the reply is an error: it is appended and the run stops before any tool. From lesson 15, 4, cancelled at the top of a turn: an assistant aborted message, with no model call. An unknown tool is not an exit: it gives an error result and the loop goes again. If max_turns were checked before the tools run, toolCall c1 would be left with no result. Every real exit leaves a list that can be sent again. messagesends with one of no tool calls: return reply1 assistantIt sets DEBUG to True. max_turns reached: stop2 assistant · errorAgent stopped after max_turns=1 the reply is an error: stop3 assistant · error503 overloaded cancelled: stop, no model call4 assistant · abortedOperation aborted never: max_turns before the tools toolResult · c1no result: c1 is left open model.complete()ScriptedModel run_tool()tools: read run_agentgo again whole list, every call append reply reply: error?tool_calls(reply)? 3yes:stop 1none:return reply 2max_turns 4cancel some:run each max_turns:never here append result unknown tool: an errorresult, not an exit 3error: stop 1none: return reply tool_calls(reply)? 2max_turns 4cancel whole list,every call appendreply append result unknown tool:an error result,not an exit

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?

The opening is one list, and their harness kept it instead of copying it. Every run appends to the list every conversation starts from
The drift through the day is the day's conversations piling up in one place, and the nightly restart is a new list.
Something on the provider's side is threading the conversations together: same account, same model, minutes apart
Each request arrives alone and is answered out of what is inside it. That is also testable from the report they sent: a restart of their process fixes it, and nothing they run touches the provider's side.
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
Appending to the caller's list is the whole design of lesson 3, and it is why the transcript is yours to keep. The list the loop was handed came from the harness, and the harness is the thing that was asked to own one.

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 follow-up.

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. The job is a red test suite.

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?

Bad news from a command is not a tool error. 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
One flag, and a model that can read an assertion turns into one that files a bug about your tooling.
Nothing is missing. A command that exits 1 has failed, and a failure is what is_error is for: their model is being told the truth and deciding to stop
Two failures are being run together. The test suite failed, which is news. The tool ran the suite and reported it, which is the tool succeeding — and is_error is a claim about the second thing, not the first.
Nothing that reaches the model. Both results carry the same output and the same failing assertion, and a flag on a message is bookkeeping for whoever reads the logs
Both do carry it, which is the interesting half of the output: the model had what it needed either way. The flag travels in that message too, and the reader of that message is the model, not you.

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?

The repair wrote down something nobody observed, and the model reasoned from it. The record supports two claims — this call was made, and no result was recorded for it — and "The tool ran." is neither of them
The only reader who can act on that sentence is the one it was written for, and it did.
Nothing much. Every synthetic result is made up, so its wording is house style; what matters is that the pair is closed and the session is not bricked
Both runs close the pair and both sessions carry on. Put the two answers side by side and ask which of them a person could act on at half past two in the morning.
The repair should not be inventing anything here. An interrupted tool is an error, and the program should raise on it so that somebody finds out before a customer does
Somebody should find out, and raising tells the wrong somebody: the session is bricked again, exactly as it was before lesson 10, and the person who hears about it is whoever is on the stack rather than whoever is on call. A line in a log does that job without taking the session with it.

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: one call in, exactly one result out, on every path, and that result says what happened to that call — the tool's output, or the reason it could not do its job. It is also the place where a raised exception becomes a claim, which is what settles what a tool may raise about: a command that exits 1 did its job, so it returns. Nowhere else can keep the rule, because nowhere else was there. By the time a request is built the tool has returned and been forgotten, and 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_error whenever you are not sure." Two of the three did something like that, in opposite directions. is_error is 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.

The event stream: what run_agent yields during one tool-using run The events of the run user, assistant with toolCall c1, toolResult c1, assistant, in the order run_agent yields them, first at the top, indented by nesting. agent_start and agent_end enclose the run; each turn_start and turn_end enclose one turn; tool_execution_start and tool_execution_end enclose one call. There is one message_end for every message appended, and the message is already in the list when its event is yielded. The result's message_end comes before tool_execution_end. agent_end carries only this run's new messages. From lesson 16 each assistant message also opens with message_start and streams message_update deltas before its message_end. eventsin yield order, first at the top message_startmessage_updateone per delta message_startmessage_update agent_start turn_start message_end user0 message_end assistantc11 tool_execution_startc1 tool_execution_endc1 message_end toolResult · c12 turn_endturn_start message_end assistant3 turn_end agent_endcarries the 4 new messages messagesafter the run 0 userWhat is in config.py? 1 assistantread(path="config.py")c1 2 toolResult · c1DEBUG = True 3 assistantIt sets DEBUG to True. already in the list {messages}: only this run's new ones {messages}: only this run's new ones

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.

  1. 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?
  2. One while True, and the turn inside it is lesson 5's. turn_start is the first thing in each pass; on the first pass only, the prompt is appended and announced; then the max_turns check 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; then turn_end carrying 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 cannot yield on 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 write yield helper(...). And agent_end carries this run's messages alone, which the caller's list cannot hand you.
  3. 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.

  1. run_agent is in the file. Until it is, every test below can only say so.
  2. A job that needs three files takes four model calls, and nobody cranks by hand.
  3. Every request is the whole list as it stood: two messages longer than the one before.
  4. Everything that happened is in the list the caller passed in, and in no other.
  5. A reply that holds a call gets its result and another turn, even when labelled "stop".
  6. After a run, the same list takes another prompt and the strict model accepts it.
  7. A reply with no calls ends the run, even when it is labelled "toolUse" or "length".
  8. One reply asks for two files: both results follow it, in call order, before the next turn.
  9. The loop has no turn count of its own: twelve requests in a row get twelve results.
  10. With no tools and a plain answer, the run is one model call: what chat used to do.
  11. The model mistypes a tool name, reads the error, retries with the right name: three model calls.
  12. Three calls in one reply, the second fails: three results, in call order, only the second an error.
  13. max_turns=1 against a stuck model: one model call, its tool call answered, then an in-band stop.
  14. max_turns=3 allows exactly three model calls, in every run, however long the list already is.
  15. max_turns=None means no limit: twelve requests in a row are all served, and a limit of 20 changes nothing.
  16. The view is worked out again for every model call: the model sees the result of the tool it just asked for.
  17. The provider answers 503: the failed reply goes on the record and the run ends without raising.
  18. After a 503 the user says "try again": the failed message stays on the record and is not sent.
  19. A reply that failed after producing text is sent back to the model like any other message.
  20. A run stopped by max_turns leaves a list the next prompt is accepted on, and the model recovers.
  21. A model that follows the notices finds `def target` on line 2,400 of big.log for under 45,000 tokens.
  22. Read big.log once, work four more turns: the whole run costs under 80,000 input tokens.
  23. A run with no tools yields agent_start, turn_start, a message_end for the prompt, one for the answer, turn_end, agent_end.
  24. One tool call: its result's message_end comes after tool_execution_start and before tool_execution_end, inside the first turn.
  25. Two calls in one reply: start, result, end for the first call, then start, result, end for the second, in one turn.
  26. 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.
  27. The provider answers 500: the failed reply gets its message_end, then turn_end and agent_end still come.
  28. 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.
  29. A second run on the same list: its agent_end holds the messages that run appended, and no earlier ones.
  30. Every event of a tool run and of a failed run can be written as a line of JSON by the given JsonRenderer.
  31. 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 mutate, then announce buys, at every point in every run. And the last two blocks are the same loop again, spending no line at all on what a screen looks like.

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 event stream, and made the first one arrive one message at a time instead of all at the end — and a test that asks the record what happened cannot tell the difference. That is worth more than a passing suite: it is the evidence that the second product was added without the first one's behaviour being renegotiated.

Common answers, and what each one misses

  • "Nothing really changed; it is the same loop with yield instead of return." Three things did. The return value is gone, so agent_end has to carry it — which is why exactly one test from Checkpoint A could not come with you: the one that asked run_agent for the final assistant message. The consumer 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.drive exhausts 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_agent in 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.