@juno-ai/bind 9.0.0 → 11.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.
Files changed (46) hide show
  1. package/README.md +375 -15
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +77 -2
  5. package/contracts/turn.js +35 -2
  6. package/index.d.ts +6 -2
  7. package/index.js +6 -2
  8. package/loop/index.d.ts +2 -1
  9. package/loop/index.js +1 -1
  10. package/loop/tool-loop.d.ts +117 -12
  11. package/loop/tool-loop.js +242 -67
  12. package/package.json +10 -2
  13. package/plugins/dispatch.d.ts +130 -0
  14. package/plugins/dispatch.js +241 -0
  15. package/plugins/index.d.ts +2 -0
  16. package/plugins/index.js +2 -0
  17. package/plugins/tool-message.d.ts +23 -0
  18. package/plugins/tool-message.js +31 -0
  19. package/skills/activation.d.ts +64 -0
  20. package/skills/activation.js +39 -0
  21. package/skills/admission.d.ts +61 -0
  22. package/skills/admission.js +41 -0
  23. package/skills/catalog.d.ts +54 -0
  24. package/skills/catalog.js +77 -0
  25. package/skills/discovery.d.ts +82 -0
  26. package/skills/discovery.js +91 -0
  27. package/skills/index.d.ts +19 -0
  28. package/skills/index.js +19 -0
  29. package/skills/refs.d.ts +21 -0
  30. package/skills/refs.js +27 -0
  31. package/skills/registry.d.ts +57 -0
  32. package/skills/registry.js +94 -0
  33. package/skills/resolve.d.ts +89 -0
  34. package/skills/resolve.js +124 -0
  35. package/skills/sha.d.ts +53 -0
  36. package/skills/sha.js +60 -0
  37. package/skills/sha256.d.ts +38 -0
  38. package/skills/sha256.js +122 -0
  39. package/skills/skill-md.d.ts +73 -0
  40. package/skills/skill-md.js +149 -0
  41. package/skills/types.d.ts +174 -0
  42. package/skills/types.js +55 -0
  43. package/testing/index.d.ts +153 -0
  44. package/testing/index.js +188 -0
  45. package/tools/control-chars.d.ts +23 -0
  46. package/tools/control-chars.js +35 -0
package/README.md CHANGED
@@ -35,6 +35,85 @@ constraints that will fail CI if you break them.
35
35
 
36
36
  **Added**
37
37
 
