UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

160 lines (159 loc) • 7.37 kB
import { createAssistantMessageEventStream } from "../internal/llm.js"; import { abortLikeName, brainErrorCodeOf, IN_BAND_OUTPUT_CODES, ZERO_USAGE, extractErrorCode } from "./errors.js"; import { emitBrainStatus, emitBrainTelemetry } from "./status-sink.js"; import { thrownText } from "../core/thrown-value.js"; export const CIRCUIT_OPEN_MARKER = "circuit open for "; class InMemoryBreakerState { m = new Map(); get(k) { return this.m.get(k); } set(k, s) { this.m.set(k, s); } } function errorAssistantMessage(model, code, detail, stopReason = "error") { return { role: "assistant", content: [{ type: "text", text: "" }], api: model.api, provider: model.provider, model: model.id, stopReason, errorMessage: `[${code}] ${detail}`, usage: ZERO_USAGE, timestamp: Date.now(), }; } function finiteBreakerKnob(value, knob, fallback, wholeAtLeastOne) { if (value === undefined) return fallback; const ok = wholeAtLeastOne ? Number.isInteger(value) && value >= 1 : Number.isFinite(value) && value >= 0; if (!ok) { const e = new Error(`createCircuitBreakerBrain: ${knob} must be ${wholeAtLeastOne ? "a whole number of 1 or more" : "a finite, non-negative number of milliseconds"} (got ${String(value)}) — an unevaluable knob leaves the breaker permanently open-loop, which is a protection that is silently not there`); e.code = "config.circuit_breaker_invalid"; throw e; } return value; } export function createCircuitBreakerBrain(inner, opts = {}) { const failureThreshold = finiteBreakerKnob(opts.failureThreshold, "failureThreshold", 5, true); const cooldownMs = finiteBreakerKnob(opts.cooldownMs, "cooldownMs", 30_000, false); const halfOpenProbes = finiteBreakerKnob(opts.halfOpenProbes, "halfOpenProbes", 1, true); const countCodes = new Set(opts.countCodes ?? ["network", "server", "rate_limit"]); const keyOf = opts.key ?? ((m) => `${m.provider}:${m.id}`); const state = opts.state ?? new InMemoryBreakerState(); const now = opts.now ?? Date.now; const snap = (key) => state.get(key) ?? { phase: "closed", failures: 0 }; const setState = (key, prevPhase, next) => { state.set(key, next); if (next.phase !== prevPhase) { emitBrainTelemetry({ kind: "breaker", key, phase: next.phase, failures: next.failures }); } }; const tryPass = (key) => { const s = snap(key); if (s.phase === "closed") return { pass: true }; if (s.phase === "open") { if (s.openedAt === undefined) { state.set(key, { ...s, openedAt: now() }); return { pass: false, retryInMs: cooldownMs }; } const elapsed = now() - s.openedAt; if (elapsed >= cooldownMs) { setState(key, s.phase, { ...s, phase: "half-open", halfOpenInFlight: 1, halfOpenAt: now() }); return { pass: true }; } return { pass: false, retryInMs: cooldownMs - elapsed }; } const inFlight = s.halfOpenInFlight ?? 0; if (inFlight < halfOpenProbes) { state.set(key, { ...s, halfOpenInFlight: inFlight + 1, halfOpenAt: s.halfOpenAt ?? now() }); return { pass: true }; } const leaseStart = s.halfOpenAt; if (leaseStart === undefined) { state.set(key, { ...s, halfOpenAt: now() }); return { pass: false, retryInMs: cooldownMs }; } const held = now() - leaseStart; if (held >= cooldownMs) { state.set(key, { ...s, halfOpenInFlight: 1, halfOpenAt: now() }); return { pass: true }; } return { pass: false, retryInMs: cooldownMs - held }; }; const record = (key, failedCode, viaProbe = true) => { const s = snap(key); const isCounted = failedCode !== undefined && countCodes.has(failedCode); if (s.phase === "half-open") { if (!viaProbe) return; setState(key, s.phase, isCounted ? { phase: "open", failures: s.failures, openedAt: now() } : { phase: "closed", failures: 0 }); return; } if (!viaProbe && s.phase === "open") return; if (isCounted) { const failures = s.failures + 1; setState(key, s.phase, failures >= failureThreshold ? { phase: "open", failures, openedAt: now() } : { phase: "closed", failures }); } else if (failedCode === undefined && s.phase !== "open") { state.set(key, { phase: "closed", failures: 0 }); } }; const stream = (model, context, options) => { const out = createAssistantMessageEventStream(); const key = keyOf(model); void (async () => { const bypass = options?.resilience?.bypassBreaker === true; const decision = bypass ? { pass: true } : tryPass(key); if (!decision.pass) { emitBrainStatus({ phase: "circuit_open", detail: "repeated errors, pausing briefly", ...(decision.retryInMs !== undefined ? { retryInMs: decision.retryInMs, retryInSec: Math.ceil(decision.retryInMs / 1000) } : {}), }); out.push({ type: "error", reason: "error", error: errorAssistantMessage(model, "network", `${CIRCUIT_OPEN_MARKER}${key}`) }); return; } try { const inner_ = await inner.stream(model, context, options); let terminal; for await (const ev of inner_) { if (ev.type === "done" || ev.type === "error") { terminal = ev; break; } out.push(ev); } const message = await inner_.result(); const failed = message.stopReason === "error" && options?.signal?.aborted !== true; const rawCode = extractErrorCode(message.errorMessage); const breakerCode = rawCode === undefined ? "http" : IN_BAND_OUTPUT_CODES.has(rawCode) && !countCodes.has(rawCode) ? "http" : rawCode; record(key, failed ? breakerCode : options?.signal?.aborted ? "http" : undefined, !bypass); try { out.push(terminal ?? { type: "done", reason: message.stopReason, message }); } catch { } } catch (e) { const aborted = options?.signal?.aborted === true || abortLikeName(e) === "AbortError"; const code = brainErrorCodeOf(e) ?? "network"; record(key, aborted ? "http" : code, !bypass); out.push({ type: "error", reason: aborted ? "aborted" : "error", error: errorAssistantMessage(model, code, thrownText(e), aborted ? "aborted" : "error"), }); } })(); return out; }; const adjudicateRoute = (model, perModelAuth) => inner.adjudicateRoute?.(model, perModelAuth); return { stream, adjudicateRoute }; }