Debug

Ask your agent "why?"

.selfExplain() lets the agent answer follow-up why-questions about its OWN previous turn from the recorded trace — one builder call, the trace tools gated until asked, evidence bound to the completed run (never the in-flight one), and a cheaper-model delegate mode.

A user asks your refunds agent to approve order A-1001. It does. The next message is "why did you approve it?" Without an API, your only move is to paste the whole trace into another LLM and pay full price for it — every turn. .selfExplain() makes it one builder call: the agent answers the follow-up from its own recorded run, not from "memory of memory."

One call

const agent = Agent.create({ provider, model: 'mock-1', maxIterations: 6 })  .system('You are a refunds assistant. Policy: refunds within 30 days of purchase.')  .tool(lookupOrder)  .selfExplain({ instruction: 'Mention the order id in your explanation.' })  .build();

That's it. Day to day nothing changes — the agent runs with its production tools. When a user asks a why-question, the agent answers from the trace of its previous completed turn, citing the exact evidence (the order was 12 days old, inside the 30-day window) instead of reconstructing a plausible-sounding reason.

It stays out of the way until asked

.selfExplain() mounts one skill. Day to day the tool catalog carries only that skill's activation row — your production tools are untouched, and the model never sees the trace tools. When the user asks "why…", the LLM activates the skill (read_skill), and that iteration alone gains the trace tools, late-bound to the previous run:

  • run_overview — the catalog of what ran (steps, stages, loops, errors, honesty markers, and what the run cost)
  • find_context_errors — what did this run contradict itself about? The Context Integrity findings the library already caught, each with the step it was filed at and the drill that opens the state behind it. When no finding evidence was captured it says so — never "no errors found", and a green verdict is printed only when the checker rows sum to at least one checked encounter. Its kind filter offers exactly the classes this build ships a check for (see the tool reference below).
  • find_in_trace — free text in, step ids out. The user's words ("order 7712") searched across stage names, state keys, every committed value and the narrative; each hit line ends with the exact call that opens it. The first move when the question names something you have no id for.
  • trace_node — inspect one step by id (its reads / writes / data + control parents / errors)
  • who_wrote — which step last wrote a value (backtrack a key to its source)
  • get_value — fetch one value by id + key, lazily — only the piece it needs, never the whole trace
  • trace_slice — the backward causal slice around a node
  • backtrack — variable-first: why is K what it is, and (with element) which step produced K[i]
  • inspect_tool_call — one tool call end to end: the args the model proposed, the args it actually ran with (they differ when a governance rule rewrote them), the result, the outcome, the duration, and the step that ran it
  • inspect_tool_run — inside one tool call, when that tool kept its own record (see Through the tool boundary below)
  • inspect_subflow — open one subflow mount's own record
  • attention_list / root_cause / impact — read recorded abnormal nodes, dependency roots and downstream impact with explicit proof references and coverage gaps (see Recorded cause and impact)
  • read_narrative — the run's plain-English story, paginated

So the cost model is honest: the production catalog stays clean, and the trace tools appear exactly once, on the iteration where the question was asked. The example prints the per-call catalog to prove it.

The expanded catalog is a real bulge on the tools slot for that one iteration. The 2000-char contextBudget.tools default is a signal, not a limiter (nothing is ever truncated) — an agent that opts into .selfExplain() should measure its catalog and raise that budget as needed.

The same is true of the bytes. The toolpack is the largest single module in the library, and since 9.94.0 it is loaded through a dynamic import() on the first iteration the skill is active — not at build(), not on a turn where nobody asked why, and never for an agent that did not call .selfExplain(). A bundler puts it in its own chunk, so a browser consumer who imports Agent does not ship it; Node loads it through the same require one microtask later. Nothing about the run changes with the load: the catalog on an idle iteration is still answered synchronously, and a cold mount and a warm one record the same events in the same order (test/trace-toolpack/lazyMount.test.ts pins both; browserGraph.test.ts fences the bundle graph).

Through the tool boundary

inspect_tool_call used to end at a wall:

⚠ boundary: what happened INSIDE the tool is not traced — this is the envelope
  (arguments in, result out) plus what the run itself decided about it.

That is the true answer for a tool that reaches into someone else's system. It was needlessly true for a tool that is a footprintjs flowchart: that tool recorded every stage it ran, and then threw the recording away, because nobody was holding it.

flowchartAsTool({ keepRecord: true }) holds it. Each invocation's inner record is filed under the toolCallId the outer run already uses to name that call, so the wall becomes a rung:

inside: this tool kept its own record of the run — 4 step(s), ok.
        Descend with inspect_tool_run({ toolCallId: 'c1' }).

inspect_tool_run then opens that inner run with the same drill vocabulary, one level down — the inner views are the pack's own tools run over the inner artifacts through openRecording, so there is no second implementation to drift:

