UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

188 lines (187 loc) • 7.81 kB
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 }; }