@firtoz/chat-agent 2.0.0 → 2.1.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,1068 @@
1
+ import { safeParseClientMessage, isAssistantMessage } from './chunk-OEX3D4WL.js';
2
+ import { exhaustiveGuard } from '@firtoz/maybe-error';
3
+ import { OpenRouter } from '@openrouter/sdk';
4
+ import { Agent } from 'agents';
5
+
6
+ var DEFAULT_SYSTEM_PROMPT = "You are a helpful AI assistant.";
7
+ var CHUNK_BUFFER_SIZE = 10;
8
+ var CHUNK_BUFFER_MAX_SIZE = 100;
9
+ var STREAM_STALE_THRESHOLD_MS = 5 * 60 * 1e3;
10
+ var CLEANUP_INTERVAL_MS = 10 * 60 * 1e3;
11
+ var CLEANUP_AGE_THRESHOLD_MS = 24 * 60 * 60 * 1e3;
12
+ var DEFAULT_MAX_TOOL_CONTENT_CHARS = 2e5;
13
+ var STREAM_TOOL_TOP_KEYS = /* @__PURE__ */ new Set(["index", "id", "type", "function"]);
14
+ function getRawToolCallDeltaEntry(chunk, index) {
15
+ const c = chunk;
16
+ const delta = c.choices?.[0]?.delta;
17
+ if (!delta) {
18
+ return void 0;
19
+ }
20
+ const list = delta.tool_calls ?? delta.toolCalls;
21
+ if (!Array.isArray(list) || index < 0 || index >= list.length) {
22
+ return void 0;
23
+ }
24
+ const raw = list[index];
25
+ if (!raw || typeof raw !== "object") {
26
+ return void 0;
27
+ }
28
+ return raw;
29
+ }
30
+ function extractProviderMetadataFromRawToolPart(raw) {
31
+ const meta = {};
32
+ for (const [k, v] of Object.entries(raw)) {
33
+ if (STREAM_TOOL_TOP_KEYS.has(k)) {
34
+ continue;
35
+ }
36
+ meta[k] = v;
37
+ }
38
+ const fn = raw.function;
39
+ if (fn && typeof fn === "object" && !Array.isArray(fn)) {
40
+ const f = fn;
41
+ const fnExtra = {};
42
+ for (const [k, v] of Object.entries(f)) {
43
+ if (k === "name" || k === "arguments") {
44
+ continue;
45
+ }
46
+ fnExtra[k] = v;
47
+ }
48
+ if (Object.keys(fnExtra).length > 0) {
49
+ meta.function = fnExtra;
50
+ }
51
+ }
52
+ return Object.keys(meta).length > 0 ? meta : void 0;
53
+ }
54
+ var ChatAgentBase = class extends Agent {
55
+ // ============================================================================
56
+ // Constructor - Following @cloudflare/ai-chat pattern
57
+ // ============================================================================
58
+ constructor(ctx, env) {
59
+ super(ctx, env);
60
+ /** In-memory cache of messages */
61
+ this.messages = [];
62
+ /** Map of message IDs to AbortControllers for request cancellation */
63
+ this._abortControllers = /* @__PURE__ */ new Map();
64
+ /** Currently active stream ID */
65
+ this._activeStreamId = null;
66
+ /** Message ID being streamed */
67
+ this._activeMessageId = null;
68
+ /** True only while the OpenRouter async iterator for the active stream is running */
69
+ this._openRouterStreamLive = false;
70
+ /** Current chunk index for active stream */
71
+ this._streamChunkIndex = 0;
72
+ /** Buffer for chunks pending write */
73
+ this._chunkBuffer = [];
74
+ /** Lock for flush operations */
75
+ this._isFlushingChunks = false;
76
+ /** Last cleanup timestamp */
77
+ this._lastCleanupTime = 0;
78
+ /** Client-registered tools (tools defined at runtime from frontend) */
79
+ this._clientTools = /* @__PURE__ */ new Map();
80
+ /** FIFO serialization of chat turns (user sends, tool continuations, etc.) */
81
+ this._turnTail = Promise.resolve();
82
+ /** Bumped by {@link resetTurnState} to ignore stale async work */
83
+ this._turnGeneration = 0;
84
+ /** Connection id that last queued an auto-continue after client tools (for multi-tab hints) */
85
+ this._continuationOriginConnectionId = null;
86
+ this._pendingClientToolAutoContinue = [];
87
+ this._clientToolAutoContinueFlushScheduled = false;
88
+ /** Human-in-the-loop: server tools awaiting `toolApprovalResponse` (not queued — avoids deadlock). */
89
+ this._pendingToolApprovals = /* @__PURE__ */ new Map();
90
+ this.dbInitialize();
91
+ this.messages = this.dbLoadMessages();
92
+ this._restoreActiveStream();
93
+ this._openRouterStreamLive = false;
94
+ const _onConnect = this.onConnect.bind(this);
95
+ this.onConnect = async (connection, connCtx) => {
96
+ if (this._activeStreamId && this._activeMessageId) {
97
+ this._notifyStreamResuming(connection);
98
+ }
99
+ return _onConnect(connection, connCtx);
100
+ };
101
+ }
102
+ // ============================================================================
103
+ // Persistence hooks (override in subclasses)
104
+ // ============================================================================
105
+ /**
106
+ * Transform a message immediately before it is written to storage.
107
+ * Default: return the message unchanged.
108
+ */
109
+ sanitizeMessageForPersistence(msg) {
110
+ return msg;
111
+ }
112
+ // ============================================================================
113
+ // Turn coordination (subclasses / host code)
114
+ // ============================================================================
115
+ /**
116
+ * Resolves after all queued turns have finished and no OpenRouter stream is active.
117
+ */
118
+ waitUntilStable() {
119
+ return this._turnTail.then(async () => {
120
+ while (this._openRouterStreamLive) {
121
+ await new Promise((r) => queueMicrotask(r));
122
+ }
123
+ });
124
+ }
125
+ /**
126
+ * Abort in-flight generation, clear pending client tool auto-continue batch, and invalidate queued async work.
127
+ * Call from custom clear handlers; {@link clearHistory} path also resets state.
128
+ */
129
+ resetTurnState() {
130
+ this._turnGeneration++;
131
+ this._pendingClientToolAutoContinue = [];
132
+ this._clientToolAutoContinueFlushScheduled = false;
133
+ this._continuationOriginConnectionId = null;
134
+ for (const p of this._pendingToolApprovals.values()) {
135
+ p.resolve(false);
136
+ }
137
+ this._pendingToolApprovals.clear();
138
+ for (const id of [...this._abortControllers.keys()]) {
139
+ this._cancelRequest(id);
140
+ }
141
+ }
142
+ /**
143
+ * True when the last assistant message still has tool calls without matching tool role replies.
144
+ */
145
+ hasPendingInteraction() {
146
+ if (this._pendingToolApprovals.size > 0) {
147
+ return true;
148
+ }
149
+ const last = this.messages[this.messages.length - 1];
150
+ if (!last || last.role !== "assistant") {
151
+ return false;
152
+ }
153
+ if (!last.toolCalls?.length) {
154
+ return false;
155
+ }
156
+ const pending = new Set(last.toolCalls.map((t) => t.id));
157
+ for (const m of this.messages) {
158
+ if (m.role === "tool" && pending.has(m.toolCallId)) {
159
+ pending.delete(m.toolCallId);
160
+ }
161
+ }
162
+ return pending.size > 0;
163
+ }
164
+ // ============================================================================
165
+ // Message Persistence
166
+ // ============================================================================
167
+ _persistMessage(msg) {
168
+ const sanitized = this.sanitizeMessageForPersistence(msg);
169
+ const stored = this._maybeTruncateToolMessageContent(sanitized);
170
+ this.dbSaveMessage(stored);
171
+ const max = this.maxPersistedMessages;
172
+ if (typeof max === "number" && max > 0) {
173
+ this.dbTrimMessagesToMax(max);
174
+ this.messages = this.dbLoadMessages();
175
+ }
176
+ }
177
+ _maybeTruncateToolMessageContent(msg) {
178
+ if (msg.role !== "tool") {
179
+ return msg;
180
+ }
181
+ const content = msg.content;
182
+ if (content.length <= DEFAULT_MAX_TOOL_CONTENT_CHARS) {
183
+ return msg;
184
+ }
185
+ const truncated = content.slice(0, DEFAULT_MAX_TOOL_CONTENT_CHARS) + `
186
+ \u2026 [truncated ${content.length - DEFAULT_MAX_TOOL_CONTENT_CHARS} chars for storage]`;
187
+ return { ...msg, content: truncated };
188
+ }
189
+ _clearMessages() {
190
+ this.resetTurnState();
191
+ this.dbClearAll();
192
+ this._activeStreamId = null;
193
+ this._activeMessageId = null;
194
+ this._streamChunkIndex = 0;
195
+ this._chunkBuffer = [];
196
+ this.messages = [];
197
+ }
198
+ // ============================================================================
199
+ // Stream Restoration (following @cloudflare/ai-chat pattern)
200
+ // ============================================================================
201
+ /**
202
+ * Restore active stream state if the agent was restarted during streaming.
203
+ * Called during construction to recover any interrupted streams.
204
+ */
205
+ _restoreActiveStream() {
206
+ const stream = this.dbFindActiveStream();
207
+ if (!stream) {
208
+ return;
209
+ }
210
+ const streamAge = Date.now() - stream.createdAt.getTime();
211
+ if (streamAge > STREAM_STALE_THRESHOLD_MS) {
212
+ this.dbDeleteStreamWithChunks(stream.id);
213
+ console.warn(
214
+ `[ChatAgent] Deleted stale stream ${stream.id} (age: ${Math.round(streamAge / 1e3)}s)`
215
+ );
216
+ return;
217
+ }
218
+ this._activeStreamId = stream.id;
219
+ this._activeMessageId = stream.messageId;
220
+ const maxIndex = this.dbFindMaxChunkIndex(stream.id);
221
+ this._streamChunkIndex = maxIndex != null ? maxIndex + 1 : 0;
222
+ }
223
+ /**
224
+ * Notify a connection about an active stream that can be resumed.
225
+ */
226
+ _notifyStreamResuming(connection) {
227
+ if (!this._activeStreamId || !this._activeMessageId) {
228
+ return;
229
+ }
230
+ this._sendTo(connection, {
231
+ type: "streamResuming",
232
+ id: this._activeMessageId,
233
+ streamId: this._activeStreamId
234
+ });
235
+ }
236
+ // ============================================================================
237
+ // Stream Chunk Management
238
+ // ============================================================================
239
+ _storeChunk(streamId, content) {
240
+ if (this._chunkBuffer.length >= CHUNK_BUFFER_MAX_SIZE) {
241
+ this._flushChunkBuffer();
242
+ }
243
+ this._chunkBuffer.push({
244
+ streamId,
245
+ content,
246
+ index: this._streamChunkIndex++
247
+ });
248
+ if (this._chunkBuffer.length >= CHUNK_BUFFER_SIZE) {
249
+ this._flushChunkBuffer();
250
+ }
251
+ }
252
+ _flushChunkBuffer() {
253
+ if (this._isFlushingChunks || this._chunkBuffer.length === 0) {
254
+ return;
255
+ }
256
+ this._isFlushingChunks = true;
257
+ try {
258
+ const chunks = this._chunkBuffer;
259
+ this._chunkBuffer = [];
260
+ const chunksToInsert = chunks.map((chunk) => ({
261
+ id: crypto.randomUUID(),
262
+ streamId: chunk.streamId,
263
+ content: chunk.content,
264
+ chunkIndex: chunk.index
265
+ }));
266
+ this.dbInsertChunks(chunksToInsert);
267
+ } finally {
268
+ this._isFlushingChunks = false;
269
+ }
270
+ }
271
+ _startStream(messageId) {
272
+ this._flushChunkBuffer();
273
+ const streamId = crypto.randomUUID();
274
+ this._activeStreamId = streamId;
275
+ this._activeMessageId = messageId;
276
+ this._streamChunkIndex = 0;
277
+ this.dbInsertStreamMetadata(streamId, messageId);
278
+ return streamId;
279
+ }
280
+ /**
281
+ * Complete stream with a full message (supports tool calls)
282
+ */
283
+ _completeStreamWithMessage(streamId, message) {
284
+ this._flushChunkBuffer();
285
+ this.dbUpdateStreamStatus(streamId, "completed");
286
+ this._persistMessage(message);
287
+ if (typeof this.maxPersistedMessages !== "number") {
288
+ this.messages.push(message);
289
+ }
290
+ this.dbDeleteChunks(streamId);
291
+ this._activeStreamId = null;
292
+ this._activeMessageId = null;
293
+ this._streamChunkIndex = 0;
294
+ this._maybeCleanupOldStreams();
295
+ }
296
+ _markStreamError(streamId) {
297
+ this._flushChunkBuffer();
298
+ this.dbUpdateStreamStatus(streamId, "error");
299
+ this._activeStreamId = null;
300
+ this._activeMessageId = null;
301
+ this._streamChunkIndex = 0;
302
+ }
303
+ /**
304
+ * Clean up old completed streams periodically.
305
+ */
306
+ _maybeCleanupOldStreams() {
307
+ const now = Date.now();
308
+ if (now - this._lastCleanupTime < CLEANUP_INTERVAL_MS) {
309
+ return;
310
+ }
311
+ this._lastCleanupTime = now;
312
+ const cutoffMs = now - CLEANUP_AGE_THRESHOLD_MS;
313
+ this.dbDeleteOldCompletedStreams(cutoffMs);
314
+ }
315
+ _getStreamChunks(streamId) {
316
+ this._flushChunkBuffer();
317
+ return this.dbGetChunks(streamId);
318
+ }
319
+ /**
320
+ * Finalize a stream that has buffered chunks but no live OpenRouter reader (e.g. after DO restart).
321
+ */
322
+ _finalizeOrphanedStreamFromChunks(streamId) {
323
+ if (this._activeStreamId !== streamId || !this._activeMessageId) {
324
+ return;
325
+ }
326
+ const messageId = this._activeMessageId;
327
+ const chunks = this._getStreamChunks(streamId);
328
+ const text = chunks.join("");
329
+ const assistantMessage = {
330
+ id: messageId,
331
+ role: "assistant",
332
+ content: text.length > 0 ? text : null,
333
+ createdAt: Date.now()
334
+ };
335
+ this._completeStreamWithMessage(streamId, assistantMessage);
336
+ this._broadcast({
337
+ type: "messageEnd",
338
+ id: messageId,
339
+ createdAt: assistantMessage.createdAt
340
+ });
341
+ }
342
+ // ============================================================================
343
+ // Abort Controller Management
344
+ // ============================================================================
345
+ _getAbortSignal(id) {
346
+ let controller = this._abortControllers.get(id);
347
+ if (!controller) {
348
+ controller = new AbortController();
349
+ this._abortControllers.set(id, controller);
350
+ }
351
+ return controller.signal;
352
+ }
353
+ _cancelRequest(id) {
354
+ const controller = this._abortControllers.get(id);
355
+ if (controller) {
356
+ controller.abort();
357
+ this._abortControllers.delete(id);
358
+ }
359
+ }
360
+ _removeAbortController(id) {
361
+ this._abortControllers.delete(id);
362
+ }
363
+ // ============================================================================
364
+ // Broadcasting
365
+ // ============================================================================
366
+ _broadcast(msg) {
367
+ this.broadcast(JSON.stringify(msg));
368
+ }
369
+ _sendTo(connection, msg) {
370
+ connection.send(JSON.stringify(msg));
371
+ }
372
+ _enqueueTurn(fn) {
373
+ const run = this._turnTail.then(fn);
374
+ this._turnTail = run.then(
375
+ () => {
376
+ },
377
+ () => {
378
+ }
379
+ );
380
+ return run;
381
+ }
382
+ _resolveToolApproval(approvalId, approved) {
383
+ const pending = this._pendingToolApprovals.get(approvalId);
384
+ if (!pending) {
385
+ return;
386
+ }
387
+ this._pendingToolApprovals.delete(approvalId);
388
+ pending.resolve(approved);
389
+ }
390
+ _mergeProviderMetadata(a, b) {
391
+ if (!a && !b) {
392
+ return void 0;
393
+ }
394
+ return { ...a ?? {}, ...b ?? {} };
395
+ }
396
+ _replaceMessagesFromClient(messages) {
397
+ this.resetTurnState();
398
+ this._flushChunkBuffer();
399
+ if (this._activeStreamId) {
400
+ this.dbDeleteStreamWithChunks(this._activeStreamId);
401
+ }
402
+ this._activeStreamId = null;
403
+ this._activeMessageId = null;
404
+ this._streamChunkIndex = 0;
405
+ this._chunkBuffer = [];
406
+ this.dbReplaceAllMessages([...messages]);
407
+ this.messages = [...messages];
408
+ }
409
+ // ============================================================================
410
+ // OpenRouter Integration
411
+ // ============================================================================
412
+ _getOpenRouter() {
413
+ const envWithGateway = this.env;
414
+ const serverURL = envWithGateway.CLOUDFLARE_ACCOUNT_ID && envWithGateway.AI_GATEWAY_NAME ? `https://gateway.ai.cloudflare.com/v1/${envWithGateway.CLOUDFLARE_ACCOUNT_ID}/${envWithGateway.AI_GATEWAY_NAME}/openrouter` : void 0;
415
+ return new OpenRouter({
416
+ apiKey: this.env.OPENROUTER_API_KEY,
417
+ ...serverURL && { serverURL }
418
+ });
419
+ }
420
+ // ============================================================================
421
+ // WebSocket Handlers
422
+ // ============================================================================
423
+ async onConnect(connection, _ctx) {
424
+ this._sendTo(connection, { type: "history", messages: this.messages });
425
+ }
426
+ async onClose(connection, _code, _reason, _wasClean) {
427
+ if (this._continuationOriginConnectionId === connection.id) {
428
+ this._continuationOriginConnectionId = null;
429
+ }
430
+ }
431
+ async onMessage(connection, message) {
432
+ const data = safeParseClientMessage(message);
433
+ if (!data) {
434
+ console.error("Invalid client message:", message);
435
+ this._sendTo(connection, {
436
+ type: "error",
437
+ message: "Invalid message format"
438
+ });
439
+ return;
440
+ }
441
+ try {
442
+ switch (data.type) {
443
+ case "getHistory":
444
+ this._sendTo(connection, {
445
+ type: "history",
446
+ messages: this.messages
447
+ });
448
+ break;
449
+ case "clearHistory":
450
+ await this._enqueueTurn(async () => {
451
+ this._clearMessages();
452
+ this._broadcast({ type: "history", messages: [] });
453
+ });
454
+ break;
455
+ case "sendMessage":
456
+ await this._enqueueTurn(async () => {
457
+ this._continuationOriginConnectionId = connection.id;
458
+ await this._handleSendMessagePayload(data);
459
+ });
460
+ break;
461
+ case "toolApprovalResponse":
462
+ this._resolveToolApproval(data.approvalId, data.approved);
463
+ break;
464
+ case "resumeStream":
465
+ this._handleResumeStream(data.streamId);
466
+ break;
467
+ case "cancelRequest":
468
+ this._cancelRequest(data.id);
469
+ break;
470
+ case "toolResult":
471
+ await this._handleToolResultMessage(
472
+ connection,
473
+ data.toolCallId,
474
+ data.toolName,
475
+ data.output,
476
+ data.autoContinue ?? false
477
+ );
478
+ break;
479
+ case "registerTools":
480
+ await this._enqueueTurn(async () => {
481
+ this._registerClientTools(
482
+ connection,
483
+ data.tools
484
+ );
485
+ });
486
+ break;
487
+ default:
488
+ exhaustiveGuard(data);
489
+ }
490
+ } catch (err) {
491
+ console.error("Error processing message:", err);
492
+ this._broadcast({
493
+ type: "error",
494
+ message: "Failed to process message"
495
+ });
496
+ }
497
+ }
498
+ // ============================================================================
499
+ // Chat Message Handling
500
+ // ============================================================================
501
+ /**
502
+ * Get the system prompt for the AI
503
+ * Override this method to customize the AI's behavior
504
+ */
505
+ getSystemPrompt() {
506
+ return DEFAULT_SYSTEM_PROMPT;
507
+ }
508
+ /**
509
+ * Get the AI model to use
510
+ * Override this method to use a different model
511
+ */
512
+ getModel() {
513
+ return "anthropic/claude-sonnet-4.5";
514
+ }
515
+ /**
516
+ * Get available tools for the AI
517
+ * Override this method to provide custom tools
518
+ */
519
+ getTools() {
520
+ return [];
521
+ }
522
+ async _handleSendMessagePayload(data) {
523
+ const trigger = data.trigger ?? "submit-message";
524
+ if (trigger === "regenerate-message") {
525
+ this._replaceMessagesFromClient(data.messages ?? []);
526
+ await this._generateAIResponse();
527
+ return;
528
+ }
529
+ if (data.messages && data.messages.length > 0) {
530
+ this._replaceMessagesFromClient(data.messages);
531
+ }
532
+ const content = data.content;
533
+ if (content !== void 0 && content !== "") {
534
+ const userMessage = {
535
+ id: crypto.randomUUID(),
536
+ role: "user",
537
+ content,
538
+ createdAt: Date.now()
539
+ };
540
+ this._persistMessage(userMessage);
541
+ if (typeof this.maxPersistedMessages !== "number") {
542
+ this.messages.push(userMessage);
543
+ }
544
+ }
545
+ const last = this.messages[this.messages.length - 1];
546
+ if (!last || last.role !== "user") {
547
+ this._broadcast({
548
+ type: "error",
549
+ message: "Cannot generate: conversation must end with a user message (sync `messages` and/or send `content`)."
550
+ });
551
+ return;
552
+ }
553
+ await this._generateAIResponse();
554
+ }
555
+ async _handleToolResultMessage(connection, toolCallId, toolName, output, autoContinue) {
556
+ if (!autoContinue) {
557
+ await this._enqueueTurn(async () => {
558
+ await this._applyClientToolResult(connection, toolCallId, output);
559
+ });
560
+ return;
561
+ }
562
+ this._pendingClientToolAutoContinue.push({
563
+ connection,
564
+ toolCallId,
565
+ toolName,
566
+ output
567
+ });
568
+ this._scheduleClientToolAutoContinueFlush();
569
+ }
570
+ _scheduleClientToolAutoContinueFlush() {
571
+ if (this._clientToolAutoContinueFlushScheduled) {
572
+ return;
573
+ }
574
+ this._clientToolAutoContinueFlushScheduled = true;
575
+ queueMicrotask(() => {
576
+ this._clientToolAutoContinueFlushScheduled = false;
577
+ const batch = this._pendingClientToolAutoContinue.splice(0);
578
+ if (batch.length === 0) {
579
+ return;
580
+ }
581
+ void this._enqueueTurn(async () => {
582
+ const origin = batch[0]?.connection;
583
+ if (origin) {
584
+ this._continuationOriginConnectionId = origin.id;
585
+ }
586
+ for (const item of batch) {
587
+ await this._applyClientToolResult(
588
+ item.connection,
589
+ item.toolCallId,
590
+ item.output
591
+ );
592
+ }
593
+ await this._generateAIResponse();
594
+ });
595
+ });
596
+ }
597
+ async _applyClientToolResult(_connection, toolCallId, output) {
598
+ const assistantMsg = this.messages.find(
599
+ (m) => isAssistantMessage(m) && m.toolCalls?.some((tc) => tc.id === toolCallId)
600
+ );
601
+ if (!assistantMsg) {
602
+ console.warn(
603
+ `[ChatAgent] Tool result for unknown tool call: ${toolCallId}`
604
+ );
605
+ this._broadcast({ type: "error", message: "Tool call not found" });
606
+ return;
607
+ }
608
+ const toolMessage = {
609
+ id: crypto.randomUUID(),
610
+ role: "tool",
611
+ toolCallId,
612
+ content: JSON.stringify(output),
613
+ createdAt: Date.now()
614
+ };
615
+ this._persistMessage(toolMessage);
616
+ if (typeof this.maxPersistedMessages !== "number") {
617
+ this.messages.push(toolMessage);
618
+ }
619
+ this._broadcast({ type: "messageUpdated", message: toolMessage });
620
+ }
621
+ /**
622
+ * Generate AI response (can be called for initial message or after tool results)
623
+ */
624
+ async _generateAIResponse() {
625
+ const generation = this._turnGeneration;
626
+ const assistantId = crypto.randomUUID();
627
+ const streamId = this._startStream(assistantId);
628
+ const abortSignal = this._getAbortSignal(assistantId);
629
+ this._broadcast({ type: "messageStart", id: assistantId, streamId });
630
+ const runStream = async () => {
631
+ let fullContent = "";
632
+ let usage;
633
+ const toolCallsInProgress = /* @__PURE__ */ new Map();
634
+ const openRouter = this._getOpenRouter();
635
+ const toolsMap = this._getToolsMap();
636
+ const tools = Array.from(toolsMap.values());
637
+ const apiMessages = this._buildApiMessages();
638
+ const envWithGateway = this.env;
639
+ const headers = envWithGateway.AI_GATEWAY_TOKEN ? {
640
+ "cf-aig-authorization": `Bearer ${envWithGateway.AI_GATEWAY_TOKEN}`
641
+ } : void 0;
642
+ const stream = await openRouter.chat.send(
643
+ {
644
+ model: this.getModel(),
645
+ messages: apiMessages,
646
+ stream: true,
647
+ ...tools.length > 0 && { tools }
648
+ },
649
+ {
650
+ ...headers && { headers },
651
+ signal: abortSignal
652
+ }
653
+ );
654
+ this._openRouterStreamLive = true;
655
+ try {
656
+ for await (const chunk of stream) {
657
+ if (generation !== this._turnGeneration) {
658
+ return;
659
+ }
660
+ if (abortSignal.aborted) {
661
+ throw new Error("Request cancelled");
662
+ }
663
+ const delta = chunk.choices?.[0]?.delta;
664
+ if (delta?.content) {
665
+ fullContent += delta.content;
666
+ this._storeChunk(streamId, delta.content);
667
+ this._broadcast({
668
+ type: "messageChunk",
669
+ id: assistantId,
670
+ chunk: delta.content
671
+ });
672
+ }
673
+ if (delta?.toolCalls) {
674
+ for (const toolCallDelta of delta.toolCalls) {
675
+ const index = toolCallDelta.index;
676
+ if (!toolCallsInProgress.has(index)) {
677
+ toolCallsInProgress.set(index, {
678
+ id: toolCallDelta.id || "",
679
+ name: toolCallDelta.function?.name || "",
680
+ arguments: ""
681
+ });
682
+ }
683
+ const tcRow = toolCallsInProgress.get(index);
684
+ if (tcRow) {
685
+ if (toolCallDelta.id) {
686
+ tcRow.id = toolCallDelta.id;
687
+ }
688
+ if (toolCallDelta.function?.name) {
689
+ tcRow.name = toolCallDelta.function.name;
690
+ }
691
+ if (toolCallDelta.function?.arguments) {
692
+ tcRow.arguments += toolCallDelta.function.arguments;
693
+ }
694
+ const rawEntry = getRawToolCallDeltaEntry(chunk, index);
695
+ const extra = rawEntry ? extractProviderMetadataFromRawToolPart(rawEntry) : void 0;
696
+ if (extra) {
697
+ tcRow.providerMetadata = this._mergeProviderMetadata(
698
+ tcRow.providerMetadata,
699
+ extra
700
+ );
701
+ }
702
+ }
703
+ const deltaMsg = {
704
+ index: toolCallDelta.index,
705
+ id: toolCallDelta.id,
706
+ type: toolCallDelta.type,
707
+ function: toolCallDelta.function ? {
708
+ name: toolCallDelta.function.name,
709
+ arguments: toolCallDelta.function.arguments
710
+ } : void 0,
711
+ ...tcRow?.providerMetadata && Object.keys(tcRow.providerMetadata).length > 0 && {
712
+ providerMetadata: tcRow.providerMetadata
713
+ }
714
+ };
715
+ this._broadcast({
716
+ type: "toolCallDelta",
717
+ id: assistantId,
718
+ delta: deltaMsg
719
+ });
720
+ }
721
+ }
722
+ if (chunk.usage) {
723
+ usage = {
724
+ prompt_tokens: chunk.usage.promptTokens ?? 0,
725
+ completion_tokens: chunk.usage.completionTokens ?? 0,
726
+ total_tokens: chunk.usage.totalTokens ?? 0
727
+ };
728
+ }
729
+ }
730
+ const finalToolCalls = [];
731
+ for (const [, tc] of toolCallsInProgress) {
732
+ if (tc.id && tc.name) {
733
+ const toolCall = {
734
+ id: tc.id,
735
+ type: "function",
736
+ function: {
737
+ name: tc.name,
738
+ arguments: tc.arguments
739
+ },
740
+ ...tc.providerMetadata && Object.keys(tc.providerMetadata).length > 0 && {
741
+ providerMetadata: tc.providerMetadata
742
+ }
743
+ };
744
+ finalToolCalls.push(toolCall);
745
+ this._broadcast({
746
+ type: "toolCall",
747
+ id: assistantId,
748
+ toolCall
749
+ });
750
+ }
751
+ }
752
+ const assistantMessage = {
753
+ id: assistantId,
754
+ role: "assistant",
755
+ content: fullContent || null,
756
+ toolCalls: finalToolCalls.length > 0 ? finalToolCalls : void 0,
757
+ createdAt: Date.now()
758
+ };
759
+ this._completeStreamWithMessage(streamId, assistantMessage);
760
+ this._removeAbortController(assistantId);
761
+ this._broadcast({
762
+ type: "messageEnd",
763
+ id: assistantId,
764
+ toolCalls: finalToolCalls.length > 0 ? finalToolCalls : void 0,
765
+ createdAt: assistantMessage.createdAt,
766
+ ...usage && { usage }
767
+ });
768
+ if (finalToolCalls.length > 0) {
769
+ const hasServerTools = await this._executeServerSideTools(finalToolCalls);
770
+ if (hasServerTools) {
771
+ return;
772
+ }
773
+ }
774
+ } catch (err) {
775
+ console.error("OpenRouter error:", err);
776
+ this._markStreamError(streamId);
777
+ this._removeAbortController(assistantId);
778
+ this._broadcast({
779
+ type: "error",
780
+ message: err instanceof Error ? err.message : "Failed to get AI response"
781
+ });
782
+ } finally {
783
+ this._openRouterStreamLive = false;
784
+ }
785
+ };
786
+ await this.experimental_waitUntil(runStream);
787
+ }
788
+ /**
789
+ * Build API messages from our message history
790
+ */
791
+ _buildApiMessages() {
792
+ const result = [
793
+ { role: "system", content: this.getSystemPrompt() }
794
+ ];
795
+ for (const msg of this.messages) {
796
+ switch (msg.role) {
797
+ case "user":
798
+ result.push({ role: "user", content: msg.content });
799
+ break;
800
+ case "assistant": {
801
+ const assistantMsg = msg;
802
+ const orMsg = {
803
+ role: "assistant",
804
+ content: assistantMsg.content,
805
+ ...assistantMsg.toolCalls && {
806
+ toolCalls: assistantMsg.toolCalls.map((tc) => {
807
+ const call = {
808
+ id: tc.id,
809
+ type: "function",
810
+ function: {
811
+ name: tc.function.name,
812
+ arguments: tc.function.arguments
813
+ }
814
+ };
815
+ const meta = tc.providerMetadata;
816
+ if (meta) {
817
+ const {
818
+ id: _i,
819
+ type: _t,
820
+ function: _fn,
821
+ ...rest
822
+ } = meta;
823
+ Object.assign(call, rest);
824
+ }
825
+ return call;
826
+ })
827
+ }
828
+ };
829
+ result.push(orMsg);
830
+ break;
831
+ }
832
+ case "tool": {
833
+ const toolMsg = msg;
834
+ result.push({
835
+ role: "tool",
836
+ content: toolMsg.content,
837
+ toolCallId: toolMsg.toolCallId
838
+ });
839
+ break;
840
+ }
841
+ default:
842
+ exhaustiveGuard(msg);
843
+ }
844
+ }
845
+ return result;
846
+ }
847
+ /**
848
+ * Build a map of tool definitions by name for quick lookup
849
+ */
850
+ _getToolsMap() {
851
+ const tools = this.getTools();
852
+ const map = new Map(tools.map((t) => [t.function.name, t]));
853
+ for (const [name, tool] of this._clientTools) {
854
+ if (!map.has(name)) {
855
+ const toolDef = {
856
+ type: "function",
857
+ function: {
858
+ name: tool.name
859
+ }
860
+ };
861
+ if (tool.description !== void 0) {
862
+ toolDef.function.description = tool.description;
863
+ }
864
+ if (tool.parameters !== void 0) {
865
+ toolDef.function.parameters = tool.parameters;
866
+ }
867
+ map.set(name, toolDef);
868
+ }
869
+ }
870
+ return map;
871
+ }
872
+ /**
873
+ * Register tools from the client at runtime
874
+ */
875
+ _registerClientTools(_connection, tools) {
876
+ for (const tool of tools) {
877
+ const entry = {
878
+ name: tool.name
879
+ };
880
+ if (tool.description !== void 0) {
881
+ entry.description = tool.description;
882
+ }
883
+ if (tool.parameters !== void 0) {
884
+ entry.parameters = tool.parameters;
885
+ }
886
+ this._clientTools.set(tool.name, entry);
887
+ console.log(`[ChatAgent] Registered client tool: ${tool.name}`);
888
+ }
889
+ this._broadcast({
890
+ type: "history",
891
+ messages: this.messages
892
+ });
893
+ }
894
+ /**
895
+ * Execute server-side tools and continue the conversation
896
+ */
897
+ async _executeServerSideTools(toolCalls) {
898
+ const toolsMap = this._getToolsMap();
899
+ let executedServerTools = false;
900
+ for (const toolCall of toolCalls) {
901
+ const toolDef = toolsMap.get(toolCall.function.name);
902
+ if (!toolDef) {
903
+ this._broadcast({
904
+ type: "toolError",
905
+ errorType: "not_found",
906
+ toolCallId: toolCall.id,
907
+ toolName: toolCall.function.name,
908
+ message: `Tool "${toolCall.function.name}" not found`
909
+ });
910
+ continue;
911
+ }
912
+ if (!toolDef.execute) {
913
+ continue;
914
+ }
915
+ executedServerTools = true;
916
+ try {
917
+ let args;
918
+ try {
919
+ args = toolCall.function.arguments ? JSON.parse(toolCall.function.arguments) : {};
920
+ } catch (parseErr) {
921
+ this._broadcast({
922
+ type: "toolError",
923
+ errorType: "input",
924
+ toolCallId: toolCall.id,
925
+ toolName: toolCall.function.name,
926
+ message: `Invalid JSON arguments: ${parseErr instanceof Error ? parseErr.message : "Parse error"}`
927
+ });
928
+ continue;
929
+ }
930
+ if (toolDef.needsApproval) {
931
+ const needApproval = await toolDef.needsApproval(args);
932
+ if (needApproval) {
933
+ const approvalId = crypto.randomUUID();
934
+ const approved = await new Promise((resolve) => {
935
+ this._pendingToolApprovals.set(approvalId, { resolve });
936
+ this._broadcast({
937
+ type: "toolApprovalRequest",
938
+ approvalId,
939
+ toolCallId: toolCall.id,
940
+ toolName: toolCall.function.name,
941
+ arguments: toolCall.function.arguments
942
+ });
943
+ });
944
+ if (!approved) {
945
+ const errorMsg = "Tool execution rejected by user";
946
+ this._broadcast({
947
+ type: "toolError",
948
+ errorType: "output",
949
+ toolCallId: toolCall.id,
950
+ toolName: toolCall.function.name,
951
+ message: errorMsg
952
+ });
953
+ const rejectedMessage = {
954
+ id: crypto.randomUUID(),
955
+ role: "tool",
956
+ toolCallId: toolCall.id,
957
+ content: JSON.stringify({
958
+ error: errorMsg,
959
+ rejected: true
960
+ }),
961
+ createdAt: Date.now()
962
+ };
963
+ this._persistMessage(rejectedMessage);
964
+ if (typeof this.maxPersistedMessages !== "number") {
965
+ this.messages.push(rejectedMessage);
966
+ }
967
+ this._broadcast({
968
+ type: "messageUpdated",
969
+ message: rejectedMessage
970
+ });
971
+ continue;
972
+ }
973
+ }
974
+ }
975
+ console.log(
976
+ `[ChatAgent] Executing server tool: ${toolCall.function.name}`,
977
+ args
978
+ );
979
+ const result = await toolDef.execute(args);
980
+ const toolMessage = {
981
+ id: crypto.randomUUID(),
982
+ role: "tool",
983
+ toolCallId: toolCall.id,
984
+ content: JSON.stringify(result),
985
+ createdAt: Date.now()
986
+ };
987
+ this._persistMessage(toolMessage);
988
+ if (typeof this.maxPersistedMessages !== "number") {
989
+ this.messages.push(toolMessage);
990
+ }
991
+ this._broadcast({ type: "messageUpdated", message: toolMessage });
992
+ console.log(
993
+ `[ChatAgent] Server tool completed: ${toolCall.function.name}`,
994
+ result
995
+ );
996
+ } catch (err) {
997
+ console.error(
998
+ `[ChatAgent] Server tool error: ${toolCall.function.name}`,
999
+ err
1000
+ );
1001
+ const errorMsg = err instanceof Error ? err.message : "Tool execution failed";
1002
+ this._broadcast({
1003
+ type: "toolError",
1004
+ errorType: "output",
1005
+ toolCallId: toolCall.id,
1006
+ toolName: toolCall.function.name,
1007
+ message: errorMsg
1008
+ });
1009
+ const errorMessage = {
1010
+ id: crypto.randomUUID(),
1011
+ role: "tool",
1012
+ toolCallId: toolCall.id,
1013
+ content: JSON.stringify({ error: errorMsg }),
1014
+ createdAt: Date.now()
1015
+ };
1016
+ this._persistMessage(errorMessage);
1017
+ if (typeof this.maxPersistedMessages !== "number") {
1018
+ this.messages.push(errorMessage);
1019
+ }
1020
+ this._broadcast({
1021
+ type: "messageUpdated",
1022
+ message: errorMessage
1023
+ });
1024
+ }
1025
+ }
1026
+ if (executedServerTools) {
1027
+ await this._generateAIResponse();
1028
+ }
1029
+ return executedServerTools;
1030
+ }
1031
+ _handleResumeStream(streamId) {
1032
+ if (!this.dbIsStreamKnown(streamId)) {
1033
+ this._broadcast({
1034
+ type: "streamResume",
1035
+ streamId,
1036
+ chunks: [],
1037
+ done: true
1038
+ });
1039
+ return;
1040
+ }
1041
+ const chunks = this._getStreamChunks(streamId);
1042
+ const isLive = this._openRouterStreamLive && this._activeStreamId === streamId;
1043
+ this._broadcast({
1044
+ type: "streamResume",
1045
+ streamId,
1046
+ chunks,
1047
+ done: !isLive
1048
+ });
1049
+ if (!isLive && this._activeStreamId === streamId) {
1050
+ this._finalizeOrphanedStreamFromChunks(streamId);
1051
+ }
1052
+ }
1053
+ // ============================================================================
1054
+ // Cleanup
1055
+ // ============================================================================
1056
+ async destroy() {
1057
+ for (const controller of this._abortControllers.values()) {
1058
+ controller.abort();
1059
+ }
1060
+ this._abortControllers.clear();
1061
+ this._flushChunkBuffer();
1062
+ await super.destroy();
1063
+ }
1064
+ };
1065
+
1066
+ export { ChatAgentBase };
1067
+ //# sourceMappingURL=chunk-G5P5JXRF.js.map
1068
+ //# sourceMappingURL=chunk-G5P5JXRF.js.map