UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

60 lines (59 loc) • 3.42 kB
import type { Model } from "../internal/llm.js"; import type { Brain } from "../core/types.js"; import { type BrainErrorCode } from "./errors.js"; /** Stable prefix of an open-breaker error's detail (`[network] circuit open for <key>`). Exported so * the degradation cascade can detect breaker-open **structurally** instead of string-matching a literal * (council #1) — reword the message only by changing this constant. */ export declare const CIRCUIT_OPEN_MARKER = "circuit open for "; export type BreakerPhase = "closed" | "open" | "half-open"; export interface BreakerSnapshot { phase: BreakerPhase; /** Consecutive failures while closed. */ failures: number; /** When the breaker opened (epoch ms), for cooldown. */ openedAt?: number; /** Probes currently allowed through while half-open. */ halfOpenInFlight?: number; /** * When the current half-open probe round was admitted (epoch ms) — the PROBE LEASE stamp. * * HRD-BRN-1: `record()` is the only writer that leaves half-open, and it only runs once the probe's * stream settles. A probe that never settles — precisely the accepted-then-hangs gateway the breaker * exists for, and the default shape since `firstTokenTimeoutMs`/`idleTimeoutMs` both default off — * therefore held `halfOpenInFlight` forever and made half-open an ABSORBING state: no clock could * release the key again. The lease bounds it: once `cooldownMs` has elapsed since this stamp, the * in-flight probe is presumed lost and a fresh probe is admitted (see `tryPass`). * * Optional/additive: a snapshot deserialized by a shared backend that predates the field simply * adopts a stamp on the first refusal, so the lease starts from then rather than never. */ halfOpenAt?: number; } /** Pluggable breaker state. In-memory by default; a deployment can back it with Redis/TiDB so all * replicas share one circuit (otherwise each replica trips independently and reacts slowly). * * ATOMICITY: the breaker does read-modify-write (`get` → decide → `set`). The in-process Map is safe * under Node's single-threaded run-to-completion. A shared async backend MUST make get/set * **linearizable** (or expose CAS) — otherwise concurrent transitions (closed→open, open→half-open) * race (TOCTOU). Implement it with a Lua script / transaction / optimistic-version on the store. */ export interface BreakerState { get(key: string): BreakerSnapshot | undefined; set(key: string, snap: BreakerSnapshot): void; } export interface CircuitBreakerOptions { /** Consecutive transient failures that trip the breaker. Default 5. */ failureThreshold?: number; /** How long the breaker stays open before a half-open probe. Default 30_000 ms. */ cooldownMs?: number; /** Probes allowed through while half-open. Default 1. */ halfOpenProbes?: number; /** Error codes that count toward tripping. Default `["network","server","rate_limit"]`. */ countCodes?: BrainErrorCode[]; /** Breaker key from a model. Default `"<provider>:<id>"` (per model+provider). */ key?: (model: Model) => string; /** State backend. Default in-memory (per process). */ state?: BreakerState; /** Clock, injectable for tests. */ now?: () => number; } export declare function createCircuitBreakerBrain(inner: Brain, opts?: CircuitBreakerOptions): Brain;