agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
280 lines (279 loc) • 13.3 kB
TypeScript
/**
* graph() — a FIXED DAG of runners, levelized at build time.
*
* WHY this exists: `Sequence` and `workflow()` run steps in a line, and
* `Parallel` fans out ONCE and merges. Real pipelines are neither: an
* intake step feeds two independent lookups, and a writer waits for both.
* Expressing that with the existing compositions means nesting a Parallel
* inside a Sequence and threading values through by hand — and on this
* codebase that hand-off silently loses structured data (see "The trap"
* below). `graph()` states the shape once, as nodes and edges, and lets
* the engine work out what can run at the same time.
*
* What it gives you:
* - **Concurrency you did not have to schedule.** Kahn levelization at
* BUILD time groups nodes with no dependency between them; every node
* in a level runs at the same time.
* - **A shape checked before it runs.** A cycle, an edge pointing at a
* node that does not exist, or a duplicate id is refused at BUILD
* time, naming the offender. You cannot construct a broken graph.
* - **No silent merges.** A node with two or more parents MUST declare
* a `join` — a silent merge is a wrong merge, so the build refuses
* and names the node.
* - **Values, not text.** An edge's payload is the producer's OUTPUT
* handed to the consumer, unchanged. There is no shared mutable scope
* between nodes: a node reads exactly what its parents produced.
*
* Pattern: Adapter over footprintjs's subflow mounts — a level with
* several nodes becomes stacked `addSubFlowChart` calls (a fork,
* run concurrently); a level with one node is mounted
* sequentially (`addSubFlowChartNext`, which resumes cleanly
* across a pause). One join stage between levels.
* Role: core-flow/ layer, alongside Sequence/Parallel/Conditional/
* Loop/Workflow. Pure control flow — no LLM dependency.
* Emits: agentfootprint.composition.enter / exit, reported as kind
* `'Sequence'`. See "Why kind 'Sequence'" below.
*
* ## The trap this was built around (verified against footprintjs, not docs)
*
* The obvious sketch — `graph = Sequence(Parallel(level0), Parallel(level1), …)`
* — does NOT work on this codebase, for two independent reasons:
*
* 1. `Sequence`'s step contract is `{ message: string } -> string`, and
* its step `outputMapper` coerces a non-string step output to `''`.
* `workflow()` (v7.10.0) exists precisely because of this.
* 2. `Parallel` has the SAME limit one layer down: its branch type is
* `Runner<{ message: string }, string>` and its branch `outputMapper`
* coerces a non-string branch output to `''` (`Parallel.ts`, the
* `typeof sfOutput === 'string' ? sfOutput : ''` mapper). So a
* Parallel level cannot carry a structured value either.
*
* So `graph()` is built on the pass-through model `workflow()` established
* — its own composition, its own mappers, the same recorder wiring and the
* same `composition.enter` / `exit` events — rather than on top of
* Sequence/Parallel.
*
* ## Why kind 'Sequence'
*
* `CompositionKind` is a CLOSED public union (`'Sequence' | 'Parallel' |
* 'Conditional' | 'Loop'`). Widening it would break exhaustive switches in
* consumer code for no behavioural gain — the same call v7.10.0 made for
* `workflow()`. A graph's LEVELS are a sequence (level 0, then level 1, …),
* so `'Sequence'` is the honest member of that union: this composition runs
* its levels in order. The fan-out WITHIN a level is visible in the chart
* itself (a fork node per level), which is where a renderer reads it from.
*
* ## Honest limits (all verified against the engine, pinned in tests)
*
* 1. Only PLAIN DATA crosses a node boundary — the same limit
* `workflow()` documents. A value with a prototype (Date, Map, class
* instance) arrives as `{}`; `undefined` fields are dropped.
* 2. A node must RETURN its output: the value handed to its children is
* the node chart's traversal result.
* 3. A node that THROWS is always reported as
* `graph '<id>': node '<node>' failed: <reason>`, but it reaches that
* sentence by two different routes. In a CONCURRENT level footprintjs
* runs children under `Promise.allSettled`, so a failed child is
* simply ABSENT from the results and the level join turns that
* absence into the error. In a SEQUENTIAL (single-node) level the
* error rejects the run raw, and `rethrowWithNodeAttribution` renames
* it. Consumers see one shape either way.
* 4. A node that PAUSES surfaces as a pause — the engine halts the
* traversal before the level's join runs, so `run()` returns a
* `RunnerPauseOutcome`. `resume()` then carries on through the REST
* of the graph only when the paused node was ALONE in its level (a
* sequential mount). Resuming into a fork child completes that child
* and stops: the remaining levels do not run. Give a node that asks a
* human a level of its own. Both halves are pinned in tests.
*
* @example a diamond: A feeds B and C, D waits for both
* ```ts
* const pipeline = graph({
* nodes: [
* { id: 'intake', runner: intake },
* { id: 'orders', runner: lookupOrders },
* { id: 'billing', runner: lookupBilling },
* {
* id: 'reply',
* runner: writeReply,
* // Two parents ⇒ a join is REQUIRED. `upstream` is keyed by node id.
* join: (upstream) => ({
* orders: upstream.orders as OrderInfo,
* billing: upstream.billing as BillingInfo,
* }),
* },
* ],
* edges: [
* { from: 'intake', to: 'orders' },
* { from: 'intake', to: 'billing' },
* { from: 'orders', to: 'reply' },
* { from: 'billing', to: 'reply' },
* ],
* });
*
* const out = await pipeline.run({ message: 'where is my refund?' });
* // out = { intake: …, orders: …, billing: …, reply: … } — keyed by node id
* ```
*/
import { type FlowchartCheckpoint, type StructureRecorder } from 'footprintjs';
import type { RunOptions } from '../core/runner.js';
import type { RunnerPauseOutcome } from '../core/pause.js';
import type { Runner } from '../core/runner.js';
import { RunnerBase } from '../core/RunnerBase.js';
/**
* One node of the graph: an id, the runner that does the work, and — when
* the node has more than one parent — how to merge what those parents
* produced into this node's input.
*/
export interface GraphNode<I = unknown, O = unknown> {
/** Unique within the graph. Used as the results key and the chart node id. */
readonly id: string;
/** The work. Any Runner: LLMCall, Agent, a Sequence, another graph. */
readonly runner: Runner<I, O>;
/**
* Merge upstream outputs into this node's input. `upstream` is keyed by
* PARENT NODE ID, and each value is that parent's output, unchanged.
*
* Optional for a node with 0 or 1 parents (a single parent's output is
* passed through). **REQUIRED when a node has 2+ parents** — a silent
* merge is a wrong merge, so the build refuses and names the node.
*/
readonly join?: (upstream: Readonly<Record<string, unknown>>) => I;
/** Human-friendly label for events + topology. Default: the node id. */
readonly name?: string;
}
/** A directed dependency: `from` must finish before `to` starts. */
export interface GraphEdge {
readonly from: string;
readonly to: string;
}
export interface GraphOptions {
/** The nodes. Ids must be unique; at least one is required. */
readonly nodes: readonly GraphNode<any, any>[];
/** The dependencies. Every endpoint must name a declared node. */
readonly edges: readonly GraphEdge[];
/** Human-friendly name for events + topology. Default `'Graph'`. */
readonly name?: string;
/** Stable id used for topology + events. Default `'graph'`. */
readonly id?: string;
/**
* Optional build-time recorders passed through to footprintjs's
* `flowChart()` factory — they observe this graph's OWN nodes (Seed +
* one mount per graph node + one join per level + Finalize). Not
* propagated into the mounted node charts; attach them to each node
* runner for full coverage.
*/
readonly structureRecorders?: readonly StructureRecorder[];
}
/** The graph's own input — handed to every ROOT node (one with no parents). */
export type GraphInput = Record<string, unknown>;
/** Outputs keyed by node id. Every node that ran contributes one entry. */
export type GraphOutput = Record<string, unknown>;
/**
* Kahn levelization: group nodes so that everything in level N depends
* only on levels < N. Nodes within a level are independent BY
* CONSTRUCTION, which is exactly the licence to run them concurrently.
*
* Declaration order is preserved inside each level so a graph's chart —
* and therefore its trace — is deterministic.
*
* Throws (naming the offender) on: an unknown edge endpoint, a duplicate
* node id, a cycle, or a fan-in > 1 with no `join`.
*/
export declare function levelize(nodes: readonly GraphNode<any, any>[], edges: readonly GraphEdge[]): readonly (readonly GraphNode<any, any>[])[];
/**
* A fixed DAG of runners. Build one with {@link graph}.
*/
export declare class Graph extends RunnerBase<GraphInput, GraphOutput> {
readonly name: string;
readonly id: string;
private readonly nodes;
private readonly levels;
private readonly parentsOf;
private readonly opts;
private currentRunContext;
/**
* Per-node first-error records for the current run. footprintjs's
* `SubflowExecutor` swallows a subflow error into the parent's debug
* bag and skips the `outputMapper`, so the message never reaches parent
* scope on its own. An internal recorder captures it here; the level
* join reads it to name what actually went wrong. Mirrors Parallel's
* `branchErrors`, epoch-guarded for the same reason.
*/
private readonly nodeErrors;
/** Monotonic run token — see Parallel's `runEpoch`. */
private runEpoch;
constructor(opts: GraphOptions);
/** How the graph was levelized — level 0 first. Stable post-construction. */
getLevels(): readonly (readonly string[])[];
run(input: GraphInput, options?: RunOptions): Promise<GraphOutput | RunnerPauseOutcome>;
resume(checkpoint: FlowchartCheckpoint, input?: unknown, options?: RunOptions): Promise<GraphOutput | RunnerPauseOutcome>;
/**
* Give a RAW rejection the same node-naming shape the level join
* produces.
*
* A node in a SEQUENTIAL (single-node) level rejects the run with its
* own error — the level join never runs, so nothing has attributed it to
* a node yet. The error recorder did see it, so correlate (by identity
* first, then bare message) and rename. Anything that does not correlate
* — including the join's own already-attributed error — is rethrown
* untouched.
*/
private rethrowWithNodeAttribution;
private createExecutor;
/**
* Capture the first error per node. The node id is the first segment of
* the engine-prefixed `stageId` (`orders/call-llm` → node `orders`) —
* the same correlation Parallel uses, and the only one that survives a
* node mounting subflows of its own.
*/
private makeNodeErrorRecorder;
private finalizeResult;
private buildChart;
/**
* What one node receives. Roots get the graph's own input; a single
* parent is passed through; 2+ parents go through the node's `join`
* (which the build already guaranteed exists).
*
* A root's input is the graph's own, as it came (a composed mark handed to
* the graph included). Every other node is handed OTHER runners' output —
* marked for a runner that reads the marker (`core/messageFrom.ts` ·
* `composedInput`), so another model's words never count as the person's;
* every other runner gets the input it always did.
*
* `parent` here is the RAW parent state the engine hands an
* `inputMapper` — not a TypedScope — so structured upstream values read
* back intact.
*/
private inputForNode;
}
/**
* Build a fixed DAG of runners. Independent nodes run concurrently; the
* result is every node's output, keyed by node id.
*
* The shape is checked at BUILD time — a cycle, an edge pointing at an
* unknown node, a duplicate id, or a 2+-parent node with no `join` throws
* here, naming the offender, rather than misbehaving mid-run.
*
* @example a fan-out with a merge
* ```ts
* const pipeline = graph({
* nodes: [
* { id: 'plan', runner: planner },
* { id: 'search', runner: searcher },
* { id: 'recall', runner: memory },
* { id: 'answer', runner: writer, join: (u) => ({ ...u }) },
* ],
* edges: [
* { from: 'plan', to: 'search' },
* { from: 'plan', to: 'recall' },
* { from: 'search', to: 'answer' },
* { from: 'recall', to: 'answer' },
* ],
* });
*
* const out = await pipeline.run({ message: 'what changed last week?' });
* console.log(out.answer);
* ```
*/
export declare function graph(opts: GraphOptions): Graph;