UNPKG

agentfootprint

Version:

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

247 lines • 12.1 kB
/** * 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. */ import { toWireJson, withWireErrors } from '../../lib/wireJson.js'; function preview(value, max) { let s; if (typeof value === 'string') s = value; else { try { s = 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 = 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 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). */ export 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(); }, }; } //# sourceMappingURL=evidenceRecorder.js.map