@sema-agent/core
Version:
Stateless, task-oriented AI agent core
226 lines (225 loc) • 15.6 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 { 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 { PrepareResume, RunInternals, ToolFaceSnapshot } from "./contracts.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 caller's spec. NEVER mutated here: the per-task agents rebind returns a
* SHALLOW COPY on {@link PrepareConfigDoorsResult.spec} (only `tools` replaced). */
spec: TaskSpec;
/** 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: TaskSpec;
/** owned — the frozen task-start snapshot of the caller's tool-face control arrays; the ONLY thing
* later face reads consult (own-task filter, defer classify, delegation ctx). Frozen-immutable ⇒
* shared by reference into tool execution ctxs. */
toolFaceSnapshot: ToolFaceSnapshot;
/** owned — the profile half of the single decision point (model-independent by contract). */
promptProfile: "simple" | "classic";
/** 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 — 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;
};
export declare function prepareConfigDoors(input: PrepareConfigDoorsInput): PrepareConfigDoorsResult;