Coverage for agentos/core/loop.py: 32%

312 statements  

« prev     ^ index     » next       coverage.py v7.14.3, created at 2026-07-08 13:14 +0800

1""" # noqa: E501 

2AgentOS v0.70 核心循环 — Gemini + Metrics + CostAnalytics 集成版。 

3v0.40: Swarm多Agent并行、Agent间通信、语义缓存、任务队列。 

4v0.70: MetricsCollector、CostAnalytics实时监控。 

5""" 

6 

7from __future__ import annotations 

8 

9import asyncio 

10import time 

11from collections.abc import Callable 

12from dataclasses import dataclass, field 

13from datetime import UTC 

14from enum import StrEnum 

15 

16from agentos.checkpoint.base import Checkpoint, CheckpointBackend, CheckpointMetadata 

17from agentos.comm.layer import CommunicationLayer 

18from agentos.core.context import ContextManager 

19from agentos.core.streaming import StreamChunk 

20from agentos.cost.tracker import CostTracker 

21from agentos.models.router import AllModelsFailed, ModelRouter 

22from agentos.observability.cost_analytics import CostAnalytics 

23from agentos.observability.metrics import MetricsCollector 

24from agentos.observability.tracer import Tracer 

25from agentos.security.sandbox import SandboxManager 

26from agentos.storage.base import CheckpointStore 

27from agentos.swarm.coordinator import AgentRole, SwarmCoordinator, SwarmResult, SwarmTopology 

28from agentos.tools.audit_logger import AuditEvent, AuditLogger, Severity 

29from agentos.tools.rate_limiter import TokenBucket 

30from agentos.tools.registry import ToolRegistry 

31 

32 

33class LoopState(StrEnum): 

34 """主循环状态。""" 

35 

36 RUNNING = "running" 

37 PAUSED = "paused" 

38 WAITING_HUMAN = "waiting_human" 

39 COMPLETED = "completed" 

40 FAILED = "failed" 

41 CANCELLED = "cancelled" 

42 

43 

44@dataclass 

45class AgentResult: 

46 """Agent 主循环的最终运行结果。""" 

47 

48 output: str 

49 iterations: int 

50 tokens_used: dict[str, int] = field(default_factory=dict) 

51 cost_usd: float = 0.0 

52 duration_ms: float = 0.0 

53 tool_calls_total: int = 0 

54 reflections_count: int = 0 

55 human_interrupts: int = 0 

56 final_state: LoopState = LoopState.COMPLETED 

57 error: str | None = None 

58 # v0.40 

59 swarm_result: SwarmResult | None = None 

60 cache_hit: bool = False 

61 

62 

63@dataclass 

64class LoopConfig: 

65 """Agent 主循环的运行时配置。""" 

66 

67 max_iterations: int = 100 

68 max_retries_per_step: int = 2 

69 step_timeout_seconds: int = 120 

70 enable_streaming: bool = False 

71 enable_checkpoints: bool = True 

72 checkpoint_interval: int = 5 

73 # v0.30 

74 enable_reflection: bool = True 

75 reflection_frequency: int = 3 

76 max_reflection_loops: int = 3 

77 enable_self_critique: bool = True 

78 enable_human_in_the_loop: bool = False 

79 human_approval_trigger: str = "high_risk" 

80 enable_cost_tracking: bool = True 

81 auto_select_model: bool = True 

82 # v0.40 

83 enable_swarm: bool = False 

84 swarm_topology: str = "sequential" 

85 swarm_roles: list[AgentRole] = field(default_factory=list) 

86 max_parallel_agents: int = 4 

87 enable_comm_layer: bool = True 

88 enable_semantic_cache: bool = True 

89 # v1.11.0 — long-running task support 

90 checkpoint_backend: CheckpointBackend | None = ( 

91 None # Full checkpoint backend for crash recovery 

92 ) 

93 enable_auto_paging: bool = True # Auto-evict old memories when context fills 

94 auto_page_threshold: float = 0.85 # Page out at 85% context window usage 

95 

96 

97class MaxIterationsExceeded(Exception): # noqa: N818 

98 """超出最大迭代次数异常。""" 

99 

100 

101 

102class HumanInterruptNeeded(Exception): # noqa: N818 

103 """需要人工介入异常。""" 

104 

105 def __init__(self, message: str, context: dict | None = None): 

106 super().__init__(message) 

107 self.context = context or {} 

108 

109 

110@dataclass 

111class ReflectionResult: 

112 """反思结果。""" 

113 

114 quality_score: float 

115 issues: list[str] 

116 suggestions: list[str] 

117 should_continue: bool 

