Compaction
Shrink a run's conversation history — automatically or on demand — without losing the thread.
A long, tool-heavy run grows its message history until it crowds the model’s context window. Compaction reclaims that space by rewriting the history: clearing stale tool output, summarising the middle of the conversation, or dropping it outright — while keeping the original task and the most recent turns verbatim.
drover ships compaction as a primitive you assemble, not a behaviour that’s
on by default. There are no baked-in thresholds or strategies: you declare what
fires it, how it reclaims, and what it must preserve. Omit spec.compaction and
nothing changes — the run keeps its full history, exactly as before.
Where it runs
pi-agent-core calls transformContext before every LLM call with the live
message list. That is the one seam that can rewrite history, and it’s where
drover’s compaction engine runs. Two consequences follow:
- The system prompt is never touched — pi sends it separately, so it is not part of the array compaction sees. Your cacheable prompt prefix is safe.
- With storage wired, the checkpoint captures the post-compaction array, so resume replays the compacted history — no re-summarising on resume.
transformContext must never throw. The engine honours that: any failure (a
summariser error, say) emits an error event tagged CompactionError and
returns the original messages. A broken compaction never breaks the run — it
just continues uncompacted.
The policy
import { defineAgent, DEFAULT_COMPACTION_SUMMARY_PROMPT } from "@drover/core";
import { Type } from "@sinclair/typebox";
const researcher = defineAgent({
id: "researcher",
systemPrompt: "Research the question thoroughly using your tools.",
inputSchema: Type.Object({ question: Type.String() }),
outputSchema: Type.Object({ answer: Type.String() }),
model: "sonnet",
tools: ["bash", "show_tool_result"],
compaction: {
strategy: ["drop-tool-results", "summarize"],
trigger: { kind: "context_fraction", value: 0.8 },
preserve: { firstUserTurn: true, recentTurns: 3, pinTools: ["remember", "recall"] },
minReclaimTokens: 4096,
cooldownTurns: 2,
summaryPrompt: DEFAULT_COMPACTION_SUMMARY_PROMPT,
},
});| Field | Required | What it does |
|---|---|---|
strategy | ✅ | Ordered ladder of strategies, applied until the trigger clears. |
preserve | ✅ | What survives a pass verbatim. |
trigger | — | Auto-fire threshold. Omit ⇒ manual-only. |
minReclaimTokens | — | Skip a pass that reclaims less than this (cache-bust guard). Omit ⇒ no gate. |
cooldownTurns | — | Don’t auto-fire within N turns of the last pass. Omit ⇒ no cooldown. |
summaryPrompt | conditional | Required when strategy includes "summarize". |
summaryModel | — | Model for the summarise sub-call. Omit ⇒ reuse the run’s model. |
A policy that lists "summarize" with no summaryPrompt is a configuration
error — the run fails fast with CompactionConfigError before any model call,
rather than silently substituting a default.
Strategies
The strategy array is a ladder: drover applies each rung in order, re-checking
the budget after each, and stops once the run is back under the trigger (or the
ladder is exhausted). Order them cheap → expensive.
-
drop-tool-results
Replace old
toolResultcontent with[cleared to save context], keeping thetoolCallrecord so the causal trace survives. No model call. Re-fetchable tool output (long logs, big file reads) is usually the biggest consumer, so this is the cheapest, highest-leverage first rung. Pair it withshow_tool_resultso the model can re-read a cleared result on demand. -
summarize
An LLM sub-call condenses the compactable head into one synthetic message, inserted before the verbatim tail and marked
[compacted]. Highest fidelity, costs tokens. The call is made with no tools and reasoning off — a tool-equipped model otherwise tends to call a tool instead of writing the summary. Reuses the run’s model unless you setsummaryModel. -
sliding-window
Mechanically delete the compactable head, leaving opener + tail. No summary, no model call — the lossiest rung, useful as a last resort when even a summarise pass can’t fit.
Triggers — auto vs manual
A trigger makes compaction automatic: drover estimates the input size
before each call and fires when it crosses the threshold.
// fire at 80% of the model's context window
trigger: { kind: "context_fraction", value: 0.8 }
// or at an absolute input-token ceiling
trigger: { kind: "input_tokens", value: 120_000 }The estimate is taken from the live message array (a chars / 4 heuristic over
rendered content), measured against model.contextWindow. There is no
recommended default — pick a fraction that leaves headroom for the model’s
output and, if you summarise, for the summary itself.
Omit trigger entirely and compaction is manual-only: it fires only when you
call handle.compact().
const handle = runAgent(researcher, { question: "…" }, { storage });
// at a natural boundary, or reacting to a usage event:
handle.compact("Focus the summary on the failing test and the stack trace.");
const result = await handle.result;compact() is honoured by the next transformContext pass even when no
trigger is set, then the request clears. The optional argument overrides the
summary prompt for that one pass. It does not require storage, and it’s a
no-op when spec.compaction is absent. You can use both: an auto trigger for
safety plus manual compact() calls at known checkpoints.
What’s preserved
preserve: { firstUserTurn: true, recentTurns: 3, pinTools: ["remember", "recall"] }firstUserTurn— keep the opening user turn (the original task) untouched.recentTurns— keep the last N model turns verbatim. A turn is an assistant message plus the tool results that follow it; the freshest turns stay last so the model acts on current reality, never a summary of it.pinTools— tool ids whose results are never compacted. Pinremember/recallso externalised memory survives a pass.
Only the head — everything between the preserved opener and the verbatim
tail — is ever compacted. The head boundary snaps to a turn boundary, so a
toolCall is never split from its toolResult.
Observing it
Each committed pass emits a compaction event:
for await (const e of handle.events) {
if (e.kind === "compaction") {
console.log(
`${e.trigger} ${e.strategy}: ${e.beforeTokens} → ${e.afterTokens} tokens`,
`(collapsed messages [${e.collapsedRange[0]}, ${e.collapsedRange[1]}))`,
);
}
}trigger is "auto" or "manual"; summarized is true only for the
summarize rung; collapsedRange is the [start, end) of the replaced head.
Skipped passes (below minReclaimTokens, in cooldown, or under budget) emit
nothing. A summariser failure emits an error event tagged CompactionError.
Cache and resume interactions
Rewriting the head busts the prompt cache from the first changed message
forward — see prompt caching. Three things keep that
cheap: cooldownTurns and minReclaimTokens make passes rare and worthwhile,
the verbatim tail re-warms a stable suffix, and the system prompt’s cache
breakpoint is never touched.
Because the checkpoint stores the compacted array, spec.compaction is folded
into hashSpec — changing the policy invalidates a
paused run’s hash, so resume won’t replay old messages under a new compaction
policy. (Changing the policy is a spec change; migrate by writing a new run
rather than mutating a paused one.)
Manual-only and drop-only recipes
The cheapest, no-LLM setup — clear stale tool output automatically, never call a model to summarise:
compaction: {
strategy: ["drop-tool-results"],
trigger: { kind: "context_fraction", value: 0.85 },
preserve: { firstUserTurn: true, recentTurns: 4 },
minReclaimTokens: 2048,
}A manual-only policy — you decide exactly when, e.g. between phases of a long task:
compaction: {
strategy: ["drop-tool-results", "summarize"],
preserve: { firstUserTurn: true, recentTurns: 5 },
summaryPrompt: DEFAULT_COMPACTION_SUMMARY_PROMPT,
// no trigger → only handle.compact() fires it
}What you assembled
spec.compaction— a policy ofstrategy+preserve, plus optionaltrigger, guards, and summariser config. No hidden defaults.- the
transformContextseam — compaction runs before each LLM call, then the checkpoint snapshots the compacted array. handle.compact(instructions?)— the manual trigger, mirroringpause().- the
compactionevent — one per committed pass, for observability.