@sema-agent/core
Version:
Stateless, task-oriented AI agent core
100 lines (99 loc) • 7.55 kB
TypeScript
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;
};