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

1"""ToolAgent 集成测试 — 使用 MockLLMProvider 测试完整 Agent 流程。""" 

2 

3import json 

4import os 

5import tempfile 

6 

7import pytest 

8 

9from agentos.agent.tool_agent import ( 

10 AgentConfig, 

11 MockLLMProvider, 

12 ToolAgent, 

13 ToolExecutor, 

14) 

15from agentos.llm.base import Tool, ToolParameter 

16 

17# ── 工具 ───────────────────────────────────────────────────────── 

18 

19WEATHER_TOOL = Tool.from_function( 

20 name="get_weather", 

21 description="获取城市天气", 

22 parameters={"city": ToolParameter(type="string", description="城市名")}, 

23) 

24 

25CALC_TOOL = Tool.from_function( 

26 name="calculate", 

27 description="数学计算", 

28 parameters={ 

29 "expression": ToolParameter(type="string", description="表达式,如 1+2*3"), 

30 }, 

31) 

32 

33 

34class TestIntegrationFullFlow: 

35 """完整 Agent 流程集成测试。""" 

36 

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等于几?") 

47 

48 assert result.success 

49 assert "42" in result.final_answer 

50 assert result.total_steps == 1 

51 assert len(mock.calls) == 1 

52 

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("北京天气怎么样?") 

65 

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 

73 

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("北京和上海天气怎么样?") 

87 

88 assert result.success 

89 assert result.total_steps == 3 

90 assert len(mock.calls) == 3 

91 

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("查天气") 

105 

106 assert not result.success 

107 assert "max steps" in (result.error or "") 

108 assert result.total_steps == 2 

109 

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("火星天气?") 

121 

122 assert not result.success 

123 assert "error" in (result.error or "").lower() 

124 

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("火星天气?") 

137 

138 # 工具出错但继续执行,LLM 应该给出文本回答 

139 assert result.success or result.total_steps > 1 

140 

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) 

152 

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 

161 

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 

166 

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") 

178 

179 agent = ToolAgent(mock, executor) 

180 result = agent.run("计算 3*4+5") 

181 

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"] 

187 

188 

189class TestCheckpointResume: 

190 """Checkpoint / Resume 集成测试。""" 

191 

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") 

203 

204 with tempfile.TemporaryDirectory() as tmpdir: 

205 config = AgentConfig(checkpoint_dir=tmpdir, max_steps=5) 

206 agent = ToolAgent(mock, executor, config=config) 

207 

208 # 第一次运行(完整) 

209 result1 = agent.run("北京天气?") 

210 assert result1.success 

211 assert os.path.exists(os.path.join(tmpdir, "agent_checkpoint.json")) 

212 

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 

218 

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 

229 

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() 

238 

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() 

246 

247 

248class TestRetry: 

249 """重试逻辑集成测试。""" 

250 

251 def test_failing_provider_triggers_retry(self): 

252 """LLM 调用失败触发重试。""" 

253 

254 class FailingThenOK(MockLLMProvider): 

255 call_count = 0 

256 

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) 

262 

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("测试重试") 

271 

272 assert result.success 

273 assert mock.call_count == 2 # 第一次失败,第二次成功 

274 

275 def test_all_retries_exhausted(self): 

276 """所有重试都失败。""" 

277 

278 class AlwaysFailing(MockLLMProvider): 

279 def chat(self, *args, **kwargs): 

280 raise RuntimeError("always fails") 

281 

282 mock = AlwaysFailing([]) 

283 executor = ToolExecutor() 

284 agent = ToolAgent(mock, executor, config=AgentConfig(max_retries=1, retry_delay=0.01)) 

285 result = agent.run("测试") 

286 

287 assert not result.success 

288 assert "always fails" in (result.error or "") 

289 

290 

291class TestAgentResult: 

292 """AgentResult 统计正确性。""" 

293 

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("天气?") 

305 

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)