broapp 0.2.0 → 0.4.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.
@@ -17,24 +17,85 @@ 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 { fromTransportError, PublicError } from '../../shared/errors.ts';
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, Confirmations, ContextDocument } from './tool.ts';
30
+ import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
31
+ import type { DeliveredContext, RunEndDetail } from './create-ai.ts';
26
32
 
27
33
  /** What the run loop needs from the `Ai` that owns it. */
28
34
  export interface RunDeps {
29
35
  readonly registry: Registry;
30
- readonly app: { readonly name: string; readonly purpose: string; readonly terminology?: readonly string[] };
36
+ readonly app: {
37
+ readonly name: string;
38
+ readonly purpose: string;
39
+ readonly terminology?: readonly string[];
40
+ readonly instructions?: string;
41
+ };
31
42
  readonly context: AiContextProviders;
32
43
  readonly tools: Record<string, AiTool>;
33
44
  readonly contextBudgetChars: number;
34
45
  readonly maxSteps: number;
35
46
  readonly confirmTimeoutMs: number;
36
- readonly confirmations: Confirmations;
47
+ readonly approvals: PendingApprovals;
37
48
  readonly logger: HostLogger;
49
+ /**
50
+ * Called once when a turn ends, however it ends.
51
+ *
52
+ * The run identifier is chosen by the browser and used as the prefix of every
53
+ * request identifier the turn produces, so this is what lets something
54
+ * outside the AI layer — Autoapp's run store — close the record the gate has
55
+ * been writing steps into.
56
+ */
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
+ }
88
+ }
89
+
90
+ /**
91
+ * What the browser is told about a tool before it runs.
92
+ *
93
+ * The browser's vocabulary is still `read` and `confirm`, because that is what
94
+ * it shows a person; the gate's vocabulary is the effect. The mapping is here,
95
+ * in one place, so the two never drift into meaning different things.
96
+ */
97
+ function permissionOf(effect: Effect): ToolPermission {
98
+ return effect === 'read' ? 'read' : 'confirm';
38
99
  }
39
100
 
40
101
  /** How many records a search may contribute to one turn. */
@@ -103,6 +164,12 @@ export function buildSystemPrompt(deps: RunDeps, documents: readonly ContextDocu
103
164
  `You are the assistant built into "${deps.app.name}". ${deps.app.purpose}`,
104
165
  ];
105
166
  if (terms.length > 0) lines.push(`Terms used in this application: ${terms.join(', ')}`);
