Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/compaction-persistence-integration.md
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.
12 changes: 0 additions & 12 deletions .changeset/persistence-stable-message-ids.md

This file was deleted.

36 changes: 26 additions & 10 deletions docs/advanced/compaction.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ keywords:

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 the history before each model call. When the transcript passes `maxTokens`, it runs a **strategy** that rewrites the messages. It is an ordinary [`ChatMiddleware`](./middleware), so you add it to the `middleware` array of any `chat()` call.
`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

Expand Down Expand Up @@ -135,9 +135,17 @@ const keepLastOnly: CompactionStrategy = (messages) => {
return messages.slice(-1);
};

withCompaction({ maxTokens: 100_000, strategy: keepLastOnly });
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.
Expand Down Expand Up @@ -165,6 +173,7 @@ withCompaction({
| `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, `summarizeOldest`, or a custom eviction marker. |
| `onCompact` | `(info: CompactionInfo) => void` | - | Runs after each compaction. `info` is `{ before, after, messagesBefore, messagesAfter }` (token and message counts). |

### Strategy options
Expand All @@ -182,21 +191,28 @@ The token count is a rough `characters / 4` estimate. It is good enough to trigg
- **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, and a later `summarizeOldest` pass folds an earlier summary into the new one.
- **The canonical transcript stays complete.** Compaction writes provider-only context. Persistence and other middleware still read `ctx.messages`.

## Compaction and persistence

Compaction rewrites the messages the model sees. If you also save the thread on the server, know which copy you save.

`withCompaction` and server-side [`withPersistence`](../persistence/chat-persistence) share one message array for the run. Compaction shrinks that array, and `withPersistence` saves it on finish with a full-overwrite `saveThread`. So the stored thread becomes the compacted one. Dropped, summarized, or stubbed messages are gone from the store. The middleware order does not change this.
Compaction and server-side [`withPersistence`](../persistence/chat-persistence)
use two message views:

This is what you want when the compacted thread is the memory. It is data loss when you expected the store to keep every message.
- `messages` is the complete canonical transcript. Persistence saves this view.
- `providerMessages` is temporary model context. Compaction rewrites this view.

Two ways to keep a full transcript and still compact:
Middleware order does not change this split. Dropped, summarized, and stubbed
content remains in the message store.

- **Client-authoritative persistence.** The browser keeps the full transcript. The server compacts only for the model call. See [Client persistence](../persistence/client-persistence).
- **Save the transcript yourself first.** Persist the incoming `messages`, then call `chat()` with compaction.
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.

Do you use server-side [Chat persistence](../persistence/chat-persistence) and want the saved thread to stay readable? Prefer `clearToolResults` or `summarizeOldest` over `evictOldest`. They keep the shape of the conversation instead of dropping turns.
The default strategy, standard `evictOldest`, `clearToolResults`, and safe
compositions get a strategy key automatically. Set `strategyKey` for
`summarizeOldest`, custom strategies, custom estimators, or custom marker
functions. Without a metadata store or safe key, compaction stays stateless.

## Next steps

