Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
38d4024
[AIC-2723] Agent graph infra (first pass)
mattrmc1 Jun 16, 2026
e28926e
[AIC-2723] don't burn TrackTotalTokens slot on empty usage
mattrmc1 Jun 16, 2026
0b1f12a
fix: ReverseTraverse visits all nodes in pure-cycle graphs
mattrmc1 Jun 16, 2026
989f71a
fix: trackpath null guard
mattrmc1 Jun 16, 2026
cd02c19
fix: wrap GraphEdge handoff in ReadOnlyDictionary to enforce immutabi…
mattrmc1 Jun 16, 2026
9c4eed8
fix: freeze edge collections to prevent mutable aliasing through GetC…
mattrmc1 Jun 16, 2026
a52d690
fix: TrackTotalTokens skips empty check before claiming at-most-once …
mattrmc1 Jun 16, 2026
dcd2455
[AIC-2723] Refactor to make changes non-blocking
mattrmc1 Jun 17, 2026
0842ab6
fix: clamp resumption token version to minimum 1
mattrmc1 Jun 17, 2026
270020c
fix: normalize non-positive _ldMeta.version to 1 at parse time
mattrmc1 Jun 17, 2026
c742ece
formatting
mattrmc1 Jun 17, 2026
bc3198d
fix: revert version coercion in ParseMeta to match spec
mattrmc1 Jun 18, 2026
797186a
fix: use debug log level for graph validation failures per spec
mattrmc1 Jun 18, 2026
1c983c4
fix: make ReverseTraverse no-op for graphs with no terminal nodes per…
mattrmc1 Jun 18, 2026
3607a48
fix: snapshot path list in TrackPath to prevent mutation
mattrmc1 Jun 18, 2026
4db18f1
more tests
mattrmc1 Jun 18, 2026
4e85ca4
test: warn -> debug
mattrmc1 Jun 18, 2026
b060deb
fix import
mattrmc1 Jun 18, 2026
ccc98b4
fix test description
mattrmc1 Jun 22, 2026
08075f1
cleanup
mattrmc1 Jun 22, 2026
b43c0e8
continue on missing config
mattrmc1 Jun 22, 2026
d64294c
only coerce nulls
mattrmc1 Jun 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions pkgs/sdk/server-ai/src/Adapters/LoggerAdapter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,7 @@ public LoggerAdapter(Logger logger)

/// <inheritdoc/>
public void Warn(string format, params object[] allParams) => _logger.Warn(format, allParams);

