Skip to content

feat: Add AgentGraph support to the AI SDK#292

Open
mattrmc1 wants to merge 11 commits into
mainfrom
mmccarthy/AIC-2723/agent-graph-infrastructure
Open

feat: Add AgentGraph support to the AI SDK#292
mattrmc1 wants to merge 11 commits into
mainfrom
mmccarthy/AIC-2723/agent-graph-infrastructure

Conversation

@mattrmc1

@mattrmc1 mattrmc1 commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

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 LdAiAgentConfigs 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

// 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

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 GraphEdges and exposes IsTerminal (true when there are no outgoing edges). Per-node metrics are recorded via the wrapped config's existing CreateTracker().
  • GraphEdgerecord 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

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

Note

Medium Risk
Large additive surface area (flag parsing, validation, traversal, and new tracking wire formats) with backward-compatible optional graphKey on existing trackers; behavior changes are limited to version parsing and graph fetch paths.

Overview
Adds agent graph support to the server AI SDK: LdAiClient now implements new ILdAiGraphClient with AgentGraph and CreateGraphTracker, while ILdAiClient is unchanged.

AgentGraph evaluates a graph flag, parses root/edges/handoff and _ldMeta, validates connectivity and that every referenced agent config is enabled, then returns an AgentGraphDefinition with node lookup, BFS/ReverseTraverse (cycle-safe), and a graph-level AiGraphTracker ($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 usable GetConfig() / CreateTracker().

When nodes are resolved inside a graph, an optional graphKey is threaded through ConfigFactory.BuildAgentConfig into LdAiConfigTracker track payloads and resumption tokens (omitted when unset). ParseMeta now treats non-positive _ldMeta.version as 1. An IsExternalInit polyfill supports new init/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.

@mattrmc1 mattrmc1 changed the title feat!: AgentGraph support feat!: Add AgentGraph support to the AI SDK Jun 16, 2026
@mattrmc1 mattrmc1 requested a review from jsonbailey June 16, 2026 19:38
@mattrmc1 mattrmc1 marked this pull request as ready for review June 16, 2026 19:38
@mattrmc1 mattrmc1 requested a review from a team as a code owner June 16, 2026 19:38
Comment thread pkgs/sdk/server-ai/src/Graph/AgentGraphDefinition.cs
Comment thread pkgs/sdk/server-ai/src/Graph/AiGraphTracker.cs
Comment thread pkgs/sdk/server-ai/src/LdAiClient.cs
Comment thread pkgs/sdk/server-ai/src/Graph/AiGraphTracker.cs
Comment thread pkgs/sdk/server-ai/src/LdAiClient.cs Outdated
Comment thread pkgs/sdk/server-ai/src/Graph/AiGraphTracker.cs
@mattrmc1 mattrmc1 changed the title feat!: Add AgentGraph support to the AI SDK feat: Add AgentGraph support to the AI SDK Jun 17, 2026
Comment thread pkgs/sdk/server-ai/src/Graph/AiGraphTracker.cs

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread pkgs/sdk/server-ai/src/LdAiClient.cs

@jsonbailey jsonbailey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +163 to +176
// 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);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +257 to +258
if (Interlocked.CompareExchange(ref _path,
new StrongBox<IReadOnlyList<string>>(path), null) != null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants