UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

265 lines (264 loc) • 20 kB
/** * Structured brain error so the *kind* of failure is machine-distinguishable end-to-end (the message * is prefixed `[code]`, which the Runner lifts into `TaskResult.errorCode`). This lets an external * caller react correctly without string-matching: `auth` → the API key is wrong, **stop and fix it** * (it's not retried, so the caller gets a fast failure); `rate_limit` → back off; `network`/`server` * → a transient outage worth retrying. Only `network`/`server`/`rate_limit` are retried by the brains. */ import type { Usage } from "../internal/llm.js"; import type { ProviderCondition, ProviderConditionSource } from "./provider-dialect.js"; /** A zeroed `Usage` for synthetic error/short-circuit assistant messages (no tokens spent). Shared by * the circuit-breaker, degradation, and routing brains so the empty-usage shape lives in one place. */ export declare const ZERO_USAGE: Usage; /** The single source of truth for brain error codes — the type, the `[code]` parser, and the prefix * stripper all derive from this one list, so adding a code can't silently diverge across call sites. * * The last three are IN-BAND terminal-output codes: stamped on errored assistant MESSAGES * (never thrown as BrainError) so `TaskResult.errorCode` covers the error family end-to-end and * downstream consumers can bucket without string-matching. Behavior-neutral on the transport side: * the circuit breaker's default countCodes excludes them (model-output problems, not provider * outages) and degradation maps them to nothing. Subagent retry classification gives each of them a * ROW of its own (`SUBAGENT_ERROR_KIND_BY_CODE`): a lost stream is the transient `network` class, a * policy refusal and a spent output cap are `logic`. They rode the `logic` fall-through together * until then, which told a parent "do not re-delegate" about a dropped response. * - `stream_torn` — the stream ended with no content and no finish/stop reason (response lost) * - `refusal` — policy refusal / provider content filter withheld the output * - `length_empty` — max_tokens exhausted with no answer text (pairs with errorKind "length_empty") */ declare const BRAIN_ERROR_CODES: readonly ["auth", "rate_limit", "invalid_request", "server", "network", "http", "stream_torn", "refusal", "length_empty"]; export type BrainErrorCode = (typeof BRAIN_ERROR_CODES)[number]; /** The full code list as a value, exported for exhaustive drift guards (e.g. the `errorClassOf` copy in * tool-errors.ts must classify every member — a code added here but missed there folds to "unknown"). */ export declare const ALL_BRAIN_ERROR_CODES: readonly BrainErrorCode[]; /** The in-band terminal-output subset (see the list doc above). The circuit breaker collapses these * to the legacy `"http"` bucket before counting: its taxonomy is TRANSPORT-level, * and before these codes existed the same failures reached it as the unprefixed→"http" fallback — a * custom `countCodes: ["http"]` policy must keep counting them exactly as it always did. */ export declare const IN_BAND_OUTPUT_CODES: ReadonlySet<BrainErrorCode>; export declare class BrainError extends Error { readonly code: BrainErrorCode; readonly status?: number; /** design/374 slice 2 — STRUCTURAL cause seat: the provider stated (in a provenance-checked * diagnostic field or status, see `input-too-long.ts`) that the request INPUT exceeds its * context limit. The engine's terminal-shell mint lifts it onto `AssistantMessage.errorKind: * "input_too_long"`, which the loop's prompt-too-long classifier reads instead of regexing the * human-facing message. Never inferred from `detail` prose after construction. */ readonly inputTooLong?: true; /** The AUTHORITATIVE-NEGATIVE half (adversarial-review r1): the classifier RAN with provenance * and this failure is NOT input-too-long (wrong status family, or the provider's own envelope * states a different error). Lifted onto `AssistantMessage.inputTooLongRuledOut`, which * suppresses the loop's prose fallback — a reflected "prompt is too long" inside an unrelated * error body must not drive a context-shrinking recovery. Mutually exclusive with * `inputTooLong`; BOTH absent = classification could not establish provenance ("unknown"), * and the prose fallback keeps its historic reach unless {@link errorBodyIncomplete} is set. */ readonly inputTooLongRuledOut?: true; /** * This failure was produced AT THE PROVIDER BOUNDARY — a transport attempt was made against the * serving endpoint, or the provider itself answered with a failure (an HTTP error response, an * in-band SSE error frame, a connect/first-token/idle stall on a live request). It is what lifts * `AssistantMessage.isApiErrorMessage` at the terminal-shell mint, so a consumer can tell "the * provider/network failed this turn" from "this deployment refused to send the request at all". * * DECLARED BY THE MINTER, never inferred from the class: `BrainError` is also the carrier for * PRE-FLIGHT refusals that never touch the wire (the route adjudicator's credential-pairing * refusal is an `invalid_request` minted before any `fetch`), and treating those as provider * faults would point an operator at the wrong system. Absent = not claimed, which is the * conservative reading for every pre-existing and third-party construction site. */ readonly apiError?: true; /** The provider's own word for the failure it stated, read from its JSON error envelope: a REFUSED request's * (a 4xx answer, read by {@link readProviderErrorCode} at the HTTP throw) or an in-band error frame's (no status). * Lifted onto `AssistantMessage.apiErrorCode` at the terminal-shell mint. Absent when neither stated a word in the * echo shape. */ readonly providerCode?: string; /** What the provider said about the account or its capacity, judged off the provider dialect table * (`provider-dialect.ts`) — on every failure the provider stated (an HTTP failure status, an in-band error * frame). Lifted onto `AssistantMessage.providerCondition` beside {@link providerConditionSource}. */ readonly providerCondition?: ProviderCondition; readonly providerConditionSource?: ProviderConditionSource; /** With a `quota_exhausted` condition whose evidence stated it: when the quota lifts, wall-clock epoch ms. */ readonly providerResetAtMs?: number; /** The failed response's body did not end (cut at the read bound, torn, unreadable), so it stated nothing: no * verdict, word or context-limit reading came from it, and the prefix quoted in the message is no evidence * either. Lifted onto `AssistantMessage.errorBodyIncomplete`. */ readonly errorBodyIncomplete?: true; constructor(code: BrainErrorCode, detail: string, status?: number, opts?: { inputTooLong?: boolean; inputTooLongRuledOut?: boolean; apiError?: boolean; providerCode?: string; verdict?: { condition: ProviderCondition; source: ProviderConditionSource; resetAtMs?: number; }; errorBodyIncomplete?: boolean; }); } /** The provider's diagnostic object out of a JSON error body, or undefined when the body establishes no provenance * for one: both the Anthropic `{type:"error",error:{…}}` and the OpenAI `{error:{…}}` envelopes reduce to an object * `error` member; a plain-text body, a string `error`, a torn read are refused. */ export declare function providerErrorObject(body: string): { message?: unknown; code?: unknown; type?: unknown; } | undefined; /** The envelope's WORD — `error.code` when it is a string, else `error.type` when that is — the one reading of "what * the provider called this failure": the echoed `apiErrorCode` and the dialect table's word rows both read it here. */ export declare function providerErrorWord(err: { code?: unknown; type?: unknown; } | undefined): string | undefined; /** * The provider's own word for a refused request, from its JSON error envelope: `error.code` when it is a string (the * OpenAI-family code, e.g. `context_length_exceeded`), else `error.type` (the Anthropic-family word, e.g. * `invalid_request_error`). Read structurally, never from prose. Carried only in the provider echo shape * ({@link isProviderEchoShape}); a word outside it is dropped whole — absent, never truncated — and counted as one * `echo_dropped` event (`field: "apiErrorCode"`). An envelope that states neither yields nothing, and so does a body * that did not end ({@link statedEnvelope}). */ export declare function readProviderErrorCode(body: FailedBody | undefined): string | undefined; /** A failed response's body as read. */ export interface FailedBody { /** Whatever text arrived: the terminal message quotes it, and that is all a prefix is good for. */ readonly text: string; /** * Whether the body ENDED on its own, rather than being cut by the byte cap, the time bound, an abort, or a torn * stream. Parseability is NOT a substitute: a stream that emits a complete envelope and never closes leaves valid * JSON in the buffer when the time bound fires, and an envelope followed by padding past the byte cap parses too * (trailing whitespace is legal JSON). A body that did not end states nothing, in any seat. */ readonly endedNormally: boolean; } /** What a failed response's body states — its envelope — and only when the body ended ({@link FailedBody}). Every * reader of a failed body (the dialect verdict, the echoed word) reads it through here. */ export declare function statedEnvelope(body: FailedBody | undefined): ReturnType<typeof providerErrorObject>; /** The envelope's word as the echo seat carries it ({@link readProviderErrorCode}, for an envelope already in hand — * an in-band error frame's): carried in the echo shape, else dropped whole and counted once. */ export declare function carriedProviderWord(err: { code?: unknown; type?: unknown; } | undefined): string | undefined; export { isProviderEchoShape, PROVIDER_ECHO_SHAPE } from "./provider-echo-shape.js"; /** How the brains read one provider echo value: a non-string or the empty string states nothing (`absent` — no field, * nothing counted); a string in {@link isProviderEchoShape} is `carried`; any other string is `refused` (dropped and * counted as `echo_dropped`). */ export declare function providerEchoReading(value: unknown): "absent" | "carried" | "refused"; /** * Lift the provider's own request identifier off a response, when it stated one in a shape worth * carrying ({@link isProviderEchoShape}). This is the handle a provider's support channel asks for, and * without it a failed turn's only evidence is prose the operator cannot correlate upstream. * * A response whose header values were all refused by the shape yields no id and one `echo_dropped` * telemetry event (`field: "requestId"`, bridged to the `brain.echo_dropped` trace frame) — one per * response, whichever header was refused; a response that states no id at all (no header, or an empty one: * {@link providerEchoReading}) yields none. * * Duck-typed on `headers.get` rather than requiring a real `Response`: the engine's transport is a * caller-injected `fetch`, so a deployment (or a test) may hand back any response-shaped object, and * a missing/throwing header bag must degrade to "no id" instead of failing the call it is describing. */ export declare function readProviderRequestId(res: { headers?: { get?: (name: string) => string | null; }; } | undefined): string | undefined; /** * Does this HTTP status NAME the failure it is being attached to? The one predicate governing every * seat on which this engine publishes a provider status — the terminal assistant frame's * `apiErrorStatus` and the retry-wait status frame's `errorStatus` (#506 ㋑). One helper on purpose: * both seats promise the same three-state discipline, and two copies of the rule would drift. * * Two distinct shapes are refused, and both are reachable: * · The engine's "no status" SENTINEL, 0 — the terminal HTTP throw carries `r?.status ?? 0` when a * transport hands back neither a response nor a throw. Publishing it would be absence rendered as a * number a consumer may format, which is worse than silence. * · A SUCCESS status. The connect loop only streams a response that is `ok` AND has a body, so an * `ok` response with a NULL body (an empty 200 from a proxy, a 204) reaches the terminal throw * carrying 200 — and a provider's own `x-should-retry` verdict can send that same 2xx into a RETRY * wait. The boundary really did fail, but the status is not what failed, and a consumer routing or * rendering by a 2xx is exactly the misreading these seats exist to prevent. * Stalls, connect failures and in-band error frames inside a 200 leave the seat absent by the same rule. */ export declare function namesTheFailure(status: number | undefined): status is number; /** Did the provider REFUSE the request as sent — a 4xx answer (400–499)? The one predicate for the refusal family: the * envelope word's read ({@link readProviderErrorCode}), its lift (`AssistantMessage.apiErrorCode`) and the run's * `terminal.detail` (`provider_rejected`). An auth refusal (401 / 403) and a rate limit (429) are refusals too; which * one is the status's own statement. */ export declare function refusedTheRequest(status: number | undefined): status is number; /** Map an HTTP status to an error class. 401/403 = auth (don't retry); 429 = rate limit; 5xx = server. */ export declare function classifyHttp(status: number): BrainErrorCode; /** Lift the machine-readable code back out of a `[code] …` prefixed `errorMessage` (the single shared * parser — circuit breaker, degradation, and the Runner all read the prefix this way, council #7). */ export declare function extractErrorCode(errorMessage: string | undefined): BrainErrorCode | undefined; /** Strip a leading `[code] ` prefix (including the trailing space) from an `errorMessage`, leaving the * human text. Pairs with {@link extractErrorCode} so a caller can lift the code and clean the message * without re-hardcoding the code list (the Runner does both when assembling `TaskResult`). */ export declare function stripErrorCodePrefix(errorMessage: string): string; /** * The code a deployment's outbound dispatcher stamps on the error of a proxy that answered the tunnel request * (`CONNECT`) with anything but 200, with the HTTP status the proxy gave in `statusCode` on the same node. Not a * runtime code — undici spells that refusal with prose and no stable code — so it is defined here, once, for the * deployment to mint and for {@link DETERMINISTIC_CONNECT_CODES} to read. The proxy ANSWERED: re-sending the same * request through the same proxy only changes that answer if someone changes the proxy's rules. */ export declare const PROXY_TUNNEL_REFUSED = "ERR_PROXY_TUNNEL_REFUSED"; /** * Whether a transport-level connect failure is DETERMINISTIC (see {@link DETERMINISTIC_CONNECT_CODES}) * or gives no such verdict. Classification is by `code` along the error graph — never by message text, * which providers and runtimes reword freely. (Distinct question from {@link classifyFetchThrow}, which * does not classify a failure at all: it reads the runtime's own fixed shell to tell whether a request * was ever built.) * * The graph, not just the chain: undici nests the actionable error under `cause`, and a dual-stack * connect nests one `AggregateError` whose `errors[]` hold the per-family results. A code outside the * set ANYWHERE in the graph returns `"indeterminate"`, and every AGGREGATE LEG must reach a * deterministic verdict of its own. Position decides how silence is read: a WRAPPER with no code (the * `fetch failed` shell, an `AbortSignal.timeout` DOMException whose `code` is a number) is structural * and defers to what it wraps, while a LEG with nothing readable is an outcome nobody accounted for and * blocks the verdict. Fail-safe by construction — every uncertain outcome (empty aggregate, unreadable * leg, a graph past the node budget, no deterministic code at all) answers `"indeterminate"`, i.e. the * pre-existing behavior. */ export type ConnectFailureClass = "deterministic" | "indeterminate"; /** What one walk of a transport error's graph found: the verdict, and — only with a `deterministic` one — the proxy * refusal in it, if any (`status` absent when the node states none readable). One walk answers both, so the verdict * and the sentence it licenses cannot disagree about which node spoke. */ export interface ConnectFailureReading { verdict: ConnectFailureClass; proxyRefusal?: { status: number | undefined; }; } export declare function readConnectFailure(e: unknown): ConnectFailureReading; export declare function classifyConnectFailure(e: unknown): ConnectFailureClass; export declare function proxyTunnelRefusedSentence(status: number | undefined): string; /** What a throw out of `fetch` was about: the NETWORK (retryable — the historic reading of every throw * here), or the REQUEST ITSELF, which is deterministic: identical arguments reproduce it every time. */ export type FetchThrowClass = "transport" | "request_unbuildable"; /** Classify a throw out of `fetch` — the fold point where a request-shaped defect used to be laundered into * a transient network failure and handed to the retry ladder (founding case: a base URL with userinfo * throws a `TypeError` with no `code` and no `cause`, so the connect classifier answered indeterminate and * the call spent its whole backoff budget on a request the runtime never built). * * ONE question — is there TRANSPORT EVIDENCE? — in its three forms: the runtime's own shell sentence, a * `cause` that is a transport fact rather than a parse verdict, or a graph the connect judge reads as a * definite answer ({@link classifyConnectFailure}: the same table, the same unanimity). A `TypeError` with none * was never built. * Measured over ten real shapes; non-`TypeError` stays on the transport arm and an unreadable value answers * `transport`, the pre-existing behavior. Stated cost: KL-933a. */ export declare function classifyFetchThrow(e: unknown): FetchThrowClass; /** The cancellation NAME an untrusted rejection states, or `undefined`. Five sites hand-rolled * `e instanceof Error && e.name === "AbortError"` on a value an injected brain threw, each able to * raise inside an async IIFE with no `.catch`. One guarded, realm-independent read here; WHICH names * count stays at each site, because that is policy and it differs (the breaker excludes `TimeoutError`). */ export declare function abortLikeName(err: unknown): "AbortError" | "TimeoutError" | undefined; /** The brain code an untrusted rejection carries, or `undefined`. Reads the WORD against the code list * rather than testing `instanceof BrainError`, which walks a chain a revoked proxy traps — and the one * site that did so sits in an async IIFE with no `.catch`. */ export declare function brainErrorCodeOf(e: unknown): BrainErrorCode | undefined; /** Render a transport-level error WITHOUT folding away its cause chain: undici rejects with * `TypeError("fetch failed", { cause })` where the CAUSE carries the actionable fact, and the shell * alone reads the same for refused / DNS / mid-stream disconnect. Walks the chain (bounded), appending * each layer's `code:`-prefixed message and skipping layers the parent's message already contains; * a causeless error renders as its plain message. */ export declare function describeNetworkError(e: unknown): string;