UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

256 lines (255 loc) • 12.6 kB
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); }; }