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.

~65 min · 2 labs · builds on 11 Pull the plug

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?

All five messages and your prompt. The session knows what happened
That gives the session a memory beside the file. After a restart there is none. replay() reads what the lines say, and there are four of them.
The four surviving messages, a stand-in result that says the call was interrupted, then your prompt
Two lessons, each doing its own job. Look at the two shapes in the output.
Four messages that end on a call with no result, so the strict model answers 400
That sends the record exactly as it is stored. Since lesson 10 what you send is computed: 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?

Once, in the request that needed it
That is "paid for once". The model keeps nothing between calls, so whatever it should still know rides in every later request.
Sent 19 times, paid for once. The provider has seen it before
That has the provider remembering your session. [general] Many providers discount a prefix they have recently cached. The discount is on the price, not on the size: the providers we know of still bill cached tokens, at a lower rate, and a cached prefix still takes up its full share of the request's limit.
19 times: in every request made since it arrived
Twenty requests in all. Only the first went out before the file was read.

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 context window: the most text a model will take in one request. This lesson calls it the wall. The session is not slow, and it is not expensive. It is over. And the agent was in the middle of something.

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) The oldest messages. Keep the newest six
Keeping only the newest few says old means unimportant. The oldest message here is the one that says what the whole job is for. And a rule that counts messages can stop anywhere, including between a call and its result. [general] In the run, what the repair leaves behind opens on an assistant message, and some providers refuse that as well.
(b) Everything. Replace the lot with a summary
A summary keeps the gist, and the agent was not working from the gist. It was halfway through using the exact text of a tool result, and exact text is the first thing a summary loses.
(c) Summarise the old part. Keep the last prompt, and everything done to answer it, word for word
The goal survives in the summary. The text the agent is working from survives as it was. It is not free. Somebody has to write that summary, and whatever it leaves out is gone from the model's view. Both bills arrive further down this page.

