@firtoz/chat-agent 1.0.0 → 2.0.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,3 +1,4 @@
1
+ import { exhaustiveGuard } from "@firtoz/maybe-error";
1
2
  import { OpenRouter } from "@openrouter/sdk";
2
3
 
3
4
  // OpenRouter SDK message types (simplified for our use)
@@ -5,7 +6,7 @@ type ORToolCall = {
5
6
  id: string;
6
7
  type: "function";
7
8
  function: { name: string; arguments: string };
8
- };
9
+ } & Record<string, unknown>;
9
10
 
10
11
  type ORSystemMessage = { role: "system"; content: string };
11
12
  type ORUserMessage = { role: "user"; content: string };
@@ -39,6 +40,7 @@ import {
39
40
  type ToolDefinition,
40
41
  type ToolMessage,
41
42
  type UserMessage,
43
+ type SendMessagePayload,
42
44
  } from "./chat-messages";
43
45
 
44
46
  // Re-export types for external use
@@ -52,6 +54,11 @@ export type {
52
54
  ToolDefinition,
53
55
  ToolMessage,
54
56
  UserMessage,
57
+ ToolNeedsApprovalFn,
58
+ SendMessagePayload,
59
+ SendMessageTrigger,
60
+ ToolApprovalResponsePayload,
61
+ ToolApprovalRequestMessage,
55
62
  } from "./chat-messages";
56
63
  export { defineTool } from "./chat-messages";
57
64
 
@@ -73,6 +80,72 @@ const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
73
80
  /** Age threshold for cleaning up completed streams (24 hours) */
74
81
  const CLEANUP_AGE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
75
82
 
83
+ /** Default max length for persisted tool message `content` (characters) */
84
+ const DEFAULT_MAX_TOOL_CONTENT_CHARS = 200_000;
85
+
86
+ /** OpenAI stream keys on each tool_calls[] element (camelCase or snake_case) */
87
+ const STREAM_TOOL_TOP_KEYS = new Set(["index", "id", "type", "function"]);
88
+
89
+ function getRawToolCallDeltaEntry(
90
+ chunk: unknown,
91
+ index: number,
92
+ ): Record<string, unknown> | undefined {
93
+ const c = chunk as {
94
+ choices?: Array<{ delta?: Record<string, unknown> }>;
95
+ };
96
+ const delta = c.choices?.[0]?.delta;
97
+ if (!delta) {
98
+ return undefined;
99
+ }
100
+ const list = (delta.tool_calls ?? delta.toolCalls) as unknown;
101
+ if (!Array.isArray(list) || index < 0 || index >= list.length) {
102
+ return undefined;
103
+ }
104
+ const raw = list[index];
105
+ if (!raw || typeof raw !== "object") {
106
+ return undefined;
107
+ }
108
+ return raw as Record<string, unknown>;
109
+ }
110
+
111
+ function extractProviderMetadataFromRawToolPart(
112
+ raw: Record<string, unknown>,
113
+ ): Record<string, unknown> | undefined {
114
+ const meta: Record<string, unknown> = {};
115
+ for (const [k, v] of Object.entries(raw)) {
116
+ if (STREAM_TOOL_TOP_KEYS.has(k)) {
117
+ continue;
118
+ }
119
+ meta[k] = v;
120
+ }
121
+ const fn = raw.function;
122
+ if (fn && typeof fn === "object" && !Array.isArray(fn)) {
123
+ const f = fn as Record<string, unknown>;
124
+ const fnExtra: Record<string, unknown> = {};
125
+ for (const [k, v] of Object.entries(f)) {
126
+ if (k === "name" || k === "arguments") {
127
+ continue;
128
+ }
129
+ fnExtra[k] = v;
130
+ }
131
+ if (Object.keys(fnExtra).length > 0) {
132
+ meta.function = fnExtra;
133
+ }
134
+ }
135
+ return Object.keys(meta).length > 0 ? meta : undefined;
136
+ }
137
+
138
+ type PendingClientToolAutoContinue = {
139
+ connection: Connection;
140
+ toolCallId: string;
141
+ toolName: string;
142
+ output: unknown;
143
+ };
144
+
145
+ type PendingToolApproval = {
146
+ resolve: (approved: boolean) => void;
147
+ };
148
+
76
149
  // ============================================================================
77
150
  // ChatAgent Class
78
151
  // ============================================================================
@@ -83,8 +156,9 @@ const CLEANUP_AGE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
83
156
  * Features:
84
157
  * - DB-agnostic SQLite persistence in Durable Objects
85
158
  * - Resumable streaming with chunk buffering (like @cloudflare/ai-chat)
159
+ * - Broadcast of stream and history updates to all WebSocket connections (multi-tab)
160
+ * - Serialized chat turns + batched client tool auto-continue
86
161
  * - OpenRouter via Cloudflare AI Gateway
87
- * - Constructor-based initialization pattern
88
162
  *
89
163
  * Subclasses must implement database operations via abstract methods.
90
164
  *
@@ -98,6 +172,12 @@ export abstract class ChatAgentBase<
98
172
  /** In-memory cache of messages */
99
173
  messages: ChatMessage[] = [];
100
174
 
175
+ /**
176
+ * When set, oldest messages are deleted from storage after each save so only the last N remain.
177
+ * Does not change what is sent to the model unless you prune separately.
178
+ */
179
+ protected maxPersistedMessages?: number;
180
+
101
181
  /** Map of message IDs to AbortControllers for request cancellation */
102
182
  private _abortControllers: Map<string, AbortController> = new Map();
103
183
 
@@ -105,6 +185,8 @@ export abstract class ChatAgentBase<
105
185
  private _activeStreamId: string | null = null;
106
186
  /** Message ID being streamed */
107
187
  private _activeMessageId: string | null = null;
188
+ /** True only while the OpenRouter async iterator for the active stream is running */
189
+ private _openRouterStreamLive = false;
108
190
  /** Current chunk index for active stream */
109
191
  private _streamChunkIndex = 0;
110
192
  /** Buffer for chunks pending write */
@@ -124,6 +206,20 @@ export abstract class ChatAgentBase<
124
206
  { name: string; description?: string; parameters?: Record<string, unknown> }
125
207
  > = new Map();
126
208
 
209
+ /** FIFO serialization of chat turns (user sends, tool continuations, etc.) */
210
+ private _turnTail: Promise<void> = Promise.resolve();
211
+ /** Bumped by {@link resetTurnState} to ignore stale async work */
212
+ private _turnGeneration = 0;
213
+
214
+ /** Connection id that last queued an auto-continue after client tools (for multi-tab hints) */
215
+ private _continuationOriginConnectionId: string | null = null;
216
+
217
+ private _pendingClientToolAutoContinue: PendingClientToolAutoContinue[] = [];
218
+ private _clientToolAutoContinueFlushScheduled = false;
219
+
220
+ /** Human-in-the-loop: server tools awaiting `toolApprovalResponse` (not queued — avoids deadlock). */
221
+ private _pendingToolApprovals: Map<string, PendingToolApproval> = new Map();
222
+
127
223
  // ============================================================================
