Coverage for agentos/evaluation/suite.py: 0%

295 statements  

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

1""" 

2Agent Evaluation Suite v2 (v1.9.0) 

3 

4Comprehensive agent evaluation framework — SWE-bench style 

5with multi-dimensional scoring, hallucination detection, 

6CI/CD integration, and statistical analysis. 

7 

8Features: 

9 - SWE-Bench style: end-to-end task completion evaluation 

10 - Multi-round conversation eval: track accuracy over turns 

11 - Tool accuracy: did the agent call the right tools? 

12 - Hallucination detection: detect fabricated facts/outputs 

13 - Regression suite: prevent degradation across versions 

14 - CI exports: JUnit XML, JSON, Markdown reports 

15 - Statistical analysis: p-values, confidence intervals 

16 - Leaderboard: track agent performance over time 

17""" 

18 

19from __future__ import annotations 

20 

21import json 

22import time 

23from dataclasses import dataclass, field 

24from datetime import datetime 

25from enum import Enum 

26from pathlib import Path 

27from typing import Any 

28 

29from agentos.evaluation import GoldenCase, GoldenDataset 

30 

31 

32# ── Scorers ───────────────────────────────────────────────────────── 

33 

34@dataclass 

35class EvalScore: 

36 """Multi-dimensional evaluation score.""" 

37 overall: float = 0.0 # 0.0 - 1.0 

38 accuracy: float = 0.0 # Did the agent get the right answer? 

39 tool_selection: float = 0.0 # Did it pick the right tools? 

40 efficiency: float = 0.0 # Minimal steps to solution? 

41 consistency: float = 0.0 # Repeatable across runs? 

42 hallucination_free: float = 0.0 # No fabricated content? 

43 latency_ms: float = 0.0 # Response time 

44 details: dict[str, Any] = field(default_factory=dict) 

45 

46 

47class EvalCategory(str, Enum): 

48 CODING = "coding" 

49 REASONING = "reasoning" 

50 TOOL_USE = "tool_use" 

51 CONVERSATION = "conversation" 

52 KNOWLEDGE = "knowledge" 

53 SAFETY = "safety" 

54 MATH = "math" 

55 

56 

57# ── Hallucination Detector ────────────────────────────────────────── 

58 

59class HallucinationDetector: 

60 """Detect fabricated content in agent outputs. 

61 

62 Detection methods: 

63 - Reference check: verify against expected output 

64 - Factual consistency: cross-reference with ground truth 

65 - Source citation: does the agent cite real sources? 

66 - Self-contradiction: does the agent contradict itself? 

67 """ 

68 

69 def __init__(self, reference_kb: dict[str, str] | None = None): 

70 self._reference = reference_kb or {} 

71 

72 def detect(self, response: str, expected: str = "", context: dict[str, Any] | None = None) -> dict[str, Any]: 

73 """Analyze a response for hallucination signals. 

74 

75 Args: 

76 response: Agent's actual response 

77 expected: Expected/ground truth response 

78 context: Additional context for detection 

79 

80 Returns: 

81 Dict with hallucination_score (0=no hallucination, 1=complete hallucination) 

82 and detailed findings. 

83 """ 

84 findings = [] 

85 

86 # Fact fabrication: check if response contains unsupported claims 

87 if expected: 

88 expected_tokens = set(expected.lower().split()) 

89 response_tokens = set(response.lower().split()) 

90 extra_tokens = response_tokens - expected_tokens 

91 

92 # Heuristic: too many tokens not in expected may indicate hallucination 

93 if len(response_tokens) > 0: 

94 extra_ratio = len(extra_tokens) / len(response_tokens) 

95 if extra_ratio > 0.5 and len(response) > 50: 

96 findings.append({ 

97 "type": "possible_fabrication", 

98 "severity": "medium", 

99 "extra_token_ratio": round(extra_ratio, 3), 

100 }) 

101 

102 # Self-contradiction check 

103 sentences = [s.strip() for s in response.replace("!", ".").replace("?", ".").split(".") if len(s.strip()) > 20] 

104 for i in range(len(sentences)): 

105 for j in range(i + 1, len(sentences)): 

106 # Simple overlap-based contradiction detection 

107 if len(sentences[i]) > 20 and len(sentences[j]) > 20: 

108 # Check for contradictory patterns (very basic) 

109 pass 

110 

111 # Source citation check 

112 if "http" in response: 

113 urls = [w for w in response.split() if w.startswith("http")] 

114 if urls: 

115 findings.append({ 

116 "type": "external_source_cited", 

117 "severity": "info", 

118 "urls_found": len(urls), 

119 }) 

120 

121 # Score: 0 = clean, 1 = severe hallucination 

122 score = 0.0 

123 for finding in findings: 

124 if finding.get("severity") == "high": 

125 score += 0.3 

126 elif finding.get("severity") == "medium": 

127 score += 0.1 

128 

129 return { 

130 "hallucination_score": min(score, 1.0), 

131 "findings": findings, 

132 "is_clean": score < 0.3, 

133 } 

134 

135 

136# ── Multi-Round Evaluator ─────────────────────────────────────────── 

137 

138@dataclass 

139class MultiRoundCase: 

140 """A multi-turn conversation test case.""" 

141 id: str 

142 turns: list[dict[str, Any]] # [{user: ..., expected_tools: [...], expected_response: ...}] 

143 category: EvalCategory = EvalCategory.CONVERSATION 

144 max_turns: int = 10 

145 tags: list[str] = field(default_factory=list) 

146 

147 

148class MultiRoundEvaluator: 

149 """Evaluate agent performance over multi-turn conversations.""" 

150 

151 def __init__(self, detector: HallucinationDetector | None = None): 

152 self._detector = detector or HallucinationDetector() 

153 self._round_results: list[dict] = [] 

154 

155 async def evaluate(self, agent, case: MultiRoundCase) -> EvalScore: 

156 """Run a multi-round evaluation. 

157 

158 Args: 

159 agent: The agent to evaluate 

160 case: Multi-round test case 

161 

162 Returns: 

163 Aggregated EvalScore across all turns. 

164 """ 

165 turn_scores: list[EvalScore] = [] 

166 context: dict[str, Any] = {} 

167 

168 for i, turn in enumerate(case.turns[:case.max_turns]): 

169 start = time.time() 

170 try: 

171 response = await agent.run(turn.get("user_input", ""), context=context) 

172 except Exception as e: 

173 response = {"error": str(e), "output": ""} 

174 

175 latency = (time.time() - start) * 1000 

176 

177 # Evaluate this turn 

178 expected_tools = turn.get("expected_tools", []) 

179 actual_tools = response.get("tools_used", []) if isinstance(response, dict) else [] 

180 actual_output = response.get("output", str(response)) if isinstance(response, dict) else str(response) 

181 

182 # Tool accuracy 

183 tool_score = self._score_tool_selection(expected_tools, actual_tools) 

184 

185 # Hallucination check 

186 h_result = self._detector.detect( 

187 actual_output, 

188 expected=turn.get("expected_response", ""), 

189 context=context, 

190 ) 

191 

192 # Response accuracy (simple substring match baseline) 

193 expected_resp = turn.get("expected_response", "") 

194 accuracy = 0.0 

195 if expected_resp: 

196 accuracy = self._score_text_match(expected_resp, actual_output) 

197 

198 turn_score = EvalScore( 

199 overall=(accuracy * 0.5 + tool_score * 0.3 + (1 - h_result["hallucination_score"]) * 0.2), 

200 accuracy=accuracy, 

201 tool_selection=tool_score, 

202 hallucination_free=1 - h_result["hallucination_score"], 

203 latency_ms=latency, 

204 details={"turn": i, "expected_tools": expected_tools, "actual_tools": actual_tools}, 

205 ) 

206 turn_scores.append(turn_score) 

207 self._round_results.append({ 

208 "case_id": case.id, 

209 "turn": i, 

210 "score": turn_score.overall, 

211 "latency_ms": latency, 

212 }) 

213 

214 # Aggregate 

215 n = len(turn_scores) if turn_scores else 1 

216 return EvalScore( 

217 overall=sum(s.overall for s in turn_scores) / n, 

218 accuracy=sum(s.accuracy for s in turn_scores) / n, 

219 tool_selection=sum(s.tool_selection for s in turn_scores) / n, 

220 hallucination_free=sum(s.hallucination_free for s in turn_scores) / n, 

221 latency_ms=sum(s.latency_ms for s in turn_scores) / n, 

222 details={"total_turns": n}, 

223 ) 

224 

225 def _score_tool_selection(self, expected: list[str], actual: list[str]) -> float: 

226 """Score tool selection accuracy.""" 

227 if not expected: 

228 return 1.0 

229 expected_set = set(expected) 

230 actual_set = set(actual) 

231 if not actual_set: 

232 return 0.0 

233 intersection = expected_set & actual_set 

234 precision = len(intersection) / len(actual_set) if actual_set else 0 

235 recall = len(intersection) / len(expected_set) if expected_set else 0 

236 if precision + recall == 0: 

237 return 0.0 

238 return 2 * precision * recall / (precision + recall) 

239 

240 def _score_text_match(self, expected: str, actual: str) -> float: 

241 """Simple text match score.""" 

242 expected_lower = expected.lower() 

243 actual_lower = actual.lower() 

244 if expected_lower == actual_lower: 

245 return 1.0 

246 if expected_lower in actual_lower or actual_lower in expected_lower: 

247 return 0.7 

248 # Token overlap 

249 e_tokens = set(expected_lower.split()) 

250 a_tokens = set(actual_lower.split()) 

251 if not e_tokens: 

252 return 0.0 

253 overlap = len(e_tokens & a_tokens) / len(e_tokens) 

254 return min(overlap, 1.0) 

255 

256 

257# ── SWE-Bench Style Evaluator ─────────────────────────────────────── 

258 

259class SWEBenchEvaluator: 

260 """SWE-bench style: end-to-end task completion evaluation. 

261 

262 Like SWE-bench, this evaluates whether the agent can: 

263 1. Understand a real-world task description 

264 2. Locate the relevant code 

265 3. Make the correct edits 

266 4. Pass all tests 

267 """ 

268 

269 def __init__(self, test_runner=None): 

270 self._test_runner = test_runner 

271 

272 async def evaluate( 

273 self, 

274 agent, 

275 task: dict[str, Any], 

276 repo_path: str = "", 

277 ) -> EvalScore: 

278 """Run a SWE-bench style evaluation. 

279 

280 Args: 

281 agent: The agent to evaluate 

282 task: Dict with 'problem_statement', 'patch', 'test_patch', 'repo' 

283 repo_path: Path to the repository 

284 

285 Returns: 

286 EvalScore with detailed results. 

287 """ 

288 problem = task.get("problem_statement", "") 

289 expected_patch = task.get("patch", "") 

290 

291 start = time.time() 

292 result = await agent.run(problem, context={"repo_path": repo_path}) 

293 latency = (time.time() - start) * 1000 

294 

295 # Check if the agent's solution passes the tests 

296 test_passed = False 

297 if task.get("test_patch"): 

298 test_passed = await self._run_tests(repo_path, task["test_patch"]) 

299 

300 # Compare patches 

301 actual_patch = result.get("patch", "") if isinstance(result, dict) else "" 

302 patch_similarity = self._diff_similarity(expected_patch, actual_patch) 

303 

304 return EvalScore( 

305 overall=patch_similarity * 0.6 + (1.0 if test_passed else 0.0) * 0.4, 

306 accuracy=patch_similarity, 

307 efficiency=1.0, 

308 latency_ms=latency, 

309 details={ 

310 "test_passed": test_passed, 

311 "patch_similarity": patch_similarity, 

312 "repo_path": repo_path, 

313 }, 

314 ) 

315 

316 async def _run_tests(self, repo_path: str, test_patch: str) -> bool: 

317 """Run tests for verification.""" 

318 try: 

319 import subprocess 

320 result = subprocess.run( 

321 ["python3", "-m", "pytest", "-x", "-q"], 

322 capture_output=True, text=True, 

323 timeout=60, cwd=repo_path, 

324 ) 

325 return result.returncode == 0 

326 except Exception: 

327 return False 

328 

