Coverage for agentos/agent/tool_agent.py: 25%

281 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-06 17:01 +0800

1""" 

2Tool-Using Agent — 基于 LLM Function Calling 的自主 Agent 循环。 

3 

4核心模式: 

5 用户任务 → LLM 推理(tool_calls) → 工具执行 → 结果回传 → 循环直到完成 

6 

7v1.16.1: +CircuitBreaker +ToolOutputValidator +Metrics integrated into ToolExecutor/Agent. 

8v1.3.38: +streaming, retry, checkpoint/resume, tool error handling, mock provider. 

9""" 

10 

11from __future__ import annotations 

12 

13import json 

14import os 

15import time 

16from collections.abc import Callable, Generator 

17from dataclasses import dataclass, field 

18 

19from agentos.llm.base import ( 

20 CompletionChoice, 

21 CompletionResult, 

22 CompletionUsage, 

23 LLMProvider, 

24 Message, 

25 MessageRole, 

26 Tool, 

27 ToolCall, 

28) 

29from agentos.tools.circuit_breaker import CircuitBreaker 

30from agentos.tools.metrics import MetricsCollector 

31from agentos.tools.validation import ToolOutputValidator, ToolResult, ValidationResult 

32 

33__all__ = [ 

34 "ToolAgent", 

35 "AgentConfig", 

36 "AgentStep", 

37 "AgentResult", 

38 "ToolExecutor", 

39 "MockLLMProvider", 

40] 

41 

42 

43# ── 数据类型 ───────────────────────────────────────────────────── 

44 

45 

46@dataclass 

47class AgentConfig: 

48 max_steps: int = 10 

49 temperature: float = 0.0 

50 max_tokens: int = 4096 

51 verbose: bool = False 

52 stop_on_error: bool = True 

53 max_retries: int = 2 

54 retry_delay: float = 0.5 

55 checkpoint_dir: str = "" 

56 

57 

58@dataclass 

59class AgentStep: 

60 step: int 

61 thought: str = "" 

62 tool_calls: list[ToolCall] = field(default_factory=list) 

63 tool_results: dict[str, str] = field(default_factory=dict) 

64 finish_reason: str = "" 

65 tokens_used: int = 0 

66 cost_usd: float = 0.0 

67 duration_ms: float = 0.0 

68 

69 

70@dataclass 

71class AgentResult: 

72 success: bool = True 

73 final_answer: str = "" 

74 steps: list[AgentStep] = field(default_factory=list) 

75 total_steps: int = 0 

76 total_tokens: int = 0 

77 total_cost_usd: float = 0.0 

78 total_duration_ms: float = 0.0 

79 error: str | None = None 

80 

81 

82# ── 工具执行器 ─────────────────────────────────────────────────── 

83 

84 

85class ToolExecutor: 

86 """工具注册与执行器。 

87 

88 v1.16.1: 集成 CircuitBreaker(熔断保护)、ToolOutputValidator(输出校验)、 

89 MetricsCollector(指标收集)。所有参数均为可选,不传则退化为原始行为。 

90 """ 

91 

92 def __init__( 

93 self, 

94 circuit_breaker: CircuitBreaker | None = None, 

95 validator: ToolOutputValidator | None = None, 

96 metrics: MetricsCollector | None = None, 

97 ): 

98 self._tools: dict[str, Callable[..., str]] = {} 

99 self._schemas: dict[str, Tool] = {} 

100 self._cb = circuit_breaker 

101 self._validator = validator 

102 self._metrics = metrics 

103 

104 def register(self, tool: Tool, handler: Callable[..., str]) -> None: 

105 self._tools[tool.function.name] = handler 

106 self._schemas[tool.function.name] = tool 

107 

108 def get_schemas(self) -> list[Tool]: 

109 return list(self._schemas.values()) 

110 

111 def execute(self, tool_call: ToolCall) -> str: 

112 """执行工具调用,经 CircuitBreaker → 执行 → Validator → Metrics 全链路。""" 

113 handler = self._tools.get(tool_call.name) 

114 if handler is None: 

115 return json.dumps({"error": f"Unknown tool: {tool_call.name}"}) 

116 

117 # 1. 熔断器检查 

118 if self._cb is not None: 

119 if not self._cb.allow_request(): 

120 self._cb.record_failure() 

121 return json.dumps( 

122 { 

123 "error": f"Circuit breaker OPEN for '{self._cb.name}'", 

124 "circuit_state": self._cb.state.name, 

125 } 

126 ) 

