broapp 0.2.0 → 0.3.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/package.json +1 -1
- package/src/ai/host/adapter.ts +4 -1
- package/src/ai/host/create-ai.ts +72 -6
- package/src/ai/host/fake.ts +11 -4
- package/src/ai/host/from-contract.ts +35 -15
- package/src/ai/host/index.ts +6 -1
- package/src/ai/host/registry.ts +19 -8
- package/src/ai/host/run.ts +247 -27
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +59 -53
- package/src/ai/react/AiChat.tsx +49 -1
- package/src/ai/react/AiSettings.tsx +8 -2
- package/src/ai/react/ai.css +14 -0
- package/src/ai/react/index.tsx +3 -0
- package/src/ai/react/use-ai-chat.ts +9 -1
- package/src/ai/shared/contract.ts +110 -1
- package/src/ai/shared/index.ts +3 -0
- package/src/ai/shared/types.check.ts +30 -2
- package/src/ai/shared/types.ts +62 -2
- package/src/host/app.ts +99 -16
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +22 -2
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/shared/contract.ts +49 -6
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +56 -2
- package/src/shared/index.ts +6 -1
- package/src/shared/schema.ts +16 -0
package/src/ai/host/run.ts
CHANGED
|
@@ -17,24 +17,54 @@ import { jsonSchema, stepCountIs, streamText, tool } from 'ai';
|
|
|
17
17
|
import type { ModelMessage, ToolSet } from 'ai';
|
|
18
18
|
|
|
19
19
|
import type { HostLogger, StreamSink } from '../../host/app.ts';
|
|
20
|
-
import {
|
|
20
|
+
import type { PendingApprovals } from '../../host/approvals.ts';
|
|
21
|
+
import type { ApprovalQuestion, Approver } from '../../host/gate.ts';
|
|
22
|
+
import type { Effect } from '../../shared/contract.ts';
|
|
23
|
+
import { fromTransportError, isPublicError, publicError } from '../../shared/errors.ts';
|
|
24
|
+
import type { ToolPermission } from '../shared/types.ts';
|
|
21
25
|
import type { ChatEvent, StreamChatParams } from './run-types.ts';
|
|
22
26
|
|
|
23
27
|
import { AdapterError } from './adapter.ts';
|
|
28
|
+
import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
|
|
24
29
|
import type { Registry } from './registry.ts';
|
|
25
|
-
import type { AiContextProviders, AiTool,
|
|
30
|
+
import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
|
|
26
31
|
|
|
27
32
|
/** What the run loop needs from the `Ai` that owns it. */
|
|
28
33
|
export interface RunDeps {
|
|
29
34
|
readonly registry: Registry;
|
|
30
|
-
readonly app: {
|
|
35
|
+
readonly app: {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly purpose: string;
|
|
38
|
+
readonly terminology?: readonly string[];
|
|
39
|
+
readonly instructions?: string;
|
|
40
|
+
};
|
|
31
41
|
readonly context: AiContextProviders;
|
|
32
42
|
readonly tools: Record<string, AiTool>;
|
|
33
43
|
readonly contextBudgetChars: number;
|
|
34
44
|
readonly maxSteps: number;
|
|
35
45
|
readonly confirmTimeoutMs: number;
|
|
36
|
-
readonly
|
|
46
|
+
readonly approvals: PendingApprovals;
|
|
37
47
|
readonly logger: HostLogger;
|
|
48
|
+
/**
|
|
49
|
+
* Called once when a turn ends, however it ends.
|
|
50
|
+
*
|
|
51
|
+
* The run identifier is chosen by the browser and used as the prefix of every
|
|
52
|
+
* request identifier the turn produces, so this is what lets something
|
|
53
|
+
* outside the AI layer — Autoapp's run store — close the record the gate has
|
|
54
|
+
* been writing steps into.
|
|
55
|
+
*/
|
|
56
|
+
readonly onRunEnd?: (runId: string, status: 'succeeded' | 'failed' | 'cancelled', summary: string) => void;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What the browser is told about a tool before it runs.
|
|
61
|
+
*
|
|
62
|
+
* The browser's vocabulary is still `read` and `confirm`, because that is what
|
|
63
|
+
* it shows a person; the gate's vocabulary is the effect. The mapping is here,
|
|
64
|
+
* in one place, so the two never drift into meaning different things.
|
|
65
|
+
*/
|
|
66
|
+
function permissionOf(effect: Effect): ToolPermission {
|
|
67
|
+
return effect === 'read' ? 'read' : 'confirm';
|
|
38
68
|
}
|
|
39
69
|
|
|
40
70
|
/** How many records a search may contribute to one turn. */
|
|
@@ -103,6 +133,12 @@ export function buildSystemPrompt(deps: RunDeps, documents: readonly ContextDocu
|
|
|
103
133
|
`You are the assistant built into "${deps.app.name}". ${deps.app.purpose}`,
|
|
104
134
|
];
|
|
105
135
|
if (terms.length > 0) lines.push(`Terms used in this application: ${terms.join(', ')}`);
|
|
136
|
+
// Verbatim, and before the rules: an application that needs standing
|
|
137
|
+
// instructions needs them read as part of what it is, not as an afterthought
|
|
138
|
+
// among the documents.
|
|
139
|
+
if (deps.app.instructions !== undefined && deps.app.instructions !== '') {
|
|
140
|
+
lines.push('', deps.app.instructions);
|
|
141
|
+
}
|
|
106
142
|
lines.push(
|
|
107
143
|
'',
|
|
108
144
|
'# Rules',
|
|
@@ -147,16 +183,72 @@ async function assembleContext(
|
|
|
147
183
|
return fitToBudget(documents, deps.contextBudgetChars);
|
|
148
184
|
}
|
|
149
185
|
|
|
150
|
-
/**
|
|
186
|
+
/**
|
|
187
|
+
* A message the model may see, without whatever a caller invented.
|
|
188
|
+
*
|
|
189
|
+
* Images ride on the message they arrived with and nowhere else. History turns
|
|
190
|
+
* are strings by contract, so an earlier turn's picture is already a
|
|
191
|
+
* `[image: name]` line the browser put there — the alternative, resending
|
|
192
|
+
* every image on every turn, would cost the user the same upload again on each
|
|
193
|
+
* question.
|
|
194
|
+
*/
|
|
151
195
|
function toModelMessages(params: StreamChatParams): ModelMessage[] {
|
|
152
196
|
const messages: ModelMessage[] = params.history.map((turn) => ({
|
|
153
197
|
role: turn.role,
|
|
154
198
|
content: turn.content,
|
|
155
199
|
}));
|
|
156
|
-
|
|
200
|
+
const files = params.files ?? [];
|
|
201
|
+
if (files.length === 0) {
|
|
202
|
+
messages.push({ role: 'user', content: params.message });
|
|
203
|
+
return messages;
|
|
204
|
+
}
|
|
205
|
+
messages.push({
|
|
206
|
+
role: 'user',
|
|
207
|
+
content: [
|
|
208
|
+
{ type: 'text', text: params.message },
|
|
209
|
+
// `data` is a base64 string, which `FilePart` accepts as `DataContent`.
|
|
210
|
+
...files.map((file) => ({
|
|
211
|
+
type: 'file' as const,
|
|
212
|
+
mediaType: file.mediaType,
|
|
213
|
+
data: file.data,
|
|
214
|
+
filename: file.name,
|
|
215
|
+
})),
|
|
216
|
+
],
|
|
217
|
+
});
|
|
157
218
|
return messages;
|
|
158
219
|
}
|
|
159
220
|
|
|
221
|
+
/** Base64 characters allowed across every image on one message. */
|
|
222
|
+
const MAX_FILE_CHARS_PER_TURN = 6_000_000;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Whether the model chosen in Settings can read an image.
|
|
226
|
+
*
|
|
227
|
+
* The capability is on the adapter's model list, which is fetched from the
|
|
228
|
+
* provider — so this asks for that list once per turn, and only when the turn
|
|
229
|
+
* actually carries an image. A model the list does not mention is assumed to
|
|
230
|
+
* see: a custom server's list is often incomplete, and a provider that cannot
|
|
231
|
+
* read the image will say so far more precisely than a guess here would.
|
|
232
|
+
*/
|
|
233
|
+
async function modelCanSee(
|
|
234
|
+
resolved: { adapter: ProviderAdapter; config: AdapterConfig; modelId: string },
|
|
235
|
+
signal: AbortSignal,
|
|
236
|
+
logger: HostLogger,
|
|
237
|
+
): Promise<boolean> {
|
|
238
|
+
try {
|
|
239
|
+
const models = await resolved.adapter.models(resolved.config, signal);
|
|
240
|
+
const found = models.find((model) => model.modelId === resolved.modelId);
|
|
241
|
+
return found === undefined ? true : found.capabilities.vision;
|
|
242
|
+
} catch (cause) {
|
|
243
|
+
// A listing that failed says nothing about the model. Refusing here would
|
|
244
|
+
// turn a provider hiccup into "your model cannot see", which is a lie.
|
|
245
|
+
logger.warn(
|
|
246
|
+
`[broapp] ai could not list models to check vision: ${String(cause instanceof Error ? cause.message : cause)}`,
|
|
247
|
+
);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
160
252
|
/**
|
|
161
253
|
* A message safe to show a user.
|
|
162
254
|
*
|
|
@@ -165,15 +257,82 @@ function toModelMessages(params: StreamChatParams): ModelMessage[] {
|
|
|
165
257
|
* URL, or an echo of the prompt.
|
|
166
258
|
*/
|
|
167
259
|
function safeMessage(cause: unknown, logger: HostLogger): string {
|
|
168
|
-
if (cause instanceof AdapterError || cause
|
|
260
|
+
if (cause instanceof AdapterError || isPublicError(cause)) return cause.message;
|
|
169
261
|
logger.error(
|
|
170
262
|
`[broapp] ai.chat provider error: ${String(cause instanceof Error ? (cause.stack ?? cause.message) : cause)}`,
|
|
171
263
|
);
|
|
172
264
|
return 'The AI provider returned an error.';
|
|
173
265
|
}
|
|
174
266
|
|
|
175
|
-
/**
|
|
176
|
-
|
|
267
|
+
/**
|
|
268
|
+
* The approver for one run.
|
|
269
|
+
*
|
|
270
|
+
* The gate decides that a person has to be asked; this is how the asking
|
|
271
|
+
* reaches them. The `confirm` event goes out on the same stream the browser is
|
|
272
|
+
* already watching, and the answer comes back on `ai.chatConfirm`, which hands
|
|
273
|
+
* it to the same approval table. The run's own deadline is applied here rather
|
|
274
|
+
* than left to the gate's, because how long a chat turn should wait for a
|
|
275
|
+
* click is a property of the chat, not of the application.
|
|
276
|
+
*/
|
|
277
|
+
function createRunApprover(
|
|
278
|
+
deps: RunDeps,
|
|
279
|
+
sink: StreamSink<ChatEvent>,
|
|
280
|
+
callIdOf: (requestId: string) => string,
|
|
281
|
+
): Approver {
|
|
282
|
+
return {
|
|
283
|
+
async ask(question: ApprovalQuestion, signal: AbortSignal): Promise<boolean> {
|
|
284
|
+
await sink.emit({
|
|
285
|
+
type: 'confirm',
|
|
286
|
+
callId: callIdOf(question.requestId),
|
|
287
|
+
tool: question.route,
|
|
288
|
+
input: question.input,
|
|
289
|
+
requestId: question.requestId,
|
|
290
|
+
releaseId: question.releaseId,
|
|
291
|
+
argumentsHash: question.argumentsHash,
|
|
292
|
+
// The narrower of the gate's window and the turn's, because the turn's
|
|
293
|
+
// is what actually stops the waiting below.
|
|
294
|
+
expiresAt: Math.min(question.expiresAt, Date.now() + deps.confirmTimeoutMs),
|
|
295
|
+
});
|
|
296
|
+
// A question nobody answers is a denial. The gate has a deadline of its
|
|
297
|
+
// own, but it belongs to the application; this one belongs to the turn.
|
|
298
|
+
const waiting = new AbortController();
|
|
299
|
+
const timer = setTimeout(
|
|
300
|
+
() => waiting.abort(new Error('the question timed out')),
|
|
301
|
+
deps.confirmTimeoutMs,
|
|
302
|
+
);
|
|
303
|
+
const relay = (): void => waiting.abort(new Error('the run was cancelled'));
|
|
304
|
+
signal.addEventListener('abort', relay, { once: true });
|
|
305
|
+
if (signal.aborted) relay();
|
|
306
|
+
try {
|
|
307
|
+
return await deps.approvals.ask(question, waiting.signal);
|
|
308
|
+
} finally {
|
|
309
|
+
clearTimeout(timer);
|
|
310
|
+
signal.removeEventListener('abort', relay);
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* True when a tool call failed because nobody allowed it.
|
|
318
|
+
*
|
|
319
|
+
* The gate throws a `PublicError` with code `rejected`; a tool that reached it
|
|
320
|
+
* through `HostApp.invoke` has had that turned into the marked bridge error the
|
|
321
|
+
* browser would have seen. Both are the same answer — the user said no — and
|
|
322
|
+
* both have to become an ordinary tool result rather than a failure.
|
|
323
|
+
*/
|
|
324
|
+
function wasDeclined(cause: unknown): boolean {
|
|
325
|
+
if (isPublicError(cause)) return cause.code === 'rejected';
|
|
326
|
+
return fromTransportError(cause).code === 'rejected';
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Build the AI SDK tool set, each call carrying the run's envelope to the gate. */
|
|
330
|
+
function buildTools(
|
|
331
|
+
params: StreamChatParams,
|
|
332
|
+
deps: RunDeps,
|
|
333
|
+
sink: StreamSink<ChatEvent>,
|
|
334
|
+
approver: Approver,
|
|
335
|
+
): ToolSet {
|
|
177
336
|
const tools: ToolSet = {};
|
|
178
337
|
for (const [name, definition] of Object.entries(deps.tools)) {
|
|
179
338
|
tools[name] = tool({
|
|
@@ -186,18 +345,27 @@ function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<Ch
|
|
|
186
345
|
callId,
|
|
187
346
|
tool: name,
|
|
188
347
|
input,
|
|
189
|
-
permission: definition.
|
|
348
|
+
permission: permissionOf(definition.effect),
|
|
190
349
|
});
|
|
191
350
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
351
|
+
let output: unknown;
|
|
352
|
+
try {
|
|
353
|
+
// The envelope is built here, from what the run loop knows. Nothing
|
|
354
|
+
// the model produced is read when it is filled in, which is what
|
|
355
|
+
// stops a model from calling a tool as the user.
|
|
356
|
+
output = await definition.execute(
|
|
357
|
+
input,
|
|
358
|
+
{
|
|
359
|
+
requestId: `${params.runId}:${callId}`,
|
|
360
|
+
channel: 'ai',
|
|
361
|
+
caller: `ai:${params.runId}`,
|
|
362
|
+
signal: sink.signal,
|
|
363
|
+
approver,
|
|
364
|
+
},
|
|
198
365
|
sink.signal,
|
|
199
366
|
);
|
|
200
|
-
|
|
367
|
+
} catch (cause) {
|
|
368
|
+
if (wasDeclined(cause)) {
|
|
201
369
|
// A refusal is an ordinary result, not a failure: the model has to
|
|
202
370
|
// be told, so it can say something rather than retry.
|
|
203
371
|
await sink.emit({
|
|
@@ -209,12 +377,6 @@ function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<Ch
|
|
|
209
377
|
});
|
|
210
378
|
return DECLINED;
|
|
211
379
|
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
let output: unknown;
|
|
215
|
-
try {
|
|
216
|
-
output = await definition.execute(input, sink.signal);
|
|
217
|
-
} catch (cause) {
|
|
218
380
|
// One tool failing is not the turn failing. The model gets the
|
|
219
381
|
// reason and can carry on or explain.
|
|
220
382
|
output = { error: safeToolMessage(cause, name, deps.logger) };
|
|
@@ -237,7 +399,7 @@ function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<Ch
|
|
|
237
399
|
* failure and is logged rather than shown.
|
|
238
400
|
*/
|
|
239
401
|
function safeToolMessage(cause: unknown, name: string, logger: HostLogger): string {
|
|
240
|
-
if (cause
|
|
402
|
+
if (isPublicError(cause)) return cause.message;
|
|
241
403
|
const reduced = fromTransportError(cause);
|
|
242
404
|
if (reduced.code !== 'internal') return reduced.message;
|
|
243
405
|
logger.error(
|
|
@@ -246,17 +408,71 @@ function safeToolMessage(cause: unknown, name: string, logger: HostLogger): stri
|
|
|
246
408
|
return 'The tool failed.';
|
|
247
409
|
}
|
|
248
410
|
|
|
249
|
-
/**
|
|
411
|
+
/** How much of the person's message stands in for the whole turn. */
|
|
412
|
+
const SUMMARY_CHARS = 200;
|
|
413
|
+
|
|
414
|
+
/** Run one `ai.chat` turn, and tell whoever is listening how it ended. */
|
|
250
415
|
export async function runChat(
|
|
251
416
|
params: StreamChatParams,
|
|
252
417
|
sink: StreamSink<ChatEvent>,
|
|
253
418
|
deps: RunDeps,
|
|
419
|
+
): Promise<void> {
|
|
420
|
+
// Reported exactly once, whatever happens: a turn that threw, a turn the
|
|
421
|
+
// browser cancelled and a turn that finished all have to close their record,
|
|
422
|
+
// or a run store is left with something that looks like it is still running.
|
|
423
|
+
let ended = false;
|
|
424
|
+
const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
|
|
425
|
+
if (ended) return;
|
|
426
|
+
ended = true;
|
|
427
|
+
deps.onRunEnd?.(params.runId, status, params.message.slice(0, SUMMARY_CHARS));
|
|
428
|
+
};
|
|
429
|
+
try {
|
|
430
|
+
await runTurn(params, sink, deps, end);
|
|
431
|
+
end(sink.signal.aborted ? 'cancelled' : 'succeeded');
|
|
432
|
+
} catch (cause) {
|
|
433
|
+
end(sink.signal.aborted ? 'cancelled' : 'failed');
|
|
434
|
+
throw cause;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The turn itself. */
|
|
439
|
+
async function runTurn(
|
|
440
|
+
params: StreamChatParams,
|
|
441
|
+
sink: StreamSink<ChatEvent>,
|
|
442
|
+
deps: RunDeps,
|
|
443
|
+
end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
|
|
254
444
|
): Promise<void> {
|
|
255
445
|
// Throws a PublicError when nothing is configured. `runStream` in host/app.ts
|
|
256
446
|
// turns that into the right thing on the wire, so it is not caught here.
|
|
257
|
-
|
|
447
|
+
// The turn's own model, when a conversation has one. `resolve` applies it
|
|
448
|
+
// after the provider and key checks, so the vision check below and the model
|
|
449
|
+
// instance built later both follow it without a second code path.
|
|
450
|
+
const resolved = await deps.registry.resolve({ modelId: params.modelId });
|
|
451
|
+
|
|
452
|
+
// Both checks come before anything is emitted, so a turn that cannot carry
|
|
453
|
+
// its images fails as a whole rather than half-answering.
|
|
454
|
+
const files = params.files ?? [];
|
|
455
|
+
if (files.length > 0) {
|
|
456
|
+
const characters = files.reduce((total, file) => total + file.data.length, 0);
|
|
457
|
+
if (characters > MAX_FILE_CHARS_PER_TURN) {
|
|
458
|
+
throw publicError.invalidInput('Images on one message are limited to about 4 MB together.');
|
|
459
|
+
}
|
|
460
|
+
if (!(await modelCanSee(resolved, sink.signal, deps.logger))) {
|
|
461
|
+
throw publicError.rejected(
|
|
462
|
+
'The chosen model cannot read images. Pick one that can in Settings.',
|
|
463
|
+
);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
258
467
|
const documents = await assembleContext(params, deps, sink.signal);
|
|
259
468
|
|
|
469
|
+
// One approver per run. The request identifier the gate will use is
|
|
470
|
+
// `<runId>:<callId>`, so the call a `confirm` event names can be recovered
|
|
471
|
+
// from it — which is what keeps `ai.chatConfirm`'s wire shape unchanged.
|
|
472
|
+
const approver = createRunApprover(deps, sink, (requestId) =>
|
|
473
|
+
requestId.startsWith(`${params.runId}:`) ? requestId.slice(params.runId.length + 1) : requestId,
|
|
474
|
+
);
|
|
475
|
+
|
|
260
476
|
const result = streamText({
|
|
261
477
|
// Always a model *instance*. A string here would be resolved by the AI
|
|
262
478
|
// SDK's gateway, over the global fetch, to a Vercel host — see
|
|
@@ -264,7 +480,7 @@ export async function runChat(
|
|
|
264
480
|
model: resolved.adapter.model(resolved.config, resolved.modelId),
|
|
265
481
|
system: buildSystemPrompt(deps, documents),
|
|
266
482
|
messages: toModelMessages(params),
|
|
267
|
-
tools: buildTools(params, deps, sink),
|
|
483
|
+
tools: buildTools(params, deps, sink, approver),
|
|
268
484
|
stopWhen: stepCountIs(deps.maxSteps),
|
|
269
485
|
abortSignal: sink.signal,
|
|
270
486
|
// The default handler prints the error; this layer reports it as an event
|
|
@@ -294,6 +510,9 @@ export async function runChat(
|
|
|
294
510
|
code: 'provider',
|
|
295
511
|
message: safeMessage(part.error, deps.logger),
|
|
296
512
|
});
|
|
513
|
+
// The stream ends here rather than at `done`, so the turn's outcome is
|
|
514
|
+
// settled here too.
|
|
515
|
+
end('failed');
|
|
297
516
|
return;
|
|
298
517
|
case 'tool-error': {
|
|
299
518
|
// `execute` never throws, so this means the SDK failed before the tool
|
|
@@ -309,6 +528,7 @@ export async function runChat(
|
|
|
309
528
|
break;
|
|
310
529
|
}
|
|
311
530
|
case 'abort':
|
|
531
|
+
end('cancelled');
|
|
312
532
|
return;
|
|
313
533
|
default:
|
|
314
534
|
// tool-call, tool-result, text-start, finish-step, reasoning, source,
|