Skip to content

Commit f8fd3a8

Browse files
jsonbaileyclaude
andcommitted
feat: migrate ManagedAgentGraph to new Runner protocol
ManagedAgentGraph now expects AgentGraphRunnerResult (with GraphMetrics) from all runners and drives graph-level and per-node LD tracking from result.metrics. AgentGraphRunner protocol updated to return AgentGraphRunnerResult. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6dc30f0 commit f8fd3a8

4 files changed

Lines changed: 162 additions & 100 deletions

File tree

packages/sdk/server-ai/src/ldai/managed_agent_graph.py

Lines changed: 54 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@
22

33
from typing import Any, Optional
44

5-
from ldai.providers import AgentGraphResult, AgentGraphRunner
5+
from ldai.providers import AgentGraphRunner
66
from ldai.providers.types import (
77
AgentGraphRunnerResult,
88
GraphMetricSummary,
9-
LDAIMetrics,
109
ManagedGraphResult,
1110
)
1211

@@ -15,19 +14,9 @@ class ManagedAgentGraph:
1514
"""
1615
LaunchDarkly managed wrapper for AI agent graph execution.
1716
18-
Holds an AgentGraphRunner and an optional AgentGraphDefinition. Wraps the
19-
runner result in a :class:`~ldai.providers.types.ManagedGraphResult` and
20-
builds a :class:`~ldai.providers.types.GraphMetricSummary` from the runner's
21-
metrics.
22-
23-
When the runner returns an :class:`~ldai.providers.types.AgentGraphRunnerResult`
24-
(new shape), the managed layer drives all graph-level tracking from
25-
``result.metrics``. When the runner returns the legacy
26-
:class:`~ldai.providers.AgentGraphResult`, tracking has already been performed
27-
inside the runner; the managed layer simply wraps the result. This detection
28-
branch exists as a deliberate bridge: once PR 11-openai and PR 11-langchain
29-
migrate both runners to return ``AgentGraphRunnerResult``, the legacy branch
30-
becomes dead code and will be removed in PR 11-langchain's final cleanup commit.
17+
Holds an AgentGraphRunner and an AgentGraphDefinition. Delegates execution
18+
to the runner, then drives all graph-level and per-node tracking from the
19+
returned :class:`~ldai.providers.types.AgentGraphRunnerResult`.
3120
3221
Obtain an instance via ``LDAIClient.create_agent_graph()``.
3322
"""
@@ -41,10 +30,8 @@ def __init__(
4130
Initialize ManagedAgentGraph.
4231
4332
:param runner: The AgentGraphRunner to delegate execution to
44-
:param graph: Optional AgentGraphDefinition used to create the
45-
graph-level tracker when the runner returns an
46-
:class:`AgentGraphRunnerResult` (new shape). Not needed for
47-
legacy runners that still return :class:`AgentGraphResult`.
33+
:param graph: Optional AgentGraphDefinition used to drive graph-level and
34+
per-node tracking from the runner result metrics.
4835
"""
4936
self._runner = runner
5037
self._graph = graph
@@ -53,50 +40,31 @@ async def run(self, input: Any) -> ManagedGraphResult:
5340
"""
5441
Run the agent graph with the given input.
5542
56-
Delegates to the underlying AgentGraphRunner. The returned type
57-
determines which tracking path is taken:
43+
Delegates to the underlying AgentGraphRunner, then drives all
44+
LaunchDarkly tracking from ``result.metrics``:
5845
59-
- :class:`AgentGraphRunnerResult` (new shape): the managed layer drives
60-
graph-level tracking from ``result.metrics`` via the graph tracker.
61-
Per-node tracking from ``result.metrics.node_metrics`` will be wired
62-
in a follow-up commit once the runners populate ``node_metrics``.
63-
- :class:`AgentGraphResult` (legacy shape): tracking already occurred
64-
inside the runner; the managed layer wraps the result without
65-
additional tracking.
46+
- Graph-level events (path, duration, success/failure, total tokens) via
47+
the graph tracker obtained from the graph definition.
48+
- Per-node events (tokens, duration, tool calls, success) via per-node
49+
trackers for each key present in ``result.metrics.node_metrics``.
6650
6751
:param input: The input prompt or structured input for the graph
6852
:return: ManagedGraphResult containing the content, metric summary,
69-
raw response, and an optional evaluations task (always ``None``
70-
for now — per-graph evaluations will be added in a future PR).
53+
and raw response.
7154
"""
72-
raw_result = await self._runner.run(input)
73-
74-
if isinstance(raw_result, AgentGraphRunnerResult):
75-
# New shape: managed layer drives all tracking.
76-
summary = self._build_summary_from_runner_result(raw_result)
77-
if self._graph is not None:
78-
self._flush_graph_tracking(raw_result, self._graph.create_tracker())
79-
return ManagedGraphResult(
80-
content=raw_result.content,
81-
metrics=summary,
82-
raw=raw_result.raw,
83-
evaluations=None,
84-
)
85-
86-
# Legacy shape (AgentGraphResult): tracking already happened in the runner.
87-
# Build a GraphMetricSummary from the runner result's LDAIMetrics.
88-
# path and node_metrics will be populated once graph runners are migrated
89-
# to return AgentGraphRunnerResult with GraphMetrics (PR 11-openai/langchain).
90-
metrics: LDAIMetrics = raw_result.metrics
91-
summary = GraphMetricSummary(
92-
success=metrics.success,
93-
usage=metrics.usage,
94-
duration_ms=getattr(metrics, 'duration_ms', None),
95-
)
55+
result = await self._runner.run(input)
56+
57+
summary = self._build_summary_from_runner_result(result)
58+
59+
if self._graph is not None:
60+
graph_tracker = self._graph.create_tracker()
61+
self._flush_graph_tracking(result, graph_tracker)
62+
self._flush_node_tracking(result)
63+
9664
return ManagedGraphResult(
97-
content=raw_result.output,
65+
content=result.content,
9866
metrics=summary,
99-
raw=raw_result.raw,
67+
raw=result.raw,
10068
evaluations=None,
10169
)
10270

@@ -117,10 +85,6 @@ def _build_summary_from_runner_result(
11785
def _flush_graph_tracking(self, result: AgentGraphRunnerResult, tracker: Any) -> None:
11886
"""
11987
Drive graph-level LaunchDarkly tracking events from runner result metrics.
120-
121-
Called only when the runner returns the new ``AgentGraphRunnerResult``
122-
shape. Node-level tracking (from ``result.metrics.node_metrics``) will
123-
be wired once the runners start populating that field.
12488
"""
12589
m = result.metrics
12690
if m.path:
@@ -134,6 +98,36 @@ def _flush_graph_tracking(self, result: AgentGraphRunnerResult, tracker: Any) ->
13498
if m.usage is not None:
13599
tracker.track_total_tokens(m.usage)
136100

101+
def _flush_node_tracking(self, result: AgentGraphRunnerResult) -> None:
102+
"""
103+
Drive per-node LaunchDarkly tracking events from ``result.metrics.node_metrics``.
104+
105+
For each node key present in ``node_metrics``, obtains the node's
106+
config tracker via the graph definition and fires token, duration,
107+
tool call, and success/error events.
108+
"""
109+
if self._graph is None:
110+
return
111+
112+
for node_key, node_ldai_metrics in result.metrics.node_metrics.items():
113+
node = self._graph.get_node(node_key)
114+
if node is None:
115+
continue
116+
node_tracker = node.get_config().create_tracker()
117+
if node_tracker is None:
118+
continue
119+
120+
if node_ldai_metrics.usage is not None:
121+
node_tracker.track_tokens(node_ldai_metrics.usage)
122+
if node_ldai_metrics.duration_ms is not None:
123+
node_tracker.track_duration(node_ldai_metrics.duration_ms)
124+
if node_ldai_metrics.tool_calls:
125+
node_tracker.track_tool_calls(node_ldai_metrics.tool_calls)
126+
if node_ldai_metrics.success:
127+
node_tracker.track_success()
128+
else:
129+
node_tracker.track_error()
130+
137131
def get_agent_graph_runner(self) -> AgentGraphRunner:
138132
"""
139133
Return the underlying AgentGraphRunner for advanced use.

packages/sdk/server-ai/src/ldai/providers/agent_graph_runner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from typing import Any, Protocol, runtime_checkable
22

3-
from ldai.providers.types import AgentGraphResult
3+
from ldai.providers.types import AgentGraphRunnerResult
44

55

66
@runtime_checkable
@@ -18,11 +18,11 @@ class AgentGraphRunner(Protocol):
1818
the caller just passes input.
1919
"""
2020

21-
async def run(self, input: Any) -> AgentGraphResult:
21+
async def run(self, input: Any) -> AgentGraphRunnerResult:
2222
"""
2323
Run the agent graph with the given input.
2424
2525
:param input: The input to the agent graph (string prompt or structured input)
26-
:return: AgentGraphResult containing the output, raw response, and metrics
26+
:return: AgentGraphRunnerResult containing the content, raw response, and GraphMetrics
2727
"""
2828
...

packages/sdk/server-ai/tests/test_managed_agent_graph.py

Lines changed: 93 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,27 @@
77

88
from ldai import LDAIClient, ManagedAgentGraph, ManagedGraphResult
99
from ldai.providers.types import AgentGraphRunnerResult, GraphMetrics, LDAIMetrics
10-
from ldai.providers import AgentGraphResult, AgentGraphRunner, ToolRegistry
10+
from ldai.providers import AgentGraphRunner, ToolRegistry
1111
from ldai.tracker import TokenUsage
1212

1313

1414
# --- Test doubles ---
1515

1616
class StubAgentGraphRunner(AgentGraphRunner):
17-
"""Legacy runner that returns AgentGraphResult (old shape)."""
18-
def __init__(self, output: str = "stub output"):
19-
self._output = output
17+
"""Runner that returns AgentGraphRunnerResult (new shape)."""
18+
def __init__(self, content: str = "stub output"):
19+
self._content = content
2020

21-
async def run(self, input) -> AgentGraphResult:
22-
return AgentGraphResult(
23-
output=self._output,
21+
async def run(self, input) -> AgentGraphRunnerResult:
22+
return AgentGraphRunnerResult(
23+
content=self._content,
2424
raw={"input": input},
25-
metrics=LDAIMetrics(success=True),
25+
metrics=GraphMetrics(success=True),
2626
)
2727

2828

29-
class StubNewShapeRunner(AgentGraphRunner):
30-
"""New-shape runner that returns AgentGraphRunnerResult."""
29+
class StubRunnerWithMetrics(AgentGraphRunner):
30+
"""Runner that returns AgentGraphRunnerResult with full GraphMetrics."""
3131
def __init__(self, content: str = "new shape output"):
3232
self._content = content
3333

@@ -39,17 +39,28 @@ async def run(self, input) -> AgentGraphRunnerResult:
3939
path=["root", "specialist"],
4040
duration_ms=42,
4141
usage=TokenUsage(total=10, input=5, output=5),
42-
node_metrics={},
42+
node_metrics={
43+
"root": LDAIMetrics(
44+
success=True,
45+
usage=TokenUsage(total=5, input=3, output=2),
46+
duration_ms=20,
47+
),
48+
"specialist": LDAIMetrics(
49+
success=True,
50+
usage=TokenUsage(total=5, input=2, output=3),
51+
duration_ms=22,
52+
),
53+
},
4354
),
4455
raw={"input": input},
4556
)
4657

4758

48-
# --- ManagedAgentGraph unit tests (legacy shape) ---
59+
# --- ManagedAgentGraph unit tests ---
4960

5061
@pytest.mark.asyncio
5162
async def test_managed_agent_graph_run_delegates_to_runner():
52-
"""Legacy AgentGraphResult shape: content comes from output field."""
63+
"""Runner result content is surfaced correctly."""
5364
runner = StubAgentGraphRunner("hello world")
5465
managed = ManagedAgentGraph(runner)
5566
result = await managed.run("test input")
@@ -64,15 +75,14 @@ def test_managed_agent_graph_get_runner():
6475
assert managed.get_agent_graph_runner() is runner
6576

6677

67-
# --- ManagedAgentGraph unit tests (new AgentGraphRunnerResult shape) ---
68-
6978
@pytest.mark.asyncio
70-
async def test_managed_agent_graph_run_handles_new_shape():
71-
"""New AgentGraphRunnerResult shape: content and GraphMetrics are surfaced."""
72-
runner = StubNewShapeRunner("final answer")
79+
async def test_managed_agent_graph_run_surfaces_graph_metrics():
80+
"""GraphMetrics fields are reflected in GraphMetricSummary."""
81+
runner = StubRunnerWithMetrics("final answer")
7382
mock_graph = MagicMock()
7483
mock_tracker = MagicMock()
7584
mock_graph.create_tracker = MagicMock(return_value=mock_tracker)
85+
mock_graph.get_node = MagicMock(return_value=None) # no nodes for this test
7686

7787
managed = ManagedAgentGraph(runner, graph=mock_graph)
7888
result = await managed.run("test input")
@@ -87,12 +97,13 @@ async def test_managed_agent_graph_run_handles_new_shape():
8797

8898

8999
@pytest.mark.asyncio
90-
async def test_managed_agent_graph_new_shape_drives_tracking():
91-
"""New shape: managed layer calls tracker methods from result.metrics."""
92-
runner = StubNewShapeRunner()
100+
async def test_managed_agent_graph_drives_graph_level_tracking():
101+
"""Managed layer calls graph tracker methods from result.metrics."""
102+
runner = StubRunnerWithMetrics()
93103
mock_graph = MagicMock()
94104
mock_tracker = MagicMock()
95105
mock_graph.create_tracker = MagicMock(return_value=mock_tracker)
106+
mock_graph.get_node = MagicMock(return_value=None)
96107

97108
managed = ManagedAgentGraph(runner, graph=mock_graph)
98109
await managed.run("test input")
@@ -104,16 +115,73 @@ async def test_managed_agent_graph_new_shape_drives_tracking():
104115

105116

106117
@pytest.mark.asyncio
107-
async def test_managed_agent_graph_new_shape_no_graph_skips_tracking():
108-
"""New shape without graph: no tracking called (graph not available)."""
109-
runner = StubNewShapeRunner()
118+
async def test_managed_agent_graph_drives_per_node_tracking():
119+
"""Managed layer creates per-node trackers and fires node-level events."""
120+
runner = StubRunnerWithMetrics()
121+
mock_graph = MagicMock()
122+
mock_graph_tracker = MagicMock()
123+
mock_graph.create_tracker = MagicMock(return_value=mock_graph_tracker)
124+
125+
root_tracker = MagicMock()
126+
specialist_tracker = MagicMock()
127+
128+
root_node = MagicMock()
129+
root_node.get_config.return_value.create_tracker = MagicMock(return_value=root_tracker)
130+
specialist_node = MagicMock()
131+
specialist_node.get_config.return_value.create_tracker = MagicMock(return_value=specialist_tracker)
132+
133+
def get_node(key):
134+
return {"root": root_node, "specialist": specialist_node}.get(key)
135+
136+
mock_graph.get_node = get_node
137+
138+
managed = ManagedAgentGraph(runner, graph=mock_graph)
139+
await managed.run("test input")
140+
141+
# root node tracking
142+
root_tracker.track_tokens.assert_called_once()
143+
root_tracker.track_duration.assert_called_once_with(20)
144+
root_tracker.track_success.assert_called_once()
145+
146+
# specialist node tracking
147+
specialist_tracker.track_tokens.assert_called_once()
148+
specialist_tracker.track_duration.assert_called_once_with(22)
149+
specialist_tracker.track_success.assert_called_once()
150+
151+
152+
@pytest.mark.asyncio
153+
async def test_managed_agent_graph_no_graph_skips_tracking():
154+
"""Without a graph reference, no tracking is called but run succeeds."""
155+
runner = StubRunnerWithMetrics()
110156
managed = ManagedAgentGraph(runner, graph=None)
111-
# Should not raise even without a graph reference
112157
result = await managed.run("test input")
113158
assert result.content == "new shape output"
114159
assert result.metrics.success is True
115160

116161

162+
@pytest.mark.asyncio
163+
async def test_managed_agent_graph_failure_calls_track_invocation_failure():
164+
"""On a failed run, track_invocation_failure is called instead of success."""
165+
166+
class FailingRunner(AgentGraphRunner):
167+
async def run(self, input) -> AgentGraphRunnerResult:
168+
return AgentGraphRunnerResult(
169+
content='',
170+
raw=None,
171+
metrics=GraphMetrics(success=False, duration_ms=5),
172+
)
173+
174+
mock_graph = MagicMock()
175+
mock_tracker = MagicMock()
176+
mock_graph.create_tracker = MagicMock(return_value=mock_tracker)
177+
mock_graph.get_node = MagicMock(return_value=None)
178+
179+
managed = ManagedAgentGraph(FailingRunner(), graph=mock_graph)
180+
result = await managed.run("test input")
181+
182+
assert result.metrics.success is False
183+
mock_tracker.track_invocation_failure.assert_called_once()
184+
mock_tracker.track_invocation_success.assert_not_called()
117185

118186

119187
# --- LDAIClient.create_agent_graph() integration tests ---

0 commit comments

Comments
 (0)