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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Build tooling and runtime for local applications made of a Bun host, a browser UI, and a Brobridge connection between them",
6
6
  "license": "MIT",
@@ -50,7 +50,10 @@ export interface ProviderAdapter {
50
50
  /** Stable id, stored in settings: `'anthropic'`, `'ollama'`, `'fake'`. */
51
51
  readonly id: string;
52
52
  readonly label: string;
53
- readonly needs: { readonly apiKey: boolean; readonly baseUrl: 'required' | 'optional' | 'none' };
53
+ readonly needs: {
54
+ readonly apiKey: 'required' | 'optional' | 'none';
55
+ readonly baseUrl: 'required' | 'optional' | 'none';
56
+ };
54
57
  readonly defaultBaseUrl: string | null;
55
58
  /** Whether requests stay on this machine under this config. */
56
59
  local(config: AdapterConfig): boolean;
@@ -58,7 +61,7 @@ export interface ProviderAdapter {
58
61
  models(config: AdapterConfig, signal: AbortSignal): Promise<BroappModel[]>;
59
62
  /** Cheapest possible proof the config works. Must reject with {@link AdapterError}. */
60
63
  test(config: AdapterConfig, signal: AbortSignal): Promise<void>;
61
- /** 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()`. */
62
65
  model(config: AdapterConfig, modelId: string): LanguageModel;
63
66
  }
64
67
 
@@ -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:
@@ -17,8 +18,8 @@ import type { Bridge } from 'brobridge';
17
18
  // is what makes a browser bundle of `broapp/ai/host` fail to build. Bun's
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
- import { createReservedHostApp } from '../../host/index.ts';
21
- import type { HostApp, HostLogger } from '../../host/app.ts';
21
+ import { createPendingApprovals, createReservedHostApp } from '../../host/index.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,15 +27,81 @@ 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
- import { createConfirmations, type AiContextProviders, type AiTool } from './tool.ts';
33
+ import { openThreads, type ThreadStore } from './threads.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
+ }
32
90
 
33
91
  /** What the application is, in the words a model is given. */
34
92
  export interface AiAppDescription {
35
93
  readonly name: string;
36
94
  readonly purpose: string;
37
95
  readonly terminology?: readonly string[];
96
+ /**
97
+ * Extra standing instructions, appended verbatim after the purpose.
98
+ *
99
+ * For an assistant whose job needs more than a sentence to describe — the
100
+ * shape of a workspace it edits, a sequence it has to follow, things it may
101
+ * not do. It is host-authored text, not anything a browser or a model
102
+ * supplied, and it goes in front of the documents rather than among them.
103
+ */
104
+ readonly instructions?: string;
38
105
  }
39
106
 
40
107
  /** Options for {@link createAi}. */
@@ -53,6 +120,28 @@ export interface CreateAiOptions {
53
120
  readonly maxSteps?: number;
54
121
  /** How long a `confirm` tool waits for the user. Default 300_000 ms. */
55
122
  readonly confirmTimeoutMs?: number;
123
+ /**
124
+ * Called once when a chat turn ends, however it ends.
125
+ *
126
+ * Autoapp's run store uses it to close the record the gate has been writing
127
+ * steps into: the browser's run identifier is the prefix of every request
128
+ * identifier the turn produced, so this is the one signal that ties the two
129
+ * together. An application that does not record runs leaves it unset.
130
+ */
131
+ readonly onRunEnd?: (
132
+ runId: string,
133
+ status: 'succeeded' | 'failed' | 'cancelled',
134
+ summary: string,
135
+ detail?: RunEndDetail,
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;
56
145
  }
57
146
 
58
147
  /**
@@ -68,11 +157,42 @@ const TOOL_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_.]*$/;
68
157
  export interface Ai {
69
158
  mount(bridge: Bridge): void;
70
159
  abortAll(reason: string): void;
160
+ /**
161
+ * Release what the layer holds open. Call it from the application's
162
+ * shutdown, beside `abortAll`.
163
+ *
164
+ * Today that is the conversation store: closing it checkpoints the WAL, so
165
+ * what is left on disk is one complete database rather than one that needs
166
+ * its sidecars. Calling it twice is harmless, and an application that never
167
+ * opened a conversation has nothing to close.
168
+ */
169
+ close(): void;
71
170
  readonly activeStreams: number;
72
171
  /** For tests, and for applications that read settings on the host. */
73
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>;
74
190
  }
