@sema-agent/core
Version:
Stateless, task-oriented AI agent core
197 lines (196 loc) • 15.5 kB
TypeScript
/**
* 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";
/** 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. */
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 a REFUSED request (a 4xx answer), read from its JSON error envelope by
* {@link readProviderErrorCode} at the HTTP throw. Lifted onto `AssistantMessage.apiErrorCode` at the terminal-shell
* mint. Absent when the answer was not a 4xx or the envelope stated no word in the echo shape. */
readonly providerCode?: string;
constructor(code: BrainErrorCode, detail: string, status?: number, opts?: {
inputTooLong?: boolean;
inputTooLongRuledOut?: boolean;
apiError?: boolean;
providerCode?: string;
});
}
/** 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 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.
*/
export declare function readProviderErrorCode(body: string): 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;
/**
* 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";
export declare function classifyConnectFailure(e: unknown): ConnectFailureClass;
/** 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 two forms: the runtime's own shell sentence, or a
* `cause` that is a transport fact rather than a parse verdict. A `TypeError` with neither 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;