@sema-agent/core
Version:
Stateless, task-oriented AI agent core
414 lines (413 loc) • 18.6 kB
JavaScript
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.`;
}