@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.
@@ -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/index.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, type RunDeadline, type CoalescedHeartbeat, } from "./harness.js";
2
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 CHANGED
@@ -1,2 +1,3 @@
1
1
  export { RunTimeoutError, createRunDeadline, classifyRunFailure, createCoalescedHeartbeat, unrefTimer, } from "./harness.js";
2
2
  export { runToolCallsPooledByTool } from "./tool-batch.js";
3
+ export { rootChain, descendChain, admitChildRun, createPollSchedule, } from "./children.js";