@juno-ai/bind 2.0.0 → 4.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 (52) hide show
  1. package/README.md +1153 -60
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +31 -7
  5. package/contracts/turn.js +45 -0
  6. package/index.d.ts +16 -5
  7. package/index.js +16 -5
  8. package/loop/index.d.ts +1 -0
  9. package/loop/index.js +1 -0
  10. package/loop/tool-loop.d.ts +260 -0
  11. package/loop/tool-loop.js +276 -0
  12. package/package.json +22 -2
  13. package/plugins/activation.d.ts +67 -0
  14. package/plugins/activation.js +61 -0
  15. package/plugins/index.d.ts +3 -0
  16. package/plugins/index.js +3 -0
  17. package/plugins/registry.d.ts +52 -0
  18. package/plugins/registry.js +54 -0
  19. package/plugins/tool.d.ts +164 -0
  20. package/plugins/tool.js +9 -0
  21. package/routing/billing-basis.d.ts +48 -0
  22. package/routing/billing-basis.js +67 -0
  23. package/routing/circuit-breaker.d.ts +2 -2
  24. package/routing/errors.d.ts +1 -1
  25. package/routing/executor.d.ts +3 -3
  26. package/routing/executor.js +1 -1
  27. package/routing/index.d.ts +11 -9
  28. package/routing/index.js +11 -9
  29. package/routing/plan-degradation.d.ts +34 -0
  30. package/routing/plan-degradation.js +38 -0
  31. package/routing/plan.d.ts +2 -2
  32. package/routing/planner.d.ts +4 -4
  33. package/routing/planner.js +1 -1
  34. package/routing/policy.d.ts +1 -1
  35. package/routing/policy.js +1 -1
  36. package/routing/transport.d.ts +2 -2
  37. package/run/children.d.ts +204 -0
  38. package/run/children.js +226 -0
  39. package/run/harness.d.ts +94 -0
  40. package/run/harness.js +140 -0
  41. package/run/index.d.ts +3 -0
  42. package/run/index.js +3 -0
  43. package/run/tool-batch.d.ts +16 -0
  44. package/run/tool-batch.js +83 -0
  45. package/tools/index.d.ts +1 -0
  46. package/tools/index.js +1 -0
  47. package/tools/sanitize-schema.d.ts +150 -0
  48. package/tools/sanitize-schema.js +683 -0
  49. package/transcript/index.d.ts +1 -0
  50. package/transcript/index.js +1 -0
  51. package/transcript/validate.d.ts +54 -0
  52. package/transcript/validate.js +226 -0
package/README.md CHANGED
@@ -3,88 +3,1181 @@
3
3
  **`@juno-ai/bind` — an agent harness.**
4
4
 
5
5
  The agent loop is a bind chain: each turn sequences a model completion into
