agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
355 lines • 17.7 kB
JavaScript
/**
* 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();
*/
import { FlowChartExecutor, } from 'footprintjs';
import { controlDepRecorder } from 'footprintjs/trace';
import { DEFAULT_INNER_RUN_LIMIT, INNER_RUN_RECORDS, innerRunStore, } from './innerRunRecords.js';
import { unionRedactionPolicies } from '../redaction/policy.js';
import { redactionMarker } from '../redaction/marker.js';
import { modelFacingState, servableSnapshot, watchRunRule } from './servableSnapshot.js';
import { argsRedactedBy, SHOWN_ARGS } from './toolShownArgs.js';
import { defineTool } from '../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.
*/
export 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
? innerRunStore(opts.keepRecordLimit ?? DEFAULT_INNER_RUN_LIMIT)
: undefined;
const tool = 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 = unionRedactionPolicies(opts.redact, ctx.redact);
// Only a joined caller policy needs the run's own rule (for its marks).
const watch = policy !== opts.redact ? watchRunRule(opts.flowchart) : undefined;
const executor = new 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(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 ? 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 ?? 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 = 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: 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 ? { [SHOWN_ARGS]: 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, [INNER_RUN_RECORDS]: store };
return keepsRecords;
}
/**
* 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