|
| 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. |
0 commit comments