@sema-agent/core
Version:
Stateless, task-oriented AI agent core
188 lines (187 loc) • 7.81 kB
JavaScript
import { createAssistantMessageEventStream, } from "../internal/llm.js";
import { abortLikeName, ZERO_USAGE, extractErrorCode } from "./errors.js";
import { CIRCUIT_OPEN_MARKER } from "./circuit-breaker.js";
import { thrownText } from "../core/thrown-value.js";
export const DEGRADED_DIAGNOSTIC_TYPE = "degraded";
const VALID_REASONS = new Set([
"breaker_open",
"rate_limit",
"budget",
"server_error",
"last_resort",
]);
const MAX_CHAIN = 3;
export function readDegradation(msg) {
const d = msg?.diagnostics?.find((x) => x.type === DEGRADED_DIAGNOSTIC_TYPE);
const det = d?.details;
if (det &&
typeof det.from === "string" &&
typeof det.to === "string" &&
typeof det.reason === "string" &&
VALID_REASONS.has(det.reason)) {
const chain = Array.isArray(det.chain) && det.chain.every((c) => typeof c === "string") ? det.chain : undefined;
return { from: det.from, to: det.to, reason: det.reason, ...(chain && { chain }) };
}
return undefined;
}
function degradeReasonOf(msg) {
if (msg.stopReason !== "error")
return undefined;
const code = extractErrorCode(msg.errorMessage);
if (code === "network" && (msg.errorMessage ?? "").includes(CIRCUIT_OPEN_MARKER))
return "breaker_open";
if (code === "rate_limit")
return "rate_limit";
if (code === "server")
return "server_error";
if (code === "http")
return "last_resort";
return undefined;
}
function tagDegraded(msg, info) {
const diag = {
type: DEGRADED_DIAGNOSTIC_TYPE,
timestamp: Date.now(),
details: { from: info.from, to: info.to, reason: info.reason, ...(info.chain && { chain: info.chain }) },
};
msg.diagnostics = [...(msg.diagnostics ?? []).filter((d) => d.type !== DEGRADED_DIAGNOSTIC_TYPE), diag];
}
function errorMessageOf(model, e, aborted) {
return {
role: "assistant",
content: [{ type: "text", text: "" }],
api: model.api,
provider: model.provider,
model: model.id,
stopReason: aborted ? "aborted" : "error",
errorMessage: thrownText(e),
usage: ZERO_USAGE,
timestamp: Date.now(),
};
}
function errorFrameOf(message) {
return { type: "error", reason: message.stopReason === "aborted" ? "aborted" : "error", error: message };
}
function terminalOf(message) {
return message.stopReason === "error" || message.stopReason === "aborted"
? errorFrameOf(message)
: { type: "done", reason: message.stopReason, message };
}
async function forward(stream, sink) {
let sawContent = false;
let heldStart;
let terminal;
for await (const ev of stream) {
if (ev.type === "done" || ev.type === "error") {
terminal = ev;
break;
}
if (ev.type === "start" && !sawContent) {
heldStart = ev;
continue;
}
if (!sawContent) {
sawContent = true;
if (heldStart)
sink.push(heldStart);
}
sink.push(ev);
}
const message = await stream.result();
return { message, sawContent, heldStart, terminal };
}
export function passthroughStream(brain, model, context, options) {
const out = createAssistantMessageEventStream();
void (async () => {
try {
const inner = await brain.stream(model, context, options);
let sawTerminal = false;
for await (const ev of inner) {
if (ev.type === "done" || ev.type === "error")
sawTerminal = true;
out.push(ev);
}
if (!sawTerminal) {
await inner.result();
out.push(errorFrameOf(errorMessageOf(model, new Error("brain stream ended without a terminal event"), false)));
}
}
catch (e) {
const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
out.push(errorFrameOf(errorMessageOf(model, e, aborted)));
}
})();
return out;
}
export function createDegradingBrain(opts) {
const triggers = new Set(opts.downgradeOn ?? ["rate_limit", "breaker_open", "server_error", "last_resort"]);
const rawHops = opts.fallbacks ?? [];
const seenIds = new Set();
const hops = rawHops.filter((h) => (seenIds.has(h.model.id) ? false : (seenIds.add(h.model.id), true))).slice(0, MAX_CHAIN);
if (hops.length === 0) {
throw new Error("createDegradingBrain: `fallbacks` needs at least one {brain, model} hop (RB-482 #3: the legacy fallback+fallbackModel pair is retired — a chain-of-one is `fallbacks: [{brain, model}]`)");
}
const stream = (model, context, options) => {
if (options?.resilience?.allowDegrade === false) {
return passthroughStream(opts.primary, model, context, options);
}
const out = createAssistantMessageEventStream();
void (async () => {
let degradeInfo;
let lastAttempted = model;
try {
const fbOptions = options
? { ...options, apiKey: undefined, headers: undefined, metadata: undefined }
: undefined;
let current = await forward(await opts.primary.stream(model, context, options), out);
let failedModelId = model.id;
const chainPath = [];
for (const hop of hops) {
const reason = degradeReasonOf(current.message);
const eligible = !current.sawContent && options?.signal?.aborted !== true && reason !== undefined && triggers.has(reason);
if (!eligible)
break;
if (hop.model.id === failedModelId)
continue;
if (hop.brain.adjudicateRoute?.(hop.model)?.ok === false)
continue;
chainPath.push(hop.model.id);
degradeInfo = {
from: model.id,
to: hop.model.id,
reason: degradeInfo?.reason ?? reason,
chain: [...chainPath],
};
failedModelId = hop.model.id;
lastAttempted = hop.model;
current = await forward(await hop.brain.stream(hop.model, context, fbOptions), out);
}
if (!degradeInfo) {
if (!current.sawContent && current.heldStart)
out.push(current.heldStart);
out.push(current.terminal ?? terminalOf(current.message));
return;
}
const fbFinal = current.terminal?.type === "done"
? current.terminal.message
: current.terminal?.type === "error"
? current.terminal.error
: current.message;
tagDegraded(fbFinal, degradeInfo);
if (!current.sawContent && current.heldStart)
out.push(current.heldStart);
out.push(current.terminal ?? terminalOf(current.message));
}
catch (e) {
const aborted = options?.signal?.aborted === true || abortLikeName(e) !== undefined;
const errMsg = errorMessageOf(lastAttempted, e, aborted);
if (degradeInfo)
tagDegraded(errMsg, degradeInfo);
out.push(errorFrameOf(errMsg));
}
})();
return out;
};
const adjudicateRoute = (model, perModelAuth) => opts.primary.adjudicateRoute?.(model, perModelAuth);
return { stream, adjudicateRoute };
}