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_in_tracefree 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_callone 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_runinside one tool call, when that tool kept its own record (see Through the tool boundary below)
  • 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.

Ten definitions 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 raise it: contextBudget: { tools: 7000 }.

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. footprintjs scrubs at commit time, so a key covered by flowchartAsTool's redact policy never enters the inner commit log at all — a kept record cannot serve what the policy removed, and the (redacted by policy) flag reads the same inside a tool as outside one.

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). Both optional parts default on:

.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.)

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 flushonRunEnd / 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 all ten names the pack can mount — run_overview / find_in_trace / trace_node / trace_slice / backtrack / who_wrote / get_value / inspect_tool_call / inspect_tool_run / read_narrative; 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 list is read from TRACE_TOOL_NAMES (the pack's own export) rather than retyped beside it, so it cannot fall behind the pack.

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