-
Notifications
You must be signed in to change notification settings - Fork 9
feat: Add AgentGraph support to the AI SDK #292
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mattrmc1
merged 22 commits into
main
from
mmccarthy/AIC-2723/agent-graph-infrastructure
Jun 22, 2026
Merged
Changes from all commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
38d4024
[AIC-2723] Agent graph infra (first pass)
mattrmc1 e28926e
[AIC-2723] don't burn TrackTotalTokens slot on empty usage
mattrmc1 0b1f12a
fix: ReverseTraverse visits all nodes in pure-cycle graphs
mattrmc1 989f71a
fix: trackpath null guard
mattrmc1 cd02c19
fix: wrap GraphEdge handoff in ReadOnlyDictionary to enforce immutabi…
mattrmc1 9c4eed8
fix: freeze edge collections to prevent mutable aliasing through GetC…
mattrmc1 a52d690
fix: TrackTotalTokens skips empty check before claiming at-most-once …
mattrmc1 dcd2455
[AIC-2723] Refactor to make changes non-blocking
mattrmc1 0842ab6
fix: clamp resumption token version to minimum 1
mattrmc1 270020c
fix: normalize non-positive _ldMeta.version to 1 at parse time
mattrmc1 c742ece
formatting
mattrmc1 bc3198d
fix: revert version coercion in ParseMeta to match spec
mattrmc1 797186a
fix: use debug log level for graph validation failures per spec
mattrmc1 1c983c4
fix: make ReverseTraverse no-op for graphs with no terminal nodes per…
mattrmc1 3607a48
fix: snapshot path list in TrackPath to prevent mutation
mattrmc1 4db18f1
more tests
mattrmc1 4e85ca4
test: warn -> debug
mattrmc1 b060deb
fix import
mattrmc1 ccc98b4
fix test description
mattrmc1 08075f1
cleanup
mattrmc1 b43c0e8
continue on missing config
mattrmc1 d64294c
only coerce nulls
mattrmc1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,251 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Linq; | ||
| using LaunchDarkly.Sdk.Server.Ai.Config; | ||
|
|
||
| namespace LaunchDarkly.Sdk.Server.Ai.Graph; | ||
|
|
||
| /// <summary> | ||
| /// Represents a fully-resolved agent graph returned by | ||
| /// <see cref="LdAiClient.AgentGraph"/>. When <see cref="Enabled"/> is false, all | ||
| /// node collections are empty and traversal is a no-op; only <see cref="GetConfig"/> | ||
| /// and <see cref="CreateTracker"/> remain meaningful. | ||
| /// </summary> | ||
| public sealed class AgentGraphDefinition | ||
| { | ||
| private readonly AgentGraphFlagValue _flagValue; | ||
| private readonly IReadOnlyDictionary<string, AgentGraphNode> _nodes; | ||
| private readonly Func<AiGraphTracker> _createTracker; | ||
|
|
||
| /// <summary> | ||
| /// Whether the graph passed all validation checks. False if the flag's | ||
| /// <c>_ldMeta.enabled</c> is false, the root is missing, any node is | ||
| /// unreachable from the root, or any child agent config could not be fetched. | ||
| /// </summary> | ||
| public bool Enabled { get; } | ||
|
|
||
| internal AgentGraphDefinition( | ||
| AgentGraphFlagValue flagValue, | ||
| IReadOnlyDictionary<string, AgentGraphNode> nodes, | ||
| bool enabled, | ||
| Func<AiGraphTracker> createTracker) | ||
| { | ||
| _flagValue = flagValue; | ||
| _nodes = nodes; | ||
| Enabled = enabled; | ||
| _createTracker = createTracker; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns the root node of the graph, or null if the graph is disabled or has no root. | ||
| /// </summary> | ||
| public AgentGraphNode RootNode() => | ||
| string.IsNullOrEmpty(_flagValue?.Root) ? null : GetNode(_flagValue.Root); | ||
|
|
||
| /// <summary> | ||
| /// Returns the node with the given key, or null if not found. | ||
| /// </summary> | ||
| public AgentGraphNode GetNode(string nodeKey) | ||
| { | ||
| if (nodeKey == null) return null; | ||
| return _nodes.TryGetValue(nodeKey, out var node) ? node : null; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns the direct children of the given node by following its outgoing edges. | ||
| /// Returns an empty list if the node is not found. | ||
| /// </summary> | ||
| public IReadOnlyList<AgentGraphNode> GetChildNodes(string nodeKey) | ||
| { | ||
| var node = GetNode(nodeKey); | ||
| if (node == null) return Array.Empty<AgentGraphNode>(); | ||
|
|
||
| return node.Edges | ||
| .Select(edge => GetNode(edge.Key)) | ||
| .Where(n => n != null) | ||
| .ToList(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns all nodes that have an outgoing edge pointing to the given node key. | ||
| /// </summary> | ||
| public IReadOnlyList<AgentGraphNode> GetParentNodes(string nodeKey) | ||
| { | ||
| return _nodes.Values | ||
| .Where(node => node.Edges.Any(edge => edge.Key == nodeKey)) | ||
| .ToList(); | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Returns all nodes with no outgoing edges (leaf nodes). | ||
| /// </summary> | ||
| public IReadOnlyList<AgentGraphNode> TerminalNodes() => | ||
| _nodes.Values.Where(n => n.IsTerminal).ToList(); | ||
|
|
||
| /// <summary> | ||
| /// Returns the raw flag value including LaunchDarkly metadata. Always non-null, | ||
| /// even when <see cref="Enabled"/> is false. | ||
| /// </summary> | ||
| public AgentGraphFlagValue GetConfig() => _flagValue; | ||
|
|
||
| /// <summary> | ||
| /// Creates a new graph-level tracker for this invocation. | ||
| /// </summary> | ||
| public AiGraphTracker CreateTracker() => _createTracker(); | ||
|
|
||
| /// <summary> | ||
| /// Performs a breadth-first traversal of the graph starting from the root node. | ||
| /// For each visited node, <paramref name="fn"/> is called with the node and the | ||
| /// accumulated context dictionary. The return value of <paramref name="fn"/> is | ||
| /// stored in the context under the node's key and passed to subsequent calls. | ||
| /// Cycle-safe: each node is visited at most once. | ||
| /// </summary> | ||
| public void Traverse( | ||
| Func<AgentGraphNode, Dictionary<string, object>, object> fn, | ||
| Dictionary<string, object> initialContext = null) | ||
| { | ||
| var root = RootNode(); | ||
| if (root == null) return; | ||
|
|
||
| var context = initialContext ?? new Dictionary<string, object>(); | ||
|
|
||
| var visited = new HashSet<string>(); | ||
| var queue = new Queue<AgentGraphNode>(); | ||
| queue.Enqueue(root); | ||
| visited.Add(root.Key); | ||
|
|
||
| while (queue.Count > 0) | ||
| { | ||
| var node = queue.Dequeue(); | ||
| var result = fn(node, context); | ||
| context[node.Key] = result; | ||
|
|
||
| foreach (var child in GetChildNodes(node.Key)) | ||
| { | ||
| if (visited.Add(child.Key)) | ||
| { | ||
| queue.Enqueue(child); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Performs a reverse breadth-first traversal: starts from terminal nodes and | ||
| /// works upward toward the root. The root node is always processed last. | ||
| /// Cycle-safe: each node is visited at most once. | ||
| /// </summary> | ||
| public void ReverseTraverse( | ||
| Func<AgentGraphNode, Dictionary<string, object>, object> fn, | ||
| Dictionary<string, object> initialContext = null) | ||
| { | ||
| if (_nodes.Count == 0) return; | ||
|
|
||
| var context = initialContext ?? new Dictionary<string, object>(); | ||
|
|
||
| var root = RootNode(); | ||
| var visited = new HashSet<string>(); | ||
| var queue = new Queue<AgentGraphNode>(); | ||
|
|
||
| // Seed with terminal nodes (excluding root if it happens to be terminal and there are others) | ||
| foreach (var terminal in TerminalNodes()) | ||
| { | ||
| if (root != null && terminal.Key == root.Key && _nodes.Count > 1) | ||
| { | ||
| continue; | ||
| } | ||
| if (visited.Add(terminal.Key)) | ||
| { | ||
| queue.Enqueue(terminal); | ||
| } | ||
| } | ||
|
|
||
| while (queue.Count > 0) | ||
| { | ||
| var node = queue.Dequeue(); | ||
|
|
||
| // Defer root until the very end (unless it's the only node) | ||
| if (root != null && node.Key == root.Key && _nodes.Count > 1) | ||
| { | ||
| continue; | ||
| } | ||
|
|
||
| var result = fn(node, context); | ||
| context[node.Key] = result; | ||
|
|
||
| foreach (var parent in GetParentNodes(node.Key)) | ||
| { | ||
| if (root != null && parent.Key == root.Key) | ||
| { | ||
| // Don't enqueue root yet — process it last | ||
| continue; | ||
| } | ||
| if (visited.Add(parent.Key)) | ||
| { | ||
| queue.Enqueue(parent); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Process root last | ||
| if (root != null && _nodes.Count > 1 && visited.Count > 0) | ||
| { | ||
| var result = fn(root, context); | ||
| context[root.Key] = result; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Builds the nodes dictionary from a parsed flag value and a map of pre-fetched | ||
| /// agent configs, associating each node with its outgoing edges from the flag value. | ||
| /// </summary> | ||
| internal static IReadOnlyDictionary<string, AgentGraphNode> BuildNodes( | ||
| AgentGraphFlagValue flagValue, | ||
| IReadOnlyDictionary<string, LdAiAgentConfig> agentConfigs) | ||
| { | ||
| var nodes = new Dictionary<string, AgentGraphNode>(); | ||
| var allKeys = CollectAllKeys(flagValue); | ||
|
|
||
| foreach (var key in allKeys) | ||
| { | ||
| if (!agentConfigs.TryGetValue(key, out var config)) | ||
| continue; | ||
|
|
||
| var outgoingEdges = flagValue.Edges != null && flagValue.Edges.TryGetValue(key, out var edges) | ||
| ? edges | ||
| : (IReadOnlyList<GraphEdge>)Array.Empty<GraphEdge>(); | ||
|
|
||
| nodes[key] = new AgentGraphNode(key, config, outgoingEdges); | ||
| } | ||
|
|
||
| return nodes; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Collects all unique node keys referenced in the flag value: the root, all | ||
| /// edge source keys, and all edge target keys. | ||
| /// </summary> | ||
| internal static HashSet<string> CollectAllKeys(AgentGraphFlagValue flagValue) | ||
| { | ||
| var keys = new HashSet<string>(); | ||
|
|
||
| if (!string.IsNullOrEmpty(flagValue?.Root)) | ||
| { | ||
| keys.Add(flagValue.Root); | ||
| } | ||
|
|
||
| if (flagValue?.Edges != null) | ||
| { | ||
| foreach (var kv in flagValue.Edges) | ||
| { | ||
| keys.Add(kv.Key); | ||
| foreach (var edge in kv.Value) | ||
| { | ||
| keys.Add(edge.Key); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return keys; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| using System.Collections.Generic; | ||
|
|
||
| namespace LaunchDarkly.Sdk.Server.Ai.Graph; | ||
|
|
||
| /// <summary> | ||
| /// Raw flag value for an agent graph configuration as returned by LaunchDarkly. | ||
| /// Returned by <see cref="AgentGraphDefinition.GetConfig"/>. | ||
| /// </summary> | ||
| public sealed class AgentGraphFlagValue | ||
| { | ||
| /// <summary> | ||
| /// The key of the root AIAgentConfig in the graph. | ||
| /// </summary> | ||
| public string Root { get; init; } | ||
|
|
||
| /// <summary> | ||
| /// Object mapping source agent config keys to arrays of outgoing target edges. | ||
| /// Null when no edges are defined. | ||
| /// </summary> | ||
| public IReadOnlyDictionary<string, IReadOnlyList<GraphEdge>> Edges { get; init; } | ||
|
|
||
| /// <summary> | ||
| /// LaunchDarkly metadata from the <c>_ldMeta</c> field on the flag value. | ||
| /// Null when a default/fallback value was used (flag not evaluated). | ||
| /// </summary> | ||
| public LdMeta Meta { get; init; } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// LaunchDarkly metadata from the <c>_ldMeta</c> field on flag values. | ||
| /// </summary> | ||
| public sealed class LdMeta | ||
| { | ||
| /// <summary> | ||
| /// The variation key, if available. Null when a default config was used. | ||
| /// </summary> | ||
| public string VariationKey { get; init; } | ||
|
|
||
| /// <summary> | ||
| /// The version of the flag variation. Defaults to 1. | ||
| /// </summary> | ||
| public int Version { get; init; } = 1; | ||
|
|
||
| /// <summary> | ||
| /// Whether the configuration is enabled in the LaunchDarkly dashboard. | ||
| /// Defaults to true. Note: this is distinct from | ||
| /// <see cref="AgentGraphDefinition.Enabled"/>, which reflects the result of ALL | ||
| /// validation checks (metadata enabled + root present + all nodes reachable + | ||
| /// all child configs fetchable). | ||
| /// </summary> | ||
| public bool Enabled { get; init; } = true; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| using System.Collections.Generic; | ||
| using LaunchDarkly.Sdk.Server.Ai.Config; | ||
|
|
||
| namespace LaunchDarkly.Sdk.Server.Ai.Graph; | ||
|
|
||
| /// <summary> | ||
| /// Represents a single node within an agent graph. | ||
| /// Each node wraps an <see cref="LdAiAgentConfig"/> and carries the outgoing edges to | ||
| /// its children. Use the config's tracker (via <c>Config.CreateTracker()</c>) to record | ||
| /// node-level metrics. | ||
| /// </summary> | ||
| public sealed class AgentGraphNode | ||
| { | ||
| /// <summary> | ||
| /// The agent config key for this node. | ||
| /// </summary> | ||
| public string Key { get; } | ||
|
|
||
| /// <summary> | ||
| /// The agent config for this node. | ||
| /// </summary> | ||
| public LdAiAgentConfig Config { get; } | ||
|
|
||
| /// <summary> | ||
| /// The outgoing edges from this node to its children. | ||
| /// </summary> | ||
| public IReadOnlyList<GraphEdge> Edges { get; } | ||
|
|
||
| /// <summary> | ||
| /// Whether this node has no outgoing edges (i.e., is a leaf node). | ||
| /// </summary> | ||
| public bool IsTerminal => Edges.Count == 0; | ||
|
|
||
| /// <summary> | ||
| /// Constructs an agent graph node. | ||
| /// </summary> | ||
| public AgentGraphNode(string key, LdAiAgentConfig config, IReadOnlyList<GraphEdge> edges) | ||
| { | ||
| Key = key; | ||
| Config = config; | ||
| Edges = edges; | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.