118 new_plan: str | None = None 

119 

120 

121class AgentLoop: 

122 """v0.30 核心循环 — Reflection + HITL + Self-Critique + 自动路由 + 成本追踪。""" 

123 

124 def __init__( 

125 self, 

126 model_router: ModelRouter, 

127 tool_registry: ToolRegistry, 

128 context_manager: ContextManager, 

129 sandbox_manager: SandboxManager | None = None, 

130 tracer: Tracer | None = None, 

131 checkpoint_store: CheckpointStore | None = None, 

132 checkpoint_backend: CheckpointBackend | None = None, 

133 cost_tracker: CostTracker | None = None, 

134 config: LoopConfig | None = None, 

135 on_iteration: Callable | None = None, 

136 on_stream: Callable[[StreamChunk], None] | None = None, 

137 on_human_interrupt: Callable[[str, dict], str | None] | None = None, 

138 on_reflection: Callable[[ReflectionResult], None] | None = None, 

139 metrics_collector: MetricsCollector | None = None, 

140 cost_analytics: CostAnalytics | None = None, 

141 audit_logger: AuditLogger | None = None, 

142 rate_limiter: TokenBucket | None = None, 

143 ): 

144 self.model_router = model_router 

145 self.tool_registry = tool_registry 

146 self.context_manager = context_manager 

147 self.sandbox_manager = sandbox_manager 

148 self.tracer = tracer or Tracer.noop() 

149 self.checkpoint_store = checkpoint_store 

150 self.cost_tracker = cost_tracker or CostTracker.noop() 

151 self.config = config or LoopConfig() 

152 self.checkpoint_backend = checkpoint_backend # v1.11.0 full checkpoint integration 

153 self._auto_page_callback: Callable | None = None # v1.11.0 auto-paging callback 

154 self.on_iteration = on_iteration 

155 self.on_stream = on_stream 

156 self.on_human_interrupt = on_human_interrupt 

157 self.on_reflection = on_reflection 

158 self.metrics = metrics_collector or MetricsCollector() 

159 self.cost_analytics = cost_analytics or CostAnalytics(self.cost_tracker) 

160 self.audit_logger = audit_logger 

161 self.rate_limiter = rate_limiter 

162 self._cancelled = False 

163 self._reflection_history: list[ReflectionResult] = [] 

164 self._human_interrupts = 0 

165 

166 # ── 运行入口 ────────────────────────────────── 

167 

168 async def run(self, task: str, session_id: str = "") -> AgentResult: 

169 start_time = time.time() 

170 await self.context_manager.init_session(session_id, task) 

171 

172 if self.audit_logger: 

173 self.audit_logger.log( 

174 event=AuditEvent( 

175 actor="agentos", 

176 action="loop.start", 

177 resource=session_id, 

178 outcome="initiated", 

179 details={"task": task[:200]}, 

180 ) 

181 ) 

182 

183 if self.config.auto_select_model: 

184 await self._auto_route_model(task) 

185 

186 iteration = await self._try_restore(session_id) 

187 tool_calls_total = 0 

188 reflection_loops = 0 

189 

190 while iteration < self.config.max_iterations and not self._cancelled: 

191 iteration += 1 

192 

193 if self.config.enable_reflection and iteration % self.config.reflection_frequency == 0: 

194 with self.tracer.step("reflection"): 

195 reflection = await self._reflect(session_id) 

196 self._reflection_history.append(reflection) 

197 if ( 

198 not reflection.should_continue 

199 and reflection_loops < self.config.max_reflection_loops 

200 ): 

201 reflection_loops += 1 

202 if reflection.new_plan: 

203 self.context_manager.update_plan(reflection.new_plan) 

204 continue 

205 

206 try: 

207 with self.tracer.step(f"loop_{iteration}"): 

208 step_result = await self._execute_step_sync(iteration, session_id) 

209 

210 if step_result.is_terminal: 

211 duration_ms = (time.time() - start_time) * 1000 

212 if self.audit_logger: 

213 self.audit_logger.log( 

214 event=AuditEvent( 

215 actor="agentos", 

216 action="loop.complete", 

217 resource=session_id, 

218 outcome="success", 

219 details={"iterations": iteration, "duration_ms": duration_ms}, 

220 ) 

221 ) 

222 return AgentResult( 

223 output=step_result.content, 

224 iterations=iteration, 

225 tokens_used=self.tracer.token_summary(), 

226 cost_usd=self.cost_tracker.total_cost, 

227 duration_ms=duration_ms, 

228 tool_calls_total=tool_calls_total, 

229 reflections_count=len(self._reflection_history), 

230 human_interrupts=self._human_interrupts, 

231 ) 

