broapp 0.3.0 → 0.4.1
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/adapter.ts +1 -1
- package/src/ai/host/create-ai.ts +164 -2
- package/src/ai/host/index.ts +11 -1
- package/src/ai/host/run.ts +82 -13
- package/src/ai/host/tool.ts +10 -4
- package/src/ai/react/AiChat.tsx +8 -4
- package/src/ai/react/use-ai-chat.ts +55 -13
- package/src/cli/main.ts +7 -1
package/package.json
CHANGED
package/src/ai/host/adapter.ts
CHANGED
|
@@ -61,7 +61,7 @@ export interface ProviderAdapter {
|
|
|
61
61
|
models(config: AdapterConfig, signal: AbortSignal): Promise<BroappModel[]>;
|
|
62
62
|
/** Cheapest possible proof the config works. Must reject with {@link AdapterError}. */
|
|
63
63
|
test(config: AdapterConfig, signal: AbortSignal): Promise<void>;
|
|
64
|
-
/** The AI SDK model. Only `broapp/ai/host` calls this. */
|
|
64
|
+
/** The AI SDK model. Only `broapp/ai/host` calls this; other host code asks `Ai.model()`. */
|
|
65
65
|
model(config: AdapterConfig, modelId: string): LanguageModel;
|
|
66
66
|
}
|
|
67
67
|
|
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* `mount` has an implementation for every route in the contract, which it
|
|
11
11
|
* insists on.
|
|
12
12
|
*/
|
|
13
|
+
import type { LanguageModel } from 'ai';
|
|
13
14
|
import type { Bridge } from 'brobridge';
|
|
14
15
|
|
|
15
16
|
// Imported from the host entry point rather than from `host/app.ts` directly:
|
|
@@ -18,7 +19,7 @@ import type { Bridge } from 'brobridge';
|
|
|
18
19
|
// browser target polyfills `node:fs`, so the file stores alone would not stop
|
|
19
20
|
// this code from being bundled into a page.
|
|
20
21
|
import { createPendingApprovals, createReservedHostApp } from '../../host/index.ts';
|
|
21
|
-
import type { HostApp, HostLogger } from '../../host/app.ts';
|
|
22
|
+
import type { HostApp, HostLogger, StreamSink } from '../../host/app.ts';
|
|
22
23
|
import { publicError } from '../../shared/errors.ts';
|
|
23
24
|
import { aiContract, type AiContract } from '../shared/contract.ts';
|
|
24
25
|
import type { ProviderInfo } from '../shared/types.ts';
|
|
@@ -26,10 +27,66 @@ import type { ProviderInfo } from '../shared/types.ts';
|
|
|
26
27
|
import { AdapterError, toPublicError, type AdapterConfig, type ProviderAdapter } from './adapter.ts';
|
|
27
28
|
import { createRegistry, type Registry } from './registry.ts';
|
|
28
29
|
import { runChat, type RunDeps } from './run.ts';
|
|
30
|
+
import type { ChatEvent } from './run-types.ts';
|
|
29
31
|
import { createFileSecretStore, createMemorySecretStore } from './secrets.ts';
|
|
30
32
|
import { createSettingsStore } from './settings.ts';
|
|
31
33
|
import { openThreads, type ThreadStore } from './threads.ts';
|
|
32
|
-
import { GUARDED, type AiContextProviders, type AiTool } from './tool.ts';
|
|
34
|
+
import { GUARDED, type AiContextProviders, type AiTool, type ContextDocument } from './tool.ts';
|
|
35
|
+
|
|
36
|
+
/** How a turn went, beyond whether it ended well. */
|
|
37
|
+
export interface RunEndDetail {
|
|
38
|
+
readonly usage?: { readonly inputTokens: number; readonly outputTokens: number };
|
|
39
|
+
/** Tool round trips the turn made. */
|
|
40
|
+
readonly steps: number;
|
|
41
|
+
readonly ms: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What one turn was given, after the budget. Documents are exactly what the
|
|
46
|
+
* model saw.
|
|
47
|
+
*
|
|
48
|
+
* Reported before the model is called, so a listener can write down the turn's
|
|
49
|
+
* inputs with the identity they had then rather than reconstruct them later
|
|
50
|
+
* from settings that may since have changed.
|
|
51
|
+
*/
|
|
52
|
+
export interface DeliveredContext {
|
|
53
|
+
readonly system: string;
|
|
54
|
+
readonly documents: readonly ContextDocument[];
|
|
55
|
+
/** The person's message for this turn, which the system prompt does not carry. */
|
|
56
|
+
readonly message: string;
|
|
57
|
+
/** The provider and model the turn was sent to. */
|
|
58
|
+
readonly model: { readonly provider: string; readonly id: string };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** One turn run in-process by {@link Ai.turn}. */
|
|
62
|
+
export interface InProcessTurn {
|
|
63
|
+
readonly runId: string;
|
|
64
|
+
readonly message: string;
|
|
65
|
+
/** The model for this turn, within the configured provider. */
|
|
66
|
+
readonly modelId?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** How {@link Ai.turn} is answered and stopped. */
|
|
70
|
+
export interface InProcessTurnOptions {
|
|
71
|
+
/**
|
|
72
|
+
* The answer to each question the gate asks during the turn.
|
|
73
|
+
*
|
|
74
|
+
* The caller is the person's stand-in, so it decides exactly as the person
|
|
75
|
+
* would have: the gate still asks, and still records the answer.
|
|
76
|
+
*/
|
|
77
|
+
readonly answer: (question: { readonly tool: string; readonly input: unknown }) => boolean;
|
|
78
|
+
/** Aborting it cancels the turn, as a browser's cancel would. */
|
|
79
|
+
readonly signal?: AbortSignal;
|
|
80
|
+
readonly onEvent?: (event: ChatEvent) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** How an in-process turn ended, and everything it emitted. */
|
|
84
|
+
export interface InProcessTurnResult {
|
|
85
|
+
readonly status: 'succeeded' | 'failed' | 'cancelled';
|
|
86
|
+
readonly events: readonly ChatEvent[];
|
|
87
|
+
/** The reason a turn that could not start gave, such as no provider being set up. */
|
|
88
|
+
readonly error?: string;
|
|
89
|
+
}
|
|
33
90
|
|
|
34
91
|
/** What the application is, in the words a model is given. */
|
|
35
92
|
export interface AiAppDescription {
|
|
@@ -75,7 +132,16 @@ export interface CreateAiOptions {
|
|
|
75
132
|
runId: string,
|
|
76
133
|
status: 'succeeded' | 'failed' | 'cancelled',
|
|
77
134
|
summary: string,
|
|
135
|
+
detail?: RunEndDetail,
|
|
78
136
|
) => void;
|
|
137
|
+
/**
|
|
138
|
+
* Called once per turn, after the context budget and before the model, with
|
|
139
|
+
* what the model is about to be given.
|
|
140
|
+
*
|
|
141
|
+
* A hook that throws is logged and ignored: recording a turn is never a
|
|
142
|
+
* reason to fail it.
|
|
143
|
+
*/
|
|
144
|
+
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
79
145
|
}
|
|
80
146
|
|
|
81
147
|
/**
|
|
@@ -104,8 +170,29 @@ export interface Ai {
|
|
|
104
170
|
readonly activeStreams: number;
|
|
105
171
|
/** For tests, and for applications that read settings on the host. */
|
|
106
172
|
readonly registry: Registry;
|
|
173
|
+
/**
|
|
174
|
+
* The configured model, for host code that needs one outside a chat turn.
|
|
175
|
+
*
|
|
176
|
+
* Resolved the same way a turn resolves it, so a caller is told "AI is not
|
|
177
|
+
* set up yet" in the same words, and always a model instance — never a
|
|
178
|
+
* string the AI SDK would send to its gateway.
|
|
179
|
+
*/
|
|
180
|
+
model(override?: { readonly modelId?: string }): Promise<LanguageModel>;
|
|
181
|
+
/**
|
|
182
|
+
* Run one chat turn in this process, without a bridge or a browser.
|
|
183
|
+
*
|
|
184
|
+
* For scripts that drive the engineer themselves, such as a replay. It is
|
|
185
|
+
* the `ai.chat` route's own loop — the same tools, gate, context providers
|
|
186
|
+
* and hooks — with a sink that collects the events instead of writing them to
|
|
187
|
+
* a socket, and `answer` standing where the person's click would.
|
|
188
|
+
*/
|
|
189
|
+
turn(turn: InProcessTurn, options: InProcessTurnOptions): Promise<InProcessTurnResult>;
|
|
107
190
|
}
|
|
108
191
|
|
|
192
|
+
/** How long an in-process answer waits for its question to be registered. */
|
|
193
|
+
const ANSWER_ATTEMPTS = 200;
|
|
194
|
+
const ANSWER_INTERVAL_MS = 5;
|
|
195
|
+
|
|
109
196
|
/** How long a provider is given to answer a listing or a connection test. */
|
|
110
197
|
const PROVIDER_TIMEOUT_MS = 20_000;
|
|
111
198
|
|
|
@@ -215,6 +302,7 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
215
302
|
confirmTimeoutMs: options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
|
|
216
303
|
approvals,
|
|
217
304
|
...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
|
|
305
|
+
...(options.onContext === undefined ? {} : { onContext: options.onContext }),
|
|
218
306
|
logger: options.logger ?? console,
|
|
219
307
|
};
|
|
220
308
|
|
|
@@ -262,5 +350,79 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
262
350
|
return host.activeStreams;
|
|
263
351
|
},
|
|
264
352
|
registry,
|
|
353
|
+
model: async (override) => {
|
|
354
|
+
const { adapter, config, modelId } = await registry.resolve(override);
|
|
355
|
+
return adapter.model(config, modelId);
|
|
356
|
+
},
|
|
357
|
+
turn: async (turn, turnOptions) => {
|
|
358
|
+
const controller = new AbortController();
|
|
359
|
+
const relay = (): void => controller.abort(turnOptions.signal?.reason);
|
|
360
|
+
turnOptions.signal?.addEventListener('abort', relay, { once: true });
|
|
361
|
+
if (turnOptions.signal?.aborted === true) relay();
|
|
362
|
+
|
|
363
|
+
const events: ChatEvent[] = [];
|
|
364
|
+
/**
|
|
365
|
+
* Answer a question once the approval table holds it.
|
|
366
|
+
*
|
|
367
|
+
* The `confirm` event is emitted, and awaited, before the run's approver
|
|
368
|
+
* registers the question, so an answer given inside `emit` would find
|
|
369
|
+
* nobody waiting. This waits for it, briefly, the way a person's click
|
|
370
|
+
* necessarily arrives later.
|
|
371
|
+
*/
|
|
372
|
+
const settle = async (requestId: string, approved: boolean): Promise<void> => {
|
|
373
|
+
for (let attempt = 0; attempt < ANSWER_ATTEMPTS; attempt += 1) {
|
|
374
|
+
if (approvals.answer({ requestId, approved }) !== 'unknown') return;
|
|
375
|
+
await Bun.sleep(ANSWER_INTERVAL_MS);
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
const sink: StreamSink<ChatEvent> = {
|
|
379
|
+
signal: controller.signal,
|
|
380
|
+
sessionId: 'in-process',
|
|
381
|
+
emit(event) {
|
|
382
|
+
if (controller.signal.aborted) return Promise.reject(new Error('stream is no longer open'));
|
|
383
|
+
events.push(event);
|
|
384
|
+
turnOptions.onEvent?.(event);
|
|
385
|
+
if (event.type === 'confirm') {
|
|
386
|
+
// The gate's request id is `<runId>:<callId>`, which is what an
|
|
387
|
+
// event without one would have named.
|
|
388
|
+
const requestId = event.requestId ?? `${turn.runId}:${event.callId}`;
|
|
389
|
+
void settle(requestId, turnOptions.answer({ tool: event.tool ?? '', input: event.input }));
|
|
390
|
+
}
|
|
391
|
+
return Promise.resolve();
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
let status: InProcessTurnResult['status'] | null = null;
|
|
396
|
+
const onRunEnd = runDeps.onRunEnd;
|
|
397
|
+
try {
|
|
398
|
+
await runChat(
|
|
399
|
+
{
|
|
400
|
+
runId: turn.runId,
|
|
401
|
+
message: turn.message,
|
|
402
|
+
refs: [],
|
|
403
|
+
history: [],
|
|
404
|
+
...(turn.modelId === undefined ? {} : { modelId: turn.modelId }),
|
|
405
|
+
},
|
|
406
|
+
sink,
|
|
407
|
+
{
|
|
408
|
+
...runDeps,
|
|
409
|
+
// The turn's own ending is what this returns; whoever the layer
|
|
410
|
+
// was built to tell is still told.
|
|
411
|
+
onRunEnd: (runId, ended, summary, detail) => {
|
|
412
|
+
status = ended;
|
|
413
|
+
onRunEnd?.(runId, ended, summary, detail);
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
);
|
|
417
|
+
return { status: status ?? 'succeeded', events };
|
|
418
|
+
} catch (cause) {
|
|
419
|
+
// A turn that could not start — no provider set up, most often — ends
|
|
420
|
+
// failed with the sentence the browser would have been shown.
|
|
421
|
+
const error = String(cause instanceof Error ? cause.message : cause);
|
|
422
|
+
return { status: status ?? 'failed', events, error };
|
|
423
|
+
} finally {
|
|
424
|
+
turnOptions.signal?.removeEventListener('abort', relay);
|
|
425
|
+
}
|
|
426
|
+
},
|
|
265
427
|
};
|
|
266
428
|
}
|
package/src/ai/host/index.ts
CHANGED
|
@@ -7,7 +7,17 @@
|
|
|
7
7
|
* follows this import fails loudly instead.
|
|
8
8
|
*/
|
|
9
9
|
export { createAi } from './create-ai.ts';
|
|
10
|
-
export type {
|
|
10
|
+
export type {
|
|
11
|
+
Ai,
|
|
12
|
+
AiAppDescription,
|
|
13
|
+
CreateAiOptions,
|
|
14
|
+
DeliveredContext,
|
|
15
|
+
InProcessTurn,
|
|
16
|
+
InProcessTurnOptions,
|
|
17
|
+
InProcessTurnResult,
|
|
18
|
+
RunEndDetail,
|
|
19
|
+
} from './create-ai.ts';
|
|
20
|
+
export type { ChatEvent } from './run-types.ts';
|
|
11
21
|
|
|
12
22
|
export { AdapterError, isLoopbackUrl, toPublicError } from './adapter.ts';
|
|
13
23
|
export type { AdapterConfig, AdapterErrorCode, ProviderAdapter } from './adapter.ts';
|
package/src/ai/host/run.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { AdapterError } from './adapter.ts';
|
|
|
28
28
|
import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
|
|
29
29
|
import type { Registry } from './registry.ts';
|
|
30
30
|
import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
|
|
31
|
+
import type { DeliveredContext, RunEndDetail } from './create-ai.ts';
|
|
31
32
|
|
|
32
33
|
/** What the run loop needs from the `Ai` that owns it. */
|
|
33
34
|
export interface RunDeps {
|
|
@@ -53,7 +54,37 @@ export interface RunDeps {
|
|
|
53
54
|
* outside the AI layer — Autoapp's run store — close the record the gate has
|
|
54
55
|
* been writing steps into.
|
|
55
56
|
*/
|
|
56
|
-
readonly onRunEnd?: (
|
|
57
|
+
readonly onRunEnd?: (
|
|
58
|
+
runId: string,
|
|
59
|
+
status: 'succeeded' | 'failed' | 'cancelled',
|
|
60
|
+
summary: string,
|
|
61
|
+
detail?: RunEndDetail,
|
|
62
|
+
) => void;
|
|
63
|
+
/** Called once per turn with what the model is about to be given. */
|
|
64
|
+
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** What a turn counts as it goes, for {@link RunEndDetail}. */
|
|
68
|
+
interface TurnTally {
|
|
69
|
+
steps: number;
|
|
70
|
+
usage?: { inputTokens: number; outputTokens: number };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Call a listener's hook without letting it touch the turn.
|
|
75
|
+
*
|
|
76
|
+
* Both hooks exist so something outside the AI layer can write down what
|
|
77
|
+
* happened. A recorder that fails has failed at recording, not at the turn, so
|
|
78
|
+
* the failure is logged and the turn carries on exactly as it would have.
|
|
79
|
+
*/
|
|
80
|
+
function safely(logger: HostLogger, hook: string, call: () => void): void {
|
|
81
|
+
try {
|
|
82
|
+
call();
|
|
83
|
+
} catch (cause) {
|
|
84
|
+
logger.error(
|
|
85
|
+
`[broapp] ai ${hook} hook failed: ${String(cause instanceof Error ? cause.message : cause)}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
57
88
|
}
|
|
58
89
|
|
|
59
90
|
/**
|
|
@@ -177,7 +208,10 @@ async function assembleContext(
|
|
|
177
208
|
|
|
178
209
|
await load(params.refs);
|
|
179
210
|
if (searcher !== undefined) {
|
|
180
|
-
const found = await searcher(
|
|
211
|
+
const found = await searcher(
|
|
212
|
+
{ text: params.message, limit: SEARCH_LIMIT, runId: params.runId },
|
|
213
|
+
signal,
|
|
214
|
+
);
|
|
181
215
|
await load(found.map((entry) => entry.ref));
|
|
182
216
|
}
|
|
183
217
|
return fitToBudget(documents, deps.contextBudgetChars);
|
|
@@ -421,13 +455,24 @@ export async function runChat(
|
|
|
421
455
|
// browser cancelled and a turn that finished all have to close their record,
|
|
422
456
|
// or a run store is left with something that looks like it is still running.
|
|
423
457
|
let ended = false;
|
|
458
|
+
const started = Date.now();
|
|
459
|
+
const tally: TurnTally = { steps: 0 };
|
|
424
460
|
const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
|
|
425
461
|
if (ended) return;
|
|
426
462
|
ended = true;
|
|
427
|
-
|
|
463
|
+
const onRunEnd = deps.onRunEnd;
|
|
464
|
+
if (onRunEnd === undefined) return;
|
|
465
|
+
const detail: RunEndDetail = {
|
|
466
|
+
steps: tally.steps,
|
|
467
|
+
ms: Date.now() - started,
|
|
468
|
+
...(tally.usage === undefined ? {} : { usage: tally.usage }),
|
|
469
|
+
};
|
|
470
|
+
safely(deps.logger, 'onRunEnd', () =>
|
|
471
|
+
onRunEnd(params.runId, status, params.message.slice(0, SUMMARY_CHARS), detail),
|
|
472
|
+
);
|
|
428
473
|
};
|
|
429
474
|
try {
|
|
430
|
-
await runTurn(params, sink, deps, end);
|
|
475
|
+
await runTurn(params, sink, deps, end, tally);
|
|
431
476
|
end(sink.signal.aborted ? 'cancelled' : 'succeeded');
|
|
432
477
|
} catch (cause) {
|
|
433
478
|
end(sink.signal.aborted ? 'cancelled' : 'failed');
|
|
@@ -441,6 +486,7 @@ async function runTurn(
|
|
|
441
486
|
sink: StreamSink<ChatEvent>,
|
|
442
487
|
deps: RunDeps,
|
|
443
488
|
end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
|
|
489
|
+
tally: TurnTally,
|
|
444
490
|
): Promise<void> {
|
|
445
491
|
// Throws a PublicError when nothing is configured. `runStream` in host/app.ts
|
|
446
492
|
// turns that into the right thing on the wire, so it is not caught here.
|
|
@@ -473,12 +519,28 @@ async function runTurn(
|
|
|
473
519
|
requestId.startsWith(`${params.runId}:`) ? requestId.slice(params.runId.length + 1) : requestId,
|
|
474
520
|
);
|
|
475
521
|
|
|
522
|
+
// Built once and handed to both the listener and the model, so what is
|
|
523
|
+
// written down is the string that was sent rather than a second rendering of
|
|
524
|
+
// it that might differ.
|
|
525
|
+
const system = buildSystemPrompt(deps, documents);
|
|
526
|
+
const onContext = deps.onContext;
|
|
527
|
+
if (onContext !== undefined) {
|
|
528
|
+
safely(deps.logger, 'onContext', () =>
|
|
529
|
+
onContext(params.runId, {
|
|
530
|
+
system,
|
|
531
|
+
documents,
|
|
532
|
+
message: params.message,
|
|
533
|
+
model: { provider: resolved.adapter.id, id: resolved.modelId },
|
|
534
|
+
}),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
476
538
|
const result = streamText({
|
|
477
539
|
// Always a model *instance*. A string here would be resolved by the AI
|
|
478
540
|
// SDK's gateway, over the global fetch, to a Vercel host — see
|
|
479
541
|
// reports/01-spike.md. Nothing in this layer may pass one.
|
|
480
542
|
model: resolved.adapter.model(resolved.config, resolved.modelId),
|
|
481
|
-
system
|
|
543
|
+
system,
|
|
482
544
|
messages: toModelMessages(params),
|
|
483
545
|
tools: buildTools(params, deps, sink, approver),
|
|
484
546
|
stopWhen: stepCountIs(deps.maxSteps),
|
|
@@ -494,16 +556,23 @@ async function runTurn(
|
|
|
494
556
|
case 'text-delta':
|
|
495
557
|
await sink.emit({ type: 'text', text: part.text });
|
|
496
558
|
break;
|
|
497
|
-
case '
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
559
|
+
case 'tool-call':
|
|
560
|
+
// Counted here rather than in `execute`: a call the SDK rejected before
|
|
561
|
+
// it ran was still a round trip the model spent.
|
|
562
|
+
tally.steps += 1;
|
|
563
|
+
break;
|
|
564
|
+
case 'finish': {
|
|
565
|
+
// `ai` flattens the provider's nested usage object into plain
|
|
566
|
+
// numbers, either of which a provider may omit.
|
|
567
|
+
const usage = {
|
|
502
568
|
inputTokens: part.totalUsage.inputTokens ?? 0,
|
|
503
569
|
outputTokens: part.totalUsage.outputTokens ?? 0,
|
|
504
|
-
}
|
|
570
|
+
};
|
|
571
|
+
tally.usage = usage;
|
|
572
|
+
await sink.emit({ type: 'usage', ...usage });
|
|
505
573
|
await sink.emit({ type: 'done' });
|
|
506
574
|
break;
|
|
575
|
+
}
|
|
507
576
|
case 'error':
|
|
508
577
|
await sink.emit({
|
|
509
578
|
type: 'error',
|
|
@@ -531,8 +600,8 @@ async function runTurn(
|
|
|
531
600
|
end('cancelled');
|
|
532
601
|
return;
|
|
533
602
|
default:
|
|
534
|
-
// tool-
|
|
535
|
-
//
|
|
603
|
+
// tool-result, text-start, finish-step, reasoning, source, raw:
|
|
604
|
+
// either already emitted from `execute`, or not something the
|
|
536
605
|
// browser has a use for.
|
|
537
606
|
break;
|
|
538
607
|
}
|
package/src/ai/host/tool.ts
CHANGED
|
@@ -47,7 +47,8 @@ export interface GuardedToolDefinition {
|
|
|
47
47
|
readonly description: string;
|
|
48
48
|
readonly inputSchema: JsonSchema;
|
|
49
49
|
readonly effect: Effect;
|
|
50
|
-
run
|
|
50
|
+
/** The envelope is the run loop's, never the model's; a tool may record it and must not act on its channel. */
|
|
51
|
+
run(input: unknown, signal: AbortSignal, envelope?: Envelope): Promise<unknown>;
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
/**
|
|
@@ -67,7 +68,7 @@ export function guardedTool(gate: Gate, tool: GuardedToolDefinition): GuardedToo
|
|
|
67
68
|
effect: tool.effect,
|
|
68
69
|
execute: (input, envelope) =>
|
|
69
70
|
gate.guard({ ...envelope, route: tool.name, effect: tool.effect, input }, (signal) =>
|
|
70
|
-
tool.run(input, signal),
|
|
71
|
+
tool.run(input, signal, envelope),
|
|
71
72
|
),
|
|
72
73
|
};
|
|
73
74
|
}
|
|
@@ -88,8 +89,13 @@ export interface ContextDocument {
|
|
|
88
89
|
|
|
89
90
|
/** Where the model's knowledge of the application's data comes from. */
|
|
90
91
|
export interface AiContextProviders {
|
|
91
|
-
/**
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Records relevant to a query. Return refs and short snippets, not full content.
|
|
94
|
+
*
|
|
95
|
+
* `runId` is the turn asking, so a provider can write down what it offered
|
|
96
|
+
* to which run. It identifies; it grants nothing.
|
|
97
|
+
*/
|
|
98
|
+
search?(query: { text: string; limit: number; runId?: string }, signal: AbortSignal): Promise<ContextRef[]>;
|
|
93
99
|
/** Full content for named refs. Unknown refs are skipped, not errors. */
|
|
94
100
|
resolve?(refs: readonly string[], signal: AbortSignal): Promise<ContextDocument[]>;
|
|
95
101
|
}
|
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. */
|
|
@@ -152,18 +202,7 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
152
202
|
break;
|
|
153
203
|
case 'confirm':
|
|
154
204
|
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
|
-
}));
|
|
205
|
+
patchPending((message) => ({ ...message, toolCalls: attachConfirm(message.toolCalls, event) }));
|
|
167
206
|
break;
|
|
168
207
|
case 'tool-result': {
|
|
169
208
|
setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
|
|
@@ -296,12 +335,15 @@ export function useAiChat(options: AiChatOptions = {}): AiChatHook {
|
|
|
296
335
|
// Nobody was waiting: the turn timed out or was cancelled while the
|
|
297
336
|
// question was on screen.
|
|
298
337
|
setError('That request has expired.');
|
|
338
|
+
return;
|
|
299
339
|
}
|
|
340
|
+
patchPending((message) => ({ ...message, toolCalls: settleAnswer(message.toolCalls, callId) }));
|
|
341
|
+
setStatus((current) => (current === 'awaiting-confirmation' ? 'streaming' : current));
|
|
300
342
|
} catch (cause) {
|
|
301
343
|
setError(cause instanceof BroappError ? cause.message : 'That answer could not be sent.');
|
|
302
344
|
}
|
|
303
345
|
},
|
|
304
|
-
[shared],
|
|
346
|
+
[shared, patchPending],
|
|
305
347
|
);
|
|
306
348
|
|
|
307
349
|
const clear = React.useCallback((): void => {
|
package/src/cli/main.ts
CHANGED
|
@@ -21,7 +21,7 @@ Usage:
|
|
|
21
21
|
broapp build --page Build the UI document only
|
|
22
22
|
|
|
23
23
|
Build options:
|
|
24
|
-
--target <id> Compile for one target
|
|
24
|
+
--target <id> Compile for one target, named <name>-<id>. Repeatable. Default: this machine, named <name>.
|
|
25
25
|
--all-targets Compile every supported target.
|
|
26
26
|
--out-dir <path> Where executables go. Default: release
|
|
27
27
|
--no-minify Keep the bundle readable.
|
|
@@ -158,6 +158,12 @@ async function main(): Promise<number> {
|
|
|
158
158
|
name: config.binaryName,
|
|
159
159
|
outDir: flags.outDir ?? config.outDir,
|
|
160
160
|
targets,
|
|
161
|
+
// A binary built for a named target carries the target in its name,
|
|
162
|
+
// even when there is only one: a release workflow that asks for
|
|
163
|
+
// `--target linux-x64` is about to archive `<name>-linux-x64`, and a
|
|
164
|
+
// bare `<name>` there was the fault that emptied two releases. Only the
|
|
165
|
+
// default — this machine, nothing named — stays bare.
|
|
166
|
+
suffixTarget: flags.allTargets || flags.targets.length > 0,
|
|
161
167
|
minify: flags.minify,
|
|
162
168
|
bytecode: flags.bytecode && config.bytecode,
|
|
163
169
|
});
|