UNPKG

@sema-agent/core

Version:

Stateless, task-oriented AI agent core

323 lines (322 loc) • 21.9 kB
import { compileToolSelector, valveLeaves, deniedProtocolTools, delegationListWhere, isCanonicalToolName, isToolSelectorLayer, listReaches, notAListSentence, toolListsDigest, notAStringSentence, readAllowedToolList, readDeniedEntry, readDeniedToolList } from "../../agents/tool-filter.js"; import { readToolNameList, valueTypeLabel } from "../tool-name-list.js"; import { compileAllowList, inertEntryPhrase, renderListEntry, rootFacingToolNames } from "../allow-entry.js"; import { inlineUntrusted } from "../untrusted-text.js"; import { ENGINE_TOOLS } from "../tool-catalog-entries.js"; import { callerToolNameProblem } from "../caller-tool-name.js"; import { mintNamespacePrefix } from "../protocol-naming.js"; import { PROTOCOL_TABLE, protocolOf } from "../protocol-table.js"; import { mountedToolNames, splitNamespacedName } from "../tool-name-aliases.js"; import { projectionToolNames } from "../tool-policy.js"; import { declaredDelegationNameOf } from "../delegation-name.js"; export function renamedDelegationMountSentence(...rosters) { for (const tools of rosters) { if (!Array.isArray(tools)) continue; for (const t of tools) { const declared = declaredDelegationNameOf(t); if (declared === undefined) continue; try { const mounted = t.name; if (declared === mounted) continue; return `The delegation tool declared as "${inlineUntrusted(declared, 80)}" is mounted under the name "${inlineUntrusted(String(mounted), 80)}": one tool would answer to two names (the agent definitions' tool lists, the copy a child is handed and the tool's prompt text speak the declared one; the roster and the model's call speak the mounted one). Mount it under its declared name, or declare the name you mount it under (\`createSubagentTool({ name })\`).`; } catch { } } } return undefined; } export function refuseRenamedDelegationMounts(...rosters) { const sentence = renamedDelegationMountSentence(...rosters); if (sentence !== undefined) throw refused(sentence, "config.tool_face_control", "renamed_mount"); } function poolOf(tools) { return { tools, names: mountedToolNames([...tools.map((t) => ({ name: t.name })), ...tools.flatMap((t) => t.agentToolPool ?? [])]), complete: tools.every((t) => t.agentToolPool !== undefined) }; } export function delegationPoolReading(callerTools, leaves) { return poolOf(callerTools.filter((t) => t.agentToolFaces !== undefined && leaves(t))); } function peersOf(known, declaredPeers) { const peers = new Set(PROTOCOL_TABLE.flatMap((ns) => (declaredPeers[ns.id] ?? []).map((p) => mintNamespacePrefix(ns, p.name)))); for (const name of known) { const ns = protocolOf(name); const split = ns !== undefined ? splitNamespacedName(name) : undefined; if (split !== undefined && split.peer !== "") peers.add(mintNamespacePrefix(split.ns, split.peer)); } return peers; } function rootFacingUniverse(mounted, pool, declaredPeers) { const known = rootFacingToolNames([...mounted, ...pool.names]); return { known, peers: peersOf(known, declaredPeers), complete: true, carrierOf: (name) => (mounted.has(name) ? "run" : pool.names.has(name) ? "pool" : "catalog") }; } function childFacingUniverse(pool, declaredPeers) { const known = new Set([...mountedToolNames(ENGINE_TOOLS), ...pool.names]); return { known, peers: peersOf(known, declaredPeers), complete: pool.complete, carrierOf: (name) => (pool.names.has(name) ? "pool" : "catalog") }; } const DENY_LIST_NAMES_ONLY = "To block by path or command, write a deny rule in the caller's permission policy: a tool-name list only takes tool names (it narrows which tools are mounted)."; function universeCarries(reach, entry, universe) { for (const name of universe.known) if (listReaches(reach, { name })) return true; const split = splitNamespacedName(entry); if (split === undefined || split.peer === "") return false; return universe.peers.has(mintNamespacePrefix(split.ns, split.peer)); } function refused(message, code, reason) { const e = new Error(message); e.code = code; e.detail = { reason }; return e; } const LIST_WORD = { allowed: "allowed tools", denied: "denied tools" }; const NEAR_MISS_EFFECT = { allowed: "it admits nothing, so the tool it was written for is not granted", denied: "it hides nothing, so the tool it was written for stays available to the agent", }; function nearMissRefusal(where, side, flaw, code) { return refused(`${where} declares tool ${inertEntryPhrase(flaw)} in its ${LIST_WORD[side]}, a spelling that reaches no tool a run mounts — ${NEAR_MISS_EFFECT[side]}. Write the name as the tool is mounted.`, code, "near_miss"); } function grammarRefusal(where, side, flaw, code) { return refused(`${where} declares tool ${inertEntryPhrase(flaw)} in its ${LIST_WORD[side]}, a spelling no tool name can have — fix the entry. ${DENY_LIST_NAMES_ONLY}`, code, "unknown_name"); } function readableToolNameList(where, side, value, code) { if (value === undefined || value === null) return []; const list = readToolNameList(value); if (list.kind !== "entries") throw refused(notAListSentence(where, side, list), code, "not_a_list"); return list.entries.map((entry, index) => { const read = readDeniedEntry(entry); if (read.kind === "not_a_string") throw refused(notAStringSentence(where, side, index, read.type), code, "not_a_string"); if (read.kind === "unreadable") throw grammarRefusal(where, side, { entry: read.entry, reason: "grammar" }, code); if (side === "denied" && read.name === "*") { throw refused(`${where} declares ${renderListEntry(entry)} in its ${LIST_WORD[side]}: a denied list has no wildcard — the entry would hide nothing. To give the agent no tools, write an empty allowed list.`, code, "unsupported_pattern"); } return { entry: entry, name: read.name, content: read.content }; }); } const KEPT_ENTRY = { allowed: "it grants nothing today and stays in force for a tool of that name mounted later", denied: "it denies nothing today and stays in force for a tool of that name mounted later", }; const KEPT_ENTRY_UNLISTED_POOL = "so it is kept here: the pool this delegation tool hands its child cannot be listed, and the spawned child judges the entry against its own roster (another spelling of a tool it carries refuses the child before it starts; an exact name takes effect; a name nothing answers to there stays in force for a tool of that name mounted later)"; const PEER_WORD = { mcp: "MCP server", a2a: "A2A peer" }; function judgeToolNameList(where, side, value, universe, advise, code) { for (const { entry, name: toolName, content: written } of readableToolNameList(where, side, value, code)) { if (toolName === "*" && !written) continue; const name = side === "denied" || !written ? toolName : entry; const content = side === "denied" && written; const reach = side === "denied" ? readDeniedToolList([entry]) : (readAllowedToolList([entry]) ?? readDeniedToolList([])); const split = splitNamespacedName(name); if (split !== undefined && split.peer === "*" && split.tool === undefined) { if (side === "denied") continue; throw refused(`${where} declares ${renderListEntry(entry)} in its ${LIST_WORD[side]}: an allowed list has no protocol-wide form — name a peer (\`${split.ns.prefix}<peer>\`) or its tools.`, code, "unsupported_pattern"); } const { inert } = compileAllowList([toolName], "deny", universe.known); const flaw = inert[0] === undefined ? undefined : { ...inert[0], entry, ...(inert[0].reason === "near_miss" && inert[0].currentName !== undefined ? { carriedBy: universe.carrierOf(inert[0].currentName) } : {}) }; if (universe.complete && flaw?.currentName !== undefined) throw nearMissRefusal(where, side, flaw, code); if (toolName !== name) { advise(new Error(`${where} declares ${renderListEntry(entry)} in its allowed tools: an allowed list names tools, not content, so this entry grants nothing — name the tool "${inlineUntrusted(toolName, 80)}" to grant it, or move the content rule to a denied list or a permission rule.`)); continue; } if (flaw === undefined && universeCarries(reach, name, universe)) { if (content) advise(new Error(`${where} declares ${renderListEntry(entry)} in its denied tools: this list reads tool names, not content, so the whole tool "${inlineUntrusted(name, 80)}" is denied. ${DENY_LIST_NAMES_ONLY}`)); continue; } const phrase = flaw !== undefined ? inertEntryPhrase(flaw) : renderListEntry(entry); const undeclared = split !== undefined && split.peer !== "" && !split.peer.includes("*") && !universe.peers.has(mintNamespacePrefix(split.ns, split.peer)); const why = undeclared ? `no ${PEER_WORD[split.ns.id]} named "${inlineUntrusted(split.peer, 80)}" is declared for this task` : universe.complete ? "no tool of this task answers to that name" : "no tool this run can list answers to that name"; advise(new Error(`${where} declares tool ${phrase} in its ${LIST_WORD[side]}: ${why}, ${universe.complete ? `so ${KEPT_ENTRY[side]}` : KEPT_ENTRY_UNLISTED_POOL}. If the name is misspelt, fix the entry.`)); } } export function deniedToolNamesOf(where, value) { return readableToolNameList(where, "denied", value, "config.agent.unknown_tool").map((read) => read.name); } function inheritedListsWhere(ceilings, i) { const up = ceilings - 1 - i; return up === 0 ? "the lists of the run this leg was spawned under" : `the lists of the run ${up} spawn${up === 1 ? "" : "s"} above the run this leg was spawned under`; } function readSelectorLayer(layer, layerWhere, listWhere, recorded) { if (!isToolSelectorLayer(layer)) { throw refused(`${layerWhere} are a value that is ${valueTypeLabel(layer)}, not a pair of tool-name lists (an allowed and a denied list) — the leg does not start. Fix the layer.`, "config.tool_face_control", "not_a_list"); } for (const side of ["allowed", "denied"]) { const where = listWhere(side); const entries = readableToolNameList(where, side, side === "allowed" ? layer.allow : layer.deny, "config.tool_face_control"); const written = recorded ? entries.find((read) => !isCanonicalToolName(read.entry)) : undefined; if (written !== undefined) { throw refused(`${where} declares ${renderListEntry(written.entry)} in its ${LIST_WORD[side]}: a row records each tool name in its bare spelling (\`Read\`, never \`Read()\`, \`Read(*)\` or \`Read(…)\`), and this entry was recorded by an engine that read the list otherwise — it is read here neither as that engine read it nor as this one would`, "config.tool_face_control", "unsupported_pattern"); } } } function ownToolListsFlaw(value) { try { if (typeof value !== "object" || value === null || Array.isArray(value)) return `a value that is ${valueTypeLabel(value)}, not a tool name with an agent definition name and the digest of the lists it wrote`; const { toolName, definition, lists } = value; if (typeof toolName !== "string" || toolName.length === 0) return `a tool whose name is ${typeof toolName === "string" ? "empty" : `a value that is ${valueTypeLabel(toolName)}`}, not a tool name`; const problem = callerToolNameProblem(toolName); if (problem !== undefined) return `a tool whose name is not one a caller tool can carry (${problem})`; if (definition !== undefined && typeof definition !== "string") return `an agent definition whose name is a value that is ${valueTypeLabel(definition)}, not a name`; if (definition === "") return "an agent definition whose name is empty, not a name"; if (lists === undefined) return "a record with no digest of the lists it wrote"; if (typeof lists !== "string") return `a digest of the lists it wrote that is a value that is ${valueTypeLabel(lists)}, not a digest`; if (lists.length === 0) return "a digest of the lists it wrote that is empty, not a digest"; return undefined; } catch { return "a value that cannot be read"; } } export function persistedToolNamesRefusal(ceiling, chain, ownLists) { const pending = "refused before the claim (the checkpoint stays pending): finish it on a worker of the engine that parked it, or expire it and run the work again"; const ownFlaw = ownLists === undefined ? undefined : ownToolListsFlaw(ownLists); if (ownFlaw !== undefined) return { message: `this leg's checkpoint row records the delegation tool that wrote its own lists as ${ownFlaw} — ${pending}`, reason: "invalid_record" }; if (ceiling !== undefined) { const layers = readToolNameList(ceiling); if (layers.kind !== "entries") return { message: `the ceiling this leg's checkpoint row recorded is a value that is ${layers.kind === "not_a_list" ? layers.type : "a list that cannot be read"}, not a list of layers — ${pending}`, reason: "not_a_list" }; try { layers.entries.forEach((layer, i) => readSelectorLayer(layer, inheritedListsWhere(layers.entries.length, i), () => inheritedListsWhere(layers.entries.length, i), true)); } catch (e) { return { message: `${e.message} — as this leg's checkpoint row recorded it; ${pending}`, reason: e.detail.reason }; } } if (!Array.isArray(chain)) return undefined; const flaw = (() => { for (const [layer, entry] of chain.entries()) { if (typeof entry !== "object" || entry === null || "opaque" in entry || !Array.isArray(entry.components)) continue; for (const c of entry.components) { if (typeof c !== "object" || c === null) continue; const { member, list } = projectionToolNames(c); const where = `layer ${layer + 1}'s ${c.kind === "tool_deny" || c.kind === "tool_allowlist" || c.kind === "shell_command" ? `\`${c.kind}\`` : "component"} ${member}`; const read = readToolNameList(list); if (read.kind !== "entries") return { what: `${where} as a value that is ${read.kind === "not_a_list" ? read.type : "a list that cannot be read"}, not a list of tool names`, reason: "not_a_list" }; const at = read.entries.findIndex((name) => !isCanonicalToolName(name)); if (at < 0) continue; const name = read.entries[at]; return typeof name === "string" ? { what: `${renderListEntry(name)} in ${where}`, reason: "unsupported_pattern" } : { what: `in ${where}, as entry ${at + 1}, a value that is ${valueTypeLabel(name)}`, reason: "not_a_string" }; } } return undefined; })(); return flaw === undefined ? undefined : { reason: flaw.reason, message: `the checkpoint's frozen inherited constraint chain holds ${flaw.what}: a row records each tool name in its bare spelling (\`Read\`, never \`Read()\`, \`Read(*)\` or \`Read(…)\`), and this entry was recorded by a reading that is not the rule syntax's (an earlier engine's name set, or a policy that reads its lists as written) — it is replayed neither as that reading nor as this engine's; ${pending} (with the policy naming the tool itself)` }; } export function refusePersistedToolNames(ceiling, chain, ownLists) { const refusal = persistedToolNamesRefusal(ceiling, chain, ownLists); if (refusal !== undefined) throw refused(refusal.message, "config.tool_face_control", refusal.reason); } export function boundToolListsJudgment(evidence, own) { return evidence !== undefined && evidence === toolListsDigest(own.allow, own.exclude) ? evidence : undefined; } export function boundOwnToolLists(record, own) { if (record === undefined) return undefined; const flaw = ownToolListsFlaw(record); if (flaw !== undefined) { throw refused(`this leg's internals (RunInternals.ownToolLists) record the delegation tool that wrote its own lists as ${flaw} — the leg does not start. Only the delegation lane writes that record: leave it out.`, "config.tool_face_control", "invalid_record"); } const held = record; return held.lists === toolListsDigest(own.allow, own.exclude) ? held : undefined; } export function judgeRunToolLists(input) { const { spec, tools, onceLedger, sessionId } = input; const advise = (err) => { try { onceLedger.onError(err, { phase: "config", sessionId }); } catch { } }; const ownWhere = input.ownBy !== undefined ? delegationListWhere(input.ownBy.toolName, input.ownBy.definition) : undefined; const ceilings = input.layers.length - 1; input.layers.forEach((layer, i) => { const own = i === ceilings; const inherited = own ? undefined : inheritedListsWhere(ceilings, i); readSelectorLayer(layer, inherited ?? "this run's own tool lists", (side) => inherited ?? ownWhere ?? (side === "allowed" ? "TaskSpec.allowTools" : "TaskSpec.excludeTools"), false); }); const denied = deniedProtocolTools(input.layers, input.legs); if (denied.length > 0) { const names = [...new Set(denied.map((h) => `"${h.name}"`))]; const one = names.length === 1; const deniedBy = (own, whose) => { const hits = denied.filter((h) => h.own === own).map((h) => `"${h.name}"`); return hits.length > 0 ? [`${whose} denies ${one ? "it" : hits.join(", ")}`] : []; }; const by = [...deniedBy(true, ownWhere ?? "this run's own excludeTools"), ...deniedBy(false, "an inherited list (the spawning run's excludeTools, or one it inherits)")]; throw refused(`This kind of leg (${input.legs.join(", ")}) relies on ${names.join(", ")}, but ${by.join(" and ")} — the leg cannot work without ${one ? "it" : "them"}. Remove the entry from the list that names it.`, "config.tool_face_control", "protocol_denied"); } const selector = compileToolSelector(input.layers, input.legs); const pool = delegationPoolReading(spec.tools ?? [], valveLeaves(spec, selector)); const mounted = mountedToolNames([...tools, ...(spec.tools ?? [])]); if (!input.judgedUpstream) { const root = rootFacingUniverse(input.ownBy !== undefined ? new Set([...mounted, input.ownBy.toolName]) : mounted, pool, input.declaredPeers); const own = [["allowed", input.own.allow], ["denied", input.own.exclude]]; const tell = input.toldUpstream === true ? () => undefined : advise; for (const [side, value] of own) judgeToolNameList(ownWhere ?? (side === "allowed" ? "TaskSpec.allowTools" : "TaskSpec.excludeTools"), side, value, root, tell, "config.tool_face_control"); } const children = new Map(pool.tools.map((t) => [t, childFacingUniverse(poolOf([t]), input.declaredPeers)])); const taskTargets = pool.tools.length > 0 ? [...children.values()] : [childFacingUniverse(poolOf([]), input.declaredPeers)]; for (const def of spec.agents ?? []) { for (const universe of taskTargets) { judgeToolNameList(`TaskSpec.agents: agent "${def.name}"`, "allowed", def.allowTools, universe, advise, "config.agent.unknown_tool"); judgeToolNameList(`TaskSpec.agents: agent "${def.name}"`, "denied", def.denyTools, universe, advise, "config.agent.unknown_tool"); } } const replaced = new Set((spec.agents ?? []).map((d) => d.name)); for (const [t, universe] of children) { for (const face of t.agentToolFaces ?? []) { if (face.authored === true && !replaced.has(face.name)) { const faceWhere = delegationListWhere(t.name, face.name); judgeToolNameList(faceWhere, "allowed", face.allowTools, universe, advise, "config.agent.unknown_tool"); judgeToolNameList(faceWhere, "denied", face.denyTools, universe, advise, "config.agent.unknown_tool"); } } judgeToolNameList(delegationListWhere(t.name, undefined), "allowed", t.agentToolLevelAllowTools, universe, advise, "config.agent.unknown_tool"); judgeToolNameList(delegationListWhere(t.name, undefined), "denied", t.agentToolLevelDenyTools, universe, advise, "config.agent.unknown_tool"); } } export function judgeHostToolCeiling(layers, agent, above = []) { const where = (i) => `RunWorkflowOptions.toolCeiling, layer ${i + 1}`; layers.forEach((layer, i) => readSelectorLayer(layer, where(i), () => where(i), false)); const callerTools = agent.tools ?? []; const own = { ...(agent.allowTools != null ? { allow: agent.allowTools } : {}), deny: agent.excludeTools ?? [] }; const selector = compileToolSelector([...above, ...layers, own], ["delegated"]); const pool = delegationPoolReading(callerTools, valveLeaves(agent, selector)); const universe = rootFacingUniverse(mountedToolNames(callerTools), pool, { mcp: undefined, a2a: undefined }); const unannounced = () => undefined; layers.forEach((layer, i) => { judgeToolNameList(where(i), "allowed", layer.allow, universe, unannounced, "config.tool_face_control"); judgeToolNameList(where(i), "denied", layer.deny, universe, unannounced, "config.tool_face_control"); }); }