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 +1 -1
- package/src/ai/host/adapter.ts +5 -2
- package/src/ai/host/create-ai.ts +235 -7
- package/src/ai/host/fake.ts +11 -4
- package/src/ai/host/from-contract.ts +35 -15
- package/src/ai/host/index.ts +17 -2
- package/src/ai/host/registry.ts +19 -8
- package/src/ai/host/run.ts +326 -37
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +67 -55
- 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/cli/main.ts +7 -1
- 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/package.json
CHANGED
package/src/ai/host/adapter.ts
CHANGED
|
@@ -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: {
|
|
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
|
|
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:
|
|
@@ -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 {
|
|
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.
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
}
|
package/src/ai/host/fake.ts
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
* the
|
|
12
|
-
*
|
|
13
|
-
* the
|
|
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
|
|
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,
|
|
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,
|
|
45
|
-
const groups: readonly (readonly [readonly OperationName<C>[],
|
|
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,
|
|
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
|
-
|
|
77
|
-
// `invoke` validates the input and applies the same error
|
|
78
|
-
// call from the browser gets, so a model's arguments are no
|
|
79
|
-
// trusted than a tab's.
|
|
80
|
-
|
|
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
|
}
|
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';
|
|
@@ -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';
|
package/src/ai/host/registry.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
/**
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
163
|
+
if (!isPublicError(cause)) throw cause;
|
|
153
164
|
configured = false;
|
|
154
165
|
}
|
|
155
166
|
return {
|