UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

124 lines • 6.2 kB
/** * Skill-body ↔ tool-contract consistency check (Proposal 009, Tier 1). * * A skill's `body` (prose injected into the system prompt) can quietly contradict * the tools it actually unlocks, and the model then **refuses a tool that is right * there** — or is told about one it can't call. The library already knows each * skill's real tool set (`inject.tools`), so it can flag the mismatch at authoring * time instead of at run time. * * Tier 1 is DETERMINISTIC (no LLM): pure string + schema checks. The semantic * contradictions it can't see — e.g. "the body calls an OPTIONAL arg required" — * are Tier 2 (LLM-advisory, opt-in). Both checks here are WARNINGS, never errors: * a body naming a foreign tool is often an intentional `read_skill` handoff hint. */ /** A snake_case token immediately followed by `(` — looks like a tool call in prose. */ const TOOL_CALL_RE = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+)+)\s*\(/g; function escapeRegExp(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } /** * Is this a name a check can actually look for? * * The tool names arrive from the CALLER — composed across a whole graph — and a * hole anywhere in that composition used to reach {@link escapeRegExp} and throw * `Cannot read properties of undefined (reading 'replace')`, from inside a regex * helper, naming neither the skill being checked nor the tool that was missing. * A contract checker that dies without saying which contract is the exact shape * this module exists to catch in other people's code. */ function isUsableToolName(name) { return typeof name === 'string' && name.length > 0; } /** The tool names a skill unlocks (`inject.tools[].schema.name`). */ export function skillToolNames(skill) { const tools = skill.inject ?.tools; return tools ? tools.map((t) => t.schema.name) : []; } /** * Check ONE skill's body against its tool contract. Pure + side-effect-free. * * @param skill the skill to check * @param knownToolNames every tool name reachable in the wider graph/agent (lets * the check tell a cross-skill HANDOFF from a typo). Omit to * check a skill in isolation (only its own tools are "known"). * @param options `knownTools` — baseline tools callable from every skill. * See {@link SkillContractOptions}. */ export function checkSkillContract(skill, knownToolNames, options = {}) { const id = skill.id; const body = skill.inject?.systemPrompt ?? ''; if (body.length === 0) return []; const baseline = new Set(options.knownTools ?? []); const own = new Set(skillToolNames(skill)); const known = knownToolNames ?? own; const problems = []; // 1. body-foreign-tool — the body names a tool that is real (exists somewhere in // the graph) but NOT in this skill's tools[]: the model is told about a tool it // cannot call on this turn. Usually a `read_skill` handoff hint — confirm, or // add the tool / reword. Word-boundary match (tool names are distinctive). for (const name of known) { // Report, never throw. A hole in the caller's tool list is a real defect — // some skill contributed a tool with no usable name — so it is surfaced as a // problem that NAMES the skill under check, rather than skipped (which would // hide it) or thrown (which loses the one fact that makes it fixable). if (!isUsableToolName(name)) { problems.push({ kind: 'error', code: 'unusable-tool-name', message: `Skill "${id}" was checked against a tool list containing ${JSON.stringify(name)} ` + `where a name was expected. The hole is in the tool list handed to this checker, not ` + `in "${id}" itself — look for a tool whose \`schema.name\` is missing in whatever ` + `composes \`knownTools\`. This skill's own contract could not be fully checked.`, skill: id, }); continue; } if (own.has(name)) continue; if (baseline.has(name)) continue; // callable from every skill — not foreign if (new RegExp(`\\b${escapeRegExp(name)}\\b`).test(body)) { problems.push({ kind: 'warning', code: 'body-foreign-tool', message: `Skill "${id}" body mentions tool "${name}", which is not in its tools[] ` + `(it belongs to another skill). The model is told about a tool it can't call here — ` + `make it an explicit read_skill handoff, add the tool to this skill, or reword.`, skill: id, }); } } // 2. body-unknown-tool — the body has a `tool_name(` call-style reference whose name // is no known tool anywhere: a typo or a hallucinated/renamed tool. Conservative: // only snake_case-with-`(` tokens count, and only when truly unknown. const seen = new Set(); for (const m of body.matchAll(TOOL_CALL_RE)) { const name = m[1]; if (seen.has(name) || own.has(name) || known.has(name) || baseline.has(name)) continue; seen.add(name); problems.push({ kind: 'warning', code: 'body-unknown-tool', message: `Skill "${id}" body references "${name}(…)", but no tool named "${name}" exists ` + `(in this skill or the graph). Likely a typo or a renamed/removed tool — the model ` + `will try to call a tool that isn't there.`, skill: id, }); } return problems; } /** Run the contract check across many skills with a shared known-tool set. Pure. */ export function checkSkillContracts(skills, options = {}) { const known = new Set(); for (const s of skills) for (const n of skillToolNames(s)) known.add(n); for (const n of options.knownTools ?? []) known.add(n); return skills.flatMap((s) => checkSkillContract(s, known, options)); } //# sourceMappingURL=skillContract.js.map