12 · Memory that outlives the process
The wall
Prompt nine comes back "prompt is too long", and the agent is mid-task. Something has to go, and whatever you throw away costs you.
This lesson builds on lesson 11. New here? Start at lesson 01, or carry on: every lab is self-contained.
Your lesson 11 log holds five lines: a prompt, two write calls and their two results. Before the session is resumed, a careless hand deletes the last line of the file, the result of the second write. A new process replays the log and you type "Carry on." What is the model sent?
replay() reads what the lines say, and there are four of them.context_for_model pairs every call with a result before each request.The log is the truth, holes included. Replay returned what four lines say, the record kept the dangling call, and lesson 10's repair made a view a provider accepts, with an honest note that nobody knows whether b.py was written. Keep one thing from this: replay is a rule for reading lines. Today it learns a second rule.
import harness, lab
ws = lab.Workspace()
log = harness.SessionLog(ws, "session.jsonl")
w1 = lab.call("write", {"path": "a.py", "content": "A = 1\n"}, id="w1")
w2 = lab.call("write", {"path": "b.py", "content": "B = 2\n"}, id="w2")
for message in [lab.user("Write a.py, then b.py."),
lab.reply(w1),
lab.tool_result(w1, "Successfully wrote to a.py."),
lab.reply(w2),
lab.tool_result(w2, "Successfully wrote to b.py.")]:
log.append_message(message)
# a hand deletes the last line of the file
lines = ws.read_text("session.jsonl").splitlines(keepends=True)
ws.write_text("session.jsonl", "".join(lines[:-1]))
model = lab.ScriptedModel([lab.say("Let me check b.py first.")])
h = harness.Harness(model, harness.SYSTEM, [],
messages=log.replay())
for event in h.prompt("Carry on."):
pass
sent = model.calls[0].messages
print("lines in the file :", len(lines) - 1)
print("the record :", lab.shape(h.messages))
print("the model was sent:", lab.shape(sent))
print(lab.show(sent[4:5], clip=200))
lines in the file : 4 the record : U A[w1] R(w1) A[w2] U A the model was sent: U A[w1] R(w1) A[w2] R(w2) U toolResult w2 -> "Tool call interrupted: no result was recorded. It may not have run, or may have run partly; check before repeating it." [is_error]
The first prompt of a session has the agent read a big log file, so that tool result is message 3. Eighteen short questions follow, one model call each, and the record now holds 40 messages. How often was that one result sent and paid for?
About 12,500 tokens, nineteen times: that single result is more than nine tenths of the bill. Lesson 6 made each result smaller; it did nothing about how many there are, or how long they stay. A growing bill is a nuisance. Today the list meets a limit that does not grow.
import harness, lab
ws = lab.Workspace({"big.log": lab.big_log(lines=3000)})
def agent(request):
# reads big.log once, then only ever says "Noted."
if request.last_result is None:
return lab.reply(lab.call("read", {"path": "big.log"}))
return lab.say("Noted.")
model = lab.ScriptedModel([lab.forever(agent)])
h = harness.Harness(model, harness.SYSTEM,
[harness.make_read_tool(ws)])
prompts = ["Read big.log."]
prompts += [f"Short question {n}." for n in range(1, 19)]
for prompt in prompts:
for event in h.prompt(prompt):
pass
result = h.messages[2]
carried = [r for r in model.calls if r.last_result is not None]
print("messages in the record:", len(h.messages))
print("message 3 is a", result["role"], "of about",
lab.count_tokens(result["content"]), "tokens")
print("requests made:", len(model.calls),
"| requests that carried it:", len(carried))
print("bill for the session:", model.bill, "input tokens")
messages in the record: 40 message 3 is a toolResult of about 12510 tokens requests made: 20 | requests that carried it: 19 bill for the session: 254994 input tokens
Prompt nine
Every model has a limit on how much text one request may hold. [general] Real limits run from tens of thousands of tokens to a million or so, and a coding agent that reads files gets there in an afternoon. The lab's model is given 2,000, so that you get there in a minute.
Below is your lesson 11 harness on an ordinary job: twelve files to look at, one prompt each. The first prompt also says what the job is for. Nothing in your code is wrong.
Twelve prompts are coming, all about the same size. The ninth will be refused as too long. Your loop records a refused prompt and its error the way lesson 5 taught it to. How many of the twelve get an answer?
Run the cell below, and count the lines that end in "is noted".
Your harness from lesson 11, unchanged, against a model with context_window=2000. Each line is one prompt: the size of the last request it caused, and what came back.
import harness, lab
GOAL = "Our goal is to index every part file into INDEX.md."
PART = "".join(f"line {n:02d}: the quick brown fox jumps over it\n"
for n in range(1, 13))
ws = lab.Workspace({f"part{i}.txt": PART for i in range(1, 13)})
def reader(request):
"""Reads the file that the newest prompt names, then says so."""
messages = request.messages
newest = max(i for i, m in enumerate(messages) if m["role"] == "user")
name = messages[newest]["content"].split()[-1]
if not any(m["role"] == "toolResult" for m in messages[newest:]):
return lab.reply(lab.call("read", {"path": name}))
return lab.say(f"{name} is noted.")
model = lab.ScriptedModel([lab.forever(reader)], context_window=2000)
h = harness.Harness(model, harness.SYSTEM, [harness.make_read_tool(ws)])
for i in range(1, 13):
ask = (GOAL + " " if i == 1 else "") + f"Now read part{i}.txt"
for event in h.prompt(ask):
pass
last = h.messages[-1]
said = last.get("error_message") or harness.text_of(last)
print(f"prompt {i:2}: {last['usage']['input']:5} tokens sent | {said}")
prompt 1: 334 tokens sent | part1.txt is noted. prompt 2: 555 tokens sent | part2.txt is noted. prompt 3: 775 tokens sent | part3.txt is noted. prompt 4: 996 tokens sent | part4.txt is noted. prompt 5: 1217 tokens sent | part5.txt is noted. prompt 6: 1438 tokens sent | part6.txt is noted. prompt 7: 1658 tokens sent | part7.txt is noted. prompt 8: 1879 tokens sent | part8.txt is noted. prompt 9: 2100 tokens sent | prompt is too long: 2100 tokens > 2000 prompt 10: 2113 tokens sent | prompt is too long: 2113 tokens > 2000 prompt 11: 2125 tokens sent | prompt is too long: 2125 tokens > 2000 prompt 12: 2138 tokens sent | prompt is too long: 2138 tokens > 2000
Eight prompts fit. The ninth is refused, and so are the tenth, the eleventh and the twelfth: a refused prompt still joins the list, and the list only grows. The limit has a name, the
Where this failure comes from: prompt is too long is one of twelve phrases Tau searches a provider's error text for, to recognise exactly this refusal (src/tau_coding/session.py:4025-4043).
Something has to go. The goal was stated once, in message 1. The newest tool result holds part8.txt, and the user's next prompt is "Add the first line of the part you just read to INDEX.md." What do you throw away? Pick one. The cell then runs all three against that prompt.
(a) lost the goal, and its list opens on a result whose call is gone: a provider refuses that, and your lesson 10 repair avoids the refusal only by throwing the result away. (b) kept the goal and lost the file, so the agent paid for a second model call to read part8.txt again. (c) is the only run in which the agent simply did the job. Old context is worth its gist; recent context is worth its exact words.
Where this failure comes from: Tau's cut function walks back by a budget and then has to step forward, because a count of messages or of tokens can stop between a call and its result (src/tau_coding/session.py:3977-4011).
import harness, lab
GOAL = "Our goal is to index every part file into INDEX.md."
PROMPT = "Add the first line of the part you just read to INDEX.md."
FILES = {}
for i in range(1, 9):
FILES[f"part{i}.txt"] = "".join(
f"part {i} line {n:02d}: the quick brown fox\n"
for n in range(1, 13))
record = [] # the eight prompts that fitted under the wall
for i in range(1, 9):
name = f"part{i}.txt"
call = lab.call("read", {"path": name}, id=f"c{i}")
ask = (GOAL + " " if i == 1 else "") + f"Now read {name}"
record += [lab.user(ask), lab.reply(call),
lab.tool_result(call, FILES[name]),
lab.say(f"{name} is noted.")]
def worker(request): # it knows only what this request shows it
if "index every part file" not in request.user_text:
return lab.say("Add it to what? Nothing here says "
"what we are working on.")
seen = [m for m in request.messages
if m["role"] == "toolResult" and "part 8" in m["content"]]
if not seen:
again = lab.call("read", {"path": "part8.txt"}, id="again")
return lab.reply(again)
return lab.say("Indexed: " + seen[-1]["content"].splitlines()[0])
def trial(label, view):
ws = lab.Workspace(FILES)
model = lab.ScriptedModel([lab.forever(worker)])
h = harness.Harness(model, harness.SYSTEM,
[harness.make_read_tool(ws)], messages=view)
for event in h.prompt(PROMPT):
pass
verdict = "; ".join(lab.validate(view)) or "accepts it"
print(f"{label}: {lab.shape(view)}")
print(" a provider on that list:", verdict)
print(" your harness sent :",
lab.shape(model.calls[0].messages))
print(" the agent :",
harness.text_of(h.messages[-1]))
print(" model calls:", len(model.calls),
"| files read again:", ws.reads)
print()
goal = ("Previous summary: the goal is to index every part file "
"into INDEX.md. ")
all_8 = lab.user(goal + "Parts 1 to 8 are read and noted.")
first_7 = lab.user(goal + "Parts 1 to 7 are read and noted.")
trial("(a) the newest 6 messages", record[-6:])
trial("(b) a summary of everything", [all_8])
trial("(c) a summary, then the last prompt and its work",
[first_7] + record[-4:])
(a) the newest 6 messages: R(c7) A U A[c8] R(c8) A
a provider on that list: messages[0]: toolResult c7 has no toolCall before it
your harness sent : A U A[c8] R(c8) A U
the agent : Add it to what? Nothing here says what we are working on.
model calls: 1 | files read again: []
(b) a summary of everything: U
a provider on that list: accepts it
your harness sent : U U
the agent : Indexed: part 8 line 01: the quick brown fox
model calls: 2 | files read again: ['part8.txt']
(c) a summary, then the last prompt and its work: U U A[c8] R(c8) A
a provider on that list: accepts it
your harness sent : U U A[c8] R(c8) A U
the agent : Indexed: part 8 line 01: the quick brown fox
model calls: 1 | files read again: []
Where does the summary live?
So: a summary of the old part, then the recent messages as they were. Never mind yet who writes it; that question gets a section of its own further down. Now remember what you built in lesson 11. session.jsonl is append-only. You may add a line. You may never change one or remove one. That rule is the reason a power cut could not hurt you, and it is not up for negotiation today.
Tomorrow a new process will resume this session from the file alone. It must come up with the summary plus the recent messages, not with all 32 messages of the eight prompts that fitted. You may only append. What do you append, and what does replay() have to learn?
The summary's text is one thing. How will the reader of the file know which old lines it stands in for?
Append one line of a new type. It is not a message. It is an instruction to whoever reads the file. For a small log of nine lines, e1 to e9, it looks like this:
{"id": "e10", "type": "compaction", "summary": "...", "first_kept_id": "e6"}
It says: read the lines before e6 as this summary. replay() learns one rule. When it meets such an entry, it takes the messages it has collected so far, puts the summary in place of those before first_kept_id, and keeps the rest as they are. Lines after the entry are added as before.
The trade calls this
Common answers, and what each one misses
- "Rewrite the file as summary plus recent messages." That is lesson 11's crash window again, and worse: if the summary turns out to be bad, the text it was made from is gone.
- "Append the summary as an ordinary user message." Then replay returns all 32 messages and one more. The entry has to say where reading starts, which is what
first_kept_idis for. - "Leave the log alone and keep the short list in a second file." Now two files claim to be the session. Lesson 2 had a second copy of the tool calls, and you saw the day it disagreed.
Take that log of nine lines, e1 to e9. You compact it: a summary of e1 to e5, recorded in the way just described. How many lines does the file hold afterwards?
Nine, and the new one. The name misleads: nothing on disk gets more compact, and the file gets one line longer. If your number was smaller than nine, something got deleted on the way, and the rule was append only.
The same file. The new line says "first_kept_id": "e6". How many messages does replay() return now?
The summary, then e6, e7, e8 and e9. The entry is not a message in the file. It becomes one when it is read. The cell below prints both numbers.
Both numbers as program output: nine messages are logged, one compaction entry is appended, and the file and replay() are printed. append_compaction is given to you in lab 2. The rule that reads the entry back is the reference one for now; once lab 2 passes, this cell runs on yours.
import json
import harness, lab
c1 = lab.call("read", {"path": "a.py"}, id="c1")
c2 = lab.call("read", {"path": "b.py"}, id="c2")
c3 = lab.call("bash", {"command": "pytest"}, id="c3")
session = [
lab.user("Our goal: make a.py and b.py agree. Read both."), # e1
lab.reply(c1, c2), # e2
lab.tool_result(c1, "A = 1\n"), # e3
lab.tool_result(c2, "B = 2\n"), # e4
lab.say("They differ in one name."), # e5
lab.user("Now run the tests."), # e6
lab.reply(c3), # e7
lab.tool_result(c3, "3 passed\n"), # e8
lab.say("Green."), # e9
]
ws = lab.Workspace()
log = harness.SessionLog(ws, "session.jsonl")
for message in session:
log.append_message(message)
before = ws.read_text("session.jsonl")
log.append_compaction(
"Goal: make a.py and b.py agree. Both read; they differ in one name.", "e6")
after = ws.read_text("session.jsonl")
print("lines in the file:", len(before.splitlines()), "->", len(after.splitlines()))
print("every old line still there, unchanged:", after.startswith(before))
print("the new line:")
for key, value in json.loads(after.splitlines()[-1]).items():
print(f" {key}: {value!r}")
print()
print("replay():", len(log.replay()), "messages,", lab.shape(log.replay()))
print(lab.show(log.replay()))
print()
tomorrow = harness.SessionLog(ws.reboot(), "session.jsonl")
print("a new process replays the same list:", tomorrow.replay() == log.replay())
lines in the file: 9 -> 10
every old line still there, unchanged: True
the new line:
id: 'e10'
type: 'compaction'
summary: 'Goal: make a.py and b.py agree. Both read; they differ in one name.'
first_kept_id: 'e6'
replay(): 5 messages, U U A[c3] R(c3) A
user -> "Previous conversation summary:\nGoal: make a.py and b.py agree. Both read; they d... (98 characters)"
user -> "Now run the tests."
assistant -> toolCall c3 bash({"command": "pytest"})
toolResult c3 -> "3 passed\n"
assistant -> "Green."
a new process replays the same list: True
The file grew by a line and lost none. What shrank is what gets read out of it. On the way out the summary became a user message that begins Previous conversation summary:, which are Tau's words too (src/tau_agent/session/memory.py:200-201). And a process that starts tomorrow reads the same five messages, because it follows the same rule over the same lines.
Where to cut
Which line should first_kept_id name? "The last prompt and its work" will not do as a rule: that can be forty tokens or fourteen hundred, and room is the whole point. So the tail gets a budget in tokens. Walk back from the newest line, add up sizes, stop when the budget is spent. The walk can add, and that is all it can do. Give it 35 tokens on the nine-line log above and it stops on e4.
In that log e2 is the assistant asking for calls c1 and c2, and e3 and e4 are their results. Cut at e4: lines e1 to e3 are summarised and the kept tail opens on the result for c2. The summary even says "the agent called read on a.py and on b.py". What does a strict provider make of that list?
toolCall block. Lesson 2: prose is not protocol. A result is paired with a call by id, and a summary has no ids.b.py said. Repair is for accidents. A cut you choose yourself should not need one.The rule from lesson 4 again: one call in, exactly one result out, right after it, and a cut between a call and its result breaks the pair as surely as a crash does, only this time on purpose. Sent as it is, the list earns a 400; sent through your harness, the repair quietly deletes the orphan, and a model that could have quoted b.py has to read it again. So a cut may not land just anywhere.
Where this failure comes from: the last lines of Tau's cut function exist to step past tool results (src/tau_coding/session.py:4008-4011).
import harness, lab
c1 = lab.call("read", {"path": "a.py"}, id="c1")
c2 = lab.call("read", {"path": "b.py"}, id="c2")
c3 = lab.call("bash", {"command": "pytest"}, id="c3")
session = [
lab.user("Our goal: make a.py and b.py agree. Read both."),
lab.reply(c1, c2),
lab.tool_result(c1, "A = 1\n"),
lab.tool_result(c2, "B = 2\n"),
lab.say("They differ in one name."),
lab.user("Now run the tests."),
lab.reply(c3),
lab.tool_result(c3, "3 passed\n"),
lab.say("Green."),
]
summary = lab.user(
"Previous conversation summary:\n"
"Goal: make a.py and b.py agree. "
"The agent called read on a.py and on b.py.")
# the cut is at e4: session[3] is the first message kept
view = [summary] + session[3:] + [lab.user("What did b.py say?")]
print("the view :", lab.shape(view))
def answers(request): # it knows only what this request shows it
seen = [m for m in request.messages
if m["role"] == "toolResult" and "B = 2" in m["content"]]
if seen:
return lab.say("b.py says: " + seen[-1]["content"].strip())
return lab.say("I can't see b.py's text any more. "
"Reading it again.")
model = lab.ScriptedModel([lab.forever(answers)])
reply = model.complete(harness.SYSTEM, view, [])
print("sent as it is:", reply.get("error_message"))
repaired = harness.context_for_model(view)
reply = model.complete(harness.SYSTEM, repaired, [])
print("through lesson 10's repair:", lab.shape(repaired))
print("the model :", harness.text_of(reply))
uncut = session + [lab.user("What did b.py say?")]
reply = model.complete(harness.SYSTEM, uncut, [])
print("the same model, uncut list:", harness.text_of(reply))
the view : U R(c2) A U A[c3] R(c3) A U sent as it is: 400 invalid_request: messages[1]: toolResult c2 has no toolCall before it through lesson 10's repair: U A U A[c3] R(c3) A U the model : I can't see b.py's text any more. Reading it again. the same model, uncut list: b.py says: B = 2
The cut cannot stay on a tool result. It has to move to a line where a tail can safely begin. Try a busier log, e1 to e11. In shorthand it is U A[c1] R(c1) A U A[c2,c3] R(c2) R(c3) A U A, and this time the walk back stops on e8, the result for c3. Remember why there is a budget at all. Which line should the kept tail start on? Type its number: 4 for e4.
Three lines had a case. Back to e6 keeps the calls with their results, and keeps more than the budget allowed; the wall is why you are here, so the cut never moves back. Forward to e9 satisfies a provider, but e9 is the last sentence of an answer whose question and evidence would be gone. e10 is where the user speaks next, so a prompt, the work it caused and its answer all stay on one side of the cut.
Careful with the word "turn". Lesson 7's events count one turn per model call, and by that count e9 starts one. The unit that matters here is bigger: everything from one user prompt up to the next. Only when no user message is left, as in one long run of tool calls, do you settle for second best and step forward past the results.
Both logs through the reference find_cut, with a budget of 35 tokens: the sizes the walk back adds up, the line it stops on, and the line the cut moves to. Once lab 2 has passed, this cell runs on your find_cut.
import harness, lab
def log_of(messages):
return [(f"e{n}", m) for n, m in enumerate(messages, start=1)]
def walk_back(rows, budget): # the given half of find_cut, alone
cut, kept = len(rows), 0
while cut > 0 and kept < budget:
cut -= 1
kept += harness.estimate_tokens([rows[cut][1]])
return cut
def report(rows, budget):
messages = [m for _, m in rows]
sizes = [harness.estimate_tokens([m]) for m in messages]
stop = walk_back(rows, budget)
cut = harness.find_cut(rows, budget)
print(lab.shape(messages))
print(" sizes in tokens :", sizes)
print(f" a budget of {budget} stops on:", rows[stop][0])
print(" find_cut keeps from :", rows[cut][0])
print()
a1 = lab.call("read", {"path": "a.py"}, id="c1")
a2 = lab.call("read", {"path": "b.py"}, id="c2")
a3 = lab.call("bash", {"command": "pytest"}, id="c3")
report(log_of([
lab.user("Our goal: make a.py and b.py agree. Read both."),
lab.reply(a1, a2), lab.tool_result(a1, "A = 1\n"),
lab.tool_result(a2, "B = 2\n"),
lab.say("They differ in one name."),
lab.user("Now run the tests."),
lab.reply(a3), lab.tool_result(a3, "3 passed\n"),
lab.say("Green."),
]), 35)
b1 = lab.call("read", {"path": "app.py"}, id="c1")
b2 = lab.call("read", {"path": "util.py"}, id="c2")
b3 = lab.call("read", {"path": "test_app.py"}, id="c3")
report(log_of([
lab.user("Our goal: find why add() is wrong. Read app.py."),
lab.reply(b1), lab.tool_result(b1, "def add(a, b): return a - b\n"),
lab.say("add() subtracts."),
lab.user("Check the other two files as well."),
lab.reply(b2, b3), lab.tool_result(b2, "import app\n"),
lab.tool_result(b3, "assert app.add(2, 2) == 4\n"
"assert app.add(0, 1) == 1\n"),
lab.say("Nothing else is wrong."),
lab.user("So what is the fix, in one line?"),
lab.say("Return a + b."),
]), 35)
U A[c1,c2] R(c1) R(c2) A U A[c3] R(c3) A
sizes in tokens : [13, 14, 4, 4, 8, 6, 9, 5, 4]
a budget of 35 stops on: e4
find_cut keeps from : e6
U A[c1] R(c1) A U A[c2,c3] R(c2) R(c3) A U A
sizes in tokens : [13, 9, 10, 6, 10, 16, 5, 16, 8, 9, 6]
a budget of 35 stops on: e8
find_cut keeps from : e10
Figure 12.1 draws the same move on the nine-line log from before: the budget lands on e4, the cut snaps to e6, and then the entry is written and read back.
Figure 12.1 The file, which only grows, and what replay() makes of it. The dimmed lines are still in the file; they are read through the summary.
- The budget,
keep_recent_tokens, reaches back to e4: a tool result whose call, in e2, would be cut off from it. The dashed line is where the arithmetic wanted to cut. - The cut snaps forward to the next user message.
first_kept_idis"e6", the solid line. - One line is appended: e10, of type
compaction. Lines e1 to e5 dim, and stay in the file. log.replay(): the harness is handed the summary, read from e10 and placed first as a user message, then the kept tail e6 to e9 word for word. That is what the next request is built from. Only the summary moves.- Later messages, e11 and e12, are appended as always and replayed after the tail.
find_cut can also end up at either end of the log. If the walk back never spends its budget, everything fits, and the answer is 0. If the snap forward runs off the end, as it does in a session that is one prompt and one enormous result, the answer is len(rows).
find_cut returns an index: the rows before it are summarised, the rows from it on are kept. Tap every answer for which writing an entry would be a mistake.
0- an index in the middle, on a user message
len(rows)
A cut at 0 summarises nothing, and an entry that summarises nothing buys nothing. A cut at len(rows) keeps nothing, and an entry that keeps nothing is option (b) from the opening. Neither plan earns an entry. Refuse both, before any model is asked for a summary.
Who writes the summary?
A model does. It is good at it, and you have one to hand, fully set up: a system prompt, a tool list, a transcript. The obvious move is to add "Summarise the conversation so far." to the list and send it like any other prompt.
The session below ends on a question that has been answered ("Is config.py still on port 9090?" "Yes, 9090."). You add "Summarise the conversation so far." and send the list with the agent's own system prompt and tools. What comes back?
The first reply answers the session's last question over again. In the second run there is no question to answer, so the model calls a tool, and nobody is there to run it. The printed cost is the other half of the lesson: a summary call sends everything it summarises once more, and the lab bills all of it. [general] A real model often does summarise when asked like this, and sometimes does not; "often" is not what you build housekeeping on.
Where this failure comes from: Tau's system prompt for this call reads "Do NOT continue the conversation. Do NOT respond to any questions in the conversation." (src/tau_coding/context_window.py:26-32). Nobody writes that before it has happened to them.
import harness, lab
read = lab.call("read", {"path": "config.py"}, id="c1")
conversation = [
lab.user("Our goal is to move the service to port 8080. "
"Start by reading config.py."),
lab.reply(lab.text("Reading it."), read),
lab.tool_result(read, "HOST = 'localhost'\nPORT = 9090\n"),
lab.say("config.py sets PORT = 9090."),
lab.user("Is config.py still on port 9090?"),
lab.say("Yes, 9090."),
]
read_tool = harness.make_read_tool(lab.Workspace())
tools = harness.tool_specs([read_tool])
ask = lab.user("Summarise the conversation so far.")
model = lab.ScriptedModel([lab.summariser()])
reply = model.complete(harness.SYSTEM, conversation + [ask], tools)
print(lab.show([reply]))
print("cost of asking:", reply["usage"]["input"], "input tokens:",
"all", len(conversation) + 1, "messages, sent again")
print()
# a younger session, with no question in it
fresh = [lab.user("Our goal is to move the service to port 8080."),
lab.say("Understood.")]
reply = model.complete(harness.SYSTEM, fresh + [ask], tools)
print(lab.show([reply]))
assistant -> "You asked: Is config.py still on port 9090? My answer: yes. (Not a summary.)"
cost of asking: 295 input tokens: all 7 messages, sent again
assistant -> toolCall c1 read({})
The model in that cell is a stage prop, lab.summariser(). This is its rule, as its own docstring states it:
Stage prop: a model that summarises only when the request leaves it nothing else to do. If the system prompt mentions "summar", the last message starts with <conversation>, and tools == [], it replies "Summary of N lines. It began: <first line> It ended: <last line>". Otherwise it carries on as an assistant: it answers the last line of user text containing "?", or else calls the first tool it was offered (once).
Its answer to every question is "yes", which is why the cell asked it a yes-or-no one. With no question and no tool left to call, it says "Done. (Not a summary.)"
Read that rule as a design, because a real model takes the same three cues, only less predictably. The system prompt told it who it is: an assistant on a job. The list of messages told it whose move it is: a conversation, with its reply due next. The tools told it what it could do about that. Take all three away: a system prompt of its own, the conversation quoted as a document inside one user message, and an empty tool list. Now the only thing left to do is summarise.
And the call belongs to no transcript. The user never asked for a summary and the agent never gave one, so neither the question nor the answer goes anywhere near the session's list.
Two lists again
Say the entry is written. The harness in this process still has the long list in its hands. The new line is in the file, and nothing has told the harness.
The compaction entry is in the log. The running harness still holds the long list. How do you bring it up to date?
The messages in the live list carry no ids, so the hand edit in (a) read "e10" as a position, and after an earlier compaction ids and positions no longer match: today's process holds a summary and nothing else, while tomorrow's resume gets the tail as well. You could write (a) without that bug, and you would then own two copies of one rule and the job of keeping them in step for good. (b) cannot disagree with a resume, because it is a resume. (c) is refused by the one door the harness offers, replace_messages, which is shut while a run is active; that is why this housekeeping happens between prompts and never during one.
Where this failure comes from: Tau does not edit its live list either. It appends the entry, rebuilds its state from the session file and hands the harness the result (src/tau_coding/session.py:3955-3974).
import harness, lab
PREFIX = "Previous conversation summary:\n"
def turn(n):
return [lab.user(f"Question {n}."), lab.say(f"Answer {n}.")]
ws = lab.Workspace()
log = harness.SessionLog(ws, "session.jsonl")
for message in turn(1) + turn(2) + turn(3): # e1 .. e6
log.append_message(message)
log.append_compaction("Questions 1 and 2 were answered.", "e5") # e7
for message in turn(4) + turn(5): # e8 .. e11
log.append_message(message)
live = log.replay() # what the running harness holds
print("the live list:", lab.shape(live))
print(" (a summary, then e5, e6, e8 .. e11)")
new = "Questions 1 to 4 were answered."
log.append_compaction(new, "e10") # e12, just now
# (a) by hand: "e10 is the tenth message". One natural way to
# write (a); the bug is the ordinary kind.
by_hand = [harness.user_message(PREFIX + new)] + live[10 - 1:]
print("(a) by hand :", lab.shape(by_hand))
print("(b) replay() :", lab.shape(log.replay()))
resumed = harness.SessionLog(ws.reboot(), "session.jsonl").replay()
print("tomorrow's resume equals (a):", resumed == by_hand,
"| equals (b):", resumed == log.replay())
def meddle(arguments): # (c) swap at once, from inside a run
try:
h.replace_messages(log.replay())
except RuntimeError as error:
return f"refused: {error}"
return "swapped"
tool = {"name": "meddle", "description": "", "parameters": {},
"execute": meddle}
model = lab.ScriptedModel(
[lab.reply(lab.call("meddle", id="m1")), lab.say("Done.")])
h = harness.Harness(model, harness.SYSTEM, [tool], messages=live)
for event in h.prompt("Question 6."):
pass
print("(c) mid-run :", h.messages[-2]["content"])
the live list: U U A U A U A
(a summary, then e5, e6, e8 .. e11)
(a) by hand : U
(b) replay() : U U A
tomorrow's resume equals (a): False | equals (b): True
(c) mid-run : refused: cannot replace messages during a run
When housekeeping fails
One question is left: when? Not during a run; the last rung settled that. Not after the refusal either: you saw in the opening run that a refused prompt has already joined the list. So it happens just before each prompt, if the list looks close to the wall. That makes compaction a chore done on the user's time, with a model call in it, and model calls fail. You met that in lesson 5.
The user types "Step 4." Before sending it, your code tries to compact, and the summary call comes back 503 overloaded. What should happen to the user's prompt?
Raising cost the user their prompt: no model call was made for them at all. Writing the empty summary answered today's prompt from a view in which the goal already reads as nothing, and every resume after it gets the same view (the goal is still in the file, which comforts you and does not help the model). Skipping lost nothing. If the wall really is reached, the user will see that error, and it will at least be about something they did.
Where this failure comes from: the comment on Tau's handler reads "automatic compaction must not lose a turn" (src/tau_coding/session.py:3706-3714).
import harness, lab
def session():
log = harness.SessionLog(lab.Workspace(), "session.jsonl")
log.append_message(lab.user("Our goal is port 8080. Step 1."))
log.append_message(lab.say("Step 1 is done."))
for n in (2, 3):
log.append_message(lab.user(f"Step {n}."))
log.append_message(lab.say(f"Step {n} is done."))
return log
def before_prompt(policy, log, summaries):
"""Housekeeping, run just before the user's prompt is sent."""
rows = log.rows()
old = [message for _, message in rows[:4]]
try:
summary = harness.summarize(summaries, old)
except RuntimeError:
if policy == "raise":
raise
if policy == "skip":
return
summary = "" # "write what we got"
log.append_compaction(summary, rows[4][0])
for policy in ("raise", "write what we got", "skip"):
log = session()
agent = lab.ScriptedModel([lab.say("Step 4 is done.")])
summaries = lab.ScriptedModel([lab.fail("503 overloaded")])
try:
before_prompt(policy, log, summaries)
h = harness.Harness(agent, harness.SYSTEM, [],
messages=log.replay())
for event in h.prompt("Step 4."):
pass
outcome = harness.text_of(h.messages[-1])
except RuntimeError as error:
outcome = f"RuntimeError: {error}"
print(f"{policy!r}:")
print(" the user typed 'Step 4.' and got:", outcome)
print(" model calls for the user:", len(agent.calls))
print(" a resume tomorrow starts from:",
lab.show(log.replay()[:1]))
print()
'raise':
the user typed 'Step 4.' and got: RuntimeError: no summary: 503 overloaded
model calls for the user: 0
a resume tomorrow starts from: user -> "Our goal is port 8080. Step 1."
'write what we got':
the user typed 'Step 4.' and got: Step 4 is done.
model calls for the user: 1
a resume tomorrow starts from: user -> "Previous conversation summary:\n"
'skip':
the user typed 'Step 4.' and got: Step 4 is done.
model calls for the user: 1
a resume tomorrow starts from: user -> "Our goal is port 8080. Step 1."
Build: make room, lose nothing
The rungs took designs away one at a time: keep-the-newest, a summary alone, a rewrite of the file, a cut in the wrong place, a summary asked for in the wrong voice, a second copy of the rule, a failure that eats a prompt. What is left is small. Most of the plumbing is given, and marked # given in lesson 12 in the diff.
Write summarize(model, messages), about eight lines. It returns a summary of messages as a string, from one call of model.complete that is no part of any transcript. The call is built so that the model has nothing to do but summarise:
- the system prompt is
SUMMARY_SYSTEM, not the agent's; - it sends one user message: the line
<conversation>, thenrender(messages), then the line</conversation>; - it offers no tools:
[].
Return the reply's text, stripped. If the reply failed (stop_reason is "error") or has no text, raise RuntimeError: a caller must never mistake nothing for a summary. No retries, and messages is left as it was.
Given in your starter: render, the transcript as plain text with one line per message; estimate_tokens; and SUMMARY_SYSTEM. The model in the tests is lab.summariser(). Build the call wrong and it answers the conversation's question instead.
- The model in "Who writes the summary?" had three reasons to carry on the conversation: who it was told it is, whose move it was, and what it could do.
model.complete(system, messages, tools)takes three arguments. Which argument removes which reason? - One string: the opening tag on a line of its own, the rendered conversation, the closing tag on a line of its own. That string, as a user message, is the only message you send, with the summary's system prompt and an empty tool list. Append nothing to
messages, and do not go throughrun_agent: this is a question put to a model, not a step of the agent. Then look at the reply before you trust it. Two things can be wrong with it. - In outline.
wrapped= a user message holding three parts joined by newlines: the opening tag,render(messages), the closing tag.reply= onemodel.completewith the summary system prompt, a list holding onlywrapped, and no tools.summary= the reply's text with the white space trimmed. If the reply'sstop_reasonis"error", orsummaryis empty: raiseRuntimeErrorsaying why (the reply'serror_messagehelps). Otherwise returnsummary.
One question, put to a model, outside every transcript. The conversation went in as a quoted document and a summary came out. Look at what you did not write: a loop, an event, an append.
What changed since lesson 11
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- lab.summariser() answers the conversation's last question unless the call is built right. One call: SUMMARY_SYSTEM as the system prompt, tools == [], and one user message, render(messages) wrapped in <conversation> tags. What comes back is a summary.
- After summarize(model, messages), `messages` is exactly what it was: the question and its answer went nowhere near the transcript.
- The provider answers the summary call with a 503, and then with an empty reply: each time summarize raises RuntimeError and returns nothing.
Three gaps, about 22 lines in all. Each one is a rung you have already argued your way through.
find_cut(rows, keep_recent_tokens). The walk back is given. Yours is the snap forward: fromcut, move to the next row that is a user message (rows[cut]itself counts). If there is none, move past anytoolResultrows instead. Never backwards. The answer may belen(rows).SessionLog.rows(). This is where the replay rule lives, sincereplay()only readsrows(). Lesson 11's one line becomes a loop overentries(). A message entry adds a row. A compaction entry turns the rows so far into one summary row,(the entry's own id, user_message("Previous conversation summary:\n" + summary)), followed by the rows fromfirst_kept_idon, or by none if no row has that id. Every row needs an id, so that a later cut can name it; the summary's row borrows the id of the entry it was read from.compact(log, model, *, keep_recent_tokens), returningTrueorFalse. Plan fromlog.rows()andfind_cut. If the cut is 0 orlen(rows), returnFalsewithout calling the model. Summarise the rows before the cut with yoursummarize; if it raisesRuntimeError, returnFalsewith nothing appended. Otherwise calllog.append_compaction(summary, first_kept_id)with the id of the row at the cut, and returnTrue.
compact never touches the harness. The given maybe_compact does that, before a prompt and never during a run. It asks one question: is the transcript within reserve tokens of the window? The reserve is the room the next prompt, its tool results and the replies will need. In the tests it is generous, 900 of 2,000, because the measuring stick is crude: estimate_tokens counts four characters to a token and sees neither the system prompt nor the tool definitions. If the answer is yes, it calls your compact and hands the harness log.replay() through replace_messages. append_compaction and replace_messages are given too.
- Three questions, one per gap. In
find_cut: which way may the cut move if the tail must never be bigger than the budget? Inrows(): when the loop meets a compaction entry, which of the rows collected so far survive, and what goes in front of them? Incompact: if you returnFalse, what must be true of the file? find_cut: search from the cut to the end for a user message and stop at the first one; only if that search finds none, move the cut forward while it sits on a tool result.rows(): build the list entry by entry. A message entry adds a row. A compaction entry replaces what you have built so far: find wherefirst_kept_idsits among the ids collected, keep from there on (or nothing, if it is not there), and put the summary row in front. Return a list, not a generator.compact: get the rows, get the cut, make the two refusals before any model call, summarise the messages before the cut with the failure caught, then one append.- In outline.
rows(): rows = empty; for each entry: a message adds (its id, its message); a compaction sets kept = the rows from the one whose id equalsfirst_kept_id, or none, and then rows = [(the entry's id, a user message of"Previous conversation summary:\n"+ its summary)] + kept.compact(): if the cut is 0 orlen(rows), returnFalsewith nothing asked of the model; trysummarizeon the messages of the rows before the cut, and onRuntimeErrorreturnFalsewith nothing appended; thenappend_compaction(summary, the id carried by the row at the cut)and returnTrue. That id isrows[cut][0]. It is notf"e{cut + 1}": after a first compaction, ids and positions part company.
Twenty prompts under a 2,000-token window, no refusals, and the goal from message 1 still in the last request. The file holds every line it ever held, and a fresh process replays exactly what your harness has in its hands. The wall has not moved. Your requests now stay under it.
What changed since the previous lab
The line-by-line diff needs JavaScript. The whole file this exercise starts from is printed at the end of it.
- The walk back is made to stop at each of eleven rows in turn. The cut moves forward to the next user message; where there is none, it moves past tool results; it never moves back, and with room for everything it is 0.
- Eleven messages, then append_compaction("...", "e6"), then two more messages: replay() is the summary as a user message, then e6 to e11, then the two later ones, and no line of the file has changed.
- A five-turn session is compacted with room for about one turn: compact returns True, the file is what it was plus one line, only the old rows were sent to be summarised, and replay() is the summary then a tail that starts on a user message.
- A session that fits in keep_recent_tokens, and a session that is one enormous tool result: compact returns False, appends nothing, and never calls the model.
- Twenty prompts, each reading a file, under context_window=2000, with maybe_compact before each prompt and lab.summariser() writing the summaries. No request is refused as too long, the record is valid after every compaction, the goal from message 1 is still in the last request, and a fresh SessionLog replays exactly list(h.messages).
- The same session with a summariser that always answers 503: maybe_compact returns False without raising, nothing is appended for it, and every prompt is still put to the model.
- A tool calls h.replace_messages([]) while the run is going on: RuntimeError, and the record is untouched. Between runs the same call swaps the transcript.
Forty files, one prompt each, under the same 2,000-token window, with maybe_compact before every prompt. No tests. Once lab 2 has passed, this runs on your code. Three things to look at: how many prompts were answered, the largest request, and lines in the log against messages in the record.
import harness, lab
GOAL = "Our goal is to index every part file into INDEX.md."
PART = "".join(f"line {n:02d}: the quick brown fox jumps over it\n"
for n in range(1, 13))
ws = lab.Workspace({f"part{i}.txt": PART for i in range(1, 41)})
def reader(request):
"""Reads the file that the newest prompt names, then says so."""
messages = request.messages
newest = max(i for i, m in enumerate(messages) if m["role"] == "user")
name = messages[newest]["content"].split()[-1]
if not any(m["role"] == "toolResult" for m in messages[newest:]):
return lab.reply(lab.call("read", {"path": name}))
return lab.say(f"{name} is noted.")
agent = lab.ScriptedModel([lab.forever(reader)], context_window=2000,
max_calls=100)
summaries = lab.ScriptedModel([lab.summariser()])
h = harness.Harness(agent, harness.SYSTEM, [harness.make_read_tool(ws)])
log = harness.SessionLog(ws, "session.jsonl")
h.subscribe(harness.persist_to(log))
compacted, replies = [], []
for i in range(1, 41):
if harness.maybe_compact(h, log, summaries, window=2000, reserve=900,
keep_recent_tokens=300):
compacted.append(i)
ask = (GOAL + " " if i == 1 else "") + f"Now read part{i}.txt"
for event in h.prompt(ask):
if event["type"] == "turn_end":
replies.append(event["message"])
refused = [m for m in replies if m.get("stop_reason") == "error"]
largest = max(m["usage"]["input"] for m in replies)
print("prompts answered:", 40 - len(refused), "of 40")
print("compacted before prompts:", compacted)
print("largest request :", largest, "tokens")
print("lines in the log:", len(log.entries()),
"| messages in the record:", len(h.messages))
print("log replays as the record:", log.replay() == list(h.messages))
print()
first = agent.calls[-1].messages[0]["content"]
print("the last request began with:", repr(first[:31]),
f"({len(first)} characters)")
at = first.find(GOAL)
print("and inside it, six summaries deep:")
print(" ", repr(first[at - 16:at + len(GOAL)]) if at > 0 else "no goal at all")
prompts answered: 40 of 40
compacted before prompts: [8, 14, 20, 26, 32, 38]
largest request : 1817 tokens
lines in the log: 166 | messages in the record: 17
log replays as the record: True
the last request began with: 'Previous conversation summary:\n' (729 characters)
and inside it, six summaries deep:
'It began: user: Our goal is to index every part file into INDEX.md.'
166 lines in the file, 17 messages in the record, and they agree, because the second is read from the first. The opening run died at prompt nine. This one is still answering at forty.
It will not go on for ever, though, and the last lines of the output show why. The goal is in there, six summaries deep. This stage prop summarises by quoting the first and last lines it was shown, so the goal survives by where it stands, and the summary grows a little each round. [general] A real model writes a better summary than this, and tends to lose a little more each time it summarises its own summary. The comparison below shows what Tau does about it.
- Hold a conversation: it knows what was said, and who said it.
- Run a tool the model asks for and show it the result.
- Keep going until the model stops asking.
- Tell the model when a tool fails, and carry on.
- Stop a runaway, and survive a provider failure.
- Keep every tool result within a budget.
- Report what it is doing, as events, to any frontend.
- Own its transcript: one writer at a time.
- Take your input mid-run, at a safe point.
- Send a valid transcript even after an interruption.
- Survive a power cut: an append-only log, resumed by replay.
- Outlast the context window: summarise the old, keep the recent word for word, delete nothing.
A colleague says: "Compaction? That is where the agent deletes old messages to free up space." In a sentence or two, what would you tell them happened to the file, and to what the model is sent?
How many lines did the file hold before and after? What does replay() do differently now?
Nothing was deleted. One line was added, and it changed how the lines before it are read: up to a certain id, as a summary; from that id on, word for word. The record is not the view, one level up from lesson 10. The file keeps everything that happened, and the list in the harness's hands is computed from it.
Common answers, and what each one misses
- "It deletes the old messages and keeps a summary of them." Count the lines: 166 after forty prompts, none changed. If the summary is bad, the text it was made from is still there.
- "It shrinks the log." The log grew. What shrank is what is read out of it and sent.
- "The summary replaces the history." In the view only, and never alone: it stands in front of a tail kept word for word, cut where a user prompt begins. A summary alone was option (b).
Compaction is not deleting history. It is a rule for how to read it.
Tau's compaction is the same two parts as yours: an appended entry holding a summary and a first kept id, and a replay function that reads the rows before that id as one summary message. Nothing leaves the session file.
elif entry["type"] == "compaction":
ids = [row_id for row_id, _ in rows]
first = entry["first_kept_id"]
kept = rows[ids.index(first):] if first in ids else []
summary = user_message("Previous conversation summary:\n"
+ entry["summary"])
rows = [(entry["id"], summary)] + kept
def _apply_compaction(
message_rows: list[tuple[str, AgentMessage]],
entry: CompactionEntry,
...
summary_row = (entry.id, UserMessage(content=_format_compaction_summary(entry.summary)))
...
first_kept_index = next(
(
index
for index, path_entry in enumerate(path_before)
if path_entry.id == entry.first_kept_entry_id
),
None,
)
if first_kept_index is None:
return [summary_row]
...
return [summary_row, *retained]
The same parts, piece by piece. Tau is async; read async for as for until lesson 15.
- The entry (
src/tau_agent/session/entries.py:72-85). - The trigger, tried before each prompt is sent (
src/tau_coding/session.py:3821-3843, called atsrc/tau_coding/session.py:3191) and again once the run has settled (src/tau_coding/session.py:3314), when the user is reading and not waiting. - The cut: walk back, snap forward to a user message, failing that step past tool results (
src/tau_coding/session.py:3977-4011). Tau's version differs in one corner: if the budget reaches back to the very first row, it still cuts at the second user message (src/tau_coding/session.py:3998-4002), where yours refuses. - The two refused plans (
src/tau_coding/session.py:3928-3932). - The one-off summary call with its own system prompt and
tools=[](src/tau_coding/session.py:3860-3865), the conversation wrapped in the same tags (src/tau_coding/context_window.py:284). - Append, replay the file, hand the harness the result (
src/tau_coding/session.py:3955-3974). - A failed compaction swallowed so that the prompt survives (
src/tau_coding/session.py:3706-3714). - The numbers: a 16,384-token reserve and about 20,000 tokens kept, against your 900 and 300 (
src/tau_coding/context_window.py:17-24).
The two reserves do different jobs. Yours mostly covers what estimate_tokens cannot see, plus the prompt about to be added and the work it sets off. [general] On most real providers the limit covers the reply as well as the request, so a real reserve is first of all room for the model to answer. Models also tend to use a very long request less well long before it is refused, which is a second reason real harnesses compact early and not at the last token. Our simplification: the lab's model counts the request only, and refuses it or reads it perfectly.
What Tau adds: knowing where the wall is. Your trigger divides characters by four. Tau's accounting is more careful, and two questions show why. They are about Tau's code, so they are optional and nothing waits on them.
You have no tokenizer. How do you know you are near the wall?
usage on the newest reply. The provider counted exactly
Seven prompts in, your estimate says 1,070 tokens and the provider counted 1,626. Of the 556 missing tokens, 121 are the system prompt and the tool definitions; the rest is framing: keys, quotes, ids, escaped newlines. That gap, plus the prompt about to be added, is why the lab's reserve is 900 tokens out of 2,000. Tau takes the newest usable provider count and estimates only the messages after it (src/tau_coding/context_window.py:210-249).
import harness, lab
PART = "".join(
f"line {n:02d}: the quick brown fox jumps over it\n"
for n in range(1, 13))
ws = lab.Workspace(
{f"part{i}.txt": PART for i in range(1, 8)})
def reader(request):
if request.messages[-1]["role"] == "user":
name = request.messages[-1]["content"].split()[-1]
return lab.reply(lab.call("read", {"path": name}))
return lab.say("Noted.")
model = lab.ScriptedModel([lab.forever(reader)])
h = harness.Harness(model, harness.SYSTEM,
[harness.make_read_tool(ws)])
for i in range(1, 8):
for event in h.prompt(f"Now read part{i}.txt"):
pass
last = model.calls[-1]
extras = lab.render_request(harness.SYSTEM, [], last.tools)
print("estimate_tokens(h.messages) :",
harness.estimate_tokens(h.messages))
print("the provider counted, last request:",
h.messages[-1]["usage"]["input"])
print("system prompt and tool definitions:",
lab.count_tokens(extras),
"tokens that render() never sees")
estimate_tokens(h.messages) : 1070 the provider counted, last request: 1626 system prompt and tool definitions: 121 tokens that render() never sees
A trigger trusts the newest usage the provider reported. It fires and you compact. The kept tail still holds the assistant message that says 1,843 tokens, and nothing new has been sent. What happens at the next check?
Four checks, four compactions, each one a paid summary call. The first did its job, 32 messages became 9; the other three had nothing left to gain, and the record even grew a little (336, 385, 484), because this stage prop's summary of a summary is longer than what it replaced. The 1,843 is a fact about a request made before the first compaction, and it is still the newest figure there is. Tau skips a usage figure when some message standing before it in the list was written after it, and a fresh summary is exactly that: newer than everything it stands in front of (src/tau_coding/context_window.py:186-207).
import harness, lab
PART = "".join(
f"line {n:02d}: the quick brown fox jumps over it\n"
for n in range(1, 13))
ws = lab.Workspace(
{f"part{i}.txt": PART for i in range(1, 9)})
def reader(request):
if request.messages[-1]["role"] == "user":
name = request.messages[-1]["content"].split()[-1]
return lab.reply(lab.call("read", {"path": name}))
return lab.say("Noted.")
def provider_says(messages):
"""The trigger: trust the newest usage reported."""
reported = [m["usage"] for m in messages if "usage" in m]
return reported[-1]["input"]
agent = lab.ScriptedModel([lab.forever(reader)])
summaries = lab.ScriptedModel([lab.summariser()])
h = harness.Harness(agent, harness.SYSTEM,
[harness.make_read_tool(ws)])
log = harness.SessionLog(ws, "session.jsonl")
h.subscribe(harness.persist_to(log))
for i in range(1, 9):
for event in h.prompt(f"Now read part{i}.txt"):
pass
# housekeeping before the next prompt, tried four times
for check in (1, 2, 3, 4):
print(f"check {check}: the provider said",
provider_says(h.messages), "tokens;",
"the record holds",
len(h.messages), "messages, about",
harness.estimate_tokens(h.messages), "by chars/4")
if provider_says(h.messages) > 2000 - 900:
done = harness.compact(log, summaries,
keep_recent_tokens=300)
h.replace_messages(log.replay())
print(" over the threshold ->",
"compacted:", done)
check 1: the provider said 1843 tokens; the record holds 32 messages, about 1223 by chars/4
over the threshold -> compacted: True
check 2: the provider said 1843 tokens; the record holds 9 messages, about 336 by chars/4
over the threshold -> compacted: True
check 3: the provider said 1843 tokens; the record holds 9 messages, about 385 by chars/4
over the threshold -> compacted: True
check 4: the provider said 1843 tokens; the record holds 9 messages, about 484 by chars/4
over the threshold -> compacted: True
Our simplification: the lab's provider also counts by fours, over the JSON it is sent, which is why your estimate can only come out low. [general] Against a real tokenizer, four characters to a token is a rule of thumb for English prose. Code, JSON and most other languages run to more tokens per character, so the error is larger and harder to predict.
What else Tau adds. When the rows to summarise begin with an earlier summary, Tau switches to a different prompt, "update the existing summary", whose first rule is to preserve what is already there (src/tau_coding/context_window.py:62-93, detected at src/tau_coding/context_window.py:340-353). That is the answer to your six-deep summary. If the estimate was wrong and the provider refuses the request anyway, Tau recognises the refusal by its text (src/tau_coding/session.py:4025-4043), compacts, and retries the turn exactly once with continue_() (src/tau_coding/session.py:3251-3309). Your harness has no continue_(), so a prompt that reaches the wall is simply refused. Tau also has a compaction the user can ask for by hand, with a line saying what the summary should focus on (src/tau_coding/context_window.py:292-294). And Tau's sessions can branch, so the boundary is looked up along the active branch only (src/tau_agent/session/memory.py:177-187): a compaction on one branch never touches another.
Declared in Tau, read by nothing. TURN_PREFIX_SUMMARIZATION_PROMPT is defined (src/tau_coding/context_window.py:95-97) and used nowhere in the repository. Splitting one oversized turn is not a feature Tau has today, and this course does not teach it as one.
Where Tau is weaker. Recognising an overflow by matching phrases is fragile by nature, in both directions: a provider rewords its error and the retry stops happening, or an unrelated error happens to contain "token limit" and a compaction is paid for that nothing needed. The text sent to be summarised is not truncated (src/tau_coding/context_window.py:299-319), and it is by construction most of a nearly full context, so the summary call can itself hit the wall. And Tau's replace_messages does not check for a run in progress (src/tau_agent/harness.py:105-106); it relies on its caller to compact only between runs. Yours raises.
Where yours is weaker. The estimate sees only render(messages). There is one summary prompt, so summaries of summaries decay. Nothing retries a refused prompt. A skipped compaction is silent: compact returns False and nothing records why. Tau swallows the failure too, and writes the exception to a diagnostic log on the way (src/tau_coding/session.py:3706-3714), so the error does reach someone who can act on it, only not the user. Your summary call is not truncated either, and the lab's summariser has no limit, so you will never see it overflow here. The trigger runs only between prompts, so one prompt that sets off a long run of tool calls can still reach the wall mid-run. Tau has the same gap and covers it with the overflow retry. You have nothing. Tau's summary prompt asks for fixed sections (goal, progress, next steps, critical context) and ends "Preserve exact file paths, function names, and error messages" (src/tau_coding/context_window.py:34-60); yours is three sentences.
What both do, and what it costs. Both harnesses replay the summary as a user message (src/tau_agent/session/memory.py:157), in front of a tail that itself opens on a user message. [general] Most hosted APIs accept two user messages in a row. Some chat templates insist that roles alternate, and an adapter for those has to merge the two. Lesson 16. And the summary is written from everything in the transcript, tool results included, so whatever a file said can come back in the user's voice. Lesson 14 is about why that matters.
src/tau_agent/session/memory.py:151-197 · pinned to commit 9fe6a71 · view on GitHub
A new case. After a compaction the user asks: "What was the exact error from the first test run?" That run is in line e3, long since read as a summary. What can the agent do as things stand, and what could a harness offer it?
Where is the exact text now? Who can reach it, and with what?
As things stand the model sees the summary and the tail. If the summary kept the error, it can quote it, which is why SUMMARY_SYSTEM asks for "exact file paths and errors". If not, it can run the tests again, at the price of a tool call and with no promise that today's error is the first one, or it can say that it no longer has the text. What it must not do is what lesson 2's model did, and write a likely one.
A harness can offer more, precisely because nothing was deleted. Line e3 is still in session.jsonl. A tool that searches the session's own log would let the agent fetch exact text on demand, paying only when it is needed. The user could be shown the full history, which the file still holds. A harness that had deleted the line could offer none of this.
Common answers, and what each one misses
- "Nothing. It was compacted away." Away from the view. The record has it, and the record is a file your tools can read.
- "The model still knows it. It read that output once." It knows what this request shows it, and this request shows a summary.
- "Make the summary keep everything important." Nobody knows in advance what will turn out to be important. A summary is a bet, and the log is what makes it safe to lose.
- You hit
- a context window: prompt nine refused, and every prompt after it
- You built
summarize(), the snap-forward half offind_cut(), a compaction-awarerows(), andcompact()- The principle
- compaction is not deleting history; it is a rule for how to read it
- Your harness now
- run_tool
- run_agent
- context_for_model
- repair_tool_history
- Harness
- SessionLog
- persist_to
- render
- estimate_tokens
- summarize
- find_cut
- compact
- maybe_compact
- replace_messages
- Still open
- Every request still begins with a system prompt you typed in lesson 1 and have not looked at since. It does not know which tools you have. Lesson 13.