side quest S2 · optional

Regret

The model offered you a global cache and you said "Do it, then." That was the mistake, and both messages are now lines in a file nothing is allowed to edit.

~50 min · 1 lab · builds on 11 Pull the plug

What this page is

A side quest: optional, about fifty minutes, and nothing after it depends on it. Lessons 12 to 16 are written against the harness.py you had at the end of lesson 11, so you can do this page now, later, or never. It exists because the file you built last lesson is one key and one new reader away from something it cannot do today: take that answer back, and let me try the other way.

The shape is a lesson's: a run that goes wrong, a few questions with a run behind most of them, one lab with hidden tests, and the comparison with Tau at the end.

One warning before you start. This page changes the format of session.jsonl: every line gains a key, and the code you write here refuses to read a file written by the code you have now. That is deliberate, it is one of the lab's hidden tests, and the lab's brief says what to do with an old file.

The message you would take back

A session, six messages long, saved by the log you built last lesson. You asked for main.py to be made faster; the model read it and offered you a module-level cache dictionary; you said Do it, then.; it said it was done. One message, one line, so the file holds e1 to e6 in the order you are about to read them.

  1. Make main.py faster.
  2. Reading it. read(main.py)
  3. def slow(n): return sum(range(n))
  4. Cache the results in a global dict.
  5. Do it, then.
  6. Done: main.py now keeps a module-level CACHE.

Now you change your mind. Not about the whole session — the first four messages are fine, and the model reading the file cost you a call you would rather not pay for twice. You change your mind about your own answer to the offer, e5. A module-level dictionary is a bad answer for this program, and you want to go back to the moment just after the offer — e4, the last line you are keeping — and answer it differently.

Your log has two methods that touch the file: append_message and entries. One adds a line; the other reads them all. Neither can take a line out, so the cheapest move available is to say so in the next message and carry on.

You type No global, please., and then one more prompt after that, on the same harness and the same log. How much of the part you regret — your Do it, then. and the answer that followed it — is in the requests that come after?

Both messages, in every later request, for as long as the session lives
They are lines in the file, the transcript is the file read back, and the request is the transcript. There is no step in that chain where something could drop them.
Both messages, but you pay for them once: they were new in one request, and after that the provider has them
Nothing is kept between calls, which is lesson 1. Each request is a fresh list of every message, priced by its length. [general] A cache discount on a repeated prefix is real and is still a price per request, not a payment that ends.
Neither: the harness was resumed from the file, and a resumed harness sends the model what is new since it last spoke
That is lesson 1's belief with a disk under it. Harness(..., messages=log.replay()) starts from the whole transcript, and every call sends the whole of it, because the thing on the other end remembers nothing.

Seven messages, then nine, and the two you regret are in both. Saying so in the next message takes nothing back; it adds, and every later request carries a sentence in the model's own voice saying main.py now keeps a module-level CACHE. So the pair has to come out of the transcript. The transcript is what replay() makes of the file, and lesson 11's one rule about that file is that no line in it is ever edited or removed.

import harness, lab

SYSTEM = "You are a careful refactoring assistant."
MAIN_PY = "def slow(n):\n    return sum(range(n))\n"
READ = lab.call("read", {"path": "main.py"}, id="c1")
YES = "Do it, then."
DONE = "Done: main.py now keeps a module-level CACHE."

ATTEMPT_ONE = [lab.user("Make main.py faster."),
               lab.reply(lab.text("Reading it."), READ),
               lab.tool_result(READ, MAIN_PY),
               lab.say("Cache the results in a global dict."),
               lab.user(YES),
               lab.say(DONE)]

ws = lab.Workspace({"main.py": MAIN_PY})
log = harness.SessionLog(ws, "session.jsonl")
for message in ATTEMPT_ONE:
    log.append_message(message)                 # the session as it stands: six lines, six messages

model = lab.ScriptedModel([lab.say("Then functools.cache on slow(), which is one import."),
                           lab.say("Done: slow() is wrapped in functools.cache.")])
h = harness.Harness(model, SYSTEM, [], messages=log.replay())
h.subscribe(harness.persist_to(log))
for prompt in ("No global, please.", "Do that, then."):
    for event in h.prompt(prompt):
        pass                                    # a frontend that shows nothing and saves nothing

print("the session after the correction:", lab.shape(log.replay()))
print()
print("what each request carried:")
for number, request in enumerate(model.calls, 1):
    unwanted = [YES in request.text, DONE in request.text]
    print(f"    request {number}   {len(request.messages)} messages, "
          f"{lab.count_tokens(request.text):>3} input tokens, "
          f"the two messages you regret: {sum(unwanted)} of 2")
print()
print("the last request, in full:")
print(lab.show(model.calls[-1].messages))
print()
sent = model.calls[-1].messages
kept = sent[:4] + sent[6:]
print(f"the file holds {len(log.entries())} lines now; when that request went out it held "
      f"{len(sent)}, and all {len(sent)} were in it.")
print(f"Without the two: {lab.count_tokens(lab.render_request(SYSTEM, kept, []))} input tokens "
      f"instead of {lab.count_tokens(model.calls[-1].text)}, on that request "
      f"and on every one after it.")
the session after the correction: U A[c1] R(c1) A U A U A U A

what each request carried:
    request 1   7 messages, 181 input tokens, the two messages you regret: 2 of 2
    request 2   9 messages, 222 input tokens, the two messages you regret: 2 of 2

the last request, in full:
user -> "Make main.py faster."
assistant -> "Reading it." + toolCall c1 read({"path": "main.py"})
toolResult c1 -> "def slow(n):\n    return sum(range(n))\n"
assistant -> "Cache the results in a global dict."
user -> "Do it, then."
assistant -> "Done: main.py now keeps a module-level CACHE."
user -> "No global, please."
assistant -> "Then functools.cache on slow(), which is one import."
user -> "Do that, then."

the file holds 10 lines now; when that request went out it held 9, and all 9 were in it.
Without the two: 183 input tokens instead of 222, on that request and on every one after it.

Which leaves exactly one way out, and you have met it twice already. The record is not the view. Keep every line; change what you compute from them. Lesson 10 repaired the view before a request without touching the record, and lesson 11 turned the record into a file. Today the view stops being "all of it".

Before any of that, the requirements. Going back is not one thing: it is a set of promises about what is still true afterwards, and a design that keeps most of them and quietly drops one is worse than no design at all.

