agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
251 lines • 12.3 kB
JavaScript
;
/**
* causalEvidenceRecorder — the evidence bridge (backlog Phase-1 #5).
*
* Harvests, DURING the run, everything a causal snapshot needs beyond
* (query, finalContent) — from events the engine already fires:
*
* stream.tool_start/tool_end → ToolCallRecord (name, args, resultPreview, errored)
* — none for a bracket carrying `notDispatched`
* (9.113.0): that call never ran
* stream.llm_end → tokenUsage accumulation + iteration high-water
* agent.turn_start/turn_end → durationMs (+ authoritative totals when seen)
* FlowRecorder.onDecision → DecisionRecord with footprintjs decide()/select()
* operator-level evidence (rule, conditions, chosen)
* context.evaluated routing → DecisionRecord per skill the graph routed to
*
* Pattern: CombinedRecorder (Convention 1 — single purpose: evidence
* accumulation); per-turn reset anchored on `agent.turn_start`
* (Convention 4 — executor `clear()` resets between runs; same-
* executor pause/resume PRESERVES pre-pause evidence by design).
* PII note: tool args/results and decide() evidence persist into snapshots.
* A causal memory is working state — a later run replays it to the
* model — so the Agent feeds this recorder's tool calls from its
* REAL-value path: under an agent's `redact` the snapshot still keeps
* the real arguments and results, as it keeps the real question and
* answer (`writeSnapshot` reads those from the conversation the run
* computed on). The one part it cannot read real is the DECISIONS:
* footprintjs serves its flow channel (`onDecision` / `onSelected`)
* under the run's policy, so a selected value inside decide()
* evidence is the placeholder there (a named limit,
* `src/redaction/README.md`). Values are bounded (`maxPreviewChars`
* for results, `maxFieldChars` for args/evidence); treat the snapshot
* store as PII-bearing and protect it accordingly.
*
* The Agent attaches this automatically when a CAUSAL memory is mounted and
* threads `collect` into the memory write mount (`evidenceSource`) — so
* `writeSnapshot` persists real evidence instead of zeros.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.causalEvidenceRecorder = void 0;
const wireJson_js_1 = require("../../lib/wireJson.js");
function preview(value, max) {
let s;
if (typeof value === 'string')
s = value;
else {
try {
s = (0, wireJson_js_1.toWireJson)(value);
}
catch {
s = String(value);
}
}
// `toWireJson(undefined)` has no text; an absent result previews as itself.
if (typeof s !== 'string')
s = String(value);
return s.length > max ? `${s.slice(0, max)}…` : s;
}
/** Bound a record-ish value: oversized serializations become a truncated
* preview marker so snapshots can't grow unbounded (and PII exposure is
* capped). Small values pass through untouched. */
function bounded(value, max) {
if (value === undefined)
return undefined;
try {
const s = (0, wireJson_js_1.toWireJson)(value);
// Kept as a value (not the text), so rendered the same way: an Error in
// it keeps its name, message, code and cause and nothing else.
if (s.length <= max)
return (0, wireJson_js_1.withWireErrors)(value);
return { __truncated: `${s.slice(0, max)}…` };
}
catch {
return { __truncated: String(value).slice(0, max) };
}
}
/** Build the evidence-harvesting recorder. Attach via `.watch(rec)` (the
* Agent does this automatically for CAUSAL memories). */
function causalEvidenceRecorder(options = {}) {
const maxPreview = options.maxPreviewChars ?? 200;
const maxField = options.maxFieldChars ?? 2000;
// ── per-turn accumulators (reset on agent.turn_start; executor clear()
// resets between runs; pause/resume keeps pre-pause evidence) ──
let decisions = [];
let toolCalls = [];
let pendingTools = new Map();
let tokens = { input: 0, output: 0 };
let iterations = 0;
let turnStartMs;
let authoritative;
const reset = () => {
decisions = [];
toolCalls = [];
pendingTools = new Map();
tokens = { input: 0, output: 0 };
iterations = 0;
turnStartMs = undefined;
authoritative = undefined;
};
return {
id: options.id ?? 'causal-evidence',
onEmit(event) {
const { name, payload: raw } = event;
// An Agent feeds this from its REAL-value path (`core/Agent.ts`): a
// causal memory is working state a later run replays to the model, so
// its evidence keeps the real tool calls whatever the agent's `redact`.
// Attached to an executor by hand it receives that executor's emits as
// served there — a placeholder string for an event `emitPatterns`
// selects whole, whose fields then read as absent.
const whole = raw !== null && typeof raw === 'object' ? undefined : raw;
const payload = (whole === undefined ? raw : {});
switch (name) {
case 'agentfootprint.agent.turn_start':
// A new turn on the same executor — start fresh (one snapshot per turn).
reset();
turnStartMs = Date.now();
break;
case 'agentfootprint.stream.tool_start': {
// A call the batch settlement answered (9.113.0) passed no args to a
// tool and got no result back — not tool evidence, so no record.
// Read off the bracket's typed `notDispatched`, never inferred from
// its `durationMs: 0` or its sentence: filed, it would put a call
// that never ran into the snapshot as a tool call, with the
// library's sentence as its result, and into the DECISIONS
// projection a later prompt replays.
if (payload.notDispatched !== undefined)
break;
const id = String(payload.toolCallId ?? '');
pendingTools.set(id, {
name: String(payload.toolName ?? 'unknown'),
args: bounded((payload.args ?? {}), maxField) ?? {},
});
break;
}
case 'agentfootprint.stream.tool_end': {
if (payload.notDispatched !== undefined)
break; // settled — see tool_start
const id = String(payload.toolCallId ?? '');
const started = pendingTools.get(id);
pendingTools.delete(id);
toolCalls.push({
name: started?.name ?? 'unknown',
args: started?.args ?? {},
resultPreview: preview(whole ?? payload.result, maxPreview),
errored: payload.error === true,
});
break;
}
case 'agentfootprint.stream.llm_end': {
const usage = payload.usage;
tokens = {
input: tokens.input + (usage?.input ?? 0),
output: tokens.output + (usage?.output ?? 0),
};
const iter = Number(payload.iteration ?? 0);
if (iter > iterations)
iterations = iter;
break;
}
case 'agentfootprint.context.evaluated': {
// Skill-graph routing provenance → one DecisionRecord per routed skill.
const routing = payload.routing;
if (Array.isArray(routing)) {
for (const r of routing) {
decisions.push({
stageId: 'skill-graph',
chosen: String(r.injectionId ?? r.id ?? 'unknown'),
...(r.label !== undefined || r.via !== undefined
? { rule: r.label ?? `via ${r.via}` }
: {}),
...(r.path !== undefined && {
evidence: { path: r.path },
}),
});
}
}
break;
}
case 'agentfootprint.agent.turn_end': {
// Authoritative totals when the write runs after turn_end.
authoritative = {
iterations: Number(payload.iterationCount ?? iterations),
input: Number(payload.totalInputTokens ?? tokens.input),
output: Number(payload.totalOutputTokens ?? tokens.output),
durationMs: Number(payload.durationMs ?? 0),
};
break;
}
default:
break;
}
},
/** footprintjs FlowRecorder channel — decide() decision evidence.
* FlowDecisionEvent carries `decider` (display name), `chosen`, optional
* `evidence` from decide(), and `traversalContext.stageId` (the stable,
* subflow-prefixed stage id — preferred for DecisionRecord.stageId). */
onDecision(event) {
const stageId = event.traversalContext?.stageId ?? event.decider;
// Internal agent plumbing (the cache-gate decider) is not domain
// decision evidence. `includes` (not startsWith): in reactMode
// 'dynamic-grouped' the names are double-prefixed
// ('sf-llm-call/sf-cache/…').
if (String(event.chosen ?? '').includes('sf-cache/') || String(stageId).includes('sf-cache'))
return;
const evidence = event.evidence;
decisions.push({
stageId: String(stageId),
chosen: String(event.chosen ?? 'unknown'),
...(evidence?.label !== undefined || evidence?.rule !== undefined
? { rule: String(evidence.label ?? evidence.rule) }
: {}),
...(evidence !== undefined && {
evidence: bounded(evidence, maxField),
}),
});
},
/** footprintjs FlowRecorder channel — select() selection evidence. */
onSelected(event) {
const stageId = event.traversalContext?.stageId ?? event.parent;
if (String(stageId).includes('sf-cache'))
return;
// The agent's own Context slot-fork is a selector — plumbing, not domain.
if (String(stageId).includes('context') && event.selected.every((s) => s.startsWith('sf-')))
return;
decisions.push({
stageId: String(stageId),
chosen: event.selected.join(', '),
...(event.evidence !== undefined && {
evidence: bounded(event.evidence, maxField),
}),
});
},
collect() {
return {
iterations: authoritative?.iterations ?? iterations,
decisions: [...decisions],
toolCalls: [...toolCalls],
durationMs: authoritative?.durationMs ?? (turnStartMs !== undefined ? Date.now() - turnStartMs : 0),
tokenUsage: {
input: authoritative?.input ?? tokens.input,
output: authoritative?.output ?? tokens.output,
},
};
},
clear() {
reset();
},
};
}
exports.causalEvidenceRecorder = causalEvidenceRecorder;
//# sourceMappingURL=evidenceRecorder.js.map