232 

233 if step_result.tool_results: 

234 tool_calls_total += len(step_result.tool_results) 

235 

236 if self.on_iteration: 

237 self.on_iteration(iteration, step_result.tool_results or []) 

238 

239 except HumanInterruptNeeded as e: 

240 self._human_interrupts += 1 

241 if self.audit_logger: 

242 self.audit_logger.log( 

243 event=AuditEvent( 

244 actor="agentos", 

245 action="loop.human_interrupt", 

246 resource=session_id, 

247 outcome="paused", 

248 severity=Severity.WARNING, 

249 details={ 

250 "interrupt_count": self._human_interrupts, 

251 "message": str(e)[:200], 

252 }, 

253 ) 

254 ) 

255 if self.on_human_interrupt: 

256 feedback = self.on_human_interrupt(str(e), e.context) 

257 if feedback: 

258 self.context_manager.append_user_message(feedback) 

259 continue 

260 

261 except StepTimeoutError: 

262 return AgentResult( 

263 output="", 

264 iterations=iteration, 

265 final_state=LoopState.FAILED, 

266 error="Step timeout", 

267 ) 

268 

269 if self.config.enable_checkpoints and iteration % self.config.checkpoint_interval == 0: 

270 await self._save_checkpoint(session_id, iteration) 

271 

272 raise MaxIterationsExceeded(f"超过 {self.config.max_iterations} 步") 

273 

274 # ── Reflection ──────────────────────────────── 

275 

276 async def _reflect(self, session_id: str) -> ReflectionResult: 

277 prompt = f"""你是一个反思者。审核以下Agent执行过程: 

278 

279任务: {self.context_manager.current_task} 

280已执行: {self.context_manager.step_count} 步 

281 

282评估并返回JSON: 

283{{"quality_score": 0.0-1.0, "issues": [...], "suggestions": [...], "should_continue": true/false, "new_plan": "如果调整,新计划"}}""" # noqa: E501 

284 

285 resp = await self.model_router.call_simple(prompt) 

286 try: 

287 import json 

288 

289 d = json.loads(resp) 

290 result = ReflectionResult( 

291 quality_score=d.get("quality_score", 0.5), 

292 issues=d.get("issues", []), 

293 suggestions=d.get("suggestions", []), 

294 should_continue=d.get("should_continue", True), 

295 new_plan=d.get("new_plan"), 

296 ) 

297 except Exception: 

298 result = ReflectionResult(0.5, [], [], True) 

299 if self.on_reflection: 

300 self.on_reflection(result) 

301 return result 

302 

303 # ── Self-Critique ───────────────────────────── 

304 

305 async def _self_critique(self, text: str) -> str: 

306 if not self.config.enable_self_critique: 

307 return text 

308 prompt = f"""审视以下回答,找出逻辑错误或不准确之处。如果已足够好就原样返回。 

309 

310{text[:3000]}""" 

311 improved = await self.model_router.call_simple(prompt) 

312 return improved or text 

313 

314 # ── Auto Route ──────────────────────────────── 

315 

316 async def _auto_route_model(self, task: str): 

317 score = self._estimate_complexity(task) 

318 if score > 0.7: 

319 self.model_router.set_preferred("deepseek-r1") 

320 elif score > 0.4: 

321 self.model_router.set_preferred("kimi-k2.6") 

322 else: 

323 self.model_router.set_preferred("deepseek-v3.1") 

324 

325 def _estimate_complexity(self, task: str) -> float: 

326 kw = [ 

327 "分析", 

328 "对比", 

329 "设计", 

330 "架构", 

331 "review", 

332 "refactor", 

333 "实现", 

334 "优化", 

335 "诊断", 

336 "troubleshoot", 

337 "debug", 

338 "deploy", 

339 "migrate", 

340 "安全", 

341 "security", 

342 ] 

343 score = sum(0.15 for k in kw if k in task.lower()) 

344 return min(score + min(len(task) / 2000, 0.3), 1.0) 

345 

346 # ── 步骤执行 ────────────────────────────────── 

347 

348 async def _execute_step_sync(self, iteration: int, session_id: str) -> StepResult: 

349 last_error = None 

350 for attempt in range(self.config.max_retries_per_step + 1): 

351 try: 

352 return await asyncio.wait_for( 

353 self._do_step(iteration, session_id), timeout=self.config.step_timeout_seconds 

354 ) 

355 except TimeoutError: 

356 last_error = StepTimeoutError(f"Step {iteration} timeout") 

