UNPKG

agentfootprint

Version:

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

359 lines • 18.1 kB
"use strict"; /** * flowchartAsTool — wrap a footprintjs `FlowChart` as an Agent `Tool`. * * NOTE (9.76.0): for NEW procedures prefer `runbookAsTool` (core/runbook/) — * the standard bridge whose every answer carries the honesty envelope * (coverage, provenance, rule version, the recorded walk). This thin wrapper * stays for compatibility; `resultMapper` users stay put and nothing here * changes. * * The Block A7 piece. footprintjs is the substrate; agentfootprint is * the agent layer above it. When a multi-step procedure is already * expressed as a footprintjs flowchart (intake validation, refund * processing, claim adjudication — anything with branches, loops, or * decision evidence), let the LLM call it as ONE tool. The flowchart's * step-by-step recorders, narrative, and pause/resume continue to work * exactly as they do outside the agent. * * Why this matters: * * 1. **Composition over re-write.** A team with a non-trivial * footprintjs flowchart shouldn't have to flatten it into N * separate tools to expose it to an agent. Wrap it once. * * 2. **Observability stays free.** Every flowchart stage emits typed * events. The Agent's recorders see the wrapping tool call; * footprintjs's recorders see everything inside. Two layers, * one observation tree. The `recorders` option bridges them: * attach agent-layer observers (the causal-evidence bridge, * `otel.decisionEvidenceRecorder()`, your own CombinedRecorder) * to the tool's INTERNAL executor so decide()/select() evidence * inside the flowchart reaches them too. * * 3. **The record can go THROUGH the boundary.** `keepRecord: true` * retains each invocation's inner record under the `toolCallId` that * named the call, so a `.selfExplain()` agent asked "why did you say * it will rain?" does not stop at *"⚠ what happened inside the tool * is not traced"* — it descends with `inspect_tool_run` and cites the * inner stage and the field that decided it. Off by default: a * retained record is retained memory (bounded LRU, default 20 * invocations, `keepRecordLimit` to change it). * * 4. **Pause/resume composes.** A pausable handler inside the * flowchart pauses the inner executor; the outer agent treats * the pause as an unfinished tool call. Resume the agent and the * inner flowchart resumes from its checkpoint. (Today: surfaces * the pause as a thrown error with the checkpoint attached; * polished agent-side pause integration in v2.6.) * * Pattern: Adapter (GoF) over `FlowChartExecutor.run()`. Translates * `Tool.execute(args, ctx)` into `executor.run({ input: args, * env: { signal: ctx.signal } })` and the result back to a * string via `resultMapper` (or a default JSON stringify). * * Role: Layer-6 (Agent) → Layer-1 (footprintjs) bridge. Pure * interop; no new abstraction in either layer. * * @example Single-stage flowchart as a tool * import { flowChart } from 'footprintjs'; * import { Agent, flowchartAsTool } from 'agentfootprint'; * * const refundChart = flowChart<{ refundId: string }>( * 'RefundFlow', * async (scope) => { * const args = scope.$getArgs<{ orderId: string; reason: string }>(); * scope.refundId = await refundService.process(args.orderId); * }, * 'refund-flow', * ).build(); * * const refundTool = flowchartAsTool({ * name: 'process_refund', * description: 'Process a refund for an order. Returns refundId on success.', * inputSchema: { * type: 'object', * properties: { * orderId: { type: 'string' }, * reason: { type: 'string' }, * }, * required: ['orderId', 'reason'], * }, * flowchart: refundChart, * resultMapper: (snapshot) => * JSON.stringify({ refundId: snapshot.values.refundId, status: 'processed' }), * }); * * const agent = Agent.create({ provider }).tool(refundTool).build(); * * @example Multi-stage flowchart with decide() + recorders * const triageChart = flowChart<TriageState>('Triage', validateInput, 'validate') * .addDeciderFunction('Classify', classifyDecider, 'classify') * .addFunctionBranch('high', 'Escalate', escalate) * .addFunctionBranch('low', 'Auto-handle', autoHandle) * .end() * .build(); * * // decide() evidence inside the chart fires `onDecision` on each * // attached recorder — wire the agent's evidence consumers straight * // into the tool instead of hand-mounting the chart. * const evidence = causalEvidenceRecorder(); * * const triageTool = flowchartAsTool({ * name: 'triage_request', * description: 'Triage an incoming request and return the decision.', * inputSchema: { ... }, * flowchart: triageChart, * recorders: [evidence], * }); * * @example A tool whose inner run the agent can explain later * const adviceTool = flowchartAsTool({ * name: 'weather_advice', * description: 'Fetch tomorrow’s forecast and advise on biking.', * inputSchema: { ... }, * flowchart: adviceChart, * keepRecord: true, // keep each invocation's inner record * keepRecordLimit: 20, // …bounded (default; LRU) * redact: { keys: ['apiKey'] }, // …one rule: the log, the result, the kept record * }); * * const agent = Agent.create({ provider }) * .tool(adviceTool) * .selfExplain() // inspect_tool_call now teaches inspect_tool_run * .build(); */ Object.defineProperty(exports, "__esModule", { value: true }); exports.flowchartAsTool = void 0; const footprintjs_1 = require("footprintjs"); const trace_1 = require("footprintjs/trace"); const innerRunRecords_js_1 = require("./innerRunRecords.js"); const policy_js_1 = require("../redaction/policy.js"); const marker_js_1 = require("../redaction/marker.js"); const servableSnapshot_js_1 = require("./servableSnapshot.js"); const toolShownArgs_js_1 = require("./toolShownArgs.js"); const tools_js_1 = require("../tools/tools.js"); /** * Wrap a footprintjs `FlowChart` as a `Tool` the Agent's LLM can call. * * On execute: * 1. Constructs a fresh `FlowChartExecutor(flowchart)` per call (so * consecutive invocations don't share state). * 2. Attaches each `opts.recorders` entry via * `executor.attachCombinedRecorder` — the SAME recorder instances * attach to every invocation's fresh executor (see the option's * JSDoc for the shared-state / runId implications). * 3. Calls `executor.run({ input: args, env: { signal } })` with the * LLM-supplied args + the agent's abort signal. * 4. If the run paused, throws an Error with the checkpoint attached * (`error.checkpoint`) so the agent loop can surface it. Polished * agent-side pause integration is v2.6 work. * 5. If the run completed, calls `resultMapper(snapshot)` (or the * default JSON.stringify) and returns the string. The snapshot is * the SERVED view (`servableSnapshot`): redacted when `redact` is * set, raw otherwise — the same view the kept record holds. * 6. If the run threw, the error propagates — the Agent's * tool-call handler converts it to a synthetic error string for * the LLM to see + recover from. * 7. With `keepRecord: true`, files the inner record under * `ctx.toolCallId` on ALL THREE exits (ok / error / paused) — * "why did it fail?" is the question most likely to come next. */ function flowchartAsTool(opts) { if (!opts.name || opts.name.trim().length === 0) { throw new Error('flowchartAsTool: `name` is required and must be non-empty.'); } if (!opts.description || opts.description.length === 0) { throw new Error(`flowchartAsTool(${opts.name}): \`description\` is required.`); } if (!opts.flowchart) { throw new Error(`flowchartAsTool(${opts.name}): \`flowchart\` is required.`); } if (opts.keepRecordLimit !== undefined) { if (opts.keepRecord !== true) { throw new Error(`flowchartAsTool(${opts.name}): \`keepRecordLimit\` caps records that are never kept — ` + `\`keepRecord\` is not on, so nothing is retained and the cap governs nothing. Add ` + `\`keepRecord: true\` to keep the inner run's record (and let inspect_tool_run open ` + `it), or drop \`keepRecordLimit\`.`); } if (!Number.isInteger(opts.keepRecordLimit) || opts.keepRecordLimit < 1) { throw new Error(`flowchartAsTool(${opts.name}): \`keepRecordLimit\` must be a whole number of ` + `invocations, at least 1 — got ${String(opts.keepRecordLimit)}. A limit of 0 is ` + `\`keepRecord: false\`, and saying it with a zero is config that lies.`); } } const mapper = opts.resultMapper ?? ((snapshot) => JSON.stringify(snapshot.values)); // Built only when asked for. `undefined` here is what makes the whole // feature free when it is off: no store, no extra recorder, no capture. const store = opts.keepRecord === true ? (0, innerRunRecords_js_1.innerRunStore)(opts.keepRecordLimit ?? innerRunRecords_js_1.DEFAULT_INNER_RUN_LIMIT) : undefined; const tool = (0, tools_js_1.defineTool)({ name: opts.name, description: opts.description, inputSchema: opts.inputSchema, execute: async (args, ctx) => { // This tool's own policy joined with the one the calling run is covered // by (`ctx.redact`): the inner run is NESTED in the agent's, and a run's // policy covers the runs nested in it — every RECORD of it (the log, the // kept record, the narrative) keeps out what either names. What the // MODEL reads is the tool's own business: `modelFacingState` below. const policy = (0, policy_js_1.unionRedactionPolicies)(opts.redact, ctx.redact); // Only a joined caller policy needs the run's own rule (for its marks). const watch = policy !== opts.redact ? (0, servableSnapshot_js_1.watchRunRule)(opts.flowchart) : undefined; const executor = new footprintjs_1.FlowChartExecutor(opts.flowchart, watch !== undefined ? { scopeFactory: watch.scopeFactory } : undefined); if (policy) { executor.setRedactionPolicy(policy); // The kept record says it was served under a policy, so its readers // read a placeholder as a value kept out (`redaction/marker.ts`). executor.attachCombinedRecorder((0, marker_js_1.redactionMarker)()); } for (const recorder of opts.recorders ?? []) { executor.attachCombinedRecorder(recorder); } // A fresh control-dependence recorder PER INVOCATION, only when the // record is being kept. Live in process, so unlike a serialized // recording the inner slices can carry `[control: rule]` edges. const ctrl = store ? (0, trace_1.controlDepRecorder)() : undefined; if (ctrl) executor.attachCombinedRecorder(ctrl); /** * File this invocation's record. Called on ALL THREE exits — a run * that threw and a run that paused are complete records of what * happened, and "why did it fail?" is the question most likely to * come next. * * Capture failure never fails the tool call: the call already * succeeded (or already failed on its own terms), and losing the * record is not a reason to change that. It is not swallowed either * — the record is filed carrying the reason, so `inspect_tool_run` * says what went wrong instead of behaving like the call never * happened. */ const keepRecordOf = (outcome, known) => { if (!store) return; try { const snapshot = known ?? (0, servableSnapshot_js_1.servableSnapshot)(executor, policy); const commitLog = snapshot.commitLog; const narrative = extractNarrative(snapshot, executor) .map((entry) => (typeof entry.text === 'string' ? entry.text : '')) .filter((text) => text.length > 0); store.keep({ toolCallId: ctx.toolCallId, ...(ctx.runId !== undefined && { runId: ctx.runId }), toolName: opts.name, outcome, steps: Array.isArray(commitLog) ? commitLog.length : 0, recording: { snapshot, structure: opts.flowchart.buildTimeStructure, }, ...(ctrl !== undefined && { controlDeps: ctrl.asLookup() }), ...(narrative.length > 0 && { narrative }), }); } catch (e) { store.keep({ toolCallId: ctx.toolCallId, ...(ctx.runId !== undefined && { runId: ctx.runId }), toolName: opts.name, outcome, steps: 0, problem: e instanceof Error ? e.message : String(e), }); } }; const env = {}; if (ctx.signal) env.signal = ctx.signal; try { await executor.run({ input: args, env }); } catch (e) { keepRecordOf('error'); throw e; } if (executor.isPaused()) { keepRecordOf('paused'); const err = new Error(`flowchartAsTool(${opts.name}): inner flowchart paused. Agent-side pause ` + `integration lands with the runbook program's approval-gate phase (see ` + `runbookAsTool). The checkpoint is on err.checkpoint.`); err.checkpoint = executor.getCheckpoint(); throw err; } // The RECORD view: with a policy set the redacted mirror exactly as // footprintjs serves it, otherwise the raw snapshot — see // `servableSnapshot`. const served = (0, servableSnapshot_js_1.servableSnapshot)(executor, policy); // Kept BEFORE the mapper runs: a mapper that throws still leaves a // complete record of the run it was mapping. keepRecordOf('ok', served); // The MODEL's view of the state: under the tool's own `redact` only — a // calling run's policy never reaches the model's input // (`servableSnapshot.ts` · `modelFacingState`); with none joined, the // served view's state, as always. The narrative the mapper may quote is // the run's, served under the union (a named limit: // `src/redaction/README.md`). const snapshot = { values: (0, servableSnapshot_js_1.modelFacingState)(executor, opts.redact, watch !== undefined ? ctx.redact : undefined, watch?.rule()), narrative: extractNarrative(served, executor), }; try { return mapper(snapshot); } catch (e) { // Preserve the consumer's error string but mark the envelope // so the LLM can see it was a result-mapper error and not the // flowchart itself. const reason = e instanceof Error ? e.message : String(e); return `[mapper-error: ${reason}]`; } }, }); // The same policy on the ARGUMENTS an event may show (`toolShownArgs.ts` · // `argsRedactedBy`): the result is already the redacted view, and a key the // policy names must not ride out beside it in the call's arguments. Under // the same kind of registry symbol as the store below; no policy → none. const shown = opts.redact !== undefined ? { [toolShownArgs_js_1.SHOWN_ARGS]: (0, toolShownArgs_js_1.argsRedactedBy)(opts.redact) } : {}; // The store rides the Tool under a registry symbol — invisible to the // LLM, invisible to `Tool`'s shape, and found by `innerRunsOf()`. The // Agent builder collects it at `.build()` and hands it to the trace // artifacts, so the descent needs no wiring from the consumer. if (store === undefined) return opts.redact === undefined ? tool : { ...tool, ...shown }; const keepsRecords = { ...tool, ...shown, [innerRunRecords_js_1.INNER_RUN_RECORDS]: store }; return keepsRecords; } exports.flowchartAsTool = flowchartAsTool; /** * Pull the narrative entries off the snapshot or executor in a * defensive way — footprintjs's snapshot shape is RuntimeSnapshot but * the public field names vary across minor versions. We probe known * shapes and fall back to an empty array. * * The executor exposes `getNarrativeEntries()` which is the canonical * source today; we prefer that, then fall back to snapshot fields. */ function extractNarrative(raw, executor) { if (executor && typeof executor.getNarrativeEntries === 'function') { try { const entries = executor.getNarrativeEntries(); if (Array.isArray(entries)) { return entries; } } catch { // fall through to snapshot probe } } if (!raw || typeof raw !== 'object') return []; const candidate = raw.narrative ?? raw.narrativeEntries; if (Array.isArray(candidate)) { return candidate; } return []; } //# sourceMappingURL=flowchartAsTool.js.map