@juno-ai/bind 9.0.0 → 10.0.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
@@ -33,8 +33,66 @@ constraints that will fail CI if you break them.
33
33
 
34
34
  ### Unreleased
35
35
 
36
+ **Breaking**
37
+
38
+ - `runToolLoop` now returns `ToolLoopResult` (`{ stopReason, stats }`) instead
39
+ of `void`. A caller that ignores the return value is unchanged, but a
40
+ wrapper *annotated* `Promise<void>` no longer typechecks — widen it to
41
+ `Promise<ToolLoopResult>`.
42
+ - `RunStats` gained `cachedInputTokens`. `emptyRunStats()` and the folds set it;
43
+ code that hand-builds a `RunStats` literal must add the field. `ToolLoopTurn`
44
+ and `CompactionApplied` gained a matching optional `cachedInputTokens`, so
45
+ the loop can actually populate it — return it from `callModel` if your
46
+ transport reports one.
47
+ - `MissingActivationPortError` — a tool outcome that asks the loop to activate
48
+ a plugin or skill while the matching port is unwired is now reported through
49
+ `onToolCallRejected` instead of being dropped in silence. The run still
50
+ completes; the wiring bug is no longer invisible.
51
+ - `defineTool` no longer applies `normalizeArgs` inside `execute`.
52
+ Normalization is the dispatcher's step (it runs before the idempotency
53
+ hash), and applying it in both places applied it twice.
54
+ - **`StopReason` no longer has `suspended`.** It split into
55
+ `waiting_for_reply` (a tool asked a human a question; nothing happens until
56
+ someone answers) and `resuming_later` (a tool scheduled its own resume).
57
+ Removing a union member breaks any exhaustive `switch`, so map both new
58
+ values wherever you handled `suspended` — and note that the old single value
59
+ could not tell them apart at all, which is why it was split.
60
+ - **`@juno-ai/bind/plugins` now loads `zod` at runtime.** The barrel re-exports
61
+ `defineTool` / `pluginFromTools`, and `toolWireDefinition` needs
62
+ `z.toJSONSchema`. Every other module in the subpath was previously
63
+ runtime-zod-free, so a consumer importing only `createToolRegistry` while
64
+ ignoring the peer-dependency warning now fails to resolve. `zod` is a
65
+ required (non-optional) peer, so a correctly-installed consumer is
66
+ unaffected. `@juno-ai/bind/loop` is deliberately still zod-free — which is
67
+ why `toolResultMessage` lives in its own type-only module.
68
+
36
69
  **Added**
37
70
 
71
+ - `ToolLoopResult.stopReason` — `done` / `waiting_for_reply` / `resuming_later`
72
+ / `iteration_limit` / `aborted`, one per exit, replacing the two or three
73
+ loop-state flags every host was combining differently. The two pause reasons
74
+ are separate values on purpose: one needs a person to act and the other does
75
+ not, and that is the distinction a host most needs to surface. `deadline` stays in the `StopReason` union
76
+ for a host classifying a thrown `RunTimeoutError`; the loop cannot return it,
77
+ and the type's doc comment says why.
78
+ - `ToolLoopResult.stats` — a `RunStats` the loop folds itself: turns,
79
+ dispatched tool calls, tokens, cost, and the model-time/tool-time split with
80
+ a per-tool breakdown. A compaction contributes its spend but not a turn
81
+ (`accumulateAuxiliarySpend`, also new), so `stats.turns` stays comparable
82
+ with `maxIterations`. `ToolLoopParams.now` injects the clock.
83
+ - `@juno-ai/bind/testing` — the scripted-model fixtures this package's own
84
+ cross-module suites use: `loopHarness`, `scriptedModel`, `toolCallTurn`,
85
+ `finalAnswer`, `toolCall`, `freshState`, `recordingSink`, `steppingClock`.
86
+ Credential-free multi-turn, multi-tool tests without mocking a chat client.
87
+ - `defineTool` / `pluginFromTools` (`@juno-ai/bind/plugins`) — author a tool as
88
+ `{ name, description, schema, execute }` and get argument parsing, a
89
+ `validation` failure the model can act on, and typed `args` in `execute`.
90
+ Plus `toolWireDefinition` (zod → sanitized JSON Schema) and
91
+ `toolResultMessage` (the `role:"tool"` encoding the loop itself uses).
92
+ - `ToolLoopParams.activePlugins`, `activatePlugins` and `activateSkills` are
93
+ now optional. A host with a fixed tool surface had been required to supply
94
+ empty functions; `activePlugins` was never read by the loop at all.
95
+
38
96
  - `runToolCallsPooledByTool` accepts an optional `signal`, and
