agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
130 lines • 6.49 kB
JavaScript
;
/**
* 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.checkSkillContracts = exports.checkSkillContract = exports.skillToolNames = void 0;
/** 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`). */
function skillToolNames(skill) {
const tools = skill.inject
?.tools;
return tools ? tools.map((t) => t.schema.name) : [];
}
exports.skillToolNames = skillToolNames;
/**
* 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}.
*/
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;
}
exports.checkSkillContract = checkSkillContract;
/** Run the contract check across many skills with a shared known-tool set. Pure. */
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));
}
exports.checkSkillContracts = checkSkillContracts;
//# sourceMappingURL=skillContract.js.map