@sema-agent/core
Version:
Stateless, task-oriented AI agent core
160 lines (159 loc) • 7.37 kB
JavaScript
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 };
}