@sema-agent/core
Version:
Stateless, task-oriented AI agent core
204 lines (203 loc) • 14.2 kB
TypeScript
import type { AutoModeRules, AutoModeWindowOptions, DerivedDenyRule } from "./auto-mode-prompt.js";
import type { AutoModeDenialLimitOptions } from "./auto-mode.js";
/** The recipe format's version. A reader that does not know a version REFUSES it (never guesses): the
* recipe names the criteria a classifier enforces, and a partially-understood criteria set is the one
* thing a permission gate may not improvise. Version 2: the breaker threshold (`failureThreshold`) left the
* criteria set with the breaker itself; a version-1 recipe named a criterion no decider enforces any more and is
* refused as unknown (the redemption keeps its honest refusal), never read as a version-2 recipe. */
export declare const AUTO_MODE_ARMING_RECIPE_VERSION = 2;
/**
* The SERIALIZABLE half of an auto-mode arming — everything `createAutoModeDecider` +
* `buildAutoModePrompt` need except the model leg itself. Plain data by construction (JSON /
* `structuredClone` round-trips), because it rides a durable checkpoint row and is bound by the
* constraint-chain digest.
*
* Deliberately NOT carried: the model/roster selection (the redeeming deployment routes its own classifier
* leg; a recorded model id would name a catalog entry that need not exist in the redeeming fleet).
*/
export interface AutoModeArmingRecipe {
/** {@link AUTO_MODE_ARMING_RECIPE_VERSION}. An unknown version is refused, never partially read. */
v: number;
/** `buildAutoModePrompt` rule overrides for the four paired sections (omitted = CC defaults verbatim). */
rules?: AutoModeRules;
/** Settings-plane deny rules rendered into `<settings_deny_rules>`. */
settingsDenyRules?: readonly string[];
/**
* The ENGINE's deny lines rendered beside them — the arming leg's effective read-deny table as rules
* (`readDenyClassifierRules`: the rule text and its letter-case note). Prompt BODY, compared by the fold exactly as `settingsDenyRules` is: a
* redeeming host recomputes its own from its deployment's read-deny configuration and the row's task
* additions, and a difference refuses the rebuild (`settings_moved`) — the same rule as a moved deployment
* setting, no exemption.
*/
derivedDenyRules?: readonly DerivedDenyRule[];
/** `## Session Context` facts appended to the assembled document. */
sessionContext?: readonly string[];
/** Transcript-window bounds for the classify prompt. */
window?: AutoModeWindowOptions;
/** Classify round-trip cap, ms. */
timeoutMs?: number;
/**
* The classifier DENIAL-LIMIT bounds (`RunnerDeps.autoMode.denialLimit`, CC 2.1.250 `FO`/`AKe`):
* how many blocks the ancestor's classifier was allowed before a person had to look, and the window the
* fallback ask ran under. A KNOB triple, not prompt body — every member is orderable, so the fold takes
* the strictest of the two sides rather than refusing on a difference.
*
* It belongs on the recipe because the tracker CANNOT travel (it is live state) while
* its BOUNDS are exactly the kind of criteria the recipe exists to carry: without them a cross-process
* redemption rebuilt the ancestor's classifier and counted its blocks against the REDEEMING
* deployment's own bounds, which may be looser — the ancestor's "three strikes and a person looks"
* silently became the redeemer's five. (A `requiresRealApproval` fallback ask still has a person as its
* only exit either way, which is why this is a widening of the BUDGET and not of the gate.)
*
* Members are canonicalized under the SAME screen `createAutoModeDenialTracker` applies to the live
* face — a recorded value that tracker would refuse makes the whole recipe malformed here rather than
* being floored into one it accepts, because the rebuild hands these very numbers back to it.
*/
denialLimit?: AutoModeDenialLimitOptions;
/**
* The cross-session lane's classifier rule was spliced into the assembled prompt (the lane was
* mounted on the arming leg). Part of the PROMPT BODY: the rebuild re-splices the same engine
* constant when the bit is set, so the recorded `promptDigest` is reproducible, and the fold treats
* the bit as body (a rebuild under a deployment whose own face does not declare it refuses as
* `settings_moved` — the two prompts differ by a rule block). Absent = the rule was not spliced.
*/
crossSessionMessagesRule?: true;
/**
* A digest of the EXACT classifier system prompt this arming assembled (see
* `rebuildAutoModeDecider`). The recipe records the deployment's OVERRIDES; the bulk of the criteria —
* the base shell and the permissions document — lives in the build's own SHA-locked assets, which the
* recipe cannot carry and a redemption assembles from its OWN binary. Under a rolling upgrade two
* builds can accept the same recipe and enforce different default rules, and every recipe and digest
* check would pass. Binding the assembled prompt closes that: a redeeming build whose assembly differs
* by one byte refuses to rebuild instead of enforcing criteria the ancestor never ran.
*/
promptDigest?: string;
/**
* The deployment's auto-mode SETTINGS GENERATION at arming time — an opaque string the deployment
* owns (a config revision, a settings hash, a publish id). It is a DECLARATION axis and can only make
* the rebuild stricter: an epoch that MOVED while the recorded body still equals the redeeming
* deployment's own is harmless (the fold proceeds), and an epoch that is EQUAL while the bodies
* differ is a contradiction the fold refuses loudly rather than resolving by picking a side.
*/
settingsEpoch?: string;
}
/** The deployment assembly face this recipe is minted from — structurally `RunnerDeps.autoMode`. */
export interface AutoModeArmingFace {
rules?: AutoModeRules;
settingsDenyRules?: readonly string[];
sessionContext?: readonly string[];
window?: AutoModeWindowOptions;
timeoutMs?: number;
denialLimit?: AutoModeDenialLimitOptions;
settingsEpoch?: string;
/** `true` when the cross-session lane's classifier rule is spliced into this deployment's classifier
* prompt (the Runner sets it from its own lane mount; a redeeming host declares it from its). */
crossSessionMessagesRule?: boolean;
}
/**
* The ONE tighten rule for the denial-limit bounds, shared by {@link foldAutoModeArming} and by
* the runner's rebuilt-tracker mint (a second computation of "the strictest of these two" is how the
* recipe's account and the tracker's actual bounds would come to disagree). Both sides' EFFECTIVE values
* are materialized first (an omitted bound is the default the tracker applies, never "unbounded" — the
* same correction the knob fold below already carries), and the result is explicit for the same reason.
*
* · `maxConsecutive` / `maxTotal` — the MINIMUM. A lower bound falls back to a person SOONER, which is
* the fail-closed direction on both: the classifier gets less rope, never more.
* · `autoDenyAfterMs` — the SHORTER WINDOW wins, and `0` is not a short window: it is the special value
* for "no window at all" (the fallback ask blocks on the approver, which is the shape a total-bound
* card and every park already run under, #559). Read numerically, `0` would read as the strictest
* value and would silently DISARM an ancestor's window; read as "no window", a declared absence of one
* never removes the other side's. Both surviving directions are deny-ward — an auto-deny can only ever
* refuse — so neither can widen the gate.
*/
export declare function tightenDenialLimit(a: AutoModeDenialLimitOptions | undefined, b: AutoModeDenialLimitOptions | undefined): Required<AutoModeDenialLimitOptions>;
/**
* Canonicalize + VALIDATE an arming recipe: the plain-data form that persists, or `undefined` when the
* input is not one (unknown version, a non-string in a rule list, a NaN knob, a non-object).
*
* IDEMPOTENT by construction (the output is a fresh object built from known keys only, with the
* absent/empty collapse and the knob normalization already applied), which is what lets the mint side
* and the resume RE-SUPPLY side run it independently and still digest identically — the two sites never
* see the same object, only the same values.
*
* A malformed recipe yields `undefined` rather than a partially-pruned one: silently dropping half a
* rule list would change the criteria a classifier enforces while every downstream face still called it
* "the ancestor's arming". The LOUD half of this rule lives at the mint (prepare-task announces a
* requested-but-unpersistable arming); here the answer is simply "no recipe", which degrades to the
* pre-#503 behavior (an honest refusal at the redemption).
*/
export declare function sanitizeAutoModeArmingRecipe(value: unknown): AutoModeArmingRecipe | undefined;
/**
* Mint a recipe from a deployment's auto-mode assembly face (`RunnerDeps.autoMode`): the same values the
* Runner arms its own decider with, canonicalized. `undefined` when the face is not serializable — the
* caller is expected to say so out loud rather than park a silently thinner arming.
*
* Used on BOTH ends of the boundary: the Runner mints it at arming time (so a park can record it), and a
* redeeming host mints its OWN face into the `current` half of {@link foldAutoModeArming}. `bind` carries
* what is the ENGINE's rather than the deployment's: the assembled prompt's digest (the arming side only)
* and the engine's deny lines (`derivedDenyRules`, both sides — a redeeming host passes the lines its own
* read-deny configuration yields over the row's task additions, `readDenyClassifierRules`).
*/
export declare function autoModeArmingRecipeOf(face: AutoModeArmingFace, bind?: {
promptDigest?: string;
derivedDenyRules?: readonly DerivedDenyRule[];
}): AutoModeArmingRecipe | undefined;
/** Why a rebuild refused. Every arm leaves the redemption on the PRE-#503 path (honest `unavailable` →
* the frozen approver chain → a human), so a refusal costs a second park, never a widened gate. */
export type AutoModeRebuildRefusal =
/** The recorded recipe is not a recipe this build understands (unknown version / not plain data). */
"malformed_recipe"
/** No current settings were declared, so nothing bounds the rebuild — see {@link foldAutoModeArming}. */
| "current_settings_missing"
/** The redeeming deployment's OWN settings face is malformed — it cannot bound the rebuild, so nothing is built. */
| "malformed_current_settings"
/** The redeeming build assembles a DIFFERENT classifier prompt than the arming recorded (asset skew). */
| "prompt_assets_moved"
/** The prompt body moved between the recorded arming and the redeeming deployment's current settings. */
| "settings_moved"
/** The two sides declare the SAME settings epoch while their bodies differ — a contradiction, not a fold. */
| "epoch_inconsistent";
export type AutoModeArmingFold = {
ok: true;
/** The RECORDED recipe, canonical — the digest-bearing half (what the ancestor's row says). */
recorded: AutoModeArmingRecipe;
/** The criteria the rebuilt decider runs under: the agreed prompt body + the MINIMUM of each knob. */
effective: AutoModeArmingRecipe;
/** True iff a knob was actually pulled in by the current settings (informational, for a host log). */
tightened: boolean;
} | {
ok: false;
reason: AutoModeRebuildRefusal;
message: string;
};
/**
* Safety bound ② — fold a RECORDED arming against the redeeming deployment's CURRENT settings,
* tighten-only.
*
* · `current` ABSENT ⇒ REFUSED. The bound reads "the rebuilt arming is bounded by the settings in
* force", and a caller that declares none has not put anything in force — an unbounded rebuild is the
* single outcome this fold exists to prevent. A redeeming deployment that genuinely runs no auto-mode
* settings of its own says so by passing its own (possibly bare) face, which is a declaration and
* leaves a record; silence is not a declaration. This is also what keeps a CHAIN of redemptions
* honest: an intermediate leg's tightening is not carried in the row (the row keeps binding what the
* ANCESTOR recorded, which is what its digest authenticates), so every later redemption is bounded by
* ITS OWN declared settings rather than by whatever the previous redeemer happened to run under. A
* deployment that wants a tightening to be durable tightens its own settings, where it is auditable.
* · bodies EQUAL ⇒ rebuild, with `timeoutMs` taking the MINIMUM of the two — fail-closed: a shorter
* timeout turns a slow classifier into `unavailable` sooner (the call goes on to a person where the
* station has an approval seat, and is refused with a sentence that says so where it has none), and can
* never widen a verdict. The denial-limit triple
* (#556) folds the same way through {@link tightenDenialLimit} — fewer blocks before a person looks,
* and no window ever lengthened or removed.
* · bodies DIFFER ⇒ refuse. `settings_moved` normally; `epoch_inconsistent` when the two sides
* nevertheless declare the same epoch — that is a deployment telling us its settings did not move
* while its own data says otherwise, and picking either side would be a guess about which one is the
* policy in force.
*
* @contract auto_mode.arming.absent_timeout_reads_recorded_default — a parked arming's absent `timeoutMs` is
* read as the default in force when it was recorded (`AUTO_MODE_RECORDED_ABSENT_TIMEOUT_MS`, 15_000 ms), not
* the default in force when it is redeemed: persisted data is read under the contract it was written under,
* so a default that moves can never widen an ancestor's bound at redemption; every recipe minted since
* materialises the knob, so absence dates the recording.
*/
export declare function foldAutoModeArming(recorded: unknown, current: unknown): AutoModeArmingFold;