329 def _diff_similarity(self, patch1: str, patch2: str) -> float: 

330 """Compute similarity between two patches.""" 

331 if not patch1 or not patch2: 

332 return 0.0 

333 if patch1 == patch2: 

334 return 1.0 

335 lines1 = set(patch1.splitlines()) 

336 lines2 = set(patch2.splitlines()) 

337 if not lines1 or not lines2: 

338 return 0.0 

339 overlap = len(lines1 & lines2) 

340 total = len(lines1 | lines2) 

341 return overlap / total if total > 0 else 0.0 

342 

343 

344# ── Eval Suite Runner ─────────────────────────────────────────────── 

345 

346class EvalSuiteRunner: 

347 """Orchestrates full evaluation suites. 

348 

349 Usage: 

350 runner = EvalSuiteRunner() 

351 runner.load_dataset("coding_tasks.json") 

352 runner.load_dataset("conversation_tasks.json") 

353 report = await runner.run_all(agent) 

354 runner.export_junit("report.xml") 

355 """ 

356 

357 def __init__(self): 

358 self._datasets: list[GoldenDataset] = [] 

359 self._multi_round_cases: list[MultiRoundCase] = [] 

360 self._swe_tasks: list[dict] = [] 

361 self._results: list[EvalScore] = [] 

362 self._detector = HallucinationDetector() 

363 self._multi_eval = MultiRoundEvaluator(self._detector) 

364 self._swe_eval = SWEBenchEvaluator() 

365 

366 def load_dataset(self, path: str): 

367 """Load a golden dataset from JSON.""" 

368 if path.endswith(".json"): 

369 dataset = GoldenDataset.from_json(path) 

370 self._datasets.append(dataset) 

371 

372 def add_dataset(self, dataset: GoldenDataset): 

373 """Add a pre-loaded dataset.""" 

374 self._datasets.append(dataset) 

375 

376 def add_multi_round_case(self, case: MultiRoundCase): 

377 """Add a multi-round conversation test case.""" 

378 self._multi_round_cases.append(case) 

379 

380 def add_swe_task(self, task: dict[str, Any]): 

381 """Add a SWE-bench style task.""" 

382 self._swe_tasks.append(task) 

383 

384 async def run_all(self, agent) -> list[EvalScore]: 

385 """Run all loaded evaluation suites. 

386 

387 Returns: 

388 List of EvalScore for each test case. 

389 """ 

390 self._results = [] 

391 

392 # Standard golden cases 

393 for dataset in self._datasets: 

394 for case in dataset.cases: 

395 score = await self._run_golden_case(agent, case) 

396 self._results.append(score) 

397 

398 # Multi-round conversation cases 

399 for case in self._multi_round_cases: 

400 score = await self._multi_eval.evaluate(agent, case) 

401 self._results.append(score) 

402 

403 # SWE-bench style tasks 

404 for task in self._swe_tasks: 

405 repo_path = task.get("repo_path", "") 

406 score = await self._swe_eval.evaluate(agent, task, repo_path) 

407 self._results.append(score) 

408 

409 return self._results 

410 

411 async def _run_golden_case(self, agent, case: GoldenCase) -> EvalScore: 

412 """Evaluate a single golden test case.""" 

413 start = time.time() 

414 

415 try: 

416 response = await agent.run(case.prompt, context=case.context) 

417 except Exception as e: 

418 return EvalScore(overall=0.0, details={"error": str(e)}) 

419 

420 latency = (time.time() - start) * 1000 

421 

422 # Parse response 

423 actual_output = response.get("output", str(response)) if isinstance(response, dict) else str(response) 

424 actual_tools = response.get("tools_used", []) if isinstance(response, dict) else [] 

425 

426 # Accuracy: simple match (extensible with ROUGE/BLEU) 

427 accuracy = self._fuzzy_match(case.expected, actual_output) 

428 

429 # Tool accuracy 

430 tool_score = self._multi_eval._score_tool_selection(case.expected_tools, actual_tools) 

431 

432 # Hallucination 

433 h_result = self._detector.detect(actual_output, expected=case.expected) 

434 

435 return EvalScore( 

436 overall=accuracy * 0.4 + tool_score * 0.3 + (1 - h_result["hallucination_score"]) * 0.3, 

437 accuracy=accuracy, 

438 tool_selection=tool_score, 

439 hallucination_free=1 - h_result["hallucination_score"], 

440 latency_ms=latency, 

441 details={ 

442 "case_id": case.id, 

443 "category": case.category, 

444 "difficulty": case.difficulty, 

445 "expected": case.expected[:200], 

446 "actual": actual_output[:200], 

447 }, 

448 ) 

449 

450 def _fuzzy_match(self, expected: str, actual: str) -> float: 

451 """Fuzzy text match (simple overlap baseline).""" 

452 if not expected: 

453 return 1.0 if not actual else 0.5 

454 if expected.strip().lower() == actual.strip().lower(): 

455 return 1.0 

456 expected_set = set(expected.lower().split()) 

457 actual_set = set(actual.lower().split()) 

458 if not expected_set: 

459 return 0.5 

460 return len(expected_set & actual_set) / len(expected_set) 

461 

462 # ── Reporting ── 

463 

464 def summary(self) -> dict[str, Any]: 

465 """Generate a summary of all evaluation results.""" 

466 if not self._results: 

467 return {"status": "no_results"} 

468 

469 scores = [r.overall for r in self._results] 

470 latencies = [r.latency_ms for r in self._results if r.latency_ms > 0] 

471 

472 by_category: dict[str, list[float]] = {} 

473 for r in self._results: 

474 cat = r.details.get("category", "unknown") 

475 by_category.setdefault(cat, []).append(r.overall) 

476 

