agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
1,659 lines • 97.8 kB
JavaScript
/**
* skillGraph — a declarative, visualizable skill-dependency graph (proposal 002).
*
* The consumer declares skills + routing EDGES; `skillGraph()` compiles each edge
* to the existing injection-engine TRIGGER on the target skill — so the dynamic,
* token-efficient loading the engine already does becomes *declared* and *drawn*.
*
* .entry(skill, { when? }) → trigger: `always` (or `rule` if when)
* .route(a, b, { onToolReturn | when }) → b compiles to a CURSOR-GATED `rule`
* (a skill with no declared incoming edge keeps its default `llm-activated`
* trigger — still reachable via `read_skill`, drawn as a dashed "model" edge)
*
* **v2 keystone — `from` IS enforced (a sticky cursor state machine).** A skill
* graph is a state machine over skills; the engine tracks which node it is in via
* `InjectionContext.currentSkillId` (the cursor). One pure resolver — `resolveCursor`
* (see `makeResolveCursor`), of which `nextSkill(ctx)` is the `.to` projection — is the
* single source of truth: each route target B
* compiles to the trigger `nextSkill(ctx) === B`, which delivers `from`-gating
* (an edge `A→B` fires only while the cursor is on A — no cross-skill edge bleed),
* stickiness (the cursor stays on B until an edge leaves B), and a clean handoff
* (B deactivates the same iteration C activates). The Injection Engine's Evaluate
* stage advances the cursor with the SAME ctx (`currentSkillId = nextSkill(ctx)`),
* so the active set and the persisted cursor never disagree. The DRAWN edge kind
* (`on-tool-return` vs `predicate`) is preserved for rendering even though the
* compiled trigger is always a `rule`. `toMermaid()` renders declared === drawn.
*
* A decision `tree()` routes per-iteration by stable `ctx` predicates (no cursor)
* and is unaffected by `from`-gating. It has no cursor for `read_skill` to move
* either, so `reachableSkills()` is EMPTY there and the gate refuses a leaf pick
* (8.5.0) — see `reachableSkills` for why honouring it would break three of this
* module's own invariants.
*
* **One skill's turn at a time (8.15.0).** The keystone's third property — "B
* deactivates the same iteration C activates" — now holds for EVERY node in a flat
* graph, including an entry that carries a `when`. Such an entry used to compile to
* `when(ctx) || cursor === id`, and the leftover rule clause meant an entry that
* routed onward stayed loaded beside its own successor, on that iteration and every
* one after it. A conditional entry is active exactly while the cursor is on it;
* `when` decides where a turn STARTS. The one declared way to be co-active beside
* the cursor is an entry with no `when` at all (`{ kind: 'always' }`), which is
* untouched. A rule that matched while the cursor was elsewhere is reported, not
* swallowed — see `supersededEntries`.
*
* **The model moves too (`read_skill`).** Scoped `read_skill` bounds the model to
* `reachableSkills(cursor)` — the declared successors of where it stands, plus the
* entries. A pick the gate ACCEPTS moves the cursor exactly like a declared edge
* does (`ctx.pendingSkillPick`, honoured in `makeResolveCursor`), so what the gate
* allows and what actually takes effect are the same set. A declared edge that
* fires the same turn wins (`D1 > D2`, docs/design/skill-graph.md §4A.1) and the
* run emits `agentfootprint.skill.reroute_superseded` rather than leaving the
* model's answered-"activated" claim quietly unmet.
*
* **The resolver says WHY, it is not asked to be guessed.** `explainNextSkill(ctx)`
* returns the destination AND the winning clause (`CursorMove`), decided at the same
* `return`. The agent stamps it on `context.evaluated` as `cursorMove`, which is how
* `routeRecorder()` can tell a model pick from a declared edge that happens to point
* at the same skill (8.5.0 — before, the drawn build-time provenance was read as the
* per-hop cause, so a model pick was recorded under a declared edge's label).
*/
import { devMode, devWarn } from './devWarn.js';
import { isHiddenSkill, toolResultsOf } from './types.js';
import { embeddingScorer } from './entryScorer.js';
import { checkupGraph, formatCheckup, } from './skillGraphCheckup.js';
import { checkSkillContracts } from './skillContract.js';
import { checkArtifactVocabularies } from './skillVocabulary.js';
import { checkStartRuleExamples, validateStartRuleExamples } from './skillExamples.js';
import { checkNeverRoutes, neverRouteKey, validateNeverRoutes } from './skillNeverRoutes.js';
import { checkPartition } from './skillPartition.js';
import { checkEntryEvidence } from './skillEntryEvidence.js';
import { compileMatch, mermaidMatchCaption, } from './skillMatch.js';
import { compileGuard, mermaidGuardCaption, plainGuardCaption, } from './skillGuard.js';
import { resolveRoutingPolicy } from './routingPolicy.js';
import { buildTurnRoutingPlan, findDuplicateIntentExamples, runCheckupIntents, } from './skillIntent.js';
export { formatCheckup } from './skillGraphCheckup.js';
// The data-guard domain (`guard:` on route edges, 9.51.0) — one module owns the
// type, the compiler, the evidence shape and the caption; see ./skillGuard.ts.
export { GUARD_HOP_KEYS, plainGuardCaption, } from './skillGuard.js';
/** Build a decision node. Leaves are skills (an `Injection`); internal nodes are
* other `decideSkill(...)` results. (Renamed from `decide` in v7 to avoid
* colliding with footprintjs's `decide()`.) */
export function decideSkill(predicate, whenTrue, whenFalse, label) {
return { kind: 'decision', predicate, whenTrue, whenFalse, label };
}
function isDecisionNode(n) {
return n.kind === 'decision';
}
/** The metadata key carrying a skill's routing provenance. */
export const SKILL_GRAPH_METADATA_KEY = 'skillGraph';
/**
* The metadata key carrying the {@link DeferredBodyContract} note on each COMPILED
* skill. The graph-level `graph.deferredBodyContract` note names the deferral, but
* skills reach an agent through more than one door — `.skillGraph(graph)` sees the
* graph, `.skills({ list: () => graph.skills })` sees only the skills — so the note
* also rides each skill's own metadata. Agent build collects it from the final
* injection list, whichever door the skills came through, and runs the deferred
* checks exactly once (skills found by BOTH the metadata and the graph note are
* deduped by id).
*/
export const SKILL_GRAPH_DEFERRED_CONTRACT_KEY = 'skillGraphDeferredBodyContract';
/** A route edge that fires on its own evidence — `when` code, a tool-name
* match, a declared status, or a data guard. NOT a model edge. The ONE
* predicate every determinism filter shares (9.19.0 folded four replicas
* into it when `onToolStatus` joined the family; `guard` joined in 9.51.0). */
function isDeterministicRoute(r) {
return (r.when !== undefined ||
r.onToolReturn !== undefined ||
r.onToolStatus !== undefined ||
r.guard !== undefined);
}
/** The declared status set, normalized to an array. */
function statusesOf(onToolStatus) {
return Array.isArray(onToolStatus) ? onToolStatus : [onToolStatus];
}
/** Mermaid node ids must be identifier-safe; keep the original id as the label. */
function nodeId(id) {
return 'n_' + id.replace(/[^A-Za-z0-9_]/g, '_');
}
function toolMatcher(toolName) {
return typeof toolName === 'string' ? (n) => n === toolName : (n) => toolName.test(n);
}
/** How a skill is identified back to its author in a refusal — the description is
* what tells two same-id skills apart (mirrors skillsFromDir naming both files). */
function describe(skill) {
return skill.description ?? '(no description)';
}
function plural(n, one, many) {
return `${n} ${n === 1 ? one : many}`;
}
export function skillGraph(config) {
const skillsById = new Map();
const entries = [];
const routes = [];
let treeRoot;
let treeScopeTools = true;
let entryScorer;
let entryByReadFlag = false;
let classifyScorer;
let classifyPolicy;
// The negative rows, in declaration order. A graph that never calls
// `.neverRoutes()` keeps an empty array and pays one length check at
// check-up time — zero cost when unused.
const neverRoutePhrases = [];
/**
* Register a skill under its id, refusing a second DIFFERENT skill under an id
* already taken (8.4.0). Until then this was a bare `Map.set` — last write won,
* silently, and the loser vanished from the compiled graph entirely: the flat
* form kept the LAST body under the FIRST declaration's routing, the config form
* dropped the first skill outright, and a tree kept the FIRST body and discarded
* the second. `skillsFromDir` has always refused a collision inside its own
* directory and `Agent.injection()` has always refused one on the agent, so the
* graph was the only place in the library where two skills could quietly claim
* one id — the id `read_skill` dispatches by and every edge routes by.
*
* Identity-based, deliberately: re-registering the SAME object is how the fluent
* form works (`.entry(a).route(a, b)` remembers `a` twice) and how one skill sits
* at two tree leaves (the compiler merges those into one ORed trigger).
*/
const remember = (skill) => {
if (skill.flavor !== 'skill') {
throw new Error(`skillGraph: "${skill.id}" is not a skill (flavor='${skill.flavor}').`);
}
const claimed = skillsById.get(skill.id);
if (claimed && claimed !== skill) {
throw new Error(`skillGraph: two different skills claim the id "${skill.id}" — ` +
`"${describe(claimed)}" and "${describe(skill)}". Skill ids must be unique ` +
`(read_skill dispatches by id, and every edge routes by id); rename one, or ` +
`pass the SAME skill object to both places.`);
}
skillsById.set(skill.id, skill);
return skill.id;
};
const builder = {
entry(skill, opts) {
const id = remember(skill);
// Exactly one condition per entry: `match` (data) or `when` (code). Both set
// is a contradiction — which one starts the turn? — refused rather than
// silently ANDed or ORed. (Neither set stays the `always` entry it has
// always been; the rules form additionally refuses "neither", because a
// rule exists to be conditional — see the config translation below.)
if (opts?.when !== undefined && opts?.match !== undefined) {
throw new Error(`skillGraph: entry "${id}" sets both \`match\` and \`when\` — an entry takes ` +
`exactly one condition. Use \`match\` (a RegExp, { keywords: [...] } or ` +
`{ all: [...] } over the user message — comparable and drawable) OR \`when\` ` +
`(a predicate over the iteration context). To combine a pattern with extra ` +
`logic, fold the pattern into your \`when\` predicate.`);
}
const compiled = opts?.match !== undefined ? compileMatch(opts.match, `entry "${id}"`) : undefined;
// The phrasings this rule claims — validated where they are declared, so
// an unusable list is refused at the keystroke instead of quietly proving
// nothing at check-up time (skillExamples.ts owns every refusal).
const examples = validateStartRuleExamples(opts?.examples, `entry "${id}"`, {
hasCondition: opts?.when !== undefined || opts?.match !== undefined,
isIntent: compiled?.data.kind === 'intent',
});
entries.push({
id,
when: compiled ? compiled.predicate : opts?.when,
...(compiled && { match: compiled.data }),
...(compiled?.witness && { witness: compiled.witness }),
...(examples && { examples }),
label: opts?.label,
});
return builder;
},
route(from, to, opts) {
const fromId = remember(from);
const toId = remember(to);
if (opts?.when && opts?.onToolReturn) {
throw new Error(`skillGraph: route ${fromId}→${toId} sets both 'when' and 'onToolReturn' — pick one.`);
}
// `when` is code, `onToolStatus` is data over the same result — both
// set is a contradiction (which one fires the edge?). `onToolReturn`
// COMPOSES with `onToolStatus` ("this tool, with this outcome"), so
// that pair stays legal.
if (opts?.when && opts?.onToolStatus) {
throw new Error(`skillGraph: route ${fromId}→${toId} sets both 'when' and 'onToolStatus' — pick ` +
`one. 'onToolStatus' is the data form (drawable, comparable); to combine a ` +
`status with extra logic, read \`result.status\` inside your 'when' predicate.`);
}
if (opts?.onToolStatus !== undefined && statusesOf(opts.onToolStatus).length === 0) {
throw new Error(`skillGraph: route ${fromId}→${toId} sets 'onToolStatus: []' — an empty status ` +
`set can never match, so the edge would be dead wiring. Name at least one ` +
`status, or drop the field.`);
}
// `when` is code, `guard` is data over the same hop — both set is a
// contradiction (which one fires the edge?), refused exactly as
// when+onToolStatus is. `guard` COMPOSES with onToolReturn/onToolStatus
// ("this tool, this outcome, AND these conditions") — conditions AND
// anyway, so the pair stays legal there.
if (opts?.when && opts?.guard) {
throw new Error(`skillGraph: route ${fromId}→${toId} sets both 'when' and 'guard' — pick one. ` +
`'guard' is the data form (drawable, comparable, evidence-recorded); to ` +
`combine declared conditions with extra logic, fold the checks into your ` +
`'when' predicate.`);
}
// ONE compilation (the compileMatch pattern): the predicate that routes,
// the data the record carries, and the evidence evaluator — all from the
// same conditions. Every malformed shape is refused here, by name.
const guard = opts?.guard !== undefined ? compileGuard(opts.guard, `route ${fromId}→${toId}`) : undefined;
routes.push({
fromId,
toId,
when: opts?.when,
onToolReturn: opts?.onToolReturn,
...(opts?.onToolStatus !== undefined && { onToolStatus: opts.onToolStatus }),
...(guard !== undefined && { guard }),
label: opts?.label,
});
return builder;
},
tree(root, opts) {
treeRoot = root;
if (opts?.scopeTools === false)
treeScopeTools = false;
return builder;
},
entryBy(scorer) {
entryScorer = scorer;
return builder;
},
entryByRelevance(embedder) {
entryScorer = embeddingScorer(embedder);
return builder;
},
entryByRead() {
entryByReadFlag = true;
return builder;
},
classify(scorer, policy) {
classifyScorer = scorer;
classifyPolicy = policy;
return builder;
},
neverRoutes(phrases) {
// Validated at the keystroke, like a rule's `examples` — an unusable row
// is refused where it was written instead of quietly asserting nothing at
// check-up time. The already-declared set makes the duplicate refusal
// work across calls, not just within one.
neverRoutePhrases.push(...validateNeverRoutes(phrases, '.neverRoutes(...)', new Set(neverRoutePhrases.map(neverRouteKey))));
return builder;
},
build(opts = {}) {
// One entry router per graph — classify is a third machine beside the
// scorer and the model-read menu, and two of them deciding one turn
// start would let the record and the routing disagree.
if (classifyScorer && (entryScorer || entryByReadFlag)) {
throw new Error('skillGraph: pick ONE entry router — .classify(scorer) classifies the message ' +
'against declared intents; .entryBy()/.entryByRelevance() rank descriptions; ' +
'.entryByRead() lets the LLM read the menu. This graph configures more than one.');
}
if (classifyScorer && treeRoot) {
throw new Error('skillGraph: .classify() is for flat entry/route graphs; a .tree() already routes ' +
'by predicate on every iteration (there is no turn start for a classifier to route).');
}
// Intent rules need their judge — an intent compiles to no message
// predicate, so without a classifier the entry could never be chosen and
// would silently never load. Refused with a stable code, like rule-id-exists.
const intentEntries = entries.filter((e) => e.match?.kind === 'intent');
if (intentEntries.length > 0 && !classifyScorer) {
const problems = intentEntries.map((e) => ({
kind: 'error',
code: 'intent-without-classify',
message: `entry "${e.id}" declares match: { intent } and the graph configures no ` +
`classifier — intent rules need one to be judged. Add ` +
`\`classify: keywordScorer()\` (no dependency) or \`embeddingScorer(embedder)\`; ` +
`\`llmClassifier(provider)\` if you want the model to judge.`,
skill: e.id,
}));
throw new Error(`skillGraph: build-time check-up failed:\n${formatCheckup({ ok: false, problems })}`);
}
if (entryByReadFlag && entryScorer) {
throw new Error('skillGraph: pick one of .entryByRead() or .entryBy()/.entryByRelevance() — not ' +
'both (the LLM reads the menu, OR a scorer ranks it).');
}
if (entryByReadFlag && treeRoot) {
throw new Error('skillGraph: .entryByRead() is for flat entry/route graphs; a .tree() already ' +
'routes by predicate (no entry menu).');
}
if (entryScorer && treeRoot) {
throw new Error('skillGraph: .entryBy()/.entryByRelevance() is for flat entry/route graphs; a ' +
'.tree() already routes by predicate (the scorer would be ignored).');
}
// One dial, one home: a tree declares tool scoping on `.tree(root, { scopeTools })`
// (object form: the tree arm's own field). Accepting it here too would let the two
// homes disagree, and only one of them would win — silently.
if (treeRoot && opts.scopeTools !== undefined) {
throw new Error("skillGraph: `scopeTools` on build() is the FLAT arm's dial. This graph is a " +
'.tree(), which declares tool scoping on .tree(root, { scopeTools }) (object ' +
"form: the tree arm's own `scopeTools` field) — set it there instead.");
}
// A negative row is judged against the START RULES, and a `.tree()` has
// none — it routes by predicate on every iteration, with no turn start to
// claim or decline a phrase. Accepting the rows here would run them
// against an empty rule set and report a clean pass, which is the one
// answer a negative assertion must never give by construction.
if (treeRoot && neverRoutePhrases.length > 0) {
throw new Error(`skillGraph: .neverRoutes(...) declares ${plural(neverRoutePhrases.length, 'phrase', 'phrases')} that must claim no skill, but this graph is a .tree() — which routes by predicate ` +
'on every iteration and has no start rules for a phrase to be judged against, so ' +
'the rows would pass in silence and you would read that as proof. Assert them on ' +
'a flat entry/route graph, or test the tree by running its predicates directly.');
}
// A tree and the flat entry/route wiring are two ways to declare the same
// thing, and only ONE of them compiles: the tree branch below never reads
// `entries` or `routes`. Until 8.4.0 that was silent — the tree won and every
// declared entry and route was dropped without a word, `checkup()` answered
// `{ ok: true, problems: [] }` because its tree arm short-circuits, and the
// author's flat wiring simply never existed at runtime.
if (treeRoot && (entries.length > 0 || routes.length > 0)) {
const dropped = [
...(entries.length > 0 ? [plural(entries.length, 'entry', 'entries')] : []),
...(routes.length > 0 ? [plural(routes.length, 'route', 'routes')] : []),
].join(' and ');
throw new Error('skillGraph: .tree() and .entry()/.route() both declare the routing and only one ' +
`can compile — the tree wins, so the ${dropped} declared here would be silently ` +
'dropped. tree() owns the graph: remove the .entry()/.route() calls, or drop ' +
'.tree() and route with the flat entry/route form.');
}
const skills = [];
const nodes = [];
const edges = [];
// The build-time check-up — pure over the declared entries/routes/skills,
// PLUS the proposal-009 Tier-1 skill-body ↔ tool-contract checks (warnings).
//
// `triggerKinds` reads the COMPILED `skills` array (filled below, before any
// caller can reach this closure) rather than the declaration, because that is
// where the truth is: `deriveTrigger` returns null for an unwired skill, so the
// skill keeps whatever trigger it arrived with — and `unreachable-skill`'s
// sentence about read_skill is only true for `llm-activated`.
const checkup = (options = {}) => {
const wiring = checkupGraph({
skillIds: new Set(skillsById.keys()),
// `conditional` counts a data `match` exactly like a `when` (it compiled
// to one — an INTENT match compiles to no predicate but is judged by the
// classifier, which is still a condition), so a rule-router built from
// matchers is never read as a fan-out. The match DATA rides along so the
// pairwise rule checks (`overlapping-rules` / `rules-shadowed-by-order`)
// have something they can honestly compare.
entries: entries.map((e) => ({
id: e.id,
conditional: e.when !== undefined || e.match?.kind === 'intent',
...(e.match && { match: e.match }),
})),
// A guard rides as DATA so the check-up can prove contradictions
// (`guard-unsatisfiable`) — beside the only preconditions that are
// provably comparable: an exact-string onToolReturn (a RegExp is not
// decided) and the declared status set.
routes: routes.map((r) => ({
fromId: r.fromId,
toId: r.toId,
deterministic: isDeterministicRoute(r),
...(r.guard !== undefined && { guard: r.guard.data }),
...(typeof r.onToolReturn === 'string' && { onToolReturnExact: r.onToolReturn }),
...(r.onToolStatus !== undefined && {
onToolStatuses: statusesOf(r.onToolStatus),
}),
})),
isTree: treeRoot !== undefined,
exclusiveEntries: entryScorer !== undefined || entryByReadFlag || classifyScorer !== undefined,
triggerKinds: new Map(skills.map((s) => [s.id, s.trigger.kind])),
hasClassifier: classifyScorer !== undefined,
});
const contract = checkSkillContracts([...skillsById.values()], {
...(options.knownTools && { knownTools: options.knownTools }),
});
// The one PROVABLE intent-pair fact (SG-C) — the same example under two
// intents. Composed here like the contract checks; the scorer-dependent
// audit is the ASYNC `checkupIntents()`.
const intentDuplicates = findDuplicateIntentExamples(entries);
// The artifact vocabularies (SG-F, 9.25.0). Composed here like the
// contract checks and NOT deferred: unlike a tool name, a `produces`
// declaration cannot arrive later from `.tool()` — it is written on a
// skill, and every skill of this graph is already in hand. Returns []
// the moment nothing declares a vocabulary, so a graph that never
// heard of the feature pays one `Array.some`.
const vocabularies = checkArtifactVocabularies([...skillsById.values()]);
// The declared phrasings (SG-G) — the three properties a witness phrase
// makes provable. `orderDecides` is the SAME gate the pairwise rule
// checks use: declaration order decides the turn start under the default
// form and under a classifier's tier 1, and does not under a scorer /
// `.entryByRead()` — where only the order-independent property is
// claimed, and a note says which one was skipped. Returns frozen empties
// the moment no rule declared examples, so a graph that never heard of
// the feature pays one `Array.some` and reports the identical object.
const examples = checkStartRuleExamples({
entries,
orderDecides: !(entryScorer !== undefined || entryByReadFlag) || classifyScorer !== undefined,
hasClassifier: classifyScorer !== undefined,
});
// The NEGATIVE rows — phrases this graph claims NOWHERE. No
// `orderDecides` gate: "nobody claims it" is an assertion every rule
// has to satisfy, so the order they are read in cannot change the
// answer (skillNeverRoutes.ts's header has the whole argument).
const negatives = checkNeverRoutes({ entries, phrases: neverRoutePhrases });
// The PARTITION advisories — how this graph cut the world into skills,
// read from names and structure alone. Warnings, always: every signal
// has a legitimate design behind it, and each message says which.
const partition = checkPartition({
skills: [...skillsById.values()],
routeCount: routes.length,
entryCount: entries.length,
isTree: treeRoot !== undefined,
});
// The entry-evidence rows (9.58.0) — the two ONE-ROW facts about
// guessed entries (skillEntryEvidence.ts has the whole argument,
// including why the intuitive per-node exit lint is dead).
const entryEvidence = checkEntryEvidence({
entries: entries.map((e) => ({
id: e.id,
conditional: e.when !== undefined || e.match?.kind === 'intent',
hasExamples: (e.examples?.length ?? 0) > 0,
})),
routeFromIds: new Set(routes.map((r) => r.fromId)),
neverRoutesCount: neverRoutePhrases.length,
isTree: treeRoot !== undefined,
});
const problems = [
...wiring.problems,
...intentDuplicates,
...contract,
...vocabularies,
...examples.problems,
...negatives.problems,
...partition,
...entryEvidence,
];
// Notes are statements about the report's own REACH, and each check
// brings its own — a graph that declared both examples and negative
// rows carries both boundaries, in declaration order of the checks.
const notes = [...examples.notes, ...negatives.notes];
return {
ok: !problems.some((p) => p.kind === 'error'),
problems,
...(notes.length > 0 && { notes }),
};
};
// The cursor resolver — the single source of truth for `from`-gated, sticky
// routing. Flat mode wires it into each route target's trigger AND returns it
// for the loop's cursor-update stage. Tree mode has no cursor (per-iteration
// predicate routing), so it stays a no-op there.
//
// It resolves to a `CursorMove` — where AND by which clause — and `nextSkill`
// is the `.to` projection of it (8.5.0). ONE resolver still: the cause is
// decided at the same `return` that decides the destination, so an observer
// can never disagree with the routing about why the cursor moved.
let resolveCursor = (ctx) => ctx.currentSkillId === undefined
? { by: 'none' }
: { from: ctx.currentSkillId, to: ctx.currentSkillId, by: 'stay' };
// The reachable-set resolver — what `read_skill` may jump to from the cursor
// (the runtime gate enforces it). Default empty; set per mode below.
let reachableSkills = () => [];
// The suppression reporter (8.15.0) — conditional entries whose rule matched
// while the cursor was elsewhere. Nothing to suppress without a cursor, so tree
// mode and cursor-less graphs keep the empty default.
let supersededEntries = () => [];
// The relevance entry scorer — present only with `.entryByRelevance()` (flat).
let scoreEntries;
// The turn-routing plan (SG-C) — flat graphs only; a tree has no turn start.
let turnRouting;
if (treeRoot) {
// Decision-tree mode (v3): compile each leaf to a path-conjunction trigger.
// Every leaf is REGISTERED as it compiles (8.4.0). Two things ride on that:
// the duplicate-id refusal now covers leaves (before, two different skills
// sharing an id as two leaves merged, first body wins, silently), and the
// skill-CONTRACT checks finally run for a fluent `.tree()` graph — until
// 8.4.0 `skillsById` stayed empty there, so `checkup()` answered
// `{ ok: true, problems: [] }` for a tree whose body called a tool that
// does not exist, while the byte-identical config-form graph reported it.
compileTree(treeRoot, () => true, { skills, nodes, edges }, null, { n: 0 }, [], treeScopeTools, remember);
attachExactlyOneLeafMonitor(skills);
const leafIds = skills.map((s) => s.id);
// A tree routes to its LEAVES and to nothing else, so a skill listed in
// `skills[]` that is not a leaf is compiled out of the graph entirely — it
// never reaches the agent, so no trigger, no `read_skill`, no body, ever.
// Refused by name rather than dropped (8.4.0).
const leafSet = new Set(leafIds);
for (const id of skillsById.keys()) {
if (leafSet.has(id))
continue;
throw new Error(`skillGraph({ tree }): skill "${id}" is listed in skills[] but is not a leaf of ` +
'the tree, so it would never load — a tree routes only to its leaves. Add it ' +
`to the tree as a leaf, drop it from skills[], or register it on the agent ` +
`with .skill(${id}) to keep it read_skill-reachable.`);
}
// Tree mode has no cursor, so `read_skill` has nothing to jump — EMPTY (8.5.0).
//
// Until 8.5.0 this answered `leafIds`, which made the gate accept a leaf pick
// that could never take effect: a leaf compiles to `{ kind: 'rule', activeWhen:
// pathCond }`, and a `read_skill` call writes only `activatedInjectionIds`,
// which no `rule` trigger reads. The pick was accepted, the tool answered
// "Skill 'x' activated for the next iteration", the tree re-decided by
// predicate, and `reroute_superseded` fired naming a winner that did not exist
// (tree mode never writes a cursor at all). Three of the library's own
// invariants say the pick cannot be honoured here instead: exactly ONE leaf
// fires per iteration (`attachExactlyOneLeafMonitor` warns otherwise), each
// leaf's tools are scoped on that basis (`TreeOptions.scopeTools`), and
// `toMermaid()` draws what is declared — a model lever over predicate routing
// is not on the drawing. So the gate refuses, in the same terms 8.4.0 already
// uses for every other rule-triggered skill.
//
// `read_skill` is not dead under a tree: the OPEN skills (anything registered
// beside the graph — `.skill(x)`, `.skills(reg)`, `.selfExplain()`) are still
// admitted from any cursor by the agent's gate, which is the only place that
// knows what else is registered.
reachableSkills = () => [];
}
else {
// Flat entry/route mode (v1 + v2 keystone). `from`-gating + sticky cursor
// both derive from one pure resolver so they can never diverge.
// `.entryByRead()` makes the entries EXCLUSIVE (like `.entryByRelevance()`),
// but the cold-start pick is the model's: no entry auto-loads — the LLM picks
// one via `read_skill`, and that choice becomes the cursor (see
// makeResolveCursor). `.classify()` suppresses the cold walk the SAME way:
// the cascade decides the start (or offers a menu) — never the
// first-declared entry by declaration-order accident. Without the
// suppression, a 'menu' verdict would fall into the cold walk and silently
// enter the first no-`when` entry while the record claimed a menu was
// outstanding. This flag is only the COMPILE-TIME half: an entry-scorer
// graph opts into the cascade at MOUNT time (`continuity:
// 'conversation'`), which no graph build can see — there the resolver
// suppresses the walk on the VERDICT's presence (`ctx.turnRoute`), so
// both arms obey one law: the cascade's verdict, never the
// declaration-order accident.
const llmReadEntry = entryByReadFlag || classifyScorer !== undefined;
resolveCursor = makeResolveCursor(entries, routes, llmReadEntry);
// Triggers share ONE memoized view of the resolver per evaluation pass —
// see memoizePerPass. The public resolvers (one call per iteration) stay raw.
const nextSkillForTriggers = memoizePerPass(resolveCursor);
reachableSkills = makeReachableSkills(entries, routes);
// Exclusive entries are cursor-gated with no rule clause at all, so nothing
// there can be superseded by one — the reporter is for the DEFAULT form.
if (!entryScorer && !llmReadEntry) {
supersededEntries = makeSupersededEntries(entries, resolveCursor);
}
if (entryScorer)
scoreEntries = makeScoreEntries(entries, skillsById, entryScorer);
// The turn-routing plan (SG-C): candidates, tier-1 rules, tie policy —
// projected ONCE, so the router, the menu and the audit read one shape.
turnRouting = buildTurnRoutingPlan({
entries,
describeFor: (id) => skillsById.get(id)?.description,
policy: resolveRoutingPolicy(classifyPolicy),
...(classifyScorer && { scorer: classifyScorer }),
});
// `scopeTools: true` (flat) — the WIRED skills, i.e. everything an entry or a
// route mentions. Stamped exactly as the tree stamps its leaves: a graph-level
// DEFAULT (`existingAuto ?? …`), never an override of a skill's own declaration.
// A listed-but-unwired skill stays additive — the graph does not route it, so
// the graph does not scope it (it remains an OPEN, read_skill-reachable skill).
// Default `false` preserves additive behavior; scoping stays explicit.
const scopedIds = opts.scopeTools === true
? new Set([...entries.map((e) => e.id), ...routes.flatMap((r) => [r.fromId, r.toId])])
: undefined;
for (const [id, skill] of skillsById) {
const trigger = deriveTrigger(id, skill, entries, routes, nextSkillForTriggers, entryScorer !== undefined || llmReadEntry);
const routing = routingFor(id, entries, routes);
const existingAuto = skill.metadata
?.autoActivate;
const autoActivate = existingAuto ?? (scopedIds?.has(id) ? 'currentSkill' : undefined);
skills.push({
...skill,
...(trigger && { trigger }),
metadata: {
...skill.metadata,
[SKILL_GRAPH_METADATA_KEY]: routing,
...(autoActivate && { autoActivate }),
},
});
nodes.push({ id, kind: 'skill', label: id });
}
edges.push(...entries.map((e) => ({
from: null,
to: e.id,
kind: 'entry',
label: e.label,
...(e.match && { match: e.match }),
})), ...routes.map((r) => {
// A guard composed with onToolReturn/onToolStatus keeps that kind
// (the guard rides beside it as data, like `match` on an entry);
// a guard ALONE is its own kind — it is deterministic routing,
// and calling it 'model' (no trigger) or 'predicate' (opaque
// code) would both be false.
const kind = r.onToolStatus
? 'on-tool-status'
: r.onToolReturn
? 'on-tool-return'
: r.guard
? 'guard'
: r.when
? 'predicate'
: 'model';
// The derived caption folds the guard clause in ("… when
// riskLevel ≥ high") wherever a tool/status part is already
// derived here; a guard-ONLY edge stores no label and toMermaid
// captions it from the data (the entry-`match` precedent, which
// keeps the mermaid escaping in one place).
const guardSuffix = r.guard ? ` when ${plainGuardCaption(r.guard.data)}` : '';
return {
from: r.fromId,
to: r.toId,
kind,
label: r.label ??
(r.onToolStatus
? `on ${r.onToolReturn !== undefined ? `${String(r.onToolReturn)} ` : ''}status=${statusesOf(r.onToolStatus).join('|')}${guardSuffix}`
: r.onToolReturn
? `on ${String(r.onToolReturn)}${guardSuffix}`
: undefined),
...(r.guard && { guard: r.guard.data }),
};
}));
}
// Run the check-up per the `check` mode: 'throw' fails loud on an error; 'warn'
// prints in dev mode only (quiet in prod / tests) and NEVER throws; 'off' skips.
//
// The default is `'throw'` since 8.7.0, matching the object-literal form. It was
// `'warn'`, which meant a fluent graph with `no-entry` or `unknown-skill` — a
// graph that cannot start a turn at all — built in silence outside dev mode and
// surfaced as a run that entered no skill. An explicit `check: 'warn'` still
// never throws, so the opt-down survives with its name intact.
const check = opts.check ?? 'throw';
// Without `knownTools`, the body-contract checks (`body-foreign-tool` /
// `body-unknown-tool`) cannot tell a typo from a baseline tool the agent
// registers later — the graph builds before `.tool()` runs. So this PASS
// defers them (reports nothing it cannot prove) and leaves a
// `deferredBodyContract` note in TWO places that say the same thing: on the
// graph, and on each compiled skill's metadata — because skills reach an
// agent through more than one door (`.skillGraph(graph)` sees the graph;
// `.skills({ list: () => graph.skills })` sees only the skills). Agent build
// collects the note from its final injection list and runs the checks exactly
// once against the full tool registry, whichever door was used. With
// `knownTools` given, the author supplied the full picture and the checks run
// right here, as always — no note anywhere, and the agent never re-runs them
// (one problem, one report). `graph.checkup()` is untouched either way: the
// explicit lint call always runs every check over what it can see.
const deferBodyContract = check !== 'off' && opts.knownTools === undefined;
const deferredNote = deferBodyContract
? { mode: check }
: undefined;
if (check !== 'off') {
const result = checkup({ ...(opts.knownTools && { knownTools: opts.knownTools }) });
const reported = deferBodyContract
? {
ok: result.ok, // body-contract problems are warnings — they never decide `ok`
problems: result.problems.filter((p) => p.code !== 'body-foreign-tool' && p.code !== 'body-unknown-tool'),
// A deferred pass filters PROBLEMS, never the report's statement
// about its own reach — dropping the notes here would make the
// build-time surface quieter than `graph.checkup()` about exactly
// the thing a clean report must not be read as.
...(result.notes && { notes: result.notes }),
}
: result;
if (check === 'throw' && !reported.ok) {
throw new Error(`skillGraph: build-time check-up failed:\n${formatCheckup(reported)}`);
}
if (reported.problems.length > 0) {
devWarn(`skillGraph: build-time check-up found problems:\n${formatCheckup(reported)}`);
}
}
return {
// Deferring? The note rides each skill too (dedup at agent build is by id,
// so the double stamp can never double-report). No deferral → the exact
// same objects as always: zero cost when unused.
skills: deferredNote
? skills.map((s) => ({
...s,
metadata: { ...s.metadata, [SKILL_GRAPH_DEFERRED_CONTRACT_KEY]: deferredNote },
}))
: skills,
edges,
nodes,
...(deferredNote && { deferredBodyContract: deferredNote }),
toMermaid: () => renderMermaid(nodes, edges),
// `nextSkill` is the `.to` PROJECTION of the one resolver, not a second
// implementation — the two cannot answer differently.
nextSkill: (ctx) => resolveCursor(ctx).to,
explainNextSkill: (ctx) => resolveCursor(ctx),
supersededEntries: (ctx) => supersededEntries(ctx),
reachableSkills: (currentSkillId) => reachableSkills(currentSkillId),
checkup: (options) => checkup(options),
...(scoreEntries && { scoreEntries }),
...(turnRouting && { turnRouting }),
...(classifyScorer
? { entrySelection: 'classify' }
: entryScorer
? { entrySelection: 'scorer' }
: entryByReadFlag
? { entrySelection: 'model-read' }
: {}),
...(classifyScorer && {
checkupIntents: (signal) => runCheckupIntents({
entries,
scorer: classifyScorer,
policy: resolveRoutingPolicy(classifyPolicy),
...(signal && { signal }),
}),
}),
};
},
};
// Object-literal form → translate to the fluent calls + build. Listing skills
// independently of the wiring is what lets the check-up flag a listed-but-unwired
// skill (every config skill is registered, even if no edge references it).
if (config) {
// `tree` and `start`/`steps` are two ways to declare the routing and only one
// compiles. The TYPE already refuses the pair (SkillGraphConfig is a union with
// `tree?: never` on the flat arm), so this is the runtime half of the same
// refusal — for JavaScript callers and for a config assembled through `any`.
// Until 8.4.0 `start` was skipped by an `else if` and `steps` were compiled into
// routes the tree branch then ignored: both vanished without a word.
const overruled = [
...(config.start !== undefined ? ['start'] : []),
...(config.steps !== undefined ? ['steps'] : []),
];
if (config.tree !== undefined && overruled.length > 0) {
const quoted = overruled.map((k) => `\`${k}\``).join(' and ');
const one = overruled.length === 1;
throw new Error(`skillGraph({ tree, ${overruled.join(', ')} }): ${quoted} declare${one ? 's' : ''} the ` +
`routing that \`tree\` already owns, so ${one ? 'it' : 'they'} would be silently ` +
'ignored — a tree routes by predicate on every iteration and has no entry menu ' +
`and no cursor. Remove ${quoted}, or drop \`tree\` and route with the flat form.`);
}
for (const s of config.skills)
remember(s);
const resolve = (id) => {
const s = skillsById.get(id);
if (!s)
throw new Error(`skillGraph: config references skill "${id}" not in skills[].`);
return s;
};
if (config.tree) {
// `scopeTools` reaches the compiler here since 8.7.0 — the fluent form's
// `.tree(root, { scopeTools: false })` had no object-form twin, so this arm
// silently forced every leaf to `autoActivate: 'currentSkill'`.
builder.tree(config.tree, {
...(config.scopeTools !== undefined && { scopeTools: config.scopeTools }),
});
}
else if (config.start !== undefined) {
const start = config.start;
if (typeof start === 'string')
builder.entry(resolve(start));
else if ('use' in start)
builder.entry(resolve(start.use));
else if ('rules' in start) {
// (a) rule-id-exists — validate EVERY rule's target before compiling any of
// them, so the refusal lists every bad id at once (not just the first) and
// names what IS allowed. An unknown id cannot compile into an entry at all,
// so this refuses under every `check` mode — a rule silently dropped under
// `check: 'warn'` would be the accepted-and-silently-wrong build.
const missing = start.rules
.map((r, i) => ({ use: r.use, i }))
.filter(({ use }) => !skillsById.has(use));
if (missing.length > 0) {
const known = [...skillsById.keys()].map((k) => `"${k}"`).join(', ');
const problems = missing.map(({ use, i }) => ({
kind: 'error',
code: 'rule-id-exists',
message: `start.rules[${i}] routes to skill "${use}", which is not in skills[]. ` +
`Known skill ids: ${known || '(none)'}. Fix the rule's \`use\`, or add the ` +
`skill to skills[].`,
skill: use,
}));
throw new Error(`skillGraph: build-time check-up failed:\n${formatCheckup({ ok: false, problems })}`);
}
for (const [i, r] of start.rules.entries()) {
// Exactly one of `match`/`when` per rule. The type already says so
// (`SkillStartRule` is a union with `never` on the other arm); this is the
// runtime half, for JavaScript callers and configs assembled through `any`.
// "Neither" is refused too — a rule exists to be conditional; the
// unconditional start is `start: 'id'` / `{ use }`, and saying so beats
// compiling a rule that would swallow every turn.
const use = r.use; // read before the narrowing checks — TS collapses the
// union to `never` inside the refusal branch (the type says it can't happen;
// this is the runtime half for callers the compiler never saw).
const both = r.when !== undefined && r.match !== undefined;
const neither = r.when === undefined && r.match === undefined;
if (both || neither) {
throw new Error(`skillGraph: start.rules[${i}] (use: "${use}") ${both ? 'sets both `match` and `when`' : 'sets neither `match` nor `when`'} — a rule takes exactly one condition. Say when it applies with \`match\` ` +
`(a RegExp, { keywords: [...] } or { all: [...] } tested against the user ` +
`message) or \`when\` (a predicate over the iteration context). For an ` +
`unconditional start, use \`start: '${use}'\` instead of a rule.`);
}
builder.entry(resolve(r.use), {
...(r.when && { when: r.when }),
...(r.match !== undefined && { match: r.match }),
...(r.examples !== undefined && { examples: r.examples }),
});
}
// The classifier + its tie policy (SG-C) — the rules form is where
// intents live, so this is where the judge is declared.
if (start.classify)
builder.classify(start.classify, start.routing);
else if (start.routing !== undefined) {
throw new Error('skillGraph: start.routing is the tie policy of the classifier and means nothing ' +
'without one — declare `classify` beside it (the one-dial-one-home rule: the ' +
'policy lives where the scorer it governs is declared).');
}
}
else {
for (const id of start.entries)
builder.entry(resolve(id));
// scoredBy (any scorer) > byRelevance (embedder sugar) > entryByRead (LLM picks).
if (start.scoredBy)
builder.entryBy(start.scoredBy);
else if (start.byRelevance)
builder.entryByRelevance(start.byRelevance);
else
builder.entryByRead();
}
}
// The negative rows (graph level, like the skills list itself — a phrase
// that must route nowhere belongs to no skill and to no `start` arm).
// Declared BEFORE `build()` so the tree refusal fires there, in one place,
// for both forms.
if (config.neverRoutes !== undefined)
builder.neverRoutes(config.neverRoutes);
for (const step of config.steps ?? []) {
builder.route(resolve(step.from), resolve(step.to), {
...(step.when && { when: step.when }),
...(step.onToolReturn && { onToolReturn: step.onToolReturn }),
...(step.onToolStatus !== undefined && { onToolStatus: step.onToolStatus }),
...(step.guard !== undefined && { guard: step.guard }),
...(step.label && { label: step.label }),
});
}
return builder.build({
check: config.check ?? 'throw',
...(config.knownTools && { knownTools: config.knownTools }),
// The FLAT arm's tool-scoping dial rides through to the one compiler below.
// (The tree arm's `scopeTools` was already handed to `.tree()` above — passing
// it here too would trip build()'s one-dial-one-home refusal.)
...(config.tree === undefined &&
config.scopeTools !== undefined && { scopeTools: config.scopeTools }),
});
}
return builder;
}
/**
* The official vocabulary (9.51.0): you declare the **SkillMap**; the agent
* is the **SkillWalker**; the recording carries both.
*
* `defineSkillMap` is a PERMANENT thin alias of {@link skillGraph} — the same
* function object (reference-equal), same overloads, both names exported
* forever; neither is a rename of the other. Use whichever reads better:
* `defineSkillMap({...})` beside `defineSkill({...})` says what you are
* doing; `skillGraph()` says what you get.
*
* There is deliberately NO `SkillWalker` export. The walker is not a thing
* you construct — it is the agent itself (`.skillGraph(map)` mounts the map;
* the agent walks it), moving the cursor by exactly three movers, every move
* on the record as `cursorMove`:
*
* • **llm** — the model picks via `read_skill`, bounded by the gate
* (`by: 'model-pick'`; refusals are `skill.rejected`);
* • **guard** — your DATA decides: a `when`/`onToolReturn`/`onToolStatus`/
* `guard:` edge fires, evidence recorded (`by: 'route'`,
* with `cursorMove.guard` when a data guard judged it);
* • **linear** — no choice: a bare hand-off that fires every time its
* source finishes (an `onToolReturn` edge with one target —
* the degenerate guard).
*/
export const defineSkillMap = skillGraph;
// FOLD · the one owner of the skills reachable from where the cursor stands (declared successors ∪ entries, minus the cursor)
// consumers read this and never re-derive it: the read_skill offer builder (Agent.ts · readSkillOfferFor), the
// injection-engine config's `reachableSkills` and the read_skill gate's `allowedSkillIds` (both Agent.ts) — which
// toolCalls reads back as deps.allowedSkillIds at the propose-transition judge and at the gate itself
// (toolCalls.ts · "── Skill-graph read_skill GATE").
// detached: yes — dedupe builds a fresh array per call; the declarations never leave.
/**
* The reachable-set resolver (the read_skill gate's allowed set). Pure +
* deterministic over the build-time entries/routes:
* • cold start (cursor undefined) → the entry skills (you enter via entries);
* • otherwise → the cursor's direct successors (ANY declared edge out of it,
* deterministic OR bare/model) ∪ the entry skills, minus the cursor itself.
*
* That last exclusion is about MOVEMENT and nothing else: there is nowhere to
* move to. It is NOT a statement that the cursor is unavailable, and five
* consumers read it as one until {@link classifySkillTarget} was given the
* question — read its doc comment before writing a `requested === cursor`
* check anywhere. Declaration order preserved; ids de-duplicated.
*/
function makeReachableSkills(entries, routes) {
const entryIds = entries.map((e) => e.id);
return (cur) => {
const ids = cur === undefined ? [...entryIds] : [...successorsOf(cur, routes), ...entryIds];
return dedupe(cur === undefined ? ids : ids.filter((id) => id !== cur));
};
}
// FOLD · the one owner of which class a read_skill target falls in from one cursor: 'self' | 'hop' | 'open' | 'unreachable'
// consumers read this and never re-derive it: skillToolDescriptors.ts · grantableRows and skillToolDescriptors.ts ·
// classOf · toolCalls.ts · atMountedCursor, the propose-transition judge (toolCalls.ts · "── A PROPOSAL TO THE
// CURSOR'S OWN SKILL IS A STAY") and the read_skill gate (toolCalls.ts · "── Skill-graph read_skill GATE") ·
// re-exported for consumers at doors/skill-graph.ts.
// detached: yes — a string literal over readonly inputs the caller already owns.
/**
* Classify one `read_skill` target against one cursor. See
* {@link SkillTargetClass} for the law this function owns.
*
* @example
* ```ts
* classifySkillTarget({ cursor: 'billing', target: 'billing' }); // 'self'
* classifySkillTarget({ cursor: 'billing', target: 'refunds', hops: ['refunds'] }); // 'hop'
* classifySkillTarget({ cursor: 'billing', target: 'debug', open: ['debug'] }); // 'open'
* classifySkillTarget({ cursor: 'billing', target: 'vault' }); // 'unreachable'
* ```
*/
export function classifySkillTarget(args) {
const { cursor, target, hops, open } = args;
if (cursor !== undefined && target === cursor)
return 'self';
if (hops?.includes(target) === true)
return 'hop';
if (open?.includes(target) === true)
return 'open';
return 'unreachable';
}
/** Direct successors of `from` — every declared route edge out of it (any kind). */
function successorsOf(from, routes) {
return routes.filter((r) => r.fromId === from).map((r) => r.toId);
}
function dedupe(ids) {
return [...new Set(ids)];
}
/**
* Bind the chosen `EntryScorer` strategy into `graph.scoreEntries`. The engine owns
* the `when`-filtering (it needs `ctx` + the entry predicates); the scorer owns the
* ranking. Filters the entries to the `when`-passing candidates, hands the scorer
* `{ userMessage, candidates: { id, description } }`, and returns its `EntryScoring`.
* Async wrapper so a sync scorer (`keywordScorer`) and an async one (`embeddingScorer`)
* present identically; runs once per turn in the OFF-LOOP PickEntry stage, never in
* the sync route triggers, so `nextSkill` stays synchronous. An empty candidate set
* (or a throwing scorer, caught by PickEntry) falls back to the normal cold-start entry.
*/
function makeScoreEntries(entries, skillsById, scorer) {
return async (ctx, signal) => {
const candidates = entries
.filter((e) => {
if (!e.when)
return true;
try {
return e.when(ctx);
}
catch (err) {
warnMatcherThrew(`entry "${e.id}"`, err);
return false;
}
})
.map((e) => ({ id: e.id, description: skillsById.get(e.id)?.description ?? e.id }));
return scorer.score({ userMessage: ctx.userMessage, candidates }, signal);
};
}
/** Does a single route edge fire for ONE tool result of the batch?
* `onToolStatus` matches the result's DECLARED status (composed with the
* tool name when `onToolReturn` is also set — a result with no status can
* never match a status edge); `onToolReturn` alone matches the tool NAME;
* `when` runs the predicate over the result (same `{ toolName, result }`
* shape it has always received, plus `status` when declared). The caller
* walks the batch in call order (9.16.0) — before that, only
* `ctx.lastToolResult` was ever offered here. */
function routeMatches(r, res) {
if (!edgePreconditionsPass(r, res))
return false;
if (r.onToolStatus !== undefined || r.onToolReturn !== undefined)
return true;
return r.when ? r.when(res) : false;
}
/** The tool/status PRECONDITIONS of one edge against one result — the part a
* `guard` is judged AFTER (9.51.0). True when the edge declares neither.
* Split out of `routeMatches` because the resolver dispatches guard edges
* itself: the guard's evidence must be collected at the very statement that
* decides the hop, and "the guard decided" is only true once these passed. */
function edgePreconditionsPass(r, res) {
if (r.onToolStatus !== undefined) {
if (res.status === undefined || !statusesOf(r.onToolStatus).includes(res.status)) {
return false;
}
return r.onToolReturn ? toolMatcher(r.onToolReturn)(res.toolName) : true;
}
if (r.onToolReturn)
return toolMatcher(r.onToolReturn)(res.toolName);
return true;
}
/**
* The cursor resolver (the keystone). Pure + deterministic. Given the iteration
* context, returns the skill the graph should be *in* after this iteration AND the
* clause that decided it (`CursorMove`; `nextSkill` is the `.to` projection):
* • cold start (`currentSkillId` unset) → first `entry` whose `when` passes
* (an `always`-entry — no `when` — matches unconditionally), else the entry
* the MODEL picked with `read_skill` (see "the model's pick" below);
* • a `from`-gated route (`fromId === currentSkillId`) whose predicate matches
* a result of the iteration's tool batch — results in call order, edges per
* result in declaration order; the first result with a match wins, later
* results matching OTHER targets are reported as `conflict` (9.16.0) →
* its target (the transition);
* • else the model's pick, when the read_skill gate accepted one this turn;
* • otherwise the current cursor unchanged (sticky stay).
*
* **The model's pick (`ctx.pendingSkillPick`) — the D2 "validated volunteer".**
* `read_skill(id)` is already GATED to `reachableSkills(cursor)` (the model may
* only name a skill the graph declares as reachable from where it stands), and
* the tool answers "Skill 'id' activated for the next iteration." Until 8.3.0 the
* cursor ignored that answer for every form except `.entryByRead()`'s cold start,
* so the sentence was false wherever the skill's activation was cursor-gated or
* rule-gated. It is honoured here, at ONE place, with the precedence the design
* pinned (docs/design/skill-graph.md §4A.1): a declared edge that fired this hop
* WINS over a same-turn pick (`D1 > D2`) — the author's determinism is never
* overridden by a model guess. The pick is one-shot (the tool-calls stage rewrites
* it every iteration), so a pick can never drag the cursor backwards after a later
* edge has moved it.
*
* Each candidate predicate runs in its OWN try/catch so one throwing edge can't
* block its siblings or crash the loop — a throw is treated as "no match" and,
* in dev mode, warned. This is the design's `routeForResult` pin-table target.
*
* The `by` on each return is decided at the same statement as the destination
* (8.5.0). That matters most in the case an outside observer cannot reconstruct:
* a declared edge and a same-turn model pick naming the SAME target. `D1 > D2`
* makes it a `'route'`, and only this function knows.
*/
/** The winning entry's recorded evidence, as a spreadable fragment: `{ witness }`
* when the rule was DATA and it yielded quotable text, `{}` otherwise (a `when`
* predicate, an unconditional entry, a match with nothing quotable). A throwing
* extractor is treated exactly as no evidence — a record is never worth a crash,
* and the hop itself already happened. */
function witnessOf(entry, ctx) {
if (!entry.witness)
return {};
try {
const found = entry.witness(ctx);
return found === undefined ? {} : { witness: found };
}
catch {
return {};
}
}
// FOLD · the one owner of where the cursor goes next, and which clause decided it (CursorMove)
// consumers read this and never re-derive it: the `nextSkill` and `explainNextSkill` hooks the Evaluate stage asks;
// the memoized `nextSkillForTriggers` that `deriveTrigger` reads; and `makeSupersededEntries`.
// NOT skillToolDescriptors: the read_skill description reads the ALREADY-RESOLVED cursor off the offer (offer.cursorId).
// detached: yes — a POJO plus a string id, memoized per pass so two readers get one decided value.
function makeResolveCursor(entries, routes, llmReadEntry = false) {
const isEntry = (id) => entries.some((e) => e.id === id);
return (ctx) => {
// ROLE VISIBILITY. The cursor never moves onto a skill this caller's role
// may not read (`isHiddenSkill`, the same rule the evaluator refuses
// activation by), so the stored cursor and the active set cannot
// disagree. Every clause below asks `may` before it names a destination:
// a keyword in the message, a declared edge, a tool proposal and a pick
// are all untrusted routes to the same place. A cursor already standing
// on a hidden skill (a restored conversation, a grant revoked mid-run) is
// treated as no cursor, so the turn re-enters through the visible entries.
const may = (id) => !isHiddenSkill(ctx, id);
const cur = ctx.currentSkillId !== undefined && may(ctx.currentSkillId) ? ctx.currentSkillId : undefined;
const from = cur !== undefined ? { from: cur } : {};
// The turn-start cascade's verdict (SG-C) — a PRECOMPUTED input consumed
// exactly as `pendingSkillPick` is, on iteration 1 only (the RouteTurn
// stage decided it off the hot loop; `userMessage` is fixed for the run,
// so iterations 2..N keep today's law byte-for-byte). Present only on
// cascade graphs (`classify` / `continuity: 'conversation'`); absent, not
// one line here behaves differently. A `menu`/`none` verdict carries no
// `to` and falls through — the model resolves the menu via the ordinary
// gated pick (D2), which is what keeps the offer and the verdict one
// machinery; the cold-start walk below is suppressed by the verdict's
// PRESENCE, so falling through can never enter an entry the record does
// not claim.
if (ctx.iteration === 1 && ctx.turnRoute?.to !== undefined && may(ctx.turnRoute.to)) {
const by = ctx.turnRoute.by === 'intent'
? 'intent'
: ctx.turnRoute.by === 'continuity'
? 'continuity'
: ctx.turnRoute.by === 'decider'
? 'decider'
: 'entry';
return {
...(ctx.turnRoute.from !== undefined && { from: ctx.turnRoute.from }),
to: ctx.turnRoute.to,
by,
// The cascade already extracted the tier-1 evidence (`turn_routed`
// carries it); the hop repeats it rather than re-deriving it, so the
// two records can never quote different text.
...(ctx.turnRoute.witness !== undefined && { witness: ctx.turnRoute.witness }),
};
}
if (cur === undefined) {
// Cold start: declaration-order first entry whose intent predicate passes.
// `.entryByRead()` skips this — there the library deliberately does NOT
// auto-pick; the model reads the menu and picks (below), so no entry body
// loads until it does. A turn whose cascade left a VERDICT on scope
// (`ctx.turnRoute`, SG-C) skips it the same way: suppression follows the
// verdict, not only the compile-time flag, because an entry-scorer graph
// opts into the cascade at MOUNT time (`continuity: 'conversation'`) —
// it compiles with `llmReadEntry` false, yet its cold `menu`/`none`
// verdict (a near-tie, or the scorer-throw fallback) must reach the
// model, never be overridden by the declaration-order walk while the
// record claims a menu is outstanding. RouteTurn writes the POJO only
// when the cascade DECIDED something, so a graph whose cold start still
// belongs to this walk (no rules, no tier 2 — e.g. after a dropped
// resume) keeps it.
if (!llmReadEntry && ctx.turnRoute === undefined) {
for (const e of entries) {
if (!may(e.id))
continue;
if (!e.when)
return { to: e.id, by: 'entry' };
try {
// The witness is extracted HERE, on the winner only — the losing
// rules never pay for evidence, and an unconditional/`when` entry
// has none to give (`witnessOf` answers undefined).
if (e.when(ctx))
return { to: e.id, by: 'entry', ...witnessOf(e, ctx) };
}
catch (err) {
warnMatcherThrew(`entry "${e.id}"`, err);
}
}
}
// A cold ACCEPTED tool proposal enters exactly where a cold model pick
// may (an entry — the reachable-from-cold set), and OUTRANKS the pick:
// deterministic tool evidence over a model guess, the same order the
// mid-run walk applies below (9.19.0).
const coldProposal = ctx.pendingToolTransition?.targetSkillId;
if (coldProposal !== undefined && isEntry(coldProposal) && may(coldProposal)) {
return { to: coldProposal, by: 'tool-proposal' };
}
// The model's pick becomes the starting cursor. This is what `.entryByRead()`
// has always done; 8.3.0 makes it true for EVERY start form, which is what
// makes the rules form's documented fallback ("no rule matched → the model
// picks from the read_skill menu") actually work. An entry's `when` is the
// AUTOMATIC pick's condition, not a lock on the manual one: the model asked
// for this skill by name and the gate allowed it.
if (ctx.pendingSkillPick !== undefined &&
isEntry(ctx.pendingSkillPick) &&
may(ctx.pendingSkillPick)) {
return { to: ctx.pendingSkillPick, by: 'model-pick' };
}
for (const e of entries) {
if (may(e.id) && ctx.activatedInjectionIds.includes(e.id)) {
return { to: e.id, by: 'model-pick' };
}
}
return { by: 'none' };
}
// D1 — transition: the whole tool batch, in CALL order (9.16.0). Per
// result, the first from-gated deterministic edge in DECLARATION order
// that fires names that result's target; the first RESULT with a target
// wins the cursor. Two orders, two jobs: declaration order is the
// author's tiebreak when one result could match several edges (the D1
// law, unchanged), call order is the run's tiebreak across results —
// the tool that returned first routed first. Before 9.16.0 only the
// LAST call of a batch was consulted, so identical batches routed
// differently depending on call order (the 9.15.0 L1 probe). A later
// result matching a DIFFERENT target is suppressed AND reported on the
// move (`conflict`), never silently dropped; a batch of one is
// byte-identical to the old singular walk. D1 still wins over a
// same-turn model pick — INCLUDING when both name the same target,
// which is why the cause is decided here and not inferred downstream
// from the destination.
let winner;
let losers;
// Guard evidence (9.51.0), collected at the very statements that decide:
// the winning edge's evaluation (verdict true) and — per guarded edge —
// the FIRST refusal whose preconditions a result met (verdict false).
// A guarded edge whose tool/status preconditions never matched leaves no
// record: its guard never decided anything.
let winnerGuard;
let guardsClosed;
let closedEdges;
for (const res of toolResultsOf(ctx)) {
let target;
let targetGuard;
for (const r of routes) {
if (r.fromId !== cur)
continue;
if (!isDeterministicRoute(r))
continue; // model edges don't auto-fire
if (!may(r.toId))
continue; // an edge into a hidden skill never fires
try {
if (r.guard !== undefined) {
// The guard is judged AFTER the edge's own tool/status
// preconditions, over the hop view (the six hop keys + the
// result's JSON fields — see skillGuard.ts). `evaluate` and the
// routing predicate are one compilation, so the recorded
// conditions ARE the ones that routed.
if (!edgePreconditionsPass(r, res))
continue;
const evaluation = r.guard.evaluate({
toolName: res.toolName,
result: res.result,
...(res.status !== undefined && { status: res.status }),
iteration: ctx.iteration,
userMessage: ctx.userMessage,
currentSkillId: cur,
});
const record = {
from: r.fromId,
to: r.toId,
toolName: res.toolName,
...(res.toolCallId !== undefined && { toolCallId: res.toolCallId }),
verdict: evaluation.verdict,
conditions: evaluation.conditions,
};
if (evaluation.verdict) {
target = r.toId;
targetGuard = record;
break;
}
if (!(closedEdges ??= new Set()).has(r)) {
closedEdges.add(r);
(guardsClosed ??= []).push(record);
}
continue;
}
if (routeMatches(r, res)) {
target = r.toId;
break;
}
}
catch (err) {
warnMatcherThrew(`route ${r.fromId}→${r.toId}`, err);
}
}
if (target === undefined)
continue;
const outcome = {
...(res.toolCallId !== undefined && { toolCallId: res.toolCallId }),
toolName: res.toolName,
target,
};
if (winner === undefined) {
winner = outcome;
winnerGuard = targetGuard;
}
else if (target !== winner.target)
(losers ??= []).push(outcome);
}
// The refusals ride WHATEVER move resulted — a closed guard explains a
// stay (or the hop that won instead) the same way `supersededEntries`
// explains a suppressed entry.
const closed = guardsClosed !== undefined ? { guardsClosed } : {};
if (winner !== undefined) {
return {
...from,
to: winner.target,
by: 'route',
...(winnerGuard !== undefined && { guard: winnerGuard }),
...closed,
...(losers !== undefined && { conflict: { winner, losers } }),
};
}
// D1.5 — the ACCEPTED tool proposal (9.19.0): no declared edge fired, so
// a `propose-transition` effect the gate validated moves the cursor. It
// sits BETWEEN the edges and the pick on purpose: a declared edge is the
// author's determinism (never overridden), a proposal is the tool
// author's determinism (code that shipped), a pick is the model's guess.
// A proposal of the CURRENT skill is a no-op stay, not a hop.
const proposal = ctx.pendingToolTransition?.targetSkillId;
if (proposal !== undefined && proposal !== cur && may(proposal)) {
return { ...from, to: proposal, by: 'tool-proposal', ...closed };
}
// D2 — the validated volunteer: no declared edge fired, so the model's own
// (already gated) pick moves the cursor. A pick of the CURRENT skill is a
// no-op stay, not a hop.
if (ctx.pendingSkillPick !== undefined &&
ctx.pendingSkillPick !== cur &&
may(ctx.pendingSkillPick)) {
return { ...from, to: ctx.pendingSkillPick, by: 'model-pick', ...closed };
}
return { ...from, to: cur, by: 'stay', ...closed }; // sticky stay — no edge out of cur fired
};
}
/**
* The suppression reporter (8.15.0) — the honesty half of the one law.
*
* A conditional entry is active exactly while the cursor is on it, so an entry whose
* own `when` matched while the graph is somewhere else is SUPPRESSED. Before 8.15.0
* it would have loaded beside the cursor's skill, which is precisely the double
* activation the law removes — so the run has to be able to SAY it happened, or an
* author debugging "why isn't my entry loading?" has nothing to read.
*
* Pure: one resolver pass plus, at most, one predicate call per OTHER conditional
* entry — O(E), inside the ceiling `memoizePerPass` sets for the whole evaluation.
* The RAW resolver (not the memoized projection) is used deliberately, matching the
* freshness policy of the other public resolvers: called once per iteration, always
* recomputed.
*
* A throwing predicate is swallowed HERE and reported by the evaluator instead
* (`skipped: 'predicate-threw'`) — one throw must not be told as two different
* stories on the same event.
*/
function makeSupersededEntries(entries, resolveCursor) {
const conditional = entries.filter((e) => e.when !== undefined);
if (conditional.length === 0)
return () => [];
return (ctx) => {
const to = resolveCursor(ctx).to;
// No cursor at all → the trigger falls back to the rule alone, so nothing is
// being superseded.
if (to === undefined)
return [];
const out = [];
for (const e of conditional) {
if (e.id === to)
continue; // this is the one that won
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (e.when(ctx))
out.push(e.id);
}
catch {
// Reported by the evaluator as `predicate-threw`, not as a suppression.
}
}
return out;
};
}
/**
* Memoize `nextSkill` per EVALUATION PASS.
*
* `evaluateInjections` hands ONE `ctx` object to every trigger in a pass, and
* (since 8.3.0) every graph-compiled trigger — entry and route target alike —
* asks `nextSkill(ctx)`. Without this, a graph with E entries and R routes pays
* O(E × (E + R)) predicate calls per iteration instead of O(E + R): a 15-entry
* regex router would re-run all 15 regexes 15 times per turn.
*
* Keyed on ctx IDENTITY in a WeakMap, so entries vanish with the context object
* and a fresh iteration always recomputes. Safe because the resolver is pure over
* `ctx` (route/entry predicates are documented as "pure + total"); the memo can
* only make a pass MORE self-consistent, never less. `graph.nextSkill` and
* `graph.explainNextSkill` are left un-memoized — they are public API called once
* per iteration, and a consumer that mutates a ctx object between calls must still
* get a fresh answer.
*
* Memoizes the whole `CursorMove` and projects `.to` for the triggers (8.5.0), so
* one pass computes the destination and the cause together, exactly once.
*/
function memoizePerPass(resolveCursor) {
const cache = new WeakMap();
return (ctx) => {
const hit = cache.get(ctx);
if (hit)
return hit.value;
const value = resolveCursor(ctx).to;
cache.set(ctx, { value });
return value;
};
}
function warnMatcherThrew(edge, err) {
devWarn(`agentfootprint skillGraph: ${edge} predicate threw — treated as no-match. ` +
`Predicates must be pure + total. ${err instanceof Error ? err.message : String(err)}`);
}
/** Compile a skill's incoming edges → one injection trigger (or null = keep the
* skill's default `llm-activated` trigger, i.e. model-reachable via read_skill).
*
* A route target B is active iff `nextSkill(ctx) === B`. That single expression
* delivers all three keystone properties from ONE source of truth:
* • `from`-gating — `nextSkill` only fires an edge `A→B` while the cursor is
* on A, so the edge no longer bleeds into an unrelated skill D (the v1 bug);
* • stickiness — when the cursor is on B and no edge leaves B, `nextSkill`
* returns B (sticky stay), so B re-activates each iteration;
* • clean handoff — the iteration a `B→C` edge fires, `nextSkill` returns C,
* so B deactivates the SAME step C activates — no double-active overlap.
* Because the loop's cursor-update stage is ALSO `currentSkillId = nextSkill(ctx)`,
* the trigger and the cursor can never disagree. */
function deriveTrigger(id, _skill, entries, routes, nextSkill, exclusiveEntries) {
const entry = entries.find((e) => e.id === id);
if (entry) {
// `.entryByRelevance()` / `.entryByRead()` make the entries EXCLUSIVE candidates:
// exactly ONE loads — the best match (embedder) or the model's pick (read_skill) —
// as the cursor, so only that entry's body lands (token-efficient). The same
// cursor-gated trigger as a route target delivers that for both modes.
if (exclusiveEntries) {
return { kind: 'rule', activeWhen: (ctx) => nextSkill(ctx) === id };
}
// Default (v1): a persistent base (always) or intent-conditional (rule).
// An unconditional entry is `always` — the cursor can add nothing to it. This is
// the ONE declared way to be co-active beside whatever the cursor is on, and it
// is untouched by 8.15.0.
if (!entry.when)
return { kind: 'always' };
// An intent-conditional entry is active EXACTLY WHILE THE CURSOR IS ON IT (8.15.0)
// — the same expression the exclusive-entry arm above and the route-target arm
// below already use. One law for a flat graph: a skill is active iff the cursor is
// on it, or it declared itself unconditional.
//
// 8.3.0 added the cursor clause to a rule-only trigger and left the rule clause
// standing as an OR. That leftover was the bug: an entry S with a `when` that
// routes to T stayed active on the hop (its rule reads the user's message, which
// does not change mid-turn), so S and T were both on the wire — two skill bodies,
// two tool sets — for a graph drawn as a single-file state machine. And it was the
// steady state, not a blip: every later iteration parked the cursor on T while S's
// rule still matched, so S came back. This finishes 8.3.0 rather than reverting
// it — both failures 8.3.0 names (a declared `step` INTO an entry skill; a
// `read_skill` pick onto one) are carried by the CURSOR clause, which survives.
//
// The rule is still evaluated FIRST, and deliberately: a throwing entry predicate
// must keep surfacing as the evaluator's `skipped: 'predicate-threw'`, which it
// only can if the trigger calls it. Its answer is then used for exactly one thing
// — the cold-start fallback below, when the graph has no cursor at all.
//
// A rule that matched while the cursor is elsewhere is a SUPPRESSION, and the run
// says so: `supersededEntries(ctx)` reports it and the Injection Engine stamps it
// on `agentfootprint.context.evaluated` as `supersededIds`.
const activeWhen = entry.when;
return {
kind: 'rule',
activeWhen: (ctx) => {
const matched = activeWhen(ctx);
const to = nextSkill(ctx);
if (to === id)
return true; // the cursor lands here — my turn
if (to !== undefined)
return false; // the graph is engaged elsewhere — not my turn
return matched; // no cursor at all → the rule alone
},
};
}
// Deterministic incoming edges (when / onToolReturn / onToolStatus) →
// cursor-gated + sticky.
const incoming = routes.filter(isDeterministicRoute).filter((r) => r.toId === id);
if (incoming.length === 0)
return null; // model-reachable — keep default trigger
return { kind: 'rule', activeWhen: (ctx) => nextSkill(ctx) === id };
}
/** Walk a decision tree → push each leaf skill (with its path-conjunction trigger,
* earlier-sibling negation baked into the path) plus predicate/skill nodes +
* branch edges for drawing. */
function compileTree(node, pathCond, out, parent, counter, path, scopeTools,
/** Registers each leaf under its id — the duplicate-id refusal and the
* skill-contract checks both read that registry (8.4.0). */
register) {
if (isDecisionNode(node)) {
const id = `d${counter.n++}`;
const label = node.label ?? 'decide';
out.nodes.push({ id, kind: 'predicate', label });
out.edges.push({
from: parent ? parent.id : null,
to: id,
kind: 'predicate',
label: parent?.branch,
});
compileTree(node.whenTrue, (ctx) => pathCond(ctx) && node.predicate(ctx), out, { id, branch: 'yes' }, counter, [...path, { label, branch: 'yes' }], scopeTools, register);
compileTree(node.whenFalse, (ctx) => pathCond(ctx) && !node.predicate(ctx), out, { id, branch: 'no' }, counter, [...path, { label, branch: 'no' }], scopeTools, register);
}
else {
if (node.flavor !== 'skill') {
throw new Error(`skillGraph.tree: leaf "${node.id}" is not a skill (flavor='${node.flavor}').`);
}
// Claim the id. The SAME object at two leaves is the merge below; two DIFFERENT
// skills under one id is refused here rather than merged (8.4.0) — the merge
// kept the first body and dropped the second one's silently.
register(node);
// The SAME skill may be the leaf of several branches ("ESXi questions" and
// "io questions" both route to the io-profile bundle). Compile it ONCE:
// merge repeated leaves into one injection whose trigger ORs the path
// predicates — pushing a second same-id injection would explode in
// Agent.injection()'s duplicate-id guard.
const existingIdx = out.skills.findIndex((skill) => skill.id === node.id);
if (existingIdx >= 0) {
const prev = out.skills[existingIdx];
const prevWhen = prev.trigger
.activeWhen;
const prevRouting = prev.metadata[SKILL_GRAPH_METADATA_KEY];
const allPaths = [
...(prevRouting.paths ?? (prevRouting.path ? [prevRouting.path] : [])),
path,
];
out.skills[existingIdx] = {
...prev,
trigger: {
kind: 'rule',
activeWhen: (ctx) => prevWhen(ctx) || pathCond(ctx),
},
metadata: {
...prev.metadata,
[SKILL_GRAPH_METADATA_KEY]: { ...prevRouting, paths: allPaths },
},
};
// Node already exists — add only the second parent edge (the drawing
// correctly shows two predicate diamonds converging on one leaf).
out.edges.push({
from: parent ? parent.id : null,
to: node.id,
kind: 'predicate',
label: parent?.branch,
});
return;
}
const routing = { via: 'tree', path };
// On-demand tools: a tree routes to exactly one leaf per iteration, so scope
// each leaf's tools to itself (`autoActivate: 'currentSkill'`) unless the user
// opted out (`scopeTools: false`) or the skill already declared its own mode.
const existingAuto = node.metadata?.autoActivate;
const autoActivate = existingAuto ?? (scopeTools ? 'currentSkill' : undefined);
out.skills.push({
...node,
trigger: { kind: 'rule', activeWhen: pathCond },
metadata: {
...node.metadata,
[SKILL_GRAPH_METADATA_KEY]: routing,
...(autoActivate && { autoActivate }),
},
});
out.nodes.push({ id: node.id, kind: 'skill', label: node.id });
out.edges.push({
from: parent ? parent.id : null,
to: node.id,
kind: 'predicate',
label: parent?.branch,
});
}
}
/**
* Dev-mode "exactly one leaf fires" monitor (backlog B11).
*
* A binary decision tree is exhaustive and non-overlapping BY CONSTRUCTION
* (each leaf's trigger conjoins its root→leaf predicates with earlier-sibling
* negation), so static analysis has nothing to check. The invariant breaks at
* RUNTIME only — when a predicate is impure/non-deterministic: the evaluator
* re-runs each `decide(...)` predicate once per leaf trigger, so a predicate
* that answers differently across those calls can fire 0 or ≥2 leaves.
*
* In dev mode (footprintjs `enableDevMode()`), each compiled leaf trigger is
* wrapped to tally fires per evaluation pass (keyed on the shared `ctx`
* identity — `evaluateInjections` passes one ctx object to every trigger in a
* pass). When all leaves have been evaluated for one ctx and the fired count
* is not exactly 1, a console.warn names the leaves. Production pays one
* `devMode()` check per evaluation; a throwing predicate is excluded here
* because the evaluator already reports it (`skipped: 'predicate-threw'`).
*/
function attachExactlyOneLeafMonitor(skills) {
const total = skills.length;
if (total < 2)
return; // single leaf — trivially exactly-one
const passes = new WeakMap();
for (let i = 0; i < skills.length; i++) {
const skill = skills[i];
const inner = skill.trigger.activeWhen;
skills[i] = {
...skill,
trigger: {
kind: 'rule',
activeWhen: (ctx) => {
if (!devMode())
return inner(ctx);
const fired = inner(ctx); // may throw → evaluator reports 'predicate-threw'
let pass = passes.get(ctx);
if (!pass) {
pass = { evaluated: 0, fired: [] };
passes.set(ctx, pass);
}
pass.evaluated += 1;
if (fired)
pass.fired.push(skill.id);
if (pass.evaluated === total) {
passes.delete(ctx); // reset so a reused ctx object starts a fresh pass
if (pass.fired.length !== 1) {
devWarn(pass.fired.length === 0
? `agentfootprint skillGraph.tree: NO leaf fired this iteration (expected exactly one). ` +
`The tree is exhaustive by construction, so a decide() predicate likely returned ` +
`different answers across leaf evaluations — predicates must be pure and deterministic. ` +
`Leaves: ${skills.map((s) => s.id).join(', ')}.`
: `agentfootprint skillGraph.tree: ${pass.fired.length} leaves fired simultaneously ` +
`(expected exactly one): ${pass.fired.join(', ')}. Each decide() predicate is ` +
`re-evaluated per leaf, so impure/non-deterministic predicates break if/else exclusivity.`);
}
}
return fired;
},
},
};
}
}
/** Routing provenance for a flat entry/route skill (the v1 model). */
function routingFor(id, entries, routes) {
const entry = entries.find((e) => e.id === id);
if (entry)
return {
via: 'entry',
...(entry.label && { label: entry.label }),
...(entry.match && { match: entry.match }),
};
const incoming = routes.filter(isDeterministicRoute).filter((r) => r.toId === id);
const first = incoming[0];
if (first) {
return {
via: 'route',
from: first.fromId,
...(first.label && { label: first.label }),
triggerKind: first.onToolReturn && !first.onToolStatus ? 'on-tool-return' : 'rule',
...(first.guard && { guard: first.guard.data }),
};
}
return { via: 'model' }; // model-reachable via read_skill
}
function renderMermaid(nodes, edges) {
const kindById = new Map(nodes.map((n) => [n.id, n.kind]));
const ref = (id) => (kindById.get(id) === 'predicate' ? id : nodeId(id));
const lines = ['flowchart TD', ' __start__([▶ start])'];
for (const n of nodes) {
lines.push(n.kind === 'predicate'
? ` ${n.id}{"${n.label ?? n.id}"}` // predicate → diamond
: ` ${nodeId(n.id)}["${n.label ?? n.id}"]`);
}
for (const e of edges) {
const from = e.from === null ? '__start__' : ref(e.from);
const arrow = e.kind === 'model' ? '-.->' : '-->'; // model edges dashed
// An explicit label wins unchanged; a data matcher captions its entry edge
// and a data guard captions its guard-only route edge (that is half the
// point of declaring the condition as data — it can be drawn). A guard
// composed with a tool/status part is already folded into that derived
// label at build.
const caption = e.label ??
(e.match ? mermaidMatchCaption(e.match) : e.guard ? mermaidGuardCaption(e.guard) : undefined);
const label = caption ? `|${caption}|` : '';
lines.push(` ${from} ${arrow}${label} ${ref(e.to)}`);
}
return lines.join('\n');
}
//# sourceMappingURL=skillGraph.js.map