127 

128 t0 = time.monotonic() 

129 try: 

130 raw_output = str(handler(**tool_call.parsed_arguments)) 

131 

132 # 2. 输出校验 

133 validation_msg = "" 

134 if self._validator is not None: 

135 tr = ToolResult(output=raw_output, tool_name=tool_call.name) 

136 val_result: ValidationResult = self._validator.validate(tr) 

137 if not val_result.is_valid: 

138 issues = "; ".join(i.message for i in val_result.issues) 

139 validation_msg = f" [validation: {issues}]" 

140 

141 # 3. 熔断器记录成功 

142 if self._cb is not None: 

143 self._cb.record_success() 

144 

145 # 4. 指标记录 

146 elapsed = (time.monotonic() - t0) * 1000 

147 if self._metrics is not None: 

148 self._metrics.get_counter("tool_calls_total").inc(tool_call.name) 

149 self._metrics.get_counter("tool_calls_success").inc(tool_call.name) 

150 self._metrics.get_timer("tool_latency_ms").record(elapsed) 

151 

152 return raw_output if not validation_msg else raw_output + validation_msg 

153 

154 except Exception as e: 

155 elapsed = (time.monotonic() - t0) * 1000 

156 if self._cb is not None: 

157 self._cb.record_failure() 

158 if self._metrics is not None: 

159 self._metrics.get_counter("tool_calls_total").inc(tool_call.name) 

160 self._metrics.get_counter("tool_calls_errors").inc(tool_call.name) 

161 return json.dumps({"error": str(e)}) 

162 

163 

164# ── MockLLMProvider ────────────────────────────────────────────── 

165 

166 

167class MockLLMProvider(LLMProvider): 

168 """可编程响应的 Mock Provider,供集成测试使用。""" 

169 

170 def __init__(self, responses: list[dict]): 

171 super().__init__(model="mock", api_key="mock") 

172 self._responses = responses 

173 self._cursor = 0 

174 self.calls: list[dict] = [] 

175 

176 def chat(self, messages=None, *, temperature=0, max_tokens=4096, tools=None, **kwargs): 

177 if self._cursor >= len(self._responses): 

178 return self._build_result({"content": "done", "finish_reason": "stop"}) 

179 resp = self._responses[self._cursor] 

180 self._cursor += 1 

181 self.calls.append( 

182 { 

183 "tools": [t.function.name for t in (tools or [])], 

184 "cursor": self._cursor - 1, 

185 } 

186 ) 

187 return self._build_result(resp) 

188 

189 async def achat(self, *args, **kwargs): 

190 return self.chat(*args, **kwargs) 

191 

192 @property 

193 def provider_name(self) -> str: 

194 return "mock" 

195 

196 @staticmethod 

197 def text_response(content: str, finish_reason: str = "stop") -> dict: 

198 return {"content": content, "finish_reason": finish_reason} 

199 

200 @staticmethod 

201 def tool_response(name: str, arguments: dict, tool_call_id: str = "") -> dict: 

202 tid = tool_call_id or f"tc_{name}" 

203 return { 

204 "content": "", 

205 "tool_calls": [ToolCall(id=tid, name=name, arguments=json.dumps(arguments))], 

206 "finish_reason": "tool_calls", 

207 } 

208 

209 def _build_result(self, resp: dict) -> CompletionResult: 

210 msg = Message( 

211 role=MessageRole.ASSISTANT, 

212 content=resp.get("content", ""), 

213 tool_calls=resp.get("tool_calls"), 

214 ) 

215 choice = CompletionChoice( 

216 index=0, 

217 message=msg, 

218 finish_reason=resp.get("finish_reason", "stop"), 

219 ) 

220 return CompletionResult( 

221 id=f"mock_{self._cursor}", 

222 model="mock-model", 

223 choices=[choice], 

224 usage=CompletionUsage( 

225 prompt_tokens=5, 

226 completion_tokens=len(resp.get("content", "")) + 3, 

227 total_tokens=len(resp.get("content", "")) + 8, 

228 ), 

229 ) 

230 

231 

232# ── Tool-Using Agent ───────────────────────────────────────────── 

233 

234 

235class ToolAgent: 

