Coverage for agentos/agent/tests/test_integration.py: 100%
160 statements
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:28 +0800
« prev ^ index » next coverage.py v7.14.3, created at 2026-07-09 10:28 +0800
1"""ToolAgent 集成测试 — 使用 MockLLMProvider 测试完整 Agent 流程。"""
3import json
4import os
5import tempfile
7import pytest
9from agentos.agent.tool_agent import (
10 AgentConfig,
11 MockLLMProvider,
12 ToolAgent,
13 ToolExecutor,
14)
15from agentos.llm.base import Tool, ToolParameter
17# ── 工具 ─────────────────────────────────────────────────────────
19WEATHER_TOOL = Tool.from_function(
20 name="get_weather",
21 description="获取城市天气",
22 parameters={"city": ToolParameter(type="string", description="城市名")},
23)
25CALC_TOOL = Tool.from_function(
26 name="calculate",
27 description="数学计算",
28 parameters={
29 "expression": ToolParameter(type="string", description="表达式,如 1+2*3"),
30 },
31)
34class TestIntegrationFullFlow:
35 """完整 Agent 流程集成测试。"""
37 def test_single_call_no_tools(self):
38 """无工具,单步直接回答。"""
39 mock = MockLLMProvider(
40 [
41 MockLLMProvider.text_response("答案是42。"),
42 ]
43 )
44 executor = ToolExecutor()
45 agent = ToolAgent(mock, executor)
46 result = agent.run("1+1等于几?")
48 assert result.success
49 assert "42" in result.final_answer
50 assert result.total_steps == 1
51 assert len(mock.calls) == 1
53 def test_single_tool_call_then_answer(self):
54 """一步工具调用,然后给出答案。"""
55 mock = MockLLMProvider(
56 [
57 MockLLMProvider.tool_response("get_weather", {"city": "北京"}),
58 MockLLMProvider.text_response("北京今天晴天,22°C。"),
59 ]
60 )
61 executor = ToolExecutor()
62 executor.register(WEATHER_TOOL, lambda city: f"{city}: 晴 22°C")
63 agent = ToolAgent(mock, executor)
64 result = agent.run("北京天气怎么样?")
66 assert result.success
67 assert "22" in result.final_answer
68 assert result.total_steps == 2
69 assert result.total_tokens > 0
70 # 验证 tool 被调用了
71 assert mock.calls[0]["tools"] == ["get_weather"]
72 assert len(mock.calls) == 2
74 def test_two_tool_calls(self):
75 """两步工具调用。"""
76 mock = MockLLMProvider(
77 [
78 MockLLMProvider.tool_response("get_weather", {"city": "北京"}),
79 MockLLMProvider.tool_response("get_weather", {"city": "上海"}),
80 MockLLMProvider.text_response("北京22°C,上海28°C,都适合出行。"),
81 ]
82 )
83 executor = ToolExecutor()
84 executor.register(WEATHER_TOOL, lambda city: f"{city}: 晴")
85 agent = ToolAgent(mock, executor)
86 result = agent.run("北京和上海天气怎么样?")
88 assert result.success
89 assert result.total_steps == 3
90 assert len(mock.calls) == 3
92 def test_max_steps_exceeds(self):
93 """超过 max_steps 限制。"""
94 mock = MockLLMProvider(
95 [
96 MockLLMProvider.tool_response("get_weather", {"city": "北京"}),
97 MockLLMProvider.tool_response("get_weather", {"city": "上海"}),
98 MockLLMProvider.tool_response("get_weather", {"city": "深圳"}),
99 ]
100 )
101 executor = ToolExecutor()
102 executor.register(WEATHER_TOOL, lambda city: f"{city}: OK")
103 agent = ToolAgent(mock, executor, config=AgentConfig(max_steps=2))
104 result = agent.run("查天气")
106 assert not result.success
107 assert "max steps" in (result.error or "")
108 assert result.total_steps == 2
110 def test_tool_execution_error_stops(self):
111 """工具执行出错且 stop_on_error=True。"""
112 mock = MockLLMProvider(
113 [
114 MockLLMProvider.tool_response("get_weather", {"city": "火星"}),
115 ]
116 )
117 executor = ToolExecutor()
118 executor.register(WEATHER_TOOL, lambda city: 1 / 0) # 必定失败
119 agent = ToolAgent(mock, executor, config=AgentConfig(stop_on_error=True))
120 result = agent.run("火星天气?")
122 assert not result.success
123 assert "error" in (result.error or "").lower()
125 def test_tool_error_continues(self):
126 """工具出错但 stop_on_error=False,Agent 继续执行。"""
127 mock = MockLLMProvider(
128 [
129 MockLLMProvider.tool_response("get_weather", {"city": "火星"}),
130 MockLLMProvider.text_response("抱歉,无法获取火星天气。"),
131 ]
132 )
133 executor = ToolExecutor()
134 executor.register(WEATHER_TOOL, lambda city: 1 / 0) # 必定失败
135 agent = ToolAgent(mock, executor, config=AgentConfig(stop_on_error=False, max_steps=3))
136 result = agent.run("火星天气?")
138 # 工具出错但继续执行,LLM 应该给出文本回答
139 assert result.success or result.total_steps > 1
141 def test_streaming_yields_steps(self):
142 """run_stream 逐步产出。"""
143 mock = MockLLMProvider(
144 [
145 MockLLMProvider.tool_response("get_weather", {"city": "北京"}),
146 MockLLMProvider.text_response("北京晴天22°C。"),
147 ]
148 )
149 executor = ToolExecutor()
150 executor.register(WEATHER_TOOL, lambda city: f"{city}: 22°C")
151 agent = ToolAgent(mock, executor)
153 gen = agent.run_stream("北京天气?")
154 steps = []
155 result = None
156 try:
157 while True:
158 steps.append(next(gen))
159 except StopIteration as e:
160 result = e.value
162 assert len(steps) == 2 # tool call step + final answer step
163 assert result is not None
164 assert result.success
165 assert "22" in result.final_answer
167 def test_multiple_tools_registered(self):
168 """多工具注册,Agent 只调用需要的。"""
169 mock = MockLLMProvider(
170 [
171 MockLLMProvider.tool_response("calculate", {"expression": "3*4+5"}),
172 MockLLMProvider.text_response("结果是17。"),
173 ]
174 )
175 executor = ToolExecutor()
176 executor.register(CALC_TOOL, lambda expression: str(eval(expression)))
177 executor.register(WEATHER_TOOL, lambda city: "sunny")
179 agent = ToolAgent(mock, executor)
180 result = agent.run("计算 3*4+5")
182 assert result.success
183 assert "17" in result.final_answer
184 # verify only calculate was called, not weather
185 assert "calculate" in mock.calls[0]["tools"]
186 assert "get_weather" in mock.calls[0]["tools"]
189class TestCheckpointResume:
190 """Checkpoint / Resume 集成测试。"""
192 def test_checkpoint_saved_and_resumed(self):
193 """完整流程:中断 → checkpoint → 从断点恢复。"""
194 # Step 1: 只给 1 步的 LLM 响应,让 Agent 在工具调用后"中断"
195 mock = MockLLMProvider(
196 [
197 MockLLMProvider.tool_response("get_weather", {"city": "北京"}),
198 MockLLMProvider.text_response("北京今天晴天,22°C。"),
199 ]
200 )
201 executor = ToolExecutor()
202 executor.register(WEATHER_TOOL, lambda city: f"{city}: 晴 22°C")
204 with tempfile.TemporaryDirectory() as tmpdir:
205 config = AgentConfig(checkpoint_dir=tmpdir, max_steps=5)
206 agent = ToolAgent(mock, executor, config=config)
208 # 第一次运行(完整)
209 result1 = agent.run("北京天气?")
210 assert result1.success
211 assert os.path.exists(os.path.join(tmpdir, "agent_checkpoint.json"))
213 # 验证 checkpoint 内容
214 with open(os.path.join(tmpdir, "agent_checkpoint.json")) as f:
215 ckpt = json.load(f)
216 assert ckpt["task"] == "北京天气?"
217 assert ckpt["step"] >= 0
219 # 第二次从 checkpoint resume(需要新 mock 继续响应)
220 mock2 = MockLLMProvider(
221 [
222 MockLLMProvider.text_response("已确认,北京22°C。"),
223 ]
224 )
225 agent2 = ToolAgent(mock2, executor, config=config)
226 result2 = agent2.resume()
227 assert result2.success
228 assert "22" in result2.final_answer or "22" in result2.final_answer
230 def test_resume_no_checkpoint_raises(self):
231 """无 checkpoint 时 resume 抛出异常。"""
232 mock = MockLLMProvider([])
233 executor = ToolExecutor()
234 with tempfile.TemporaryDirectory() as tmpdir:
235 agent = ToolAgent(mock, executor, config=AgentConfig(checkpoint_dir=tmpdir))
236 with pytest.raises(FileNotFoundError):
237 agent.resume()
239 def test_resume_no_checkpoint_dir_raises(self):
240 """未配置 checkpoint_dir 时 resume 抛出异常。"""
241 mock = MockLLMProvider([])
242 executor = ToolExecutor()
243 agent = ToolAgent(mock, executor)
244 with pytest.raises(ValueError, match="checkpoint_dir"):
245 agent.resume()
248class TestRetry:
249 """重试逻辑集成测试。"""
251 def test_failing_provider_triggers_retry(self):
252 """LLM 调用失败触发重试。"""
254 class FailingThenOK(MockLLMProvider):
255 call_count = 0
257 def chat(self, *args, **kwargs):
258 self.call_count += 1
259 if self.call_count == 1:
260 raise RuntimeError("API timeout")
261 return super().chat(*args, **kwargs)
263 mock = FailingThenOK(
264 [
265 MockLLMProvider.text_response("OK after retry"),
266 ]
267 )
268 executor = ToolExecutor()
269 agent = ToolAgent(mock, executor, config=AgentConfig(max_retries=2, retry_delay=0.01))
270 result = agent.run("测试重试")
272 assert result.success
273 assert mock.call_count == 2 # 第一次失败,第二次成功
275 def test_all_retries_exhausted(self):
276 """所有重试都失败。"""
278 class AlwaysFailing(MockLLMProvider):
279 def chat(self, *args, **kwargs):
280 raise RuntimeError("always fails")
282 mock = AlwaysFailing([])
283 executor = ToolExecutor()
284 agent = ToolAgent(mock, executor, config=AgentConfig(max_retries=1, retry_delay=0.01))
285 result = agent.run("测试")
287 assert not result.success
288 assert "always fails" in (result.error or "")
291class TestAgentResult:
292 """AgentResult 统计正确性。"""
294 def test_statistics_accumulate(self):
295 mock = MockLLMProvider(
296 [
297 MockLLMProvider.tool_response("get_weather", {"city": "北京"}),
298 MockLLMProvider.text_response("北京晴天22°C。"),
299 ]
300 )
301 executor = ToolExecutor()
302 executor.register(WEATHER_TOOL, lambda city: "22°C")
303 agent = ToolAgent(mock, executor)
304 result = agent.run("天气?")
306 assert result.total_steps == 2
307 assert result.total_tokens > 0
308 assert result.total_duration_ms > 0
309 assert isinstance(result.total_cost_usd, float)