357 except AllModelsFailed as e: 

358 last_error = e 

359 await asyncio.sleep(2**attempt) 

360 raise last_error 

361 

362 async def _do_step(self, iteration: int, session_id: str) -> StepResult: 

363 ctx = self.context_manager.build_context( 

364 model_type=self.model_router.model_type, 

365 tools=self.tool_registry.get_schemas_for_model(self.model_router.model_type), 

366 ) 

367 

368 # v1.11.0 — auto-page old memories if context nearing limit 

369 if self.config.enable_auto_paging and self._auto_page_callback: 

370 usage_ratio = self.context_manager.estimate_context_usage() 

371 if usage_ratio > self.config.auto_page_threshold: 

372 await self._auto_page_callback(usage_ratio) 

373 

374 # v1.16.6 — rate limiting before model calls 

375 if self.rate_limiter and not self.rate_limiter.try_acquire("model_call"): 

376 raise StepTimeoutError(f"Rate limit exceeded for model call at step {iteration}") 

377 

378 resp = await self.model_router.call(ctx) 

379 

380 # 成本记录 

381 if self.config.enable_cost_tracking and hasattr(resp, "usage"): 

382 self.cost_tracker.record(self.model_router.current_model, resp.usage) 

383 

384 if not resp.tool_calls: 

385 if self.config.enable_self_critique: 

386 improved = await self._self_critique(resp.content) 

387 return StepResult(content=improved, is_terminal=True) 

388 return StepResult(content=resp.content, is_terminal=True) 

389 

390 # HITL 检查 

391 if self.config.enable_human_in_the_loop: 

392 for tc in resp.tool_calls: 

393 if self._is_high_risk(tc): 

394 raise HumanInterruptNeeded( 

395 f"高风险操作需确认: {tc.name}", {"tool": tc.name, "args": tc.arguments} 

396 ) 

397 

398 groups = self._group_independent_calls(resp.tool_calls) 

399 all_results = [] 

400 for group in groups: 

401 sandbox = self.sandbox_manager.get_sandbox(session_id) if self.sandbox_manager else None 

402 batch_results = await self.tool_registry.execute_batch(group, sandbox=sandbox) 

403 all_results.extend(batch_results) 

404 

405 self.context_manager.append_tool_results(all_results) 

406 return StepResult(content="", is_terminal=False, tool_results=all_results) 

407 

408 def _is_high_risk(self, tc) -> bool: 

409 risky = ["delete", "rm", "uninstall", "format", "sudo", "kill", "drop"] 

410 name = tc.name.lower() if hasattr(tc, "name") else tc.get("name", "").lower() 

411 return any(r in name for r in risky) 

412 

413 def _group_independent_calls(self, tool_calls: list) -> list[list]: 

414 if len(tool_calls) <= 1: 

415 return [tool_calls] if tool_calls else [] 

416 groups: list[list] = [] 

417 for call in tool_calls: 

418 for group in groups: 

419 if not self._has_conflict(call, group): 

420 group.append(call) 

421 break 

422 else: 

423 groups.append([call]) 

424 return groups 

425 

426 def _has_conflict(self, call, group: list) -> bool: 

427 write_paths = set() 

428 for tc in group: 

429 tool = self.tool_registry.get(tc.name) 

430 if tool and tool.is_write_operation(tc.arguments): 

431 if p := tool.extract_target_path(tc.arguments): 

432 write_paths.add(p) 

433 cur = self.tool_registry.get(call.name) 

434 if cur and cur.is_read_operation(call.arguments): 

435 return cur.extract_target_path(call.arguments) in write_paths 

436 return False 

437 

438 # ── v1.11.0 全量 Checkpoint (完整状态快照) ──── 

439 

440 async def _save_checkpoint(self, session_id: str, iteration: int): 

441 """Save full runtime state snapshot via CheckpointBackend.""" 

442 backend = self.checkpoint_backend 

443 if not backend: 

444 # Fallback to thin CheckpointStore 

445 if not self.checkpoint_store: 

446 return 

447 snap = { 

448 "session_id": session_id, 

449 "iteration": iteration, 

450 "messages": [ 

451 {"role": m.role, "content": m.content} for m in self.context_manager._messages 

452 ], 

453 "timestamp": time.time(), 

454 } 

455 await self.checkpoint_store.save(session_id, snap) 

456 return 

457 

458 # Full checkpoint via CheckpointBackend 

459 try: 

460 from datetime import datetime 

461 

462 checkpoint_id = f"ckpt-{session_id}-{iteration:06d}" 

463 parent_id = getattr(self, "_last_checkpoint_id", None) 