128
224
  // Constructor - Following @cloudflare/ai-chat pattern
129
225
  // ============================================================================
@@ -137,8 +233,9 @@ export abstract class ChatAgentBase<
137
233
  // Load messages from DB
138
234
  this.messages = this.dbLoadMessages();
139
235
 
140
- // Restore any active stream from a previous session
236
+ // Restore any active stream from a previous session (never live after restore)
141
237
  this._restoreActiveStream();
238
+ this._openRouterStreamLive = false;
142
239
 
143
240
  // Wrap onConnect to handle stream resumption
144
241
  const _onConnect = this.onConnect.bind(this);
@@ -146,7 +243,6 @@ export abstract class ChatAgentBase<
146
243
  connection: Connection,
147
244
  connCtx: ConnectionContext,
148
245
  ) => {
149
- // Notify client about active streams that can be resumed
150
246
  if (this._activeStreamId && this._activeMessageId) {
151
247
  this._notifyStreamResuming(connection);
152
248
  }
@@ -256,15 +352,120 @@ export abstract class ChatAgentBase<
256
352
  */
257
353
  protected abstract dbDeleteChunks(streamId: string): void;
258
354
 
355
+ /**
356
+ * Whether stream metadata or chunks exist for this stream id
357
+ */
358
+ protected abstract dbIsStreamKnown(streamId: string): boolean;
359
+
360
+ /**
361
+ * Delete oldest messages until at most `maxMessages` rows remain
362
+ */
363
+ protected abstract dbTrimMessagesToMax(maxMessages: number): void;
364
+
365
+ /**
366
+ * Replace all chat messages (used for client sync / regenerate). Implementations should delete existing rows then insert.
367
+ */
368
+ protected abstract dbReplaceAllMessages(messages: ChatMessage[]): void;
369
+
370
+ // ============================================================================
371
+ // Persistence hooks (override in subclasses)
372
+ // ============================================================================
373
+
374
+ /**
375
+ * Transform a message immediately before it is written to storage.
376
+ * Default: return the message unchanged.
377
+ */
378
+ protected sanitizeMessageForPersistence(msg: ChatMessage): ChatMessage {
379
+ return msg;
380
+ }
381
+
382
+ // ============================================================================
383
+ // Turn coordination (subclasses / host code)
384
+ // ============================================================================
385
+
386
+ /**
387
+ * Resolves after all queued turns have finished and no OpenRouter stream is active.
388
+ */
389
+ protected waitUntilStable(): Promise<void> {
390
+ return this._turnTail.then(async () => {
391
+ while (this._openRouterStreamLive) {
392
+ await new Promise((r) => queueMicrotask(r));
393
+ }
394
+ });
395
+ }
396
+
397
+ /**
398
+ * Abort in-flight generation, clear pending client tool auto-continue batch, and invalidate queued async work.
399
+ * Call from custom clear handlers; {@link clearHistory} path also resets state.
400
+ */
401
+ protected resetTurnState(): void {
402
+ this._turnGeneration++;
403
+ this._pendingClientToolAutoContinue = [];
404
+ this._clientToolAutoContinueFlushScheduled = false;
405
+ this._continuationOriginConnectionId = null;
406
+ for (const p of this._pendingToolApprovals.values()) {
407
+ p.resolve(false);
408
+ }
409
+ this._pendingToolApprovals.clear();
410
+ for (const id of [...this._abortControllers.keys()]) {
411
+ this._cancelRequest(id);
412
+ }
413
+ }
414
+
415
+ /**
416
+ * True when the last assistant message still has tool calls without matching tool role replies.
417
+ */
418
+ protected hasPendingInteraction(): boolean {
419
+ if (this._pendingToolApprovals.size > 0) {
420
+ return true;
421
+ }
422
+ const last = this.messages[this.messages.length - 1];
423
+ if (!last || last.role !== "assistant") {
424
+ return false;
425
+ }
426
+ if (!last.toolCalls?.length) {
427
+ return false;
428
+ }
429
+ const pending = new Set(last.toolCalls.map((t) => t.id));
430
+ for (const m of this.messages) {
431
+ if (m.role === "tool" && pending.has(m.toolCallId)) {
432
+ pending.delete(m.toolCallId);
433
+ }
434
+ }
435
+ return pending.size > 0;
436
+ }
437
+
259
438
  // ============================================================================
260
439
  // Message Persistence
261
440
  // ============================================================================
262
441
 
263
- private _saveMessage(msg: ChatMessage): void {
264
- this.dbSaveMessage(msg);
442
+ private _persistMessage(msg: ChatMessage): void {
443
+ const sanitized = this.sanitizeMessageForPersistence(msg);
444
+ const stored = this._maybeTruncateToolMessageContent(sanitized);
445
+ this.dbSaveMessage(stored);
446
+ const max = this.maxPersistedMessages;
447
+ if (typeof max === "number" && max > 0) {
448
+ this.dbTrimMessagesToMax(max);
449
+ this.messages = this.dbLoadMessages();
450
+ }
451
+ }
452
+
453
+ private _maybeTruncateToolMessageContent(msg: ChatMessage): ChatMessage {
454
+ if (msg.role !== "tool") {
455
+ return msg;
456
+ }
457
+ const content = msg.content;
458
+ if (content.length <= DEFAULT_MAX_TOOL_CONTENT_CHARS) {
459
+ return msg;
460
+ }
461
+ const truncated =
462
+ content.slice(0, DEFAULT_MAX_TOOL_CONTENT_CHARS) +
463
+ `\n… [truncated ${content.length - DEFAULT_MAX_TOOL_CONTENT_CHARS} chars for storage]`;
464
+ return { ...msg, content: truncated };
265
465
  }
266
466
 
267
467
  private _clearMessages(): void {
468
+ this.resetTurnState();
268
469
  this.dbClearAll();
269
470
  this._activeStreamId = null;
270
471
  this._activeMessageId = null;
@@ -314,13 +515,11 @@ export abstract class ChatAgentBase<
314
515
  return;
315
516
  }
316
517
 
317
- connection.send(
318
- JSON.stringify({
319
- type: "streamResuming",
320
- id: this._activeMessageId,
321
- streamId: this._activeStreamId,
322
- }),
323
- );
518
+ this._sendTo(connection, {
519
+ type: "streamResuming",
520
+ id: this._activeMessageId,
521
+ streamId: this._activeStreamId,
522
+ });
324
523
  }
325
524
 
326
525
  // ============================================================================
@@ -396,8 +595,10 @@ export abstract class ChatAgentBase<
396
595
  this.dbUpdateStreamStatus(streamId, "completed");
397
596
 
398
597
  // Save the complete message
399
- this._saveMessage(message);
400
- this.messages.push(message);
598
+ this._persistMessage(message);
599
+ if (typeof this.maxPersistedMessages !== "number") {
600
+ this.messages.push(message);
601
+ }
401
602
 
402
603
  // Clean up stream chunks
403
604
  this.dbDeleteChunks(streamId);
@@ -441,6 +642,30 @@ export abstract class ChatAgentBase<
441
642
  return this.dbGetChunks(streamId);
442
643
  }
