@trigger.dev/sdk 4.5.12 → 4.5.13
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 +154 -20
- package/dist/commonjs/v3/ai.js +1241 -387
- package/dist/commonjs/v3/ai.js.map +1 -1
- package/dist/commonjs/v3/chat.d.ts +7 -2
- package/dist/commonjs/v3/chat.js +22 -7
- package/dist/commonjs/v3/chat.js.map +1 -1
- package/dist/commonjs/v3/chat.test.js +13 -4
- package/dist/commonjs/v3/chat.test.js.map +1 -1
- package/dist/commonjs/v3/envvars.js.map +1 -1
- package/dist/commonjs/v3/sessions.d.ts +4 -10
- package/dist/commonjs/v3/sessions.js +73 -47
- package/dist/commonjs/v3/sessions.js.map +1 -1
- package/dist/commonjs/v3/test/mock-chat-agent.js +1 -0
- package/dist/commonjs/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/commonjs/v3/test/test-session-handle.js +22 -23
- package/dist/commonjs/v3/test/test-session-handle.js.map +1 -1
- package/dist/commonjs/version.js +1 -1
- package/dist/esm/v3/ai.d.ts +154 -20
- package/dist/esm/v3/ai.js +1239 -387
- package/dist/esm/v3/ai.js.map +1 -1
- package/dist/esm/v3/chat.d.ts +7 -2
- package/dist/esm/v3/chat.js +22 -7
- package/dist/esm/v3/chat.js.map +1 -1
- package/dist/esm/v3/chat.test.js +13 -4
- package/dist/esm/v3/chat.test.js.map +1 -1
- package/dist/esm/v3/envvars.js.map +1 -1
- package/dist/esm/v3/sessions.d.ts +4 -10
- package/dist/esm/v3/sessions.js +73 -47
- package/dist/esm/v3/sessions.js.map +1 -1
- package/dist/esm/v3/test/mock-chat-agent.js +2 -1
- package/dist/esm/v3/test/mock-chat-agent.js.map +1 -1
- package/dist/esm/v3/test/test-session-handle.js +23 -24
- package/dist/esm/v3/test/test-session-handle.js.map +1 -1
- package/dist/esm/version.js +1 -1
- package/docs/ai-chat/client-protocol.mdx +8 -3
- package/docs/ai-chat/custom-agents.mdx +181 -46
- package/docs/ai-chat/patterns/recovery-boot.mdx +9 -2
- package/docs/ai-chat/patterns/version-upgrades.mdx +26 -6
- package/docs/ai-chat/pending-messages.mdx +5 -3
- package/docs/ai-chat/reference.mdx +26 -10
- package/docs/ai-chat/types.mdx +5 -1
- package/docs/deployment/atomic-deployment.mdx +12 -0
- package/docs/deployment/overview.mdx +7 -1
- package/docs/deployment/version-skew-protection.mdx +430 -0
- package/docs/github-actions.mdx +33 -5
- package/docs/github-integration.mdx +12 -0
- package/docs/self-hosting/env/webapp.mdx +1 -0
- package/docs/vercel-integration.mdx +43 -9
- package/docs/versioning.mdx +2 -0
- package/package.json +2 -2
package/dist/esm/v3/ai.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { accessoryAttributes, apiClientManager, controlSubtype, generateJWT, getSchemaParseFn, headerValue, InputStreamOncePromise, isAdditionalApiKey, isSchemaZodEsque, logger, ManualWaitpointPromise, OutOfMemoryError, resourceCatalog, SemanticInternalAttributes, SESSION_IN_EVENT_ID_HEADER, sessionStreams, taskContext, TRIGGER_CONTROL_SUBTYPE, } from "@trigger.dev/core/v3";
|
|
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, } from "@trigger.dev/core/v3";
|
|
2
2
|
// Runtime VALUES go through the ESM/CJS shim so the CJS build can `require`
|
|
3
3
|
// ESM-only `ai@7` (see ../imports/ai-runtime.ts).
|
|
4
4
|
import { trace } from "@opentelemetry/api";
|
|
@@ -103,49 +103,6 @@ async function findLatestSessionInCursor(chatId) {
|
|
|
103
103
|
}
|
|
104
104
|
return latestCursor;
|
|
105
105
|
}
|
|
106
|
-
/** Test-only entry point for the records-based cursor scan. @internal */
|
|
107
|
-
export async function __findLatestSessionInCursorForTests(chatId) {
|
|
108
|
-
return findLatestSessionInCursor(chatId);
|
|
109
|
-
}
|
|
110
|
-
/**
|
|
111
|
-
* Seed the `.in` resume cursor for custom-agent loops (`chat.customAgent`
|
|
112
|
-
* raw loops and `chat.createSession`) the way `chat.agent`'s boot does.
|
|
113
|
-
*
|
|
114
|
-
* MUST run before anything attaches a `.in` listener (`createStopSignal`,
|
|
115
|
-
* `chat.messages.on`, the first wait): attaching opens the SSE tail with
|
|
116
|
-
* `Last-Event-ID` from the seeded cursor, so attach-then-seed replays
|
|
117
|
-
* every record from seq 0 — already-answered user messages get delivered
|
|
118
|
-
* into the new run's first wait and the loop re-answers them.
|
|
119
|
-
*
|
|
120
|
-
* Seeds both cursors: `setLastSeqNum` controls the SSE `Last-Event-ID`,
|
|
121
|
-
* `setLastDispatchedSeqNum` gates waiter dispatch — seeding only the
|
|
122
|
-
* former still re-delivers records the manager buffered before the seed.
|
|
123
|
-
*
|
|
124
|
-
* No-ops on fresh boots and when a cursor is already seeded (e.g. the
|
|
125
|
-
* `chatCustomAgent` wrapper ran before a nested `createChatSession`).
|
|
126
|
-
* @internal
|
|
127
|
-
*/
|
|
128
|
-
async function seedSessionInResumeCursorForCustomLoop(payload) {
|
|
129
|
-
if (sessionStreams.lastSeqNum(payload.chatId, "in") !== undefined)
|
|
130
|
-
return;
|
|
131
|
-
// No continuation/attempt gate: the wire may omit `continuation` on a
|
|
132
|
-
// run that still has prior turns (chat.agent covers that case via its
|
|
133
|
-
// snapshot). The scan doubles as the prior-state probe — a fresh
|
|
134
|
-
// session has no turn-complete on `.out`, returns no cursor, and
|
|
135
|
-
// seeds nothing. Cost on fresh boots is one non-blocking records read.
|
|
136
|
-
try {
|
|
137
|
-
const cursor = await findLatestSessionInCursor(payload.chatId);
|
|
138
|
-
if (cursor !== undefined) {
|
|
139
|
-
sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
|
|
140
|
-
sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
catch (error) {
|
|
144
|
-
logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", {
|
|
145
|
-
error: error instanceof Error ? error.message : String(error),
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
106
|
let readChatSnapshotImpl;
|
|
150
107
|
export function __setReadChatSnapshotImplForTests(impl) {
|
|
151
108
|
readChatSnapshotImpl = impl;
|
|
@@ -1009,90 +966,508 @@ async function withChatWriter(fn) {
|
|
|
1009
966
|
await flush();
|
|
1010
967
|
return result;
|
|
1011
968
|
}
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
969
|
+
const CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT = "Invalid client data";
|
|
970
|
+
const chatCustomAgentClientDataParserKey = locals.create("chat.customAgentClientDataParser");
|
|
971
|
+
const chatCustomAgentClientDataErrorHandlerKey = locals.create("chat.customAgentClientDataErrorHandler");
|
|
972
|
+
function shouldValidateChatCustomAgentPayload(payload) {
|
|
973
|
+
return (payload.trigger !== "close" && locals.get(chatCustomAgentClientDataParserKey) !== undefined);
|
|
974
|
+
}
|
|
975
|
+
function assertChatCustomAgentSyncParseResult(result) {
|
|
976
|
+
if (result && typeof result.then === "function") {
|
|
977
|
+
void Promise.resolve(result).catch(() => { });
|
|
978
|
+
throw new Error("chat.messages.peek() cannot validate clientData with an asynchronous schema. " +
|
|
979
|
+
"Use chat.messages.once(), chat.messages.wait(), or chat.messages.waitWithIdleTimeout().");
|
|
980
|
+
}
|
|
981
|
+
return result;
|
|
982
|
+
}
|
|
983
|
+
function getChatCustomAgentSyncSchemaParseFn(schema) {
|
|
984
|
+
const parser = schema;
|
|
985
|
+
if (typeof parser === "function" && typeof parser.assert === "function") {
|
|
986
|
+
return parser.assert.bind(parser);
|
|
987
|
+
}
|
|
988
|
+
if (typeof parser === "function") {
|
|
989
|
+
return (value) => assertChatCustomAgentSyncParseResult(parser(value));
|
|
990
|
+
}
|
|
991
|
+
if (typeof parser.parse === "function") {
|
|
992
|
+
return (value) => assertChatCustomAgentSyncParseResult(parser.parse(value));
|
|
993
|
+
}
|
|
994
|
+
if (typeof parser.validateSync === "function") {
|
|
995
|
+
return parser.validateSync.bind(parser);
|
|
996
|
+
}
|
|
997
|
+
if (typeof parser.create === "function") {
|
|
998
|
+
return parser.create.bind(parser);
|
|
999
|
+
}
|
|
1000
|
+
if (typeof parser.assert === "function") {
|
|
1001
|
+
return (value) => {
|
|
1002
|
+
parser.assert(value);
|
|
1003
|
+
return value;
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
return () => {
|
|
1007
|
+
throw new Error("chat.messages.peek() cannot validate clientData with this schema. " +
|
|
1008
|
+
"Use chat.messages.once(), chat.messages.wait(), or chat.messages.waitWithIdleTimeout().");
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
async function writeChatCustomAgentClientDataErrorToStream(payload) {
|
|
1012
|
+
try {
|
|
1013
|
+
await withChatWriter((writer) => {
|
|
1014
|
+
writer.write({
|
|
1015
|
+
type: "error",
|
|
1016
|
+
errorText: CHAT_CUSTOM_AGENT_CLIENT_DATA_ERROR_TEXT,
|
|
1017
|
+
});
|
|
1018
|
+
});
|
|
1019
|
+
await chatWriteTurnComplete();
|
|
1020
|
+
}
|
|
1021
|
+
catch (signalError) {
|
|
1022
|
+
logger.warn("chat.customAgent: failed to report clientData validation error", {
|
|
1023
|
+
chatId: payload.chatId,
|
|
1024
|
+
trigger: payload.trigger,
|
|
1025
|
+
error: signalError instanceof Error ? signalError.message : String(signalError),
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
async function reportChatCustomAgentClientDataError(payload, error, options) {
|
|
1030
|
+
const errorText = error instanceof Error ? error.message : "An unexpected error occurred";
|
|
1031
|
+
logger.warn("chat.customAgent: clientData validation failed", {
|
|
1032
|
+
chatId: payload.chatId,
|
|
1033
|
+
trigger: payload.trigger,
|
|
1034
|
+
error: errorText,
|
|
1035
|
+
});
|
|
1036
|
+
const errorHandler = options.callHandler === false
|
|
1037
|
+
? undefined
|
|
1038
|
+
: locals.get(chatCustomAgentClientDataErrorHandlerKey);
|
|
1039
|
+
if (errorHandler) {
|
|
1040
|
+
try {
|
|
1041
|
+
await errorHandler({ error, payload });
|
|
1042
|
+
}
|
|
1043
|
+
catch (handlerError) {
|
|
1044
|
+
logger.warn("chat.customAgent: clientData validation error handler failed", {
|
|
1045
|
+
chatId: payload.chatId,
|
|
1046
|
+
trigger: payload.trigger,
|
|
1047
|
+
error: handlerError instanceof Error ? handlerError.message : String(handlerError),
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
if (!options.writeToStream) {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
await writeChatCustomAgentClientDataErrorToStream(payload);
|
|
1055
|
+
}
|
|
1056
|
+
async function parseChatCustomAgentPayload(payload) {
|
|
1057
|
+
const parser = locals.get(chatCustomAgentClientDataParserKey);
|
|
1058
|
+
if (!parser || payload.trigger === "close") {
|
|
1059
|
+
return { ok: true, payload };
|
|
1060
|
+
}
|
|
1061
|
+
try {
|
|
1062
|
+
const metadata = await parser.parse(payload.metadata);
|
|
1063
|
+
return { ok: true, payload: { ...payload, metadata } };
|
|
1064
|
+
}
|
|
1065
|
+
catch (error) {
|
|
1066
|
+
return { ok: false, error };
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
async function validateChatCustomAgentPayload(payload, options = {}) {
|
|
1070
|
+
const result = await parseChatCustomAgentPayload(payload);
|
|
1071
|
+
if (!result.ok) {
|
|
1072
|
+
await reportChatCustomAgentClientDataError(payload, result.error, {
|
|
1073
|
+
writeToStream: options.writeErrorToStream ?? true,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
return result;
|
|
1077
|
+
}
|
|
1078
|
+
function validateChatCustomAgentPayloadSync(payload) {
|
|
1079
|
+
const parser = locals.get(chatCustomAgentClientDataParserKey);
|
|
1080
|
+
if (!parser || payload.trigger === "close") {
|
|
1081
|
+
return payload;
|
|
1082
|
+
}
|
|
1083
|
+
try {
|
|
1084
|
+
return { ...payload, metadata: parser.parseSync(payload.metadata) };
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
logger.warn("chat.customAgent: clientData validation failed in chat.messages.peek()", {
|
|
1088
|
+
chatId: payload.chatId,
|
|
1089
|
+
trigger: payload.trigger,
|
|
1090
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1091
|
+
});
|
|
1092
|
+
throw error;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Read one record from a route, suspending the run if nothing is there yet.
|
|
1097
|
+
*
|
|
1098
|
+
* The wake and the read are separate steps: the channel wakes the run, then the
|
|
1099
|
+
* router hands over whatever it routed. Nothing else can take the record in
|
|
1100
|
+
* between, which is what keeps the published cursors and the delivered record
|
|
1101
|
+
* in agreement.
|
|
1102
|
+
* @internal
|
|
1103
|
+
*/
|
|
1104
|
+
async function waitOnChatRoute(route, options) {
|
|
1105
|
+
const router = chatInputRouter();
|
|
1106
|
+
const session = getChatSession();
|
|
1107
|
+
return tracer.startActiveSpan(options.spanName ?? `chat.${route}.wait()`, async (span) => {
|
|
1108
|
+
const idleMs = (options.idleTimeoutInSeconds ?? 0) * 1000;
|
|
1109
|
+
if (idleMs > 0) {
|
|
1110
|
+
const warm = await router.next(route, { timeoutMs: idleMs });
|
|
1111
|
+
if (warm) {
|
|
1112
|
+
span.setAttribute("wait.resolved", "idle");
|
|
1113
|
+
return { ok: true, output: warm.data, record: warm };
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
else {
|
|
1117
|
+
const buffered = await router.next(route, { timeoutMs: 0 });
|
|
1118
|
+
if (buffered) {
|
|
1119
|
+
span.setAttribute("wait.resolved", "buffered");
|
|
1120
|
+
return { ok: true, output: buffered.data, record: buffered };
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
if (options.skipSuspend) {
|
|
1124
|
+
span.setAttribute("wait.resolved", "skipped");
|
|
1125
|
+
return {
|
|
1126
|
+
ok: false,
|
|
1127
|
+
error: new Error("Idle timeout elapsed and skipSuspend is set"),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
if (options.onSuspend)
|
|
1131
|
+
await options.onSuspend();
|
|
1132
|
+
span.setAttribute("wait.resolved", "suspended");
|
|
1133
|
+
while (true) {
|
|
1134
|
+
/**
|
|
1135
|
+
* The floor doubles as the wake cursor: the server completes the
|
|
1136
|
+
* waitpoint immediately if anything sits after this sequence, so a
|
|
1137
|
+
* floor that has advanced past an unread record parks a waitpoint
|
|
1138
|
+
* nothing will complete. Recorded on the span so a run that never woke
|
|
1139
|
+
* can be diagnosed from its trace alone.
|
|
1140
|
+
*/
|
|
1141
|
+
const wakeFrom = router.resumeFloor();
|
|
1142
|
+
span.setAttribute("wait.lastSeqNum", wakeFrom ?? -1);
|
|
1143
|
+
const wake = await session.in.awaitWake({
|
|
1144
|
+
timeout: options.timeout,
|
|
1145
|
+
lastSeqNum: wakeFrom,
|
|
1146
|
+
});
|
|
1147
|
+
if (!wake.ok) {
|
|
1148
|
+
span.recordException(wake.error);
|
|
1149
|
+
return { ok: false, error: wake.error };
|
|
1150
|
+
}
|
|
1151
|
+
const record = await router.next(route);
|
|
1152
|
+
if (!record)
|
|
1153
|
+
continue;
|
|
1154
|
+
if (options.onResume)
|
|
1155
|
+
await options.onResume();
|
|
1156
|
+
return { ok: true, output: record.data, record };
|
|
1157
|
+
}
|
|
1158
|
+
}, {
|
|
1159
|
+
attributes: {
|
|
1160
|
+
[SemanticInternalAttributes.STYLE_ICON]: "sessions",
|
|
1161
|
+
session: session.id,
|
|
1162
|
+
io: "in",
|
|
1163
|
+
route,
|
|
1164
|
+
...accessoryAttributes({
|
|
1165
|
+
items: [{ text: `${session.id}.in:${route}`, variant: "normal" }],
|
|
1166
|
+
style: "codepath",
|
|
1167
|
+
}),
|
|
1168
|
+
},
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Raw `.in` message delivery, with no client-data validation.
|
|
1173
|
+
*
|
|
1174
|
+
* Sits directly on the router's `messages` route, so a record handed to the
|
|
1175
|
+
* handler is consumed and the resume floor is free to advance past it.
|
|
1176
|
+
*/
|
|
1177
|
+
function subscribeToRawChatMessages(handler) {
|
|
1178
|
+
return chatInputRouter().on(CHAT_ROUTE_MESSAGES, (record) => {
|
|
1179
|
+
const chunk = record.data;
|
|
1180
|
+
void Promise.resolve(handler(chunk.payload)).catch(() => { });
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* Message delivery with client-data validation in front of it.
|
|
1185
|
+
*
|
|
1186
|
+
* Parses are chained so payloads are validated in wire order, which is the
|
|
1187
|
+
* point of the feature: a later message must not be validated against state a
|
|
1188
|
+
* earlier one has not established yet.
|
|
1189
|
+
*
|
|
1190
|
+
* The handler is dispatched but **not** awaited by that chain. Awaiting it
|
|
1191
|
+
* would serialise user code, so one slow or never-resolving handler would stall
|
|
1192
|
+
* delivery of every later message, and only when a schema is declared. Raw
|
|
1193
|
+
* delivery has always been fire-and-forget, so awaiting here would also make
|
|
1194
|
+
* handler concurrency differ between the validated and unvalidated paths for no
|
|
1195
|
+
* stated reason.
|
|
1196
|
+
*
|
|
1197
|
+
* `off()` detaches from the router but does not cancel work already chained:
|
|
1198
|
+
* the parse chain is a live promise chain and runs to completion on its own, so
|
|
1199
|
+
* a frame that arrives just before a turn closes is still parsed and a failure
|
|
1200
|
+
* is still reported (through the `!active` branch). Nothing therefore has to
|
|
1201
|
+
* wait on it, which is why no `drain()` hook is exposed. The one uncovered edge
|
|
1202
|
+
* is a parse still in flight when the task itself returns, where teardown can
|
|
1203
|
+
* cut the report short; give this a bounded wait at the run-end boundary rather
|
|
1204
|
+
* than an unbounded one, since the chain awaits a user-supplied schema.
|
|
1205
|
+
*/
|
|
1206
|
+
function subscribeToValidatedChatMessages(handler, options = {}) {
|
|
1207
|
+
let active = true;
|
|
1208
|
+
let delivery = Promise.resolve();
|
|
1209
|
+
const subscription = subscribeToRawChatMessages((payload) => {
|
|
1210
|
+
delivery = delivery
|
|
1211
|
+
.then(async () => {
|
|
1212
|
+
const result = await parseChatCustomAgentPayload(payload);
|
|
1213
|
+
if (!result.ok) {
|
|
1214
|
+
if (active) {
|
|
1215
|
+
// Completing the turn here could close an active response.
|
|
1216
|
+
await reportChatCustomAgentClientDataError(payload, result.error, {
|
|
1217
|
+
writeToStream: false,
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
else if (options.onInvalidAfterOff) {
|
|
1221
|
+
await options.onInvalidAfterOff(payload, result.error);
|
|
1222
|
+
}
|
|
1223
|
+
else {
|
|
1224
|
+
// The subscription was removed while parsing. Keep the failure
|
|
1225
|
+
// observable without invoking a user callback after off().
|
|
1226
|
+
await reportChatCustomAgentClientDataError(payload, result.error, {
|
|
1227
|
+
writeToStream: false,
|
|
1228
|
+
callHandler: false,
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
if (active) {
|
|
1234
|
+
void Promise.resolve(handler(result.payload, () => active)).catch(() => { });
|
|
1235
|
+
}
|
|
1236
|
+
else {
|
|
1237
|
+
void Promise.resolve(options.onAfterOff?.(result.payload)).catch(() => { });
|
|
1238
|
+
}
|
|
1239
|
+
})
|
|
1240
|
+
.catch(() => { });
|
|
1241
|
+
});
|
|
1242
|
+
return {
|
|
1243
|
+
off() {
|
|
1244
|
+
active = false;
|
|
1245
|
+
subscription.off();
|
|
1246
|
+
},
|
|
1247
|
+
};
|
|
1248
|
+
}
|
|
1249
|
+
/**
|
|
1250
|
+
* Validated delivery that does **not** consume.
|
|
1251
|
+
*
|
|
1252
|
+
* The steering path must observe rather than take: a record it does not inject
|
|
1253
|
+
* has to stay queued so a later turn answers it. Validation still runs in wire
|
|
1254
|
+
* order in front of the handler, so `clientData` is parsed before steering code
|
|
1255
|
+
* sees it, and a frame whose parse finishes after the turn closed needs no
|
|
1256
|
+
* buffer of its own, because the record was never removed from the channel.
|
|
1257
|
+
*/
|
|
1258
|
+
function observeValidatedChatMessages(
|
|
1259
|
+
/**
|
|
1260
|
+
* Receives the observed record's own `seqNum`. It is passed per record rather
|
|
1261
|
+
* than tracked outside, because the handler runs after an await: with two
|
|
1262
|
+
* frames in flight a shared slot already holds the newer sequence by the time
|
|
1263
|
+
* the older frame's parse resolves, and the steering queue would then take
|
|
1264
|
+
* the wrong record off the channel.
|
|
1265
|
+
*/
|
|
1266
|
+
handler) {
|
|
1267
|
+
let active = true;
|
|
1268
|
+
let delivery = Promise.resolve();
|
|
1269
|
+
const claims = locals.get(chatObserverClaimedSeqNumsKey) ??
|
|
1270
|
+
new Map();
|
|
1271
|
+
locals.set(chatObserverClaimedSeqNumsKey, claims);
|
|
1272
|
+
const subscription = chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
|
|
1273
|
+
const payload = record.data.payload;
|
|
1274
|
+
const seqNum = record.seqNum;
|
|
1275
|
+
/**
|
|
1276
|
+
* Claimed synchronously, before any await, so a read cannot validate the
|
|
1277
|
+
* same record. The promise lets a read wait for the outcome rather than
|
|
1278
|
+
* spin on a record it is not allowed to take.
|
|
1279
|
+
*/
|
|
1280
|
+
let release;
|
|
1281
|
+
const released = new Promise((resolve) => {
|
|
1282
|
+
release = resolve;
|
|
1283
|
+
});
|
|
1284
|
+
claims.set(record.seqNum, { released, release });
|
|
1285
|
+
const releaseClaim = () => {
|
|
1286
|
+
claims.delete(record.seqNum);
|
|
1287
|
+
release();
|
|
1288
|
+
};
|
|
1289
|
+
delivery = delivery
|
|
1290
|
+
.then(async () => {
|
|
1291
|
+
const result = await parseChatCustomAgentPayload(payload);
|
|
1292
|
+
if (!result.ok) {
|
|
1293
|
+
/**
|
|
1294
|
+
* Reported once, then taken off the channel.
|
|
1295
|
+
*
|
|
1296
|
+
* Observing does not consume, so without this the record would be
|
|
1297
|
+
* read again by the next turn and parsed a second time, firing a
|
|
1298
|
+
* user-supplied schema and reporting the same failure twice. A frame
|
|
1299
|
+
* that fails validation is never going to be answered by this run or
|
|
1300
|
+
* a later one, so removing it loses nothing that was still owed.
|
|
1301
|
+
*/
|
|
1302
|
+
const timing = locals.get(chatCustomAgentClientDataErrorTimingKey) ?? "turn-end";
|
|
1303
|
+
const writeNow = timing === "arrival" || !active;
|
|
1304
|
+
await reportChatCustomAgentClientDataError(payload, result.error, {
|
|
1305
|
+
writeToStream: writeNow,
|
|
1306
|
+
});
|
|
1307
|
+
if (!writeNow) {
|
|
1308
|
+
const deferred = locals.get(chatCustomAgentDeferredClientDataErrorsKey) ?? [];
|
|
1309
|
+
deferred.push(payload);
|
|
1310
|
+
locals.set(chatCustomAgentDeferredClientDataErrorsKey, deferred);
|
|
1311
|
+
}
|
|
1312
|
+
// Drop the record: an invalid frame is never answered, by this run or
|
|
1313
|
+
// a later one. Released so a waiting read stops waiting, finds it
|
|
1314
|
+
// gone, and goes back to waiting for the next message.
|
|
1315
|
+
chatInputRouter().take(CHAT_ROUTE_MESSAGES, record.seqNum);
|
|
1316
|
+
releaseClaim();
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
// Valid, so release. The record is still queued, and whichever comes
|
|
1320
|
+
// first, an injection or a later turn's read, now owns it.
|
|
1321
|
+
releaseClaim();
|
|
1322
|
+
void Promise.resolve(handler(result.payload, seqNum, () => active)).catch(() => { });
|
|
1323
|
+
})
|
|
1324
|
+
.catch(() => { });
|
|
1325
|
+
});
|
|
1326
|
+
return {
|
|
1327
|
+
off() {
|
|
1328
|
+
active = false;
|
|
1329
|
+
subscription.off();
|
|
1330
|
+
},
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
/**
|
|
1334
|
+
* Whether a record is currently owned by the validating observer.
|
|
1335
|
+
*
|
|
1336
|
+
* A read that pulls one puts it back and keeps waiting, so the observer's parse
|
|
1337
|
+
* stays the only one and the record is not consumed out from under it.
|
|
1338
|
+
*/
|
|
1339
|
+
function observerClaimFor(record) {
|
|
1340
|
+
return locals.get(chatObserverClaimedSeqNumsKey)?.get(record.seqNum)?.released;
|
|
1341
|
+
}
|
|
1021
1342
|
const messagesInput = {
|
|
1022
1343
|
id: "chat-messages",
|
|
1023
1344
|
on(handler) {
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
// Without this, a message arriving mid-stream was delivered twice
|
|
1030
|
-
// and ran a duplicate turn.
|
|
1031
|
-
void Promise.resolve(handler(chunk.payload)).catch(() => { });
|
|
1032
|
-
return true;
|
|
1033
|
-
}
|
|
1034
|
-
return undefined;
|
|
1035
|
-
});
|
|
1345
|
+
if (!locals.get(chatCustomAgentClientDataParserKey)) {
|
|
1346
|
+
return subscribeToRawChatMessages(handler);
|
|
1347
|
+
}
|
|
1348
|
+
const deliver = (payload) => handler(payload);
|
|
1349
|
+
return subscribeToValidatedChatMessages(deliver, { onAfterOff: deliver });
|
|
1036
1350
|
},
|
|
1037
1351
|
once(options) {
|
|
1038
|
-
const ctx = taskContext.ctx;
|
|
1039
|
-
const runId = ctx?.run.id;
|
|
1040
1352
|
return new InputStreamOncePromise((resolve, reject) => {
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1353
|
+
/**
|
|
1354
|
+
* Same skip-and-wait rule as `waitWithIdleTimeout`: a payload that fails
|
|
1355
|
+
* validation is reported and not surfaced. The timeout is a total budget
|
|
1356
|
+
* across retries, so skipping a frame cannot extend the wait forever.
|
|
1357
|
+
*/
|
|
1358
|
+
const deadline = options?.timeoutMs === undefined ? undefined : Date.now() + options.timeoutMs;
|
|
1359
|
+
const take = () => {
|
|
1360
|
+
chatInputRouter()
|
|
1361
|
+
.next(CHAT_ROUTE_MESSAGES, {
|
|
1362
|
+
timeoutMs: deadline === undefined ? undefined : Math.max(0, deadline - Date.now()),
|
|
1363
|
+
})
|
|
1364
|
+
.then(async (record) => {
|
|
1365
|
+
if (!record) {
|
|
1366
|
+
resolve({
|
|
1367
|
+
ok: false,
|
|
1368
|
+
error: new InputStreamTimeoutError("chat-messages", options?.timeoutMs ?? 0),
|
|
1369
|
+
});
|
|
1047
1370
|
return;
|
|
1048
1371
|
}
|
|
1049
|
-
|
|
1050
|
-
|
|
1372
|
+
const claim = observerClaimFor(record);
|
|
1373
|
+
if (claim) {
|
|
1374
|
+
chatInputRouter().untake(CHAT_ROUTE_MESSAGES, record);
|
|
1375
|
+
await claim;
|
|
1376
|
+
take();
|
|
1051
1377
|
return;
|
|
1052
1378
|
}
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
items: [{ text: "chat-messages", variant: "normal" }],
|
|
1068
|
-
style: "codepath",
|
|
1069
|
-
}),
|
|
1070
|
-
},
|
|
1071
|
-
})
|
|
1072
|
-
.catch(reject);
|
|
1379
|
+
const payload = record.data.payload;
|
|
1380
|
+
if (!shouldValidateChatCustomAgentPayload(payload)) {
|
|
1381
|
+
resolve({ ok: true, output: payload });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
const validated = await validateChatCustomAgentPayload(payload);
|
|
1385
|
+
if (validated.ok) {
|
|
1386
|
+
resolve({ ok: true, output: validated.payload });
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
take();
|
|
1390
|
+
}, reject);
|
|
1391
|
+
};
|
|
1392
|
+
take();
|
|
1073
1393
|
});
|
|
1074
1394
|
},
|
|
1075
1395
|
peek() {
|
|
1076
|
-
const
|
|
1077
|
-
if (
|
|
1078
|
-
return
|
|
1079
|
-
|
|
1396
|
+
const record = chatInputRouter().peek(CHAT_ROUTE_MESSAGES);
|
|
1397
|
+
if (!record)
|
|
1398
|
+
return undefined;
|
|
1399
|
+
const payload = record.data.payload;
|
|
1400
|
+
return validateChatCustomAgentPayloadSync(payload);
|
|
1401
|
+
},
|
|
1402
|
+
async hasPending() {
|
|
1403
|
+
return chatInputRouter().hasPending(CHAT_ROUTE_MESSAGES);
|
|
1404
|
+
},
|
|
1405
|
+
async next(options) {
|
|
1406
|
+
const timeoutInSeconds = options?.timeoutInSeconds;
|
|
1407
|
+
if (timeoutInSeconds !== undefined &&
|
|
1408
|
+
(!Number.isFinite(timeoutInSeconds) || timeoutInSeconds < 0)) {
|
|
1409
|
+
throw new TypeError("chat.messages.next() timeoutInSeconds must be a finite non-negative number");
|
|
1410
|
+
}
|
|
1411
|
+
// Consuming read, so it takes the same claim-and-validate path as the other
|
|
1412
|
+
// reads: a record the observer still owns is put back and awaited, and an
|
|
1413
|
+
// invalid payload is reported and skipped rather than surfaced raw.
|
|
1414
|
+
const totalMs = timeoutInSeconds === undefined ? undefined : timeoutInSeconds * 1000;
|
|
1415
|
+
/**
|
|
1416
|
+
* The caller's timeout is a total budget, not a per-attempt one. Skipping an
|
|
1417
|
+
* invalid frame must not buy another full wait, or a client sending invalid
|
|
1418
|
+
* frames faster than the timeout would keep the read blocked indefinitely
|
|
1419
|
+
* and it would never return.
|
|
1420
|
+
*/
|
|
1421
|
+
const deadline = totalMs === undefined ? undefined : Date.now() + totalMs;
|
|
1422
|
+
while (true) {
|
|
1423
|
+
const record = await chatInputRouter().next(CHAT_ROUTE_MESSAGES, {
|
|
1424
|
+
timeoutMs: deadline === undefined ? undefined : Math.max(0, deadline - Date.now()),
|
|
1425
|
+
});
|
|
1426
|
+
if (!record)
|
|
1427
|
+
return undefined;
|
|
1428
|
+
const claim = observerClaimFor(record);
|
|
1429
|
+
if (claim) {
|
|
1430
|
+
chatInputRouter().untake(CHAT_ROUTE_MESSAGES, record);
|
|
1431
|
+
await claim;
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
const chunk = record.data;
|
|
1435
|
+
if (!shouldValidateChatCustomAgentPayload(chunk.payload)) {
|
|
1436
|
+
return { id: record.id, seqNum: record.seqNum, payload: chunk.payload };
|
|
1437
|
+
}
|
|
1438
|
+
const validated = await validateChatCustomAgentPayload(chunk.payload);
|
|
1439
|
+
if (validated.ok) {
|
|
1440
|
+
return { id: record.id, seqNum: record.seqNum, payload: validated.payload };
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1080
1443
|
},
|
|
1081
1444
|
wait(options) {
|
|
1082
1445
|
return new ManualWaitpointPromise(async (resolve, reject) => {
|
|
1083
1446
|
try {
|
|
1447
|
+
// Same skip-and-wait rule as `waitWithIdleTimeout`, looping back into
|
|
1448
|
+
// the suspending wait rather than surfacing an unvalidated payload.
|
|
1084
1449
|
while (true) {
|
|
1085
|
-
const result = await
|
|
1450
|
+
const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, { timeout: options?.timeout, spanName: options?.spanName });
|
|
1086
1451
|
if (!result.ok) {
|
|
1087
|
-
resolve(result);
|
|
1452
|
+
resolve({ ok: false, error: result.error ?? new Error("Timed out") });
|
|
1453
|
+
return;
|
|
1454
|
+
}
|
|
1455
|
+
const claim = observerClaimFor(result.record);
|
|
1456
|
+
if (claim) {
|
|
1457
|
+
chatInputRouter().untake(CHAT_ROUTE_MESSAGES, result.record);
|
|
1458
|
+
await claim;
|
|
1459
|
+
continue;
|
|
1460
|
+
}
|
|
1461
|
+
const payload = result.output.payload;
|
|
1462
|
+
if (!shouldValidateChatCustomAgentPayload(payload)) {
|
|
1463
|
+
resolve({ ok: true, output: payload });
|
|
1088
1464
|
return;
|
|
1089
1465
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1466
|
+
const validated = await validateChatCustomAgentPayload(payload);
|
|
1467
|
+
if (validated.ok) {
|
|
1468
|
+
resolve({ ok: true, output: validated.payload });
|
|
1092
1469
|
return;
|
|
1093
1470
|
}
|
|
1094
|
-
// Stop chunks are handled by the stopInput facade's persistent
|
|
1095
|
-
// listener; loop back into the suspending wait.
|
|
1096
1471
|
}
|
|
1097
1472
|
}
|
|
1098
1473
|
catch (error) {
|
|
@@ -1101,102 +1476,75 @@ const messagesInput = {
|
|
|
1101
1476
|
});
|
|
1102
1477
|
},
|
|
1103
1478
|
async waitWithIdleTimeout(options) {
|
|
1479
|
+
/**
|
|
1480
|
+
* A payload that fails client-data validation is reported and skipped
|
|
1481
|
+
* rather than surfaced, so the caller never sees an unvalidated
|
|
1482
|
+
* `clientData`. The loop waits for the next message instead of returning an
|
|
1483
|
+
* error, which keeps a single bad client send from ending the turn.
|
|
1484
|
+
*/
|
|
1104
1485
|
while (true) {
|
|
1105
|
-
const result = await
|
|
1486
|
+
const result = await waitOnChatRoute(CHAT_ROUTE_MESSAGES, options);
|
|
1106
1487
|
if (!result.ok)
|
|
1107
|
-
return result;
|
|
1108
|
-
|
|
1109
|
-
|
|
1488
|
+
return { ok: false, error: result.error };
|
|
1489
|
+
const claim = observerClaimFor(result.record);
|
|
1490
|
+
if (claim) {
|
|
1491
|
+
chatInputRouter().untake(CHAT_ROUTE_MESSAGES, result.record);
|
|
1492
|
+
await claim;
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
const payload = result.output.payload;
|
|
1496
|
+
if (!shouldValidateChatCustomAgentPayload(payload)) {
|
|
1497
|
+
return { ok: true, output: payload };
|
|
1498
|
+
}
|
|
1499
|
+
const validated = await validateChatCustomAgentPayload(payload);
|
|
1500
|
+
if (validated.ok) {
|
|
1501
|
+
return { ok: true, output: validated.payload };
|
|
1110
1502
|
}
|
|
1111
|
-
// Swallow stop-kind chunks — persistent stop listener already handled
|
|
1112
|
-
// the abort; we just loop for the next message.
|
|
1113
1503
|
}
|
|
1114
1504
|
},
|
|
1115
1505
|
async send(_runId, data, options) {
|
|
1116
|
-
// The `runId` argument is kept for signature parity with
|
|
1117
|
-
// `RealtimeDefinedInputStream` but ignored — sessions are addressed
|
|
1118
|
-
// by sessionId, not runId. Callers producing messages from outside
|
|
1119
|
-
// the run should prefer the transport's `session.in.send(...)` path.
|
|
1120
1506
|
await getChatSession().in.send({ kind: "message", payload: data }, options?.requestOptions);
|
|
1121
1507
|
},
|
|
1122
1508
|
};
|
|
1123
1509
|
const stopInput = {
|
|
1124
1510
|
id: "chat-stop",
|
|
1125
1511
|
on(handler) {
|
|
1126
|
-
return
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// only meaningful to the turn it interrupts — buffering it would
|
|
1130
|
-
// let a stale stop abort a future turn.
|
|
1131
|
-
void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
|
|
1132
|
-
return true;
|
|
1133
|
-
}
|
|
1134
|
-
return undefined;
|
|
1512
|
+
return chatInputRouter().on(CHAT_ROUTE_STOP, (record) => {
|
|
1513
|
+
const chunk = record.data;
|
|
1514
|
+
void Promise.resolve(handler({ stop: true, message: chunk.message })).catch(() => { });
|
|
1135
1515
|
});
|
|
1136
1516
|
},
|
|
1137
1517
|
once(options) {
|
|
1138
|
-
const ctx = taskContext.ctx;
|
|
1139
|
-
const runId = ctx?.run.id;
|
|
1140
1518
|
return new InputStreamOncePromise((resolve, reject) => {
|
|
1141
|
-
|
|
1142
|
-
.
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
}
|
|
1149
|
-
|
|
1150
|
-
resolve({
|
|
1151
|
-
ok: true,
|
|
1152
|
-
output: { stop: true, message: result.output.message },
|
|
1153
|
-
});
|
|
1154
|
-
return;
|
|
1155
|
-
}
|
|
1519
|
+
chatInputRouter()
|
|
1520
|
+
.next(CHAT_ROUTE_STOP, { timeoutMs: options?.timeoutMs })
|
|
1521
|
+
.then((record) => {
|
|
1522
|
+
if (!record) {
|
|
1523
|
+
resolve({
|
|
1524
|
+
ok: false,
|
|
1525
|
+
error: new InputStreamTimeoutError("chat-stop", options?.timeoutMs ?? 0),
|
|
1526
|
+
});
|
|
1527
|
+
return;
|
|
1156
1528
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
[SemanticInternalAttributes.ENTITY_TYPE]: "input-stream",
|
|
1161
|
-
...(runId
|
|
1162
|
-
? {
|
|
1163
|
-
[SemanticInternalAttributes.ENTITY_ID]: `${runId}:chat-stop`,
|
|
1164
|
-
}
|
|
1165
|
-
: {}),
|
|
1166
|
-
streamId: "chat-stop",
|
|
1167
|
-
...accessoryAttributes({
|
|
1168
|
-
items: [{ text: "chat-stop", variant: "normal" }],
|
|
1169
|
-
style: "codepath",
|
|
1170
|
-
}),
|
|
1171
|
-
},
|
|
1172
|
-
})
|
|
1173
|
-
.catch(reject);
|
|
1529
|
+
const chunk = record.data;
|
|
1530
|
+
resolve({ ok: true, output: { stop: true, message: chunk.message } });
|
|
1531
|
+
}, reject);
|
|
1174
1532
|
});
|
|
1175
1533
|
},
|
|
1176
1534
|
peek() {
|
|
1177
|
-
const
|
|
1178
|
-
if (
|
|
1179
|
-
return
|
|
1180
|
-
|
|
1181
|
-
return
|
|
1535
|
+
const record = chatInputRouter().peek(CHAT_ROUTE_STOP);
|
|
1536
|
+
if (!record)
|
|
1537
|
+
return undefined;
|
|
1538
|
+
const chunk = record.data;
|
|
1539
|
+
return { stop: true, message: chunk.message };
|
|
1182
1540
|
},
|
|
1183
1541
|
wait(options) {
|
|
1184
1542
|
return new ManualWaitpointPromise(async (resolve, reject) => {
|
|
1185
1543
|
try {
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
return;
|
|
1191
|
-
}
|
|
1192
|
-
if (result.output.kind === "stop") {
|
|
1193
|
-
resolve({
|
|
1194
|
-
ok: true,
|
|
1195
|
-
output: { stop: true, message: result.output.message },
|
|
1196
|
-
});
|
|
1197
|
-
return;
|
|
1198
|
-
}
|
|
1199
|
-
}
|
|
1544
|
+
const result = await waitOnChatRoute(CHAT_ROUTE_STOP, { timeout: options?.timeout, spanName: options?.spanName });
|
|
1545
|
+
resolve(result.ok
|
|
1546
|
+
? { ok: true, output: { stop: true, message: result.output.message } }
|
|
1547
|
+
: { ok: false, error: result.error ?? new Error("Timed out") });
|
|
1200
1548
|
}
|
|
1201
1549
|
catch (error) {
|
|
1202
1550
|
reject(error);
|
|
@@ -1204,14 +1552,10 @@ const stopInput = {
|
|
|
1204
1552
|
});
|
|
1205
1553
|
},
|
|
1206
1554
|
async waitWithIdleTimeout(options) {
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
if (result.output.kind === "stop") {
|
|
1212
|
-
return { ok: true, output: { stop: true, message: result.output.message } };
|
|
1213
|
-
}
|
|
1214
|
-
}
|
|
1555
|
+
const result = await waitOnChatRoute(CHAT_ROUTE_STOP, options);
|
|
1556
|
+
return result.ok
|
|
1557
|
+
? { ok: true, output: { stop: true, message: result.output.message } }
|
|
1558
|
+
: { ok: false, error: result.error };
|
|
1215
1559
|
},
|
|
1216
1560
|
async send(_runId, data, options) {
|
|
1217
1561
|
await getChatSession().in.send({ kind: "stop", message: data?.message }, options?.requestOptions);
|
|
@@ -1225,17 +1569,8 @@ const stopInput = {
|
|
|
1225
1569
|
*/
|
|
1226
1570
|
const handoverInput = {
|
|
1227
1571
|
async waitWithIdleTimeout(options) {
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
if (!result.ok)
|
|
1231
|
-
return result;
|
|
1232
|
-
if (result.output.kind === "handover" || result.output.kind === "handover-skip") {
|
|
1233
|
-
return { ok: true, output: result.output };
|
|
1234
|
-
}
|
|
1235
|
-
// Other kinds (message, stop) are not expected during handover-prepare.
|
|
1236
|
-
// Loop back; the message and stop facades have their own listeners
|
|
1237
|
-
// running so signals on those kinds aren't lost.
|
|
1238
|
-
}
|
|
1572
|
+
const result = await waitOnChatRoute(CHAT_ROUTE_HANDOVER, options);
|
|
1573
|
+
return result.ok ? { ok: true, output: result.output } : result;
|
|
1239
1574
|
},
|
|
1240
1575
|
};
|
|
1241
1576
|
/**
|
|
@@ -1251,22 +1586,229 @@ const handoverInput = {
|
|
|
1251
1586
|
* For the common case prefer `accumulator.consumeHandover()`, which also seeds
|
|
1252
1587
|
* `payload.headStartMessages` and applies the partial for you.
|
|
1253
1588
|
*
|
|
1254
|
-
*
|
|
1255
|
-
*
|
|
1256
|
-
* handover signal.
|
|
1589
|
+
* Safe to call at any point in turn 0: the handover signal has its own route,
|
|
1590
|
+
* so a message facade waiting at the same time cannot take it.
|
|
1257
1591
|
*/
|
|
1258
1592
|
async function waitForHandover(options) {
|
|
1259
1593
|
if (options.payload.trigger !== "handover-prepare")
|
|
1260
1594
|
return null;
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1595
|
+
try {
|
|
1596
|
+
const result = await handoverInput.waitWithIdleTimeout({
|
|
1597
|
+
idleTimeoutInSeconds: options.idleTimeoutInSeconds ?? options.payload.idleTimeoutInSeconds ?? 60,
|
|
1598
|
+
timeout: options.timeout,
|
|
1599
|
+
spanName: options.spanName ?? "waiting for handover signal",
|
|
1600
|
+
});
|
|
1601
|
+
// Non-ok = idle timeout or the warm handler crashed without signaling.
|
|
1602
|
+
if (!result.ok)
|
|
1603
|
+
return null;
|
|
1604
|
+
return result.output;
|
|
1605
|
+
}
|
|
1606
|
+
finally {
|
|
1607
|
+
chatInputRouter().clearRoute(CHAT_ROUTE_HANDOVER);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Everything `session.in` carries, and what happens to each kind.
|
|
1612
|
+
*
|
|
1613
|
+
* A route's two properties are what make the resume protocol derivable rather
|
|
1614
|
+
* than hand-maintained. `messages` is replayable because losing a user message
|
|
1615
|
+
* is data loss. `stop` is neither queued nor replayable: it only means anything
|
|
1616
|
+
* to the turn that is live when it lands, and a replayed one would abort
|
|
1617
|
+
* whichever turn happened to be running. `handover` is queued but not
|
|
1618
|
+
* replayable, because it can arrive before its consumer is ready yet is
|
|
1619
|
+
* meaningless to any later boot.
|
|
1620
|
+
*
|
|
1621
|
+
* A kind absent from this table has no consumer, so the router discards it
|
|
1622
|
+
* instead of letting it park at the head of a queue.
|
|
1623
|
+
* @internal
|
|
1624
|
+
*/
|
|
1625
|
+
const CHAT_INPUT_ROUTES = {
|
|
1626
|
+
kindOf: (data) => data?.kind,
|
|
1627
|
+
routes: [
|
|
1628
|
+
{ name: "messages", delivery: "queue", replayable: true, kinds: ["message"] },
|
|
1629
|
+
{ name: "stop", delivery: "at-arrival", replayable: false, kinds: ["stop"] },
|
|
1630
|
+
{
|
|
1631
|
+
name: "handover",
|
|
1632
|
+
delivery: "queue",
|
|
1633
|
+
replayable: false,
|
|
1634
|
+
kinds: ["handover", "handover-skip"],
|
|
1635
|
+
},
|
|
1636
|
+
],
|
|
1637
|
+
};
|
|
1638
|
+
const CHAT_ROUTE_MESSAGES = "messages";
|
|
1639
|
+
const CHAT_ROUTE_STOP = "stop";
|
|
1640
|
+
const CHAT_ROUTE_HANDOVER = "handover";
|
|
1641
|
+
/**
|
|
1642
|
+
* The `.in` router for the run this worker is currently serving.
|
|
1643
|
+
*
|
|
1644
|
+
* One slot rather than a map, because the facades have to reach the same router
|
|
1645
|
+
* the boot attached without depending on a locals scope being active. Tagged
|
|
1646
|
+
* with the run as well as the chat: a warm process is reused across runs and the
|
|
1647
|
+
* executor tears the channel subscription down at the end of each one, so
|
|
1648
|
+
* reusing a router across runs would leave the new run with no input at all. A
|
|
1649
|
+
* nested `chat.createSession` within the same run still shares it.
|
|
1650
|
+
* @internal
|
|
1651
|
+
*/
|
|
1652
|
+
let currentChatInputRouter;
|
|
1653
|
+
/**
|
|
1654
|
+
* Both cursors from the latest `turn-complete` on `.out`, in one scan.
|
|
1655
|
+
*
|
|
1656
|
+
* Absent for a chat whose turns predate the headers, in which case the router
|
|
1657
|
+
* starts from the beginning of the channel and nothing is treated as replayed.
|
|
1658
|
+
* @internal
|
|
1659
|
+
*/
|
|
1660
|
+
async function findLatestSessionInCheckpoint(chatId) {
|
|
1661
|
+
const apiClient = apiClientManager.clientOrThrow();
|
|
1662
|
+
const response = await apiClient.readSessionStreamRecords(chatId, "out");
|
|
1663
|
+
const checkpoint = {};
|
|
1664
|
+
for (const record of response.records) {
|
|
1665
|
+
if (controlSubtype(record.headers) !== TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE)
|
|
1666
|
+
continue;
|
|
1667
|
+
const resumeFrom = Number.parseInt(headerValue(record.headers, SESSION_IN_EVENT_ID_HEADER) ?? "", 10);
|
|
1668
|
+
if (Number.isFinite(resumeFrom))
|
|
1669
|
+
checkpoint.resumeFrom = resumeFrom;
|
|
1670
|
+
const appliedThrough = Number.parseInt(headerValue(record.headers, SESSION_IN_CONSUMED_ID_HEADER) ?? "", 10);
|
|
1671
|
+
if (Number.isFinite(appliedThrough))
|
|
1672
|
+
checkpoint.appliedThrough = appliedThrough;
|
|
1673
|
+
}
|
|
1674
|
+
return checkpoint;
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
* The highest `.in` sequence already on the channel at boot, above the floor.
|
|
1678
|
+
*
|
|
1679
|
+
* Everything already on the channel when a run boots is, by definition, not
|
|
1680
|
+
* arriving live on this run, so the channel's own tail is the end of this run's
|
|
1681
|
+
* replay window.
|
|
1682
|
+
*
|
|
1683
|
+
* The turn boundary's value is not enough on its own. A boundary is written when
|
|
1684
|
+
* a turn ends, so a control record that arrived after the last boundary is not
|
|
1685
|
+
* covered by it, and a boundary written by an older SDK does not carry one at
|
|
1686
|
+
* all. Both cases leave an already-applied record looking live.
|
|
1687
|
+
*
|
|
1688
|
+
* Bounded by `afterEventId` when a floor is known, which is the common case, so
|
|
1689
|
+
* the read covers the replay window rather than the conversation. An absent
|
|
1690
|
+
* floor means no boundary has committed a cursor yet, and the channel is still
|
|
1691
|
+
* short.
|
|
1692
|
+
*
|
|
1693
|
+
* Returns `undefined` if the read fails; the caller then falls back to the
|
|
1694
|
+
* floor, which is the previous release's behaviour.
|
|
1695
|
+
* @internal
|
|
1696
|
+
*/
|
|
1697
|
+
async function findSessionInReplayWindowEnd(chatId, afterSeqNum) {
|
|
1698
|
+
try {
|
|
1699
|
+
const apiClient = apiClientManager.clientOrThrow();
|
|
1700
|
+
const response = await apiClient.readSessionStreamRecords(chatId, "in", {
|
|
1701
|
+
...(afterSeqNum === undefined ? {} : { afterEventId: String(afterSeqNum) }),
|
|
1702
|
+
});
|
|
1703
|
+
let highest;
|
|
1704
|
+
for (const record of response.records) {
|
|
1705
|
+
const seqNum = typeof record.seqNum === "number" ? record.seqNum : Number.NaN;
|
|
1706
|
+
if (Number.isFinite(seqNum) && (highest === undefined || seqNum > highest)) {
|
|
1707
|
+
highest = seqNum;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
return highest;
|
|
1711
|
+
}
|
|
1712
|
+
catch {
|
|
1713
|
+
return undefined;
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Attach the `.in` router for this run.
|
|
1718
|
+
*
|
|
1719
|
+
* Reads the checkpoint and subscribes in one call, so there is no window in
|
|
1720
|
+
* which a listener is attached before the resume cursor is seeded. Attaching
|
|
1721
|
+
* first would open the tail at sequence 0 and replay every record the previous
|
|
1722
|
+
* run already answered, which is a mistake the previous shape of this code made
|
|
1723
|
+
* possible and this shape does not.
|
|
1724
|
+
*
|
|
1725
|
+
* The router consumes every record at dispatch, so the channel's own buffer
|
|
1726
|
+
* stays empty for chat and its cursor bookkeeping never engages. Delivery,
|
|
1727
|
+
* ordering and the published cursors are the router's, entirely.
|
|
1728
|
+
* @internal
|
|
1729
|
+
*/
|
|
1730
|
+
async function installChatInputRouter(chatId, options) {
|
|
1731
|
+
const entry = chatInputRouterEntry(chatId);
|
|
1732
|
+
if (entry.attached)
|
|
1733
|
+
return entry.router;
|
|
1734
|
+
let checkpoint = {};
|
|
1735
|
+
try {
|
|
1736
|
+
checkpoint = await findLatestSessionInCheckpoint(chatId);
|
|
1737
|
+
}
|
|
1738
|
+
catch (error) {
|
|
1739
|
+
logger.warn("chat session: session.in resume cursor lookup failed; old messages may replay", {
|
|
1740
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1741
|
+
});
|
|
1742
|
+
}
|
|
1743
|
+
if (checkpoint.resumeFrom === undefined && options?.fallbackResumeFrom !== undefined) {
|
|
1744
|
+
checkpoint.resumeFrom = options.fallbackResumeFrom;
|
|
1745
|
+
}
|
|
1746
|
+
// Only a resuming run has a replay window. On a first boot nothing has been
|
|
1747
|
+
// applied by anyone, so treating what is already on the channel as replayed
|
|
1748
|
+
// would discard a signal that arrived before the agent got here, which is
|
|
1749
|
+
// exactly how a head-start handover reaches a cold run.
|
|
1750
|
+
const resuming = checkpoint.resumeFrom !== undefined || options?.resuming === true;
|
|
1751
|
+
if (resuming) {
|
|
1752
|
+
const replayWindowEnd = await findSessionInReplayWindowEnd(chatId, checkpoint.resumeFrom);
|
|
1753
|
+
if (replayWindowEnd !== undefined) {
|
|
1754
|
+
checkpoint.appliedThrough = Math.max(checkpoint.appliedThrough ?? replayWindowEnd, replayWindowEnd);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
const router = entry.router;
|
|
1758
|
+
router.restore(checkpoint);
|
|
1759
|
+
const floor = router.resumeFrom();
|
|
1760
|
+
if (floor !== undefined) {
|
|
1761
|
+
sessionStreams.setLastSeqNum(chatId, "in", floor);
|
|
1762
|
+
sessionStreams.setLastDispatchedSeqNum(chatId, "in", floor);
|
|
1763
|
+
}
|
|
1764
|
+
sessionStreams.onRecord(chatId, "in", (record) => {
|
|
1765
|
+
router.ingest(record);
|
|
1766
|
+
return true;
|
|
1265
1767
|
});
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1768
|
+
entry.attached = true;
|
|
1769
|
+
return router;
|
|
1770
|
+
}
|
|
1771
|
+
function chatInputRouterEntry(chatId) {
|
|
1772
|
+
const runId = taskContext.ctx?.run.id;
|
|
1773
|
+
if (currentChatInputRouter?.chatId === chatId && currentChatInputRouter.runId === runId) {
|
|
1774
|
+
return currentChatInputRouter;
|
|
1775
|
+
}
|
|
1776
|
+
currentChatInputRouter = {
|
|
1777
|
+
chatId,
|
|
1778
|
+
runId,
|
|
1779
|
+
router: new SessionChannelRouter(CHAT_INPUT_ROUTES, {
|
|
1780
|
+
onDrop: (record, reason) => {
|
|
1781
|
+
if (reason === "unroutable" || reason === "malformed") {
|
|
1782
|
+
logger.warn("chat: discarded a session.in record nothing on this worker can consume", {
|
|
1783
|
+
reason,
|
|
1784
|
+
seqNum: record.seqNum,
|
|
1785
|
+
});
|
|
1786
|
+
}
|
|
1787
|
+
},
|
|
1788
|
+
}),
|
|
1789
|
+
attached: false,
|
|
1790
|
+
};
|
|
1791
|
+
return currentChatInputRouter;
|
|
1792
|
+
}
|
|
1793
|
+
/** Drop the router so the next boot attaches a fresh one. @internal */
|
|
1794
|
+
export function __resetChatInputRouterForTests() {
|
|
1795
|
+
currentChatInputRouter = undefined;
|
|
1796
|
+
}
|
|
1797
|
+
/** The cursors this run would publish on its next turn boundary. @internal */
|
|
1798
|
+
export function __chatInputCheckpointForTests() {
|
|
1799
|
+
return currentChatInputRouter?.router.checkpoint() ?? {};
|
|
1800
|
+
}
|
|
1801
|
+
/** Test-only entry point for the turn-boundary cursor scan. @internal */
|
|
1802
|
+
export async function __findLatestSessionInCheckpointForTests(chatId) {
|
|
1803
|
+
return findLatestSessionInCheckpoint(chatId);
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* This chat's router. Created on first use so a facade reached before the
|
|
1807
|
+
* install still shares the one the install will attach.
|
|
1808
|
+
* @internal
|
|
1809
|
+
*/
|
|
1810
|
+
function chatInputRouter() {
|
|
1811
|
+
return chatInputRouterEntry(getChatSession().id).router;
|
|
1270
1812
|
}
|
|
1271
1813
|
/**
|
|
1272
1814
|
* Per-turn deferred promises. Registered via `chat.defer()`, awaited
|
|
@@ -1862,6 +2404,30 @@ const chatCompactionStateKey = locals.create("chat.compaction");
|
|
|
1862
2404
|
const chatOnCompactedKey = locals.create("chat.onCompacted");
|
|
1863
2405
|
/** @internal Full task `ctx` for the active `chat.agent` run (for hooks invoked from nested compaction). */
|
|
1864
2406
|
const chatAgentRunContextKey = locals.create("chat.agentRunContext");
|
|
2407
|
+
/** @internal When a mid-turn validation failure writes its terminal error. */
|
|
2408
|
+
const chatCustomAgentClientDataErrorTimingKey = locals.create("chat.customAgent.clientDataErrorTiming");
|
|
2409
|
+
/**
|
|
2410
|
+
* @internal Client-data errors held back until the open turn closes.
|
|
2411
|
+
*
|
|
2412
|
+
* The default timing exists so a bad send cannot truncate an answer already
|
|
2413
|
+
* being read, which means the write has to happen later rather than not at all:
|
|
2414
|
+
* the callback and the task log are server-side, so dropping the stream write
|
|
2415
|
+
* would leave the client with no signal that its frame was rejected.
|
|
2416
|
+
*/
|
|
2417
|
+
const chatCustomAgentDeferredClientDataErrorsKey = locals.create("chat.customAgent.deferredClientDataErrors");
|
|
2418
|
+
/**
|
|
2419
|
+
* @internal Sequences the validating observer has claimed.
|
|
2420
|
+
*
|
|
2421
|
+
* The observer watches without consuming, so a record it is still validating is
|
|
2422
|
+
* also visible to a turn's read. Claiming makes ownership single: a read that
|
|
2423
|
+
* pulls a claimed record puts it back and keeps waiting, so exactly one of them
|
|
2424
|
+
* validates it and the schema runs once per frame.
|
|
2425
|
+
*/
|
|
2426
|
+
const chatObserverClaimedSeqNumsKey = locals.create("chat.customAgent.observerClaims");
|
|
2427
|
+
/** @internal Marks the root run created by `chat.customAgent()`. */
|
|
2428
|
+
const chatCustomAgentRunKey = locals.create("chat.customAgentRun");
|
|
2429
|
+
/** @internal Number of active `chat.createSession()` iterators in this run. */
|
|
2430
|
+
const chatActiveSessionIteratorsKey = locals.create("chat.createSession.activeIterators");
|
|
1865
2431
|
const chatPrepareMessagesKey = locals.create("chat.prepareMessages");
|
|
1866
2432
|
/**
|
|
1867
2433
|
* @internal The raw `tools` option from `chat.agent({ tools })`, either a
|
|
@@ -2267,7 +2833,14 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2267
2833
|
return [];
|
|
2268
2834
|
const ctx = locals.get(chatTurnContextKey);
|
|
2269
2835
|
const stepNumber = steps.length - 1;
|
|
2270
|
-
|
|
2836
|
+
/**
|
|
2837
|
+
* Snapshot, because `shouldInject` and `prepare` can await. A record arriving
|
|
2838
|
+
* during either is not in this batch, so it must not be consumed by it: the
|
|
2839
|
+
* callbacks never saw it and could not have injected it, and taking it would
|
|
2840
|
+
* lose a message that should become a later turn instead.
|
|
2841
|
+
*/
|
|
2842
|
+
const batch = [...queue];
|
|
2843
|
+
const uiMessages = batch.map((e) => e.uiMessage);
|
|
2271
2844
|
const batchEvent = {
|
|
2272
2845
|
messages: uiMessages,
|
|
2273
2846
|
modelMessages: messages,
|
|
@@ -2281,22 +2854,79 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2281
2854
|
const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false;
|
|
2282
2855
|
if (!shouldInject)
|
|
2283
2856
|
return [];
|
|
2284
|
-
|
|
2285
|
-
const messageTexts = uiMessages.map((m) => (m.parts ?? [])
|
|
2857
|
+
const textOfUIMessage = (m) => (m.parts ?? [])
|
|
2286
2858
|
.filter((p) => p.type === "text")
|
|
2287
2859
|
.map((p) => p.text)
|
|
2288
|
-
.join("") || ""
|
|
2860
|
+
.join("") || "";
|
|
2861
|
+
// Span attributes describe the offered batch; the chunk and callback below
|
|
2862
|
+
// describe what was actually claimed and injected.
|
|
2863
|
+
const messageTexts = uiMessages.map(textOfUIMessage);
|
|
2289
2864
|
const _previewText = messageTexts.length === 1 ? messageTexts[0].slice(0, 80) : `${queue.length} messages`;
|
|
2290
2865
|
return tracer.startActiveSpan("pending message injected", async () => {
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2866
|
+
/**
|
|
2867
|
+
* Claim before transforming, and inject only what was claimed.
|
|
2868
|
+
*
|
|
2869
|
+
* Injection is the point of consumption: the records were only observed
|
|
2870
|
+
* on arrival, so they are still queued on the router and still holding
|
|
2871
|
+
* the resume floor, and taking them here is what stops the same message
|
|
2872
|
+
* also being answered as a later turn. A declined batch never reaches
|
|
2873
|
+
* this line, so its records stay queued and become later turns, which is
|
|
2874
|
+
* what "messages queue for the next turn" means.
|
|
2875
|
+
*
|
|
2876
|
+
* A failed claim means something else already consumed that record while
|
|
2877
|
+
* `shouldInject` was awaiting, so it is already being answered as a turn
|
|
2878
|
+
* of its own. Injecting it as well would process the same message twice.
|
|
2879
|
+
* An entry with no `seqNum` did not come from a channel record (the
|
|
2880
|
+
* accumulator's own queue), so there is nothing to claim and it is kept.
|
|
2881
|
+
*/
|
|
2882
|
+
const router = chatInputRouter();
|
|
2883
|
+
const claimed = [];
|
|
2884
|
+
const takenRecords = [];
|
|
2885
|
+
for (const entry of batch) {
|
|
2886
|
+
if (entry.seqNum === undefined) {
|
|
2887
|
+
claimed.push(entry);
|
|
2888
|
+
continue;
|
|
2889
|
+
}
|
|
2890
|
+
const record = router.take(CHAT_ROUTE_MESSAGES, entry.seqNum);
|
|
2891
|
+
if (!record)
|
|
2892
|
+
continue;
|
|
2893
|
+
takenRecords.push(record);
|
|
2894
|
+
claimed.push(entry);
|
|
2895
|
+
}
|
|
2896
|
+
for (const entry of claimed) {
|
|
2897
|
+
const at = queue.indexOf(entry);
|
|
2898
|
+
if (at !== -1)
|
|
2899
|
+
queue.splice(at, 1);
|
|
2900
|
+
}
|
|
2901
|
+
if (claimed.length === 0)
|
|
2902
|
+
return [];
|
|
2903
|
+
/**
|
|
2904
|
+
* Give the claim back if the transform fails. `prepare` is caller code and
|
|
2905
|
+
* can throw; the records have already left the router by this point, so
|
|
2906
|
+
* without returning them a failed transform would consume the messages and
|
|
2907
|
+
* they would never be answered at all.
|
|
2908
|
+
*/
|
|
2909
|
+
const releaseClaim = () => {
|
|
2910
|
+
for (const record of takenRecords)
|
|
2911
|
+
router.untake(CHAT_ROUTE_MESSAGES, record);
|
|
2912
|
+
for (const entry of claimed)
|
|
2913
|
+
if (!queue.includes(entry))
|
|
2914
|
+
queue.push(entry);
|
|
2915
|
+
};
|
|
2916
|
+
const claimedUIMessages = claimed.map((e) => e.uiMessage);
|
|
2917
|
+
let injected;
|
|
2918
|
+
try {
|
|
2919
|
+
injected = config.prepare
|
|
2920
|
+
? await config.prepare({ ...batchEvent, messages: claimedUIMessages })
|
|
2921
|
+
: claimed.flatMap((e) => e.modelMessages);
|
|
2922
|
+
}
|
|
2923
|
+
catch (err) {
|
|
2924
|
+
releaseClaim();
|
|
2925
|
+
throw err;
|
|
2926
|
+
}
|
|
2297
2927
|
const injectedIds = locals.get(chatInjectedMessageIdsKey);
|
|
2298
2928
|
if (injectedIds) {
|
|
2299
|
-
for (const m of
|
|
2929
|
+
for (const m of claimedUIMessages)
|
|
2300
2930
|
injectedIds.add(m.id);
|
|
2301
2931
|
}
|
|
2302
2932
|
// Write injection confirmation chunk to the stream so the frontend
|
|
@@ -2310,10 +2940,10 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2310
2940
|
type: PENDING_MESSAGE_INJECTED_TYPE,
|
|
2311
2941
|
id: generateMessageId(),
|
|
2312
2942
|
data: {
|
|
2313
|
-
messageIds:
|
|
2314
|
-
messages:
|
|
2943
|
+
messageIds: claimedUIMessages.map((m) => m.id),
|
|
2944
|
+
messages: claimedUIMessages.map((m) => ({
|
|
2315
2945
|
id: m.id,
|
|
2316
|
-
text:
|
|
2946
|
+
text: textOfUIMessage(m),
|
|
2317
2947
|
})),
|
|
2318
2948
|
},
|
|
2319
2949
|
});
|
|
@@ -2329,7 +2959,7 @@ async function drainSteeringQueue(config, messages, steps, queueOverride) {
|
|
|
2329
2959
|
if (config.onInjected && injected.length > 0) {
|
|
2330
2960
|
try {
|
|
2331
2961
|
await config.onInjected({
|
|
2332
|
-
messages:
|
|
2962
|
+
messages: claimedUIMessages,
|
|
2333
2963
|
injectedModelMessages: injected,
|
|
2334
2964
|
chatId: ctx?.chatId ?? "",
|
|
2335
2965
|
turn: ctx?.turn ?? 0,
|
|
@@ -2763,7 +3393,11 @@ async function pipeChat(source, options) {
|
|
|
2763
3393
|
await waitUntilComplete();
|
|
2764
3394
|
}
|
|
2765
3395
|
function chatCustomAgent(options) {
|
|
2766
|
-
const { clientDataSchema, run: userRun, ...restOptions } = options;
|
|
3396
|
+
const { clientDataSchema, onClientDataValidationError, clientDataReportErrorAt, run: userRun, ...restOptions } = options;
|
|
3397
|
+
const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined;
|
|
3398
|
+
const parseClientDataSync = clientDataSchema
|
|
3399
|
+
? getChatCustomAgentSyncSchemaParseFn(clientDataSchema)
|
|
3400
|
+
: undefined;
|
|
2767
3401
|
const task = createTask({
|
|
2768
3402
|
...restOptions,
|
|
2769
3403
|
triggerSource: "agent",
|
|
@@ -2782,6 +3416,17 @@ function chatCustomAgent(options) {
|
|
|
2782
3416
|
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
|
|
2783
3417
|
locals.set(chatExternalIdKey, payload.chatId);
|
|
2784
3418
|
locals.set(chatAgentRunContextKey, runOptions.ctx);
|
|
3419
|
+
locals.set(chatCustomAgentRunKey, true);
|
|
3420
|
+
if (parseClientData && parseClientDataSync) {
|
|
3421
|
+
locals.set(chatCustomAgentClientDataParserKey, {
|
|
3422
|
+
parse: parseClientData,
|
|
3423
|
+
parseSync: parseClientDataSync,
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
if (onClientDataValidationError) {
|
|
3427
|
+
locals.set(chatCustomAgentClientDataErrorHandlerKey, onClientDataValidationError);
|
|
3428
|
+
}
|
|
3429
|
+
locals.set(chatCustomAgentClientDataErrorTimingKey, clientDataReportErrorAt ?? "turn-end");
|
|
2785
3430
|
// Initialize the turn-complete trim slot so `chat.writeTurnComplete`
|
|
2786
3431
|
// trims `session.out` back to the previous turn boundary. Without
|
|
2787
3432
|
// this the slot is undefined and the trim never runs, so `.out`
|
|
@@ -2790,11 +3435,65 @@ function chatCustomAgent(options) {
|
|
|
2790
3435
|
markChatAgentRunForStreamsWarning();
|
|
2791
3436
|
taskContext.setConversationId(payload.chatId);
|
|
2792
3437
|
stampConversationIdOnActiveSpan(payload.chatId);
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
3438
|
+
await installChatInputRouter(payload.chatId, {
|
|
3439
|
+
resuming: Boolean(payload.continuation),
|
|
3440
|
+
});
|
|
3441
|
+
// Keep the schema-free path identical to the original custom-agent
|
|
3442
|
+
// wrapper, including when userRun starts executing.
|
|
3443
|
+
if (!parseClientData) {
|
|
3444
|
+
return userRun(payload, runOptions);
|
|
3445
|
+
}
|
|
3446
|
+
const isHandoverBoot = payload.trigger === "handover-prepare";
|
|
3447
|
+
const isMessagelessBoot = payload.trigger === "preload" ||
|
|
3448
|
+
(payload.continuation === true &&
|
|
3449
|
+
payload.message === undefined &&
|
|
3450
|
+
payload.trigger !== "action" &&
|
|
3451
|
+
payload.trigger !== "regenerate-message" &&
|
|
3452
|
+
!isHandoverBoot);
|
|
3453
|
+
const validated = await validateChatCustomAgentPayload(payload, {
|
|
3454
|
+
// Preload and continuation boots do not represent a submitted turn,
|
|
3455
|
+
// so there is no sender waiting for a terminal frame. Handover errors
|
|
3456
|
+
// must be written after the warm response flushes and signals below.
|
|
3457
|
+
writeErrorToStream: !isMessagelessBoot && !isHandoverBoot,
|
|
3458
|
+
});
|
|
3459
|
+
if (validated.ok) {
|
|
3460
|
+
return userRun(validated.payload, runOptions);
|
|
3461
|
+
}
|
|
3462
|
+
if (isHandoverBoot) {
|
|
3463
|
+
const signal = await waitForHandover({
|
|
3464
|
+
payload,
|
|
3465
|
+
timeout: "1h",
|
|
3466
|
+
spanName: "waiting for handover signal (invalid clientData)",
|
|
3467
|
+
});
|
|
3468
|
+
if (!signal || signal.kind === "handover-skip") {
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
// The head-start writer flushes before sending this signal. Writing
|
|
3472
|
+
// the terminal error now preserves stream order and closes the stitch.
|
|
3473
|
+
await writeChatCustomAgentClientDataErrorToStream(payload);
|
|
3474
|
+
return;
|
|
3475
|
+
}
|
|
3476
|
+
// The Session base payload is sticky across continuation runs. If it is
|
|
3477
|
+
// invalid, returning here would boot the same bad metadata again on the
|
|
3478
|
+
// next message. Stay attached and wait for a valid wire frame instead.
|
|
3479
|
+
const next = await messagesInput.waitWithIdleTimeout({
|
|
3480
|
+
idleTimeoutInSeconds: payload.idleTimeoutInSeconds ?? 30,
|
|
3481
|
+
timeout: "1h",
|
|
3482
|
+
spanName: "waiting for valid clientData",
|
|
3483
|
+
});
|
|
3484
|
+
if (!next.ok || next.output.trigger === "close") {
|
|
3485
|
+
return;
|
|
3486
|
+
}
|
|
3487
|
+
// Normal input frames omit run-level boot context. Carry it forward so
|
|
3488
|
+
// a continuation still tells the custom loop to restore prior state.
|
|
3489
|
+
const recoveredPayload = {
|
|
3490
|
+
...next.output,
|
|
3491
|
+
continuation: next.output.continuation ?? payload.continuation,
|
|
3492
|
+
previousRunId: next.output.previousRunId ?? payload.previousRunId,
|
|
3493
|
+
sessionId: next.output.sessionId ?? payload.sessionId,
|
|
3494
|
+
idleTimeoutInSeconds: next.output.idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds,
|
|
3495
|
+
};
|
|
3496
|
+
return userRun(recoveredPayload, runOptions);
|
|
2798
3497
|
},
|
|
2799
3498
|
});
|
|
2800
3499
|
// Register clientDataSchema so the CLI converts it to JSONSchema
|
|
@@ -2824,6 +3523,7 @@ function chatAgent(options) {
|
|
|
2824
3523
|
agentConfig: { type: "ai-sdk-chat" },
|
|
2825
3524
|
run: async (payload, { signal: runSignal, ctx }) => {
|
|
2826
3525
|
locals.set(chatAgentRunContextKey, ctx);
|
|
3526
|
+
locals.set(chatCustomAgentRunKey, false);
|
|
2827
3527
|
// On AI SDK 7, register the `@ai-sdk/otel` integration (once per process)
|
|
2828
3528
|
// so `experimental_telemetry` spans flow into the run trace. Awaited here
|
|
2829
3529
|
// at run boot — before any `streamText` — and a no-op on v5/v6 or when the
|
|
@@ -2916,11 +3616,6 @@ function chatAgent(options) {
|
|
|
2916
3616
|
// default, `inFlightUsers`). The turn-loop checks this queue ahead of
|
|
2917
3617
|
// `messagesInput.waitWithIdleTimeout` so recovered turns fire first.
|
|
2918
3618
|
const bootInjectedQueue = [];
|
|
2919
|
-
// Messages consumed by a turn's `messagesInput.on` handler, dispatched
|
|
2920
|
-
// one per turn by the end-of-turn pickup. Loop-level on purpose:
|
|
2921
|
-
// consuming a record advances the committed `.in` cursor, so entries
|
|
2922
|
-
// dropped with a turn-local buffer are lost permanently.
|
|
2923
|
-
const pendingWireMessages = [];
|
|
2924
3619
|
const couldHavePriorState = payload.continuation === true || ctx.attempt.number > 1;
|
|
2925
3620
|
// `.in` resume cursor, computed at most once per boot. The boot
|
|
2926
3621
|
// block below resolves it (snapshot field or records scan) and the
|
|
@@ -3043,49 +3738,16 @@ function chatAgent(options) {
|
|
|
3043
3738
|
},
|
|
3044
3739
|
});
|
|
3045
3740
|
}
|
|
3046
|
-
// ── session.in
|
|
3047
|
-
//
|
|
3048
|
-
// A fresh worker subscribes to `session.in` from seq 0 and would
|
|
3049
|
-
// re-deliver every record ever appended — including user messages
|
|
3050
|
-
// from turns already completed on a prior run. Without a cursor,
|
|
3051
|
-
// the loop would re-process them as fresh turns and the slim-wire
|
|
3052
|
-
// merge would replace-by-id against snapshot-restored copies,
|
|
3053
|
-
// yielding no-op replaces while the customer's actual new message
|
|
3054
|
-
// waits in the queue.
|
|
3741
|
+
// ── session.in router ──────────────────────────────────────────
|
|
3055
3742
|
//
|
|
3056
|
-
//
|
|
3057
|
-
//
|
|
3058
|
-
//
|
|
3059
|
-
//
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
// Applies in three cases (any of which means `.in` has records
|
|
3065
|
-
// belonging to completed turns the new run should skip):
|
|
3066
|
-
// - OOM retry (`ctx.attempt.number > 1`)
|
|
3067
|
-
// - Continuation run (`payload.continuation === true`) — prior run
|
|
3068
|
-
// crashed / was canceled / requested upgrade
|
|
3069
|
-
// - Snapshot exists at all (catches edge cases where the wire
|
|
3070
|
-
// didn't set `continuation` but a snapshot indicates prior turns)
|
|
3071
|
-
const needsResumeCursor = ctx.attempt.number > 1 || payload.continuation === true || bootSnapshot !== undefined;
|
|
3072
|
-
if (needsResumeCursor) {
|
|
3073
|
-
try {
|
|
3074
|
-
// Reuse the cursor the boot block already resolved (snapshot
|
|
3075
|
-
// field or records scan) — only scan here when the boot block
|
|
3076
|
-
// was skipped (hydrateMessages, or snapshot-only signals).
|
|
3077
|
-
const cursor = bootInCursorResolved
|
|
3078
|
-
? bootInCursor
|
|
3079
|
-
: await findLatestSessionInCursor(payload.chatId);
|
|
3080
|
-
if (cursor !== undefined) {
|
|
3081
|
-
sessionStreams.setLastSeqNum(payload.chatId, "in", cursor);
|
|
3082
|
-
sessionStreams.setLastDispatchedSeqNum(payload.chatId, "in", cursor);
|
|
3083
|
-
}
|
|
3084
|
-
}
|
|
3085
|
-
catch (error) {
|
|
3086
|
-
logger.warn("chat.agent: session.in resume cursor lookup failed; old messages may replay", { error: error instanceof Error ? error.message : String(error) });
|
|
3087
|
-
}
|
|
3088
|
-
}
|
|
3743
|
+
// Reads the turn boundary and subscribes in one call. `bootInCursor` is
|
|
3744
|
+
// only a fallback: the boot block above may already have resolved a
|
|
3745
|
+
// cursor from the snapshot, which is used when the boundary itself
|
|
3746
|
+
// carries none.
|
|
3747
|
+
await installChatInputRouter(payload.chatId, {
|
|
3748
|
+
fallbackResumeFrom: bootInCursorResolved ? bootInCursor : undefined,
|
|
3749
|
+
resuming: Boolean(payload.continuation) || ctx.attempt.number > 1,
|
|
3750
|
+
});
|
|
3089
3751
|
// ── Recovery boot + chain reconstruction ────────────────────────
|
|
3090
3752
|
if (!hydrateMessages) {
|
|
3091
3753
|
const settledMessages = mergeByIdReplaceWins(bootSnapshot?.messages ?? [], replayedSettled);
|
|
@@ -3154,14 +3816,23 @@ function chatAgent(options) {
|
|
|
3154
3816
|
hookBeforeBoot = hookResult.beforeBoot;
|
|
3155
3817
|
}
|
|
3156
3818
|
}
|
|
3157
|
-
// Default: splice partial + the user it was answering into
|
|
3158
|
-
//
|
|
3819
|
+
// Default: splice partial + the user it was answering into the chain
|
|
3820
|
+
// so follow-ups like "keep going" still have context, and re-dispatch
|
|
3821
|
+
// the users that arrived after it.
|
|
3822
|
+
//
|
|
3823
|
+
// The splice needs a follow-up user to answer — it consumes
|
|
3824
|
+
// `inFlightUsers[0]` into the chain instead of dispatching it. With
|
|
3825
|
+
// exactly ONE in-flight user (the plain OOM / crash-mid-answer case)
|
|
3826
|
+
// there is nothing left to dispatch, so splicing would strand that
|
|
3827
|
+
// user unanswered and idle the run. Require `length > 1` on both
|
|
3828
|
+
// branches: at n=1 the orphan partial is dropped and the interrupted
|
|
3829
|
+
// user is re-dispatched as a fresh turn instead.
|
|
3159
3830
|
let seedChain;
|
|
3160
3831
|
let recoveredTurns;
|
|
3161
3832
|
if (hookChain !== undefined) {
|
|
3162
3833
|
seedChain = hookChain;
|
|
3163
3834
|
}
|
|
3164
|
-
else if (partialAssistant !== undefined && inFlightUsers.length >
|
|
3835
|
+
else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
|
|
3165
3836
|
seedChain = [...settledMessages, inFlightUsers[0], partialAssistant];
|
|
3166
3837
|
}
|
|
3167
3838
|
else {
|
|
@@ -3170,7 +3841,7 @@ function chatAgent(options) {
|
|
|
3170
3841
|
if (hookRecoveredTurns !== undefined) {
|
|
3171
3842
|
recoveredTurns = hookRecoveredTurns;
|
|
3172
3843
|
}
|
|
3173
|
-
else if (partialAssistant !== undefined && inFlightUsers.length >
|
|
3844
|
+
else if (partialAssistant !== undefined && inFlightUsers.length > 1) {
|
|
3174
3845
|
recoveredTurns = inFlightUsers.slice(1);
|
|
3175
3846
|
}
|
|
3176
3847
|
else {
|
|
@@ -3723,11 +4394,18 @@ function chatAgent(options) {
|
|
|
3723
4394
|
const cancelSignal = runSignal;
|
|
3724
4395
|
const combinedSignal = AbortSignal.any([runSignal, stopController.signal]);
|
|
3725
4396
|
const pmConfig = locals.get(chatPendingMessagesKey);
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
4397
|
+
/**
|
|
4398
|
+
* Only attached when there is a steering config to feed. Without
|
|
4399
|
+
* one a mid-turn message is left queued on the router, which is
|
|
4400
|
+
* what holds the resume floor behind it: a record handed to a
|
|
4401
|
+
* handler counts as terminally decided, so buffering one here
|
|
4402
|
+
* published a cursor past a message held only in memory and a
|
|
4403
|
+
* crash before the next turn lost it.
|
|
4404
|
+
*/
|
|
4405
|
+
const msgSub = pmConfig
|
|
4406
|
+
? chatInputRouter().observe(CHAT_ROUTE_MESSAGES, async (record) => {
|
|
4407
|
+
const msg = record.data
|
|
4408
|
+
.payload;
|
|
3731
4409
|
// Slim wire: at most one delta message per record. The
|
|
3732
4410
|
// pendingMessages handler reads `msg.message` directly
|
|
3733
4411
|
// instead of slicing an array — a wire record arrives
|
|
@@ -3758,6 +4436,7 @@ function chatAgent(options) {
|
|
|
3758
4436
|
queue.push({
|
|
3759
4437
|
uiMessage: lastUIMessage,
|
|
3760
4438
|
modelMessages: modelMsgs,
|
|
4439
|
+
seqNum: record.seqNum,
|
|
3761
4440
|
});
|
|
3762
4441
|
locals.set(chatSteeringQueueKey, queue);
|
|
3763
4442
|
}
|
|
@@ -3765,11 +4444,8 @@ function chatAgent(options) {
|
|
|
3765
4444
|
/* conversion failed — skip steering queue */
|
|
3766
4445
|
}
|
|
3767
4446
|
}
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
// No pendingMessages config — standard wire buffer for next turn
|
|
3771
|
-
pendingWireMessages.push(msg);
|
|
3772
|
-
});
|
|
4447
|
+
})
|
|
4448
|
+
: undefined;
|
|
3773
4449
|
turnMsgSub = msgSub;
|
|
3774
4450
|
// Track new messages for this turn (user input + assistant response).
|
|
3775
4451
|
const turnNewModelMessages = [];
|
|
@@ -4091,7 +4767,7 @@ function chatAgent(options) {
|
|
|
4091
4767
|
// The turn counter is decremented so the next iteration
|
|
4092
4768
|
// sees the same `turn` value — actions don't count.
|
|
4093
4769
|
if (isAction) {
|
|
4094
|
-
msgSub
|
|
4770
|
+
msgSub?.off();
|
|
4095
4771
|
if ((locals.get(chatPipeCountKey) ?? 0) === 0 &&
|
|
4096
4772
|
isUIMessageStreamable(actionStreamResult)) {
|
|
4097
4773
|
try {
|
|
@@ -4348,7 +5024,7 @@ function chatAgent(options) {
|
|
|
4348
5024
|
}
|
|
4349
5025
|
}
|
|
4350
5026
|
finally {
|
|
4351
|
-
msgSub
|
|
5027
|
+
msgSub?.off();
|
|
4352
5028
|
}
|
|
4353
5029
|
// Wait for onFinish to fire — on abort this may resolve slightly
|
|
4354
5030
|
// after pipeChat, since the stream's cancel() handler is async.
|
|
@@ -4792,7 +5468,7 @@ function chatAgent(options) {
|
|
|
4792
5468
|
if (!hydrateMessages) {
|
|
4793
5469
|
try {
|
|
4794
5470
|
await tracer.startActiveSpan("snapshot.write", async () => {
|
|
4795
|
-
const snapshotInCursor =
|
|
5471
|
+
const snapshotInCursor = chatInputRouter().resumeFloor();
|
|
4796
5472
|
await writeChatSnapshot(sessionIdForSnapshot, {
|
|
4797
5473
|
version: 1,
|
|
4798
5474
|
savedAt: Date.now(),
|
|
@@ -4831,13 +5507,6 @@ function chatAgent(options) {
|
|
|
4831
5507
|
currentWirePayload = bootInjectedQueue.shift();
|
|
4832
5508
|
return "continue";
|
|
4833
5509
|
}
|
|
4834
|
-
// If messages arrived during streaming (without pendingMessages config),
|
|
4835
|
-
// dispatch the oldest as the next turn. The rest stay queued
|
|
4836
|
-
// and drain one per turn.
|
|
4837
|
-
if (pendingWireMessages.length > 0) {
|
|
4838
|
-
currentWirePayload = pendingWireMessages.shift();
|
|
4839
|
-
return "continue";
|
|
4840
|
-
}
|
|
4841
5510
|
// chat.requestUpgrade() was called — exit the loop so the
|
|
4842
5511
|
// transport triggers a new run on the latest version.
|
|
4843
5512
|
// chat.endRun() — same exit, no upgrade semantics.
|
|
@@ -5071,7 +5740,7 @@ function chatAgent(options) {
|
|
|
5071
5740
|
// neither the snapshot nor the replayable `.in` tail.
|
|
5072
5741
|
if (!hydrateMessages) {
|
|
5073
5742
|
try {
|
|
5074
|
-
const errorSnapshotInCursor =
|
|
5743
|
+
const errorSnapshotInCursor = chatInputRouter().resumeFloor();
|
|
5075
5744
|
await writeChatSnapshot(sessionIdForSnapshot, {
|
|
5076
5745
|
version: 1,
|
|
5077
5746
|
savedAt: Date.now(),
|
|
@@ -5098,11 +5767,6 @@ function chatAgent(options) {
|
|
|
5098
5767
|
currentWirePayload = bootInjectedQueue.shift();
|
|
5099
5768
|
continue;
|
|
5100
5769
|
}
|
|
5101
|
-
// Same for messages buffered during the errored turn — already consumed, idling strands them.
|
|
5102
|
-
if (pendingWireMessages.length > 0) {
|
|
5103
|
-
currentWirePayload = pendingWireMessages.shift();
|
|
5104
|
-
continue;
|
|
5105
|
-
}
|
|
5106
5770
|
// Wait for the next message — same as after a successful turn
|
|
5107
5771
|
const effectiveIdleTimeout = metadata.get(IDLE_TIMEOUT_METADATA_KEY) ??
|
|
5108
5772
|
idleTimeoutInSeconds;
|
|
@@ -5162,6 +5826,8 @@ function createChatBuilder(config) {
|
|
|
5162
5826
|
return createChatBuilder({
|
|
5163
5827
|
...config,
|
|
5164
5828
|
clientDataSchema: cdConfig.schema,
|
|
5829
|
+
clientDataReportErrorAt: cdConfig.reportErrorAt ?? config.clientDataReportErrorAt,
|
|
5830
|
+
clientDataOnValidationError: cdConfig.onValidationError ?? config.clientDataOnValidationError,
|
|
5165
5831
|
});
|
|
5166
5832
|
},
|
|
5167
5833
|
onBoot(fn) {
|
|
@@ -5242,6 +5908,10 @@ function createChatBuilder(config) {
|
|
|
5242
5908
|
return chatCustomAgent({
|
|
5243
5909
|
...options,
|
|
5244
5910
|
...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}),
|
|
5911
|
+
...(config.clientDataReportErrorAt
|
|
5912
|
+
? { clientDataReportErrorAt: config.clientDataReportErrorAt }
|
|
5913
|
+
: {}),
|
|
5914
|
+
onClientDataValidationError: composeHooks(config.clientDataOnValidationError, options.onClientDataValidationError),
|
|
5245
5915
|
});
|
|
5246
5916
|
},
|
|
5247
5917
|
};
|
|
@@ -5294,6 +5964,8 @@ function withUIMessage(config) {
|
|
|
5294
5964
|
function withClientData(config) {
|
|
5295
5965
|
return createChatBuilder({
|
|
5296
5966
|
clientDataSchema: config.schema,
|
|
5967
|
+
clientDataReportErrorAt: config.reportErrorAt,
|
|
5968
|
+
clientDataOnValidationError: config.onValidationError,
|
|
5297
5969
|
hooks: {},
|
|
5298
5970
|
});
|
|
5299
5971
|
}
|
|
@@ -5484,6 +6156,58 @@ function isStopped() {
|
|
|
5484
6156
|
function requestUpgrade() {
|
|
5485
6157
|
locals.set(chatUpgradeRequestedKey, true);
|
|
5486
6158
|
}
|
|
6159
|
+
/**
|
|
6160
|
+
* Hand off the current custom agent Session to a fresh run.
|
|
6161
|
+
*
|
|
6162
|
+
* This is the low-level handoff for a fully hand-rolled
|
|
6163
|
+
* `chat.customAgent()` loop. This method rejects while a
|
|
6164
|
+
* `chat.createSession()` iterator is active. Close the iterator before calling
|
|
6165
|
+
* it. If `return()` races an active `next()`, it waits for that read to settle
|
|
6166
|
+
* before releasing the handoff guard. Call only between turns and after
|
|
6167
|
+
* detaching input listeners for the old run. If the old run completed its
|
|
6168
|
+
* current turn, persist its state and call {@link chatWriteTurnComplete} before
|
|
6169
|
+
* handing off.
|
|
6170
|
+
* Do not write a new turn boundary after input that the continuation run should
|
|
6171
|
+
* process has been dispatched: the boundary acknowledges that input.
|
|
6172
|
+
*
|
|
6173
|
+
* The server starts the continuation run but does not stop this run, so return
|
|
6174
|
+
* from the task immediately after awaiting this function. The promise rejects
|
|
6175
|
+
* if the server cannot complete the handoff.
|
|
6176
|
+
*
|
|
6177
|
+
* Pending Session input that the old run has not consumed remains on the
|
|
6178
|
+
* durable `.in` stream and is delivered to the continuation run.
|
|
6179
|
+
*
|
|
6180
|
+
* @example
|
|
6181
|
+
* ```ts
|
|
6182
|
+
* // Detach any chat.messages.on() subscriptions you created.
|
|
6183
|
+
* await persistMessages();
|
|
6184
|
+
* await chat.writeTurnComplete();
|
|
6185
|
+
* await chat.endAndContinue();
|
|
6186
|
+
* return;
|
|
6187
|
+
* ```
|
|
6188
|
+
*/
|
|
6189
|
+
async function endAndContinue() {
|
|
6190
|
+
if (locals.get(chatCustomAgentRunKey) !== true) {
|
|
6191
|
+
throw new Error("chat.endAndContinue() can only be called from inside a chat.customAgent() run");
|
|
6192
|
+
}
|
|
6193
|
+
if ((locals.get(chatActiveSessionIteratorsKey) ?? 0) > 0) {
|
|
6194
|
+
throw new Error("chat.endAndContinue() cannot be called while a chat.createSession() iterator is active. Close the iterator, then call chat.endAndContinue().");
|
|
6195
|
+
}
|
|
6196
|
+
await performEndAndContinue();
|
|
6197
|
+
}
|
|
6198
|
+
/** @internal Shared server handoff used by managed and custom agent loops. */
|
|
6199
|
+
async function performEndAndContinue() {
|
|
6200
|
+
const chatId = locals.get(chatExternalIdKey);
|
|
6201
|
+
const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
|
|
6202
|
+
if (!chatId || !callingRunId) {
|
|
6203
|
+
throw new Error("Cannot end and continue without an active chat agent run");
|
|
6204
|
+
}
|
|
6205
|
+
const apiClient = apiClientManager.clientOrThrow();
|
|
6206
|
+
await apiClient.endAndContinueSession(chatId, {
|
|
6207
|
+
callingRunId,
|
|
6208
|
+
reason: "upgrade",
|
|
6209
|
+
});
|
|
6210
|
+
}
|
|
5487
6211
|
/**
|
|
5488
6212
|
* Exit the run after the current turn completes, without waiting for the
|
|
5489
6213
|
* next message. Unlike {@link requestUpgrade}, no upgrade-required signal
|
|
@@ -5689,10 +6413,14 @@ function createStopSignal() {
|
|
|
5689
6413
|
* task instead of round-tripping them back from the client:
|
|
5690
6414
|
* - `lastEventId` — the turn-complete control record's seq_num on
|
|
5691
6415
|
* `session.out`; where the next turn's output stream resumes.
|
|
5692
|
-
* - `sessionInEventId` — the
|
|
5693
|
-
* this turn-complete
|
|
5694
|
-
*
|
|
5695
|
-
*
|
|
6416
|
+
* - `sessionInEventId` — the safe-to-resume-from cursor on `session.in` as of
|
|
6417
|
+
* this turn-complete. It is the highest sequence that can be resumed past
|
|
6418
|
+
* without skipping an unhandled message, so it is held back behind any
|
|
6419
|
+
* message still buffered unconsumed and is NOT necessarily the sequence of
|
|
6420
|
+
* the record this turn answered. Trigger owns input-cursor recovery, so this
|
|
6421
|
+
* is for correlation / out-of-sync detection, not required. Treat it as a
|
|
6422
|
+
* lower bound: a value below the record you just handled is expected, not a
|
|
6423
|
+
* sign of a lost turn.
|
|
5696
6424
|
*
|
|
5697
6425
|
* Either is `undefined` when the corresponding cursor isn't available.
|
|
5698
6426
|
*
|
|
@@ -5704,15 +6432,36 @@ function createStopSignal() {
|
|
|
5704
6432
|
* ```
|
|
5705
6433
|
*/
|
|
5706
6434
|
async function chatWriteTurnComplete(options) {
|
|
6435
|
+
await flushDeferredChatCustomAgentClientDataErrors();
|
|
5707
6436
|
const result = await writeTurnCompleteChunk(undefined, options?.publicAccessToken);
|
|
5708
6437
|
// Same cursor written to the `session-in-event-id` header inside
|
|
5709
6438
|
// `writeTurnCompleteChunk`; surfaced here so the caller can persist it.
|
|
5710
|
-
const inCursor =
|
|
6439
|
+
const inCursor = chatInputRouter().resumeFloor();
|
|
5711
6440
|
return {
|
|
5712
6441
|
lastEventId: result?.lastEventId,
|
|
5713
6442
|
...(inCursor !== undefined ? { sessionInEventId: String(inCursor) } : {}),
|
|
5714
6443
|
};
|
|
5715
6444
|
}
|
|
6445
|
+
/**
|
|
6446
|
+
* Writes the client-data errors held back by the default `"turn-end"` timing.
|
|
6447
|
+
*
|
|
6448
|
+
* Ordered before the turn-complete chunk, matching the error-then-turn-complete
|
|
6449
|
+
* shape the submitted-turn and async-read paths already write.
|
|
6450
|
+
*/
|
|
6451
|
+
async function flushDeferredChatCustomAgentClientDataErrors() {
|
|
6452
|
+
const deferred = locals.get(chatCustomAgentDeferredClientDataErrorsKey);
|
|
6453
|
+
if (!deferred || deferred.length === 0)
|
|
6454
|
+
return;
|
|
6455
|
+
locals.set(chatCustomAgentDeferredClientDataErrorsKey, []);
|
|
6456
|
+
for (const payload of deferred) {
|
|
6457
|
+
try {
|
|
6458
|
+
await writeChatCustomAgentClientDataErrorToStream(payload);
|
|
6459
|
+
}
|
|
6460
|
+
catch {
|
|
6461
|
+
/* non-fatal */
|
|
6462
|
+
}
|
|
6463
|
+
}
|
|
6464
|
+
}
|
|
5716
6465
|
/**
|
|
5717
6466
|
* Pass every chunk through untouched while recording it in `buffer`. Handles
|
|
5718
6467
|
* both the `AsyncIterable` and `ReadableStream` shapes `toUIMessageStream()`
|
|
@@ -6099,6 +6848,95 @@ class ChatMessageAccumulator {
|
|
|
6099
6848
|
return true;
|
|
6100
6849
|
}
|
|
6101
6850
|
}
|
|
6851
|
+
function trackActiveChatSessionIterator(iterator) {
|
|
6852
|
+
locals.set(chatActiveSessionIteratorsKey, (locals.get(chatActiveSessionIteratorsKey) ?? 0) + 1);
|
|
6853
|
+
let active = true;
|
|
6854
|
+
let closing = false;
|
|
6855
|
+
let activeNextCalls = 0;
|
|
6856
|
+
let closePromise;
|
|
6857
|
+
const nextSettledWaiters = new Set();
|
|
6858
|
+
function finish() {
|
|
6859
|
+
if (!active)
|
|
6860
|
+
return;
|
|
6861
|
+
active = false;
|
|
6862
|
+
const remaining = Math.max((locals.get(chatActiveSessionIteratorsKey) ?? 1) - 1, 0);
|
|
6863
|
+
locals.set(chatActiveSessionIteratorsKey, remaining);
|
|
6864
|
+
}
|
|
6865
|
+
function settleNextCall() {
|
|
6866
|
+
activeNextCalls = Math.max(activeNextCalls - 1, 0);
|
|
6867
|
+
if (activeNextCalls > 0)
|
|
6868
|
+
return;
|
|
6869
|
+
for (const resolve of nextSettledWaiters) {
|
|
6870
|
+
resolve();
|
|
6871
|
+
}
|
|
6872
|
+
nextSettledWaiters.clear();
|
|
6873
|
+
}
|
|
6874
|
+
function waitForNextCalls() {
|
|
6875
|
+
if (activeNextCalls === 0)
|
|
6876
|
+
return Promise.resolve();
|
|
6877
|
+
return new Promise((resolve) => nextSettledWaiters.add(resolve));
|
|
6878
|
+
}
|
|
6879
|
+
function closeIterator() {
|
|
6880
|
+
closing = true;
|
|
6881
|
+
if (!closePromise) {
|
|
6882
|
+
closePromise = (async () => {
|
|
6883
|
+
// A blocked next() can install a new message listener after it resumes.
|
|
6884
|
+
// Let every started call settle, then make the inner cleanup final.
|
|
6885
|
+
await waitForNextCalls();
|
|
6886
|
+
try {
|
|
6887
|
+
return iterator.return
|
|
6888
|
+
? await iterator.return()
|
|
6889
|
+
: { done: true, value: undefined };
|
|
6890
|
+
}
|
|
6891
|
+
finally {
|
|
6892
|
+
finish();
|
|
6893
|
+
}
|
|
6894
|
+
})();
|
|
6895
|
+
}
|
|
6896
|
+
return closePromise;
|
|
6897
|
+
}
|
|
6898
|
+
return {
|
|
6899
|
+
async next() {
|
|
6900
|
+
if (closing) {
|
|
6901
|
+
return { done: true, value: undefined };
|
|
6902
|
+
}
|
|
6903
|
+
activeNextCalls++;
|
|
6904
|
+
let result;
|
|
6905
|
+
try {
|
|
6906
|
+
result = await iterator.next();
|
|
6907
|
+
}
|
|
6908
|
+
catch (error) {
|
|
6909
|
+
settleNextCall();
|
|
6910
|
+
try {
|
|
6911
|
+
await closeIterator();
|
|
6912
|
+
}
|
|
6913
|
+
catch {
|
|
6914
|
+
// Preserve the original iterator error after best-effort cleanup.
|
|
6915
|
+
}
|
|
6916
|
+
throw error;
|
|
6917
|
+
}
|
|
6918
|
+
settleNextCall();
|
|
6919
|
+
if (result.done) {
|
|
6920
|
+
try {
|
|
6921
|
+
await closeIterator();
|
|
6922
|
+
}
|
|
6923
|
+
catch {
|
|
6924
|
+
// The inner next() already ended cleanly; cleanup remains best-effort.
|
|
6925
|
+
}
|
|
6926
|
+
}
|
|
6927
|
+
else if (closing) {
|
|
6928
|
+
// return() won the race. Do not expose a turn the caller has already
|
|
6929
|
+
// abandoned; without a new turn-complete boundary its input remains
|
|
6930
|
+
// replayable by the continuation run.
|
|
6931
|
+
return { done: true, value: undefined };
|
|
6932
|
+
}
|
|
6933
|
+
return result;
|
|
6934
|
+
},
|
|
6935
|
+
return() {
|
|
6936
|
+
return closeIterator();
|
|
6937
|
+
},
|
|
6938
|
+
};
|
|
6939
|
+
}
|
|
6102
6940
|
/**
|
|
6103
6941
|
* Create a chat session that yields turns as an async iterator.
|
|
6104
6942
|
*
|
|
@@ -6150,20 +6988,18 @@ function createChatSession(payload, options) {
|
|
|
6150
6988
|
const accumulator = new ChatMessageAccumulator();
|
|
6151
6989
|
let previousTurnUsage;
|
|
6152
6990
|
let cumulativeUsage = emptyUsage();
|
|
6153
|
-
// Messages consumed mid-turn, dispatched one per next(). Iterator-level
|
|
6154
|
-
// for the same reason as the agent loop's `pendingWireMessages`:
|
|
6155
|
-
// consumed records never replay, so a turn-local buffer loses them.
|
|
6156
|
-
const sessionPendingWire = [];
|
|
6157
6991
|
// The current turn's message subscription — detached defensively at the
|
|
6158
6992
|
// top of next() in case user code threw without complete()/done().
|
|
6159
6993
|
let activeMsgSub;
|
|
6160
|
-
|
|
6994
|
+
const iterator = {
|
|
6161
6995
|
async next() {
|
|
6162
6996
|
activeMsgSub?.off();
|
|
6163
6997
|
activeMsgSub = undefined;
|
|
6164
6998
|
if (!booted) {
|
|
6165
6999
|
booted = true;
|
|
6166
|
-
await
|
|
7000
|
+
await installChatInputRouter(currentPayload.chatId, {
|
|
7001
|
+
resuming: Boolean(currentPayload.continuation),
|
|
7002
|
+
});
|
|
6167
7003
|
stop = createStopSignal();
|
|
6168
7004
|
}
|
|
6169
7005
|
turn++;
|
|
@@ -6220,29 +7056,32 @@ function createChatSession(payload, options) {
|
|
|
6220
7056
|
currentPayload = { ...currentPayload, continuation: true };
|
|
6221
7057
|
}
|
|
6222
7058
|
}
|
|
6223
|
-
|
|
6224
|
-
|
|
7059
|
+
/**
|
|
7060
|
+
* Subsequent turns take the next message from the router. A record
|
|
7061
|
+
* that arrived mid-turn is already queued there, so this returns it
|
|
7062
|
+
* without suspending.
|
|
7063
|
+
*/
|
|
6225
7064
|
if (turn > 0) {
|
|
6226
|
-
|
|
6227
|
-
|
|
7065
|
+
// chat.requestUpgrade() / chat.endRun() — exit before waiting
|
|
7066
|
+
if (locals.get(chatUpgradeRequestedKey) || locals.get(chatEndRunRequestedKey)) {
|
|
7067
|
+
stop.cleanup();
|
|
7068
|
+
return { done: true, value: undefined };
|
|
6228
7069
|
}
|
|
6229
|
-
|
|
6230
|
-
|
|
6231
|
-
|
|
6232
|
-
|
|
6233
|
-
|
|
6234
|
-
|
|
6235
|
-
|
|
6236
|
-
|
|
6237
|
-
timeout,
|
|
6238
|
-
spanName: "waiting for next message",
|
|
6239
|
-
});
|
|
6240
|
-
if (!next.ok || runSignal.aborted) {
|
|
6241
|
-
stop.cleanup();
|
|
6242
|
-
return { done: true, value: undefined };
|
|
6243
|
-
}
|
|
6244
|
-
currentPayload = next.output;
|
|
7070
|
+
const next = await messagesInput.waitWithIdleTimeout({
|
|
7071
|
+
idleTimeoutInSeconds,
|
|
7072
|
+
timeout,
|
|
7073
|
+
spanName: "waiting for next message",
|
|
7074
|
+
});
|
|
7075
|
+
if (!next.ok || runSignal.aborted) {
|
|
7076
|
+
stop.cleanup();
|
|
7077
|
+
return { done: true, value: undefined };
|
|
6245
7078
|
}
|
|
7079
|
+
/**
|
|
7080
|
+
* The facade validated `clientData` against the configured schema
|
|
7081
|
+
* before returning, so the parsed shape is what arrives here. The
|
|
7082
|
+
* cast carries that across the untyped wire payload boundary.
|
|
7083
|
+
*/
|
|
7084
|
+
currentPayload = next.output;
|
|
6246
7085
|
}
|
|
6247
7086
|
// Check limits
|
|
6248
7087
|
if (turn >= maxTurns || runSignal.aborted) {
|
|
@@ -6266,39 +7105,49 @@ function createChatSession(payload, options) {
|
|
|
6266
7105
|
continuation: currentPayload.continuation ?? false,
|
|
6267
7106
|
clientData: currentPayload.metadata,
|
|
6268
7107
|
});
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
|
|
6275
|
-
|
|
6276
|
-
|
|
6277
|
-
if (
|
|
6278
|
-
if (sessionPendingMessages.onReceived) {
|
|
6279
|
-
try {
|
|
6280
|
-
await sessionPendingMessages.onReceived({
|
|
6281
|
-
message: lastUIMessage,
|
|
6282
|
-
chatId: currentPayload.chatId,
|
|
6283
|
-
turn,
|
|
6284
|
-
});
|
|
6285
|
-
}
|
|
6286
|
-
catch {
|
|
6287
|
-
/* non-fatal */
|
|
6288
|
-
}
|
|
6289
|
-
}
|
|
7108
|
+
/**
|
|
7109
|
+
* Only attached when there is a steering config to feed. Without one a
|
|
7110
|
+
* mid-turn message stays queued on the router, which is what keeps the
|
|
7111
|
+
* resume floor behind it.
|
|
7112
|
+
*/
|
|
7113
|
+
const handleSteeringMessage = async (msg, seqNum, isActive = () => true) => {
|
|
7114
|
+
const lastUIMessage = msg.message;
|
|
7115
|
+
if (lastUIMessage) {
|
|
7116
|
+
if (sessionPendingMessages?.onReceived) {
|
|
6290
7117
|
try {
|
|
6291
|
-
|
|
6292
|
-
|
|
7118
|
+
await sessionPendingMessages.onReceived({
|
|
7119
|
+
message: lastUIMessage,
|
|
7120
|
+
chatId: currentPayload.chatId,
|
|
7121
|
+
turn,
|
|
7122
|
+
});
|
|
6293
7123
|
}
|
|
6294
7124
|
catch {
|
|
6295
7125
|
/* non-fatal */
|
|
6296
7126
|
}
|
|
6297
7127
|
}
|
|
6298
|
-
|
|
7128
|
+
if (!isActive())
|
|
7129
|
+
return;
|
|
7130
|
+
try {
|
|
7131
|
+
const modelMsgs = await toModelMessages([lastUIMessage]);
|
|
7132
|
+
turnSteeringQueue.push({
|
|
7133
|
+
uiMessage: lastUIMessage,
|
|
7134
|
+
modelMessages: modelMsgs,
|
|
7135
|
+
seqNum,
|
|
7136
|
+
});
|
|
7137
|
+
}
|
|
7138
|
+
catch {
|
|
7139
|
+
/* non-fatal */
|
|
7140
|
+
}
|
|
6299
7141
|
}
|
|
6300
|
-
|
|
6301
|
-
|
|
7142
|
+
};
|
|
7143
|
+
const sessionMsgSub = sessionPendingMessages
|
|
7144
|
+
? locals.get(chatCustomAgentClientDataParserKey)
|
|
7145
|
+
? observeValidatedChatMessages((msg, seqNum, isActive) => handleSteeringMessage(msg, seqNum, isActive))
|
|
7146
|
+
: chatInputRouter().observe(CHAT_ROUTE_MESSAGES, (record) => {
|
|
7147
|
+
const msg = record.data.payload;
|
|
7148
|
+
void handleSteeringMessage(msg, record.seqNum);
|
|
7149
|
+
})
|
|
7150
|
+
: undefined;
|
|
6302
7151
|
activeMsgSub = sessionMsgSub;
|
|
6303
7152
|
// Accumulate messages. Slim wire: pass the single delta message as
|
|
6304
7153
|
// a 0-or-1-length array. The accumulator's behavior is unchanged —
|
|
@@ -6320,7 +7169,7 @@ function createChatSession(payload, options) {
|
|
|
6320
7169
|
// chat.requestUpgrade() called before this turn — signal transport and exit
|
|
6321
7170
|
if (locals.get(chatUpgradeRequestedKey)) {
|
|
6322
7171
|
await writeUpgradeRequiredChunk();
|
|
6323
|
-
sessionMsgSub
|
|
7172
|
+
sessionMsgSub?.off();
|
|
6324
7173
|
stop.cleanup();
|
|
6325
7174
|
return { done: true, value: undefined };
|
|
6326
7175
|
}
|
|
@@ -6360,7 +7209,7 @@ function createChatSession(payload, options) {
|
|
|
6360
7209
|
if (!response || response.role !== "assistant") {
|
|
6361
7210
|
throw new Error("turn.complete() could not find the spliced handover response");
|
|
6362
7211
|
}
|
|
6363
|
-
sessionMsgSub
|
|
7212
|
+
sessionMsgSub?.off();
|
|
6364
7213
|
await chatWriteTurnComplete();
|
|
6365
7214
|
return response;
|
|
6366
7215
|
}
|
|
@@ -6377,7 +7226,7 @@ function createChatSession(payload, options) {
|
|
|
6377
7226
|
});
|
|
6378
7227
|
if (runSignal.aborted) {
|
|
6379
7228
|
// Full cancel — don't accumulate
|
|
6380
|
-
sessionMsgSub
|
|
7229
|
+
sessionMsgSub?.off();
|
|
6381
7230
|
await chatWriteTurnComplete();
|
|
6382
7231
|
return undefined;
|
|
6383
7232
|
}
|
|
@@ -6400,7 +7249,7 @@ function createChatSession(payload, options) {
|
|
|
6400
7249
|
finally {
|
|
6401
7250
|
// Detach at stream end (like the agent loop): the steering queue
|
|
6402
7251
|
// can't inject anymore, so later arrivals must buffer for the next turn.
|
|
6403
|
-
sessionMsgSub
|
|
7252
|
+
sessionMsgSub?.off();
|
|
6404
7253
|
}
|
|
6405
7254
|
if (response) {
|
|
6406
7255
|
const cleaned = stop.signal.aborted && !runSignal.aborted
|
|
@@ -6496,7 +7345,7 @@ function createChatSession(payload, options) {
|
|
|
6496
7345
|
}
|
|
6497
7346
|
}
|
|
6498
7347
|
}
|
|
6499
|
-
sessionMsgSub
|
|
7348
|
+
sessionMsgSub?.off();
|
|
6500
7349
|
await chatWriteTurnComplete();
|
|
6501
7350
|
return response;
|
|
6502
7351
|
},
|
|
@@ -6513,7 +7362,7 @@ function createChatSession(payload, options) {
|
|
|
6513
7362
|
await accumulator.addResponse(response);
|
|
6514
7363
|
},
|
|
6515
7364
|
async done() {
|
|
6516
|
-
sessionMsgSub
|
|
7365
|
+
sessionMsgSub?.off();
|
|
6517
7366
|
await chatWriteTurnComplete();
|
|
6518
7367
|
},
|
|
6519
7368
|
prepareStep() {
|
|
@@ -6552,6 +7401,7 @@ function createChatSession(payload, options) {
|
|
|
6552
7401
|
return { done: true, value: undefined };
|
|
6553
7402
|
},
|
|
6554
7403
|
};
|
|
7404
|
+
return trackActiveChatSessionIterator(iterator);
|
|
6555
7405
|
},
|
|
6556
7406
|
};
|
|
6557
7407
|
}
|
|
@@ -7000,6 +7850,8 @@ export const chat = {
|
|
|
7000
7850
|
isStopped,
|
|
7001
7851
|
/** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */
|
|
7002
7852
|
requestUpgrade,
|
|
7853
|
+
/** Hand off a custom agent Session to a fresh run. See {@link endAndContinue}. */
|
|
7854
|
+
endAndContinue,
|
|
7003
7855
|
/** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */
|
|
7004
7856
|
endRun,
|
|
7005
7857
|
/** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */
|
|
@@ -7107,6 +7959,16 @@ export const chat = {
|
|
|
7107
7959
|
*/
|
|
7108
7960
|
async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
|
|
7109
7961
|
const session = getChatSession();
|
|
7962
|
+
// A handover-prepare boot claims the handover kinds so a signal arriving
|
|
7963
|
+
// before `waitForHandover` attaches is not drained. Released here rather than
|
|
7964
|
+
// only in `waitForHandover`, because a loop that never calls it would
|
|
7965
|
+
// otherwise hold the claim for the life of the run and leave a handover
|
|
7966
|
+
// record parked at the head of the channel, where it wedges
|
|
7967
|
+
// `chat.messages.next()`. Every surface reaches a turn boundary through this
|
|
7968
|
+
// function, including the managed agent, which does not call the public
|
|
7969
|
+
// `chat.writeTurnComplete`. By the time a turn completes the handover window
|
|
7970
|
+
// is over either way.
|
|
7971
|
+
chatInputRouter().clearRoute(CHAT_ROUTE_HANDOVER);
|
|
7110
7972
|
// 1. Write the turn-complete control record. The ack's `lastEventId` is
|
|
7111
7973
|
// this record's seq_num — that's the trim target for the NEXT turn.
|
|
7112
7974
|
//
|
|
@@ -7121,10 +7983,15 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
|
|
|
7121
7983
|
if (publicAccessToken) {
|
|
7122
7984
|
extraHeaders.push(["public-access-token", publicAccessToken]);
|
|
7123
7985
|
}
|
|
7124
|
-
const
|
|
7986
|
+
const routerCheckpoint = chatInputRouter().checkpoint();
|
|
7987
|
+
const inCursor = routerCheckpoint.resumeFrom;
|
|
7125
7988
|
if (inCursor !== undefined) {
|
|
7126
7989
|
extraHeaders.push([SESSION_IN_EVENT_ID_HEADER, String(inCursor)]);
|
|
7127
7990
|
}
|
|
7991
|
+
const consumedCursor = routerCheckpoint.appliedThrough;
|
|
7992
|
+
if (consumedCursor !== undefined) {
|
|
7993
|
+
extraHeaders.push([SESSION_IN_CONSUMED_ID_HEADER, String(consumedCursor)]);
|
|
7994
|
+
}
|
|
7128
7995
|
const result = await session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.TURN_COMPLETE, extraHeaders);
|
|
7129
7996
|
const T_N = result.lastEventId ? Number.parseInt(result.lastEventId, 10) : undefined;
|
|
7130
7997
|
// 2. Trim back to the previous turn-complete, if we have one. Skipping on
|
|
@@ -7183,16 +8050,11 @@ async function writeTurnCompleteChunk(_chatId, publicAccessToken) {
|
|
|
7183
8050
|
* @internal
|
|
7184
8051
|
*/
|
|
7185
8052
|
async function writeUpgradeRequiredChunk() {
|
|
7186
|
-
const
|
|
7187
|
-
const
|
|
7188
|
-
const callingRunId = ctx?.run.id;
|
|
8053
|
+
const chatId = locals.get(chatExternalIdKey);
|
|
8054
|
+
const callingRunId = locals.get(chatAgentRunContextKey)?.run.id;
|
|
7189
8055
|
if (chatId && callingRunId) {
|
|
7190
|
-
const apiClient = apiClientManager.clientOrThrow();
|
|
7191
8056
|
try {
|
|
7192
|
-
await
|
|
7193
|
-
callingRunId,
|
|
7194
|
-
reason: "upgrade",
|
|
7195
|
-
});
|
|
8057
|
+
await performEndAndContinue();
|
|
7196
8058
|
}
|
|
7197
8059
|
catch (error) {
|
|
7198
8060
|
// Non-fatal: the next `.in/append` re-triggers via the probe.
|
|
@@ -7207,16 +8069,6 @@ async function writeUpgradeRequiredChunk() {
|
|
|
7207
8069
|
const session = getChatSession();
|
|
7208
8070
|
return session.out.writeControl(TRIGGER_CONTROL_SUBTYPE.UPGRADE_REQUIRED);
|
|
7209
8071
|
}
|
|
7210
|
-
/**
|
|
7211
|
-
* Resolves the current chat's `chatId` (used as session externalId) from
|
|
7212
|
-
* the bound session handle. Returns `undefined` if no agent is bound —
|
|
7213
|
-
* shouldn't happen at the call sites that invoke
|
|
7214
|
-
* `writeUpgradeRequiredChunk`, but defensive against misuse.
|
|
7215
|
-
* @internal
|
|
7216
|
-
*/
|
|
7217
|
-
function getChatIdFromContext() {
|
|
7218
|
-
return locals.get(chatSessionHandleKey)?.id;
|
|
7219
|
-
}
|
|
7220
8072
|
/**
|
|
7221
8073
|
* Extracts the text content of the last user message from a UIMessage array.
|
|
7222
8074
|
* Returns undefined if no user message is found.
|