broapp 0.4.2 → 0.4.4
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 +4 -4
- package/src/ai/host/create-ai.ts +27 -2
- package/src/ai/host/index.ts +1 -0
- package/src/ai/host/run.ts +153 -10
- package/src/ai/shared/contract.ts +3 -0
- package/src/ai/shared/types.ts +4 -1
- package/src/host/runtime.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "broapp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.4",
|
|
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",
|
|
@@ -49,9 +49,9 @@
|
|
|
49
49
|
"./package.json": "./package.json"
|
|
50
50
|
},
|
|
51
51
|
"dependencies": {
|
|
52
|
-
"@brobridgejs/client": "^0.2.
|
|
53
|
-
"@brobridgejs/core": "^0.2.
|
|
54
|
-
"brobridge": "^0.2.
|
|
52
|
+
"@brobridgejs/client": "^0.2.2",
|
|
53
|
+
"@brobridgejs/core": "^0.2.2",
|
|
54
|
+
"brobridge": "^0.2.2"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"react": ">=18",
|
package/src/ai/host/create-ai.ts
CHANGED
|
@@ -71,6 +71,18 @@ export interface InProcessTurn {
|
|
|
71
71
|
readonly history?: readonly ChatTurn[];
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/** One question put to an in-process turn's stand-in. */
|
|
75
|
+
export interface InProcessQuestion {
|
|
76
|
+
readonly tool: string;
|
|
77
|
+
readonly input: unknown;
|
|
78
|
+
/** The approval table's key, `<runId>:<callId>`. */
|
|
79
|
+
readonly requestId: string;
|
|
80
|
+
/** The call the question is about, as `ai.chatConfirm` names it with the run id. */
|
|
81
|
+
readonly callId: string;
|
|
82
|
+
/** When the question stops waiting, when the gate said. */
|
|
83
|
+
readonly expiresAt?: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
74
86
|
/** How {@link Ai.turn} is answered and stopped. */
|
|
75
87
|
export interface InProcessTurnOptions {
|
|
76
88
|
/**
|
|
@@ -78,8 +90,14 @@ export interface InProcessTurnOptions {
|
|
|
78
90
|
*
|
|
79
91
|
* The caller is the person's stand-in, so it decides exactly as the person
|
|
80
92
|
* would have: the gate still asks, and still records the answer.
|
|
93
|
+
*
|
|
94
|
+
* `'defer'` answers nothing. The question stays in the approval table for
|
|
95
|
+
* somebody else to answer by its request id, over `ai.chatConfirm` as a
|
|
96
|
+
* person in a chat would, and the gate's own window still ends it. That is
|
|
97
|
+
* for a stand-in that answers some questions itself and brings the rest to a
|
|
98
|
+
* person: whoever answers, each question is answered once.
|
|
81
99
|
*/
|
|
82
|
-
readonly answer: (question:
|
|
100
|
+
readonly answer: (question: InProcessQuestion) => boolean | 'defer';
|
|
83
101
|
/** Aborting it cancels the turn, as a browser's cancel would. */
|
|
84
102
|
readonly signal?: AbortSignal;
|
|
85
103
|
readonly onEvent?: (event: ChatEvent) => void;
|
|
@@ -398,7 +416,14 @@ export function createAi(options: CreateAiOptions): Ai {
|
|
|
398
416
|
// The gate's request id is `<runId>:<callId>`, which is what an
|
|
399
417
|
// event without one would have named.
|
|
400
418
|
const requestId = event.requestId ?? `${turn.runId}:${event.callId}`;
|
|
401
|
-
|
|
419
|
+
const answer = turnOptions.answer({
|
|
420
|
+
tool: event.tool ?? '',
|
|
421
|
+
input: event.input,
|
|
422
|
+
requestId,
|
|
423
|
+
callId: event.callId ?? '',
|
|
424
|
+
...(event.expiresAt === undefined ? {} : { expiresAt: event.expiresAt }),
|
|
425
|
+
});
|
|
426
|
+
if (answer !== 'defer') void settle(requestId, answer);
|
|
402
427
|
}
|
|
403
428
|
return Promise.resolve();
|
|
404
429
|
},
|
package/src/ai/host/index.ts
CHANGED
package/src/ai/host/run.ts
CHANGED
|
@@ -85,7 +85,29 @@ export interface RunDeps {
|
|
|
85
85
|
/** What a turn counts as it goes, for {@link RunEndDetail}. */
|
|
86
86
|
interface TurnTally {
|
|
87
87
|
steps: number;
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The turn's usage. The SDK's total when the turn reached `finish`;
|
|
90
|
+
* otherwise the sum of the steps that completed, marked `partial`, because
|
|
91
|
+
* the step in flight when it stopped used tokens nobody reported.
|
|
92
|
+
*/
|
|
93
|
+
usage?: { inputTokens: number; outputTokens: number; partial?: true };
|
|
94
|
+
/** Model steps that completed, and what they reported, added as each ends. */
|
|
95
|
+
stepsEnded: number;
|
|
96
|
+
stepInput: number;
|
|
97
|
+
stepOutput: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* What the completed steps of a turn that did not finish add up to, or
|
|
102
|
+
* nothing when no step completed.
|
|
103
|
+
*
|
|
104
|
+
* A turn cut short by a time limit, a stop or an error never sees `finish`,
|
|
105
|
+
* and so never sees the total. Counting it as zero made a turn that ran for
|
|
106
|
+
* twenty minutes look free; this is what is known, and says it is not all.
|
|
107
|
+
*/
|
|
108
|
+
function partialUsage(tally: TurnTally): TurnTally['usage'] {
|
|
109
|
+
if (tally.stepsEnded === 0) return undefined;
|
|
110
|
+
return { inputTokens: tally.stepInput, outputTokens: tally.stepOutput, partial: true };
|
|
89
111
|
}
|
|
90
112
|
|
|
91
113
|
/**
|
|
@@ -288,14 +310,22 @@ function boundOutput(output: unknown, max: number): unknown {
|
|
|
288
310
|
return { type: 'text', value: cut(json, max) };
|
|
289
311
|
}
|
|
290
312
|
|
|
291
|
-
/**
|
|
313
|
+
/**
|
|
314
|
+
* One transcript message with every tool input and output bounded.
|
|
315
|
+
*
|
|
316
|
+
* A cut input stays an object, `{ truncated: "<head><omitted N chars>" }`,
|
|
317
|
+
* rather than becoming the string itself: an OpenAI-compatible provider sends
|
|
318
|
+
* a call's input as its `arguments`, and Ollama refuses a whole request whose
|
|
319
|
+
* arguments are not a JSON object ("invalid tool call arguments"). The first
|
|
320
|
+
* 12j evaluation lost a turn to exactly that.
|
|
321
|
+
*/
|
|
292
322
|
function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMessage {
|
|
293
323
|
if (!Array.isArray(message.content)) return message;
|
|
294
324
|
const content = (message.content as readonly unknown[]).map((part) => {
|
|
295
325
|
if (!isRecord(part)) return part;
|
|
296
326
|
if (part['type'] === 'tool-call') {
|
|
297
327
|
const json = JSON.stringify(part['input']) ?? '';
|
|
298
|
-
return json.length <= limits.inputChars ? part : { ...part, input: cut(json, limits.inputChars) };
|
|
328
|
+
return json.length <= limits.inputChars ? part : { ...part, input: { truncated: cut(json, limits.inputChars) } };
|
|
299
329
|
}
|
|
300
330
|
if (part['type'] === 'tool-result') return { ...part, output: boundOutput(part['output'], limits.outputChars) };
|
|
301
331
|
return part;
|
|
@@ -303,6 +333,85 @@ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMes
|
|
|
303
333
|
return { ...message, content } as ModelMessage;
|
|
304
334
|
}
|
|
305
335
|
|
|
336
|
+
/** The line a partial turn opens with, saying how many of its calls are not there. */
|
|
337
|
+
function omittedMarker(calls: number): string {
|
|
338
|
+
return `[${String(calls)} earlier tool calls of this turn are not shown]`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** The tool-call ids an assistant message makes, or the tool-result ids a tool message answers. */
|
|
342
|
+
function partIds(message: ModelMessage, type: 'tool-call' | 'tool-result'): string[] {
|
|
343
|
+
if (!Array.isArray(message.content)) return [];
|
|
344
|
+
return (message.content as readonly unknown[])
|
|
345
|
+
.filter((part): part is { type: string; toolCallId: string } => isRecord(part) && part['type'] === type && typeof part['toolCallId'] === 'string')
|
|
346
|
+
.map((part) => part.toolCallId);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* A transcript cut into groups: each assistant message with the tool messages
|
|
351
|
+
* that answer it. A group is complete when every call it makes has a result
|
|
352
|
+
* in it and every result answers one of its calls; `null` marks a group that
|
|
353
|
+
* is not, which no suffix may reach past.
|
|
354
|
+
*/
|
|
355
|
+
function groupsOf(messages: readonly ModelMessage[]): ({ messages: ModelMessage[]; calls: number } | null)[] {
|
|
356
|
+
const groups: { messages: ModelMessage[] }[] = [];
|
|
357
|
+
for (const message of messages) {
|
|
358
|
+
if (message.role === 'assistant') groups.push({ messages: [message] });
|
|
359
|
+
else {
|
|
360
|
+
const current = groups[groups.length - 1];
|
|
361
|
+
// A result with no assistant before it answers nothing that is kept.
|
|
362
|
+
if (current === undefined) groups.push({ messages: [message] });
|
|
363
|
+
else current.messages.push(message);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return groups.map((group) => {
|
|
367
|
+
const [head, ...answers] = group.messages;
|
|
368
|
+
if (head === undefined || head.role !== 'assistant') return null;
|
|
369
|
+
const calls = partIds(head, 'tool-call');
|
|
370
|
+
const results = answers.flatMap((message) => (message.role === 'tool' ? partIds(message, 'tool-result') : [null]));
|
|
371
|
+
const answered = new Set(results);
|
|
372
|
+
const complete =
|
|
373
|
+
results.length === calls.length && calls.every((id) => answered.has(id)) && results.every((id) => id !== null && calls.includes(id));
|
|
374
|
+
return complete ? { messages: group.messages, calls: calls.length } : null;
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** The first assistant message of a suffix, opening with the marker as its first text part. */
|
|
379
|
+
function withMarker(messages: readonly ModelMessage[], marker: string): ModelMessage[] {
|
|
380
|
+
const [first, ...rest] = messages;
|
|
381
|
+
if (first === undefined || first.role !== 'assistant') return [...messages];
|
|
382
|
+
const text = { type: 'text' as const, text: marker };
|
|
383
|
+
const content = typeof first.content === 'string' ? [text, { type: 'text' as const, text: first.content }] : [text, ...first.content];
|
|
384
|
+
return [{ ...first, content } as ModelMessage, ...rest];
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The newest complete groups of a turn too long to expand whole, or `null`
|
|
389
|
+
* when not even its closing words and one group with a call fit in `room`.
|
|
390
|
+
*
|
|
391
|
+
* Kept newest first, whole groups only, so a call never arrives without its
|
|
392
|
+
* result or a result without its call. The first kept assistant message says
|
|
393
|
+
* how many earlier calls are not shown, and the marker counts toward `room`.
|
|
394
|
+
*/
|
|
395
|
+
function newestGroups(bounded: readonly ModelMessage[], room: number): ModelMessage[] | null {
|
|
396
|
+
const groups = groupsOf(bounded);
|
|
397
|
+
const totalCalls = groups.reduce((sum, group) => sum + (group?.calls ?? 0), 0);
|
|
398
|
+
let best: ModelMessage[] | null = null;
|
|
399
|
+
let kept: ModelMessage[] = [];
|
|
400
|
+
let keptCalls = 0;
|
|
401
|
+
for (let index = groups.length - 1; index >= 0; index -= 1) {
|
|
402
|
+
const group = groups[index];
|
|
403
|
+
if (group === null || group === undefined) break;
|
|
404
|
+
kept = [...group.messages, ...kept];
|
|
405
|
+
keptCalls += group.calls;
|
|
406
|
+
// The closing words alone are not worth a partial turn: it needs a call.
|
|
407
|
+
if (keptCalls === 0) continue;
|
|
408
|
+
const candidate = withMarker(kept, omittedMarker(totalCalls - keptCalls));
|
|
409
|
+
if (JSON.stringify(candidate).length > room) break;
|
|
410
|
+
best = candidate;
|
|
411
|
+
}
|
|
412
|
+
return best;
|
|
413
|
+
}
|
|
414
|
+
|
|
306
415
|
/**
|
|
307
416
|
* History as the model is given it.
|
|
308
417
|
*
|
|
@@ -310,8 +419,13 @@ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMes
|
|
|
310
419
|
* transcript the host holds is replaced by that transcript — its own tool calls
|
|
311
420
|
* and results, bounded — while fewer than `limits.turns` have been and the total
|
|
312
421
|
* stays under `limits.totalChars`. The first turn that would cross the total
|
|
313
|
-
*
|
|
314
|
-
*
|
|
422
|
+
* gives its newest complete calls instead, as many as fit what is left, under a
|
|
423
|
+
* line saying how many earlier ones are not shown; if not even one call and its
|
|
424
|
+
* result fit, it stays text. Either way it is the last turn expanded, and every
|
|
425
|
+
* turn older than it is text. 12j expanded a turn whole or not at all, and
|
|
426
|
+
* measured that a turn of twenty calls or so is 60,000 to 145,000 characters
|
|
427
|
+
* after bounds: the long turns were exactly the ones that came back as words.
|
|
428
|
+
* Every other turn is its text, exactly as before. User turns keep their place.
|
|
315
429
|
*/
|
|
316
430
|
export function expandHistory(
|
|
317
431
|
history: readonly ChatTurn[],
|
|
@@ -339,7 +453,14 @@ export function expandHistory(
|
|
|
339
453
|
const chars = JSON.stringify(bounded).length;
|
|
340
454
|
if (total + chars > limits.totalChars) {
|
|
341
455
|
full = true;
|
|
342
|
-
|
|
456
|
+
const partial = newestGroups(bounded, limits.totalChars - total);
|
|
457
|
+
if (partial === null) {
|
|
458
|
+
segments.push(text);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
total += JSON.stringify(partial).length;
|
|
462
|
+
expanded += 1;
|
|
463
|
+
segments.push(partial);
|
|
343
464
|
continue;
|
|
344
465
|
}
|
|
345
466
|
total += chars;
|
|
@@ -653,7 +774,7 @@ export async function runChat(
|
|
|
653
774
|
// or a run store is left with something that looks like it is still running.
|
|
654
775
|
let ended = false;
|
|
655
776
|
const started = Date.now();
|
|
656
|
-
const tally: TurnTally = { steps: 0 };
|
|
777
|
+
const tally: TurnTally = { steps: 0, stepsEnded: 0, stepInput: 0, stepOutput: 0 };
|
|
657
778
|
const recorder = new TranscriptRecorder();
|
|
658
779
|
const transcript = new TranscriptWriter(params.runId, deps);
|
|
659
780
|
// A turn that never reaches `finish` — stopped, failed, or cut off by the
|
|
@@ -669,6 +790,8 @@ export async function runChat(
|
|
|
669
790
|
const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
|
|
670
791
|
if (ended) return;
|
|
671
792
|
ended = true;
|
|
793
|
+
// A turn that never reached `finish` still leaves its record what it knows.
|
|
794
|
+
tally.usage ??= partialUsage(tally);
|
|
672
795
|
const onRunEnd = deps.onRunEnd;
|
|
673
796
|
if (onRunEnd === undefined) return;
|
|
674
797
|
const detail: RunEndDetail = {
|
|
@@ -800,11 +923,22 @@ async function runTurn(
|
|
|
800
923
|
onChunk: ({ chunk }) => {
|
|
801
924
|
if (chunk.type === 'text-delta') recorder.wrote(chunk.text);
|
|
802
925
|
},
|
|
803
|
-
onStepEnd: (step) =>
|
|
926
|
+
onStepEnd: (step) => {
|
|
927
|
+
recorder.stepEnded(step.response.messages);
|
|
928
|
+
// Per step, as the step ends: the only usage a turn that does not reach
|
|
929
|
+
// `finish` will ever have.
|
|
930
|
+
tally.stepsEnded += 1;
|
|
931
|
+
tally.stepInput += step.usage.inputTokens ?? 0;
|
|
932
|
+
tally.stepOutput += step.usage.outputTokens ?? 0;
|
|
933
|
+
},
|
|
804
934
|
});
|
|
805
935
|
|
|
806
936
|
for await (const part of result.fullStream) {
|
|
807
|
-
|
|
937
|
+
// Stopped: the sink is closed, so the subtotal goes to the run record only.
|
|
938
|
+
if (sink.signal.aborted) {
|
|
939
|
+
tally.usage ??= partialUsage(tally);
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
808
942
|
switch (part.type) {
|
|
809
943
|
case 'text-delta':
|
|
810
944
|
await sink.emit({ type: 'text', text: part.text });
|
|
@@ -829,7 +963,14 @@ async function runTurn(
|
|
|
829
963
|
await sink.emit({ type: 'done' });
|
|
830
964
|
break;
|
|
831
965
|
}
|
|
832
|
-
case 'error':
|
|
966
|
+
case 'error': {
|
|
967
|
+
// The sink is still open, so what the completed steps used is said,
|
|
968
|
+
// marked as not the whole, before the error that ends the turn.
|
|
969
|
+
const partial = partialUsage(tally);
|
|
970
|
+
if (partial !== undefined) {
|
|
971
|
+
tally.usage = partial;
|
|
972
|
+
await sink.emit({ type: 'usage', ...partial });
|
|
973
|
+
}
|
|
833
974
|
await sink.emit({
|
|
834
975
|
type: 'error',
|
|
835
976
|
code: 'provider',
|
|
@@ -839,6 +980,7 @@ async function runTurn(
|
|
|
839
980
|
// settled here too.
|
|
840
981
|
end('failed');
|
|
841
982
|
return;
|
|
983
|
+
}
|
|
842
984
|
case 'tool-error': {
|
|
843
985
|
// `execute` never throws, so this means the SDK failed before the tool
|
|
844
986
|
// ran — a malformed call, usually. The browser still needs a result
|
|
@@ -853,6 +995,7 @@ async function runTurn(
|
|
|
853
995
|
break;
|
|
854
996
|
}
|
|
855
997
|
case 'abort':
|
|
998
|
+
tally.usage ??= partialUsage(tally);
|
|
856
999
|
end('cancelled');
|
|
857
1000
|
return;
|
|
858
1001
|
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
|
}
|
package/src/host/runtime.ts
CHANGED
|
@@ -76,6 +76,15 @@ export interface RunningApp {
|
|
|
76
76
|
* looking attached for that whole minute.
|
|
77
77
|
*/
|
|
78
78
|
readonly attached: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* A fresh address for this application, carrying a new one-time launch token.
|
|
81
|
+
*
|
|
82
|
+
* Brobridge mints the token; each address works once and expires on the
|
|
83
|
+
* bridge's launch-token lifetime. Call it on the host's own decision — a
|
|
84
|
+
* person's click in an authenticated tab, a local process that already holds
|
|
85
|
+
* a credential — never because a browser asked. Do not log the result.
|
|
86
|
+
*/
|
|
87
|
+
launchUrl(): string;
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
const POLL_INTERVAL_MS = 1_000;
|
|
@@ -215,5 +224,6 @@ export async function startApp(options: StartAppOptions): Promise<RunningApp> {
|
|
|
215
224
|
get attached() {
|
|
216
225
|
return isAttached();
|
|
217
226
|
},
|
|
227
|
+
launchUrl: () => bridge.launchUrl(),
|
|
218
228
|
};
|
|
219
229
|
}
|