|
| 1 | +""" |
| 2 | +Integration tests for LangGraphAgentGraphRunner tracking pipeline. |
| 3 | +
|
| 4 | +Uses real AIGraphTracker and LDAIConfigTracker backed by a mock LD client, |
| 5 | +and a fake LangChain model to verify that the correct LD events are emitted |
| 6 | +with the correct payloads — without making real API calls. |
| 7 | +""" |
| 8 | + |
| 9 | +import pytest |
| 10 | +from collections import defaultdict |
| 11 | +from unittest.mock import MagicMock, patch |
| 12 | + |
| 13 | +from ldai.agent_graph import AgentGraphDefinition |
| 14 | +from ldai.models import AIAgentGraphConfig, AIAgentConfig, ModelConfig, ProviderConfig |
| 15 | +from ldai.tracker import AIGraphTracker, LDAIConfigTracker |
| 16 | +from ldai_langchain.langgraph_agent_graph_runner import LangGraphAgentGraphRunner |
| 17 | + |
| 18 | +pytestmark = pytest.mark.skipif( |
| 19 | + pytest.importorskip('langgraph', reason='langgraph not installed') is None, |
| 20 | + reason='langgraph not installed', |
| 21 | +) |
| 22 | + |
| 23 | + |
| 24 | +# --------------------------------------------------------------------------- |
| 25 | +# Helpers |
| 26 | +# --------------------------------------------------------------------------- |
| 27 | + |
| 28 | +def _make_graph( |
| 29 | + mock_ld_client: MagicMock, |
| 30 | + node_key: str = 'root-agent', |
| 31 | + graph_key: str = 'test-graph', |
| 32 | + tool_names: list = None, |
| 33 | +) -> AgentGraphDefinition: |
| 34 | + """ |
| 35 | + Build an AgentGraphDefinition backed by real tracker objects that record |
| 36 | + events to a mock LD client. |
| 37 | + """ |
| 38 | + context = MagicMock() |
| 39 | + |
| 40 | + node_tracker = LDAIConfigTracker( |
| 41 | + ld_client=mock_ld_client, |
| 42 | + variation_key='test-variation', |
| 43 | + config_key=node_key, |
| 44 | + version=1, |
| 45 | + model_name='gpt-4', |
| 46 | + provider_name='openai', |
| 47 | + context=context, |
| 48 | + ) |
| 49 | + |
| 50 | + graph_tracker = AIGraphTracker( |
| 51 | + ld_client=mock_ld_client, |
| 52 | + variation_key='test-variation', |
| 53 | + graph_key=graph_key, |
| 54 | + version=1, |
| 55 | + context=context, |
| 56 | + ) |
| 57 | + |
| 58 | + tool_defs = ( |
| 59 | + [{'name': name, 'type': 'function', 'description': '', 'parameters': {}} |
| 60 | + for name in tool_names] |
| 61 | + if tool_names else None |
| 62 | + ) |
| 63 | + |
| 64 | + root_config = AIAgentConfig( |
| 65 | + key=node_key, |
| 66 | + enabled=True, |
| 67 | + model=ModelConfig(name='gpt-4', parameters={'tools': tool_defs} if tool_defs else {}), |
| 68 | + provider=ProviderConfig(name='openai'), |
| 69 | + instructions='You are a helpful assistant.', |
| 70 | + tracker=node_tracker, |
| 71 | + ) |
| 72 | + |
| 73 | + graph_config = AIAgentGraphConfig( |
| 74 | + key=graph_key, |
| 75 | + root_config_key=node_key, |
| 76 | + edges=[], |
| 77 | + enabled=True, |
| 78 | + ) |
| 79 | + |
| 80 | + nodes = AgentGraphDefinition.build_nodes(graph_config, {node_key: root_config}) |
| 81 | + return AgentGraphDefinition( |
| 82 | + agent_graph=graph_config, |
| 83 | + nodes=nodes, |
| 84 | + context=context, |
| 85 | + enabled=True, |
| 86 | + tracker=graph_tracker, |
| 87 | + ) |
| 88 | + |
| 89 | + |
| 90 | +def _make_fake_response( |
| 91 | + content: str, |
| 92 | + input_tokens: int = 10, |
| 93 | + output_tokens: int = 5, |
| 94 | + tool_call_names: list = None, |
| 95 | +): |
| 96 | + """Create a real AIMessage with usage metadata and optional tool calls.""" |
| 97 | + from langchain_core.messages import AIMessage |
| 98 | + |
| 99 | + tool_calls = [ |
| 100 | + {'name': name, 'args': {}, 'id': f'call_{i}', 'type': 'tool_call'} |
| 101 | + for i, name in enumerate(tool_call_names or []) |
| 102 | + ] |
| 103 | + |
| 104 | + return AIMessage( |
| 105 | + content=content, |
| 106 | + tool_calls=tool_calls, |
| 107 | + usage_metadata={ |
| 108 | + 'input_tokens': input_tokens, |
| 109 | + 'output_tokens': output_tokens, |
| 110 | + 'total_tokens': input_tokens + output_tokens, |
| 111 | + }, |
| 112 | + ) |
| 113 | + |
| 114 | + |
| 115 | +def _events(mock_ld_client: MagicMock) -> dict: |
| 116 | + """Return dict of event_name -> list of (data, value) from all track() calls.""" |
| 117 | + result = defaultdict(list) |
| 118 | + for call in mock_ld_client.track.call_args_list: |
| 119 | + name, _ctx, data, value = call.args |
| 120 | + result[name].append((data, value)) |
| 121 | + return dict(result) |
| 122 | + |
| 123 | + |
| 124 | +def _mock_model(response): |
| 125 | + """Return a mock LangChain model that always returns response on invoke().""" |
| 126 | + model = MagicMock() |
| 127 | + model.invoke.return_value = response |
| 128 | + model.bind_tools.return_value = model |
| 129 | + return model |
| 130 | + |
| 131 | + |
| 132 | +# --------------------------------------------------------------------------- |
| 133 | +# Tests |
| 134 | +# --------------------------------------------------------------------------- |
| 135 | + |
| 136 | +@pytest.mark.asyncio |
| 137 | +async def test_tracks_node_and_graph_tokens_on_success(): |
| 138 | + """Node-level and graph-level token events fire with the correct counts.""" |
| 139 | + mock_ld_client = MagicMock() |
| 140 | + graph = _make_graph(mock_ld_client) |
| 141 | + fake_response = _make_fake_response('Sunny.', input_tokens=10, output_tokens=5) |
| 142 | + |
| 143 | + with patch('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model', |
| 144 | + return_value=_mock_model(fake_response)): |
| 145 | + runner = LangGraphAgentGraphRunner(graph, {}) |
| 146 | + result = await runner.run("What's the weather?") |
| 147 | + |
| 148 | + assert result.metrics.success is True |
| 149 | + assert result.output == 'Sunny.' |
| 150 | + |
| 151 | + ev = _events(mock_ld_client) |
| 152 | + |
| 153 | + # Node-level token events |
| 154 | + assert ev['$ld:ai:tokens:total'][0][1] == 15 |
| 155 | + assert ev['$ld:ai:tokens:input'][0][1] == 10 |
| 156 | + assert ev['$ld:ai:tokens:output'][0][1] == 5 |
| 157 | + assert ev['$ld:ai:generation:success'][0][1] == 1 |
| 158 | + assert '$ld:ai:duration:total' in ev |
| 159 | + |
| 160 | + # Graph-level events |
| 161 | + assert ev['$ld:ai:graph:total_tokens'][0][1] == 15 |
| 162 | + assert ev['$ld:ai:graph:invocation_success'][0][1] == 1 |
| 163 | + assert '$ld:ai:graph:latency' in ev |
| 164 | + assert '$ld:ai:graph:path' in ev |
| 165 | + |
| 166 | + |
| 167 | +@pytest.mark.asyncio |
| 168 | +async def test_tracks_execution_path(): |
| 169 | + """The path event contains the executed node key.""" |
| 170 | + mock_ld_client = MagicMock() |
| 171 | + graph = _make_graph(mock_ld_client, node_key='my-agent') |
| 172 | + fake_response = _make_fake_response('Done.') |
| 173 | + |
| 174 | + with patch('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model', |
| 175 | + return_value=_mock_model(fake_response)): |
| 176 | + runner = LangGraphAgentGraphRunner(graph, {}) |
| 177 | + await runner.run('hello') |
| 178 | + |
| 179 | + ev = _events(mock_ld_client) |
| 180 | + path_data = ev['$ld:ai:graph:path'][0][0] |
| 181 | + assert 'my-agent' in path_data['path'] |
| 182 | + |
| 183 | + |
| 184 | +@pytest.mark.asyncio |
| 185 | +async def test_tracks_tool_calls(): |
| 186 | + """A tool_call event fires for each tool name found in the model response.""" |
| 187 | + mock_ld_client = MagicMock() |
| 188 | + graph = _make_graph(mock_ld_client, tool_names=['get_weather']) |
| 189 | + fake_response = _make_fake_response('Calling tool.', tool_call_names=['get_weather']) |
| 190 | + |
| 191 | + tool_registry = {'get_weather': lambda location='NYC': 'sunny'} |
| 192 | + |
| 193 | + with patch('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model', |
| 194 | + return_value=_mock_model(fake_response)): |
| 195 | + runner = LangGraphAgentGraphRunner(graph, tool_registry) |
| 196 | + await runner.run('What is the weather?') |
| 197 | + |
| 198 | + ev = _events(mock_ld_client) |
| 199 | + tool_events = ev.get('$ld:ai:tool_call', []) |
| 200 | + assert len(tool_events) == 1 |
| 201 | + assert tool_events[0][0]['toolKey'] == 'get_weather' |
| 202 | + |
| 203 | + |
| 204 | +@pytest.mark.asyncio |
| 205 | +async def test_tracks_multiple_tool_calls(): |
| 206 | + """One tool_call event fires per tool name in the response.""" |
| 207 | + mock_ld_client = MagicMock() |
| 208 | + graph = _make_graph(mock_ld_client, tool_names=['search', 'summarize']) |
| 209 | + fake_response = _make_fake_response('Done.', tool_call_names=['search', 'summarize']) |
| 210 | + |
| 211 | + tool_registry = {'search': lambda q='': q, 'summarize': lambda text='': text} |
| 212 | + |
| 213 | + with patch('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model', |
| 214 | + return_value=_mock_model(fake_response)): |
| 215 | + runner = LangGraphAgentGraphRunner(graph, tool_registry) |
| 216 | + await runner.run('Search and summarize.') |
| 217 | + |
| 218 | + ev = _events(mock_ld_client) |
| 219 | + tool_keys = [data['toolKey'] for data, _ in ev.get('$ld:ai:tool_call', [])] |
| 220 | + assert sorted(tool_keys) == ['search', 'summarize'] |
| 221 | + |
| 222 | + |
| 223 | +@pytest.mark.asyncio |
| 224 | +async def test_tracks_graph_key_on_node_events(): |
| 225 | + """Node-level events include the graphKey so they can be correlated to the graph.""" |
| 226 | + mock_ld_client = MagicMock() |
| 227 | + graph = _make_graph(mock_ld_client, graph_key='my-graph') |
| 228 | + fake_response = _make_fake_response('OK.', input_tokens=5, output_tokens=3) |
| 229 | + |
| 230 | + with patch('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model', |
| 231 | + return_value=_mock_model(fake_response)): |
| 232 | + runner = LangGraphAgentGraphRunner(graph, {}) |
| 233 | + await runner.run('hello') |
| 234 | + |
| 235 | + ev = _events(mock_ld_client) |
| 236 | + token_data = ev['$ld:ai:tokens:total'][0][0] |
| 237 | + assert token_data.get('graphKey') == 'my-graph' |
| 238 | + |
| 239 | + |
| 240 | +@pytest.mark.asyncio |
| 241 | +async def test_tracks_failure_and_latency_on_model_error(): |
| 242 | + """When the model raises, failure and latency events fire; success does not.""" |
| 243 | + mock_ld_client = MagicMock() |
| 244 | + graph = _make_graph(mock_ld_client) |
| 245 | + |
| 246 | + error_model = MagicMock() |
| 247 | + error_model.invoke.side_effect = RuntimeError('model error') |
| 248 | + error_model.bind_tools.return_value = error_model |
| 249 | + |
| 250 | + with patch('ldai_langchain.langgraph_agent_graph_runner.create_langchain_model', |
| 251 | + return_value=error_model): |
| 252 | + runner = LangGraphAgentGraphRunner(graph, {}) |
| 253 | + result = await runner.run('fail') |
| 254 | + |
| 255 | + assert result.metrics.success is False |
| 256 | + |
| 257 | + ev = _events(mock_ld_client) |
| 258 | + assert '$ld:ai:graph:invocation_failure' in ev |
| 259 | + assert '$ld:ai:graph:latency' in ev |
| 260 | + assert '$ld:ai:graph:invocation_success' not in ev |
0 commit comments