Skip to content

Commit c69a9ff

Browse files
jsonbaileyclaude
andcommitted
feat: Graph tracking refactor — ManagedAgentGraph drives tracking for new runner shape
ManagedAgentGraph.run() now detects the runner result type and dispatches accordingly: - AgentGraphRunnerResult (new shape): managed layer drives all graph-level tracking from result.metrics (path, duration, success/failure, total tokens) via the graph tracker. Node-level tracking from node_metrics will be wired once runners populate that field (PR 11-openai/langchain). - AgentGraphResult (legacy shape): tracking already occurred inside the runner; managed layer wraps result without additional tracking. ManagedAgentGraph now accepts an optional graph parameter (AgentGraphDefinition) used to create the graph tracker. LDAIClient.create_agent_graph() passes the resolved graph definition. This is a deliberate bridge pattern: the legacy detection branch will be removed once both runners are migrated. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a468a7f commit c69a9ff

3 files changed

Lines changed: 167 additions & 22 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -815,7 +815,7 @@ async def create_agent_graph(
815815
if not runner:
816816
return None
817817

818-
return ManagedAgentGraph(runner)
818+
return ManagedAgentGraph(runner, graph=graph)
819819

820820
def agents(
821821
self,

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

Lines changed: 91 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,139 @@
11
"""ManagedAgentGraph — LaunchDarkly managed wrapper for agent graph execution."""
22

3-
import asyncio
4-
from typing import Any, List
3+
from typing import Any, Optional
54

65
from ldai.providers import AgentGraphResult, AgentGraphRunner
7-
from ldai.providers.types import GraphMetricSummary, JudgeResult, ManagedGraphResult
6+
from ldai.providers.types import (
7+
AgentGraphRunnerResult,
8+
GraphMetricSummary,
9+
LDAIMetrics,
10+
ManagedGraphResult,
11+
)
812

913

1014
class ManagedAgentGraph:
1115
"""
1216
LaunchDarkly managed wrapper for AI agent graph execution.
1317
14-
Holds an AgentGraphRunner. Wraps the runner result in a
15-
:class:`~ldai.providers.types.ManagedGraphResult` and builds a
16-
:class:`~ldai.providers.types.GraphMetricSummary` from the runner's metrics.
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.
1731
1832
Obtain an instance via ``LDAIClient.create_agent_graph()``.
1933
"""
2034

2135
def __init__(
2236
self,
2337
runner: AgentGraphRunner,
38+
graph: Optional[Any] = None,
2439
):
2540
"""
2641
Initialize ManagedAgentGraph.
2742
2843
: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`.
2948
"""
3049
self._runner = runner
50+
self._graph = graph
3151

3252
async def run(self, input: Any) -> ManagedGraphResult:
3353
"""
3454
Run the agent graph with the given input.
3555
36-
Delegates to the underlying AgentGraphRunner, builds a
37-
:class:`GraphMetricSummary` from the result, and wraps everything in a
38-
:class:`ManagedGraphResult`.
56+
Delegates to the underlying AgentGraphRunner. The returned type
57+
determines which tracking path is taken:
58+
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.
3966
4067
:param input: The input prompt or structured input for the graph
41-
:return: ManagedGraphResult containing the content, metric summary, raw response,
42-
and an optional evaluations task (currently always ``None`` for graphs —
43-
per-graph evaluations will be added in a future PR).
68+
: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).
4471
"""
45-
result: AgentGraphResult = await self._runner.run(input)
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+
)
4685

86+
# Legacy shape (AgentGraphResult): tracking already happened in the runner.
4787
# Build a GraphMetricSummary from the runner result's LDAIMetrics.
4888
# path and node_metrics will be populated once graph runners are migrated
49-
# to return AgentGraphRunnerResult with GraphMetrics (PR 11).
50-
metrics = result.metrics
89+
# to return AgentGraphRunnerResult with GraphMetrics (PR 11-openai/langchain).
90+
metrics: LDAIMetrics = raw_result.metrics
5191
summary = GraphMetricSummary(
5292
success=metrics.success,
5393
usage=metrics.usage,
5494
duration_ms=getattr(metrics, 'duration_ms', None),
5595
)
56-
5796
return ManagedGraphResult(
58-
content=result.output,
97+
content=raw_result.output,
5998
metrics=summary,
60-
raw=result.raw,
99+
raw=raw_result.raw,
61100
evaluations=None,
62101
)
63102

103+
def _build_summary_from_runner_result(
104+
self,
105+
result: AgentGraphRunnerResult,
106+
) -> GraphMetricSummary:
107+
"""Build a GraphMetricSummary from an AgentGraphRunnerResult."""
108+
m = result.metrics
109+
return GraphMetricSummary(
110+
success=m.success,
111+
path=list(m.path),
112+
duration_ms=m.duration_ms,
113+
usage=m.usage,
114+
node_metrics=dict(m.node_metrics),
115+
)
116+
117+
def _flush_graph_tracking(self, result: AgentGraphRunnerResult, tracker: Any) -> None:
118+
"""
119+
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.
124+
"""
125+
m = result.metrics
126+
if m.path:
127+
tracker.track_path(m.path)
128+
if m.duration_ms is not None:
129+
tracker.track_duration(m.duration_ms)
130+
if m.success:
131+
tracker.track_invocation_success()
132+
else:
133+
tracker.track_invocation_failure()
134+
if m.usage is not None:
135+
tracker.track_total_tokens(m.usage)
136+
64137
def get_agent_graph_runner(self) -> AgentGraphRunner:
65138
"""
66139
Return the underlying AgentGraphRunner for advanced use.

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

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@
66
from ldclient.integrations.test_data import TestData
77

88
from ldai import LDAIClient, ManagedAgentGraph, ManagedGraphResult
9-
from ldai.providers.types import LDAIMetrics
9+
from ldai.providers.types import AgentGraphRunnerResult, GraphMetrics, LDAIMetrics
1010
from ldai.providers import AgentGraphResult, AgentGraphRunner, ToolRegistry
11+
from ldai.tracker import TokenUsage
1112

1213

13-
# --- Test double ---
14+
# --- Test doubles ---
1415

1516
class StubAgentGraphRunner(AgentGraphRunner):
17+
"""Legacy runner that returns AgentGraphResult (old shape)."""
1618
def __init__(self, output: str = "stub output"):
1719
self._output = output
1820

@@ -24,10 +26,30 @@ async def run(self, input) -> AgentGraphResult:
2426
)
2527

2628

27-
# --- ManagedAgentGraph unit tests ---
29+
class StubNewShapeRunner(AgentGraphRunner):
30+
"""New-shape runner that returns AgentGraphRunnerResult."""
31+
def __init__(self, content: str = "new shape output"):
32+
self._content = content
33+
34+
async def run(self, input) -> AgentGraphRunnerResult:
35+
return AgentGraphRunnerResult(
36+
content=self._content,
37+
metrics=GraphMetrics(
38+
success=True,
39+
path=["root", "specialist"],
40+
duration_ms=42,
41+
usage=TokenUsage(total=10, input=5, output=5),
42+
node_metrics={},
43+
),
44+
raw={"input": input},
45+
)
46+
47+
48+
# --- ManagedAgentGraph unit tests (legacy shape) ---
2849

2950
@pytest.mark.asyncio
3051
async def test_managed_agent_graph_run_delegates_to_runner():
52+
"""Legacy AgentGraphResult shape: content comes from output field."""
3153
runner = StubAgentGraphRunner("hello world")
3254
managed = ManagedAgentGraph(runner)
3355
result = await managed.run("test input")
@@ -42,6 +64,56 @@ def test_managed_agent_graph_get_runner():
4264
assert managed.get_agent_graph_runner() is runner
4365

4466

67+
# --- ManagedAgentGraph unit tests (new AgentGraphRunnerResult shape) ---
68+
69+
@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")
73+
mock_graph = MagicMock()
74+
mock_tracker = MagicMock()
75+
mock_graph.create_tracker = MagicMock(return_value=mock_tracker)
76+
77+
managed = ManagedAgentGraph(runner, graph=mock_graph)
78+
result = await managed.run("test input")
79+
80+
assert isinstance(result, ManagedGraphResult)
81+
assert result.content == "final answer"
82+
assert result.metrics.success is True
83+
assert result.metrics.path == ["root", "specialist"]
84+
assert result.metrics.duration_ms == 42
85+
assert result.metrics.usage is not None
86+
assert result.metrics.usage.total == 10
87+
88+
89+
@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()
93+
mock_graph = MagicMock()
94+
mock_tracker = MagicMock()
95+
mock_graph.create_tracker = MagicMock(return_value=mock_tracker)
96+
97+
managed = ManagedAgentGraph(runner, graph=mock_graph)
98+
await managed.run("test input")
99+
100+
mock_tracker.track_path.assert_called_once_with(["root", "specialist"])
101+
mock_tracker.track_duration.assert_called_once_with(42)
102+
mock_tracker.track_invocation_success.assert_called_once()
103+
mock_tracker.track_total_tokens.assert_called_once()
104+
105+
106+
@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()
110+
managed = ManagedAgentGraph(runner, graph=None)
111+
# Should not raise even without a graph reference
112+
result = await managed.run("test input")
113+
assert result.content == "new shape output"
114+
assert result.metrics.success is True
115+
116+
45117

46118

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

0 commit comments

Comments
 (0)