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