@sema-agent/core
Version:
Stateless, task-oriented AI agent core
275 lines (274 loc) • 21 kB
TypeScript
/**
* The auto-mode classifier's CANDIDATE LADDER, as one module: which models a classification may be asked on
* (and which model stands in when no `classifier` role names one), its per-rung reasoning seat and the
* per-attempt user-message tail, how a rung's reply is read for a re-ask or a failure, and the once-per-run
* disclosures its first move and its broken contract mint.
*
* Beside `derived-route-fallback.ts` and in the same family — both are seats the caps phase's classifier
* arming reaches DOWN for, and both answer "this leg's model did not work out" one layer apart. Which is why
* the rung comparison here borrows the route adjudicator's own identity law instead of inventing one.
*
* THE TWO LAYERS, since one sentence ("the classifier seat did not work out ⇒ use the run's main model") is
* spelled in two places. They are ordered, and each has its own trigger and its own record face:
*
* ① PRE-FLIGHT — `derivedRouteFallsBack`, BEFORE any call. Trigger: the classifier model's route differs
* from the primary's AND the brain's own route judge finds the credential/URL pairing broken. Effect:
* the arming SWAPS the seat, so the ladder below is built from the main model and rung 0 IS the main
* model. Record: the once-per-run operator notice `route.fallback_to_primary` (`detail.{seat, from, to,
* cause}`) — and only that. `GateDisposition.classifier.modelRequested` reads the MAIN model's id,
* because the ladder it describes never carried the configured seat; the gate record answers "which
* model was asked", and "what did the deployment configure, and was it swapped" is the notice's
* question. `classifierFallback` governs THIS layer too: under `"none"` the pre-flight does not swap —
* it keeps the declared seat and announces `route.classifier_unroutable`, so the deployment that said
* "deny rather than fall back" gets the denial (the seat's calls are refused at the request gate)
* instead of a silent fallback the knob had just declined.
* ② THE LADDER — here, AFTER a call. Trigger: the rung produced no RULING. Effect: the next rung is asked.
* Record: `GateDisposition.classifier.fallback` on every classification this run's own gate decided (an
* inherited-ancestor chain carries the form and no attribution) + the throttled `classifier.fallback`.
*
* They FOLD rather than stack: after a ① SWAP the seat and the main model are the same route, so
* {@link classifierLadderRungs} returns ONE rung and ② cannot fire in the same run (and under `"none"`, where
* ① does not swap, the ladder is one rung by the knob). The split's cost is the
* `modelRequested` reading above; the unification (the pre-flight as rung 0 under its own cause word, one
* record face for both) changes the notice set and is a ticket, not a repair.
*/
import type { AssistantMessage, Model } from "../../internal/llm.js";
import { type AutoModeClassified } from "../auto-mode.js";
import type { ClassifierUnavailableInfo, ClassifierUnavailableSeat } from "../classifier-unavailable-arm.js";
import type { EngineNotice, RunnerDeps } from "../types.js";
/**
* The rungs a classification walks, in order: the classifier seat, and — under `classifierFallback: "main"` (the
* default) — the run's main model behind it. The knob is screened by `autoModeClassifierFallbackOf`, here and
* (idempotently) at the caps phase that reads the same word for the pre-flight's posture, so a value outside
* the closed set refuses the prepare loudly (`config.auto_mode_classifier_fallback_invalid`) instead of
* reading as the default.
*
* ONE RUNG when the two resolve to the SAME ROUTE. Route identity, not `Model.id`: two catalog entries may
* legitimately share a wire id while pointing at different endpoints (the route adjudicator's own law, and
* the split its pre-flight exists for) — an id-only comparison would collapse a healthy main endpoint and a
* broken classifier endpoint into one rung, turning the ladder off for the deployment that needs it most.
* The collapse where it does apply is deliberate: re-asking the SAME model is a retry, which is what CC's
* `unavailableOuterRetries` does and what this ladder is not. A catalog value the identity law cannot
* normalize (a non-string `baseUrl` — it throws) reads as TWO rungs: being wrong that way costs one extra
* call on a broken catalog, the other way turns the feature off invisibly, and the bad value still earns its
* loud refusal at the brain's request gate.
*/
export declare function classifierLadderRungs(knob: unknown, classifier: Model, main: Model): readonly Model[];
/**
* WHY the classifier seat is the model it is when no `classifier` role names one — the closed set behind the
* `classifier.role_defaulted` notice's `reason`. `tier_below_main` = the nearest directly-bound tier below the
* run's main model; `tier_table` = the static `flash` row (a main bound to no tier); `role_fallback` = the
* `summarize → default` chain (no tiers at all); `main_only` = the main model is the only model there is (a
* single-model catalog, or nothing bound below the main's tier); `role_unresolvable` = a `classifier` role
* NAMED a model the catalog cannot resolve (a stale or misspelled reference), so the main model stands in —
* the pre-#805 leg fell back the same way, silently.
*/
export declare const CLASSIFIER_ROLE_DEFAULT_REASONS: readonly ["tier_below_main", "tier_table", "role_fallback", "main_only", "role_unresolvable"];
export type ClassifierRoleDefaultReason = (typeof CLASSIFIER_ROLE_DEFAULT_REASONS)[number];
/** What {@link classifierSeatModel} returns: the model, and — when no role NAMED it — why it is that one. */
export type ClassifierSeat = {
readonly model: Model;
readonly defaulted?: ClassifierRoleDefaultReason;
};
/**
* The model the classifier SEAT resolves to for this run — ONE resolver for "which model classifies":
* · a `classifier` role that NAMES a model (`roleSpecNamesModel`: a string ref, a Model object, `{model}`,
* `{select}`) ⇒ the role resolver's answer, exactly as before (a preset-only role — `{thinking}` alone —
* does not name one and takes the default below);
* · else, with tier bindings declared (`deps.tiers`) and a main model on a tier ⇒ the nearest tier strictly
* BELOW the main's whose route differs from the main's — `tier_below_main`; the main's tier is the LOWEST
* tier its route sits on (a route bound as both `max` and `flash` is a `flash` main: deterministic, and
* the cheaper reading); nothing below ⇒ the main itself, `main_only`. Read off the expanded catalog's
* tier keys, the Runner's own resolution of a tier word: a key synthesised for an unbound tier falls DOWN
* to the next bound tier, which is the model this walk reaches anyway, and a catalog key spelled like a
* tier shadows the binding here as it does everywhere. No thinking preference: whether a model's
* thinking can be turned off is the SEAT's business ({@link classifierRungSeat}), not routing's;
* · else the role resolver's own fallback (the static tier row `classifier → flash` when tiers are active,
* `tier_table`; the `summarize → default` chain otherwise, `role_fallback`); a catalog with no roles at
* all ⇒ the main model, `main_only`.
* The relative pick follows the run's MAIN model on purpose: a side channel that resolved without it is the
* misfit the ticket names (a `flash` classifier above a `lite` main; two tiers below a `max` main).
*
* @contract classifier.seat.one_resolver — this function is the ONE answer to "which model judges this run's
* gated calls". The engine's arming calls it and a host that DISPLAYS the seat (a capabilities face, an
* operator pane) calls the same exported function rather than re-deriving the pick from catalog, roles and
* tiers: a second derivation is a second answer, and the two drift the moment either side's rules move.
*/
export declare function classifierSeatModel(args: {
deps: Pick<RunnerDeps, "models" | "roles" | "tiers">;
main: Model;
}): ClassifierSeat;
/**
* The `classifier.role_defaulted` line — minted ONCE per run by the arming, AFTER the route pre-flight, so it
* names the seat actually asked (a pre-flight swap to the main model is its own line,
* `route.fallback_to_primary`, same run). Silent when a role named the model: nothing was defaulted.
*/
export declare function announceClassifierRoleDefaulted(args: {
onNotice: RunnerDeps["onNotice"];
seat: ClassifierSeat;
sessionId?: string;
runId?: string;
}): void;
/**
* The `classifier.unavailable` notice — a classification that could not RULE (the classifier leg failed, or its
* per-level budget ran out) and where the call went: `"asked"` = on down the ask chain to this leg's approval seat (a
* person or a durable park decides; the handed-on ask carries `requiresRealApproval`, so no automatic lane answers it),
* `"denied"` = refused (no approval seat: nobody to ask). Audience `user`: the refusal is the session user's own
* call, and on a lane with no approval face (`-p`) this line is the only place they hear why it did not run. Once
* per classification that could not answer, from both classifier stations (the leg's gate and an ancestor's frozen
* classifier in the policy fold) — a call judged by more than one classifier can carry one line per classifier. It is never a block: the denial limit does not count it, so this line is not a step toward the
* limit's card either.
*
* `detail: { toolName, toolCallId, cause, disposition, sessionId?, runId? }` — `cause` is the
* `AutoModeUnavailableCause` word (`timeout` / `error`). The sentence names the seat the station forked on — `leg` (this
* run's own approval seat) or `ancestor` (the frozen approver of the ancestor layer whose classifier it was) — and, on a
* timeout, the budget the classifier actually held (`budgetMs`, the decider's {@link AutoModeDecider.timeoutMs}; a
* hand-built decider states none, and the sentence names the knob alone).
*/
export declare function classifierUnavailableNotice(args: ClassifierUnavailableInfo & ClassifierUnavailableStation & {
sessionId?: string;
runId?: string;
}): EngineNotice;
/** A classifier station's fork facts, wired once per leg: whether an ask the station hands on reaches a person, and
* the `classifier.unavailable` notice through the one throat. Both stations (the gate, the inherited fold) take it. */
export declare function classifierUnavailableSeat(args: ClassifierUnavailableStation & {
approvalSeat: boolean;
onNotice: RunnerDeps["onNotice"];
sessionId?: string;
runId?: string;
}): ClassifierUnavailableSeat;
/** Which station forked (`leg` — the leg's gate, on its own approval seat; `ancestor` — an ancestor's frozen classifier in
* the policy fold, on that ancestor's frozen approver) and the per-level budget its classifier held, when stated.
*
* ONE question at both stations — does the route a handed-on ask takes from HERE end at a person — asked of two routes:
* the leg's gate hands its ask down the leg's own chain (`askReachesPerson(wiring_manifest.ask)`: a live approver, or the
* durable park); the inherited station resolves at the ancestor's frozen approver, whose only road to a person is being
* a live function (`isLiveApproverSeat`: a blanket or absent seat there ends in a refusal, and the leg's park is reached
* only through a live approver's `unavailable`). Reading the leg's route at the inherited station would hand the ask to a
* frozen seat that cannot answer it. */
export interface ClassifierUnavailableStation {
station: "leg" | "ancestor";
budgetMs?: number;
}
/**
* The classifier's REASONING SEAT, read off the model a classification is actually asked on — ONE predicate:
* does the wire SPELL off ({@link thinkingOffSpelled})? Yes ⇒ `off` + the verdict-sized cap (the request pays
* for the answer alone); no ⇒ `low` + the verdict cap + the low allowance (a model whose thinking the wire
* cannot turn off is asked for the least it can grade, in the room that costs — CC 2.1.259 `MU`: spell off,
* or leave thinking to the provider and pay 2048). A model that declares no reasoning at all is on the `low`
* seat too: its wire carries nothing either way, so the request is the gateway's default, and a default-on
* gateway thinks — the arm that used to read it as "never thinks" is the one the field report was about.
* PER RUNG, not once per arming: the second rung is a different model with its own wire.
*
* The THINK seat is the RE-ASK seat ({@link classifierReplyAsksAgain}): a first attempt that produced no
* verdict is asked once more with thinking allowed the room of CC's stage-2 classifier (8192) — an unknown
* model that answered NOTHING at its cap has just told us it thinks past it, and is re-asked as one that does.
*/
export declare const CLASSIFIER_OFF_SEAT: {
reasoning: "off";
maxTokens: number;
};
export declare const CLASSIFIER_LOW_SEAT: {
reasoning: "low";
maxTokens: number;
};
export declare const CLASSIFIER_THINK_SEAT: {
reasoning: "low";
maxTokens: number;
};
export type ClassifierSeatRequest = typeof CLASSIFIER_OFF_SEAT | typeof CLASSIFIER_LOW_SEAT | typeof CLASSIFIER_THINK_SEAT;
/**
* The seat for one rung's FIRST attempt — see the seat table above.
*
* @contract classifier.budget_is_room — every classifier output cap is ROOM the request asks for, never a
* bound the engine can vouch for: a gateway may exclude reasoning tokens from `max_tokens` (a vLLM-fronted
* Qwen gateway answered a cap of 128 with 938 output tokens and `stop`), so an over-long think is cut by the
* round-trip timeout (`unavailable` / `timeout`), not by the cap (`parse_error` on an empty reply), and the
* two are attested as the two different facts they are.
*/
export declare function classifierRungSeat(model: Model): typeof CLASSIFIER_OFF_SEAT | typeof CLASSIFIER_LOW_SEAT;
/**
* The user-message TAIL of each attempt — CC 2.1.259's classifier suffixes, verbatim: the FIRST attempt
* carries the fast-mode suffix (`$ns`: the answer must begin with `<block>`, no preamble), the RE-ASK carries
* the thinking-stage suffix (`Uns`: think in `<thinking>` first, then answer). The base prompt (2.1.250 `KRg`)
* already says "no preamble" once; upstream repeats it at the END of the user message, and it is the repeat
* that lands — live 2026-09-15, a Qwen 3.8 with thinking off went from 70% tagged replies (1.2–3.9k chars of
* "Looking at this action…" prose) to 90% at 8 output tokens with the first suffix, and the re-ask suffix
* had it write its reasoning inside `<thinking>` (stripped by the reader) before the tag.
*/
export declare const CLASSIFIER_ATTEMPT_SUFFIX: {
readonly 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.";
readonly 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.";
};
/** The classifier reply's answer text — every text block joined; thinking blocks are not the verdict. */
export declare function classifierReplyText(reply: AssistantMessage): string;
/**
* CC 2.1.259 `$Tt`: an EMPTY reply that stopped at the output cap is "unparseable" — a reply to ask for again,
* not a refusal and not a verdict. The engine's brains report that cut two ways: `stopReason:"length"` (an
* engine-shrunk cap) or `stopReason:"error"` with `errorKind:"length_empty"` (a static cap — the classifier's
* own). An empty reply with any OTHER stop is CC's `policy_refusal` arm: not re-asked.
*/
export declare function classifierReplyEmptyAtCap(reply: AssistantMessage): boolean;
/**
* A reply that may answer in the TAG form only: its text is not a complete answer of its own. Output cut by the cap
* (`stopReason: "length"`), a dropped connection or a stalled stream (`partialFinalized`, with a synthesised
* `stop`), and a reply that CALLED A TOOL (`stopReason: "toolUse"`, or a tool-call block in the content — the
* classifier is given no tools, and the openai brain reports a length cut with a surviving call as `toolUse`):
* whatever text arrived is a prefix or a fragment — a `No` on its way to `No, this must be blocked` — so it may
* carry a verdict only through a `<block>` tag, which reads a `<block>no` prefix as CC does; the bare-answer arm
* never sees it.
*/
export declare function classifierReplyTagOnly(reply: AssistantMessage): boolean;
/**
* The text the DECIDER reads for its verdict: the reply's answer text, except that a tag-only reply
* ({@link classifierReplyTagOnly}) whose text carries NO tagged answer reads as EMPTY — a cut prefix must not
* reach the bare-answer arm as if it were the whole reply (adversarial passes 2–3: a connection cut after `No`,
* and a scratch `<block>` mention before a cut `No`, executed the gated tool). Judged by the reader itself with
* its bare arm closed, so "no tagged verdict" and "no verdict" cannot drift. Only the MISSING form is emptied:
* a tag-only reply that carries a CONFLICT keeps its text, so the decider records the conflict and the leg
* does not re-ask it (adversarial pass 4: emptying every parse failure had turned a cut conflict into a
* missing reply, and the re-ask into an allow). The decider then records `parse_error` / `missing` for the
* emptied text, the same word an empty reply gets.
*/
export declare function classifierReplyVerdictText(reply: AssistantMessage): string;
/**
* Whether a first attempt is asked AGAIN on the THINK seat — ONE rule: the reply is MISSING a verdict, either
* empty at the cap ({@link classifierReplyEmptyAtCap}), cut or fragmentary without a tagged verdict
* ({@link classifierReplyTagOnly}), or text in which the reader finds no answer at all (`parse_error` of form
* `missing`: prose, a thinking-only reply). Not a CONFLICT (two `<block>` answers — it carries a `yes`, and a second attempt that answered `no`
* would erase it: the fail-closed `parse_error` it is stands) and not an empty reply with a non-cap stop
* (CC's refusal arm). CC 2.1.259 `Vrr` re-asks an unparseable reply up to `maxRetries` times; sema re-asks
* ONCE, on the stage-2 room, so a rung costs at most two calls. A failed reply ({@link classifierReplyFailure})
* is judged before this and never reaches it.
*/
export declare function classifierReplyAsksAgain(reply: AssistantMessage): boolean;
/**
* A reply that is a LEG FAILURE rather than an answer — the brains RETURN a provider error as a message
* (`stopReason: "error"` with the provider's text on `errorMessage`: an HTTP 4xx/5xx, a quota refusal, an
* overloaded gateway) and a cancelled request as `"aborted"`; neither is a reply the reader may judge. Read as
* text they were `parse_error` — a card saying "the classifier's reply carried no verdict" about a model that
* never answered, a ladder record blaming `parse_error` instead of `error`. The leg THROWS what this returns
* and the decider maps the throw to `unavailable` (`error`), as it maps every leg failure; the sibling
* side-channel legs (compaction, consolidation) already read an error stop this way. The cap-cut form
* (`errorKind: "length_empty"`) is not a failure: it is the empty-at-cap reply the re-ask owns.
*/
export declare function classifierReplyFailure(reply: AssistantMessage): Error | undefined;
/**
* The per-decision READ of a classification, as one seat: the `auto_mode.classified` trace frame (wall time,
* verdict, cause), the ladder's throttled `classifier.fallback` disclosure and the per-model
* `classifier.contract_broken` streak. All are observers — a throwing sink never reaches the gate (the decider
* swallows it), and none changes a verdict.
*
* The frame's `model` is the model the classification RAN ON, which is what the field has always meant (its
* declaration reads "the resolved classifier role, or the main model it fell back to"): the route
* pre-flight's swap and the ladder's walk are two ways to reach that same sentence. `seatModelId` is the
* fallback for a classification that carries no attribution at all.
*/
export declare function classifierRoundObserver(args: {
tracer: RunnerDeps["tracer"];
onNotice: RunnerDeps["onNotice"];
taskId: string;
seatModelId: string;
sessionId?: string;
runId?: string;
}): (info: AutoModeClassified) => void;