@ixo/oracle-runtime-workers 0.4.0 → 0.9.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.
@@ -1,4 +1,4 @@
1
- import type { BotStatus } from '@ixo/matrix-bot-workers-sdk';
1
+ import type { BotStatus, EncryptedFileInfo } from '@ixo/matrix-bot-workers-sdk';
2
2
  import type { AttachmentInput } from '../attachments/types';
3
3
  import type { RealtimeStatus } from '../realtime/realtime-endpoint';
4
4
  /**
@@ -107,6 +107,8 @@ export interface OracleWorkerEnv {
107
107
  /** Display markup for `GET /models` prices (default 1.6). */
108
108
  MODEL_PRICE_MARKUP?: string;
109
109
  MAIN_REASONING_EFFORT?: string;
110
+ /** LangGraph steps one turn may take before `GraphRecursionError` (default 600; Node hard-codes 200). */
111
+ TURN_RECURSION_LIMIT?: string;
110
112
  /** Platform model provider. Default `openrouter`; `nebius` for self-hosted. */
111
113
  LLM_PROVIDER?: 'openrouter' | 'nebius';
112
114
  /** Nebius Token Factory API key — required when `LLM_PROVIDER=nebius`. */
@@ -205,6 +207,21 @@ export interface TurnResult {
205
207
  messageId?: string;
206
208
  /** Tool calls made during the turn (name + summarized status), for logging. */
207
209
  toolCalls: Array<{ name: string; status: 'done' | 'error' }>;
210
+ /** The stored reply of a turn that had already finished (a gateway asked again after a reset). */
211
+ replayed?: boolean;
212
+ }
213
+
214
+ /**
215
+ * Prefix of the error a user object raises when a gateway asks again about a
216
+ * room message whose turn the object itself lost mid-run (see
217
+ * `src/do/matrix-turn-ledger.ts`): not re-run, the user is told to retry.
218
+ */
219
+ export const TURN_INTERRUPTED_MARKER =
220
+ 'turn interrupted by a runtime reset before it finished; not re-run';
221
+
222
+ export function isInterruptedTurnError(err: unknown): boolean {
223
+ const message = err instanceof Error ? err.message : String(err);
224
+ return message.includes(TURN_INTERRUPTED_MARKER);
208
225
  }
209
226
 
210
227
  /** Row shape served by `GET /sessions`. */
@@ -341,6 +358,10 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
341
358
  resetWorkingCopy(): Promise<{ reloadedFromOwnerStore: boolean }>;
342
359
  /** Raw upstream schema of the memory search tool as fetched right now. */
343
360
  debugMemorySchema(userDid: string): Promise<MemorySchemaDebug>;
361
+ /** Forcibly reset the object like a platform host drain (debug routes only). The call itself rejects. */
362
+ debugAbortObject(): Promise<void>;
363
+ /** Forget when the last re-authorise prompt was posted (debug routes only). */
364
+ debugResetReauthThrottle(): Promise<void>;
344
365
  /** Raw session row for `GET /debug/sessions/:id` (null when unknown). */
