@sema-agent/core
Version:
Stateless, task-oriented AI agent core
280 lines (279 loc) • 20.3 kB
TypeScript
/**
* PrepareTask's config-doors phase: the synchronous stretch from the
* tool-face snapshot freeze through model/thinking/compaction-model resolution. The inputs are
* exactly the fields of {@link PrepareConfigDoorsInput} (the original extraction slice — spec, deps,
* sessions, the resume/internals seats); the outputs are the fields of
* {@link PrepareConfigDoorsResult}, each one a value the slice defined and later phases consume —
* re-verified as the complete "defined inside, consumed outside" closure at extraction time.
*
* ⚠️ SYNCHRONOUS FUNCTION, SYNCHRONOUS CALL — a hard constraint, not a style choice. This phase and
* the safety scan after it run BEFORE prepareTask's first await (`sessions.acquire`), so a caller
* has no microtask window in which to mutate `spec.tools`/the face arrays between the snapshots,
* the doors and the scan. An `async` wrapper — even with a zero-await body — would hand the caller
* exactly that window (the driver's `await` yields one microtask). Pinned by the
* `prepare-task-phase-pins` microtask-latch tests.
*
* Throws pass through unchanged: every `config.*` / `resume_at.*` refusal here is a DELIBERATE
* at-the-door rejection (a throw before acquisition needs no cleanup), and the
* interaction-posture door's `deps.onError` disclosure (call order and payload) is part of the
* contract. The per-task agents rebind returns a REBOUND spec (shallow copy, only `tools`
* replaced) — the caller's object is never mutated; the safety scan must receive the rebound one.
*/
import { type BrainCallGuardrailRef } from "../../brain/timeout.js";
import type { Model } from "../../internal/llm.js";
import type { ThinkingLevel } from "../../internal/harness.js";
import { type MicroCompactMachine } from "../context-edit.js";
import { recordedModeSeed } from "../checkpoint-store.js";
import { type ConfigDoorEntry, type LockedPreflight } from "../locked-config.js";
import { type ResolvedRole } from "../roles.js";
import { type ResolvedSkillListingBudget } from "../skill-listing-budget.js";
import type { SessionStore } from "../session.js";
import type { RunnerDeps, TaskLimits, TaskSpec } from "../types.js";
import { type UsageWindow } from "../usage-window-store.js";
import { type AskSeat } from "../wiring-manifest.js";
import type { PrepareResume, RunInternals, ToolFaceLists } from "./contracts.js";
import { resolveHost } from "../../prompt-assembly/select.js";
import { type RosterSpec } from "./caller-roster.js";
export declare function limitConfigError(code: string, message: string): Error & {
code?: string;
};
/**
* Validate `TaskSpec.limits` at the door and return it unchanged.
*
* Two refusals, both fail-loud (the {@link resolveStallTimeoutMs} posture: a bound nobody can evaluate is
* not a bound, and folding it to a default would silently run the task under limits nobody chose):
* - **unknown key** ⇒ `config.limit_unknown_key`. TypeScript already rejects a stale key, but a wire /
* plain-JS caller does not go through TypeScript, and the retired names (`timeoutSec` above all) used
* to be honored — accepting them silently is how a caller keeps believing a limit is armed when the
* engine has stopped reading it.
* - **unevaluable numeric value** (non-number / non-finite / negative) ⇒ `config.limit_invalid`, naming
* the key. `0` is legal on every numeric axis: on `maxTurns` it is the documented "unbounded"
* sentinel, and on the budget axes it is an exhausted window (absurd but honest).
*/
export declare function resolveTaskLimits(limits: TaskLimits | undefined): TaskLimits | undefined;
/**
* Validate the MODE-VALUED members of `TaskSpec.attachments` at the same door, and return the
* config unchanged. Today that is exactly one member, `totalTokensReminderMode`.
*
* WHY A DOOR AT ALL (坏值响亮度律): a knob whose bad value silently folds to the default is a knob the
* deployment believes is armed. `totalTokensReminderMode: "padded_countdown"` (underscore) or
* `"Countdown"` are the near-misses a hand-written config produces, and both would otherwise read as
* "absent" and take the default arm — a DIFFERENT readout than the one that was asked for, with nothing
* anywhere saying so.
*
* DELIBERATELY NARROW, twice over:
* - no unknown-KEY refusal (the `resolveTaskLimits` half that has no counterpart here): a wire caller
* that sends an attachment flag this engine does not know is asking for a lane that stays off, which
* is the family's own default posture — not a ceiling silently disarmed. Widening it would also be a
* behavior change for every existing caller, which this additive lane does not license.
* - the pre-existing `todoReminderMode` is NOT validated here. It has the same shape and the same
* weakness, but tightening it would refuse specs that run today; recorded as a follow-up rather than
* folded into this lane's change.
*
* A NON-OBJECT CONTAINER (`attachments: null` off a wire, above all) is neither refused nor crashed on
* (verified by reading): every consumer reads this config through `attachmentsCfg?.…`,
* so a null container already means "no lanes are wired" and the spec RUNS today. An unguarded property
* read here — the first cut — turned that running spec into an untyped `TypeError` from inside a door
* that promises a typed `config.attachment_invalid`, and refusing it instead would newly reject a spec
* that works. There is no container-shape door in this family yet; that is a separate widening, not
* something this lane gets to invent on the way past.
*
* The bypass is narrowed to values that CANNOT supply attachment fields: a
* plain-JS caller can hand this a FUNCTION carrying `totalTokensReminderMode` as a property, and every
* downstream `attachmentsCfg?.…` read would happily find it — so skipping a function here would fold a
* typo'd mode to the default through exactly the hole this door exists to close. Functions are
* property-bearing containers and are validated like objects; only `null` and primitives pass through.
*/
export declare function resolveAttachmentsConfig(attachments: TaskSpec["attachments"]): TaskSpec["attachments"];
/**
* R2 双形轴(追加令 2026-07-18): CC 2.1.212's fable-variant prompt gate (b9e —
* `fable_5_mitigations` capability / claude-mythos-5), ORTHOGONAL to the simple/classic profile.
* sema is BYOM, so the id may carry provider prefixes ("anthropic/claude-fable-5",
* "openrouter/anthropic/claude-fable-5"): BOUNDARY-AWARE family match on the last path segment
* (raw substring classified "vendor/not-claude-fable-5" and "claude-mythos-50"
* as fable), case-normalized. Recognition set = CC's _Nr (startsWith "claude-fable-") + b9e
* (mythos-5). R3 system sections fork on the resulting fact.
*/
export declare function isFableFamilyModelId(id: string): boolean;
/**
* The SINGLE decision point for the two prompt-shape axes.
* Both were resolved in separate places with different mechanisms — `promptProfile` off a TaskSpec field,
* `fableMitigations` off a raw model-id prefix test — so "which shape does this task speak" had no one
* place to read. CC 2.1.220's counterpart is a model-registry `capabilities` array (one table drives
* `lean_prompt` + `fable_5_mitigations` alike; anchors/2.1.220/CC-218-220-DIFF.md §2).
*
* sema stays BYOM: we cannot key off a capability table for arbitrary model ids, so the RESOLUTION RULES
* are unchanged — profile: spec > inherited internals > "simple"; mitigations: model family. This is a
* consolidation, not a behavior change (the axes stay ORTHOGONAL: neither rewrites the other).
*/
export declare function resolveModelPromptTraits(model: {
id: string;
}, spec: {
promptProfile?: "simple" | "classic";
}, internals?: {
promptProfile?: "simple" | "classic";
}): {
promptProfile: "simple" | "classic";
fableMitigations: boolean;
};
export interface PrepareConfigDoorsInput {
/** borrowed-readonly — the snapshot spec (its roster is faces). NEVER mutated here: the per-task agents rebind returns a
* SHALLOW COPY on {@link PrepareConfigDoorsResult.spec} (only `tools` replaced, each rebuilt entry through the snapshot). */
spec: RosterSpec;
/** borrowed-readonly — deployment seats. Read for: the two containment-face value screens, locked-config preflight,
* retention door, interaction-posture door (incl. `deps.onError` disclosure), usage windows,
* brain-call guardrail default, model/role maps. */
deps: RunnerDeps;
/** borrowed-readonly — the retention door validates THIS store's declaration (it is one of the
* effective stores the leg would run over). No acquire happens in this phase. */
sessions: SessionStore;
/** borrowed-readonly — presence gates the resumeAt-conflict door and exempts the empty-objective
* door (a checkpoint resume passes `objective: ""` by design). */
resume?: PrepareResume | undefined;
/** borrowed-readonly — trusted engine channel: prompt-profile inheritance, the spawning run's
* interaction posture, the engine question-strip flag. */
internals?: RunInternals | undefined;
}
export interface PrepareConfigDoorsResult {
/** owned (by the caller, from here on) — the REBOUND spec: identical to the input object unless
* `spec.agents` was set, in which case it is a shallow copy whose `tools` is the
* withAgents-rebuilt roster. Every later phase (the safety scan above all — T21) must read THIS,
* never the original input. */
spec: RosterSpec;
/** owned — the frozen task-start copies of the caller's tool-face control arrays; the driver compiles the leg's selector
* from them, and the selector (with the disclosure lists) is the ONLY thing later face reads consult. Frozen-immutable. */
toolFaceLists: ToolFaceLists;
/** owned — the profile half of the single decision point (model-independent by contract). */
promptProfile: "simple" | "classic";
/** owned — the host selector values (`TaskSpec.promptRole` / `TaskSpec.promptForm`, validated) the assembly selects on. */
promptHost: ReturnType<typeof resolveHost>;
/** owned — the run's binding (`TaskSpec.promptBinding`, validated): the selector value every overlay's `when.binding` reads. */
promptBinding: string | undefined;
/** owned — the administrator lock snapshot; the rest of prepare reads guarded slots through it. */
lockedPreflight: LockedPreflight;
/** owned — the resolved interaction posture (spec > spawning run > deployment); the
* AskUserQuestion mount and the child-ctx injection read this same value. */
resolvedInteractionPosture: "interactive" | "headless" | undefined;
/** owned, frozen — the run's approval seat ({@link resolveAskSeat}), the one read of the seats; later phases read only this. */
askSeat: AskSeat;
/** owned — the validated/resolved microCompact knob ({@link resolveMicroCompactKnob}
* for the door + placement reasoning). The per-run state builder consumes exactly these values —
* the deps bag is never re-read after this door. */
microCompactKnob: {
machine: MicroCompactMachine;
clearOnRejection: boolean;
};
/** owned — the resolved role; its `systemPrompt` seat is still read at prompt-input time. */
resolvedRole: ResolvedRole;
/** owned — the resolved main model (`resolvedRole.model`, re-exposed as the name every later
* phase reads). */
model: Model;
/** owned — caller-explicit > role default > model default; undefined = off. */
thinking: ThinkingLevel | undefined;
/** owned — the compaction model: explicit wins, else the `summarize` role, else undefined (main
* model fallback inside maybeCompact). NOT derivable from {@link resolvedRole}. */
compModel: Model | undefined;
/** owned — the mitigations half of the decision point (model-family fact). */
fableMitigations: boolean;
/** owned, frozen — model-gate DISCLOSURE data (the application itself already
* happened on {@link spec}: its `tools` is the survivor rebind when anything was removed).
* prepareTask emits the notices from this after the doors return (the onNotice station);
* the doors stay side-effect-free on the announcement axis. */
modelGate: {
/** gate-class → removed wire names (sorted, unique); empty map = nothing removed. */
removedByClass: ReadonlyMap<string, readonly string[]>;
/** stamped classes with no merged-table row (inert tags, fail-open — announce material). */
unknownClasses: readonly string[];
/** a not-in-force `SEMA_TOOL_MODEL_GATE` value outside the closed set (discard-announce
* material; the in-force arm never lands here — it throws at the door). */
discardedEnvRaw: string | undefined;
};
/** owned — the NORMALIZED per-leg delegation-evidence standard: the deps seat read
* ONCE, in this synchronous pre-await stretch, screened, and absent folded to the "static-face"
* default. The delegation wrap consumes THIS value, never the live deps object — a deps seat
* mutated (or getter-backed) after the door must not present an unscreened value to the mark
* branch (the read-once posture). */
memoryDelegationEvidence: "static-face" | "attested-only";
/** owned — the lowered skills-listing budget seats (`TaskSpec.skillListingBudgetFraction` /
* `skillListingMaxDescChars`): the renderer's two numbers, resolved once against the resolved main
* model's window. Read at the door so a bad value refuses BEFORE session acquisition, and carried so
* the listing phase and the boundary renderer measure against the same resolution. */
skillListingBudget: ResolvedSkillListingBudget;
/** owned — the NORMALIZED memory-provenance mode: the deps seat read ONCE in
* the synchronous pre-await stretch, screened, absent folded to the `"carry"` default.
* The memory phase constructs every engine with THIS value — never a live deps re-read. */
memoryProvenance: "off" | "carry";
/** owned — the NORMALIZED memory-capture posture: the deps seat read ONCE,
* screened (a bad value refuses, never folds), absent folded to `"open"`. The capture
* opt-out adjudicator consumes THIS value on every leg (declaration, resume record re-check,
* and the flip verb's frozen closure) — never a live deps re-read. */
memoryCapturePolicy: "open" | "governed" | "capture-required";
/** owned — validated deployment governance windows (undefined = ungoverned). */
usageWindows: readonly UsageWindow[] | undefined;
/** owned, out-param cell — created EMPTY here; the brain-call wiring later installs into
* `.current`. Returned so driver and harness share one cell identity. */
brainCallGuardrailRef: BrainCallGuardrailRef;
/** owned — the resolved outer brain-call guardrail (task > deployment > default; undefined per
* the resolver's off states). */
brainCallGuardrailMs: number | undefined;
}
/** The microCompact knob door (坏值响亮度默认律, the sibling knob doors' form:
* interactionPosture / toolModelGate / staleToolResultOffload). Placement is load-bearing:
* it runs in THIS pre-first-await config-doors phase so a malformed declaration refuses
* BEFORE any resource-bearing preparation — session acquisition, execution-env creation, the MCP
* dial — whose external effects a late refusal's teardown cannot undo. SINGLE READ per seat, and
* the CONTAINER is judged first (reviews r1/r2): an accessor-backed bag answering a legal value at
* one read and junk at another must be caught by whichever read this door takes, and a
* `microCompact: "cc"` / null / number / array bag makes both property reads answer undefined —
* it must refuse exactly like a malformed field, never fold to the defaults. The RESOLVED values
* returned here are immutable and are the state builder's ONLY source — nothing re-reads the bag. */
export declare function resolveMicroCompactKnob(bag: RunnerDeps["microCompact"]): {
machine: MicroCompactMachine;
clearOnRejection: boolean;
};
/** The seat a checkpoint-store resolution is judged at: the store the task's own config names, else the deployment's. */
export declare const checkpointStoreSeat: (spec: {
checkpointStore?: unknown;
}) => "taskConfig" | "deps";
/** The first door of a resume: its re-supplied config is a record. A `null`, a primitive or an array where the record is declared is refused by name
* (`config.invalid_argument`), through the same channel as every door after it, before any of its members is read. */
export declare function assertResumeConfigRecord(config: unknown): void;
/** The spec the value doors read: a prepare's (its roster is faces) or a resume's re-supplied config (no objective, no session id). */
type ValueDoorSpec = Omit<TaskSpec, "tools" | "objective" | "sessionId" | "userAttachments"> & Partial<Pick<TaskSpec, "objective" | "sessionId">>;
type ConfigValueDoorsResult = Pick<PrepareConfigDoorsResult, "toolFaceLists" | "promptHost" | "promptBinding" | "lockedPreflight" | "microCompactKnob" | "memoryDelegationEvidence" | "memoryProvenance" | "memoryCapturePolicy" | "usageWindows" | "brainCallGuardrailMs"> & {
skillListingSeats: {
fraction: unknown;
maxDescChars: unknown;
};
skills: TaskSpec["skills"];
};
/**
* The phase's VALUE doors as ONE function with two callers — each judges a declared value against its own vocabulary, reading only the
* spec, the deployment's deps, the session store the Runner was constructed over and whether the leg resumes: {@link prepareConfigDoors}
* enters them as they stand, and a resume's screen before the claim (`refuseResumeDoors`) enters them through the resume's refusal
* channel, so a resume prepare would refuse here is refused before it consumes the row. `enter(seat, door)` names the input that carries
* each value; what a door resolves is returned. Every door is total over what a plain-JSON caller can hand it: a `null`, a primitive or an
* array where a record is declared is refused by its own code, never read through.
*/
export declare function configValueDoors(input: {
spec: ValueDoorSpec;
deps: RunnerDeps;
resumeLeg: boolean;
sessions: SessionStore;
}, enter?: ConfigDoorEntry): ConfigValueDoorsResult;
/**
* The doors a RESUMED leg's prepare refuses at, run as one screen BEFORE the claim — prepare's own functions, so a refusal carries
* the very sentence and code prepare throws: the mode doors (`refusePermissionModeDoors`), the output-style seat, the approval
* reach, the skills invoked by name (the part no roster changes), the attachments (a resume sends no new human message, so any
* entry is refused), this phase's value doors over the config, the deployment's deps and the session store the Runner holds
* ({@link configValueDoors}, the retention capability door included) and the mode words the row recorded (`recordedModeSeed`).
* It leaves by the resume's ONE refusal channel (`asResumeConfigRefusal`:
* `checkpoint.config_refused`, `detail.code` = prepare's code, `detail.field` = `"taskConfig"`, `"deps"` or `"inheritedGate"`)
* where prepare would end the leg `failed` after the claim — the row consumed on a store without `reopen`, replayed as a system
* retry on one with it, which a configuration fix cannot reach. Before the claim the row stays `pending` and byte-identical and the
* corrected resume redeems the same token. A throw that is none of prepare's codes leaves as it was thrown. Returns the config the
* leg is handed: the caller's, its MCP servers and A2A peers the frozen entries the lock layer judged (read once, dialed as judged).
*/
export declare function refuseResumeDoors<C extends ValueDoorSpec & Pick<TaskSpec, "userAttachments">>(config: C, recordedGate: Parameters<typeof recordedModeSeed>[0], deps: RunnerDeps, sessions: SessionStore): C;
export declare function prepareConfigDoors(input: PrepareConfigDoorsInput): PrepareConfigDoorsResult;
export {};