Skip to content

Commit 69ba99f

Browse files
committed
add multi node tests and sanitize agent names for openai
1 parent c8318a2 commit 69ba99f

3 files changed

Lines changed: 279 additions & 6 deletions

File tree

packages/ai-providers/server-ai-langchain/tests/test_tracking_langgraph.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from unittest.mock import MagicMock, patch
1212

1313
from ldai.agent_graph import AgentGraphDefinition
14-
from ldai.models import AIAgentGraphConfig, AIAgentConfig, ModelConfig, ProviderConfig
14+
from ldai.models import AIAgentGraphConfig, AIAgentConfig, Edge, ModelConfig, ProviderConfig
1515
from ldai.tracker import AIGraphTracker, LDAIConfigTracker
1616
from ldai_langchain.langgraph_agent_graph_runner import LangGraphAgentGraphRunner
1717

@@ -129,6 +129,74 @@ def _mock_model(response):
129129
return model
130130

131131

132+
def _make_two_node_graph(mock_ld_client: MagicMock) -> 'AgentGraphDefinition':
133+
"""Build a two-node AgentGraphDefinition (root-agent → child-agent)."""
134+
context = MagicMock()
135+
136+
root_tracker = LDAIConfigTracker(
137+
ld_client=mock_ld_client,
138+
variation_key='test-variation',
139+
config_key='root-agent',
140+
version=1,
141+
model_name='gpt-4',
142+
provider_name='openai',
143+
context=context,
144+
)
145+
child_tracker = LDAIConfigTracker(
146+
ld_client=mock_ld_client,
147+
variation_key='test-variation',
148+
config_key='child-agent',
149+
version=1,
150+
model_name='gpt-4',
151+
provider_name='openai',
152+
context=context,
153+
)
154+
graph_tracker = AIGraphTracker(
155+
ld_client=mock_ld_client,
156+
variation_key='test-variation',
157+
graph_key='two-node-graph',
158+
version=1,
159+
context=context,
160+
)
161+
162+
root_config = AIAgentConfig(
163+
key='root-agent',
164+
enabled=True,
165+
model=ModelConfig(name='gpt-4', parameters={}),
166+
provider=ProviderConfig(name='openai'),
167+
instructions='You are root.',
168+
tracker=root_tracker,
169+
)
170+
child_config = AIAgentConfig(
171+
key='child-agent',
172+
enabled=True,
173+
model=ModelConfig(name='gpt-4', parameters={}),
174+
provider=ProviderConfig(name='openai'),
175+
instructions='You are child.',
176+
tracker=child_tracker,
177+
)
178+
179+
edge = Edge(key='root-to-child', source_config='root-agent', target_config='child-agent')
180+
graph_config = AIAgentGraphConfig(
181+
key='two-node-graph',
182+
root_config_key='root-agent',
183+
edges=[edge],
184+
enabled=True,
185+
)
186+
187+
nodes = AgentGraphDefinition.build_nodes(graph_config, {
188+
'root-agent': root_config,
189+
'child-agent': child_config,
190+
})
191+
return AgentGraphDefinition(
192+
agent_graph=graph_config,
193+
nodes=nodes,
194+
context=context,
195+
enabled=True,
196+
tracker=graph_tracker,
197+
)
198+
199+
132200
# ---------------------------------------------------------------------------
133201
# Tests
134202
# ---------------------------------------------------------------------------
@@ -258,3 +326,41 @@ async def test_tracks_failure_and_latency_on_model_error():
258326
assert '$ld:ai:graph:invocation_failure' in ev
259327
assert '$ld:ai:graph:latency' in ev
260328
assert '$ld:ai:graph:invocation_success' not in ev
329+
330+
331+
@pytest.mark.asyncio
332+
async def test_multi_node_tracks_per_node_tokens_and_path():
333+
"""Each node emits its own token events; path and graph total cover both nodes."""
334+
mock_ld_client = MagicMock()
335+
graph = _make_two_node_graph(mock_ld_client)
336+
337+
root_response = _make_fake_response('Root done.', input_tokens=10, output_tokens=5)
338+
child_response = _make_fake_response('Child done.', input_tokens=3, output_tokens=2)
339+
340+
def model_factory(node_config):
341+
if node_config.key == 'root-agent':
342+
return _mock_model(root_response)
343+
return _mock_model(child_response)
344+
345+
with patch('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model',
346+
side_effect=model_factory):
347+
runner = LangGraphAgentGraphRunner(graph, {})
348+
result = await runner.run('hello')
349+
350+
assert result.metrics.success is True
351+
352+
ev = _events(mock_ld_client)
353+
354+
# Per-node token events identified by configKey
355+
root_tokens = [(d, v) for d, v in ev.get('$ld:ai:tokens:total', []) if d.get('configKey') == 'root-agent']
356+
child_tokens = [(d, v) for d, v in ev.get('$ld:ai:tokens:total', []) if d.get('configKey') == 'child-agent']
357+
assert root_tokens[0][1] == 15
358+
assert child_tokens[0][1] == 5
359+
360+
# Graph-level total accumulates both nodes (10+3 in, 5+2 out)
361+
assert ev['$ld:ai:graph:total_tokens'][0][1] == 20
362+
363+
# Execution path includes both node keys
364+
path_data = ev['$ld:ai:graph:path'][0][0]
365+
assert 'root-agent' in path_data['path']
366+
assert 'child-agent' in path_data['path']

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""OpenAI agent graph runner for LaunchDarkly AI SDK."""
22

3+
import re
34
import time
4-
from typing import Any, List, Optional
5+
from typing import Any, Dict, List, Optional
56

67
from ldai import log
78
from ldai.agent_graph import AgentGraphDefinition, AgentGraphNode
@@ -16,6 +17,11 @@
1617
)
1718

1819

20+
def _sanitize_agent_name(key: str) -> str:
21+
"""Replace characters invalid for OpenAI function names with underscores."""
22+
return re.sub(r'[^a-zA-Z0-9_]', '_', key)
23+
24+
1925
class _RunState:
2026
"""Mutable state shared across handoff and tool callbacks during a single run."""
2127

@@ -44,6 +50,7 @@ def __init__(self, graph: AgentGraphDefinition, tools: ToolRegistry):
4450
"""
4551
self._graph = graph
4652
self._tools = tools
53+
self._agent_name_map: Dict[str, str] = {}
4754