You have gone back to e4, the offer, and answered it differently. Tap everything that must be true afterwards.

  • the model is never again sent Do it, then. or the answer that followed it
  • attempt one can still be read back, whole and in order
  • the file is shorter than it was, because two messages left the session
  • after a reboot, the file alone says which attempt the session is on
  • whatever attempt one did to your files is undone
  • the first four messages go out again exactly as they were

Four of the six hold. The two that do not are the two that sound like undo.

  • A shorter file is the one thing you may not have. Rewriting a file to make it shorter is the design lesson 11 rejected: for as long as the rewrite is going on the whole session is at risk, and the part being risked here is the attempt you said you wanted to keep.
  • Your files are not in the log. The log holds what was said, not what the tools did. If attempt one wrote to main.py, that write is on your disk and going back does not know about it. Hold on to that: the last question of this page is about it.
  • Read back, whole and in order is the promise that makes the other three worth having. Anyone can drop two messages. Dropping them in a way that lets you pick them up again tomorrow is the exercise.
  • The file alone, after a reboot, because lesson 11 already settled where a session lives. If going back is only true inside a running program, it is not true.

Which line does this one follow?

The view you want is a slice: the first four messages, then the new attempt. So replay() has to be able to pick lines out of the file, which means the lines have to tell it something they do not tell it now. A line knows its own id. Nothing in the file says which line came before it in the conversation, as opposed to which line came before it in the file — and once two attempts share a beginning, those are different questions.

One key fixes it. Every entry names the entry it follows:

{"id": "e5", "type": "message", "parent_id": "e4", "message": {"role": "user", ...}}

The first entry of a session names none: "parent_id": null. Every later one names the entry it was appended under, which until today is simply the line before it. The file is still a list of lines. What it now describes is a tree.

Which raises the question this whole design turns on. When you go back to e4 and type your correction, something has to know that the correction follows e4 and not e6. Where does that fact live between the moment you go back and the moment you type?

The obvious place is the log object: branch_to("e4") sets self._parent, and the next append_message uses it. The cell does that, and then you close the lid before typing anything. Tomorrow a new process opens the same file, you type No global, please., and it is appended. Which entry does that line follow?

e6, the last line of the file: the correction lands on the end of the attempt you walked away from
A new process has to get its cursor from somewhere, and the only place is the file, whose last line is e6. Yesterday's going back is not in the file.
e4. That is what going back did, and it did it to the log, which is the object that writes the file
It wrote nothing. An attribute is a fact about a Python object, and lesson 11's cell is the answer to what those are worth: the process died and the list died with it. This one died quieter, because nothing even tried to save it.
Nothing: a new process has no cursor, so the correction is the first entry of a new branch, with parent_id null
That puts the session in the program rather than in the file. Opening a file does not start a session; the session is whatever the lines say, and a program that reads them is at the end of them.

Entry e7 follows e6, so your correction is appended to the attempt you walked away from, with the answer you rejected sitting above it. The fact lived in one attribute of one object, and the object is gone. Going back is something that happened, at a time, in a session, which is exactly what this file is for: so write it down, as one more line, carrying no message, whose parent is e4. That line is the last line now, so it is where the session is, and everything appended afterwards hangs off it.

Where this design comes from: it is Tau's. A cursor, _last_parent_id, is moved when you go back, and the comment says what that costs: "Plain navigation is in-memory only" (src/tau_coding/session.py:1053-1058). Tau can afford it and you cannot, for a reason the Tau block at the end of this page goes into.

import json

import harness, lab

SYSTEM = "You are a careful refactoring assistant."
MAIN_PY = "def slow(n):\n    return sum(range(n))\n"
READ = lab.call("read", {"path": "main.py"}, id="c1")

ATTEMPT_ONE = [lab.user("Make main.py faster."),
               lab.reply(lab.text("Reading it."), READ),
               lab.tool_result(READ, MAIN_PY),
               lab.say("Cache the results in a global dict."),          # e4: the proposal
               lab.user("Do it, then."),                                # e5: the answer you regret
               lab.say("Done: main.py now keeps a module-level CACHE.")]


class CursorLog(harness.SessionLog):
    """Lesson 11's log, plus one attribute: where the session went back to. Every message is
    appended under that parent, and then the parent moves on to the message just written.
    A process that opens an existing file starts at the end of it, because that is where the
    session was when the last line was written (Tau does exactly this)."""

    def __init__(self, ws, path):
        super().__init__(ws, path)
        entries = self.entries()
        self._parent = entries[-1]["id"] if entries else None

    def branch_to(self, entry_id):
        self._parent = entry_id                 # going back: an assignment. Nothing is written.

    def append_message(self, message):
        entry = {"id": f"e{len(self.entries()) + 1}", "type": "message",
                 "parent_id": self._parent, "message": message}
        self.ws.append_text(self.path, json.dumps(entry) + "\n")
        self._parent = entry["id"]
        return entry["id"]


def show_file(ws, label):
    entries = [json.loads(line) for line in ws.read_text("session.jsonl").splitlines()]
    print(f"{label}: {len(entries)} lines")
    for entry in entries:
        print(f"    {entry['id']}  parent {str(entry['parent_id']):<5} "
              f"{lab.show([entry['message']], clip=50)}")


ws = lab.Workspace({"main.py": MAIN_PY})
log = CursorLog(ws, "session.jsonl")
for message in ATTEMPT_ONE:
    log.append_message(message)

log.branch_to("e4")                             # "go back to the proposal and answer it again"
print(f"you went back to e4. log._parent is {log._parent!r}, and the file is still "
      f"{len(log.entries())} lines long.")
print("-- then the lid closes, before you have typed anything --")
print()

disk = ws.reboot()                              # a new process, the same file
tomorrow = CursorLog(disk, "session.jsonl")
print(f"the new process starts with log._parent = {tomorrow._parent!r}")
model = lab.ScriptedModel([lab.say("Then functools.cache on slow().")])
h = harness.Harness(model, SYSTEM, [], messages=tomorrow.replay())
h.subscribe(harness.persist_to(tomorrow))
for event in h.prompt("No global, please."):
    pass

print()
show_file(disk, "session.jsonl, after you typed your correction again")
print()
print("and this is what that run sent the model:")
print(lab.show(model.calls[0].messages, clip=50))
you went back to e4. log._parent is 'e4', and the file is still 6 lines long.
-- then the lid closes, before you have typed anything --