236 """基于 LLM Function Calling 的自主 Agent。 

237 

238 用法: 

239 from agentos.agent import ToolAgent, ToolExecutor 

240 from agentos.llm import create_provider, Tool 

241 

242 provider = create_provider("openai") 

243 executor = ToolExecutor() 

244 executor.register( 

245 Tool.from_function("get_weather", "获取天气", {"city": ...}), 

246 lambda city: f"{city}: 22°C sunny" 

247 ) 

248 agent = ToolAgent(provider, executor) 

249 result = agent.run("北京天气怎么样?") 

250 print(result.final_answer) 

251 """ 

252 

253 def __init__( 

254 self, 

255 provider: LLMProvider, 

256 tool_executor: ToolExecutor, 

257 *, 

258 config: AgentConfig | None = None, 

259 system_prompt: str = "", 

260 metrics: MetricsCollector | None = None, 

261 ): 

262 self._provider = provider 

263 self._executor = tool_executor 

264 self._config = config or AgentConfig() 

265 self._system_prompt = system_prompt or ( 

266 "你是一个智能助手。你可以使用工具来获取信息。" 

267 "当你可以给出最终答案时,直接回答,不要再调用工具。" 

268 "用中文回答。" 

269 ) 

270 self._metrics = metrics 

271 

272 # ── 同步 ────────────────────────────────────────────────── 

273 

274 def run(self, task: str) -> AgentResult: 

275 t0 = time.monotonic() 

276 steps: list[AgentStep] = [] 

277 tools = self._executor.get_schemas() 

278 messages: list[Message] = [ 

279 Message(role=MessageRole.SYSTEM, content=self._system_prompt), 

280 Message(role=MessageRole.USER, content=task), 

281 ] 

282 return self._run_loop(messages, task, tools, steps, 1, t0) 

283 

284 def _run_loop( 

285 self, 

286 messages, 

287 task, 

288 tools, 

289 steps, 

290 start_step, 

291 t0, 

292 ) -> AgentResult: 

293 final_answer = "" 

294 total_tokens = 0 

295 total_cost = 0.0 

296 step_num = start_step 

297 

298 try: 

299 for step_num in range(start_step, self._config.max_steps + 1): 

300 result = self._call_with_retry(messages, tools) 

301 step, done, final = self._process_step(result, step_num) 

302 total_tokens += step.tokens_used 

303 total_cost += step.cost_usd 

304 steps.append(step) 

305 if done: 

306 final_answer = final 

307 break 

308 messages.append(result.choices[0].message) 

309 for tc in step.tool_calls: 

310 messages.append( 

311 Message( 

312 role=MessageRole.TOOL, 

313 content=step.tool_results.get(tc.id, ""), 

314 tool_call_id=tc.id, 

315 ) 

316 ) 

317 self._checkpoint(messages, task, step_num) 

318 else: 

319 return self._make_result( 

320 False, 

321 "", 

322 steps, 

323 total_tokens, 

324 total_cost, 

325 t0, 

326 f"Reached max steps ({self._config.max_steps}) without final answer", 

327 ) 

328 except Exception as e: 

329 return self._make_result(False, "", steps, total_tokens, total_cost, t0, str(e)) 

330 

331 return self._make_result(True, final_answer, steps, total_tokens, total_cost, t0) 

332 

333 # ── 流式 ────────────────────────────────────────────────── 

334 

335 def run_stream(self, task: str) -> Generator[AgentStep, None, AgentResult]: 

336 t0 = time.monotonic() 

337 steps: list[AgentStep] = [] 

338 tools = self._executor.get_schemas() 

339 messages: list[Message] = [ 

340 Message(role=MessageRole.SYSTEM, content=self._system_prompt), 

341 Message(role=MessageRole.USER, content=task), 

342 ] 

343 total_tokens = 0 

344 total_cost = 0.0 

345 final_answer = "" 

346 

347 try: 

348 for step_num in range(1, self._config.max_steps + 1): 

349 result = self._call_with_retry(messages, tools) 

350 step, done, final = self._process_step(result, step_num) 

351 total_tokens += step.tokens_used 

352 total_cost += step.cost_usd 

353 yield step 

354 steps.append(step) 

355 if done: 

356 final_answer = final 

357 break 

358 messages.append(result.choices[0].message) 

359 for tc in step.tool_calls: 

360 messages.append( 

361 Message( 

362 role=MessageRole.TOOL, 

363 content=step.tool_results.get(tc.id, ""), 

364 tool_call_id=tc.id, 

365 ) 

366 ) 

367 self._checkpoint(messages, task, step_num) 

368 else: 