39
97
  `AbortedToolCallError` / `ToolBatchOptions` are exported from
40
98
  `@juno-ai/bind/run`. Additive — a caller that passes nothing is unchanged, and
@@ -297,26 +355,165 @@ const state: ToolLoopState = {
297
355
  toolCalls: 0,
298
356
  };
299
357
 
300
- await runToolLoop({
358
+ const { stopReason, stats } = await runToolLoop({
301
359
  state,
302
- activePlugins,
303
360
  maxIterations: 30,
304
361
 
305
362
  callModel: (messages, tools) => llm.complete({ messages, tools }),
306
363
  buildTools: () => registry.toolDefinitions([...activePlugins]),
307
364
  runToolCall: (call) => dispatch(call),
365
+
366
+ // Only if your tools can change the tool surface mid-run:
308
367
  activatePlugins: (names) => names.forEach((n) => activePlugins.add(n)),
309
368
  activateSkills: (refs) => loadInstructions(refs),
310
369
  });
311
370
 
312
- // `state` is mutated in place — read totals off it after, or mid-run from a
313
- // heartbeat.
371
+ // `state` is mutated in place — read totals off it mid-run from a heartbeat.
314
372
  console.log(state.inputTokens, state.outputTokens, state.toolCalls);
373
+
374
+ // The result is the run's conclusion, which only exists once it is over.
375
+ await persistRun({ stopReason, ...stats });
376
+ ```
377
+
378
+ The two halves are deliberate. `state` is mutated rather than returned so a
379
+ heartbeat can read live totals while the run is still going; `ToolLoopResult`
380
+ is what could not exist until the run ended.
381
+
382
+ `stopReason` is the whole outcome, in one word:
383
+
384
+ | Reason | The model | What a host should say |
385
+ |---|---|---|
386
+ | `done` | Stopped calling tools | It answered |
387
+ | `waiting_for_reply` | A tool asked a human a question | It needs you to answer — nothing happens until you do |
388
+ | `resuming_later` | A tool scheduled its own resume | It paused on purpose and will come back |
389
+ | `iteration_limit` | Still calling tools at the ceiling | It ran out of room, mid-task |
390
+ | `aborted` | Cut off by `shouldStop` or the batch `signal` | It was stopped |
391
+
392
+ The two pause reasons are separate values because their consequences differ
393
+ more than any other pair: one needs a person to act, the other needs nobody to.
394
+ Collapsed into one `suspended`, a host that wanted to say which had to go back
395
+ and read `state.suspended` — the loop-state-flag reconstruction this return
396
+ value exists to replace.
397
+
398
+ `deadline` is in the `StopReason` union but never returned: the wall-clock port
399
+ (`throwIfTimedOut`) is throw-based, so an expired budget leaves the loop as a
400
+ `RunTimeoutError` your catch block maps — `classifyRunFailure` recognises the
401
+ same condition. The loop returns `aborted` for a fired signal because once a
402
+ deadline and a cancellation are combined into one `AbortSignal` it genuinely
403
+ cannot tell which one fired.
404
+
405
+ `stats` is a `RunStats`: turns, dispatched tool calls, tokens (including
406
+ provider-reported cached input, when your `callModel` returns a
407
+ `cachedInputTokens`), cost, and model time vs tool time with a per-tool
408
+ breakdown. Three things are worth reading carefully:
409
+
410
+ - **`stats` is this invocation's contribution; `state` is whatever you seeded
411
+ plus that.** They match only if you seeded zeros. A host resuming a run seeds
412
+ `state` from the stored totals, and then `state.costCents` is the run's
413
+ lifetime cost while `stats.costCents` is this leg's — bill from whichever you
414
+ mean, and don't substitute one for the other.
415
+
416
+ - **`stats.toolCalls` counts calls that *ran*; `state.toolCalls` counts calls
417
+ the model *requested*.** They differ by exactly the work an aborted batch
418
+ prevented, which is why they are two numbers.
419
+ - **A compaction contributes tokens, cost and model time but not a turn**, so
420
+ `stats.turns` stays comparable with `maxIterations`.
421
+
422
+ Both are lost if the loop throws: a deadline, a cancellation, or a fatal tool
423
+ error leaves no return value, so `state` — mutated in place — is the only
424
+ accounting that survives those exits.
425
+
426
+ Pass `now` to make either measurement deterministic in a test; it defaults to
427
+ `Date.now`.
428
+
429
+ ### How to test an agent without a provider
430
+
431
+ `@juno-ai/bind/testing` ships the fixtures this package's own cross-module
432
+ suites use. A scripted model is a queue of prepared turns, so a multi-turn,
433
+ multi-tool test needs no credential, no network, and no mocked chat client.
434
+
435
+ ```ts
436
+ import { runToolLoop } from "@juno-ai/bind/loop";
437
+ import {
438
+ loopHarness, toolCall, toolCallTurn, finalAnswer,
439
+ } from "@juno-ai/bind/testing";
440
+
441
+ const h = loopHarness([
442
+ toolCallTurn([toolCall("search", { q: "bind" }), toolCall("read_file")]),
443
+ toolCallTurn([toolCall("write_file", { path: "out.md" })]),
444
+ finalAnswer("Done."),
445
+ ]);
446
+
447
+ const { stopReason, stats } = await runToolLoop(h.params);
448
+
449
+ expect(stopReason).toBe("done");
450
+ expect(stats.turns).toBe(3);
451
+ expect(h.ran).toEqual(["search", "read_file", "write_file"]);
452
+ ```
453
+
454
+ Every field of `h.params` is overridable, which is how you reach the
455
+ interesting states — `{ runToolCall }` to make one tool fail, `{ signal }` to
456
+ abort mid-batch, `{ now }` to make the timing figures deterministic
457
+ (`steppingClock()` for a fixed tick, or your own closure advanced inside
458
+ `runToolCall` when you want to *choose* each interval).
459
+ `h.ran` records what the loop *dispatched* and `h.sideEffects` what actually
460
+ *completed*; the gap between them is the answer to every cancellation question.
461
+
462
+ Two failure modes are deliberate. A script that runs out throws rather than
463
+ returning an empty turn — otherwise `maxIterations` absorbs the mistake and the
464
+ test passes while describing a run that never happened. And a duplicate
465
+ tool-call id throws at construction, rather than producing a transcript the
466
+ provider rejects ten frames deep in the loop.
467
+
468
+ ### How to author a tool without writing dispatch by hand
469
+
470
+ A `ToolPlugin` dispatches by tool name, which is right for a bundle with shared
471
+ setup and pure ceremony for a flat list of independent tools. `defineTool` does
472
+ the mechanical half — parse the arguments, turn a parse failure into something
473
+ the model can act on — and `pluginFromTools` bundles the result.
474
+
475
+ ```ts
476
+ import { defineTool, pluginFromTools } from "@juno-ai/bind/plugins";
477
+
478
+ const search = defineTool({
479
+ name: "search",
480
+ description: "Search the corpus.",
481
+ schema: z.object({ query: z.string().min(1), limit: z.number().default(10) }),
482
+ // `args` is typed from the schema; `limit` has already defaulted.
483
+ execute: async (args, ctx: Ctx) => ({
484
+ success: true,
485
+ data: await corpus.search(args.query, args.limit, ctx.tenantId),
486
+ }),
487
+ });
488
+
489
+ const plugin = pluginFromTools<Ctx>({
490
+ name: "corpus",
491
+ description: "Corpus tools.",
492
+ tools: [search],
493
+ });
315
494
  ```
316
495
 
317
- `state` is mutated rather than returned so a heartbeat can read live totals while
318
- the run is still going; a returned result could not report anything until the
319
- run ended.
496
+ The schema is the single source of truth: it is converted to JSON Schema for
497
+ the model *and* used to validate what comes back, so the two cannot drift.
498
+
499
+ Bad arguments come back as `{ success: false, kind: "validation", error }`
500
+ naming the offending path — **returned, not thrown**. That distinction is the
501
+ bug this replaces: a thrown parse error turns a recoverable "you passed the
502
+ wrong field" into a dead run. An unknown tool name is likewise a returned
503
+ `not_found`, because a resumed session's history can reference a tool you have
504
+ since retired.
505
+
506
+ The validation lives on the tool, not on the bundle, so a host with its own
507
+ dispatcher can call `search.execute(rawArgs, ctx)` directly and get the same
508
+ guarantee — `pluginFromTools` only resolves names.
509
+
510
+ Two encoders come with it. `toolWireDefinition(tool, wireName?)` converts a
511
+ tool to what the provider is shown, through `sanitizeToolSchema` — `wireName`
512
+ because tool naming is host policy (Monad encodes `plugin__tool` to route a
513
+ call back to its plugin). `toolResultMessage(callId, result)` produces the
514
+ `role:"tool"` message, using the same encoding the loop synthesizes for a
515
+ failed or refused call — so a model never has to learn two error formats in one
516
+ transcript.
320
517
 
321
518
  ### How to make a tool take effect mid-batch
322
519
 
@@ -923,7 +1120,9 @@ client error), call `releaseProbe` so the slot cannot stick.
923
1120
  your editor. This section covers what the types cannot say: which entry point
924
1121
  to reach for, and the contracts that hold between calls.*
925
1122
 
926
- Every export is re-exported from the package root, but prefer the subpath — it
1123
+ Every export is re-exported from the package root **except `@juno-ai/bind/testing`,
1124
+ which is subpath-only so fixtures never reach a production bundle** — but prefer
1125
+ the subpath either way, since it
927
1126
  keeps a consumer who only wants routing from pulling in the rest.
928
1127
 
929
1128
  | Import | Owns | Reach for it when |
@@ -935,7 +1134,8 @@ keeps a consumer who only wants routing from pulling in the rest.
935
1134
  | `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff, tool-call receipts for retry-safe side effects | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
936
1135
  | `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
937
1136
  | `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
938
- | `@juno-ai/bind/plugins` | Tool/plugin vocabulary, the registry factory, progressive-disclosure activation | You have more tools than fit comfortably in one prompt |
1137
+ | `@juno-ai/bind/plugins` | Tool/plugin vocabulary, `defineTool` / `pluginFromTools`, the wire-definition and tool-result encoders, the registry factory, progressive-disclosure activation | You are authoring tools, or you have more of them than fit comfortably in one prompt |
1138
+ | `@juno-ai/bind/testing` | Scripted-model fixtures — `loopHarness`, `scriptedModel`, `toolCallTurn`, `finalAnswer`, `freshState`, `recordingSink`, `steppingClock` | You want multi-turn, multi-tool tests without a credential or a mocked chat client |
939
1139
 
940
1140
  ### Contracts the types do not carry
941
1141
 
@@ -1288,8 +1488,14 @@ const turn: TurnFn = async (messages, tools, signal) => {
1288
1488
 
1289
1489
  ### Accumulating run statistics
1290
1490
 
1491
+ **If you use `runToolLoop`, you do not need this** — it folds `RunStats` itself
1492
+ and returns it. This is the shape for a host driving turns by hand.
1493
+
1291
1494
  Fold each turn and each tool call as they complete; `RunStats` keeps model time
1292
- and tool time separate so a slow tool never looks like a slow model.
1495
+ and tool time separate so a slow tool never looks like a slow model. Model
1496
+ calls that are not agent turns — a compaction pass — go through
1497
+ `accumulateAuxiliarySpend` instead, which charges the tokens and the time
1498
+ without counting a turn.
1293
1499
 
1294
1500
  ```ts
1295
1501
  let stats = emptyRunStats();
@@ -1401,7 +1607,13 @@ try {
1401
1607
  stats = accumulateTurn(stats, result);
1402
1608
  await heartbeat.beat();
1403
1609
  if (!result.message.tool_calls?.length) break;
1404
- if (await runToolBatch(result.message.tool_calls)) { stopReason = "suspended"; break; }
1610
+ const suspend = await runToolBatch(result.message.tool_calls);
1611
+ if (suspend) {
1612
+ // The two pause reasons are separate values — see the stop-reason table.
1613
+ stopReason =
1614
+ suspend.resumeKind === "answer" ? "waiting_for_reply" : "resuming_later";
1615
+ break;
1616
+ }
1405
1617
  }
1406
1618
  } catch (error) {
1407
1619
  stopReason = classifyRunFailure(deadline, error) === "timed_out" ? "deadline" : "aborted";
@@ -1579,10 +1791,12 @@ two rates weights a 10-token run like a 10,000-token one.
1579
1791
  Named, not scheduled. Listed so a consumer can tell a deliberate omission from
1580
1792
  an oversight.
1581
1793
 
1582
- - **One `RunStats` per turn.** The loop accounts usage with `ToolLoopTurn`
1583
- (tokens and cost) while `ModelTurnResult` carries timings too. They converge
1584
- when the loop folds `RunStats` directly; today a host that wants throughput
1585
- metrics accumulates them alongside.
1794
+ - **Time-to-first-token in `RunStats`.** The loop now folds `RunStats` itself,
1795
+ so `modelTimeMs`, `toolTimeMs` and the per-tool breakdown come for free but
1796
+ `ttftMs` lives on `TurnTimings` and only the transport can see the first byte.
1797
+ A `callModel` that reported its own timings back would close the gap;
1798
+ `ToolLoopTurn` would have to grow, which is a change to the shape every host
1799
+ already implements.
1586
1800
  - **A streaming turn contract.** Half of this landed:
1587
1801
  `createTurnTextStream` owns the emit/retry interaction for assistant *text*,
1588
1802
  arms `producedOutput` itself, and repairs a retractable surface between
@@ -1 +1 @@
1
- export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateRun, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn.js";
1
+ export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateAuxiliarySpend, accumulateRun, type AuxiliarySpend, type TranscriptMessage, type AssistantTurnMessage, type WireToolDefinition, type WireToolCall, type TurnTimings, type TurnUsage, type ModelTurnResult, type TurnFn, type StopReason, type RunStats, } from "./turn.js";
@@ -1 +1 @@
1
- export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateRun, } from "./turn.js";
1
+ export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateAuxiliarySpend, accumulateRun, } from "./turn.js";
@@ -50,18 +50,67 @@ export interface ModelTurnResult {
50
50
  * completed assistant turn.
51
51
  */
52
52
  export type TurnFn = (messages: readonly TranscriptMessage[], tools: readonly WireToolDefinition[] | undefined, signal: AbortSignal | undefined) => Promise<ModelTurnResult>;
53
- /** Why a run stopped. `suspended` = a tool intentionally paused the run. */
54
- export type StopReason = "done" | "suspended" | "iteration_limit" | "deadline" | "aborted";
53
+ /**
54
+ * Why a run stopped the single vocabulary a host reports an outcome in,
55
+ * instead of re-deriving it from a handful of loop-state flags.
56
+ *
57
+ * `runToolLoop` returns five of the six directly:
58
+ *
59
+ * - `done` — the model produced a turn with no tool calls (and no
60
+ * `onTurnWouldEnd` nudge pushed it forward).
61
+ * - `waiting_for_reply` — a tool asked a human a question and the run is
62
+ * blocked until someone answers. **Nothing happens until they do.**
63
+ * - `resuming_later` — a tool scheduled its own resume (a sleep, a timer).
64
+ * The run paused on purpose and will come back by itself.
65
+ * - `iteration_limit` — `maxIterations` was exhausted with the model still
66
+ * calling tools. The run did not finish; it was cut off.
67
+ * - `aborted` — the batch `signal` fired, or `shouldStop` asked to stop.
68
+ *
69
+ * The two pause reasons are split rather than one `suspended` because they are
70
+ * the outcomes whose consequences differ most: one needs a person to act, the
71
+ * other needs no one to do anything. Collapsed into a single value, a host
72
+ * that wanted to tell them apart had to go back and read `state.suspended` —
73
+ * which is the loop-state-flag reconstruction this type exists to replace.
74
+ *
75
+ * `deadline` is the one the loop does not return, and deliberately so: the
76
+ * wall-clock budget is a **throw-based** port (`throwIfTimedOut`), so an
77
+ * expired budget leaves the loop as a `RunTimeoutError` rather than a value.
78
+ * A host maps its catch block onto this vocabulary — `classifyRunFailure`
79
+ * from `@juno-ai/bind/run` recognises the same condition as `"timed_out"`.
80
+ * Returning `aborted` for an expired deadline would be worse than not
81
+ * returning it at all: the loop genuinely cannot distinguish a deadline signal
82
+ * from a cancellation signal once both are combined into one `AbortSignal`.
83
+ */
84
+ export type StopReason = "done" | "waiting_for_reply" | "resuming_later" | "iteration_limit" | "deadline" | "aborted";
55
85
  /**
56
86
  * Cumulative run accounting: model-time and tool-time reported separately —
57
87
  * task wall-clock conflates provider inference speed with tool execution,
58
88
  * and consumers comparing models need the model's contribution isolated.
59
89
  */
60
90
  export interface RunStats {
91
+ /**
92
+ * Agent turns — one per model completion the loop iterated on. Auxiliary
93
+ * model calls (a compaction pass) contribute their tokens, cost and model
94
+ * time but NOT a turn, so this stays comparable with the loop's iteration
95
+ * budget. See {@link accumulateAuxiliarySpend}.
96
+ */
61
97
  readonly turns: number;
98
+ /**
99
+ * Tool calls that were actually **dispatched**. A call the batch refused at
100
+ * claim time (an aborted `signal`) never ran, so it is not counted here even
101
+ * though the model requested it — the gap between this and the requested
102
+ * count is exactly the work an abort prevented.
103
+ */
62
104
  readonly toolCalls: number;
63
105
  readonly inputTokens: number;
64
106
  readonly outputTokens: number;
107
+ /**
108
+ * Provider-reported cached input tokens, summed across turns. Zero is
109
+ * indistinguishable from "the transport could not report it" — a turn whose
110
+ * `cachedInputTokens` is `null` contributes nothing rather than poisoning the
111
+ * total, so read this as a floor.
112
+ */
113
+ readonly cachedInputTokens: number;
65
114
  readonly costCents: number;
66
115
  /** Sum of model `generationMs` across turns. */
67
116
  readonly modelTimeMs: number;
@@ -75,6 +124,32 @@ export interface RunStats {
75
124
  export declare function emptyRunStats(): RunStats;
76
125
  /** Fold one completed model turn into cumulative run stats. */
77
126
  export declare function accumulateTurn(stats: RunStats, turn: ModelTurnResult): RunStats;
127
+ /**
128
+ * Model spend that is not an agent turn — today, a compaction pass.
129
+ *
130
+ * Split out rather than folded through {@link accumulateTurn} because the two
131
+ * numbers answer different questions. A compaction is a real model call that
132
+ * costs real money and real latency, so its tokens, cost and time belong in the
133
+ * run's totals; it is *not* an iteration the agent spent making progress, so
134
+ * counting it in `turns` would make `stats.turns` incomparable with the loop's
135
+ * `maxIterations` and quietly overstate how much thinking the agent did.
136
+ */
137
+ export interface AuxiliarySpend {
138
+ readonly inputTokens: number;
139
+ readonly outputTokens: number;
140
+ /**
141
+ * Required, unlike the two below, because a caller that cannot price a call
142
+ * still knows it cost *something* and should pass `0` deliberately rather
143
+ * than omit it. Time and cache figures are genuinely unknowable to some
144
+ * callers, so they are optional and contribute nothing when absent.
145
+ */
146
+ readonly costCents: number;
147
+ /** Wall time of the auxiliary model call, if measured. */
148
+ readonly modelTimeMs?: number;
149
+ readonly cachedInputTokens?: number | null;
150
+ }
151
+ /** Fold auxiliary model spend into a run's totals without counting a turn. */
152
+ export declare function accumulateAuxiliarySpend(stats: RunStats, spend: AuxiliarySpend): RunStats;
78
153
  /** Fold one dispatched tool call's duration into cumulative run stats. */
79
154
  export declare function accumulateToolCall(stats: RunStats, toolName: string, durationMs: number): RunStats;
80
155
  /**
package/contracts/turn.js CHANGED
@@ -4,6 +4,7 @@ export function emptyRunStats() {
4
4
  toolCalls: 0,
5
5
  inputTokens: 0,
6
6
  outputTokens: 0,
7
+ cachedInputTokens: 0,
7
8
  costCents: 0,
8
9
  modelTimeMs: 0,
9
10
  toolTimeMs: 0,
@@ -20,11 +21,41 @@ export function accumulateTurn(stats, turn) {
20
21
  turns: stats.turns + 1,
21
22
  inputTokens: stats.inputTokens + turn.usage.inputTokens,
22
23
  outputTokens,
24
+ cachedInputTokens: stats.cachedInputTokens + (turn.usage.cachedInputTokens ?? 0),
23
25
  costCents: stats.costCents + (turn.usage.costCents ?? 0),
24
26
  modelTimeMs,
25
27
  outputTokensPerSecond: modelTimeMs > 0 ? (outputTokens / modelTimeMs) * 1000 : null,
26
28
  };
27
29
  }
30
+ /** Fold auxiliary model spend into a run's totals without counting a turn. */
31
+ export function accumulateAuxiliarySpend(stats, spend) {
32
+ const outputTokens = stats.outputTokens + spend.outputTokens;
33
+ const modelTimeMs = stats.modelTimeMs + (spend.modelTimeMs ?? 0);
34
+ return {
35
+ ...stats,
36
+ inputTokens: stats.inputTokens + spend.inputTokens,
37
+ outputTokens,
38
+ cachedInputTokens: stats.cachedInputTokens + (spend.cachedInputTokens ?? 0),
39
+ costCents: stats.costCents + spend.costCents,
40
+ modelTimeMs,
41
+ // Recomputed, not carried: the added output tokens and model time both move
42
+ // the rate, and leaving the old value would report a throughput that
43
+ // matches neither the turns nor the totals now stored beside it.
44
+ outputTokensPerSecond: modelTimeMs > 0 ? (outputTokens / modelTimeMs) * 1000 : null,
45
+ };
46
+ }
47
+ /**
48
+ * Read a tool's accumulated time without going through `Object.prototype`.
49
+ *
50
+ * A bare `breakdown[name] ?? 0` reads inherited properties, so a tool legally
51
+ * named `constructor` or `toString` returns a *function*, and `fn + duration`
52
+ * silently produces a string — a corrupted `toolTimeBreakdownMs` entry that
53
+ * typechecks as `number`. Tool names come from the model, so this is reachable
54
+ * on any run. Same guard the plugin registry already applies to alias lookups.
55
+ */
56
+ function ownDuration(breakdown, toolName) {
57
+ return Object.hasOwn(breakdown, toolName) ? breakdown[toolName] : 0;
58
+ }
28
59
  /** Fold one dispatched tool call's duration into cumulative run stats. */
29
60
  export function accumulateToolCall(stats, toolName, durationMs) {
30
61
  return {
@@ -33,7 +64,7 @@ export function accumulateToolCall(stats, toolName, durationMs) {
33
64
  toolTimeMs: stats.toolTimeMs + durationMs,
34
65
  toolTimeBreakdownMs: {
35
66
  ...stats.toolTimeBreakdownMs,
36
- [toolName]: (stats.toolTimeBreakdownMs[toolName] ?? 0) + durationMs,
67
+ [toolName]: ownDuration(stats.toolTimeBreakdownMs, toolName) + durationMs,
37
68
  },
38
69
  };
39
70
  }
@@ -67,14 +98,16 @@ export function accumulateRun(stats, run) {
67
98
  ...stats.toolTimeBreakdownMs,
68
99
  };
69
100
  for (const [toolName, durationMs] of Object.entries(run.toolTimeBreakdownMs)) {
101
+ // Own-property read, for the same reason as `accumulateToolCall`.
70
102
  toolTimeBreakdownMs[toolName] =
71
- (toolTimeBreakdownMs[toolName] ?? 0) + durationMs;
103
+ ownDuration(toolTimeBreakdownMs, toolName) + durationMs;
72
104
  }
73
105
  return {
74
106
  turns: stats.turns + run.turns,
75
107
  toolCalls: stats.toolCalls + run.toolCalls,
76
108
  inputTokens: stats.inputTokens + run.inputTokens,
77
109
  outputTokens,
110
+ cachedInputTokens: stats.cachedInputTokens + run.cachedInputTokens,
78
111
  costCents: stats.costCents + run.costCents,
79
112
  modelTimeMs,
80
113
  toolTimeMs: stats.toolTimeMs + run.toolTimeMs,
package/loop/index.d.ts CHANGED
@@ -1 +1,2 @@
1
- export { runToolLoop, type ToolLoopParams, type ToolLoopState, type ToolLoopTurn, type ToolCallOutcome, type CompactionApplied, type RunStatus, } from "./tool-loop.js";
1
+ export type { StopReason, RunStats } from "../contracts/turn.js";
2
+ export { runToolLoop, MissingActivationPortError, type ToolLoopParams, type ToolLoopResult, type ToolLoopState, type ToolLoopTurn, type ToolCallOutcome, type CompactionApplied, type RunStatus, } from "./tool-loop.js";
package/loop/index.js CHANGED
@@ -1 +1 @@
1
- export { runToolLoop, } from "./tool-loop.js";
1
+ export { runToolLoop, MissingActivationPortError, } from "./tool-loop.js";