the new process starts with log._parent = 'e6'

session.jsonl, after you typed your correction again: 8 lines
    e1  parent None  user -> "Make main.py faster."
    e2  parent e1    assistant -> "Reading it." + toolCall c1 read({"path": "main.py"})
    e3  parent e2    toolResult c1 -> "def slow(n):\n    return sum(range(n))\n"
    e4  parent e3    assistant -> "Cache the results in a global dict."
    e5  parent e4    user -> "Do it, then."
    e6  parent e5    assistant -> "Done: main.py now keeps a module-level CACHE."
    e7  parent e6    user -> "No global, please."
    e8  parent e7    assistant -> "Then functools.cache on slow()."

and this is what that run sent the model:
user -> "Make main.py faster."
assistant -> "Reading it." + toolCall c1 read({"path": "main.py"})
toolResult c1 -> "def slow(n):\n    return sum(range(n))\n"
assistant -> "Cache the results in a global dict."
user -> "Do it, then."
assistant -> "Done: main.py now keeps a module-level CACHE."
user -> "No global, please."

Two entry types, then: a message and, from today, a branch. Lesson 11 put that type key on every line for a day like this one, and rows() has filtered on it since you wrote it, so a line that carries no message costs you nothing to ignore.

Here is the file after all of that: six lines of attempt one, the line that says the session went back, and two lines of attempt two. Every line names its parent. Nothing has been deleted, nothing has been edited, and the tip — the last line — is the end of the attempt you are on.

Nine lines, eight of them messages. Your replay() from lesson 11 reads the file in order and keeps every message it finds. What does the model get?

Eight messages: both attempts end to end, the answer you took back included, and your correction arriving as if you had accepted that answer first
Reading in order was right while the file was a list. It is a tree now, and a loop over the lines cannot see that.
Six: the branch line says where the session is, so the reader starts there
Nothing reads that line. You added a key to the writer and the reader is last lesson's, which walks from the top and keeps what it is given.
It does not get anything: a transcript that holds two attempts, the second walking back the first, is not one a provider will take
The last line of the output is the verdict on that transcript. Nothing about it is malformed: every call has its result, every role is a role. It is simply a conversation you never had.

Eight, and valid, and wrong. The model is handed the answer you took back and the sentence in which you accepted it, then your correction, as if you had said both things. Writing the parents down changed the file and changed nothing about what is read out of it.

The reader is the other half of the job, and it is the half with all the thinking in it.

Where this comes from: Tau replays the path to one entry, never the file in order (src/tau_agent/session/memory.py:52-57).

import json

import harness, lab

MAIN_PY = "def slow(n):\n    return sum(range(n))\n"
READ = lab.call("read", {"path": "main.py"}, id="c1")

# session.jsonl, written by the program you are about to build: every entry names the entry it
# follows, and line 7 is the one that says the session went back to e4.
FILE = [{"id": "e1", "type": "message", "parent_id": None,
         "message": lab.user("Make main.py faster.")},
        {"id": "e2", "type": "message", "parent_id": "e1",
         "message": lab.reply(lab.text("Reading it."), READ)},
        {"id": "e3", "type": "message", "parent_id": "e2",
         "message": lab.tool_result(READ, MAIN_PY)},
        {"id": "e4", "type": "message", "parent_id": "e3",
         "message": lab.say("Cache the results in a global dict.")},
        {"id": "e5", "type": "message", "parent_id": "e4",
         "message": lab.user("Do it, then.")},
        {"id": "e6", "type": "message", "parent_id": "e5",
         "message": lab.say("Done: main.py now keeps a module-level CACHE.")},
        {"id": "e7", "type": "branch", "parent_id": "e4"},
        {"id": "e8", "type": "message", "parent_id": "e7",
         "message": lab.user("No global, please.")},
        {"id": "e9", "type": "message", "parent_id": "e8",
         "message": lab.say("Then functools.cache on slow().")}]

ws = lab.Workspace({"main.py": MAIN_PY,
                    "session.jsonl": "".join(json.dumps(entry) + "\n" for entry in FILE)})
log = harness.SessionLog(ws, "session.jsonl")

print("session.jsonl, nine lines:")
for entry in log.entries():
    message = entry.get("message")
    print(f"    {entry['id']}  {entry['type']:<8} parent {str(entry['parent_id']):<5} "
      + (lab.show([message], clip=50) if message else "(no message: the session went back to e4)"))
print()

replayed = log.replay()
print(f"lesson 11's replay(): {len(replayed)} messages, {lab.shape(replayed)}")
print(lab.show(replayed, clip=50))
print()
print(f"a provider's verdict on that transcript: {lab.validate(replayed) or 'valid'}")
session.jsonl, nine lines:
    e1  message  parent None  user -> "Make main.py faster."
    e2  message  parent e1    assistant -> "Reading it." + toolCall c1 read({"path": "main.py"})
    e3  message  parent e2    toolResult c1 -> "def slow(n):\n    return sum(range(n))\n"
    e4  message  parent e3    assistant -> "Cache the results in a global dict."
    e5  message  parent e4    user -> "Do it, then."
    e6  message  parent e5    assistant -> "Done: main.py now keeps a module-level CACHE."
    e7  branch   parent e4    (no message: the session went back to e4)
    e8  message  parent e7    user -> "No global, please."
    e9  message  parent e8    assistant -> "Then functools.cache on slow()."

lesson 11's replay(): 8 messages, U A[c1] R(c1) A U A U A
user -> "Make main.py faster."
assistant -> "Reading it." + toolCall c1 read({"path": "main.py"})
toolResult c1 -> "def slow(n):\n    return sum(range(n))\n"
assistant -> "Cache the results in a global dict."
user -> "Do it, then."
assistant -> "Done: main.py now keeps a module-level CACHE."
user -> "No global, please."
assistant -> "Then functools.cache on slow()."

a provider's verdict on that transcript: valid

Reading one branch

You know both ends of the answer. The session is at the tip, which is the last line of the file, and the conversation is the run of entries from the first one down to it. There is only one way to get from one end to the other, because every line points at its parent and no line points at its children.

