---
url: 'https://adk.nht.io/batteries/context/compact.md'
description: >-
  The summarize-over-threshold context-management battery:
  assembleCompactedTurns, summariseTurns, the injected SummarizeFn seam, the
  canonical DispatchRunner recipe, and the COMPACTION_SYSTEM_PROMPT it defaults
  to.
---

# Compact

## LLM summary — Compact battery (API reference)

* Import from `@nhtio/adk/batteries/context/compact`.
* **`assembleCompactedTurns(turns, options)`** — the whole pass. `turns: HistoryTurn[]` (reuses the shape from `@nhtio/adk/batteries/context/thrift`'s `groupHistoryIntoTurns`). Splits into `older`/`recent` at `keepVerbatim` (default 2). If `older.length === 0`, returns unchanged. Otherwise re-summarizes when `older.length > coveredOlder && (summary === null || olderTokens > summariseAtTokens)` (line-for-line port of the original flagship condition). Returns `{ turns, summary, coveredOlder }` — `turns` is `[syntheticSummaryTurn?, ...recent]`.
* The synthetic summary turn sorts first: `createdAt = new Date(0).toISOString()` (epoch-zero sentinel), one message `{ id: summaryMessageId, role: 'user', content: '[Earlier conversation, compacted summary]\n' + summaryText, createdAt: sentinelCreatedAt }`.
* **`summariseTurns(historyText, priorSummary, options)`** — the one-shot summarizer call. Builds a prompt (`PREVIOUS SUMMARY (extend/merge, do not lose facts)` + prior summary, then `CONVERSATION TO SUMMARISE` + historyText, sliced to a 12,000-char cap), calls `options.summarize({ system, text })`, fires `onCost({ calls: 1, inTok, outTok })` (token estimates via the injected `estimateTokens`, not metered usage).
* **`SummarizeFn = (req: { system: string; text: string }) => Promise<string>`** — the one unavoidable model-call seam, injected, never bundled. TSDoc on the contract embeds the canonical recipe: build it over [`DispatchRunner`](https://adk.nht.io/api/@nhtio/adk/dispatch_runner/classes/DispatchRunner)'s `dispatch` with an all-noop [`RawTurnContext`](https://adk.nht.io/api/@nhtio/adk/types/interfaces/RawTurnContext) except `storeMessage`, which captures the completion string.
* **Deliberate deviation from the original flagship code**: the original swallowed a failed summarizer call (degraded silently to the prior summary). This battery lets a REJECTED `summarize` promise propagate UNCAUGHT — a battery has no chat-turn context to decide whether silent degradation is the right failure mode; that decision belongs to the caller.
* `DEFAULT_KEEP_VERBATIM = 2`, `DEFAULT_SUMMARISE_AT_TOKENS = 2500`, `DEFAULT_SUMMARY_MESSAGE_ID = '__compact-summary'` (cross-battery contract with thrift's `isSummaryMessage` default — overriding one without the other breaks the protection), `COMPACTION_SYSTEM_PROMPT` (default `systemPrompt`, 9 numbered sections, extracted verbatim from the flagship agent's real ~4700-token auto-compactions).
* `priorState: { summary: string | null; coveredOlder: number }` is the caller's rolling-persistence contract — read it in, pass the returned `{ summary, coveredOlder }` back in on the next call.
* Evaluation (see the [hub](./) for the full matrix): compact tops two cells outright — kimi-k2.5 @ 128k (1.48 vs. thrift's 1.13) and the 128k gemma-31b control (1.48 vs 1.35), both 3-judge panels. Cost: ~80-89 summarizer calls and ~380k-550k extra tokens per 94-turn run. Degenerates at a 1M window (threshold never fires, 0 calls — behaves like naive accumulation). Under a tight downstream budget, whatever sheds recent verbatim turns to fit is UNFAITHFUL to real compaction products (which re-summarize tighter instead of dropping turns) — tune `summariseAtTokens` down rather than lean on a downstream shed.

Same problem as [Token Thrift](./thrift) — a dispatch's working set outgrows its window — but a different
bet: instead of cutting old turns to fit, Compact pays for one summarizer call to fold them into a running
recap, trading real API cost for keeping compressed signal from everything it folds away.

Compact ships with `@nhtio/adk` — no separate install. Import it from the battery subpath:

```ts
import { assembleCompactedTurns, type SummarizeFn } from '@nhtio/adk/batteries/context/compact'
```

This page is API/usage reference. For the evaluated numbers against Token Thrift and naive recency — including
the two cells where Compact wins — see the [domain hub](./). For Token Thrift's own reference page (the sibling
strategy Compact composes with via `isSummaryMessage`), see [thrift.md](./thrift).

## The thesis, briefly

The mechanics: keep the newest `keepVerbatim` turns in full, and once everything *older* than that grows past
a token threshold, pay for one summarization call to fold it into a **rolling summary** — a running summary
message that stands in for everything folded so far. On the next threshold crossing, the prior summary is
folded back into the new summarization request, so detail degrades gracefully across many compactions instead
of being recomputed from scratch or lost outright. This is a direct, faithful extraction of the flagship
reference agent's own production auto-compaction — same schema, same prompt, lifted rather than reinvented.

## `assembleCompactedTurns` — the whole pass

```ts
function assembleCompactedTurns<M extends RelevanceMessage, TC extends RelevanceToolCall>(
  turns: ReadonlyArray<HistoryTurn<M, TC>>,
  options: AssembleCompactedTurnsOptions
): Promise<AssembleCompactedTurnsResult<M, TC>>
```

`turns` is the same `HistoryTurn[]` shape produced by
[`groupHistoryIntoTurns`](./thrift#relevance-based-turn-selection) in the Token Thrift battery — Compact reuses
that type directly (a relative, battery-internal import) rather than redeclaring it, since both batteries
operate on the same upstream turn grouping.

```ts
interface AssembleCompactedTurnsOptions {
  summarize: SummarizeFn // required
  estimateTokens: EstimateTokensFn // required
  encoding?: string // default 'cl100k_base'
  keepVerbatim?: number // default 2
  summariseAtTokens?: number // default 2500
  systemPrompt?: string // default COMPACTION_SYSTEM_PROMPT
  summaryMessageId?: string // default '__compact-summary'
  onCost?: OnCostFn
  priorState?: { summary: string | null; coveredOlder: number }
}

interface AssembleCompactedTurnsResult<M, TC> {
  turns: HistoryTurn<M, TC>[]
  summary: string | null
  coveredOlder: number
}
```

Both `summarize` and `estimateTokens` are required — the call throws immediately if either is missing, the same
fail-loud posture as Token Thrift's `estimateTokens` requirement. Measuring under a custom (non-built-in)
encoding? Register it once with [`Tokenizable`](https://adk.nht.io/api/@nhtio/adk/common/classes/Tokenizable)'s own `registerTokenEstimator`, then keep passing
`Tokenizable.estimateTokens` — same recipe as Token Thrift's own `estimateTokens` injection, see its docs page
for the full rationale.

The pass: split `turns` into `older` (everything before the newest `keepVerbatim`) and `recent` (the newest
`keepVerbatim`, always kept verbatim). If `older` is empty, there's nothing to compact — return `turns`
unchanged, `summary`/`coveredOlder` passed through from `priorState` (the caller's own persisted
`{ summary, coveredOlder }` from the previous call — Compact has no memory of its own, so the caller reads
this in and writes the result back out every time). Otherwise, join `older` into a single text blob and
measure it. Re-summarization is gated by a condition ported line-for-line from the original flagship
implementation:

```ts
older.length > coveredOlder && (summary === null || olderTokens > summariseAtTokens)
```

In words: summarize again only if there's genuinely new older content beyond what the last summary already
covered, AND (there is no summary yet, OR the older region has grown past the threshold since the last summary).
This is what makes the threshold a real gate rather than a per-call re-summarize: a caller invoking
`assembleCompactedTurns` every turn only pays for a new summarization call when the older region has actually
grown enough to matter.

When re-summarization fires, the result is a synthetic **summary turn** prepended to `recent`:

* `qa` is the new summary text.
* `createdAt` is the epoch-zero sentinel (`new Date(0).toISOString()`) — guaranteed to sort before every real
  turn, so downstream consumers that sort by `createdAt` place it first without special-casing.
* One message: `{ id: summaryMessageId, role: 'user', content: '[Earlier conversation, compacted summary]\n' + summaryText, createdAt: <same sentinel> }`.
* `toolCalls: []`.

The returned `{ turns: [summaryTurn, ...recent], summary, coveredOlder }` is what you feed downstream — e.g.
straight into Token Thrift's `subtractToFit` as the working set's `messages`, exactly like any other
turn-derived history.

```ts
import { assembleCompactedTurns, type SummarizeFn } from '@nhtio/adk/batteries/context/compact'
import { groupHistoryIntoTurns } from '@nhtio/adk/batteries/context/thrift'
import { Tokenizable } from '@nhtio/adk'

const summarize: SummarizeFn = async ({ system, text }) => {
  const completion = await myLlmCall({ system, prompt: text })
  return completion.trim()
}

const turns = groupHistoryIntoTurns(myMessages, myToolCalls)

// Persist this across turns — read it in, write the result back out.
let priorState: { summary: string | null; coveredOlder: number } | undefined

const result = await assembleCompactedTurns(turns, {
  summarize,
  estimateTokens: (value, encoding, ctx) => Tokenizable.estimateTokens(value, encoding, ctx),
  priorState,
  onCost: (event) => trackCompactionCost(event), // { calls: 1, inTok, outTok }
})

priorState = { summary: result.summary, coveredOlder: result.coveredOlder }
// result.turns feeds downstream — e.g. into thrift's subtractToFit as ws.messages.
```

## `summariseTurns` — the one-shot call underneath

```ts
function summariseTurns(
  historyText: string,
  priorSummary: string | null,
  options: SummariseTurnsOptions
): Promise<string>
```

`assembleCompactedTurns` calls this internally when its threshold condition fires, but it's also exported
standalone for a caller who wants to drive summarization directly (e.g. summarizing an arbitrary text blob that
isn't turn-shaped).

```ts
interface SummariseTurnsOptions {
  summarize: SummarizeFn // required
  estimateTokens: EstimateTokensFn // required
  encoding?: string // default 'cl100k_base'
  systemPrompt?: string // default COMPACTION_SYSTEM_PROMPT
  onCost?: OnCostFn
}
```

The prompt it assembles:

```
PREVIOUS SUMMARY (extend/merge, do not lose facts):
<priorSummary>

---

CONVERSATION TO SUMMARISE:
<historyText>
```

(the `PREVIOUS SUMMARY` block is omitted entirely when `priorSummary` is `null`), sliced to a 12,000-character
cap before being sent — the same cap the original flagship implementation applied to its own summarizer input.
`options.summarize({ system, text })` is called with `system` = the resolved `systemPrompt` and `text` = the
assembled prompt above; the result is trimmed and returned, falling back to `priorSummary ?? ''` only if
`summarize` resolves with an empty string. `onCost` — if provided — fires once per call with token estimates for
both sides of the exchange, via the same injected `estimateTokens`:

```ts
interface CompactionCostEvent {
  calls: number // always 1
  inTok: number
  outTok: number
}
type OnCostFn = (event: CompactionCostEvent) => void
```

These are estimates from your own `estimateTokens`, not metered provider usage — useful for the same kind of
budget accounting Token Thrift's trace gives you, but not a substitute for real usage metering if you need
billing-accurate numbers.

::: warning A rejected `summarize` call propagates
The original flagship implementation this was extracted from swallowed a failed summarizer call and degraded
silently to the prior summary. This battery deliberately does **not** do that: if the injected `summarize`
promise rejects, `summariseTurns`/`assembleCompactedTurns` let that rejection propagate uncaught. A battery has
no chat-turn context to know whether "keep going with the last known-good summary" is an acceptable failure
mode for your pipeline — that decision belongs to the caller, who should wrap the call and decide explicitly
whether to degrade, retry, or abort the dispatch.
:::

## `COMPACTION_SYSTEM_PROMPT` — the default system prompt

`assembleCompactedTurns`/`summariseTurns` default `systemPrompt` to `COMPACTION_SYSTEM_PROMPT`, an exported
constant string extracted verbatim from the flagship reference agent's real production auto-compactions
(observed across 28 real compactions during its own development). It instructs the summarizer to produce a
structured recap across nine numbered sections:

1. Primary Request and Intent
2. Key Technical Concepts
3. Files and Code Sections
4. Errors and Fixes
5. Problem Solving
6. All User Messages
7. Pending Tasks
8. Current Work
9. Next Step

This is the structured-recap schema popularized by coding-agent auto-compaction — deliberately, since the
flagship agent this battery was extracted from is built the same way. Pass your own `systemPrompt` to replace it entirely if your
domain needs a different recap shape; there's no partial-override mechanism (the whole string is replaced, not
merged).

## The `SummarizeFn` seam — the one unavoidable model call

```ts
type SummarizeFn = (req: { system: string; text: string }) => Promise<string>
```

This is the one capability Compact cannot bundle: an actual model completion. Like Token Thrift's
`estimateTokens`, it's an injected function — no default, no bundled transport, no assumed adapter. Two ways to
satisfy it, from simplest to most integrated:

### The trivial version — any HTTP-reachable completion endpoint

If you already have a completion endpoint (your own proxy, a hosted API, anything that takes a system prompt
and text and returns a string), `SummarizeFn` is a thin wrapper:

```ts
const summarize: SummarizeFn = async ({ system, text }) => {
  const response = await fetch('https://your-llm-endpoint/v1/complete', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ system, prompt: text, max_tokens: 1200 }),
  })
  const body = await response.json()
  return body.completion as string
}
```

This is enough to prove the battery is genuinely transport-independent — nothing about `assembleCompactedTurns`
cares how `summarize` gets its answer.

### The canonical recipe — building `SummarizeFn` over `DispatchRunner.dispatch`

The flagship agent's real implementation (and the pattern this battery's own contract TSDoc documents as
canonical) builds `SummarizeFn` as a purpose-built, isolated dispatch: run `DispatchRunner.dispatch` with an
all-noop `RawTurnContext`, except for the one capability that captures the model's output. This gets you a
"fake turn" purely to obtain one completion string, using your already-configured adapter and executor rather
than a separate HTTP client:

```ts
import { DispatchRunner, type RawTurnContext } from '@nhtio/adk'
import type { SummarizeFn } from '@nhtio/adk/batteries/context/compact'

function buildSummarizeFn(adapter: MyLlmAdapter): SummarizeFn {
  return async ({ system, text }) => {
    let out = ''

    const ctx: RawTurnContext = {
      // Every capability is a noop except storeMessage, which captures the completion.
      systemPrompt: () => system,
      standingInstructions: () => [],
      messages: () => [{ role: 'user', content: text }],
      memories: () => [],
      retrievables: () => [],
      thoughts: () => [],
      tools: () => [],
      storeMessage: (message) => {
        out = typeof message.content === 'string' ? message.content : out
      },
      // ...remaining RawTurnContext capabilities as noops appropriate to your adapter version
    }

    const executor = await adapter.executor({
      maxTokens: 1200,
      toolCallParser: 'none',
      stream: false,
      autoAck: true,
      enableThinking: false,
    })

    const runner = new DispatchRunner(executor)
    await runner.dispatch(ctx)

    return out
  }
}
```

The exact `RawTurnContext` shape (which capabilities exist, their precise signatures) depends on your installed
`@nhtio/adk` version — check `DispatchRunner`'s and `RawTurnContext`'s own TypeDoc for the authoritative,
current list of noop-able capabilities. The shape above is the pattern, not a copy-paste-exact contract; the
constant across versions is: mint a **fresh** runner per call (runners are single-use), keep the executor
options minimal (`toolCallParser: 'none'` — a summarization call doesn't need tool-call parsing), and capture
the output via whatever capability persists the assistant's turn.

## `DEFAULT_SUMMARY_MESSAGE_ID` — the cross-battery contract

```ts
const DEFAULT_SUMMARY_MESSAGE_ID = '__compact-summary'
```

This is the id `assembleCompactedTurns` stamps onto its synthetic summary message by default, and it is also
the exact string Token Thrift's `isSummaryMessage` default predicate checks for (`(m) => m.id === '__compact-summary'`).
This is a deliberate, explicit cross-battery contract, not a coincidence: it's what lets a pipeline run Compact
upstream and Thrift downstream without Thrift's oldest-turn shed (step 7 of its subtractive pass) treating the
rolling summary as just another old message to discard.

If you override `summaryMessageId` on the Compact side, you must also override `isSummaryMessage` to match on
the Thrift side (or vice versa) — the two options are one contract, not two independent knobs. See
[Token Thrift's reference](./thrift#subtracttofit-options) for the `isSummaryMessage` option, and the
[hub's composability section](./#the-two-compose) for the full picture of running both together.

## Honest limits

* **Cost is real, not incidental.** Roughly 80-89 summarizer calls and 380k-550k extra tokens over a 94-turn
  evaluation run, on every cell where the threshold fires. This is the fundamental trade-off Compact makes, not
  an implementation detail to optimize away.
* **It degenerates on a huge window.** At a 1M-token window, the older-region threshold never crossed across
  the full evaluated run — zero summarizer calls fired, and Compact behaved identically to unbounded naive
  accumulation. It only earns its cost under genuine context pressure.
* **Under a tight downstream budget, fidelity to real compaction slips.** Whatever shedding a caller layers on
  top to fit the *final* dispatch (for instance, Token Thrift's own subtractive pass running downstream) will
  drop verbatim recent turns outright once the window is tight — unlike real coding-agent auto-compaction
  products, which respond to pressure by re-summarizing *more aggressively and sooner*, not by silently dropping
  turns. If your deployment needs that fidelity under tight budgets, tune `summariseAtTokens` down so Compact
  itself compresses earlier, rather than leaning on a downstream shed to compensate.
* **A rejected `summarize` call is the caller's problem to handle**, by design — see the warning above.

## Where to go next

* [Context Batteries hub](./) — the full head-to-head matrix, and when to reach for Compact vs. Thrift.
* [Token Thrift battery reference](./thrift) — the paired subtractive strategy, and the `isSummaryMessage`
  option this page's `DEFAULT_SUMMARY_MESSAGE_ID` composes with.
* [Token Thrift (The Loop)](/the-loop/token-thrift) — the concept behind the sibling strategy and the research
  underpinning this whole domain.
