Module 4: Memory & Context Engineering · Lesson 2 of 5 · 35 min

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.

context window (finite budget)systemtoolshistorytool resultsnew turn⚠ approaching limit → compactsystemsummary ✦recent turns← reclaimed budgetold turns are summarized; system prompt and recent turns survive verbatim
Compaction in motion: the oldest turns collapse into a summary block; the tail of recent turns and the system prompt are untouched.
1/6

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_use block must keep its paired tool_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.
threshold-triggered compaction
# 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])
The boundary shuffle is the part everyone gets wrong first: if the kept region begins with a 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.
Compaction is lossy. Prove the agent survives it.
Every compaction discards information — the only question is whether it discards anything load-bearing. So test it like the failure mode it is: plant a constraint early in a long scripted conversation, force compaction, then ask a question whose correct answer depends on that constraint. If the agent violates it, your summarizer prompt (or your untouchables list) is broken. Lab 04 requires exactly this test.
a compaction regression test
# 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")
This is behavior-level testing: don't inspect the summary text (brittle), verify the agent still acts correctly after compaction. Keep two or three of these planted-constraint scenarios in your suite and run them whenever you touch the summarizer prompt — summarizer prompts regress silently.

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

CategoryKeep verbatimSafe to compress or drop
Tool call outcomesThe final result a decision depended onThe 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
ExplorationThe conclusion reachedThe 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 / acknowledgmentsEverything — "Sounds good, thanks!" carries no task state
ErrorsThe 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.

Spot the bug
A teammate "simplifies" maybe_compact by deleting the boundary-shift loop, since "the tests pass on our scripted conversation":
python
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

Check yourself
Drill: "Your compaction threshold is 75% of budget. A teammate wants to drop it to 50% to 'never risk running out of room.' What's wrong with that instinct?"
Check yourself
Drill: "How do you prove compaction didn't quietly break your agent, beyond eyeballing that the summary reads fine?"
Key takeaways
  • 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_use from its tool_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.