/** * Child runs: lineage, admission, and waiting. * * A sub-agent is not a special kind of thing — it is **a run that another run * asked for**. Everything the harness already gives a run applies to it * unchanged: its own deadline, its own heartbeat, its own stats, its own route * plan. What is genuinely new is the relationship between runs, and that is * what this module owns: * * - **Lineage** — where a run sits in the chain that produced it. * - **Admission** — whether the next child may be created at all. * - **Waiting** — how long to sleep before checking on a child again. * * All three are pure. The queue, the persistence, the counting, and the choice * of bounds stay with the host: the harness decides, it does not measure. That * split is deliberate — counting runs needs a database, and deciding whether a * count is too high does not. */ /** * A run's position in the chain that spawned it. * * `rootRunId` and `parentRunId` are `null` at depth 0, where the run *is* the * root — a chain's origin has no id to point at until its own row exists, so * the convention is "null means me". Read the root of any chain as * `chain.rootRunId ?? thisRunId`; {@link descendChain} does exactly that when * it hands the id down, so every descendant carries a concrete root. */ export interface ChainRef { /** 0 for a run nobody spawned; one more than its parent otherwise. */ readonly depth: number; /** The run that started the chain, or `null` when this run is that run. */ readonly rootRunId: string | null; /** The run that spawned this one, or `null` when nothing did. */ readonly parentRunId: string | null; } /** The lineage of a run that nothing spawned — the origin of a new chain. */ export declare function rootChain(): ChainRef; /** * The lineage a child of `parentRunId` should carry. * * The root-id fallback is the part hosts get wrong: a depth-1 child must adopt * its parent's *id* as the root (the parent's own `rootRunId` is null), while a * depth-2 grandchild must adopt the root the parent already carries. Getting it * backwards makes each generation start a fresh chain, which silently defeats * every per-chain bound — the counts stay small because they count the wrong * set. */ export declare function descendChain(parentRunId: string, parent: Pick): ChainRef; /** * One admission rule, carrying its limit and the measurement it applies to. * * Limit and fact travel together on purpose. The alternative — a bounds object * beside a facts object — lets a host configure a bound whose count was never * wired up, and the only symptom is a limit that silently never fires. Here a * rule cannot be expressed without the number it judges, so a host pays only * for what it actually measures and cannot ask for a bound it does not feed. */ export type ChainRule = /** * How deep the chain may go. `parentDepth` is the spawning run's own depth, * so the child would sit at `parentDepth + 1`; the rule rejects once that * would reach `maxDepth`. A `maxDepth` of 5 therefore permits depths 0 * through 4 — five runs deep counting the root. */ { kind: "depth"; parentDepth: number; maxDepth: number; } /** * Total runs one chain may produce. Caps the spend of a chain that stays * shallow but keeps fanning out, which a depth bound alone does not touch. */ | { kind: "chain_budget"; runsInChain: number; maxRuns: number; } /** * Minimum spacing between spawns from the same parent, which is what stops * two runs ping-ponging work at each other. `msSinceLastSpawn` is `null` when * this parent has not spawned before — always admitted. */ | { kind: "pair_cooldown"; msSinceLastSpawn: number | null; cooldownMs: number; } /** * Whether this parent→child pair has *already* run, at all, within whatever * scope the host measured over. The stronger sibling of * `pair_cooldown`: a cooldown lets a pair repeat once enough time passes, * while this permits the pair exactly once and never again. * * Reach for it where a repeat is not slow but wrong — two agents that answer * each other are a loop whose every hop looks individually reasonable, and a * time-based bound only makes such a loop cheaper per hour rather than * ending it. * * The scope is the host's to choose and is deliberately not modelled here: * "already paired in this chain" and "already paired in this conversation" * are the same rule over different measurements, and naming either one would * put a product's containment vocabulary into the harness. */ | { kind: "pair_seen"; alreadyPaired: boolean; } /** * A ceiling on concurrent runs for the whole tenant. Worth having alongside * the chain rules: depth and chain budgets constrain one lineage, and neither * stops someone starting a thousand independent ones. */ | { kind: "tenant_ceiling"; activeRuns: number; maxActiveRuns: number; } /** * How many runs a tenant may *start* within a rolling window, as opposed to * how many may be in flight at once (`tenant_ceiling`). * * The two bound different abuses and neither implies the other. A ceiling * caps concurrency, so a caller that starts and finishes runs quickly slips * under it indefinitely — a message flood, or one fan-out across a large * group, is exactly that shape. A rate caps total starts, so it bounds spend * where the ceiling bounds load. * * `windowMs` is carried only so the refusal can say how long the wait is; * the rule does not roll the window itself. The host measures over whatever * window it chose and passes both. */ | { kind: "tenant_rate"; runsInWindow: number; maxRuns: number; windowMs: number; }; export type ChildAdmission = { admitted: true; } | { admitted: false; /** Which rule refused, for metrics and for branching. */ rule: ChainRule["kind"]; /** * Could waiting change this answer? * * The rules are not all the same kind of refusal, and prose alone does * not separate them: a cooldown or a rolling window clears on its own, a * spent chain budget or a repeated pair never does. Without this a model reads "refused" and has to guess * between waiting and giving up — and guessing wrong either wastes the * run on retries into the same wall or abandons work it could have done * a moment later. */ retryable: boolean; /** * Why, in prose, for the model to read as a tool error. Names the limit, * the measurement, and what to do instead — a refusal that reports only * the failure invites a retry into the identical wall. */ reason: string; }; /** * Decide whether one more child run may be created. * * Evaluated in the order given, first refusal wins, so a host controls which * reason the model sees when several apply. An empty rule list admits — this * function bounds what it is given and claims nothing about what it is not. * * **When a measurement fails, omit the rule — do not pass a sentinel.** The * two failure postures here answer different questions and are easy to * conflate. A rule whose number arrived broken (`NaN`, negative) refuses, * because a nonsense count is not evidence of safety. A rule you could not * measure at all — the count query threw, the store was unreachable — is * absent, and absence admits. That is a deliberate seam, not an oversight: * whether a transient infrastructure failure should stop all work or let it * through is an availability judgement about a specific product, and this * function has no standing to make it. Decide it at the call site, in a * `catch`, by choosing whether to append the rule. Passing `NaN` or `-1` to * mean "unknown" inverts the answer you almost certainly want. * * **Call before enqueuing, never after.** A chain that is bounded only once its * runs are already queued is not bounded; it is billed. * * **This is the decision, not the claim.** A counted rule (`chain_budget`, * `tenant_ceiling`) bounds only as tightly as the host's count is atomic with * the create. Two spawners that read the same count both admit — two replicas, * or two spawn calls in one assistant batch, which the tool loop fans out * concurrently. If you need the bound to hold under concurrency, take a lock or * use a conditional insert around count-then-create; this function cannot see * the race and will not tell you about it. * * A non-finite number anywhere in a rule — the measurement or the limit — * refuses rather than admits. Comparisons against `NaN` are always false, so * the natural reading of every rule below would silently admit, turning a * broken count or a misread config into an unbounded chain. That is the one * failure this function exists to prevent, so it fails toward refusing. */ export declare function admitChildRun(rules: readonly ChainRule[]): ChildAdmission; /** * What a poller should do next while waiting on a child run. * * `wait` is already clamped to whatever budget remains, so sleeping for it can * never overshoot the deadline — the next call returns `expired` instead. */ export type PollStep = { kind: "wait"; delayMs: number; } | { kind: "expired"; waitedMs: number; }; export interface PollScheduleOptions { /** First delay. Clamped to at least `minDelayMs` and at most `maxDelayMs`. */ readonly initialDelayMs: number; /** Ceiling the doubling backoff climbs to. */ readonly maxDelayMs: number; /** Total wall-clock the poll may consume before giving up. */ readonly budgetMs: number; /** * Floor on any single delay, defaulting to 50ms — which is the real guard * against an `initialDelayMs` of 0 turning the poll into a busy loop. * * Setting it explicitly *lowers* that guard: the hard floor is 1ms, so * `minDelayMs: 0` yields 1ms rather than the default. That is deliberate — a * host asking for a sub-50ms poll gets one — but it means passing 0 to mean * "no floor" gives you the tightest loop this module allows, not the safest. * * If this exceeds `maxDelayMs`, the floor wins and the ceiling is raised to * match: a delay below the floor would defeat the busy-loop guard, while one * above the ceiling only polls less often. */ readonly minDelayMs?: number; } export interface PollSchedule { /** * The next step, given how long the poll has been running. Elapsed time is an * argument rather than something read from a clock, which keeps this pure and * lets a test drive the whole backoff without waiting for any of it. * * A schedule carries the backoff state for **one** wait and advances on every * call, so it is not shareable between concurrent waiters — build one per * wait, which is cheap. Two schedules from identical options are fully * independent. */ next(elapsedMs: number): PollStep; } /** * A doubling backoff bounded by a total budget. * * Polling a child run is the alternative to suspending the parent and letting * completion wake it. It keeps the parent's transcript intact but holds its * worker slot for the duration — so the budget belongs well under the parent's * own deadline, or the parent dies waiting instead of reporting what it learned. */ export declare function createPollSchedule(options: PollScheduleOptions): PollSchedule;