UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

275 lines (274 loc) • 21 kB
/** * 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;