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

ts
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,
  },
});
FieldRequiredWhat it does
strategyOrdered ladder of strategies, applied until the trigger clears.
preserveWhat survives a pass verbatim.
triggerAuto-fire threshold. Omit ⇒ manual-only.
minReclaimTokensSkip a pass that reclaims less than this (cache-bust guard). Omit ⇒ no gate.
cooldownTurnsDon’t auto-fire within N turns of the last pass. Omit ⇒ no cooldown.
summaryPromptconditionalRequired when strategy includes "summarize".
summaryModelModel 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.

  1. drop-tool-results

    Replace old toolResult content with [cleared to save context], keeping the toolCall record 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 with show_tool_result so the model can re-read a cleared result on demand.

  2. 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 set summaryModel.

  3. 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.

ts
// 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().

ts
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

ts
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. Pin remember/recall so 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:

ts
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:

ts
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:

ts
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 of strategy + preserve, plus optional trigger, guards, and summariser config. No hidden defaults.
  • the transformContext seam — compaction runs before each LLM call, then the checkpoint snapshots the compacted array.
  • handle.compact(instructions?) — the manual trigger, mirroring pause().
  • the compaction event — one per committed pass, for observability.

Type to search…

↑↓ navigate open esc close