@juno-ai/bind 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,8 @@ is; you do not need it to use the package.*
36
36
 
37
37
  ### What a harness is, and what it is not
38
38
 
39
- A harness owns the parts of an agent loop that are the same for everyone:
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
40
41
  deciding which provider to call and what to do when it fails, bounding a run in
41
42
  wall-clock time, keeping a transcript in a shape providers accept, rewriting
42
43
  tool schemas that strict validators reject, and tracking which tools are
@@ -103,11 +104,18 @@ losing it.
103
104
 
104
105
  ### What stays with your application
105
106
 
106
- Transports' request construction and wire-error classification, credentials and
107
+ Transports' request construction and your own error classes, credentials and
107
108
  environment parsing, your routing policy configuration, billing accounting
108
109
  (persistence and charging), inference logging, authorization, prompt rendering,
109
- and run orchestration — the queue a run is scheduled on, and any child runs it
110
- spawns (see [Spawning child runs](#spawning-child-runs-sub-agents)).
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.
111
119
 
112
120
  ---
113
121
 
@@ -246,6 +254,93 @@ progressive tool disclosure with
246
254
  *Goal-oriented. Each answers one question and assumes you know roughly what you
247
255
  are doing.*
248
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
+
249
344
  ### How to pin a model to one provider
250
345
 
251
346
  Use a hard `only` fence. Plan-time filtering and runtime traversal both respect
@@ -284,6 +379,91 @@ should mint its own, so a turn is never unbounded. Handle caller-driven
284
379
  cancellation *before* calling `classifyRunFailure` — a cancellation is not a
285
380
  timeout, and `timedOut` stays false when only a combined external signal fires.
286
381
 
382
+ ### How to stop a stalled stream from hanging forever
383
+
384
+ Your SDK's `timeout` bounds establishing the request, not the gap between
385
+ streamed chunks. Hand the watchdog's signal to the transport, tell it what each
386
+ chunk carried, and ask it afterwards whether it was the one that tore the stream
387
+ down.
388
+
389
+ ```ts
390
+ import { createStreamWatchdog } from "@juno-ai/bind/completion";
391
+
392
+ const watchdog = createStreamWatchdog({ external: cancellationSignal });
393
+ try {
394
+ const stream = await client.chat.completions.create(body, { signal: watchdog.signal });
395
+ watchdog.open(); // arms the first-token budget and the absolute cap
396
+
397
+ for await (const chunk of stream) {
398
+ const delta = chunk.choices?.[0]?.delta;
399
+ // Only ANSWER output arms the tight inter-chunk budget. Reasoning deltas —
400
+ // and the pause between reasoning and the first answer token — must stay on
401
+ // the generous budget, or a reasoning model's normal think-then-answer gap
402
+ // fails healthy turns.
403
+ // Providers disagree on the reasoning field's name, and it is off-spec for
404
+ // the OpenAI types either way — read both spellings.
405
+ const reasoning = delta?.reasoning_content ?? delta?.reasoning;
406
+ watchdog.observedChunk(
407
+ delta?.content || delta?.refusal || delta?.tool_calls
408
+ ? "answer"
409
+ : reasoning
410
+ ? "reasoning"
411
+ : "none",
412
+ );
413
+ // …accumulate…
414
+ }
415
+ } catch (error) {
416
+ const stall = watchdog.stall();
417
+ if (stall !== null) throw new MyRetriableError(describe(stall)); // your wording
418
+ throw error; // a caller abort, or a real transport failure
419
+ } finally {
420
+ watchdog.dispose();
421
+ }
422
+ ```
423
+
424
+ `stall()` returns `null` when your own `external` signal aborted, so a
425
+ cancellation is never reported as a retriable upstream stall. Call it in the
426
+ `catch`, before `dispose()`. Budgets are validated at construction: a `NaN` or
427
+ `Infinity` budget throws rather than silently tearing down every healthy stream
428
+ (`setTimeout` coerces a non-finite delay to ~1ms — it does not disable the
429
+ timer).
430
+
431
+ ### How to map your transport errors onto the routing taxonomy
432
+
433
+ `failureDisposition` decides what the router does with a classified failure, but
434
+ something has to produce the classification. Give `classifyAttemptError` two
435
+ ports — one that recognizes your error class, one that overrides the neutral
436
+ HTTP mapping where your provider disagrees with it — and it does the rest.
437
+
438
+ ```ts
439
+ import { classifyAttemptError, type AttemptClassification } from "@juno-ai/bind/routing";
440
+
441
+ const CLASSIFICATION: AttemptClassification = {
442
+ asTransportFailure: (error) =>
443
+ error instanceof MyLLMError
444
+ ? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
445
+ : null,
446
+ // This gateway answers 403 for moderation-flagged INPUT. The neutral mapping
447
+ // reads 403 as a credential failure and opens the circuit immediately —
448
+ // degrading a healthy shared endpoint for every tenant over one prompt.
449
+ categorizeStatus: (status, providerId) =>
450
+ status === 403 && providerId === MY_GATEWAY ? "provider_bad_request" : null,
451
+ };
452
+
453
+ // `target` is the `AttemptTarget` you assemble in your `attempt` callback from
454
+ // the candidate and cursor it was handed — see the tutorial's step 4.
455
+ const attemptError = classifyAttemptError(error, target, CLASSIFICATION);
456
+ ```
457
+
458
+ An `AbortError` is classified first, whatever else is true of it. Anything
459
+ `asTransportFailure` does not recognize becomes a propagating `client_error`: an
460
+ error that escaped your transport without becoming one of its own is a bug, and
461
+ retrying it against every provider and your fallback model arrives at the same
462
+ exception having spent a whole plan. `retryAfterMsFromHeaders` lives alongside
463
+ it and feeds the `retryAfterMs` the breaker uses to extend a cooldown — clamp
464
+ it before using it as a delay anywhere else, since it is a value the upstream
465
+ chose and RFC 9110 puts no ceiling on it.
466
+
287
467
  ### How to stop a run's progress writes from stampeding
288
468
 
289
469
  ```ts
@@ -427,9 +607,11 @@ keeps a consumer who only wants routing from pulling in the rest.
427
607
 
428
608
  | Import | Owns | Reach for it when |
429
609
  |---|---|---|
430
- | `@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 |
431
- | `@juno-ai/bind/contracts` | Turn vocabulary `TurnFn`, `ModelTurnResult`, `StopReason`, `RunStats` | You want one seam between your loop and any LLM client, and comparable per-run metrics |
432
- | `@juno-ai/bind/run` | Wall-clock deadline, failure classification, coalesced heartbeat, tool-batch pooling | A run must be bounded, observable, and able to say *why* it stopped |
610
+ | `@juno-ai/bind/routing` | Route plans, the planner, the failure taxonomy and the classifier that maps your errors onto it, 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 |
611
+ | `@juno-ai/bind/completion` | Streaming idle watchdog (time-to-first-token, inter-chunk, absolute cap), completion-defect detection, and the structured-output retry predicates | You read a streamed completion |
612
+ | `@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 |
613
+ | `@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 |
614
+ | `@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 |
433
615
  | `@juno-ai/bind/transcript` | `validateAndHealMessages` | You send transcripts to more than one provider, or you build them across turns |
434
616
  | `@juno-ai/bind/tools` | `sanitizeToolSchema` | Any tool schema reaches a provider — especially third-party ones |
435
617
  | `@juno-ai/bind/plugins` | Tool/plugin vocabulary, the registry factory, progressive-disclosure activation | You have more tools than fit comfortably in one prompt |
@@ -462,6 +644,20 @@ tenant's revoked key opens the circuit for every tenant sharing that
462
644
  60 s ceiling including `Retry-After` extensions. Resolve a half-open probe that
463
645
  ended without a recordable outcome via `releaseProbe`, or the slot sticks.
464
646
 
647
+ **Run — a child is admitted before it exists, never after.** `admitChildRun`
648
+ judges counts you supply; call it ahead of enqueuing. A chain bounded only once
649
+ its runs are on the queue is not bounded, it is billed. A rule whose measurement
650
+ is `NaN` or `Infinity` refuses rather than admits — every comparison is false
651
+ against `NaN`, so the naive reading of a broken count is an unbounded chain.
652
+
653
+ **Run — chain lineage is "null means me".** A root run's `rootRunId` and
654
+ `parentRunId` are both `null`, because a chain's origin has no id to point at
655
+ until its own row exists. Read any chain's root as `chain.rootRunId ?? runId`;
656
+ `descendChain` does that when it hands the id down, so every descendant carries
657
+ a concrete root. Adopting the parent's id at *every* level instead makes each
658
+ generation a fresh chain, and every per-chain bound then counts the wrong set
659
+ and silently never fires.
660
+
465
661
  **Run — whoever classifies the outcome owns the deadline.** `timedOut` reflects
466
662
  *that* deadline firing, and stays false when only a combined external signal
467
663
  aborts — which is what separates a timeout from a cancellation. Handle
@@ -675,7 +871,15 @@ transport reports **facts**, the executor decides order.
675
871
  // else if (status === 401) throw;
676
872
  // else tryNextModel();
677
873
 
678
- // After: the transport only classifies.
874
+ // After: the transport only classifies — and `classifyAttemptError` does that
875
+ // for you, so the only thing you write is how to recognize your own error class.
876
+ const CLASSIFICATION: AttemptClassification = {
877
+ asTransportFailure: (error) =>
878
+ error instanceof MyLLMError
879
+ ? { kind: error.kind, statusCode: error.statusCode, retryAfterMs: error.retryAfterMs }
880
+ : null,
881
+ };
882
+
679
883
  const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
680
884
  const startedAt = performance.now();
681
885
  try {
@@ -688,27 +892,23 @@ const attempt: AttemptFn<Completion> = async (candidate, cursor) => {
688
892
  providerInvocationModel: candidate.providerInvocationModel,
689
893
  durationMs: Math.round(performance.now() - startedAt),
690
894
  };
691
- if (isAbort(cause)) return { kind: "failure", error: { kind: "aborted", target, cause } };
692
- if (!cause.status) return { kind: "failure", error: { kind: "network", target, cause } };
693
- return {
694
- kind: "failure",
695
- error: {
696
- kind: "http",
697
- category: categorizeHttpStatus(cause.status),
698
- statusCode: cause.status,
699
- retryAfterMs: parseRetryAfter(cause),
700
- target,
701
- cause,
702
- },
703
- };
895
+ return { kind: "failure", error: classifyAttemptError(cause, target, CLASSIFICATION) };
704
896
  }
705
897
  };
706
898
  ```
707
899
 
900
+ Do not hand-roll that `catch` from the neutral pieces — mapping statuses
901
+ yourself is where the consequential mistakes live. The neutral mapping reads
902
+ **403 as a credential failure**, which opens the breaker immediately; if your
903
+ gateway also answers 403 for a moderation-flagged *prompt*, that one tenant's
904
+ request takes a healthy endpoint out of rotation for everyone sharing the
905
+ breaker key. Supply a `categorizeStatus` port instead — see
906
+ [How to map your transport errors onto the routing taxonomy](#how-to-map-your-transport-errors-onto-the-routing-taxonomy).
907
+
708
908
  Two behaviours you get for free and probably did not have: a request-shaped
709
- rejection (a 400, a moderation refusal) traverses to another provider **without**
710
- opening the breaker, and an empty or truncated completion is retried on the same
711
- endpoint before any fallback.
909
+ rejection (a 400, a correctly-classified moderation refusal) traverses to
910
+ another provider **without** opening the breaker, and an empty or truncated
911
+ completion is retried on the same endpoint before any fallback.
712
912
 
713
913
  ### Adapting a non-SDK client to `TurnFn`
714
914
 
@@ -865,14 +1065,13 @@ try {
865
1065
 
866
1066
  ### Spawning child runs (sub-agents)
867
1067
 
868
- > **Not in the harness yet.** There is no sub-agent, spawn, or child-run concept
869
- > in `bind` today — the vocabulary below is yours to define. The admission
870
- > policy is a planned addition; see [Roadmap](#roadmap).
871
-
872
1068
  A sub-agent is not a special kind of thing. It is **a run that another run
873
1069
  asked for**, so everything the harness already gives a run applies unchanged:
874
1070
  its own deadline, its own heartbeat, its own `RunStats`, its own route plan.
875
- Only three things are genuinely new, and all three are currently yours.
1071
+ Three things are genuinely new — lineage, admission, and waiting and
1072
+ `@juno-ai/bind/run` owns the decision in each. It owns none of the measuring:
1073
+ counting runs in a chain needs your database, and judging whether a count is
1074
+ too high does not.
876
1075
 
877
1076
  **1. The spawn tool is an ordinary plugin.** Nothing special is needed here —
878
1077
  the tool vocabulary is already shared.
@@ -892,10 +1091,10 @@ const orchestration: ToolPlugin<Ctx> = {
892
1091
  async execute(toolName, args, ctx) {
893
1092
  const { objective, plugins } = spawnArgsSchema.parse(args);
894
1093
 
895
- const admission = await admitChildRun(
896
- { depth: ctx.chainDepth, chainId: ctx.chainId, parentKey: ctx.runKey },
897
- BOUNDS,
898
- );
1094
+ const admission = admitChildRun([
1095
+ { kind: "depth", parentDepth: ctx.chain.depth, maxDepth: 5 },
1096
+ { kind: "chain_budget", runsInChain: await countRuns(ctx.chain), maxRuns: 50 },
1097
+ ]);
899
1098
  if (!admission.admitted) {
900
1099
  return { success: false, kind: "validation", error: admission.reason };
901
1100
  }
@@ -903,9 +1102,7 @@ const orchestration: ToolPlugin<Ctx> = {
903
1102
  const childRunId = await queue.enqueueRun({
904
1103
  objective,
905
1104
  plugins,
906
- chainId: ctx.chainId,
907
- depth: ctx.chainDepth + 1,
908
- parentRunId: ctx.runId,
1105
+ chain: descendChain(ctx.runId, ctx.chain),
909
1106
  });
910
1107
  return { success: true, data: { childRunId } };
911
1108
  },
@@ -914,34 +1111,47 @@ const orchestration: ToolPlugin<Ctx> = {
914
1111
 
915
1112
  **2. Admission is the part worth getting right.** An agent that can spawn can
916
1113
  spawn agents that spawn. Bound it *before* enqueuing, not after — an unbounded
917
- chain is a runaway spend, and the failure mode is silent. The counting needs
918
- your database; the deciding does not.
1114
+ chain is a runaway spend, and the failure mode is silent.
919
1115
 
920
- ```ts
921
- type Admission = { admitted: true } | { admitted: false; reason: string };
1116
+ Each rule carries its limit **and** the measurement it judges, so a bound you
1117
+ configure but never wired a count for is not expressible. That shape exists
1118
+ because the alternative fails quietly: a bounds object beside a facts object
1119
+ lets a limit sit in config and never fire, and nothing looks wrong.
922
1120
 
923
- async function admitChildRun(chain: Chain, bounds: Bounds): Promise<Admission> {
924
- if (chain.depth >= bounds.maxDepth) {
925
- return { admitted: false, reason: `chain depth limit reached (${chain.depth}/${bounds.maxDepth})` };
926
- }
927
- if (await countRunsInChain(chain.chainId) >= bounds.maxRunsPerChain) {
928
- return { admitted: false, reason: "chain run budget exhausted" };
929
- }
930
- if (Date.now() - (await lastSpawnBetween(chain.parentKey)) < bounds.pairCooldownMs) {
931
- return { admitted: false, reason: "spawn cooldown active" };
932
- }
933
- return { admitted: true };
1121
+ ```ts
1122
+ import { admitChildRun, descendChain, type ChainRule } from "@juno-ai/bind/run";
1123
+
1124
+ const rules: ChainRule[] = [
1125
+ { kind: "depth", parentDepth: chain.depth, maxDepth: 5 },
1126
+ { kind: "chain_budget", runsInChain: await countRunsInChain(chain), maxRuns: 50 },
1127
+ { kind: "pair_cooldown", msSinceLastSpawn: await msSinceLastSpawn(runId), cooldownMs: 30_000 },
1128
+ { kind: "tenant_ceiling", activeRuns: await countActiveRuns(tenantId), maxActiveRuns: 200 },
1129
+ ];
1130
+
1131
+ const admission = admitChildRun(rules);
1132
+ if (!admission.admitted) {
1133
+ log.warn("child run refused", { rule: admission.rule });
1134
+ return { success: false, kind: "validation", error: admission.reason };
934
1135
  }
935
1136
  ```
936
1137
 
937
- Pick bounds against your own cost model. Depth caps runaway recursion; a
938
- per-chain run budget caps total spend once a chain starts; a pair cooldown stops
939
- two agents ping-ponging. A separate per-tenant ceiling on *directly triggered*
940
- runs is worth having too depth limits do not constrain someone starting a
941
- thousand independent runs.
1138
+ Rules are evaluated in order and the first refusal wins, so you choose which
1139
+ reason the model sees. Pick the set against your own cost model: depth caps
1140
+ runaway recursion, a chain budget caps a chain that stays shallow but keeps
1141
+ fanning out, a pair cooldown stops two runs ping-ponging, and a tenant ceiling
1142
+ covers what none of the chain rules can — someone starting a thousand
1143
+ independent chains. A broken measurement (`NaN`, `Infinity`) refuses rather
1144
+ than admits: every comparison is false against `NaN`, so the naive reading
1145
+ would turn a broken count into an unbounded chain.
1146
+
1147
+ `descendChain` handles the lineage arithmetic, including the root-id fallback
1148
+ that is easy to get backwards — a first-generation child adopts its parent's
1149
+ *id* as the chain root, later generations keep the root the parent already
1150
+ carries. Getting that wrong makes every generation its own chain, and every
1151
+ per-chain bound then counts the wrong set and never fires.
942
1152
 
943
- **3. Waiting, and rolling results up.** Two shapes work. Suspend the parent and
944
- let child completion wake it, which frees the worker slot:
1153
+ **3. Waiting.** Two shapes work. Suspend the parent and let child completion
1154
+ wake it, which frees the worker slot:
945
1155
 
946
1156
  ```ts
947
1157
  return {
@@ -952,27 +1162,51 @@ return {
952
1162
  ```
953
1163
 
954
1164
  …or poll inside the tool, which keeps the parent's transcript intact but holds
955
- its slot — so cap the poll well under the parent's deadline.
1165
+ its slot — so budget the poll well under the parent's own deadline.
1166
+ `createPollSchedule` is a doubling backoff bounded by that budget; it clamps
1167
+ the final delay so a sleep can never overshoot the deadline you promised.
956
1168
 
957
- For accounting, note that `accumulateTurn` and `accumulateToolCall` fold a
958
- run's *own* activity; there is no built-in roll-up across child runs. Sum them
959
- yourself if a chain's total cost is a number you report:
1169
+ ```ts
1170
+ import { createPollSchedule } from "@juno-ai/bind/run";
1171
+
1172
+ const startedAt = Date.now();
1173
+ const poll = createPollSchedule({
1174
+ initialDelayMs: 500,
1175
+ maxDelayMs: 30_000,
1176
+ budgetMs: 10 * 60_000,
1177
+ });
1178
+
1179
+ while (true) {
1180
+ const children = await loadChildRuns(childRunIds);
1181
+ if (children.every((c) => c.finished)) return { success: true, data: { children } };
1182
+
1183
+ const step = poll.next(Date.now() - startedAt);
1184
+ if (step.kind === "expired") {
1185
+ return { success: true, data: { children, timedOut: true } };
1186
+ }
1187
+ await sleep(step.delayMs, signal);
1188
+ }
1189
+ ```
1190
+
1191
+ Elapsed time is an argument rather than something the schedule reads off a
1192
+ clock, so a test can drive the whole backoff without waiting for any of it.
1193
+
1194
+ **4. Rolling results up.** `accumulateTurn` and `accumulateToolCall` fold a
1195
+ run's *own* activity; `accumulateRun` folds one whole run into another, which
1196
+ is what a chain's totals are made of.
960
1197
 
961
1198
  ```ts
962
- const chainTotals = [parentStats, ...childStats].reduce((acc, s) => ({
963
- ...acc,
964
- turns: acc.turns + s.turns,
965
- toolCalls: acc.toolCalls + s.toolCalls,
966
- inputTokens: acc.inputTokens + s.inputTokens,
967
- outputTokens: acc.outputTokens + s.outputTokens,
968
- costCents: acc.costCents + s.costCents,
969
- modelTimeMs: acc.modelTimeMs + s.modelTimeMs,
970
- toolTimeMs: acc.toolTimeMs + s.toolTimeMs,
971
- }), emptyRunStats());
1199
+ import { accumulateRun, emptyRunStats } from "@juno-ai/bind/contracts";
1200
+
1201
+ const chainTotals = childStats.reduce(accumulateRun, parentStats);
972
1202
  ```
973
1203
 
974
- Model time will exceed wall-clock once children run in parallel that is
975
- correct, and it is why the two are tracked separately.
1204
+ The fold is associative, so a chain reduces in whatever order its children
1205
+ finish. Two properties are worth expecting rather than debugging: `modelTimeMs`
1206
+ will exceed the chain's wall-clock once children run in parallel (the sum is
1207
+ what the chain *cost*, not how long it took), and `outputTokensPerSecond` is
1208
+ recomputed from the merged totals rather than averaged across runs — averaging
1209
+ two rates weights a 10-token run like a 10,000-token one.
976
1210
 
977
1211
  ---
978
1212
 
@@ -981,13 +1215,12 @@ correct, and it is why the two are tracked separately.
981
1215
  Named, not scheduled. Listed so a consumer can tell a deliberate omission from
982
1216
  an oversight.
983
1217
 
984
- - **The turn kernel** `runAgent` / `advanceTurn` over `TurnFn`. The loop
985
- itself is still yours.
986
- - **Sub-agent admission** the depth / chain-budget / cooldown policy
987
- sketched [above](#spawning-child-runs-sub-agents), as a pure decision over
988
- injected counts. The queue, the persistence, and the bounds stay yours.
989
- - **Child-run stats roll-up** an `accumulateRun` fold so a chain's totals do
990
- not have to be summed by hand.
1218
+ - **One `RunStats` per turn.** The loop accounts usage with `ToolLoopTurn`
1219
+ (tokens and cost) while `ModelTurnResult` carries timings too. They converge
1220
+ when the loop folds `RunStats` directly; today a host that wants throughput
1221
+ metrics accumulates them alongside.
1222
+ - **A streaming turn contract.** `callModel` reports an output-token estimate
1223
+ mid-stream; the assistant message itself still arrives whole.
991
1224
 
992
1225
  ---
993
1226
 
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Transient defects in an assembled completion, and the retry predicates that
3
+ * decide whether to ask the same endpoint again.
4
+ *
5
+ * A completion can come back structurally intact at the transport layer and
6
+ * still be unusable: the provider cut the stream mid-arguments, or closed it
7
+ * having emitted nothing at all. Neither is an HTTP failure — there is no
8
+ * status code to classify — so nothing upstream in the routing taxonomy sees
9
+ * them. Left undetected they reach the agent loop, where a truncated tool call
10
+ * is rejected as "invalid JSON in tool arguments" and burns a whole recovery
11
+ * turn on something a plain retry fixes.
12
+ *
13
+ * The two defect kinds here are exactly the two that feed
14
+ * `InferenceAttemptError`'s `completion_defect` arm, which the disposition
15
+ * matrix routes to a same-endpoint retry before traversing providers.
16
+ *
17
+ * Everything in this module is pure. Types are structural rather than tied to
18
+ * any SDK's message class, so a host assembling chunks by hand and a host
19
+ * handing over an `openai` message both fit without a cast.
20
+ */
21
+ /**
22
+ * Additional attempts when a structured-output call returns content that will
23
+ * not parse as JSON: the initial call plus this many retries. Providers
24
+ * occasionally truncate or malform JSON even under a strict schema, and a fresh
25
+ * attempt almost always comes back valid.
26
+ */
27
+ export declare const DEFAULT_STRUCTURED_OUTPUT_MAX_RETRIES = 3;
28
+ /**
29
+ * Same-endpoint retry budget for a transiently-corrupt completion. A fresh
30
+ * attempt on the SAME endpoint usually recovers an intact response; a
31
+ * persistently-broken endpoint exhausts these, and the route executor then
32
+ * traverses to the next provider or model.
33
+ */
34
+ export declare const DEFAULT_COMPLETION_DEFECT_MAX_RETRIES = 2;
35
+ /** The part of a streamed tool call this module reads. */
36
+ export interface StreamedToolCall {
37
+ readonly function: {
38
+ readonly name: string;
39
+ readonly arguments: string;
40
+ };
41
+ }
42
+ /**
43
+ * The outputs of an assembled completion. `tool_calls` is `unknown[]` here
44
+ * because the checks that only need "did the model call a tool" must accept a
45
+ * host's full SDK union (which may include non-function tool calls);
46
+ * {@link detectCompletionDefect} narrows it where it actually reads arguments.
47
+ */
48
+ export interface CompletionOutputs {
49
+ readonly content: string | null;
50
+ readonly refusal?: string | null | undefined;
51
+ readonly tool_calls?: readonly unknown[] | undefined;
52
+ }
53
+ /** {@link CompletionOutputs} with tool calls narrowed to the readable shape. */
54
+ export interface AssembledCompletion<TToolCall extends StreamedToolCall = StreamedToolCall> extends CompletionOutputs {
55
+ readonly tool_calls?: readonly TToolCall[] | undefined;
56
+ }
57
+ /**
58
+ * The defect vocabulary, named once so the routing taxonomy can reference it
59
+ * rather than re-spelling the same two literals. `InferenceAttemptError`'s
60
+ * `completion_defect` arm carries exactly these, and a third kind added here
61
+ * must widen that arm too — which it will, by type error, only because both
62
+ * sides read this one declaration.
63
+ */
64
+ export type CompletionDefectKind = "empty_completion" | "truncated_tool_call";
65
+ export type CompletionDefect<TToolCall extends StreamedToolCall = StreamedToolCall> =
66
+ /** The provider closed the stream having emitted no content, tool call, or refusal. */
67
+ Readonly<{
68
+ kind: "empty_completion";
69
+ }>
70
+ /** A tool call whose arguments never arrived intact. */
71
+ | Readonly<{
72
+ kind: "truncated_tool_call";
73
+ toolCall: TToolCall;
74
+ }>;
75
+ /** True when `s` parses as JSON. */
76
+ export declare function jsonParses(s: string): boolean;
77
+ /**
78
+ * Find the transient defect in an assembled completion, or `null` when it is
79
+ * usable.
80
+ *
81
+ * **Empty completion.** No content, no tool calls, no refusal. A refusal alone
82
+ * is a legitimate output and is not a defect.
83
+ *
84
+ * **Truncated tool call.** Non-empty arguments that do not parse as JSON are
85
+ * always a truncation — the provider cut the stream mid-arguments. *Empty*
86
+ * arguments are the normal zero-arg shape and are not a defect **unless** the
87
+ * stream was `cutByTokenLimit`: a length-truncated empty-args call is an
88
+ * incomplete message, not a deliberate no-arg call, and exempting it would let
89
+ * a dispatcher execute a tool off a half-streamed turn.
90
+ *
91
+ * `cutByTokenLimit` is the *fact*, not the wire spelling of it — OpenAI says
92
+ * `finish_reason: "length"`, Anthropic says `max_tokens`, Gemini says
93
+ * `MAX_TOKENS`. Taking the fact keeps the one behavioural rule in this module
94
+ * from silently reading `false` for every host that isn't on an
95
+ * OpenAI-compatible gateway, which would apply the zero-arg exemption to
96
+ * exactly the truncated calls it exists to exclude.
97
+ *
98
+ * A tool call whose `arguments` are not a readable string is skipped rather
99
+ * than inspected: {@link CompletionOutputs.tool_calls} accepts a host's full
100
+ * SDK union, and a non-function member (OpenAI's `type: "custom"`) has no
101
+ * arguments to truncate.
102
+ */
103
+ export declare function detectCompletionDefect<TToolCall extends StreamedToolCall>(completion: AssembledCompletion<TToolCall>, cutByTokenLimit: boolean): CompletionDefect<TToolCall> | null;
104
+ /**
105
+ * The part of a request's `response_format` that decides whether output is JSON
106
+ * we can validate by parsing. Structural so any SDK's type fits.
107
+ *
108
+ * The two JSON spellings are the OpenAI-compatible wire values. The open
109
+ * `(string & {})` arm keeps any other value assignable — a gateway with its own
110
+ * vocabulary is not a type error — while still letting an editor complete the
111
+ * two that {@link expectsJsonOutput} actually recognizes, so a near-miss like
112
+ * `"json"` is visible at the call site rather than silently falling through to
113
+ * "no JSON expected".
114
+ */
115
+ export interface ResponseFormatShape {
116
+ readonly type?: "text" | "json_object" | "json_schema" | (string & {}) | undefined;
117
+ }
118
+ /**
119
+ * True when `response_format` asks the model for JSON. Free-form output
120
+ * (`text`, or omitted) is never retried on a parse failure — there is nothing
121
+ * to parse, so every completion would look like a defect.
122
+ */
123
+ export declare function expectsJsonOutput(responseFormat: ResponseFormatShape | null | undefined): boolean;
124
+ /**
125
+ * Whether a structured-output completion holds parseable JSON.
126
+ *
127
+ * Tool calls and refusals are valid non-JSON outcomes and pass. A wholly empty
128
+ * completion is a {@link detectCompletionDefect} concern, but a *whitespace-only*
129
+ * one is truthy there and slips through — so this deliberately does NOT
130
+ * short-circuit on empty content: an empty or whitespace string is not valid
131
+ * JSON, fails the parse, and earns a retry.
132
+ *
133
+ * The ``` / ```json fences some models wrap JSON in are stripped first,
134
+ * mirroring what structured consumers do before parsing. A bare `true` / `false`
135
+ * is itself valid JSON, so a model that answers a boolean schema with the bare
136
+ * literal still passes; only genuinely malformed or truncated JSON fails.
137
+ */
138
+ export declare function structuredOutputParses(completion: CompletionOutputs): boolean;