pi-roundtable-webchat 0.8.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/src/chat.ts ADDED
@@ -0,0 +1,661 @@
1
+ import {
2
+ type AgentRuntime,
3
+ type ChannelClaim,
4
+ type ChannelKey,
5
+ type ConversationPort,
6
+ type ConversationRecord,
7
+ type ConversationRegistry,
8
+ type ConversationTurns,
9
+ channelKey,
10
+ type Logger,
11
+ type RouteSocket,
12
+ type Speaker,
13
+ TIERS,
14
+ type Tier,
15
+ type ToolSelection,
16
+ type TranscriptEntry,
17
+ type TurnResult,
18
+ } from "pi-roundtable";
19
+ import type { WebAccess } from "./access.ts";
20
+ import { RateWindow, TurnBudget } from "./budget.ts";
21
+ import { type Connection, Connections } from "./connections.ts";
22
+ import { TokenRefused, type TokenVerifier, type WebIdentity } from "./oidc.ts";
23
+ import { PromptDesk } from "./prompts.ts";
24
+ import {
25
+ CLOSE_CODES,
26
+ type ClientFrame,
27
+ type ErrorCode,
28
+ type PersonaSummary,
29
+ parseClientFrame,
30
+ type ServerFrame,
31
+ WEBCHAT_PROTOCOL_VERSION,
32
+ } from "./protocol.ts";
33
+ import { WebSurface } from "./surface.ts";
34
+
35
+ /** A conversation kind a client may open. */
36
+ export interface WebPersona {
37
+ /** The conversation kind; "owner" and "agent" belong to the host and are refused. */
38
+ kind: string;
39
+ /** How clients list it; default the kind. */
40
+ label?: string;
41
+ /**
42
+ * Its system prompt, read when a conversation's session is made. Absent when another plugin
43
+ * contributes the persona of this kind.
44
+ */
45
+ prompt?(): string;
46
+ /** The lowest tier that may open and use it; default member. */
47
+ minTier?: Tier;
48
+ /** The tools of its turns; default the plugins' `agentSelection`. */
49
+ selection?: ToolSelection;
50
+ }
51
+
52
+ /** The limits of one web chat. */
53
+ export interface WebChatLimits {
54
+ /** Connections one person may hold open at once; default 5. */
55
+ connectionsPerPrincipal: number;
56
+ /** New conversations one person may hold before writing in them; default 20. */
57
+ unusedConversationsPerPrincipal: number;
58
+ /** New conversations one person may open in any hour, written in or not; default 60. */
59
+ newConversationsPerHour: number;
60
+ /**
61
+ * Turns one person may have running or queued at once, across their conversations; default 2.
62
+ * A conversation also holds at most its running turn and one queued behind it.
63
+ */
64
+ turnsPerPrincipal: number;
65
+ /** The longest message text in characters; default 32 000. */
66
+ messageChars: number;
67
+ /** How long an approval or question waits for an answer; default 30 minutes. */
68
+ promptTimeoutMs: number;
69
+ /** How long before a token expires the client is asked for a fresh one; default 60 seconds. */
70
+ reauthLeadMs: number;
71
+ }
72
+
73
+ export interface WebChatDeps {
74
+ surface: string;
75
+ verifier: TokenVerifier;
76
+ access: WebAccess;
77
+ personas: readonly WebPersona[];
78
+ limits: WebChatLimits;
79
+ logger: Logger;
80
+ /** Read when used, after the host linked every plugin. */
81
+ registry(): ConversationRegistry;
82
+ conversations(): ConversationPort;
83
+ turns(): ConversationTurns;
84
+ runtime(): AgentRuntime;
85
+ /** The clock, in milliseconds; default `Date.now`. */
86
+ now?(): number;
87
+ }
88
+
89
+ /** A person, verified and admitted. */
90
+ export interface Admitted {
91
+ identity: WebIdentity;
92
+ speaker: Speaker;
93
+ }
94
+
95
+ interface Minted {
96
+ principal: string;
97
+ persona: string;
98
+ title?: string;
99
+ }
100
+
101
+ interface Pending {
102
+ speaker: Speaker;
103
+ persona: WebPersona;
104
+ title: string;
105
+ /** Whether the conversation was new when the message was accepted. */
106
+ fresh: boolean;
107
+ /** Frees the message's place in the person's turn budget; called once its turn ends or never runs. */
108
+ release(): void;
109
+ }
110
+
111
+ /** Why a conversation or persona was refused. */
112
+ export class Refusal extends Error {
113
+ override name = "Refusal";
114
+ readonly code: ErrorCode;
115
+
116
+ constructor(code: ErrorCode) {
117
+ super(code);
118
+ this.code = code;
119
+ }
120
+ }
121
+
122
+ const TITLE_CHARS = 80;
123
+ const HOUR_MS = 60 * 60_000;
124
+ /** The longest delay a timer keeps; a longer one fires at once. */
125
+ const MAX_TIMER_MS = 2 ** 31 - 1;
126
+ const RESERVED_KINDS = new Set(["owner", "agent"]);
127
+
128
+ const atLeast = (tier: Tier, least: Tier) =>
129
+ TIERS.indexOf(tier) >= TIERS.indexOf(least);
130
+
131
+ /** A conversation's title from its first message: its first line, cut to 80 characters. */
132
+ function titleOf(text: string): string {
133
+ const line = text.trim().split("\n")[0]?.trim() ?? "";
134
+ return line.length > TITLE_CHARS
135
+ ? `${line.slice(0, TITLE_CHARS - 1)}…`
136
+ : line;
137
+ }
138
+
139
+ /**
140
+ * The personas by kind; throws on a kind that is not a word, a reserved or repeated kind, a
141
+ * `minTier` that is not a tier, or an empty list. A JavaScript configuration is not type-checked,
142
+ * and an unknown `minTier` would let every tier open the persona.
143
+ */
144
+ export function checkPersonas(
145
+ personas: readonly WebPersona[],
146
+ ): Map<string, WebPersona> {
147
+ const byKind = new Map<string, WebPersona>();
148
+ for (const persona of personas) {
149
+ if (typeof persona.kind !== "string" || persona.kind === "")
150
+ throw new Error(
151
+ `webChat: a persona's kind must be a non-empty string; got ${JSON.stringify(persona.kind)}`,
152
+ );
153
+ if (persona.minTier !== undefined && !TIERS.includes(persona.minTier))
154
+ throw new Error(
155
+ `webChat: persona "${persona.kind}" minTier must be one of ${TIERS.join(", ")}; got ${JSON.stringify(persona.minTier)}`,
156
+ );
157
+ if (RESERVED_KINDS.has(persona.kind))
158
+ throw new Error(
159
+ `webChat: persona kind "${persona.kind}" belongs to the host; name yours another kind`,
160
+ );
161
+ if (byKind.has(persona.kind))
162
+ throw new Error(`webChat: persona "${persona.kind}" is listed twice`);
163
+ byKind.set(persona.kind, persona);
164
+ }
165
+ if (byKind.size === 0)
166
+ throw new Error("webChat: personas is empty; list at least one");
167
+ return byKind;
168
+ }
169
+
170
+ /**
171
+ * One web chat: its connections, conversations, prompts, surface, and claim. Every conversation
172
+ * is private to the person who opened it: only they see it listed, read it, write in it, stop it,
173
+ * or answer its prompts; the registry records whose it is at its first turn, and the claim
174
+ * checks that record inside the conversation's queue before each turn.
175
+ */
176
+ export class WebChat {
177
+ readonly surface: WebSurface;
178
+ readonly connections: Connections;
179
+ readonly desk: PromptDesk;
180
+ readonly #deps: WebChatDeps;
181
+ readonly #personas: Map<string, WebPersona>;
182
+ /** Conversations opened but not yet written in, so the registry has no record of them. */
183
+ readonly #minted = new Map<string, Minted>();
184
+ /** Whose each conversation is, once its person wrote in it during this process. */
185
+ readonly #owners = new Map<string, string>();
186
+ /** Accepted messages waiting for the claim, by message id. */
187
+ readonly #pending = new Map<string, Pending>();
188
+ /** Each person's turns, running or queued. */
189
+ readonly #turns: TurnBudget;
190
+ /** Each person's newly opened conversations. */
191
+ readonly #opened: RateWindow;
192
+
193
+ constructor(deps: WebChatDeps) {
194
+ this.#deps = deps;
195
+ this.#personas = checkPersonas(deps.personas);
196
+ this.#turns = new TurnBudget(deps.limits.turnsPerPrincipal);
197
+ this.#opened = new RateWindow(
198
+ deps.limits.newConversationsPerHour,
199
+ HOUR_MS,
200
+ deps.now ?? Date.now,
201
+ );
202
+ this.connections = new Connections({
203
+ perPrincipal: deps.limits.connectionsPerPrincipal,
204
+ logger: deps.logger,
205
+ });
206
+ const send = (principal: string, frame: ServerFrame) =>
207
+ this.connections.sendTo(principal, frame);
208
+ this.desk = new PromptDesk({
209
+ send,
210
+ timeoutMs: deps.limits.promptTimeoutMs,
211
+ });
212
+ this.surface = new WebSurface({
213
+ surface: deps.surface,
214
+ principalOf: (conversation) => this.#owners.get(conversation),
215
+ send,
216
+ prompts: this.desk,
217
+ });
218
+ }
219
+
220
+ /** The personas the claim's host must know: those this chat contributes a prompt for. */
221
+ get contributedPersonas(): { kind: string; prompt(): string }[] {
222
+ return [...this.#personas.values()].flatMap((persona) =>
223
+ persona.prompt ? [{ kind: persona.kind, prompt: persona.prompt }] : [],
224
+ );
225
+ }
226
+
227
+ /** The person a token names, at their tier; throws `TokenRefused`, or a Refusal when they are not admitted. */
228
+ async admit(token: string): Promise<Admitted> {
229
+ const identity = await this.#deps.verifier(token);
230
+ return this.admitIdentity(identity);
231
+ }
232
+
233
+ admitIdentity(identity: WebIdentity): Admitted {
234
+ const tier = this.#deps.access.tierOf(identity);
235
+ if (!tier) throw new Refusal("forbidden");
236
+ return {
237
+ identity,
238
+ speaker: { id: identity.id, name: identity.name, tier },
239
+ };
240
+ }
241
+
242
+ /** The personas a speaker may open. */
243
+ personasFor(speaker: Speaker): PersonaSummary[] {
244
+ return [...this.#personas.values()]
245
+ .filter((persona) => atLeast(speaker.tier, persona.minTier ?? "member"))
246
+ .map((persona) => ({
247
+ kind: persona.kind,
248
+ label: persona.label ?? persona.kind,
249
+ }));
250
+ }
251
+
252
+ #persona(kind: string, speaker: Speaker): WebPersona {
253
+ const persona = this.#personas.get(kind);
254
+ if (!persona || !atLeast(speaker.tier, persona.minTier ?? "member"))
255
+ throw new Refusal("unknown_persona");
256
+ return persona;
257
+ }
258
+
259
+ /** Opens a new conversation of a persona for the speaker; its id, unguessable. */
260
+ open(speaker: Speaker, kind: string, title?: string): string {
261
+ this.#persona(kind, speaker);
262
+ const unused = [...this.#minted.values()].filter(
263
+ (minted) => minted.principal === speaker.id,
264
+ ).length;
265
+ if (unused >= this.#deps.limits.unusedConversationsPerPrincipal)
266
+ throw new Refusal("too_many_conversations");
267
+ if (!this.#opened.take(speaker.id))
268
+ throw new Refusal("too_many_conversations");
269
+ const id = crypto.randomUUID();
270
+ const cut = title === undefined ? undefined : titleOf(title);
271
+ this.#minted.set(id, {
272
+ principal: speaker.id,
273
+ persona: kind,
274
+ ...(cut ? { title: cut } : {}),
275
+ });
276
+ return id;
277
+ }
278
+
279
+ /**
280
+ * The speaker's own conversation and its persona; a Refusal when it is someone else's
281
+ * (`forbidden`) or unknown (`unknown_conversation`), or its persona is no longer theirs.
282
+ */
283
+ async own(
284
+ speaker: Speaker,
285
+ conversation: string,
286
+ ): Promise<{
287
+ persona: WebPersona;
288
+ record?: ConversationRecord;
289
+ minted?: Minted;
290
+ }> {
291
+ const record = await this.#deps
292
+ .registry()
293
+ .get(channelKey(this.#deps.surface, conversation));
294
+ if (record) {
295
+ if (record.principalId !== speaker.id || record.visibility !== "private")
296
+ throw new Refusal("forbidden");
297
+ const persona = this.#persona(record.kind, speaker);
298
+ this.#owners.set(conversation, speaker.id);
299
+ return { persona, record };
300
+ }
301
+ const minted = this.#minted.get(conversation);
302
+ if (!minted) throw new Refusal("unknown_conversation");
303
+ if (minted.principal !== speaker.id) throw new Refusal("forbidden");
304
+ const persona = this.#persona(minted.persona, speaker);
305
+ // The surface learns whose a conversation is from each check that proves it.
306
+ this.#owners.set(conversation, speaker.id);
307
+ return { persona, minted };
308
+ }
309
+
310
+ /** The speaker's web conversations, the most recently active first. */
311
+ async list(speaker: Speaker): Promise<ConversationRecord[]> {
312
+ const records = await this.#deps.registry().list({ principal: speaker.id });
313
+ return records.filter(
314
+ (record) =>
315
+ record.surface === this.#deps.surface &&
316
+ record.visibility === "private",
317
+ );
318
+ }
319
+
320
+ /** The last `limit` messages of one of the speaker's conversations. */
321
+ async transcript(
322
+ speaker: Speaker,
323
+ conversation: string,
324
+ limit: number,
325
+ ): Promise<TranscriptEntry[]> {
326
+ const { record } = await this.own(speaker, conversation);
327
+ if (!record) return [];
328
+ return this.#deps.runtime().recentTranscript(record.key, limit);
329
+ }
330
+
331
+ // The WebSocket side.
332
+
333
+ /** A socket opened: count it, greet it, send the prompts waiting for its person, and watch its token. */
334
+ opened(socket: RouteSocket<Connection>): void {
335
+ const connection = socket.data;
336
+ if (!this.connections.opened(connection, socket)) {
337
+ socket.close(CLOSE_CODES.notAdmitted, "upgrade took too long");
338
+ return;
339
+ }
340
+ this.#greet(connection);
341
+ }
342
+
343
+ #greet(connection: Connection): void {
344
+ const { identity, speaker } = connection;
345
+ this.connections.send(connection, {
346
+ type: "ready",
347
+ protocol: WEBCHAT_PROTOCOL_VERSION,
348
+ speaker: { ...speaker },
349
+ personas: this.personasFor(speaker),
350
+ expiresAt: identity.expiresAt.toISOString(),
351
+ });
352
+ for (const frame of this.desk.openFor(identity.id))
353
+ this.connections.send(connection, frame);
354
+ this.#watchToken(connection);
355
+ }
356
+
357
+ /**
358
+ * Asks for a fresh token `reauthLeadMs` before the token expires and closes the socket when it
359
+ * does. A token further off than a timer can wait is looked at again once the longest wait passes.
360
+ */
361
+ #watchToken(connection: Connection): void {
362
+ for (const timer of connection.timers) clearTimeout(timer);
363
+ const left = connection.identity.expiresAt.getTime() - Date.now();
364
+ const lead = this.#deps.limits.reauthLeadMs;
365
+ if (left - lead > MAX_TIMER_MS) {
366
+ connection.timers = [
367
+ setTimeout(() => this.#watchToken(connection), MAX_TIMER_MS),
368
+ ];
369
+ return;
370
+ }
371
+ connection.timers = [
372
+ setTimeout(
373
+ () =>
374
+ this.connections.send(connection, {
375
+ type: "reauth",
376
+ expiresAt: connection.identity.expiresAt.toISOString(),
377
+ }),
378
+ Math.max(0, left - lead),
379
+ ),
380
+ setTimeout(
381
+ () =>
382
+ connection.socket?.close(CLOSE_CODES.tokenExpired, "token expired"),
383
+ Math.max(0, left),
384
+ ),
385
+ ];
386
+ }
387
+
388
+ closed(socket: RouteSocket<Connection>): void {
389
+ this.connections.closed(socket.data);
390
+ }
391
+
392
+ /** One frame from a socket; a refused frame is answered with `error`, never thrown. */
393
+ async message(
394
+ socket: RouteSocket<Connection>,
395
+ raw: string | Uint8Array,
396
+ ): Promise<void> {
397
+ const connection = socket.data;
398
+ const frame = parseClientFrame(raw);
399
+ if (!frame) {
400
+ this.connections.send(connection, { type: "error", code: "bad_frame" });
401
+ return;
402
+ }
403
+ try {
404
+ await this.#handle(connection, frame);
405
+ } catch (error) {
406
+ if (!(error instanceof Refusal)) throw error;
407
+ const ref =
408
+ frame.type === "send"
409
+ ? frame.id
410
+ : frame.type === "approval" || frame.type === "answer"
411
+ ? frame.prompt
412
+ : undefined;
413
+ this.connections.send(connection, {
414
+ type: "error",
415
+ code: error.code,
416
+ ...(ref ? { ref } : {}),
417
+ });
418
+ }
419
+ }
420
+
421
+ async #handle(connection: Connection, frame: ClientFrame): Promise<void> {
422
+ const { speaker } = connection;
423
+ switch (frame.type) {
424
+ case "auth":
425
+ return this.#reauth(connection, frame.token);
426
+ case "send":
427
+ return this.#send(connection, frame);
428
+ case "stop": {
429
+ await this.own(speaker, frame.conversation);
430
+ this.#deps
431
+ .conversations()
432
+ .stop(channelKey(this.#deps.surface, frame.conversation));
433
+ return;
434
+ }
435
+ case "approval": {
436
+ const refused = this.desk.approve(
437
+ speaker,
438
+ frame.prompt,
439
+ frame.approved,
440
+ );
441
+ if (refused) throw new Refusal(refused);
442
+ return;
443
+ }
444
+ case "answer": {
445
+ const refused = this.desk.answer(speaker, frame.prompt, {
446
+ choices: frame.choices,
447
+ ...(frame.text?.trim() ? { text: frame.text.trim() } : {}),
448
+ });
449
+ if (refused) throw new Refusal(refused);
450
+ return;
451
+ }
452
+ }
453
+ }
454
+
455
+ async #reauth(connection: Connection, token: string): Promise<void> {
456
+ let admitted: Admitted;
457
+ try {
458
+ admitted = await this.admit(token);
459
+ } catch (error) {
460
+ if (error instanceof TokenRefused) {
461
+ this.#deps.logger.warn(
462
+ { reason: error.reason },
463
+ "a web chat token renewal was refused",
464
+ );
465
+ connection.socket?.close(CLOSE_CODES.tokenExpired, "token refused");
466
+ return;
467
+ }
468
+ if (error instanceof Refusal) {
469
+ connection.socket?.close(CLOSE_CODES.notAdmitted, "not admitted");
470
+ return;
471
+ }
472
+ throw error;
473
+ }
474
+ // A connection belongs to one person: a token for someone else ends it.
475
+ if (admitted.identity.id !== connection.identity.id) {
476
+ connection.socket?.close(CLOSE_CODES.notAdmitted, "another person");
477
+ return;
478
+ }
479
+ connection.identity = admitted.identity;
480
+ connection.speaker = admitted.speaker;
481
+ this.#greet(connection);
482
+ }
483
+
484
+ async #send(
485
+ connection: Connection,
486
+ frame: Extract<ClientFrame, { type: "send" }>,
487
+ ): Promise<void> {
488
+ const { speaker } = connection;
489
+ const text = frame.text;
490
+ if (!text.trim() || text.length > this.#deps.limits.messageChars)
491
+ throw new Refusal("bad_frame");
492
+ // Checked before a conversation is opened for it, so a refused message opens none.
493
+ if (!this.#turns.allows(speaker.id, frame.conversation))
494
+ throw new Refusal("busy");
495
+ const conversation =
496
+ frame.conversation ?? this.open(speaker, frame.persona as string);
497
+ const { persona, record, minted } = await this.own(speaker, conversation);
498
+ const release = this.#turns.take(speaker.id, conversation);
499
+ if (!release) {
500
+ // Another message took the last place meanwhile.
501
+ if (!frame.conversation) this.#minted.delete(conversation);
502
+ throw new Refusal("busy");
503
+ }
504
+ const messageId = crypto.randomUUID();
505
+ this.#pending.set(messageId, {
506
+ speaker,
507
+ persona,
508
+ title: minted?.title ?? titleOf(text),
509
+ fresh: record === undefined,
510
+ release,
511
+ });
512
+ this.connections.send(connection, {
513
+ type: "accepted",
514
+ id: frame.id,
515
+ conversation,
516
+ });
517
+ try {
518
+ this.surface.deliver({
519
+ channel: channelKey(this.#deps.surface, conversation),
520
+ messageId,
521
+ authorId: speaker.id,
522
+ authorName: speaker.name,
523
+ authorIsBot: false,
524
+ authorRoleIds: connection.identity.roles,
525
+ isDirect: true,
526
+ mentionsBot: false,
527
+ repliesToBot: false,
528
+ text,
529
+ attachments: [],
530
+ });
531
+ } catch (error) {
532
+ this.#pending.delete(messageId);
533
+ release();
534
+ throw error;
535
+ }
536
+ }
537
+
538
+ // The claim.
539
+
540
+ /** The claim of this chat's conversations: only messages this chat accepted run, as their person's turns. */
541
+ claim(): ChannelClaim {
542
+ const { surface } = this.#deps;
543
+ return {
544
+ name: `webchat:${surface}`,
545
+ priority: 10,
546
+ owns: (channel) => channel.startsWith(`${surface}:`),
547
+ admit: (message) => {
548
+ const pending = this.#pending.get(message.messageId);
549
+ this.#pending.delete(message.messageId);
550
+ if (!pending || pending.speaker.id !== message.authorId) {
551
+ pending?.release();
552
+ return undefined;
553
+ }
554
+ return {
555
+ kind: "turn",
556
+ run: async () => {
557
+ try {
558
+ await this.#turn(message.channel, message.text, pending);
559
+ } finally {
560
+ pending.release();
561
+ }
562
+ },
563
+ failure: "a web chat turn failed",
564
+ };
565
+ },
566
+ startFresh: async (channel) => {
567
+ const record = await this.#deps.registry().get(channel);
568
+ await this.#deps.runtime().startFresh(channel);
569
+ return record?.kind ?? "";
570
+ },
571
+ stop: (channel) => this.#deps.runtime().stop(channel),
572
+ };
573
+ }
574
+
575
+ /** One turn, after checking again, inside the queue, that the conversation is still the speaker's. */
576
+ async #turn(
577
+ channel: ChannelKey,
578
+ text: string,
579
+ pending: Pending,
580
+ ): Promise<void> {
581
+ const { speaker, persona } = pending;
582
+ const conversation = this.surface.conversationOf(channel);
583
+ try {
584
+ await this.own(speaker, conversation);
585
+ } catch (error) {
586
+ if (!(error instanceof Refusal)) throw error;
587
+ this.#deps.logger.warn(
588
+ { channel, code: error.code },
589
+ "a web chat message was refused before its turn",
590
+ );
591
+ this.connections.sendTo(speaker.id, {
592
+ type: "error",
593
+ code: error.code,
594
+ });
595
+ return;
596
+ }
597
+ let answered = false;
598
+ try {
599
+ await this.#deps.turns().run({
600
+ channel,
601
+ kind: persona.kind,
602
+ text,
603
+ speaker,
604
+ interactive: true,
605
+ ...(persona.selection
606
+ ? {
607
+ selection: {
608
+ id: `webchat:${persona.kind}`,
609
+ ...persona.selection,
610
+ },
611
+ }
612
+ : {}),
613
+ conversation: {
614
+ visibility: "private",
615
+ ...(pending.fresh && pending.title ? { title: pending.title } : {}),
616
+ },
617
+ reply: async (result) => {
618
+ answered = true;
619
+ this.#reply(speaker.id, conversation, result);
620
+ },
621
+ });
622
+ } catch (error) {
623
+ // A turn the host refused before it ran, such as one whose conversation could not be
624
+ // recorded, has no reply: the person is told it failed, and the router logs the cause.
625
+ if (!answered)
626
+ this.connections.sendTo(speaker.id, {
627
+ type: "failed",
628
+ conversation,
629
+ stopped: false,
630
+ });
631
+ throw error;
632
+ }
633
+ // The registry now records it, so the opened-but-unused entry is spent.
634
+ this.#minted.delete(conversation);
635
+ }
636
+
637
+ #reply(principal: string, conversation: string, result: TurnResult): void {
638
+ if (!result.ok) {
639
+ this.connections.sendTo(principal, {
640
+ type: "failed",
641
+ conversation,
642
+ stopped: result.stopped === true,
643
+ });
644
+ return;
645
+ }
646
+ this.connections.sendTo(principal, {
647
+ type: "reply",
648
+ conversation,
649
+ text: result.text,
650
+ ...(result.thinking ? { thinking: result.thinking } : {}),
651
+ ...(result.files?.length
652
+ ? {
653
+ files: result.files.map((file) => ({
654
+ name: file.name,
655
+ data: Buffer.from(file.data).toString("base64"),
656
+ })),
657
+ }
658
+ : {}),
659
+ });
660
+ }
661
+ }