UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

414 lines (413 loc) • 18.6 kB
import { AUTO_MODE_DEFAULT_TIMEOUT_MS, AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS, AUTO_MODE_DENIAL_LIMIT_DEFAULTS } from "./auto-mode-defaults.js"; import { thrownIs } from "./thrown-value.js"; import { describeRejectedValue } from "./tool-search-setting.js"; export const AUTO_MODE_UNAVAILABLE_CAUSES = Object.freeze(["error", "timeout"]); const AUTO_MODE_UNAVAILABLE_CAUSE_SET = new Set(AUTO_MODE_UNAVAILABLE_CAUSES); export function isAutoModeUnavailableCause(v) { return AUTO_MODE_UNAVAILABLE_CAUSE_SET.has(v); } export const AUTO_MODE_PARSE_FAILURES = Object.freeze(["missing", "conflict"]); const AUTO_MODE_PARSE_FAILURE_SET = new Set(AUTO_MODE_PARSE_FAILURES); export function isAutoModeParseFailure(v) { return AUTO_MODE_PARSE_FAILURE_SET.has(v); } export const AUTO_MODE_FALLBACK_CAUSES = Object.freeze(["error", "timeout", "parse_error"]); const AUTO_MODE_FALLBACK_CAUSE_SET = new Set(AUTO_MODE_FALLBACK_CAUSES); export function isAutoModeFallbackCause(v) { return AUTO_MODE_FALLBACK_CAUSE_SET.has(v); } export const AUTO_MODE_CLASSIFIER_FALLBACKS = Object.freeze(["main", "none"]); const AUTO_MODE_CLASSIFIER_FALLBACK_SET = new Set(AUTO_MODE_CLASSIFIER_FALLBACKS); export function isAutoModeClassifierFallback(v) { return AUTO_MODE_CLASSIFIER_FALLBACK_SET.has(v); } export function autoModeClassifierFallbackOf(v) { if (v === undefined) return "main"; if (isAutoModeClassifierFallback(v)) return v; const e = new Error(`RunnerDeps.autoMode.classifierFallback must be one of ${AUTO_MODE_CLASSIFIER_FALLBACKS.map((x) => `"${x}"`).join(" | ")} ` + `(got ${describeRejectedValue(v)}) — omit it for the default "main"`); e.code = "config.auto_mode_classifier_fallback_invalid"; throw e; } const MAX_TIMER_DELAY_MS = 2_147_483_647; export function isAutoModeTimeoutMs(v) { return typeof v === "number" && Number.isFinite(v) && v >= 1 && v <= MAX_TIMER_DELAY_MS; } export function autoModeTimeoutMsOf(v) { if (v === undefined) return AUTO_MODE_DEFAULT_TIMEOUT_MS; if (isAutoModeTimeoutMs(v)) return v; const e = new Error(`RunnerDeps.autoMode.timeoutMs must be a number of milliseconds between 1 and ${MAX_TIMER_DELAY_MS} ` + `(got ${describeRejectedValue(v)}) — omit it for the default ${AUTO_MODE_DEFAULT_TIMEOUT_MS}`); e.code = "config.auto_mode_timeout_invalid"; throw e; } export const AUTO_MODE_ROUND_NOT_ASKED_CODE = "auto_mode.round_not_asked"; export function autoModeRoundNotAsked(reason) { let detail; try { const m = reason.message; if (typeof m === "string") detail = m.slice(0, 200); } catch { } const e = new Error(`the auto-mode classification could not be assembled: ${detail ?? describeRejectedValue(reason)}`, { cause: reason }); e.code = AUTO_MODE_ROUND_NOT_ASKED_CODE; return e; } function isAutoModeRoundNotAsked(e) { try { return typeof e === "object" && e !== null && e.code === AUTO_MODE_ROUND_NOT_ASKED_CODE; } catch { return false; } } export function rungDidNotRule(v) { if (v.kind === "unavailable") return v.cause; if (v.kind === "parse_error") return v.form === "conflict" ? undefined : "parse_error"; return undefined; } export function classifierUnavailableDenyMessage(toolName, cause, attribution) { const parenthetical = cause === "timeout" ? " (timed out)" : ""; const fb = attribution?.fallback; const rootCause = fb === undefined ? "" : `The classifier model "${fb.from}" did not answer (${fb.cause}) and neither did the model it fell back to, "${fb.to}" (${cause}). `; return (`The auto-mode classifier is temporarily unavailable${parenthetical}, so auto mode cannot determine the safety of ${toolName} right now. ` + rootCause + "Wait a moment and then try this action again. " + "If it keeps failing, continue with other tasks that don't require this action and come back to it later. " + "Note: reading files, searching code, and other read-only operations do not require the classifier and can still be used."); } export function classifierUnavailableAskMessage(askText, cause) { const parenthetical = cause === "timeout" ? " (timed out)" : ""; return `${askText}\n\nThe auto-mode classifier is temporarily unavailable${parenthetical}, so auto mode could not judge this call; it needs an approval instead of the classifier's verdict.`; } export function classifierParseFailureDenyMessage(attribution) { const stem = "Auto mode could not evaluate this action and is blocking it for safety"; return attribution === undefined ? `${stem} — the classifier's reply carried no verdict.` : `${stem} — the classifier model "${attribution.modelUsed}" replied without a verdict.`; } function stripThinking(text) { return text.replace(/<(thinking|think)>[\s\S]*?<\/\1>/gi, "").replace(/<(thinking|think)>[\s\S]*$/i, ""); } const BARE_ANSWER_WRAPPERS = [ ["**", "**"], ["__", "__"], ["*", "*"], ["_", "_"], ["`", "`"], ['"', '"'], ["'", "'"], ["«", "»"], ]; function unwrapBareAnswer(text) { let s = text; for (;;) { const before = s; s = s.trim().replace(/[.!]+$/, "").trim(); for (const [open, close] of BARE_ANSWER_WRAPPERS) { if (s.length > open.length + close.length && s.startsWith(open) && s.endsWith(close)) { s = s.slice(open.length, s.length - close.length); break; } } if (s === before) return s; } } function bareAnswerOf(text) { const visible = text.replace(/[\u200b-\u200d\ufeff]/g, "").trim(); if (visible.includes("```") || visible.includes("\n")) return undefined; let s = unwrapBareAnswer(visible); const labelled = labelledAnswer(s); if (labelled !== undefined) s = unwrapBareAnswer(labelled); const word = /^(yes|no)$/i.exec(s)?.[1]?.toLowerCase(); return word === "yes" || word === "no" ? word : undefined; } function labelledAnswer(s) { const label = "(?:final[ \\t]+)?(?:block|answer|verdict|decision)"; for (const [open, close] of [["", ""], ...BARE_ANSWER_WRAPPERS]) { const re = new RegExp(`^${escapeRe(open)}${label}${escapeRe(close)}[ \\t]*[:=][ \\t]*(.*)$`, "i"); const m = re.exec(s); if (m !== null) return m[1]; } return undefined; } function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } export function parseAutoModeResponse(text, options) { const rawAnswers = new Set([...text.matchAll(/<block>(yes|no)\b/gi)].map((m) => m[1].toLowerCase())); if (rawAnswers.size > 1) return { kind: "parse_error", raw: text, form: "conflict" }; const stripped = stripThinking(text); const hits = [...stripped.matchAll(/<block>(yes|no)\b(<\/block>)?/gi)]; let answer; if (hits.length > 0) { const answers = new Set(hits.map((m) => m[1].toLowerCase())); if (answers.size !== 1) return { kind: "parse_error", raw: text, form: "conflict" }; answer = answers.has("yes") ? "yes" : "no"; } else { answer = options?.bareAnswer === false ? undefined : bareAnswerOf(stripped); if (answer === undefined) return { kind: "parse_error", raw: text, form: "missing" }; if (rawAnswers.size > 0 && !rawAnswers.has(answer)) return { kind: "parse_error", raw: text, form: "conflict" }; } if (answer === "no") return { kind: "allow" }; const category = /<category>([\s\S]*?)<\/category>/i.exec(stripped)?.[1]?.trim() ?? ""; const reason = /<reason>([\s\S]*?)<\/reason>/.exec(stripped)?.[1]?.trim() ?? ""; return { kind: "block", category, reason }; } export function createAutoModeDecider(opts) { const timeoutMs = autoModeTimeoutMsOf(opts.timeoutMs); if (opts.candidates !== undefined) { if (opts.candidates.length === 0) { throw new Error("AutoModeDeciderOptions.candidates must name at least one model — omit the member for the leg's own model, never an empty ladder"); } const bad = opts.candidates.findIndex((m) => typeof m !== "string" || m.trim() === ""); if (bad >= 0) throw new Error(`AutoModeDeciderOptions.candidates[${bad}] must be a non-empty model id (got ${JSON.stringify(opts.candidates[bad])})`); } const ladder = opts.candidates ?? [undefined]; const decide = async (input, signal) => { const startedAt = performance.now(); const round = {}; const verdict = await decideUnreported(input, round, signal); if (opts.onClassified !== undefined) { try { opts.onClassified({ toolCallId: input.req.toolCallId, ms: Math.max(0, Math.round(performance.now() - startedAt)), verdict: verdict.kind, ...(verdict.kind === "unavailable" ? { cause: verdict.cause } : {}), ...(round.notAsked !== undefined ? { notAsked: round.notAsked } : {}), ...(verdict.classifier !== undefined ? { classifier: verdict.classifier } : {}), }); } catch { } } return verdict; }; return { decide, timeoutMs }; async function decideUnreported(input, round, signal) { const causes = []; let verdict = { kind: "unavailable", cause: "error" }; let i = 0; const assembled = await assembleRound(input, round, signal); for (; round.notAsked === undefined && i < ladder.length; i++) { const name = ladder[i]; verdict = await askRung(input, signal, name !== undefined ? { index: i, model: name } : undefined, round, assembled); const cause = rungDidNotRule(verdict); if (cause === undefined) break; causes.push(cause); if (round.notAsked !== undefined || signal?.aborted) break; } const landed = round.notAsked !== undefined ? 0 : Math.min(i, ladder.length - 1); const requested = ladder[0]; const used = ladder[landed]; if (requested === undefined || used === undefined) return verdict; const from = landed > 0 ? ladder[landed - 1] : undefined; const movedBy = landed > 0 ? causes[landed - 1] : undefined; return { ...verdict, classifier: Object.freeze({ modelRequested: requested, modelUsed: used, ...(from !== undefined && movedBy !== undefined ? { fallback: Object.freeze({ from, to: used, cause: movedBy }) } : {}), }), }; } async function assembleRound(input, round, signal) { const assemble = opts.assemble; if (assemble === undefined) return undefined; try { return await withDeadline(timeoutMs, signal, (inner) => assemble(input, inner)); } catch (e) { round.notAsked = isAutoModeTimeout(e) ? "timeout" : "failed"; return undefined; } } async function askRung(input, signal, rung, round, assembled) { try { return parseAutoModeResponse(await withDeadline(timeoutMs, signal, (inner) => opts.classify(input, inner, rung, assembled))); } catch (e) { if (isAutoModeRoundNotAsked(e)) { round.notAsked = "failed"; return { kind: "unavailable", cause: "error" }; } if (signal?.aborted) return { kind: "unavailable", cause: "error" }; return { kind: "unavailable", cause: isAutoModeTimeout(e) ? "timeout" : "error" }; } } } async function withDeadline(capMs, signal, call) { if (signal?.aborted === true) throw new Error("the classification was cancelled before it was dispatched"); let timer; let onOuterAbort; const inner = new AbortController(); try { return await new Promise((resolve, reject) => { let settled = false; const settle = (end) => { if (settled) return; settled = true; end(); }; onOuterAbort = () => { inner.abort(); settle(() => reject(new Error("the classification was cancelled"))); }; signal?.addEventListener("abort", onOuterAbort, { once: true }); timer = setTimeout(() => { settle(() => { inner.abort(); reject(new AutoModeTimeout()); }); }, capMs); let pending; try { pending = call(inner.signal); } catch (e) { settle(() => reject(e)); return; } pending.then((v) => settle(() => resolve(v)), (e) => settle(() => reject(e))); }); } finally { if (timer !== undefined) clearTimeout(timer); if (signal !== undefined && onOuterAbort !== undefined) signal.removeEventListener("abort", onOuterAbort); } } class AutoModeTimeout extends Error { constructor() { super("auto-mode classify timeout"); } } const isAutoModeTimeout = (e) => thrownIs(e, AutoModeTimeout); export function readDenialLimitFallback(v) { if (typeof v !== "object" || v === null) return undefined; const o = v; const count = (x) => typeof x === "number" && Number.isFinite(x) && x >= 0; if (!count(o["consecutive"]) || !count(o["total"]) || !count(o["autoDenyAfterMs"])) return undefined; if (o["limit"] !== "consecutive" && o["limit"] !== "total") return undefined; return { consecutive: o["consecutive"], total: o["total"], limit: o["limit"], autoDenyAfterMs: o["autoDenyAfterMs"] }; } export function unarmedWindow(fallback) { return { ...countsOf(fallback), autoDenyAfterMs: 0 }; } function countsOf(fallback) { return { consecutive: fallback.consecutive, total: fallback.total, limit: fallback.limit }; } const DENIAL_LIMIT_MEMBERS = new Set(["maxConsecutive", "maxTotal", "autoDenyAfterMs"]); export function autoModeDenialLimitOf(v) { const refuse = (what) => { const e = new Error(`RunnerDeps.autoMode.denialLimit${what}`); e.code = "config.auto_mode_denial_limit_invalid"; throw e; }; if (v === undefined) return { ...AUTO_MODE_DENIAL_LIMIT_DEFAULTS, autoDenyAfterMs: AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS }; if (typeof v !== "object" || v === null || Array.isArray(v)) return refuse(` must be a record of bounds (got ${describeRejectedValue(v)}) — omit it for the defaults`); const opts = v; const stray = Object.keys(opts).find((k) => !DENIAL_LIMIT_MEMBERS.has(k)); if (stray !== undefined) return refuse(` has no member ${JSON.stringify(stray)} — its members are maxConsecutive, maxTotal and autoDenyAfterMs`); const bound = (name, dflt) => { const b = opts[name]; if (b === undefined) return dflt; return typeof b === "number" && Number.isInteger(b) && b >= 1 ? b : refuse(`.${name} must be a positive integer (got ${describeRejectedValue(b)}) — omit it for the default ${dflt}`); }; const w = opts["autoDenyAfterMs"]; const autoDenyAfterMs = w === undefined ? AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS : typeof w === "number" && Number.isInteger(w) && w >= 0 && w <= MAX_TIMER_DELAY_MS ? w : refuse(`.autoDenyAfterMs must be an integer between 0 and ${MAX_TIMER_DELAY_MS} (got ${describeRejectedValue(w)}) — 0 disarms the window; omit it for the default ${AUTO_MODE_DENIAL_AUTO_DENY_DEFAULT_MS}`); return { maxConsecutive: bound("maxConsecutive", AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxConsecutive), maxTotal: bound("maxTotal", AUTO_MODE_DENIAL_LIMIT_DEFAULTS.maxTotal), autoDenyAfterMs }; } export function screenAutoModeKnobs(face) { autoModeTimeoutMsOf(face.timeoutMs); autoModeClassifierFallbackOf(face.classifierFallback); autoModeDenialLimitOf(face.denialLimit); } export function createAutoModeDenialTracker(opts) { const { maxConsecutive, maxTotal, autoDenyAfterMs } = autoModeDenialLimitOf(opts); let consecutive = 0; let total = 0; let timedFallbackShown = false; let windowArmedAt; let minted = new WeakSet(); return { recordBlock() { consecutive += 1; total += 1; if (consecutive < maxConsecutive && total < maxTotal) return { limitReached: false }; const totalTripped = total >= maxTotal; const fallback = { consecutive, total, limit: totalTripped ? "total" : "consecutive" }; minted.add(fallback); if (totalTripped) { consecutive = 0; total = 0; timedFallbackShown = false; windowArmedAt = undefined; minted = new WeakSet(); minted.add(fallback); } return { limitReached: true, fallback }; }, armTimedWindow(fallback) { if (!minted.delete(fallback) || fallback.limit === "total" || timedFallbackShown || autoDenyAfterMs === 0) return unarmedWindow(fallback); timedFallbackShown = true; windowArmedAt = Date.now(); return { ...countsOf(fallback), autoDenyAfterMs }; }, recordAllow() { consecutive = 0; timedFallbackShown = false; windowArmedAt = undefined; minted = new WeakSet(); }, snapshot: () => ({ consecutive, total, timedFallbackShown, ...(windowArmedAt !== undefined ? { windowArmedAt } : {}) }), }; } export function denialLimitFallbackMessage(fallback, latestBlockedAction) { return `Classifier denial limit exceeded, falling back to prompting: ${denialLimitSentence(fallback)}\n\nLatest blocked action: ${latestBlockedAction}`; } export function denialLimitSentence(fallback) { return fallback.limit === "total" ? `${fallback.total} actions were blocked this session. Please review the transcript before continuing.` : `${fallback.consecutive} consecutive actions were blocked. Please review the transcript before continuing.`; }