@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 +314 -81
- package/completion/defects.d.ts +138 -0
- package/completion/defects.js +128 -0
- package/completion/index.d.ts +2 -0
- package/completion/index.js +2 -0
- package/completion/watchdog.d.ts +165 -0
- package/completion/watchdog.js +211 -0
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +26 -2
- package/contracts/turn.js +45 -0
- package/index.d.ts +13 -7
- package/index.js +13 -7
- package/loop/index.d.ts +1 -0
- package/loop/index.js +1 -0
- package/loop/tool-loop.d.ts +260 -0
- package/loop/tool-loop.js +288 -0
- package/package.json +10 -2
- package/routing/attempt-errors.d.ts +122 -0
- package/routing/attempt-errors.js +176 -0
- package/routing/executor.js +16 -1
- package/routing/index.d.ts +1 -0
- package/routing/index.js +1 -0
- package/run/children.d.ts +204 -0
- package/run/children.js +226 -0
- package/run/index.d.ts +1 -0
- package/run/index.js +1 -0
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
|
|
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
|
|
110
|
-
|
|
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/
|
|
432
|
-
| `@juno-ai/bind/
|
|
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
|
-
|
|
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
|
|
710
|
-
opening the breaker, and an empty or truncated
|
|
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
|
-
|
|
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 =
|
|
896
|
-
{
|
|
897
|
-
|
|
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
|
-
|
|
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.
|
|
918
|
-
your database; the deciding does not.
|
|
1114
|
+
chain is a runaway spend, and the failure mode is silent.
|
|
919
1115
|
|
|
920
|
-
|
|
921
|
-
|
|
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
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
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
|
|
944
|
-
|
|
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
|
|
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
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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
|
-
|
|
975
|
-
|
|
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
|
-
- **
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
-
|
|
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;
|