agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
259 lines • 13.6 kB
TypeScript
/**
* 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 { type CombinedRecorder, type RuntimeSnapshot } from 'footprintjs';
import type { Injection } from '../injection-engine/types.js';
import type { AgentOptions } from '../core/agent/types.js';
import type { Unsubscribe } from '../events/dispatcher.js';
import type { AgentfootprintEvent } from '../events/registry.js';
import type { ToolProvider } from '../tools/toolProvider.js';
import { type InnerRunLookup } from '../core/innerRunRecords.js';
import type { TraceToolpackArtifacts, TraceToolpackOptions } from './types.js';
/**
* How much of a turn's evidence the binding keeps.
*
* Both default to TRUE, which is the point: the tools that read them
* (`read_narrative`, `inspect_tool_call`) 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 — a very long-running turn, or a run whose
* narrative would repeat what the structured tools already say.
*/
export interface SelfExplainInclude {
/** The run's plain-English story → the `read_narrative` tool. Default true. */
readonly narrative?: boolean;
/**
* A bounded tail of the run's typed events → tool-call timings and
* outcomes in `inspect_tool_call`, and the Context Integrity findings
* `find_context_errors` reads. Default true. Off means no wildcard event
* subscription is made at all, not a subscription that is ignored — and
* `find_context_errors` then reports the evidence channel as ABSENT
* rather than reporting a run with no context errors.
*/
readonly events?: boolean;
}
/** Consumer surface for `.selfExplain()` on the Agent builder. */
export interface SelfExplainOptions {
/** Appended to the recommended skill body (ours stays; yours adds). */
readonly instruction?: string;
/**
* Answer why-questions on a SEPARATE (typically cheaper) model: the
* skill unlocks one `explain_run` tool that runs a nested
* `traceDebugAgent` and returns its evidence-cited answer.
*/
readonly delegate?: {
readonly provider: AgentOptions['provider'];
readonly model: string;
readonly maxIterations?: number;
};
/** Skill id (activation key for `read_skill`). Default 'self-explain'. */
readonly id?: string;
/** Bounding dials forwarded to the toolpack. */
readonly toolpack?: TraceToolpackOptions;
/** Which optional parts of a turn's evidence to capture. Both default true. */
readonly include?: SelfExplainInclude;
/**
* Cap on retained events per turn (only with `include.events`). Default
* 2,000 — enough for a long tool-using turn, small enough that a server
* holding one binding per agent does not grow without limit. A tail that
* dropped events says so in `inspect_tool_call`.
*/
readonly maxEvents?: number;
}
/** Default per-turn event cap for the self-explain binding. */
export declare const SELF_EXPLAIN_MAX_EVENTS = 2000;
/** Which conversation the agent is serving, and which run — see {@link SelfExplainSource.getServing}. */
export interface SelfExplainServing {
/** The evidence key. `undefined` = a direct run with no session. */
readonly conversation: string | undefined;
/** The run in flight, or the one served last. */
readonly runId: string;
/** A conversation no later request can name (a hosted request with no
* session) — kept on its own, smaller shelf. */
readonly oneShot?: boolean;
}
/**
* What the binding reads a completed turn's evidence FROM.
*
* One object rather than three wiring calls, on purpose: the
* `BoundaryRecorder` lesson in this codebase is that a seam needing three
* separate connections gets two of them in some integrations, and the
* missing third fails silently. Here the only caller is `AgentBuilder`,
* and it hands over all of it at once.
*/
export interface SelfExplainSource {
/** The just-finished run's snapshot — `agent.getLastSnapshot()`. */
getSnapshot(): RuntimeSnapshot | undefined;
/** The run's narrative entries — `agent.getLastNarrativeEntries()`. */
getNarrative?(): readonly {
readonly text: string;
}[];
/** The typed event stream — `agent.on('*', …)`. */
on?(type: '*', listener: (event: AgentfootprintEvent) => void): Unsubscribe;
/**
* The records TOOLS kept of their own runs — present when the agent
* mounts a `flowchartAsTool({ keepRecord: true })`. Unlike the other
* three, this is NOT captured at the terminal flush: the store is a live,
* bounded object owned by the tool, and the binding holds the lookup so
* `inspect_tool_run` reads whatever the tool has filed. Rotating it per
* turn would throw away the previous turn's inner runs at exactly the
* moment the follow-up question arrives.
*/
getInnerRuns?(): InnerRunLookup | undefined;
/**
* The conversation the agent is serving — or, between runs, served last —
* and that run's id. The conversation is the KEY the evidence is kept and
* served under: read at the terminal flush it names the conversation the
* finished turn belongs to, read inside a turn it names the conversation
* asking. It is the run's session; a HOSTED run with no session gets the
* host's per-request key, which nobody can present again; only a direct run
* with no session shares `undefined`. Absent (a bare `getSnapshot` binding)
* every run shares one key — the single-conversation behaviour — and inner
* runs are served unfiltered.
*/
getServing?(): SelfExplainServing;
}
/**
* 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 declare class SelfExplainBinding {
private readonly include;
private readonly maxEvents;
private source;
private ctrl;
private 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). */
private readonly lasting;
private readonly oneShot;
constructor(include?: SelfExplainInclude, maxEvents?: number);
private get wantsNarrative();
private get wantsEvents();
/**
* 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: SelfExplainSource | (() => RuntimeSnapshot | undefined)): void;
/**
* 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(): TraceToolpackArtifacts | undefined;
/**
* 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: string | undefined): boolean;
/** The conversation (and run) in flight, or served last. */
private serving;
/** Which shelf a conversation lives on. */
private shelfOf;
/** Remember that the serving run belongs to its conversation (bounded both ways). */
private recordRun;
/** File the serving conversation's evidence as its most recent, within the bound. */
private file;
/** The recorder to attach — forwards flow events to the per-run ctrl. */
recorder(): CombinedRecorder;
}
/** The default skill id — the activation key the LLM passes to read_skill. */
export declare 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 declare function buildSelfExplainSkill(options: SelfExplainOptions): Injection;
/**
* 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 declare function buildSelfExplainToolProvider(binding: SelfExplainBinding, options: SelfExplainOptions, existing?: ToolProvider): ToolProvider;
//# sourceMappingURL=selfExplain.d.ts.map