Context compaction: one trigger, five harnesses
An agent's context grows with every tool result it reads, and the model's window is finite: 204,800 tokens for MiniMax M2.5, 262,144 for Laguna xs 2.1. Past the edge the provider answers with a 400 and the attempt dies — a framework failure, not the agent's. Every serious harness therefore compacts, but each does it at its own point, in its own way, and if five harnesses compact at five different points the comparison between them is partly a comparison of their compaction settings. So the framework decides the number once and renders it five ways.
The one number
OUTPUT_RESERVE = 16,384
threshold = min(0.90 × W, W − OUTPUT_RESERVE)
= 184,320 for W = 204,800 (MiniMax M2.5)
= 235,929 for W = 262,144 (Laguna xs 2.1)
The window W is resolved once per run by the dispatcher from the gateway's model config and
threaded to the harness in its job; no adapter looks it up. The reserve is a fixed output
allowance, not the model's own max_completion_tokens — reserving that would look correct and be
wrong (one model's is 235,929, which would leave a 26 K threshold). It was lowered from 32,768 to
16,384 after two runs measured live outputs never above about 2 K. The 0.90 cap binds for the
larger window and leaves 26 K of headroom against provider token-count drift; the reserve binds
for the smaller one.
The shim enforces the same reserve from the other side: it injects max_tokens = 16,384 on every
call that does not set one, so no provider can reject a call for requested output before the
harness has had its chance to compact. A run without a resolved window has compaction disabled,
deliberately, and says so.
Rendered five ways
| harness | who compacts | how the number gets in | what is recorded |
|---|---|---|---|
| Claude Code | the CLI, natively | two environment variables; the CLI subtracts its own output reserve from the auto-compact window to get its trigger, so the framework exports threshold + reserve and pins the reserve to the shared 16,384. The first attempt exported the threshold itself and double-subtracted — a live run compacted at 197,621 instead of 235,929 before it was caught | the CLI's compact_boundary stream events, with the model-call index each landed on, written to a compaction_events.json artifact |
| Codex CLI | the CLI, natively | two real fields in the config.toml the adapter already writes | its console notice, counted |
| OpenCode | the CLI, natively — but it had never compacted: its gate is "model has a declared context limit", and with the models catalogue disabled no limit was declared | the adapter now declares the model's input and output limits and turns compaction.auto on with reserved = W − threshold, so OpenCode's own arithmetic lands on the shared trigger | its console notice, counted |
| mini-swe-agent | the framework's pruner, loaded into the reference agent as a subclass through --agent-class; standalone on purpose (stdlib plus mini only), so it imports inside mini's isolated environment | the agent's compact_at_tokens setting | its COMPACT #N fired log lines |
| custom minimal | the framework's pruner, called at the top of the loop before every model call | the harness's own threshold | counter, tokens before and after, and the same compaction_events.json shape Claude Code emits |
Three CLIs were configured; two harnesses needed code, and they share one module.
The pruner the two framework harnesses share
Both keep an OpenAI-shaped message list — system and user, assistant with tool_calls, tool
results — so one function serves both. It never calls a model. It is deterministic, free, and
reproducible from the trajectory.
Policy: keep the first two messages verbatim, keep the last six verbatim, shrink the middle.
- Stub the middle tool results. Each tool result in the middle is cut to its first 800 characters plus a marker: compacted, N chars elided, the full output is in the trajectory, continue from your most recent conclusion. Only when the stub is genuinely smaller — measured live, the boilerplate made short outputs grow.
- If that is not enough, drop the oldest middle turn-groups whole. A group is an assistant
message together with the tool results that answer its
tool_calls. Dropping in groups is what makes an orphanedtool_call_idstructurally impossible; an orphan is a 400 that kills the instance. This is why the obvious library helper was unusable: it drops messages individually and was verified to emit a tool result whose originating call was gone. - Last resort, cap oversized results in the tail. "Keep the last six verbatim" has a floor: a single 26,000-character test log sitting in the tail can hold the context above the trigger no matter how much middle is dropped. Measured on a real run, head plus tail alone were 34,209 characters against a 22,988 target and every later pass reclaimed nothing.
Assistant text, reasoning content and tool calls are never modified — a real run kept 174 of 174 commands and 145 of 145 reasoning blocks through its passes. The tail boundary is snapped to a group boundary, because parallel tool calls put more than one result after one assistant message and a fixed-size tail lands mid-group. Before returning, the module asserts there are no orphans.
The trigger is measured, not estimated. Occupancy is the provider-reported prompt_tokens of
the last response plus a characters-over-four estimate of everything appended since. It is never
input − cached: a cache-read token still occupies the window, and at the 74% cache-read share
these runs carry, that trigger fires about four times too early.
It latches off. A pass that reclaims less than 10% means there is nothing left to prune, and
the pruner stops for the rest of the attempt — without this, one run compacted 14 times on 58
messages, most passes reclaiming nothing. When the window is exhausted and compaction has nothing
left, the attempt ends with its own terminal reason, context_exhausted: a framework-imposed cut
in the same family as the turn cap, never graded as an empty patch, never a clean finish.
What the runs measured
Every instance row records how many passes fired and the tokens before and after the last one; Claude Code and the custom agent also write the per-pass record with the model call it landed on, so results analysis can ask whether compacting at call N changed the outcome.
On MiniMax M2.5 the machinery was rarely needed. Peak prompt size per attempt, from the call ledger of the five 500-runs:
| harness | median peak | 95th percentile | largest | attempts above 200 K |
|---|---|---|---|---|
| custom minimal | 26,261 | 69,512 | 184,365 | 0 |
| OpenCode | 26,146 | 66,700 | 166,708 | 0 |
| Codex CLI | 26,725 | 68,093 | 210,245 | 1 |
| Claude Code | 18,875 | 36,169 | 80,785 | 0 |
| mini-swe-agent | 25,699 | 68,503 | 131,040 | 0 |
Two attempts compacted in 2,510: the custom agent once, on django__django-11734 at model call
281 (about 185 K to 139 K, estimated), and Codex once, natively, on the attempt that peaked at
210 K. Claude Code's contexts never reached half the trigger.
Laguna xs 2.1 is the model that carries context — three times the prompt per call on the same tasks — and there the trigger was reached: on the 78-instance arms its attempts peaked at up to 251 K against the 235,929 trigger, and three of the pilot's 84 attempts show the compaction drop in their ledger. Cheaper per token is not cheaper per task has the context figures.
Code: swebench_eval/harnesses/compaction.py (the shared module and the
threshold), scripts/mini_pruning_agent.py (the mini-swe-agent subclass),
swebench_eval/harnesses/custom_minimal/harness.py (the custom agent's
trigger), and the three adapters under swebench_eval/harnesses/ for the CLI
settings.