@ixo/oracle-runtime-workers 0.10.0 → 0.11.0
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/README.md +5 -2
- package/package.json +1 -1
- package/src/core/context-budget.ts +201 -0
- package/src/core/context-window.ts +289 -0
- package/src/core/env.ts +29 -0
- package/src/core/main-agent-types.ts +46 -0
- package/src/core/main-agent.ts +86 -9
- package/src/core/middlewares/context-guard.ts +270 -0
- package/src/core/middlewares/result-cap.ts +180 -0
- package/src/core/middlewares/summarization.ts +87 -6
- package/src/core/middlewares/tool-marks.ts +209 -0
- package/src/core/openrouter-pricing.ts +42 -1
- package/src/core/read-result-tool.ts +77 -0
- package/src/core/runtime-context.ts +7 -0
- package/src/core/sub-agent-fallback.ts +20 -3
- package/src/core/wrap-plugin-tool.ts +24 -3
- package/src/do/ambient.ts +17 -0
- package/src/do/contracts.ts +227 -0
- package/src/do/result-store-test-do.ts +88 -0
- package/src/do/result-store.ts +339 -0
- package/src/do/run-buffer.ts +204 -0
- package/src/do/run-coordinator.ts +572 -0
- package/src/do/run-request.ts +52 -0
- package/src/do/run-store-test-do.ts +130 -0
- package/src/do/run-store.ts +604 -0
- package/src/do/session-id-migration.ts +85 -0
- package/src/do/speaker-prefix.ts +37 -0
- package/src/do/sse-stream.ts +567 -400
- package/src/do/transcript.ts +238 -0
- package/src/do/turn-body.ts +10 -0
- package/src/do/user-oracle-do.ts +875 -146
- package/src/matrix/gateway-do.ts +230 -29
- package/src/matrix/group-chat-summarizer.ts +107 -0
- package/src/matrix/group-chat.ts +1163 -0
- package/src/matrix/inbox-store.ts +23 -3
- package/src/matrix/ingest.ts +54 -11
- package/src/matrix/reply-chain.ts +34 -25
- package/src/plugin-api/tool-helper.ts +9 -1
- package/src/plugin-api/types.ts +86 -0
- package/src/plugins/index.ts +3 -0
- package/src/plugins/matrix-group-chats/README.md +70 -0
- package/src/plugins/matrix-group-chats/index.ts +132 -0
- package/src/plugins/matrix-group-chats/tools.ts +172 -0
- package/src/plugins/tasks/tasks-tools.ts +4 -0
- package/src/shell/app.ts +66 -0
- package/src/sqlite/sqlite-saver.ts +81 -0
- package/src/tasks/scheduler.ts +134 -3
- package/src/tasks/store.ts +22 -0
package/src/do/sse-stream.ts
CHANGED
|
@@ -1,30 +1,58 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* LangGraph `streamEvents` → Server-Sent Events
|
|
2
|
+
* LangGraph `streamEvents` → run frames → Server-Sent Events.
|
|
3
3
|
*
|
|
4
4
|
* Wire-compatible port of the Node runtime's `SseStreamRunner`: the same
|
|
5
|
-
* event names and payload shapes the Portal / client SDK parse
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* event names and payload shapes the Portal / client SDK parse (`message`,
|
|
6
|
+
* `reasoning`, `tool_call`, `action_call`, `error`, `done`), the same
|
|
7
|
+
* "instant Thinking…" ack, the same 15 s heartbeat comment.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Two halves, since runs became durable (docs/plans/durable-runs.md):
|
|
10
|
+
*
|
|
11
|
+
* - `runTurnFrames` consumes the graph events and pushes every frame into
|
|
12
|
+
* the run's `RunBuffer` (the source of truth: numbered, packed into
|
|
13
|
+
* SQLite, replayable). It runs to the end of the turn whoever is or is
|
|
14
|
+
* not connected; only the run's abort signal stops it.
|
|
15
|
+
* - `createSseSubscriberStream` is what an HTTP response is: a replay of
|
|
16
|
+
* the frames after the client's cursor, then the live tail. Cancelling
|
|
17
|
+
* it (the browser left) only unsubscribes.
|
|
18
|
+
*
|
|
19
|
+
* Every frame carries its sequence number as the SSE `id:` line, so a client
|
|
20
|
+
* that reconnects can ask for `?after=<id>`.
|
|
11
21
|
*/
|
|
12
22
|
import {
|
|
13
23
|
classifyLlmError,
|
|
14
24
|
isOperatorFault,
|
|
15
25
|
redactOperatorFault,
|
|
16
26
|
} from '../llm/provider-error';
|
|
17
|
-
import type
|
|
18
|
-
import {
|
|
27
|
+
import { type AIMessageChunk, ToolMessage } from '@langchain/core/messages';
|
|
28
|
+
import { RunBuffer, type RunFrame } from './run-buffer';
|
|
29
|
+
|
|
30
|
+
/** Where the producer puts frames — a `RunBuffer`, or a test sink. */
|
|
31
|
+
export interface FrameSink {
|
|
32
|
+
push(event: string, data: unknown): RunFrame;
|
|
33
|
+
}
|
|
19
34
|
|
|
20
|
-
export interface
|
|
35
|
+
export interface TurnFrameProducerInput {
|
|
21
36
|
/** `agent.streamEvents(stateInput, config)` async iterable. */
|
|
22
37
|
events: AsyncIterable<unknown>;
|
|
38
|
+
sink: FrameSink;
|
|
23
39
|
sessionId: string;
|
|
24
40
|
requestId: string;
|
|
41
|
+
/** The run this turn belongs to (in the `run` and `done` frames). */
|
|
42
|
+
runId?: string;
|
|
43
|
+
/** Set on a resumed attempt (the coordinator announced it; no `run` frame here). */
|
|
44
|
+
resumed?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Longest tool/action output a frame carries. Wrapped tools are capped
|
|
47
|
+
* before their result exists (result-cap.ts); this is the belt for
|
|
48
|
+
* anything else (a sub-agent's reply), so no multi-megabyte frame ever
|
|
49
|
+
* reaches a client or a segment row.
|
|
50
|
+
*/
|
|
51
|
+
toolOutputCapChars?: number;
|
|
25
52
|
abortController: AbortController;
|
|
26
53
|
/** Names declared as client-side AG-UI actions (rendered as `action_call`). */
|
|
27
54
|
agActionNames?: ReadonlySet<string>;
|
|
55
|
+
/** Called with the reply text once the turn finished normally. */
|
|
28
56
|
onComplete?: (fullText: string) => void | Promise<void>;
|
|
29
57
|
onError?: (error: unknown) => void;
|
|
30
58
|
log?: (msg: string) => void;
|
|
@@ -36,8 +64,23 @@ export interface SseTurnRunnerInput {
|
|
|
36
64
|
* already has them). Payloads carry `sessionId`.
|
|
37
65
|
*/
|
|
38
66
|
mirror?: (eventName: string, payload: Record<string, unknown>) => void;
|
|
67
|
+
/** The `done` frame's `messageId` (the final assistant message), when known. */
|
|
68
|
+
messageIdOf?: () => string | undefined;
|
|
69
|
+
/** Called on every frame — the keep-alive touch. */
|
|
70
|
+
onFrame?: () => void;
|
|
39
71
|
}
|
|
40
72
|
|
|
73
|
+
export type TurnFrameOutcome =
|
|
74
|
+
| { status: 'completed'; fullText: string }
|
|
75
|
+
| { status: 'aborted'; fullText: string }
|
|
76
|
+
| { status: 'failed'; fullText: string; error: unknown };
|
|
77
|
+
|
|
78
|
+
/** Legacy one-shot input: a producer and a subscriber on an in-memory buffer. */
|
|
79
|
+
export type SseTurnRunnerInput = Omit<
|
|
80
|
+
TurnFrameProducerInput,
|
|
81
|
+
'sink' | 'onFrame'
|
|
82
|
+
>;
|
|
83
|
+
|
|
41
84
|
const THINKING_PHRASES = [
|
|
42
85
|
'Thinking...',
|
|
43
86
|
'Working...',
|
|
@@ -60,8 +103,18 @@ export function pickThinkingPhrase(): string {
|
|
|
60
103
|
);
|
|
61
104
|
}
|
|
62
105
|
|
|
63
|
-
|
|
64
|
-
|
|
106
|
+
/**
|
|
107
|
+
* One SSE frame. The sequence number travels as the standard `id:` field,
|
|
108
|
+
* placed after `event:` so the existing frame parsers (which key on the
|
|
109
|
+
* first line) keep working.
|
|
110
|
+
*/
|
|
111
|
+
export function formatSSE(
|
|
112
|
+
eventType: string,
|
|
113
|
+
data: unknown,
|
|
114
|
+
id?: number,
|
|
115
|
+
): string {
|
|
116
|
+
const idLine = id !== undefined && id > 0 ? `id: ${id}\n` : '';
|
|
117
|
+
return `event: ${eventType}\n${idLine}data: ${JSON.stringify(data)}\n\n`;
|
|
65
118
|
}
|
|
66
119
|
|
|
67
120
|
export const SSE_HEADERS: Record<string, string> = {
|
|
@@ -71,35 +124,32 @@ export const SSE_HEADERS: Record<string, string> = {
|
|
|
71
124
|
'X-Accel-Buffering': 'no',
|
|
72
125
|
};
|
|
73
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Frames a recovery must never lose, packed at once instead of on the
|
|
129
|
+
* timer: a settled tool/action result (the checkpoint that holds it never
|
|
130
|
+
* re-emits it, so a client re-joining after a reset would otherwise see the
|
|
131
|
+
* call running forever) and `done` (the run's close — its pack also carries
|
|
132
|
+
* the last text). Everything else waits for the timer: a tool's `isRunning`
|
|
133
|
+
* frame is re-emitted by a resumed attempt when the tool runs again, the
|
|
134
|
+
* `run` / `router.update` / `error` frames are informational, and text is
|
|
135
|
+
* what the timer is for. One row per flush interval of output, plus one per
|
|
136
|
+
* settled tool call, is the segment write budget of a turn.
|
|
137
|
+
*/
|
|
138
|
+
export function isImmediateFrame(event: string, data: unknown): boolean {
|
|
139
|
+
if (event === 'done') return true;
|
|
140
|
+
if (event === 'tool_call' || event === 'action_call')
|
|
141
|
+
return (data as { status?: string } | null)?.status !== 'isRunning';
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
|
|
74
145
|
interface ToolCallPayload {
|
|
75
146
|
sessionId: string;
|
|
76
147
|
requestId: string;
|
|
77
148
|
toolName: string;
|
|
78
149
|
args: Record<string, unknown>;
|
|
79
|
-
status: 'isRunning' | 'done'
|
|
150
|
+
status: 'isRunning' | 'done';
|
|
80
151
|
output?: string;
|
|
81
152
|
eventId?: string;
|
|
82
|
-
/** Set with `status: 'error'`: what the tool reported (schema rejection, retries exhausted). */
|
|
83
|
-
error?: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Model calls the agent makes for itself — the summarization middleware
|
|
88
|
-
* condensing the thread, a sub-agent's inner turn — stream through the same
|
|
89
|
-
* `streamEvents` pipe as the user-facing reply. Only the outermost agent's
|
|
90
|
-
* model output belongs on the wire; everything else is bookkeeping the user
|
|
91
|
-
* never asked to read (the "Here is a summary of the conversation to date"
|
|
92
|
-
* leak). The summarizer tags its invoke with `lc_source: 'summarization'`;
|
|
93
|
-
* sub-agents run under their own run name below the main agent.
|
|
94
|
-
*/
|
|
95
|
-
export function isInternalModelEvent(evt: {
|
|
96
|
-
metadata?: Record<string, unknown>;
|
|
97
|
-
tags?: string[];
|
|
98
|
-
}): boolean {
|
|
99
|
-
const source = evt.metadata?.['lc_source'];
|
|
100
|
-
if (source === 'summarization') return true;
|
|
101
|
-
const tags = evt.tags ?? [];
|
|
102
|
-
return tags.some((t) => t === 'summarization' || t === 'internal');
|
|
103
153
|
}
|
|
104
154
|
|
|
105
155
|
interface ActionCallPayload {
|
|
@@ -123,6 +173,23 @@ function extractToolArgs(input: unknown): Record<string, unknown> {
|
|
|
123
173
|
return {};
|
|
124
174
|
}
|
|
125
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Model calls whose tokens must never reach the user's reply: the
|
|
178
|
+
* summarizer's own invoke (LangChain tags it `lc_source: 'summarization'`),
|
|
179
|
+
* anything tagged `summarization` or `internal` (sub-agents run under their
|
|
180
|
+
* own run name below the main agent, tagged by subagent-as-tool). Only the
|
|
181
|
+
* outermost agent's model output belongs on the wire.
|
|
182
|
+
*/
|
|
183
|
+
export function isInternalModelEvent(evt: {
|
|
184
|
+
metadata?: Record<string, unknown>;
|
|
185
|
+
tags?: string[];
|
|
186
|
+
}): boolean {
|
|
187
|
+
const source = evt.metadata?.['lc_source'];
|
|
188
|
+
if (source === 'summarization') return true;
|
|
189
|
+
const tags = evt.tags ?? [];
|
|
190
|
+
return tags.some((t) => t === 'summarization' || t === 'internal');
|
|
191
|
+
}
|
|
192
|
+
|
|
126
193
|
function collectBlockStrings(
|
|
127
194
|
content: unknown,
|
|
128
195
|
type: string,
|
|
@@ -165,11 +232,6 @@ function toText(content: unknown): string {
|
|
|
165
232
|
return content == null ? '' : JSON.stringify(content);
|
|
166
233
|
}
|
|
167
234
|
|
|
168
|
-
/**
|
|
169
|
-
* Build the SSE body for one turn. The returned stream starts emitting the
|
|
170
|
-
* Thinking… ack immediately; `events` is consumed lazily once the consumer
|
|
171
|
-
* reads. Errors inside the run become an `error` frame followed by `done`.
|
|
172
|
-
*/
|
|
173
235
|
/** SSE frame name → socket event name (Node's `serverEvents` spelling). */
|
|
174
236
|
const MIRRORED_EVENTS: Record<string, string | undefined> = {
|
|
175
237
|
tool_call: 'tool_call',
|
|
@@ -177,398 +239,503 @@ const MIRRORED_EVENTS: Record<string, string | undefined> = {
|
|
|
177
239
|
'router.update': 'router_update',
|
|
178
240
|
};
|
|
179
241
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
242
|
+
/**
|
|
243
|
+
* Consume one turn's graph events and push its frames into the sink. Runs
|
|
244
|
+
* to the end whether or not anyone is subscribed; `abortController` is the
|
|
245
|
+
* only way to stop it early. The `done` frame is always the last frame.
|
|
246
|
+
*/
|
|
247
|
+
export async function runTurnFrames(
|
|
248
|
+
input: TurnFrameProducerInput,
|
|
249
|
+
): Promise<TurnFrameOutcome> {
|
|
250
|
+
const { sessionId, requestId, abortController, sink } = input;
|
|
185
251
|
const toolCallMap = new Map<string, ToolCallPayload>();
|
|
186
252
|
const actionCallMap = new Map<string, ActionCallPayload>();
|
|
253
|
+
let fullContent = '';
|
|
254
|
+
|
|
255
|
+
const cap = input.toolOutputCapChars;
|
|
256
|
+
const wireCap = (payload: unknown): unknown => {
|
|
257
|
+
if (!cap || !payload || typeof payload !== 'object') return payload;
|
|
258
|
+
const output = (payload as { output?: unknown }).output;
|
|
259
|
+
if (typeof output !== 'string' || output.length <= cap) return payload;
|
|
260
|
+
const budget = Math.max(200, cap - 200);
|
|
261
|
+
const head = Math.floor(budget * 0.4);
|
|
262
|
+
const tail = budget - head;
|
|
263
|
+
return {
|
|
264
|
+
...payload,
|
|
265
|
+
output: `${output.slice(0, head)}\n\n… [${output.length - head - tail} characters omitted on the wire] …\n\n${output.slice(output.length - tail)}`,
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
const write = (eventName: string, rawPayload: unknown) => {
|
|
269
|
+
const payload =
|
|
270
|
+
eventName === 'tool_call' || eventName === 'action_call'
|
|
271
|
+
? wireCap(rawPayload)
|
|
272
|
+
: rawPayload;
|
|
273
|
+
try {
|
|
274
|
+
sink.push(eventName, payload);
|
|
275
|
+
} catch {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
input.onFrame?.();
|
|
279
|
+
// Node's WebSocket gateway relays these to the session's sockets as
|
|
280
|
+
// well; message chunks and `done` stay SSE-only, as there.
|
|
281
|
+
const mirrored = MIRRORED_EVENTS[eventName];
|
|
282
|
+
if (mirrored && input.mirror && payload && typeof payload === 'object')
|
|
283
|
+
input.mirror(mirrored, payload as Record<string, unknown>);
|
|
284
|
+
};
|
|
285
|
+
const doneFrame = (extra: Record<string, unknown> = {}) => {
|
|
286
|
+
const messageId = input.messageIdOf?.();
|
|
287
|
+
write('done', {
|
|
288
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
289
|
+
...(messageId ? { messageId } : {}),
|
|
290
|
+
...extra,
|
|
291
|
+
});
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
// A resumed attempt was announced by the coordinator when it was
|
|
295
|
+
// scheduled (its `run` frame carries `resumed`, `attempt`,
|
|
296
|
+
// `partialLength`); only a fresh run opens with the frame here.
|
|
297
|
+
if (input.runId && !input.resumed)
|
|
298
|
+
write('run', { runId: input.runId, sessionId, requestId });
|
|
299
|
+
write('router.update', {
|
|
300
|
+
step: pickThinkingPhrase(),
|
|
301
|
+
sessionId,
|
|
302
|
+
requestId,
|
|
303
|
+
...(input.runId ? { runId: input.runId } : {}),
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
const flushOrphans = () => {
|
|
307
|
+
for (const evt of actionCallMap.values()) {
|
|
308
|
+
write('action_call', {
|
|
309
|
+
...evt,
|
|
310
|
+
status: 'error',
|
|
311
|
+
error: 'Action did not complete',
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
actionCallMap.clear();
|
|
315
|
+
for (const evt of toolCallMap.values()) {
|
|
316
|
+
write('tool_call', {
|
|
317
|
+
...evt,
|
|
318
|
+
status: 'done',
|
|
319
|
+
output: '⏱️ Tool did not complete',
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
toolCallMap.clear();
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// Tool calls the main agent's model produced, by call id, until their
|
|
326
|
+
// tool run starts. A call whose arguments fail the tool's schema never
|
|
327
|
+
// starts (LangChain validates before the tool's start callback), so
|
|
328
|
+
// `on_tool_start` / `on_tool_end` never fire for it; the error tool
|
|
329
|
+
// message the tools node returns is the only trace. Anything still
|
|
330
|
+
// recorded when that node ends is reported from that message.
|
|
331
|
+
const pendingCalls = new Map<
|
|
332
|
+
string,
|
|
333
|
+
{ toolName: string; args: Record<string, unknown> }
|
|
334
|
+
>();
|
|
335
|
+
/**
|
|
336
|
+
* The model's call id for a tool run that is starting. The start event
|
|
337
|
+
* does not carry it, and the arguments it carries are the schema-parsed
|
|
338
|
+
* ones (defaults applied), so they cannot be matched against the model's.
|
|
339
|
+
* The tools node starts the calls of one model turn in the order the model
|
|
340
|
+
* produced them, so the first pending call with this tool's name is it.
|
|
341
|
+
* Keying every frame of a call by that id — the id the transcript uses —
|
|
342
|
+
* is what lets a client show ONE card per call, live and after a refetch.
|
|
343
|
+
*/
|
|
344
|
+
const takePendingCall = (toolName: string): string | undefined => {
|
|
345
|
+
for (const [id, call] of pendingCalls) {
|
|
346
|
+
if (call.toolName === toolName) {
|
|
347
|
+
pendingCalls.delete(id);
|
|
348
|
+
return id;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return undefined;
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
for await (const raw of input.events) {
|
|
356
|
+
if (abortController.signal.aborted) break;
|
|
357
|
+
const evt = raw as {
|
|
358
|
+
event: string;
|
|
359
|
+
run_id: string;
|
|
360
|
+
name?: string;
|
|
361
|
+
data: unknown;
|
|
362
|
+
metadata?: Record<string, unknown>;
|
|
363
|
+
tags?: string[];
|
|
364
|
+
};
|
|
365
|
+
if (evt.event === 'on_chat_model_end') {
|
|
366
|
+
if (isInternalModelEvent(evt)) continue;
|
|
367
|
+
const output = (evt.data as { output?: AIMessageChunk })?.output;
|
|
368
|
+
for (const call of output?.tool_calls ?? []) {
|
|
369
|
+
if (call.id)
|
|
370
|
+
pendingCalls.set(call.id, {
|
|
371
|
+
toolName: call.name,
|
|
372
|
+
args: (call.args ?? {}) as Record<string, unknown>,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
if (evt.event === 'on_chain_end' && evt.name === 'tools') {
|
|
378
|
+
if (pendingCalls.size === 0) continue;
|
|
379
|
+
const output = (evt.data as { output?: unknown })?.output;
|
|
380
|
+
const messages: unknown[] = Array.isArray(output)
|
|
381
|
+
? output
|
|
382
|
+
: Array.isArray((output as { messages?: unknown[] })?.messages)
|
|
383
|
+
? ((output as { messages: unknown[] }).messages ?? [])
|
|
384
|
+
: [];
|
|
385
|
+
for (const message of messages) {
|
|
386
|
+
if (!ToolMessage.isInstance(message)) continue;
|
|
387
|
+
const call = pendingCalls.get(message.tool_call_id);
|
|
388
|
+
if (!call) continue;
|
|
389
|
+
pendingCalls.delete(message.tool_call_id);
|
|
390
|
+
const text = toText(message.content);
|
|
391
|
+
const failed = message.status === 'error';
|
|
392
|
+
write('tool_call', {
|
|
393
|
+
sessionId,
|
|
394
|
+
requestId,
|
|
395
|
+
toolName: message.name ?? call.toolName,
|
|
396
|
+
args: {
|
|
397
|
+
...call.args,
|
|
398
|
+
toolName: message.name ?? call.toolName,
|
|
399
|
+
},
|
|
400
|
+
status: failed ? 'error' : 'done',
|
|
401
|
+
output: text,
|
|
402
|
+
...(failed && { error: text || 'Tool call rejected' }),
|
|
403
|
+
eventId: message.tool_call_id,
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
pendingCalls.clear();
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (evt.event === 'on_tool_start') {
|
|
410
|
+
const toolName = evt.name ?? 'tool';
|
|
411
|
+
const args = extractToolArgs((evt.data as { input?: unknown })?.input);
|
|
412
|
+
const callId = takePendingCall(toolName) ?? evt.run_id;
|
|
413
|
+
if (input.agActionNames?.has(toolName)) {
|
|
414
|
+
const payload: ActionCallPayload = {
|
|
415
|
+
requestId,
|
|
416
|
+
sessionId,
|
|
417
|
+
toolCallId: callId,
|
|
418
|
+
toolName,
|
|
419
|
+
args,
|
|
420
|
+
status: 'isRunning',
|
|
421
|
+
};
|
|
422
|
+
actionCallMap.set(evt.run_id, payload);
|
|
423
|
+
write('action_call', payload);
|
|
424
|
+
} else {
|
|
425
|
+
const payload: ToolCallPayload = {
|
|
426
|
+
requestId,
|
|
427
|
+
sessionId,
|
|
428
|
+
toolName,
|
|
429
|
+
args: { ...args, toolName },
|
|
430
|
+
status: 'isRunning',
|
|
431
|
+
eventId: callId,
|
|
432
|
+
};
|
|
433
|
+
toolCallMap.set(evt.run_id, payload);
|
|
434
|
+
write('tool_call', payload);
|
|
435
|
+
}
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (evt.event === 'on_tool_end') {
|
|
439
|
+
const output = (evt.data as { output?: ToolMessage })?.output;
|
|
440
|
+
const text = toText(output?.content);
|
|
441
|
+
const action = actionCallMap.get(evt.run_id);
|
|
442
|
+
if (action) {
|
|
443
|
+
const parsed = safeParseToolContent(output?.content);
|
|
444
|
+
const failed = parsed?.success === false || Boolean(parsed?.error);
|
|
445
|
+
write('action_call', {
|
|
446
|
+
...action,
|
|
447
|
+
output: text,
|
|
448
|
+
status: failed ? 'error' : 'done',
|
|
449
|
+
...(failed && {
|
|
450
|
+
error:
|
|
451
|
+
typeof parsed?.error === 'string'
|
|
452
|
+
? parsed.error
|
|
453
|
+
: 'Action failed',
|
|
454
|
+
}),
|
|
455
|
+
});
|
|
456
|
+
actionCallMap.delete(evt.run_id);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
const tool = toolCallMap.get(evt.run_id);
|
|
460
|
+
if (tool) {
|
|
461
|
+
// A tool whose call was rejected (schema mismatch) or whose
|
|
462
|
+
// retries ran out comes back as a ToolMessage with
|
|
463
|
+
// `status: 'error'`, not as `on_tool_error`; the client must
|
|
464
|
+
// see it as failed, not as a finished call with odd output.
|
|
465
|
+
const failed = output?.status === 'error';
|
|
466
|
+
write('tool_call', {
|
|
467
|
+
...tool,
|
|
468
|
+
status: failed ? 'error' : 'done',
|
|
469
|
+
output: text,
|
|
470
|
+
...(failed && { error: text || 'Tool failed' }),
|
|
471
|
+
args: {
|
|
472
|
+
...tool.args,
|
|
473
|
+
toolName: output?.name ?? tool.toolName,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
toolCallMap.delete(evt.run_id);
|
|
477
|
+
}
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (evt.event === 'on_tool_error') {
|
|
481
|
+
// A tool that threw (retries exhausted, upstream down, MCP
|
|
482
|
+
// timeout) never emits `on_tool_end`; without this branch the
|
|
483
|
+
// client only learns "did not complete" at stream end.
|
|
484
|
+
const raw = (evt.data as { error?: unknown })?.error;
|
|
485
|
+
const message =
|
|
486
|
+
raw instanceof Error ? raw.message : String(raw ?? 'Tool failed');
|
|
487
|
+
const action = actionCallMap.get(evt.run_id);
|
|
488
|
+
if (action) {
|
|
489
|
+
write('action_call', {
|
|
490
|
+
...action,
|
|
491
|
+
output: message,
|
|
492
|
+
status: 'error',
|
|
493
|
+
error: message,
|
|
494
|
+
});
|
|
495
|
+
actionCallMap.delete(evt.run_id);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
const tool = toolCallMap.get(evt.run_id);
|
|
499
|
+
if (tool) {
|
|
500
|
+
write('tool_call', {
|
|
501
|
+
...tool,
|
|
502
|
+
status: 'done',
|
|
503
|
+
output: `⚠️ ${message}`,
|
|
504
|
+
});
|
|
505
|
+
toolCallMap.delete(evt.run_id);
|
|
506
|
+
}
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if (evt.event === 'on_chat_model_stream') {
|
|
510
|
+
if (isInternalModelEvent(evt)) continue;
|
|
511
|
+
const chunk = (evt.data as { chunk?: AIMessageChunk })?.chunk;
|
|
512
|
+
if (!chunk) continue;
|
|
513
|
+
const raw = chunk.additional_kwargs?.__raw_response as
|
|
514
|
+
| {
|
|
515
|
+
choices?: Array<{
|
|
516
|
+
delta?: {
|
|
517
|
+
reasoning?: string;
|
|
518
|
+
reasoning_content?: string;
|
|
519
|
+
reasoning_details?: unknown[];
|
|
520
|
+
};
|
|
521
|
+
}>;
|
|
522
|
+
}
|
|
523
|
+
| undefined;
|
|
524
|
+
const delta = raw?.choices?.[0]?.delta;
|
|
525
|
+
const deltaReasoning = delta?.reasoning ?? delta?.reasoning_content;
|
|
526
|
+
const reasoning = deltaReasoning?.trim()
|
|
527
|
+
? deltaReasoning
|
|
528
|
+
: collectBlockStrings(chunk.content, 'reasoning', 'reasoning');
|
|
529
|
+
if (reasoning && reasoning.trim()) {
|
|
530
|
+
const details = Array.isArray(delta?.reasoning_details)
|
|
531
|
+
? delta.reasoning_details
|
|
532
|
+
.filter(
|
|
533
|
+
(d): d is { type: string; text: string } =>
|
|
534
|
+
!!d &&
|
|
535
|
+
typeof d === 'object' &&
|
|
536
|
+
typeof (d as { type?: unknown }).type === 'string' &&
|
|
537
|
+
typeof (d as { text?: unknown }).text === 'string' &&
|
|
538
|
+
(d as { text: string }).text.trim().length > 0,
|
|
539
|
+
)
|
|
540
|
+
.map((d) => ({
|
|
541
|
+
type: d.type,
|
|
542
|
+
text: d.text,
|
|
543
|
+
format: 'unknown',
|
|
544
|
+
index: 0,
|
|
545
|
+
}))
|
|
546
|
+
: undefined;
|
|
547
|
+
write('reasoning', {
|
|
548
|
+
sessionId,
|
|
549
|
+
requestId,
|
|
550
|
+
reasoning,
|
|
551
|
+
...(details && { reasoningDetails: details }),
|
|
552
|
+
isComplete: false,
|
|
553
|
+
timestamp: new Date().toISOString(),
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
const text =
|
|
557
|
+
typeof chunk.content === 'string'
|
|
558
|
+
? chunk.content
|
|
559
|
+
: collectBlockStrings(chunk.content, 'text', 'text');
|
|
560
|
+
if (text) {
|
|
561
|
+
fullContent += text;
|
|
562
|
+
write('message', {
|
|
563
|
+
content: text,
|
|
564
|
+
timestamp: new Date().toISOString(),
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
if (!abortController.signal.aborted) {
|
|
570
|
+
flushOrphans();
|
|
571
|
+
write('reasoning', {
|
|
572
|
+
sessionId,
|
|
573
|
+
requestId,
|
|
574
|
+
reasoning: '',
|
|
575
|
+
isComplete: true,
|
|
576
|
+
timestamp: new Date().toISOString(),
|
|
577
|
+
});
|
|
578
|
+
await input.onComplete?.(fullContent);
|
|
579
|
+
doneFrame();
|
|
580
|
+
return { status: 'completed', fullText: fullContent };
|
|
581
|
+
}
|
|
582
|
+
// Aborted (POST /messages/abort, or superseded): close the stream
|
|
583
|
+
// cleanly so the UI leaves its "thinking" state.
|
|
584
|
+
flushOrphans();
|
|
585
|
+
doneFrame({ aborted: true });
|
|
586
|
+
return { status: 'aborted', fullText: fullContent };
|
|
587
|
+
} catch (error) {
|
|
588
|
+
// The run's signal is the authority: LangGraph rethrows the signal's
|
|
589
|
+
// reason (whatever the aborter passed), not always an `AbortError`.
|
|
590
|
+
const aborted =
|
|
591
|
+
abortController.signal.aborted ||
|
|
592
|
+
(error instanceof Error &&
|
|
593
|
+
(error.name === 'AbortError' || /abort/i.test(error.message)));
|
|
594
|
+
if (aborted) {
|
|
595
|
+
flushOrphans();
|
|
596
|
+
doneFrame({ aborted: true });
|
|
597
|
+
return { status: 'aborted', fullText: fullContent };
|
|
598
|
+
}
|
|
599
|
+
input.onError?.(error);
|
|
600
|
+
flushOrphans();
|
|
601
|
+
write('reasoning', {
|
|
602
|
+
sessionId,
|
|
603
|
+
requestId,
|
|
604
|
+
reasoning: '',
|
|
605
|
+
isComplete: true,
|
|
606
|
+
timestamp: new Date().toISOString(),
|
|
607
|
+
});
|
|
608
|
+
// Same wire shape as the Node runtime's `sendSSEError`: the
|
|
609
|
+
// classification is redacted here — the one place an LLM failure
|
|
610
|
+
// becomes bytes on a client's wire.
|
|
611
|
+
const classified = classifyLlmError(error, {
|
|
612
|
+
byoProvider: input.byoProvider,
|
|
613
|
+
});
|
|
614
|
+
if (isOperatorFault(classified))
|
|
615
|
+
input.log?.(
|
|
616
|
+
`OPERATOR FAULT (platform ${classified.kind}) on turn ${requestId}: ${classified.detail}`,
|
|
617
|
+
);
|
|
618
|
+
const safe = redactOperatorFault(classified);
|
|
619
|
+
write('error', {
|
|
620
|
+
error: safe.message,
|
|
621
|
+
kind: safe.kind,
|
|
622
|
+
source: safe.source,
|
|
623
|
+
...(safe.provider && { provider: safe.provider }),
|
|
624
|
+
...(safe.providerLabel && { providerLabel: safe.providerLabel }),
|
|
625
|
+
...(safe.status !== undefined && { status: safe.status }),
|
|
626
|
+
retryable: safe.retryable,
|
|
627
|
+
detail: safe.detail,
|
|
628
|
+
sessionId,
|
|
629
|
+
requestId,
|
|
630
|
+
timestamp: new Date().toISOString(),
|
|
631
|
+
});
|
|
632
|
+
doneFrame({ failed: true });
|
|
633
|
+
return { status: 'failed', fullText: fullContent, error };
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
export interface SseSubscriberInput {
|
|
638
|
+
/** Frames to replay first (from segments and the buffer's tail), in order. */
|
|
639
|
+
replay: readonly RunFrame[];
|
|
640
|
+
/** Live source; omitted for a run that already ended (replay, then done). */
|
|
641
|
+
buffer?: RunBuffer;
|
|
642
|
+
/** Emitted after the replay when there is no live buffer and no `done` was replayed. */
|
|
643
|
+
trailer?: { event: string; data: unknown };
|
|
644
|
+
heartbeatMs?: number;
|
|
645
|
+
/** The client went away (the run is NOT stopped). */
|
|
646
|
+
onCancel?: () => void;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* An SSE response body over a run: the replay, then every live frame until
|
|
651
|
+
* the `done` frame. Cancelling only detaches.
|
|
652
|
+
*/
|
|
653
|
+
export function createSseSubscriberStream(
|
|
654
|
+
input: SseSubscriberInput,
|
|
655
|
+
): ReadableStream<Uint8Array> {
|
|
656
|
+
const encoder = new TextEncoder();
|
|
187
657
|
let closed = false;
|
|
188
658
|
let heartbeat: ReturnType<typeof setInterval> | undefined;
|
|
659
|
+
let unsubscribe: (() => void) | undefined;
|
|
189
660
|
|
|
190
661
|
return new ReadableStream<Uint8Array>({
|
|
191
662
|
start: (controller) => {
|
|
192
|
-
const write = (
|
|
193
|
-
eventName: string,
|
|
194
|
-
payload: unknown,
|
|
195
|
-
opts: { force?: boolean } = {},
|
|
196
|
-
) => {
|
|
197
|
-
if (closed || (abortController.signal.aborted && !opts.force)) return;
|
|
198
|
-
try {
|
|
199
|
-
controller.enqueue(encoder.encode(formatSSE(eventName, payload)));
|
|
200
|
-
} catch {
|
|
201
|
-
closed = true;
|
|
202
|
-
}
|
|
203
|
-
// Node's WebSocket gateway relays these to the session's sockets as
|
|
204
|
-
// well; message chunks and `done` stay SSE-only, as there.
|
|
205
|
-
const mirrored = MIRRORED_EVENTS[eventName];
|
|
206
|
-
if (mirrored && input.mirror && payload && typeof payload === 'object')
|
|
207
|
-
input.mirror(mirrored, payload as Record<string, unknown>);
|
|
208
|
-
};
|
|
209
663
|
const finish = () => {
|
|
210
664
|
if (closed) return;
|
|
211
665
|
closed = true;
|
|
212
666
|
if (heartbeat) clearInterval(heartbeat);
|
|
667
|
+
unsubscribe?.();
|
|
213
668
|
try {
|
|
214
669
|
controller.close();
|
|
215
670
|
} catch {
|
|
216
671
|
/* already closed */
|
|
217
672
|
}
|
|
218
673
|
};
|
|
219
|
-
|
|
220
|
-
write('router.update', {
|
|
221
|
-
step: pickThinkingPhrase(),
|
|
222
|
-
sessionId,
|
|
223
|
-
requestId,
|
|
224
|
-
});
|
|
225
|
-
heartbeat = setInterval(() => {
|
|
674
|
+
const write = (frame: RunFrame) => {
|
|
226
675
|
if (closed) return;
|
|
227
676
|
try {
|
|
228
|
-
controller.enqueue(
|
|
677
|
+
controller.enqueue(
|
|
678
|
+
encoder.encode(formatSSE(frame.event, frame.data, frame.seq)),
|
|
679
|
+
);
|
|
229
680
|
} catch {
|
|
230
681
|
closed = true;
|
|
682
|
+
return;
|
|
231
683
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
const flushOrphans = () => {
|
|
235
|
-
for (const [runId, evt] of actionCallMap) {
|
|
236
|
-
write('action_call', {
|
|
237
|
-
...evt,
|
|
238
|
-
status: 'error',
|
|
239
|
-
error: 'Action did not complete',
|
|
240
|
-
toolCallId: runId,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
actionCallMap.clear();
|
|
244
|
-
for (const [runId, evt] of toolCallMap) {
|
|
245
|
-
write('tool_call', {
|
|
246
|
-
...evt,
|
|
247
|
-
status: 'done',
|
|
248
|
-
output: '⏱️ Tool did not complete',
|
|
249
|
-
eventId: runId,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
toolCallMap.clear();
|
|
684
|
+
if (frame.event === 'done') finish();
|
|
253
685
|
};
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
686
|
+
for (const frame of input.replay) write(frame);
|
|
687
|
+
if (closed) return;
|
|
688
|
+
if (input.buffer && !input.buffer.isClosed) {
|
|
689
|
+
unsubscribe = input.buffer.subscribe(write);
|
|
690
|
+
} else {
|
|
691
|
+
if (input.trailer)
|
|
692
|
+
write({
|
|
693
|
+
seq: 0,
|
|
694
|
+
event: input.trailer.event,
|
|
695
|
+
data: input.trailer.data,
|
|
696
|
+
});
|
|
697
|
+
finish();
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
heartbeat = setInterval(() => {
|
|
701
|
+
if (closed) return;
|
|
266
702
|
try {
|
|
267
|
-
|
|
703
|
+
controller.enqueue(encoder.encode(': heartbeat\n\n'));
|
|
268
704
|
} catch {
|
|
269
|
-
|
|
270
|
-
}
|
|
271
|
-
};
|
|
272
|
-
const forgetStartedCall = (toolName: string, args: unknown) => {
|
|
273
|
-
for (const [id, call] of pendingCalls) {
|
|
274
|
-
if (call.toolName === toolName && sameArgs(call.args, args)) {
|
|
275
|
-
pendingCalls.delete(id);
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
};
|
|
280
|
-
|
|
281
|
-
const run = async () => {
|
|
282
|
-
let fullContent = '';
|
|
283
|
-
try {
|
|
284
|
-
for await (const raw of input.events) {
|
|
285
|
-
if (abortController.signal.aborted) break;
|
|
286
|
-
const evt = raw as {
|
|
287
|
-
event: string;
|
|
288
|
-
run_id: string;
|
|
289
|
-
name?: string;
|
|
290
|
-
data: unknown;
|
|
291
|
-
metadata?: Record<string, unknown>;
|
|
292
|
-
tags?: string[];
|
|
293
|
-
};
|
|
294
|
-
if (evt.event === 'on_chat_model_end') {
|
|
295
|
-
if (isInternalModelEvent(evt)) continue;
|
|
296
|
-
const output = (evt.data as { output?: AIMessageChunk })?.output;
|
|
297
|
-
for (const call of output?.tool_calls ?? []) {
|
|
298
|
-
if (call.id)
|
|
299
|
-
pendingCalls.set(call.id, {
|
|
300
|
-
toolName: call.name,
|
|
301
|
-
args: (call.args ?? {}) as Record<string, unknown>,
|
|
302
|
-
});
|
|
303
|
-
}
|
|
304
|
-
continue;
|
|
305
|
-
}
|
|
306
|
-
if (evt.event === 'on_chain_end' && evt.name === 'tools') {
|
|
307
|
-
if (pendingCalls.size === 0) continue;
|
|
308
|
-
const output = (evt.data as { output?: unknown })?.output;
|
|
309
|
-
const messages: unknown[] = Array.isArray(output)
|
|
310
|
-
? output
|
|
311
|
-
: Array.isArray((output as { messages?: unknown[] })?.messages)
|
|
312
|
-
? ((output as { messages: unknown[] }).messages ?? [])
|
|
313
|
-
: [];
|
|
314
|
-
for (const message of messages) {
|
|
315
|
-
if (!isToolMessage(message)) continue;
|
|
316
|
-
const call = pendingCalls.get(message.tool_call_id);
|
|
317
|
-
if (!call) continue;
|
|
318
|
-
pendingCalls.delete(message.tool_call_id);
|
|
319
|
-
const text = toText(message.content);
|
|
320
|
-
const failed = message.status === 'error';
|
|
321
|
-
write('tool_call', {
|
|
322
|
-
sessionId,
|
|
323
|
-
requestId,
|
|
324
|
-
toolName: message.name ?? call.toolName,
|
|
325
|
-
args: {
|
|
326
|
-
...call.args,
|
|
327
|
-
toolName: message.name ?? call.toolName,
|
|
328
|
-
},
|
|
329
|
-
status: failed ? 'error' : 'done',
|
|
330
|
-
output: text,
|
|
331
|
-
...(failed && { error: text || 'Tool call rejected' }),
|
|
332
|
-
eventId: message.tool_call_id,
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
pendingCalls.clear();
|
|
336
|
-
continue;
|
|
337
|
-
}
|
|
338
|
-
if (evt.event === 'on_tool_start') {
|
|
339
|
-
const toolName = evt.name ?? 'tool';
|
|
340
|
-
const args = extractToolArgs(
|
|
341
|
-
(evt.data as { input?: unknown })?.input,
|
|
342
|
-
);
|
|
343
|
-
forgetStartedCall(toolName, args);
|
|
344
|
-
if (input.agActionNames?.has(toolName)) {
|
|
345
|
-
const payload: ActionCallPayload = {
|
|
346
|
-
requestId,
|
|
347
|
-
sessionId,
|
|
348
|
-
toolCallId: evt.run_id,
|
|
349
|
-
toolName,
|
|
350
|
-
args,
|
|
351
|
-
status: 'isRunning',
|
|
352
|
-
};
|
|
353
|
-
actionCallMap.set(evt.run_id, payload);
|
|
354
|
-
write('action_call', payload);
|
|
355
|
-
} else {
|
|
356
|
-
const payload: ToolCallPayload = {
|
|
357
|
-
requestId,
|
|
358
|
-
sessionId,
|
|
359
|
-
toolName,
|
|
360
|
-
args: { ...args, toolName },
|
|
361
|
-
status: 'isRunning',
|
|
362
|
-
eventId: evt.run_id,
|
|
363
|
-
};
|
|
364
|
-
toolCallMap.set(evt.run_id, payload);
|
|
365
|
-
write('tool_call', payload);
|
|
366
|
-
}
|
|
367
|
-
continue;
|
|
368
|
-
}
|
|
369
|
-
if (evt.event === 'on_tool_end') {
|
|
370
|
-
const output = (evt.data as { output?: ToolMessage })?.output;
|
|
371
|
-
const text = toText(output?.content);
|
|
372
|
-
const action = actionCallMap.get(evt.run_id);
|
|
373
|
-
if (action) {
|
|
374
|
-
const parsed = safeParseToolContent(output?.content);
|
|
375
|
-
const failed =
|
|
376
|
-
parsed?.success === false || Boolean(parsed?.error);
|
|
377
|
-
write('action_call', {
|
|
378
|
-
...action,
|
|
379
|
-
output: text,
|
|
380
|
-
status: failed ? 'error' : 'done',
|
|
381
|
-
...(failed && {
|
|
382
|
-
error:
|
|
383
|
-
typeof parsed?.error === 'string'
|
|
384
|
-
? parsed.error
|
|
385
|
-
: 'Action failed',
|
|
386
|
-
}),
|
|
387
|
-
});
|
|
388
|
-
actionCallMap.delete(evt.run_id);
|
|
389
|
-
continue;
|
|
390
|
-
}
|
|
391
|
-
const tool = toolCallMap.get(evt.run_id);
|
|
392
|
-
if (tool) {
|
|
393
|
-
// A tool whose call was rejected (schema mismatch) or whose
|
|
394
|
-
// retries ran out comes back as a ToolMessage with
|
|
395
|
-
// `status: 'error'`, not as `on_tool_error`; the client must
|
|
396
|
-
// see it as failed, not as a finished call with odd output.
|
|
397
|
-
const failed = output?.status === 'error';
|
|
398
|
-
write('tool_call', {
|
|
399
|
-
...tool,
|
|
400
|
-
status: failed ? 'error' : 'done',
|
|
401
|
-
output: text,
|
|
402
|
-
...(failed && { error: text || 'Tool failed' }),
|
|
403
|
-
eventId: evt.run_id,
|
|
404
|
-
args: {
|
|
405
|
-
...tool.args,
|
|
406
|
-
toolName: output?.name ?? tool.toolName,
|
|
407
|
-
},
|
|
408
|
-
});
|
|
409
|
-
toolCallMap.delete(evt.run_id);
|
|
410
|
-
}
|
|
411
|
-
continue;
|
|
412
|
-
}
|
|
413
|
-
if (evt.event === 'on_tool_error') {
|
|
414
|
-
// A tool that threw (retries exhausted, upstream down, MCP
|
|
415
|
-
// timeout) never emits `on_tool_end`; without this branch the
|
|
416
|
-
// client only learns "did not complete" at stream end.
|
|
417
|
-
const raw = (evt.data as { error?: unknown })?.error;
|
|
418
|
-
const message =
|
|
419
|
-
raw instanceof Error
|
|
420
|
-
? raw.message
|
|
421
|
-
: String(raw ?? 'Tool failed');
|
|
422
|
-
const action = actionCallMap.get(evt.run_id);
|
|
423
|
-
if (action) {
|
|
424
|
-
write('action_call', {
|
|
425
|
-
...action,
|
|
426
|
-
output: message,
|
|
427
|
-
status: 'error',
|
|
428
|
-
error: message,
|
|
429
|
-
});
|
|
430
|
-
actionCallMap.delete(evt.run_id);
|
|
431
|
-
continue;
|
|
432
|
-
}
|
|
433
|
-
const tool = toolCallMap.get(evt.run_id);
|
|
434
|
-
if (tool) {
|
|
435
|
-
write('tool_call', {
|
|
436
|
-
...tool,
|
|
437
|
-
status: 'done',
|
|
438
|
-
output: `⚠️ ${message}`,
|
|
439
|
-
eventId: evt.run_id,
|
|
440
|
-
});
|
|
441
|
-
toolCallMap.delete(evt.run_id);
|
|
442
|
-
}
|
|
443
|
-
continue;
|
|
444
|
-
}
|
|
445
|
-
if (evt.event === 'on_chat_model_stream') {
|
|
446
|
-
if (isInternalModelEvent(evt)) continue;
|
|
447
|
-
const chunk = (evt.data as { chunk?: AIMessageChunk })?.chunk;
|
|
448
|
-
if (!chunk) continue;
|
|
449
|
-
const raw = chunk.additional_kwargs?.__raw_response as
|
|
450
|
-
| {
|
|
451
|
-
choices?: Array<{
|
|
452
|
-
delta?: {
|
|
453
|
-
reasoning?: string;
|
|
454
|
-
reasoning_content?: string;
|
|
455
|
-
reasoning_details?: unknown[];
|
|
456
|
-
};
|
|
457
|
-
}>;
|
|
458
|
-
}
|
|
459
|
-
| undefined;
|
|
460
|
-
const delta = raw?.choices?.[0]?.delta;
|
|
461
|
-
const deltaReasoning =
|
|
462
|
-
delta?.reasoning ?? delta?.reasoning_content;
|
|
463
|
-
const reasoning = deltaReasoning?.trim()
|
|
464
|
-
? deltaReasoning
|
|
465
|
-
: collectBlockStrings(chunk.content, 'reasoning', 'reasoning');
|
|
466
|
-
if (reasoning && reasoning.trim()) {
|
|
467
|
-
const details = Array.isArray(delta?.reasoning_details)
|
|
468
|
-
? delta.reasoning_details
|
|
469
|
-
.filter(
|
|
470
|
-
(d): d is { type: string; text: string } =>
|
|
471
|
-
!!d &&
|
|
472
|
-
typeof d === 'object' &&
|
|
473
|
-
typeof (d as { type?: unknown }).type === 'string' &&
|
|
474
|
-
typeof (d as { text?: unknown }).text === 'string' &&
|
|
475
|
-
(d as { text: string }).text.trim().length > 0,
|
|
476
|
-
)
|
|
477
|
-
.map((d) => ({
|
|
478
|
-
type: d.type,
|
|
479
|
-
text: d.text,
|
|
480
|
-
format: 'unknown',
|
|
481
|
-
index: 0,
|
|
482
|
-
}))
|
|
483
|
-
: undefined;
|
|
484
|
-
write('reasoning', {
|
|
485
|
-
sessionId,
|
|
486
|
-
requestId,
|
|
487
|
-
reasoning,
|
|
488
|
-
...(details && { reasoningDetails: details }),
|
|
489
|
-
isComplete: false,
|
|
490
|
-
timestamp: new Date().toISOString(),
|
|
491
|
-
});
|
|
492
|
-
}
|
|
493
|
-
const text =
|
|
494
|
-
typeof chunk.content === 'string'
|
|
495
|
-
? chunk.content
|
|
496
|
-
: collectBlockStrings(chunk.content, 'text', 'text');
|
|
497
|
-
if (text) {
|
|
498
|
-
fullContent += text;
|
|
499
|
-
write('message', {
|
|
500
|
-
content: text,
|
|
501
|
-
timestamp: new Date().toISOString(),
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
}
|
|
506
|
-
if (!abortController.signal.aborted) {
|
|
507
|
-
flushOrphans();
|
|
508
|
-
write('reasoning', {
|
|
509
|
-
sessionId,
|
|
510
|
-
requestId,
|
|
511
|
-
reasoning: '',
|
|
512
|
-
isComplete: true,
|
|
513
|
-
timestamp: new Date().toISOString(),
|
|
514
|
-
});
|
|
515
|
-
write('done', {});
|
|
516
|
-
await input.onComplete?.(fullContent);
|
|
517
|
-
} else {
|
|
518
|
-
// Aborted by the client (POST /messages/abort): close the stream
|
|
519
|
-
// cleanly so the UI leaves its "thinking" state.
|
|
520
|
-
write('done', {}, { force: true });
|
|
521
|
-
}
|
|
522
|
-
} catch (error) {
|
|
523
|
-
const aborted =
|
|
524
|
-
error instanceof Error &&
|
|
525
|
-
(error.name === 'AbortError' || /abort/i.test(error.message));
|
|
526
|
-
if (!aborted) {
|
|
527
|
-
input.onError?.(error);
|
|
528
|
-
flushOrphans();
|
|
529
|
-
write('reasoning', {
|
|
530
|
-
sessionId,
|
|
531
|
-
requestId,
|
|
532
|
-
reasoning: '',
|
|
533
|
-
isComplete: true,
|
|
534
|
-
timestamp: new Date().toISOString(),
|
|
535
|
-
});
|
|
536
|
-
// Same wire shape as the Node runtime's `sendSSEError`: the
|
|
537
|
-
// classification is redacted here — the one place an LLM failure
|
|
538
|
-
// becomes bytes on a client's wire.
|
|
539
|
-
const classified = classifyLlmError(error, {
|
|
540
|
-
byoProvider: input.byoProvider,
|
|
541
|
-
});
|
|
542
|
-
if (isOperatorFault(classified))
|
|
543
|
-
input.log?.(
|
|
544
|
-
`OPERATOR FAULT (platform ${classified.kind}) on turn ${requestId}: ${classified.detail}`,
|
|
545
|
-
);
|
|
546
|
-
const safe = redactOperatorFault(classified);
|
|
547
|
-
write('error', {
|
|
548
|
-
error: safe.message,
|
|
549
|
-
kind: safe.kind,
|
|
550
|
-
source: safe.source,
|
|
551
|
-
...(safe.provider && { provider: safe.provider }),
|
|
552
|
-
...(safe.providerLabel && { providerLabel: safe.providerLabel }),
|
|
553
|
-
...(safe.status !== undefined && { status: safe.status }),
|
|
554
|
-
retryable: safe.retryable,
|
|
555
|
-
detail: safe.detail,
|
|
556
|
-
sessionId,
|
|
557
|
-
requestId,
|
|
558
|
-
timestamp: new Date().toISOString(),
|
|
559
|
-
});
|
|
560
|
-
}
|
|
561
|
-
write('done', {}, { force: true });
|
|
562
|
-
} finally {
|
|
563
|
-
finish();
|
|
705
|
+
closed = true;
|
|
564
706
|
}
|
|
565
|
-
};
|
|
566
|
-
void run();
|
|
707
|
+
}, input.heartbeatMs ?? 15_000);
|
|
567
708
|
},
|
|
568
709
|
cancel: () => {
|
|
569
710
|
closed = true;
|
|
570
711
|
if (heartbeat) clearInterval(heartbeat);
|
|
571
|
-
|
|
712
|
+
unsubscribe?.();
|
|
713
|
+
input.onCancel?.();
|
|
572
714
|
},
|
|
573
715
|
});
|
|
574
716
|
}
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* One-shot turn stream (no durability): a private buffer, the producer
|
|
720
|
+
* running behind it, and a subscriber from the first frame. Kept for the
|
|
721
|
+
* unit tests and hosts that do not persist runs. Cancelling the stream
|
|
722
|
+
* aborts the turn, as the pre-durable runtime did.
|
|
723
|
+
*/
|
|
724
|
+
export function createSseTurnStream(
|
|
725
|
+
input: SseTurnRunnerInput,
|
|
726
|
+
): ReadableStream<Uint8Array> {
|
|
727
|
+
const buffer = new RunBuffer({
|
|
728
|
+
flushMs: 60_000,
|
|
729
|
+
flushBytes: Number.MAX_SAFE_INTEGER,
|
|
730
|
+
onPack: () => undefined,
|
|
731
|
+
setTimer: () => null,
|
|
732
|
+
clearTimer: () => undefined,
|
|
733
|
+
});
|
|
734
|
+
const stream = createSseSubscriberStream({
|
|
735
|
+
replay: [],
|
|
736
|
+
buffer,
|
|
737
|
+
onCancel: () => input.abortController.abort(),
|
|
738
|
+
});
|
|
739
|
+
void runTurnFrames({ ...input, sink: buffer }).finally(() => buffer.close());
|
|
740
|
+
return stream;
|
|
741
|
+
}
|