Skip to content

Commit c81b28f

Browse files
feat: Add AgentGraph support to the AI SDK (#292)
## Summary Adds first-class support for **agent graphs** to `LaunchDarkly.ServerSdk.Ai`. An agent graph is a LaunchDarkly-managed directed graph whose nodes are existing `LdAiAgentConfig`s and whose edges describe handoffs between agents. The SDK fetches the graph flag, resolves every referenced agent config, validates connectivity, and hands the caller a typed object they can traverse and instrument — without exposing the underlying flag shape. ### `AgentGraph` and `CreateGraphTracker` on `ILdAiGraphClient` ```csharp // New interface — does not modify ILdAiClient public interface ILdAiGraphClient { AgentGraphDefinition AgentGraph( string graphKey, Context context, IReadOnlyDictionary<string, object> variables = null); AiGraphTracker CreateGraphTracker(string resumptionToken, Context context); } ``` `LdAiClient` now implements both `ILdAiClient` and the new `ILdAiGraphClient`. The existing `ILdAiClient` interface is **unchanged** — no members added, removed, or modified. Graph functionality lives entirely on the separate `ILdAiGraphClient` interface, making this a purely additive change. `AgentGraph` fires a `$ld:ai:usage:agent-graph` usage event, evaluates the graph flag, fetches every child `LdAiAgentConfig`, runs connectivity validation, and returns an `AgentGraphDefinition`. The returned definition's `Enabled` property reflects the result of **all** validation checks combined: `_ldMeta.enabled`, root present, every node reachable from the root, and every child agent config fetchable and enabled. When any check fails, the SDK logs a warning and returns a disabled definition whose traversal/inspection methods are safe no-ops; only `GetConfig()` (raw flag value + `_ldMeta`) and `CreateTracker()` remain meaningful. `CreateGraphTracker` reconstructs an `AiGraphTracker` from a base64url resumption token, enabling cross-process continuation (e.g. emitting graph-level events from a worker that didn't run the original evaluation). ### `AgentGraphDefinition` ```csharp public AgentGraphNode RootNode(); public AgentGraphNode GetNode(string nodeKey); public IReadOnlyList<AgentGraphNode> GetChildNodes(string nodeKey); public IReadOnlyList<AgentGraphNode> GetParentNodes(string nodeKey); public IReadOnlyList<AgentGraphNode> TerminalNodes(); public AgentGraphFlagValue GetConfig(); public AiGraphTracker CreateTracker(); public void Traverse( Func<AgentGraphNode, Dictionary<string, object>, object> fn, Dictionary<string, object> initialContext = null); public void ReverseTraverse( Func<AgentGraphNode, Dictionary<string, object>, object> fn, Dictionary<string, object> initialContext = null); ``` `Traverse` does a breadth-first walk from the root; `ReverseTraverse` walks from terminals back toward the root (the root is always processed last unless it is the only node). Both are cycle-safe — each node is visited at most once. The callback receives the current node and an accumulator dictionary; whatever the callback returns is stored in the accumulator under that node's key and is visible to subsequent visits. ### Node and edge types - **`AgentGraphNode`** — wraps an `LdAiAgentConfig` plus its outgoing `GraphEdge`s and exposes `IsTerminal` (true when there are no outgoing edges). Per-node metrics are recorded via the wrapped config's existing `CreateTracker()`. - **`GraphEdge`** — `record` carrying the target `Key` and an optional `Handoff` dictionary (`IReadOnlyDictionary<string, LdValue>`). Handoff maps are wrapped in `ReadOnlyDictionary` to enforce immutability; edge lists are frozen via `AsReadOnly()`. - **`AgentGraphFlagValue` / `LdMeta`** — the parsed raw flag value, including `_ldMeta`. Always non-null on the returned definition, even when the graph is disabled. ### `AiGraphTracker` ```csharp public void TrackInvocationSuccess(); // at-most-once (shares slot with TrackInvocationFailure) public void TrackInvocationFailure(); // at-most-once public void TrackDuration(double durationMs); // at-most-once public void TrackTotalTokens(Usage tokens); // at-most-once (skips slot claim when usage is empty) public void TrackPath(IReadOnlyList<string> path); // at-most-once public void TrackRedirect(string sourceKey, string redirectedTarget); // multi-fire public void TrackHandoffSuccess(string sourceKey, string targetKey); // multi-fire public void TrackHandoffFailure(string sourceKey, string targetKey); // multi-fire public string ResumptionToken { get; } public AiGraphMetricSummary Summary { get; } public AiGraphTrackData GetTrackData(); public static AiGraphTracker FromResumptionToken(string token, ILaunchDarklyClient client, Context context); ``` Graph-scoped tracker emitting `$ld:ai:graph:*` events. At-most-once methods use `Interlocked.CompareExchange` so the contract holds under racing callers — a second call logs a warning and is silently dropped. `TrackTotalTokens` short-circuits before claiming the slot when usage is empty, consistent with `LdAiConfigTracker.TrackTokens`. Multi-fire methods may be called once per edge traversal. Every event carries the standard `runId` + `graphKey` + `version` track data (plus `variationKey` when non-empty). `ResumptionToken` is a base64url-encoded JSON payload of those fields; `FromResumptionToken` validates and round-trips it, throwing `ArgumentException` on malformed or missing-required-fields input. ### `graphKey` propagation through `LdAiConfigTracker` When a per-node tracker is created via `AgentGraph` (rather than via the standalone `AgentConfig` path), the parent graph's key is threaded through to `LdAiConfigTracker` so every per-node event includes `graphKey` in its track data and resumption token. The wire format omits empty optional fields, so existing non-graph trackers continue to round-trip exactly. ### Other touched files - `ConfigFactory.BuildAgentConfig` and `TrackerFactoryFor` accept an optional `graphKey` parameter (default `null`). The default-path helper `BuildAgentFromDefault` is now `internal` (was `private`) so the graph code path can reuse it. All existing call sites are unchanged. - `Polyfills/IsExternalInit.cs` enables `init` accessors and positional records on the `net462`/`netstandard2.0` targets used by the new graph types. ## Migration **None required.** `ILdAiClient` is unchanged — no new members were added. Graph functionality is on the new `ILdAiGraphClient` interface, which `LdAiClient` implements alongside `ILdAiClient`. Existing consumers, including hand-rolled test doubles that implement `ILdAiClient`, will continue to compile and work without modification. The wire format is backward-compatible (the new `graphKey` field on config resumption tokens is omitted when empty). ## Test plan - [ ] `dotnet build` succeeds across `netstandard2.0`, `net462`, `net6.0`, `net8.0` - [ ] `dotnet test pkgs/sdk/server-ai/test/LaunchDarkly.ServerSdk.Ai.Tests.csproj --framework net8.0` passes - [ ] `AgentGraphDefinitionTest` covers node lookup, parent/child/terminal queries, BFS and reverse-BFS traversal, cycle safety, and disabled-graph no-op behavior - [ ] `AiGraphTrackerTest` covers track-data shape, at-most-once vs. multi-fire semantics, `TrackTotalTokens` empty-usage short-circuit, `ResumptionToken` round-trip, `FromResumptionToken` error handling, and the `Summary` snapshot - [ ] `LdAiAgentGraphConfigTest` covers `graphKey` propagation into per-node track data and resumption tokens - [ ] `LdAiClientAgentGraphTest` covers end-to-end `AgentGraph` retrieval and every validation path: disabled `_ldMeta`, missing root, unreachable node, and unfetchable/disabled child agent config — plus the `$ld:ai:usage:agent-graph` usage event - [ ] Reviewer confirms graph event names, at-most-once semantics, and resumption-token wire format match the cross-SDK contract <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Large additive public API and shared telemetry/resumption-token contracts must stay aligned with other AI SDKs; optional ILogger.Debug is a compile-time break only for custom ILogger implementations outside the package. > > **Overview** > Adds **agent graph** support to the server AI SDK: `LdAiClient` now also implements **`ILdAiGraphClient`** with `AgentGraph` and `CreateGraphTracker`, while **`ILdAiClient` stays unchanged**. > > `AgentGraph` evaluates a graph flag, parses `root` / `edges` / `_ldMeta`, validates connectivity and that every referenced agent config is enabled, then returns an **`AgentGraphDefinition`** (or a disabled shell with empty nodes when validation fails). The definition exposes node lookup, BFS **`Traverse`** / terminal-up **`ReverseTraverse`**, and **`CreateTracker`** for graph-scoped metrics. > > New types include **`AgentGraphNode`**, **`GraphEdge`** (optional handoff payload), **`AgentGraphFlagValue`**, **`AiGraphTracker`** (`$ld:ai:graph:*` events, at-most-once vs multi-fire handoff/redirect), and **`AiGraphMetricSummary`**. Graph node fetches thread an optional **`graphKey`** into **`LdAiConfigTracker`** track data and resumption tokens when absent from standalone `AgentConfig` calls. > > Supporting tweaks: **`ILogger.Debug`** (+ adapter), optional **`graphKey`** on **`ConfigFactory.BuildAgentConfig`**, and an **`IsExternalInit`** polyfill for older TFMs. Broad xUnit coverage for definition, tracker, client validation paths, and `graphKey` propagation. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d64294c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY --> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 2e595a4 commit c81b28f

17 files changed

Lines changed: 2363 additions & 15 deletions

pkgs/sdk/server-ai/src/Adapters/LoggerAdapter.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,4 +25,7 @@ public LoggerAdapter(Logger logger)
2525

2626
/// <inheritdoc/>
2727
public void Warn(string format, params object[] allParams) => _logger.Warn(format, allParams);
28+
29+
/// <inheritdoc/>
30+
public void Debug(string format, params object[] allParams) => _logger.Debug(format, allParams);
2831
}

