diff --git a/pkgs/sdk/server-ai/src/Adapters/LoggerAdapter.cs b/pkgs/sdk/server-ai/src/Adapters/LoggerAdapter.cs
index e50eeed3..096dc30f 100644
--- a/pkgs/sdk/server-ai/src/Adapters/LoggerAdapter.cs
+++ b/pkgs/sdk/server-ai/src/Adapters/LoggerAdapter.cs
@@ -25,4 +25,7 @@ public LoggerAdapter(Logger logger)
///
public void Warn(string format, params object[] allParams) => _logger.Warn(format, allParams);
+
+ ///
+ public void Debug(string format, params object[] allParams) => _logger.Debug(format, allParams);
}
diff --git a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs
index fcad708f..75945507 100644
--- a/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs
+++ b/pkgs/sdk/server-ai/src/Config/ConfigFactory.cs
@@ -102,10 +102,11 @@ public LdAiAgentConfig BuildAgentConfig(
LdValue ldValue,
Context context,
LdAiAgentConfigDefault defaultValue,
- IReadOnlyDictionary variables)
+ IReadOnlyDictionary variables,
+ string graphKey = null)
{
var mergedVars = MergeVariables(variables, context);
- var trackerFactory = TrackerFactoryFor(context);
+ var trackerFactory = TrackerFactoryFor(context, graphKey);
if (ldValue.Type != LdValueType.Object)
{
@@ -144,7 +145,7 @@ public LdAiAgentConfig BuildAgentConfig(
trackerFactory);
}
- private LdAiAgentConfig BuildAgentFromDefault(
+ internal LdAiAgentConfig BuildAgentFromDefault(
string key,
LdAiAgentConfigDefault defaultValue,
IReadOnlyDictionary mergedVars,
@@ -275,7 +276,7 @@ private string InterpolateInstructions(
return result;
}
- private Func TrackerFactoryFor(Context context)
+ private Func TrackerFactoryFor(Context context, string graphKey = null)
{
return cfg => new LdAiConfigTracker(
_client,
@@ -285,7 +286,8 @@ private Func TrackerFactoryFor(Context context)
cfg.Version,
context,
cfg.Model?.Name,
- cfg.Provider?.Name);
+ cfg.Provider?.Name,
+ graphKey);
}
private static (bool Enabled, string VariationKey, int Version, string Mode) ParseMeta(LdValue value)
diff --git a/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs b/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs
new file mode 100644
index 00000000..eb73ddf2
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs
@@ -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;
+
+///
+/// Represents a fully-resolved agent graph returned by
+/// . When is false, all
+/// node collections are empty and traversal is a no-op; only
+/// and remain meaningful.
+///
+public sealed class AgentGraphDefinition
+{
+ private readonly AgentGraphFlagValue _flagValue;
+ private readonly IReadOnlyDictionary _nodes;
+ private readonly Func _createTracker;
+
+ ///
+ /// Whether the graph passed all validation checks. False if the flag's
+ /// _ldMeta.enabled is false, the root is missing, any node is
+ /// unreachable from the root, or any child agent config could not be fetched.
+ ///
+ public bool Enabled { get; }
+
+ internal AgentGraphDefinition(
+ AgentGraphFlagValue flagValue,
+ IReadOnlyDictionary nodes,
+ bool enabled,
+ Func createTracker)
+ {
+ _flagValue = flagValue;
+ _nodes = nodes;
+ Enabled = enabled;
+ _createTracker = createTracker;
+ }
+
+ ///
+ /// Returns the root node of the graph, or null if the graph is disabled or has no root.
+ ///
+ public AgentGraphNode RootNode() =>
+ string.IsNullOrEmpty(_flagValue?.Root) ? null : GetNode(_flagValue.Root);
+
+ ///
+ /// Returns the node with the given key, or null if not found.
+ ///
+ public AgentGraphNode GetNode(string nodeKey)
+ {
+ if (nodeKey == null) return null;
+ return _nodes.TryGetValue(nodeKey, out var node) ? node : null;
+ }
+
+ ///
+ /// Returns the direct children of the given node by following its outgoing edges.
+ /// Returns an empty list if the node is not found.
+ ///
+ public IReadOnlyList GetChildNodes(string nodeKey)
+ {
+ var node = GetNode(nodeKey);
+ if (node == null) return Array.Empty();
+
+ return node.Edges
+ .Select(edge => GetNode(edge.Key))
+ .Where(n => n != null)
+ .ToList();
+ }
+
+ ///
+ /// Returns all nodes that have an outgoing edge pointing to the given node key.
+ ///
+ public IReadOnlyList GetParentNodes(string nodeKey)
+ {
+ return _nodes.Values
+ .Where(node => node.Edges.Any(edge => edge.Key == nodeKey))
+ .ToList();
+ }
+
+ ///
+ /// Returns all nodes with no outgoing edges (leaf nodes).
+ ///
+ public IReadOnlyList TerminalNodes() =>
+ _nodes.Values.Where(n => n.IsTerminal).ToList();
+
+ ///
+ /// Returns the raw flag value including LaunchDarkly metadata. Always non-null,
+ /// even when is false.
+ ///
+ public AgentGraphFlagValue GetConfig() => _flagValue;
+
+ ///
+ /// Creates a new graph-level tracker for this invocation.
+ ///
+ public AiGraphTracker CreateTracker() => _createTracker();
+
+ ///
+ /// Performs a breadth-first traversal of the graph starting from the root node.
+ /// For each visited node, is called with the node and the
+ /// accumulated context dictionary. The return value of is
+ /// stored in the context under the node's key and passed to subsequent calls.
+ /// Cycle-safe: each node is visited at most once.
+ ///
+ public void Traverse(
+ Func, object> fn,
+ Dictionary initialContext = null)
+ {
+ var root = RootNode();
+ if (root == null) return;
+
+ var context = initialContext ?? new Dictionary();
+
+ var visited = new HashSet();
+ var queue = new Queue();
+ 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);
+ }
+ }
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ public void ReverseTraverse(
+ Func, object> fn,
+ Dictionary initialContext = null)
+ {
+ if (_nodes.Count == 0) return;
+
+ var context = initialContext ?? new Dictionary();
+
+ var root = RootNode();
+ var visited = new HashSet();
+ var queue = new Queue();
+
+ // 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;
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ internal static IReadOnlyDictionary BuildNodes(
+ AgentGraphFlagValue flagValue,
+ IReadOnlyDictionary agentConfigs)
+ {
+ var nodes = new Dictionary();
+ 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)Array.Empty();
+
+ nodes[key] = new AgentGraphNode(key, config, outgoingEdges);
+ }
+
+ return nodes;
+ }
+
+ ///
+ /// Collects all unique node keys referenced in the flag value: the root, all
+ /// edge source keys, and all edge target keys.
+ ///
+ internal static HashSet CollectAllKeys(AgentGraphFlagValue flagValue)
+ {
+ var keys = new HashSet();
+
+ 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;
+ }
+}
diff --git a/pkgs/sdk/server-ai/src/Graph/AgentGraphFlagValue.cs b/pkgs/sdk/server-ai/src/Graph/AgentGraphFlagValue.cs
new file mode 100644
index 00000000..590bbb26
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Graph/AgentGraphFlagValue.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+
+namespace LaunchDarkly.Sdk.Server.Ai.Graph;
+
+///
+/// Raw flag value for an agent graph configuration as returned by LaunchDarkly.
+/// Returned by .
+///
+public sealed class AgentGraphFlagValue
+{
+ ///
+ /// The key of the root AIAgentConfig in the graph.
+ ///
+ public string Root { get; init; }
+
+ ///
+ /// Object mapping source agent config keys to arrays of outgoing target edges.
+ /// Null when no edges are defined.
+ ///
+ public IReadOnlyDictionary> Edges { get; init; }
+
+ ///
+ /// LaunchDarkly metadata from the _ldMeta field on the flag value.
+ /// Null when a default/fallback value was used (flag not evaluated).
+ ///
+ public LdMeta Meta { get; init; }
+}
+
+///
+/// LaunchDarkly metadata from the _ldMeta field on flag values.
+///
+public sealed class LdMeta
+{
+ ///
+ /// The variation key, if available. Null when a default config was used.
+ ///
+ public string VariationKey { get; init; }
+
+ ///
+ /// The version of the flag variation. Defaults to 1.
+ ///
+ public int Version { get; init; } = 1;
+
+ ///
+ /// Whether the configuration is enabled in the LaunchDarkly dashboard.
+ /// Defaults to true. Note: this is distinct from
+ /// , which reflects the result of ALL
+ /// validation checks (metadata enabled + root present + all nodes reachable +
+ /// all child configs fetchable).
+ ///
+ public bool Enabled { get; init; } = true;
+}
diff --git a/pkgs/sdk/server-ai/src/Graph/AgentGraphNode.cs b/pkgs/sdk/server-ai/src/Graph/AgentGraphNode.cs
new file mode 100644
index 00000000..9354e4f8
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Graph/AgentGraphNode.cs
@@ -0,0 +1,43 @@
+using System.Collections.Generic;
+using LaunchDarkly.Sdk.Server.Ai.Config;
+
+namespace LaunchDarkly.Sdk.Server.Ai.Graph;
+
+///
+/// Represents a single node within an agent graph.
+/// Each node wraps an and carries the outgoing edges to
+/// its children. Use the config's tracker (via Config.CreateTracker()) to record
+/// node-level metrics.
+///
+public sealed class AgentGraphNode
+{
+ ///
+ /// The agent config key for this node.
+ ///
+ public string Key { get; }
+
+ ///
+ /// The agent config for this node.
+ ///
+ public LdAiAgentConfig Config { get; }
+
+ ///
+ /// The outgoing edges from this node to its children.
+ ///
+ public IReadOnlyList Edges { get; }
+
+ ///
+ /// Whether this node has no outgoing edges (i.e., is a leaf node).
+ ///
+ public bool IsTerminal => Edges.Count == 0;
+
+ ///
+ /// Constructs an agent graph node.
+ ///
+ public AgentGraphNode(string key, LdAiAgentConfig config, IReadOnlyList edges)
+ {
+ Key = key;
+ Config = config;
+ Edges = edges;
+ }
+}
diff --git a/pkgs/sdk/server-ai/src/Graph/AiGraphTracker.cs b/pkgs/sdk/server-ai/src/Graph/AiGraphTracker.cs
new file mode 100644
index 00000000..fdffd059
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Graph/AiGraphTracker.cs
@@ -0,0 +1,344 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.CompilerServices;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Linq;
+using System.Threading;
+using LaunchDarkly.Sdk.Server.Ai.Interfaces;
+using LaunchDarkly.Sdk.Server.Ai.Tracking;
+
+namespace LaunchDarkly.Sdk.Server.Ai.Graph;
+
+///
+/// Tracking metadata included in every graph tracker event.
+///
+public sealed record AiGraphTrackData(
+ string RunId,
+ string GraphKey,
+ string VariationKey,
+ int Version
+);
+
+///
+/// Records metrics for a single agent graph invocation.
+///
+///
+/// All events share a runId so LaunchDarkly can correlate them. Graph-level
+/// tracking methods (TrackInvocationSuccess, TrackDuration, etc.) are
+/// at-most-once — a second call logs a warning and is silently dropped. Edge-level
+/// methods (TrackRedirect, TrackHandoffSuccess, TrackHandoffFailure)
+/// are multi-fire and may be called once per edge traversal.
+///
+public sealed class AiGraphTracker
+{
+ private readonly ILaunchDarklyClient _client;
+ private readonly string _runId;
+ private readonly string _graphKey;
+ private readonly string _variationKey;
+ private readonly int _version;
+ private readonly Context _context;
+ private readonly LdValue _trackData;
+ private readonly ILogger _logger;
+
+ private StrongBox _trackedInvocation;
+ private StrongBox _durationMs;
+ private StrongBox _tokens;
+ private StrongBox> _path;
+
+ private readonly Lazy _resumptionToken;
+
+ private const string GraphInvocationSuccess = "$ld:ai:graph:invocation_success";
+ private const string GraphInvocationFailure = "$ld:ai:graph:invocation_failure";
+ private const string GraphDuration = "$ld:ai:graph:duration:total";
+ private const string GraphTotalTokens = "$ld:ai:graph:total_tokens";
+ private const string GraphPath = "$ld:ai:graph:path";
+ private const string GraphRedirect = "$ld:ai:graph:redirect";
+ private const string GraphHandoffSuccess = "$ld:ai:graph:handoff_success";
+ private const string GraphHandoffFailure = "$ld:ai:graph:handoff_failure";
+
+ ///
+ /// Constructs a new graph tracker. If is null, a new UUIDv4
+ /// is generated automatically.
+ ///
+ public AiGraphTracker(
+ ILaunchDarklyClient ldClient,
+ string graphKey,
+ int version,
+ Context context,
+ string variationKey = null,
+ string runId = null)
+ {
+ _client = ldClient ?? throw new ArgumentNullException(nameof(ldClient));
+ _graphKey = graphKey ?? throw new ArgumentNullException(nameof(graphKey));
+ _runId = runId ?? Guid.NewGuid().ToString();
+ _variationKey = variationKey ?? "";
+ _version = version;
+ _context = context;
+ _logger = _client.GetLogger();
+
+ var trackDataBuilder = new Dictionary
+ {
+ { "runId", LdValue.Of(_runId) },
+ { "graphKey", LdValue.Of(_graphKey) },
+ { "version", LdValue.Of(_version) },
+ };
+ if (!string.IsNullOrEmpty(_variationKey))
+ {
+ trackDataBuilder.Add("variationKey", LdValue.Of(_variationKey));
+ }
+ _trackData = LdValue.ObjectFrom(trackDataBuilder);
+
+ _resumptionToken = new Lazy(BuildResumptionToken);
+ }
+
+ ///
+ /// The resumption token for cross-process continuation of this tracker.
+ ///
+ public string ResumptionToken => _resumptionToken.Value;
+
+ ///
+ /// A partial snapshot of the metrics tracked so far. Fields are null until the
+ /// corresponding track method fires.
+ ///
+ public AiGraphMetricSummary Summary => new AiGraphMetricSummary(
+ _trackedInvocation?.Value,
+ _durationMs?.Value,
+ _tokens?.Value,
+ _path?.Value,
+ null,
+ ResumptionToken
+ );
+
+ ///
+ /// Returns the track data included in every event fired by this tracker.
+ ///
+ public AiGraphTrackData GetTrackData() =>
+ new AiGraphTrackData(_runId, _graphKey,
+ string.IsNullOrEmpty(_variationKey) ? null : _variationKey,
+ _version);
+
+ private string BuildResumptionToken()
+ {
+ using var stream = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("runId", _runId);
+ writer.WriteString("graphKey", _graphKey);
+ if (!string.IsNullOrEmpty(_variationKey))
+ {
+ writer.WriteString("variationKey", _variationKey);
+ }
+ writer.WriteNumber("version", _version);
+ writer.WriteEndObject();
+ }
+ var base64 = Convert.ToBase64String(stream.ToArray());
+ return base64.Replace('+', '-').Replace('/', '_').TrimEnd('=');
+ }
+
+ ///
+ /// Reconstructs a graph tracker from a resumption token, enabling cross-process
+ /// scenarios where a tracker's run ID needs to be reused.
+ ///
+ public static AiGraphTracker FromResumptionToken(
+ string token, ILaunchDarklyClient ldClient, Context context)
+ {
+ if (token == null) throw new ArgumentNullException(nameof(token));
+ if (ldClient == null) throw new ArgumentNullException(nameof(ldClient));
+
+ var base64 = token.Replace('-', '+').Replace('_', '/');
+ switch (base64.Length % 4)
+ {
+ case 2: base64 += "=="; break;
+ case 3: base64 += "="; break;
+ }
+
+ GraphResumptionPayload payload;
+ try
+ {
+ var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64));
+ payload = JsonSerializer.Deserialize(json);
+ }
+ catch (Exception e) when (!(e is OutOfMemoryException))
+ {
+ throw new ArgumentException("Invalid graph resumption token", nameof(token), e);
+ }
+
+ if (payload == null || string.IsNullOrEmpty(payload.RunId) || string.IsNullOrEmpty(payload.GraphKey))
+ {
+ throw new ArgumentException(
+ "Graph resumption token is missing required fields (runId, graphKey)",
+ nameof(token));
+ }
+
+ return new AiGraphTracker(ldClient, payload.GraphKey, payload.Version ?? 1, context,
+ payload.VariationKey, payload.RunId);
+ }
+
+ ///
+ /// Records a successful graph invocation. At-most-once; shares a slot with
+ /// .
+ ///
+ public void TrackInvocationSuccess()
+ {
+ if (Interlocked.CompareExchange(ref _trackedInvocation,
+ new StrongBox(true), null) != null)
+ {
+ _logger?.Warn("Skipping TrackInvocationSuccess: invocation already recorded on this graph tracker. {0}",
+ _trackData.ToJsonString());
+ return;
+ }
+ _client.Track(GraphInvocationSuccess, _context, _trackData, 1);
+ }
+
+ ///
+ /// Records a failed graph invocation. At-most-once; shares a slot with
+ /// .
+ ///
+ public void TrackInvocationFailure()
+ {
+ if (Interlocked.CompareExchange(ref _trackedInvocation,
+ new StrongBox(false), null) != null)
+ {
+ _logger?.Warn("Skipping TrackInvocationFailure: invocation already recorded on this graph tracker. {0}",
+ _trackData.ToJsonString());
+ return;
+ }
+ _client.Track(GraphInvocationFailure, _context, _trackData, 1);
+ }
+
+ ///
+ /// Records the total duration of the graph invocation in milliseconds. At-most-once.
+ ///
+ public void TrackDuration(double durationMs)
+ {
+ if (Interlocked.CompareExchange(ref _durationMs,
+ new StrongBox(durationMs), null) != null)
+ {
+ _logger?.Warn("Skipping TrackDuration: duration already recorded on this graph tracker. {0}",
+ _trackData.ToJsonString());
+ return;
+ }
+ _client.Track(GraphDuration, _context, _trackData, durationMs);
+ }
+
+ ///
+ /// Records the aggregate token usage across all nodes. At-most-once.
+ ///
+ public void TrackTotalTokens(Usage tokens)
+ {
+ // Empty usage doesn't burn the slot.
+ if ((tokens.Total ?? 0) <= 0 && (tokens.Input ?? 0) <= 0 && (tokens.Output ?? 0) <= 0)
+ {
+ return;
+ }
+ if (Interlocked.CompareExchange(ref _tokens,
+ new StrongBox(tokens), null) != null)
+ {
+ _logger?.Warn("Skipping TrackTotalTokens: tokens already recorded on this graph tracker. {0}",
+ _trackData.ToJsonString());
+ return;
+ }
+ var total = (tokens.Total ?? 0) > 0
+ ? tokens.Total.Value
+ : (tokens.Input ?? 0) + (tokens.Output ?? 0);
+ _client.Track(GraphTotalTokens, _context, _trackData, total);
+ }
+
+ ///
+ /// Records the path of node keys visited during graph execution. At-most-once.
+ ///
+ public void TrackPath(IReadOnlyList path)
+ {
+ if (path == null) return;
+
+ if (Interlocked.CompareExchange(ref _path,
+ new StrongBox>(path.ToArray()), null) != null)
+ {
+ _logger?.Warn("Skipping TrackPath: path already recorded on this graph tracker. {0}",
+ _trackData.ToJsonString());
+ return;
+ }
+
+ var pathArray = LdValue.ArrayFrom(path.Select(LdValue.Of));
+ var data = MergeTrackData("path", pathArray);
+ _client.Track(GraphPath, _context, data, 1);
+ }
+
+ ///
+ /// Records that a node redirected execution to a different target than the configured edge.
+ /// Multi-fire — may be called once per redirect that occurs.
+ ///
+ public void TrackRedirect(string sourceKey, string redirectedTarget)
+ {
+ var data = MergeTrackData(new Dictionary
+ {
+ { "sourceKey", LdValue.Of(sourceKey) },
+ { "redirectedTarget", LdValue.Of(redirectedTarget) }
+ });
+ _client.Track(GraphRedirect, _context, data, 1);
+ }
+
+ ///
+ /// Records a successful handoff from one node to another.
+ /// Multi-fire — may be called once per handoff that occurs.
+ ///
+ public void TrackHandoffSuccess(string sourceKey, string targetKey)
+ {
+ var data = MergeTrackData(new Dictionary
+ {
+ { "sourceKey", LdValue.Of(sourceKey) },
+ { "targetKey", LdValue.Of(targetKey) }
+ });
+ _client.Track(GraphHandoffSuccess, _context, data, 1);
+ }
+
+ ///
+ /// Records a failed handoff from one node to another.
+ /// Multi-fire — may be called once per failed handoff that occurs.
+ ///
+ public void TrackHandoffFailure(string sourceKey, string targetKey)
+ {
+ var data = MergeTrackData(new Dictionary
+ {
+ { "sourceKey", LdValue.Of(sourceKey) },
+ { "targetKey", LdValue.Of(targetKey) }
+ });
+ _client.Track(GraphHandoffFailure, _context, data, 1);
+ }
+
+ private LdValue MergeTrackData(string key, LdValue value)
+ {
+ var builder = new Dictionary(_trackData.Dictionary);
+ builder[key] = value;
+ return LdValue.ObjectFrom(builder);
+ }
+
+ private LdValue MergeTrackData(Dictionary extra)
+ {
+ var builder = new Dictionary(_trackData.Dictionary);
+ foreach (var kv in extra)
+ {
+ builder[kv.Key] = kv.Value;
+ }
+ return LdValue.ObjectFrom(builder);
+ }
+
+ private class GraphResumptionPayload
+ {
+ [JsonPropertyName("runId")]
+ public string RunId { get; set; }
+
+ [JsonPropertyName("graphKey")]
+ public string GraphKey { get; set; }
+
+ [JsonPropertyName("variationKey")]
+ public string VariationKey { get; set; }
+
+ [JsonPropertyName("version")]
+ public int? Version { get; set; }
+ }
+}
diff --git a/pkgs/sdk/server-ai/src/Graph/GraphEdge.cs b/pkgs/sdk/server-ai/src/Graph/GraphEdge.cs
new file mode 100644
index 00000000..c137f52a
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Graph/GraphEdge.cs
@@ -0,0 +1,12 @@
+using System.Collections.Generic;
+
+namespace LaunchDarkly.Sdk.Server.Ai.Graph;
+
+///
+/// A directed edge in an agent graph, connecting a source node to a target node.
+/// The source is implicit — it is the node that owns this edge.
+///
+public sealed record GraphEdge(
+ string Key,
+ IReadOnlyDictionary Handoff
+);
diff --git a/pkgs/sdk/server-ai/src/Interfaces/ILdAiGraphClient.cs b/pkgs/sdk/server-ai/src/Interfaces/ILdAiGraphClient.cs
new file mode 100644
index 00000000..8f05d4f0
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Interfaces/ILdAiGraphClient.cs
@@ -0,0 +1,34 @@
+using System.Collections.Generic;
+using LaunchDarkly.Sdk.Server.Ai.Graph;
+
+namespace LaunchDarkly.Sdk.Server.Ai.Interfaces;
+
+///
+/// Extension interface for agent graph operations. Implemented alongside
+/// by .
+///
+public interface ILdAiGraphClient
+{
+ ///
+ /// Retrieves and validates an agent graph identified by .
+ /// Fires a usage tracking event, evaluates the graph flag, fetches all agent configs
+ /// referenced in the graph, and performs connectivity validation. Returns an
+ /// whose Enabled property indicates whether
+ /// all validation steps passed.
+ ///
+ /// the LaunchDarkly flag key for the agent graph
+ /// the context
+ /// optional variables interpolated into each node's agent instructions
+ /// an agent graph definition
+ AgentGraphDefinition AgentGraph(string graphKey, Context context,
+ IReadOnlyDictionary variables = null);
+
+ ///
+ /// Reconstructs a graph tracker from a resumption token. This enables cross-process
+ /// continuation of graph-level metrics.
+ ///
+ /// the token obtained from
+ /// the context to use for track events
+ /// a graph tracker associated with the original run
+ AiGraphTracker CreateGraphTracker(string resumptionToken, Context context);
+}
diff --git a/pkgs/sdk/server-ai/src/Interfaces/ILogger.cs b/pkgs/sdk/server-ai/src/Interfaces/ILogger.cs
index 6c0d0c18..b64b9757 100644
--- a/pkgs/sdk/server-ai/src/Interfaces/ILogger.cs
+++ b/pkgs/sdk/server-ai/src/Interfaces/ILogger.cs
@@ -18,4 +18,11 @@ public interface ILogger
/// format string
/// parameters
void Warn(string format, params object[] allParams);
+
+ ///
+ /// Log a debug message.
+ ///
+ /// format string
+ /// parameters
+ void Debug(string format, params object[] allParams);
}
diff --git a/pkgs/sdk/server-ai/src/LdAiClient.cs b/pkgs/sdk/server-ai/src/LdAiClient.cs
index 81c0ef97..f36b0f3a 100644
--- a/pkgs/sdk/server-ai/src/LdAiClient.cs
+++ b/pkgs/sdk/server-ai/src/LdAiClient.cs
@@ -1,17 +1,20 @@
using System;
using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Linq;
using LaunchDarkly.Sdk.Server.Ai.Adapters;
using LaunchDarkly.Sdk.Server.Ai.Config;
+using LaunchDarkly.Sdk.Server.Ai.Graph;
using LaunchDarkly.Sdk.Server.Ai.Interfaces;
namespace LaunchDarkly.Sdk.Server.Ai;
///
-/// The LaunchDarkly AI client. The client is capable of retrieving AI Configs from LaunchDarkly,
-/// and generating events specific to usage of the AI Config when interacting with model providers.
+/// The LaunchDarkly AI client. The client is capable of retrieving AI Configs and agent graphs
+/// from LaunchDarkly, and generating events specific to usage of those configs when interacting
+/// with model providers.
///
-public sealed class LdAiClient : ILdAiClient
+public sealed class LdAiClient : ILdAiClient, ILdAiGraphClient
{
private readonly ILaunchDarklyClient _client;
private readonly ConfigFactory _factory;
@@ -21,6 +24,7 @@ public sealed class LdAiClient : ILdAiClient
private const string TrackUsageAgentConfig = "$ld:ai:usage:agent-config";
private const string TrackUsageAgentConfigs = "$ld:ai:usage:agent-configs";
private const string TrackUsageJudgeConfig = "$ld:ai:usage:judge-config";
+ private const string TrackUsageAgentGraph = "$ld:ai:usage:agent-graph";
///
/// Constructs a new LaunchDarkly AI client. Please note, the client library is an alpha release and is
@@ -79,11 +83,12 @@ public LdAiCompletionConfig Config(string key, Context context, LdAiCompletionCo
private LdAiAgentConfig BuildAgentConfig(string key, Context context,
LdAiAgentConfigDefault defaultValue,
- IReadOnlyDictionary variables)
+ IReadOnlyDictionary variables,
+ string graphKey = null)
{
defaultValue ??= LdAiAgentConfigDefault.Disabled;
var ldValue = _client.JsonVariation(key, context, defaultValue.ToLdValue());
- return _factory.BuildAgentConfig(key, ldValue, context, defaultValue, variables);
+ return _factory.BuildAgentConfig(key, ldValue, context, defaultValue, variables, graphKey);
}
///
@@ -125,4 +130,152 @@ public ILdAiConfigTracker CreateTracker(string resumptionToken, Context context)
{
return LdAiConfigTracker.FromResumptionToken(resumptionToken, _client, context);
}
+
+ ///
+ public AgentGraphDefinition AgentGraph(string graphKey, Context context,
+ IReadOnlyDictionary variables = null)
+ {
+ _client.Track(TrackUsageAgentGraph, context, LdValue.Of(graphKey), 1);
+
+ var defaultFlagValue = LdValue.ObjectFrom(new Dictionary
+ {
+ { "root", LdValue.Of("") }
+ });
+ var flagValue = _client.JsonVariation(graphKey, context, defaultFlagValue);
+ var parsed = ParseAgentGraphFlagValue(flagValue);
+
+ var variationKey = parsed.Meta?.VariationKey;
+ var version = parsed.Meta?.Version ?? 1;
+ AiGraphTracker TrackerFactory() => new AiGraphTracker(_client, graphKey, version, context, variationKey);
+
+ var disabled = new AgentGraphDefinition(parsed,
+ new Dictionary(),
+ enabled: false,
+ createTracker: TrackerFactory);
+
+ if (parsed.Meta?.Enabled == false)
+ {
+ _client.GetLogger()?.Debug($"agentGraph: graph \"{graphKey}\" is disabled.");
+ return disabled;
+ }
+
+ if (string.IsNullOrEmpty(parsed.Root))
+ {
+ _client.GetLogger()?.Debug($"agentGraph: graph \"{graphKey}\" is not fetchable or has no root node.");
+ return disabled;
+ }
+
+ var allKeys = AgentGraphDefinition.CollectAllKeys(parsed);
+ var reachableKeys = CollectReachableKeys(parsed);
+ var unreachable = allKeys.FirstOrDefault(k => !reachableKeys.Contains(k));
+ if (unreachable != null)
+ {
+ _client.GetLogger()?.Debug(
+ $"agentGraph: graph \"{graphKey}\" has unconnected node \"{unreachable}\" that is not reachable from the root.");
+ return disabled;
+ }
+
+ var agentConfigs = new Dictionary();
+ foreach (var key in allKeys)
+ {
+ var config = BuildAgentConfig(key, context, null, variables, graphKey);
+ if (!config.Enabled)
+ {
+ _client.GetLogger()?.Debug(
+ $"agentGraph: agent config \"{key}\" in graph \"{graphKey}\" is not enabled or could not be fetched.");
+ return disabled;
+ }
+ agentConfigs[key] = config;
+ }
+
+ var nodes = AgentGraphDefinition.BuildNodes(parsed, agentConfigs);
+ return new AgentGraphDefinition(parsed, nodes, enabled: true, createTracker: TrackerFactory);
+ }
+
+ ///
+ public AiGraphTracker CreateGraphTracker(string resumptionToken, Context context)
+ {
+ return AiGraphTracker.FromResumptionToken(resumptionToken, _client, context);
+ }
+
+ private static AgentGraphFlagValue ParseAgentGraphFlagValue(LdValue flagValue)
+ {
+ if (flagValue.Type != LdValueType.Object)
+ {
+ return new AgentGraphFlagValue { Root = "" };
+ }
+
+ var root = flagValue.Get("root").AsString ?? "";
+
+ IReadOnlyDictionary> edges = null;
+ var edgesValue = flagValue.Get("edges");
+ if (edgesValue.Type == LdValueType.Object)
+ {
+ var edgesDict = new Dictionary>();
+ foreach (var kv in edgesValue.Dictionary)
+ {
+ if (kv.Value.Type != LdValueType.Array) continue;
+ var edgeList = new List();
+ for (var i = 0; i < kv.Value.Count; i++)
+ {
+ var edgeVal = kv.Value.Get(i);
+ if (edgeVal.Type != LdValueType.Object) continue;
+ var targetKey = edgeVal.Get("key").AsString;
+ if (string.IsNullOrEmpty(targetKey)) continue;
+
+ IReadOnlyDictionary handoff = null;
+ var handoffVal = edgeVal.Get("handoff");
+ if (handoffVal.Type == LdValueType.Object)
+ {
+ handoff = new ReadOnlyDictionary(
+ handoffVal.Dictionary.ToDictionary(h => h.Key, h => h.Value));
+ }
+
+ edgeList.Add(new GraphEdge(targetKey, handoff));
+ }
+ edgesDict[kv.Key] = edgeList.AsReadOnly();
+ }
+ edges = new ReadOnlyDictionary>(edgesDict);
+ }
+
+ LdMeta meta = null;
+ var metaValue = flagValue.Get("_ldMeta");
+ if (metaValue.Type == LdValueType.Object)
+ {
+ var versionVal = metaValue.Get("version");
+ var enabledVal = metaValue.Get("enabled");
+ meta = new LdMeta
+ {
+ VariationKey = metaValue.Get("variationKey").AsString,
+ Version = versionVal.IsNull ? 1 : versionVal.AsInt,
+ Enabled = enabledVal.IsNull || enabledVal.AsBool
+ };
+ }
+
+ return new AgentGraphFlagValue { Root = root, Edges = edges, Meta = meta };
+ }
+
+ private static HashSet CollectReachableKeys(AgentGraphFlagValue flagValue)
+ {
+ var visited = new HashSet { flagValue.Root };
+ var queue = new Queue();
+ queue.Enqueue(flagValue.Root);
+
+ while (queue.Count > 0)
+ {
+ var key = queue.Dequeue();
+ if (flagValue.Edges != null && flagValue.Edges.TryGetValue(key, out var edges))
+ {
+ foreach (var edge in edges)
+ {
+ if (visited.Add(edge.Key))
+ {
+ queue.Enqueue(edge.Key);
+ }
+ }
+ }
+ }
+
+ return visited;
+ }
}
diff --git a/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs b/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs
index 2b814b02..1dd3a4b6 100644
--- a/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs
+++ b/pkgs/sdk/server-ai/src/LdAiConfigTracker.cs
@@ -29,6 +29,7 @@ public class LdAiConfigTracker : ILdAiConfigTracker
private readonly ILaunchDarklyClient _client;
private readonly string _runId;
private readonly string _configKey;
+ private readonly string _graphKey;
private readonly string _variationKey;
private readonly int _version;
private readonly Context _context;
@@ -68,11 +69,13 @@ public class LdAiConfigTracker : ILdAiConfigTracker
/// funnel through this single constructor.
///
internal LdAiConfigTracker(ILaunchDarklyClient client, string runId, string configKey,
- string variationKey, int version, Context context, string modelName, string providerName)
+ string variationKey, int version, Context context, string modelName, string providerName,
+ string graphKey = null)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_configKey = configKey ?? throw new ArgumentNullException(nameof(configKey));
_runId = runId ?? "";
+ _graphKey = graphKey ?? "";
_variationKey = variationKey ?? "";
_version = version;
_context = context;
@@ -88,6 +91,10 @@ internal LdAiConfigTracker(ILaunchDarklyClient client, string runId, string conf
{ "modelName", LdValue.Of(_modelName) },
{ "providerName", LdValue.Of(_providerName) },
};
+ if (!string.IsNullOrEmpty(_graphKey))
+ {
+ trackDataBuilder.Add("graphKey", LdValue.Of(_graphKey));
+ }
if (!string.IsNullOrEmpty(_variationKey))
{
trackDataBuilder.Add("variationKey", LdValue.Of(_variationKey));
@@ -103,14 +110,18 @@ internal LdAiConfigTracker(ILaunchDarklyClient client, string runId, string conf
private string BuildResumptionToken()
{
// Utf8JsonWriter gives stable key ordering and avoids the runtime cost of
- // anonymous-type reflection. The wire format omits empty variationKey so that
- // resumption tokens round-trip exactly for configs that never carried one.
+ // anonymous-type reflection. The wire format omits empty optional fields so that
+ // resumption tokens round-trip exactly for configs that never carried them.
using var stream = new MemoryStream();
using (var writer = new Utf8JsonWriter(stream))
{
writer.WriteStartObject();
writer.WriteString("runId", _runId);
writer.WriteString("configKey", _configKey);
+ if (!string.IsNullOrEmpty(_graphKey))
+ {
+ writer.WriteString("graphKey", _graphKey);
+ }
if (!string.IsNullOrEmpty(_variationKey))
{
writer.WriteString("variationKey", _variationKey);
@@ -414,7 +425,7 @@ public static LdAiConfigTracker FromResumptionToken(string token, ILaunchDarklyC
}
return new LdAiConfigTracker(client, payload.RunId, payload.ConfigKey,
- payload.VariationKey, payload.Version, context, "", "");
+ payload.VariationKey, payload.Version ?? 1, context, "", "", payload.GraphKey);
}
private class ResumptionPayload
@@ -425,10 +436,13 @@ private class ResumptionPayload
[JsonPropertyName("configKey")]
public string ConfigKey { get; set; }
+ [JsonPropertyName("graphKey")]
+ public string GraphKey { get; set; }
+
[JsonPropertyName("variationKey")]
public string VariationKey { get; set; }
[JsonPropertyName("version")]
- public int Version { get; set; } = 1;
+ public int? Version { get; set; }
}
}
diff --git a/pkgs/sdk/server-ai/src/Polyfills/IsExternalInit.cs b/pkgs/sdk/server-ai/src/Polyfills/IsExternalInit.cs
new file mode 100644
index 00000000..0a534dd7
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Polyfills/IsExternalInit.cs
@@ -0,0 +1,13 @@
+// Required for C# 9+ init-only properties when targeting net462 or netstandard2.0.
+#if !NET5_0_OR_GREATER
+namespace System.Runtime.CompilerServices
+{
+ using System.ComponentModel;
+
+ // Polyfill for the IsExternalInit marker class, which the C# compiler requires
+ // for 'init' accessors and positional record properties but which is only defined
+ // in .NET 5+ runtimes.
+ [EditorBrowsable(EditorBrowsableState.Never)]
+ internal static class IsExternalInit { }
+}
+#endif
diff --git a/pkgs/sdk/server-ai/src/Tracking/AiGraphMetricSummary.cs b/pkgs/sdk/server-ai/src/Tracking/AiGraphMetricSummary.cs
new file mode 100644
index 00000000..efe882f2
--- /dev/null
+++ b/pkgs/sdk/server-ai/src/Tracking/AiGraphMetricSummary.cs
@@ -0,0 +1,21 @@
+using System.Collections.Generic;
+
+namespace LaunchDarkly.Sdk.Server.Ai.Tracking;
+
+///
+/// A summary of the metrics tracked by an AiGraphTracker during a graph invocation.
+///
+/// whether the overall graph invocation succeeded
+/// the total duration in milliseconds
+/// the aggregate token usage across the graph
+/// the sequence of node keys visited during execution
+/// per-node metric summaries keyed by agent config key
+/// the resumption token for cross-process continuation
+public record struct AiGraphMetricSummary(
+ bool? Success,
+ double? DurationMs,
+ Usage? Tokens,
+ IReadOnlyList Path,
+ IReadOnlyDictionary NodeMetrics,
+ string ResumptionToken
+);
diff --git a/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs
new file mode 100644
index 00000000..54bb8d5f
--- /dev/null
+++ b/pkgs/sdk/server-ai/test/AgentGraphDefinitionTest.cs
@@ -0,0 +1,423 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using LaunchDarkly.Sdk.Server.Ai.Config;
+using LaunchDarkly.Sdk.Server.Ai.Graph;
+using LaunchDarkly.Sdk.Server.Ai.Interfaces;
+using Moq;
+using Xunit;
+
+namespace LaunchDarkly.Sdk.Server.Ai;
+
+///
+/// Tests for AgentGraphDefinition (spec tests 26–35).
+///
+public class AgentGraphDefinitionTest
+{
+ private static LdAiAgentConfig MakeAgentConfig(string key, bool enabled = true)
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetLogger()).Returns(new Mock().Object);
+ return new LdAiAgentConfig(
+ key: key,
+ enabled: enabled,
+ variationKey: "v1",
+ version: 1,
+ instructions: null,
+ tools: new Dictionary(),
+ model: null,
+ provider: null,
+ judgeConfiguration: null,
+ trackerFactory: _ => new LdAiConfigTracker(mockClient.Object, Guid.NewGuid().ToString(),
+ key, "v1", 1, Context.New("u"), "", ""));
+ }
+
+ private static AgentGraphFlagValue ThreeNodeFlagValue()
+ {
+ return new AgentGraphFlagValue
+ {
+ Root = "agent-a",
+ Edges = new Dictionary>
+ {
+ ["agent-a"] = new[] { new GraphEdge("agent-b", null) },
+ ["agent-b"] = new[] { new GraphEdge("agent-c", null) }
+ },
+ Meta = new LdMeta { VariationKey = "v1", Version = 1, Enabled = true }
+ };
+ }
+
+ private static IReadOnlyDictionary ThreeNodeConfigs()
+ {
+ return new Dictionary
+ {
+ ["agent-a"] = MakeAgentConfig("agent-a"),
+ ["agent-b"] = MakeAgentConfig("agent-b"),
+ ["agent-c"] = MakeAgentConfig("agent-c")
+ };
+ }
+
+ private static AgentGraphDefinition BuildEnabled(AgentGraphFlagValue flagValue,
+ IReadOnlyDictionary configs)
+ {
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetLogger()).Returns(new Mock().Object);
+ var nodes = AgentGraphDefinition.BuildNodes(flagValue, configs);
+ return new AgentGraphDefinition(flagValue, nodes, enabled: true,
+ createTracker: () => new AiGraphTracker(mockClient.Object, "g", 1, Context.New("u")));
+ }
+
+ // Test 26: BuildNodes populates each node with structured GraphEdge[] from flag value edges map
+ [Fact]
+ public void BuildNodesPopulatesEdgesFromFlagValue()
+ {
+ var flagValue = ThreeNodeFlagValue();
+ var configs = ThreeNodeConfigs();
+
+ var nodes = AgentGraphDefinition.BuildNodes(flagValue, configs);
+
+ Assert.Equal(3, nodes.Count);
+ Assert.True(nodes.ContainsKey("agent-a"));
+ Assert.True(nodes.ContainsKey("agent-b"));
+ Assert.True(nodes.ContainsKey("agent-c"));
+
+ Assert.Single(nodes["agent-a"].Edges);
+ Assert.Equal("agent-b", nodes["agent-a"].Edges[0].Key);
+
+ Assert.Single(nodes["agent-b"].Edges);
+ Assert.Equal("agent-c", nodes["agent-b"].Edges[0].Key);
+
+ Assert.Empty(nodes["agent-c"].Edges);
+ }
+
+ // Test 27: GetChildNodes maps through node's Edges (edge.Key → node lookup)
+ [Fact]
+ public void GetChildNodesMapsEdgesToNodes()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+
+ var childrenOfA = graph.GetChildNodes("agent-a");
+ Assert.Single(childrenOfA);
+ Assert.Equal("agent-b", childrenOfA[0].Key);
+
+ var childrenOfB = graph.GetChildNodes("agent-b");
+ Assert.Single(childrenOfB);
+ Assert.Equal("agent-c", childrenOfB[0].Key);
+
+ var childrenOfC = graph.GetChildNodes("agent-c");
+ Assert.Empty(childrenOfC);
+ }
+
+ // Test 28: GetParentNodes finds all nodes whose edges reference the given key
+ [Fact]
+ public void GetParentNodesFindsByEdgeTarget()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+
+ var parentsOfA = graph.GetParentNodes("agent-a");
+ Assert.Empty(parentsOfA);
+
+ var parentsOfB = graph.GetParentNodes("agent-b");
+ Assert.Single(parentsOfB);
+ Assert.Equal("agent-a", parentsOfB[0].Key);
+
+ var parentsOfC = graph.GetParentNodes("agent-c");
+ Assert.Single(parentsOfC);
+ Assert.Equal("agent-b", parentsOfC[0].Key);
+ }
+
+ // Test 29: TerminalNodes returns nodes with no outgoing edges
+ [Fact]
+ public void TerminalNodesReturnsLeafNodes()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+
+ var terminals = graph.TerminalNodes();
+ Assert.Single(terminals);
+ Assert.Equal("agent-c", terminals[0].Key);
+ }
+
+ // Test 30: RootNode returns node matching GetConfig().Root
+ [Fact]
+ public void RootNodeReturnsNodeMatchingRoot()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+
+ var root = graph.RootNode();
+ Assert.NotNull(root);
+ Assert.Equal("agent-a", root.Key);
+ Assert.Equal(graph.GetConfig().Root, root.Key);
+ }
+
+ // Test 31: GetConfig returns AgentGraphFlagValue with Meta nested (variationKey, version, enabled)
+ [Fact]
+ public void GetConfigReturnsFlagValueWithMeta()
+ {
+ var flagValue = ThreeNodeFlagValue();
+ var graph = BuildEnabled(flagValue, ThreeNodeConfigs());
+
+ var config = graph.GetConfig();
+ Assert.Equal("agent-a", config.Root);
+ Assert.NotNull(config.Meta);
+ Assert.Equal("v1", config.Meta.VariationKey);
+ Assert.Equal(1, config.Meta.Version);
+ Assert.True(config.Meta.Enabled);
+ }
+
+ // Test 31b: GetConfig is still available when graph is disabled
+ [Fact]
+ public void GetConfigAvailableOnDisabledGraph()
+ {
+ var flagValue = ThreeNodeFlagValue();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetLogger()).Returns(new Mock().Object);
+ var disabled = new AgentGraphDefinition(flagValue, new Dictionary(),
+ enabled: false, createTracker: () => new AiGraphTracker(mockClient.Object, "g", 1, Context.New("u")));
+
+ Assert.False(disabled.Enabled);
+ Assert.Same(flagValue, disabled.GetConfig());
+ }
+
+ // Test 32: Traverse visits nodes BFS from root
+ [Fact]
+ public void TraverseVisitsNodesInBfsOrder()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+
+ var visited = new List();
+ graph.Traverse((node, ctx) =>
+ {
+ visited.Add(node.Key);
+ return null;
+ });
+
+ Assert.Equal(new[] { "agent-a", "agent-b", "agent-c" }, visited);
+ }
+
+ // Test 33: ReverseTraverse visits from terminals upward, root always last
+ [Fact]
+ public void ReverseTraverseVisitsRootLast()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+
+ var visited = new List();
+ graph.ReverseTraverse((node, ctx) =>
+ {
+ visited.Add(node.Key);
+ return null;
+ });
+
+ Assert.Equal(3, visited.Count);
+ Assert.Equal("agent-c", visited[0]);
+ Assert.Equal("agent-a", visited[visited.Count - 1]);
+ }
+
+ // Test 34: CreateTracker returns AiGraphTracker with correct graphKey
+ [Fact]
+ public void CreateTrackerReturnsGraphTrackerWithGraphKey()
+ {
+ var flagValue = ThreeNodeFlagValue();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetLogger()).Returns(new Mock().Object);
+ var context = Context.New("user");
+ var nodes = AgentGraphDefinition.BuildNodes(flagValue, ThreeNodeConfigs());
+ var graph = new AgentGraphDefinition(flagValue, nodes, enabled: true,
+ createTracker: () => new AiGraphTracker(mockClient.Object, "my-graph-key", 1, context));
+
+ var tracker = graph.CreateTracker();
+ Assert.Equal("my-graph-key", tracker.GetTrackData().GraphKey);
+ }
+
+ // Test 35: Cycle-safe — pure cycle has no terminal nodes, so reverse traversal is a no-op
+ [Fact]
+ public void ReverseTraverseIsCycleSafe()
+ {
+ // a → b → c → a (pure cycle, no terminal nodes)
+ var flagValue = new AgentGraphFlagValue
+ {
+ Root = "a",
+ Edges = new Dictionary>
+ {
+ ["a"] = new[] { new GraphEdge("b", null) },
+ ["b"] = new[] { new GraphEdge("c", null) },
+ ["c"] = new[] { new GraphEdge("a", null) }
+ },
+ Meta = new LdMeta { Enabled = true }
+ };
+ var configs = new Dictionary
+ {
+ ["a"] = MakeAgentConfig("a"),
+ ["b"] = MakeAgentConfig("b"),
+ ["c"] = MakeAgentConfig("c")
+ };
+
+ var graph = BuildEnabled(flagValue, configs);
+
+ var visited = new List();
+ graph.ReverseTraverse((node, ctx) =>
+ {
+ visited.Add(node.Key);
+ return null;
+ });
+
+ // Pure cycle has no terminal nodes — reverse traversal is a no-op per spec AIGRAPH 1.4
+ Assert.Empty(visited);
+ }
+
+ // Test 36: Cycle-safe — graph with cycles doesn't infinite loop
+ [Fact]
+ public void TraverseIsCycleSafe()
+ {
+ // a → b → c → a (cycle)
+ var flagValue = new AgentGraphFlagValue
+ {
+ Root = "a",
+ Edges = new Dictionary>
+ {
+ ["a"] = new[] { new GraphEdge("b", null) },
+ ["b"] = new[] { new GraphEdge("c", null) },
+ ["c"] = new[] { new GraphEdge("a", null) }
+ },
+ Meta = new LdMeta { Enabled = true }
+ };
+ var configs = new Dictionary
+ {
+ ["a"] = MakeAgentConfig("a"),
+ ["b"] = MakeAgentConfig("b"),
+ ["c"] = MakeAgentConfig("c")
+ };
+
+ var graph = BuildEnabled(flagValue, configs);
+
+ var visited = new List();
+ graph.Traverse((node, ctx) =>
+ {
+ visited.Add(node.Key);
+ return null;
+ });
+
+ // Each node visited exactly once despite cycle
+ Assert.Equal(3, visited.Count);
+ Assert.Contains("a", visited);
+ Assert.Contains("b", visited);
+ Assert.Contains("c", visited);
+ }
+
+ [Fact]
+ public void GetNodeReturnsNullForUnknownKey()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+ Assert.Null(graph.GetNode("nonexistent"));
+ }
+
+ [Fact]
+ public void GetChildNodesReturnsEmptyForUnknownNode()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+ Assert.Empty(graph.GetChildNodes("nonexistent"));
+ }
+
+ [Fact]
+ public void CollectAllKeysIncludesRootEdgeSourcesAndTargets()
+ {
+ var flagValue = new AgentGraphFlagValue
+ {
+ Root = "a",
+ Edges = new Dictionary>
+ {
+ ["a"] = new[] { new GraphEdge("b", null), new GraphEdge("c", null) }
+ }
+ };
+
+ var keys = AgentGraphDefinition.CollectAllKeys(flagValue);
+ Assert.Contains("a", keys);
+ Assert.Contains("b", keys);
+ Assert.Contains("c", keys);
+ Assert.Equal(3, keys.Count);
+ }
+
+ [Fact]
+ public void BuildNodesSkipsKeysMissingFromConfigs()
+ {
+ var flagValue = new AgentGraphFlagValue
+ {
+ Root = "a",
+ Edges = new Dictionary>
+ {
+ ["a"] = new[] { new GraphEdge("b", null) }
+ }
+ };
+ // Only provide config for "a", not "b"
+ var configs = new Dictionary
+ {
+ ["a"] = MakeAgentConfig("a")
+ };
+
+ var nodes = AgentGraphDefinition.BuildNodes(flagValue, configs);
+ Assert.Single(nodes);
+ Assert.True(nodes.ContainsKey("a"));
+ Assert.False(nodes.ContainsKey("b"));
+ }
+
+ [Fact]
+ public void TraversePassesContextBetweenNodes()
+ {
+ var graph = BuildEnabled(ThreeNodeFlagValue(), ThreeNodeConfigs());
+
+ var ctx = new Dictionary();
+ graph.Traverse((node, context) =>
+ {
+ context[$"{node.Key}-visited"] = true;
+ return node.Key.ToUpper();
+ }, ctx);
+
+ Assert.Equal("AGENT-A", ctx["agent-a"]);
+ Assert.Equal("AGENT-B", ctx["agent-b"]);
+ Assert.Equal("AGENT-C", ctx["agent-c"]);
+ }
+
+ [Fact]
+ public void IsTerminalTrueForNodeWithNoEdges()
+ {
+ var flagValue = ThreeNodeFlagValue();
+ var nodes = AgentGraphDefinition.BuildNodes(flagValue, ThreeNodeConfigs());
+
+ Assert.False(nodes["agent-a"].IsTerminal);
+ Assert.False(nodes["agent-b"].IsTerminal);
+ Assert.True(nodes["agent-c"].IsTerminal);
+ }
+
+ [Fact]
+ public void GraphEdgeHandoffDataPreserved()
+ {
+ var flagValue = new AgentGraphFlagValue
+ {
+ Root = "a",
+ Edges = new Dictionary>
+ {
+ ["a"] = new[]
+ {
+ new GraphEdge("b", new Dictionary { ["tool"] = LdValue.Of("search") })
+ }
+ }
+ };
+ var configs = new Dictionary
+ {
+ ["a"] = MakeAgentConfig("a"),
+ ["b"] = MakeAgentConfig("b")
+ };
+
+ var nodes = AgentGraphDefinition.BuildNodes(flagValue, configs);
+ var edge = nodes["a"].Edges[0];
+ Assert.Equal("b", edge.Key);
+ Assert.NotNull(edge.Handoff);
+ Assert.Equal("search", edge.Handoff["tool"].AsString);
+ }
+
+ [Fact]
+ public void GraphEdgeWithNoHandoffHasNullHandoff()
+ {
+ var flagValue = ThreeNodeFlagValue();
+ var nodes = AgentGraphDefinition.BuildNodes(flagValue, ThreeNodeConfigs());
+ Assert.Null(nodes["agent-a"].Edges[0].Handoff);
+ }
+}
diff --git a/pkgs/sdk/server-ai/test/AiGraphTrackerTest.cs b/pkgs/sdk/server-ai/test/AiGraphTrackerTest.cs
new file mode 100644
index 00000000..134838db
--- /dev/null
+++ b/pkgs/sdk/server-ai/test/AiGraphTrackerTest.cs
@@ -0,0 +1,487 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Text.Json;
+using LaunchDarkly.Sdk.Server.Ai.Graph;
+using LaunchDarkly.Sdk.Server.Ai.Interfaces;
+using LaunchDarkly.Sdk.Server.Ai.Tracking;
+using Moq;
+using Xunit;
+
+namespace LaunchDarkly.Sdk.Server.Ai;
+
+///
+/// Tests for AiGraphTracker (spec tests 14–25).
+///
+public class AiGraphTrackerTest
+{
+ private static Mock MockClient()
+ {
+ var mock = new Mock();
+ mock.Setup(c => c.GetLogger()).Returns(new Mock().Object);
+ return mock;
+ }
+
+ private static AiGraphTracker MakeTracker(ILaunchDarklyClient client, Context context,
+ string graphKey = "my-graph", string variationKey = "v1", int version = 2, string runId = null)
+ {
+ return new AiGraphTracker(client, graphKey, version, context, variationKey, runId);
+ }
+
+ // Test 14: TrackInvocationSuccess fires correct event with track data
+ [Fact]
+ public void TrackInvocationSuccessFiresCorrectEvent()
+ {
+ var mockClient = MockClient();
+ var context = Context.New("user");
+ var tracker = MakeTracker(mockClient.Object, context);
+
+ tracker.TrackInvocationSuccess();
+
+ mockClient.Verify(c => c.Track(
+ "$ld:ai:graph:invocation_success",
+ context,
+ It.Is(v =>
+ v.Get("graphKey").AsString == "my-graph" &&
+ v.Get("version").AsInt == 2 &&
+ v.Get("variationKey").AsString == "v1" &&
+ v.Get("runId").Type == LdValueType.String),
+ 1.0), Times.Once);
+ }
+
+ // Test 15: TrackInvocationFailure fires correct event
+ [Fact]
+ public void TrackInvocationFailureFiresCorrectEvent()
+ {
+ var mockClient = MockClient();
+ var context = Context.New("user");
+ var tracker = MakeTracker(mockClient.Object, context);
+
+ tracker.TrackInvocationFailure();
+
+ mockClient.Verify(c => c.Track(
+ "$ld:ai:graph:invocation_failure",
+ context,
+ It.Is(v => v.Get("graphKey").AsString == "my-graph"),
+ 1.0), Times.Once);
+ }
+
+ // Test 16: TrackDuration fires correct event with duration as metric value
+ [Fact]
+ public void TrackDurationFiresCorrectEvent()
+ {
+ var mockClient = MockClient();
+ var context = Context.New("user");
+ var tracker = MakeTracker(mockClient.Object, context);
+
+ tracker.TrackDuration(250.5);
+
+ mockClient.Verify(c => c.Track(
+ "$ld:ai:graph:duration:total",
+ context,
+ It.Is(v => v.Get("graphKey").AsString == "my-graph"),
+ 250.5), Times.Once);
+ }
+
+ // Test 17: TrackTotalTokens fires correct event
+ [Fact]
+ public void TrackTotalTokensFiresCorrectEvent()
+ {
+ var mockClient = MockClient();
+ var context = Context.New("user");
+ var tracker = MakeTracker(mockClient.Object, context);
+
+ tracker.TrackTotalTokens(new Usage(100, 60, 40));
+
+ mockClient.Verify(c => c.Track(
+ "$ld:ai:graph:total_tokens",
+ context,
+ It.Is(v => v.Get("graphKey").AsString == "my-graph"),
+ 100.0), Times.Once);
+ }
+
+ // Test 18: TrackPath fires correct event with path array in data
+ [Fact]
+ public void TrackPathFiresCorrectEventWithPathInData()
+ {
+ var mockClient = MockClient();
+ var context = Context.New("user");
+ var tracker = MakeTracker(mockClient.Object, context);
+
+ tracker.TrackPath(new[] { "agent-a", "agent-b", "agent-c" });
+
+ mockClient.Verify(c => c.Track(
+ "$ld:ai:graph:path",
+ context,
+ It.Is(v =>
+ v.Get("graphKey").AsString == "my-graph" &&
+ v.Get("path").Type == LdValueType.Array &&
+ v.Get("path").Count == 3 &&
+ v.Get("path").Get(0).AsString == "agent-a" &&
+ v.Get("path").Get(1).AsString == "agent-b" &&
+ v.Get("path").Get(2).AsString == "agent-c"),
+ 1.0), Times.Once);
+ }
+
+ // Test 19: At-most-once — second TrackDuration logs warning and drops
+ [Fact]
+ public void TrackDurationAtMostOnce()
+ {
+ var mockLogger = new Mock();
+ var mockClient = new Mock();
+ mockClient.Setup(c => c.GetLogger()).Returns(mockLogger.Object);
+ var context = Context.New("user");
+ var tracker = MakeTracker(mockClient.Object, context);
+
+ tracker.TrackDuration(100.0);
+ tracker.TrackDuration(200.0);
+
+ mockClient.Verify(c => c.Track(
+ "$ld:ai:graph:duration:total",
+ context,
+ It.IsAny(),
+ It.IsAny()), Times.Once);
+ mockLogger.Verify(l => l.Warn(It.IsAny(), It.IsAny