Skip to content

fix: a turn is not a unit of size, and a summary of a summary is not a compaction - #119

Merged
webmatze merged 2 commits into
mainfrom
issue-118-compaction-one-long-turn
Sep 9, 2026
Merged

fix: a turn is not a unit of size, and a summary of a summary is not a compaction#119
webmatze merged 2 commits into
mainfrom
issue-118-compaction-one-long-turn

Conversation

@webmatze

@webmatze webmatze commented Sep 8, 2026

Copy link
Copy Markdown
Owner

Behebt #118.

Was falsch war

Das Recency-Window zählt Turns, keine Tokens. Ein langer Agentenlauf — ein Prompt, dann hundert Tool-Calls — ist ein Turn, also deckte das Fenster, das die letzten drei Turns schützen soll, die gesamte Historie ab:

  • window_start gibt 0 zurück, sobald es nicht mehr echte Turns gibt als das Fenster breit ist → alle vier billigen Stages brechen bei der ersten Nachricht ab
  • safe_cut_index findet keine Grenze, deren Tail passt, und fällt auf die neuste zurück
  • ab der zweiten Kompaktierung ist der Prefix genau eine Nachricht: die Summary der ersten

Ergebnis in einer echten Session: ~105421 → ~105341 tokens, 0% of the budget reclaimed · summarize, in jedem Turn — jedes Mal ein Provider-Call und eine Prompt-Cache-Invalidierung, um eine Summary gegen die nächste zu tauschen, während die 76 k Tokens Tool-Output hinter dem Cut nie Kandidat waren.

Der Notausgang, der das Fenster ignoriert, existierte schon — er hing an cut.zero?, also an der einen Form, die dieser Fall nie annimmt.

Was sich ändert

  1. Der Notausgang läuft jetzt, wenn der Tail, der den Cut überlebt, selbst über dem Target liegt. cut.zero? ist der Fall ohne Grenze, das hier der Fall, in dem keine Grenze hilft — dieselbe Sackgasse über einen anderen Weg. Danach wird die Grenze gegen die verkürzte Historie neu gewählt, und was jetzt passt, wird zurückgegeben statt umsonst zusammengefasst.
  2. Ein Prefix, der nur die letzte Summary ist, wird vor dem Call abgelehnt — erkannt an SUMMARY_PREFIX, den Schreiber und Leser jetzt teilen. Bewusst am Text statt an einem Flag: eine resumte Session holt ihre Historie über dieselbe JSON zurück wie jede andere Nachricht, und ein Flag, das den Roundtrip nicht überlebt, würde die Guard frisch greifen und resumt aussetzen lassen.
  3. Eine Summary, die länger zurückkommt als die Turns, die sie ersetzt, wird verworfen. Den Call bezahlen, das Detail verlieren und den Request vergrößern ist das eine Ergebnis, für das nichts spricht.

Messung

Reproduktion: ein kurzer Turn, dann 30 Tool-Ergebnisse à 8 KB, Budget 70 000.

vorher nachher
stages ["summarize"] ["truncate"]
tokens 60212 → 60168 60212 → 34249
reclaimed 44 (0 %) 25963 (74 %)
target erreicht nein ja
Provider-Calls 1 pro Turn 0

Für den Review

Fidelity-Tradeoff, bewusst: ein großes read_file-Ergebnis im laufenden Turn kann jetzt gekürzt werden, bevor das Modell darauf reagiert hat. Vorher machte der cut.zero?-Zweig dieses Zugeständnis nur im entarteten Einzelturn-Fall, jetzt gilt es in jeder Session, in der ein Turn über das Target wächst. Gemildert dadurch, dass truncate_old_tool_results größtes-zuerst geht und abbricht, sobald das Target erreicht ist — in der Messung oben bleiben von 240 KB Tool-Output 136 KB stehen, kleine junge Ergebnisse überleben also. Die Alternative wäre ein Request, den der Provider ablehnt.

Der Strategy::None-Fall bleibt still, wenn Compaction nichts ausrichten kann. Das ist unverändert — der alte cut.zero?-Pfad gab dasselbe zurück.

Tests

Jede der drei Änderungen fällt einzeln durch eine eigene Spec, wenn man sie zurücknimmt — nachgeprüft, nicht behauptet. Die erste Fassung der Summary-Guard-Spec traf die Guard nicht (sie kam über den Notausgang ans Ziel) und ist ersetzt.

crystal spec: 1352 Beispiele, 0 Fehler. crystal tool format --check: sauber.

🤖 Generated with Claude Code

webmatze and others added 2 commits September 8, 2026 17:41
…a compaction

The recency window counts turns. A long agentic run — one prompt, then a
hundred tool calls — is one turn, so the window that protects the last three
turns covered the whole history: every cheap stage broke on the first message,
safe_cut_index found no boundary whose tail fit and fell back to the newest
one, and from the second compaction onward the prefix left to summarize was
the previous summary and nothing else. ~105421 → ~105341 tokens, 0% reclaimed,
every turn, each one paying for a provider call and a prompt-cache
invalidation while the 76k of tool results behind the cut were never a
candidate.

The escape hatch that ignores the window already existed, wired to cut.zero? —
the one shape this case never takes. It now runs whenever the tail that would
survive the cut is itself over target: cut.zero? is the case where there is no
boundary, this is the case where no boundary helps. The boundary is then
re-chosen against the shortened history, and a history that fits is returned
as it is rather than summarized for nothing.

Two guards behind that. A prefix that is only a previous summary is refused
before the call is made, recognised by the SUMMARY_PREFIX the writer and the
reader now share rather than by a flag, which a resumed session's JSON round
trip would not carry. And a summary that comes back longer than the turns it
replaced is discarded, because paying for the call, losing the detail and
growing the request is the one outcome with nothing to recommend it.

On the reproduction — a short turn, then thirty 8 KB tool results — the same
history now goes 60212 → 34249, 74% of the budget reclaimed, target reached,
and no provider call at all.

Closes #118

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ised a window that is gone

Both were found by removing the code and watching the suite stay green.

`prefix.size == 1 && summary?` had no coverage at all: the spec named for it
reached `calls == 0` through the escape hatch instead, because a history of
tool results is truncatable and never gets as far as the guard. It is renamed
to what it does show — shortening the turn makes the call unnecessary — and a
second one takes its place whose bulk is assistant text, so nothing is
truncatable, the cut lands back on the same boundary, and the prefix really is
the summary alone.

Each of the three changes now fails a spec of its own when reverted.

The readme said the recency window is given up for "one oversized `cat` inside
the only turn there is". That was true of the branch this replaced. It is now
given up whenever no turn boundary leaves a tail that fits, which a session of
a hundred tool calls under one prompt reaches routinely — so the sentence says
that, and says what still limits the pass: largest first, stopping at the
target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@webmatze
webmatze merged commit 9a6c01c into main Sep 9, 2026
2 checks passed
@webmatze
webmatze deleted the issue-118-compaction-one-long-turn branch September 9, 2026 08:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant