Coverage for agentos/agent/production.py: 35%

171 statements  

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

1"""ProductionAgent — ToolAgent with ModelRouter + AuditLogger + SmartCache. 

2 

3ProductionAgent wraps the standard ToolAgent with production-grade features: 

4 - ModelRouter: auto-selects the best model per task (complexity-aware) 

5 - AuditLogger: immutable audit trail of every tool call and step 

6 - SmartCache: LLM response caching (exact + fuzzy match) reduces API costs 

7 - Automatic cost/latency tracking and budget enforcement 

8 - Structured task classification (trivial → expert) 

9 

10Usage: 

11 from agentos.agent import ProductionAgent, ToolExecutor 

12 from agentos.llm import create_provider, SmartCache 

13 from agentos.agent.model_router import ModelRouter 

14 

15 router = ModelRouter.with_defaults(daily_budget_usd=50.0) 

16 cache = SmartCache() 

17 provider = create_provider("openai", api_key="...") 

18 executor = ToolExecutor() 

19 executor.register(...) 

20 

21 agent = ProductionAgent(provider, executor, router=router, cache=cache) 

22 result = agent.run("总结这篇论文的核心观点") 

23 # → automatically routes to gpt-4o for complex analysis 

24 # → caches responses to save cost on repeated queries 

25 # → all tool calls audited in audit.jsonl 

26 

27v1.9.12: +SmartCache integration, cache-aware cost tracking. 

28v1.9.10: Initial — ModelRouter + AuditLogger bidirectional integration. 

29""" 

30 

31from __future__ import annotations 

32 

33import time 

34import uuid 

35from dataclasses import dataclass, field 

36from typing import Any, Callable, Generator, Optional 

37 

38from agentos.agent.tool_agent import ( 

39 ToolAgent, 

40 ToolExecutor, 

41 AgentConfig, 

42 AgentStep, 

43 AgentResult, 

44) 

45from agentos.agent.model_router import ( 

46 ModelRouter, 

47 ModelSpec, 

48 RequestSpec, 

49 TaskComplexity, 

50 TaskPriority, 

51) 

52from agentos.security.audit_logger import ( 

53 AuditLogger, 

54 AuditSeverity, 

55 AuditActionCategory, 

56) 

57from agentos.llm.base import LLMProvider 

58from agentos.llm.smart_cache import SmartCache 

59 

60 

61__all__ = [ 

62 "ProductionAgent", 

63 "ProductionConfig", 

64 "ComplexityEstimator", 

65 "ComplexityEstimate", 

66] 

67 

68 

69# ── Complexity Estimator ──────────────────────────────────────────── 

70 

71@dataclass 

72class ComplexityEstimate: 

73 complexity: TaskComplexity 

74 priority: TaskPriority 

75 estimated_tokens: int 

76 reason: str 

77 

78 

79class ComplexityEstimator: 

80 """Estimates task complexity from the user's request text. 

81 

82 Uses keyword heuristics to classify tasks from TRIVIAL to EXPERT. 

83 Production use should integrate with a lightweight classifier model. 

84 """ 

85 

86 # keywords that indicate higher complexity 

87 COMPLEX_KEYWORDS = [ 

88 "分析", "对比", "比较", "总结", "调研", "研究", "评估", 

89 "analyze", "compare", "research", "evaluate", "assess", 

90 "代码审查", "架构", "重构", "安全审计", 

91 "code review", "architecture", "refactor", "audit", 

92 ] 

93 EXPERT_KEYWORDS = [ 

94 "深度", "全面", "完整", "生产级", "企业级", "从零", 

95 "comprehensive", "production", "enterprise", "from scratch", 

96 "论文", "学术", "法律", 

97 "paper", "academic", "legal", 

98 ] 

99 TRIVIAL_KEYWORDS = [ 

100 "天气", "时间", "翻译", "计算", "换算", "几点", "日期", 

101 "weather", "time", "translate", "calculate", "convert", 

102 ] 

103 URGENT_KEYWORDS = [ 

104 "快", "紧急", "马上", "立即", 

105 "urgent", "asap", "immediately", "now", 

106 ] 

107 

108 def estimate(self, task: str) -> ComplexityEstimate: 

109 task_lower = task.lower() 

110 

111 # check for urgency 

112 priority = TaskPriority.NORMAL 

113 for kw in self.URGENT_KEYWORDS: 

114 if kw in task_lower: 

115 priority = TaskPriority.HIGH 

116 break 

117 

118 # complexity 

119 complexity = TaskComplexity.MODERATE 

120 

121 expert_hits = sum(1 for kw in self.EXPERT_KEYWORDS if kw in task_lower) 

