@firtoz/chat-agent 1.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.
@@ -0,0 +1,1128 @@
1
+ import { OpenRouter } from "@openrouter/sdk";
2
+
3
+ // OpenRouter SDK message types (simplified for our use)
4
+ type ORToolCall = {
5
+ id: string;
6
+ type: "function";
7
+ function: { name: string; arguments: string };
8
+ };
9
+
10
+ type ORSystemMessage = { role: "system"; content: string };
11
+ type ORUserMessage = { role: "user"; content: string };
12
+ type ORAssistantMessage = {
13
+ role: "assistant";
14
+ content?: string | null;
15
+ toolCalls?: ORToolCall[];
16
+ };
17
+ type ORToolMessage = { role: "tool"; content: string; toolCallId: string };
18
+ type OpenRouterMessage =
19
+ | ORSystemMessage
20
+ | ORUserMessage
21
+ | ORAssistantMessage
22
+ | ORToolMessage;
23
+
24
+ import {
25
+ Agent,
26
+ type AgentContext,
27
+ type Connection,
28
+ type ConnectionContext,
29
+ } from "agents";
30
+ import {
31
+ type AssistantMessage,
32
+ type ChatMessage,
33
+ isAssistantMessage,
34
+ type ServerMessage,
35
+ safeParseClientMessage,
36
+ type TokenUsage,
37
+ type ToolCall,
38
+ type ToolCallDelta,
39
+ type ToolDefinition,
40
+ type ToolMessage,
41
+ type UserMessage,
42
+ } from "./chat-messages";
43
+
44
+ // Re-export types for external use
45
+ export type {
46
+ AssistantMessage,
47
+ ChatMessage,
48
+ ClientMessage,
49
+ ServerMessage,
50
+ TokenUsage,
51
+ ToolCall,
52
+ ToolDefinition,
53
+ ToolMessage,
54
+ UserMessage,
55
+ } from "./chat-messages";
56
+ export { defineTool } from "./chat-messages";
57
+
58
+ // ============================================================================
59
+ // Constants
60
+ // ============================================================================
61
+
62
+ /** Default system prompt - override getSystemPrompt() to customize */
63
+ const DEFAULT_SYSTEM_PROMPT = "You are a helpful AI assistant.";
64
+
65
+ /** Number of chunks to buffer before flushing to SQLite */
66
+ const CHUNK_BUFFER_SIZE = 10;
67
+ /** Maximum buffer size to prevent memory issues */
68
+ const CHUNK_BUFFER_MAX_SIZE = 100;
69
+ /** Maximum age for a "streaming" stream before considering it stale (5 minutes) */
70
+ const STREAM_STALE_THRESHOLD_MS = 5 * 60 * 1000;
71
+ /** Cleanup interval for old streams (10 minutes) */
72
+ const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
73
+ /** Age threshold for cleaning up completed streams (24 hours) */
74
+ const CLEANUP_AGE_THRESHOLD_MS = 24 * 60 * 60 * 1000;
75
+
76
+ // ============================================================================
77
+ // ChatAgent Class
78
+ // ============================================================================
79
+
80
+ /**
81
+ * ChatAgentBase - Abstract base class for AI chat agents
82
+ *
83
+ * Features:
84
+ * - DB-agnostic SQLite persistence in Durable Objects
85
+ * - Resumable streaming with chunk buffering (like @cloudflare/ai-chat)
86
+ * - OpenRouter via Cloudflare AI Gateway
87
+ * - Constructor-based initialization pattern
88
+ *
89
+ * Subclasses must implement database operations via abstract methods.
90
+ *
91
+ * @template Env - Environment bindings type (must include OPENROUTER_API_KEY, optionally AI Gateway config)
92
+ */
93
+ export abstract class ChatAgentBase<
94
+ Env extends Cloudflare.Env & {
95
+ OPENROUTER_API_KEY: string;
96
+ } = Cloudflare.Env & { OPENROUTER_API_KEY: string },
97
+ > extends Agent<Env> {
98
+ /** In-memory cache of messages */
99
+ messages: ChatMessage[] = [];
100
+
101
+ /** Map of message IDs to AbortControllers for request cancellation */
102
+ private _abortControllers: Map<string, AbortController> = new Map();
103
+
104
+ /** Currently active stream ID */
105
+ private _activeStreamId: string | null = null;
106
+ /** Message ID being streamed */
107
+ private _activeMessageId: string | null = null;
108
+ /** Current chunk index for active stream */
109
+ private _streamChunkIndex = 0;
110
+ /** Buffer for chunks pending write */
111
+ private _chunkBuffer: Array<{
112
+ streamId: string;
113
+ content: string;
114
+ index: number;
115
+ }> = [];
116
+ /** Lock for flush operations */
117
+ private _isFlushingChunks = false;
118
+ /** Last cleanup timestamp */
119
+ private _lastCleanupTime = 0;
120
+
121
+ /** Client-registered tools (tools defined at runtime from frontend) */
122
+ private _clientTools: Map<
123
+ string,
124
+ { name: string; description?: string; parameters?: Record<string, unknown> }
125
+ > = new Map();
126
+
127
+ // ============================================================================
128
+ // Constructor - Following @cloudflare/ai-chat pattern
129
+ // ============================================================================
130
+
131
+ constructor(ctx: AgentContext, env: Env) {
132
+ super(ctx, env);
133
+
134
+ // Initialize database (subclass-specific)
135
+ this.dbInitialize();
136
+
137
+ // Load messages from DB
138
+ this.messages = this.dbLoadMessages();
139
+
140
+ // Restore any active stream from a previous session
141
+ this._restoreActiveStream();
142
+
143
+ // Wrap onConnect to handle stream resumption
144
+ const _onConnect = this.onConnect.bind(this);
145
+ this.onConnect = async (
146
+ connection: Connection,
147
+ connCtx: ConnectionContext,
148
+ ) => {
149
+ // Notify client about active streams that can be resumed
150
+ if (this._activeStreamId && this._activeMessageId) {
151
+ this._notifyStreamResuming(connection);
152
+ }
153
+ return _onConnect(connection, connCtx);
154
+ };
155
+ }
156
+
157
+ // ============================================================================
158
+ // Abstract Database Methods - Subclasses must implement
159
+ // ============================================================================
160
+
161
+ /**
162
+ * Initialize database and run migrations
163
+ * Called once during constructor
164
+ */
165
+ protected abstract dbInitialize(): void;
166
+
167
+ /**
168
+ * Load all messages from database
169
+ * @returns Array of chat messages ordered by createdAt
170
+ */
171
+ protected abstract dbLoadMessages(): ChatMessage[];
172
+
173
+ /**
174
+ * Save or update a message in database
175
+ * @param msg - The message to save
176
+ */
177
+ protected abstract dbSaveMessage(msg: ChatMessage): void;
178
+
179
+ /**
180
+ * Clear all data (messages, streams, chunks)
181
+ */
182
+ protected abstract dbClearAll(): void;
183
+
184
+ /**
185
+ * Find an active streaming session
186
+ * @returns Stream info or null if none active
187
+ */
188
+ protected abstract dbFindActiveStream(): {
189
+ id: string;
190
+ messageId: string;
191
+ createdAt: Date;
192
+ } | null;
193
+
194
+ /**
195
+ * Delete a stream and all its chunks
196
+ * @param streamId - The stream to delete
197
+ */
198
+ protected abstract dbDeleteStreamWithChunks(streamId: string): void;
199
+
200
+ /**
201
+ * Create a new stream metadata entry
202
+ * @param streamId - Unique stream identifier
203
+ * @param messageId - Associated message ID
204
+ */
205
+ protected abstract dbInsertStreamMetadata(
206
+ streamId: string,
207
+ messageId: string,
208
+ ): void;
209
+
210
+ /**
211
+ * Update stream status to completed or error
212
+ * @param streamId - The stream to update
213
+ * @param status - New status
214
+ */
215
+ protected abstract dbUpdateStreamStatus(
216
+ streamId: string,
217
+ status: "completed" | "error",
218
+ ): void;
219
+
220
+ /**
221
+ * Delete old completed streams older than cutoff
222
+ * @param cutoffMs - Timestamp in milliseconds
223
+ */
224
+ protected abstract dbDeleteOldCompletedStreams(cutoffMs: number): void;
225
+
226
+ /**
227
+ * Find the maximum chunk index for a stream
228
+ * @param streamId - The stream to query
229
+ * @returns Max chunk index or null if no chunks
230
+ */
231
+ protected abstract dbFindMaxChunkIndex(streamId: string): number | null;
232
+
233
+ /**
234
+ * Insert multiple stream chunks
235
+ * @param chunks - Array of chunks to insert
236
+ */
237
+ protected abstract dbInsertChunks(
238
+ chunks: Array<{
239
+ id: string;
240
+ streamId: string;
241
+ content: string;
242
+ chunkIndex: number;
243
+ }>,
244
+ ): void;
245
+
246
+ /**
247
+ * Get all chunks for a stream, ordered by index
248
+ * @param streamId - The stream to query
249
+ * @returns Array of chunk content strings
250
+ */
251
+ protected abstract dbGetChunks(streamId: string): string[];
252
+
253
+ /**
254
+ * Delete all chunks for a stream
255
+ * @param streamId - The stream to clean up
256
+ */
257
+ protected abstract dbDeleteChunks(streamId: string): void;
258
+
259
+ // ============================================================================
260
+ // Message Persistence
261
+ // ============================================================================
262
+
263
+ private _saveMessage(msg: ChatMessage): void {
264
+ this.dbSaveMessage(msg);
265
+ }
266
+
267
+ private _clearMessages(): void {
268
+ this.dbClearAll();
269
+ this._activeStreamId = null;
270
+ this._activeMessageId = null;
271
+ this._streamChunkIndex = 0;
272
+ this._chunkBuffer = [];
273
+ this.messages = [];
274
+ }
275
+
276
+ // ============================================================================
277
+ // Stream Restoration (following @cloudflare/ai-chat pattern)
278
+ // ============================================================================
279
+
280
+ /**
281
+ * Restore active stream state if the agent was restarted during streaming.
282
+ * Called during construction to recover any interrupted streams.
283
+ */
284
+ private _restoreActiveStream(): void {
285
+ const stream = this.dbFindActiveStream();
286
+ if (!stream) {
287
+ return;
288
+ }
289
+
290
+ const streamAge = Date.now() - stream.createdAt.getTime();
291
+
292
+ // Delete stale streams
293
+ if (streamAge > STREAM_STALE_THRESHOLD_MS) {
294
+ this.dbDeleteStreamWithChunks(stream.id);
295
+ console.warn(
296
+ `[ChatAgent] Deleted stale stream ${stream.id} (age: ${Math.round(streamAge / 1000)}s)`,
297
+ );
298
+ return;
299
+ }
300
+
301
+ this._activeStreamId = stream.id;
302
+ this._activeMessageId = stream.messageId;
303
+
304
+ // Get the last chunk index
305
+ const maxIndex = this.dbFindMaxChunkIndex(stream.id);
306
+ this._streamChunkIndex = maxIndex != null ? maxIndex + 1 : 0;
307
+ }
308
+
309
+ /**
310
+ * Notify a connection about an active stream that can be resumed.
311
+ */
312
+ private _notifyStreamResuming(connection: Connection): void {
313
+ if (!this._activeStreamId || !this._activeMessageId) {
314
+ return;
315
+ }
316
+
317
+ connection.send(
318
+ JSON.stringify({
319
+ type: "streamResuming",
320
+ id: this._activeMessageId,
321
+ streamId: this._activeStreamId,
322
+ }),
323
+ );
324
+ }
325
+
326
+ // ============================================================================
327
+ // Stream Chunk Management
328
+ // ============================================================================
329
+
330
+ private _storeChunk(streamId: string, content: string): void {
331
+ // Force flush if buffer is at max
332
+ if (this._chunkBuffer.length >= CHUNK_BUFFER_MAX_SIZE) {
333
+ this._flushChunkBuffer();
334
+ }
335
+
336
+ this._chunkBuffer.push({
337
+ streamId,
338
+ content,
339
+ index: this._streamChunkIndex++,
340
+ });
341
+
342
+ // Flush when buffer reaches threshold
343
+ if (this._chunkBuffer.length >= CHUNK_BUFFER_SIZE) {
344
+ this._flushChunkBuffer();
345
+ }
346
+ }
347
+
348
+ private _flushChunkBuffer(): void {
349
+ if (this._isFlushingChunks || this._chunkBuffer.length === 0) {
350
+ return;
351
+ }
352
+
353
+ this._isFlushingChunks = true;
354
+ try {
355
+ const chunks = this._chunkBuffer;
356
+ this._chunkBuffer = [];
357
+
358
+ // Convert to format expected by dbInsertChunks
359
+ const chunksToInsert = chunks.map((chunk) => ({
360
+ id: crypto.randomUUID(),
361
+ streamId: chunk.streamId,
362
+ content: chunk.content,
363
+ chunkIndex: chunk.index,
364
+ }));
365
+
366
+ this.dbInsertChunks(chunksToInsert);
367
+ } finally {
368
+ this._isFlushingChunks = false;
369
+ }
370
+ }
371
+
372
+ private _startStream(messageId: string): string {
373
+ // Flush any pending chunks from previous streams
374
+ this._flushChunkBuffer();
375
+
376
+ const streamId = crypto.randomUUID();
377
+ this._activeStreamId = streamId;
378
+ this._activeMessageId = messageId;
379
+ this._streamChunkIndex = 0;
380
+
381
+ this.dbInsertStreamMetadata(streamId, messageId);
382
+
383
+ return streamId;
384
+ }
385
+
386
+ /**
387
+ * Complete stream with a full message (supports tool calls)
388
+ */
389
+ private _completeStreamWithMessage(
390
+ streamId: string,
391
+ message: AssistantMessage,
392
+ ): void {
393
+ // Flush any pending chunks
394
+ this._flushChunkBuffer();
395
+
396
+ this.dbUpdateStreamStatus(streamId, "completed");
397
+
398
+ // Save the complete message
399
+ this._saveMessage(message);
400
+ this.messages.push(message);
401
+
402
+ // Clean up stream chunks
403
+ this.dbDeleteChunks(streamId);
404
+
405
+ this._activeStreamId = null;
406
+ this._activeMessageId = null;
407
+ this._streamChunkIndex = 0;
408
+
409
+ // Periodically clean up old streams
410
+ this._maybeCleanupOldStreams();
411
+ }
412
+
413
+ private _markStreamError(streamId: string): void {
414
+ this._flushChunkBuffer();
415
+
416
+ this.dbUpdateStreamStatus(streamId, "error");
417
+
418
+ this._activeStreamId = null;
419
+ this._activeMessageId = null;
420
+ this._streamChunkIndex = 0;
421
+ }
422
+
423
+ /**
424
+ * Clean up old completed streams periodically.
425
+ */
426
+ private _maybeCleanupOldStreams(): void {
427
+ const now = Date.now();
428
+ if (now - this._lastCleanupTime < CLEANUP_INTERVAL_MS) {
429
+ return;
430
+ }
431
+ this._lastCleanupTime = now;
432
+
433
+ const cutoffMs = now - CLEANUP_AGE_THRESHOLD_MS;
434
+ this.dbDeleteOldCompletedStreams(cutoffMs);
435
+ }
436
+
437
+ private _getStreamChunks(streamId: string): string[] {
438
+ // Flush first to ensure all chunks are persisted
439
+ this._flushChunkBuffer();
440
+
441
+ return this.dbGetChunks(streamId);
442
+ }
443
+
444
+ // ============================================================================
445
+ // Abort Controller Management
446
+ // ============================================================================
447
+
448
+ private _getAbortSignal(id: string): AbortSignal {
449
+ let controller = this._abortControllers.get(id);
450
+ if (!controller) {
451
+ controller = new AbortController();
452
+ this._abortControllers.set(id, controller);
453
+ }
454
+ return controller.signal;
455
+ }
456
+
457
+ private _cancelRequest(id: string): void {
458
+ const controller = this._abortControllers.get(id);
459
+ if (controller) {
460
+ controller.abort();
461
+ this._abortControllers.delete(id);
462
+ }
463
+ }
464
+
465
+ private _removeAbortController(id: string): void {
466
+ this._abortControllers.delete(id);
467
+ }
468
+
469
+ // ============================================================================
470
+ // OpenRouter Integration
471
+ // ============================================================================
472
+
473
+ private _getOpenRouter(): OpenRouter {
474
+ // Use AI Gateway if configured, otherwise use OpenRouter directly
475
+ const envWithGateway = this.env as Env & {
476
+ CLOUDFLARE_ACCOUNT_ID?: string;
477
+ AI_GATEWAY_NAME?: string;
478
+ AI_GATEWAY_TOKEN?: string;
479
+ };
480
+
481
+ const serverURL =
482
+ envWithGateway.CLOUDFLARE_ACCOUNT_ID && envWithGateway.AI_GATEWAY_NAME
483
+ ? `https://gateway.ai.cloudflare.com/v1/${envWithGateway.CLOUDFLARE_ACCOUNT_ID}/${envWithGateway.AI_GATEWAY_NAME}/openrouter`
484
+ : undefined;
485
+
486
+ return new OpenRouter({
487
+ apiKey: this.env.OPENROUTER_API_KEY,
488
+ ...(serverURL && { serverURL }),
489
+ });
490
+ }
491
+
492
+ // ============================================================================
493
+ // WebSocket Handlers
494
+ // ============================================================================
495
+
496
+ async onConnect(connection: Connection, _ctx: ConnectionContext) {
497
+ // Send history to client
498
+ this.send(connection, { type: "history", messages: this.messages });
499
+ }
500
+
501
+ async onMessage(connection: Connection, message: string) {
502
+ const data = safeParseClientMessage(message);
503
+
504
+ if (!data) {
505
+ console.error("Invalid client message:", message);
506
+ this.send(connection, {
507
+ type: "error",
508
+ message: "Invalid message format",
509
+ });
510
+ return;
511
+ }
512
+
513
+ try {
514
+ switch (data.type) {
515
+ case "getHistory":
516
+ this.send(connection, { type: "history", messages: this.messages });
517
+ break;
518
+
519
+ case "clearHistory":
520
+ this._clearMessages();
521
+ this.send(connection, { type: "history", messages: [] });
522
+ break;
523
+
524
+ case "sendMessage":
525
+ await this._handleChatMessage(connection, data.content);
526
+ break;
527
+
528
+ case "resumeStream":
529
+ this._handleResumeStream(connection, data.streamId);
530
+ break;
531
+
532
+ case "cancelRequest":
533
+ this._cancelRequest(data.id);
534
+ break;
535
+
536
+ case "toolResult":
537
+ await this._handleToolResult(
538
+ connection,
539
+ data.toolCallId,
540
+ data.toolName,
541
+ data.output,
542
+ data.autoContinue ?? false,
543
+ );
544
+ break;
545
+
546
+ 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
+ );
556
+ break;
557
+ }
558
+ } catch (err) {
559
+ console.error("Error processing message:", err);
560
+ this.send(connection, {
561
+ type: "error",
562
+ message: "Failed to process message",
563
+ });
564
+ }
565
+ }
566
+
567
+ private send(connection: Connection, msg: ServerMessage): void {
568
+ connection.send(JSON.stringify(msg));
569
+ }
570
+
571
+ // ============================================================================
572
+ // Chat Message Handling
573
+ // ============================================================================
574
+
575
+ /**
576
+ * Get the system prompt for the AI
577
+ * Override this method to customize the AI's behavior
578
+ */
579
+ protected getSystemPrompt(): string {
580
+ return DEFAULT_SYSTEM_PROMPT;
581
+ }
582
+
583
+ /**
584
+ * Get the AI model to use
585
+ * 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
+ */
592
+ protected getModel(): string {
593
+ return "anthropic/claude-sonnet-4.5";
594
+ }
595
+
596
+ /**
597
+ * Get available tools for the AI
598
+ * Override this method to provide custom tools
599
+ */
600
+ protected getTools(): ToolDefinition[] {
601
+ // Default: no tools. Override in subclass to add tools.
602
+ return [];
603
+ }
604
+
605
+ private async _handleChatMessage(
606
+ connection: Connection,
607
+ content: string,
608
+ ): Promise<void> {
609
+ // Add user message
610
+ const userMessage: UserMessage = {
611
+ id: crypto.randomUUID(),
612
+ role: "user",
613
+ content,
614
+ createdAt: Date.now(),
615
+ };
616
+ this._saveMessage(userMessage);
617
+ this.messages.push(userMessage);
618
+
619
+ // Generate AI response
620
+ await this._generateAIResponse(connection);
621
+ }
622
+
623
+ /**
624
+ * Generate AI response (can be called for initial message or after tool results)
625
+ */
626
+ private async _generateAIResponse(connection: Connection): Promise<void> {
627
+ const assistantId = crypto.randomUUID();
628
+ const streamId = this._startStream(assistantId);
629
+ const abortSignal = this._getAbortSignal(assistantId);
630
+
631
+ this.send(connection, { type: "messageStart", id: assistantId, streamId });
632
+
633
+ try {
634
+ const openRouter = this._getOpenRouter();
635
+ // Get all tools (server-defined + client-registered)
636
+ const toolsMap = this._getToolsMap();
637
+ const tools = Array.from(toolsMap.values());
638
+
639
+ // Build messages for API (convert our format to OpenRouter format)
640
+ const apiMessages = this._buildApiMessages();
641
+
642
+ // Stream response from OpenRouter via AI Gateway
643
+ const envWithGateway = this.env as Env & { AI_GATEWAY_TOKEN?: string };
644
+ const headers = envWithGateway.AI_GATEWAY_TOKEN
645
+ ? {
646
+ "cf-aig-authorization": `Bearer ${envWithGateway.AI_GATEWAY_TOKEN}`,
647
+ }
648
+ : undefined;
649
+
650
+ const stream = await openRouter.chat.send(
651
+ {
652
+ model: this.getModel(),
653
+ messages: apiMessages,
654
+ stream: true,
655
+ ...(tools.length > 0 && { tools }),
656
+ },
657
+ {
658
+ ...(headers && { headers }),
659
+ signal: abortSignal,
660
+ },
661
+ );
662
+
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
+ }
680
+
681
+ const delta = chunk.choices?.[0]?.delta;
682
+
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
+ }
693
+
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
+ }
707
+
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;
715
+ }
716
+ if (toolCallDelta.function?.arguments) {
717
+ tc.arguments += toolCallDelta.function.arguments;
718
+ }
719
+ }
720
+
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,
732
+ };
733
+ this.send(connection, {
734
+ type: "toolCallDelta",
735
+ id: assistantId,
736
+ delta: deltaMsg,
737
+ });
738
+ }
739
+ }
740
+
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);
767
+
768
+ // Send complete tool call to client
769
+ this.send(connection, {
770
+ type: "toolCall",
771
+ id: assistantId,
772
+ toolCall,
773
+ });
774
+ }
775
+ }
776
+
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
+ };
785
+
786
+ this._completeStreamWithMessage(streamId, assistantMessage);
787
+ this._removeAbortController(assistantId);
788
+
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
+ });
796
+
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
807
+ }
808
+ }
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
+ }
819
+ }
820
+
821
+ /**
822
+ * Build API messages from our message history
823
+ */
824
+ private _buildApiMessages(): OpenRouterMessage[] {
825
+ const result: OpenRouterMessage[] = [
826
+ { role: "system", content: this.getSystemPrompt() } as ORSystemMessage,
827
+ ];
828
+
829
+ 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);
856
+ }
857
+ }
858
+
859
+ return result;
860
+ }
861
+
862
+ /**
863
+ * Build a map of tool definitions by name for quick lookup
864
+ * Includes both server-defined tools and client-registered tools
865
+ */
866
+ private _getToolsMap(): Map<string, ToolDefinition> {
867
+ const tools = this.getTools();
868
+ const map = new Map(tools.map((t) => [t.function.name, t]));
869
+
870
+ // Add client-registered tools (no execute function - client handles them)
871
+ for (const [name, tool] of this._clientTools) {
872
+ if (!map.has(name)) {
873
+ const toolDef: ToolDefinition = {
874
+ type: "function",
875
+ function: {
876
+ name: tool.name,
877
+ },
878
+ };
879
+ if (tool.description !== undefined) {
880
+ toolDef.function.description = tool.description;
881
+ }
882
+ if (tool.parameters !== undefined) {
883
+ toolDef.function.parameters = tool.parameters;
884
+ }
885
+ map.set(name, toolDef);
886
+ }
887
+ }
888
+
889
+ return map;
890
+ }
891
+
892
+ /**
893
+ * Register tools from the client at runtime
894
+ */
895
+ private _registerClientTools(
896
+ connection: Connection,
897
+ tools: ReadonlyArray<{
898
+ name: string;
899
+ description?: string;
900
+ parameters?: Record<string, unknown>;
901
+ }>,
902
+ ): void {
903
+ for (const tool of tools) {
904
+ const entry: {
905
+ name: string;
906
+ description?: string;
907
+ parameters?: Record<string, unknown>;
908
+ } = {
909
+ name: tool.name,
910
+ };
911
+ if (tool.description !== undefined) {
912
+ entry.description = tool.description;
913
+ }
914
+ if (tool.parameters !== undefined) {
915
+ entry.parameters = tool.parameters;
916
+ }
917
+ this._clientTools.set(tool.name, entry);
918
+ console.log(`[ChatAgent] Registered client tool: ${tool.name}`);
919
+ }
920
+
921
+ // Acknowledge registration
922
+ this.send(connection, {
923
+ type: "history",
924
+ messages: this.messages,
925
+ });
926
+ }
927
+
928
+ /**
929
+ * Execute server-side tools and continue the conversation
930
+ * Returns true if any server-side tools were executed
931
+ */
932
+ private async _executeServerSideTools(
933
+ connection: Connection,
934
+ toolCalls: ToolCall[],
935
+ ): Promise<boolean> {
936
+ const toolsMap = this._getToolsMap();
937
+ let executedServerTools = false;
938
+
939
+ for (const toolCall of toolCalls) {
940
+ const toolDef = toolsMap.get(toolCall.function.name);
941
+
942
+ // Check if tool exists
943
+ if (!toolDef) {
944
+ // Send tool error for unknown tool
945
+ this.send(connection, {
946
+ type: "toolError",
947
+ errorType: "not_found",
948
+ toolCallId: toolCall.id,
949
+ toolName: toolCall.function.name,
950
+ message: `Tool "${toolCall.function.name}" not found`,
951
+ });
952
+ continue;
953
+ }
954
+
955
+ // Skip if tool has no execute function (client-side tool)
956
+ if (!toolDef.execute) {
957
+ continue;
958
+ }
959
+
960
+ executedServerTools = true;
961
+
962
+ try {
963
+ // Parse arguments (handle empty arguments)
964
+ let args: Record<string, unknown>;
965
+ try {
966
+ args = toolCall.function.arguments
967
+ ? JSON.parse(toolCall.function.arguments)
968
+ : {};
969
+ } catch (parseErr) {
970
+ // Send input error for malformed arguments
971
+ this.send(connection, {
972
+ type: "toolError",
973
+ errorType: "input",
974
+ toolCallId: toolCall.id,
975
+ toolName: toolCall.function.name,
976
+ message: `Invalid JSON arguments: ${parseErr instanceof Error ? parseErr.message : "Parse error"}`,
977
+ });
978
+ continue;
979
+ }
980
+
981
+ console.log(
982
+ `[ChatAgent] Executing server tool: ${toolCall.function.name}`,
983
+ args,
984
+ );
985
+
986
+ const result = await toolDef.execute(args);
987
+
988
+ // Create and save tool message
989
+ const toolMessage: ToolMessage = {
990
+ id: crypto.randomUUID(),
991
+ role: "tool",
992
+ toolCallId: toolCall.id,
993
+ content: JSON.stringify(result),
994
+ createdAt: Date.now(),
995
+ };
996
+
997
+ this._saveMessage(toolMessage);
998
+ this.messages.push(toolMessage);
999
+
1000
+ // Notify clients of tool result
1001
+ this.send(connection, { type: "messageUpdated", message: toolMessage });
1002
+
1003
+ console.log(
1004
+ `[ChatAgent] Server tool completed: ${toolCall.function.name}`,
1005
+ result,
1006
+ );
1007
+ } catch (err) {
1008
+ console.error(
1009
+ `[ChatAgent] Server tool error: ${toolCall.function.name}`,
1010
+ err,
1011
+ );
1012
+
1013
+ // Send output error
1014
+ const errorMsg =
1015
+ err instanceof Error ? err.message : "Tool execution failed";
1016
+ this.send(connection, {
1017
+ type: "toolError",
1018
+ errorType: "output",
1019
+ toolCallId: toolCall.id,
1020
+ toolName: toolCall.function.name,
1021
+ message: errorMsg,
1022
+ });
1023
+
1024
+ // Still create an error tool message so conversation can continue
1025
+ const errorMessage: ToolMessage = {
1026
+ id: crypto.randomUUID(),
1027
+ role: "tool",
1028
+ toolCallId: toolCall.id,
1029
+ content: JSON.stringify({ error: errorMsg }),
1030
+ createdAt: Date.now(),
1031
+ };
1032
+
1033
+ this._saveMessage(errorMessage);
1034
+ this.messages.push(errorMessage);
1035
+ this.send(connection, {
1036
+ type: "messageUpdated",
1037
+ message: errorMessage,
1038
+ });
1039
+ }
1040
+ }
1041
+
1042
+ // If we executed any server-side tools, continue the conversation
1043
+ if (executedServerTools) {
1044
+ await this._generateAIResponse(connection);
1045
+ }
1046
+
1047
+ return executedServerTools;
1048
+ }
1049
+
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" });
1072
+ return;
1073
+ }
1074
+
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
+ const chunks = this._getStreamChunks(streamId);
1099
+
1100
+ // Check if stream is still active
1101
+ const isActive = this._activeStreamId === streamId;
1102
+
1103
+ // Send all buffered chunks
1104
+ this.send(connection, {
1105
+ type: "streamResume",
1106
+ streamId,
1107
+ chunks,
1108
+ done: !isActive,
1109
+ });
1110
+ }
1111
+
1112
+ // ============================================================================
1113
+ // Cleanup
1114
+ // ============================================================================
1115
+
1116
+ async destroy(): Promise<void> {
1117
+ // Abort all pending requests
1118
+ for (const controller of this._abortControllers.values()) {
1119
+ controller.abort();
1120
+ }
1121
+ this._abortControllers.clear();
1122
+
1123
+ // Flush remaining chunks
1124
+ this._flushChunkBuffer();
1125
+
1126
+ await super.destroy();
1127
+ }
1128
+ }