@cubicecho/agent-core 4.18.0 → 4.19.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/README.md CHANGED
@@ -826,7 +826,12 @@ failure short of a stop, the cut-off answer is kept with a notice. The cap is on
826
826
  continue, one to find out.
827
827
 
828
828
  Every turn ends in a `usage` event, whether or not the endpoint reported tokens. Its totals are the
829
- run's so far; its `turn` is that turn's own `TurnUsage` and `finishReason`. The loop adds what only
829
+ run's so far — `promptTokens`, `completionTokens`, `totalTokens`, `cachedTokens` and
830
+ `cacheReported`, the `UsageTotals` spelling that `result.usage` and a failed run's `usage` share
831
+ (their older `prompt`, `completion`, `total` and `cached` are still there, deprecated). A
832
+ `cachedTokens` of zero is a miss only where `cacheReported` is true; false is an endpoint that never
833
+ said. Its `turn` is that turn's own `TurnUsage` and `finishReason`, with the `contextLength` the loop
834
+ was told. The loop adds what only
830
835
  it can know: `toolsDeclared` and `toolSchemaTokens` for the tool block it sent, and, from the second
831
836
  request on, `cacheExpected` (the previous prompt plus its reply) and — where a cache count was
832
837
  reported — `cacheBroken`, a hit short of 90% of the previous prompt. A broken cache is given a
@@ -855,10 +860,12 @@ a lesser body and a continuation with the reply so far on the end, and neither i
855
860
 
856
861
  `runMetrics(events)` adds a run up from those events — tokens, cache hit ratio and breaks by
857
862
  reason, prefill, decode and tool time, the slowest turn, mean time to first token, draft
858
- acceptance, the largest prompt against a `contextLength`, turns cut off, tool errors by name, and
859
- an `outcome`. The loop returns it as `metrics`, with the `load_tools` counts only it can see
860
- (`toolsLoaded`, `redundantLoads`, `unknownToolNames`). It is a sibling of `fold` rather than part
861
- of it, since `fold`'s blocks are for display and a summary is not one:
863
+ acceptance, the largest prompt against its window, turns cut off, tool errors by name, what
864
+ `load_tools` resolved to (`toolsLoaded`, `redundantLoads`, `unknownToolNames`, off each load's
865
+ result as `loads`), and an `outcome`. The loop returns it as `metrics`. Every turn carries its own
866
+ window and every load its own counts, so a host that runs one loop per step gets the whole run from
867
+ `runMetrics` over all the loops' events, with no per-loop result to merge. It is a sibling of `fold`
868
+ rather than part of it, since `fold`'s blocks are for display and a summary is not one:
862
869
 
