Skip to content
C.W.K.
Lesson 06 of 06 · published

A Summary That Does Not Grow With Its Age

~14 min · master-chief, context-compaction, summarization, growth

Level 0Visitor
0 XP0/44 lessons0/11 achievements
0/100 XP to next level100 XP to go0% complete

The Bound Was Growing Too

Lesson 1 listed six things that grow in a conversation with no last turn, and gave the replay its bound: compaction, which replaces older rows with one summary. Nobody put the summary itself on that list. On 2026-10-22 the Master Chief review (Coop todo #597, finding M2) did, because the summary had become one of the larger things in every turn.

The reason sat in lesson 3's step 3 as it was then written. A background pass summarized everything older than the newest 40 rows, and the oldest of those rows was the summary the previous pass had written. So every pass handed the previous summary back to the chunker as one more row. A chunk gathered whole rows up to about 18,000 characters, and a summary far longer than that fit beside nothing: it went in whole, as a chunk of its own, and the model summarized it again next to the new rows' chunks. Each summary carried the summaries before it, the way a photocopy of a photocopy carries every earlier smudge.

What the Review Measured

  • The Chief's 16 summaries ran from 114 KB to 524 KB and settled at 150 to 330 KB: a quarter to a third of every turn's floor of about 281K input tokens.
  • The newest summary still held 22 chunk headings from two earlier generations of summaries.
  • Its identifier list kept the first 120 of 1,562 identifiers by position, so the head of every summary was the first week's Wikipedia and Polygon URLs, and each new identifier fell into "…and N more".
  • Its tool list carried whole heredoc commands at 800 characters each, 17 KB on every turn.
  • And one race (finding m4): a pass asked for while another was running found 60 live rows when it started, and summarized the summary that other pass had written moments before.

In tokens: over the week to 2026-10-22, the compaction summary was about 82K of the 356.6K input tokens an average Dad turn in the Chief cost. Every turn paid that again.

Merged, Never Re-chunked

The fix (commit 2e35ef04) changes what a pass does with the summary it finds:

  1. Among the rows to summarize, a summary row is the standing summary. It never goes back through the chunker.
  2. Only the new rows are chunked and summarized, exactly as lesson 3 describes, four chunks at a time, each cached on disk under a hash of its text.
  3. One more utility request, _merge_standing, merges the new chunk summaries into the standing one. Its prompt asks for a single updated summary: fold whatever later turns resolved, closed or replaced into one line about the outcome; keep Dad's standing rulings, open tasks and the identifiers they need; no chunk headings, no item twice. The merge is cached on disk like a chunk, and it is the largest request a pass makes, so it gets the longest timeout (300 seconds).
  4. When the model cannot write the merge (an error, or an empty answer), nothing is lost: the standing summary and the new chunk summaries sit side by side under ## Since the last summary, and the next pass merges them.
  5. The identifier and tool lists are rebuilt from the new rows alone, newest first. The identifier list keeps the newest 120 and says how many more there were; each tool note is one line whose text is cut at 240 characters, followed by a count of what the cut left out. The chunk prompts still get the longer excerpts, and the rows keep the full commands.
  6. A pass that finds the replay already under the Chief's line, by the estimate the previous pass recorded, stops before doing any of this. That closes the m4 race.

A test file, tests/test_compaction_standing_summary.py, pins each piece: merged and never re-chunked, the old lists not handed back, identifiers newest first, the side-by-side fallback, one-line notes, and the pass that stops under the line.

Why Not Cap It

A size cap on the summary would have been one line of code, and it is the line the fix deliberately does not write. Dad's rule about token caps, from 2026-09-20:

왜 한도를 넣냐고? 알아서 쓸만큼 쓰라고 해. — 아빠

("Why put a limit in? Let it use as much as it needs.") A cap decides in advance how much of the past the model may keep, and it cuts by position, which is exactly how the identifier list lost everything after its first 120. The merge prompt asks for what a cap cannot: keep what is still open and what Dad ruled, fold what is finished, repeat nothing. The rows that leave the replay stay in the transcript and in recall (lesson 5), so dropping the detail of finished work loses nothing that cannot be found again.

What It Still Costs

Be honest about the price. Every pass that finds a standing summary spends one more utility request, the biggest one. A merge is a model's judgment about what to fold, and judgment is lossy. A run of failed merges leaves side-by-side sections that grow until one succeeds. And the commit recorded the sizes before the fix, not after: the number to watch now is the summary's share of a turn's input, pass after pass.

A bound can grow too. When you bound something by replacing it with a summary of itself, ask what happens to the summary on the next pass. If the answer is "it gets summarized again with everything new", it carries its whole ancestry. Merge into it; never feed it back in with the new rows.

Code

verbatim, backend/services/context_compaction_guard.py, as of 2026-10-27: the standing summary merged with the new chunk summaries by one request, side by side when the model cannot·python
async def _merge_standing(standing_rows: list[Any], chunk_summaries: list[str], *,
                          cache_dir: Path | None = None) -> list[str]:
    """The body of a pass that found an earlier summary: the standing summary
    merged with the new chunk summaries by one model request, cached on disk
    like a chunk. When the model is unavailable or answers nothing, the
    standing summary and the new chunk summaries side by side: nothing is
    lost, and the next pass merges them."""
    standing = "\n\n".join(body for body in (_standing_body(m) for m in standing_rows) if body)
    if not chunk_summaries:
        return [standing] if standing else []
    if not standing:
        return chunk_summaries
    prompt = _build_merge_prompt(standing, chunk_summaries)
    cache_path = _cache_path(cache_dir, "merge\n" + prompt)
    if cache_path is not None:
        try:
            cached = cache_path.read_text(encoding="utf-8").strip()
            if cached:
                return [cached]
        except OSError:
            pass
    try:
        merged = (await _call_utility_model_complete(
            prompt,
            system=(
                "You keep the standing summary of a long conversation current. Merge, "
                "fold what later turns resolved, and keep what is still open. Return only "
                "the updated summary."
            ),
            temperature=0.1,
            timeout=MERGE_TIMEOUT_SECONDS,
        )).strip()
    except Exception as exc:  # noqa: BLE001
        logger.warning("compaction merge failed: %s", exc)
        merged = ""
    if not merged:
        return [standing, "## Since the last summary", *chunk_summaries]
    if cache_path is not None:
        try:
            cache_path.parent.mkdir(parents=True, exist_ok=True)
            cache_path.write_text(merged, encoding="utf-8")
        except OSError:
            logger.debug("compaction merge cache write failed", exc_info=True)
    return [merged]
A minimal sketch you can run: eight passes that hand the old summary back to the chunker, against eight that merge into it (a toy summarizer with the real shape; stdlib, Python 3.9+)·python
"""A minimal sketch: a pass that hands the old summary back to the chunker
nests it; a pass that merges into it keeps one. Toy summarizer, real shape."""
CHUNK_CHARS = 60  # stands in for the real threshold of 18,000 characters


def chunks_of(rows: list[str]) -> list[list[str]]:
    # Whole rows, a chunk closing before the row that would push it past the
    # threshold; a row longer than that (an old summary, soon) rides alone, whole.
    chunks: list[list[str]] = []
    current: list[str] = []
    size = 0
    for row in rows:
        if current and size + len(row) > CHUNK_CHARS:
            chunks.append(current)
            current, size = [], 0
        current.append(row)
        size += len(row)
    if current:
        chunks.append(current)
    return chunks


def summarize_chunk(rows: list[str], n: int) -> list[str]:
    # Like the real model: what already reads as a summary is kept, every
    # event becomes a bullet, and chatter is dropped.
    lines = [line for row in rows for line in row.splitlines()]
    kept = [line for line in lines if line.startswith(("## ", "- "))]
    events = [f"- {line}" for line in lines if line.startswith(("open", "closed", "Dad ruled"))]
    return [f"## Chunk {n}"] + kept + events


def rechunk_pass(summary: list[str], rows: list[str]) -> list[str]:
    # Before: the standing summary goes back in as one more row, the oldest.
    material = (["\n".join(summary)] if summary else []) + rows
    out: list[str] = []
    for n, chunk in enumerate(chunks_of(material), start=1):
        out += summarize_chunk(chunk, n)
    return out


def merge_pass(summary: list[str], rows: list[str]) -> list[str]:
    fresh: list[str] = []
    for n, chunk in enumerate(chunks_of(rows), start=1):  # only the new rows are chunked
        fresh += summarize_chunk(chunk, n)
    # The one merge request: fold what later turns resolved, keep what is
    # open and what Dad ruled, one summary with no chunk headings.
    items = [line for line in summary + fresh if line.startswith("- ")]
    closed = {line.removeprefix("- closed ") for line in items if line.startswith("- closed ")}
    items = [line for line in items if line.removeprefix("- open ") not in closed]
    return ["## Standing summary"] + list(dict.fromkeys(items))


def rows_for(k: int) -> list[str]:
    return [f"open #{k}", "chatter", f"closed #{k - 1}", "chatter", f"Dad ruled R{k}", "chatter",
            "chatter", "chatter", "chatter", "chatter", "chatter", "chatter"]


nested: list[str] = []
merged: list[str] = []
print("pass  re-chunked: lines / chunk headings   merged: lines / chunk headings")
for k in range(1, 9):
    nested = rechunk_pass(nested, rows_for(k))
    merged = merge_pass(merged, rows_for(k))
    heads = lambda summary: sum(line.startswith("## Chunk") for line in summary)
    print(f"{k:>4}  {len(nested):>13} / {heads(nested):<16} {len(merged):>11} / {heads(merged)}")

Exercise

If anything of yours keeps a running summary (a chat memory, a changelog digest, a rolling report), run it for twenty generations on real or replayed input and record the summary's size after each one. Then look inside the newest summary for text that came from summaries rather than from events: headings, phrases like "previously", items that were resolved long ago. If the size climbs or that residue piles up, change the step so the old summary is merged with a summary of only the new events, and run the twenty generations again.
Hint
Plot size against generation, not against time; a summary that grows a little every pass looks harmless for weeks. Give the merge step explicit rules for resolved items (keep the outcome in one line) and for open ones (keep them whole), and keep a fallback that stores both pieces side by side when the merge fails, so a bad day costs space, not memory.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.