UNPKG

agentfootprint

Version:

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

477 lines • 22.3 kB
"use strict"; /** * skillGuard — the DATA-guard domain for skill-graph route edges (9.51.0). * * This module completes the SkillWalker's third mover. The SkillMap is data * (`skillGraph()`/`defineSkillMap`), entry matchers are data (`match:` — * skillMatch.ts), tool-outcome route arms are data (`onToolStatus`) — the one * thing left opaque on a route edge was the general `when` predicate. A * `guard:` is its declarative twin: conditions over the hop's context and the * tool result's own fields, compiled ONCE into the predicate that routes AND * the serializable data that describes it, so the check-up can compare * guards, `toMermaid()` can caption them, `skill.graph_declared` can carry * them, and every evaluation leaves per-condition evidence on the record. * * One module owns everything a guard is and does (the skillMatch.ts law): * * • what a guard IS — {@link SkillGuard} (author form) and * {@link SkillGuardData} (serializable description); * • how it RUNS — {@link compileGuard}: ONE compilation returns the * predicate, the data, and the evidence evaluator; * • what it PROVES WRONG — {@link guardUnsatisfiable}: the contradictions * the check-up can honestly flag; * • how it DRAWS — {@link plainGuardCaption} / {@link mermaidGuardCaption}. * * ## The operator grammar deliberately MIRRORS footprintjs's WhereFilter * * `eq / ne / gt / gte / lt / lte / in / notIn`, every condition ANDed, with * per-condition evidence — the exact vocabulary and evidence shape of * footprintjs's `evaluateFilter` (footprintjs `src/lib/decide/evaluator.ts`). * It is a door-local TWIN, not an import: this module sits behind the * skill-graph door's no-footprintjs fence * (test/injection-engine/skill-graph-fence.test.ts), so the tiny operator * set is mirrored here and a future shared extraction is mechanical. Two * deliberate divergences, both because guards are compiled at BUILD time * while footprintjs filters arrive at run time: a malformed guard is REFUSED * by name at the keystroke (footprintjs dev-warns and fails the condition), * and there is no redaction hook (this layer has no redaction registry — the * summarized `actualSummary` is the bounded record, never the raw bytes). * * ## What a guard can read (the key rule) * * A guard is judged per tool result of the previous iteration's batch — the * same evidence every route edge fires on. Six HOP KEYS read the hop context * directly: * * `toolName`, `result`, `status` — the judged tool result (`status` only * when the tool's envelope declared one); * `iteration`, `userMessage`, `currentSkillId` — the iteration context. * * Any OTHER key reads the top-level field of that name from the RESULT * parsed as JSON — the shape structured tool results already have. A result * that is not a JSON object yields `undefined` for such keys, so the * condition fails and the evidence says so. NOTE the same caveat that rides * `InjectionContext.lastToolResult`: `result` is the string the MODEL read, * which artifact placement can replace with a claim ticket — a guard over * result fields judges what the model was told. Guard on `toolName` / * `status` when you want a condition placement cannot move. * * Engine-type-free by design (the view is structural), imports only the * closed status vocabulary from its own door — `skillGraphCheckup.ts` can * import it and keep its "pure over strings" law intact. */ Object.defineProperty(exports, "__esModule", { value: true }); exports.guardUnsatisfiable = exports.mermaidGuardCaption = exports.plainGuardCaption = exports.compileGuard = exports.GUARD_HOP_KEYS = void 0; const toolOutcome_js_1 = require("../semantics/toolOutcome.js"); /** The six hop keys a guard reads directly (every other key reads the * result's top-level JSON field). Exported for docs/tests. */ exports.GUARD_HOP_KEYS = [ 'toolName', 'result', 'status', 'iteration', 'userMessage', 'currentSkillId', ]; const HOP_KEY_SET = new Set(exports.GUARD_HOP_KEYS); /** Mirrors footprintjs's prototype-pollution denylist — refused at compile * (build time) rather than failed at run time, because a guard is declared * code-side and a refusal teaches where a silent no-match hides. */ const DENIED_KEYS = new Set([ '__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf', 'propertyIsEnumerable', '__defineGetter__', '__defineSetter__', '__lookupGetter__', '__lookupSetter__', ]); const GUARD_OPERATORS = [ 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', 'in', 'notIn', ]; const OPERATOR_SET = new Set(GUARD_OPERATORS); /** The footprintjs `MAX_IN_ARRAY_SIZE` bound, mirrored. */ const MAX_IN_ARRAY_SIZE = 1000; const MAX_SUMMARY_LEN = 80; function isGuardValue(v) { return v === null || typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean'; } /** Bound one judged value into evidence text. Strings verbatim (truncated), * primitives via String, structures via JSON — never the raw bytes past the * bound. */ function summarize(v) { if (v === undefined) return 'undefined'; if (typeof v === 'string') { return v.length > MAX_SUMMARY_LEN ? `${v.slice(0, MAX_SUMMARY_LEN - 1)}…` : v; } if (v === null || typeof v === 'number' || typeof v === 'boolean') return String(v); try { const s = JSON.stringify(v); if (typeof s !== 'string') return Object.prototype.toString.call(v); return s.length > MAX_SUMMARY_LEN ? `${s.slice(0, MAX_SUMMARY_LEN - 1)}…` : s; } catch { return Object.prototype.toString.call(v); } } /** The operator dispatch table — footprintjs's `OPERATOR_HANDLERS`, verbatim * in spirit: raw JS comparison, strict equality, array membership. The * in/notIn size guard lives at COMPILE here (the lists are declared, not * discovered at run time). */ const OPERATOR_TESTS = { eq: (a, t) => a === t, ne: (a, t) => a !== t, gt: (a, t) => a > t, gte: (a, t) => a >= t, lt: (a, t) => a < t, lte: (a, t) => a <= t, in: (a, t) => Array.isArray(t) && t.includes(a), notIn: (a, t) => !(Array.isArray(t) && t.includes(a)), }; /** Parse a tool result as a JSON OBJECT, or undefined — arrays and scalars * have no named top-level fields for a guard key to read. */ function parseResultObject(result) { try { const parsed = JSON.parse(result); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; return parsed; } catch { return undefined; } } function refuse(where, what) { throw new Error(`skillGraph: ${where} declares a \`guard\` this library cannot honor — ${what} A guard ` + `is { key: { op: value, … }, … } with operators ${GUARD_OPERATORS.join('/')}, values ` + `plain data (string/number/boolean/null; arrays of those for in/notIn), every ` + `condition ANDed. Keys ${exports.GUARD_HOP_KEYS.join('/')} read the hop directly; any other ` + `key reads the tool result's top-level JSON field. For any other condition, use ` + `\`when: (result) => …\` instead.`); } /** * Compile a guard into its predicate + its serializable data + its evidence * evaluator — ONE compilation, so the predicate that routes, the data the * check-up compares, and the evidence the record quotes can never describe * different guards (the `compileMatch` law). Refuses every shape it cannot * honor, by name, at the keystroke: an empty guard (asserts nothing — the * footprintjs anti-vacuous-truth law, refused at build instead of matched * never), an unknown operator (a typo like `gle` must not become an edge that * silently never fires), a non-data threshold (a guard that cannot ride a * recording is not a guard), an empty or oversized `in`/`notIn` list, and the * prototype-pollution key set. */ function compileGuard(guard, where) { if (guard === null || typeof guard !== 'object' || Array.isArray(guard)) { refuse(where, `it is not an object of { key: { op: value } } conditions.`); } const keys = Object.keys(guard); if (keys.length === 0) { refuse(where, `it is empty. A guard with no conditions asserts nothing, so it could only match ` + `everything or nothing — both the silent kind of wrong. Name at least one ` + `condition, or drop the field.`); } const conditions = []; for (const key of keys) { if (DENIED_KEYS.has(key)) { refuse(where, `key "${key}" is reserved (prototype-pollution denylist).`); } const ops = guard[key]; if (ops === null || typeof ops !== 'object' || Array.isArray(ops)) { refuse(where, `key "${key}" maps to ${JSON.stringify(ops)} instead of { op: value }.`); } const opEntries = Object.entries(ops); if (opEntries.length === 0) { refuse(where, `key "${key}" declares no operator ({}).`); } for (const [op, value] of opEntries) { if (!OPERATOR_SET.has(op)) { refuse(where, `key "${key}" uses unknown operator "${op}" (valid: ${GUARD_OPERATORS.join(', ')}).`); } if (op === 'in' || op === 'notIn') { if (!Array.isArray(value) || value.length === 0) { refuse(where, `key "${key}" \`${op}\` needs a NON-EMPTY array — an empty \`in\` can never ` + `match and an empty \`notIn\` always does; both assert nothing.`); } if (value.length > MAX_IN_ARRAY_SIZE) { refuse(where, `key "${key}" \`${op}\` lists ${value.length} entries (max ${MAX_IN_ARRAY_SIZE}).`); } if (!value.every(isGuardValue)) { refuse(where, `key "${key}" \`${op}\` holds a non-data entry — every entry must be a ` + `string, number, boolean or null.`); } const list = value.slice(); const test = OPERATOR_TESTS[op]; conditions.push({ key, op: op, value: list, test: (a) => test(a, list) }); continue; } if (op === 'gt' || op === 'gte' || op === 'lt' || op === 'lte') { if (typeof value !== 'number' && typeof value !== 'string') { refuse(where, `key "${key}" \`${op}\` needs a number or a string to compare against ` + `(got ${JSON.stringify(value)}).`); } } else if (!isGuardValue(value)) { refuse(where, `key "${key}" \`${op}\` holds a non-data value — a guard is data (it rides ` + `edges, events and recordings), so thresholds must be strings, numbers, ` + `booleans or null.`); } const test = OPERATOR_TESTS[op]; conditions.push({ key, op: op, value: value, test: (a) => test(a, value), }); } } const needsResultFields = conditions.some((c) => !HOP_KEY_SET.has(c.key)); const valueOf = (view, key, parsed) => { if (HOP_KEY_SET.has(key)) return view[key]; if (parsed === undefined) return undefined; return Object.prototype.hasOwnProperty.call(parsed, key) ? parsed[key] : undefined; }; const data = { conditions: conditions.map(({ key, op, value }) => ({ key, op, value })), }; return { data, predicate: (view) => { const parsed = needsResultFields ? parseResultObject(view.result) : undefined; for (const c of conditions) { if (!c.test(valueOf(view, c.key, parsed))) return false; } return true; }, evaluate: (view) => { const parsed = needsResultFields ? parseResultObject(view.result) : undefined; let verdict = true; const evidence = conditions.map((c) => { const actual = valueOf(view, c.key, parsed); const passed = c.test(actual); if (!passed) verdict = false; return { key: c.key, op: c.op, value: c.value, actualSummary: summarize(actual), passed }; }); return { verdict, conditions: evidence }; }, }; } exports.compileGuard = compileGuard; // ─── How it DRAWS ─────────────────────────────────────────────────────── const OP_WORDS = { eq: '=', ne: '≠', gt: '>', gte: '≥', lt: '<', lte: '≤', in: 'in', notIn: 'not in', }; function captionValue(v) { if (Array.isArray(v)) return `[${v.map(captionValue).join(', ')}]`; return String(v); } /** * The UNESCAPED caption — one grammar for naming a guard, shared by the * mermaid label and by prose quoting a guard back to its author (the * `plainMatchCaption` twin): `riskLevel ≥ high AND iteration ≤ 5`. */ function plainGuardCaption(g) { return g.conditions .map((c) => `${c.key} ${OP_WORDS[c.op]} ${captionValue(c.value)}`) .join(' AND '); } exports.plainGuardCaption = plainGuardCaption; /** Caption a guard-only edge for a mermaid `|…|` label — the plain caption * under a leading "when", escaped once (the `mermaidMatchCaption` twin). */ function mermaidGuardCaption(g) { return `when ${plainGuardCaption(g)}`.replace(/\|/g, '#124;').replace(/"/g, '#quot;'); } exports.mermaidGuardCaption = mermaidGuardCaption; function conditionsFor(g, key) { return g.conditions.filter((c) => c.key === key); } /** Same-type ordered comparison; undefined when the types differ (a claim * across types would be a guess — JS coercion is not a proof). */ function ordered(a, b) { if (typeof a === 'number' && typeof b === 'number') return a === b ? 0 : a < b ? -1 : 1; if (typeof a === 'string' && typeof b === 'string') return a === b ? 0 : a < b ? -1 : 1; return undefined; } function boundsOf(conds) { let lower; let upper; for (const c of conds) { if (c.op === 'gt' || c.op === 'gte') { lower = { value: c.value, strict: c.op === 'gt' }; } else if (c.op === 'lt' || c.op === 'lte') { upper = { value: c.value, strict: c.op === 'lt' }; } } return { ...(lower && { lower }), ...(upper && { upper }) }; } /** Does `v` satisfy every ordered bound among `conds`? `undefined` = not * decidable (mixed types) — the caller must stay silent. */ function satisfiesBounds(v, conds) { for (const c of conds) { if (c.op !== 'gt' && c.op !== 'gte' && c.op !== 'lt' && c.op !== 'lte') continue; const cmp = ordered(v, c.value); if (cmp === undefined) return undefined; if (c.op === 'gt' && !(cmp > 0)) return false; if (c.op === 'gte' && !(cmp >= 0)) return false; if (c.op === 'lt' && !(cmp < 0)) return false; if (c.op === 'lte' && !(cmp <= 0)) return false; } return true; } /** * The check-up's honesty core: is this guard PROVABLY unsatisfiable — by its * own conditions, or against the edge's declared preconditions? Returns the * human why-clause, or `undefined` when nothing is provable (say nothing * rather than guess — the `compareMatchers` law). * * What is claimed, exactly (all same-key, all decidable from the data): * • `eq` vs `ne`/`in`/`notIn`/ordered bounds on one key — a required value * the same guard excludes; * • crossed ordered bounds (`gt`/`gte` above `lt`/`lte`, same-type only); * • `in` whose every member the same key's `notIn` excludes; * • `status` values outside the CLOSED result-status vocabulary — a status * a tool can never declare (`TOOL_RESULT_STATUSES` is the whole set); * • the guard's `status`/`toolName` conditions vs the edge's own declared * `onToolStatus` set / exact-string `onToolReturn` — two declarations on * one edge, one of which must be wrong. * NOT claimed: anything across keys, anything involving result-JSON fields' * runtime values, RegExp `onToolReturn` intersection, mixed-type bounds. */ function guardUnsatisfiable(g, pre) { const keys = [...new Set(g.conditions.map((c) => c.key))]; for (const key of keys) { const conds = conditionsFor(g, key); const eq = conds.find((c) => c.op === 'eq'); const ne = conds.find((c) => c.op === 'ne'); const inC = conds.find((c) => c.op === 'in'); const notInC = conds.find((c) => c.op === 'notIn'); if (eq !== undefined) { const e = eq.value; if (ne !== undefined && ne.value === e) { return `it requires ${key} = ${captionValue(e)} AND ${key} ≠ ${captionValue(e)} — no value satisfies both`; } if (inC !== undefined && !inC.value.includes(e)) { return (`it requires ${key} = ${captionValue(e)} AND ${key} in ` + `${captionValue(inC.value)}, which does not contain it`); } if (notInC !== undefined && notInC.value.includes(e)) { return (`it requires ${key} = ${captionValue(e)} AND ${key} not in ` + `${captionValue(notInC.value)}, which excludes it`); } if (satisfiesBounds(e, conds) === false) { return `it requires ${key} = ${captionValue(e)}, which violates the same key's ordered bounds`; } } const { lower, upper } = boundsOf(conds); if (lower !== undefined && upper !== undefined) { const cmp = ordered(lower.value, upper.value); if (cmp !== undefined && (cmp > 0 || (cmp === 0 && (lower.strict || upper.strict)))) { return (`its ${key} bounds are crossed (${lower.strict ? '>' : '≥'} ` + `${captionValue(lower.value)} AND ${upper.strict ? '<' : '≤'} ` + `${captionValue(upper.value)}) — no value satisfies both`); } } if (inC !== undefined && notInC !== undefined && inC.value.every((v) => notInC.value.includes(v))) { return `its ${key} \`notIn\` excludes every member of the same key's \`in\` list`; } // The status vocabulary is CLOSED (toolOutcome.ts) — an eq/in value no // tool can ever declare is a typo the data makes provable. if (key === 'status') { const vocabulary = toolOutcome_js_1.TOOL_RESULT_STATUSES; if (eq !== undefined && typeof eq.value === 'string' && !vocabulary.includes(eq.value)) { return (`"${eq.value}" is not a result status any tool can declare ` + `(the closed set: ${vocabulary.join(', ')})`); } if (inC !== undefined && inC.value.every((v) => typeof v !== 'string' || !vocabulary.includes(v))) { return (`none of its status \`in\` entries ${captionValue(inC.value)} is a result ` + `status any tool can declare (the closed set: ${vocabulary.join(', ')})`); } } } // The edge's own preconditions AND with the guard over the SAME result — // two declarations that provably disagree are one edge that never fires. if (pre?.onToolStatuses !== undefined) { const s = pre.onToolStatuses; for (const c of conditionsFor(g, 'status')) { if (c.op === 'eq' && !s.includes(c.value)) { return (`the edge routes on onToolStatus [${s.join(', ')}] but its guard requires ` + `status = ${captionValue(c.value)} — the two can never both hold`); } if (c.op === 'in' && !c.value.some((v) => s.includes(v))) { return (`the edge routes on onToolStatus [${s.join(', ')}] but its guard requires ` + `status in ${captionValue(c.value)} — the sets are disjoint`); } if (c.op === 'ne' && s.length === 1 && s[0] === c.value) { return (`the edge routes only on onToolStatus [${s[0]}] but its guard requires ` + `status ≠ ${captionValue(c.value)}`); } if (c.op === 'notIn' && s.every((v) => c.value.includes(v))) { return (`the edge routes on onToolStatus [${s.join(', ')}] but its guard's \`notIn\` ` + `excludes every one of those statuses`); } } } if (pre?.onToolReturnExact !== undefined) { const t = pre.onToolReturnExact; for (const c of conditionsFor(g, 'toolName')) { if (c.op === 'eq' && c.value !== t) { return (`the edge routes on onToolReturn "${t}" but its guard requires ` + `toolName = ${captionValue(c.value)}`); } if (c.op === 'ne' && c.value === t) { return `the edge routes on onToolReturn "${t}" but its guard requires toolName ≠ "${t}"`; } if (c.op === 'in' && !c.value.includes(t)) { return (`the edge routes on onToolReturn "${t}" but its guard's toolName \`in\` list ` + `${captionValue(c.value)} does not contain it`); } if (c.op === 'notIn' && c.value.includes(t)) { return (`the edge routes on onToolReturn "${t}" but its guard's toolName \`notIn\` list ` + `excludes it`); } } } return undefined; } exports.guardUnsatisfiable = guardUnsatisfiable; //# sourceMappingURL=skillGuard.js.map