UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

204 lines (203 loc) • 14.2 kB
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;