Put the steps of reading one branch in order.

  1. Start at the entry you were asked for: the tip, when nobody says otherwise.
  2. Keep it, and read its parent_id.
  3. Find the entry with that id, and do the same again.
  4. Stop at the entry whose parent_id is null.
  5. Turn round what you have collected: the model reads oldest first.

Up, then reverse. That is the whole of it, and it is the function you will write first: path(entries, leaf_id), given the file and told where to start.

Notice what the walk never does. It never looks at an entry's children, because nothing in the file points downwards. A branch you are not on cannot leak into the transcript by accident; it can only leak in if somebody reads the file in order, which is what last lesson's replay() does.

The log is the truth: one appended line per message, and the list you get by reading it back session.jsonl sits on disk. persist_to(log) appends one JSON line at each message_end, with ids e1, e2 and so on counted from the number of lines; no earlier line ever changes. The messages list lives in memory and dies with the process. After a power cut and ws.reboot(), log.replay() reads the five lines in file order and rebuilds the same five messages. In the tree form every line also names its parent_id: after branch_to("e3") two new lines e6 and e7 are appended with parent e3, the lines e4 and e5 stay in the file as an abandoned branch, and the context is found by walking up from the tip, the last line, and reversing: e1, e2, e3, e6, e7. session.jsonllog · on disk {"id":"e1",..."role":"user"...}{"id":"e2",..."role":"assistant"...}{"id":"e3",..."role":"toolResult"...}{"id":"e4",..."role":"assistant"...}{"id":"e5",..."role":"toolResult"...} e6: the next line goes here {"id":"e1","parent_id":null,...}{"id":"e2","parent_id":"e1",...}{"id":"e3","parent_id":"e2",...}{"id":"e4","parent_id":"e3",...}{"id":"e5","parent_id":"e4",...} {"id":"e6","parent_id":"e3",...}{"id":"e7","parent_id":"e6",...} messagesin memory power_cut()the process died,and this list died with it 0 userWrite a.py, b.py and c.py.e1 1 assistantwrite(path="a.py",...)c1e2 2 toolResult · c1Successfully wrote to a.py.e3 3 assistantwrite(path="b.py",...)c2e4 4 toolResult · c2Successfully wrote to b.py.e5 append: one line at each message_end,no earlier line ever changes replay(): after ws.reboot(), the same list,rebuilt from the lines in file order append replay() append replay() the same linesjoined by parent_id e1user e2assistantc1 e3toolResult · c1 e4assistantc2 e5toolResult · c2 abandoned,not deleted e6userafter branch_to("e3") e7assistanttip: the last line context = path("e7"): walk up, then reverse:e1 e2 e3 e6 e7 still append-only: a branch is new lineswhose parent_id points further back

Figure S2.1 Lesson 11's figure again, and the session it drew: the same lines, now joined by parent_id. The drawing hangs the new attempt's first message straight off e3; in your log there is one more line in between, the one branch_to writes.

  1. Attempt one: five lines, each naming the entry it follows, and the chain runs e1 to e5.
  2. Going back to e3: two new lines whose parent chain starts at e3. Nothing is deleted — e4 and e5 are still in the file, drawn to the side — and e7, the last line, is the tip.
  3. The walk from the tip, turned round: e1 e2 e3 e6 e7. The two entries hanging off to the side are never visited.

What a file can do to you

The walk has one property worth being afraid of: it trusts what it reads. A file that came out of your own program is fine. A file that somebody has had open in an editor, or that an older version of your program wrote, can be wrong in three different ways, and all three of them can end a walk early rather than loudly.

The cell runs two walks that try not to make a fuss, over two such files. One stops the moment it cannot follow a parent and gives back what it has. The other counts its steps, and when it has taken more of them than there are lines it gives up and names the entry it was asked for.

Which of these is the failure to be afraid of?

The walk that gives an answer: half a session, or a session in an order nobody wrote, with nothing said about it
Both walks in the cell do it. The second one refuses the circle, and still reads the whole of an old-format session as its last message alone.
The walk that refuses. A damaged line is rare, and a session you can still work in beats an error on a Tuesday morning
This is lesson 11's torn line again, one level up. A refusal costs you one session until a human looks at one line. A silent short walk costs you a model that has forgotten what it was told, and no one will ever know which conversation that was.
The walk that never ends. A circle is an infinite loop, which is a real bug; which entry the message names is a detail for whoever reads the logs
Nobody is reading a log. The message is read by a person with a text editor open on a session file, deciding which line to fix, and the second walk sends them to e4 — the one line in that file that is correct.

Three troubles, three refusals, each naming the entry it stopped at: an entry that is not in the file, an entry that says nothing about what it follows, and an entry the walk has already been through — a circle, named where it is and not where the walk began. The second file is the one that will actually happen to you: a session written by the program you have now, where no entry has a parent at all. Read tolerantly, that whole session comes back as its last message, in silence. It is the failure lesson 11 refused for a torn line, refused here for the same reason.

Where the two refusals come from: Tau's walk raises on a missing entry and on a cycle, and its test asks for both (tests/test_session.py:763-770). The third is ours, and the Tau block says why.

import json

import lab

# Two session files that no program wrote: one edited by hand into a circle, one left over from
# the version of the program that had no parents at all.
CIRCLE = [{"id": "e1", "type": "message", "parent_id": None,
           "message": lab.user("Make main.py faster.")},
          {"id": "e2", "type": "message", "parent_id": "e3",
           "message": lab.user("Do it, then.")},
          {"id": "e3", "type": "message", "parent_id": "e2",
           "message": lab.user("No global, please.")},
          {"id": "e4", "type": "message", "parent_id": "e3",
           "message": lab.user("Nor an import.")}]
OLD = [{"id": "e1", "type": "message", "message": lab.user("Make main.py faster.")},
       {"id": "e2", "type": "message", "message": lab.user("Do it, then.")}]


def walks_and_stops(entries, leaf_id):
    """Tolerant: at anything it cannot follow -- an entry that is not there, an entry that says
    nothing about what it follows, an entry it has already walked through -- it stops and gives
    back what it has."""
    by_id = {entry["id"]: entry for entry in entries}
    walked, seen, current = [], set(), leaf_id
    while current is not None and current in by_id and current not in seen:
        seen.add(current)
        walked.append(by_id[current])
        current = by_id[current].get("parent_id")
    walked.reverse()
    return walked


