/** * 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 '': node '' failed: `, 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 RunOptions, type StructureRecorder } from 'footprintjs'; 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 { /** 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; /** * 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>) => 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[]; /** 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; /** Outputs keyed by node id. Every node that ran contributes one entry. */ export type GraphOutput = Record; /** * 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[], edges: readonly GraphEdge[]): readonly (readonly GraphNode[])[]; /** * A fixed DAG of runners. Build one with {@link graph}. */ export declare class Graph extends RunnerBase { 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; resume(checkpoint: FlowchartCheckpoint, input?: unknown, options?: RunOptions): Promise; /** * 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). * * `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;