agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
135 lines • 5.61 kB
JavaScript
/**
* ToolLineageRecorder — derives the tool→tool DATA-FLOW graph of a run.
*
* Why this exists: in a ReAct agent a tool's output flows back to the LLM as
* text, and the LLM decides the NEXT tool's arguments — so the data dependency
* between tools (e.g. FLOGI's FCID reused as io_profile's `initiator_id`) never
* touches footprintjs's shared scope, and `causalChain` can't reconstruct it.
* This recorder rebuilds that graph from the tool emit stream by VALUE
* PROVENANCE: when a tool call's argument value equals a distinctive value
* produced by an EARLIER iteration's tool result, it records an edge
* (producer → consumer).
*
* It is a HEURISTIC, not ground truth — it matches by value, so it is accurate
* for distinctive identifiers (FCIDs, WWPNs, ids, NAAs, hostnames) and
* deliberately conservative:
* - short / common values (below `minValueLength`) are ignored,
* - numbers are ignored by default (coincidental matches are likely),
* - same-iteration (parallel) tool calls never link to each other — the LLM
* chose all their args from the same prior context, before any of them ran.
*
* No core changes: it consumes the existing `agentfootprint.stream.tool_start`
* (args) and `agentfootprint.stream.tool_end` (result) emits. Attach via
* `.recorder(toolLineageRecorder())` and read `getLineage()` after the run; the
* lens (or any consumer) can render the returned graph.
*
* Convention 1 — one purpose: it derives ONE artifact (the lineage graph) from
* the emit channel. Convention 4 — run-scoped: it resets when a new run starts
* (the `pipelineId` changes).
*/
const TOOL_START = 'agentfootprint.stream.tool_start';
const TOOL_END = 'agentfootprint.stream.tool_end';
const ITER_START = 'agentfootprint.agent.iteration_start';
export function toolLineageRecorder(options = {}) {
const minLen = options.minValueLength ?? 4;
const matchNumbers = options.matchNumbers ?? false;
const maxDepth = options.maxDepth ?? 6;
let currentIteration = 0;
let lastPipelineId;
const nodes = [];
const edges = [];
/** distinctive value → the tool call that last produced it (in a result). */
const producers = new Map();
/** toolCallId → ref, so `tool_end` can attribute its result. */
const byId = new Map();
function collectValues(x, depth, out) {
if (x == null || depth > maxDepth)
return;
if (typeof x === 'string') {
if (x.length >= minLen)
out.add(x);
}
else if (typeof x === 'number') {
if (matchNumbers && Number.isFinite(x))
out.add(String(x));
}
else if (Array.isArray(x)) {
for (const v of x)
collectValues(v, depth + 1, out);
}
else if (typeof x === 'object') {
for (const v of Object.values(x))
collectValues(v, depth + 1, out);
}
}
function reset() {
currentIteration = 0;
nodes.length = 0;
edges.length = 0;
producers.clear();
byId.clear();
}
return {
id: options.id ?? 'tool-lineage',
onEmit(e) {
// Run-scope: a changed pipelineId means a new run — drop prior state.
if (lastPipelineId !== undefined && e.pipelineId !== lastPipelineId)
reset();
lastPipelineId = e.pipelineId;
if (e.name === ITER_START) {
const it = e.payload?.iterIndex;
if (typeof it === 'number')
currentIteration = it;
return;
}
if (e.name === TOOL_START) {
const p = e.payload;
if (!p?.toolCallId)
return;
const ref = {
runtimeStageId: e.runtimeStageId,
toolCallId: p.toolCallId,
toolName: p.toolName ?? '(unknown)',
iteration: currentIteration,
};
nodes.push(ref);
byId.set(ref.toolCallId, ref);
// Link arg values to values produced by EARLIER iterations only.
const argVals = new Set();
collectValues(p.args, 0, argVals);
const emitted = new Set(); // dedupe (producer,value) edges
for (const v of argVals) {
const producer = producers.get(v);
if (producer && producer.iteration < ref.iteration) {
const key = `${producer.toolCallId}