@trigger.dev/sdk 4.7.4 → 4.8.0-webhooks.20261020092920
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/v3/ai.d.ts +626 -8
- package/dist/commonjs/v3/ai.js +2343 -185
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +23 -0
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/index.d.ts +2 -0
- package/dist/commonjs/v3/index.js +23 -17
- package/dist/commonjs/v3/index.js.map +1 -1
- package/dist/commonjs/v3/modelFileDownload.d.ts +10 -0
- package/dist/commonjs/v3/modelFileDownload.js +164 -0
- package/dist/commonjs/v3/modelFileDownload.js.map +1 -0
- package/dist/commonjs/v3/query.d.ts +434 -1
- package/dist/commonjs/v3/query.js +115 -0
- package/dist/commonjs/v3/query.js.map +1 -1
- package/dist/commonjs/v3/test/index.d.ts +2 -1
- package/dist/commonjs/v3/test/index.js +4 -1
- package/dist/commonjs/v3/test/index.js.map +1 -1
- package/dist/commonjs/v3/test/mock-channel-connector.d.ts +172 -0
- package/dist/commonjs/v3/test/mock-channel-connector.js +173 -0
- package/dist/commonjs/v3/test/mock-channel-connector.js.map +1 -0
- package/dist/commonjs/v3/test/mock-chat-agent.d.ts +81 -1
- package/dist/commonjs/v3/test/mock-chat-agent.js +137 -10
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/commonjs/v3/transcriptStorage.d.ts +44 -1
- package/dist/commonjs/v3/transcriptStorage.js +24 -0
- package/dist/commonjs/v3/transcriptStorage.js.map +1 -1
- package/dist/commonjs/v3/webhookWaiters.d.ts +80 -0
- package/dist/commonjs/v3/webhookWaiters.js +156 -0
- package/dist/commonjs/v3/webhookWaiters.js.map +1 -0
- package/dist/commonjs/v3/webhooks.d.ts +184 -2
- package/dist/commonjs/v3/webhooks.js +323 -1
- package/dist/commonjs/v3/webhooks.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +626 -8
- package/dist/esm/v3/ai.js +2336 -184
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +23 -0
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/index.d.ts +2 -0
- package/dist/esm/v3/index.js +1 -0
- package/dist/esm/v3/index.js.map +1 -1
- package/dist/esm/v3/modelFileDownload.d.ts +10 -0
- package/dist/esm/v3/modelFileDownload.js +161 -0
- package/dist/esm/v3/modelFileDownload.js.map +1 -0
- package/dist/esm/v3/query.d.ts +434 -1
- package/dist/esm/v3/query.js +115 -0
- package/dist/esm/v3/query.js.map +1 -1
- package/dist/esm/v3/test/index.d.ts +2 -1
- package/dist/esm/v3/test/index.js +2 -1
- package/dist/esm/v3/test/index.js.map +1 -1
- package/dist/esm/v3/test/mock-channel-connector.d.ts +172 -0
- package/dist/esm/v3/test/mock-channel-connector.js +170 -0
- package/dist/esm/v3/test/mock-channel-connector.js.map +1 -0
- package/dist/esm/v3/test/mock-chat-agent.d.ts +81 -1
- package/dist/esm/v3/test/mock-chat-agent.js +137 -11
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/esm/v3/transcriptStorage.d.ts +44 -1
- package/dist/esm/v3/transcriptStorage.js +24 -0
- package/dist/esm/v3/transcriptStorage.js.map +1 -1
- package/dist/esm/v3/webhookWaiters.d.ts +80 -0
- package/dist/esm/v3/webhookWaiters.js +149 -0
- package/dist/esm/v3/webhookWaiters.js.map +1 -0
- package/dist/esm/v3/webhooks.d.ts +184 -2
- package/dist/esm/v3/webhooks.js +319 -1
- package/dist/esm/v3/webhooks.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/actions.mdx +6 -1
- package/docs/ai-chat/backend.mdx +40 -0
- package/docs/ai-chat/patterns/redacting-telemetry.mdx +117 -0
- package/docs/ai-chat/pending-messages.mdx +2 -0
- package/docs/ai-chat/reference.mdx +53 -2
- package/docs/ai-chat/transcript-storage.mdx +1 -1
- package/docs/apikeys.mdx +6 -4
- package/docs/cli-runs-commands.mdx +2 -2
- package/docs/cli-tasks-commands.mdx +95 -0
- package/docs/config/config-file.mdx +58 -0
- package/docs/config/extensions/prismaExtension.mdx +4 -4
- package/docs/deploy-environment-variables.mdx +4 -0
- package/docs/deployment/atomic-deployment.mdx +3 -3
- package/docs/deployment/overview.mdx +1 -1
- package/docs/deployment/preview-branches.mdx +1 -1
- package/docs/github-actions.mdx +3 -3
- package/docs/limits.mdx +6 -0
- package/docs/manual-setup.mdx +1 -1
- package/docs/mcp-tools.mdx +99 -3
- package/docs/observability/dashboards.mdx +62 -0
- package/docs/runs/max-duration.mdx +4 -0
- package/docs/self-hosting/env/supervisor.mdx +1 -0
- package/docs/webhooks/channels.mdx +132 -0
- package/docs/webhooks/connect.mdx +84 -0
- package/docs/webhooks/deliveries.mdx +96 -0
- package/docs/webhooks/filters.mdx +142 -0
- package/docs/webhooks/human-in-the-loop.mdx +155 -0
- package/docs/webhooks/linear.mdx +189 -0
- package/docs/webhooks/overview.mdx +103 -0
- package/docs/webhooks/session-routing.mdx +138 -0
- package/docs/webhooks/slack.mdx +368 -0
- package/docs/webhooks/sources.mdx +221 -0
- package/docs/webhooks/waiters.mdx +194 -0
- package/package.json +4 -2
package/dist/commonjs/v3/ai.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.chat = exports.__buildManagedStreamTextOptionsForTests = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = exports.snapshotTranscriptStorage = exports.reduceTranscriptChanges = exports.memoryTranscriptStorage = exports.defaultStorage = exports.__writeChatSnapshotProductionPathForTests = exports.__setWriteChatSnapshotImplForTests = exports.__setReadChatSnapshotImplForTests = exports.__readChatSnapshotProductionPathForTests = void 0;
|
|
3
|
+
exports.chat = exports.CHANNEL_TURN_ERROR_MESSAGE = exports.__buildManagedStreamTextOptionsForTests = exports.upsertIncomingMessage = exports.PENDING_MESSAGE_INJECTED_TYPE = exports.ai = exports.snapshotTranscriptStorage = exports.reduceTranscriptChanges = exports.memoryTranscriptStorage = exports.defaultStorage = exports.__writeChatSnapshotProductionPathForTests = exports.__setWriteChatSnapshotImplForTests = exports.__setReadChatSnapshotImplForTests = exports.__readChatSnapshotProductionPathForTests = void 0;
|
|
4
4
|
exports.__setTranscriptStorageForTests = __setTranscriptStorageForTests;
|
|
5
5
|
exports.__mergeByIdReplaceWinsForTests = __mergeByIdReplaceWinsForTests;
|
|
6
6
|
exports.__setReplaySessionOutTailImplForTests = __setReplaySessionOutTailImplForTests;
|
|
@@ -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 */
|
|
@@ -2880,7 +2954,6 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2880
2954
|
// Span attributes describe the offered batch; the chunk and callback below
|
|
2881
2955
|
// describe what was actually claimed and injected.
|
|
2882
2956
|
const messageTexts = uiMessages.map(textOfUIMessage);
|
|
2883
|
-
const _previewText = messageTexts.length === 1 ? messageTexts[0].slice(0, 80) : `${queue.length} messages`;
|
|
2884
2957
|
return tracer_js_1.tracer.startActiveSpan("pending message injected", async () => {
|
|
2885
2958
|
/**
|
|
2886
2959
|
* Claim before transforming, and inject only what was claimed.
|
|
@@ -3019,7 +3092,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
3019
3092
|
[v3_1.SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward",
|
|
3020
3093
|
"pending.message_count": uiMessages.length,
|
|
3021
3094
|
"pending.step_number": stepNumber,
|
|
3022
|
-
"pending.messages": messageTexts,
|
|
3095
|
+
...(ctx?.recordUserMessage === false ? {} : { "pending.messages": messageTexts }),
|
|
3023
3096
|
...(ctx?.chatId ? { "pending.chat_id": ctx.chatId } : {}),
|
|
3024
3097
|
...(ctx?.turn != null ? { "pending.turn": ctx.turn } : {}),
|
|
3025
3098
|
...(0, v3_1.accessoryAttributes)({
|
|
@@ -3284,7 +3357,7 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3284
3357
|
* caller's value winning because `rest` is spread after `managed`. Pulling a
|
|
3285
3358
|
* key out to "handle" it is how a caller's option gets silently dropped.
|
|
3286
3359
|
*/
|
|
3287
|
-
const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
|
|
3360
|
+
const { tools, system: callerSystem, prepareStep: callerPrepareStep, experimental_download: callerDownload, ...rest } = options;
|
|
3288
3361
|
const managed = toStreamTextOptions({
|
|
3289
3362
|
registry,
|
|
3290
3363
|
system: callerSystem ?? agentSystem,
|
|
@@ -3327,6 +3400,12 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3327
3400
|
if (typeof managed.prepareStep === "function") {
|
|
3328
3401
|
managed.prepareStep = (0, steeringContext_js_1.retainStepMessages)(managed.prepareStep);
|
|
3329
3402
|
}
|
|
3403
|
+
if (typeof callerDownload === "function") {
|
|
3404
|
+
const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
|
|
3405
|
+
managed.experimental_download = channelDownload
|
|
3406
|
+
? channelAwareDownload(channelDownload, callerDownload)
|
|
3407
|
+
: callerDownload;
|
|
3408
|
+
}
|
|
3330
3409
|
return { ...managed, ...rest };
|
|
3331
3410
|
}
|
|
3332
3411
|
/** @internal Test hook for {@link buildManagedStreamTextOptions}. */
|
|
@@ -3556,6 +3635,10 @@ function toStreamTextOptions(options, retain = true) {
|
|
|
3556
3635
|
if (retain && typeof result.prepareStep === "function") {
|
|
3557
3636
|
result.prepareStep = (0, steeringContext_js_1.retainStepMessages)(result.prepareStep);
|
|
3558
3637
|
}
|
|
3638
|
+
const channelDownload = locals_js_1.locals.get(chatChannelDownloadKey);
|
|
3639
|
+
if (channelDownload) {
|
|
3640
|
+
result.experimental_download = channelAwareDownload(channelDownload);
|
|
3641
|
+
}
|
|
3559
3642
|
return result;
|
|
3560
3643
|
}
|
|
3561
3644
|
const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
@@ -3570,6 +3653,9 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3570
3653
|
* tools, steering, compaction, injected instructions and persistence, with no
|
|
3571
3654
|
* action-specific handling.
|
|
3572
3655
|
*
|
|
3656
|
+
* Pass `message` to have the turn answer a new user message, appended after the edit: a click on a
|
|
3657
|
+
* "Explain more" button becomes `chat.turn("Explain that in more detail.")`.
|
|
3658
|
+
*
|
|
3573
3659
|
* @example
|
|
3574
3660
|
* ```ts
|
|
3575
3661
|
* onAction: async ({ action }) => {
|
|
@@ -3577,12 +3663,13 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3577
3663
|
* chat.history.slice(0, -1);
|
|
3578
3664
|
* return chat.turn();
|
|
3579
3665
|
* }
|
|
3666
|
+
* if (action.type === "ask") return chat.turn(action.question);
|
|
3580
3667
|
* if (action.type === "undo") chat.history.slice(0, -2); // no turn
|
|
3581
3668
|
* },
|
|
3582
3669
|
* ```
|
|
3583
3670
|
*/
|
|
3584
|
-
function chatTurn() {
|
|
3585
|
-
return { [actionTurnBrand]: true };
|
|
3671
|
+
function chatTurn(message) {
|
|
3672
|
+
return (message === undefined ? { [actionTurnBrand]: true } : { [actionTurnBrand]: true, message });
|
|
3586
3673
|
}
|
|
3587
3674
|
function isActionTurn(value) {
|
|
3588
3675
|
return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
|
|
@@ -3648,6 +3735,21 @@ function warnMissingOnActionOnce() {
|
|
|
3648
3735
|
function isAsyncIterable(value) {
|
|
3649
3736
|
return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
|
|
3650
3737
|
}
|
|
3738
|
+
function readableStreamFromAsyncIterable(source) {
|
|
3739
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
3740
|
+
return new ReadableStream({
|
|
3741
|
+
async pull(controller) {
|
|
3742
|
+
const next = await iterator.next();
|
|
3743
|
+
if (next.done)
|
|
3744
|
+
controller.close();
|
|
3745
|
+
else
|
|
3746
|
+
controller.enqueue(next.value);
|
|
3747
|
+
},
|
|
3748
|
+
async cancel(reason) {
|
|
3749
|
+
await iterator.return?.(reason);
|
|
3750
|
+
},
|
|
3751
|
+
});
|
|
3752
|
+
}
|
|
3651
3753
|
function isReadableStream(value) {
|
|
3652
3754
|
return (typeof value === "object" && value !== null && typeof value.getReader === "function");
|
|
3653
3755
|
}
|
|
@@ -3703,6 +3805,10 @@ async function pipeChat(source, options, capture) {
|
|
|
3703
3805
|
throw new Error("pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " +
|
|
3704
3806
|
"an AsyncIterable, or a ReadableStream");
|
|
3705
3807
|
}
|
|
3808
|
+
const channelTap = locals_js_1.locals.get(chatTurnChannelTapKey);
|
|
3809
|
+
if (channelTap) {
|
|
3810
|
+
stream = channelTap(isReadableStream(stream) ? stream : readableStreamFromAsyncIterable(stream));
|
|
3811
|
+
}
|
|
3706
3812
|
const pipeOptions = {};
|
|
3707
3813
|
if (options?.signal) {
|
|
3708
3814
|
pipeOptions.signal = options.signal;
|
|
@@ -3732,155 +3838,1068 @@ async function pipeChat(source, options, capture) {
|
|
|
3732
3838
|
await waitUntilComplete();
|
|
3733
3839
|
}
|
|
3734
3840
|
}
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3841
|
+
/** The AI SDK's default `toUIMessageStream` `onError`: the error's message, as the client sees it. */
|
|
3842
|
+
function uiStreamErrorText(error) {
|
|
3843
|
+
if (error == null)
|
|
3844
|
+
return "unknown error";
|
|
3845
|
+
if (typeof error === "string")
|
|
3846
|
+
return error;
|
|
3847
|
+
if (error instanceof Error)
|
|
3848
|
+
return error.message;
|
|
3849
|
+
return JSON.stringify(error);
|
|
3850
|
+
}
|
|
3851
|
+
/** The line a channel shows when a turn fails, unless the connector sets its own `errorMessage`. */
|
|
3852
|
+
exports.CHANNEL_TURN_ERROR_MESSAGE = "Something went wrong answering this. Try again.";
|
|
3853
|
+
async function channelTurnErrorMessage(connector, ctx) {
|
|
3854
|
+
const option = connector?.errorMessage;
|
|
3855
|
+
if (typeof option !== "function")
|
|
3856
|
+
return option || exports.CHANNEL_TURN_ERROR_MESSAGE;
|
|
3857
|
+
try {
|
|
3858
|
+
const line = await option({ ...ctx, defaultMessage: exports.CHANNEL_TURN_ERROR_MESSAGE });
|
|
3859
|
+
if (line)
|
|
3860
|
+
return line;
|
|
3861
|
+
}
|
|
3862
|
+
catch (error) {
|
|
3863
|
+
v3_1.logger.warn("chat.agent: channel errorMessage threw; posting the default line", { error });
|
|
3864
|
+
}
|
|
3865
|
+
return exports.CHANNEL_TURN_ERROR_MESSAGE;
|
|
3866
|
+
}
|
|
3867
|
+
/**
|
|
3868
|
+
* A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
|
|
3869
|
+
* `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
|
|
3870
|
+
* control. Use for providers without a preset, or to test the channel round-trip end to end.
|
|
3871
|
+
*/
|
|
3872
|
+
function chatChannelCustom(options) {
|
|
3873
|
+
const { id, endpoint, key, inbound, bind, seed, outbound, ack, send, renderInteraction, onInteraction, finalizeInteraction, react, reactions, progress, surface, onControl, stream, endInterruptedStream, download, received, toAction, lifecycle, cancelInteraction, filter, startOn, delivery, errorMessage, } = options;
|
|
3874
|
+
if (!endpoint?.id) {
|
|
3875
|
+
throw new Error(`chat.channels.custom("${id}"): an endpoint is required. Declare one with webhooks.endpoint.define() and pass it as { endpoint }.`);
|
|
3876
|
+
}
|
|
3877
|
+
v3_1.resourceCatalog.registerDeclaredSessionWebhook(id);
|
|
3878
|
+
return {
|
|
3879
|
+
id,
|
|
3880
|
+
endpointId: endpoint.id,
|
|
3881
|
+
source: endpoint.source,
|
|
3882
|
+
key: (0, webhooks_js_1.normalizeKeyString)(key),
|
|
3883
|
+
filter,
|
|
3884
|
+
startOn,
|
|
3885
|
+
inbound,
|
|
3886
|
+
bind,
|
|
3887
|
+
seed,
|
|
3888
|
+
outbound,
|
|
3889
|
+
ack,
|
|
3890
|
+
send,
|
|
3891
|
+
renderInteraction,
|
|
3892
|
+
onInteraction,
|
|
3893
|
+
finalizeInteraction,
|
|
3894
|
+
react,
|
|
3895
|
+
reactions,
|
|
3896
|
+
progress,
|
|
3897
|
+
surface,
|
|
3898
|
+
onControl,
|
|
3899
|
+
stream,
|
|
3900
|
+
endInterruptedStream,
|
|
3901
|
+
download,
|
|
3902
|
+
received,
|
|
3903
|
+
toAction,
|
|
3904
|
+
lifecycle,
|
|
3905
|
+
cancelInteraction,
|
|
3906
|
+
delivery: delivery ?? "final",
|
|
3907
|
+
errorMessage,
|
|
3908
|
+
};
|
|
3909
|
+
}
|
|
3910
|
+
const chatChannels = {
|
|
3911
|
+
/** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
|
|
3912
|
+
custom: chatChannelCustom,
|
|
3913
|
+
};
|
|
3914
|
+
// Prior surface messages from a connector's seed(), as transcript messages stamped with provenance.
|
|
3915
|
+
function channelSeedMessages(seed, provenance) {
|
|
3916
|
+
return seed
|
|
3917
|
+
.filter((m) => m && typeof m.text === "string" && m.text.length > 0)
|
|
3918
|
+
.map((m, index) => {
|
|
3919
|
+
const meta = {
|
|
3920
|
+
...provenance,
|
|
3921
|
+
seeded: true,
|
|
3922
|
+
...(m.externalUserId ? { externalUserId: m.externalUserId } : {}),
|
|
3923
|
+
...(m.externalMessageId ? { externalMessageId: m.externalMessageId } : {}),
|
|
3924
|
+
};
|
|
3925
|
+
return {
|
|
3926
|
+
id: `seed-${m.externalMessageId ?? `${provenance.deliveryId}-${index}`}`,
|
|
3927
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
3928
|
+
parts: [{ type: "text", text: m.text }],
|
|
3929
|
+
metadata: meta,
|
|
3930
|
+
};
|
|
3931
|
+
});
|
|
3932
|
+
}
|
|
3933
|
+
// Turn a channel connector's inbound() result into the turn's user UIMessage, stamped with where
|
|
3934
|
+
// it came from so a transcript storage can attribute it (see ChannelMessageMetadata).
|
|
3935
|
+
/**
|
|
3936
|
+
* True when a connector's `inbound()` result carries nothing to answer: blank text and no file or
|
|
3937
|
+
* data parts. A button click from another app on a shared endpoint maps to one.
|
|
3938
|
+
*/
|
|
3939
|
+
function isEmptyChannelInbound(input) {
|
|
3940
|
+
const message = input !== null && typeof input === "object" && "message" in input && !("role" in input)
|
|
3941
|
+
? input.message
|
|
3942
|
+
: input;
|
|
3943
|
+
if (typeof message === "string")
|
|
3944
|
+
return message.trim() === "";
|
|
3945
|
+
return (message.parts ?? []).every((part) => part.type === "text" && part.text?.trim() === "");
|
|
3946
|
+
}
|
|
3947
|
+
/** Bytes held for a run's channel file downloads before the oldest are dropped and fetched again. */
|
|
3948
|
+
const CHANNEL_DOWNLOAD_CACHE_BYTES = 64 * 1024 * 1024;
|
|
3949
|
+
/**
|
|
3950
|
+
* One downloader for a run's connectors that define `download`: asks each in turn (the ones bound to
|
|
3951
|
+
* the session first, the next one after a failure) and caches what they return by URL, so a file in
|
|
3952
|
+
* the history is fetched once per run rather than per step.
|
|
3953
|
+
*/
|
|
3954
|
+
function makeChannelDownloader(connectors, handleFor) {
|
|
3955
|
+
const downloaders = connectors.filter((c) => typeof c.download === "function");
|
|
3956
|
+
if (downloaders.length === 0)
|
|
3957
|
+
return undefined;
|
|
3958
|
+
const cache = new Map();
|
|
3959
|
+
const sizes = new Map();
|
|
3960
|
+
let cachedBytes = 0;
|
|
3961
|
+
const evict = () => {
|
|
3962
|
+
for (const key of cache.keys()) {
|
|
3963
|
+
if (cachedBytes <= CHANNEL_DOWNLOAD_CACHE_BYTES)
|
|
3964
|
+
return;
|
|
3965
|
+
cachedBytes -= sizes.get(key) ?? 0;
|
|
3966
|
+
sizes.delete(key);
|
|
3967
|
+
cache.delete(key);
|
|
3968
|
+
}
|
|
3969
|
+
};
|
|
3970
|
+
const fetchOwned = async (url) => {
|
|
3971
|
+
const candidates = downloaders.map((connector) => ({
|
|
3972
|
+
connector,
|
|
3973
|
+
handle: handleFor(connector.id),
|
|
3974
|
+
}));
|
|
3975
|
+
const ordered = [
|
|
3976
|
+
...candidates.filter((c) => c.handle !== undefined),
|
|
3977
|
+
...candidates.filter((c) => c.handle === undefined),
|
|
3978
|
+
];
|
|
3979
|
+
let failure;
|
|
3980
|
+
for (const { connector, handle } of ordered) {
|
|
3981
|
+
try {
|
|
3982
|
+
const file = await connector.download(url, { handle });
|
|
3983
|
+
if (file)
|
|
3984
|
+
return file;
|
|
3765
3985
|
}
|
|
3766
|
-
|
|
3767
|
-
|
|
3986
|
+
catch (error) {
|
|
3987
|
+
failure ??= { error };
|
|
3768
3988
|
}
|
|
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));
|
|
3989
|
+
}
|
|
3990
|
+
if (failure)
|
|
3991
|
+
throw failure.error;
|
|
3992
|
+
return null;
|
|
3993
|
+
};
|
|
3994
|
+
return (url) => {
|
|
3995
|
+
const key = url.href;
|
|
3996
|
+
const cached = cache.get(key);
|
|
3997
|
+
if (cached)
|
|
3998
|
+
return cached;
|
|
3999
|
+
const pending = fetchOwned(url).then((file) => {
|
|
4000
|
+
if (file && cache.get(key) === pending) {
|
|
4001
|
+
sizes.set(key, file.data.byteLength);
|
|
4002
|
+
cachedBytes += file.data.byteLength;
|
|
4003
|
+
evict();
|
|
3797
4004
|
}
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
4005
|
+
return file;
|
|
4006
|
+
}, (error) => {
|
|
4007
|
+
if (cache.get(key) === pending)
|
|
4008
|
+
cache.delete(key);
|
|
4009
|
+
throw error;
|
|
4010
|
+
});
|
|
4011
|
+
cache.set(key, pending);
|
|
4012
|
+
return pending;
|
|
4013
|
+
};
|
|
4014
|
+
}
|
|
4015
|
+
function fileUrlOf(part) {
|
|
4016
|
+
const p = part;
|
|
4017
|
+
const raw = p?.type === "file" ? p.data : p?.type === "image" ? p.image : undefined;
|
|
4018
|
+
if (raw instanceof URL)
|
|
4019
|
+
return raw;
|
|
4020
|
+
if (typeof raw !== "string" || !/^https?:\/\//i.test(raw))
|
|
4021
|
+
return undefined;
|
|
4022
|
+
try {
|
|
4023
|
+
return new URL(raw);
|
|
4024
|
+
}
|
|
4025
|
+
catch {
|
|
4026
|
+
return undefined;
|
|
4027
|
+
}
|
|
4028
|
+
}
|
|
4029
|
+
/**
|
|
4030
|
+
* Fetch the channel-owned files in the messages a turn hands to `run()`, so the model call reads
|
|
4031
|
+
* them from the cache. A file a connector owns but cannot read becomes a text note in this turn's
|
|
4032
|
+
* copy of the messages, and the turn still runs. The transcript keeps the URL.
|
|
4033
|
+
*/
|
|
4034
|
+
async function resolveChannelFiles(messages, download) {
|
|
4035
|
+
const failed = new Set();
|
|
4036
|
+
const mediaTypes = new Map();
|
|
4037
|
+
const urls = new Map();
|
|
4038
|
+
for (const message of messages) {
|
|
4039
|
+
if (!Array.isArray(message.content))
|
|
4040
|
+
continue;
|
|
4041
|
+
for (const part of message.content) {
|
|
4042
|
+
const url = fileUrlOf(part);
|
|
4043
|
+
if (url)
|
|
4044
|
+
urls.set(url.href, url);
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
await Promise.all([...urls.values()].map(async (url) => {
|
|
4048
|
+
try {
|
|
4049
|
+
const file = await download(url);
|
|
4050
|
+
if (file?.mediaType)
|
|
4051
|
+
mediaTypes.set(url.href, file.mediaType);
|
|
4052
|
+
}
|
|
4053
|
+
catch (error) {
|
|
4054
|
+
failed.add(url.href);
|
|
4055
|
+
v3_1.logger.warn("chat.agent: a channel file could not be downloaded; the model gets a note", {
|
|
4056
|
+
url: url.origin + url.pathname,
|
|
4057
|
+
error,
|
|
3810
4058
|
});
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
4059
|
+
}
|
|
4060
|
+
}));
|
|
4061
|
+
const isWildcard = (part) => {
|
|
4062
|
+
const mediaType = part.mediaType;
|
|
4063
|
+
return part.type === "file" && !!mediaType && mediaType.endsWith("/*");
|
|
4064
|
+
};
|
|
4065
|
+
return messages.map((message) => {
|
|
4066
|
+
if (!Array.isArray(message.content))
|
|
4067
|
+
return message;
|
|
4068
|
+
let changed = false;
|
|
4069
|
+
const content = message.content.map((part) => {
|
|
4070
|
+
const url = fileUrlOf(part);
|
|
4071
|
+
if (!url)
|
|
4072
|
+
return part;
|
|
4073
|
+
if (failed.has(url.href)) {
|
|
4074
|
+
changed = true;
|
|
4075
|
+
const name = part.filename;
|
|
4076
|
+
return {
|
|
4077
|
+
type: "text",
|
|
4078
|
+
text: `[Attached file${name ? ` "${name}"` : ""} could not be loaded]`,
|
|
4079
|
+
};
|
|
3827
4080
|
}
|
|
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;
|
|
4081
|
+
const downloaded = mediaTypes.get(url.href);
|
|
4082
|
+
if (downloaded && isWildcard(part)) {
|
|
4083
|
+
changed = true;
|
|
4084
|
+
return { ...part, mediaType: downloaded };
|
|
3838
4085
|
}
|
|
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
|
-
},
|
|
4086
|
+
return part;
|
|
4087
|
+
});
|
|
4088
|
+
return changed ? { ...message, content } : message;
|
|
3850
4089
|
});
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
4090
|
+
}
|
|
4091
|
+
/**
|
|
4092
|
+
* The AI SDK `experimental_download` for a turn: channel-owned URLs come from the connectors, and
|
|
4093
|
+
* every other URL goes to `fallback`, or to the AI SDK's default behaviour (left for a model that
|
|
4094
|
+
* reads the URL itself, fetched from a public host otherwise).
|
|
4095
|
+
*/
|
|
4096
|
+
function channelAwareDownload(download, fallback) {
|
|
4097
|
+
return async (requests) => {
|
|
4098
|
+
const results = await Promise.all(requests.map(async (request) => {
|
|
4099
|
+
const file = await download(request.url);
|
|
4100
|
+
return file ? { data: file.data, mediaType: file.mediaType } : null;
|
|
4101
|
+
}));
|
|
4102
|
+
const misses = requests.flatMap((_, i) => (results[i] ? [] : [i]));
|
|
4103
|
+
if (misses.length === 0)
|
|
4104
|
+
return results;
|
|
4105
|
+
const rest = fallback
|
|
4106
|
+
? await fallback(misses.map((i) => requests[i]))
|
|
4107
|
+
: await Promise.all(misses.map((i) => requests[i].isUrlSupportedByModel ? null : (0, modelFileDownload_js_1.fetchPublicFile)(requests[i].url)));
|
|
4108
|
+
misses.forEach((i, n) => {
|
|
4109
|
+
results[i] = rest[n] ?? null;
|
|
3856
4110
|
});
|
|
4111
|
+
return results;
|
|
4112
|
+
};
|
|
4113
|
+
}
|
|
4114
|
+
/** The action a channel click maps to through its connector's `toAction()`, if it maps to one. */
|
|
4115
|
+
function channelClickAction(connectors, wire) {
|
|
4116
|
+
const channelEvent = wire;
|
|
4117
|
+
if (!channelEvent?.connectorId)
|
|
4118
|
+
return undefined;
|
|
4119
|
+
const connector = connectors?.find((c) => c.id === channelEvent.connectorId);
|
|
4120
|
+
if (!connector?.toAction)
|
|
4121
|
+
return undefined;
|
|
4122
|
+
try {
|
|
4123
|
+
return connector.toAction(channelEvent.event) ?? undefined;
|
|
4124
|
+
}
|
|
4125
|
+
catch (error) {
|
|
4126
|
+
v3_1.logger.warn("chat.agent: channel toAction() threw; handling the delivery as a message", {
|
|
4127
|
+
error,
|
|
4128
|
+
});
|
|
4129
|
+
return undefined;
|
|
3857
4130
|
}
|
|
3858
|
-
return task;
|
|
3859
4131
|
}
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
4132
|
+
/**
|
|
4133
|
+
* A channel delivery that arrived during a turn, as a message `pendingMessages` can steer into it:
|
|
4134
|
+
* the connector's `inbound()` with channel provenance. Undefined for what must not be steered (a
|
|
4135
|
+
* stop or other control, an interaction such as an approval click, an empty message, a connector
|
|
4136
|
+
* this agent does not list), which stays queued as its own turn.
|
|
4137
|
+
*/
|
|
4138
|
+
function channelSteeringMessage(connectors, wire, messageId) {
|
|
4139
|
+
const connector = connectors?.find((c) => c.id === wire.connectorId);
|
|
4140
|
+
if (!connector)
|
|
4141
|
+
return undefined;
|
|
4142
|
+
try {
|
|
4143
|
+
if (connector.onControl?.(wire.event))
|
|
4144
|
+
return undefined;
|
|
4145
|
+
if (connector.onInteraction?.(wire.event))
|
|
4146
|
+
return undefined;
|
|
4147
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4148
|
+
return undefined;
|
|
4149
|
+
const inbound = connector.inbound(wire.event);
|
|
4150
|
+
if (isEmptyChannelInbound(inbound))
|
|
4151
|
+
return undefined;
|
|
4152
|
+
const message = channelUserMessage(inbound, messageId ?? wire.deliveryId, {
|
|
4153
|
+
source: "channel",
|
|
4154
|
+
connectorId: connector.id,
|
|
4155
|
+
provider: connector.source,
|
|
4156
|
+
deliveryId: wire.deliveryId,
|
|
4157
|
+
...(wire.externalDeliveryId ? { externalDeliveryId: wire.externalDeliveryId } : {}),
|
|
4158
|
+
});
|
|
4159
|
+
return { message };
|
|
3873
4160
|
}
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
4161
|
+
catch (error) {
|
|
4162
|
+
v3_1.logger.warn("chat.agent: a channel message could not be offered for steering; it runs as its own turn", {
|
|
4163
|
+
error,
|
|
4164
|
+
});
|
|
4165
|
+
return undefined;
|
|
4166
|
+
}
|
|
4167
|
+
}
|
|
4168
|
+
/**
|
|
4169
|
+
* Call the connector's `received` for a message that arrived mid-turn, unless it is not a message
|
|
4170
|
+
* to answer (an interaction click, an action click, an empty message). Fire and forget.
|
|
4171
|
+
*/
|
|
4172
|
+
function acknowledgeChannelMessage(connector, wire, handle) {
|
|
4173
|
+
if (!connector.received)
|
|
4174
|
+
return;
|
|
4175
|
+
try {
|
|
4176
|
+
if (connector.onInteraction?.(wire.event))
|
|
4177
|
+
return;
|
|
4178
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4179
|
+
return;
|
|
4180
|
+
if (isEmptyChannelInbound(connector.inbound(wire.event)))
|
|
4181
|
+
return;
|
|
4182
|
+
void Promise.resolve(connector.received({
|
|
4183
|
+
event: wire.event,
|
|
4184
|
+
deliveryId: wire.deliveryId,
|
|
4185
|
+
handle,
|
|
4186
|
+
bound: false,
|
|
4187
|
+
})).catch(() => { });
|
|
4188
|
+
}
|
|
4189
|
+
catch {
|
|
4190
|
+
return;
|
|
4191
|
+
}
|
|
4192
|
+
}
|
|
4193
|
+
function channelUserMessage(input, messageId, provenance) {
|
|
4194
|
+
const wrapped = input !== null && typeof input === "object" && "message" in input && !("role" in input);
|
|
4195
|
+
const message = (wrapped ? input.message : input);
|
|
4196
|
+
const extra = wrapped
|
|
4197
|
+
? input
|
|
4198
|
+
: {};
|
|
4199
|
+
const meta = {
|
|
4200
|
+
...provenance,
|
|
4201
|
+
...(extra.externalUserId ? { externalUserId: extra.externalUserId } : {}),
|
|
4202
|
+
...(extra.externalMessageId ? { externalMessageId: extra.externalMessageId } : {}),
|
|
4203
|
+
};
|
|
4204
|
+
const base = typeof message === "string"
|
|
4205
|
+
? { id: messageId, role: "user", parts: [{ type: "text", text: message }] }
|
|
4206
|
+
: message.id
|
|
4207
|
+
? message
|
|
4208
|
+
: { ...message, id: messageId };
|
|
4209
|
+
const existing = base.metadata;
|
|
4210
|
+
return {
|
|
4211
|
+
...base,
|
|
4212
|
+
metadata: { ...(existing && typeof existing === "object" ? existing : {}), ...meta },
|
|
4213
|
+
};
|
|
4214
|
+
}
|
|
4215
|
+
/**
|
|
4216
|
+
* The assistant's closing text for a channel reply: the text after the last tool part in the message,
|
|
4217
|
+
* so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
|
|
4218
|
+
* is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
|
|
4219
|
+
* tool parts (an ordinary turn), so a plain reply is unchanged.
|
|
4220
|
+
*/
|
|
4221
|
+
function channelReplyText(message) {
|
|
4222
|
+
const parts = (message.parts ?? []);
|
|
4223
|
+
const isTool = (p) => {
|
|
4224
|
+
const t = p?.type;
|
|
4225
|
+
return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
|
|
4226
|
+
};
|
|
4227
|
+
let lastToolIdx = -1;
|
|
4228
|
+
parts.forEach((p, i) => {
|
|
4229
|
+
if (isTool(p))
|
|
4230
|
+
lastToolIdx = i;
|
|
4231
|
+
});
|
|
4232
|
+
const textFrom = (from) => parts
|
|
4233
|
+
.slice(from)
|
|
4234
|
+
.map((p) => p && typeof p === "object" && p.type === "text"
|
|
4235
|
+
? String(p.text)
|
|
4236
|
+
: "")
|
|
4237
|
+
.join("");
|
|
4238
|
+
const trailing = textFrom(lastToolIdx + 1);
|
|
4239
|
+
return trailing.trim().length > 0 ? trailing : textFrom(0);
|
|
4240
|
+
}
|
|
4241
|
+
/**
|
|
4242
|
+
* Tries at a turn's final channel reply before it is given up on and the turn shows the error
|
|
4243
|
+
* reaction. Only an edit of the ack placeholder is retried: a failed post may still have landed, so
|
|
4244
|
+
* a post is tried once.
|
|
4245
|
+
*/
|
|
4246
|
+
const CHANNEL_FINAL_SEND_ATTEMPTS = 3;
|
|
4247
|
+
/** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
|
|
4248
|
+
function pendingToolCallsInMessage(message) {
|
|
4249
|
+
const out = [];
|
|
4250
|
+
for (const part of (message.parts ?? [])) {
|
|
4251
|
+
if (!part || typeof part !== "object")
|
|
4252
|
+
continue;
|
|
4253
|
+
const type = part.type;
|
|
4254
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4255
|
+
if (!isTool || part.state !== "input-available")
|
|
4256
|
+
continue;
|
|
4257
|
+
const toolName = type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
|
|
4258
|
+
if (typeof part.toolCallId === "string")
|
|
4259
|
+
out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
|
|
4260
|
+
}
|
|
4261
|
+
return out;
|
|
4262
|
+
}
|
|
4263
|
+
/**
|
|
4264
|
+
* Fill in `toolCallId` on a resolution that names the tool instead: the most recent assistant part of
|
|
4265
|
+
* that tool still awaiting input. Returns the resolution unchanged when it already has an id, and
|
|
4266
|
+
* undefined when no pending call of that tool exists.
|
|
4267
|
+
*/
|
|
4268
|
+
function resolveInteractionToolCallId(resolution, messages) {
|
|
4269
|
+
if (resolution.toolCallId) {
|
|
4270
|
+
return resolution;
|
|
4271
|
+
}
|
|
4272
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4273
|
+
const message = messages[i];
|
|
4274
|
+
if (message.role !== "assistant")
|
|
4275
|
+
continue;
|
|
4276
|
+
for (const call of pendingToolCallsInMessage(message).reverse()) {
|
|
4277
|
+
if (call.toolName === resolution.toolName) {
|
|
4278
|
+
return { ...resolution, toolCallId: call.toolCallId };
|
|
4279
|
+
}
|
|
4280
|
+
}
|
|
4281
|
+
}
|
|
4282
|
+
return undefined;
|
|
4283
|
+
}
|
|
4284
|
+
/**
|
|
4285
|
+
* Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
|
|
4286
|
+
* the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
|
|
4287
|
+
* the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
|
|
4288
|
+
*/
|
|
4289
|
+
function buildInteractionResolutionMessage(resolution, messages, provenance) {
|
|
4290
|
+
// Resolved anywhere means resolved: a resumed turn can carry the same tool call in more than
|
|
4291
|
+
// one assistant message, and a second click on a decided call must not resolve a stale copy.
|
|
4292
|
+
for (const message of messages) {
|
|
4293
|
+
if (message.role !== "assistant")
|
|
4294
|
+
continue;
|
|
4295
|
+
for (const part of (message.parts ?? [])) {
|
|
4296
|
+
if (part && typeof part === "object" && part.toolCallId === resolution.toolCallId) {
|
|
4297
|
+
if (isResolvedToolState(part.state))
|
|
4298
|
+
return undefined;
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
}
|
|
4302
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4303
|
+
const message = messages[i];
|
|
4304
|
+
if (message.role !== "assistant")
|
|
4305
|
+
continue;
|
|
4306
|
+
for (const part of (message.parts ?? [])) {
|
|
4307
|
+
if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId)
|
|
4308
|
+
continue;
|
|
4309
|
+
const type = part.type;
|
|
4310
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4311
|
+
if (!isTool)
|
|
4312
|
+
continue;
|
|
4313
|
+
if (part.state !== "input-available")
|
|
4314
|
+
return undefined;
|
|
4315
|
+
const slimPart = {
|
|
4316
|
+
type,
|
|
4317
|
+
toolCallId: resolution.toolCallId,
|
|
4318
|
+
state: "output-available",
|
|
4319
|
+
output: resolution.output,
|
|
4320
|
+
};
|
|
4321
|
+
if (type === "dynamic-tool" && typeof part.toolName === "string")
|
|
4322
|
+
slimPart.toolName = part.toolName;
|
|
4323
|
+
return {
|
|
4324
|
+
id: message.id,
|
|
4325
|
+
role: "assistant",
|
|
4326
|
+
parts: [slimPart],
|
|
4327
|
+
...(provenance ? { metadata: provenance } : {}),
|
|
4328
|
+
};
|
|
4329
|
+
}
|
|
4330
|
+
}
|
|
4331
|
+
return undefined;
|
|
4332
|
+
}
|
|
4333
|
+
// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
|
|
4334
|
+
function defaultChannelOutbound(reply) {
|
|
4335
|
+
return reply.text ? { text: reply.text } : null;
|
|
4336
|
+
}
|
|
4337
|
+
// Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
|
|
4338
|
+
// pick one at random from an array. Returns undefined to skip (no choice / empty).
|
|
4339
|
+
async function resolveReactionChoice(choice, event) {
|
|
4340
|
+
if (choice == null)
|
|
4341
|
+
return undefined;
|
|
4342
|
+
let value = typeof choice === "function" ? await choice(event) : choice;
|
|
4343
|
+
if (Array.isArray(value)) {
|
|
4344
|
+
if (value.length === 0)
|
|
4345
|
+
return undefined;
|
|
4346
|
+
value = value[Math.floor(Math.random() * value.length)];
|
|
4347
|
+
}
|
|
4348
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
4349
|
+
}
|
|
4350
|
+
// Best-effort reaction on the triggering message; a failure never breaks the turn.
|
|
4351
|
+
async function applyChannelReaction(connector, wireEvent, reaction) {
|
|
4352
|
+
if (!connector?.react || !wireEvent || !reaction.name)
|
|
4353
|
+
return;
|
|
4354
|
+
try {
|
|
4355
|
+
await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
|
|
4356
|
+
}
|
|
4357
|
+
catch (error) {
|
|
4358
|
+
v3_1.logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
|
|
4359
|
+
}
|
|
4360
|
+
}
|
|
4361
|
+
// The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
|
|
4362
|
+
// `react`, so `payload.channel` is only present when reacting is actually possible.
|
|
4363
|
+
function buildChannelRunSurface(connector, wireEvent, handle) {
|
|
4364
|
+
if (!connector)
|
|
4365
|
+
return undefined;
|
|
4366
|
+
const canReact = !!connector.react && !!wireEvent;
|
|
4367
|
+
if (!canReact && !connector.surface)
|
|
4368
|
+
return undefined;
|
|
4369
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4370
|
+
let extra = {};
|
|
4371
|
+
if (connector.surface) {
|
|
4372
|
+
try {
|
|
4373
|
+
extra = connector.surface(turnCtx) ?? {};
|
|
4374
|
+
}
|
|
4375
|
+
catch (error) {
|
|
4376
|
+
v3_1.logger.warn("chat.agent: channel surface() threw; continuing without it", { error });
|
|
4377
|
+
}
|
|
4378
|
+
}
|
|
4379
|
+
const noop = async () => { };
|
|
4380
|
+
return {
|
|
4381
|
+
...extra,
|
|
4382
|
+
react: canReact ? (name) => applyChannelReaction(connector, wireEvent, { name }) : noop,
|
|
4383
|
+
unreact: canReact
|
|
4384
|
+
? (name) => applyChannelReaction(connector, wireEvent, { name, remove: true })
|
|
4385
|
+
: noop,
|
|
4386
|
+
};
|
|
4387
|
+
}
|
|
4388
|
+
function channelTurnCtx(wireEvent, handle) {
|
|
4389
|
+
return {
|
|
4390
|
+
...(wireEvent ? { event: wireEvent.event, deliveryId: wireEvent.deliveryId } : {}),
|
|
4391
|
+
...(handle !== undefined ? { handle } : {}),
|
|
4392
|
+
bound: !wireEvent,
|
|
4393
|
+
};
|
|
4394
|
+
}
|
|
4395
|
+
/**
|
|
4396
|
+
* Feed a connector's `progress()` from the UI message stream: a tool call once its input is available,
|
|
4397
|
+
* then its result or error. Calls are serialized so the surface sees them in order; a throw is logged.
|
|
4398
|
+
* `stop()` refuses further chunks and drains what is already queued, so a slow first post does not
|
|
4399
|
+
* lose the results behind it, and nothing new lands after the turn ended.
|
|
4400
|
+
*/
|
|
4401
|
+
function makeChannelProgressObserver(connector, wireEvent, handle) {
|
|
4402
|
+
const progress = connector.progress;
|
|
4403
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4404
|
+
const toolNames = new Map();
|
|
4405
|
+
const toolTitles = new Map();
|
|
4406
|
+
let chain = Promise.resolve();
|
|
4407
|
+
let stopped = false;
|
|
4408
|
+
const emit = (event) => {
|
|
4409
|
+
chain = chain.then(async () => {
|
|
4410
|
+
try {
|
|
4411
|
+
await progress(event, turnCtx);
|
|
4412
|
+
}
|
|
4413
|
+
catch (error) {
|
|
4414
|
+
v3_1.logger.warn("chat.agent: channel progress() failed; continuing", {
|
|
4415
|
+
error,
|
|
4416
|
+
type: event.type,
|
|
4417
|
+
});
|
|
4418
|
+
}
|
|
4419
|
+
});
|
|
4420
|
+
};
|
|
4421
|
+
return {
|
|
4422
|
+
observe(chunk) {
|
|
4423
|
+
if (stopped)
|
|
4424
|
+
return;
|
|
4425
|
+
const c = chunk;
|
|
4426
|
+
if (!c?.type || !c.toolCallId)
|
|
4427
|
+
return;
|
|
4428
|
+
if (c.title)
|
|
4429
|
+
toolTitles.set(c.toolCallId, c.title);
|
|
4430
|
+
const title = toolTitles.get(c.toolCallId);
|
|
4431
|
+
const titled = title ? { title } : {};
|
|
4432
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4433
|
+
toolNames.set(c.toolCallId, c.toolName);
|
|
4434
|
+
return;
|
|
4435
|
+
}
|
|
4436
|
+
if (c.type === "tool-input-available") {
|
|
4437
|
+
const toolName = c.toolName ?? toolNames.get(c.toolCallId) ?? "tool";
|
|
4438
|
+
toolNames.set(c.toolCallId, toolName);
|
|
4439
|
+
emit({ type: "tool-call", toolCallId: c.toolCallId, toolName, ...titled, input: c.input });
|
|
4440
|
+
return;
|
|
4441
|
+
}
|
|
4442
|
+
if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4443
|
+
emit({
|
|
4444
|
+
type: "tool-result",
|
|
4445
|
+
toolCallId: c.toolCallId,
|
|
4446
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4447
|
+
...titled,
|
|
4448
|
+
output: c.output,
|
|
4449
|
+
});
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4452
|
+
if (c.type === "tool-output-error") {
|
|
4453
|
+
emit({
|
|
4454
|
+
type: "tool-error",
|
|
4455
|
+
toolCallId: c.toolCallId,
|
|
4456
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4457
|
+
...titled,
|
|
4458
|
+
errorText: c.errorText ?? "tool failed",
|
|
4459
|
+
});
|
|
4460
|
+
}
|
|
4461
|
+
},
|
|
4462
|
+
async stop() {
|
|
4463
|
+
stopped = true;
|
|
4464
|
+
await chain;
|
|
4465
|
+
},
|
|
4466
|
+
};
|
|
4467
|
+
}
|
|
4468
|
+
/** Re-send `working` this often during a long turn: Slack drops a processing status after an hour. */
|
|
4469
|
+
const CHANNEL_WORKING_REFRESH_MS = 50 * 60 * 1000;
|
|
4470
|
+
/**
|
|
4471
|
+
* Report a session's lifecycle to its connector in order, one call at a time, and re-send `working`
|
|
4472
|
+
* while a long turn runs. Every report bumps a sequence number and a refresh only fires while its
|
|
4473
|
+
* `working` is still the latest report, so a refresh can never land after the turn went idle.
|
|
4474
|
+
*/
|
|
4475
|
+
function makeChannelLifecycleReporter() {
|
|
4476
|
+
let chain = Promise.resolve();
|
|
4477
|
+
let seq = 0;
|
|
4478
|
+
let refreshTimer;
|
|
4479
|
+
let closed = false;
|
|
4480
|
+
const clearRefresh = () => {
|
|
4481
|
+
if (refreshTimer)
|
|
4482
|
+
clearTimeout(refreshTimer);
|
|
4483
|
+
refreshTimer = undefined;
|
|
4484
|
+
};
|
|
4485
|
+
const enqueue = (connector, state, ctx, reportSeq) => {
|
|
4486
|
+
chain = chain.then(async () => {
|
|
4487
|
+
if (ctx.refresh && reportSeq !== seq)
|
|
4488
|
+
return;
|
|
4489
|
+
try {
|
|
4490
|
+
await connector.lifecycle(state, ctx);
|
|
4491
|
+
}
|
|
4492
|
+
catch (error) {
|
|
4493
|
+
v3_1.logger.warn("chat.agent: channel lifecycle() failed; continuing", { error, state });
|
|
4494
|
+
}
|
|
4495
|
+
});
|
|
4496
|
+
return chain;
|
|
4497
|
+
};
|
|
4498
|
+
return {
|
|
4499
|
+
report(connector, state, ctx) {
|
|
4500
|
+
if (!connector?.lifecycle || closed)
|
|
4501
|
+
return Promise.resolve();
|
|
4502
|
+
if (state === "closed")
|
|
4503
|
+
closed = true;
|
|
4504
|
+
const reportSeq = ++seq;
|
|
4505
|
+
clearRefresh();
|
|
4506
|
+
const done = enqueue(connector, state, ctx, reportSeq);
|
|
4507
|
+
if (state === "working") {
|
|
4508
|
+
const arm = () => {
|
|
4509
|
+
refreshTimer = setTimeout(() => {
|
|
4510
|
+
if (reportSeq !== seq)
|
|
4511
|
+
return;
|
|
4512
|
+
void enqueue(connector, "working", { ...ctx, firstTurn: false, refresh: true }, reportSeq);
|
|
4513
|
+
arm();
|
|
4514
|
+
}, CHANNEL_WORKING_REFRESH_MS);
|
|
4515
|
+
refreshTimer.unref?.();
|
|
4516
|
+
};
|
|
4517
|
+
arm();
|
|
4518
|
+
}
|
|
4519
|
+
return done;
|
|
4520
|
+
},
|
|
4521
|
+
dispose: clearRefresh,
|
|
4522
|
+
};
|
|
4523
|
+
}
|
|
4524
|
+
/**
|
|
4525
|
+
* The assistant message with the given pending tool calls resolved as stopped (`output-error`), for
|
|
4526
|
+
* a stop that arrives while the session waits on a human decision. Undefined when none matched.
|
|
4527
|
+
*/
|
|
4528
|
+
function stopPendingToolCalls(message, toolCallIds, errorText) {
|
|
4529
|
+
let changed = false;
|
|
4530
|
+
const parts = (message.parts ?? []).map((part) => {
|
|
4531
|
+
if (part &&
|
|
4532
|
+
typeof part === "object" &&
|
|
4533
|
+
toolCallIds.has(part.toolCallId) &&
|
|
4534
|
+
part.state === "input-available") {
|
|
4535
|
+
changed = true;
|
|
4536
|
+
return { ...part, state: "output-error", errorText };
|
|
4537
|
+
}
|
|
4538
|
+
return part;
|
|
4539
|
+
});
|
|
4540
|
+
return changed ? { ...message, parts } : undefined;
|
|
4541
|
+
}
|
|
4542
|
+
/**
|
|
4543
|
+
* Feed a connector's native reply stream from the UI message stream: text deltas as they arrive, and
|
|
4544
|
+
* tool progress when the stream accepts it. `stop()` only refuses further chunks; the turn boundary
|
|
4545
|
+
* ends the stream itself with the turn's outcome.
|
|
4546
|
+
*/
|
|
4547
|
+
function makeChannelReplyStreamObserver(stream) {
|
|
4548
|
+
const toolNames = new Map();
|
|
4549
|
+
const toolTitles = new Map();
|
|
4550
|
+
let stopped = false;
|
|
4551
|
+
const safe = (fn) => {
|
|
4552
|
+
try {
|
|
4553
|
+
fn();
|
|
4554
|
+
}
|
|
4555
|
+
catch (error) {
|
|
4556
|
+
v3_1.logger.warn("chat.agent: channel reply stream threw; continuing", { error });
|
|
4557
|
+
}
|
|
4558
|
+
};
|
|
4559
|
+
return {
|
|
4560
|
+
observe(chunk) {
|
|
4561
|
+
if (stopped)
|
|
4562
|
+
return;
|
|
4563
|
+
const c = chunk;
|
|
4564
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4565
|
+
const delta = c.delta;
|
|
4566
|
+
safe(() => stream.text(delta));
|
|
4567
|
+
return;
|
|
4568
|
+
}
|
|
4569
|
+
if (!stream.progress || !c?.type || !c.toolCallId)
|
|
4570
|
+
return;
|
|
4571
|
+
const toolCallId = c.toolCallId;
|
|
4572
|
+
if (c.title)
|
|
4573
|
+
toolTitles.set(toolCallId, c.title);
|
|
4574
|
+
const title = toolTitles.get(toolCallId);
|
|
4575
|
+
const titled = title ? { title } : {};
|
|
4576
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4577
|
+
toolNames.set(toolCallId, c.toolName);
|
|
4578
|
+
}
|
|
4579
|
+
else if (c.type === "tool-input-available") {
|
|
4580
|
+
const toolName = c.toolName ?? toolNames.get(toolCallId) ?? "tool";
|
|
4581
|
+
toolNames.set(toolCallId, toolName);
|
|
4582
|
+
safe(() => stream.progress({ type: "tool-call", toolCallId, toolName, ...titled, input: c.input }));
|
|
4583
|
+
}
|
|
4584
|
+
else if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4585
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4586
|
+
safe(() => stream.progress({
|
|
4587
|
+
type: "tool-result",
|
|
4588
|
+
toolCallId,
|
|
4589
|
+
toolName,
|
|
4590
|
+
...titled,
|
|
4591
|
+
output: c.output,
|
|
4592
|
+
}));
|
|
4593
|
+
}
|
|
4594
|
+
else if (c.type === "tool-output-error") {
|
|
4595
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4596
|
+
const errorText = c.errorText ?? "tool failed";
|
|
4597
|
+
safe(() => stream.progress({ type: "tool-error", toolCallId, toolName, ...titled, errorText }));
|
|
4598
|
+
}
|
|
4599
|
+
},
|
|
4600
|
+
stop() {
|
|
4601
|
+
stopped = true;
|
|
4602
|
+
},
|
|
4603
|
+
};
|
|
4604
|
+
}
|
|
4605
|
+
function composeChannelObservers(observers) {
|
|
4606
|
+
return {
|
|
4607
|
+
observe(chunk) {
|
|
4608
|
+
for (const o of observers)
|
|
4609
|
+
o.observe(chunk);
|
|
4610
|
+
},
|
|
4611
|
+
async stop() {
|
|
4612
|
+
await Promise.all(observers.map((o) => o.stop()));
|
|
4613
|
+
},
|
|
4614
|
+
};
|
|
4615
|
+
}
|
|
4616
|
+
// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
|
|
4617
|
+
// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
|
|
4618
|
+
// the authoritative last write. Best-effort: an edit failure is logged, never fatal.
|
|
4619
|
+
const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
|
|
4620
|
+
function makeChannelStreamEditor(connector, channelEvent, ackRef, handle) {
|
|
4621
|
+
const outbound = connector.outbound ?? defaultChannelOutbound;
|
|
4622
|
+
let latest = "";
|
|
4623
|
+
let lastSent = "";
|
|
4624
|
+
let timer;
|
|
4625
|
+
let inFlightPromise;
|
|
4626
|
+
let stopped = false;
|
|
4627
|
+
const arm = () => {
|
|
4628
|
+
if (stopped || timer)
|
|
4629
|
+
return;
|
|
4630
|
+
timer = setTimeout(() => {
|
|
4631
|
+
timer = undefined;
|
|
4632
|
+
void edit();
|
|
4633
|
+
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
|
|
4634
|
+
};
|
|
4635
|
+
const edit = async () => {
|
|
4636
|
+
if (stopped || inFlightPromise)
|
|
4637
|
+
return;
|
|
4638
|
+
const text = latest;
|
|
4639
|
+
if (text === lastSent)
|
|
4640
|
+
return;
|
|
4641
|
+
const message = outbound({
|
|
4642
|
+
text,
|
|
4643
|
+
message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] },
|
|
4644
|
+
final: false,
|
|
4645
|
+
stopped: false,
|
|
4646
|
+
});
|
|
4647
|
+
if (!message)
|
|
4648
|
+
return;
|
|
4649
|
+
inFlightPromise = (async () => {
|
|
4650
|
+
try {
|
|
4651
|
+
await connector.send(message, {
|
|
4652
|
+
event: channelEvent.event,
|
|
4653
|
+
handle,
|
|
4654
|
+
deliveryId: channelEvent.deliveryId,
|
|
4655
|
+
previousRef: ackRef,
|
|
4656
|
+
mode: "stream",
|
|
4657
|
+
final: false,
|
|
4658
|
+
bound: false,
|
|
4659
|
+
});
|
|
4660
|
+
lastSent = text;
|
|
4661
|
+
}
|
|
4662
|
+
catch (error) {
|
|
4663
|
+
v3_1.logger.warn("chat.agent: channel stream edit failed", { error });
|
|
4664
|
+
}
|
|
4665
|
+
finally {
|
|
4666
|
+
inFlightPromise = undefined;
|
|
4667
|
+
if (!stopped && latest !== lastSent)
|
|
4668
|
+
arm();
|
|
4669
|
+
}
|
|
4670
|
+
})();
|
|
4671
|
+
await inFlightPromise;
|
|
4672
|
+
};
|
|
4673
|
+
return {
|
|
4674
|
+
observe(chunk) {
|
|
4675
|
+
if (stopped)
|
|
4676
|
+
return;
|
|
4677
|
+
const c = chunk;
|
|
4678
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4679
|
+
latest += c.delta;
|
|
4680
|
+
arm();
|
|
4681
|
+
}
|
|
4682
|
+
},
|
|
4683
|
+
async stop() {
|
|
4684
|
+
stopped = true;
|
|
4685
|
+
if (timer) {
|
|
4686
|
+
clearTimeout(timer);
|
|
4687
|
+
timer = undefined;
|
|
4688
|
+
}
|
|
4689
|
+
if (inFlightPromise) {
|
|
4690
|
+
try {
|
|
4691
|
+
await inFlightPromise;
|
|
4692
|
+
}
|
|
4693
|
+
catch { }
|
|
4694
|
+
}
|
|
4695
|
+
},
|
|
4696
|
+
};
|
|
4697
|
+
}
|
|
4698
|
+
/**
|
|
4699
|
+
* Wrap the reply stream so it debounce-edits the channel message as it streams.
|
|
4700
|
+
* Both `flush` (the stream completed) and `cancel` (the stream was aborted or
|
|
4701
|
+
* cancelled downstream) stop the editor, so a pending debounce timer can never
|
|
4702
|
+
* fire an edit after the turn ended, onto a message the next turn already owns.
|
|
4703
|
+
*/
|
|
4704
|
+
/**
|
|
4705
|
+
* Feed every chunk of a stream to `editor`. By default the editor is stopped when the stream ends or
|
|
4706
|
+
* is cancelled (one stream per turn, the automatic pipe). With `stopOnEnd: false` the editor outlives
|
|
4707
|
+
* the stream, for a turn whose run() pipes several streams through `chat.pipe()`; the turn boundary
|
|
4708
|
+
* stops it.
|
|
4709
|
+
*/
|
|
4710
|
+
function makeChannelStreamTap(editor, options = {}) {
|
|
4711
|
+
const stopOnEnd = options.stopOnEnd ?? true;
|
|
4712
|
+
const transformer = {
|
|
4713
|
+
transform(chunk, controller) {
|
|
4714
|
+
editor.observe(chunk);
|
|
4715
|
+
controller.enqueue(chunk);
|
|
4716
|
+
},
|
|
4717
|
+
async flush() {
|
|
4718
|
+
if (stopOnEnd)
|
|
4719
|
+
await editor.stop();
|
|
4720
|
+
},
|
|
4721
|
+
async cancel() {
|
|
4722
|
+
if (stopOnEnd)
|
|
4723
|
+
await editor.stop();
|
|
4724
|
+
},
|
|
4725
|
+
};
|
|
4726
|
+
return new TransformStream(transformer);
|
|
4727
|
+
}
|
|
4728
|
+
/**
|
|
4729
|
+
* Per-chat latch for the channel egress that runs AFTER `trigger:turn-complete` is written (the
|
|
4730
|
+
* final `send()` and the done/error reactions). The run loop opens it before writing the chunk and
|
|
4731
|
+
* settles it once that egress has finished, so a caller that saw the chunk can wait for the
|
|
4732
|
+
* connector to be quiet instead of guessing with timers. One latch per chat at a time; a chat with
|
|
4733
|
+
* no open latch is quiet.
|
|
4734
|
+
*/
|
|
4735
|
+
const channelEgressLatches = new Map();
|
|
4736
|
+
function openChannelEgressLatch(chatId) {
|
|
4737
|
+
let settle;
|
|
4738
|
+
const promise = new Promise((resolve) => (settle = resolve));
|
|
4739
|
+
channelEgressLatches.set(chatId, { promise, settle });
|
|
4740
|
+
}
|
|
4741
|
+
function settleChannelEgressLatch(chatId) {
|
|
4742
|
+
const latch = channelEgressLatches.get(chatId);
|
|
4743
|
+
if (!latch)
|
|
4744
|
+
return;
|
|
4745
|
+
channelEgressLatches.delete(chatId);
|
|
4746
|
+
latch.settle();
|
|
4747
|
+
}
|
|
4748
|
+
/** Resolves once the post-turn channel egress for `chatId` has finished (immediately when none is running). */
|
|
4749
|
+
async function __waitForChannelEgressForTests(chatId) {
|
|
4750
|
+
await channelEgressLatches.get(chatId)?.promise;
|
|
4751
|
+
}
|
|
4752
|
+
function chatCustomAgent(options) {
|
|
4753
|
+
const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
|
|
4754
|
+
const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
|
|
4755
|
+
const parseClientDataSync = clientDataSchema
|
|
4756
|
+
? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
|
|
4757
|
+
: undefined;
|
|
4758
|
+
const task = (0, shared_js_1.createTask)({
|
|
4759
|
+
...restOptions,
|
|
4760
|
+
triggerSource: "agent",
|
|
4761
|
+
agentConfig: { type: "ai-sdk-chat" },
|
|
4762
|
+
run: async (payload, runOptions) => {
|
|
4763
|
+
// Bind the run to its backing Session so module-level helpers
|
|
4764
|
+
// (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
|
|
4765
|
+
// resolve to this chat's `.in` / `.out` channels. Address
|
|
4766
|
+
// everywhere by `payload.chatId` (the session externalId) so the
|
|
4767
|
+
// agent's writes and the transport's reads converge on the same
|
|
4768
|
+
// S2 stream key + waitpoint key.
|
|
4769
|
+
//
|
|
4770
|
+
// The Session row is created server-side by `POST /sessions` (or
|
|
4771
|
+
// `chat.createStartSessionAction`) before this run is triggered.
|
|
4772
|
+
// No client-side upsert needed.
|
|
4773
|
+
locals_js_1.locals.set(chatSessionHandleKey, sessions_js_1.sessions.open(payload.chatId));
|
|
4774
|
+
locals_js_1.locals.set(chatExternalIdKey, payload.chatId);
|
|
4775
|
+
locals_js_1.locals.set(chatAgentRunContextKey, runOptions.ctx);
|
|
4776
|
+
locals_js_1.locals.set(chatCustomAgentRunKey, true);
|
|
4777
|
+
if (parseClientData && parseClientDataSync) {
|
|
4778
|
+
locals_js_1.locals.set(chatCustomAgentClientDataParserKey, {
|
|
4779
|
+
parse: parseClientData,
|
|
4780
|
+
parseSync: parseClientDataSync,
|
|
4781
|
+
});
|
|
4782
|
+
}
|
|
4783
|
+
if (onClientDataValidationError) {
|
|
4784
|
+
locals_js_1.locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
|
|
4785
|
+
}
|
|
4786
|
+
locals_js_1.locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
|
|
4787
|
+
// Initialize the turn-complete trim slot so `chat.writeTurnComplete`
|
|
4788
|
+
// trims `session.out` back to the previous turn boundary. Without
|
|
4789
|
+
// this the slot is undefined and the trim never runs, so `.out`
|
|
4790
|
+
// grows without bound for the whole custom-agent surface.
|
|
4791
|
+
locals_js_1.locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
|
|
4792
|
+
(0, streams_js_1.markChatAgentRunForStreamsWarning)();
|
|
4793
|
+
v3_1.taskContext.setConversationId(payload.chatId);
|
|
4794
|
+
stampConversationIdOnActiveSpan(payload.chatId);
|
|
4795
|
+
await installChatInputRouter(payload.chatId, {
|
|
4796
|
+
resuming: Boolean(payload.continuation),
|
|
4797
|
+
});
|
|
4798
|
+
// A custom agent's loop is the customer's, so there is no exit site the
|
|
4799
|
+
// SDK controls. Perform a requested close when `run()` returns, whatever
|
|
4800
|
+
// shape the loop had. Idempotent, so the createSession iterator having
|
|
4801
|
+
// already closed on its own exit costs nothing.
|
|
4802
|
+
const withClose = async (result) => {
|
|
4803
|
+
try {
|
|
4804
|
+
return await result;
|
|
4805
|
+
}
|
|
4806
|
+
finally {
|
|
4807
|
+
await performChatClose();
|
|
4808
|
+
}
|
|
4809
|
+
};
|
|
4810
|
+
// Keep the schema-free path identical to the original custom-agent
|
|
4811
|
+
// wrapper, including when userRun starts executing.
|
|
4812
|
+
if (!parseClientData) {
|
|
4813
|
+
return withClose(userRun(payload, runOptions));
|
|
4814
|
+
}
|
|
4815
|
+
const isHandoverBoot = payload.trigger === "handover-prepare";
|
|
4816
|
+
const isMessagelessBoot = payload.trigger === "preload" ||
|
|
4817
|
+
(payload.continuation === true &&
|
|
4818
|
+
payload.message === undefined &&
|
|
4819
|
+
payload.trigger !== "action" &&
|
|
4820
|
+
payload.trigger !== "regenerate-message" &&
|
|
4821
|
+
!isHandoverBoot);
|
|
4822
|
+
const validated = await validateChatCustomAgentPayload(payload, {
|
|
4823
|
+
// Preload and continuation boots do not represent a submitted turn,
|
|
4824
|
+
// so there is no sender waiting for a terminal frame. Handover errors
|
|
4825
|
+
// must be written after the warm response flushes and signals below.
|
|
4826
|
+
writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
|
|
4827
|
+
});
|
|
4828
|
+
if (validated.ok) {
|
|
4829
|
+
return withClose(userRun(validated.payload, runOptions));
|
|
4830
|
+
}
|
|
4831
|
+
if (isHandoverBoot) {
|
|
4832
|
+
const signal = await waitForHandover({
|
|
4833
|
+
payload,
|
|
4834
|
+
timeout: "1h",
|
|
4835
|
+
spanName: "waiting for handover signal (invalid clientData)",
|
|
4836
|
+
});
|
|
4837
|
+
if (!signal || signal.kind === "handover-skip") {
|
|
4838
|
+
return;
|
|
4839
|
+
}
|
|
4840
|
+
// The head-start writer flushes before sending this signal. Writing
|
|
4841
|
+
// the terminal error now preserves stream order and closes the stitch.
|
|
4842
|
+
await writeChatCustomAgentClientDataErrorToStream(payload);
|
|
4843
|
+
return;
|
|
4844
|
+
}
|
|
4845
|
+
// The Session base payload is sticky across continuation runs. If it is
|
|
4846
|
+
// invalid, returning here would boot the same bad metadata again on the
|
|
4847
|
+
// next message. Stay attached and wait for a valid wire frame instead.
|
|
4848
|
+
const next = await messagesInput.waitWithIdleTimeout({
|
|
4849
|
+
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
4850
|
+
timeout: "1h",
|
|
4851
|
+
spanName: "waiting for valid clientData",
|
|
4852
|
+
});
|
|
4853
|
+
if (!next.ok || next.output.trigger === "close") {
|
|
4854
|
+
return;
|
|
4855
|
+
}
|
|
4856
|
+
// Normal input frames omit run-level boot context. Carry it forward so
|
|
4857
|
+
// a continuation still tells the custom loop to restore prior state.
|
|
4858
|
+
const recoveredPayload = {
|
|
4859
|
+
...next.output,
|
|
4860
|
+
continuation: next.output.continuation ?? payload.continuation,
|
|
4861
|
+
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
4862
|
+
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
4863
|
+
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
4864
|
+
};
|
|
4865
|
+
return withClose(userRun(recoveredPayload, runOptions));
|
|
4866
|
+
},
|
|
4867
|
+
});
|
|
4868
|
+
// Register clientDataSchema so the CLI converts it to JSONSchema
|
|
4869
|
+
// and stores it as payloadSchema — used by the Playground UI
|
|
4870
|
+
if (clientDataSchema) {
|
|
4871
|
+
v3_1.resourceCatalog.updateTaskMetadata(options.id, {
|
|
4872
|
+
schema: clientDataSchema,
|
|
4873
|
+
});
|
|
4874
|
+
}
|
|
4875
|
+
return task;
|
|
4876
|
+
}
|
|
4877
|
+
function chatAgent(options) {
|
|
4878
|
+
const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, events, channels, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, telemetry: telemetryOptions, ...restOptions } = options;
|
|
4879
|
+
const recordUserMessage = telemetryOptions?.recordUserMessage ?? true;
|
|
4880
|
+
const recordClientData = telemetryOptions?.recordClientData ?? true;
|
|
4881
|
+
if (hydrateMessages) {
|
|
4882
|
+
if (storage) {
|
|
4883
|
+
throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
|
|
4884
|
+
"`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
|
|
4885
|
+
"change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
|
|
4886
|
+
}
|
|
4887
|
+
if (typeof (transcriptStorageOverride ?? transcriptStorage_js_1.defaultStorage).loadContext === "function") {
|
|
4888
|
+
throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
|
|
4889
|
+
"`loadContext`. Both would own the model's context; keep one.");
|
|
4890
|
+
}
|
|
4891
|
+
warnHydrateMessagesDeprecatedOnce(options.id);
|
|
4892
|
+
}
|
|
4893
|
+
const parseClientData = clientDataSchema ? (0, v3_1.getSchemaParseFn)(clientDataSchema) : undefined;
|
|
4894
|
+
const parseAction = actionSchema ? (0, v3_1.getSchemaParseFn)(actionSchema) : undefined;
|
|
4895
|
+
// chat.agent does not expose generic retry options (see docstring on
|
|
4896
|
+
// `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
|
|
4897
|
+
// `oomMachine` is set we allow one retry on a larger machine; otherwise
|
|
4898
|
+
// we keep the historical no-retry default.
|
|
4899
|
+
const retry = oomMachine
|
|
4900
|
+
? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
|
|
4901
|
+
: { maxAttempts: 1 };
|
|
4902
|
+
const task = (0, shared_js_1.createTask)({
|
|
3884
4903
|
...restOptions,
|
|
3885
4904
|
retry,
|
|
3886
4905
|
triggerSource: "agent",
|
|
@@ -4024,6 +5043,69 @@ function chatAgent(options) {
|
|
|
4024
5043
|
: undefined;
|
|
4025
5044
|
let transcriptShadow = (0, transcriptStorage_js_1.createTranscriptShadow)([]);
|
|
4026
5045
|
let bootTranscriptState = null;
|
|
5046
|
+
/**
|
|
5047
|
+
* The channel this session answers into (see TranscriptChannelBinding). Set by a channel turn
|
|
5048
|
+
* whose connector defines `bind`, restored from runtime state at boot, saved on every changeset.
|
|
5049
|
+
*/
|
|
5050
|
+
let sessionChannelBinding;
|
|
5051
|
+
const channelDownloader = channels
|
|
5052
|
+
? makeChannelDownloader(channels, (connectorId) => sessionChannelBinding?.connectorId === connectorId
|
|
5053
|
+
? sessionChannelBinding.handle
|
|
5054
|
+
: undefined)
|
|
5055
|
+
: undefined;
|
|
5056
|
+
locals_js_1.locals.set(chatChannelDownloadKey, channelDownloader);
|
|
5057
|
+
/**
|
|
5058
|
+
* Tool calls that already have channel controls posted. A turn that resumes with one of
|
|
5059
|
+
* several pending calls resolved still carries the others as pending; without this the
|
|
5060
|
+
* controls would be posted again, and the model's final text would lose to them. A
|
|
5061
|
+
* continuation worker seeds it at boot from the calls still pending in the restored
|
|
5062
|
+
* transcript of a channel-bound session, since the worker before it posted their controls.
|
|
5063
|
+
*/
|
|
5064
|
+
const channelRenderedToolCallIds = new Set();
|
|
5065
|
+
/** Serializes `lifecycle()` reports for the session's channel across turns. */
|
|
5066
|
+
const channelLifecycle = makeChannelLifecycleReporter();
|
|
5067
|
+
/**
|
|
5068
|
+
* Pending calls a channel stop resolved as stopped on this worker. A click on their old controls
|
|
5069
|
+
* is dropped even when the transcript was hydrated again from a store that never saw the stop.
|
|
5070
|
+
*/
|
|
5071
|
+
const channelStoppedToolCallIds = new Set();
|
|
5072
|
+
/** When the latest channel turn started, so a stop pressed before it is recognized as stale. */
|
|
5073
|
+
let lastChannelTurnStartedAt;
|
|
5074
|
+
/** When the latest channel turn ended, so a stop pressed while it ran is not run as a new turn. */
|
|
5075
|
+
let lastChannelTurnEndedAt;
|
|
5076
|
+
/** The turn reported its end state: the binding no longer marks the surface as working. */
|
|
5077
|
+
const clearChannelWorking = () => {
|
|
5078
|
+
if (!sessionChannelBinding?.working)
|
|
5079
|
+
return;
|
|
5080
|
+
const { working: _working, ...rest } = sessionChannelBinding;
|
|
5081
|
+
void _working;
|
|
5082
|
+
sessionChannelBinding = rest;
|
|
5083
|
+
};
|
|
5084
|
+
/** The turn's reply stream ended: the binding no longer names it for recovery. */
|
|
5085
|
+
const clearOpenReplyStream = () => {
|
|
5086
|
+
if (!sessionChannelBinding?.stream)
|
|
5087
|
+
return;
|
|
5088
|
+
const { stream: _stream, ...rest } = sessionChannelBinding;
|
|
5089
|
+
void _stream;
|
|
5090
|
+
sessionChannelBinding = rest;
|
|
5091
|
+
};
|
|
5092
|
+
/** Tell the bound channel the conversation is over (`chat.close()` or a close signal). */
|
|
5093
|
+
const reportChannelClosed = async () => {
|
|
5094
|
+
const binding = sessionChannelBinding;
|
|
5095
|
+
if (!binding)
|
|
5096
|
+
return;
|
|
5097
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5098
|
+
await channelLifecycle.report(connector, "closed", {
|
|
5099
|
+
handle: binding.handle,
|
|
5100
|
+
bound: true,
|
|
5101
|
+
firstTurn: false,
|
|
5102
|
+
});
|
|
5103
|
+
};
|
|
5104
|
+
/** Provenance of the turn or action being processed, for the storage context. */
|
|
5105
|
+
let currentTurnSource = "client";
|
|
5106
|
+
let currentTurnConnectorId;
|
|
5107
|
+
/** True once `onAction` returned `chat.turn()` for the action being processed. */
|
|
5108
|
+
let turnIsActionTurn = false;
|
|
4027
5109
|
/**
|
|
4028
5110
|
* True while the model lane holds a compaction summary, so it cannot be
|
|
4029
5111
|
* rebuilt from the transcript and has to be persisted as state. Reset
|
|
@@ -4086,9 +5168,14 @@ function chatAgent(options) {
|
|
|
4086
5168
|
const transcriptIds = new Set(opts.messages.map((message) => message.id));
|
|
4087
5169
|
const markerIds = new Set((0, steeringContext_js_1.steeringMarkers)(opts.messages).map((marker) => marker.id));
|
|
4088
5170
|
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 ||
|
|
5171
|
+
const runtimeState = laneCompacted ||
|
|
5172
|
+
laneInjections.length > 0 ||
|
|
5173
|
+
queued.length > 0 ||
|
|
5174
|
+
steering.length > 0 ||
|
|
5175
|
+
sessionChannelBinding
|
|
4090
5176
|
? {
|
|
4091
5177
|
v: 1,
|
|
5178
|
+
...(sessionChannelBinding ? { channelBinding: sessionChannelBinding } : {}),
|
|
4092
5179
|
...(laneCompacted
|
|
4093
5180
|
? {
|
|
4094
5181
|
compaction: {
|
|
@@ -4118,7 +5205,9 @@ function chatAgent(options) {
|
|
|
4118
5205
|
chatId: payload.chatId,
|
|
4119
5206
|
clientData: opts.clientData,
|
|
4120
5207
|
turn: opts.turn,
|
|
4121
|
-
trigger: opts.trigger,
|
|
5208
|
+
trigger: opts.trigger === "action" && turnIsActionTurn ? "action-turn" : opts.trigger,
|
|
5209
|
+
source: currentTurnSource,
|
|
5210
|
+
...(currentTurnConnectorId ? { connectorId: currentTurnConnectorId } : {}),
|
|
4122
5211
|
runId: ctx.run.id,
|
|
4123
5212
|
ctx,
|
|
4124
5213
|
}, {
|
|
@@ -4201,6 +5290,9 @@ function chatAgent(options) {
|
|
|
4201
5290
|
let replayedPartial;
|
|
4202
5291
|
let replayedPartialRaw;
|
|
4203
5292
|
let replayedInTail = [];
|
|
5293
|
+
// Channel deliveries on the same tail. They are not "messages" for the recovery hooks, but
|
|
5294
|
+
// they are turns the predecessor owed, and they have to dispatch in seq order with the rest.
|
|
5295
|
+
let replayedChannelInTail = [];
|
|
4204
5296
|
// Wire payloads to dispatch as turns before the regular session.in
|
|
4205
5297
|
// pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
|
|
4206
5298
|
// default, `inFlightUsers`). The turn-loop checks this queue ahead of
|
|
@@ -4331,9 +5423,11 @@ function chatAgent(options) {
|
|
|
4331
5423
|
}
|
|
4332
5424
|
bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
|
|
4333
5425
|
try {
|
|
4334
|
-
|
|
5426
|
+
const tail = await replaySessionInTailWithChannels(payload.chatId, {
|
|
4335
5427
|
lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
|
|
4336
5428
|
});
|
|
5429
|
+
replayedInTail = tail.messages;
|
|
5430
|
+
replayedChannelInTail = tail.channelEvents;
|
|
4337
5431
|
}
|
|
4338
5432
|
catch (error) {
|
|
4339
5433
|
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 +5448,10 @@ function chatAgent(options) {
|
|
|
4354
5448
|
}
|
|
4355
5449
|
// ── session.in router ──────────────────────────────────────────
|
|
4356
5450
|
//
|
|
4357
|
-
const recoveredSeqNums =
|
|
5451
|
+
const recoveredSeqNums = [
|
|
5452
|
+
...replayedInTail.map((r) => r.seqNum),
|
|
5453
|
+
...replayedChannelInTail.map((r) => r.seqNum),
|
|
5454
|
+
];
|
|
4358
5455
|
await installChatInputRouter(payload.chatId, {
|
|
4359
5456
|
fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
|
|
4360
5457
|
recoveredSeqNums,
|
|
@@ -4371,6 +5468,9 @@ function chatAgent(options) {
|
|
|
4371
5468
|
// predecessor chose to end before processing the message;
|
|
4372
5469
|
// those route through the normal continuation-wait path.
|
|
4373
5470
|
const hasRecoveredState = partialAssistant !== undefined;
|
|
5471
|
+
if (couldHavePriorState && hasRecoveredState) {
|
|
5472
|
+
locals_js_1.locals.set(chatChannelRecoveryPendingKey, { value: true });
|
|
5473
|
+
}
|
|
4374
5474
|
let hookChain;
|
|
4375
5475
|
let hookRecoveredTurns;
|
|
4376
5476
|
let hookBeforeBoot;
|
|
@@ -4441,10 +5541,18 @@ function chatAgent(options) {
|
|
|
4441
5541
|
// user is re-dispatched as a fresh turn instead.
|
|
4442
5542
|
let seedChain;
|
|
4443
5543
|
let recoveredEntries;
|
|
5544
|
+
// The orphan-partial splice pairs the partial assistant with the oldest unprocessed record,
|
|
5545
|
+
// which only makes sense when that record is the browser message it was answering. When a
|
|
5546
|
+
// channel delivery is older than every browser message, the partial belongs to it instead, and
|
|
5547
|
+
// splicing the first browser message ahead of it would reorder the conversation.
|
|
5548
|
+
const oldestTailSeq = Math.min(...replayedInTail.map((r) => r.seqNum), ...replayedChannelInTail.map((r) => r.seqNum));
|
|
5549
|
+
const partialFollowsBrowserMessage = partialAssistant !== undefined &&
|
|
5550
|
+
inFlightUsers.length > 1 &&
|
|
5551
|
+
(replayedChannelInTail.length === 0 || replayedInTail[0]?.seqNum === oldestTailSeq);
|
|
4444
5552
|
if (hookChain !== undefined) {
|
|
4445
5553
|
seedChain = hookChain;
|
|
4446
5554
|
}
|
|
4447
|
-
else if (
|
|
5555
|
+
else if (partialFollowsBrowserMessage) {
|
|
4448
5556
|
seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
|
|
4449
5557
|
}
|
|
4450
5558
|
else {
|
|
@@ -4464,7 +5572,7 @@ function chatAgent(options) {
|
|
|
4464
5572
|
seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
|
|
4465
5573
|
}));
|
|
4466
5574
|
}
|
|
4467
|
-
else if (
|
|
5575
|
+
else if (partialFollowsBrowserMessage) {
|
|
4468
5576
|
recoveredEntries = replayedInTail
|
|
4469
5577
|
.slice(1)
|
|
4470
5578
|
.map((r) => ({ message: r.message, seqNum: r.seqNum }));
|
|
@@ -4501,6 +5609,7 @@ function chatAgent(options) {
|
|
|
4501
5609
|
metadataById.set(entry.message.id, entry.metadata);
|
|
4502
5610
|
}
|
|
4503
5611
|
const dispatchedRecoveredSeqs = new Set();
|
|
5612
|
+
const toInject = [];
|
|
4504
5613
|
for (const { message: msg, seqNum } of recoveredEntries) {
|
|
4505
5614
|
if (wireMessageId && msg.id === wireMessageId)
|
|
4506
5615
|
continue;
|
|
@@ -4517,13 +5626,37 @@ function chatAgent(options) {
|
|
|
4517
5626
|
continuation: payload.continuation,
|
|
4518
5627
|
previousRunId: payload.previousRunId,
|
|
4519
5628
|
};
|
|
5629
|
+
toInject.push({ injectedPayload, seqNum });
|
|
5630
|
+
}
|
|
5631
|
+
// Channel deliveries the predecessor never processed dispatch too, interleaved with the
|
|
5632
|
+
// browser messages by seq_num so a Slack message that arrived before a browser message
|
|
5633
|
+
// still runs before it. A delivery the boot payload itself carries is not queued twice.
|
|
5634
|
+
const wireDeliveryId = payload.channelEvent
|
|
5635
|
+
?.deliveryId;
|
|
5636
|
+
for (const entry of replayedChannelInTail) {
|
|
5637
|
+
const entryDeliveryId = entry.channelEvent.deliveryId;
|
|
5638
|
+
if (wireDeliveryId && entryDeliveryId === wireDeliveryId)
|
|
5639
|
+
continue;
|
|
5640
|
+
const injectedPayload = {
|
|
5641
|
+
chatId: payload.chatId,
|
|
5642
|
+
sessionId: payload.sessionId,
|
|
5643
|
+
metadata: entry.metadata ?? payload.metadata,
|
|
5644
|
+
trigger: "submit-message",
|
|
5645
|
+
channelEvent: entry.channelEvent,
|
|
5646
|
+
continuation: payload.continuation,
|
|
5647
|
+
previousRunId: payload.previousRunId,
|
|
5648
|
+
};
|
|
5649
|
+
toInject.push({ injectedPayload, seqNum: entry.seqNum });
|
|
5650
|
+
}
|
|
5651
|
+
toInject.sort((a, b) => (a.seqNum ?? Infinity) - (b.seqNum ?? Infinity));
|
|
5652
|
+
for (const { injectedPayload, seqNum } of toInject) {
|
|
4520
5653
|
bootInjectedQueue.push(injectedPayload);
|
|
4521
5654
|
if (seqNum !== undefined) {
|
|
4522
5655
|
recoveredSeqByPayload.set(injectedPayload, seqNum);
|
|
4523
5656
|
dispatchedRecoveredSeqs.add(seqNum);
|
|
4524
5657
|
}
|
|
4525
5658
|
}
|
|
4526
|
-
for (const entry of replayedInTail) {
|
|
5659
|
+
for (const entry of [...replayedInTail, ...replayedChannelInTail]) {
|
|
4527
5660
|
if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
|
|
4528
5661
|
chatInputRouter().settleRecovered(entry.seqNum);
|
|
4529
5662
|
}
|
|
@@ -4566,15 +5699,47 @@ function chatAgent(options) {
|
|
|
4566
5699
|
catch (error) {
|
|
4567
5700
|
v3_1.logger.warn("chat.agent: tools() resolver threw at boot; restored history converted without toModelOutput", { error: error instanceof Error ? error.message : String(error) });
|
|
4568
5701
|
}
|
|
4569
|
-
}
|
|
4570
|
-
try {
|
|
4571
|
-
const bootRuntimeState = (0, transcriptStorage_js_1.parseTranscriptRuntimeState)(bootTranscriptState);
|
|
4572
|
-
locals_js_1.locals.set(chatSteeringInjectionsKey, new Map((bootRuntimeState?.steering ?? []).map((entry) => [entry.id, entry])));
|
|
4573
|
-
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
4574
|
-
const restored = await (0, transcriptStorage_js_1.restoreModelLane)(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
|
|
4575
|
-
accumulatedMessages = restored.messages;
|
|
4576
|
-
laneCompacted = restored.compacted;
|
|
4577
|
-
laneInjections = restored.injections;
|
|
5702
|
+
}
|
|
5703
|
+
try {
|
|
5704
|
+
const bootRuntimeState = (0, transcriptStorage_js_1.parseTranscriptRuntimeState)(bootTranscriptState);
|
|
5705
|
+
locals_js_1.locals.set(chatSteeringInjectionsKey, new Map((bootRuntimeState?.steering ?? []).map((entry) => [entry.id, entry])));
|
|
5706
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
5707
|
+
const restored = await (0, transcriptStorage_js_1.restoreModelLane)(accumulatedUIMessages, bootRuntimeState, (messages) => toModelMessages(messages));
|
|
5708
|
+
accumulatedMessages = restored.messages;
|
|
5709
|
+
laneCompacted = restored.compacted;
|
|
5710
|
+
laneInjections = restored.injections;
|
|
5711
|
+
sessionChannelBinding = bootRuntimeState?.channelBinding;
|
|
5712
|
+
const interruptedStream = sessionChannelBinding?.stream;
|
|
5713
|
+
if (interruptedStream && sessionChannelBinding) {
|
|
5714
|
+
const binding = sessionChannelBinding;
|
|
5715
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5716
|
+
clearOpenReplyStream();
|
|
5717
|
+
if (connector?.endInterruptedStream) {
|
|
5718
|
+
try {
|
|
5719
|
+
await connector.endInterruptedStream(interruptedStream, {
|
|
5720
|
+
handle: binding.handle,
|
|
5721
|
+
bound: true,
|
|
5722
|
+
});
|
|
5723
|
+
}
|
|
5724
|
+
catch (error) {
|
|
5725
|
+
v3_1.logger.warn("chat.agent: ending the interrupted reply stream failed", { error });
|
|
5726
|
+
}
|
|
5727
|
+
}
|
|
5728
|
+
}
|
|
5729
|
+
if (sessionChannelBinding?.working) {
|
|
5730
|
+
const binding = sessionChannelBinding;
|
|
5731
|
+
clearChannelWorking();
|
|
5732
|
+
await channelLifecycle.report(channels?.find((c) => c.id === binding.connectorId), "idle", { handle: binding.handle, bound: true, firstTurn: false });
|
|
5733
|
+
}
|
|
5734
|
+
if (sessionChannelBinding) {
|
|
5735
|
+
for (const message of accumulatedUIMessages) {
|
|
5736
|
+
if (message.role !== "assistant")
|
|
5737
|
+
continue;
|
|
5738
|
+
for (const call of pendingToolCallsInMessage(message)) {
|
|
5739
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
5740
|
+
}
|
|
5741
|
+
}
|
|
5742
|
+
}
|
|
4578
5743
|
if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
|
|
4579
5744
|
locals_js_1.locals.set(chatBackgroundQueueKey, [
|
|
4580
5745
|
...(locals_js_1.locals.get(chatBackgroundQueueKey) ?? []),
|
|
@@ -4961,26 +6126,434 @@ function chatAgent(options) {
|
|
|
4961
6126
|
// Declared here so the finally can detach it — a handler leaked past
|
|
4962
6127
|
// its turn duplicates every mid-stream message into the shared buffer.
|
|
4963
6128
|
let turnMsgSub;
|
|
6129
|
+
let turnControlSub;
|
|
4964
6130
|
let capturedPartialResponse;
|
|
4965
6131
|
let responseCommitted = false;
|
|
4966
6132
|
const turnBufferedChunks = [];
|
|
6133
|
+
let channelConn;
|
|
6134
|
+
let channelWorkingReaction;
|
|
6135
|
+
let channelWireEvent;
|
|
6136
|
+
/** The connector's posting handle for this turn: from `bind(event)` on a channel turn, else the session binding. */
|
|
6137
|
+
let channelHandle;
|
|
6138
|
+
/** True when no channel delivery started this turn and egress goes through the session binding. */
|
|
6139
|
+
let boundTurn = false;
|
|
6140
|
+
let channelAckRef;
|
|
6141
|
+
let channelStreamEditor;
|
|
6142
|
+
let droppedStaleInteraction = false;
|
|
6143
|
+
/** True when the connector mapped this delivery to an empty message: no turn runs. */
|
|
6144
|
+
let droppedEmptyMessage = false;
|
|
6145
|
+
let droppedUnknownConnector = false;
|
|
6146
|
+
/** True when this delivery was a stop the connector drops while idle: no turn runs. */
|
|
6147
|
+
let droppedIdleControl = false;
|
|
6148
|
+
/** This turn's native reply stream (`delivery: "stream"` with a `stream()` hook). */
|
|
6149
|
+
let channelReplyStream;
|
|
6150
|
+
/** True once this turn reported `working`, so its end reports the outcome. */
|
|
6151
|
+
let channelLifecycleStarted = false;
|
|
6152
|
+
/** When this turn started, to tell a stop meant for an earlier turn from one for this turn. */
|
|
6153
|
+
const channelTurnStartedAt = Date.now();
|
|
6154
|
+
/** True when this turn is a channel or bound turn that runs (not a dropped delivery). */
|
|
6155
|
+
let channelTurnRan = false;
|
|
6156
|
+
/** True when this turn resolves a pending tool call from a channel interaction callback. */
|
|
6157
|
+
let channelInteractionResolved = false;
|
|
6158
|
+
let channelFinalAnswerPosted = false;
|
|
6159
|
+
let channelFinalSendFailed = false;
|
|
6160
|
+
/** The stream-level error (an `error` chunk) this turn ended on, if any; posted to the channel in place of an empty reply. */
|
|
6161
|
+
let channelStreamErrorText;
|
|
6162
|
+
/** The original error behind that chunk, captured from `toUIMessageStream`'s `onError` when auto-piping. */
|
|
6163
|
+
let channelStreamError;
|
|
6164
|
+
/** The text this turn streamed, so the channel reply is this turn's answer and not the whole message. */
|
|
6165
|
+
let channelTurnText;
|
|
4967
6166
|
try {
|
|
4968
6167
|
// Extract turn-level context before entering the span. Slim
|
|
4969
6168
|
// wire: at most one delta message per record. `headStartMessages`
|
|
4970
6169
|
// is consumed at boot only (via `payload.headStartMessages`)
|
|
4971
6170
|
// and intentionally discarded here.
|
|
4972
|
-
const
|
|
6171
|
+
const clickedAction = channelClickAction(channels, currentWirePayload.channelEvent);
|
|
6172
|
+
let actionChannel;
|
|
6173
|
+
if (clickedAction !== undefined) {
|
|
6174
|
+
const { channelEvent: clicked, ...withoutChannelEvent } = currentWirePayload;
|
|
6175
|
+
const click = clicked;
|
|
6176
|
+
actionChannel = {
|
|
6177
|
+
connectorId: click.connectorId,
|
|
6178
|
+
provider: channels?.find((c) => c.id === click.connectorId)?.source ?? "custom",
|
|
6179
|
+
event: click.event,
|
|
6180
|
+
deliveryId: click.deliveryId,
|
|
6181
|
+
};
|
|
6182
|
+
currentWirePayload = {
|
|
6183
|
+
...withoutChannelEvent,
|
|
6184
|
+
trigger: "action",
|
|
6185
|
+
action: clickedAction,
|
|
6186
|
+
actionSource: "channel",
|
|
6187
|
+
};
|
|
6188
|
+
}
|
|
6189
|
+
const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, channelEvent: wireChannelEvent, ...restWire } = currentWirePayload;
|
|
4973
6190
|
void _hsm;
|
|
4974
|
-
|
|
4975
|
-
|
|
6191
|
+
channelWireEvent = wireChannelEvent;
|
|
6192
|
+
let effectiveIncomingMessage = incomingMessage;
|
|
6193
|
+
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
6194
|
+
turnClientData = clientData;
|
|
6195
|
+
turnIsActionTurn = false;
|
|
6196
|
+
currentTurnSource = wireChannelEvent
|
|
6197
|
+
? "channel"
|
|
6198
|
+
: currentWirePayload.actionSource === "webhook"
|
|
6199
|
+
? "webhook"
|
|
6200
|
+
: "client";
|
|
6201
|
+
currentTurnConnectorId = undefined;
|
|
6202
|
+
if (wireChannelEvent) {
|
|
6203
|
+
channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
|
|
6204
|
+
if (channelConn) {
|
|
6205
|
+
currentTurnConnectorId = channelConn.id;
|
|
6206
|
+
let idleControl = null;
|
|
6207
|
+
if (channelConn.onControl) {
|
|
6208
|
+
try {
|
|
6209
|
+
idleControl = channelConn.onControl(wireChannelEvent.event);
|
|
6210
|
+
}
|
|
6211
|
+
catch (controlError) {
|
|
6212
|
+
v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6213
|
+
error: controlError,
|
|
6214
|
+
});
|
|
6215
|
+
}
|
|
6216
|
+
}
|
|
6217
|
+
const stopForEndedTurn = idleControl?.at !== undefined &&
|
|
6218
|
+
lastChannelTurnEndedAt !== undefined &&
|
|
6219
|
+
idleControl.at < lastChannelTurnEndedAt;
|
|
6220
|
+
droppedIdleControl =
|
|
6221
|
+
idleControl?.kind === "stop" &&
|
|
6222
|
+
(idleControl.whenIdle === "drop" || stopForEndedTurn);
|
|
6223
|
+
// Bind the session to this connector so a later turn that no channel delivery
|
|
6224
|
+
// started (an action turn, a browser message) can still answer into the thread.
|
|
6225
|
+
if (channelConn.bind && !droppedIdleControl) {
|
|
6226
|
+
try {
|
|
6227
|
+
const handle = channelConn.bind(wireChannelEvent.event);
|
|
6228
|
+
if (handle !== null && handle !== undefined) {
|
|
6229
|
+
const controls = sessionChannelBinding?.connectorId === channelConn.id
|
|
6230
|
+
? sessionChannelBinding.controls
|
|
6231
|
+
: undefined;
|
|
6232
|
+
sessionChannelBinding = {
|
|
6233
|
+
connectorId: channelConn.id,
|
|
6234
|
+
handle,
|
|
6235
|
+
boundAt: new Date().toISOString(),
|
|
6236
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6237
|
+
...(controls ? { controls } : {}),
|
|
6238
|
+
};
|
|
6239
|
+
}
|
|
6240
|
+
}
|
|
6241
|
+
catch (bindError) {
|
|
6242
|
+
v3_1.logger.warn("chat.agent: channel bind() failed; session binding unchanged", {
|
|
6243
|
+
error: bindError,
|
|
6244
|
+
});
|
|
6245
|
+
}
|
|
6246
|
+
}
|
|
6247
|
+
channelHandle =
|
|
6248
|
+
sessionChannelBinding?.connectorId === channelConn.id
|
|
6249
|
+
? sessionChannelBinding.handle
|
|
6250
|
+
: undefined;
|
|
6251
|
+
const rawInteraction = droppedIdleControl
|
|
6252
|
+
? null
|
|
6253
|
+
: (channelConn.onInteraction?.(wireChannelEvent.event) ?? null);
|
|
6254
|
+
let interaction = rawInteraction
|
|
6255
|
+
? (resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null)
|
|
6256
|
+
: null;
|
|
6257
|
+
const accumulatorHasInteractionToolCall = interaction != null &&
|
|
6258
|
+
accumulatedUIMessages.some((m) => (m.parts ?? []).some((p) => p?.toolCallId === interaction.toolCallId));
|
|
6259
|
+
if (rawInteraction && loadContextHook && !accumulatorHasInteractionToolCall) {
|
|
6260
|
+
try {
|
|
6261
|
+
const hydratedForInteraction = (await loadContextHook({
|
|
6262
|
+
chatId: currentWirePayload.chatId,
|
|
6263
|
+
turn,
|
|
6264
|
+
trigger: "submit-message",
|
|
6265
|
+
incomingMessages: [],
|
|
6266
|
+
previousMessages: [...accumulatedUIMessages],
|
|
6267
|
+
clientData,
|
|
6268
|
+
continuation,
|
|
6269
|
+
previousRunId,
|
|
6270
|
+
}));
|
|
6271
|
+
accumulatedUIMessages = hydratedForInteraction;
|
|
6272
|
+
accumulatedMessages = await toModelMessages(hydratedForInteraction);
|
|
6273
|
+
laneCompacted = false;
|
|
6274
|
+
laneInjections = [];
|
|
6275
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6276
|
+
if (!interaction) {
|
|
6277
|
+
interaction =
|
|
6278
|
+
resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null;
|
|
6279
|
+
}
|
|
6280
|
+
}
|
|
6281
|
+
catch (hydrateError) {
|
|
6282
|
+
v3_1.logger.warn("chat.agent: hydrateMessages for channel interaction resolution failed", { error: hydrateError });
|
|
6283
|
+
}
|
|
6284
|
+
}
|
|
6285
|
+
const resolutionMessage = interaction && !channelStoppedToolCallIds.has(interaction.toolCallId)
|
|
6286
|
+
? buildInteractionResolutionMessage(interaction, accumulatedUIMessages, {
|
|
6287
|
+
source: "channel",
|
|
6288
|
+
connectorId: channelConn.id,
|
|
6289
|
+
provider: channelConn.source,
|
|
6290
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6291
|
+
})
|
|
6292
|
+
: undefined;
|
|
6293
|
+
const channelInbound = droppedIdleControl || resolutionMessage || rawInteraction
|
|
6294
|
+
? undefined
|
|
6295
|
+
: channelConn.inbound(wireChannelEvent.event);
|
|
6296
|
+
if (droppedIdleControl) {
|
|
6297
|
+
const staleStop = idleControl?.at !== undefined &&
|
|
6298
|
+
lastChannelTurnStartedAt !== undefined &&
|
|
6299
|
+
idleControl.at < lastChannelTurnStartedAt;
|
|
6300
|
+
if (!staleStop && loadContextHook && accumulatedUIMessages.length === 0) {
|
|
6301
|
+
try {
|
|
6302
|
+
const hydratedForStop = (await loadContextHook({
|
|
6303
|
+
chatId: currentWirePayload.chatId,
|
|
6304
|
+
turn,
|
|
6305
|
+
trigger: "submit-message",
|
|
6306
|
+
incomingMessages: [],
|
|
6307
|
+
previousMessages: [],
|
|
6308
|
+
clientData,
|
|
6309
|
+
continuation,
|
|
6310
|
+
previousRunId,
|
|
6311
|
+
}));
|
|
6312
|
+
accumulatedUIMessages = hydratedForStop;
|
|
6313
|
+
accumulatedMessages = await toModelMessages(hydratedForStop);
|
|
6314
|
+
laneCompacted = false;
|
|
6315
|
+
laneInjections = [];
|
|
6316
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6317
|
+
}
|
|
6318
|
+
catch (hydrateError) {
|
|
6319
|
+
v3_1.logger.warn("chat.agent: hydrateMessages for a channel stop failed", {
|
|
6320
|
+
error: hydrateError,
|
|
6321
|
+
});
|
|
6322
|
+
}
|
|
6323
|
+
}
|
|
6324
|
+
let lastAssistantIndex = -1;
|
|
6325
|
+
for (let i = accumulatedUIMessages.length - 1; i >= 0; i--) {
|
|
6326
|
+
if (accumulatedUIMessages[i].role === "assistant") {
|
|
6327
|
+
lastAssistantIndex = i;
|
|
6328
|
+
break;
|
|
6329
|
+
}
|
|
6330
|
+
}
|
|
6331
|
+
const lastAssistant = lastAssistantIndex === -1
|
|
6332
|
+
? undefined
|
|
6333
|
+
: accumulatedUIMessages[lastAssistantIndex];
|
|
6334
|
+
const stoppedCalls = lastAssistant && !staleStop && idleControl?.whenIdle === "drop"
|
|
6335
|
+
? pendingToolCallsInMessage(lastAssistant)
|
|
6336
|
+
: [];
|
|
6337
|
+
if (lastAssistant && stoppedCalls.length > 0) {
|
|
6338
|
+
const errorText = idleControl?.message || "stopped";
|
|
6339
|
+
const stoppedMessage = stopPendingToolCalls(lastAssistant, new Set(stoppedCalls.map((call) => call.toolCallId)), errorText);
|
|
6340
|
+
if (stoppedMessage) {
|
|
6341
|
+
accumulatedUIMessages = accumulatedUIMessages.map((m, i) => i === lastAssistantIndex ? stoppedMessage : m);
|
|
6342
|
+
const pausedRun = await toModelMessages([
|
|
6343
|
+
stripProviderMetadata(lastAssistant),
|
|
6344
|
+
]);
|
|
6345
|
+
if (pausedRun.length === 0) {
|
|
6346
|
+
accumulatedMessages = [
|
|
6347
|
+
...accumulatedMessages,
|
|
6348
|
+
...(await toModelMessages([stoppedMessage])),
|
|
6349
|
+
];
|
|
6350
|
+
}
|
|
6351
|
+
else if (!(await replaceModelRun(accumulatedMessages, lastAssistant, stoppedMessage, 0, pausedRun))) {
|
|
6352
|
+
v3_1.logger.warn("chat.agent: paused message not found at the model lane tail; reconverting the lane");
|
|
6353
|
+
accumulatedMessages = await toModelMessages(accumulatedUIMessages, accumulatedUIMessages);
|
|
6354
|
+
laneCompacted = false;
|
|
6355
|
+
laneInjections = [];
|
|
6356
|
+
}
|
|
6357
|
+
for (const call of stoppedCalls)
|
|
6358
|
+
channelStoppedToolCallIds.add(call.toolCallId);
|
|
6359
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6360
|
+
}
|
|
6361
|
+
const refs = sessionChannelBinding?.connectorId === channelConn.id
|
|
6362
|
+
? sessionChannelBinding.controls
|
|
6363
|
+
: undefined;
|
|
6364
|
+
const cancelled = stoppedCalls.map((call) => refs?.[call.toolCallId] ? { ...call, ref: refs[call.toolCallId] } : call);
|
|
6365
|
+
for (const call of stoppedCalls) {
|
|
6366
|
+
channelRenderedToolCallIds.delete(call.toolCallId);
|
|
6367
|
+
}
|
|
6368
|
+
if (refs && sessionChannelBinding) {
|
|
6369
|
+
const remaining = Object.fromEntries(Object.entries(refs).filter(([id]) => !stoppedCalls.some((call) => call.toolCallId === id)));
|
|
6370
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6371
|
+
void _controls;
|
|
6372
|
+
sessionChannelBinding =
|
|
6373
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6374
|
+
}
|
|
6375
|
+
await writeSnapshotOutsideTurn("channel-stop", { turn, clientData });
|
|
6376
|
+
if (channelConn.cancelInteraction) {
|
|
6377
|
+
try {
|
|
6378
|
+
await channelConn.cancelInteraction(cancelled, channelTurnCtx(wireChannelEvent, channelHandle));
|
|
6379
|
+
}
|
|
6380
|
+
catch (cancelError) {
|
|
6381
|
+
v3_1.logger.warn("chat.agent: channel cancelInteraction failed; continuing", {
|
|
6382
|
+
error: cancelError,
|
|
6383
|
+
});
|
|
6384
|
+
}
|
|
6385
|
+
}
|
|
6386
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
6387
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6388
|
+
firstTurn: false,
|
|
6389
|
+
});
|
|
6390
|
+
}
|
|
6391
|
+
}
|
|
6392
|
+
else if (resolutionMessage) {
|
|
6393
|
+
effectiveIncomingMessage = resolutionMessage;
|
|
6394
|
+
channelInteractionResolved = true;
|
|
6395
|
+
const resolvedCallId = interaction?.toolCallId;
|
|
6396
|
+
const controlRefs = sessionChannelBinding?.controls;
|
|
6397
|
+
if (resolvedCallId && controlRefs?.[resolvedCallId] && sessionChannelBinding) {
|
|
6398
|
+
const { [resolvedCallId]: _resolved, ...remaining } = controlRefs;
|
|
6399
|
+
void _resolved;
|
|
6400
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6401
|
+
void _controls;
|
|
6402
|
+
sessionChannelBinding =
|
|
6403
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6404
|
+
}
|
|
6405
|
+
if (interaction && channelConn.finalizeInteraction) {
|
|
6406
|
+
try {
|
|
6407
|
+
await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
|
|
6408
|
+
}
|
|
6409
|
+
catch (finalizeError) {
|
|
6410
|
+
v3_1.logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
|
|
6411
|
+
error: finalizeError,
|
|
6412
|
+
});
|
|
6413
|
+
}
|
|
6414
|
+
}
|
|
6415
|
+
}
|
|
6416
|
+
else if (rawInteraction) {
|
|
6417
|
+
droppedStaleInteraction = true;
|
|
6418
|
+
v3_1.logger.debug("chat.agent: dropping stale channel interaction that matched no pending tool call", {
|
|
6419
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6420
|
+
});
|
|
6421
|
+
}
|
|
6422
|
+
else if (channelInbound === undefined || isEmptyChannelInbound(channelInbound)) {
|
|
6423
|
+
droppedEmptyMessage = true;
|
|
6424
|
+
v3_1.logger.debug("chat.agent: dropping a channel delivery with an empty message", {
|
|
6425
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6426
|
+
});
|
|
6427
|
+
}
|
|
6428
|
+
else {
|
|
6429
|
+
// A session that starts on a conversation with history: let the connector supply
|
|
6430
|
+
// it, so the agent answers with the context a reader of the thread has.
|
|
6431
|
+
if (channelConn.seed && accumulatedUIMessages.length === 0) {
|
|
6432
|
+
try {
|
|
6433
|
+
const seeded = channelSeedMessages(await channelConn.seed(wireChannelEvent.event, {
|
|
6434
|
+
event: wireChannelEvent.event,
|
|
6435
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6436
|
+
}), {
|
|
6437
|
+
source: "channel",
|
|
6438
|
+
connectorId: channelConn.id,
|
|
6439
|
+
provider: channelConn.source,
|
|
6440
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6441
|
+
});
|
|
6442
|
+
if (seeded.length > 0) {
|
|
6443
|
+
accumulatedUIMessages = seeded;
|
|
6444
|
+
accumulatedMessages = await toModelMessages(seeded);
|
|
6445
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6446
|
+
}
|
|
6447
|
+
}
|
|
6448
|
+
catch (seedError) {
|
|
6449
|
+
v3_1.logger.warn("chat.agent: channel seed() failed; starting the session empty", {
|
|
6450
|
+
error: seedError,
|
|
6451
|
+
});
|
|
6452
|
+
}
|
|
6453
|
+
}
|
|
6454
|
+
effectiveIncomingMessage = channelUserMessage(channelInbound, currentWirePayload.messageId ?? wireChannelEvent.deliveryId, {
|
|
6455
|
+
source: "channel",
|
|
6456
|
+
connectorId: channelConn.id,
|
|
6457
|
+
provider: channelConn.source,
|
|
6458
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6459
|
+
...(wireChannelEvent.externalDeliveryId
|
|
6460
|
+
? { externalDeliveryId: wireChannelEvent.externalDeliveryId }
|
|
6461
|
+
: {}),
|
|
6462
|
+
});
|
|
6463
|
+
if (channelConn.send && channelConn.ack) {
|
|
6464
|
+
const recoveryPending = locals_js_1.locals.get(chatChannelRecoveryPendingKey);
|
|
6465
|
+
const recovered = recoveryPending?.value === true;
|
|
6466
|
+
if (recovered)
|
|
6467
|
+
recoveryPending.value = false;
|
|
6468
|
+
const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
|
|
6469
|
+
if (ackMessage) {
|
|
6470
|
+
try {
|
|
6471
|
+
const ackResult = await channelConn.send(ackMessage, {
|
|
6472
|
+
event: wireChannelEvent.event,
|
|
6473
|
+
handle: channelHandle,
|
|
6474
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6475
|
+
mode: channelConn.delivery,
|
|
6476
|
+
final: false,
|
|
6477
|
+
bound: false,
|
|
6478
|
+
});
|
|
6479
|
+
channelAckRef = ackResult?.ref;
|
|
6480
|
+
}
|
|
6481
|
+
catch (ackError) {
|
|
6482
|
+
v3_1.logger.warn("chat.agent: channel ack post failed; continuing", {
|
|
6483
|
+
error: ackError,
|
|
6484
|
+
});
|
|
6485
|
+
}
|
|
6486
|
+
}
|
|
6487
|
+
}
|
|
6488
|
+
try {
|
|
6489
|
+
channelWorkingReaction = await resolveReactionChoice(channelConn.reactions?.working, wireChannelEvent.event);
|
|
6490
|
+
if (channelWorkingReaction) {
|
|
6491
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
6492
|
+
name: channelWorkingReaction,
|
|
6493
|
+
});
|
|
6494
|
+
}
|
|
6495
|
+
}
|
|
6496
|
+
catch (reactionError) {
|
|
6497
|
+
v3_1.logger.warn("chat.agent: channel working reaction failed", {
|
|
6498
|
+
error: reactionError,
|
|
6499
|
+
});
|
|
6500
|
+
}
|
|
6501
|
+
}
|
|
6502
|
+
}
|
|
6503
|
+
else {
|
|
6504
|
+
droppedUnknownConnector = true;
|
|
6505
|
+
v3_1.logger.warn("chat.agent: no channel connector matches this delivery; skipping turn", {
|
|
6506
|
+
connectorId: wireChannelEvent.connectorId,
|
|
6507
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6508
|
+
});
|
|
6509
|
+
}
|
|
6510
|
+
}
|
|
6511
|
+
if (!wireChannelEvent &&
|
|
6512
|
+
sessionChannelBinding &&
|
|
6513
|
+
currentWirePayload.trigger !== "close" &&
|
|
6514
|
+
currentWirePayload.trigger !== "preload") {
|
|
6515
|
+
// No channel delivery started this turn, but the session is bound to a channel: answer
|
|
6516
|
+
// into it. Reactions need the triggering message, so a bound turn adds none; its reply,
|
|
6517
|
+
// any error and any approval controls go to the bound thread.
|
|
6518
|
+
const bound = channels?.find((c) => c.id === sessionChannelBinding.connectorId);
|
|
6519
|
+
if (bound?.send) {
|
|
6520
|
+
channelConn = bound;
|
|
6521
|
+
channelHandle = sessionChannelBinding.handle;
|
|
6522
|
+
boundTurn = true;
|
|
6523
|
+
currentTurnConnectorId = bound.id;
|
|
6524
|
+
}
|
|
6525
|
+
}
|
|
6526
|
+
const incomingMessages = effectiveIncomingMessage
|
|
6527
|
+
? [effectiveIncomingMessage]
|
|
4976
6528
|
: [];
|
|
4977
6529
|
// Cleaning happens once here so `extractLastUserMessageText` and
|
|
4978
6530
|
// every downstream consumer see the same message shape — and
|
|
4979
6531
|
// `cleanupAbortedParts` no longer has to be re-applied below.
|
|
4980
6532
|
const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
|
|
4981
|
-
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
4982
|
-
turnClientData = clientData;
|
|
4983
6533
|
const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
|
|
6534
|
+
channelTurnRan =
|
|
6535
|
+
!!channelConn &&
|
|
6536
|
+
!!(wireChannelEvent || boundTurn) &&
|
|
6537
|
+
currentWirePayload.trigger !== "action" &&
|
|
6538
|
+
!droppedIdleControl &&
|
|
6539
|
+
!droppedStaleInteraction &&
|
|
6540
|
+
!droppedEmptyMessage;
|
|
6541
|
+
const startChannelLifecycle = async () => {
|
|
6542
|
+
lastChannelTurnStartedAt = channelTurnStartedAt;
|
|
6543
|
+
if (!channelConn?.lifecycle)
|
|
6544
|
+
return;
|
|
6545
|
+
channelLifecycleStarted = true;
|
|
6546
|
+
if (sessionChannelBinding?.connectorId === channelConn.id) {
|
|
6547
|
+
sessionChannelBinding = { ...sessionChannelBinding, working: true };
|
|
6548
|
+
}
|
|
6549
|
+
await channelLifecycle.report(channelConn, "working", {
|
|
6550
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6551
|
+
firstTurn: accumulatedUIMessages.every((m) => m.metadata?.seeded === true),
|
|
6552
|
+
...(lastUserMessage ? { prompt: lastUserMessage } : {}),
|
|
6553
|
+
});
|
|
6554
|
+
};
|
|
6555
|
+
if (channelTurnRan)
|
|
6556
|
+
await startChannelLifecycle();
|
|
4984
6557
|
// Actions are not turns. They use a different span name
|
|
4985
6558
|
// and don't carry a turn.number. Branched on at `isAction`.
|
|
4986
6559
|
const isAction = currentWirePayload.trigger === "action";
|
|
@@ -4995,7 +6568,7 @@ function chatAgent(options) {
|
|
|
4995
6568
|
: "tabler-message-chatbot",
|
|
4996
6569
|
[v3_1.SemanticInternalAttributes.ENTITY_TYPE]: isAction ? "chat-action" : "chat-turn",
|
|
4997
6570
|
};
|
|
4998
|
-
if (lastUserMessage) {
|
|
6571
|
+
if (lastUserMessage && recordUserMessage) {
|
|
4999
6572
|
turnAttributes["chat.user_message"] = lastUserMessage;
|
|
5000
6573
|
// Show a truncated preview of the user message as an accessory
|
|
5001
6574
|
const preview = lastUserMessage.length > 80
|
|
@@ -5006,7 +6579,7 @@ function chatAgent(options) {
|
|
|
5006
6579
|
style: "codepath",
|
|
5007
6580
|
}));
|
|
5008
6581
|
}
|
|
5009
|
-
if (wireMetadata !== undefined) {
|
|
6582
|
+
if (wireMetadata !== undefined && recordClientData) {
|
|
5010
6583
|
turnAttributes["chat.client_data"] =
|
|
5011
6584
|
typeof wireMetadata === "string" ? wireMetadata : JSON.stringify(wireMetadata);
|
|
5012
6585
|
}
|
|
@@ -5031,11 +6604,16 @@ function chatAgent(options) {
|
|
|
5031
6604
|
turn,
|
|
5032
6605
|
continuation,
|
|
5033
6606
|
clientData,
|
|
6607
|
+
recordUserMessage,
|
|
5034
6608
|
});
|
|
5035
6609
|
// Resolve the per-turn `tools` set now that turn context
|
|
5036
6610
|
// (incl. parsed clientData) exists, so every toModelMessages
|
|
5037
6611
|
// call this turn can re-apply tool `toModelOutput`.
|
|
5038
6612
|
await resolveTurnTools();
|
|
6613
|
+
// The channel surface for this turn, handed to run() and reachable from a tool's
|
|
6614
|
+
// execute through chat.surface(), so a connector can ship tools the model drives.
|
|
6615
|
+
const channelSurface = buildChannelRunSurface(channelConn, wireChannelEvent, channelHandle);
|
|
6616
|
+
locals_js_1.locals.set(chatChannelSurfaceKey, channelSurface);
|
|
5039
6617
|
// Per-turn stop controller (reset each turn)
|
|
5040
6618
|
const stopController = new AbortController();
|
|
5041
6619
|
currentStopController = stopController;
|
|
@@ -5057,12 +6635,19 @@ function chatAgent(options) {
|
|
|
5057
6635
|
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
|
|
5058
6636
|
const msg = record.data
|
|
5059
6637
|
.payload;
|
|
6638
|
+
const wireChannel = msg
|
|
6639
|
+
.channelEvent;
|
|
6640
|
+
const steeredChannel = wireChannel != null
|
|
6641
|
+
? channelSteeringMessage(channels, wireChannel, msg.messageId)
|
|
6642
|
+
: undefined;
|
|
6643
|
+
if (wireChannel != null && !steeredChannel)
|
|
6644
|
+
return;
|
|
5060
6645
|
// Slim wire: at most one delta message per record. The
|
|
5061
6646
|
// pendingMessages handler reads `msg.message` directly
|
|
5062
6647
|
// instead of slicing an array — a wire record arrives
|
|
5063
6648
|
// with the new user message in `.message`, or no message
|
|
5064
6649
|
// at all (regenerate / preload / close / handover-prepare).
|
|
5065
|
-
const lastUIMessage = msg.message;
|
|
6650
|
+
const lastUIMessage = (steeredChannel?.message ?? msg.message);
|
|
5066
6651
|
if (lastUIMessage) {
|
|
5067
6652
|
if (pmConfig.onReceived) {
|
|
5068
6653
|
try {
|
|
@@ -5083,7 +6668,10 @@ function chatAgent(options) {
|
|
|
5083
6668
|
queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
|
|
5084
6669
|
return;
|
|
5085
6670
|
}
|
|
5086
|
-
const
|
|
6671
|
+
const converted = await toModelMessages([lastUIMessage]);
|
|
6672
|
+
const modelMsgs = steeredChannel && channelDownloader
|
|
6673
|
+
? await resolveChannelFiles(converted, channelDownloader)
|
|
6674
|
+
: converted;
|
|
5087
6675
|
queue.push({
|
|
5088
6676
|
uiMessage: lastUIMessage,
|
|
5089
6677
|
modelMessages: modelMsgs,
|
|
@@ -5098,6 +6686,52 @@ function chatAgent(options) {
|
|
|
5098
6686
|
})
|
|
5099
6687
|
: undefined;
|
|
5100
6688
|
turnMsgSub = msgSub;
|
|
6689
|
+
/**
|
|
6690
|
+
* Channel deliveries while this turn runs. A delivery the connector maps to `stop`
|
|
6691
|
+
* aborts this turn through the same controller the stop input uses, and is taken
|
|
6692
|
+
* off the route so it does not also run as a turn. Any other message is
|
|
6693
|
+
* acknowledged through the connector's `received` as soon as it arrives, and left
|
|
6694
|
+
* queued for steering or the next turn.
|
|
6695
|
+
*/
|
|
6696
|
+
const controlSub = channels?.some((c) => c.onControl || c.received)
|
|
6697
|
+
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
|
|
6698
|
+
const msg = record.data
|
|
6699
|
+
.payload;
|
|
6700
|
+
const ce = msg.channelEvent;
|
|
6701
|
+
if (!ce)
|
|
6702
|
+
return;
|
|
6703
|
+
const conn = channels?.find((c) => c.id === ce.connectorId);
|
|
6704
|
+
if (!conn)
|
|
6705
|
+
return;
|
|
6706
|
+
let control = null;
|
|
6707
|
+
try {
|
|
6708
|
+
control = conn.onControl?.(ce.event) ?? null;
|
|
6709
|
+
}
|
|
6710
|
+
catch (controlError) {
|
|
6711
|
+
v3_1.logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6712
|
+
error: controlError,
|
|
6713
|
+
});
|
|
6714
|
+
}
|
|
6715
|
+
if (!control) {
|
|
6716
|
+
acknowledgeChannelMessage(conn, ce, sessionChannelBinding?.connectorId === conn.id
|
|
6717
|
+
? sessionChannelBinding.handle
|
|
6718
|
+
: undefined);
|
|
6719
|
+
return;
|
|
6720
|
+
}
|
|
6721
|
+
if (control.kind !== "stop")
|
|
6722
|
+
return;
|
|
6723
|
+
const earlierTurnStop = control.at !== undefined && control.at < channelTurnStartedAt;
|
|
6724
|
+
if (!earlierTurnStop && !stopController.signal.aborted) {
|
|
6725
|
+
stopController.abort(new DOMException(control.message || "stopped", "AbortError"));
|
|
6726
|
+
}
|
|
6727
|
+
// Observers are offered a record before it is queued, so the take has to
|
|
6728
|
+
// wait a tick or it finds nothing and the stop runs again as a turn.
|
|
6729
|
+
void Promise.resolve().then(() => {
|
|
6730
|
+
chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
|
|
6731
|
+
});
|
|
6732
|
+
})
|
|
6733
|
+
: undefined;
|
|
6734
|
+
turnControlSub = controlSub;
|
|
5101
6735
|
// Track new messages for this turn (user input + assistant response).
|
|
5102
6736
|
const turnNewModelMessages = [];
|
|
5103
6737
|
const turnNewUIMessages = [];
|
|
@@ -5127,7 +6761,8 @@ function chatAgent(options) {
|
|
|
5127
6761
|
let actionChangedHistory = false;
|
|
5128
6762
|
if (isAction) {
|
|
5129
6763
|
// Parse and validate the action payload
|
|
5130
|
-
const
|
|
6764
|
+
const isWebhookAction = currentWirePayload.actionSource === "webhook";
|
|
6765
|
+
const parsedAction = parseAction && !isWebhookAction
|
|
5131
6766
|
? await parseAction(currentWirePayload.action)
|
|
5132
6767
|
: currentWirePayload.action;
|
|
5133
6768
|
// Hydrate messages from backend if configured
|
|
@@ -5168,6 +6803,7 @@ function chatAgent(options) {
|
|
|
5168
6803
|
clientData,
|
|
5169
6804
|
uiMessages: accumulatedUIMessages,
|
|
5170
6805
|
messages: accumulatedMessages,
|
|
6806
|
+
...(actionChannel ? { channel: actionChannel } : {}),
|
|
5171
6807
|
});
|
|
5172
6808
|
}, {
|
|
5173
6809
|
attributes: {
|
|
@@ -5202,7 +6838,11 @@ function chatAgent(options) {
|
|
|
5202
6838
|
// snapshot + `session.out` replay (or `hydrateMessages`,
|
|
5203
6839
|
// which also fires per-turn below). Per-turn handling is
|
|
5204
6840
|
// therefore a delta merge, not a full-history reset.
|
|
5205
|
-
if (currentWirePayload.trigger !== "action"
|
|
6841
|
+
if (currentWirePayload.trigger !== "action" &&
|
|
6842
|
+
!droppedStaleInteraction &&
|
|
6843
|
+
!droppedEmptyMessage &&
|
|
6844
|
+
!droppedUnknownConnector &&
|
|
6845
|
+
!droppedIdleControl) {
|
|
5206
6846
|
let cleanedUIMessages = cleanedIncomingMessages;
|
|
5207
6847
|
// Turn-0 head-start with hydrateMessages: the boot seeding from
|
|
5208
6848
|
// `payload.headStartMessages` is non-hydrate-only, so ship the
|
|
@@ -5454,17 +7094,41 @@ function chatAgent(options) {
|
|
|
5454
7094
|
// calling the model would prefill its own last reply. Keyed on
|
|
5455
7095
|
// the model tail, so a `tool`-terminated chain (a merged tool
|
|
5456
7096
|
// approval) still runs.
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
7097
|
+
// A channel delivery the run decided not to turn into a turn: a stale
|
|
7098
|
+
// interaction callback (no pending tool call matched) or an event for
|
|
7099
|
+
// a connector this agent does not list. Nothing to answer either way.
|
|
7100
|
+
const isDroppedChannelDelivery = droppedStaleInteraction ||
|
|
7101
|
+
droppedEmptyMessage ||
|
|
7102
|
+
droppedUnknownConnector ||
|
|
7103
|
+
droppedIdleControl;
|
|
7104
|
+
// A resolved interaction is never a no-op: the assistant may already have text after
|
|
7105
|
+
// the pending call, so the lane ends on an assistant message, but the model has not
|
|
7106
|
+
// seen the decision yet.
|
|
7107
|
+
const isNoOpTurn = isDroppedChannelDelivery ||
|
|
7108
|
+
(!isAction &&
|
|
7109
|
+
!splicedHandoverPartial &&
|
|
7110
|
+
!channelInteractionResolved &&
|
|
7111
|
+
currentWirePayload.trigger === "submit-message" &&
|
|
7112
|
+
turnNewUIMessages.length === 0 &&
|
|
7113
|
+
accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant");
|
|
5462
7114
|
if (isNoOpTurn) {
|
|
5463
7115
|
msgSub?.off();
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
7116
|
+
turnControlSub?.off();
|
|
7117
|
+
if (channelTurnRan)
|
|
7118
|
+
lastChannelTurnEndedAt = Date.now();
|
|
7119
|
+
clearChannelWorking();
|
|
7120
|
+
if (channelLifecycleStarted) {
|
|
7121
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
7122
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7123
|
+
firstTurn: false,
|
|
7124
|
+
});
|
|
7125
|
+
}
|
|
7126
|
+
if (!isDroppedChannelDelivery) {
|
|
7127
|
+
v3_1.logger.warn("chat.agent: turn added no new user message; skipping the model", {
|
|
7128
|
+
chatId: currentWirePayload.chatId,
|
|
7129
|
+
messageId: currentWirePayload.messageId,
|
|
7130
|
+
});
|
|
7131
|
+
}
|
|
5468
7132
|
settleRecoveredTurn(currentWirePayload);
|
|
5469
7133
|
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
5470
7134
|
// Not a turn — don't consume an iteration.
|
|
@@ -5480,6 +7144,24 @@ function chatAgent(options) {
|
|
|
5480
7144
|
// sees the same `turn` value — actions don't count.
|
|
5481
7145
|
if (isAction) {
|
|
5482
7146
|
if (isActionTurn(actionResult)) {
|
|
7147
|
+
if (actionResult.message !== undefined) {
|
|
7148
|
+
const asked = (typeof actionResult.message === "string"
|
|
7149
|
+
? {
|
|
7150
|
+
id: (0, ai_runtime_js_1.generateId)(),
|
|
7151
|
+
role: "user",
|
|
7152
|
+
parts: [{ type: "text", text: actionResult.message }],
|
|
7153
|
+
}
|
|
7154
|
+
: actionResult.message.id
|
|
7155
|
+
? actionResult.message
|
|
7156
|
+
: { ...actionResult.message, id: (0, ai_runtime_js_1.generateId)() });
|
|
7157
|
+
accumulatedUIMessages = [...accumulatedUIMessages, asked];
|
|
7158
|
+
accumulatedMessages = [
|
|
7159
|
+
...accumulatedMessages,
|
|
7160
|
+
...(await toModelMessages([asked])),
|
|
7161
|
+
];
|
|
7162
|
+
locals_js_1.locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
7163
|
+
actionChangedHistory = true;
|
|
7164
|
+
}
|
|
5483
7165
|
// Persist the edit before the turn starts, so a turn that is
|
|
5484
7166
|
// cancelled or runs out of memory continues from the edited
|
|
5485
7167
|
// history rather than from the snapshot the edit replaced.
|
|
@@ -5488,6 +7170,11 @@ function chatAgent(options) {
|
|
|
5488
7170
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5489
7171
|
}
|
|
5490
7172
|
actionTurn = true;
|
|
7173
|
+
turnIsActionTurn = true;
|
|
7174
|
+
if (boundTurn && channelConn) {
|
|
7175
|
+
channelTurnRan = true;
|
|
7176
|
+
await startChannelLifecycle();
|
|
7177
|
+
}
|
|
5491
7178
|
}
|
|
5492
7179
|
else if (actionResult !== undefined) {
|
|
5493
7180
|
throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
|
|
@@ -5496,6 +7183,7 @@ function chatAgent(options) {
|
|
|
5496
7183
|
}
|
|
5497
7184
|
else {
|
|
5498
7185
|
msgSub?.off();
|
|
7186
|
+
turnControlSub?.off();
|
|
5499
7187
|
if (actionChangedHistory) {
|
|
5500
7188
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5501
7189
|
}
|
|
@@ -5697,12 +7385,87 @@ function chatAgent(options) {
|
|
|
5697
7385
|
}
|
|
5698
7386
|
else {
|
|
5699
7387
|
const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
|
|
7388
|
+
const runMessages = channelDownloader
|
|
7389
|
+
? await resolveChannelFiles(preparedMessages, channelDownloader)
|
|
7390
|
+
: preparedMessages;
|
|
7391
|
+
// Channel observers for this turn are built before run(). The progress
|
|
7392
|
+
// observer and the native reply stream are shared with a manual chat.pipe()
|
|
7393
|
+
// inside run() through the turn tap and stay open across several pipes; the
|
|
7394
|
+
// ack stream editor only rides the automatic pipe, because a manual pipe
|
|
7395
|
+
// captures no response and posts no final reply that would supersede a
|
|
7396
|
+
// partial edit. The finally of this turn stops all of them and clears the tap;
|
|
7397
|
+
// the turn boundary ends the native stream.
|
|
7398
|
+
if (channelConn) {
|
|
7399
|
+
const observers = [];
|
|
7400
|
+
const tapObservers = [];
|
|
7401
|
+
if (channelConn.delivery === "stream" &&
|
|
7402
|
+
channelConn.stream &&
|
|
7403
|
+
(wireChannelEvent || channelHandle)) {
|
|
7404
|
+
const streamConnectorId = channelConn.id;
|
|
7405
|
+
const opened = (ref) => {
|
|
7406
|
+
if (sessionChannelBinding?.connectorId !== streamConnectorId)
|
|
7407
|
+
return;
|
|
7408
|
+
sessionChannelBinding = { ...sessionChannelBinding, stream: ref };
|
|
7409
|
+
if (hydrateMessages)
|
|
7410
|
+
return;
|
|
7411
|
+
void saveTranscript({
|
|
7412
|
+
reason: "turn-start",
|
|
7413
|
+
messages: accumulatedUIMessages,
|
|
7414
|
+
turn,
|
|
7415
|
+
trigger: storageTrigger(currentWirePayload.trigger),
|
|
7416
|
+
clientData,
|
|
7417
|
+
lastOutEventId: lastSnapshotOutEventId,
|
|
7418
|
+
skipIfUnchanged: true,
|
|
7419
|
+
carryInCursor: true,
|
|
7420
|
+
}).catch((error) => {
|
|
7421
|
+
v3_1.logger.warn("chat.agent: saving the open reply stream failed", {
|
|
7422
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7423
|
+
});
|
|
7424
|
+
});
|
|
7425
|
+
};
|
|
7426
|
+
try {
|
|
7427
|
+
channelReplyStream =
|
|
7428
|
+
channelConn.stream({
|
|
7429
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7430
|
+
opened,
|
|
7431
|
+
}) ?? undefined;
|
|
7432
|
+
}
|
|
7433
|
+
catch (streamError) {
|
|
7434
|
+
v3_1.logger.warn("chat.agent: channel stream() threw; posting the reply", {
|
|
7435
|
+
error: streamError,
|
|
7436
|
+
});
|
|
7437
|
+
}
|
|
7438
|
+
}
|
|
7439
|
+
if (channelReplyStream) {
|
|
7440
|
+
const replyObserver = makeChannelReplyStreamObserver(channelReplyStream);
|
|
7441
|
+
observers.push(replyObserver);
|
|
7442
|
+
tapObservers.push(replyObserver);
|
|
7443
|
+
}
|
|
7444
|
+
else if (wireChannelEvent &&
|
|
7445
|
+
channelConn.send &&
|
|
7446
|
+
channelConn.delivery === "stream" &&
|
|
7447
|
+
channelAckRef) {
|
|
7448
|
+
observers.push(makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef, channelHandle));
|
|
7449
|
+
}
|
|
7450
|
+
if (channelConn.progress && (wireChannelEvent || channelHandle)) {
|
|
7451
|
+
const progressObserver = makeChannelProgressObserver(channelConn, wireChannelEvent, channelHandle);
|
|
7452
|
+
observers.push(progressObserver);
|
|
7453
|
+
tapObservers.push(progressObserver);
|
|
7454
|
+
}
|
|
7455
|
+
if (tapObservers.length > 0) {
|
|
7456
|
+
const tapObserver = composeChannelObservers(tapObservers);
|
|
7457
|
+
locals_js_1.locals.set(chatTurnChannelTapKey, (stream) => stream.pipeThrough(makeChannelStreamTap(tapObserver, { stopOnEnd: false })));
|
|
7458
|
+
}
|
|
7459
|
+
if (observers.length > 0) {
|
|
7460
|
+
channelStreamEditor = composeChannelObservers(observers);
|
|
7461
|
+
}
|
|
7462
|
+
}
|
|
5700
7463
|
runResult = await userRun({
|
|
5701
7464
|
...restWire,
|
|
5702
7465
|
// A turn requested by chat.turn() is not the action itself:
|
|
5703
7466
|
// a run() that short-circuits on "action" must still answer.
|
|
5704
7467
|
...(actionTurn ? { trigger: "action-turn" } : {}),
|
|
5705
|
-
messages:
|
|
7468
|
+
messages: runMessages,
|
|
5706
7469
|
clientData,
|
|
5707
7470
|
continuation,
|
|
5708
7471
|
previousRunId,
|
|
@@ -5715,6 +7478,7 @@ function chatAgent(options) {
|
|
|
5715
7478
|
cancelSignal,
|
|
5716
7479
|
stopSignal,
|
|
5717
7480
|
streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
|
|
7481
|
+
channel: channelSurface,
|
|
5718
7482
|
});
|
|
5719
7483
|
}
|
|
5720
7484
|
// Auto-pipe if the run function returned a StreamTextResult or similar,
|
|
@@ -5741,6 +7505,12 @@ function chatAgent(options) {
|
|
|
5741
7505
|
// messageId. Without this, the frontend and backend generate IDs
|
|
5742
7506
|
// independently and they won't match for ID-based dedup.
|
|
5743
7507
|
generateMessageId: resolvedOptions.generateMessageId ?? ai_runtime_js_1.generateId,
|
|
7508
|
+
onError: (error) => {
|
|
7509
|
+
channelStreamError ??= error;
|
|
7510
|
+
return resolvedOptions.onError
|
|
7511
|
+
? resolvedOptions.onError(error)
|
|
7512
|
+
: uiStreamErrorText(error);
|
|
7513
|
+
},
|
|
5744
7514
|
onFinish: ({ responseMessage, finishReason, }) => {
|
|
5745
7515
|
capturedResponseMessage = responseMessage;
|
|
5746
7516
|
capturedPartialResponse = responseMessage;
|
|
@@ -5748,7 +7518,13 @@ function chatAgent(options) {
|
|
|
5748
7518
|
resolveOnFinish();
|
|
5749
7519
|
},
|
|
5750
7520
|
});
|
|
5751
|
-
|
|
7521
|
+
// The automatic pipe feeds every observer (progress and the ack stream editor)
|
|
7522
|
+
// itself; the manual tap is cleared so pipeChat does not feed progress twice.
|
|
7523
|
+
locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
|
|
7524
|
+
const streamForPipe = channelStreamEditor && uiStream instanceof ReadableStream
|
|
7525
|
+
? uiStream.pipeThrough(makeChannelStreamTap(channelStreamEditor))
|
|
7526
|
+
: uiStream;
|
|
7527
|
+
await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
|
|
5752
7528
|
signal: combinedSignal,
|
|
5753
7529
|
spanName: "stream response",
|
|
5754
7530
|
}, { originalMessages: isActionTurn ? undefined : accumulatedUIMessages });
|
|
@@ -5771,6 +7547,9 @@ function chatAgent(options) {
|
|
|
5771
7547
|
}
|
|
5772
7548
|
finally {
|
|
5773
7549
|
msgSub?.off();
|
|
7550
|
+
turnControlSub?.off();
|
|
7551
|
+
locals_js_1.locals.set(chatTurnChannelTapKey, undefined);
|
|
7552
|
+
await channelStreamEditor?.stop();
|
|
5774
7553
|
}
|
|
5775
7554
|
// Wait for onFinish to fire — on abort this may resolve slightly
|
|
5776
7555
|
// after pipeChat, since the stream's cancel() handler is async.
|
|
@@ -6014,6 +7793,16 @@ function chatAgent(options) {
|
|
|
6014
7793
|
if (capturedResponseMessage) {
|
|
6015
7794
|
responseCommitted = true;
|
|
6016
7795
|
capturedPartialResponse = capturedResponseMessage;
|
|
7796
|
+
const errorChunk = turnBufferedChunks.find((chunk) => chunk.type === "error");
|
|
7797
|
+
channelStreamErrorText = errorChunk?.errorText;
|
|
7798
|
+
channelTurnText = turnBufferedChunks
|
|
7799
|
+
.map((chunk) => {
|
|
7800
|
+
const c = chunk;
|
|
7801
|
+
return c.type === "text-delta" && typeof c.delta === "string"
|
|
7802
|
+
? c.delta
|
|
7803
|
+
: "";
|
|
7804
|
+
})
|
|
7805
|
+
.join("");
|
|
6017
7806
|
turnBufferedChunks.length = 0;
|
|
6018
7807
|
}
|
|
6019
7808
|
if (runSignal.aborted)
|
|
@@ -6233,8 +8022,206 @@ function chatAgent(options) {
|
|
|
6233
8022
|
}
|
|
6234
8023
|
}
|
|
6235
8024
|
settleRecoveredTurn(currentWirePayload);
|
|
8025
|
+
openChannelEgressLatch(currentWirePayload.chatId);
|
|
6236
8026
|
// Write turn-complete control chunk — closes the frontend stream.
|
|
6237
8027
|
const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
|
|
8028
|
+
// Tool calls that have not had controls posted yet; a resume with the rest still
|
|
8029
|
+
// pending must not repost them or hide the answer text behind them.
|
|
8030
|
+
const pendingToolCalls = (wireChannelEvent || boundTurn) &&
|
|
8031
|
+
channelConn?.send &&
|
|
8032
|
+
turnCompleteEvent.responseMessage
|
|
8033
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).filter((call) => !channelRenderedToolCallIds.has(call.toolCallId))
|
|
8034
|
+
: [];
|
|
8035
|
+
const renderControls = !!((wireChannelEvent || boundTurn) &&
|
|
8036
|
+
pendingToolCalls.length > 0 &&
|
|
8037
|
+
channelConn?.renderInteraction);
|
|
8038
|
+
let channelErrorLine;
|
|
8039
|
+
if (channelStreamErrorText && (wireChannelEvent || boundTurn)) {
|
|
8040
|
+
v3_1.logger.warn("chat.agent: channel turn ended on a model or stream error", {
|
|
8041
|
+
errorText: channelStreamErrorText,
|
|
8042
|
+
});
|
|
8043
|
+
channelErrorLine = await channelTurnErrorMessage(channelConn, {
|
|
8044
|
+
error: channelStreamError instanceof Error
|
|
8045
|
+
? channelStreamError
|
|
8046
|
+
: new Error(channelStreamErrorText),
|
|
8047
|
+
partialText: channelTurnText?.trim() ?? "",
|
|
8048
|
+
event: channelWireEvent?.event,
|
|
8049
|
+
runId: ctx.run.id,
|
|
8050
|
+
chatId: currentWirePayload.chatId,
|
|
8051
|
+
});
|
|
8052
|
+
}
|
|
8053
|
+
let channelReplyDelivered = false;
|
|
8054
|
+
if (channelReplyStream) {
|
|
8055
|
+
const replyStream = channelReplyStream;
|
|
8056
|
+
channelReplyStream = undefined;
|
|
8057
|
+
try {
|
|
8058
|
+
const ended = await replyStream.end({
|
|
8059
|
+
stopped: turnCompleteEvent.stopped,
|
|
8060
|
+
pending: renderControls,
|
|
8061
|
+
...(channelStreamErrorText
|
|
8062
|
+
? { errorText: channelErrorLine ?? exports.CHANNEL_TURN_ERROR_MESSAGE }
|
|
8063
|
+
: {}),
|
|
8064
|
+
});
|
|
8065
|
+
channelReplyDelivered = ended.delivered;
|
|
8066
|
+
}
|
|
8067
|
+
catch (streamError) {
|
|
8068
|
+
v3_1.logger.warn("chat.agent: channel reply stream end failed", {
|
|
8069
|
+
error: streamError,
|
|
8070
|
+
});
|
|
8071
|
+
}
|
|
8072
|
+
finally {
|
|
8073
|
+
clearOpenReplyStream();
|
|
8074
|
+
}
|
|
8075
|
+
}
|
|
8076
|
+
// Channel egress: map the turn's reply via outbound() and send it, editing the ack
|
|
8077
|
+
// placeholder posted at turn start (previousRef). A failed send is retried; one that
|
|
8078
|
+
// never lands is logged and the turn gets the error reaction. A manual-pipe turn has
|
|
8079
|
+
// no responseMessage, so it opts out.
|
|
8080
|
+
const finalChannelConn = channelConn;
|
|
8081
|
+
if ((wireChannelEvent || boundTurn) &&
|
|
8082
|
+
finalChannelConn?.send &&
|
|
8083
|
+
turnCompleteEvent.responseMessage) {
|
|
8084
|
+
if (renderControls) {
|
|
8085
|
+
for (const call of pendingToolCalls) {
|
|
8086
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
8087
|
+
}
|
|
8088
|
+
}
|
|
8089
|
+
const replyText = channelTurnText && channelTurnText.trim().length > 0
|
|
8090
|
+
? channelTurnText
|
|
8091
|
+
: channelReplyText(turnCompleteEvent.responseMessage);
|
|
8092
|
+
// A model or stream error surfaces as an `error` chunk and an empty reply, not as a
|
|
8093
|
+
// thrown turn. The mapper gets it on this one call, with the connector's error line
|
|
8094
|
+
// standing in for an empty reply, so the placeholder never sits there as if the agent
|
|
8095
|
+
// were still working. The error text itself stays on the run, not in the channel.
|
|
8096
|
+
const finalReply = {
|
|
8097
|
+
// Prefer what this turn streamed; a resumed message carries earlier turns' text too.
|
|
8098
|
+
// A failed turn always ends on the connector's error line, after any partial text.
|
|
8099
|
+
text: channelErrorLine
|
|
8100
|
+
? replyText.trim().length > 0
|
|
8101
|
+
? `${replyText.trimEnd()}\n\n${channelErrorLine}`
|
|
8102
|
+
: channelErrorLine
|
|
8103
|
+
: replyText,
|
|
8104
|
+
message: turnCompleteEvent.responseMessage,
|
|
8105
|
+
final: true,
|
|
8106
|
+
stopped: turnCompleteEvent.stopped,
|
|
8107
|
+
...(renderControls ? { pending: true } : {}),
|
|
8108
|
+
...(channelStreamErrorText
|
|
8109
|
+
? { error: new Error(channelStreamErrorText) }
|
|
8110
|
+
: {}),
|
|
8111
|
+
};
|
|
8112
|
+
let outboundMessage = null;
|
|
8113
|
+
if (!channelReplyDelivered) {
|
|
8114
|
+
try {
|
|
8115
|
+
outboundMessage = (finalChannelConn.outbound ?? defaultChannelOutbound)(finalReply);
|
|
8116
|
+
}
|
|
8117
|
+
catch (outboundError) {
|
|
8118
|
+
v3_1.logger.warn("chat.agent: channel outbound failed; posting the plain reply", {
|
|
8119
|
+
error: outboundError,
|
|
8120
|
+
});
|
|
8121
|
+
outboundMessage = defaultChannelOutbound(finalReply) ?? {
|
|
8122
|
+
text: "An unexpected error occurred",
|
|
8123
|
+
};
|
|
8124
|
+
}
|
|
8125
|
+
}
|
|
8126
|
+
const sendFinal = async (message) => {
|
|
8127
|
+
for (let attempt = 1;; attempt++) {
|
|
8128
|
+
try {
|
|
8129
|
+
const sent = await finalChannelConn.send(message, {
|
|
8130
|
+
event: wireChannelEvent?.event,
|
|
8131
|
+
handle: channelHandle,
|
|
8132
|
+
deliveryId: wireChannelEvent?.deliveryId,
|
|
8133
|
+
previousRef: channelAckRef,
|
|
8134
|
+
mode: finalChannelConn.delivery,
|
|
8135
|
+
final: true,
|
|
8136
|
+
bound: boundTurn,
|
|
8137
|
+
});
|
|
8138
|
+
channelFinalAnswerPosted = true;
|
|
8139
|
+
return sent ?? {};
|
|
8140
|
+
}
|
|
8141
|
+
catch (egressError) {
|
|
8142
|
+
if (attempt >= CHANNEL_FINAL_SEND_ATTEMPTS || !channelAckRef) {
|
|
8143
|
+
channelFinalSendFailed = true;
|
|
8144
|
+
v3_1.logger.error("chat.agent: channel egress send failed", {
|
|
8145
|
+
error: egressError,
|
|
8146
|
+
attempts: attempt,
|
|
8147
|
+
});
|
|
8148
|
+
return undefined;
|
|
8149
|
+
}
|
|
8150
|
+
v3_1.logger.warn("chat.agent: channel egress send failed; retrying", {
|
|
8151
|
+
error: egressError,
|
|
8152
|
+
attempt,
|
|
8153
|
+
});
|
|
8154
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
8155
|
+
}
|
|
8156
|
+
}
|
|
8157
|
+
};
|
|
8158
|
+
// A turn that pauses on a human decision often explains itself first (a summary,
|
|
8159
|
+
// the steps so far) and then calls the tool. That text is the answer so far: post it
|
|
8160
|
+
// as the placeholder edit, and post the controls as their own message after it.
|
|
8161
|
+
if (renderControls && outboundMessage && (await sendFinal(outboundMessage))) {
|
|
8162
|
+
channelAckRef = undefined;
|
|
8163
|
+
}
|
|
8164
|
+
const channelMessage = renderControls
|
|
8165
|
+
? finalChannelConn.renderInteraction(pendingToolCalls, wireChannelEvent
|
|
8166
|
+
? {
|
|
8167
|
+
event: wireChannelEvent.event,
|
|
8168
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
8169
|
+
}
|
|
8170
|
+
: {})
|
|
8171
|
+
: outboundMessage;
|
|
8172
|
+
if (channelMessage) {
|
|
8173
|
+
const controlsRef = (await sendFinal(channelMessage))?.ref;
|
|
8174
|
+
if (renderControls &&
|
|
8175
|
+
controlsRef &&
|
|
8176
|
+
sessionChannelBinding?.connectorId === finalChannelConn.id) {
|
|
8177
|
+
sessionChannelBinding = {
|
|
8178
|
+
...sessionChannelBinding,
|
|
8179
|
+
controls: {
|
|
8180
|
+
...sessionChannelBinding.controls,
|
|
8181
|
+
...Object.fromEntries(pendingToolCalls.map((call) => [call.toolCallId, controlsRef])),
|
|
8182
|
+
},
|
|
8183
|
+
};
|
|
8184
|
+
}
|
|
8185
|
+
}
|
|
8186
|
+
}
|
|
8187
|
+
if (channelTurnRan)
|
|
8188
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8189
|
+
clearChannelWorking();
|
|
8190
|
+
if (channelLifecycleStarted) {
|
|
8191
|
+
const stillPending = turnCompleteEvent.responseMessage
|
|
8192
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).length > 0
|
|
8193
|
+
: false;
|
|
8194
|
+
await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey)
|
|
8195
|
+
? "closed"
|
|
8196
|
+
: stillPending
|
|
8197
|
+
? "waiting"
|
|
8198
|
+
: "idle", { ...channelTurnCtx(wireChannelEvent, channelHandle), firstTurn: false });
|
|
8199
|
+
}
|
|
8200
|
+
// Lifecycle reaction: turn done. Remove "working", mark "done".
|
|
8201
|
+
if (wireChannelEvent && channelConn?.react) {
|
|
8202
|
+
try {
|
|
8203
|
+
if (channelWorkingReaction) {
|
|
8204
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8205
|
+
name: channelWorkingReaction,
|
|
8206
|
+
remove: true,
|
|
8207
|
+
});
|
|
8208
|
+
}
|
|
8209
|
+
const outcomeReaction = await resolveReactionChoice(channelStreamErrorText || channelFinalSendFailed
|
|
8210
|
+
? channelConn.reactions?.error
|
|
8211
|
+
: channelConn.reactions?.done, wireChannelEvent.event);
|
|
8212
|
+
if (outcomeReaction) {
|
|
8213
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8214
|
+
name: outcomeReaction,
|
|
8215
|
+
});
|
|
8216
|
+
}
|
|
8217
|
+
}
|
|
8218
|
+
catch (reactionError) {
|
|
8219
|
+
v3_1.logger.warn("chat.agent: channel done reaction failed", {
|
|
8220
|
+
error: reactionError,
|
|
8221
|
+
});
|
|
8222
|
+
}
|
|
8223
|
+
}
|
|
8224
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6238
8225
|
// Fire onTurnComplete — stream is closed, use for persistence.
|
|
6239
8226
|
if (onTurnComplete) {
|
|
6240
8227
|
await tracer_js_1.tracer.startActiveSpan("onTurnComplete()", async () => {
|
|
@@ -6352,6 +8339,7 @@ function chatAgent(options) {
|
|
|
6352
8339
|
// chat.endRun() — same exit, no upgrade semantics.
|
|
6353
8340
|
if (locals_js_1.locals.get(chatCloseRequestedKey)) {
|
|
6354
8341
|
await performChatClose();
|
|
8342
|
+
await reportChannelClosed();
|
|
6355
8343
|
return "exit";
|
|
6356
8344
|
}
|
|
6357
8345
|
if (locals_js_1.locals.get(chatUpgradeRequestedKey) || locals_js_1.locals.get(chatEndRunRequestedKey)) {
|
|
@@ -6423,6 +8411,7 @@ function chatAgent(options) {
|
|
|
6423
8411
|
currentWirePayload = next.output;
|
|
6424
8412
|
// Close signal — exit the loop gracefully
|
|
6425
8413
|
if (currentWirePayload.trigger === "close") {
|
|
8414
|
+
await reportChannelClosed();
|
|
6426
8415
|
return "exit";
|
|
6427
8416
|
}
|
|
6428
8417
|
return "continue";
|
|
@@ -6440,12 +8429,16 @@ function chatAgent(options) {
|
|
|
6440
8429
|
// Detach the turn's message handler first — left attached it would
|
|
6441
8430
|
// eat the very message the wait below is waiting for.
|
|
6442
8431
|
turnMsgSub?.off();
|
|
8432
|
+
turnControlSub?.off();
|
|
6443
8433
|
if (turnError instanceof Error &&
|
|
6444
8434
|
turnError.name === "AbortError" &&
|
|
6445
8435
|
runSignal.aborted) {
|
|
8436
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
8437
|
+
channelLifecycle.dispose();
|
|
6446
8438
|
// Full run cancellation — exit immediately
|
|
6447
8439
|
throw turnError;
|
|
6448
8440
|
}
|
|
8441
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6449
8442
|
// OOM errors must escape the turn loop so the task runtime can
|
|
6450
8443
|
// honor `retry.outOfMemory.machine` (set on chat.agent via
|
|
6451
8444
|
// `oomMachine`). Catching them here would keep the dead worker
|
|
@@ -6456,6 +8449,107 @@ function chatAgent(options) {
|
|
|
6456
8449
|
if (turnError instanceof v3_1.OutOfMemoryError) {
|
|
6457
8450
|
throw turnError;
|
|
6458
8451
|
}
|
|
8452
|
+
if (channelWireEvent && channelConn?.react) {
|
|
8453
|
+
try {
|
|
8454
|
+
if (channelWorkingReaction) {
|
|
8455
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8456
|
+
name: channelWorkingReaction,
|
|
8457
|
+
remove: true,
|
|
8458
|
+
});
|
|
8459
|
+
}
|
|
8460
|
+
const errorReaction = await resolveReactionChoice(channelConn.reactions?.error, channelWireEvent.event);
|
|
8461
|
+
if (errorReaction) {
|
|
8462
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8463
|
+
name: errorReaction,
|
|
8464
|
+
});
|
|
8465
|
+
}
|
|
8466
|
+
}
|
|
8467
|
+
catch (reactionError) {
|
|
8468
|
+
v3_1.logger.warn("chat.agent: channel error reaction failed", { error: reactionError });
|
|
8469
|
+
}
|
|
8470
|
+
}
|
|
8471
|
+
// The channel gets the connector's error line, never the error itself: the run keeps that.
|
|
8472
|
+
const channelErrorText = channelReplyStream || ((channelWireEvent || boundTurn) && channelConn?.send)
|
|
8473
|
+
? await channelTurnErrorMessage(channelConn, {
|
|
8474
|
+
error: turnError instanceof Error ? turnError : new Error(String(turnError)),
|
|
8475
|
+
partialText: channelTurnText?.trim() ?? "",
|
|
8476
|
+
event: channelWireEvent?.event,
|
|
8477
|
+
runId: ctx.run.id,
|
|
8478
|
+
chatId: currentWirePayload.chatId,
|
|
8479
|
+
})
|
|
8480
|
+
: exports.CHANNEL_TURN_ERROR_MESSAGE;
|
|
8481
|
+
let errorStreamDelivered = false;
|
|
8482
|
+
if (channelReplyStream) {
|
|
8483
|
+
const replyStream = channelReplyStream;
|
|
8484
|
+
channelReplyStream = undefined;
|
|
8485
|
+
try {
|
|
8486
|
+
const ended = await replyStream.end({
|
|
8487
|
+
stopped: false,
|
|
8488
|
+
pending: false,
|
|
8489
|
+
errorText: channelErrorText,
|
|
8490
|
+
});
|
|
8491
|
+
errorStreamDelivered = ended.delivered;
|
|
8492
|
+
}
|
|
8493
|
+
catch (streamError) {
|
|
8494
|
+
v3_1.logger.warn("chat.agent: channel reply stream end failed", {
|
|
8495
|
+
error: streamError,
|
|
8496
|
+
});
|
|
8497
|
+
}
|
|
8498
|
+
finally {
|
|
8499
|
+
clearOpenReplyStream();
|
|
8500
|
+
}
|
|
8501
|
+
}
|
|
8502
|
+
if ((channelWireEvent || boundTurn) && channelConn?.send) {
|
|
8503
|
+
const errorPreviousRef = channelFinalAnswerPosted ? undefined : channelAckRef;
|
|
8504
|
+
const channelErrorMessageId = errorPreviousRef ?? channelWireEvent?.deliveryId ?? `channel-error-${turn}`;
|
|
8505
|
+
let errorMessage = null;
|
|
8506
|
+
try {
|
|
8507
|
+
errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
|
|
8508
|
+
text: channelErrorText,
|
|
8509
|
+
message: {
|
|
8510
|
+
id: channelErrorMessageId,
|
|
8511
|
+
role: "assistant",
|
|
8512
|
+
parts: [{ type: "text", text: channelErrorText }],
|
|
8513
|
+
},
|
|
8514
|
+
final: true,
|
|
8515
|
+
stopped: false,
|
|
8516
|
+
error: turnError,
|
|
8517
|
+
});
|
|
8518
|
+
}
|
|
8519
|
+
catch (outboundError) {
|
|
8520
|
+
v3_1.logger.warn("chat.agent: channel error outbound failed; posting the plain error", {
|
|
8521
|
+
error: outboundError,
|
|
8522
|
+
});
|
|
8523
|
+
errorMessage = { text: channelErrorText };
|
|
8524
|
+
}
|
|
8525
|
+
if (errorMessage && !errorStreamDelivered) {
|
|
8526
|
+
try {
|
|
8527
|
+
await channelConn.send(errorMessage, {
|
|
8528
|
+
event: channelWireEvent?.event,
|
|
8529
|
+
handle: channelHandle,
|
|
8530
|
+
deliveryId: channelWireEvent?.deliveryId,
|
|
8531
|
+
previousRef: errorPreviousRef,
|
|
8532
|
+
mode: channelConn.delivery,
|
|
8533
|
+
final: true,
|
|
8534
|
+
bound: boundTurn,
|
|
8535
|
+
});
|
|
8536
|
+
}
|
|
8537
|
+
catch (egressError) {
|
|
8538
|
+
v3_1.logger.warn("chat.agent: channel error egress send failed", {
|
|
8539
|
+
error: egressError,
|
|
8540
|
+
});
|
|
8541
|
+
}
|
|
8542
|
+
}
|
|
8543
|
+
}
|
|
8544
|
+
if (channelTurnRan)
|
|
8545
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8546
|
+
clearChannelWorking();
|
|
8547
|
+
if (channelLifecycleStarted) {
|
|
8548
|
+
await channelLifecycle.report(channelConn, locals_js_1.locals.get(chatCloseRequestedKey) ? "closed" : "idle", {
|
|
8549
|
+
...channelTurnCtx(channelWireEvent, channelHandle),
|
|
8550
|
+
firstTurn: false,
|
|
8551
|
+
});
|
|
8552
|
+
}
|
|
6459
8553
|
let errorTurnCompleteResult;
|
|
6460
8554
|
try {
|
|
6461
8555
|
await withChatWriter(async (writer) => {
|
|
@@ -6712,6 +8806,7 @@ function chatAgent(options) {
|
|
|
6712
8806
|
.get(chatManagedResponseKey)
|
|
6713
8807
|
?.close()
|
|
6714
8808
|
.catch(() => { });
|
|
8809
|
+
turnControlSub?.off();
|
|
6715
8810
|
}
|
|
6716
8811
|
}
|
|
6717
8812
|
}
|
|
@@ -6735,6 +8830,45 @@ function chatAgent(options) {
|
|
|
6735
8830
|
schema: clientDataSchema,
|
|
6736
8831
|
});
|
|
6737
8832
|
}
|
|
8833
|
+
// Claim each listed chat.event descriptor: subscribe THIS agent's session to the event's endpoint.
|
|
8834
|
+
// The subscriber id is scoped by agent, so the same descriptor on two agents is two subscribers.
|
|
8835
|
+
if (events) {
|
|
8836
|
+
for (const wh of events) {
|
|
8837
|
+
v3_1.resourceCatalog.markSessionWebhookClaimed(wh.id);
|
|
8838
|
+
v3_1.resourceCatalog.registerWebhookSubscriber({
|
|
8839
|
+
endpointId: wh.endpointId,
|
|
8840
|
+
target: {
|
|
8841
|
+
type: "session",
|
|
8842
|
+
id: `${options.id}:${wh.id}`,
|
|
8843
|
+
taskIdentifier: options.id,
|
|
8844
|
+
keyTemplate: wh.key,
|
|
8845
|
+
actionType: wh.type,
|
|
8846
|
+
deliverAs: "action",
|
|
8847
|
+
...(wh.filter ? { filter: wh.filter } : {}),
|
|
8848
|
+
},
|
|
8849
|
+
});
|
|
8850
|
+
}
|
|
8851
|
+
}
|
|
8852
|
+
// Claim each listed channel connector: the same kind of subscriber, but deliverAs "message" so a
|
|
8853
|
+
// verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
|
|
8854
|
+
if (channels) {
|
|
8855
|
+
for (const ch of channels) {
|
|
8856
|
+
v3_1.resourceCatalog.markSessionWebhookClaimed(ch.id);
|
|
8857
|
+
v3_1.resourceCatalog.registerWebhookSubscriber({
|
|
8858
|
+
endpointId: ch.endpointId,
|
|
8859
|
+
target: {
|
|
8860
|
+
type: "session",
|
|
8861
|
+
id: `${options.id}:${ch.id}`,
|
|
8862
|
+
taskIdentifier: options.id,
|
|
8863
|
+
keyTemplate: ch.key,
|
|
8864
|
+
connectorId: ch.id,
|
|
8865
|
+
deliverAs: "message",
|
|
8866
|
+
...(ch.filter ? { filter: ch.filter } : {}),
|
|
8867
|
+
...(ch.startOn ? { startOn: ch.startOn } : {}),
|
|
8868
|
+
},
|
|
8869
|
+
});
|
|
8870
|
+
}
|
|
8871
|
+
}
|
|
6738
8872
|
return task;
|
|
6739
8873
|
}
|
|
6740
8874
|
function composeHooks(builderHook, taskHook) {
|
|
@@ -7030,6 +9164,22 @@ function resolveUIMessageStreamOptions() {
|
|
|
7030
9164
|
// ---------------------------------------------------------------------------
|
|
7031
9165
|
// Stop detection
|
|
7032
9166
|
// ---------------------------------------------------------------------------
|
|
9167
|
+
/**
|
|
9168
|
+
* The channel surface of the current turn, from anywhere inside a `chat.agent` run, including a
|
|
9169
|
+
* tool's `execute`. Undefined on a browser turn or when the connector defines no reactions and no
|
|
9170
|
+
* `surface()`. Pass the connector's surface type to get its helpers typed:
|
|
9171
|
+
*
|
|
9172
|
+
* @example
|
|
9173
|
+
* ```ts
|
|
9174
|
+
* execute: async ({ steps }) => {
|
|
9175
|
+
* const linear = chat.surface<LinearRunSurface>();
|
|
9176
|
+
* if (linear) await linear.plan(steps);
|
|
9177
|
+
* }
|
|
9178
|
+
* ```
|
|
9179
|
+
*/
|
|
9180
|
+
function getChannelSurface() {
|
|
9181
|
+
return locals_js_1.locals.get(chatChannelSurfaceKey);
|
|
9182
|
+
}
|
|
7033
9183
|
/**
|
|
7034
9184
|
* Check whether the user stopped generation during the current turn.
|
|
7035
9185
|
*
|
|
@@ -9027,6 +11177,10 @@ function createChatLoadTranscriptAction(storage, options) {
|
|
|
9027
11177
|
exports.chat = {
|
|
9028
11178
|
/** Create a chat agent. See {@link chatAgent}. */
|
|
9029
11179
|
agent: chatAgent,
|
|
11180
|
+
/** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
|
|
11181
|
+
event: webhooks_js_1.chatEvent,
|
|
11182
|
+
/** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
|
|
11183
|
+
channels: chatChannels,
|
|
9030
11184
|
/** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
|
|
9031
11185
|
customAgent: chatCustomAgent,
|
|
9032
11186
|
/** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
|
|
@@ -9045,6 +11199,8 @@ exports.chat = {
|
|
|
9045
11199
|
local: chatLocal,
|
|
9046
11200
|
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
|
|
9047
11201
|
createAccessToken: createChatAccessToken,
|
|
11202
|
+
/** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
|
|
11203
|
+
createWatchToken: createChatWatchToken,
|
|
9048
11204
|
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
|
|
9049
11205
|
setTurnTimeout,
|
|
9050
11206
|
/** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
|
|
@@ -9055,6 +11211,8 @@ exports.chat = {
|
|
|
9055
11211
|
setUIMessageStreamOptions,
|
|
9056
11212
|
/** Check if the current turn was stopped by the user. See {@link isStopped}. */
|
|
9057
11213
|
isStopped,
|
|
11214
|
+
/** The current turn's channel surface (reactions plus connector helpers), also from inside a tool. See {@link getChannelSurface}. */
|
|
11215
|
+
surface: getChannelSurface,
|
|
9058
11216
|
/** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
|
|
9059
11217
|
requestUpgrade,
|
|
9060
11218
|
/** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
|