broapp 0.4.3 → 0.4.5
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/create-ai.ts +10 -0
- package/src/ai/host/run.ts +161 -8
- package/src/ai/shared/contract.ts +3 -0
- package/src/ai/shared/types.ts +4 -1
package/package.json
CHANGED
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface RunEndDetail {
|
|
|
39
39
|
/** Tool round trips the turn made. */
|
|
40
40
|
readonly steps: number;
|
|
41
41
|
readonly ms: number;
|
|
42
|
+
/** The model the turn was sent to; absent when the turn ended before one was resolved. */
|
|
43
|
+
readonly modelId?: string;
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
/**
|
|
@@ -165,6 +167,13 @@ export interface CreateAiOptions {
|
|
|
165
167
|
* reason to fail it.
|
|
166
168
|
*/
|
|
167
169
|
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
170
|
+
/**
|
|
171
|
+
* Called after each completed model step of a turn, with the tokens its
|
|
172
|
+
* completed steps have used so far: what a running turn has cost, before
|
|
173
|
+
* `onRunEnd` says what it cost in all. A hook that throws is logged and
|
|
174
|
+
* ignored, like the others.
|
|
175
|
+
*/
|
|
176
|
+
readonly onUsageSoFar?: (runId: string, soFar: { inputTokens: number; outputTokens: number }) => void;
|
|
168
177
|
}
|
|
169
178
|
|
|
170
179
|
/**
|
|
@@ -334,6 +343,7 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
334
343
|
approvals,
|
|
335
344
|
...(options.onRunEnd === undefined ? {} : { onRunEnd: options.onRunEnd }),
|
|
336
345
|
...(options.onContext === undefined ? {} : { onContext: options.onContext }),
|
|
346
|
+
...(options.onUsageSoFar === undefined ? {} : { onUsageSoFar: options.onUsageSoFar }),
|
|
337
347
|
transcripts: {
|
|
338
348
|
save: (runId, messages) => {
|
|
339
349
|
threadStore().saveTranscript(runId, messages);
|
package/src/ai/host/run.ts
CHANGED
|
@@ -75,6 +75,13 @@ export interface RunDeps {
|
|
|
75
75
|
) => void;
|
|
76
76
|
/** Called once per turn with what the model is about to be given. */
|
|
77
77
|
readonly onContext?: (runId: string, delivered: DeliveredContext) => void;
|
|
78
|
+
/**
|
|
79
|
+
* Called after each model step completes, with what the turn's completed
|
|
80
|
+
* steps have used so far. Not the SDK's `onStepEnd`, which this layer
|
|
81
|
+
* already hands to `streamText`: this is the turn's running subtotal, for
|
|
82
|
+
* somebody who wants to say what a turn has cost before it ends.
|
|
83
|
+
*/
|
|
84
|
+
readonly onUsageSoFar?: (runId: string, soFar: { inputTokens: number; outputTokens: number }) => void;
|
|
78
85
|
/**
|
|
79
86
|
* The turn transcripts. Absent, every history turn is text and nothing is
|
|
80
87
|
* written, which is exactly the layer before transcripts existed.
|
|
@@ -85,7 +92,31 @@ export interface RunDeps {
|
|
|
85
92
|
/** What a turn counts as it goes, for {@link RunEndDetail}. */
|
|
86
93
|
interface TurnTally {
|
|
87
94
|
steps: number;
|
|
88
|
-
|
|
95
|
+
/**
|
|
96
|
+
* The turn's usage. The SDK's total when the turn reached `finish`;
|
|
97
|
+
* otherwise the sum of the steps that completed, marked `partial`, because
|
|
98
|
+
* the step in flight when it stopped used tokens nobody reported.
|
|
99
|
+
*/
|
|
100
|
+
usage?: { inputTokens: number; outputTokens: number; partial?: true };
|
|
101
|
+
/** Model steps that completed, and what they reported, added as each ends. */
|
|
102
|
+
stepsEnded: number;
|
|
103
|
+
stepInput: number;
|
|
104
|
+
stepOutput: number;
|
|
105
|
+
/** The model the turn was sent to, once it is resolved. */
|
|
106
|
+
modelId?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* What the completed steps of a turn that did not finish add up to, or
|
|
111
|
+
* nothing when no step completed.
|
|
112
|
+
*
|
|
113
|
+
* A turn cut short by a time limit, a stop or an error never sees `finish`,
|
|
114
|
+
* and so never sees the total. Counting it as zero made a turn that ran for
|
|
115
|
+
* twenty minutes look free; this is what is known, and says it is not all.
|
|
116
|
+
*/
|
|
117
|
+
function partialUsage(tally: TurnTally): TurnTally['usage'] {
|
|
118
|
+
if (tally.stepsEnded === 0) return undefined;
|
|
119
|
+
return { inputTokens: tally.stepInput, outputTokens: tally.stepOutput, partial: true };
|
|
89
120
|
}
|
|
90
121
|
|
|
91
122
|
/**
|
|
@@ -311,6 +342,85 @@ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMes
|
|
|
311
342
|
return { ...message, content } as ModelMessage;
|
|
312
343
|
}
|
|
313
344
|
|
|
345
|
+
/** The line a partial turn opens with, saying how many of its calls are not there. */
|
|
346
|
+
function omittedMarker(calls: number): string {
|
|
347
|
+
return `[${String(calls)} earlier tool calls of this turn are not shown]`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The tool-call ids an assistant message makes, or the tool-result ids a tool message answers. */
|
|
351
|
+
function partIds(message: ModelMessage, type: 'tool-call' | 'tool-result'): string[] {
|
|
352
|
+
if (!Array.isArray(message.content)) return [];
|
|
353
|
+
return (message.content as readonly unknown[])
|
|
354
|
+
.filter((part): part is { type: string; toolCallId: string } => isRecord(part) && part['type'] === type && typeof part['toolCallId'] === 'string')
|
|
355
|
+
.map((part) => part.toolCallId);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* A transcript cut into groups: each assistant message with the tool messages
|
|
360
|
+
* that answer it. A group is complete when every call it makes has a result
|
|
361
|
+
* in it and every result answers one of its calls; `null` marks a group that
|
|
362
|
+
* is not, which no suffix may reach past.
|
|
363
|
+
*/
|
|
364
|
+
function groupsOf(messages: readonly ModelMessage[]): ({ messages: ModelMessage[]; calls: number } | null)[] {
|
|
365
|
+
const groups: { messages: ModelMessage[] }[] = [];
|
|
366
|
+
for (const message of messages) {
|
|
367
|
+
if (message.role === 'assistant') groups.push({ messages: [message] });
|
|
368
|
+
else {
|
|
369
|
+
const current = groups[groups.length - 1];
|
|
370
|
+
// A result with no assistant before it answers nothing that is kept.
|
|
371
|
+
if (current === undefined) groups.push({ messages: [message] });
|
|
372
|
+
else current.messages.push(message);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return groups.map((group) => {
|
|
376
|
+
const [head, ...answers] = group.messages;
|
|
377
|
+
if (head === undefined || head.role !== 'assistant') return null;
|
|
378
|
+
const calls = partIds(head, 'tool-call');
|
|
379
|
+
const results = answers.flatMap((message) => (message.role === 'tool' ? partIds(message, 'tool-result') : [null]));
|
|
380
|
+
const answered = new Set(results);
|
|
381
|
+
const complete =
|
|
382
|
+
results.length === calls.length && calls.every((id) => answered.has(id)) && results.every((id) => id !== null && calls.includes(id));
|
|
383
|
+
return complete ? { messages: group.messages, calls: calls.length } : null;
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** The first assistant message of a suffix, opening with the marker as its first text part. */
|
|
388
|
+
function withMarker(messages: readonly ModelMessage[], marker: string): ModelMessage[] {
|
|
389
|
+
const [first, ...rest] = messages;
|
|
390
|
+
if (first === undefined || first.role !== 'assistant') return [...messages];
|
|
391
|
+
const text = { type: 'text' as const, text: marker };
|
|
392
|
+
const content = typeof first.content === 'string' ? [text, { type: 'text' as const, text: first.content }] : [text, ...first.content];
|
|
393
|
+
return [{ ...first, content } as ModelMessage, ...rest];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* The newest complete groups of a turn too long to expand whole, or `null`
|
|
398
|
+
* when not even its closing words and one group with a call fit in `room`.
|
|
399
|
+
*
|
|
400
|
+
* Kept newest first, whole groups only, so a call never arrives without its
|
|
401
|
+
* result or a result without its call. The first kept assistant message says
|
|
402
|
+
* how many earlier calls are not shown, and the marker counts toward `room`.
|
|
403
|
+
*/
|
|
404
|
+
function newestGroups(bounded: readonly ModelMessage[], room: number): ModelMessage[] | null {
|
|
405
|
+
const groups = groupsOf(bounded);
|
|
406
|
+
const totalCalls = groups.reduce((sum, group) => sum + (group?.calls ?? 0), 0);
|
|
407
|
+
let best: ModelMessage[] | null = null;
|
|
408
|
+
let kept: ModelMessage[] = [];
|
|
409
|
+
let keptCalls = 0;
|
|
410
|
+
for (let index = groups.length - 1; index >= 0; index -= 1) {
|
|
411
|
+
const group = groups[index];
|
|
412
|
+
if (group === null || group === undefined) break;
|
|
413
|
+
kept = [...group.messages, ...kept];
|
|
414
|
+
keptCalls += group.calls;
|
|
415
|
+
// The closing words alone are not worth a partial turn: it needs a call.
|
|
416
|
+
if (keptCalls === 0) continue;
|
|
417
|
+
const candidate = withMarker(kept, omittedMarker(totalCalls - keptCalls));
|
|
418
|
+
if (JSON.stringify(candidate).length > room) break;
|
|
419
|
+
best = candidate;
|
|
420
|
+
}
|
|
421
|
+
return best;
|
|
422
|
+
}
|
|
423
|
+
|
|
314
424
|
/**
|
|
315
425
|
* History as the model is given it.
|
|
316
426
|
*
|
|
@@ -318,8 +428,13 @@ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMes
|
|
|
318
428
|
* transcript the host holds is replaced by that transcript — its own tool calls
|
|
319
429
|
* and results, bounded — while fewer than `limits.turns` have been and the total
|
|
320
430
|
* stays under `limits.totalChars`. The first turn that would cross the total
|
|
321
|
-
*
|
|
322
|
-
*
|
|
431
|
+
* gives its newest complete calls instead, as many as fit what is left, under a
|
|
432
|
+
* line saying how many earlier ones are not shown; if not even one call and its
|
|
433
|
+
* result fit, it stays text. Either way it is the last turn expanded, and every
|
|
434
|
+
* turn older than it is text. 12j expanded a turn whole or not at all, and
|
|
435
|
+
* measured that a turn of twenty calls or so is 60,000 to 145,000 characters
|
|
436
|
+
* after bounds: the long turns were exactly the ones that came back as words.
|
|
437
|
+
* Every other turn is its text, exactly as before. User turns keep their place.
|
|
323
438
|
*/
|
|
324
439
|
export function expandHistory(
|
|
325
440
|
history: readonly ChatTurn[],
|
|
@@ -347,7 +462,14 @@ export function expandHistory(
|
|
|
347
462
|
const chars = JSON.stringify(bounded).length;
|
|
348
463
|
if (total + chars > limits.totalChars) {
|
|
349
464
|
full = true;
|
|
350
|
-
|
|
465
|
+
const partial = newestGroups(bounded, limits.totalChars - total);
|
|
466
|
+
if (partial === null) {
|
|
467
|
+
segments.push(text);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
total += JSON.stringify(partial).length;
|
|
471
|
+
expanded += 1;
|
|
472
|
+
segments.push(partial);
|
|
351
473
|
continue;
|
|
352
474
|
}
|
|
353
475
|
total += chars;
|
|
@@ -661,7 +783,7 @@ export async function runChat(
|
|
|
661
783
|
// or a run store is left with something that looks like it is still running.
|
|
662
784
|
let ended = false;
|
|
663
785
|
const started = Date.now();
|
|
664
|
-
const tally: TurnTally = { steps: 0 };
|
|
786
|
+
const tally: TurnTally = { steps: 0, stepsEnded: 0, stepInput: 0, stepOutput: 0 };
|
|
665
787
|
const recorder = new TranscriptRecorder();
|
|
666
788
|
const transcript = new TranscriptWriter(params.runId, deps);
|
|
667
789
|
// A turn that never reaches `finish` — stopped, failed, or cut off by the
|
|
@@ -677,12 +799,15 @@ export async function runChat(
|
|
|
677
799
|
const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
|
|
678
800
|
if (ended) return;
|
|
679
801
|
ended = true;
|
|
802
|
+
// A turn that never reached `finish` still leaves its record what it knows.
|
|
803
|
+
tally.usage ??= partialUsage(tally);
|
|
680
804
|
const onRunEnd = deps.onRunEnd;
|
|
681
805
|
if (onRunEnd === undefined) return;
|
|
682
806
|
const detail: RunEndDetail = {
|
|
683
807
|
steps: tally.steps,
|
|
684
808
|
ms: Date.now() - started,
|
|
685
809
|
...(tally.usage === undefined ? {} : { usage: tally.usage }),
|
|
810
|
+
...(tally.modelId === undefined ? {} : { modelId: tally.modelId }),
|
|
686
811
|
};
|
|
687
812
|
safely(deps.logger, 'onRunEnd', () =>
|
|
688
813
|
onRunEnd(params.runId, status, params.message.slice(0, SUMMARY_CHARS), detail),
|
|
@@ -745,6 +870,9 @@ async function runTurn(
|
|
|
745
870
|
// after the provider and key checks, so the vision check below and the model
|
|
746
871
|
// instance built later both follow it without a second code path.
|
|
747
872
|
const resolved = await deps.registry.resolve({ modelId: params.modelId });
|
|
873
|
+
// Known here and nowhere later: a listener writing down what the turn used
|
|
874
|
+
// is told which model used it, not left to guess from settings since changed.
|
|
875
|
+
tally.modelId = resolved.modelId;
|
|
748
876
|
|
|
749
877
|
// Both checks come before anything is emitted, so a turn that cannot carry
|
|
750
878
|
// its images fails as a whole rather than half-answering.
|
|
@@ -808,11 +936,27 @@ async function runTurn(
|
|
|
808
936
|
onChunk: ({ chunk }) => {
|
|
809
937
|
if (chunk.type === 'text-delta') recorder.wrote(chunk.text);
|
|
810
938
|
},
|
|
811
|
-
onStepEnd: (step) =>
|
|
939
|
+
onStepEnd: (step) => {
|
|
940
|
+
recorder.stepEnded(step.response.messages);
|
|
941
|
+
// Per step, as the step ends: the only usage a turn that does not reach
|
|
942
|
+
// `finish` will ever have.
|
|
943
|
+
tally.stepsEnded += 1;
|
|
944
|
+
tally.stepInput += step.usage.inputTokens ?? 0;
|
|
945
|
+
tally.stepOutput += step.usage.outputTokens ?? 0;
|
|
946
|
+
const onUsageSoFar = deps.onUsageSoFar;
|
|
947
|
+
if (onUsageSoFar !== undefined) {
|
|
948
|
+
const soFar = { inputTokens: tally.stepInput, outputTokens: tally.stepOutput };
|
|
949
|
+
safely(deps.logger, 'onUsageSoFar', () => onUsageSoFar(params.runId, soFar));
|
|
950
|
+
}
|
|
951
|
+
},
|
|
812
952
|
});
|
|
813
953
|
|
|
814
954
|
for await (const part of result.fullStream) {
|
|
815
|
-
|
|
955
|
+
// Stopped: the sink is closed, so the subtotal goes to the run record only.
|
|
956
|
+
if (sink.signal.aborted) {
|
|
957
|
+
tally.usage ??= partialUsage(tally);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
816
960
|
switch (part.type) {
|
|
817
961
|
case 'text-delta':
|
|
818
962
|
await sink.emit({ type: 'text', text: part.text });
|
|
@@ -837,7 +981,14 @@ async function runTurn(
|
|
|
837
981
|
await sink.emit({ type: 'done' });
|
|
838
982
|
break;
|
|
839
983
|
}
|
|
840
|
-
case 'error':
|
|
984
|
+
case 'error': {
|
|
985
|
+
// The sink is still open, so what the completed steps used is said,
|
|
986
|
+
// marked as not the whole, before the error that ends the turn.
|
|
987
|
+
const partial = partialUsage(tally);
|
|
988
|
+
if (partial !== undefined) {
|
|
989
|
+
tally.usage = partial;
|
|
990
|
+
await sink.emit({ type: 'usage', ...partial });
|
|
991
|
+
}
|
|
841
992
|
await sink.emit({
|
|
842
993
|
type: 'error',
|
|
843
994
|
code: 'provider',
|
|
@@ -847,6 +998,7 @@ async function runTurn(
|
|
|
847
998
|
// settled here too.
|
|
848
999
|
end('failed');
|
|
849
1000
|
return;
|
|
1001
|
+
}
|
|
850
1002
|
case 'tool-error': {
|
|
851
1003
|
// `execute` never throws, so this means the SDK failed before the tool
|
|
852
1004
|
// ran — a malformed call, usually. The browser still needs a result
|
|
@@ -861,6 +1013,7 @@ async function runTurn(
|
|
|
861
1013
|
break;
|
|
862
1014
|
}
|
|
863
1015
|
case 'abort':
|
|
1016
|
+
tally.usage ??= partialUsage(tally);
|
|
864
1017
|
end('cancelled');
|
|
865
1018
|
return;
|
|
866
1019
|
default:
|
|
@@ -128,6 +128,9 @@ const chatEvent = s.object({
|
|
|
128
128
|
expiresAt: s.optional(s.number()),
|
|
129
129
|
inputTokens: s.optional(s.number()),
|
|
130
130
|
outputTokens: s.optional(s.number()),
|
|
131
|
+
// On `usage`: true when the turn did not finish, so these are the steps that
|
|
132
|
+
// completed and not the whole. Absent on a finished turn's total.
|
|
133
|
+
partial: s.optional(s.boolean()),
|
|
131
134
|
code: s.optional(s.string()),
|
|
132
135
|
message: s.optional(s.string()),
|
|
133
136
|
});
|
package/src/ai/shared/types.ts
CHANGED
|
@@ -127,7 +127,8 @@ export interface StoredMessage {
|
|
|
127
127
|
* confirm callId, tool, input, requestId, releaseId, argumentsHash,
|
|
128
128
|
* expiresAt (waits for ai.chatConfirm)
|
|
129
129
|
* tool-result callId, tool, output, denied?
|
|
130
|
-
* usage inputTokens, outputTokens
|
|
130
|
+
* usage inputTokens, outputTokens, partial? (true: the turn did not
|
|
131
|
+
* finish, and these are only its completed steps)
|
|
131
132
|
* done —
|
|
132
133
|
* error code, message
|
|
133
134
|
*/
|
|
@@ -148,6 +149,8 @@ export interface ChatEvent {
|
|
|
148
149
|
expiresAt?: number;
|
|
149
150
|
inputTokens?: number;
|
|
150
151
|
outputTokens?: number;
|
|
152
|
+
/** On `usage`: the turn did not finish, so this is a subtotal, not the whole. */
|
|
153
|
+
partial?: boolean;
|
|
151
154
|
code?: string;
|
|
152
155
|
message?: string;
|
|
153
156
|
}
|