@sema-agent/core
Version:
Stateless, task-oriented AI agent core
456 lines (455 loc) • 33.9 kB
TypeScript
import type { ToolCallRequest } from "./tool-policy.js";
/**
* WHY an `unavailable` verdict could not run — the closed set behind the verdict arm, the model-facing
* sentence's parenthetical and the `auto_mode.classified` trace frame's `cause`, so the three spell one
* word (the deny's structural face carries only the round's FORM, `GateDisposition.cause`). `error` = the model leg
* threw or rejected (a route failure at classify time reads here too — the derived-route pre-flight fell
* back BEFORE any decide, so there is no separate word for it; so does a question that never ASSEMBLED,
* whether its build threw or outran its cap — nothing was dispatched either way); `timeout` = a rung's
* DISPATCH cap.
*/
export declare const AUTO_MODE_UNAVAILABLE_CAUSES: readonly ["error", "timeout"];
export type AutoModeUnavailableCause = (typeof AUTO_MODE_UNAVAILABLE_CAUSES)[number];
/** Whether a value is a member of the unavailable-cause set — the screen a row reader applies to a stored word. */
export declare function isAutoModeUnavailableCause(v: unknown): v is AutoModeUnavailableCause;
/**
* HOW a reply failed to carry a verdict — the closed set behind `parse_error.form`. `missing` = the reader found
* NO answer (empty text, prose, an answer only inside a thinking block); `conflict` = it found TWO distinct
* `<block>` answers (raw or thinking-stripped). The split decides who gets a SECOND ATTEMPT at the same
* question, on both seats that offer one: a missing answer is asked for again (CC 2.1.259 `Vrr` re-asks an
* unparseable reply) and handed on to the next candidate ({@link rungDidNotRule}); a conflict gets neither —
* it carries a `yes`, and any second attempt that answered `no` would erase it. One rule, two seats: whatever
* a re-ask may not overwrite, a later rung may not overwrite either. Not on any wire face; the trace frame and
* the gate record keep the one word `parse_error`.
*/
export declare const AUTO_MODE_PARSE_FAILURES: readonly ["missing", "conflict"];
export type AutoModeParseFailure = (typeof AUTO_MODE_PARSE_FAILURES)[number];
/** Whether a value is a member of the parse-failure set. */
export declare function isAutoModeParseFailure(v: unknown): v is AutoModeParseFailure;
/**
* WHY a classification moved DOWN the candidate ladder — the closed set behind
* {@link AutoModeClassifierRound}'s `fallback.cause`, i.e. the ways a candidate produces NO RULING
* ({@link rungDidNotRule}). Two words are the unavailable causes verbatim (the leg threw / rejected; the
* round-trip cap elapsed); `parse_error` is the candidate that ANSWERED with no answer in it at all (the
* `missing` form). A ruling is not a member, and neither is a CONTRADICTORY reply (`conflict`): the ladder
* stops at a rung that settled the classification, whichever way it settled it.
*/
export declare const AUTO_MODE_FALLBACK_CAUSES: readonly ["error", "timeout", "parse_error"];
export type AutoModeFallbackCause = (typeof AUTO_MODE_FALLBACK_CAUSES)[number];
/** Whether a value is a member of the fallback-cause set — the screen a row reader applies to a stored word. */
export declare function isAutoModeFallbackCause(v: unknown): v is AutoModeFallbackCause;
/**
* The deployment's answer to "what happens when the classifier seat does not rule?"
* (`RunnerDeps.autoMode.classifierFallback`) — the closed set behind the candidate ladder's LENGTH:
* `main` = [classifier seat, the run's main model]; `none` = [classifier seat].
*/
export declare const AUTO_MODE_CLASSIFIER_FALLBACKS: readonly ["main", "none"];
export type AutoModeClassifierFallback = (typeof AUTO_MODE_CLASSIFIER_FALLBACKS)[number];
/** Whether a value is a member of the classifier-fallback set. */
export declare function isAutoModeClassifierFallback(v: unknown): v is AutoModeClassifierFallback;
/** Read the knob: absent ⇒ the default `main`; a member ⇒ itself; ANYTHING ELSE ⇒ a loud refusal carrying
* `config.auto_mode_classifier_fallback_invalid`. A typo'd knob must not read as the default: `"non"` would
* keep paying for the rung it meant to turn off. */
export declare function autoModeClassifierFallbackOf(v: unknown): AutoModeClassifierFallback;
/** Whether a present budget value is one the knob takes: a finite number of milliseconds in [1, 2147483647]. */
export declare function isAutoModeTimeoutMs(v: unknown): v is number;
/** Read the per-level budget knob (`RunnerDeps.autoMode.timeoutMs` / `AutoModeDeciderOptions.timeoutMs`):
* absent ⇒ {@link AUTO_MODE_DEFAULT_TIMEOUT_MS}; a finite number in [1, 2147483647] ⇒ itself; ANYTHING ELSE
* (NaN, ±Infinity, `0`, a negative, a value a host timer would fire at once, a string, an object) ⇒ a loud
* refusal carrying `config.auto_mode_timeout_invalid`. There is no OFF spelling: the budget bounds how long a
* permission gate waits, and a gate with no bound is not a setting this knob offers. */
export declare function autoModeTimeoutMsOf(v: unknown): number;
/** The classifier's verdict over one pending `ask`. Every arm may carry the classification's model
* attribution ({@link AutoModeClassifierRound}), present iff the decider was wired with a candidate ladder. */
export type AutoModeVerdict = ({
kind: "allow";
} & AutoModeRoundAttributed)
/** `category` is the matched BLOCK rule name (207 output contract); `reason` starts with `[Rule Name]`. */
| ({
kind: "block";
category: string;
reason: string;
} & AutoModeRoundAttributed)
/** The classifier could not run: the model leg threw / rejected, or ran past the cap. NOT a decision — the
* gate denies and says so ({@link classifierUnavailableDenyMessage}). */
| ({
kind: "unavailable";
cause: AutoModeUnavailableCause;
} & AutoModeRoundAttributed)
/** The model responded but not in the `<block>…` contract shape. Handled as a BLOCK with the CC
* parse-failure sentence ({@link classifierParseFailureDenyMessage}), counted by the denial limit. `form`
* says WHICH way the reply failed ({@link AutoModeParseFailure}) and decides who may try the question a
* second time: the classify leg re-asks a `missing` reply once and the ladder hands it to the next rung;
* a `conflict` gets neither ({@link rungDidNotRule}). */
| ({
kind: "parse_error";
raw: string;
form: AutoModeParseFailure;
} & AutoModeRoundAttributed);
/** The member every verdict arm shares: WHICH MODEL this classification asked and which one answered. */
export interface AutoModeRoundAttributed {
/** Absent when the decider was given no candidate ladder (a hand-built decider, the cross-process rebuild's
* single fresh leg): it ran on whatever model the leg is bound to and can vouch for no name. Never faked. */
readonly classifier?: AutoModeClassifierRound;
}
/**
* WHICH MODEL answered one classification — the record a walk of the CANDIDATE LADDER
* ({@link AutoModeDeciderOptions.candidates}) hands its consumers: `modelRequested` = the ladder's FIRST rung
* (the seat the classification was for); `modelUsed` = the rung whose answer the gate acted on, and where NO
* candidate ruled, the last rung ATTEMPTED (stated, not implied by absence); `fallback` = present exactly
* when the ladder MOVED to reach `modelUsed` — which rung it left, which it arrived at, and why
* ({@link AutoModeFallbackCause}); absent = the first rung answered.
*/
export interface AutoModeClassifierRound {
readonly modelRequested: string;
readonly modelUsed: string;
readonly fallback?: {
readonly from: string;
readonly to: string;
readonly cause: AutoModeFallbackCause;
};
}
/**
* The word for "this classification was NEVER PUT TO A MODEL" — the failure is in assembling the QUESTION (the
* transcript window every rung shares), not in a rung's answer. The decider raises it for its own
* {@link AutoModeDeciderOptions.assemble} seat; a hook that owns assembly throws an error carrying this code.
*
* WHY IT EXISTS. The ladder advances on "the rung produced no ruling", and a hook that rejects looks exactly
* like that. But a classification whose prompt could not be built fails IDENTICALLY on every rung (the build is
* one shared promise, by contract — "the same prompt, re-asked" is literal), so without this word the walk
* records a fallback and a deny sentence naming two models for a classification in which NO MODEL WAS EVER
* DISPATCHED. The verdict is unchanged (fail-closed `unavailable`); what this changes is that the record stops
* attributing it to models that were never asked.
*
* Carried as a `code` on the thrown error rather than a class so any classify hook — including one this
* package never constructed — can raise it without importing a constructor.
*
* A build that OUTRUNS its cap is the same fact and takes the same exit — see
* {@link AutoModeDeciderOptions.assemble}, the seat that bounds it.
*/
export declare const AUTO_MODE_ROUND_NOT_ASKED_CODE = "auto_mode.round_not_asked";
/** Mint the marker above around whatever failed, keeping the original as `cause` for the host's own logs.
* TOTAL by construction: the failure it wraps is an arbitrary value, and an `instanceof` or a `.message` read
* on a revoked proxy (or a throwing getter) THROWS — which would replace the marker with an ordinary error and
* hand the walk back the attribution this whole seat exists to remove. So the detail is a guarded read that
* must already be a string, and the total describer otherwise. */
export declare function autoModeRoundNotAsked(reason: unknown): Error & {
code: string;
};
/**
* Why a rung did not settle the classification, or `undefined` when it SETTLED it and the ladder stops — the
* ONE predicate behind the advance (there is no `if (fallback)` arm anywhere).
*
* A rung settles the classification two ways: it RULES (`allow` / `block`), or it answers with a
* CONTRADICTION (`parse_error` of form `conflict` — two distinct `<block>` answers in one reply). The second
* is not a ruling and stays the fail-closed deny it is; what it is NOT is an unanswered question. It holds a
* `yes`, so asking the SAME question again — of the same model as a re-ask, or of the next rung as a fallback
* — can only overwrite that `yes` with a `no` and allow a call the classifier already objected to. One rule
* for both second attempts: {@link AUTO_MODE_PARSE_FAILURES} names the form, the leg reads it for the re-ask
* and this predicate reads it for the walk. Only a reply with NO answer in it (`missing`) moves the ladder.
*
* @contract auto_mode.ladder.advances_on_no_evidence — the candidate ladder advances on exactly one
* condition, and it is the ABSENCE of evidence: the rung's leg threw or rejected, its round-trip cap elapsed,
* or its reply carried no answer at all. A rung that RULED and a rung whose reply CONTRADICTED itself both
* settle the classification where they stand — the contradiction as the fail-closed `parse_error` deny it
* already is, attributed to that rung and disclosing no fallback — so no later candidate is ever asked a
* question an earlier one has already answered `yes` to.
*/
export declare function rungDidNotRule(v: AutoModeVerdict): AutoModeFallbackCause | undefined;
/**
* The model-facing deny text for an `unavailable` verdict — CC 2.1.250 `x1t`, with the parenthetical `EIt`
* spells for the causes this engine distinguishes (a wall-clock / connection timeout reads " (timed out)";
* an unknown kind reads nothing). The subject is the classifier itself rather than a model id: without a
* candidate ladder the decider cannot vouch for a name. One writer for every deny site. ROOT CAUSE, when
* `attribution.fallback` is present: the sentence names BOTH rungs that failed to answer; a ladder that did
* NOT move keeps the CC text byte for byte.
*/
export declare function classifierUnavailableDenyMessage(toolName: string, cause: AutoModeUnavailableCause, attribution?: AutoModeClassifierRound): string;
/**
* The text an ask carries to a person when the classifier could not rule on it and the leg has an approval seat
* (the gate's classifier station and the inherited station): the ask's own text first — the card still says what
* is being asked — then why auto mode is asking at all. One writer for both stations.
*/
export declare function classifierUnavailableAskMessage(askText: string, cause: AutoModeUnavailableCause): string;
/**
* The model-facing deny text for a `parse_error` verdict — CC 2.1.259 `i7t` stem ("Auto mode could not evaluate
* this action and is blocking it for safety"; 2.1.250 `Ure`): the classifier answered, but not with a verdict,
* and auto mode blocks what it could not evaluate. With a candidate ladder wired the tail NAMES the model whose
* reply carried no verdict (`attribution.modelUsed`) — a declared departure from upstream, whose card names no
* model: the person reading this card is the one who can change the catalog, and on a single-model deployment
* the name is the whole diagnosis. Without attribution the sentence is unchanged. The stem is a stable prefix
* either way; one writer for both deny sites.
*/
export declare function classifierParseFailureDenyMessage(attribution?: AutoModeClassifierRound): string;
/**
* Parse the classifier's output contract — the CC 2.1.259 verdict reader (`MTt`, its thinking strip `QTt`, its
* reason reader `TTt`; unchanged from 2.1.250 `wCe`/`xCe`/`yCe`), plus ONE arm of sema's own:
* blocked: `<block>yes</block><category>Rule Name</category><reason>[Rule Name] …</reason>`
* allowed: `<block>no</block>`
*
* The verdict is the set of `<block>yes|no` hits, NOT a prefix match:
* 1. CONFLICT, read on the RAW text (thinking included): `<block>(yes|no)\b` collected over the whole
* response — more than one distinct answer ⇒ no verdict (`parse_error`, form `conflict`). A scratch-space
* "yes" contradicting a final "no" is a contradiction, not an allow.
* 2. HITS, read on the text with thinking blocks removed ({@link stripThinking}): `<block>(yes|no)\b` with
* the closing tag OPTIONAL, at ANY position. Two distinct answers ⇒ `conflict`; the one distinct answer ⇒
* `no` = allow, `yes` = block.
* 3. BARE ANSWER (sema, ruled 2026-09-15 — small open models drop the tag; recorded as a divergence; a caller
* that knows the reply is not a complete answer passes `bareAnswer: false` and this arm is closed): zero
* hits AND the whole thinking-stripped text is an answer by {@link bareAnswerOf} ⇒ that word is the
* verdict (a bare `yes` blocks with empty category/reason) — unless a `<block>` answer inside the
* thinking disagrees with it (`conflict`). Zero hits and anything else — prose, a question with its
* answer, a reply that only answered inside its thinking — ⇒ `parse_error`, form `missing`.
* So a preamble before the tag, prose after `<block>no`, a missing `</block>` and a reply that repeats
* its answer all read as the answer they carry; the tag must be spelled `<block>yes` / `<block>no` with
* no whitespace inside (the word-boundary form — `<block>yesterday` is not a hit) and case does not matter.
*
* A stated BLOCK stands with or without its tail: `<category>` (the BLOCK rule NAME the prompt asked
* for, read case-insensitively as free text — the deployment authors those names, so there is no fixed
* vocabulary to screen them against) and `<reason>` (first `<reason>…</reason>` pair on the stripped
* text, trimmed) are carried when present and `""` when absent; the consumers render their own fallback
* line from the tool name in that case. Downgrading a stated block to `parse_error` would lose the
* classifier's explicit verdict on the exact calls it flagged.
*
* `parse_error` is fail-closed: the gate handles it as a BLOCK (CC 2.1.259's own outcome for a reply with no
* verdict) that the denial limit counts like any other — never a silent allow, and never a question handed
* to a person on the classifier's behalf.
*/
export declare function parseAutoModeResponse(text: string, options?: {
bareAnswer?: boolean;
}): AutoModeVerdict;
/** What the decider hands the deployment's `classify` hook (the assembled prompt is the hook's job
* when it owns assembly; when using {@link buildAutoModePrompt} the decider passes its output). */
export interface AutoModeClassifyInput {
/** The pending tool call — FINAL post-hook/post-rewrite args (the same args the gate adjudicated). */
req: ToolCallRequest;
/** The human-readable text of the ask being decided (policy/safety message), when present. */
askMessage?: string;
}
/** One rung of the ladder, as the leg is told about it. `index` is the ADDRESS (a ladder may carry two rungs
* whose model id is the same string), while `model` is the NAME the record will carry. */
export interface AutoModeLadderRung {
readonly index: number;
readonly model: string;
}
/** The pluggable model leg: given the classify input, the RUNG the ladder is on and whatever
* {@link AutoModeAssembleFn} built for this classification, return that model's raw text response.
* Deployment-injected; MAY throw / reject — the decider fail-closes. A rejection carrying
* {@link AUTO_MODE_ROUND_NOT_ASKED_CODE} says the failure was in assembling the QUESTION rather than in this
* rung's answer, and stops the walk instead of blaming the next rung. `rung` is `undefined`
* for a decider wired with no ladder; `assembled` is `undefined` for one wired with no `assemble` seat (the
* leg then owns assembly, inside this rung's cap). All trailing parameters are OPTIONAL, not `| undefined`:
* a caller INVOKING it with one, two or three arguments must keep compiling. */
export type AutoModeClassifyFn<T = void> = (input: AutoModeClassifyInput, signal?: AbortSignal, rung?: AutoModeLadderRung, assembled?: T) => Promise<string>;
/** The pluggable ASSEMBLY leg: build this classification's question ONCE, before any rung is asked, on its own
* turn of {@link AutoModeDeciderOptions.timeoutMs}. The question is built from state that is not the model's
* (a transcript window, a store read), so its cost is not a rung's: a build that fails or outruns the cap is
* the {@link AUTO_MODE_ROUND_NOT_ASKED_CODE} fact (nothing dispatched, no rung blamed), and the rung cap
* bounds a DISPATCH alone — which is what makes that cap's `timeout` word true. MAY throw / reject; the
* decider fail-closes and does not start the walk. Its signal aborts when the run does AND at the cap.
* Omitted ⇒ `assembled` reaches `classify` as `undefined`. */
export type AutoModeAssembleFn<T> = (input: AutoModeClassifyInput, signal?: AbortSignal) => Promise<T>;
export interface AutoModeDeciderOptions<T = void> {
classify: AutoModeClassifyFn<T>;
/** Build the question ONCE per classification, before the walk, on its own turn of {@link timeoutMs} — see
* {@link AutoModeAssembleFn}. Omitted = the leg assembles inside `classify`, inside the rung cap. */
assemble?: AutoModeAssembleFn<T>;
/**
* The CANDIDATE LADDER this decider walks, in order — the models a classification may be asked on, NOT a
* second way to say what `classify` says (that hook is the DISPATCH; this is the sequence it dispatches
* to). Rung 0 is the seat the classification is for; every later rung is consulted only when the one
* before it produced NO RULING ({@link rungDidNotRule}). The FIRST ruling ends the walk and is the
* verdict; when no rung rules, the LAST rung's own non-ruling verdict stands.
*
* Omitted = a one-rung anonymous ladder: the leg as wired, no attribution on the verdict. An EMPTY array,
* or a rung that is not a non-empty string, is REFUSED loudly at construction (a deployment that computed
* its ladder and got nothing has a bug, not a default). COST: {@link timeoutMs} bounds ONE rung's dispatch
* and, once, the {@link assemble} call — worst case, the cap times the rungs walked, plus one.
*/
candidates?: readonly string[];
/** Hard cap on one classification round-trip — one LADDER RUNG's DISPATCH, not the whole classification
* (see {@link candidates}) — and, separately, the one {@link assemble} call that precedes the walk (ONE
* number: a second knob would be a second answer to "how long may a classification make the gate wait").
* Omitted ⇒ {@link AUTO_MODE_DEFAULT_TIMEOUT_MS}. A present value that is not a finite number of at least 1
* and at most 2147483647 (a host timer fires at once past that) is REFUSED at construction
* (`config.auto_mode_timeout_invalid`, {@link autoModeTimeoutMsOf}) — never clamped, never read as the
* default, and there is no OFF: a permission gate always has a bound. */
timeoutMs?: number;
/**
* Fired once per `decide` call with the verdict the gate is about to act on and the wall time it waited
* (`ms`, integer ≥ 0; a timed-out classification reads the deadline, one that walked two rungs the whole
* walk). `cause` is present iff the verdict is `unavailable`; `classifier` iff a ladder was wired. A
* throwing hook never breaks the gate.
*/
onClassified?: (info: AutoModeClassified) => void;
}
/** What {@link AutoModeDeciderOptions.onClassified} receives — one record per decision. */
export interface AutoModeClassified {
/** The gated call the decision was about (`AutoModeClassifyInput.req.toolCallId`). */
toolCallId: string;
/** Wall-clock milliseconds between the decide call and its verdict. */
ms: number;
verdict: AutoModeVerdict["kind"];
cause?: Extract<AutoModeVerdict, {
kind: "unavailable";
}>["cause"];
/** Present exactly when no model was asked because the QUESTION did not assemble: `"timeout"` = the build
* outran its cap, `"failed"` = it threw (also what a classify hook owning its own assembly reports). ABSENT
* = a model was asked. The verdict is `unavailable` / `error` either way — nothing was dispatched — so this
* is what tells an operator "the build is slow" from "the provider is down". */
notAsked?: "failed" | "timeout";
/** The classification's model attribution, present exactly when the decider was wired with a candidate ladder
* ({@link AutoModeClassifierRound}) — the same object the verdict carries. */
classifier?: AutoModeClassifierRound;
}
export interface AutoModeDecider {
/** Never rejects. Any internal failure surfaces as `unavailable`/`parse_error` (fail-closed). */
decide(input: AutoModeClassifyInput, signal?: AbortSignal): Promise<AutoModeVerdict>;
/** The per-level budget this decider holds each rung's dispatch (and the assembly) to, in ms — the resolved
* {@link AutoModeDeciderOptions.timeoutMs}; what a notice about a timed-out classification names. Absent on a decider a
* host implements by hand. */
readonly timeoutMs?: number;
}
/** The per-run decider: timeout + fail-closed error mapping, nothing carried between rounds. One instance per
* run (it binds the run's model leg and assembled prompt); a round that failed says nothing about the next,
* which asks the model again. */
export declare function createAutoModeDecider<T = void>(opts: AutoModeDeciderOptions<T>): AutoModeDecider;
/** The deployment's bounds for the denial limit (`RunnerDeps.autoMode.denialLimit`). Every member
* optional; an omitted member takes its CC default. A present member with a bad value is REFUSED
* loudly at construction (never clamped, never read as the default). */
export interface AutoModeDenialLimitOptions {
/** Consecutive classifier blocks (no classifier/human allow between them) that fall back to a person.
* Default 3. A positive integer. */
maxConsecutive?: number;
/** Total classifier blocks per run that fall back to a person and reset the budget. Default 20. A
* positive integer. */
maxTotal?: number;
/** The fallback ask's auto-deny window, ms. Default 120_000; `0` = no window (the ask waits on the
* approver alone). A non-negative integer no greater than 2147483647 (a host timer truncates larger
* delays and fires at once — the fail-closed direction, and invisible). */
autoDenyAfterMs?: number;
}
/** What BOTH shapes below say, and the whole of what a reader that only renders the card needs: the
* counts that tripped the bound and which bound it was. Declared once so the two faces cannot drift.
* A reader that only renders takes {@link DenialLimitFallbackFace} rather than this, so that an ARMED
* object LITERAL is accepted too (a parameter of this type would meet the excess-property check). */
export interface DenialLimitCounts {
/** Consecutive blocks INCLUDING the one that tripped the bound. */
readonly consecutive: number;
/** Total blocks this run INCLUDING the one that tripped the bound (read before the total-bound reset). */
readonly total: number;
/** Which bound tripped. */
readonly limit: "consecutive" | "total";
}
/**
* The DECISION-layer member of a denial-limit fallback ask (`PermissionResult` ask arm): the counts
* that tripped the bound, and NOTHING about a window. A window is a fact of the ask's ROUTE — a live
* function approver can count down, a durable park row and a headless deny cannot — and the route is
* chosen downstream of the count. The type says so: there is no window member to write here, and the only
* ways to reach a {@link DenialLimitFallback} (the member an {@link
* import("./tool-policy.js").AskRequest} carries) are the two route stations,
* {@link AutoModeDenialTracker.armTimedWindow} and {@link unarmedWindow}.
*/
export interface UnarmedDenialLimitFallback extends DenialLimitCounts {
/** FORBIDDEN on a decision, and stated rather than merely omitted. Leaving the member out would only
* stop a FRESH object literal (TypeScript's excess-property check); a policy that writes its window
* through a `const`, an `as const`, or an annotated return type would still compile — and would then
* have its number silently replaced at whichever route station the ask reached. `never` makes the old
* shape a compile error at the policy boundary instead. (A host that casts past the type still meets
* the route stations' runtime belt: they write the route's number, never the decision's.) */
readonly autoDenyAfterMs?: never;
}
/** The REQUEST-layer member (`AskRequest`, a durable park row, a checkpoint summary's echo): the same
* counts with the route's window already decided. Reachable only through the two route stations —
* {@link AutoModeDenialTracker.armTimedWindow} for a hand-out to a live function approver, and
* {@link unarmedWindow} for every route that cannot count down (a durable park, a headless deny, a
* non-function `onAsk` seat, a fallback no tracker of this run minted). Neither station accepts an
* ALREADY-armed member: a window is decided once, at the route, and re-routing an armed one would be
* a second decision with the first one silently discarded. */
export interface DenialLimitFallback extends DenialLimitCounts {
/** The auto-deny window for THIS ask, ms; `0` = none. Non-zero only where a live function approver
* is about to be called and the streak's timed card is still due — {@link
* AutoModeDenialTracker.armTimedWindow} is its one writer. The ask resolver reads this member to
* arm its deadline. */
readonly autoDenyAfterMs: number;
}
/** Either face of the member — what every READ-ONLY consumer accepts. A reader that renders the card
* has no interest in the route, so it must take both, and as a UNION rather than as the shared base:
* the base alone would reject a caller's four-member object literal on the excess-property check,
* which is a break with nothing behind it (the routing invariant constrains where a window is WRITTEN,
* never who may read one). */
export type DenialLimitFallbackFace = UnarmedDenialLimitFallback | DenialLimitFallback;
/**
* A stored or foreign value read back AS a fallback member, or `undefined` when it is not one: exactly
* the four members, each of the right kind (finite non-negative counts, the closed `limit` word, a
* finite non-negative window), nothing else carried. A deployment-written row (a checkpoint store is a
* host's) can hold any shape; the faces that echo it (the inbox summary) read through here, so a bad
* shape reads as ABSENT rather than reaching a consumer as a malformed card. Not a parser of intent —
* `"3"` is not 3.
*/
export declare function readDenialLimitFallback(v: unknown): DenialLimitFallback | undefined;
/** The window-less route's member, spelled in ONE place: every station that hands a fallback somewhere
* nothing can count down (a durable park row, a headless deny, a string `onAsk` seat, a member this
* run's tracker did not mint) passes through here rather than writing a `0` of its own. */
export declare function unarmedWindow(fallback: UnarmedDenialLimitFallback): DenialLimitFallback;
export type DenialLimitVerdict = {
limitReached: false;
} | {
limitReached: true;
fallback: UnarmedDenialLimitFallback;
};
/** The per-run denial tracker. Lives beside {@link AutoModeDecider} (same owner, same lifetime). */
export interface AutoModeDenialTracker {
/** A classifier `block`: count first, judge second (CC `eme` → `tme`). Returns whether THIS block is
* the one that falls back to a person, with the fallback's counts. There is no window here — the
* route is not known yet, and the minted member has no member to hold one; see {@link armTimedWindow}. */
recordBlock(): DenialLimitVerdict;
/** The ONE writer of the timed card (CC `u8t`: the timed shape exists only when a dialog is present,
* the CONSECUTIVE bound tripped, and no timed card was shown this streak). Called by the station
* that is about to hand `fallback` to a live function approver; returns the member that ask carries:
* the configured window when the card is due, else the same counts with `0` ({@link unarmedWindow}).
* Sets the once-per-streak mark and `windowArmedAt` exactly when it hands out a non-zero window. A
* fallback this tracker did not mint (a deeper ancestor's, or a policy's self-declared member) comes
* back with no window — the tracker arms only the streak it counted, and consults each minted member
* ONCE (a second call with the same member arms nothing), so the hand-out is a recorded fact, not a
* convention. */
armTimedWindow(fallback: UnarmedDenialLimitFallback): DenialLimitFallback;
/** A classifier `allow`, a person's allow of a fallback ask (CC `yR`), or — in auto mode — an allow rule or the
* read-only reader clearing the ask (the reference resets the streak when its pipeline answers allow): consecutive → 0
* and the timed-card mark cleared. A hook's allow does not call this. */
recordAllow(): void;
/** The current counts (diagnostics / pins). `windowArmedAt` = the epoch-ms instant
* {@link armTimedWindow} handed the CURRENT streak's fallback a non-zero window to a live approver
* (the instant CC's `timedFallbackShown` mark was set); present ⇔ that mark stands, absent otherwise
* — a fallback parked or denied headless WITHOUT reaching a function approver never arms it, a
* total-bound card is never timed, a disarmed window (`autoDenyAfterMs: 0`) arms nothing, and every
* reset (`recordAllow`, the total-bound budget reset) clears it. Present ⇒ the ask was handed to a
* function approver carrying a window; the resolver arms its deadline from it unless it exits before
* the wait (an already-aborted signal, unclonable args, or an approver that answers "unavailable" and
* sends the ask to the park) — the card counts as shown in those cases too, which errs toward a
* person being asked without a countdown, never toward an auto-deny nobody was shown. */
snapshot(): {
consecutive: number;
total: number;
timedFallbackShown: boolean;
windowArmedAt?: number;
};
}
export declare function autoModeDenialLimitOf(v: unknown): Required<AutoModeDenialLimitOptions>;
/** The deployment face's three value knobs, read by their one readers — every leg's prepare runs it while
* `RunnerDeps.autoMode` is present, whatever mode the leg starts in, so a bad knob refuses the run before a steer or a
* parent's live ceiling can reach an arming that would have refused it. */
export declare function screenAutoModeKnobs(face: {
readonly timeoutMs?: unknown;
readonly classifierFallback?: unknown;
readonly denialLimit?: unknown;
}): void;
/** Build the per-run tracker over {@link autoModeDenialLimitOf}'s bounds (a bad knob refuses there). */
export declare function createAutoModeDenialTracker(opts?: AutoModeDenialLimitOptions): AutoModeDenialTracker;
/** The fallback ask's text — CC's sentence family, single-sourced for every mint site (the main gate
* and the inherited-lane arms): `Classifier denial limit exceeded, falling back to prompting: <limit
* sentence>` + a blank line + `Latest blocked action: <the classifier's own reason, or the tool name>`.
* The reason is the classifier MODEL's text — the caller neutralizes it before it gets here. */
export declare function denialLimitFallbackMessage(fallback: DenialLimitFallbackFace, latestBlockedAction: string): string;
/** The limit sentence alone (CC's two forms) — shared by the fallback ask and the headless terminal. */
export declare function denialLimitSentence(fallback: DenialLimitFallbackFace): string;