@sema-agent/core
Version:
Stateless, task-oriented AI agent core
94 lines (93 loc) • 6.36 kB
TypeScript
/**
* Error/budget-aware **degradation cascade** (1.39, design/21, design/17 §5C).
*
* Unlike failover (a *peer* backup whose goal is "same quality, a gateway that's actually up"),
* degradation **deliberately downgrades** to a cheaper / different-provider model to KEEP SERVING when
* the primary is rate-limited or its circuit breaker is open — accepting some quality loss instead of
* failing the task. Opt-in; the served turn is tagged so the Runner can surface `TaskResult.degraded`.
*
* This file handles the four **reactive** triggers visible at the brain layer — `rate_limit`,
* `breaker_open`, `server_error` and `last_resort` (design/126; all four are the default gate, narrow it
* with `downgradeOn`). The proactive **near-budget** trigger needs the task budget (only the Runner sees
* it) and is not implemented here; `"budget"` is reserved in `DegradeReason` for it.
*
* Detection is **structural**, not literal string-matching: the breaker-open signal is
* `CIRCUIT_OPEN_MARKER` (exported from circuit-breaker.ts) and the code comes from the shared
* `extractErrorCode`. Degradation must be the **outermost** brain in a stack (so it sees the inner
* failover/breaker/timeout outcomes and can downgrade on them), and the downgrade target is the ordered
* `fallbacks` chain (RB-482 #3 — the single `fallback`+`fallbackModel` pair is retired):
* `createDegradingBrain({ primary: failover(breaker(timeout(brain))), fallbacks: [{ brain, model }] })`.
*/
import { type AssistantMessageDiagnostic, type Model } from "../internal/llm.js";
import type { Brain } from "../core/types.js";
/** Diagnostic `type` the decorator stamps on a degraded turn's message; the Runner reads it back. */
export declare const DEGRADED_DIAGNOSTIC_TYPE = "degraded";
/** Why a turn was degraded. `"budget"` is reserved for the v2 near-budget trigger (Runner-level).
* design/126 (CC 批 β) widens the reactive set: `"server_error"` = a `server`-class failure (5xx incl.
* 529 overloaded, 408/409) that ESCAPED the inner brain's own retry budget (CC parity: retry in place
* first, then fall back — CC :600552); `"last_resort"` = an `http`-class status error (404
* model-not-found and other non-retryable statuses — CC :600513/:600588 folds both into "try the next
* model instead of dying"). Deliberate deviations from CC, documented in design/126 D2: `rate_limit`
* stays a trigger (CC waits in place; an unattended headless task waiting forever is a dead task), and
* `auth` (401/403) NEVER triggers (CC falls back on 403; silently swapping models over a
* permission/key misconfiguration is worse than failing loud for headless). */
export type DegradeReason = "breaker_open" | "rate_limit" | "budget" | "server_error" | "last_resort";
export interface DegradationInfo {
/** The model id that was meant to serve (the primary). */
from: string;
/** The cheaper model id that actually served (or the LAST model attempted when the chain failed too). */
to: string;
/** The FIRST trigger that started the downgrade walk (later hops may fail for other reasons). */
reason: DegradeReason;
/** design/126: the model ids attempted after the primary, in order (length 1 = the classic single
* hop). Optional — absent on markers written by pre-chain versions. */
chain?: string[];
}
interface DegradingBrainBaseOptions {
/** The preferred brain (typically `failover(breaker(timeout(openai)))`). */
primary: Brain;
/**
* Which reactive triggers cause a downgrade. Default = all four reactive classes
* (`rate_limit`/`breaker_open`/`server_error`/`last_resort`). Pass it explicitly to narrow.
*/
downgradeOn?: DegradeReason[];
}
/** RB-482 #3 (ruled 2026-08-03): ONE form — the ordered `fallbacks` chain. The legacy single
* `fallback`+`fallbackModel` pair is retired (a chain-of-one is `fallbacks: [{brain, model}]`), and
* its pre-126 two-trigger default retires with it: every construction gets the full four-class gate
* unless `downgradeOn` narrows it explicitly. */
export type DegradingBrainOptions = DegradingBrainBaseOptions & {
/**
* design/126 (CC 批 β) — the ORDERED downgrade chain (up to 3 entries, CC `Kjd` parity; excess is
* truncated, duplicate model ids deduped, and an entry whose model id equals the model that just
* failed is skipped at walk time). Each hop carries its OWN brain — carry its credentials in its
* config (e.g. `createOpenAIBrain({ apiKey, baseUrl })`): the decorator clears the primary's
* per-call `apiKey`/`headers`/`metadata` for every hop so a hop never leaks the primary's identity
* to a different provider.
*/
fallbacks: Array<{
brain: Brain;
model: Model;
}>;
};
/** Pull a degradation marker off a served message (used by the Runner to fill `TaskResult.degraded`). */
export declare function readDegradation(msg: {
diagnostics?: AssistantMessageDiagnostic[];
} | undefined): DegradationInfo | undefined;
/**
* design/131 — resilience-standdown pass-through: forward one brain's stream
* verbatim, but keep the decorator discipline that a REJECTING inner brain (throw instead of an
* error event) still leaves a terminal on the returned stream — otherwise `result()` never
* resolves and the harness hangs (the same class as the degradation-brain blocker).
*
* HRD-BRN-5: that discipline covered only the REJECT shape. An inner stream ENDED BARE (`end()` with no
* terminal event) drained the `for await` loop and then pushed nothing, so `result()` stayed pending
* forever — the exact hang class this function's contract claims to prevent, and reached through the
* two standdown knobs (`resilience.allowFailover:false` / `allowDegrade:false`) that a benchmark or eval
* harness turns on to see a provider's TRUE failure shape. Standing resilience down was therefore
* strictly MORE hang-prone than leaving it on, since the normal failover path awaits `inner.result()`
* and lands in its catch. Both shapes now leave exactly one terminal.
*/
export declare function passthroughStream(brain: Brain, model: Model, context: Parameters<Brain["stream"]>[1], options: Parameters<Brain["stream"]>[2]): ReturnType<Brain["stream"]>;
export declare function createDegradingBrain(opts: DegradingBrainOptions): Brain;
export {};