UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

100 lines (99 loc) • 7.55 kB
import type { Model } from "../../internal/llm.js"; import type { ToolSpec } from "../types.js"; import type { ToolFingerprintInput } from "../cache-break-detector.js"; import { type ResolvedToolSearch, type ToolSearchSetting } from "../tool-search-setting.js"; /** * The deferral GATE: which mounted tool names ship as placeholders on a request. Pure * registry logic over names and sizes — no harness, no I/O. `tool-disclosure.ts` owns the placeholder * and ToolSearch mechanics that act on the set this module decides; `prepare-defer-classify.ts` is the * phase that assembles the inputs from the mounted face. */ /** The deferral gate's DEFAULT threshold: with no `toolSearch` seat, the candidate set defers when its inlined size reaches this fraction of the model's * context window (that set measured, never the rest of the face). Derived from the seat's own default percent — one default, two units. */ export declare const DEFER_THRESHOLD_FRACTION: number; /** The typed refusal a `deferMode` outside its declared domain (`"auto"` or absent) raises at the classifier — * a bad value never selects an arm silently. */ export declare const DEFER_MODE_INVALID_CODE = "config.defer_mode_invalid"; /** The deferral gate's verdict — the two deferral lists, what the gate measured, and whether it opened. */ export interface DeferralGate { /** The caller's LITERAL list: `TaskSpec.deferTools` names mounted on this face, minus the inline pins. * Deferred on every request, whatever the gate says — an intentional deferral is not a size question. */ explicit: Set<string>; /** The engine's SELF-SELECTED candidates: `ToolSpec.defer === true`, every remote-protocol name, built-ins * that declare their own `defer`, and — under `deferMode: "auto"` — every caller spec; minus the inline * pins and minus `explicit`. Deferred iff the gate opens. */ gated: Set<string>; /** Σ inlined chars (name + description + serialized schema) over `gated` — the bytes the gate could reclaim. */ gatedChars: number; /** `floor(contextTokens × fraction) × CHARS_PER_TOKEN`; the char reading of the token threshold. `0` under `toolSearch: true` and `Infinity` under * `false` — the two boolean arms ARE this number. */ thresholdChars: number; /** Whether `gated` is deferred on this request: `gatedChars ≥ thresholdChars`. */ open: boolean; /** The lowered seat this verdict was measured under ({@link ToolSearchSetting}); always present, since an absent seat resolves to the default `auto`. */ toolSearch: ResolvedToolSearch; } /** * Deferral has TWO rules, and only two. * * ① THE CALLER'S LIST IS LITERAL. `TaskSpec.deferTools` (arriving as `callerDeferNames`, pre-filtered to * mounted wire names, built-ins included) is deferred on every request; `alwaysLoad` (`ToolSpec.alwaysLoad`, * `alwaysLoadNames`) is inlined on every request and is judged FIRST — a pinned name is in neither list * and is never measured. Neither depends on the gate: they are the deployment's stated intent. * * ② THE ENGINE'S OWN CANDIDATES SIT BEHIND ONE THRESHOLD GATE. The candidates are declared: explicit * `ToolSpec.defer === true` on a caller spec, every REMOTE-PROTOCOL tool name (MCP servers AND A2A peers — * the caller unions them), and `builtinDeferNames` (mounted BUILT-INS that declare their own `defer`; a * built-in ToolSpec never reaches `specs`, so its declaration arrives as a mounted name). `deferMode: * "auto"` WIDENS the candidate set to every caller spec — undeclared caller tools included — and nothing * else: it is not a second gate, and engine built-ins the deployment did not name are candidates under no * setting. The gate decides the set all or nothing: deferred iff `gatedChars ≥ thresholdChars`, where the * threshold is the DEPLOYMENT'S FRACTION of the model's context window (`contextTokens ?? contextWindow`, * in chars at {@link CHARS_PER_TOKEN}) — {@link ToolSearchSetting} lowered by {@link resolveToolSearch}, * which is {@link DEFER_THRESHOLD_FRACTION} when no seat is wired. The measure is the candidate set ONLY — * the bytes the gate could reclaim — never the caller's list, the pins, or non-candidates: a request whose * weight sits elsewhere gains nothing from a ToolSearch round-trip, so nothing about it argues for one. A * window of zero reads as a zero threshold (every candidate byte is over budget), so the candidates defer. * * Invariants: `explicit ∩ gated = ∅`; `deferred = explicit ∪ (open ? gated : ∅)`; a pinned name is in no * list; the same inputs always yield the same verdict (a pure function of the request). */ export declare function deferralGate(opts: { specs: ReadonlyArray<ToolSpec>; /** Every namespaced tool a remote protocol materialized for this task (MCP servers AND A2A peers — * the caller unions them). The remote-roster shape (many tools, schemas the deployment does not * control, drift between tasks) is the same for every protocol, so every protocol's names are * candidates by being remote. Only an inline pin (or the caller's own list) takes a name out. */ protocolToolNames: ReadonlyArray<string>; /** The MOUNTED face — the tools whose full definitions the request would carry. The measure reads * each candidate's inlined size from here; a candidate absent from it weighs nothing. */ fullTools: ReadonlyArray<ToolFingerprintInput>; /** `"auto"` widens the candidate set to every caller spec (see above). Any other value refuses. */ deferMode?: "auto"; /** The deployment's gate policy for rule ② ({@link ToolSearchSetting}); absent ⇒ the default `auto` at * {@link TOOL_SEARCH_DEFAULT_THRESHOLD_PERCENT}, byte-for-byte the prior behavior. */ toolSearch?: ToolSearchSetting; model: Model; /** Rule ① — `TaskSpec.deferTools`: ALREADY-MOUNTED wire names (built-ins included; `ToolSpec.defer` * only reaches caller specs) the caller defers on every request. Caller pre-filters to mounted names. */ callerDeferNames?: ReadonlyArray<string>; /** Rule ② — mounted BUILT-INS that declare their own `defer` (the engine's declaration, not the * caller's), so they are candidates. Caller pre-filters to mounted names. */ builtinDeferNames?: ReadonlyArray<string>; /** Rule ① — inline-pin names (`TaskSpec.alwaysLoadTools` + the MCP tools whose server declared * `_meta["anthropic/alwaysLoad"]`, caller-collected; A2A has no such per-skill declaration, so the * task-level list is a peer's only inline-keep channel): exempt from BOTH lists. * `ToolSpec.alwaysLoad` rides the specs directly and needs no entry here. */ alwaysLoadNames?: ReadonlyArray<string>; }): DeferralGate; /** * Decide which tool names are deferred: the caller's literal list, plus the gated candidates when the gate * opens. THIS CONTRACT (with {@link deferralGate}) IS THE SINGLE AUTHORITY on the deferral sources and the * gate; the `TaskSpec` / `RunnerDeps` JSDoc points here. */ export declare function classifyDeferred(opts: Parameters<typeof deferralGate>[0]): Set<string>; /** The SAME fold, with the verdict it folded — a disclosure that reports the policy must read the one the gate measured under, never a later re-resolution. */ export declare function classifyDeferredWithGate(opts: Parameters<typeof deferralGate>[0]): { deferred: Set<string>; gate: DeferralGate; };