@sema-agent/core
Version:
Stateless, task-oriented AI agent core
126 lines (125 loc) • 9.16 kB
TypeScript
/**
* design/277 — the tool-registration MODEL GATE (CC 233 counterpart, first formalized there as a
* family+version-floor table over the todo/task-board tool family).
*
* WHAT THE GATE IS: a DEFAULT-MOUNT trim, never a capability ban. A {@link ToolSpec.modelGate}
* tag declares "this entry is a default-mounted scaffold of the named class"; when the task's
* RESOLVED model id matches the class's rule below, that entry is dropped from the roster at
* prepare (true unmount — the schema never reaches `tools[]`). An EXPLICITLY composed tool is
* never tagged (the bundle only stamps its default arms), so "the user asked for it" is the
* first restore channel by construction. Restore channels beyond that:
* `TaskSpec.restoreGatedTools` (per task-tree), env `SEMA_TOOL_MODEL_GATE=off` (process),
* `RunnerDeps.toolModelGate: false` (deployment).
*
* FAIL-OPEN, BY CONTRACT (not by accident): sema is BYOM — model ids are an OPEN set, and this
* table encodes POSITIVE knowledge ("this family at this version and above manages multi-step
* work without the scaffold"). An id the table knows nothing about (deepseek-* / gpt-* / qwen-* / any
* custom name) is NOT gated: no knowledge ⇒ no trim ⇒ tool stays. The matcher's three pass-through
* edges (family absent, id shape unmatched, version below floor) all point the same way, and it is
* the only polarity compatible with BYOM — the reverse would trim every non-claude deployment's
* default face. The engine does NOT guarantee a canonical id either: a deployment-authored
* `Model.id` (a raw Model object, or a catalog key shadowing the tier expansion) that is not in
* canonical `claude-<family>-<version>` shape simply falls off the regex and stays open — the
* explicit-id discipline's deployment-side duty. To gate a non-claude strong model, write the
* exact id into a {@link ToolModelGateRule.modelIds} row (the only non-claude channel; no prefix
* or wildcard syntax exists).
*
* DIVERGENCE (registered, deliberate): CC judges the MAIN-LOOP canonical model once per process;
* sema judges the PER-TASK resolved model on every prepare — BYOM per-task models are first-class,
* so a delegated child on a different model re-judges under its own (strong parent / weak child ⇒
* the child gets the scaffold back). And the env valve's polarity is reversed (CC: enable the
* tools; sema: disable the GATE) because sema's class vocabulary is open — a per-family enable
* env would have to grow with every class.
*/
import type { ToolSpec } from "./types.js";
/** The shared tail extractor (BYOM ids may carry provider prefixes — "openrouter/anthropic/claude-fable-5"):
* boundary-aware LAST path segment, case-folded. Single source for every model-family/model-id
* comparison site (`isFableFamilyModelId` consumes it too — the prompt-shape axis, semantically
* independent but sharing the one tail-extraction posture). */
export declare function modelIdTail(id: string): string;
/** One gate class's matching rule (both axes optional; a rule with BOTH axes empty gates nothing —
* the per-class OFF shape a deployment reaches by explicitly clearing the axes). */
export interface ToolModelGateRule {
/** claude-syntax family+floor rows — meaningful only for ids whose TAIL SEGMENT matches
* `claude-<letters>-<digits(-digits)*>`. Version tuples compare positionally, missing positions
* read 0, tuple ≥ floor ⇒ gated (CC 233 comparator, verbatim semantics). Any other id shape
* falls through this axis entirely (fail-open). */
floors?: ReadonlyArray<readonly [family: string, floor: ReadonlyArray<number>]>;
/** Exact-id rows (explicit-id discipline): the resolved `Model.id`'s tail segment must equal the
* row case-insensitively. The ONLY channel by which a BYOM deployment gates its own non-claude
* strong model — deliberately no prefix/wildcard syntax (a substring guess against an open id
* set is how the `deepseek-chat` alias downgrade class of accident happens). */
modelIds?: ReadonlyArray<string>;
}
/**
* The built-in gate table: gate-class → rule. CC 233 z_S floors, verbatim (evidence D2; the
* `mythos` row rides per the anchor). v1 vocabulary = ONE class, `"task-scaffold"` — the
* TodoWrite/TaskCreate/TaskGet/TaskUpdate/TaskList default bundle (`assembleCodeTools`). One class
* is evidence discipline, not mechanism limit: a family enters this table only with positive
* "strong models don't need it" evidence (a CC anchor or deployment measurement); classes are an
* open vocabulary and a deployment adds its own via `RunnerDeps.toolModelGate.classes`.
*/
export declare const TOOL_MODEL_GATE_CLASSES: Readonly<Record<string, ToolModelGateRule>>;
/**
* Pure matcher: is `modelId` gated under `rule`? Every unmatched shape returns `false` (the
* fail-open contract in the module header). Exported for deployment pre-flight ("would my model
* lose the scaffold?") — the engine's own decision point calls this same function.
*/
export declare function isModelGatedForClass(modelId: string, rule: ToolModelGateRule): boolean;
/**
* #123 value screen for `TaskSpec.restoreGatedTools` (same posture as `assertContainmentFaceValue`: the
* VALUE is screened at the door, unconditionally — a garbage restore list must refuse loudly on
* every leg, never be silently read as "no restore" in the narrowing direction). Legal: absent,
* literal `true` (restore every class), or an array of strings (wire names; unknown names are
* inert by contract, like `excludeTools`).
*/
export declare function assertRestoreGatedToolsValue(value: unknown): asserts value is true | readonly string[] | undefined;
/** Doors-facing decision (built + applied inside `prepareConfigDoors`' synchronous stretch —
* decision and application share one atomic window, no drift gap). */
export interface ToolModelGateDecision<T extends ToolSpec = ToolSpec> {
/** Present ⇔ at least one entry was removed: the fresh, PRIVATE survivor array `spec.tools` is
* rebound to (a caller mutating its original live array afterwards can neither re-add a removed
* entry nor displace anything — there are no indices left to misalign). `undefined` = no
* removal, the spec is left untouched. */
survivors: T[] | undefined;
/** gate-class → removed wire names (sorted, unique) — the removal-notice payload. */
removedByClass: ReadonlyMap<string, readonly string[]>;
/** Stamped classes with no row in the merged table — announce-once material (the loud half of
* fail-open: a tag typo must not silently become "never gated" with nobody told). */
unknownClasses: readonly string[];
/** An env value outside the closed set that was NOT in force (nothing this prepare would gate) —
* discarded-value announce material. In-force garbage never lands here: it throws. */
discardedEnvRaw: string | undefined;
}
/**
* The single decision point, called from `prepareConfigDoors` with the model id read ONCE into the
* decision (the Model object is mutable; decision and later reads must not diverge) and the frozen
* restore snapshot (never the live spec).
*
* Channel semantics (a UNION of loosening channels — any hit ⇒ don't trim; the order below is
* evaluation order, not priority):
* - `depsSeat === false` — deployment kill switch; nothing is scanned.
* - env `SEMA_TOOL_MODEL_GATE` `off|0|false` (case-folded) — process kill switch; `on|1|true` is
* the explicit default (no-op). A value outside the closed set follows the loud-bad-value
* dialect split (#123, the SEMA_TOOL_MATERIALIZE_STRATEGY precedent): where the value is IN
* FORCE — its reading would change this prepare's outcome, i.e. after folding the other
* channels there remains a stamped entry the gate would remove — the prepare REFUSES
* (`config.tool_model_gate_env_invalid`; a mistyped `off` must not silently select the
* narrowing arm). Everywhere else it is a DISCARDED value: announced once per process, never a
* veto of the configuration that outranks it.
* - `restoreGated === true` — every class exempt for this task; an array exempts the WHOLE class
* of any stamped tool it names (CC "opting into any tool of the family restores the family",
* judged against the FULL stamp set — a name `excludeTools` will later remove still selects its
* class here, while the exclusion itself stands downstream; unknown names inert).
* - a stamped class with no merged row ⇒ kept + reported in `unknownClasses` (fail-open, loud).
* - a merged rule with both axes empty ⇒ kept, silent (the documented per-class OFF).
* - the exclusion valve (`excludeTools`, applied downstream at the roster splice) ALWAYS wins in
* the result: no restore channel resurrects an excluded name.
*/
export declare function applyToolModelGate<T extends ToolSpec>(input: {
tools: ReadonlyArray<T> | undefined;
modelId: string;
depsSeat: unknown;
restoreGated: true | readonly string[] | undefined;
envRaw: string | undefined;
}): ToolModelGateDecision<T>;