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.
@@ -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 { 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';
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: { readonly name: string; readonly purpose: string; readonly terminology?: readonly string[] };
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 confirmations: Confirmations;
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
- /** A message the model may see, without whatever a caller invented. */
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
- messages.push({ role: 'user', content: params.message });
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 instanceof PublicError) return cause.message;
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
- /** Build the AI SDK tool set, wrapping each tool in the permission dance. */
176
- function buildTools(params: StreamChatParams, deps: RunDeps, sink: StreamSink<ChatEvent>): ToolSet {
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.permission,
348
+ permission: permissionOf(definition.effect),
190
349
  });
191
350
 
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,
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
- if (!approved) {
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 instanceof PublicError) return cause.message;
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
- /** Run one `ai.chat` turn. */
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
- const resolved = await deps.registry.resolve();
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,