@sema-agent/core
Version:
Stateless, task-oriented AI agent core
256 lines (255 loc) • 12.6 kB
JavaScript
import { sameRouteIdentity } from "../../brain/route-adjudicator.js";
import { thinkingOffSpelled } from "../../brain/reasoning.js";
import { AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER, AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS, AUTO_MODE_CLASSIFIER_MAX_TOKENS, AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS } from "../auto-mode-defaults.js";
import { autoModeClassifierFallbackOf, parseAutoModeResponse } from "../auto-mode.js";
import { DEFAULT_TIER_ORDER, expandTiers, resolveTaskModel, roleSpecNamesModel } from "../roles.js";
import { emitTrace } from "../trace.js";
import { deliverEngineNotice } from "../types.js";
export function classifierLadderRungs(knob, classifier, main) {
if (autoModeClassifierFallbackOf(knob) === "none")
return [classifier];
return sameRouteQuietly(classifier, main) ? [classifier] : [classifier, main];
}
function sameRouteQuietly(a, b) {
try {
return sameRouteIdentity(a, b);
}
catch {
return false;
}
}
export const CLASSIFIER_ROLE_DEFAULT_REASONS = Object.freeze(["tier_below_main", "tier_table", "role_fallback", "main_only", "role_unresolvable"]);
export function classifierSeatModel(args) {
const { main } = args;
const deps = normalizeSeatDeps(args.deps);
if (roleSpecNamesModel(deps.roles?.classifier)) {
try {
return { model: resolveTaskModel({ modelRole: "classifier" }, deps).model };
}
catch {
return { model: main, defaulted: "role_unresolvable" };
}
}
const below = tierBelowMain(deps, main);
if (below !== undefined)
return below;
try {
const model = resolveTaskModel({ modelRole: "classifier" }, deps).model;
if (sameRouteQuietly(model, main))
return { model, defaulted: "main_only" };
return { model, defaulted: deps.tiers !== undefined && Object.keys(deps.tiers).length > 0 ? "tier_table" : "role_fallback" };
}
catch {
return { model: main, defaulted: "main_only" };
}
}
function normalizeSeatDeps(deps) {
if (deps.tiers === undefined)
return deps;
try {
return { ...deps, models: expandTiers(deps.models, deps.tiers) };
}
catch {
return deps;
}
}
function tierBelowMain(deps, main) {
if (deps.tiers === undefined || Object.keys(deps.tiers).length === 0 || deps.models === undefined)
return undefined;
const bound = DEFAULT_TIER_ORDER.map((tier) => deps.models?.[tier]);
let mainTier = -1;
for (let i = bound.length - 1; i >= 0; i--) {
const m = bound[i];
if (m !== undefined && sameRouteQuietly(m, main)) {
mainTier = i;
break;
}
}
if (mainTier < 0)
return undefined;
for (let i = mainTier + 1; i < bound.length; i++) {
const m = bound[i];
if (m !== undefined && !sameRouteQuietly(m, main))
return { model: m, defaulted: "tier_below_main" };
}
return { model: main, defaulted: "main_only" };
}
export function announceClassifierRoleDefaulted(args) {
const { seat } = args;
if (seat.defaulted === undefined)
return;
const spelled = thinkingOffSpelled(seat.model);
deliverEngineNotice(args.onNotice, {
code: "classifier.role_defaulted",
message: `no classifier role names a model — "${seat.model.id}" classifies this run (${seat.defaulted}); ` +
(spelled ? "its wire can turn thinking off for the verdict. " : "its wire cannot turn thinking off, so each classification carries a thinking allowance. ") +
"Name a `classifier` role, or declare the model's thinking format (`compat.thinkingFormat`) so the seat can spell off.",
detail: {
model: seat.model.id,
reason: seat.defaulted,
thinkingOffSpelled: spelled,
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
...(args.runId !== undefined ? { runId: args.runId } : {}),
},
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
});
}
export function classifierUnavailableNotice(args) {
const whose = args.station === "ancestor" ? "the classifier of the ancestor layer that asked" : "the classifier";
const why = args.cause === "timeout"
? `${whose} did not answer within its per-level budget (${args.budgetMs !== undefined ? `${args.budgetMs} ms, ` : ""}RunnerDeps.autoMode.timeoutMs)`
: `${whose} could not run (error)`;
const next = args.disposition === "asked"
? args.station === "ancestor"
? "so the call was handed on to that layer's approver rather than refused by the classifier."
: "so the call was handed on to a person (a live approver or a durable park) rather than refused by the classifier."
: (args.station === "ancestor" ? "and that layer has no live approver" : "and this run has no approval seat") +
", so the call was refused and did not run." +
(args.cause === "timeout" ? " A deployment on a slow gateway raises the budget." : "");
return {
code: "classifier.unavailable",
message: `auto mode could not judge "${args.toolName}": ${why}, ${next}`,
detail: {
toolName: args.toolName,
toolCallId: args.toolCallId,
cause: args.cause,
disposition: args.disposition,
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
...(args.runId !== undefined ? { runId: args.runId } : {}),
},
};
}
export function classifierUnavailableSeat(args) {
const { approvalSeat, onNotice, sessionId, runId, station, budgetMs } = args;
return { approvalSeat, onUnavailable: (info) => deliverEngineNotice(onNotice, classifierUnavailableNotice({ ...info, station, ...(budgetMs !== undefined ? { budgetMs } : {}), ...(sessionId !== undefined ? { sessionId } : {}), ...(runId !== undefined ? { runId } : {}) })) };
}
function classifierFallbackAnnouncer(args) {
const announced = new Set();
return (attribution) => {
const fallback = attribution?.fallback;
if (fallback === undefined)
return;
const key = `${fallback.from}\u0000${fallback.cause}`;
if (announced.has(key))
return;
announced.add(key);
deliverEngineNotice(args.onNotice, {
code: "classifier.fallback",
message: `the auto-mode classifier model "${fallback.from}" did not rule (${fallback.cause}) — this run re-asks each gated call on "${fallback.to}". ` +
'Check that the classifier role names a model this deployment\'s gateway serves; set RunnerDeps.autoMode.classifierFallback to "none" to deny instead of falling back.',
detail: {
from: fallback.from,
to: fallback.to,
cause: fallback.cause,
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
...(args.runId !== undefined ? { runId: args.runId } : {}),
},
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
});
};
}
function classifierContractAnnouncer(args) {
const streak = new Map();
const announced = new Set();
const noVerdict = (rung, model) => {
const key = `${rung}:${model}`;
const n = (streak.get(key) ?? 0) + 1;
streak.set(key, n);
if (n < AUTO_MODE_CLASSIFIER_CONTRACT_BROKEN_AFTER || announced.has(key))
return;
announced.add(key);
deliverEngineNotice(args.onNotice, {
code: "classifier.contract_broken",
message: `the auto-mode classifier model "${model}" has answered ${n} consecutive classifications without a verdict (each asked twice) — ` +
"every gated call it judges is denied. Declare the model's thinking format (`compat.thinkingFormat`) so the seat can turn thinking off, " +
"or name a `classifier` role whose model follows the <block> contract.",
detail: {
model,
rung,
consecutive: n,
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
...(args.runId !== undefined ? { runId: args.runId } : {}),
},
...(args.sessionId !== undefined ? { sessionId: args.sessionId } : {}),
});
};
return (info) => {
const record = info.classifier;
if (record === undefined)
return;
if (record.fallback?.cause === "parse_error")
noVerdict(0, record.fallback.from);
const landed = record.fallback !== undefined ? 1 : 0;
if (info.verdict === "parse_error")
noVerdict(landed, record.modelUsed);
else if (info.verdict === "allow" || info.verdict === "block")
streak.delete(`${landed}:${record.modelUsed}`);
};
}
export const CLASSIFIER_OFF_SEAT = { reasoning: "off", maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS };
export const CLASSIFIER_LOW_SEAT = { reasoning: "low", maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS + AUTO_MODE_CLASSIFIER_LOW_REASONING_BUDGET_TOKENS };
export const CLASSIFIER_THINK_SEAT = { reasoning: "low", maxTokens: AUTO_MODE_CLASSIFIER_MAX_TOKENS + AUTO_MODE_CLASSIFIER_THINK_BUDGET_TOKENS };
export function classifierRungSeat(model) {
return thinkingOffSpelled(model) ? CLASSIFIER_OFF_SEAT : CLASSIFIER_LOW_SEAT;
}
export const CLASSIFIER_ATTEMPT_SUFFIX = {
first: '\nErr on the side of blocking. Your ENTIRE response MUST begin with <block>. Do NOT output any analysis, reasoning, or commentary before <block>. No "Looking at..." or similar preamble.',
reask: "\nReview the classification process and follow it carefully, making sure you deny actions that should be blocked. As a reminder, explicit (not suggestive or implicit) user confirmation is required to override blocks. Use <thinking> before responding with <block>. Think longer on ambiguous or borderline actions; keep reasoning brief for clear-cut ones.",
};
export function classifierReplyText(reply) {
return reply.content
.filter((c) => c.type === "text")
.map((c) => c.text)
.join("");
}
export function classifierReplyEmptyAtCap(reply) {
if (classifierReplyText(reply) !== "")
return false;
return reply.stopReason === "length" || (reply.stopReason === "error" && reply.errorKind === "length_empty");
}
export function classifierReplyTagOnly(reply) {
return reply.stopReason === "length" || reply.stopReason === "toolUse" || reply.partialFinalized === true || reply.content.some((c) => c.type === "toolCall");
}
export function classifierReplyVerdictText(reply) {
const text = classifierReplyText(reply);
if (!classifierReplyTagOnly(reply))
return text;
const tagged = parseAutoModeResponse(text, { bareAnswer: false });
return tagged.kind === "parse_error" && tagged.form === "missing" ? "" : text;
}
export function classifierReplyAsksAgain(reply) {
if (classifierReplyEmptyAtCap(reply))
return true;
const text = classifierReplyVerdictText(reply);
if (text === "")
return classifierReplyTagOnly(reply) && classifierReplyText(reply) !== "";
const verdict = parseAutoModeResponse(text);
return verdict.kind === "parse_error" && verdict.form === "missing";
}
export function classifierReplyFailure(reply) {
if (reply.stopReason === "aborted")
return new Error("the auto-mode classifier request was aborted");
if (reply.stopReason !== "error" || reply.errorKind === "length_empty")
return undefined;
return new Error(`the auto-mode classifier request failed: ${reply.errorMessage ?? "the model leg reported an error with no message"}`);
}
export function classifierRoundObserver(args) {
const announceFallback = classifierFallbackAnnouncer(args);
const countContract = classifierContractAnnouncer(args);
return (info) => {
emitTrace(args.tracer, () => ({
kind: "auto_mode.classified",
version: 1,
taskId: args.taskId,
toolCallId: info.toolCallId,
model: info.classifier?.modelUsed ?? args.seatModelId,
ms: info.ms,
verdict: info.verdict,
...(info.cause !== undefined ? { cause: info.cause } : {}),
...(info.notAsked !== undefined ? { notAsked: info.notAsked } : {}),
ts: Date.now(),
}));
announceFallback(info.classifier);
countContract(info);
};
}