443
644
 
645
+ /**
646
+ * Finalize a stream that has buffered chunks but no live OpenRouter reader (e.g. after DO restart).
647
+ */
648
+ private _finalizeOrphanedStreamFromChunks(streamId: string): void {
649
+ if (this._activeStreamId !== streamId || !this._activeMessageId) {
650
+ return;
651
+ }
652
+ const messageId = this._activeMessageId;
653
+ const chunks = this._getStreamChunks(streamId);
654
+ const text = chunks.join("");
655
+ const assistantMessage: AssistantMessage = {
656
+ id: messageId,
657
+ role: "assistant",
658
+ content: text.length > 0 ? text : null,
659
+ createdAt: Date.now(),
660
+ };
661
+ this._completeStreamWithMessage(streamId, assistantMessage);
662
+ this._broadcast({
663
+ type: "messageEnd",
664
+ id: messageId,
665
+ createdAt: assistantMessage.createdAt,
666
+ });
667
+ }
668
+
444
669
  // ============================================================================
445
670
  // Abort Controller Management
446
671
  // ============================================================================
@@ -466,6 +691,62 @@ export abstract class ChatAgentBase<
466
691
  this._abortControllers.delete(id);
467
692
  }
468
693
 
694
+ // ============================================================================
695
+ // Broadcasting
696
+ // ============================================================================
697
+
698
+ private _broadcast(msg: ServerMessage): void {
699
+ this.broadcast(JSON.stringify(msg));
700
+ }
701
+
702
+ private _sendTo(connection: Connection, msg: ServerMessage): void {
703
+ connection.send(JSON.stringify(msg));
704
+ }
705
+
706
+ private _enqueueTurn(fn: () => Promise<void>): Promise<void> {
707
+ const run = this._turnTail.then(fn);
708
+ this._turnTail = run.then(
709
+ () => {},
710
+ () => {},
711
+ );
712
+ return run;
713
+ }
714
+
715
+ private _resolveToolApproval(approvalId: string, approved: boolean): void {
716
+ const pending = this._pendingToolApprovals.get(approvalId);
717
+ if (!pending) {
718
+ return;
719
+ }
720
+ this._pendingToolApprovals.delete(approvalId);
721
+ pending.resolve(approved);
722
+ }
723
+
724
+ private _mergeProviderMetadata(
725
+ a: Record<string, unknown> | undefined,
726
+ b: Record<string, unknown> | undefined,
727
+ ): Record<string, unknown> | undefined {
728
+ if (!a && !b) {
729
+ return undefined;
730
+ }
731
+ return { ...(a ?? {}), ...(b ?? {}) };
732
+ }
733
+
734
+ private _replaceMessagesFromClient(
735
+ messages: ReadonlyArray<ChatMessage>,
736
+ ): void {
737
+ this.resetTurnState();
738
+ this._flushChunkBuffer();
739
+ if (this._activeStreamId) {
740
+ this.dbDeleteStreamWithChunks(this._activeStreamId);
741
+ }
742
+ this._activeStreamId = null;
743
+ this._activeMessageId = null;
744
+ this._streamChunkIndex = 0;
745
+ this._chunkBuffer = [];
746
+ this.dbReplaceAllMessages([...messages]);
747
+ this.messages = [...messages];
748
+ }
749
+
469
750
  // ============================================================================
470
751
  // OpenRouter Integration
471
752
  // ============================================================================
@@ -494,8 +775,18 @@ export abstract class ChatAgentBase<
494
775
  // ============================================================================
495
776
 
496
777
  async onConnect(connection: Connection, _ctx: ConnectionContext) {
497
- // Send history to client
498
- this.send(connection, { type: "history", messages: this.messages });
778
+ this._sendTo(connection, { type: "history", messages: this.messages });
779
+ }
780
+
781
+ async onClose(
782
+ connection: Connection,
783
+ _code: number,
784
+ _reason: string,
785
+ _wasClean: boolean,
786
+ ): Promise<void> {
787
+ if (this._continuationOriginConnectionId === connection.id) {
788
+ this._continuationOriginConnectionId = null;
789
+ }
499
790
  }
500
791
 
