UNPKG

agentfootprint

Version:

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

228 lines • 13.2 kB
/** * skillNeverRoutes — the NEGATIVE routing row: a phrase this graph must claim * NOWHERE. * * ## Why the negative form is the valuable one * * `skillExamples.ts` pins the positive assertion — this rule claims this * phrasing — and it cannot state the opposite. Field use says the opposite is * the one that catches the expensive failure. An UNDER-triggering skill costs a * turn: the deterministic layer declines, the model tier picks, and the answer * is usually still reachable. An OVER-triggering skill costs the whole answer: * the wrong body enters the system prompt, the wrong tools enter the tools * slot, and everything the model then says is shaped by a skill that had no * business in the turn. One reviewer of a production graph had written his own * harness for exactly this, outside the library, because the library could only * express the cheap half. * * ## Where a negative row lives, and why it is NOT on a skill * * `examples` sits on the rule it is about. A negative row cannot: the phrase it * describes belongs to NO skill, and hanging it on one would make three things * wrong at once — * * • it would read as that skill's business ("weather questions, filed under * billing") when the point is that it is nobody's; * • the assertion is satisfied only when EVERY rule declines, so a row read * as one rule's property would be checked against one rule and pass while * another rule swallowed the phrase; * • deleting the skill would delete the assertion — exactly when a graph is * re-partitioned, which is exactly when over-triggering is introduced. * * A negative row is a property of the PARTITION, so it is declared at the graph * level (`.neverRoutes([...])` / `neverRoutes: [...]`) and judged against every * rule in declaration order. * * ## What is asserted — precisely, and what it does NOT cover * * The claim this module proves is: **no declared start RULE claims the * phrase.** It runs the compiled conditions on a cold-start context, exactly as * `skillExamples.ts` does, and reports the rule that says yes. * * It does NOT prove "no routing at all occurs", and could not without becoming * a different kind of check: * * • an INTENT rule compiles to no sync predicate — a classifier judges it at * run time, with a scorer (and, for `llmClassifier`, a model call). Running * that would make the check async, non-deterministic, and in the LLM case * billable — `graph.checkupIntents()` is where scorer-dependent auditing * already lives; * • a scorer menu (`.entryBy()`) and `.entryByRead()` RANK descriptions; * there is no claim to run; * • the model tier can open any open skill by name with `read_skill`, at * which point no declaration is deciding anything. * * That statement ships on the report as {@link NEVER_ROUTES_BOUNDARY}, next to * the problems, for the same reason the examples boundary does: a reader who * meets a boundary only in prose docs meets a clean report first. * * A predicate that THROWS on the cold context counts as a no-match here, and is * not reported — because routing itself treats a throw as a no-match, so the * phrase really does not route through that rule. Its own rule's `examples` * report the throw as an error (that is a defect in the rule, not in the * partition). * * Composed into `graph.checkup()` by `skillGraph.ts`, like every other domain * check, and reporting in the shared `GraphProblem` voice. * * Zone: PURE CORE. Pinned by * `test/injection-engine/skill-graph-fence.test.ts`. */ import { COLD_CONTEXT_PHRASE, claimLaws, describeCondition, } from './startRuleClaim.js'; /** * The statement the negative check makes about its own reach, carried on * `GraphCheckup.notes` whenever a graph declared any negative row. Same voice * as `EXAMPLES_BOUNDARY` (skillExamples.ts): silence here is evidence about the declared * RULES, and about nothing downstream of them. */ export const NEVER_ROUTES_BOUNDARY = 'A `neverRoutes` row proves that no DECLARED start rule claims the phrase. It does not ' + 'prove the turn routes nowhere: an intent rule is judged by a classifier at run time, a ' + 'scorer / `.entryByRead()` menu ranks descriptions, and the model can always open an open ' + 'skill by name with read_skill — none of those are run here.'; const NOTHING = Object.freeze({ problems: Object.freeze([]), notes: Object.freeze([]), }); /** How two phrases are compared for "the same phrase": trimmed and * case-folded, the same normalization `duplicate-intent-example` uses — an * author who writes one row in two casings meant one row. Exported so the * builder's accumulated-rows set is keyed the SAME way the refusal is; two * spellings of one rule is how a duplicate slips through. */ export function neverRouteKey(phrase) { return phrase.trim().toLowerCase(); } /** * Validate a graph-level `neverRoutes` list at DECLARATION time, refusing every * shape whose check-up answer could only mislead — each refusal naming the fix: * * • not an array / an EMPTY array — nothing to assert, and a check-up that * passes in silence reads as coverage; * • a non-string or blank entry — there is no phrase to run a matcher on; * • a phrase already declared — a repeat asserts nothing the first one did * not, and reads as if two different phrasings were meant. * * A single string is accepted and read as a one-row list: `.neverRoutes('what * is the weather')` is the commonest call, and making the author type brackets * for it buys nothing. * * Returns a frozen copy of the ROWS THIS CALL ADDS, so the stored list cannot * drift from the validated one. */ export function validateNeverRoutes(phrases, where, alreadyDeclared) { const list = typeof phrases === 'string' ? [phrases] : phrases; if (!Array.isArray(list) || list.length === 0) { throw new Error(`skillGraph: ${where} declares a \`neverRoutes\` list this library cannot honor. ` + `\`neverRoutes\` is the phrasings this graph must claim NOWHERE — a non-empty array ` + `of non-empty strings (or one string), e.g. neverRoutes: ['what is the weather']. ` + `${Array.isArray(list) ? 'An empty list asserts nothing, so the check-up would pass in silence and you ' + 'would read that as proof no rule over-triggers. ' : ''}List at least one phrasing, or drop the field.`); } const bad = list.findIndex((p) => typeof p !== 'string' || p.trim().length === 0); if (bad >= 0) { throw new Error(`skillGraph: ${where} declares \`neverRoutes[${bad}]\` = ${JSON.stringify(list[bad])}, which is not a usable phrasing. Every row must be a NON-EMPTY string — the words a ` + `user would really type and that no skill here should take. Fix that entry, or drop it.`); } const seen = new Set(alreadyDeclared); for (const phrase of list) { const key = neverRouteKey(phrase); if (seen.has(key)) { throw new Error(`skillGraph: ${where} declares the \`neverRoutes\` row ${JSON.stringify(phrase)} twice (compared trimmed and case-folded). One row already asserts it against every ` + `rule in the graph; the repeat proves nothing further. Drop the duplicate.`); } seen.add(key); } return Object.freeze([...list]); } /** * Run the negative rows against the declared start rules. Pure, and * byte-identically silent when the graph declared none (one length check and * out). * * Unlike the positive examples, this needs NO `orderDecides` gate: the * assertion is "nobody claims it", which every rule has to satisfy, so the * order they are read in cannot change the answer. That is also why it is the * one phrase check that stays meaningful under a classifier — tier 1 is still * read first there, and a rule that claims the phrase still takes the turn * before any scorer runs. */ export function checkNeverRoutes(input) { const { entries, phrases } = input; if (phrases.length === 0) return NOTHING; const laws = claimLaws(entries); const problems = []; for (const phrase of phrases) { // (a) CONTRADICTION — the author declared the same phrase as a POSITIVE // example on some rule. Reported first and alone: whichever way the // router lands, one of the two declarations is wrong, and no fact about // matchers helps until the author decides which. (Checked before the // matchers are run, because a rule can declare an example its own // matcher misses — the contradiction is in the declarations, not in the // routing.) const contradicting = entries.find((e) => (e.examples ?? []).some((ex) => neverRouteKey(ex) === neverRouteKey(phrase))); if (contradicting !== undefined) { problems.push({ kind: 'error', code: 'never-routes-contradicts-example', message: `The graph declares that "${phrase}" must route NOWHERE, and the start rule for ` + `"${contradicting.id}" declares the SAME phrase in its own \`examples\` — the list ` + `of phrasings that rule CLAIMS. Two of your own declarations contradict each other ` + `about one phrase, so one of them is wrong wherever the router lands, and the ` + `check-up cannot tell which you meant. Decide: drop the \`neverRoutes\` row, or ` + `drop the phrase from "${contradicting.id}"'s \`examples\`.`, skill: contradicting.id, example: phrase, }); continue; } // (b) CLAIMED BY A RULE — the failure this row exists to catch. An ERROR: // the author personally wrote that no skill should take this phrase, a // rule takes it, and both start laws agree it does (a CONDITIONAL // claimant is read by tier 1 AND by the cold walk). The turn would open // with that skill's body and tools on the wire. const byRule = laws.underCascade(phrase); if (byRule !== undefined) { problems.push({ kind: 'error', code: 'never-routes-claimed', message: `The graph declares that "${phrase}" must route NOWHERE, but the start rule for ` + `"${byRule.entry.id}" claims it — ${describeCondition(byRule.entry)} matches the phrase, so a turn phrased that way STARTS on "${byRule.entry.id}", ` + `with that skill's body in the system prompt and that skill's tools on the wire ` + `shaping the whole answer. Narrow "${byRule.entry.id}"'s ${byRule.entry.match === undefined ? 'predicate' : 'matcher'} until it stops claiming this phrasing, or drop the row if "${byRule.entry.id}" ` + `really should take it. (Proved by running the declared conditions on the phrase on ` + `${COLD_CONTEXT_PHRASE} — no regex intersection was decided.)`, skill: byRule.entry.id, example: phrase, }); continue; } // (c) CLAIMED BY A DEFAULT — no RULE claims it, but an unconditional entry // does under one of the two start laws. That is the one place the laws // differ (`skillExamples.ts` header), and which one applies is decided // at AGENT MOUNT, not here — so a WARNING naming both readings, never // an error the router might disagree with. const byWalk = laws.underColdWalk(phrase); if (byWalk === undefined) continue; problems.push({ kind: 'warning', code: 'never-routes-by-default', message: `The graph declares that "${phrase}" must route NOWHERE, and no start RULE claims it ` + `— but the entry "${byWalk.entry.id}" ${describeCondition(byWalk.entry)}, so whether the phrase routes depends on how this graph is MOUNTED and the check-up ` + `cannot decide it. Mounted the default way (continuity: 'turn'), the declaration-order ` + `cold start stops at "${byWalk.entry.id}" and every message starts there, this one ` + `included. Mounted with a classifier or with continuity: 'conversation', the turn-start ` + `cascade's tier 1 reads the CONDITIONAL rules only — an unconditional entry is a ` + `default, not a rule — so nothing claims the phrase and the turn falls onward. Give ` + `"${byWalk.entry.id}" a \`match\`/\`when\` if it is meant to be a rule; if it is meant ` + `to be the catch-all, a negative row can only ever be a statement about the RULES ` + `above it (which is what this warning reports).`, skill: byWalk.entry.id, example: phrase, }); } return { problems, notes: [NEVER_ROUTES_BOUNDARY] }; } //# sourceMappingURL=skillNeverRoutes.js.map