Skip to content

Commit 5313ce5

Browse files
jsonbaileyclaude
andcommitted
fix: Cache node trackers per execution to preserve runId correlation
_flush_final_segment and _track_tool_calls were each calling create_tracker() independently, generating new runIds that broke per-execution event correlation. Now build_node creates one tracker per node, cached in _node_trackers, and reused by all tracking methods. Adds test_same_run_id_across_token_success_and_tool_call_events to verify all node-level events for a single execution share one runId. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent fc814c6 commit 5313ce5

3 files changed

Lines changed: 47 additions & 12 deletions

File tree

packages/ai-providers/server-ai-openai/src/ldai_openai/openai_agent_graph_runner.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def __init__(self, graph: AgentGraphDefinition, tools: ToolRegistry):
5757
self._tools = tools
5858
self._agent_name_map: Dict[str, str] = {}
5959
self._tool_name_map: Dict[str, str] = {}
60+
self._node_trackers: Dict[str, Any] = {}
6061

6162
async def run(self, input: Any) -> AgentGraphResult:
6263
"""
@@ -145,10 +146,12 @@ def _build_agents(self, path: List[str], state: _RunState) -> Any:
145146
tracker = self._graph.create_tracker()
146147
name_map: Dict[str, str] = {}
147148
tool_name_map: Dict[str, str] = {}
149+
node_trackers: Dict[str, Any] = {}
148150

149151
def build_node(node: AgentGraphNode, ctx: dict) -> Any:
150152
node_config = node.get_config()
151153
config_tracker = node_config.create_tracker()
154+
node_trackers[node_config.key] = config_tracker
152155
model = node_config.model
153156

154157
if not model:
@@ -204,6 +207,7 @@ def build_node(node: AgentGraphNode, ctx: dict) -> Any:
204207
root = self._graph.reverse_traverse(fn=build_node)
205208
self._agent_name_map = name_map
206209
self._tool_name_map = tool_name_map
210+
self._node_trackers = node_trackers
207211
return root
208212

209213
def _make_on_handoff(
@@ -263,10 +267,7 @@ def _flush_final_segment(
263267
"""Record duration/tokens for the last active agent (no handoff after it)."""
264268
if not state.last_node_key:
265269
return
266-
node = self._graph.get_node(state.last_node_key)
267-
if node is None:
268-
return
269-
config_tracker = node.get_config().create_tracker()
270+
config_tracker = self._node_trackers.get(state.last_node_key)
270271
if config_tracker is None:
271272
return
272273

@@ -293,9 +294,6 @@ def _track_tool_calls(self, result: Any) -> None:
293294
tool_name = self._tool_name_map.get(tool_fn_name)
294295
if tool_name is None:
295296
continue
296-
node = self._graph.get_node(agent_key)
297-
if node is None:
298-
continue
299-
config_tracker = node.get_config().create_tracker()
297+
config_tracker = self._node_trackers.get(agent_key)
300298
if config_tracker is not None:
301299
config_tracker.track_tool_call(tool_name)

packages/ai-providers/server-ai-openai/tests/test_openai_agent_graph_runner.py

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,10 @@ async def test_openai_agent_graph_runner_run_success():
138138
tracker.track_path.assert_called_once()
139139
tracker.track_latency.assert_called_once()
140140

141-
root_tracker = graph.get_node('root-agent').get_config().create_tracker()
142-
root_tracker.track_duration.assert_called_once()
143-
root_tracker.track_tokens.assert_called_once()
144-
root_tracker.track_success.assert_called_once()
141+
# The runner caches one tracker per node — verify it is the same instance
142+
# returned by create_tracker() and that all tracking calls hit it.
143+
cached = runner._node_trackers['root-agent']
144+
assert cached is graph.get_node('root-agent').get_config().create_tracker()
145+
cached.track_duration.assert_called_once()
146+
cached.track_tokens.assert_called_once()
147+
cached.track_success.assert_called_once()

packages/ai-providers/server-ai-openai/tests/test_tracking_openai_agents.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,40 @@ async def test_tracks_multiple_tool_calls():
359359
assert sorted(tool_keys) == ['search', 'summarize']
360360

361361

362+
@pytest.mark.asyncio
363+
async def test_same_run_id_across_token_success_and_tool_call_events():
364+
"""All node-level events for a single execution share the same runId."""
365+
mock_ld_client = MagicMock()
366+
graph = _make_graph(
367+
mock_ld_client, node_key='root-agent', graph_key='g', tool_names=['search']
368+
)
369+
370+
tool_item = _make_tool_call_item('root-agent', 'search')
371+
run_result = _make_run_result(
372+
output='ok', total_tokens=10, input_tokens=7, output_tokens=3,
373+
tool_call_items=[tool_item],
374+
)
375+
376+
with patch.dict('sys.modules', _make_agents_modules(run_result)):
377+
runner = OpenAIAgentGraphRunner(graph, _tool_registry('search'))
378+
await runner.run('go')
379+
380+
ev = _events(mock_ld_client)
381+
382+
# Collect runIds from node-level events
383+
run_ids = set()
384+
for event_name in (
385+
'$ld:ai:tokens:total', '$ld:ai:tokens:input', '$ld:ai:tokens:output',
386+
'$ld:ai:generation:success', '$ld:ai:generation:duration', '$ld:ai:tool_call',
387+
):
388+
for data, _ in ev.get(event_name, []):
389+
if data.get('configKey') == 'root-agent':
390+
run_ids.add(data['runId'])
391+
392+
# All events must share a single runId
393+
assert len(run_ids) == 1
394+
395+
362396
@pytest.mark.asyncio
363397
async def test_does_not_track_tool_calls_without_graph_and_registry_config():
364398
"""RunResult tool items that are not backed by graph + registry tools are ignored."""

0 commit comments

Comments
 (0)