inspect_tool_run({ toolCallId: 'c1' })                                  // the inner run's overview
inspect_tool_run({ toolCallId: 'c1', find: 'rain' })                    // free text → INNER ids
inspect_tool_run({ toolCallId: 'c1', variable: 'advice' })              // why is this inner value what it is
inspect_tool_run({ toolCallId: 'c1', runtimeStageId: 'validate#1' })    // one inner step
inspect_tool_run({ toolCallId: 'c1', runtimeStageId: 'validate#1', key: 'rainChancePct' })  // the field, in full

Four things keep it honest:

  • Off by default. A retained record is retained memory. keepRecord is the caller agreeing to it; keepRecordLimit (default 20) bounds it as an LRU window, and a session that dropped older records says so rather than answering "not found".
  • Two id namespaces, said out loud. Inner runtimeStageIds name steps of the tool's chart, not of the run that called it. Every answer repeats that, and the outer trace_node / get_value do not accept them.
  • Honest absence names the switch. With no record kept, inspect_tool_run names keepRecord and points back at the envelope — it never answers emptily.
  • Redaction still governs — the whole record, not only its log. footprintjs scrubs at commit time, so a key covered by flowchartAsTool's redact policy — joined with the calling run's own (ctx.redact, an agent's redact) — never enters the inner commit log at all; and since 9.89.1 the kept record's sharedState, its subflow states and the string the tool returns are all served from the same redacted view (one owner, servableSnapshot), so a kept record cannot serve what the policy removed from any field, and the (redacted by policy) flag reads the same inside a tool as outside one. Under a policy the record omits initialState — footprintjs's own rule for the redacted view — so a fold of it reports basis: 'log-only'.

One thing an inner record carries that a saved recording never can: control edges. The record is live in process, so the wrapping tool attaches a fresh controlDepRecorder() per invocation and inner slices show the decision rule that routed execution — ← [control: Rain chance at or above the 60% bike threshold].

The builder does the wiring: .build() collects the store off every statically registered (and skill-declared) chart tool and hands one merged lookup to the trace artifacts. A chart tool delivered through a .toolProvider() is resolved per iteration and therefore not collected at build time — register it statically as well if you want the descent.

Wiring it by hand

Assembling artifacts yourself (a scripted auditor, a custom debugger, a chart tool mounted outside an Agent)? The store is a small, exported piece of machinery, and TraceToolpackArtifacts takes it as an optional innerRuns:

import {
  innerRunsOf, innerRunStore, mergeInnerRuns, traceToolpack,
} from 'agentfootprint/observe';

const records = innerRunsOf(adviceTool)!;               // the store the tool is holding
const tools = traceToolpack({ snapshot, innerRuns: records });
  • innerRunsOf(tool) reads a tool's store, or undefined when it keeps none. It is total — a plain tool, a string, null all answer "no records" rather than throwing. It finds the store under INNER_RUN_RECORDS, a registry symbol rather than a named property, so a consumer's own tool cannot collide with it.
  • innerRunStore(limit?) builds one directly (an InnerRunStore: the read side plus keep). DEFAULT_INNER_RUN_LIMIT is that default of 20; a limit below 1 is clamped, because a store that keeps nothing is keepRecord: false said with a number that lies.
  • mergeInnerRuns(lookups) composes several stores into one InnerRunLookup — an agent may mount more than one chart tool, and the model asking to descend into a call should not have to know which tool produced it. An empty list gives undefined, not an empty lookup that would answer "nothing was kept" when the truth is "nothing keeps anything".
  • An InnerRunRecord carries the toolCallId, the toolName, an InnerRunOutcome ('ok' | 'error' | 'paused' — a failed inner run is still a complete record), the step count, the { snapshot, structure } recording, the live control-dependence lookup, and — if capture itself failed — the problem that explains why there is no recording. InnerRunSummary is that row without the recording, which is what the unknown-id correction lists. KeepsInnerRuns is the structural type of a tool carrying a store.

Worked end to end in examples/features/50-through-the-tool-boundary.ts: a weather-advice agent answers "why did you say it'll rain?" by descending into its own tool and citing the inner stage plus the exact field — with the chart's stage counters printed either side to prove nothing re-executed.

What one captured turn carries

A snapshot alone cannot answer everything. The binding captures three things at the same terminal flush, so all three describe one turn: the snapshot, the run's narrative (what read_narrative pages through), and a bounded tail of the run's typed events (the only clock a run has — what inspect_tool_call reads durations from, and where the Context Integrity findings find_context_errors serves live). Both optional parts default on; with events: false, find_context_errors reports the evidence channel as absent rather than reporting a clean run:

.selfExplain({
  include: { narrative: true, events: true },  // SelfExplainInclude — both default true
  maxEvents: 2000,                             // per-turn tail cap (SELF_EXPLAIN_MAX_EVENTS)
})

maxEvents caps the retained tail per turn; its default is exported as SELF_EXPLAIN_MAX_EVENTS (2,000 — enough for a long tool-using turn, small enough that a server holding one binding per agent does not grow without limit), and a tail that dropped events says so rather than letting a reader mistake the remainder for the whole turn. include takes a SelfExplainInclude — two booleans, both defaulting to true, because the tools that read these parts are on the catalog either way and a tool that answers "⚠ no evidence" by default is a tool that teaches the model not to call it. Turn one off when the cost matters more than the answer. events: false makes no wildcard event subscription at all, rather than one that is ignored; a tool whose evidence is missing then says which switch turns it back on instead of answering emptily. (SelfExplainSource is the shape the builder hands the binding — all three sources in one call, so no wiring can be half-done.)

One agent, many people. Evidence is kept per conversation, never per instance: on an agent shared by many sessions (standingAgent({ agent })), "the previous completed run" is the asking conversation's, not whoever ran last. The source's getServing() answers a SelfExplainServing — which conversation the run in flight belongs to, and which run it is. A run with a session is keyed by its session; a request standingAgent serves without one is keyed by a random id minted for that request, in a key space no session id can enter; only a direct run with no session shares the no-session key. A tool's kept records are served only to the conversation whose runs filed them — including a run that paused or failed.

Delegate mode — answer on a cheaper model

The why-question doesn't need your big model to walk the trace — only to relay the answer. Delegate mode unlocks a single explain_run(question) tool whose work runs on a separate, cheaper provider via a nested trace debugger:

const delegatingAgent = Agent.create({  provider: mainProvider,  model: 'mock-big',  maxIterations: 6,})  .system('You are a refunds assistant.')  .tool(lookupOrder)  // Answer why-questions on a separate, cheaper model (swap the mocks for  // anthropic() + a Haiku-class model in production).  .selfExplain({ delegate: { provider: delegateProvider, model: 'mock-cheap' } })  .build();

The main conversation pays for one tool call; the trace-walking loop runs at the delegate's price. Swap the mocks for anthropic() + a Haiku-class model and that's the real split.

SelfExplainOptions: instruction? (appended to the skill body — yours adds, ours stays), delegate?: { provider, model, maxIterations? }, id? (the skill activation key, default 'self-explain'), toolpack? (bounding dials forwarded to the trace tools).

How we know it's faithful (the guarantee)

The point of self-explanation is to not inherit the unfaithfulness it's meant to diagnose. Two invariants make that real:

  1. It can never answer about an in-flight run. Agent.run() reassigns its executor at the start of a run, so reading the "current" snapshot mid-run would expose the unfinished turn. The binding sidesteps this by capturing the snapshot only at terminal flush — onRunEnd / onRunFailed. There is no code path that hands the model an in-progress trace; it physically can only see a completed run.
  2. A failed run is still explainable. Capture fires on onRunFailed too, so "why did you fail?" works — the error and the trace up to it are recorded evidence.

Two more details that keep it correct: a fresh control-dependence recorder per run (so "which decision allowed this step?" survives the per-run id reset and stays valid for the whole follow-up turn), and — before the first completed run — a plain "no completed run yet" message instead of an error. Every answer cites step ids you can re-open with the same tools, so the explanation is checkable, not asserted.

.selfExplain() vs. Causal memory — which "read its own trace"?

Both let the agent answer "why?" from the trace, but they pull at different ranges — use them together:

.selfExplain()Causal memory
Answers aboutthe agent's previous run (this conversation)any past run, retrieved across sessions
Selected bythe user asking, nowsemantic similarity to the new question
Persisted?no — in-memory, the last completed runyes — to the configured store (survives restarts)
Granularitydrills to one piece, by id, on demandrecalls a whole past run's snapshot
Reach for it whena follow-up in the same conversation"why did you decide X last week / in session Y?"

Mental model: Causal memory pulls the right run; .selfExplain() drills the right piece of it. Both are lazy — only what's needed, only when asked. (Stacking them — recall a stored run, then drill it with the trace tools — is the in-progress "evidence bridge"; see Causal memory deep-dive.)

Gotchas

  • Needs reactMode: 'dynamic' (the default) or 'dynamic-grouped'. 'classic' caches the tools slot on turn 1, so a mid-turn skill activation could never surface the trace tools — .selfExplain() fails loud at build rather than silently never answering. (Need it on a classic-mode agent? Run traceDebugAgent() as a separate session instead.)
  • Reserved tool names. Inline mode reserves the names in TRACE_TOOL_NAMES, including inspect_subflow, attention_list, root_cause and impact; delegate mode reserves explain_run. The tools slot dedupes by name (first wins), so a consumer tool with one of those names would silently shadow the trace tool — .build() throws to stop it. The reservation is derived from the pack's own list.

Without .selfExplain() — the toolpack and a dedicated debugger

.selfExplain() is one of three doors over the same tools. The other two take the evidence of a completed run as a plain object — a TraceToolpackArtifacts bag — and work on any agent, including a 'classic'-mode one:

  • traceToolpack(artifacts, options?) returns the raw tools. Mount them on any agent, or drive them from a script with callTraceTool(tools, name, args, context?) — it validates the arguments against the tool's schema first, exactly as an Agent would, and returns the same correction string on a bad call instead of executing. An optional ToolExecutionContext retains the caller's cancellation and scoped capabilities; omitting it keeps the offline context.
  • traceDebugAgent({ artifacts, provider, model }) returns a ready Agent whose whole catalog is the toolpack and whose system prompt is the debugging method (overview → drill by id → cite evidence → respect ⚠). Options: maxIterations (default 8), mode ('recorded' by default, with explicit 'hypothesis' opt-in), instruction (appended to the method), toolpack (the bounding dials), name.
import { recordRun, openRecording, traceToolpack, callTraceTool, traceDebugAgent } from 'agentfootprint/observe';
import { anthropic } from 'agentfootprint/providers';

const recorder = recordRun(agent);
await agent.run({ message: 'Approve loan APP-7' });
const artifacts = openRecording(recorder.toRecording());   // { snapshot, events, … }

// Scripted — the offline auditor pattern:
const tools = traceToolpack(artifacts, { valueMaxChars: 4000 });
console.log(await callTraceTool(tools, 'run_overview'));

// Conversational — a cheap model, in a session of its own:
const debuggerAi = traceDebugAgent({ artifacts, provider: anthropic(), model: 'claude-haiku-4-5' });
await debuggerAi.run({ message: 'Why was loan APP-7 approved?' });

The bag has one required field and optional evidence. A tool whose evidence is missing says so rather than answering emptily:

fieldwhat it iswhat it adds
snapshotthe run's snapshot (agent.getLastSnapshot())everything — the commit log and execution tree every tool reads
controlDepsfootprintjs's controlDepRecorder().asLookup() from the run[control: rule] edges in slices; without it, slices carry ⚠ control edges unavailable
narrativethe narrative linesthe read_narrative tool (it is not mounted without them)
eventsthe run's typed eventsinspect_tool_call durations and outcomes, and every find_context_errors finding
innerRunsthe records chart tools kept (see Wiring it by hand)inspect_tool_run
cause{ graph, nodeIds? }, a host-authorized public Foottrace CauseGraph for this exact artifact scopean explicit source for attention_list, root_cause and impact; nodeIds supplies bounded discovery references when the graph has no roots port

Runnable: examples/observability/01-trace-debug-session.ts (scripted) and examples/observability/07-trace-debug-agent.ts (the dedicated debugger).

Recorded cause and impact

These three tools ask the public foottrace/cause reader to reason over a recorded graph. No model is needed to obtain the finding. They do not replay business work, run a payment, or infer what happened inside a provider.

TraceCauseArtifacts is the host-supplied graph and optional discovery IDs for one authorized artifact scope. TraceCauseOperation names the three read-only questions in the table below. The host configures query ceilings, directional allowances and the served character limit through TraceCauseOptions on TraceToolpackOptions.cause. Model-facing cause schemas accept only node IDs; they expose no resource or output-size controls.

toolquestionwhat to cite
attention_listWhich captured nodes need attention? Optional nodeIds restricts discovery.Returned node IDs, attention reasons and proof references. Missing capture is unknown, not a clean bill of health.
root_causeWhat recorded dependency explains this nodeId?A supported rootCause and its path, or the alternatives and gaps that prevent selecting one.
impactWhat recorded downstream nodes depend on this nodeId?nodes, edges, confidence and gaps. A reachable dependency is not proof of an unrecorded real-world effect.

Authorize before building the artifact bag. The host checks tenant, principal, run and scope, then supplies artifacts.cause. A graph is a trusted executable capability, not an authorization policy. Its node IDs and proof references must be safe to serve; do not put credentials, customer payloads or private storage paths into them. The tools accept opaque node IDs, not URLs, storage credentials or arbitrary run lookups. A model choosing a node never grants access to another run.

The runnable fixture below uses public createRecordGraph. Its synthetic records carry per-write readKeys, explicit reads/control/attention maps, and a host-attested completed capture. complete: true is an assertion the fixture author can make about this fixture. Do not derive it merely from finding no errors. For real flows, enable writeProvenance: 'reads-prefix' when recording and verify coverage and the end marker before attesting completeness.

const address = { logRunId: `synthetic:${name}`, drillPath: [] };const timeout: Attention = {  kind: 'timeout',  reason: 'TIMEOUT_RECORDED',  evidence: ['proof:synthetic-timeout-attempt'],};const scope: RecordScope = {  address,  commitLog: [    commit(0, 'quote', 'quote', []),    commit(1, 'stock', 'stock', []),    commit(2, 'choose', 'decision', tied ? ['quote', 'stock'] : ['quote']),    commit(3, 'prepare', 'preparation', ['decision']),  ],  reads: {    'quote#1': [],    'stock#2': [],    'choose#3': tied ? ['quote', 'stock'] : ['quote'],    'prepare#4': ['decision'],  },  controlDeps: {}, // Captured and checked: no control edges in this fixture.  attention: {    'quote#1': [timeout],    ...(tied      ? {          'stock#2': [            {              kind: 'error',              reason: 'ERROR_RECORDED',              evidence: ['proof:synthetic-stock-error'],            } as Attention,          ],        }      : {}),  },  complete, // A host must verify capture coverage and the end marker before setting true.};const graph = createRecordGraph([scope]); // Public reader; no application walker.const target = graph.nodeId(address, {  runtimeStageId: 'choose#3',  commitIdx: 2,  key: 'decision',});const root = graph.nodeId(address, { runtimeStageId: 'quote#1', commitIdx: 0, key: 'quote' });const affected = graph.nodeId(address, {  runtimeStageId: 'prepare#4',  commitIdx: 3,  key: 'preparation',});const artifacts: TraceToolpackArtifacts = {  // This empty snapshot is just the toolpack shell; the explicit authorized  // graph below is the synthetic source of this example's cause evidence.  snapshot: {    runId: `synthetic-shell:${name}`,    commitLog: [],    executionTree: { id: 'synthetic-shell', logs: {}, errors: {}, metrics: {}, evals: {} },    sharedState: {},    commitValues: 'full',    writeProvenance: 'reads-prefix',  },  cause: { graph, nodeIds: [root] }, // A preauthorized capability, never a model-supplied location.};

For saved records, the host can instead provide a graph from public createStoredRecordGraph(host). That host loads only authorized scopes, honors the supplied abort signal and returns capture gaps honestly. Storage and service resolution stay in the host adapter. The tools do not invent a second storage reader or dependency walker.

A normal snapshot can now support a recorded root when its native capture note covers the queried root log. Enable captureCoverage, full read/write views, per-write provenance, and the control recorder's capture acknowledgement when recording. The reader uses the engine note and the retained control-dependencies recorder entry; no custom graph or live lookup is required.

let attempts = 0;const chart = flowChart<{ quote: number; total: number }>(  'Quote',  (scope) => {    if (++attempts === 1) throw new Error('Synthetic first-attempt failure');    scope.quote = 4;  },  'quote',)  .retry({ attempts: 2 })  .addFunction(    'Total',    (scope) => {      scope.total = scope.quote;    },    'total',  )  .build();const executor = new FlowChartExecutor(chart, {  captureCoverage: true,  readTracking: 'full',  writeTracking: 'full',  writeProvenance: 'reads-prefix',});executor.attachFlowRecorder(controlDepRecorder({ captureCoverage: true }));await executor.run();// Authorize this run before serving its snapshot. No custom CauseGraph or lookup is needed.const tools = traceToolpack({ snapshot: executor.getSnapshot() });const attention = JSON.parse(await callTraceTool(tools, 'attention_list'));const nodeId = attention.finding.nodes[0].nodeId;const root = JSON.parse(await callTraceTool(tools, 'root_cause', { nodeId }));const impact = JSON.parse(await callTraceTool(tools, 'impact', { nodeId }));

Admission requires a supported same-run note, a completed boundary, a matching native root address, the entire current log extent, full retained read/write modes, reads-prefix provenance, all four required channels captured, and one same-run captured control snapshot. The capture field in tool responses gives admitted or incomplete and a fixed reason. Captured-empty maps are admitted only under this contract. A successful return alone is insufficient. Missing, failed, paused, cancelled, partial, legacy, resumed, stripped or child captures stay uncertain; parent coverage never attests a child scope. Unknown note versions and extra limitations also stay uncertain.

Capture admission is not a promise of a unique root. Foottrace still computes every dependency and gap. Step-level queries may remain conservative; per-write queries, ties, query limits, untracked inputs and redacted evidence retain their existing meaning. A recovered retry is recorded attention, not proof that it caused an incorrect business outcome. Unrecorded service internals, exact read versions, semantic derivation and unreached branches remain outside the finding.

This first note attests an in-memory root-log segment, not saved delivery or a complete workflow. A JSON round trip does not upgrade that claim. Hosts must authorize and preserve the snapshot and its note together; metadata is not a signature and cannot prove that an untrusted sender told the truth. Other scopes and storage can still use a host-authorized CauseGraph through artifacts.cause. That executable capability and all query ceilings are unchanged.

Read one question, keep ambiguity

The example checks a unique root and its impact, then checks two marked ancestors tied as alternatives and a record missing its completion marker. The latter cases must not assert a unique root:

const tools = traceToolpack(fixture.artifacts, {  cause: { maxNodes: 25, maxEdges: 100, maxCalls: 100, timeoutMs: 1000 },});const root = JSON.parse(  await callTraceTool(tools, 'root_cause', { nodeId: fixture.target }),) as ToolFinding<CauseResult>;const impact = JSON.parse(await callTraceTool(tools, 'impact', { nodeId: fixture.target }));const tied = authorizedFixture('tied', true);const alternatives = JSON.parse(  await callTraceTool(traceToolpack(tied.artifacts), 'root_cause', { nodeId: tied.target }),) as ToolFinding<CauseResult>;const unfinished = authorizedFixture('missing-end-marker', false, false);const gap = JSON.parse(  await callTraceTool(traceToolpack(unfinished.artifacts), 'root_cause', {    nodeId: unfinished.target,  }),) as ToolFinding<CauseResult>;

Read the result fields separately. Missing backward evidence can prevent a supported root. A gap confined to forward impact can leave an established recorded root intact while limiting what is known about its effects; report both facts rather than discarding the root or calling the impact complete.

Cause IDs are opaque, graph-scoped references. They are not the runtimeStageId accepted by trace_node. To descend, first call inspect_tool_run({ toolCallId, cause: 'attention_list' }) or inspect_subflow({ mount, cause: 'attention_list' }); then send that child's returned ID back through the same entry point with cause: 'root_cause' or 'impact' and nodeId. A child uses its own captured record, and does not inherit the parent's graph capability. Existing inner overview, key and variable questions keep their behavior when cause is omitted.

Keep the query bounded

The host sets total maxNodes, maxDepth, maxEdges, maxCalls, timeoutMs and maxChars through TraceToolpackOptions.cause, with optional backward and impact work allowances. These are host-only settings. attention_list accepts optional nodeIds; root_cause and impact require nodeId. Model-supplied budget, deadline, directional or output-limit arguments are rejected by the schemas, so a model cannot override the host's ceiling. Directional allowances restrict the one enclosing query ceiling; they do not grant extra work or another deadline.

By default the AgentFootprint wrapper limits backward node, edge and call work to approximately 80% of the total, rounded down with a minimum of one; depth is unchanged. Forward impact can spend what remains. This leaves capacity rather than promising a complete answer: a tiny budget may still return a named limit gap. Foottrace's own default behavior is unchanged.

Each invocation has one query deadline, including loading the cause reader, and honors a caller signal through lazy, inner and delegated calls. If the host omits configuration, limits are 25 nodes, depth 6, 100 edges, 100 calls, one second and 8,000 JSON characters. Host configuration remains bounded by library ceilings of 100 nodes, depth 20, 1,000 edges, 1,000 calls, five seconds and 16,000 characters. If a result exceeds its character allowance, the tool returns a fixed truncated gap and withholds the finding; it does not cut a proof in half and leave a seemingly supported root. Failures use fixed gap wording rather than forwarding a host exception.

The legacy snapshot projection also refuses more than 4,096 commits or stages with a truncated gap. This local size refusal is distinct from evidence that was not collected.

The public cause entry point supports both ESM and CommonJS. No import bridge, eval or private distribution path is needed. Keep one module format consistently where possible, and compare public errors by their code rather than relying on instanceof across formats.

Let a model explain the finding

traceDebugAgent can read the same tools in a separate session. This example's scripted mock asks three real tools and composes its summary from their actual node and proof IDs. It makes no paid model calls:

const plan = [  { name: 'attention_list', args: { nodeIds: [fixture.root] } },  { name: 'root_cause', args: { nodeId: fixture.target } },  { name: 'impact', args: { nodeId: fixture.target } },];let next = 0;let citedProof: string | undefined;const provider = mock({  respond: (request) => {    const step = plan[next++];    if (step !== undefined) return { toolCalls: [{ id: `read-${next}`, ...step }] };    // The mock reads the ACTUAL tool results; it does not invent a proof ID.    const results = request.messages      .filter((message) => message.role === 'tool')      .map((message) => JSON.parse(message.content));    const finding = results.find((result) => result.tool === 'root_cause').finding as CauseResult;    const downstream = results.find((result) => result.tool === 'impact').finding      .nodes as string[];    citedProof = results.find((result) => result.tool === 'attention_list').finding.nodes[0]      .attention[0].evidence[0];    return (      `The unique recorded dependency root is ${finding.rootCause}. Proof: ${citedProof}. ` +      `The recorded path is ${finding.path.join(' -> ')}. ` +      `The recorded downstream write is ${downstream.join(', ')}. ` +      'This does not establish why a provider timed out internally.'    );  },});const debuggerAgent = traceDebugAgent({  artifacts: fixture.artifacts,  provider,  model: 'mock',  maxIterations: 5,});const result = await debuggerAgent.run({ message: input ?? meta.defaultInput! });if (typeof result !== 'string') throw new Error('The mock debugger must finish without pausing.');const summary = result;// This is a demo assertion for a scripted mock, not general prose validation.if (  citedProof === undefined ||  !summary.includes(citedProof) ||  !summary.includes(fixture.root) ||  !summary.includes(fixture.affected))  throw new Error('The mock summary must cite the returned node and proof.');

The debugger's mode defaults to 'recorded': its prompt asks for findings supported by the returned fields, citations and relevant gaps. A host may explicitly select mode: 'hypothesis' to invite separately labelled hypotheses. That changes the prompt, not the evidence: the tools always return recorded findings, and a hypothesis does not become a validated cause.

The deterministic guarantee belongs to the returned tool findings. Arbitrary model prose is not automatically semantically validated: a model could cite a real ID while making the wrong connection. When delivery must enforce your domain's claim rules, mount traceToolpack(...) on an Agent builder and attach the existing host-authored answer validation. The mock's final assertion is a demo check, not a general prose validator. A prompt alone does not enforce semantic correctness. The tools do not generate hypotheses or silently replace unknown evidence with a guess.

Run the complete example with npm run example -- examples/features/101-recorded-cause-and-impact.ts.

The tools, one by one

Step-oriented tools use a runtimeStageId — stageId#executionIndex, for example normalize#1. The #index counts across the whole run, not per stage. It links the commit log, execution tree and events. The cause tools instead use the opaque graph-scoped nodeId returned by their evidence reader.

toolparametersanswers
run_overview—What happened, broadly: status, every stage (id, name, description), loop counts, where errors appeared, honesty notes, and cost when the run priced itself. Start here.
find_context_errorskind?, limit? (default 10, cap 25)What did the run contradict itself about? The integrity findings already filed.
find_in_tracequery, maxHits? (default 10, cap 25)Where does this text appear? Free text in, step ids and keys out.
trace_noderuntimeStageIdWhat did step X write (bounded previews plus true sizes), read, and where did its inputs come from?
trace_sliceruntimeStageId, key?, maxDepth? (default 6, cap 20), maxNodes? (default 25, cap 100)Which chain of steps produced the data at X, as an indented tree of drillable ids?
backtrackvariable, element?, before?, maxDepth?, maxNodes?Why is variable K what it is? No step id needed. element asks which step produced K[i]; before (a commit index) explains the value as it stood before that point.
who_wrotekey, beforeStageId?Which step last wrote K (optionally, strictly before step Y)?
get_valueruntimeStageId, key, maxChars? (default 2000, cap 8000)The full value of K as of step X. It works on delta commit logs.
inspect_tool_calltoolCallIdOne tool call, end to end.
inspect_tool_runtoolCallId, find?, variable?, runtimeStageId?, key?, cause?, nodeId?Inside one tool call, when the tool kept its own record.
inspect_subflowmount, find?, variable?, runtimeStageId?, key?, cause?, nodeId?Inside one subflow mount's own record.
attention_listnodeIds?Captured abnormal nodes and their evidence; gaps preserve unknown coverage. Query limits are host-only.
root_cause / impactnodeIdA public recorded-cause query within host-only ceilings, projected as root evidence or downstream impact.
read_narrativeoffset?, maxLines? (default 40, cap 200)The run's story, one page at a time.

The defaults come from TraceToolpackOptions (previewChars, sliceMaxDepth, sliceMaxNodes, valueMaxChars), which you pass as the second argument to traceToolpack or as toolpack on traceDebugAgent and .selfExplain(). The caps are TOOLPACK_HARD_CAPS, and no argument can go past them.

find_context_errors — what the run contradicted itself about

When an answer looks wrong, the cheapest move is to ask whether the library already caught the problem. Every Context Integrity check files its findings on the run's event stream. This tool reads them. It never re-runs a check and never re-judges one. What it adds is the join: the step each finding was filed at, and the next call to make.

CONTEXT ERRORS — 1 finding(s) filed by this run's integrity checks (showing 1 of 1).

[1] dangling-reference @compose · iteration 4 — 'screen_fire' is being offered while the
    results that ground its arguments have left the window (whats_here) …
    about: tool:screen_fire, tool:whats_here → find_in_trace('screen_fire')
    filed at call-llm#88 → trace_node('call-llm#88') · trace_slice({ runtimeStageId: 'call-llm#88' })

CHECKERS (posture: observe · …):
- dangling-reference @compose: ran 6× · 6 finding(s) · not-applicable 3 (out of scope by rule)

(Abridged.) What to know:

  • The checker rows are part of the answer. "The checks ran and found nothing" and "no check was registered here" are different facts. A clean verdict is printed only when the rows add up to at least one checked encounter. If every encounter was out of scope or unreachable, the answer is ⚠ NOTHING WAS CHECKED, with the reason.
  • Absence has its own wording. With no events in the bag, the tool reports no finding evidence, not a clean run. When the checker rows count findings the event tail no longer holds, it says ⚠ EVIDENCE MISSING.
  • kind offers only classes a check in this build can file: invariant-violation, unsupported-argument, dangling-reference, unsupported-claim, empty-lookup, column-type-mismatch, missing-column and prior-turn-evidence. The finding type also names duplicate-execution, but nothing files it. The tool says so rather than answering "none found" about a class nobody checks.
  • Findings are deduplicated when they are filed, so a defect found again on a later iteration counts once. If the rows count more encounters than the list shows, the output says so.
  • Findings are drillable. The step a finding was filed at is a real runtimeStageId. An id that is not a step in this run is marked ⚠ instead of being offered as a drill.

inspect_tool_call — the four records, joined

A tool call is spread across four records: the args the model proposed are on an assistant turn, the args it actually ran with are in the middleware ledger (only when a rule changed them), the result is a role: 'tool' turn, and the timing exists only in the event stream. This tool joins them for one toolCallId:

TOOL CALL c2 — check_inventory
step: tool-calls#22 — drill with trace_node('tool-calls#22')
proposed by the model: {"sku":"KB-88"}
ran with: {"sku":"KB-88","limit":5} — CHANGED at before-tool by 'clamp-limit': "page size capped at 5"
result: "Stock for KB-88: 12 units available…"
outcome: ok
duration: 4ms

The proposed/ran-with split is the reason it exists. A middleware rule that rewrites args does not show up in the conversation: the model sees its own proposal in the history, and the tool ran with something else. Outcomes are ok, error, denied or paused. Without events in the bag, the line reads duration: ⚠ unavailable and explains that the commit log has no clock. An unknown id gets a correction that lists the ids this run actually recorded.

For a tool that reached into someone else's system, the answer ends at the tool boundary — what went in and what came back. A tool can push that further by returning its own diagnostic refs (a request id, a log link), which travel through the trace like any other result. A chart tool with keepRecord: true goes further still: see Through the tool boundary.

The contracts

  • Bounded by default. Previews are capped, slices have depth and node budgets, and values have character budgets. Legacy slice/value tools let the model raise a per-call budget up to TOOLPACK_HARD_CAPS; cause-query work, deadlines and output limits are host-only. A value cut short says so: ⚠ truncated: served N of M chars.
  • Honest, never silent. A truncated slice is marked, and so is a step that consumed inputs the commit log cannot see (run args, env, untracked reads). A missing controlDeps lookup, missing read tracking and values that never enter the commit log (pre-run state, values carried in closures) are named, not left out. who_wrote on a key with no tracked write says the value may have come from one of those places. It does not say "nothing wrote it".
  • Redaction-respecting. footprintjs scrubs the commit log at commit time. The tools pass the placeholders through as they are, flag the key (redacted by policy), and never reconstruct around a redaction.
  • Strict schemas. On a small run (48 steps or fewer), step-id parameters carry an enum of every real step id, so a made-up id is rejected before the tool runs and the model corrects itself. Key parameters have no enum on purpose: a question about a key outside the commit log has an honest answer ("args, env and pre-run values never enter the commit log"), not a validation error. A bad id that gets through returns a correction naming the real steps.

Security posture

Trace content can carry adversarial text from the original run. Tool results, retrieved documents and user input all pass through state, and these tools serve them back, bounded. Showing trace content to a model therefore shows it any prompt injection the run contained (see Security).

  • Prefer a separate session over a completed run — traceDebugAgent, or .selfExplain({ delegate }) — to trace tools mounted on the production agent. Delegate mode keeps the trace walk out of the production context; only the bounded, cited answer comes back, as a tool result.
  • Treat what the tools return as data, not instructions. The bounded views limit how much comes back. They do not clean up what it says.
  • The toolpack never re-runs anything and holds no credentials. It is a read-only view over frozen artifacts.

Token economics

examples/observability/01-trace-debug-session.ts plants a wrong value (a debt-to-income ratio computed against annual instead of monthly income) that flows through a decide() decision. A scripted debugger finds the culprit step in 8 tool calls, served about 2.8K characters, against a full trace dump (snapshot JSON plus narrative) of about 29K — roughly a tenth. The gap grows with run size, because a session costs what the model opens, not what the run produced. The example prints its own numbers, so run it to see them for your build.

Explaining a run that already finished

.selfExplain() answers about the agent's previous turn, in memory. For a run recorded weeks ago, openRecording reopens a saved recordRun bundle — { snapshot, events, structure }, live or parsed back from JSON — as the same artifacts the trace tools navigate:

import { recordRun, openRecording, traceToolpack, callTraceTool } from 'agentfootprint/observe';

const tools = traceToolpack(openRecording(JSON.parse(fs.readFileSync('run.json', 'utf8'))));
await callTraceTool(tools, 'find_in_trace', { query: 'order 7712' });

It is pure — no engine, no agent, no I/O — and takes an OpenableRecording (any bundle structurally matching a Recording). It is honest about the two things a serialized run cannot carry back: controlDeps is a lookup function and does not serialize, so slices carry the existing ⚠ control edges unavailable marker; and the narrative survives only if a narrative recorder was attached at record time, since recordRun deliberately attaches none. A bundle with no snapshot, or a snapshot missing commitLog / executionTree, is refused with a message naming recordRun as the producer.

See also

On this page