@trigger.dev/sdk 4.7.4 → 4.8.0-webhooks.20261015065339
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 +560 -8
- package/dist/commonjs/v3/ai.js +2263 -178
- 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/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 +178 -2
- package/dist/commonjs/v3/webhooks.js +322 -1
- package/dist/commonjs/v3/webhooks.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +560 -8
- package/dist/esm/v3/ai.js +2257 -178
- 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/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 +178 -2
- package/dist/esm/v3/webhooks.js +318 -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 +34 -0
- package/docs/ai-chat/pending-messages.mdx +2 -0
- package/docs/ai-chat/reference.mdx +50 -2
- package/docs/mcp-tools.mdx +76 -0
- package/docs/webhooks/channels.mdx +128 -0
- package/docs/webhooks/connect.mdx +80 -0
- package/docs/webhooks/deliveries.mdx +92 -0
- package/docs/webhooks/filters.mdx +109 -0
- package/docs/webhooks/human-in-the-loop.mdx +149 -0
- package/docs/webhooks/linear.mdx +185 -0
- package/docs/webhooks/overview.mdx +99 -0
- package/docs/webhooks/session-routing.mdx +132 -0
- package/docs/webhooks/slack.mdx +360 -0
- package/docs/webhooks/sources.mdx +217 -0
- package/docs/webhooks/waiters.mdx +170 -0
- package/package.json +3 -2
package/dist/commonjs/v3/ai.js
CHANGED
|
@@ -11,7 +11,16 @@ exports.__resetChatInputRouterForTests = __resetChatInputRouterForTests;
|
|
|
11
11
|
exports.__chatInputCheckpointForTests = __chatInputCheckpointForTests;
|
|
12
12
|
exports.__findLatestSessionInCheckpointForTests = __findLatestSessionInCheckpointForTests;
|
|
13
13
|
exports.buildSkillTools = buildSkillTools;
|
|
14
|
+
exports.chatChannelCustom = chatChannelCustom;
|
|
15
|
+
exports.resolveReactionChoice = resolveReactionChoice;
|
|
16
|
+
exports.__makeChannelStreamEditorForTests = makeChannelStreamEditor;
|
|
17
|
+
exports.__makeChannelStreamTapForTests = makeChannelStreamTap;
|
|
18
|
+
exports.__makeChannelProgressObserverForTests = makeChannelProgressObserver;
|
|
19
|
+
exports.__makeChannelLifecycleReporterForTests = makeChannelLifecycleReporter;
|
|
20
|
+
exports.__waitForChannelEgressForTests = __waitForChannelEgressForTests;
|
|
14
21
|
const v3_1 = require("@trigger.dev/core/v3");
|
|
22
|
+
const webhooks_js_1 = require("./webhooks.js");
|
|
23
|
+
const modelFileDownload_js_1 = require("./modelFileDownload.js");
|
|
15
24
|
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
|
|
16
25
|
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
|
|
17
26
|
const api_1 = require("@opentelemetry/api");
|
|
@@ -88,6 +97,14 @@ const chatSessionHandleKey = locals_js_1.locals.create("chat.sessionHandle");
|
|
|
88
97
|
// is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
|
|
89
98
|
// metadata reads this directly rather than the Session handle id.
|
|
90
99
|
const chatExternalIdKey = locals_js_1.locals.create("chat.externalId");
|
|
100
|
+
/**
|
|
101
|
+
* Set at boot when a continuation run inherited an interrupted turn (a
|
|
102
|
+
* partial assistant reply on `session.out`). Consumed by the first channel
|
|
103
|
+
* turn's ack so the re-emitted placeholder reads as a recovery resume rather
|
|
104
|
+
* than a silent duplicate. Boxed so the ack site can flip it to false.
|
|
105
|
+
* @internal
|
|
106
|
+
*/
|
|
107
|
+
const chatChannelRecoveryPendingKey = locals_js_1.locals.create("chat.channelRecoveryPending");
|
|
91
108
|
/**
|
|
92
109
|
* S2 seq_num of the most recent `turn-complete` control record written by
|
|
93
110
|
* this worker. Read by `writeTurnCompleteChunk` to know what to trim back
|
|
@@ -379,14 +396,23 @@ function __setReplaySessionInTailImplForTests(impl) {
|
|
|
379
396
|
* @internal
|
|
380
397
|
*/
|
|
381
398
|
async function replaySessionInTail(sessionId, options) {
|
|
399
|
+
return (await replaySessionInTailWithChannels(sessionId, options)).messages;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The full unprocessed `session.in` tail: browser messages and channel deliveries, each with its
|
|
403
|
+
* seq_num, so recovery can dispatch them in the order they arrived rather than messages first.
|
|
404
|
+
*/
|
|
405
|
+
async function replaySessionInTailWithChannels(sessionId, options) {
|
|
382
406
|
if (replaySessionInTailImpl) {
|
|
383
|
-
|
|
407
|
+
const replayed = await replaySessionInTailImpl(sessionId, options);
|
|
408
|
+
return Array.isArray(replayed) ? { messages: replayed, channelEvents: [] } : replayed;
|
|
384
409
|
}
|
|
385
410
|
const apiClient = v3_1.apiClientManager.clientOrThrow();
|
|
386
411
|
const response = await apiClient.readSessionStreamRecords(sessionId, "in", {
|
|
387
412
|
afterEventId: options?.lastEventId,
|
|
388
413
|
});
|
|
389
414
|
const out = [];
|
|
415
|
+
const channelEvents = [];
|
|
390
416
|
for (const record of response.records) {
|
|
391
417
|
// session.in writers POST `JSON.stringify(chunk)` directly; the
|
|
392
418
|
// webapp wraps that in `{ data: <string>, id }` and stores it on
|
|
@@ -413,15 +439,23 @@ async function replaySessionInTail(sessionId, options) {
|
|
|
413
439
|
if (!payload || payload.trigger !== "submit-message")
|
|
414
440
|
continue;
|
|
415
441
|
const message = payload.message;
|
|
416
|
-
if (!message || typeof message !== "object")
|
|
442
|
+
if (!message || typeof message !== "object") {
|
|
443
|
+
if (payload.channelEvent && typeof payload.channelEvent === "object") {
|
|
444
|
+
channelEvents.push({
|
|
445
|
+
channelEvent: payload.channelEvent,
|
|
446
|
+
metadata: payload.metadata,
|
|
447
|
+
seqNum: record.seqNum,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
417
450
|
continue;
|
|
451
|
+
}
|
|
418
452
|
out.push({
|
|
419
453
|
message: message,
|
|
420
454
|
metadata: payload.metadata,
|
|
421
455
|
seqNum: record.seqNum,
|
|
422
456
|
});
|
|
423
457
|
}
|
|
424
|
-
return out;
|
|
458
|
+
return { messages: out, channelEvents };
|
|
425
459
|
}
|
|
426
460
|
/**
|
|
427
461
|
* Test-only entry point that bypasses
|
|
@@ -682,6 +716,37 @@ exports.ai = {
|
|
|
682
716
|
function createChatAccessToken(taskId) {
|
|
683
717
|
return auth_js_1.auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
|
|
684
718
|
}
|
|
719
|
+
/**
|
|
720
|
+
* Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`.
|
|
721
|
+
*
|
|
722
|
+
* A session is addressed by `externalId`, so the same session is reachable from any surface that
|
|
723
|
+
* knows it: a web `useChat` client and a Slack thread converge on one session when they share an
|
|
724
|
+
* `externalId` (for a channel, the connector's `key` template produces it). This token carries
|
|
725
|
+
* `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out`
|
|
726
|
+
* stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`.
|
|
727
|
+
*
|
|
728
|
+
* Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side
|
|
729
|
+
* (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint
|
|
730
|
+
* a read+write token instead ({@link createChatStartSessionAction}).
|
|
731
|
+
*
|
|
732
|
+
* @example
|
|
733
|
+
* ```ts
|
|
734
|
+
* // actions.ts
|
|
735
|
+
* "use server";
|
|
736
|
+
* import { chat } from "@trigger.dev/sdk/ai";
|
|
737
|
+
*
|
|
738
|
+
* export const watchChat = (externalId: string) => chat.createWatchToken(externalId);
|
|
739
|
+
* ```
|
|
740
|
+
*/
|
|
741
|
+
function createChatWatchToken(externalId, options) {
|
|
742
|
+
if (!externalId) {
|
|
743
|
+
throw new Error("chat.createWatchToken: externalId is required (the session addressing key).");
|
|
744
|
+
}
|
|
745
|
+
return auth_js_1.auth.createPublicToken({
|
|
746
|
+
scopes: { read: { sessions: externalId } },
|
|
747
|
+
expirationTime: options?.tokenTTL ?? "1h",
|
|
748
|
+
});
|
|
749
|
+
}
|
|
685
750
|
function createChatOutGate() {
|
|
686
751
|
let resolveOpened;
|
|
687
752
|
const opened = new Promise((resolve) => {
|
|
@@ -1918,7 +1983,16 @@ const chatInstructionsConsumedKey = locals_js_1.locals.create("chat.injectedInst
|
|
|
1918
1983
|
* @internal
|
|
1919
1984
|
*/
|
|
1920
1985
|
const chatPipeCountKey = locals_js_1.locals.create("chat.pipeCount");
|
|
1986
|
+
/**
|
|
1987
|
+
* The current turn's channel observer tap, set before run() when the turn has channel observers
|
|
1988
|
+
* (ack stream edits, tool progress) and cleared at the turn boundary. `pipeChat` applies it so a
|
|
1989
|
+
* manual `chat.pipe()` inside run() feeds the same observers as the automatic pipe.
|
|
1990
|
+
* @internal
|
|
1991
|
+
*/
|
|
1992
|
+
const chatTurnChannelTapKey = locals_js_1.locals.create("chat.turnChannelTap");
|
|
1921
1993
|
const chatStopControllerKey = locals_js_1.locals.create("chat.stopController");
|
|
1994
|
+
const chatChannelSurfaceKey = locals_js_1.locals.create("chat.channelSurface");
|
|
1995
|
+
const chatChannelDownloadKey = locals_js_1.locals.create("chat.channelDownload");
|
|
1922
1996
|
/** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */
|
|
1923
1997
|
const chatUIStreamStaticKey = locals_js_1.locals.create("chat.uiMessageStreamOptions.static");
|
|
1924
1998
|
/** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
|
|
@@ -3284,7 +3358,7 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3284
3358
|
* caller's value winning because `rest` is spread after `managed`. Pulling a
|
|
3285
3359
|
* key out to "handle" it is how a caller's option gets silently dropped.
|
|
3286
3360
|
*/
|
|
3287
|
-
const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
|
|
3361
|
+
const { tools, system: callerSystem, prepareStep: callerPrepareStep, experimental_download: callerDownload, ...rest } = options;
|
|
3288
3362
|
const managed = toStreamTextOptions({
|
|
3289
3363
|
registry,
|
|
3290
3364
|
system: callerSystem ?? agentSystem,
|
|
@@ -3327,6 +3401,12 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3327
3401
|
if (typeof managed.prepareStep === "function") {
|
|
3328
3402
|
managed.prepareStep = (0, steeringContext_js_1.retainStepMessages)(managed.prepareStep);
|
|
3329
3403
|
}
|
|
3404
|
+
if (typeof callerDownload === "function") {
|
|
3405
|
+
const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
|
|
3406
|
+
managed.experimental_download = channelDownload
|
|
3407
|
+
? channelAwareDownload(channelDownload, callerDownload)
|
|
3408
|
+
: callerDownload;
|
|
3409
|
+
}
|
|
3330
3410
|
return { ...managed, ...rest };
|
|
3331
3411
|
}
|
|
3332
3412
|
/** @internal Test hook for {@link buildManagedStreamTextOptions}. */
|
|
@@ -3556,6 +3636,10 @@ function toStreamTextOptions(options, retain = true) {
|
|
|
3556
3636
|
if (retain && typeof result.prepareStep === "function") {
|
|
3557
3637
|
result.prepareStep = (0, steeringContext_js_1.retainStepMessages)(result.prepareStep);
|
|
3558
3638
|
}
|
|
3639
|
+
const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
|
|
3640
|
+
if (channelDownload) {
|
|
3641
|
+
result.experimental_download = channelAwareDownload(channelDownload);
|
|
3642
|
+
}
|
|
3559
3643
|
return result;
|
|
3560
3644
|
}
|
|
3561
3645
|
const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
@@ -3570,6 +3654,9 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3570
3654
|
* tools, steering, compaction, injected instructions and persistence, with no
|
|
3571
3655
|
* action-specific handling.
|
|
3572
3656
|
*
|
|
3657
|
+
* Pass `message` to have the turn answer a new user message, appended after the edit: a click on a
|
|
3658
|
+
* "Explain more" button becomes `chat.turn("Explain that in more detail.")`.
|
|
3659
|
+
*
|
|
3573
3660
|
* @example
|
|
3574
3661
|
* ```ts
|
|
3575
3662
|
* onAction: async ({ action }) => {
|
|
@@ -3577,12 +3664,13 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3577
3664
|
* chat.history.slice(0, -1);
|
|
3578
3665
|
* return chat.turn();
|
|
3579
3666
|
* }
|
|
3667
|
+
* if (action.type === "ask") return chat.turn(action.question);
|
|
3580
3668
|
* if (action.type === "undo") chat.history.slice(0, -2); // no turn
|
|
3581
3669
|
* },
|
|
3582
3670
|
* ```
|
|
3583
3671
|
*/
|
|
3584
|
-
function chatTurn() {
|
|
3585
|
-
return { [actionTurnBrand]: true };
|
|
3672
|
+
function chatTurn(message) {
|
|
3673
|
+
return (message === undefined ? { [actionTurnBrand]: true } : { [actionTurnBrand]: true, message });
|
|
3586
3674
|
}
|
|
3587
3675
|
function isActionTurn(value) {
|
|
3588
3676
|
return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
|
|
@@ -3648,6 +3736,21 @@ function warnMissingOnActionOnce() {
|
|
|
3648
3736
|
function isAsyncIterable(value) {
|
|
3649
3737
|
return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
|
|
3650
3738
|
}
|
|
3739
|
+
function readableStreamFromAsyncIterable(source) {
|
|
3740
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
3741
|
+
return new ReadableStream({
|
|
3742
|
+
async pull(controller) {
|
|
3743
|
+
const next = await iterator.next();
|
|
3744
|
+
if (next.done)
|
|
3745
|
+
controller.close();
|
|
3746
|
+
else
|
|
3747
|
+
controller.enqueue(next.value);
|
|
3748
|
+
},
|
|
3749
|
+
async cancel(reason) {
|
|
3750
|
+
await iterator.return?.(reason);
|
|
3751
|
+
},
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
3651
3754
|
function isReadableStream(value) {
|
|
3652
3755
|
return (typeof value === "object" && value !== null && typeof value.getReader === "function");
|
|
3653
3756
|
}
|
|
@@ -3703,6 +3806,10 @@ async function pipeChat(source, options, capture) {
|
|
|
3703
3806
|
throw new Error("pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " +
|
|
3704
3807
|
"an AsyncIterable, or a ReadableStream");
|
|
3705
3808
|
}
|
|
3809
|
+
const channelTap = locals_js_1.locals.get(chatTurnChannelTapKey);
|
|
3810
|
+
if (channelTap) {
|
|
3811
|
+
stream = channelTap(isReadableStream(stream) ? stream : readableStreamFromAsyncIterable(stream));
|
|
3812
|
+
}
|
|
3706
3813
|
const pipeOptions = {};
|
|
3707
3814
|
if (options?.signal) {
|
|
3708
3815
|
pipeOptions.signal = options.signal;
|
|
@@ -3732,160 +3839,1026 @@ async function pipeChat(source, options, capture) {
|
|
|
3732
3839
|
await waitUntilComplete();
|
|
3733
3840
|
}
|
|
3734
3841
|
}
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
const
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3842
|
+
/**
|
|
3843
|
+
* A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
|
|
3844
|
+
* `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
|
|
3845
|
+
* control. Use for providers without a preset, or to test the channel round-trip end to end.
|
|
3846
|
+
*/
|
|
3847
|
+
function chatChannelCustom(options) {
|
|
3848
|
+
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, } = options;
|
|
3849
|
+
if (!endpoint?.id) {
|
|
3850
|
+
throw new Error(`chat.channels.custom("${id}"): an endpoint is required. Declare one with webhooks.endpoint.define() and pass it as { endpoint }.`);
|
|
3851
|
+
}
|
|
3852
|
+
v3_1.resourceCatalog.registerDeclaredSessionWebhook(id);
|
|
3853
|
+
return {
|
|
3854
|
+
id,
|
|
3855
|
+
endpointId: endpoint.id,
|
|
3856
|
+
source: endpoint.source,
|
|
3857
|
+
key: (0, webhooks_js_1.normalizeKeyString)(key),
|
|
3858
|
+
filter,
|
|
3859
|
+
startOn,
|
|
3860
|
+
inbound,
|
|
3861
|
+
bind,
|
|
3862
|
+
seed,
|
|
3863
|
+
outbound,
|
|
3864
|
+
ack,
|
|
3865
|
+
send,
|
|
3866
|
+
renderInteraction,
|
|
3867
|
+
onInteraction,
|
|
3868
|
+
finalizeInteraction,
|
|
3869
|
+
react,
|
|
3870
|
+
reactions,
|
|
3871
|
+
progress,
|
|
3872
|
+
surface,
|
|
3873
|
+
onControl,
|
|
3874
|
+
stream,
|
|
3875
|
+
endInterruptedStream,
|
|
3876
|
+
download,
|
|
3877
|
+
received,
|
|
3878
|
+
toAction,
|
|
3879
|
+
lifecycle,
|
|
3880
|
+
cancelInteraction,
|
|
3881
|
+
delivery: delivery ?? "final",
|
|
3882
|
+
};
|
|
3883
|
+
}
|
|
3884
|
+
const chatChannels = {
|
|
3885
|
+
/** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
|
|
3886
|
+
custom: chatChannelCustom,
|
|
3887
|
+
};
|
|
3888
|
+
// Prior surface messages from a connector's seed(), as transcript messages stamped with provenance.
|
|
3889
|
+
function channelSeedMessages(seed, provenance) {
|
|
3890
|
+
return seed
|
|
3891
|
+
.filter((m) => m && typeof m.text === "string" && m.text.length > 0)
|
|
3892
|
+
.map((m, index) => {
|
|
3893
|
+
const meta = {
|
|
3894
|
+
...provenance,
|
|
3895
|
+
seeded: true,
|
|
3896
|
+
...(m.externalUserId ? { externalUserId: m.externalUserId } : {}),
|
|
3897
|
+
...(m.externalMessageId ? { externalMessageId: m.externalMessageId } : {}),
|
|
3898
|
+
};
|
|
3899
|
+
return {
|
|
3900
|
+
id: `seed-${m.externalMessageId ?? `${provenance.deliveryId}-${index}`}`,
|
|
3901
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
3902
|
+
parts: [{ type: "text", text: m.text }],
|
|
3903
|
+
metadata: meta,
|
|
3904
|
+
};
|
|
3905
|
+
});
|
|
3906
|
+
}
|
|
3907
|
+
// Turn a channel connector's inbound() result into the turn's user UIMessage, stamped with where
|
|
3908
|
+
// it came from so a transcript storage can attribute it (see ChannelMessageMetadata).
|
|
3909
|
+
/**
|
|
3910
|
+
* True when a connector's `inbound()` result carries nothing to answer: blank text and no file or
|
|
3911
|
+
* data parts. A button click from another app on a shared endpoint maps to one.
|
|
3912
|
+
*/
|
|
3913
|
+
function isEmptyChannelInbound(input) {
|
|
3914
|
+
const message = input !== null && typeof input === "object" && "message" in input && !("role" in input)
|
|
3915
|
+
? input.message
|
|
3916
|
+
: input;
|
|
3917
|
+
if (typeof message === "string")
|
|
3918
|
+
return message.trim() === "";
|
|
3919
|
+
return (message.parts ?? []).every((part) => part.type === "text" && part.text?.trim() === "");
|
|
3920
|
+
}
|
|
3921
|
+
/** Bytes held for a run's channel file downloads before the oldest are dropped and fetched again. */
|
|
3922
|
+
const CHANNEL_DOWNLOAD_CACHE_BYTES = 64 * 1024 * 1024;
|
|
3923
|
+
/**
|
|
3924
|
+
* One downloader for a run's connectors that define `download`: asks each in turn (the ones bound to
|
|
3925
|
+
* the session first, the next one after a failure) and caches what they return by URL, so a file in
|
|
3926
|
+
* the history is fetched once per run rather than per step.
|
|
3927
|
+
*/
|
|
3928
|
+
function makeChannelDownloader(connectors, handleFor) {
|
|
3929
|
+
const downloaders = connectors.filter((c) => typeof c.download === "function");
|
|
3930
|
+
if (downloaders.length === 0)
|
|
3931
|
+
return undefined;
|
|
3932
|
+
const cache = new Map();
|
|
3933
|
+
const sizes = new Map();
|
|
3934
|
+
let cachedBytes = 0;
|
|
3935
|
+
const evict = () => {
|
|
3936
|
+
for (const key of cache.keys()) {
|
|
3937
|
+
if (cachedBytes <= CHANNEL_DOWNLOAD_CACHE_BYTES)
|
|
3938
|
+
return;
|
|
3939
|
+
cachedBytes -= sizes.get(key) ?? 0;
|
|
3940
|
+
sizes.delete(key);
|
|
3941
|
+
cache.delete(key);
|
|
3942
|
+
}
|
|
3943
|
+
};
|
|
3944
|
+
const fetchOwned = async (url) => {
|
|
3945
|
+
const candidates = downloaders.map((connector) => ({
|
|
3946
|
+
connector,
|
|
3947
|
+
handle: handleFor(connector.id),
|
|
3948
|
+
}));
|
|
3949
|
+
const ordered = [
|
|
3950
|
+
...candidates.filter((c) => c.handle !== undefined),
|
|
3951
|
+
...candidates.filter((c) => c.handle === undefined),
|
|
3952
|
+
];
|
|
3953
|
+
let failure;
|
|
3954
|
+
for (const { connector, handle } of ordered) {
|
|
3955
|
+
try {
|
|
3956
|
+
const file = await connector.download(url, { handle });
|
|
3957
|
+
if (file)
|
|
3958
|
+
return file;
|
|
3765
3959
|
}
|
|
3766
|
-
|
|
3767
|
-
|
|
3960
|
+
catch (error) {
|
|
3961
|
+
failure ??= { error };
|
|
3768
3962
|
}
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
// already closed on its own exit costs nothing.
|
|
3785
|
-
const withClose = async (result) => {
|
|
3786
|
-
try {
|
|
3787
|
-
return await result;
|
|
3788
|
-
}
|
|
3789
|
-
finally {
|
|
3790
|
-
await performChatClose();
|
|
3791
|
-
}
|
|
3792
|
-
};
|
|
3793
|
-
// Keep the schema-free path identical to the original custom-agent
|
|
3794
|
-
// wrapper, including when userRun starts executing.
|
|
3795
|
-
if (!parseClientData) {
|
|
3796
|
-
return withClose(userRun(payload, runOptions));
|
|
3963
|
+
}
|
|
3964
|
+
if (failure)
|
|
3965
|
+
throw failure.error;
|
|
3966
|
+
return null;
|
|
3967
|
+
};
|
|
3968
|
+
return (url) => {
|
|
3969
|
+
const key = url.href;
|
|
3970
|
+
const cached = cache.get(key);
|
|
3971
|
+
if (cached)
|
|
3972
|
+
return cached;
|
|
3973
|
+
const pending = fetchOwned(url).then((file) => {
|
|
3974
|
+
if (file && cache.get(key) === pending) {
|
|
3975
|
+
sizes.set(key, file.data.byteLength);
|
|
3976
|
+
cachedBytes += file.data.byteLength;
|
|
3977
|
+
evict();
|
|
3797
3978
|
}
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3979
|
+
return file;
|
|
3980
|
+
}, (error) => {
|
|
3981
|
+
if (cache.get(key) === pending)
|
|
3982
|
+
cache.delete(key);
|
|
3983
|
+
throw error;
|
|
3984
|
+
});
|
|
3985
|
+
cache.set(key, pending);
|
|
3986
|
+
return pending;
|
|
3987
|
+
};
|
|
3988
|
+
}
|
|
3989
|
+
function fileUrlOf(part) {
|
|
3990
|
+
const p = part;
|
|
3991
|
+
const raw = p?.type === "file" ? p.data : p?.type === "image" ? p.image : undefined;
|
|
3992
|
+
if (raw instanceof URL)
|
|
3993
|
+
return raw;
|
|
3994
|
+
if (typeof raw !== "string" || !/^https?:\/\//i.test(raw))
|
|
3995
|
+
return undefined;
|
|
3996
|
+
try {
|
|
3997
|
+
return new URL(raw);
|
|
3998
|
+
}
|
|
3999
|
+
catch {
|
|
4000
|
+
return undefined;
|
|
4001
|
+
}
|
|
4002
|
+
}
|
|
4003
|
+
/**
|
|
4004
|
+
* Fetch the channel-owned files in the messages a turn hands to `run()`, so the model call reads
|
|
4005
|
+
* them from the cache. A file a connector owns but cannot read becomes a text note in this turn's
|
|
4006
|
+
* copy of the messages, and the turn still runs. The transcript keeps the URL.
|
|
4007
|
+
*/
|
|
4008
|
+
async function resolveChannelFiles(messages, download) {
|
|
4009
|
+
const failed = new Set();
|
|
4010
|
+
const mediaTypes = new Map();
|
|
4011
|
+
const urls = new Map();
|
|
4012
|
+
for (const message of messages) {
|
|
4013
|
+
if (!Array.isArray(message.content))
|
|
4014
|
+
continue;
|
|
4015
|
+
for (const part of message.content) {
|
|
4016
|
+
const url = fileUrlOf(part);
|
|
4017
|
+
if (url)
|
|
4018
|
+
urls.set(url.href, url);
|
|
4019
|
+
}
|
|
4020
|
+
}
|
|
4021
|
+
await Promise.all([...urls.values()].map(async (url) => {
|
|
4022
|
+
try {
|
|
4023
|
+
const file = await download(url);
|
|
4024
|
+
if (file?.mediaType)
|
|
4025
|
+
mediaTypes.set(url.href, file.mediaType);
|
|
4026
|
+
}
|
|
4027
|
+
catch (error) {
|
|
4028
|
+
failed.add(url.href);
|
|
4029
|
+
v3_1.logger.warn("chat.agent: a channel file could not be downloaded; the model gets a note", {
|
|
4030
|
+
url: url.origin + url.pathname,
|
|
4031
|
+
error,
|
|
3810
4032
|
});
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
4033
|
+
}
|
|
4034
|
+
}));
|
|
4035
|
+
const isWildcard = (part) => {
|
|
4036
|
+
const mediaType = part.mediaType;
|
|
4037
|
+
return part.type === "file" && !!mediaType && mediaType.endsWith("/*");
|
|
4038
|
+
};
|
|
4039
|
+
return messages.map((message) => {
|
|
4040
|
+
if (!Array.isArray(message.content))
|
|
4041
|
+
return message;
|
|
4042
|
+
let changed = false;
|
|
4043
|
+
const content = message.content.map((part) => {
|
|
4044
|
+
const url = fileUrlOf(part);
|
|
4045
|
+
if (!url)
|
|
4046
|
+
return part;
|
|
4047
|
+
if (failed.has(url.href)) {
|
|
4048
|
+
changed = true;
|
|
4049
|
+
const name = part.filename;
|
|
4050
|
+
return {
|
|
4051
|
+
type: "text",
|
|
4052
|
+
text: `[Attached file${name ? ` "${name}"` : ""} could not be loaded]`,
|
|
4053
|
+
};
|
|
3827
4054
|
}
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
3833
|
-
timeout: "1h",
|
|
3834
|
-
spanName: "waiting for valid clientData",
|
|
3835
|
-
});
|
|
3836
|
-
if (!next.ok || next.output.trigger === "close") {
|
|
3837
|
-
return;
|
|
4055
|
+
const downloaded = mediaTypes.get(url.href);
|
|
4056
|
+
if (downloaded && isWildcard(part)) {
|
|
4057
|
+
changed = true;
|
|
4058
|
+
return { ...part, mediaType: downloaded };
|
|
3838
4059
|
}
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
...next.output,
|
|
3843
|
-
continuation: next.output.continuation ?? payload.continuation,
|
|
3844
|
-
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
3845
|
-
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
3846
|
-
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
3847
|
-
};
|
|
3848
|
-
return withClose(userRun(recoveredPayload, runOptions));
|
|
3849
|
-
},
|
|
4060
|
+
return part;
|
|
4061
|
+
});
|
|
4062
|
+
return changed ? { ...message, content } : message;
|
|
3850
4063
|
});
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* The AI SDK `experimental_download` for a turn: channel-owned URLs come from the connectors, and
|
|
4067
|
+
* every other URL goes to `fallback`, or to the AI SDK's default behaviour (left for a model that
|
|
4068
|
+
* reads the URL itself, fetched from a public host otherwise).
|
|
4069
|
+
*/
|
|
4070
|
+
function channelAwareDownload(download, fallback) {
|
|
4071
|
+
return async (requests) => {
|
|
4072
|
+
const results = await Promise.all(requests.map(async (request) => {
|
|
4073
|
+
const file = await download(request.url);
|
|
4074
|
+
return file ? { data: file.data, mediaType: file.mediaType } : null;
|
|
4075
|
+
}));
|
|
4076
|
+
const misses = requests.flatMap((_, i) => (results[i] ? [] : [i]));
|
|
4077
|
+
if (misses.length === 0)
|
|
4078
|
+
return results;
|
|
4079
|
+
const rest = fallback
|
|
4080
|
+
? await fallback(misses.map((i) => requests[i]))
|
|
4081
|
+
: await Promise.all(misses.map((i) => requests[i].isUrlSupportedByModel ? null : (0, modelFileDownload_js_1.fetchPublicFile)(requests[i].url)));
|
|
4082
|
+
misses.forEach((i, n) => {
|
|
4083
|
+
results[i] = rest[n] ?? null;
|
|
3856
4084
|
});
|
|
4085
|
+
return results;
|
|
4086
|
+
};
|
|
4087
|
+
}
|
|
4088
|
+
/** The action a channel click maps to through its connector's `toAction()`, if it maps to one. */
|
|
4089
|
+
function channelClickAction(connectors, wire) {
|
|
4090
|
+
const channelEvent = wire;
|
|
4091
|
+
if (!channelEvent?.connectorId)
|
|
4092
|
+
return undefined;
|
|
4093
|
+
const connector = connectors?.find((c) => c.id === channelEvent.connectorId);
|
|
4094
|
+
if (!connector?.toAction)
|
|
4095
|
+
return undefined;
|
|
4096
|
+
try {
|
|
4097
|
+
return connector.toAction(channelEvent.event) ?? undefined;
|
|
4098
|
+
}
|
|
4099
|
+
catch (error) {
|
|
4100
|
+
v3_1.logger.warn("chat.agent: channel toAction() threw; handling the delivery as a message", {
|
|
4101
|
+
error,
|
|
4102
|
+
});
|
|
4103
|
+
return undefined;
|
|
3857
4104
|
}
|
|
3858
|
-
return task;
|
|
3859
4105
|
}
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
4106
|
+
/**
|
|
4107
|
+
* A channel delivery that arrived during a turn, as a message `pendingMessages` can steer into it:
|
|
4108
|
+
* the connector's `inbound()` with channel provenance. Undefined for what must not be steered (a
|
|
4109
|
+
* stop or other control, an interaction such as an approval click, an empty message, a connector
|
|
4110
|
+
* this agent does not list), which stays queued as its own turn.
|
|
4111
|
+
*/
|
|
4112
|
+
function channelSteeringMessage(connectors, wire, messageId) {
|
|
4113
|
+
const connector = connectors?.find((c) => c.id === wire.connectorId);
|
|
4114
|
+
if (!connector)
|
|
4115
|
+
return undefined;
|
|
4116
|
+
try {
|
|
4117
|
+
if (connector.onControl?.(wire.event))
|
|
4118
|
+
return undefined;
|
|
4119
|
+
if (connector.onInteraction?.(wire.event))
|
|
4120
|
+
return undefined;
|
|
4121
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4122
|
+
return undefined;
|
|
4123
|
+
const inbound = connector.inbound(wire.event);
|
|
4124
|
+
if (isEmptyChannelInbound(inbound))
|
|
4125
|
+
return undefined;
|
|
4126
|
+
const message = channelUserMessage(inbound, messageId ?? wire.deliveryId, {
|
|
4127
|
+
source: "channel",
|
|
4128
|
+
connectorId: connector.id,
|
|
4129
|
+
provider: connector.source,
|
|
4130
|
+
deliveryId: wire.deliveryId,
|
|
4131
|
+
...(wire.externalDeliveryId ? { externalDeliveryId: wire.externalDeliveryId } : {}),
|
|
4132
|
+
});
|
|
4133
|
+
return { message };
|
|
3873
4134
|
}
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
4135
|
+
catch (error) {
|
|
4136
|
+
v3_1.logger.warn("chat.agent: a channel message could not be offered for steering; it runs as its own turn", {
|
|
4137
|
+
error,
|
|
4138
|
+
});
|
|
4139
|
+
return undefined;
|
|
4140
|
+
}
|
|
4141
|
+
}
|
|
4142
|
+
/**
|
|
4143
|
+
* Call the connector's `received` for a message that arrived mid-turn, unless it is not a message
|
|
4144
|
+
* to answer (an interaction click, an action click, an empty message). Fire and forget.
|
|
4145
|
+
*/
|
|
4146
|
+
function acknowledgeChannelMessage(connector, wire, handle) {
|
|
4147
|
+
if (!connector.received)
|
|
4148
|
+
return;
|
|
4149
|
+
try {
|
|
4150
|
+
if (connector.onInteraction?.(wire.event))
|
|
4151
|
+
return;
|
|
4152
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4153
|
+
return;
|
|
4154
|
+
if (isEmptyChannelInbound(connector.inbound(wire.event)))
|
|
4155
|
+
return;
|
|
4156
|
+
void Promise.resolve(connector.received({
|
|
4157
|
+
event: wire.event,
|
|
4158
|
+
deliveryId: wire.deliveryId,
|
|
4159
|
+
handle,
|
|
4160
|
+
bound: false,
|
|
4161
|
+
})).catch(() => { });
|
|
4162
|
+
}
|
|
4163
|
+
catch {
|
|
4164
|
+
return;
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
4167
|
+
function channelUserMessage(input, messageId, provenance) {
|
|
4168
|
+
const wrapped = input !== null && typeof input === "object" && "message" in input && !("role" in input);
|
|
4169
|
+
const message = (wrapped ? input.message : input);
|
|
4170
|
+
const extra = wrapped
|
|
4171
|
+
? input
|
|
4172
|
+
: {};
|
|
4173
|
+
const meta = {
|
|
4174
|
+
...provenance,
|
|
4175
|
+
...(extra.externalUserId ? { externalUserId: extra.externalUserId } : {}),
|
|
4176
|
+
...(extra.externalMessageId ? { externalMessageId: extra.externalMessageId } : {}),
|
|
4177
|
+
};
|
|
4178
|
+
const base = typeof message === "string"
|
|
4179
|
+
? { id: messageId, role: "user", parts: [{ type: "text", text: message }] }
|
|
4180
|
+
: message.id
|
|
4181
|
+
? message
|
|
4182
|
+
: { ...message, id: messageId };
|
|
4183
|
+
const existing = base.metadata;
|
|
4184
|
+
return {
|
|
4185
|
+
...base,
|
|
4186
|
+
metadata: { ...(existing && typeof existing === "object" ? existing : {}), ...meta },
|
|
4187
|
+
};
|
|
4188
|
+
}
|
|
4189
|
+
/**
|
|
4190
|
+
* The assistant's closing text for a channel reply: the text after the last tool part in the message,
|
|
4191
|
+
* so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
|
|
4192
|
+
* is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
|
|
4193
|
+
* tool parts (an ordinary turn), so a plain reply is unchanged.
|
|
4194
|
+
*/
|
|
4195
|
+
function channelReplyText(message) {
|
|
4196
|
+
const parts = (message.parts ?? []);
|
|
4197
|
+
const isTool = (p) => {
|
|
4198
|
+
const t = p?.type;
|
|
4199
|
+
return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
|
|
4200
|
+
};
|
|
4201
|
+
let lastToolIdx = -1;
|
|
4202
|
+
parts.forEach((p, i) => {
|
|
4203
|
+
if (isTool(p))
|
|
4204
|
+
lastToolIdx = i;
|
|
4205
|
+
});
|
|
4206
|
+
const textFrom = (from) => parts
|
|
4207
|
+
.slice(from)
|
|
4208
|
+
.map((p) => p && typeof p === "object" && p.type === "text"
|
|
4209
|
+
? String(p.text)
|
|
4210
|
+
: "")
|
|
4211
|
+
.join("");
|
|
4212
|
+
const trailing = textFrom(lastToolIdx + 1);
|
|
4213
|
+
return trailing.trim().length > 0 ? trailing : textFrom(0);
|
|
4214
|
+
}
|
|
4215
|
+
/**
|
|
4216
|
+
* Tries at a turn's final channel reply before it is given up on and the turn shows the error
|
|
4217
|
+
* reaction. Only an edit of the ack placeholder is retried: a failed post may still have landed, so
|
|
4218
|
+
* a post is tried once.
|
|
4219
|
+
*/
|
|
4220
|
+
const CHANNEL_FINAL_SEND_ATTEMPTS = 3;
|
|
4221
|
+
/** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
|
|
4222
|
+
function pendingToolCallsInMessage(message) {
|
|
4223
|
+
const out = [];
|
|
4224
|
+
for (const part of (message.parts ?? [])) {
|
|
4225
|
+
if (!part || typeof part !== "object")
|
|
4226
|
+
continue;
|
|
4227
|
+
const type = part.type;
|
|
4228
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4229
|
+
if (!isTool || part.state !== "input-available")
|
|
4230
|
+
continue;
|
|
4231
|
+
const toolName = type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
|
|
4232
|
+
if (typeof part.toolCallId === "string")
|
|
4233
|
+
out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
|
|
4234
|
+
}
|
|
4235
|
+
return out;
|
|
4236
|
+
}
|
|
4237
|
+
/**
|
|
4238
|
+
* Fill in `toolCallId` on a resolution that names the tool instead: the most recent assistant part of
|
|
4239
|
+
* that tool still awaiting input. Returns the resolution unchanged when it already has an id, and
|
|
4240
|
+
* undefined when no pending call of that tool exists.
|
|
4241
|
+
*/
|
|
4242
|
+
function resolveInteractionToolCallId(resolution, messages) {
|
|
4243
|
+
if (resolution.toolCallId) {
|
|
4244
|
+
return resolution;
|
|
4245
|
+
}
|
|
4246
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4247
|
+
const message = messages[i];
|
|
4248
|
+
if (message.role !== "assistant")
|
|
4249
|
+
continue;
|
|
4250
|
+
for (const call of pendingToolCallsInMessage(message).reverse()) {
|
|
4251
|
+
if (call.toolName === resolution.toolName) {
|
|
4252
|
+
return { ...resolution, toolCallId: call.toolCallId };
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
return undefined;
|
|
4257
|
+
}
|
|
4258
|
+
/**
|
|
4259
|
+
* Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
|
|
4260
|
+
* the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
|
|
4261
|
+
* the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
|
|
4262
|
+
*/
|
|
4263
|
+
function buildInteractionResolutionMessage(resolution, messages, provenance) {
|
|
4264
|
+
// Resolved anywhere means resolved: a resumed turn can carry the same tool call in more than
|
|
4265
|
+
// one assistant message, and a second click on a decided call must not resolve a stale copy.
|
|
4266
|
+
for (const message of messages) {
|
|
4267
|
+
if (message.role !== "assistant")
|
|
4268
|
+
continue;
|
|
4269
|
+
for (const part of (message.parts ?? [])) {
|
|
4270
|
+
if (part && typeof part === "object" && part.toolCallId === resolution.toolCallId) {
|
|
4271
|
+
if (isResolvedToolState(part.state))
|
|
4272
|
+
return undefined;
|
|
4273
|
+
}
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4277
|
+
const message = messages[i];
|
|
4278
|
+
if (message.role !== "assistant")
|
|
4279
|
+
continue;
|
|
4280
|
+
for (const part of (message.parts ?? [])) {
|
|
4281
|
+
if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId)
|
|
4282
|
+
continue;
|
|
4283
|
+
const type = part.type;
|
|
4284
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4285
|
+
if (!isTool)
|
|
4286
|
+
continue;
|
|
4287
|
+
if (part.state !== "input-available")
|
|
4288
|
+
return undefined;
|
|
4289
|
+
const slimPart = {
|
|
4290
|
+
type,
|
|
4291
|
+
toolCallId: resolution.toolCallId,
|
|
4292
|
+
state: "output-available",
|
|
4293
|
+
output: resolution.output,
|
|
4294
|
+
};
|
|
4295
|
+
if (type === "dynamic-tool" && typeof part.toolName === "string")
|
|
4296
|
+
slimPart.toolName = part.toolName;
|
|
4297
|
+
return {
|
|
4298
|
+
id: message.id,
|
|
4299
|
+
role: "assistant",
|
|
4300
|
+
parts: [slimPart],
|
|
4301
|
+
...(provenance ? { metadata: provenance } : {}),
|
|
4302
|
+
};
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
return undefined;
|
|
4306
|
+
}
|
|
4307
|
+
// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
|
|
4308
|
+
function defaultChannelOutbound(reply) {
|
|
4309
|
+
return reply.text ? { text: reply.text } : null;
|
|
4310
|
+
}
|
|
4311
|
+
// Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
|
|
4312
|
+
// pick one at random from an array. Returns undefined to skip (no choice / empty).
|
|
4313
|
+
async function resolveReactionChoice(choice, event) {
|
|
4314
|
+
if (choice == null)
|
|
4315
|
+
return undefined;
|
|
4316
|
+
let value = typeof choice === "function" ? await choice(event) : choice;
|
|
4317
|
+
if (Array.isArray(value)) {
|
|
4318
|
+
if (value.length === 0)
|
|
4319
|
+
return undefined;
|
|
4320
|
+
value = value[Math.floor(Math.random() * value.length)];
|
|
4321
|
+
}
|
|
4322
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
4323
|
+
}
|
|
4324
|
+
// Best-effort reaction on the triggering message; a failure never breaks the turn.
|
|
4325
|
+
async function applyChannelReaction(connector, wireEvent, reaction) {
|
|
4326
|
+
if (!connector?.react || !wireEvent || !reaction.name)
|
|
4327
|
+
return;
|
|
4328
|
+
try {
|
|
4329
|
+
await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
|
|
4330
|
+
}
|
|
4331
|
+
catch (error) {
|
|
4332
|
+
v3_1.logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
|
|
4333
|
+
}
|
|
4334
|
+
}
|
|
4335
|
+
// The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
|
|
4336
|
+
// `react`, so `payload.channel` is only present when reacting is actually possible.
|
|
4337
|
+
function buildChannelRunSurface(connector, wireEvent, handle) {
|
|
4338
|
+
if (!connector)
|
|
4339
|
+
return undefined;
|
|
4340
|
+
const canReact = !!connector.react && !!wireEvent;
|
|
4341
|
+
if (!canReact && !connector.surface)
|
|
4342
|
+
return undefined;
|
|
4343
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4344
|
+
let extra = {};
|
|
4345
|
+
if (connector.surface) {
|
|
4346
|
+
try {
|
|
4347
|
+
extra = connector.surface(turnCtx) ?? {};
|
|
4348
|
+
}
|
|
4349
|
+
catch (error) {
|
|
4350
|
+
v3_1.logger.warn("chat.agent: channel surface() threw; continuing without it", { error });
|
|
4351
|
+
}
|
|
4352
|
+
}
|
|
4353
|
+
const noop = async () => { };
|
|
4354
|
+
return {
|
|
4355
|
+
...extra,
|
|
4356
|
+
react: canReact ? (name) => applyChannelReaction(connector, wireEvent, { name }) : noop,
|
|
4357
|
+
unreact: canReact
|
|
4358
|
+
? (name) => applyChannelReaction(connector, wireEvent, { name, remove: true })
|
|
4359
|
+
: noop,
|
|
4360
|
+
};
|
|
4361
|
+
}
|
|
4362
|
+
function channelTurnCtx(wireEvent, handle) {
|
|
4363
|
+
return {
|
|
4364
|
+
...(wireEvent ? { event: wireEvent.event, deliveryId: wireEvent.deliveryId } : {}),
|
|
4365
|
+
...(handle !== undefined ? { handle } : {}),
|
|
4366
|
+
bound: !wireEvent,
|
|
4367
|
+
};
|
|
4368
|
+
}
|
|
4369
|
+
/**
|
|
4370
|
+
* Feed a connector's `progress()` from the UI message stream: a tool call once its input is available,
|
|
4371
|
+
* then its result or error. Calls are serialized so the surface sees them in order; a throw is logged.
|
|
4372
|
+
* `stop()` refuses further chunks and drains what is already queued, so a slow first post does not
|
|
4373
|
+
* lose the results behind it, and nothing new lands after the turn ended.
|
|
4374
|
+
*/
|
|
4375
|
+
function makeChannelProgressObserver(connector, wireEvent, handle) {
|
|
4376
|
+
const progress = connector.progress;
|
|
4377
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4378
|
+
const toolNames = new Map();
|
|
4379
|
+
let chain = Promise.resolve();
|
|
4380
|
+
let stopped = false;
|
|
4381
|
+
const emit = (event) => {
|
|
4382
|
+
chain = chain.then(async () => {
|
|
4383
|
+
try {
|
|
4384
|
+
await progress(event, turnCtx);
|
|
4385
|
+
}
|
|
4386
|
+
catch (error) {
|
|
4387
|
+
v3_1.logger.warn("chat.agent: channel progress() failed; continuing", {
|
|
4388
|
+
error,
|
|
4389
|
+
type: event.type,
|
|
4390
|
+
});
|
|
4391
|
+
}
|
|
4392
|
+
});
|
|
4393
|
+
};
|
|
4394
|
+
return {
|
|
4395
|
+
observe(chunk) {
|
|
4396
|
+
if (stopped)
|
|
4397
|
+
return;
|
|
4398
|
+
const c = chunk;
|
|
4399
|
+
if (!c?.type || !c.toolCallId)
|
|
4400
|
+
return;
|
|
4401
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4402
|
+
toolNames.set(c.toolCallId, c.toolName);
|
|
4403
|
+
return;
|
|
4404
|
+
}
|
|
4405
|
+
if (c.type === "tool-input-available") {
|
|
4406
|
+
const toolName = c.toolName ?? toolNames.get(c.toolCallId) ?? "tool";
|
|
4407
|
+
toolNames.set(c.toolCallId, toolName);
|
|
4408
|
+
emit({ type: "tool-call", toolCallId: c.toolCallId, toolName, input: c.input });
|
|
4409
|
+
return;
|
|
4410
|
+
}
|
|
4411
|
+
if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4412
|
+
emit({
|
|
4413
|
+
type: "tool-result",
|
|
4414
|
+
toolCallId: c.toolCallId,
|
|
4415
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4416
|
+
output: c.output,
|
|
4417
|
+
});
|
|
4418
|
+
return;
|
|
4419
|
+
}
|
|
4420
|
+
if (c.type === "tool-output-error") {
|
|
4421
|
+
emit({
|
|
4422
|
+
type: "tool-error",
|
|
4423
|
+
toolCallId: c.toolCallId,
|
|
4424
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4425
|
+
errorText: c.errorText ?? "tool failed",
|
|
4426
|
+
});
|
|
4427
|
+
}
|
|
4428
|
+
},
|
|
4429
|
+
async stop() {
|
|
4430
|
+
stopped = true;
|
|
4431
|
+
await chain;
|
|
4432
|
+
},
|
|
4433
|
+
};
|
|
4434
|
+
}
|
|
4435
|
+
/** Re-send `working` this often during a long turn: Slack drops a processing status after an hour. */
|
|
4436
|
+
const CHANNEL_WORKING_REFRESH_MS = 50 * 60 * 1000;
|
|
4437
|
+
/**
|
|
4438
|
+
* Report a session's lifecycle to its connector in order, one call at a time, and re-send `working`
|
|
4439
|
+
* while a long turn runs. Every report bumps a sequence number and a refresh only fires while its
|
|
4440
|
+
* `working` is still the latest report, so a refresh can never land after the turn went idle.
|
|
4441
|
+
*/
|
|
4442
|
+
function makeChannelLifecycleReporter() {
|
|
4443
|
+
let chain = Promise.resolve();
|
|
4444
|
+
let seq = 0;
|
|
4445
|
+
let refreshTimer;
|
|
4446
|
+
let closed = false;
|
|
4447
|
+
const clearRefresh = () => {
|
|
4448
|
+
if (refreshTimer)
|
|
4449
|
+
clearTimeout(refreshTimer);
|
|
4450
|
+
refreshTimer = undefined;
|
|
4451
|
+
};
|
|
4452
|
+
const enqueue = (connector, state, ctx, reportSeq) => {
|
|
4453
|
+
chain = chain.then(async () => {
|
|
4454
|
+
if (ctx.refresh && reportSeq !== seq)
|
|
4455
|
+
return;
|
|
4456
|
+
try {
|
|
4457
|
+
await connector.lifecycle(state, ctx);
|
|
4458
|
+
}
|
|
4459
|
+
catch (error) {
|
|
4460
|
+
v3_1.logger.warn("chat.agent: channel lifecycle() failed; continuing", { error, state });
|
|
4461
|
+
}
|
|
4462
|
+
});
|
|
4463
|
+
return chain;
|
|
4464
|
+
};
|
|
4465
|
+
return {
|
|
4466
|
+
report(connector, state, ctx) {
|
|
4467
|
+
if (!connector?.lifecycle || closed)
|
|
4468
|
+
return Promise.resolve();
|
|
4469
|
+
if (state === "closed")
|
|
4470
|
+
closed = true;
|
|
4471
|
+
const reportSeq = ++seq;
|
|
4472
|
+
clearRefresh();
|
|
4473
|
+
const done = enqueue(connector, state, ctx, reportSeq);
|
|
4474
|
+
if (state === "working") {
|
|
4475
|
+
const arm = () => {
|
|
4476
|
+
refreshTimer = setTimeout(() => {
|
|
4477
|
+
if (reportSeq !== seq)
|
|
4478
|
+
return;
|
|
4479
|
+
void enqueue(connector, "working", { ...ctx, firstTurn: false, refresh: true }, reportSeq);
|
|
4480
|
+
arm();
|
|
4481
|
+
}, CHANNEL_WORKING_REFRESH_MS);
|
|
4482
|
+
refreshTimer.unref?.();
|
|
4483
|
+
};
|
|
4484
|
+
arm();
|
|
4485
|
+
}
|
|
4486
|
+
return done;
|
|
4487
|
+
},
|
|
4488
|
+
dispose: clearRefresh,
|
|
4489
|
+
};
|
|
4490
|
+
}
|
|
4491
|
+
/**
|
|
4492
|
+
* The assistant message with the given pending tool calls resolved as stopped (`output-error`), for
|
|
4493
|
+
* a stop that arrives while the session waits on a human decision. Undefined when none matched.
|
|
4494
|
+
*/
|
|
4495
|
+
function stopPendingToolCalls(message, toolCallIds, errorText) {
|
|
4496
|
+
let changed = false;
|
|
4497
|
+
const parts = (message.parts ?? []).map((part) => {
|
|
4498
|
+
if (part &&
|
|
4499
|
+
typeof part === "object" &&
|
|
4500
|
+
toolCallIds.has(part.toolCallId) &&
|
|
4501
|
+
part.state === "input-available") {
|
|
4502
|
+
changed = true;
|
|
4503
|
+
return { ...part, state: "output-error", errorText };
|
|
4504
|
+
}
|
|
4505
|
+
return part;
|
|
4506
|
+
});
|
|
4507
|
+
return changed ? { ...message, parts } : undefined;
|
|
4508
|
+
}
|
|
4509
|
+
/**
|
|
4510
|
+
* Feed a connector's native reply stream from the UI message stream: text deltas as they arrive, and
|
|
4511
|
+
* tool progress when the stream accepts it. `stop()` only refuses further chunks; the turn boundary
|
|
4512
|
+
* ends the stream itself with the turn's outcome.
|
|
4513
|
+
*/
|
|
4514
|
+
function makeChannelReplyStreamObserver(stream) {
|
|
4515
|
+
const toolNames = new Map();
|
|
4516
|
+
let stopped = false;
|
|
4517
|
+
const safe = (fn) => {
|
|
4518
|
+
try {
|
|
4519
|
+
fn();
|
|
4520
|
+
}
|
|
4521
|
+
catch (error) {
|
|
4522
|
+
v3_1.logger.warn("chat.agent: channel reply stream threw; continuing", { error });
|
|
4523
|
+
}
|
|
4524
|
+
};
|
|
4525
|
+
return {
|
|
4526
|
+
observe(chunk) {
|
|
4527
|
+
if (stopped)
|
|
4528
|
+
return;
|
|
4529
|
+
const c = chunk;
|
|
4530
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4531
|
+
const delta = c.delta;
|
|
4532
|
+
safe(() => stream.text(delta));
|
|
4533
|
+
return;
|
|
4534
|
+
}
|
|
4535
|
+
if (!stream.progress || !c?.type || !c.toolCallId)
|
|
4536
|
+
return;
|
|
4537
|
+
const toolCallId = c.toolCallId;
|
|
4538
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4539
|
+
toolNames.set(toolCallId, c.toolName);
|
|
4540
|
+
}
|
|
4541
|
+
else if (c.type === "tool-input-available") {
|
|
4542
|
+
const toolName = c.toolName ?? toolNames.get(toolCallId) ?? "tool";
|
|
4543
|
+
toolNames.set(toolCallId, toolName);
|
|
4544
|
+
safe(() => stream.progress({ type: "tool-call", toolCallId, toolName, input: c.input }));
|
|
4545
|
+
}
|
|
4546
|
+
else if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4547
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4548
|
+
safe(() => stream.progress({ type: "tool-result", toolCallId, toolName, output: c.output }));
|
|
4549
|
+
}
|
|
4550
|
+
else if (c.type === "tool-output-error") {
|
|
4551
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4552
|
+
const errorText = c.errorText ?? "tool failed";
|
|
4553
|
+
safe(() => stream.progress({ type: "tool-error", toolCallId, toolName, errorText }));
|
|
4554
|
+
}
|
|
4555
|
+
},
|
|
4556
|
+
stop() {
|
|
4557
|
+
stopped = true;
|
|
4558
|
+
},
|
|
4559
|
+
};
|
|
4560
|
+
}
|
|
4561
|
+
function composeChannelObservers(observers) {
|
|
4562
|
+
return {
|
|
4563
|
+
observe(chunk) {
|
|
4564
|
+
for (const o of observers)
|
|
4565
|
+
o.observe(chunk);
|
|
4566
|
+
},
|
|
4567
|
+
async stop() {
|
|
4568
|
+
await Promise.all(observers.map((o) => o.stop()));
|
|
4569
|
+
},
|
|
4570
|
+
};
|
|
4571
|
+
}
|
|
4572
|
+
// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
|
|
4573
|
+
// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
|
|
4574
|
+
// the authoritative last write. Best-effort: an edit failure is logged, never fatal.
|
|
4575
|
+
const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
|
|
4576
|
+
function makeChannelStreamEditor(connector, channelEvent, ackRef, handle) {
|
|
4577
|
+
const outbound = connector.outbound ?? defaultChannelOutbound;
|
|
4578
|
+
let latest = "";
|
|
4579
|
+
let lastSent = "";
|
|
4580
|
+
let timer;
|
|
4581
|
+
let inFlightPromise;
|
|
4582
|
+
let stopped = false;
|
|
4583
|
+
const arm = () => {
|
|
4584
|
+
if (stopped || timer)
|
|
4585
|
+
return;
|
|
4586
|
+
timer = setTimeout(() => {
|
|
4587
|
+
timer = undefined;
|
|
4588
|
+
void edit();
|
|
4589
|
+
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
|
|
4590
|
+
};
|
|
4591
|
+
const edit = async () => {
|
|
4592
|
+
if (stopped || inFlightPromise)
|
|
4593
|
+
return;
|
|
4594
|
+
const text = latest;
|
|
4595
|
+
if (text === lastSent)
|
|
4596
|
+
return;
|
|
4597
|
+
const message = outbound({
|
|
4598
|
+
text,
|
|
4599
|
+
message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] },
|
|
4600
|
+
final: false,
|
|
4601
|
+
stopped: false,
|
|
4602
|
+
});
|
|
4603
|
+
if (!message)
|
|
4604
|
+
return;
|
|
4605
|
+
inFlightPromise = (async () => {
|
|
4606
|
+
try {
|
|
4607
|
+
await connector.send(message, {
|
|
4608
|
+
event: channelEvent.event,
|
|
4609
|
+
handle,
|
|
4610
|
+
deliveryId: channelEvent.deliveryId,
|
|
4611
|
+
previousRef: ackRef,
|
|
4612
|
+
mode: "stream",
|
|
4613
|
+
final: false,
|
|
4614
|
+
bound: false,
|
|
4615
|
+
});
|
|
4616
|
+
lastSent = text;
|
|
4617
|
+
}
|
|
4618
|
+
catch (error) {
|
|
4619
|
+
v3_1.logger.warn("chat.agent: channel stream edit failed", { error });
|
|
4620
|
+
}
|
|
4621
|
+
finally {
|
|
4622
|
+
inFlightPromise = undefined;
|
|
4623
|
+
if (!stopped && latest !== lastSent)
|
|
4624
|
+
arm();
|
|
4625
|
+
}
|
|
4626
|
+
})();
|
|
4627
|
+
await inFlightPromise;
|
|
4628
|
+
};
|
|
4629
|
+
return {
|
|
4630
|
+
observe(chunk) {
|
|
4631
|
+
if (stopped)
|
|
4632
|
+
return;
|
|
4633
|
+
const c = chunk;
|
|
4634
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4635
|
+
latest += c.delta;
|
|
4636
|
+
arm();
|
|
4637
|
+
}
|
|
4638
|
+
},
|
|
4639
|
+
async stop() {
|
|
4640
|
+
stopped = true;
|
|
4641
|
+
if (timer) {
|
|
4642
|
+
clearTimeout(timer);
|
|
4643
|
+
timer = undefined;
|
|
4644
|
+
}
|
|
4645
|
+
if (inFlightPromise) {
|
|
4646
|
+
try {
|
|
4647
|
+
await inFlightPromise;
|
|
4648
|
+
}
|
|
4649
|
+
catch { }
|
|
4650
|
+
}
|
|
4651
|
+
},
|
|
4652
|
+
};
|
|
4653
|
+
}
|
|
4654
|
+
/**
|
|
4655
|
+
* Wrap the reply stream so it debounce-edits the channel message as it streams.
|
|
4656
|
+
* Both `flush` (the stream completed) and `cancel` (the stream was aborted or
|
|
4657
|
+
* cancelled downstream) stop the editor, so a pending debounce timer can never
|
|
4658
|
+
* fire an edit after the turn ended, onto a message the next turn already owns.
|
|
4659
|
+
*/
|
|
4660
|
+
/**
|
|
4661
|
+
* Feed every chunk of a stream to `editor`. By default the editor is stopped when the stream ends or
|
|
4662
|
+
* is cancelled (one stream per turn, the automatic pipe). With `stopOnEnd: false` the editor outlives
|
|
4663
|
+
* the stream, for a turn whose run() pipes several streams through `chat.pipe()`; the turn boundary
|
|
4664
|
+
* stops it.
|
|
4665
|
+
*/
|
|
4666
|
+
function makeChannelStreamTap(editor, options = {}) {
|
|
4667
|
+
const stopOnEnd = options.stopOnEnd ?? true;
|
|
4668
|
+
const transformer = {
|
|
4669
|
+
transform(chunk, controller) {
|
|
4670
|
+
editor.observe(chunk);
|
|
4671
|
+
controller.enqueue(chunk);
|
|
4672
|
+
},
|
|
4673
|
+
async flush() {
|
|
4674
|
+
if (stopOnEnd)
|
|
4675
|
+
await editor.stop();
|
|
4676
|
+
},
|
|
4677
|
+
async cancel() {
|
|
4678
|
+
if (stopOnEnd)
|
|
4679
|
+
await editor.stop();
|
|
4680
|
+
},
|
|
4681
|
+
};
|
|
4682
|
+
return new TransformStream(transformer);
|
|
4683
|
+
}
|
|
4684
|
+
/**
|
|
4685
|
+
* Per-chat latch for the channel egress that runs AFTER `trigger:turn-complete` is written (the
|
|
4686
|
+
* final `send()` and the done/error reactions). The run loop opens it before writing the chunk and
|
|
4687
|
+
* settles it once that egress has finished, so a caller that saw the chunk can wait for the
|
|
4688
|
+
* connector to be quiet instead of guessing with timers. One latch per chat at a time; a chat with
|
|
4689
|
+
* no open latch is quiet.
|
|
4690
|
+
*/
|
|
4691
|
+
const channelEgressLatches = new Map();
|
|
4692
|
+
function openChannelEgressLatch(chatId) {
|
|
4693
|
+
let settle;
|
|
4694
|
+
const promise = new Promise((resolve) => (settle = resolve));
|
|
4695
|
+
channelEgressLatches.set(chatId, { promise, settle });
|
|
4696
|
+
}
|
|
4697
|
+
function settleChannelEgressLatch(chatId) {
|
|
4698
|
+
const latch = channelEgressLatches.get(chatId);
|
|
4699
|
+
if (!latch)
|
|
4700
|
+
return;
|
|
4701
|
+
channelEgressLatches.delete(chatId);
|
|
4702
|
+
latch.settle();
|
|
4703
|
+
}
|
|
4704
|
+
/** Resolves once the post-turn channel egress for `chatId` has finished (immediately when none is running). */
|
|
4705
|
+
async function __waitForChannelEgressForTests(chatId) {
|
|
4706
|
+
await channelEgressLatches.get(chatId)?.promise;
|
|
4707
|
+
}
|
|
4708
|
+
function chatCustomAgent(options) {
|
|
4709
|
+
const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
|
|
4710
|
+
const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
|
|
4711
|
+
const parseClientDataSync = clientDataSchema
|
|
4712
|
+
? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
|
|
4713
|
+
: undefined;
|
|
4714
|
+
const task = (0, shared_js_1.createTask)({
|
|
4715
|
+
...restOptions,
|
|
4716
|
+
triggerSource: "agent",
|
|
4717
|
+
agentConfig: { type: "ai-sdk-chat" },
|
|
4718
|
+
run: async (payload, runOptions) => {
|
|
4719
|
+
// Bind the run to its backing Session so module-level helpers
|
|
4720
|
+
// (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
|
|
4721
|
+
// resolve to this chat's `.in` / `.out` channels. Address
|
|
4722
|
+
// everywhere by `payload.chatId` (the session externalId) so the
|
|
4723
|
+
// agent's writes and the transport's reads converge on the same
|
|
4724
|
+
// S2 stream key + waitpoint key.
|
|
4725
|
+
//
|
|
4726
|
+
// The Session row is created server-side by `POST /sessions` (or
|
|
4727
|
+
// `chat.createStartSessionAction`) before this run is triggered.
|
|
4728
|
+
// No client-side upsert needed.
|
|
4729
|
+
locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
|
|
4730
|
+
locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
|
|
4731
|
+
locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
|
|
4732
|
+
locals_js_1.locals.set(chatCustomAgentRunKey, true);
|
|
4733
|
+
if (parseClientData && parseClientDataSync) {
|
|
4734
|
+
locals_js_1.locals.set(chatCustomAgentClientDataParserKey, {
|
|
4735
|
+
parse: parseClientData,
|
|
4736
|
+
parseSync: parseClientDataSync,
|
|
4737
|
+
});
|
|
4738
|
+
}
|
|
4739
|
+
if (onClientDataValidationError) {
|
|
4740
|
+
locals_js_1.locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
|
|
4741
|
+
}
|
|
4742
|
+
locals_js_1.locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
|
|
4743
|
+
// Initialize the turn-complete trim slot so `chat.writeTurnComplete`
|
|
4744
|
+
// trims `session.out` back to the previous turn boundary. Without
|
|
4745
|
+
// this the slot is undefined and the trim never runs, so `.out`
|
|
4746
|
+
// grows without bound for the whole custom-agent surface.
|
|
4747
|
+
locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
|
|
4748
|
+
(0, streams_js_1.markChatAgentRunForStreamsWarning)();
|
|
4749
|
+
v3_1.taskContext.setConversationId(payload.chatId);
|
|
4750
|
+
stampConversationIdOnActiveSpan(payload.chatId);
|
|
4751
|
+
await installChatInputRouter(payload.chatId, {
|
|
4752
|
+
resuming: Boolean(payload.continuation),
|
|
4753
|
+
});
|
|
4754
|
+
// A custom agent's loop is the customer's, so there is no exit site the
|
|
4755
|
+
// SDK controls. Perform a requested close when `run()` returns, whatever
|
|
4756
|
+
// shape the loop had. Idempotent, so the createSession iterator having
|
|
4757
|
+
// already closed on its own exit costs nothing.
|
|
4758
|
+
const withClose = async (result) => {
|
|
4759
|
+
try {
|
|
4760
|
+
return await result;
|
|
4761
|
+
}
|
|
4762
|
+
finally {
|
|
4763
|
+
await performChatClose();
|
|
4764
|
+
}
|
|
4765
|
+
};
|
|
4766
|
+
// Keep the schema-free path identical to the original custom-agent
|
|
4767
|
+
// wrapper, including when userRun starts executing.
|
|
4768
|
+
if (!parseClientData) {
|
|
4769
|
+
return withClose(userRun(payload, runOptions));
|
|
4770
|
+
}
|
|
4771
|
+
const isHandoverBoot = payload.trigger === "handover-prepare";
|
|
4772
|
+
const isMessagelessBoot = payload.trigger === "preload" ||
|
|
4773
|
+
(payload.continuation === true &&
|
|
4774
|
+
payload.message === undefined &&
|
|
4775
|
+
payload.trigger !== "action" &&
|
|
4776
|
+
payload.trigger !== "regenerate-message" &&
|
|
4777
|
+
!isHandoverBoot);
|
|
4778
|
+
const validated = await validateChatCustomAgentPayload(payload, {
|
|
4779
|
+
// Preload and continuation boots do not represent a submitted turn,
|
|
4780
|
+
// so there is no sender waiting for a terminal frame. Handover errors
|
|
4781
|
+
// must be written after the warm response flushes and signals below.
|
|
4782
|
+
writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
|
|
4783
|
+
});
|
|
4784
|
+
if (validated.ok) {
|
|
4785
|
+
return withClose(userRun(validated.payload, runOptions));
|
|
4786
|
+
}
|
|
4787
|
+
if (isHandoverBoot) {
|
|
4788
|
+
const signal = await waitForHandover({
|
|
4789
|
+
payload,
|
|
4790
|
+
timeout: "1h",
|
|
4791
|
+
spanName: "waiting for handover signal (invalid clientData)",
|
|
4792
|
+
});
|
|
4793
|
+
if (!signal || signal.kind === "handover-skip") {
|
|
4794
|
+
return;
|
|
4795
|
+
}
|
|
4796
|
+
// The head-start writer flushes before sending this signal. Writing
|
|
4797
|
+
// the terminal error now preserves stream order and closes the stitch.
|
|
4798
|
+
await writeChatCustomAgentClientDataErrorToStream(payload);
|
|
4799
|
+
return;
|
|
4800
|
+
}
|
|
4801
|
+
// The Session base payload is sticky across continuation runs. If it is
|
|
4802
|
+
// invalid, returning here would boot the same bad metadata again on the
|
|
4803
|
+
// next message. Stay attached and wait for a valid wire frame instead.
|
|
4804
|
+
const next = await messagesInput.waitWithIdleTimeout({
|
|
4805
|
+
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
4806
|
+
timeout: "1h",
|
|
4807
|
+
spanName: "waiting for valid clientData",
|
|
4808
|
+
});
|
|
4809
|
+
if (!next.ok || next.output.trigger === "close") {
|
|
4810
|
+
return;
|
|
4811
|
+
}
|
|
4812
|
+
// Normal input frames omit run-level boot context. Carry it forward so
|
|
4813
|
+
// a continuation still tells the custom loop to restore prior state.
|
|
4814
|
+
const recoveredPayload = {
|
|
4815
|
+
...next.output,
|
|
4816
|
+
continuation: next.output.continuation ?? payload.continuation,
|
|
4817
|
+
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
4818
|
+
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
4819
|
+
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
4820
|
+
};
|
|
4821
|
+
return withClose(userRun(recoveredPayload, runOptions));
|
|
4822
|
+
},
|
|
4823
|
+
});
|
|
4824
|
+
// Register clientDataSchema so the CLI converts it to JSONSchema
|
|
4825
|
+
// and stores it as payloadSchema — used by the Playground UI
|
|
4826
|
+
if (clientDataSchema) {
|
|
4827
|
+
v3_1.resourceCatalog.updateTaskMetadata(options.id, {
|
|
4828
|
+
schema: clientDataSchema,
|
|
4829
|
+
});
|
|
4830
|
+
}
|
|
4831
|
+
return task;
|
|
4832
|
+
}
|
|
4833
|
+
function chatAgent(options) {
|
|
4834
|
+
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, ...restOptions } = options;
|
|
4835
|
+
if (hydrateMessages) {
|
|
4836
|
+
if (storage) {
|
|
4837
|
+
throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
|
|
4838
|
+
"`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
|
|
4839
|
+
"change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
|
|
4840
|
+
}
|
|
4841
|
+
if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
|
|
4842
|
+
throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
|
|
4843
|
+
"`loadContext`. Both would own the model's context; keep one.");
|
|
4844
|
+
}
|
|
4845
|
+
warnHydrateMessagesDeprecatedOnce(options.id);
|
|
4846
|
+
}
|
|
4847
|
+
const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
|
|
4848
|
+
const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
|
|
4849
|
+
// chat.agent does not expose generic retry options (see docstring on
|
|
4850
|
+
// `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
|
|
4851
|
+
// `oomMachine` is set we allow one retry on a larger machine; otherwise
|
|
4852
|
+
// we keep the historical no-retry default.
|
|
4853
|
+
const retry = oomMachine
|
|
4854
|
+
? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
|
|
4855
|
+
: { maxAttempts: 1 };
|
|
4856
|
+
const task = (0, shared_js_1.createTask)({
|
|
4857
|
+
...restOptions,
|
|
4858
|
+
retry,
|
|
4859
|
+
triggerSource: "agent",
|
|
4860
|
+
agentConfig: { type: "ai-sdk-chat" },
|
|
4861
|
+
run: async (payload, { signal: runSignal, ctx }) => {
|
|
3889
4862
|
locals_js_1.locals.set(chatAgentRunContextKey, ctx);
|
|
3890
4863
|
locals_js_1.locals.set(chatCustomAgentRunKey, false);
|
|
3891
4864
|
// On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
|
|
@@ -4024,6 +4997,69 @@ function chatAgent(options) {
|
|
|
4024
4997
|
: undefined;
|
|
4025
4998
|
let transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)([]);
|
|
4026
4999
|
let bootTranscriptState = null;
|
|
5000
|
+
/**
|
|
5001
|
+
* The channel this session answers into (see TranscriptChannelBinding). Set by a channel turn
|
|
5002
|
+
* whose connector defines `bind`, restored from runtime state at boot, saved on every changeset.
|
|
5003
|
+
*/
|
|
5004
|
+
let sessionChannelBinding;
|
|
5005
|
+
const channelDownloader = channels
|
|
5006
|
+
? makeChannelDownloader(channels, (connectorId) => sessionChannelBinding?.connectorId === connectorId
|
|
5007
|
+
? sessionChannelBinding.handle
|
|
5008
|
+
: undefined)
|
|
5009
|
+
: undefined;
|
|
5010
|
+
locals_js_1.locals.set(chatChannelDownloadKey, channelDownloader);
|
|
5011
|
+
/**
|
|
5012
|
+
* Tool calls that already have channel controls posted. A turn that resumes with one of
|
|
5013
|
+
* several pending calls resolved still carries the others as pending; without this the
|
|
5014
|
+
* controls would be posted again, and the model's final text would lose to them. A
|
|
5015
|
+
* continuation worker seeds it at boot from the calls still pending in the restored
|
|
5016
|
+
* transcript of a channel-bound session, since the worker before it posted their controls.
|
|
5017
|
+
*/
|
|
5018
|
+
const channelRenderedToolCallIds = new Set();
|
|
5019
|
+
/** Serializes `lifecycle()` reports for the session's channel across turns. */
|
|
5020
|
+
const channelLifecycle = makeChannelLifecycleReporter();
|
|
5021
|
+
/**
|
|
5022
|
+
* Pending calls a channel stop resolved as stopped on this worker. A click on their old controls
|
|
5023
|
+
* is dropped even when the transcript was hydrated again from a store that never saw the stop.
|
|
5024
|
+
*/
|
|
5025
|
+
const channelStoppedToolCallIds = new Set();
|
|
5026
|
+
/** When the latest channel turn started, so a stop pressed before it is recognized as stale. */
|
|
5027
|
+
let lastChannelTurnStartedAt;
|
|
5028
|
+
/** When the latest channel turn ended, so a stop pressed while it ran is not run as a new turn. */
|
|
5029
|
+
let lastChannelTurnEndedAt;
|
|
5030
|
+
/** The turn reported its end state: the binding no longer marks the surface as working. */
|
|
5031
|
+
const clearChannelWorking = () => {
|
|
5032
|
+
if (!sessionChannelBinding?.working)
|
|
5033
|
+
return;
|
|
5034
|
+
const { working: _working, ...rest } = sessionChannelBinding;
|
|
5035
|
+
void _working;
|
|
5036
|
+
sessionChannelBinding = rest;
|
|
5037
|
+
};
|
|
5038
|
+
/** The turn's reply stream ended: the binding no longer names it for recovery. */
|
|
5039
|
+
const clearOpenReplyStream = () => {
|
|
5040
|
+
if (!sessionChannelBinding?.stream)
|
|
5041
|
+
return;
|
|
5042
|
+
const { stream: _stream, ...rest } = sessionChannelBinding;
|
|
5043
|
+
void _stream;
|
|
5044
|
+
sessionChannelBinding = rest;
|
|
5045
|
+
};
|
|
5046
|
+
/** Tell the bound channel the conversation is over (`chat.close()` or a close signal). */
|
|
5047
|
+
const reportChannelClosed = async () => {
|
|
5048
|
+
const binding = sessionChannelBinding;
|
|
5049
|
+
if (!binding)
|
|
5050
|
+
return;
|
|
5051
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5052
|
+
await channelLifecycle.report(connector, "closed", {
|
|
5053
|
+
handle: binding.handle,
|
|
5054
|
+
bound: true,
|
|
5055
|
+
firstTurn: false,
|
|
5056
|
+
});
|
|
5057
|
+
};
|
|
5058
|
+
/** Provenance of the turn or action being processed, for the storage context. */
|
|
5059
|
+
let currentTurnSource = "client";
|
|
5060
|
+
let currentTurnConnectorId;
|
|
5061
|
+
/** True once `onAction` returned `chat.turn()` for the action being processed. */
|
|
5062
|
+
let turnIsActionTurn = false;
|
|
4027
5063
|
/**
|
|
4028
5064
|
* True while the model lane holds a compaction summary, so it cannot be
|
|
4029
5065
|
* rebuilt from the transcript and has to be persisted as state. Reset
|
|
@@ -4086,9 +5122,14 @@ function chatAgent(options) {
|
|
|
4086
5122
|
const transcriptIds = new Set(opts.messages.map((message) => message.id));
|
|
4087
5123
|
const markerIds = new Set((0, steeringContext_js_1.steeringMarkers)(opts.messages).map((marker) => marker.id));
|
|
4088
5124
|
const steering = [...(locals_js_1.locals.get(chatSteeringInjectionsKey)?.values() ?? [])].filter((entry) => markerIds.has(entry.id) || entry.messageIds.some((id) => transcriptIds.has(id)));
|
|
4089
|
-
const runtimeState = laneCompacted ||
|
|
5125
|
+
const runtimeState = laneCompacted ||
|
|
5126
|
+
laneInjections.length > 0 ||
|
|
5127
|
+
queued.length > 0 ||
|
|
5128
|
+
steering.length > 0 ||
|
|
5129
|
+
sessionChannelBinding
|
|
4090
5130
|
? {
|
|
4091
5131
|
v: 1,
|
|
5132
|
+
...(sessionChannelBinding ? { channelBinding: sessionChannelBinding } : {}),
|
|
4092
5133
|
...(laneCompacted
|
|
4093
5134
|
? {
|
|
4094
5135
|
compaction: {
|
|
@@ -4118,7 +5159,9 @@ function chatAgent(options) {
|
|
|
4118
5159
|
chatId: payload.chatId,
|
|
4119
5160
|
clientData: opts.clientData,
|
|
4120
5161
|
turn: opts.turn,
|
|
4121
|
-
trigger: opts.trigger,
|
|
5162
|
+
trigger: opts.trigger === "action" && turnIsActionTurn ? "action-turn" : opts.trigger,
|
|
5163
|
+
source: currentTurnSource,
|
|
5164
|
+
...(currentTurnConnectorId ? { connectorId: currentTurnConnectorId } : {}),
|
|
4122
5165
|
runId: ctx.run.id,
|
|
4123
5166
|
ctx,
|
|
4124
5167
|
}, {
|
|
@@ -4201,6 +5244,9 @@ function chatAgent(options) {
|
|
|
4201
5244
|
let replayedPartial;
|
|
4202
5245
|
let replayedPartialRaw;
|
|
4203
5246
|
let replayedInTail = [];
|
|
5247
|
+
// Channel deliveries on the same tail. They are not "messages" for the recovery hooks, but
|
|
5248
|
+
// they are turns the predecessor owed, and they have to dispatch in seq order with the rest.
|
|
5249
|
+
let replayedChannelInTail = [];
|
|
4204
5250
|
// Wire payloads to dispatch as turns before the regular session.in
|
|
4205
5251
|
// pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
|
|
4206
5252
|
// default, `inFlightUsers`). The turn-loop checks this queue ahead of
|
|
@@ -4331,9 +5377,11 @@ function chatAgent(options) {
|
|
|
4331
5377
|
}
|
|
4332
5378
|
bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
|
|
4333
5379
|
try {
|
|
4334
|
-
|
|
5380
|
+
const tail = await replaySessionInTailWithChannels(payload.chatId, {
|
|
4335
5381
|
lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
|
|
4336
5382
|
});
|
|
5383
|
+
replayedInTail = tail.messages;
|
|
5384
|
+
replayedChannelInTail = tail.channelEvents;
|
|
4337
5385
|
}
|
|
4338
5386
|
catch (error) {
|
|
4339
5387
|
v3_1.logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -4354,7 +5402,10 @@ function chatAgent(options) {
|
|
|
4354
5402
|
}
|
|
4355
5403
|
// ── session.in router ──────────────────────────────────────────
|
|
4356
5404
|
//
|
|
4357
|
-
const recoveredSeqNums =
|
|
5405
|
+
const recoveredSeqNums = [
|
|
5406
|
+
...replayedInTail.map((r) => r.seqNum),
|
|
5407
|
+
...replayedChannelInTail.map((r) => r.seqNum),
|
|
5408
|
+
];
|
|
4358
5409
|
await installChatInputRouter(payload.chatId, {
|
|
4359
5410
|
fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
|
|
4360
5411
|
recoveredSeqNums,
|
|
@@ -4371,6 +5422,9 @@ function chatAgent(options) {
|
|
|
4371
5422
|
// predecessor chose to end before processing the message;
|
|
4372
5423
|
// those route through the normal continuation-wait path.
|
|
4373
5424
|
const hasRecoveredState = partialAssistant !== undefined;
|
|
5425
|
+
if (couldHavePriorState && hasRecoveredState) {
|
|
5426
|
+
locals_js_1.locals.set(chatChannelRecoveryPendingKey, { value: true });
|
|
5427
|
+
}
|
|
4374
5428
|
let hookChain;
|
|
4375
5429
|
let hookRecoveredTurns;
|
|
4376
5430
|
let hookBeforeBoot;
|
|
@@ -4441,10 +5495,18 @@ function chatAgent(options) {
|
|
|
4441
5495
|
// user is re-dispatched as a fresh turn instead.
|
|
4442
5496
|
let seedChain;
|
|
4443
5497
|
let recoveredEntries;
|
|
5498
|
+
// The orphan-partial splice pairs the partial assistant with the oldest unprocessed record,
|
|
5499
|
+
// which only makes sense when that record is the browser message it was answering. When a
|
|
5500
|
+
// channel delivery is older than every browser message, the partial belongs to it instead, and
|
|
5501
|
+
// splicing the first browser message ahead of it would reorder the conversation.
|
|
5502
|
+
const oldestTailSeq = Math.min(...replayedInTail.map((r) => r.seqNum), ...replayedChannelInTail.map((r) => r.seqNum));
|
|
5503
|
+
const partialFollowsBrowserMessage = partialAssistant !== undefined &&
|
|
5504
|
+
inFlightUsers.length > 1 &&
|
|
5505
|
+
(replayedChannelInTail.length === 0 || replayedInTail[0]?.seqNum === oldestTailSeq);
|
|
4444
5506
|
if (hookChain !== undefined) {
|
|
4445
5507
|
seedChain = hookChain;
|
|
4446
5508
|
}
|
|
4447
|
-
else if (
|
|
5509
|
+
else if (partialFollowsBrowserMessage) {
|
|
4448
5510
|
seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
|
|
4449
5511
|
}
|
|
4450
5512
|
else {
|
|
@@ -4464,7 +5526,7 @@ function chatAgent(options) {
|
|
|
4464
5526
|
seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
|
|
4465
5527
|
}));
|
|
4466
5528
|
}
|
|
4467
|
-
else if (
|
|
5529
|
+
else if (partialFollowsBrowserMessage) {
|
|
4468
5530
|
recoveredEntries = replayedInTail
|
|
4469
5531
|
.slice(1)
|
|
4470
5532
|
.map((r) => ({ message: r.message, seqNum: r.seqNum }));
|
|
@@ -4501,6 +5563,7 @@ function chatAgent(options) {
|
|
|
4501
5563
|
metadataById.set(entry.message.id, entry.metadata);
|
|
4502
5564
|
}
|
|
4503
5565
|
const dispatchedRecoveredSeqs = new Set();
|
|
5566
|
+
const toInject = [];
|
|
4504
5567
|
for (const { message: msg, seqNum } of recoveredEntries) {
|
|
4505
5568
|
if (wireMessageId && msg.id === wireMessageId)
|
|
4506
5569
|
continue;
|
|
@@ -4512,18 +5575,42 @@ function chatAgent(options) {
|
|
|
4512
5575
|
sessionId: payload.sessionId,
|
|
4513
5576
|
metadata: recoveredMetadata,
|
|
4514
5577
|
trigger: "submit-message",
|
|
4515
|
-
message: msg,
|
|
4516
|
-
messageId: msg.id,
|
|
5578
|
+
message: msg,
|
|
5579
|
+
messageId: msg.id,
|
|
5580
|
+
continuation: payload.continuation,
|
|
5581
|
+
previousRunId: payload.previousRunId,
|
|
5582
|
+
};
|
|
5583
|
+
toInject.push({ injectedPayload, seqNum });
|
|
5584
|
+
}
|
|
5585
|
+
// Channel deliveries the predecessor never processed dispatch too, interleaved with the
|
|
5586
|
+
// browser messages by seq_num so a Slack message that arrived before a browser message
|
|
5587
|
+
// still runs before it. A delivery the boot payload itself carries is not queued twice.
|
|
5588
|
+
const wireDeliveryId = payload.channelEvent
|
|
5589
|
+
?.deliveryId;
|
|
5590
|
+
for (const entry of replayedChannelInTail) {
|
|
5591
|
+
const entryDeliveryId = entry.channelEvent.deliveryId;
|
|
5592
|
+
if (wireDeliveryId && entryDeliveryId === wireDeliveryId)
|
|
5593
|
+
continue;
|
|
5594
|
+
const injectedPayload = {
|
|
5595
|
+
chatId: payload.chatId,
|
|
5596
|
+
sessionId: payload.sessionId,
|
|
5597
|
+
metadata: entry.metadata ?? payload.metadata,
|
|
5598
|
+
trigger: "submit-message",
|
|
5599
|
+
channelEvent: entry.channelEvent,
|
|
4517
5600
|
continuation: payload.continuation,
|
|
4518
5601
|
previousRunId: payload.previousRunId,
|
|
4519
5602
|
};
|
|
5603
|
+
toInject.push({ injectedPayload, seqNum: entry.seqNum });
|
|
5604
|
+
}
|
|
5605
|
+
toInject.sort((a, b) => (a.seqNum ?? Infinity) - (b.seqNum ?? Infinity));
|
|
5606
|
+
for (const { injectedPayload, seqNum } of toInject) {
|
|
4520
5607
|
bootInjectedQueue.push(injectedPayload);
|
|
4521
5608
|
if (seqNum !== undefined) {
|
|
4522
5609
|
recoveredSeqByPayload.set(injectedPayload, seqNum);
|
|
4523
5610
|
dispatchedRecoveredSeqs.add(seqNum);
|
|
4524
5611
|
}
|
|
4525
5612
|
}
|
|
4526
|
-
for (const entry of replayedInTail) {
|
|
5613
|
+
for (const entry of [...replayedInTail, ...replayedChannelInTail]) {
|
|
4527
5614
|
if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
|
|
4528
5615
|
chatInputRouter().settleRecovered(entry.seqNum);
|
|
4529
5616
|
}
|
|
@@ -4575,6 +5662,38 @@ function chatAgent(options) {
|
|
|
4575
5662
|
accumulatedMessages = restored.messages;
|
|
4576
5663
|
laneCompacted = restored.compacted;
|
|
4577
5664
|
laneInjections = restored.injections;
|
|
5665
|
+
sessionChannelBinding = bootRuntimeState?.channelBinding;
|
|
5666
|
+
const interruptedStream = sessionChannelBinding?.stream;
|
|
5667
|
+
if (interruptedStream && sessionChannelBinding) {
|
|
5668
|
+
const binding = sessionChannelBinding;
|
|
5669
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5670
|
+
clearOpenReplyStream();
|
|
5671
|
+
if (connector?.endInterruptedStream) {
|
|
5672
|
+
try {
|
|
5673
|
+
await connector.endInterruptedStream(interruptedStream, {
|
|
5674
|
+
handle: binding.handle,
|
|
5675
|
+
bound: true,
|
|
5676
|
+
});
|
|
5677
|
+
}
|
|
5678
|
+
catch (error) {
|
|
5679
|
+
v3_1.logger.warn("chat.agent: ending the interrupted reply stream failed", { error });
|
|
5680
|
+
}
|
|
5681
|
+
}
|
|
5682
|
+
}
|
|
5683
|
+
if (sessionChannelBinding?.working) {
|
|
5684
|
+
const binding = sessionChannelBinding;
|
|
5685
|
+
clearChannelWorking();
|
|
5686
|
+
await channelLifecycle.report(channels?.find((c) => c.id === binding.connectorId), "idle", { handle: binding.handle, bound: true, firstTurn: false });
|
|
5687
|
+
}
|
|
5688
|
+
if (sessionChannelBinding) {
|
|
5689
|
+
for (const message of accumulatedUIMessages) {
|
|
5690
|
+
if (message.role !== "assistant")
|
|
5691
|
+
continue;
|
|
5692
|
+
for (const call of pendingToolCallsInMessage(message)) {
|
|
5693
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
}
|
|
4578
5697
|
if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
|
|
4579
5698
|
locals_js_1.locals.set(chatBackgroundQueueKey, [
|
|
4580
5699
|
...(locals_js_1.locals.get(chatBackgroundQueueKey) ?? []),
|
|
@@ -4961,26 +6080,432 @@ function chatAgent(options) {
|
|
|
4961
6080
|
// Declared here so the finally can detach it — a handler leaked past
|
|
4962
6081
|
// its turn duplicates every mid-stream message into the shared buffer.
|
|
4963
6082
|
let turnMsgSub;
|
|
6083
|
+
let turnControlSub;
|
|
4964
6084
|
let capturedPartialResponse;
|
|
4965
6085
|
let responseCommitted = false;
|
|
4966
6086
|
const turnBufferedChunks = [];
|
|
6087
|
+
let channelConn;
|
|
6088
|
+
let channelWorkingReaction;
|
|
6089
|
+
let channelWireEvent;
|
|
6090
|
+
/** The connector's posting handle for this turn: from `bind(event)` on a channel turn, else the session binding. */
|
|
6091
|
+
let channelHandle;
|
|
6092
|
+
/** True when no channel delivery started this turn and egress goes through the session binding. */
|
|
6093
|
+
let boundTurn = false;
|
|
6094
|
+
let channelAckRef;
|
|
6095
|
+
let channelStreamEditor;
|
|
6096
|
+
let droppedStaleInteraction = false;
|
|
6097
|
+
/** True when the connector mapped this delivery to an empty message: no turn runs. */
|
|
6098
|
+
let droppedEmptyMessage = false;
|
|
6099
|
+
let droppedUnknownConnector = false;
|
|
6100
|
+
/** True when this delivery was a stop the connector drops while idle: no turn runs. */
|
|
6101
|
+
let droppedIdleControl = false;
|
|
6102
|
+
/** This turn's native reply stream (`delivery: "stream"` with a `stream()` hook). */
|
|
6103
|
+
let channelReplyStream;
|
|
6104
|
+
/** True once this turn reported `working`, so its end reports the outcome. */
|
|
6105
|
+
let channelLifecycleStarted = false;
|
|
6106
|
+
/** When this turn started, to tell a stop meant for an earlier turn from one for this turn. */
|
|
6107
|
+
const channelTurnStartedAt = Date.now();
|
|
6108
|
+
/** True when this turn is a channel or bound turn that runs (not a dropped delivery). */
|
|
6109
|
+
let channelTurnRan = false;
|
|
6110
|
+
/** True when this turn resolves a pending tool call from a channel interaction callback. */
|
|
6111
|
+
let channelInteractionResolved = false;
|
|
6112
|
+
let channelFinalAnswerPosted = false;
|
|
6113
|
+
let channelFinalSendFailed = false;
|
|
6114
|
+
/** The stream-level error (an `error` chunk) this turn ended on, if any; posted to the channel in place of an empty reply. */
|
|
6115
|
+
let channelStreamErrorText;
|
|
6116
|
+
/** The text this turn streamed, so the channel reply is this turn's answer and not the whole message. */
|
|
6117
|
+
let channelTurnText;
|
|
4967
6118
|
try {
|
|
4968
6119
|
// Extract turn-level context before entering the span. Slim
|
|
4969
6120
|
// wire: at most one delta message per record. `headStartMessages`
|
|
4970
6121
|
// is consumed at boot only (via `payload.headStartMessages`)
|
|
4971
6122
|
// and intentionally discarded here.
|
|
4972
|
-
const
|
|
6123
|
+
const clickedAction = channelClickAction(channels, currentWirePayload.channelEvent);
|
|
6124
|
+
let actionChannel;
|
|
6125
|
+
if (clickedAction !== undefined) {
|
|
6126
|
+
const { channelEvent: clicked, ...withoutChannelEvent } = currentWirePayload;
|
|
6127
|
+
const click = clicked;
|
|
6128
|
+
actionChannel = {
|
|
6129
|
+
connectorId: click.connectorId,
|
|
6130
|
+
provider: channels?.find((c) => c.id === click.connectorId)?.source ?? "custom",
|
|
6131
|
+
event: click.event,
|
|
6132
|
+
deliveryId: click.deliveryId,
|
|
6133
|
+
};
|
|
6134
|
+
currentWirePayload = {
|
|
6135
|
+
...withoutChannelEvent,
|
|
6136
|
+
trigger: "action",
|
|
6137
|
+
action: clickedAction,
|
|
6138
|
+
actionSource: "channel",
|
|
6139
|
+
};
|
|
6140
|
+
}
|
|
6141
|
+
const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, channelEvent: wireChannelEvent, ...restWire } = currentWirePayload;
|
|
4973
6142
|
void _hsm;
|
|
4974
|
-
|
|
4975
|
-
|
|
6143
|
+
channelWireEvent = wireChannelEvent;
|
|
6144
|
+
let effectiveIncomingMessage = incomingMessage;
|
|
6145
|
+
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
6146
|
+
turnClientData = clientData;
|
|
6147
|
+
turnIsActionTurn = false;
|
|
6148
|
+
currentTurnSource = wireChannelEvent
|
|
6149
|
+
? "channel"
|
|
6150
|
+
: currentWirePayload.actionSource === "webhook"
|
|
6151
|
+
? "webhook"
|
|
6152
|
+
: "client";
|
|
6153
|
+
currentTurnConnectorId = undefined;
|
|
6154
|
+
if (wireChannelEvent) {
|
|
6155
|
+
channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
|
|
6156
|
+
if (channelConn) {
|
|
6157
|
+
currentTurnConnectorId = channelConn.id;
|
|
6158
|
+
let idleControl = null;
|
|
6159
|
+
if (channelConn.onControl) {
|
|
6160
|
+
try {
|
|
6161
|
+
idleControl = channelConn.onControl(wireChannelEvent.event);
|
|
6162
|
+
}
|
|
6163
|
+
catch (controlError) {
|
|
6164
|
+
v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6165
|
+
error: controlError,
|
|
6166
|
+
});
|
|
6167
|
+
}
|
|
6168
|
+
}
|
|
6169
|
+
const stopForEndedTurn = idleControl?.at !== undefined &&
|
|
6170
|
+
lastChannelTurnEndedAt !== undefined &&
|
|
6171
|
+
idleControl.at < lastChannelTurnEndedAt;
|
|
6172
|
+
droppedIdleControl =
|
|
6173
|
+
idleControl?.kind === "stop" &&
|
|
6174
|
+
(idleControl.whenIdle === "drop" || stopForEndedTurn);
|
|
6175
|
+
// Bind the session to this connector so a later turn that no channel delivery
|
|
6176
|
+
// started (an action turn, a browser message) can still answer into the thread.
|
|
6177
|
+
if (channelConn.bind && !droppedIdleControl) {
|
|
6178
|
+
try {
|
|
6179
|
+
const handle = channelConn.bind(wireChannelEvent.event);
|
|
6180
|
+
if (handle !== null && handle !== undefined) {
|
|
6181
|
+
const controls = sessionChannelBinding?.connectorId === channelConn.id
|
|
6182
|
+
? sessionChannelBinding.controls
|
|
6183
|
+
: undefined;
|
|
6184
|
+
sessionChannelBinding = {
|
|
6185
|
+
connectorId: channelConn.id,
|
|
6186
|
+
handle,
|
|
6187
|
+
boundAt: new Date().toISOString(),
|
|
6188
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6189
|
+
...(controls ? { controls } : {}),
|
|
6190
|
+
};
|
|
6191
|
+
}
|
|
6192
|
+
}
|
|
6193
|
+
catch (bindError) {
|
|
6194
|
+
v3_1.logger.warn("chat.agent: channel bind() failed; session binding unchanged", {
|
|
6195
|
+
error: bindError,
|
|
6196
|
+
});
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
channelHandle =
|
|
6200
|
+
sessionChannelBinding?.connectorId === channelConn.id
|
|
6201
|
+
? sessionChannelBinding.handle
|
|
6202
|
+
: undefined;
|
|
6203
|
+
const rawInteraction = droppedIdleControl
|
|
6204
|
+
? null
|
|
6205
|
+
: (channelConn.onInteraction?.(wireChannelEvent.event) ?? null);
|
|
6206
|
+
let interaction = rawInteraction
|
|
6207
|
+
? (resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null)
|
|
6208
|
+
: null;
|
|
6209
|
+
const accumulatorHasInteractionToolCall = interaction != null &&
|
|
6210
|
+
accumulatedUIMessages.some((m) => (m.parts ?? []).some((p) => p?.toolCallId === interaction.toolCallId));
|
|
6211
|
+
if (rawInteraction && loadContextHook && !accumulatorHasInteractionToolCall) {
|
|
6212
|
+
try {
|
|
6213
|
+
const hydratedForInteraction = (await loadContextHook({
|
|
6214
|
+
chatId: currentWirePayload.chatId,
|
|
6215
|
+
turn,
|
|
6216
|
+
trigger: "submit-message",
|
|
6217
|
+
incomingMessages: [],
|
|
6218
|
+
previousMessages: [...accumulatedUIMessages],
|
|
6219
|
+
clientData,
|
|
6220
|
+
continuation,
|
|
6221
|
+
previousRunId,
|
|
6222
|
+
}));
|
|
6223
|
+
accumulatedUIMessages = hydratedForInteraction;
|
|
6224
|
+
accumulatedMessages = await toModelMessages(hydratedForInteraction);
|
|
6225
|
+
laneCompacted = false;
|
|
6226
|
+
laneInjections = [];
|
|
6227
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6228
|
+
if (!interaction) {
|
|
6229
|
+
interaction =
|
|
6230
|
+
resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null;
|
|
6231
|
+
}
|
|
6232
|
+
}
|
|
6233
|
+
catch (hydrateError) {
|
|
6234
|
+
v3_1.logger.warn("chat.agent: hydrateMessages for channel interaction resolution failed", { error: hydrateError });
|
|
6235
|
+
}
|
|
6236
|
+
}
|
|
6237
|
+
const resolutionMessage = interaction && !channelStoppedToolCallIds.has(interaction.toolCallId)
|
|
6238
|
+
? buildInteractionResolutionMessage(interaction, accumulatedUIMessages, {
|
|
6239
|
+
source: "channel",
|
|
6240
|
+
connectorId: channelConn.id,
|
|
6241
|
+
provider: channelConn.source,
|
|
6242
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6243
|
+
})
|
|
6244
|
+
: undefined;
|
|
6245
|
+
const channelInbound = droppedIdleControl || resolutionMessage || rawInteraction
|
|
6246
|
+
? undefined
|
|
6247
|
+
: channelConn.inbound(wireChannelEvent.event);
|
|
6248
|
+
if (droppedIdleControl) {
|
|
6249
|
+
const staleStop = idleControl?.at !== undefined &&
|
|
6250
|
+
lastChannelTurnStartedAt !== undefined &&
|
|
6251
|
+
idleControl.at < lastChannelTurnStartedAt;
|
|
6252
|
+
if (!staleStop && loadContextHook && accumulatedUIMessages.length === 0) {
|
|
6253
|
+
try {
|
|
6254
|
+
const hydratedForStop = (await loadContextHook({
|
|
6255
|
+
chatId: currentWirePayload.chatId,
|
|
6256
|
+
turn,
|
|
6257
|
+
trigger: "submit-message",
|
|
6258
|
+
incomingMessages: [],
|
|
6259
|
+
previousMessages: [],
|
|
6260
|
+
clientData,
|
|
6261
|
+
continuation,
|
|
6262
|
+
previousRunId,
|
|
6263
|
+
}));
|
|
6264
|
+
accumulatedUIMessages = hydratedForStop;
|
|
6265
|
+
accumulatedMessages = await toModelMessages(hydratedForStop);
|
|
6266
|
+
laneCompacted = false;
|
|
6267
|
+
laneInjections = [];
|
|
6268
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6269
|
+
}
|
|
6270
|
+
catch (hydrateError) {
|
|
6271
|
+
v3_1.logger.warn("chat.agent: hydrateMessages for a channel stop failed", {
|
|
6272
|
+
error: hydrateError,
|
|
6273
|
+
});
|
|
6274
|
+
}
|
|
6275
|
+
}
|
|
6276
|
+
let lastAssistantIndex = -1;
|
|
6277
|
+
for (let i = accumulatedUIMessages.length - 1; i >= 0; i--) {
|
|
6278
|
+
if (accumulatedUIMessages[i].role === "assistant") {
|
|
6279
|
+
lastAssistantIndex = i;
|
|
6280
|
+
break;
|
|
6281
|
+
}
|
|
6282
|
+
}
|
|
6283
|
+
const lastAssistant = lastAssistantIndex === -1
|
|
6284
|
+
? undefined
|
|
6285
|
+
: accumulatedUIMessages[lastAssistantIndex];
|
|
6286
|
+
const stoppedCalls = lastAssistant && !staleStop && idleControl?.whenIdle === "drop"
|
|
6287
|
+
? pendingToolCallsInMessage(lastAssistant)
|
|
6288
|
+
: [];
|
|
6289
|
+
if (lastAssistant && stoppedCalls.length > 0) {
|
|
6290
|
+
const errorText = idleControl?.message || "stopped";
|
|
6291
|
+
const stoppedMessage = stopPendingToolCalls(lastAssistant, new Set(stoppedCalls.map((call) => call.toolCallId)), errorText);
|
|
6292
|
+
if (stoppedMessage) {
|
|
6293
|
+
accumulatedUIMessages = accumulatedUIMessages.map((m, i) => i === lastAssistantIndex ? stoppedMessage : m);
|
|
6294
|
+
const pausedRun = await toModelMessages([
|
|
6295
|
+
stripProviderMetadata(lastAssistant),
|
|
6296
|
+
]);
|
|
6297
|
+
if (pausedRun.length === 0) {
|
|
6298
|
+
accumulatedMessages = [
|
|
6299
|
+
...accumulatedMessages,
|
|
6300
|
+
...(await toModelMessages([stoppedMessage])),
|
|
6301
|
+
];
|
|
6302
|
+
}
|
|
6303
|
+
else if (!(await replaceModelRun(accumulatedMessages, lastAssistant, stoppedMessage, 0, pausedRun))) {
|
|
6304
|
+
v3_1.logger.warn("chat.agent: paused message not found at the model lane tail; reconverting the lane");
|
|
6305
|
+
accumulatedMessages = await toModelMessages(accumulatedUIMessages, accumulatedUIMessages);
|
|
6306
|
+
laneCompacted = false;
|
|
6307
|
+
laneInjections = [];
|
|
6308
|
+
}
|
|
6309
|
+
for (const call of stoppedCalls)
|
|
6310
|
+
channelStoppedToolCallIds.add(call.toolCallId);
|
|
6311
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6312
|
+
}
|
|
6313
|
+
const refs = sessionChannelBinding?.connectorId === channelConn.id
|
|
6314
|
+
? sessionChannelBinding.controls
|
|
6315
|
+
: undefined;
|
|
6316
|
+
const cancelled = stoppedCalls.map((call) => refs?.[call.toolCallId] ? { ...call, ref: refs[call.toolCallId] } : call);
|
|
6317
|
+
for (const call of stoppedCalls) {
|
|
6318
|
+
channelRenderedToolCallIds.delete(call.toolCallId);
|
|
6319
|
+
}
|
|
6320
|
+
if (refs && sessionChannelBinding) {
|
|
6321
|
+
const remaining = Object.fromEntries(Object.entries(refs).filter(([id]) => !stoppedCalls.some((call) => call.toolCallId === id)));
|
|
6322
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6323
|
+
void _controls;
|
|
6324
|
+
sessionChannelBinding =
|
|
6325
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6326
|
+
}
|
|
6327
|
+
await writeSnapshotOutsideTurn("channel-stop", { turn, clientData });
|
|
6328
|
+
if (channelConn.cancelInteraction) {
|
|
6329
|
+
try {
|
|
6330
|
+
await channelConn.cancelInteraction(cancelled, channelTurnCtx(wireChannelEvent, channelHandle));
|
|
6331
|
+
}
|
|
6332
|
+
catch (cancelError) {
|
|
6333
|
+
v3_1.logger.warn("chat.agent: channel cancelInteraction failed; continuing", {
|
|
6334
|
+
error: cancelError,
|
|
6335
|
+
});
|
|
6336
|
+
}
|
|
6337
|
+
}
|
|
6338
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
6339
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6340
|
+
firstTurn: false,
|
|
6341
|
+
});
|
|
6342
|
+
}
|
|
6343
|
+
}
|
|
6344
|
+
else if (resolutionMessage) {
|
|
6345
|
+
effectiveIncomingMessage = resolutionMessage;
|
|
6346
|
+
channelInteractionResolved = true;
|
|
6347
|
+
const resolvedCallId = interaction?.toolCallId;
|
|
6348
|
+
const controlRefs = sessionChannelBinding?.controls;
|
|
6349
|
+
if (resolvedCallId && controlRefs?.[resolvedCallId] && sessionChannelBinding) {
|
|
6350
|
+
const { [resolvedCallId]: _resolved, ...remaining } = controlRefs;
|
|
6351
|
+
void _resolved;
|
|
6352
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6353
|
+
void _controls;
|
|
6354
|
+
sessionChannelBinding =
|
|
6355
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6356
|
+
}
|
|
6357
|
+
if (interaction && channelConn.finalizeInteraction) {
|
|
6358
|
+
try {
|
|
6359
|
+
await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
|
|
6360
|
+
}
|
|
6361
|
+
catch (finalizeError) {
|
|
6362
|
+
v3_1.logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
|
|
6363
|
+
error: finalizeError,
|
|
6364
|
+
});
|
|
6365
|
+
}
|
|
6366
|
+
}
|
|
6367
|
+
}
|
|
6368
|
+
else if (rawInteraction) {
|
|
6369
|
+
droppedStaleInteraction = true;
|
|
6370
|
+
v3_1.logger.debug("chat.agent: dropping stale channel interaction that matched no pending tool call", {
|
|
6371
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6372
|
+
});
|
|
6373
|
+
}
|
|
6374
|
+
else if (channelInbound === undefined || isEmptyChannelInbound(channelInbound)) {
|
|
6375
|
+
droppedEmptyMessage = true;
|
|
6376
|
+
v3_1.logger.debug("chat.agent: dropping a channel delivery with an empty message", {
|
|
6377
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6378
|
+
});
|
|
6379
|
+
}
|
|
6380
|
+
else {
|
|
6381
|
+
// A session that starts on a conversation with history: let the connector supply
|
|
6382
|
+
// it, so the agent answers with the context a reader of the thread has.
|
|
6383
|
+
if (channelConn.seed && accumulatedUIMessages.length === 0) {
|
|
6384
|
+
try {
|
|
6385
|
+
const seeded = channelSeedMessages(await channelConn.seed(wireChannelEvent.event, {
|
|
6386
|
+
event: wireChannelEvent.event,
|
|
6387
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6388
|
+
}), {
|
|
6389
|
+
source: "channel",
|
|
6390
|
+
connectorId: channelConn.id,
|
|
6391
|
+
provider: channelConn.source,
|
|
6392
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6393
|
+
});
|
|
6394
|
+
if (seeded.length > 0) {
|
|
6395
|
+
accumulatedUIMessages = seeded;
|
|
6396
|
+
accumulatedMessages = await toModelMessages(seeded);
|
|
6397
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6398
|
+
}
|
|
6399
|
+
}
|
|
6400
|
+
catch (seedError) {
|
|
6401
|
+
v3_1.logger.warn("chat.agent: channel seed() failed; starting the session empty", {
|
|
6402
|
+
error: seedError,
|
|
6403
|
+
});
|
|
6404
|
+
}
|
|
6405
|
+
}
|
|
6406
|
+
effectiveIncomingMessage = channelUserMessage(channelInbound, currentWirePayload.messageId ?? wireChannelEvent.deliveryId, {
|
|
6407
|
+
source: "channel",
|
|
6408
|
+
connectorId: channelConn.id,
|
|
6409
|
+
provider: channelConn.source,
|
|
6410
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6411
|
+
...(wireChannelEvent.externalDeliveryId
|
|
6412
|
+
? { externalDeliveryId: wireChannelEvent.externalDeliveryId }
|
|
6413
|
+
: {}),
|
|
6414
|
+
});
|
|
6415
|
+
if (channelConn.send && channelConn.ack) {
|
|
6416
|
+
const recoveryPending = locals_js_1.locals.get(chatChannelRecoveryPendingKey);
|
|
6417
|
+
const recovered = recoveryPending?.value === true;
|
|
6418
|
+
if (recovered)
|
|
6419
|
+
recoveryPending.value = false;
|
|
6420
|
+
const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
|
|
6421
|
+
if (ackMessage) {
|
|
6422
|
+
try {
|
|
6423
|
+
const ackResult = await channelConn.send(ackMessage, {
|
|
6424
|
+
event: wireChannelEvent.event,
|
|
6425
|
+
handle: channelHandle,
|
|
6426
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6427
|
+
mode: channelConn.delivery,
|
|
6428
|
+
final: false,
|
|
6429
|
+
bound: false,
|
|
6430
|
+
});
|
|
6431
|
+
channelAckRef = ackResult?.ref;
|
|
6432
|
+
}
|
|
6433
|
+
catch (ackError) {
|
|
6434
|
+
v3_1.logger.warn("chat.agent: channel ack post failed; continuing", {
|
|
6435
|
+
error: ackError,
|
|
6436
|
+
});
|
|
6437
|
+
}
|
|
6438
|
+
}
|
|
6439
|
+
}
|
|
6440
|
+
try {
|
|
6441
|
+
channelWorkingReaction = await resolveReactionChoice(channelConn.reactions?.working, wireChannelEvent.event);
|
|
6442
|
+
if (channelWorkingReaction) {
|
|
6443
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
6444
|
+
name: channelWorkingReaction,
|
|
6445
|
+
});
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
catch (reactionError) {
|
|
6449
|
+
v3_1.logger.warn("chat.agent: channel working reaction failed", {
|
|
6450
|
+
error: reactionError,
|
|
6451
|
+
});
|
|
6452
|
+
}
|
|
6453
|
+
}
|
|
6454
|
+
}
|
|
6455
|
+
else {
|
|
6456
|
+
droppedUnknownConnector = true;
|
|
6457
|
+
v3_1.logger.warn("chat.agent: no channel connector matches this delivery; skipping turn", {
|
|
6458
|
+
connectorId: wireChannelEvent.connectorId,
|
|
6459
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6460
|
+
});
|
|
6461
|
+
}
|
|
6462
|
+
}
|
|
6463
|
+
if (!wireChannelEvent &&
|
|
6464
|
+
sessionChannelBinding &&
|
|
6465
|
+
currentWirePayload.trigger !== "close" &&
|
|
6466
|
+
currentWirePayload.trigger !== "preload") {
|
|
6467
|
+
// No channel delivery started this turn, but the session is bound to a channel: answer
|
|
6468
|
+
// into it. Reactions need the triggering message, so a bound turn adds none; its reply,
|
|
6469
|
+
// any error and any approval controls go to the bound thread.
|
|
6470
|
+
const bound = channels?.find((c) => c.id === sessionChannelBinding.connectorId);
|
|
6471
|
+
if (bound?.send) {
|
|
6472
|
+
channelConn = bound;
|
|
6473
|
+
channelHandle = sessionChannelBinding.handle;
|
|
6474
|
+
boundTurn = true;
|
|
6475
|
+
currentTurnConnectorId = bound.id;
|
|
6476
|
+
}
|
|
6477
|
+
}
|
|
6478
|
+
const incomingMessages = effectiveIncomingMessage
|
|
6479
|
+
? [effectiveIncomingMessage]
|
|
4976
6480
|
: [];
|
|
4977
6481
|
// Cleaning happens once here so `extractLastUserMessageText` and
|
|
4978
6482
|
// every downstream consumer see the same message shape — and
|
|
4979
6483
|
// `cleanupAbortedParts` no longer has to be re-applied below.
|
|
4980
6484
|
const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
|
|
4981
|
-
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
4982
|
-
turnClientData = clientData;
|
|
4983
6485
|
const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
|
|
6486
|
+
channelTurnRan =
|
|
6487
|
+
!!channelConn &&
|
|
6488
|
+
!!(wireChannelEvent || boundTurn) &&
|
|
6489
|
+
currentWirePayload.trigger !== "action" &&
|
|
6490
|
+
!droppedIdleControl &&
|
|
6491
|
+
!droppedStaleInteraction &&
|
|
6492
|
+
!droppedEmptyMessage;
|
|
6493
|
+
const startChannelLifecycle = async () => {
|
|
6494
|
+
lastChannelTurnStartedAt = channelTurnStartedAt;
|
|
6495
|
+
if (!channelConn?.lifecycle)
|
|
6496
|
+
return;
|
|
6497
|
+
channelLifecycleStarted = true;
|
|
6498
|
+
if (sessionChannelBinding?.connectorId === channelConn.id) {
|
|
6499
|
+
sessionChannelBinding = { ...sessionChannelBinding, working: true };
|
|
6500
|
+
}
|
|
6501
|
+
await channelLifecycle.report(channelConn, "working", {
|
|
6502
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6503
|
+
firstTurn: accumulatedUIMessages.every((m) => m.metadata?.seeded === true),
|
|
6504
|
+
...(lastUserMessage ? { prompt: lastUserMessage } : {}),
|
|
6505
|
+
});
|
|
6506
|
+
};
|
|
6507
|
+
if (channelTurnRan)
|
|
6508
|
+
await startChannelLifecycle();
|
|
4984
6509
|
// Actions are not turns. They use a different span name
|
|
4985
6510
|
// and don't carry a turn.number. Branched on at `isAction`.
|
|
4986
6511
|
const isAction = currentWirePayload.trigger === "action";
|
|
@@ -5036,6 +6561,10 @@ function chatAgent(options) {
|
|
|
5036
6561
|
// (incl. parsed clientData) exists, so every toModelMessages
|
|
5037
6562
|
// call this turn can re-apply tool `toModelOutput`.
|
|
5038
6563
|
await resolveTurnTools();
|
|
6564
|
+
// The channel surface for this turn, handed to run() and reachable from a tool's
|
|
6565
|
+
// execute through chat.surface(), so a connector can ship tools the model drives.
|
|
6566
|
+
const channelSurface = buildChannelRunSurface(channelConn, wireChannelEvent, channelHandle);
|
|
6567
|
+
locals_js_1.locals.set(chatChannelSurfaceKey, channelSurface);
|
|
5039
6568
|
// Per-turn stop controller (reset each turn)
|
|
5040
6569
|
const stopController = new AbortController();
|
|
5041
6570
|
currentStopController = stopController;
|
|
@@ -5057,12 +6586,19 @@ function chatAgent(options) {
|
|
|
5057
6586
|
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
|
|
5058
6587
|
const msg = record.data
|
|
5059
6588
|
.payload;
|
|
6589
|
+
const wireChannel = msg
|
|
6590
|
+
.channelEvent;
|
|
6591
|
+
const steeredChannel = wireChannel != null
|
|
6592
|
+
? channelSteeringMessage(channels, wireChannel, msg.messageId)
|
|
6593
|
+
: undefined;
|
|
6594
|
+
if (wireChannel != null && !steeredChannel)
|
|
6595
|
+
return;
|
|
5060
6596
|
// Slim wire: at most one delta message per record. The
|
|
5061
6597
|
// pendingMessages handler reads `msg.message` directly
|
|
5062
6598
|
// instead of slicing an array — a wire record arrives
|
|
5063
6599
|
// with the new user message in `.message`, or no message
|
|
5064
6600
|
// at all (regenerate / preload / close / handover-prepare).
|
|
5065
|
-
const lastUIMessage = msg.message;
|
|
6601
|
+
const lastUIMessage = (steeredChannel?.message ?? msg.message);
|
|
5066
6602
|
if (lastUIMessage) {
|
|
5067
6603
|
if (pmConfig.onReceived) {
|
|
5068
6604
|
try {
|
|
@@ -5083,7 +6619,10 @@ function chatAgent(options) {
|
|
|
5083
6619
|
queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
|
|
5084
6620
|
return;
|
|
5085
6621
|
}
|
|
5086
|
-
const
|
|
6622
|
+
const converted = await toModelMessages([lastUIMessage]);
|
|
6623
|
+
const modelMsgs = steeredChannel && channelDownloader
|
|
6624
|
+
? await resolveChannelFiles(converted, channelDownloader)
|
|
6625
|
+
: converted;
|
|
5087
6626
|
queue.push({
|
|
5088
6627
|
uiMessage: lastUIMessage,
|
|
5089
6628
|
modelMessages: modelMsgs,
|
|
@@ -5098,6 +6637,52 @@ function chatAgent(options) {
|
|
|
5098
6637
|
})
|
|
5099
6638
|
: undefined;
|
|
5100
6639
|
turnMsgSub = msgSub;
|
|
6640
|
+
/**
|
|
6641
|
+
* Channel deliveries while this turn runs. A delivery the connector maps to `stop`
|
|
6642
|
+
* aborts this turn through the same controller the stop input uses, and is taken
|
|
6643
|
+
* off the route so it does not also run as a turn. Any other message is
|
|
6644
|
+
* acknowledged through the connector's `received` as soon as it arrives, and left
|
|
6645
|
+
* queued for steering or the next turn.
|
|
6646
|
+
*/
|
|
6647
|
+
const controlSub = channels?.some((c) => c.onControl || c.received)
|
|
6648
|
+
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
|
|
6649
|
+
const msg = record.data
|
|
6650
|
+
.payload;
|
|
6651
|
+
const ce = msg.channelEvent;
|
|
6652
|
+
if (!ce)
|
|
6653
|
+
return;
|
|
6654
|
+
const conn = channels?.find((c) => c.id === ce.connectorId);
|
|
6655
|
+
if (!conn)
|
|
6656
|
+
return;
|
|
6657
|
+
let control = null;
|
|
6658
|
+
try {
|
|
6659
|
+
control = conn.onControl?.(ce.event) ?? null;
|
|
6660
|
+
}
|
|
6661
|
+
catch (controlError) {
|
|
6662
|
+
v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6663
|
+
error: controlError,
|
|
6664
|
+
});
|
|
6665
|
+
}
|
|
6666
|
+
if (!control) {
|
|
6667
|
+
acknowledgeChannelMessage(conn, ce, sessionChannelBinding?.connectorId === conn.id
|
|
6668
|
+
? sessionChannelBinding.handle
|
|
6669
|
+
: undefined);
|
|
6670
|
+
return;
|
|
6671
|
+
}
|
|
6672
|
+
if (control.kind !== "stop")
|
|
6673
|
+
return;
|
|
6674
|
+
const earlierTurnStop = control.at !== undefined && control.at < channelTurnStartedAt;
|
|
6675
|
+
if (!earlierTurnStop && !stopController.signal.aborted) {
|
|
6676
|
+
stopController.abort(new DOMException(control.message || "stopped", "AbortError"));
|
|
6677
|
+
}
|
|
6678
|
+
// Observers are offered a record before it is queued, so the take has to
|
|
6679
|
+
// wait a tick or it finds nothing and the stop runs again as a turn.
|
|
6680
|
+
void Promise.resolve().then(() => {
|
|
6681
|
+
chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
|
|
6682
|
+
});
|
|
6683
|
+
})
|
|
6684
|
+
: undefined;
|
|
6685
|
+
turnControlSub = controlSub;
|
|
5101
6686
|
// Track new messages for this turn (user input + assistant response).
|
|
5102
6687
|
const turnNewModelMessages = [];
|
|
5103
6688
|
const turnNewUIMessages = [];
|
|
@@ -5127,7 +6712,8 @@ function chatAgent(options) {
|
|
|
5127
6712
|
let actionChangedHistory = false;
|
|
5128
6713
|
if (isAction) {
|
|
5129
6714
|
// Parse and validate the action payload
|
|
5130
|
-
const
|
|
6715
|
+
const isWebhookAction = currentWirePayload.actionSource === "webhook";
|
|
6716
|
+
const parsedAction = parseAction && !isWebhookAction
|
|
5131
6717
|
? await parseAction(currentWirePayload.action)
|
|
5132
6718
|
: currentWirePayload.action;
|
|
5133
6719
|
// Hydrate messages from backend if configured
|
|
@@ -5168,6 +6754,7 @@ function chatAgent(options) {
|
|
|
5168
6754
|
clientData,
|
|
5169
6755
|
uiMessages: accumulatedUIMessages,
|
|
5170
6756
|
messages: accumulatedMessages,
|
|
6757
|
+
...(actionChannel ? { channel: actionChannel } : {}),
|
|
5171
6758
|
});
|
|
5172
6759
|
}, {
|
|
5173
6760
|
attributes: {
|
|
@@ -5202,7 +6789,11 @@ function chatAgent(options) {
|
|
|
5202
6789
|
// snapshot + `session.out` replay (or `hydrateMessages`,
|
|
5203
6790
|
// which also fires per-turn below). Per-turn handling is
|
|
5204
6791
|
// therefore a delta merge, not a full-history reset.
|
|
5205
|
-
if (currentWirePayload.trigger !== "action"
|
|
6792
|
+
if (currentWirePayload.trigger !== "action" &&
|
|
6793
|
+
!droppedStaleInteraction &&
|
|
6794
|
+
!droppedEmptyMessage &&
|
|
6795
|
+
!droppedUnknownConnector &&
|
|
6796
|
+
!droppedIdleControl) {
|
|
5206
6797
|
let cleanedUIMessages = cleanedIncomingMessages;
|
|
5207
6798
|
// Turn-0 head-start with hydrateMessages: the boot seeding from
|
|
5208
6799
|
// `payload.headStartMessages` is non-hydrate-only, so ship the
|
|
@@ -5454,17 +7045,41 @@ function chatAgent(options) {
|
|
|
5454
7045
|
// calling the model would prefill its own last reply. Keyed on
|
|
5455
7046
|
// the model tail, so a `tool`-terminated chain (a merged tool
|
|
5456
7047
|
// approval) still runs.
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
7048
|
+
// A channel delivery the run decided not to turn into a turn: a stale
|
|
7049
|
+
// interaction callback (no pending tool call matched) or an event for
|
|
7050
|
+
// a connector this agent does not list. Nothing to answer either way.
|
|
7051
|
+
const isDroppedChannelDelivery = droppedStaleInteraction ||
|
|
7052
|
+
droppedEmptyMessage ||
|
|
7053
|
+
droppedUnknownConnector ||
|
|
7054
|
+
droppedIdleControl;
|
|
7055
|
+
// A resolved interaction is never a no-op: the assistant may already have text after
|
|
7056
|
+
// the pending call, so the lane ends on an assistant message, but the model has not
|
|
7057
|
+
// seen the decision yet.
|
|
7058
|
+
const isNoOpTurn = isDroppedChannelDelivery ||
|
|
7059
|
+
(!isAction &&
|
|
7060
|
+
!splicedHandoverPartial &&
|
|
7061
|
+
!channelInteractionResolved &&
|
|
7062
|
+
currentWirePayload.trigger === "submit-message" &&
|
|
7063
|
+
turnNewUIMessages.length === 0 &&
|
|
7064
|
+
accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant");
|
|
5462
7065
|
if (isNoOpTurn) {
|
|
5463
7066
|
msgSub?.off();
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
7067
|
+
turnControlSub?.off();
|
|
7068
|
+
if (channelTurnRan)
|
|
7069
|
+
lastChannelTurnEndedAt = Date.now();
|
|
7070
|
+
clearChannelWorking();
|
|
7071
|
+
if (channelLifecycleStarted) {
|
|
7072
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
7073
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7074
|
+
firstTurn: false,
|
|
7075
|
+
});
|
|
7076
|
+
}
|
|
7077
|
+
if (!isDroppedChannelDelivery) {
|
|
7078
|
+
v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
|
|
7079
|
+
chatId: currentWirePayload.chatId,
|
|
7080
|
+
messageId: currentWirePayload.messageId,
|
|
7081
|
+
});
|
|
7082
|
+
}
|
|
5468
7083
|
settleRecoveredTurn(currentWirePayload);
|
|
5469
7084
|
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
5470
7085
|
// Not a turn — don't consume an iteration.
|
|
@@ -5480,6 +7095,24 @@ function chatAgent(options) {
|
|
|
5480
7095
|
// sees the same `turn` value — actions don't count.
|
|
5481
7096
|
if (isAction) {
|
|
5482
7097
|
if (isActionTurn(actionResult)) {
|
|
7098
|
+
if (actionResult.message !== undefined) {
|
|
7099
|
+
const asked = (typeof actionResult.message === "string"
|
|
7100
|
+
? {
|
|
7101
|
+
id: (0, ai_runtime_js_1.generateId)(),
|
|
7102
|
+
role: "user",
|
|
7103
|
+
parts: [{ type: "text", text: actionResult.message }],
|
|
7104
|
+
}
|
|
7105
|
+
: actionResult.message.id
|
|
7106
|
+
? actionResult.message
|
|
7107
|
+
: { ...actionResult.message, id: (0, ai_runtime_js_1.generateId)() });
|
|
7108
|
+
accumulatedUIMessages = [...accumulatedUIMessages, asked];
|
|
7109
|
+
accumulatedMessages = [
|
|
7110
|
+
...accumulatedMessages,
|
|
7111
|
+
...(await toModelMessages([asked])),
|
|
7112
|
+
];
|
|
7113
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
7114
|
+
actionChangedHistory = true;
|
|
7115
|
+
}
|
|
5483
7116
|
// Persist the edit before the turn starts, so a turn that is
|
|
5484
7117
|
// cancelled or runs out of memory continues from the edited
|
|
5485
7118
|
// history rather than from the snapshot the edit replaced.
|
|
@@ -5488,6 +7121,11 @@ function chatAgent(options) {
|
|
|
5488
7121
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5489
7122
|
}
|
|
5490
7123
|
actionTurn = true;
|
|
7124
|
+
turnIsActionTurn = true;
|
|
7125
|
+
if (boundTurn && channelConn) {
|
|
7126
|
+
channelTurnRan = true;
|
|
7127
|
+
await startChannelLifecycle();
|
|
7128
|
+
}
|
|
5491
7129
|
}
|
|
5492
7130
|
else if (actionResult !== undefined) {
|
|
5493
7131
|
throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
|
|
@@ -5496,6 +7134,7 @@ function chatAgent(options) {
|
|
|
5496
7134
|
}
|
|
5497
7135
|
else {
|
|
5498
7136
|
msgSub?.off();
|
|
7137
|
+
turnControlSub?.off();
|
|
5499
7138
|
if (actionChangedHistory) {
|
|
5500
7139
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5501
7140
|
}
|
|
@@ -5697,12 +7336,87 @@ function chatAgent(options) {
|
|
|
5697
7336
|
}
|
|
5698
7337
|
else {
|
|
5699
7338
|
const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
|
|
7339
|
+
const runMessages = channelDownloader
|
|
7340
|
+
? await resolveChannelFiles(preparedMessages, channelDownloader)
|
|
7341
|
+
: preparedMessages;
|
|
7342
|
+
// Channel observers for this turn are built before run(). The progress
|
|
7343
|
+
// observer and the native reply stream are shared with a manual chat.pipe()
|
|
7344
|
+
// inside run() through the turn tap and stay open across several pipes; the
|
|
7345
|
+
// ack stream editor only rides the automatic pipe, because a manual pipe
|
|
7346
|
+
// captures no response and posts no final reply that would supersede a
|
|
7347
|
+
// partial edit. The finally of this turn stops all of them and clears the tap;
|
|
7348
|
+
// the turn boundary ends the native stream.
|
|
7349
|
+
if (channelConn) {
|
|
7350
|
+
const observers = [];
|
|
7351
|
+
const tapObservers = [];
|
|
7352
|
+
if (channelConn.delivery === "stream" &&
|
|
7353
|
+
channelConn.stream &&
|
|
7354
|
+
(wireChannelEvent || channelHandle)) {
|
|
7355
|
+
const streamConnectorId = channelConn.id;
|
|
7356
|
+
const opened = (ref) => {
|
|
7357
|
+
if (sessionChannelBinding?.connectorId !== streamConnectorId)
|
|
7358
|
+
return;
|
|
7359
|
+
sessionChannelBinding = { ...sessionChannelBinding, stream: ref };
|
|
7360
|
+
if (hydrateMessages)
|
|
7361
|
+
return;
|
|
7362
|
+
void saveTranscript({
|
|
7363
|
+
reason: "turn-start",
|
|
7364
|
+
messages: accumulatedUIMessages,
|
|
7365
|
+
turn,
|
|
7366
|
+
trigger: storageTrigger(currentWirePayload.trigger),
|
|
7367
|
+
clientData,
|
|
7368
|
+
lastOutEventId: lastSnapshotOutEventId,
|
|
7369
|
+
skipIfUnchanged: true,
|
|
7370
|
+
carryInCursor: true,
|
|
7371
|
+
}).catch((error) => {
|
|
7372
|
+
v3_1.logger.warn("chat.agent: saving the open reply stream failed", {
|
|
7373
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7374
|
+
});
|
|
7375
|
+
});
|
|
7376
|
+
};
|
|
7377
|
+
try {
|
|
7378
|
+
channelReplyStream =
|
|
7379
|
+
channelConn.stream({
|
|
7380
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7381
|
+
opened,
|
|
7382
|
+
}) ?? undefined;
|
|
7383
|
+
}
|
|
7384
|
+
catch (streamError) {
|
|
7385
|
+
v3_1.logger.warn("chat.agent: channel stream() threw; posting the reply", {
|
|
7386
|
+
error: streamError,
|
|
7387
|
+
});
|
|
7388
|
+
}
|
|
7389
|
+
}
|
|
7390
|
+
if (channelReplyStream) {
|
|
7391
|
+
const replyObserver = makeChannelReplyStreamObserver(channelReplyStream);
|
|
7392
|
+
observers.push(replyObserver);
|
|
7393
|
+
tapObservers.push(replyObserver);
|
|
7394
|
+
}
|
|
7395
|
+
else if (wireChannelEvent &&
|
|
7396
|
+
channelConn.send &&
|
|
7397
|
+
channelConn.delivery === "stream" &&
|
|
7398
|
+
channelAckRef) {
|
|
7399
|
+
observers.push(makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef, channelHandle));
|
|
7400
|
+
}
|
|
7401
|
+
if (channelConn.progress && (wireChannelEvent || channelHandle)) {
|
|
7402
|
+
const progressObserver = makeChannelProgressObserver(channelConn, wireChannelEvent, channelHandle);
|
|
7403
|
+
observers.push(progressObserver);
|
|
7404
|
+
tapObservers.push(progressObserver);
|
|
7405
|
+
}
|
|
7406
|
+
if (tapObservers.length > 0) {
|
|
7407
|
+
const tapObserver = composeChannelObservers(tapObservers);
|
|
7408
|
+
locals_js_1.locals.set(chatTurnChannelTapKey, (stream) => stream.pipeThrough(makeChannelStreamTap(tapObserver, { stopOnEnd: false })));
|
|
7409
|
+
}
|
|
7410
|
+
if (observers.length > 0) {
|
|
7411
|
+
channelStreamEditor = composeChannelObservers(observers);
|
|
7412
|
+
}
|
|
7413
|
+
}
|
|
5700
7414
|
runResult = await userRun({
|
|
5701
7415
|
...restWire,
|
|
5702
7416
|
// A turn requested by chat.turn() is not the action itself:
|
|
5703
7417
|
// a run() that short-circuits on "action" must still answer.
|
|
5704
7418
|
...(actionTurn ? { trigger: "action-turn" } : {}),
|
|
5705
|
-
messages:
|
|
7419
|
+
messages: runMessages,
|
|
5706
7420
|
clientData,
|
|
5707
7421
|
continuation,
|
|
5708
7422
|
previousRunId,
|
|
@@ -5715,6 +7429,7 @@ function chatAgent(options) {
|
|
|
5715
7429
|
cancelSignal,
|
|
5716
7430
|
stopSignal,
|
|
5717
7431
|
streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
|
|
7432
|
+
channel: channelSurface,
|
|
5718
7433
|
});
|
|
5719
7434
|
}
|
|
5720
7435
|
// Auto-pipe if the run function returned a StreamTextResult or similar,
|
|
@@ -5748,7 +7463,13 @@ function chatAgent(options) {
|
|
|
5748
7463
|
resolveOnFinish();
|
|
5749
7464
|
},
|
|
5750
7465
|
});
|
|
5751
|
-
|
|
7466
|
+
// The automatic pipe feeds every observer (progress and the ack stream editor)
|
|
7467
|
+
// itself; the manual tap is cleared so pipeChat does not feed progress twice.
|
|
7468
|
+
locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
|
|
7469
|
+
const streamForPipe = channelStreamEditor && uiStream instanceof ReadableStream
|
|
7470
|
+
? uiStream.pipeThrough(makeChannelStreamTap(channelStreamEditor))
|
|
7471
|
+
: uiStream;
|
|
7472
|
+
await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
|
|
5752
7473
|
signal: combinedSignal,
|
|
5753
7474
|
spanName: "stream response",
|
|
5754
7475
|
}, { originalMessages: isActionTurn ? undefined : accumulatedUIMessages });
|
|
@@ -5771,6 +7492,9 @@ function chatAgent(options) {
|
|
|
5771
7492
|
}
|
|
5772
7493
|
finally {
|
|
5773
7494
|
msgSub?.off();
|
|
7495
|
+
turnControlSub?.off();
|
|
7496
|
+
locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
|
|
7497
|
+
await channelStreamEditor?.stop();
|
|
5774
7498
|
}
|
|
5775
7499
|
// Wait for onFinish to fire — on abort this may resolve slightly
|
|
5776
7500
|
// after pipeChat, since the stream's cancel() handler is async.
|
|
@@ -6014,6 +7738,16 @@ function chatAgent(options) {
|
|
|
6014
7738
|
if (capturedResponseMessage) {
|
|
6015
7739
|
responseCommitted = true;
|
|
6016
7740
|
capturedPartialResponse = capturedResponseMessage;
|
|
7741
|
+
const errorChunk = turnBufferedChunks.find((chunk) => chunk.type === "error");
|
|
7742
|
+
channelStreamErrorText = errorChunk?.errorText;
|
|
7743
|
+
channelTurnText = turnBufferedChunks
|
|
7744
|
+
.map((chunk) => {
|
|
7745
|
+
const c = chunk;
|
|
7746
|
+
return c.type === "text-delta" && typeof c.delta === "string"
|
|
7747
|
+
? c.delta
|
|
7748
|
+
: "";
|
|
7749
|
+
})
|
|
7750
|
+
.join("");
|
|
6017
7751
|
turnBufferedChunks.length = 0;
|
|
6018
7752
|
}
|
|
6019
7753
|
if (runSignal.aborted)
|
|
@@ -6233,8 +7967,186 @@ function chatAgent(options) {
|
|
|
6233
7967
|
}
|
|
6234
7968
|
}
|
|
6235
7969
|
settleRecoveredTurn(currentWirePayload);
|
|
7970
|
+
openChannelEgressLatch(currentWirePayload.chatId);
|
|
6236
7971
|
// Write turn-complete control chunk — closes the frontend stream.
|
|
6237
7972
|
const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
|
|
7973
|
+
// Tool calls that have not had controls posted yet; a resume with the rest still
|
|
7974
|
+
// pending must not repost them or hide the answer text behind them.
|
|
7975
|
+
const pendingToolCalls = (wireChannelEvent || boundTurn) &&
|
|
7976
|
+
channelConn?.send &&
|
|
7977
|
+
turnCompleteEvent.responseMessage
|
|
7978
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).filter((call) => !channelRenderedToolCallIds.has(call.toolCallId))
|
|
7979
|
+
: [];
|
|
7980
|
+
const renderControls = !!((wireChannelEvent || boundTurn) &&
|
|
7981
|
+
pendingToolCalls.length > 0 &&
|
|
7982
|
+
channelConn?.renderInteraction);
|
|
7983
|
+
let channelReplyDelivered = false;
|
|
7984
|
+
if (channelReplyStream) {
|
|
7985
|
+
const replyStream = channelReplyStream;
|
|
7986
|
+
channelReplyStream = undefined;
|
|
7987
|
+
try {
|
|
7988
|
+
const ended = await replyStream.end({
|
|
7989
|
+
stopped: turnCompleteEvent.stopped,
|
|
7990
|
+
pending: renderControls,
|
|
7991
|
+
...(channelStreamErrorText ? { errorText: channelStreamErrorText } : {}),
|
|
7992
|
+
});
|
|
7993
|
+
channelReplyDelivered = ended.delivered;
|
|
7994
|
+
}
|
|
7995
|
+
catch (streamError) {
|
|
7996
|
+
v3_1.logger.warn("chat.agent: channel reply stream end failed", {
|
|
7997
|
+
error: streamError,
|
|
7998
|
+
});
|
|
7999
|
+
}
|
|
8000
|
+
finally {
|
|
8001
|
+
clearOpenReplyStream();
|
|
8002
|
+
}
|
|
8003
|
+
}
|
|
8004
|
+
// Channel egress: map the turn's reply via outbound() and send it, editing the ack
|
|
8005
|
+
// placeholder posted at turn start (previousRef). A failed send is retried; one that
|
|
8006
|
+
// never lands is logged and the turn gets the error reaction. A manual-pipe turn has
|
|
8007
|
+
// no responseMessage, so it opts out.
|
|
8008
|
+
const finalChannelConn = channelConn;
|
|
8009
|
+
if ((wireChannelEvent || boundTurn) &&
|
|
8010
|
+
finalChannelConn?.send &&
|
|
8011
|
+
turnCompleteEvent.responseMessage) {
|
|
8012
|
+
if (renderControls) {
|
|
8013
|
+
for (const call of pendingToolCalls) {
|
|
8014
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
8015
|
+
}
|
|
8016
|
+
}
|
|
8017
|
+
const replyText = channelTurnText && channelTurnText.trim().length > 0
|
|
8018
|
+
? channelTurnText
|
|
8019
|
+
: channelReplyText(turnCompleteEvent.responseMessage);
|
|
8020
|
+
// A model or stream error surfaces as an `error` chunk and an empty reply, not as a
|
|
8021
|
+
// thrown turn. The mapper gets it on this one call, with the error text standing in
|
|
8022
|
+
// for an empty reply, so the placeholder never sits there as if the agent were
|
|
8023
|
+
// still working.
|
|
8024
|
+
const finalReply = {
|
|
8025
|
+
// Prefer what this turn streamed; a resumed message carries earlier turns' text too.
|
|
8026
|
+
text: channelStreamErrorText && replyText.trim().length === 0
|
|
8027
|
+
? channelStreamErrorText
|
|
8028
|
+
: replyText,
|
|
8029
|
+
message: turnCompleteEvent.responseMessage,
|
|
8030
|
+
final: true,
|
|
8031
|
+
stopped: turnCompleteEvent.stopped,
|
|
8032
|
+
...(renderControls ? { pending: true } : {}),
|
|
8033
|
+
...(channelStreamErrorText
|
|
8034
|
+
? { error: new Error(channelStreamErrorText) }
|
|
8035
|
+
: {}),
|
|
8036
|
+
};
|
|
8037
|
+
let outboundMessage = null;
|
|
8038
|
+
if (!channelReplyDelivered) {
|
|
8039
|
+
try {
|
|
8040
|
+
outboundMessage = (finalChannelConn.outbound ?? defaultChannelOutbound)(finalReply);
|
|
8041
|
+
}
|
|
8042
|
+
catch (outboundError) {
|
|
8043
|
+
v3_1.logger.warn("chat.agent: channel outbound failed; posting the plain reply", {
|
|
8044
|
+
error: outboundError,
|
|
8045
|
+
});
|
|
8046
|
+
outboundMessage = defaultChannelOutbound(finalReply) ?? {
|
|
8047
|
+
text: "An unexpected error occurred",
|
|
8048
|
+
};
|
|
8049
|
+
}
|
|
8050
|
+
}
|
|
8051
|
+
const sendFinal = async (message) => {
|
|
8052
|
+
for (let attempt = 1;; attempt++) {
|
|
8053
|
+
try {
|
|
8054
|
+
const sent = await finalChannelConn.send(message, {
|
|
8055
|
+
event: wireChannelEvent?.event,
|
|
8056
|
+
handle: channelHandle,
|
|
8057
|
+
deliveryId: wireChannelEvent?.deliveryId,
|
|
8058
|
+
previousRef: channelAckRef,
|
|
8059
|
+
mode: finalChannelConn.delivery,
|
|
8060
|
+
final: true,
|
|
8061
|
+
bound: boundTurn,
|
|
8062
|
+
});
|
|
8063
|
+
channelFinalAnswerPosted = true;
|
|
8064
|
+
return sent ?? {};
|
|
8065
|
+
}
|
|
8066
|
+
catch (egressError) {
|
|
8067
|
+
if (attempt >= CHANNEL_FINAL_SEND_ATTEMPTS || !channelAckRef) {
|
|
8068
|
+
channelFinalSendFailed = true;
|
|
8069
|
+
v3_1.logger.error("chat.agent: channel egress send failed", {
|
|
8070
|
+
error: egressError,
|
|
8071
|
+
attempts: attempt,
|
|
8072
|
+
});
|
|
8073
|
+
return undefined;
|
|
8074
|
+
}
|
|
8075
|
+
v3_1.logger.warn("chat.agent: channel egress send failed; retrying", {
|
|
8076
|
+
error: egressError,
|
|
8077
|
+
attempt,
|
|
8078
|
+
});
|
|
8079
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
8080
|
+
}
|
|
8081
|
+
}
|
|
8082
|
+
};
|
|
8083
|
+
// A turn that pauses on a human decision often explains itself first (a summary,
|
|
8084
|
+
// the steps so far) and then calls the tool. That text is the answer so far: post it
|
|
8085
|
+
// as the placeholder edit, and post the controls as their own message after it.
|
|
8086
|
+
if (renderControls && outboundMessage && (await sendFinal(outboundMessage))) {
|
|
8087
|
+
channelAckRef = undefined;
|
|
8088
|
+
}
|
|
8089
|
+
const channelMessage = renderControls
|
|
8090
|
+
? finalChannelConn.renderInteraction(pendingToolCalls, wireChannelEvent
|
|
8091
|
+
? {
|
|
8092
|
+
event: wireChannelEvent.event,
|
|
8093
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
8094
|
+
}
|
|
8095
|
+
: {})
|
|
8096
|
+
: outboundMessage;
|
|
8097
|
+
if (channelMessage) {
|
|
8098
|
+
const controlsRef = (await sendFinal(channelMessage))?.ref;
|
|
8099
|
+
if (renderControls &&
|
|
8100
|
+
controlsRef &&
|
|
8101
|
+
sessionChannelBinding?.connectorId === finalChannelConn.id) {
|
|
8102
|
+
sessionChannelBinding = {
|
|
8103
|
+
...sessionChannelBinding,
|
|
8104
|
+
controls: {
|
|
8105
|
+
...sessionChannelBinding.controls,
|
|
8106
|
+
...Object.fromEntries(pendingToolCalls.map((call) => [call.toolCallId, controlsRef])),
|
|
8107
|
+
},
|
|
8108
|
+
};
|
|
8109
|
+
}
|
|
8110
|
+
}
|
|
8111
|
+
}
|
|
8112
|
+
if (channelTurnRan)
|
|
8113
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8114
|
+
clearChannelWorking();
|
|
8115
|
+
if (channelLifecycleStarted) {
|
|
8116
|
+
const stillPending = turnCompleteEvent.responseMessage
|
|
8117
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).length > 0
|
|
8118
|
+
: false;
|
|
8119
|
+
await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey)
|
|
8120
|
+
? "closed"
|
|
8121
|
+
: stillPending
|
|
8122
|
+
? "waiting"
|
|
8123
|
+
: "idle", { ...channelTurnCtx(wireChannelEvent, channelHandle), firstTurn: false });
|
|
8124
|
+
}
|
|
8125
|
+
// Lifecycle reaction: turn done. Remove "working", mark "done".
|
|
8126
|
+
if (wireChannelEvent && channelConn?.react) {
|
|
8127
|
+
try {
|
|
8128
|
+
if (channelWorkingReaction) {
|
|
8129
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8130
|
+
name: channelWorkingReaction,
|
|
8131
|
+
remove: true,
|
|
8132
|
+
});
|
|
8133
|
+
}
|
|
8134
|
+
const outcomeReaction = await resolveReactionChoice(channelStreamErrorText || channelFinalSendFailed
|
|
8135
|
+
? channelConn.reactions?.error
|
|
8136
|
+
: channelConn.reactions?.done, wireChannelEvent.event);
|
|
8137
|
+
if (outcomeReaction) {
|
|
8138
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8139
|
+
name: outcomeReaction,
|
|
8140
|
+
});
|
|
8141
|
+
}
|
|
8142
|
+
}
|
|
8143
|
+
catch (reactionError) {
|
|
8144
|
+
v3_1.logger.warn("chat.agent: channel done reaction failed", {
|
|
8145
|
+
error: reactionError,
|
|
8146
|
+
});
|
|
8147
|
+
}
|
|
8148
|
+
}
|
|
8149
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6238
8150
|
// Fire onTurnComplete — stream is closed, use for persistence.
|
|
6239
8151
|
if (onTurnComplete) {
|
|
6240
8152
|
await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
|
|
@@ -6352,6 +8264,7 @@ function chatAgent(options) {
|
|
|
6352
8264
|
// chat.endRun() — same exit, no upgrade semantics.
|
|
6353
8265
|
if (locals_js_1.locals.get(chatCloseRequestedKey)) {
|
|
6354
8266
|
await performChatClose();
|
|
8267
|
+
await reportChannelClosed();
|
|
6355
8268
|
return "exit";
|
|
6356
8269
|
}
|
|
6357
8270
|
if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
|
|
@@ -6423,6 +8336,7 @@ function chatAgent(options) {
|
|
|
6423
8336
|
currentWirePayload = next.output;
|
|
6424
8337
|
// Close signal — exit the loop gracefully
|
|
6425
8338
|
if (currentWirePayload.trigger === "close") {
|
|
8339
|
+
await reportChannelClosed();
|
|
6426
8340
|
return "exit";
|
|
6427
8341
|
}
|
|
6428
8342
|
return "continue";
|
|
@@ -6440,12 +8354,16 @@ function chatAgent(options) {
|
|
|
6440
8354
|
// Detach the turn's message handler first — left attached it would
|
|
6441
8355
|
// eat the very message the wait below is waiting for.
|
|
6442
8356
|
turnMsgSub?.off();
|
|
8357
|
+
turnControlSub?.off();
|
|
6443
8358
|
if (turnError instanceof Error &&
|
|
6444
8359
|
turnError.name === "AbortError" &&
|
|
6445
8360
|
runSignal.aborted) {
|
|
8361
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
8362
|
+
channelLifecycle.dispose();
|
|
6446
8363
|
// Full run cancellation — exit immediately
|
|
6447
8364
|
throw turnError;
|
|
6448
8365
|
}
|
|
8366
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6449
8367
|
// OOM errors must escape the turn loop so the task runtime can
|
|
6450
8368
|
// honor `retry.outOfMemory.machine` (set on chat.agent via
|
|
6451
8369
|
// `oomMachine`). Catching them here would keep the dead worker
|
|
@@ -6456,6 +8374,109 @@ function chatAgent(options) {
|
|
|
6456
8374
|
if (turnError instanceof v3_1.OutOfMemoryError) {
|
|
6457
8375
|
throw turnError;
|
|
6458
8376
|
}
|
|
8377
|
+
if (channelWireEvent && channelConn?.react) {
|
|
8378
|
+
try {
|
|
8379
|
+
if (channelWorkingReaction) {
|
|
8380
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8381
|
+
name: channelWorkingReaction,
|
|
8382
|
+
remove: true,
|
|
8383
|
+
});
|
|
8384
|
+
}
|
|
8385
|
+
const errorReaction = await resolveReactionChoice(channelConn.reactions?.error, channelWireEvent.event);
|
|
8386
|
+
if (errorReaction) {
|
|
8387
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8388
|
+
name: errorReaction,
|
|
8389
|
+
});
|
|
8390
|
+
}
|
|
8391
|
+
}
|
|
8392
|
+
catch (reactionError) {
|
|
8393
|
+
v3_1.logger.warn("chat.agent: channel error reaction failed", { error: reactionError });
|
|
8394
|
+
}
|
|
8395
|
+
}
|
|
8396
|
+
let channelErrorText = "An unexpected error occurred";
|
|
8397
|
+
if ((channelWireEvent || boundTurn) && channelConn) {
|
|
8398
|
+
const sanitizeChannelError = resolveUIMessageStreamOptions().onError;
|
|
8399
|
+
try {
|
|
8400
|
+
channelErrorText = sanitizeChannelError
|
|
8401
|
+
? sanitizeChannelError(turnError)
|
|
8402
|
+
: turnError instanceof Error
|
|
8403
|
+
? turnError.message
|
|
8404
|
+
: channelErrorText;
|
|
8405
|
+
}
|
|
8406
|
+
catch { }
|
|
8407
|
+
}
|
|
8408
|
+
let errorStreamDelivered = false;
|
|
8409
|
+
if (channelReplyStream) {
|
|
8410
|
+
const replyStream = channelReplyStream;
|
|
8411
|
+
channelReplyStream = undefined;
|
|
8412
|
+
try {
|
|
8413
|
+
const ended = await replyStream.end({
|
|
8414
|
+
stopped: false,
|
|
8415
|
+
pending: false,
|
|
8416
|
+
errorText: channelErrorText,
|
|
8417
|
+
});
|
|
8418
|
+
errorStreamDelivered = ended.delivered;
|
|
8419
|
+
}
|
|
8420
|
+
catch (streamError) {
|
|
8421
|
+
v3_1.logger.warn("chat.agent: channel reply stream end failed", {
|
|
8422
|
+
error: streamError,
|
|
8423
|
+
});
|
|
8424
|
+
}
|
|
8425
|
+
finally {
|
|
8426
|
+
clearOpenReplyStream();
|
|
8427
|
+
}
|
|
8428
|
+
}
|
|
8429
|
+
if ((channelWireEvent || boundTurn) && channelConn?.send) {
|
|
8430
|
+
const errorPreviousRef = channelFinalAnswerPosted ? undefined : channelAckRef;
|
|
8431
|
+
const channelErrorMessageId = errorPreviousRef ?? channelWireEvent?.deliveryId ?? `channel-error-${turn}`;
|
|
8432
|
+
let errorMessage = null;
|
|
8433
|
+
try {
|
|
8434
|
+
errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
|
|
8435
|
+
text: channelErrorText,
|
|
8436
|
+
message: {
|
|
8437
|
+
id: channelErrorMessageId,
|
|
8438
|
+
role: "assistant",
|
|
8439
|
+
parts: [{ type: "text", text: channelErrorText }],
|
|
8440
|
+
},
|
|
8441
|
+
final: true,
|
|
8442
|
+
stopped: false,
|
|
8443
|
+
error: turnError,
|
|
8444
|
+
});
|
|
8445
|
+
}
|
|
8446
|
+
catch (outboundError) {
|
|
8447
|
+
v3_1.logger.warn("chat.agent: channel error outbound failed; posting the plain error", {
|
|
8448
|
+
error: outboundError,
|
|
8449
|
+
});
|
|
8450
|
+
errorMessage = { text: channelErrorText };
|
|
8451
|
+
}
|
|
8452
|
+
if (errorMessage && !errorStreamDelivered) {
|
|
8453
|
+
try {
|
|
8454
|
+
await channelConn.send(errorMessage, {
|
|
8455
|
+
event: channelWireEvent?.event,
|
|
8456
|
+
handle: channelHandle,
|
|
8457
|
+
deliveryId: channelWireEvent?.deliveryId,
|
|
8458
|
+
previousRef: errorPreviousRef,
|
|
8459
|
+
mode: channelConn.delivery,
|
|
8460
|
+
final: true,
|
|
8461
|
+
bound: boundTurn,
|
|
8462
|
+
});
|
|
8463
|
+
}
|
|
8464
|
+
catch (egressError) {
|
|
8465
|
+
v3_1.logger.warn("chat.agent: channel error egress send failed", {
|
|
8466
|
+
error: egressError,
|
|
8467
|
+
});
|
|
8468
|
+
}
|
|
8469
|
+
}
|
|
8470
|
+
}
|
|
8471
|
+
if (channelTurnRan)
|
|
8472
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8473
|
+
clearChannelWorking();
|
|
8474
|
+
if (channelLifecycleStarted) {
|
|
8475
|
+
await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey) ? "closed" : "idle", {
|
|
8476
|
+
...channelTurnCtx(channelWireEvent, channelHandle),
|
|
8477
|
+
firstTurn: false,
|
|
8478
|
+
});
|
|
8479
|
+
}
|
|
6459
8480
|
let errorTurnCompleteResult;
|
|
6460
8481
|
try {
|
|
6461
8482
|
await withChatWriter(async (writer) => {
|
|
@@ -6712,6 +8733,7 @@ function chatAgent(options) {
|
|
|
6712
8733
|
.get(chatManagedResponseKey)
|
|
6713
8734
|
?.close()
|
|
6714
8735
|
.catch(() => { });
|
|
8736
|
+
turnControlSub?.off();
|
|
6715
8737
|
}
|
|
6716
8738
|
}
|
|
6717
8739
|
}
|
|
@@ -6735,6 +8757,45 @@ function chatAgent(options) {
|
|
|
6735
8757
|
schema: clientDataSchema,
|
|
6736
8758
|
});
|
|
6737
8759
|
}
|
|
8760
|
+
// Claim each listed chat.event descriptor: subscribe THIS agent's session to the event's endpoint.
|
|
8761
|
+
// The subscriber id is scoped by agent, so the same descriptor on two agents is two subscribers.
|
|
8762
|
+
if (events) {
|
|
8763
|
+
for (const wh of events) {
|
|
8764
|
+
v3_1.resourceCatalog.markSessionWebhookClaimed(wh.id);
|
|
8765
|
+
v3_1.resourceCatalog.registerWebhookSubscriber({
|
|
8766
|
+
endpointId: wh.endpointId,
|
|
8767
|
+
target: {
|
|
8768
|
+
type: "session",
|
|
8769
|
+
id: `${options.id}:${wh.id}`,
|
|
8770
|
+
taskIdentifier: options.id,
|
|
8771
|
+
keyTemplate: wh.key,
|
|
8772
|
+
actionType: wh.type,
|
|
8773
|
+
deliverAs: "action",
|
|
8774
|
+
...(wh.filter ? { filter: wh.filter } : {}),
|
|
8775
|
+
},
|
|
8776
|
+
});
|
|
8777
|
+
}
|
|
8778
|
+
}
|
|
8779
|
+
// Claim each listed channel connector: the same kind of subscriber, but deliverAs "message" so a
|
|
8780
|
+
// verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
|
|
8781
|
+
if (channels) {
|
|
8782
|
+
for (const ch of channels) {
|
|
8783
|
+
v3_1.resourceCatalog.markSessionWebhookClaimed(ch.id);
|
|
8784
|
+
v3_1.resourceCatalog.registerWebhookSubscriber({
|
|
8785
|
+
endpointId: ch.endpointId,
|
|
8786
|
+
target: {
|
|
8787
|
+
type: "session",
|
|
8788
|
+
id: `${options.id}:${ch.id}`,
|
|
8789
|
+
taskIdentifier: options.id,
|
|
8790
|
+
keyTemplate: ch.key,
|
|
8791
|
+
connectorId: ch.id,
|
|
8792
|
+
deliverAs: "message",
|
|
8793
|
+
...(ch.filter ? { filter: ch.filter } : {}),
|
|
8794
|
+
...(ch.startOn ? { startOn: ch.startOn } : {}),
|
|
8795
|
+
},
|
|
8796
|
+
});
|
|
8797
|
+
}
|
|
8798
|
+
}
|
|
6738
8799
|
return task;
|
|
6739
8800
|
}
|
|
6740
8801
|
function composeHooks(builderHook, taskHook) {
|
|
@@ -7030,6 +9091,22 @@ function resolveUIMessageStreamOptions() {
|
|
|
7030
9091
|
// ---------------------------------------------------------------------------
|
|
7031
9092
|
// Stop detection
|
|
7032
9093
|
// ---------------------------------------------------------------------------
|
|
9094
|
+
/**
|
|
9095
|
+
* The channel surface of the current turn, from anywhere inside a `chat.agent` run, including a
|
|
9096
|
+
* tool's `execute`. Undefined on a browser turn or when the connector defines no reactions and no
|
|
9097
|
+
* `surface()`. Pass the connector's surface type to get its helpers typed:
|
|
9098
|
+
*
|
|
9099
|
+
* @example
|
|
9100
|
+
* ```ts
|
|
9101
|
+
* execute: async ({ steps }) => {
|
|
9102
|
+
* const linear = chat.surface<LinearRunSurface>();
|
|
9103
|
+
* if (linear) await linear.plan(steps);
|
|
9104
|
+
* }
|
|
9105
|
+
* ```
|
|
9106
|
+
*/
|
|
9107
|
+
function getChannelSurface() {
|
|
9108
|
+
return locals_js_1.locals.get(chatChannelSurfaceKey);
|
|
9109
|
+
}
|
|
7033
9110
|
/**
|
|
7034
9111
|
* Check whether the user stopped generation during the current turn.
|
|
7035
9112
|
*
|
|
@@ -9027,6 +11104,10 @@ function createChatLoadTranscriptAction(storage, options) {
|
|
|
9027
11104
|
exports.chat = {
|
|
9028
11105
|
/** Create a chat agent. See {@link chatAgent}. */
|
|
9029
11106
|
agent: chatAgent,
|
|
11107
|
+
/** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
|
|
11108
|
+
event: webhooks_js_1.chatEvent,
|
|
11109
|
+
/** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
|
|
11110
|
+
channels: chatChannels,
|
|
9030
11111
|
/** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
|
|
9031
11112
|
customAgent: chatCustomAgent,
|
|
9032
11113
|
/** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
|
|
@@ -9045,6 +11126,8 @@ exports.chat = {
|
|
|
9045
11126
|
local: chatLocal,
|
|
9046
11127
|
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
|
|
9047
11128
|
createAccessToken: createChatAccessToken,
|
|
11129
|
+
/** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
|
|
11130
|
+
createWatchToken: createChatWatchToken,
|
|
9048
11131
|
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
|
|
9049
11132
|
setTurnTimeout,
|
|
9050
11133
|
/** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
|
|
@@ -9055,6 +11138,8 @@ exports.chat = {
|
|
|
9055
11138
|
setUIMessageStreamOptions,
|
|
9056
11139
|
/** Check if the current turn was stopped by the user. See {@link isStopped}. */
|
|
9057
11140
|
isStopped,
|
|
11141
|
+
/** The current turn's channel surface (reactions plus connector helpers), also from inside a tool. See {@link getChannelSurface}. */
|
|
11142
|
+
surface: getChannelSurface,
|
|
9058
11143
|
/** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
|
|
9059
11144
|
requestUpgrade,
|
|
9060
11145
|
/** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
|