def walks_with_a_budget(entries, leaf_id):
    """Refuses, but decides it is lost by counting steps, so the entry it names is the one the
    walk was asked for."""
    by_id = {entry["id"]: entry for entry in entries}
    walked, current = [], leaf_id
    for _ in range(len(entries)):
        if current is None:
            walked.reverse()
            return walked
        walked.append(by_id[current])
        current = by_id[current].get("parent_id")
    raise ValueError(f"session entry {leaf_id} is its own ancestor")


def try_both(label, entries, leaf_id, messages):
    print(f"{label}, walked from the last line, {leaf_id}:")
    for name, walk in (("stops at the first trouble", walks_and_stops),
                       ("counts steps, then gives up", walks_with_a_budget)):
        try:
            walked = walk(entries, leaf_id)
            ids = " ".join(entry["id"] for entry in walked)
            print(f"    {name:<29}{len(walked)} of the {messages} messages: {ids}")
        except ValueError as exc:
            print(f"    {name:<29}ValueError: {exc}")
    print()


print("the file: " + " . ".join(f"{e['id']} follows {e.get('parent_id', '(not said)')}"
                                for e in CIRCLE))
try_both("e2 and e3 have been edited into naming each other", CIRCLE, "e4", 4)
print("the file: " + " . ".join(f"{e['id']} follows {e.get('parent_id', '(not said)')}"
                                for e in OLD))
try_both("a session written before entries had parents", OLD, "e2", 2)
print("e4 follows e3 perfectly well, and e1 and e2 are a whole session.")
the file: e1 follows None . e2 follows e3 . e3 follows e2 . e4 follows e3
e2 and e3 have been edited into naming each other, walked from the last line, e4:
    stops at the first trouble   3 of the 4 messages: e2 e3 e4
    counts steps, then gives up  ValueError: session entry e4 is its own ancestor

the file: e1 follows (not said) . e2 follows (not said)
a session written before entries had parents, walked from the last line, e2:
    stops at the first trouble   1 of the 2 messages: e2
    counts steps, then gives up  1 of the 2 messages: e2

e4 follows e3 perfectly well, and e1 and e2 are a whole session.

Build: a file that holds every attempt

Everything you write is in region 6, and the harness knows none of it. Region 4 is untouched: Harness hands every event to its listeners exactly as it did last lesson, and the listener, persist_to at the bottom of region 6, still appends every completed message blindly, with no idea which branch it will land on. That constraint is why going back had to be a line in the file and not an attribute of an object.

One naming note. The walk is a function of the region, path(entries, leaf_id), not a method — SessionLog.path has been the log's file name since lesson 11, and a session is not the only thing that wants one branch of a log. Tau splits it the same way (src/tau_agent/session/tree.py:22-23).

Your harness.py from the end of lesson 11, with region 6 opened up: every signature and docstring is given, and five bodies are gaps. About 24 lines in all. entries(), persist_to and two new helpers of a line or two each, _next_id() and _write(entry), are given whole.

  1. path(entries, leaf_id), the walk of the last two questions: up by parent_id from leaf_id, then turned round. leaf_id=None is [], so a session that has not started and one that has go down the same code. Three ValueErrors, each naming the entry it stopped at: an entry with no parent_id key, a parent that is in no entry of the file, and a parent chain that comes back to an entry the walk has already been through.
  2. tip(): the id of the last line, or None when there is no file. Nothing stores it.
  3. append_message(message): one line, four keys, the parent being wherever the session is now.
  4. branch_to(entry_id): one line of type branch, carrying no message, whose parent is entry_id; its id comes back. An id the log does not hold is a ValueError naming it, raised before anything is written.
  5. rows(): the (id, message) pairs of the branch that ends at the tip, oldest first. replay() is already written in terms of it.

Thirteen hidden tests. They append a session, go back, and go back again, and they insist that going back writes one line and leaves every line before it byte for byte as it was; that path(entries, "e6") still reads the attempt you left, whole; and that a harness resumed on the branched log is sent the four messages before the fork and never the words that live only on the abandoned attempt. Five are refusals: an id that is not in the file (on a session of six entries and on one that has not started, and nothing may be written either time), an entry with no parent key, a parent that is missing, a circle, and a circle further up the file than the tip — which must be named where it is, not where the walk started.

Two things that are easy to get wrong and are both tested. Ids count lines, not messages, so the entry after a branch must not reuse an id. And branch_to checks before it writes: an entry whose parent is missing can never be replayed, and a line, once appended, is never taken out again.

There is no "what changed" diff on this lab. A side quest is not part of the code thread, so this starter is lesson 11's solution with region 6 rewritten, and there was no previous lab to diff it against. For the same reason the format change is yours to live with: a session.jsonl written before today has no parents, and your code will refuse it by name rather than read it. Converting one is a short script and a decision about what the first entry's parent is; reading it as if every line were a first message is the thing that must not happen quietly.

  1. Four of the five bodies are one or two lines, and three of them are about the same question: where is the session right now? Answer that once, in tip(), and see how much of the rest falls out. For the walk: you need to notice that you have been somewhere before, which means keeping something as you go — and the walk is over ids, so what you keep is ids.
  2. Index the entries by id first, so that following a parent is a lookup and not a search. Then walk with three names: the list you are collecting, a set of the ids you have visited, and the id you are on, starting at leaf_id. The loop runs while that id is not None. Inside it, in this order: refuse an id you have already seen, which is the circle, and notice that the entry to name is the one you came back to and not the one you set out from; refuse an id the index does not hold; refuse an entry with no "parent_id" key, which is "parent_id" not in entry and not entry.get("parent_id") is None, because a parent of null is how the first entry says it is the first entry; then keep the entry and move to its parent. Reverse at the end.

    The other four are one or two lines each once tip() is written, and the outline below has them.

  3. In outline.
    path(entries, leaf_id):
        by_id = {entry id: entry}
        walked, seen, current = [], set(), leaf_id
        while current is not None:
            if current in seen:            raise ValueError(... {current} is its own ancestor)
            seen.add(current)
            entry = by_id.get(current)
            if entry is None:              raise ValueError(... {current} is missing)
            if "parent_id" not in entry:   raise ValueError(... {current} does not say what it follows)
            walked.append(entry)
            current = entry["parent_id"]
        walked.reverse()
        return walked
    
    tip():              the id of the last entry, or None when there are none
    append_message(m):  self._write({id, type "message", parent_id: self.tip(), message: m})
    branch_to(id):      if no entry has that id: raise ValueError naming it and the file
                        self._write({id, type "branch", parent_id: id})
    rows():             (entry id, message) for the "message" entries of
                        path(self.entries(), self.tip())

