@sema-agent/core
Version:
Stateless, task-oriented AI agent core
60 lines (59 loc) • 3.42 kB
TypeScript
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;