agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
348 lines (347 loc) • 17.9 kB
TypeScript
/**
* 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 { type CombinedRecorder, type FlowChart, type RedactionPolicy } from 'footprintjs';
import type { Tool } from '../tools/tools.js';
/**
* Pruned snapshot view passed to `resultMapper`. We keep this minimal
* (the values bag + the chart's narrative entries) to avoid leaking
* internal scope plumbing. Consumers needing the full snapshot can
* pass a `passthrough` resultMapper that ignores the prune.
*/
export interface FlowchartToolSnapshot {
/**
* Final scope state — the merged result of every stage's writes, as the
* tool may SHOW it: `executor.getSnapshot().sharedState` without a
* `redact` policy, the redacted mirror with one (`servableSnapshot`).
*/
readonly values: Readonly<Record<string, unknown>>;
/**
* The flowchart's combined narrative entries (flow + data).
* Useful for resultMappers that want to extract specific commit
* artifacts or audit a decision path.
*/
readonly narrative: readonly {
readonly type?: string;
readonly text?: string;
}[];
}
/**
* Optional result mapper. Receives the flowchart's final snapshot
* (pruned to `FlowchartToolSnapshot`) and returns the string the LLM
* sees as the tool result.
*
* If omitted, the default behavior is `JSON.stringify(snapshot.values)`.
*
* Errors thrown from the mapper become the tool result with a
* `[mapper-error: ...]` prefix so the LLM sees a useful diagnostic.
*/
export type FlowchartResultMapper = (snapshot: FlowchartToolSnapshot) => string;
/**
* Options for `flowchartAsTool`.
*/
export interface FlowchartAsToolOptions {
/** Tool name the LLM dispatches by. Must be unique across the agent's tools. */
readonly name: string;
/** Tool description shown to the LLM. */
readonly description: string;
/**
* JSON Schema describing the input args the LLM must produce.
* Becomes `flowchart.run({ input: args })`. Default: `{ type: 'object', properties: {} }`.
*/
readonly inputSchema?: Readonly<Record<string, unknown>>;
/**
* The footprintjs flowchart to mount as the tool's body.
* The chart's stages receive args via `scope.$getArgs()`.
*/
readonly flowchart: FlowChart;
/**
* Optional shaping function. Default: `JSON.stringify(snapshot.values)`.
* Errors throw into the tool's `[mapper-error: ...]` envelope.
*/
readonly resultMapper?: FlowchartResultMapper;
/**
* Observers to attach to the tool's INTERNAL `FlowChartExecutor`
* before each run. This is the hook that lets decide()/select()
* evidence (and every other footprintjs event) inside a tool-mounted
* flowchart reach agent-layer evidence consumers — e.g. the causal
* `causalEvidenceRecorder()` bridge or `otel.decisionEvidenceRecorder()`.
* Without it, the internal executor is unobservable from outside.
*
* Each entry is a footprintjs `CombinedRecorder`, attached via
* `executor.attachCombinedRecorder` and routed by runtime
* method-shape detection — so ONE array covers all three observer
* channels (scope data-flow `onRead`/`onWrite`/`onCommit`/…,
* control-flow `onDecision`/`onSelected`/`onLoop`/…, and emit
* `onEmit`). Implement only the hooks you care about.
*
* **Per-invocation semantics:** the tool builds a FRESH executor per
* call (flowchart state never leaks between invocations) and attaches
* every recorder in this array to EACH invocation's executor before
* `run()`. The recorder INSTANCES are yours and are shared across
* invocations — a stateful recorder therefore accumulates events from
* EVERY invocation of the tool. Each invocation is a distinct run
* with a fresh `runId`; recorders needing per-invocation bookkeeping
* detect the boundary via `event.traversalContext.runId !== lastRunId`
* (Convention 4) rather than assuming one run per recorder lifetime.
*/
readonly recorders?: ReadonlyArray<CombinedRecorder>;
/**
* KEEP the inner run's record, so the agent's trace can go THROUGH this
* tool boundary instead of stopping at it.
*
* Off by default, and the default is the honest one: a retained record
* is a retained snapshot, and a tool called every turn would pin one per
* call forever. Turning it on is the caller agreeing to that memory.
*
* With it on, each invocation's record is filed under the `toolCallId`
* the outer run already uses to name the call — so `inspect_tool_call`
* gains a line teaching the descent, and `inspect_tool_run` opens the
* inner chart with the same drill vocabulary (overview → step → value →
* why). Records are bounded: the last {@link keepRecordLimit}
* invocations, least-recently-used dropped first, and a session that
* dropped some says so rather than answering "not found".
*
* What it costs when ON: one `controlDepRecorder()` attached per
* invocation (so inner slices carry the `[control: rule]` edges a
* serialized recording can never carry back), and the invocation's
* snapshot held by reference until it ages out.
*
* What it costs when OFF: nothing. No store, no extra recorder, no
* capture — the byte-identical path this tool had before the option
* existed.
*
* **One store per `flowchartAsTool(...)` call**, held on the returned
* tool — the same scoping as the `recorders` array above. Mount that one
* tool object on two agents and they share it, which is usually what you
* want (the id is the agent's own tool-call id) but is worth knowing if
* the two agents can mint the same id: build the tool twice instead.
*/
readonly keepRecord?: boolean;
/**
* How many invocations `keepRecord` retains. Default
* {@link DEFAULT_INNER_RUN_LIMIT} (20) — a debugging window, not an
* archive. Only meaningful with `keepRecord: true`; passing it alone is
* refused rather than silently ignored.
*/
readonly keepRecordLimit?: number;
/**
* Redaction policy for the INNER run, applied before every invocation
* (`executor.setRedactionPolicy`).
*
* footprintjs scrubs at COMMIT time, so a redacted key never reaches the
* inner COMMIT LOG at all — through every path, since footprintjs 9.19.0
* (see 3 below). Same mechanism, same placeholders, and the same
* `(redacted by policy)` flag as the outer run; the trace tools pass
* placeholders through verbatim and never reconstruct around them.
*
* ONE RULE FOR EVERYTHING THE TOOL SHOWS (9.89.1). With a policy set, every
* state-bearing thing this tool hands outward is footprintjs's REDACTED
* view — `getSnapshot({ redact: true })`, the mirror the engine maintains
* beside the raw heap for exactly this — taken through one owner,
* `servableSnapshot`:
*
* - the string this tool RETURNS: `resultMapper` is handed `snapshot.values`
* from that view (`'REDACTED'` where the log says so), so the default
* `JSON.stringify` and a custom mapper see the same thing the log holds;
* - a kept record's `sharedState`, `commitLog` and every subflow's final
* state (`subflowResults[*].treeContext.globalContext` — the subflow's
* own redacted mirror, served by footprintjs itself since 9.20.0);
* - on every exit — ok, error, paused — the record is filed from that view.
*
* Independent of `keepRecord` — a chart handling secrets should carry a
* policy whether or not anyone keeps its record. Without this option the
* raw snapshot is served, byte for byte as before.
*
* JOINED WITH THE CALLER'S. The inner run is nested in the agent run that
* called the tool, and a run's policy covers the runs nested in it — so the
* policy that run is covered by (`ctx.redact`, the agent's own `redact`) is
* joined with this one for the inner run, its result and its kept record.
* Either alone is enough to keep a name out; neither can remove one the
* other selects. With neither, the tool behaves exactly as before.
*
* WHAT IT DOES NOT GOVERN — said here so nobody has to rediscover it:
*
* 1. The run's fold base. The redacted view OMITS `initialState`
* (footprintjs `ExecutionRuntime.getSnapshot`: the raw pre-run seed
* never passed a policy, so it is dropped rather than served). A fold of
* a kept record therefore reports `basis: 'log-only'` — partial, and
* saying so. A subflow's `treeContext.initialState` travels as
* footprintjs serves it: its seed is a commit of its own (`history[0]`),
* so that base is the nested runtime's pre-seed state.
* 2. The resume checkpoint. A paused run throws with `err.checkpoint`,
* which holds real values because resumption must replay against them;
* it goes to the agent loop, never to a model.
* 3. What the LOG itself carries — footprintjs's law, not this option's,
* and since footprintjs 9.19.0 (this package's floor from 9.89.2) that
* law is one rule: a policy covers everything the run retains or serves
* — the log in both encodings, the mirror, `stageReads`/`stageWrites`,
* the narrative, a subflow's `inputMapper` seed (its `history[0]` and
* its `Input:` line), its `outputMapper` merge-back, `fields` dot-paths
* — and never the live heap or the checkpoint. The five places 9.18
* left plaintext in the record (dot-path fields, merge-back, seed, seed
* narrative, tracked reads) are closed at the root, and each is asserted
* closed in `test/core/flowchartAsTool.redact.test.ts` §6 (red on 9.18).
* So "every field" means every field, and the log agrees. The last
* limit — `subflowResults[*].treeContext.globalContext` and its `#n`
* twin were the subflow's own heap, since only the run-level runtime
* kept a mirror, which `servableSnapshot` refolded from the scrubbed
* `history` — is closed by footprintjs 9.20.0 (this package's floor
* from 9.89.3): a subflow keeps its own mirror whenever the run does,
* and the redacted view serves it, one object under both keys. Nothing
* left the log carries that the served view does not scrub; the
* checkpoint is not a served view. `servableSnapshot` is now that view
* exactly as the substrate serves it, and §7 of the same file pins both
* the placeholder in footprintjs's own view (red on 9.19.x) and the
* identity.
*/
readonly redact?: RedactionPolicy;
}
/**
* 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 declare function flowchartAsTool(opts: FlowchartAsToolOptions): Tool;