@trigger.dev/sdk 4.7.3 → 4.8.0-webhooks.20261015065339
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commonjs/v3/ai.d.ts +560 -8
- package/dist/commonjs/v3/ai.js +2263 -178
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +23 -0
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/concurrencyLimits.d.ts +37 -7
- package/dist/commonjs/v3/concurrencyLimits.js +38 -70
- package/dist/commonjs/v3/concurrencyLimits.js.map +1 -1
- package/dist/commonjs/v3/index.d.ts +3 -1
- 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/runs.d.ts +22 -1
- package/dist/commonjs/v3/runs.js +6 -3
- package/dist/commonjs/v3/runs.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 +178 -2
- package/dist/commonjs/v3/webhooks.js +322 -1
- package/dist/commonjs/v3/webhooks.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +560 -8
- package/dist/esm/v3/ai.js +2257 -178
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +23 -0
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/concurrencyLimits.d.ts +37 -7
- package/dist/esm/v3/concurrencyLimits.js +38 -70
- package/dist/esm/v3/concurrencyLimits.js.map +1 -1
- package/dist/esm/v3/index.d.ts +3 -1
- 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/runs.d.ts +22 -1
- package/dist/esm/v3/runs.js +6 -3
- package/dist/esm/v3/runs.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 +178 -2
- package/dist/esm/v3/webhooks.js +318 -1
- package/dist/esm/v3/webhooks.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/actions.mdx +6 -1
- package/docs/ai-chat/backend.mdx +34 -0
- package/docs/ai-chat/patterns/sub-agents.mdx +33 -0
- package/docs/ai-chat/pending-messages.mdx +2 -0
- package/docs/ai-chat/reference.mdx +50 -2
- package/docs/apikeys.mdx +3 -3
- package/docs/billing-limits.mdx +1 -1
- package/docs/cli-env-commands.mdx +5 -4
- package/docs/cli-init-commands.mdx +27 -1
- package/docs/cli-login-commands.mdx +20 -0
- package/docs/cli-projects-commands.mdx +13 -1
- package/docs/cli-runs-commands.mdx +3 -2
- package/docs/cli-update-commands.mdx +9 -3
- package/docs/cli-whoami-commands.mdx +12 -0
- package/docs/concurrency.mdx +32 -0
- package/docs/config/extensions/audioWaveform.mdx +4 -1
- package/docs/config/extensions/overview.mdx +2 -0
- package/docs/config/extensions/prismaExtension.mdx +2 -3
- package/docs/config/extensions/puppeteer.mdx +3 -5
- package/docs/config/extensions/pythonExtension.mdx +9 -1
- package/docs/config/extensions/syncEnvVars.mdx +6 -0
- package/docs/deploy-environment-variables.mdx +1 -1
- package/docs/deployment/atomic-deployment.mdx +2 -0
- package/docs/deployment/dev-branches.mdx +5 -5
- package/docs/deployment/overview.mdx +8 -7
- package/docs/deployment/preview-branches.mdx +6 -3
- package/docs/errors-retrying.mdx +4 -4
- package/docs/how-it-works.mdx +5 -5
- package/docs/introduction.mdx +6 -0
- package/docs/limits.mdx +26 -8
- package/docs/logging.mdx +1 -1
- package/docs/machines.mdx +1 -1
- package/docs/mcp-introduction.mdx +8 -7
- package/docs/mcp-tools.mdx +91 -1
- package/docs/migrating-from-v3.mdx +1 -1
- package/docs/observability/dashboards.mdx +2 -0
- package/docs/observability/query.mdx +6 -2
- package/docs/private-networking/aws-console-setup.mdx +1 -1
- package/docs/private-networking/overview.mdx +1 -1
- package/docs/quick-start.mdx +3 -40
- package/docs/realtime/auth.mdx +1 -1
- package/docs/realtime/backend/input-streams.mdx +159 -0
- package/docs/realtime/backend/streams.mdx +25 -10
- package/docs/realtime/how-it-works.mdx +1 -1
- package/docs/realtime/react-hooks/overview.mdx +1 -1
- package/docs/realtime/react-hooks/streams.mdx +2 -2
- package/docs/realtime/react-hooks/subscribe.mdx +19 -3
- package/docs/realtime/react-hooks/triggering.mdx +2 -2
- package/docs/realtime/react-hooks/use-wait-token.mdx +1 -1
- package/docs/realtime/run-object.mdx +47 -24
- package/docs/replaying.mdx +26 -1
- package/docs/reports.mdx +4 -2
- package/docs/run-tests.mdx +2 -2
- package/docs/run-usage.mdx +3 -3
- package/docs/runs/heartbeats.mdx +2 -2
- package/docs/runs/list-best-practices.mdx +234 -0
- package/docs/runs/metadata.mdx +7 -10
- package/docs/runs.mdx +18 -8
- package/docs/self-hosting/env/supervisor.mdx +2 -0
- package/docs/self-hosting/env/webapp.mdx +5 -0
- package/docs/self-hosting/overview.mdx +26 -0
- package/docs/tags.mdx +6 -0
- package/docs/tasks/scheduled.mdx +11 -5
- package/docs/triggering.mdx +8 -0
- package/docs/troubleshooting-alerts.mdx +102 -3
- package/docs/troubleshooting.mdx +1 -1
- package/docs/upgrading-packages.mdx +3 -3
- package/docs/vercel-integration.mdx +13 -3
- package/docs/versioning.mdx +1 -1
- package/docs/wait-for-token.mdx +3 -3
- package/docs/webhooks/channels.mdx +128 -0
- package/docs/webhooks/connect.mdx +80 -0
- package/docs/webhooks/deliveries.mdx +92 -0
- package/docs/webhooks/filters.mdx +109 -0
- package/docs/webhooks/human-in-the-loop.mdx +149 -0
- package/docs/webhooks/linear.mdx +185 -0
- package/docs/webhooks/overview.mdx +99 -0
- package/docs/webhooks/session-routing.mdx +132 -0
- package/docs/webhooks/slack.mdx +360 -0
- package/docs/webhooks/sources.mdx +217 -0
- package/docs/webhooks/waiters.mdx +170 -0
- package/package.json +3 -2
package/dist/esm/v3/ai.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_CONSUMED_ID_HEADER, SESSION_IN_EVENT_ID_HEADER, sessionStreams, SessionChannelRouter, InputStreamTimeoutError, taskContext, TRIGGER_CONTROL_SUBTYPE, SESSION_CLOSED_HEADER, SESSION_CLOSED_REASON_HEADER, tryCatch, } from "@trigger.dev/core/v3";
|
|
2
|
+
import { chatEvent, normalizeKeyString } from "./webhooks.js";
|
|
3
|
+
import { fetchPublicFile } from "./modelFileDownload.js";
|
|
2
4
|
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
|
|
3
5
|
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
|
|
4
6
|
import { trace } from "@opentelemetry/api";
|
|
@@ -73,6 +75,14 @@ const chatSessionHandleKey = locals.create("chat.sessionHandle");
|
|
|
73
75
|
// is documented to carry. Custom-agent loops never set per-turn context, so subtask tool
|
|
74
76
|
// metadata reads this directly rather than the Session handle id.
|
|
75
77
|
const chatExternalIdKey = locals.create("chat.externalId");
|
|
78
|
+
/**
|
|
79
|
+
* Set at boot when a continuation run inherited an interrupted turn (a
|
|
80
|
+
* partial assistant reply on `session.out`). Consumed by the first channel
|
|
81
|
+
* turn's ack so the re-emitted placeholder reads as a recovery resume rather
|
|
82
|
+
* than a silent duplicate. Boxed so the ack site can flip it to false.
|
|
83
|
+
* @internal
|
|
84
|
+
*/
|
|
85
|
+
const chatChannelRecoveryPendingKey = locals.create("chat.channelRecoveryPending");
|
|
76
86
|
/**
|
|
77
87
|
* S2 seq_num of the most recent `turn-complete` control record written by
|
|
78
88
|
* this worker. Read by `writeTurnCompleteChunk` to know what to trim back
|
|
@@ -356,14 +366,23 @@ export function __setReplaySessionInTailImplForTests(impl) {
|
|
|
356
366
|
* @internal
|
|
357
367
|
*/
|
|
358
368
|
async function replaySessionInTail(sessionId, options) {
|
|
369
|
+
return (await replaySessionInTailWithChannels(sessionId, options)).messages;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The full unprocessed `session.in` tail: browser messages and channel deliveries, each with its
|
|
373
|
+
* seq_num, so recovery can dispatch them in the order they arrived rather than messages first.
|
|
374
|
+
*/
|
|
375
|
+
async function replaySessionInTailWithChannels(sessionId, options) {
|
|
359
376
|
if (replaySessionInTailImpl) {
|
|
360
|
-
|
|
377
|
+
const replayed = await replaySessionInTailImpl(sessionId, options);
|
|
378
|
+
return Array.isArray(replayed) ? { messages: replayed, channelEvents: [] } : replayed;
|
|
361
379
|
}
|
|
362
380
|
const apiClient = apiClientManager.clientOrThrow();
|
|
363
381
|
const response = await apiClient.readSessionStreamRecords(sessionId, "in", {
|
|
364
382
|
afterEventId: options?.lastEventId,
|
|
365
383
|
});
|
|
366
384
|
const out = [];
|
|
385
|
+
const channelEvents = [];
|
|
367
386
|
for (const record of response.records) {
|
|
368
387
|
// session.in writers POST `JSON.stringify(chunk)` directly; the
|
|
369
388
|
// webapp wraps that in `{ data: <string>, id }` and stores it on
|
|
@@ -390,15 +409,23 @@ async function replaySessionInTail(sessionId, options) {
|
|
|
390
409
|
if (!payload || payload.trigger !== "submit-message")
|
|
391
410
|
continue;
|
|
392
411
|
const message = payload.message;
|
|
393
|
-
if (!message || typeof message !== "object")
|
|
412
|
+
if (!message || typeof message !== "object") {
|
|
413
|
+
if (payload.channelEvent && typeof payload.channelEvent === "object") {
|
|
414
|
+
channelEvents.push({
|
|
415
|
+
channelEvent: payload.channelEvent,
|
|
416
|
+
metadata: payload.metadata,
|
|
417
|
+
seqNum: record.seqNum,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
394
420
|
continue;
|
|
421
|
+
}
|
|
395
422
|
out.push({
|
|
396
423
|
message: message,
|
|
397
424
|
metadata: payload.metadata,
|
|
398
425
|
seqNum: record.seqNum,
|
|
399
426
|
});
|
|
400
427
|
}
|
|
401
|
-
return out;
|
|
428
|
+
return { messages: out, channelEvents };
|
|
402
429
|
}
|
|
403
430
|
/**
|
|
404
431
|
* Test-only entry point that bypasses
|
|
@@ -659,6 +686,37 @@ export const ai = {
|
|
|
659
686
|
function createChatAccessToken(taskId) {
|
|
660
687
|
return auth.createTriggerPublicToken(taskId, { expirationTime: "24h" });
|
|
661
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Mint a read-only, session-scoped token for WATCHING a chat session by its `externalId`.
|
|
691
|
+
*
|
|
692
|
+
* A session is addressed by `externalId`, so the same session is reachable from any surface that
|
|
693
|
+
* knows it: a web `useChat` client and a Slack thread converge on one session when they share an
|
|
694
|
+
* `externalId` (for a channel, the connector's `key` template produces it). This token carries
|
|
695
|
+
* `read:sessions:{externalId}` only (no write): the holder can subscribe to the session's `.out`
|
|
696
|
+
* stream (observe the conversation, hydrating history from the snapshot) but cannot append to `.in`.
|
|
697
|
+
*
|
|
698
|
+
* Use it for a dashboard viewer or a cross-surface watcher of a Slack-thread session. Run server-side
|
|
699
|
+
* (needs the secret key). To CONTINUE a session from another surface (drive it, not just watch), mint
|
|
700
|
+
* a read+write token instead ({@link createChatStartSessionAction}).
|
|
701
|
+
*
|
|
702
|
+
* @example
|
|
703
|
+
* ```ts
|
|
704
|
+
* // actions.ts
|
|
705
|
+
* "use server";
|
|
706
|
+
* import { chat } from "@trigger.dev/sdk/ai";
|
|
707
|
+
*
|
|
708
|
+
* export const watchChat = (externalId: string) => chat.createWatchToken(externalId);
|
|
709
|
+
* ```
|
|
710
|
+
*/
|
|
711
|
+
function createChatWatchToken(externalId, options) {
|
|
712
|
+
if (!externalId) {
|
|
713
|
+
throw new Error("chat.createWatchToken: externalId is required (the session addressing key).");
|
|
714
|
+
}
|
|
715
|
+
return auth.createPublicToken({
|
|
716
|
+
scopes: { read: { sessions: externalId } },
|
|
717
|
+
expirationTime: options?.tokenTTL ?? "1h",
|
|
718
|
+
});
|
|
719
|
+
}
|
|
662
720
|
function createChatOutGate() {
|
|
663
721
|
let resolveOpened;
|
|
664
722
|
const opened = new Promise((resolve) => {
|
|
@@ -1895,7 +1953,16 @@ const chatInstructionsConsumedKey = locals.create("chat.injectedInstructionsCons
|
|
|
1895
1953
|
* @internal
|
|
1896
1954
|
*/
|
|
1897
1955
|
const chatPipeCountKey = locals.create("chat.pipeCount");
|
|
1956
|
+
/**
|
|
1957
|
+
* The current turn's channel observer tap, set before run() when the turn has channel observers
|
|
1958
|
+
* (ack stream edits, tool progress) and cleared at the turn boundary. `pipeChat` applies it so a
|
|
1959
|
+
* manual `chat.pipe()` inside run() feeds the same observers as the automatic pipe.
|
|
1960
|
+
* @internal
|
|
1961
|
+
*/
|
|
1962
|
+
const chatTurnChannelTapKey = locals.create("chat.turnChannelTap");
|
|
1898
1963
|
const chatStopControllerKey = locals.create("chat.stopController");
|
|
1964
|
+
const chatChannelSurfaceKey = locals.create("chat.channelSurface");
|
|
1965
|
+
const chatChannelDownloadKey = locals.create("chat.channelDownload");
|
|
1899
1966
|
/** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */
|
|
1900
1967
|
const chatUIStreamStaticKey = locals.create("chat.uiMessageStreamOptions.static");
|
|
1901
1968
|
/** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */
|
|
@@ -3271,7 +3338,7 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3271
3338
|
* caller's value winning because `rest` is spread after `managed`. Pulling a
|
|
3272
3339
|
* key out to "handle" it is how a caller's option gets silently dropped.
|
|
3273
3340
|
*/
|
|
3274
|
-
const { tools, system: callerSystem, prepareStep: callerPrepareStep, ...rest } = options;
|
|
3341
|
+
const { tools, system: callerSystem, prepareStep: callerPrepareStep, experimental_download: callerDownload, ...rest } = options;
|
|
3275
3342
|
const managed = toStreamTextOptions({
|
|
3276
3343
|
registry,
|
|
3277
3344
|
system: callerSystem ?? agentSystem,
|
|
@@ -3314,6 +3381,12 @@ function buildManagedStreamTextOptions(options, config) {
|
|
|
3314
3381
|
if (typeof managed.prepareStep === "function") {
|
|
3315
3382
|
managed.prepareStep = retainStepMessages(managed.prepareStep);
|
|
3316
3383
|
}
|
|
3384
|
+
if (typeof callerDownload === "function") {
|
|
3385
|
+
const channelDownload = locals.get(chatChannelDownloadKey);
|
|
3386
|
+
managed.experimental_download = channelDownload
|
|
3387
|
+
? channelAwareDownload(channelDownload, callerDownload)
|
|
3388
|
+
: callerDownload;
|
|
3389
|
+
}
|
|
3317
3390
|
return { ...managed, ...rest };
|
|
3318
3391
|
}
|
|
3319
3392
|
/** @internal Test hook for {@link buildManagedStreamTextOptions}. */
|
|
@@ -3543,6 +3616,10 @@ function toStreamTextOptions(options, retain = true) {
|
|
|
3543
3616
|
if (retain && typeof result.prepareStep === "function") {
|
|
3544
3617
|
result.prepareStep = retainStepMessages(result.prepareStep);
|
|
3545
3618
|
}
|
|
3619
|
+
const channelDownload = locals.get(chatChannelDownloadKey);
|
|
3620
|
+
if (channelDownload) {
|
|
3621
|
+
result.experimental_download = channelAwareDownload(channelDownload);
|
|
3622
|
+
}
|
|
3546
3623
|
return result;
|
|
3547
3624
|
}
|
|
3548
3625
|
const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
@@ -3557,6 +3634,9 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3557
3634
|
* tools, steering, compaction, injected instructions and persistence, with no
|
|
3558
3635
|
* action-specific handling.
|
|
3559
3636
|
*
|
|
3637
|
+
* Pass `message` to have the turn answer a new user message, appended after the edit: a click on a
|
|
3638
|
+
* "Explain more" button becomes `chat.turn("Explain that in more detail.")`.
|
|
3639
|
+
*
|
|
3560
3640
|
* @example
|
|
3561
3641
|
* ```ts
|
|
3562
3642
|
* onAction: async ({ action }) => {
|
|
@@ -3564,12 +3644,13 @@ const actionTurnBrand = Symbol.for("trigger.dev/chat/actionTurn");
|
|
|
3564
3644
|
* chat.history.slice(0, -1);
|
|
3565
3645
|
* return chat.turn();
|
|
3566
3646
|
* }
|
|
3647
|
+
* if (action.type === "ask") return chat.turn(action.question);
|
|
3567
3648
|
* if (action.type === "undo") chat.history.slice(0, -2); // no turn
|
|
3568
3649
|
* },
|
|
3569
3650
|
* ```
|
|
3570
3651
|
*/
|
|
3571
|
-
function chatTurn() {
|
|
3572
|
-
return { [actionTurnBrand]: true };
|
|
3652
|
+
function chatTurn(message) {
|
|
3653
|
+
return (message === undefined ? { [actionTurnBrand]: true } : { [actionTurnBrand]: true, message });
|
|
3573
3654
|
}
|
|
3574
3655
|
function isActionTurn(value) {
|
|
3575
3656
|
return typeof value === "object" && value !== null && value[actionTurnBrand] === true;
|
|
@@ -3635,6 +3716,21 @@ function warnMissingOnActionOnce() {
|
|
|
3635
3716
|
function isAsyncIterable(value) {
|
|
3636
3717
|
return typeof value === "object" && value !== null && Symbol.asyncIterator in value;
|
|
3637
3718
|
}
|
|
3719
|
+
function readableStreamFromAsyncIterable(source) {
|
|
3720
|
+
const iterator = source[Symbol.asyncIterator]();
|
|
3721
|
+
return new ReadableStream({
|
|
3722
|
+
async pull(controller) {
|
|
3723
|
+
const next = await iterator.next();
|
|
3724
|
+
if (next.done)
|
|
3725
|
+
controller.close();
|
|
3726
|
+
else
|
|
3727
|
+
controller.enqueue(next.value);
|
|
3728
|
+
},
|
|
3729
|
+
async cancel(reason) {
|
|
3730
|
+
await iterator.return?.(reason);
|
|
3731
|
+
},
|
|
3732
|
+
});
|
|
3733
|
+
}
|
|
3638
3734
|
function isReadableStream(value) {
|
|
3639
3735
|
return (typeof value === "object" && value !== null && typeof value.getReader === "function");
|
|
3640
3736
|
}
|
|
@@ -3690,6 +3786,10 @@ async function pipeChat(source, options, capture) {
|
|
|
3690
3786
|
throw new Error("pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " +
|
|
3691
3787
|
"an AsyncIterable, or a ReadableStream");
|
|
3692
3788
|
}
|
|
3789
|
+
const channelTap = locals.get(chatTurnChannelTapKey);
|
|
3790
|
+
if (channelTap) {
|
|
3791
|
+
stream = channelTap(isReadableStream(stream) ? stream : readableStreamFromAsyncIterable(stream));
|
|
3792
|
+
}
|
|
3693
3793
|
const pipeOptions = {};
|
|
3694
3794
|
if (options?.signal) {
|
|
3695
3795
|
pipeOptions.signal = options.signal;
|
|
@@ -3719,160 +3819,1027 @@ async function pipeChat(source, options, capture) {
|
|
|
3719
3819
|
await waitUntilComplete();
|
|
3720
3820
|
}
|
|
3721
3821
|
}
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
const
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3822
|
+
/**
|
|
3823
|
+
* A generic chat-frontend channel over any verified source. Unlike `slack()`, you supply the egress
|
|
3824
|
+
* `send` yourself (post/edit the reply back to your surface), so the whole round-trip is under your
|
|
3825
|
+
* control. Use for providers without a preset, or to test the channel round-trip end to end.
|
|
3826
|
+
*/
|
|
3827
|
+
export function chatChannelCustom(options) {
|
|
3828
|
+
const { id, endpoint, key, inbound, bind, seed, outbound, ack, send, renderInteraction, onInteraction, finalizeInteraction, react, reactions, progress, surface, onControl, stream, endInterruptedStream, download, received, toAction, lifecycle, cancelInteraction, filter, startOn, delivery, } = options;
|
|
3829
|
+
if (!endpoint?.id) {
|
|
3830
|
+
throw new Error(`chat.channels.custom("${id}"): an endpoint is required. Declare one with webhooks.endpoint.define() and pass it as { endpoint }.`);
|
|
3831
|
+
}
|
|
3832
|
+
resourceCatalog.registerDeclaredSessionWebhook(id);
|
|
3833
|
+
return {
|
|
3834
|
+
id,
|
|
3835
|
+
endpointId: endpoint.id,
|
|
3836
|
+
source: endpoint.source,
|
|
3837
|
+
key: normalizeKeyString(key),
|
|
3838
|
+
filter,
|
|
3839
|
+
startOn,
|
|
3840
|
+
inbound,
|
|
3841
|
+
bind,
|
|
3842
|
+
seed,
|
|
3843
|
+
outbound,
|
|
3844
|
+
ack,
|
|
3845
|
+
send,
|
|
3846
|
+
renderInteraction,
|
|
3847
|
+
onInteraction,
|
|
3848
|
+
finalizeInteraction,
|
|
3849
|
+
react,
|
|
3850
|
+
reactions,
|
|
3851
|
+
progress,
|
|
3852
|
+
surface,
|
|
3853
|
+
onControl,
|
|
3854
|
+
stream,
|
|
3855
|
+
endInterruptedStream,
|
|
3856
|
+
download,
|
|
3857
|
+
received,
|
|
3858
|
+
toAction,
|
|
3859
|
+
lifecycle,
|
|
3860
|
+
cancelInteraction,
|
|
3861
|
+
delivery: delivery ?? "final",
|
|
3862
|
+
};
|
|
3863
|
+
}
|
|
3864
|
+
const chatChannels = {
|
|
3865
|
+
/** A generic chat-frontend channel over any source, with your own egress. See {@link chatChannelCustom}. */
|
|
3866
|
+
custom: chatChannelCustom,
|
|
3867
|
+
};
|
|
3868
|
+
// Prior surface messages from a connector's seed(), as transcript messages stamped with provenance.
|
|
3869
|
+
function channelSeedMessages(seed, provenance) {
|
|
3870
|
+
return seed
|
|
3871
|
+
.filter((m) => m && typeof m.text === "string" && m.text.length > 0)
|
|
3872
|
+
.map((m, index) => {
|
|
3873
|
+
const meta = {
|
|
3874
|
+
...provenance,
|
|
3875
|
+
seeded: true,
|
|
3876
|
+
...(m.externalUserId ? { externalUserId: m.externalUserId } : {}),
|
|
3877
|
+
...(m.externalMessageId ? { externalMessageId: m.externalMessageId } : {}),
|
|
3878
|
+
};
|
|
3879
|
+
return {
|
|
3880
|
+
id: `seed-${m.externalMessageId ?? `${provenance.deliveryId}-${index}`}`,
|
|
3881
|
+
role: m.role === "assistant" ? "assistant" : "user",
|
|
3882
|
+
parts: [{ type: "text", text: m.text }],
|
|
3883
|
+
metadata: meta,
|
|
3884
|
+
};
|
|
3885
|
+
});
|
|
3886
|
+
}
|
|
3887
|
+
// Turn a channel connector's inbound() result into the turn's user UIMessage, stamped with where
|
|
3888
|
+
// it came from so a transcript storage can attribute it (see ChannelMessageMetadata).
|
|
3889
|
+
/**
|
|
3890
|
+
* True when a connector's `inbound()` result carries nothing to answer: blank text and no file or
|
|
3891
|
+
* data parts. A button click from another app on a shared endpoint maps to one.
|
|
3892
|
+
*/
|
|
3893
|
+
function isEmptyChannelInbound(input) {
|
|
3894
|
+
const message = input !== null && typeof input === "object" && "message" in input && !("role" in input)
|
|
3895
|
+
? input.message
|
|
3896
|
+
: input;
|
|
3897
|
+
if (typeof message === "string")
|
|
3898
|
+
return message.trim() === "";
|
|
3899
|
+
return (message.parts ?? []).every((part) => part.type === "text" && part.text?.trim() === "");
|
|
3900
|
+
}
|
|
3901
|
+
/** Bytes held for a run's channel file downloads before the oldest are dropped and fetched again. */
|
|
3902
|
+
const CHANNEL_DOWNLOAD_CACHE_BYTES = 64 * 1024 * 1024;
|
|
3903
|
+
/**
|
|
3904
|
+
* One downloader for a run's connectors that define `download`: asks each in turn (the ones bound to
|
|
3905
|
+
* the session first, the next one after a failure) and caches what they return by URL, so a file in
|
|
3906
|
+
* the history is fetched once per run rather than per step.
|
|
3907
|
+
*/
|
|
3908
|
+
function makeChannelDownloader(connectors, handleFor) {
|
|
3909
|
+
const downloaders = connectors.filter((c) => typeof c.download === "function");
|
|
3910
|
+
if (downloaders.length === 0)
|
|
3911
|
+
return undefined;
|
|
3912
|
+
const cache = new Map();
|
|
3913
|
+
const sizes = new Map();
|
|
3914
|
+
let cachedBytes = 0;
|
|
3915
|
+
const evict = () => {
|
|
3916
|
+
for (const key of cache.keys()) {
|
|
3917
|
+
if (cachedBytes <= CHANNEL_DOWNLOAD_CACHE_BYTES)
|
|
3918
|
+
return;
|
|
3919
|
+
cachedBytes -= sizes.get(key) ?? 0;
|
|
3920
|
+
sizes.delete(key);
|
|
3921
|
+
cache.delete(key);
|
|
3922
|
+
}
|
|
3923
|
+
};
|
|
3924
|
+
const fetchOwned = async (url) => {
|
|
3925
|
+
const candidates = downloaders.map((connector) => ({
|
|
3926
|
+
connector,
|
|
3927
|
+
handle: handleFor(connector.id),
|
|
3928
|
+
}));
|
|
3929
|
+
const ordered = [
|
|
3930
|
+
...candidates.filter((c) => c.handle !== undefined),
|
|
3931
|
+
...candidates.filter((c) => c.handle === undefined),
|
|
3932
|
+
];
|
|
3933
|
+
let failure;
|
|
3934
|
+
for (const { connector, handle } of ordered) {
|
|
3935
|
+
try {
|
|
3936
|
+
const file = await connector.download(url, { handle });
|
|
3937
|
+
if (file)
|
|
3938
|
+
return file;
|
|
3752
3939
|
}
|
|
3753
|
-
|
|
3754
|
-
|
|
3940
|
+
catch (error) {
|
|
3941
|
+
failure ??= { error };
|
|
3755
3942
|
}
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
// already closed on its own exit costs nothing.
|
|
3772
|
-
const withClose = async (result) => {
|
|
3773
|
-
try {
|
|
3774
|
-
return await result;
|
|
3775
|
-
}
|
|
3776
|
-
finally {
|
|
3777
|
-
await performChatClose();
|
|
3778
|
-
}
|
|
3779
|
-
};
|
|
3780
|
-
// Keep the schema-free path identical to the original custom-agent
|
|
3781
|
-
// wrapper, including when userRun starts executing.
|
|
3782
|
-
if (!parseClientData) {
|
|
3783
|
-
return withClose(userRun(payload, runOptions));
|
|
3943
|
+
}
|
|
3944
|
+
if (failure)
|
|
3945
|
+
throw failure.error;
|
|
3946
|
+
return null;
|
|
3947
|
+
};
|
|
3948
|
+
return (url) => {
|
|
3949
|
+
const key = url.href;
|
|
3950
|
+
const cached = cache.get(key);
|
|
3951
|
+
if (cached)
|
|
3952
|
+
return cached;
|
|
3953
|
+
const pending = fetchOwned(url).then((file) => {
|
|
3954
|
+
if (file && cache.get(key) === pending) {
|
|
3955
|
+
sizes.set(key, file.data.byteLength);
|
|
3956
|
+
cachedBytes += file.data.byteLength;
|
|
3957
|
+
evict();
|
|
3784
3958
|
}
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3959
|
+
return file;
|
|
3960
|
+
}, (error) => {
|
|
3961
|
+
if (cache.get(key) === pending)
|
|
3962
|
+
cache.delete(key);
|
|
3963
|
+
throw error;
|
|
3964
|
+
});
|
|
3965
|
+
cache.set(key, pending);
|
|
3966
|
+
return pending;
|
|
3967
|
+
};
|
|
3968
|
+
}
|
|
3969
|
+
function fileUrlOf(part) {
|
|
3970
|
+
const p = part;
|
|
3971
|
+
const raw = p?.type === "file" ? p.data : p?.type === "image" ? p.image : undefined;
|
|
3972
|
+
if (raw instanceof URL)
|
|
3973
|
+
return raw;
|
|
3974
|
+
if (typeof raw !== "string" || !/^https?:\/\//i.test(raw))
|
|
3975
|
+
return undefined;
|
|
3976
|
+
try {
|
|
3977
|
+
return new URL(raw);
|
|
3978
|
+
}
|
|
3979
|
+
catch {
|
|
3980
|
+
return undefined;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
/**
|
|
3984
|
+
* Fetch the channel-owned files in the messages a turn hands to `run()`, so the model call reads
|
|
3985
|
+
* them from the cache. A file a connector owns but cannot read becomes a text note in this turn's
|
|
3986
|
+
* copy of the messages, and the turn still runs. The transcript keeps the URL.
|
|
3987
|
+
*/
|
|
3988
|
+
async function resolveChannelFiles(messages, download) {
|
|
3989
|
+
const failed = new Set();
|
|
3990
|
+
const mediaTypes = new Map();
|
|
3991
|
+
const urls = new Map();
|
|
3992
|
+
for (const message of messages) {
|
|
3993
|
+
if (!Array.isArray(message.content))
|
|
3994
|
+
continue;
|
|
3995
|
+
for (const part of message.content) {
|
|
3996
|
+
const url = fileUrlOf(part);
|
|
3997
|
+
if (url)
|
|
3998
|
+
urls.set(url.href, url);
|
|
3999
|
+
}
|
|
4000
|
+
}
|
|
4001
|
+
await Promise.all([...urls.values()].map(async (url) => {
|
|
4002
|
+
try {
|
|
4003
|
+
const file = await download(url);
|
|
4004
|
+
if (file?.mediaType)
|
|
4005
|
+
mediaTypes.set(url.href, file.mediaType);
|
|
4006
|
+
}
|
|
4007
|
+
catch (error) {
|
|
4008
|
+
failed.add(url.href);
|
|
4009
|
+
logger.warn("chat.agent: a channel file could not be downloaded; the model gets a note", {
|
|
4010
|
+
url: url.origin + url.pathname,
|
|
4011
|
+
error,
|
|
3797
4012
|
});
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
4013
|
+
}
|
|
4014
|
+
}));
|
|
4015
|
+
const isWildcard = (part) => {
|
|
4016
|
+
const mediaType = part.mediaType;
|
|
4017
|
+
return part.type === "file" && !!mediaType && mediaType.endsWith("/*");
|
|
4018
|
+
};
|
|
4019
|
+
return messages.map((message) => {
|
|
4020
|
+
if (!Array.isArray(message.content))
|
|
4021
|
+
return message;
|
|
4022
|
+
let changed = false;
|
|
4023
|
+
const content = message.content.map((part) => {
|
|
4024
|
+
const url = fileUrlOf(part);
|
|
4025
|
+
if (!url)
|
|
4026
|
+
return part;
|
|
4027
|
+
if (failed.has(url.href)) {
|
|
4028
|
+
changed = true;
|
|
4029
|
+
const name = part.filename;
|
|
4030
|
+
return {
|
|
4031
|
+
type: "text",
|
|
4032
|
+
text: `[Attached file${name ? ` "${name}"` : ""} could not be loaded]`,
|
|
4033
|
+
};
|
|
3814
4034
|
}
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
3820
|
-
timeout: "1h",
|
|
3821
|
-
spanName: "waiting for valid clientData",
|
|
3822
|
-
});
|
|
3823
|
-
if (!next.ok || next.output.trigger === "close") {
|
|
3824
|
-
return;
|
|
4035
|
+
const downloaded = mediaTypes.get(url.href);
|
|
4036
|
+
if (downloaded && isWildcard(part)) {
|
|
4037
|
+
changed = true;
|
|
4038
|
+
return { ...part, mediaType: downloaded };
|
|
3825
4039
|
}
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
...next.output,
|
|
3830
|
-
continuation: next.output.continuation ?? payload.continuation,
|
|
3831
|
-
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
3832
|
-
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
3833
|
-
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
3834
|
-
};
|
|
3835
|
-
return withClose(userRun(recoveredPayload, runOptions));
|
|
3836
|
-
},
|
|
4040
|
+
return part;
|
|
4041
|
+
});
|
|
4042
|
+
return changed ? { ...message, content } : message;
|
|
3837
4043
|
});
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* The AI SDK `experimental_download` for a turn: channel-owned URLs come from the connectors, and
|
|
4047
|
+
* every other URL goes to `fallback`, or to the AI SDK's default behaviour (left for a model that
|
|
4048
|
+
* reads the URL itself, fetched from a public host otherwise).
|
|
4049
|
+
*/
|
|
4050
|
+
function channelAwareDownload(download, fallback) {
|
|
4051
|
+
return async (requests) => {
|
|
4052
|
+
const results = await Promise.all(requests.map(async (request) => {
|
|
4053
|
+
const file = await download(request.url);
|
|
4054
|
+
return file ? { data: file.data, mediaType: file.mediaType } : null;
|
|
4055
|
+
}));
|
|
4056
|
+
const misses = requests.flatMap((_, i) => (results[i] ? [] : [i]));
|
|
4057
|
+
if (misses.length === 0)
|
|
4058
|
+
return results;
|
|
4059
|
+
const rest = fallback
|
|
4060
|
+
? await fallback(misses.map((i) => requests[i]))
|
|
4061
|
+
: await Promise.all(misses.map((i) => requests[i].isUrlSupportedByModel ? null : fetchPublicFile(requests[i].url)));
|
|
4062
|
+
misses.forEach((i, n) => {
|
|
4063
|
+
results[i] = rest[n] ?? null;
|
|
3843
4064
|
});
|
|
4065
|
+
return results;
|
|
4066
|
+
};
|
|
4067
|
+
}
|
|
4068
|
+
/** The action a channel click maps to through its connector's `toAction()`, if it maps to one. */
|
|
4069
|
+
function channelClickAction(connectors, wire) {
|
|
4070
|
+
const channelEvent = wire;
|
|
4071
|
+
if (!channelEvent?.connectorId)
|
|
4072
|
+
return undefined;
|
|
4073
|
+
const connector = connectors?.find((c) => c.id === channelEvent.connectorId);
|
|
4074
|
+
if (!connector?.toAction)
|
|
4075
|
+
return undefined;
|
|
4076
|
+
try {
|
|
4077
|
+
return connector.toAction(channelEvent.event) ?? undefined;
|
|
4078
|
+
}
|
|
4079
|
+
catch (error) {
|
|
4080
|
+
logger.warn("chat.agent: channel toAction() threw; handling the delivery as a message", {
|
|
4081
|
+
error,
|
|
4082
|
+
});
|
|
4083
|
+
return undefined;
|
|
3844
4084
|
}
|
|
3845
|
-
return task;
|
|
3846
4085
|
}
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
4086
|
+
/**
|
|
4087
|
+
* A channel delivery that arrived during a turn, as a message `pendingMessages` can steer into it:
|
|
4088
|
+
* the connector's `inbound()` with channel provenance. Undefined for what must not be steered (a
|
|
4089
|
+
* stop or other control, an interaction such as an approval click, an empty message, a connector
|
|
4090
|
+
* this agent does not list), which stays queued as its own turn.
|
|
4091
|
+
*/
|
|
4092
|
+
function channelSteeringMessage(connectors, wire, messageId) {
|
|
4093
|
+
const connector = connectors?.find((c) => c.id === wire.connectorId);
|
|
4094
|
+
if (!connector)
|
|
4095
|
+
return undefined;
|
|
4096
|
+
try {
|
|
4097
|
+
if (connector.onControl?.(wire.event))
|
|
4098
|
+
return undefined;
|
|
4099
|
+
if (connector.onInteraction?.(wire.event))
|
|
4100
|
+
return undefined;
|
|
4101
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4102
|
+
return undefined;
|
|
4103
|
+
const inbound = connector.inbound(wire.event);
|
|
4104
|
+
if (isEmptyChannelInbound(inbound))
|
|
4105
|
+
return undefined;
|
|
4106
|
+
const message = channelUserMessage(inbound, messageId ?? wire.deliveryId, {
|
|
4107
|
+
source: "channel",
|
|
4108
|
+
connectorId: connector.id,
|
|
4109
|
+
provider: connector.source,
|
|
4110
|
+
deliveryId: wire.deliveryId,
|
|
4111
|
+
...(wire.externalDeliveryId ? { externalDeliveryId: wire.externalDeliveryId } : {}),
|
|
4112
|
+
});
|
|
4113
|
+
return { message };
|
|
3860
4114
|
}
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
4115
|
+
catch (error) {
|
|
4116
|
+
logger.warn("chat.agent: a channel message could not be offered for steering; it runs as its own turn", {
|
|
4117
|
+
error,
|
|
4118
|
+
});
|
|
4119
|
+
return undefined;
|
|
4120
|
+
}
|
|
4121
|
+
}
|
|
4122
|
+
/**
|
|
4123
|
+
* Call the connector's `received` for a message that arrived mid-turn, unless it is not a message
|
|
4124
|
+
* to answer (an interaction click, an action click, an empty message). Fire and forget.
|
|
4125
|
+
*/
|
|
4126
|
+
function acknowledgeChannelMessage(connector, wire, handle) {
|
|
4127
|
+
if (!connector.received)
|
|
4128
|
+
return;
|
|
4129
|
+
try {
|
|
4130
|
+
if (connector.onInteraction?.(wire.event))
|
|
4131
|
+
return;
|
|
4132
|
+
if (connector.toAction?.(wire.event) != null)
|
|
4133
|
+
return;
|
|
4134
|
+
if (isEmptyChannelInbound(connector.inbound(wire.event)))
|
|
4135
|
+
return;
|
|
4136
|
+
void Promise.resolve(connector.received({
|
|
4137
|
+
event: wire.event,
|
|
4138
|
+
deliveryId: wire.deliveryId,
|
|
4139
|
+
handle,
|
|
4140
|
+
bound: false,
|
|
4141
|
+
})).catch(() => { });
|
|
4142
|
+
}
|
|
4143
|
+
catch {
|
|
4144
|
+
return;
|
|
4145
|
+
}
|
|
4146
|
+
}
|
|
4147
|
+
function channelUserMessage(input, messageId, provenance) {
|
|
4148
|
+
const wrapped = input !== null && typeof input === "object" && "message" in input && !("role" in input);
|
|
4149
|
+
const message = (wrapped ? input.message : input);
|
|
4150
|
+
const extra = wrapped
|
|
4151
|
+
? input
|
|
4152
|
+
: {};
|
|
4153
|
+
const meta = {
|
|
4154
|
+
...provenance,
|
|
4155
|
+
...(extra.externalUserId ? { externalUserId: extra.externalUserId } : {}),
|
|
4156
|
+
...(extra.externalMessageId ? { externalMessageId: extra.externalMessageId } : {}),
|
|
4157
|
+
};
|
|
4158
|
+
const base = typeof message === "string"
|
|
4159
|
+
? { id: messageId, role: "user", parts: [{ type: "text", text: message }] }
|
|
4160
|
+
: message.id
|
|
4161
|
+
? message
|
|
4162
|
+
: { ...message, id: messageId };
|
|
4163
|
+
const existing = base.metadata;
|
|
4164
|
+
return {
|
|
4165
|
+
...base,
|
|
4166
|
+
metadata: { ...(existing && typeof existing === "object" ? existing : {}), ...meta },
|
|
4167
|
+
};
|
|
4168
|
+
}
|
|
4169
|
+
/**
|
|
4170
|
+
* The assistant's closing text for a channel reply: the text after the last tool part in the message,
|
|
4171
|
+
* so a pre-tool preamble (e.g. the HITL "I'll need approval first" line, or a "let me look that up")
|
|
4172
|
+
* is dropped and only the answer is posted. Falls back to all text when there's no trailing text or no
|
|
4173
|
+
* tool parts (an ordinary turn), so a plain reply is unchanged.
|
|
4174
|
+
*/
|
|
4175
|
+
function channelReplyText(message) {
|
|
4176
|
+
const parts = (message.parts ?? []);
|
|
4177
|
+
const isTool = (p) => {
|
|
4178
|
+
const t = p?.type;
|
|
4179
|
+
return typeof t === "string" && (t.startsWith("tool-") || t === "dynamic-tool");
|
|
4180
|
+
};
|
|
4181
|
+
let lastToolIdx = -1;
|
|
4182
|
+
parts.forEach((p, i) => {
|
|
4183
|
+
if (isTool(p))
|
|
4184
|
+
lastToolIdx = i;
|
|
4185
|
+
});
|
|
4186
|
+
const textFrom = (from) => parts
|
|
4187
|
+
.slice(from)
|
|
4188
|
+
.map((p) => p && typeof p === "object" && p.type === "text"
|
|
4189
|
+
? String(p.text)
|
|
4190
|
+
: "")
|
|
4191
|
+
.join("");
|
|
4192
|
+
const trailing = textFrom(lastToolIdx + 1);
|
|
4193
|
+
return trailing.trim().length > 0 ? trailing : textFrom(0);
|
|
4194
|
+
}
|
|
4195
|
+
/**
|
|
4196
|
+
* Tries at a turn's final channel reply before it is given up on and the turn shows the error
|
|
4197
|
+
* reaction. Only an edit of the ack placeholder is retried: a failed post may still have landed, so
|
|
4198
|
+
* a post is tried once.
|
|
4199
|
+
*/
|
|
4200
|
+
const CHANNEL_FINAL_SEND_ATTEMPTS = 3;
|
|
4201
|
+
/** Tool parts of a UIMessage awaiting a human answer (`input-available`), for `renderInteraction`. */
|
|
4202
|
+
function pendingToolCallsInMessage(message) {
|
|
4203
|
+
const out = [];
|
|
4204
|
+
for (const part of (message.parts ?? [])) {
|
|
4205
|
+
if (!part || typeof part !== "object")
|
|
4206
|
+
continue;
|
|
4207
|
+
const type = part.type;
|
|
4208
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4209
|
+
if (!isTool || part.state !== "input-available")
|
|
4210
|
+
continue;
|
|
4211
|
+
const toolName = type === "dynamic-tool" ? String(part.toolName ?? "") : String(type).slice("tool-".length);
|
|
4212
|
+
if (typeof part.toolCallId === "string")
|
|
4213
|
+
out.push({ toolCallId: part.toolCallId, toolName, input: part.input });
|
|
4214
|
+
}
|
|
4215
|
+
return out;
|
|
4216
|
+
}
|
|
4217
|
+
/**
|
|
4218
|
+
* Fill in `toolCallId` on a resolution that names the tool instead: the most recent assistant part of
|
|
4219
|
+
* that tool still awaiting input. Returns the resolution unchanged when it already has an id, and
|
|
4220
|
+
* undefined when no pending call of that tool exists.
|
|
4221
|
+
*/
|
|
4222
|
+
function resolveInteractionToolCallId(resolution, messages) {
|
|
4223
|
+
if (resolution.toolCallId) {
|
|
4224
|
+
return resolution;
|
|
4225
|
+
}
|
|
4226
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4227
|
+
const message = messages[i];
|
|
4228
|
+
if (message.role !== "assistant")
|
|
4229
|
+
continue;
|
|
4230
|
+
for (const call of pendingToolCallsInMessage(message).reverse()) {
|
|
4231
|
+
if (call.toolName === resolution.toolName) {
|
|
4232
|
+
return { ...resolution, toolCallId: call.toolCallId };
|
|
4233
|
+
}
|
|
4234
|
+
}
|
|
4235
|
+
}
|
|
4236
|
+
return undefined;
|
|
4237
|
+
}
|
|
4238
|
+
/**
|
|
4239
|
+
* Build the slim assistant message the HITL resume path expects (mirrors `slimSubmitMessageForWire`):
|
|
4240
|
+
* the pending tool part matched by `toolCallId` across `messages`, advanced to `output-available` with
|
|
4241
|
+
* the interaction's output. Returns undefined when no pending part matches (a stale/duplicate callback).
|
|
4242
|
+
*/
|
|
4243
|
+
function buildInteractionResolutionMessage(resolution, messages, provenance) {
|
|
4244
|
+
// Resolved anywhere means resolved: a resumed turn can carry the same tool call in more than
|
|
4245
|
+
// one assistant message, and a second click on a decided call must not resolve a stale copy.
|
|
4246
|
+
for (const message of messages) {
|
|
4247
|
+
if (message.role !== "assistant")
|
|
4248
|
+
continue;
|
|
4249
|
+
for (const part of (message.parts ?? [])) {
|
|
4250
|
+
if (part && typeof part === "object" && part.toolCallId === resolution.toolCallId) {
|
|
4251
|
+
if (isResolvedToolState(part.state))
|
|
4252
|
+
return undefined;
|
|
4253
|
+
}
|
|
4254
|
+
}
|
|
4255
|
+
}
|
|
4256
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
4257
|
+
const message = messages[i];
|
|
4258
|
+
if (message.role !== "assistant")
|
|
4259
|
+
continue;
|
|
4260
|
+
for (const part of (message.parts ?? [])) {
|
|
4261
|
+
if (!part || typeof part !== "object" || part.toolCallId !== resolution.toolCallId)
|
|
4262
|
+
continue;
|
|
4263
|
+
const type = part.type;
|
|
4264
|
+
const isTool = typeof type === "string" && (type.startsWith("tool-") || type === "dynamic-tool");
|
|
4265
|
+
if (!isTool)
|
|
4266
|
+
continue;
|
|
4267
|
+
if (part.state !== "input-available")
|
|
4268
|
+
return undefined;
|
|
4269
|
+
const slimPart = {
|
|
4270
|
+
type,
|
|
4271
|
+
toolCallId: resolution.toolCallId,
|
|
4272
|
+
state: "output-available",
|
|
4273
|
+
output: resolution.output,
|
|
4274
|
+
};
|
|
4275
|
+
if (type === "dynamic-tool" && typeof part.toolName === "string")
|
|
4276
|
+
slimPart.toolName = part.toolName;
|
|
4277
|
+
return {
|
|
4278
|
+
id: message.id,
|
|
4279
|
+
role: "assistant",
|
|
4280
|
+
parts: [slimPart],
|
|
4281
|
+
...(provenance ? { metadata: provenance } : {}),
|
|
4282
|
+
};
|
|
4283
|
+
}
|
|
4284
|
+
}
|
|
4285
|
+
return undefined;
|
|
4286
|
+
}
|
|
4287
|
+
// Default outbound: post the reply text, or nothing when empty (a tool-only / empty turn).
|
|
4288
|
+
function defaultChannelOutbound(reply) {
|
|
4289
|
+
return reply.text ? { text: reply.text } : null;
|
|
4290
|
+
}
|
|
4291
|
+
// Resolve a lifecycle reaction choice to one emoji name for this turn: call a resolver if given, then
|
|
4292
|
+
// pick one at random from an array. Returns undefined to skip (no choice / empty).
|
|
4293
|
+
export async function resolveReactionChoice(choice, event) {
|
|
4294
|
+
if (choice == null)
|
|
4295
|
+
return undefined;
|
|
4296
|
+
let value = typeof choice === "function" ? await choice(event) : choice;
|
|
4297
|
+
if (Array.isArray(value)) {
|
|
4298
|
+
if (value.length === 0)
|
|
4299
|
+
return undefined;
|
|
4300
|
+
value = value[Math.floor(Math.random() * value.length)];
|
|
4301
|
+
}
|
|
4302
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
4303
|
+
}
|
|
4304
|
+
// Best-effort reaction on the triggering message; a failure never breaks the turn.
|
|
4305
|
+
async function applyChannelReaction(connector, wireEvent, reaction) {
|
|
4306
|
+
if (!connector?.react || !wireEvent || !reaction.name)
|
|
4307
|
+
return;
|
|
4308
|
+
try {
|
|
4309
|
+
await connector.react(reaction, { event: wireEvent.event, deliveryId: wireEvent.deliveryId });
|
|
4310
|
+
}
|
|
4311
|
+
catch (error) {
|
|
4312
|
+
logger.warn("chat.agent: channel reaction failed", { error, name: reaction.name });
|
|
4313
|
+
}
|
|
4314
|
+
}
|
|
4315
|
+
// The run()-facing reaction surface for a channel turn (flavor 2). Undefined when the connector has no
|
|
4316
|
+
// `react`, so `payload.channel` is only present when reacting is actually possible.
|
|
4317
|
+
function buildChannelRunSurface(connector, wireEvent, handle) {
|
|
4318
|
+
if (!connector)
|
|
4319
|
+
return undefined;
|
|
4320
|
+
const canReact = !!connector.react && !!wireEvent;
|
|
4321
|
+
if (!canReact && !connector.surface)
|
|
4322
|
+
return undefined;
|
|
4323
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4324
|
+
let extra = {};
|
|
4325
|
+
if (connector.surface) {
|
|
4326
|
+
try {
|
|
4327
|
+
extra = connector.surface(turnCtx) ?? {};
|
|
4328
|
+
}
|
|
4329
|
+
catch (error) {
|
|
4330
|
+
logger.warn("chat.agent: channel surface() threw; continuing without it", { error });
|
|
4331
|
+
}
|
|
4332
|
+
}
|
|
4333
|
+
const noop = async () => { };
|
|
4334
|
+
return {
|
|
4335
|
+
...extra,
|
|
4336
|
+
react: canReact ? (name) => applyChannelReaction(connector, wireEvent, { name }) : noop,
|
|
4337
|
+
unreact: canReact
|
|
4338
|
+
? (name) => applyChannelReaction(connector, wireEvent, { name, remove: true })
|
|
4339
|
+
: noop,
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
function channelTurnCtx(wireEvent, handle) {
|
|
4343
|
+
return {
|
|
4344
|
+
...(wireEvent ? { event: wireEvent.event, deliveryId: wireEvent.deliveryId } : {}),
|
|
4345
|
+
...(handle !== undefined ? { handle } : {}),
|
|
4346
|
+
bound: !wireEvent,
|
|
4347
|
+
};
|
|
4348
|
+
}
|
|
4349
|
+
/**
|
|
4350
|
+
* Feed a connector's `progress()` from the UI message stream: a tool call once its input is available,
|
|
4351
|
+
* then its result or error. Calls are serialized so the surface sees them in order; a throw is logged.
|
|
4352
|
+
* `stop()` refuses further chunks and drains what is already queued, so a slow first post does not
|
|
4353
|
+
* lose the results behind it, and nothing new lands after the turn ended.
|
|
4354
|
+
*/
|
|
4355
|
+
function makeChannelProgressObserver(connector, wireEvent, handle) {
|
|
4356
|
+
const progress = connector.progress;
|
|
4357
|
+
const turnCtx = channelTurnCtx(wireEvent, handle);
|
|
4358
|
+
const toolNames = new Map();
|
|
4359
|
+
let chain = Promise.resolve();
|
|
4360
|
+
let stopped = false;
|
|
4361
|
+
const emit = (event) => {
|
|
4362
|
+
chain = chain.then(async () => {
|
|
4363
|
+
try {
|
|
4364
|
+
await progress(event, turnCtx);
|
|
4365
|
+
}
|
|
4366
|
+
catch (error) {
|
|
4367
|
+
logger.warn("chat.agent: channel progress() failed; continuing", {
|
|
4368
|
+
error,
|
|
4369
|
+
type: event.type,
|
|
4370
|
+
});
|
|
4371
|
+
}
|
|
4372
|
+
});
|
|
4373
|
+
};
|
|
4374
|
+
return {
|
|
4375
|
+
observe(chunk) {
|
|
4376
|
+
if (stopped)
|
|
4377
|
+
return;
|
|
4378
|
+
const c = chunk;
|
|
4379
|
+
if (!c?.type || !c.toolCallId)
|
|
4380
|
+
return;
|
|
4381
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4382
|
+
toolNames.set(c.toolCallId, c.toolName);
|
|
4383
|
+
return;
|
|
4384
|
+
}
|
|
4385
|
+
if (c.type === "tool-input-available") {
|
|
4386
|
+
const toolName = c.toolName ?? toolNames.get(c.toolCallId) ?? "tool";
|
|
4387
|
+
toolNames.set(c.toolCallId, toolName);
|
|
4388
|
+
emit({ type: "tool-call", toolCallId: c.toolCallId, toolName, input: c.input });
|
|
4389
|
+
return;
|
|
4390
|
+
}
|
|
4391
|
+
if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4392
|
+
emit({
|
|
4393
|
+
type: "tool-result",
|
|
4394
|
+
toolCallId: c.toolCallId,
|
|
4395
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4396
|
+
output: c.output,
|
|
4397
|
+
});
|
|
4398
|
+
return;
|
|
4399
|
+
}
|
|
4400
|
+
if (c.type === "tool-output-error") {
|
|
4401
|
+
emit({
|
|
4402
|
+
type: "tool-error",
|
|
4403
|
+
toolCallId: c.toolCallId,
|
|
4404
|
+
toolName: toolNames.get(c.toolCallId) ?? "tool",
|
|
4405
|
+
errorText: c.errorText ?? "tool failed",
|
|
4406
|
+
});
|
|
4407
|
+
}
|
|
4408
|
+
},
|
|
4409
|
+
async stop() {
|
|
4410
|
+
stopped = true;
|
|
4411
|
+
await chain;
|
|
4412
|
+
},
|
|
4413
|
+
};
|
|
4414
|
+
}
|
|
4415
|
+
/** Re-send `working` this often during a long turn: Slack drops a processing status after an hour. */
|
|
4416
|
+
const CHANNEL_WORKING_REFRESH_MS = 50 * 60 * 1000;
|
|
4417
|
+
/**
|
|
4418
|
+
* Report a session's lifecycle to its connector in order, one call at a time, and re-send `working`
|
|
4419
|
+
* while a long turn runs. Every report bumps a sequence number and a refresh only fires while its
|
|
4420
|
+
* `working` is still the latest report, so a refresh can never land after the turn went idle.
|
|
4421
|
+
*/
|
|
4422
|
+
function makeChannelLifecycleReporter() {
|
|
4423
|
+
let chain = Promise.resolve();
|
|
4424
|
+
let seq = 0;
|
|
4425
|
+
let refreshTimer;
|
|
4426
|
+
let closed = false;
|
|
4427
|
+
const clearRefresh = () => {
|
|
4428
|
+
if (refreshTimer)
|
|
4429
|
+
clearTimeout(refreshTimer);
|
|
4430
|
+
refreshTimer = undefined;
|
|
4431
|
+
};
|
|
4432
|
+
const enqueue = (connector, state, ctx, reportSeq) => {
|
|
4433
|
+
chain = chain.then(async () => {
|
|
4434
|
+
if (ctx.refresh && reportSeq !== seq)
|
|
4435
|
+
return;
|
|
4436
|
+
try {
|
|
4437
|
+
await connector.lifecycle(state, ctx);
|
|
4438
|
+
}
|
|
4439
|
+
catch (error) {
|
|
4440
|
+
logger.warn("chat.agent: channel lifecycle() failed; continuing", { error, state });
|
|
4441
|
+
}
|
|
4442
|
+
});
|
|
4443
|
+
return chain;
|
|
4444
|
+
};
|
|
4445
|
+
return {
|
|
4446
|
+
report(connector, state, ctx) {
|
|
4447
|
+
if (!connector?.lifecycle || closed)
|
|
4448
|
+
return Promise.resolve();
|
|
4449
|
+
if (state === "closed")
|
|
4450
|
+
closed = true;
|
|
4451
|
+
const reportSeq = ++seq;
|
|
4452
|
+
clearRefresh();
|
|
4453
|
+
const done = enqueue(connector, state, ctx, reportSeq);
|
|
4454
|
+
if (state === "working") {
|
|
4455
|
+
const arm = () => {
|
|
4456
|
+
refreshTimer = setTimeout(() => {
|
|
4457
|
+
if (reportSeq !== seq)
|
|
4458
|
+
return;
|
|
4459
|
+
void enqueue(connector, "working", { ...ctx, firstTurn: false, refresh: true }, reportSeq);
|
|
4460
|
+
arm();
|
|
4461
|
+
}, CHANNEL_WORKING_REFRESH_MS);
|
|
4462
|
+
refreshTimer.unref?.();
|
|
4463
|
+
};
|
|
4464
|
+
arm();
|
|
4465
|
+
}
|
|
4466
|
+
return done;
|
|
4467
|
+
},
|
|
4468
|
+
dispose: clearRefresh,
|
|
4469
|
+
};
|
|
4470
|
+
}
|
|
4471
|
+
/**
|
|
4472
|
+
* The assistant message with the given pending tool calls resolved as stopped (`output-error`), for
|
|
4473
|
+
* a stop that arrives while the session waits on a human decision. Undefined when none matched.
|
|
4474
|
+
*/
|
|
4475
|
+
function stopPendingToolCalls(message, toolCallIds, errorText) {
|
|
4476
|
+
let changed = false;
|
|
4477
|
+
const parts = (message.parts ?? []).map((part) => {
|
|
4478
|
+
if (part &&
|
|
4479
|
+
typeof part === "object" &&
|
|
4480
|
+
toolCallIds.has(part.toolCallId) &&
|
|
4481
|
+
part.state === "input-available") {
|
|
4482
|
+
changed = true;
|
|
4483
|
+
return { ...part, state: "output-error", errorText };
|
|
4484
|
+
}
|
|
4485
|
+
return part;
|
|
4486
|
+
});
|
|
4487
|
+
return changed ? { ...message, parts } : undefined;
|
|
4488
|
+
}
|
|
4489
|
+
/**
|
|
4490
|
+
* Feed a connector's native reply stream from the UI message stream: text deltas as they arrive, and
|
|
4491
|
+
* tool progress when the stream accepts it. `stop()` only refuses further chunks; the turn boundary
|
|
4492
|
+
* ends the stream itself with the turn's outcome.
|
|
4493
|
+
*/
|
|
4494
|
+
function makeChannelReplyStreamObserver(stream) {
|
|
4495
|
+
const toolNames = new Map();
|
|
4496
|
+
let stopped = false;
|
|
4497
|
+
const safe = (fn) => {
|
|
4498
|
+
try {
|
|
4499
|
+
fn();
|
|
4500
|
+
}
|
|
4501
|
+
catch (error) {
|
|
4502
|
+
logger.warn("chat.agent: channel reply stream threw; continuing", { error });
|
|
4503
|
+
}
|
|
4504
|
+
};
|
|
4505
|
+
return {
|
|
4506
|
+
observe(chunk) {
|
|
4507
|
+
if (stopped)
|
|
4508
|
+
return;
|
|
4509
|
+
const c = chunk;
|
|
4510
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4511
|
+
const delta = c.delta;
|
|
4512
|
+
safe(() => stream.text(delta));
|
|
4513
|
+
return;
|
|
4514
|
+
}
|
|
4515
|
+
if (!stream.progress || !c?.type || !c.toolCallId)
|
|
4516
|
+
return;
|
|
4517
|
+
const toolCallId = c.toolCallId;
|
|
4518
|
+
if (c.type === "tool-input-start" && c.toolName) {
|
|
4519
|
+
toolNames.set(toolCallId, c.toolName);
|
|
4520
|
+
}
|
|
4521
|
+
else if (c.type === "tool-input-available") {
|
|
4522
|
+
const toolName = c.toolName ?? toolNames.get(toolCallId) ?? "tool";
|
|
4523
|
+
toolNames.set(toolCallId, toolName);
|
|
4524
|
+
safe(() => stream.progress({ type: "tool-call", toolCallId, toolName, input: c.input }));
|
|
4525
|
+
}
|
|
4526
|
+
else if (c.type === "tool-output-available" && !c.preliminary) {
|
|
4527
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4528
|
+
safe(() => stream.progress({ type: "tool-result", toolCallId, toolName, output: c.output }));
|
|
4529
|
+
}
|
|
4530
|
+
else if (c.type === "tool-output-error") {
|
|
4531
|
+
const toolName = toolNames.get(toolCallId) ?? "tool";
|
|
4532
|
+
const errorText = c.errorText ?? "tool failed";
|
|
4533
|
+
safe(() => stream.progress({ type: "tool-error", toolCallId, toolName, errorText }));
|
|
4534
|
+
}
|
|
4535
|
+
},
|
|
4536
|
+
stop() {
|
|
4537
|
+
stopped = true;
|
|
4538
|
+
},
|
|
4539
|
+
};
|
|
4540
|
+
}
|
|
4541
|
+
function composeChannelObservers(observers) {
|
|
4542
|
+
return {
|
|
4543
|
+
observe(chunk) {
|
|
4544
|
+
for (const o of observers)
|
|
4545
|
+
o.observe(chunk);
|
|
4546
|
+
},
|
|
4547
|
+
async stop() {
|
|
4548
|
+
await Promise.all(observers.map((o) => o.stop()));
|
|
4549
|
+
},
|
|
4550
|
+
};
|
|
4551
|
+
}
|
|
4552
|
+
// "stream" egress: as the reply streams, debounce-edit the ack message (previousRef) with the growing
|
|
4553
|
+
// text. Trailing-edge, one edit per interval (Slack chat.update ~1/s); the turn-complete final edit is
|
|
4554
|
+
// the authoritative last write. Best-effort: an edit failure is logged, never fatal.
|
|
4555
|
+
const CHANNEL_STREAM_EDIT_INTERVAL_MS = 1000;
|
|
4556
|
+
function makeChannelStreamEditor(connector, channelEvent, ackRef, handle) {
|
|
4557
|
+
const outbound = connector.outbound ?? defaultChannelOutbound;
|
|
4558
|
+
let latest = "";
|
|
4559
|
+
let lastSent = "";
|
|
4560
|
+
let timer;
|
|
4561
|
+
let inFlightPromise;
|
|
4562
|
+
let stopped = false;
|
|
4563
|
+
const arm = () => {
|
|
4564
|
+
if (stopped || timer)
|
|
4565
|
+
return;
|
|
4566
|
+
timer = setTimeout(() => {
|
|
4567
|
+
timer = undefined;
|
|
4568
|
+
void edit();
|
|
4569
|
+
}, CHANNEL_STREAM_EDIT_INTERVAL_MS);
|
|
4570
|
+
};
|
|
4571
|
+
const edit = async () => {
|
|
4572
|
+
if (stopped || inFlightPromise)
|
|
4573
|
+
return;
|
|
4574
|
+
const text = latest;
|
|
4575
|
+
if (text === lastSent)
|
|
4576
|
+
return;
|
|
4577
|
+
const message = outbound({
|
|
4578
|
+
text,
|
|
4579
|
+
message: { id: ackRef, role: "assistant", parts: [{ type: "text", text }] },
|
|
4580
|
+
final: false,
|
|
4581
|
+
stopped: false,
|
|
4582
|
+
});
|
|
4583
|
+
if (!message)
|
|
4584
|
+
return;
|
|
4585
|
+
inFlightPromise = (async () => {
|
|
4586
|
+
try {
|
|
4587
|
+
await connector.send(message, {
|
|
4588
|
+
event: channelEvent.event,
|
|
4589
|
+
handle,
|
|
4590
|
+
deliveryId: channelEvent.deliveryId,
|
|
4591
|
+
previousRef: ackRef,
|
|
4592
|
+
mode: "stream",
|
|
4593
|
+
final: false,
|
|
4594
|
+
bound: false,
|
|
4595
|
+
});
|
|
4596
|
+
lastSent = text;
|
|
4597
|
+
}
|
|
4598
|
+
catch (error) {
|
|
4599
|
+
logger.warn("chat.agent: channel stream edit failed", { error });
|
|
4600
|
+
}
|
|
4601
|
+
finally {
|
|
4602
|
+
inFlightPromise = undefined;
|
|
4603
|
+
if (!stopped && latest !== lastSent)
|
|
4604
|
+
arm();
|
|
4605
|
+
}
|
|
4606
|
+
})();
|
|
4607
|
+
await inFlightPromise;
|
|
4608
|
+
};
|
|
4609
|
+
return {
|
|
4610
|
+
observe(chunk) {
|
|
4611
|
+
if (stopped)
|
|
4612
|
+
return;
|
|
4613
|
+
const c = chunk;
|
|
4614
|
+
if (c?.type === "text-delta" && typeof c.delta === "string") {
|
|
4615
|
+
latest += c.delta;
|
|
4616
|
+
arm();
|
|
4617
|
+
}
|
|
4618
|
+
},
|
|
4619
|
+
async stop() {
|
|
4620
|
+
stopped = true;
|
|
4621
|
+
if (timer) {
|
|
4622
|
+
clearTimeout(timer);
|
|
4623
|
+
timer = undefined;
|
|
4624
|
+
}
|
|
4625
|
+
if (inFlightPromise) {
|
|
4626
|
+
try {
|
|
4627
|
+
await inFlightPromise;
|
|
4628
|
+
}
|
|
4629
|
+
catch { }
|
|
4630
|
+
}
|
|
4631
|
+
},
|
|
4632
|
+
};
|
|
4633
|
+
}
|
|
4634
|
+
/**
|
|
4635
|
+
* Wrap the reply stream so it debounce-edits the channel message as it streams.
|
|
4636
|
+
* Both `flush` (the stream completed) and `cancel` (the stream was aborted or
|
|
4637
|
+
* cancelled downstream) stop the editor, so a pending debounce timer can never
|
|
4638
|
+
* fire an edit after the turn ended, onto a message the next turn already owns.
|
|
4639
|
+
*/
|
|
4640
|
+
/**
|
|
4641
|
+
* Feed every chunk of a stream to `editor`. By default the editor is stopped when the stream ends or
|
|
4642
|
+
* is cancelled (one stream per turn, the automatic pipe). With `stopOnEnd: false` the editor outlives
|
|
4643
|
+
* the stream, for a turn whose run() pipes several streams through `chat.pipe()`; the turn boundary
|
|
4644
|
+
* stops it.
|
|
4645
|
+
*/
|
|
4646
|
+
function makeChannelStreamTap(editor, options = {}) {
|
|
4647
|
+
const stopOnEnd = options.stopOnEnd ?? true;
|
|
4648
|
+
const transformer = {
|
|
4649
|
+
transform(chunk, controller) {
|
|
4650
|
+
editor.observe(chunk);
|
|
4651
|
+
controller.enqueue(chunk);
|
|
4652
|
+
},
|
|
4653
|
+
async flush() {
|
|
4654
|
+
if (stopOnEnd)
|
|
4655
|
+
await editor.stop();
|
|
4656
|
+
},
|
|
4657
|
+
async cancel() {
|
|
4658
|
+
if (stopOnEnd)
|
|
4659
|
+
await editor.stop();
|
|
4660
|
+
},
|
|
4661
|
+
};
|
|
4662
|
+
return new TransformStream(transformer);
|
|
4663
|
+
}
|
|
4664
|
+
export { makeChannelStreamEditor as __makeChannelStreamEditorForTests, makeChannelStreamTap as __makeChannelStreamTapForTests, makeChannelProgressObserver as __makeChannelProgressObserverForTests, makeChannelLifecycleReporter as __makeChannelLifecycleReporterForTests, };
|
|
4665
|
+
/**
|
|
4666
|
+
* Per-chat latch for the channel egress that runs AFTER `trigger:turn-complete` is written (the
|
|
4667
|
+
* final `send()` and the done/error reactions). The run loop opens it before writing the chunk and
|
|
4668
|
+
* settles it once that egress has finished, so a caller that saw the chunk can wait for the
|
|
4669
|
+
* connector to be quiet instead of guessing with timers. One latch per chat at a time; a chat with
|
|
4670
|
+
* no open latch is quiet.
|
|
4671
|
+
*/
|
|
4672
|
+
const channelEgressLatches = new Map();
|
|
4673
|
+
function openChannelEgressLatch(chatId) {
|
|
4674
|
+
let settle;
|
|
4675
|
+
const promise = new Promise((resolve) => (settle = resolve));
|
|
4676
|
+
channelEgressLatches.set(chatId, { promise, settle });
|
|
4677
|
+
}
|
|
4678
|
+
function settleChannelEgressLatch(chatId) {
|
|
4679
|
+
const latch = channelEgressLatches.get(chatId);
|
|
4680
|
+
if (!latch)
|
|
4681
|
+
return;
|
|
4682
|
+
channelEgressLatches.delete(chatId);
|
|
4683
|
+
latch.settle();
|
|
4684
|
+
}
|
|
4685
|
+
/** Resolves once the post-turn channel egress for `chatId` has finished (immediately when none is running). */
|
|
4686
|
+
export async function __waitForChannelEgressForTests(chatId) {
|
|
4687
|
+
await channelEgressLatches.get(chatId)?.promise;
|
|
4688
|
+
}
|
|
4689
|
+
function chatCustomAgent(options) {
|
|
4690
|
+
const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
|
|
4691
|
+
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
|
|
4692
|
+
const parseClientDataSync = clientDataSchema
|
|
4693
|
+
? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
|
|
4694
|
+
: undefined;
|
|
4695
|
+
const task = createTask({
|
|
4696
|
+
...restOptions,
|
|
4697
|
+
triggerSource: "agent",
|
|
4698
|
+
agentConfig: { type: "ai-sdk-chat" },
|
|
4699
|
+
run: async (payload, runOptions) => {
|
|
4700
|
+
// Bind the run to its backing Session so module-level helpers
|
|
4701
|
+
// (chat.messages, chat.stream, chat.createStopSignal, chat.createSession)
|
|
4702
|
+
// resolve to this chat's `.in` / `.out` channels. Address
|
|
4703
|
+
// everywhere by `payload.chatId` (the session externalId) so the
|
|
4704
|
+
// agent's writes and the transport's reads converge on the same
|
|
4705
|
+
// S2 stream key + waitpoint key.
|
|
4706
|
+
//
|
|
4707
|
+
// The Session row is created server-side by `POST /sessions` (or
|
|
4708
|
+
// `chat.createStartSessionAction`) before this run is triggered.
|
|
4709
|
+
// No client-side upsert needed.
|
|
4710
|
+
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
|
|
4711
|
+
locals.set(chatExternalIdKey, payload.chatId);
|
|
4712
|
+
locals.set(chatAgentRunContextKey, runOptions.ctx);
|
|
4713
|
+
locals.set(chatCustomAgentRunKey, true);
|
|
4714
|
+
if (parseClientData && parseClientDataSync) {
|
|
4715
|
+
locals.set(chatCustomAgentClientDataParserKey, {
|
|
4716
|
+
parse: parseClientData,
|
|
4717
|
+
parseSync: parseClientDataSync,
|
|
4718
|
+
});
|
|
4719
|
+
}
|
|
4720
|
+
if (onClientDataValidationError) {
|
|
4721
|
+
locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
|
|
4722
|
+
}
|
|
4723
|
+
locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
|
|
4724
|
+
// Initialize the turn-complete trim slot so `chat.writeTurnComplete`
|
|
4725
|
+
// trims `session.out` back to the previous turn boundary. Without
|
|
4726
|
+
// this the slot is undefined and the trim never runs, so `.out`
|
|
4727
|
+
// grows without bound for the whole custom-agent surface.
|
|
4728
|
+
locals.set(lastTurnCompleteSeqNumKey, { value: undefined });
|
|
4729
|
+
markChatAgentRunForStreamsWarning();
|
|
4730
|
+
taskContext.setConversationId(payload.chatId);
|
|
4731
|
+
stampConversationIdOnActiveSpan(payload.chatId);
|
|
4732
|
+
await installChatInputRouter(payload.chatId, {
|
|
4733
|
+
resuming: Boolean(payload.continuation),
|
|
4734
|
+
});
|
|
4735
|
+
// A custom agent's loop is the customer's, so there is no exit site the
|
|
4736
|
+
// SDK controls. Perform a requested close when `run()` returns, whatever
|
|
4737
|
+
// shape the loop had. Idempotent, so the createSession iterator having
|
|
4738
|
+
// already closed on its own exit costs nothing.
|
|
4739
|
+
const withClose = async (result) => {
|
|
4740
|
+
try {
|
|
4741
|
+
return await result;
|
|
4742
|
+
}
|
|
4743
|
+
finally {
|
|
4744
|
+
await performChatClose();
|
|
4745
|
+
}
|
|
4746
|
+
};
|
|
4747
|
+
// Keep the schema-free path identical to the original custom-agent
|
|
4748
|
+
// wrapper, including when userRun starts executing.
|
|
4749
|
+
if (!parseClientData) {
|
|
4750
|
+
return withClose(userRun(payload, runOptions));
|
|
4751
|
+
}
|
|
4752
|
+
const isHandoverBoot = payload.trigger === "handover-prepare";
|
|
4753
|
+
const isMessagelessBoot = payload.trigger === "preload" ||
|
|
4754
|
+
(payload.continuation === true &&
|
|
4755
|
+
payload.message === undefined &&
|
|
4756
|
+
payload.trigger !== "action" &&
|
|
4757
|
+
payload.trigger !== "regenerate-message" &&
|
|
4758
|
+
!isHandoverBoot);
|
|
4759
|
+
const validated = await validateChatCustomAgentPayload(payload, {
|
|
4760
|
+
// Preload and continuation boots do not represent a submitted turn,
|
|
4761
|
+
// so there is no sender waiting for a terminal frame. Handover errors
|
|
4762
|
+
// must be written after the warm response flushes and signals below.
|
|
4763
|
+
writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
|
|
4764
|
+
});
|
|
4765
|
+
if (validated.ok) {
|
|
4766
|
+
return withClose(userRun(validated.payload, runOptions));
|
|
4767
|
+
}
|
|
4768
|
+
if (isHandoverBoot) {
|
|
4769
|
+
const signal = await waitForHandover({
|
|
4770
|
+
payload,
|
|
4771
|
+
timeout: "1h",
|
|
4772
|
+
spanName: "waiting for handover signal (invalid clientData)",
|
|
4773
|
+
});
|
|
4774
|
+
if (!signal || signal.kind === "handover-skip") {
|
|
4775
|
+
return;
|
|
4776
|
+
}
|
|
4777
|
+
// The head-start writer flushes before sending this signal. Writing
|
|
4778
|
+
// the terminal error now preserves stream order and closes the stitch.
|
|
4779
|
+
await writeChatCustomAgentClientDataErrorToStream(payload);
|
|
4780
|
+
return;
|
|
4781
|
+
}
|
|
4782
|
+
// The Session base payload is sticky across continuation runs. If it is
|
|
4783
|
+
// invalid, returning here would boot the same bad metadata again on the
|
|
4784
|
+
// next message. Stay attached and wait for a valid wire frame instead.
|
|
4785
|
+
const next = await messagesInput.waitWithIdleTimeout({
|
|
4786
|
+
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
4787
|
+
timeout: "1h",
|
|
4788
|
+
spanName: "waiting for valid clientData",
|
|
4789
|
+
});
|
|
4790
|
+
if (!next.ok || next.output.trigger === "close") {
|
|
4791
|
+
return;
|
|
4792
|
+
}
|
|
4793
|
+
// Normal input frames omit run-level boot context. Carry it forward so
|
|
4794
|
+
// a continuation still tells the custom loop to restore prior state.
|
|
4795
|
+
const recoveredPayload = {
|
|
4796
|
+
...next.output,
|
|
4797
|
+
continuation: next.output.continuation ?? payload.continuation,
|
|
4798
|
+
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
4799
|
+
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
4800
|
+
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
4801
|
+
};
|
|
4802
|
+
return withClose(userRun(recoveredPayload, runOptions));
|
|
4803
|
+
},
|
|
4804
|
+
});
|
|
4805
|
+
// Register clientDataSchema so the CLI converts it to JSONSchema
|
|
4806
|
+
// and stores it as payloadSchema — used by the Playground UI
|
|
4807
|
+
if (clientDataSchema) {
|
|
4808
|
+
resourceCatalog.updateTaskMetadata(options.id, {
|
|
4809
|
+
schema: clientDataSchema,
|
|
4810
|
+
});
|
|
4811
|
+
}
|
|
4812
|
+
return task;
|
|
4813
|
+
}
|
|
4814
|
+
function chatAgent(options) {
|
|
4815
|
+
const { run: userRun, clientDataSchema, onBoot, onRecoveryBoot, onPreload, onChatStart, onValidateMessages, hydrateMessages, storage, actionSchema, events, channels, onAction, onTurnStart, onBeforeTurnComplete, onCompacted, compaction, pendingMessages: pendingMessagesConfig, prepareMessages, tools: toolsOption, onTurnComplete, maxTurns = 100, turnTimeout = "1h", idleTimeoutInSeconds = 30, chatAccessTokenTTL = "1h", preloadIdleTimeoutInSeconds, preloadTimeout, uiMessageStreamOptions, onChatSuspend, onChatResume, exitAfterPreloadIdle = false, oomMachine, registry: promptRegistry, system: agentSystem, cacheControl: agentCacheControl, systemProviderOptions: agentSystemProviderOptions, versionSkew, ...restOptions } = options;
|
|
4816
|
+
if (hydrateMessages) {
|
|
4817
|
+
if (storage) {
|
|
4818
|
+
throw new Error(`chat.agent: "${options.id}" sets both \`hydrateMessages\` and \`storage\`. ` +
|
|
4819
|
+
"`hydrateMessages` is deprecated and replaced by the storage: `save` receives every " +
|
|
4820
|
+
"change and `loadContext` on the storage owns the model's context. Remove `hydrateMessages`.");
|
|
4821
|
+
}
|
|
4822
|
+
if (typeof (transcriptStorageOverride ?? defaultStorage).loadContext === "function") {
|
|
4823
|
+
throw new Error(`chat.agent: "${options.id}" sets \`hydrateMessages\` and uses a transcript storage with ` +
|
|
4824
|
+
"`loadContext`. Both would own the model's context; keep one.");
|
|
4825
|
+
}
|
|
4826
|
+
warnHydrateMessagesDeprecatedOnce(options.id);
|
|
4827
|
+
}
|
|
4828
|
+
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
|
|
4829
|
+
const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined;
|
|
4830
|
+
// chat.agent does not expose generic retry options (see docstring on
|
|
4831
|
+
// `oomMachine`). The only opt-in is an OOM-triggered machine swap. If
|
|
4832
|
+
// `oomMachine` is set we allow one retry on a larger machine; otherwise
|
|
4833
|
+
// we keep the historical no-retry default.
|
|
4834
|
+
const retry = oomMachine
|
|
4835
|
+
? { maxAttempts: 2, outOfMemory: { machine: oomMachine } }
|
|
4836
|
+
: { maxAttempts: 1 };
|
|
4837
|
+
const task = createTask({
|
|
4838
|
+
...restOptions,
|
|
4839
|
+
retry,
|
|
4840
|
+
triggerSource: "agent",
|
|
4841
|
+
agentConfig: { type: "ai-sdk-chat" },
|
|
4842
|
+
run: async (payload, { signal: runSignal, ctx }) => {
|
|
3876
4843
|
locals.set(chatAgentRunContextKey, ctx);
|
|
3877
4844
|
locals.set(chatCustomAgentRunKey, false);
|
|
3878
4845
|
// On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
|
|
@@ -4011,6 +4978,69 @@ function chatAgent(options) {
|
|
|
4011
4978
|
: undefined;
|
|
4012
4979
|
let transcriptShadow = createTranscriptShadow([]);
|
|
4013
4980
|
let bootTranscriptState = null;
|
|
4981
|
+
/**
|
|
4982
|
+
* The channel this session answers into (see TranscriptChannelBinding). Set by a channel turn
|
|
4983
|
+
* whose connector defines `bind`, restored from runtime state at boot, saved on every changeset.
|
|
4984
|
+
*/
|
|
4985
|
+
let sessionChannelBinding;
|
|
4986
|
+
const channelDownloader = channels
|
|
4987
|
+
? makeChannelDownloader(channels, (connectorId) => sessionChannelBinding?.connectorId === connectorId
|
|
4988
|
+
? sessionChannelBinding.handle
|
|
4989
|
+
: undefined)
|
|
4990
|
+
: undefined;
|
|
4991
|
+
locals.set(chatChannelDownloadKey, channelDownloader);
|
|
4992
|
+
/**
|
|
4993
|
+
* Tool calls that already have channel controls posted. A turn that resumes with one of
|
|
4994
|
+
* several pending calls resolved still carries the others as pending; without this the
|
|
4995
|
+
* controls would be posted again, and the model's final text would lose to them. A
|
|
4996
|
+
* continuation worker seeds it at boot from the calls still pending in the restored
|
|
4997
|
+
* transcript of a channel-bound session, since the worker before it posted their controls.
|
|
4998
|
+
*/
|
|
4999
|
+
const channelRenderedToolCallIds = new Set();
|
|
5000
|
+
/** Serializes `lifecycle()` reports for the session's channel across turns. */
|
|
5001
|
+
const channelLifecycle = makeChannelLifecycleReporter();
|
|
5002
|
+
/**
|
|
5003
|
+
* Pending calls a channel stop resolved as stopped on this worker. A click on their old controls
|
|
5004
|
+
* is dropped even when the transcript was hydrated again from a store that never saw the stop.
|
|
5005
|
+
*/
|
|
5006
|
+
const channelStoppedToolCallIds = new Set();
|
|
5007
|
+
/** When the latest channel turn started, so a stop pressed before it is recognized as stale. */
|
|
5008
|
+
let lastChannelTurnStartedAt;
|
|
5009
|
+
/** When the latest channel turn ended, so a stop pressed while it ran is not run as a new turn. */
|
|
5010
|
+
let lastChannelTurnEndedAt;
|
|
5011
|
+
/** The turn reported its end state: the binding no longer marks the surface as working. */
|
|
5012
|
+
const clearChannelWorking = () => {
|
|
5013
|
+
if (!sessionChannelBinding?.working)
|
|
5014
|
+
return;
|
|
5015
|
+
const { working: _working, ...rest } = sessionChannelBinding;
|
|
5016
|
+
void _working;
|
|
5017
|
+
sessionChannelBinding = rest;
|
|
5018
|
+
};
|
|
5019
|
+
/** The turn's reply stream ended: the binding no longer names it for recovery. */
|
|
5020
|
+
const clearOpenReplyStream = () => {
|
|
5021
|
+
if (!sessionChannelBinding?.stream)
|
|
5022
|
+
return;
|
|
5023
|
+
const { stream: _stream, ...rest } = sessionChannelBinding;
|
|
5024
|
+
void _stream;
|
|
5025
|
+
sessionChannelBinding = rest;
|
|
5026
|
+
};
|
|
5027
|
+
/** Tell the bound channel the conversation is over (`chat.close()` or a close signal). */
|
|
5028
|
+
const reportChannelClosed = async () => {
|
|
5029
|
+
const binding = sessionChannelBinding;
|
|
5030
|
+
if (!binding)
|
|
5031
|
+
return;
|
|
5032
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5033
|
+
await channelLifecycle.report(connector, "closed", {
|
|
5034
|
+
handle: binding.handle,
|
|
5035
|
+
bound: true,
|
|
5036
|
+
firstTurn: false,
|
|
5037
|
+
});
|
|
5038
|
+
};
|
|
5039
|
+
/** Provenance of the turn or action being processed, for the storage context. */
|
|
5040
|
+
let currentTurnSource = "client";
|
|
5041
|
+
let currentTurnConnectorId;
|
|
5042
|
+
/** True once `onAction` returned `chat.turn()` for the action being processed. */
|
|
5043
|
+
let turnIsActionTurn = false;
|
|
4014
5044
|
/**
|
|
4015
5045
|
* True while the model lane holds a compaction summary, so it cannot be
|
|
4016
5046
|
* rebuilt from the transcript and has to be persisted as state. Reset
|
|
@@ -4073,9 +5103,14 @@ function chatAgent(options) {
|
|
|
4073
5103
|
const transcriptIds = new Set(opts.messages.map((message) => message.id));
|
|
4074
5104
|
const markerIds = new Set(steeringMarkers(opts.messages).map((marker) => marker.id));
|
|
4075
5105
|
const steering = [...(locals.get(chatSteeringInjectionsKey)?.values() ?? [])].filter((entry) => markerIds.has(entry.id) || entry.messageIds.some((id) => transcriptIds.has(id)));
|
|
4076
|
-
const runtimeState = laneCompacted ||
|
|
5106
|
+
const runtimeState = laneCompacted ||
|
|
5107
|
+
laneInjections.length > 0 ||
|
|
5108
|
+
queued.length > 0 ||
|
|
5109
|
+
steering.length > 0 ||
|
|
5110
|
+
sessionChannelBinding
|
|
4077
5111
|
? {
|
|
4078
5112
|
v: 1,
|
|
5113
|
+
...(sessionChannelBinding ? { channelBinding: sessionChannelBinding } : {}),
|
|
4079
5114
|
...(laneCompacted
|
|
4080
5115
|
? {
|
|
4081
5116
|
compaction: {
|
|
@@ -4105,7 +5140,9 @@ function chatAgent(options) {
|
|
|
4105
5140
|
chatId: payload.chatId,
|
|
4106
5141
|
clientData: opts.clientData,
|
|
4107
5142
|
turn: opts.turn,
|
|
4108
|
-
trigger: opts.trigger,
|
|
5143
|
+
trigger: opts.trigger === "action" && turnIsActionTurn ? "action-turn" : opts.trigger,
|
|
5144
|
+
source: currentTurnSource,
|
|
5145
|
+
...(currentTurnConnectorId ? { connectorId: currentTurnConnectorId } : {}),
|
|
4109
5146
|
runId: ctx.run.id,
|
|
4110
5147
|
ctx,
|
|
4111
5148
|
}, {
|
|
@@ -4188,6 +5225,9 @@ function chatAgent(options) {
|
|
|
4188
5225
|
let replayedPartial;
|
|
4189
5226
|
let replayedPartialRaw;
|
|
4190
5227
|
let replayedInTail = [];
|
|
5228
|
+
// Channel deliveries on the same tail. They are not "messages" for the recovery hooks, but
|
|
5229
|
+
// they are turns the predecessor owed, and they have to dispatch in seq order with the rest.
|
|
5230
|
+
let replayedChannelInTail = [];
|
|
4191
5231
|
// Wire payloads to dispatch as turns before the regular session.in
|
|
4192
5232
|
// pump kicks in. Populated by `onRecoveryBoot.recoveredTurns` (or its
|
|
4193
5233
|
// default, `inFlightUsers`). The turn-loop checks this queue ahead of
|
|
@@ -4318,9 +5358,11 @@ function chatAgent(options) {
|
|
|
4318
5358
|
}
|
|
4319
5359
|
bootSpan.setAttribute("chat.boot.replay.in.cursorFromSnapshot", snapshotInCursor !== undefined);
|
|
4320
5360
|
try {
|
|
4321
|
-
|
|
5361
|
+
const tail = await replaySessionInTailWithChannels(payload.chatId, {
|
|
4322
5362
|
lastEventId: bootInCursor !== undefined ? String(bootInCursor) : undefined,
|
|
4323
5363
|
});
|
|
5364
|
+
replayedInTail = tail.messages;
|
|
5365
|
+
replayedChannelInTail = tail.channelEvents;
|
|
4324
5366
|
}
|
|
4325
5367
|
catch (error) {
|
|
4326
5368
|
logger.warn("chat.agent: session.in replay failed; in-flight users may not be recovered", { error: error instanceof Error ? error.message : String(error) });
|
|
@@ -4341,7 +5383,10 @@ function chatAgent(options) {
|
|
|
4341
5383
|
}
|
|
4342
5384
|
// ── session.in router ──────────────────────────────────────────
|
|
4343
5385
|
//
|
|
4344
|
-
const recoveredSeqNums =
|
|
5386
|
+
const recoveredSeqNums = [
|
|
5387
|
+
...replayedInTail.map((r) => r.seqNum),
|
|
5388
|
+
...replayedChannelInTail.map((r) => r.seqNum),
|
|
5389
|
+
];
|
|
4345
5390
|
await installChatInputRouter(payload.chatId, {
|
|
4346
5391
|
fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
|
|
4347
5392
|
recoveredSeqNums,
|
|
@@ -4358,6 +5403,9 @@ function chatAgent(options) {
|
|
|
4358
5403
|
// predecessor chose to end before processing the message;
|
|
4359
5404
|
// those route through the normal continuation-wait path.
|
|
4360
5405
|
const hasRecoveredState = partialAssistant !== undefined;
|
|
5406
|
+
if (couldHavePriorState && hasRecoveredState) {
|
|
5407
|
+
locals.set(chatChannelRecoveryPendingKey, { value: true });
|
|
5408
|
+
}
|
|
4361
5409
|
let hookChain;
|
|
4362
5410
|
let hookRecoveredTurns;
|
|
4363
5411
|
let hookBeforeBoot;
|
|
@@ -4428,10 +5476,18 @@ function chatAgent(options) {
|
|
|
4428
5476
|
// user is re-dispatched as a fresh turn instead.
|
|
4429
5477
|
let seedChain;
|
|
4430
5478
|
let recoveredEntries;
|
|
5479
|
+
// The orphan-partial splice pairs the partial assistant with the oldest unprocessed record,
|
|
5480
|
+
// which only makes sense when that record is the browser message it was answering. When a
|
|
5481
|
+
// channel delivery is older than every browser message, the partial belongs to it instead, and
|
|
5482
|
+
// splicing the first browser message ahead of it would reorder the conversation.
|
|
5483
|
+
const oldestTailSeq = Math.min(...replayedInTail.map((r) => r.seqNum), ...replayedChannelInTail.map((r) => r.seqNum));
|
|
5484
|
+
const partialFollowsBrowserMessage = partialAssistant !== undefined &&
|
|
5485
|
+
inFlightUsers.length > 1 &&
|
|
5486
|
+
(replayedChannelInTail.length === 0 || replayedInTail[0]?.seqNum === oldestTailSeq);
|
|
4431
5487
|
if (hookChain !== undefined) {
|
|
4432
5488
|
seedChain = hookChain;
|
|
4433
5489
|
}
|
|
4434
|
-
else if (
|
|
5490
|
+
else if (partialFollowsBrowserMessage) {
|
|
4435
5491
|
seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
|
|
4436
5492
|
}
|
|
4437
5493
|
else {
|
|
@@ -4451,7 +5507,7 @@ function chatAgent(options) {
|
|
|
4451
5507
|
seqNum: seqNumsByRecoveredId.get(message.id)?.shift(),
|
|
4452
5508
|
}));
|
|
4453
5509
|
}
|
|
4454
|
-
else if (
|
|
5510
|
+
else if (partialFollowsBrowserMessage) {
|
|
4455
5511
|
recoveredEntries = replayedInTail
|
|
4456
5512
|
.slice(1)
|
|
4457
5513
|
.map((r) => ({ message: r.message, seqNum: r.seqNum }));
|
|
@@ -4488,6 +5544,7 @@ function chatAgent(options) {
|
|
|
4488
5544
|
metadataById.set(entry.message.id, entry.metadata);
|
|
4489
5545
|
}
|
|
4490
5546
|
const dispatchedRecoveredSeqs = new Set();
|
|
5547
|
+
const toInject = [];
|
|
4491
5548
|
for (const { message: msg, seqNum } of recoveredEntries) {
|
|
4492
5549
|
if (wireMessageId && msg.id === wireMessageId)
|
|
4493
5550
|
continue;
|
|
@@ -4499,18 +5556,42 @@ function chatAgent(options) {
|
|
|
4499
5556
|
sessionId: payload.sessionId,
|
|
4500
5557
|
metadata: recoveredMetadata,
|
|
4501
5558
|
trigger: "submit-message",
|
|
4502
|
-
message: msg,
|
|
4503
|
-
messageId: msg.id,
|
|
5559
|
+
message: msg,
|
|
5560
|
+
messageId: msg.id,
|
|
5561
|
+
continuation: payload.continuation,
|
|
5562
|
+
previousRunId: payload.previousRunId,
|
|
5563
|
+
};
|
|
5564
|
+
toInject.push({ injectedPayload, seqNum });
|
|
5565
|
+
}
|
|
5566
|
+
// Channel deliveries the predecessor never processed dispatch too, interleaved with the
|
|
5567
|
+
// browser messages by seq_num so a Slack message that arrived before a browser message
|
|
5568
|
+
// still runs before it. A delivery the boot payload itself carries is not queued twice.
|
|
5569
|
+
const wireDeliveryId = payload.channelEvent
|
|
5570
|
+
?.deliveryId;
|
|
5571
|
+
for (const entry of replayedChannelInTail) {
|
|
5572
|
+
const entryDeliveryId = entry.channelEvent.deliveryId;
|
|
5573
|
+
if (wireDeliveryId && entryDeliveryId === wireDeliveryId)
|
|
5574
|
+
continue;
|
|
5575
|
+
const injectedPayload = {
|
|
5576
|
+
chatId: payload.chatId,
|
|
5577
|
+
sessionId: payload.sessionId,
|
|
5578
|
+
metadata: entry.metadata ?? payload.metadata,
|
|
5579
|
+
trigger: "submit-message",
|
|
5580
|
+
channelEvent: entry.channelEvent,
|
|
4504
5581
|
continuation: payload.continuation,
|
|
4505
5582
|
previousRunId: payload.previousRunId,
|
|
4506
5583
|
};
|
|
5584
|
+
toInject.push({ injectedPayload, seqNum: entry.seqNum });
|
|
5585
|
+
}
|
|
5586
|
+
toInject.sort((a, b) => (a.seqNum ?? Infinity) - (b.seqNum ?? Infinity));
|
|
5587
|
+
for (const { injectedPayload, seqNum } of toInject) {
|
|
4507
5588
|
bootInjectedQueue.push(injectedPayload);
|
|
4508
5589
|
if (seqNum !== undefined) {
|
|
4509
5590
|
recoveredSeqByPayload.set(injectedPayload, seqNum);
|
|
4510
5591
|
dispatchedRecoveredSeqs.add(seqNum);
|
|
4511
5592
|
}
|
|
4512
5593
|
}
|
|
4513
|
-
for (const entry of replayedInTail) {
|
|
5594
|
+
for (const entry of [...replayedInTail, ...replayedChannelInTail]) {
|
|
4514
5595
|
if (!dispatchedRecoveredSeqs.has(entry.seqNum)) {
|
|
4515
5596
|
chatInputRouter().settleRecovered(entry.seqNum);
|
|
4516
5597
|
}
|
|
@@ -4562,6 +5643,38 @@ function chatAgent(options) {
|
|
|
4562
5643
|
accumulatedMessages = restored.messages;
|
|
4563
5644
|
laneCompacted = restored.compacted;
|
|
4564
5645
|
laneInjections = restored.injections;
|
|
5646
|
+
sessionChannelBinding = bootRuntimeState?.channelBinding;
|
|
5647
|
+
const interruptedStream = sessionChannelBinding?.stream;
|
|
5648
|
+
if (interruptedStream && sessionChannelBinding) {
|
|
5649
|
+
const binding = sessionChannelBinding;
|
|
5650
|
+
const connector = channels?.find((c) => c.id === binding.connectorId);
|
|
5651
|
+
clearOpenReplyStream();
|
|
5652
|
+
if (connector?.endInterruptedStream) {
|
|
5653
|
+
try {
|
|
5654
|
+
await connector.endInterruptedStream(interruptedStream, {
|
|
5655
|
+
handle: binding.handle,
|
|
5656
|
+
bound: true,
|
|
5657
|
+
});
|
|
5658
|
+
}
|
|
5659
|
+
catch (error) {
|
|
5660
|
+
logger.warn("chat.agent: ending the interrupted reply stream failed", { error });
|
|
5661
|
+
}
|
|
5662
|
+
}
|
|
5663
|
+
}
|
|
5664
|
+
if (sessionChannelBinding?.working) {
|
|
5665
|
+
const binding = sessionChannelBinding;
|
|
5666
|
+
clearChannelWorking();
|
|
5667
|
+
await channelLifecycle.report(channels?.find((c) => c.id === binding.connectorId), "idle", { handle: binding.handle, bound: true, firstTurn: false });
|
|
5668
|
+
}
|
|
5669
|
+
if (sessionChannelBinding) {
|
|
5670
|
+
for (const message of accumulatedUIMessages) {
|
|
5671
|
+
if (message.role !== "assistant")
|
|
5672
|
+
continue;
|
|
5673
|
+
for (const call of pendingToolCallsInMessage(message)) {
|
|
5674
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
5675
|
+
}
|
|
5676
|
+
}
|
|
5677
|
+
}
|
|
4565
5678
|
if (bootRuntimeState?.queued && bootRuntimeState.queued.length > 0) {
|
|
4566
5679
|
locals.set(chatBackgroundQueueKey, [
|
|
4567
5680
|
...(locals.get(chatBackgroundQueueKey) ?? []),
|
|
@@ -4948,26 +6061,432 @@ function chatAgent(options) {
|
|
|
4948
6061
|
// Declared here so the finally can detach it — a handler leaked past
|
|
4949
6062
|
// its turn duplicates every mid-stream message into the shared buffer.
|
|
4950
6063
|
let turnMsgSub;
|
|
6064
|
+
let turnControlSub;
|
|
4951
6065
|
let capturedPartialResponse;
|
|
4952
6066
|
let responseCommitted = false;
|
|
4953
6067
|
const turnBufferedChunks = [];
|
|
6068
|
+
let channelConn;
|
|
6069
|
+
let channelWorkingReaction;
|
|
6070
|
+
let channelWireEvent;
|
|
6071
|
+
/** The connector's posting handle for this turn: from `bind(event)` on a channel turn, else the session binding. */
|
|
6072
|
+
let channelHandle;
|
|
6073
|
+
/** True when no channel delivery started this turn and egress goes through the session binding. */
|
|
6074
|
+
let boundTurn = false;
|
|
6075
|
+
let channelAckRef;
|
|
6076
|
+
let channelStreamEditor;
|
|
6077
|
+
let droppedStaleInteraction = false;
|
|
6078
|
+
/** True when the connector mapped this delivery to an empty message: no turn runs. */
|
|
6079
|
+
let droppedEmptyMessage = false;
|
|
6080
|
+
let droppedUnknownConnector = false;
|
|
6081
|
+
/** True when this delivery was a stop the connector drops while idle: no turn runs. */
|
|
6082
|
+
let droppedIdleControl = false;
|
|
6083
|
+
/** This turn's native reply stream (`delivery: "stream"` with a `stream()` hook). */
|
|
6084
|
+
let channelReplyStream;
|
|
6085
|
+
/** True once this turn reported `working`, so its end reports the outcome. */
|
|
6086
|
+
let channelLifecycleStarted = false;
|
|
6087
|
+
/** When this turn started, to tell a stop meant for an earlier turn from one for this turn. */
|
|
6088
|
+
const channelTurnStartedAt = Date.now();
|
|
6089
|
+
/** True when this turn is a channel or bound turn that runs (not a dropped delivery). */
|
|
6090
|
+
let channelTurnRan = false;
|
|
6091
|
+
/** True when this turn resolves a pending tool call from a channel interaction callback. */
|
|
6092
|
+
let channelInteractionResolved = false;
|
|
6093
|
+
let channelFinalAnswerPosted = false;
|
|
6094
|
+
let channelFinalSendFailed = false;
|
|
6095
|
+
/** The stream-level error (an `error` chunk) this turn ended on, if any; posted to the channel in place of an empty reply. */
|
|
6096
|
+
let channelStreamErrorText;
|
|
6097
|
+
/** The text this turn streamed, so the channel reply is this turn's answer and not the whole message. */
|
|
6098
|
+
let channelTurnText;
|
|
4954
6099
|
try {
|
|
4955
6100
|
// Extract turn-level context before entering the span. Slim
|
|
4956
6101
|
// wire: at most one delta message per record. `headStartMessages`
|
|
4957
6102
|
// is consumed at boot only (via `payload.headStartMessages`)
|
|
4958
6103
|
// and intentionally discarded here.
|
|
4959
|
-
const
|
|
6104
|
+
const clickedAction = channelClickAction(channels, currentWirePayload.channelEvent);
|
|
6105
|
+
let actionChannel;
|
|
6106
|
+
if (clickedAction !== undefined) {
|
|
6107
|
+
const { channelEvent: clicked, ...withoutChannelEvent } = currentWirePayload;
|
|
6108
|
+
const click = clicked;
|
|
6109
|
+
actionChannel = {
|
|
6110
|
+
connectorId: click.connectorId,
|
|
6111
|
+
provider: channels?.find((c) => c.id === click.connectorId)?.source ?? "custom",
|
|
6112
|
+
event: click.event,
|
|
6113
|
+
deliveryId: click.deliveryId,
|
|
6114
|
+
};
|
|
6115
|
+
currentWirePayload = {
|
|
6116
|
+
...withoutChannelEvent,
|
|
6117
|
+
trigger: "action",
|
|
6118
|
+
action: clickedAction,
|
|
6119
|
+
actionSource: "channel",
|
|
6120
|
+
};
|
|
6121
|
+
}
|
|
6122
|
+
const { metadata: wireMetadata, message: incomingMessage, headStartMessages: _hsm, channelEvent: wireChannelEvent, ...restWire } = currentWirePayload;
|
|
4960
6123
|
void _hsm;
|
|
4961
|
-
|
|
4962
|
-
|
|
6124
|
+
channelWireEvent = wireChannelEvent;
|
|
6125
|
+
let effectiveIncomingMessage = incomingMessage;
|
|
6126
|
+
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
6127
|
+
turnClientData = clientData;
|
|
6128
|
+
turnIsActionTurn = false;
|
|
6129
|
+
currentTurnSource = wireChannelEvent
|
|
6130
|
+
? "channel"
|
|
6131
|
+
: currentWirePayload.actionSource === "webhook"
|
|
6132
|
+
? "webhook"
|
|
6133
|
+
: "client";
|
|
6134
|
+
currentTurnConnectorId = undefined;
|
|
6135
|
+
if (wireChannelEvent) {
|
|
6136
|
+
channelConn = channels?.find((c) => c.id === wireChannelEvent.connectorId);
|
|
6137
|
+
if (channelConn) {
|
|
6138
|
+
currentTurnConnectorId = channelConn.id;
|
|
6139
|
+
let idleControl = null;
|
|
6140
|
+
if (channelConn.onControl) {
|
|
6141
|
+
try {
|
|
6142
|
+
idleControl = channelConn.onControl(wireChannelEvent.event);
|
|
6143
|
+
}
|
|
6144
|
+
catch (controlError) {
|
|
6145
|
+
logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6146
|
+
error: controlError,
|
|
6147
|
+
});
|
|
6148
|
+
}
|
|
6149
|
+
}
|
|
6150
|
+
const stopForEndedTurn = idleControl?.at !== undefined &&
|
|
6151
|
+
lastChannelTurnEndedAt !== undefined &&
|
|
6152
|
+
idleControl.at < lastChannelTurnEndedAt;
|
|
6153
|
+
droppedIdleControl =
|
|
6154
|
+
idleControl?.kind === "stop" &&
|
|
6155
|
+
(idleControl.whenIdle === "drop" || stopForEndedTurn);
|
|
6156
|
+
// Bind the session to this connector so a later turn that no channel delivery
|
|
6157
|
+
// started (an action turn, a browser message) can still answer into the thread.
|
|
6158
|
+
if (channelConn.bind && !droppedIdleControl) {
|
|
6159
|
+
try {
|
|
6160
|
+
const handle = channelConn.bind(wireChannelEvent.event);
|
|
6161
|
+
if (handle !== null && handle !== undefined) {
|
|
6162
|
+
const controls = sessionChannelBinding?.connectorId === channelConn.id
|
|
6163
|
+
? sessionChannelBinding.controls
|
|
6164
|
+
: undefined;
|
|
6165
|
+
sessionChannelBinding = {
|
|
6166
|
+
connectorId: channelConn.id,
|
|
6167
|
+
handle,
|
|
6168
|
+
boundAt: new Date().toISOString(),
|
|
6169
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6170
|
+
...(controls ? { controls } : {}),
|
|
6171
|
+
};
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
catch (bindError) {
|
|
6175
|
+
logger.warn("chat.agent: channel bind() failed; session binding unchanged", {
|
|
6176
|
+
error: bindError,
|
|
6177
|
+
});
|
|
6178
|
+
}
|
|
6179
|
+
}
|
|
6180
|
+
channelHandle =
|
|
6181
|
+
sessionChannelBinding?.connectorId === channelConn.id
|
|
6182
|
+
? sessionChannelBinding.handle
|
|
6183
|
+
: undefined;
|
|
6184
|
+
const rawInteraction = droppedIdleControl
|
|
6185
|
+
? null
|
|
6186
|
+
: (channelConn.onInteraction?.(wireChannelEvent.event) ?? null);
|
|
6187
|
+
let interaction = rawInteraction
|
|
6188
|
+
? (resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null)
|
|
6189
|
+
: null;
|
|
6190
|
+
const accumulatorHasInteractionToolCall = interaction != null &&
|
|
6191
|
+
accumulatedUIMessages.some((m) => (m.parts ?? []).some((p) => p?.toolCallId === interaction.toolCallId));
|
|
6192
|
+
if (rawInteraction && loadContextHook && !accumulatorHasInteractionToolCall) {
|
|
6193
|
+
try {
|
|
6194
|
+
const hydratedForInteraction = (await loadContextHook({
|
|
6195
|
+
chatId: currentWirePayload.chatId,
|
|
6196
|
+
turn,
|
|
6197
|
+
trigger: "submit-message",
|
|
6198
|
+
incomingMessages: [],
|
|
6199
|
+
previousMessages: [...accumulatedUIMessages],
|
|
6200
|
+
clientData,
|
|
6201
|
+
continuation,
|
|
6202
|
+
previousRunId,
|
|
6203
|
+
}));
|
|
6204
|
+
accumulatedUIMessages = hydratedForInteraction;
|
|
6205
|
+
accumulatedMessages = await toModelMessages(hydratedForInteraction);
|
|
6206
|
+
laneCompacted = false;
|
|
6207
|
+
laneInjections = [];
|
|
6208
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6209
|
+
if (!interaction) {
|
|
6210
|
+
interaction =
|
|
6211
|
+
resolveInteractionToolCallId(rawInteraction, accumulatedUIMessages) ?? null;
|
|
6212
|
+
}
|
|
6213
|
+
}
|
|
6214
|
+
catch (hydrateError) {
|
|
6215
|
+
logger.warn("chat.agent: hydrateMessages for channel interaction resolution failed", { error: hydrateError });
|
|
6216
|
+
}
|
|
6217
|
+
}
|
|
6218
|
+
const resolutionMessage = interaction && !channelStoppedToolCallIds.has(interaction.toolCallId)
|
|
6219
|
+
? buildInteractionResolutionMessage(interaction, accumulatedUIMessages, {
|
|
6220
|
+
source: "channel",
|
|
6221
|
+
connectorId: channelConn.id,
|
|
6222
|
+
provider: channelConn.source,
|
|
6223
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6224
|
+
})
|
|
6225
|
+
: undefined;
|
|
6226
|
+
const channelInbound = droppedIdleControl || resolutionMessage || rawInteraction
|
|
6227
|
+
? undefined
|
|
6228
|
+
: channelConn.inbound(wireChannelEvent.event);
|
|
6229
|
+
if (droppedIdleControl) {
|
|
6230
|
+
const staleStop = idleControl?.at !== undefined &&
|
|
6231
|
+
lastChannelTurnStartedAt !== undefined &&
|
|
6232
|
+
idleControl.at < lastChannelTurnStartedAt;
|
|
6233
|
+
if (!staleStop && loadContextHook && accumulatedUIMessages.length === 0) {
|
|
6234
|
+
try {
|
|
6235
|
+
const hydratedForStop = (await loadContextHook({
|
|
6236
|
+
chatId: currentWirePayload.chatId,
|
|
6237
|
+
turn,
|
|
6238
|
+
trigger: "submit-message",
|
|
6239
|
+
incomingMessages: [],
|
|
6240
|
+
previousMessages: [],
|
|
6241
|
+
clientData,
|
|
6242
|
+
continuation,
|
|
6243
|
+
previousRunId,
|
|
6244
|
+
}));
|
|
6245
|
+
accumulatedUIMessages = hydratedForStop;
|
|
6246
|
+
accumulatedMessages = await toModelMessages(hydratedForStop);
|
|
6247
|
+
laneCompacted = false;
|
|
6248
|
+
laneInjections = [];
|
|
6249
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6250
|
+
}
|
|
6251
|
+
catch (hydrateError) {
|
|
6252
|
+
logger.warn("chat.agent: hydrateMessages for a channel stop failed", {
|
|
6253
|
+
error: hydrateError,
|
|
6254
|
+
});
|
|
6255
|
+
}
|
|
6256
|
+
}
|
|
6257
|
+
let lastAssistantIndex = -1;
|
|
6258
|
+
for (let i = accumulatedUIMessages.length - 1; i >= 0; i--) {
|
|
6259
|
+
if (accumulatedUIMessages[i].role === "assistant") {
|
|
6260
|
+
lastAssistantIndex = i;
|
|
6261
|
+
break;
|
|
6262
|
+
}
|
|
6263
|
+
}
|
|
6264
|
+
const lastAssistant = lastAssistantIndex === -1
|
|
6265
|
+
? undefined
|
|
6266
|
+
: accumulatedUIMessages[lastAssistantIndex];
|
|
6267
|
+
const stoppedCalls = lastAssistant && !staleStop && idleControl?.whenIdle === "drop"
|
|
6268
|
+
? pendingToolCallsInMessage(lastAssistant)
|
|
6269
|
+
: [];
|
|
6270
|
+
if (lastAssistant && stoppedCalls.length > 0) {
|
|
6271
|
+
const errorText = idleControl?.message || "stopped";
|
|
6272
|
+
const stoppedMessage = stopPendingToolCalls(lastAssistant, new Set(stoppedCalls.map((call) => call.toolCallId)), errorText);
|
|
6273
|
+
if (stoppedMessage) {
|
|
6274
|
+
accumulatedUIMessages = accumulatedUIMessages.map((m, i) => i === lastAssistantIndex ? stoppedMessage : m);
|
|
6275
|
+
const pausedRun = await toModelMessages([
|
|
6276
|
+
stripProviderMetadata(lastAssistant),
|
|
6277
|
+
]);
|
|
6278
|
+
if (pausedRun.length === 0) {
|
|
6279
|
+
accumulatedMessages = [
|
|
6280
|
+
...accumulatedMessages,
|
|
6281
|
+
...(await toModelMessages([stoppedMessage])),
|
|
6282
|
+
];
|
|
6283
|
+
}
|
|
6284
|
+
else if (!(await replaceModelRun(accumulatedMessages, lastAssistant, stoppedMessage, 0, pausedRun))) {
|
|
6285
|
+
logger.warn("chat.agent: paused message not found at the model lane tail; reconverting the lane");
|
|
6286
|
+
accumulatedMessages = await toModelMessages(accumulatedUIMessages, accumulatedUIMessages);
|
|
6287
|
+
laneCompacted = false;
|
|
6288
|
+
laneInjections = [];
|
|
6289
|
+
}
|
|
6290
|
+
for (const call of stoppedCalls)
|
|
6291
|
+
channelStoppedToolCallIds.add(call.toolCallId);
|
|
6292
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6293
|
+
}
|
|
6294
|
+
const refs = sessionChannelBinding?.connectorId === channelConn.id
|
|
6295
|
+
? sessionChannelBinding.controls
|
|
6296
|
+
: undefined;
|
|
6297
|
+
const cancelled = stoppedCalls.map((call) => refs?.[call.toolCallId] ? { ...call, ref: refs[call.toolCallId] } : call);
|
|
6298
|
+
for (const call of stoppedCalls) {
|
|
6299
|
+
channelRenderedToolCallIds.delete(call.toolCallId);
|
|
6300
|
+
}
|
|
6301
|
+
if (refs && sessionChannelBinding) {
|
|
6302
|
+
const remaining = Object.fromEntries(Object.entries(refs).filter(([id]) => !stoppedCalls.some((call) => call.toolCallId === id)));
|
|
6303
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6304
|
+
void _controls;
|
|
6305
|
+
sessionChannelBinding =
|
|
6306
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6307
|
+
}
|
|
6308
|
+
await writeSnapshotOutsideTurn("channel-stop", { turn, clientData });
|
|
6309
|
+
if (channelConn.cancelInteraction) {
|
|
6310
|
+
try {
|
|
6311
|
+
await channelConn.cancelInteraction(cancelled, channelTurnCtx(wireChannelEvent, channelHandle));
|
|
6312
|
+
}
|
|
6313
|
+
catch (cancelError) {
|
|
6314
|
+
logger.warn("chat.agent: channel cancelInteraction failed; continuing", {
|
|
6315
|
+
error: cancelError,
|
|
6316
|
+
});
|
|
6317
|
+
}
|
|
6318
|
+
}
|
|
6319
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
6320
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6321
|
+
firstTurn: false,
|
|
6322
|
+
});
|
|
6323
|
+
}
|
|
6324
|
+
}
|
|
6325
|
+
else if (resolutionMessage) {
|
|
6326
|
+
effectiveIncomingMessage = resolutionMessage;
|
|
6327
|
+
channelInteractionResolved = true;
|
|
6328
|
+
const resolvedCallId = interaction?.toolCallId;
|
|
6329
|
+
const controlRefs = sessionChannelBinding?.controls;
|
|
6330
|
+
if (resolvedCallId && controlRefs?.[resolvedCallId] && sessionChannelBinding) {
|
|
6331
|
+
const { [resolvedCallId]: _resolved, ...remaining } = controlRefs;
|
|
6332
|
+
void _resolved;
|
|
6333
|
+
const { controls: _controls, ...rest } = sessionChannelBinding;
|
|
6334
|
+
void _controls;
|
|
6335
|
+
sessionChannelBinding =
|
|
6336
|
+
Object.keys(remaining).length > 0 ? { ...rest, controls: remaining } : rest;
|
|
6337
|
+
}
|
|
6338
|
+
if (interaction && channelConn.finalizeInteraction) {
|
|
6339
|
+
try {
|
|
6340
|
+
await channelConn.finalizeInteraction(wireChannelEvent.event, interaction);
|
|
6341
|
+
}
|
|
6342
|
+
catch (finalizeError) {
|
|
6343
|
+
logger.warn("chat.agent: channel finalizeInteraction failed; continuing", {
|
|
6344
|
+
error: finalizeError,
|
|
6345
|
+
});
|
|
6346
|
+
}
|
|
6347
|
+
}
|
|
6348
|
+
}
|
|
6349
|
+
else if (rawInteraction) {
|
|
6350
|
+
droppedStaleInteraction = true;
|
|
6351
|
+
logger.debug("chat.agent: dropping stale channel interaction that matched no pending tool call", {
|
|
6352
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6353
|
+
});
|
|
6354
|
+
}
|
|
6355
|
+
else if (channelInbound === undefined || isEmptyChannelInbound(channelInbound)) {
|
|
6356
|
+
droppedEmptyMessage = true;
|
|
6357
|
+
logger.debug("chat.agent: dropping a channel delivery with an empty message", {
|
|
6358
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6359
|
+
});
|
|
6360
|
+
}
|
|
6361
|
+
else {
|
|
6362
|
+
// A session that starts on a conversation with history: let the connector supply
|
|
6363
|
+
// it, so the agent answers with the context a reader of the thread has.
|
|
6364
|
+
if (channelConn.seed && accumulatedUIMessages.length === 0) {
|
|
6365
|
+
try {
|
|
6366
|
+
const seeded = channelSeedMessages(await channelConn.seed(wireChannelEvent.event, {
|
|
6367
|
+
event: wireChannelEvent.event,
|
|
6368
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6369
|
+
}), {
|
|
6370
|
+
source: "channel",
|
|
6371
|
+
connectorId: channelConn.id,
|
|
6372
|
+
provider: channelConn.source,
|
|
6373
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6374
|
+
});
|
|
6375
|
+
if (seeded.length > 0) {
|
|
6376
|
+
accumulatedUIMessages = seeded;
|
|
6377
|
+
accumulatedMessages = await toModelMessages(seeded);
|
|
6378
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
6379
|
+
}
|
|
6380
|
+
}
|
|
6381
|
+
catch (seedError) {
|
|
6382
|
+
logger.warn("chat.agent: channel seed() failed; starting the session empty", {
|
|
6383
|
+
error: seedError,
|
|
6384
|
+
});
|
|
6385
|
+
}
|
|
6386
|
+
}
|
|
6387
|
+
effectiveIncomingMessage = channelUserMessage(channelInbound, currentWirePayload.messageId ?? wireChannelEvent.deliveryId, {
|
|
6388
|
+
source: "channel",
|
|
6389
|
+
connectorId: channelConn.id,
|
|
6390
|
+
provider: channelConn.source,
|
|
6391
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6392
|
+
...(wireChannelEvent.externalDeliveryId
|
|
6393
|
+
? { externalDeliveryId: wireChannelEvent.externalDeliveryId }
|
|
6394
|
+
: {}),
|
|
6395
|
+
});
|
|
6396
|
+
if (channelConn.send && channelConn.ack) {
|
|
6397
|
+
const recoveryPending = locals.get(chatChannelRecoveryPendingKey);
|
|
6398
|
+
const recovered = recoveryPending?.value === true;
|
|
6399
|
+
if (recovered)
|
|
6400
|
+
recoveryPending.value = false;
|
|
6401
|
+
const ackMessage = channelConn.ack(wireChannelEvent.event, { recovered });
|
|
6402
|
+
if (ackMessage) {
|
|
6403
|
+
try {
|
|
6404
|
+
const ackResult = await channelConn.send(ackMessage, {
|
|
6405
|
+
event: wireChannelEvent.event,
|
|
6406
|
+
handle: channelHandle,
|
|
6407
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6408
|
+
mode: channelConn.delivery,
|
|
6409
|
+
final: false,
|
|
6410
|
+
bound: false,
|
|
6411
|
+
});
|
|
6412
|
+
channelAckRef = ackResult?.ref;
|
|
6413
|
+
}
|
|
6414
|
+
catch (ackError) {
|
|
6415
|
+
logger.warn("chat.agent: channel ack post failed; continuing", {
|
|
6416
|
+
error: ackError,
|
|
6417
|
+
});
|
|
6418
|
+
}
|
|
6419
|
+
}
|
|
6420
|
+
}
|
|
6421
|
+
try {
|
|
6422
|
+
channelWorkingReaction = await resolveReactionChoice(channelConn.reactions?.working, wireChannelEvent.event);
|
|
6423
|
+
if (channelWorkingReaction) {
|
|
6424
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
6425
|
+
name: channelWorkingReaction,
|
|
6426
|
+
});
|
|
6427
|
+
}
|
|
6428
|
+
}
|
|
6429
|
+
catch (reactionError) {
|
|
6430
|
+
logger.warn("chat.agent: channel working reaction failed", {
|
|
6431
|
+
error: reactionError,
|
|
6432
|
+
});
|
|
6433
|
+
}
|
|
6434
|
+
}
|
|
6435
|
+
}
|
|
6436
|
+
else {
|
|
6437
|
+
droppedUnknownConnector = true;
|
|
6438
|
+
logger.warn("chat.agent: no channel connector matches this delivery; skipping turn", {
|
|
6439
|
+
connectorId: wireChannelEvent.connectorId,
|
|
6440
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
6441
|
+
});
|
|
6442
|
+
}
|
|
6443
|
+
}
|
|
6444
|
+
if (!wireChannelEvent &&
|
|
6445
|
+
sessionChannelBinding &&
|
|
6446
|
+
currentWirePayload.trigger !== "close" &&
|
|
6447
|
+
currentWirePayload.trigger !== "preload") {
|
|
6448
|
+
// No channel delivery started this turn, but the session is bound to a channel: answer
|
|
6449
|
+
// into it. Reactions need the triggering message, so a bound turn adds none; its reply,
|
|
6450
|
+
// any error and any approval controls go to the bound thread.
|
|
6451
|
+
const bound = channels?.find((c) => c.id === sessionChannelBinding.connectorId);
|
|
6452
|
+
if (bound?.send) {
|
|
6453
|
+
channelConn = bound;
|
|
6454
|
+
channelHandle = sessionChannelBinding.handle;
|
|
6455
|
+
boundTurn = true;
|
|
6456
|
+
currentTurnConnectorId = bound.id;
|
|
6457
|
+
}
|
|
6458
|
+
}
|
|
6459
|
+
const incomingMessages = effectiveIncomingMessage
|
|
6460
|
+
? [effectiveIncomingMessage]
|
|
4963
6461
|
: [];
|
|
4964
6462
|
// Cleaning happens once here so `extractLastUserMessageText` and
|
|
4965
6463
|
// every downstream consumer see the same message shape — and
|
|
4966
6464
|
// `cleanupAbortedParts` no longer has to be re-applied below.
|
|
4967
6465
|
const cleanedIncomingMessages = incomingMessages.map((msg) => msg.role === "assistant" ? cleanupAbortedParts(msg) : msg);
|
|
4968
|
-
const clientData = (parseClientData ? await parseClientData(wireMetadata) : wireMetadata);
|
|
4969
|
-
turnClientData = clientData;
|
|
4970
6466
|
const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages);
|
|
6467
|
+
channelTurnRan =
|
|
6468
|
+
!!channelConn &&
|
|
6469
|
+
!!(wireChannelEvent || boundTurn) &&
|
|
6470
|
+
currentWirePayload.trigger !== "action" &&
|
|
6471
|
+
!droppedIdleControl &&
|
|
6472
|
+
!droppedStaleInteraction &&
|
|
6473
|
+
!droppedEmptyMessage;
|
|
6474
|
+
const startChannelLifecycle = async () => {
|
|
6475
|
+
lastChannelTurnStartedAt = channelTurnStartedAt;
|
|
6476
|
+
if (!channelConn?.lifecycle)
|
|
6477
|
+
return;
|
|
6478
|
+
channelLifecycleStarted = true;
|
|
6479
|
+
if (sessionChannelBinding?.connectorId === channelConn.id) {
|
|
6480
|
+
sessionChannelBinding = { ...sessionChannelBinding, working: true };
|
|
6481
|
+
}
|
|
6482
|
+
await channelLifecycle.report(channelConn, "working", {
|
|
6483
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
6484
|
+
firstTurn: accumulatedUIMessages.every((m) => m.metadata?.seeded === true),
|
|
6485
|
+
...(lastUserMessage ? { prompt: lastUserMessage } : {}),
|
|
6486
|
+
});
|
|
6487
|
+
};
|
|
6488
|
+
if (channelTurnRan)
|
|
6489
|
+
await startChannelLifecycle();
|
|
4971
6490
|
// Actions are not turns. They use a different span name
|
|
4972
6491
|
// and don't carry a turn.number. Branched on at `isAction`.
|
|
4973
6492
|
const isAction = currentWirePayload.trigger === "action";
|
|
@@ -5023,6 +6542,10 @@ function chatAgent(options) {
|
|
|
5023
6542
|
// (incl. parsed clientData) exists, so every toModelMessages
|
|
5024
6543
|
// call this turn can re-apply tool `toModelOutput`.
|
|
5025
6544
|
await resolveTurnTools();
|
|
6545
|
+
// The channel surface for this turn, handed to run() and reachable from a tool's
|
|
6546
|
+
// execute through chat.surface(), so a connector can ship tools the model drives.
|
|
6547
|
+
const channelSurface = buildChannelRunSurface(channelConn, wireChannelEvent, channelHandle);
|
|
6548
|
+
locals.set(chatChannelSurfaceKey, channelSurface);
|
|
5026
6549
|
// Per-turn stop controller (reset each turn)
|
|
5027
6550
|
const stopController = new AbortController();
|
|
5028
6551
|
currentStopController = stopController;
|
|
@@ -5044,12 +6567,19 @@ function chatAgent(options) {
|
|
|
5044
6567
|
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
|
|
5045
6568
|
const msg = record.data
|
|
5046
6569
|
.payload;
|
|
6570
|
+
const wireChannel = msg
|
|
6571
|
+
.channelEvent;
|
|
6572
|
+
const steeredChannel = wireChannel != null
|
|
6573
|
+
? channelSteeringMessage(channels, wireChannel, msg.messageId)
|
|
6574
|
+
: undefined;
|
|
6575
|
+
if (wireChannel != null && !steeredChannel)
|
|
6576
|
+
return;
|
|
5047
6577
|
// Slim wire: at most one delta message per record. The
|
|
5048
6578
|
// pendingMessages handler reads `msg.message` directly
|
|
5049
6579
|
// instead of slicing an array — a wire record arrives
|
|
5050
6580
|
// with the new user message in `.message`, or no message
|
|
5051
6581
|
// at all (regenerate / preload / close / handover-prepare).
|
|
5052
|
-
const lastUIMessage = msg.message;
|
|
6582
|
+
const lastUIMessage = (steeredChannel?.message ?? msg.message);
|
|
5053
6583
|
if (lastUIMessage) {
|
|
5054
6584
|
if (pmConfig.onReceived) {
|
|
5055
6585
|
try {
|
|
@@ -5070,7 +6600,10 @@ function chatAgent(options) {
|
|
|
5070
6600
|
queue.some((e) => e.uiMessage.id === lastUIMessage.id)) {
|
|
5071
6601
|
return;
|
|
5072
6602
|
}
|
|
5073
|
-
const
|
|
6603
|
+
const converted = await toModelMessages([lastUIMessage]);
|
|
6604
|
+
const modelMsgs = steeredChannel && channelDownloader
|
|
6605
|
+
? await resolveChannelFiles(converted, channelDownloader)
|
|
6606
|
+
: converted;
|
|
5074
6607
|
queue.push({
|
|
5075
6608
|
uiMessage: lastUIMessage,
|
|
5076
6609
|
modelMessages: modelMsgs,
|
|
@@ -5085,6 +6618,52 @@ function chatAgent(options) {
|
|
|
5085
6618
|
})
|
|
5086
6619
|
: undefined;
|
|
5087
6620
|
turnMsgSub = msgSub;
|
|
6621
|
+
/**
|
|
6622
|
+
* Channel deliveries while this turn runs. A delivery the connector maps to `stop`
|
|
6623
|
+
* aborts this turn through the same controller the stop input uses, and is taken
|
|
6624
|
+
* off the route so it does not also run as a turn. Any other message is
|
|
6625
|
+
* acknowledged through the connector's `received` as soon as it arrives, and left
|
|
6626
|
+
* queued for steering or the next turn.
|
|
6627
|
+
*/
|
|
6628
|
+
const controlSub = channels?.some((c) => c.onControl || c.received)
|
|
6629
|
+
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
|
|
6630
|
+
const msg = record.data
|
|
6631
|
+
.payload;
|
|
6632
|
+
const ce = msg.channelEvent;
|
|
6633
|
+
if (!ce)
|
|
6634
|
+
return;
|
|
6635
|
+
const conn = channels?.find((c) => c.id === ce.connectorId);
|
|
6636
|
+
if (!conn)
|
|
6637
|
+
return;
|
|
6638
|
+
let control = null;
|
|
6639
|
+
try {
|
|
6640
|
+
control = conn.onControl?.(ce.event) ?? null;
|
|
6641
|
+
}
|
|
6642
|
+
catch (controlError) {
|
|
6643
|
+
logger.warn("chat.agent: channel onControl threw; treating as a message", {
|
|
6644
|
+
error: controlError,
|
|
6645
|
+
});
|
|
6646
|
+
}
|
|
6647
|
+
if (!control) {
|
|
6648
|
+
acknowledgeChannelMessage(conn, ce, sessionChannelBinding?.connectorId === conn.id
|
|
6649
|
+
? sessionChannelBinding.handle
|
|
6650
|
+
: undefined);
|
|
6651
|
+
return;
|
|
6652
|
+
}
|
|
6653
|
+
if (control.kind !== "stop")
|
|
6654
|
+
return;
|
|
6655
|
+
const earlierTurnStop = control.at !== undefined && control.at < channelTurnStartedAt;
|
|
6656
|
+
if (!earlierTurnStop && !stopController.signal.aborted) {
|
|
6657
|
+
stopController.abort(new DOMException(control.message || "stopped", "AbortError"));
|
|
6658
|
+
}
|
|
6659
|
+
// Observers are offered a record before it is queued, so the take has to
|
|
6660
|
+
// wait a tick or it finds nothing and the stop runs again as a turn.
|
|
6661
|
+
void Promise.resolve().then(() => {
|
|
6662
|
+
chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
|
|
6663
|
+
});
|
|
6664
|
+
})
|
|
6665
|
+
: undefined;
|
|
6666
|
+
turnControlSub = controlSub;
|
|
5088
6667
|
// Track new messages for this turn (user input + assistant response).
|
|
5089
6668
|
const turnNewModelMessages = [];
|
|
5090
6669
|
const turnNewUIMessages = [];
|
|
@@ -5114,7 +6693,8 @@ function chatAgent(options) {
|
|
|
5114
6693
|
let actionChangedHistory = false;
|
|
5115
6694
|
if (isAction) {
|
|
5116
6695
|
// Parse and validate the action payload
|
|
5117
|
-
const
|
|
6696
|
+
const isWebhookAction = currentWirePayload.actionSource === "webhook";
|
|
6697
|
+
const parsedAction = parseAction && !isWebhookAction
|
|
5118
6698
|
? await parseAction(currentWirePayload.action)
|
|
5119
6699
|
: currentWirePayload.action;
|
|
5120
6700
|
// Hydrate messages from backend if configured
|
|
@@ -5155,6 +6735,7 @@ function chatAgent(options) {
|
|
|
5155
6735
|
clientData,
|
|
5156
6736
|
uiMessages: accumulatedUIMessages,
|
|
5157
6737
|
messages: accumulatedMessages,
|
|
6738
|
+
...(actionChannel ? { channel: actionChannel } : {}),
|
|
5158
6739
|
});
|
|
5159
6740
|
}, {
|
|
5160
6741
|
attributes: {
|
|
@@ -5189,7 +6770,11 @@ function chatAgent(options) {
|
|
|
5189
6770
|
// snapshot + `session.out` replay (or `hydrateMessages`,
|
|
5190
6771
|
// which also fires per-turn below). Per-turn handling is
|
|
5191
6772
|
// therefore a delta merge, not a full-history reset.
|
|
5192
|
-
if (currentWirePayload.trigger !== "action"
|
|
6773
|
+
if (currentWirePayload.trigger !== "action" &&
|
|
6774
|
+
!droppedStaleInteraction &&
|
|
6775
|
+
!droppedEmptyMessage &&
|
|
6776
|
+
!droppedUnknownConnector &&
|
|
6777
|
+
!droppedIdleControl) {
|
|
5193
6778
|
let cleanedUIMessages = cleanedIncomingMessages;
|
|
5194
6779
|
// Turn-0 head-start with hydrateMessages: the boot seeding from
|
|
5195
6780
|
// `payload.headStartMessages` is non-hydrate-only, so ship the
|
|
@@ -5441,17 +7026,41 @@ function chatAgent(options) {
|
|
|
5441
7026
|
// calling the model would prefill its own last reply. Keyed on
|
|
5442
7027
|
// the model tail, so a `tool`-terminated chain (a merged tool
|
|
5443
7028
|
// approval) still runs.
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
7029
|
+
// A channel delivery the run decided not to turn into a turn: a stale
|
|
7030
|
+
// interaction callback (no pending tool call matched) or an event for
|
|
7031
|
+
// a connector this agent does not list. Nothing to answer either way.
|
|
7032
|
+
const isDroppedChannelDelivery = droppedStaleInteraction ||
|
|
7033
|
+
droppedEmptyMessage ||
|
|
7034
|
+
droppedUnknownConnector ||
|
|
7035
|
+
droppedIdleControl;
|
|
7036
|
+
// A resolved interaction is never a no-op: the assistant may already have text after
|
|
7037
|
+
// the pending call, so the lane ends on an assistant message, but the model has not
|
|
7038
|
+
// seen the decision yet.
|
|
7039
|
+
const isNoOpTurn = isDroppedChannelDelivery ||
|
|
7040
|
+
(!isAction &&
|
|
7041
|
+
!splicedHandoverPartial &&
|
|
7042
|
+
!channelInteractionResolved &&
|
|
7043
|
+
currentWirePayload.trigger === "submit-message" &&
|
|
7044
|
+
turnNewUIMessages.length === 0 &&
|
|
7045
|
+
accumulatedMessages[accumulatedMessages.length - 1]?.role === "assistant");
|
|
5449
7046
|
if (isNoOpTurn) {
|
|
5450
7047
|
msgSub?.off();
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5454
|
-
|
|
7048
|
+
turnControlSub?.off();
|
|
7049
|
+
if (channelTurnRan)
|
|
7050
|
+
lastChannelTurnEndedAt = Date.now();
|
|
7051
|
+
clearChannelWorking();
|
|
7052
|
+
if (channelLifecycleStarted) {
|
|
7053
|
+
await channelLifecycle.report(channelConn, "idle", {
|
|
7054
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7055
|
+
firstTurn: false,
|
|
7056
|
+
});
|
|
7057
|
+
}
|
|
7058
|
+
if (!isDroppedChannelDelivery) {
|
|
7059
|
+
logger.warn("chat.agent: turn added no new user message; skipping the model", {
|
|
7060
|
+
chatId: currentWirePayload.chatId,
|
|
7061
|
+
messageId: currentWirePayload.messageId,
|
|
7062
|
+
});
|
|
7063
|
+
}
|
|
5455
7064
|
settleRecoveredTurn(currentWirePayload);
|
|
5456
7065
|
await writeTurnCompleteChunk(currentWirePayload.chatId);
|
|
5457
7066
|
// Not a turn — don't consume an iteration.
|
|
@@ -5467,6 +7076,24 @@ function chatAgent(options) {
|
|
|
5467
7076
|
// sees the same `turn` value — actions don't count.
|
|
5468
7077
|
if (isAction) {
|
|
5469
7078
|
if (isActionTurn(actionResult)) {
|
|
7079
|
+
if (actionResult.message !== undefined) {
|
|
7080
|
+
const asked = (typeof actionResult.message === "string"
|
|
7081
|
+
? {
|
|
7082
|
+
id: generateMessageId(),
|
|
7083
|
+
role: "user",
|
|
7084
|
+
parts: [{ type: "text", text: actionResult.message }],
|
|
7085
|
+
}
|
|
7086
|
+
: actionResult.message.id
|
|
7087
|
+
? actionResult.message
|
|
7088
|
+
: { ...actionResult.message, id: generateMessageId() });
|
|
7089
|
+
accumulatedUIMessages = [...accumulatedUIMessages, asked];
|
|
7090
|
+
accumulatedMessages = [
|
|
7091
|
+
...accumulatedMessages,
|
|
7092
|
+
...(await toModelMessages([asked])),
|
|
7093
|
+
];
|
|
7094
|
+
locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages);
|
|
7095
|
+
actionChangedHistory = true;
|
|
7096
|
+
}
|
|
5470
7097
|
// Persist the edit before the turn starts, so a turn that is
|
|
5471
7098
|
// cancelled or runs out of memory continues from the edited
|
|
5472
7099
|
// history rather than from the snapshot the edit replaced.
|
|
@@ -5475,6 +7102,11 @@ function chatAgent(options) {
|
|
|
5475
7102
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5476
7103
|
}
|
|
5477
7104
|
actionTurn = true;
|
|
7105
|
+
turnIsActionTurn = true;
|
|
7106
|
+
if (boundTurn && channelConn) {
|
|
7107
|
+
channelTurnRan = true;
|
|
7108
|
+
await startChannelLifecycle();
|
|
7109
|
+
}
|
|
5478
7110
|
}
|
|
5479
7111
|
else if (actionResult !== undefined) {
|
|
5480
7112
|
throw new Error("chat.agent: onAction returned a value. An action is a state edit; to answer " +
|
|
@@ -5483,6 +7115,7 @@ function chatAgent(options) {
|
|
|
5483
7115
|
}
|
|
5484
7116
|
else {
|
|
5485
7117
|
msgSub?.off();
|
|
7118
|
+
turnControlSub?.off();
|
|
5486
7119
|
if (actionChangedHistory) {
|
|
5487
7120
|
await writeSnapshotOutsideTurn("action", { turn, clientData });
|
|
5488
7121
|
}
|
|
@@ -5684,12 +7317,87 @@ function chatAgent(options) {
|
|
|
5684
7317
|
}
|
|
5685
7318
|
else {
|
|
5686
7319
|
const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run");
|
|
7320
|
+
const runMessages = channelDownloader
|
|
7321
|
+
? await resolveChannelFiles(preparedMessages, channelDownloader)
|
|
7322
|
+
: preparedMessages;
|
|
7323
|
+
// Channel observers for this turn are built before run(). The progress
|
|
7324
|
+
// observer and the native reply stream are shared with a manual chat.pipe()
|
|
7325
|
+
// inside run() through the turn tap and stay open across several pipes; the
|
|
7326
|
+
// ack stream editor only rides the automatic pipe, because a manual pipe
|
|
7327
|
+
// captures no response and posts no final reply that would supersede a
|
|
7328
|
+
// partial edit. The finally of this turn stops all of them and clears the tap;
|
|
7329
|
+
// the turn boundary ends the native stream.
|
|
7330
|
+
if (channelConn) {
|
|
7331
|
+
const observers = [];
|
|
7332
|
+
const tapObservers = [];
|
|
7333
|
+
if (channelConn.delivery === "stream" &&
|
|
7334
|
+
channelConn.stream &&
|
|
7335
|
+
(wireChannelEvent || channelHandle)) {
|
|
7336
|
+
const streamConnectorId = channelConn.id;
|
|
7337
|
+
const opened = (ref) => {
|
|
7338
|
+
if (sessionChannelBinding?.connectorId !== streamConnectorId)
|
|
7339
|
+
return;
|
|
7340
|
+
sessionChannelBinding = { ...sessionChannelBinding, stream: ref };
|
|
7341
|
+
if (hydrateMessages)
|
|
7342
|
+
return;
|
|
7343
|
+
void saveTranscript({
|
|
7344
|
+
reason: "turn-start",
|
|
7345
|
+
messages: accumulatedUIMessages,
|
|
7346
|
+
turn,
|
|
7347
|
+
trigger: storageTrigger(currentWirePayload.trigger),
|
|
7348
|
+
clientData,
|
|
7349
|
+
lastOutEventId: lastSnapshotOutEventId,
|
|
7350
|
+
skipIfUnchanged: true,
|
|
7351
|
+
carryInCursor: true,
|
|
7352
|
+
}).catch((error) => {
|
|
7353
|
+
logger.warn("chat.agent: saving the open reply stream failed", {
|
|
7354
|
+
error: error instanceof Error ? error.message : String(error),
|
|
7355
|
+
});
|
|
7356
|
+
});
|
|
7357
|
+
};
|
|
7358
|
+
try {
|
|
7359
|
+
channelReplyStream =
|
|
7360
|
+
channelConn.stream({
|
|
7361
|
+
...channelTurnCtx(wireChannelEvent, channelHandle),
|
|
7362
|
+
opened,
|
|
7363
|
+
}) ?? undefined;
|
|
7364
|
+
}
|
|
7365
|
+
catch (streamError) {
|
|
7366
|
+
logger.warn("chat.agent: channel stream() threw; posting the reply", {
|
|
7367
|
+
error: streamError,
|
|
7368
|
+
});
|
|
7369
|
+
}
|
|
7370
|
+
}
|
|
7371
|
+
if (channelReplyStream) {
|
|
7372
|
+
const replyObserver = makeChannelReplyStreamObserver(channelReplyStream);
|
|
7373
|
+
observers.push(replyObserver);
|
|
7374
|
+
tapObservers.push(replyObserver);
|
|
7375
|
+
}
|
|
7376
|
+
else if (wireChannelEvent &&
|
|
7377
|
+
channelConn.send &&
|
|
7378
|
+
channelConn.delivery === "stream" &&
|
|
7379
|
+
channelAckRef) {
|
|
7380
|
+
observers.push(makeChannelStreamEditor(channelConn, wireChannelEvent, channelAckRef, channelHandle));
|
|
7381
|
+
}
|
|
7382
|
+
if (channelConn.progress && (wireChannelEvent || channelHandle)) {
|
|
7383
|
+
const progressObserver = makeChannelProgressObserver(channelConn, wireChannelEvent, channelHandle);
|
|
7384
|
+
observers.push(progressObserver);
|
|
7385
|
+
tapObservers.push(progressObserver);
|
|
7386
|
+
}
|
|
7387
|
+
if (tapObservers.length > 0) {
|
|
7388
|
+
const tapObserver = composeChannelObservers(tapObservers);
|
|
7389
|
+
locals.set(chatTurnChannelTapKey, (stream) => stream.pipeThrough(makeChannelStreamTap(tapObserver, { stopOnEnd: false })));
|
|
7390
|
+
}
|
|
7391
|
+
if (observers.length > 0) {
|
|
7392
|
+
channelStreamEditor = composeChannelObservers(observers);
|
|
7393
|
+
}
|
|
7394
|
+
}
|
|
5687
7395
|
runResult = await userRun({
|
|
5688
7396
|
...restWire,
|
|
5689
7397
|
// A turn requested by chat.turn() is not the action itself:
|
|
5690
7398
|
// a run() that short-circuits on "action" must still answer.
|
|
5691
7399
|
...(actionTurn ? { trigger: "action-turn" } : {}),
|
|
5692
|
-
messages:
|
|
7400
|
+
messages: runMessages,
|
|
5693
7401
|
clientData,
|
|
5694
7402
|
continuation,
|
|
5695
7403
|
previousRunId,
|
|
@@ -5702,6 +7410,7 @@ function chatAgent(options) {
|
|
|
5702
7410
|
cancelSignal,
|
|
5703
7411
|
stopSignal,
|
|
5704
7412
|
streamText: createBoundStreamText(promptRegistry, agentSystem, agentCacheControl, agentSystemProviderOptions),
|
|
7413
|
+
channel: channelSurface,
|
|
5705
7414
|
});
|
|
5706
7415
|
}
|
|
5707
7416
|
// Auto-pipe if the run function returned a StreamTextResult or similar,
|
|
@@ -5735,7 +7444,13 @@ function chatAgent(options) {
|
|
|
5735
7444
|
resolveOnFinish();
|
|
5736
7445
|
},
|
|
5737
7446
|
});
|
|
5738
|
-
|
|
7447
|
+
// The automatic pipe feeds every observer (progress and the ack stream editor)
|
|
7448
|
+
// itself; the manual tap is cleared so pipeChat does not feed progress twice.
|
|
7449
|
+
locals.set(chatTurnChannelTapKey, undefined);
|
|
7450
|
+
const streamForPipe = channelStreamEditor && uiStream instanceof ReadableStream
|
|
7451
|
+
? uiStream.pipeThrough(makeChannelStreamTap(channelStreamEditor))
|
|
7452
|
+
: uiStream;
|
|
7453
|
+
await pipeChat(tapUIMessageChunks(streamForPipe, turnBufferedChunks), {
|
|
5739
7454
|
signal: combinedSignal,
|
|
5740
7455
|
spanName: "stream response",
|
|
5741
7456
|
}, { originalMessages: isActionTurn ? undefined : accumulatedUIMessages });
|
|
@@ -5758,6 +7473,9 @@ function chatAgent(options) {
|
|
|
5758
7473
|
}
|
|
5759
7474
|
finally {
|
|
5760
7475
|
msgSub?.off();
|
|
7476
|
+
turnControlSub?.off();
|
|
7477
|
+
locals.set(chatTurnChannelTapKey, undefined);
|
|
7478
|
+
await channelStreamEditor?.stop();
|
|
5761
7479
|
}
|
|
5762
7480
|
// Wait for onFinish to fire — on abort this may resolve slightly
|
|
5763
7481
|
// after pipeChat, since the stream's cancel() handler is async.
|
|
@@ -6001,6 +7719,16 @@ function chatAgent(options) {
|
|
|
6001
7719
|
if (capturedResponseMessage) {
|
|
6002
7720
|
responseCommitted = true;
|
|
6003
7721
|
capturedPartialResponse = capturedResponseMessage;
|
|
7722
|
+
const errorChunk = turnBufferedChunks.find((chunk) => chunk.type === "error");
|
|
7723
|
+
channelStreamErrorText = errorChunk?.errorText;
|
|
7724
|
+
channelTurnText = turnBufferedChunks
|
|
7725
|
+
.map((chunk) => {
|
|
7726
|
+
const c = chunk;
|
|
7727
|
+
return c.type === "text-delta" && typeof c.delta === "string"
|
|
7728
|
+
? c.delta
|
|
7729
|
+
: "";
|
|
7730
|
+
})
|
|
7731
|
+
.join("");
|
|
6004
7732
|
turnBufferedChunks.length = 0;
|
|
6005
7733
|
}
|
|
6006
7734
|
if (runSignal.aborted)
|
|
@@ -6220,8 +7948,186 @@ function chatAgent(options) {
|
|
|
6220
7948
|
}
|
|
6221
7949
|
}
|
|
6222
7950
|
settleRecoveredTurn(currentWirePayload);
|
|
7951
|
+
openChannelEgressLatch(currentWirePayload.chatId);
|
|
6223
7952
|
// Write turn-complete control chunk — closes the frontend stream.
|
|
6224
7953
|
const turnCompleteResult = await writeTurnCompleteChunk(currentWirePayload.chatId, turnAccessToken);
|
|
7954
|
+
// Tool calls that have not had controls posted yet; a resume with the rest still
|
|
7955
|
+
// pending must not repost them or hide the answer text behind them.
|
|
7956
|
+
const pendingToolCalls = (wireChannelEvent || boundTurn) &&
|
|
7957
|
+
channelConn?.send &&
|
|
7958
|
+
turnCompleteEvent.responseMessage
|
|
7959
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).filter((call) => !channelRenderedToolCallIds.has(call.toolCallId))
|
|
7960
|
+
: [];
|
|
7961
|
+
const renderControls = !!((wireChannelEvent || boundTurn) &&
|
|
7962
|
+
pendingToolCalls.length > 0 &&
|
|
7963
|
+
channelConn?.renderInteraction);
|
|
7964
|
+
let channelReplyDelivered = false;
|
|
7965
|
+
if (channelReplyStream) {
|
|
7966
|
+
const replyStream = channelReplyStream;
|
|
7967
|
+
channelReplyStream = undefined;
|
|
7968
|
+
try {
|
|
7969
|
+
const ended = await replyStream.end({
|
|
7970
|
+
stopped: turnCompleteEvent.stopped,
|
|
7971
|
+
pending: renderControls,
|
|
7972
|
+
...(channelStreamErrorText ? { errorText: channelStreamErrorText } : {}),
|
|
7973
|
+
});
|
|
7974
|
+
channelReplyDelivered = ended.delivered;
|
|
7975
|
+
}
|
|
7976
|
+
catch (streamError) {
|
|
7977
|
+
logger.warn("chat.agent: channel reply stream end failed", {
|
|
7978
|
+
error: streamError,
|
|
7979
|
+
});
|
|
7980
|
+
}
|
|
7981
|
+
finally {
|
|
7982
|
+
clearOpenReplyStream();
|
|
7983
|
+
}
|
|
7984
|
+
}
|
|
7985
|
+
// Channel egress: map the turn's reply via outbound() and send it, editing the ack
|
|
7986
|
+
// placeholder posted at turn start (previousRef). A failed send is retried; one that
|
|
7987
|
+
// never lands is logged and the turn gets the error reaction. A manual-pipe turn has
|
|
7988
|
+
// no responseMessage, so it opts out.
|
|
7989
|
+
const finalChannelConn = channelConn;
|
|
7990
|
+
if ((wireChannelEvent || boundTurn) &&
|
|
7991
|
+
finalChannelConn?.send &&
|
|
7992
|
+
turnCompleteEvent.responseMessage) {
|
|
7993
|
+
if (renderControls) {
|
|
7994
|
+
for (const call of pendingToolCalls) {
|
|
7995
|
+
channelRenderedToolCallIds.add(call.toolCallId);
|
|
7996
|
+
}
|
|
7997
|
+
}
|
|
7998
|
+
const replyText = channelTurnText && channelTurnText.trim().length > 0
|
|
7999
|
+
? channelTurnText
|
|
8000
|
+
: channelReplyText(turnCompleteEvent.responseMessage);
|
|
8001
|
+
// A model or stream error surfaces as an `error` chunk and an empty reply, not as a
|
|
8002
|
+
// thrown turn. The mapper gets it on this one call, with the error text standing in
|
|
8003
|
+
// for an empty reply, so the placeholder never sits there as if the agent were
|
|
8004
|
+
// still working.
|
|
8005
|
+
const finalReply = {
|
|
8006
|
+
// Prefer what this turn streamed; a resumed message carries earlier turns' text too.
|
|
8007
|
+
text: channelStreamErrorText && replyText.trim().length === 0
|
|
8008
|
+
? channelStreamErrorText
|
|
8009
|
+
: replyText,
|
|
8010
|
+
message: turnCompleteEvent.responseMessage,
|
|
8011
|
+
final: true,
|
|
8012
|
+
stopped: turnCompleteEvent.stopped,
|
|
8013
|
+
...(renderControls ? { pending: true } : {}),
|
|
8014
|
+
...(channelStreamErrorText
|
|
8015
|
+
? { error: new Error(channelStreamErrorText) }
|
|
8016
|
+
: {}),
|
|
8017
|
+
};
|
|
8018
|
+
let outboundMessage = null;
|
|
8019
|
+
if (!channelReplyDelivered) {
|
|
8020
|
+
try {
|
|
8021
|
+
outboundMessage = (finalChannelConn.outbound ?? defaultChannelOutbound)(finalReply);
|
|
8022
|
+
}
|
|
8023
|
+
catch (outboundError) {
|
|
8024
|
+
logger.warn("chat.agent: channel outbound failed; posting the plain reply", {
|
|
8025
|
+
error: outboundError,
|
|
8026
|
+
});
|
|
8027
|
+
outboundMessage = defaultChannelOutbound(finalReply) ?? {
|
|
8028
|
+
text: "An unexpected error occurred",
|
|
8029
|
+
};
|
|
8030
|
+
}
|
|
8031
|
+
}
|
|
8032
|
+
const sendFinal = async (message) => {
|
|
8033
|
+
for (let attempt = 1;; attempt++) {
|
|
8034
|
+
try {
|
|
8035
|
+
const sent = await finalChannelConn.send(message, {
|
|
8036
|
+
event: wireChannelEvent?.event,
|
|
8037
|
+
handle: channelHandle,
|
|
8038
|
+
deliveryId: wireChannelEvent?.deliveryId,
|
|
8039
|
+
previousRef: channelAckRef,
|
|
8040
|
+
mode: finalChannelConn.delivery,
|
|
8041
|
+
final: true,
|
|
8042
|
+
bound: boundTurn,
|
|
8043
|
+
});
|
|
8044
|
+
channelFinalAnswerPosted = true;
|
|
8045
|
+
return sent ?? {};
|
|
8046
|
+
}
|
|
8047
|
+
catch (egressError) {
|
|
8048
|
+
if (attempt >= CHANNEL_FINAL_SEND_ATTEMPTS || !channelAckRef) {
|
|
8049
|
+
channelFinalSendFailed = true;
|
|
8050
|
+
logger.error("chat.agent: channel egress send failed", {
|
|
8051
|
+
error: egressError,
|
|
8052
|
+
attempts: attempt,
|
|
8053
|
+
});
|
|
8054
|
+
return undefined;
|
|
8055
|
+
}
|
|
8056
|
+
logger.warn("chat.agent: channel egress send failed; retrying", {
|
|
8057
|
+
error: egressError,
|
|
8058
|
+
attempt,
|
|
8059
|
+
});
|
|
8060
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * attempt));
|
|
8061
|
+
}
|
|
8062
|
+
}
|
|
8063
|
+
};
|
|
8064
|
+
// A turn that pauses on a human decision often explains itself first (a summary,
|
|
8065
|
+
// the steps so far) and then calls the tool. That text is the answer so far: post it
|
|
8066
|
+
// as the placeholder edit, and post the controls as their own message after it.
|
|
8067
|
+
if (renderControls && outboundMessage && (await sendFinal(outboundMessage))) {
|
|
8068
|
+
channelAckRef = undefined;
|
|
8069
|
+
}
|
|
8070
|
+
const channelMessage = renderControls
|
|
8071
|
+
? finalChannelConn.renderInteraction(pendingToolCalls, wireChannelEvent
|
|
8072
|
+
? {
|
|
8073
|
+
event: wireChannelEvent.event,
|
|
8074
|
+
deliveryId: wireChannelEvent.deliveryId,
|
|
8075
|
+
}
|
|
8076
|
+
: {})
|
|
8077
|
+
: outboundMessage;
|
|
8078
|
+
if (channelMessage) {
|
|
8079
|
+
const controlsRef = (await sendFinal(channelMessage))?.ref;
|
|
8080
|
+
if (renderControls &&
|
|
8081
|
+
controlsRef &&
|
|
8082
|
+
sessionChannelBinding?.connectorId === finalChannelConn.id) {
|
|
8083
|
+
sessionChannelBinding = {
|
|
8084
|
+
...sessionChannelBinding,
|
|
8085
|
+
controls: {
|
|
8086
|
+
...sessionChannelBinding.controls,
|
|
8087
|
+
...Object.fromEntries(pendingToolCalls.map((call) => [call.toolCallId, controlsRef])),
|
|
8088
|
+
},
|
|
8089
|
+
};
|
|
8090
|
+
}
|
|
8091
|
+
}
|
|
8092
|
+
}
|
|
8093
|
+
if (channelTurnRan)
|
|
8094
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8095
|
+
clearChannelWorking();
|
|
8096
|
+
if (channelLifecycleStarted) {
|
|
8097
|
+
const stillPending = turnCompleteEvent.responseMessage
|
|
8098
|
+
? pendingToolCallsInMessage(turnCompleteEvent.responseMessage).length > 0
|
|
8099
|
+
: false;
|
|
8100
|
+
await channelLifecycle.report(channelConn, locals.get(chatCloseRequestedKey)
|
|
8101
|
+
? "closed"
|
|
8102
|
+
: stillPending
|
|
8103
|
+
? "waiting"
|
|
8104
|
+
: "idle", { ...channelTurnCtx(wireChannelEvent, channelHandle), firstTurn: false });
|
|
8105
|
+
}
|
|
8106
|
+
// Lifecycle reaction: turn done. Remove "working", mark "done".
|
|
8107
|
+
if (wireChannelEvent && channelConn?.react) {
|
|
8108
|
+
try {
|
|
8109
|
+
if (channelWorkingReaction) {
|
|
8110
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8111
|
+
name: channelWorkingReaction,
|
|
8112
|
+
remove: true,
|
|
8113
|
+
});
|
|
8114
|
+
}
|
|
8115
|
+
const outcomeReaction = await resolveReactionChoice(channelStreamErrorText || channelFinalSendFailed
|
|
8116
|
+
? channelConn.reactions?.error
|
|
8117
|
+
: channelConn.reactions?.done, wireChannelEvent.event);
|
|
8118
|
+
if (outcomeReaction) {
|
|
8119
|
+
await applyChannelReaction(channelConn, wireChannelEvent, {
|
|
8120
|
+
name: outcomeReaction,
|
|
8121
|
+
});
|
|
8122
|
+
}
|
|
8123
|
+
}
|
|
8124
|
+
catch (reactionError) {
|
|
8125
|
+
logger.warn("chat.agent: channel done reaction failed", {
|
|
8126
|
+
error: reactionError,
|
|
8127
|
+
});
|
|
8128
|
+
}
|
|
8129
|
+
}
|
|
8130
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6225
8131
|
// Fire onTurnComplete — stream is closed, use for persistence.
|
|
6226
8132
|
if (onTurnComplete) {
|
|
6227
8133
|
await tracer.startActiveSpan("onTurnComplete()", async () => {
|
|
@@ -6339,6 +8245,7 @@ function chatAgent(options) {
|
|
|
6339
8245
|
// chat.endRun() — same exit, no upgrade semantics.
|
|
6340
8246
|
if (locals.get(chatCloseRequestedKey)) {
|
|
6341
8247
|
await performChatClose();
|
|
8248
|
+
await reportChannelClosed();
|
|
6342
8249
|
return "exit";
|
|
6343
8250
|
}
|
|
6344
8251
|
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
|
|
@@ -6410,6 +8317,7 @@ function chatAgent(options) {
|
|
|
6410
8317
|
currentWirePayload = next.output;
|
|
6411
8318
|
// Close signal — exit the loop gracefully
|
|
6412
8319
|
if (currentWirePayload.trigger === "close") {
|
|
8320
|
+
await reportChannelClosed();
|
|
6413
8321
|
return "exit";
|
|
6414
8322
|
}
|
|
6415
8323
|
return "continue";
|
|
@@ -6427,12 +8335,16 @@ function chatAgent(options) {
|
|
|
6427
8335
|
// Detach the turn's message handler first — left attached it would
|
|
6428
8336
|
// eat the very message the wait below is waiting for.
|
|
6429
8337
|
turnMsgSub?.off();
|
|
8338
|
+
turnControlSub?.off();
|
|
6430
8339
|
if (turnError instanceof Error &&
|
|
6431
8340
|
turnError.name === "AbortError" &&
|
|
6432
8341
|
runSignal.aborted) {
|
|
8342
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
8343
|
+
channelLifecycle.dispose();
|
|
6433
8344
|
// Full run cancellation — exit immediately
|
|
6434
8345
|
throw turnError;
|
|
6435
8346
|
}
|
|
8347
|
+
settleChannelEgressLatch(currentWirePayload.chatId);
|
|
6436
8348
|
// OOM errors must escape the turn loop so the task runtime can
|
|
6437
8349
|
// honor `retry.outOfMemory.machine` (set on chat.agent via
|
|
6438
8350
|
// `oomMachine`). Catching them here would keep the dead worker
|
|
@@ -6443,6 +8355,109 @@ function chatAgent(options) {
|
|
|
6443
8355
|
if (turnError instanceof OutOfMemoryError) {
|
|
6444
8356
|
throw turnError;
|
|
6445
8357
|
}
|
|
8358
|
+
if (channelWireEvent && channelConn?.react) {
|
|
8359
|
+
try {
|
|
8360
|
+
if (channelWorkingReaction) {
|
|
8361
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8362
|
+
name: channelWorkingReaction,
|
|
8363
|
+
remove: true,
|
|
8364
|
+
});
|
|
8365
|
+
}
|
|
8366
|
+
const errorReaction = await resolveReactionChoice(channelConn.reactions?.error, channelWireEvent.event);
|
|
8367
|
+
if (errorReaction) {
|
|
8368
|
+
await applyChannelReaction(channelConn, channelWireEvent, {
|
|
8369
|
+
name: errorReaction,
|
|
8370
|
+
});
|
|
8371
|
+
}
|
|
8372
|
+
}
|
|
8373
|
+
catch (reactionError) {
|
|
8374
|
+
logger.warn("chat.agent: channel error reaction failed", { error: reactionError });
|
|
8375
|
+
}
|
|
8376
|
+
}
|
|
8377
|
+
let channelErrorText = "An unexpected error occurred";
|
|
8378
|
+
if ((channelWireEvent || boundTurn) && channelConn) {
|
|
8379
|
+
const sanitizeChannelError = resolveUIMessageStreamOptions().onError;
|
|
8380
|
+
try {
|
|
8381
|
+
channelErrorText = sanitizeChannelError
|
|
8382
|
+
? sanitizeChannelError(turnError)
|
|
8383
|
+
: turnError instanceof Error
|
|
8384
|
+
? turnError.message
|
|
8385
|
+
: channelErrorText;
|
|
8386
|
+
}
|
|
8387
|
+
catch { }
|
|
8388
|
+
}
|
|
8389
|
+
let errorStreamDelivered = false;
|
|
8390
|
+
if (channelReplyStream) {
|
|
8391
|
+
const replyStream = channelReplyStream;
|
|
8392
|
+
channelReplyStream = undefined;
|
|
8393
|
+
try {
|
|
8394
|
+
const ended = await replyStream.end({
|
|
8395
|
+
stopped: false,
|
|
8396
|
+
pending: false,
|
|
8397
|
+
errorText: channelErrorText,
|
|
8398
|
+
});
|
|
8399
|
+
errorStreamDelivered = ended.delivered;
|
|
8400
|
+
}
|
|
8401
|
+
catch (streamError) {
|
|
8402
|
+
logger.warn("chat.agent: channel reply stream end failed", {
|
|
8403
|
+
error: streamError,
|
|
8404
|
+
});
|
|
8405
|
+
}
|
|
8406
|
+
finally {
|
|
8407
|
+
clearOpenReplyStream();
|
|
8408
|
+
}
|
|
8409
|
+
}
|
|
8410
|
+
if ((channelWireEvent || boundTurn) && channelConn?.send) {
|
|
8411
|
+
const errorPreviousRef = channelFinalAnswerPosted ? undefined : channelAckRef;
|
|
8412
|
+
const channelErrorMessageId = errorPreviousRef ?? channelWireEvent?.deliveryId ?? `channel-error-${turn}`;
|
|
8413
|
+
let errorMessage = null;
|
|
8414
|
+
try {
|
|
8415
|
+
errorMessage = (channelConn.outbound ?? defaultChannelOutbound)({
|
|
8416
|
+
text: channelErrorText,
|
|
8417
|
+
message: {
|
|
8418
|
+
id: channelErrorMessageId,
|
|
8419
|
+
role: "assistant",
|
|
8420
|
+
parts: [{ type: "text", text: channelErrorText }],
|
|
8421
|
+
},
|
|
8422
|
+
final: true,
|
|
8423
|
+
stopped: false,
|
|
8424
|
+
error: turnError,
|
|
8425
|
+
});
|
|
8426
|
+
}
|
|
8427
|
+
catch (outboundError) {
|
|
8428
|
+
logger.warn("chat.agent: channel error outbound failed; posting the plain error", {
|
|
8429
|
+
error: outboundError,
|
|
8430
|
+
});
|
|
8431
|
+
errorMessage = { text: channelErrorText };
|
|
8432
|
+
}
|
|
8433
|
+
if (errorMessage && !errorStreamDelivered) {
|
|
8434
|
+
try {
|
|
8435
|
+
await channelConn.send(errorMessage, {
|
|
8436
|
+
event: channelWireEvent?.event,
|
|
8437
|
+
handle: channelHandle,
|
|
8438
|
+
deliveryId: channelWireEvent?.deliveryId,
|
|
8439
|
+
previousRef: errorPreviousRef,
|
|
8440
|
+
mode: channelConn.delivery,
|
|
8441
|
+
final: true,
|
|
8442
|
+
bound: boundTurn,
|
|
8443
|
+
});
|
|
8444
|
+
}
|
|
8445
|
+
catch (egressError) {
|
|
8446
|
+
logger.warn("chat.agent: channel error egress send failed", {
|
|
8447
|
+
error: egressError,
|
|
8448
|
+
});
|
|
8449
|
+
}
|
|
8450
|
+
}
|
|
8451
|
+
}
|
|
8452
|
+
if (channelTurnRan)
|
|
8453
|
+
lastChannelTurnEndedAt = Date.now();
|
|
8454
|
+
clearChannelWorking();
|
|
8455
|
+
if (channelLifecycleStarted) {
|
|
8456
|
+
await channelLifecycle.report(channelConn, locals.get(chatCloseRequestedKey) ? "closed" : "idle", {
|
|
8457
|
+
...channelTurnCtx(channelWireEvent, channelHandle),
|
|
8458
|
+
firstTurn: false,
|
|
8459
|
+
});
|
|
8460
|
+
}
|
|
6446
8461
|
let errorTurnCompleteResult;
|
|
6447
8462
|
try {
|
|
6448
8463
|
await withChatWriter(async (writer) => {
|
|
@@ -6699,6 +8714,7 @@ function chatAgent(options) {
|
|
|
6699
8714
|
.get(chatManagedResponseKey)
|
|
6700
8715
|
?.close()
|
|
6701
8716
|
.catch(() => { });
|
|
8717
|
+
turnControlSub?.off();
|
|
6702
8718
|
}
|
|
6703
8719
|
}
|
|
6704
8720
|
}
|
|
@@ -6722,6 +8738,45 @@ function chatAgent(options) {
|
|
|
6722
8738
|
schema: clientDataSchema,
|
|
6723
8739
|
});
|
|
6724
8740
|
}
|
|
8741
|
+
// Claim each listed chat.event descriptor: subscribe THIS agent's session to the event's endpoint.
|
|
8742
|
+
// The subscriber id is scoped by agent, so the same descriptor on two agents is two subscribers.
|
|
8743
|
+
if (events) {
|
|
8744
|
+
for (const wh of events) {
|
|
8745
|
+
resourceCatalog.markSessionWebhookClaimed(wh.id);
|
|
8746
|
+
resourceCatalog.registerWebhookSubscriber({
|
|
8747
|
+
endpointId: wh.endpointId,
|
|
8748
|
+
target: {
|
|
8749
|
+
type: "session",
|
|
8750
|
+
id: `${options.id}:${wh.id}`,
|
|
8751
|
+
taskIdentifier: options.id,
|
|
8752
|
+
keyTemplate: wh.key,
|
|
8753
|
+
actionType: wh.type,
|
|
8754
|
+
deliverAs: "action",
|
|
8755
|
+
...(wh.filter ? { filter: wh.filter } : {}),
|
|
8756
|
+
},
|
|
8757
|
+
});
|
|
8758
|
+
}
|
|
8759
|
+
}
|
|
8760
|
+
// Claim each listed channel connector: the same kind of subscriber, but deliverAs "message" so a
|
|
8761
|
+
// verified event becomes a turn (the run maps it via the connector's inbound(), resolved by connectorId).
|
|
8762
|
+
if (channels) {
|
|
8763
|
+
for (const ch of channels) {
|
|
8764
|
+
resourceCatalog.markSessionWebhookClaimed(ch.id);
|
|
8765
|
+
resourceCatalog.registerWebhookSubscriber({
|
|
8766
|
+
endpointId: ch.endpointId,
|
|
8767
|
+
target: {
|
|
8768
|
+
type: "session",
|
|
8769
|
+
id: `${options.id}:${ch.id}`,
|
|
8770
|
+
taskIdentifier: options.id,
|
|
8771
|
+
keyTemplate: ch.key,
|
|
8772
|
+
connectorId: ch.id,
|
|
8773
|
+
deliverAs: "message",
|
|
8774
|
+
...(ch.filter ? { filter: ch.filter } : {}),
|
|
8775
|
+
...(ch.startOn ? { startOn: ch.startOn } : {}),
|
|
8776
|
+
},
|
|
8777
|
+
});
|
|
8778
|
+
}
|
|
8779
|
+
}
|
|
6725
8780
|
return task;
|
|
6726
8781
|
}
|
|
6727
8782
|
function composeHooks(builderHook, taskHook) {
|
|
@@ -7017,6 +9072,22 @@ function resolveUIMessageStreamOptions() {
|
|
|
7017
9072
|
// ---------------------------------------------------------------------------
|
|
7018
9073
|
// Stop detection
|
|
7019
9074
|
// ---------------------------------------------------------------------------
|
|
9075
|
+
/**
|
|
9076
|
+
* The channel surface of the current turn, from anywhere inside a `chat.agent` run, including a
|
|
9077
|
+
* tool's `execute`. Undefined on a browser turn or when the connector defines no reactions and no
|
|
9078
|
+
* `surface()`. Pass the connector's surface type to get its helpers typed:
|
|
9079
|
+
*
|
|
9080
|
+
* @example
|
|
9081
|
+
* ```ts
|
|
9082
|
+
* execute: async ({ steps }) => {
|
|
9083
|
+
* const linear = chat.surface<LinearRunSurface>();
|
|
9084
|
+
* if (linear) await linear.plan(steps);
|
|
9085
|
+
* }
|
|
9086
|
+
* ```
|
|
9087
|
+
*/
|
|
9088
|
+
function getChannelSurface() {
|
|
9089
|
+
return locals.get(chatChannelSurfaceKey);
|
|
9090
|
+
}
|
|
7020
9091
|
/**
|
|
7021
9092
|
* Check whether the user stopped generation during the current turn.
|
|
7022
9093
|
*
|
|
@@ -9014,6 +11085,10 @@ function createChatLoadTranscriptAction(storage, options) {
|
|
|
9014
11085
|
export const chat = {
|
|
9015
11086
|
/** Create a chat agent. See {@link chatAgent}. */
|
|
9016
11087
|
agent: chatAgent,
|
|
11088
|
+
/** Declare an inbound webhook event an agent claims via `chat.agent({ events })`. See {@link chatEvent}. */
|
|
11089
|
+
event: chatEvent,
|
|
11090
|
+
/** Chat frontend connectors (Slack, etc.) an agent claims via `chat.agent({ channels })`. */
|
|
11091
|
+
channels: chatChannels,
|
|
9017
11092
|
/** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */
|
|
9018
11093
|
customAgent: chatCustomAgent,
|
|
9019
11094
|
/** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */
|
|
@@ -9032,6 +11107,8 @@ export const chat = {
|
|
|
9032
11107
|
local: chatLocal,
|
|
9033
11108
|
/** Create a public access token for a chat task. See {@link createChatAccessToken}. */
|
|
9034
11109
|
createAccessToken: createChatAccessToken,
|
|
11110
|
+
/** Mint a read-only token to WATCH a session by externalId (cross-surface). See {@link createChatWatchToken}. */
|
|
11111
|
+
createWatchToken: createChatWatchToken,
|
|
9035
11112
|
/** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */
|
|
9036
11113
|
setTurnTimeout,
|
|
9037
11114
|
/** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */
|
|
@@ -9042,6 +11119,8 @@ export const chat = {
|
|
|
9042
11119
|
setUIMessageStreamOptions,
|
|
9043
11120
|
/** Check if the current turn was stopped by the user. See {@link isStopped}. */
|
|
9044
11121
|
isStopped,
|
|
11122
|
+
/** The current turn's channel surface (reactions plus connector helpers), also from inside a tool. See {@link getChannelSurface}. */
|
|
11123
|
+
surface: getChannelSurface,
|
|
9045
11124
|
/** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
|
|
9046
11125
|
requestUpgrade,
|
|
9047
11126
|
/** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
|