@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
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification: a thrown transport error → the {@link InferenceAttemptError}
|
|
3
|
+
* taxonomy the executor routes on.
|
|
4
|
+
*
|
|
5
|
+
* `errors.ts` owns what the router *does* with a classified failure
|
|
6
|
+
* (`failureDisposition`) and `executor.ts` applies it. This module is the step
|
|
7
|
+
* before both — the one every host was writing itself. The PRD's split is
|
|
8
|
+
* "transports classify facts; the executor decides," and shipping only the
|
|
9
|
+
* decider left each consumer to hand-roll the classifier, where the mistakes
|
|
10
|
+
* are not obvious: misfile a moderation refusal as a credential failure and the
|
|
11
|
+
* circuit breaker opens the endpoint for everyone sharing that breaker key —
|
|
12
|
+
* which, for a host that does not set `credentialScope`, is every tenant on the
|
|
13
|
+
* process.
|
|
14
|
+
*
|
|
15
|
+
* The host keeps the two things that are genuinely its own — recognizing its
|
|
16
|
+
* error class ({@link AttemptClassification.asTransportFailure}) and any
|
|
17
|
+
* provider-specific status reading ({@link AttemptClassification.categorizeStatus}).
|
|
18
|
+
* Neither can live here: the first is a class this package must not import, and
|
|
19
|
+
* the second is provider-registry knowledge.
|
|
20
|
+
*/
|
|
21
|
+
import type { AttemptTarget, HttpFailureCategory, InferenceAttemptError } from "./errors.js";
|
|
22
|
+
import type { ProviderId } from "./canonical-model.js";
|
|
23
|
+
import type { CompletionDefectKind } from "../completion/defects.js";
|
|
24
|
+
/**
|
|
25
|
+
* What a transport observed, in the vocabulary the taxonomy needs. A host maps
|
|
26
|
+
* its own error class onto this once.
|
|
27
|
+
*
|
|
28
|
+
* `"api"` is the catch-all for a response the provider rejected with a status
|
|
29
|
+
* the transport did not interpret further; it is the only kind whose routing
|
|
30
|
+
* behavior depends on {@link TransportFailure.statusCode}.
|
|
31
|
+
*/
|
|
32
|
+
export type TransportFailureKind = CompletionDefectKind | "server_error" | "rate_limit" | "no_credits" | "api" | "network";
|
|
33
|
+
export interface TransportFailure {
|
|
34
|
+
readonly kind: TransportFailureKind;
|
|
35
|
+
/** HTTP status when the failure carried one; null for transport-level faults. */
|
|
36
|
+
readonly statusCode: number | null;
|
|
37
|
+
/** Parsed `Retry-After`, which bounds-extends the breaker cooldown. */
|
|
38
|
+
readonly retryAfterMs: number | null;
|
|
39
|
+
}
|
|
40
|
+
export interface AttemptClassification {
|
|
41
|
+
/**
|
|
42
|
+
* Recognize the host's own transport-error class and describe it. Return
|
|
43
|
+
* `null` for anything that is not one — those propagate as a client error
|
|
44
|
+
* rather than burning the plan (see {@link classifyAttemptError}).
|
|
45
|
+
*/
|
|
46
|
+
readonly asTransportFailure: (error: unknown) => TransportFailure | null;
|
|
47
|
+
/**
|
|
48
|
+
* Provider-specific status → category, consulted before the neutral
|
|
49
|
+
* {@link categorizeHttpStatus}. Return `null` to fall through to it.
|
|
50
|
+
*
|
|
51
|
+
* This exists because status codes are not portable across providers: one
|
|
52
|
+
* gateway answers 403 for moderation-flagged *input* (request-shaped — a
|
|
53
|
+
* different provider may accept it, and the endpoint is healthy), where the
|
|
54
|
+
* neutral mapping reads 403 as a credential failure and opens the circuit
|
|
55
|
+
* immediately.
|
|
56
|
+
*/
|
|
57
|
+
readonly categorizeStatus?: (statusCode: number, providerId: ProviderId) => HttpFailureCategory | null;
|
|
58
|
+
/**
|
|
59
|
+
* Recognize a caller-cancellation. Defaults to {@link isAbortByName} —
|
|
60
|
+
* `error.name === "AbortError"`, which is what `AbortSignal` and `fetch`
|
|
61
|
+
* produce.
|
|
62
|
+
*
|
|
63
|
+
* **Override this if your SDK wraps aborts in its own class.** The one that
|
|
64
|
+
* bites: `openai`'s `APIUserAbortError` extends its `APIError` and never sets
|
|
65
|
+
* `name`, so `error.name` is the inherited `"Error"` — it matches neither the
|
|
66
|
+
* default nor an `instanceof` check you didn't write. Left unrecognized, a
|
|
67
|
+
* deliberate cancellation classifies as a propagating `client_error`: it
|
|
68
|
+
* still stops the plan, but it is attributed as a fault rather than a
|
|
69
|
+
* cancellation, which pollutes failure telemetry and any breaker or retry
|
|
70
|
+
* accounting keyed off it.
|
|
71
|
+
*
|
|
72
|
+
* ```ts
|
|
73
|
+
* isAbort: (e) => e instanceof OpenAI.APIUserAbortError || isAbortByName(e),
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
readonly isAbort?: (error: unknown) => boolean;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The default abort test: the `name` an `AbortSignal`-driven `fetch` rejection
|
|
80
|
+
* carries. Exported so a host overriding {@link AttemptClassification.isAbort}
|
|
81
|
+
* can widen it rather than replace it.
|
|
82
|
+
*/
|
|
83
|
+
export declare function isAbortByName(error: unknown): boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Map a thrown error onto the attempt-error taxonomy. The original error rides
|
|
86
|
+
* in `cause` and is re-thrown verbatim if the plan exhausts, so a host's error
|
|
87
|
+
* classes and messages survive routing untouched.
|
|
88
|
+
*
|
|
89
|
+
* Order is deliberate. An abort is checked first: a cancelled call must never
|
|
90
|
+
* be reclassified as a provider fault, whatever else is true of it. Anything
|
|
91
|
+
* the host does not recognize as a transport failure is classified
|
|
92
|
+
* `client_error` — which propagates rather than traverses — because an error
|
|
93
|
+
* that escaped the transport without becoming one of its own is a programming
|
|
94
|
+
* defect (a `TypeError`, a validation throw), and burning every provider and
|
|
95
|
+
* the fallback model retrying a bug wastes a whole plan to arrive at the same
|
|
96
|
+
* exception.
|
|
97
|
+
*/
|
|
98
|
+
export declare function classifyAttemptError(error: unknown, target: AttemptTarget, classification: AttemptClassification): InferenceAttemptError;
|
|
99
|
+
/**
|
|
100
|
+
* Parse a `Retry-After` response header into milliseconds, per RFC 9110: either
|
|
101
|
+
* delta-seconds or an HTTP-date. Returns `null` when absent or unparseable —
|
|
102
|
+
* the breaker then falls back to its own cooldown, so a header this cannot read
|
|
103
|
+
* degrades to the default rather than to no cooldown at all.
|
|
104
|
+
*
|
|
105
|
+
* **This value is attacker-influenceable and is not bounded here.** It comes
|
|
106
|
+
* from whatever answered the request — the provider, a gateway, a proxy — and
|
|
107
|
+
* RFC 9110 puts no ceiling on it, so a hostile or malfunctioning upstream can
|
|
108
|
+
* ask for a delay of years. Clamp before using it as a delay:
|
|
109
|
+
* {@link createCircuitBreaker} already does (`maxCooldownMs`), but a host that
|
|
110
|
+
* sleeps on this directly must impose its own bound, or one bad response header
|
|
111
|
+
* parks an endpoint indefinitely.
|
|
112
|
+
*
|
|
113
|
+
* `headers` is deliberately `unknown`: SDKs hand back a `Headers`, a plain
|
|
114
|
+
* object, or a `Map` depending on version and runtime. Anything with a `get`
|
|
115
|
+
* method is asked for the header (covering `Headers` and `Map` without naming
|
|
116
|
+
* either global, which keeps this portable to runtimes that ship neither);
|
|
117
|
+
* a plain object is read case-insensitively for the two spellings that occur
|
|
118
|
+
* in practice.
|
|
119
|
+
*
|
|
120
|
+
* @param now Injectable clock for the HTTP-date branch; defaults to `Date.now`.
|
|
121
|
+
*/
|
|
122
|
+
export declare function retryAfterMsFromHeaders(headers: unknown, now?: () => number): number | null;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Classification: a thrown transport error → the {@link InferenceAttemptError}
|
|
3
|
+
* taxonomy the executor routes on.
|
|
4
|
+
*
|
|
5
|
+
* `errors.ts` owns what the router *does* with a classified failure
|
|
6
|
+
* (`failureDisposition`) and `executor.ts` applies it. This module is the step
|
|
7
|
+
* before both — the one every host was writing itself. The PRD's split is
|
|
8
|
+
* "transports classify facts; the executor decides," and shipping only the
|
|
9
|
+
* decider left each consumer to hand-roll the classifier, where the mistakes
|
|
10
|
+
* are not obvious: misfile a moderation refusal as a credential failure and the
|
|
11
|
+
* circuit breaker opens the endpoint for everyone sharing that breaker key —
|
|
12
|
+
* which, for a host that does not set `credentialScope`, is every tenant on the
|
|
13
|
+
* process.
|
|
14
|
+
*
|
|
15
|
+
* The host keeps the two things that are genuinely its own — recognizing its
|
|
16
|
+
* error class ({@link AttemptClassification.asTransportFailure}) and any
|
|
17
|
+
* provider-specific status reading ({@link AttemptClassification.categorizeStatus}).
|
|
18
|
+
* Neither can live here: the first is a class this package must not import, and
|
|
19
|
+
* the second is provider-registry knowledge.
|
|
20
|
+
*/
|
|
21
|
+
import { categorizeHttpStatus } from "./errors.js";
|
|
22
|
+
/**
|
|
23
|
+
* The default abort test: the `name` an `AbortSignal`-driven `fetch` rejection
|
|
24
|
+
* carries. Exported so a host overriding {@link AttemptClassification.isAbort}
|
|
25
|
+
* can widen it rather than replace it.
|
|
26
|
+
*/
|
|
27
|
+
export function isAbortByName(error) {
|
|
28
|
+
return error instanceof Error && error.name === "AbortError";
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Map a thrown error onto the attempt-error taxonomy. The original error rides
|
|
32
|
+
* in `cause` and is re-thrown verbatim if the plan exhausts, so a host's error
|
|
33
|
+
* classes and messages survive routing untouched.
|
|
34
|
+
*
|
|
35
|
+
* Order is deliberate. An abort is checked first: a cancelled call must never
|
|
36
|
+
* be reclassified as a provider fault, whatever else is true of it. Anything
|
|
37
|
+
* the host does not recognize as a transport failure is classified
|
|
38
|
+
* `client_error` — which propagates rather than traverses — because an error
|
|
39
|
+
* that escaped the transport without becoming one of its own is a programming
|
|
40
|
+
* defect (a `TypeError`, a validation throw), and burning every provider and
|
|
41
|
+
* the fallback model retrying a bug wastes a whole plan to arrive at the same
|
|
42
|
+
* exception.
|
|
43
|
+
*/
|
|
44
|
+
export function classifyAttemptError(error, target, classification) {
|
|
45
|
+
const cause = error instanceof Error ? error : new Error(String(error));
|
|
46
|
+
const isAbort = classification.isAbort ?? isAbortByName;
|
|
47
|
+
if (isAbort(error)) {
|
|
48
|
+
return { kind: "aborted", target, cause };
|
|
49
|
+
}
|
|
50
|
+
const failure = classification.asTransportFailure(error);
|
|
51
|
+
if (failure === null) {
|
|
52
|
+
return {
|
|
53
|
+
kind: "http",
|
|
54
|
+
category: "client_error",
|
|
55
|
+
statusCode: 0,
|
|
56
|
+
retryAfterMs: null,
|
|
57
|
+
target,
|
|
58
|
+
cause,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
switch (failure.kind) {
|
|
62
|
+
case "empty_completion":
|
|
63
|
+
case "truncated_tool_call":
|
|
64
|
+
return { kind: "completion_defect", defect: failure.kind, target, cause };
|
|
65
|
+
case "network":
|
|
66
|
+
return { kind: "network", target, cause };
|
|
67
|
+
case "rate_limit":
|
|
68
|
+
return httpError("rate_limit", failure.statusCode ?? 429, failure, target, cause);
|
|
69
|
+
case "no_credits":
|
|
70
|
+
return httpError("credits", failure.statusCode ?? 402, failure, target, cause);
|
|
71
|
+
case "server_error":
|
|
72
|
+
return httpError("server_error", failure.statusCode ?? 500, failure, target, cause);
|
|
73
|
+
case "api": {
|
|
74
|
+
const statusCode = failure.statusCode;
|
|
75
|
+
// No status at all means the transport could not attribute the rejection
|
|
76
|
+
// to the endpoint; propagate rather than traverse.
|
|
77
|
+
const category = statusCode === null
|
|
78
|
+
? "client_error"
|
|
79
|
+
: (classification.categorizeStatus?.(statusCode, target.providerId) ??
|
|
80
|
+
categorizeHttpStatus(statusCode));
|
|
81
|
+
return httpError(category, statusCode ?? 0, failure, target, cause);
|
|
82
|
+
}
|
|
83
|
+
default: {
|
|
84
|
+
const _exhaustive = failure.kind;
|
|
85
|
+
throw new Error(`unknown transport failure kind: ${JSON.stringify(_exhaustive)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function httpError(category, statusCode, failure, target, cause) {
|
|
90
|
+
return {
|
|
91
|
+
kind: "http",
|
|
92
|
+
category,
|
|
93
|
+
statusCode,
|
|
94
|
+
retryAfterMs: failure.retryAfterMs,
|
|
95
|
+
target,
|
|
96
|
+
cause,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Parse a `Retry-After` response header into milliseconds, per RFC 9110: either
|
|
101
|
+
* delta-seconds or an HTTP-date. Returns `null` when absent or unparseable —
|
|
102
|
+
* the breaker then falls back to its own cooldown, so a header this cannot read
|
|
103
|
+
* degrades to the default rather than to no cooldown at all.
|
|
104
|
+
*
|
|
105
|
+
* **This value is attacker-influenceable and is not bounded here.** It comes
|
|
106
|
+
* from whatever answered the request — the provider, a gateway, a proxy — and
|
|
107
|
+
* RFC 9110 puts no ceiling on it, so a hostile or malfunctioning upstream can
|
|
108
|
+
* ask for a delay of years. Clamp before using it as a delay:
|
|
109
|
+
* {@link createCircuitBreaker} already does (`maxCooldownMs`), but a host that
|
|
110
|
+
* sleeps on this directly must impose its own bound, or one bad response header
|
|
111
|
+
* parks an endpoint indefinitely.
|
|
112
|
+
*
|
|
113
|
+
* `headers` is deliberately `unknown`: SDKs hand back a `Headers`, a plain
|
|
114
|
+
* object, or a `Map` depending on version and runtime. Anything with a `get`
|
|
115
|
+
* method is asked for the header (covering `Headers` and `Map` without naming
|
|
116
|
+
* either global, which keeps this portable to runtimes that ship neither);
|
|
117
|
+
* a plain object is read case-insensitively for the two spellings that occur
|
|
118
|
+
* in practice.
|
|
119
|
+
*
|
|
120
|
+
* @param now Injectable clock for the HTTP-date branch; defaults to `Date.now`.
|
|
121
|
+
*/
|
|
122
|
+
export function retryAfterMsFromHeaders(headers, now = Date.now) {
|
|
123
|
+
const raw = rawRetryAfter(headers);
|
|
124
|
+
if (raw === null || raw === "")
|
|
125
|
+
return null;
|
|
126
|
+
const seconds = Number(raw);
|
|
127
|
+
if (Number.isFinite(seconds)) {
|
|
128
|
+
// A negative delta is malformed, and the date branch below would not
|
|
129
|
+
// reliably reject a bare "-5" either. Reject here instead.
|
|
130
|
+
if (seconds < 0)
|
|
131
|
+
return null;
|
|
132
|
+
// `1e308` is finite but overflows to Infinity once scaled to ms, and a
|
|
133
|
+
// non-finite delay is the one value that makes a timer fire immediately
|
|
134
|
+
// rather than never — the same trap `requirePositiveMs` guards in the
|
|
135
|
+
// watchdog. Refuse it rather than hand a caller a poisoned number.
|
|
136
|
+
const ms = Math.round(seconds * 1000);
|
|
137
|
+
return Number.isFinite(ms) ? ms : null;
|
|
138
|
+
}
|
|
139
|
+
const dateMs = Date.parse(raw);
|
|
140
|
+
if (Number.isFinite(dateMs))
|
|
141
|
+
return Math.max(0, dateMs - now());
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
function rawRetryAfter(headers) {
|
|
145
|
+
if (typeof headers !== "object" || headers === null)
|
|
146
|
+
return null;
|
|
147
|
+
const getter = headers.get;
|
|
148
|
+
if (typeof getter === "function") {
|
|
149
|
+
// The getter belongs to the caller's object, so it is arbitrary code. This
|
|
150
|
+
// runs inside a host's `catch` while classifying a failure; letting a throw
|
|
151
|
+
// escape would replace the real provider error with this one and lose the
|
|
152
|
+
// classification entirely.
|
|
153
|
+
let value;
|
|
154
|
+
try {
|
|
155
|
+
value = getter.call(headers, "retry-after");
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (typeof value === "string")
|
|
161
|
+
return value;
|
|
162
|
+
// A `Map<string, number>` is as plausible as a `Map<string, string>`; the
|
|
163
|
+
// plain-object branch below accepts a number, so this one must too or the
|
|
164
|
+
// same header is read from one container and dropped from the other.
|
|
165
|
+
if (typeof value === "number")
|
|
166
|
+
return String(value);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const record = headers;
|
|
170
|
+
const value = record["retry-after"] ?? record["Retry-After"];
|
|
171
|
+
if (typeof value === "string")
|
|
172
|
+
return value;
|
|
173
|
+
if (typeof value === "number")
|
|
174
|
+
return String(value);
|
|
175
|
+
return null;
|
|
176
|
+
}
|
package/routing/executor.js
CHANGED
|
@@ -42,7 +42,22 @@ export async function executeRoutePlan(options) {
|
|
|
42
42
|
endpointAttempt,
|
|
43
43
|
});
|
|
44
44
|
attemptCount += 1;
|
|
45
|
-
|
|
45
|
+
// `attempt` is host code and is expected to resolve to an outcome, not
|
|
46
|
+
// throw — but if it does throw (a bug in its own classification, a
|
|
47
|
+
// host-supplied port raising), the throw must not carry a half-open
|
|
48
|
+
// probe out of the loop with it. The probe slot would stay occupied for
|
|
49
|
+
// the process's lifetime and every later call for this endpoint would
|
|
50
|
+
// be refused admission, silently, with no failure recorded to ever
|
|
51
|
+
// reopen it.
|
|
52
|
+
let outcome;
|
|
53
|
+
try {
|
|
54
|
+
outcome = await options.attempt(candidate, cursor);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (halfOpenProbe)
|
|
58
|
+
breaker?.releaseProbe(breakerKey);
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
46
61
|
if (outcome.kind === "success") {
|
|
47
62
|
breaker?.recordSuccess(breakerKey);
|
|
48
63
|
return {
|
package/routing/index.d.ts
CHANGED
|
@@ -7,5 +7,6 @@ export { buildRoutePlanWithConfigDegradation, type DegradedStage, type DegradedP
|
|
|
7
7
|
export { computeConfiguredRatesCostCents, type BillingBasisUsage, type BillingBasisResult, } from "./billing-basis.js";
|
|
8
8
|
export { fallbackKindOfCursor } from "./executor.js";
|
|
9
9
|
export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, type RouteAttemptCursor, type AttemptTarget, type HttpFailureCategory, type InferenceAttemptError, type FailureDisposition, type BreakerEffect, } from "./errors.js";
|
|
10
|
+
export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, type AttemptClassification, type TransportFailure, type TransportFailureKind, } from "./attempt-errors.js";
|
|
10
11
|
export { createCircuitBreaker, type BreakerKey, type CircuitBreakerOptions, type EndpointAdmission, type RecordFailureOptions, type RouteCircuitBreaker, } from "./circuit-breaker.js";
|
|
11
12
|
export { executeRoutePlan, type AttemptOutcome, type AttemptFn, type FallbackKind, type ExecutePlanOptions, type RouteExecutionResult, } from "./executor.js";
|
package/routing/index.js
CHANGED
|
@@ -7,5 +7,6 @@ export { buildRoutePlanWithConfigDegradation, } from "./plan-degradation.js";
|
|
|
7
7
|
export { computeConfiguredRatesCostCents, } from "./billing-basis.js";
|
|
8
8
|
export { fallbackKindOfCursor } from "./executor.js";
|
|
9
9
|
export { failureDisposition, categorizeHttpStatus, isRetriableAttemptError, } from "./errors.js";
|
|
10
|
+
export { classifyAttemptError, retryAfterMsFromHeaders, isAbortByName, } from "./attempt-errors.js";
|
|
10
11
|
export { createCircuitBreaker, } from "./circuit-breaker.js";
|
|
11
12
|
export { executeRoutePlan, } from "./executor.js";
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Child runs: lineage, admission, and waiting.
|
|
3
|
+
*
|
|
4
|
+
* A sub-agent is not a special kind of thing — it is **a run that another run
|
|
5
|
+
* asked for**. Everything the harness already gives a run applies to it
|
|
6
|
+
* unchanged: its own deadline, its own heartbeat, its own stats, its own route
|
|
7
|
+
* plan. What is genuinely new is the relationship between runs, and that is
|
|
8
|
+
* what this module owns:
|
|
9
|
+
*
|
|
10
|
+
* - **Lineage** — where a run sits in the chain that produced it.
|
|
11
|
+
* - **Admission** — whether the next child may be created at all.
|
|
12
|
+
* - **Waiting** — how long to sleep before checking on a child again.
|
|
13
|
+
*
|
|
14
|
+
* All three are pure. The queue, the persistence, the counting, and the choice
|
|
15
|
+
* of bounds stay with the host: the harness decides, it does not measure. That
|
|
16
|
+
* split is deliberate — counting runs needs a database, and deciding whether a
|
|
17
|
+
* count is too high does not.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* A run's position in the chain that spawned it.
|
|
21
|
+
*
|
|
22
|
+
* `rootRunId` and `parentRunId` are `null` at depth 0, where the run *is* the
|
|
23
|
+
* root — a chain's origin has no id to point at until its own row exists, so
|
|
24
|
+
* the convention is "null means me". Read the root of any chain as
|
|
25
|
+
* `chain.rootRunId ?? thisRunId`; {@link descendChain} does exactly that when
|
|
26
|
+
* it hands the id down, so every descendant carries a concrete root.
|
|
27
|
+
*/
|
|
28
|
+
export interface ChainRef {
|
|
29
|
+
/** 0 for a run nobody spawned; one more than its parent otherwise. */
|
|
30
|
+
readonly depth: number;
|
|
31
|
+
/** The run that started the chain, or `null` when this run is that run. */
|
|
32
|
+
readonly rootRunId: string | null;
|
|
33
|
+
/** The run that spawned this one, or `null` when nothing did. */
|
|
34
|
+
readonly parentRunId: string | null;
|
|
35
|
+
}
|
|
36
|
+
/** The lineage of a run that nothing spawned — the origin of a new chain. */
|
|
37
|
+
export declare function rootChain(): ChainRef;
|
|
38
|
+
/**
|
|
39
|
+
* The lineage a child of `parentRunId` should carry.
|
|
40
|
+
*
|
|
41
|
+
* The root-id fallback is the part hosts get wrong: a depth-1 child must adopt
|
|
42
|
+
* its parent's *id* as the root (the parent's own `rootRunId` is null), while a
|
|
43
|
+
* depth-2 grandchild must adopt the root the parent already carries. Getting it
|
|
44
|
+
* backwards makes each generation start a fresh chain, which silently defeats
|
|
45
|
+
* every per-chain bound — the counts stay small because they count the wrong
|
|
46
|
+
* set.
|
|
47
|
+
*/
|
|
48
|
+
export declare function descendChain(parentRunId: string, parent: Pick<ChainRef, "depth" | "rootRunId">): ChainRef;
|
|
49
|
+
/**
|
|
50
|
+
* One admission rule, carrying its limit and the measurement it applies to.
|
|
51
|
+
*
|
|
52
|
+
* Limit and fact travel together on purpose. The alternative — a bounds object
|
|
53
|
+
* beside a facts object — lets a host configure a bound whose count was never
|
|
54
|
+
* wired up, and the only symptom is a limit that silently never fires. Here a
|
|
55
|
+
* rule cannot be expressed without the number it judges, so a host pays only
|
|
56
|
+
* for what it actually measures and cannot ask for a bound it does not feed.
|
|
57
|
+
*/
|
|
58
|
+
export type ChainRule =
|
|
59
|
+
/**
|
|
60
|
+
* How deep the chain may go. `parentDepth` is the spawning run's own depth,
|
|
61
|
+
* so the child would sit at `parentDepth + 1`; the rule rejects once that
|
|
62
|
+
* would reach `maxDepth`. A `maxDepth` of 5 therefore permits depths 0
|
|
63
|
+
* through 4 — five runs deep counting the root.
|
|
64
|
+
*/
|
|
65
|
+
{
|
|
66
|
+
kind: "depth";
|
|
67
|
+
parentDepth: number;
|
|
68
|
+
maxDepth: number;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Total runs one chain may produce. Caps the spend of a chain that stays
|
|
72
|
+
* shallow but keeps fanning out, which a depth bound alone does not touch.
|
|
73
|
+
*/
|
|
74
|
+
| {
|
|
75
|
+
kind: "chain_budget";
|
|
76
|
+
runsInChain: number;
|
|
77
|
+
maxRuns: number;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Minimum spacing between spawns from the same parent, which is what stops
|
|
81
|
+
* two runs ping-ponging work at each other. `msSinceLastSpawn` is `null` when
|
|
82
|
+
* this parent has not spawned before — always admitted.
|
|
83
|
+
*/
|
|
84
|
+
| {
|
|
85
|
+
kind: "pair_cooldown";
|
|
86
|
+
msSinceLastSpawn: number | null;
|
|
87
|
+
cooldownMs: number;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* A ceiling on concurrent runs for the whole tenant. Worth having alongside
|
|
91
|
+
* the chain rules: depth and chain budgets constrain one lineage, and neither
|
|
92
|
+
* stops someone starting a thousand independent ones.
|
|
93
|
+
*/
|
|
94
|
+
| {
|
|
95
|
+
kind: "tenant_ceiling";
|
|
96
|
+
activeRuns: number;
|
|
97
|
+
maxActiveRuns: number;
|
|
98
|
+
};
|
|
99
|
+
export type ChildAdmission = {
|
|
100
|
+
admitted: true;
|
|
101
|
+
} | {
|
|
102
|
+
admitted: false;
|
|
103
|
+
/** Which rule refused, for metrics and for branching. */
|
|
104
|
+
rule: ChainRule["kind"];
|
|
105
|
+
/**
|
|
106
|
+
* Could waiting change this answer?
|
|
107
|
+
*
|
|
108
|
+
* The four rules are not the same kind of refusal, and prose alone does
|
|
109
|
+
* not separate them: a cooldown clears on its own, a spent chain budget
|
|
110
|
+
* never does. Without this a model reads "refused" and has to guess
|
|
111
|
+
* between waiting and giving up — and guessing wrong either wastes the
|
|
112
|
+
* run on retries into the same wall or abandons work it could have done
|
|
113
|
+
* a moment later.
|
|
114
|
+
*/
|
|
115
|
+
retryable: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Why, in prose, for the model to read as a tool error. Names the limit,
|
|
118
|
+
* the measurement, and what to do instead — a refusal that reports only
|
|
119
|
+
* the failure invites a retry into the identical wall.
|
|
120
|
+
*/
|
|
121
|
+
reason: string;
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* Decide whether one more child run may be created.
|
|
125
|
+
*
|
|
126
|
+
* Evaluated in the order given, first refusal wins, so a host controls which
|
|
127
|
+
* reason the model sees when several apply. An empty rule list admits — this
|
|
128
|
+
* function bounds what it is given and claims nothing about what it is not.
|
|
129
|
+
*
|
|
130
|
+
* **Call before enqueuing, never after.** A chain that is bounded only once its
|
|
131
|
+
* runs are already queued is not bounded; it is billed.
|
|
132
|
+
*
|
|
133
|
+
* **This is the decision, not the claim.** A counted rule (`chain_budget`,
|
|
134
|
+
* `tenant_ceiling`) bounds only as tightly as the host's count is atomic with
|
|
135
|
+
* the create. Two spawners that read the same count both admit — two replicas,
|
|
136
|
+
* or two spawn calls in one assistant batch, which the tool loop fans out
|
|
137
|
+
* concurrently. If you need the bound to hold under concurrency, take a lock or
|
|
138
|
+
* use a conditional insert around count-then-create; this function cannot see
|
|
139
|
+
* the race and will not tell you about it.
|
|
140
|
+
*
|
|
141
|
+
* A non-finite number anywhere in a rule — the measurement or the limit —
|
|
142
|
+
* refuses rather than admits. Comparisons against `NaN` are always false, so
|
|
143
|
+
* the natural reading of every rule below would silently admit, turning a
|
|
144
|
+
* broken count or a misread config into an unbounded chain. That is the one
|
|
145
|
+
* failure this function exists to prevent, so it fails toward refusing.
|
|
146
|
+
*/
|
|
147
|
+
export declare function admitChildRun(rules: readonly ChainRule[]): ChildAdmission;
|
|
148
|
+
/**
|
|
149
|
+
* What a poller should do next while waiting on a child run.
|
|
150
|
+
*
|
|
151
|
+
* `wait` is already clamped to whatever budget remains, so sleeping for it can
|
|
152
|
+
* never overshoot the deadline — the next call returns `expired` instead.
|
|
153
|
+
*/
|
|
154
|
+
export type PollStep = {
|
|
155
|
+
kind: "wait";
|
|
156
|
+
delayMs: number;
|
|
157
|
+
} | {
|
|
158
|
+
kind: "expired";
|
|
159
|
+
waitedMs: number;
|
|
160
|
+
};
|
|
161
|
+
export interface PollScheduleOptions {
|
|
162
|
+
/** First delay. Clamped to at least `minDelayMs` and at most `maxDelayMs`. */
|
|
163
|
+
readonly initialDelayMs: number;
|
|
164
|
+
/** Ceiling the doubling backoff climbs to. */
|
|
165
|
+
readonly maxDelayMs: number;
|
|
166
|
+
/** Total wall-clock the poll may consume before giving up. */
|
|
167
|
+
readonly budgetMs: number;
|
|
168
|
+
/**
|
|
169
|
+
* Floor on any single delay, defaulting to 50ms — which is the real guard
|
|
170
|
+
* against an `initialDelayMs` of 0 turning the poll into a busy loop.
|
|
171
|
+
*
|
|
172
|
+
* Setting it explicitly *lowers* that guard: the hard floor is 1ms, so
|
|
173
|
+
* `minDelayMs: 0` yields 1ms rather than the default. That is deliberate — a
|
|
174
|
+
* host asking for a sub-50ms poll gets one — but it means passing 0 to mean
|
|
175
|
+
* "no floor" gives you the tightest loop this module allows, not the safest.
|
|
176
|
+
*
|
|
177
|
+
* If this exceeds `maxDelayMs`, the floor wins and the ceiling is raised to
|
|
178
|
+
* match: a delay below the floor would defeat the busy-loop guard, while one
|
|
179
|
+
* above the ceiling only polls less often.
|
|
180
|
+
*/
|
|
181
|
+
readonly minDelayMs?: number;
|
|
182
|
+
}
|
|
183
|
+
export interface PollSchedule {
|
|
184
|
+
/**
|
|
185
|
+
* The next step, given how long the poll has been running. Elapsed time is an
|
|
186
|
+
* argument rather than something read from a clock, which keeps this pure and
|
|
187
|
+
* lets a test drive the whole backoff without waiting for any of it.
|
|
188
|
+
*
|
|
189
|
+
* A schedule carries the backoff state for **one** wait and advances on every
|
|
190
|
+
* call, so it is not shareable between concurrent waiters — build one per
|
|
191
|
+
* wait, which is cheap. Two schedules from identical options are fully
|
|
192
|
+
* independent.
|
|
193
|
+
*/
|
|
194
|
+
next(elapsedMs: number): PollStep;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* A doubling backoff bounded by a total budget.
|
|
198
|
+
*
|
|
199
|
+
* Polling a child run is the alternative to suspending the parent and letting
|
|
200
|
+
* completion wake it. It keeps the parent's transcript intact but holds its
|
|
201
|
+
* worker slot for the duration — so the budget belongs well under the parent's
|
|
202
|
+
* own deadline, or the parent dies waiting instead of reporting what it learned.
|
|
203
|
+
*/
|
|
204
|
+
export declare function createPollSchedule(options: PollScheduleOptions): PollSchedule;
|