Thirteen tests, and the one to read twice is the one that resumes a harness on a branched log. The model was sent four messages and a new prompt; the words that live only on the abandoned attempt were in the file the whole time and in none of the request. Nothing in your harness knows what a branch is. One function decides what the model sees, and it is fifteen lines long.

  1. Six messages appended in turn become six lines whose ids are e1 to e6, each entry naming the one before it as its parent_id, and the first naming none.
  2. tip() is None while there is no file, then the id of whatever was appended last, and after going back it is the entry that says so, not the newest message.
  3. branch_to() appends a single line and touches nothing else: everything already in the file is still there, byte for byte, and the disk sees appends and nothing but appends.
  4. Once the log holds two attempts, replay() is the four messages before the fork and the new attempt: the file has more messages in it than the transcript has.
  5. After going back and writing a second attempt, path(entries, "e6") still gives attempt one, whole and in order: it was left behind, not lost.
  6. A harness resumed from a branched log sends the model the four messages before the fork and the new prompt, the abandoned attempt appears in neither the request nor the record, and the log still matches the record message for message.
  7. Going back to e4 twice leaves three branches in one file, each still replayable from its own last entry, and no entry ever reuses an id.
  8. branch_to("e1") leaves the first message alone in the transcript: the walk up the parents stops at the entry whose parent is null, and path(entries, None) is the empty session.
  9. branch_to() with an id the log does not hold raises, says which id, and writes nothing -- on a session of six entries and on one that has not started.
  10. A session file from before entries had parents: rows(), replay() and path() all raise and name the entry, rather than reading a whole session as a pile of first messages.
  11. A log edited by hand, in which the last entry names a parent that was deleted: rows(), replay() and path() all raise and all say which entry is missing.
  12. A log edited by hand into a circle, e2's parent being e3 and e3's being e2: rows(), replay() and path() all raise and name one of the two, instead of walking round for ever.
  13. The same circle with one more line on top, so the tip is not part of it: the entry the walk comes back to is the one named, not whichever entry the walk happened to start from.

The whole thing: one job, three attempts, one file. The model has a write tool this time, so each attempt really changes main.py. No tests — read the file listing, then the three walks, then the last request. Once the lab has passed, this runs against your code.

import harness, lab

SYSTEM = "You are a careful refactoring assistant."
JOB = "Make main.py faster."
PLAIN = "def slow(n):\n    return sum(range(n))\n"
GLOBAL = ("CACHE = {}\n\n\ndef slow(n):\n    if n not in CACHE:\n"
          "        CACHE[n] = sum(range(n))\n    return CACHE[n]\n")
DECORATED = "from functools import cache\n\n\n@cache\ndef slow(n):\n    return sum(range(n))\n"
BY_HAND = ("def slow(n, _memo={}):\n    if n not in _memo:\n"
           "        _memo[n] = sum(range(n))\n    return _memo[n]\n")
ANSWER = {"No global, please.": DECORATED, "Nor an import.": BY_HAND}
DONE = {GLOBAL: "Done: main.py now keeps a module-level CACHE.",
        DECORATED: "Done: slow() is wrapped in functools.cache.",
        BY_HAND: "Done: slow() memoises in a default argument."}


def refactorer(request):
    """Goes only by the transcript it is sent: it has no memory of the attempts it is not on."""
    last = request.messages[-1]
    if last["role"] == "toolResult":
        if not last["content"].startswith("Successfully wrote"):
            return lab.say("Cache the results in a global dict.")
        written = harness.tool_calls(request.messages[-2])[0]["arguments"]["content"]
        return lab.say(DONE[written])
    said = last["content"]
    if said == JOB:
        return lab.reply(lab.text("Reading it."), lab.call("read", {"path": "main.py"}))
    content = GLOBAL if said == "Do it, then." else ANSWER[said]
    return lab.reply(lab.call("write", {"path": "main.py", "content": content}))


def brief(message):
    """One short line per message: the write calls carry a whole file, and this page is not
    about their contents."""
    if message["role"] == "user":
        return f'user        "{message["content"]}"'
    if message["role"] == "toolResult":
        return f'result {message["tool_call_id"]}    "{message["content"].splitlines()[0]}"'
    parts = []
    for block in message["content"]:
        if block["type"] == "text":
            parts.append(f'"{block["text"]}"')
        else:
            shown = {key: (f"<{len(value.splitlines())} lines>" if key == "content" else value)
                     for key, value in block["arguments"].items()}
            arguments = ", ".join(repr(value) for value in shown.values())
            parts.append(f'{block["name"]} {block["id"]}({arguments})')
    return "assistant   " + " + ".join(parts)


def session(ws, log, model):
    """The whole program, as lesson 11 left it: replay the branch, build a harness on it, and
    subscribe the saver. Nothing here knows that the file is a tree."""
    h = harness.Harness(model, SYSTEM, [harness.make_read_tool(ws), harness.make_write_tool(ws)],
                        messages=log.replay())
    h.subscribe(harness.persist_to(log))
    return h


def run(ws, log, model, *prompts):
    for prompt in prompts:
        for event in session(ws, log, model).prompt(prompt):
            pass


ws = lab.Workspace({"main.py": PLAIN})
log = harness.SessionLog(ws, "session.jsonl")
model = lab.ScriptedModel([lab.forever(refactorer)])

run(ws, log, model, JOB, "Do it, then.")             # attempt one: the global dict
print(f"attempt one ends at {log.tip()}; main.py now starts {ws.read_text('main.py')[:10]!r}")
print(f"going back to e4 wrote {log.branch_to('e4')}")
run(ws, log, model, "No global, please.")            # attempt two: the decorator
print(f"going back to e4 again wrote {log.branch_to('e4')}")
run(ws, log, model, "Nor an import.")                # attempt three: memoise by hand

print()
print(f"session.jsonl, {len(log.entries())} lines:")
for entry in log.entries():
    message = entry.get("message")
    print(f"    {entry['id']:<4} {entry['type']:<8} parent {entry['parent_id']!s:<5} "
          + (brief(message) if message else "(no message: the session went back to e4)"))