863
870
  ```ts
864
871
  const metrics = runMetrics(history(runId), { contextLength: config.contextLength });
package/dist/index.d.ts CHANGED
@@ -20,7 +20,7 @@ export { CACHE_PROMPT_FIELD, resetSlots, SESSION_HEADER, SLOT_FIELD, type SlotAl
20
20
  export { HOOK_EVENTS, HookEvent, INJECT_EVENTS } from './hooks/hook-events.ts';
21
21
  export { assembleContext, configureHooks, consult, type Gathered, gather, HOOK_CONTEXT_TOKENS, HOOK_PREFACE, type HookContext, type HookMessage, type HookNote, type HookOptions, type HookOutcome, type HookRunner, notify, resetHooks, turnIndex, turnMessages, UNTRUSTED_PREFACE, untrusted, withContext, } from './hooks/hooks.ts';
22
22
  export { type AgentLoopHooks, type AgentLoopOptions, type AgentLoopRequest, type AgentLoopResult, PRESELECT_APPEND, PRESELECT_EXCLUSIVE, PRESELECTED_NOTICE, runAgentLoop, type StepWindow, } from './run/agent-loop.ts';
23
- export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type RunEvent, type RunEventInput, RunEventKind, type RunMetrics, type RunMetricsOptions, RunOutcome, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from './run/events.ts';
23
+ export { configureEvents, type EventBusOptions, emit, endRun, fold, history, type LoadCounts, type RunEvent, type RunEventInput, RunEventKind, type RunMetrics, type RunMetricsOptions, RunOutcome, type RunUsage, resetEvents, runMetrics, type TurnReport, watch, } from './run/events.ts';
24
24
  export { buildBody } from './run/request-body.ts';
25
25
  export { preview, type ToolDispatch } from './run/run-calls.ts';
26
26
  export { resetAll } from './runtime/reset.ts';
@@ -37,7 +37,7 @@ export { type RunTurnOptions, runTurn } from './turn/run-turn.ts';
37
37
  export { type AskJsonOptions, ask, askJson, askReply, clean, listLines, parseJson, resetHints, type SideTask, type SideTaskInput, type SideTaskOptions, type SideTaskReply, taskCall, tryAsk, } from './turn/side-task.ts';
38
38
  export { AgentLoopError, type AgentLoopFailure, AgentLoopOverflow, errorMessage, failedRun, ToolIterationLimit, } from './wire/errors.ts';
39
39
  export { backoffMs, ContextOverflow, EndpointSilent, isModelLoading, isOverflow, isTransient, SMALLEST_LIKELY_WINDOW, } from './wire/retry.ts';
40
- export { CacheBreakReason, type InFlight, type PartialTurn, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, } from './wire/stream.ts';
40
+ export { CacheBreakReason, type InFlight, type LoopUsage, type PartialTurn, type Produced, type StreamTurnOptions, streamTurn, type Turn, type TurnUsage, type UsageTotals, } from './wire/stream.ts';
41
41
  export { ALL_FENCES, DEFAULT_FENCES, type Fence, FenceSplitter, type FenceSplitterOptions, SPLIT_OUTPUT, SPLIT_REASONING, type Split, stripThinking, THINK_FENCE, } from './wire/thinking.ts';
42
42
  export { type ContextBreakdown, type ContextBreakdownOptions, compact, contextChars, contextTokens, estimateTokens, messageTokens, requestChars, requestTokens, type TokenEstimateOptions, } from './wire/tokens.ts';
43
43
  export { ARGUMENTS_MALFORMED, ARGUMENTS_TRUNCATED, type EndedBy, parseToolArguments, type ResourceLink, type RunArtifact, recoverToolCalls, type ServerLogEntry, ServerLogLevel, type ToolAnswer, ToolArgumentsError, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult, } from './wire/tool-calls.ts';
@@ -9,7 +9,7 @@ import { type ServerInstructionsOptions } from '../tools/instructions.ts';
9
9
  import { type KeywordPreselectOptions } from '../tools/preselect.ts';
10
10
  import { type ToolOrder } from '../tools/tool-loading.ts';
11
11
  import { type SideTask } from '../turn/side-task.ts';
12
- import { type Turn, type TurnUsage } from '../wire/stream.ts';
12
+ import { type LoopUsage, type Turn } from '../wire/stream.ts';
13
13
  import { type EndedBy, type RunArtifact, type ToolCallOutcome, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
14
14
  import { type RunEventInput, type RunMetrics } from './events.ts';
15
15
  import { type ToolDispatch } from './run-calls.ts';
@@ -497,8 +497,11 @@ export interface AgentLoopResult {
497
497
  ended?: EndedBy;
498
498
  /** The transcript, with every assistant turn and tool result the run added. No system prompt. */
499
499
  messages: OpenAI.ChatCompletionMessageParam[];
500
- /** Summed over every turn of the run. */
501
- usage: TurnUsage;
500
+ /**
501
+ * Summed over every turn of the run, in the spelling the `usage` events use, with whether any
502
+ * turn reported a cache count. The four counts are also under their older names.
503
+ */
504
+ usage: LoopUsage;
502
505
  /**
503
506
  * Every call, `load_tools` included, in the order they were made, each under its call id — a
504
507
  * proxied preselection's among them, first.
@@ -550,8 +553,10 @@ export interface AgentLoopResult {
550
553
  */
551
554
  afterTurn: Promise<HookNote[]>;
552
555
  /**
553
- * The run summed and derived: what `runMetrics` makes of the events this loop emitted, plus the
554
- * `load_tools` findings only the loop sees, `wallMs` from the call to the return, and `outcome`.
556
+ * The run summed and derived: what `runMetrics` makes of the events this loop emitted, with the
557
+ * `load_tools` counts at zero rather than absent in a run on demand, `wallMs` from the call to
558
+ * the return, and `outcome`. A host running several loops for one run gets its counts from
559
+ * `runMetrics` over all their events, without these results beside it.
555
560
  */
556
561
  metrics: RunMetrics;
557
562
  /**
@@ -16,7 +16,7 @@ import { runTurn } from '../turn/run-turn.js';
16
16
  import { taskCall, tryAsk } from '../turn/side-task.js';
17
17
  import { AgentLoopError, AgentLoopOverflow, errorMessage, ToolIterationLimit, } from '../wire/errors.js';
18
18
  import { ContextOverflow } from '../wire/retry.js';
19
- import { addCounts, noUsage } from '../wire/stream.js';
19
+ import { addCounts, loopUsage, noUsage, reportsCache, } from '../wire/stream.js';
20
20
  import { contextTokens, toolsChars } from '../wire/tokens.js';
21
21
  import { recoverToolCalls, } from '../wire/tool-calls.js';
22
22
  import { FinishReason, PartType, Role } from '../wire/wire.js';
@@ -127,7 +127,7 @@ export async function runAgentLoop(options) {
127
127
  const standing = {
128
128
  read: () => ({
129
129
  messages: [...options.messages],
130
- usage: noUsage(),
130
+ usage: loopUsage(noUsage(), false),
131
131
  toolCalls: [],
132
132
  artifacts: [],
133
133
  loaded: [],
@@ -530,19 +530,25 @@ function announcer(onMessage) {
530
530
  * @param usage - The run's totals, this turn's already added.
531
531
  * @param turn - The turn just finished, read for its usage and why it ended.
532
532
  * @param ledger - The ledger as it stands. Carried on the event as it is, not copied.
533
+ * @param [contextLength] - The window the turn was served. Absent or zero leaves it off the report.
533
534
  * @returns The event, for the caller to emit.
534
535
  */
535
- const usageEvent = (usage, turn, ledger) => ({
536
- kind: RunEventKind.Usage,
537
- usage: {
538
- promptTokens: usage.prompt,
539
- completionTokens: usage.completion,
540
- totalTokens: usage.total,
541
- cachedTokens: usage.cached,
542
- turn: { ...turn.usage, finishReason: turn.finishReason },
543
- ledger,
544
- },
545
- });
536
+ const usageEvent = (usage, turn, ledger, contextLength) => {
537
+ const { promptTokens, completionTokens, totalTokens, cachedTokens, cacheReported } = usage;
538
+ const window = contextLength ? { contextLength } : {};
539
+ return {
540
+ kind: RunEventKind.Usage,
541
+ usage: {
542
+ promptTokens,
543
+ completionTokens,
544
+ totalTokens,
545
+ cachedTokens,
546
+ cacheReported,
547
+ turn: { ...turn.usage, finishReason: turn.finishReason, ...window },
548
+ ledger,
549
+ },
550
+ };
551
+ };
546
552
  /**
547
553
  * The calls a turn made and the answer beside them, with calls the model wrote as text taken out
548
554
  * of the answer and counted as calls.
@@ -629,6 +635,7 @@ async function runSteps(options, standing, pin) {
629
635
  const byName = (names) => definedAs(definitions, names);
630
636
  let messages = [...options.messages];
631
637
  let usage = noUsage();
638
+ let cacheReported = false;
632
639
  const toolCalls = [];
633
640
  const artifacts = new Map();
634
641
  // A box per request, emptied once its turn is in `messages`: a failure while the tools run had
@@ -639,7 +646,7 @@ async function runSteps(options, standing, pin) {
639
646
  return {
640
647
  messages,
641
648
  ...(partial ? { partial } : {}),
642
- usage,
649
+ usage: loopUsage(usage, cacheReported),
643
650
  toolCalls,
644
651
  artifacts: [...artifacts.values()],
645
652
  // As the result would have said it: proxied, nothing is loaded that the history does not hold.
@@ -837,7 +844,8 @@ async function runSteps(options, standing, pin) {
837
844
  completion: turn.usage.completion,
838
845
  };
839
846
  usage = addCounts(usage, turn.usage);
840
- onEvent(usageEvent(usage, turn, ledger));
847
+ cacheReported ||= reportsCache(turn.usage);
848
+ onEvent(usageEvent(loopUsage(usage, cacheReported), turn, ledger, config.contextLength));
841
849
  if (turn.finishReason === FinishReason.Length) {
842
850
  notice(`the model stopped at maxTokens (${config.maxTokens}); this turn is cut short`);
843
851
  }
@@ -873,7 +881,7 @@ async function runSteps(options, standing, pin) {
873
881
  turn: shown,
874
882
  ...(ended ? { ended } : {}),
875
883
  messages,
876
- usage,
884
+ usage: loopUsage(usage, cacheReported),
877
885
  toolCalls,
878
886
  artifacts: [...artifacts.values()],
879
887
  loaded: proxied ? [] : [...loaded],
@@ -121,6 +121,10 @@ export type RunEventKind = (typeof RunEventKind)[keyof typeof RunEventKind];
121
121
  * What a run has spent, counted from the start of the run rather than for the turn that
122
122
  * carried it: a client draws the latest one it has seen and needs no arithmetic of its own,
123
123
  * and one lost to the backlog cap costs nothing because the next supersedes it.
124
+ *
125
+ * @remarks
126
+ * The counts are `UsageTotals`, the spelling `AgentLoopResult.usage` shares, with the cache's two
127
+ * fields optional so a host emitting usage of its own need not know them.
124
128
  */
125
129
  export interface RunUsage {
126
130
  promptTokens: number;
@@ -131,6 +135,12 @@ export interface RunUsage {
131
135
  * emitting usage before this field existed still compiles; absent reads the same as zero.
132
136
  */
133
137
  cachedTokens?: number;
138
+ /**
139
+ * Whether any turn so far reported a cache count, as `UsageTotals.cacheReported` says it — so a
140
+ * `cachedTokens` of zero reads as a miss or as nothing said. `runAgentLoop` always sets it;
141
+ * optional for the reason `cachedTokens` is, and absent reads as not known.
142
+ */
143
+ cacheReported?: boolean;
134
144
  /**
135
145
  * The turn that carried this report, on its own rather than added in — the half `runMetrics`
136
146
  * reads, since a total says nothing about which turn was slow or where the cache broke. Absent
@@ -148,6 +158,30 @@ export interface RunUsage {
148
158
  export interface TurnReport extends TurnUsage {
149
159
  /** Why the model stopped, as `Turn.finishReason` says it; `length` is a turn cut short. */
150
160
  finishReason: string;
161
+ /**
162
+ * The window the turn was served, where the loop was told one, for `largestPromptShare`.
163
+ *
164
+ * @remarks
165
+ * On the turn rather than only in `runMetrics`' options, because a run of several loops can put
166
+ * them on models with different windows, and one window for the whole run misstates how close
167
+ * any of them came.
168
+ */
169
+ contextLength?: number;
170
+ }
171
+ /**
172
+ * What `load_tools` calls resolved to: new tools, ones already loaded, and names in no catalogue.
173
+ *
174
+ * @remarks
175
+ * Carried on each `load_tools` result as `loads`, so `runMetrics` over every loop's events adds
176
+ * them up without the loops' results beside it.
177
+ */
178
+ export interface LoadCounts {
179
+ /** Tools loaded that were not loaded already. */
180
+ toolsLoaded: number;
181
+ /** Tools asked for that were loaded already. */
182
+ redundantLoads: number;
183
+ /** Names asked for that are in no catalogue. */
184
+ unknownToolNames: number;
151
185
  }
152
186
  /**
153
187
  * One thing that happened in a run, as a watcher receives it.
@@ -202,6 +236,11 @@ export interface RunEvent {
202
236
  * `id` is.
203
237
  */
204
238
  level?: string;
239
+ /**
240
+ * What the call loaded, on a `load_tools` `tool-result` and on no other event. Absent from one
241
+ * whose arguments did not parse, which loaded nothing. Optional for the reason `id` is.
242
+ */
243
+ loads?: LoadCounts;
205
244
  }
206
245
  /**
207
246
  * What `emit` is given: the run and the sequence are the bus's to assign.
@@ -334,13 +373,14 @@ export interface RunMetrics {
334
373
  /** `server-log` events at `error` or above: `critical`, `alert` and `emergency` with it. */
335
374
  serverErrors: number;
336
375
  /**
337
- * Tools `load_tools` loaded that were not loaded already. Filled by `runAgentLoop`, which sees
338
- * the resolution; the events carry only its text.
376
+ * Tools `load_tools` loaded that were not loaded already, summed from each result's `loads`.
377
+ * Absent where no result carried any; `runAgentLoop` fills all three with zero in a run on
378
+ * demand that loaded nothing.
339
379
  */
340
380
  toolsLoaded?: number;
341
- /** Tools the model asked `load_tools` for that it already had, filled the same way. */
381
+ /** Tools the model asked `load_tools` for that it already had, summed the same way. */
342
382
  redundantLoads?: number;
343
- /** Names the model asked `load_tools` for that are in no catalogue, filled the same way. */
383
+ /** Names the model asked `load_tools` for that are in no catalogue, summed the same way. */
344
384
  unknownToolNames?: number;
345
385
  promptTokens: number;
346
386
  completionTokens: number;
@@ -382,7 +422,10 @@ export interface RunMetrics {
382
422
  draftAcceptance?: number;
383
423
  /** The biggest prompt any turn reported. */
384
424
  largestPrompt?: number;
385
- /** `largestPrompt` over the window `runMetrics` was told, for how close the run came. */
425
+ /**
426
+ * The highest share of its window any turn's prompt took, for how close the run came: each
427
+ * turn over its own `contextLength`, or over the one `runMetrics` was told where it has none.
428
+ */
386
429
  largestPromptShare?: number;
387
430
  /** How it ended, read off `done` and the last turn. */
388
431
  outcome?: RunOutcome;
@@ -408,7 +451,10 @@ export declare const RunOutcome: {
408
451
  export type RunOutcome = (typeof RunOutcome)[keyof typeof RunOutcome];
409
452
  /** What `runMetrics` takes besides the events. */
410
453
  export interface RunMetricsOptions {
411
- /** The window the run was served, for `largestPromptShare`. Absent or zero leaves it out. */
454
+ /**
455
+ * The window the run was served, for `largestPromptShare` over the turns that do not carry
456
+ * their own. Absent or zero leaves those out.
457
+ */
412
458
  contextLength?: number;
413
459
  }
414
460
  /**
@@ -425,6 +471,8 @@ export interface RunMetricsOptions {
425
471
  * what it returns as blocks; a summary is another shape, and folding one in would give every
426
472
  * consumer of `fold` a block it does not know how to draw. Derived from the `usage` reports' own
427
473
  * `turn` rather than the running totals, so a run with several loops in it — a question per loop —
428
- * adds up the same as one with a single loop.
474
+ * adds up the same as one with a single loop. That holds for everything here: what `load_tools`
475
+ * resolved to rides on its results, and each turn carries the window it was served, so the events
476
+ * of every loop of a run, concatenated, are a complete account of it.
429
477
  */
430
478
  export declare function runMetrics(events: RunEvent[], { contextLength }?: RunMetricsOptions): RunMetrics;
@@ -522,7 +522,9 @@ function outcomeOf({ ok, name }, last) {
522
522
  * what it returns as blocks; a summary is another shape, and folding one in would give every
523
523
  * consumer of `fold` a block it does not know how to draw. Derived from the `usage` reports' own
524
524
  * `turn` rather than the running totals, so a run with several loops in it — a question per loop —
525
- * adds up the same as one with a single loop.
525
+ * adds up the same as one with a single loop. That holds for everything here: what `load_tools`
526
+ * resolved to rides on its results, and each turn carries the window it was served, so the events
527
+ * of every loop of a run, concatenated, are a complete account of it.
526
528
  */
527
529
  export function runMetrics(events, { contextLength } = {}) {
528
530
  const metrics = {
@@ -570,6 +572,11 @@ export function runMetrics(events, { contextLength } = {}) {
570
572
  if (event.ok === false) {
571
573
  metrics.toolErrors[event.name] = (metrics.toolErrors[event.name] ?? 0) + 1;
572
574
  }
575
+ if (event.loads) {
576
+ add('toolsLoaded', event.loads.toolsLoaded);
577
+ add('redundantLoads', event.loads.redundantLoads);
578
+ add('unknownToolNames', event.loads.unknownToolNames);
579
+ }
573
580
  const called = pending.get(event.name)?.shift();
574
581
  if (called !== undefined) {
575
582
  add('toolMs', event.at - called);
@@ -625,6 +632,10 @@ export function runMetrics(events, { contextLength } = {}) {
625
632
  }
626
633
  if (turn.prompt > 0) {
627
634
  metrics.largestPrompt = Math.max(metrics.largestPrompt ?? 0, turn.prompt);
635
+ const window = turn.contextLength ?? contextLength ?? 0;
636
+ if (window > 0) {
637
+ metrics.largestPromptShare = Math.max(metrics.largestPromptShare ?? 0, turn.prompt / window);
638
+ }
628
639
  }
629
640
  }
630
641
  else if (event.kind === RunEventKind.Done) {
@@ -643,8 +654,5 @@ export function runMetrics(events, { contextLength } = {}) {
643
654
  if (metrics.draftTotal && metrics.draftAccepted !== undefined) {
644
655
  metrics.draftAcceptance = metrics.draftAccepted / metrics.draftTotal;
645
656
  }
646
- if (metrics.largestPrompt !== undefined && contextLength && contextLength > 0) {
647
- metrics.largestPromptShare = metrics.largestPrompt / contextLength;
648
- }
649
657
  return metrics;
650
658
  }
@@ -2,7 +2,7 @@ import type OpenAI from 'openai';
2
2
  import type { CatalogServer } from '../tools/catalog.ts';
3
3
  import type { Turn } from '../wire/stream.ts';
4
4
  import { type EndedBy, type RunArtifact, type ToolAnswer, type ToolCall, type ToolCallOutcome, type ToolCallReport, type ToolCallRequest, type ToolCallResult } from '../wire/tool-calls.ts';
5
- import type { RunEventInput } from './events.ts';
5
+ import type { LoadCounts, RunEventInput } from './events.ts';
6
6
  /**
7
7
  * The tools between two turns: a step's calls read, run and written into the transcript.
8
8
  *
@@ -89,11 +89,7 @@ export interface Calling {
89
89
  /** The host's own tools by name: declared on every step, and never reached through `call_tool`. */
90
90
  hostNames: ReadonlySet<string>;
91
91
  loaded: Set<string>;
92
- loads: {
93
- toolsLoaded: number;
94
- redundantLoads: number;
95
- unknownToolNames: number;
96
- };
92
+ loads: LoadCounts;
97
93
  used: Set<string>;
98
94
  toolCalls: ToolCallOutcome[];
99
95
  /** What the run's calls linked to, by uri, in the order first seen. */
@@ -155,17 +155,28 @@ function answerOf(answer) {
155
155
  const shownAs = (run, { call, args, normal }) => run.proxies && call.function.name === CALL_TOOL
156
156
  ? shownCall(CALL_TOOL, args ? normal : call.function.arguments)
157
157
  : { name: call.function.name, input: call.function.arguments };
158
+ /**
159
+ * Adds one call's load counts to the run's.
160
+ *
161
+ * @param into - The run's counts. Written to.
162
+ * @param counts - The call's. Not written to.
163
+ */
164
+ function addLoads(into, counts) {
165
+ into.toolsLoaded += counts.toolsLoaded;
166
+ into.redundantLoads += counts.redundantLoads;
167
+ into.unknownToolNames += counts.unknownToolNames;
168
+ }
158
169
  /**
159
170
  * Answers a `load_tools`, counting what it loaded.
160
171
  *
161
172
  * @param run - The run. Written to: the names this call loads are added to `loaded`, and `loads`
162
173
  * counts the new ones, the ones already loaded and the ones not in the catalogue.
163
174
  * @param args - The call's arguments, read as `requestedNames` reads them.
164
- * @returns The text the model reads, and `ok`: false where nothing it named was loadable, true
165
- * where it named even one tool that was loaded already.
175
+ * @returns The text the model reads; `ok`, false where nothing it named was loadable and true
176
+ * where it named even one tool that was loaded already; and `counts`, this call's alone.
166
177
  */
167
178
  function answerLoad(run, args) {
168
- const { catalog, proxied, definitions, loaded, loads } = run;
179
+ const { catalog, proxied, definitions, loaded } = run;
169
180
  const resolved = expandNames(requestedNames(args), catalog);
170
181
  const content = proxied
171
182
  ? proxyLoadResult(resolved, catalog, definedAs(definitions, resolved.matched), loaded)
@@ -173,14 +184,15 @@ function answerLoad(run, args) {
173
184
  // Proxied, a load is only of what the result could define: a catalogued name the host
174
185
  // gave no definition for was not loaded, and is not counted or remembered as though it was.
175
186
  const hits = proxied ? resolved.matched.filter((hit) => definitions.has(hit)) : resolved.matched;
187
+ const counts = { toolsLoaded: 0, redundantLoads: 0, unknownToolNames: resolved.unknown.length };
176
188
  for (const hit of hits) {
177
- loads[loaded.has(hit) ? 'redundantLoads' : 'toolsLoaded']++;
189
+ counts[loaded.has(hit) ? 'redundantLoads' : 'toolsLoaded']++;
178
190
  }
179
- loads.unknownToolNames += resolved.unknown.length;
191
+ addLoads(run.loads, counts);
180
192
  for (const hit of hits) {
181
193
  loaded.add(hit);
182
194
  }
183
- return { content, ok: hits.length > 0 };
195
+ return { content, ok: hits.length > 0, counts };
184
196
  }
185
197
  /**
186
198
  * Makes a call at most once per key, sharing the in-flight promise so two identical calls in one
@@ -353,6 +365,7 @@ async function runCall(run, prepared, answered, step) {
353
365
  let links = [];
354
366
  let end = false;
355
367
  let ok = true;
368
+ let loads;
356
369
  // Answered from an earlier identical call of the step, whose links are already counted.
357
370
  let shared = false;
358
371
  try {
@@ -360,7 +373,7 @@ async function runCall(run, prepared, answered, step) {
360
373
  throw unreadable;
361
374
  }
362
375
  if (load) {
363
- ({ content, ok } = answerLoad(run, args));
376
+ ({ content, ok, counts: loads } = answerLoad(run, args));
364
377
  }
365
378
  else {
366
379
  if (refused) {
@@ -393,7 +406,14 @@ async function runCall(run, prepared, answered, step) {
393
406
  finally {
394
407
  close();
395
408
  }
396
- run.onEvent({ kind: RunEventKind.ToolResult, id: call.id, name, ok, text: preview(content) });
409
+ run.onEvent({
410
+ kind: RunEventKind.ToolResult,
411
+ id: call.id,
412
+ name,
413
+ ok,
414
+ text: preview(content),
415
+ ...(loads ? { loads } : {}),
416
+ });
397
417
  const isFirstAnswer = shared === false;
398
418
  if (isFirstAnswer) {
399
419
  collect(run, links, { callId: call.id, tool: name, step });
@@ -521,10 +541,11 @@ export async function loadShortlist(run, shortlist, messages) {
521
541
  const content = proxyLoadResult(expandNames(names, catalog, names.length), catalog, shortlist);
522
542
  run.onEvent({ kind: RunEventKind.ToolCall, id, name: LOAD_TOOLS, text: preview(args) });
523
543
  run.onToolCall?.({ id, name: LOAD_TOOLS, args: { names }, raw: args });
524
- run.onEvent({ kind: RunEventKind.ToolResult, id, name: LOAD_TOOLS, ok: true, text: preview(content) });
544
+ const loads = { toolsLoaded: names.length, redundantLoads: 0, unknownToolNames: 0 };
545
+ run.onEvent({ kind: RunEventKind.ToolResult, id, name: LOAD_TOOLS, ok: true, text: preview(content), loads });
525
546
  run.onToolResult?.({ id, name: LOAD_TOOLS, ok: true, content });
526
547
  run.toolCalls.push({ id, name: LOAD_TOOLS, ok: true });
527
- run.loads.toolsLoaded += names.length;
548
+ addLoads(run.loads, loads);
528
549
  for (const name of names) {
529
550
  run.loaded.add(name);
530
551
  }
@@ -1,6 +1,6 @@
1
1
  import type OpenAI from 'openai';
2
2
  import { ContextOverflow } from './retry.ts';
3
- import type { PartialTurn, TurnUsage } from './stream.ts';
3
+ import type { LoopUsage, PartialTurn } from './stream.ts';
4
4
  import type { RunArtifact, ToolCallOutcome } from './tool-calls.ts';
5
5
  /**
6
6
  * What went wrong, as a string.
@@ -40,8 +40,11 @@ export interface AgentLoopFailure {
40
40
  * watching had been shown, and whether it belongs in the history is the host's call.
41
41
  */
42
42
  partial?: PartialTurn;
43
- /** Summed over the turns that came back. A request that failed or was stopped adds nothing. */
44
- usage: TurnUsage;
43
+ /**
44
+ * Summed over the turns that came back, as the result's `usage` would have said it. A request
45
+ * that failed or was stopped adds nothing.
46
+ */
47
+ usage: LoopUsage;
45
48
  /** Every call that was made, in order. One the run was stopped during is `ok: false`. */
46
49
  toolCalls: ToolCallOutcome[];
47
50
  /**
@@ -70,7 +73,7 @@ export declare class AgentLoopError extends Error implements AgentLoopFailure {
70
73
  readonly name: string;
71
74
  readonly messages: OpenAI.ChatCompletionMessageParam[];
72
75
  readonly partial?: PartialTurn;
73
- readonly usage: TurnUsage;
76
+ readonly usage: LoopUsage;
74
77
  readonly toolCalls: ToolCallOutcome[];
75
78
  readonly artifacts: RunArtifact[];
76
79
  readonly loaded: string[];
@@ -105,7 +108,7 @@ export declare class ToolIterationLimit extends AgentLoopError {
105
108
  export declare class AgentLoopOverflow extends ContextOverflow implements AgentLoopFailure {
106
109
  readonly messages: OpenAI.ChatCompletionMessageParam[];
107
110
  readonly partial?: PartialTurn;
108
- readonly usage: TurnUsage;
111
+ readonly usage: LoopUsage;
109
112
  readonly toolCalls: ToolCallOutcome[];
110
113
  readonly artifacts: RunArtifact[];
111
114
  readonly loaded: string[];
@@ -145,6 +145,60 @@ export interface TurnUsage {
145
145
  * nothing reported them.
146
146
  */
147
147
  export declare const noUsage: () => TurnUsage;
148
+ /**
149
+ * A run's token totals, in the spelling every run-level usage shares.
150
+ *
151
+ * @remarks
152
+ * The spelling of `RunUsage`, which is what the `usage` events carry and what hosts persist, so a
153
+ * host reads a loop's result and its events with one mapping rather than two. A single turn's own
154
+ * report, `TurnUsage`, keeps its shorter names: it is a different thing, a turn's measurements,
155
+ * and every field on it is that turn's.
156
+ */
157
+ export interface UsageTotals {
158
+ promptTokens: number;
159
+ completionTokens: number;
160
+ totalTokens: number;
161
+ /** How much of `promptTokens` came from the endpoint's prompt cache. Zero where none said. */
162
+ cachedTokens: number;
163
+ /**
164
+ * Whether any turn of the run reported a cache count, which is what tells a `cachedTokens` of
165
+ * zero that is a miss from one the endpoint never reports. A turn reported one where its usage
166
+ * carries `uncached`.
167
+ */
168
+ cacheReported: boolean;
169
+ }
170
+ /**
171
+ * What a loop spent, as `AgentLoopResult.usage` and a failed run's `usage` carry it.
172
+ *
173
+ * @remarks
174
+ * `UsageTotals`, with the four counts also under the names this field had before it: a host that
175
+ * reads `usage.prompt` keeps compiling and reads the same number as `usage.promptTokens`.
176
+ */
177
+ export interface LoopUsage extends TurnUsage, UsageTotals {
178
+ /** @deprecated Read `promptTokens`, the same number. */
179
+ prompt: number;
180
+ /** @deprecated Read `completionTokens`, the same number. */
181
+ completion: number;
182
+ /** @deprecated Read `totalTokens`, the same number. */
183
+ total: number;
184
+ /** @deprecated Read `cachedTokens`, the same number, and `cacheReported` for whether it was reported. */
185
+ cached: number;
186
+ }
187
+ /**
188
+ * Whether a turn's usage says the endpoint reported its cache.
189
+ *
190
+ * @param usage - One turn's report.
191
+ * @returns True where it carries `uncached`, which is present only where a cache count was.
192
+ */
193
+ export declare const reportsCache: (usage: TurnUsage) => boolean;
194
+ /**
195
+ * A loop's summed counts in both spellings, for its result and its `usage` events.
196
+ *
197
+ * @param counts - The four counts summed over the loop's turns. Not written to.
198
+ * @param cacheReported - Whether any of those turns reported a cache count.
199
+ * @returns A new usage holding the four counts under both names, and the flag.
200
+ */
201
+ export declare const loopUsage: (counts: TurnUsage, cacheReported: boolean) => LoopUsage;
148
202
  /**
149
203
  * The four token counts of two usages, added.
150
204
  *
@@ -42,6 +42,31 @@ export const CacheBreakReason = {
42
42
  * nothing reported them.
43
43
  */
44
44
  export const noUsage = () => ({ prompt: 0, completion: 0, total: 0, cached: 0 });
45
+ /**
46
+ * Whether a turn's usage says the endpoint reported its cache.
47
+ *
48
+ * @param usage - One turn's report.
49
+ * @returns True where it carries `uncached`, which is present only where a cache count was.
50
+ */
51
+ export const reportsCache = (usage) => usage.uncached !== undefined;
52
+ /**
53
+ * A loop's summed counts in both spellings, for its result and its `usage` events.
54
+ *
55
+ * @param counts - The four counts summed over the loop's turns. Not written to.
56
+ * @param cacheReported - Whether any of those turns reported a cache count.
57
+ * @returns A new usage holding the four counts under both names, and the flag.
58
+ */
59
+ export const loopUsage = (counts, cacheReported) => ({
60
+ prompt: counts.prompt,
61
+ completion: counts.completion,
62
+ total: counts.total,
63
+ cached: counts.cached,
64
+ promptTokens: counts.prompt,
65
+ completionTokens: counts.completion,
66
+ totalTokens: counts.total,
67
+ cachedTokens: counts.cached,
68
+ cacheReported,
69
+ });
45
70
  /**
46
71
  * The four token counts of two usages, added.
47
72
  *
package/llms.txt CHANGED
@@ -187,6 +187,7 @@ What a run is doing, while it is doing it.
187
187
  - `endRun` — Forgets a run that will not be emitting `done` — one whose process is tearing down, or whose loop threw where it could not be caught.
188
188
  - `fold` — A run's events with each stretch of `thinking` or of `output` joined into one block, for a reader that takes a run in snapshots.
189
189
  - `history` — The backlog alone, for a caller that wants a snapshot rather than a subscription.
190
+ - `LoadCounts` (type) — What `load_tools` calls resolved to: new tools, ones already loaded, and names in no catalogue.
190
191
  - `RunEvent` (type) — One thing that happened in a run, as a watcher receives it.
191
192
  - `RunEventInput` (type) — What `emit` is given: the run and the sequence are the bus's to assign.
192
193
  - `RunEventKind` — Any one of the kinds in `RunEventKind`.
@@ -388,12 +389,14 @@ Reading one streamed turn back into a message.
388
389
 
389
390
  - `CacheBreakReason` — Any one of the reasons in `CacheBreakReason`.
390
391
  - `InFlight` (type) — Where the request in flight can be read from, after it has thrown.
392
+ - `LoopUsage` (type) — What a loop spent, as `AgentLoopResult.usage` and a failed run's `usage` carry it.
391
393
  - `PartialTurn` (type) — What a turn had said when it ended without coming back: the answer, the scratchpad and the calls, as far as each had arrived.
392
394
  - `Produced` (type) — Whether the model has said anything a second attempt would say twice.
393
395
  - `StreamTurnOptions` (type) — What `streamTurn` takes besides the request body.
394
396
  - `streamTurn` — Runs one turn as a stream, reporting tokens as they arrive and assembling them back into a message.
395
397
  - `Turn` (type) — One streamed turn, put back together into the shape a loop and a transcript work with.
396
398
  - `TurnUsage` (type) — What a turn cost, and how it went.
399
+ - `UsageTotals` (type) — A run's token totals, in the spelling every run-level usage shares.
397
400
 
398
401
  ### thinking
399
402
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cubicecho/agent-core",
3
- "version": "4.18.0",
3
+ "version": "4.19.0",
4
4
  "description": "The endpoint-agnostic half of an OpenAI-compatible agent loop: tool-schema compatibility, on-demand tool loading, one-shot side tasks, run events, and a pooled client.",
5
5
  "keywords": [
6
6
  "openai",