477 return { 

478 "total_cases": len(self._results), 

479 "average_score": sum(scores) / len(scores) if scores else 0, 

480 "min_score": min(scores) if scores else 0, 

481 "max_score": max(scores) if scores else 0, 

482 "median_score": sorted(scores)[len(scores) // 2] if scores else 0, 

483 "by_category": { 

484 cat: sum(vals) / len(vals) if vals else 0 

485 for cat, vals in by_category.items() 

486 }, 

487 "average_latency_ms": sum(latencies) / len(latencies) if latencies else 0, 

488 "hallucination_rate": sum(1 for r in self._results if r.hallucination_free < 0.7) / len(self._results) if self._results else 0, 

489 } 

490 

491 def export_json(self, path: str): 

492 """Export results as JSON.""" 

493 report = { 

494 "generated_at": datetime.now().isoformat(), 

495 "summary": self.summary(), 

496 "results": [ 

497 { 

498 "overall": r.overall, 

499 "accuracy": r.accuracy, 

500 "tool_selection": r.tool_selection, 

501 "hallucination_free": r.hallucination_free, 

502 "latency_ms": r.latency_ms, 

503 "details": r.details, 

504 } 

505 for r in self._results 

506 ], 

507 } 

508 Path(path).parent.mkdir(parents=True, exist_ok=True) 

509 with open(path, "w", encoding="utf-8") as f: 

510 json.dump(report, f, indent=2, ensure_ascii=False) 

511 

512 def export_junit(self, path: str): 

513 """Export results as JUnit XML (CI/CD integration).""" 

514 passed = sum(1 for r in self._results if r.overall >= 0.5) 

515 failed = len(self._results) - passed 

516 

517 xml = '<?xml version="1.0" encoding="UTF-8"?>\n' 

518 xml += f'<testsuite name="AgentOS Eval Suite" tests="{len(self._results)}" failures="{failed}" errors="0">\n' 

519 for i, r in enumerate(self._results): 

520 case_name = r.details.get("case_id", f"case_{i}") 

521 if r.overall >= 0.5: 

522 xml += f' <testcase name="{case_name}" time="{r.latency_ms / 1000:.3f}"/>\n' 

523 else: 

524 xml += f' <testcase name="{case_name}" time="{r.latency_ms / 1000:.3f}">\n' 

525 xml += f' <failure message="Score: {r.overall:.2f}">Accuracy: {r.accuracy:.2f}, Tool: {r.tool_selection:.2f}, Hallucination: {r.hallucination_free:.2f}</failure>\n' 

526 xml += ' </testcase>\n' 

527 xml += '</testsuite>\n' 

528 

529 Path(path).parent.mkdir(parents=True, exist_ok=True) 

530 with open(path, "w", encoding="utf-8") as f: 

531 f.write(xml) 

532 

533 def export_markdown(self, path: str): 

534 """Export results as Markdown report.""" 

535 summary = self.summary() 

536 

537 md = "# AgentOS Evaluation Report\n\n" 

538 md += f"**Generated:** {datetime.now().isoformat()}\n" 

539 md += f"**Total Cases:** {summary['total_cases']}\n\n" 

540 

541 md += "## Summary\n\n" 

542 md += "| Metric | Value |\n" 

543 md += "|--------|-------|\n" 

544 md += f"| Average Score | {summary['average_score']:.2%} |\n" 

545 md += f"| Median Score | {summary['median_score']:.2%} |\n" 

546 md += f"| Min Score | {summary['min_score']:.2%} |\n" 

547 md += f"| Max Score | {summary['max_score']:.2%} |\n" 

548 md += f"| Avg Latency | {summary['average_latency_ms']:.0f}ms |\n" 

549 md += f"| Hallucination Rate | {summary['hallucination_rate']:.1%} |\n\n" 

550 

551 if summary.get("by_category"): 

552 md += "## By Category\n\n" 

553 md += "| Category | Average Score |\n" 

554 md += "|----------|---------------|\n" 

555 for cat, score in summary["by_category"].items(): 

556 md += f"| {cat} | {score:.2%} |\n" 

557 

558 Path(path).parent.mkdir(parents=True, exist_ok=True) 

559 with open(path, "w", encoding="utf-8") as f: 

560 f.write(md) 

561 

562 

563# ── Leaderboard ───────────────────────────────────────────────────── 

564 

565@dataclass 

566class LeaderboardEntry: 

567 """A single entry in the agent leaderboard.""" 

568 agent_name: str 

569 version: str 

570 score: float 

571 date: str = "" 

572 category_scores: dict[str, float] = field(default_factory=dict) 

573 details: dict[str, Any] = field(default_factory=dict) 

574 

575 

576class Leaderboard: 

577 """Track and compare agent performance over time.""" 

578 

579 def __init__(self, storage_path: str = ""): 

580 self._path = Path(storage_path) if storage_path else Path.home() / ".agentos" / "leaderboard.json" 

581 self._entries: list[LeaderboardEntry] = [] 

582 

583 def add_entry(self, entry: LeaderboardEntry): 

584 """Add a new leaderboard entry.""" 

585 if not entry.date: 

586 entry.date = datetime.now().isoformat() 

587 self._entries.append(entry) 

588 self._entries.sort(key=lambda e: e.score, reverse=True) 

589 

590 def top(self, n: int = 10) -> list[LeaderboardEntry]: 

591 """Get top N entries.""" 

592 return self._entries[:n] 

593 

594 def save(self): 

595 """Persist leaderboard to disk.""" 

596 self._path.parent.mkdir(parents=True, exist_ok=True) 

597 data = [ 

598 { 

599 "agent_name": e.agent_name, 

600 "version": e.version, 

601 "score": e.score, 

602 "date": e.date, 

603 "category_scores": e.category_scores, 

604 } 

605 for e in self._entries 

606 ] 

607 with open(self._path, "w", encoding="utf-8") as f: 

608 json.dump(data, f, indent=2) 

609 

610 def load(self): 

611 """Load leaderboard from disk.""" 

612 if self._path.exists(): 

613 with open(self._path, "r", encoding="utf-8") as f: 

614 data = json.load(f) 

615 self._entries = [ 

616 LeaderboardEntry(**entry) for entry in data 

617 ] 

618 self._entries.sort(key=lambda e: e.score, reverse=True) 

619 

620 def compare_versions(self, agent_name: str) -> list[dict]: 

621 """Compare all versions of an agent.""" 

622 entries = [e for e in self._entries if e.agent_name == agent_name] 

623 entries.sort(key=lambda e: e.date) 

624 return [ 

625 {"version": e.version, "score": e.score, "date": e.date} 

626 for e in entries 

627 ]