Compaction: Summarizing Without Losing the Plot
Long sessions overflow any window. Truncation forgets; compaction summarizes the oldest turns into a dense digest while recent turns stay verbatim. The craft is in what must survive untouched — and in never splitting a tool_use from its tool_result.
A productive agent session grows without bound; the window doesn't. Truncation (drop oldest turns) is simple and brutal — the user's constraint from turn 2 vanishes and the agent cheerfully violates it at turn 40. Compaction replaces the oldest span of turns with an LLM-written summary, keeping recent turns verbatim. Done well it's nearly invisible; done badly it's amnesia with extra steps. Trigger it by threshold: when the conversation crosses ~75% of your window budget, compact — before you're forced to, so there's headroom for the summary call itself and the next big tool result.
What must survive untouched
- The system prompt — it's the agent's identity and rules; it is never compaction input.
- Active task state: the current goal, the user's standing constraints, decisions already made. A summary that drops "user said do NOT touch the prod database" is a security incident, not a summarization artifact.
- Tool-call structure: every
tool_useblock must keep its pairedtool_result— compact at turn boundaries, never through a pair, or the API rejects the malformed history with a 400 (Module 1's strict pairing rule). - The most recent turns, verbatim: the model needs exact recent wording — paraphrase kills follow-ups like "change that second option".
- Hard-won values: file paths, IDs, numbers, error strings. Instruct the summarizer to preserve these exactly.
# Colab cell 1 — run once. Set your key in the 🔑 panel (name it
# ANTHROPIC_API_KEY) or just paste it when prompted.
!pip install -q anthropic
import os
try:
from google.colab import userdata
os.environ["ANTHROPIC_API_KEY"] = userdata.get("ANTHROPIC_API_KEY")
except Exception:
from getpass import getpass
os.environ.setdefault("ANTHROPIC_API_KEY", getpass("Anthropic API key: "))
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"
def count(messages: list, system: str = "") -> int: # lesson 1's counter
kwargs = {"model": MODEL, "messages": messages}
if system:
kwargs["system"] = system
return client.messages.count_tokens(**kwargs).input_tokens
COMPACT_AT = 0.75 # of the window budget
WINDOW_BUDGET = 60_000 # tokens you allow the conversation to occupy
KEEP_RECENT = 8 # messages kept verbatim
SUMMARIZER_PROMPT = (
"Summarize this conversation prefix for an agent that will continue it.\n"
"PRESERVE EXACTLY: the user's goal, all standing constraints and "
"prohibitions, decisions made, and every file path, identifier, number, "
"and error message. Note outcomes of tool calls, not their transcripts.\n"
"Write a dense factual digest. No praise, no meta-commentary."
)
def maybe_compact(messages: list[dict], system: str) -> list[dict]:
if count(messages, system) < COMPACT_AT * WINDOW_BUDGET:
return messages
cut = len(messages) - KEEP_RECENT
# never orphan a tool_result from its tool_use: shift the cut to a
# boundary where the next kept message starts a fresh user turn
while cut > 0 and starts_with_tool_result(messages[cut]):
cut -= 1
old, recent = messages[:cut], messages[cut:]
if not old:
return messages # nothing safely compactable; raise budget or digest tools harder
resp = client.messages.create(
model=MODEL, max_tokens=1_500,
system=SUMMARIZER_PROMPT,
messages=old + [{"role": "user", "content":
"Now produce the summary of everything above."}],
)
summary = next(b.text for b in resp.content if b.type == "text")
return [
{"role": "user", "content":
f"<conversation_summary>\n{summary}\n</conversation_summary>"},
{"role": "assistant", "content": "Understood. Continuing from that summary."},
] + recent
def starts_with_tool_result(msg: dict) -> bool:
c = msg.get("content")
return (msg["role"] == "user" and isinstance(c, list)
and any(getattr(b, "type", None) == "tool_result"
or (isinstance(b, dict) and b.get("type") == "tool_result")
for b in c))
# demo: shrink the budget so compaction triggers on a short scripted chat
WINDOW_BUDGET = 2_000
messages = [
{"role": "user", "content": "Constraint: never modify files under "
"legacy/ - they are frozen for the audit."},
{"role": "assistant", "content": "Noted: legacy/ is frozen."},
]
for i in range(10):
messages.append({"role": "user",
"content": f"Please refactor helper_{i}.py and report back. " * 15})
messages.append({"role": "assistant",
"content": f"Refactored helper_{i}.py; tests still pass. " * 15})
compacted = maybe_compact(messages, "You are a careful coding agent.")
print(f"{len(messages)} messages -> {len(compacted)}")
print(compacted[0]["content"][:400])tool_result, its tool_use partner just got summarized away and your next API call 400s. Also note the summarizer runs with an explicit preservation list baked into the system prompt — a freestyle summary will smooth away the exact constraint you most needed. The demo shrinks WINDOW_BUDGET to 2,000 so compaction fires on a 22-message scripted chat; the threshold logic is identical at 60K.# Colab cell 2 — run cell 1 first (client, count, maybe_compact; its demo
# left WINDOW_BUDGET at 2_000 so this test compacts cheaply).
SYSTEM_PROMPT = "You are a careful coding agent. Honor every standing constraint."
def pad_with_filler_turns(messages: list[dict], turns: int) -> list[dict]:
padded = list(messages)
for i in range(turns):
padded.append({"role": "user",
"content": f"Please refactor helper_{i}.py and report back. " * 15})
padded.append({"role": "assistant",
"content": f"Refactored helper_{i}.py; tests still pass. " * 15})
return padded
def test_constraint_survives_compaction():
messages = [
{"role": "user", "content":
"We're refactoring billing. Constraint: never modify files "
"under legacy/ - they are frozen for the audit."},
{"role": "assistant", "content": "Noted: legacy/ is frozen."},
]
# ... pad with 40 turns of filler work until compaction triggers ...
messages = pad_with_filler_turns(messages, turns=40)
compacted = maybe_compact(messages, SYSTEM_PROMPT)
assert len(compacted) < len(messages), "compaction should have fired"
compacted.append({"role": "user", "content":
"Quick cleanup: delete the unused helpers in legacy/utils.py?"})
resp = client.messages.create(model=MODEL, max_tokens=400,
system=SYSTEM_PROMPT, messages=compacted)
text = next(b.text for b in resp.content if b.type == "text")
answer = text.lower()
assert "frozen" in answer or "legacy" in answer and "no" in answer.split(".")[0], (
"agent forgot the frozen-directory constraint after compaction")
test_constraint_survives_compaction()
print("constraint survived compaction")Compaction fights your prompt cache
Compaction has a cost this lesson hasn't priced yet: it fights prompt caching. Module 2 Lesson 5 covers the mechanics in full — caching is an exact-prefix match, and every compaction pass rewrites the message array starting at the cut point, which invalidates the cached prefix from that byte forward. Concretely: a session that was enjoying high cache_read_input_tokens on every call suddenly re-prefills its entire history at full price the moment maybe_compact fires, because the rewritten summary block sits early in the array and nothing after it can reuse the old cache entry. The fix is the same one taught there — compact rarely and in batches (the 75% threshold in this lesson's code is already doing that job, not compacting every turn) — and the same amortization inequality applies: tokens saved per call, times calls remaining, has to beat the cost of one full uncached re-prefill. If your COMPACT_AT threshold is tuned to trip on nearly every turn, you're paying that re-prefill tax constantly for a marginal context-size win — watch cache_read_input_tokens in the trace before and after a compaction pass to confirm the trade is actually paying off.
What the summary can drop
| Category | Keep verbatim | Safe to compress or drop |
|---|---|---|
| Tool call outcomes | The final result a decision depended on | The intermediate retries, false starts, and commands that didn't pan out — keep "grep found nothing in src/, then found it in lib/", drop the five failed greps in between |
| Exploration | The conclusion reached | The back-and-forth that reached it — a debugging session that tried four hypotheses only needs to remember the one that found the bug |
| Chit-chat / acknowledgments | — | Everything — "Sounds good, thanks!" carries no task state |
| Errors | The error that changed behavior (e.g. triggered a constraint) | Errors that were retried and resolved with no lasting effect |
Provider-native compaction
As of this module, server-side compaction exists as a beta API feature on several frontier models: instead of writing maybe_compact yourself, the server auto-summarizes older context when a session approaches a configured token threshold, and returns a compaction block you pass back verbatim on the next call (extracting only the text and discarding the block loses the compaction state). It's the same idea this lesson teaches — cheaper to adopt, but a black box: you don't control the preservation list, so the planted-constraint regression test above matters more, not less, because you can't read the summarizer prompt to sanity-check it. Hand-rolling stays the right call when you need a domain-specific preservation list (this lesson's exact-values-and-constraints instruction), multi-provider portability, or a compaction trigger tied to something other than token count.
maybe_compact by deleting the boundary-shift loop, since "the tests pass on our scripted conversation":cut = len(messages) - KEEP_RECENT
old, recent = messages[:cut], messages[cut:]
# (the "while cut > 0 and starts_with_tool_result(messages[cut]): cut -= 1"
# line has been deleted entirely)Whiteboard drills
- ▸Compact at ~75% of budget — before you're forced to, leaving headroom for the summary call itself.
- ▸Untouchables: system prompt, active task state, standing constraints, exact IDs/paths/numbers, recent turns.
- ▸Never split a
tool_usefrom itstool_result; move the cut to a clean turn boundary or the API 400s. - ▸Summarize with an explicit preservation list; freestyle summaries smooth away constraints.
- ▸Test compaction behaviorally: plant a constraint, force compaction, verify the agent still honors it.
- ▸Compaction fights prompt caching: every rewrite invalidates the cached prefix from the cut point forward — compact rarely, in batches, and watch cache_read_input_tokens to confirm the trade pays off (Module 2 Lesson 5).
- ▸A summary should keep decisions, constraints, and exact values; it should drop resolved retries, dead-end exploration, and chit-chat — compressing to conclusions, not transcripts.
- ▸Server-side compaction now exists as a beta API feature; it changes who writes the summarizer, not the need to behaviorally test what survives.