345
366
  debugSession(
346
367
  userDid: string,
@@ -354,7 +375,8 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
354
375
  * Diagnostics: the object's current alarm and the user's task records — the
355
376
  * pair that answers "is this task going to fire, and when?".
356
377
  */
357
- tasksStatus(): Promise<{
378
+ /** Task records, open runs and the alarm; boots the object when cold (`userDid` = the caller). */
379
+ tasksStatus(userDid?: string): Promise<{
358
380
  now: number;
359
381
  alarm: number | null;
360
382
  schedulerActive: boolean;
@@ -367,6 +389,15 @@ export interface UserOracleObject extends Rpc.DurableObjectBranded {
367
389
  consecutiveFailures: number;
368
390
  deliveryRoomId: string | null;
369
391
  }>;
392
+ /** Runs no incarnation has finished: what an alarm will re-deliver or close. */
393
+ openRuns: Array<{
394
+ runId: string;
395
+ taskId: string;
396
+ startedAt: string;
397
+ state: 'running' | 'delivering';
398
+ attempts: number;
399
+ retryAt: number | null;
400
+ }>;
370
401
  }>;
371
402
  }
372
403
 
@@ -388,17 +419,21 @@ export interface CreateRoomOptions {
388
419
  * rest is the oracle's.
389
420
  */
390
421
  export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
391
- /** Bytes of a media event (decrypted when it carries an encrypted `file`); null when unknown. */
392
- downloadEventMedia(
422
+ /**
423
+ * A media event's content as a stream, AS STORED: in an E2EE room the
424
+ * ciphertext plus the `EncryptedFile` fields to pipe through the SDK's
425
+ * `createAttachmentDecryptor` on the caller's side (a stream that errors
426
+ * on the far side of an RPC only surfaces here as a disconnect, so the
427
+ * hash check belongs to the consumer). Null when unknown or redacted.
428
+ */
429
+ downloadEventMediaStream(
393
430
  roomId: string,
394
431
  eventId: string,
395
- ): Promise<{
396
- bytes: Uint8Array;
397
- mimetype?: string;
398
- filename?: string;
399
- } | null>;
400
- /** Authenticated media download of an `mxc://` URI. */
401
- downloadMxcMedia(mxc: string): Promise<Uint8Array>;
432
+ ): Promise<MediaStream | null>;
433
+ /** Authenticated media download of an `mxc://` URI, as a stream. */
434
+ downloadMxcMediaStream(mxc: string): Promise<ReadableStream<Uint8Array>>;
435
+ /** Forcibly reset the gateway object like a platform host drain (debug routes only). The call itself rejects. */
436
+ debugAbortObject(): Promise<void>;
402
437
  /** Stop the bot (operator/debug; `ensureStarted`/cron brings it back). */
403
438
  stop(): Promise<void>;
404
439
  /** Idempotent — starts the bot + keep-alive alarm if not running. */
@@ -451,8 +486,18 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
451
486
  txnId?: string;
452
487
  },
453
488
  ): Promise<string>;
454
- /** Send an arbitrary timeline event (`content` JSON-encoded). Returns the event id. */
455
- sendEvent(roomId: string, type: string, content: JsonString): Promise<string>;
489
+ /**
490
+ * Send an arbitrary timeline event (`content` JSON-encoded). Returns the
491
+ * event id. `txnId` pins the transaction id, as for `sendText`: the
492
+ * homeserver deduplicates by it per device, so a send retried after a lost
493
+ * response returns the original event instead of posting a second one.
494
+ */
495
+ sendEvent(
496
+ roomId: string,
497
+ type: string,
498
+ content: JsonString,
499
+ opts?: { txnId?: string },
500
+ ): Promise<string>;
456
501
  /** Resolve the canonical user↔oracle room for a user DID (alias lookup, cached). */
