Skip to content

Commit 2683de1

Browse files
ericallamTrigger.dev RepoOps
authored andcommitted
docs(ai-chat): native compaction & provider fallback pattern
New AI chat docs page, "Native compaction & provider fallback", under Patterns. It covers persisting a provider's own compaction across turns (Anthropic context editing and OpenAI stored responses) by mirroring it into stored history with `chat.history.set()`, and falling back between providers without re-sending history using a portable compaction summary. The existing Compaction page now links out to it. Mono-RevId: 247922b2b3e30e071253ddf39ada0dbf06cc374d
1 parent b6da034 commit 2683de1

3 files changed

Lines changed: 316 additions & 1 deletion

File tree

docs/ai-chat/compaction.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ After each turn completes:
6161

6262
On the next turn, the LLM receives the compact summary instead of the full history — dramatically reducing token usage while preserving context.
6363

64+
<Note>
65+
This is Trigger.dev's provider-agnostic compaction. To persist a **provider's own** compaction across turns instead (Anthropic context editing or OpenAI stored responses), and to fall back between providers without re-sending history, see [Native compaction & provider fallback](/ai-chat/patterns/native-compaction).
66+
</Note>
67+
6468
## Customizing what gets persisted
6569

6670
By default, compaction only affects model messages — UI messages stay intact so users see the full conversation after a page refresh. You can customize this with `compactUIMessages`:
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
---
2+
title: "Native compaction & provider fallback"
3+
sidebarTitle: "Native compaction"
4+
description: "Persist provider-native compaction (Anthropic context editing, OpenAI stored responses) across chat.agent turns so history is never re-sent, and fall back between providers without losing the conversation."
5+
---
6+
7+
Providers compact a conversation within a single request. Anthropic's [context editing](https://docs.anthropic.com/en/docs/build-with-claude/context-editing) clears old tool-use blocks server-side, and OpenAI's [stored responses](https://platform.openai.com/docs/guides/conversation-state) keep the thread server-side so you only send the delta. Neither changes what your agent has accumulated, so on its own the next turn re-sends the whole transcript again and the token saving is lost.
8+
9+
This is the gap this page closes. After each turn, mirror what the provider compacted into the agent's stored history with [`chat.history.set()`](/ai-chat/reference#chat-namespace), so the next turn is derived from the already-reduced conversation. And because a native handle is provider-specific, this page also shows how a provider-agnostic [Trigger.dev compaction](/ai-chat/compaction) summary lets you fall back between providers without re-expanding the context.
10+
11+
<Note>
12+
The full runnable example is [`triggerdotdev/resilient-chat-example`](https://github.com/triggerdotdev/resilient-chat-example). See `native-persist.ts` for the Anthropic persistence flow and `resilient-chat.ts` for OpenAI stored responses plus provider fallback.
13+
</Note>
14+
15+
## Two kinds of compaction
16+
17+
They are not competing; they compose. Native compaction is the per-turn optimization, and Trigger.dev compaction is the durable, portable checkpoint.
18+
19+
| | Native (provider) | Trigger.dev `compaction` |
20+
| --- | --- | --- |
21+
| Runs | Inside one provider request | Between steps / turns, in your run |
22+
| Scope | Provider-specific (Anthropic edits, OpenAI stored thread) | Provider-agnostic |
23+
| Portable across a provider switch | No, the handle is a cache miss on the other provider | Yes, `summarize` returns a plain string |
24+
| Persisted by default | No, you mirror it in `onTurnComplete` | Yes, replaces model messages and keeps UI messages |
25+
26+
## Persist Anthropic native context editing
27+
28+
Anthropic's `contextManagement` clears old tool-use/tool-result blocks server-side per request, and reports how many it cleared in `providerMetadata.anthropic.contextManagement.appliedEdits` (`clearedToolUses`, `clearedInputTokens`). It does not touch your accumulated history, so on its own the next turn still re-sends everything.
29+
30+
The fix: read the `appliedEdits` counts as they stream in `onStepFinish`, then after the turn mirror that clearing into stored history with `chat.history.set()`. No custom summarizer is involved, since the provider's native editing drives what gets persisted.
31+
32+
```ts /trigger/native-persist.ts
33+
import { chat } from "@trigger.dev/sdk/ai";
34+
import { streamText, stepCountIs, tool, type UIMessage } from "ai";
35+
import { anthropic } from "@ai-sdk/anthropic";
36+
import { z } from "zod";
37+
38+
const fetchRecord = tool({
39+
description: "Fetch the full text of a record by its numeric id.",
40+
inputSchema: z.object({ id: z.number() }),
41+
execute: async ({ id }) => ({ id, text: `RECORD ${id}: ...` }),
42+
});
43+
44+
// How many tool-uses Anthropic cleared this turn, per chat. Captured in run(),
45+
// applied in onTurnComplete. An in-memory Map is enough because the run stays
46+
// alive across turns (idleTimeoutInSeconds).
47+
const clearedByChat = new Map<string, number>();
48+
49+
const isToolPart = (p: { type?: string }) =>
50+
typeof p?.type === "string" && (p.type.startsWith("tool-") || p.type === "dynamic-tool");
51+
52+
// Drop the oldest n tool parts, mirroring what the provider cleared. A tool call
53+
// and its result live in one part, so pairing stays intact.
54+
function pruneOldestToolParts(messages: UIMessage[], n: number): UIMessage[] {
55+
let toRemove = n;
56+
const out: UIMessage[] = [];
57+
for (const m of messages) {
58+
if (toRemove <= 0 || m.role !== "assistant" || !m.parts) {
59+
out.push(m);
60+
continue;
61+
}
62+
const kept = m.parts.filter((p) => {
63+
if (toRemove > 0 && isToolPart(p)) {
64+
toRemove--;
65+
return false;
66+
}
67+
return true;
68+
});
69+
if (kept.length > 0) out.push({ ...m, parts: kept });
70+
}
71+
return out;
72+
}
73+
74+
export const nativePersist = chat.agent({
75+
id: "native-persist",
76+
idleTimeoutInSeconds: 120,
77+
tools: { fetchRecord },
78+
run: async ({ messages, chatId, tools, signal }) => {
79+
return streamText({
80+
model: anthropic("claude-sonnet-4-5"),
81+
messages,
82+
tools,
83+
abortSignal: signal,
84+
stopWhen: stepCountIs(12),
85+
providerOptions: {
86+
anthropic: {
87+
contextManagement: {
88+
edits: [
89+
{
90+
type: "clear_tool_uses_20250919",
91+
trigger: { type: "tool_uses", value: 2 },
92+
keep: { type: "tool_uses", value: 1 },
93+
clearToolInputs: true,
94+
},
95+
],
96+
},
97+
},
98+
},
99+
onStepFinish: ({ providerMetadata }) => {
100+
const cm = providerMetadata?.anthropic?.contextManagement as
101+
| { appliedEdits?: Array<{ type?: string; clearedToolUses?: number }> }
102+
| undefined;
103+
let stepCleared = 0;
104+
for (const e of cm?.appliedEdits ?? []) {
105+
if (e.type === "clear_tool_uses_20250919") stepCleared += e.clearedToolUses ?? 0;
106+
}
107+
if (stepCleared > 0) {
108+
clearedByChat.set(chatId, (clearedByChat.get(chatId) ?? 0) + stepCleared);
109+
}
110+
},
111+
});
112+
},
113+
// After the turn, mirror the server-side clearing into stored history.
114+
onTurnComplete: async ({ chatId, uiMessages }) => {
115+
const cleared = clearedByChat.get(chatId) ?? 0;
116+
if (cleared <= 0) return;
117+
chat.history.set(pruneOldestToolParts(uiMessages, cleared));
118+
clearedByChat.set(chatId, 0);
119+
},
120+
});
121+
```
122+
123+
Turn 1 sends the user message and accumulates six tool results. Anthropic clears four of them server-side. `onTurnComplete` prunes those four from stored history, so turn 2 re-sends the smaller conversation (one tool result, not six) instead of the full transcript.
124+
125+
<Note>
126+
`onTurnComplete` is where persistence happens. Action turns fire `onAction` only, and a `chat.history.set()` inside `run()` is overwritten by the accumulator at turn end. See [Persistence and replay](/ai-chat/patterns/persistence-and-replay#action-turns-no-snapshot-write).
127+
</Note>
128+
129+
## Persist OpenAI stored responses
130+
131+
OpenAI's `store: true` keeps the thread server-side and returns a `responseId`. Pass that back as `previousResponseId` on the next turn and send only the messages since the last assistant reply; everything before it lives on OpenAI's side.
132+
133+
```ts /trigger/openai-store.ts
134+
import { chat } from "@trigger.dev/sdk/ai";
135+
import { streamText, stepCountIs, type ModelMessage } from "ai";
136+
import { openai } from "@ai-sdk/openai";
137+
138+
// Persist the stored-response handle between turns. Replace with your database.
139+
const nativeStore = new Map<string, { previousResponseId: string }>();
140+
141+
// When OpenAI already holds the thread, send only what is new since the last
142+
// assistant reply. Everything before that lives server-side.
143+
function messagesSinceLastAssistant(messages: ModelMessage[]): ModelMessage[] {
144+
let last = -1;
145+
for (let i = 0; i < messages.length; i++) {
146+
if (messages[i]!.role === "assistant") last = i;
147+
}
148+
return last === -1 ? messages : messages.slice(last + 1);
149+
}
150+
151+
export const openaiStore = chat.agent({
152+
id: "openai-store",
153+
idleTimeoutInSeconds: 120,
154+
run: async ({ messages, chatId, signal }) => {
155+
const native = nativeStore.get(chatId);
156+
const outbound = native ? messagesSinceLastAssistant(messages) : messages;
157+
158+
const result = streamText({
159+
model: openai("gpt-4o"),
160+
messages: outbound,
161+
abortSignal: signal,
162+
stopWhen: stepCountIs(5),
163+
providerOptions: {
164+
openai: native ? { store: true, previousResponseId: native.previousResponseId } : { store: true },
165+
},
166+
});
167+
168+
// Capture the response id off the metadata for the next turn.
169+
void result.providerMetadata.then((meta) => {
170+
const rid = typeof meta?.openai?.responseId === "string" ? meta.openai.responseId : undefined;
171+
if (rid) nativeStore.set(chatId, { previousResponseId: rid });
172+
});
173+
174+
return result;
175+
},
176+
});
177+
```
178+
179+
Turn 1 stores the thread and sends all three messages. Turn 2 sends only the new user message (`1/3`), because OpenAI already has the rest.
180+
181+
## Fall back between providers without losing history
182+
183+
A native handle is a per-provider cache. An OpenAI `previousResponseId` means nothing to Anthropic, and Anthropic's server-side edits don't exist on OpenAI. So when a provider is down and you fall back to another, the native optimization is a cache miss, and a naive fallback re-sends the entire raw transcript to the new provider.
184+
185+
[Trigger.dev's `compaction`](/ai-chat/compaction) is the portable checkpoint that closes this gap. `summarize` returns a plain string and `compactModelMessages` returns neutral `ModelMessage[]`, so the summary survives any provider switch. Tag each native handle with the provider that produced it. On a switch it's a cache miss, and you rebuild from the summary instead of re-expanding the context.
186+
187+
```ts /trigger/resilient-chat.ts
188+
import { chat } from "@trigger.dev/sdk/ai";
189+
import { streamText, generateText, stepCountIs, generateId, type ModelMessage } from "ai";
190+
import { anthropic } from "@ai-sdk/anthropic";
191+
import { openai } from "@ai-sdk/openai";
192+
193+
type Provider = "anthropic" | "openai";
194+
const FALLBACK_ORDER: Provider[] = ["anthropic", "openai"];
195+
196+
// Native handle, tagged with the provider that produced it. Replace with your DB.
197+
type NativeState = { provider: "openai"; previousResponseId: string };
198+
const nativeStore = new Map<string, NativeState>();
199+
200+
// Provider-agnostic summary: a plain string, portable across any provider.
201+
async function summarizeConversation(messages: ModelMessage[]): Promise<string> {
202+
const { text } = await generateText({
203+
model: openai("gpt-4o-mini"),
204+
messages: [
205+
...messages,
206+
{
207+
role: "user",
208+
content:
209+
"Summarize this conversation so it can continue with ANY model. " +
210+
"Preserve decisions made, facts established, open questions, and the user's intent.",
211+
},
212+
],
213+
});
214+
return text;
215+
}
216+
217+
export const resilientChat = chat.agent({
218+
id: "resilient-chat",
219+
idleTimeoutInSeconds: 120,
220+
221+
compaction: {
222+
shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
223+
summarize: ({ messages }) => summarizeConversation(messages),
224+
compactModelMessages: ({ modelMessages, summary }) => [
225+
{ role: "user", content: `Summary of the conversation so far:\n\n${summary}` },
226+
...modelMessages.slice(-2),
227+
],
228+
compactUIMessages: ({ uiMessages, summary }) => [
229+
{
230+
id: generateId(),
231+
role: "assistant",
232+
parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }],
233+
},
234+
...uiMessages.slice(-2),
235+
],
236+
},
237+
238+
// A Trigger.dev compaction is the reset point: the provider's server-side thread
239+
// no longer matches the compacted baseline, so invalidate the native handle.
240+
onCompacted: async ({ chatId }) => {
241+
if (chatId) nativeStore.delete(chatId);
242+
},
243+
244+
run: async ({ messages, chatId, signal }) => {
245+
let lastError: unknown;
246+
for (const providerId of FALLBACK_ORDER) {
247+
const native = nativeStore.get(chatId);
248+
try {
249+
if (providerId === "openai") {
250+
// On a switch to OpenAI with no matching handle, `messages` is already the
251+
// compacted baseline (summary + recent), so raw history is not re-sent.
252+
const useHandle = native?.provider === "openai";
253+
const result = streamText({
254+
model: openai("gpt-4o"),
255+
messages,
256+
abortSignal: signal,
257+
stopWhen: stepCountIs(5),
258+
providerOptions: {
259+
openai: useHandle
260+
? { store: true, previousResponseId: native!.previousResponseId }
261+
: { store: true },
262+
},
263+
});
264+
void result.providerMetadata.then((meta) => {
265+
const rid = typeof meta?.openai?.responseId === "string" ? meta.openai.responseId : undefined;
266+
if (rid) nativeStore.set(chatId, { provider: "openai", previousResponseId: rid });
267+
});
268+
return result;
269+
}
270+
271+
return streamText({
272+
model: anthropic("claude-sonnet-4-5"),
273+
messages,
274+
abortSignal: signal,
275+
stopWhen: stepCountIs(5),
276+
providerOptions: {
277+
anthropic: {
278+
contextManagement: {
279+
edits: [{ type: "clear_tool_uses_20250919", trigger: { type: "input_tokens", value: 80_000 }, keep: { type: "tool_uses", value: 3 } }],
280+
},
281+
},
282+
},
283+
});
284+
} catch (error) {
285+
lastError = error; // Provider failed, try the next one in the order.
286+
}
287+
}
288+
throw lastError;
289+
},
290+
});
291+
```
292+
293+
When Anthropic is down, the loop falls through to OpenAI. Because `compaction` has already reduced `messages` to a summary plus the last couple of exchanges, the switch sends the portable baseline, not megabytes of raw transcript.
294+
295+
<Warning>
296+
Fallback here retries a turn that hasn't started streaming yet. Once a response is streaming to the client, a mid-stream provider failure can't be swapped transparently. Surface the error and let the frontend regenerate the turn. See [Error handling](/ai-chat/error-handling).
297+
</Warning>
298+
299+
## Production notes
300+
301+
- **Persist the handles.** The `Map`s above (`nativeStore`, `clearedByChat`) work in the example because the run stays alive across turns, but they don't survive a run boundary. Store native handles and summaries in your database keyed by `chatId`, alongside your [message persistence](/ai-chat/patterns/database-persistence).
302+
- **No cross-provider translation.** Native compaction from one provider never transfers to another. The Trigger.dev `compaction` summary is the only portable baseline across a switch.
303+
- **Native compaction is opt-in per turn.** It applies only for the provider whose `providerOptions` you set on that turn's `streamText` call.
304+
305+
## See also
306+
307+
- [Compaction](/ai-chat/compaction): the provider-agnostic `compaction` option, `onCompacted`, and manual `chat.compact()`.
308+
- [Prompt caching](/ai-chat/prompt-caching): the other per-turn token optimization, and how it interacts with a growing history.
309+
- [Database persistence](/ai-chat/patterns/database-persistence): where to store native handles and summaries for real.
310+
- [Lifecycle hooks](/ai-chat/lifecycle-hooks): `onTurnComplete` and `onCompacted` in the broader hook taxonomy.

docs/docs.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@
133133
"ai-chat/patterns/skills",
134134
"ai-chat/patterns/oom-resilience",
135135
"ai-chat/patterns/recovery-boot",
136-
"ai-chat/patterns/trusted-edge-signals"
136+
"ai-chat/patterns/trusted-edge-signals",
137+
"ai-chat/patterns/native-compaction"
137138
]
138139
},
139140
{

0 commit comments

Comments
 (0)