/// <inheritdoc/>
public void Debug(string format, params object[] allParams) => _logger.Debug(format, allParams);
}
12 changes: 7 additions & 5 deletions pkgs/sdk/server-ai/src/Config/ConfigFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,11 @@ public LdAiAgentConfig BuildAgentConfig(
LdValue ldValue,
Context context,
LdAiAgentConfigDefault defaultValue,
IReadOnlyDictionary<string, object> variables)
IReadOnlyDictionary<string, object> variables,
string graphKey = null)
{
var mergedVars = MergeVariables(variables, context);
var trackerFactory = TrackerFactoryFor(context);
var trackerFactory = TrackerFactoryFor(context, graphKey);

if (ldValue.Type != LdValueType.Object)
{
Expand Down Expand Up @@ -144,7 +145,7 @@ public LdAiAgentConfig BuildAgentConfig(
trackerFactory);
}

private LdAiAgentConfig BuildAgentFromDefault(
internal LdAiAgentConfig BuildAgentFromDefault(
string key,
LdAiAgentConfigDefault defaultValue,
IReadOnlyDictionary<string, object> mergedVars,
Expand Down Expand Up @@ -275,7 +276,7 @@ private string InterpolateInstructions(
return result;
}

private Func<LdAiConfig, ILdAiConfigTracker> TrackerFactoryFor(Context context)
private Func<LdAiConfig, ILdAiConfigTracker> TrackerFactoryFor(Context context, string graphKey = null)
{
return cfg => new LdAiConfigTracker(
_client,
Expand All @@ -285,7 +286,8 @@ private Func<LdAiConfig, ILdAiConfigTracker> 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)
Expand Down
251 changes: 251 additions & 0 deletions pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LaunchDarkly.Sdk.Server.Ai.Config;

namespace LaunchDarkly.Sdk.Server.Ai.Graph;

/// <summary>
/// Represents a fully-resolved agent graph returned by
/// <see cref="LdAiClient.AgentGraph"/>. When <see cref="Enabled"/> is false, all
/// node collections are empty and traversal is a no-op; only <see cref="GetConfig"/>
/// and <see cref="CreateTracker"/> remain meaningful.
/// </summary>
public sealed class AgentGraphDefinition
{
private readonly AgentGraphFlagValue _flagValue;
private readonly IReadOnlyDictionary<string, AgentGraphNode> _nodes;
private readonly Func<AiGraphTracker> _createTracker;

/// <summary>
/// Whether the graph passed all validation checks. False if the flag's
/// <c>_ldMeta.enabled</c> is false, the root is missing, any node is
/// unreachable from the root, or any child agent config could not be fetched.
/// </summary>
public bool Enabled { get; }

internal AgentGraphDefinition(
AgentGraphFlagValue flagValue,
IReadOnlyDictionary<string, AgentGraphNode> nodes,
bool enabled,
Func<AiGraphTracker> createTracker)
{
_flagValue = flagValue;
_nodes = nodes;
Enabled = enabled;
_createTracker = createTracker;
}

/// <summary>
/// Returns the root node of the graph, or null if the graph is disabled or has no root.
/// </summary>
public AgentGraphNode RootNode() =>
string.IsNullOrEmpty(_flagValue?.Root) ? null : GetNode(_flagValue.Root);

/// <summary>
/// Returns the node with the given key, or null if not found.
/// </summary>
public AgentGraphNode GetNode(string nodeKey)
{
if (nodeKey == null) return null;
return _nodes.TryGetValue(nodeKey, out var node) ? node : null;
}

/// <summary>
/// Returns the direct children of the given node by following its outgoing edges.
/// Returns an empty list if the node is not found.
/// </summary>
public IReadOnlyList<AgentGraphNode> GetChildNodes(string nodeKey)
{
var node = GetNode(nodeKey);
if (node == null) return Array.Empty<AgentGraphNode>();

return node.Edges
.Select(edge => GetNode(edge.Key))
.Where(n => n != null)
.ToList();
}

/// <summary>
/// Returns all nodes that have an outgoing edge pointing to the given node key.
/// </summary>
public IReadOnlyList<AgentGraphNode> GetParentNodes(string nodeKey)
{
return _nodes.Values
.Where(node => node.Edges.Any(edge => edge.Key == nodeKey))
.ToList();
}

/// <summary>
/// Returns all nodes with no outgoing edges (leaf nodes).
/// </summary>
public IReadOnlyList<AgentGraphNode> TerminalNodes() =>
_nodes.Values.Where(n => n.IsTerminal).ToList();

/// <summary>
/// Returns the raw flag value including LaunchDarkly metadata. Always non-null,
/// even when <see cref="Enabled"/> is false.
/// </summary>
public AgentGraphFlagValue GetConfig() => _flagValue;

/// <summary>
/// Creates a new graph-level tracker for this invocation.
/// </summary>
public AiGraphTracker CreateTracker() => _createTracker();

/// <summary>
/// Performs a breadth-first traversal of the graph starting from the root node.
/// For each visited node, <paramref name="fn"/> is called with the node and the
/// accumulated context dictionary. The return value of <paramref name="fn"/> is
/// stored in the context under the node's key and passed to subsequent calls.
/// Cycle-safe: each node is visited at most once.
/// </summary>
public void Traverse(
Func<AgentGraphNode, Dictionary<string, object>, object> fn,
Dictionary<string, object> initialContext = null)
{
var root = RootNode();
if (root == null) return;

var context = initialContext ?? new Dictionary<string, object>();

var visited = new HashSet<string>();
var queue = new Queue<AgentGraphNode>();
queue.Enqueue(root);
visited.Add(root.Key);

while (queue.Count > 0)
{
var node = queue.Dequeue();
var result = fn(node, context);
context[node.Key] = result;

foreach (var child in GetChildNodes(node.Key))
{
if (visited.Add(child.Key))
{
queue.Enqueue(child);
}
}
}
}

/// <summary>
/// Performs a reverse breadth-first traversal: starts from terminal nodes and
/// works upward toward the root. The root node is always processed last.
/// Cycle-safe: each node is visited at most once.
/// </summary>
public void ReverseTraverse(
Func<AgentGraphNode, Dictionary<string, object>, object> fn,
Dictionary<string, object> initialContext = null)
{
if (_nodes.Count == 0) return;

var context = initialContext ?? new Dictionary<string, object>();

var root = RootNode();
var visited = new HashSet<string>();
var queue = new Queue<AgentGraphNode>();

// Seed with terminal nodes (excluding root if it happens to be terminal and there are others)
foreach (var terminal in TerminalNodes())
{
if (root != null && terminal.Key == root.Key && _nodes.Count > 1)
{
continue;
}
if (visited.Add(terminal.Key))
{
queue.Enqueue(terminal);
}
}

while (queue.Count > 0)
{
var node = queue.Dequeue();

// Defer root until the very end (unless it's the only node)
if (root != null && node.Key == root.Key && _nodes.Count > 1)
{
continue;
}

var result = fn(node, context);
context[node.Key] = result;

foreach (var parent in GetParentNodes(node.Key))
{
if (root != null && parent.Key == root.Key)
{
// Don't enqueue root yet — process it last
continue;
}
if (visited.Add(parent.Key))
{
queue.Enqueue(parent);
}
}
}

// Process root last
if (root != null && _nodes.Count > 1 && visited.Count > 0)
{
var result = fn(root, context);
context[root.Key] = result;
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

/// <summary>
/// Builds the nodes dictionary from a parsed flag value and a map of pre-fetched
/// agent configs, associating each node with its outgoing edges from the flag value.
/// </summary>
internal static IReadOnlyDictionary<string, AgentGraphNode> BuildNodes(
AgentGraphFlagValue flagValue,
IReadOnlyDictionary<string, LdAiAgentConfig> agentConfigs)
{
var nodes = new Dictionary<string, AgentGraphNode>();
var allKeys = CollectAllKeys(flagValue);

foreach (var key in allKeys)
{
if (!agentConfigs.TryGetValue(key, out var config))
continue;

var outgoingEdges = flagValue.Edges != null && flagValue.Edges.TryGetValue(key, out var edges)
? edges
: (IReadOnlyList<GraphEdge>)Array.Empty<GraphEdge>();

nodes[key] = new AgentGraphNode(key, config, outgoingEdges);
}

return nodes;
}

/// <summary>
/// Collects all unique node keys referenced in the flag value: the root, all
/// edge source keys, and all edge target keys.
/// </summary>
internal static HashSet<string> CollectAllKeys(AgentGraphFlagValue flagValue)
{
var keys = new HashSet<string>();

if (!string.IsNullOrEmpty(flagValue?.Root))
{
keys.Add(flagValue.Root);
}

if (flagValue?.Edges != null)
{
foreach (var kv in flagValue.Edges)
{
keys.Add(kv.Key);
foreach (var edge in kv.Value)
{
keys.Add(edge.Key);
}
}
}

return keys;
}
}
52 changes: 52 additions & 0 deletions pkgs/sdk/server-ai/src/Graph/AgentGraphFlagValue.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Collections.Generic;

namespace LaunchDarkly.Sdk.Server.Ai.Graph;

/// <summary>
/// Raw flag value for an agent graph configuration as returned by LaunchDarkly.
/// Returned by <see cref="AgentGraphDefinition.GetConfig"/>.
/// </summary>
public sealed class AgentGraphFlagValue
{
/// <summary>
/// The key of the root AIAgentConfig in the graph.
/// </summary>
public string Root { get; init; }

/// <summary>
/// Object mapping source agent config keys to arrays of outgoing target edges.
/// Null when no edges are defined.
/// </summary>
public IReadOnlyDictionary<string, IReadOnlyList<GraphEdge>> Edges { get; init; }

/// <summary>
/// LaunchDarkly metadata from the <c>_ldMeta</c> field on the flag value.
/// Null when a default/fallback value was used (flag not evaluated).
/// </summary>
public LdMeta Meta { get; init; }
}

/// <summary>
/// LaunchDarkly metadata from the <c>_ldMeta</c> field on flag values.
/// </summary>
public sealed class LdMeta
{
/// <summary>
/// The variation key, if available. Null when a default config was used.
/// </summary>
public string VariationKey { get; init; }

/// <summary>
/// The version of the flag variation. Defaults to 1.
/// </summary>
public int Version { get; init; } = 1;

/// <summary>
/// Whether the configuration is enabled in the LaunchDarkly dashboard.
/// Defaults to true. Note: this is distinct from
/// <see cref="AgentGraphDefinition.Enabled"/>, which reflects the result of ALL
/// validation checks (metadata enabled + root present + all nodes reachable +
/// all child configs fetchable).
/// </summary>
public bool Enabled { get; init; } = true;
}
43 changes: 43 additions & 0 deletions pkgs/sdk/server-ai/src/Graph/AgentGraphNode.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
using System.Collections.Generic;
using LaunchDarkly.Sdk.Server.Ai.Config;

namespace LaunchDarkly.Sdk.Server.Ai.Graph;

/// <summary>
/// Represents a single node within an agent graph.
/// Each node wraps an <see cref="LdAiAgentConfig"/> and carries the outgoing edges to
/// its children. Use the config's tracker (via <c>Config.CreateTracker()</c>) to record
/// node-level metrics.
/// </summary>
public sealed class AgentGraphNode
{
/// <summary>
/// The agent config key for this node.
/// </summary>
public string Key { get; }

/// <summary>
/// The agent config for this node.
/// </summary>
public LdAiAgentConfig Config { get; }

/// <summary>
/// The outgoing edges from this node to its children.
/// </summary>
public IReadOnlyList<GraphEdge> Edges { get; }

/// <summary>
/// Whether this node has no outgoing edges (i.e., is a leaf node).
/// </summary>
public bool IsTerminal => Edges.Count == 0;

/// <summary>
/// Constructs an agent graph node.
/// </summary>
public AgentGraphNode(string key, LdAiAgentConfig config, IReadOnlyList<GraphEdge> edges)
{
Key = key;
Config = config;
Edges = edges;
}
}
Loading
Loading