Context compaction
Bound a chat loop's message history by folding stale turns into a running summary from an explicit compaction state.
The problem
The Vercel AI SDK core does not auto-compact messages. Resend the full array every turn and a long conversation grows without bound:
- Cost and latency scale with history length.
- Eventually the context window overflows and the call fails.
This is userland work, and this library's answer is to make it a machine state, not hidden middleware:
- The compaction boundary is visible in the state chart.
- You can pause, persist, and inspect exactly when the agent compacts.
- The trigger is an authored transition, not a heuristic buried in a wrapper.
examples/context-compaction/index.ts is the full runnable example; its tests drive the loop with mock executors.
Context shape
The machine carries the history, the running summary, and the thresholds in its own context:
context: z.object({
messages: z.custom<AgentMessage[]>((value) => Array.isArray(value)),
summary: z.string().nullable(),
turns: z.number(),
maxMessages: z.number(), // compact once history grows past this
keepRecent: z.number(), // messages kept verbatim after compaction
pendingInput: z.string().nullable(),
}),maxMessages and keepRecent arrive as machine input with defaults, so the same machine runs with different budgets.
The two requests
Two requests entries on setupAgent:
respond: the chat reply. Rendered from the running summary (as a system message) plus only the recent messages.summarize: folds the prior summary and the stale messages into one compact summary, as structured outputz.object({ summary: z.string() }).
respond: {
// ...
messages: ({ input }) => [
...(input.summary
? [systemMessage(`Summary of earlier conversation:\n${input.summary}`)]
: []),
...input.messages,
],
},The summarize system prompt tells the model what survives compaction: concrete facts, names, numbers, decisions, open questions; pleasantries dropped.
The loop
awaitingUser → routingInput → responding → checkingWindow ──→ awaitingUser
│ │
└→ done (final) └→ compacting → awaitingUserawaitingUserinvokesagent.userInput; typingexit(or nothing) routes todone.respondinginvokesrespondand appends the user and assistant messages.checkingWindowis atype: 'choice'state: over budget goes tocompacting, otherwise back to the prompt.
checkingWindow: {
type: 'choice',
choice: ({ context }) =>
context.messages.length > context.maxMessages
? { target: 'compacting' }
: { target: 'awaitingUser' },
},The compacting state
compacting summarizes everything except the last keepRecent messages, then keeps only those:
compacting: {
invoke: {
src: 'summarize',
input: ({ context }) => ({
priorSummary: context.summary,
staleMessages: context.messages.slice(0, -context.keepRecent),
}),
onDone: ({ context, output }) => ({
target: 'awaitingUser',
context: {
summary: output.summary,
messages: context.messages.slice(-context.keepRecent),
},
}),
// If summarization fails, keep going without dropping history.
onError: { target: 'awaitingUser' },
},
},Passing priorSummary back in makes the summary running: each compaction folds the previous one in, so no fact needs to survive more than one hop.
Summary as context
After compaction, every respond call sends the summary as a system message plus the last keepRecent messages. Tokens per turn stay bounded no matter how long the conversation runs; older turns stay available as compacted facts rather than verbatim transcript.
Tuning the thresholds
- Higher
maxMessages: fewer summarize calls, more verbatim fidelity, larger per-turn context. - Higher
keepRecent: recent nuance survives compaction, at the cost of window size. keepRecentshould stay comfortably belowmaxMessages, or the machine compacts on nearly every turn.
Counting messages is the simplest trigger. The same shape works with a token estimate: change the checkingWindow predicate, nothing else moves.
Testing without a model
Executors are injected, so the tests in examples/context-compaction/index.test.ts drive the full loop with a mock generateText and a scripted userInput, asserting that:
- History is capped at
keepRecentand the summary comes from the summarize request. - The first
respondafter compaction receives the summary as a system message. exitsettlesdonewith{ summary, messages, turns }.
Note: route on
request.name. Every lowered request carries itssetupAgent({ requests })key asname, so a mock (or a router picking providers per request) tellsrespondfromsummarizewithrequest.name === 'summarize'(see the tests above). Do not sniffsystem/prompttext for routing.
Extending
- Token budget: swap the message-count predicate for a token estimate over
context.messages. - Persist at the boundary:
compactingis a real state, so snapshot persistence can checkpoint right before or after it. - Different retention: keep pinned messages (system instructions, tool results) out of the stale slice before summarizing.
Related
- Text requests: declaring
respondandsummarizewith typed schemas. - Messages: the
AgentMessageshape and message helpers. - Human in the loop:
agent.userInputand the idle-first waiting model.