@trigger.dev/sdk 0.0.0-prerelease-streamfix-20260909094302 → 0.0.0-prerelease-20260911094758
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 +275 -25
- package/dist/commonjs/v3/ai.js +1373 -354
- package/dist/commonjs/v3/ai.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 +370 -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 +275 -25
- package/dist/esm/v3/ai.js +1365 -351
- package/dist/esm/v3/ai.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 +357 -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 +3 -3
- package/docs/ai-chat/backend.mdx +125 -48
- package/docs/ai-chat/background-injection.mdx +69 -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/patterns/database-persistence.mdx +6 -2
- 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 +67 -9
- package/docs/ai-chat/sessions.mdx +6 -1
- package/docs/ai-chat/testing.mdx +2 -1
- package/docs/ai-chat/tools.mdx +18 -13
- package/docs/ai-chat/transcript-storage.mdx +239 -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/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
package/dist/esm/v3/ai.js
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
|
-
import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
|
|
1
|
+
import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, SESSION_CLOSED_HEADER, SESSION_CLOSED_REASON_HEADER, tryCatch, } from "@trigger.dev/core/v3";
|
|
2
2
|
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
|
|
3
3
|
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
|
|
4
4
|
import { trace } from "@opentelemetry/api";
|
|
5
|
-
import { tool as aiTool, convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, zodSchema, } from "../imports/ai-runtime.js";
|
|
5
|
+
import { tool as aiTool, convertToModelMessages, dynamicTool, generateId as generateMessageId, getToolName, isToolUIPart, jsonSchema, readUIMessageStream, streamText as aiStreamText, zodSchema, } from "../imports/ai-runtime.js";
|
|
6
|
+
import { createTranscriptShadow, defaultStorage, diffTranscript, parseTranscriptRuntimeState, restoreModelLane, } from "./transcriptStorage.js";
|
|
7
|
+
let transcriptStorageOverride;
|
|
8
|
+
/**
|
|
9
|
+
* Test-only override for the storage `chat.agent` persists through, so a
|
|
10
|
+
* test can capture the exact changesets the runtime produces.
|
|
11
|
+
* @internal
|
|
12
|
+
*/
|
|
13
|
+
export function __setTranscriptStorageForTests(storage) {
|
|
14
|
+
transcriptStorageOverride = storage;
|
|
15
|
+
}
|
|
6
16
|
import { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage, chatRunTags, } from "./ai-shared.js";
|
|
7
17
|
import { auth } from "./auth.js";
|
|
8
18
|
import { locals } from "./locals.js";
|
|
@@ -17,6 +27,8 @@ import { metadata } from "./metadata.js";
|
|
|
17
27
|
// pulled in transitively here never reach a client chunk.
|
|
18
28
|
import { readFileInSkill, runBashInSkill } from "./agentSkillsRuntime.js";
|
|
19
29
|
import { ensureAiSdkTelemetry } from "./aiAutoTelemetry.js";
|
|
30
|
+
import { withResolvedExternalDeploymentId } from "./externalDeploymentId.js";
|
|
31
|
+
import { resolvePinToFollow } from "./chatVersionSkew.js";
|
|
20
32
|
import { sessions, } from "./sessions.js";
|
|
21
33
|
import { createTask } from "./shared.js";
|
|
22
34
|
import { markChatAgentRunForStreamsWarning } from "./streams.js";
|
|
@@ -103,182 +115,8 @@ async function findLatestSessionInCursor(chatId) {
|
|
|
103
115
|
}
|
|
104
116
|
return latestCursor;
|
|
105
117
|
}
|
|
106
|
-
|
|
107
|
-
export
|
|
108
|
-
readChatSnapshotImpl = impl;
|
|
109
|
-
}
|
|
110
|
-
let writeChatSnapshotImpl;
|
|
111
|
-
export function __setWriteChatSnapshotImplForTests(impl) {
|
|
112
|
-
writeChatSnapshotImpl = impl;
|
|
113
|
-
}
|
|
114
|
-
/**
|
|
115
|
-
* Read the persisted snapshot for a session. Returns `undefined` on:
|
|
116
|
-
* - missing object (404 from the presigned GET — fresh session, never
|
|
117
|
-
* persisted)
|
|
118
|
-
* - presign failure (network/auth issue)
|
|
119
|
-
* - malformed JSON
|
|
120
|
-
* - version mismatch (forward-compat — older runtimes ignore newer blobs)
|
|
121
|
-
*
|
|
122
|
-
* Always swallows errors via `logger.warn`. The agent boot loop must stay
|
|
123
|
-
* available even if S3 hiccups; the worst case is replaying more of
|
|
124
|
-
* `session.out` than strictly necessary.
|
|
125
|
-
* @internal
|
|
126
|
-
*/
|
|
127
|
-
async function readChatSnapshot(sessionId) {
|
|
128
|
-
if (readChatSnapshotImpl) {
|
|
129
|
-
return (await readChatSnapshotImpl(sessionId)) ?? undefined;
|
|
130
|
-
}
|
|
131
|
-
const apiClient = apiClientManager.clientOrThrow();
|
|
132
|
-
let presignedUrl;
|
|
133
|
-
try {
|
|
134
|
-
const resp = await apiClient.getChatSnapshotUrl(sessionId);
|
|
135
|
-
presignedUrl = resp.presignedUrl;
|
|
136
|
-
}
|
|
137
|
-
catch (error) {
|
|
138
|
-
logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", {
|
|
139
|
-
error: error instanceof Error ? error.message : String(error),
|
|
140
|
-
sessionId,
|
|
141
|
-
});
|
|
142
|
-
return undefined;
|
|
143
|
-
}
|
|
144
|
-
let response;
|
|
145
|
-
try {
|
|
146
|
-
response = await fetch(presignedUrl, { method: "GET" });
|
|
147
|
-
}
|
|
148
|
-
catch (error) {
|
|
149
|
-
logger.warn("chat.agent: snapshot fetch failed; continuing without snapshot", {
|
|
150
|
-
error: error instanceof Error ? error.message : String(error),
|
|
151
|
-
sessionId,
|
|
152
|
-
});
|
|
153
|
-
return undefined;
|
|
154
|
-
}
|
|
155
|
-
if (response.status === 404) {
|
|
156
|
-
// First-ever boot for this session — no snapshot yet. Caller falls
|
|
157
|
-
// through to replay-only.
|
|
158
|
-
return undefined;
|
|
159
|
-
}
|
|
160
|
-
if (!response.ok) {
|
|
161
|
-
logger.warn("chat.agent: snapshot fetch returned non-OK; continuing without snapshot", {
|
|
162
|
-
status: response.status,
|
|
163
|
-
sessionId,
|
|
164
|
-
});
|
|
165
|
-
return undefined;
|
|
166
|
-
}
|
|
167
|
-
let parsed;
|
|
168
|
-
try {
|
|
169
|
-
parsed = await response.json();
|
|
170
|
-
}
|
|
171
|
-
catch (error) {
|
|
172
|
-
logger.warn("chat.agent: snapshot JSON parse failed; continuing without snapshot", {
|
|
173
|
-
error: error instanceof Error ? error.message : String(error),
|
|
174
|
-
sessionId,
|
|
175
|
-
});
|
|
176
|
-
return undefined;
|
|
177
|
-
}
|
|
178
|
-
if (!parsed || typeof parsed !== "object")
|
|
179
|
-
return undefined;
|
|
180
|
-
const candidate = parsed;
|
|
181
|
-
if (candidate.version !== 1 || !Array.isArray(candidate.messages)) {
|
|
182
|
-
logger.warn("chat.agent: snapshot version/shape mismatch; ignoring", {
|
|
183
|
-
version: candidate.version,
|
|
184
|
-
sessionId,
|
|
185
|
-
});
|
|
186
|
-
return undefined;
|
|
187
|
-
}
|
|
188
|
-
return candidate;
|
|
189
|
-
}
|
|
190
|
-
/**
|
|
191
|
-
* Persist the snapshot for a session. Awaited by callers immediately after
|
|
192
|
-
* `onTurnComplete` — the agent may suspend right after this point, and
|
|
193
|
-
* fire-and-forget promises don't reliably complete on suspend.
|
|
194
|
-
*
|
|
195
|
-
* Errors are swallowed via `logger.warn`. A failed write means the next
|
|
196
|
-
* boot replays slightly more of `session.out` (back to the previous
|
|
197
|
-
* snapshot's cursor) instead of failing — the conversation stays
|
|
198
|
-
* coherent, only the boot path does marginally more work.
|
|
199
|
-
* @internal
|
|
200
|
-
*/
|
|
201
|
-
async function writeChatSnapshot(sessionId, snapshot) {
|
|
202
|
-
if (writeChatSnapshotImpl) {
|
|
203
|
-
await writeChatSnapshotImpl(sessionId, snapshot);
|
|
204
|
-
return;
|
|
205
|
-
}
|
|
206
|
-
const apiClient = apiClientManager.clientOrThrow();
|
|
207
|
-
let presignedUrl;
|
|
208
|
-
try {
|
|
209
|
-
const resp = await apiClient.createChatSnapshotUploadUrl(sessionId);
|
|
210
|
-
presignedUrl = resp.presignedUrl;
|
|
211
|
-
}
|
|
212
|
-
catch (error) {
|
|
213
|
-
logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", {
|
|
214
|
-
error: error instanceof Error ? error.message : String(error),
|
|
215
|
-
sessionId,
|
|
216
|
-
});
|
|
217
|
-
return;
|
|
218
|
-
}
|
|
219
|
-
let response;
|
|
220
|
-
try {
|
|
221
|
-
response = await fetch(presignedUrl, {
|
|
222
|
-
method: "PUT",
|
|
223
|
-
headers: { "content-type": "application/json" },
|
|
224
|
-
body: JSON.stringify(snapshot),
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
catch (error) {
|
|
228
|
-
logger.warn("chat.agent: snapshot upload failed; next run will replay further", {
|
|
229
|
-
error: error instanceof Error ? error.message : String(error),
|
|
230
|
-
sessionId,
|
|
231
|
-
});
|
|
232
|
-
return;
|
|
233
|
-
}
|
|
234
|
-
if (!response.ok) {
|
|
235
|
-
logger.warn("chat.agent: snapshot upload returned non-OK; next run will replay further", {
|
|
236
|
-
status: response.status,
|
|
237
|
-
sessionId,
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* Test-only entry point that bypasses `__setReadChatSnapshotImplForTests`
|
|
243
|
-
* and reaches the real `apiClient.getPayloadUrl` + `fetch` + JSON-parse path.
|
|
244
|
-
* Used by `chat-snapshot.test.ts` to verify 404 / 500 / malformed JSON /
|
|
245
|
-
* version-mismatch / network-error behavior end-to-end. Tests mock global
|
|
246
|
-
* `fetch` and the api-client config; this wrapper lets them drive the
|
|
247
|
-
* production code without the override hook short-circuiting.
|
|
248
|
-
*
|
|
249
|
-
* Not part of the public API. The `__` prefix and `ForTests` suffix mirror
|
|
250
|
-
* the override-hook setters above.
|
|
251
|
-
* @internal
|
|
252
|
-
*/
|
|
253
|
-
export async function __readChatSnapshotProductionPathForTests(sessionId) {
|
|
254
|
-
const saved = readChatSnapshotImpl;
|
|
255
|
-
readChatSnapshotImpl = undefined;
|
|
256
|
-
try {
|
|
257
|
-
return await readChatSnapshot(sessionId);
|
|
258
|
-
}
|
|
259
|
-
finally {
|
|
260
|
-
readChatSnapshotImpl = saved;
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
/**
|
|
264
|
-
* Test-only entry point that bypasses `__setWriteChatSnapshotImplForTests`
|
|
265
|
-
* and reaches the real `apiClient.createUploadPayloadUrl` + `fetch` PUT
|
|
266
|
-
* path. Pairs with `__readChatSnapshotProductionPathForTests` — see that
|
|
267
|
-
* function's note for the rationale.
|
|
268
|
-
*
|
|
269
|
-
* Not part of the public API.
|
|
270
|
-
* @internal
|
|
271
|
-
*/
|
|
272
|
-
export async function __writeChatSnapshotProductionPathForTests(sessionId, snapshot) {
|
|
273
|
-
const saved = writeChatSnapshotImpl;
|
|
274
|
-
writeChatSnapshotImpl = undefined;
|
|
275
|
-
try {
|
|
276
|
-
await writeChatSnapshot(sessionId, snapshot);
|
|
277
|
-
}
|
|
278
|
-
finally {
|
|
279
|
-
writeChatSnapshotImpl = saved;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
118
|
+
export { __readChatSnapshotProductionPathForTests, __setReadChatSnapshotImplForTests, __setWriteChatSnapshotImplForTests, __writeChatSnapshotProductionPathForTests, } from "./chatSnapshotIo.js";
|
|
119
|
+
export { defaultStorage, memoryTranscriptStorage, reduceTranscriptChanges, snapshotTranscriptStorage, } from "./transcriptStorage.js";
|
|
282
120
|
/**
|
|
283
121
|
* Merge two `UIMessage[]` lists by `id`, with the second list winning on
|
|
284
122
|
* collision. Used at run boot to combine the snapshot's persisted history
|
|
@@ -1027,7 +865,7 @@ async function writeChatCustomAgentClientDataErrorToStream(payload) {
|
|
|
1027
865
|
}
|
|
1028
866
|
}
|
|
1029
867
|
async function reportChatCustomAgentClientDataError(payload, error, options) {
|
|
1030
|
-
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
868
|
+
const errorText = error instanceof Error && error.message ? error.message : "An unexpected error occurred";
|
|
1031
869
|
logger.warn("chat.customAgent: clientData validation failed", {
|
|
1032
870
|
chatId: payload.chatId,
|
|
1033
871
|
trigger: payload.trigger,
|
|
@@ -1756,12 +1594,19 @@ async function installChatInputRouter(chatId, options) {
|
|
|
1756
1594
|
}
|
|
1757
1595
|
const router = entry.router;
|
|
1758
1596
|
router.restore(checkpoint);
|
|
1597
|
+
if (options?.recoveredSeqNums && options.recoveredSeqNums.length > 0) {
|
|
1598
|
+
router.markRecovered(options.recoveredSeqNums);
|
|
1599
|
+
}
|
|
1759
1600
|
const floor = router.resumeFrom();
|
|
1760
1601
|
if (floor !== undefined) {
|
|
1761
1602
|
sessionStreams.setLastSeqNum(chatId, "in", floor);
|
|
1762
1603
|
sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
|
|
1763
1604
|
}
|
|
1764
1605
|
sessionStreams.onRecord(chatId, "in", (record) => {
|
|
1606
|
+
// The floor is the tail's `Last-Event-ID`, but a reconnect can still
|
|
1607
|
+
// re-deliver below it and a replayable route would re-queue it.
|
|
1608
|
+
if (floor !== undefined && record.seqNum <= floor)
|
|
1609
|
+
return true;
|
|
1765
1610
|
router.ingest(record);
|
|
1766
1611
|
return true;
|
|
1767
1612
|
});
|
|
@@ -1933,6 +1778,35 @@ function spliceHandoverPartial(modelMessages, uiMessages, signal) {
|
|
|
1933
1778
|
* @internal
|
|
1934
1779
|
*/
|
|
1935
1780
|
const chatBackgroundQueueKey = locals.create("chat.backgroundQueue");
|
|
1781
|
+
/**
|
|
1782
|
+
* Background injections a step-boundary drain handed to the model this turn,
|
|
1783
|
+
* with the transcript message they followed. Reconciled into the model lane
|
|
1784
|
+
* and the persisted injections once the turn's response is in.
|
|
1785
|
+
*/
|
|
1786
|
+
const chatPendingBackgroundKey = locals.create("chat.pendingBackground");
|
|
1787
|
+
/**
|
|
1788
|
+
* System-role context injected mid-conversation, held for the instructions lane.
|
|
1789
|
+
*
|
|
1790
|
+
* Kept apart from the message queue because ai@7 rejects a system message inside
|
|
1791
|
+
* `messages` for every provider — `standardizePrompt` throws upstream of any
|
|
1792
|
+
* provider call, and its own advice is to use the instructions option. Instructions
|
|
1793
|
+
* accept `Array<SystemModelMessage>`, so a system-role injection has a correct
|
|
1794
|
+
* home: appended as another system block rather than smuggled into the transcript.
|
|
1795
|
+
*
|
|
1796
|
+
* This is also the only way to inject *trusted* context. A message injected as
|
|
1797
|
+
* `user` is untrusted by construction, and a well-aligned model treats it that
|
|
1798
|
+
* way — it will say so, and re-derive the answer from tools instead.
|
|
1799
|
+
*/
|
|
1800
|
+
const chatInjectedInstructionsKey = locals.create("chat.injectedInstructions");
|
|
1801
|
+
/**
|
|
1802
|
+
* What a turn already consumed from the instructions lane, so a second
|
|
1803
|
+
* `toStreamTextOptions()` call in the same turn sees the same blocks.
|
|
1804
|
+
*
|
|
1805
|
+
* Consumed blocks are moved here rather than left in the pending lane: leaving
|
|
1806
|
+
* them there means an injection made during the consumed turn sits behind them,
|
|
1807
|
+
* and clearing the lane on the next turn destroys both.
|
|
1808
|
+
*/
|
|
1809
|
+
const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsConsumed");
|
|
1936
1810
|
/**
|
|
1937
1811
|
* Run-scoped pipe counter. Stored in locals so concurrent runs in the
|
|
1938
1812
|
* same worker don't share state.
|
|
@@ -2443,12 +2317,31 @@ const chatToolsOptionKey = locals.create("chat.toolsOption");
|
|
|
2443
2317
|
const chatResolvedToolsKey = locals.create("chat.resolvedTools");
|
|
2444
2318
|
/** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */
|
|
2445
2319
|
const chatUpgradeRequestedKey = locals.create("chat.upgradeRequested");
|
|
2320
|
+
/** @internal Target for the upgrade handoff, set by `chat.requestUpgrade({ externalDeploymentId })`. */
|
|
2321
|
+
const chatUpgradeExternalDeploymentIdKey = locals.create("chat.upgradeExternalDeploymentId");
|
|
2446
2322
|
/**
|
|
2447
2323
|
* @internal Flag set by `chat.endRun()` to exit the loop after the current
|
|
2448
2324
|
* turn completes, without any upgrade semantics. Checked at the same
|
|
2449
2325
|
* post-turn / pre-wait sites as `chatUpgradeRequestedKey`.
|
|
2450
2326
|
*/
|
|
2451
2327
|
const chatEndRunRequestedKey = locals.create("chat.endRunRequested");
|
|
2328
|
+
/**
|
|
2329
|
+
* @internal Set by `chat.close()`. Holds the close request (and its reason)
|
|
2330
|
+
* for the rest of the run: the loop writes the terminal `session-closed`
|
|
2331
|
+
* record, closes the session row, and exits at the same post-turn /
|
|
2332
|
+
* pre-wait sites as `chatEndRunRequestedKey`.
|
|
2333
|
+
*/
|
|
2334
|
+
const chatCloseRequestedKey = locals.create("chat.closeRequested");
|
|
2335
|
+
/** @internal Matches the server's `CloseSessionRequestBody.reason` cap. */
|
|
2336
|
+
const CHAT_CLOSE_REASON_MAX_LENGTH = 256;
|
|
2337
|
+
/** @internal Set once the session row is closed, so the close happens once. */
|
|
2338
|
+
const chatClosePerformedKey = locals.create("chat.closePerformed");
|
|
2339
|
+
/**
|
|
2340
|
+
* @internal Set once the terminal `.out` record is written. Tracked apart from
|
|
2341
|
+
* {@link chatClosePerformedKey} so a retried close does not emit a second
|
|
2342
|
+
* client-visible event.
|
|
2343
|
+
*/
|
|
2344
|
+
const chatCloseRecordWrittenKey = locals.create("chat.closeRecordWritten");
|
|
2452
2345
|
/** @internal */
|
|
2453
2346
|
const chatAgentCompactionKey = locals.create("chat.agentCompaction");
|
|
2454
2347
|
/**
|
|
@@ -2465,6 +2358,32 @@ export { PENDING_MESSAGE_INJECTED_TYPE, upsertIncomingMessage };
|
|
|
2465
2358
|
const chatPendingMessagesKey = locals.create("chat.pendingMessages");
|
|
2466
2359
|
/** @internal */
|
|
2467
2360
|
const chatSteeringQueueKey = locals.create("chat.steeringQueue");
|
|
2361
|
+
/**
|
|
2362
|
+
* This turn's new messages, as `onTurnComplete.newUIMessages` will see them.
|
|
2363
|
+
*
|
|
2364
|
+
* Held in locals because `drainSteeringQueue` runs outside the turn closure and
|
|
2365
|
+
* has to append the messages it injects. Without that, an injected message
|
|
2366
|
+
* reaches the model and the browser but no hook, so an app persisting from
|
|
2367
|
+
* `onTurnComplete` never learns it existed.
|
|
2368
|
+
*/
|
|
2369
|
+
const chatTurnNewUIMessagesKey = locals.create("chat.turnNewUIMessages");
|
|
2370
|
+
/**
|
|
2371
|
+
* Steering messages a drain consumed that the model accumulator has not been
|
|
2372
|
+
* given yet.
|
|
2373
|
+
*
|
|
2374
|
+
* The two accumulators are maintained separately, and the model one is
|
|
2375
|
+
* normally advanced by appending each turn's delta. A drained message is
|
|
2376
|
+
* appended to the UI one but reaches the model only through the `prepareStep`
|
|
2377
|
+
* return value, which is per-step: without this the model lane never learns
|
|
2378
|
+
* the message exists and every later turn of the run answers without it,
|
|
2379
|
+
* while the browser, the snapshot and `chat.history.*` all still show it.
|
|
2380
|
+
*
|
|
2381
|
+
* Held as the messages rather than a "rebuild me" flag because the model lane
|
|
2382
|
+
* can only be appended to, never reconstructed. Compaction replaces it with a
|
|
2383
|
+
* summary and deliberately leaves the UI lane whole, so reconverting the UI
|
|
2384
|
+
* lane restores every message the summary replaced.
|
|
2385
|
+
*/
|
|
2386
|
+
const chatPendingSteerKey = locals.create("chat.pendingSteer");
|
|
2468
2387
|
/** @internal — IDs of messages that were successfully injected via prepareStep */
|
|
2469
2388
|
const chatInjectedMessageIdsKey = locals.create("chat.injectedMessageIds");
|
|
2470
2389
|
/** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */
|
|
@@ -2817,20 +2736,32 @@ function chatCompactionStep(options) {
|
|
|
2817
2736
|
return result.type === "skipped" ? undefined : result;
|
|
2818
2737
|
};
|
|
2819
2738
|
}
|
|
2820
|
-
|
|
2821
|
-
|
|
2822
|
-
|
|
2739
|
+
const EMPTY_DRAIN = { injected: [], claimed: [] };
|
|
2740
|
+
/**
|
|
2741
|
+
* The model messages to record for one claimed message. Without `prepare`
|
|
2742
|
+
* each entry's own conversion is used. With it, `prepare` returned one list
|
|
2743
|
+
* for the whole batch, so the first claimed message carries all of it and the
|
|
2744
|
+
* rest carry none, which keeps the total exactly what the model received.
|
|
2745
|
+
*/
|
|
2746
|
+
function modelFormOf(m, batch, injected) {
|
|
2747
|
+
return batch[0] === m ? injected : [];
|
|
2748
|
+
}
|
|
2823
2749
|
/**
|
|
2824
2750
|
* Drain the steering queue as a batch. Calls `shouldInject` once with all
|
|
2825
2751
|
* pending messages. If it returns true, calls `prepareMessages` once to
|
|
2826
2752
|
* transform the batch, then clears the queue.
|
|
2827
|
-
* Returns the model messages to inject
|
|
2753
|
+
* Returns the model messages to inject and the UI messages actually claimed.
|
|
2754
|
+
*
|
|
2755
|
+
* `claimed` is returned rather than only published to locals because each
|
|
2756
|
+
* surface files it somewhere different: `chat.agent` has an accumulator in
|
|
2757
|
+
* locals, while `chat.createSession` keeps its own. Publishing to locals alone
|
|
2758
|
+
* is silently a no-op for any surface that never set the key.
|
|
2828
2759
|
* @internal
|
|
2829
2760
|
*/
|
|
2830
2761
|
async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
2831
2762
|
const queue = queueOverride ?? locals.get(chatSteeringQueueKey);
|
|
2832
2763
|
if (!queue || queue.length === 0)
|
|
2833
|
-
return
|
|
2764
|
+
return EMPTY_DRAIN;
|
|
2834
2765
|
const ctx = locals.get(chatTurnContextKey);
|
|
2835
2766
|
const stepNumber = steps.length - 1;
|
|
2836
2767
|
/**
|
|
@@ -2853,7 +2784,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2853
2784
|
// Call shouldInject once for the whole batch
|
|
2854
2785
|
const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
|
|
2855
2786
|
if (!shouldInject)
|
|
2856
|
-
return
|
|
2787
|
+
return EMPTY_DRAIN;
|
|
2857
2788
|
const textOfUIMessage = (m) => (m.parts ?? [])
|
|
2858
2789
|
.filter((p) => p.type === "text")
|
|
2859
2790
|
.map((p) => p.text)
|
|
@@ -2899,7 +2830,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2899
2830
|
queue.splice(at, 1);
|
|
2900
2831
|
}
|
|
2901
2832
|
if (claimed.length === 0)
|
|
2902
|
-
return
|
|
2833
|
+
return EMPTY_DRAIN;
|
|
2903
2834
|
/**
|
|
2904
2835
|
* Give the claim back if the transform fails. `prepare` is caller code and
|
|
2905
2836
|
* can throw; the records have already left the router by this point, so
|
|
@@ -2929,6 +2860,37 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2929
2860
|
for (const m of claimedUIMessages)
|
|
2930
2861
|
injectedIds.add(m.id);
|
|
2931
2862
|
}
|
|
2863
|
+
// Record them as part of the conversation.
|
|
2864
|
+
//
|
|
2865
|
+
// The model has them and the browser has them; without this the
|
|
2866
|
+
// accumulator does not, so they reach neither `uiMessages` nor
|
|
2867
|
+
// `newUIMessages` on `onTurnComplete` and an app that persists from there
|
|
2868
|
+
// silently loses the instruction the answer was shaped by. Appending here
|
|
2869
|
+
// rather than at turn end keeps them in the order they happened: after the
|
|
2870
|
+
// message that started the turn, before the response that answers it.
|
|
2871
|
+
//
|
|
2872
|
+
// De-duplicated by id because a step boundary can drain more than once per
|
|
2873
|
+
// turn, and because a message that failed to inject falls back to becoming
|
|
2874
|
+
// its own turn, where it is accumulated the normal way.
|
|
2875
|
+
const currentUIMessages = locals.get(chatCurrentUIMessagesKey);
|
|
2876
|
+
const turnNew = locals.get(chatTurnNewUIMessagesKey);
|
|
2877
|
+
for (const m of claimedUIMessages) {
|
|
2878
|
+
if (currentUIMessages && !currentUIMessages.some((existing) => existing.id === m.id)) {
|
|
2879
|
+
currentUIMessages.push(m);
|
|
2880
|
+
}
|
|
2881
|
+
if (turnNew && !turnNew.some((existing) => existing.id === m.id)) {
|
|
2882
|
+
turnNew.push(m);
|
|
2883
|
+
}
|
|
2884
|
+
}
|
|
2885
|
+
if (claimedUIMessages.length > 0 && currentUIMessages) {
|
|
2886
|
+
const pendingSteer = locals.get(chatPendingSteerKey) ?? [];
|
|
2887
|
+
for (const m of claimedUIMessages) {
|
|
2888
|
+
if (!pendingSteer.some((existing) => existing.ui.id === m.id)) {
|
|
2889
|
+
pendingSteer.push({ ui: m, model: modelFormOf(m, claimedUIMessages, injected) });
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
locals.set(chatPendingSteerKey, pendingSteer);
|
|
2893
|
+
}
|
|
2932
2894
|
// Write injection confirmation chunk to the stream so the frontend
|
|
2933
2895
|
// knows which messages were injected and where in the response.
|
|
2934
2896
|
if (injected.length > 0) {
|
|
@@ -2970,7 +2932,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2970
2932
|
/* non-fatal */
|
|
2971
2933
|
}
|
|
2972
2934
|
}
|
|
2973
|
-
return injected;
|
|
2935
|
+
return { injected, claimed: claimedUIMessages };
|
|
2974
2936
|
}, {
|
|
2975
2937
|
attributes: {
|
|
2976
2938
|
[SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
|
|
@@ -3196,32 +3158,149 @@ export function buildSkillTools(skills) {
|
|
|
3196
3158
|
return { loadSkill, readFile, bash };
|
|
3197
3159
|
}
|
|
3198
3160
|
/**
|
|
3199
|
-
*
|
|
3200
|
-
*
|
|
3201
|
-
*
|
|
3202
|
-
* (
|
|
3203
|
-
*
|
|
3204
|
-
*
|
|
3205
|
-
*
|
|
3161
|
+
* A `streamText` with the agent's managed options already applied.
|
|
3162
|
+
*
|
|
3163
|
+
* Handed to `run()` so the managed state cannot be missed by omission. Spreading
|
|
3164
|
+
* `chat.toStreamTextOptions()` is still supported and equivalent; this exists
|
|
3165
|
+
* because forgetting the spread silently drops the managed prompt, the skill
|
|
3166
|
+
* tools, telemetry, and the `prepareStep` that delivers steering, compaction and
|
|
3167
|
+
* conversational injection.
|
|
3168
|
+
*
|
|
3169
|
+
* Caller options win for everything the caller owns (model, messages, signal,
|
|
3170
|
+
* stopWhen). The three that would otherwise clobber managed behaviour are
|
|
3171
|
+
* merged instead of replaced:
|
|
3172
|
+
*
|
|
3173
|
+
* - `tools` are passed into the helper, so skill tools survive.
|
|
3174
|
+
* - `prepareStep` is composed after the managed one, so a caller's per-step
|
|
3175
|
+
* overrides apply on top of steering and compaction instead of disabling them.
|
|
3176
|
+
*
|
|
3177
|
+
* `system` may be set at the call site, on `chat.agent({ system })`, or
|
|
3178
|
+
* through `chat.prompt.set()`, but only in one of them. Setting it in two
|
|
3179
|
+
* places throws: no shape merges two system values on every supported
|
|
3180
|
+
* version, and dropping one silently is the failure this seam exists to
|
|
3181
|
+
* prevent. Injected instructions append to whichever one is in play.
|
|
3182
|
+
*/
|
|
3183
|
+
/**
|
|
3184
|
+
* The agent-level managed options (`registry`, `system`, `cacheControl`,
|
|
3185
|
+
* `systemProviderOptions`), published for the run so that
|
|
3186
|
+
* `chat.toStreamTextOptions()` applies them too. Without this only the bound
|
|
3187
|
+
* `streamText` saw them, and the documented spread form silently ran without
|
|
3188
|
+
* the agent's system prompt or model.
|
|
3189
|
+
*/
|
|
3190
|
+
const chatAgentManagedConfigKey = locals.create("chat.agentManagedConfig");
|
|
3191
|
+
/**
|
|
3192
|
+
* The caller's `streamText` options merged with the agent's managed ones.
|
|
3206
3193
|
*
|
|
3207
|
-
*
|
|
3194
|
+
* Pure, and separate from the call so it can be asserted directly: everything
|
|
3195
|
+
* the caller did not name has to survive the merge, and the way to be sure of
|
|
3196
|
+
* that is to look at the merged object rather than at what the model received.
|
|
3208
3197
|
*/
|
|
3198
|
+
function buildManagedStreamTextOptions(options, config) {
|
|
3199
|
+
const { registry, system: agentSystem, cacheControl, systemProviderOptions, tools: agentTools, } = config;
|
|
3200
|
+
/**
|
|
3201
|
+
* Only the three keys that collide are intercepted. Everything else, telemetry
|
|
3202
|
+
* included, stays in `rest` and reaches `streamText` untouched, with the
|
|
3203
|
+
* caller's value winning because `rest` is spread after `managed`. Pulling a
|
|
3204
|
+
* key out to "handle" it is how a caller's option gets silently dropped.
|
|
3205
|
+
*/
|
|
3206
|
+
const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
|
|
3207
|
+
const managed = toStreamTextOptions({
|
|
3208
|
+
registry,
|
|
3209
|
+
system: callerSystem ?? agentSystem,
|
|
3210
|
+
cacheControl,
|
|
3211
|
+
systemProviderOptions,
|
|
3212
|
+
/**
|
|
3213
|
+
* A call site that names `tools` replaces the agent's set rather than
|
|
3214
|
+
* adding to it, so narrowing the tools for one call still works. Omitting
|
|
3215
|
+
* `tools` falls back to the agent's, which is what an `onAction`
|
|
3216
|
+
* regenerate needs: without it a regenerated answer can call nothing.
|
|
3217
|
+
*/
|
|
3218
|
+
tools: (tools ?? agentTools),
|
|
3219
|
+
});
|
|
3220
|
+
const promptSystem = locals.get(chatPromptKey)?.text;
|
|
3221
|
+
/**
|
|
3222
|
+
* Two managed sources conflict too, not only a caller against a managed one.
|
|
3223
|
+
* `toStreamTextOptions` resolves `prompt?.text || agentSystem`, so the prompt
|
|
3224
|
+
* would win and `chat.agent({ system })` would go nowhere.
|
|
3225
|
+
*/
|
|
3226
|
+
if (promptSystem && agentSystem) {
|
|
3227
|
+
throw new Error("chat.agent: `system` is set both on chat.agent({ system }) and by chat.prompt.set(), and only one " +
|
|
3228
|
+
"of them can apply. The prompt would win and the agent's `system` would go nowhere. Keep it in one " +
|
|
3229
|
+
"place, and add per-turn context with chat.inject({ role: 'system' }).");
|
|
3230
|
+
}
|
|
3231
|
+
const managedSystem = promptSystem || agentSystem;
|
|
3232
|
+
if (callerSystem !== undefined && managedSystem) {
|
|
3233
|
+
throw new Error("chat.agent: `system` is already set " +
|
|
3234
|
+
(promptSystem ? "by chat.prompt.set()" : "on chat.agent({ system })") +
|
|
3235
|
+
", so it cannot also be passed to the `streamText` given to run(). Set it in one place, and add " +
|
|
3236
|
+
"per-turn context with chat.inject({ role: 'system' }) rather than a second system value.");
|
|
3237
|
+
}
|
|
3238
|
+
const managedPrepareStep = managed.prepareStep;
|
|
3239
|
+
if (typeof callerPrepareStep === "function") {
|
|
3240
|
+
managed.prepareStep = async (arg) => {
|
|
3241
|
+
const first = managedPrepareStep ? await managedPrepareStep(arg) : undefined;
|
|
3242
|
+
const second = await callerPrepareStep({ ...arg, ...(first ?? {}) });
|
|
3243
|
+
return { ...(first ?? {}), ...(second ?? {}) };
|
|
3244
|
+
};
|
|
3245
|
+
}
|
|
3246
|
+
return { ...managed, ...rest };
|
|
3247
|
+
}
|
|
3248
|
+
/** @internal Test hook for {@link buildManagedStreamTextOptions}. */
|
|
3249
|
+
export const __buildManagedStreamTextOptionsForTests = buildManagedStreamTextOptions;
|
|
3250
|
+
function createBoundStreamText(registry, agentSystem, agentCacheControl, agentSystemProviderOptions) {
|
|
3251
|
+
const bound = (options = {}) => aiStreamText(buildManagedStreamTextOptions(options, {
|
|
3252
|
+
registry,
|
|
3253
|
+
system: agentSystem,
|
|
3254
|
+
cacheControl: agentCacheControl,
|
|
3255
|
+
systemProviderOptions: agentSystemProviderOptions,
|
|
3256
|
+
/** Read per call, so per-turn tools resolved after binding are included. */
|
|
3257
|
+
tools: locals.get(chatResolvedToolsKey),
|
|
3258
|
+
}));
|
|
3259
|
+
return bound;
|
|
3260
|
+
}
|
|
3209
3261
|
function toStreamTextOptions(options) {
|
|
3262
|
+
const agentDefaults = locals.get(chatAgentManagedConfigKey);
|
|
3263
|
+
if (agentDefaults) {
|
|
3264
|
+
options = {
|
|
3265
|
+
registry: agentDefaults.registry,
|
|
3266
|
+
system: agentDefaults.system,
|
|
3267
|
+
cacheControl: agentDefaults.cacheControl,
|
|
3268
|
+
systemProviderOptions: agentDefaults.systemProviderOptions,
|
|
3269
|
+
...options,
|
|
3270
|
+
};
|
|
3271
|
+
}
|
|
3210
3272
|
const prompt = locals.get(chatPromptKey);
|
|
3211
3273
|
const skills = locals.get(chatSkillsKey);
|
|
3212
3274
|
const result = {};
|
|
3213
3275
|
// Build the combined system prompt: stored prompt + skills preamble.
|
|
3214
|
-
const
|
|
3276
|
+
const baseSystem = options?.system;
|
|
3277
|
+
const baseSystemText = typeof baseSystem === "string"
|
|
3278
|
+
? baseSystem
|
|
3279
|
+
: typeof baseSystem?.content === "string"
|
|
3280
|
+
? baseSystem.content
|
|
3281
|
+
: "";
|
|
3282
|
+
const promptText = prompt?.text || baseSystemText;
|
|
3215
3283
|
const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : "";
|
|
3216
3284
|
if (promptText || skillsText) {
|
|
3217
3285
|
const systemText = [promptText, skillsText].filter(Boolean).join("\n\n");
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3286
|
+
/**
|
|
3287
|
+
* Resolve system-prompt provider options for caching. Precedence, most
|
|
3288
|
+
* specific first and no deep merge: explicit `systemProviderOptions`, the
|
|
3289
|
+
* `cacheControl` sugar, the ones carried on a structured `system` message,
|
|
3290
|
+
* then whatever `chat.prompt.set()` stored.
|
|
3291
|
+
*
|
|
3292
|
+
* A structured `system` counts only when its own text is the one being
|
|
3293
|
+
* sent. When `chat.prompt.set()` supplied the text, its provider options
|
|
3294
|
+
* are the ones that describe it.
|
|
3295
|
+
*/
|
|
3296
|
+
const baseSystemProviderOptions = !prompt?.text && baseSystem && typeof baseSystem !== "string"
|
|
3297
|
+
? baseSystem.providerOptions
|
|
3298
|
+
: undefined;
|
|
3221
3299
|
const systemProviderOptions = options?.systemProviderOptions ??
|
|
3222
3300
|
(options?.cacheControl
|
|
3223
3301
|
? { anthropic: { cacheControl: options.cacheControl } }
|
|
3224
3302
|
: undefined) ??
|
|
3303
|
+
baseSystemProviderOptions ??
|
|
3225
3304
|
locals.get(chatPromptProviderOptionsKey);
|
|
3226
3305
|
// A bare string stays a bare string (the unchanged default). With provider
|
|
3227
3306
|
// options, emit a structured `SystemModelMessage` so the provider can cache
|
|
@@ -3230,6 +3309,88 @@ function toStreamTextOptions(options) {
|
|
|
3230
3309
|
? { role: "system", content: systemText, providerOptions: systemProviderOptions }
|
|
3231
3310
|
: systemText;
|
|
3232
3311
|
}
|
|
3312
|
+
/**
|
|
3313
|
+
* Append anything injected as system context, in whichever shape the installed
|
|
3314
|
+
* AI SDK accepts.
|
|
3315
|
+
*
|
|
3316
|
+
* `system` widened over time: on ai@5 it is `string` only, and from ai@6 it is
|
|
3317
|
+
* `string | SystemModelMessage | Array<SystemModelMessage>`. This package's peer
|
|
3318
|
+
* range still spans all three, so emitting an array unconditionally would break
|
|
3319
|
+
* v5 consumers — for whom a system-role injection used to work, since v5 accepted
|
|
3320
|
+
* a system message inside `messages` that v7 rejects.
|
|
3321
|
+
*
|
|
3322
|
+
* So: concatenate into one string when the base is a plain string, which every
|
|
3323
|
+
* version accepts and which loses nothing (separate blocks only matter for
|
|
3324
|
+
* per-block `providerOptions`). Use the array form only when the base is already
|
|
3325
|
+
* a structured message — that path requires v6+ regardless, because it is how
|
|
3326
|
+
* prompt caching marks the system block, and flattening it would silently throw
|
|
3327
|
+
* the cache away.
|
|
3328
|
+
*
|
|
3329
|
+
* Either way the injected text goes last: the base prompt keeps its position for
|
|
3330
|
+
* caching, and the addition reads as a later amendment. A changed prefix does
|
|
3331
|
+
* cost the first call its cache hit, on turns that actually injected.
|
|
3332
|
+
*/
|
|
3333
|
+
/**
|
|
3334
|
+
* Consumed once per turn, not once per read, and moved out of the lane rather
|
|
3335
|
+
* than marked read in place.
|
|
3336
|
+
*
|
|
3337
|
+
* Per turn, because a `run()` that builds options twice (a cheap classifier
|
|
3338
|
+
* pass and then the answer) has to see the injection in both, and draining on
|
|
3339
|
+
* read hands it to whichever call ran first. Moved out, because blocks left in
|
|
3340
|
+
* the lane sit in front of anything injected during the same turn, and
|
|
3341
|
+
* clearing the lane on the next turn then destroys both. Outside a turn there
|
|
3342
|
+
* is no turn to scope the stash to, so the lane drains on read there.
|
|
3343
|
+
*/
|
|
3344
|
+
const injectedInstructions = locals.get(chatInjectedInstructionsKey);
|
|
3345
|
+
const currentTurn = locals.get(chatTurnContextKey)?.turn;
|
|
3346
|
+
const consumedThisTurn = currentTurn === undefined ? undefined : locals.get(chatInstructionsConsumedKey);
|
|
3347
|
+
let injectedBlocks = [];
|
|
3348
|
+
if (consumedThisTurn && consumedThisTurn.turn === currentTurn) {
|
|
3349
|
+
injectedBlocks = consumedThisTurn.blocks;
|
|
3350
|
+
// Anything injected since the stash was taken joins it, so an instruction
|
|
3351
|
+
// added after an action read the lane still reaches the real turn that
|
|
3352
|
+
// shares the action's turn number, rather than the one after.
|
|
3353
|
+
if (injectedInstructions && injectedInstructions.length > 0) {
|
|
3354
|
+
injectedBlocks.push(...injectedInstructions.splice(0));
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
else if (injectedInstructions && injectedInstructions.length > 0) {
|
|
3358
|
+
injectedBlocks = injectedInstructions.splice(0);
|
|
3359
|
+
if (currentTurn !== undefined) {
|
|
3360
|
+
locals.set(chatInstructionsConsumedKey, { turn: currentTurn, blocks: injectedBlocks });
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
if (injectedBlocks.length > 0) {
|
|
3364
|
+
const blocks = injectedBlocks;
|
|
3365
|
+
const injectedText = blocks
|
|
3366
|
+
.map((block) => (typeof block.content === "string" ? block.content : ""))
|
|
3367
|
+
.filter(Boolean)
|
|
3368
|
+
.join("\n\n");
|
|
3369
|
+
const base = result.system;
|
|
3370
|
+
if (base === undefined) {
|
|
3371
|
+
result.system = injectedText;
|
|
3372
|
+
}
|
|
3373
|
+
else if (typeof base === "string") {
|
|
3374
|
+
result.system = [base, injectedText].filter(Boolean).join("\n\n");
|
|
3375
|
+
}
|
|
3376
|
+
else {
|
|
3377
|
+
// Merged into the existing block rather than added as a second one. An array
|
|
3378
|
+
// of system blocks would keep the base block's cache entry, but ai@5 rejects
|
|
3379
|
+
// it outright ("Invalid prompt: system must be a string") while accepting a
|
|
3380
|
+
// single structured block, and this package's peer range still spans v5.
|
|
3381
|
+
// Choosing per version would mean resolving the installed version at runtime,
|
|
3382
|
+
// which is not something to build on: `import.meta.url` is illegal in this
|
|
3383
|
+
// package's CommonJS output, and a bundled task may have no resolvable `ai`
|
|
3384
|
+
// to read. One shape that works everywhere beats a cache hit.
|
|
3385
|
+
const baseBlock = base;
|
|
3386
|
+
result.system = {
|
|
3387
|
+
...baseBlock,
|
|
3388
|
+
content: [typeof baseBlock.content === "string" ? baseBlock.content : "", injectedText]
|
|
3389
|
+
.filter(Boolean)
|
|
3390
|
+
.join("\n\n"),
|
|
3391
|
+
};
|
|
3392
|
+
}
|
|
3393
|
+
}
|
|
3233
3394
|
// Prompt-related options (only if chat.prompt.set() was called)
|
|
3234
3395
|
if (prompt) {
|
|
3235
3396
|
// Resolve model via registry if both are present
|
|
@@ -3284,7 +3445,7 @@ function toStreamTextOptions(options) {
|
|
|
3284
3445
|
}
|
|
3285
3446
|
// 2. Pending message injection (steering)
|
|
3286
3447
|
if (taskPendingMessages) {
|
|
3287
|
-
const injected = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
|
|
3448
|
+
const { injected } = await drainSteeringQueue(taskPendingMessages, resultMessages ?? messages, steps);
|
|
3288
3449
|
if (injected.length > 0) {
|
|
3289
3450
|
resultMessages = [...(resultMessages ?? messages), ...injected];
|
|
3290
3451
|
}
|
|
@@ -3294,18 +3455,89 @@ function toStreamTextOptions(options) {
|
|
|
3294
3455
|
if (bgQueue && bgQueue.length > 0) {
|
|
3295
3456
|
const injected = bgQueue.splice(0); // drain
|
|
3296
3457
|
resultMessages = [...(resultMessages ?? messages), ...injected];
|
|
3458
|
+
const pendingBackground = locals.get(chatPendingBackgroundKey) ?? [];
|
|
3459
|
+
pendingBackground.push({
|
|
3460
|
+
afterId: locals.get(chatCurrentUIMessagesKey)?.at(-1)?.id ?? "",
|
|
3461
|
+
messages: injected,
|
|
3462
|
+
});
|
|
3463
|
+
locals.set(chatPendingBackgroundKey, pendingBackground);
|
|
3297
3464
|
}
|
|
3298
3465
|
return resultMessages ? { messages: resultMessages } : undefined;
|
|
3299
3466
|
};
|
|
3300
3467
|
}
|
|
3301
3468
|
return result;
|
|
3302
3469
|
}
|
|
3470
|
+
const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
3471
|
+
/**
|
|
3472
|
+
* Turn the current action into a turn.
|
|
3473
|
+
*
|
|
3474
|
+
* Return it from `onAction` after editing history. The action's own work is
|
|
3475
|
+
* finished first (the edit is applied and snapshotted), then a turn runs on the
|
|
3476
|
+
* result exactly as a message turn does: `onTurnStart`, `run()` with the edited
|
|
3477
|
+
* history, `onBeforeTurnComplete`, `onTurnComplete`, and the turn counter
|
|
3478
|
+
* advances. That gives the answer everything a turn has, the system prompt,
|
|
3479
|
+
* tools, steering, compaction, injected instructions and persistence, with no
|
|
3480
|
+
* action-specific handling.
|
|
3481
|
+
*
|
|
3482
|
+
* @example
|
|
3483
|
+
* ```ts
|
|
3484
|
+
* onAction: async ({ action }) => {
|
|
3485
|
+
* if (action.type === "regenerate") {
|
|
3486
|
+
* chat.history.slice(0, -1);
|
|
3487
|
+
* return chat.turn();
|
|
3488
|
+
* }
|
|
3489
|
+
* if (action.type === "undo") chat.history.slice(0, -2); // no turn
|
|
3490
|
+
* },
|
|
3491
|
+
* ```
|
|
3492
|
+
*/
|
|
3493
|
+
function chatTurn() {
|
|
3494
|
+
return { [actionTurnBrand]: true };
|
|
3495
|
+
}
|
|
3496
|
+
function isActionTurn(value) {
|
|
3497
|
+
return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
|
|
3498
|
+
}
|
|
3499
|
+
/**
|
|
3500
|
+
* Replace, in a model lane, the run of messages one UI message contributed.
|
|
3501
|
+
*
|
|
3502
|
+
* Used when a UI message is replaced in place (a tool-approval continuation
|
|
3503
|
+
* merging onto the trailing assistant, a captured response reusing an existing
|
|
3504
|
+
* id, a partial replacing an existing message). Reconverting the whole lane
|
|
3505
|
+
* from the UI lane would also replace a compaction summary with the full
|
|
3506
|
+
* transcript and drop the model forms `pendingMessages.prepare` produced.
|
|
3507
|
+
*
|
|
3508
|
+
* The replaced message is the trailing one, so its run is the lane's tail,
|
|
3509
|
+
* before any steer forms appended after it this turn (`tailAfter`). If the
|
|
3510
|
+
* tail does not match the old message's conversion, nothing is changed and
|
|
3511
|
+
* `false` is returned so the caller can fall back to a full reconversion.
|
|
3512
|
+
*/
|
|
3513
|
+
async function replaceModelRun(lane, oldUi, newUi, tailAfter) {
|
|
3514
|
+
const oldRun = await toModelMessages([stripProviderMetadata(oldUi)]);
|
|
3515
|
+
const newRun = await toModelMessages([stripProviderMetadata(newUi)]);
|
|
3516
|
+
const end = lane.length - tailAfter;
|
|
3517
|
+
const start = end - oldRun.length;
|
|
3518
|
+
if (start < 0 || end > lane.length)
|
|
3519
|
+
return false;
|
|
3520
|
+
if (JSON.stringify(lane.slice(start, end)) !== JSON.stringify(oldRun))
|
|
3521
|
+
return false;
|
|
3522
|
+
lane.splice(start, oldRun.length, ...newRun);
|
|
3523
|
+
return true;
|
|
3524
|
+
}
|
|
3303
3525
|
function isUIMessageStreamable(value) {
|
|
3304
3526
|
return (typeof value === "object" &&
|
|
3305
3527
|
value !== null &&
|
|
3306
3528
|
"toUIMessageStream" in value &&
|
|
3307
3529
|
typeof value.toUIMessageStream === "function");
|
|
3308
3530
|
}
|
|
3531
|
+
const warnedHydrateMessagesDeprecated = new Set();
|
|
3532
|
+
function warnHydrateMessagesDeprecatedOnce(agentId) {
|
|
3533
|
+
if (warnedHydrateMessagesDeprecated.has(agentId))
|
|
3534
|
+
return;
|
|
3535
|
+
warnedHydrateMessagesDeprecated.add(agentId);
|
|
3536
|
+
console.warn(`[chat.agent] \`hydrateMessages\` on "${agentId}" is deprecated. Give the agent a transcript ` +
|
|
3537
|
+
"storage instead: `save` receives every change to the conversation and `loadContext` " +
|
|
3538
|
+
"lets the application own the model's context, with crash recovery and durable " +
|
|
3539
|
+
"compaction that `hydrateMessages` never had.");
|
|
3540
|
+
}
|
|
3309
3541
|
let warnedMissingOnAction = false;
|
|
3310
3542
|
function warnMissingOnActionOnce() {
|
|
3311
3543
|
if (warnedMissingOnAction)
|
|
@@ -3438,10 +3670,22 @@ function chatCustomAgent(options) {
|
|
|
3438
3670
|
await installChatInputRouter(payload.chatId, {
|
|
3439
3671
|
resuming: Boolean(payload.continuation),
|
|
3440
3672
|
});
|
|
3673
|
+
// A custom agent's loop is the customer's, so there is no exit site the
|
|
3674
|
+
// SDK controls. Perform a requested close when `run()` returns, whatever
|
|
3675
|
+
// shape the loop had. Idempotent, so the createSession iterator having
|
|
3676
|
+
// already closed on its own exit costs nothing.
|
|
3677
|
+
const withClose = async (result) => {
|
|
3678
|
+
try {
|
|
3679
|
+
return await result;
|
|
3680
|
+
}
|
|
3681
|
+
finally {
|
|
3682
|
+
await performChatClose();
|
|
3683
|
+
}
|
|
3684
|
+
};
|
|
3441
3685
|
// Keep the schema-free path identical to the original custom-agent
|
|
3442
3686
|
// wrapper, including when userRun starts executing.
|
|
3443
3687
|
if (!parseClientData) {
|
|
3444
|
-
return userRun(payload, runOptions);
|
|
3688
|
+
return withClose(userRun(payload, runOptions));
|
|
3445
3689
|
}
|
|
3446
3690
|
const isHandoverBoot = payload.trigger === "handover-prepare";
|
|
3447
3691
|
const isMessagelessBoot = payload.trigger === "preload" ||
|
|
@@ -3457,7 +3701,7 @@ function chatCustomAgent(options) {
|
|
|
3457
3701
|
writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
|
|
3458
3702
|
});
|
|
3459
3703
|
if (validated.ok) {
|
|
3460
|
-
return userRun(validated.payload, runOptions);
|
|
3704
|
+
return withClose(userRun(validated.payload, runOptions));
|
|
3461
3705
|
}
|
|
3462
3706
|
if (isHandoverBoot) {
|
|
3463
3707
|
const signal = await waitForHandover({
|
|
@@ -3493,7 +3737,7 @@ function chatCustomAgent(options) {
|
|
|
3493
3737
|
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
3494
3738
|
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
3495
3739
|
};
|
|
3496
|
-
return userRun(recoveredPayload, runOptions);
|
|
3740
|
+
return withClose(userRun(recoveredPayload, runOptions));
|
|
3497
3741
|
},
|
|
3498
3742
|
});
|
|
3499
3743
|
// Register clientDataSchema so the CLI converts it to JSONSchema
|
|
@@ -3506,7 +3750,19 @@ function chatCustomAgent(options) {
|
|
|
3506
3750
|
return task;
|
|
3507
3751
|
}
|
|
3508
3752
|
function chatAgent(options) {
|
|
3509
|
-
const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, ...restOptions } = options;
|
|
3753
|
+
const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, ...restOptions } = options;
|
|
3754
|
+
if (hydrateMessages) {
|
|
3755
|
+
if (storage) {
|
|
3756
|
+
throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
|
|
3757
|
+
"`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
|
|
3758
|
+
"change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
|
|
3759
|
+
}
|
|
3760
|
+
if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
|
|
3761
|
+
throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
|
|
3762
|
+
"`loadContext`. Both would own the model's context; keep one.");
|
|
3763
|
+
}
|
|
3764
|
+
warnHydrateMessagesDeprecatedOnce(options.id);
|
|
3765
|
+
}
|
|
3510
3766
|
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
|
|
3511
3767
|
const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
|
|
3512
3768
|
// chat.agent does not expose generic retry options (see docstring on
|
|
@@ -3589,6 +3845,42 @@ function chatAgent(options) {
|
|
|
3589
3845
|
// durable snapshot + `session.out` replay (or `hydrateMessages` if
|
|
3590
3846
|
// registered) — the wire is delta-only now, no longer a seed.
|
|
3591
3847
|
let accumulatedMessages = [];
|
|
3848
|
+
/**
|
|
3849
|
+
* Give the model accumulator the background injections a step-boundary
|
|
3850
|
+
* drain handed to the model this turn, and record them for persistence.
|
|
3851
|
+
* Returns how many model messages were appended.
|
|
3852
|
+
*/
|
|
3853
|
+
const reconcilePendingBackground = () => {
|
|
3854
|
+
const pending = locals.get(chatPendingBackgroundKey);
|
|
3855
|
+
if (!pending || pending.length === 0)
|
|
3856
|
+
return 0;
|
|
3857
|
+
locals.set(chatPendingBackgroundKey, []);
|
|
3858
|
+
let appended = 0;
|
|
3859
|
+
for (const entry of pending) {
|
|
3860
|
+
accumulatedMessages.push(...entry.messages);
|
|
3861
|
+
laneInjections.push(entry);
|
|
3862
|
+
appended += entry.messages.length;
|
|
3863
|
+
}
|
|
3864
|
+
return appended;
|
|
3865
|
+
};
|
|
3866
|
+
/**
|
|
3867
|
+
* Give the model accumulator the steering messages a drain consumed,
|
|
3868
|
+
* in the form the model actually received. Appended, never reconverted
|
|
3869
|
+
* from the UI lane, so a model-only compaction summary survives. Called
|
|
3870
|
+
* on both the success and the error path, before the response or the
|
|
3871
|
+
* partial joins the lane, so the order stays steer-then-answer.
|
|
3872
|
+
*/
|
|
3873
|
+
const reconcilePendingSteer = (options) => {
|
|
3874
|
+
const pending = locals.get(chatPendingSteerKey);
|
|
3875
|
+
if (!pending || pending.length === 0)
|
|
3876
|
+
return [];
|
|
3877
|
+
locals.set(chatPendingSteerKey, []);
|
|
3878
|
+
for (const entry of pending) {
|
|
3879
|
+
accumulatedMessages.push(...entry.model);
|
|
3880
|
+
options?.turnNew?.push(...entry.model);
|
|
3881
|
+
}
|
|
3882
|
+
return pending;
|
|
3883
|
+
};
|
|
3592
3884
|
// Accumulated UI messages for persistence. Mirrors the model accumulator
|
|
3593
3885
|
// but in frontend-friendly UIMessage format (with parts, id, etc.).
|
|
3594
3886
|
let accumulatedUIMessages = [];
|
|
@@ -3606,7 +3898,156 @@ function chatAgent(options) {
|
|
|
3606
3898
|
// collectively cost ~600ms on every first-message TTFC. Both reads
|
|
3607
3899
|
// swallow errors internally; the agent stays available either way.
|
|
3608
3900
|
const sessionIdForSnapshot = payload.sessionId ?? payload.chatId;
|
|
3901
|
+
const transcriptStorage = storage ??
|
|
3902
|
+
transcriptStorageOverride ??
|
|
3903
|
+
defaultStorage;
|
|
3904
|
+
const storageLoadContext = transcriptStorage.loadContext?.bind(transcriptStorage);
|
|
3905
|
+
/**
|
|
3906
|
+
* Who supplies the model's context each turn: the deprecated
|
|
3907
|
+
* `hydrateMessages` hook, the storage's `loadContext`, or (undefined)
|
|
3908
|
+
* the runtime's own transcript.
|
|
3909
|
+
*/
|
|
3910
|
+
const loadContextHook = hydrateMessages
|
|
3911
|
+
? (event) => hydrateMessages(event)
|
|
3912
|
+
: storageLoadContext
|
|
3913
|
+
? (event) => storageLoadContext({ chatId: event.chatId, clientData: event.clientData }, event)
|
|
3914
|
+
: undefined;
|
|
3915
|
+
let transcriptShadow = createTranscriptShadow([]);
|
|
3916
|
+
let bootTranscriptState = null;
|
|
3917
|
+
/**
|
|
3918
|
+
* True while the model lane holds a compaction summary, so it cannot be
|
|
3919
|
+
* rebuilt from the transcript and has to be persisted as state. Reset
|
|
3920
|
+
* wherever the lane is reconverted from the UI lane.
|
|
3921
|
+
*/
|
|
3922
|
+
let laneCompacted = false;
|
|
3923
|
+
/** Conversational `chat.inject` messages in the lane, anchored to the transcript. */
|
|
3924
|
+
let laneInjections = [];
|
|
3925
|
+
let persistedStateSet = false;
|
|
3609
3926
|
let bootSnapshot;
|
|
3927
|
+
let bootClientData = payload.metadata;
|
|
3928
|
+
if (parseClientData) {
|
|
3929
|
+
try {
|
|
3930
|
+
bootClientData = await parseClientData(payload.metadata);
|
|
3931
|
+
}
|
|
3932
|
+
catch {
|
|
3933
|
+
bootClientData = payload.metadata;
|
|
3934
|
+
}
|
|
3935
|
+
}
|
|
3936
|
+
/**
|
|
3937
|
+
* The `lastOutEventId` the most recent snapshot carried.
|
|
3938
|
+
*
|
|
3939
|
+
* A snapshot written outside a turn — after an action mutates history — has
|
|
3940
|
+
* no turn cursor of its own, and writing `undefined` there would drop the
|
|
3941
|
+
* resume point and make the next boot replay from further back. Retaining it
|
|
3942
|
+
* keeps an action's write cursor-neutral.
|
|
3943
|
+
*/
|
|
3944
|
+
let lastSnapshotOutEventId;
|
|
3945
|
+
const storageTrigger = (trigger) => trigger === "regenerate-message"
|
|
3946
|
+
? "regenerate-message"
|
|
3947
|
+
: trigger === "action" || trigger === "action-turn"
|
|
3948
|
+
? "action"
|
|
3949
|
+
: "submit-message";
|
|
3950
|
+
/**
|
|
3951
|
+
* Hand the runtime's view of the transcript to the storage as a
|
|
3952
|
+
* changeset: the diff against what was last saved, plus the cursors the
|
|
3953
|
+
* next boot resumes from. The shadow only advances when the save
|
|
3954
|
+
* succeeds, so a failed save is folded into the next changeset.
|
|
3955
|
+
*/
|
|
3956
|
+
/** The runtime's opaque state as of the last save; carried on every changeset's transcript. */
|
|
3957
|
+
let transcriptState = null;
|
|
3958
|
+
const saveTranscript = async (opts) => {
|
|
3959
|
+
const { changes, shadow } = diffTranscript(transcriptShadow, opts.messages, {
|
|
3960
|
+
nonFinalIds: opts.nonFinalIds,
|
|
3961
|
+
});
|
|
3962
|
+
const throughId = opts.messages.at(-1)?.id ?? "";
|
|
3963
|
+
const queued = locals.get(chatBackgroundQueueKey) ?? [];
|
|
3964
|
+
const runtimeState = laneCompacted || laneInjections.length > 0 || queued.length > 0
|
|
3965
|
+
? {
|
|
3966
|
+
v: 1,
|
|
3967
|
+
...(laneCompacted
|
|
3968
|
+
? {
|
|
3969
|
+
compaction: {
|
|
3970
|
+
modelMessages: accumulatedMessages,
|
|
3971
|
+
throughId,
|
|
3972
|
+
},
|
|
3973
|
+
}
|
|
3974
|
+
: {}),
|
|
3975
|
+
...(laneInjections.length > 0 ? { injections: laneInjections } : {}),
|
|
3976
|
+
...(queued.length > 0 ? { queued: [...queued] } : {}),
|
|
3977
|
+
}
|
|
3978
|
+
: null;
|
|
3979
|
+
if (runtimeState !== null || persistedStateSet) {
|
|
3980
|
+
changes.push({ op: "state", value: runtimeState });
|
|
3981
|
+
}
|
|
3982
|
+
transcriptState = runtimeState;
|
|
3983
|
+
const inCursor = chatInputRouter().resumeFloor();
|
|
3984
|
+
await transcriptStorage.save({
|
|
3985
|
+
chatId: payload.chatId,
|
|
3986
|
+
clientData: opts.clientData,
|
|
3987
|
+
turn: opts.turn,
|
|
3988
|
+
trigger: opts.trigger,
|
|
3989
|
+
runId: ctx.run.id,
|
|
3990
|
+
ctx,
|
|
3991
|
+
}, {
|
|
3992
|
+
reason: opts.reason,
|
|
3993
|
+
changes,
|
|
3994
|
+
transcript: {
|
|
3995
|
+
entries: opts.messages.map((message) => ({
|
|
3996
|
+
id: message.id,
|
|
3997
|
+
final: !shadow.nonFinal.has(message.id),
|
|
3998
|
+
message,
|
|
3999
|
+
})),
|
|
4000
|
+
state: transcriptState,
|
|
4001
|
+
},
|
|
4002
|
+
cursors: {
|
|
4003
|
+
lastOutEventId: opts.lastOutEventId,
|
|
4004
|
+
lastInEventId: inCursor !== undefined ? String(inCursor) : undefined,
|
|
4005
|
+
},
|
|
4006
|
+
});
|
|
4007
|
+
transcriptShadow = shadow;
|
|
4008
|
+
persistedStateSet = runtimeState !== null;
|
|
4009
|
+
};
|
|
4010
|
+
/**
|
|
4011
|
+
* Persist the accumulator outside a turn.
|
|
4012
|
+
*
|
|
4013
|
+
* An action is not a turn, so it never reaches the turn-complete path where
|
|
4014
|
+
* the transcript is normally saved, but a `chat.history` mutation changes
|
|
4015
|
+
* the conversation and has to survive the run ending.
|
|
4016
|
+
*
|
|
4017
|
+
* Cursor-neutral: an action has no turn cursor of its own, and writing
|
|
4018
|
+
* `undefined` would drop the resume point the last turn established and make
|
|
4019
|
+
* the next boot replay from further back.
|
|
4020
|
+
*/
|
|
4021
|
+
const writeSnapshotOutsideTurn = async (reason, turnContext) => {
|
|
4022
|
+
if (hydrateMessages)
|
|
4023
|
+
return;
|
|
4024
|
+
try {
|
|
4025
|
+
await tracer.startActiveSpan("snapshot.write", async () => {
|
|
4026
|
+
await saveTranscript({
|
|
4027
|
+
reason: "action",
|
|
4028
|
+
messages: accumulatedUIMessages,
|
|
4029
|
+
turn: turnContext.turn,
|
|
4030
|
+
trigger: "action",
|
|
4031
|
+
clientData: turnContext.clientData,
|
|
4032
|
+
lastOutEventId: lastSnapshotOutEventId,
|
|
4033
|
+
});
|
|
4034
|
+
}, {
|
|
4035
|
+
attributes: {
|
|
4036
|
+
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
|
|
4037
|
+
[SemanticInternalAttributes.COLLAPSED]: true,
|
|
4038
|
+
"chat.snapshot.reason": reason,
|
|
4039
|
+
"chat.messages.count": accumulatedUIMessages.length,
|
|
4040
|
+
},
|
|
4041
|
+
});
|
|
4042
|
+
}
|
|
4043
|
+
catch (error) {
|
|
4044
|
+
logger.warn("chat.agent: snapshot write outside a turn failed; the change may not survive a continuation", {
|
|
4045
|
+
error: error instanceof Error ? error.message : String(error),
|
|
4046
|
+
sessionId: sessionIdForSnapshot,
|
|
4047
|
+
reason,
|
|
4048
|
+
});
|
|
4049
|
+
}
|
|
4050
|
+
};
|
|
3610
4051
|
let replayedSettled = [];
|
|
3611
4052
|
let replayedPartial;
|
|
3612
4053
|
let replayedPartialRaw;
|
|
@@ -3616,13 +4057,20 @@ function chatAgent(options) {
|
|
|
3616
4057
|
// default, `inFlightUsers`). The turn-loop checks this queue ahead of
|
|
3617
4058
|
// `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
|
|
3618
4059
|
const bootInjectedQueue = [];
|
|
4060
|
+
const recoveredSeqByPayload = new WeakMap();
|
|
4061
|
+
const dispatchBootInjected = () => bootInjectedQueue.shift();
|
|
4062
|
+
const settleRecoveredTurn = (wirePayload) => {
|
|
4063
|
+
const settledSeq = recoveredSeqByPayload.get(wirePayload);
|
|
4064
|
+
if (settledSeq !== undefined)
|
|
4065
|
+
chatInputRouter().settleRecovered(settledSeq);
|
|
4066
|
+
};
|
|
3619
4067
|
const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
|
|
3620
4068
|
// `.in` resume cursor, computed at most once per boot. The boot
|
|
3621
4069
|
// block below resolves it (snapshot field or records scan) and the
|
|
3622
4070
|
// resume-cursor block reuses it instead of re-scanning.
|
|
3623
4071
|
let bootInCursor;
|
|
3624
4072
|
let bootInCursorResolved = false;
|
|
3625
|
-
if (
|
|
4073
|
+
if (couldHavePriorState) {
|
|
3626
4074
|
// Single parent span for the whole boot read phase — snapshot
|
|
3627
4075
|
// read, session.out replay, session.in replay. Per-phase timing
|
|
3628
4076
|
// + result counts are attributes on the span.
|
|
@@ -3630,18 +4078,33 @@ function chatAgent(options) {
|
|
|
3630
4078
|
// snapshot read
|
|
3631
4079
|
const snapStart = Date.now();
|
|
3632
4080
|
try {
|
|
3633
|
-
|
|
4081
|
+
const loaded = hydrateMessages
|
|
4082
|
+
? undefined
|
|
4083
|
+
: await transcriptStorage.load({
|
|
4084
|
+
chatId: payload.chatId,
|
|
4085
|
+
clientData: bootClientData,
|
|
4086
|
+
});
|
|
4087
|
+
if (loaded) {
|
|
4088
|
+
transcriptShadow = createTranscriptShadow(loaded.messages, new Set(loaded.nonFinalIds ?? []));
|
|
4089
|
+
bootTranscriptState = loaded.state;
|
|
4090
|
+
transcriptState = loaded.state ?? null;
|
|
4091
|
+
persistedStateSet = loaded.state !== null && loaded.state !== undefined;
|
|
4092
|
+
bootSnapshot = {
|
|
4093
|
+
messages: loaded.messages,
|
|
4094
|
+
lastOutEventId: loaded.cursors?.lastOutEventId,
|
|
4095
|
+
lastInEventId: loaded.cursors?.lastInEventId,
|
|
4096
|
+
};
|
|
4097
|
+
}
|
|
3634
4098
|
}
|
|
3635
4099
|
catch (error) {
|
|
3636
|
-
|
|
3637
|
-
// is just belt-and-suspenders against tracer/span errors.
|
|
3638
|
-
logger.warn("chat.agent: snapshot read failed; continuing without snapshot", {
|
|
4100
|
+
logger.warn("chat.agent: transcript load failed; continuing from the stream tail", {
|
|
3639
4101
|
error: error instanceof Error ? error.message : String(error),
|
|
3640
4102
|
sessionId: sessionIdForSnapshot,
|
|
3641
4103
|
});
|
|
3642
4104
|
}
|
|
3643
4105
|
bootSpan.setAttribute("chat.boot.snapshot.durationMs", Date.now() - snapStart);
|
|
3644
|
-
bootSpan.setAttribute("chat.boot.snapshot.present",
|
|
4106
|
+
bootSpan.setAttribute("chat.boot.snapshot.present", bootSnapshot !== undefined &&
|
|
4107
|
+
(bootSnapshot.messages.length > 0 || bootSnapshot.lastOutEventId !== undefined));
|
|
3645
4108
|
bootSpan.setAttribute("chat.boot.snapshot.messageCount", bootSnapshot?.messages?.length ?? 0);
|
|
3646
4109
|
// Seed the trim chain from the snapshot's `lastOutEventId` (the SSE
|
|
3647
4110
|
// id of the previous turn's `turn-complete` control record). The
|
|
@@ -3649,6 +4112,7 @@ function chatAgent(options) {
|
|
|
3649
4112
|
// Without seeding, the new worker would emit no trim on its first
|
|
3650
4113
|
// turn (chain self-bootstraps from turn 2), so this is purely an
|
|
3651
4114
|
// optimization to keep continuation runs bounded from the first turn.
|
|
4115
|
+
lastSnapshotOutEventId = bootSnapshot?.lastOutEventId;
|
|
3652
4116
|
if (bootSnapshot?.lastOutEventId !== undefined) {
|
|
3653
4117
|
const seeded = Number.parseInt(bootSnapshot.lastOutEventId, 10);
|
|
3654
4118
|
if (Number.isFinite(seeded)) {
|
|
@@ -3740,16 +4204,14 @@ function chatAgent(options) {
|
|
|
3740
4204
|
}
|
|
3741
4205
|
// ── session.in router ──────────────────────────────────────────
|
|
3742
4206
|
//
|
|
3743
|
-
|
|
3744
|
-
// only a fallback: the boot block above may already have resolved a
|
|
3745
|
-
// cursor from the snapshot, which is used when the boundary itself
|
|
3746
|
-
// carries none.
|
|
4207
|
+
const recoveredSeqNums = replayedInTail.map((r) => r.seqNum);
|
|
3747
4208
|
await installChatInputRouter(payload.chatId, {
|
|
3748
4209
|
fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
|
|
4210
|
+
recoveredSeqNums,
|
|
3749
4211
|
resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
|
|
3750
4212
|
});
|
|
3751
4213
|
// ── Recovery boot + chain reconstruction ────────────────────────
|
|
3752
|
-
|
|
4214
|
+
{
|
|
3753
4215
|
const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
|
|
3754
4216
|
const inFlightUsers = replayedInTail.map((r) => r.message);
|
|
3755
4217
|
const partialAssistant = replayedPartial;
|
|
@@ -3828,7 +4290,7 @@ function chatAgent(options) {
|
|
|
3828
4290
|
// branches: at n=1 the orphan partial is dropped and the interrupted
|
|
3829
4291
|
// user is re-dispatched as a fresh turn instead.
|
|
3830
4292
|
let seedChain;
|
|
3831
|
-
let
|
|
4293
|
+
let recoveredEntries;
|
|
3832
4294
|
if (hookChain !== undefined) {
|
|
3833
4295
|
seedChain = hookChain;
|
|
3834
4296
|
}
|
|
@@ -3839,13 +4301,26 @@ function chatAgent(options) {
|
|
|
3839
4301
|
seedChain = settledMessages;
|
|
3840
4302
|
}
|
|
3841
4303
|
if (hookRecoveredTurns !== undefined) {
|
|
3842
|
-
|
|
4304
|
+
const seqNumsByRecoveredId = new Map();
|
|
4305
|
+
for (const entry of replayedInTail) {
|
|
4306
|
+
const existing = seqNumsByRecoveredId.get(entry.message.id);
|
|
4307
|
+
if (existing)
|
|
4308
|
+
existing.push(entry.seqNum);
|
|
4309
|
+
else
|
|
4310
|
+
seqNumsByRecoveredId.set(entry.message.id, [entry.seqNum]);
|
|
4311
|
+
}
|
|
4312
|
+
recoveredEntries = hookRecoveredTurns.map((message) => ({
|
|
4313
|
+
message,
|
|
4314
|
+
seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
|
|
4315
|
+
}));
|
|
3843
4316
|
}
|
|
3844
4317
|
else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
|
|
3845
|
-
|
|
4318
|
+
recoveredEntries = replayedInTail
|
|
4319
|
+
.slice(1)
|
|
4320
|
+
.map((r) => ({ message: r.message, seqNum: r.seqNum }));
|
|
3846
4321
|
}
|
|
3847
4322
|
else {
|
|
3848
|
-
|
|
4323
|
+
recoveredEntries = replayedInTail.map((r) => ({ message: r.message, seqNum: r.seqNum }));
|
|
3849
4324
|
}
|
|
3850
4325
|
// `beforeBoot` errors bubble — the customer opted into blocking
|
|
3851
4326
|
// persistence and a failure there should fail the run rather than
|
|
@@ -3853,16 +4328,6 @@ function chatAgent(options) {
|
|
|
3853
4328
|
if (hookBeforeBoot) {
|
|
3854
4329
|
await hookBeforeBoot();
|
|
3855
4330
|
}
|
|
3856
|
-
// Advance the session.in cursor past every recovered user so
|
|
3857
|
-
// the live subscription doesn't re-deliver them.
|
|
3858
|
-
if (replayedInTail.length > 0) {
|
|
3859
|
-
const lastRecoveredSeq = replayedInTail[replayedInTail.length - 1].seqNum;
|
|
3860
|
-
const currentCursor = sessionStreams.lastSeqNum(payload.chatId, "in");
|
|
3861
|
-
if (currentCursor === undefined || lastRecoveredSeq > currentCursor) {
|
|
3862
|
-
sessionStreams.setLastSeqNum(payload.chatId, "in", lastRecoveredSeq);
|
|
3863
|
-
sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", lastRecoveredSeq);
|
|
3864
|
-
}
|
|
3865
|
-
}
|
|
3866
4331
|
// Synthesize wire payloads for each recoveredTurn. The turn-loop
|
|
3867
4332
|
// pops these ahead of `messagesInput.waitWithIdleTimeout` so they
|
|
3868
4333
|
// dispatch as normal turns with the existing hook stack.
|
|
@@ -3885,13 +4350,14 @@ function chatAgent(options) {
|
|
|
3885
4350
|
for (const entry of replayedInTail) {
|
|
3886
4351
|
metadataById.set(entry.message.id, entry.metadata);
|
|
3887
4352
|
}
|
|
3888
|
-
|
|
4353
|
+
const dispatchedRecoveredSeqs = new Set();
|
|
4354
|
+
for (const { message: msg, seqNum } of recoveredEntries) {
|
|
3889
4355
|
if (wireMessageId && msg.id === wireMessageId)
|
|
3890
4356
|
continue;
|
|
3891
4357
|
const recoveredMetadata = metadataById.has(msg.id)
|
|
3892
4358
|
? metadataById.get(msg.id)
|
|
3893
4359
|
: payload.metadata;
|
|
3894
|
-
|
|
4360
|
+
const injectedPayload = {
|
|
3895
4361
|
chatId: payload.chatId,
|
|
3896
4362
|
sessionId: payload.sessionId,
|
|
3897
4363
|
metadata: recoveredMetadata,
|
|
@@ -3900,7 +4366,17 @@ function chatAgent(options) {
|
|
|
3900
4366
|
messageId: msg.id,
|
|
3901
4367
|
continuation: payload.continuation,
|
|
3902
4368
|
previousRunId: payload.previousRunId,
|
|
3903
|
-
}
|
|
4369
|
+
};
|
|
4370
|
+
bootInjectedQueue.push(injectedPayload);
|
|
4371
|
+
if (seqNum !== undefined) {
|
|
4372
|
+
recoveredSeqByPayload.set(injectedPayload, seqNum);
|
|
4373
|
+
dispatchedRecoveredSeqs.add(seqNum);
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
for (const entry of replayedInTail) {
|
|
4377
|
+
if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
|
|
4378
|
+
chatInputRouter().settleRecovered(entry.seqNum);
|
|
4379
|
+
}
|
|
3904
4380
|
}
|
|
3905
4381
|
accumulatedUIMessages = seedChain;
|
|
3906
4382
|
// ── Head-start bootstrap ─────────────────────────────────────
|
|
@@ -3912,7 +4388,8 @@ function chatAgent(options) {
|
|
|
3912
4388
|
// path where wire-borne UIMessage[] still seeds the accumulator,
|
|
3913
4389
|
// and it's safe because the route handler isn't subject to the
|
|
3914
4390
|
// `/in/append` 512 KiB cap.
|
|
3915
|
-
if (
|
|
4391
|
+
if (!loadContextHook &&
|
|
4392
|
+
accumulatedUIMessages.length === 0 &&
|
|
3916
4393
|
payload.trigger === "handover-prepare" &&
|
|
3917
4394
|
Array.isArray(payload.headStartMessages) &&
|
|
3918
4395
|
payload.headStartMessages.length > 0) {
|
|
@@ -3941,7 +4418,17 @@ function chatAgent(options) {
|
|
|
3941
4418
|
}
|
|
3942
4419
|
}
|
|
3943
4420
|
try {
|
|
3944
|
-
|
|
4421
|
+
const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
|
|
4422
|
+
const restored = await restoreModelLane(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
|
|
4423
|
+
accumulatedMessages = restored.messages;
|
|
4424
|
+
laneCompacted = restored.compacted;
|
|
4425
|
+
laneInjections = restored.injections;
|
|
4426
|
+
if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
|
|
4427
|
+
locals.set(chatBackgroundQueueKey, [
|
|
4428
|
+
...(locals.get(chatBackgroundQueueKey) ?? []),
|
|
4429
|
+
...bootRuntimeState.queued,
|
|
4430
|
+
]);
|
|
4431
|
+
}
|
|
3945
4432
|
}
|
|
3946
4433
|
catch (error) {
|
|
3947
4434
|
logger.warn("chat.agent: toModelMessages failed at boot; starting empty", {
|
|
@@ -3955,6 +4442,12 @@ function chatAgent(options) {
|
|
|
3955
4442
|
// before any hook (`onChatStart`, `onTurnStart`, etc.) fires.
|
|
3956
4443
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
3957
4444
|
}
|
|
4445
|
+
locals.set(chatAgentManagedConfigKey, {
|
|
4446
|
+
registry: promptRegistry,
|
|
4447
|
+
system: agentSystem,
|
|
4448
|
+
cacheControl: agentCacheControl,
|
|
4449
|
+
systemProviderOptions: agentSystemProviderOptions,
|
|
4450
|
+
});
|
|
3958
4451
|
// Token usage tracking across turns
|
|
3959
4452
|
let previousTurnUsage;
|
|
3960
4453
|
let cumulativeUsage = emptyUsage();
|
|
@@ -4043,7 +4536,7 @@ function chatAgent(options) {
|
|
|
4043
4536
|
*/
|
|
4044
4537
|
let dispatchedRecoveredFirstTurn = false;
|
|
4045
4538
|
if (preloaded && bootInjectedQueue.length > 0) {
|
|
4046
|
-
currentWirePayload =
|
|
4539
|
+
currentWirePayload = dispatchBootInjected();
|
|
4047
4540
|
dispatchedRecoveredFirstTurn = true;
|
|
4048
4541
|
}
|
|
4049
4542
|
// Handle preloaded runs — fire onPreload, then wait for the first real message
|
|
@@ -4251,7 +4744,7 @@ function chatAgent(options) {
|
|
|
4251
4744
|
// waiting on the live session.in. Subsequent recovered turns
|
|
4252
4745
|
// get drained by the end-of-turn picker below.
|
|
4253
4746
|
if (bootInjectedQueue.length > 0) {
|
|
4254
|
-
currentWirePayload =
|
|
4747
|
+
currentWirePayload = dispatchBootInjected();
|
|
4255
4748
|
}
|
|
4256
4749
|
else {
|
|
4257
4750
|
const effectiveIdleTimeout = idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds;
|
|
@@ -4312,6 +4805,7 @@ function chatAgent(options) {
|
|
|
4312
4805
|
} // end else (no boot-injected first turn)
|
|
4313
4806
|
}
|
|
4314
4807
|
for (let turn = 0; turn < maxTurns; turn++) {
|
|
4808
|
+
let turnClientData = payload.metadata;
|
|
4315
4809
|
// Declared here so the finally can detach it — a handler leaked past
|
|
4316
4810
|
// its turn duplicates every mid-stream message into the shared buffer.
|
|
4317
4811
|
let turnMsgSub;
|
|
@@ -4333,6 +4827,7 @@ function chatAgent(options) {
|
|
|
4333
4827
|
// `cleanupAbortedParts` no longer has to be re-applied below.
|
|
4334
4828
|
const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
|
|
4335
4829
|
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
4830
|
+
turnClientData = clientData;
|
|
4336
4831
|
const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
|
|
4337
4832
|
// Actions are not turns. They use a different span name
|
|
4338
4833
|
// and don't carry a turn.number. Branched on at `isAction`.
|
|
@@ -4369,6 +4864,7 @@ function chatAgent(options) {
|
|
|
4369
4864
|
locals.set(chatDeferKey, new Set());
|
|
4370
4865
|
locals.set(chatCompactionStateKey, undefined);
|
|
4371
4866
|
locals.set(chatSteeringQueueKey, []);
|
|
4867
|
+
locals.set(chatPendingBackgroundKey, []);
|
|
4372
4868
|
locals.set(chatResponsePartsKey, []);
|
|
4373
4869
|
// NOTE: chatBackgroundQueueKey is NOT reset here — messages injected
|
|
4374
4870
|
// by deferred work from the previous turn's onTurnComplete need to
|
|
@@ -4450,6 +4946,10 @@ function chatAgent(options) {
|
|
|
4450
4946
|
// Track new messages for this turn (user input + assistant response).
|
|
4451
4947
|
const turnNewModelMessages = [];
|
|
4452
4948
|
const turnNewUIMessages = [];
|
|
4949
|
+
locals.set(chatTurnNewUIMessagesKey, turnNewUIMessages);
|
|
4950
|
+
// A head-start handover deliberately resumes from an assistant
|
|
4951
|
+
// message it spliced in, so it isn't a no-op turn.
|
|
4952
|
+
let splicedHandoverPartial = false;
|
|
4453
4953
|
// ── Action handling ──────────────────────────────────────
|
|
4454
4954
|
// Actions arrive on the same input stream but with
|
|
4455
4955
|
// trigger === "action". They are NOT turns — only
|
|
@@ -4460,16 +4960,25 @@ function chatAgent(options) {
|
|
|
4460
4960
|
// an action, return a `StreamTextResult` (auto-piped),
|
|
4461
4961
|
// string, or UIMessage from `onAction`. Turn counter
|
|
4462
4962
|
// does not advance.
|
|
4463
|
-
let
|
|
4963
|
+
let actionResult = undefined;
|
|
4964
|
+
/** Set when `onAction` returned `chat.turn()`: the turn block below runs. */
|
|
4965
|
+
let actionTurn = false;
|
|
4966
|
+
/**
|
|
4967
|
+
* Whether this action changed the conversation, by rolling history
|
|
4968
|
+
* back or by streaming a response. Drives the single snapshot write
|
|
4969
|
+
* at the end — an action never reaches the turn-complete path that
|
|
4970
|
+
* normally does it.
|
|
4971
|
+
*/
|
|
4972
|
+
let actionChangedHistory = false;
|
|
4464
4973
|
if (isAction) {
|
|
4465
4974
|
// Parse and validate the action payload
|
|
4466
4975
|
const parsedAction = parseAction
|
|
4467
4976
|
? await parseAction(currentWirePayload.action)
|
|
4468
4977
|
: currentWirePayload.action;
|
|
4469
4978
|
// Hydrate messages from backend if configured
|
|
4470
|
-
if (
|
|
4979
|
+
if (loadContextHook) {
|
|
4471
4980
|
const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
|
|
4472
|
-
return
|
|
4981
|
+
return loadContextHook({
|
|
4473
4982
|
chatId: currentWirePayload.chatId,
|
|
4474
4983
|
turn,
|
|
4475
4984
|
trigger: "action",
|
|
@@ -4489,12 +4998,14 @@ function chatAgent(options) {
|
|
|
4489
4998
|
});
|
|
4490
4999
|
accumulatedUIMessages = [...hydrated];
|
|
4491
5000
|
accumulatedMessages = await toModelMessages(hydrated);
|
|
5001
|
+
laneCompacted = false;
|
|
5002
|
+
laneInjections = [];
|
|
4492
5003
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
4493
5004
|
}
|
|
4494
5005
|
// Fire onAction — handler may mutate state via
|
|
4495
5006
|
// `chat.history.*` and / or return a model response.
|
|
4496
5007
|
if (onAction) {
|
|
4497
|
-
|
|
5008
|
+
actionResult = await tracer.startActiveSpan("onAction()", async () => {
|
|
4498
5009
|
return await onAction({
|
|
4499
5010
|
action: parsedAction,
|
|
4500
5011
|
chatId: currentWirePayload.chatId,
|
|
@@ -4519,7 +5030,10 @@ function chatAgent(options) {
|
|
|
4519
5030
|
locals.set(chatOverrideMessagesKey, undefined);
|
|
4520
5031
|
accumulatedUIMessages = [...actionOverride];
|
|
4521
5032
|
accumulatedMessages = await toModelMessages(actionOverride);
|
|
5033
|
+
laneCompacted = false;
|
|
5034
|
+
laneInjections = [];
|
|
4522
5035
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5036
|
+
actionChangedHistory = true;
|
|
4523
5037
|
}
|
|
4524
5038
|
}
|
|
4525
5039
|
else {
|
|
@@ -4540,7 +5054,7 @@ function chatAgent(options) {
|
|
|
4540
5054
|
// route handler's first-turn history to the hydrate hook as
|
|
4541
5055
|
// incoming messages instead (gated on the pending handover).
|
|
4542
5056
|
if (turn === 0 &&
|
|
4543
|
-
|
|
5057
|
+
loadContextHook &&
|
|
4544
5058
|
cleanedUIMessages.length === 0 &&
|
|
4545
5059
|
(locals.get(chatHandoverPartialKey)?.length ?? 0) > 0 &&
|
|
4546
5060
|
Array.isArray(payload.headStartMessages) &&
|
|
@@ -4570,7 +5084,7 @@ function chatAgent(options) {
|
|
|
4570
5084
|
},
|
|
4571
5085
|
}));
|
|
4572
5086
|
}
|
|
4573
|
-
if (
|
|
5087
|
+
if (loadContextHook) {
|
|
4574
5088
|
// Snapshot the ids the accumulator knew BEFORE this
|
|
4575
5089
|
// turn ran — used below to decide whether an
|
|
4576
5090
|
// incoming wire message is genuinely new or just a
|
|
@@ -4588,7 +5102,7 @@ function chatAgent(options) {
|
|
|
4588
5102
|
// was always true for `submit-message` is now true for every
|
|
4589
5103
|
// trigger.
|
|
4590
5104
|
const hydrated = await tracer.startActiveSpan("hydrateMessages()", async () => {
|
|
4591
|
-
return
|
|
5105
|
+
return loadContextHook({
|
|
4592
5106
|
chatId: currentWirePayload.chatId,
|
|
4593
5107
|
turn,
|
|
4594
5108
|
trigger: currentWirePayload.trigger,
|
|
@@ -4627,6 +5141,8 @@ function chatAgent(options) {
|
|
|
4627
5141
|
}
|
|
4628
5142
|
accumulatedUIMessages = merged;
|
|
4629
5143
|
accumulatedMessages = await toModelMessages(merged);
|
|
5144
|
+
laneCompacted = false;
|
|
5145
|
+
laneInjections = [];
|
|
4630
5146
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
4631
5147
|
// Track new messages for onTurnComplete.newUIMessages.
|
|
4632
5148
|
// Only push for genuinely new ids — HITL continuations
|
|
@@ -4672,6 +5188,8 @@ function chatAgent(options) {
|
|
|
4672
5188
|
accumulatedUIMessages.pop();
|
|
4673
5189
|
}
|
|
4674
5190
|
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
|
|
5191
|
+
laneCompacted = false;
|
|
5192
|
+
laneInjections = [];
|
|
4675
5193
|
}
|
|
4676
5194
|
else if (cleanedUIMessages.length > 0) {
|
|
4677
5195
|
// Submit-message (and the special-cased
|
|
@@ -4688,6 +5206,7 @@ function chatAgent(options) {
|
|
|
4688
5206
|
// where AI SDK regenerates the id (TRI-9137) still
|
|
4689
5207
|
// applies via `rewriteIncomingIdViaToolCallMap`.
|
|
4690
5208
|
let replaced = false;
|
|
5209
|
+
const replacedPairs = [];
|
|
4691
5210
|
for (const raw of cleanedUIMessages) {
|
|
4692
5211
|
let incoming = raw;
|
|
4693
5212
|
let idx = accumulatedUIMessages.findIndex((m) => m.id === incoming.id);
|
|
@@ -4699,7 +5218,9 @@ function chatAgent(options) {
|
|
|
4699
5218
|
}
|
|
4700
5219
|
}
|
|
4701
5220
|
if (idx !== -1) {
|
|
4702
|
-
|
|
5221
|
+
const previous = accumulatedUIMessages[idx];
|
|
5222
|
+
accumulatedUIMessages[idx] = mergeIncomingIntoHydrated(previous, incoming);
|
|
5223
|
+
replacedPairs.push({ previous, merged: accumulatedUIMessages[idx] });
|
|
4703
5224
|
replaced = true;
|
|
4704
5225
|
}
|
|
4705
5226
|
else {
|
|
@@ -4709,9 +5230,19 @@ function chatAgent(options) {
|
|
|
4709
5230
|
recordToolCallIdsFromMessage(incoming);
|
|
4710
5231
|
}
|
|
4711
5232
|
if (replaced) {
|
|
4712
|
-
|
|
4713
|
-
|
|
4714
|
-
|
|
5233
|
+
let inPlace = true;
|
|
5234
|
+
for (const { previous, merged } of replacedPairs) {
|
|
5235
|
+
if (!(await replaceModelRun(accumulatedMessages, previous, merged, 0))) {
|
|
5236
|
+
inPlace = false;
|
|
5237
|
+
break;
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
5240
|
+
if (!inPlace) {
|
|
5241
|
+
logger.warn("chat.agent: replaced message not found at the model lane tail; reconverting the lane");
|
|
5242
|
+
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
|
|
5243
|
+
laneCompacted = false;
|
|
5244
|
+
laneInjections = [];
|
|
5245
|
+
}
|
|
4715
5246
|
}
|
|
4716
5247
|
else {
|
|
4717
5248
|
const incomingModelMessages = await toModelMessages(cleanedUIMessages);
|
|
@@ -4754,10 +5285,34 @@ function chatAgent(options) {
|
|
|
4754
5285
|
messageId: locals.get(chatHandoverMessageIdKey),
|
|
4755
5286
|
});
|
|
4756
5287
|
locals.set(chatHandoverPartialKey, []); // consume once
|
|
5288
|
+
splicedHandoverPartial = true;
|
|
4757
5289
|
}
|
|
4758
5290
|
}
|
|
4759
5291
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
4760
5292
|
} // end if (trigger !== "action")
|
|
5293
|
+
// ── No-op turn ──────────────────────────────────────────
|
|
5294
|
+
//
|
|
5295
|
+
// A submit that added no new user message and leaves the model
|
|
5296
|
+
// chain ending on an assistant message has nothing to answer —
|
|
5297
|
+
// calling the model would prefill its own last reply. Keyed on
|
|
5298
|
+
// the model tail, so a `tool`-terminated chain (a merged tool
|
|
5299
|
+
// approval) still runs.
|
|
5300
|
+
const isNoOpTurn = !isAction &&
|
|
5301
|
+
!splicedHandoverPartial &&
|
|
5302
|
+
currentWirePayload.trigger === "submit-message" &&
|
|
5303
|
+
turnNewUIMessages.length === 0 &&
|
|
5304
|
+
accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant";
|
|
5305
|
+
if (isNoOpTurn) {
|
|
5306
|
+
msgSub?.off();
|
|
5307
|
+
logger.warn("chat.agent: turn added no new user message; skipping the model", {
|
|
5308
|
+
chatId: currentWirePayload.chatId,
|
|
5309
|
+
messageId: currentWirePayload.messageId,
|
|
5310
|
+
});
|
|
5311
|
+
settleRecoveredTurn(currentWirePayload);
|
|
5312
|
+
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
5313
|
+
// Not a turn — don't consume an iteration.
|
|
5314
|
+
turn--;
|
|
5315
|
+
}
|
|
4761
5316
|
// ── Action result handling ──────────────────────────────
|
|
4762
5317
|
// For action turns, skip the turn machinery entirely.
|
|
4763
5318
|
// If `onAction` returned a stream / string / UIMessage,
|
|
@@ -4767,34 +5322,34 @@ function chatAgent(options) {
|
|
|
4767
5322
|
// The turn counter is decremented so the next iteration
|
|
4768
5323
|
// sees the same `turn` value — actions don't count.
|
|
4769
5324
|
if (isAction) {
|
|
4770
|
-
|
|
4771
|
-
|
|
4772
|
-
|
|
4773
|
-
|
|
4774
|
-
|
|
4775
|
-
|
|
4776
|
-
|
|
4777
|
-
generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
|
|
4778
|
-
});
|
|
4779
|
-
await pipeChat(uiStream, {
|
|
4780
|
-
signal: combinedSignal,
|
|
4781
|
-
spanName: "stream response",
|
|
4782
|
-
});
|
|
5325
|
+
if (isActionTurn(actionResult)) {
|
|
5326
|
+
// Persist the edit before the turn starts, so a turn that is
|
|
5327
|
+
// cancelled or runs out of memory continues from the edited
|
|
5328
|
+
// history rather than from the snapshot the edit replaced.
|
|
5329
|
+
// The turn then does its own hooks, completion and snapshot.
|
|
5330
|
+
if (actionChangedHistory) {
|
|
5331
|
+
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
4783
5332
|
}
|
|
4784
|
-
|
|
4785
|
-
|
|
4786
|
-
|
|
4787
|
-
|
|
4788
|
-
|
|
4789
|
-
|
|
4790
|
-
|
|
5333
|
+
actionTurn = true;
|
|
5334
|
+
}
|
|
5335
|
+
else if (actionResult !== undefined) {
|
|
5336
|
+
throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
|
|
5337
|
+
"after the edit, return chat.turn() and a turn runs on the edited history. " +
|
|
5338
|
+
"Returning a StreamTextResult, string or UIMessage is no longer supported.");
|
|
5339
|
+
}
|
|
5340
|
+
else {
|
|
5341
|
+
msgSub?.off();
|
|
5342
|
+
if (actionChangedHistory) {
|
|
5343
|
+
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
4791
5344
|
}
|
|
5345
|
+
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
5346
|
+
// Don't consume a turn iteration — actions aren't turns.
|
|
5347
|
+
turn--;
|
|
4792
5348
|
}
|
|
4793
|
-
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
4794
|
-
// Don't consume a turn iteration — actions aren't turns.
|
|
4795
|
-
turn--;
|
|
4796
5349
|
}
|
|
4797
|
-
|
|
5350
|
+
// A no-op turn skips this block, and with it `followSessionPin`:
|
|
5351
|
+
// there is nothing to answer, so nothing to hand over.
|
|
5352
|
+
if ((!isAction || actionTurn) && !isNoOpTurn) {
|
|
4798
5353
|
// Mint a scoped public access token once per turn, reused for
|
|
4799
5354
|
// onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
|
|
4800
5355
|
const currentRunId = ctx.run.id;
|
|
@@ -4883,6 +5438,8 @@ function chatAgent(options) {
|
|
|
4883
5438
|
locals.set(chatOverrideMessagesKey, undefined);
|
|
4884
5439
|
accumulatedUIMessages = [...turnStartOverride];
|
|
4885
5440
|
accumulatedMessages = await toModelMessages(turnStartOverride);
|
|
5441
|
+
laneCompacted = false;
|
|
5442
|
+
laneInjections = [];
|
|
4886
5443
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
4887
5444
|
}
|
|
4888
5445
|
}, {
|
|
@@ -4899,9 +5456,11 @@ function chatAgent(options) {
|
|
|
4899
5456
|
},
|
|
4900
5457
|
});
|
|
4901
5458
|
}
|
|
5459
|
+
await followSessionPin(currentWirePayload.chatId, versionSkew);
|
|
4902
5460
|
// chat.requestUpgrade() called in onTurnStart (or onValidateMessages) —
|
|
4903
|
-
// skip run() and
|
|
4904
|
-
//
|
|
5461
|
+
// skip run() and hand over to a fresh run on the new version. The
|
|
5462
|
+
// successor picks the message up off session.in; the transport only
|
|
5463
|
+
// keeps reading.
|
|
4905
5464
|
if (locals.get(chatUpgradeRequestedKey)) {
|
|
4906
5465
|
await writeUpgradeRequiredChunk();
|
|
4907
5466
|
return "exit";
|
|
@@ -4941,7 +5500,12 @@ function chatAgent(options) {
|
|
|
4941
5500
|
const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1];
|
|
4942
5501
|
const bgQueue = locals.get(chatBackgroundQueueKey);
|
|
4943
5502
|
if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") {
|
|
4944
|
-
|
|
5503
|
+
const injected = bgQueue.splice(0);
|
|
5504
|
+
accumulatedMessages.push(...injected);
|
|
5505
|
+
laneInjections.push({
|
|
5506
|
+
afterId: accumulatedUIMessages.at(-1)?.id ?? "",
|
|
5507
|
+
messages: injected,
|
|
5508
|
+
});
|
|
4945
5509
|
}
|
|
4946
5510
|
if (isHeadStartFinalTurn) {
|
|
4947
5511
|
// The synthesized partial UIMessage IS the response.
|
|
@@ -4960,6 +5524,9 @@ function chatAgent(options) {
|
|
|
4960
5524
|
const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
|
|
4961
5525
|
runResult = await userRun({
|
|
4962
5526
|
...restWire,
|
|
5527
|
+
// A turn requested by chat.turn() is not the action itself:
|
|
5528
|
+
// a run() that short-circuits on "action" must still answer.
|
|
5529
|
+
...(actionTurn ? { trigger: "action-turn" } : {}),
|
|
4963
5530
|
messages: preparedMessages,
|
|
4964
5531
|
clientData,
|
|
4965
5532
|
continuation,
|
|
@@ -4972,6 +5539,7 @@ function chatAgent(options) {
|
|
|
4972
5539
|
signal: combinedSignal,
|
|
4973
5540
|
cancelSignal,
|
|
4974
5541
|
stopSignal,
|
|
5542
|
+
streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
|
|
4975
5543
|
});
|
|
4976
5544
|
}
|
|
4977
5545
|
// Auto-pipe if the run function returned a StreamTextResult or similar,
|
|
@@ -5085,7 +5653,21 @@ function chatAgent(options) {
|
|
|
5085
5653
|
if (runOverride) {
|
|
5086
5654
|
locals.set(chatOverrideMessagesKey, undefined);
|
|
5087
5655
|
accumulatedUIMessages = [...runOverride];
|
|
5088
|
-
|
|
5656
|
+
/**
|
|
5657
|
+
* Steers the drain consumed are left out of the rebuild and
|
|
5658
|
+
* appended by the reconciliation below instead, so the lane
|
|
5659
|
+
* gets the form the model actually received rather than a
|
|
5660
|
+
* reconversion of the UI message, and gets it once. A steer
|
|
5661
|
+
* the edit removed is dropped from the pending list too, so
|
|
5662
|
+
* the edit is honoured.
|
|
5663
|
+
*/
|
|
5664
|
+
const overrideIds = new Set(runOverride.map((m) => m.id));
|
|
5665
|
+
const pending = (locals.get(chatPendingSteerKey) ?? []).filter((e) => overrideIds.has(e.ui.id));
|
|
5666
|
+
locals.set(chatPendingSteerKey, pending);
|
|
5667
|
+
const pendingIds = new Set(pending.map((e) => e.ui.id));
|
|
5668
|
+
accumulatedMessages = await toModelMessages(runOverride.filter((m) => !pendingIds.has(m.id)));
|
|
5669
|
+
laneCompacted = false;
|
|
5670
|
+
laneInjections = [];
|
|
5089
5671
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5090
5672
|
}
|
|
5091
5673
|
// Check if compaction set a model-only override (preserves UI messages).
|
|
@@ -5108,6 +5690,8 @@ function chatAgent(options) {
|
|
|
5108
5690
|
accumulatedMessages = taskCompactionConfig?.compactModelMessages
|
|
5109
5691
|
? await taskCompactionConfig.compactModelMessages(compactEvent)
|
|
5110
5692
|
: modelOnlyOverride;
|
|
5693
|
+
laneCompacted = true;
|
|
5694
|
+
laneInjections = [];
|
|
5111
5695
|
// Apply UI messages: callback or default (preserve all)
|
|
5112
5696
|
if (taskCompactionConfig?.compactUIMessages) {
|
|
5113
5697
|
accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages(compactEvent));
|
|
@@ -5115,10 +5699,20 @@ function chatAgent(options) {
|
|
|
5115
5699
|
}
|
|
5116
5700
|
// Determine if the user stopped generation this turn (not a full run cancel).
|
|
5117
5701
|
const wasStopped = stopController.signal.aborted && !runSignal.aborted;
|
|
5702
|
+
// Give the model accumulator the steering messages the drain
|
|
5703
|
+
// consumed. Appended, never reconverted from the UI lane, so a
|
|
5704
|
+
// model-only compaction summary set just above survives; and done
|
|
5705
|
+
// before the response is appended so the order stays
|
|
5706
|
+
// steer-then-answer. Outside the `capturedResponseMessage`
|
|
5707
|
+
// branches below, so a turn that captured no response is covered.
|
|
5708
|
+
const steerTailThisTurn = reconcilePendingSteer({
|
|
5709
|
+
turnNew: turnNewModelMessages,
|
|
5710
|
+
}).reduce((n, e) => n + e.model.length, 0) + reconcilePendingBackground();
|
|
5118
5711
|
// Append the assistant's response (partial or complete) to the accumulator.
|
|
5119
5712
|
// The onFinish callback fires even on abort/stop, so partial responses
|
|
5120
5713
|
// from stopped generation are captured correctly.
|
|
5121
5714
|
let rawResponseMessage;
|
|
5715
|
+
let responseWasSkipped = false;
|
|
5122
5716
|
if (capturedResponseMessage) {
|
|
5123
5717
|
// Keep the raw message before cleanup for users who want custom handling
|
|
5124
5718
|
rawResponseMessage = capturedResponseMessage;
|
|
@@ -5144,43 +5738,56 @@ function chatAgent(options) {
|
|
|
5144
5738
|
};
|
|
5145
5739
|
locals.set(chatResponsePartsKey, []);
|
|
5146
5740
|
}
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
|
|
5155
|
-
|
|
5156
|
-
|
|
5157
|
-
|
|
5158
|
-
else {
|
|
5159
|
-
accumulatedUIMessages.push(capturedResponseMessage);
|
|
5160
|
-
}
|
|
5161
|
-
turnNewUIMessages.push(capturedResponseMessage);
|
|
5162
|
-
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5163
|
-
// Record toolCallId → head messageId so a HITL
|
|
5164
|
-
// continuation next turn can recover the head id
|
|
5165
|
-
// even if the AI SDK regenerates it. See
|
|
5166
|
-
// `chatToolCallToMessageIdKey` for the full
|
|
5167
|
-
// rationale (TRI-9137).
|
|
5168
|
-
recordToolCallIdsFromMessage(capturedResponseMessage);
|
|
5169
|
-
try {
|
|
5170
|
-
const responseModelMessages = await toModelMessages([
|
|
5171
|
-
stripProviderMetadata(capturedResponseMessage),
|
|
5172
|
-
]);
|
|
5741
|
+
const responseHasContent = capturedResponseMessage.parts.some((part) => part.type !== "step-start");
|
|
5742
|
+
if (responseHasContent) {
|
|
5743
|
+
// Tool-approval continuations: the AI SDK reuses the trailing
|
|
5744
|
+
// assistant's ID (via originalMessages) so the captured response
|
|
5745
|
+
// carries the same ID as an existing message. Replace in place
|
|
5746
|
+
// instead of pushing a duplicate. For action turns this never
|
|
5747
|
+
// matches because originalMessages is omitted (fresh ID).
|
|
5748
|
+
const existingIdx = capturedResponseMessage.id
|
|
5749
|
+
? accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage.id)
|
|
5750
|
+
: -1;
|
|
5751
|
+
const previousAtIdx = existingIdx !== -1 ? accumulatedUIMessages[existingIdx] : undefined;
|
|
5173
5752
|
if (existingIdx !== -1) {
|
|
5174
|
-
|
|
5175
|
-
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
|
|
5753
|
+
accumulatedUIMessages[existingIdx] = capturedResponseMessage;
|
|
5176
5754
|
}
|
|
5177
5755
|
else {
|
|
5178
|
-
|
|
5756
|
+
accumulatedUIMessages.push(capturedResponseMessage);
|
|
5757
|
+
}
|
|
5758
|
+
turnNewUIMessages.push(capturedResponseMessage);
|
|
5759
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5760
|
+
// Record toolCallId → head messageId so a HITL
|
|
5761
|
+
// continuation next turn can recover the head id
|
|
5762
|
+
// even if the AI SDK regenerates it. See
|
|
5763
|
+
// `chatToolCallToMessageIdKey` for the full
|
|
5764
|
+
// rationale (TRI-9137).
|
|
5765
|
+
recordToolCallIdsFromMessage(capturedResponseMessage);
|
|
5766
|
+
try {
|
|
5767
|
+
const responseModelMessages = await toModelMessages([
|
|
5768
|
+
stripProviderMetadata(capturedResponseMessage),
|
|
5769
|
+
]);
|
|
5770
|
+
if (existingIdx !== -1) {
|
|
5771
|
+
const ok = previousAtIdx !== undefined &&
|
|
5772
|
+
(await replaceModelRun(accumulatedMessages, previousAtIdx, capturedResponseMessage, steerTailThisTurn));
|
|
5773
|
+
if (!ok) {
|
|
5774
|
+
logger.warn("chat.agent: replaced response not found at the model lane tail; reconverting the lane");
|
|
5775
|
+
accumulatedMessages = await toModelMessages(accumulatedUIMessages);
|
|
5776
|
+
laneCompacted = false;
|
|
5777
|
+
laneInjections = [];
|
|
5778
|
+
}
|
|
5779
|
+
}
|
|
5780
|
+
else {
|
|
5781
|
+
accumulatedMessages.push(...responseModelMessages);
|
|
5782
|
+
}
|
|
5783
|
+
turnNewModelMessages.push(...responseModelMessages);
|
|
5784
|
+
}
|
|
5785
|
+
catch {
|
|
5786
|
+
// Conversion failed — skip accumulation for this turn
|
|
5179
5787
|
}
|
|
5180
|
-
turnNewModelMessages.push(...responseModelMessages);
|
|
5181
5788
|
}
|
|
5182
|
-
|
|
5183
|
-
|
|
5789
|
+
else {
|
|
5790
|
+
responseWasSkipped = true;
|
|
5184
5791
|
}
|
|
5185
5792
|
}
|
|
5186
5793
|
// If there's no captured response (manual pipe mode) but there are
|
|
@@ -5280,6 +5887,8 @@ function chatAgent(options) {
|
|
|
5280
5887
|
],
|
|
5281
5888
|
},
|
|
5282
5889
|
];
|
|
5890
|
+
laneCompacted = true;
|
|
5891
|
+
laneInjections = [];
|
|
5283
5892
|
// UI messages: callback or default (preserve all)
|
|
5284
5893
|
if (outerCompaction.compactUIMessages) {
|
|
5285
5894
|
accumulatedUIMessages = (await outerCompaction.compactUIMessages(outerCompactEvent));
|
|
@@ -5369,6 +5978,8 @@ function chatAgent(options) {
|
|
|
5369
5978
|
locals.set(chatOverrideMessagesKey, undefined);
|
|
5370
5979
|
accumulatedUIMessages = [...override];
|
|
5371
5980
|
accumulatedMessages = await toModelMessages(override);
|
|
5981
|
+
laneCompacted = false;
|
|
5982
|
+
laneInjections = [];
|
|
5372
5983
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5373
5984
|
// Update event so onTurnComplete sees compacted messages
|
|
5374
5985
|
turnCompleteEvent.messages = accumulatedMessages;
|
|
@@ -5397,9 +6008,23 @@ function chatAgent(options) {
|
|
|
5397
6008
|
capturedPartialResponse = capturedResponseMessage;
|
|
5398
6009
|
turnCompleteEvent.responseMessage = capturedResponseMessage;
|
|
5399
6010
|
turnCompleteEvent.uiMessages = accumulatedUIMessages;
|
|
6011
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6012
|
+
}
|
|
6013
|
+
else if (responseWasSkipped) {
|
|
6014
|
+
capturedResponseMessage = {
|
|
6015
|
+
...capturedResponseMessage,
|
|
6016
|
+
parts: [...(capturedResponseMessage.parts ?? []), ...lateParts],
|
|
6017
|
+
};
|
|
6018
|
+
accumulatedUIMessages.push(capturedResponseMessage);
|
|
6019
|
+
turnNewUIMessages.push(capturedResponseMessage);
|
|
6020
|
+
capturedPartialResponse = capturedResponseMessage;
|
|
6021
|
+
turnCompleteEvent.responseMessage = capturedResponseMessage;
|
|
6022
|
+
turnCompleteEvent.uiMessages = accumulatedUIMessages;
|
|
6023
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5400
6024
|
}
|
|
5401
6025
|
locals.set(chatResponsePartsKey, []);
|
|
5402
6026
|
}
|
|
6027
|
+
settleRecoveredTurn(currentWirePayload);
|
|
5403
6028
|
// Write turn-complete control chunk — closes the frontend stream.
|
|
5404
6029
|
const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
|
|
5405
6030
|
// Fire onTurnComplete — stream is closed, use for persistence.
|
|
@@ -5415,6 +6040,8 @@ function chatAgent(options) {
|
|
|
5415
6040
|
locals.set(chatOverrideMessagesKey, undefined);
|
|
5416
6041
|
accumulatedUIMessages = [...turnCompleteOverride];
|
|
5417
6042
|
accumulatedMessages = await toModelMessages(turnCompleteOverride);
|
|
6043
|
+
laneCompacted = false;
|
|
6044
|
+
laneInjections = [];
|
|
5418
6045
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5419
6046
|
}
|
|
5420
6047
|
}, {
|
|
@@ -5468,13 +6095,18 @@ function chatAgent(options) {
|
|
|
5468
6095
|
if (!hydrateMessages) {
|
|
5469
6096
|
try {
|
|
5470
6097
|
await tracer.startActiveSpan("snapshot.write", async () => {
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
6098
|
+
lastSnapshotOutEventId =
|
|
6099
|
+
turnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
|
|
6100
|
+
await saveTranscript({
|
|
6101
|
+
reason: "turn-complete",
|
|
5475
6102
|
messages: accumulatedUIMessages,
|
|
5476
|
-
|
|
5477
|
-
|
|
6103
|
+
turn,
|
|
6104
|
+
trigger: storageTrigger(currentWirePayload.trigger),
|
|
6105
|
+
clientData,
|
|
6106
|
+
lastOutEventId: lastSnapshotOutEventId,
|
|
6107
|
+
nonFinalIds: wasStopped && capturedResponseMessage?.id
|
|
6108
|
+
? new Set([capturedResponseMessage.id])
|
|
6109
|
+
: undefined,
|
|
5478
6110
|
});
|
|
5479
6111
|
}, {
|
|
5480
6112
|
attributes: {
|
|
@@ -5504,13 +6136,20 @@ function chatAgent(options) {
|
|
|
5504
6136
|
// produced these from in-flight user messages on session.in
|
|
5505
6137
|
// that the dead predecessor never acknowledged.
|
|
5506
6138
|
if (bootInjectedQueue.length > 0) {
|
|
5507
|
-
currentWirePayload =
|
|
6139
|
+
currentWirePayload = dispatchBootInjected();
|
|
5508
6140
|
return "continue";
|
|
5509
6141
|
}
|
|
5510
|
-
// chat.requestUpgrade() was called — exit the loop
|
|
5511
|
-
//
|
|
6142
|
+
// chat.requestUpgrade() was called — exit the loop; the handover
|
|
6143
|
+
// has already triggered a new run on the latest version.
|
|
5512
6144
|
// chat.endRun() — same exit, no upgrade semantics.
|
|
6145
|
+
if (locals.get(chatCloseRequestedKey)) {
|
|
6146
|
+
await performChatClose();
|
|
6147
|
+
return "exit";
|
|
6148
|
+
}
|
|
5513
6149
|
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
|
|
6150
|
+
if (locals.get(chatUpgradeRequestedKey)) {
|
|
6151
|
+
await persistUpgradeHandoff();
|
|
6152
|
+
}
|
|
5514
6153
|
return "exit";
|
|
5515
6154
|
}
|
|
5516
6155
|
// Wait for the next message — stay idle briefly, then suspend
|
|
@@ -5612,11 +6251,17 @@ function chatAgent(options) {
|
|
|
5612
6251
|
let errorTurnCompleteResult;
|
|
5613
6252
|
try {
|
|
5614
6253
|
await withChatWriter(async (writer) => {
|
|
5615
|
-
const errorText = turnError instanceof Error
|
|
6254
|
+
const errorText = turnError instanceof Error && turnError.message
|
|
6255
|
+
? turnError.message
|
|
6256
|
+
: "An unexpected error occurred";
|
|
5616
6257
|
writer.write({ type: "error", errorText });
|
|
5617
6258
|
});
|
|
5618
6259
|
// Signal turn complete so the client knows this turn is done
|
|
5619
6260
|
errorTurnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
6261
|
+
// A later action's snapshot reuses this cursor, so it has to move
|
|
6262
|
+
// here too or that snapshot resumes from before the failed turn.
|
|
6263
|
+
lastSnapshotOutEventId =
|
|
6264
|
+
errorTurnCompleteResult?.lastEventId ?? lastSnapshotOutEventId;
|
|
5620
6265
|
}
|
|
5621
6266
|
catch {
|
|
5622
6267
|
// Best-effort — if stream write fails, let the run continue anyway
|
|
@@ -5661,15 +6306,47 @@ function chatAgent(options) {
|
|
|
5661
6306
|
: partialIdx === -1
|
|
5662
6307
|
? [...erroredUIMessages, partialResponse]
|
|
5663
6308
|
: erroredUIMessages.map((m, i) => i === partialIdx ? partialResponse : m);
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
6309
|
+
/**
|
|
6310
|
+
* Seeded from the per-turn list, not just the wire message and the
|
|
6311
|
+
* partial, so a steering message the drain consumed is reported too.
|
|
6312
|
+
* An app persisting from `newUIMessages` would otherwise lose the
|
|
6313
|
+
* instruction whenever the turn it steered went on to fail.
|
|
6314
|
+
*/
|
|
6315
|
+
const buildErroredNew = () => {
|
|
6316
|
+
const out = [];
|
|
6317
|
+
const addUnique = (m) => {
|
|
6318
|
+
if (m && !out.some((existing) => existing.id === m.id))
|
|
6319
|
+
out.push(m);
|
|
6320
|
+
};
|
|
6321
|
+
addUnique(erroredWireMessage);
|
|
6322
|
+
for (const m of (locals.get(chatTurnNewUIMessagesKey) ?? [])) {
|
|
6323
|
+
addUnique(m);
|
|
6324
|
+
}
|
|
6325
|
+
if (includePartial)
|
|
6326
|
+
addUnique(partialResponse);
|
|
6327
|
+
return out;
|
|
6328
|
+
};
|
|
6329
|
+
let erroredNewUIMessages = buildErroredNew();
|
|
5668
6330
|
let erroredNewModelMessages = [];
|
|
6331
|
+
const reconciledSteer = reconcilePendingSteer();
|
|
6332
|
+
const backgroundTailThisTurn = reconcilePendingBackground();
|
|
5669
6333
|
if (!responseCommitted) {
|
|
5670
6334
|
try {
|
|
5671
6335
|
if (erroredNewUIMessages.length > 0) {
|
|
5672
|
-
|
|
6336
|
+
/**
|
|
6337
|
+
* Built in order from the recorded forms rather than by
|
|
6338
|
+
* converting the UI list, so a steer appears in the delta as
|
|
6339
|
+
* the model received it (what `prepare` produced), matching the
|
|
6340
|
+
* lane. The wire message and partial are converted as before.
|
|
6341
|
+
*/
|
|
6342
|
+
const steerModelById = new Map(reconciledSteer.map((e) => [e.ui.id, e.model]));
|
|
6343
|
+
for (const m of erroredNewUIMessages) {
|
|
6344
|
+
const recorded = steerModelById.get(m.id);
|
|
6345
|
+
if (recorded)
|
|
6346
|
+
erroredNewModelMessages.push(...recorded);
|
|
6347
|
+
else
|
|
6348
|
+
erroredNewModelMessages.push(...(await toModelMessages([stripProviderMetadata(m)])));
|
|
6349
|
+
}
|
|
5673
6350
|
}
|
|
5674
6351
|
if (erroredUIMessagesWithPartial !== accumulatedUIMessages) {
|
|
5675
6352
|
if (partialIdx === -1) {
|
|
@@ -5677,7 +6354,14 @@ function chatAgent(options) {
|
|
|
5677
6354
|
accumulatedMessages.push(...(await toModelMessages(appended.map((m) => stripProviderMetadata(m)))));
|
|
5678
6355
|
}
|
|
5679
6356
|
else {
|
|
5680
|
-
|
|
6357
|
+
const ok = await replaceModelRun(accumulatedMessages, erroredUIMessages[partialIdx], partialResponse, reconciledSteer.reduce((n, e) => n + e.model.length, 0) +
|
|
6358
|
+
backgroundTailThisTurn);
|
|
6359
|
+
if (!ok) {
|
|
6360
|
+
logger.warn("chat.agent: replaced partial not found at the model lane tail; reconverting the lane");
|
|
6361
|
+
accumulatedMessages = await toModelMessages(erroredUIMessagesWithPartial);
|
|
6362
|
+
laneCompacted = false;
|
|
6363
|
+
laneInjections = [];
|
|
6364
|
+
}
|
|
5681
6365
|
}
|
|
5682
6366
|
accumulatedUIMessages = erroredUIMessagesWithPartial;
|
|
5683
6367
|
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
@@ -5686,7 +6370,7 @@ function chatAgent(options) {
|
|
|
5686
6370
|
catch {
|
|
5687
6371
|
erroredNewModelMessages = [];
|
|
5688
6372
|
erroredUIMessagesWithPartial = erroredUIMessages;
|
|
5689
|
-
erroredNewUIMessages =
|
|
6373
|
+
erroredNewUIMessages = buildErroredNew().filter((m) => m !== partialResponse);
|
|
5690
6374
|
}
|
|
5691
6375
|
}
|
|
5692
6376
|
if (onTurnComplete) {
|
|
@@ -5740,13 +6424,14 @@ function chatAgent(options) {
|
|
|
5740
6424
|
// neither the snapshot nor the replayable `.in` tail.
|
|
5741
6425
|
if (!hydrateMessages) {
|
|
5742
6426
|
try {
|
|
5743
|
-
|
|
5744
|
-
|
|
5745
|
-
version: 1,
|
|
5746
|
-
savedAt: Date.now(),
|
|
6427
|
+
await saveTranscript({
|
|
6428
|
+
reason: "turn-error",
|
|
5747
6429
|
messages: erroredUIMessagesWithPartial,
|
|
5748
|
-
|
|
5749
|
-
|
|
6430
|
+
turn,
|
|
6431
|
+
trigger: storageTrigger(currentWirePayload.trigger),
|
|
6432
|
+
clientData: turnClientData,
|
|
6433
|
+
lastOutEventId: lastSnapshotOutEventId,
|
|
6434
|
+
nonFinalIds: includePartial && partialResponse ? new Set([partialResponse.id]) : undefined,
|
|
5750
6435
|
});
|
|
5751
6436
|
}
|
|
5752
6437
|
catch (error) {
|
|
@@ -5756,15 +6441,22 @@ function chatAgent(options) {
|
|
|
5756
6441
|
});
|
|
5757
6442
|
}
|
|
5758
6443
|
}
|
|
6444
|
+
if (locals.get(chatCloseRequestedKey)) {
|
|
6445
|
+
await performChatClose();
|
|
6446
|
+
return;
|
|
6447
|
+
}
|
|
5759
6448
|
// chat.requestUpgrade() / chat.endRun() — exit after error turn too
|
|
5760
6449
|
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
|
|
6450
|
+
if (locals.get(chatUpgradeRequestedKey)) {
|
|
6451
|
+
await persistUpgradeHandoff();
|
|
6452
|
+
}
|
|
5761
6453
|
return;
|
|
5762
6454
|
}
|
|
5763
6455
|
// Drain remaining recovered turns before idling — a thrown
|
|
5764
6456
|
// recovered turn shouldn't strand the rest of the boot queue
|
|
5765
6457
|
// until an unrelated live message arrives.
|
|
5766
6458
|
if (bootInjectedQueue.length > 0) {
|
|
5767
|
-
currentWirePayload =
|
|
6459
|
+
currentWirePayload = dispatchBootInjected();
|
|
5768
6460
|
continue;
|
|
5769
6461
|
}
|
|
5770
6462
|
// Wait for the next message — same as after a successful turn
|
|
@@ -5780,6 +6472,12 @@ function chatAgent(options) {
|
|
|
5780
6472
|
return; // Timed out — end run gracefully
|
|
5781
6473
|
}
|
|
5782
6474
|
currentWirePayload = next.output;
|
|
6475
|
+
// Same close check the success path makes. Without it a close
|
|
6476
|
+
// record that lands after a failed turn is consumed as if it were
|
|
6477
|
+
// a turn payload, and the loop runs on against a closed session.
|
|
6478
|
+
if (currentWirePayload.trigger === "close") {
|
|
6479
|
+
return;
|
|
6480
|
+
}
|
|
5783
6481
|
// Continue to next iteration of the for loop
|
|
5784
6482
|
}
|
|
5785
6483
|
finally {
|
|
@@ -5788,6 +6486,11 @@ function chatAgent(options) {
|
|
|
5788
6486
|
}
|
|
5789
6487
|
}
|
|
5790
6488
|
finally {
|
|
6489
|
+
// Safety net for a close requested on a path that exits without
|
|
6490
|
+
// reaching one of the loop's close checks (a turn timeout, an OOM
|
|
6491
|
+
// re-throw). `performChatClose` is idempotent, so the ordinary path
|
|
6492
|
+
// having already run it costs nothing here.
|
|
6493
|
+
await performChatClose();
|
|
5791
6494
|
// `stopSub` is registered post-preload so the close-during-preload
|
|
5792
6495
|
// early-return path may exit before it ever attached. Guard the
|
|
5793
6496
|
// cleanup so a missing subscription doesn't throw.
|
|
@@ -6125,15 +6828,22 @@ function isStopped() {
|
|
|
6125
6828
|
// Version upgrade
|
|
6126
6829
|
// ---------------------------------------------------------------------------
|
|
6127
6830
|
/**
|
|
6128
|
-
*
|
|
6129
|
-
*
|
|
6831
|
+
* Hand the conversation over to another deployment.
|
|
6832
|
+
*
|
|
6833
|
+
* The handover happens immediately and server-side: a successor run is created
|
|
6834
|
+
* and picks the conversation up from `session.in`. The transport keeps reading
|
|
6835
|
+
* the same session output, so no client action is needed and nothing waits for
|
|
6836
|
+
* the next message.
|
|
6837
|
+
*
|
|
6838
|
+
* Without a target the session's pin is cleared, so the successor lands on the
|
|
6839
|
+
* latest deployed version; with `externalDeploymentId` the session is re-pinned
|
|
6840
|
+
* to that deployment.
|
|
6130
6841
|
*
|
|
6131
6842
|
* When called from `onTurnStart` or `onValidateMessages`, `run()` is skipped
|
|
6132
|
-
* entirely
|
|
6133
|
-
* same message on the new version.
|
|
6843
|
+
* entirely and the successor answers the message that opened the turn.
|
|
6134
6844
|
*
|
|
6135
6845
|
* When called from `run()` or `chat.defer()`, the current turn completes
|
|
6136
|
-
* normally and the
|
|
6846
|
+
* normally and the handover happens afterward.
|
|
6137
6847
|
*
|
|
6138
6848
|
* Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`,
|
|
6139
6849
|
* or inside `chat.defer()`.
|
|
@@ -6153,8 +6863,37 @@ function isStopped() {
|
|
|
6153
6863
|
* });
|
|
6154
6864
|
* ```
|
|
6155
6865
|
*/
|
|
6156
|
-
function requestUpgrade() {
|
|
6866
|
+
function requestUpgrade(options) {
|
|
6157
6867
|
locals.set(chatUpgradeRequestedKey, true);
|
|
6868
|
+
// Without a target the handoff clears the session's pin; with one it re-pins to that deployment.
|
|
6869
|
+
const target = options?.externalDeploymentId?.trim();
|
|
6870
|
+
if (target)
|
|
6871
|
+
locals.set(chatUpgradeExternalDeploymentIdKey, target);
|
|
6872
|
+
}
|
|
6873
|
+
/** @internal Requests a handoff when the session's pin no longer names this deployment. */
|
|
6874
|
+
async function followSessionPin(chatId, policy) {
|
|
6875
|
+
if (!chatId) {
|
|
6876
|
+
return;
|
|
6877
|
+
}
|
|
6878
|
+
const deployedExternalId = locals.get(chatAgentRunContextKey)?.deployment?.externalId;
|
|
6879
|
+
if (policy !== "hold" && !deployedExternalId) {
|
|
6880
|
+
logger.debug("chat.versionSkew: cannot follow the session pin", {
|
|
6881
|
+
chatId,
|
|
6882
|
+
reason: "the run context carries no deployment.externalId",
|
|
6883
|
+
});
|
|
6884
|
+
}
|
|
6885
|
+
const target = await resolvePinToFollow({
|
|
6886
|
+
policy,
|
|
6887
|
+
deployedExternalId,
|
|
6888
|
+
upgradeAlreadyRequested: locals.get(chatUpgradeRequestedKey) === true,
|
|
6889
|
+
readPin: async () => (await sessions.retrieve(chatId, { retry: { maxAttempts: 2, randomize: true } }))
|
|
6890
|
+
.triggerConfig,
|
|
6891
|
+
});
|
|
6892
|
+
if (!target) {
|
|
6893
|
+
return;
|
|
6894
|
+
}
|
|
6895
|
+
logger.info("chat.versionSkew: following the session pin", { chatId, target });
|
|
6896
|
+
requestUpgrade({ externalDeploymentId: target });
|
|
6158
6897
|
}
|
|
6159
6898
|
/**
|
|
6160
6899
|
* Hand off the current custom agent Session to a fresh run.
|
|
@@ -6193,20 +6932,31 @@ async function endAndContinue() {
|
|
|
6193
6932
|
if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
|
|
6194
6933
|
throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
|
|
6195
6934
|
}
|
|
6196
|
-
await performEndAndContinue();
|
|
6935
|
+
await performEndAndContinue({ reason: "continuation" });
|
|
6197
6936
|
}
|
|
6198
6937
|
/** @internal Shared server handoff used by managed and custom agent loops. */
|
|
6199
|
-
async function performEndAndContinue() {
|
|
6938
|
+
async function performEndAndContinue(options) {
|
|
6200
6939
|
const chatId = locals.get(chatExternalIdKey);
|
|
6201
6940
|
const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
|
|
6202
6941
|
if (!chatId || !callingRunId) {
|
|
6203
6942
|
throw new Error("Cannot end and continue without an active chat agent run");
|
|
6204
6943
|
}
|
|
6944
|
+
const externalDeploymentId = options.externalDeploymentId;
|
|
6205
6945
|
const apiClient = apiClientManager.clientOrThrow();
|
|
6206
|
-
await apiClient.endAndContinueSession(chatId, {
|
|
6946
|
+
const result = await apiClient.endAndContinueSession(chatId, {
|
|
6207
6947
|
callingRunId,
|
|
6208
|
-
reason:
|
|
6948
|
+
reason: options.reason,
|
|
6949
|
+
...(externalDeploymentId ? { externalDeploymentId } : {}),
|
|
6209
6950
|
});
|
|
6951
|
+
if (result?.pendingVersion !== true) {
|
|
6952
|
+
return;
|
|
6953
|
+
}
|
|
6954
|
+
// The successor parked. Say so on `.out` while this run still can — the transport's
|
|
6955
|
+
// subscription survives the swap, so the client learns without waiting for its next send.
|
|
6956
|
+
const [error] = await tryCatch(getChatSession().out.writeControl(TRIGGER_CONTROL_SUBTYPE.PENDING_VERSION));
|
|
6957
|
+
if (error) {
|
|
6958
|
+
logger.warn("could not signal a parked handoff", { chatId, error });
|
|
6959
|
+
}
|
|
6210
6960
|
}
|
|
6211
6961
|
/**
|
|
6212
6962
|
* Exit the run after the current turn completes, without waiting for the
|
|
@@ -6237,6 +6987,124 @@ async function performEndAndContinue() {
|
|
|
6237
6987
|
function endRun() {
|
|
6238
6988
|
locals.set(chatEndRunRequestedKey, true);
|
|
6239
6989
|
}
|
|
6990
|
+
/**
|
|
6991
|
+
* End the whole conversation, permanently. The session row is closed, further
|
|
6992
|
+
* appends are refused, and the run exits without scheduling a continuation.
|
|
6993
|
+
*
|
|
6994
|
+
* This is the session-level stop. {@link endRun} ends the current run and lets
|
|
6995
|
+
* the next message start a fresh one; `chat.close()` ends the session itself,
|
|
6996
|
+
* so there is no next message. Use it for a budget cap, a completed goal,
|
|
6997
|
+
* abuse detection, or a user signing out.
|
|
6998
|
+
*
|
|
6999
|
+
* In a `chat.agent`, call it from `run()`, `prepareStep`, or
|
|
7000
|
+
* `onBeforeTurnComplete`. Called mid-step, it aborts the in-flight
|
|
7001
|
+
* `streamText` the same way the stop signal does, so the partial response is
|
|
7002
|
+
* still captured and streamed. The turn then completes normally, a terminal
|
|
7003
|
+
* `session-closed` record carrying `reason` is written to the response stream,
|
|
7004
|
+
* and the loop exits.
|
|
7005
|
+
*
|
|
7006
|
+
* Prefer `onBeforeTurnComplete` over `onTurnComplete`. It carries the same
|
|
7007
|
+
* fields but still runs while the stream is open, so the closed state rides
|
|
7008
|
+
* out on the turn's final record. `onTurnComplete` runs after that record, so
|
|
7009
|
+
* a close decided there does not reach a reader that has already finished the
|
|
7010
|
+
* turn, and the user only finds out when their next message is refused.
|
|
7011
|
+
*
|
|
7012
|
+
* In a `chat.customAgent`, call it anywhere in your own loop. The close is
|
|
7013
|
+
* performed when `run()` returns, so it lands whether you break out of a
|
|
7014
|
+
* `chat.createSession` loop, return early, or hand-roll the loop entirely.
|
|
7015
|
+
*
|
|
7016
|
+
* Closing is one-way: a closed session cannot be reopened. Its transcript
|
|
7017
|
+
* stays readable.
|
|
7018
|
+
*
|
|
7019
|
+
* @example
|
|
7020
|
+
* ```ts
|
|
7021
|
+
* chat.agent({
|
|
7022
|
+
* id: "budgeted-agent",
|
|
7023
|
+
* onBeforeTurnComplete: async ({ usage }) => {
|
|
7024
|
+
* if (await overBudget(usage)) {
|
|
7025
|
+
* chat.close({ reason: "Monthly budget reached" });
|
|
7026
|
+
* }
|
|
7027
|
+
* },
|
|
7028
|
+
* });
|
|
7029
|
+
* ```
|
|
7030
|
+
*/
|
|
7031
|
+
function close(options) {
|
|
7032
|
+
if (!locals.get(chatExternalIdKey)) {
|
|
7033
|
+
throw new Error("chat.close() can only be called from inside a chat.agent() or chat.customAgent() run");
|
|
7034
|
+
}
|
|
7035
|
+
// Bound the reason once, here. It goes out on S2 record headers as well as
|
|
7036
|
+
// the close API, and an oversized value would fail the turn-complete write
|
|
7037
|
+
// that carries the turn boundary, costing the client far more than the
|
|
7038
|
+
// reason text.
|
|
7039
|
+
// Trailing high surrogate: the cut landed between the two halves of an
|
|
7040
|
+
// astral character, and encoding the orphan to UTF-8 for a record header
|
|
7041
|
+
// yields a replacement character. Drop it rather than ship mojibake.
|
|
7042
|
+
const reason = options?.reason
|
|
7043
|
+
?.slice(0, CHAT_CLOSE_REASON_MAX_LENGTH)
|
|
7044
|
+
.replace(/[\uD800-\uDBFF]$/, "");
|
|
7045
|
+
locals.set(chatCloseRequestedKey, reason ? { reason } : {});
|
|
7046
|
+
// Mid-step call: unblock the in-flight streamText exactly like the stop
|
|
7047
|
+
// signal, so the turn can reach its turn boundary instead of running the
|
|
7048
|
+
// model out to completion after the decision to close has been made.
|
|
7049
|
+
locals.get(chatStopControllerKey)?.abort(reason ?? "closed");
|
|
7050
|
+
}
|
|
7051
|
+
/**
|
|
7052
|
+
* @internal Terminal close sequence, run once at whichever exit site observes
|
|
7053
|
+
* the close request. Writes the standalone `session-closed` record, then closes
|
|
7054
|
+
* the session row.
|
|
7055
|
+
*
|
|
7056
|
+
* The record lands after the turn's `turn-complete`, so a client reading that
|
|
7057
|
+
* turn's stream has already terminated on it and will not see this one. It is
|
|
7058
|
+
* there for a reconnect and for replay. What a live client reads is the
|
|
7059
|
+
* `session-closed` header stamped onto `turn-complete` itself by
|
|
7060
|
+
* `writeTurnCompleteChunk`, which fires whenever the close was decided before
|
|
7061
|
+
* the turn ended. A close decided from `onTurnComplete` is past that point, so
|
|
7062
|
+
* the client learns from the 409 on its next send.
|
|
7063
|
+
*/
|
|
7064
|
+
async function performChatClose() {
|
|
7065
|
+
const request = locals.get(chatCloseRequestedKey);
|
|
7066
|
+
if (!request || locals.get(chatClosePerformedKey))
|
|
7067
|
+
return;
|
|
7068
|
+
const reason = request.reason;
|
|
7069
|
+
// Two flags, not one. The record is a client-visible event and must not be
|
|
7070
|
+
// written twice, but the row close is the part that actually ends the
|
|
7071
|
+
// conversation: flagging it as done before it succeeds would let a transient
|
|
7072
|
+
// failure leave the session open with no later call willing to retry.
|
|
7073
|
+
if (!locals.get(chatCloseRecordWrittenKey)) {
|
|
7074
|
+
locals.set(chatCloseRecordWrittenKey, true);
|
|
7075
|
+
try {
|
|
7076
|
+
const session = getChatSession();
|
|
7077
|
+
await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.SESSION_CLOSED, reason ? [[SESSION_CLOSED_REASON_HEADER, reason]] : undefined);
|
|
7078
|
+
}
|
|
7079
|
+
catch (error) {
|
|
7080
|
+
logger.warn("chat.close: failed to write the session-closed record", {
|
|
7081
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7082
|
+
});
|
|
7083
|
+
}
|
|
7084
|
+
}
|
|
7085
|
+
const chatId = locals.get(chatExternalIdKey);
|
|
7086
|
+
if (!chatId)
|
|
7087
|
+
return;
|
|
7088
|
+
try {
|
|
7089
|
+
await sessions.close(chatId, {
|
|
7090
|
+
...(reason ? { reason } : {}),
|
|
7091
|
+
...(locals.get(chatAgentRunContextKey)?.run.id
|
|
7092
|
+
? { callingRunId: locals.get(chatAgentRunContextKey).run.id }
|
|
7093
|
+
: {}),
|
|
7094
|
+
});
|
|
7095
|
+
locals.set(chatClosePerformedKey, true);
|
|
7096
|
+
}
|
|
7097
|
+
catch (error) {
|
|
7098
|
+
// Deliberately NOT flagged as performed: the close API is idempotent, so a
|
|
7099
|
+
// later exit site on this run gets to retry it. Losing every retry to a
|
|
7100
|
+
// transient failure would leave the row open and the conversation alive.
|
|
7101
|
+
// Non-fatal either way — the run still exits.
|
|
7102
|
+
logger.error("chat.close: failed to close the session", {
|
|
7103
|
+
chatId,
|
|
7104
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7105
|
+
});
|
|
7106
|
+
}
|
|
7107
|
+
}
|
|
6240
7108
|
// ---------------------------------------------------------------------------
|
|
6241
7109
|
// Per-turn deferred work
|
|
6242
7110
|
// ---------------------------------------------------------------------------
|
|
@@ -6298,9 +7166,18 @@ function chatDefer(promiseOrFn) {
|
|
|
6298
7166
|
* ```
|
|
6299
7167
|
*/
|
|
6300
7168
|
function injectBackgroundContext(messages) {
|
|
6301
|
-
const
|
|
6302
|
-
|
|
6303
|
-
|
|
7169
|
+
const systemBlocks = messages.filter((message) => message.role === "system");
|
|
7170
|
+
const conversational = messages.filter((message) => message.role !== "system");
|
|
7171
|
+
if (systemBlocks.length > 0) {
|
|
7172
|
+
const instructions = locals.get(chatInjectedInstructionsKey) ?? [];
|
|
7173
|
+
instructions.push(...systemBlocks);
|
|
7174
|
+
locals.set(chatInjectedInstructionsKey, instructions);
|
|
7175
|
+
}
|
|
7176
|
+
if (conversational.length > 0) {
|
|
7177
|
+
const queue = locals.get(chatBackgroundQueueKey) ?? [];
|
|
7178
|
+
queue.push(...conversational);
|
|
7179
|
+
locals.set(chatBackgroundQueueKey, queue);
|
|
7180
|
+
}
|
|
6304
7181
|
}
|
|
6305
7182
|
// ---------------------------------------------------------------------------
|
|
6306
7183
|
// Aborted message cleanup
|
|
@@ -6713,10 +7590,12 @@ class ChatMessageAccumulator {
|
|
|
6713
7590
|
// a duplicate, mirroring the chat.agent accumulator.
|
|
6714
7591
|
const existingIdx = this.uiMessages.findIndex((m) => m.id === response.id);
|
|
6715
7592
|
if (existingIdx !== -1) {
|
|
7593
|
+
const previous = this.uiMessages[existingIdx];
|
|
6716
7594
|
this.uiMessages[existingIdx] = response;
|
|
6717
7595
|
try {
|
|
6718
|
-
|
|
6719
|
-
|
|
7596
|
+
if (!(await replaceModelRun(this.modelMessages, previous, response, 0))) {
|
|
7597
|
+
this.modelMessages = await toModelMessages(this.uiMessages.map((m) => stripProviderMetadata(m)));
|
|
7598
|
+
}
|
|
6720
7599
|
}
|
|
6721
7600
|
catch {
|
|
6722
7601
|
// Conversion failed — leave the existing model messages in place
|
|
@@ -6752,6 +7631,28 @@ class ChatMessageAccumulator {
|
|
|
6752
7631
|
const modelMsgs = await toModelMessages([message]);
|
|
6753
7632
|
this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs });
|
|
6754
7633
|
}
|
|
7634
|
+
/**
|
|
7635
|
+
* Record the messages a steering drain consumed.
|
|
7636
|
+
*
|
|
7637
|
+
* The drain only puts them in this step's prompt, so without this they
|
|
7638
|
+
* shape one answer and then exist in neither lane: not in `uiMessages`,
|
|
7639
|
+
* which is what an app persists from, and not in `modelMessages`, which is
|
|
7640
|
+
* what every later turn sends.
|
|
7641
|
+
*
|
|
7642
|
+
* Both lanes are appended to. The model lane is never reconverted from the
|
|
7643
|
+
* UI lane, because `compactIfNeeded` replaces it with a summary and leaves
|
|
7644
|
+
* the UI lane whole: a reconversion would restore everything the summary
|
|
7645
|
+
* replaced.
|
|
7646
|
+
*/
|
|
7647
|
+
async absorbSteering(claimed, injected) {
|
|
7648
|
+
const fresh = claimed.filter((m) => !this.uiMessages.some((e) => e.id === m.id));
|
|
7649
|
+
if (fresh.length === 0)
|
|
7650
|
+
return;
|
|
7651
|
+
this.uiMessages.push(...fresh);
|
|
7652
|
+
// Record what the model received. Only when the whole batch is new is
|
|
7653
|
+
// `injected` known to describe exactly these messages.
|
|
7654
|
+
this.modelMessages.push(...(injected && fresh.length === claimed.length ? injected : await toModelMessages(fresh)));
|
|
7655
|
+
}
|
|
6755
7656
|
/**
|
|
6756
7657
|
* Get and clear unconsumed steering messages.
|
|
6757
7658
|
*/
|
|
@@ -6784,7 +7685,8 @@ class ChatMessageAccumulator {
|
|
|
6784
7685
|
}
|
|
6785
7686
|
// 2. Pending message injection
|
|
6786
7687
|
if (pm && queue.length > 0) {
|
|
6787
|
-
const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
|
|
7688
|
+
const { injected, claimed } = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue);
|
|
7689
|
+
await this.absorbSteering(claimed, injected);
|
|
6788
7690
|
if (injected.length > 0) {
|
|
6789
7691
|
resultMessages = [...(resultMessages ?? messages), ...injected];
|
|
6790
7692
|
}
|
|
@@ -6973,7 +7875,7 @@ function trackActiveChatSessionIterator(iterator) {
|
|
|
6973
7875
|
* ```
|
|
6974
7876
|
*/
|
|
6975
7877
|
function createChatSession(payload, options) {
|
|
6976
|
-
const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, } = options;
|
|
7878
|
+
const { signal: runSignal, idleTimeoutInSeconds: sessionIdleTimeoutOpt, timeout = "1h", maxTurns = 100, compaction: sessionCompaction, pendingMessages: sessionPendingMessages, versionSkew: sessionVersionSkew, } = options;
|
|
6977
7879
|
const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30;
|
|
6978
7880
|
return {
|
|
6979
7881
|
[Symbol.asyncIterator]() {
|
|
@@ -7062,8 +7964,16 @@ function createChatSession(payload, options) {
|
|
|
7062
7964
|
* without suspending.
|
|
7063
7965
|
*/
|
|
7064
7966
|
if (turn > 0) {
|
|
7967
|
+
if (locals.get(chatCloseRequestedKey)) {
|
|
7968
|
+
await performChatClose();
|
|
7969
|
+
stop.cleanup();
|
|
7970
|
+
return { done: true, value: undefined };
|
|
7971
|
+
}
|
|
7065
7972
|
// chat.requestUpgrade() / chat.endRun() — exit before waiting
|
|
7066
7973
|
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
|
|
7974
|
+
if (locals.get(chatUpgradeRequestedKey)) {
|
|
7975
|
+
await persistUpgradeHandoff();
|
|
7976
|
+
}
|
|
7067
7977
|
stop.cleanup();
|
|
7068
7978
|
return { done: true, value: undefined };
|
|
7069
7979
|
}
|
|
@@ -7166,6 +8076,7 @@ function createChatSession(payload, options) {
|
|
|
7166
8076
|
}
|
|
7167
8077
|
accumulator.applyHandover(pendingHandoverSignal);
|
|
7168
8078
|
}
|
|
8079
|
+
await followSessionPin(currentPayload.chatId, sessionVersionSkew);
|
|
7169
8080
|
// chat.requestUpgrade() called before this turn — signal transport and exit
|
|
7170
8081
|
if (locals.get(chatUpgradeRequestedKey)) {
|
|
7171
8082
|
await writeUpgradeRequiredChunk();
|
|
@@ -7382,7 +8293,8 @@ function createChatSession(payload, options) {
|
|
|
7382
8293
|
}
|
|
7383
8294
|
}
|
|
7384
8295
|
if (sessionPendingMessages) {
|
|
7385
|
-
const injected = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
|
|
8296
|
+
const { injected, claimed } = await drainSteeringQueue(sessionPendingMessages, resultMessages ?? stepMsgs, steps, turnSteeringQueue);
|
|
8297
|
+
await accumulator.absorbSteering(claimed, injected);
|
|
7386
8298
|
if (injected.length > 0) {
|
|
7387
8299
|
resultMessages = [...(resultMessages ?? stepMsgs), ...injected];
|
|
7388
8300
|
}
|
|
@@ -7396,6 +8308,11 @@ function createChatSession(payload, options) {
|
|
|
7396
8308
|
async return() {
|
|
7397
8309
|
activeMsgSub?.off();
|
|
7398
8310
|
activeMsgSub = undefined;
|
|
8311
|
+
// Reached when the consumer leaves the `for await` early (`break`,
|
|
8312
|
+
// `return`, a throw). A `chat.close()` from the loop body would
|
|
8313
|
+
// otherwise be dropped: the exit that performs it lives in `next()`,
|
|
8314
|
+
// and `next()` is never called again.
|
|
8315
|
+
await performChatClose();
|
|
7399
8316
|
// `stop` only exists once next() has booted the iterator.
|
|
7400
8317
|
stop?.cleanup();
|
|
7401
8318
|
return { done: true, value: undefined };
|
|
@@ -7652,6 +8569,11 @@ function createChatStartSessionAction(taskId, options) {
|
|
|
7652
8569
|
const maxAttempts = params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts;
|
|
7653
8570
|
const maxDuration = params.triggerConfig?.maxDuration ?? options?.triggerConfig?.maxDuration;
|
|
7654
8571
|
const idleTimeoutInSeconds = params.triggerConfig?.idleTimeoutInSeconds ?? options?.triggerConfig?.idleTimeoutInSeconds;
|
|
8572
|
+
// Only `undefined` means "not supplied": a per-call `null` (opt out) has to beat a pinning
|
|
8573
|
+
// action default, which neither truthiness nor `??` would allow.
|
|
8574
|
+
const externalDeploymentId = params.triggerConfig?.externalDeploymentId !== undefined
|
|
8575
|
+
? params.triggerConfig.externalDeploymentId
|
|
8576
|
+
: options?.triggerConfig?.externalDeploymentId;
|
|
7655
8577
|
const triggerConfig = {
|
|
7656
8578
|
basePayload: {
|
|
7657
8579
|
messages: [],
|
|
@@ -7678,6 +8600,10 @@ function createChatStartSessionAction(taskId, options) {
|
|
|
7678
8600
|
lockToVersion: params.triggerConfig?.lockToVersion ?? options?.triggerConfig?.lockToVersion,
|
|
7679
8601
|
}
|
|
7680
8602
|
: {}),
|
|
8603
|
+
...(params.triggerConfig?.ttl !== undefined || options?.triggerConfig?.ttl !== undefined
|
|
8604
|
+
? { ttl: params.triggerConfig?.ttl ?? options?.triggerConfig?.ttl }
|
|
8605
|
+
: {}),
|
|
8606
|
+
...(externalDeploymentId !== undefined ? { externalDeploymentId } : {}),
|
|
7681
8607
|
...(idleTimeoutInSeconds !== undefined ? { idleTimeoutInSeconds } : {}),
|
|
7682
8608
|
};
|
|
7683
8609
|
const startBody = {
|
|
@@ -7722,6 +8648,7 @@ function createChatStartSessionAction(taskId, options) {
|
|
|
7722
8648
|
publicAccessToken,
|
|
7723
8649
|
runId: created.runId,
|
|
7724
8650
|
sessionId: created.id,
|
|
8651
|
+
...(created.pendingVersion ? { pendingVersion: true } : {}),
|
|
7725
8652
|
};
|
|
7726
8653
|
};
|
|
7727
8654
|
}
|
|
@@ -7755,7 +8682,8 @@ async function callSessionsCreateWithOverride(args) {
|
|
|
7755
8682
|
const init = {
|
|
7756
8683
|
method: "POST",
|
|
7757
8684
|
headers: overrideRequestHeaders(accessToken),
|
|
7758
|
-
|
|
8685
|
+
// This path bypasses `sessions.start`, so it resolves the pin itself.
|
|
8686
|
+
body: JSON.stringify(withResolvedExternalDeploymentId(args.body)),
|
|
7759
8687
|
};
|
|
7760
8688
|
const response = args.fetchOverride
|
|
7761
8689
|
? await args.fetchOverride(url, init, ctx)
|
|
@@ -7820,6 +8748,41 @@ async function mintPublicTokenWithOverride(args) {
|
|
|
7820
8748
|
expirationTime: args.expirationTime,
|
|
7821
8749
|
});
|
|
7822
8750
|
}
|
|
8751
|
+
/**
|
|
8752
|
+
* Creates a server-side helper that reads a conversation from a transcript
|
|
8753
|
+
* storage, for rendering history before the chat connects. Works the same
|
|
8754
|
+
* for every storage, the platform default included, so the browser never
|
|
8755
|
+
* reads a store directly and the secret key stays on the server.
|
|
8756
|
+
*
|
|
8757
|
+
* Wrap it in a Next.js server action (or any server-side handler), scope it
|
|
8758
|
+
* to the authenticated user through `clientData`, and pass the result to
|
|
8759
|
+
* `useLoadTranscript` in the browser.
|
|
8760
|
+
*
|
|
8761
|
+
* @example
|
|
8762
|
+
* ```ts
|
|
8763
|
+
* // actions.ts
|
|
8764
|
+
* "use server";
|
|
8765
|
+
* import { chat, defaultStorage } from "@trigger.dev/sdk/ai";
|
|
8766
|
+
*
|
|
8767
|
+
* export const loadTranscript = chat.createLoadTranscriptAction(defaultStorage, { limit: 50 });
|
|
8768
|
+
* ```
|
|
8769
|
+
*/
|
|
8770
|
+
function createChatLoadTranscriptAction(storage, options) {
|
|
8771
|
+
return async (params) => {
|
|
8772
|
+
if (!params.chatId) {
|
|
8773
|
+
throw new Error("chat.createLoadTranscriptAction: params.chatId is required.");
|
|
8774
|
+
}
|
|
8775
|
+
if (options?.apiClient) {
|
|
8776
|
+
const { apiClient, ...rest } = options;
|
|
8777
|
+
return apiClientManager.runWithConfig(apiClient, () => createChatLoadTranscriptAction(storage, rest)(params));
|
|
8778
|
+
}
|
|
8779
|
+
const limit = params.limit ?? options?.limit;
|
|
8780
|
+
return storage.load({ chatId: params.chatId, clientData: params.clientData }, {
|
|
8781
|
+
...(limit !== undefined ? { limit } : {}),
|
|
8782
|
+
...(params.before !== undefined ? { before: params.before } : {}),
|
|
8783
|
+
});
|
|
8784
|
+
};
|
|
8785
|
+
}
|
|
7823
8786
|
export const chat = {
|
|
7824
8787
|
/** Create a chat agent. See {@link chatAgent}. */
|
|
7825
8788
|
agent: chatAgent,
|
|
@@ -7831,8 +8794,12 @@ export const chat = {
|
|
|
7831
8794
|
withClientData,
|
|
7832
8795
|
/** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */
|
|
7833
8796
|
createStartSessionAction: createChatStartSessionAction,
|
|
8797
|
+
/** Returns a server-side helper that reads a conversation from a transcript storage. */
|
|
8798
|
+
createLoadTranscriptAction: createChatLoadTranscriptAction,
|
|
7834
8799
|
/** Pipe a stream to the chat transport. See {@link pipeChat}. */
|
|
7835
8800
|
pipe: pipeChat,
|
|
8801
|
+
/** Return from `onAction` to run a turn on the edited history. See {@link chatTurn}. */
|
|
8802
|
+
turn: chatTurn,
|
|
7836
8803
|
/** Create a per-run typed local. See {@link chatLocal}. */
|
|
7837
8804
|
local: chatLocal,
|
|
7838
8805
|
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
|
|
@@ -7853,6 +8820,8 @@ export const chat = {
|
|
|
7853
8820
|
endAndContinue,
|
|
7854
8821
|
/** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
|
|
7855
8822
|
endRun,
|
|
8823
|
+
/** End the conversation permanently: close the session and exit the run. See {@link close}. */
|
|
8824
|
+
close,
|
|
7856
8825
|
/** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
|
|
7857
8826
|
cleanupAbortedParts,
|
|
7858
8827
|
/** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
|
|
@@ -8000,6 +8969,16 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
|
|
|
8000
8969
|
if (consumedCursor !== undefined) {
|
|
8001
8970
|
extraHeaders.push([SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
|
|
8002
8971
|
}
|
|
8972
|
+
// A close decided before this turn ended rides out on turn-complete. Readers
|
|
8973
|
+
// terminate their stream on turn-complete, so a standalone record written
|
|
8974
|
+
// after it only reaches a reconnect — this header is what a live client sees.
|
|
8975
|
+
const pendingClose = locals.get(chatCloseRequestedKey);
|
|
8976
|
+
if (pendingClose) {
|
|
8977
|
+
extraHeaders.push([SESSION_CLOSED_HEADER, "true"]);
|
|
8978
|
+
if (pendingClose.reason) {
|
|
8979
|
+
extraHeaders.push([SESSION_CLOSED_REASON_HEADER, pendingClose.reason]);
|
|
8980
|
+
}
|
|
8981
|
+
}
|
|
8003
8982
|
const result = await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
|
|
8004
8983
|
const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
|
|
8005
8984
|
// 2. Trim back to the previous turn-complete, if we have one. Skipping on
|
|
@@ -8057,12 +9036,47 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
|
|
|
8057
9036
|
*
|
|
8058
9037
|
* @internal
|
|
8059
9038
|
*/
|
|
9039
|
+
/**
|
|
9040
|
+
* Persists an upgrade requested after the turn has already run.
|
|
9041
|
+
*
|
|
9042
|
+
* The pre-turn sites reach {@link performEndAndContinue} through
|
|
9043
|
+
* {@link writeUpgradeRequiredChunk}, which is what clears (or re-points) the
|
|
9044
|
+
* session's stored `externalDeploymentId`. The post-turn exits had no such path,
|
|
9045
|
+
* so a `chat.requestUpgrade()` from `run()` or `chat.defer()` left the pin intact
|
|
9046
|
+
* and every continuation re-pinned to the deployment the agent asked to leave.
|
|
9047
|
+
*
|
|
9048
|
+
* No `upgrade-required` chunk is written here: the turn already produced its
|
|
9049
|
+
* answer, so there is nothing for a client to be told about.
|
|
9050
|
+
*/
|
|
9051
|
+
async function persistUpgradeHandoff() {
|
|
9052
|
+
const chatId = locals.get(chatExternalIdKey);
|
|
9053
|
+
const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
|
|
9054
|
+
if (!chatId || !callingRunId) {
|
|
9055
|
+
return;
|
|
9056
|
+
}
|
|
9057
|
+
try {
|
|
9058
|
+
await performEndAndContinue({
|
|
9059
|
+
reason: "upgrade",
|
|
9060
|
+
externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
|
|
9061
|
+
});
|
|
9062
|
+
}
|
|
9063
|
+
catch (error) {
|
|
9064
|
+
logger.warn("upgrade handoff failed; session keeps its current version pin", {
|
|
9065
|
+
chatId,
|
|
9066
|
+
callingRunId,
|
|
9067
|
+
error,
|
|
9068
|
+
});
|
|
9069
|
+
}
|
|
9070
|
+
}
|
|
8060
9071
|
async function writeUpgradeRequiredChunk() {
|
|
8061
9072
|
const chatId = locals.get(chatExternalIdKey);
|
|
8062
9073
|
const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
|
|
8063
9074
|
if (chatId && callingRunId) {
|
|
8064
9075
|
try {
|
|
8065
|
-
await performEndAndContinue(
|
|
9076
|
+
await performEndAndContinue({
|
|
9077
|
+
reason: "upgrade",
|
|
9078
|
+
externalDeploymentId: locals.get(chatUpgradeExternalDeploymentIdKey),
|
|
9079
|
+
});
|
|
8066
9080
|
}
|
|
8067
9081
|
catch (error) {
|
|
8068
9082
|
// Non-fatal: the next `.in/append` re-triggers via the probe.
|