369 return self._make_result( 

370 False, 

371 "", 

372 steps, 

373 total_tokens, 

374 total_cost, 

375 t0, 

376 f"Reached max steps ({self._config.max_steps}) without final answer", 

377 ) 

378 except Exception as e: 

379 return self._make_result(False, "", steps, total_tokens, total_cost, t0, str(e)) 

380 

381 return self._make_result(True, final_answer, steps, total_tokens, total_cost, t0) 

382 

383 # ── 异步 ────────────────────────────────────────────────── 

384 

385 async def arun(self, task: str) -> AgentResult: 

386 t0 = time.monotonic() 

387 steps: list[AgentStep] = [] 

388 tools = self._executor.get_schemas() 

389 messages: list[Message] = [ 

390 Message(role=MessageRole.SYSTEM, content=self._system_prompt), 

391 Message(role=MessageRole.USER, content=task), 

392 ] 

393 final_answer = "" 

394 total_tokens = 0 

395 total_cost = 0.0 

396 

397 try: 

398 for step_num in range(1, self._config.max_steps + 1): 

399 result = await self._acall_with_retry(messages, tools) 

400 step, done, final = self._process_step(result, step_num) 

401 total_tokens += step.tokens_used 

402 total_cost += step.cost_usd 

403 steps.append(step) 

404 if done: 

405 final_answer = final 

406 break 

407 messages.append(result.choices[0].message) 

408 for tc in step.tool_calls: 

409 messages.append( 

410 Message( 

411 role=MessageRole.TOOL, 

412 content=step.tool_results.get(tc.id, ""), 

413 tool_call_id=tc.id, 

414 ) 

415 ) 

416 self._checkpoint(messages, task, step_num) 

417 else: 

418 return self._make_result( 

419 False, 

420 "", 

421 steps, 

422 total_tokens, 

423 total_cost, 

424 t0, 

425 f"Reached max steps ({self._config.max_steps}) without final answer", 

426 ) 

427 except Exception as e: 

428 return self._make_result(False, "", steps, total_tokens, total_cost, t0, str(e)) 

429 

430 return self._make_result(True, final_answer, steps, total_tokens, total_cost, t0) 

431 

432 # ── 共享步骤逻辑 ─────────────────────────────────────────────── 

433 

434 def _process_step( 

435 self, 

436 result: CompletionResult, 

437 step_num: int, 

438 ) -> tuple[AgentStep, bool, str]: 

439 """处理单步 LLM 结果:构建 AgentStep、执行工具、判断终止。 

440 

441 Returns: 

442 (step, done, final_answer) 

443 """ 

444 step_t0 = time.monotonic() 

445 choice = result.choices[0] 

446 assistant_msg = choice.message 

447 

448 step = AgentStep( 

449 step=step_num, 

450 thought=assistant_msg.content, 

451 tool_calls=assistant_msg.tool_calls or [], 

452 finish_reason=choice.finish_reason, 

453 tokens_used=result.usage.total_tokens, 

454 cost_usd=result.usage.cost_usd, 

455 duration_ms=(time.monotonic() - step_t0) * 1000, 

456 ) 

457 

458 if self._config.verbose: 

459 self._log_step(step) 

460 

461 # Metrics: track LLM calls and tokens 

462 if self._metrics is not None: 

463 self._metrics.get_counter("llm_calls_total").inc() 

464 self._metrics.get_counter("llm_tokens_total").inc(result.usage.total_tokens) 

465 self._metrics.get_counter("agent_steps_total").inc() 

466 

467 # 无工具调用 → 终止,内容即为答案 

468 if not assistant_msg.tool_calls: 

469 return step, True, assistant_msg.content 

470 

471 # 执行工具调用 

472 for tc in assistant_msg.tool_calls: 

473 tool_result = self._executor.execute(tc) 

474 step.tool_results[tc.id] = tool_result 

475 if "error" in tool_result and self._config.stop_on_error: 

476 raise RuntimeError(f"Tool '{tc.name}' error: {tool_result}") 

477 

478 # finish_reason == "stop" → 提前终止 

479 if choice.finish_reason == "stop": 

480 return step, True, assistant_msg.content 

481 

482 return step, False, "" 

483 

484 def _make_result( 

485 self, 

486 success: bool, 

487 answer: str, 

488 steps: list[AgentStep], 

489 total_tokens: int, 

490 total_cost: float, 

491 t0: float, 

492 error: str = None, 

493 ) -> AgentResult: 

