diff --git a/packages/sdk/server-ai/__tests__/AgentGraphDefinition.test.ts b/packages/sdk/server-ai/__tests__/AgentGraphDefinition.test.ts index 8839a3474a..e4c12c3848 100644 --- a/packages/sdk/server-ai/__tests__/AgentGraphDefinition.test.ts +++ b/packages/sdk/server-ai/__tests__/AgentGraphDefinition.test.ts @@ -4,6 +4,7 @@ import { LDContext } from '@launchdarkly/js-server-sdk-common'; import { LDAIAgentConfig } from '../src/api/config'; import { AgentGraphDefinition } from '../src/api/graph/AgentGraphDefinition'; +import { AgentGraphNode } from '../src/api/graph/AgentGraphNode'; import { LDAgentGraphFlagValue, LDGraphEdge } from '../src/api/graph/types'; import { LDClientMin } from '../src/LDClientMin'; import { LDGraphTrackerImpl } from '../src/LDGraphTrackerImpl'; @@ -231,7 +232,7 @@ it('getParentNodes returns empty array for root node', () => { // traverse // --------------------------------------------------------------------------- -it('traverse calls fn for every node in BFS order (root first)', () => { +it('traverse visits every node with predecessors before dependents (root first)', () => { // root // / \ // a b @@ -254,7 +255,7 @@ it('traverse calls fn for every node in BFS order (root first)', () => { }); expect(order[0]).toBe('root'); - // a and b must both appear before c + // a and b must both appear before c (sibling order among a/b is not significant) const aIdx = order.indexOf('a'); const bIdx = order.indexOf('b'); const cIdx = order.indexOf('c'); @@ -335,7 +336,7 @@ it('reverseTraverse processes terminal nodes before their parents, root last', ( // c must appear before a (c is a descendant of a) expect(order.indexOf('c')).toBeLessThan(order.indexOf('a')); // all four nodes visited - expect(order.sort()).toEqual(['a', 'b', 'c', 'root']); + expect([...order].sort()).toEqual(['a', 'b', 'c', 'root']); }); it('reverseTraverse stores fn return values in execution context', () => { @@ -384,10 +385,29 @@ it('reverseTraverse visits a node with multiple parents only once', () => { // root is always last expect(order[order.length - 1]).toBe('root'); // every node visited exactly once - expect(order.sort()).toEqual(['a', 'b', 'c', 'd', 'root']); + expect([...order].sort()).toEqual(['a', 'b', 'c', 'd', 'root']); }); -it('reverseTraverse visits each node once on a cyclic graph', () => { +it('reverseTraverse accepts and uses initial execution context', () => { + const graph = makeGraph('root', { root: [{ key: 'child' }] }); + const def = makeDefinition(graph, { + root: makeAgentConfig('root'), + child: makeAgentConfig('child'), + }); + + const captured: Record[] = []; + def.reverseTraverse( + (node, ctx) => { + captured.push({ ...ctx }); + return `result-of-${node.getKey()}`; + }, + { initialKey: 'initialValue' }, + ); + + expect(captured[0]).toHaveProperty('initialKey', 'initialValue'); +}); + +it('reverseTraverse visits each node once on a cyclic graph with root last', () => { // A → B → A (no terminals) const graph = makeGraph('a', { a: [{ key: 'b' }], @@ -403,8 +423,598 @@ it('reverseTraverse visits each node once on a cyclic graph', () => { visited.push(node.getKey()); }); - // No terminals → returns without visiting anything (same as Python) - expect(visited).toEqual([]); + expect(visited).toHaveLength(2); + expect(visited[visited.length - 1]).toBe('a'); + expect([...visited].sort()).toEqual(['a', 'b']); +}); + +// --------------------------------------------------------------------------- +// Topological parity fixtures (G1–G6) +// --------------------------------------------------------------------------- + +function collectOrder( + def: AgentGraphDefinition, + direction: 'forward' | 'reverse', +): string[] { + const order: string[] = []; + const fn = (node: AgentGraphNode) => { + order.push(node.getKey()); + }; + if (direction === 'forward') { + def.traverse(fn); + } else { + def.reverseTraverse(fn); + } + return order; +} + +/** Canonical G1–G6/G2b agent-graph traversal vectors, shared across the LaunchDarkly AI SDKs. */ +type TraversalVector = { + id: string; + root: string; + nodes: string[]; + edges: [string, string][]; + traverse: string[]; + reverseTraverse: string[]; + traverseContext: Record; + reverseTraverseContext: Record; +}; + +const TRAVERSAL_VECTORS: TraversalVector[] = [ + { + id: 'G1', + root: 'a', + nodes: ['a', 'b', 'c'], + edges: [ + ['a', 'b'], + ['b', 'c'], + ], + traverse: ['a', 'b', 'c'], + reverseTraverse: ['c', 'b', 'a'], + traverseContext: { a: [], b: ['a'], c: ['a', 'b'] }, + reverseTraverseContext: { a: ['b', 'c'], b: ['c'], c: [] }, + }, + { + id: 'G2', + root: 'a', + nodes: ['a', 'b', 'c', 'd', 'e'], + edges: [ + ['a', 'b'], + ['a', 'c'], + ['c', 'd'], + ['d', 'e'], + ['b', 'e'], + ], + traverse: ['a', 'b', 'c', 'd', 'e'], + reverseTraverse: ['e', 'b', 'd', 'c', 'a'], + traverseContext: { + a: [], + b: ['a'], + c: ['a'], + d: ['a', 'c'], + e: ['a', 'b', 'c', 'd'], + }, + reverseTraverseContext: { + a: ['b', 'c', 'd', 'e'], + b: ['e'], + c: ['d', 'e'], + d: ['e'], + e: [], + }, + }, + { + id: 'G2b', + root: 'a', + nodes: ['a', 'b', 'c', 'd', 'e'], + edges: [ + ['a', 'c'], + ['a', 'b'], + ['c', 'd'], + ['d', 'e'], + ['b', 'e'], + ], + traverse: ['a', 'c', 'b', 'd', 'e'], + reverseTraverse: ['e', 'b', 'd', 'c', 'a'], + traverseContext: { + a: [], + b: ['a'], + c: ['a'], + d: ['a', 'c'], + e: ['a', 'b', 'c', 'd'], + }, + reverseTraverseContext: { + a: ['b', 'c', 'd', 'e'], + b: ['e'], + c: ['d', 'e'], + d: ['e'], + e: [], + }, + }, + { + id: 'G3', + root: 'a', + nodes: ['a', 'b', 'c', 'd'], + edges: [ + ['a', 'b'], + ['a', 'c'], + ['b', 'd'], + ['c', 'd'], + ], + traverse: ['a', 'b', 'c', 'd'], + reverseTraverse: ['d', 'b', 'c', 'a'], + traverseContext: { + a: [], + b: ['a'], + c: ['a'], + d: ['a', 'b', 'c'], + }, + reverseTraverseContext: { + a: ['b', 'c', 'd'], + b: ['d'], + c: ['d'], + d: [], + }, + }, + { + id: 'G4', + root: 'a', + nodes: ['a', 'n', 'm', 't'], + edges: [ + ['a', 'n'], + ['n', 'm'], + ['n', 't'], + ['m', 't'], + ], + traverse: ['a', 'n', 'm', 't'], + reverseTraverse: ['t', 'm', 'n', 'a'], + traverseContext: { + a: [], + n: ['a'], + m: ['a', 'n'], + t: ['a', 'm', 'n'], + }, + reverseTraverseContext: { + a: ['m', 'n', 't'], + n: ['m', 't'], + m: ['t'], + t: [], + }, + }, + { + id: 'G5', + root: 'a', + nodes: ['a', 'b', 'c', 'd'], + edges: [ + ['a', 'b'], + ['a', 'c'], + ['b', 'd'], + ], + traverse: ['a', 'b', 'c', 'd'], + reverseTraverse: ['c', 'd', 'b', 'a'], + traverseContext: { + a: [], + b: ['a'], + c: ['a'], + d: ['a', 'b'], + }, + reverseTraverseContext: { + a: ['b', 'c', 'd'], + b: ['d'], + c: [], + d: [], + }, + }, + { + id: 'G6', + root: 'a', + nodes: ['a', 'b', 'c'], + edges: [ + ['a', 'b'], + ['b', 'c'], + ['c', 'b'], + ], + traverse: ['a', 'b', 'c'], + reverseTraverse: ['b', 'c', 'a'], + traverseContext: { a: [], b: ['a'], c: ['a', 'b'] }, + reverseTraverseContext: { a: ['b', 'c'], b: [], c: ['b'] }, + }, +]; + +/** Converts [src, tgt] pairs into makeGraph edges, preserving declaration order. */ +function edgesFromPairs(pairs: [string, string][]): Record { + const edges: Record = {}; + for (const [src, tgt] of pairs) { + if (!edges[src]) { + edges[src] = []; + } + edges[src].push({ key: tgt }); + } + return edges; +} + +function makeDefinitionFromVector(v: TraversalVector): AgentGraphDefinition { + const configs: Record = {}; + for (const key of v.nodes) { + configs[key] = makeAgentConfig(key); + } + return makeDefinition(makeGraph(v.root, edgesFromPairs(v.edges)), configs); +} + +it.each(TRAVERSAL_VECTORS)( + '$id asserts traverse/reverse order and exact scoped context', + (v) => { + const def = makeDefinitionFromVector(v); + + const fwdOrder: string[] = []; + const fwdCtx: Record = {}; + def.traverse((node, ctx) => { + const key = node.getKey(); + fwdOrder.push(key); + fwdCtx[key] = Object.keys(ctx).sort(); + return `${key}-result`; + }); + expect(fwdOrder).toEqual(v.traverse); + for (const [key, expected] of Object.entries(v.traverseContext)) { + expect(fwdCtx[key]).toEqual([...expected].sort()); + } + + const revOrder: string[] = []; + const revCtx: Record = {}; + def.reverseTraverse((node, ctx) => { + const key = node.getKey(); + revOrder.push(key); + revCtx[key] = Object.keys(ctx).sort(); + return `${key}-result`; + }); + expect(revOrder).toEqual(v.reverseTraverse); + for (const [key, expected] of Object.entries(v.reverseTraverseContext)) { + expect(revCtx[key]).toEqual([...expected].sort()); + } + }, +); + +describe('given G1 linear graph a→b→c', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }], + b: [{ key: 'c' }], + }); + const configs = { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + }; + const def = makeDefinition(graph, configs); + + it('traverse visits a, b, c', () => { + expect(collectOrder(def, 'forward')).toEqual(['a', 'b', 'c']); + }); + + it('reverseTraverse visits c, b, a', () => { + expect(collectOrder(def, 'reverse')).toEqual(['c', 'b', 'a']); + }); +}); + +describe('given G2 skewed diamond a→b, a→c, c→d, d→e, b→e', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }, { key: 'c' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }); + const configs = { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + e: makeAgentConfig('e'), + }; + const def = makeDefinition(graph, configs); + + it('traverse visits a, b, c, d, e', () => { + expect(collectOrder(def, 'forward')).toEqual(['a', 'b', 'c', 'd', 'e']); + }); + + it('reverseTraverse visits e, b, d, c, a', () => { + expect(collectOrder(def, 'reverse')).toEqual(['e', 'b', 'd', 'c', 'a']); + }); + + it('traverse keeps e last when a edges are declared [c, b]', () => { + const reordered = makeDefinition( + makeGraph('a', { + a: [{ key: 'c' }, { key: 'b' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }), + configs, + ); + const order = collectOrder(reordered, 'forward'); + expect(order).toEqual(['a', 'c', 'b', 'd', 'e']); + expect(order.indexOf('d')).toBeLessThan(order.indexOf('e')); + }); + + it('reverseTraverse keeps e before d when a edges are declared [c, b]', () => { + const reordered = makeDefinition( + makeGraph('a', { + a: [{ key: 'c' }, { key: 'b' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }), + configs, + ); + const order = collectOrder(reordered, 'reverse'); + expect(order[0]).toBe('e'); + expect(order.indexOf('e')).toBeLessThan(order.indexOf('d')); + expect(order.indexOf('e')).toBeLessThan(order.indexOf('b')); + expect(order[order.length - 1]).toBe('a'); + }); +}); + +describe('given G3 symmetric diamond a→b, a→c, b→d, c→d', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }, { key: 'c' }], + b: [{ key: 'd' }], + c: [{ key: 'd' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + }); + + it('traverse visits a, b, c, d', () => { + expect(collectOrder(def, 'forward')).toEqual(['a', 'b', 'c', 'd']); + }); + + it('reverseTraverse visits d, b, c, a', () => { + expect(collectOrder(def, 'reverse')).toEqual(['d', 'b', 'c', 'a']); + }); +}); + +describe('given G4 nested-parent a→n, n→m, n→t, m→t', () => { + const graph = makeGraph('a', { + a: [{ key: 'n' }], + n: [{ key: 'm' }, { key: 't' }], + m: [{ key: 't' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + n: makeAgentConfig('n'), + m: makeAgentConfig('m'), + t: makeAgentConfig('t'), + }); + + it('traverse visits a, n, m, t', () => { + expect(collectOrder(def, 'forward')).toEqual(['a', 'n', 'm', 't']); + }); + + it('reverseTraverse visits t, m, n, a', () => { + expect(collectOrder(def, 'reverse')).toEqual(['t', 'm', 'n', 'a']); + }); +}); + +describe('given G5 multi-terminal a→b, a→c, b→d', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }, { key: 'c' }], + b: [{ key: 'd' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + }); + + it('traverse visits a, b, c, d', () => { + expect(collectOrder(def, 'forward')).toEqual(['a', 'b', 'c', 'd']); + }); + + it('reverseTraverse visits c, d, b, a', () => { + expect(collectOrder(def, 'reverse')).toEqual(['c', 'd', 'b', 'a']); + }); +}); + +describe('given G6 cycle a→b, b→c, c→b', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }], + b: [{ key: 'c' }], + c: [{ key: 'b' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + }); + + it('traverse visits each node once with a first and a deterministic order', () => { + const first = collectOrder(def, 'forward'); + const second = collectOrder(def, 'forward'); + expect(first[0]).toBe('a'); + expect(first).toHaveLength(3); + expect([...first].sort()).toEqual(['a', 'b', 'c']); + expect(first).toEqual(second); + }); + + it('reverseTraverse visits each node once with a last and a deterministic order', () => { + const first = collectOrder(def, 'reverse'); + const second = collectOrder(def, 'reverse'); + expect(first[first.length - 1]).toBe('a'); + expect(first).toHaveLength(3); + expect([...first].sort()).toEqual(['a', 'b', 'c']); + expect(first).toEqual(second); + }); +}); + +it('traverse scopes context to exact predecessors on G2 skewed diamond', () => { + // a→b, a→c, c→d, d→e, b→e + const graph = makeGraph('a', { + a: [{ key: 'b' }, { key: 'c' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + e: makeAgentConfig('e'), + }); + + const expectedKeys: Record = { + a: [], + b: ['a'], + c: ['a'], + d: ['a', 'c'], + e: ['a', 'b', 'c', 'd'], + }; + + def.traverse((node, ctx) => { + expect(Object.keys(ctx).sort()).toEqual(expectedKeys[node.getKey()].sort()); + // Parallel-branch leak: b must not see sibling-branch node c + if (node.getKey() === 'b') { + expect(ctx).not.toHaveProperty('c'); + } + // Parallel-branch leak: d must not see unrelated branch node b + if (node.getKey() === 'd') { + expect(ctx).not.toHaveProperty('b'); + } + return `result-of-${node.getKey()}`; + }); +}); + +it('reverseTraverse scopes context to exact descendants on G2 skewed diamond', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }, { key: 'c' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + e: makeAgentConfig('e'), + }); + + const expectedKeys: Record = { + a: ['b', 'c', 'd', 'e'], + b: ['e'], + c: ['d', 'e'], + d: ['e'], + e: [], + }; + + def.reverseTraverse((node, ctx) => { + expect(Object.keys(ctx).sort()).toEqual(expectedKeys[node.getKey()].sort()); + // Parallel-branch leak: b/d must not see c (not a descendant of either) + if (node.getKey() === 'b' || node.getKey() === 'd') { + expect(ctx).not.toHaveProperty('c'); + } + return `result-of-${node.getKey()}`; + }); +}); + +it('traverse context scoping is independent of a edge declaration order on G2', () => { + const configs = { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + e: makeAgentConfig('e'), + }; + const def = makeDefinition( + makeGraph('a', { + a: [{ key: 'c' }, { key: 'b' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }), + configs, + ); + + const expectedKeys: Record = { + a: [], + b: ['a'], + c: ['a'], + d: ['a', 'c'], + e: ['a', 'b', 'c', 'd'], + }; + + def.traverse((node, ctx) => { + expect(Object.keys(ctx).sort()).toEqual(expectedKeys[node.getKey()].sort()); + return `result-of-${node.getKey()}`; + }); +}); + +it('traverse includes initial context keys alongside scoped predecessors', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }, { key: 'c' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + e: makeAgentConfig('e'), + }); + + const expectedDeps: Record = { + a: [], + b: ['a'], + c: ['a'], + d: ['a', 'c'], + e: ['a', 'b', 'c', 'd'], + }; + + def.traverse( + (node, ctx) => { + expect(Object.keys(ctx).sort()).toEqual( + ['seed', ...expectedDeps[node.getKey()]].sort(), + ); + expect(ctx).toHaveProperty('seed', 'value'); + return `result-of-${node.getKey()}`; + }, + { seed: 'value' }, + ); +}); + +it('traverse visits each node once on a self-loop without hanging', () => { + const graph = makeGraph('a', { + a: [{ key: 'a' }], + }); + const def = makeDefinition(graph, { a: makeAgentConfig('a') }); + + const order: string[] = []; + def.traverse((node) => { + order.push(node.getKey()); + }); + expect(order).toEqual(['a']); +}); + +it('traverse and reverseTraverse produce identical orders across repeated runs', () => { + const graph = makeGraph('a', { + a: [{ key: 'b' }, { key: 'c' }], + b: [{ key: 'e' }], + c: [{ key: 'd' }], + d: [{ key: 'e' }], + }); + const def = makeDefinition(graph, { + a: makeAgentConfig('a'), + b: makeAgentConfig('b'), + c: makeAgentConfig('c'), + d: makeAgentConfig('d'), + e: makeAgentConfig('e'), + }); + + expect(collectOrder(def, 'forward')).toEqual(collectOrder(def, 'forward')); + expect(collectOrder(def, 'reverse')).toEqual(collectOrder(def, 'reverse')); }); // --------------------------------------------------------------------------- diff --git a/packages/sdk/server-ai/examples/features/create-agent-graph/README.md b/packages/sdk/server-ai/examples/features/create-agent-graph/README.md index 587b17d25a..1cf1c8fff6 100644 --- a/packages/sdk/server-ai/examples/features/create-agent-graph/README.md +++ b/packages/sdk/server-ai/examples/features/create-agent-graph/README.md @@ -29,7 +29,7 @@ yarn workspace create-agent-graph start The example demonstrates both traversal directions: -- **Forward traversal** (`graph.traverse`) walks root → terminals (BFS). Use this when your framework expects a parent to be defined first so that child agents can be registered on it (e.g. OpenAI Agents SDK). -- **Reverse traversal** (`graph.reverseTraverse`) walks terminals → root. Use this when your framework expects children to be defined first so they can be attached to their parent as tools or sub-graphs (e.g. LangGraph). +- **Forward traversal** (`graph.traverse`) walks root → terminals in topological order (a node only after its predecessors). Use this when your framework expects a parent to be defined first so that child agents can be registered on it (e.g. OpenAI Agents SDK). +- **Reverse traversal** (`graph.reverseTraverse`) walks terminals → root in reverse topological order (a node only after its descendants). Use this when your framework expects children to be defined first so they can be attached to their parent as tools or sub-graphs (e.g. LangGraph). Each callback receives an `executionContext` map where the previously processed nodes' return values are available by node key. diff --git a/packages/sdk/server-ai/src/api/graph/AgentGraphDefinition.ts b/packages/sdk/server-ai/src/api/graph/AgentGraphDefinition.ts index b50eafaa9b..1b752d4801 100644 --- a/packages/sdk/server-ai/src/api/graph/AgentGraphDefinition.ts +++ b/packages/sdk/server-ai/src/api/graph/AgentGraphDefinition.ts @@ -15,7 +15,7 @@ export type TraversalFn = ( * Encapsulates an agent graph configuration and its pre-built node collection. * * Provides graph-level orchestration including relationship queries (parent/child), - * breadth-first traversal in both forward and reverse directions, and graph tracker creation. + * topological traversal in both forward and reverse directions, and graph tracker creation. * * Obtain an instance via {@link LDAIClient.agentGraph}. When the graph is disabled * or invalid, the returned instance has {@link enabled} set to `false` and an @@ -123,17 +123,21 @@ export class AgentGraphDefinition { } /** - * Traverses the graph breadth-first from the root to all terminal nodes. + * Traverses the graph in topological order from the root (predecessors-first). * - * Nodes at the same depth are processed before advancing to the next depth. - * The value returned by `fn` is stored in the mutable `executionContext` under - * the node's key, making upstream results available to downstream nodes. + * A node is visited only after every reachable predecessor has been visited. + * The root is visited first. When multiple nodes are simultaneously eligible, + * they are visited in graph-discovery order (BFS from root following declared + * edge order) for determinism. Cyclic graphs are cycle-safe — each reachable + * node is visited exactly once. * - * Cyclic graphs are handled safely — each node is visited at most once. + * Each call to `fn` receives a fresh context containing the caller-provided + * `initialExecutionContext` plus the return values of exactly that node's + * reachable predecessors — not results from unrelated parallel-branch nodes. * - * @param fn Callback invoked for each node. Its return value is added to - * `executionContext` keyed by the node's config key. - * @param initialExecutionContext Optional initial context to seed the traversal. + * @param fn Callback invoked for each node. Its return value is stored under + * the node's config key for use by dependent nodes. + * @param initialExecutionContext Optional initial context visible to every node. */ traverse(fn: TraversalFn, initialExecutionContext: Record = {}): void { const root = this.rootNode(); @@ -141,94 +145,170 @@ export class AgentGraphDefinition { return; } - const executionContext = { ...initialExecutionContext }; + const { reachable, order } = this._reachableAndDiscovery(root.getKey()); + + const indeg = new Map(); + reachable.forEach((k) => indeg.set(k, 0)); + reachable.forEach((k) => { + this._nodes[k]!.getEdges().forEach((e) => { + if (reachable.has(e.key)) { + indeg.set(e.key, indeg.get(e.key)! + 1); + } + }); + }); + indeg.set(root.getKey(), 0); + const visited = new Set(); - const queue: AgentGraphNode[] = [root]; - visited.add(root.getKey()); + const results: Record = {}; + const ancestors = new Map>(); + const scoped = (deps: Set) => { + const c: Record = { ...initialExecutionContext }; + deps.forEach((k) => { + c[k] = results[k]; + }); + return c; + }; - while (queue.length > 0) { - const node = queue.shift()!; - const result = fn(node, executionContext); - executionContext[node.getKey()] = result; + while (visited.size < reachable.size) { + let next = order.find((k) => !visited.has(k) && indeg.get(k)! === 0); + if (next === undefined) { + // Cycle break: lowest remaining in-degree, tie-broken by discovery order + next = order + .filter((k) => !visited.has(k)) + .sort((a, b) => indeg.get(a)! - indeg.get(b)!)[0]; + } - node.getEdges().forEach((edge) => { - if (!visited.has(edge.key)) { - const child = this._nodes[edge.key]; - if (child) { - visited.add(edge.key); - queue.push(child); - } + const anc = new Set(); + this.getParentNodes(next).forEach((p) => { + const pk = p.getKey(); + if (!visited.has(pk)) { + return; + } + anc.add(pk); + ancestors.get(pk)?.forEach((a) => anc.add(a)); + }); + ancestors.set(next, anc); + visited.add(next); + + results[next] = fn(this._nodes[next]!, scoped(anc)); + this._nodes[next]!.getEdges().forEach((e) => { + if (reachable.has(e.key)) { + indeg.set(e.key, indeg.get(e.key)! - 1); } }); } } /** - * Traverses the graph from terminal nodes up to the root. + * Traverses the graph in reverse topological order (descendants-first). * - * Uses BFS upward via parent edges so that each node is processed only after - * all of its reachable descendants have been processed. The root is always - * visited last. Cyclic graphs are handled safely — each node is visited at - * most once; if the graph has no terminal nodes, this method returns without - * invoking `fn`. + * A node is visited only after every reachable descendant has been visited. + * The root is always visited last. When multiple nodes are simultaneously + * eligible, they are visited in graph-discovery order for determinism. Cyclic + * graphs are cycle-safe — each reachable node is visited exactly once (including + * graphs with no terminal nodes). * - * **Ordering note:** Within a single BFS level (nodes at the same depth from a - * terminal) the visit order is not strictly guaranteed. The guarantee is only - * that a node is visited before any of its ancestors — not that siblings at the - * same depth are visited in a specific order relative to each other. + * Each call to `fn` receives a fresh context containing the caller-provided + * `initialExecutionContext` plus the return values of exactly that node's + * reachable descendants — not results from unrelated parallel-branch nodes. * - * The value returned by `fn` is stored in the mutable `executionContext` under - * the node's key. - * - * @param fn Callback invoked for each node. Its return value is added to - * `executionContext` keyed by the node's config key. - * @param initialExecutionContext Optional initial context to seed the traversal. + * @param fn Callback invoked for each node. Its return value is stored under + * the node's config key for use by dependent nodes. + * @param initialExecutionContext Optional initial context visible to every node. */ reverseTraverse(fn: TraversalFn, initialExecutionContext: Record = {}): void { - const terminals = this.terminalNodes(); - if (terminals.length === 0) { + const root = this.rootNode(); + if (!root) { return; } - const executionContext = { ...initialExecutionContext }; - const rootKey = this._agentGraph.root; + const rootKey = root.getKey(); + const { reachable, order } = this._reachableAndDiscovery(rootKey); + + const outdeg = new Map(); + reachable.forEach((k) => { + outdeg.set( + k, + this._nodes[k]!.getEdges().filter((e) => reachable.has(e.key)).length, + ); + }); + const visited = new Set(); - let queue: AgentGraphNode[] = terminals; + const results: Record = {}; + const descendants = new Map>(); + const scoped = (deps: Set) => { + const c: Record = { ...initialExecutionContext }; + deps.forEach((k) => { + c[k] = results[k]; + }); + return c; + }; - while (queue.length > 0) { - const nextQueue: AgentGraphNode[] = []; + const nonRootRemaining = () => [...reachable].some((k) => k !== rootKey && !visited.has(k)); + while (nonRootRemaining()) { + let next = order.find( + (k) => k !== rootKey && !visited.has(k) && outdeg.get(k)! === 0, + ); + if (next === undefined) { + // Cycle break: lowest remaining out-degree, tie-broken by discovery order + next = order + .filter((k) => k !== rootKey && !visited.has(k)) + .sort((a, b) => outdeg.get(a)! - outdeg.get(b)!)[0]; + } - queue.forEach((node) => { - const key = node.getKey(); - if (visited.has(key)) { + const desc = new Set(); + this._nodes[next]!.getEdges().forEach((e) => { + if (!reachable.has(e.key) || !visited.has(e.key)) { return; } - visited.add(key); + desc.add(e.key); + descendants.get(e.key)?.forEach((d) => desc.add(d)); + }); + descendants.set(next, desc); + visited.add(next); - // Defer the root so it is always processed last - if (key === rootKey) { - return; + results[next] = fn(this._nodes[next]!, scoped(desc)); + this.getParentNodes(next).forEach((p) => { + const pk = p.getKey(); + if (pk !== rootKey && reachable.has(pk)) { + outdeg.set(pk, outdeg.get(pk)! - 1); } + }); + } - const result = fn(node, executionContext); - executionContext[key] = result; + // Root last; depends on every reachable non-root node + const rootDeps = new Set([...reachable].filter((k) => k !== rootKey)); + visited.add(rootKey); + results[rootKey] = fn(root, scoped(rootDeps)); + } - this.getParentNodes(key).forEach((parent) => { - if (!visited.has(parent.getKey())) { - nextQueue.push(parent); - } - }); - }); + /** + * Reachable set from root plus deterministic discovery order (BFS following + * declared edge order, root first). Used as a tie-break for topological traversal. + */ + private _reachableAndDiscovery(rootKey: string): { reachable: Set; order: string[] } { + const reachable = new Set(); + const order: string[] = []; + const queue: string[] = [rootKey]; + reachable.add(rootKey); + order.push(rootKey); - queue = nextQueue; + while (queue.length > 0) { + const key = queue.shift()!; + const node = this._nodes[key]; + if (!node) { + continue; + } + node.getEdges().forEach((edge) => { + if (this._nodes[edge.key] && !reachable.has(edge.key)) { + reachable.add(edge.key); + order.push(edge.key); + queue.push(edge.key); + } + }); } - // Root is always last — only invoke if it was reached during traversal - const root = this._nodes[rootKey]; - if (root && visited.has(rootKey)) { - const result = fn(root, executionContext); - executionContext[rootKey] = result; - } + return { reachable, order }; } /**