Skip to content

Commit 23f1c50

Browse files
committed
fix: align agent graph tracking (tokens, tracker, edges, logger) with SDK spec
1 parent c2cfbe0 commit 23f1c50

7 files changed

Lines changed: 70 additions & 52 deletions

File tree

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AIGraphTracker.java

Lines changed: 7 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
import com.launchdarkly.sdk.LDValue;
77
import com.launchdarkly.sdk.ObjectBuilder;
88
import com.launchdarkly.sdk.server.ai.datamodel.LDAITrackingTypes.TokenUsage;
9-
import com.launchdarkly.sdk.server.ai.internal.Loggers;
109
import com.launchdarkly.sdk.server.ai.internal.ResumptionTokens;
1110
import com.launchdarkly.sdk.server.interfaces.LDClientInterface;
1211

@@ -83,32 +82,15 @@ public final class AIGraphTracker {
8382
this.resumptionToken = ResumptionTokens.encodeGraph(runId, graphKey, variationKey, version);
8483
}
8584

86-
/**
87-
* Reconstructs a graph tracker from a resumption token, preserving the original run identity.
88-
*
89-
* @param token the resumption token produced by {@link #getResumptionToken()}
90-
* @param client the LaunchDarkly client; must not be {@code null}
91-
* @param context the evaluation context; must not be {@code null}
92-
* @return a new tracker with the decoded run identity
93-
* @throws IllegalArgumentException if the token is malformed
94-
*/
95-
public static AIGraphTracker fromResumptionToken(
96-
String token, LDClientInterface client, LDContext context) {
97-
return fromResumptionToken(token, client, context, Loggers.defaultLogger());
98-
}
99-
10085
/**
10186
* Reconstructs a graph tracker from a resumption token, preserving the original run identity,
10287
* and logging through the supplied logger.
103-
*
104-
* @param token the resumption token produced by {@link #getResumptionToken()}
105-
* @param client the LaunchDarkly client; must not be {@code null}
106-
* @param context the evaluation context; must not be {@code null}
107-
* @param logger the logger to use for at-most-once warnings; must not be {@code null}
108-
* @return a new tracker with the decoded run identity
109-
* @throws IllegalArgumentException if the token is malformed
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.
11092
*/
111-
public static AIGraphTracker fromResumptionToken(
93+
static AIGraphTracker fromResumptionToken(
11294
String token, LDClientInterface client, LDContext context, LDLogger logger) {
11395
ResumptionTokens.DecodedGraph d = ResumptionTokens.decodeGraph(token);
11496
int version = d.getVersion();
@@ -174,8 +156,7 @@ public void trackDuration(double durationMs) {
174156
/**
175157
* Records the total token usage for the graph invocation.
176158
* <p>
177-
* At-most-once: subsequent calls are silently dropped. Calls where all counts are zero do not
178-
* consume the at-most-once slot.
159+
* At-most-once: subsequent calls are silently dropped.
179160
*
180161
* @param tokens the token usage; ignored if {@code null}
181162
*/
@@ -184,17 +165,11 @@ public void trackTotalTokens(TokenUsage tokens) {
184165
logger.debug("Skipping trackTotalTokens: tokens was null.");
185166
return;
186167
}
187-
boolean hasPositive = tokens.getTotal() > 0 || tokens.getInput() > 0 || tokens.getOutput() > 0;
188-
if (!hasPositive) {
189-
return;
190-
}
191168
if (!tokensRecorded.compareAndSet(null, tokens)) {
192169
logger.warn("Skipping trackTotalTokens: token usage already recorded on this graph tracker.");
193170
return;
194171
}
195-
if (tokens.getTotal() > 0) {
196-
client.trackMetric(GRAPH_TOTAL_TOKENS, context, baseData().build(), tokens.getTotal());
197-
}
172+
client.trackMetric(GRAPH_TOTAL_TOKENS, context, baseData().build(), tokens.getTotal());
198173
}
199174

200175
/**

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinition.java

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@
1919
* <p>
2020
* An {@code AgentGraphDefinition} is obtained from {@link LDAIClient#agentGraph}. When
2121
* {@link #isEnabled()} returns {@code false}, the graph definition was not fetchable or failed
22-
* validation; in that case all node collections are empty and traversal methods are no-ops.
22+
* validation; in that case all node collections are empty and traversal methods are no-ops. Only
23+
* {@link #getConfig()} and {@link #createTracker()} remain meaningful, so callers can still inspect
24+
* the raw flag value and fire graph-level usage events for a disabled graph.
2325
* <p>
2426
* Traversal methods ({@link #traverse} and {@link #reverseTraverse}) are BFS-based and
2527
* cycle-safe: each node is visited at most once.
@@ -142,13 +144,14 @@ AgentGraphFlagValue getConfig() {
142144
/**
143145
* Creates a new {@link AIGraphTracker} for this graph invocation.
144146
* <p>
145-
* Each call produces a fresh tracker with a new run ID. Returns {@code null} if the graph is
146-
* disabled.
147+
* Each call produces a fresh tracker with a new run ID. A tracker is returned even when the
148+
* graph is disabled, so callers can still fire graph-level usage events (e.g. invocation
149+
* failure) when the graph's configuration could not be resolved.
147150
*
148-
* @return a new tracker, or {@code null} if disabled
151+
* @return a new tracker, or {@code null} if no tracker factory was provided
149152
*/
150153
public AIGraphTracker createTracker() {
151-
if (!enabled || trackerFactory == null) {
154+
if (trackerFactory == null) {
152155
return null;
153156
}
154157
return trackerFactory.get();

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/AgentGraphNode.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.launchdarkly.sdk.server.ai;
22

3+
import java.util.ArrayList;
34
import java.util.Collections;
45
import java.util.List;
56

@@ -18,7 +19,9 @@ public final class AgentGraphNode {
1819
AgentGraphNode(String key, AIAgentConfig config, List<GraphEdge> edges) {
1920
this.key = key;
2021
this.config = config;
21-
this.edges = edges == null ? Collections.<GraphEdge>emptyList() : edges;
22+
this.edges = edges == null
23+
? Collections.<GraphEdge>emptyList()
24+
: Collections.unmodifiableList(new ArrayList<>(edges));
2225
}
2326

2427
/**

lib/sdk/server-ai/src/main/java/com/launchdarkly/sdk/server/ai/LDAIClientImpl.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,10 @@ private Supplier<LDAIConfigTracker> trackerFactory(
368368
@Override
369369
public AgentGraphDefinition agentGraph(
370370
String graphKey, LDContext context, Map<String, Object> variables) {
371+
Objects.requireNonNull(graphKey, "graphKey");
372+
if (graphKey.trim().isEmpty()) {
373+
throw new IllegalArgumentException("graphKey must not be blank");
374+
}
371375
client.trackMetric(TRACK_USAGE_AGENT_GRAPH, context, LDValue.of(graphKey), 1);
372376

373377
LDValue flagValue = client.jsonValueVariation(graphKey, context, LDValue.ofNull());

lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AIGraphTrackerTest.java

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -176,22 +176,29 @@ public void trackTotalTokensIsAtMostOnce() {
176176
}
177177

178178
@Test
179-
public void trackTotalTokensAllZeroDoesNotBurnSlot() {
180-
tracker.trackTotalTokens(new TokenUsage(0, 0, 0));
179+
public void trackTotalTokensNullIsIgnored() {
180+
tracker.trackTotalTokens(null);
181181
verify(client, never()).trackMetric(
182182
eq("$ld:ai:graph:total_tokens"), any(), any(), anyDouble());
183-
// Slot not consumed — a subsequent non-zero call should fire
184-
tracker.trackTotalTokens(new TokenUsage(5, 5, 0));
185-
verify(client, times(1)).trackMetric(
186-
eq("$ld:ai:graph:total_tokens"), any(), any(), anyDouble());
183+
assertThat(debugs().stream().anyMatch(w -> w.contains("tokens was null")), is(true));
187184
}
188185

189186
@Test
190-
public void trackTotalTokensNullIsIgnored() {
191-
tracker.trackTotalTokens(null);
192-
verify(client, never()).trackMetric(
187+
public void trackTotalTokensEmitsRawTotalIgnoringInputOutput() {
188+
// total=0 with positive input/output: emits 0, not input+output
189+
tracker.trackTotalTokens(new TokenUsage(0, 5, 3));
190+
verify(client).trackMetric(
191+
eq("$ld:ai:graph:total_tokens"), eq(CONTEXT), eq(baseExpectedData()), eq(0.0));
192+
}
193+
194+
@Test
195+
public void trackTotalTokensSlotConsumedEvenForZeroTotal() {
196+
tracker.trackTotalTokens(new TokenUsage(0, 0, 0));
197+
// slot already consumed; second call should be dropped with a warning
198+
tracker.trackTotalTokens(new TokenUsage(10, 5, 5));
199+
verify(client, times(1)).trackMetric(
193200
eq("$ld:ai:graph:total_tokens"), any(), any(), anyDouble());
194-
assertThat(debugs().stream().anyMatch(w -> w.contains("tokens was null")), is(true));
201+
assertThat(warnings().stream().anyMatch(w -> w.contains("token usage already recorded")), is(true));
195202
}
196203

197204
// ---- trackPath ------------------------------------------------------------
@@ -366,14 +373,14 @@ public void getResumptionTokenIsNotNull() {
366373
@Test
367374
public void fromResumptionTokenRoundTrips() {
368375
String token = tracker.getResumptionToken();
369-
AIGraphTracker reconstructed = AIGraphTracker.fromResumptionToken(token, client, CONTEXT);
376+
AIGraphTracker reconstructed = AIGraphTracker.fromResumptionToken(token, client, CONTEXT, logger);
370377
assertThat(reconstructed.getResumptionToken(), is(token));
371378
}
372379

373380
@Test
374381
public void fromResumptionTokenPreservesRunId() {
375382
String token = tracker.getResumptionToken();
376-
AIGraphTracker reconstructed = AIGraphTracker.fromResumptionToken(token, client, CONTEXT);
383+
AIGraphTracker reconstructed = AIGraphTracker.fromResumptionToken(token, client, CONTEXT, logger);
377384
// Verify same events are emitted by the reconstructed tracker
378385
reconstructed.trackInvocationSuccess();
379386
ArgumentCaptor<LDValue> captor = ArgumentCaptor.forClass(LDValue.class);
@@ -404,7 +411,7 @@ public void fromResumptionTokenWithLoggerRoutesWarningsThroughIt() {
404411
public void fromResumptionTokenPreservesVersionZero() {
405412
String token = com.launchdarkly.sdk.server.ai.internal.ResumptionTokens.encodeGraph(
406413
RUN_ID, GRAPH_KEY, null, 0);
407-
AIGraphTracker reconstructed = AIGraphTracker.fromResumptionToken(token, client, CONTEXT);
414+
AIGraphTracker reconstructed = AIGraphTracker.fromResumptionToken(token, client, CONTEXT, logger);
408415
reconstructed.trackInvocationSuccess();
409416
ArgumentCaptor<LDValue> captor = ArgumentCaptor.forClass(LDValue.class);
410417
verify(client).trackMetric(eq("$ld:ai:graph:invocation_success"), any(), captor.capture(), anyDouble());

lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/AgentGraphDefinitionTest.java

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ private static AgentGraphFlagValue flagValue(String root, String[][] edges) {
6363

6464
private static AIAgentConfig makeConfig(String key, boolean enabled) {
6565
return new AIAgentConfig(key, enabled, null, null, null, null, null,
66-
() -> mock(com.launchdarkly.sdk.server.ai.LDAIConfigTracker.class));
66+
() -> mock(com.launchdarkly.sdk.server.ai.LDAIConfigTracker.class),
67+
Evaluator.noop());
6768
}
6869

6970
private static Map<String, AIAgentConfig> configs(String... keys) {
@@ -264,11 +265,26 @@ public void isEnabledReflectsConstructorValue() {
264265

265266
@Test
266267
public void createTrackerReturnsNullWhenDisabled() {
268+
// A null factory is the only case that returns null (defensive guard).
267269
AgentGraphDefinition graph = new AgentGraphDefinition(
268270
AgentGraphFlagValue.disabled(), Collections.emptyMap(), false, null);
269271
assertThat(graph.createTracker(), is(nullValue()));
270272
}
271273

274+
@Test
275+
public void createTrackerReturnsTrackerEvenWhenDisabled() {
276+
// Disabled graphs still produce a tracker so callers can fire graph-level usage events
277+
// (e.g. invocation failure) when the graph's configuration could not be resolved.
278+
LDClientInterface client = mock(LDClientInterface.class);
279+
AgentGraphDefinition graph = new AgentGraphDefinition(
280+
AgentGraphFlagValue.disabled(), Collections.emptyMap(), false,
281+
() -> new AIGraphTracker(client, "run-id", "graph-key", null, 1,
282+
com.launchdarkly.sdk.LDContext.create("user"),
283+
com.launchdarkly.logging.LDLogger.withAdapter(
284+
com.launchdarkly.logging.Logs.none(), "")));
285+
assertThat(graph.createTracker(), is(notNullValue()));
286+
}
287+
272288
@Test
273289
public void createTrackerReturnsTrackerWhenEnabled() {
274290
LDClientInterface client = mock(LDClientInterface.class);

lib/sdk/server-ai/src/test/java/com/launchdarkly/sdk/server/ai/LDAIClientImplTest.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,16 @@ private static LDValue agentFlagValue(boolean enabled) {
333333
+ "\"instructions\":\"test instructions\"}");
334334
}
335335

336+
@Test(expected = NullPointerException.class)
337+
public void agentGraphThrowsOnNullGraphKey() {
338+
ai.agentGraph(null, context, null);
339+
}
340+
341+
@Test(expected = IllegalArgumentException.class)
342+
public void agentGraphThrowsOnBlankGraphKey() {
343+
ai.agentGraph(" ", context, null);
344+
}
345+
336346
@Test
337347
public void agentGraphFiresUsageEvent() {
338348
when(client.jsonValueVariation(eq("g"), any(), any()))

0 commit comments

Comments
 (0)