494 """统一构造 AgentResult。""" 

495 return AgentResult( 

496 success=success, 

497 final_answer=answer, 

498 steps=steps, 

499 total_steps=len(steps), 

500 total_tokens=total_tokens, 

501 total_cost_usd=total_cost, 

502 total_duration_ms=(time.monotonic() - t0) * 1000, 

503 error=error, 

504 ) 

505 

506 # ── Checkpoint / Resume ─────────────────────────────────── 

507 

508 def resume(self) -> AgentResult: 

509 if not self._config.checkpoint_dir: 

510 raise ValueError("checkpoint_dir not configured") 

511 ckpt_path = os.path.join(self._config.checkpoint_dir, "agent_checkpoint.json") 

512 if not os.path.exists(ckpt_path): 

513 raise FileNotFoundError(f"No checkpoint found at {ckpt_path}") 

514 with open(ckpt_path) as f: 

515 data = json.load(f) 

516 task = data["task"] 

517 start_step = data["step"] + 1 

518 messages_raw = data["messages"] 

519 messages = [ 

520 Message( 

521 role=MessageRole(m["role"]), 

522 content=m["content"], 

523 tool_call_id=m.get("tool_call_id"), 

524 tool_calls=( 

525 [ToolCall(**tc) for tc in m["tool_calls"]] if m.get("tool_calls") else None 

526 ), 

527 ) 

528 for m in messages_raw 

529 ] 

530 t0 = time.monotonic() 

531 tools = self._executor.get_schemas() 

532 steps: list[AgentStep] = [] 

533 return self._run_loop(messages, task, tools, steps, start_step, t0) 

534 

535 def _checkpoint(self, messages: list[Message], task: str, step: int) -> None: 

536 if not self._config.checkpoint_dir: 

537 return 

538 ckpt_path = os.path.join(self._config.checkpoint_dir, "agent_checkpoint.json") 

539 data = { 

540 "task": task, 

541 "step": step, 

542 "messages": [ 

543 { 

544 "role": m.role.value, 

545 "content": m.content, 

546 "tool_call_id": m.tool_call_id, 

547 "tool_calls": ( 

548 [ 

549 {"id": tc.id, "name": tc.name, "arguments": tc.arguments} 

550 for tc in m.tool_calls 

551 ] 

552 if m.tool_calls 

553 else None 

554 ), 

555 } 

556 for m in messages 

557 ], 

558 } 

559 with open(ckpt_path, "w") as f: 

560 json.dump(data, f, ensure_ascii=False) 

561 

562 # ── 内部方法 ────────────────────────────────────────────── 

563 

564 def _call_with_retry(self, messages: list[Message], tools: list[Tool]) -> CompletionResult: 

565 last_error = None 

566 for attempt in range(self._config.max_retries + 1): 

567 try: 

568 return self._provider.chat( 

569 messages, 

570 temperature=self._config.temperature, 

571 max_tokens=self._config.max_tokens, 

572 tools=tools if tools else None, 

573 ) 

574 except Exception as e: 

575 last_error = e 

576 if attempt < self._config.max_retries: 

577 time.sleep(self._config.retry_delay) 

578 raise last_error # type: ignore 

579 

580 async def _acall_with_retry( 

581 self, messages: list[Message], tools: list[Tool] 

582 ) -> CompletionResult: 

583 last_error = None 

584 for attempt in range(self._config.max_retries + 1): 

585 try: 

586 return await self._provider.achat( 

587 messages, 

588 temperature=self._config.temperature, 

589 max_tokens=self._config.max_tokens, 

590 tools=tools if tools else None, 

591 ) 

592 except Exception as e: 

593 last_error = e 

594 if attempt < self._config.max_retries: 

595 import asyncio 

596 

597 await asyncio.sleep(self._config.retry_delay) 

598 raise last_error # type: ignore 

599 

600 def _log_step(self, step: AgentStep) -> None: 

601 print( 

602 f"\n── Step {step.step} ({step.duration_ms:.0f}ms, {step.tokens_used}t, ${step.cost_usd:.6f}) ──" 

603 ) 

604 if step.thought: 

605 print(f" Thought: {step.thought}") 

606 if step.tool_calls: 

607 for tc in step.tool_calls: 

608 result_preview = step.tool_results.get(tc.id, "")[:100] 

609 print(f" Tool: {tc.name}({tc.arguments}) → {result_preview}") 

610 print(f" Finish: {step.finish_reason}")