@juno-ai/bind 2.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +1153 -60
  2. package/contracts/index.d.ts +1 -1
  3. package/contracts/index.js +1 -1
  4. package/contracts/turn.d.ts +31 -7
  5. package/contracts/turn.js +45 -0
  6. package/index.d.ts +16 -5
  7. package/index.js +16 -5
  8. package/loop/index.d.ts +1 -0
  9. package/loop/index.js +1 -0
  10. package/loop/tool-loop.d.ts +260 -0
  11. package/loop/tool-loop.js +276 -0
  12. package/package.json +22 -2
  13. package/plugins/activation.d.ts +67 -0
  14. package/plugins/activation.js +61 -0
  15. package/plugins/index.d.ts +3 -0
  16. package/plugins/index.js +3 -0
  17. package/plugins/registry.d.ts +52 -0
  18. package/plugins/registry.js +54 -0
  19. package/plugins/tool.d.ts +164 -0
  20. package/plugins/tool.js +9 -0
  21. package/routing/billing-basis.d.ts +48 -0
  22. package/routing/billing-basis.js +67 -0
  23. package/routing/circuit-breaker.d.ts +2 -2
  24. package/routing/errors.d.ts +1 -1
  25. package/routing/executor.d.ts +3 -3
  26. package/routing/executor.js +1 -1
  27. package/routing/index.d.ts +11 -9
  28. package/routing/index.js +11 -9
  29. package/routing/plan-degradation.d.ts +34 -0
  30. package/routing/plan-degradation.js +38 -0
  31. package/routing/plan.d.ts +2 -2
  32. package/routing/planner.d.ts +4 -4
  33. package/routing/planner.js +1 -1
  34. package/routing/policy.d.ts +1 -1
  35. package/routing/policy.js +1 -1
  36. package/routing/transport.d.ts +2 -2
  37. package/run/children.d.ts +204 -0
  38. package/run/children.js +226 -0
  39. package/run/harness.d.ts +94 -0
  40. package/run/harness.js +140 -0
  41. package/run/index.d.ts +3 -0
  42. package/run/index.js +3 -0
  43. package/run/tool-batch.d.ts +16 -0
  44. package/run/tool-batch.js +83 -0
  45. package/tools/index.d.ts +1 -0
  46. package/tools/index.js +1 -0
  47. package/tools/sanitize-schema.d.ts +150 -0
  48. package/tools/sanitize-schema.js +683 -0
  49. package/transcript/index.d.ts +1 -0
  50. package/transcript/index.js +1 -0
  51. package/transcript/validate.d.ts +54 -0
  52. package/transcript/validate.js +226 -0
@@ -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;
@@ -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
+ }
@@ -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";