167
+ // Verbatim, and before the rules: an application that needs standing
168
+ // instructions needs them read as part of what it is, not as an afterthought
169
+ // among the documents.
170
+ if (deps.app.instructions !== undefined && deps.app.instructions !== '') {
171
+ lines.push('', deps.app.instructions);
172
+ }
106
173
  lines.push(
107
174
  '',
108
175
  '# Rules',
@@ -141,22 +208,81 @@ async function assembleContext(
141
208
 
142
209
  await load(params.refs);
143
210
  if (searcher !== undefined) {
144
- const found = await searcher({ text: params.message, limit: SEARCH_LIMIT }, signal);
211
+ const found = await searcher(
212
+ { text: params.message, limit: SEARCH_LIMIT, runId: params.runId },
213
+ signal,
214
+ );
145
215
  await load(found.map((entry) => entry.ref));
146
216
  }
147
217
  return fitToBudget(documents, deps.contextBudgetChars);
148
218
  }
149
219
 
150
- /** A message the model may see, without whatever a caller invented. */
220
+ /**
221
+ * A message the model may see, without whatever a caller invented.
222
+ *
223
+ * Images ride on the message they arrived with and nowhere else. History turns
224
+ * are strings by contract, so an earlier turn's picture is already a
225
+ * `[image: name]` line the browser put there — the alternative, resending
226
+ * every image on every turn, would cost the user the same upload again on each
227
+ * question.
228
+ */
151
229
  function toModelMessages(params: StreamChatParams): ModelMessage[] {
152
230
  const messages: ModelMessage[] = params.history.map((turn) => ({
153
231
  role: turn.role,
154
232
  content: turn.content,
155
233
  }));
156
- messages.push({ role: 'user', content: params.message });
234
+ const files = params.files ?? [];
235
+ if (files.length === 0) {
236
+ messages.push({ role: 'user', content: params.message });
237
+ return messages;
238
+ }
239
+ messages.push({
240
+ role: 'user',
241
+ content: [
242
+ { type: 'text', text: params.message },
243
+ // `data` is a base64 string, which `FilePart` accepts as `DataContent`.
244
+ ...files.map((file) => ({
245
+ type: 'file' as const,
246
+ mediaType: file.mediaType,
247
+ data: file.data,
248
+ filename: file.name,
249
+ })),
250
+ ],
251
+ });
157
252
  return messages;
158
253
  }
159
254
 
255
+ /** Base64 characters allowed across every image on one message. */
256
+ const MAX_FILE_CHARS_PER_TURN = 6_000_000;
257
+
258
+ /**
259
+ * Whether the model chosen in Settings can read an image.
260
+ *
261
+ * The capability is on the adapter's model list, which is fetched from the
262
+ * provider — so this asks for that list once per turn, and only when the turn
263
+ * actually carries an image. A model the list does not mention is assumed to
264
+ * see: a custom server's list is often incomplete, and a provider that cannot
265
+ * read the image will say so far more precisely than a guess here would.
266
+ */
267
+ async function modelCanSee(
268
+ resolved: { adapter: ProviderAdapter; config: AdapterConfig; modelId: string },
269
+ signal: AbortSignal,
270
+ logger: HostLogger,
271
+ ): Promise<boolean> {
272
+ try {
273
+ const models = await resolved.adapter.models(resolved.config, signal);
274
+ const found = models.find((model) => model.modelId === resolved.modelId);
275
+ return found === undefined ? true : found.capabilities.vision;
276
+ } catch (cause) {
277
+ // A listing that failed says nothing about the model. Refusing here would
278
+ // turn a provider hiccup into "your model cannot see", which is a lie.
279
+ logger.warn(
280
+ `[broapp] ai could not list models to check vision: ${String(cause instanceof Error ? cause.message : cause)}`,
281
+ );
282
+ return true;
283
+ }
284
+ }
285
+
160
286
  /**
161
287
  * A message safe to show a user.
162
288
  *
@@ -165,15 +291,82 @@ function toModelMessages(params: StreamChatParams): ModelMessage[] {
165
291
  * URL, or an echo of the prompt.
166
292
  */
167
293
  function safeMessage(cause: unknown, logger: HostLogger): string {
168
- if (cause instanceof AdapterError || cause instanceof PublicError) return cause.message;
294
+ if (cause instanceof AdapterError || isPublicError(cause)) return cause.message;
169
295
  logger.error(
170
296
  `[broapp] ai.chat provider error: ${String(cause instanceof Error ? (cause.stack ?? cause.message) : cause)}`,
171
297
  );
172
298
  return 'The AI provider returned an error.';
173
299
  }
174
300
 
175
- /** Build the AI SDK tool set, wrapping each tool in the permission dance. */
176
- function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<ChatEvent>): ToolSet {
301
+ /**
302
+ * The approver for one run.
303
+ *
304
+ * The gate decides that a person has to be asked; this is how the asking
305
+ * reaches them. The `confirm` event goes out on the same stream the browser is
306
+ * already watching, and the answer comes back on `ai.chatConfirm`, which hands
307
+ * it to the same approval table. The run's own deadline is applied here rather
308
+ * than left to the gate's, because how long a chat turn should wait for a
309
+ * click is a property of the chat, not of the application.
310
+ */
311
+ function createRunApprover(
312
+ deps: RunDeps,
313
+ sink: StreamSink<ChatEvent>,
314
+ callIdOf: (requestId: string) => string,
315
+ ): Approver {
316
+ return {
317
+ async ask(question: ApprovalQuestion, signal: AbortSignal): Promise<boolean> {
318
+ await sink.emit({
319
+ type: 'confirm',
320
+ callId: callIdOf(question.requestId),
321
+ tool: question.route,
322
+ input: question.input,
323
+ requestId: question.requestId,
324
+ releaseId: question.releaseId,
325
+ argumentsHash: question.argumentsHash,
326
+ // The narrower of the gate's window and the turn's, because the turn's
327
+ // is what actually stops the waiting below.
328
+ expiresAt: Math.min(question.expiresAt, Date.now() + deps.confirmTimeoutMs),
329
+ });
330
+ // A question nobody answers is a denial. The gate has a deadline of its
331
+ // own, but it belongs to the application; this one belongs to the turn.
332
+ const waiting = new AbortController();
333
+ const timer = setTimeout(
334
+ () => waiting.abort(new Error('the question timed out')),
335
+ deps.confirmTimeoutMs,
336
+ );
337
+ const relay = (): void => waiting.abort(new Error('the run was cancelled'));
338
+ signal.addEventListener('abort', relay, { once: true });
339
+ if (signal.aborted) relay();
340
+ try {
341
+ return await deps.approvals.ask(question, waiting.signal);
342
+ } finally {
343
+ clearTimeout(timer);
344
+ signal.removeEventListener('abort', relay);
345
+ }
346
+ },
347
+ };
348
+ }
349
+
350
+ /**
351
+ * True when a tool call failed because nobody allowed it.
352
+ *
353
+ * The gate throws a `PublicError` with code `rejected`; a tool that reached it
354
+ * through `HostApp.invoke` has had that turned into the marked bridge error the
355
+ * browser would have seen. Both are the same answer — the user said no — and
356
+ * both have to become an ordinary tool result rather than a failure.
357
+ */
358
+ function wasDeclined(cause: unknown): boolean {
359
+ if (isPublicError(cause)) return cause.code === 'rejected';
360
+ return fromTransportError(cause).code === 'rejected';
361
+ }
362
+
363
+ /** Build the AI SDK tool set, each call carrying the run's envelope to the gate. */
364
+ function buildTools(
365
+ params: StreamChatParams,
366
+ deps: RunDeps,
367
+ sink: StreamSink<ChatEvent>,
368
+ approver: Approver,
369
+ ): ToolSet {
177
370
  const tools: ToolSet = {};
178
371
  for (const [name, definition] of Object.entries(deps.tools)) {
179
372
  tools[name] = tool({
@@ -186,18 +379,27 @@ function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<Ch
186
379
  callId,
187
380
  tool: name,
188
381
  input,
189
- permission: definition.permission,
382
+ permission: permissionOf(definition.effect),
190
383
  });
191
384
 
192
- if (definition.permission === 'confirm') {
193
- await sink.emit({ type: 'confirm', callId, tool: name, input });
194
- const approved = await deps.confirmations.wait(
195
- params.runId,
196
- callId,
197
- deps.confirmTimeoutMs,
385
+ let output: unknown;
386
+ try {
387
+ // The envelope is built here, from what the run loop knows. Nothing
388
+ // the model produced is read when it is filled in, which is what
389
+ // stops a model from calling a tool as the user.
390
+ output = await definition.execute(
391
+ input,
392
+ {
393
+ requestId: `${params.runId}:${callId}`,
394
+ channel: 'ai',
395
+ caller: `ai:${params.runId}`,
396
+ signal: sink.signal,
397
+ approver,
398
+ },
198
399
  sink.signal,
199
400
  );
200
- if (!approved) {
401
+ } catch (cause) {
402
+ if (wasDeclined(cause)) {
201
403
  // A refusal is an ordinary result, not a failure: the model has to
202
404
  // be told, so it can say something rather than retry.
203
405
  await sink.emit({
@@ -209,12 +411,6 @@ function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<Ch
209
411
  });
210
412
  return DECLINED;
211
413
  }
212
- }
213
-
214
- let output: unknown;
215
- try {
216
- output = await definition.execute(input, sink.signal);
217
- } catch (cause) {
218
414
  // One tool failing is not the turn failing. The model gets the
219
415
  // reason and can carry on or explain.
220
416
  output = { error: safeToolMessage(cause, name, deps.logger) };
@@ -237,7 +433,7 @@ function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<Ch
237
433
  * failure and is logged rather than shown.
238
434
  */
239
435
  function safeToolMessage(cause: unknown, name: string, logger: HostLogger): string {
240
- if (cause instanceof PublicError) return cause.message;
436
+ if (isPublicError(cause)) return cause.message;
241
437
  const reduced = fromTransportError(cause);
242
438
  if (reduced.code !== 'internal') return reduced.message;
243
439
  logger.error(
@@ -246,25 +442,107 @@ function safeToolMessage(cause: unknown, name: string, logger: HostLogger): stri
246
442
  return 'The tool failed.';
247
443
  }
248
444
 
249
- /** Run one `ai.chat` turn. */
445
+ /** How much of the person's message stands in for the whole turn. */
446
+ const SUMMARY_CHARS = 200;
447
+
448
+ /** Run one `ai.chat` turn, and tell whoever is listening how it ended. */
250
449
  export async function runChat(
251
450
  params: StreamChatParams,
252
451
  sink: StreamSink<ChatEvent>,
253
452
  deps: RunDeps,
453
+ ): Promise<void> {
454
+ // Reported exactly once, whatever happens: a turn that threw, a turn the
455
+ // browser cancelled and a turn that finished all have to close their record,
456
+ // or a run store is left with something that looks like it is still running.
457
+ let ended = false;
458
+ const started = Date.now();
459
+ const tally: TurnTally = { steps: 0 };
460
+ const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
461
+ if (ended) return;
462
+ ended = true;
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
+ );
473
+ };
474
+ try {
475
+ await runTurn(params, sink, deps, end, tally);
476
+ end(sink.signal.aborted ? 'cancelled' : 'succeeded');
477
+ } catch (cause) {
478
+ end(sink.signal.aborted ? 'cancelled' : 'failed');
479
+ throw cause;
480
+ }
481
+ }
482
+
483
+ /** The turn itself. */
484
+ async function runTurn(
485
+ params: StreamChatParams,
486
+ sink: StreamSink<ChatEvent>,
487
+ deps: RunDeps,
488
+ end: (status: 'succeeded' | 'failed' | 'cancelled') => void,
489
+ tally: TurnTally,
254
490
  ): Promise<void> {
255
491
  // Throws a PublicError when nothing is configured. `runStream` in host/app.ts
256
492
  // turns that into the right thing on the wire, so it is not caught here.
257
- const resolved = await deps.registry.resolve();
493
+ // The turn's own model, when a conversation has one. `resolve` applies it
494
+ // after the provider and key checks, so the vision check below and the model
495
+ // instance built later both follow it without a second code path.
496
+ const resolved = await deps.registry.resolve({ modelId: params.modelId });
497
+
498
+ // Both checks come before anything is emitted, so a turn that cannot carry
499
+ // its images fails as a whole rather than half-answering.
500
+ const files = params.files ?? [];
501
+ if (files.length > 0) {
502
+ const characters = files.reduce((total, file) => total + file.data.length, 0);
503
+ if (characters > MAX_FILE_CHARS_PER_TURN) {
504
+ throw publicError.invalidInput('Images on one message are limited to about 4 MB together.');
505
+ }
506
+ if (!(await modelCanSee(resolved, sink.signal, deps.logger))) {
507
+ throw publicError.rejected(
508
+ 'The chosen model cannot read images. Pick one that can in Settings.',
509
+ );
510
+ }
511
+ }
512
+
258
513
  const documents = await assembleContext(params, deps, sink.signal);
259
514
 
515
+ // One approver per run. The request identifier the gate will use is
516
+ // `<runId>:<callId>`, so the call a `confirm` event names can be recovered
517
+ // from it — which is what keeps `ai.chatConfirm`'s wire shape unchanged.
518
+ const approver = createRunApprover(deps, sink, (requestId) =>
519
+ requestId.startsWith(`${params.runId}:`) ? requestId.slice(params.runId.length + 1) : requestId,
520
+ );
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
+
260
538
  const result = streamText({
261
539
  // Always a model *instance*. A string here would be resolved by the AI
262
540
  // SDK's gateway, over the global fetch, to a Vercel host — see
263
541
  // reports/01-spike.md. Nothing in this layer may pass one.
264
542
  model: resolved.adapter.model(resolved.config, resolved.modelId),
265
- system: buildSystemPrompt(deps, documents),
543
+ system,
266
544
  messages: toModelMessages(params),
267
- tools: buildTools(params, deps, sink),
545
+ tools: buildTools(params, deps, sink, approver),
268
546
  stopWhen: stepCountIs(deps.maxSteps),
269
547
  abortSignal: sink.signal,
270
548
  // The default handler prints the error; this layer reports it as an event
@@ -278,22 +556,32 @@ export async function runChat(
278
556
  case 'text-delta':
279
557
  await sink.emit({ type: 'text', text: part.text });
280
558
  break;
281
- case 'finish':
282
- await sink.emit({
283
- type: 'usage',
284
- // `ai` flattens the provider's nested usage object into plain
285
- // numbers, either of which a provider may omit.
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 = {
286
568
  inputTokens: part.totalUsage.inputTokens ?? 0,
287
569
  outputTokens: part.totalUsage.outputTokens ?? 0,
288
- });
570
+ };
571
+ tally.usage = usage;
572
+ await sink.emit({ type: 'usage', ...usage });
289
573
  await sink.emit({ type: 'done' });
290
574
  break;
575
+ }
291
576
  case 'error':
292
577
  await sink.emit({
293
578
  type: 'error',
294
579
  code: 'provider',
295
580
  message: safeMessage(part.error, deps.logger),
296
581
  });
582
+ // The stream ends here rather than at `done`, so the turn's outcome is
583
+ // settled here too.
584
+ end('failed');
297
585
  return;
298
586
  case 'tool-error': {
299
587
  // `execute` never throws, so this means the SDK failed before the tool
@@ -309,10 +597,11 @@ export async function runChat(
309
597
  break;
310
598
  }
311
599
  case 'abort':
600
+ end('cancelled');
312
601
  return;
313
602
  default:
314
- // tool-call, tool-result, text-start, finish-step, reasoning, source,
315
- // raw: either already emitted from `execute`, or not something the
603
+ // tool-result, text-start, finish-step, reasoning, source, raw:
604
+ // either already emitted from `execute`, or not something the
316
605
  // browser has a use for.
317
606
  break;
318
607
  }