This page covers the details of compaction that you need only for special cases. Set up the basics first with Compaction.
| Option | Type | Default | Description |
|---|---|---|---|
| maxTokens | number | (ctx: { model }) => number | - | Required. Compact when the token count of messages passes this. See countTokens. A function gives the limit for the model of the call. See A limit for each model. |
| strategy | CompactionStrategy | evictOldest() | How to shrink the messages. |
| estimateTokens | (message: ModelMessage) => number | characters / 4 | The token estimate for one message. Pass a real tokenizer to get exact counts. |
| strategyKey | string | identity of the built-in strategy | A stable identity for checkpoints. Set it for custom strategies, custom estimators, or a custom eviction marker. Change it when your summarize function can change. |
| onCompact | (info: CompactionInfo) => void | - | Runs after each compaction. See onCompact. |
| countTokens | 'estimate' | 'usage' | 'estimate' | 'usage' counts with the usage that the provider reported for the last call. See Count with real usage. |
| auto | boolean | true | false turns off compaction at maxTokens. compactNext, the overflow retry, and the check after a run still compact. |
| contextWindow | number | (ctx: { model }) => number | - | The context window of the model. See Set the limit from the context window. |
| durable | boolean | false | On a host with a session log, write the result into the log. See Keep compaction in a session log. |
| continueOnError | boolean | false | When the strategy fails, send the full messages and continue. By default the run fails. |
| background | { atTokens: number | (ctx: { model }) => number } | - | Prepare the summary when the count passes atTokens, and apply it at the next run. See Prepare the summary in the background. |
| native | { compact?: (options) => Promise<ModelMessage[]> } | - | The adapter of the chat() call. When it has compact, the provider compacts in place of strategy. See Let the provider compact. |
| Strategy | Options |
|---|---|
| evictOldest | keepRecentTokens (default maxTokens / 2), marker |
| summarizeOldest | summarize (required), keepRecentTokens (default maxTokens / 2), summaryRole (default 'assistant'), cut (default 'message') |
| clearToolResults | keepRecentToolResults (default 3), stub |
The token count is a rough characters / 4 estimate. It is good enough to start a compaction, but it is not exact. Pass estimateTokens to get counts that match the provider.
onCompact gets { before, after, messagesBefore, messagesAfter, reason, usage, error, stale }:
A strategy is a function. It gets the messages and the budget. It returns the rewritten messages, or null to change nothing.
It runs when the count is over maxTokens. It also runs when compactNext forces it, so the count can be under maxTokens.
import { withCompaction } from "@tanstack/ai-compaction";
import type { CompactionStrategy } from "@tanstack/ai-compaction";
// Keep only the last message.
const keepLastOnly: CompactionStrategy = (messages) => {
if (messages.length <= 1) return null;
return messages.slice(-1);
};
withCompaction({
maxTokens: 100_000,
strategy: keepLastOnly,
strategyKey: "keep-last-v1",
});The second argument of the strategy has:
If you use a custom strategy with persistence, set strategyKey. When the strategy can give a different output, change the key. Then an old checkpoint cannot use stale behavior.
These rules apply when you pass native (see Let the provider compact):
After a context overflow, the retry compacts even under maxTokens. But a strategy can still find nothing to cut:
Set a small keepRecentTokens, so that the retry always drops something:
import { evictOldest, withCompaction } from "@tanstack/ai-compaction";
withCompaction({
maxTokens: 100_000,
strategy: evictOldest({ keepRecentTokens: 20_000 }),
});Call compactNext(threadId) to make the next call on a thread compact, even under maxTokens. For example, do this before you switch the thread to a model with a smaller context window.
Create the middleware once, so that compactNext reaches the same instance:
import { withCompaction } from "@tanstack/ai-compaction";
const compaction = withCompaction({ maxTokens: 100_000 });
// The next model call on this thread compacts first.
compaction.compactNext("thread-1");The characters / 4 estimate drifts on long agent runs. Code, JSON, and other languages do not split into tokens like English text. Then compaction runs too late and the call fails. Or it runs too early and drops context that you still need.
Set countTokens: 'usage'. After each model call, compaction saves the usage that the provider reported. The next check starts from that number and estimates only the messages after it.
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withCompaction } from '@tanstack/ai-compaction'
export async function POST(request: Request) {
const { messages, threadId } = await request.json()
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
middleware: [withCompaction({ maxTokens: 100_000, countTokens: 'usage' })],
})
return toServerSentEventsResponse(stream)
}Most models publish a context window. Keep room for the answer: set maxTokens to the window minus a reserve.
import { summarize } from '@tanstack/ai'
import { openaiSummarize } from '@tanstack/ai-openai'
import { summarizeOldest, withCompaction } from '@tanstack/ai-compaction'
const contextWindow = 200_000
const reserveTokens = 20_000
const windowed = withCompaction({
maxTokens: contextWindow - reserveTokens,
contextWindow,
countTokens: 'usage',
strategy: summarizeOldest({
keepRecentTokens: 8_000,
summarize: (messages, { threadId }) =>
summarize({
adapter: openaiSummarize('gpt-6.1-sol'),
text: messages
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : ''}`)
.join('\n'),
threadId: `${threadId}:compaction`,
}),
}),
})| You want | Set |
|---|---|
| Compact before the context passes the window minus a reserve | maxTokens: contextWindow - reserveTokens |
| Keep the newest messages word for word | keepRecentTokens on the strategy |
| On a host with a session log: no automatic compaction, but recovery from a silent overflow | auto: false, contextWindow, countTokens: 'usage', durable: true |
Some providers accept a request that is too large. They cut the input and answer without an error.
On a host with a session log, compaction with countTokens: 'usage' and durable: true checks once more after the last model call of a run. If the usage passed maxTokens or contextWindow, it compacts immediately, so the next run starts small.
A thread can change its model. To give each model its own limit, pass a function. It gets the model of the call and gives the limit:
import { withCompaction } from '@tanstack/ai-compaction'
const windows: Record<string, number> = { 'gpt-6.1-sol': 1_050_000 }
const windowOf = ({ model }: { model: string }) => windows[model] ?? 200_000
const perModel = withCompaction({
maxTokens: (ctx) => windowOf(ctx) - 20_000,
contextWindow: windowOf,
background: { atTokens: (ctx) => windowOf(ctx) - 50_000 },
})A summarizeOldest call takes time. When compaction runs at maxTokens, the user waits for that call before the model answers. Set background to prepare the summary earlier, while the chat continues:
import { chat, summarize, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiSummarize, openaiText } from '@tanstack/ai-openai'
import { summarizeOldest, withCompaction } from '@tanstack/ai-compaction'
// Create it once, so that a summary from one request reaches the next.
const compaction = withCompaction({
maxTokens: 100_000,
background: { atTokens: 80_000 },
strategy: summarizeOldest({
summarize: (messages, { threadId }) =>
summarize({
adapter: openaiSummarize('gpt-6.1-sol'),
text: messages
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : ''}`)
.join('\n'),
threadId: `${threadId}:compaction`,
}),
}),
})
export async function POST(request: Request) {
const { messages, threadId } = await request.json()
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
middleware: [compaction],
})
return toServerSentEventsResponse(stream)
}How the background summary runs:
Sometimes a summary does not apply:
onCompact always gets these reports. A compaction:ended event for a failed summary reaches the stream only while the run that started the summary still runs.
The basic summarizeOldest setup works for a chat. A coding agent needs more. It must keep the goal, the decisions, and the files that it touched across many compactions. Three opt-in parts help:
import { openaiText } from '@tanstack/ai-openai'
import {
conversationSummarizer,
summarizeOldest,
withCompaction,
} from '@tanstack/ai-compaction'
function pathOf(args: string) {
const input: unknown = JSON.parse(args)
return typeof input === 'object' &&
input !== null &&
'path' in input &&
typeof input.path === 'string'
? input.path
: undefined
}
const agentCompaction = withCompaction({
maxTokens: 180_000,
contextWindow: 200_000,
countTokens: 'usage',
strategy: summarizeOldest({
cut: 'turn',
keepRecentTokens: 8_000,
summarize: conversationSummarizer({
adapter: openaiText('gpt-6.1-sol'),
details: ({ messages, previousDetails }) => {
const files = new Set(previousDetails?.split('\n').filter(Boolean))
for (const message of messages) {
for (const call of message.toolCalls ?? []) {
const path =
call.function.name === 'read_file'
? pathOf(call.function.arguments)
: undefined
if (path) files.add(path)
}
}
return files.size > 0 ? [...files].join('\n') : undefined
},
}),
}),
})After a compaction, the chat stream includes three CUSTOM events in this order:
Durability flushes each event when it is emitted. So a slow summarizeOldest call still shows as started on the client while it runs.
compaction:ended has these fields:
A compaction after the last model call of a run has reason: 'after-turn'. The stream is closed by then, so only onCompact reports it. A failed strategy or onCompact there does not fail the run, because the answer is complete. Only a failed write to the log fails it.
TanStack AI DevTools has a Compaction tab on the hook. Each compaction shows:
The conversation timeline also keeps onCompactStart, onCompact, and onCompactEnd steps.
To see it:
The /compaction route in examples/ts-react-chat uses a small maxTokens, so compaction runs after a few turns. It also uses withPersistence (SQLite). Reload after a compaction: the chat still shows every message. The banner is example UI, not part of useChat.
If the persistence adapter has a metadata store, compaction saves a small checkpoint:
When compaction reuses a checkpoint, a later summarizeOldest pass sees the previous summary plus new messages. Then it merges the old summary into the new one. This merge needs a metadata store and a strategy key.
These strategies get a strategy key automatically:
Set strategyKey for custom strategies, custom estimators, or custom marker functions. When your summarize function can change, change strategyKey too. Without a metadata store or a safe key, compaction stays stateless.
With durable: true on a host with a session log, compaction writes its result into the log instead. See the next section.
Some hosts keep a durable log for each thread. They rebuild the model context from it when a session opens again. A metadata checkpoint can miss on that rebuild. Set durable: true so that the compaction goes into the log, and the rebuilt session sees the same context.
import type { ModelMessage } from '@tanstack/ai'
import { projectCompaction, withCompaction } from '@tanstack/ai-compaction'
const compaction = withCompaction({
maxTokens: 180_000,
contextWindow: 200_000,
countTokens: 'usage',
durable: true,
})
type LogEntry =
| { kind: 'message'; message: ModelMessage }
| { kind: 'record'; record: { type: string; [key: string]: unknown } }
// Fold the log in order. A compaction record replaces the messages before it.
function contextOf(log: Array<LogEntry>): Array<ModelMessage> {
let messages: Array<ModelMessage> = []
for (const entry of log) {
if (entry.kind === 'message') messages = [...messages, entry.message]
else messages = projectCompaction({ messages, record: entry.record }) ?? messages
}
return messages
}