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/src/ai/host/run.ts
CHANGED
|
@@ -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 {
|
|
20
|
+
import type { PendingApprovals } from '../../host/approvals.ts';
|
|
21
|
+
import type { ApprovalQuestion, Approver } from '../../host/gate.ts';
|
|
22
|
+
import type { Effect } from '../../shared/contract.ts';
|
|
23
|
+
import { fromTransportError, isPublicError, publicError } from '../../shared/errors.ts';
|
|
24
|
+
import type { ToolPermission } from '../shared/types.ts';
|
|
21
25
|
import type { ChatEvent, StreamChatParams } from './run-types.ts';
|
|
22
26
|
|
|
23
27
|
import { AdapterError } from './adapter.ts';
|
|
28
|
+
import type { AdapterConfig, ProviderAdapter } from './adapter.ts';
|
|
24
29
|
import type { Registry } from './registry.ts';
|
|
25
|
-
import type { AiContextProviders, AiTool,
|
|
30
|
+
import type { AiContextProviders, AiTool, ContextDocument } from './tool.ts';
|
|
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: {
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
176
|
-
|
|
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.
|
|
382
|
+
permission: permissionOf(definition.effect),
|
|
190
383
|
});
|
|
191
384
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
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 '
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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-
|
|
315
|
-
//
|
|
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
|
}
|