UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

226 lines (225 loc) • 15.6 kB
/** * 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;