@juno-ai/bind 2.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1153 -60
- package/contracts/index.d.ts +1 -1
- package/contracts/index.js +1 -1
- package/contracts/turn.d.ts +31 -7
- package/contracts/turn.js +45 -0
- package/index.d.ts +16 -5
- package/index.js +16 -5
- 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 +276 -0
- package/package.json +22 -2
- package/plugins/activation.d.ts +67 -0
- package/plugins/activation.js +61 -0
- package/plugins/index.d.ts +3 -0
- package/plugins/index.js +3 -0
- package/plugins/registry.d.ts +52 -0
- package/plugins/registry.js +54 -0
- package/plugins/tool.d.ts +164 -0
- package/plugins/tool.js +9 -0
- package/routing/billing-basis.d.ts +48 -0
- package/routing/billing-basis.js +67 -0
- package/routing/circuit-breaker.d.ts +2 -2
- package/routing/errors.d.ts +1 -1
- package/routing/executor.d.ts +3 -3
- package/routing/executor.js +1 -1
- package/routing/index.d.ts +11 -9
- package/routing/index.js +11 -9
- package/routing/plan-degradation.d.ts +34 -0
- package/routing/plan-degradation.js +38 -0
- package/routing/plan.d.ts +2 -2
- package/routing/planner.d.ts +4 -4
- package/routing/planner.js +1 -1
- package/routing/policy.d.ts +1 -1
- package/routing/policy.js +1 -1
- package/routing/transport.d.ts +2 -2
- package/run/children.d.ts +204 -0
- package/run/children.js +226 -0
- package/run/harness.d.ts +94 -0
- package/run/harness.js +140 -0
- package/run/index.d.ts +3 -0
- package/run/index.js +3 -0
- package/run/tool-batch.d.ts +16 -0
- package/run/tool-batch.js +83 -0
- package/tools/index.d.ts +1 -0
- package/tools/index.js +1 -0
- package/tools/sanitize-schema.d.ts +150 -0
- package/tools/sanitize-schema.js +683 -0
- package/transcript/index.d.ts +1 -0
- package/transcript/index.js +1 -0
- package/transcript/validate.d.ts +54 -0
- package/transcript/validate.js +226 -0
|
@@ -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;
|
package/run/children.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
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
|
+
/** The lineage of a run that nothing spawned — the origin of a new chain. */
|
|
20
|
+
export function rootChain() {
|
|
21
|
+
return { depth: 0, rootRunId: null, parentRunId: null };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The lineage a child of `parentRunId` should carry.
|
|
25
|
+
*
|
|
26
|
+
* The root-id fallback is the part hosts get wrong: a depth-1 child must adopt
|
|
27
|
+
* its parent's *id* as the root (the parent's own `rootRunId` is null), while a
|
|
28
|
+
* depth-2 grandchild must adopt the root the parent already carries. Getting it
|
|
29
|
+
* backwards makes each generation start a fresh chain, which silently defeats
|
|
30
|
+
* every per-chain bound — the counts stay small because they count the wrong
|
|
31
|
+
* set.
|
|
32
|
+
*/
|
|
33
|
+
export function descendChain(parentRunId,
|
|
34
|
+
// Only what the arithmetic reads, so a host storing lineage as flat columns
|
|
35
|
+
// can pass the two it has rather than assembling a whole `ChainRef` around a
|
|
36
|
+
// field this never looks at.
|
|
37
|
+
parent) {
|
|
38
|
+
return {
|
|
39
|
+
depth: parent.depth + 1,
|
|
40
|
+
rootRunId: parent.rootRunId ?? parentRunId,
|
|
41
|
+
parentRunId,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether one more child run may be created.
|
|
46
|
+
*
|
|
47
|
+
* Evaluated in the order given, first refusal wins, so a host controls which
|
|
48
|
+
* reason the model sees when several apply. An empty rule list admits — this
|
|
49
|
+
* function bounds what it is given and claims nothing about what it is not.
|
|
50
|
+
*
|
|
51
|
+
* **Call before enqueuing, never after.** A chain that is bounded only once its
|
|
52
|
+
* runs are already queued is not bounded; it is billed.
|
|
53
|
+
*
|
|
54
|
+
* **This is the decision, not the claim.** A counted rule (`chain_budget`,
|
|
55
|
+
* `tenant_ceiling`) bounds only as tightly as the host's count is atomic with
|
|
56
|
+
* the create. Two spawners that read the same count both admit — two replicas,
|
|
57
|
+
* or two spawn calls in one assistant batch, which the tool loop fans out
|
|
58
|
+
* concurrently. If you need the bound to hold under concurrency, take a lock or
|
|
59
|
+
* use a conditional insert around count-then-create; this function cannot see
|
|
60
|
+
* the race and will not tell you about it.
|
|
61
|
+
*
|
|
62
|
+
* A non-finite number anywhere in a rule — the measurement or the limit —
|
|
63
|
+
* refuses rather than admits. Comparisons against `NaN` are always false, so
|
|
64
|
+
* the natural reading of every rule below would silently admit, turning a
|
|
65
|
+
* broken count or a misread config into an unbounded chain. That is the one
|
|
66
|
+
* failure this function exists to prevent, so it fails toward refusing.
|
|
67
|
+
*/
|
|
68
|
+
export function admitChildRun(rules) {
|
|
69
|
+
for (const rule of rules) {
|
|
70
|
+
const refusal = evaluateRule(rule);
|
|
71
|
+
if (refusal !== null)
|
|
72
|
+
return refusal;
|
|
73
|
+
}
|
|
74
|
+
return { admitted: true };
|
|
75
|
+
}
|
|
76
|
+
function evaluateRule(rule) {
|
|
77
|
+
switch (rule.kind) {
|
|
78
|
+
case "depth": {
|
|
79
|
+
const broken = unmeasurable(rule, rule.parentDepth, "parentDepth") ??
|
|
80
|
+
unmeasurable(rule, rule.maxDepth, "maxDepth");
|
|
81
|
+
if (broken)
|
|
82
|
+
return broken;
|
|
83
|
+
const childDepth = rule.parentDepth + 1;
|
|
84
|
+
return childDepth >= rule.maxDepth
|
|
85
|
+
? refuse(rule, false, `chain depth limit reached (${childDepth}/${rule.maxDepth}) — ` +
|
|
86
|
+
`this is as deep as the chain may go, so do the work in this run ` +
|
|
87
|
+
`instead of spawning`)
|
|
88
|
+
: null;
|
|
89
|
+
}
|
|
90
|
+
case "chain_budget": {
|
|
91
|
+
const broken = unmeasurable(rule, rule.runsInChain, "runsInChain") ??
|
|
92
|
+
unmeasurable(rule, rule.maxRuns, "maxRuns");
|
|
93
|
+
if (broken)
|
|
94
|
+
return broken;
|
|
95
|
+
return rule.runsInChain >= rule.maxRuns
|
|
96
|
+
? refuse(rule, false, `chain run budget exhausted (${rule.runsInChain}/${rule.maxRuns}) — ` +
|
|
97
|
+
`the budget is spent for this whole chain and does not refill, so ` +
|
|
98
|
+
`do the work in this run instead of spawning`)
|
|
99
|
+
: null;
|
|
100
|
+
}
|
|
101
|
+
case "pair_cooldown": {
|
|
102
|
+
if (rule.msSinceLastSpawn === null)
|
|
103
|
+
return null;
|
|
104
|
+
const broken = unmeasurable(rule, rule.msSinceLastSpawn, "msSinceLastSpawn") ??
|
|
105
|
+
unmeasurable(rule, rule.cooldownMs, "cooldownMs");
|
|
106
|
+
if (broken)
|
|
107
|
+
return broken;
|
|
108
|
+
return rule.msSinceLastSpawn < rule.cooldownMs
|
|
109
|
+
? refuse(rule, true, `spawn cooldown active (${rule.msSinceLastSpawn}ms since the last ` +
|
|
110
|
+
`spawn, ${rule.cooldownMs}ms required) — retry in ` +
|
|
111
|
+
`${rule.cooldownMs - rule.msSinceLastSpawn}ms, or do the work in ` +
|
|
112
|
+
`this run`)
|
|
113
|
+
: null;
|
|
114
|
+
}
|
|
115
|
+
case "tenant_ceiling": {
|
|
116
|
+
const broken = unmeasurable(rule, rule.activeRuns, "activeRuns") ??
|
|
117
|
+
unmeasurable(rule, rule.maxActiveRuns, "maxActiveRuns");
|
|
118
|
+
if (broken)
|
|
119
|
+
return broken;
|
|
120
|
+
return rule.activeRuns >= rule.maxActiveRuns
|
|
121
|
+
? refuse(rule, true, `concurrent run ceiling reached (${rule.activeRuns}/` +
|
|
122
|
+
`${rule.maxActiveRuns}) — this clears as other runs finish, so ` +
|
|
123
|
+
`retry shortly or do the work in this run`)
|
|
124
|
+
: null;
|
|
125
|
+
}
|
|
126
|
+
default: {
|
|
127
|
+
const _exhaustive = rule;
|
|
128
|
+
throw new Error(`Unhandled chain rule: ${JSON.stringify(_exhaustive)}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const refuse = (rule, retryable, reason) => ({
|
|
133
|
+
admitted: false,
|
|
134
|
+
rule: rule.kind,
|
|
135
|
+
retryable,
|
|
136
|
+
reason,
|
|
137
|
+
});
|
|
138
|
+
/**
|
|
139
|
+
* Every number a rule carries must be a finite, non-negative count — the
|
|
140
|
+
* measurement AND the limit.
|
|
141
|
+
*
|
|
142
|
+
* The limit matters as much as the count and for the same reason: every
|
|
143
|
+
* comparison below is `<` or `>=`, both false against `NaN`, so a `maxDepth`
|
|
144
|
+
* that arrived as `NaN` (a bad config read, an `undefined` coerced by
|
|
145
|
+
* arithmetic) leaves the chain unbounded with nothing to show for it.
|
|
146
|
+
*
|
|
147
|
+
* Negatives are rejected on the same grounds rather than as a style rule. No
|
|
148
|
+
* depth, count, or elapsed span can legitimately be below zero, so a negative
|
|
149
|
+
* means the host's bookkeeping is wrong — and the result is not a *stricter*
|
|
150
|
+
* bound but no bound at all: `runsInChain: -1` against `maxRuns: 10` admits,
|
|
151
|
+
* and keeps admitting. To express "no cooldown", omit the rule rather than
|
|
152
|
+
* passing a negative one.
|
|
153
|
+
*
|
|
154
|
+
* A rule that cannot be evaluated refuses, because "we could not check" and
|
|
155
|
+
* "it is fine" are not the same answer.
|
|
156
|
+
*/
|
|
157
|
+
const unmeasurable = (rule, value, field) => Number.isFinite(value) && value >= 0
|
|
158
|
+
? null
|
|
159
|
+
: // A noun phrase, like every other reason, so it still reads correctly
|
|
160
|
+
// when a host prefixes it ("Sub-agent chain bound unreadable…"). A verb
|
|
161
|
+
// phrase here would invert the actor and read as though the child were
|
|
162
|
+
// the one refusing.
|
|
163
|
+
refuse(rule, false, `chain bound unreadable (${field} is ${value}) — a misconfiguration, ` +
|
|
164
|
+
`not a limit that clears, so do the work in this run rather than ` +
|
|
165
|
+
`retrying`);
|
|
166
|
+
function requireFinite(value, field) {
|
|
167
|
+
if (!Number.isFinite(value)) {
|
|
168
|
+
throw new RangeError(`createPollSchedule: ${field} must be a finite number, got ${value}.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* A doubling backoff bounded by a total budget.
|
|
173
|
+
*
|
|
174
|
+
* Polling a child run is the alternative to suspending the parent and letting
|
|
175
|
+
* completion wake it. It keeps the parent's transcript intact but holds its
|
|
176
|
+
* worker slot for the duration — so the budget belongs well under the parent's
|
|
177
|
+
* own deadline, or the parent dies waiting instead of reporting what it learned.
|
|
178
|
+
*/
|
|
179
|
+
export function createPollSchedule(options) {
|
|
180
|
+
// Rejected at construction, not clamped. `Math.min`/`Math.max` propagate
|
|
181
|
+
// `NaN`, so one non-finite option reaches the caller as `sleep(NaN)` — which
|
|
182
|
+
// fires immediately and turns the poll into a busy loop. A non-finite
|
|
183
|
+
// `budgetMs` is worse still: `NaN <= 0` is false, so it also never expires.
|
|
184
|
+
//
|
|
185
|
+
// Unlike a bad measurement in `admitChildRun`, which has a safe answer
|
|
186
|
+
// (refuse), a bad *option* here has none — there is no way to guess the
|
|
187
|
+
// budget that was meant. Substituting a default would hide the caller's bug
|
|
188
|
+
// behind a poll nobody configured, so this throws at the call that made the
|
|
189
|
+
// mistake rather than inside the loop that inherits it.
|
|
190
|
+
requireFinite(options.initialDelayMs, "initialDelayMs");
|
|
191
|
+
requireFinite(options.maxDelayMs, "maxDelayMs");
|
|
192
|
+
requireFinite(options.budgetMs, "budgetMs");
|
|
193
|
+
if (options.minDelayMs !== undefined) {
|
|
194
|
+
requireFinite(options.minDelayMs, "minDelayMs");
|
|
195
|
+
}
|
|
196
|
+
const minDelayMs = Math.max(1, options.minDelayMs ?? 50);
|
|
197
|
+
const maxDelayMs = Math.max(minDelayMs, options.maxDelayMs);
|
|
198
|
+
// Snapshot, not read live: the guards above run once, so a host that mutates
|
|
199
|
+
// its options object afterwards would otherwise walk a `NaN` budget straight
|
|
200
|
+
// past them on the next call.
|
|
201
|
+
const budgetMs = options.budgetMs;
|
|
202
|
+
let delayMs = Math.min(Math.max(options.initialDelayMs, minDelayMs), maxDelayMs);
|
|
203
|
+
return {
|
|
204
|
+
next(elapsedMs) {
|
|
205
|
+
// The options are validated at construction; this is the one number that
|
|
206
|
+
// arrives per call, and it fails the same way — `NaN <= 0` is false, so a
|
|
207
|
+
// bad elapsed never expires, and it poisons the delay into `sleep(NaN)`.
|
|
208
|
+
// A host computing `Date.now() - new Date(row.started_at).getTime()`
|
|
209
|
+
// against an unparsed timestamp string produces exactly that.
|
|
210
|
+
//
|
|
211
|
+
// Expires rather than throws: unlike a bad option there IS a safe reading
|
|
212
|
+
// here — we cannot tell how long this has been running, so stop waiting.
|
|
213
|
+
if (!Number.isFinite(elapsedMs)) {
|
|
214
|
+
return { kind: "expired", waitedMs: budgetMs };
|
|
215
|
+
}
|
|
216
|
+
const remainingMs = budgetMs - elapsedMs;
|
|
217
|
+
if (remainingMs <= 0)
|
|
218
|
+
return { kind: "expired", waitedMs: elapsedMs };
|
|
219
|
+
// Clamped to what is left, so the caller cannot sleep past its own
|
|
220
|
+
// budget and report a timeout later than the one it promised.
|
|
221
|
+
const step = Math.min(delayMs, remainingMs);
|
|
222
|
+
delayMs = Math.min(delayMs * 2, maxDelayMs);
|
|
223
|
+
return { kind: "wait", delayMs: step };
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
}
|
package/run/harness.d.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-cutting run mechanics for an agent loop:
|
|
3
|
+
*
|
|
4
|
+
* 1. a **wall-clock deadline** that aborts a hung run (thread its signal into
|
|
5
|
+
* every model and tool call, and re-check at the top of each iteration);
|
|
6
|
+
* 2. a **coalesced progress heartbeat** for hosts that record liveness;
|
|
7
|
+
* 3. a **failure classifier** mapping a thrown run to a terminal status
|
|
8
|
+
* (`timed_out` vs `failed`).
|
|
9
|
+
*
|
|
10
|
+
* Any driver of the loop needs all three, and reimplementing them per driver
|
|
11
|
+
* is how one ends up unbounded: a loop whose abort signal never fires holds its
|
|
12
|
+
* worker slot until an external reaper notices, which is a stall the user sees
|
|
13
|
+
* as an agent that never answers.
|
|
14
|
+
*
|
|
15
|
+
* This module owns no persistence and makes no model calls — it is pure
|
|
16
|
+
* mechanics, so a small consumer can use it without pulling in a loop's
|
|
17
|
+
* transitive graph.
|
|
18
|
+
*/
|
|
19
|
+
/** Thrown by {@link RunDeadline.throwIfTimedOut} once the wall-clock budget is
|
|
20
|
+
* exhausted. Distinct from a caller-driven cancellation so callers can map it
|
|
21
|
+
* to a `timed_out` terminal status (or simply read {@link RunDeadline.timedOut}). */
|
|
22
|
+
export declare class RunTimeoutError extends Error {
|
|
23
|
+
readonly timeoutMs: number;
|
|
24
|
+
constructor(timeoutMs: number, label: string);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Call `unref()` on a timer when the runtime exposes it (Node/Bun) so a
|
|
28
|
+
* forgotten clear can't hold the event loop open; a no-op under the DOM `number`
|
|
29
|
+
* timer type. Feature-detected rather than `as`-cast to keep type safety.
|
|
30
|
+
*/
|
|
31
|
+
export declare function unrefTimer(timer: ReturnType<typeof setTimeout>): void;
|
|
32
|
+
/**
|
|
33
|
+
* A wall-clock budget for a single run. Backed by an `AbortController` that
|
|
34
|
+
* fires after `timeoutMs`; `signal` is threaded into LLM/tool calls so an
|
|
35
|
+
* in-flight upstream request is actually torn down on timeout (not merely
|
|
36
|
+
* abandoned). `timedOut` reflects *this* deadline firing only — it stays false
|
|
37
|
+
* when a combined external (cancellation) signal aborts — so it is a reliable
|
|
38
|
+
* basis for classifying a `timed_out` outcome regardless of which error shape
|
|
39
|
+
* surfaced (a `RunTimeoutError` from {@link throwIfTimedOut} or an `AbortError`
|
|
40
|
+
* from a torn-down `callLLM` stream).
|
|
41
|
+
*/
|
|
42
|
+
export interface RunDeadline {
|
|
43
|
+
/** The deadline's own abort signal. Combine with a cancellation signal via
|
|
44
|
+
* {@link withExternal} before handing to `callLLM`. */
|
|
45
|
+
readonly signal: AbortSignal;
|
|
46
|
+
/** True once this deadline's timer has fired. Unaffected by external signals. */
|
|
47
|
+
readonly timedOut: boolean;
|
|
48
|
+
/** Throw {@link RunTimeoutError} if the budget is exhausted; no-op otherwise.
|
|
49
|
+
* Call at the top of each loop iteration. */
|
|
50
|
+
throwIfTimedOut(): void;
|
|
51
|
+
/** Combine this deadline with an optional external signal (e.g. a DB-backed
|
|
52
|
+
* cancellation controller). Returns the deadline's own signal when no
|
|
53
|
+
* external signal is given. */
|
|
54
|
+
withExternal(external?: AbortSignal | null): AbortSignal;
|
|
55
|
+
/** Clear the underlying timer. Idempotent; call in a `finally`. */
|
|
56
|
+
dispose(): void;
|
|
57
|
+
}
|
|
58
|
+
export declare function createRunDeadline(opts: {
|
|
59
|
+
timeoutMs: number;
|
|
60
|
+
label?: string;
|
|
61
|
+
}): RunDeadline;
|
|
62
|
+
/**
|
|
63
|
+
* Classify a run that ended by throwing into its terminal status. A run whose
|
|
64
|
+
* deadline fired — or whose error is a {@link RunTimeoutError} — is `timed_out`;
|
|
65
|
+
* anything else is `failed`. Checking the error too makes the result robust to
|
|
66
|
+
* the surfaced shape (an `AbortError` from a torn-down stream leaves
|
|
67
|
+
* `deadline.timedOut` true; a `RunTimeoutError` thrown between iterations is
|
|
68
|
+
* caught directly even if a combined-signal edge left `timedOut` unread).
|
|
69
|
+
*
|
|
70
|
+
* Caller-driven cancellation is a distinct outcome and must be handled *before*
|
|
71
|
+
* calling this (the executor special-cases `AgentRunCancelledError`); the
|
|
72
|
+
* one-off/onboarding path has no cancellation, so this split is complete there.
|
|
73
|
+
*/
|
|
74
|
+
export declare function classifyRunFailure(deadline: RunDeadline, error?: unknown): "timed_out" | "failed";
|
|
75
|
+
/** A progress heartbeat that collapses bursts of calls into at most one flush
|
|
76
|
+
* per `coalesceMs` window (a `force` beat always flushes). */
|
|
77
|
+
export interface CoalescedHeartbeat {
|
|
78
|
+
beat(opts?: {
|
|
79
|
+
force?: boolean;
|
|
80
|
+
}): Promise<void>;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Build a coalesced heartbeat. A run with N concurrent tool calls would
|
|
84
|
+
* otherwise fire N near-simultaneous run-row UPDATEs; coalescing collapses
|
|
85
|
+
* them to one per window while a `force: true` beat (used at the end of each
|
|
86
|
+
* iteration) guarantees a bump within the reaper's stale threshold. `flush`
|
|
87
|
+
* owns the actual write; a flush failure is reported to `onError` and
|
|
88
|
+
* swallowed so a transient DB hiccup never aborts the run.
|
|
89
|
+
*/
|
|
90
|
+
export declare function createCoalescedHeartbeat(opts: {
|
|
91
|
+
coalesceMs: number;
|
|
92
|
+
flush: () => Promise<void>;
|
|
93
|
+
onError?: (err: unknown) => void;
|
|
94
|
+
}): CoalescedHeartbeat;
|
package/run/harness.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-cutting run mechanics for an agent loop:
|
|
3
|
+
*
|
|
4
|
+
* 1. a **wall-clock deadline** that aborts a hung run (thread its signal into
|
|
5
|
+
* every model and tool call, and re-check at the top of each iteration);
|
|
6
|
+
* 2. a **coalesced progress heartbeat** for hosts that record liveness;
|
|
7
|
+
* 3. a **failure classifier** mapping a thrown run to a terminal status
|
|
8
|
+
* (`timed_out` vs `failed`).
|
|
9
|
+
*
|
|
10
|
+
* Any driver of the loop needs all three, and reimplementing them per driver
|
|
11
|
+
* is how one ends up unbounded: a loop whose abort signal never fires holds its
|
|
12
|
+
* worker slot until an external reaper notices, which is a stall the user sees
|
|
13
|
+
* as an agent that never answers.
|
|
14
|
+
*
|
|
15
|
+
* This module owns no persistence and makes no model calls — it is pure
|
|
16
|
+
* mechanics, so a small consumer can use it without pulling in a loop's
|
|
17
|
+
* transitive graph.
|
|
18
|
+
*/
|
|
19
|
+
/** Thrown by {@link RunDeadline.throwIfTimedOut} once the wall-clock budget is
|
|
20
|
+
* exhausted. Distinct from a caller-driven cancellation so callers can map it
|
|
21
|
+
* to a `timed_out` terminal status (or simply read {@link RunDeadline.timedOut}). */
|
|
22
|
+
export class RunTimeoutError extends Error {
|
|
23
|
+
timeoutMs;
|
|
24
|
+
constructor(timeoutMs, label) {
|
|
25
|
+
super(`${label} exceeded ${formatDuration(timeoutMs)} timeout`);
|
|
26
|
+
this.timeoutMs = timeoutMs;
|
|
27
|
+
this.name = "RunTimeoutError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function formatDuration(ms) {
|
|
31
|
+
if (ms % 60_000 === 0) {
|
|
32
|
+
const m = ms / 60_000;
|
|
33
|
+
return `${m} ${m === 1 ? "minute" : "minutes"}`;
|
|
34
|
+
}
|
|
35
|
+
// Floor at 1 so a sub-second budget (e.g. a 50ms test deadline) never reads
|
|
36
|
+
// "0 seconds".
|
|
37
|
+
const s = Math.max(1, Math.round(ms / 1000));
|
|
38
|
+
return `${s} ${s === 1 ? "second" : "seconds"}`;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Call `unref()` on a timer when the runtime exposes it (Node/Bun) so a
|
|
42
|
+
* forgotten clear can't hold the event loop open; a no-op under the DOM `number`
|
|
43
|
+
* timer type. Feature-detected rather than `as`-cast to keep type safety.
|
|
44
|
+
*/
|
|
45
|
+
export function unrefTimer(timer) {
|
|
46
|
+
if (typeof timer === "object" &&
|
|
47
|
+
timer !== null &&
|
|
48
|
+
typeof timer.unref === "function") {
|
|
49
|
+
timer.unref();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function createRunDeadline(opts) {
|
|
53
|
+
const label = opts.label ?? "agent run";
|
|
54
|
+
const controller = new AbortController();
|
|
55
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
56
|
+
// Unref so a forgotten dispose() (e.g. in a test) can't hold the event loop
|
|
57
|
+
// open; the timer is always cleared on the real run path.
|
|
58
|
+
unrefTimer(timer);
|
|
59
|
+
return {
|
|
60
|
+
signal: controller.signal,
|
|
61
|
+
get timedOut() {
|
|
62
|
+
return controller.signal.aborted;
|
|
63
|
+
},
|
|
64
|
+
throwIfTimedOut() {
|
|
65
|
+
if (controller.signal.aborted) {
|
|
66
|
+
throw new RunTimeoutError(opts.timeoutMs, label);
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
withExternal(external) {
|
|
70
|
+
if (!external)
|
|
71
|
+
return controller.signal;
|
|
72
|
+
return AbortSignal.any([external, controller.signal]);
|
|
73
|
+
},
|
|
74
|
+
dispose() {
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Classify a run that ended by throwing into its terminal status. A run whose
|
|
81
|
+
* deadline fired — or whose error is a {@link RunTimeoutError} — is `timed_out`;
|
|
82
|
+
* anything else is `failed`. Checking the error too makes the result robust to
|
|
83
|
+
* the surfaced shape (an `AbortError` from a torn-down stream leaves
|
|
84
|
+
* `deadline.timedOut` true; a `RunTimeoutError` thrown between iterations is
|
|
85
|
+
* caught directly even if a combined-signal edge left `timedOut` unread).
|
|
86
|
+
*
|
|
87
|
+
* Caller-driven cancellation is a distinct outcome and must be handled *before*
|
|
88
|
+
* calling this (the executor special-cases `AgentRunCancelledError`); the
|
|
89
|
+
* one-off/onboarding path has no cancellation, so this split is complete there.
|
|
90
|
+
*/
|
|
91
|
+
export function classifyRunFailure(deadline, error) {
|
|
92
|
+
if (deadline.timedOut || error instanceof RunTimeoutError)
|
|
93
|
+
return "timed_out";
|
|
94
|
+
return "failed";
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Build a coalesced heartbeat. A run with N concurrent tool calls would
|
|
98
|
+
* otherwise fire N near-simultaneous run-row UPDATEs; coalescing collapses
|
|
99
|
+
* them to one per window while a `force: true` beat (used at the end of each
|
|
100
|
+
* iteration) guarantees a bump within the reaper's stale threshold. `flush`
|
|
101
|
+
* owns the actual write; a flush failure is reported to `onError` and
|
|
102
|
+
* swallowed so a transient DB hiccup never aborts the run.
|
|
103
|
+
*/
|
|
104
|
+
export function createCoalescedHeartbeat(opts) {
|
|
105
|
+
let lastAt = 0;
|
|
106
|
+
let inFlight = null;
|
|
107
|
+
let queued = false;
|
|
108
|
+
// Drain queued flushes ONE AT A TIME, so a coalesced background beat and a
|
|
109
|
+
// forced end-of-iteration beat can never run `flush()` concurrently — two
|
|
110
|
+
// overlapping DB writes could otherwise resolve out of order and let an older
|
|
111
|
+
// token/cost snapshot overwrite a newer one. `flush` reads live state at call
|
|
112
|
+
// time, so each drained flush still writes the latest values, and a request
|
|
113
|
+
// enqueued during the prior await is picked up on the next loop turn.
|
|
114
|
+
const drain = async () => {
|
|
115
|
+
while (queued) {
|
|
116
|
+
queued = false;
|
|
117
|
+
try {
|
|
118
|
+
await opts.flush();
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
opts.onError?.(err);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
inFlight = null;
|
|
125
|
+
};
|
|
126
|
+
return {
|
|
127
|
+
async beat(o) {
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
if (!o?.force && now - lastAt < opts.coalesceMs)
|
|
130
|
+
return;
|
|
131
|
+
lastAt = now;
|
|
132
|
+
queued = true;
|
|
133
|
+
if (!inFlight)
|
|
134
|
+
inFlight = drain();
|
|
135
|
+
// Await the drain so a forced beat returns only after the latest write has
|
|
136
|
+
// landed — the reaper-liveness / token-flush guarantee callers rely on.
|
|
137
|
+
await inFlight;
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
}
|
package/run/index.d.ts
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, type RunDeadline, type CoalescedHeartbeat, } from "./harness.js";
|
|
2
|
+
export { runToolCallsPooledByTool } from "./tool-batch.js";
|
|
3
|
+
export { rootChain, descendChain, admitChildRun, createPollSchedule, type ChainRef, type ChainRule, type ChildAdmission, type PollStep, type PollSchedule, type PollScheduleOptions, } from "./children.js";
|
package/run/index.js
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, } from "./harness.js";
|
|
2
|
+
export { runToolCallsPooledByTool } from "./tool-batch.js";
|
|
3
|
+
export { rootChain, descendChain, admitChildRun, createPollSchedule, } from "./children.js";
|