agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
413 lines • 20 kB
JavaScript
/**
* selfExplain — the IN-CONVERSATION door over the agent's own trace.
*
* `.selfExplain()` on the builder mounts ONE skill plus ONE scoped tool
* provider. Day to day the tool catalog carries only the skill's
* activation row — the trace tools are NOT in the skill (skill `tools`
* land in the static registry, exposed every iteration); they ride a
* `skillScopedTools` provider gated on the skill's activation, composed
* with whatever provider the consumer already set. When the user asks a
* why-question the LLM activates the skill, and the NEXT iteration's
* catalog gains the trace tools, bound to the agent's own PREVIOUS
* COMPLETED run.
*
* The two pieces here:
*
* 1. `SelfExplainBinding` — the late-binding seam, a plain CombinedRecorder
* attached like any consumer recorder (zero engine changes):
*
* - capture at `onRunEnd`/`onRunFailed`: the just-finished run's
* snapshot becomes the explainable evidence (a FAILED run is
* still a completed trace — "why did you fail?" works);
* - rotate at `onRunStart`: a FRESH ControlDepRecorder per run. The
* retired instance never sees the new run's events, so its live
* `asLookup()` survives Convention-4's runId reset — the captured
* control edges stay valid for the whole next turn.
*
* B13 safety lives here: `Agent.run()` reassigns its executor at run
* START, so resolving artifacts mid-run through `getLastSnapshot()`
* would expose the IN-FLIGHT run. Capturing only at terminal flush
* means the binding can never serve anything but a completed run.
*
* Keyed PER CONVERSATION (R2-11): one agent instance can serve
* many people (`standingAgent({ agent })`), so "the previous completed
* run" is asked of the ASKING run's session — the evidence of the last
* run that session completed, never whichever run the instance finished
* last. A run with no session reads the last run that had none. A tool's
* retained inner runs are served through the same key
* (`core/innerRunRecords.ts · innerRunsOfConversation`).
*
* 2. `buildSelfExplainSkill` — the skill in two modes:
*
* - INLINE (default): the skill unlocks the trace tools — every name
* in `TRACE_TOOL_NAMES` (11 today) — in the main agent's own loop
* (same model). The count is deliberately not restated anywhere
* that could fall behind the pack.
* - DELEGATE: the skill unlocks ONE tool — `explain_run(question)` —
* whose execute runs a nested `traceDebugAgent` on the consumer's
* chosen (cheaper) provider/model and returns its evidence-cited
* answer. The main conversation pays for one tool call; the
* trace-walking loop happens at the delegate's price. Loaded via
* dynamic import so the builder never statically pulls Agent
* through this module (no core ↔ lib cycle).
*/
import { isFlowEvent } from 'footprintjs';
import { controlDepRecorder } from 'footprintjs/trace';
import { defineSkill } from '../injection-engine/factories/defineSkill.js';
import { defineTool } from '../tools/tools.js';
import { eventTail } from '../events/eventTail.js';
import { skillScopedTools } from '../tools/skillScopedTools.js';
import { SELF_EXPLAIN_BODY, SELF_EXPLAIN_WHEN } from './debugPrompt.js';
import { innerRunsOfConversation } from '../core/innerRunRecords.js';
import { NO_COMPLETED_RUN_MESSAGE } from '../lib/traceToolNames.js';
/** Default per-turn event cap for the self-explain binding. */
export const SELF_EXPLAIN_MAX_EVENTS = 2000;
/**
* How many conversations one binding keeps evidence for — the least recently
* explained or completed is dropped past it. One agent instance serving many
* sessions holds one completed turn per session, so the bound is what keeps a
* long-lived shared agent from pinning every session's last snapshot. A
* session whose evidence was dropped is answered like one with no completed
* turn: nothing is served, and nothing of anybody else's is served instead.
*/
const MAX_CONVERSATIONS = 64;
/**
* The separate bound for ONE-SHOT conversations — a hosted request with no
* session, which no later request can name (`SelfExplainServing.oneShot`).
* Kept on their own shelf so a flood of sessionless requests can never evict a
* session's evidence (recheck NIT 4); small, because nothing ever asks a
* follow-up question of one.
*/
const MAX_ONE_SHOT_CONVERSATIONS = 8;
function shelf(max) {
return { captured: new Map(), runs: new Map(), max };
}
/** Drop the oldest entries of an insertion-ordered map past `max`. */
function bound(held, max) {
while (held.size > max) {
const oldest = held.keys().next();
if (oldest.done === true)
return;
held.delete(oldest.value);
}
}
/** How many of one conversation's runs its inner-run view admits. */
const MAX_RUNS_PER_CONVERSATION = 32;
/**
* The late-binding seam. Create one per built Agent, attach
* `binding.recorder()` via `agent.attach()`, and point `bindTo()` at the
* agent's `getLastSnapshot`. `artifacts` then always answers with the
* previous COMPLETED run of the asking conversation — never the in-flight
* one, and never another conversation's.
*/
export class SelfExplainBinding {
include;
maxEvents;
source;
ctrl = controlDepRecorder();
tail;
/** Evidence and run ids per conversation (see
* {@link SelfExplainSource.getServing}), on two shelves: one for
* conversations a caller can come back to ({@link MAX_CONVERSATIONS}), one
* for one-shot ones ({@link MAX_ONE_SHOT_CONVERSATIONS}). Insertion order is
* recency order. Run ids are recorded at run START, so a run that PAUSED or
* FAILED (neither reaches the terminal capture) still owns the records its
* tools filed (recheck RS4). */
lasting = shelf(MAX_CONVERSATIONS);
oneShot = shelf(MAX_ONE_SHOT_CONVERSATIONS);
constructor(include = {}, maxEvents = SELF_EXPLAIN_MAX_EVENTS) {
this.include = include;
this.maxEvents = maxEvents;
}
get wantsNarrative() {
return this.include.narrative !== false;
}
get wantsEvents() {
return this.include.events !== false;
}
/**
* Point the binding at the agent it explains.
*
* Accepts the bare `getSnapshot` function it has always accepted (the
* evidence a completed run leaves behind on its own), or the full
* source, which adds the two parts a snapshot does NOT carry: the
* narrative, and the typed event stream.
*/
bindTo(source) {
this.source = typeof source === 'function' ? { getSnapshot: source } : source;
if (this.wantsEvents && this.source.on) {
this.tail = eventTail(this.maxEvents);
this.source.on('*', (event) => this.tail?.push(event));
}
}
/**
* Evidence of the previous completed run OF THE ASKING CONVERSATION, or
* undefined when that conversation has completed none (or its evidence was
* dropped past the bound). Never another conversation's.
*/
get artifacts() {
// No refresh on read: a read happens only inside the asking
// conversation's own run, whose terminal capture files it as the most
// recent anyway.
const serving = this.serving();
const conversation = serving?.conversation;
const held = this.shelfOf(serving);
const turn = held.captured.get(conversation);
if (turn === undefined)
return undefined;
const allInnerRuns = this.source?.getInnerRuns?.();
const innerRuns = allInnerRuns === undefined
? undefined
: this.source?.getServing === undefined
? allInnerRuns
: innerRunsOfConversation(allInnerRuns, ownRuns(held.runs.get(conversation) ?? [], conversation));
return {
snapshot: turn.snapshot,
controlDeps: turn.ctrl.asLookup(),
...(turn.narrative !== undefined && { narrative: turn.narrative }),
...(turn.events !== undefined && { events: turn.events }),
...(innerRuns !== undefined && { innerRuns }),
};
}
/**
* Does the binding serve the run a tool call is in? Only the agent's OWN run
* — the one its instance is serving — reads its evidence. A call from the
* agent mounted as a composition member carries that composition's run id
* (`ctx.runId`), which is never the instance's: the instance's evidence is
* another caller's, so it is not served there.
*/
servesRun(runId) {
const serving = this.serving();
return serving === undefined || (runId !== undefined && serving.runId === runId);
}
/** The conversation (and run) in flight, or served last. */
serving() {
return this.source?.getServing?.();
}
/** Which shelf a conversation lives on. */
shelfOf(serving) {
return serving?.oneShot === true ? this.oneShot : this.lasting;
}
/** Remember that the serving run belongs to its conversation (bounded both ways). */
recordRun(serving) {
const { runs, max } = this.shelfOf(serving);
const held = runs.get(serving.conversation) ?? [];
runs.delete(serving.conversation);
runs.set(serving.conversation, [...held, serving.runId].slice(-MAX_RUNS_PER_CONVERSATION));
bound(runs, max);
}
/** File the serving conversation's evidence as its most recent, within the bound. */
file(serving, turn) {
const { captured, max } = this.shelfOf(serving);
captured.delete(serving?.conversation);
captured.set(serving?.conversation, turn);
bound(captured, max);
}
/** The recorder to attach — forwards flow events to the per-run ctrl. */
recorder() {
const capture = () => {
const snapshot = this.source?.getSnapshot();
if (!snapshot)
return;
// The narrative and the event tail are read at the SAME terminal
// flush as the snapshot, so the three describe one turn. Reading
// either later would mean reading it during the NEXT turn.
const narrative = this.wantsNarrative && this.source?.getNarrative
? this.source.getNarrative().map((entry) => entry.text)
: undefined;
const events = this.tail?.snapshot().events;
// Filed under the FINISHED run's conversation — at the terminal flush
// the source still names the run that just ended.
this.file(this.serving(), {
snapshot,
ctrl: this.ctrl,
...(narrative !== undefined && { narrative }),
...(events !== undefined && { events }),
});
};
return {
id: 'self-explain-binding',
// Inline always: capture must complete at the terminal flush, and
// the ctrl forwarding is four cheap map writes per event.
delivery: 'inline',
onRunStart: () => {
// Rotate FIRST: the retired ctrl never sees this run's events,
// so the captured lookup survives Convention-4's runId reset.
// The event tail rotates for the same reason — turn N+1's
// evidence must not carry turn N's events. The retired tail is
// the one its conversation's `captured` entry already holds, so it
// is not lost.
this.ctrl = controlDepRecorder();
if (this.wantsEvents && this.source?.on)
this.tail = eventTail(this.maxEvents);
// The run joins its conversation NOW, not at the end: a pause or a
// failure never reaches the terminal capture, and the records its
// tools filed must still be its conversation's to read.
const serving = this.serving();
if (serving !== undefined)
this.recordRun(serving);
},
onRunEnd: capture,
onRunFailed: capture,
onDecision: (e) => this.ctrl.onDecision(e),
onSelected: (e) => this.ctrl.onSelected(e),
onStageExecuted: (e) => this.ctrl.onStageExecuted(e),
onError: (e) => {
// ControlDepRecorder consumes FLOW errors only (branch-slot
// accounting); scope errors don't carry traversal context.
if (isFlowEvent(e))
this.ctrl.onError(e);
},
};
}
}
/**
* Which inner-run records a conversation may read: those its own completed
* runs filed. A record that names no run (a third-party producer, a tool run
* outside an agent) cannot be attributed, so it is admitted only on the
* direct no-session path (`undefined`) — never to a session or a hosted
* request.
*/
function ownRuns(runIds, conversation) {
const runs = new Set(runIds);
return (runId) => (runId === undefined ? conversation === undefined : runs.has(runId));
}
/** The delegate-mode tool: one call → a nested debugger at delegate price. */
function buildExplainRunTool(binding, delegate, toolpack) {
return defineTool({
name: 'explain_run',
description: "Answer a question about this agent's PREVIOUS completed turn by walking its recorded " +
'trace (a dedicated trace debugger runs the investigation and returns an evidence-cited ' +
"answer). Pass the user's why-question verbatim.",
inputSchema: {
type: 'object',
properties: {
question: { type: 'string', description: 'The why-question to investigate.' },
},
required: ['question'],
additionalProperties: false,
},
execute: async ({ question }, ctx) => {
const artifacts = binding.servesRun(ctx.runId) ? binding.artifacts : undefined;
if (!artifacts)
return NO_COMPLETED_RUN_MESSAGE;
// Dynamic import: keeps Agent out of this module's static graph
// (AgentBuilder imports this file; Agent imports AgentBuilder).
const { traceDebugAgent } = await import('./traceDebugAgent.js');
const debuggerAgent = traceDebugAgent({
artifacts,
provider: delegate.provider,
model: delegate.model,
maxIterations: delegate.maxIterations,
toolpack,
});
// The debugger's run is NESTED in the calling agent's run, so it is
// covered by the same policy — its own record (the question, what the
// trace tools served it, its answer) keeps out what the agent's does.
const out = await debuggerAgent.run({ message: question }, ctx.redact === undefined && ctx.signal === undefined
? undefined
: {
...(ctx.redact !== undefined && { redact: ctx.redact }),
...(ctx.signal !== undefined && { env: { signal: ctx.signal } }),
});
return typeof out === 'object' && out !== null && 'content' in out
? String(out.content)
: String(out);
},
});
}
/** The default skill id — the activation key the LLM passes to read_skill. */
export const SELF_EXPLAIN_SKILL_ID = 'self-explain';
/**
* The skill `.selfExplain()` mounts — methodology body ONLY. The trace
* tools deliberately do NOT ride the skill: skill `tools` land in the
* static registry (exposed every iteration); catalog gating is the
* ToolProvider's job — see {@link buildSelfExplainToolProvider}.
*/
export function buildSelfExplainSkill(options) {
return defineSkill({
id: options.id ?? SELF_EXPLAIN_SKILL_ID,
description: SELF_EXPLAIN_WHEN,
body: SELF_EXPLAIN_BODY + (options.instruction ? `\n\n${options.instruction}` : ''),
});
}
/**
* The gated tool delivery — `skillScopedTools` (the shipped primitive)
* scoped to the skill's id, composed with the consumer's own provider
* when they set one. The iteration after activation, `ctx.activeSkillId`
* matches and the catalog gains the trace tools (inline) or the single
* `explain_run` tool (delegate).
*
* INLINE MODE LOADS THE PACK LAZILY (9.94.0). The toolpack module is the
* largest single module on the library's default graph, and until this
* release every consumer who imported `Agent` carried it — `.selfExplain()`
* or not — because this function reached it statically. Now the pack is
* `import()`ed the first time the self-explain skill is ACTIVE on an
* iteration, and never before: not at `build()`, not on a turn where the
* model never opened the skill, not at all for an agent that did not enable
* `.selfExplain()`. A bundler splits it into its own chunk; Node loads it
* through the same `require` it always did, one microtask later.
*
* What the consumer sees: `list(ctx)` still answers `[]` synchronously when
* the skill is not active (the sync fast path the tools slot skips the await
* for), and a Promise of the same eleven tools when it is — the shape
* `ToolProvider.list` has always allowed, and the one the composed provider
* below already used. The provider id is unchanged, so `.watch()` output and
* the `skill-scoped:` refusal in the builder read exactly as before.
*/
export function buildSelfExplainToolProvider(binding, options, existing) {
const skillId = options.id ?? SELF_EXPLAIN_SKILL_ID;
const scoped = options.delegate
? skillScopedTools(skillId, [buildExplainRunTool(binding, options.delegate, options.toolpack)])
: lazilyMountedTraceTools(skillId, binding, options.toolpack);
if (!existing)
return scoped;
return {
id: `${existing.id}+${scoped.id}`,
list: async (ctx) => [...(await existing.list(ctx)), ...(await scoped.list(ctx))],
};
}
/**
* The trace tools, answering only inside the run the binding serves: a call
* from the agent mounted as a composition member (`ctx.runId` is the
* composition's run) is told there is no completed run, never handed the
* instance's evidence — another caller's conversation.
*/
function ownRunOnly(tools, binding) {
return tools.map((tool) => ({
...tool,
execute: (args, ctx) => binding.servesRun(ctx?.runId) ? tool.execute(args, ctx) : NO_COMPLETED_RUN_MESSAGE,
}));
}
/**
* The inline-mode provider: `skillScopedTools` semantics, with the tool list
* behind an `import()` that runs once, on the first ACTIVE iteration.
*
* The inactive path delegates to a real `skillScopedTools(skillId, [])` so it
* keeps that primitive's exact behaviour — the `[]` answer AND its one-time
* dev-mode warning about a skill that is loaded but not by `read_skill` — and
* the active path resolves the pack, builds the same scoped provider over it
* ONCE, and asks it. A failed load rejects `list()`, which the tools slot
* already reports as `tools.discovery_failed` and rethrows; the memo is
* cleared so the next iteration retries rather than serving a dead promise.
*/
function lazilyMountedTraceTools(skillId, binding, toolpack) {
const inactive = skillScopedTools(skillId, []);
let mounted;
const mount = () => {
mounted ??= import('./lazyToolpack.js').then(({ lazyTraceToolpack }) => skillScopedTools(skillId, ownRunOnly(lazyTraceToolpack(() => binding.artifacts, toolpack), binding)), (err) => {
mounted = undefined;
throw err;
});
return mounted;
};
return {
id: inactive.id,
list(ctx) {
if (ctx.activeSkillId !== skillId)
return inactive.list(ctx);
return mount().then((provider) => provider.list(ctx));
},
};
}
//# sourceMappingURL=selfExplain.js.map