Expand Down
10 changes: 8 additions & 2 deletions docs/advanced/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,14 +157,19 @@ const dynamicTemperature: ChatMiddleware = {

| Field | Type | Description |
|-------|------|-------------|
| `messages` | `ModelMessage[]` | Conversation history |
| `messages` | `ModelMessage[]` | Canonical conversation history. Persistence and `ctx.messages` use this field. |
| `providerMessages` | `ModelMessage[]` | Temporary context sent to the provider. Defaults to `messages`. |
| `systemPrompts` | `string[]` | System prompts |
| `tools` | `Tool[]` | Available tools |
| `metadata` | `Record<string, unknown>` | Request metadata |
| `modelOptions` | `Record<string, unknown>` | Provider-native options — this is where sampling params (`temperature`, `top_p` / `topP`, the provider's `max*Tokens` key) now live, alongside every other model-specific knob. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). |

When multiple middleware define `onConfig`, the config is **piped** through them in order — each receives the merged config from the previous middleware.

Return `providerMessages` when a transform must affect only the model call. For
compatibility, returning `messages` also updates provider input unless the same
result sets `providerMessages` explicitly.

### onStructuredOutputConfig

Called once at the start of the final structured-output adapter call — only when `chat()` was invoked with `outputSchema` **and** `supportsCombinedToolsAndSchema()` does not return `true` for the current model/options. Pipes through middleware in order, like `onConfig`, but with access to the **JSON Schema** being sent to the provider. Use this hook when you need to transform the schema (e.g., inject `$defs`, strip vendor-incompatible keywords) or apply structured-output-specific behavior (e.g., suppress system prompts on the final call).
Expand Down Expand Up @@ -195,7 +200,8 @@ const injectDefs: ChatMiddleware = {

| Field | Type | Description |
|-------|------|-------------|
| `messages` | `ModelMessage[]` | Conversation history sent to the final call |
| `messages` | `ModelMessage[]` | Canonical conversation history |
| `providerMessages` | `ModelMessage[]` | Temporary context sent to the final call |
| `systemPrompts` | `SystemPrompt[]` | System prompts on the final call |
| `metadata` | `Record<string, unknown>` | Request metadata |
| `modelOptions` | `Record<string, unknown>` | Provider-native options — this is where sampling params (`temperature`, `top_p` / `topP`, the provider's `max*Tokens` key) now live, alongside every other model-specific knob. See [Moving Sampling Options into modelOptions](../migration/sampling-options-to-model-options). |
Expand Down
8 changes: 4 additions & 4 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@
"label": "Chat Persistence",
"to": "persistence/chat-persistence",
"addedAt": "2026-08-04",
"updatedAt": "2026-08-25"
"updatedAt": "2026-08-26"
},
{
"label": "Client Persistence",
Expand Down Expand Up @@ -348,7 +348,7 @@
"label": "Store Reference",
"to": "persistence/store-reference",
"addedAt": "2026-08-04",
"updatedAt": "2026-08-25"
"updatedAt": "2026-08-26"
},
{
"label": "How Persistence Works",
Expand Down Expand Up @@ -537,7 +537,7 @@
"label": "Middleware",
"to": "advanced/middleware",
"addedAt": "2026-04-15",
"updatedAt": "2026-08-24"
"updatedAt": "2026-08-26"
},
{
"label": "Built-in Middleware",
Expand All @@ -549,7 +549,7 @@
"label": "Compaction",
"to": "advanced/compaction",
"addedAt": "2026-08-24",
"updatedAt": "2026-08-25"
"updatedAt": "2026-08-26"
},
{
"label": "Locks",
Expand Down
13 changes: 8 additions & 5 deletions docs/persistence/chat-persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,13 +91,16 @@ generation hooks. [How persistence works](./internals) has the rest.
middleware loads the stored transcript and the run picks up from there, so the
client does not have to re-send history.

## Compaction rewrites what you save
## Compaction keeps the transcript complete

Do you add [`withCompaction`](../advanced/compaction) to the same `chat()`? The
saved thread is the compacted one. Compaction and `withPersistence` share the
message array of the run, and `saveThread` overwrites the thread in full. The
stored transcript then matches what the model saw, not the original messages. To
keep a full transcript, see
saved thread remains canonical. Compaction changes only the provider context,
not `ctx.messages`. The message store keeps dropped content, summaries do not
replace old turns, and cleared tool output remains available for reloads.

If your adapter provides `stores.metadata`, `withPersistence` exposes it to
other middleware. Compaction uses it automatically for validated checkpoints.
See
[Compaction and persistence](../advanced/compaction#compaction-and-persistence).

## What gets persisted, and when
Expand Down
31 changes: 6 additions & 25 deletions docs/persistence/store-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,31 +40,6 @@ interface MessageStore {
`saveThread` receives the full authoritative model-message history, not a delta.
`loadThread` returns `[]` (never `null`) for a thread that was never saved.

### Storing messages per row

The simplest `saveThread` writes the whole transcript as one row: a JSON blob
keyed by `threadId`. That is what the shipped adapters do, and it stays cheap
until threads get very long.

To store one row per message instead, reconcile against what you already have
rather than rewrite everything. Every persisted message carries a stable `id`.
The middleware fills one in for any message that lacks it, including messages
that [compaction](../advanced/compaction) rewrote. So you can key rows by the
`id`:

1. `SELECT id, version FROM messages WHERE thread_id = ?` to read the light index.
2. Diff the incoming array against it: insert new ids, delete absent ids, update
rows whose `version` changed.
3. Set `version` to a content hash, so an in-place edit (a cleared tool result)
shows up as a change.

Keep an order column, because compaction can insert a message at the front.
Assign a sortable value once (a gapped or fractional index) so an insert does not
renumber every row. Order the load by it.

The stable `id` holds when the server owns the thread. A client-authoritative
caller that re-sends the transcript must keep the ids itself.

## RunStore

`RunStore` and `RunRecord` come from `@tanstack/ai`; `@tanstack/ai-persistence`
Expand Down Expand Up @@ -294,6 +269,12 @@ composite identity. A stored `null` is indistinguishable from absence at the typ
level, so wrap a value you must persist as `null` (e.g. `{ value: null }`), or
reject nullish values outright the way the SQLite store above does.

`withPersistence` also provides this store through the core
`MetadataCapability`. Middleware can use it for derived state without depending
on `@tanstack/ai-persistence`. For example, `withCompaction` stores validated
context checkpoints here. Do not place the canonical transcript in metadata;
the `messages` store owns it.

## GenerationRunStore

The generation counterpart to `RunStore`. Keyed by its own `runId`, with
Expand Down
17 changes: 14 additions & 3 deletions packages/ai-compaction/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

Context-window compaction as a `chat()` middleware. When the working message set
grows past `maxTokens`, `withCompaction` runs a pluggable **strategy** that
rewrites the messages. It runs before every model call, so compaction is
incremental and rolling. The system prompt is untouched (`chat()` keeps it
separate from `messages`).
rewrites provider context. It runs before every model call, so compaction is
incremental and rolling. The canonical transcript and system prompt stay
unchanged.

```bash
npm install @tanstack/ai-compaction
Expand Down Expand Up @@ -94,6 +94,12 @@ import type { CompactionStrategy } from '@tanstack/ai-compaction'

const keepLastOnly: CompactionStrategy = (messages) =>
messages.length <= 1 ? null : messages.slice(-1)

withCompaction({
maxTokens: 100_000,
strategy: keepLastOnly,
strategyKey: 'keep-last-v1',
})
```

## Options
Expand All @@ -105,6 +111,7 @@ const keepLastOnly: CompactionStrategy = (messages) =>
| `maxTokens` | (required) | Compact when estimated tokens exceed this. |
| `strategy` | `evictOldest()` | How to shrink the messages. |
| `estimateTokens` | chars / 4 | Per-message token estimate. Swap in a real tokenizer for accuracy. |
| `strategyKey` | built-in key | Stable checkpoint identity. Set it for custom strategies or estimators. |
| `onCompact` | — | Observe each compaction (`before`/`after`/`messagesBefore`/`messagesAfter`). |

### Strategy options
Expand All @@ -117,3 +124,7 @@ const keepLastOnly: CompactionStrategy = (messages) =>

The token estimate is a rough `chars / 4` heuristic, good enough to trigger on,
not exact. Pass `estimateTokens` if you need provider-accurate counts.

When `withPersistence` provides a metadata store, compaction saves a validated
checkpoint automatically. The next request reuses the compacted prefix and adds
new canonical messages. Without metadata, compaction remains stateless.
Loading
Loading