Skip to content

Commit 6bbce5f

Browse files
committed
feat: add ManagedGraphResult, GraphMetricSummary, and ManagedAgentGraph (#1335)
1 parent fcadf29 commit 6bbce5f

4 files changed

Lines changed: 390 additions & 0 deletions

File tree

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { AgentGraphDefinition } from '../src/api/graph/AgentGraphDefinition';
2+
import { AgentGraphNode } from '../src/api/graph/AgentGraphNode';
3+
import { LDGraphTracker } from '../src/api/graph/LDGraphTracker';
4+
import { ManagedAgentGraph } from '../src/api/graph/ManagedAgentGraph';
5+
import { AgentGraphRunnerResult } from '../src/api/graph/types';
6+
import { LDAIConfigTracker } from '../src/api/config/LDAIConfigTracker';
7+
8+
const makeNodeTracker = (summary: Record<string, unknown> = {}): jest.Mocked<LDAIConfigTracker> =>
9+
({
10+
trackTokens: jest.fn(),
11+
trackDuration: jest.fn(),
12+
trackToolCalls: jest.fn(),
13+
trackSuccess: jest.fn(),
14+
trackError: jest.fn(),
15+
getSummary: jest.fn().mockReturnValue(summary),
16+
}) as any;
17+
18+
const makeNode = (tracker: jest.Mocked<LDAIConfigTracker>): AgentGraphNode =>
19+
({
20+
getConfig: jest.fn().mockReturnValue({ createTracker: jest.fn().mockReturnValue(tracker) }),
21+
}) as any;
22+
23+
describe('ManagedAgentGraph', () => {
24+
const mockGraphTracker: jest.Mocked<LDGraphTracker> = {
25+
getTrackData: jest.fn().mockReturnValue({ runId: 'r1', graphKey: 'g1', version: 1 }),
26+
getSummary: jest.fn().mockReturnValue({}),
27+
resumptionToken: 'graph-resumption-token',
28+
trackInvocationSuccess: jest.fn(),
29+
trackInvocationFailure: jest.fn(),
30+
trackDuration: jest.fn(),
31+
trackTotalTokens: jest.fn(),
32+
trackPath: jest.fn(),
33+
trackRedirect: jest.fn(),
34+
trackHandoffSuccess: jest.fn(),
35+
trackHandoffFailure: jest.fn(),
36+
};
37+
38+
let mockGraphDefinition: jest.Mocked<AgentGraphDefinition>;
39+
40+
beforeEach(() => {
41+
jest.clearAllMocks();
42+
mockGraphDefinition = {
43+
enabled: true,
44+
createTracker: jest.fn().mockReturnValue(mockGraphTracker),
45+
getConfig: jest.fn(),
46+
getNode: jest.fn().mockReturnValue(undefined),
47+
getChildNodes: jest.fn(),
48+
getParentNodes: jest.fn(),
49+
terminalNodes: jest.fn(),
50+
rootNode: jest.fn(),
51+
traverse: jest.fn(),
52+
reverseTraverse: jest.fn(),
53+
} as any;
54+
});
55+
56+
it('builds ManagedGraphResult from runner result', async () => {
57+
const nodeATracker = makeNodeTracker({ success: true, resumptionToken: 'node-a-token' });
58+
const nodeBTracker = makeNodeTracker({ success: true, resumptionToken: 'node-b-token' });
59+
mockGraphDefinition.getNode = jest
60+
.fn()
61+
.mockImplementation((key: string) =>
62+
key === 'node-a' ? makeNode(nodeATracker) : makeNode(nodeBTracker),
63+
);
64+
65+
const runnerResult: AgentGraphRunnerResult = {
66+
content: 'Graph output',
67+
metrics: {
68+
success: true,
69+
path: ['node-a', 'node-b'],
70+
durationMs: 1500,
71+
usage: { total: 100, input: 50, output: 50 },
72+
nodeMetrics: {
73+
'node-a': { success: true, usage: { total: 40, input: 20, output: 20 } },
74+
'node-b': { success: true, usage: { total: 60, input: 30, output: 30 } },
75+
},
76+
},
77+
};
78+
79+
const managedGraph = new ManagedAgentGraph(mockGraphDefinition);
80+
const result = await managedGraph.run(async (_def, _tracker) => runnerResult);
81+
82+
expect(result.content).toBe('Graph output');
83+
expect(result.metrics.success).toBe(true);
84+
expect(result.metrics.path).toEqual(['node-a', 'node-b']);
85+
expect(result.metrics.durationMs).toBe(1500);
86+
expect(result.metrics.tokens).toEqual({ total: 100, input: 50, output: 50 });
87+
expect(result.metrics.resumptionToken).toBe('graph-resumption-token');
88+
expect(result.metrics.nodeMetrics).toEqual({
89+
'node-a': { success: true, resumptionToken: 'node-a-token' },
90+
'node-b': { success: true, resumptionToken: 'node-b-token' },
91+
});
92+
});
93+
94+
it('fires tracking events into per-node trackers', async () => {
95+
const nodeTracker = makeNodeTracker({});
96+
mockGraphDefinition.getNode = jest.fn().mockReturnValue(makeNode(nodeTracker));
97+
98+
const runnerResult: AgentGraphRunnerResult = {
99+
content: 'out',
100+
metrics: {
101+
success: true,
102+
path: ['n1'],
103+
nodeMetrics: {
104+
n1: {
105+
success: true,
106+
usage: { total: 10, input: 5, output: 5 },
107+
durationMs: 200,
108+
toolCalls: ['tool-a'],
109+
},
110+
},
111+
},
112+
};
113+
114+
const managedGraph = new ManagedAgentGraph(mockGraphDefinition);
115+
await managedGraph.run(async () => runnerResult);
116+
117+
expect(nodeTracker.trackTokens).toHaveBeenCalledWith({ total: 10, input: 5, output: 5 });
118+
expect(nodeTracker.trackDuration).toHaveBeenCalledWith(200);
119+
expect(nodeTracker.trackToolCalls).toHaveBeenCalledWith(['tool-a']);
120+
expect(nodeTracker.trackSuccess).toHaveBeenCalled();
121+
expect(nodeTracker.getSummary).toHaveBeenCalled();
122+
});
123+
124+
it('calls trackError for failed nodes', async () => {
125+
const nodeTracker = makeNodeTracker({});
126+
mockGraphDefinition.getNode = jest.fn().mockReturnValue(makeNode(nodeTracker));
127+
128+
await new ManagedAgentGraph(mockGraphDefinition).run(async () => ({
129+
content: '',
130+
metrics: { success: false, path: [], nodeMetrics: { n1: { success: false } } },
131+
}));
132+
133+
expect(nodeTracker.trackError).toHaveBeenCalled();
134+
expect(nodeTracker.trackSuccess).not.toHaveBeenCalled();
135+
});
136+
137+
it('skips node metrics when getNode returns undefined', async () => {
138+
mockGraphDefinition.getNode = jest.fn().mockReturnValue(undefined);
139+
140+
const managedGraph = new ManagedAgentGraph(mockGraphDefinition);
141+
const result = await managedGraph.run(async () => ({
142+
content: '',
143+
metrics: {
144+
success: true,
145+
path: [],
146+
nodeMetrics: { missing: { success: true } },
147+
},
148+
}));
149+
150+
expect(result.metrics.nodeMetrics).toEqual({});
151+
});
152+
153+
it('passes graphDefinition and graphTracker to runner', async () => {
154+
const runnerFn = jest.fn().mockResolvedValue({
155+
content: 'output',
156+
metrics: { success: true, path: [], nodeMetrics: {} },
157+
});
158+
159+
await new ManagedAgentGraph(mockGraphDefinition).run(runnerFn);
160+
161+
expect(runnerFn).toHaveBeenCalledWith(mockGraphDefinition, mockGraphTracker);
162+
});
163+
164+
it('creates a tracker via graphDefinition.createTracker()', async () => {
165+
await new ManagedAgentGraph(mockGraphDefinition).run(async () => ({
166+
content: '',
167+
metrics: { success: true, path: [], nodeMetrics: {} },
168+
}));
169+
170+
expect(mockGraphDefinition.createTracker).toHaveBeenCalled();
171+
});
172+
173+
it('resolves to empty evaluations by default', async () => {
174+
const result = await new ManagedAgentGraph(mockGraphDefinition).run(async () => ({
175+
content: '',
176+
metrics: { success: true, path: [], nodeMetrics: {} },
177+
}));
178+
179+
expect(await result.evaluations).toEqual([]);
180+
});
181+
182+
it('returns the graph definition via getGraphDefinition', () => {
183+
expect(new ManagedAgentGraph(mockGraphDefinition).getGraphDefinition()).toBe(
184+
mockGraphDefinition,
185+
);
186+
});
187+
});
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { LDLogger } from '@launchdarkly/js-server-sdk-common';
2+
3+
import { LDAIMetrics } from '../metrics';
4+
import { LDAIMetricSummary } from '../model/types';
5+
import { LDJudgeResult } from '../judge/types';
6+
import { AgentGraphDefinition } from './AgentGraphDefinition';
7+
import { LDGraphTracker } from './LDGraphTracker';
8+
import { AgentGraphRunnerResult, GraphMetricSummary, ManagedGraphResult } from './types';
9+
10+
/**
11+
* ManagedAgentGraph wraps an AgentGraphDefinition and provides a managed run()
12+
* method that returns ManagedGraphResult with async judge evaluations.
13+
*
14+
* The runner function is responsible for executing the graph and returning
15+
* an AgentGraphRunnerResult. ManagedAgentGraph builds the managed result from
16+
* the runner result, including GraphMetricSummary with the graphTracker's
17+
* resumptionToken.
18+
*/
19+
export class ManagedAgentGraph {
20+
constructor(
21+
private readonly _graphDefinition: AgentGraphDefinition,
22+
private readonly _logger?: LDLogger,
23+
) {}
24+
25+
/**
26+
* Runs the agent graph using the provided runner function and returns a ManagedGraphResult.
27+
*
28+
* The runner function receives the graph tracker and AgentGraphDefinition,
29+
* executes the graph, and returns an AgentGraphRunnerResult.
30+
*
31+
* run() returns before ManagedGraphResult.evaluations resolves.
32+
*
33+
* @param runner Async function that executes the graph and returns AgentGraphRunnerResult.
34+
* @returns ManagedGraphResult with GraphMetricSummary and evaluations promise.
35+
*/
36+
async run(
37+
runner: (
38+
graphDefinition: AgentGraphDefinition,
39+
graphTracker: LDGraphTracker,
40+
) => Promise<AgentGraphRunnerResult>,
41+
): Promise<ManagedGraphResult> {
42+
const graphTracker = this._graphDefinition.createTracker();
43+
44+
const runnerResult = await runner(this._graphDefinition, graphTracker);
45+
46+
const metrics: GraphMetricSummary = {
47+
success: runnerResult.metrics.success,
48+
path: runnerResult.metrics.path,
49+
durationMs: runnerResult.metrics.durationMs,
50+
tokens: runnerResult.metrics.usage,
51+
nodeMetrics: this._buildNodeMetrics(runnerResult.metrics.nodeMetrics),
52+
resumptionToken: graphTracker.resumptionToken,
53+
};
54+
55+
const evaluations: Promise<LDJudgeResult[]> = Promise.resolve([]);
56+
57+
return {
58+
content: runnerResult.content,
59+
metrics,
60+
raw: runnerResult.raw,
61+
evaluations,
62+
};
63+
}
64+
65+
/**
66+
* Converts per-node LDAIMetrics from the runner into LDAIMetricSummary by
67+
* creating a per-node tracker, firing tracking events, and calling getSummary().
68+
*/
69+
private _buildNodeMetrics(
70+
nodeMetrics: Record<string, LDAIMetrics>,
71+
): Record<string, LDAIMetricSummary> {
72+
const summaries: Record<string, LDAIMetricSummary> = {};
73+
74+
for (const [nodeKey, metrics] of Object.entries(nodeMetrics)) {
75+
const node = this._graphDefinition.getNode(nodeKey);
76+
if (!node) {
77+
this._logger?.warn(`ManagedAgentGraph: no node found for key "${nodeKey}", skipping metrics`);
78+
continue;
79+
}
80+
81+
const tracker = node.getConfig().createTracker!();
82+
if (metrics.usage) {
83+
tracker.trackTokens(metrics.usage);
84+
}
85+
if (metrics.durationMs !== undefined) {
86+
tracker.trackDuration(metrics.durationMs);
87+
}
88+
if (metrics.toolCalls?.length) {
89+
tracker.trackToolCalls(metrics.toolCalls);
90+
}
91+
if (metrics.success) {
92+
tracker.trackSuccess();
93+
} else {
94+
tracker.trackError();
95+
}
96+
97+
summaries[nodeKey] = tracker.getSummary();
98+
}
99+
100+
return summaries;
101+
}
102+
103+
/**
104+
* Returns the underlying AgentGraphDefinition.
105+
*/
106+
getGraphDefinition(): AgentGraphDefinition {
107+
return this._graphDefinition;
108+
}
109+
}

packages/sdk/server-ai/src/api/graph/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './types';
22
export * from './LDGraphTracker';
33
export * from './AgentGraphNode';
44
export * from './AgentGraphDefinition';
5+
export * from './ManagedAgentGraph';

0 commit comments

Comments
 (0)