38
+ - **`@juno-ai/bind/skills`** — progressive disclosure for *instructions*, the
39
+ mirror of `@juno-ai/bind/plugins`. `createSkillRegistry` for what your source
40
+ ships, `partitionSkillCatalog` for the Tier-1 catalog and its token budget,
41
+ `resolveActiveSkillInstructions` for the bodies (live-head or pinned to a
42
+ completed run's hashes, over a batched `ExternalSkillSource` for skills your
43
+ users author), `createSkillActivation` to drive the loop's `activateSkills`
44
+ port, `admitSkillLoad` for the active-set bounds, `parseSkillMarkdown` /
45
+ `serializeSkillMarkdown` for the `SKILL.md` interchange format over your own
46
+ YAML, and `buildAgentSkillsDiscoveryIndex` for the Agent Skills Discovery RFC
47
+ v0.2.0 document. See "How to give an agent loadable skills".
48
+
49
+ Two notes for a host that already has something like this. The catalog
50
+ returns **data, not prose** — the wording is yours, for the same prompt-cache
51
+ reason `partitionPluginCatalog` gives. And the content digest is **the
52
+ package's**, not a port like the one `toolCallArgsHash` takes: a skill's hash
53
+ is computed at registration, which is synchronous, and it identifies build
54
+ content rather than being persisted across versions. `Sha256Hex` is still a
55
+ parameter if you would rather inject a native one.
56
+
57
+ **Breaking**
58
+
59
+ - `runToolLoop` now returns `ToolLoopResult` (`{ stopReason, stats }`) instead
60
+ of `void`. A caller that ignores the return value is unchanged, but a
61
+ wrapper *annotated* `Promise<void>` no longer typechecks — widen it to
62
+ `Promise<ToolLoopResult>`.
63
+ - `RunStats` gained `cachedInputTokens`. `emptyRunStats()` and the folds set it;
64
+ code that hand-builds a `RunStats` literal must add the field. `ToolLoopTurn`
65
+ and `CompactionApplied` gained a matching optional `cachedInputTokens`, so
66
+ the loop can actually populate it — return it from `callModel` if your
67
+ transport reports one.
68
+ - `MissingActivationPortError` — a tool outcome that asks the loop to activate
69
+ a plugin or skill while the matching port is unwired is now reported through
70
+ `onToolCallRejected` instead of being dropped in silence. The run still
71
+ completes; the wiring bug is no longer invisible.
72
+ - `defineTool` no longer applies `normalizeArgs` inside `execute`.
73
+ Normalization is the dispatcher's step (it runs before the idempotency
74
+ hash), and applying it in both places applied it twice.
75
+ - **`StopReason` no longer has `suspended`.** It split into
76
+ `waiting_for_reply` (a tool asked a human a question; nothing happens until
77
+ someone answers) and `resuming_later` (a tool scheduled its own resume).
78
+ Removing a union member breaks any exhaustive `switch`, so map both new
79
+ values wherever you handled `suspended` — and note that the old single value
80
+ could not tell them apart at all, which is why it was split.
81
+ - **`@juno-ai/bind/plugins` now loads `zod` at runtime.** The barrel re-exports
82
+ `defineTool` / `pluginFromTools`, and `toolWireDefinition` needs
83
+ `z.toJSONSchema`. Every other module in the subpath was previously
84
+ runtime-zod-free, so a consumer importing only `createToolRegistry` while
85
+ ignoring the peer-dependency warning now fails to resolve. `zod` is a
86
+ required (non-optional) peer, so a correctly-installed consumer is
87
+ unaffected. `@juno-ai/bind/loop` is deliberately still zod-free — which is
88
+ why `toolResultMessage` lives in its own type-only module.
89
+
90
+ **Added**
91
+
92
+ - `ToolLoopResult.stopReason` — `done` / `waiting_for_reply` / `resuming_later`
93
+ / `iteration_limit` / `aborted`, one per exit, replacing the two or three
94
+ loop-state flags every host was combining differently. The two pause reasons
95
+ are separate values on purpose: one needs a person to act and the other does
96
+ not, and that is the distinction a host most needs to surface. `deadline` stays in the `StopReason` union
97
+ for a host classifying a thrown `RunTimeoutError`; the loop cannot return it,
98
+ and the type's doc comment says why.
99
+ - `ToolLoopResult.stats` — a `RunStats` the loop folds itself: turns,
100
+ dispatched tool calls, tokens, cost, and the model-time/tool-time split with
101
+ a per-tool breakdown. A compaction contributes its spend but not a turn
102
+ (`accumulateAuxiliarySpend`, also new), so `stats.turns` stays comparable
103
+ with `maxIterations`. `ToolLoopParams.now` injects the clock.
104
+ - `@juno-ai/bind/testing` — the scripted-model fixtures this package's own
105
+ cross-module suites use: `loopHarness`, `scriptedModel`, `toolCallTurn`,
106
+ `finalAnswer`, `toolCall`, `freshState`, `recordingSink`, `steppingClock`.
107
+ Credential-free multi-turn, multi-tool tests without mocking a chat client.
108
+ - `defineTool` / `pluginFromTools` (`@juno-ai/bind/plugins`) — author a tool as
109
+ `{ name, description, schema, execute }` and get argument parsing, a
110
+ `validation` failure the model can act on, and typed `args` in `execute`.
111
+ Plus `toolWireDefinition` (zod → sanitized JSON Schema) and
112
+ `toolResultMessage` (the `role:"tool"` encoding the loop itself uses).
113
+ - `ToolLoopParams.activePlugins`, `activatePlugins` and `activateSkills` are
114
+ now optional. A host with a fixed tool surface had been required to supply
115
+ empty functions; `activePlugins` was never read by the loop at all.
116
+
38
117
  - `runToolCallsPooledByTool` accepts an optional `signal`, and
39
118
  `AbortedToolCallError` / `ToolBatchOptions` are exported from
