UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

264 lines (263 loc) • 19.5 kB
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;