feat: Add AgentGraph support to the AI SDK#292
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 0842ab6. Configure here.
jsonbailey
left a comment
There was a problem hiding this comment.
Reviewed the graph implementation against the shipped js-core and python-server-sdk-ai SDKs and reconciled the differences against the AI SDK spec (sdk-specs, specs/AISDK-ai-sdk/). Security review came back clean (resumption-token decode, at-most-once concurrency, fail-closed validation, graphKey back-compat all verified).
Two spec-compliance items to fix (reverse-traverse no-terminal behavior, validation log level) plus an out-of-scope version-coercion change and a couple of low-severity items inline. Where js-core and python disagreed, the spec sided with js-core on forward-traverse ordering (BFS) and trackPath at-most-once, so .NET is correct on those — no change needed there.
| // If no terminals were seeded (pure cycle — no leaf nodes exist), seed all non-root nodes | ||
| // so every node is still visited. For a single-node graph (or self-loop), fall back to | ||
| // seeding root itself as the only node. | ||
| if (queue.Count == 0 && root != null) | ||
| { | ||
| foreach (var n in _nodes.Values) | ||
| { | ||
| if (n.Key == root.Key) continue; | ||
| if (visited.Add(n.Key)) | ||
| queue.Enqueue(n); | ||
| } | ||
| if (queue.Count == 0 && visited.Add(root.Key)) | ||
| queue.Enqueue(root); | ||
| } |
There was a problem hiding this comment.
ReverseTraverse diverges from the spec for graphs with no terminal nodes. This fallback seeds all nodes when no terminals exist, so a pure cycle (a→b→c→a) or root self-loop (a→a) visits every node. The spec (AIGRAPH Req 1.4) says reverse traversal "starts from the graph's terminal nodes" — an empty terminal set should be a no-op, which is what both js-core (AgentGraphDefinition.ts:187-191) and python (agent_graph/__init__.py:232-234) do. Suggest dropping this block and letting an empty terminal set fall through to a no-op (the ReverseTraverseIsCycleSafe test would then assert Empty(visited)).
A quick proof: with this code ReverseTraverse returns ["b","c","a"] here vs [] in both references. Also worth confirming the normal reverse path honors the spec's "start from the deepest terminal node" ordering, not just plain reverse-BFS.
|
|
||
| if (parsed.Meta?.Enabled == false) | ||
| { | ||
| _client.GetLogger()?.Warn($"agentGraph: graph \"{graphKey}\" is disabled."); |
There was a problem hiding this comment.
Spec AIGRAPH Req 1.5.2 says graph validation-failure logs should be emitted at DEBUG level, not warn — a disabled graph is a normal configuration state rather than a warning condition, and js-core uses _logger?.debug(...) for these. Suggest switching these Warn(...) calls (here and at the other validation-failure sites in this method) to debug.
| var variationKey = meta.Get("variationKey").AsString ?? ""; | ||
| var versionValue = meta.Get("version"); | ||
| var version = versionValue.IsNull ? 1 : versionValue.AsInt; | ||
| var version = versionValue.IsNull || versionValue.AsInt <= 0 ? 1 : versionValue.AsInt; |
There was a problem hiding this comment.
Version defaulting should be: if _ldMeta.version isn't set, default to 1; if it is set, use that value as-is with no further coercion. The new || versionValue.AsInt <= 0 clause coerces a present 0/negative to 1, which both references avoid (js version ?? 1, python .get("version", 1) — they default only when the field is absent), and this applies to all config types (completion/agent/judge), not just graphs. Suggest reverting to versionValue.IsNull ? 1 : versionValue.AsInt.
| if (Interlocked.CompareExchange(ref _path, | ||
| new StrongBox<IReadOnlyList<string>>(path), null) != null) |
There was a problem hiding this comment.
TrackPath stores the caller's IReadOnlyList<string> reference directly, and Summary returns it (:109). If a caller passes a mutable List<string> and mutates it afterward, the recorded summary changes underneath. js-core defensive-copies ([...path]). Suggest snapshotting, e.g. new StrongBox<IReadOnlyList<string>>(path.ToArray()).
|
|
||
| // Test 19: At-most-once — second TrackDuration logs warning and drops | ||
| [Fact] | ||
| public void TrackDurationAtMostOnce() |
There was a problem hiding this comment.
A few at-most-once paths are unasserted alongside this one: TrackPath and TrackTotalTokens called twice, and the "empty Usage does not consume the slot" ordering (AiGraphTracker.cs:232-236 — an empty call should still let a later real call fire). Worth adding to lock down the subtle slot semantics.

Summary
Adds first-class support for agent graphs to
LaunchDarkly.ServerSdk.Ai. An agent graph is a LaunchDarkly-managed directed graph whose nodes are existingLdAiAgentConfigs 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.AgentGraphandCreateGraphTrackeronILdAiGraphClientLdAiClientnow implements bothILdAiClientand the newILdAiGraphClient. The existingILdAiClientinterface is unchanged — no members added, removed, or modified. Graph functionality lives entirely on the separateILdAiGraphClientinterface, making this a purely additive change.AgentGraphfires a$ld:ai:usage:agent-graphusage event, evaluates the graph flag, fetches every childLdAiAgentConfig, runs connectivity validation, and returns anAgentGraphDefinition. The returned definition'sEnabledproperty 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; onlyGetConfig()(raw flag value +_ldMeta) andCreateTracker()remain meaningful.CreateGraphTrackerreconstructs anAiGraphTrackerfrom a base64url resumption token, enabling cross-process continuation (e.g. emitting graph-level events from a worker that didn't run the original evaluation).AgentGraphDefinitionTraversedoes a breadth-first walk from the root;ReverseTraversewalks 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 anLdAiAgentConfigplus its outgoingGraphEdges and exposesIsTerminal(true when there are no outgoing edges). Per-node metrics are recorded via the wrapped config's existingCreateTracker().GraphEdge—recordcarrying the targetKeyand an optionalHandoffdictionary (IReadOnlyDictionary<string, LdValue>). Handoff maps are wrapped inReadOnlyDictionaryto enforce immutability; edge lists are frozen viaAsReadOnly().AgentGraphFlagValue/LdMeta— the parsed raw flag value, including_ldMeta. Always non-null on the returned definition, even when the graph is disabled.AiGraphTrackerGraph-scoped tracker emitting
$ld:ai:graph:*events. At-most-once methods useInterlocked.CompareExchangeso the contract holds under racing callers — a second call logs a warning and is silently dropped.TrackTotalTokensshort-circuits before claiming the slot when usage is empty, consistent withLdAiConfigTracker.TrackTokens. Multi-fire methods may be called once per edge traversal. Every event carries the standardrunId+graphKey+versiontrack data (plusvariationKeywhen non-empty).ResumptionTokenis a base64url-encoded JSON payload of those fields;FromResumptionTokenvalidates and round-trips it, throwingArgumentExceptionon malformed or missing-required-fields input.graphKeypropagation throughLdAiConfigTrackerWhen a per-node tracker is created via
AgentGraph(rather than via the standaloneAgentConfigpath), the parent graph's key is threaded through toLdAiConfigTrackerso every per-node event includesgraphKeyin 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.BuildAgentConfigandTrackerFactoryForaccept an optionalgraphKeyparameter (defaultnull). The default-path helperBuildAgentFromDefaultis nowinternal(wasprivate) so the graph code path can reuse it. All existing call sites are unchanged.Polyfills/IsExternalInit.csenablesinitaccessors and positional records on thenet462/netstandard2.0targets used by the new graph types.Migration
None required.
ILdAiClientis unchanged — no new members were added. Graph functionality is on the newILdAiGraphClientinterface, whichLdAiClientimplements alongsideILdAiClient. Existing consumers, including hand-rolled test doubles that implementILdAiClient, will continue to compile and work without modification. The wire format is backward-compatible (the newgraphKeyfield on config resumption tokens is omitted when empty).Test plan
dotnet buildsucceeds acrossnetstandard2.0,net462,net6.0,net8.0dotnet test pkgs/sdk/server-ai/test/LaunchDarkly.ServerSdk.Ai.Tests.csproj --framework net8.0passesAgentGraphDefinitionTestcovers node lookup, parent/child/terminal queries, BFS and reverse-BFS traversal, cycle safety, and disabled-graph no-op behaviorAiGraphTrackerTestcovers track-data shape, at-most-once vs. multi-fire semantics,TrackTotalTokensempty-usage short-circuit,ResumptionTokenround-trip,FromResumptionTokenerror handling, and theSummarysnapshotLdAiAgentGraphConfigTestcoversgraphKeypropagation into per-node track data and resumption tokensLdAiClientAgentGraphTestcovers end-to-endAgentGraphretrieval and every validation path: disabled_ldMeta, missing root, unreachable node, and unfetchable/disabled child agent config — plus the$ld:ai:usage:agent-graphusage eventNote
Medium Risk
Large additive surface area (flag parsing, validation, traversal, and new tracking wire formats) with backward-compatible optional
graphKeyon existing trackers; behavior changes are limited to version parsing and graph fetch paths.Overview
Adds agent graph support to the server AI SDK:
LdAiClientnow implements newILdAiGraphClientwithAgentGraphandCreateGraphTracker, whileILdAiClientis unchanged.AgentGraphevaluates a graph flag, parsesroot/edges/handoffand_ldMeta, validates connectivity and that every referenced agent config is enabled, then returns anAgentGraphDefinitionwith node lookup, BFS/ReverseTraverse(cycle-safe), and a graph-levelAiGraphTracker($ld:ai:graph:*events, at-most-once vs multi-fire handoff/redirect, resumption tokens). Failed validation still returns a disabled definition with empty nodes but usableGetConfig()/CreateTracker().When nodes are resolved inside a graph, an optional
graphKeyis threaded throughConfigFactory.BuildAgentConfigintoLdAiConfigTrackertrack payloads and resumption tokens (omitted when unset).ParseMetanow treats non-positive_ldMeta.versionas 1. AnIsExternalInitpolyfill supports newinit/record types on older targets.Reviewed by Cursor Bugbot for commit c742ece. Bugbot is set up for automated code reviews on this repo. Configure here.