Skip to content

fix!: Make AgentGraph traversal topological - #325

Open
mattrmc1 wants to merge 5 commits into
mainfrom
mmccarthy/AIC-3043/agent-graph-traversal
Open

fix!: Make AgentGraph traversal topological#325
mattrmc1 wants to merge 5 commits into
mainfrom
mmccarthy/AIC-3043/agent-graph-traversal

Conversation

@mattrmc1

@mattrmc1 mattrmc1 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Makes AgentGraphDefinition traversal topological and gives each visitor a dependency-scoped
execution context
, aligning the traversal behavior across the LaunchDarkly AI SDKs.

Previously Traverse / ReverseTraverse were plain BFS over a single shared, accumulating context
dictionary. That had two problems:

  1. Ordering: where branches of unequal length converge, the convergence node ran on first
    discovery — before all of its predecessors had run.
  2. Context leakage & mutation: every callback saw the results of all previously-visited nodes
    (including unrelated parallel branches), and results were written back into the caller's
    initialContext.

What changed

pkgs/sdk/server-aiGraph/AgentGraphDefinition.cs only (no public API/signature changes):

  • Traverse now visits a node only after all of its reachable predecessors have been visited
    (Kahn over in-degree; the root is always released first). On cycles, the unvisited node with the
    lowest remaining in-degree is chosen next, ties broken by discovery order.
  • ReverseTraverse now visits a node only after all of its reachable descendants have been
    visited, so the root is visited last (Kahn over out-degree, root excluded from cycle-break
    selection). Pure cycles now visit every node instead of being a no-op.
  • Scoped context: each callback receives a fresh dictionary = initialContext plus only that
    node's true dependency results (transitive ancestors forward / descendants reverse). Results are
    kept in a private map keyed by node; unrelated branches and self-loops are excluded, and the
    caller's dictionary is never mutated. Cross-node data flows only through callback return values.
  • Determinism: discovery order is the graph's BFS encounter order (root first, then declared edge
    order), used only for tie-breaks. Ready-check uses == 0 to match the reference algorithm and the
    other SDKs.
void Traverse(Func<AgentGraphNode, Dictionary<string, object>, object> fn,
              Dictionary<string, object> initialContext = null);

Behavior change (breaking)

Titled fix! because visit order and callback-context contents change for graphs with convergent
paths, cycles, or parallel branches. Simple linear graphs are unaffected. Cross-SDK parity with
JS/Python/.NET/Java.

Tests

  • A data-driven test over the canonical cross-SDK vectors G1G6 (+G2b) asserts exact visit
    order and exact context keys in both directions.
  • Convergence runs the shared node last; pure cycle visits all nodes (root last); caller context not
    mutated; unrelated branches excluded; self-loop excluded from its own context; deterministic across
    runs.

Notes

  • No manual CHANGELOG.md edit — release-please generates it from the conventional-commit title.
  • Graph tracking / wire parsing unchanged; this PR is scoped to traversal + context.

Note

High Risk
Breaking visit order and callback context for convergent graphs, cycles, and parallel branches; integrators relying on old BFS behavior or full accumulated context will need updates.

Overview
Breaking: AgentGraphDefinition.Traverse and ReverseTraverse no longer use BFS over one shared, mutating context. They now visit nodes in topological order (predecessors first forward; descendants first reverse, root last), with dependency-scoped callback dictionaries aligned to other LaunchDarkly AI SDKs.

Forward traversal waits until all reachable predecessors have run before a node (Kahn on in-degree; ties broken by BFS discovery order). Reverse traversal does the same on out-degree and always processes the root last. On cycles, nodes still visit once with deterministic tie-breaking; pure cycles now run on reverse traverse instead of being a no-op.

Each callback gets initialContext plus only transitive ancestor (forward) or descendant (reverse) results from private storage—no writes to the caller’s dictionary and no context from unrelated parallel branches or self-loops.

Tests add canonical G1–G6 / G2b vectors for exact visit order and context keys in both directions.

Reviewed by Cursor Bugbot for commit 04bbbe9. Bugbot is set up for automated code reviews on this repo. Configure here.

@mattrmc1
mattrmc1 marked this pull request as ready for review July 29, 2026 21:54
@mattrmc1
mattrmc1 requested a review from a team as a code owner July 29, 2026 21:54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are a lot of new dictionary calls, toDictionary, as well as several contains and order by. Do you have any performance requirements wrt throughput or memory allocation delays? May be worth a quick benchmark to see if your expected cases are well behaved.

I'm guessing this would apply to the other PRs for the other SDKs if they are following similar implementations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants