UNPKG

agentfootprint

Version:

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

343 lines • 17.5 kB
"use strict"; /** * AgentThinkingTraceRecorder — produce an AgentThinkingUI `Trace` from a run. * * AgentThinkingUI (the "watch it think" player) consumes a framework-agnostic * `Trace` — a list of beats: `prompt → ask → return → answer`, where a tool * reply is `data` (reason) or an `instruction` (a skill that says how to act). * This recorder builds that `Trace` from agentfootprint's emit stream AS THE RUN * TRAVERSES (no post-processing) — so any agentfootprint agent gets the * domain-expert view for free, and AgentThinkingUI stays vendor-agnostic (it * just renders the `Trace` JSON, exactly as it renders the OTLP adapter's output). * * Mapping (from the events already on the stream): * stream.llm_end (toolCalls>0) → the brain reasoned; content + usage become * the upcoming ask's `brain` + `cost`. * stream.llm_end (toolCalls==0) → the final `answer`, unless contentWithheld. * agent.turn_end → a withheld answer becomes a beat only on * accepted delivery, using finalContent. * stream.tool_start → `ask` (read_skill → reaching for a skill). * stream.tool_end → `return` (read_skill → replyType:'instruction' * + skill; any other tool → replyType:'data'). * a bracket carrying → no beat (9.113.0): a call a paused batch * `notDispatched` never dispatched neither asked nor returned. * * Commentary (each beat's `brain`): filled from agentfootprint's OWN commentary * engine — the SAME `selectCommentaryKey`/`extractCommentaryVars`/`renderCommentary` * the Lens uses — so AgentThinkingUI's Notepad / bottom caption read identically * to the Lens commentary panel (one engine, consumer-overridable via * `commentaryTemplates`). The LLM's own reasoning still wins on the first ask of * an iteration; the engine fills every other beat so no line is ever blank. * * Convention 1 (one purpose) + Convention 4 (run-scoped — resets per run). */ Object.defineProperty(exports, "__esModule", { value: true }); exports.agentThinkingTrace = void 0; const commentaryTemplates_js_1 = require("./commentary/commentaryTemplates.js"); const LLM_START = 'agentfootprint.stream.llm_start'; const LLM_END = 'agentfootprint.stream.llm_end'; const TOOL_START = 'agentfootprint.stream.tool_start'; const TOOL_END = 'agentfootprint.stream.tool_end'; const THINKING_END = 'agentfootprint.stream.thinking_end'; const TURN_END = 'agentfootprint.agent.turn_end'; const CONTEXT_EVALUATED = 'agentfootprint.context.evaluated'; // Skill-graph routing verdicts (9.16.0/9.17.0) — narrated via the same // commentary engine and stashed to lead the next beat, exactly like the // `context.evaluated` routing line. Both fire at most once per turn/batch. const TURN_ROUTED = 'agentfootprint.skill.turn_routed'; const ROUTE_CONFLICT = 'agentfootprint.skill.route_conflict'; function asObject(x) { if (x != null && typeof x === 'object' && !Array.isArray(x)) return x; return { value: x }; } function headlineOf(s) { const line = (s ?? '').split('\n').find((l) => l.trim().length > 0) ?? ''; return line.length > 140 ? line.slice(0, 140) + '…' : line || 'Done'; } /** `EmitEvent.subflowPath` may arrive as a `/`-joined string or already split; * normalize to the array shape the commentary engine's `extractAgentName` reads. */ function splitPath(p) { if (Array.isArray(p)) return p; if (typeof p === 'string' && p.length > 0) return p.split('/'); return []; } function agentThinkingTrace(options = {}) { let lastPipelineId; let task = ''; let steps = []; // Index-aligned with `steps`: the stage each beat was recorded under. let at = []; // The most recent reasoning + its cost, awaiting the iteration's ask step(s). let pendingBrain = ''; let pendingCost = { ms: 0, tokens: 0 }; let pendingCostUsed = false; // Model's extended-thinking chain-of-thought for the current iteration (joined // from the iteration's reasoning blocks); attached to the iteration's first ask // (or back-filled onto the answer beat). '' when thinking is off / empty. let pendingThinking = ''; // Skill-graph routing narration to lead the next beat (from `context.evaluated`, // which fires before the LLM call) — so the Notepad opens with WHY this skill was // chosen. '' when nothing new to say. let pendingRouting = ''; // The last routing line narrated this run. `context.evaluated` re-fires every // iteration with the SAME routing while a skill stays active, so we narrate it // ONCE (on first route / when the routed skill changes), not every turn. let lastRoutingLine = ''; // The tools the model saw for the current iteration's call (from `llm_start`, // which fires before the call) — attached to the iteration's first ask / its // answer so an expert can expand "what was on the menu". undefined when none. let pendingToolsSeen; // Keep metadata only while output governance decides; withheld text is not // a delivered answer, and the accepted content belongs to the final stage. let pendingAnswer; const byId = new Map(); // Commentary engine — the SAME one the Lens uses. Merged once: consumer // overrides spread over the bundled defaults. const templates = options.commentaryTemplates ? { ...commentaryTemplates_js_1.defaultCommentaryTemplates, ...options.commentaryTemplates } : commentaryTemplates_js_1.defaultCommentaryTemplates; const appName = options.agent ?? 'Agent'; /** Narrate one emit event into a prose `brain` line via agentfootprint's * commentary engine. The raw `EmitEvent` is adapted to the typed * `AgentfootprintEvent` shape the engine reads (`type`/`payload`/`meta`). * Returns '' for events the engine deliberately skips. */ function narrate(e) { const ev = { type: e.name, payload: e.payload, meta: { subflowPath: splitPath(e.subflowPath) }, }; const key = (0, commentaryTemplates_js_1.selectCommentaryKey)(ev); if (!key) return ''; const vars = (0, commentaryTemplates_js_1.extractCommentaryVars)(ev, { appName }, templates); return (0, commentaryTemplates_js_1.renderCommentary)(templates[key] ?? '', vars); } /** Prepend this iteration's skill-graph routing line (if any) to a beat's * brain, then consume it so only the iteration's FIRST beat leads with it. */ function leadWithRouting(brain) { if (!pendingRouting) return brain; const lead = pendingRouting; pendingRouting = ''; return brain ? `${lead}\n\n${brain}` : lead; } function recordAnswer(e, content, metadata) { at.push(e.runtimeStageId); steps.push({ kind: 'answer', to: options.asker ?? 'you', brain: leadWithRouting(content), answer: { headline: headlineOf(content), text: content }, ...metadata, }); } function reset() { task = ''; steps = []; at = []; pendingBrain = ''; pendingCost = { ms: 0, tokens: 0 }; pendingCostUsed = false; pendingThinking = ''; pendingRouting = ''; lastRoutingLine = ''; pendingToolsSeen = undefined; pendingAnswer = undefined; byId.clear(); } return { id: options.id ?? 'agent-thinking-trace', onEmit(e) { if (lastPipelineId !== undefined && e.pipelineId !== lastPipelineId) reset(); lastPipelineId = e.pipelineId; if (e.name === LLM_START) { // A later call supersedes an attempt still waiting for delivery. pendingAnswer = undefined; // Fires as the call begins — capture the tool menu the model saw, to // attach to this iteration's first beat. Empty/absent when no tools. const p = e.payload; pendingToolsSeen = p.tools && p.tools.length > 0 ? p.tools.map((t) => ({ name: t.name, ...(t.description ? { description: t.description } : {}), })) : undefined; return; } if (e.name === TURN_ROUTED || e.name === ROUTE_CONFLICT) { // Turn-start verdict (fires BEFORE iteration 1) / batch route conflict — // narrate and queue to lead the next beat. Each fires at most once per // turn/batch, so no dedupe; appended so a verdict never overwrites a // pending routing line (or vice versa). const line = narrate(e); if (line) pendingRouting = pendingRouting ? `${pendingRouting}\n\n${line}` : line; return; } if (e.name === CONTEXT_EVALUATED) { // Fires before the LLM call — narrate the skill-graph routing (which skill // + why) and stash it to lead the next beat. Empty when no skillGraph() // routed. Narrate ONCE per distinct routing (it re-fires identically each // iteration while a skill stays active). // A cascade graph witnesses a start-rule route TWICE — once on its // verdict (`turn_routed`, queued just above) and once on the hop that // carried it — and since 9.28.0 both render the SAME sentence. One fact, // one line: never append what this lead already says. const line = narrate(e); if (line && line !== lastRoutingLine && !pendingRouting.split('\n\n').includes(line)) { pendingRouting = pendingRouting ? `${pendingRouting}\n\n${line}` : line; lastRoutingLine = line; } return; } if (e.name === LLM_END) { const p = e.payload; const cost = { ms: p.durationMs ?? 0, tokens: (p.usage?.input ?? 0) + (p.usage?.output ?? 0), tokensIn: p.usage?.input, tokensOut: p.usage?.output, tokensCached: p.usage?.cacheRead, }; pendingAnswer = undefined; if ((p.toolCallCount ?? 0) === 0) { const metadata = { cost, ...(pendingToolsSeen ? { toolsSeen: pendingToolsSeen } : {}), }; if (p.contentWithheld === true) pendingAnswer = metadata; else recordAnswer(e, p.content ?? '', metadata); } else { // Reasoning that will drive the upcoming ask step(s) this iteration. pendingBrain = p.content ?? ''; pendingCost = cost; pendingCostUsed = false; } return; } if (e.name === TURN_END) { const pending = pendingAnswer; pendingAnswer = undefined; const p = e.payload; // Ungoverned answers were already recorded. A refusal can carry // diagnostic content; only an accepted delivery creates this beat. if (pending && p.refused === undefined && typeof p.finalContent === 'string') recordAnswer(e, p.finalContent, pending); return; } if (e.name === THINKING_END) { // Extended-thinking blocks for this iteration. `thinking_end` fires just // AFTER `llm_end`: retain for a withheld answer or back-fill one already // delivered; an upcoming ask picks `pendingThinking` up below. const p = e.payload; pendingThinking = (p.blocks ?? []) .map((b) => (b.content ?? '').trim()) .filter(Boolean) .join('\n\n'); if (pendingAnswer) pendingAnswer.thinking = pendingThinking || undefined; else { const last = steps[steps.length - 1]; if (last && last.kind === 'answer' && pendingThinking) last.thinking = pendingThinking; } return; } if (e.name === TOOL_START) { const p = e.payload; if (!p?.toolCallId) return; // A call a paused batch never dispatched (9.113.0): the story's beats // are the model reaching for a tool and the tool answering, and this // call did neither on the record — no ask, and (since it never joins // `byId`) no return, so never a "tool failed" about a call that did // not run. The trace is what it was before the settlement existed. if (p.notDispatched !== undefined) return; const isSkill = p.toolName === 'read_skill'; const skillId = isSkill ? p.args?.id ?? undefined : undefined; byId.set(p.toolCallId, { toolName: p.toolName ?? '(tool)', isSkill, skillId }); at.push(e.runtimeStageId); steps.push({ kind: 'ask', tool: isSkill ? skillId ?? 'skill' : p.toolName ?? '(tool)', toolName: p.toolName, toolCallId: p.toolCallId, input: asObject(p.args), // First ask of the iteration carries the LLM's own reasoning (and leads // with the routing decision); later asks (and the reasoning-less ones) // fall back to engine commentary so the Notepad never shows a blank // line. `leadWithRouting` self-clears, so only the first ask leads. brain: leadWithRouting(pendingCostUsed ? narrate(e) : pendingBrain || narrate(e)), cost: pendingCostUsed ? { ms: 0, tokens: 0 } : pendingCost, // attribute the LLM cost to the first ask of the iteration // The iteration's chain-of-thought + tool menu ride on its FIRST ask only. ...(!pendingCostUsed && pendingThinking ? { thinking: pendingThinking } : {}), ...(!pendingCostUsed && pendingToolsSeen ? { toolsSeen: pendingToolsSeen } : {}), }); pendingCostUsed = true; return; } if (e.name === TOOL_END) { const p = e.payload; if (p?.notDispatched !== undefined) return; // settled — see TOOL_START const started = p?.toolCallId ? byId.get(p.toolCallId) : undefined; if (!started) return; byId.delete(p.toolCallId); at.push(e.runtimeStageId); steps.push({ kind: 'return', tool: started.isSkill ? started.skillId ?? 'skill' : started.toolName, toolName: started.toolName, toolCallId: p.toolCallId, replyType: started.isSkill ? 'instruction' : 'data', output: asObject(p.result), // The tool-result beat has no LLM reasoning of its own — narrate the // mechanics via the commentary engine (matches the Lens). Stamp the // author so the notepad never prefixes this framework sentence with // "LLM reasons —" (two narrators in one line; field-reported). brain: narrate(e), brainSource: 'framework', brainMode: started.isSkill ? 'act' : 'reason', ...(started.isSkill && started.skillId ? { skill: started.skillId } : {}), cost: { ms: p.durationMs ?? 0, tokens: 0 }, ...(p.error ? { error: 'tool failed' } : {}), }); } }, getTrace(overrides = {}) { const prompt = { kind: 'prompt', brain: overrides.task ?? task, cost: { ms: 0, tokens: 0 }, }; return { task: overrides.task ?? task, ...(overrides.title ? { title: overrides.title } : {}), agent: overrides.agent ?? options.agent ?? 'Agent', model: overrides.model ?? options.model ?? 'model', asker: overrides.asker ?? options.asker ?? 'you', steps: [prompt, ...steps], at: ['', ...at], }; }, clear() { reset(); lastPipelineId = undefined; }, }; } exports.agentThinkingTrace = agentThinkingTrace; //# sourceMappingURL=AgentThinkingTraceRecorder.js.map