@sema-agent/core
Version:
Stateless, task-oriented AI agent core
227 lines (226 loc) • 16 kB
TypeScript
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;