import type { Usage } from '../types/provider.js'; import type { EventBus } from '../kernel/events.js'; export type BudgetKind = 'tool_calls' | 'iterations' | 'tokens' | 'timeout' | 'idle_timeout' | 'cost'; /** * Fraction of the wall-clock `timeoutMs` window at which a PROACTIVE extension * is negotiated — BEFORE the deadline is actually crossed. The coordinator * watchdog (`executeWithTimeout`) arms at `timeoutMs * TIMEOUT_PREEMPT_FRACTION` * so a still-progressing subagent gets its ceiling raised while it is below the * limit, and never enters a "timed out" state. Reactive enforcement at the real * deadline still stands for the no-progress / denied case. Shared so the asking * side and any future caller agree on the same lead point. */ export declare const TIMEOUT_PREEMPT_FRACTION = 0.85; /** * Hard safety net for budget negotiation decisions. If no listener responds to * `budget.threshold_reached` within this window the negotiation defaults to * `'stop'`. Exported so the coordinator's watchdog can reuse the same ceiling * without hardcoding a second copy. */ export declare const DECISION_TIMEOUT_MS = 60000; export declare class BudgetExceededError extends Error { readonly kind: BudgetKind; readonly limit: number; readonly observed: number; constructor(kind: BudgetKind, limit: number, observed: number); } export interface BudgetLimits { maxIterations?: number | undefined; maxToolCalls?: number | undefined; maxTokens?: number | undefined; /** Estimated USD cost ceiling. */ maxCostUsd?: number | undefined; /** * Hard wall-clock timeout measured from `start()`. Off by default — set it * explicitly only when a task must finish within an absolute window. For * the everyday "don't kill an agent that's still working" guard, prefer * `idleTimeoutMs`, which resets on activity. */ timeoutMs?: number | undefined; /** * Idle timeout: the maximum gap (ms) between activity signals (iterations, * tool calls, token usage, streamed progress) before the subagent is * considered hung and reaped. Unlike `timeoutMs`, an actively-working * agent continuously resets this clock via `markActivity()`, so it never * trips on a long-but-productive run — only on a genuine stall. */ idleTimeoutMs?: number | undefined; } /** * Controls how the budget behaves when `onThreshold` is set and a limit is hit. * * `'auto'` — emit `budget.threshold_reached` on the EventBus and wait for a * coordinator response (extend/stop). If no listener responds within * `DECISION_TIMEOUT_MS` the decision defaults to `'stop'`. * `'sync'` — do not emit any event; treat the threshold as a hard stop and * throw `BudgetExceededError` synchronously. Useful for fire-and-forget * subagents that have an `onThreshold` handler for logging/metrics but are * not wired into a coordinator. * * @default 'auto' */ export type BudgetNegotiationMode = 'auto' | 'sync'; export type BudgetSessionIdSource = string | (() => string | undefined); interface SubagentBudgetOptions { sessionId?: BudgetSessionIdSource | undefined; } export interface BudgetUsage { iterations: number; toolCalls: number; tokens: { input: number; output: number; total: number; }; costUsd: number; elapsedMs: number; } /** * Thrown by `SubagentBudget.record*` when a soft limit is hit and * an `onThreshold` handler is configured that wants to ask the * coordinator (via `budget.threshold_reached` event). The runner * catches this and awaits the embedded `decision` promise to get * the coordinator's extend/stop decision. * * Distinct from `BudgetExceededError` which is a hard stop. */ export declare class BudgetThresholdSignal extends Error { readonly kind: BudgetKind; readonly limit: number; readonly used: number; /** Resolves to 'extend' (with optional new limits) or 'stop' */ readonly decision: Promise; constructor(kind: BudgetKind, limit: number, used: number, decision: Promise); } export type BudgetThresholdDecision = 'stop' | { extend: Partial; }; /** * Callback invoked when a budget limit is about to be exceeded. * Return 'throw' for hard stop (default — throws BudgetExceededError). * Return 'continue' to allow one more unit and re-check next time. * Return a Promise to ask the coordinator via `budget.threshold_reached` * event (uses the same grant/deny pattern as `iteration.limit_reached`). */ export type BudgetThresholdHandler = (info: { kind: BudgetKind; used: number; limit: number; requestDecision: () => Promise; /** * Direct grant/deny hooks for SYNCHRONOUS policy or recording handlers that * decide in-process without a wired `budget.threshold_reached` listener * (e.g. the coordinator watchdog). `extend` patches the limits in place; * `deny` records the intent to stop. Production listener-driven handlers use * `requestDecision()` instead and can ignore these. */ extend?: (extra: Partial) => void; deny?: () => void; }) => 'throw' | 'continue' | 'stop' | { extend: Partial; } | Promise; /** * Per-subagent budget enforcement. Each subagent gets its own instance so a * runaway agent can't drain the cost ceiling of its siblings. All record/check * methods are O(1) and safe to call from hot paths. * * Behavior without `onThreshold`: hard stops synchronously on every limit hit. * * Behavior with `onThreshold` and `_mode === 'auto'`: emits `budget.threshold_reached` * on the EventBus and throws `BudgetThresholdSignal`. The coordinator's verdict * (extend/stop) resolves the embedded promise. If no listener responds within * `DECISION_TIMEOUT_MS` the decision defaults to `'stop'`. * * Behavior with `onThreshold` and `_mode === 'sync'`: throws `BudgetExceededError` * synchronously regardless of EventBus state or listener presence. This is useful * for fire-and-forget subagents that have an `onThreshold` handler for logging/metrics * but are not wired into a coordinator — the `'sync'` mode makes the hard-stop * behavior explicit and means tests can use `expect().toThrow()` even without * a fully-wired EventBus. */ export declare class SubagentBudget { readonly limits: Readonly; /** Patch one or more budget limits in-place after construction. * Used by the coordinator watchdog when granting an extension. * All fields are optional — only provided fields are updated. * This is the single write path for limit mutations so that future * validation or side-effects live in one place (M1). */ patchLimits(ext: Partial): void; private iterations; private toolCalls; private tokenInput; private tokenOutput; private costUsd; private startTime; /** * Timestamp of the most recent activity (iteration / tool call / token * usage / streamed progress). Drives the idle timeout — reset by * `markActivity()`. Initialised to `start()` time so a never-active agent * still eventually trips its idle window. */ private lastActivityTime; private _onThreshold; private readonly _sessionId; /** * Hard cap on how long `_negotiateExtension` waits for the coordinator to * respond before defaulting to 'stop'. Without this fallback an absent * or hung listener (Director not built / event filter detached mid-run) * leaves the budget over-limit and never enforces anything. */ private static readonly DECISION_TIMEOUT_MS; /** * Injected by the runner when wiring the budget to its EventBus. * Used to emit `budget.threshold_reached` events in `'auto'` mode. */ _events?: EventBus | undefined; /** * Guard against dual-path races between the coordinator watchdog * (`executeWithTimeout`) and the budget's own `checkTimeout()`. * Both paths detect `elapsed >= timeoutMs` and can emit * `budget.threshold_reached` for kind `'timeout'` simultaneously. * Set to the current `timeoutMs` ceiling by the coordinator BEFORE * calling `onThreshold`, and cleared after the negotiation resolves. * `checkTimeout()` skips its wall-clock check while this is set so * the coordinator's watchdog is the sole source of wall-clock timeout * events — `checkTimeout()` focuses exclusively on `idle_timeout`. */ private _watchdogActive; /** Returns the timeout ceiling currently being negotiated by the watchdog, * or `undefined` when no wall-clock negotiation is in flight. * Used by `executeWithTimeout` to detect a stale lock (M3). */ get watchdogActive(): number | undefined; /** Called by the coordinator watchdog BEFORE calling `onThreshold` so that * `checkTimeout()` skips its wall-clock check for this ceiling. Prevents * the budget's own `checkTimeout()` from emitting a second * `budget.threshold_reached` event while the watchdog is already * negotiating the same wall-clock deadline (C1). */ setWatchdogNegotiation(timeoutMs: number): void; /** Clears the watchdog guard after negotiation resolves. Called in the * `finally` block of both the pre-empt and deadline branches so it fires * on every exit path: grant, deny, throw, or error. */ clearWatchdogNegotiation(): void; /** * Negotiation mode — controls whether a threshold hit tries to emit * `budget.threshold_reached` and wait for a coordinator decision, or * falls straight through to a synchronous hard stop. * * `'auto'` (default) — emit on the EventBus and wait; times out to 'stop'. * `'sync'` — throw `BudgetExceededError` immediately regardless of listeners. */ private _mode; /** * Optional callback for soft-limit handling. When set, the budget will * invoke it rather than throw immediately. The handler decides whether to * throw synchronously, continue, or ask the coordinator for an extension. */ get onThreshold(): BudgetThresholdHandler | undefined; set onThreshold(fn: BudgetThresholdHandler | undefined); /** Returns the current negotiation mode. */ get mode(): BudgetNegotiationMode; constructor(limits?: BudgetLimits, mode?: BudgetNegotiationMode, options?: SubagentBudgetOptions); private currentSessionId; start(): void; /** * Reset the idle clock. Called on any sign of forward progress — * iterations, tool calls, token usage, and streamed tool/text progress — * so a long-but-productive subagent never trips its `idleTimeoutMs`. */ markActivity(): void; /** * Milliseconds since the last activity signal. Returns 0 before `start()` * (nothing to measure yet). Used by the coordinator watchdog to decide * whether to re-arm (still active) or reap (genuinely idle). */ idleMs(): number; /** Returns true if we're within 10% of any limit — useful for pre-flight checks. */ isNearLimit(): boolean; /** * Synchronous budget check. Always throws synchronously so callers (especially * test event handlers using `expect().toThrow()`) get an unhandled rejection * when the budget is exceeded without a handler. * * Decision table: * - no `onThreshold` handler → throw `BudgetExceededError` (hard stop, always) * - `mode === 'sync'` → throw `BudgetExceededError` (hard stop, always) * - `mode === 'auto'` + no listener → throw `BudgetExceededError` (hard stop; no one to ask) * - `mode === 'auto'` + listener → throw `BudgetThresholdSignal` with async decision promise */ /** * Collects all exceeded budget kinds into a single NOOP-free negotiation. * Called by recordIteration / recordToolCall / recordUsage — each may call * this for its own kind. The first call starts the negotiation and stores * the Promise in _pendingNegotiation. Subsequent calls for DIFFERENT * kinds (while a negotiation is in flight) are NOOPs — they don't start * new conversations with the coordinator. This prevents an EventBus flood * when multiple budget kinds are exceeded simultaneously in one iteration. * * Returns the kinds that were found to be exceeded (for logging/debugging). */ private checkLimits; /** * Invoke `onThreshold` once for `entry` on the NO-LISTENER path and report * whether it decided synchronously. Returns `true` when the handler returned * a synchronous decision (already honored — an `extend` patched the limits), * or `false` when it returned a Promise (async; the caller hard-stops, since * there is no listener to resolve the negotiation). The handler is given the * full info shape (`requestDecision` plus direct `extend`/`deny`) so both * recording handlers and policy handlers work without a wired listener. */ private _invokeHandlerSync; /** * Emit `budget.threshold_reached` and resolve to the listener's verdict. * Resolves to `'stop'` immediately when there is no listener (or no bus) so * no negotiation can hang and no fallback timer leaks. Mirrors the * coordinator watchdog's own request path so both agree on the no-listener * default. */ private _busRequestDecision; /** * Per-kind in-flight negotiation Promises. Each budget kind can have its * own concurrent negotiation — e.g. iterations and timeout can both * be exceeded simultaneously without blocking each other. The same kind * cannot start two concurrent negotiations (dedup guard). * Cleared in `_negotiateExtension`'s `finally` block. */ private _pendingNegotiations; /** * Drive the threshold handler to a decision. Resolves with `'stop'` * (signal the runner to abort) or `{ extend: ... }` (limits already * patched in-place; the runner should not abort). Clears the * per-kind slot in `_pendingNegotiations` in `finally`. * * The 'continue' return from a sync handler is treated as * `{ extend: {} }` — keep going without patching; next overrun fires * a fresh signal. */ private _negotiateExtension; recordIteration(): void; recordToolCall(): void; recordUsage(usage: Usage, costUsd?: number): void; /** * Wall-clock / idle budget check. Delegates to `checkLimits(elapsed)`, so * `timeout` and `idle_timeout` follow the SAME negotiation path as the other * kinds — they are NOT a special-cased hard stop. This is deliberate: a * heartbeat-aware policy (see `attachAutoExtend` and `CollabSession`) grants * a timeout extension only while the agent is making progress and denies it * once the agent is genuinely stuck, which is safer than an unconditional * hard kill of a long-but-working agent. The runner translates the resulting * `BudgetThresholdSignal` decision (`extend` → patch limits in place, * `stop` → abort) just like every other kind. * * Decision table (same as `checkLimits`): * - no `onThreshold` handler → throw `BudgetExceededError` (hard stop) * - `mode === 'sync'` → throw `BudgetExceededError` (hard stop) * - `mode === 'auto'` + no listener → throw `BudgetExceededError` (no one to ask) * - `mode === 'auto'` + listener → throw `BudgetThresholdSignal` (negotiated; * a heartbeat-aware policy may extend the timeout) */ checkTimeout(): void; /** Returns true if a wall-clock or idle timeout has occurred without throwing. */ isTimedOut(): boolean; usage(): BudgetUsage; } export {}; //# sourceMappingURL=subagent-budget.d.ts.map