4855
async def run(self, input: Any) -> AgentGraphResult:
4956
"""
@@ -132,6 +139,7 @@ def _build_agents(self, path: List[str], state: _RunState) -> Any:
132139
) from exc
133140

134141
tracker = self._graph.get_tracker()
142+
name_map: Dict[str, str] = {}
135143

136144
def build_node(node: AgentGraphNode, ctx: dict) -> Any:
137145
node_config = node.get_config()
@@ -142,6 +150,8 @@ def build_node(node: AgentGraphNode, ctx: dict) -> Any:
142150
raise ValueError(f"Model not set for node '{node_config.key}'")
143151

144152
tool_defs = model.get_parameter('tools') or []
153+
sanitized_name = _sanitize_agent_name(node_config.key)
154+
name_map[sanitized_name] = node_config.key
145155

146156
# --- handoffs ---
147157
agent_handoffs: List[Handoff] = []
@@ -173,14 +183,16 @@ def build_node(node: AgentGraphNode, ctx: dict) -> Any:
173183
agent_tools.append(function_tool(tool_fn))
174184

175185
return Agent(
176-
name=node_config.key,
186+
name=sanitized_name,
177187
model=model.name,
178188
instructions=f'{RECOMMENDED_PROMPT_PREFIX} {node_config.instructions or ""}',
179189
handoffs=list(agent_handoffs),
180190
tools=list(agent_tools),
181191
)
182192

183-
return self._graph.reverse_traverse(fn=build_node)
193+
root = self._graph.reverse_traverse(fn=build_node)
194+
self._agent_name_map = name_map
195+
return root
184196

185197
def _make_on_handoff(
186198
self,
@@ -269,7 +281,8 @@ def _track_tool_calls(self, result: Any, tracker: Any) -> None:
269281
"""Track all tool calls from the run result, attributed to the node that called them."""
270282
gk = tracker.graph_key if tracker is not None else None
271283
for agent_name, tool_name in get_tool_calls_from_run_items(result.new_items):
272-
node = self._graph.get_node(agent_name)
284+
original_key = self._agent_name_map.get(agent_name, agent_name)
285+
node = self._graph.get_node(original_key)
273286
if node is None:
274287
continue
275288
config_tracker = node.get_config().tracker

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

Lines changed: 155 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from unittest.mock import AsyncMock, MagicMock, patch
1212

1313
from ldai.agent_graph import AgentGraphDefinition
14-
from ldai.models import AIAgentGraphConfig, AIAgentConfig, ModelConfig, ProviderConfig
14+
from ldai.models import AIAgentGraphConfig, AIAgentConfig, Edge, ModelConfig, ProviderConfig
1515
from ldai.tracker import AIGraphTracker, LDAIConfigTracker
1616
from ldai_openai.openai_agent_graph_runner import OpenAIAgentGraphRunner
1717

@@ -153,6 +153,74 @@ def _make_agents_modules(run_result: MagicMock) -> dict:
153153
}
154154

155155

156+
def _make_two_node_graph(mock_ld_client: MagicMock) -> AgentGraphDefinition:
157+
"""Build a two-node AgentGraphDefinition (root-agent → child-agent)."""
158+
context = MagicMock()
159+
160+
root_tracker = LDAIConfigTracker(
161+
ld_client=mock_ld_client,
162+
variation_key='test-variation',
163+
config_key='root-agent',
164+
version=1,
165+
model_name='gpt-4',
166+
provider_name='openai',
167+
context=context,
168+
)
169+
child_tracker = LDAIConfigTracker(
170+
ld_client=mock_ld_client,
171+
variation_key='test-variation',
172+
config_key='child-agent',
173+
version=1,
174+
model_name='gpt-4',
175+
provider_name='openai',
176+
context=context,
177+
)
178+
graph_tracker = AIGraphTracker(
179+
ld_client=mock_ld_client,
180+
variation_key='test-variation',
181+
graph_key='two-node-graph',
182+
version=1,
183+
context=context,
184+
)
185+
186+
root_config = AIAgentConfig(
187+
key='root-agent',
188+
enabled=True,
189+
model=ModelConfig(name='gpt-4', parameters={}),
190+
provider=ProviderConfig(name='openai'),
191+
instructions='You are root.',
192+
tracker=root_tracker,
193+
)
194+
child_config = AIAgentConfig(
195+
key='child-agent',
196+
enabled=True,
197+
model=ModelConfig(name='gpt-4', parameters={}),
198+
provider=ProviderConfig(name='openai'),
199+
instructions='You are child.',
200+
tracker=child_tracker,
201+
)
202+
203+
edge = Edge(key='root-to-child', source_config='root-agent', target_config='child-agent')
204+
graph_config = AIAgentGraphConfig(
205+
key='two-node-graph',
206+
root_config_key='root-agent',
207+
edges=[edge],
208+
enabled=True,
209+
)
210+
211+
nodes = AgentGraphDefinition.build_nodes(graph_config, {
212+
'root-agent': root_config,
213+
'child-agent': child_config,
214+
})
215+
return AgentGraphDefinition(
216+
agent_graph=graph_config,
217+
nodes=nodes,
218+
context=context,
219+
enabled=True,
220+
tracker=graph_tracker,
221+
)
222+
223+
156224
def _events(mock_ld_client: MagicMock) -> dict:
157225
"""Return dict of event_name -> list of (data, value) from all track() calls."""
158226
result = defaultdict(list)
@@ -303,3 +371,89 @@ async def test_tracks_failure_and_latency_on_runner_error():
303371
assert '$ld:ai:graph:invocation_failure' in ev
304372
assert '$ld:ai:graph:latency' in ev
305373
assert '$ld:ai:graph:invocation_success' not in ev
374+
375+
376+
@pytest.mark.asyncio
377+
async def test_multi_node_tracks_per_node_tokens_and_handoff():
378+
"""Each node emits its own token events; handoff event fires between them."""
379+
mock_ld_client = MagicMock()
380+
graph = _make_two_node_graph(mock_ld_client)
381+
382+
root_entry = MagicMock()
383+
root_entry.total_tokens = 15
384+
root_entry.input_tokens = 10
385+
root_entry.output_tokens = 5
386+
387+
child_entry = MagicMock()
388+
child_entry.total_tokens = 9
389+
child_entry.input_tokens = 6
390+
child_entry.output_tokens = 3
391+
392+
run_result = MagicMock()
393+
run_result.final_output = 'child answer'
394+
run_result.new_items = []
395+
run_result.usage = None
396+
run_result.context_wrapper.usage.total_tokens = 24
397+
run_result.context_wrapper.usage.input_tokens = 16
398+
run_result.context_wrapper.usage.output_tokens = 8
399+
run_result.context_wrapper.usage.request_usage_entries = [root_entry, child_entry]
400+
401+
on_handoff_callbacks = []
402+
403+
def capture_handoff(**kwargs):
404+
cb = kwargs.get('on_handoff')
405+
if cb:
406+
on_handoff_callbacks.append(cb)
407+
return MagicMock()
408+
409+
async def mock_run(agent, input_str, **kwargs):
410+
# Simulate the root→child handoff before returning
411+
if on_handoff_callbacks:
412+
run_ctx = MagicMock()
413+
run_ctx.usage.request_usage_entries = [root_entry]
414+
on_handoff_callbacks[0](run_ctx)
415+
return run_result
416+
417+
mock_runner_cls = MagicMock()
418+
mock_runner_cls.run = mock_run
419+
420+
mock_agents = MagicMock()
421+
mock_agents.Runner = mock_runner_cls
422+
mock_agents.Agent = MagicMock(return_value=MagicMock())
423+
mock_agents.Handoff = MagicMock()
424+
mock_agents.Tool = MagicMock()
425+
mock_agents.function_tool = lambda fn: MagicMock()
426+
mock_agents.handoff = capture_handoff
427+
428+
mock_ext = MagicMock()
429+
mock_ext.RECOMMENDED_PROMPT_PREFIX = '[PREFIX]'
430+
431+
with patch.dict('sys.modules', {
432+
'agents': mock_agents,
433+
'agents.extensions': MagicMock(),
434+
'agents.extensions.handoff_prompt': mock_ext,
435+
'agents.tool_context': MagicMock(),
436+
}):
437+
runner = OpenAIAgentGraphRunner(graph, {})
438+
result = await runner.run('hello')
439+
440+
assert result.metrics.success is True
441+
442+
ev = _events(mock_ld_client)
443+
444+
# Per-node token events identified by configKey
445+
root_tokens = [(d, v) for d, v in ev.get('$ld:ai:tokens:total', []) if d.get('configKey') == 'root-agent']
446+
child_tokens = [(d, v) for d, v in ev.get('$ld:ai:tokens:total', []) if d.get('configKey') == 'child-agent']
447+
assert root_tokens[0][1] == 15
448+
assert child_tokens[0][1] == 9
449+
450+
# Execution path includes both node keys
451+
path_data = ev['$ld:ai:graph:path'][0][0]
452+
assert 'root-agent' in path_data['path']
453+
assert 'child-agent' in path_data['path']
454+
455+
# Handoff event fires with correct source and target
456+
handoff_events = ev.get('$ld:ai:graph:handoff_success', [])
457+
assert len(handoff_events) == 1
458+
assert handoff_events[0][0]['sourceKey'] == 'root-agent'
459+
assert handoff_events[0][0]['targetKey'] == 'child-agent'

0 commit comments

Comments
 (0)