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.
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.
- Make main.py faster.
- Reading it. read(main.py)
- def slow(n): return sum(range(n))
- Cache the results in a global dict.
- Do it, then.
- 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?
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
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
parent_id null
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, 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.
- Start at the entry you were asked for: the tip, when nobody says otherwise.
- Keep it, and read its
parent_id. - Find the entry with that id, and do the same again.
- Stop at the entry whose
parent_idis null. - 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.
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.
- Attempt one: five lines, each naming the entry it follows, and the chain runs e1 to e5.
- 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.
- 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?
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.
path(entries, leaf_id), the walk of the last two questions: up byparent_idfromleaf_id, then turned round.leaf_id=Noneis[], so a session that has not started and one that has go down the same code. ThreeValueErrors, each naming the entry it stopped at: an entry with noparent_idkey, 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.tip(): the id of the last line, orNonewhen there is no file. Nothing stores it.append_message(message): one line, four keys, the parent being wherever the session is now.branch_to(entry_id): one line of typebranch, carrying no message, whose parent isentry_id; its id comes back. An id the log does not hold is aValueErrornaming it, raised before anything is written.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.
- 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. - 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
setof the ids you have visited, and the id you are on, starting atleaf_id. The loop runs while that id is notNone. 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 entryand notentry.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. - 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Every entry carries
id,parent_idand a timestamp, whatever its type, andparent_id=Nonemeans root (src/tau_agent/session/entries.py:25-32). - The tip is the last entry in file order, and nothing records it: "the active entry is the last non-leaf entry in file order" (
src/tau_agent/session/memory.py:45-50,src/tau_agent/session/memory.py:144-148). Replay is the walk to it, andNoneis the empty path, as yours is (src/tau_agent/session/memory.py:52-57). - Siblings are never visited, because nothing points downwards. Tau's terminal UI builds an index of children of its own when it has to draw the tree for a person to pick from, and the replay path never uses it (
src/tau_coding/session.py:4144-4152). - Going back to an id the file does not hold is refused by name before anything happens (
src/tau_coding/session.py:1017-1018), and an entry that cannot be branched from — a tool result, a model change — is refused too (src/tau_coding/session.py:4123-4128).
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 oneparent_idchain 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 abranch_summaryentry 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
gitcommand. 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
pytesttakes 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_idon every entry,path(entries, leaf_id)with its three refusals,tip(),branch_to(entry_id), and arows()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.