122 complex_hits = sum(1 for kw in self.COMPLEX_KEYWORDS if kw in task_lower) 

123 trivial_hits = sum(1 for kw in self.TRIVIAL_KEYWORDS if kw in task_lower) 

124 

125 if expert_hits >= 2 or len(task) > 500: 

126 complexity = TaskComplexity.EXPERT 

127 elif expert_hits >= 1 or complex_hits >= 3: 

128 complexity = TaskComplexity.COMPLEX 

129 elif complex_hits >= 1 or len(task) > 200: 

130 complexity = TaskComplexity.MODERATE 

131 elif trivial_hits >= 1: 

132 complexity = TaskComplexity.TRIVIAL 

133 else: 

134 complexity = TaskComplexity.SIMPLE 

135 

136 # estimate token count 

137 # rough heuristic: ~1.5 chars per token for Chinese, ~4 for English 

138 char_count = len(task) 

139 if any('\u4e00' <= c <= '\u9fff' for c in task): 

140 estimated_tokens = max(50, char_count // 1.5) 

141 else: 

142 estimated_tokens = max(50, char_count // 4) 

143 

144 estimated_tokens = int(min(estimated_tokens, 100_000)) 

145 

146 return ComplexityEstimate( 

147 complexity=complexity, 

148 priority=priority, 

149 estimated_tokens=estimated_tokens, 

150 reason=f"task_len={char_count} chars, expert_hits={expert_hits}, " 

151 f"complex_hits={complex_hits}, trivial_hits={trivial_hits}", 

152 ) 

153 

154 

155# ── ProductionConfig ──────────────────────────────────────────────── 

156 

157@dataclass 

158class ProductionConfig: 

159 agent_config: AgentConfig = field(default_factory=AgentConfig) 

160 enable_audit: bool = True 

161 enable_routing: bool = True 

162 enable_cache: bool = True 

163 audit_log_dir: str = "" 

164 session_id: str = "" 

165 budget_usd: float = 50.0 

166 fallback_on_error: bool = True 

167 

168 

169# ── ProductionAgent ───────────────────────────────────────────────── 

170 

171class ProductionAgent: 

172 """Production-grade ToolAgent wrapper with routing and auditing. 

173 

174 Architecture: 

175 User Task 

176 

177 

178 ComplexityEstimator → classifies task 

179 

180 

181 ModelRouter → selects best model for this complexity 

182 

183 

184 ToolAgent.run() ───→ AuditLogger (every step & tool call) 

185 

186 

187 AgentResult (with routing metadata + audit trail) 

188 

189 All tool calls and agent steps are automatically audited with 

190 SHA256-chained immutable entries. Model selection is automatic 

191 based on task analysis. 

192 """ 

193 

194 def __init__( 

195 self, 

196 provider: LLMProvider, 

197 tool_executor: ToolExecutor, 

198 *, 

199 config: ProductionConfig | None = None, 

200 router: ModelRouter | None = None, 

201 cache: SmartCache | None = None, 

202 system_prompt: str = "", 

203 ): 

204 self._config = config or ProductionConfig() 

205 

206 # cache — wrap provider transparently 

207 if cache and self._config.enable_cache: 

208 self._cache = cache 

209 self._provider = cache.wrap(provider) 

210 else: 

211 self._cache = None 

212 self._provider = provider 

213 

214 self._executor = tool_executor 

215 self._system_prompt = system_prompt 

216 

217 # routing 

218 self._router = router or ModelRouter.with_defaults( 

219 daily_budget_usd=self._config.budget_usd, 

220 ) 

221 self._estimator = ComplexityEstimator() 

222 

223 # auditing 

224 self._session_id = self._config.session_id or f"sess-{uuid.uuid4().hex[:8]}" 

225 self._audit = AuditLogger( 

226 log_dir=self._config.audit_log_dir, 

227 max_events=100_000, 

228 max_age_days=90, 

229 ) if self._config.enable_audit else None 

230 

231 # track routing decisions 

232 self._last_route: Optional[RequestSpec] = None 

233 self._last_model: Optional[ModelSpec] = None 

234 

235 # ── public API ────────────────────────────────────────────── 

236 

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

238 t_start = time.time() 

239 

240 # 1. classify 

241 estimate = self._estimator.estimate(task) 

242 

243 # 2. route 

244 route_spec = RequestSpec( 

245 estimated_input_tokens=estimate.estimated_tokens, 

246 estimated_output_tokens=estimate.estimated_tokens // 2, 

247 complexity=estimate.complexity, 

248 priority=estimate.priority, 

249 task_id=f"task-{uuid.uuid4().hex[:8]}", 

250 session_id=self._session_id, 

251 ) 

252 self._last_route = route_spec 

253 route_result = self._router.route(route_spec) 

254 

255 if not route_result.success: 

256 return AgentResult( 

257 success=False, 

258 error=f"Model routing failed: {route_result.reason}", 

259 ) 

260 

261 self._last_model = route_result.model 

262 

263 # audit: route decision 

264 if self._audit: 

265 self._audit.log( 

266 agent="production", 

267 action="model_route", 

268 target=route_result.model.name, 

269 result="success", 

270 severity=AuditSeverity.INFO, 

271 category=AuditActionCategory.CONFIG_CHANGE, 

272 session_id=self._session_id, 

273 details={ 

274 "complexity": estimate.complexity.name, 

275 "priority": estimate.priority.name, 

276 "estimated_tokens": estimate.estimated_tokens, 

277 "estimated_cost": round(route_result.estimated_cost, 6), 

278 "fallback_chain": route_result.fallback_chain, 

279 "budget_remaining": round(self._router.daily_budget_remaining, 4), 

280 }, 

281 ) 

282 

283 # 3. build agent config with model info 

284 agent_config = AgentConfig( 

285 max_steps=self._config.agent_config.max_steps, 

286 temperature=self._config.agent_config.temperature, 

287 max_tokens=self._config.agent_config.max_tokens, 

288 verbose=self._config.agent_config.verbose, 

289 stop_on_error=self._config.agent_config.stop_on_error, 

290 max_retries=self._config.agent_config.max_retries, 

291 retry_delay=self._config.agent_config.retry_delay, 

292 ) 

293 

294 # 4. create tool agent and wrap tool executor with audit 

295 agent = ToolAgent( 

296 provider=self._provider, 

297 tool_executor=self._make_audited_executor(), 

298 config=agent_config, 

299 system_prompt=self._system_prompt, 

300 ) 

301 

302 # 5. run 

303 if self._audit: 

304 self._audit.log( 

305 agent="production", 

306 action="agent_start", 

307 target=task[:100], 

308 result="success", 

309 severity=AuditSeverity.INFO, 

310 category=AuditActionCategory.AGENT_INVOKE, 

311 session_id=self._session_id, 

312 details={ 

313 "complexity": estimate.complexity.name, 

314 "model": route_result.model.name, 

315 "cost_estimate": round(route_result.estimated_cost, 6), 

316 }, 

317 ) 

318 

319 result = agent.run(task) 

320 elapsed_ms = (time.time() - t_start) * 1000 

321 

322 # 6. record model stats 

323 self._router.record_request( 

324 model_name=route_result.model.name, 

325 success=result.success, 

326 tokens_used=result.total_tokens, 

327 cost_usd=result.total_cost_usd, 

328 latency_ms=elapsed_ms, 

329 ) 

330 

331 # 7. audit: agent completion 

332 if self._audit: 

333 self._audit.log( 

334 agent="production", 

335 action="agent_end", 

336 result="success" if result.success else "failure", 

337 severity=AuditSeverity.ERROR if not result.success else AuditSeverity.INFO, 

338 category=AuditActionCategory.AGENT_INVOKE, 

339 session_id=self._session_id, 

340 duration_ms=elapsed_ms, 

341 error_message=result.error or "", 

342 details={ 

343 "steps": result.total_steps, 

344 "tokens": result.total_tokens, 

345 "cost": round(result.total_cost_usd, 6), 

346 "model": route_result.model.name, 

347 }, 

348 ) 

349 

350 # attach routing metadata to result 

351 result.total_cost_usd = result.total_cost_usd 

352 result.total_duration_ms = elapsed_ms 

353 

354 # 8. capture cache stats 

355 if self._cache and result.success: 

356 # track estimated cost savings from cache 

357 self._cache._stats.total_cost_saved_usd += route_result.estimated_cost 

358 

359 return result 

360 

361 # ── properties ─────────────────────────────────────────────── 

362 

363 @property 

364 def last_route(self) -> Optional[RequestSpec]: 

365 """The last routing request spec.""" 

366 return self._last_route 

367 

368 @property 

369 def last_model(self) -> Optional[ModelSpec]: 

370 """The model used in the last run.""" 

371 return self._last_model 

372 

373 @property 

374 def cache_stats(self) -> Optional[Any]: 

375 """Cache statistics if cache is enabled, else None. 

376 

377 Returns a CacheStats dataclass with fields: 

378 hits, misses, fuzzy_hits, exact_hits, evictions, 

379 total_cost_saved_usd, total_entries, hit_rate, fuzzy_hit_rate. 

380 """ 

381 return self._cache.stats if self._cache else None 

382 

383 @property 

384 def cache_hit_rate(self) -> float: 

385 """Cache hit rate (0.0-1.0). Returns 0.0 if cache disabled.""" 

386 if not self._cache: 

387 return 0.0 

388 s = self._cache.stats 

389 total = s.hits + s.misses 

390 return s.hits / total if total > 0 else 0.0 

391 

392 @property 

393 def cache_savings(self) -> float: 

394 """Estimated USD saved by cache hits.""" 

395 if not self._cache: 

396 return 0.0 

397 return self._cache.stats.total_cost_saved_usd 

398 

399 # ── streaming ──────────────────────────────────────────────── 

400 

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

402 """Streaming version — yields steps as they complete.""" 

403 estimate = self._estimator.estimate(task) 

404 route_spec = RequestSpec( 

405 estimated_input_tokens=estimate.estimated_tokens, 

406 estimated_output_tokens=estimate.estimated_tokens // 2, 

407 complexity=estimate.complexity, 

408 priority=estimate.priority, 

409 task_id=f"task-{uuid.uuid4().hex[:8]}", 

410 session_id=self._session_id, 

411 ) 

412 self._last_route = route_spec 

413 route_result = self._router.route(route_spec) 

414 

415 agent = ToolAgent( 

416 provider=self._provider, 

417 tool_executor=self._make_audited_executor(), 

418 config=self._config.agent_config, 

419 system_prompt=self._system_prompt, 

420 ) 

421 

422 yield from agent.run_stream(task) 

423 

424 # ── accessors ─────────────────────────────────────────────── 

425 

426 @property 

427 def router(self) -> ModelRouter: 

428 return self._router 

429 

430 @property 

431 def audit(self) -> Optional[AuditLogger]: 

432 return self._audit 

433 

434 @property 

435 def session_id(self) -> str: 

436 return self._session_id 

437 

438 @property 

439 def last_route(self) -> Optional[RequestSpec]: 

440 return self._last_route 

441 

442 @property 

443 def last_model(self) -> Optional[ModelSpec]: 

444 return self._last_model 

445 

446 def route_summary(self) -> dict: 

447 """Return routing + audit summary for the current session.""" 

448 summary = { 

449 "session_id": self._session_id, 

450 "router": self._router.summary() if self._router else {}, 

451 } 

452 if self._audit: 

453 summary["audit"] = self._audit.stats_summary() 

454 if self._last_model: 

455 summary["last_model"] = self._last_model.name 

456 summary["last_model_tier"] = self._last_model.tier.name 

457 return summary 

458 

459 # ── internal ──────────────────────────────────────────────── 

460 

461 def _make_audited_executor(self) -> ToolExecutor: 

462 """Wrap tool executor to auto-audit every call.""" 

463 if not self._audit: 

464 return self._executor 

465 

466 audited = ToolExecutor() 

467 

468 # copy original tools with audit wrapping 

469 for schema in self._executor.get_schemas(): 

470 original_name = schema.function.name 

471 

472 # Capture the original execute method for this tool 

473 def make_wrapper(name: str) -> Callable[..., str]: 

474 def wrapper(**kwargs: Any) -> str: 

475 t_start = time.time() 

476 try: 

477 result = self._executor.execute( 

478 type("FakeCall", (), { 

479 "name": name, 

480 "parsed_arguments": kwargs, 

481 })() 

482 ) 

483 elapsed = (time.time() - t_start) * 1000 

484 self._audit.log( 

485 agent="production", 

486 action=f"tool:{name}", 

487 target=str(list(kwargs.keys())), 

488 result="success", 

489 severity=AuditSeverity.DEBUG, 

490 category=AuditActionCategory.TOOL_CALL, 

491 session_id=self._session_id, 

492 duration_ms=elapsed, 

493 details={"arguments": kwargs}, 

494 ) 

495 return result 

496 except Exception as exc: 

497 elapsed = (time.time() - t_start) * 1000 

498 self._audit.log( 

499 agent="production", 

500 action=f"tool:{name}", 

501 target=str(list(kwargs.keys())), 

502 result="failure", 

503 severity=AuditSeverity.ERROR, 

504 category=AuditActionCategory.TOOL_CALL, 

505 session_id=self._session_id, 

506 duration_ms=elapsed, 

507 error_message=str(exc), 

508 ) 

509 raise 

510 return wrapper 

511 

512 audited.register(schema, make_wrapper(original_name)) 

513 

514 return audited