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()), Times.Once); + } + + // Test 20: Success/failure share slot: TrackInvocationSuccess then TrackInvocationFailure → second dropped + [Fact] + public void InvocationSuccessAndFailureShareSlot() + { + 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.TrackInvocationSuccess(); + tracker.TrackInvocationFailure(); + + mockClient.Verify(c => c.Track( + "$ld:ai:graph:invocation_success", + context, + It.IsAny(), + It.IsAny()), Times.Once); + mockClient.Verify(c => c.Track( + "$ld:ai:graph:invocation_failure", + context, + It.IsAny(), + It.IsAny()), Times.Never); + mockLogger.Verify(l => l.Warn(It.IsAny(), It.IsAny()), Times.Once); + } + + // Test 21: Edge methods (redirect, handoff success/failure) are multi-fire + [Fact] + public void EdgeMethodsAreMultiFire() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context); + + tracker.TrackRedirect("a", "b"); + tracker.TrackRedirect("a", "c"); + tracker.TrackHandoffSuccess("a", "b"); + tracker.TrackHandoffSuccess("b", "c"); + tracker.TrackHandoffFailure("a", "x"); + tracker.TrackHandoffFailure("b", "y"); + + mockClient.Verify(c => c.Track( + "$ld:ai:graph:redirect", context, It.IsAny(), It.IsAny()), Times.Exactly(2)); + mockClient.Verify(c => c.Track( + "$ld:ai:graph:handoff_success", context, It.IsAny(), It.IsAny()), Times.Exactly(2)); + mockClient.Verify(c => c.Track( + "$ld:ai:graph:handoff_failure", context, It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + // Test 22: Summary reflects tracked values incrementally + [Fact] + public void SummaryReflectsTrackedValues() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context); + + Assert.Null(tracker.Summary.Success); + Assert.Null(tracker.Summary.DurationMs); + + tracker.TrackInvocationSuccess(); + Assert.Equal(true, tracker.Summary.Success); + + tracker.TrackDuration(100.0); + Assert.Equal(100.0, tracker.Summary.DurationMs); + + tracker.TrackTotalTokens(new Usage(50, 30, 20)); + Assert.Equal(50, tracker.Summary.Tokens?.Total); + + tracker.TrackPath(new[] { "a", "b" }); + Assert.Equal(new[] { "a", "b" }, tracker.Summary.Path); + } + + // Test 23: GetTrackData returns correct RunId, GraphKey, VariationKey, Version + [Fact] + public void GetTrackDataReturnsCorrectFields() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var runId = Guid.NewGuid().ToString(); + var tracker = new AiGraphTracker(mockClient.Object, "graph-key", 3, context, "vkey", runId); + + var td = tracker.GetTrackData(); + Assert.Equal(runId, td.RunId); + Assert.Equal("graph-key", td.GraphKey); + Assert.Equal("vkey", td.VariationKey); + Assert.Equal(3, td.Version); + } + + // Test 23b: VariationKey is null in GetTrackData when not provided + [Fact] + public void GetTrackDataVariationKeyNullWhenAbsent() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = new AiGraphTracker(mockClient.Object, "graph-key", 1, context); + + var td = tracker.GetTrackData(); + Assert.Null(td.VariationKey); + } + + // Test 24: ResumptionToken round-trips correctly + [Fact] + public void ResumptionTokenRoundTrips() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var runId = Guid.NewGuid().ToString(); + var tracker = new AiGraphTracker(mockClient.Object, "graph-key", 5, context, "var-key", runId); + + var token = tracker.ResumptionToken; + Assert.NotEmpty(token); + + var base64 = token.Replace('-', '+').Replace('_', '/'); + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + var doc = JsonDocument.Parse(json); + Assert.Equal(runId, doc.RootElement.GetProperty("runId").GetString()); + Assert.Equal("graph-key", doc.RootElement.GetProperty("graphKey").GetString()); + Assert.Equal("var-key", doc.RootElement.GetProperty("variationKey").GetString()); + Assert.Equal(5, doc.RootElement.GetProperty("version").GetInt32()); + } + + // Test 24b: ResumptionToken omits variationKey when absent + [Fact] + public void ResumptionTokenOmitsVariationKeyWhenAbsent() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = new AiGraphTracker(mockClient.Object, "graph-key", 1, context); + + var token = tracker.ResumptionToken; + var base64 = token.Replace('-', '+').Replace('_', '/'); + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + var doc = JsonDocument.Parse(json); + Assert.False(doc.RootElement.TryGetProperty("variationKey", out _)); + } + + // Test 25: FromResumptionToken reconstructs tracker with same runId + [Fact] + public void FromResumptionTokenReconstructsTracker() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var runId = Guid.NewGuid().ToString(); + var original = new AiGraphTracker(mockClient.Object, "graph-key", 2, context, "vkey", runId); + + var token = original.ResumptionToken; + var reconstructed = AiGraphTracker.FromResumptionToken(token, mockClient.Object, context); + + var td = reconstructed.GetTrackData(); + Assert.Equal(runId, td.RunId); + Assert.Equal("graph-key", td.GraphKey); + Assert.Equal("vkey", td.VariationKey); + Assert.Equal(2, td.Version); + } + + [Fact] + public void FromResumptionTokenThrowsOnNullToken() + { + var mockClient = MockClient(); + Assert.Throws(() => + AiGraphTracker.FromResumptionToken(null, mockClient.Object, Context.New("u"))); + } + + [Fact] + public void FromResumptionTokenThrowsOnMalformedToken() + { + var mockClient = MockClient(); + Assert.Throws(() => + AiGraphTracker.FromResumptionToken("not-valid-base64!!!", mockClient.Object, Context.New("u"))); + } + + [Fact] + public void EdgeMethodsIncludeSourceAndTargetKeys() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context); + + tracker.TrackRedirect("src-node", "redir-node"); + mockClient.Verify(c => c.Track( + "$ld:ai:graph:redirect", + context, + It.Is(v => + v.Get("sourceKey").AsString == "src-node" && + v.Get("redirectedTarget").AsString == "redir-node"), + 1.0), Times.Once); + + tracker.TrackHandoffSuccess("src-node", "tgt-node"); + mockClient.Verify(c => c.Track( + "$ld:ai:graph:handoff_success", + context, + It.Is(v => + v.Get("sourceKey").AsString == "src-node" && + v.Get("targetKey").AsString == "tgt-node"), + 1.0), Times.Once); + + tracker.TrackHandoffFailure("src-node", "bad-node"); + mockClient.Verify(c => c.Track( + "$ld:ai:graph:handoff_failure", + context, + It.Is(v => + v.Get("sourceKey").AsString == "src-node" && + v.Get("targetKey").AsString == "bad-node"), + 1.0), Times.Once); + } + + [Fact] + public void RunIdIsAutoGeneratedWhenNotProvided() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = new AiGraphTracker(mockClient.Object, "graph-key", 1, context); + + var td = tracker.GetTrackData(); + Assert.NotEmpty(td.RunId); + // Should be a valid GUID format + Assert.True(Guid.TryParse(td.RunId, out _)); + } + + // At-most-once — second TrackPath logs warning and drops + [Fact] + public void TrackPathAtMostOnce() + { + 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.TrackPath(new[] { "a", "b" }); + tracker.TrackPath(new[] { "a", "b" }); + + mockClient.Verify(c => c.Track( + "$ld:ai:graph:path", + context, + It.IsAny(), + It.IsAny()), Times.Once); + mockLogger.Verify(l => l.Warn(It.IsAny(), It.IsAny()), Times.Once); + } + + // At-most-once — second TrackTotalTokens logs warning and drops + [Fact] + public void TrackTotalTokensAtMostOnce() + { + 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.TrackTotalTokens(new Usage(100, 60, 40)); + tracker.TrackTotalTokens(new Usage(100, 60, 40)); + + mockClient.Verify(c => c.Track( + "$ld:ai:graph:total_tokens", + context, + It.IsAny(), + It.IsAny()), Times.Once); + mockLogger.Verify(l => l.Warn(It.IsAny(), It.IsAny()), Times.Once); + } + + // Empty Usage does not consume the TrackTotalTokens slot + [Fact] + public void TrackTotalTokensEmptyUsageDoesNotConsumeSlot() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context); + + tracker.TrackTotalTokens(new Usage(0, 0, 0)); + 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); + } + + // TrackTotalTokens derives total from Input+Output when Total is null + [Fact] + public void TrackTotalTokensDerivesSumWhenTotalIsNull() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context); + + tracker.TrackTotalTokens(new Usage(null, 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); + } + + // TrackTotalTokens does not fire when Total is null and Input/Output are both absent + [Fact] + public void TrackTotalTokensDropsWhenAllFieldsEmpty() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context); + + tracker.TrackTotalTokens(new Usage(null, null, null)); + + mockClient.Verify(c => c.Track( + "$ld:ai:graph:total_tokens", + It.IsAny(), + It.IsAny(), + It.IsAny()), Times.Never); + } + + // TrackTotalTokens fires when only Output is provided (Total and Input null) + [Fact] + public void TrackTotalTokensUsesOutputAloneWhenInputIsNull() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context); + + tracker.TrackTotalTokens(new Usage(null, null, 75)); + + mockClient.Verify(c => c.Track( + "$ld:ai:graph:total_tokens", + context, + It.Is(v => v.Get("graphKey").AsString == "my-graph"), + 75.0), Times.Once); + } +} diff --git a/pkgs/sdk/server-ai/test/LdAiAgentGraphConfigTest.cs b/pkgs/sdk/server-ai/test/LdAiAgentGraphConfigTest.cs new file mode 100644 index 00000000..7e47c846 --- /dev/null +++ b/pkgs/sdk/server-ai/test/LdAiAgentGraphConfigTest.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; +using LaunchDarkly.Sdk.Server.Ai.Interfaces; +using Moq; +using Xunit; + +namespace LaunchDarkly.Sdk.Server.Ai; + +/// +/// Tests graphKey threading through LdAiConfigTracker (spec tests 1–3). +/// +public class LdAiAgentGraphConfigTest +{ + private static Mock MockClient() + { + var mock = new Mock(); + mock.Setup(c => c.GetLogger()).Returns(new Mock().Object); + return mock; + } + + private static LdAiConfigTracker MakeTracker(ILaunchDarklyClient client, Context context, + string graphKey = null) + { + return new LdAiConfigTracker(client, Guid.NewGuid().ToString(), "config-key", + "v1", 1, context, "model", "provider", graphKey); + } + + // Test 1: Tracker created with graphKey → events include graphKey in data + [Fact] + public void TrackDataIncludesGraphKeyWhenSet() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context, "my-graph"); + + tracker.TrackSuccess(); + + mockClient.Verify(c => c.Track( + It.IsAny(), + context, + It.Is(v => v.Get("graphKey").AsString == "my-graph"), + It.IsAny()), Times.Once); + } + + // Test 2: Tracker created without graphKey → events omit graphKey + [Fact] + public void TrackDataOmitsGraphKeyWhenNotSet() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context, graphKey: null); + + tracker.TrackSuccess(); + + mockClient.Verify(c => c.Track( + It.IsAny(), + context, + It.Is(v => v.Get("graphKey").IsNull), + It.IsAny()), Times.Once); + } + + // Test 3a: ResumptionToken includes graphKey when set; round-trips via FromResumptionToken + [Fact] + public void ResumptionTokenIncludesGraphKeyWhenSet() + { + var mockClient = MockClient(); + var context = Context.New("user"); + const string graphKey = "my-graph"; + var tracker = MakeTracker(mockClient.Object, context, graphKey); + + var token = tracker.ResumptionToken; + Assert.NotEmpty(token); + + // Decode and verify graphKey is present + var base64 = token.Replace('-', '+').Replace('_', '/'); + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + var doc = JsonDocument.Parse(json); + Assert.Equal(graphKey, doc.RootElement.GetProperty("graphKey").GetString()); + } + + // Test 3b: ResumptionToken omits graphKey when not set + [Fact] + public void ResumptionTokenOmitsGraphKeyWhenNotSet() + { + var mockClient = MockClient(); + var context = Context.New("user"); + var tracker = MakeTracker(mockClient.Object, context, graphKey: null); + + var token = tracker.ResumptionToken; + + var base64 = token.Replace('-', '+').Replace('_', '/'); + switch (base64.Length % 4) + { + case 2: base64 += "=="; break; + case 3: base64 += "="; break; + } + var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); + var doc = JsonDocument.Parse(json); + Assert.False(doc.RootElement.TryGetProperty("graphKey", out _)); + } + + // Test 3c: FromResumptionToken reconstructs tracker with same graphKey + [Fact] + public void FromResumptionTokenRoundTripsGraphKey() + { + var mockClient = MockClient(); + var context = Context.New("user"); + const string graphKey = "round-trip-graph"; + var original = MakeTracker(mockClient.Object, context, graphKey); + + var token = original.ResumptionToken; + var reconstructed = LdAiConfigTracker.FromResumptionToken(token, mockClient.Object, context); + + // The reconstructed tracker should include graphKey in track data + reconstructed.TrackSuccess(); + mockClient.Verify(c => c.Track( + It.IsAny(), + context, + It.Is(v => v.Get("graphKey").AsString == graphKey), + It.IsAny()), Times.Once); + } +} diff --git a/pkgs/sdk/server-ai/test/LdAiClientAgentGraphTest.cs b/pkgs/sdk/server-ai/test/LdAiClientAgentGraphTest.cs new file mode 100644 index 00000000..4c815b9d --- /dev/null +++ b/pkgs/sdk/server-ai/test/LdAiClientAgentGraphTest.cs @@ -0,0 +1,360 @@ +using System; +using System.Collections.Generic; +using LaunchDarkly.Sdk.Server.Ai.Graph; +using LaunchDarkly.Sdk.Server.Ai.Interfaces; +using Moq; +using Xunit; + +namespace LaunchDarkly.Sdk.Server.Ai; + +/// +/// Tests for LdAiClient.AgentGraph() and CreateGraphTracker() (spec tests 36–44). +/// +public class LdAiClientAgentGraphTest +{ + private static (LdAiClient client, Mock mockClient) MakeClient() + { + var mockClient = new Mock(); + mockClient.Setup(c => c.GetLogger()).Returns(new Mock().Object); + return (new LdAiClient(mockClient.Object), mockClient); + } + + /// + /// Returns an LdValue representing a valid agent config flag variation for the given key. + /// Mode must be "agent" to pass ConfigFactory's mode check. + /// + private static LdValue AgentConfigValue(string variationKey = "v1", bool enabled = true) + { + return LdValue.ObjectFrom(new Dictionary + { + ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary + { + ["enabled"] = LdValue.Of(enabled), + ["variationKey"] = LdValue.Of(variationKey), + ["version"] = LdValue.Of(1), + ["mode"] = LdValue.Of("agent") + }), + ["model"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("gpt-4") + }), + ["provider"] = LdValue.ObjectFrom(new Dictionary + { + ["name"] = LdValue.Of("openai") + }), + ["instructions"] = LdValue.Of("You are helpful.") + }); + } + + /// + /// Returns an LdValue for a two-node graph flag: root=agent-a, edge a→b. + /// + private static LdValue TwoNodeGraphValue(string variationKey = "gv1", bool metaEnabled = true) + { + return LdValue.ObjectFrom(new Dictionary + { + ["root"] = LdValue.Of("agent-a"), + ["edges"] = LdValue.ObjectFrom(new Dictionary + { + ["agent-a"] = LdValue.ArrayOf( + LdValue.ObjectFrom(new Dictionary + { + ["key"] = LdValue.Of("agent-b") + })) + }), + ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary + { + ["enabled"] = LdValue.Of(metaEnabled), + ["variationKey"] = LdValue.Of(variationKey), + ["version"] = LdValue.Of(2) + }) + }); + } + + private static void SetupAgentConfigReturns(Mock mockClient, + params string[] nodeKeys) + { + foreach (var key in nodeKeys) + { + var capturedKey = key; + mockClient.Setup(c => c.JsonVariation(capturedKey, It.IsAny(), It.IsAny())) + .Returns(AgentConfigValue()); + } + } + + // Test 36: Valid graph → returns enabled definition with structured nodes/edges + [Fact] + public void ValidGraphReturnsEnabledDefinition() + { + var (client, mockClient) = MakeClient(); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(TwoNodeGraphValue()); + SetupAgentConfigReturns(mockClient, "agent-a", "agent-b"); + + var result = client.AgentGraph("my-graph", context); + + Assert.True(result.Enabled); + Assert.NotNull(result.RootNode()); + Assert.Equal("agent-a", result.RootNode().Key); + Assert.NotNull(result.GetNode("agent-b")); + } + + // Test 37: _ldMeta.enabled === false → AgentGraphDefinition.Enabled = false + debug log + [Fact] + public void MetaDisabledReturnsFalseEnabledWithDebugLog() + { + var mockLogger = new Mock(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetLogger()).Returns(mockLogger.Object); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(TwoNodeGraphValue(metaEnabled: false)); + + var client = new LdAiClient(mockClient.Object); + var result = client.AgentGraph("my-graph", context); + + Assert.False(result.Enabled); + mockLogger.Verify(l => l.Debug(It.IsAny(), It.IsAny()), Times.AtLeastOnce); + } + + // Test 38: _ldMeta absent → defaults to enabled (no false check fires) + [Fact] + public void MissingMetaDefaultsToEnabled() + { + var (client, mockClient) = MakeClient(); + var context = Context.New("user"); + + // Graph flag with no _ldMeta + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(LdValue.ObjectFrom(new Dictionary + { + ["root"] = LdValue.Of("agent-a"), + ["edges"] = LdValue.ObjectFrom(new Dictionary + { + ["agent-a"] = LdValue.ArrayOf( + LdValue.ObjectFrom(new Dictionary + { + ["key"] = LdValue.Of("agent-b") + })) + }) + })); + SetupAgentConfigReturns(mockClient, "agent-a", "agent-b"); + + var result = client.AgentGraph("my-graph", context); + + Assert.True(result.Enabled); + } + + // Test 39: Missing root → disabled + debug log + [Fact] + public void MissingRootReturnsDisabledWithDebugLog() + { + var mockLogger = new Mock(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetLogger()).Returns(mockLogger.Object); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns((string _, Context _, LdValue dv) => dv); // returns default {"root": ""} + + var client = new LdAiClient(mockClient.Object); + var result = client.AgentGraph("my-graph", context); + + Assert.False(result.Enabled); + mockLogger.Verify(l => l.Debug(It.IsAny(), It.IsAny()), Times.AtLeastOnce); + } + + // Test 40: Unconnected node → disabled + debug log + [Fact] + public void UnconnectedNodeReturnsDisabledWithDebugLog() + { + var mockLogger = new Mock(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetLogger()).Returns(mockLogger.Object); + var context = Context.New("user"); + + // a → b, but c is listed as edge source from nowhere (disconnected island) + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(LdValue.ObjectFrom(new Dictionary + { + ["root"] = LdValue.Of("agent-a"), + ["edges"] = LdValue.ObjectFrom(new Dictionary + { + ["agent-a"] = LdValue.ArrayOf( + LdValue.ObjectFrom(new Dictionary + { + ["key"] = LdValue.Of("agent-b") + })), + // agent-c is an edge source not reachable from root + ["agent-c"] = LdValue.ArrayOf( + LdValue.ObjectFrom(new Dictionary + { + ["key"] = LdValue.Of("agent-d") + })) + }), + ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary + { + ["enabled"] = LdValue.Of(true), + ["version"] = LdValue.Of(1) + }) + })); + + var client = new LdAiClient(mockClient.Object); + var result = client.AgentGraph("my-graph", context); + + Assert.False(result.Enabled); + mockLogger.Verify(l => l.Debug(It.IsAny(), It.IsAny()), Times.AtLeastOnce); + } + + // Test 41: Child config disabled → disabled + debug log + [Fact] + public void DisabledChildConfigReturnsDisabledWithDebugLog() + { + var mockLogger = new Mock(); + var mockClient = new Mock(); + mockClient.Setup(c => c.GetLogger()).Returns(mockLogger.Object); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(TwoNodeGraphValue()); + mockClient.Setup(c => c.JsonVariation("agent-a", It.IsAny(), It.IsAny())) + .Returns(AgentConfigValue(enabled: true)); + mockClient.Setup(c => c.JsonVariation("agent-b", It.IsAny(), It.IsAny())) + .Returns(AgentConfigValue(enabled: false)); + + var client = new LdAiClient(mockClient.Object); + var result = client.AgentGraph("my-graph", context); + + Assert.False(result.Enabled); + mockLogger.Verify(l => l.Debug(It.IsAny(), It.IsAny()), Times.AtLeastOnce); + } + + // Test 42: Per-node trackers include graphKey (via BuildAgentConfig threading) + [Fact] + public void PerNodeTrackersIncludeGraphKey() + { + var (client, mockClient) = MakeClient(); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(TwoNodeGraphValue()); + SetupAgentConfigReturns(mockClient, "agent-a", "agent-b"); + + var result = client.AgentGraph("my-graph", context); + + Assert.True(result.Enabled); + + // Verify that per-node tracker emits graphKey in track data + var nodeTracker = result.GetNode("agent-a").Config.CreateTracker(); + nodeTracker.TrackSuccess(); + + mockClient.Verify(c => c.Track( + "$ld:ai:generation:success", + context, + It.Is(v => v.Get("graphKey").AsString == "my-graph"), + It.IsAny()), Times.Once); + } + + // Test 43: Disabled definition still has GetConfig() returning the raw flag value + [Fact] + public void DisabledGraphDefinitionStillExposesGetConfig() + { + var (client, mockClient) = MakeClient(); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(TwoNodeGraphValue(variationKey: "var-123", metaEnabled: false)); + + var result = client.AgentGraph("my-graph", context); + + Assert.False(result.Enabled); + var config = result.GetConfig(); + Assert.NotNull(config); + Assert.Equal("agent-a", config.Root); + Assert.NotNull(config.Meta); + Assert.Equal("var-123", config.Meta.VariationKey); + } + + // Test 44: CreateGraphTracker delegates to AiGraphTracker.FromResumptionToken; returned tracker has correct graphKey/runId + [Fact] + public void CreateGraphTrackerDelegatesToFromResumptionToken() + { + var (client, mockClient) = MakeClient(); + var context = Context.New("user"); + + var runId = Guid.NewGuid().ToString(); + var original = new AiGraphTracker(mockClient.Object, "test-graph", 3, context, "vkey", runId); + var token = original.ResumptionToken; + + var reconstructed = client.CreateGraphTracker(token, context); + + Assert.Equal(runId, reconstructed.GetTrackData().RunId); + Assert.Equal("test-graph", reconstructed.GetTrackData().GraphKey); + Assert.Equal("vkey", reconstructed.GetTrackData().VariationKey); + Assert.Equal(3, reconstructed.GetTrackData().Version); + } + + // Test: AgentGraph fires $ld:ai:usage:agent-graph tracking event + [Fact] + public void AgentGraphFiresUsageTrackingEvent() + { + var (client, mockClient) = MakeClient(); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(TwoNodeGraphValue()); + SetupAgentConfigReturns(mockClient, "agent-a", "agent-b"); + + client.AgentGraph("my-graph", context); + + mockClient.Verify(c => c.Track( + "$ld:ai:usage:agent-graph", + context, + LdValue.Of("my-graph"), + 1), Times.Once); + } + + // Test: Edges with handoff data are parsed correctly + [Fact] + public void EdgeHandoffDataParsedCorrectly() + { + var (client, mockClient) = MakeClient(); + var context = Context.New("user"); + + mockClient.Setup(c => c.JsonVariation("my-graph", It.IsAny(), It.IsAny())) + .Returns(LdValue.ObjectFrom(new Dictionary + { + ["root"] = LdValue.Of("agent-a"), + ["edges"] = LdValue.ObjectFrom(new Dictionary + { + ["agent-a"] = LdValue.ArrayOf( + LdValue.ObjectFrom(new Dictionary + { + ["key"] = LdValue.Of("agent-b"), + ["handoff"] = LdValue.ObjectFrom(new Dictionary + { + ["tool"] = LdValue.Of("search") + }) + })) + }), + ["_ldMeta"] = LdValue.ObjectFrom(new Dictionary + { + ["enabled"] = LdValue.Of(true), + ["version"] = LdValue.Of(1) + }) + })); + SetupAgentConfigReturns(mockClient, "agent-a", "agent-b"); + + var result = client.AgentGraph("my-graph", context); + + Assert.True(result.Enabled); + var edgesFromA = result.GetNode("agent-a").Edges; + Assert.Single(edgesFromA); + Assert.Equal("agent-b", edgesFromA[0].Key); + Assert.NotNull(edgesFromA[0].Handoff); + Assert.Equal("search", edgesFromA[0].Handoff["tool"].AsString); + } +}