@trigger.dev/sdk 4.5.16 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/imports/ai-runtime-cjs.cjs.map +1 -1
- package/dist/commonjs/imports/ai-runtime.js +2 -0
- package/dist/commonjs/v3/ai-shared.d.ts +15 -0
- package/dist/commonjs/v3/ai-shared.js +35 -1
- package/dist/commonjs/v3/ai-shared.js.map +1 -1
- package/dist/commonjs/v3/ai.d.ts +311 -25
- package/dist/commonjs/v3/ai.js +1633 -380
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/auth.d.ts +10 -4
- package/dist/commonjs/v3/auth.js.map +1 -1
- package/dist/commonjs/v3/chat-client.d.ts +11 -2
- package/dist/commonjs/v3/chat-client.js +227 -111
- package/dist/commonjs/v3/chat-client.js.map +1 -1
- package/dist/commonjs/v3/chat-react.d.ts +88 -0
- package/dist/commonjs/v3/chat-react.js +130 -1
- package/dist/commonjs/v3/chat-react.js.map +1 -1
- package/dist/commonjs/v3/chat-server.d.ts +42 -6
- package/dist/commonjs/v3/chat-server.js +52 -7
- package/dist/commonjs/v3/chat-server.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +92 -10
- package/dist/commonjs/v3/chat.js +328 -73
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/chatSnapshotIo.d.ts +2 -0
- package/dist/commonjs/v3/chatSnapshotIo.js +173 -0
- package/dist/commonjs/v3/chatSnapshotIo.js.map +1 -0
- package/dist/commonjs/v3/chatVersionSkew.d.ts +12 -0
- package/dist/commonjs/v3/chatVersionSkew.js +30 -0
- package/dist/commonjs/v3/chatVersionSkew.js.map +1 -0
- package/dist/commonjs/v3/externalDeploymentId.d.ts +23 -0
- package/dist/commonjs/v3/externalDeploymentId.js +43 -0
- package/dist/commonjs/v3/externalDeploymentId.js.map +1 -0
- package/dist/commonjs/v3/retry.d.ts +4 -0
- package/dist/commonjs/v3/retry.js +22 -13
- package/dist/commonjs/v3/retry.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +15 -2
- package/dist/commonjs/v3/sessions.js +12 -1
- package/dist/commonjs/v3/sessions.js.map +1 -1
- package/dist/commonjs/v3/shared.js +30 -36
- package/dist/commonjs/v3/shared.js.map +1 -1
- package/dist/commonjs/v3/test/index.d.ts +1 -0
- package/dist/commonjs/v3/test/index.js +3 -1
- package/dist/commonjs/v3/test/index.js.map +1 -1
- package/dist/commonjs/v3/test/mock-chat-agent.d.ts +47 -4
- package/dist/commonjs/v3/test/mock-chat-agent.js +116 -10
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/commonjs/v3/test/test-session-handle.js +6 -0
- package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
- package/dist/commonjs/v3/test/transcript-storage-tests.d.ts +40 -0
- package/dist/commonjs/v3/test/transcript-storage-tests.js +258 -0
- package/dist/commonjs/v3/test/transcript-storage-tests.js.map +1 -0
- package/dist/commonjs/v3/transcriptStorage.d.ts +261 -0
- package/dist/commonjs/v3/transcriptStorage.js +374 -0
- package/dist/commonjs/v3/transcriptStorage.js.map +1 -0
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/imports/ai-runtime.d.ts +2 -2
- package/dist/esm/imports/ai-runtime.js +2 -2
- package/dist/esm/imports/ai-runtime.js.map +1 -1
- package/dist/esm/v3/ai-shared.d.ts +15 -0
- package/dist/esm/v3/ai-shared.js +33 -0
- package/dist/esm/v3/ai-shared.js.map +1 -1
- package/dist/esm/v3/ai.d.ts +311 -25
- package/dist/esm/v3/ai.js +1625 -377
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/auth.d.ts +10 -4
- package/dist/esm/v3/auth.js.map +1 -1
- package/dist/esm/v3/chat-client.d.ts +11 -2
- package/dist/esm/v3/chat-client.js +228 -112
- package/dist/esm/v3/chat-client.js.map +1 -1
- package/dist/esm/v3/chat-react.d.ts +88 -0
- package/dist/esm/v3/chat-react.js +127 -1
- package/dist/esm/v3/chat-react.js.map +1 -1
- package/dist/esm/v3/chat-server.d.ts +42 -6
- package/dist/esm/v3/chat-server.js +53 -8
- package/dist/esm/v3/chat-server.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +92 -10
- package/dist/esm/v3/chat.js +329 -74
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/chatSnapshotIo.d.ts +2 -0
- package/dist/esm/v3/chatSnapshotIo.js +165 -0
- package/dist/esm/v3/chatSnapshotIo.js.map +1 -0
- package/dist/esm/v3/chatVersionSkew.d.ts +12 -0
- package/dist/esm/v3/chatVersionSkew.js +27 -0
- package/dist/esm/v3/chatVersionSkew.js.map +1 -0
- package/dist/esm/v3/externalDeploymentId.d.ts +23 -0
- package/dist/esm/v3/externalDeploymentId.js +38 -0
- package/dist/esm/v3/externalDeploymentId.js.map +1 -0
- package/dist/esm/v3/retry.d.ts +4 -0
- package/dist/esm/v3/retry.js +14 -8
- package/dist/esm/v3/retry.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +15 -2
- package/dist/esm/v3/sessions.js +11 -1
- package/dist/esm/v3/sessions.js.map +1 -1
- package/dist/esm/v3/shared.js +17 -23
- package/dist/esm/v3/shared.js.map +1 -1
- package/dist/esm/v3/test/index.d.ts +1 -0
- package/dist/esm/v3/test/index.js +1 -0
- package/dist/esm/v3/test/index.js.map +1 -1
- package/dist/esm/v3/test/mock-chat-agent.d.ts +47 -4
- package/dist/esm/v3/test/mock-chat-agent.js +118 -12
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/esm/v3/test/test-session-handle.js +6 -0
- package/dist/esm/v3/test/test-session-handle.js.map +1 -1
- package/dist/esm/v3/test/transcript-storage-tests.d.ts +40 -0
- package/dist/esm/v3/test/transcript-storage-tests.js +255 -0
- package/dist/esm/v3/test/transcript-storage-tests.js.map +1 -0
- package/dist/esm/v3/transcriptStorage.d.ts +261 -0
- package/dist/esm/v3/transcriptStorage.js +360 -0
- package/dist/esm/v3/transcriptStorage.js.map +1 -0
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/actions.mdx +55 -23
- package/docs/ai-chat/anatomy.mdx +47 -27
- package/docs/ai-chat/backend.mdx +125 -48
- package/docs/ai-chat/background-injection.mdx +122 -19
- package/docs/ai-chat/client-protocol.mdx +5 -4
- package/docs/ai-chat/compaction.mdx +13 -7
- package/docs/ai-chat/custom-agents.mdx +23 -0
- package/docs/ai-chat/fast-starts.mdx +27 -20
- package/docs/ai-chat/frontend.mdx +19 -14
- package/docs/ai-chat/how-it-works.mdx +1 -1
- package/docs/ai-chat/lifecycle-hooks.mdx +6 -2
- package/docs/ai-chat/migrating-from-a-route-handler.mdx +16 -14
- package/docs/ai-chat/migrating-from-hydrate-messages.mdx +299 -0
- package/docs/ai-chat/patterns/database-persistence.mdx +24 -9
- package/docs/ai-chat/patterns/native-compaction.mdx +310 -0
- package/docs/ai-chat/patterns/persistence-and-replay.mdx +34 -23
- package/docs/ai-chat/patterns/skills.mdx +7 -10
- package/docs/ai-chat/patterns/version-upgrades.mdx +79 -6
- package/docs/ai-chat/pending-messages.mdx +3 -3
- package/docs/ai-chat/prompt-caching.mdx +23 -25
- package/docs/ai-chat/quick-start.mdx +11 -11
- package/docs/ai-chat/reference.mdx +69 -10
- package/docs/ai-chat/sessions.mdx +6 -1
- package/docs/ai-chat/side-channels.mdx +1 -1
- package/docs/ai-chat/testing.mdx +2 -1
- package/docs/ai-chat/tools.mdx +18 -13
- package/docs/ai-chat/transcript-storage.mdx +247 -0
- package/docs/ai-chat/upgrade-guide.mdx +2 -2
- package/docs/apikeys.mdx +27 -45
- package/docs/config/extensions/syncEnvVars.mdx +6 -0
- package/docs/deploy-environment-variables.mdx +23 -2
- package/docs/deployment/overview.mdx +4 -8
- package/docs/deployment/preview-branches.mdx +4 -4
- package/docs/deployment/version-skew-protection.mdx +62 -0
- package/docs/introduction.mdx +58 -152
- package/docs/manual-setup.mdx +7 -7
- package/docs/mcp-tools.mdx +9 -0
- package/docs/quick-start.mdx +3 -3
- package/docs/realtime/auth.mdx +1 -1
- package/docs/reports.mdx +157 -0
- package/docs/self-hosting/security.mdx +17 -0
- package/docs/tasks/scheduled.mdx +24 -0
- package/docs/triggering.mdx +1 -1
- package/docs/troubleshooting-zod.mdx +158 -0
- package/package.json +4 -4
- package/skills/trigger-authoring-chat-agent/SKILL.md +41 -29
- package/skills/trigger-chat-agent-advanced/SKILL.md +54 -19
|
@@ -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.
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "Persistence and replay"
|
|
3
3
|
sidebarTitle: "Persistence and replay"
|
|
4
|
-
description: "How chat.agent rebuilds conversation history at run boot —
|
|
4
|
+
description: "How chat.agent rebuilds conversation history at run boot — the transcript storage's persisted conversation plus session.out replay, and what changes when your app owns the model's context."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
7
|
`chat.agent` runs are processes — they boot, stream a turn, and either suspend (waiting for the next message) or exit. When the next message arrives at a session whose previous run already exited, a **fresh** run boots with no in-memory state. Something has to rebuild the conversation history before that turn can produce a coherent response.
|
|
8
8
|
|
|
9
|
-
This page walks through the **
|
|
9
|
+
This page walks through the **storage + replay** model. The persisted conversation comes from the agent's [transcript storage](/ai-chat/transcript-storage); the default storage is the snapshot in object storage described below, and a storage you bring is read the same way. Replay of the session streams covers what happened after the last save, and it runs for every agent, including one that owns the model's context.
|
|
10
10
|
|
|
11
11
|
## Why a snapshot at all
|
|
12
12
|
|
|
@@ -32,7 +32,7 @@ sequenceDiagram
|
|
|
32
32
|
User->>Run1: u1
|
|
33
33
|
Run1->>SessionOut: assistant chunks for a1
|
|
34
34
|
Run1->>Run1: onTurnComplete
|
|
35
|
-
Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId,
|
|
35
|
+
Run1->>Snapshot: write { messages: [u1, a1], lastOutEventId, lastInEventId }
|
|
36
36
|
Note over Run1: idle suspend (or exit)
|
|
37
37
|
|
|
38
38
|
User->>Run2: u2 (delta only)
|
|
@@ -52,15 +52,21 @@ The accumulator starts empty. The wire delivers `u1`. After the model finishes,
|
|
|
52
52
|
|
|
53
53
|
```json
|
|
54
54
|
{
|
|
55
|
-
"version":
|
|
55
|
+
"version": 2,
|
|
56
56
|
"savedAt": 1715180400000,
|
|
57
|
-
"messages": [
|
|
57
|
+
"messages": [
|
|
58
|
+
{ "id": "u1", "final": true, "message": u1 },
|
|
59
|
+
{ "id": "a1", "final": true, "message": a1 }
|
|
60
|
+
],
|
|
61
|
+
"state": null,
|
|
58
62
|
"lastOutEventId": "42",
|
|
59
|
-
"
|
|
63
|
+
"lastInEventId": "7"
|
|
60
64
|
}
|
|
61
65
|
```
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
`state` holds what the runtime cannot rebuild from the messages, such as a [compaction](/ai-chat/compaction) summary; `final` is false for a partial answer captured from a failed turn. Snapshots written by older SDK versions have `version: 1` and are read as if every message were final with no state.
|
|
68
|
+
|
|
69
|
+
The key is `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` — overwritten every turn, never appended. With your own storage, the equivalent is whatever `save` writes: the runtime hands it the two new messages as `put` changes and the same cursors, and a row-per-message store writes two rows instead of the whole conversation. The write is **awaited**, not fire-and-forget — if the run idle-suspends immediately after, in-flight promises don't reliably complete and the snapshot would be lost.
|
|
64
70
|
|
|
65
71
|
### Run 2 — boot
|
|
66
72
|
|
|
@@ -110,21 +116,25 @@ Replay carries the conversation across the crash boundary with zero customer cod
|
|
|
110
116
|
|
|
111
117
|
## OOM-retry interaction
|
|
112
118
|
|
|
113
|
-
The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/ai-chat/patterns/oom-resilience)). The snapshot
|
|
119
|
+
The runtime already had an OOM-retry path that scans `session.out` for the latest `trigger:turn-complete` timestamp to use as a cutoff for `session.in` (so the retry doesn't re-process completed turns — see [OOM resilience](/ai-chat/patterns/oom-resilience)). The snapshot's `lastInEventId` field is exactly that committed `.in` cursor.
|
|
114
120
|
|
|
115
|
-
When a snapshot exists, the OOM-retry path reads `
|
|
121
|
+
When a snapshot exists, the OOM-retry path reads `lastInEventId` directly instead of scanning `session.out`. One fewer stream subscription per retry. Free win.
|
|
116
122
|
|
|
117
123
|
If no snapshot exists (first turn, or `hydrateMessages` registered), the path falls back to the scan.
|
|
118
124
|
|
|
119
|
-
## Action turns
|
|
125
|
+
## Action turns
|
|
126
|
+
|
|
127
|
+
[Actions](/ai-chat/actions) (`trigger: "action"`) don't fire `onTurnComplete` — they fire `onAction` only. An action that changed the conversation is saved on its own, with `reason: "action"` and the same resume cursors as the last turn, so an undo survives the run ending. See [Actions and persistence](/ai-chat/actions#actions-and-persistence).
|
|
128
|
+
|
|
129
|
+
## When your app owns the model's context
|
|
120
130
|
|
|
121
|
-
[
|
|
131
|
+
A storage with [`loadContext`](/ai-chat/transcript-storage#owning-the-models-context), or the deprecated [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, decides what the model sees on every turn instead of the runtime's accumulated transcript. That changes the boot sequence in one place: with `hydrateMessages` the storage read is skipped, because the hook is the source of truth. Everything else still runs. The `session.out` and `session.in` tails are replayed, a partial answer and unacknowledged messages are recovered, and `onRecoveryBoot` fires. The hook then receives the recovered tail in `previousMessages`, so it can persist an answer a crashed run had already started.
|
|
122
132
|
|
|
123
|
-
|
|
133
|
+
With `loadContext` on a storage, the storage is still read and written: `load` restores the cursors and the runtime's `state` (a compaction summary survives), `save` still receives every change, and only the model's context comes from `loadContext`.
|
|
124
134
|
|
|
125
|
-
|
|
135
|
+
### The `hydrateMessages` hook
|
|
126
136
|
|
|
127
|
-
When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history.
|
|
137
|
+
When the customer registers a [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) hook, the runtime trusts the hook to be the source of truth for history. The snapshot is neither read nor written. The hook fires per turn, returns the canonical chain from the customer's database, and the accumulator is set to whatever the hook returned.
|
|
128
138
|
|
|
129
139
|
```ts
|
|
130
140
|
import { chat, upsertIncomingMessage } from "@trigger.dev/sdk/ai";
|
|
@@ -160,24 +170,24 @@ export const myChat = chat.agent({
|
|
|
160
170
|
|
|
161
171
|
What you gain:
|
|
162
172
|
|
|
163
|
-
- **Zero object-store traffic per turn.** No snapshot read, no snapshot write
|
|
173
|
+
- **Zero object-store traffic per turn.** No snapshot read, no snapshot write. `OBJECT_STORE_*` env vars don't have to be set.
|
|
164
174
|
- **Branching, undo, edit, abuse prevention** — patterns that need a backend-side single source of truth work naturally because the customer mediates every read.
|
|
165
175
|
|
|
166
176
|
What you give up:
|
|
167
177
|
|
|
168
|
-
- **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model.
|
|
169
|
-
- **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it.
|
|
178
|
+
- **You own persistence end-to-end.** A bug in `hydrateMessages` that returns the wrong chain corrupts the conversation visible to the model, and a compaction summary has nowhere durable to live.
|
|
179
|
+
- **OOM-retry needs a `session.out` scan again** because there's no snapshot to short-circuit it.
|
|
170
180
|
|
|
171
|
-
|
|
181
|
+
A [transcript storage](/ai-chat/transcript-storage) with `loadContext` gives you the same ownership of the model's context while the runtime keeps writing every change and its own state to your store. It is the recommended path; `hydrateMessages` is deprecated.
|
|
172
182
|
|
|
173
|
-
## When
|
|
183
|
+
## When no storage is configured
|
|
174
184
|
|
|
175
|
-
If
|
|
185
|
+
If no object store is configured and the agent has no `storage` of its own, conversations don't survive run boundaries. A continuation boots empty. The runtime logs a warning at agent registration time so you see this at deploy time, not at user-traffic time.
|
|
176
186
|
|
|
177
187
|
For local development this is sometimes fine — you're not testing continuations. For production it isn't. Configure one of:
|
|
178
188
|
|
|
179
189
|
- **Object store** (`OBJECT_STORE_*` env vars on your webapp) — easiest, default behavior.
|
|
180
|
-
-
|
|
190
|
+
- **A transcript storage over your own database** — stronger control, suits multi-tenant apps with audit needs.
|
|
181
191
|
|
|
182
192
|
## Snapshot key & lifecycle
|
|
183
193
|
|
|
@@ -188,7 +198,7 @@ For local development this is sometimes fine — you're not testing continuation
|
|
|
188
198
|
| Key suffix | `sessions/{sessionId}/snapshot.json` |
|
|
189
199
|
| Final key | `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json` |
|
|
190
200
|
| Size | Tens of KB typical, capped only by object-store limits |
|
|
191
|
-
| Cadence | Overwritten after every successful `onTurnComplete
|
|
201
|
+
| Cadence | Overwritten after every successful `onTurnComplete`, and after a history-changing action |
|
|
192
202
|
|
|
193
203
|
Snapshots accumulate per-session forever unless you set a lifecycle policy on the bucket. A 90-day expiry on `packets/*/sessions/*/snapshot.json` is a reasonable default if your chats don't typically resume after that window. Closed sessions are not auto-cleaned today.
|
|
194
204
|
|
|
@@ -201,7 +211,8 @@ For local development against `pnpm run docker`, the bundled MinIO container is
|
|
|
201
211
|
## See also
|
|
202
212
|
|
|
203
213
|
- [Client Protocol](/ai-chat/client-protocol#how-history-is-rebuilt) — the wire-level view of the same model
|
|
204
|
-
- [
|
|
214
|
+
- [Transcript storage](/ai-chat/transcript-storage) — the adapter the runtime persists through, and how to bring your own
|
|
215
|
+
- [`hydrateMessages`](/ai-chat/lifecycle-hooks#hydratemessages) — the deprecated context hook
|
|
205
216
|
- [OOM resilience](/ai-chat/patterns/oom-resilience) — how `session.in` cutoffs interact with snapshots
|
|
206
217
|
- [Database persistence](/ai-chat/patterns/database-persistence) — the canonical persistence pattern using `onTurnComplete`
|
|
207
218
|
- [v4.5 upgrade guide](/ai-chat/upgrade-guide#v45-wire-format-change) — when this model landed and what changed
|
|
@@ -79,7 +79,7 @@ The **body** is loaded on demand via the `loadSkill` tool when the agent decides
|
|
|
79
79
|
```ts trigger/chat.ts
|
|
80
80
|
import { chat } from "@trigger.dev/sdk/ai";
|
|
81
81
|
import { skills } from "@trigger.dev/sdk";
|
|
82
|
-
import {
|
|
82
|
+
import { stepCountIs } from "ai";
|
|
83
83
|
import { anthropic } from "@ai-sdk/anthropic";
|
|
84
84
|
|
|
85
85
|
const timeUtilsSkill = skills.define({
|
|
@@ -92,12 +92,11 @@ export const agent = chat.agent({
|
|
|
92
92
|
onChatStart: async () => {
|
|
93
93
|
chat.skills.set([await timeUtilsSkill.local()]);
|
|
94
94
|
},
|
|
95
|
-
run: async ({ messages, signal }) => {
|
|
95
|
+
run: async ({ messages, signal, streamText }) => {
|
|
96
96
|
return streamText({
|
|
97
97
|
model: anthropic("claude-sonnet-4-5"),
|
|
98
98
|
messages,
|
|
99
99
|
abortSignal: signal,
|
|
100
|
-
...chat.toStreamTextOptions(),
|
|
101
100
|
stopWhen: stepCountIs(15),
|
|
102
101
|
});
|
|
103
102
|
},
|
|
@@ -111,7 +110,7 @@ export const agent = chat.agent({
|
|
|
111
110
|
|
|
112
111
|
`skill.local()` reads the bundled `SKILL.md` from disk and returns a `ResolvedSkill` with the parsed frontmatter + body + on-disk path.
|
|
113
112
|
|
|
114
|
-
`chat.skills.set([...])` stores the resolved skills for the current run. `
|
|
113
|
+
`chat.skills.set([...])` stores the resolved skills for the current run. The `streamText` from `run`'s argument picks them up automatically:
|
|
115
114
|
|
|
116
115
|
- The frontmatter `description` lands in the system prompt under "Available skills:".
|
|
117
116
|
- Three tools are added: `loadSkill`, `readFile`, `bash` — scoped per skill.
|
|
@@ -169,12 +168,10 @@ return streamText({
|
|
|
169
168
|
model: anthropic("claude-sonnet-4-5"),
|
|
170
169
|
messages,
|
|
171
170
|
abortSignal: signal,
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
},
|
|
177
|
-
}),
|
|
171
|
+
tools: {
|
|
172
|
+
webFetch, // your tool
|
|
173
|
+
deepResearch, // your tool
|
|
174
|
+
},
|
|
178
175
|
stopWhen: stepCountIs(15),
|
|
179
176
|
});
|
|
180
177
|
```
|
|
@@ -8,6 +8,17 @@ Chat agent runs are pinned to the worker version they started on. When you deplo
|
|
|
8
8
|
|
|
9
9
|
`chat.requestUpgrade()` is the managed upgrade signal for `chat.agent()` and the `chat.createSession()` iterator. Fully hand-rolled custom agents use `chat.endAndContinue()` between turns to immediately hand the Session to a new run.
|
|
10
10
|
|
|
11
|
+
<Note>
|
|
12
|
+
If your sessions are pinned by [version skew
|
|
13
|
+
protection](/deployment/version-skew-protection#chat-sessions), you do not need this page to move a
|
|
14
|
+
conversation onto a new deployment. A pinned session follows its pin on its own: when the stored
|
|
15
|
+
`externalDeploymentId` stops naming the deployment a run is on, the agent hands over at the next
|
|
16
|
+
turn boundary. Set [`versionSkew: "hold"`](#staying-put) to turn that off for one agent.
|
|
17
|
+
|
|
18
|
+
Read on for the cases that are still yours to decide — leaving a pin for a version nobody named,
|
|
19
|
+
a session that was never pinned, and hand-rolled custom agents.
|
|
20
|
+
</Note>
|
|
21
|
+
|
|
11
22
|
## How it works
|
|
12
23
|
|
|
13
24
|
When `chat.requestUpgrade()` is called in `onTurnStart` or `onValidateMessages`:
|
|
@@ -19,22 +30,44 @@ When `chat.requestUpgrade()` is called in `onTurnStart` or `onValidateMessages`:
|
|
|
19
30
|
|
|
20
31
|
The new run lives on the **same Session** as the old one. `chatId` is the durable identity; only the underlying `currentRunId` rotates. The audit log records the new run with `reason: "upgrade"`.
|
|
21
32
|
|
|
33
|
+
### What "the latest deployment" means
|
|
34
|
+
|
|
35
|
+
The handoff clears the session's [external deployment id](/deployment/version-skew-protection#chat-sessions) so the new run can land on the current version — re-applying the pin the agent just rejected would make the upgrade impossible. The cleared pin is persisted on the session, so the next continuation doesn't fall back to it either.
|
|
36
|
+
|
|
37
|
+
To move to a specific deployment rather than to whatever is current, name it:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
chat.requestUpgrade({ externalDeploymentId: clientData.commitSha });
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That is usually what you want when the client told you which build it is on: it upgrades to the version the client expects instead of merely to the newest one.
|
|
44
|
+
|
|
45
|
+
<Warning>
|
|
46
|
+
`lockToVersion` is a different thing and is **never** cleared. A session started with an explicit
|
|
47
|
+
`lockToVersion` re-applies it on every run including upgrade handoffs, so `chat.requestUpgrade()`
|
|
48
|
+
cannot escape it — the new run lands on the same version the old one did. Use the external
|
|
49
|
+
deployment id if you want a pin an agent can opt out of.
|
|
50
|
+
</Warning>
|
|
51
|
+
|
|
22
52
|
When called from inside `run()` or `chat.defer()`, the current turn completes normally first and the run exits afterward. The next message triggers the continuation on the same session.
|
|
23
53
|
|
|
24
54
|
```mermaid
|
|
25
55
|
sequenceDiagram
|
|
26
56
|
participant User
|
|
27
57
|
participant Transport
|
|
58
|
+
participant Session as session.in
|
|
28
59
|
participant RunV1 as Run (v1)
|
|
29
60
|
participant RunV2 as Run (v2)
|
|
30
61
|
|
|
31
62
|
User->>Transport: send message
|
|
32
|
-
Transport->>
|
|
63
|
+
Transport->>Session: append message
|
|
64
|
+
Session->>RunV1: input stream
|
|
33
65
|
RunV1->>RunV1: onTurnStart → requestUpgrade()
|
|
34
|
-
RunV1
|
|
35
|
-
RunV1
|
|
36
|
-
|
|
37
|
-
RunV2
|
|
66
|
+
RunV1->>RunV2: end-and-continue triggers the successor
|
|
67
|
+
RunV1-->>Transport: trigger:upgrade-required (filtered, no re-send)
|
|
68
|
+
RunV1->>RunV1: exit (run() never called, message left unacknowledged)
|
|
69
|
+
Session->>RunV2: same message, replayed on boot
|
|
70
|
+
RunV2-->>Transport: response stream (same session.out)
|
|
38
71
|
Transport-->>User: response (seamless)
|
|
39
72
|
```
|
|
40
73
|
|
|
@@ -103,6 +136,13 @@ This pattern is useful when:
|
|
|
103
136
|
|
|
104
137
|
## Auto-detect from build ID (Next.js / Vercel)
|
|
105
138
|
|
|
139
|
+
<Warning>
|
|
140
|
+
You probably don't need this any more. If your sessions are pinned, following the pin is the
|
|
141
|
+
built-in behaviour and it needs no `clientData` and no `chat.local`. Reach for the recipe below
|
|
142
|
+
only when you want to upgrade on a signal the pin doesn't carry — a frontend build id that moves
|
|
143
|
+
independently of the deployment your app names.
|
|
144
|
+
</Warning>
|
|
145
|
+
|
|
106
146
|
For automatic upgrade on every deploy, pass your platform's build ID via `clientData` instead of a manual version. The agent stores the ID from the first message and upgrades when it changes:
|
|
107
147
|
|
|
108
148
|
```tsx title="app/components/Chat.tsx"
|
|
@@ -151,6 +191,38 @@ export const myChat = chat
|
|
|
151
191
|
|
|
152
192
|
This upgrades on **every** deploy, not just breaking changes. Good for fast-moving projects where you always want the latest code.
|
|
153
193
|
|
|
194
|
+
## Staying put
|
|
195
|
+
|
|
196
|
+
A pinned session follows its pin by default. To keep one agent where it is — a long tool chain you
|
|
197
|
+
don't want interrupted, or a conversation you'd rather move on your own terms — set `versionSkew`:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
export const myChat = chat.agent({
|
|
201
|
+
id: "my-chat",
|
|
202
|
+
versionSkew: "hold",
|
|
203
|
+
run: async ({ messages, signal }) => { ... },
|
|
204
|
+
});
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`"hold"` only stops the automatic handoff. `chat.requestUpgrade()` still works, so you can keep the
|
|
208
|
+
decision and still get the seamless swap.
|
|
209
|
+
|
|
210
|
+
Two cases never hand over automatically, whatever `versionSkew` says:
|
|
211
|
+
|
|
212
|
+
- **A session with no pin.** There is nothing to compare against, and an unpinned session already
|
|
213
|
+
lands on the current version every time it starts a run.
|
|
214
|
+
- **A session using `lockToVersion`.** That pin outranks the external deployment id and
|
|
215
|
+
`chat.requestUpgrade()` cannot escape it, so handing over would land on the same version and
|
|
216
|
+
repeat.
|
|
217
|
+
|
|
218
|
+
<Note>
|
|
219
|
+
Following the pin costs one session read per turn on pinned chats, and the handoff happens at a
|
|
220
|
+
turn boundary — never mid-turn. If the pin names a deployment that hasn't landed yet, the successor
|
|
221
|
+
parks: your messages stay durable, and the transport emits `run-pending-version` with
|
|
222
|
+
`source: "upgrade"` so you can say so in the UI. See [parked
|
|
223
|
+
chats](/deployment/version-skew-protection#chat-sessions).
|
|
224
|
+
</Note>
|
|
225
|
+
|
|
154
226
|
## Custom agents
|
|
155
227
|
|
|
156
228
|
Use `chat.requestUpgrade()` with `chat.agent()`. With `chat.createSession()`, call `chat.requestUpgrade()`, then advance the iterator once more so it can exit normally. For an immediate handoff, close the iterator before calling `chat.endAndContinue()`. In a fully hand-rolled `chat.customAgent()` task, detach input listeners, persist the completed turn, write its boundary, then call `chat.endAndContinue()` and return immediately:
|
|
@@ -166,7 +238,7 @@ await chat.endAndContinue();
|
|
|
166
238
|
return;
|
|
167
239
|
```
|
|
168
240
|
|
|
169
|
-
The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version
|
|
241
|
+
The continuation uses the same durable Session and receives `.in` records that the old run has not consumed. It starts on the latest deployed task version only if the Session is unpinned — a Session carrying `lockToVersion` or an `externalDeploymentId` re-applies that pin, so the continuation lands where the Session says rather than on the newest build.
|
|
170
242
|
|
|
171
243
|
If input has been dispatched to the old run but should be processed by the continuation, detach the old listeners and skip the final `chat.writeTurnComplete()`. A turn-complete boundary acknowledges the latest input dispatched to the old run, so writing one after that dispatch would cause the continuation to resume past the input.
|
|
172
244
|
|
|
@@ -182,6 +254,7 @@ Both are graceful exits. [`onRecoveryBoot`](/ai-chat/patterns/recovery-boot) doe
|
|
|
182
254
|
|
|
183
255
|
## See also
|
|
184
256
|
|
|
257
|
+
- [Version skew protection](/deployment/version-skew-protection#chat-sessions) — pin a session to the deployment matching the app build that started it
|
|
185
258
|
- [Lifecycle hooks](/ai-chat/lifecycle-hooks) — where `onTurnStart` and `onChatResume` fit in the turn cycle
|
|
186
259
|
- [Recovery boot](/ai-chat/patterns/recovery-boot) — the sibling hook for mid-stream interruptions (does NOT fire on `requestUpgrade`)
|
|
187
260
|
- [Database persistence](/ai-chat/patterns/database-persistence) — how continuations interact with session state
|