40
119
  `@juno-ai/bind/run`. Additive — a caller that passes nothing is unchanged, and
@@ -297,26 +376,165 @@ const state: ToolLoopState = {
297
376
  toolCalls: 0,
298
377
  };
299
378
 
300
- await runToolLoop({
379
+ const { stopReason, stats } = await runToolLoop({
301
380
  state,
302
- activePlugins,
303
381
  maxIterations: 30,
304
382
 
305
383
  callModel: (messages, tools) => llm.complete({ messages, tools }),
306
384
  buildTools: () => registry.toolDefinitions([...activePlugins]),
307
385
  runToolCall: (call) => dispatch(call),
386
+
387
+ // Only if your tools can change the tool surface mid-run:
308
388
  activatePlugins: (names) => names.forEach((n) => activePlugins.add(n)),
309
389
  activateSkills: (refs) => loadInstructions(refs),
310
390
  });
311
391
 
312
- // `state` is mutated in place — read totals off it after, or mid-run from a
313
- // heartbeat.
392
+ // `state` is mutated in place — read totals off it mid-run from a heartbeat.
314
393
  console.log(state.inputTokens, state.outputTokens, state.toolCalls);
394
+
395
+ // The result is the run's conclusion, which only exists once it is over.
396
+ await persistRun({ stopReason, ...stats });
397
+ ```
398
+
399
+ The two halves are deliberate. `state` is mutated rather than returned so a
400
+ heartbeat can read live totals while the run is still going; `ToolLoopResult`
401
+ is what could not exist until the run ended.
402
+
403
+ `stopReason` is the whole outcome, in one word:
404
+
405
+ | Reason | The model | What a host should say |
406
+ |---|---|---|
407
+ | `done` | Stopped calling tools | It answered |
408
+ | `waiting_for_reply` | A tool asked a human a question | It needs you to answer — nothing happens until you do |
409
+ | `resuming_later` | A tool scheduled its own resume | It paused on purpose and will come back |
410
+ | `iteration_limit` | Still calling tools at the ceiling | It ran out of room, mid-task |
411
+ | `aborted` | Cut off by `shouldStop` or the batch `signal` | It was stopped |
412
+
413
+ The two pause reasons are separate values because their consequences differ
414
+ more than any other pair: one needs a person to act, the other needs nobody to.
415
+ Collapsed into one `suspended`, a host that wanted to say which had to go back
416
+ and read `state.suspended` — the loop-state-flag reconstruction this return
417
+ value exists to replace.
418
+
419
+ `deadline` is in the `StopReason` union but never returned: the wall-clock port
420
+ (`throwIfTimedOut`) is throw-based, so an expired budget leaves the loop as a
421
+ `RunTimeoutError` your catch block maps — `classifyRunFailure` recognises the
422
+ same condition. The loop returns `aborted` for a fired signal because once a
423
+ deadline and a cancellation are combined into one `AbortSignal` it genuinely
424
+ cannot tell which one fired.
425
+
426
+ `stats` is a `RunStats`: turns, dispatched tool calls, tokens (including
427
+ provider-reported cached input, when your `callModel` returns a
428
+ `cachedInputTokens`), cost, and model time vs tool time with a per-tool
429
+ breakdown. Three things are worth reading carefully:
430
+
431
+ - **`stats` is this invocation's contribution; `state` is whatever you seeded
432
+ plus that.** They match only if you seeded zeros. A host resuming a run seeds
433
+ `state` from the stored totals, and then `state.costCents` is the run's
434
+ lifetime cost while `stats.costCents` is this leg's — bill from whichever you
435
+ mean, and don't substitute one for the other.
436
+
437
+ - **`stats.toolCalls` counts calls that *ran*; `state.toolCalls` counts calls
438
+ the model *requested*.** They differ by exactly the work an aborted batch
439
+ prevented, which is why they are two numbers.
440
+ - **A compaction contributes tokens, cost and model time but not a turn**, so
441
+ `stats.turns` stays comparable with `maxIterations`.
442
+
443
+ Both are lost if the loop throws: a deadline, a cancellation, or a fatal tool
444
+ error leaves no return value, so `state` — mutated in place — is the only
445
+ accounting that survives those exits.
446
+
447
+ Pass `now` to make either measurement deterministic in a test; it defaults to
448
+ `Date.now`.
449
+
450
+ ### How to test an agent without a provider
451
+
452
+ `@juno-ai/bind/testing` ships the fixtures this package's own cross-module
453
+ suites use. A scripted model is a queue of prepared turns, so a multi-turn,
454
+ multi-tool test needs no credential, no network, and no mocked chat client.
455
+
456
+ ```ts
457
+ import { runToolLoop } from "@juno-ai/bind/loop";
458
+ import {
459
+ loopHarness, toolCall, toolCallTurn, finalAnswer,
460
+ } from "@juno-ai/bind/testing";
461
+
462
+ const h = loopHarness([
463
+ toolCallTurn([toolCall("search", { q: "bind" }), toolCall("read_file")]),
464
+ toolCallTurn([toolCall("write_file", { path: "out.md" })]),
465
+ finalAnswer("Done."),
466
+ ]);
467
+
468
+ const { stopReason, stats } = await runToolLoop(h.params);
469
+
470
+ expect(stopReason).toBe("done");
471
+ expect(stats.turns).toBe(3);
472
+ expect(h.ran).toEqual(["search", "read_file", "write_file"]);
473
+ ```
474
+
475
+ Every field of `h.params` is overridable, which is how you reach the
476
+ interesting states — `{ runToolCall }` to make one tool fail, `{ signal }` to
477
+ abort mid-batch, `{ now }` to make the timing figures deterministic
478
+ (`steppingClock()` for a fixed tick, or your own closure advanced inside
479
+ `runToolCall` when you want to *choose* each interval).
480
+ `h.ran` records what the loop *dispatched* and `h.sideEffects` what actually
481
+ *completed*; the gap between them is the answer to every cancellation question.
482
+
483
+ Two failure modes are deliberate. A script that runs out throws rather than
484
+ returning an empty turn — otherwise `maxIterations` absorbs the mistake and the
485
+ test passes while describing a run that never happened. And a duplicate
486
+ tool-call id throws at construction, rather than producing a transcript the
487
+ provider rejects ten frames deep in the loop.
488
+
489
+ ### How to author a tool without writing dispatch by hand
490
+
491
+ A `ToolPlugin` dispatches by tool name, which is right for a bundle with shared
492
+ setup and pure ceremony for a flat list of independent tools. `defineTool` does
493
+ the mechanical half — parse the arguments, turn a parse failure into something
494
+ the model can act on — and `pluginFromTools` bundles the result.
495
+
496
+ ```ts
497
+ import { defineTool, pluginFromTools } from "@juno-ai/bind/plugins";
498
+
499
+ const search = defineTool({
500
+ name: "search",
501
+ description: "Search the corpus.",
502
+ schema: z.object({ query: z.string().min(1), limit: z.number().default(10) }),
503
+ // `args` is typed from the schema; `limit` has already defaulted.
504
+ execute: async (args, ctx: Ctx) => ({
505
+ success: true,
506
+ data: await corpus.search(args.query, args.limit, ctx.tenantId),
507
+ }),
508
+ });
509
+
510
+ const plugin = pluginFromTools<Ctx>({
511
+ name: "corpus",
512
+ description: "Corpus tools.",
513
+ tools: [search],
514
+ });
315
515
  ```
316
516
 
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.
517
+ The schema is the single source of truth: it is converted to JSON Schema for
518
+ the model *and* used to validate what comes back, so the two cannot drift.
519
+
520
+ Bad arguments come back as `{ success: false, kind: "validation", error }`
521
+ naming the offending path — **returned, not thrown**. That distinction is the
522
+ bug this replaces: a thrown parse error turns a recoverable "you passed the
523
+ wrong field" into a dead run. An unknown tool name is likewise a returned
524
+ `not_found`, because a resumed session's history can reference a tool you have
525
+ since retired.
526
+
527
+ The validation lives on the tool, not on the bundle, so a host with its own
528
+ dispatcher can call `search.execute(rawArgs, ctx)` directly and get the same
529
+ guarantee — `pluginFromTools` only resolves names.
530
+
531
+ Two encoders come with it. `toolWireDefinition(tool, wireName?)` converts a
532
+ tool to what the provider is shown, through `sanitizeToolSchema` — `wireName`
533
+ because tool naming is host policy (Monad encodes `plugin__tool` to route a
534
+ call back to its plugin). `toolResultMessage(callId, result)` produces the
535
+ `role:"tool"` message, using the same encoding the loop synthesizes for a
536
+ failed or refused call — so a model never has to learn two error formats in one
537
+ transcript.
320
538
 
321
539
  ### How to make a tool take effect mid-batch
322
540
 
@@ -876,6 +1094,130 @@ data migration.
876
1094
  system prompt's business, and re-rendering it byte-identically for unchanged
877
1095
  inputs is what preserves a provider's prompt-cache prefix.
878
1096
 
1097
+ ### How to give an agent loadable skills
1098
+
1099
+ A *skill* is a markdown procedure or reference module the model can pull into
1100
+ its own instructions: a catalog line it always sees, a body injected into the
1101
+ system prompt once loaded, and resources it may read after that. It is the same
1102
+ progressive disclosure as tool activation, applied to knowledge — and it exists
1103
+ because the accumulated know-how of a real workspace does not fit in a context
1104
+ window, while a catalog line costs about fifty tokens.
1105
+
1106
+ Register what your own source ships:
1107
+
1108
+ ```ts
1109
+ import { createSkillRegistry, partitionSkillCatalog } from "@juno-ai/bind/skills";
1110
+
1111
+ const skills = createSkillRegistry({ onWarn: (message, fields) => log.warn(message, fields) });
1112
+
1113
+ skills.registerPlatform({
1114
+ name: "triaging-inbound-work",
1115
+ description: "How this team triages inbound requests.",
1116
+ whenToUse: "When asked to sort, rank, or route a queue of incoming work.",
1117
+ render: () => TRIAGE_BODY,
1118
+ });
1119
+ // A skill contributed by a plugin is offered only to an agent that can load
1120
+ // that plugin — a recipe for tools it cannot call is noise.
1121
+ skills.registerPlugin("documents", DRAFTING_SKILL);
1122
+ ```
1123
+
1124
+ Render the catalog from a partition, in your own words:
1125
+
1126
+ ```ts
1127
+ const active = new Set<string>(session.activeSkills);
1128
+ const available = skills.summaries({ availablePlugins: agent.plugins });
1129
+ const { active: loaded, loadable, truncated } = partitionSkillCatalog(active, available);
1130
+ ```
1131
+
1132
+ `partitionSkillCatalog` returns **data, not prose**, exactly as
1133
+ `partitionPluginCatalog` does. It also enforces a token budget by marking the
1134
+ overflow `name_only` rather than dropping it — a name is still enough for the
1135
+ model to call `load_skill` and read the real description, whereas a skill it
1136
+ cannot see is one it can never ask for. Pass your own `cost` if your line format
1137
+ differs from `- name: description — whenToUse`; a budget is only as honest as
1138
+ its measurement.
1139
+
1140
+ Then wire activation into the loop, and bound how much can be loaded:
1141
+
1142
+ ```ts
1143
+ import {
1144
+ admitSkillLoad,
1145
+ createSkillActivation,
1146
+ estimateSkillBodyTokens,
1147
+ resolveActiveSkillInstructions,
1148
+ } from "@juno-ai/bind/skills";
1149
+
1150
+ const loadedSkillShas: Record<string, string> = {};
1151
+ const resolveActiveInstructions = (activeRefs: string[]) =>
1152
+ resolveActiveSkillInstructions({ activeRefs, available, registry: skills });
1153
+
1154
+ const activation = createSkillActivation({
1155
+ availableSkills: available,
1156
+ activeSkills: active, // yours: seeded before the first turn, persisted after the last
1157
+ loadedSkillShas, // yours: hoist it so a failed run still records what it read
1158
+ store: { resolveActiveInstructions },
1159
+ applyInstructions: (instructions) => renderSystemPrompt({ instructions }),
1160
+ });
1161
+
1162
+ await runToolLoop({ ...params, activateSkills: (refs) => activation.activateSkills(refs) });
1163
+ ```
1164
+
1165
+ Two details are worth knowing before you wire your own `load_skill` tool. The
1166
+ resolver renders in a **total order** (origin, then name) rather than the order
1167
+ skills were loaded, because these bodies sit high in the system prompt and a
1168
+ resumed session's persisted order would otherwise byte-shift the cacheable
1169
+ prefix. And `admitSkillLoad` is what keeps a looping model from loading its way
1170
+ into a context-limit error — you measure, it judges:
1171
+
1172
+ ```ts
1173
+ // Cheapest first. The count is a `Set.size`; the token bound needs the
1174
+ // resolver, which for a host-stored skill is a query plus the wrapping of every
1175
+ // active body. A model that has hit the cap keeps calling `load_skill`, so
1176
+ // folding these into one pass pays that cost on every call purely to refuse.
1177
+ const byCount = admitSkillLoad({ activeCount: active.size });
1178
+ if (!byCount.admitted) return { success: false, kind: "validation", error: byCount.reason };
1179
+
1180
+ const { instructions } = await resolveActiveInstructions([...active, ref]);
1181
+ const byTokens = admitSkillLoad({ projectedBodyTokens: estimateSkillBodyTokens(instructions) });
1182
+ if (!byTokens.admitted) return { success: false, kind: "validation", error: byTokens.reason };
1183
+ ```
1184
+
1185
+ Omitting a measurement omits its bound, which is what makes the two-pass shape
1186
+ expressible — and a measurement that arrives broken (`NaN`, negative) refuses
1187
+ rather than admits, because a nonsense count is not evidence of room.
1188
+
1189
+ Skills your *users* author live in your database, not the registry. Hand the
1190
+ resolver an `externalSource` and it routes any ref that is not `platform:<name>`
1191
+ to you — batched, so one activation stays one query:
1192
+
1193
+ ```ts
1194
+ resolveActiveSkillInstructions({
1195
+ activeRefs,
1196
+ available,
1197
+ registry: skills,
1198
+ externalSource: async (refs, pinnedShas) => loadWorkspaceSkills(workspaceId, refs, pinnedShas),
1199
+ });
1200
+ ```
1201
+
1202
+ `pinnedShas` is how a replay stays honest. Each resolution records the
1203
+ `contentSha` it actually rendered; feed a completed run's map back in and every
1204
+ skill resolves to the body that run saw, so an eval is not silently grading
1205
+ against instructions that were edited afterwards.
1206
+
1207
+ To read or write the interchange format, pass your own YAML implementation —
1208
+ the package takes peer dependencies only:
1209
+
1210
+ ```ts
1211
+ import { parseSkillMarkdown } from "@juno-ai/bind/skills";
1212
+ import yaml from "js-yaml";
1213
+
1214
+ const parsed = parseSkillMarkdown(raw, { parse: yaml.load, stringify: (v) => yaml.dump(v, { lineWidth: -1 }) });
1215
+ ```
1216
+
1217
+ Import is deliberately lenient — it repairs the unquoted-colon frontmatter
1218
+ mistake and warns — and fails only on frontmatter that is not YAML and on a
1219
+ missing `description`, the one field with no sensible default.
1220
+
879
1221
  ### How to restore a persisted activation set
880
1222
 
881
1223
  ```ts
@@ -923,7 +1265,9 @@ client error), call `releaseProbe` so the slot cannot stick.
923
1265
  your editor. This section covers what the types cannot say: which entry point
924
1266
  to reach for, and the contracts that hold between calls.*
925
1267
 
926
- Every export is re-exported from the package root, but prefer the subpath — it
1268
+ Every export is re-exported from the package root **except `@juno-ai/bind/testing`,
1269
+ which is subpath-only so fixtures never reach a production bundle** — but prefer
1270
+ the subpath either way, since it
927
1271
  keeps a consumer who only wants routing from pulling in the rest.
928
1272
 
929
1273
  | Import | Owns | Reach for it when |
@@ -935,7 +1279,9 @@ keeps a consumer who only wants routing from pulling in the rest.
935
1279
  | `@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
1280
  | `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
937
1281
  | `@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 |
1282
+ | `@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 |
1283
+ | `@juno-ai/bind/skills` | Skill vocabulary — the code-skill registry, the `SKILL.md` codec, the content hash, the catalog's total order and token budget, the active-instruction resolver, the activation controller, active-set bounds, and the Agent Skills Discovery document | Your agent needs loadable instructions, not just tools — a workspace's procedures, a house style, a runbook |
1284
+ | `@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
1285
 
940
1286
  ### Contracts the types do not carry
941
1287
 
@@ -1288,8 +1634,14 @@ const turn: TurnFn = async (messages, tools, signal) => {
1288
1634
 
1289
1635
  ### Accumulating run statistics
1290
1636
 
1637
+ **If you use `runToolLoop`, you do not need this** — it folds `RunStats` itself
1638
+ and returns it. This is the shape for a host driving turns by hand.
1639
+
1291
1640
  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.
1641
+ and tool time separate so a slow tool never looks like a slow model. Model
1642
+ calls that are not agent turns — a compaction pass — go through
1643
+ `accumulateAuxiliarySpend` instead, which charges the tokens and the time
1644
+ without counting a turn.
1293
1645
 
1294
1646
  ```ts
1295
1647
  let stats = emptyRunStats();
@@ -1401,7 +1753,13 @@ try {
1401
1753
  stats = accumulateTurn(stats, result);
1402
1754
  await heartbeat.beat();
1403
1755
  if (!result.message.tool_calls?.length) break;
1404
- if (await runToolBatch(result.message.tool_calls)) { stopReason = "suspended"; break; }
1756
+ const suspend = await runToolBatch(result.message.tool_calls);
1757
+ if (suspend) {
1758
+ // The two pause reasons are separate values — see the stop-reason table.
1759
+ stopReason =
1760
+ suspend.resumeKind === "answer" ? "waiting_for_reply" : "resuming_later";
1761
+ break;
1762
+ }
1405
1763
  }
1406
1764
  } catch (error) {
1407
1765
  stopReason = classifyRunFailure(deadline, error) === "timed_out" ? "deadline" : "aborted";
@@ -1579,10 +1937,12 @@ two rates weights a 10-token run like a 10,000-token one.
1579
1937
  Named, not scheduled. Listed so a consumer can tell a deliberate omission from
1580
1938
  an oversight.
1581
1939
 
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.
1940
+ - **Time-to-first-token in `RunStats`.** The loop now folds `RunStats` itself,
1941
+ so `modelTimeMs`, `toolTimeMs` and the per-tool breakdown come for free but
1942
+ `ttftMs` lives on `TurnTimings` and only the transport can see the first byte.
1943
+ A `callModel` that reported its own timings back would close the gap;
1944
+ `ToolLoopTurn` would have to grow, which is a change to the shape every host
1945
+ already implements.
1586
1946
  - **A streaming turn contract.** Half of this landed:
1587
1947
  `createTurnTextStream` owns the emit/retry interaction for assistant *text*,
1588
1948
  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/index.d.ts CHANGED
@@ -11,9 +11,12 @@
11
11
  * coalesced heartbeat, failure classification, tool-batch pooling, child-run
12
12
  * lineage and admission), the streaming-completion watchdog and completion
13
13
  * defect detection (`src/completion/`), transcript validation/healing, provider
14
- * tool-schema sanitization, and the plugin/tool vocabulary with its registry and
14
+ * tool-schema sanitization, the plugin/tool vocabulary with its registry and
15
15
  * progressive-disclosure activation — generic over the host's invocation
16
- * context. What is NOT here is
16
+ * context and the skill vocabulary that applies the same disclosure to
17
+ * instructions (`src/skills/` — the code-skill registry, the `SKILL.md` codec,
18
+ * the content hash that makes a replay honest, the catalog budget, the
19
+ * active-instruction resolver and its activation controller). What is NOT here is
17
20
  * the run driver: starting a run, recording what it did, and delivering its
18
21
  * output. See the README for the rest of what is deliberately absent.
19
22
  */
@@ -24,4 +27,5 @@ export * from "./run/index.js";
24
27
  export * from "./transcript/index.js";
25
28
  export * from "./tools/index.js";
26
29
  export * from "./plugins/index.js";
30
+ export * from "./skills/index.js";
27
31
  export * from "./loop/index.js";