(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 compaction.

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_id is 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?

It refuses: a result whose call is not in the request
The call went into the summary. The result stayed behind without it.
It accepts. The summary says the read was made, so the result has something to answer
That asks a sentence to stand in for a toolCall block. Lesson 2: prose is not protocol. A result is paired with a call by id, and a summary has no ids.
It never gets the chance. Lesson 10's repair runs before every request and fixes this
The repair does run, and it does avoid the 400, by dropping the result. "Fixed" here means the agent no longer has what 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.

Compaction is a replay rule: the file only grows, the context is read differently session.jsonl holds nine message lines, e1 to e9. find_cut walks back by keep_recent_tokens and lands on e4, a toolResult whose call is in e2: cutting there would leave an orphan, so the cut snaps forward to the next user message, e6. compact appends one line, e10, of type compaction with a summary and first_kept_id e6. Nothing is deleted: e1 to e5 stay in the file, dimmed here. replay() then builds the context as the summary, read from e10 and placed first as a user message, then the kept tail e6 to e9 word for word, then the messages appended later, e11 and e12. session.jsonllog · it only grows {"id":"e1",..."role":"user"...}{"id":"e2",..."role":"assistant"...}{"id":"e3",..."role":"toolResult"...}{"id":"e4",..."role":"toolResult"...}{"id":"e5",..."role":"assistant"...} {"id":"e6",..."role":"user"...}{"id":"e7",..."role":"assistant"...}{"id":"e8",..."role":"toolResult"...}{"id":"e9",..."role":"assistant"...} 1 2 {"id":"e10","type":"compaction", "summary":"...","first_kept_id":"e6"} {"id":"e11",..."role":"user"...}{"id":"e12",..."role":"assistant"...} 1keep_recent_tokens reaches back to e4,a toolResult whose call would be cut off 2so the cut snaps forward to the nextuser message: first_kept_id = "e6" log.replay()what the model is sent userPrevious conversation summary:e10 usere6 assistantc3e7 toolResult · c3e8 assistante9 summarykept tail e10 is not a message: replay reads itfirst, as the summary usere11 assistante12 later

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.

  1. 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.
  2. The cut snaps forward to the next user message. first_kept_id is "e6", the solid line.
  3. One line is appended: e10, of type compaction. Lines e1 to e5 dim, and stay in the file.
  4. 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.
  5. 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?

A summary. The newest message asks for one
That trusts the model to tell your instruction apart from the conversation it sits in. Both arrive on one channel, a list of messages, and a list of messages reads as a conversation to continue. Lesson 1 planted this. Lesson 14 is where it turns dangerous.
More conversation. The model carries on as the agent it was told to be
Everything in the request says "you are an assistant with tools, in the middle of a job". So it is one.
A summary, and a cheap one: the provider has read this conversation before
The model kept nothing, so a summary of the whole conversation means sending the whole conversation again, and the lab bills it in full. [general] A provider that caches prefixes may discount the part it saw a moment ago. It is still sent, it still counts against the limit, and it is not free.

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?

Edit the live list to match: the summary first, then a slice from the first kept message on
That writes the replay rule a second time, in memory, and trusts the two copies to agree from now on. It can be done correctly today. The cell shows one ordinary way it goes wrong: the live list has no ids to slice by.
Replay the log and hand the harness whatever comes out, between prompts
One rule, in one place, used by today's process and by tomorrow's.
Replay the log and swap the list at once, even if a run is going on
Lesson 8: one writer at a time. A run in progress is appending to that list and will send it on its next turn. The transcript cannot be changed at any moment you like, and that holds for you as much as for the user.

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?

Raise. Compaction failed, and somebody ought to know
Somebody ought to know, yes. A log line or an event does that without eating the prompt. Lesson 11's saver had to be loud because silence there loses data. Silence here loses nothing: every line is still in the file, and there is another chance before the next prompt.
Write the entry anyway, with whatever came back. A thin summary beats none
That pretends the call succeeded. The entry is permanent, and from now on it stands in for everything before the cut.
Skip the compaction, append nothing, send the prompt
The session carries on a little fuller than you wanted, and there is another chance before the next 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>, then render(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.

  1. 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?
  2. 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 through run_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.
  3. In outline. wrapped = a user message holding three parts joined by newlines: the opening tag, render(messages), the closing tag. reply = one model.complete with the summary system prompt, a list holding only wrapped, and no tools. summary = the reply's text with the white space trimmed. If the reply's stop_reason is "error", or summary is empty: raise RuntimeError saying why (the reply's error_message helps). Otherwise return summary.

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.

  1. 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.
  2. After summarize(model, messages), `messages` is exactly what it was: the question and its answer went nowhere near the transcript.
  3. 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.

  1. find_cut(rows, keep_recent_tokens). The walk back is given. Yours is the snap forward: from cut, move to the next row that is a user message (rows[cut] itself counts). If there is none, move past any toolResult rows instead. Never backwards. The answer may be len(rows).
  2. SessionLog.rows(). This is where the replay rule lives, since replay() only reads rows(). Lesson 11's one line becomes a loop over entries(). 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 from first_kept_id on, 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.
  3. compact(log, model, *, keep_recent_tokens), returning True or False. Plan from log.rows() and find_cut. If the cut is 0 or len(rows), return False without calling the model. Summarise the rows before the cut with your summarize; if it raises RuntimeError, return False with nothing appended. Otherwise call log.append_compaction(summary, first_kept_id) with the id of the row at the cut, and return True.

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.

  1. Three questions, one per gap. In find_cut: which way may the cut move if the tail must never be bigger than the budget? In rows(): when the loop meets a compaction entry, which of the rows collected so far survive, and what goes in front of them? In compact: if you return False, what must be true of the file?
  2. 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 where first_kept_id sits 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.
  3. 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 equals first_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 or len(rows), return False with nothing asked of the model; try summarize on the messages of the rows before the cut, and on RuntimeError return False with nothing appended; then append_compaction(summary, the id carried by the row at the cut) and return True. That id is rows[cut][0]. It is not f"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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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).
  6. 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.
  7. 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 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?

Count the transcript's characters and divide by four. Close enough
It is free and gives the same answer every time, and it undercounts: it never sees the system prompt, the tool definitions or the JSON wrapped round every message. Lesson 2: tool definitions ride in every request. They are not free here either.
Read usage on the newest reply. The provider counted exactly
Exactly, for the request it measured. Everything added since, the tool results and the prompt about to be sent, is not in that number. What was measured and what you are about to send are different lists.
Both: the provider's count for the prefix it measured, an estimate for what came after
Each covers what the other cannot see.

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?

It fires again, and again
The number is a fact about a request made before the compaction, and it is still the newest one there is.
Nothing. After compacting, the usage reflects the shorter list
Usage is stored on a message and describes the request that produced it. Nobody recomputes it when the view changes.
Nothing. The message that carried that number was deleted
Compaction deletes nothing, and this message is in the kept tail in any case.

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 of find_cut(), a compaction-aware rows(), and compact()
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.