75
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
+
76
196
  /** How long a provider is given to answer a listing or a connection test. */
77
197
  const PROVIDER_TIMEOUT_MS = 20_000;
78
198
 
@@ -93,10 +213,19 @@ export function createAi(options: CreateAiOptions): Ai {
93
213
  }
94
214
  seen.add(adapter.id);
95
215
  }
96
- for (const name of Object.keys(options.tools ?? {})) {
216
+ for (const [name, definition] of Object.entries(options.tools ?? {})) {
97
217
  if (!TOOL_NAME_PATTERN.test(name)) {
98
218
  throw new TypeError(`tool name ${JSON.stringify(name)} must be letters, digits, "_" or "."`);
99
219
  }
220
+ // A tool is host code that a model gets to trigger. Whether it asked
221
+ // anybody first is not visible in its type, so the brand is required
222
+ // rather than hoped for: an application cannot hand a model an ungated
223
+ // capability by forgetting one wrapper.
224
+ if ((definition as { [GUARDED]?: true })[GUARDED] !== true) {
225
+ throw new TypeError(
226
+ `tool ${JSON.stringify(name)} does not pass the gate; build it with guardedTool()`,
227
+ );
228
+ }
100
229
  }
101
230
 
102
231
  // Both stores are built once and kept. `remember` chooses between them, and
@@ -162,7 +291,7 @@ export function createAi(options: CreateAiOptions): Ai {
162
291
  }
163
292
  });
164
293
 
165
- const confirmations = createConfirmations();
294
+ const approvals = createPendingApprovals(options.logger);
166
295
  const runDeps: RunDeps = {
167
296
  registry,
168
297
  app: options.app,
@@ -171,15 +300,36 @@ export function createAi(options: CreateAiOptions): Ai {
171
300
  contextBudgetChars: options.contextBudgetChars ?? DEFAULT_CONTEXT_BUDGET_CHARS,
172
301
  maxSteps: options.maxSteps ?? DEFAULT_MAX_STEPS,
173
302
  confirmTimeoutMs: options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS,
174
- confirmations,
303
+ approvals,
304
+ ...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
305
+ ...(options.onContext === undefined ? {} : { onContext: options.onContext }),
175
306
  logger: options.logger ?? console,
176
307
  };
177
308
 
178
309
  host.stream('ai.chat', (params, sink) => runChat(params, sink, runDeps));
310
+ // The wire shape is unchanged: a run and a call name the question, and
311
+ // `accepted` says whether anybody was waiting on it. What changed is where
312
+ // the answer goes — into the same approval table the gate asks.
179
313
  host.operation('ai.chatConfirm', ({ runId, callId, approve }) => ({
180
- accepted: confirmations.answer(runId, callId, approve),
314
+ accepted:
315
+ approvals.answer({ requestId: `${runId}:${callId}`, approved: approve }) === 'accepted',
181
316
  }));
182
317
 
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
+ host.operation('ai.threadsList', () => ({ threads: threadStore().list() }));
326
+ host.operation('ai.threadsCreate', (input) => threadStore().create(input));
327
+ host.operation('ai.threadsGet', ({ id }) => threadStore().get(id));
328
+ host.operation('ai.threadsSave', (input) => threadStore().save(input));
329
+ host.operation('ai.threadsUpdate', (input) => threadStore().update(input));
330
+ host.operation('ai.threadsDelete', ({ id }) => ({ deleted: threadStore().remove(id) }));
331
+ host.operation('ai.threadsClear', () => ({ deleted: threadStore().clear() }));
332
+
183
333
  /** The current provider config, or the "not set up" error. */
184
334
  async function requireConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig }> {
185
335
  const current = await registry.currentConfig();
@@ -192,9 +342,87 @@ export function createAi(options: CreateAiOptions): Ai {
192
342
  return {
193
343
  mount: (bridge: Bridge) => host.mount(bridge),
194
344
  abortAll: (reason: string) => host.abortAll(reason),
345
+ close: () => {
346
+ threads?.close();
347
+ threads = null;
348
+ },
195
349
  get activeStreams() {
196
350
  return host.activeStreams;
197
351
  },
198
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
+ },
199
427
  };
200
428
  }
