@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.
- package/README.md +443 -0
- package/package.json +74 -0
- package/src/chat-agent-base.ts +1128 -0
- package/src/chat-agent-drizzle.ts +227 -0
- package/src/chat-agent-sql.ts +199 -0
- package/src/chat-messages.ts +472 -0
- package/src/db/index.ts +21 -0
- package/src/db/schema.ts +47 -0
- package/src/index.ts +99 -0
|
@@ -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
|
+
}
|