print()
print("the three attempts, each read back from its own last entry:")
for leaf in ("e8", "e13", "e18"):
    walked = harness.path(log.entries(), leaf)
    messages = [entry["message"] for entry in walked if entry["type"] == "message"]
    print(f"    {f'path(entries, {leaf!r})':<22}{' '.join(e['id'] for e in walked):<34}"
          f"{lab.shape(messages)}")
print("    e9 and e14 carry no message, so rows() steps over them.")

print()
print(f"the session is on the third: rows() is {[entry_id for entry_id, _ in log.rows()]}")
print(f"the request that ended it, {len(model.calls[-1].messages)} messages:")
for message in model.calls[-1].messages:
    print("    " + brief(message))
print()
print(f"main.py was written {sum(path == 'main.py' for _, path, _ in ws.writes)} times and now "
      f"starts {ws.read_text('main.py')[:10]!r}. The log knows which branch each of those writes "
      "is on; the workspace does not.")
attempt one ends at e8; main.py now starts 'CACHE = {}'
going back to e4 wrote e9
going back to e4 again wrote e14

session.jsonl, 18 lines:
    e1   message  parent None  user        "Make main.py faster."
    e2   message  parent e1    assistant   "Reading it." + read c1('main.py')
    e3   message  parent e2    result c1    "def slow(n):"
    e4   message  parent e3    assistant   "Cache the results in a global dict."
    e5   message  parent e4    user        "Do it, then."
    e6   message  parent e5    assistant   write c2('main.py', '<7 lines>')
    e7   message  parent e6    result c2    "Successfully wrote to main.py."
    e8   message  parent e7    assistant   "Done: main.py now keeps a module-level CACHE."
    e9   branch   parent e4    (no message: the session went back to e4)
    e10  message  parent e9    user        "No global, please."
    e11  message  parent e10   assistant   write c3('main.py', '<6 lines>')
    e12  message  parent e11   result c3    "Successfully wrote to main.py."
    e13  message  parent e12   assistant   "Done: slow() is wrapped in functools.cache."
    e14  branch   parent e4    (no message: the session went back to e4)
    e15  message  parent e14   user        "Nor an import."
    e16  message  parent e15   assistant   write c4('main.py', '<4 lines>')
    e17  message  parent e16   result c4    "Successfully wrote to main.py."
    e18  message  parent e17   assistant   "Done: slow() memoises in a default argument."

the three attempts, each read back from its own last entry:
    path(entries, 'e8')   e1 e2 e3 e4 e5 e6 e7 e8           U A[c1] R(c1) A U A[c2] R(c2) A
    path(entries, 'e13')  e1 e2 e3 e4 e9 e10 e11 e12 e13    U A[c1] R(c1) A U A[c3] R(c3) A
    path(entries, 'e18')  e1 e2 e3 e4 e14 e15 e16 e17 e18   U A[c1] R(c1) A U A[c4] R(c4) A
    e9 and e14 carry no message, so rows() steps over them.

the session is on the third: rows() is ['e1', 'e2', 'e3', 'e4', 'e15', 'e16', 'e17', 'e18']
the request that ended it, 7 messages:
    user        "Make main.py faster."
    assistant   "Reading it." + read c1('main.py')
    result c1    "def slow(n):"
    assistant   "Cache the results in a global dict."
    user        "Nor an import."
    assistant   write c4('main.py', '<4 lines>')
    result c4    "Successfully wrote to main.py."

main.py was written 3 times and now starts 'def slow(n'. The log knows which branch each of those writes is on; the workspace does not.

Eighteen lines, sixteen messages, and the model's last request carried seven. Two of those lines are the ones that say the session went back; the other sixteen are messages, and eight of them are on attempts nobody is on. Look at what nothing in the run had to know: Harness was handed log.replay() exactly as in lesson 11, persist_to appended every completed message without asking where it would land, and run_agent has not changed since lesson 9.

Look at the last line of the output too. main.py was written three times, and the workspace remembers only the third. The log knows which branch each write is on; the disk does not, and that is not a bug you can fix in a session log.

What your harness can do now, if you keep this page's code:

  • Hold a conversation: it knows what was said, and who said it.
  • Run a tool the model asks for and show it the result.
  • Keep going until the model stops asking.
  • Tell the model when a tool fails, and carry on.
  • Stop a runaway, and survive a provider failure.
  • Keep every tool result within a budget.
  • Report what it is doing, as events, to any frontend.
  • Own its transcript: one writer at a time.
  • Take your input mid-run, at a safe point.
  • Send a valid transcript even after an interruption.
  • Survive a power cut: an append-only log, resumed by replay.
  • Go back to any earlier message and try another way, keeping every attempt in one file and sending the model exactly one of them.

Nothing in lessons 12 to 16 assumes any of it. Lesson 12 starts from the log you had at the end of lesson 11, and if you start it from its own starter this page's region 6 will simply not be there.

Say it in your own words

You added one key to every line and one line to the file, and got something back that felt much bigger than that. In a sentence or two: what changed about the file, and what changed about the reader?

Before today, "the session" and "the file, in order" were the same thing. Which of the two did the page keep, and what is the other one now?

The file stopped being a list and became a tree: still one line per thing that happened, still append-only, but each line saying which line it follows, so the lines describe a shape instead of an order. The reader is what changed more. "The session" is now a walk — one branch, from the first entry down to the tip — and the lines it does not visit are not lost, they are just not this conversation. The record kept everything; the view became a choice.

Common answers, and what each one misses

  • "You added a way to delete messages without deleting them." Nothing is deleted or hidden. Every line is as readable tomorrow as it was the day it was written, and path(entries, "e6") is how you read the attempt you left. The word for what you built is not "delete" but "choose".
  • "The file became a tree, and that is the change." Half of it. A file whose lines name their parents, read by a reader that goes from the top, is the third run on this page, and it sent the model both attempts. The tree is only worth something to a reader that walks.
  • "The tip is the new state of the session, so you are storing state after all." The tip is not stored. It is the last line, which is a fact about the file, worked out by reading it — which is why going back had to be a line rather than an attribute.

Going back is something that happened: a new line, not a missing one. The conversation is one walk; the file keeps the rest.

Tau walks up from the leaf by parent_id and reverses, refusing a cycle and a missing entry by name. It is the same function, and two of your three refusals.