6
- tool effects into the next turn's context. `bind` is the harness that runs
7
- that chain — the runtime-agnostic core of a production agent loop, extracted
8
- from [Monad](https://onmonad.ai).
6
+ tool effects into the next turn's context. `bind` is the harness that runs that
7
+ chain — the runtime-agnostic core of a production agent loop, extracted from
8
+ [Monad](https://onmonad.ai).
9
9
 
10
- ## Versioning
10
+ This document is organised on the [Diátaxis](https://diataxis.fr) axes.
11
+ **Tutorial** and **How-to** are practical; **Reference** and **Explanation** are
12
+ theoretical. If you are a coding agent working against this package, read the
13
+ exported types for signatures — they are the specification — and use
14
+ [Reference](#reference) for the contracts those types cannot express,
15
+ [Usage scenarios](#usage-scenarios) for porting shapes, and
16
+ [Rules for automated contributors](#rules-for-automated-contributors) for the
17
+ constraints that will fail CI if you break them.
11
18
 
12
- **Versioning is not semver.** Each published release increments the major and
13
- resets the rest — `1.0.0`, `2.0.0`, `3.0.0` — so the major is a release
14
- counter, not a compatibility signal, and a bump does not by itself mean the
15
- surface changed. Pin an exact version and read the changes between releases
16
- until this stabilizes.
17
-
18
- ## What's here today: provider routing
19
-
20
- Expand one canonical model selection (OpenRouter-style `vendor/model-slug`
21
- ids) into an ordered, immutable, secret-free **route plan** across providers,
22
- then drive attempts over it with a normative failure policy:
23
-
24
- - **`ProviderPolicy`** — ordered provider preference per model, or a hard
25
- `only` fence that pins a model to a single provider (useful for evals and
26
- compliance).
27
- - **`buildRoutePlan`** deterministic planner: policy order is the only
28
- ordering input; capability mismatches and configuration gaps become
29
- recorded skips, never network attempts.
30
- - **`executeRoutePlan`** the attempt loop: model stage provider candidate
31
- → bounded same-endpoint retries, with an exhaustive failure-disposition
32
- matrix (`failureDisposition`) deciding retry / next-provider /
33
- fallback-model / propagate.
34
- - **Circuit breaker** per `(provider, invocation model, credential source)`
35
- endpoint, with half-open probes, bounded Retry-After cooldowns, and an
36
- injectable clock.
37
-
38
- Transports (the actual HTTP clients), credentials, pricing policy, and
39
- persistence stay with the host application the harness never reads the
40
- environment, never touches a filesystem, and holds no secrets, which is what
41
- keeps it portable across Bun, Node, and edge runtimes such as Cloudflare
42
- workerd.
19
+ | I want to… | Go to |
20
+ |---|---|
21
+ | Understand what this is and whether I need it | [Explanation](#explanation) |
22
+ | Get something running end to end | [Tutorial](#tutorial-route-one-completion) |
23
+ | Solve one specific problem | [How-to guides](#how-to-guides) |
24
+ | Know which entry point to use, and what holds between calls | [Reference](#reference) |
25
+ | Know how versions work before installing | [Versioning](#versioning) |
26
+ | Port an existing agent runtime onto this | [Usage scenarios](#usage-scenarios) |
27
+ | Know what is deliberately not here yet | [Roadmap](#roadmap) |
28
+ | Change this package safely | [Rules for automated contributors](#rules-for-automated-contributors) |
29
+
30
+ ---
31
+
32
+ ## Explanation
33
+
34
+ *Understanding-oriented. Read this to know why the package is shaped the way it
35
+ is; you do not need it to use the package.*
36
+
37
+ ### What a harness is, and what it is not
38
+
39
+ A harness owns the parts of an agent loop that are the same for everyone: the
40
+ iteration itself — call the model, run what it asked for, repeat — plus
41
+ deciding which provider to call and what to do when it fails, bounding a run in
42
+ wall-clock time, keeping a transcript in a shape providers accept, rewriting
43
+ tool schemas that strict validators reject, and tracking which tools are
44
+ currently loaded.
45
+
46
+ A *runtime* owns the parts that are yours: identity, authorization, persistence,
47
+ transports, prompt voice, and product behaviour. `bind` deliberately contains
48
+ none of that. It never reads the environment, never touches a filesystem, and
49
+ holds no secrets — which is what lets the same code run on Bun, Node, and edge
50
+ runtimes such as Cloudflare workerd.
51
+
52
+ ### The fences, and why they exist
53
+
54
+ Four constraints, enforced by lint:
55
+
56
+ - **No `@/*` application imports.** Anything the harness needs from the host
57
+ arrives through an injected function or value — a port — never a direct
58
+ import.
59
+ - **No Node builtins, no `process`.** No filesystem, no environment reads.
60
+ Configuration is explicit input. Clocks are injectable, with a `Date.now`
61
+ default as the one sanctioned exception.
62
+ - **No framework imports.**
63
+ - **Peer dependencies only** (`zod`, plus `openai` as an optional *type-only*
64
+ peer). The host supplies the instances, so schemas never split across
65
+ duplicate copies — a failure that stays invisible until two zod instances
66
+ disagree about the same schema at runtime.
67
+
68
+ The fences are not stylistic. They are the reason a Cloudflare Worker and a
69
+ long-lived Node server can share this code unmodified.
70
+
71
+ ### Why the plugin types are generic
72
+
73
+ `ToolPlugin` is generic over the invocation context (`TCtx`) rather than
74
+ shipping a concrete one. Almost nothing in a real tool context is common: the
75
+ abort signal is, and the rest is the host's own identity model, its
76
+ authorization, and its product features. Two applications comparing notes here
77
+ will typically find they share one field. A concrete context would therefore be
78
+ either a lowest-common-denominator or the union of several products' identity
79
+ models — so the context is a type parameter you supply, and you add your own
80
+ plugin fields by ordinary interface extension.
81
+
82
+ `ToolResult` is separately generic over the content-part type for a smaller
83
+ reason: the wire shape is the provider's, but turning bytes into a multimodal
84
+ part needs runtime-specific APIs that differ between Node and the edge. The
85
+ harness carries parts through without interpreting them, which keeps those APIs
86
+ — and the dependencies they imply — out of a package that must run on workerd.
87
+
88
+ ### Why the registry is a factory
89
+
90
+ `createToolRegistry` returns an instance rather than exposing a module-level
91
+ map. Module state is per-isolate on edge runtimes and its lifetime is not the
92
+ host's; it also makes tests share state implicitly. A host that wants singleton
93
+ ergonomics wraps one instance in a module — the choice belongs to the host.
94
+
95
+ ### Why a restored activation set is a hint
96
+
97
+ Progressive tool disclosure persists plugin **names**; implementations resolve
98
+ at load time. Between two runs a plugin can be renamed, gated off, or (if it is
99
+ dynamically connected) fail to reconnect. `rehydrateActivation` therefore
100
+ re-validates every persisted entry against current reality and **drops** what no
101
+ longer resolves, returning the drops with a reason rather than failing the run.
102
+ Reporting a plugin as active when its tools cannot be called is worse than
103
+ losing it.
104
+
105
+ ### What stays with your application
106
+
107
+ Transports' request construction and wire-error classification, credentials and
108
+ environment parsing, your routing policy configuration, billing accounting
109
+ (persistence and charging), inference logging, authorization, prompt rendering,
110
+ and run orchestration — the queue a run is scheduled on, and the enqueuing and
111
+ storage behind any child runs it spawns. The harness decides whether a child is
112
+ *allowed*; putting it on a queue is yours (see
113
+ [Spawning child runs](#spawning-child-runs-sub-agents)).
114
+
115
+ The loop is here, but the **driver** around it is not: starting a run, recording
116
+ what it did, delivering its output, and deciding when to run it again. That is
117
+ where a runtime's identity, storage, and product behaviour live, and it is why
118
+ `runToolLoop` takes a dozen observers instead of doing any of it.
119
+
120
+ ---
121
+
122
+ ## Tutorial: route one completion
123
+
124
+ *Learning-oriented. Follow these steps in order on a scratch file; the goal is a
125
+ working mental model, not production code.*
126
+
127
+ You will plan a route across two providers, execute it against a fake
128
+ transport, and watch the failure policy fall over to the second provider.
129
+
130
+ **1. Install.** `zod` is a peer dependency — bring your own v4 instance. Add
131
+ `openai` too if you use `/run`, `/transcript`, `/contracts`, or the package
132
+ root: they reference its message types (type-only, erased at runtime).
133
+
134
+ ```sh
135
+ bun add @juno-ai/bind zod openai
136
+ ```
137
+
138
+ **2. Describe your providers.** A `PlannerTransport` answers two questions:
139
+ are you available, and can you serve this model? It returns a candidate, a
140
+ recorded skip, or `unserved`.
43
141
 
44
142
  ```ts
45
143
  import {
46
- buildRoutePlan,
47
- executeRoutePlan,
48
- createCircuitBreaker,
49
144
  canonicalModelIdSchema,
145
+ providerIdSchema,
146
+ type PlannerTransport,
50
147
  } from "@juno-ai/bind/routing";
51
148
 
149
+ const model = canonicalModelIdSchema.parse("openai/gpt-example");
150
+ const primaryId = providerIdSchema.parse("primary");
151
+ const backupId = providerIdSchema.parse("backup");
152
+
153
+ function fakeTransport(id: typeof primaryId): PlannerTransport {
154
+ return {
155
+ id,
156
+ getAvailability: () => ({ available: true }),
157
+ resolveCandidate: () => ({
158
+ kind: "candidate",
159
+ candidate: {
160
+ providerId: id,
161
+ canonicalModelId: model,
162
+ providerInvocationModel: "gpt-example",
163
+ credentialSource: "platform",
164
+ creditEligible: true,
165
+ capabilities: new Set(["chat_completions"]),
166
+ maxCompletionTokens: null,
167
+ pricingBasis: { kind: "provider_reported" },
168
+ bindingFingerprint: `${id}:gpt-example`,
169
+ },
170
+ }),
171
+ };
172
+ }
173
+ ```
174
+
175
+ **3. Build the plan.** Policy order is the only ordering input. The result is
176
+ frozen, secret-free, and safe to log or snapshot in a test.
177
+
178
+ ```ts
179
+ import { buildRoutePlan } from "@juno-ai/bind/routing";
180
+
52
181
  const { plan, skips } = buildRoutePlan({
53
- primaryModel: canonicalModelIdSchema.parse("openai/gpt-example"),
182
+ primaryModel: model,
54
183
  fallbackModel: null,
55
- requirements: { capabilities: new Set(["chat_completions", "streaming"]), requestedMaxCompletionTokens: null },
56
- policyFor: (model) => resolveMyPolicy(model),
57
- transports: myTransportMap, // availability + endpoint resolution per provider
184
+ requirements: {
185
+ capabilities: new Set(["chat_completions"]),
186
+ requestedMaxCompletionTokens: null,
187
+ },
188
+ policyFor: () => ({ mode: "ordered", providers: [primaryId, backupId] }),
189
+ transports: new Map([
190
+ [primaryId, fakeTransport(primaryId)],
191
+ [backupId, fakeTransport(backupId)],
192
+ ]),
58
193
  });
59
194
 
195
+ console.log(plan.stages[0].candidates.map((c) => c.providerId)); // primary, backup
196
+ console.log(skips); // [] — nothing was filtered out
197
+ ```
198
+
199
+ **4. Execute it.** Your `attempt` function performs the real call and
200
+ classifies any failure into facts. It never decides route order — that is the
201
+ executor's job.
202
+
203
+ ```ts
204
+ import { executeRoutePlan, createCircuitBreaker } from "@juno-ai/bind/routing";
205
+
60
206
  const result = await executeRoutePlan({
61
207
  plan,
62
208
  breaker: createCircuitBreaker(),
63
- attempt: async (candidate, cursor) => myTransportAttempt(candidate, cursor),
209
+ attempt: async (candidate, cursor) => {
210
+ if (candidate.providerId === primaryId) {
211
+ return {
212
+ kind: "failure",
213
+ error: {
214
+ kind: "http",
215
+ category: "server_error",
216
+ statusCode: 503,
217
+ retryAfterMs: null,
218
+ target: {
219
+ cursor,
220
+ providerId: candidate.providerId,
221
+ canonicalModelId: candidate.canonicalModelId,
222
+ providerInvocationModel: candidate.providerInvocationModel,
223
+ durationMs: 12,
224
+ },
225
+ cause: new Error("upstream unavailable"),
226
+ },
227
+ };
228
+ }
229
+ return { kind: "success", value: "hello from backup" };
230
+ },
64
231
  });
232
+
233
+ if (result.ok) {
234
+ console.log(result.value); // "hello from backup"
235
+ console.log(result.served.providerId); // backup
236
+ console.log(result.fallbackKind); // "provider"
237
+ }
65
238
  ```
66
239
 
67
- ## Design rules
240
+ **What you just saw.** A 503 classifies as a retriable transport failure, so the
241
+ disposition table allows traversal to the next provider and records a breaker
242
+ failure against the first endpoint. You did not write that logic, and you cannot
243
+ accidentally reorder it from inside a transport.
244
+
245
+ **5. Next.** Add a wall-clock budget with
246
+ [`createRunDeadline`](#how-to-bound-a-run-in-wall-clock-time), or add
247
+ progressive tool disclosure with
248
+ [`createToolRegistry`](#how-to-add-progressive-tool-disclosure).
249
+
250
+ ---
251
+
252
+ ## How-to guides
253
+
254
+ *Goal-oriented. Each answers one question and assumes you know roughly what you
255
+ are doing.*
256
+
257
+ ### How to run the loop
258
+
259
+ `runToolLoop` is the engine: it calls the model, runs the tools the model asks
260
+ for, and repeats until the model stops asking, a tool suspends the run, a caller
261
+ stops it, or `maxIterations` is reached. Everything that *happens* as a result is
262
+ a callback you supply.
263
+
264
+ ```ts
265
+ import { runToolLoop, type ToolLoopState } from "@juno-ai/bind/loop";
266
+
267
+ const state: ToolLoopState = {
268
+ messages: [systemMessage, userMessage],
269
+ inputTokens: 0,
270
+ outputTokens: 0,
271
+ costCents: 0,
272
+ lastPromptTokens: 0,
273
+ lastOutputTokens: 0,
274
+ hasFreshTokenCount: false,
275
+ toolCalls: 0,
276
+ };
277
+
278
+ await runToolLoop({
279
+ state,
280
+ activePlugins,
281
+ maxIterations: 30,
282
+
283
+ callModel: (messages, tools) => llm.complete({ messages, tools }),
284
+ buildTools: () => registry.toolDefinitions([...activePlugins]),
285
+ runToolCall: (call) => dispatch(call),
286
+ activatePlugins: (names) => names.forEach((n) => activePlugins.add(n)),
287
+ activateSkills: (refs) => loadInstructions(refs),
288
+ });
289
+
290
+ // `state` is mutated in place — read totals off it after, or mid-run from a
291
+ // heartbeat.
292
+ console.log(state.inputTokens, state.outputTokens, state.toolCalls);
293
+ ```
294
+
295
+ `state` is mutated rather than returned so a heartbeat can read live totals while
296
+ the run is still going; a returned result could not report anything until the
297
+ run ended.
298
+
299
+ ### How to make a tool take effect mid-batch
300
+
301
+ A model can request several tools at once, and one of them may change what the
302
+ others can do — activating a plugin, loading an instruction module. Those must
303
+ run first, alone, or a dependent call in the same batch executes against the old
304
+ tool surface.
305
+
306
+ ```ts
307
+ runsSerially: (call) =>
308
+ call.type === "function" && ACTIVATION_TOOLS.has(call.function.name),
309
+ ```
310
+
311
+ The loop runs those one at a time, applies each outcome immediately, then fans
312
+ the rest out concurrently (pooled per tool name). Outcomes are reassembled in the
313
+ model's original order either way — every `tool_call_id` gets its answer in the
314
+ sequence the provider expects.
315
+
316
+ Unwired, nothing is serial. That is correct for a host whose tools do not reshape
317
+ the tool surface, and wrong the moment one does.
318
+
319
+ ### How to decide which tool failures kill the run
320
+
321
+ By default a thrown tool becomes a tool error the model can read and recover
322
+ from, which is what you want for an isolated failure. Two kinds are not that:
323
+
324
+ ```ts
325
+ isFatalToolError: (error) =>
326
+ error instanceof RunCancelledError || // must abort, not be answered
327
+ error instanceof PersistenceError, // we could not RECORD the outcome
328
+ ```
329
+
330
+ Cancellation has to propagate even when no `ensureNotCancelled` observer is
331
+ wired. A persistence failure matters for a subtler reason: synthesizing "the tool
332
+ failed" over a write you could not record tells the model a lie about work that
333
+ may well have happened.
334
+
335
+ Everything not fatal is answered and observed:
336
+
337
+ ```ts
338
+ onToolCallRejected: (toolCallId, error) => log.warn("tool rejected", { toolCallId, error }),
339
+ ```
340
+
341
+ Wire it. The model sees these failures either way; without the observer, nothing
342
+ else does.
343
+
344
+ ### How to pin a model to one provider
345
+
346
+ Use a hard `only` fence. Plan-time filtering and runtime traversal both respect
347
+ it — a pinned provider that fails at request time is never retried elsewhere.
348
+
349
+ ```ts
350
+ policyFor: () => ({ mode: "only", provider: complianceProviderId });
351
+ ```
352
+
353
+ Useful for evals (reproducible plans) and for compliance routing.
354
+
355
+ ### How to bound a run in wall-clock time
356
+
357
+ Create the deadline where you classify the outcome, and dispose it in a
358
+ `finally`.
359
+
360
+ ```ts
361
+ import { createRunDeadline, classifyRunFailure } from "@juno-ai/bind/run";
362
+
363
+ const deadline = createRunDeadline({ timeoutMs: 60 * 60 * 1000, label: "agent run" });
364
+ try {
365
+ for (;;) {
366
+ deadline.throwIfTimedOut();
367
+ await callModel({ signal: deadline.withExternal(cancellationSignal) });
368
+ }
369
+ } catch (error) {
370
+ const status = classifyRunFailure(deadline, error); // "timed_out" | "failed"
371
+ } finally {
372
+ deadline.dispose();
373
+ }
374
+ ```
375
+
376
+ **Ownership rule:** whoever needs to read `deadline.timedOut` must create and
377
+ own the deadline. A loop handed one must not dispose it; a loop given none
378
+ should mint its own, so a turn is never unbounded. Handle caller-driven
379
+ cancellation *before* calling `classifyRunFailure` — a cancellation is not a
380
+ timeout, and `timedOut` stays false when only a combined external signal fires.
381
+
382
+ ### How to stop a run's progress writes from stampeding
383
+
384
+ ```ts
385
+ import { createCoalescedHeartbeat } from "@juno-ai/bind/run";
386
+
387
+ const heartbeat = createCoalescedHeartbeat({
388
+ coalesceMs: 10_000,
389
+ flush: () => db.bumpRunRow(runId),
390
+ onError: (error) => log.warn("heartbeat flush failed", { error }),
391
+ });
392
+
393
+ await heartbeat.beat(); // coalesced
394
+ await heartbeat.beat({ force: true }); // always flushes, resolves after the write
395
+ ```
396
+
397
+ Flush errors go to `onError` and are swallowed, so a transient database hiccup
398
+ never aborts a run. Flushes drain one at a time, so two overlapping writes can
399
+ never land out of order.
400
+
401
+ ### How to keep a provider from rejecting your whole tool list
402
+
403
+ Strict validators reject the **entire** request — every tool — on the first
404
+ schema violation. Run each tool's JSON Schema through the sanitizer before it
405
+ reaches the model.
406
+
407
+ ```ts
408
+ import { sanitizeToolSchema } from "@juno-ai/bind/tools";
409
+
410
+ const wireTools = tools.map((tool) => ({
411
+ type: "function",
412
+ function: {
413
+ name: tool.name,
414
+ description: tool.description,
415
+ parameters: sanitizeToolSchema(tool.inputSchema),
416
+ },
417
+ }));
418
+ ```
419
+
420
+ Every transform is correctness-preserving and was bisected against live
421
+ inference. It fixes, among others: a `type` array where a provider requires a
422
+ scalar; `enum` on a non-string type; `required` entries with no matching
423
+ property; a boolean `additionalProperties: false` on a nested object; and a
424
+ parameter literally named `properties`. Run it on third-party (e.g. MCP) tool
425
+ schemas too — those are where the violations usually come from.
426
+
427
+ ### How to repair a transcript before sending it
428
+
429
+ ```ts
430
+ import { validateAndHealMessages } from "@juno-ai/bind/transcript";
431
+
432
+ const { messages, issues } = validateAndHealMessages(transcript);
433
+ if (issues.length > 0) log.warn("healed transcript", { issues });
434
+ ```
435
+
436
+ Detects and repairs orphan tool results, dangling unanswered tool calls, empty
437
+ assistant messages mid-conversation, and a trailing assistant turn — which is
438
+ prefill on one provider's native API and a hard rejection through another.
439
+
440
+ ### How to add progressive tool disclosure
441
+
442
+ Model tool-selection accuracy degrades past a few dozen tools, and every tool's
443
+ schema is resent on every turn. Load a small core set, announce the rest as a
444
+ catalog, and activate on demand.
445
+
446
+ ```ts
447
+ import {
448
+ createToolRegistry,
449
+ initialActivePlugins,
450
+ partitionPluginCatalog,
451
+ } from "@juno-ai/bind/plugins";
452
+
453
+ const registry = createToolRegistry<MyPlugin>({
454
+ corePlugins: ["messaging", "memory"],
455
+ aliases: { "old-name": "new-name" },
456
+ onRegister: (plugin) => indexPluginSkills(plugin),
457
+ });
458
+
459
+ registry.register(myPlugin);
460
+
461
+ const active = new Set(initialActivePlugins(registry.corePlugins()));
462
+ const { active: shown, loadable } = partitionPluginCatalog(active, registry.summaries());
463
+ ```
464
+
465
+ `aliases` are permanent: persisted activation state stores names, so an alias is
466
+ how a rename avoids silently stripping capabilities from live sessions without a
467
+ data migration.
468
+
469
+ `partitionPluginCatalog` returns **data, not prose** — catalog wording is your
470
+ system prompt's business, and re-rendering it byte-identically for unchanged
471
+ inputs is what preserves a provider's prompt-cache prefix.
472
+
473
+ ### How to restore a persisted activation set
474
+
475
+ ```ts
476
+ import { rehydrateActivation } from "@juno-ai/bind/plugins";
477
+
478
+ const { active, dropped } = await rehydrateActivation(persistedNames, {
479
+ canonicalizeName: (name) => registry.canonicalizeName(name),
480
+ resolve: async (name) => {
481
+ if (isGatedOffThisRun(name)) return "unavailable";
482
+ if (!isDynamic(name)) return registry.get(name) ? true : "unknown";
483
+ return (await connect(name)) ? true : "unreachable";
484
+ },
485
+ });
486
+
487
+ for (const drop of dropped) log.warn("activation dropped", drop);
488
+ ```
489
+
490
+ The walk canonicalizes, de-duplicates (two legacy names collapsing to one plugin
491
+ resolve **once**, so a reconnect is not paid twice), and drops rather than
492
+ throws. `resolve` is async precisely so a reconnect can happen inside it.
493
+
494
+ ### How to scope the circuit breaker per tenant
495
+
496
+ Pass a non-secret `credentialScope`. Without it, one tenant's revoked
497
+ bring-your-own key opens the circuit for every tenant sharing the same
498
+ `credentialSource`.
499
+
500
+ ```ts
501
+ breaker.recordFailure({
502
+ providerId,
503
+ invocationModel,
504
+ credentialSource: "tenant",
505
+ credentialScope: tenantTag, // opaque, non-secret — it lands in state keys
506
+ });
507
+ ```
508
+
509
+ If a half-open probe ends without a recordable outcome (an abort, a propagated
510
+ client error), call `releaseProbe` so the slot cannot stick.
511
+
512
+ ---
513
+
514
+ ## Reference
515
+
516
+ *Information-oriented. The exported types are the specification — read them in
517
+ your editor. This section covers what the types cannot say: which entry point
518
+ to reach for, and the contracts that hold between calls.*
519
+
520
+ Every export is re-exported from the package root, but prefer the subpath — it
521
+ keeps a consumer who only wants routing from pulling in the rest.
522
+
523
+ | Import | Owns | Reach for it when |
524
+ |---|---|---|
525
+ | `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy, the plan executor, the circuit breaker, billing-basis arithmetic, config degradation | You call more than one provider or model, or you want retries and fallback governed by one table |
526
+ | `@juno-ai/bind/contracts` | Turn vocabulary — `TurnFn`, `ModelTurnResult`, `StopReason`, `RunStats` and its folds | You want one seam between your loop and any LLM client, and comparable per-run metrics |
527
+ | `@juno-ai/bind/loop` | `runToolLoop` — the iteration engine: model turn, two-phase tool batch, activation, compaction, interrupts, suspend | You want the agent loop itself, not just the pieces to build one |
528
+ | `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling, child-run lineage and admission, poll backoff | A run must be bounded, observable, and able to say *why* it stopped — or it can spawn runs of its own |
529
+ | `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
530
+ | `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
531
+ | `@juno-ai/bind/plugins` | Tool/plugin vocabulary, the registry factory, progressive-disclosure activation | You have more tools than fit comfortably in one prompt |
532
+
533
+ ### Contracts the types do not carry
534
+
535
+ **Routing — attempt order is fixed.** Structured-output attempt → model stage →
536
+ provider candidate → same-endpoint retry. Nothing else reorders it: not
537
+ transport registration order, not map iteration, not the clock.
538
+
539
+ **Routing — transports classify, they never decide.** An `AttemptFn` reports a
540
+ fact (`aborted` / `completion_defect` / `network` / `http` + category);
541
+ `failureDisposition` alone decides retry, next-provider, fallback-model, or
542
+ propagate. Putting routing logic in a transport is the one way to break the
543
+ guarantee that identical inputs produce identical plans.
544
+
545
+ **Routing — a healthy endpoint must not be punished for a bad request.**
546
+ Request-shaped rejections (a 400, a moderation refusal) traverse to another
547
+ provider but record **no** breaker failure. Otherwise one caller's malformed or
548
+ flagged prompt degrades a shared endpoint for everyone.
549
+
550
+ **Routing — a fallback model equal to the primary is ignored**, so you can pass
551
+ a configured fallback through unconditionally without producing a duplicate
552
+ stage.
553
+
554
+ **Routing — breaker keys must stay secret-free.** `credentialScope` lands in
555
+ state keys; pass an opaque tag, never key material. Omitting it means one
556
+ tenant's revoked key opens the circuit for every tenant sharing that
557
+ `credentialSource`. Defaults: open after 3 consecutive failures, 60 s cooldown,
558
+ 60 s ceiling including `Retry-After` extensions. Resolve a half-open probe that
559
+ ended without a recordable outcome via `releaseProbe`, or the slot sticks.
560
+
561
+ **Run — a child is admitted before it exists, never after.** `admitChildRun`
562
+ judges counts you supply; call it ahead of enqueuing. A chain bounded only once
563
+ its runs are on the queue is not bounded, it is billed. A rule whose measurement
564
+ is `NaN` or `Infinity` refuses rather than admits — every comparison is false
565
+ against `NaN`, so the naive reading of a broken count is an unbounded chain.
68
566
 
69
- - **Pure by construction.** No `process`, no Node builtins, no framework
70
- imports, no I/O except through injected functions. Enforced by lint in the
71
- source-of-truth repository.
72
- - **Deterministic.** Identical inputs produce identical plans; nothing about
73
- registration order, map iteration, or wall-clock time reorders candidates.
74
- - **Explicit failure policy.** Every classified failure maps through one
75
- exhaustive disposition table; transports classify facts, they never decide
76
- route order.
77
- - **zod is a peer dependency** bring your own instance (v4+).
567
+ **Run chain lineage is "null means me".** A root run's `rootRunId` and
568
+ `parentRunId` are both `null`, because a chain's origin has no id to point at
569
+ until its own row exists. Read any chain's root as `chain.rootRunId ?? runId`;
570
+ `descendChain` does that when it hands the id down, so every descendant carries
571
+ a concrete root. Adopting the parent's id at *every* level instead makes each
572
+ generation a fresh chain, and every per-chain bound then counts the wrong set
573
+ and silently never fires.
574
+
575
+ **Run whoever classifies the outcome owns the deadline.** `timedOut` reflects
576
+ *that* deadline firing, and stays false when only a combined external signal
577
+ aborts — which is what separates a timeout from a cancellation. Handle
578
+ cancellation before calling `classifyRunFailure`. A loop handed a deadline must
579
+ not dispose it; a loop given none should mint its own, so a turn is never
580
+ unbounded.
581
+
582
+ **Run — pooling preserves input order and answers everything.**
583
+ `runToolCallsPooledByTool` caps concurrent calls to the *same* tool at 5 while
584
+ different tools fan out fully, and returns `PromiseSettledResult`s in input
585
+ order. A rejection still needs a synthesized error tool message, or the next
586
+ request carries an unanswered `tool_call_id`.
587
+
588
+ **Routing — a policy that names a provider you have not configured degrades,
589
+ it does not fail.** `buildRoutePlanWithConfigDegradation` drops such a stage at
590
+ *plan time* and records why, so a missing key costs you that provider rather
591
+ than making the model unavailable. Runtime failures keep the fence: a configured
592
+ `only` provider that errors at request time is still never retried elsewhere.
593
+
594
+ **Run — heartbeat flushes drain one at a time**, so two overlapping writes
595
+ cannot land out of order. Flush errors go to `onError` and are swallowed; a
596
+ transient storage failure never aborts a run.
597
+
598
+ **Contracts — model time and tool time stay separate.** Task wall-clock
599
+ conflates provider inference speed with tool execution. `RunStats` reports both,
600
+ plus a per-tool breakdown; field shapes follow the [StirrupJS](https://github.com/stirrupjs/stirrup)
601
+ `speedStats` methodology, so numbers stay comparable with published benchmarks
602
+ and a slow tool never reads as a slow model.
603
+
604
+ **Transcript — the second argument is an assertion, not a silence.** Ids passed
605
+ as `allowedOpenToolCallIds` are *legitimately* open (a suspended call awaiting a
606
+ human answer). Because this is the universal heal site and a correct transcript
607
+ never reaches it with a suspended call unpaired, an allowed id arriving unpaired
608
+ means resume failed — and it fails loudly rather than being quietly kept.
609
+
610
+ **Tools — sanitize third-party schemas too.** Strict validators reject the
611
+ *entire* request, every tool, on the first violation, and third-party schemas
612
+ are where violations usually originate. Every transform is
613
+ correctness-preserving and was bisected against live inference.
614
+
615
+ **Plugins — `rawJsonSchema` overrides, it does not replace.** `parameters` is
616
+ always required; a host authoring tools as raw JSON Schema supplies a
617
+ placeholder there and puts the real schema on `rawJsonSchema`, which is what
618
+ reaches the model.
619
+
620
+ **Plugins — availability is evaluated per call.** `get()` returns `undefined`
621
+ for an unavailable plugin while `all()` still includes it, so a plugin can stay
622
+ registered for configuration purposes while being gated off for a run. A
623
+ connection going unhealthy mid-process takes its tools out of the catalog with
624
+ no re-registration.
625
+
626
+ **Plugins — aliases are permanent.** Activation state persists *names*, so an
627
+ alias is how a rename avoids stripping capabilities from live sessions without a
628
+ data migration. Removing one silently downgrades every session that still stores
629
+ the old name.
630
+
631
+ **Plugins — `hidden: true` keeps a tool callable but unadvertised**, for resumed
632
+ sessions whose history references a tool you have retired from the prompt.
633
+
634
+ **Plugins — rehydration drops, it never throws.** `resolve` returns `true` to
635
+ keep or a drop reason to discard, and may be async so a reconnect can happen
636
+ inside it. The walk canonicalizes and de-duplicates first, so two legacy names
637
+ collapsing to one plugin resolve once — a reconnect is not paid twice.
638
+
639
+ **Plugins — catalog partitioning returns data, not prose.** Wording belongs to
640
+ your system prompt, where re-rendering byte-identically for unchanged inputs is
641
+ what preserves a provider's prompt-cache prefix.
642
+
643
+ ### Suspend semantics
644
+
645
+ A first-party tool returns a `SuspendDirective` on a **successful** result to end
646
+ the run with its call recorded as awaiting resolution. The resume kind decides
647
+ what happens to the tool message:
648
+
649
+ - `"answer"` — the reply becomes this call's `role:"tool"` result, so the loop
650
+ **withholds** the message. At most one may be open per run.
651
+ - `"wake"` — a time or event resume that re-enters via a prompt and **keeps** the
652
+ message.
653
+
654
+ `request` is an opaque render/route payload, validated by the consumer and never
655
+ inspected by the loop.
656
+
657
+ ---
658
+
659
+ ## Usage scenarios
660
+
661
+ *Sketches, not runnable files — they name types loosely and omit imports. Each
662
+ shows the shape of one migration an existing agent runtime typically has to
663
+ make. Read the [Reference](#reference) for exact signatures.*
664
+
665
+ ### Tools authored as raw JSON Schema, not zod
666
+
667
+ `ToolDef.parameters` is a zod schema, but a host whose tools are already JSON
668
+ Schema does not need to rewrite them — set `rawJsonSchema` and it is used
669
+ verbatim. Keep `parameters` as a permissive placeholder if you validate
670
+ elsewhere.
671
+
672
+ ```ts
673
+ const toolDef: ToolDef = {
674
+ name: "search",
675
+ description: "Search one page of results.",
676
+ parameters: z.unknown(), // unused when rawJsonSchema is present
677
+ rawJsonSchema: existingJsonSchema, // your hand-written schema, as-is
678
+ annotations: { readOnlyHint: true },
679
+ };
680
+
681
+ // Sanitize on the way to the model, regardless of which form it came from.
682
+ const wire = {
683
+ type: "function",
684
+ function: {
685
+ name: toolDef.name,
686
+ description: toolDef.description,
687
+ parameters: sanitizeToolSchema(toolDef.rawJsonSchema ?? z.toJSONSchema(toolDef.parameters)),
688
+ },
689
+ };
690
+ ```
691
+
692
+ ### One plugin per integration
693
+
694
+ A natural plugin boundary is the external system a group of tools talks to.
695
+
696
+ `isAvailable` gates *whether the deployment has this integration at all* — a
697
+ build flag, a missing binding, an edition that does not ship it. It must return
698
+ the same answer for the whole life of the process: the catalog is re-rendered
699
+ several times per run, and a value that flips mid-run changes the system prompt
700
+ between turns, which costs the prompt-cache prefix and can strand a tool call
701
+ the model already issued.
702
+
703
+ Health that changes at runtime belongs at *invocation* instead. Let the call
704
+ fail, and return the reason:
705
+
706
+ ```ts
707
+ function integrationPlugin(adapter: Adapter, conn: Connection): ToolPlugin<Ctx> {
708
+ return {
709
+ name: adapter.id, // "calendar", "issues", "docs", …
710
+ description: adapter.catalogLine, // one line; it lands in the prompt catalog
711
+ isAvailable: () => adapter.installed, // constant for this process
712
+ tools: adapter.tools.filter((t) => conn.grants.has(t.capability)),
713
+ execute: async (toolName, args, ctx) => {
714
+ if (conn.status !== "healthy") {
715
+ // The model can retry, or route around it — a dropped catalog entry
716
+ // tells it nothing.
717
+ return { content: `${adapter.id} is unreachable right now.`, isError: true };
718
+ }
719
+ return adapter.invoke(toolName, args, ctx);
720
+ },
721
+ };
722
+ }
723
+
724
+ for (const conn of connections) {
725
+ registry.register(integrationPlugin(adapterFor(conn), conn));
726
+ }
727
+ ```
728
+
729
+ For a plugin whose *connection* is established per run rather than per process,
730
+ restoring a persisted activation is where the reconnect belongs — see
731
+ [How to restore a persisted activation set](#how-to-restore-a-persisted-activation-set).
732
+
733
+ Splitting one large integration into several plugins (`mail`, `calendar`,
734
+ `files`) is usually worth it: the unit of activation should match the unit of
735
+ intent, and a 20-tool plugin is a large thing to load for one call.
736
+
737
+ ### A per-request registry on an edge runtime
738
+
739
+ Module-level state is per-isolate and outlives a single request unpredictably.
740
+ Build the registry inside the request instead — the factory is cheap.
741
+
742
+ ```ts
743
+ export default {
744
+ async fetch(request: Request, env: Env) {
745
+ const registry = createToolRegistry<MyPlugin>({
746
+ corePlugins: ["core", "discovery"],
747
+ });
748
+ for (const plugin of await loadPluginsFor(env, request)) {
749
+ registry.register(plugin);
750
+ }
751
+ return handle(request, registry);
752
+ },
753
+ };
754
+ ```
755
+
756
+ ### A single-provider transport
757
+
758
+ You do not need multiple providers to benefit from the plan/execute split — one
759
+ transport still gets you the failure taxonomy, the retry budget, and the
760
+ breaker.
761
+
762
+ ```ts
763
+ const transport: PlannerTransport = {
764
+ id: providerIdSchema.parse("openrouter"),
765
+ getAvailability: () =>
766
+ env.API_KEY ? { available: true } : { available: false, reason: "no key" },
767
+ resolveCandidate: (model, requirements) => {
768
+ if (!supports(model, requirements.capabilities)) {
769
+ return { kind: "skip", skip: { canonicalModelId: model, providerId: id, reason: "capability_mismatch" } };
770
+ }
771
+ return { kind: "candidate", candidate: candidateFor(model) };
772
+ },
773
+ };
774
+ ```
775
+
776
+ ### Replacing a hand-rolled retry/fallback loop
777
+
778
+ A typical hand-rolled loop is `for (model) for (attempt)` with an ad-hoc
779
+ `retry | fallback | terminal` decision. Move the decision to the taxonomy: your
780
+ transport reports **facts**, the executor decides order.
781
+
782
+ ```ts
783
+ // Before: the transport decided what to do next.
784
+ // if (status === 429 || status >= 500) retry();
785
+ // else if (status === 401) throw;
786
+ // else tryNextModel();
787
+
788
+ // After: the transport only classifies.
789
+ const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
790
+ const startedAt = performance.now();
791
+ try {
792
+ return { kind: "success", value: await callProvider(candidate) };
793
+ } catch (cause) {
794
+ const target = {
795
+ cursor,
796
+ providerId: candidate.providerId,
797
+ canonicalModelId: candidate.canonicalModelId,
798
+ providerInvocationModel: candidate.providerInvocationModel,
799
+ durationMs: Math.round(performance.now() - startedAt),
800
+ };
801
+ if (isAbort(cause)) return { kind: "failure", error: { kind: "aborted", target, cause } };
802
+ if (!cause.status) return { kind: "failure", error: { kind: "network", target, cause } };
803
+ return {
804
+ kind: "failure",
805
+ error: {
806
+ kind: "http",
807
+ category: categorizeHttpStatus(cause.status),
808
+ statusCode: cause.status,
809
+ retryAfterMs: parseRetryAfter(cause),
810
+ target,
811
+ cause,
812
+ },
813
+ };
814
+ }
815
+ };
816
+ ```
817
+
818
+ Two behaviours you get for free and probably did not have: a request-shaped
819
+ rejection (a 400, a moderation refusal) traverses to another provider **without**
820
+ opening the breaker, and an empty or truncated completion is retried on the same
821
+ endpoint before any fallback.
822
+
823
+ ### Adapting a non-SDK client to `TurnFn`
824
+
825
+ `TurnFn` is the seam between a loop and any client. If you hand-roll SSE, adapt
826
+ at this boundary and the rest of the harness does not care.
827
+
828
+ ```ts
829
+ const turn: TurnFn = async (messages, tools, signal) => {
830
+ const startedAt = performance.now();
831
+ let ttftMs: number | null = null;
832
+
833
+ const { message, usage } = await streamCompletion({
834
+ messages, tools, signal,
835
+ onFirstToken: () => { ttftMs ??= Math.round(performance.now() - startedAt); },
836
+ });
837
+
838
+ return {
839
+ message,
840
+ usage: {
841
+ inputTokens: usage.prompt_tokens ?? 0,
842
+ outputTokens: usage.completion_tokens ?? 0,
843
+ cachedInputTokens: usage.cached_tokens ?? null,
844
+ costCents: usage.cost != null ? usage.cost * 100 : null,
845
+ },
846
+ timings: { ttftMs, generationMs: Math.round(performance.now() - startedAt) },
847
+ };
848
+ };
849
+ ```
850
+
851
+ ### Accumulating run statistics
852
+
853
+ Fold each turn and each tool call as they complete; `RunStats` keeps model time
854
+ and tool time separate so a slow tool never looks like a slow model.
855
+
856
+ ```ts
857
+ let stats = emptyRunStats();
858
+
859
+ for (const turn of turns) {
860
+ const result = await turnFn(messages, tools, signal);
861
+ stats = accumulateTurn(stats, result);
862
+
863
+ for (const call of result.message.tool_calls ?? []) {
864
+ const startedAt = performance.now();
865
+ await dispatch(call);
866
+ stats = accumulateToolCall(stats, call.function.name, performance.now() - startedAt);
867
+ }
868
+ }
869
+
870
+ await persistRun({ ...stats, stopReason: "done" satisfies StopReason });
871
+ ```
872
+
873
+ ### Batching a turn's tool calls
874
+
875
+ Reads can overlap; writes usually should not. Pool the reads and keep the
876
+ transcript in the model's original call order.
877
+
878
+ ```ts
879
+ const reads = calls.filter((c) => isReadOnly(c));
880
+ const writes = calls.filter((c) => !isReadOnly(c));
881
+
882
+ const readResults = await runToolCallsPooledByTool(reads, (call) => dispatch(call));
883
+
884
+ const writeResults: Outcome[] = [];
885
+ for (const call of writes) writeResults.push(await dispatch(call));
886
+
887
+ // Re-emit in the order the model asked for, so every tool_call_id is answered.
888
+ for (const call of calls) {
889
+ messages.push(toolMessageFor(call, resultFor(call, readResults, writeResults)));
890
+ }
891
+ ```
892
+
893
+ `runToolCallsPooledByTool` returns `PromiseSettledResult`s — a rejection still
894
+ needs a synthesized error tool message, or the next request has an unanswered
895
+ call.
896
+
897
+ ### Pausing a run for human input
898
+
899
+ A tool that needs an answer returns a `suspend` directive on a **successful**
900
+ result. Pick the resume kind by whether the answer becomes the tool's result.
901
+
902
+ ```ts
903
+ // The run resumes later via a prompt; the tool message is kept.
904
+ return {
905
+ success: true,
906
+ data: { askedAt: nowIso },
907
+ suspend: { reason: "awaiting user selection", resumeKind: "wake", request: formSpec },
908
+ };
909
+
910
+ // The human's reply IS this call's tool result; the loop withholds the message.
911
+ return {
912
+ success: true,
913
+ data: null,
914
+ suspend: { reason: "awaiting answer", resumeKind: "answer", request: formSpec },
915
+ };
916
+ ```
917
+
918
+ If your runtime ends the whole run and starts a fresh one on reply, `wake` is
919
+ the kind you want — `answer` only pays off when you thread the reply back into
920
+ the same transcript as the matching `role:"tool"` message.
921
+
922
+ ### Carrying disclosure across a run boundary
923
+
924
+ When a run ends and a later one continues the same conversation, persist the
925
+ **names** and re-validate on the way back in.
926
+
927
+ ```ts
928
+ // End of run: names only, never plugin objects.
929
+ await db.saveActivation(conversationId, [...activePlugins]);
930
+
931
+ // Start of the next run.
932
+ const persisted = await db.loadActivation(conversationId);
933
+ const { active, dropped } = await rehydrateActivation(persisted, {
934
+ canonicalizeName: (n) => registry.canonicalizeName(n),
935
+ resolve: async (n) => (registry.get(n) ? true : "unknown"),
936
+ });
937
+ // Seed core, then add what survived — core plugins load unconditionally.
938
+ const activePlugins = new Set([...initialActivePlugins(registry.corePlugins()), ...active]);
939
+ for (const drop of dropped) log.info("plugin not restored", drop);
940
+ ```
941
+
942
+ Without this, every boundary silently resets the agent to core-only and it
943
+ re-discovers from scratch — which costs a round-trip per resumption.
944
+
945
+ ### Assembling a bounded run
946
+
947
+ The pieces compose; nothing here knows about the others.
948
+
949
+ ```ts
950
+ const deadline = createRunDeadline({ timeoutMs: RUN_BUDGET_MS, label: "run" });
951
+ const heartbeat = createCoalescedHeartbeat({
952
+ coalesceMs: 10_000,
953
+ flush: () => db.touchRun(runId),
954
+ });
955
+ let stats = emptyRunStats();
956
+ let stopReason: StopReason = "done";
957
+
958
+ try {
959
+ for (let i = 0; i < maxIterations; i++) {
960
+ deadline.throwIfTimedOut();
961
+ const { messages: healed } = validateAndHealMessages(transcript);
962
+ const result = await turnFn(healed, wireTools, deadline.withExternal(cancelSignal));
963
+ stats = accumulateTurn(stats, result);
964
+ await heartbeat.beat();
965
+ if (!result.message.tool_calls?.length) break;
966
+ if (await runToolBatch(result.message.tool_calls)) { stopReason = "suspended"; break; }
967
+ }
968
+ } catch (error) {
969
+ stopReason = classifyRunFailure(deadline, error) === "timed_out" ? "deadline" : "aborted";
970
+ } finally {
971
+ await heartbeat.beat({ force: true });
972
+ deadline.dispose();
973
+ }
974
+ ```
975
+
976
+ ### Spawning child runs (sub-agents)
977
+
978
+ A sub-agent is not a special kind of thing. It is **a run that another run
979
+ asked for**, so everything the harness already gives a run applies unchanged:
980
+ its own deadline, its own heartbeat, its own `RunStats`, its own route plan.
981
+ Three things are genuinely new — lineage, admission, and waiting — and
982
+ `@juno-ai/bind/run` owns the decision in each. It owns none of the measuring:
983
+ counting runs in a chain needs your database, and judging whether a count is
984
+ too high does not.
985
+
986
+ **1. The spawn tool is an ordinary plugin.** Nothing special is needed here —
987
+ the tool vocabulary is already shared.
988
+
989
+ ```ts
990
+ const orchestration: ToolPlugin<Ctx> = {
991
+ name: "orchestration",
992
+ description: "Delegate focused work to a child run.",
993
+ icon: "share",
994
+ tools: [
995
+ {
996
+ name: "spawn",
997
+ description: "Run one focused objective as a child and return its id.",
998
+ parameters: spawnArgsSchema, // z.object({ objective: z.string(), plugins: z.array(z.string()) })
999
+ },
1000
+ ],
1001
+ async execute(toolName, args, ctx) {
1002
+ const { objective, plugins } = spawnArgsSchema.parse(args);
1003
+
1004
+ const admission = admitChildRun([
1005
+ { kind: "depth", parentDepth: ctx.chain.depth, maxDepth: 5 },
1006
+ { kind: "chain_budget", runsInChain: await countRuns(ctx.chain), maxRuns: 50 },
1007
+ ]);
1008
+ if (!admission.admitted) {
1009
+ return { success: false, kind: "validation", error: admission.reason };
1010
+ }
1011
+
1012
+ const childRunId = await queue.enqueueRun({
1013
+ objective,
1014
+ plugins,
1015
+ chain: descendChain(ctx.runId, ctx.chain),
1016
+ });
1017
+ return { success: true, data: { childRunId } };
1018
+ },
1019
+ };
1020
+ ```
1021
+
1022
+ **2. Admission is the part worth getting right.** An agent that can spawn can
1023
+ spawn agents that spawn. Bound it *before* enqueuing, not after — an unbounded
1024
+ chain is a runaway spend, and the failure mode is silent.
1025
+
1026
+ Each rule carries its limit **and** the measurement it judges, so a bound you
1027
+ configure but never wired a count for is not expressible. That shape exists
1028
+ because the alternative fails quietly: a bounds object beside a facts object
1029
+ lets a limit sit in config and never fire, and nothing looks wrong.
1030
+
1031
+ ```ts
1032
+ import { admitChildRun, descendChain, type ChainRule } from "@juno-ai/bind/run";
1033
+
1034
+ const rules: ChainRule[] = [
1035
+ { kind: "depth", parentDepth: chain.depth, maxDepth: 5 },
1036
+ { kind: "chain_budget", runsInChain: await countRunsInChain(chain), maxRuns: 50 },
1037
+ { kind: "pair_cooldown", msSinceLastSpawn: await msSinceLastSpawn(runId), cooldownMs: 30_000 },
1038
+ { kind: "tenant_ceiling", activeRuns: await countActiveRuns(tenantId), maxActiveRuns: 200 },
1039
+ ];
1040
+
1041
+ const admission = admitChildRun(rules);
1042
+ if (!admission.admitted) {
1043
+ log.warn("child run refused", { rule: admission.rule });
1044
+ return { success: false, kind: "validation", error: admission.reason };
1045
+ }
1046
+ ```
1047
+
1048
+ Rules are evaluated in order and the first refusal wins, so you choose which
1049
+ reason the model sees. Pick the set against your own cost model: depth caps
1050
+ runaway recursion, a chain budget caps a chain that stays shallow but keeps
1051
+ fanning out, a pair cooldown stops two runs ping-ponging, and a tenant ceiling
1052
+ covers what none of the chain rules can — someone starting a thousand
1053
+ independent chains. A broken measurement (`NaN`, `Infinity`) refuses rather
1054
+ than admits: every comparison is false against `NaN`, so the naive reading
1055
+ would turn a broken count into an unbounded chain.
1056
+
1057
+ `descendChain` handles the lineage arithmetic, including the root-id fallback
1058
+ that is easy to get backwards — a first-generation child adopts its parent's
1059
+ *id* as the chain root, later generations keep the root the parent already
1060
+ carries. Getting that wrong makes every generation its own chain, and every
1061
+ per-chain bound then counts the wrong set and never fires.
1062
+
1063
+ **3. Waiting.** Two shapes work. Suspend the parent and let child completion
1064
+ wake it, which frees the worker slot:
1065
+
1066
+ ```ts
1067
+ return {
1068
+ success: true,
1069
+ data: { childRunIds },
1070
+ suspend: { reason: "awaiting child runs", resumeKind: "wake", request: { childRunIds } },
1071
+ };
1072
+ ```
1073
+
1074
+ …or poll inside the tool, which keeps the parent's transcript intact but holds
1075
+ its slot — so budget the poll well under the parent's own deadline.
1076
+ `createPollSchedule` is a doubling backoff bounded by that budget; it clamps
1077
+ the final delay so a sleep can never overshoot the deadline you promised.
1078
+
1079
+ ```ts
1080
+ import { createPollSchedule } from "@juno-ai/bind/run";
1081
+
1082
+ const startedAt = Date.now();
1083
+ const poll = createPollSchedule({
1084
+ initialDelayMs: 500,
1085
+ maxDelayMs: 30_000,
1086
+ budgetMs: 10 * 60_000,
1087
+ });
1088
+
1089
+ while (true) {
1090
+ const children = await loadChildRuns(childRunIds);
1091
+ if (children.every((c) => c.finished)) return { success: true, data: { children } };
1092
+
1093
+ const step = poll.next(Date.now() - startedAt);
1094
+ if (step.kind === "expired") {
1095
+ return { success: true, data: { children, timedOut: true } };
1096
+ }
1097
+ await sleep(step.delayMs, signal);
1098
+ }
1099
+ ```
1100
+
1101
+ Elapsed time is an argument rather than something the schedule reads off a
1102
+ clock, so a test can drive the whole backoff without waiting for any of it.
1103
+
1104
+ **4. Rolling results up.** `accumulateTurn` and `accumulateToolCall` fold a
1105
+ run's *own* activity; `accumulateRun` folds one whole run into another, which
1106
+ is what a chain's totals are made of.
1107
+
1108
+ ```ts
1109
+ import { accumulateRun, emptyRunStats } from "@juno-ai/bind/contracts";
1110
+
1111
+ const chainTotals = childStats.reduce(accumulateRun, parentStats);
1112
+ ```
1113
+
1114
+ The fold is associative, so a chain reduces in whatever order its children
1115
+ finish. Two properties are worth expecting rather than debugging: `modelTimeMs`
1116
+ will exceed the chain's wall-clock once children run in parallel (the sum is
1117
+ what the chain *cost*, not how long it took), and `outputTokensPerSecond` is
1118
+ recomputed from the merged totals rather than averaged across runs — averaging
1119
+ two rates weights a 10-token run like a 10,000-token one.
1120
+
1121
+ ---
1122
+
1123
+ ## Roadmap
1124
+
1125
+ Named, not scheduled. Listed so a consumer can tell a deliberate omission from
1126
+ an oversight.
1127
+
1128
+ - **One `RunStats` per turn.** The loop accounts usage with `ToolLoopTurn`
1129
+ (tokens and cost) while `ModelTurnResult` carries timings too. They converge
1130
+ when the loop folds `RunStats` directly; today a host that wants throughput
1131
+ metrics accumulates them alongside.
1132
+ - **A streaming turn contract.** `callModel` reports an output-token estimate
1133
+ mid-stream; the assistant message itself still arrives whole.
1134
+
1135
+ ---
1136
+
1137
+ ## Rules for automated contributors
1138
+
1139
+ *Invariants of this package. Read this before changing anything under `src/`;
1140
+ each is enforced by lint, typecheck, or CI where it is developed.*
1141
+
1142
+ 1. **Never import `@/*`, a Node builtin, `process`, or a framework.** Take a
1143
+ port instead. An ESLint block scoped to `packages/bind/**` enforces this.
1144
+ 2. **Never add a runtime dependency.** New third-party code must be a peer
1145
+ dependency, and only with a strong reason. `openai` is type-only.
1146
+ 3. **Keep every module I/O-free.** If a change needs a clock, a random source,
1147
+ the environment, or a network call, take it as an explicit input. A
1148
+ `Date.now` default on an injectable clock is the only sanctioned exception.
1149
+ 4. **Stay deterministic.** Identical inputs must produce identical plans.
1150
+ Nothing about registration order, map iteration, or wall-clock time may
1151
+ reorder candidates.
1152
+ 5. **Do not add module-level mutable state.** Export a factory. Module state is
1153
+ per-isolate on edge runtimes and leaks between tests.
1154
+ 6. **Do not collapse a generic into a concrete host type.** `TCtx` and
1155
+ `TContentPart` are parameters because hosts genuinely diverge there; binding
1156
+ them to one application's types would fork the package.
1157
+ 7. **Version the policy, do not mutate it.** If routing order or meaning
1158
+ changes, bump `ROUTE_POLICY_VERSION`.
1159
+ 8. **Put tests in `src/**/__tests__/`.** They run under bare `bun test` with no
1160
+ DOM and no database. Anything needing either belongs in the host.
1161
+ 9. **Do not edit `version` in `package.json`.** It is a placeholder; the
1162
+ published version is stamped at release time.
1163
+
1164
+ ---
1165
+
1166
+ ## Versioning
1167
+
1168
+ **Versioning is not semver.** Each published release increments the major and
1169
+ resets the rest — `1.0.0`, `2.0.0`, `3.0.0` — so the major is a release counter,
1170
+ not a compatibility signal, and a bump does not by itself mean the surface
1171
+ changed. Pin an exact version and read the changes between releases until this
1172
+ stabilizes.
78
1173
 
79
1174
  ## Development
80
1175
 
81
- This repository is a read-only **archive mirror** of the `packages/bind`
82
- workspace in Monad's canonical repository, exported via Copybara. npm
83
- releases of `@juno-ai/bind` are published from the canonical repository, not
84
- from here the manifest here stays `"private": true` so it cannot be
85
- published by accident, though the version it carries is the one the matching
86
- npm release has. Issues are welcome here; code changes land in the canonical
87
- repo and flow out with the next export.
1176
+ The source lives in Monad's canonical repository. `juno-ai-labs/agent-harness`
1177
+ on GitHub is a read-only **archive mirror** of it, exported via Copybara, and
1178
+ npm releases are published from the canonical repository rather than from either
1179
+ mirror. Issues are welcome on the GitHub mirror; code changes land in the
1180
+ canonical repo and flow out with the next export.
88
1181
 
89
1182
  Run the tests with [Bun](https://bun.sh):
90
1183