@@ -48,6 +48,13 @@ export interface FakeAdapterOptions {
48
48
  readonly script?: readonly FakeStep[];
49
49
  /** Delay between chunks, so a cancel test can catch a stream mid-flight. */
50
50
  readonly chunkDelayMs?: number;
51
+ /**
52
+ * Whether the default model says it can read images. Default `false`.
53
+ *
54
+ * The default stays `false` so a test that does not mention images keeps
55
+ * proving that a turn with images is refused by a model that cannot see.
56
+ */
57
+ readonly vision?: boolean;
51
58
  }
52
59
 
53
60
  /** A fake adapter, plus what the test wants to know about it afterwards. */
@@ -60,12 +67,12 @@ export interface FakeAdapter extends ProviderAdapter {
60
67
  readonly aborted: number;
61
68
  }
62
69
 
63
- function defaultModel(providerId: string): BroappModel {
70
+ function defaultModel(providerId: string, vision: boolean): BroappModel {
64
71
  return {
65
72
  provider: providerId,
66
73
  modelId: 'fake-1',
67
74
  label: 'Fake 1',
68
- capabilities: { tools: true, vision: false, structuredOutput: true },
75
+ capabilities: { tools: true, vision, structuredOutput: true },
69
76
  };
70
77
  }
71
78
 
@@ -127,7 +134,7 @@ function chunksFor(step: FakeStep, callIndex: number): StreamPart[] {
127
134
  /** Build an adapter that needs no provider. */
128
135
  export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter {
129
136
  const id = options.id ?? 'fake';
130
- const models = options.models ?? [defaultModel(id)];
137
+ const models = options.models ?? [defaultModel(id, options.vision === true)];
131
138
  const script = options.script ?? [{ kind: 'text', chunks: ['fake reply'] } as const];
132
139
  const steps = flatten(script);
133
140
  const delay = options.chunkDelayMs ?? 0;
@@ -140,7 +147,7 @@ export function createFakeAdapter(options: FakeAdapterOptions = {}): FakeAdapter
140
147
  const adapter: FakeAdapter = {
141
148
  id,
142
149
  label: 'Fake provider',
143
- needs: { apiKey: options.needsKey === true, baseUrl: 'none' },
150
+ needs: { apiKey: options.needsKey === true ? 'required' : 'none', baseUrl: 'none' },
144
151
  defaultBaseUrl: null,
145
152
  // Nothing leaves the process, so this is true whatever the configuration.
146
153
  local: () => true,
@@ -7,17 +7,20 @@
7
7
  * that does not exist, and a change to an operation's input reaches the tool
8
8
  * description without anybody remembering to update it.
9
9
  *
10
- * The permission split is the important part. `read` operations run as soon as
11
- * the model asks; `confirm` operations wait for the user. Nothing is a tool
12
- * unless it is named here, so the default for an application's surface is that
13
- * the model cannot reach it.
10
+ * The lists here *select*; they no longer decide. What a call is allowed to do
11
+ * is the route's own `effect`, and the gate reads it from the contract. The
12
+ * list a route sits in has to agree with what it declares, and a route that
13
+ * declares nothing takes the list's word for it — which is how an application
14
+ * written before effects existed still says what it meant. Nothing is a tool
15
+ * unless it is named here, so the default for an application's surface is
16
+ * still that the model cannot reach it.
14
17
  */
15
18
  import type { HostApp } from '../../host/app.ts';
19
+ import type { Effect } from '../../shared/contract.ts';
16
20
  import type { AnyContract, OperationName } from '../../shared/contract.ts';
17
21
  import type { JsonSchema } from '../../shared/schema.ts';
18
- import type { ToolPermission } from '../shared/types.ts';
19
22
 
20
- import type { AiTool } from './tool.ts';
23
+ import { GUARDED, type GuardedTool } from './tool.ts';
21
24
 
22
25
  /** Which operations a model may call, and how much ceremony each needs. */
23
26
  export interface ContractToolAllowList<C extends AnyContract> {
@@ -25,12 +28,15 @@ export interface ContractToolAllowList<C extends AnyContract> {
25
28
  readonly confirm?: readonly OperationName<C>[];
26
29
  }
27
30
 
31
+ /** What each list means about a route that does not declare an effect. */
32
+ const IMPLIED: Record<'read' | 'confirm', Effect> = { read: 'read', confirm: 'write' };
33
+
28
34
  /** Build tools from operations the contract already describes. */
29
35
  export function fromContract<C extends AnyContract>(
30
36
  contract: C,
31
37
  app: HostApp<C>,
32
38
  allow: ContractToolAllowList<C>,
33
- ): Record<string, AiTool> {
39
+ ): Record<string, GuardedTool> {
34
40
  const read = allow.read ?? [];
35
41
  const confirm = allow.confirm ?? [];
36
42
 
@@ -41,12 +47,12 @@ export function fromContract<C extends AnyContract>(
41
47
  );
42
48
  }
43
49
 
44
- const tools: Record<string, AiTool> = {};
45
- const groups: readonly (readonly [readonly OperationName<C>[], ToolPermission])[] = [
50
+ const tools: Record<string, GuardedTool> = {};
51
+ const groups: readonly (readonly [readonly OperationName<C>[], 'read' | 'confirm'])[] = [
46
52
  [read, 'read'],
47
53
  [confirm, 'confirm'],
48
54
  ];
49
- for (const [routes, permission] of groups) {
55
+ for (const [routes, list] of groups) {
50
56
  for (const route of routes) {
51
57
  const spec = contract.operations[route];
52
58
  if (spec === undefined) {
@@ -59,6 +65,17 @@ export function fromContract<C extends AnyContract>(
59
65
  `operation ${JSON.stringify(route)} needs a summary before it can be offered to a model`,
60
66
  );
61
67
  }
68
+ const declared = spec.effect;
69
+ // A list that disagrees with the contract is a misunderstanding about
70
+ // what an operation does, and the two readings differ in exactly the way
71
+ // that matters: one asks the user and the other does not. Neither is
72
+ // safe to guess at, so it is refused where a developer can see it.
73
+ if (declared !== undefined && declared !== IMPLIED[list] && !(list === 'confirm' && declared === 'external')) {
74
+ throw new TypeError(
75
+ `operation ${JSON.stringify(route)} is listed as a ${list} tool but declares effect ${JSON.stringify(declared)}`,
76
+ );
77
+ }
78
+ const effect: Effect = declared ?? IMPLIED[list];
62
79
  const describe = (spec.input as { toJsonSchema?: () => JsonSchema }).toJsonSchema;
63
80
  if (typeof describe !== 'function') {
64
81
  throw new TypeError(
@@ -71,13 +88,16 @@ export function fromContract<C extends AnyContract>(
71
88
  // lost by turning it back into "no argument" here.
72
89
  const takesNothing = spec.input.kind === 'void';
73
90
  tools[route] = {
91
+ [GUARDED]: true,
74
92
  description: spec.summary,
75
93
  inputSchema: describe.call(spec.input),
76
- permission,
77
- // `invoke` validates the input and applies the same error boundary a
78
- // call from the browser gets, so a model's arguments are no more
79
- // trusted than a tab's.
80
- execute: (input) => app.invoke(route, takesNothing ? undefined : input),
94
+ effect,
95
+ // `invoke` validates the input, guards it and applies the same error
96
+ // boundary a call from the browser gets, so a model's arguments are no
97
+ // more trusted than a tab's. The hint is what the list decided, and it
98
+ // only applies where the contract itself is silent.
99
+ execute: (input, envelope) =>
100
+ app.invoke(route, takesNothing ? undefined : input, { ...envelope, effectHint: effect }),
81
101
  };
82
102
  }
83
103
  }
@@ -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 { Ai, AiAppDescription, CreateAiOptions } from './create-ai.ts';
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';
@@ -18,15 +28,20 @@ export type { FakeAdapter, FakeAdapterOptions, FakeStep } from './fake.ts';
18
28
  export { fromContract } from './from-contract.ts';
19
29
  export type { ContractToolAllowList } from './from-contract.ts';
20
30
 
31
+ export { GUARDED, guardedTool } from './tool.ts';
21
32
  export type {
22
33
  AiContextProviders,
23
34
  AiTool,
24
- Confirmations,
25
35
  ContextDocument,
26
36
  ContextRef,
37
+ GuardedTool,
38
+ GuardedToolDefinition,
27
39
  } from './tool.ts';
28
40
 
29
41
  export { apiKeySecretName, createFileSecretStore, createMemorySecretStore } from './secrets.ts';
30
42
  export type { SecretStore } from './secrets.ts';
31
43
 
32
44
  export type { Registry, ResolvedModel, UpdatePatch } from './registry.ts';
45
+
46
+ export { DEFAULT_THREAD_TITLE, openThreads } from './threads.ts';
47
+ export type { ThreadStore } from './threads.ts';
@@ -7,7 +7,7 @@
7
7
  * "would `resolve()` succeed", rather than a second copy of the same
8
8
  * conditions that can drift from the first.
9
9
  */
10
- import { PublicError, publicError } from '../../shared/errors.ts';
10
+ import { isPublicError, publicError } from '../../shared/errors.ts';
11
11
  import type { AiSettings } from '../shared/types.ts';
12
12
 
13
13
  import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
@@ -36,8 +36,15 @@ export interface Registry {
36
36
  adapter(id: string): ProviderAdapter | null;
37
37
  /** Current settings plus the key, for adapter calls. */
38
38
  currentConfig(): Promise<{ adapter: ProviderAdapter; config: AdapterConfig } | null>;
39
- /** Everything needed to run a chat, or a `PublicError` explaining what is missing. */
40
- resolve(): Promise<ResolvedModel>;
39
+ /**
40
+ * Everything needed to run a chat, or a `PublicError` explaining what is
41
+ * missing.
42
+ *
43
+ * `override.modelId` replaces the model Settings names, for this call only
44
+ * and only within the configured provider — a conversation may pin a model,
45
+ * never a vendor.
46
+ */
47
+ resolve(override?: { readonly modelId?: string | undefined }): Promise<ResolvedModel>;
41
48
  /** The public view: settings without the key. */
42
49
  settings(): Promise<AiSettings>;
43
50
  update(patch: UpdatePatch): Promise<AiSettings>;
@@ -112,7 +119,7 @@ export function createRegistry(options: RegistryOptions): Registry {
112
119
  return { adapter, config: configFrom(settings, adapter, apiKey) };
113
120
  },
114
121
 
115
- async resolve() {
122
+ async resolve(override) {
116
123
  const settings = options.settingsStore.read();
117
124
  if (settings.provider === null) throw publicError.unavailable(NOT_SET_UP);
118
125
  const adapter = byId.get(settings.provider);
@@ -123,20 +130,24 @@ export function createRegistry(options: RegistryOptions): Registry {
123
130
  // models is fetched *from* the provider, so telling a user to choose one
124
131
  // before they can see any is an instruction they cannot follow.
125
132
  const apiKey = await keyFor(settings, adapter.id);
126
- if (adapter.needs.apiKey && (apiKey === null || apiKey === '')) {
133
+ if (adapter.needs.apiKey === 'required' && (apiKey === null || apiKey === '')) {
127
134
  throw publicError.unavailable(`An API key is required for ${adapter.label}.`);
128
135
  }
129
136
  const config = configFrom(settings, adapter, apiKey);
130
137
  if (adapter.needs.baseUrl === 'required' && (config.baseUrl === null || config.baseUrl === '')) {
131
138
  throw publicError.unavailable(`A server address is required for ${adapter.label}.`);
132
139
  }
133
- if (settings.modelId === null) {
140
+ // Read after the provider, the key and the address, so a conversation
141
+ // carrying its own model still hears "AI is not set up yet" first: the
142
+ // model is the last thing missing, never the first.
143
+ const modelId = override?.modelId ?? settings.modelId;
144
+ if (modelId === null) {
134
145
  // Distinct from "not set up": the user is looking at the settings panel
135
146
  // with a provider selected, and being told to choose a provider is an
136
147
  // instruction they have already followed.
137
148
  throw publicError.unavailable(`Choose a model for ${adapter.label}.`);
138
149
  }
139
- return { adapter, config, modelId: settings.modelId };
150
+ return { adapter, config, modelId };
140
151
  },
141
152
 
142
153
  async settings() {
@@ -149,7 +160,7 @@ export function createRegistry(options: RegistryOptions): Registry {
149
160
  } catch (cause) {
150
161
  // Anything that is not a deliberate "not configured" is a real fault
151
162
  // and must not be reported as merely unconfigured.
152
- if (!(cause instanceof PublicError)) throw cause;
163
+ if (!isPublicError(cause)) throw cause;
153
164
  configured = false;
154
165
  }
155
166
  return {