agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
216 lines (215 loc) • 11.2 kB
TypeScript
/**
* 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.
*/
/** A guard threshold value — plain data only, because a guard IS data: it
* rides `SkillEdge.guard`, `skill.graph_declared` and every recording, so it
* must survive `structuredClone` and read back as what was declared. */
export type GuardValue = string | number | boolean | null;
/** The guard operators — deliberately the footprintjs WhereFilter set. */
export type GuardOperator = 'eq' | 'ne' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'notIn';
/**
* The operators one guarded key may declare. All declared operators must
* pass (AND), exactly as in footprintjs's `FilterOps`.
*
* • `eq` / `ne` — strict equality / inequality;
* • `gt`/`gte`/`lt`/`lte` — ordered comparison (numbers, or strings
* lexicographically — `iteration: { gte: 3 }`, `riskLevel: { gte: 'high' }`);
* • `in` / `notIn` — membership in a non-empty list (≤ 1000 entries, the
* footprintjs bound).
*/
export interface SkillGuardOps {
readonly eq?: GuardValue;
readonly ne?: GuardValue;
readonly gt?: string | number;
readonly gte?: string | number;
readonly lt?: string | number;
readonly lte?: string | number;
readonly in?: readonly GuardValue[];
readonly notIn?: readonly GuardValue[];
}
/**
* The AUTHOR form of a route-edge guard (`SkillRouteOptions.guard`): keys to
* operator sets, every condition ANDed. Keys resolve per the module-header
* rule — six hop keys read the hop directly, any other key reads the result's
* top-level JSON field.
*
* @example
* .route(triage, escalation, { guard: { riskLevel: { gte: 'high' } } })
* .route(triage, billing, {
* onToolReturn: 'lookup_order',
* guard: { status: { ne: 'denied' }, iteration: { lte: 5 } },
* })
*/
export type SkillGuard = {
readonly [key: string]: SkillGuardOps;
};
/** One compiled guard condition, as serializable data. */
export interface GuardConditionData {
readonly key: string;
readonly op: GuardOperator;
readonly value: GuardValue | readonly GuardValue[];
}
/**
* The serializable description of a route-edge guard — what the check-up
* compares, what `toMermaid()` captions, what `SkillEdge.guard` and the
* `skill.graph_declared` payload carry. Pure data (survives
* `structuredClone`); conditions in declaration order, all ANDed.
*/
export interface SkillGuardData {
readonly conditions: readonly GuardConditionData[];
}
/** The hop view a compiled guard reads. Structural on purpose — the cursor
* resolver builds it from `InjectionContext` + one tool result, and this
* module never has to import either. */
export interface GuardHopView {
readonly toolName: string;
readonly result: string;
readonly status?: string;
readonly iteration: number;
readonly userMessage: string;
readonly currentSkillId?: string;
}
/**
* One condition's evaluation, for the record — the footprintjs
* `FilterCondition` shape, door-local: which condition, what it was judged
* against (bounded summary, never the raw bytes), and whether it passed.
*/
export interface GuardConditionEvidence {
readonly key: string;
readonly op: GuardOperator;
readonly value: GuardValue | readonly GuardValue[];
/** The judged value, summarized to ≤ 80 chars — evidence, not a transcript. */
readonly actualSummary: string;
readonly passed: boolean;
}
/** A full guard evaluation: the verdict plus every condition's evidence. */
export interface GuardVerdict {
readonly verdict: boolean;
readonly conditions: readonly GuardConditionEvidence[];
}
/**
* ONE compilation's three faces (the `compileMatch` pattern): the predicate
* that routes, the data that describes it, and the evidence evaluator the
* record quotes — all from the same conditions, so they can never describe
* different guards.
*/
export interface CompiledGuard {
/** Cheap boolean — short-circuits on the first failing condition. */
readonly predicate: (view: GuardHopView) => boolean;
/** The serializable description. */
readonly data: SkillGuardData;
/** The full evaluation, with per-condition evidence. Same conditions, same
* order, same answers as `predicate` — one compiled list feeds both. */
readonly evaluate: (view: GuardHopView) => GuardVerdict;
}
/** The six hop keys a guard reads directly (every other key reads the
* result's top-level JSON field). Exported for docs/tests. */
export declare const GUARD_HOP_KEYS: readonly ["toolName", "result", "status", "iteration", "userMessage", "currentSkillId"];
/**
* 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.
*/
export declare function compileGuard(guard: SkillGuard, where: string): CompiledGuard;
/**
* 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`.
*/
export declare function plainGuardCaption(g: SkillGuardData): string;
/** Caption a guard-only edge for a mermaid `|…|` label — the plain caption
* under a leading "when", escaped once (the `mermaidMatchCaption` twin). */
export declare function mermaidGuardCaption(g: SkillGuardData): string;
/** The route-edge preconditions a guard is judged AFTER — what the edge
* already requires of the same result. Only the provably-comparable forms
* arrive here: an exact-string `onToolReturn` (a RegExp is not decided) and
* the declared `onToolStatus` set. */
export interface GuardPreconditions {
readonly onToolReturnExact?: string;
readonly onToolStatuses?: readonly string[];
}
/**
* 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.
*/
export declare function guardUnsatisfiable(g: SkillGuardData, pre?: GuardPreconditions): string | undefined;