by_id = {entry["id"]: entry for entry in entries}
walked, seen, current = [], set(), leaf_id
while current is not None:
    if current in seen:  raise ValueError(f"session entry {current} is its own ancestor")
    seen.add(current)
    entry = by_id.get(current)
    ... missing, and no parent_id key ...
    walked.append(entry)
    current = entry["parent_id"]
walked.reverse()
def path_to_entry(entries: list[SessionEntry], leaf_id: str) -> list[SessionEntry]:
    by_id = entries_by_id(entries)
# ...
    while current_id is not None:
        if current_id in seen:
            raise SessionTreeError(f"Cycle detected at session entry: {current_id}")
        seen.add(current_id)
        entry = by_id.get(current_id)
        if entry is None:
            raise SessionTreeError(f"Missing session entry: {current_id}")
        path.append(entry)
        current_id = entry.parent_id

    path.reverse()
    return path

The same decisions, one by one.

Tau is async: until lesson 15, read await f(x) as f(x) and async def as def.

The one real difference: where "I went back" lives. Tau moves a cursor in memory, _last_parent_id, and writes nothing; the next message is appended under it, and the branch becomes a fact about the file at that moment and not before (src/tau_coding/session.py:1049-1060). That is the design the second run on this page is built out of, and Tau has the behaviour that run shows: go back in Tau, quit without typing, reopen, and the cursor is read off the end of the file again (src/tau_coding/session.py:4086-4091), so you are at the tip of the attempt you left. It is a deliberate trade — navigation is a thing a person is doing, not a thing that happened to the session — and Tau can make it because one object owns both the cursor and the appending. You cannot: persist_to appends every message_end knowing nothing, region 4 must not learn that region 6 exists, and your SessionLog holds nothing but ws and path. The parent of the run's first message therefore has to be in the file before the run starts. Yours is the more honest file and the less convenient one: a branch you took and did not use is a line you cannot take out.

What Tau adds.

  • Ids that do not depend on counting. Every entry id is a random hex string (src/tau_agent/session/entries.py:15-17), so two processes appending to one file cannot both invent e5, and duplicate ids are refused when the index is built (src/tau_agent/session/tree.py:12-19). Yours counts lines, which is readable, name-able in a lesson, and would be a real bug in a program two people can run at once.
  • Ten kinds of line, not two (src/tau_agent/session/entries.py:132-144): model changes, labels, compactions and more. The walk does not care — it is one parent_id chain through all of them — and the replay decides, per type, what becomes a message.
  • The work you left behind, summarised. When you branch with summarize=True, the messages after the fork on the old path are sent to the model, and the summary is appended as a branch_summary entry whose parent is the fork (src/tau_coding/session.py:1038-1048, src/tau_coding/branch_summary.py:68-75). It replays as a user message on the new branch (src/tau_agent/session/memory.py:107-110), so the new attempt starts knowing what the old one found out. That is the last question on this page, answered in about two hundred lines.
  • Going back to your own words. Choosing a user message in Tau branches to its parent and puts the old text back in the input box (src/tau_coding/session.py:1049-1051): "edit and resend", which is the case this page started with, done in one keystroke.

Where yours is stricter. Your third refusal, the entry with no parent_id key, has no equivalent in Tau, and it does not need one: Tau's entries are typed objects whose parent_id defaults to None (src/tau_agent/session/entries.py:30-32), so a line without the key parses as a root and always meant to. Yours is a bare dict out of a file your own program wrote in a different shape last week, which is why the missing key has to be a refusal rather than a default. The cost is the one the lab's brief names: your old session files do not open.

Where yours is weaker. Tau refuses to branch while a run is going on (src/tau_coding/session.py:1012-1013); nothing stops you calling branch_to in the middle of a run, and the messages already in flight will be appended under the branch entry as if you had meant them to be. Tau's walk also rejects duplicate ids before walking, which yours cannot notice: hand-edit two lines to share an id and the walk quietly follows whichever by_id kept. And both designs pay for the branch you leave in the same way: it is in the file, every read of the session reads it, and a session with forty abandoned attempts is a slow file to open.

src/tau_agent/session/tree.py:22-40 · pinned to commit 9fe6a71 · view on GitHub

One more case

Attempt one did not only talk: it ran write, and main.py on your disk is the version it produced. You go back to e4 and the new branch's transcript says nothing about any of that, while the file on disk still says all of it. In a sentence or two: what does the new attempt need to be told, and who can tell it?

The model on the new branch is about to read main.py. What will it find, and what will it think it is looking at?

Two things can be true at once and only one of them is in the log: the conversation was rewound and the workspace was not. The new attempt needs to know what the old one did to the world — which files it touched, what it found out — and nothing in your session log can tell it, because the log records what was said. Either something summarises the abandoned branch into a message on the new one, or the agent finds out the way anybody finds out: by reading the file again, on a branch where nothing explains why main.py already has a CACHE in it.

Common answers, and what each one misses

  • "Undo the writes when you branch." The log is not a backup and the workspace is not yours alone: a test run, a build, another person, a git command. A session log that pretends it can roll back a disk is a session log that will one day delete somebody's work to be tidy.
  • "Replay the tool calls of the new branch from the start, so the world matches the transcript." Tool calls are not free and not all of them are repeatable; lesson 6's pytest takes a minute and sending an email twice is not a tidy-up. It also assumes the world only changes when your agent changes it.
  • "Tell the model at the top of the branch what the old attempt did." That is the answer, and it is the one Tau ships: the abandoned messages are summarised into an entry on the new branch, so the summary is history too, in the same file, on the branch it belongs to.
You hit
an answer you wanted back, two lines down in a file where no line may be edited or removed
You built
parent_id on every entry, path(entries, leaf_id) with its three refusals, tip(), branch_to(entry_id), and a rows() that walks instead of reading in order
The principle
going back is something that happened: a new line, not a missing one; the conversation is one walk, and the file keeps the rest
Your harness now
lesson 11's file, with region 6 rewritten.
  • run_tool
  • run_agent
  • context_for_model
  • repair_tool_history
  • Harness
  • subscribe
  • FinalTextRenderer
  • JsonRenderer
  • path
  • SessionLog
  • tip
  • branch_to
  • persist_to
Your answers
Back on the path
Nothing after this page needs it. Lesson 12 asks what happens when the session gets longer than the model can read.