@trigger.dev/sdk 4.7.4 → 4.8.0-webhooks.20261020092920
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 +626 -8
- package/dist/commonjs/v3/ai.js +2343 -185
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +23 -0
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/index.d.ts +2 -0
- package/dist/commonjs/v3/index.js +23 -17
- package/dist/commonjs/v3/index.js.map +1 -1
- package/dist/commonjs/v3/modelFileDownload.d.ts +10 -0
- package/dist/commonjs/v3/modelFileDownload.js +164 -0
- package/dist/commonjs/v3/modelFileDownload.js.map +1 -0
- package/dist/commonjs/v3/query.d.ts +434 -1
- package/dist/commonjs/v3/query.js +115 -0
- package/dist/commonjs/v3/query.js.map +1 -1
- package/dist/commonjs/v3/test/index.d.ts +2 -1
- package/dist/commonjs/v3/test/index.js +4 -1
- package/dist/commonjs/v3/test/index.js.map +1 -1
- package/dist/commonjs/v3/test/mock-channel-connector.d.ts +172 -0
- package/dist/commonjs/v3/test/mock-channel-connector.js +173 -0
- package/dist/commonjs/v3/test/mock-channel-connector.js.map +1 -0
- package/dist/commonjs/v3/test/mock-chat-agent.d.ts +81 -1
- package/dist/commonjs/v3/test/mock-chat-agent.js +137 -10
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/commonjs/v3/transcriptStorage.d.ts +44 -1
- package/dist/commonjs/v3/transcriptStorage.js +24 -0
- package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
- package/dist/commonjs/v3/webhookWaiters.d.ts +80 -0
- package/dist/commonjs/v3/webhookWaiters.js +156 -0
- package/dist/commonjs/v3/webhookWaiters.js.map +1 -0
- package/dist/commonjs/v3/webhooks.d.ts +184 -2
- package/dist/commonjs/v3/webhooks.js +323 -1
- package/dist/commonjs/v3/webhooks.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +626 -8
- package/dist/esm/v3/ai.js +2336 -184
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +23 -0
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/index.d.ts +2 -0
- package/dist/esm/v3/index.js +1 -0
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/esm/v3/modelFileDownload.d.ts +10 -0
- package/dist/esm/v3/modelFileDownload.js +161 -0
- package/dist/esm/v3/modelFileDownload.js.map +1 -0
- package/dist/esm/v3/query.d.ts +434 -1
- package/dist/esm/v3/query.js +115 -0
- package/dist/esm/v3/query.js.map +1 -1
- package/dist/esm/v3/test/index.d.ts +2 -1
- package/dist/esm/v3/test/index.js +2 -1
- package/dist/esm/v3/test/index.js.map +1 -1
- package/dist/esm/v3/test/mock-channel-connector.d.ts +172 -0
- package/dist/esm/v3/test/mock-channel-connector.js +170 -0
- package/dist/esm/v3/test/mock-channel-connector.js.map +1 -0
- package/dist/esm/v3/test/mock-chat-agent.d.ts +81 -1
- package/dist/esm/v3/test/mock-chat-agent.js +137 -11
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/esm/v3/transcriptStorage.d.ts +44 -1
- package/dist/esm/v3/transcriptStorage.js +24 -0
- package/dist/esm/v3/transcriptStorage.js.map +1 -1
- package/dist/esm/v3/webhookWaiters.d.ts +80 -0
- package/dist/esm/v3/webhookWaiters.js +149 -0
- package/dist/esm/v3/webhookWaiters.js.map +1 -0
- package/dist/esm/v3/webhooks.d.ts +184 -2
- package/dist/esm/v3/webhooks.js +319 -1
- package/dist/esm/v3/webhooks.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/actions.mdx +6 -1
- package/docs/ai-chat/backend.mdx +40 -0
- package/docs/ai-chat/patterns/redacting-telemetry.mdx +117 -0
- package/docs/ai-chat/pending-messages.mdx +2 -0
- package/docs/ai-chat/reference.mdx +53 -2
- package/docs/ai-chat/transcript-storage.mdx +1 -1
- package/docs/apikeys.mdx +6 -4
- package/docs/cli-runs-commands.mdx +2 -2
- package/docs/cli-tasks-commands.mdx +95 -0
- package/docs/config/config-file.mdx +58 -0
- package/docs/config/extensions/prismaExtension.mdx +4 -4
- package/docs/deploy-environment-variables.mdx +4 -0
- package/docs/deployment/atomic-deployment.mdx +3 -3
- package/docs/deployment/overview.mdx +1 -1
- package/docs/deployment/preview-branches.mdx +1 -1
- package/docs/github-actions.mdx +3 -3
- package/docs/limits.mdx +6 -0
- package/docs/manual-setup.mdx +1 -1
- package/docs/mcp-tools.mdx +99 -3
- package/docs/observability/dashboards.mdx +62 -0
- package/docs/runs/max-duration.mdx +4 -0
- package/docs/self-hosting/env/supervisor.mdx +1 -0
- package/docs/webhooks/channels.mdx +132 -0
- package/docs/webhooks/connect.mdx +84 -0
- package/docs/webhooks/deliveries.mdx +96 -0
- package/docs/webhooks/filters.mdx +142 -0
- package/docs/webhooks/human-in-the-loop.mdx +155 -0
- package/docs/webhooks/linear.mdx +189 -0
- package/docs/webhooks/overview.mdx +103 -0
- package/docs/webhooks/session-routing.mdx +138 -0
- package/docs/webhooks/slack.mdx +368 -0
- package/docs/webhooks/sources.mdx +221 -0
- package/docs/webhooks/waiters.mdx +194 -0
- package/package.json +4 -2
package/dist/esm/v3/ai.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
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
|
+
import { chatEvent, normalizeKeyString } from "./webhooks.js";
|
|
3
|
+
import { fetchPublicFile } from "./modelFileDownload.js";
|
|
2
4
|
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
|
|
3
5
|
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
|
|
4
6
|
import { trace } from "@opentelemetry/api";
|
|
@@ -73,6 +75,14 @@ const chatSessionHandleKey = locals.create("chat.sessionHandle");
|
|
|
73
75
|
// is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
|
|
74
76
|
// metadata reads this directly rather than the Session handle id.
|
|
75
77
|
const chatExternalIdKey = locals.create("chat.externalId");
|
|
78
|
+
/**
|
|
79
|
+
* Set at boot when a continuation run inherited an interrupted turn (a
|
|
80
|
+
* partial assistant reply on `session.out`). Consumed by the first channel
|
|
81
|
+
* turn's ack so the re-emitted placeholder reads as a recovery resume rather
|
|
82
|
+
* than a silent duplicate. Boxed so the ack site can flip it to false.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
85
|
+
const chatChannelRecoveryPendingKey = locals.create("chat.channelRecoveryPending");
|
|
76
86
|
/**
|
|
77
87
|
* S2 seq_num of the most recent `turn-complete` control record written by
|
|
78
88
|
* this worker. Read by `writeTurnCompleteChunk` to know what to trim back
|
|
@@ -356,14 +366,23 @@ export function __setReplaySessionInTailImplForTests(impl) {
|
|
|
356
366
|
* @internal
|
|
357
367
|
*/
|
|
358
368
|
async function replaySessionInTail(sessionId, options) {
|
|
369
|
+
return (await replaySessionInTailWithChannels(sessionId, options)).messages;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The full unprocessed `session.in` tail: browser messages and channel deliveries, each with its
|
|
373
|
+
* seq_num, so recovery can dispatch them in the order they arrived rather than messages first.
|
|
374
|
+
*/
|
|
375
|
+
async function replaySessionInTailWithChannels(sessionId, options) {
|
|
359
376
|
if (replaySessionInTailImpl) {
|
|
360
|
-
|
|
377
|
+
const replayed = await replaySessionInTailImpl(sessionId, options);
|
|
378
|
+
return Array.isArray(replayed) ? { messages: replayed, channelEvents: [] } : replayed;
|
|
361
379
|
}
|
|
362
380
|
const apiClient = apiClientManager.clientOrThrow();
|
|
363
381
|
const response = await apiClient.readSessionStreamRecords(sessionId, "in", {
|
|
364
382
|
afterEventId: options?.lastEventId,
|
|
365
383
|
});
|
|
366
384
|
const out = [];
|
|
385
|
+
const channelEvents = [];
|
|
367
386
|
for (const record of response.records) {
|
|
368
387
|
// session.in writers POST `JSON.stringify(chunk)` directly; the
|
|
369
388
|
// webapp wraps that in `{ data: <string>, id }` and stores it on
|
|
@@ -390,15 +409,23 @@ async function replaySessionInTail(sessionId, options) {
|
|
|
390
409
|
if (!payload || payload.trigger !== "submit-message")
|
|
391
410
|
continue;
|
|
392
411
|
const message = payload.message;
|
|
393
|
-
if (!message || typeof message !== "object")
|
|
412
|
+
if (!message || typeof message !== "object") {
|
|
413
|
+
if (payload.channelEvent && typeof payload.channelEvent === "object") {
|
|
414
|
+
channelEvents.push({
|
|
415
|
+
channelEvent: payload.channelEvent,
|
|
416
|
+
metadata: payload.metadata,
|
|
417
|
+
seqNum: record.seqNum,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
394
420
|
continue;
|
|
421
|
+
}
|
|
395
422
|
out.push({
|
|
396
423
|
message: message,
|
|
397
424
|
metadata: payload.metadata,
|
|
398
425
|
seqNum: record.seqNum,
|
|
399
426
|
});
|
|
400
427
|
}
|
|
401
|
-
return out;
|
|
428
|
+
return { messages: out, channelEvents };
|
|
402
429
|
}
|
|
403
430
|
/**
|
|
404
431
|
* Test-only entry point that bypasses
|
|
@@ -659,6 +686,37 @@ export const ai = {
|
|
|
659
686
|
function createChatAccessToken(taskId) {
|
|
660
687
|
return auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
|
|
661
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`.
|
|
691
|
+
*
|
|
692
|
+
* A session is addressed by `externalId`, so the same session is reachable from any surface that
|
|
693
|
+
* knows it: a web `useChat` client and a Slack thread converge on one session when they share an
|
|
694
|
+
* `externalId` (for a channel, the connector's `key` template produces it). This token carries
|
|
695
|
+
* `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out`
|
|
696
|
+
* stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`.
|
|
697
|
+
*
|
|
698
|
+
* Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side
|
|
699
|
+
* (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint
|
|
700
|
+
* a read+write token instead ({@link createChatStartSessionAction}).
|
|
701
|
+
*
|
|
702
|
+
* @example
|
|
703
|
+
* ```ts
|
|
704
|
+
* // actions.ts
|
|
705
|
+
* "use server";
|
|
706
|
+
* import { chat } from "@trigger.dev/sdk/ai";
|
|
707
|
+
*
|
|
708
|
+
* export const watchChat = (externalId: string) => chat.createWatchToken(externalId);
|
|
709
|
+
* ```
|
|
710
|
+
*/
|
|
711
|
+
function createChatWatchToken(externalId, options) {
|
|
712
|
+
if (!externalId) {
|
|
713
|
+
throw new Error("chat.createWatchToken: externalId is required (the session addressing key).");
|
|
714
|
+
}
|
|
715
|
+
return auth.createPublicToken({
|
|
716
|
+
scopes: { read: { sessions: externalId } },
|
|
717
|
+
expirationTime: options?.tokenTTL ?? "1h",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
662
720
|
function createChatOutGate() {
|
|
663
721
|
let resolveOpened;
|
|
664
722
|
const opened = new Promise((resolve) => {
|
|
@@ -1895,7 +1953,16 @@ const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsCons
|
|
|
1895
1953
|
* @internal
|
|
1896
1954
|
*/
|
|
1897
1955
|
const chatPipeCountKey = locals.create("chat.pipeCount");
|
|
1956
|
+
/**
|
|
1957
|
+
* The current turn's channel observer tap, set before run() when the turn has channel observers
|
|
1958
|
+
* (ack stream edits, tool progress) and cleared at the turn boundary. `pipeChat` applies it so a
|
|
1959
|
+
* manual `chat.pipe()` inside run() feeds the same observers as the automatic pipe.
|
|
1960
|
+
* @internal
|
|
1961
|
+
*/
|
|
1962
|
+
const chatTurnChannelTapKey = locals.create("chat.turnChannelTap");
|
|
1898
1963
|
const chatStopControllerKey = locals.create("chat.stopController");
|
|
1964
|
+
const chatChannelSurfaceKey = locals.create("chat.channelSurface");
|
|
1965
|
+
const chatChannelDownloadKey = locals.create("chat.channelDownload");
|
|
1899
1966
|
/** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */
|
|
1900
1967
|
const chatUIStreamStaticKey = locals.create("chat.uiMessageStreamOptions.static");
|
|
1901
1968
|
/** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
|
|
@@ -2867,7 +2934,6 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2867
2934
|
// Span attributes describe the offered batch; the chunk and callback below
|
|
2868
2935
|
// describe what was actually claimed and injected.
|
|
2869
2936
|
const messageTexts = uiMessages.map(textOfUIMessage);
|
|
2870
|
-
const _previewText = messageTexts.length === 1 ? messageTexts[0].slice(0, 80) : `${queue.length} messages`;
|
|
2871
2937
|
return tracer.startActiveSpan("pending message injected", async () => {
|
|
2872
2938
|
/**
|
|
2873
2939
|
* Claim before transforming, and inject only what was claimed.
|
|
@@ -3006,7 +3072,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
3006
3072
|
[SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
|
|
3007
3073
|
"pending.message_count": uiMessages.length,
|
|
3008
3074
|
"pending.step_number": stepNumber,
|
|
3009
|
-
"pending.messages": messageTexts,
|
|
3075
|
+
...(ctx?.recordUserMessage === false ? {} : { "pending.messages": messageTexts }),
|
|
3010
3076
|
...(ctx?.chatId ? { "pending.chat_id": ctx.chatId } : {}),
|
|
3011
3077
|
...(ctx?.turn != null ? { "pending.turn": ctx.turn } : {}),
|
|
3012
3078
|
...accessoryAttributes({
|
|
@@ -3271,7 +3337,7 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3271
3337
|
* caller's value winning because `rest` is spread after `managed`. Pulling a
|
|
3272
3338
|
* key out to "handle" it is how a caller's option gets silently dropped.
|
|
3273
3339
|
*/
|
|
3274
|
-
const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
|
|
3340
|
+
const { tools, system: callerSystem, prepareStep: callerPrepareStep, experimental_download: callerDownload, ...rest } = options;
|
|
3275
3341
|
const managed = toStreamTextOptions({
|
|
3276
3342
|
registry,
|
|
3277
3343
|
system: callerSystem ?? agentSystem,
|
|
@@ -3314,6 +3380,12 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3314
3380
|
if (typeof managed.prepareStep === "function") {
|
|
3315
3381
|
managed.prepareStep = retainStepMessages(managed.prepareStep);
|
|
3316
3382
|
}
|
|
3383
|
+
if (typeof callerDownload === "function") {
|
|
3384
|
+
const channelDownload = locals.get(chatChannelDownloadKey);
|
|
3385
|
+
managed.experimental_download = channelDownload
|
|
3386
|
+
? channelAwareDownload(channelDownload, callerDownload)
|
|
3387
|
+
: callerDownload;
|
|
3388
|
+
}
|
|
3317
3389
|
return { ...managed, ...rest };
|
|
3318
3390
|
}
|
|
3319
3391
|
/** @internal Test hook for {@link buildManagedStreamTextOptions}. */
|
|
@@ -3543,6 +3615,10 @@ function toStreamTextOptions(options, retain = true) {
|
|
|
3543
3615
|
if (retain && typeof result.prepareStep === "function") {
|
|
3544
3616
|
result.prepareStep = retainStepMessages(result.prepareStep);
|
|
3545
3617
|
}
|
|
3618
|
+
const channelDownload = locals.get(chatChannelDownloadKey);
|
|
3619
|
+
if (channelDownload) {
|
|
3620
|
+
result.experimental_download = channelAwareDownload(channelDownload);
|
|
3621
|
+
}
|
|
3546
3622
|
return result;
|
|
3547
3623
|
}
|
|
3548
3624
|
const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
@@ -3557,6 +3633,9 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3557
3633
|
* tools, steering, compaction, injected instructions and persistence, with no
|
|
3558
3634
|
* action-specific handling.
|
|
3559
3635
|
*
|
|
3636
|
+
* Pass `message` to have the turn answer a new user message, appended after the edit: a click on a
|
|
3637
|
+
* "Explain more" button becomes `chat.turn("Explain that in more detail.")`.
|
|
3638
|
+
*
|
|
3560
3639
|
* @example
|
|
3561
3640
|
* ```ts
|
|
3562
3641
|
* onAction: async ({ action }) => {
|
|
@@ -3564,12 +3643,13 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3564
3643
|
* chat.history.slice(0, -1);
|
|
3565
3644
|
* return chat.turn();
|
|
3566
3645
|
* }
|
|
3646
|
+
* if (action.type === "ask") return chat.turn(action.question);
|
|
3567
3647
|
* if (action.type === "undo") chat.history.slice(0, -2); // no turn
|
|
3568
3648
|
* },
|
|
3569
3649
|
* ```
|
|
3570
3650
|
*/
|
|
3571
|
-
function chatTurn() {
|
|
3572
|
-
return { [actionTurnBrand]: true };
|
|
3651
|
+
function chatTurn(message) {
|
|
3652
|
+
return (message === undefined ? { [actionTurnBrand]: true } : { [actionTurnBrand]: true, message });
|
|
3573
3653
|
}
|
|
3574
3654
|
function isActionTurn(value) {
|
|
3575
3655
|
return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
|
|
@@ -3635,6 +3715,21 @@ function warnMissingOnActionOnce() {
|
|
|
3635
3715
|
function isAsyncIterable(value) {
|
|
3636
3716
|
return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
|
|
3637
3717
|
}
|
|
3718
|
+
function readableStreamFromAsyncIterable(source) {
|
|
3719
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
3720
|
+
return new ReadableStream({
|
|
3721
|
+
async pull(controller) {
|
|
3722
|
+
const next = await iterator.next();
|
|
3723
|
+
if (next.done)
|
|
3724
|
+
controller.close();
|
|
3725
|
+
else
|
|
3726
|
+
controller.enqueue(next.value);
|
|
3727
|
+
},
|
|
3728
|
+
async cancel(reason) {
|
|
3729
|
+
await iterator.return?.(reason);
|
|
3730
|
+
},
|
|
3731
|
+
});
|
|
3732
|
+
}
|
|
3638
3733
|
function isReadableStream(value) {
|
|
3639
3734
|
return (typeof value === "object" && value !== null && typeof value.getReader === "function");
|
|
3640
3735
|
}
|
|
@@ -3690,6 +3785,10 @@ async function pipeChat(source, options, capture) {
|
|
|
3690
3785
|
throw new Error("pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " +
|
|
3691
3786
|
"an AsyncIterable, or a ReadableStream");
|
|
3692
3787
|
}
|
|
3788
|
+
const channelTap = locals.get(chatTurnChannelTapKey);
|
|
3789
|
+
if (channelTap) {
|
|
3790
|
+
stream = channelTap(isReadableStream(stream) ? stream : readableStreamFromAsyncIterable(stream));
|
|
3791
|
+
}
|
|
3693
3792
|
const pipeOptions = {};
|
|
3694
3793
|
if (options?.signal) {
|
|
3695
3794
|
pipeOptions.signal = options.signal;
|
|
@@ -3719,155 +3818,1069 @@ async function pipeChat(source, options, capture) {
|
|
|
3719
3818
|
await waitUntilComplete();
|
|
3720
3819
|
}
|
|
3721
3820
|
}
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3821
|
+
/** The AI SDK's default `toUIMessageStream` `onError`: the error's message, as the client sees it. */
|
|
3822
|
+
function uiStreamErrorText(error) {
|
|
3823
|
+
if (error == null)
|
|
3824
|
+
return "unknown error";
|
|
3825
|
+
if (typeof error === "string")
|
|
3826
|
+
return error;
|
|
3827
|
+
if (error instanceof Error)
|
|
3828
|
+
return error.message;
|
|
3829
|
+
return JSON.stringify(error);
|
|
3830
|
+
}
|
|
3831
|
+
/** The line a channel shows when a turn fails, unless the connector sets its own `errorMessage`. */
|
|
3832
|
+
export const CHANNEL_TURN_ERROR_MESSAGE = "Something went wrong answering this. Try again.";
|
|
3833
|
+
async function channelTurnErrorMessage(connector, ctx) {
|
|
3834
|
+
const option = connector?.errorMessage;
|
|
3835
|
+
if (typeof option !== "function")
|
|
3836
|
+
return option || CHANNEL_TURN_ERROR_MESSAGE;
|
|
3837
|
+
try {
|
|
3838
|
+
const line = await option({ ...ctx, defaultMessage: CHANNEL_TURN_ERROR_MESSAGE });
|
|
3839
|
+
if (line)
|
|
3840
|
+
return line;
|
|
3841
|
+
}
|
|
3842
|
+
catch (error) {
|
|
3843
|
+
logger.warn("chat.agent: channel errorMessage threw; posting the default line", { error });
|
|
3844
|
+
}
|
|
3845
|
+
return CHANNEL_TURN_ERROR_MESSAGE;
|
|
3846
|
+
}
|
|
3847
|
+
/**
|
|
3848
|
+
* A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
|
|
3849
|
+
* `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
|
|
3850
|
+
* control. Use for providers without a preset, or to test the channel round-trip end to end.
|
|
3851
|
+
*/
|
|
3852
|
+
export function chatChannelCustom(options) {
|
|
3853
|
+
const { id, endpoint, key, inbound, bind, seed, outbound, ack, send, renderInteraction, onInteraction, finalizeInteraction, react, reactions, progress, surface, onControl, stream, endInterruptedStream, download, received, toAction, lifecycle, cancelInteraction, filter, startOn, delivery, errorMessage, } = options;
|
|
3854
|
+
if (!endpoint?.id) {
|
|
3855
|
+
throw new Error(`chat.channels.custom("${id}"): an endpoint is required. Declare one with webhooks.endpoint.define() and pass it as { endpoint }.`);
|
|
3856
|
+
}
|
|
3857
|
+
resourceCatalog.registerDeclaredSessionWebhook(id);
|
|
3858
|
+
return {
|
|
3859
|
+
id,
|
|
3860
|
+
endpointId: endpoint.id,
|
|
3861
|
+
source: endpoint.source,
|
|
3862
|
+
key: normalizeKeyString(key),
|
|
3863
|
+
filter,
|
|
3864
|
+
startOn,
|
|
3865
|
+
inbound,
|
|
3866
|
+
bind,
|
|
3867
|
+
seed,
|
|
3868
|
+
outbound,
|
|
3869
|
+
ack,
|
|
3870
|
+
send,
|
|
3871
|
+
renderInteraction,
|
|
3872
|
+
onInteraction,
|
|
3873
|
+
finalizeInteraction,
|
|
3874
|
+
react,
|
|
3875
|
+
reactions,
|
|
3876
|
+
progress,
|
|
3877
|
+
surface,
|
|
3878
|
+
onControl,
|
|
3879
|
+
stream,
|
|
3880
|
+
endInterruptedStream,
|
|
3881
|
+
download,
|
|
3882
|
+
received,
|
|
3883
|
+
toAction,
|
|
3884
|
+
lifecycle,
|
|
3885
|
+
cancelInteraction,
|
|
3886
|
+
delivery: delivery ?? "final",
|
|
3887
|
+
errorMessage,
|
|
3888
|
+
};
|
|
3889
|
+
}
|
|
3890
|
+
const chatChannels = {
|
|
3891
|
+
/** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
|
|
3892
|
+
custom: chatChannelCustom,
|
|
3893
|
+
};
|
|
3894
|
+
// Prior surface messages from a connector's seed(), as transcript messages stamped with provenance.
|
|
3895
|
+
function channelSeedMessages(seed, provenance) {
|
|
3896
|
+
return seed
|
|
3897
|
+
.filter((m) => m && typeof m.text === "string" && m.text.length > 0)
|
|
3898
|
+
.map((m, index) => {
|
|
3899
|
+
const meta = {
|
|
3900
|
+
...provenance,
|
|
3901
|
+
seeded: true,
|
|
3902
|
+
...(m.externalUserId ? { externalUserId: m.externalUserId } : {}),
|
|
3903
|
+
...(m.externalMessageId ? { externalMessageId: m.externalMessageId } : {}),
|
|
3904
|
+
};
|
|
3905
|
+
return {
|
|
3906
|
+
id: `seed-${m.externalMessageId ?? `${provenance.deliveryId}-${index}`}`,
|
|
3907
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
3908
|
+
parts: [{ type: "text", text: m.text }],
|
|
3909
|
+
metadata: meta,
|
|
3910
|
+
};
|
|
3911
|
+
});
|
|
3912
|
+
}
|
|
3913
|
+
// Turn a channel connector's inbound() result into the turn's user UIMessage, stamped with where
|
|
3914
|
+
// it came from so a transcript storage can attribute it (see ChannelMessageMetadata).
|
|
3915
|
+
/**
|
|
3916
|
+
* True when a connector's `inbound()` result carries nothing to answer: blank text and no file or
|
|
3917
|
+
* data parts. A button click from another app on a shared endpoint maps to one.
|
|
3918
|
+
*/
|
|
3919
|
+
function isEmptyChannelInbound(input) {
|
|
3920
|
+
const message = input !== null && typeof input === "object" && "message" in input && !("role" in input)
|
|
3921
|
+
? input.message
|
|
3922
|
+
: input;
|
|
3923
|
+
if (typeof message === "string")
|
|
3924
|
+
return message.trim() === "";
|
|
3925
|
+
return (message.parts ?? []).every((part) => part.type === "text" && part.text?.trim() === "");
|
|
3926
|
+
}
|
|
3927
|
+
/** Bytes held for a run's channel file downloads before the oldest are dropped and fetched again. */
|
|
3928
|
+
const CHANNEL_DOWNLOAD_CACHE_BYTES = 64 * 1024 * 1024;
|
|
3929
|
+
/**
|
|
3930
|
+
* One downloader for a run's connectors that define `download`: asks each in turn (the ones bound to
|
|
3931
|
+
* the session first, the next one after a failure) and caches what they return by URL, so a file in
|
|
3932
|
+
* the history is fetched once per run rather than per step.
|
|
3933
|
+
*/
|
|
3934
|
+
function makeChannelDownloader(connectors, handleFor) {
|
|
3935
|
+
const downloaders = connectors.filter((c) => typeof c.download === "function");
|
|
3936
|
+
if (downloaders.length === 0)
|
|
3937
|
+
return undefined;
|
|
3938
|
+
const cache = new Map();
|
|
3939
|
+
const sizes = new Map();
|
|
3940
|
+
let cachedBytes = 0;
|
|
3941
|
+
const evict = () => {
|
|
3942
|
+
for (const key of cache.keys()) {
|
|
3943
|
+
if (cachedBytes <= CHANNEL_DOWNLOAD_CACHE_BYTES)
|
|
3944
|
+
return;
|
|
3945
|
+
cachedBytes -= sizes.get(key) ?? 0;
|
|
3946
|
+
sizes.delete(key);
|
|
3947
|
+
cache.delete(key);
|
|
3948
|
+
}
|
|
3949
|
+
};
|
|
3950
|
+
const fetchOwned = async (url) => {
|
|
3951
|
+
const candidates = downloaders.map((connector) => ({
|
|
3952
|
+
connector,
|
|
3953
|
+
handle: handleFor(connector.id),
|
|
3954
|
+
}));
|
|
3955
|
+
const ordered = [
|
|
3956
|
+
...candidates.filter((c) => c.handle !== undefined),
|
|
3957
|
+
...candidates.filter((c) => c.handle === undefined),
|
|
3958
|
+
];
|
|
3959
|
+
let failure;
|
|
3960
|
+
for (const { connector, handle } of ordered) {
|
|
3961
|
+
try {
|
|
3962
|
+
const file = await connector.download(url, { handle });
|
|
3963
|
+
if (file)
|
|
3964
|
+
return file;
|
|
3752
3965
|
}
|
|
3753
|
-
|
|
3754
|
-
|
|
3966
|
+
catch (error) {
|
|
3967
|
+
failure ??= { error };
|
|
3755
3968
|
}
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
// already closed on its own exit costs nothing.
|
|
3772
|
-
const withClose = async (result) => {
|
|
3773
|
-
try {
|
|
3774
|
-
return await result;
|
|
3775
|
-
}
|
|
3776
|
-
finally {
|
|
3777
|
-
await performChatClose();
|
|
3778
|
-
}
|
|
3779
|
-
};
|
|
3780
|
-
// Keep the schema-free path identical to the original custom-agent
|
|
3781
|
-
// wrapper, including when userRun starts executing.
|
|
3782
|
-
if (!parseClientData) {
|
|
3783
|
-
return withClose(userRun(payload, runOptions));
|
|
3969
|
+
}
|
|
3970
|
+
if (failure)
|
|
3971
|
+
throw failure.error;
|
|
3972
|
+
return null;
|
|
3973
|
+
};
|
|
3974
|
+
return (url) => {
|
|
3975
|
+
const key = url.href;
|
|
3976
|
+
const cached = cache.get(key);
|
|
3977
|
+
if (cached)
|
|
3978
|
+
return cached;
|
|
3979
|
+
const pending = fetchOwned(url).then((file) => {
|
|
3980
|
+
if (file && cache.get(key) === pending) {
|
|
3981
|
+
sizes.set(key, file.data.byteLength);
|
|
3982
|
+
cachedBytes += file.data.byteLength;
|
|
3983
|
+
evict();
|
|
3784
3984
|
}
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3985
|
+
return file;
|
|
3986
|
+
}, (error) => {
|
|
3987
|
+
if (cache.get(key) === pending)
|
|
3988
|
+
cache.delete(key);
|
|
3989
|
+
throw error;
|
|
3990
|
+
});
|
|
3991
|
+
cache.set(key, pending);
|
|
3992
|
+
return pending;
|
|
3993
|
+
};
|
|
3994
|
+
}
|
|
3995
|
+
function fileUrlOf(part) {
|
|
3996
|
+
const p = part;
|
|
3997
|
+
const raw = p?.type === "file" ? p.data : p?.type === "image" ? p.image : undefined;
|
|
3998
|
+
if (raw instanceof URL)
|
|
3999
|
+
return raw;
|
|
4000
|
+
if (typeof raw !== "string" || !/^https?:\/\//i.test(raw))
|
|
4001
|
+
return undefined;
|
|
4002
|
+
try {
|
|
4003
|
+
return new URL(raw);
|
|
4004
|
+
}
|
|
4005
|
+
catch {
|
|
4006
|
+
return undefined;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Fetch the channel-owned files in the messages a turn hands to `run()`, so the model call reads
|
|
4011
|
+
* them from the cache. A file a connector owns but cannot read becomes a text note in this turn's
|
|
4012
|
+
* copy of the messages, and the turn still runs. The transcript keeps the URL.
|
|
4013
|
+
*/
|
|
4014
|
+
async function resolveChannelFiles(messages, download) {
|
|
4015
|
+
const failed = new Set();
|
|
4016
|
+
const mediaTypes = new Map();
|
|
4017
|
+
const urls = new Map();
|
|
4018
|
+
for (const message of messages) {
|
|
4019
|
+
if (!Array.isArray(message.content))
|
|
4020
|
+
continue;
|
|
4021
|
+
for (const part of message.content) {
|
|
4022
|
+
const url = fileUrlOf(part);
|
|
4023
|
+
if (url)
|
|
4024
|
+
urls.set(url.href, url);
|
|
4025
|
+
}
|
|
4026
|
+
}
|
|
4027
|
+
await Promise.all([...urls.values()].map(async (url) => {
|
|
4028
|
+
try {
|
|
4029
|
+
const file = await download(url);
|
|
4030
|
+
if (file?.mediaType)
|
|
4031
|
+
mediaTypes.set(url.href, file.mediaType);
|
|
4032
|
+
}
|
|
4033
|
+
catch (error) {
|
|
4034
|
+
failed.add(url.href);
|
|
4035
|
+
logger.warn("chat.agent: a channel file could not be downloaded; the model gets a note", {
|
|
4036
|
+
url: url.origin + url.pathname,
|
|
4037
|
+
error,
|
|
3797
4038
|
});
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
4039
|
+
}
|
|
4040
|
+
}));
|
|
4041
|
+
const isWildcard = (part) => {
|
|
4042
|
+
const mediaType = part.mediaType;
|
|
4043
|
+
return part.type === "file" && !!mediaType && mediaType.endsWith("/*");
|
|
4044
|
+
};
|
|
4045
|
+
return messages.map((message) => {
|
|
4046
|
+
if (!Array.isArray(message.content))
|
|
4047
|
+
return message;
|
|
4048
|
+
let changed = false;
|
|
4049
|
+
const content = message.content.map((part) => {
|
|
4050
|
+
const url = fileUrlOf(part);
|
|
4051
|
+
if (!url)
|
|
4052
|
+
return part;
|
|
4053
|
+
if (failed.has(url.href)) {
|
|
4054
|
+
changed = true;
|
|
4055
|
+
const name = part.filename;
|
|
4056
|
+
return {
|
|
4057
|
+
type: "text",
|
|
4058
|
+
text: `[Attached file${name ? ` "${name}"` : ""} could not be loaded]`,
|
|
4059
|
+
};
|
|
3814
4060
|
}
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
3820
|
-
timeout: "1h",
|
|
3821
|
-
spanName: "waiting for valid clientData",
|
|
3822
|
-
});
|
|
3823
|
-
if (!next.ok || next.output.trigger === "close") {
|
|
3824
|
-
return;
|
|
4061
|
+
const downloaded = mediaTypes.get(url.href);
|
|
4062
|
+
if (downloaded && isWildcard(part)) {
|
|
4063
|
+
changed = true;
|
|
4064
|
+
return { ...part, mediaType: downloaded };
|
|
3825
4065
|
}
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
...next.output,
|
|
3830
|
-
continuation: next.output.continuation ?? payload.continuation,
|
|
3831
|
-
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
3832
|
-
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
3833
|
-
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
3834
|
-
};
|
|
3835
|
-
return withClose(userRun(recoveredPayload, runOptions));
|
|
3836
|
-
},
|
|
4066
|
+
return part;
|
|
4067
|
+
});
|
|
4068
|
+
return changed ? { ...message, content } : message;
|
|
3837
4069
|
});
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
4070
|
+
}
|
|
4071
|
+
/**
|
|
4072
|
+
* The AI SDK `experimental_download` for a turn: channel-owned URLs come from the connectors, and
|
|
4073
|
+
* every other URL goes to `fallback`, or to the AI SDK's default behaviour (left for a model that
|
|
4074
|
+
* reads the URL itself, fetched from a public host otherwise).
|
|
4075
|
+
*/
|
|
4076
|
+
function channelAwareDownload(download, fallback) {
|
|
4077
|
+
return async (requests) => {
|
|
4078
|
+
const results = await Promise.all(requests.map(async (request) => {
|
|
4079
|
+
const file = await download(request.url);
|
|
4080
|
+
return file ? { data: file.data, mediaType: file.mediaType } : null;
|
|
4081
|
+
}));
|
|
4082
|
+
const misses = requests.flatMap((_, i) => (results[i] ? [] : [i]));
|
|
4083
|
+
if (misses.length === 0)
|
|
4084
|
+
return results;
|
|
4085
|
+
const rest = fallback
|
|
4086
|
+
? await fallback(misses.map((i) => requests[i]))
|
|
4087
|
+
: await Promise.all(misses.map((i) => requests[i].isUrlSupportedByModel ? null : fetchPublicFile(requests[i].url)));
|
|
4088
|
+
misses.forEach((i, n) => {
|
|
4089
|
+
results[i] = rest[n] ?? null;
|
|
3843
4090
|
});
|
|
4091
|
+
return results;
|
|
4092
|
+
};
|
|
4093
|
+
}
|
|
4094
|
+
/** The action a channel click maps to through its connector's `toAction()`, if it maps to one. */
|
|
4095
|
+
function channelClickAction(connectors, wire) {
|
|
4096
|
+
const channelEvent = wire;
|
|
4097
|
+
if (!channelEvent?.connectorId)
|
|
4098
|
+
return undefined;
|
|
4099
|
+
const connector = connectors?.find((c) => c.id === channelEvent.connectorId);
|
|
4100
|
+
if (!connector?.toAction)
|
|
4101
|
+
return undefined;
|
|
4102
|
+
try {
|
|
4103
|
+
return connector.toAction(channelEvent.event) ?? undefined;
|
|
4104
|
+
}
|
|
4105
|
+
catch (error) {
|
|
4106
|
+
logger.warn("chat.agent: channel toAction() threw; handling the delivery as a message", {
|
|
4107
|
+
error,
|
|
4108
|
+
});
|
|
4109
|
+
return undefined;
|
|
3844
4110
|
}
|
|
3845
|
-
return task;
|
|
3846
4111
|
}
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
4112
|
+
/**
|
|
4113
|
+
* A channel delivery that arrived during a turn, as a message `pendingMessages` can steer into it:
|
|
4114
|
+
* the connector's `inbound()` with channel provenance. Undefined for what must not be steered (a
|
|
4115
|
+
* stop or other control, an interaction such as an approval click, an empty message, a connector
|
|
4116
|
+
* this agent does not list), which stays queued as its own turn.
|
|
4117
|
+
*/
|
|
4118
|
+
function channelSteeringMessage(connectors, wire, messageId) {
|
|
4119
|
+
const connector = connectors?.find((c) => c.id === wire.connectorId);
|
|
4120
|
+
if (!connector)
|
|
4121
|
+
return undefined;
|
|
4122
|
+
try {
|
|
4123
|
+
if (connector.onControl?.(wire.event))
|
|
4124
|
+
return undefined;
|
|
4125
|
+
if (connector.onInteraction?.(wire.event))
|
|
4126
|
+
return undefined;
|
|
4127
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4128
|
+
return undefined;
|
|
4129
|
+
const inbound = connector.inbound(wire.event);
|
|
4130
|
+
if (isEmptyChannelInbound(inbound))
|
|
4131
|
+
return undefined;
|
|
4132
|
+
const message = channelUserMessage(inbound, messageId ?? wire.deliveryId, {
|
|
4133
|
+
source: "channel",
|
|
4134
|
+
connectorId: connector.id,
|
|
4135
|
+
provider: connector.source,
|
|
4136
|
+
deliveryId: wire.deliveryId,
|
|
4137
|
+
...(wire.externalDeliveryId ? { externalDeliveryId: wire.externalDeliveryId } : {}),
|
|
4138
|
+
});
|
|
4139
|
+
return { message };
|
|
3860
4140
|
}
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
4141
|
+
catch (error) {
|
|
4142
|
+
logger.warn("chat.agent: a channel message could not be offered for steering; it runs as its own turn", {
|
|
4143
|
+
error,
|
|
4144
|
+
});
|
|
4145
|
+
return undefined;
|
|
4146
|
+
}
|
|
4147
|
+
}
|
|
4148
|
+
/**
|
|
4149
|
+
* Call the connector's `received` for a message that arrived mid-turn, unless it is not a message
|
|
4150
|
+
* to answer (an interaction click, an action click, an empty message). Fire and forget.
|
|
4151
|
+
*/
|
|
4152
|
+
function acknowledgeChannelMessage(connector, wire, handle) {
|
|
4153
|
+
if (!connector.received)
|
|
4154
|
+
return;
|
|
4155
|
+
try {
|
|
4156
|
+
if (connector.onInteraction?.(wire.event))
|
|
4157
|
+
return;
|
|
4158
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4159
|
+
return;
|
|
4160
|
+
if (isEmptyChannelInbound(connector.inbound(wire.event)))
|
|
4161
|
+
return;
|
|
4162
|
+
void Promise.resolve(connector.received({
|
|
4163
|
+
event: wire.event,
|
|
4164
|
+
deliveryId: wire.deliveryId,
|
|
4165
|
+
handle,
|
|
4166
|
+
bound: false,
|
|
4167
|
+
})).catch(() => { });
|
|
4168
|
+
}
|
|
4169
|
+
catch {
|
|
4170
|
+
return;
|
|
4171
|
+
}
|
|
4172
|
+
}
|
|
4173
|
+
function channelUserMessage(input, messageId, provenance) {
|
|
4174
|
+
const wrapped = input !== null && typeof input === "object" && "message" in input && !("role" in input);
|
|
4175
|
+
const message = (wrapped ? input.message : input);
|
|
4176
|
+
const extra = wrapped
|
|
4177
|
+
? input
|
|
4178
|
+
: {};
|
|
4179
|
+
const meta = {
|
|
4180
|
+
...provenance,
|
|
4181
|
+
...(extra.externalUserId ? { externalUserId: extra.externalUserId } : {}),
|
|
4182
|
+
...(extra.externalMessageId ? { externalMessageId: extra.externalMessageId } : {}),
|
|
4183
|
+
};
|
|
4184
|
+
const base = typeof message === "string"
|
|
4185
|
+
? { id: messageId, role: "user", parts: [{ type: "text", text: message }] }
|
|
4186
|
+
: message.id
|
|
4187
|
+
? message
|
|
4188
|
+
: { ...message, id: messageId };
|
|
4189
|
+
const existing = base.metadata;
|
|
4190
|
+
return {
|
|
4191
|
+
...base,
|
|
4192
|
+
metadata: { ...(existing && typeof existing === "object" ? existing : {}), ...meta },
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4195
|
+
/**
|
|
4196
|
+
* The assistant's closing text for a channel reply: the text after the last tool part in the message,
|
|
4197
|
+
* so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
|
|
4198
|
+
* is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
|
|
4199
|
+
* tool parts (an ordinary turn), so a plain reply is unchanged.
|
|
4200
|
+
*/
|
|
4201
|
+
function channelReplyText(message) {
|
|
4202
|
+
const parts = (message.parts ?? []);
|
|
4203
|
+
const isTool = (p) => {
|
|
4204
|
+
const t = p?.type;
|
|
4205
|
+
return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
|
|
4206
|
+
};
|
|
4207
|
+
let lastToolIdx = -1;
|
|
4208
|
+
parts.forEach((p, i) => {
|
|
4209
|
+
if (isTool(p))
|
|
4210
|
+
lastToolIdx = i;
|
|
4211
|
+
});
|
|
4212
|
+
const textFrom = (from) => parts
|
|
4213
|
+
.slice(from)
|
|
4214
|
+
.map((p) => p && typeof p === "object" && p.type === "text"
|
|
4215
|
+
? String(p.text)
|
|
4216
|
+
: "")
|
|
4217
|
+
.join("");
|
|
4218
|
+
const trailing = textFrom(lastToolIdx + 1);
|
|
4219
|
+
return trailing.trim().length > 0 ? trailing : textFrom(0);
|
|
4220
|
+
}
|
|
4221
|
+
/**
|
|
4222
|
+
* Tries at a turn's final channel reply before it is given up on and the turn shows the error
|
|
4223
|
+
* reaction. Only an edit of the ack placeholder is retried: a failed post may still have landed, so
|
|
4224
|
+
* a post is tried once.
|
|
4225
|
+
*/
|
|
4226
|
+
const CHANNEL_FINAL_SEND_ATTEMPTS = 3;
|
|
4227
|
+
/** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
|
|
4228
|
+
function pendingToolCallsInMessage(message) {
|
|
4229
|
+
const out = [];
|
|
4230
|
+
for (const part of (message.parts ?? [])) {
|
|
4231
|
+
if (!part || typeof part !== "object")
|
|
4232
|
+
continue;
|
|
4233
|
+
const type = part.type;
|
|
4234
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4235
|
+
if (!isTool || part.state !== "input-available")
|
|
4236
|
+
continue;
|
|
4237
|
+
const toolName = type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
|
|
4238
|
+
if (typeof part.toolCallId === "string")
|
|
4239
|
+
out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
|
|
4240
|
+
}
|
|
4241
|
+
return out;
|
|
4242
|
+
}
|
|
4243
|
+
/**
|
|
4244
|
+
* Fill in `toolCallId` on a resolution that names the tool instead: the most recent assistant part of
|
|
4245
|
+
* that tool still awaiting input. Returns the resolution unchanged when it already has an id, and
|
|
4246
|
+
* undefined when no pending call of that tool exists.
|
|
4247
|
+
*/
|
|
4248
|
+
function resolveInteractionToolCallId(resolution, messages) {
|
|
4249
|
+
if (resolution.toolCallId) {
|
|
4250
|
+
return resolution;
|
|
4251
|
+
}
|
|
4252
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4253
|
+
const message = messages[i];
|
|
4254
|
+
if (message.role !== "assistant")
|
|
4255
|
+
continue;
|
|
4256
|
+
for (const call of pendingToolCallsInMessage(message).reverse()) {
|
|
4257
|
+
if (call.toolName === resolution.toolName) {
|
|
4258
|
+
return { ...resolution, toolCallId: call.toolCallId };
|
|
4259
|
+
}
|
|
4260
|
+
}
|
|
4261
|
+
}
|
|
4262
|
+
return undefined;
|
|
4263
|
+
}
|
|
4264
|
+
/**
|
|
4265
|
+
* Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
|
|
4266
|
+
* the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
|
|
4267
|
+
* the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
|
|
4268
|
+
*/
|
|
4269
|
+
function buildInteractionResolutionMessage(resolution, messages, provenance) {
|
|
4270
|
+
// Resolved anywhere means resolved: a resumed turn can carry the same tool call in more than
|
|
4271
|
+
// one assistant message, and a second click on a decided call must not resolve a stale copy.
|
|
4272
|
+
for (const message of messages) {
|
|
4273
|
+
if (message.role !== "assistant")
|
|
4274
|
+
continue;
|
|
4275
|
+
for (const part of (message.parts ?? [])) {
|
|
4276
|
+
if (part && typeof part === "object" && part.toolCallId === resolution.toolCallId) {
|
|
4277
|
+
if (isResolvedToolState(part.state))
|
|
4278
|
+
return undefined;
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4283
|
+
const message = messages[i];
|
|
4284
|
+
if (message.role !== "assistant")
|
|
4285
|
+
continue;
|
|
4286
|
+
for (const part of (message.parts ?? [])) {
|
|
4287
|
+
if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId)
|
|
4288
|
+
continue;
|
|
4289
|
+
const type = part.type;
|
|
4290
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4291
|
+
if (!isTool)
|
|
4292
|
+
continue;
|
|
4293
|
+
if (part.state !== "input-available")
|
|
4294
|
+
return undefined;
|
|
4295
|
+
const slimPart = {
|
|
4296
|
+
type,
|
|
4297
|
+
toolCallId: resolution.toolCallId,
|
|
4298
|
+
state: "output-available",
|
|
4299
|
+
output: resolution.output,
|
|
4300
|
+
};
|
|
4301
|
+
if (type === "dynamic-tool" && typeof part.toolName === "string")
|
|
4302
|
+
slimPart.toolName = part.toolName;
|
|
4303
|
+
return {
|
|
4304
|
+
id: message.id,
|
|
4305
|
+
role: "assistant",
|
|
4306
|
+
parts: [slimPart],
|
|
4307
|
+
...(provenance ? { metadata: provenance } : {}),
|
|
4308
|
+
};
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4311
|
+
return undefined;
|
|
4312
|
+
}
|
|
4313
|
+
// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
|
|
4314
|
+
function defaultChannelOutbound(reply) {
|
|
4315
|
+
return reply.text ? { text: reply.text } : null;
|
|
4316
|
+
}
|
|
4317
|
+
// Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
|
|
4318
|
+
// pick one at random from an array. Returns undefined to skip (no choice / empty).
|
|
4319
|
+
export async function resolveReactionChoice(choice, event) {
|
|
4320
|
+
if (choice == null)
|
|
4321
|
+
return undefined;
|
|
4322
|
+
let value = typeof choice === "function" ? await choice(event) : choice;
|
|
4323
|
+
if (Array.isArray(value)) {
|
|
4324
|
+
if (value.length === 0)
|
|
4325
|
+
return undefined;
|
|
4326
|
+
value = value[Math.floor(Math.random() * value.length)];
|
|
4327
|
+
}
|
|
4328
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
4329
|
+
}
|
|
4330
|
+
// Best-effort reaction on the triggering message; a failure never breaks the turn.
|
|
4331
|
+
async function applyChannelReaction(connector, wireEvent, reaction) {
|
|
4332
|
+
if (!connector?.react || !wireEvent || !reaction.name)
|
|
4333
|
+
return;
|
|
4334
|
+
try {
|
|
4335
|
+
await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
|
|
4336
|
+
}
|
|
4337
|
+
catch (error) {
|
|
4338
|
+
logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
// The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
|
|
4342
|
+
// `react`, so `payload.channel` is only present when reacting is actually possible.
|
|
4343
|
+
function buildChannelRunSurface(connector, wireEvent, handle) {
|
|
4344
|
+
if (!connector)
|
|
4345
|
+
return undefined;
|
|
4346
|
+
const canReact = !!connector.react && !!wireEvent;
|
|
4347
|
+
if (!canReact && !connector.surface)
|
|
4348
|
+
return undefined;
|
|
4349
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4350
|
+
let extra = {};
|
|
4351
|
+
if (connector.surface) {
|
|
4352
|
+
try {
|
|
4353
|
+
extra = connector.surface(turnCtx) ?? {};
|
|
4354
|
+
}
|
|
4355
|
+
catch (error) {
|
|
4356
|
+
logger.warn("chat.agent: channel surface() threw; continuing without it", { error });
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4359
|
+
const noop = async () => { };
|
|
4360
|
+
return {
|
|
4361
|
+
...extra,
|
|
4362
|
+
react: canReact ? (name) => applyChannelReaction(connector, wireEvent, { name }) : noop,
|
|
4363
|
+
unreact: canReact
|
|
4364
|
+
? (name) => applyChannelReaction(connector, wireEvent, { name, remove: true })
|
|
4365
|
+
: noop,
|
|
4366
|
+
};
|
|
4367
|
+
}
|
|
4368
|
+
function channelTurnCtx(wireEvent, handle) {
|
|
4369
|
+
return {
|
|
4370
|
+
...(wireEvent ? { event: wireEvent.event, deliveryId: wireEvent.deliveryId } : {}),
|
|
4371
|
+
...(handle !== undefined ? { handle } : {}),
|
|
4372
|
+
bound: !wireEvent,
|
|
4373
|
+
};
|
|
4374
|
+
}
|
|
4375
|
+
/**
|
|
4376
|
+
* Feed a connector's `progress()` from the UI message stream: a tool call once its input is available,
|
|
4377
|
+
* then its result or error. Calls are serialized so the surface sees them in order; a throw is logged.
|
|
4378
|
+
* `stop()` refuses further chunks and drains what is already queued, so a slow first post does not
|
|
4379
|
+
* lose the results behind it, and nothing new lands after the turn ended.
|
|
4380
|
+
*/
|
|
4381
|
+
function makeChannelProgressObserver(connector, wireEvent, handle) {
|
|
4382
|
+
const progress = connector.progress;
|
|
4383
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4384
|
+
const toolNames = new Map();
|
|
4385
|
+
const toolTitles = new Map();
|
|
4386
|
+
let chain = Promise.resolve();
|
|
4387
|
+
let stopped = false;
|
|
4388
|
+
const emit = (event) => {
|
|
4389
|
+
chain = chain.then(async () => {
|
|
4390
|
+
try {
|
|
4391
|
+
await progress(event, turnCtx);
|
|
4392
|
+
}
|
|
4393
|
+
catch (error) {
|
|
4394
|
+
logger.warn("chat.agent: channel progress() failed; continuing", {
|
|
4395
|
+
error,
|
|
4396
|
+
type: event.type,
|
|
4397
|
+
});
|
|
4398
|
+
}
|
|
4399
|
+
});
|
|
4400
|
+
};
|
|
4401
|
+
return {
|
|
4402
|
+
observe(chunk) {
|
|
4403
|
+
if (stopped)
|
|
4404
|
+
return;
|
|
4405
|
+
const c = chunk;
|
|
4406
|
+
if (!c?.type || !c.toolCallId)
|
|
4407
|
+
return;
|
|
4408
|
+
if (c.title)
|
|
4409
|
+
toolTitles.set(c.toolCallId, c.title);
|
|
4410
|
+
const title = toolTitles.get(c.toolCallId);
|
|
4411
|
+
const titled = title ? { title } : {};
|
|
4412
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4413
|
+
toolNames.set(c.toolCallId, c.toolName);
|
|
4414
|
+
return;
|
|
4415
|
+
}
|
|
4416
|
+
if (c.type === "tool-input-available") {
|
|
4417
|
+
const toolName = c.toolName ?? toolNames.get(c.toolCallId) ?? "tool";
|
|
4418
|
+
toolNames.set(c.toolCallId, toolName);
|
|
4419
|
+
emit({ type: "tool-call", toolCallId: c.toolCallId, toolName, ...titled, input: c.input });
|
|
4420
|
+
return;
|
|
4421
|
+
}
|
|
4422
|
+
if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4423
|
+
emit({
|
|
4424
|
+
type: "tool-result",
|
|
4425
|
+
toolCallId: c.toolCallId,
|
|
4426
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4427
|
+
...titled,
|
|
4428
|
+
output: c.output,
|
|
4429
|
+
});
|
|
4430
|
+
return;
|
|
4431
|
+
}
|
|
4432
|
+
if (c.type === "tool-output-error") {
|
|
4433
|
+
emit({
|
|
4434
|
+
type: "tool-error",
|
|
4435
|
+
toolCallId: c.toolCallId,
|
|
4436
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4437
|
+
...titled,
|
|
4438
|
+
errorText: c.errorText ?? "tool failed",
|
|
4439
|
+
});
|
|
4440
|
+
}
|
|
4441
|
+
},
|
|
4442
|
+
async stop() {
|
|
4443
|
+
stopped = true;
|
|
4444
|
+
await chain;
|
|
4445
|
+
},
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
/** Re-send `working` this often during a long turn: Slack drops a processing status after an hour. */
|
|
4449
|
+
const CHANNEL_WORKING_REFRESH_MS = 50 * 60 * 1000;
|
|
4450
|
+
/**
|
|
4451
|
+
* Report a session's lifecycle to its connector in order, one call at a time, and re-send `working`
|
|
4452
|
+
* while a long turn runs. Every report bumps a sequence number and a refresh only fires while its
|
|
4453
|
+
* `working` is still the latest report, so a refresh can never land after the turn went idle.
|
|
4454
|
+
*/
|
|
4455
|
+
function makeChannelLifecycleReporter() {
|
|
4456
|
+
let chain = Promise.resolve();
|
|
4457
|
+
let seq = 0;
|
|
4458
|
+
let refreshTimer;
|
|
4459
|
+
let closed = false;
|
|
4460
|
+
const clearRefresh = () => {
|
|
4461
|
+
if (refreshTimer)
|
|
4462
|
+
clearTimeout(refreshTimer);
|
|
4463
|
+
refreshTimer = undefined;
|
|
4464
|
+
};
|
|
4465
|
+
const enqueue = (connector, state, ctx, reportSeq) => {
|
|
4466
|
+
chain = chain.then(async () => {
|
|
4467
|
+
if (ctx.refresh && reportSeq !== seq)
|
|
4468
|
+
return;
|
|
4469
|
+
try {
|
|
4470
|
+
await connector.lifecycle(state, ctx);
|
|
4471
|
+
}
|
|
4472
|
+
catch (error) {
|
|
4473
|
+
logger.warn("chat.agent: channel lifecycle() failed; continuing", { error, state });
|
|
4474
|
+
}
|
|
4475
|
+
});
|
|
4476
|
+
return chain;
|
|
4477
|
+
};
|
|
4478
|
+
return {
|
|
4479
|
+
report(connector, state, ctx) {
|
|
4480
|
+
if (!connector?.lifecycle || closed)
|
|
4481
|
+
return Promise.resolve();
|
|
4482
|
+
if (state === "closed")
|
|
4483
|
+
closed = true;
|
|
4484
|
+
const reportSeq = ++seq;
|
|
4485
|
+
clearRefresh();
|
|
4486
|
+
const done = enqueue(connector, state, ctx, reportSeq);
|
|
4487
|
+
if (state === "working") {
|
|
4488
|
+
const arm = () => {
|
|
4489
|
+
refreshTimer = setTimeout(() => {
|
|
4490
|
+
if (reportSeq !== seq)
|
|
4491
|
+
return;
|
|
4492
|
+
void enqueue(connector, "working", { ...ctx, firstTurn: false, refresh: true }, reportSeq);
|
|
4493
|
+
arm();
|
|
4494
|
+
}, CHANNEL_WORKING_REFRESH_MS);
|
|
4495
|
+
refreshTimer.unref?.();
|
|
4496
|
+
};
|
|
4497
|
+
arm();
|
|
4498
|
+
}
|
|
4499
|
+
return done;
|
|
4500
|
+
},
|
|
4501
|
+
dispose: clearRefresh,
|
|
4502
|
+
};
|
|
4503
|
+
}
|
|
4504
|
+
/**
|
|
4505
|
+
* The assistant message with the given pending tool calls resolved as stopped (`output-error`), for
|
|
4506
|
+
* a stop that arrives while the session waits on a human decision. Undefined when none matched.
|
|
4507
|
+
*/
|
|
4508
|
+
function stopPendingToolCalls(message, toolCallIds, errorText) {
|
|
4509
|
+
let changed = false;
|
|
4510
|
+
const parts = (message.parts ?? []).map((part) => {
|
|
4511
|
+
if (part &&
|
|
4512
|
+
typeof part === "object" &&
|
|
4513
|
+
toolCallIds.has(part.toolCallId) &&
|
|
4514
|
+
part.state === "input-available") {
|
|
4515
|
+
changed = true;
|
|
4516
|
+
return { ...part, state: "output-error", errorText };
|
|
4517
|
+
}
|
|
4518
|
+
return part;
|
|
4519
|
+
});
|
|
4520
|
+
return changed ? { ...message, parts } : undefined;
|
|
4521
|
+
}
|
|
4522
|
+
/**
|
|
4523
|
+
* Feed a connector's native reply stream from the UI message stream: text deltas as they arrive, and
|
|
4524
|
+
* tool progress when the stream accepts it. `stop()` only refuses further chunks; the turn boundary
|
|
4525
|
+
* ends the stream itself with the turn's outcome.
|
|
4526
|
+
*/
|
|
4527
|
+
function makeChannelReplyStreamObserver(stream) {
|
|
4528
|
+
const toolNames = new Map();
|
|
4529
|
+
const toolTitles = new Map();
|
|
4530
|
+
let stopped = false;
|
|
4531
|
+
const safe = (fn) => {
|
|
4532
|
+
try {
|
|
4533
|
+
fn();
|
|
4534
|
+
}
|
|
4535
|
+
catch (error) {
|
|
4536
|
+
logger.warn("chat.agent: channel reply stream threw; continuing", { error });
|
|
4537
|
+
}
|
|
4538
|
+
};
|
|
4539
|
+
return {
|
|
4540
|
+
observe(chunk) {
|
|
4541
|
+
if (stopped)
|
|
4542
|
+
return;
|
|
4543
|
+
const c = chunk;
|
|
4544
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4545
|
+
const delta = c.delta;
|
|
4546
|
+
safe(() => stream.text(delta));
|
|
4547
|
+
return;
|
|
4548
|
+
}
|
|
4549
|
+
if (!stream.progress || !c?.type || !c.toolCallId)
|
|
4550
|
+
return;
|
|
4551
|
+
const toolCallId = c.toolCallId;
|
|
4552
|
+
if (c.title)
|
|
4553
|
+
toolTitles.set(toolCallId, c.title);
|
|
4554
|
+
const title = toolTitles.get(toolCallId);
|
|
4555
|
+
const titled = title ? { title } : {};
|
|
4556
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4557
|
+
toolNames.set(toolCallId, c.toolName);
|
|
4558
|
+
}
|
|
4559
|
+
else if (c.type === "tool-input-available") {
|
|
4560
|
+
const toolName = c.toolName ?? toolNames.get(toolCallId) ?? "tool";
|
|
4561
|
+
toolNames.set(toolCallId, toolName);
|
|
4562
|
+
safe(() => stream.progress({ type: "tool-call", toolCallId, toolName, ...titled, input: c.input }));
|
|
4563
|
+
}
|
|
4564
|
+
else if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4565
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4566
|
+
safe(() => stream.progress({
|
|
4567
|
+
type: "tool-result",
|
|
4568
|
+
toolCallId,
|
|
4569
|
+
toolName,
|
|
4570
|
+
...titled,
|
|
4571
|
+
output: c.output,
|
|
4572
|
+
}));
|
|
4573
|
+
}
|
|
4574
|
+
else if (c.type === "tool-output-error") {
|
|
4575
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4576
|
+
const errorText = c.errorText ?? "tool failed";
|
|
4577
|
+
safe(() => stream.progress({ type: "tool-error", toolCallId, toolName, ...titled, errorText }));
|
|
4578
|
+
}
|
|
4579
|
+
},
|
|
4580
|
+
stop() {
|
|
4581
|
+
stopped = true;
|
|
4582
|
+
},
|
|
4583
|
+
};
|
|
4584
|
+
}
|
|
4585
|
+
function composeChannelObservers(observers) {
|
|
4586
|
+
return {
|
|
4587
|
+
observe(chunk) {
|
|
4588
|
+
for (const o of observers)
|
|
4589
|
+
o.observe(chunk);
|
|
4590
|
+
},
|
|
4591
|
+
async stop() {
|
|
4592
|
+
await Promise.all(observers.map((o) => o.stop()));
|
|
4593
|
+
},
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
|
|
4597
|
+
// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
|
|
4598
|
+
// the authoritative last write. Best-effort: an edit failure is logged, never fatal.
|
|
4599
|
+
const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
|
|
4600
|
+
function makeChannelStreamEditor(connector, channelEvent, ackRef, handle) {
|
|
4601
|
+
const outbound = connector.outbound ?? defaultChannelOutbound;
|
|
4602
|
+
let latest = "";
|
|
4603
|
+
let lastSent = "";
|
|
4604
|
+
let timer;
|
|
4605
|
+
let inFlightPromise;
|
|
4606
|
+
let stopped = false;
|
|
4607
|
+
const arm = () => {
|
|
4608
|
+
if (stopped || timer)
|
|
4609
|
+
return;
|
|
4610
|
+
timer = setTimeout(() => {
|
|
4611
|
+
timer = undefined;
|
|
4612
|
+
void edit();
|
|
4613
|
+
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
|
|
4614
|
+
};
|
|
4615
|
+
const edit = async () => {
|
|
4616
|
+
if (stopped || inFlightPromise)
|
|
4617
|
+
return;
|
|
4618
|
+
const text = latest;
|
|
4619
|
+
if (text === lastSent)
|
|
4620
|
+
return;
|
|
4621
|
+
const message = outbound({
|
|
4622
|
+
text,
|
|
4623
|
+
message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] },
|
|
4624
|
+
final: false,
|
|
4625
|
+
stopped: false,
|
|
4626
|
+
});
|
|
4627
|
+
if (!message)
|
|
4628
|
+
return;
|
|
4629
|
+
inFlightPromise = (async () => {
|
|
4630
|
+
try {
|
|
4631
|
+
await connector.send(message, {
|
|
4632
|
+
event: channelEvent.event,
|
|
4633
|
+
handle,
|
|
4634
|
+
deliveryId: channelEvent.deliveryId,
|
|
4635
|
+
previousRef: ackRef,
|
|
4636
|
+
mode: "stream",
|
|
4637
|
+
final: false,
|
|
4638
|
+
bound: false,
|
|
4639
|
+
});
|
|
4640
|
+
lastSent = text;
|
|
4641
|
+
}
|
|
4642
|
+
catch (error) {
|
|
4643
|
+
logger.warn("chat.agent: channel stream edit failed", { error });
|
|
4644
|
+
}
|
|
4645
|
+
finally {
|
|
4646
|
+
inFlightPromise = undefined;
|
|
4647
|
+
if (!stopped && latest !== lastSent)
|
|
4648
|
+
arm();
|
|
4649
|
+
}
|
|
4650
|
+
})();
|
|
4651
|
+
await inFlightPromise;
|
|
4652
|
+
};
|
|
4653
|
+
return {
|
|
4654
|
+
observe(chunk) {
|
|
4655
|
+
if (stopped)
|
|
4656
|
+
return;
|
|
4657
|
+
const c = chunk;
|
|
4658
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4659
|
+
latest += c.delta;
|
|
4660
|
+
arm();
|
|
4661
|
+
}
|
|
4662
|
+
},
|
|
4663
|
+
async stop() {
|
|
4664
|
+
stopped = true;
|
|
4665
|
+
if (timer) {
|
|
4666
|
+
clearTimeout(timer);
|
|
4667
|
+
timer = undefined;
|
|
4668
|
+
}
|
|
4669
|
+
if (inFlightPromise) {
|
|
4670
|
+
try {
|
|
4671
|
+
await inFlightPromise;
|
|
4672
|
+
}
|
|
4673
|
+
catch { }
|
|
4674
|
+
}
|
|
4675
|
+
},
|
|
4676
|
+
};
|
|
4677
|
+
}
|
|
4678
|
+
/**
|
|
4679
|
+
* Wrap the reply stream so it debounce-edits the channel message as it streams.
|
|
4680
|
+
* Both `flush` (the stream completed) and `cancel` (the stream was aborted or
|
|
4681
|
+
* cancelled downstream) stop the editor, so a pending debounce timer can never
|
|
4682
|
+
* fire an edit after the turn ended, onto a message the next turn already owns.
|
|
4683
|
+
*/
|
|
4684
|
+
/**
|
|
4685
|
+
* Feed every chunk of a stream to `editor`. By default the editor is stopped when the stream ends or
|
|
4686
|
+
* is cancelled (one stream per turn, the automatic pipe). With `stopOnEnd: false` the editor outlives
|
|
4687
|
+
* the stream, for a turn whose run() pipes several streams through `chat.pipe()`; the turn boundary
|
|
4688
|
+
* stops it.
|
|
4689
|
+
*/
|
|
4690
|
+
function makeChannelStreamTap(editor, options = {}) {
|
|
4691
|
+
const stopOnEnd = options.stopOnEnd ?? true;
|
|
4692
|
+
const transformer = {
|
|
4693
|
+
transform(chunk, controller) {
|
|
4694
|
+
editor.observe(chunk);
|
|
4695
|
+
controller.enqueue(chunk);
|
|
4696
|
+
},
|
|
4697
|
+
async flush() {
|
|
4698
|
+
if (stopOnEnd)
|
|
4699
|
+
await editor.stop();
|
|
4700
|
+
},
|
|
4701
|
+
async cancel() {
|
|
4702
|
+
if (stopOnEnd)
|
|
4703
|
+
await editor.stop();
|
|
4704
|
+
},
|
|
4705
|
+
};
|
|
4706
|
+
return new TransformStream(transformer);
|
|
4707
|
+
}
|
|
4708
|
+
export { makeChannelStreamEditor as __makeChannelStreamEditorForTests, makeChannelStreamTap as __makeChannelStreamTapForTests, makeChannelProgressObserver as __makeChannelProgressObserverForTests, makeChannelLifecycleReporter as __makeChannelLifecycleReporterForTests, };
|
|
4709
|
+
/**
|
|
4710
|
+
* Per-chat latch for the channel egress that runs AFTER `trigger:turn-complete` is written (the
|
|
4711
|
+
* final `send()` and the done/error reactions). The run loop opens it before writing the chunk and
|
|
4712
|
+
* settles it once that egress has finished, so a caller that saw the chunk can wait for the
|
|
4713
|
+
* connector to be quiet instead of guessing with timers. One latch per chat at a time; a chat with
|
|
4714
|
+
* no open latch is quiet.
|
|
4715
|
+
*/
|
|
4716
|
+
const channelEgressLatches = new Map();
|
|
4717
|
+
function openChannelEgressLatch(chatId) {
|
|
4718
|
+
let settle;
|
|
4719
|
+
const promise = new Promise((resolve) => (settle = resolve));
|
|
4720
|
+
channelEgressLatches.set(chatId, { promise, settle });
|
|
4721
|
+
}
|
|
4722
|
+
function settleChannelEgressLatch(chatId) {
|
|
4723
|
+
const latch = channelEgressLatches.get(chatId);
|
|
4724
|
+
if (!latch)
|
|
4725
|
+
return;
|
|
4726
|
+
channelEgressLatches.delete(chatId);
|
|
4727
|
+
latch.settle();
|
|
4728
|
+
}
|
|
4729
|
+
/** Resolves once the post-turn channel egress for `chatId` has finished (immediately when none is running). */
|
|
4730
|
+
export async function __waitForChannelEgressForTests(chatId) {
|
|
4731
|
+
await channelEgressLatches.get(chatId)?.promise;
|
|
4732
|
+
}
|
|
4733
|
+
function chatCustomAgent(options) {
|
|
4734
|
+
const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
|
|
4735
|
+
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
|
|
4736
|
+
const parseClientDataSync = clientDataSchema
|
|
4737
|
+
? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
|
|
4738
|
+
: undefined;
|
|
4739
|
+
const task = createTask({
|
|
4740
|
+
...restOptions,
|
|
4741
|
+
triggerSource: "agent",
|
|
4742
|
+
agentConfig: { type: "ai-sdk-chat" },
|
|
4743
|
+
run: async (payload, runOptions) => {
|
|
4744
|
+
// Bind the run to its backing Session so module-level helpers
|
|
4745
|
+
// (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
|
|
4746
|
+
// resolve to this chat's `.in` / `.out` channels. Address
|
|
4747
|
+
// everywhere by `payload.chatId` (the session externalId) so the
|
|
4748
|
+
// agent's writes and the transport's reads converge on the same
|
|
4749
|
+
// S2 stream key + waitpoint key.
|
|
4750
|
+
//
|
|
4751
|
+
// The Session row is created server-side by `POST /sessions` (or
|
|
4752
|
+
// `chat.createStartSessionAction`) before this run is triggered.
|
|
4753
|
+
// No client-side upsert needed.
|
|
4754
|
+
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
|
|
4755
|
+
locals.set(chatExternalIdKey, payload.chatId);
|
|
4756
|
+
locals.set(chatAgentRunContextKey, runOptions.ctx);
|
|
4757
|
+
locals.set(chatCustomAgentRunKey, true);
|
|
4758
|
+
if (parseClientData && parseClientDataSync) {
|
|
4759
|
+
locals.set(chatCustomAgentClientDataParserKey, {
|
|
4760
|
+
parse: parseClientData,
|
|
4761
|
+
parseSync: parseClientDataSync,
|
|
4762
|
+
});
|
|
4763
|
+
}
|
|
4764
|
+
if (onClientDataValidationError) {
|
|
4765
|
+
locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
|
|
4766
|
+
}
|
|
4767
|
+
locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
|
|
4768
|
+
// Initialize the turn-complete trim slot so `chat.writeTurnComplete`
|
|
4769
|
+
// trims `session.out` back to the previous turn boundary. Without
|
|
4770
|
+
// this the slot is undefined and the trim never runs, so `.out`
|
|
4771
|
+
// grows without bound for the whole custom-agent surface.
|
|
4772
|
+
locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
|
|
4773
|
+
markChatAgentRunForStreamsWarning();
|
|
4774
|
+
taskContext.setConversationId(payload.chatId);
|
|
4775
|
+
stampConversationIdOnActiveSpan(payload.chatId);
|
|
4776
|
+
await installChatInputRouter(payload.chatId, {
|
|
4777
|
+
resuming: Boolean(payload.continuation),
|
|
4778
|
+
});
|
|
4779
|
+
// A custom agent's loop is the customer's, so there is no exit site the
|
|
4780
|
+
// SDK controls. Perform a requested close when `run()` returns, whatever
|
|
4781
|
+
// shape the loop had. Idempotent, so the createSession iterator having
|
|
4782
|
+
// already closed on its own exit costs nothing.
|
|
4783
|
+
const withClose = async (result) => {
|
|
4784
|
+
try {
|
|
4785
|
+
return await result;
|
|
4786
|
+
}
|
|
4787
|
+
finally {
|
|
4788
|
+
await performChatClose();
|
|
4789
|
+
}
|
|
4790
|
+
};
|
|
4791
|
+
// Keep the schema-free path identical to the original custom-agent
|
|
4792
|
+
// wrapper, including when userRun starts executing.
|
|
4793
|
+
if (!parseClientData) {
|
|
4794
|
+
return withClose(userRun(payload, runOptions));
|
|
4795
|
+
}
|
|
4796
|
+
const isHandoverBoot = payload.trigger === "handover-prepare";
|
|
4797
|
+
const isMessagelessBoot = payload.trigger === "preload" ||
|
|
4798
|
+
(payload.continuation === true &&
|
|
4799
|
+
payload.message === undefined &&
|
|
4800
|
+
payload.trigger !== "action" &&
|
|
4801
|
+
payload.trigger !== "regenerate-message" &&
|
|
4802
|
+
!isHandoverBoot);
|
|
4803
|
+
const validated = await validateChatCustomAgentPayload(payload, {
|
|
4804
|
+
// Preload and continuation boots do not represent a submitted turn,
|
|
4805
|
+
// so there is no sender waiting for a terminal frame. Handover errors
|
|
4806
|
+
// must be written after the warm response flushes and signals below.
|
|
4807
|
+
writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
|
|
4808
|
+
});
|
|
4809
|
+
if (validated.ok) {
|
|
4810
|
+
return withClose(userRun(validated.payload, runOptions));
|
|
4811
|
+
}
|
|
4812
|
+
if (isHandoverBoot) {
|
|
4813
|
+
const signal = await waitForHandover({
|
|
4814
|
+
payload,
|
|
4815
|
+
timeout: "1h",
|
|
4816
|
+
spanName: "waiting for handover signal (invalid clientData)",
|
|
4817
|
+
});
|
|
4818
|
+
if (!signal || signal.kind === "handover-skip") {
|
|
4819
|
+
return;
|
|
4820
|
+
}
|
|
4821
|
+
// The head-start writer flushes before sending this signal. Writing
|
|
4822
|
+
// the terminal error now preserves stream order and closes the stitch.
|
|
4823
|
+
await writeChatCustomAgentClientDataErrorToStream(payload);
|
|
4824
|
+
return;
|
|
4825
|
+
}
|
|
4826
|
+
// The Session base payload is sticky across continuation runs. If it is
|
|
4827
|
+
// invalid, returning here would boot the same bad metadata again on the
|
|
4828
|
+
// next message. Stay attached and wait for a valid wire frame instead.
|
|
4829
|
+
const next = await messagesInput.waitWithIdleTimeout({
|
|
4830
|
+
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
4831
|
+
timeout: "1h",
|
|
4832
|
+
spanName: "waiting for valid clientData",
|
|
4833
|
+
});
|
|
4834
|
+
if (!next.ok || next.output.trigger === "close") {
|
|
4835
|
+
return;
|
|
4836
|
+
}
|
|
4837
|
+
// Normal input frames omit run-level boot context. Carry it forward so
|
|
4838
|
+
// a continuation still tells the custom loop to restore prior state.
|
|
4839
|
+
const recoveredPayload = {
|
|
4840
|
+
...next.output,
|
|
4841
|
+
continuation: next.output.continuation ?? payload.continuation,
|
|
4842
|
+
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
4843
|
+
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
4844
|
+
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
4845
|
+
};
|
|
4846
|
+
return withClose(userRun(recoveredPayload, runOptions));
|
|
4847
|
+
},
|
|
4848
|
+
});
|
|
4849
|
+
// Register clientDataSchema so the CLI converts it to JSONSchema
|
|
4850
|
+
// and stores it as payloadSchema — used by the Playground UI
|
|
4851
|
+
if (clientDataSchema) {
|
|
4852
|
+
resourceCatalog.updateTaskMetadata(options.id, {
|
|
4853
|
+
schema: clientDataSchema,
|
|
4854
|
+
});
|
|
4855
|
+
}
|
|
4856
|
+
return task;
|
|
4857
|
+
}
|
|
4858
|
+
function chatAgent(options) {
|
|
4859
|
+
const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, events, channels, 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, telemetry: telemetryOptions, ...restOptions } = options;
|
|
4860
|
+
const recordUserMessage = telemetryOptions?.recordUserMessage ?? true;
|
|
4861
|
+
const recordClientData = telemetryOptions?.recordClientData ?? true;
|
|
4862
|
+
if (hydrateMessages) {
|
|
4863
|
+
if (storage) {
|
|
4864
|
+
throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
|
|
4865
|
+
"`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
|
|
4866
|
+
"change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
|
|
4867
|
+
}
|
|
4868
|
+
if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
|
|
4869
|
+
throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
|
|
4870
|
+
"`loadContext`. Both would own the model's context; keep one.");
|
|
4871
|
+
}
|
|
4872
|
+
warnHydrateMessagesDeprecatedOnce(options.id);
|
|
4873
|
+
}
|
|
4874
|
+
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
|
|
4875
|
+
const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
|
|
4876
|
+
// chat.agent does not expose generic retry options (see docstring on
|
|
4877
|
+
// `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
|
|
4878
|
+
// `oomMachine` is set we allow one retry on a larger machine; otherwise
|
|
4879
|
+
// we keep the historical no-retry default.
|
|
4880
|
+
const retry = oomMachine
|
|
4881
|
+
? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
|
|
4882
|
+
: { maxAttempts: 1 };
|
|
4883
|
+
const task = createTask({
|
|
3871
4884
|
...restOptions,
|
|
3872
4885
|
retry,
|
|
3873
4886
|
triggerSource: "agent",
|
|
@@ -4011,6 +5024,69 @@ function chatAgent(options) {
|
|
|
4011
5024
|
: undefined;
|
|
4012
5025
|
let transcriptShadow = createTranscriptShadow([]);
|
|
4013
5026
|
let bootTranscriptState = null;
|
|
5027
|
+
/**
|
|
5028
|
+
* The channel this session answers into (see TranscriptChannelBinding). Set by a channel turn
|
|
5029
|
+
* whose connector defines `bind`, restored from runtime state at boot, saved on every changeset.
|
|
5030
|
+
*/
|
|
5031
|
+
let sessionChannelBinding;
|
|
5032
|
+
const channelDownloader = channels
|
|
5033
|
+
? makeChannelDownloader(channels, (connectorId) => sessionChannelBinding?.connectorId === connectorId
|
|
5034
|
+
? sessionChannelBinding.handle
|
|
5035
|
+
: undefined)
|
|
5036
|
+
: undefined;
|
|
5037
|
+
locals.set(chatChannelDownloadKey, channelDownloader);
|
|
5038
|
+
/**
|
|
5039
|
+
* Tool calls that already have channel controls posted. A turn that resumes with one of
|
|
5040
|
+
* several pending calls resolved still carries the others as pending; without this the
|
|
5041
|
+
* controls would be posted again, and the model's final text would lose to them. A
|
|
5042
|
+
* continuation worker seeds it at boot from the calls still pending in the restored
|
|
5043
|
+
* transcript of a channel-bound session, since the worker before it posted their controls.
|
|
5044
|
+
*/
|
|
5045
|
+
const channelRenderedToolCallIds = new Set();
|
|
5046
|
+
/** Serializes `lifecycle()` reports for the session's channel across turns. */
|
|
5047
|
+
const channelLifecycle = makeChannelLifecycleReporter();
|
|
5048
|
+
/**
|
|
5049
|
+
* Pending calls a channel stop resolved as stopped on this worker. A click on their old controls
|
|
5050
|
+
* is dropped even when the transcript was hydrated again from a store that never saw the stop.
|
|
5051
|
+
*/
|
|
5052
|
+
const channelStoppedToolCallIds = new Set();
|
|
5053
|
+
/** When the latest channel turn started, so a stop pressed before it is recognized as stale. */
|
|
5054
|
+
let lastChannelTurnStartedAt;
|
|
5055
|
+
/** When the latest channel turn ended, so a stop pressed while it ran is not run as a new turn. */
|
|
5056
|
+
let lastChannelTurnEndedAt;
|
|
5057
|
+
/** The turn reported its end state: the binding no longer marks the surface as working. */
|
|
5058
|
+
const clearChannelWorking = () => {
|
|
5059
|
+
if (!sessionChannelBinding?.working)
|
|
5060
|
+
return;
|
|
5061
|
+
const { working: _working, ...rest } = sessionChannelBinding;
|
|
5062
|
+
void _working;
|
|
5063
|
+
sessionChannelBinding = rest;
|
|
5064
|
+
};
|
|
5065
|
+
/** The turn's reply stream ended: the binding no longer names it for recovery. */
|
|
5066
|
+
const clearOpenReplyStream = () => {
|
|
5067
|
+
if (!sessionChannelBinding?.stream)
|
|
5068
|
+
return;
|
|
5069
|
+
const { stream: _stream, ...rest } = sessionChannelBinding;
|
|
5070
|
+
void _stream;
|
|
5071
|
+
sessionChannelBinding = rest;
|
|
5072
|
+
};
|
|
5073
|
+
/** Tell the bound channel the conversation is over (`chat.close()` or a close signal). */
|
|
5074
|
+
const reportChannelClosed = async () => {
|
|
5075
|
+
const binding = sessionChannelBinding;
|
|
5076
|
+
if (!binding)
|
|
5077
|
+
return;
|
|
5078
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5079
|
+
await channelLifecycle.report(connector, "closed", {
|
|
5080
|
+
handle: binding.handle,
|
|
5081
|
+
bound: true,
|
|
5082
|
+
firstTurn: false,
|
|
5083
|
+
});
|
|
5084
|
+
};
|
|
5085
|
+
/** Provenance of the turn or action being processed, for the storage context. */
|
|
5086
|
+
let currentTurnSource = "client";
|
|
5087
|
+
let currentTurnConnectorId;
|
|
5088
|
+
/** True once `onAction` returned `chat.turn()` for the action being processed. */
|
|
5089
|
+
let turnIsActionTurn = false;
|
|
4014
5090
|
/**
|
|
4015
5091
|
* True while the model lane holds a compaction summary, so it cannot be
|
|
4016
5092
|
* rebuilt from the transcript and has to be persisted as state. Reset
|
|
@@ -4073,9 +5149,14 @@ function chatAgent(options) {
|
|
|
4073
5149
|
const transcriptIds = new Set(opts.messages.map((message) => message.id));
|
|
4074
5150
|
const markerIds = new Set(steeringMarkers(opts.messages).map((marker) => marker.id));
|
|
4075
5151
|
const steering = [...(locals.get(chatSteeringInjectionsKey)?.values() ?? [])].filter((entry) => markerIds.has(entry.id) || entry.messageIds.some((id) => transcriptIds.has(id)));
|
|
4076
|
-
const runtimeState = laneCompacted ||
|
|
5152
|
+
const runtimeState = laneCompacted ||
|
|
5153
|
+
laneInjections.length > 0 ||
|
|
5154
|
+
queued.length > 0 ||
|
|
5155
|
+
steering.length > 0 ||
|
|
5156
|
+
sessionChannelBinding
|
|
4077
5157
|
? {
|
|
4078
5158
|
v: 1,
|
|
5159
|
+
...(sessionChannelBinding ? { channelBinding: sessionChannelBinding } : {}),
|
|
4079
5160
|
...(laneCompacted
|
|
4080
5161
|
? {
|
|
4081
5162
|
compaction: {
|
|
@@ -4105,7 +5186,9 @@ function chatAgent(options) {
|
|
|
4105
5186
|
chatId: payload.chatId,
|
|
4106
5187
|
clientData: opts.clientData,
|
|
4107
5188
|
turn: opts.turn,
|
|
4108
|
-
trigger: opts.trigger,
|
|
5189
|
+
trigger: opts.trigger === "action" && turnIsActionTurn ? "action-turn" : opts.trigger,
|
|
5190
|
+
source: currentTurnSource,
|
|
5191
|
+
...(currentTurnConnectorId ? { connectorId: currentTurnConnectorId } : {}),
|
|
4109
5192
|
runId: ctx.run.id,
|
|
4110
5193
|
ctx,
|
|
4111
5194
|
}, {
|
|
@@ -4188,6 +5271,9 @@ function chatAgent(options) {
|
|
|
4188
5271
|
let replayedPartial;
|
|
4189
5272
|
let replayedPartialRaw;
|
|
4190
5273
|
let replayedInTail = [];
|
|
5274
|
+
// Channel deliveries on the same tail. They are not "messages" for the recovery hooks, but
|
|
5275
|
+
// they are turns the predecessor owed, and they have to dispatch in seq order with the rest.
|
|
5276
|
+
let replayedChannelInTail = [];
|
|
4191
5277
|
// Wire payloads to dispatch as turns before the regular session.in
|
|
4192
5278
|
// pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
|
|
4193
5279
|
// default, `inFlightUsers`). The turn-loop checks this queue ahead of
|
|
@@ -4318,9 +5404,11 @@ function chatAgent(options) {
|
|
|
4318
5404
|
}
|
|
4319
5405
|
bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
|
|
4320
5406
|
try {
|
|
4321
|
-
|
|
5407
|
+
const tail = await replaySessionInTailWithChannels(payload.chatId, {
|
|
4322
5408
|
lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
|
|
4323
5409
|
});
|
|
5410
|
+
replayedInTail = tail.messages;
|
|
5411
|
+
replayedChannelInTail = tail.channelEvents;
|
|
4324
5412
|
}
|
|
4325
5413
|
catch (error) {
|
|
4326
5414
|
logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -4341,7 +5429,10 @@ function chatAgent(options) {
|
|
|
4341
5429
|
}
|
|
4342
5430
|
// ── session.in router ──────────────────────────────────────────
|
|
4343
5431
|
//
|
|
4344
|
-
const recoveredSeqNums =
|
|
5432
|
+
const recoveredSeqNums = [
|
|
5433
|
+
...replayedInTail.map((r) => r.seqNum),
|
|
5434
|
+
...replayedChannelInTail.map((r) => r.seqNum),
|
|
5435
|
+
];
|
|
4345
5436
|
await installChatInputRouter(payload.chatId, {
|
|
4346
5437
|
fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
|
|
4347
5438
|
recoveredSeqNums,
|
|
@@ -4358,6 +5449,9 @@ function chatAgent(options) {
|
|
|
4358
5449
|
// predecessor chose to end before processing the message;
|
|
4359
5450
|
// those route through the normal continuation-wait path.
|
|
4360
5451
|
const hasRecoveredState = partialAssistant !== undefined;
|
|
5452
|
+
if (couldHavePriorState && hasRecoveredState) {
|
|
5453
|
+
locals.set(chatChannelRecoveryPendingKey, { value: true });
|
|
5454
|
+
}
|
|
4361
5455
|
let hookChain;
|
|
4362
5456
|
let hookRecoveredTurns;
|
|
4363
5457
|
let hookBeforeBoot;
|
|
@@ -4428,10 +5522,18 @@ function chatAgent(options) {
|
|
|
4428
5522
|
// user is re-dispatched as a fresh turn instead.
|
|
4429
5523
|
let seedChain;
|
|
4430
5524
|
let recoveredEntries;
|
|
5525
|
+
// The orphan-partial splice pairs the partial assistant with the oldest unprocessed record,
|
|
5526
|
+
// which only makes sense when that record is the browser message it was answering. When a
|
|
5527
|
+
// channel delivery is older than every browser message, the partial belongs to it instead, and
|
|
5528
|
+
// splicing the first browser message ahead of it would reorder the conversation.
|
|
5529
|
+
const oldestTailSeq = Math.min(...replayedInTail.map((r) => r.seqNum), ...replayedChannelInTail.map((r) => r.seqNum));
|
|
5530
|
+
const partialFollowsBrowserMessage = partialAssistant !== undefined &&
|
|
5531
|
+
inFlightUsers.length > 1 &&
|
|
5532
|
+
(replayedChannelInTail.length === 0 || replayedInTail[0]?.seqNum === oldestTailSeq);
|
|
4431
5533
|
if (hookChain !== undefined) {
|
|
4432
5534
|
seedChain = hookChain;
|
|
4433
5535
|
}
|
|
4434
|
-
else if (
|
|
5536
|
+
else if (partialFollowsBrowserMessage) {
|
|
4435
5537
|
seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
|
|
4436
5538
|
}
|
|
4437
5539
|
else {
|
|
@@ -4451,7 +5553,7 @@ function chatAgent(options) {
|
|
|
4451
5553
|
seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
|
|
4452
5554
|
}));
|
|
4453
5555
|
}
|
|
4454
|
-
else if (
|
|
5556
|
+
else if (partialFollowsBrowserMessage) {
|
|
4455
5557
|
recoveredEntries = replayedInTail
|
|
4456
5558
|
.slice(1)
|
|
4457
5559
|
.map((r) => ({ message: r.message, seqNum: r.seqNum }));
|
|
@@ -4488,6 +5590,7 @@ function chatAgent(options) {
|
|
|
4488
5590
|
metadataById.set(entry.message.id, entry.metadata);
|
|
4489
5591
|
}
|
|
4490
5592
|
const dispatchedRecoveredSeqs = new Set();
|
|
5593
|
+
const toInject = [];
|
|
4491
5594
|
for (const { message: msg, seqNum } of recoveredEntries) {
|
|
4492
5595
|
if (wireMessageId && msg.id === wireMessageId)
|
|
4493
5596
|
continue;
|
|
@@ -4504,13 +5607,37 @@ function chatAgent(options) {
|
|
|
4504
5607
|
continuation: payload.continuation,
|
|
4505
5608
|
previousRunId: payload.previousRunId,
|
|
4506
5609
|
};
|
|
5610
|
+
toInject.push({ injectedPayload, seqNum });
|
|
5611
|
+
}
|
|
5612
|
+
// Channel deliveries the predecessor never processed dispatch too, interleaved with the
|
|
5613
|
+
// browser messages by seq_num so a Slack message that arrived before a browser message
|
|
5614
|
+
// still runs before it. A delivery the boot payload itself carries is not queued twice.
|
|
5615
|
+
const wireDeliveryId = payload.channelEvent
|
|
5616
|
+
?.deliveryId;
|
|
5617
|
+
for (const entry of replayedChannelInTail) {
|
|
5618
|
+
const entryDeliveryId = entry.channelEvent.deliveryId;
|
|
5619
|
+
if (wireDeliveryId && entryDeliveryId === wireDeliveryId)
|
|
5620
|
+
continue;
|
|
5621
|
+
const injectedPayload = {
|
|
5622
|
+
chatId: payload.chatId,
|
|
5623
|
+
sessionId: payload.sessionId,
|
|
5624
|
+
metadata: entry.metadata ?? payload.metadata,
|
|
5625
|
+
trigger: "submit-message",
|
|
5626
|
+
channelEvent: entry.channelEvent,
|
|
5627
|
+
continuation: payload.continuation,
|
|
5628
|
+
previousRunId: payload.previousRunId,
|
|
5629
|
+
};
|
|
5630
|
+
toInject.push({ injectedPayload, seqNum: entry.seqNum });
|
|
5631
|
+
}
|
|
5632
|
+
toInject.sort((a, b) => (a.seqNum ?? Infinity) - (b.seqNum ?? Infinity));
|
|
5633
|
+
for (const { injectedPayload, seqNum } of toInject) {
|
|
4507
5634
|
bootInjectedQueue.push(injectedPayload);
|
|
4508
5635
|
if (seqNum !== undefined) {
|
|
4509
5636
|
recoveredSeqByPayload.set(injectedPayload, seqNum);
|
|
4510
5637
|
dispatchedRecoveredSeqs.add(seqNum);
|
|
4511
5638
|
}
|
|
4512
5639
|
}
|
|
4513
|
-
for (const entry of replayedInTail) {
|
|
5640
|
+
for (const entry of [...replayedInTail, ...replayedChannelInTail]) {
|
|
4514
5641
|
if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
|
|
4515
5642
|
chatInputRouter().settleRecovered(entry.seqNum);
|
|
4516
5643
|
}
|
|
@@ -4553,15 +5680,47 @@ function chatAgent(options) {
|
|
|
4553
5680
|
catch (error) {
|
|
4554
5681
|
logger.warn("chat.agent: tools() resolver threw at boot; restored history converted without toModelOutput", { error: error instanceof Error ? error.message : String(error) });
|
|
4555
5682
|
}
|
|
4556
|
-
}
|
|
4557
|
-
try {
|
|
4558
|
-
const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
|
|
4559
|
-
locals.set(chatSteeringInjectionsKey, new Map((bootRuntimeState?.steering ?? []).map((entry) => [entry.id, entry])));
|
|
4560
|
-
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
4561
|
-
const restored = await restoreModelLane(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
|
|
4562
|
-
accumulatedMessages = restored.messages;
|
|
4563
|
-
laneCompacted = restored.compacted;
|
|
4564
|
-
laneInjections = restored.injections;
|
|
5683
|
+
}
|
|
5684
|
+
try {
|
|
5685
|
+
const bootRuntimeState = parseTranscriptRuntimeState(bootTranscriptState);
|
|
5686
|
+
locals.set(chatSteeringInjectionsKey, new Map((bootRuntimeState?.steering ?? []).map((entry) => [entry.id, entry])));
|
|
5687
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5688
|
+
const restored = await restoreModelLane(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
|
|
5689
|
+
accumulatedMessages = restored.messages;
|
|
5690
|
+
laneCompacted = restored.compacted;
|
|
5691
|
+
laneInjections = restored.injections;
|
|
5692
|
+
sessionChannelBinding = bootRuntimeState?.channelBinding;
|
|
5693
|
+
const interruptedStream = sessionChannelBinding?.stream;
|
|
5694
|
+
if (interruptedStream && sessionChannelBinding) {
|
|
5695
|
+
const binding = sessionChannelBinding;
|
|
5696
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5697
|
+
clearOpenReplyStream();
|
|
5698
|
+
if (connector?.endInterruptedStream) {
|
|
5699
|
+
try {
|
|
5700
|
+
await connector.endInterruptedStream(interruptedStream, {
|
|
5701
|
+
handle: binding.handle,
|
|
5702
|
+
bound: true,
|
|
5703
|
+
});
|
|
5704
|
+
}
|
|
5705
|
+
catch (error) {
|
|
5706
|
+
logger.warn("chat.agent: ending the interrupted reply stream failed", { error });
|
|
5707
|
+
}
|
|
5708
|
+
}
|
|
5709
|
+
}
|
|
5710
|
+
if (sessionChannelBinding?.working) {
|
|
5711
|
+
const binding = sessionChannelBinding;
|
|
5712
|
+
clearChannelWorking();
|
|
5713
|
+
await channelLifecycle.report(channels?.find((c) => c.id === binding.connectorId), "idle", { handle: binding.handle, bound: true, firstTurn: false });
|
|
5714
|
+
}
|
|
5715
|
+
if (sessionChannelBinding) {
|
|
5716
|
+
for (const message of accumulatedUIMessages) {
|
|
5717
|
+
if (message.role !== "assistant")
|
|
5718
|
+
continue;
|
|
5719
|
+
for (const call of pendingToolCallsInMessage(message)) {
|
|
5720
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
5721
|
+
}
|
|
5722
|
+
}
|
|
5723
|
+
}
|
|
4565
5724
|
if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
|
|
4566
5725
|
locals.set(chatBackgroundQueueKey, [
|
|
4567
5726
|
...(locals.get(chatBackgroundQueueKey) ?? []),
|
|
@@ -4948,26 +6107,434 @@ function chatAgent(options) {
|
|
|
4948
6107
|
// Declared here so the finally can detach it — a handler leaked past
|
|
4949
6108
|
// its turn duplicates every mid-stream message into the shared buffer.
|
|
4950
6109
|
let turnMsgSub;
|
|
6110
|
+
let turnControlSub;
|
|
4951
6111
|
let capturedPartialResponse;
|
|
4952
6112
|
let responseCommitted = false;
|
|
4953
6113
|
const turnBufferedChunks = [];
|
|
6114
|
+
let channelConn;
|
|
6115
|
+
let channelWorkingReaction;
|
|
6116
|
+
let channelWireEvent;
|
|
6117
|
+
/** The connector's posting handle for this turn: from `bind(event)` on a channel turn, else the session binding. */
|
|
6118
|
+
let channelHandle;
|
|
6119
|
+
/** True when no channel delivery started this turn and egress goes through the session binding. */
|
|
6120
|
+
let boundTurn = false;
|
|
6121
|
+
let channelAckRef;
|
|
6122
|
+
let channelStreamEditor;
|
|
6123
|
+
let droppedStaleInteraction = false;
|
|
6124
|
+
/** True when the connector mapped this delivery to an empty message: no turn runs. */
|
|
6125
|
+
let droppedEmptyMessage = false;
|
|
6126
|
+
let droppedUnknownConnector = false;
|
|
6127
|
+
/** True when this delivery was a stop the connector drops while idle: no turn runs. */
|
|
6128
|
+
let droppedIdleControl = false;
|
|
6129
|
+
/** This turn's native reply stream (`delivery: "stream"` with a `stream()` hook). */
|
|
6130
|
+
let channelReplyStream;
|
|
6131
|
+
/** True once this turn reported `working`, so its end reports the outcome. */
|
|
6132
|
+
let channelLifecycleStarted = false;
|
|
6133
|
+
/** When this turn started, to tell a stop meant for an earlier turn from one for this turn. */
|
|
6134
|
+
const channelTurnStartedAt = Date.now();
|
|
6135
|
+
/** True when this turn is a channel or bound turn that runs (not a dropped delivery). */
|
|
6136
|
+
let channelTurnRan = false;
|
|
6137
|
+
/** True when this turn resolves a pending tool call from a channel interaction callback. */
|
|
6138
|
+
let channelInteractionResolved = false;
|
|
6139
|
+
let channelFinalAnswerPosted = false;
|
|
6140
|
+
let channelFinalSendFailed = false;
|
|
6141
|
+
/** The stream-level error (an `error` chunk) this turn ended on, if any; posted to the channel in place of an empty reply. */
|
|
6142
|
+
let channelStreamErrorText;
|
|
6143
|
+
/** The original error behind that chunk, captured from `toUIMessageStream`'s `onError` when auto-piping. */
|
|
6144
|
+
let channelStreamError;
|
|
6145
|
+
/** The text this turn streamed, so the channel reply is this turn's answer and not the whole message. */
|
|
6146
|
+
let channelTurnText;
|
|
4954
6147
|
try {
|
|
4955
6148
|
// Extract turn-level context before entering the span. Slim
|
|
4956
6149
|
// wire: at most one delta message per record. `headStartMessages`
|
|
4957
6150
|
// is consumed at boot only (via `payload.headStartMessages`)
|
|
4958
6151
|
// and intentionally discarded here.
|
|
4959
|
-
const
|
|
6152
|
+
const clickedAction = channelClickAction(channels, currentWirePayload.channelEvent);
|
|
6153
|
+
let actionChannel;
|
|
6154
|
+
if (clickedAction !== undefined) {
|
|
6155
|
+
const { channelEvent: clicked, ...withoutChannelEvent } = currentWirePayload;
|
|
6156
|
+
const click = clicked;
|
|
6157
|
+
actionChannel = {
|
|
6158
|
+
connectorId: click.connectorId,
|
|
6159
|
+
provider: channels?.find((c) => c.id === click.connectorId)?.source ?? "custom",
|
|
6160
|
+
event: click.event,
|
|
6161
|
+
deliveryId: click.deliveryId,
|
|
6162
|
+
};
|
|
6163
|
+
currentWirePayload = {
|
|
6164
|
+
...withoutChannelEvent,
|
|
6165
|
+
trigger: "action",
|
|
6166
|
+
action: clickedAction,
|
|
6167
|
+
actionSource: "channel",
|
|
6168
|
+
};
|
|
6169
|
+
}
|
|
6170
|
+
const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, channelEvent: wireChannelEvent, ...restWire } = currentWirePayload;
|
|
4960
6171
|
void _hsm;
|
|
4961
|
-
|
|
4962
|
-
|
|
6172
|
+
channelWireEvent = wireChannelEvent;
|
|
6173
|
+
let effectiveIncomingMessage = incomingMessage;
|
|
6174
|
+
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
6175
|
+
turnClientData = clientData;
|
|
6176
|
+
turnIsActionTurn = false;
|
|
6177
|
+
currentTurnSource = wireChannelEvent
|
|
6178
|
+
? "channel"
|
|
6179
|
+
: currentWirePayload.actionSource === "webhook"
|
|
6180
|
+
? "webhook"
|
|
6181
|
+
: "client";
|
|
6182
|
+
currentTurnConnectorId = undefined;
|
|
6183
|
+
if (wireChannelEvent) {
|
|
6184
|
+
channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
|
|
6185
|
+
if (channelConn) {
|
|
6186
|
+
currentTurnConnectorId = channelConn.id;
|
|
6187
|
+
let idleControl = null;
|
|
6188
|
+
if (channelConn.onControl) {
|
|
6189
|
+
try {
|
|
6190
|
+
idleControl = channelConn.onControl(wireChannelEvent.event);
|
|
6191
|
+
}
|
|
6192
|
+
catch (controlError) {
|
|
6193
|
+
logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6194
|
+
error: controlError,
|
|
6195
|
+
});
|
|
6196
|
+
}
|
|
6197
|
+
}
|
|
6198
|
+
const stopForEndedTurn = idleControl?.at !== undefined &&
|
|
6199
|
+
lastChannelTurnEndedAt !== undefined &&
|
|
6200
|
+
idleControl.at < lastChannelTurnEndedAt;
|
|
6201
|
+
droppedIdleControl =
|
|
6202
|
+
idleControl?.kind === "stop" &&
|
|
6203
|
+
(idleControl.whenIdle === "drop" || stopForEndedTurn);
|
|
6204
|
+
// Bind the session to this connector so a later turn that no channel delivery
|
|
6205
|
+
// started (an action turn, a browser message) can still answer into the thread.
|
|
6206
|
+
if (channelConn.bind && !droppedIdleControl) {
|
|
6207
|
+
try {
|
|
6208
|
+
const handle = channelConn.bind(wireChannelEvent.event);
|
|
6209
|
+
if (handle !== null && handle !== undefined) {
|
|
6210
|
+
const controls = sessionChannelBinding?.connectorId === channelConn.id
|
|
6211
|
+
? sessionChannelBinding.controls
|
|
6212
|
+
: undefined;
|
|
6213
|
+
sessionChannelBinding = {
|
|
6214
|
+
connectorId: channelConn.id,
|
|
6215
|
+
handle,
|
|
6216
|
+
boundAt: new Date().toISOString(),
|
|
6217
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6218
|
+
...(controls ? { controls } : {}),
|
|
6219
|
+
};
|
|
6220
|
+
}
|
|
6221
|
+
}
|
|
6222
|
+
catch (bindError) {
|
|
6223
|
+
logger.warn("chat.agent: channel bind() failed; session binding unchanged", {
|
|
6224
|
+
error: bindError,
|
|
6225
|
+
});
|
|
6226
|
+
}
|
|
6227
|
+
}
|
|
6228
|
+
channelHandle =
|
|
6229
|
+
sessionChannelBinding?.connectorId === channelConn.id
|
|
6230
|
+
? sessionChannelBinding.handle
|
|
6231
|
+
: undefined;
|
|
6232
|
+
const rawInteraction = droppedIdleControl
|
|
6233
|
+
? null
|
|
6234
|
+
: (channelConn.onInteraction?.(wireChannelEvent.event) ?? null);
|
|
6235
|
+
let interaction = rawInteraction
|
|
6236
|
+
? (resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null)
|
|
6237
|
+
: null;
|
|
6238
|
+
const accumulatorHasInteractionToolCall = interaction != null &&
|
|
6239
|
+
accumulatedUIMessages.some((m) => (m.parts ?? []).some((p) => p?.toolCallId === interaction.toolCallId));
|
|
6240
|
+
if (rawInteraction && loadContextHook && !accumulatorHasInteractionToolCall) {
|
|
6241
|
+
try {
|
|
6242
|
+
const hydratedForInteraction = (await loadContextHook({
|
|
6243
|
+
chatId: currentWirePayload.chatId,
|
|
6244
|
+
turn,
|
|
6245
|
+
trigger: "submit-message",
|
|
6246
|
+
incomingMessages: [],
|
|
6247
|
+
previousMessages: [...accumulatedUIMessages],
|
|
6248
|
+
clientData,
|
|
6249
|
+
continuation,
|
|
6250
|
+
previousRunId,
|
|
6251
|
+
}));
|
|
6252
|
+
accumulatedUIMessages = hydratedForInteraction;
|
|
6253
|
+
accumulatedMessages = await toModelMessages(hydratedForInteraction);
|
|
6254
|
+
laneCompacted = false;
|
|
6255
|
+
laneInjections = [];
|
|
6256
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6257
|
+
if (!interaction) {
|
|
6258
|
+
interaction =
|
|
6259
|
+
resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null;
|
|
6260
|
+
}
|
|
6261
|
+
}
|
|
6262
|
+
catch (hydrateError) {
|
|
6263
|
+
logger.warn("chat.agent: hydrateMessages for channel interaction resolution failed", { error: hydrateError });
|
|
6264
|
+
}
|
|
6265
|
+
}
|
|
6266
|
+
const resolutionMessage = interaction && !channelStoppedToolCallIds.has(interaction.toolCallId)
|
|
6267
|
+
? buildInteractionResolutionMessage(interaction, accumulatedUIMessages, {
|
|
6268
|
+
source: "channel",
|
|
6269
|
+
connectorId: channelConn.id,
|
|
6270
|
+
provider: channelConn.source,
|
|
6271
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6272
|
+
})
|
|
6273
|
+
: undefined;
|
|
6274
|
+
const channelInbound = droppedIdleControl || resolutionMessage || rawInteraction
|
|
6275
|
+
? undefined
|
|
6276
|
+
: channelConn.inbound(wireChannelEvent.event);
|
|
6277
|
+
if (droppedIdleControl) {
|
|
6278
|
+
const staleStop = idleControl?.at !== undefined &&
|
|
6279
|
+
lastChannelTurnStartedAt !== undefined &&
|
|
6280
|
+
idleControl.at < lastChannelTurnStartedAt;
|
|
6281
|
+
if (!staleStop && loadContextHook && accumulatedUIMessages.length === 0) {
|
|
6282
|
+
try {
|
|
6283
|
+
const hydratedForStop = (await loadContextHook({
|
|
6284
|
+
chatId: currentWirePayload.chatId,
|
|
6285
|
+
turn,
|
|
6286
|
+
trigger: "submit-message",
|
|
6287
|
+
incomingMessages: [],
|
|
6288
|
+
previousMessages: [],
|
|
6289
|
+
clientData,
|
|
6290
|
+
continuation,
|
|
6291
|
+
previousRunId,
|
|
6292
|
+
}));
|
|
6293
|
+
accumulatedUIMessages = hydratedForStop;
|
|
6294
|
+
accumulatedMessages = await toModelMessages(hydratedForStop);
|
|
6295
|
+
laneCompacted = false;
|
|
6296
|
+
laneInjections = [];
|
|
6297
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6298
|
+
}
|
|
6299
|
+
catch (hydrateError) {
|
|
6300
|
+
logger.warn("chat.agent: hydrateMessages for a channel stop failed", {
|
|
6301
|
+
error: hydrateError,
|
|
6302
|
+
});
|
|
6303
|
+
}
|
|
6304
|
+
}
|
|
6305
|
+
let lastAssistantIndex = -1;
|
|
6306
|
+
for (let i = accumulatedUIMessages.length - 1; i >= 0; i--) {
|
|
6307
|
+
if (accumulatedUIMessages[i].role === "assistant") {
|
|
6308
|
+
lastAssistantIndex = i;
|
|
6309
|
+
break;
|
|
6310
|
+
}
|
|
6311
|
+
}
|
|
6312
|
+
const lastAssistant = lastAssistantIndex === -1
|
|
6313
|
+
? undefined
|
|
6314
|
+
: accumulatedUIMessages[lastAssistantIndex];
|
|
6315
|
+
const stoppedCalls = lastAssistant && !staleStop && idleControl?.whenIdle === "drop"
|
|
6316
|
+
? pendingToolCallsInMessage(lastAssistant)
|
|
6317
|
+
: [];
|
|
6318
|
+
if (lastAssistant && stoppedCalls.length > 0) {
|
|
6319
|
+
const errorText = idleControl?.message || "stopped";
|
|
6320
|
+
const stoppedMessage = stopPendingToolCalls(lastAssistant, new Set(stoppedCalls.map((call) => call.toolCallId)), errorText);
|
|
6321
|
+
if (stoppedMessage) {
|
|
6322
|
+
accumulatedUIMessages = accumulatedUIMessages.map((m, i) => i === lastAssistantIndex ? stoppedMessage : m);
|
|
6323
|
+
const pausedRun = await toModelMessages([
|
|
6324
|
+
stripProviderMetadata(lastAssistant),
|
|
6325
|
+
]);
|
|
6326
|
+
if (pausedRun.length === 0) {
|
|
6327
|
+
accumulatedMessages = [
|
|
6328
|
+
...accumulatedMessages,
|
|
6329
|
+
...(await toModelMessages([stoppedMessage])),
|
|
6330
|
+
];
|
|
6331
|
+
}
|
|
6332
|
+
else if (!(await replaceModelRun(accumulatedMessages, lastAssistant, stoppedMessage, 0, pausedRun))) {
|
|
6333
|
+
logger.warn("chat.agent: paused message not found at the model lane tail; reconverting the lane");
|
|
6334
|
+
accumulatedMessages = await toModelMessages(accumulatedUIMessages, accumulatedUIMessages);
|
|
6335
|
+
laneCompacted = false;
|
|
6336
|
+
laneInjections = [];
|
|
6337
|
+
}
|
|
6338
|
+
for (const call of stoppedCalls)
|
|
6339
|
+
channelStoppedToolCallIds.add(call.toolCallId);
|
|
6340
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6341
|
+
}
|
|
6342
|
+
const refs = sessionChannelBinding?.connectorId === channelConn.id
|
|
6343
|
+
? sessionChannelBinding.controls
|
|
6344
|
+
: undefined;
|
|
6345
|
+
const cancelled = stoppedCalls.map((call) => refs?.[call.toolCallId] ? { ...call, ref: refs[call.toolCallId] } : call);
|
|
6346
|
+
for (const call of stoppedCalls) {
|
|
6347
|
+
channelRenderedToolCallIds.delete(call.toolCallId);
|
|
6348
|
+
}
|
|
6349
|
+
if (refs && sessionChannelBinding) {
|
|
6350
|
+
const remaining = Object.fromEntries(Object.entries(refs).filter(([id]) => !stoppedCalls.some((call) => call.toolCallId === id)));
|
|
6351
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6352
|
+
void _controls;
|
|
6353
|
+
sessionChannelBinding =
|
|
6354
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6355
|
+
}
|
|
6356
|
+
await writeSnapshotOutsideTurn("channel-stop", { turn, clientData });
|
|
6357
|
+
if (channelConn.cancelInteraction) {
|
|
6358
|
+
try {
|
|
6359
|
+
await channelConn.cancelInteraction(cancelled, channelTurnCtx(wireChannelEvent, channelHandle));
|
|
6360
|
+
}
|
|
6361
|
+
catch (cancelError) {
|
|
6362
|
+
logger.warn("chat.agent: channel cancelInteraction failed; continuing", {
|
|
6363
|
+
error: cancelError,
|
|
6364
|
+
});
|
|
6365
|
+
}
|
|
6366
|
+
}
|
|
6367
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
6368
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6369
|
+
firstTurn: false,
|
|
6370
|
+
});
|
|
6371
|
+
}
|
|
6372
|
+
}
|
|
6373
|
+
else if (resolutionMessage) {
|
|
6374
|
+
effectiveIncomingMessage = resolutionMessage;
|
|
6375
|
+
channelInteractionResolved = true;
|
|
6376
|
+
const resolvedCallId = interaction?.toolCallId;
|
|
6377
|
+
const controlRefs = sessionChannelBinding?.controls;
|
|
6378
|
+
if (resolvedCallId && controlRefs?.[resolvedCallId] && sessionChannelBinding) {
|
|
6379
|
+
const { [resolvedCallId]: _resolved, ...remaining } = controlRefs;
|
|
6380
|
+
void _resolved;
|
|
6381
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6382
|
+
void _controls;
|
|
6383
|
+
sessionChannelBinding =
|
|
6384
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6385
|
+
}
|
|
6386
|
+
if (interaction && channelConn.finalizeInteraction) {
|
|
6387
|
+
try {
|
|
6388
|
+
await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
|
|
6389
|
+
}
|
|
6390
|
+
catch (finalizeError) {
|
|
6391
|
+
logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
|
|
6392
|
+
error: finalizeError,
|
|
6393
|
+
});
|
|
6394
|
+
}
|
|
6395
|
+
}
|
|
6396
|
+
}
|
|
6397
|
+
else if (rawInteraction) {
|
|
6398
|
+
droppedStaleInteraction = true;
|
|
6399
|
+
logger.debug("chat.agent: dropping stale channel interaction that matched no pending tool call", {
|
|
6400
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6401
|
+
});
|
|
6402
|
+
}
|
|
6403
|
+
else if (channelInbound === undefined || isEmptyChannelInbound(channelInbound)) {
|
|
6404
|
+
droppedEmptyMessage = true;
|
|
6405
|
+
logger.debug("chat.agent: dropping a channel delivery with an empty message", {
|
|
6406
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6407
|
+
});
|
|
6408
|
+
}
|
|
6409
|
+
else {
|
|
6410
|
+
// A session that starts on a conversation with history: let the connector supply
|
|
6411
|
+
// it, so the agent answers with the context a reader of the thread has.
|
|
6412
|
+
if (channelConn.seed && accumulatedUIMessages.length === 0) {
|
|
6413
|
+
try {
|
|
6414
|
+
const seeded = channelSeedMessages(await channelConn.seed(wireChannelEvent.event, {
|
|
6415
|
+
event: wireChannelEvent.event,
|
|
6416
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6417
|
+
}), {
|
|
6418
|
+
source: "channel",
|
|
6419
|
+
connectorId: channelConn.id,
|
|
6420
|
+
provider: channelConn.source,
|
|
6421
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6422
|
+
});
|
|
6423
|
+
if (seeded.length > 0) {
|
|
6424
|
+
accumulatedUIMessages = seeded;
|
|
6425
|
+
accumulatedMessages = await toModelMessages(seeded);
|
|
6426
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6427
|
+
}
|
|
6428
|
+
}
|
|
6429
|
+
catch (seedError) {
|
|
6430
|
+
logger.warn("chat.agent: channel seed() failed; starting the session empty", {
|
|
6431
|
+
error: seedError,
|
|
6432
|
+
});
|
|
6433
|
+
}
|
|
6434
|
+
}
|
|
6435
|
+
effectiveIncomingMessage = channelUserMessage(channelInbound, currentWirePayload.messageId ?? wireChannelEvent.deliveryId, {
|
|
6436
|
+
source: "channel",
|
|
6437
|
+
connectorId: channelConn.id,
|
|
6438
|
+
provider: channelConn.source,
|
|
6439
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6440
|
+
...(wireChannelEvent.externalDeliveryId
|
|
6441
|
+
? { externalDeliveryId: wireChannelEvent.externalDeliveryId }
|
|
6442
|
+
: {}),
|
|
6443
|
+
});
|
|
6444
|
+
if (channelConn.send && channelConn.ack) {
|
|
6445
|
+
const recoveryPending = locals.get(chatChannelRecoveryPendingKey);
|
|
6446
|
+
const recovered = recoveryPending?.value === true;
|
|
6447
|
+
if (recovered)
|
|
6448
|
+
recoveryPending.value = false;
|
|
6449
|
+
const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
|
|
6450
|
+
if (ackMessage) {
|
|
6451
|
+
try {
|
|
6452
|
+
const ackResult = await channelConn.send(ackMessage, {
|
|
6453
|
+
event: wireChannelEvent.event,
|
|
6454
|
+
handle: channelHandle,
|
|
6455
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6456
|
+
mode: channelConn.delivery,
|
|
6457
|
+
final: false,
|
|
6458
|
+
bound: false,
|
|
6459
|
+
});
|
|
6460
|
+
channelAckRef = ackResult?.ref;
|
|
6461
|
+
}
|
|
6462
|
+
catch (ackError) {
|
|
6463
|
+
logger.warn("chat.agent: channel ack post failed; continuing", {
|
|
6464
|
+
error: ackError,
|
|
6465
|
+
});
|
|
6466
|
+
}
|
|
6467
|
+
}
|
|
6468
|
+
}
|
|
6469
|
+
try {
|
|
6470
|
+
channelWorkingReaction = await resolveReactionChoice(channelConn.reactions?.working, wireChannelEvent.event);
|
|
6471
|
+
if (channelWorkingReaction) {
|
|
6472
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
6473
|
+
name: channelWorkingReaction,
|
|
6474
|
+
});
|
|
6475
|
+
}
|
|
6476
|
+
}
|
|
6477
|
+
catch (reactionError) {
|
|
6478
|
+
logger.warn("chat.agent: channel working reaction failed", {
|
|
6479
|
+
error: reactionError,
|
|
6480
|
+
});
|
|
6481
|
+
}
|
|
6482
|
+
}
|
|
6483
|
+
}
|
|
6484
|
+
else {
|
|
6485
|
+
droppedUnknownConnector = true;
|
|
6486
|
+
logger.warn("chat.agent: no channel connector matches this delivery; skipping turn", {
|
|
6487
|
+
connectorId: wireChannelEvent.connectorId,
|
|
6488
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6489
|
+
});
|
|
6490
|
+
}
|
|
6491
|
+
}
|
|
6492
|
+
if (!wireChannelEvent &&
|
|
6493
|
+
sessionChannelBinding &&
|
|
6494
|
+
currentWirePayload.trigger !== "close" &&
|
|
6495
|
+
currentWirePayload.trigger !== "preload") {
|
|
6496
|
+
// No channel delivery started this turn, but the session is bound to a channel: answer
|
|
6497
|
+
// into it. Reactions need the triggering message, so a bound turn adds none; its reply,
|
|
6498
|
+
// any error and any approval controls go to the bound thread.
|
|
6499
|
+
const bound = channels?.find((c) => c.id === sessionChannelBinding.connectorId);
|
|
6500
|
+
if (bound?.send) {
|
|
6501
|
+
channelConn = bound;
|
|
6502
|
+
channelHandle = sessionChannelBinding.handle;
|
|
6503
|
+
boundTurn = true;
|
|
6504
|
+
currentTurnConnectorId = bound.id;
|
|
6505
|
+
}
|
|
6506
|
+
}
|
|
6507
|
+
const incomingMessages = effectiveIncomingMessage
|
|
6508
|
+
? [effectiveIncomingMessage]
|
|
4963
6509
|
: [];
|
|
4964
6510
|
// Cleaning happens once here so `extractLastUserMessageText` and
|
|
4965
6511
|
// every downstream consumer see the same message shape — and
|
|
4966
6512
|
// `cleanupAbortedParts` no longer has to be re-applied below.
|
|
4967
6513
|
const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
|
|
4968
|
-
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
4969
|
-
turnClientData = clientData;
|
|
4970
6514
|
const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
|
|
6515
|
+
channelTurnRan =
|
|
6516
|
+
!!channelConn &&
|
|
6517
|
+
!!(wireChannelEvent || boundTurn) &&
|
|
6518
|
+
currentWirePayload.trigger !== "action" &&
|
|
6519
|
+
!droppedIdleControl &&
|
|
6520
|
+
!droppedStaleInteraction &&
|
|
6521
|
+
!droppedEmptyMessage;
|
|
6522
|
+
const startChannelLifecycle = async () => {
|
|
6523
|
+
lastChannelTurnStartedAt = channelTurnStartedAt;
|
|
6524
|
+
if (!channelConn?.lifecycle)
|
|
6525
|
+
return;
|
|
6526
|
+
channelLifecycleStarted = true;
|
|
6527
|
+
if (sessionChannelBinding?.connectorId === channelConn.id) {
|
|
6528
|
+
sessionChannelBinding = { ...sessionChannelBinding, working: true };
|
|
6529
|
+
}
|
|
6530
|
+
await channelLifecycle.report(channelConn, "working", {
|
|
6531
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6532
|
+
firstTurn: accumulatedUIMessages.every((m) => m.metadata?.seeded === true),
|
|
6533
|
+
...(lastUserMessage ? { prompt: lastUserMessage } : {}),
|
|
6534
|
+
});
|
|
6535
|
+
};
|
|
6536
|
+
if (channelTurnRan)
|
|
6537
|
+
await startChannelLifecycle();
|
|
4971
6538
|
// Actions are not turns. They use a different span name
|
|
4972
6539
|
// and don't carry a turn.number. Branched on at `isAction`.
|
|
4973
6540
|
const isAction = currentWirePayload.trigger === "action";
|
|
@@ -4982,7 +6549,7 @@ function chatAgent(options) {
|
|
|
4982
6549
|
: "tabler-message-chatbot",
|
|
4983
6550
|
[SemanticInternalAttributes.ENTITY_TYPE]: isAction ? "chat-action" : "chat-turn",
|
|
4984
6551
|
};
|
|
4985
|
-
if (lastUserMessage) {
|
|
6552
|
+
if (lastUserMessage && recordUserMessage) {
|
|
4986
6553
|
turnAttributes["chat.user_message"] = lastUserMessage;
|
|
4987
6554
|
// Show a truncated preview of the user message as an accessory
|
|
4988
6555
|
const preview = lastUserMessage.length > 80
|
|
@@ -4993,7 +6560,7 @@ function chatAgent(options) {
|
|
|
4993
6560
|
style: "codepath",
|
|
4994
6561
|
}));
|
|
4995
6562
|
}
|
|
4996
|
-
if (wireMetadata !== undefined) {
|
|
6563
|
+
if (wireMetadata !== undefined && recordClientData) {
|
|
4997
6564
|
turnAttributes["chat.client_data"] =
|
|
4998
6565
|
typeof wireMetadata === "string" ? wireMetadata : JSON.stringify(wireMetadata);
|
|
4999
6566
|
}
|
|
@@ -5018,11 +6585,16 @@ function chatAgent(options) {
|
|
|
5018
6585
|
turn,
|
|
5019
6586
|
continuation,
|
|
5020
6587
|
clientData,
|
|
6588
|
+
recordUserMessage,
|
|
5021
6589
|
});
|
|
5022
6590
|
// Resolve the per-turn `tools` set now that turn context
|
|
5023
6591
|
// (incl. parsed clientData) exists, so every toModelMessages
|
|
5024
6592
|
// call this turn can re-apply tool `toModelOutput`.
|
|
5025
6593
|
await resolveTurnTools();
|
|
6594
|
+
// The channel surface for this turn, handed to run() and reachable from a tool's
|
|
6595
|
+
// execute through chat.surface(), so a connector can ship tools the model drives.
|
|
6596
|
+
const channelSurface = buildChannelRunSurface(channelConn, wireChannelEvent, channelHandle);
|
|
6597
|
+
locals.set(chatChannelSurfaceKey, channelSurface);
|
|
5026
6598
|
// Per-turn stop controller (reset each turn)
|
|
5027
6599
|
const stopController = new AbortController();
|
|
5028
6600
|
currentStopController = stopController;
|
|
@@ -5044,12 +6616,19 @@ function chatAgent(options) {
|
|
|
5044
6616
|
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
|
|
5045
6617
|
const msg = record.data
|
|
5046
6618
|
.payload;
|
|
6619
|
+
const wireChannel = msg
|
|
6620
|
+
.channelEvent;
|
|
6621
|
+
const steeredChannel = wireChannel != null
|
|
6622
|
+
? channelSteeringMessage(channels, wireChannel, msg.messageId)
|
|
6623
|
+
: undefined;
|
|
6624
|
+
if (wireChannel != null && !steeredChannel)
|
|
6625
|
+
return;
|
|
5047
6626
|
// Slim wire: at most one delta message per record. The
|
|
5048
6627
|
// pendingMessages handler reads `msg.message` directly
|
|
5049
6628
|
// instead of slicing an array — a wire record arrives
|
|
5050
6629
|
// with the new user message in `.message`, or no message
|
|
5051
6630
|
// at all (regenerate / preload / close / handover-prepare).
|
|
5052
|
-
const lastUIMessage = msg.message;
|
|
6631
|
+
const lastUIMessage = (steeredChannel?.message ?? msg.message);
|
|
5053
6632
|
if (lastUIMessage) {
|
|
5054
6633
|
if (pmConfig.onReceived) {
|
|
5055
6634
|
try {
|
|
@@ -5070,7 +6649,10 @@ function chatAgent(options) {
|
|
|
5070
6649
|
queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
|
|
5071
6650
|
return;
|
|
5072
6651
|
}
|
|
5073
|
-
const
|
|
6652
|
+
const converted = await toModelMessages([lastUIMessage]);
|
|
6653
|
+
const modelMsgs = steeredChannel && channelDownloader
|
|
6654
|
+
? await resolveChannelFiles(converted, channelDownloader)
|
|
6655
|
+
: converted;
|
|
5074
6656
|
queue.push({
|
|
5075
6657
|
uiMessage: lastUIMessage,
|
|
5076
6658
|
modelMessages: modelMsgs,
|
|
@@ -5085,6 +6667,52 @@ function chatAgent(options) {
|
|
|
5085
6667
|
})
|
|
5086
6668
|
: undefined;
|
|
5087
6669
|
turnMsgSub = msgSub;
|
|
6670
|
+
/**
|
|
6671
|
+
* Channel deliveries while this turn runs. A delivery the connector maps to `stop`
|
|
6672
|
+
* aborts this turn through the same controller the stop input uses, and is taken
|
|
6673
|
+
* off the route so it does not also run as a turn. Any other message is
|
|
6674
|
+
* acknowledged through the connector's `received` as soon as it arrives, and left
|
|
6675
|
+
* queued for steering or the next turn.
|
|
6676
|
+
*/
|
|
6677
|
+
const controlSub = channels?.some((c) => c.onControl || c.received)
|
|
6678
|
+
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
|
|
6679
|
+
const msg = record.data
|
|
6680
|
+
.payload;
|
|
6681
|
+
const ce = msg.channelEvent;
|
|
6682
|
+
if (!ce)
|
|
6683
|
+
return;
|
|
6684
|
+
const conn = channels?.find((c) => c.id === ce.connectorId);
|
|
6685
|
+
if (!conn)
|
|
6686
|
+
return;
|
|
6687
|
+
let control = null;
|
|
6688
|
+
try {
|
|
6689
|
+
control = conn.onControl?.(ce.event) ?? null;
|
|
6690
|
+
}
|
|
6691
|
+
catch (controlError) {
|
|
6692
|
+
logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6693
|
+
error: controlError,
|
|
6694
|
+
});
|
|
6695
|
+
}
|
|
6696
|
+
if (!control) {
|
|
6697
|
+
acknowledgeChannelMessage(conn, ce, sessionChannelBinding?.connectorId === conn.id
|
|
6698
|
+
? sessionChannelBinding.handle
|
|
6699
|
+
: undefined);
|
|
6700
|
+
return;
|
|
6701
|
+
}
|
|
6702
|
+
if (control.kind !== "stop")
|
|
6703
|
+
return;
|
|
6704
|
+
const earlierTurnStop = control.at !== undefined && control.at < channelTurnStartedAt;
|
|
6705
|
+
if (!earlierTurnStop && !stopController.signal.aborted) {
|
|
6706
|
+
stopController.abort(new DOMException(control.message || "stopped", "AbortError"));
|
|
6707
|
+
}
|
|
6708
|
+
// Observers are offered a record before it is queued, so the take has to
|
|
6709
|
+
// wait a tick or it finds nothing and the stop runs again as a turn.
|
|
6710
|
+
void Promise.resolve().then(() => {
|
|
6711
|
+
chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
|
|
6712
|
+
});
|
|
6713
|
+
})
|
|
6714
|
+
: undefined;
|
|
6715
|
+
turnControlSub = controlSub;
|
|
5088
6716
|
// Track new messages for this turn (user input + assistant response).
|
|
5089
6717
|
const turnNewModelMessages = [];
|
|
5090
6718
|
const turnNewUIMessages = [];
|
|
@@ -5114,7 +6742,8 @@ function chatAgent(options) {
|
|
|
5114
6742
|
let actionChangedHistory = false;
|
|
5115
6743
|
if (isAction) {
|
|
5116
6744
|
// Parse and validate the action payload
|
|
5117
|
-
const
|
|
6745
|
+
const isWebhookAction = currentWirePayload.actionSource === "webhook";
|
|
6746
|
+
const parsedAction = parseAction && !isWebhookAction
|
|
5118
6747
|
? await parseAction(currentWirePayload.action)
|
|
5119
6748
|
: currentWirePayload.action;
|
|
5120
6749
|
// Hydrate messages from backend if configured
|
|
@@ -5155,6 +6784,7 @@ function chatAgent(options) {
|
|
|
5155
6784
|
clientData,
|
|
5156
6785
|
uiMessages: accumulatedUIMessages,
|
|
5157
6786
|
messages: accumulatedMessages,
|
|
6787
|
+
...(actionChannel ? { channel: actionChannel } : {}),
|
|
5158
6788
|
});
|
|
5159
6789
|
}, {
|
|
5160
6790
|
attributes: {
|
|
@@ -5189,7 +6819,11 @@ function chatAgent(options) {
|
|
|
5189
6819
|
// snapshot + `session.out` replay (or `hydrateMessages`,
|
|
5190
6820
|
// which also fires per-turn below). Per-turn handling is
|
|
5191
6821
|
// therefore a delta merge, not a full-history reset.
|
|
5192
|
-
if (currentWirePayload.trigger !== "action"
|
|
6822
|
+
if (currentWirePayload.trigger !== "action" &&
|
|
6823
|
+
!droppedStaleInteraction &&
|
|
6824
|
+
!droppedEmptyMessage &&
|
|
6825
|
+
!droppedUnknownConnector &&
|
|
6826
|
+
!droppedIdleControl) {
|
|
5193
6827
|
let cleanedUIMessages = cleanedIncomingMessages;
|
|
5194
6828
|
// Turn-0 head-start with hydrateMessages: the boot seeding from
|
|
5195
6829
|
// `payload.headStartMessages` is non-hydrate-only, so ship the
|
|
@@ -5441,17 +7075,41 @@ function chatAgent(options) {
|
|
|
5441
7075
|
// calling the model would prefill its own last reply. Keyed on
|
|
5442
7076
|
// the model tail, so a `tool`-terminated chain (a merged tool
|
|
5443
7077
|
// approval) still runs.
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
7078
|
+
// A channel delivery the run decided not to turn into a turn: a stale
|
|
7079
|
+
// interaction callback (no pending tool call matched) or an event for
|
|
7080
|
+
// a connector this agent does not list. Nothing to answer either way.
|
|
7081
|
+
const isDroppedChannelDelivery = droppedStaleInteraction ||
|
|
7082
|
+
droppedEmptyMessage ||
|
|
7083
|
+
droppedUnknownConnector ||
|
|
7084
|
+
droppedIdleControl;
|
|
7085
|
+
// A resolved interaction is never a no-op: the assistant may already have text after
|
|
7086
|
+
// the pending call, so the lane ends on an assistant message, but the model has not
|
|
7087
|
+
// seen the decision yet.
|
|
7088
|
+
const isNoOpTurn = isDroppedChannelDelivery ||
|
|
7089
|
+
(!isAction &&
|
|
7090
|
+
!splicedHandoverPartial &&
|
|
7091
|
+
!channelInteractionResolved &&
|
|
7092
|
+
currentWirePayload.trigger === "submit-message" &&
|
|
7093
|
+
turnNewUIMessages.length === 0 &&
|
|
7094
|
+
accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant");
|
|
5449
7095
|
if (isNoOpTurn) {
|
|
5450
7096
|
msgSub?.off();
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
7097
|
+
turnControlSub?.off();
|
|
7098
|
+
if (channelTurnRan)
|
|
7099
|
+
lastChannelTurnEndedAt = Date.now();
|
|
7100
|
+
clearChannelWorking();
|
|
7101
|
+
if (channelLifecycleStarted) {
|
|
7102
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
7103
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7104
|
+
firstTurn: false,
|
|
7105
|
+
});
|
|
7106
|
+
}
|
|
7107
|
+
if (!isDroppedChannelDelivery) {
|
|
7108
|
+
logger.warn("chat.agent: turn added no new user message; skipping the model", {
|
|
7109
|
+
chatId: currentWirePayload.chatId,
|
|
7110
|
+
messageId: currentWirePayload.messageId,
|
|
7111
|
+
});
|
|
7112
|
+
}
|
|
5455
7113
|
settleRecoveredTurn(currentWirePayload);
|
|
5456
7114
|
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
5457
7115
|
// Not a turn — don't consume an iteration.
|
|
@@ -5467,6 +7125,24 @@ function chatAgent(options) {
|
|
|
5467
7125
|
// sees the same `turn` value — actions don't count.
|
|
5468
7126
|
if (isAction) {
|
|
5469
7127
|
if (isActionTurn(actionResult)) {
|
|
7128
|
+
if (actionResult.message !== undefined) {
|
|
7129
|
+
const asked = (typeof actionResult.message === "string"
|
|
7130
|
+
? {
|
|
7131
|
+
id: generateMessageId(),
|
|
7132
|
+
role: "user",
|
|
7133
|
+
parts: [{ type: "text", text: actionResult.message }],
|
|
7134
|
+
}
|
|
7135
|
+
: actionResult.message.id
|
|
7136
|
+
? actionResult.message
|
|
7137
|
+
: { ...actionResult.message, id: generateMessageId() });
|
|
7138
|
+
accumulatedUIMessages = [...accumulatedUIMessages, asked];
|
|
7139
|
+
accumulatedMessages = [
|
|
7140
|
+
...accumulatedMessages,
|
|
7141
|
+
...(await toModelMessages([asked])),
|
|
7142
|
+
];
|
|
7143
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
7144
|
+
actionChangedHistory = true;
|
|
7145
|
+
}
|
|
5470
7146
|
// Persist the edit before the turn starts, so a turn that is
|
|
5471
7147
|
// cancelled or runs out of memory continues from the edited
|
|
5472
7148
|
// history rather than from the snapshot the edit replaced.
|
|
@@ -5475,6 +7151,11 @@ function chatAgent(options) {
|
|
|
5475
7151
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5476
7152
|
}
|
|
5477
7153
|
actionTurn = true;
|
|
7154
|
+
turnIsActionTurn = true;
|
|
7155
|
+
if (boundTurn && channelConn) {
|
|
7156
|
+
channelTurnRan = true;
|
|
7157
|
+
await startChannelLifecycle();
|
|
7158
|
+
}
|
|
5478
7159
|
}
|
|
5479
7160
|
else if (actionResult !== undefined) {
|
|
5480
7161
|
throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
|
|
@@ -5483,6 +7164,7 @@ function chatAgent(options) {
|
|
|
5483
7164
|
}
|
|
5484
7165
|
else {
|
|
5485
7166
|
msgSub?.off();
|
|
7167
|
+
turnControlSub?.off();
|
|
5486
7168
|
if (actionChangedHistory) {
|
|
5487
7169
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5488
7170
|
}
|
|
@@ -5684,12 +7366,87 @@ function chatAgent(options) {
|
|
|
5684
7366
|
}
|
|
5685
7367
|
else {
|
|
5686
7368
|
const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
|
|
7369
|
+
const runMessages = channelDownloader
|
|
7370
|
+
? await resolveChannelFiles(preparedMessages, channelDownloader)
|
|
7371
|
+
: preparedMessages;
|
|
7372
|
+
// Channel observers for this turn are built before run(). The progress
|
|
7373
|
+
// observer and the native reply stream are shared with a manual chat.pipe()
|
|
7374
|
+
// inside run() through the turn tap and stay open across several pipes; the
|
|
7375
|
+
// ack stream editor only rides the automatic pipe, because a manual pipe
|
|
7376
|
+
// captures no response and posts no final reply that would supersede a
|
|
7377
|
+
// partial edit. The finally of this turn stops all of them and clears the tap;
|
|
7378
|
+
// the turn boundary ends the native stream.
|
|
7379
|
+
if (channelConn) {
|
|
7380
|
+
const observers = [];
|
|
7381
|
+
const tapObservers = [];
|
|
7382
|
+
if (channelConn.delivery === "stream" &&
|
|
7383
|
+
channelConn.stream &&
|
|
7384
|
+
(wireChannelEvent || channelHandle)) {
|
|
7385
|
+
const streamConnectorId = channelConn.id;
|
|
7386
|
+
const opened = (ref) => {
|
|
7387
|
+
if (sessionChannelBinding?.connectorId !== streamConnectorId)
|
|
7388
|
+
return;
|
|
7389
|
+
sessionChannelBinding = { ...sessionChannelBinding, stream: ref };
|
|
7390
|
+
if (hydrateMessages)
|
|
7391
|
+
return;
|
|
7392
|
+
void saveTranscript({
|
|
7393
|
+
reason: "turn-start",
|
|
7394
|
+
messages: accumulatedUIMessages,
|
|
7395
|
+
turn,
|
|
7396
|
+
trigger: storageTrigger(currentWirePayload.trigger),
|
|
7397
|
+
clientData,
|
|
7398
|
+
lastOutEventId: lastSnapshotOutEventId,
|
|
7399
|
+
skipIfUnchanged: true,
|
|
7400
|
+
carryInCursor: true,
|
|
7401
|
+
}).catch((error) => {
|
|
7402
|
+
logger.warn("chat.agent: saving the open reply stream failed", {
|
|
7403
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7404
|
+
});
|
|
7405
|
+
});
|
|
7406
|
+
};
|
|
7407
|
+
try {
|
|
7408
|
+
channelReplyStream =
|
|
7409
|
+
channelConn.stream({
|
|
7410
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7411
|
+
opened,
|
|
7412
|
+
}) ?? undefined;
|
|
7413
|
+
}
|
|
7414
|
+
catch (streamError) {
|
|
7415
|
+
logger.warn("chat.agent: channel stream() threw; posting the reply", {
|
|
7416
|
+
error: streamError,
|
|
7417
|
+
});
|
|
7418
|
+
}
|
|
7419
|
+
}
|
|
7420
|
+
if (channelReplyStream) {
|
|
7421
|
+
const replyObserver = makeChannelReplyStreamObserver(channelReplyStream);
|
|
7422
|
+
observers.push(replyObserver);
|
|
7423
|
+
tapObservers.push(replyObserver);
|
|
7424
|
+
}
|
|
7425
|
+
else if (wireChannelEvent &&
|
|
7426
|
+
channelConn.send &&
|
|
7427
|
+
channelConn.delivery === "stream" &&
|
|
7428
|
+
channelAckRef) {
|
|
7429
|
+
observers.push(makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef, channelHandle));
|
|
7430
|
+
}
|
|
7431
|
+
if (channelConn.progress && (wireChannelEvent || channelHandle)) {
|
|
7432
|
+
const progressObserver = makeChannelProgressObserver(channelConn, wireChannelEvent, channelHandle);
|
|
7433
|
+
observers.push(progressObserver);
|
|
7434
|
+
tapObservers.push(progressObserver);
|
|
7435
|
+
}
|
|
7436
|
+
if (tapObservers.length > 0) {
|
|
7437
|
+
const tapObserver = composeChannelObservers(tapObservers);
|
|
7438
|
+
locals.set(chatTurnChannelTapKey, (stream) => stream.pipeThrough(makeChannelStreamTap(tapObserver, { stopOnEnd: false })));
|
|
7439
|
+
}
|
|
7440
|
+
if (observers.length > 0) {
|
|
7441
|
+
channelStreamEditor = composeChannelObservers(observers);
|
|
7442
|
+
}
|
|
7443
|
+
}
|
|
5687
7444
|
runResult = await userRun({
|
|
5688
7445
|
...restWire,
|
|
5689
7446
|
// A turn requested by chat.turn() is not the action itself:
|
|
5690
7447
|
// a run() that short-circuits on "action" must still answer.
|
|
5691
7448
|
...(actionTurn ? { trigger: "action-turn" } : {}),
|
|
5692
|
-
messages:
|
|
7449
|
+
messages: runMessages,
|
|
5693
7450
|
clientData,
|
|
5694
7451
|
continuation,
|
|
5695
7452
|
previousRunId,
|
|
@@ -5702,6 +7459,7 @@ function chatAgent(options) {
|
|
|
5702
7459
|
cancelSignal,
|
|
5703
7460
|
stopSignal,
|
|
5704
7461
|
streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
|
|
7462
|
+
channel: channelSurface,
|
|
5705
7463
|
});
|
|
5706
7464
|
}
|
|
5707
7465
|
// Auto-pipe if the run function returned a StreamTextResult or similar,
|
|
@@ -5728,6 +7486,12 @@ function chatAgent(options) {
|
|
|
5728
7486
|
// messageId. Without this, the frontend and backend generate IDs
|
|
5729
7487
|
// independently and they won't match for ID-based dedup.
|
|
5730
7488
|
generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId,
|
|
7489
|
+
onError: (error) => {
|
|
7490
|
+
channelStreamError ??= error;
|
|
7491
|
+
return resolvedOptions.onError
|
|
7492
|
+
? resolvedOptions.onError(error)
|
|
7493
|
+
: uiStreamErrorText(error);
|
|
7494
|
+
},
|
|
5731
7495
|
onFinish: ({ responseMessage, finishReason, }) => {
|
|
5732
7496
|
capturedResponseMessage = responseMessage;
|
|
5733
7497
|
capturedPartialResponse = responseMessage;
|
|
@@ -5735,7 +7499,13 @@ function chatAgent(options) {
|
|
|
5735
7499
|
resolveOnFinish();
|
|
5736
7500
|
},
|
|
5737
7501
|
});
|
|
5738
|
-
|
|
7502
|
+
// The automatic pipe feeds every observer (progress and the ack stream editor)
|
|
7503
|
+
// itself; the manual tap is cleared so pipeChat does not feed progress twice.
|
|
7504
|
+
locals.set(chatTurnChannelTapKey, undefined);
|
|
7505
|
+
const streamForPipe = channelStreamEditor && uiStream instanceof ReadableStream
|
|
7506
|
+
? uiStream.pipeThrough(makeChannelStreamTap(channelStreamEditor))
|
|
7507
|
+
: uiStream;
|
|
7508
|
+
await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
|
|
5739
7509
|
signal: combinedSignal,
|
|
5740
7510
|
spanName: "stream response",
|
|
5741
7511
|
}, { originalMessages: isActionTurn ? undefined : accumulatedUIMessages });
|
|
@@ -5758,6 +7528,9 @@ function chatAgent(options) {
|
|
|
5758
7528
|
}
|
|
5759
7529
|
finally {
|
|
5760
7530
|
msgSub?.off();
|
|
7531
|
+
turnControlSub?.off();
|
|
7532
|
+
locals.set(chatTurnChannelTapKey, undefined);
|
|
7533
|
+
await channelStreamEditor?.stop();
|
|
5761
7534
|
}
|
|
5762
7535
|
// Wait for onFinish to fire — on abort this may resolve slightly
|
|
5763
7536
|
// after pipeChat, since the stream's cancel() handler is async.
|
|
@@ -6001,6 +7774,16 @@ function chatAgent(options) {
|
|
|
6001
7774
|
if (capturedResponseMessage) {
|
|
6002
7775
|
responseCommitted = true;
|
|
6003
7776
|
capturedPartialResponse = capturedResponseMessage;
|
|
7777
|
+
const errorChunk = turnBufferedChunks.find((chunk) => chunk.type === "error");
|
|
7778
|
+
channelStreamErrorText = errorChunk?.errorText;
|
|
7779
|
+
channelTurnText = turnBufferedChunks
|
|
7780
|
+
.map((chunk) => {
|
|
7781
|
+
const c = chunk;
|
|
7782
|
+
return c.type === "text-delta" && typeof c.delta === "string"
|
|
7783
|
+
? c.delta
|
|
7784
|
+
: "";
|
|
7785
|
+
})
|
|
7786
|
+
.join("");
|
|
6004
7787
|
turnBufferedChunks.length = 0;
|
|
6005
7788
|
}
|
|
6006
7789
|
if (runSignal.aborted)
|
|
@@ -6220,8 +8003,206 @@ function chatAgent(options) {
|
|
|
6220
8003
|
}
|
|
6221
8004
|
}
|
|
6222
8005
|
settleRecoveredTurn(currentWirePayload);
|
|
8006
|
+
openChannelEgressLatch(currentWirePayload.chatId);
|
|
6223
8007
|
// Write turn-complete control chunk — closes the frontend stream.
|
|
6224
8008
|
const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
|
|
8009
|
+
// Tool calls that have not had controls posted yet; a resume with the rest still
|
|
8010
|
+
// pending must not repost them or hide the answer text behind them.
|
|
8011
|
+
const pendingToolCalls = (wireChannelEvent || boundTurn) &&
|
|
8012
|
+
channelConn?.send &&
|
|
8013
|
+
turnCompleteEvent.responseMessage
|
|
8014
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).filter((call) => !channelRenderedToolCallIds.has(call.toolCallId))
|
|
8015
|
+
: [];
|
|
8016
|
+
const renderControls = !!((wireChannelEvent || boundTurn) &&
|
|
8017
|
+
pendingToolCalls.length > 0 &&
|
|
8018
|
+
channelConn?.renderInteraction);
|
|
8019
|
+
let channelErrorLine;
|
|
8020
|
+
if (channelStreamErrorText && (wireChannelEvent || boundTurn)) {
|
|
8021
|
+
logger.warn("chat.agent: channel turn ended on a model or stream error", {
|
|
8022
|
+
errorText: channelStreamErrorText,
|
|
8023
|
+
});
|
|
8024
|
+
channelErrorLine = await channelTurnErrorMessage(channelConn, {
|
|
8025
|
+
error: channelStreamError instanceof Error
|
|
8026
|
+
? channelStreamError
|
|
8027
|
+
: new Error(channelStreamErrorText),
|
|
8028
|
+
partialText: channelTurnText?.trim() ?? "",
|
|
8029
|
+
event: channelWireEvent?.event,
|
|
8030
|
+
runId: ctx.run.id,
|
|
8031
|
+
chatId: currentWirePayload.chatId,
|
|
8032
|
+
});
|
|
8033
|
+
}
|
|
8034
|
+
let channelReplyDelivered = false;
|
|
8035
|
+
if (channelReplyStream) {
|
|
8036
|
+
const replyStream = channelReplyStream;
|
|
8037
|
+
channelReplyStream = undefined;
|
|
8038
|
+
try {
|
|
8039
|
+
const ended = await replyStream.end({
|
|
8040
|
+
stopped: turnCompleteEvent.stopped,
|
|
8041
|
+
pending: renderControls,
|
|
8042
|
+
...(channelStreamErrorText
|
|
8043
|
+
? { errorText: channelErrorLine ?? CHANNEL_TURN_ERROR_MESSAGE }
|
|
8044
|
+
: {}),
|
|
8045
|
+
});
|
|
8046
|
+
channelReplyDelivered = ended.delivered;
|
|
8047
|
+
}
|
|
8048
|
+
catch (streamError) {
|
|
8049
|
+
logger.warn("chat.agent: channel reply stream end failed", {
|
|
8050
|
+
error: streamError,
|
|
8051
|
+
});
|
|
8052
|
+
}
|
|
8053
|
+
finally {
|
|
8054
|
+
clearOpenReplyStream();
|
|
8055
|
+
}
|
|
8056
|
+
}
|
|
8057
|
+
// Channel egress: map the turn's reply via outbound() and send it, editing the ack
|
|
8058
|
+
// placeholder posted at turn start (previousRef). A failed send is retried; one that
|
|
8059
|
+
// never lands is logged and the turn gets the error reaction. A manual-pipe turn has
|
|
8060
|
+
// no responseMessage, so it opts out.
|
|
8061
|
+
const finalChannelConn = channelConn;
|
|
8062
|
+
if ((wireChannelEvent || boundTurn) &&
|
|
8063
|
+
finalChannelConn?.send &&
|
|
8064
|
+
turnCompleteEvent.responseMessage) {
|
|
8065
|
+
if (renderControls) {
|
|
8066
|
+
for (const call of pendingToolCalls) {
|
|
8067
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
8068
|
+
}
|
|
8069
|
+
}
|
|
8070
|
+
const replyText = channelTurnText && channelTurnText.trim().length > 0
|
|
8071
|
+
? channelTurnText
|
|
8072
|
+
: channelReplyText(turnCompleteEvent.responseMessage);
|
|
8073
|
+
// A model or stream error surfaces as an `error` chunk and an empty reply, not as a
|
|
8074
|
+
// thrown turn. The mapper gets it on this one call, with the connector's error line
|
|
8075
|
+
// standing in for an empty reply, so the placeholder never sits there as if the agent
|
|
8076
|
+
// were still working. The error text itself stays on the run, not in the channel.
|
|
8077
|
+
const finalReply = {
|
|
8078
|
+
// Prefer what this turn streamed; a resumed message carries earlier turns' text too.
|
|
8079
|
+
// A failed turn always ends on the connector's error line, after any partial text.
|
|
8080
|
+
text: channelErrorLine
|
|
8081
|
+
? replyText.trim().length > 0
|
|
8082
|
+
? `${replyText.trimEnd()}\n\n${channelErrorLine}`
|
|
8083
|
+
: channelErrorLine
|
|
8084
|
+
: replyText,
|
|
8085
|
+
message: turnCompleteEvent.responseMessage,
|
|
8086
|
+
final: true,
|
|
8087
|
+
stopped: turnCompleteEvent.stopped,
|
|
8088
|
+
...(renderControls ? { pending: true } : {}),
|
|
8089
|
+
...(channelStreamErrorText
|
|
8090
|
+
? { error: new Error(channelStreamErrorText) }
|
|
8091
|
+
: {}),
|
|
8092
|
+
};
|
|
8093
|
+
let outboundMessage = null;
|
|
8094
|
+
if (!channelReplyDelivered) {
|
|
8095
|
+
try {
|
|
8096
|
+
outboundMessage = (finalChannelConn.outbound ?? defaultChannelOutbound)(finalReply);
|
|
8097
|
+
}
|
|
8098
|
+
catch (outboundError) {
|
|
8099
|
+
logger.warn("chat.agent: channel outbound failed; posting the plain reply", {
|
|
8100
|
+
error: outboundError,
|
|
8101
|
+
});
|
|
8102
|
+
outboundMessage = defaultChannelOutbound(finalReply) ?? {
|
|
8103
|
+
text: "An unexpected error occurred",
|
|
8104
|
+
};
|
|
8105
|
+
}
|
|
8106
|
+
}
|
|
8107
|
+
const sendFinal = async (message) => {
|
|
8108
|
+
for (let attempt = 1;; attempt++) {
|
|
8109
|
+
try {
|
|
8110
|
+
const sent = await finalChannelConn.send(message, {
|
|
8111
|
+
event: wireChannelEvent?.event,
|
|
8112
|
+
handle: channelHandle,
|
|
8113
|
+
deliveryId: wireChannelEvent?.deliveryId,
|
|
8114
|
+
previousRef: channelAckRef,
|
|
8115
|
+
mode: finalChannelConn.delivery,
|
|
8116
|
+
final: true,
|
|
8117
|
+
bound: boundTurn,
|
|
8118
|
+
});
|
|
8119
|
+
channelFinalAnswerPosted = true;
|
|
8120
|
+
return sent ?? {};
|
|
8121
|
+
}
|
|
8122
|
+
catch (egressError) {
|
|
8123
|
+
if (attempt >= CHANNEL_FINAL_SEND_ATTEMPTS || !channelAckRef) {
|
|
8124
|
+
channelFinalSendFailed = true;
|
|
8125
|
+
logger.error("chat.agent: channel egress send failed", {
|
|
8126
|
+
error: egressError,
|
|
8127
|
+
attempts: attempt,
|
|
8128
|
+
});
|
|
8129
|
+
return undefined;
|
|
8130
|
+
}
|
|
8131
|
+
logger.warn("chat.agent: channel egress send failed; retrying", {
|
|
8132
|
+
error: egressError,
|
|
8133
|
+
attempt,
|
|
8134
|
+
});
|
|
8135
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
8136
|
+
}
|
|
8137
|
+
}
|
|
8138
|
+
};
|
|
8139
|
+
// A turn that pauses on a human decision often explains itself first (a summary,
|
|
8140
|
+
// the steps so far) and then calls the tool. That text is the answer so far: post it
|
|
8141
|
+
// as the placeholder edit, and post the controls as their own message after it.
|
|
8142
|
+
if (renderControls && outboundMessage && (await sendFinal(outboundMessage))) {
|
|
8143
|
+
channelAckRef = undefined;
|
|
8144
|
+
}
|
|
8145
|
+
const channelMessage = renderControls
|
|
8146
|
+
? finalChannelConn.renderInteraction(pendingToolCalls, wireChannelEvent
|
|
8147
|
+
? {
|
|
8148
|
+
event: wireChannelEvent.event,
|
|
8149
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
8150
|
+
}
|
|
8151
|
+
: {})
|
|
8152
|
+
: outboundMessage;
|
|
8153
|
+
if (channelMessage) {
|
|
8154
|
+
const controlsRef = (await sendFinal(channelMessage))?.ref;
|
|
8155
|
+
if (renderControls &&
|
|
8156
|
+
controlsRef &&
|
|
8157
|
+
sessionChannelBinding?.connectorId === finalChannelConn.id) {
|
|
8158
|
+
sessionChannelBinding = {
|
|
8159
|
+
...sessionChannelBinding,
|
|
8160
|
+
controls: {
|
|
8161
|
+
...sessionChannelBinding.controls,
|
|
8162
|
+
...Object.fromEntries(pendingToolCalls.map((call) => [call.toolCallId, controlsRef])),
|
|
8163
|
+
},
|
|
8164
|
+
};
|
|
8165
|
+
}
|
|
8166
|
+
}
|
|
8167
|
+
}
|
|
8168
|
+
if (channelTurnRan)
|
|
8169
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8170
|
+
clearChannelWorking();
|
|
8171
|
+
if (channelLifecycleStarted) {
|
|
8172
|
+
const stillPending = turnCompleteEvent.responseMessage
|
|
8173
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).length > 0
|
|
8174
|
+
: false;
|
|
8175
|
+
await channelLifecycle.report(channelConn, locals.get(chatCloseRequestedKey)
|
|
8176
|
+
? "closed"
|
|
8177
|
+
: stillPending
|
|
8178
|
+
? "waiting"
|
|
8179
|
+
: "idle", { ...channelTurnCtx(wireChannelEvent, channelHandle), firstTurn: false });
|
|
8180
|
+
}
|
|
8181
|
+
// Lifecycle reaction: turn done. Remove "working", mark "done".
|
|
8182
|
+
if (wireChannelEvent && channelConn?.react) {
|
|
8183
|
+
try {
|
|
8184
|
+
if (channelWorkingReaction) {
|
|
8185
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8186
|
+
name: channelWorkingReaction,
|
|
8187
|
+
remove: true,
|
|
8188
|
+
});
|
|
8189
|
+
}
|
|
8190
|
+
const outcomeReaction = await resolveReactionChoice(channelStreamErrorText || channelFinalSendFailed
|
|
8191
|
+
? channelConn.reactions?.error
|
|
8192
|
+
: channelConn.reactions?.done, wireChannelEvent.event);
|
|
8193
|
+
if (outcomeReaction) {
|
|
8194
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8195
|
+
name: outcomeReaction,
|
|
8196
|
+
});
|
|
8197
|
+
}
|
|
8198
|
+
}
|
|
8199
|
+
catch (reactionError) {
|
|
8200
|
+
logger.warn("chat.agent: channel done reaction failed", {
|
|
8201
|
+
error: reactionError,
|
|
8202
|
+
});
|
|
8203
|
+
}
|
|
8204
|
+
}
|
|
8205
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6225
8206
|
// Fire onTurnComplete — stream is closed, use for persistence.
|
|
6226
8207
|
if (onTurnComplete) {
|
|
6227
8208
|
await tracer.startActiveSpan("onTurnComplete()", async () => {
|
|
@@ -6339,6 +8320,7 @@ function chatAgent(options) {
|
|
|
6339
8320
|
// chat.endRun() — same exit, no upgrade semantics.
|
|
6340
8321
|
if (locals.get(chatCloseRequestedKey)) {
|
|
6341
8322
|
await performChatClose();
|
|
8323
|
+
await reportChannelClosed();
|
|
6342
8324
|
return "exit";
|
|
6343
8325
|
}
|
|
6344
8326
|
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
|
|
@@ -6410,6 +8392,7 @@ function chatAgent(options) {
|
|
|
6410
8392
|
currentWirePayload = next.output;
|
|
6411
8393
|
// Close signal — exit the loop gracefully
|
|
6412
8394
|
if (currentWirePayload.trigger === "close") {
|
|
8395
|
+
await reportChannelClosed();
|
|
6413
8396
|
return "exit";
|
|
6414
8397
|
}
|
|
6415
8398
|
return "continue";
|
|
@@ -6427,12 +8410,16 @@ function chatAgent(options) {
|
|
|
6427
8410
|
// Detach the turn's message handler first — left attached it would
|
|
6428
8411
|
// eat the very message the wait below is waiting for.
|
|
6429
8412
|
turnMsgSub?.off();
|
|
8413
|
+
turnControlSub?.off();
|
|
6430
8414
|
if (turnError instanceof Error &&
|
|
6431
8415
|
turnError.name === "AbortError" &&
|
|
6432
8416
|
runSignal.aborted) {
|
|
8417
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
8418
|
+
channelLifecycle.dispose();
|
|
6433
8419
|
// Full run cancellation — exit immediately
|
|
6434
8420
|
throw turnError;
|
|
6435
8421
|
}
|
|
8422
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6436
8423
|
// OOM errors must escape the turn loop so the task runtime can
|
|
6437
8424
|
// honor `retry.outOfMemory.machine` (set on chat.agent via
|
|
6438
8425
|
// `oomMachine`). Catching them here would keep the dead worker
|
|
@@ -6443,6 +8430,107 @@ function chatAgent(options) {
|
|
|
6443
8430
|
if (turnError instanceof OutOfMemoryError) {
|
|
6444
8431
|
throw turnError;
|
|
6445
8432
|
}
|
|
8433
|
+
if (channelWireEvent && channelConn?.react) {
|
|
8434
|
+
try {
|
|
8435
|
+
if (channelWorkingReaction) {
|
|
8436
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8437
|
+
name: channelWorkingReaction,
|
|
8438
|
+
remove: true,
|
|
8439
|
+
});
|
|
8440
|
+
}
|
|
8441
|
+
const errorReaction = await resolveReactionChoice(channelConn.reactions?.error, channelWireEvent.event);
|
|
8442
|
+
if (errorReaction) {
|
|
8443
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8444
|
+
name: errorReaction,
|
|
8445
|
+
});
|
|
8446
|
+
}
|
|
8447
|
+
}
|
|
8448
|
+
catch (reactionError) {
|
|
8449
|
+
logger.warn("chat.agent: channel error reaction failed", { error: reactionError });
|
|
8450
|
+
}
|
|
8451
|
+
}
|
|
8452
|
+
// The channel gets the connector's error line, never the error itself: the run keeps that.
|
|
8453
|
+
const channelErrorText = channelReplyStream || ((channelWireEvent || boundTurn) && channelConn?.send)
|
|
8454
|
+
? await channelTurnErrorMessage(channelConn, {
|
|
8455
|
+
error: turnError instanceof Error ? turnError : new Error(String(turnError)),
|
|
8456
|
+
partialText: channelTurnText?.trim() ?? "",
|
|
8457
|
+
event: channelWireEvent?.event,
|
|
8458
|
+
runId: ctx.run.id,
|
|
8459
|
+
chatId: currentWirePayload.chatId,
|
|
8460
|
+
})
|
|
8461
|
+
: CHANNEL_TURN_ERROR_MESSAGE;
|
|
8462
|
+
let errorStreamDelivered = false;
|
|
8463
|
+
if (channelReplyStream) {
|
|
8464
|
+
const replyStream = channelReplyStream;
|
|
8465
|
+
channelReplyStream = undefined;
|
|
8466
|
+
try {
|
|
8467
|
+
const ended = await replyStream.end({
|
|
8468
|
+
stopped: false,
|
|
8469
|
+
pending: false,
|
|
8470
|
+
errorText: channelErrorText,
|
|
8471
|
+
});
|
|
8472
|
+
errorStreamDelivered = ended.delivered;
|
|
8473
|
+
}
|
|
8474
|
+
catch (streamError) {
|
|
8475
|
+
logger.warn("chat.agent: channel reply stream end failed", {
|
|
8476
|
+
error: streamError,
|
|
8477
|
+
});
|
|
8478
|
+
}
|
|
8479
|
+
finally {
|
|
8480
|
+
clearOpenReplyStream();
|
|
8481
|
+
}
|
|
8482
|
+
}
|
|
8483
|
+
if ((channelWireEvent || boundTurn) && channelConn?.send) {
|
|
8484
|
+
const errorPreviousRef = channelFinalAnswerPosted ? undefined : channelAckRef;
|
|
8485
|
+
const channelErrorMessageId = errorPreviousRef ?? channelWireEvent?.deliveryId ?? `channel-error-${turn}`;
|
|
8486
|
+
let errorMessage = null;
|
|
8487
|
+
try {
|
|
8488
|
+
errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
|
|
8489
|
+
text: channelErrorText,
|
|
8490
|
+
message: {
|
|
8491
|
+
id: channelErrorMessageId,
|
|
8492
|
+
role: "assistant",
|
|
8493
|
+
parts: [{ type: "text", text: channelErrorText }],
|
|
8494
|
+
},
|
|
8495
|
+
final: true,
|
|
8496
|
+
stopped: false,
|
|
8497
|
+
error: turnError,
|
|
8498
|
+
});
|
|
8499
|
+
}
|
|
8500
|
+
catch (outboundError) {
|
|
8501
|
+
logger.warn("chat.agent: channel error outbound failed; posting the plain error", {
|
|
8502
|
+
error: outboundError,
|
|
8503
|
+
});
|
|
8504
|
+
errorMessage = { text: channelErrorText };
|
|
8505
|
+
}
|
|
8506
|
+
if (errorMessage && !errorStreamDelivered) {
|
|
8507
|
+
try {
|
|
8508
|
+
await channelConn.send(errorMessage, {
|
|
8509
|
+
event: channelWireEvent?.event,
|
|
8510
|
+
handle: channelHandle,
|
|
8511
|
+
deliveryId: channelWireEvent?.deliveryId,
|
|
8512
|
+
previousRef: errorPreviousRef,
|
|
8513
|
+
mode: channelConn.delivery,
|
|
8514
|
+
final: true,
|
|
8515
|
+
bound: boundTurn,
|
|
8516
|
+
});
|
|
8517
|
+
}
|
|
8518
|
+
catch (egressError) {
|
|
8519
|
+
logger.warn("chat.agent: channel error egress send failed", {
|
|
8520
|
+
error: egressError,
|
|
8521
|
+
});
|
|
8522
|
+
}
|
|
8523
|
+
}
|
|
8524
|
+
}
|
|
8525
|
+
if (channelTurnRan)
|
|
8526
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8527
|
+
clearChannelWorking();
|
|
8528
|
+
if (channelLifecycleStarted) {
|
|
8529
|
+
await channelLifecycle.report(channelConn, locals.get(chatCloseRequestedKey) ? "closed" : "idle", {
|
|
8530
|
+
...channelTurnCtx(channelWireEvent, channelHandle),
|
|
8531
|
+
firstTurn: false,
|
|
8532
|
+
});
|
|
8533
|
+
}
|
|
6446
8534
|
let errorTurnCompleteResult;
|
|
6447
8535
|
try {
|
|
6448
8536
|
await withChatWriter(async (writer) => {
|
|
@@ -6699,6 +8787,7 @@ function chatAgent(options) {
|
|
|
6699
8787
|
.get(chatManagedResponseKey)
|
|
6700
8788
|
?.close()
|
|
6701
8789
|
.catch(() => { });
|
|
8790
|
+
turnControlSub?.off();
|
|
6702
8791
|
}
|
|
6703
8792
|
}
|
|
6704
8793
|
}
|
|
@@ -6722,6 +8811,45 @@ function chatAgent(options) {
|
|
|
6722
8811
|
schema: clientDataSchema,
|
|
6723
8812
|
});
|
|
6724
8813
|
}
|
|
8814
|
+
// Claim each listed chat.event descriptor: subscribe THIS agent's session to the event's endpoint.
|
|
8815
|
+
// The subscriber id is scoped by agent, so the same descriptor on two agents is two subscribers.
|
|
8816
|
+
if (events) {
|
|
8817
|
+
for (const wh of events) {
|
|
8818
|
+
resourceCatalog.markSessionWebhookClaimed(wh.id);
|
|
8819
|
+
resourceCatalog.registerWebhookSubscriber({
|
|
8820
|
+
endpointId: wh.endpointId,
|
|
8821
|
+
target: {
|
|
8822
|
+
type: "session",
|
|
8823
|
+
id: `${options.id}:${wh.id}`,
|
|
8824
|
+
taskIdentifier: options.id,
|
|
8825
|
+
keyTemplate: wh.key,
|
|
8826
|
+
actionType: wh.type,
|
|
8827
|
+
deliverAs: "action",
|
|
8828
|
+
...(wh.filter ? { filter: wh.filter } : {}),
|
|
8829
|
+
},
|
|
8830
|
+
});
|
|
8831
|
+
}
|
|
8832
|
+
}
|
|
8833
|
+
// Claim each listed channel connector: the same kind of subscriber, but deliverAs "message" so a
|
|
8834
|
+
// verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
|
|
8835
|
+
if (channels) {
|
|
8836
|
+
for (const ch of channels) {
|
|
8837
|
+
resourceCatalog.markSessionWebhookClaimed(ch.id);
|
|
8838
|
+
resourceCatalog.registerWebhookSubscriber({
|
|
8839
|
+
endpointId: ch.endpointId,
|
|
8840
|
+
target: {
|
|
8841
|
+
type: "session",
|
|
8842
|
+
id: `${options.id}:${ch.id}`,
|
|
8843
|
+
taskIdentifier: options.id,
|
|
8844
|
+
keyTemplate: ch.key,
|
|
8845
|
+
connectorId: ch.id,
|
|
8846
|
+
deliverAs: "message",
|
|
8847
|
+
...(ch.filter ? { filter: ch.filter } : {}),
|
|
8848
|
+
...(ch.startOn ? { startOn: ch.startOn } : {}),
|
|
8849
|
+
},
|
|
8850
|
+
});
|
|
8851
|
+
}
|
|
8852
|
+
}
|
|
6725
8853
|
return task;
|
|
6726
8854
|
}
|
|
6727
8855
|
function composeHooks(builderHook, taskHook) {
|
|
@@ -7017,6 +9145,22 @@ function resolveUIMessageStreamOptions() {
|
|
|
7017
9145
|
// ---------------------------------------------------------------------------
|
|
7018
9146
|
// Stop detection
|
|
7019
9147
|
// ---------------------------------------------------------------------------
|
|
9148
|
+
/**
|
|
9149
|
+
* The channel surface of the current turn, from anywhere inside a `chat.agent` run, including a
|
|
9150
|
+
* tool's `execute`. Undefined on a browser turn or when the connector defines no reactions and no
|
|
9151
|
+
* `surface()`. Pass the connector's surface type to get its helpers typed:
|
|
9152
|
+
*
|
|
9153
|
+
* @example
|
|
9154
|
+
* ```ts
|
|
9155
|
+
* execute: async ({ steps }) => {
|
|
9156
|
+
* const linear = chat.surface<LinearRunSurface>();
|
|
9157
|
+
* if (linear) await linear.plan(steps);
|
|
9158
|
+
* }
|
|
9159
|
+
* ```
|
|
9160
|
+
*/
|
|
9161
|
+
function getChannelSurface() {
|
|
9162
|
+
return locals.get(chatChannelSurfaceKey);
|
|
9163
|
+
}
|
|
7020
9164
|
/**
|
|
7021
9165
|
* Check whether the user stopped generation during the current turn.
|
|
7022
9166
|
*
|
|
@@ -9014,6 +11158,10 @@ function createChatLoadTranscriptAction(storage, options) {
|
|
|
9014
11158
|
export const chat = {
|
|
9015
11159
|
/** Create a chat agent. See {@link chatAgent}. */
|
|
9016
11160
|
agent: chatAgent,
|
|
11161
|
+
/** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
|
|
11162
|
+
event: chatEvent,
|
|
11163
|
+
/** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
|
|
11164
|
+
channels: chatChannels,
|
|
9017
11165
|
/** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
|
|
9018
11166
|
customAgent: chatCustomAgent,
|
|
9019
11167
|
/** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
|
|
@@ -9032,6 +11180,8 @@ export const chat = {
|
|
|
9032
11180
|
local: chatLocal,
|
|
9033
11181
|
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
|
|
9034
11182
|
createAccessToken: createChatAccessToken,
|
|
11183
|
+
/** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
|
|
11184
|
+
createWatchToken: createChatWatchToken,
|
|
9035
11185
|
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
|
|
9036
11186
|
setTurnTimeout,
|
|
9037
11187
|
/** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
|
|
@@ -9042,6 +11192,8 @@ export const chat = {
|
|
|
9042
11192
|
setUIMessageStreamOptions,
|
|
9043
11193
|
/** Check if the current turn was stopped by the user. See {@link isStopped}. */
|
|
9044
11194
|
isStopped,
|
|
11195
|
+
/** The current turn's channel surface (reactions plus connector helpers), also from inside a tool. See {@link getChannelSurface}. */
|
|
11196
|
+
surface: getChannelSurface,
|
|
9045
11197
|
/** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
|
|
9046
11198
|
requestUpgrade,
|
|
9047
11199
|
/** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
|