Compact
Same problem as Token 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:
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.
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
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 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.
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'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:
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:
qais the new summary text.createdAtis the epoch-zero sentinel (new Date(0).toISOString()) — guaranteed to sort before every real turn, so downstream consumers that sort bycreatedAtplace 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.
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
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).
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:
interface CompactionCostEvent {
calls: number // always 1
inTok: number
outTok: number
}
type OnCostFn = (event: CompactionCostEvent) => voidThese 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.
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:
- Primary Request and Intent
- Key Technical Concepts
- Files and Code Sections
- Errors and Fixes
- Problem Solving
- All User Messages
- Pending Tasks
- Current Work
- 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
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:
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:
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
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 for the isSummaryMessage option, and the hub's composability section 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
summariseAtTokensdown so Compact itself compresses earlier, rather than leaning on a downstream shed to compensate. - A rejected
summarizecall 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 — the paired subtractive strategy, and the
isSummaryMessageoption this page'sDEFAULT_SUMMARY_MESSAGE_IDcomposes with. - Token Thrift (The Loop) — the concept behind the sibling strategy and the research underpinning this whole domain.