pkgs/sdk/server-ai/src/Config/ConfigFactory.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,11 @@ public LdAiAgentConfig BuildAgentConfig(
102102
LdValue ldValue,
103103
Context context,
104104
LdAiAgentConfigDefault defaultValue,
105-
IReadOnlyDictionary<string, object> variables)
105+
IReadOnlyDictionary<string, object> variables,
106+
string graphKey = null)
106107
{
107108
var mergedVars = MergeVariables(variables, context);
108-
var trackerFactory = TrackerFactoryFor(context);
109+
var trackerFactory = TrackerFactoryFor(context, graphKey);
109110

110111
if (ldValue.Type != LdValueType.Object)
111112
{
@@ -144,7 +145,7 @@ public LdAiAgentConfig BuildAgentConfig(
144145
trackerFactory);
145146
}
146147

147-
private LdAiAgentConfig BuildAgentFromDefault(
148+
internal LdAiAgentConfig BuildAgentFromDefault(
148149
string key,
149150
LdAiAgentConfigDefault defaultValue,
150151
IReadOnlyDictionary<string, object> mergedVars,
@@ -275,7 +276,7 @@ private string InterpolateInstructions(
275276
return result;
276277
}
277278

278-
private Func<LdAiConfig, ILdAiConfigTracker> TrackerFactoryFor(Context context)
279+
private Func<LdAiConfig, ILdAiConfigTracker> TrackerFactoryFor(Context context, string graphKey = null)
279280
{
280281
return cfg => new LdAiConfigTracker(
281282
_client,
@@ -285,7 +286,8 @@ private Func<LdAiConfig, ILdAiConfigTracker> TrackerFactoryFor(Context context)
285286
cfg.Version,
286287
context,
287288
cfg.Model?.Name,
288-
cfg.Provider?.Name);
289+
cfg.Provider?.Name,
290+
graphKey);
289291
}
290292

291293
private static (bool Enabled, string VariationKey, int Version, string Mode) ParseMeta(LdValue value)
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
4+
using LaunchDarkly.Sdk.Server.Ai.Config;
5+
6+
namespace LaunchDarkly.Sdk.Server.Ai.Graph;
7+
8+
/// <summary>
9+
/// Represents a fully-resolved agent graph returned by
10+
/// <see cref="LdAiClient.AgentGraph"/>. When <see cref="Enabled"/> is false, all
11+
/// node collections are empty and traversal is a no-op; only <see cref="GetConfig"/>
12+
/// and <see cref="CreateTracker"/> remain meaningful.
13+
/// </summary>
14+
public sealed class AgentGraphDefinition
15+
{
16+
private readonly AgentGraphFlagValue _flagValue;
17+
private readonly IReadOnlyDictionary<string, AgentGraphNode> _nodes;
18+
private readonly Func<AiGraphTracker> _createTracker;
19+
20+
/// <summary>
21+
/// Whether the graph passed all validation checks. False if the flag's
22+
/// <c>_ldMeta.enabled</c> is false, the root is missing, any node is
23+
/// unreachable from the root, or any child agent config could not be fetched.
24+
/// </summary>
25+
public bool Enabled { get; }
26+
27+
internal AgentGraphDefinition(
28+
AgentGraphFlagValue flagValue,
29+
IReadOnlyDictionary<string, AgentGraphNode> nodes,
30+
bool enabled,
31+
Func<AiGraphTracker> createTracker)
32+
{
33+
_flagValue = flagValue;
34+
_nodes = nodes;
35+
Enabled = enabled;
36+
_createTracker = createTracker;
37+
}
38+
39+
/// <summary>
40+
/// Returns the root node of the graph, or null if the graph is disabled or has no root.
41+
/// </summary>
42+
public AgentGraphNode RootNode() =>
43+
string.IsNullOrEmpty(_flagValue?.Root) ? null : GetNode(_flagValue.Root);
44+
45+
/// <summary>
46+
/// Returns the node with the given key, or null if not found.
47+
/// </summary>
48+
public AgentGraphNode GetNode(string nodeKey)
49+
{
50+
if (nodeKey == null) return null;
51+
return _nodes.TryGetValue(nodeKey, out var node) ? node : null;
52+
}
53+
54+
/// <summary>
55+
/// Returns the direct children of the given node by following its outgoing edges.
56+
/// Returns an empty list if the node is not found.
57+
/// </summary>
58+
public IReadOnlyList<AgentGraphNode> GetChildNodes(string nodeKey)
59+
{
60+
var node = GetNode(nodeKey);
61+
if (node == null) return Array.Empty<AgentGraphNode>();
62+
63+
return node.Edges
64+
.Select(edge => GetNode(edge.Key))
65+
.Where(n => n != null)
66+
.ToList();
67+
}
68+
69+
/// <summary>
70+
/// Returns all nodes that have an outgoing edge pointing to the given node key.
71+
/// </summary>
72+
public IReadOnlyList<AgentGraphNode> GetParentNodes(string nodeKey)
73+
{
74+
return _nodes.Values
75+
.Where(node => node.Edges.Any(edge => edge.Key == nodeKey))
76+
.ToList();
77+
}
78+
79+
/// <summary>
80+
/// Returns all nodes with no outgoing edges (leaf nodes).
81+
/// </summary>
82+
public IReadOnlyList<AgentGraphNode> TerminalNodes() =>
83+
_nodes.Values.Where(n => n.IsTerminal).ToList();
84+
85+
/// <summary>
86+
/// Returns the raw flag value including LaunchDarkly metadata. Always non-null,
87+
/// even when <see cref="Enabled"/> is false.
88+
/// </summary>
89+
public AgentGraphFlagValue GetConfig() => _flagValue;
90+
91+
/// <summary>
92+
/// Creates a new graph-level tracker for this invocation.
93+
/// </summary>
94+
public AiGraphTracker CreateTracker() => _createTracker();
95+
96+
/// <summary>
97+
/// Performs a breadth-first traversal of the graph starting from the root node.
98+
/// For each visited node, <paramref name="fn"/> is called with the node and the
99+
/// accumulated context dictionary. The return value of <paramref name="fn"/> is
100+
/// stored in the context under the node's key and passed to subsequent calls.
101+
/// Cycle-safe: each node is visited at most once.
102+
/// </summary>
103+
public void Traverse(
104+
Func<AgentGraphNode, Dictionary<string, object>, object> fn,
105+
Dictionary<string, object> initialContext = null)
106+
{
107+
var root = RootNode();
108+
if (root == null) return;
109+
110+
var context = initialContext ?? new Dictionary<string, object>();
111+
112+
var visited = new HashSet<string>();
113+
var queue = new Queue<AgentGraphNode>();
114+
queue.Enqueue(root);
115+
visited.Add(root.Key);
116+
117+
while (queue.Count > 0)
118+
{
119+
var node = queue.Dequeue();
120+
var result = fn(node, context);
121+
context[node.Key] = result;
122+
123+
foreach (var child in GetChildNodes(node.Key))
124+
{
125+
if (visited.Add(child.Key))
126+
{
127+
queue.Enqueue(child);
128+
}
129+
}
130+
}
131+
}
132+
133+
/// <summary>
134+
/// Performs a reverse breadth-first traversal: starts from terminal nodes and
135+
/// works upward toward the root. The root node is always processed last.
136+
/// Cycle-safe: each node is visited at most once.
137+
/// </summary>
138+
public void ReverseTraverse(
139+
Func<AgentGraphNode, Dictionary<string, object>, object> fn,
140+
Dictionary<string, object> initialContext = null)
141+
{
142+
if (_nodes.Count == 0) return;
143+
144+
var context = initialContext ?? new Dictionary<string, object>();
145+
146+
var root = RootNode();
147+
var visited = new HashSet<string>();
148+
var queue = new Queue<AgentGraphNode>();
149+
150+
// Seed with terminal nodes (excluding root if it happens to be terminal and there are others)
151+
foreach (var terminal in TerminalNodes())
152+
{
153+
if (root != null && terminal.Key == root.Key && _nodes.Count > 1)
154+
{
155+
continue;
156+
}
157+
if (visited.Add(terminal.Key))
158+
{
159+
queue.Enqueue(terminal);
160+
}
161+
}
162+
163+
while (queue.Count > 0)
164+
{
165+
var node = queue.Dequeue();
166+
167+
// Defer root until the very end (unless it's the only node)
168+
if (root != null && node.Key == root.Key && _nodes.Count > 1)
169+
{
170+
continue;
171+
}
172+
173+
var result = fn(node, context);
174+
context[node.Key] = result;
175+
176+
foreach (var parent in GetParentNodes(node.Key))
177+
{
178+
if (root != null && parent.Key == root.Key)
179+
{
180+
// Don't enqueue root yet — process it last
181+
continue;
182+
}
183+
if (visited.Add(parent.Key))
184+
{
185+
queue.Enqueue(parent);
186+
}
187+
}
188+
}
189+
190+
// Process root last
191+
if (root != null && _nodes.Count > 1 && visited.Count > 0)
192+
{
193+
var result = fn(root, context);
194+
context[root.Key] = result;
195+
}
196+
}
197+
198+
/// <summary>
199+
/// Builds the nodes dictionary from a parsed flag value and a map of pre-fetched
200+
/// agent configs, associating each node with its outgoing edges from the flag value.
201+
/// </summary>
202+
internal static IReadOnlyDictionary<string, AgentGraphNode> BuildNodes(
203+
AgentGraphFlagValue flagValue,
204+
IReadOnlyDictionary<string, LdAiAgentConfig> agentConfigs)
205+
{
206+
var nodes = new Dictionary<string, AgentGraphNode>();
207+
var allKeys = CollectAllKeys(flagValue);
208+
209+
foreach (var key in allKeys)
210+
{
211+
if (!agentConfigs.TryGetValue(key, out var config))
212+
continue;
213+
214+
var outgoingEdges = flagValue.Edges != null && flagValue.Edges.TryGetValue(key, out var edges)
215+
? edges
216+
: (IReadOnlyList<GraphEdge>)Array.Empty<GraphEdge>();
217+
218+
nodes[key] = new AgentGraphNode(key, config, outgoingEdges);
219+
}
220+
221+
return nodes;
222+
}
223+
224+
/// <summary>
225+
/// Collects all unique node keys referenced in the flag value: the root, all
226+
/// edge source keys, and all edge target keys.
227+
/// </summary>
228+
internal static HashSet<string> CollectAllKeys(AgentGraphFlagValue flagValue)
229+
{
230+
var keys = new HashSet<string>();
231+
232+
if (!string.IsNullOrEmpty(flagValue?.Root))
233+
{
234+
keys.Add(flagValue.Root);
235+
}
236+
237+
if (flagValue?.Edges != null)
238+
{
239+
foreach (var kv in flagValue.Edges)
240+
{
241+
keys.Add(kv.Key);
242+
foreach (var edge in kv.Value)
243+
{
244+
keys.Add(edge.Key);
245+
}
246+
}
247+
}
248+
249+
return keys;
250+
}
251+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
using System.Collections.Generic;
2+
3+
namespace LaunchDarkly.Sdk.Server.Ai.Graph;
4+
5+
/// <summary>
6+
/// Raw flag value for an agent graph configuration as returned by LaunchDarkly.
7+
/// Returned by <see cref="AgentGraphDefinition.GetConfig"/>.
8+
/// </summary>
9+
public sealed class AgentGraphFlagValue
10+
{
11+
/// <summary>
12+
/// The key of the root AIAgentConfig in the graph.
13+
/// </summary>
14+
public string Root { get; init; }
15+
16+
/// <summary>
17+
/// Object mapping source agent config keys to arrays of outgoing target edges.
18+
/// Null when no edges are defined.
19+
/// </summary>
20+
public IReadOnlyDictionary<string, IReadOnlyList<GraphEdge>> Edges { get; init; }
21+
22+
/// <summary>
23+
/// LaunchDarkly metadata from the <c>_ldMeta</c> field on the flag value.
24+
/// Null when a default/fallback value was used (flag not evaluated).
25+
/// </summary>
26+
public LdMeta Meta { get; init; }
27+
}
28+
29+
/// <summary>
30+
/// LaunchDarkly metadata from the <c>_ldMeta</c> field on flag values.
31+
/// </summary>
32+
public sealed class LdMeta
33+
{
34+
/// <summary>
35+
/// The variation key, if available. Null when a default config was used.
36+
/// </summary>
37+
public string VariationKey { get; init; }
38+
39+
/// <summary>
40+
/// The version of the flag variation. Defaults to 1.
41+
/// </summary>
42+
public int Version { get; init; } = 1;
43+
44+
/// <summary>
45+
/// Whether the configuration is enabled in the LaunchDarkly dashboard.
46+
/// Defaults to true. Note: this is distinct from
47+
/// <see cref="AgentGraphDefinition.Enabled"/>, which reflects the result of ALL
48+
/// validation checks (metadata enabled + root present + all nodes reachable +
49+
/// all child configs fetchable).
50+
/// </summary>
51+
public bool Enabled { get; init; } = true;
52+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using System.Collections.Generic;
2+
using LaunchDarkly.Sdk.Server.Ai.Config;
3+
4+
namespace LaunchDarkly.Sdk.Server.Ai.Graph;
5+
6+
/// <summary>
7+
/// Represents a single node within an agent graph.
8+
/// Each node wraps an <see cref="LdAiAgentConfig"/> and carries the outgoing edges to
9+
/// its children. Use the config's tracker (via <c>Config.CreateTracker()</c>) to record
10+
/// node-level metrics.
11+
/// </summary>
12+
public sealed class AgentGraphNode
13+
{
14+
/// <summary>
15+
/// The agent config key for this node.
16+
/// </summary>
17+
public string Key { get; }
18+
19+
/// <summary>
20+
/// The agent config for this node.
21+
/// </summary>
22+
public LdAiAgentConfig Config { get; }
23+
24+
/// <summary>
25+
/// The outgoing edges from this node to its children.
26+
/// </summary>
27+
public IReadOnlyList<GraphEdge> Edges { get; }
28+
29+
/// <summary>
30+
/// Whether this node has no outgoing edges (i.e., is a leaf node).
31+
/// </summary>
32+
public bool IsTerminal => Edges.Count == 0;
33+
34+
/// <summary>
35+
/// Constructs an agent graph node.
36+
/// </summary>
37+
public AgentGraphNode(string key, LdAiAgentConfig config, IReadOnlyList<GraphEdge> edges)
38+
{
39+
Key = key;
40+
Config = config;
41+
Edges = edges;
42+
}
43+
}

0 commit comments

Comments
 (0)