Skip to content

Commit 47c4f36

Browse files
authored
feat: Agent graph support (#181)
## Summary Adds **agent graph support** — flag evaluation, graph validation, BFS traversal, graph-level tracking, and resumption tokens. Callers fetch a graph definition via `agentGraph(graphKey, context, variables)`, inspect or traverse the node topology, and track graph-level metrics (invocation success/failure, duration, tokens, path) plus edge-level events (redirect, handoff) through `AIGraphTracker`. ### New types **`GraphEdge`** — immutable edge holding target `key` and optional `handoff` metadata map (unmodifiable defensive copy). **`AgentGraphNode`** — wraps a node key, its resolved `AIAgentConfig`, and an unmodifiable outgoing `GraphEdge` list (defensive copy). `isTerminal()` returns true when edges are empty. **`AgentGraphFlagValue`** (package-private) — parses the graph flag JSON protocol: `root`, `edges` adjacency map, and `_ldMeta` (enabled, variationKey, version). Defensively handles malformed input without throwing. **`AgentGraphDefinition`** — the resolved graph: ```java boolean isEnabled(); AgentGraphNode rootNode(); AgentGraphNode getNode(String nodeKey); List<AgentGraphNode> getChildNodes(String nodeKey); List<AgentGraphNode> getParentNodes(String nodeKey); List<AgentGraphNode> terminalNodes(); AIGraphTracker createTracker(); void traverse(BiFunction<AgentGraphNode, Map<String, Object>, Object> fn, Map<String, Object> ctx); void reverseTraverse(BiFunction<AgentGraphNode, Map<String, Object>, Object> fn, Map<String, Object> ctx); ``` `traverse` is BFS root-to-leaves; `reverseTraverse` is BFS terminals-to-root (root always processed last). Both are cycle-safe — each node visited at most once. Visitor results stored in the context map under the node's key. **`AIGraphTracker`** — graph-level tracking: ```java // At-most-once (invocation success/failure share one guard): void trackInvocationSuccess(); void trackInvocationFailure(); void trackDuration(double durationMs); void trackTotalTokens(TokenUsage tokens); void trackPath(List<String> path); // Multi-fire: void trackRedirect(String sourceKey, String redirectedTarget); void trackHandoffSuccess(String sourceKey, String targetKey); void trackHandoffFailure(String sourceKey, String targetKey); AIGraphMetricSummary getSummary(); String getResumptionToken(); ``` Uses `AtomicReference.compareAndSet(null, value)` for at-most-once. Non-finite durations (NaN, infinity) are rejected without consuming the at-most-once slot. Reconstruction from a resumption token is handled via `LDAIClient.createGraphTracker(token, context)`, which pipes the configured logger from the client (the static `fromResumptionToken` method is package-private). **`AIGraphMetricSummary`** — immutable snapshot of graph tracker state (success, durationMs, tokens, path, resumptionToken). All nullable except resumptionToken. ### Client methods ```java AgentGraphDefinition agentGraph(String graphKey, LDContext context, Map<String, Object> variables); AgentGraphDefinition agentGraph(String graphKey, LDContext context); AIGraphTracker createGraphTracker(String resumptionToken, LDContext context); ``` `agentGraph` validates that `graphKey` is non-null and non-blank (throws `NullPointerException` / `IllegalArgumentException`), then evaluates the graph flag, validates (enabled -> root present -> all nodes reachable from root -> all child configs enabled), fetches each node's `AIAgentConfig` passing `graphKey` for tracker correlation. Returns disabled definition on any validation failure. Emits `$ld:ai:usage:agent-graph` usage event. `createGraphTracker` reconstructs an `AIGraphTracker` from a resumption token, preserving the original run identity while using the client's configured logger. ### Logging Default logger resolution (`Loggers.defaultLogger()`) is centralized in `internal/Loggers.java` and used only from the `LDAIClientImpl` single-argument constructor as an entry-point fallback. All other code paths receive the logger from the top via constructor injection, consistent with the convention in other LaunchDarkly SDKs (.NET, JS). ### Other changes - `ResumptionTokens` extended with `encodeGraph`/`decodeGraph` for graph-specific tokens (fields: `runId`, `graphKey`, `variationKey`, `version`). Made `public` for access from `AIGraphTracker`. Decode enforces non-blank `runId` and `graphKey`. - `agentConfigs()` reordered to emit usage count before fetching configs. - Config evaluation methods gain `graphKey` parameter so child node trackers include graph identity in their track data. ## Test plan - [ ] `./gradlew :lib:sdk:server-ai:test` passes - [ ] `AIGraphTrackerTest` — invocation success/failure + shared guard, duration (including non-finite rejection), total tokens, path, redirect/handoff multi-fire, base data correctness, variationKey omission, getSummary, resumption token round-trip, concurrency (20-thread contention for invocation and duration) - [ ] `AgentGraphDefinitionTest` — buildNodes, collectAllKeys, traverse/reverseTraverse (including cycles, single-node, diamond graphs), rootNode/getNode/getChildNodes/getParentNodes/terminalNodes, disabled graph behavior, createTracker - [ ] `LDAIClientImplTest` — agentGraph usage event, enabled/disabled graph, unreachable node validation, non-enabled child config validation, graphKey threading to child trackers, createGraphTracker delegation, null/blank graphKey rejection - [ ] `AgentGraphFlagValueTest` — parse root/edges/meta, missing fields, disabled flag, malformed input, handoff metadata, edge with missing key skipped - [ ] `ResumptionTokensTest` — graph token encode/decode round-trips, blank field rejection
1 parent a32c4fa commit 47c4f36

14 files changed

Lines changed: 2891 additions & 31 deletions
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.launchdarkly.sdk.server.ai;
2+
3+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.TokenUsage;
4+
5+
import java.util.List;
6+
7+
/**
8+
* A snapshot of the metrics tracked so far by an {@link AIGraphTracker}.
9+
* <p>
10+
* All fields are nullable: a {@code null} value means the corresponding metric has not been
11+
* recorded yet on the tracker. {@link #getResumptionToken()} is always present.
12+
* <p>
13+
* Instances are immutable.
14+
*/
15+
public final class AIGraphMetricSummary {
16+
private final Boolean success;
17+
private final Double durationMs;
18+
private final TokenUsage tokens;
19+
private final List<String> path;
20+
private final String resumptionToken;
21+
22+
AIGraphMetricSummary(
23+
Boolean success,
24+
Double durationMs,
25+
TokenUsage tokens,
26+
List<String> path,
27+
String resumptionToken) {
28+
this.success = success;
29+
this.durationMs = durationMs;
30+
this.tokens = tokens;
31+
this.path = path;
32+
this.resumptionToken = resumptionToken;
33+
}
34+
35+
/**
36+
* Returns the invocation outcome: {@code true} if {@code trackInvocationSuccess} was called,
37+
* {@code false} if {@code trackInvocationFailure} was called, or {@code null} if neither has
38+
* been called yet.
39+
*
40+
* @return the success flag, or {@code null} if not yet recorded
41+
*/
42+
public Boolean getSuccess() {
43+
return success;
44+
}
45+
46+
/**
47+
* Returns the tracked graph-level duration in milliseconds, or {@code null} if not recorded.
48+
*
49+
* @return the duration in ms, or {@code null}
50+
*/
51+
public Double getDurationMs() {
52+
return durationMs;
53+
}
54+
55+
/**
56+
* Returns the tracked token usage, or {@code null} if not recorded.
57+
*
58+
* @return the token usage, or {@code null}
59+
*/
60+
public TokenUsage getTokens() {
61+
return tokens;
62+
}
63+
64+
/**
65+
* Returns the tracked node path (ordered list of node keys visited), or {@code null} if not
66+
* recorded.
67+
*
68+
* @return an unmodifiable list of node keys, or {@code null}
69+
*/
70+
public List<String> getPath() {
71+
return path;
72+
}
73+
74+
/**
75+
* Returns the resumption token for this graph run, which can be passed to
76+
* {@link LDAIClient#createGraphTracker(String, com.launchdarkly.sdk.LDContext)} to reconstruct
77+
* the tracker on a subsequent request.
78+
*
79+
* @return the resumption token; never {@code null}
80+
*/
81+
public String getResumptionToken() {
82+
return resumptionToken;
83+
}
84+
}
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
package com.launchdarkly.sdk.server.ai;
2+
3+
import com.launchdarkly.logging.LDLogger;
4+
import com.launchdarkly.sdk.ArrayBuilder;
5+
import com.launchdarkly.sdk.LDContext;
6+
import com.launchdarkly.sdk.LDValue;
7+
import com.launchdarkly.sdk.ObjectBuilder;
8+
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.TokenUsage;
9+
import com.launchdarkly.sdk.server.ai.internal.ResumptionTokens;
10+
import com.launchdarkly.sdk.server.interfaces.LDClientInterface;
11+
12+
import java.util.ArrayList;
13+
import java.util.Collections;
14+
import java.util.List;
15+
import java.util.Objects;
16+
import java.util.UUID;
17+
import java.util.concurrent.atomic.AtomicReference;
18+
19+
/**
20+
* Reports graph-level events for a single invocation of an {@link AgentGraphDefinition}.
21+
* <p>
22+
* An {@code AIGraphTracker} is obtained from an enabled graph definition via
23+
* {@link AgentGraphDefinition#createTracker()}, or reconstructed from a resumption token via
24+
* {@link LDAIClient#createGraphTracker(String, LDContext)}.
25+
* <p>
26+
* Graph-level methods (invocation, duration, tokens, path) are at-most-once: a second call on
27+
* the same tracker is silently dropped. Edge-level methods (redirect, handoff) are multi-fire —
28+
* each call records a distinct event.
29+
* <p>
30+
* Implementations are thread-safe.
31+
*/
32+
public final class AIGraphTracker {
33+
34+
private static final String GRAPH_INVOCATION_SUCCESS = "$ld:ai:graph:invocation_success";
35+
private static final String GRAPH_INVOCATION_FAILURE = "$ld:ai:graph:invocation_failure";
36+
private static final String GRAPH_DURATION_TOTAL = "$ld:ai:graph:duration:total";
37+
private static final String GRAPH_TOTAL_TOKENS = "$ld:ai:graph:total_tokens";
38+
private static final String GRAPH_PATH = "$ld:ai:graph:path";
39+
private static final String GRAPH_REDIRECT = "$ld:ai:graph:redirect";
40+
private static final String GRAPH_HANDOFF_SUCCESS = "$ld:ai:graph:handoff_success";
41+
private static final String GRAPH_HANDOFF_FAILURE = "$ld:ai:graph:handoff_failure";
42+
43+
private final LDClientInterface client;
44+
private final LDContext context;
45+
private final LDLogger logger;
46+
47+
private final String runId;
48+
private final String graphKey;
49+
private final String variationKey;
50+
private final int version;
51+
52+
private final String resumptionToken;
53+
54+
// At-most-once guards: null = not yet recorded, non-null = recorded.
55+
// trackInvocationSuccess and trackInvocationFailure share invocationRecorded:
56+
// true = success was recorded, false = failure was recorded.
57+
private final AtomicReference<Boolean> invocationRecorded = new AtomicReference<>();
58+
private final AtomicReference<Double> durationRecorded = new AtomicReference<>();
59+
private final AtomicReference<TokenUsage> tokensRecorded = new AtomicReference<>();
60+
private final AtomicReference<List<String>> pathRecorded = new AtomicReference<>();
61+
62+
AIGraphTracker(
63+
LDClientInterface client,
64+
String runId,
65+
String graphKey,
66+
String variationKey,
67+
int version,
68+
LDContext context,
69+
LDLogger logger) {
70+
this.client = Objects.requireNonNull(client, "client");
71+
this.runId = Objects.requireNonNull(runId, "runId");
72+
Objects.requireNonNull(graphKey, "graphKey");
73+
if (graphKey.trim().isEmpty()) {
74+
throw new IllegalArgumentException("graphKey must not be blank");
75+
}
76+
this.graphKey = graphKey;
77+
this.variationKey = variationKey;
78+
this.version = version;
79+
this.context = Objects.requireNonNull(context, "context");
80+
this.logger = Objects.requireNonNull(logger, "logger");
81+
82+
this.resumptionToken = ResumptionTokens.encodeGraph(runId, graphKey, variationKey, version);
83+
}
84+
85+
/**
86+
* Reconstructs a graph tracker from a resumption token, preserving the original run identity,
87+
* and logging through the supplied logger.
88+
* <p>
89+
* This method is package-private. External callers should use
90+
* {@link LDAIClient#createGraphTracker(String, LDContext)} instead, which correctly pipes the
91+
* configured logger through from the top.
92+
*/
93+
static AIGraphTracker fromResumptionToken(
94+
String token, LDClientInterface client, LDContext context, LDLogger logger) {
95+
ResumptionTokens.DecodedGraph d = ResumptionTokens.decodeGraph(token);
96+
int version = d.getVersion();
97+
return new AIGraphTracker(
98+
client,
99+
d.getRunId(),
100+
d.getGraphKey(),
101+
d.getVariationKey(),
102+
version,
103+
context,
104+
logger);
105+
}
106+
107+
/**
108+
* Records that the graph invocation succeeded.
109+
* <p>
110+
* At-most-once and mutually exclusive with {@link #trackInvocationFailure()}: whichever is
111+
* called first wins.
112+
*/
113+
public void trackInvocationSuccess() {
114+
if (!invocationRecorded.compareAndSet(null, Boolean.TRUE)) {
115+
logger.warn("Skipping trackInvocationSuccess: invocation already recorded on this graph tracker.");
116+
return;
117+
}
118+
client.trackMetric(GRAPH_INVOCATION_SUCCESS, context, baseData().build(), 1);
119+
}
120+
121+
/**
122+
* Records that the graph invocation failed.
123+
* <p>
124+
* At-most-once and mutually exclusive with {@link #trackInvocationSuccess()}: whichever is
125+
* called first wins.
126+
*/
127+
public void trackInvocationFailure() {
128+
if (!invocationRecorded.compareAndSet(null, Boolean.FALSE)) {
129+
logger.warn("Skipping trackInvocationFailure: invocation already recorded on this graph tracker.");
130+
return;
131+
}
132+
client.trackMetric(GRAPH_INVOCATION_FAILURE, context, baseData().build(), 1);
133+
}
134+
135+
/**
136+
* Records the total wall-clock duration of the graph invocation.
137+
* <p>
138+
* At-most-once: subsequent calls on the same tracker are silently dropped. Non-finite values
139+
* ({@link Double#NaN}, positive/negative infinity) are ignored without consuming the
140+
* at-most-once slot.
141+
*
142+
* @param durationMs the duration in milliseconds; must be finite
143+
*/
144+
public void trackDuration(double durationMs) {
145+
if (!Double.isFinite(durationMs)) {
146+
logger.debug("Skipping trackDuration: durationMs is not finite ({}).", durationMs);
147+
return;
148+
}
149+
if (!durationRecorded.compareAndSet(null, durationMs)) {
150+
logger.warn("Skipping trackDuration: duration already recorded on this graph tracker.");
151+
return;
152+
}
153+
client.trackMetric(GRAPH_DURATION_TOTAL, context, baseData().build(), durationMs);
154+
}
155+
156+
/**
157+
* Records the total token usage for the graph invocation.
158+
* <p>
159+
* At-most-once: subsequent calls are silently dropped.
160+
*
161+
* @param tokens the token usage; ignored if {@code null}
162+
*/
163+
public void trackTotalTokens(TokenUsage tokens) {
164+
if (tokens == null) {
165+
logger.debug("Skipping trackTotalTokens: tokens was null.");
166+
return;
167+
}
168+
if (!tokensRecorded.compareAndSet(null, tokens)) {
169+
logger.warn("Skipping trackTotalTokens: token usage already recorded on this graph tracker.");
170+
return;
171+
}
172+
client.trackMetric(GRAPH_TOTAL_TOKENS, context, baseData().build(), tokens.getTotal());
173+
}
174+
175+
/**
176+
* Records the ordered path of node keys visited during the graph invocation.
177+
* <p>
178+
* At-most-once: subsequent calls on the same tracker are silently dropped.
179+
*
180+
* @param path the ordered list of node keys; ignored if {@code null} or empty
181+
*/
182+
public void trackPath(List<String> path) {
183+
if (path == null || path.isEmpty()) {
184+
logger.debug("Skipping trackPath: path was null or empty.");
185+
return;
186+
}
187+
List<String> snapshot = Collections.unmodifiableList(new ArrayList<>(path));
188+
if (!pathRecorded.compareAndSet(null, snapshot)) {
189+
logger.warn("Skipping trackPath: path already recorded on this graph tracker.");
190+
return;
191+
}
192+
ArrayBuilder ab = LDValue.buildArray();
193+
for (String s : path) {
194+
ab.add(LDValue.of(s));
195+
}
196+
LDValue data = baseData().put("path", ab.build()).build();
197+
client.trackMetric(GRAPH_PATH, context, data, 1);
198+
}
199+
200+
/**
201+
* Records a redirect event, where the graph transitioned from one node to a different target
202+
* than the edge originally specified.
203+
* <p>
204+
* Multi-fire: every call emits an event.
205+
*
206+
* @param sourceKey the key of the source node
207+
* @param redirectedTarget the key of the node that was actually used
208+
*/
209+
public void trackRedirect(String sourceKey, String redirectedTarget) {
210+
if (isNullOrBlank(sourceKey) || isNullOrBlank(redirectedTarget)) {
211+
logger.debug("Skipping trackRedirect: sourceKey or redirectedTarget was null or blank.");
212+
return;
213+
}
214+
LDValue data = baseData()
215+
.put("sourceKey", sourceKey)
216+
.put("redirectedTarget", redirectedTarget)
217+
.build();
218+
client.trackMetric(GRAPH_REDIRECT, context, data, 1);
219+
}
220+
221+
/**
222+
* Records a successful handoff from one node to another.
223+
* <p>
224+
* Multi-fire: every call emits an event.
225+
*
226+
* @param sourceKey the key of the source node
227+
* @param targetKey the key of the target node
228+
*/
229+
public void trackHandoffSuccess(String sourceKey, String targetKey) {
230+
if (isNullOrBlank(sourceKey) || isNullOrBlank(targetKey)) {
231+
logger.debug("Skipping trackHandoffSuccess: sourceKey or targetKey was null or blank.");
232+
return;
233+
}
234+
LDValue data = baseData()
235+
.put("sourceKey", sourceKey)
236+
.put("targetKey", targetKey)
237+
.build();
238+
client.trackMetric(GRAPH_HANDOFF_SUCCESS, context, data, 1);
239+
}
240+
241+
/**
242+
* Records a failed handoff from one node to another.
243+
* <p>
244+
* Multi-fire: every call emits an event.
245+
*
246+
* @param sourceKey the key of the source node
247+
* @param targetKey the key of the target node
248+
*/
249+
public void trackHandoffFailure(String sourceKey, String targetKey) {
250+
if (isNullOrBlank(sourceKey) || isNullOrBlank(targetKey)) {
251+
logger.debug("Skipping trackHandoffFailure: sourceKey or targetKey was null or blank.");
252+
return;
253+
}
254+
LDValue data = baseData()
255+
.put("sourceKey", sourceKey)
256+
.put("targetKey", targetKey)
257+
.build();
258+
client.trackMetric(GRAPH_HANDOFF_FAILURE, context, data, 1);
259+
}
260+
261+
/**
262+
* Returns a snapshot of all graph-level metrics tracked so far on this tracker.
263+
*
264+
* @return the metric summary; never {@code null}
265+
*/
266+
public AIGraphMetricSummary getSummary() {
267+
return new AIGraphMetricSummary(
268+
invocationRecorded.get(),
269+
durationRecorded.get(),
270+
tokensRecorded.get(),
271+
pathRecorded.get(),
272+
resumptionToken);
273+
}
274+
275+
/**
276+
* Returns the resumption token for this graph run.
277+
* <p>
278+
* The token encodes the run identity and can be passed to
279+
* {@link LDAIClient#createGraphTracker(String, LDContext)} to reconstruct the tracker across
280+
* requests.
281+
*
282+
* @return the resumption token; never {@code null}
283+
*/
284+
public String getResumptionToken() {
285+
return resumptionToken;
286+
}
287+
288+
private ObjectBuilder baseData() {
289+
ObjectBuilder b = LDValue.buildObject()
290+
.put("runId", runId)
291+
.put("graphKey", graphKey)
292+
.put("version", version);
293+
if (variationKey != null) {
294+
b.put("variationKey", variationKey);
295+
}
296+
return b;
297+
}
298+
299+
private static boolean isNullOrBlank(String value) {
300+
return value == null || value.trim().isEmpty();
301+
}
302+
303+
}

0 commit comments

Comments
 (0)