Skip to content

Commit 9ae20ca

Browse files
committed
agent graph support (first pass)
1 parent a0c8784 commit 9ae20ca

13 files changed

Lines changed: 2745 additions & 15 deletions

File tree

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

0 commit comments

Comments
 (0)