|
15 | 15 | from __future__ import annotations |
16 | 16 |
|
17 | 17 | import asyncio |
| 18 | +import time |
18 | 19 | from concurrent.futures import ThreadPoolExecutor |
19 | 20 | from types import SimpleNamespace |
20 | 21 | from typing import Any, AsyncIterator, Iterator |
@@ -186,6 +187,64 @@ def _parse_provider_response_delta(self, response: Any) -> ModelResponse: |
186 | 187 | return response |
187 | 188 |
|
188 | 189 |
|
| 190 | +class LatencyToolLoopModel(ToolLoopModel): |
| 191 | + def __init__(self, delay_s: float = 0.03): |
| 192 | + super().__init__() |
| 193 | + self.delay_s = delay_s |
| 194 | + |
| 195 | + def invoke(self, *args: Any, **kwargs: Any) -> ModelResponse: |
| 196 | + time.sleep(self.delay_s) |
| 197 | + return self._next_response() |
| 198 | + |
| 199 | + async def ainvoke(self, *args: Any, **kwargs: Any) -> ModelResponse: |
| 200 | + await asyncio.sleep(self.delay_s) |
| 201 | + return self._next_response() |
| 202 | + |
| 203 | + def invoke_stream(self, *args: Any, **kwargs: Any) -> Iterator[Any]: |
| 204 | + response = self._next_response() |
| 205 | + time.sleep(self.delay_s) |
| 206 | + if response.tool_calls: |
| 207 | + yield ModelResponse( |
| 208 | + role="assistant", |
| 209 | + tool_calls=response.tool_calls, |
| 210 | + response_usage=response.response_usage, |
| 211 | + ) |
| 212 | + return |
| 213 | + yield ModelResponse( |
| 214 | + role="assistant", |
| 215 | + content="sunny ", |
| 216 | + response_usage=MessageMetrics(input_tokens=20, output_tokens=1), |
| 217 | + ) |
| 218 | + time.sleep(self.delay_s) |
| 219 | + yield ModelResponse( |
| 220 | + role="assistant", |
| 221 | + content="in Hangzhou", |
| 222 | + response_usage=MessageMetrics(output_tokens=2), |
| 223 | + ) |
| 224 | + |
| 225 | + async def ainvoke_stream(self, *args: Any, **kwargs: Any) -> AsyncIterator: |
| 226 | + response = self._next_response() |
| 227 | + await asyncio.sleep(self.delay_s) |
| 228 | + if response.tool_calls: |
| 229 | + yield ModelResponse( |
| 230 | + role="assistant", |
| 231 | + tool_calls=response.tool_calls, |
| 232 | + response_usage=response.response_usage, |
| 233 | + ) |
| 234 | + return |
| 235 | + yield ModelResponse( |
| 236 | + role="assistant", |
| 237 | + content="sunny ", |
| 238 | + response_usage=MessageMetrics(input_tokens=20, output_tokens=1), |
| 239 | + ) |
| 240 | + await asyncio.sleep(self.delay_s) |
| 241 | + yield ModelResponse( |
| 242 | + role="assistant", |
| 243 | + content="in Hangzhou", |
| 244 | + response_usage=MessageMetrics(output_tokens=2), |
| 245 | + ) |
| 246 | + |
| 247 | + |
189 | 248 | @pytest.fixture |
190 | 249 | def span_exporter() -> InMemorySpanExporter: |
191 | 250 | return InMemorySpanExporter() |
@@ -228,6 +287,10 @@ def _spans_by_kind( |
228 | 287 | ) |
229 | 288 |
|
230 | 289 |
|
| 290 | +def _duration_ms(span: Any) -> float: |
| 291 | + return (span.end_time - span.start_time) / 1_000_000 |
| 292 | + |
| 293 | + |
231 | 294 | def _weather_tool() -> Function: |
232 | 295 | fn = Function.from_callable(lambda city: f"weather for {city}: sunny") |
233 | 296 | fn.name = "get_weather" |
@@ -898,6 +961,40 @@ def test_stream_tool_call_loop_emits_agent_tokens_and_split_llm_spans( |
898 | 961 | _assert_tool_loop_tree(span_exporter, "StreamToolLoopAgent") |
899 | 962 |
|
900 | 963 |
|
| 964 | +def test_tool_call_loop_llm_spans_cover_provider_latency( |
| 965 | + span_exporter: InMemorySpanExporter, |
| 966 | +): |
| 967 | + agent = Agent( |
| 968 | + name="LatencyToolLoopAgent", |
| 969 | + model=LatencyToolLoopModel(), |
| 970 | + tools=[_weather_tool()], |
| 971 | + telemetry=False, |
| 972 | + ) |
| 973 | + |
| 974 | + agent.run("what is the weather") |
| 975 | + |
| 976 | + llm_spans = _spans_by_kind(span_exporter, "LLM") |
| 977 | + assert len(llm_spans) == 2 |
| 978 | + assert all(_duration_ms(span) >= 25 for span in llm_spans) |
| 979 | + |
| 980 | + |
| 981 | +def test_stream_tool_call_loop_llm_spans_cover_provider_latency( |
| 982 | + span_exporter: InMemorySpanExporter, |
| 983 | +): |
| 984 | + agent = Agent( |
| 985 | + name="LatencyStreamToolLoopAgent", |
| 986 | + model=LatencyToolLoopModel(), |
| 987 | + tools=[_weather_tool()], |
| 988 | + telemetry=False, |
| 989 | + ) |
| 990 | + |
| 991 | + list(agent.run("what is the weather", stream=True)) |
| 992 | + |
| 993 | + llm_spans = _spans_by_kind(span_exporter, "LLM") |
| 994 | + assert len(llm_spans) == 2 |
| 995 | + assert all(_duration_ms(span) >= 25 for span in llm_spans) |
| 996 | + |
| 997 | + |
901 | 998 | def test_async_stream_tool_call_loop_emits_agent_tokens_and_split_llm_spans( |
902 | 999 | span_exporter: InMemorySpanExporter, |
903 | 1000 | ): |
|
0 commit comments