UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

381 lines (380 loc) • 27.6 kB
import { type ReceiptFault } from "./mods/kernel/receive.js"; import type { ToolContentOrigin, ToolEffect } from "./tool-spec.js"; import type { ToolFamily, ToolPathTarget, ToolRenderHints, ToolRuleFace } from "./tool-face.js"; /** Definition of an MCP server to connect to for the duration of one task, then discard. */ export interface McpServerSpec { /** Stable name; tool names are namespaced as `mcp__<server>__<tool>` (the protocol prefix, then this name as the peer segment) to avoid collisions. */ name: string; /** * Where this declaration CAME FROM, in the declaring side's own words (a shell's `plugin` / `user` / * `project` / `local` config tiers, or anything else the deployment finds useful). Core reads * nothing off it: it is echoed verbatim (single-line, length-bounded) onto the wiring manifest's * per-server read face (`WiringManifest.mcp[].source`) so a consumer can group servers by their * declaring tier without keeping a side table keyed on names. Optional; absent stays absent. */ source?: string; transport: { kind: "stdio"; command: string; args?: string[]; env?: Record<string, string>; } | { kind: "http"; url: string; /** Static headers sent on every request (e.g. an auth bearer for the MCP server itself). */ headers?: Record<string, string>; /** * Per-task **end-user principal** header (design/62). When set, the Runner injects the running task's * authenticated {@link TaskSpec.principal} as this header (e.g. `"X-Agent-Principal"`) on every call to * this server's tools, so the MCP server can enforce per-user RBAC. Absent {@link TaskSpec.principal} * ⇒ the header is **not** sent (the MCP server must default to deny/public, never admin). A static `headers` * entry under the same name in any case refuses this server at its dial (`config.protocol_header_collision`, * header names being case-insensitive). The principal is Runner-held and the model/worker can neither read * nor set it (it is not a tool argument) — see {@link TaskSpec.principal}. */ principalHeader?: string; }; /** Optional allowlist of tool names to expose (others are dropped). ORTHOGONAL to * {@link contentOrigin} and usefully paired with it (design/378 §2): the class declaration follows * the server's roster, so a high-assurance deployment that wants a CLOSED tool set writes both — * a tool a later refresh adds then lands outside this list and is simply not mounted. Existing * semantics, no new mechanism; the refresh receipt still names every added tool either way. */ allowTools?: string[]; /** * design/99 §E23 — opt in to INBOUND elicitation for THIS server: when `true` AND a {@link RunnerDeps.onElicit} * seam is wired, core advertises the `elicitation` capability to this server and registers a handler, so the * server may send an `elicitation/create` request to ask the END USER for input mid-tool-call. **Default OFF * (fail-closed)**: never advertise unless explicitly wired, so a untrusted/over-eager server cannot pull a human * into the loop. v1 is LIVE-ONLY — `onElicit` answers synchronously on the open connection (no durable * suspend; a durable/cross-replica elicitation needs the deferred E22 longer-lived connection). */ elicitation?: boolean; /** * Caller-side per-tool safety-axis overrides, keyed by the server's REMOTE (un-namespaced) tool name. The * CALLER is the trust root: unlike a server's self-declared annotations (which may only TIGHTEN), a caller * override is AUTHORITATIVE and may LOWER an effect (vouch a tool is `read`/`idempotent`) as well as raise it * (`egress` / irreversible). This is the ONLY trusted way to drop an MCP tool below the fail-closed `write` * default (design F). Folds over the server hints in prepare-task (caller > server hint > fail-closed write). * * A DIFFERENT AXIS from {@link contentOrigin}: this one is about what a call DOES (repeat-safety, * blast radius, reversibility — it feeds the approval gate); the content class is about what a call * BRINGS BACK (memory-write governance, no gate/policy/roster effect). Neither implies the other — * a read-only tool can return third-party text, and a deployment's own writer brings back nothing * external — so vouching on one axis never quietly vouches on the other. * * THE SHAPE IS JUDGED, NOT TRUSTED: {@link readToolAxes} is the one reader of this declaration (an A2A peer's `toolAxes` is read by it too). The * preparation door runs it on every entry, and a declaration it cannot read refuses the whole prepare * (`config.invalid_argument`) — one bad tool refuses the entry, a bad key is never dropped and a bad word * never defaults, because a caller's safety axis that silently reads as absent is a trust root that is not * in force. The materialization entrance (`materializeMcpTools`, which the no-run probe dials through) asks * it again per server: a declaration it cannot read fails THAT server's dial. A host that assembles this * field from JSON (a console, a deployment intake) asks the same reader at its own door, so both sides * answer one question one way. */ toolAxes?: Record<string, { effect?: ToolEffect; egress?: boolean; irreversibility?: "always" | "never"; }>; /** * design/388 §1.1 / §2.7 — the OPTIONAL per-tool FACE overlay for this server's tools (keyed by the server's * RAW tool name, like {@link allowTools} / {@link toolAxes}): a deployment lets an MCP tool enter a specialised * approval card, be confirmed by path, carry a display name / summary, or narrow its rule vocabulary. TRUSTED * (the deployment writes it), but validated against the MATERIALIZED schema at every listing (first mount and * every refresh): a `pathTarget.param` that is not a top-level property, an `approvalCard` whose keys the schema * lacks, a `ruleFace` naming a non-scalar key ⇒ the tool mounts WITHOUT the overlay and the leg announces * `config.tool_face_invalid` once; a key naming a tool the listing does not carry announces * `config.tool_face_undeclared` once. Applied whole or not at all. */ toolFaces?: Record<string, { family?: ToolFamily; pathTarget?: ToolPathTarget; renderHints?: ToolRenderHints; ruleFace?: ToolRuleFace; }>; /** * design/378 — declare {@link ToolSpec.contentOrigin} on behalf of THIS SERVER'S ENTIRE TOOL SET * (tools a mid-task refresh adds included), with the same authority and the same responsibility a * directly-mounted host tool's own declaration carries. * * WHY THE SEAT EXISTS. Without it the class keys on the MOUNT SHAPE rather than on lineage: a tool * the deployment wrote and runs itself is structurally `"external"` the moment it arrives over the * MCP protocol namespace, so every call marks the session's memory externally exposed. The * per-name channels cannot express the fact either — {@link import("./memory.js").MemorySpecInput.trustedTools} * is a per-REQUEST allowlist keyed on the MINTED name (the host would have to predict the charset * normalization) whose own definition is "the exception channel for UNDECLARED tools", and it says * nothing about tools the server adds later. * * THIS IS A TRUST DECLARATION, not a routing hint. Use it only for servers inside the deployment's * trust boundary — a process, socket or service the deployment itself runs. Declaring a THIRD-PARTY * server means treating its output as content the deployment wrote: the class names are * BOUNDARY-relative, never topological, so neither the transport kind nor the address is evidence * of lineage (a stdio child can be an untrusted package; a loopback URL can be your own service) and * core deliberately does not gate on either. The declaration covers THE PEER THIS ENTRY CONNECTS TO * — authenticating that peer (socket permissions, credentials) is the host's mounting duty. * * SEMANTICS. `"local"` ⇒ invocations no longer mark this session's memory; `"execution"` ⇒ the same, * except that {@link import("./memory.js").MemorySpecInput.execIsExternalContent} can still upgrade * the class for a strict deployment (which is why this seat takes the three-value vocabulary and not * a single "mine" flag — an execution-shaped tool mounted over MCP must stay inside that knob's * reach); `"external"` is an explicit PIN, and pins are not no-ops — a declaration beats the * `trustedTools` allowlist, so writing it forecloses the per-name exemption for this server's tools. * ABSENT ⇒ the pre-378 behavior byte for byte: the protocol namespace classifies the tools * `"external"` (fail-closed). A value outside the vocabulary is refused at the preparation door * (`config.mcp_content_class`), never folded to a class. * * COVERAGE, stated honestly: the class rides this server's own mounted tools. The cross-server * resource faces (ListMcpResourcesTool / ReadMcpResourceTool / ReadMcpResourceDirTool) aggregate * over every connected server in one call, so they stay `"external"` and still mark — over-marking, * the safe direction. A delegated child's pool is a separate static declaration surface * ({@link ToolSpec.agentToolPool} entries carry their own `contentOrigin`): a deployment handing * this server's tools to children mirrors the value there, and not mirroring it over-marks. * * TRUST SOURCE — a DEPLOYMENT-plane key. It redefines where the trust boundary runs, which puts it * on the same authority plane as {@link RunnerDeps} wiring, not on the request plane. Core sees one * `TaskSpec` and cannot tell a deployment-baseline entry from one a request supplied, so any * assembly layer that accepts REQUEST-side MCP entries must reject or strip this key from them: * "allowed to mount a server" is not "allowed to redefine the deployment's trust boundary", and a * caller-supplied `"local"` would otherwise be self-authorization around the session mark. A * single-tenant superuser surface (a host reading its own `--mcp-config` file) IS the deployment * plane and needs no such gate. */ contentOrigin?: ToolContentOrigin; /** * The host's identity of THIS MOUNT of the server — a non-empty string (≤ 128 characters) the host * minted from the declaration; the engine computes nothing from it and reads nothing off it. When present * it rides the revocation probe ({@link McpRevocationLedger.isRevoked}) as the second argument, so the * host can tell one mount generation of a server from a re-declared one under the same name. Absent ⇒ * the probe is asked with the name alone. Any other value refuses this server at its dial * (`config.mcp_fingerprint_invalid`). */ fingerprint?: string; /** * THIS server's startup bound, in whole milliseconds: the handshake (connect + initialize) and the initial tool listing each carry it * (the listing as one walk, however many pages). One knob, three seats, read by one function in this order — this member, else the * process-level `MCP_TIMEOUT`, else the MCP SDK's own 60 000 ms — so a server that hangs at startup is cut on its own clock while its * siblings keep the deployment's. It bounds only that startup phase: a call's own timeout (`MCP_TOOL_TIMEOUT`) is untouched. * * The range is the one every MCP millisecond bound keeps, 1 000 to 2 147 483 647 (a larger delay is silently rewritten to 1 ms by the * timer, a smaller one is a unit slip). Unlike the environment seat — a string a process may clamp and announce — a declaration is typed * data, so anything else (a string, a fraction, zero, a negative, NaN, `null`, a number outside the range) refuses THIS server at its * dial (`config.mcp_startup_timeout_invalid`; siblings mount) rather than being clamped or read as the default. * * The bound a dial runs under (this member, else the environment seat) is part of a pooled connection's identity: mounts under one * bound lease ONE connection, mounts under different bounds dial their own — so a mount is never held by another mount's clock. * The no-run probe has no clock of its own: each startup phase carries this same bound (a caller's `timeoutMs` is an overall ceiling beside it). */ startupTimeoutMs?: number; } type ToolAxes = NonNullable<McpServerSpec["toolAxes"]>; /** * The surfaces that declare `toolAxes`, which {@link readToolAxes} names its reading by: an MCP server's keys are the server's * remote TOOL names, an A2A peer's keys are the peer's remote SKILL ids. */ export type ToolAxesSurface = "mcp" | "a2a"; /** * {@link readToolAxes}'s answer. `ok: true` carries the declaration as a PLAIN COPY the reader built from * what it judged (never the caller's own object); `toolAxes` is absent when the entry declares nothing — no * key at all, or only empty per-tool objects (which the engine reads the same as no declaration). `ok: false` * carries one sentence naming the tool (or skill) and the member that did not fit, never a value. */ export type ToolAxesRead = { ok: true; toolAxes?: ToolAxes; } | { ok: false; problem: string; }; /** * Where a declaration stops being one: the path from the declaration's root — a list index as a number, a member name as a string — and * the clause that says what is there ("it contains itself; …"): a kind and a remedy, never a value the declarer wrote. */ export interface DeclarationFault { readonly path: readonly (string | number)[]; readonly problem: string; } /** {@link receiveDeclaration}'s answer: the engine's own frozen copy, or where and why the declaration could not be received. */ export type DeclarationRead = { readonly ok: true; readonly value: unknown; } | { readonly ok: false; readonly fault: DeclarationFault; }; /** * A receipt's refusal of a declaration (or of the list that carries declarations), said as a declaration refusal says it. The receipt's * path does not say which kind of container each name came from, so a segment of digits alone is read as a list index. */ export declare function declarationFaultOf(fault: ReceiptFault): DeclarationFault; /** What a declaration's own type fixes at one path, beyond being plain data: a string there, or a record of names to strings. */ export interface DeclarationPathRule { readonly path: readonly string[]; readonly holds: "a string" | "names to strings"; /** The member must be there (a declaration with no value at the path is refused, as one with a value of another kind is). */ readonly required?: true; } /** * Per surface, every path its declaration's type fixes beyond plain data — the declarer's `name` (a string, always there) and every record * the type writes as `Record<string, string>`: an MCP server's `transport.env` (a stdio server's environment) and `transport.headers` (an * HTTP server's headers); an A2A peer's `headers`. A path that is not its surface's (an A2A peer's `transport`) is not judged by another * surface's row: it is a member the surface does not read. */ export declare const DECLARATION_PATH_RULES: Readonly<Record<ToolAxesSurface, readonly DeclarationPathRule[]>>; /** * The one reading of a declaration a caller hands the engine — an MCP server's or an A2A peer's entry (the snapshot), a `toolAxes` * record ({@link readToolAxes}), a lock declaration: the engine's one receipt of a caller's object in its declaration mode, so the * value is plain data held to the engine's one depth ({@link MODS_VALUE_DEPTH} levels below it) and every own member is read once, then, when the copy is a record, the declaration's own * vocabulary over the copy (a list holds names; no member is named `__proto__`; each path `rules` names holds what its rule says — an * entry's rules are its surface's {@link DECLARATION_PATH_RULES}). The copy is the answer — the caller's object is never handed on and * never read again. A copy that is not a record is answered as it is, for the reader that expected a record to word. */ export declare function receiveDeclaration(declared: unknown, rules?: readonly DeclarationPathRule[]): DeclarationRead; /** * The one reader of a raw `toolAxes` declaration — {@link McpServerSpec.toolAxes} and {@link A2aServerSpec.toolAxes} are the same * declaration, and the engine's preparation door, its materialization of an MCP server and of an A2A peer, and every host that * assembles the field from JSON ask this same function. `at` names the surface the declaration sits on, because that decides what its * keys are called in the sentence: `"mcp"` — the server's remote tools; `"a2a"` — the peer's remote skills. Absent ⇒ `{ ok: true }` * (nothing declared). Anything else must be an object of per-tool objects whose members are exactly `effect` / * `egress` / `irreversibility` with legal values; a declaration of any other shape, an empty tool name, an * unknown member, a word outside the vocabulary or a non-boolean `egress` on ANY one tool refuses the whole * declaration (`ok: false`) — the good tools are not kept and nothing is defaulted, since a caller axis is * two-way (it can vouch a tool down as well as mark it up) and a silently lost half is a trust root that is * not in force. The reader judges SHAPE only; whether the axes contradict each other (`egress` over a * non-write effect) is the ingestion's own arithmetic. The declaration is read once, as plain data, by {@link receiveDeclaration} — a * Proxy, a map, a class instance, a record built on a prototype or in another realm, a getter that throws, a structure that contains * itself, a tool named `__proto__` are refusals that say where they sit (never the thrown text), not a throw — and judged on that copy. * `at` is the caller's own word, not the declarer's: one outside {@link ToolAxesSurface} throws `config.invalid_argument` naming the * surfaces, whatever is declared. */ export declare function readToolAxes(declared: unknown, at: ToolAxesSurface): ToolAxesRead; /** {@link readToolAxes} for an MCP server's declaration — the entry a host that mounts MCP servers asks. */ export declare function readMcpToolAxes(declared: unknown): ToolAxesRead; /** * The word a sentence about one declaration of the surface `at` names the declarer by — `MCP server`, `A2A peer`. The same row the * `toolAxes` refusal below reads, so every sentence the engine writes about a declaration it cannot read calls the declarer one thing. */ export declare function declarerWord(at: ToolAxesSurface): string; /** * The refusal an entry's unreadable `toolAxes` becomes (`config.invalid_argument`), ONE sentence for every surface that declares them: * the subject is the surface's declarer word and `name` (already quoted and bounded by the caller: `MCP server "db"`, `A2A peer "agent"`), * `problem` is {@link readToolAxes}'s own sentence. */ export declare function toolAxesRefusal(at: ToolAxesSurface, name: string, problem: string): Error & { code: "config.invalid_argument"; }; /** The identity of one mount of a declared server, as the host minted it ({@link McpServerSpec.fingerprint}). */ export interface McpMountIdentity { readonly fingerprint: string; } /** The host's MCP revocation ledger ({@link RunnerDeps.mcpRevocations}) — declared once, read by every MCP dispatch outlet. */ export interface McpRevocationLedger { /** Synchronous. `mount` is passed exactly when the server's declaration carries a `fingerprint`. */ isRevoked(serverName: string, mount?: McpMountIdentity): boolean; } /** * Definition of one A2A (agent-to-agent protocol) PEER to talk to for the duration of one task. * * The peer is a remote AGENT, not a data source: it acts on its own side. That single fact sets the * defaults — every skill this peer advertises mounts with `egress: true` and `effect: "write"` (ruled * 2026-08-03), so it registers the approval gate and is never auto-allowed on a no-policy deployment, * and its replies reach the model inside an untrusted-data fence. {@link toolAxes} is the only trusted * way down from those defaults (design F: the caller is the trust root). * * Skills mount as `a2a__<peer>__<skill>` (the protocol table's second namespace). */ export interface A2aServerSpec { /** Stable local name for this peer; it becomes the namespace segment of every mounted tool name. */ name: string; /** The peer's service URL. Doubles as the origin the well-known agent-card paths resolve against, and * as the JSON-RPC endpoint when the card does not name a different one. */ url: string; /** Explicit agent-card location, when the peer does not publish at a well-known path. Set ⇒ it is used * ALONE (no well-known probing behind the operator's back). */ cardUrl?: string; /** Static headers sent on every request to this peer (e.g. an auth bearer). They travel only to the * declared origins (`url`, `cardUrl`): a card naming an endpoint on another origin refuses the peer * (`config.protocol_endpoint_unbound`), and a redirect is not followed. */ headers?: Record<string, string>; /** * Per-task **end-user principal** header (design/62), same contract as the MCP side: when set, the * Runner injects the running task's authenticated {@link TaskSpec.principal} under this header name on * every call to this peer, so the peer can enforce per-user authorization. Absent principal ⇒ the * header is not sent (the peer must then default to deny/public, never admin). A static entry in * {@link headers} under the same name in any case refuses the peer (`config.protocol_header_collision`). * The principal is Runner-held: the model can neither read nor set it. */ principalHeader?: string; /** Optional allowlist of skill ids to expose (others are not mounted). */ allowSkills?: string[]; /** * Caller-side per-skill safety-axis overrides, keyed by the peer's REMOTE skill id. The CALLER is the * trust root, so this may LOWER the fail-closed default (`egress:true` + `effect:"write"`) as well as * raise it — e.g. `{ egress: false, effect: "read" }` vouches that a skill only reads. Note that * lowering `effect` WITHOUT clearing `egress` is a contradiction prepare-task refuses by design (an * external write must be repeat-unsafe). * * THE SHAPE IS JUDGED, NOT TRUSTED, by the same reader as {@link McpServerSpec.toolAxes} ({@link readToolAxes} with `at: "a2a"`) and * with the same disposition: a task's preparation door runs it on every peer of `TaskSpec.a2a`, and a declaration it cannot read * refuses the whole prepare (`config.invalid_argument`, subject `A2A peer "<name>"`) before any peer is dialed — one bad skill refuses * the task, a bad key is never dropped and a bad word never defaults. The materialization entrance (`materializeA2aTools`, the library * entrance) asks it once per peer, when it reads that peer's declaration: there a declaration it cannot read fails THAT peer's dial * (never dialed, a warning whose cause is the same refusal), its siblings unaffected; a refresh does not ask again, it dials the copy * that was judged (a peer refused at the entrance is refused again by every refresh, from the stored refusal). */ toolAxes?: Record<string, { effect?: ToolEffect; egress?: boolean; irreversibility?: "always" | "never"; }>; } /** * design/99 §E23 — an INBOUND MCP elicitation request (a connected server asking the END USER for input * mid-tool-call). Handed to the deployment's {@link RunnerDeps.onElicit} seam. **UNTRUSTED**: `message` / * `requestedSchema` / `url` are SERVER-controlled — core never interprets `requestedSchema`; a consumer that * shows `message` to a human MUST fence it (`delimitUntrusted`). */ export interface McpElicitRequest { /** The {@link McpServerSpec.name} of the server that issued the request. */ server: string; /** The server's human-facing prompt (UNTRUSTED — fence before display). */ message: string; /** Form mode: the server's requested input schema (a flat object of scalars). OPAQUE to core. */ requestedSchema?: unknown; /** * URL mode: a server-controlled URL the user is asked to visit (UNTRUSTED — a phishing/social-engineering * surface; a consumer MUST scrutinize/fence it before showing it). **Inactive**: core advertises FORM mode * only, so the SDK rejects url-mode requests before this seam — the field is never populated today. * * Where url mode is heading (MCP revision `2026-07-28`), because the earlier note here ("reserved for a * future url-mode opt-in") pointed at machinery that no longer exists: the revision REMOVES the * `2025-11-25` url-mode correlator (`elicitationId`) and the `notifications/elicitation/complete` * notification that went with it. A url interaction is instead carried by the revision's multi-round-trip * request flow — the server answers with an input-required result, the client RETRIES the original * request, and the server correlates across retries through its OWN opaque request state rather than a * client-visible id. The one thing both shapes agree on is the server-supplied URL itself, which is what * this field holds. So the field stays; anything that would have hung off the removed correlator must not * be added here without re-reading the protocol revision core actually speaks. */ url?: string; /** The running task's authenticated end-user (design/62), if any — for routing the prompt to the right human. */ principal?: string; } /** design/99 §E23 — the deployment's answer to an {@link McpElicitRequest} (returned to the MCP server). */ export interface McpElicitResponse { action: "accept" | "decline" | "cancel"; /** Submitted values (accept only); core passes them through opaquely, never validated against the schema. */ content?: Record<string, string | number | boolean | string[]>; } /** * design/99 §E23 — the LIVE-ONLY inbound-elicitation seam. A deployment provides it (e.g. the sema shell * prompts the user at the terminal) and returns the answer SYNCHRONOUSLY; core replies to the server on the * still-open connection. Absent ⇒ no server is advertised the elicitation capability (fail-closed). Not a * durable suspend — a crash/replica-change loses an in-flight elicitation (acceptable for an interactive shell). * * 🔴 THROTTLING IS THE DEPLOYMENT'S JOB: once a server is opted in, it may issue elicitations at any frequency * (one per tool call, or many) — core imposes NO rate/count limit (an elicitation is not a tool call and does * not ride the policy gate). `onElicit` is the choke point: throttle, aggregate, or `decline`/`cancel` an * over-eager or untrusted server here (the request's `AbortSignal` is forwarded so a deadline can cancel it). */ export type OnElicit = (req: McpElicitRequest, signal?: AbortSignal) => Promise<McpElicitResponse>; export {};