UNPKG

agentfootprint

Version:

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

442 lines • 21.8 kB
"use strict"; /** * 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). */ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.buildSelfExplainToolProvider = exports.buildSelfExplainSkill = exports.SELF_EXPLAIN_SKILL_ID = exports.SelfExplainBinding = exports.SELF_EXPLAIN_MAX_EVENTS = void 0; const footprintjs_1 = require("footprintjs"); const trace_1 = require("footprintjs/trace"); const defineSkill_js_1 = require("../injection-engine/factories/defineSkill.js"); const tools_js_1 = require("../tools/tools.js"); const eventTail_js_1 = require("../events/eventTail.js"); const skillScopedTools_js_1 = require("../tools/skillScopedTools.js"); const debugPrompt_js_1 = require("./debugPrompt.js"); const innerRunRecords_js_1 = require("../core/innerRunRecords.js"); const traceToolNames_js_1 = require("../lib/traceToolNames.js"); /** Default per-turn event cap for the self-explain binding. */ exports.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. */ class SelfExplainBinding { include; maxEvents; source; ctrl = (0, trace_1.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 = exports.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 = (0, eventTail_js_1.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 : (0, innerRunRecords_js_1.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 = (0, trace_1.controlDepRecorder)(); if (this.wantsEvents && this.source?.on) this.tail = (0, eventTail_js_1.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 ((0, footprintjs_1.isFlowEvent)(e)) this.ctrl.onError(e); }, }; } } exports.SelfExplainBinding = SelfExplainBinding; /** * 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 (0, tools_js_1.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 traceToolNames_js_1.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 Promise.resolve().then(() => __importStar(require('./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. */ exports.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}. */ function buildSelfExplainSkill(options) { return (0, defineSkill_js_1.defineSkill)({ id: options.id ?? exports.SELF_EXPLAIN_SKILL_ID, description: debugPrompt_js_1.SELF_EXPLAIN_WHEN, body: debugPrompt_js_1.SELF_EXPLAIN_BODY + (options.instruction ? `\n\n${options.instruction}` : ''), }); } exports.buildSelfExplainSkill = buildSelfExplainSkill; /** * 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. */ function buildSelfExplainToolProvider(binding, options, existing) { const skillId = options.id ?? exports.SELF_EXPLAIN_SKILL_ID; const scoped = options.delegate ? (0, skillScopedTools_js_1.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))], }; } exports.buildSelfExplainToolProvider = buildSelfExplainToolProvider; /** * 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) : traceToolNames_js_1.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 = (0, skillScopedTools_js_1.skillScopedTools)(skillId, []); let mounted; const mount = () => { mounted ??= Promise.resolve().then(() => __importStar(require('./lazyToolpack.js'))).then(({ lazyTraceToolpack }) => (0, skillScopedTools_js_1.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