UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

126 lines (125 loc) • 9.12 kB
/** * 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 { /** 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: ToolSpec[] | 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(input: { tools: ReadonlyArray<ToolSpec> | undefined; modelId: string; depsSeat: unknown; restoreGated: true | readonly string[] | undefined; envRaw: string | undefined; }): ToolModelGateDecision;