@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.
Files changed (48) hide show
  1. package/README.md +5 -2
  2. package/package.json +1 -1
  3. package/src/core/context-budget.ts +201 -0
  4. package/src/core/context-window.ts +289 -0
  5. package/src/core/env.ts +29 -0
  6. package/src/core/main-agent-types.ts +46 -0
  7. package/src/core/main-agent.ts +86 -9
  8. package/src/core/middlewares/context-guard.ts +270 -0
  9. package/src/core/middlewares/result-cap.ts +180 -0
  10. package/src/core/middlewares/summarization.ts +87 -6
  11. package/src/core/middlewares/tool-marks.ts +209 -0
  12. package/src/core/openrouter-pricing.ts +42 -1
  13. package/src/core/read-result-tool.ts +77 -0
  14. package/src/core/runtime-context.ts +7 -0
  15. package/src/core/sub-agent-fallback.ts +20 -3
  16. package/src/core/wrap-plugin-tool.ts +24 -3
  17. package/src/do/ambient.ts +17 -0
  18. package/src/do/contracts.ts +227 -0
  19. package/src/do/result-store-test-do.ts +88 -0
  20. package/src/do/result-store.ts +339 -0
  21. package/src/do/run-buffer.ts +204 -0
  22. package/src/do/run-coordinator.ts +572 -0
  23. package/src/do/run-request.ts +52 -0
  24. package/src/do/run-store-test-do.ts +130 -0
  25. package/src/do/run-store.ts +604 -0
  26. package/src/do/session-id-migration.ts +85 -0
  27. package/src/do/speaker-prefix.ts +37 -0
  28. package/src/do/sse-stream.ts +567 -400
  29. package/src/do/transcript.ts +238 -0
  30. package/src/do/turn-body.ts +10 -0
  31. package/src/do/user-oracle-do.ts +875 -146
  32. package/src/matrix/gateway-do.ts +230 -29
  33. package/src/matrix/group-chat-summarizer.ts +107 -0
  34. package/src/matrix/group-chat.ts +1163 -0
  35. package/src/matrix/inbox-store.ts +23 -3
  36. package/src/matrix/ingest.ts +54 -11
  37. package/src/matrix/reply-chain.ts +34 -25
  38. package/src/plugin-api/tool-helper.ts +9 -1
  39. package/src/plugin-api/types.ts +86 -0
  40. package/src/plugins/index.ts +3 -0
  41. package/src/plugins/matrix-group-chats/README.md +70 -0
  42. package/src/plugins/matrix-group-chats/index.ts +132 -0
  43. package/src/plugins/matrix-group-chats/tools.ts +172 -0
  44. package/src/plugins/tasks/tasks-tools.ts +4 -0
  45. package/src/shell/app.ts +66 -0
  46. package/src/sqlite/sqlite-saver.ts +81 -0
  47. package/src/tasks/scheduler.ts +134 -3
  48. package/src/tasks/store.ts +22 -0
@@ -1,30 +1,58 @@
1
1
  /**
2
- * LangGraph `streamEvents` → Server-Sent Events, as a Web `ReadableStream`.
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
- * (`message`, `reasoning`, `tool_call`, `action_call`, `error`, `done`),
7
- * the same "instant Thinking…" ack, the same 15 s heartbeat comment.
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
- * On Workers the response body is a stream we write into; the client
10
- * disconnecting cancels the stream, which aborts the run.
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 { AIMessageChunk, ToolMessage } from '@langchain/core/messages';
18
- import { isToolMessage } from '@langchain/core/messages';
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 SseTurnRunnerInput {
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
- export function formatSSE(eventType: string, data: unknown): string {
64
- return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`;
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' | 'error';
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
- export function createSseTurnStream(
181
- input: SseTurnRunnerInput,
182
- ): ReadableStream<Uint8Array> {
183
- const encoder = new TextEncoder();
184
- const { sessionId, requestId, abortController } = input;
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(encoder.encode(': heartbeat\n\n'));
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
- }, 15_000);
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
- // Tool calls the main agent's model produced, by call id, until their
256
- // tool run starts. A call whose arguments fail the tool's schema never
257
- // starts (LangChain validates before the tool's start callback), so
258
- // `on_tool_start` / `on_tool_end` never fire for it; the error tool
259
- // message the tools node returns is the only trace. Anything still
260
- // recorded when that node ends is reported from that message.
261
- const pendingCalls = new Map<
262
- string,
263
- { toolName: string; args: Record<string, unknown> }
264
- >();
265
- const sameArgs = (a: unknown, b: unknown) => {
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
- return JSON.stringify(a) === JSON.stringify(b);
703
+ controller.enqueue(encoder.encode(': heartbeat\n\n'));
268
704
  } catch {
269
- return false;
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
- abortController.abort();
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
+ }