457
502
  resolveUserRoom(
458
503
  userDid: string,
@@ -465,17 +510,23 @@ export interface MatrixGatewayObject extends Rpc.DurableObjectBranded {
465
510
  * Returns the new event id. Wire-compatible with the Node runtime so
466
511
  * existing users migrate transparently.
467
512
  */
468
- uploadUserSnapshot(
513
+ uploadUserSnapshotStream(
469
514
  userDid: string,
470
515
  storageKey: string,
471
- bytes: Uint8Array,
516
+ /** The gzipped file; encrypted chunk by chunk in an E2EE room, never buffered. */
517
+ body: ReadableStream<Uint8Array>,
472
518
  filename: string,
519
+ /** Exact byte length of `body` (the homeserver needs a Content-Length). */
520
+ size: number,
473
521
  ): Promise<{ eventId: string }>;
474
- /** Download the latest snapshot for `storageKey`, or null when none exists. */
475
- downloadUserSnapshot(
522
+ /**
523
+ * The latest snapshot for `storageKey` as a stream, as stored (see
524
+ * `downloadEventMediaStream`), or null when none exists.
525
+ */
526
+ downloadUserSnapshotStream(
476
527
  userDid: string,
477
528
  storageKey: string,
478
- ): Promise<{ bytes: Uint8Array; eventId: string } | null>;
529
+ ): Promise<SnapshotStream | null>;
479
530
  /** Read a room state event's content, JSON-encoded (state is never E2EE). Null when absent. */
480
531
  getRoomStateEvent(
481
532
  roomId: string,
@@ -533,10 +584,32 @@ export interface OutboxRow {
533
584
  }
534
585
 
535
586
  /** `MatrixBotDO.status()` plus the gateway's own turn bookkeeping. */
587
+ /**
588
+ * Media handed across the Durable Object boundary as a stream, as stored on
589
+ * the homeserver: `file` is present when the event carried an encrypted
590
+ * attachment — pipe `stream` through the SDK's `createAttachmentDecryptor`
591
+ * with it; without `file` the stream is the plain upload. `size` is the
592
+ * sender's `info.size` when it set one.
593
+ */
594
+ export interface MediaStream {
595
+ stream: ReadableStream<Uint8Array>;
596
+ file?: EncryptedFileInfo;
597
+ size?: number;
598
+ mimetype?: string;
599
+ filename?: string;
600
+ }
601
+
602
+ /** `MediaStream` of a user's snapshot, plus the media event that carries it. */
603
+ export interface SnapshotStream extends MediaStream {
604
+ eventId: string;
605
+ }
606
+
536
607
  export interface GatewayStatus extends BotStatus {
537
608
  turns: { inFlight: number; waiting: number };
538
609
  /** Debounce buffers waiting to become turns. */
539
610
  ingestPending: number;
611
+ /** Room messages whose turn has not ended yet (durable; replayed after a reset). */
612
+ inbox: number;
540
613
  }
541
614
 
542
615
  /** Derive the durable-object name for a user's object. */
@@ -0,0 +1,126 @@
1
+ /**
2
+ * What the user object remembers about each room message it was asked to
3
+ * answer, so the gateway can ask again after a reset without the turn ever
4
+ * running twice (`src/matrix/inbox-store.ts` is the asking side).
5
+ *
6
+ * One row per Matrix event id, in the user's own SQLite next to the
7
+ * checkpoints: `started_at` is written before the turn runs, `reply_text` +
8
+ * `answered_at` when it has finished. A second request for the same event:
9
+ *
10
+ * - answered → the stored text is returned; no model, no tools;
11
+ * - running in this instance → the caller attaches to the running turn
12
+ * (kept in memory by the object, not here);
13
+ * - started, not answered, not running → the object itself reset mid-turn;
14
+ * tools may have run, so the turn is NOT re-run: the request is refused
15
+ * with `TURN_INTERRUPTED_MARKER` and the gateway tells the user to try
16
+ * again — the same thing that happens today when the object dies while
17
+ * the gateway is alive;
18
+ * - unknown → run once.
19
+ *
20
+ * Rows older than `RETENTION_MS` are pruned at setup; a replay that late
21
+ * cannot happen (the gateway inbox gives up after `MAX_TURN_REPLAYS`).
22
+ */
23
+ import type { DoSqliteDatabase } from '../sqlite/database';
24
+
25
+ export interface MatrixTurnRecord {
26
+ eventId: string;
27
+ sessionId: string;
28
+ requestId: string;
29
+ startedAt: string;
30
+ answeredAt: string | null;
31
+ replyText: string | null;
32
+ }
33
+
34
+ export type MatrixTurnDecision = 'run' | 'answered' | 'interrupted';
35
+
36
+ /** Rows are kept for a week; the gateway never replays anything that old. */
37
+ export const RETENTION_MS = 7 * 24 * 3600 * 1000;
38
+
39
+ export function decideMatrixTurn(
40
+ existing: MatrixTurnRecord | undefined,
41
+ ): MatrixTurnDecision {
42
+ if (!existing) return 'run';
43
+ return existing.replyText !== null ? 'answered' : 'interrupted';
44
+ }
45
+
46
+ type LedgerRow = {
47
+ event_id: string;
48
+ session_id: string;
49
+ request_id: string;
50
+ started_at: string;
51
+ answered_at: string | null;
52
+ reply_text: string | null;
53
+ } & Record<string, string | null>;
54
+
55
+ export class MatrixTurnLedger {
56
+ private setupPromise: Promise<void> | undefined;
57
+
58
+ constructor(
59
+ readonly db: DoSqliteDatabase,
60
+ private readonly now: () => number = () => Date.now(),
61
+ ) {}
62
+
63
+ setup(): Promise<void> {
64
+ this.setupPromise ??= this.doSetup().catch((error: unknown) => {
65
+ this.setupPromise = undefined;
66
+ throw error;
67
+ });
68
+ return this.setupPromise;
69
+ }
70
+
71
+ private async doSetup(): Promise<void> {
72
+ await this.db.run(`
73
+ CREATE TABLE IF NOT EXISTS matrix_turns (
74
+ event_id TEXT PRIMARY KEY,
75
+ session_id TEXT NOT NULL,
76
+ request_id TEXT NOT NULL,
77
+ started_at TEXT NOT NULL,
78
+ answered_at TEXT,
79
+ reply_text TEXT
80
+ )`);
81
+ await this.db.run(`DELETE FROM matrix_turns WHERE started_at < ?`, [
82
+ new Date(this.now() - RETENTION_MS).toISOString(),
83
+ ]);
84
+ }
85
+
86
+ async get(eventId: string): Promise<MatrixTurnRecord | undefined> {
87
+ await this.setup();
88
+ const row = await this.db.get<LedgerRow>(
89
+ `SELECT event_id, session_id, request_id, started_at, answered_at, reply_text
90
+ FROM matrix_turns WHERE event_id = ?`,
91
+ [eventId],
92
+ );
93
+ if (!row) return undefined;
94
+ return {
95
+ eventId: row.event_id,
96
+ sessionId: row.session_id,
97
+ requestId: row.request_id,
98
+ startedAt: row.started_at,
99
+ answeredAt: row.answered_at,
100
+ replyText: row.reply_text,
101
+ };
102
+ }
103
+
104
+ /** Mark the turn as started. Durable before any model or tool runs. */
105
+ async start(
106
+ eventId: string,
107
+ sessionId: string,
108
+ requestId: string,
109
+ ): Promise<void> {
110
+ await this.setup();
111
+ await this.db.run(
112
+ `INSERT OR IGNORE INTO matrix_turns (event_id, session_id, request_id, started_at)
113
+ VALUES (?, ?, ?, ?)`,
114
+ [eventId, sessionId, requestId, new Date(this.now()).toISOString()],
115
+ );
116
+ }
117
+
118
+ /** Record the reply; from now on the event is answered, never re-run. */
119
+ async answer(eventId: string, replyText: string): Promise<void> {
120
+ await this.setup();
121
+ await this.db.run(
122
+ `UPDATE matrix_turns SET reply_text = ?, answered_at = ? WHERE event_id = ?`,
123
+ [replyText, new Date(this.now()).toISOString(), eventId],
124
+ );
125
+ }
126
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * The `ixo.oracle.delegation_required` prompt: when a room message arrives
3
+ * and the user has no usable delegation, the object posts an event the web
4
+ * app listens for to open its "authorize for Matrix" modal in place. It is
5
+ * throttled per user (`UCAN_REAUTH_PROMPT_THROTTLE_SECONDS`) so the room is
6
+ * not spammed while the user is away.
7
+ *
8
+ * The throttle is stamped only AFTER the homeserver accepted the event. The
9
+ * earlier order — stamp, then send — turned a send lost to a gateway restart
10
+ * into six hours of silence: the user kept messaging an oracle that could
11
+ * not act, and nothing told them why. The send is retried across a restart
12
+ * under one transaction id, so a response lost after the homeserver accepted
13
+ * the event is deduplicated rather than posted twice; a second turn arriving
14
+ * while a prompt is in flight joins it instead of posting another.
15
+ */
16
+ import { retryTxnId } from '../matrix/txn-id';
17
+ import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
18
+
19
+ export interface ReauthPromptDeps {
20
+ throttleMs: number;
21
+ /** When the last prompt was accepted (undefined = never). */
22
+ getStamp(): Promise<number | undefined>;
23
+ setStamp(at: number): Promise<void>;
24
+ /** Post the event under the transaction id; resolves to its event id. */
25
+ send(roomId: string, txnId: string): Promise<string>;
26
+ /** Keeps the (un-awaited) prompt alive past the request that started it: `ctx.waitUntil`. */
27
+ keepAlive(work: Promise<unknown>): void;
28
+ log(message: string): void;
29
+ warn(message: string): void;
30
+ now?: () => number;
31
+ retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
32
+ }
33
+
34
+ const errorText = (err: unknown): string =>
35
+ err instanceof Error ? err.message : String(err);
36
+
37
+ export class ReauthPrompter {
38
+ private inFlight: Promise<void> | null = null;
39
+
40
+ constructor(private readonly deps: ReauthPromptDeps) {}
41
+
42
+ /** Prompt unless throttled or already in flight. Never rejects. */
43
+ prompt(userDid: string, roomId: string): Promise<void> {
44
+ if (this.inFlight) return this.inFlight;
45
+ const run = this.run(userDid, roomId)
46
+ .catch((err: unknown) => {
47
+ this.deps.warn(
48
+ `[user-do] delegation-required event failed for ${userDid}: ${errorText(err)}`,
49
+ );
50
+ })
51
+ .finally(() => {
52
+ this.inFlight = null;
53
+ });
54
+ this.inFlight = run;
55
+ this.deps.keepAlive(run);
56
+ return run;
57
+ }
58
+
59
+ private async run(userDid: string, roomId: string): Promise<void> {
60
+ const now = this.deps.now?.() ?? Date.now();
61
+ const last = await this.deps.getStamp();
62
+ if (last !== undefined && now - last < this.deps.throttleMs) return;
63
+ const txnId = retryTxnId('reauth');
64
+ const eventId = await retryGateway(() => this.deps.send(roomId, txnId), {
65
+ ...this.deps.retry,
66
+ onRetry: (err, attempt, delayMs) =>
67
+ this.deps.warn(
68
+ `[user-do] delegation-required event send failed (attempt ${attempt}) — retrying in ${delayMs} ms: ${errorText(err)}`,
69
+ ),
70
+ });
71
+ await this.deps.setStamp(this.deps.now?.() ?? Date.now());
72
+ this.deps.log(
73
+ `[user-do] sent ixo.oracle.delegation_required (${eventId}) to ${roomId} for ${userDid}`,
74
+ );
75
+ }
76
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The room mirror of HTTP chats: every Portal turn is replayed into the
3
+ * user's oracle room as a thread under the session's marker event, the user
4
+ * message when the turn starts and the reply when it ends (Node's
5
+ * `MessagesService`). Node fires these and forgets them; on Workers the
6
+ * gateway object is replaced on every deploy and can be drained at any time,
7
+ * so a mirror sent at that instant fails with a transport error and, unless
8
+ * retried, is gone for good — the room transcript then disagrees with the
9
+ * Portal's for ever.
10
+ *
11
+ * Two rules make the retry safe:
12
+ * - each send carries a transaction id derived from the session, the request
13
+ * and the author, so a send whose response was lost is deduplicated by the
14
+ * homeserver rather than posted twice;
15
+ * - sends are serialised per session (parallel across sessions), so a reply
16
+ * being retried can never overtake the message it answers, and a later
17
+ * turn's mirrors queue behind an earlier turn's.
18
+ */
19
+ import { matrixTxnId } from '../matrix/txn-id';
20
+ import { retryGateway, type RetryGatewayOptions } from './gateway-retry';
21
+
22
+ export type MirrorAuthor = 'user' | 'oracle';
23
+
24
+ export function mirrorTxnId(
25
+ sessionId: string,
26
+ requestId: string,
27
+ who: MirrorAuthor,
28
+ ): string {
29
+ return matrixTxnId(
30
+ 'replay',
31
+ sessionId,
32
+ requestId,
33
+ who === 'user' ? 'u' : 'o',
34
+ );
35
+ }
36
+
37
+ export interface MirrorSend {
38
+ roomId: string;
39
+ body: string;
40
+ formattedBody?: string;
41
+ /** The session's marker event — the thread the mirror is posted under. */
42
+ threadId: string;
43
+ txnId: string;
44
+ }
45
+
46
+ export interface RoomMirrorDeps {
47
+ sendText(send: MirrorSend): Promise<string>;
48
+ /** Keeps the (un-awaited) send alive past the request that started it: `ctx.waitUntil`. */
49
+ keepAlive(work: Promise<unknown>): void;
50
+ log(message: string): void;
51
+ warn(message: string): void;
52
+ /** Retry policy override (tests shrink the delays). */
53
+ retry?: Pick<RetryGatewayOptions, 'delaysMs' | 'sleep' | 'isTransient'>;
54
+ }
55
+
56
+ const errorText = (err: unknown): string =>
57
+ err instanceof Error ? err.message : String(err);
58
+
59
+ export class RoomMirror {
60
+ /** Per session: the tail of its send chain. Removed once the chain drains. */
61
+ private readonly chains = new Map<string, Promise<void>>();
62
+
63
+ constructor(private readonly deps: RoomMirrorDeps) {}
64
+
65
+ /** Sessions with a mirror still in flight. */
66
+ get pending(): number {
67
+ return this.chains.size;
68
+ }
69
+
70
+ /**
71
+ * Queue one mirror behind the session's previous ones. `prepare` runs when
72
+ * the session's earlier mirrors have settled and resolves what to send
73
+ * (`null` = nothing to mirror, e.g. no room). Never rejects: a mirror that
74
+ * cannot be posted is logged and the chain moves on.
75
+ */
76
+ enqueue(
77
+ sessionId: string,
78
+ prepare: () => Promise<MirrorSend | null>,
79
+ label: string,
80
+ ): Promise<void> {
81
+ const previous = this.chains.get(sessionId) ?? Promise.resolve();
82
+ const tracked: Promise<void> = previous
83
+ .then(() => prepare())
84
+ .then((send) => (send ? this.send(send, label) : undefined))
85
+ .catch((err: unknown) => {
86
+ this.deps.warn(
87
+ `[user-do] Matrix replay (${label}) failed — session=${sessionId}: ${errorText(err)}`,
88
+ );
89
+ })
90
+ .finally(() => {
91
+ if (this.chains.get(sessionId) === tracked)
92
+ this.chains.delete(sessionId);
93
+ });
94
+ this.chains.set(sessionId, tracked);
95
+ this.deps.keepAlive(tracked);
96
+ return tracked;
97
+ }
98
+
99
+ private async send(send: MirrorSend, label: string): Promise<void> {
100
+ const eventId = await retryGateway(() => this.deps.sendText(send), {
101
+ ...this.deps.retry,
102
+ onRetry: (err, attempt, delayMs) =>
103
+ this.deps.warn(
104
+ `[user-do] Matrix replay (${label}) send failed (attempt ${attempt}) — retrying in ${delayMs} ms with the same transaction id: ${errorText(err)}`,
105
+ ),
106
+ });
107
+ this.deps.log(
108
+ `[user-do] Matrix replay (${label}) → ${eventId} in thread ${send.threadId}`,
109
+ );
110
+ }
111
+ }
@@ -15,6 +15,7 @@ import {
15
15
  redactOperatorFault,
16
16
  } from '../llm/provider-error';
17
17
  import type { AIMessageChunk, ToolMessage } from '@langchain/core/messages';
18
+ import { isToolMessage } from '@langchain/core/messages';
18
19
 
19
20
  export interface SseTurnRunnerInput {
20
21
  /** `agent.streamEvents(stateInput, config)` async iterable. */
@@ -75,9 +76,30 @@ interface ToolCallPayload {
75
76
  requestId: string;
76
77
  toolName: string;
77
78
  args: Record<string, unknown>;
78
- status: 'isRunning' | 'done';
79
+ status: 'isRunning' | 'done' | 'error';
79
80
  output?: string;
80
81
  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');
81
103
  }
82
104
 
83
105
  interface ActionCallPayload {
@@ -230,6 +252,32 @@ export function createSseTurnStream(
230
252
  toolCallMap.clear();
231
253
  };
232
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) => {
266
+ try {
267
+ return JSON.stringify(a) === JSON.stringify(b);
268
+ } 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
+
233
281
  const run = async () => {
234
282
  let fullContent = '';
235
283
  try {
@@ -240,12 +288,59 @@ export function createSseTurnStream(
240
288
  run_id: string;
241
289
  name?: string;
242
290
  data: unknown;
291
+ metadata?: Record<string, unknown>;
292
+ tags?: string[];
243
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
+ }
244
338
  if (evt.event === 'on_tool_start') {
245
339
  const toolName = evt.name ?? 'tool';
246
340
  const args = extractToolArgs(
247
341
  (evt.data as { input?: unknown })?.input,
248
342
  );
343
+ forgetStartedCall(toolName, args);
249
344
  if (input.agActionNames?.has(toolName)) {
250
345
  const payload: ActionCallPayload = {
251
346
  requestId,
@@ -295,10 +390,16 @@ export function createSseTurnStream(
295
390
  }
296
391
  const tool = toolCallMap.get(evt.run_id);
297
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';
298
398
  write('tool_call', {
299
399
  ...tool,
300
- status: 'done',
400
+ status: failed ? 'error' : 'done',
301
401
  output: text,
402
+ ...(failed && { error: text || 'Tool failed' }),
302
403
  eventId: evt.run_id,
303
404
  args: {
304
405
  ...tool.args,
@@ -342,6 +443,7 @@ export function createSseTurnStream(
342
443
  continue;
343
444
  }
344
445
  if (evt.event === 'on_chat_model_stream') {
446
+ if (isInternalModelEvent(evt)) continue;
345
447
  const chunk = (evt.data as { chunk?: AIMessageChunk })?.chunk;
346
448
  if (!chunk) continue;
347
449
  const raw = chunk.additional_kwargs?.__raw_response as