@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 +229 -15
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +77 -2
- package/contracts/turn.js +35 -2
- package/loop/index.d.ts +2 -1
- package/loop/index.js +1 -1
- package/loop/tool-loop.d.ts +117 -12
- package/loop/tool-loop.js +242 -67
- package/package.json +5 -1
- package/plugins/dispatch.d.ts +130 -0
- package/plugins/dispatch.js +241 -0
- package/plugins/index.d.ts +2 -0
- package/plugins/index.js +2 -0
- package/plugins/tool-message.d.ts +23 -0
- package/plugins/tool-message.js +31 -0
- package/testing/index.d.ts +153 -0
- package/testing/index.js +188 -0
- package/tools/control-chars.d.ts +23 -0
- package/tools/control-chars.js +35 -0
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
|
|
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
|
-
|
|
318
|
-
the
|
|
319
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
- **
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
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
|
package/contracts/index.d.ts
CHANGED
|
@@ -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";
|
package/contracts/index.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateRun, } from "./turn.js";
|
|
1
|
+
export { emptyRunStats, accumulateTurn, accumulateToolCall, accumulateAuxiliarySpend, accumulateRun, } from "./turn.js";
|
package/contracts/turn.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
54
|
-
|
|
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
|
|
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
|
|
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
|
|
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";
|