@trigger.dev/sdk 0.0.0-prerelease-20260306173130 → 0.0.0-prerelease-20260310150337
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/v3/ai.d.ts +475 -8
- package/dist/commonjs/v3/ai.js +785 -40
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +15 -0
- package/dist/commonjs/v3/chat.js +62 -1
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/runs.d.ts +1 -1
- package/dist/commonjs/v3/streams.js +33 -1
- package/dist/commonjs/v3/streams.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +475 -8
- package/dist/esm/v3/ai.js +785 -40
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +15 -0
- package/dist/esm/v3/chat.js +62 -1
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/streams.js +33 -1
- package/dist/esm/v3/streams.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/package.json +2 -2
package/dist/commonjs/v3/ai.js
CHANGED
|
@@ -14,6 +14,7 @@ const chat_constants_js_1 = require("./chat-constants.js");
|
|
|
14
14
|
Object.defineProperty(exports, "CHAT_MESSAGES_STREAM_ID", { enumerable: true, get: function () { return chat_constants_js_1.CHAT_MESSAGES_STREAM_ID; } });
|
|
15
15
|
Object.defineProperty(exports, "CHAT_STOP_STREAM_ID", { enumerable: true, get: function () { return chat_constants_js_1.CHAT_STOP_STREAM_ID; } });
|
|
16
16
|
const METADATA_KEY = "tool.execute.options";
|
|
17
|
+
const chatTurnContextKey = locals_js_1.locals.create("chat.turnContext");
|
|
17
18
|
function toolFromTask(task, options) {
|
|
18
19
|
if (("schema" in task && !task.schema) || ("jsonSchema" in task && !task.jsonSchema)) {
|
|
19
20
|
throw new Error("Cannot convert this task to to a tool because the task has no schema. Make sure to either use schemaTask or a task with an input jsonSchema.");
|
|
@@ -22,12 +23,43 @@ function toolFromTask(task, options) {
|
|
|
22
23
|
description: task.description,
|
|
23
24
|
inputSchema: convertTaskSchemaToToolParameters(task),
|
|
24
25
|
execute: async (input, options) => {
|
|
25
|
-
|
|
26
|
+
// Build tool metadata — skip messages (can be large) and abortSignal (non-serializable)
|
|
27
|
+
const toolMeta = {
|
|
28
|
+
toolCallId: options?.toolCallId ?? "",
|
|
29
|
+
};
|
|
30
|
+
if (options?.experimental_context !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
toolMeta.experimental_context = JSON.parse(JSON.stringify(options.experimental_context));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
// Non-serializable context — skip
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Auto-detect chat context from the parent turn
|
|
39
|
+
const chatCtx = locals_js_1.locals.get(chatTurnContextKey);
|
|
40
|
+
if (chatCtx) {
|
|
41
|
+
toolMeta.chatId = chatCtx.chatId;
|
|
42
|
+
toolMeta.turn = chatCtx.turn;
|
|
43
|
+
toolMeta.continuation = chatCtx.continuation;
|
|
44
|
+
toolMeta.clientData = chatCtx.clientData;
|
|
45
|
+
}
|
|
46
|
+
// Serialize initialized chat.local values for subtask hydration
|
|
47
|
+
const chatLocals = {};
|
|
48
|
+
for (const entry of chatLocalRegistry) {
|
|
49
|
+
const value = locals_js_1.locals.get(entry.key);
|
|
50
|
+
if (value !== undefined) {
|
|
51
|
+
chatLocals[entry.id] = value;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (Object.keys(chatLocals).length > 0) {
|
|
55
|
+
toolMeta.chatLocals = chatLocals;
|
|
56
|
+
}
|
|
26
57
|
return await task
|
|
27
58
|
.triggerAndWait(input, {
|
|
28
59
|
metadata: {
|
|
29
|
-
[METADATA_KEY]:
|
|
60
|
+
[METADATA_KEY]: toolMeta,
|
|
30
61
|
},
|
|
62
|
+
tags: options?.toolCallId ? [`toolCallId:${options.toolCallId}`] : undefined,
|
|
31
63
|
})
|
|
32
64
|
.unwrap();
|
|
33
65
|
},
|
|
@@ -42,6 +74,53 @@ function getToolOptionsFromMetadata() {
|
|
|
42
74
|
}
|
|
43
75
|
return tool;
|
|
44
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Get the current tool call ID from inside a subtask invoked via `ai.tool()`.
|
|
79
|
+
* Returns `undefined` if not running as a tool subtask.
|
|
80
|
+
*/
|
|
81
|
+
function getToolCallId() {
|
|
82
|
+
return getToolOptionsFromMetadata()?.toolCallId;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get the chat context from inside a subtask invoked via `ai.tool()` within a `chat.task`.
|
|
86
|
+
* Pass `typeof yourChatTask` as the type parameter to get typed `clientData`.
|
|
87
|
+
* Returns `undefined` if the parent is not a chat task.
|
|
88
|
+
*
|
|
89
|
+
* @example
|
|
90
|
+
* ```ts
|
|
91
|
+
* const ctx = ai.chatContext<typeof myChat>();
|
|
92
|
+
* // ctx?.clientData is typed based on myChat's clientDataSchema
|
|
93
|
+
* ```
|
|
94
|
+
*/
|
|
95
|
+
function getToolChatContext() {
|
|
96
|
+
const opts = getToolOptionsFromMetadata();
|
|
97
|
+
if (!opts?.chatId)
|
|
98
|
+
return undefined;
|
|
99
|
+
return {
|
|
100
|
+
chatId: opts.chatId,
|
|
101
|
+
turn: opts.turn ?? 0,
|
|
102
|
+
continuation: opts.continuation ?? false,
|
|
103
|
+
clientData: opts.clientData,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Get the chat context from inside a subtask, throwing if not in a chat context.
|
|
108
|
+
* Pass `typeof yourChatTask` as the type parameter to get typed `clientData`.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* ```ts
|
|
112
|
+
* const ctx = ai.chatContextOrThrow<typeof myChat>();
|
|
113
|
+
* // ctx.chatId, ctx.clientData are guaranteed non-null
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
function getToolChatContextOrThrow() {
|
|
117
|
+
const ctx = getToolChatContext();
|
|
118
|
+
if (!ctx) {
|
|
119
|
+
throw new Error("ai.chatContextOrThrow() called outside of a chat.task context. " +
|
|
120
|
+
"This helper can only be used inside a subtask invoked via ai.tool() from a chat.task.");
|
|
121
|
+
}
|
|
122
|
+
return ctx;
|
|
123
|
+
}
|
|
45
124
|
function convertTaskSchemaToToolParameters(task) {
|
|
46
125
|
if ("schema" in task) {
|
|
47
126
|
// If TaskSchema is ArkTypeEsque, use ai.jsonSchema to convert it to a Schema
|
|
@@ -61,6 +140,12 @@ function convertTaskSchemaToToolParameters(task) {
|
|
|
61
140
|
exports.ai = {
|
|
62
141
|
tool: toolFromTask,
|
|
63
142
|
currentToolOptions: getToolOptionsFromMetadata,
|
|
143
|
+
/** Get the tool call ID from inside a subtask invoked via `ai.tool()`. */
|
|
144
|
+
toolCallId: getToolCallId,
|
|
145
|
+
/** Get chat context (chatId, turn, clientData, etc.) from inside a subtask of a `chat.task`. Returns undefined if not in a chat context. */
|
|
146
|
+
chatContext: getToolChatContext,
|
|
147
|
+
/** Get chat context or throw if not in a chat context. Pass `typeof yourChatTask` for typed clientData. */
|
|
148
|
+
chatContextOrThrow: getToolChatContextOrThrow,
|
|
64
149
|
};
|
|
65
150
|
/**
|
|
66
151
|
* Creates a public access token for a chat task.
|
|
@@ -91,15 +176,48 @@ function createChatAccessToken(taskId) {
|
|
|
91
176
|
* use this key by default.
|
|
92
177
|
*/
|
|
93
178
|
exports.CHAT_STREAM_KEY = chat_constants_js_1.CHAT_STREAM_KEY;
|
|
179
|
+
/**
|
|
180
|
+
* Typed chat output stream. Provides `.writer()`, `.pipe()`, `.append()`,
|
|
181
|
+
* and `.read()` methods pre-bound to the chat stream key and typed to `UIMessageChunk`.
|
|
182
|
+
*
|
|
183
|
+
* Use from within a `chat.task` run to write custom chunks:
|
|
184
|
+
* ```ts
|
|
185
|
+
* const { waitUntilComplete } = chat.stream.writer({
|
|
186
|
+
* execute: ({ write }) => {
|
|
187
|
+
* write({ type: "text-start", id: "status-1" });
|
|
188
|
+
* write({ type: "text-delta", id: "status-1", delta: "Processing..." });
|
|
189
|
+
* write({ type: "text-end", id: "status-1" });
|
|
190
|
+
* },
|
|
191
|
+
* });
|
|
192
|
+
* await waitUntilComplete();
|
|
193
|
+
* ```
|
|
194
|
+
*
|
|
195
|
+
* Use from a subtask to stream back to the parent chat:
|
|
196
|
+
* ```ts
|
|
197
|
+
* chat.stream.pipe(myStream, { target: "root" });
|
|
198
|
+
* ```
|
|
199
|
+
*/
|
|
200
|
+
const chatStream = streams_js_1.streams.define({ id: chat_constants_js_1.CHAT_STREAM_KEY });
|
|
94
201
|
// Input streams for bidirectional chat communication
|
|
95
202
|
const messagesInput = streams_js_1.streams.input({ id: chat_constants_js_1.CHAT_MESSAGES_STREAM_ID });
|
|
96
203
|
const stopInput = streams_js_1.streams.input({ id: chat_constants_js_1.CHAT_STOP_STREAM_ID });
|
|
204
|
+
/**
|
|
205
|
+
* Per-turn deferred promises. Registered via `chat.defer()`, awaited
|
|
206
|
+
* before `onTurnComplete` fires. Reset each turn.
|
|
207
|
+
* @internal
|
|
208
|
+
*/
|
|
209
|
+
const chatDeferKey = locals_js_1.locals.create("chat.defer");
|
|
97
210
|
/**
|
|
98
211
|
* Run-scoped pipe counter. Stored in locals so concurrent runs in the
|
|
99
212
|
* same worker don't share state.
|
|
100
213
|
* @internal
|
|
101
214
|
*/
|
|
102
215
|
const chatPipeCountKey = locals_js_1.locals.create("chat.pipeCount");
|
|
216
|
+
const chatStopControllerKey = locals_js_1.locals.create("chat.stopController");
|
|
217
|
+
/** Static (task-level) UIMessageStream options, set once during chatTask setup. @internal */
|
|
218
|
+
const chatUIStreamStaticKey = locals_js_1.locals.create("chat.uiMessageStreamOptions.static");
|
|
219
|
+
/** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
|
|
220
|
+
const chatUIStreamPerTurnKey = locals_js_1.locals.create("chat.uiMessageStreamOptions.perTurn");
|
|
103
221
|
function isUIMessageStreamable(value) {
|
|
104
222
|
return (typeof value === "object" &&
|
|
105
223
|
value !== null &&
|
|
@@ -206,7 +324,7 @@ async function pipeChat(source, options) {
|
|
|
206
324
|
* ```
|
|
207
325
|
*/
|
|
208
326
|
function chatTask(options) {
|
|
209
|
-
const { run: userRun, clientDataSchema, onChatStart, onTurnStart, onTurnComplete, maxTurns = 100, turnTimeout = "1h", warmTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", ...restOptions } = options;
|
|
327
|
+
const { run: userRun, clientDataSchema, onPreload, onChatStart, onTurnStart, onTurnComplete, maxTurns = 100, turnTimeout = "1h", warmTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadWarmTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, ...restOptions } = options;
|
|
210
328
|
const parseClientData = clientDataSchema
|
|
211
329
|
? (0, v3_1.getSchemaParseFn)(clientDataSchema)
|
|
212
330
|
: undefined;
|
|
@@ -218,7 +336,14 @@ function chatTask(options) {
|
|
|
218
336
|
if (activeSpan) {
|
|
219
337
|
activeSpan.setAttribute("gen_ai.conversation.id", payload.chatId);
|
|
220
338
|
}
|
|
339
|
+
// Store static UIMessageStream options in locals so resolveUIMessageStreamOptions() can read them
|
|
340
|
+
if (uiMessageStreamOptions) {
|
|
341
|
+
locals_js_1.locals.set(chatUIStreamStaticKey, uiMessageStreamOptions);
|
|
342
|
+
}
|
|
221
343
|
let currentWirePayload = payload;
|
|
344
|
+
const continuation = payload.continuation ?? false;
|
|
345
|
+
const previousRunId = payload.previousRunId;
|
|
346
|
+
const preloaded = payload.trigger === "preload";
|
|
222
347
|
// Accumulated model messages across turns. Turn 1 initialises from the
|
|
223
348
|
// full history the frontend sends; subsequent turns append only the new
|
|
224
349
|
// user message(s) and the captured assistant response.
|
|
@@ -234,6 +359,67 @@ function chatTask(options) {
|
|
|
234
359
|
currentStopController?.abort(data?.message || "stopped");
|
|
235
360
|
});
|
|
236
361
|
try {
|
|
362
|
+
// Handle preloaded runs — fire onPreload, then wait for the first real message
|
|
363
|
+
if (preloaded) {
|
|
364
|
+
if (activeSpan) {
|
|
365
|
+
activeSpan.setAttribute("chat.preloaded", true);
|
|
366
|
+
}
|
|
367
|
+
const currentRunId = v3_1.taskContext.ctx?.run.id ?? "";
|
|
368
|
+
let preloadAccessToken = "";
|
|
369
|
+
if (currentRunId) {
|
|
370
|
+
try {
|
|
371
|
+
preloadAccessToken = await auth_js_1.auth.createPublicToken({
|
|
372
|
+
scopes: {
|
|
373
|
+
read: { runs: currentRunId },
|
|
374
|
+
write: { inputStreams: currentRunId },
|
|
375
|
+
},
|
|
376
|
+
expirationTime: chatAccessTokenTTL,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// Token creation failed
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
// Parse client data for the preload hook
|
|
384
|
+
const preloadClientData = (parseClientData
|
|
385
|
+
? await parseClientData(payload.metadata)
|
|
386
|
+
: payload.metadata);
|
|
387
|
+
// Fire onPreload hook
|
|
388
|
+
if (onPreload) {
|
|
389
|
+
await tracer_js_1.tracer.startActiveSpan("onPreload()", async () => {
|
|
390
|
+
await onPreload({
|
|
391
|
+
chatId: payload.chatId,
|
|
392
|
+
runId: currentRunId,
|
|
393
|
+
chatAccessToken: preloadAccessToken,
|
|
394
|
+
clientData: preloadClientData,
|
|
395
|
+
});
|
|
396
|
+
}, {
|
|
397
|
+
attributes: {
|
|
398
|
+
[v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
|
|
399
|
+
[v3_1.SemanticInternalAttributes.COLLAPSED]: true,
|
|
400
|
+
"chat.id": payload.chatId,
|
|
401
|
+
"chat.preloaded": true,
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
// Wait for the first real message — use preload-specific timeouts if configured
|
|
406
|
+
const effectivePreloadWarmTimeout = payload.warmTimeoutInSeconds
|
|
407
|
+
?? preloadWarmTimeoutInSeconds
|
|
408
|
+
?? warmTimeoutInSeconds;
|
|
409
|
+
const effectivePreloadTimeout = metadata_js_1.metadata.get(TURN_TIMEOUT_METADATA_KEY)
|
|
410
|
+
?? preloadTimeout
|
|
411
|
+
?? turnTimeout;
|
|
412
|
+
const preloadResult = await messagesInput.waitWithWarmup({
|
|
413
|
+
warmTimeoutInSeconds: effectivePreloadWarmTimeout,
|
|
414
|
+
timeout: effectivePreloadTimeout,
|
|
415
|
+
spanName: "waiting for first message",
|
|
416
|
+
});
|
|
417
|
+
if (!preloadResult.ok) {
|
|
418
|
+
return; // Timed out waiting for first message — end run
|
|
419
|
+
}
|
|
420
|
+
let firstMessage = preloadResult.output;
|
|
421
|
+
currentWirePayload = firstMessage;
|
|
422
|
+
}
|
|
237
423
|
for (let turn = 0; turn < maxTurns; turn++) {
|
|
238
424
|
// Extract turn-level context before entering the span
|
|
239
425
|
const { metadata: wireMetadata, messages: uiMessages, ...restWire } = currentWirePayload;
|
|
@@ -266,9 +452,18 @@ function chatTask(options) {
|
|
|
266
452
|
}
|
|
267
453
|
const turnResult = await tracer_js_1.tracer.startActiveSpan(`chat turn ${turn + 1}`, async () => {
|
|
268
454
|
locals_js_1.locals.set(chatPipeCountKey, 0);
|
|
455
|
+
locals_js_1.locals.set(chatDeferKey, new Set());
|
|
456
|
+
// Store chat context for auto-detection by ai.tool subtasks
|
|
457
|
+
locals_js_1.locals.set(chatTurnContextKey, {
|
|
458
|
+
chatId: currentWirePayload.chatId,
|
|
459
|
+
turn,
|
|
460
|
+
continuation,
|
|
461
|
+
clientData,
|
|
462
|
+
});
|
|
269
463
|
// Per-turn stop controller (reset each turn)
|
|
270
464
|
const stopController = new AbortController();
|
|
271
465
|
currentStopController = stopController;
|
|
466
|
+
locals_js_1.locals.set(chatStopControllerKey, stopController);
|
|
272
467
|
// Three signals for the user's run function
|
|
273
468
|
const stopSignal = stopController.signal;
|
|
274
469
|
const cancelSignal = runSignal;
|
|
@@ -278,20 +473,26 @@ function chatTask(options) {
|
|
|
278
473
|
const msgSub = messagesInput.on((msg) => {
|
|
279
474
|
pendingMessages.push(msg);
|
|
280
475
|
});
|
|
476
|
+
// Clean up any incomplete tool parts in the incoming history.
|
|
477
|
+
// When a previous run was stopped mid-tool-call, the frontend's
|
|
478
|
+
// useChat state may still contain assistant messages with tool parts
|
|
479
|
+
// in partial/input-available state. These cause API errors (e.g.
|
|
480
|
+
// Anthropic requires every tool_use to have a matching tool_result).
|
|
481
|
+
const cleanedUIMessages = uiMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
|
|
281
482
|
// Convert the incoming UIMessages to model messages and update the accumulator.
|
|
282
483
|
// Turn 1: full history from the frontend → replaces the accumulator.
|
|
283
484
|
// Turn 2+: only the new message(s) → appended to the accumulator.
|
|
284
|
-
const incomingModelMessages = await (0, ai_1.convertToModelMessages)(
|
|
485
|
+
const incomingModelMessages = await (0, ai_1.convertToModelMessages)(cleanedUIMessages);
|
|
285
486
|
// Track new messages for this turn (user input + assistant response).
|
|
286
487
|
const turnNewModelMessages = [];
|
|
287
488
|
const turnNewUIMessages = [];
|
|
288
489
|
if (turn === 0) {
|
|
289
490
|
accumulatedMessages = incomingModelMessages;
|
|
290
|
-
accumulatedUIMessages = [...
|
|
491
|
+
accumulatedUIMessages = [...cleanedUIMessages];
|
|
291
492
|
// On first turn, the "new" messages are just the last user message
|
|
292
493
|
// (the rest is history). We'll add the response after streaming.
|
|
293
|
-
if (
|
|
294
|
-
turnNewUIMessages.push(
|
|
494
|
+
if (cleanedUIMessages.length > 0) {
|
|
495
|
+
turnNewUIMessages.push(cleanedUIMessages[cleanedUIMessages.length - 1]);
|
|
295
496
|
const lastModel = incomingModelMessages[incomingModelMessages.length - 1];
|
|
296
497
|
if (lastModel)
|
|
297
498
|
turnNewModelMessages.push(lastModel);
|
|
@@ -301,15 +502,15 @@ function chatTask(options) {
|
|
|
301
502
|
// Regenerate: frontend sent full history with last assistant message
|
|
302
503
|
// removed. Reset the accumulator to match.
|
|
303
504
|
accumulatedMessages = incomingModelMessages;
|
|
304
|
-
accumulatedUIMessages = [...
|
|
505
|
+
accumulatedUIMessages = [...cleanedUIMessages];
|
|
305
506
|
// No new user messages for regenerate — just the response (added below)
|
|
306
507
|
}
|
|
307
508
|
else {
|
|
308
509
|
// Submit: frontend sent only the new user message(s). Append to accumulator.
|
|
309
510
|
accumulatedMessages.push(...incomingModelMessages);
|
|
310
|
-
accumulatedUIMessages.push(...
|
|
511
|
+
accumulatedUIMessages.push(...cleanedUIMessages);
|
|
311
512
|
turnNewModelMessages.push(...incomingModelMessages);
|
|
312
|
-
turnNewUIMessages.push(...
|
|
513
|
+
turnNewUIMessages.push(...cleanedUIMessages);
|
|
313
514
|
}
|
|
314
515
|
// Mint a scoped public access token once per turn, reused for
|
|
315
516
|
// onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk.
|
|
@@ -338,11 +539,19 @@ function chatTask(options) {
|
|
|
338
539
|
clientData,
|
|
339
540
|
runId: currentRunId,
|
|
340
541
|
chatAccessToken: turnAccessToken,
|
|
542
|
+
continuation,
|
|
543
|
+
previousRunId,
|
|
544
|
+
preloaded,
|
|
341
545
|
});
|
|
342
546
|
}, {
|
|
343
547
|
attributes: {
|
|
344
548
|
[v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
|
|
345
549
|
[v3_1.SemanticInternalAttributes.COLLAPSED]: true,
|
|
550
|
+
"chat.id": currentWirePayload.chatId,
|
|
551
|
+
"chat.messages.count": accumulatedMessages.length,
|
|
552
|
+
"chat.continuation": continuation,
|
|
553
|
+
"chat.preloaded": preloaded,
|
|
554
|
+
...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
|
|
346
555
|
},
|
|
347
556
|
});
|
|
348
557
|
}
|
|
@@ -358,21 +567,42 @@ function chatTask(options) {
|
|
|
358
567
|
runId: currentRunId,
|
|
359
568
|
chatAccessToken: turnAccessToken,
|
|
360
569
|
clientData,
|
|
570
|
+
continuation,
|
|
571
|
+
previousRunId,
|
|
572
|
+
preloaded,
|
|
361
573
|
});
|
|
362
574
|
}, {
|
|
363
575
|
attributes: {
|
|
364
576
|
[v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart",
|
|
365
577
|
[v3_1.SemanticInternalAttributes.COLLAPSED]: true,
|
|
578
|
+
"chat.id": currentWirePayload.chatId,
|
|
579
|
+
"chat.turn": turn + 1,
|
|
580
|
+
"chat.messages.count": accumulatedMessages.length,
|
|
581
|
+
"chat.trigger": currentWirePayload.trigger,
|
|
582
|
+
"chat.continuation": continuation,
|
|
583
|
+
"chat.preloaded": preloaded,
|
|
584
|
+
...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
|
|
366
585
|
},
|
|
367
586
|
});
|
|
368
587
|
}
|
|
369
588
|
// Captured by the onFinish callback below — works even on abort/stop.
|
|
370
589
|
let capturedResponseMessage;
|
|
590
|
+
// Promise that resolves when the AI SDK's onFinish fires.
|
|
591
|
+
// On abort, the stream's cancel() handler calls onFinish
|
|
592
|
+
// asynchronously AFTER pipeChat resolves, so we must await
|
|
593
|
+
// this to avoid a race where we check capturedResponseMessage
|
|
594
|
+
// before it's been set.
|
|
595
|
+
let resolveOnFinish;
|
|
596
|
+
const onFinishPromise = new Promise((r) => { resolveOnFinish = r; });
|
|
597
|
+
let onFinishAttached = false;
|
|
371
598
|
try {
|
|
372
599
|
const result = await userRun({
|
|
373
600
|
...restWire,
|
|
374
601
|
messages: accumulatedMessages,
|
|
375
602
|
clientData,
|
|
603
|
+
continuation,
|
|
604
|
+
previousRunId,
|
|
605
|
+
preloaded,
|
|
376
606
|
signal: combinedSignal,
|
|
377
607
|
cancelSignal,
|
|
378
608
|
stopSignal,
|
|
@@ -381,9 +611,12 @@ function chatTask(options) {
|
|
|
381
611
|
// but only if pipeChat() wasn't already called manually during this turn.
|
|
382
612
|
// We call toUIMessageStream ourselves to attach onFinish for response capture.
|
|
383
613
|
if ((locals_js_1.locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(result)) {
|
|
614
|
+
onFinishAttached = true;
|
|
384
615
|
const uiStream = result.toUIMessageStream({
|
|
616
|
+
...resolveUIMessageStreamOptions(),
|
|
385
617
|
onFinish: ({ responseMessage }) => {
|
|
386
618
|
capturedResponseMessage = responseMessage;
|
|
619
|
+
resolveOnFinish();
|
|
387
620
|
},
|
|
388
621
|
});
|
|
389
622
|
await pipeChat(uiStream, { signal: combinedSignal, spanName: "stream response" });
|
|
@@ -404,10 +637,24 @@ function chatTask(options) {
|
|
|
404
637
|
finally {
|
|
405
638
|
msgSub.off();
|
|
406
639
|
}
|
|
640
|
+
// Wait for onFinish to fire — on abort this may resolve slightly
|
|
641
|
+
// after pipeChat, since the stream's cancel() handler is async.
|
|
642
|
+
if (onFinishAttached) {
|
|
643
|
+
await onFinishPromise;
|
|
644
|
+
}
|
|
645
|
+
// Determine if the user stopped generation this turn (not a full run cancel).
|
|
646
|
+
const wasStopped = stopController.signal.aborted && !runSignal.aborted;
|
|
407
647
|
// Append the assistant's response (partial or complete) to the accumulator.
|
|
408
648
|
// The onFinish callback fires even on abort/stop, so partial responses
|
|
409
649
|
// from stopped generation are captured correctly.
|
|
650
|
+
let rawResponseMessage;
|
|
410
651
|
if (capturedResponseMessage) {
|
|
652
|
+
// Keep the raw message before cleanup for users who want custom handling
|
|
653
|
+
rawResponseMessage = capturedResponseMessage;
|
|
654
|
+
// Clean up aborted parts (streaming tool calls, reasoning) when stopped
|
|
655
|
+
if (wasStopped) {
|
|
656
|
+
capturedResponseMessage = cleanupAbortedParts(capturedResponseMessage);
|
|
657
|
+
}
|
|
411
658
|
// Ensure the response message has an ID (the stream's onFinish
|
|
412
659
|
// may produce a message with an empty ID since IDs are normally
|
|
413
660
|
// assigned by the frontend's useChat).
|
|
@@ -436,6 +683,15 @@ function chatTask(options) {
|
|
|
436
683
|
// Write turn-complete control chunk so frontend closes its stream.
|
|
437
684
|
// Capture the lastEventId from the stream writer for resume support.
|
|
438
685
|
const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
|
|
686
|
+
// Await deferred background work (e.g. DB writes from onTurnStart)
|
|
687
|
+
// before firing onTurnComplete so hooks can rely on the work being done.
|
|
688
|
+
const deferredWork = locals_js_1.locals.get(chatDeferKey);
|
|
689
|
+
if (deferredWork && deferredWork.size > 0) {
|
|
690
|
+
await Promise.race([
|
|
691
|
+
Promise.allSettled(deferredWork),
|
|
692
|
+
new Promise((r) => setTimeout(r, 5_000)),
|
|
693
|
+
]);
|
|
694
|
+
}
|
|
439
695
|
// Fire onTurnComplete after response capture
|
|
440
696
|
if (onTurnComplete) {
|
|
441
697
|
await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
|
|
@@ -446,16 +702,30 @@ function chatTask(options) {
|
|
|
446
702
|
newMessages: turnNewModelMessages,
|
|
447
703
|
newUIMessages: turnNewUIMessages,
|
|
448
704
|
responseMessage: capturedResponseMessage,
|
|
705
|
+
rawResponseMessage,
|
|
449
706
|
turn,
|
|
450
707
|
runId: currentRunId,
|
|
451
708
|
chatAccessToken: turnAccessToken,
|
|
452
709
|
lastEventId: turnCompleteResult.lastEventId,
|
|
453
710
|
clientData,
|
|
711
|
+
stopped: wasStopped,
|
|
712
|
+
continuation,
|
|
713
|
+
previousRunId,
|
|
714
|
+
preloaded,
|
|
454
715
|
});
|
|
455
716
|
}, {
|
|
456
717
|
attributes: {
|
|
457
718
|
[v3_1.SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete",
|
|
458
719
|
[v3_1.SemanticInternalAttributes.COLLAPSED]: true,
|
|
720
|
+
"chat.id": currentWirePayload.chatId,
|
|
721
|
+
"chat.turn": turn + 1,
|
|
722
|
+
"chat.stopped": wasStopped,
|
|
723
|
+
"chat.continuation": continuation,
|
|
724
|
+
"chat.preloaded": preloaded,
|
|
725
|
+
...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}),
|
|
726
|
+
"chat.messages.count": accumulatedMessages.length,
|
|
727
|
+
"chat.response.parts.count": capturedResponseMessage?.parts?.length ?? 0,
|
|
728
|
+
"chat.new_messages.count": turnNewUIMessages.length,
|
|
459
729
|
},
|
|
460
730
|
});
|
|
461
731
|
}
|
|
@@ -464,28 +734,15 @@ function chatTask(options) {
|
|
|
464
734
|
currentWirePayload = pendingMessages[0];
|
|
465
735
|
return "continue";
|
|
466
736
|
}
|
|
467
|
-
//
|
|
468
|
-
// The run stays active (using compute) during this window.
|
|
737
|
+
// Wait for the next message — stay warm briefly, then suspend
|
|
469
738
|
const effectiveWarmTimeout = metadata_js_1.metadata.get(WARM_TIMEOUT_METADATA_KEY) ?? warmTimeoutInSeconds;
|
|
470
|
-
if (effectiveWarmTimeout > 0) {
|
|
471
|
-
const warm = await messagesInput.once({
|
|
472
|
-
timeoutMs: effectiveWarmTimeout * 1000,
|
|
473
|
-
spanName: "waiting (warm)",
|
|
474
|
-
});
|
|
475
|
-
if (warm.ok) {
|
|
476
|
-
// Message arrived while warm — respond instantly
|
|
477
|
-
currentWirePayload = warm.output;
|
|
478
|
-
return "continue";
|
|
479
|
-
}
|
|
480
|
-
}
|
|
481
|
-
// Phase 2: Suspend the task (frees compute) until the next message arrives
|
|
482
739
|
const effectiveTurnTimeout = metadata_js_1.metadata.get(TURN_TIMEOUT_METADATA_KEY) ?? turnTimeout;
|
|
483
|
-
const next = await messagesInput.
|
|
740
|
+
const next = await messagesInput.waitWithWarmup({
|
|
741
|
+
warmTimeoutInSeconds: effectiveWarmTimeout,
|
|
484
742
|
timeout: effectiveTurnTimeout,
|
|
485
|
-
spanName: "waiting
|
|
743
|
+
spanName: "waiting for next message",
|
|
486
744
|
});
|
|
487
745
|
if (!next.ok) {
|
|
488
|
-
// Timed out waiting for the next message — end the conversation
|
|
489
746
|
return "exit";
|
|
490
747
|
}
|
|
491
748
|
currentWirePayload = next.output;
|
|
@@ -589,6 +846,424 @@ function setTurnTimeoutInSeconds(seconds) {
|
|
|
589
846
|
function setWarmTimeoutInSeconds(seconds) {
|
|
590
847
|
metadata_js_1.metadata.set(WARM_TIMEOUT_METADATA_KEY, seconds);
|
|
591
848
|
}
|
|
849
|
+
/**
|
|
850
|
+
* Override the `toUIMessageStream()` options for the current turn.
|
|
851
|
+
*
|
|
852
|
+
* These options control how the `StreamTextResult` is converted to a
|
|
853
|
+
* `UIMessageChunk` stream — error handling, reasoning/source visibility,
|
|
854
|
+
* message metadata, etc.
|
|
855
|
+
*
|
|
856
|
+
* Per-turn options are merged on top of the static `uiMessageStreamOptions`
|
|
857
|
+
* set on `chat.task()`. Per-turn values win on conflicts.
|
|
858
|
+
*
|
|
859
|
+
* @example
|
|
860
|
+
* ```ts
|
|
861
|
+
* run: async ({ messages, signal }) => {
|
|
862
|
+
* chat.setUIMessageStreamOptions({
|
|
863
|
+
* sendReasoning: true,
|
|
864
|
+
* onError: (error) => error instanceof Error ? error.message : "An error occurred.",
|
|
865
|
+
* });
|
|
866
|
+
* return streamText({ model, messages, abortSignal: signal });
|
|
867
|
+
* }
|
|
868
|
+
* ```
|
|
869
|
+
*/
|
|
870
|
+
function setUIMessageStreamOptions(options) {
|
|
871
|
+
locals_js_1.locals.set(chatUIStreamPerTurnKey, options);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Resolve the effective UIMessageStream options by merging:
|
|
875
|
+
* 1. Static task-level options (from `chat.task({ uiMessageStreamOptions })`)
|
|
876
|
+
* 2. Per-turn overrides (from `chat.setUIMessageStreamOptions()`)
|
|
877
|
+
*
|
|
878
|
+
* Per-turn values win on conflicts. Clears the per-turn override after reading
|
|
879
|
+
* so it doesn't leak into subsequent turns.
|
|
880
|
+
* @internal
|
|
881
|
+
*/
|
|
882
|
+
function resolveUIMessageStreamOptions() {
|
|
883
|
+
const staticOptions = locals_js_1.locals.get(chatUIStreamStaticKey) ?? {};
|
|
884
|
+
const perTurnOptions = locals_js_1.locals.get(chatUIStreamPerTurnKey) ?? {};
|
|
885
|
+
// Clear per-turn override so it doesn't leak into subsequent turns
|
|
886
|
+
locals_js_1.locals.set(chatUIStreamPerTurnKey, undefined);
|
|
887
|
+
return { ...staticOptions, ...perTurnOptions };
|
|
888
|
+
}
|
|
889
|
+
// ---------------------------------------------------------------------------
|
|
890
|
+
// Stop detection
|
|
891
|
+
// ---------------------------------------------------------------------------
|
|
892
|
+
/**
|
|
893
|
+
* Check whether the user stopped generation during the current turn.
|
|
894
|
+
*
|
|
895
|
+
* Works from **anywhere** inside a `chat.task` run — including inside
|
|
896
|
+
* `streamText`'s `onFinish` callback — without needing to thread the
|
|
897
|
+
* `stopSignal` through closures.
|
|
898
|
+
*
|
|
899
|
+
* This is especially useful when the AI SDK's `isAborted` flag is unreliable
|
|
900
|
+
* (e.g. when using `createUIMessageStream` + `writer.merge()`).
|
|
901
|
+
*
|
|
902
|
+
* @example
|
|
903
|
+
* ```ts
|
|
904
|
+
* onFinish: ({ isAborted }) => {
|
|
905
|
+
* const wasStopped = isAborted || chat.isStopped();
|
|
906
|
+
* if (wasStopped) {
|
|
907
|
+
* // handle stop
|
|
908
|
+
* }
|
|
909
|
+
* }
|
|
910
|
+
* ```
|
|
911
|
+
*/
|
|
912
|
+
function isStopped() {
|
|
913
|
+
const controller = locals_js_1.locals.get(chatStopControllerKey);
|
|
914
|
+
return controller?.signal.aborted ?? false;
|
|
915
|
+
}
|
|
916
|
+
// ---------------------------------------------------------------------------
|
|
917
|
+
// Per-turn deferred work
|
|
918
|
+
// ---------------------------------------------------------------------------
|
|
919
|
+
/**
|
|
920
|
+
* Register a promise that runs in the background during the current turn.
|
|
921
|
+
*
|
|
922
|
+
* Use this to move non-blocking work (DB writes, analytics, etc.) out of
|
|
923
|
+
* the critical path. The promise runs in parallel with streaming and is
|
|
924
|
+
* awaited (with a 5 s timeout) before `onTurnComplete` fires.
|
|
925
|
+
*
|
|
926
|
+
* @example
|
|
927
|
+
* ```ts
|
|
928
|
+
* onTurnStart: async ({ chatId, uiMessages }) => {
|
|
929
|
+
* // Persist messages without blocking the LLM call
|
|
930
|
+
* chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }));
|
|
931
|
+
* },
|
|
932
|
+
* ```
|
|
933
|
+
*/
|
|
934
|
+
function chatDefer(promise) {
|
|
935
|
+
const promises = locals_js_1.locals.get(chatDeferKey);
|
|
936
|
+
if (promises) {
|
|
937
|
+
promises.add(promise);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// ---------------------------------------------------------------------------
|
|
941
|
+
// Aborted message cleanup
|
|
942
|
+
// ---------------------------------------------------------------------------
|
|
943
|
+
/**
|
|
944
|
+
* Clean up a UIMessage that was captured during an aborted/stopped turn.
|
|
945
|
+
*
|
|
946
|
+
* When generation is stopped mid-stream, the captured message may contain:
|
|
947
|
+
* - Tool parts stuck in incomplete states (`partial-call`, `input-available`,
|
|
948
|
+
* `input-streaming`) that cause permanent UI spinners
|
|
949
|
+
* - Reasoning parts with `state: "streaming"` instead of `"done"`
|
|
950
|
+
* - Text parts with `state: "streaming"` instead of `"done"`
|
|
951
|
+
*
|
|
952
|
+
* This function returns a cleaned copy with:
|
|
953
|
+
* - Incomplete tool parts removed entirely
|
|
954
|
+
* - Reasoning and text parts marked as `"done"`
|
|
955
|
+
*
|
|
956
|
+
* `chat.task` calls this automatically when stop is detected before passing
|
|
957
|
+
* the response to `onTurnComplete`. Use this manually when calling `pipeChat`
|
|
958
|
+
* directly and capturing response messages yourself.
|
|
959
|
+
*
|
|
960
|
+
* @example
|
|
961
|
+
* ```ts
|
|
962
|
+
* onTurnComplete: async ({ responseMessage, stopped }) => {
|
|
963
|
+
* // Already cleaned automatically by chat.task — but if you captured
|
|
964
|
+
* // your own message via pipeChat, clean it manually:
|
|
965
|
+
* const cleaned = chat.cleanupAbortedParts(myMessage);
|
|
966
|
+
* await db.messages.save(cleaned);
|
|
967
|
+
* }
|
|
968
|
+
* ```
|
|
969
|
+
*/
|
|
970
|
+
function cleanupAbortedParts(message) {
|
|
971
|
+
if (!message.parts)
|
|
972
|
+
return message;
|
|
973
|
+
const isToolPart = (part) => part.type === "tool-invocation" ||
|
|
974
|
+
part.type?.startsWith("tool-") ||
|
|
975
|
+
part.type === "dynamic-tool";
|
|
976
|
+
return {
|
|
977
|
+
...message,
|
|
978
|
+
parts: message.parts
|
|
979
|
+
.filter((part) => {
|
|
980
|
+
if (!isToolPart(part))
|
|
981
|
+
return true;
|
|
982
|
+
// Remove tool parts that never completed execution.
|
|
983
|
+
// partial-call: input was still streaming when aborted.
|
|
984
|
+
// input-available: input was complete but tool never ran.
|
|
985
|
+
// input-streaming: input was mid-stream.
|
|
986
|
+
const state = part.toolInvocation?.state ?? part.state;
|
|
987
|
+
return state !== "partial-call" && state !== "input-available" && state !== "input-streaming";
|
|
988
|
+
})
|
|
989
|
+
.map((part) => {
|
|
990
|
+
// Mark streaming reasoning as done
|
|
991
|
+
if (part.type === "reasoning" && part.state === "streaming") {
|
|
992
|
+
return { ...part, state: "done" };
|
|
993
|
+
}
|
|
994
|
+
// Mark streaming text as done
|
|
995
|
+
if (part.type === "text" && part.state === "streaming") {
|
|
996
|
+
return { ...part, state: "done" };
|
|
997
|
+
}
|
|
998
|
+
return part;
|
|
999
|
+
}),
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
// ---------------------------------------------------------------------------
|
|
1003
|
+
// Composable primitives for raw task chat
|
|
1004
|
+
// ---------------------------------------------------------------------------
|
|
1005
|
+
/**
|
|
1006
|
+
* Create a managed stop signal wired to the chat stop input stream.
|
|
1007
|
+
*
|
|
1008
|
+
* Call once at the start of your run. Use `signal` as the abort signal for
|
|
1009
|
+
* `streamText`. Call `reset()` at the start of each turn to get a fresh
|
|
1010
|
+
* per-turn signal. Call `cleanup()` when the run ends.
|
|
1011
|
+
*
|
|
1012
|
+
* @example
|
|
1013
|
+
* ```ts
|
|
1014
|
+
* const stop = chat.createStopSignal();
|
|
1015
|
+
* for (let turn = 0; turn < 100; turn++) {
|
|
1016
|
+
* stop.reset();
|
|
1017
|
+
* const result = streamText({ model, messages, abortSignal: stop.signal });
|
|
1018
|
+
* await chat.pipe(result);
|
|
1019
|
+
* // ...
|
|
1020
|
+
* }
|
|
1021
|
+
* stop.cleanup();
|
|
1022
|
+
* ```
|
|
1023
|
+
*/
|
|
1024
|
+
function createStopSignal() {
|
|
1025
|
+
let controller = new AbortController();
|
|
1026
|
+
const sub = stopInput.on((data) => {
|
|
1027
|
+
controller.abort(data?.message || "stopped");
|
|
1028
|
+
});
|
|
1029
|
+
return {
|
|
1030
|
+
get signal() { return controller.signal; },
|
|
1031
|
+
reset() { controller = new AbortController(); },
|
|
1032
|
+
cleanup() { sub.off(); },
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Signal the frontend that the current turn is complete.
|
|
1037
|
+
*
|
|
1038
|
+
* The `TriggerChatTransport` intercepts this to close the ReadableStream
|
|
1039
|
+
* for the current turn. Call after piping the response stream.
|
|
1040
|
+
*
|
|
1041
|
+
* @example
|
|
1042
|
+
* ```ts
|
|
1043
|
+
* await chat.pipe(result);
|
|
1044
|
+
* await chat.writeTurnComplete();
|
|
1045
|
+
* ```
|
|
1046
|
+
*/
|
|
1047
|
+
async function chatWriteTurnComplete(options) {
|
|
1048
|
+
await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Pipe a `StreamTextResult` (or similar) to the chat stream and capture
|
|
1052
|
+
* the assistant's response message via `onFinish`.
|
|
1053
|
+
*
|
|
1054
|
+
* Combines `toUIMessageStream()` + `onFinish` callback + `chat.pipe()`.
|
|
1055
|
+
* Returns the captured `UIMessage`, or `undefined` if capture failed.
|
|
1056
|
+
*
|
|
1057
|
+
* @example
|
|
1058
|
+
* ```ts
|
|
1059
|
+
* const result = streamText({ model, messages, abortSignal: signal });
|
|
1060
|
+
* const response = await chat.pipeAndCapture(result, { signal });
|
|
1061
|
+
* if (response) conversation.addResponse(response);
|
|
1062
|
+
* ```
|
|
1063
|
+
*/
|
|
1064
|
+
async function pipeChatAndCapture(source, options) {
|
|
1065
|
+
let captured;
|
|
1066
|
+
let resolveOnFinish;
|
|
1067
|
+
const onFinishPromise = new Promise((r) => { resolveOnFinish = r; });
|
|
1068
|
+
const uiStream = source.toUIMessageStream({
|
|
1069
|
+
...resolveUIMessageStreamOptions(),
|
|
1070
|
+
onFinish: ({ responseMessage }) => {
|
|
1071
|
+
captured = responseMessage;
|
|
1072
|
+
resolveOnFinish();
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
await pipeChat(uiStream, { signal: options?.signal, spanName: options?.spanName ?? "stream response" });
|
|
1076
|
+
await onFinishPromise;
|
|
1077
|
+
return captured;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Accumulates conversation messages across turns.
|
|
1081
|
+
*
|
|
1082
|
+
* Handles the transport protocol: turn 0 sends full history (replace),
|
|
1083
|
+
* subsequent turns send only new messages (append), regenerate sends
|
|
1084
|
+
* full history minus last assistant message (replace).
|
|
1085
|
+
*
|
|
1086
|
+
* @example
|
|
1087
|
+
* ```ts
|
|
1088
|
+
* const conversation = new chat.MessageAccumulator();
|
|
1089
|
+
* for (let turn = 0; turn < 100; turn++) {
|
|
1090
|
+
* const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn);
|
|
1091
|
+
* const result = streamText({ model, messages });
|
|
1092
|
+
* const response = await chat.pipeAndCapture(result);
|
|
1093
|
+
* if (response) await conversation.addResponse(response);
|
|
1094
|
+
* }
|
|
1095
|
+
* ```
|
|
1096
|
+
*/
|
|
1097
|
+
class ChatMessageAccumulator {
|
|
1098
|
+
modelMessages = [];
|
|
1099
|
+
uiMessages = [];
|
|
1100
|
+
/**
|
|
1101
|
+
* Add incoming messages from the transport payload.
|
|
1102
|
+
* Returns the full accumulated model messages for `streamText`.
|
|
1103
|
+
*/
|
|
1104
|
+
async addIncoming(messages, trigger, turn) {
|
|
1105
|
+
const cleaned = messages.map((m) => m.role === "assistant" ? cleanupAbortedParts(m) : m);
|
|
1106
|
+
const model = await (0, ai_1.convertToModelMessages)(cleaned);
|
|
1107
|
+
if (turn === 0 || trigger === "regenerate-message") {
|
|
1108
|
+
this.modelMessages = model;
|
|
1109
|
+
this.uiMessages = [...cleaned];
|
|
1110
|
+
}
|
|
1111
|
+
else {
|
|
1112
|
+
this.modelMessages.push(...model);
|
|
1113
|
+
this.uiMessages.push(...cleaned);
|
|
1114
|
+
}
|
|
1115
|
+
return this.modelMessages;
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Add the assistant's response to the accumulator.
|
|
1119
|
+
* Call after `pipeAndCapture` with the captured response.
|
|
1120
|
+
*/
|
|
1121
|
+
async addResponse(response) {
|
|
1122
|
+
if (!response.id) {
|
|
1123
|
+
response = { ...response, id: (0, ai_1.generateId)() };
|
|
1124
|
+
}
|
|
1125
|
+
this.uiMessages.push(response);
|
|
1126
|
+
try {
|
|
1127
|
+
const msgs = await (0, ai_1.convertToModelMessages)([stripProviderMetadata(response)]);
|
|
1128
|
+
this.modelMessages.push(...msgs);
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
// Conversion failed — skip model message accumulation for this response
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
/**
|
|
1136
|
+
* Create a chat session that yields turns as an async iterator.
|
|
1137
|
+
*
|
|
1138
|
+
* Handles: preload wait, stop signals, message accumulation, turn-complete
|
|
1139
|
+
* signaling, and warm/suspend between turns. You control: initialization,
|
|
1140
|
+
* model/tool selection, persistence, and any custom per-turn logic.
|
|
1141
|
+
*
|
|
1142
|
+
* @example
|
|
1143
|
+
* ```ts
|
|
1144
|
+
* import { task } from "@trigger.dev/sdk";
|
|
1145
|
+
* import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai";
|
|
1146
|
+
* import { streamText } from "ai";
|
|
1147
|
+
* import { openai } from "@ai-sdk/openai";
|
|
1148
|
+
*
|
|
1149
|
+
* export const myChat = task({
|
|
1150
|
+
* id: "my-chat",
|
|
1151
|
+
* run: async (payload: ChatTaskWirePayload, { signal }) => {
|
|
1152
|
+
* const session = chat.createSession(payload, { signal });
|
|
1153
|
+
*
|
|
1154
|
+
* for await (const turn of session) {
|
|
1155
|
+
* const result = streamText({
|
|
1156
|
+
* model: openai("gpt-4o"),
|
|
1157
|
+
* messages: turn.messages,
|
|
1158
|
+
* abortSignal: turn.signal,
|
|
1159
|
+
* });
|
|
1160
|
+
* await turn.complete(result);
|
|
1161
|
+
* }
|
|
1162
|
+
* },
|
|
1163
|
+
* });
|
|
1164
|
+
* ```
|
|
1165
|
+
*/
|
|
1166
|
+
function createChatSession(payload, options) {
|
|
1167
|
+
const { signal: runSignal, warmTimeoutInSeconds = 30, timeout = "1h", maxTurns = 100, } = options;
|
|
1168
|
+
return {
|
|
1169
|
+
[Symbol.asyncIterator]() {
|
|
1170
|
+
let currentPayload = payload;
|
|
1171
|
+
let turn = -1;
|
|
1172
|
+
const stop = createStopSignal();
|
|
1173
|
+
const accumulator = new ChatMessageAccumulator();
|
|
1174
|
+
return {
|
|
1175
|
+
async next() {
|
|
1176
|
+
turn++;
|
|
1177
|
+
// First turn: handle preload — wait for the first real message
|
|
1178
|
+
if (turn === 0 && currentPayload.trigger === "preload") {
|
|
1179
|
+
const result = await messagesInput.waitWithWarmup({
|
|
1180
|
+
warmTimeoutInSeconds: currentPayload.warmTimeoutInSeconds ?? warmTimeoutInSeconds,
|
|
1181
|
+
timeout,
|
|
1182
|
+
spanName: "waiting for first message",
|
|
1183
|
+
});
|
|
1184
|
+
if (!result.ok || runSignal.aborted) {
|
|
1185
|
+
stop.cleanup();
|
|
1186
|
+
return { done: true, value: undefined };
|
|
1187
|
+
}
|
|
1188
|
+
currentPayload = result.output;
|
|
1189
|
+
}
|
|
1190
|
+
// Subsequent turns: wait for the next message
|
|
1191
|
+
if (turn > 0) {
|
|
1192
|
+
const next = await messagesInput.waitWithWarmup({
|
|
1193
|
+
warmTimeoutInSeconds,
|
|
1194
|
+
timeout,
|
|
1195
|
+
spanName: "waiting for next message",
|
|
1196
|
+
});
|
|
1197
|
+
if (!next.ok || runSignal.aborted) {
|
|
1198
|
+
stop.cleanup();
|
|
1199
|
+
return { done: true, value: undefined };
|
|
1200
|
+
}
|
|
1201
|
+
currentPayload = next.output;
|
|
1202
|
+
}
|
|
1203
|
+
// Check limits
|
|
1204
|
+
if (turn >= maxTurns || runSignal.aborted) {
|
|
1205
|
+
stop.cleanup();
|
|
1206
|
+
return { done: true, value: undefined };
|
|
1207
|
+
}
|
|
1208
|
+
// Reset stop signal for this turn
|
|
1209
|
+
stop.reset();
|
|
1210
|
+
// Accumulate messages
|
|
1211
|
+
const messages = await accumulator.addIncoming(currentPayload.messages, currentPayload.trigger, turn);
|
|
1212
|
+
const combinedSignal = AbortSignal.any([runSignal, stop.signal]);
|
|
1213
|
+
const turnObj = {
|
|
1214
|
+
number: turn,
|
|
1215
|
+
chatId: currentPayload.chatId,
|
|
1216
|
+
trigger: currentPayload.trigger,
|
|
1217
|
+
clientData: currentPayload.metadata,
|
|
1218
|
+
messages,
|
|
1219
|
+
uiMessages: accumulator.uiMessages,
|
|
1220
|
+
signal: combinedSignal,
|
|
1221
|
+
get stopped() { return stop.signal.aborted && !runSignal.aborted; },
|
|
1222
|
+
continuation: currentPayload.continuation ?? false,
|
|
1223
|
+
async complete(source) {
|
|
1224
|
+
let response;
|
|
1225
|
+
try {
|
|
1226
|
+
response = await pipeChatAndCapture(source, { signal: combinedSignal });
|
|
1227
|
+
}
|
|
1228
|
+
catch (error) {
|
|
1229
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
1230
|
+
if (runSignal.aborted) {
|
|
1231
|
+
// Full cancel — don't accumulate
|
|
1232
|
+
await chatWriteTurnComplete();
|
|
1233
|
+
return undefined;
|
|
1234
|
+
}
|
|
1235
|
+
// Stop — fall through to accumulate partial response
|
|
1236
|
+
}
|
|
1237
|
+
else {
|
|
1238
|
+
throw error;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
if (response) {
|
|
1242
|
+
const cleaned = (stop.signal.aborted && !runSignal.aborted)
|
|
1243
|
+
? cleanupAbortedParts(response)
|
|
1244
|
+
: response;
|
|
1245
|
+
await accumulator.addResponse(cleaned);
|
|
1246
|
+
}
|
|
1247
|
+
await chatWriteTurnComplete();
|
|
1248
|
+
return response;
|
|
1249
|
+
},
|
|
1250
|
+
async addResponse(response) {
|
|
1251
|
+
await accumulator.addResponse(response);
|
|
1252
|
+
},
|
|
1253
|
+
async done() {
|
|
1254
|
+
await chatWriteTurnComplete();
|
|
1255
|
+
},
|
|
1256
|
+
};
|
|
1257
|
+
return { done: false, value: turnObj };
|
|
1258
|
+
},
|
|
1259
|
+
async return() {
|
|
1260
|
+
stop.cleanup();
|
|
1261
|
+
return { done: true, value: undefined };
|
|
1262
|
+
},
|
|
1263
|
+
};
|
|
1264
|
+
},
|
|
1265
|
+
};
|
|
1266
|
+
}
|
|
592
1267
|
// ---------------------------------------------------------------------------
|
|
593
1268
|
// chat.local — per-run typed data with Proxy access
|
|
594
1269
|
// ---------------------------------------------------------------------------
|
|
@@ -596,8 +1271,25 @@ function setWarmTimeoutInSeconds(seconds) {
|
|
|
596
1271
|
const CHAT_LOCAL_KEY = Symbol("chatLocalKey");
|
|
597
1272
|
/** @internal Symbol for storing the dirty-tracking locals key. */
|
|
598
1273
|
const CHAT_LOCAL_DIRTY_KEY = Symbol("chatLocalDirtyKey");
|
|
599
|
-
|
|
600
|
-
|
|
1274
|
+
const chatLocalRegistry = new Set();
|
|
1275
|
+
/** @internal Run-scoped flag to ensure hydration happens at most once per run. */
|
|
1276
|
+
const chatLocalsHydratedKey = locals_js_1.locals.create("chat.locals.hydrated");
|
|
1277
|
+
/**
|
|
1278
|
+
* Hydrate chat.local values from subtask metadata (set by toolFromTask).
|
|
1279
|
+
* Runs once per run — subsequent calls are no-ops.
|
|
1280
|
+
* @internal
|
|
1281
|
+
*/
|
|
1282
|
+
function hydrateLocalsFromMetadata() {
|
|
1283
|
+
if (locals_js_1.locals.get(chatLocalsHydratedKey))
|
|
1284
|
+
return;
|
|
1285
|
+
locals_js_1.locals.set(chatLocalsHydratedKey, true);
|
|
1286
|
+
const opts = metadata_js_1.metadata.get(METADATA_KEY);
|
|
1287
|
+
if (!opts?.chatLocals)
|
|
1288
|
+
return;
|
|
1289
|
+
for (const [id, value] of Object.entries(opts.chatLocals)) {
|
|
1290
|
+
locals_js_1.locals.set(locals_js_1.locals.create(id), value);
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
601
1293
|
/**
|
|
602
1294
|
* Creates a per-run typed data object accessible from anywhere during task execution.
|
|
603
1295
|
*
|
|
@@ -606,12 +1298,16 @@ let chatLocalCounter = 0;
|
|
|
606
1298
|
*
|
|
607
1299
|
* Multiple locals can coexist — each gets its own isolated run-scoped storage.
|
|
608
1300
|
*
|
|
1301
|
+
* The `id` is required and must be unique across all `chat.local()` calls in
|
|
1302
|
+
* your project. It's used to serialize values into subtask metadata so that
|
|
1303
|
+
* `ai.tool()` subtasks can auto-hydrate parent locals (read-only).
|
|
1304
|
+
*
|
|
609
1305
|
* @example
|
|
610
1306
|
* ```ts
|
|
611
1307
|
* import { chat } from "@trigger.dev/sdk/ai";
|
|
612
1308
|
*
|
|
613
|
-
* const userPrefs = chat.local<{ theme: string; language: string }>();
|
|
614
|
-
* const gameState = chat.local<{ score: number; streak: number }>();
|
|
1309
|
+
* const userPrefs = chat.local<{ theme: string; language: string }>({ id: "userPrefs" });
|
|
1310
|
+
* const gameState = chat.local<{ score: number; streak: number }>({ id: "gameState" });
|
|
615
1311
|
*
|
|
616
1312
|
* export const myChat = chat.task({
|
|
617
1313
|
* id: "my-chat",
|
|
@@ -635,9 +1331,11 @@ let chatLocalCounter = 0;
|
|
|
635
1331
|
* });
|
|
636
1332
|
* ```
|
|
637
1333
|
*/
|
|
638
|
-
function chatLocal() {
|
|
639
|
-
const
|
|
640
|
-
const
|
|
1334
|
+
function chatLocal(options) {
|
|
1335
|
+
const id = `chat.local.${options.id}`;
|
|
1336
|
+
const localKey = locals_js_1.locals.create(id);
|
|
1337
|
+
const dirtyKey = locals_js_1.locals.create(`${id}.dirty`);
|
|
1338
|
+
chatLocalRegistry.add({ key: localKey, id });
|
|
641
1339
|
const target = {};
|
|
642
1340
|
target[CHAT_LOCAL_KEY] = localKey;
|
|
643
1341
|
target[CHAT_LOCAL_DIRTY_KEY] = dirtyKey;
|
|
@@ -664,7 +1362,11 @@ function chatLocal() {
|
|
|
664
1362
|
}
|
|
665
1363
|
if (prop === "get") {
|
|
666
1364
|
return () => {
|
|
667
|
-
|
|
1365
|
+
let current = locals_js_1.locals.get(localKey);
|
|
1366
|
+
if (current === undefined) {
|
|
1367
|
+
hydrateLocalsFromMetadata();
|
|
1368
|
+
current = locals_js_1.locals.get(localKey);
|
|
1369
|
+
}
|
|
668
1370
|
if (current === undefined) {
|
|
669
1371
|
throw new Error("local.get() called before initialization. Call local.init() first.");
|
|
670
1372
|
}
|
|
@@ -674,11 +1376,20 @@ function chatLocal() {
|
|
|
674
1376
|
// toJSON for serialization (JSON.stringify(local))
|
|
675
1377
|
if (prop === "toJSON") {
|
|
676
1378
|
return () => {
|
|
677
|
-
|
|
1379
|
+
let current = locals_js_1.locals.get(localKey);
|
|
1380
|
+
if (current === undefined) {
|
|
1381
|
+
hydrateLocalsFromMetadata();
|
|
1382
|
+
current = locals_js_1.locals.get(localKey);
|
|
1383
|
+
}
|
|
678
1384
|
return current ? { ...current } : undefined;
|
|
679
1385
|
};
|
|
680
1386
|
}
|
|
681
|
-
|
|
1387
|
+
let current = locals_js_1.locals.get(localKey);
|
|
1388
|
+
if (current === undefined) {
|
|
1389
|
+
// Auto-hydrate from parent metadata in subtask context
|
|
1390
|
+
hydrateLocalsFromMetadata();
|
|
1391
|
+
current = locals_js_1.locals.get(localKey);
|
|
1392
|
+
}
|
|
682
1393
|
if (current === undefined)
|
|
683
1394
|
return undefined;
|
|
684
1395
|
return current[prop];
|
|
@@ -699,17 +1410,29 @@ function chatLocal() {
|
|
|
699
1410
|
has(_target, prop) {
|
|
700
1411
|
if (typeof prop === "symbol")
|
|
701
1412
|
return prop in _target;
|
|
702
|
-
|
|
1413
|
+
let current = locals_js_1.locals.get(localKey);
|
|
1414
|
+
if (current === undefined) {
|
|
1415
|
+
hydrateLocalsFromMetadata();
|
|
1416
|
+
current = locals_js_1.locals.get(localKey);
|
|
1417
|
+
}
|
|
703
1418
|
return current !== undefined && prop in current;
|
|
704
1419
|
},
|
|
705
1420
|
ownKeys() {
|
|
706
|
-
|
|
1421
|
+
let current = locals_js_1.locals.get(localKey);
|
|
1422
|
+
if (current === undefined) {
|
|
1423
|
+
hydrateLocalsFromMetadata();
|
|
1424
|
+
current = locals_js_1.locals.get(localKey);
|
|
1425
|
+
}
|
|
707
1426
|
return current ? Reflect.ownKeys(current) : [];
|
|
708
1427
|
},
|
|
709
1428
|
getOwnPropertyDescriptor(_target, prop) {
|
|
710
1429
|
if (typeof prop === "symbol")
|
|
711
1430
|
return undefined;
|
|
712
|
-
|
|
1431
|
+
let current = locals_js_1.locals.get(localKey);
|
|
1432
|
+
if (current === undefined) {
|
|
1433
|
+
hydrateLocalsFromMetadata();
|
|
1434
|
+
current = locals_js_1.locals.get(localKey);
|
|
1435
|
+
}
|
|
713
1436
|
if (current === undefined || !(prop in current))
|
|
714
1437
|
return undefined;
|
|
715
1438
|
return {
|
|
@@ -736,6 +1459,28 @@ exports.chat = {
|
|
|
736
1459
|
setTurnTimeoutInSeconds,
|
|
737
1460
|
/** Override the warm timeout at runtime. See {@link setWarmTimeoutInSeconds}. */
|
|
738
1461
|
setWarmTimeoutInSeconds,
|
|
1462
|
+
/** Override toUIMessageStream() options for the current turn. See {@link setUIMessageStreamOptions}. */
|
|
1463
|
+
setUIMessageStreamOptions,
|
|
1464
|
+
/** Check if the current turn was stopped by the user. See {@link isStopped}. */
|
|
1465
|
+
isStopped,
|
|
1466
|
+
/** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
|
|
1467
|
+
cleanupAbortedParts,
|
|
1468
|
+
/** Register background work that runs in parallel with streaming. See {@link chatDefer}. */
|
|
1469
|
+
defer: chatDefer,
|
|
1470
|
+
/** Typed chat output stream for writing custom chunks or piping from subtasks. */
|
|
1471
|
+
stream: chatStream,
|
|
1472
|
+
/** Pre-built input stream for receiving messages from the transport. */
|
|
1473
|
+
messages: messagesInput,
|
|
1474
|
+
/** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */
|
|
1475
|
+
createStopSignal,
|
|
1476
|
+
/** Signal the frontend that the current turn is complete. See {@link chatWriteTurnComplete}. */
|
|
1477
|
+
writeTurnComplete: chatWriteTurnComplete,
|
|
1478
|
+
/** Pipe a stream and capture the response message. See {@link pipeChatAndCapture}. */
|
|
1479
|
+
pipeAndCapture: pipeChatAndCapture,
|
|
1480
|
+
/** Message accumulator class for raw task chat. See {@link ChatMessageAccumulator}. */
|
|
1481
|
+
MessageAccumulator: ChatMessageAccumulator,
|
|
1482
|
+
/** Create a chat session (async iterator). See {@link createChatSession}. */
|
|
1483
|
+
createSession: createChatSession,
|
|
739
1484
|
};
|
|
740
1485
|
/**
|
|
741
1486
|
* Writes a turn-complete control chunk to the chat output stream.
|