broapp 0.4.0 → 0.4.2
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/package.json +1 -1
- package/src/ai/host/create-ai.ts +21 -9
- package/src/ai/host/run.ts +265 -9
- package/src/ai/host/threads.ts +198 -1
- package/src/ai/react/AiChat.tsx +8 -4
- package/src/ai/react/use-ai-chat.ts +65 -17
- package/src/ai/shared/contract.ts +3 -0
- package/src/ai/shared/types.ts +6 -0
package/package.json
CHANGED
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { createPendingApprovals, createReservedHostApp } from '../../host/index.
|
|
|
22
22
|
import type { HostApp, HostLogger, StreamSink } from '../../host/app.ts';
|
|
23
23
|
import { publicError } from '../../shared/errors.ts';
|
|
24
24
|
import { aiContract, type AiContract } from '../shared/contract.ts';
|
|
25
|
-
import type { ProviderInfo } from '../shared/types.ts';
|
|
25
|
+
import type { ChatTurn, ProviderInfo } from '../shared/types.ts';
|
|
26
26
|
|
|
27
27
|
import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
|
|
28
28
|
import { createRegistry, type Registry } from './registry.ts';
|
|
@@ -64,6 +64,11 @@ export interface InProcessTurn {
|
|
|
64
64
|
readonly message: string;
|
|
65
65
|
/** The model for this turn, within the configured provider. */
|
|
66
66
|
readonly modelId?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Earlier turns, as a browser would send them. An assistant turn naming a
|
|
69
|
+
* run this layer kept a transcript for is expanded exactly as on `ai.chat`.
|
|
70
|
+
*/
|
|
71
|
+
readonly history?: readonly ChatTurn[];
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** How {@link Ai.turn} is answered and stopped. */
|
|
@@ -291,6 +296,14 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
291
296
|
}
|
|
292
297
|
});
|
|
293
298
|
|
|
299
|
+
// Opened on the first conversation route and not before: an application
|
|
300
|
+
// whose user never opens the panel should not find a database in its data
|
|
301
|
+
// directory, and `createAi` is built unconditionally by every application
|
|
302
|
+
// that offers AI at all. A turn opens it too, to keep its transcript.
|
|
303
|
+
let threads: ThreadStore | null = null;
|
|
304
|
+
const threadStore = (): ThreadStore =>
|
|
305
|
+
(threads ??= openThreads(options.dataDir, options.logger === undefined ? {} : { logger: options.logger }));
|
|
306
|
+
|
|
294
307
|
const approvals = createPendingApprovals(options.logger);
|
|
295
308
|
const runDeps: RunDeps = {
|
|
296
309
|
registry,
|
|
@@ -303,6 +316,12 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
303
316
|
approvals,
|
|
304
317
|
...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
|
|
305
318
|
...(options.onContext === undefined ? {} : { onContext: options.onContext }),
|
|
319
|
+
transcripts: {
|
|
320
|
+
save: (runId, messages) => {
|
|
321
|
+
threadStore().saveTranscript(runId, messages);
|
|
322
|
+
},
|
|
323
|
+
read: (runId) => threadStore().transcript(runId),
|
|
324
|
+
},
|
|
306
325
|
logger: options.logger ?? console,
|
|
307
326
|
};
|
|
308
327
|
|
|
@@ -315,13 +334,6 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
315
334
|
approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
|
|
316
335
|
}));
|
|
317
336
|
|
|
318
|
-
// Opened on the first conversation route and not before: an application
|
|
319
|
-
// whose user never opens the panel should not find a database in its data
|
|
320
|
-
// directory, and `createAi` is built unconditionally by every application
|
|
321
|
-
// that offers AI at all.
|
|
322
|
-
let threads: ThreadStore | null = null;
|
|
323
|
-
const threadStore = (): ThreadStore => (threads ??= openThreads(options.dataDir));
|
|
324
|
-
|
|
325
337
|
host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
|
|
326
338
|
host.operation('ai.threadsCreate', (input) => threadStore().create(input));
|
|
327
339
|
host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
|
|
@@ -400,7 +412,7 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
400
412
|
runId: turn.runId,
|
|
401
413
|
message: turn.message,
|
|
402
414
|
refs: [],
|
|
403
|
-
history: [],
|
|
415
|
+
history: turn.history === undefined ? [] : [...turn.history],
|
|
404
416
|
...(turn.modelId === undefined ? {} : { modelId: turn.modelId }),
|
|
405
417
|
},
|
|
406
418
|
sink,
|
package/src/ai/host/run.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { ApprovalQuestion, Approver } from '../../host/gate.ts';
|
|
|
22
22
|
import type { Effect } from '../../shared/contract.ts';
|
|
23
23
|
import { fromTransportError, isPublicError, publicError } from '../../shared/errors.ts';
|
|
24
24
|
import type { ToolPermission } from '../shared/types.ts';
|
|
25
|
+
import type { ChatTurn } from '../shared/types.ts';
|
|
25
26
|
import type { ChatEvent, StreamChatParams } from './run-types.ts';
|
|
26
27
|
|
|
27
28
|
import { AdapterError } from './adapter.ts';
|
|
@@ -29,6 +30,18 @@ import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
|
|
|
29
30
|
import type { Registry } from './registry.ts';
|
|
30
31
|
import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
|
|
31
32
|
import type { DeliveredContext, RunEndDetail } from './create-ai.ts';
|
|
33
|
+
import type { ResponseMessage } from './threads.ts';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Where a turn's own transcript is kept and read back.
|
|
37
|
+
*
|
|
38
|
+
* Both sides are the host's: `save` is handed what the AI SDK produced, and
|
|
39
|
+
* `read` returns only what `save` wrote. A browser never supplies either.
|
|
40
|
+
*/
|
|
41
|
+
export interface RunTranscripts {
|
|
42
|
+
save(runId: string, messages: readonly ResponseMessage[]): void;
|
|
43
|
+
read(runId: string): readonly ResponseMessage[] | null;
|
|
44
|
+
}
|
|
32
45
|
|
|
33
46
|
/** What the run loop needs from the `Ai` that owns it. */
|
|
34
47
|
export interface RunDeps {
|
|
@@ -62,6 +75,11 @@ export interface RunDeps {
|
|
|
62
75
|
) => void;
|
|
63
76
|
/** Called once per turn with what the model is about to be given. */
|
|
64
77
|
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
78
|
+
/**
|
|
79
|
+
* The turn transcripts. Absent, every history turn is text and nothing is
|
|
80
|
+
* written, which is exactly the layer before transcripts existed.
|
|
81
|
+
*/
|
|
82
|
+
readonly transcripts?: RunTranscripts;
|
|
65
83
|
}
|
|
66
84
|
|
|
67
85
|
/** What a turn counts as it goes, for {@link RunEndDetail}. */
|
|
@@ -217,6 +235,120 @@ async function assembleContext(
|
|
|
217
235
|
return fitToBudget(documents, deps.contextBudgetChars);
|
|
218
236
|
}
|
|
219
237
|
|
|
238
|
+
/** The bounds on history expanded from transcripts. */
|
|
239
|
+
export interface HistoryLimits {
|
|
240
|
+
/** Assistant turns expanded, newest first. */
|
|
241
|
+
readonly turns: number;
|
|
242
|
+
/** Characters of one tool call's input, as JSON. */
|
|
243
|
+
readonly inputChars: number;
|
|
244
|
+
/** Characters of one tool result's output, as JSON. */
|
|
245
|
+
readonly outputChars: number;
|
|
246
|
+
/** Characters of every expanded message together, as JSON. */
|
|
247
|
+
readonly totalChars: number;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The bounds a turn uses. */
|
|
251
|
+
export const HISTORY_LIMITS: HistoryLimits = {
|
|
252
|
+
turns: 6,
|
|
253
|
+
inputChars: 1_000,
|
|
254
|
+
outputChars: 2_000,
|
|
255
|
+
totalChars: 60_000,
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/** The head of a long string, and how much was left out. Never a summary. */
|
|
259
|
+
function cut(text: string, max: number): string {
|
|
260
|
+
return text.length <= max ? text : `${text.slice(0, max)}<omitted ${String(text.length - max)} chars>`;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
264
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* A tool result's output, bounded.
|
|
269
|
+
*
|
|
270
|
+
* An `error` is kept whole wherever it is — an error-typed output, or the
|
|
271
|
+
* `{ error }` a failed tool returns — because it is the one thing a model
|
|
272
|
+
* needs verbatim to avoid repeating the call that caused it.
|
|
273
|
+
*/
|
|
274
|
+
function boundOutput(output: unknown, max: number): unknown {
|
|
275
|
+
if (!isRecord(output)) return output;
|
|
276
|
+
const type = output['type'];
|
|
277
|
+
if (type === 'error-text' || type === 'error-json' || type === 'execution-denied') return output;
|
|
278
|
+
if (type === 'text' && typeof output['value'] === 'string') {
|
|
279
|
+
return { type: 'text', value: cut(output['value'], max) };
|
|
280
|
+
}
|
|
281
|
+
const json = JSON.stringify(type === 'json' ? output['value'] : output) ?? '';
|
|
282
|
+
if (json.length <= max) return output;
|
|
283
|
+
const value = type === 'json' ? output['value'] : undefined;
|
|
284
|
+
if (isRecord(value) && 'error' in value) {
|
|
285
|
+
const { error, ...rest } = value;
|
|
286
|
+
return { type: 'json', value: { error, rest: cut(JSON.stringify(rest), max) } };
|
|
287
|
+
}
|
|
288
|
+
return { type: 'text', value: cut(json, max) };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** One transcript message with every tool input and output bounded. */
|
|
292
|
+
function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMessage {
|
|
293
|
+
if (!Array.isArray(message.content)) return message;
|
|
294
|
+
const content = (message.content as readonly unknown[]).map((part) => {
|
|
295
|
+
if (!isRecord(part)) return part;
|
|
296
|
+
if (part['type'] === 'tool-call') {
|
|
297
|
+
const json = JSON.stringify(part['input']) ?? '';
|
|
298
|
+
return json.length <= limits.inputChars ? part : { ...part, input: cut(json, limits.inputChars) };
|
|
299
|
+
}
|
|
300
|
+
if (part['type'] === 'tool-result') return { ...part, output: boundOutput(part['output'], limits.outputChars) };
|
|
301
|
+
return part;
|
|
302
|
+
});
|
|
303
|
+
return { ...message, content } as ModelMessage;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* History as the model is given it.
|
|
308
|
+
*
|
|
309
|
+
* Walking from the newest turn back, an assistant turn whose `runId` names a
|
|
310
|
+
* transcript the host holds is replaced by that transcript — its own tool calls
|
|
311
|
+
* and results, bounded — while fewer than `limits.turns` have been and the total
|
|
312
|
+
* stays under `limits.totalChars`. The first turn that would cross the total
|
|
313
|
+
* stays text, and so does every turn older than it. Every other turn is its text,
|
|
314
|
+
* exactly as before. User turns keep their place.
|
|
315
|
+
*/
|
|
316
|
+
export function expandHistory(
|
|
317
|
+
history: readonly ChatTurn[],
|
|
318
|
+
read: (runId: string) => readonly ResponseMessage[] | null,
|
|
319
|
+
limits: HistoryLimits = HISTORY_LIMITS,
|
|
320
|
+
): ModelMessage[] {
|
|
321
|
+
const segments: ModelMessage[][] = [];
|
|
322
|
+
let expanded = 0;
|
|
323
|
+
let total = 0;
|
|
324
|
+
let full = false;
|
|
325
|
+
for (let index = history.length - 1; index >= 0; index -= 1) {
|
|
326
|
+
const turn = history[index];
|
|
327
|
+
if (turn === undefined) continue;
|
|
328
|
+
const text: ModelMessage[] = [{ role: turn.role, content: turn.content }];
|
|
329
|
+
if (turn.role !== 'assistant' || turn.runId === undefined || full || expanded >= limits.turns) {
|
|
330
|
+
segments.push(text);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
const transcript = read(turn.runId);
|
|
334
|
+
if (transcript === null || transcript.length === 0) {
|
|
335
|
+
segments.push(text);
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
const bounded = transcript.map((message) => boundMessage(message, limits));
|
|
339
|
+
const chars = JSON.stringify(bounded).length;
|
|
340
|
+
if (total + chars > limits.totalChars) {
|
|
341
|
+
full = true;
|
|
342
|
+
segments.push(text);
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
total += chars;
|
|
346
|
+
expanded += 1;
|
|
347
|
+
segments.push(bounded);
|
|
348
|
+
}
|
|
349
|
+
return segments.reverse().flat();
|
|
350
|
+
}
|
|
351
|
+
|
|
220
352
|
/**
|
|
221
353
|
* A message the model may see, without whatever a caller invented.
|
|
222
354
|
*
|
|
@@ -224,13 +356,21 @@ async function assembleContext(
|
|
|
224
356
|
* are strings by contract, so an earlier turn's picture is already a
|
|
225
357
|
* `[image: name]` line the browser put there — the alternative, resending
|
|
226
358
|
* every image on every turn, would cost the user the same upload again on each
|
|
227
|
-
* question.
|
|
359
|
+
* question. An assistant turn that names its run is expanded from the host's
|
|
360
|
+
* own transcript by {@link expandHistory}.
|
|
228
361
|
*/
|
|
229
|
-
function toModelMessages(params: StreamChatParams): ModelMessage[] {
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
362
|
+
function toModelMessages(params: StreamChatParams, transcripts: RunTranscripts | undefined, logger: HostLogger): ModelMessage[] {
|
|
363
|
+
const read = (runId: string): readonly ResponseMessage[] | null => {
|
|
364
|
+
if (transcripts === undefined) return null;
|
|
365
|
+
try {
|
|
366
|
+
return transcripts.read(runId);
|
|
367
|
+
} catch (cause) {
|
|
368
|
+
// A store that cannot be read has no transcript to give; the turn is text.
|
|
369
|
+
logger.error(`[broapp] ai could not read the transcript of run ${runId}: ${String(cause instanceof Error ? cause.message : cause)}`);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
const messages: ModelMessage[] = expandHistory(params.history, read);
|
|
234
374
|
const files = params.files ?? [];
|
|
235
375
|
if (files.length === 0) {
|
|
236
376
|
messages.push({ role: 'user', content: params.message });
|
|
@@ -366,6 +506,7 @@ function buildTools(
|
|
|
366
506
|
deps: RunDeps,
|
|
367
507
|
sink: StreamSink<ChatEvent>,
|
|
368
508
|
approver: Approver,
|
|
509
|
+
recorder: TranscriptRecorder,
|
|
369
510
|
): ToolSet {
|
|
370
511
|
const tools: ToolSet = {};
|
|
371
512
|
for (const [name, definition] of Object.entries(deps.tools)) {
|
|
@@ -374,6 +515,7 @@ function buildTools(
|
|
|
374
515
|
inputSchema: jsonSchema(definition.inputSchema),
|
|
375
516
|
execute: async (input: unknown, options: { toolCallId: string }): Promise<unknown> => {
|
|
376
517
|
const callId = options.toolCallId;
|
|
518
|
+
recorder.called(callId, name, input);
|
|
377
519
|
await sink.emit({
|
|
378
520
|
type: 'tool-call',
|
|
379
521
|
callId,
|
|
@@ -409,12 +551,14 @@ function buildTools(
|
|
|
409
551
|
output: DECLINED,
|
|
410
552
|
denied: true,
|
|
411
553
|
});
|
|
554
|
+
recorder.answered(callId, DECLINED);
|
|
412
555
|
return DECLINED;
|
|
413
556
|
}
|
|
414
557
|
// One tool failing is not the turn failing. The model gets the
|
|
415
558
|
// reason and can carry on or explain.
|
|
416
559
|
output = { error: safeToolMessage(cause, name, deps.logger) };
|
|
417
560
|
}
|
|
561
|
+
recorder.answered(callId, output);
|
|
418
562
|
await sink.emit({ type: 'tool-result', callId, tool: name, output });
|
|
419
563
|
return output;
|
|
420
564
|
},
|
|
@@ -442,6 +586,59 @@ function safeToolMessage(cause: unknown, name: string, logger: HostLogger): stri
|
|
|
442
586
|
return 'The tool failed.';
|
|
443
587
|
}
|
|
444
588
|
|
|
589
|
+
/**
|
|
590
|
+
* What a turn has produced so far, for a turn that does not reach `finish`.
|
|
591
|
+
*
|
|
592
|
+
* A finished turn's transcript is the AI SDK's own `responseMessages`. A turn
|
|
593
|
+
* that is stopped never gets one, and a stopped turn is exactly the one whose
|
|
594
|
+
* "continue" needs it most; so the steps the SDK finished are kept as it gave
|
|
595
|
+
* them, and the step in flight is kept from its text and from the calls this
|
|
596
|
+
* layer ran. Storage removes a call that never got its result.
|
|
597
|
+
*/
|
|
598
|
+
class TranscriptRecorder {
|
|
599
|
+
/** Set once the model has been asked; before that there is nothing to keep. */
|
|
600
|
+
started = false;
|
|
601
|
+
private readonly finished: ResponseMessage[] = [];
|
|
602
|
+
private text = '';
|
|
603
|
+
private calls = new Map<string, { name: string; input: unknown; output?: { value: unknown } }>();
|
|
604
|
+
|
|
605
|
+
stepEnded(messages: readonly ResponseMessage[]): void {
|
|
606
|
+
this.finished.push(...messages);
|
|
607
|
+
this.text = '';
|
|
608
|
+
this.calls = new Map();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
wrote(text: string): void {
|
|
612
|
+
this.text += text;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
called(callId: string, name: string, input: unknown): void {
|
|
616
|
+
this.calls.set(callId, { name, input });
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
answered(callId: string, output: unknown): void {
|
|
620
|
+
const call = this.calls.get(callId);
|
|
621
|
+
if (call !== undefined) call.output = { value: output };
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/** Every finished step, then the step in flight. */
|
|
625
|
+
sofar(): ResponseMessage[] {
|
|
626
|
+
const assistant: Array<{ type: 'text'; text: string } | { type: 'tool-call'; toolCallId: string; toolName: string; input: unknown }> = [];
|
|
627
|
+
if (this.text !== '') assistant.push({ type: 'text', text: this.text });
|
|
628
|
+
const results: Array<{ type: 'tool-result'; toolCallId: string; toolName: string; output: { type: 'json'; value: never } }> = [];
|
|
629
|
+
for (const [toolCallId, call] of this.calls) {
|
|
630
|
+
assistant.push({ type: 'tool-call', toolCallId, toolName: call.name, input: call.input });
|
|
631
|
+
if (call.output !== undefined) {
|
|
632
|
+
results.push({ type: 'tool-result', toolCallId, toolName: call.name, output: { type: 'json', value: call.output.value as never } });
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
const out = [...this.finished];
|
|
636
|
+
if (assistant.length > 0) out.push({ role: 'assistant', content: assistant });
|
|
637
|
+
if (results.length > 0) out.push({ role: 'tool', content: results });
|
|
638
|
+
return out;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
445
642
|
/** How much of the person's message stands in for the whole turn. */
|
|
446
643
|
const SUMMARY_CHARS = 200;
|
|
447
644
|
|
|
@@ -457,6 +654,18 @@ export async function runChat(
|
|
|
457
654
|
let ended = false;
|
|
458
655
|
const started = Date.now();
|
|
459
656
|
const tally: TurnTally = { steps: 0 };
|
|
657
|
+
const recorder = new TranscriptRecorder();
|
|
658
|
+
const transcript = new TranscriptWriter(params.runId, deps);
|
|
659
|
+
// A turn that never reaches `finish` — stopped, failed, or cut off by the
|
|
660
|
+
// provider — still leaves what it did. A stop is written the moment it
|
|
661
|
+
// happens rather than when the loop notices: the AI SDK waits for a running
|
|
662
|
+
// tool before it ends the stream, and what the turn did is what had come back
|
|
663
|
+
// when the person stopped it, which is also what the browser was shown.
|
|
664
|
+
// Written once: a finished turn has already written the SDK's own messages.
|
|
665
|
+
const keepSoFar = (): void => {
|
|
666
|
+
if (recorder.started) transcript.write(recorder.sofar());
|
|
667
|
+
};
|
|
668
|
+
sink.signal.addEventListener('abort', keepSoFar, { once: true });
|
|
460
669
|
const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
|
|
461
670
|
if (ended) return;
|
|
462
671
|
ended = true;
|
|
@@ -472,11 +681,43 @@ export async function runChat(
|
|
|
472
681
|
);
|
|
473
682
|
};
|
|
474
683
|
try {
|
|
475
|
-
await runTurn(params, sink, deps, end, tally);
|
|
684
|
+
await runTurn(params, sink, deps, end, tally, recorder, transcript);
|
|
476
685
|
end(sink.signal.aborted ? 'cancelled' : 'succeeded');
|
|
477
686
|
} catch (cause) {
|
|
478
687
|
end(sink.signal.aborted ? 'cancelled' : 'failed');
|
|
479
688
|
throw cause;
|
|
689
|
+
} finally {
|
|
690
|
+
sink.signal.removeEventListener('abort', keepSoFar);
|
|
691
|
+
keepSoFar();
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Writes one turn's transcript, once, without ever failing the turn.
|
|
697
|
+
*
|
|
698
|
+
* A store that cannot write loses the transcript, not the turn: the error is
|
|
699
|
+
* logged, `done` still goes out, and the next turn's history for this run is
|
|
700
|
+
* its text.
|
|
701
|
+
*/
|
|
702
|
+
class TranscriptWriter {
|
|
703
|
+
private written = false;
|
|
704
|
+
|
|
705
|
+
constructor(
|
|
706
|
+
private readonly runId: string,
|
|
707
|
+
private readonly deps: RunDeps,
|
|
708
|
+
) {}
|
|
709
|
+
|
|
710
|
+
write(messages: readonly ResponseMessage[]): void {
|
|
711
|
+
const transcripts = this.deps.transcripts;
|
|
712
|
+
if (this.written || transcripts === undefined) return;
|
|
713
|
+
this.written = true;
|
|
714
|
+
try {
|
|
715
|
+
transcripts.save(this.runId, messages);
|
|
716
|
+
} catch (cause) {
|
|
717
|
+
this.deps.logger.error(
|
|
718
|
+
`[broapp] ai could not keep the transcript of run ${this.runId}: ${String(cause instanceof Error ? cause.message : cause)}`,
|
|
719
|
+
);
|
|
720
|
+
}
|
|
480
721
|
}
|
|
481
722
|
}
|
|
482
723
|
|
|
@@ -487,6 +728,8 @@ async function runTurn(
|
|
|
487
728
|
deps: RunDeps,
|
|
488
729
|
end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
|
|
489
730
|
tally: TurnTally,
|
|
731
|
+
recorder: TranscriptRecorder,
|
|
732
|
+
transcript: TranscriptWriter,
|
|
490
733
|
): Promise<void> {
|
|
491
734
|
// Throws a PublicError when nothing is configured. `runStream` in host/app.ts
|
|
492
735
|
// turns that into the right thing on the wire, so it is not caught here.
|
|
@@ -535,19 +778,29 @@ async function runTurn(
|
|
|
535
778
|
);
|
|
536
779
|
}
|
|
537
780
|
|
|
781
|
+
// From here the model has been asked, so there is a transcript to keep
|
|
782
|
+
// however the turn ends. A turn refused before this point has none.
|
|
783
|
+
recorder.started = true;
|
|
538
784
|
const result = streamText({
|
|
539
785
|
// Always a model *instance*. A string here would be resolved by the AI
|
|
540
786
|
// SDK's gateway, over the global fetch, to a Vercel host — see
|
|
541
787
|
// reports/01-spike.md. Nothing in this layer may pass one.
|
|
542
788
|
model: resolved.adapter.model(resolved.config, resolved.modelId),
|
|
543
789
|
system,
|
|
544
|
-
messages: toModelMessages(params),
|
|
545
|
-
tools: buildTools(params, deps, sink, approver),
|
|
790
|
+
messages: toModelMessages(params, deps.transcripts, deps.logger),
|
|
791
|
+
tools: buildTools(params, deps, sink, approver, recorder),
|
|
546
792
|
stopWhen: stepCountIs(deps.maxSteps),
|
|
547
793
|
abortSignal: sink.signal,
|
|
548
794
|
// The default handler prints the error; this layer reports it as an event
|
|
549
795
|
// and decides for itself what is safe to say.
|
|
550
796
|
onError: () => undefined,
|
|
797
|
+
// Both from the SDK's own pipeline rather than from the loop below, so a
|
|
798
|
+
// step's text and the step's end are seen in the order they happened even
|
|
799
|
+
// when the loop is behind, waiting on a slow socket.
|
|
800
|
+
onChunk: ({ chunk }) => {
|
|
801
|
+
if (chunk.type === 'text-delta') recorder.wrote(chunk.text);
|
|
802
|
+
},
|
|
803
|
+
onStepEnd: (step) => recorder.stepEnded(step.response.messages),
|
|
551
804
|
});
|
|
552
805
|
|
|
553
806
|
for await (const part of result.fullStream) {
|
|
@@ -569,6 +822,9 @@ async function runTurn(
|
|
|
569
822
|
outputTokens: part.totalUsage.outputTokens ?? 0,
|
|
570
823
|
};
|
|
571
824
|
tally.usage = usage;
|
|
825
|
+
// Before `done`: a client that saves the conversation on `done` and
|
|
826
|
+
// sends it back at once must find the run's transcript already there.
|
|
827
|
+
transcript.write(await result.responseMessages);
|
|
572
828
|
await sink.emit({ type: 'usage', ...usage });
|
|
573
829
|
await sink.emit({ type: 'done' });
|
|
574
830
|
break;
|
package/src/ai/host/threads.ts
CHANGED
|
@@ -14,6 +14,10 @@ import { Database } from 'bun:sqlite';
|
|
|
14
14
|
import { mkdirSync } from 'node:fs';
|
|
15
15
|
import { join } from 'node:path';
|
|
16
16
|
|
|
17
|
+
import type { AssistantModelMessage, ToolModelMessage } from 'ai';
|
|
18
|
+
|
|
19
|
+
import type { HostLogger } from '../../host/app.ts';
|
|
20
|
+
import { canonicalJson } from '../../host/gate.ts';
|
|
17
21
|
import { publicError } from '../../shared/errors.ts';
|
|
18
22
|
import type { StoredMessage, Thread } from '../shared/types.ts';
|
|
19
23
|
|
|
@@ -58,8 +62,40 @@ const MIGRATIONS: readonly string[] = [
|
|
|
58
62
|
OR (earlier.updated_at = threads.updated_at AND earlier.rowid <= threads.rowid)
|
|
59
63
|
);
|
|
60
64
|
CREATE INDEX threads_seq ON threads (seq DESC);`,
|
|
65
|
+
/*
|
|
66
|
+
* What the model itself saw and did on each turn, by run id.
|
|
67
|
+
*
|
|
68
|
+
* Written by the host from the AI SDK's own response messages, never from
|
|
69
|
+
* anything a browser saved: a history turn that names a run gets these back
|
|
70
|
+
* in place of its text. Not tied to a thread, because the host does not know
|
|
71
|
+
* which conversation a run belongs to; the age and count caps are the bound.
|
|
72
|
+
*/
|
|
73
|
+
`CREATE TABLE transcripts (
|
|
74
|
+
run_id TEXT PRIMARY KEY,
|
|
75
|
+
messages TEXT NOT NULL,
|
|
76
|
+
chars INTEGER NOT NULL,
|
|
77
|
+
created_at INTEGER NOT NULL
|
|
78
|
+
);
|
|
79
|
+
CREATE INDEX transcripts_created_at ON transcripts (created_at);`,
|
|
61
80
|
];
|
|
62
81
|
|
|
82
|
+
/**
|
|
83
|
+
* One message a turn produced: what `StreamTextResult.responseMessages` holds.
|
|
84
|
+
*
|
|
85
|
+
* `ai` declares `ResponseMessage` as exactly this union but does not export the
|
|
86
|
+
* name, so it is spelled out here from the two types it does export.
|
|
87
|
+
*/
|
|
88
|
+
export type ResponseMessage = AssistantModelMessage | ToolModelMessage;
|
|
89
|
+
|
|
90
|
+
/** The most characters one transcript may be; a longer turn stays text. */
|
|
91
|
+
export const MAX_TRANSCRIPT_CHARS = 200_000;
|
|
92
|
+
|
|
93
|
+
/** How long a transcript is kept. */
|
|
94
|
+
const TRANSCRIPT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
95
|
+
|
|
96
|
+
/** How many transcripts are kept, newest first. */
|
|
97
|
+
const TRANSCRIPT_MAX_COUNT = 2_000;
|
|
98
|
+
|
|
63
99
|
/** What a conversation is called until it has been named. */
|
|
64
100
|
export const DEFAULT_THREAD_TITLE = 'New conversation';
|
|
65
101
|
|
|
@@ -100,9 +136,104 @@ export interface ThreadStore {
|
|
|
100
136
|
remove(id: string): boolean;
|
|
101
137
|
/** Every conversation. Returns how many were deleted. */
|
|
102
138
|
clear(): number;
|
|
139
|
+
/**
|
|
140
|
+
* Keep one turn's response messages under its run id.
|
|
141
|
+
*
|
|
142
|
+
* Unpaired tool calls are removed first, provider metadata is dropped, and a
|
|
143
|
+
* transcript over {@link MAX_TRANSCRIPT_CHARS} is not written. Returns whether
|
|
144
|
+
* a row was written. Throws when the store cannot write.
|
|
145
|
+
*/
|
|
146
|
+
saveTranscript(runId: string, messages: readonly ResponseMessage[]): boolean;
|
|
147
|
+
/** A transcript this store wrote, or null when it holds none that reads back whole. */
|
|
148
|
+
transcript(runId: string): readonly ResponseMessage[] | null;
|
|
149
|
+
/** Delete transcripts older than 30 days or beyond the newest 2,000. Returns how many went. */
|
|
150
|
+
retainTranscripts(): number;
|
|
103
151
|
close(): void;
|
|
104
152
|
}
|
|
105
153
|
|
|
154
|
+
/** Options for {@link openThreads}. */
|
|
155
|
+
export interface OpenThreadsOptions {
|
|
156
|
+
/** Where an oversized or unreadable transcript is reported. Default `console`. */
|
|
157
|
+
readonly logger?: HostLogger;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A plain object, for reading a stored or SDK-given value without trusting its type. */
|
|
161
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
162
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A copy of an object without the provider's own fields, which no later prompt needs. */
|
|
166
|
+
function withoutProviderFields(value: Record<string, unknown>): Record<string, unknown> {
|
|
167
|
+
const out: Record<string, unknown> = {};
|
|
168
|
+
for (const [key, member] of Object.entries(value)) {
|
|
169
|
+
if (key === 'providerOptions' || key === 'providerMetadata' || key === 'providerExecuted') continue;
|
|
170
|
+
out[key] = member;
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A turn's response messages as they are kept.
|
|
177
|
+
*
|
|
178
|
+
* Two edits and nothing else. A tool call with no result — a turn stopped while
|
|
179
|
+
* the tool ran — is removed, because a provider refuses a prompt that has a call
|
|
180
|
+
* without its answer; a message that edit leaves empty goes with it. Provider
|
|
181
|
+
* metadata is dropped at the message, part and output level: it belongs to the
|
|
182
|
+
* request it came from, and tool inputs and outputs are left exactly as given.
|
|
183
|
+
*/
|
|
184
|
+
export function transcriptForStorage(messages: readonly ResponseMessage[]): ResponseMessage[] {
|
|
185
|
+
const answered = new Set<string>();
|
|
186
|
+
for (const message of messages) {
|
|
187
|
+
if (!Array.isArray(message.content)) continue;
|
|
188
|
+
for (const part of message.content as readonly unknown[]) {
|
|
189
|
+
if (isRecord(part) && part['type'] === 'tool-result' && typeof part['toolCallId'] === 'string') {
|
|
190
|
+
answered.add(part['toolCallId']);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const out: ResponseMessage[] = [];
|
|
195
|
+
for (const message of messages) {
|
|
196
|
+
if (!Array.isArray(message.content)) {
|
|
197
|
+
out.push(withoutProviderFields(message as unknown as Record<string, unknown>) as unknown as ResponseMessage);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const content: unknown[] = [];
|
|
201
|
+
for (const part of message.content as readonly unknown[]) {
|
|
202
|
+
if (!isRecord(part)) continue;
|
|
203
|
+
if (part['type'] === 'tool-call' && !answered.has(String(part['toolCallId']))) continue;
|
|
204
|
+
const kept = withoutProviderFields(part);
|
|
205
|
+
if (isRecord(kept['output'])) kept['output'] = withoutProviderFields(kept['output']);
|
|
206
|
+
content.push(kept);
|
|
207
|
+
}
|
|
208
|
+
if (content.length === 0) continue;
|
|
209
|
+
out.push({ ...withoutProviderFields(message as unknown as Record<string, unknown>), content } as unknown as ResponseMessage);
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Whether a stored value reads back as response messages.
|
|
216
|
+
*
|
|
217
|
+
* The row was written by this process, but a file can be edited or damaged, and
|
|
218
|
+
* what comes back goes into a prompt. Only an assistant or tool message with a
|
|
219
|
+
* string or an array of typed parts passes; a `system` role never does.
|
|
220
|
+
*/
|
|
221
|
+
function isTranscript(value: unknown): value is ResponseMessage[] {
|
|
222
|
+
if (!Array.isArray(value)) return false;
|
|
223
|
+
return value.every((message) => {
|
|
224
|
+
if (!isRecord(message)) return false;
|
|
225
|
+
const role = message['role'];
|
|
226
|
+
const content = message['content'];
|
|
227
|
+
if (role === 'assistant') {
|
|
228
|
+
return typeof content === 'string' || (Array.isArray(content) && content.every((part) => isRecord(part) && typeof part['type'] === 'string'));
|
|
229
|
+
}
|
|
230
|
+
if (role === 'tool') {
|
|
231
|
+
return Array.isArray(content) && content.every((part) => isRecord(part) && typeof part['type'] === 'string');
|
|
232
|
+
}
|
|
233
|
+
return false;
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
106
237
|
/** A row of `threads`, joined with its message count. */
|
|
107
238
|
interface ThreadRow {
|
|
108
239
|
id: string;
|
|
@@ -181,7 +312,8 @@ function derivedTitle(messages: readonly StoredMessage[]): string | null {
|
|
|
181
312
|
}
|
|
182
313
|
|
|
183
314
|
/** Open the conversation store for one data directory, migrating it as needed. */
|
|
184
|
-
export function openThreads(dataDir: string): ThreadStore {
|
|
315
|
+
export function openThreads(dataDir: string, options: OpenThreadsOptions = {}): ThreadStore {
|
|
316
|
+
const logger = options.logger ?? console;
|
|
185
317
|
const directory = join(dataDir, 'ai');
|
|
186
318
|
// The same mode the settings store uses: this directory holds what somebody
|
|
187
319
|
// wrote to their assistant, which is nobody else's business.
|
|
@@ -235,8 +367,32 @@ export function openThreads(dataDir: string): ThreadStore {
|
|
|
235
367
|
remove: db.query<{ id: string }, [string]>('DELETE FROM threads WHERE id = ? RETURNING id'),
|
|
236
368
|
count: db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM threads'),
|
|
237
369
|
clear: db.query<unknown, []>('DELETE FROM threads'),
|
|
370
|
+
saveTranscript: db.query<unknown, [string, string, number, number]>(
|
|
371
|
+
`INSERT INTO transcripts (run_id, messages, chars, created_at) VALUES (?, ?, ?, ?)
|
|
372
|
+
ON CONFLICT (run_id) DO UPDATE SET messages = excluded.messages, chars = excluded.chars,
|
|
373
|
+
created_at = excluded.created_at`,
|
|
374
|
+
),
|
|
375
|
+
transcript: db.query<{ messages: string }, [string]>('SELECT messages FROM transcripts WHERE run_id = ?'),
|
|
376
|
+
expireTranscripts: db.query<{ run_id: string }, [number]>(
|
|
377
|
+
'DELETE FROM transcripts WHERE created_at < ? RETURNING run_id',
|
|
378
|
+
),
|
|
379
|
+
capTranscripts: db.query<{ run_id: string }, [number]>(
|
|
380
|
+
`DELETE FROM transcripts WHERE rowid NOT IN (
|
|
381
|
+
SELECT rowid FROM transcripts ORDER BY created_at DESC, rowid DESC LIMIT ?
|
|
382
|
+
) RETURNING run_id`,
|
|
383
|
+
),
|
|
238
384
|
};
|
|
239
385
|
|
|
386
|
+
/** One unreadable transcript is reported once, not on every turn that names it. */
|
|
387
|
+
const reported = new Set<string>();
|
|
388
|
+
|
|
389
|
+
/** Both caps, in one transaction, so a crash never leaves one applied without the other. */
|
|
390
|
+
const retain = (): number =>
|
|
391
|
+
db.transaction(() => {
|
|
392
|
+
const expired = statements.expireTranscripts.all(Date.now() - TRANSCRIPT_MAX_AGE_MS).length;
|
|
393
|
+
return expired + statements.capTranscripts.all(TRANSCRIPT_MAX_COUNT).length;
|
|
394
|
+
})();
|
|
395
|
+
|
|
240
396
|
/** The row, or the sentence a browser shows when a conversation is gone. */
|
|
241
397
|
function mustGet(id: string): ThreadRow {
|
|
242
398
|
const row = statements.byId.get(id);
|
|
@@ -244,6 +400,10 @@ export function openThreads(dataDir: string): ThreadStore {
|
|
|
244
400
|
return row;
|
|
245
401
|
}
|
|
246
402
|
|
|
403
|
+
// Retention runs on open as well as on every save, so a store that is only
|
|
404
|
+
// read still sheds what has aged out.
|
|
405
|
+
retain();
|
|
406
|
+
|
|
247
407
|
const store: ThreadStore = {
|
|
248
408
|
path,
|
|
249
409
|
|
|
@@ -318,6 +478,43 @@ export function openThreads(dataDir: string): ThreadStore {
|
|
|
318
478
|
return before;
|
|
319
479
|
},
|
|
320
480
|
|
|
481
|
+
saveTranscript(runId, messages) {
|
|
482
|
+
const kept = transcriptForStorage(messages);
|
|
483
|
+
const json = canonicalJson(kept);
|
|
484
|
+
if (json.length > MAX_TRANSCRIPT_CHARS) {
|
|
485
|
+
logger.warn(
|
|
486
|
+
`[broapp] ai transcript for run ${runId} is ${String(json.length)} characters, over ${String(MAX_TRANSCRIPT_CHARS)}; the turn stays text`,
|
|
487
|
+
);
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
db.transaction(() => {
|
|
491
|
+
statements.saveTranscript.run(runId, json, json.length, Date.now());
|
|
492
|
+
retain();
|
|
493
|
+
})();
|
|
494
|
+
return true;
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
transcript(runId) {
|
|
498
|
+
const row = statements.transcript.get(runId);
|
|
499
|
+
if (row === null) return null;
|
|
500
|
+
let parsed: unknown;
|
|
501
|
+
try {
|
|
502
|
+
parsed = JSON.parse(row.messages);
|
|
503
|
+
} catch {
|
|
504
|
+
parsed = undefined;
|
|
505
|
+
}
|
|
506
|
+
if (!isTranscript(parsed) || row.messages.length > MAX_TRANSCRIPT_CHARS) {
|
|
507
|
+
if (!reported.has(runId)) {
|
|
508
|
+
reported.add(runId);
|
|
509
|
+
logger.warn(`[broapp] ai transcript for run ${runId} does not read back as messages; the turn stays text`);
|
|
510
|
+
}
|
|
511
|
+
return null;
|
|
512
|
+
}
|
|
513
|
+
return parsed;
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
retainTranscripts: retain,
|
|
517
|
+
|
|
321
518
|
close() {
|
|
322
519
|
// Checkpointing folds the WAL back into the main file, so what is left
|
|
323
520
|
// behind is one complete database rather than one that needs its
|
package/src/ai/react/AiChat.tsx
CHANGED
|
@@ -71,20 +71,24 @@ function ToolCall({
|
|
|
71
71
|
<div
|
|
72
72
|
className={`ai-chat__confirm${urgent ? ' ai-chat__confirm--urgent' : ''}`}
|
|
73
73
|
role="group"
|
|
74
|
-
aria-label={`Allow ${call.tool}?`}
|
|
74
|
+
aria-label={`Allow ${call.asks ?? call.tool}?`}
|
|
75
75
|
>
|
|
76
|
-
|
|
76
|
+
{/* A question about one of this call's own steps says which step. */}
|
|
77
|
+
<span>{call.asks === undefined ? 'Allow this?' : `Allow ${call.asks}?`}</span>
|
|
78
|
+
{call.asks === undefined || call.asksInput === undefined ? null : (
|
|
79
|
+
<pre className="ai-chat__json">{JSON.stringify(call.asksInput, null, 2)}</pre>
|
|
80
|
+
)}
|
|
77
81
|
{call.expiresAt === undefined ? null : (
|
|
78
82
|
<span className="ai-chat__expires">expires in {countdown(call.expiresAt, now)}</span>
|
|
79
83
|
)}
|
|
80
84
|
<button
|
|
81
85
|
className="button button--primary"
|
|
82
86
|
type="button"
|
|
83
|
-
onClick={() => onConfirm(call.callId, true)}
|
|
87
|
+
onClick={() => onConfirm(call.confirmId ?? call.callId, true)}
|
|
84
88
|
>
|
|
85
89
|
Allow
|
|
86
90
|
</button>
|
|
87
|
-
<button className="button" type="button" onClick={() => onConfirm(call.callId, false)}>
|
|
91
|
+
<button className="button" type="button" onClick={() => onConfirm(call.confirmId ?? call.callId, false)}>
|
|
88
92
|
Decline
|
|
89
93
|
</button>
|
|
90
94
|
</div>
|
|
@@ -24,6 +24,56 @@ export interface ToolCallState {
|
|
|
24
24
|
readonly output?: unknown;
|
|
25
25
|
/** While awaiting confirmation: when the question stops waiting. */
|
|
26
26
|
readonly expiresAt?: number;
|
|
27
|
+
/**
|
|
28
|
+
* While awaiting confirmation for one of this call's own steps: the id the
|
|
29
|
+
* answer goes to (`<callId>.<step>`), what that step runs and its input.
|
|
30
|
+
* Absent when the question is about the call itself.
|
|
31
|
+
*/
|
|
32
|
+
readonly confirmId?: string;
|
|
33
|
+
readonly asks?: string;
|
|
34
|
+
readonly asksInput?: unknown;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Put a question on the card it belongs to.
|
|
39
|
+
*
|
|
40
|
+
* A question about the call itself names the call's own id. A tool that runs
|
|
41
|
+
* several gated steps — the launcher's `candidate.cycle` builds and previews
|
|
42
|
+
* after the patch it was called with — asks once per step, as `<callId>.<step>`,
|
|
43
|
+
* so each answer is to exactly one action. Those questions go on the parent's
|
|
44
|
+
* card, which then says what it is asking about.
|
|
45
|
+
*/
|
|
46
|
+
export function attachConfirm(
|
|
47
|
+
calls: readonly ToolCallState[],
|
|
48
|
+
event: { readonly callId?: string; readonly tool?: string; readonly input?: unknown; readonly expiresAt?: number },
|
|
49
|
+
): ToolCallState[] {
|
|
50
|
+
const id = event.callId ?? '';
|
|
51
|
+
const exact = calls.some((call) => call.callId === id);
|
|
52
|
+
return calls.map((call) => {
|
|
53
|
+
const own = call.callId === id;
|
|
54
|
+
const step = !exact && id.startsWith(`${call.callId}.`);
|
|
55
|
+
if (!own && !step) return call;
|
|
56
|
+
const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, ...rest } = call;
|
|
57
|
+
return {
|
|
58
|
+
...rest,
|
|
59
|
+
status: 'awaiting-confirmation',
|
|
60
|
+
...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
|
|
61
|
+
...(step ? { confirmId: id, asks: event.tool ?? '', asksInput: event.input } : {}),
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* An answer was accepted: the card goes back to running until its result or
|
|
68
|
+
* its next question arrives. Without this a card whose tool asks again later
|
|
69
|
+
* would keep offering buttons for a question already answered.
|
|
70
|
+
*/
|
|
71
|
+
export function settleAnswer(calls: readonly ToolCallState[], answeredId: string): ToolCallState[] {
|
|
72
|
+
return calls.map((call) => {
|
|
73
|
+
if (call.status !== 'awaiting-confirmation' || (call.callId !== answeredId && call.confirmId !== answeredId)) return call;
|
|
74
|
+
const { confirmId: _confirmId, asks: _asks, asksInput: _asksInput, expiresAt: _expiresAt, ...rest } = call;
|
|
75
|
+
return { ...rest, status: 'running' };
|
|
76
|
+
});
|
|
27
77
|
}
|
|
28
78
|
|
|
29
79
|
/** One message in the transcript. */
|
|
@@ -35,6 +85,8 @@ export type ChatMessage =
|
|
|
35
85
|
readonly content: string;
|
|
36
86
|
readonly toolCalls: ToolCallState[];
|
|
37
87
|
readonly pending: boolean;
|
|
88
|
+
/** The run that wrote it, sent back in `history` so the host can use its transcript. */
|
|
89
|
+
readonly runId: string;
|
|
38
90
|
};
|
|
39
91
|
|
|
40
92
|
/** What {@link useAiChat} returns. */
|
|
@@ -57,12 +109,16 @@ function newRunId(): string {
|
|
|
57
109
|
return crypto.randomUUID().replace(/-/g, '');
|
|
58
110
|
}
|
|
59
111
|
|
|
60
|
-
/** Every completed turn, as the model should see it. */
|
|
61
|
-
function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
|
|
112
|
+
/** Every completed turn, as the model should see it. Exported for its test; not part of `broapp/ai/react`. */
|
|
113
|
+
export function toHistory(messages: readonly ChatMessage[]): ChatTurn[] {
|
|
62
114
|
const turns: ChatTurn[] = [];
|
|
63
115
|
for (const message of messages) {
|
|
64
116
|
if (message.role === 'assistant' && (message.pending || message.content === '')) continue;
|
|
65
|
-
turns.push(
|
|
117
|
+
turns.push(
|
|
118
|
+
message.role === 'assistant'
|
|
119
|
+
? { role: 'assistant', content: message.content, runId: message.runId }
|
|
120
|
+
: { role: 'user', content: message.content },
|
|
121
|
+
);
|
|
66
122
|
}
|
|
67
123
|
return turns.slice(-MAX_HISTORY);
|
|
68
124
|
}
|
|
@@ -152,18 +208,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
152
208
|
break;
|
|
153
209
|
case 'confirm':
|
|
154
210
|
setStatus('awaiting-confirmation');
|
|
155
|
-
patchPending((message) => ({
|
|
156
|
-
...message,
|
|
157
|
-
toolCalls: message.toolCalls.map((call) =>
|
|
158
|
-
call.callId === event.callId
|
|
159
|
-
? {
|
|
160
|
-
...call,
|
|
161
|
-
status: 'awaiting-confirmation',
|
|
162
|
-
...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
|
|
163
|
-
}
|
|
164
|
-
: call,
|
|
165
|
-
),
|
|
166
|
-
}));
|
|
211
|
+
patchPending((message) => ({ ...message, toolCalls: attachConfirm(message.toolCalls, event) }));
|
|
167
212
|
break;
|
|
168
213
|
case 'tool-result': {
|
|
169
214
|
setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
|
|
@@ -228,7 +273,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
228
273
|
setMessages((current) => [
|
|
229
274
|
...current,
|
|
230
275
|
{ id: `${id}-user`, role: 'user', content: trimmed },
|
|
231
|
-
{ id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true },
|
|
276
|
+
{ id: `${id}-assistant`, role: 'assistant', content: '', toolCalls: [], pending: true, runId: id },
|
|
232
277
|
]);
|
|
233
278
|
|
|
234
279
|
try {
|
|
@@ -296,12 +341,15 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
296
341
|
// Nobody was waiting: the turn timed out or was cancelled while the
|
|
297
342
|
// question was on screen.
|
|
298
343
|
setError('That request has expired.');
|
|
344
|
+
return;
|
|
299
345
|
}
|
|
346
|
+
patchPending((message) => ({ ...message, toolCalls: settleAnswer(message.toolCalls, callId) }));
|
|
347
|
+
setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
|
|
300
348
|
} catch (cause) {
|
|
301
349
|
setError(cause instanceof BroappError ? cause.message : 'That answer could not be sent.');
|
|
302
350
|
}
|
|
303
351
|
},
|
|
304
|
-
[shared],
|
|
352
|
+
[shared, patchPending],
|
|
305
353
|
);
|
|
306
354
|
|
|
307
355
|
const clear = React.useCallback((): void => {
|
|
@@ -53,6 +53,9 @@ const settings = s.object({
|
|
|
53
53
|
const chatTurn = s.object({
|
|
54
54
|
role: s.enum(['user', 'assistant']),
|
|
55
55
|
content: s.string({ max: 20_000 }),
|
|
56
|
+
// Names a run whose transcript the host wrote. It only ever selects
|
|
57
|
+
// something the host already holds; an id it does not hold is ignored.
|
|
58
|
+
runId: s.optional(runId),
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
/**
|
package/src/ai/shared/types.ts
CHANGED
|
@@ -59,6 +59,12 @@ export type ToolPermission = 'read' | 'confirm';
|
|
|
59
59
|
export interface ChatTurn {
|
|
60
60
|
role: 'user' | 'assistant';
|
|
61
61
|
content: string;
|
|
62
|
+
/**
|
|
63
|
+
* On an assistant turn: the run that produced it. The host expands a turn
|
|
64
|
+
* whose run it holds a transcript for into that turn's own tool calls and
|
|
65
|
+
* results; any other turn, and any run it does not hold, is `content`.
|
|
66
|
+
*/
|
|
67
|
+
runId?: string;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
/**
|