464 

465 cp = Checkpoint( 

466 metadata=CheckpointMetadata( 

467 thread_id=session_id, 

468 checkpoint_id=checkpoint_id, 

469 step=iteration, 

470 parent_checkpoint_id=parent_id, 

471 created_at=datetime.now(UTC).isoformat(), 

472 tags=["auto", f"iter_{iteration}"], 

473 ), 

474 messages=[ 

475 {"role": m.role, "content": m.content} for m in self.context_manager._messages 

476 ], 

477 state={ 

478 "iteration": iteration, 

479 "task": self.context_manager.current_task, 

480 "session_id": session_id, 

481 "cost_usd": self.cost_tracker.total_cost, 

482 "reflections": len(self._reflection_history), 

483 "human_interrupts": self._human_interrupts, 

484 "loop_state": ( 

485 self.context_manager.current_state 

486 if hasattr(self.context_manager, "current_state") 

487 else "running" 

488 ), 

489 }, 

490 tools_result={}, 

491 next_node="loop", 

492 ) 

493 await backend.put(cp) 

494 self._last_checkpoint_id = checkpoint_id 

495 

496 except Exception: 

497 pass # Checkpoint failure must not crash the loop 

498 

499 async def _try_restore(self, session_id: str) -> int: 

500 """Restore full state from last checkpoint. Returns iteration to resume from.""" 

501 backend = self.checkpoint_backend 

502 if not backend: 

503 # Fallback to thin CheckpointStore 

504 if not self.checkpoint_store or not self.config.enable_checkpoints: 

505 return 0 

506 snap = await self.checkpoint_store.load(session_id) 

507 if not snap: 

508 return 0 

509 iter_count = snap.get("iteration", 0) 

510 if iter_count > 0: 

511 msgs = snap.get("messages", []) 

512 for msg in msgs: 

513 self.context_manager.append_message(msg["role"], msg["content"]) 

514 return iter_count 

515 

516 if not self.config.enable_checkpoints: 

517 return 0 

518 

519 try: 

520 latest = await backend.get_latest(session_id) 

521 if not latest: 

522 return 0 

523 self._last_checkpoint_id = latest.metadata.checkpoint_id 

524 iter_count = latest.metadata.step 

525 

526 # Restore messages 

527 for msg in latest.messages: 

528 self.context_manager.append_message(msg.get("role", "user"), msg.get("content", "")) 

529 

530 # Restore state 

531 state = latest.state 

532 self._human_interrupts = state.get("human_interrupts", 0) 

533 

534 return iter_count 

535 except Exception: 

536 return 0 

537 

538 def set_auto_paging(self, callback: Callable): 

539 """Register callback for automatic memory paging (v1.11.0).""" 

540 self._auto_page_callback = callback 

541 

542 def cancel(self): 

543 self._cancelled = True 

544 

545 # ── v0.40 Swarm执行 ────────────────────────── 

546 

547 async def run_swarm(self, task: str, roles: list[AgentRole] | None = None) -> AgentResult: 

548 """以Swarm模式执行任务 — 多Agent协作。""" 

549 start_time = time.time() 

550 roles = roles or self.config.swarm_roles 

551 if not roles: 

552 return AgentResult( 

553 output="[Swarm] No roles defined", 

554 iterations=0, 

555 final_state=LoopState.FAILED, 

556 error="No roles", 

557 ) 

558 

559 topology = SwarmTopology(self.config.swarm_topology) 

560 CommunicationLayer() if self.config.enable_comm_layer else None 

561 

562 swarm = SwarmCoordinator( 

563 router=self.model_router, 

564 tool_registry=self.tool_registry, 

565 topology=topology, 

566 max_parallel=self.config.max_parallel_agents, 

567 ) 

568 swarm.register_roles(roles) 

569 

570 swarm_result = await swarm.execute(task, roles) 

571 duration_ms = (time.time() - start_time) * 1000 

572 

573 return AgentResult( 

574 output=swarm_result.combined_output, 

575 iterations=1, 

576 cost_usd=self.cost_tracker.total_cost, 

577 duration_ms=duration_ms, 

578 tool_calls_total=0, 

579 reflections_count=0, 

580 human_interrupts=0, 

581 final_state=LoopState.COMPLETED, 

582 swarm_result=swarm_result, 

583 ) 

584 

585 

586class StepTimeoutError(Exception): 

587 """步骤超时异常。""" 

588 

589 

590 

591@dataclass 

592class StepResult: 

593 """步骤执行结果。""" 

594 

595 content: str 

596 is_terminal: bool = False 

597 tool_results: list | None = None