UNPKG

agentfootprint

Version:

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

262 lines • 12.6 kB
/** * routeRecorder — records the skill-graph route a run actually took. * * A passive observer that reconstructs, hop by hop, which skill the agent was in, * where it went next, and WHY — by COMPOSING already-shipped events (no engine * change): `agentfootprint.context.evaluated` (its `routing[]` carries via/from/ * label per active skill-graph injection) + `agentfootprint.skill.rejected` (an * out-of-reach read_skill) + `stream.tool_start` (the tool that drove a hop). * * Also folds in the GREY-AREA GOVERNORS (observability tier): it detects * oscillation (A→B→A→B within `pingPongWindow`) and a run of consecutive rejected * `read_skill` jumps (`maxRejectedRetries`), reported via `getTrips()`. These LABEL * the trace (`onTrip:'stay'` semantics) — the hard "always stops" guarantee remains * the agent's iteration cap; a runtime force-stop is a deferred follow-on. * * Pattern: CombinedRecorder (Convention 1 — single purpose: route evidence). Owns a * `SequenceStore<RouteHop>`. Convention 4: resets on a new `runId`. * Role: Tier-3 /observe recorder — `Agent.create(...).watch(routeRecorder())`. * Powers the lens, the "Why this skill?" panel, and paper route figures. */ import { SequenceStore } from 'footprintjs/trace'; /** A human-readable one-line reason for a hop. Exported (pure). */ export function formatRouteHop(hop) { switch (hop.outcome) { case 'entry': return `entered "${hop.toSkill}"`; case 'route': return `"${hop.fromSkill}" → "${hop.toSkill}"${hop.edgeLabel ? ` (${hop.edgeLabel})` : ''}${hop.lastTool ? ` on ${hop.lastTool}` : ''}`; case 'model-pick': // Deliberately carries no edge caption: no declared edge fired, and lending it // one is the exact misattribution this outcome exists to end. return hop.fromSkill === undefined ? `read_skill("${hop.toSkill}") accepted at cold start` : `read_skill("${hop.toSkill}") accepted from "${hop.fromSkill}"`; case 'tool-proposal': // Same no-caption rule as model-pick: no declared edge fired — a tool's // accepted propose-transition effect moved the cursor. return hop.fromSkill === undefined ? `a tool's transition proposal accepted at cold start → "${hop.toSkill}"` : `a tool's transition proposal moved "${hop.fromSkill}" → "${hop.toSkill}"`; case 'intent': // The turn-start classifier's decisive win — the numbers ride turn_routed. return hop.fromSkill === undefined ? `the message decisively matched "${hop.toSkill}" (turn-start classifier)` : `"${hop.fromSkill}" → "${hop.toSkill}" (the message decisively matched it)`; case 'continuity': return `carried from last turn — still in "${hop.toSkill}"`; case 'decider': return `the routing decider read the menu and chose "${hop.toSkill}"`; case 'stay': return `stayed in "${hop.toSkill}"`; case 'rejected': return `read_skill("${hop.requestedSkill}") rejected from "${hop.fromSkill ?? 'cold start'}" — reachable: ${(hop.reachable ?? []).join(', ') || '(none)'}`; } } /** * The hop's cause as the GRAPH reported it (`context.evaluated`'s `cursorMove.by`, * 8.5.0) — or `undefined` when the event carries none, so the caller can fall back * to inferring it from the destination. * * `'none'` maps to `undefined` on purpose: it means the graph has no cursor at all * this iteration, which is not a hop cause. A decision `tree()` reports it every * iteration, and there the inferred outcome (entry, then stay) is the right story: * a tree really does re-route by predicate rather than move a cursor. */ function causeOf(cursorMove) { if (cursorMove === null || typeof cursorMove !== 'object') return undefined; const by = cursorMove.by; switch (by) { case 'entry': case 'route': case 'model-pick': case 'tool-proposal': case 'intent': case 'continuity': case 'decider': case 'stay': return by; default: return undefined; } } /** The current cursor skill from a `context.evaluated` routing[] — prefer a * transitioned-into route target, then an entry, then a tree leaf, then model. */ function cursorFromRouting(routing) { for (const via of ['route', 'entry', 'tree', 'model']) { const e = routing.find((r) => r.via === via && typeof r.injectionId === 'string'); if (e) { return { id: e.injectionId, ...(typeof e.from === 'string' ? { from: e.from } : {}), ...(typeof e.label === 'string' ? { label: e.label } : {}), }; } } return undefined; } /** Build the route recorder. */ export function routeRecorder(options = {}) { const pingPongWindow = options.pingPongWindow ?? 4; const maxRejectedRetries = options.maxRejectedRetries ?? 3; const store = new SequenceStore(); const trips = []; const transitions = []; // toSkill of 'route'/'entry' hops, for oscillation let lastRunId; let cursor; let lastTool; let consecutiveRejected = 0; let trippedRejectedCap = false; const reset = () => { store.clear(); trips.length = 0; transitions.length = 0; cursor = undefined; lastTool = undefined; consecutiveRejected = 0; trippedRejectedCap = false; }; const detectPingPong = (iteration) => { if (transitions.length < pingPongWindow) return; const recent = transitions.slice(-pingPongWindow); const distinct = new Set(recent); // [X,Y,X,Y,...]: exactly two skills, strictly alternating across the window. if (distinct.size === 2 && recent.every((s, i) => s === recent[i % 2])) { const skills = [...distinct]; if (!trips.some((t) => t.kind === 'ping-pong' && t.iteration === iteration)) { trips.push({ kind: 'ping-pong', iteration, skills, detail: `oscillating between "${skills[0]}" and "${skills[1]}" over the last ${pingPongWindow} hops`, }); } } }; return { id: options.id ?? 'route', onEmit(event) { const payload = event.payload; if (payload === null || typeof payload !== 'object') return; const p = payload; switch (event.name) { case 'agentfootprint.stream.tool_start': { // `lastTool` is the tool whose RESULT drove the hop. A bracket // carrying `notDispatched` (9.113.0) is a call the paused batch never // ran — it has no result, so it can never be the one that drove it. if (p.notDispatched !== undefined) break; if (typeof p.toolName === 'string') lastTool = p.toolName; break; } case 'agentfootprint.context.evaluated': { const routing = Array.isArray(p.routing) ? p.routing : []; const cur = cursorFromRouting(routing); if (cur === undefined) break; // no skill-graph routing this iteration const iteration = Number(p.iteration ?? 0); const from = cursor; // The CAUSE, from the graph's own resolver when it reported one (8.5.0). // Falling back to inferring it from the destination — what this recorder // did before — is kept for a graph too old to explain itself, and is the // only path that can still mistake a model pick for a declared edge. const cause = causeOf(p.cursorMove); const outcome = cause ?? (cursor === undefined ? 'entry' : cur.id !== cursor ? 'route' : 'stay'); // An edge caption belongs to a hop a declared EDGE drove. On a model pick // the caption is the label of an edge that did not fire. const wearsEdgeLabel = outcome === 'route' || outcome === 'entry'; const hop = { runtimeStageId: event.runtimeStageId, iteration, ...(from !== undefined ? { fromSkill: from } : {}), toSkill: cur.id, outcome, why: '', ...(wearsEdgeLabel && cur.label !== undefined ? { edgeLabel: cur.label } : {}), ...(outcome === 'route' && lastTool !== undefined ? { lastTool } : {}), }; const finished = { ...hop, why: formatRouteHop(hop) }; store.push(finished); const moved = outcome !== 'stay'; if (moved) { transitions.push(cur.id); detectPingPong(iteration); } cursor = cur.id; // Only a CURSOR MOVE breaks a rejection run (8.5.0). Resetting on every // evaluation made `maxRejectedRetries` untrippable: one evaluation fires // between every pair of rejections, so the count never passed 1 and a model // could re-ask for the same out-of-reach skill until the iteration cap. A // 'stay' is precisely the case where the model asked and got nowhere. if (moved) { consecutiveRejected = 0; trippedRejectedCap = false; } break; } case 'agentfootprint.skill.rejected': { const iteration = Number(p.iteration ?? 0); const hop = { runtimeStageId: event.runtimeStageId, iteration, ...(typeof p.currentSkillId === 'string' ? { fromSkill: p.currentSkillId } : {}), outcome: 'rejected', why: '', ...(typeof p.requestedId === 'string' ? { requestedSkill: p.requestedId } : {}), reachable: Array.isArray(p.allowed) ? p.allowed : [], }; store.push({ ...hop, why: formatRouteHop(hop) }); consecutiveRejected += 1; // ONE trip per run of rejections, re-armed when the cursor next moves. // The old guard was per-ITERATION, so once the count passed the cap every // further iteration pushed another identical trip. if (consecutiveRejected >= maxRejectedRetries && !trippedRejectedCap) { trippedRejectedCap = true; trips.push({ kind: 'rejected-cap', iteration, skills: typeof p.currentSkillId === 'string' ? [p.currentSkillId] : [], detail: `${consecutiveRejected} consecutive out-of-reach read_skill jumps`, }); } break; } default: break; } }, // Convention 4 — reset on a new run. onRunStart(event) { const runId = event.traversalContext?.runId; if (runId !== undefined && runId !== lastRunId) { reset(); lastRunId = runId; } }, getPath() { const path = []; for (const hop of store.getAll()) { if (hop.toSkill !== undefined && hop.toSkill !== path[path.length - 1]) path.push(hop.toSkill); } return path; }, getHops() { return store.getAll(); }, getRejections() { return store.getAll().filter((h) => h.outcome === 'rejected'); }, getTrips() { return [...trips]; }, clear() { reset(); }, }; } //# sourceMappingURL=RouteRecorder.js.map