UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

94 lines (93 loc) • 6.36 kB
/** * 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 {};