@sema-agent/core
Version:
Stateless, task-oriented AI agent core
145 lines (144 loc) • 9.14 kB
JavaScript
import { MCP_NAMESPACE } from "../protocol-table.js";
import { mintNamespacePrefix } from "../protocol-naming.js";
import { TOOL_APPROVAL_CARDS, TOOL_FAMILIES, TOOL_PATH_ABSENCES, TOOL_PATH_ACCESSES, TOOL_PATH_BASES, RENDER_HINT_ACTIVITY_MAX_CHARS, RENDER_HINT_MAX_CHARS, RENDER_HINT_MAX_LIST, TOOL_KEY_MAX_CHARS, } from "../tool-face.js";
import { inputKeysOf } from "../tool-roster.js";
import { deliverEngineNotice } from "../engine-notice.js";
export const APPROVAL_CARD_REQUIRED_KEYS = Object.freeze({
"file-edit": ["file_path"],
"file-write": ["file_path"],
"notebook-edit": ["notebook_path"],
shell: ["command"],
fetch: ["url"],
plan: [],
question: [],
generic: [],
});
export function toolFaceProblem(face, schema) {
const { keys, scalar } = inputKeysOf(schema);
const top = new Set(keys.map((k) => k.name));
if (face.family !== undefined && !TOOL_FAMILIES.includes(face.family))
return `family ${JSON.stringify(face.family)} is outside the closed set`;
const overlong = (label, values) => {
const k = values.find((v) => v.length > TOOL_KEY_MAX_CHARS);
return k === undefined ? undefined : `${label} ${JSON.stringify(k.slice(0, 40) + "…")} is ${k.length} characters; a roster key is at most ${TOOL_KEY_MAX_CHARS}`;
};
const vocabulary = new Set(scalar);
if (face.pathTarget !== undefined) {
if (typeof face.pathTarget.param !== "string" || !top.has(face.pathTarget.param))
return `pathTarget.param ${JSON.stringify(face.pathTarget.param)} is not a top-level property of the tool's schema`;
if (!TOOL_PATH_ACCESSES.includes(face.pathTarget.access))
return `pathTarget.access ${JSON.stringify(face.pathTarget.access)} is outside the closed set`;
const base = face.pathTarget.base;
if (base !== undefined && !TOOL_PATH_BASES.includes(base))
return `pathTarget.base ${JSON.stringify(base)} is outside the closed set`;
if (base === "root" && face.pathTarget.access !== "read")
return `pathTarget.base "root" needs pathTarget.access "read" — two write guards (the session-transcript directory guard and the frozen-spec deny) resolve a write target against the call's working directory only, so a write face declaring a root base would be judged on two different files (#638)`;
const absent = face.pathTarget.absent;
if (absent !== undefined && !TOOL_PATH_ABSENCES.includes(absent))
return `pathTarget.absent ${JSON.stringify(absent)} is outside the closed set`;
if (absent === "base" && face.pathTarget.access !== "read")
return `pathTarget.absent "base" needs pathTarget.access "read" — it says the call is about the whole base directory when the slot is empty, which is a search SCOPE; a write with no path names no file, and the containment fences judge a write on its own target`;
const patternParam = face.pathTarget.patternParam;
if (patternParam !== undefined) {
if (typeof patternParam !== "string" || !top.has(patternParam))
return `pathTarget.patternParam ${JSON.stringify(patternParam)} is not a top-level property of the tool's schema`;
if (absent !== "base")
return `pathTarget.patternParam ${JSON.stringify(patternParam)} is declared but pathTarget.absent is ${JSON.stringify(absent ?? "none")} — the pattern base is only ever read for an absent slot, so this declaration would be inert`;
}
const long = overlong("pathTarget.param", [face.pathTarget.param]) ??
overlong("pathTarget.aliases", face.pathTarget.aliases ?? []) ??
overlong("pathTarget.patternParam", patternParam === undefined ? [] : [patternParam]);
if (long !== undefined)
return long;
vocabulary.add(face.pathTarget.param);
for (const a of face.pathTarget.aliases ?? [])
vocabulary.add(a);
}
const hints = face.renderHints;
if (hints !== undefined) {
const card = hints.approvalCard;
if (card !== undefined) {
if (!TOOL_APPROVAL_CARDS.includes(card))
return `renderHints.approvalCard ${JSON.stringify(card)} is outside the closed set`;
const missing = APPROVAL_CARD_REQUIRED_KEYS[card].filter((k) => !top.has(k));
if (missing.length > 0)
return `renderHints.approvalCard ${JSON.stringify(card)} needs ${missing.join(", ")} on the tool's schema`;
}
if (hints.userFacingName !== undefined && hints.userFacingName.length > RENDER_HINT_MAX_CHARS)
return `renderHints.userFacingName is ${hints.userFacingName.length} characters; the bound is ${RENDER_HINT_MAX_CHARS}`;
if (hints.activity !== undefined && hints.activity.length > RENDER_HINT_ACTIVITY_MAX_CHARS)
return `renderHints.activity is ${hints.activity.length} characters; the bound is ${RENDER_HINT_ACTIVITY_MAX_CHARS}`;
if ((hints.summaryParams?.length ?? 0) > RENDER_HINT_MAX_LIST)
return `renderHints.summaryParams lists ${hints.summaryParams.length} entries; the bound is ${RENDER_HINT_MAX_LIST}`;
if ((hints.resultCards?.length ?? 0) > RENDER_HINT_MAX_LIST)
return `renderHints.resultCards lists ${hints.resultCards.length} entries; the bound is ${RENDER_HINT_MAX_LIST}`;
const long = overlong("renderHints.summaryParams", hints.summaryParams ?? []) ?? overlong("renderHints.resultCards", hints.resultCards ?? []);
if (long !== undefined)
return long;
const stray = (hints.summaryParams ?? []).find((p) => !top.has(p));
if (stray !== undefined)
return `renderHints.summaryParams names ${JSON.stringify(stray)}, which is not a top-level property`;
}
for (const p of face.ruleFace?.params ?? [])
if (!vocabulary.has(p))
return `ruleFace.params names ${JSON.stringify(p)}, which is neither a scalar top-level property nor the declared path target`;
for (const p of face.ruleFace?.primaryParams ?? [])
if (!vocabulary.has(p))
return `ruleFace.primaryParams names ${JSON.stringify(p)}, which is neither a top-level property nor the declared path target`;
const long = overlong("ruleFace.params", face.ruleFace?.params ?? []) ?? overlong("ruleFace.primaryParams", face.ruleFace?.primaryParams ?? []);
if (long !== undefined)
return long;
return undefined;
}
export function applyMcpToolFaces(input) {
const faces = input.server.toolFaces;
if (faces === undefined)
return [...input.tools];
const prefix = mintNamespacePrefix(MCP_NAMESPACE, input.server.name);
const accepted = new Set(input.tools.filter((t) => t.name.startsWith(prefix)).map((t) => t.mcpRemoteName).filter((r) => typeof r === "string"));
for (const raw of Object.keys(faces)) {
if (accepted.has(raw))
continue;
const key = `undeclared:${input.server.name}/${raw}`;
if (input.announced.has(key))
continue;
input.announced.add(key);
deliverEngineNotice(input.onNotice, {
code: "config.tool_face_undeclared",
message: `MCP server "${input.server.name}": toolFaces declares a face for "${raw}", which the server's tool listing does not carry — the declaration is inert`,
detail: { server: input.server.name, tool: raw, sessionId: input.sessionId, runId: input.runId },
});
}
return input.tools.flatMap((t) => {
const stamped = stampOne(t);
return stamped === undefined ? [] : [stamped];
});
function stampOne(t) {
const raw = t.mcpRemoteName;
const face = raw !== undefined && t.name.startsWith(prefix) ? faces?.[raw] : undefined;
if (raw === undefined || face === undefined)
return t;
const hit = { raw, face };
const problem = toolFaceProblem(hit.face, t.parameters);
if (problem !== undefined) {
const key = `invalid:${t.name}`;
if (!input.announced.has(key)) {
input.announced.add(key);
deliverEngineNotice(input.onNotice, {
code: "config.tool_face_invalid",
message: `MCP server "${input.server.name}": the toolFaces overlay for "${hit.raw}" does not fit the tool's materialized schema (${problem}) — the tool is NOT mounted until the overlay fits (mounting it bare would drop the protection the overlay declares)`,
detail: { server: input.server.name, tool: hit.raw, problem, sessionId: input.sessionId, runId: input.runId },
});
}
return undefined;
}
const f = hit.face;
return {
...t,
...(f.family !== undefined ? { family: f.family } : {}),
...(f.pathTarget !== undefined ? { pathTarget: f.pathTarget } : {}),
...(f.renderHints !== undefined ? { renderHints: f.renderHints } : {}),
...(f.ruleFace !== undefined ? { ruleFace: f.ruleFace } : {}),
};
}
}