501
792
  async onMessage(connection: Connection, message: string) {
@@ -503,7 +794,7 @@ export abstract class ChatAgentBase<
503
794
 
504
795
  if (!data) {
505
796
  console.error("Invalid client message:", message);
506
- this.send(connection, {
797
+ this._sendTo(connection, {
507
798
  type: "error",
508
799
  message: "Invalid message format",
509
800
  });
@@ -513,20 +804,32 @@ export abstract class ChatAgentBase<
513
804
  try {
514
805
  switch (data.type) {
515
806
  case "getHistory":
516
- this.send(connection, { type: "history", messages: this.messages });
807
+ this._sendTo(connection, {
808
+ type: "history",
809
+ messages: this.messages,
810
+ });
517
811
  break;
518
812
 
519
813
  case "clearHistory":
520
- this._clearMessages();
521
- this.send(connection, { type: "history", messages: [] });
814
+ await this._enqueueTurn(async () => {
815
+ this._clearMessages();
816
+ this._broadcast({ type: "history", messages: [] });
817
+ });
522
818
  break;
523
819
 
524
820
  case "sendMessage":
525
- await this._handleChatMessage(connection, data.content);
821
+ await this._enqueueTurn(async () => {
822
+ this._continuationOriginConnectionId = connection.id;
823
+ await this._handleSendMessagePayload(data);
824
+ });
825
+ break;
826
+
827
+ case "toolApprovalResponse":
828
+ this._resolveToolApproval(data.approvalId, data.approved);
526
829
  break;
527
830
 
528
831
  case "resumeStream":
529
- this._handleResumeStream(connection, data.streamId);
832
+ this._handleResumeStream(data.streamId);
530
833
  break;
531
834
 
532
835
  case "cancelRequest":
@@ -534,7 +837,7 @@ export abstract class ChatAgentBase<
534
837
  break;
535
838
 
536
839
  case "toolResult":
537
- await this._handleToolResult(
840
+ await this._handleToolResultMessage(
538
841
  connection,
539
842
  data.toolCallId,
540
843
  data.toolName,
@@ -544,30 +847,29 @@ export abstract class ChatAgentBase<
544
847
  break;
545
848
 
546
849
  case "registerTools":
547
- // Cast needed due to Zod's type inference with exactOptionalPropertyTypes
548
- this._registerClientTools(
549
- connection,
550
- data.tools as ReadonlyArray<{
551
- name: string;
552
- description?: string;
553
- parameters?: Record<string, unknown>;
554
- }>,
555
- );
850
+ await this._enqueueTurn(async () => {
851
+ this._registerClientTools(
852
+ connection,
853
+ data.tools as ReadonlyArray<{
854
+ name: string;
855
+ description?: string;
856
+ parameters?: Record<string, unknown>;
857
+ }>,
858
+ );
859
+ });
556
860
  break;
861
+ default:
862
+ exhaustiveGuard(data);
557
863
  }
558
864
  } catch (err) {
559
865
  console.error("Error processing message:", err);
560
- this.send(connection, {
866
+ this._broadcast({
561
867
  type: "error",
562
868
  message: "Failed to process message",
563
869
  });
564
870
  }
565
871
  }
566
872
 
567
- private send(connection: Connection, msg: ServerMessage): void {
568
- connection.send(JSON.stringify(msg));
569
- }
570
-
571
873
  // ============================================================================
572
874
  // Chat Message Handling
573
875
  // ============================================================================
@@ -583,11 +885,6 @@ export abstract class ChatAgentBase<
583
885
  /**
584
886
  * Get the AI model to use
585
887
  * Override this method to use a different model
586
- *
587
- * Popular Anthropic models on OpenRouter:
588
- * - anthropic/claude-opus-4.5 (most capable)
589
- * - anthropic/claude-sonnet-4.5 (balanced, default)
590
- * - anthropic/claude-haiku-3.5 (fastest, cheapest)
591
888
  */
592
889
  protected getModel(): string {
593
890
  return "anthropic/claude-sonnet-4.5";
@@ -598,48 +895,161 @@ export abstract class ChatAgentBase<
598
895
  * Override this method to provide custom tools
599
896
  */
600
897
  protected getTools(): ToolDefinition[] {
601
- // Default: no tools. Override in subclass to add tools.
602
898
  return [];
603
899
  }
604
900
 
605
- private async _handleChatMessage(
901
+ private async _handleSendMessagePayload(
902
+ data: SendMessagePayload,
903
+ ): Promise<void> {
904
+ const trigger = data.trigger ?? "submit-message";
905
+ if (trigger === "regenerate-message") {
906
+ this._replaceMessagesFromClient(data.messages ?? []);
907
+ await this._generateAIResponse();
908
+ return;
909
+ }
910
+ if (data.messages && data.messages.length > 0) {
911
+ this._replaceMessagesFromClient(data.messages);
912
+ }
913
+ const content = data.content;
914
+ if (content !== undefined && content !== "") {
915
+ const userMessage: UserMessage = {
916
+ id: crypto.randomUUID(),
917
+ role: "user",
918
+ content,
919
+ createdAt: Date.now(),
920
+ };
921
+ this._persistMessage(userMessage);
922
+ if (typeof this.maxPersistedMessages !== "number") {
923
+ this.messages.push(userMessage);
924
+ }
925
+ }
926
+ const last = this.messages[this.messages.length - 1];
927
+ if (!last || last.role !== "user") {
928
+ this._broadcast({
929
+ type: "error",
930
+ message:
931
+ "Cannot generate: conversation must end with a user message (sync `messages` and/or send `content`).",
932
+ });
933
+ return;
934
+ }
935
+ await this._generateAIResponse();
936
+ }
937
+
938
+ private async _handleToolResultMessage(
606
939
  connection: Connection,
607
- content: string,
940
+ toolCallId: string,
941
+ toolName: string,
942
+ output: unknown,
943
+ autoContinue: boolean,
944
+ ): Promise<void> {
945
+ if (!autoContinue) {
946
+ await this._enqueueTurn(async () => {
947
+ await this._applyClientToolResult(connection, toolCallId, output);
948
+ });
949
+ return;
950
+ }
951
+
952
+ this._pendingClientToolAutoContinue.push({
953
+ connection,
954
+ toolCallId,
955
+ toolName,
956
+ output,
957
+ });
958
+ this._scheduleClientToolAutoContinueFlush();
959
+ }
960
+
961
+ private _scheduleClientToolAutoContinueFlush(): void {
962
+ if (this._clientToolAutoContinueFlushScheduled) {
963
+ return;
964
+ }
965
+ this._clientToolAutoContinueFlushScheduled = true;
966
+ queueMicrotask(() => {
967
+ this._clientToolAutoContinueFlushScheduled = false;
968
+ const batch = this._pendingClientToolAutoContinue.splice(0);
969
+ if (batch.length === 0) {
970
+ return;
971
+ }
972
+ void this._enqueueTurn(async () => {
973
+ const origin = batch[0]?.connection;
974
+ if (origin) {
975
+ this._continuationOriginConnectionId = origin.id;
976
+ }
977
+ for (const item of batch) {
978
+ await this._applyClientToolResult(
979
+ item.connection,
980
+ item.toolCallId,
981
+ item.output,
982
+ );
983
+ }
984
+ await this._generateAIResponse();
985
+ });
986
+ });
987
+ }
988
+
989
+ private async _applyClientToolResult(
990
+ _connection: Connection,
991
+ toolCallId: string,
992
+ output: unknown,
608
993
  ): Promise<void> {
609
- // Add user message
610
- const userMessage: UserMessage = {
994
+ const assistantMsg = this.messages.find(
995
+ (m) =>
996
+ isAssistantMessage(m) &&
997
+ m.toolCalls?.some((tc: ToolCall) => tc.id === toolCallId),
998
+ ) as AssistantMessage | undefined;
999
+
1000
+ if (!assistantMsg) {
1001
+ console.warn(
1002
+ `[ChatAgent] Tool result for unknown tool call: ${toolCallId}`,
1003
+ );
1004
+ this._broadcast({ type: "error", message: "Tool call not found" });
1005
+ return;
1006
+ }
1007
+
1008
+ const toolMessage: ToolMessage = {
611
1009
  id: crypto.randomUUID(),
612
- role: "user",
613
- content,
1010
+ role: "tool",
1011
+ toolCallId,
1012
+ content: JSON.stringify(output),
614
1013
  createdAt: Date.now(),
615
1014
  };
616
- this._saveMessage(userMessage);
617
- this.messages.push(userMessage);
618
1015
 
619
- // Generate AI response
620
- await this._generateAIResponse(connection);
1016
+ this._persistMessage(toolMessage);
1017
+ if (typeof this.maxPersistedMessages !== "number") {
1018
+ this.messages.push(toolMessage);
1019
+ }
1020
+ this._broadcast({ type: "messageUpdated", message: toolMessage });
621
1021
  }
622
1022
 
623
1023
  /**
624
1024
  * Generate AI response (can be called for initial message or after tool results)
625
1025
  */
626
- private async _generateAIResponse(connection: Connection): Promise<void> {
1026
+ private async _generateAIResponse(): Promise<void> {
1027
+ const generation = this._turnGeneration;
627
1028
  const assistantId = crypto.randomUUID();
628
1029
  const streamId = this._startStream(assistantId);
629
1030
  const abortSignal = this._getAbortSignal(assistantId);
630
1031
 
631
- this.send(connection, { type: "messageStart", id: assistantId, streamId });
1032
+ this._broadcast({ type: "messageStart", id: assistantId, streamId });
1033
+
1034
+ const runStream = async (): Promise<void> => {
1035
+ let fullContent = "";
1036
+ let usage: TokenUsage | undefined;
1037
+
1038
+ const toolCallsInProgress: Map<
1039
+ number,
1040
+ {
1041
+ id: string;
1042
+ name: string;
1043
+ arguments: string;
1044
+ providerMetadata?: Record<string, unknown>;
1045
+ }
1046
+ > = new Map();
632
1047
 
633
- try {
634
1048
  const openRouter = this._getOpenRouter();
635
- // Get all tools (server-defined + client-registered)
636
1049
  const toolsMap = this._getToolsMap();
637
1050
  const tools = Array.from(toolsMap.values());
638
-
639
- // Build messages for API (convert our format to OpenRouter format)
640
1051
  const apiMessages = this._buildApiMessages();
641
1052
 
642
- // Stream response from OpenRouter via AI Gateway
643
1053
  const envWithGateway = this.env as Env & { AI_GATEWAY_TOKEN?: string };
644
1054
  const headers = envWithGateway.AI_GATEWAY_TOKEN
645
1055
  ? {
@@ -660,162 +1070,161 @@ export abstract class ChatAgentBase<
660
1070
  },
661
1071
  );
662
1072
 
663
- let fullContent = "";
664
- let usage: TokenUsage | undefined;
665
-
666
- // Track tool calls being streamed
667
- const toolCallsInProgress: Map<
668
- number,
669
- {
670
- id: string;
671
- name: string;
672
- arguments: string;
673
- }
674
- > = new Map();
675
-
676
- for await (const chunk of stream) {
677
- if (abortSignal.aborted) {
678
- throw new Error("Request cancelled");
679
- }
1073
+ this._openRouterStreamLive = true;
1074
+ try {
1075
+ for await (const chunk of stream) {
1076
+ if (generation !== this._turnGeneration) {
1077
+ return;
1078
+ }
1079
+ if (abortSignal.aborted) {
1080
+ throw new Error("Request cancelled");
1081
+ }
680
1082
 
681
- const delta = chunk.choices?.[0]?.delta;
1083
+ const delta = chunk.choices?.[0]?.delta;
682
1084
 
683
- // Handle text content
684
- if (delta?.content) {
685
- fullContent += delta.content;
686
- this._storeChunk(streamId, delta.content);
687
- this.send(connection, {
688
- type: "messageChunk",
689
- id: assistantId,
690
- chunk: delta.content,
691
- });
692
- }
1085
+ if (delta?.content) {
1086
+ fullContent += delta.content;
1087
+ this._storeChunk(streamId, delta.content);
1088
+ this._broadcast({
1089
+ type: "messageChunk",
1090
+ id: assistantId,
1091
+ chunk: delta.content,
1092
+ });
1093
+ }
693
1094
 
694
- // Handle tool calls
695
- if (delta?.toolCalls) {
696
- for (const toolCallDelta of delta.toolCalls) {
697
- const index = toolCallDelta.index;
698
-
699
- // Initialize or update tool call
700
- if (!toolCallsInProgress.has(index)) {
701
- toolCallsInProgress.set(index, {
702
- id: toolCallDelta.id || "",
703
- name: toolCallDelta.function?.name || "",
704
- arguments: "",
705
- });
706
- }
1095
+ if (delta?.toolCalls) {
1096
+ for (const toolCallDelta of delta.toolCalls) {
1097
+ const index = toolCallDelta.index;
707
1098
 
708
- const tc = toolCallsInProgress.get(index);
709
- if (tc) {
710
- if (toolCallDelta.id) {
711
- tc.id = toolCallDelta.id;
712
- }
713
- if (toolCallDelta.function?.name) {
714
- tc.name = toolCallDelta.function.name;
1099
+ if (!toolCallsInProgress.has(index)) {
1100
+ toolCallsInProgress.set(index, {
1101
+ id: toolCallDelta.id || "",
1102
+ name: toolCallDelta.function?.name || "",
1103
+ arguments: "",
1104
+ });
715
1105
  }
716
- if (toolCallDelta.function?.arguments) {
717
- tc.arguments += toolCallDelta.function.arguments;
1106
+
1107
+ const tcRow = toolCallsInProgress.get(index);
1108
+ if (tcRow) {
1109
+ if (toolCallDelta.id) {
1110
+ tcRow.id = toolCallDelta.id;
1111
+ }
1112
+ if (toolCallDelta.function?.name) {
1113
+ tcRow.name = toolCallDelta.function.name;
1114
+ }
1115
+ if (toolCallDelta.function?.arguments) {
1116
+ tcRow.arguments += toolCallDelta.function.arguments;
1117
+ }
1118
+ const rawEntry = getRawToolCallDeltaEntry(chunk, index);
1119
+ const extra = rawEntry
1120
+ ? extractProviderMetadataFromRawToolPart(rawEntry)
1121
+ : undefined;
1122
+ if (extra) {
1123
+ tcRow.providerMetadata = this._mergeProviderMetadata(
1124
+ tcRow.providerMetadata,
1125
+ extra,
1126
+ );
1127
+ }
718
1128
  }
1129
+
1130
+ const deltaMsg: ToolCallDelta = {
1131
+ index: toolCallDelta.index,
1132
+ id: toolCallDelta.id,
1133
+ type: toolCallDelta.type as "function" | undefined,
1134
+ function: toolCallDelta.function
1135
+ ? {
1136
+ name: toolCallDelta.function.name,
1137
+ arguments: toolCallDelta.function.arguments,
1138
+ }
1139
+ : undefined,
1140
+ ...(tcRow?.providerMetadata &&
1141
+ Object.keys(tcRow.providerMetadata).length > 0 && {
1142
+ providerMetadata: tcRow.providerMetadata,
1143
+ }),
1144
+ };
1145
+ this._broadcast({
1146
+ type: "toolCallDelta",
1147
+ id: assistantId,
1148
+ delta: deltaMsg,
1149
+ });
719
1150
  }
1151
+ }
720
1152
 
721
- // Send delta to client for streaming UI
722
- const deltaMsg: ToolCallDelta = {
723
- index: toolCallDelta.index,
724
- id: toolCallDelta.id,
725
- type: toolCallDelta.type as "function" | undefined,
726
- function: toolCallDelta.function
727
- ? {
728
- name: toolCallDelta.function.name,
729
- arguments: toolCallDelta.function.arguments,
730
- }
731
- : undefined,
1153
+ if (chunk.usage) {
1154
+ usage = {
1155
+ prompt_tokens: chunk.usage.promptTokens ?? 0,
1156
+ completion_tokens: chunk.usage.completionTokens ?? 0,
1157
+ total_tokens: chunk.usage.totalTokens ?? 0,
732
1158
  };
733
- this.send(connection, {
734
- type: "toolCallDelta",
735
- id: assistantId,
736
- delta: deltaMsg,
737
- });
738
1159
  }
739
1160
  }
740
1161
 
741
- // Capture usage stats from final chunk
742
- if (chunk.usage) {
743
- usage = {
744
- prompt_tokens: chunk.usage.promptTokens ?? 0,
745
- completion_tokens: chunk.usage.completionTokens ?? 0,
746
- total_tokens: chunk.usage.totalTokens ?? 0,
747
- };
748
- }
749
- }
750
-
751
- // Build final tool calls array
752
- const finalToolCalls: ToolCall[] = [];
753
- for (const [, tc] of toolCallsInProgress as Map<
754
- number,
755
- { id: string; name: string; arguments: string }
756
- >) {
757
- if (tc.id && tc.name) {
758
- const toolCall: ToolCall = {
759
- id: tc.id,
760
- type: "function",
761
- function: {
762
- name: tc.name,
763
- arguments: tc.arguments,
764
- },
765
- };
766
- finalToolCalls.push(toolCall);
1162
+ const finalToolCalls: ToolCall[] = [];
1163
+ for (const [, tc] of toolCallsInProgress) {
1164
+ if (tc.id && tc.name) {
1165
+ const toolCall: ToolCall = {
1166
+ id: tc.id,
1167
+ type: "function",
1168
+ function: {
1169
+ name: tc.name,
1170
+ arguments: tc.arguments,
1171
+ },
1172
+ ...(tc.providerMetadata &&
1173
+ Object.keys(tc.providerMetadata).length > 0 && {
1174
+ providerMetadata: tc.providerMetadata,
1175
+ }),
1176
+ };
1177
+ finalToolCalls.push(toolCall);
767
1178
 
768
- // Send complete tool call to client
769
- this.send(connection, {
770
- type: "toolCall",
771
- id: assistantId,
772
- toolCall,
773
- });
1179
+ this._broadcast({
1180
+ type: "toolCall",
1181
+ id: assistantId,
1182
+ toolCall,
1183
+ });
1184
+ }
774
1185
  }
775
- }
776
1186
 
777
- // Create and save assistant message
778
- const assistantMessage: AssistantMessage = {
779
- id: assistantId,
780
- role: "assistant",
781
- content: fullContent || null,
782
- toolCalls: finalToolCalls.length > 0 ? finalToolCalls : undefined,
783
- createdAt: Date.now(),
784
- };
1187
+ const assistantMessage: AssistantMessage = {
1188
+ id: assistantId,
1189
+ role: "assistant",
1190
+ content: fullContent || null,
1191
+ toolCalls: finalToolCalls.length > 0 ? finalToolCalls : undefined,
1192
+ createdAt: Date.now(),
1193
+ };
785
1194
 
786
- this._completeStreamWithMessage(streamId, assistantMessage);
787
- this._removeAbortController(assistantId);
1195
+ this._completeStreamWithMessage(streamId, assistantMessage);
1196
+ this._removeAbortController(assistantId);
788
1197
 
789
- this.send(connection, {
790
- type: "messageEnd",
791
- id: assistantId,
792
- toolCalls: finalToolCalls.length > 0 ? finalToolCalls : undefined,
793
- createdAt: assistantMessage.createdAt,
794
- ...(usage && { usage }),
795
- });
1198
+ this._broadcast({
1199
+ type: "messageEnd",
1200
+ id: assistantId,
1201
+ toolCalls: finalToolCalls.length > 0 ? finalToolCalls : undefined,
1202
+ createdAt: assistantMessage.createdAt,
1203
+ ...(usage && { usage }),
1204
+ });
796
1205
 
797
- // Execute server-side tools if any
798
- if (finalToolCalls.length > 0) {
799
- const hasServerTools = await this._executeServerSideTools(
800
- connection,
801
- finalToolCalls,
802
- );
803
- // If we executed server tools, the conversation continues automatically
804
- // Client-side tools will wait for toolResult from client
805
- if (hasServerTools) {
806
- return; // Response continues from _executeServerSideTools
1206
+ if (finalToolCalls.length > 0) {
1207
+ const hasServerTools =
1208
+ await this._executeServerSideTools(finalToolCalls);
1209
+ if (hasServerTools) {
1210
+ return;
1211
+ }
807
1212
  }
1213
+ } catch (err) {
1214
+ console.error("OpenRouter error:", err);
1215
+ this._markStreamError(streamId);
1216
+ this._removeAbortController(assistantId);
1217
+ this._broadcast({
1218
+ type: "error",
1219
+ message:
1220
+ err instanceof Error ? err.message : "Failed to get AI response",
1221
+ });
1222
+ } finally {
1223
+ this._openRouterStreamLive = false;
808
1224
  }
809
- } catch (err) {
810
- console.error("OpenRouter error:", err);
811
- this._markStreamError(streamId);
812
- this._removeAbortController(assistantId);
813
- this.send(connection, {
814
- type: "error",
815
- message:
816
- err instanceof Error ? err.message : "Failed to get AI response",
817
- });
818
- }
1225
+ };
1226
+
1227
+ await this.experimental_waitUntil(runStream);
819
1228
  }
820
1229
 
821
1230
  /**
@@ -827,32 +1236,53 @@ export abstract class ChatAgentBase<
827
1236
  ];
828
1237
 
829
1238
  for (const msg of this.messages) {
830
- if (msg.role === "user") {
831
- result.push({ role: "user", content: msg.content } as ORUserMessage);
832
- } else if (msg.role === "assistant") {
833
- const assistantMsg = msg as AssistantMessage;
834
- const orMsg: ORAssistantMessage = {
835
- role: "assistant",
836
- content: assistantMsg.content,
837
- ...(assistantMsg.toolCalls && {
838
- toolCalls: assistantMsg.toolCalls.map((tc) => ({
839
- id: tc.id,
840
- type: "function" as const,
841
- function: {
842
- name: tc.function.name,
843
- arguments: tc.function.arguments,
844
- },
845
- })),
846
- }),
847
- };
848
- result.push(orMsg);
849
- } else if (msg.role === "tool") {
850
- const toolMsg = msg as ToolMessage;
851
- result.push({
852
- role: "tool",
853
- content: toolMsg.content,
854
- toolCallId: toolMsg.toolCallId,
855
- } as ORToolMessage);
1239
+ switch (msg.role) {
1240
+ case "user":
1241
+ result.push({ role: "user", content: msg.content } as ORUserMessage);
1242
+ break;
1243
+ case "assistant": {
1244
+ const assistantMsg = msg;
1245
+ const orMsg: ORAssistantMessage = {
1246
+ role: "assistant",
1247
+ content: assistantMsg.content,
1248
+ ...(assistantMsg.toolCalls && {
1249
+ toolCalls: assistantMsg.toolCalls.map((tc) => {
1250
+ const call: ORToolCall = {
1251
+ id: tc.id,
1252
+ type: "function",
1253
+ function: {
1254
+ name: tc.function.name,
1255
+ arguments: tc.function.arguments,
1256
+ },
1257
+ };
1258
+ const meta = tc.providerMetadata;
1259
+ if (meta) {
1260
+ const {
1261
+ id: _i,
1262
+ type: _t,
1263
+ function: _fn,
1264
+ ...rest
1265
+ } = meta as Record<string, unknown>;
1266
+ Object.assign(call, rest);
1267
+ }
1268
+ return call;
1269
+ }),
1270
+ }),
1271
+ };
1272
+ result.push(orMsg);
1273
+ break;
1274
+ }
1275
+ case "tool": {
1276
+ const toolMsg = msg;
1277
+ result.push({
1278
+ role: "tool",
1279
+ content: toolMsg.content,
1280
+ toolCallId: toolMsg.toolCallId,
1281
+ } as ORToolMessage);
1282
+ break;
1283
+ }
1284
+ default:
1285
+ exhaustiveGuard(msg);
856
1286
  }
857
1287
  }
858
1288
 
@@ -861,13 +1291,11 @@ export abstract class ChatAgentBase<
861
1291
 
862
1292
  /**
863
1293
  * Build a map of tool definitions by name for quick lookup
864
- * Includes both server-defined tools and client-registered tools
865
1294
  */
866
1295
  private _getToolsMap(): Map<string, ToolDefinition> {
867
1296
  const tools = this.getTools();
868
1297
  const map = new Map(tools.map((t) => [t.function.name, t]));
869
1298
 
870
- // Add client-registered tools (no execute function - client handles them)
871
1299
  for (const [name, tool] of this._clientTools) {
872
1300
  if (!map.has(name)) {
873
1301
  const toolDef: ToolDefinition = {
@@ -893,7 +1321,7 @@ export abstract class ChatAgentBase<
893
1321
  * Register tools from the client at runtime
894
1322
  */
895
1323
  private _registerClientTools(
896
- connection: Connection,
1324
+ _connection: Connection,
897
1325
  tools: ReadonlyArray<{
898
1326
  name: string;
899
1327
  description?: string;
@@ -918,8 +1346,7 @@ export abstract class ChatAgentBase<
918
1346
  console.log(`[ChatAgent] Registered client tool: ${tool.name}`);
919
1347
  }
920
1348
 
921
- // Acknowledge registration
922
- this.send(connection, {
1349
+ this._broadcast({
923
1350
  type: "history",
924
1351
  messages: this.messages,
925
1352
  });
@@ -927,10 +1354,8 @@ export abstract class ChatAgentBase<
927
1354
 
928
1355
  /**
929
1356
  * Execute server-side tools and continue the conversation
930
- * Returns true if any server-side tools were executed
931
1357
  */
932
1358
  private async _executeServerSideTools(
933
- connection: Connection,
934
1359
  toolCalls: ToolCall[],
935
1360
  ): Promise<boolean> {
936
1361
  const toolsMap = this._getToolsMap();
@@ -939,10 +1364,8 @@ export abstract class ChatAgentBase<
939
1364
  for (const toolCall of toolCalls) {
940
1365
  const toolDef = toolsMap.get(toolCall.function.name);
941
1366
 
942
- // Check if tool exists
943
1367
  if (!toolDef) {
944
- // Send tool error for unknown tool
945
- this.send(connection, {
1368
+ this._broadcast({
946
1369
  type: "toolError",
947
1370
  errorType: "not_found",
948
1371
  toolCallId: toolCall.id,
@@ -952,7 +1375,6 @@ export abstract class ChatAgentBase<
952
1375
  continue;
953
1376
  }
954
1377
 
955
- // Skip if tool has no execute function (client-side tool)
956
1378
  if (!toolDef.execute) {
957
1379
  continue;
958
1380
  }
@@ -960,15 +1382,13 @@ export abstract class ChatAgentBase<
960
1382
  executedServerTools = true;
961
1383
 
962
1384
  try {
963
- // Parse arguments (handle empty arguments)
964
1385
  let args: Record<string, unknown>;
965
1386
  try {
966
1387
  args = toolCall.function.arguments
967
1388
  ? JSON.parse(toolCall.function.arguments)
968
1389
  : {};
969
1390
  } catch (parseErr) {
970
- // Send input error for malformed arguments
971
- this.send(connection, {
1391
+ this._broadcast({
972
1392
  type: "toolError",
973
1393
  errorType: "input",
974
1394
  toolCallId: toolCall.id,
@@ -978,6 +1398,52 @@ export abstract class ChatAgentBase<
978
1398
  continue;
979
1399
  }
980
1400
 
1401
+ if (toolDef.needsApproval) {
1402
+ const needApproval = await toolDef.needsApproval(args);
1403
+ if (needApproval) {
1404
+ const approvalId = crypto.randomUUID();
1405
+ const approved = await new Promise<boolean>((resolve) => {
1406
+ this._pendingToolApprovals.set(approvalId, { resolve });
1407
+ this._broadcast({
1408
+ type: "toolApprovalRequest",
1409
+ approvalId,
1410
+ toolCallId: toolCall.id,
1411
+ toolName: toolCall.function.name,
1412
+ arguments: toolCall.function.arguments,
1413
+ });
1414
+ });
1415
+ if (!approved) {
1416
+ const errorMsg = "Tool execution rejected by user";
1417
+ this._broadcast({
1418
+ type: "toolError",
1419
+ errorType: "output",
1420
+ toolCallId: toolCall.id,
1421
+ toolName: toolCall.function.name,
1422
+ message: errorMsg,
1423
+ });
1424
+ const rejectedMessage: ToolMessage = {
1425
+ id: crypto.randomUUID(),
1426
+ role: "tool",
1427
+ toolCallId: toolCall.id,
1428
+ content: JSON.stringify({
1429
+ error: errorMsg,
1430
+ rejected: true,
1431
+ }),
1432
+ createdAt: Date.now(),
1433
+ };
1434
+ this._persistMessage(rejectedMessage);
1435
+ if (typeof this.maxPersistedMessages !== "number") {
1436
+ this.messages.push(rejectedMessage);
1437
+ }
1438
+ this._broadcast({
1439
+ type: "messageUpdated",
1440
+ message: rejectedMessage,
1441
+ });
1442
+ continue;
1443
+ }
1444
+ }
1445
+ }
1446
+
981
1447
  console.log(
982
1448
  `[ChatAgent] Executing server tool: ${toolCall.function.name}`,
983
1449
  args,
@@ -985,7 +1451,6 @@ export abstract class ChatAgentBase<
985
1451
 
986
1452
  const result = await toolDef.execute(args);
987
1453
 
988
- // Create and save tool message
989
1454
  const toolMessage: ToolMessage = {
990
1455
  id: crypto.randomUUID(),
991
1456
  role: "tool",
@@ -994,11 +1459,12 @@ export abstract class ChatAgentBase<
994
1459
  createdAt: Date.now(),
995
1460
  };
996
1461
 
997
- this._saveMessage(toolMessage);
998
- this.messages.push(toolMessage);
1462
+ this._persistMessage(toolMessage);
1463
+ if (typeof this.maxPersistedMessages !== "number") {
1464
+ this.messages.push(toolMessage);
1465
+ }
999
1466
 
1000
- // Notify clients of tool result
1001
- this.send(connection, { type: "messageUpdated", message: toolMessage });
1467
+ this._broadcast({ type: "messageUpdated", message: toolMessage });
1002
1468
 
1003
1469
  console.log(
1004
1470
  `[ChatAgent] Server tool completed: ${toolCall.function.name}`,
@@ -1010,10 +1476,9 @@ export abstract class ChatAgentBase<
1010
1476
  err,
1011
1477
  );
1012
1478
 
1013
- // Send output error
1014
1479
  const errorMsg =
1015
1480
  err instanceof Error ? err.message : "Tool execution failed";
1016
- this.send(connection, {
1481
+ this._broadcast({
1017
1482
  type: "toolError",
1018
1483
  errorType: "output",
1019
1484
  toolCallId: toolCall.id,
@@ -1021,7 +1486,6 @@ export abstract class ChatAgentBase<
1021
1486
  message: errorMsg,
1022
1487
  });
1023
1488
 
1024
- // Still create an error tool message so conversation can continue
1025
1489
  const errorMessage: ToolMessage = {
1026
1490
  id: crypto.randomUUID(),
1027
1491
  role: "tool",
@@ -1030,83 +1494,49 @@ export abstract class ChatAgentBase<
1030
1494
  createdAt: Date.now(),
1031
1495
  };
1032
1496
 
1033
- this._saveMessage(errorMessage);
1034
- this.messages.push(errorMessage);
1035
- this.send(connection, {
1497
+ this._persistMessage(errorMessage);
1498
+ if (typeof this.maxPersistedMessages !== "number") {
1499
+ this.messages.push(errorMessage);
1500
+ }
1501
+ this._broadcast({
1036
1502
  type: "messageUpdated",
1037
1503
  message: errorMessage,
1038
1504
  });
1039
1505
  }
1040
1506
  }
1041
1507
 
1042
- // If we executed any server-side tools, continue the conversation
1043
1508
  if (executedServerTools) {
1044
- await this._generateAIResponse(connection);
1509
+ await this._generateAIResponse();
1045
1510
  }
1046
1511
 
1047
1512
  return executedServerTools;
1048
1513
  }
1049
1514
 
1050
- /**
1051
- * Handle tool result from client
1052
- */
1053
- private async _handleToolResult(
1054
- connection: Connection,
1055
- toolCallId: string,
1056
- _toolName: string, // Reserved for future use (logging, validation)
1057
- output: unknown,
1058
- autoContinue: boolean,
1059
- ): Promise<void> {
1060
- // Find the assistant message with this tool call
1061
- const assistantMsg = this.messages.find(
1062
- (m) =>
1063
- isAssistantMessage(m) &&
1064
- m.toolCalls?.some((tc: ToolCall) => tc.id === toolCallId),
1065
- ) as AssistantMessage | undefined;
1066
-
1067
- if (!assistantMsg) {
1068
- console.warn(
1069
- `[ChatAgent] Tool result for unknown tool call: ${toolCallId}`,
1070
- );
1071
- this.send(connection, { type: "error", message: "Tool call not found" });
1515
+ private _handleResumeStream(streamId: string): void {
1516
+ if (!this.dbIsStreamKnown(streamId)) {
1517
+ this._broadcast({
1518
+ type: "streamResume",
1519
+ streamId,
1520
+ chunks: [],
1521
+ done: true,
1522
+ });
1072
1523
  return;
1073
1524
  }
1074
1525
 
1075
- // Create tool message with result
1076
- const toolMessage: ToolMessage = {
1077
- id: crypto.randomUUID(),
1078
- role: "tool",
1079
- toolCallId,
1080
- content: JSON.stringify(output),
1081
- createdAt: Date.now(),
1082
- };
1083
-
1084
- this._saveMessage(toolMessage);
1085
- this.messages.push(toolMessage);
1086
-
1087
- // Notify clients
1088
- this.send(connection, { type: "messageUpdated", message: toolMessage });
1089
-
1090
- // If autoContinue, generate next AI response
1091
- if (autoContinue) {
1092
- await this._generateAIResponse(connection);
1093
- }
1094
- }
1095
-
1096
- private _handleResumeStream(connection: Connection, streamId: string): void {
1097
- // Get stored chunks
1098
1526
  const chunks = this._getStreamChunks(streamId);
1527
+ const isLive =
1528
+ this._openRouterStreamLive && this._activeStreamId === streamId;
1099
1529
 
1100
- // Check if stream is still active
1101
- const isActive = this._activeStreamId === streamId;
1102
-
1103
- // Send all buffered chunks
1104
- this.send(connection, {
1530
+ this._broadcast({
1105
1531
  type: "streamResume",
1106
1532
  streamId,
1107
1533
  chunks,
1108
- done: !isActive,
1534
+ done: !isLive,
1109
1535
  });
1536
+
1537
+ if (!isLive && this._activeStreamId === streamId) {
1538
+ this._finalizeOrphanedStreamFromChunks(streamId);
1539
+ }
1110
1540
  }
1111
1541
 
1112
1542
  // ============================================================================
@@ -1114,13 +1544,11 @@ export abstract class ChatAgentBase<
1114
1544
  // ============================================================================
1115
1545
 
1116
1546
  async destroy(): Promise<void> {
1117
- // Abort all pending requests
1118
1547
  for (const controller of this._abortControllers.values()) {
1119
1548
  controller.abort();
1120
1549
  }
1121
1550
  this._abortControllers.clear();
1122
1551
 
1123
- // Flush remaining chunks
1124
1552
  this._flushChunkBuffer();
1125
1553
 
1126
1554
  await super.destroy();