agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
674 lines • 32.6 kB
JavaScript
/**
* 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 { assertUnreservedSubflowSegment } from '../lib/conventions.js';
import { FlowChartExecutor, flowChart, isFlowEvent, } from 'footprintjs';
import { composedInput, readsMessageFromIfAny } from '../core/messageFrom.js';
import { adoptMemberRedaction } from '../redaction/declared.js';
import { RunnerBase, makeRunId, underRunSignal } from '../core/RunnerBase.js';
import { agentRecorder } from '../recorders/core/AgentRecorder.js';
import { compositionRecorder } from '../recorders/core/CompositionRecorder.js';
import { ContextRecorder } from '../recorders/core/ContextRecorder.js';
import { streamRecorder } from '../recorders/core/StreamRecorder.js';
import { typedEmit } from '../recorders/core/typedEmit.js';
import { childError, childOutcome } from './childOutcome.js';
import { adoptParentRun, parentRunIn, readsParentRun, closeParentRun, openParentRun, reopenParentRun, withParentRun, } from '../core/memberRun.js';
import { requestOf } from '../core/requestOfRun.js';
/**
* The one sentence every node failure is reported with, whatever mount the
* level used. A graph has two mount paths — a fork for concurrent levels, a
* sequential mount for single-node ones — and footprintjs surfaces failures
* differently through each (a fork child's error is swallowed into absence;
* a sequential child's rejects the run raw). Consumers should not have to
* know which one they got, so both are reported like this.
*/
function nodeFailureMessage(graphId, nodeId, reason) {
return `graph '${graphId}': node '${nodeId}' failed: ${reason}`;
}
// ─── Build-time validation + levelization ────────────────────────────
/**
* 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 function levelize(nodes, edges) {
if (nodes.length === 0) {
throw new Error('graph: needs at least one node');
}
const byId = new Map();
for (const node of nodes) {
if (byId.has(node.id)) {
throw new Error(`graph: duplicate node id '${node.id}' — every node id must be unique (it is the results key).`);
}
// Node ids are mounted verbatim as subflow segments; an 'sf-' name would be
// filtered as framework plumbing by every downstream reader.
assertUnreservedSubflowSegment('graph()', 'node id', node.id);
byId.set(node.id, node);
}
const parents = new Map();
const children = new Map();
for (const node of nodes) {
parents.set(node.id, []);
children.set(node.id, []);
}
for (const edge of edges) {
if (!byId.has(edge.from)) {
throw new Error(`graph: edge '${edge.from}' -> '${edge.to}' references unknown node '${edge.from}'.`);
}
if (!byId.has(edge.to)) {
throw new Error(`graph: edge '${edge.from}' -> '${edge.to}' references unknown node '${edge.to}'.`);
}
parents.get(edge.to)?.push(edge.from);
children.get(edge.from)?.push(edge.to);
}
// Fan-in > 1 demands an explicit join. Checked BEFORE the cycle walk so
// the more actionable error wins on a graph that has both problems.
for (const node of nodes) {
const up = parents.get(node.id) ?? [];
if (up.length > 1 && node.join === undefined) {
throw new Error(`graph: node '${node.id}' has ${up.length} parents (${up.join(', ')}) but no join — ` +
'a silent merge is a wrong merge. Give the node a join(upstream) that returns its input; ' +
'upstream is keyed by parent node id.');
}
}
// Kahn: repeatedly take every node whose parents have all been placed.
const remainingParents = new Map();
for (const node of nodes)
remainingParents.set(node.id, (parents.get(node.id) ?? []).length);
const levels = [];
let frontier = nodes.filter((n) => remainingParents.get(n.id) === 0);
let placed = 0;
while (frontier.length > 0) {
levels.push(frontier);
placed += frontier.length;
const next = [];
for (const node of frontier) {
for (const childId of children.get(node.id) ?? []) {
const left = (remainingParents.get(childId) ?? 0) - 1;
remainingParents.set(childId, left);
if (left === 0) {
const child = byId.get(childId);
if (child !== undefined)
next.push(child);
}
}
}
// Restore declaration order — `next` is built in parent-visit order.
frontier = nodes.filter((n) => next.includes(n));
}
if (placed < nodes.length) {
const stuck = new Set(nodes.filter((n) => (remainingParents.get(n.id) ?? 0) > 0).map((n) => n.id));
throw new Error(`graph: cycle detected — edge '${findBackEdge(stuck, edges)}' closes a loop. A graph must be acyclic.`);
}
return levels;
}
/**
* Name ONE edge that closes a cycle, so the error can point at something
* the author can delete. Depth-first over the nodes Kahn could not place;
* the first edge back onto the current stack is the one reported.
*/
function findBackEdge(stuck, edges) {
const out = new Map();
for (const edge of edges) {
if (stuck.has(edge.from) && stuck.has(edge.to)) {
const list = out.get(edge.from) ?? [];
list.push(edge.to);
out.set(edge.from, list);
}
}
const onStack = new Set();
const done = new Set();
let found;
const walk = (id) => {
if (found !== undefined)
return;
onStack.add(id);
for (const next of out.get(id) ?? []) {
if (found !== undefined)
return;
if (onStack.has(next)) {
found = `${id}' -> '${next}`;
return;
}
if (!done.has(next))
walk(next);
}
onStack.delete(id);
done.add(id);
};
for (const id of stuck) {
if (!done.has(id))
walk(id);
if (found !== undefined)
break;
}
// Every unplaced node is on a cycle, so a back edge always exists; the
// fallback keeps the error useful rather than throwing inside a thrower.
return found ?? [...stuck].join("' -> '");
}
/**
* Hand a producer's value to a consumer as its input args.
*
* `string` → `{ message }` (the house convention every LLM runner here
* speaks, identical to `workflow()`'s hand-off rule). Plain object →
* itself. Anything else is a broken hand-off and says so, naming both
* ends — the alternative is an empty input inside the consumer with
* nothing pointing back here.
*/
function toNodeArgs(value, nodeId, source) {
if (typeof value === 'string')
return { message: value };
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
return { ...value };
}
const got = value === null ? 'null' : Array.isArray(value) ? 'an array' : typeof value;
throw new Error(`graph: ${source} handed node '${nodeId}' ${got}, but a node needs an object ` +
'(or a string, which arrives as { message }). Make each node return its output.');
}
// ─── The composition ─────────────────────────────────────────────────
/**
* A fixed DAG of runners. Build one with {@link graph}.
*/
export class Graph extends RunnerBase {
name;
id;
nodes;
levels;
parentsOf;
opts;
currentRunContext = {
runStartMs: 0,
runId: 'pending',
compositionPath: [],
};
/**
* 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.
*/
nodeErrors = new Map();
/** Monotonic run token — see Parallel's `runEpoch`. */
runEpoch = 0;
constructor(opts) {
super();
this.opts = opts;
this.name = opts.name ?? 'Graph';
this.id = opts.id ?? 'graph';
this.nodes = opts.nodes;
// Levelization VALIDATES — a broken graph throws here, at construction.
this.levels = levelize(opts.nodes, opts.edges);
const parents = new Map();
for (const node of opts.nodes)
parents.set(node.id, []);
for (const edge of opts.edges)
parents.get(edge.to)?.push(edge.from);
this.parentsOf = parents;
// Holding a node that reads `messageFrom`, it reads it too (`core/messageFrom.ts`).
readsMessageFromIfAny(this, opts.nodes.map((n) => n.runner));
// A node's redaction policy covers this composition's run — every node
// runs on its executor (`src/redaction/declared.ts`).
adoptMemberRedaction(this, opts.nodes.map((n) => n.runner));
// It hands the run it serves to its members (`core/memberRun.ts`).
readsParentRun(this);
// Eager chart construction — see `RunnerBase.initChart` JSDoc.
this.initChart(() => this.buildChart());
}
/** How the graph was levelized — level 0 first. Stable post-construction. */
getLevels() {
return this.levels.map((level) => level.map((n) => n.id));
}
async run(input, options) {
// The incoming request, refused here — before anything moves — when
// requestContext() did not build it.
const request = requestOf(options, 'graph.run');
const executor = this.createExecutor();
this.lastExecutor = executor;
// The run every node serves — the caller's identity when the input names
// one (`input.identity`), else this run's own conversation (`core/memberRun.ts`).
const parentRun = openParentRun(this.currentRunContext.runId, input?.identity, 'graph.run', request);
try {
let result;
try {
result = await underRunSignal(options, (engine) => executor.run({
input: { ...input, parentRun: parentRun.link },
...(engine ?? {}),
}));
}
catch (err) {
this.rethrowWithNodeAttribution(err);
}
return this.finalizeResult(executor, result);
}
finally {
// Released when the run returns — finished, failed or paused (a resume re-opens it).
closeParentRun(parentRun);
}
}
async resume(checkpoint, input, options) {
// The incoming request, and the paused run its members serve (re-opened
// from the checkpoint's own record) — both refused before anything moves.
const request = requestOf(options, 'graph.resume');
const parentRun = reopenParentRun(checkpoint.sharedState, 'graph.resume', request);
try {
// The leg's policy — the paused run's marks — goes to ITS executor, never via the instance.
const resumeLeg = this.emitPauseResume(checkpoint, input);
const executor = this.createExecutor(resumeLeg);
this.lastExecutor = executor;
let result;
try {
result = await underRunSignal(options, (engine) => executor.resume(checkpoint, input, engine));
}
catch (err) {
this.rethrowWithNodeAttribution(err);
}
return this.finalizeResult(executor, result);
}
finally {
closeParentRun(parentRun);
}
}
/**
* 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.
*/
rethrowWithNodeAttribution(err) {
const message = err instanceof Error ? err.message : String(err);
for (const [nodeId, record] of this.nodeErrors) {
if (record.raw === err || record.message === message) {
throw new Error(nodeFailureMessage(this.id, nodeId, record.message), { cause: err });
}
}
throw err;
}
createExecutor(resumeLeg) {
this.currentRunContext = {
runStartMs: Date.now(),
runId: makeRunId(),
compositionPath: [`Graph:${this.id}`],
};
this.runEpoch += 1;
this.nodeErrors.clear();
// THIS run's context, captured: a run that overlaps it on this instance
// never restamps its events, so each is served under its own run's policy.
const runContext = this.currentRunContext;
const getRunCtx = () => runContext;
// The run's redaction (`src/redaction/`): every policy a node declared.
const redaction = this.openRunRedaction(resumeLeg, getRunCtx);
const spec = this.getSpec();
const executor = new FlowChartExecutor(spec, { scopeFactory: redaction.scopeFactoryFor(spec) });
redaction.applyTo(executor);
const dispatcher = this.getDispatcher();
executor.attachCombinedRecorder(new ContextRecorder({ dispatcher, getRunContext: getRunCtx, realWrites: redaction }));
executor.attachCombinedRecorder(streamRecorder({ dispatcher, getRunContext: getRunCtx }));
executor.attachCombinedRecorder(agentRecorder({ dispatcher, getRunContext: getRunCtx }));
executor.attachCombinedRecorder(compositionRecorder({ dispatcher, getRunContext: getRunCtx }));
executor.attachCombinedRecorder(this.makeNodeErrorRecorder(this.runEpoch));
for (const r of this.attachedRecorders)
executor.attachCombinedRecorder(r);
return executor;
}
/**
* 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.
*/
makeNodeErrorRecorder(epoch) {
const nodeIds = new Set(this.nodes.map((n) => n.id));
return {
id: 'graph-node-errors',
onError: (event) => {
if (epoch !== this.runEpoch)
return; // straggler from a dead run
if (!isFlowEvent(event))
return;
const stageId = event.traversalContext?.stageId ?? '';
const slash = stageId.indexOf('/');
const nodeId = slash >= 0 ? stageId.slice(0, slash) : undefined;
if (nodeId === undefined || !nodeIds.has(nodeId))
return;
if (this.nodeErrors.has(nodeId))
return;
const structured = event.structuredError;
const message = structured?.message ??
(event.message.startsWith('Error: ')
? event.message.slice('Error: '.length)
: event.message);
this.nodeErrors.set(nodeId, { message, raw: structured?.raw });
},
};
}
finalizeResult(executor, result) {
const paused = this.detectPause(executor, result);
if (paused)
return paused;
if (result instanceof Error)
throw result;
return (result ?? {});
}
buildChart() {
const compositionId = this.id;
const compositionName = this.name;
const nodeCount = this.nodes.length;
const levels = this.levels;
const nodeErrors = this.nodeErrors;
const seed = (scope) => {
// The graph's own input is what every ROOT node receives; the run it
// serves is kept apart and handed to EVERY node (`core/memberRun.ts`).
const { parentRun: _run, ...graphInput } = scope.$getArgs();
adoptParentRun(scope, { parentRun: _run }, 'graph');
scope.graphInput = graphInput;
scope.results = {};
typedEmit(scope, 'agentfootprint.composition.enter', {
kind: 'Sequence',
id: compositionId,
name: compositionName,
childCount: nodeCount,
});
};
// Root description prefix `Sequence:` is the taxonomy marker every
// consumer (Lens, FlowchartRecorder.mapTopologyToSteps) already reads.
let builder = flowChart('Seed', seed, 'seed', {
...(this.opts.structureRecorders !== undefined && {
structureRecorders: [...this.opts.structureRecorders],
}),
description: `Sequence: ${nodeCount}-node DAG in ${levels.length} level(s)`,
});
levels.forEach((level, levelIndex) => {
// A level with SEVERAL nodes is mounted on the SAME builder cursor —
// stacked `addSubFlowChart` calls produce a fork node, which
// footprintjs's ChildrenExecutor runs concurrently. That is the whole
// reason to levelize.
//
// A level with ONE node is mounted SEQUENTIALLY instead
// (`addSubFlowChartNext`). There is nothing to run it alongside, and
// the sequential mount is strictly better across a pause: resuming
// into a fork child completes THAT child and stops, whereas a
// sequential mount resumes and carries on through the rest of the
// graph. Verified against footprintjs; see the class JSDoc's
// "Honest limits".
const concurrent = level.length > 1;
for (const node of level) {
const mount = concurrent
? builder.addSubFlowChart.bind(builder)
: builder.addSubFlowChartNext.bind(builder);
builder = mount(node.id, node.runner.getSpec(), node.name ?? node.id, {
// A THROW from an inputMapper (a broken hand-off, or a join that
// rejects what it got) is caught by footprintjs's SubflowExecutor
// and turned into a plain "this subflow did not run" — the reason
// never reaches parent scope. Record it first, so the level join
// can name what actually happened instead of 'unknown error'.
inputMapper: (parent) => {
try {
return withParentRun(this.inputForNode(node, parent), parentRunIn(parent), node.runner);
}
catch (err) {
if (!nodeErrors.has(node.id)) {
nodeErrors.set(node.id, {
message: err instanceof Error ? err.message : String(err),
raw: err,
});
}
throw err;
}
},
// Untouched: whatever the node's chart returned is what its
// children (and the caller) receive. No string coercion — this
// is exactly what Sequence and Parallel cannot do.
//
// The child reads its own outcome (`childOutcome.ts`): an agent's
// validated answer arrives as its text, not the object it leaves as.
// A node that ended on a verdict is a FAILED node: the mapper throws
// the child's own error — recorded first, as the inputMapper does,
// because footprintjs swallows a mapper throw — so the node is
// absent and the level join names it.
outputMapper: (sfOutput) => {
const outcome = childOutcome(node.runner, sfOutput);
if ('verdict' in outcome) {
const err = childError(outcome.verdict);
if (!nodeErrors.has(node.id))
nodeErrors.set(node.id, { message: err.message, raw: err });
throw err;
}
return { results: { [node.id]: outcome.answer } };
},
});
}
// The join stage does two jobs: it ADVANCES the builder cursor (so
// the next level forks from here instead of joining this level's
// fork), and it turns a failed node's ABSENCE into a loud error.
builder = builder.addFunction(`Level ${levelIndex} join`, (scope) => {
// Read through `$getValue`, never `scope.results`: values merged
// into parent state by a subflow outputMapper are present in
// shared state but do NOT enumerate through TypedScope's nested
// property proxy. Verified against footprintjs — the property
// read returns `{}` while `$getValue` returns the real record.
const results = scope.$getValue('results') ?? {};
const missing = level.map((n) => n.id).filter((id) => !(id in results));
if (missing.length > 0) {
typedEmit(scope, 'agentfootprint.composition.exit', {
kind: 'Sequence',
id: compositionId,
name: compositionName,
status: 'err',
durationMs: Date.now() - this.currentRunContext.runStartMs,
});
const reasonFor = (id) => nodeErrors.get(id)?.message ?? 'unknown error';
const firstMissing = missing[0] ?? '';
if (missing.length === 1) {
throw new Error(nodeFailureMessage(compositionId, firstMissing, reasonFor(firstMissing)));
}
const details = missing.map((id) => ` ${id}: ${reasonFor(id)}`).join('\n');
throw new Error(`graph '${compositionId}': ${missing.length} nodes failed in level ${levelIndex}:\n${details}`);
}
return undefined;
}, `level-${levelIndex}-join`, `Graph level ${levelIndex} join`);
});
builder = builder.addFunction('Finalize', (scope) => {
typedEmit(scope, 'agentfootprint.composition.exit', {
kind: 'Sequence',
id: compositionId,
name: compositionName,
status: 'ok',
durationMs: Date.now() - this.currentRunContext.runStartMs,
});
// `$getValue` for the same reason as the level join.
return scope.$getValue('results') ?? {};
}, 'finalize', 'Graph finalize');
return builder.build();
}
/**
* 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.
*/
inputForNode(node, parent) {
const results = parent.results ?? {};
const upstreamIds = this.parentsOf.get(node.id) ?? [];
if (upstreamIds.length === 0) {
return { ...(parent.graphInput ?? {}) };
}
if (node.join !== undefined) {
const upstream = {};
for (const id of upstreamIds)
upstream[id] = results[id];
return composedInput(node.runner, toNodeArgs(node.join(upstream), node.id, `join of node '${node.id}'`));
}
const only = upstreamIds[0] ?? '';
return composedInput(node.runner, toNodeArgs(results[only], node.id, `node '${only}'`));
}
}
// ─── The factory ─────────────────────────────────────────────────────
/**
* 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 function graph(opts) {
return new Graph(opts);
}
//# sourceMappingURL=Graph.js.map