broapp 0.4.3 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "broapp",
3
- "version": "0.4.3",
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",
@@ -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
- usage?: { inputTokens: number; outputTokens: number };
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
  /**
@@ -311,6 +333,85 @@ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMes
311
333
  return { ...message, content } as ModelMessage;
312
334
  }
313
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
+
314
415
  /**
315
416
  * History as the model is given it.
316
417
  *
@@ -318,8 +419,13 @@ function boundMessage(message: ResponseMessage, limits: HistoryLimits): ModelMes
318
419
  * transcript the host holds is replaced by that transcript — its own tool calls
319
420
  * and results, bounded — while fewer than `limits.turns` have been and the total
320
421
  * stays under `limits.totalChars`. The first turn that would cross the total
321
- * stays text, and so does every turn older than it. Every other turn is its text,
322
- * exactly as before. User turns keep their place.
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.
323
429
  */
324
430
  export function expandHistory(
325
431
  history: readonly ChatTurn[],
@@ -347,7 +453,14 @@ export function expandHistory(
347
453
  const chars = JSON.stringify(bounded).length;
348
454
  if (total + chars > limits.totalChars) {
349
455
  full = true;
350
- segments.push(text);
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);
351
464
  continue;
352
465
  }
353
466
  total += chars;
@@ -661,7 +774,7 @@ export async function runChat(
661
774
  // or a run store is left with something that looks like it is still running.
662
775
  let ended = false;
663
776
  const started = Date.now();
664
- const tally: TurnTally = { steps: 0 };
777
+ const tally: TurnTally = { steps: 0, stepsEnded: 0, stepInput: 0, stepOutput: 0 };
665
778
  const recorder = new TranscriptRecorder();
666
779
  const transcript = new TranscriptWriter(params.runId, deps);
667
780
  // A turn that never reaches `finish` — stopped, failed, or cut off by the
@@ -677,6 +790,8 @@ export async function runChat(
677
790
  const end = (status: 'succeeded' | 'failed' | 'cancelled'): void => {
678
791
  if (ended) return;
679
792
  ended = true;
793
+ // A turn that never reached `finish` still leaves its record what it knows.
794
+ tally.usage ??= partialUsage(tally);
680
795
  const onRunEnd = deps.onRunEnd;
681
796
  if (onRunEnd === undefined) return;
682
797
  const detail: RunEndDetail = {
@@ -808,11 +923,22 @@ async function runTurn(
808
923
  onChunk: ({ chunk }) => {
809
924
  if (chunk.type === 'text-delta') recorder.wrote(chunk.text);
810
925
  },
811
- onStepEnd: (step) => recorder.stepEnded(step.response.messages),
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
+ },
812
934
  });
813
935
 
814
936
  for await (const part of result.fullStream) {
815
- if (sink.signal.aborted) return;
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
+ }
816
942
  switch (part.type) {
817
943
  case 'text-delta':
818
944
  await sink.emit({ type: 'text', text: part.text });
@@ -837,7 +963,14 @@ async function runTurn(
837
963
  await sink.emit({ type: 'done' });
838
964
  break;
839
965
  }
840
- 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
+ }
841
974
  await sink.emit({
842
975
  type: 'error',
843
976
  code: 'provider',
@@ -847,6 +980,7 @@ async function runTurn(
847
980
  // settled here too.
848
981
  end('failed');
849
982
  return;
983
+ }
850
984
  case 'tool-error': {
851
985
  // `execute` never throws, so this means the SDK failed before the tool
852
986
  // ran — a malformed call, usually. The browser still needs a result
@@ -861,6 +995,7 @@ async function runTurn(
861
995
  break;
862
996
  }
863
997
  case 'abort':
998
+ tally.usage ??= partialUsage(tally);
864
999
  end('cancelled');
865
1000
  return;
866
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
  });
@@ -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
  }