UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

227 lines (226 loc) • 16 kB
import type { StreamFn } from "../internal/llm.js"; /** * Tiered timeouts (design/17 §5B; re-cut along the segments of a call in 7.33.0). The task-level * `timeoutSec` only bounds the *whole* run; it cannot tell apart a gateway that never completes the * handshake, one that accepts the request and then holds the response headers while it works, one * that opens the stream and never emits a token, and one that goes quiet mid-answer. Four clocks, * one per segment, each named for the segment it bounds: * * - **connect**: TCP + TLS establishment inside `fetch` — the transport's own clock (undici's 10s by * default), tuned on the process dispatcher the host installs; not a knob here (`transport.ts`). * - **first-byte**: the `fetch()` call → response headers. A stall here is this process cancelling * its own request at a deadline it chose, and is announced as such. * - **first-token**: headers → the first content delta (a thinking delta counts). * - **idle**: silence between deltas once output has started. * * Every field resolves through the same chain: construction config (an explicit `0` is OFF) > * per-call advisory (`StreamOptions.stallTimeouts`) > the engine default constant below. */ /** * The largest delay a host timer can actually wait for. Past this `setTimeout` does not schedule far in * the future — it truncates the delay to 1ms and fires at once, which for a watchdog means the OPPOSITE * of what a deployment widening the bound asked for: every call aborted immediately, while the failure * text quotes the number that was requested. Any ms knob that reaches a timer is refused above it * (knob-hygiene #98); a watchdog that is meant to be off has its own `0` / `false` spelling. */ export declare const MAX_TIMER_DELAY_MS = 2147483647; /** * Resolve one stall-timeout knob to its effective value, refusing invalid numbers loudly. `x && x > 0` * downstream reads NaN as "unset" — a deployment that SET a tighter watchdog and fumbled the units * ("120s" → Number → NaN) used to get every watchdog silently OFF, the dangerous direction (ruled * 2026-08-03, cross-repo robustness batch; same posture as the guardrail knob). `0` stays legal — * an explicit off. Absent stays absent. Above {@link MAX_TIMER_DELAY_MS} is refused for the mirror-image * reason: the host timer would turn "watch for 34 days" into "abort on arrival". */ export declare function resolveStallTimeoutMs(value: number | undefined, knob: string): number | undefined; /** * The engine's DEFAULT watchdog values, one per segment of a model call — the third and last level of * the chain every brain reads (`BrainTimeoutConfig` field > per-call advisory `StreamOptions.stallTimeouts` * > these). The Runner supplies these same numbers as the advisory on every call, so a Brain supplied by * the deployment (BYOM) is told what the engine would arm; a first-party brain arms them itself when * neither a config field nor an advisory names the segment. A silent call is therefore always bounded by * something finer than the hour-scale {@link BRAIN_CALL_GUARDRAIL_DEFAULT_MS} (a measured production * frame once sat 680s with ZERO deltas) unless a deployment switched the watchdog for that segment off * by name — an explicit `0` is the only spelling of OFF. The TCP + TLS connect has no row here: it is * the transport's own clock (`transport.ts`), not a segment the engine can observe. 量纲: * - first-byte 300s: the `fetch()` call → response headers. A self-hosted gateway that runs its * prefill BEFORE writing headers is silent here for minutes and healthy; the 30s "connect" clock * this replaces cut that prefill and re-sent the same body ten times. 300s is the reference * implementation's window for a non-first-party route; the window grows with the request body * ({@link firstByteWindow}) and the one retry is widened ({@link FIRST_BYTE_RETRY_WINDOW_FACTOR}). * - first-token 600s: bounds the headers→first-delta wait, NOT the model's thought (the first * THINKING delta already counts as the first token, design/17 O5), so a slow reasoner is safe. A * minutes-scale bound is FALSE for a self-hosted server working through a long prefill AFTER * headers: it emits nothing for 2–3 minutes while healthy, and cancelling it burns the prefill and * re-sends a request that burns it again. The default transport keeps NO clock over this segment * (the request carries `bodyTimeout: 0`), so this number is the bound actually granted. * {@link import("../orchestration/workflow.js").WORKFLOW_AGENT_STALL_MS} is DERIVED from the * first-byte lane at {@link STALL_FIRST_BYTE_MS} ({@link firstByteLaneKnobMs}) + this value, so the coarse agent-restart cannot sit UNDER a single * silent provider call (a floor, not a guarantee of ordering — see that constant's comment). * - idle 300s: mid-stream silence after output started — the reference implementation's idle floor * (a self-hosted server can stall for minutes between deltas while a server-side tool runs; a provider * keep-alive comment is not a delta and does not count). * ADVISORY: a brain whose construction-time {@link BrainTimeoutConfig} sets the corresponding field * keeps it (deployment sovereignty; see the `StreamOptions.stallTimeouts` priority chain). */ export declare const STALL_FIRST_BYTE_MS = 300000; export declare const STALL_FIRST_TOKEN_MS = 600000; export declare const STALL_IDLE_MS = 300000; /** * The retired names of the header-wait bound, each with the name that replaced it: the construction key and * the per-call advisory key. The type face no longer has them, but a type only refuses a LITERAL — a * spread object (`{ ...timeouts }`) or a JS caller still delivers the key, and a knob that arrives and is * then ignored is a deployment setting silently doing nothing. So their arrival is refused, naming the * replacement: a tripwire, not a second spelling (nothing reads the value). */ export declare const RETIRED_TIMEOUT_KNOBS: { readonly construction: { readonly connectTimeoutMs: "firstByteTimeoutMs"; }; readonly advisory: { readonly connectMs: "firstByteMs"; }; }; /** Refuse a retired timeout key present on `holder` (see {@link RETIRED_TIMEOUT_KNOBS}); `where` names the * object in the message (`"createOpenAIBrain config"`, `"StreamOptions.stallTimeouts"`). */ export declare function refuseRetiredTimeoutKnobs(holder: object | undefined, level: keyof typeof RETIRED_TIMEOUT_KNOBS, where: string): void; /** The first-byte window grows with the request body: one second per this many bytes, rounded up (the * reference implementation's allowance) — a large context on a slow uplink is still UPLOADING when a * fixed window would call the gateway silent. */ export declare const FIRST_BYTE_UPLOAD_BYTES_PER_SECOND = 32768; /** A first-byte stall is re-sent at most this many times by default ({@link BrainTimeoutConfig.firstByteRetries}), * out of its OWN budget rather than `maxRetries` (the reference implementation: "normally one retry, not the * session budget"); the shared ladder counts transport failures, and this is not one. */ export declare const FIRST_BYTE_RETRY_BUDGET = 1; /** The first-byte lane's retry budget: absent ⇒ {@link FIRST_BYTE_RETRY_BUDGET}; `0` / `1` are themselves; anything * else is refused (`config.stall_timeout_invalid`), never folded to the default or to `0`. */ export declare function resolveFirstByteRetries(value: unknown): 0 | 1; /** The retry's knob is the first times this — the reference implementation widens its retry to a second * knob's default (599s against a 300s first window); one knob here keeps that ratio instead. */ export declare const FIRST_BYTE_RETRY_WINDOW_FACTOR = 2; /** The ONE resolution every watchdog knob reads: construction config (an explicit `0` is OFF) > per-call * advisory > the engine default. A bad number at either level is refused ({@link resolveStallTimeoutMs}); * only an ABSENT value falls through. */ export declare function resolveWatchdogMs(configured: number | undefined, advisory: number | undefined, engineDefault: number, knob: string): number; /** One armed first-byte window, with the two numbers its announcement quotes. */ export interface FirstByteWindow { /** The knob's value for THIS attempt (the configured value, widened on the retry). */ knobMs: number; /** The upload allowance added for the request body, ms (0 for an empty body). */ allowanceMs: number; /** What is actually armed: `knobMs + allowanceMs`, capped at {@link MAX_TIMER_DELAY_MS}. `0` = off. */ windowMs: number; } /** The first-byte window for one attempt; `retryIndex` = first-byte stalls already suffered (0 first), * each widening the knob by {@link FIRST_BYTE_RETRY_WINDOW_FACTOR}. A knob of `0` (OFF) stays off. */ export declare function firstByteWindow(knobMs: number | undefined, bodyBytes: number, retryIndex: number): FirstByteWindow; /** * The most a first-byte lane armed at `knobMs` waits on the gateway in total before it gives up: the probe * window and every widened retry its budget allows ({@link FIRST_BYTE_RETRY_BUDGET}, * {@link FIRST_BYTE_RETRY_WINDOW_FACTOR}) — read off {@link firstByteWindow}, the same function that arms * each attempt, so the sum cannot drift from what is armed. The knob part only: the upload allowance * depends on the request body and the backoff between attempts is not a wait on the gateway, so the lane's * real wall time is this plus both — a LOWER bound. */ export declare function firstByteLaneKnobMs(knobMs: number): number; export interface BrainTimeoutConfig { /** * `fetch()` call → response headers, ms: the window a gateway has to START answering (until 7.33.0 a * knob named `connectTimeoutMs` bounded this wait; it is gone — the connect clock is the transport's). Absent ⇒ * {@link STALL_FIRST_BYTE_MS}; the armed window grows with the request body ({@link firstByteWindow}); * a stall earns ONE retry at a widened window ({@link firstByteRetries}), then fails naming this knob. * Fires as a `stall` (this process cancelled its own request), never as a transport fault. `0` ⇒ off. */ firstByteTimeoutMs?: number; /** * The first-byte lane's own retry budget: `1` (absent ⇒ {@link FIRST_BYTE_RETRY_BUDGET}) or `0` — the first * window's expiry is the call's terminal, for a host whose own failover ladder should take a silent gateway after * one window. Independent of `maxRetries` both ways; construction only; any other value fails the call naming this * knob. The derived floors ({@link firstByteLaneKnobMs}) read the default. */ firstByteRetries?: 0 | 1; /** * After the stream opens, cancel it if no content delta (text / thinking / tool input) arrives * within this many ms → `BrainError("network", "first-token timeout")`. For reasoning models the * **first thinking delta also counts** as the first token, so a long-thinking model isn't killed * for being slow to its first *visible* word (design/17 O5). Absent ⇒ {@link STALL_FIRST_TOKEN_MS}; * `0` ⇒ off. */ firstTokenTimeoutMs?: number; /** * **Idle / mid-stream stall watchdog** (1.40.1, design/24 — gap found in the Claude Code source study). * After the first delta, re-armed on every delta; if the stream then goes silent for this many ms * (a gateway that hung mid-output) → `reader.cancel()` + `BrainError("network", "stream idle timeout")`. * Complements `firstTokenTimeoutMs`: that one tolerates a long *first* token (reasoning), this one * catches a stall *after* output has started, where a healthy stream emits steadily. Absent ⇒ * {@link STALL_IDLE_MS}; `0` ⇒ off. */ idleTimeoutMs?: number; } /** * A per-attempt abort controller that fires at the first-byte deadline while still forwarding an * outer (task) abort. The returned `signal` governs the **whole** request including the streamed body * — so once headers arrive, call `clearTimer()` (NOT `dispose()`) to stop the deadline while keeping * the outer-abort link alive for the body read; call `dispose()` only when the request is fully done * or being discarded. */ export declare function createAttemptController(firstByteWindowMs: number | undefined, outerSignal: AbortSignal | undefined): { signal: AbortSignal; timedOut: () => boolean; clearTimer: () => void; /** Abort this attempt's signal. The signal handed to `doFetch` is how an injected fetch wrapper * learns it may release its transport, so an attempt being ABANDONED (its error body cut short by * a bound, its response discarded before a retry) must say so rather than just dropping the * reference. Idempotent; a no-op once the response is fully consumed. */ abort: () => void; dispose: () => void; }; /** * Default ceiling on how long the ENGINE waits for a single brain call (ruled 2026-08-03: one hour). * Deliberately far above any legitimate call: this is a wedged-run backstop, not a latency policy — * a deployment that wants a tight per-call bound sets the fine tier above ({@link BrainTimeoutConfig}, * or the advisory {@link STALL_FIRST_BYTE_MS} family the engine supplies by default). All of these clocks * run in parallel with the task's own `limits.timeoutSec`; whichever expires first owns the run. */ export declare const BRAIN_CALL_GUARDRAIL_DEFAULT_MS = 3600000; /** Terminal `TaskResult.errorCode` for a run the guardrail stopped waiting on. */ export declare const BRAIN_CALL_GUARDRAIL_ERROR_CODE = "brain.call_guardrail_timeout"; /** Thrown at every await point of a brain call the guardrail has given up on. */ export declare class BrainCallGuardrailTimeoutError extends Error { /** Read by the runner's terminal-code chain (`errorCodeOf`) — never matched on the message text. */ readonly code = "brain.call_guardrail_timeout"; readonly limitMs: number; constructor(limitMs: number); } /** * The guardrail knob's value space: a positive number of milliseconds, or `false`/`0` to switch the * guardrail OFF entirely (a deployment that owns its own outer bound). Absent ⇒ * {@link BRAIN_CALL_GUARDRAIL_DEFAULT_MS}. */ export type BrainCallGuardrailKnob = number | false; /** * Resolve the knob to the effective limit, or `undefined` when the guardrail is off. * * FAIL-LOUD on a value that is neither: a negative / NaN / Infinity setting is a configuration bug, * and silently folding it to a default (or to "off") is how an operator ends up believing a wedged * run is bounded when nothing bounds it. */ export declare function resolveBrainCallGuardrailMs(knob: BrainCallGuardrailKnob | undefined): number | undefined; /** Records the FIRST guardrail expiry of a run, so the runner can give the task a typed terminal. */ export interface BrainCallGuardrailRef { /** Set once, by the first call the guardrail gave up on. Absent ⇒ the guardrail never fired. */ timedOut?: BrainCallGuardrailTimeoutError; } /** * Wrap a {@link StreamFn} so the ENGINE never waits longer than `limitMs` for one brain call. * * Every await point of the call is bounded: the `stream()` promise itself (a brain that never * returns a stream), each iterator step (a brain that trickles events forever), and `result()` (a * brain that opens a stream and never terminates it). The guardrail's clock starts when the call is * issued and stops when the call ends — it bounds ONE call, not the turn and not the task. * * `return()` on the wrapped iterator forwards the inner cleanup WITHOUT awaiting it: the whole point * is that the engine stops waiting on a brain, so releasing the iterator must not re-introduce a wait * on the same hung code. */ export declare function withBrainCallGuardrail(streamFn: StreamFn, limitMs: number, ref: BrainCallGuardrailRef): StreamFn;