@sema-agent/core
Version:
Stateless, task-oriented AI agent core
264 lines (263 loc) • 19.5 kB
TypeScript
import { type TSchema } from "typebox";
import type { AgentMessage, AgentTool, AgentToolResult, AgentToolUpdateCallback, ToolExecutionMode } from "../../internal/harness-types.js";
/**
* Deferred-tool dynamic disclosure. A deployment with hundreds of tools / several MCP
* servers can't inline every full JSON Schema into every request — turn-1 cache-creation tokens
* explode and any tool-description drift breaks the prefix cache. Instead, deferred tools ship as
* lightweight placeholders ({name, one-line hint, empty params}); a resident `tool_search` lets the
* model activate them by name or keyword, at which point core MATERIALIZES their full schema into the
* next request's tools[] (client-side, provider-agnostic — no Anthropic `tool_reference` beta) and
* announces them via the search's own result content (tail of the log; the cache prefix is untouched).
*
* This module is pure tool/registry logic: prepare-task owns the harness wiring and supplies a
* `rematerialize` callback (which calls `harness.setTools` + refreshes the fingerprint).
*/
export declare const TOOL_SEARCH_NAME = "ToolSearch";
/** Default keyword-search result cap (CC ToolSearchTool.ts:28-32 `max_results` default 5). */
export declare const TOOL_SEARCH_DEFAULT_MAX_RESULTS = 5;
/**
* How many times in a row ONE placeholder may answer the IDENTICAL invalid-arguments
* shape before the lane declares the loop unproductive and stops re-teaching. Three, because the
* corrective round is worth paying for twice (the first rejection hands over the schema; a second
* covers a model that needed the restatement) and a third identical failure is evidence that the
* schema was never what was missing.
*/
export declare const DEFERRED_NO_PROGRESS_LIMIT = 3;
/**
* The stable machine code the no-progress terminal carries on its tool-result `details.noProgress`
* (and verbatim in its model-facing text, so a transcript reader and a log reader see one string).
* Names the CONDITION, not the blame: the caller and the tool's declaration cannot be reconciled on
* this route — the classic producer is a constrained decoder bound to the placeholder's empty
* advertised schema.
*/
export declare const DEFERRED_SCHEMA_INCOMPATIBLE_CODE = "tool.deferred_schema_incompatible";
/** A deferred tool as the search ranks it: stable name + the one-line hint + full description for scoring. */
export interface DeferredToolInfo {
name: string;
hint: string;
description: string;
/** The real tool's aliases — a placeholder stands for the tool under EVERY name it answers to, so dispatch by
* alias resolves to the same slot before and after activation (and the roster's name→row resolution agrees). */
aliases?: readonly string[];
}
/** First line of a description, trimmed to a single short hint for a placeholder / announcement. */
export declare function deferHint(description: string, max?: number): string;
/**
* Neutralize a tool name before it appears in ANY model-facing text. A tool name is
* externally influenced — an untrusted MCP server can return one containing newlines (to inject a fake
* `<system-reminder>`/instruction line), backticks/quotes (to corrupt the JSON or markdown the model is
* told to emit), or angle brackets (to break a delimiter). Strip control chars, CR/LF, `` ` ``, `"`, `<`, `>`.
* For a normal name (`[A-Za-z0-9_.-]`, incl. MCP `server__tool`) this is a no-op.
*
* MUST wrap a tool name at EVERY site where it reaches the model — placeholder text, the search tool's
* announcement, anywhere. A bare interpolation is a prompt-injection hole.
*/
export declare function safeName(name: string): string;
/** Build the registry the search ranks over (deferred tools only — non-deferred are already visible). */
export declare function buildDeferredRegistry(deferred: ReadonlySet<string>, tools: ReadonlyArray<{
name: string;
description: string;
aliases?: readonly string[];
}>): Map<string, DeferredToolInfo>;
/**
* The direct-call lane a placeholder may carry. The upstream defer gate is SHAPE
* validation, not activation state: a call whose arguments validate against the REAL tool's
* declared schema runs the real tool directly (activation is only about whether the model has
* been SHOWN the schema, never an execution gate). Governance cannot be laundered through this
* lane: policy/effect/irreversibility gates key on the tool NAME maps built from the real
* declarations, and the engine's approval pipeline runs before any execute — the placeholder
* object on the harness list plays no part in adjudication.
*/
export interface PlaceholderDirectCall {
/**
* Resolve the real tool AT CALL TIME, never from a prepare-time snapshot. The mounted
* roster is a LIVE array: an MCP refresh splices a server's name domain out and pushes freshly
* materialized tool objects back in, so a Map captured when the placeholder was built would hand
* this lane the WITHDRAWN object (stale schema shape gate, stale transport client) long after the
* roster moved on. Returns `undefined` when the name no longer resolves — a withdrawn name has no
* direct lane, and the caller falls back to the teaching rejection.
*/
resolveReal: () => PlaceholderDirectTarget | undefined;
/**
* The scheduling declaration this placeholder carries while it stands in for the real
* tool. Mirrors the REAL tool's own `executionMode` (see the mirror rationale in
* {@link createPlaceholderTool}); absent ⇒ the engine's fail-closed default, exactly as it would
* be for the real tool once activated.
*/
executionMode?: ToolExecutionMode;
/**
* Does THIS name sit on the static face right now, i.e. will it never be swapped for
* the real tool's declaration? The lane's model-facing rejection text reads it to say where the
* schema it hands back actually lives (this result only, versus this result AND every later
* request's tools block). Absent ⇒ the swap posture.
*
* A FUNCTION, evaluated per call, for the same reason `resolveReal` is: the answer is not
* a prepare-time constant. The run-wide strategy is, but the per-tool exemption on top of it reads
* the LIVE roster — an MCP refresh can replace a tool with a declaration that no longer fits the
* result-text carrier (or one that now does), and a boolean captured when the placeholder was built
* would then describe the wrong carrier.
*/
staticFace?: () => boolean;
/**
* Activation side effects (active-set add + rematerialize + boundary delta announcement).
* Idempotent; awaited BEFORE the invoke so the next boundary already shows the full schema.
* MUST be atomic: a thrown rematerialize has to leave the active set as it found it.
*
* Returns the announcement text this activation has to CARRY, or `undefined` when it has
* none (already active, or nothing rides on this name). The ToolSearch lane announces on its own
* result content; a direct call makes no ToolSearch call at all, so a face whose only carrier is
* that result — today the agent-type roster of a deferred delegation tool — had no way to reach
* the model on this leg. The text is appended to THIS call's result (see
* {@link createPlaceholderTool}), the same channel discipline: tail of the log, cache prefix
* untouched, emitted only after the rematerialize succeeded.
*/
activate: () => Promise<string | undefined>;
}
/** The live view of the real tool behind a placeholder, resolved per call (see {@link PlaceholderDirectCall.resolveReal}). */
export interface PlaceholderDirectTarget {
/** The REAL tool's declared parameter schema — the shape gate for a direct call. */
parameters: TSchema;
/** Invoke the real tool; the result passes through VERBATIM (structured details intact).
* `onUpdate` is part of the contract: the engine hands every execute a progress sink,
* and dropping it here silently mutes streaming progress (MCP `notifications/progress`) for any
* tool reached through this lane, while the SAME tool streams normally once activated. */
invoke: (toolCallId: string, params: unknown, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<unknown>) => Promise<AgentToolResult<unknown>>;
}
/**
* Can the STATIC face carry this declaration at all? Static's only in-context schema
* carrier is result TEXT, so a declaration that cannot be serialized, or that the model-facing error
* bound would truncate into non-JSON, has no carrier there. Such a tool is exempted per-tool from the
* static face (it materializes into the tools block on activation, the swap way) rather than being
* announced as callable with a schema the model never actually receives. `undefined` (a name the live
* roster no longer resolves) is not renderable either.
*/
export declare function staticSchemaRenderable(schema: TSchema | undefined): boolean;
/** A placeholder AgentTool for a deferred tool: visible name + hint, empty params. Without `direct`,
* every call is the teaching rejection; with it, a schema-valid call runs the real tool. */
export declare function createPlaceholderTool(info: DeferredToolInfo, direct?: PlaceholderDirectCall): AgentTool;
/** Keyword score of a query against a deferred tool: name hits weigh more than description/hint hits. */
export declare function scoreToolMatch(query: string, info: DeferredToolInfo): number;
/** The argument shapes `tool_search` accepts (CC-canonical): `query` (+ optional
* `max_results`). The pre-keyword-search `select` ARRAY arm is retired —
* durable-replay compat was already sacrificed by a prior ruling (replay resolution included);
* the CC-canonical `select:A,B,C` string prefix inside `query` is the live selection form. */
export interface ToolSearchArgs {
query?: string;
max_results?: number;
}
/**
* Resolve a tool_search request to deferred-tool names (CC ToolSearchTool.ts form).
* Query forms, checked in order:
* ① `select:A,B,C` prefix (case-insensitive prefix, CC ToolSearchTool.ts:363) — comma-split EXACT
* name activation against the registry's names; misses are reported in `missing` (never silently
* eaten) and the result is NOT truncated by `max_results` (explicit selection gets everything).
* ② bare tool name (case-insensitive equality, CC:199-204) — selects that tool directly; handles
* models sending a name instead of the select: prefix (seen from subagents/post-compaction).
* ③ keyword search — scored matches capped at `max_results` (CC default 5).
* The pre-keyword-search `select` ARRAY arm is retired (see {@link ToolSearchArgs}) — a stale
* replayed array argument now resolves like an empty query (nothing matched, honestly).
*/
export declare function resolveToolSearchDetailed(args: ToolSearchArgs, registry: ReadonlyMap<string, DeferredToolInfo>): {
matched: string[];
missing: string[];
};
/** Names-only view of {@link resolveToolSearchDetailed} — the deterministic replay core used by
* {@link extractDiscoveredToolNames} (and kept as the stable exported signature). */
export declare function resolveToolSearch(args: ToolSearchArgs, registry: ReadonlyMap<string, DeferredToolInfo>): string[];
/**
* Stateless recompute of the activated-tool set from the message log. The in-process
* `active` Set is the primary source within one process, but a RESUMED session (new process) starts with
* an empty Set — without this, previously-activated tools would revert to placeholders. Two carriers,
* both deterministic and both replayed here:
*
* ① every prior `tool_search` call's arguments, re-resolved through `resolveToolSearch` — unless that
* call's OWN result carries the error flag (a denied/blocked search never reached execute
* and staged nothing; a call with no result at all keeps the historic credit);
* ② A SUCCESSFUL call on a deferred name, which is an activation in its own right: the
* direct lane runs `activate()` BEFORE it invokes the real tool, so a result that came back
* without an error flag can only have come from a call that passed the shape gate and activated.
* No new carrier had to be minted for this: the toolCall part and its result are already in the
* transcript. Before this arm, a direct activation left NO trace at all — the next leg reverted the
* tool to a placeholder (losing one turn's schema) and, for the roster-bearing delegation tool, kept
* its listing face deferred so the boundary producer stayed silent as well.
*
* ② is deliberately keyed on the ERROR FLAG rather than on re-running the shape gate: the error flag is
* the only signal that distinguishes "the lane ran" from "the call never reached it". Every non-execution
* outcome the engine can produce for a placeholder call — the teaching rejection (a throw), a policy
* denial or hook block, an abort, invalid arguments — is flagged as an error, and none of them activated
* anything. It is an UNDER-approximation in one direction only: an activation whose real tool then failed
* (or threw) is not counted, so that name reverts to a placeholder and the model re-activates on demand —
* self-healing, exactly like the keyword-cap note below, never a widening. The shape-INVALID
* direct call (ruled 2026-08-03) activates too and is flagged as an error, so it lands in that same
* under-approximated class by construction: within the process the in-memory `active` set carries it, and
* a replay that loses it costs the corrected call — which succeeds, and is itself carrier ②. The one way to
* self-declare a positive is a deployment `tool_result` hook that rewrites a rejection's error flag to false,
* which is the deployment editing its own transcript.
*
* Pre-116 checkpoints whose KEYWORD queries activated up to 25 tools re-derive at
* the new default cap (5) — the extra tools revert to placeholders, which is self-healing (the model
* re-activates on demand), never a widening. Exact-name and `select:`-prefix activations replay unchanged.
*
* The retired `{select:[…]}` ARRAY no longer replays: it resolves to nothing here, so those names revert
* to placeholders on the same self-healing path. This is the READ half of a deliberate split — the write
* lane (`createToolSearchTool.execute`) REFUSES the dead key loudly, because there a message can still
* change what the caller does; here it cannot, and erroring would only turn an old transcript into a
* resume that cannot start. Recompute is total: no shape of persisted arguments makes it throw.
*/
export declare function extractDiscoveredToolNames(messages: ReadonlyArray<AgentMessage>, registry: ReadonlyMap<string, DeferredToolInfo>): string[];
/**
* Build the resident `tool_search` tool. On a search that NEWLY activates deferred tools it (1) adds them
* to the monotonic `active` Set, (2) calls `rematerialize` (prepare-task swaps placeholders→full schema
* via `harness.setTools` and refreshes the fingerprint), and (3) returns the "now available"
* announcement as its OWN result content — the delta lands at the tail of the log, never in the cache prefix.
*/
export declare function createToolSearchTool(opts: {
registry: ReadonlyMap<string, DeferredToolInfo>;
active: Set<string>;
rematerialize: (active: ReadonlySet<string>) => Promise<void>;
/** Listing ride-along on the deferred-tool channel: when an activation newly
* materializes a listing-bearing tool (the deferred delegation tool whose description points at an
* agent-types listing that the deferred leg's boundary producer never ships), the caller returns
* the listing text here and it rides THIS search's own result content — tail of the log, cache
* prefix untouched, same channel the activation announcement already uses. */
listingRide?: (newlyActivated: readonly string[]) => string | undefined;
/** The names callable DIRECTLY right now (mounted non-deferred + activated),
* computed at call time (setTools swaps mutate the surface). Lets the miss note tell the model
* "Workflow is already available — call it" instead of a generic not-found that reads as absence
* (observed: the model treated the miss as unavailability and only recovered by guessing). */
mountedNames?: () => ReadonlySet<string>;
/** Whether the placeholders on this task carry the direct-call lane
* (`TaskSpec.deferSelfResolve` not disabled). Selects which of the two teaching postures below is
* true for this task; defaults to the direct-lane posture, matching the spec field's own default. */
directCallEnabled?: boolean;
/** Run this tool's activation critical section on the CALLER's serializer instead of a
* private one. The `active` set and `rematerialize` are shared with the direct-call lane,
* which activates from a placeholder's own execute; both lanes must queue on ONE section or a
* concurrent batch lets the direct lane stage a name while this section is still pending (and
* vice versa, so a rollback here cannot be observed as "already active" there). Absent ⇒ a private
* chain, which still gives the standalone factory its own same-instance guarantee. */
/**
* Is this deferred name still on the LIVE roster? The registry is a prepare-time snapshot, but the
* roster is not: an MCP refresh splices a server's name domain out, and a name that was deferred at
* prepare can be gone. Without this test an exact `select:` still resolves through the stale
* registry, the activation section adds the name to `active`, the rematerialize "succeeds" while
* mounting nothing, and the result announces a tool that does not exist — an uncallable name the
* model has been told is now available. Absent ⇒ every registry name is assumed live (the standalone
* factory has no roster to consult), which is the prior behavior.
*/
isMounted?: (name: string) => boolean;
serializeActivation?: <T>(section: () => Promise<T>) => Promise<T>;
/** Called at the TAIL of this lane's activation section, after the rollback window has closed — the one point at which the shared active set is committed
* for this call. The disclosure census rides it, because a publisher reading that set from OUTSIDE the section (an MCP refresh's rematerialize, which runs
* unserialized and after an await) can observe a name a racing activation staged and then rolled back, and announce an activation that never happened.
* Absent ⇒ nothing is published. */
onActivationCommitted?: () => void;
/** Resolve a deferred name's REAL declared
* parameter schema, read from the live roster at call time. Presence switches this face to the
* static posture: activation never swaps the placeholder in the tools block, so the result text
* BELOW is the schema's only in-context carrier — each matched tool's schema is inlined into the
* result (newly-activated AND already-active alike, so a post-compaction re-select recovers it),
* and every "full schemas are loaded" claim about the tools block is dropped as false. Absent ⇒
* the swap posture (activation rewrites the tools block; prior wording, unchanged). */
staticSchemaFor?: (name: string) => TSchema | undefined;
}): AgentTool;