Repository navigation
feat(ai-compaction): pluggable context-window compaction #1235
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 23 commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
cd413c4
feat(ai-compaction): add context-window compaction middleware
jherr 941d5d8
ci: apply automated fixes
autofix-ci[bot] 948b231
docs: add compaction guide
jherr 3f77a40
feat(ai-compaction): pluggable compaction strategies
jherr f9d6e1e
ci: apply automated fixes
autofix-ci[bot] 4dea0fb
feat(ai-compaction): add composeStrategies combinator
jherr 058921e
docs: document compaction + persistence interaction and test the seam
jherr eb89d09
feat(ai-persistence): stamp stable ids on persisted messages
jherr ed24a68
feat(ai-compaction): preserve history with persisted checkpoints (#1250)
AlemTuzlak 547dc33
fix(ai-compaction): keep trailing tool turns and skip init
AlemTuzlak e6c3edc
feat(ai-compaction): show compaction stats in AI DevTools
AlemTuzlak f3fc67b
fix(examples): use BYOK on compaction chat and expand DevTools rows
AlemTuzlak 58209db
fix(ai-devtools): selecting a hook leaves the dashboard
AlemTuzlak a1760ff
fix(examples): load compaction chat keys from .env
AlemTuzlak ac4834b
feat(ai-devtools): add a Compaction tab with before/after previews
AlemTuzlak 39a3b94
fix(ai-devtools): render compaction with existing timeline and messag…
AlemTuzlak ef9eac9
feat(ai-compaction): emit started, state, and ended events
AlemTuzlak 16d205a
feat(ai): add ctx.emitCustomEvent for live middleware events
AlemTuzlak b680a20
fix(ai-devtools): pad compaction before/after columns
AlemTuzlak 7362b08
fix(ai): accept ISO createdAt strings after JSON hydrate
AlemTuzlak 80ca70f
feat(examples): persist compaction chat with SQLite
AlemTuzlak 4fff8f6
Merge remote-tracking branch 'origin/main' into compaction
AlemTuzlak 8d5ce25
chore: drop createdAt hydrate changeset now that main has the fix
AlemTuzlak ad4b9a6
Merge latest main into compaction
AlemTuzlak 0b37970
fix: sherif dep order and oxlint prefer-readonly after main merge
AlemTuzlak 719263c
test: compaction skips init and keeps trailing tool groups
AlemTuzlak File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| --- | ||
| '@tanstack/ai-compaction': minor | ||
| --- | ||
|
|
||
| Add `@tanstack/ai-compaction` — context-window compaction as a `chat()` | ||
| middleware. `withCompaction({ maxTokens, strategy })` runs a pluggable | ||
| `CompactionStrategy` before each model call, so compaction is incremental and | ||
| rolling. Three strategies ship built in: `evictOldest` (drop old messages, the | ||
| default), `summarizeOldest` (replace them with an LLM summary), and | ||
| `clearToolResults` (stub old tool output, keep the messages). Combine them with | ||
| `composeStrategies`, which escalates through strategies until the transcript is | ||
| back under budget. Strategies preserve tool-call/result pairing and never touch | ||
| the system prompt. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| --- | ||
| '@tanstack/ai-compaction': minor | ||
| '@tanstack/ai-client': patch | ||
| '@tanstack/ai-event-client': patch | ||
| '@tanstack/ai-devtools-core': patch | ||
| --- | ||
|
|
||
| Show compaction in TanStack AI DevTools. `withCompaction` injects | ||
| `compaction:started`, `compaction:state`, and `compaction:ended` CUSTOM | ||
| stream events. State includes before/after counts, the token budget, and | ||
| dropped vs sent message previews. The chat client re-emits the same three | ||
| events. The AI panel has a Compaction tab and started/state/ended steps on | ||
| the iteration. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| --- | ||
| '@tanstack/ai': minor | ||
| '@tanstack/ai-compaction': minor | ||
| '@tanstack/ai-persistence': patch | ||
| --- | ||
|
|
||
| Keep canonical chat history separate from compacted provider context. Reuse | ||
| validated compaction checkpoints through an optional persistence metadata store. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| --- | ||
| '@tanstack/ai': minor | ||
| '@tanstack/ai-compaction': patch | ||
| --- | ||
|
|
||
| Add `ctx.emitCustomEvent` on chat middleware context. The engine yields | ||
| `CUSTOM` chunks while hooks such as `onConfig` are still running, so a long | ||
| middleware step can send progress before it finishes. Compaction uses this | ||
| to emit `compaction:started` before the strategy returns. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,247 @@ | ||
| --- | ||
| title: Compaction | ||
| id: compaction | ||
| order: 3 | ||
| description: "Keep long chats under the context limit with @tanstack/ai-compaction. withCompaction runs a pluggable strategy before each model call: evict, summarize, or clear old tool output." | ||
| keywords: | ||
| - tanstack ai | ||
| - compaction | ||
| - context window | ||
| - middleware | ||
| - token limit | ||
| - summarize history | ||
| --- | ||
|
|
||
| A long chat or a multi-step agent loop keeps adding messages. At some point the transcript passes the model's context limit and the call fails. You want the conversation to keep working without hitting that wall. | ||
|
|
||
| `withCompaction` shrinks provider context before each model call. When the context passes `maxTokens`, a **strategy** rewrites what the model sees. The canonical transcript does not change. Add this [`ChatMiddleware`](./middleware) to the `middleware` array of any `chat()` call. | ||
|
|
||
| ## Install | ||
|
|
||
| ```bash | ||
| pnpm add @tanstack/ai-compaction | ||
| ``` | ||
|
|
||
| ## Quick start | ||
|
|
||
| The default strategy drops the oldest messages once the transcript passes `maxTokens` and keeps the recent ones. | ||
|
|
||
| ```typescript | ||
| 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 } = await request.json(); | ||
|
|
||
| const stream = chat({ | ||
| adapter: openaiText("gpt-5.5"), | ||
| messages, | ||
| middleware: [withCompaction({ maxTokens: 100_000 })], | ||
| }); | ||
|
|
||
| return toServerSentEventsResponse(stream); | ||
| } | ||
| ``` | ||
|
|
||
| ## Pick a strategy | ||
|
|
||
| Pass `strategy` to change how the history shrinks. Three are built in. | ||
|
|
||
| | Strategy | What it does | Cost | | ||
| |----------|--------------|------| | ||
| | `evictOldest` (default) | Drop the oldest messages, leave a marker | No extra model call | | ||
| | `summarizeOldest` | Replace the oldest messages with an LLM summary | One summarize call | | ||
| | `clearToolResults` | Stub the content of old tool results, keep the messages | No extra model call | | ||
|
|
||
| ### evictOldest | ||
|
|
||
| Cheapest. Keeps the recent tail, drops the older head, and leaves a short marker in its place. This is the default, so you only name it to tune `keepRecentTokens`. | ||
|
|
||
| ```typescript | ||
| import { withCompaction, evictOldest } from "@tanstack/ai-compaction"; | ||
|
|
||
| withCompaction({ | ||
| maxTokens: 100_000, | ||
| strategy: evictOldest({ keepRecentTokens: 40_000 }), | ||
| }); | ||
| ``` | ||
|
|
||
| ### summarizeOldest | ||
|
|
||
| Keeps the gist of old turns instead of dropping them, at the cost of one summarization call. Pass a `summarize` callback. It gets the messages about to be dropped and returns the summary text. Wire it to `summarize()` or any model call. | ||
|
|
||
| ```typescript | ||
| import { chat, summarize, toServerSentEventsResponse } from "@tanstack/ai"; | ||
| import { openaiText, openaiSummarize } from "@tanstack/ai-openai"; | ||
| import { withCompaction, summarizeOldest } from "@tanstack/ai-compaction"; | ||
| import type { ModelMessage } from "@tanstack/ai"; | ||
|
|
||
| async function summarizeHistory(messages: Array<ModelMessage>): Promise<string> { | ||
| const text = messages | ||
| .map((m) => `${m.role}: ${typeof m.content === "string" ? m.content : ""}`) | ||
| .join("\n"); | ||
|
|
||
| const { summary } = await summarize({ | ||
| adapter: openaiSummarize("gpt-5.5"), | ||
| text, | ||
| }); | ||
| return summary; | ||
| } | ||
|
|
||
| export async function POST(request: Request) { | ||
| const { messages } = await request.json(); | ||
|
|
||
| const stream = chat({ | ||
| adapter: openaiText("gpt-5.5"), | ||
| messages, | ||
| middleware: [ | ||
| withCompaction({ | ||
| maxTokens: 100_000, | ||
| strategy: summarizeOldest({ summarize: summarizeHistory }), | ||
| }), | ||
| ], | ||
| }); | ||
|
|
||
| return toServerSentEventsResponse(stream); | ||
| } | ||
| ``` | ||
|
|
||
| ### clearToolResults | ||
|
|
||
| Best for agent loops. Tool output (file reads, command output) is usually most of the tokens. This strategy replaces the content of old tool results with a stub and keeps every message and its tool-call pairing in place. The conversation shape does not change. | ||
|
|
||
| ```typescript | ||
| import { withCompaction, clearToolResults } from "@tanstack/ai-compaction"; | ||
|
|
||
| withCompaction({ | ||
| maxTokens: 100_000, | ||
| // Keep the 5 most recent tool results in full, stub the older ones. | ||
| strategy: clearToolResults({ keepRecentToolResults: 5 }), | ||
| }); | ||
| ``` | ||
|
|
||
| ### Write your own | ||
|
|
||
| A strategy is a function. It gets the messages and the budget, and returns the rewritten messages, or `null` to change nothing. It runs only when the estimate is over `maxTokens`. | ||
|
|
||
| ```typescript | ||
| 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", | ||
| }); | ||
| ``` | ||
|
|
||
| Set `strategyKey` when you combine a custom strategy with persistence. Change | ||
| the key when the strategy can produce different output. This prevents an old | ||
| checkpoint from using stale behavior. | ||
|
|
||
| ## Combine strategies | ||
|
|
||
| `composeStrategies` runs several strategies in order and **escalates**: it stops as soon as the result is back under `maxTokens`. Put the cheap, targeted strategy first and a broad fallback last. Here it clears old tool output first, and only drops old messages if that was not enough. | ||
|
|
||
| ```typescript | ||
| import { | ||
| withCompaction, | ||
| composeStrategies, | ||
| clearToolResults, | ||
| evictOldest, | ||
| } from "@tanstack/ai-compaction"; | ||
|
|
||
| withCompaction({ | ||
| maxTokens: 100_000, | ||
| strategy: composeStrategies(clearToolResults(), evictOldest()), | ||
| }); | ||
| ``` | ||
|
|
||
| ## Options | ||
|
|
||
| ### withCompaction | ||
|
|
||
| | Option | Type | Default | Description | | ||
| |--------|------|---------|-------------| | ||
| | `maxTokens` | `number` | - | **Required.** Compact when the estimated tokens across `messages` pass this. | | ||
| | `strategy` | `CompactionStrategy` | `evictOldest()` | How to shrink the messages. | | ||
| | `estimateTokens` | `(message: ModelMessage) => number` | characters / 4 | Per-message token estimate. Pass a real tokenizer if you need exact counts. | | ||
| | `strategyKey` | `string` | built-in strategy identity | Stable checkpoint identity. 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. `info` is `{ before, after, messagesBefore, messagesAfter }` (token and message counts). | | ||
|
|
||
| ### Strategy options | ||
|
|
||
| | Strategy | Options | | ||
| |----------|---------| | ||
| | `evictOldest` | `keepRecentTokens` (default `maxTokens / 2`), `marker` | | ||
| | `summarizeOldest` | `summarize` (**required**), `keepRecentTokens`, `summaryRole` (default `assistant`) | | ||
| | `clearToolResults` | `keepRecentToolResults` (default `3`), `stub` | | ||
|
|
||
| The token count is a rough `characters / 4` estimate. It is good enough to trigger on, not exact. Pass `estimateTokens` for provider-accurate counts. | ||
|
|
||
| ## What it keeps safe | ||
|
|
||
| - **The system prompt is never dropped.** `chat()` keeps it separate from `messages`, so compaction only touches the conversation. | ||
| - **Tool calls stay paired with their results.** The built-in strategies never leave an orphaned tool result, so the request stays valid. | ||
| - **It runs before every model call.** Compaction is incremental: as the chat keeps growing it compacts again. | ||
| - **The canonical transcript stays complete.** Compaction writes provider-only context. Persistence and other middleware still read `ctx.messages`. | ||
|
|
||
| ## DevTools | ||
|
|
||
| After a compaction, the chat stream includes three CUSTOM events in order: | ||
| `compaction:started`, `compaction:state`, then `compaction:ended`. | ||
| `compaction:started` is sent before the strategy runs, so a slow | ||
| `summarizeOldest` call still shows up as started on the client. The state and | ||
| ended events follow when the strategy returns. | ||
| TanStack AI DevTools has a Compaction tab on the hook. Each compact shows: | ||
|
|
||
| - started, state, and ended rows | ||
| - when it ran, with token and message counts | ||
| - the `maxTokens` budget | ||
| - dropped messages | ||
| - the transcript sent to the model | ||
|
|
||
| The conversation timeline also keeps `onCompactStart`, `onCompact`, and | ||
| `onCompactEnd` steps. | ||
|
|
||
| Open the AI plugin in the DevTools panel (the `ts-react-chat` example mounts it). Select the Compaction hook, then open the Compaction tab. | ||
|
|
||
| The `/compaction` route in `examples/ts-react-chat` uses a small `maxTokens` so this fires after a few turns. It also uses `withPersistence` (SQLite). Reload after a compact: the chat still shows every message. The banner is example UI, not part of `useChat`. | ||
|
|
||
| ## Compaction and persistence | ||
|
|
||
| Compaction and server-side [`withPersistence`](../persistence/chat-persistence) | ||
| use two message views: | ||
|
|
||
| - `messages` is the complete canonical transcript. Persistence saves this view. | ||
| - `providerMessages` is temporary model context. Compaction rewrites this view. | ||
|
|
||
| Middleware order does not change this split. Dropped, summarized, and stubbed | ||
| content remains in the message store. | ||
|
|
||
| If the persistence adapter has a `metadata` store, compaction also saves a small | ||
| checkpoint. The next request validates the canonical prefix, restores the last | ||
| compacted result, and adds only new messages. A changed prefix or strategy key | ||
| invalidates the checkpoint. | ||
|
|
||
| When a checkpoint is reused, a later `summarizeOldest` pass sees the previous | ||
| summary plus new messages. Then it folds the old summary into the new one. | ||
| Folding needs a metadata store and a strategy key. | ||
|
|
||
| The default strategy, standard `evictOldest`, `summarizeOldest`, | ||
| `clearToolResults`, and safe compositions get a strategy key automatically. | ||
| Set `strategyKey` for custom strategies, custom estimators, or custom marker | ||
| functions. Change `strategyKey` when your `summarize` function can change. | ||
| Without a metadata store or safe key, compaction stays stateless. | ||
|
|
||
| ## Next steps | ||
|
|
||
| - [Middleware](./middleware): the full hook reference and how middleware composes | ||
| - [Built-in Middleware](./built-in-middleware): ready-made middleware that ships in `@tanstack/ai` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.