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

295 statements  

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

1""" # noqa: E501 

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 StrEnum 

26from pathlib import Path 

27from typing import Any 

28 

29from agentos.evaluation import GoldenCase, GoldenDataset 

30 

31# ── Scorers ───────────────────────────────────────────────────────── 

32 

33 

34@dataclass 

35class EvalScore: 

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

37 

38 overall: float = 0.0 # 0.0 - 1.0 

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

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

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

42 consistency: float = 0.0 # Repeatable across runs? 

43 hallucination_free: float = 0.0 # No fabricated content? 

44 latency_ms: float = 0.0 # Response time 

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

46 

47 

48class EvalCategory(StrEnum): 

49 CODING = "coding" 

50 REASONING = "reasoning" 

51 TOOL_USE = "tool_use" 

52 CONVERSATION = "conversation" 

53 KNOWLEDGE = "knowledge" 

54 SAFETY = "safety" 

55 MATH = "math" 

56 

57 

58# ── Hallucination Detector ────────────────────────────────────────── 

59 

60 

61class HallucinationDetector: 

62 """Detect fabricated content in agent outputs. 

63 

64 Detection methods: 

65 - Reference check: verify against expected output 

66 - Factual consistency: cross-reference with ground truth 

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

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

69 """ 

70 

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

72 self._reference = reference_kb or {} 

73 

74 def detect( 

75 self, response: str, expected: str = "", context: dict[str, Any] | None = None 

76 ) -> dict[str, Any]: 

77 """Analyze a response for hallucination signals. 

78 

79 Args: 

80 response: Agent's actual response 

81 expected: Expected/ground truth response 

82 context: Additional context for detection 

83 

84 Returns: 

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

86 and detailed findings. 

87 """ 

88 findings = [] 

89 

90 # Fact fabrication: check if response contains unsupported claims 

91 if expected: 

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

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

94 extra_tokens = response_tokens - expected_tokens 

95 

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

97 if len(response_tokens) > 0: 

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

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

100 findings.append( 

101 { 

102 "type": "possible_fabrication", 

103 "severity": "medium", 

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

105 } 

106 ) 

107 

108 # Self-contradiction check 

109 sentences = [ 

110 s.strip() 

111 for s in response.replace("!", ".").replace("?", ".").split(".") 

112 if len(s.strip()) > 20 

113 ] 

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

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

116 # Simple overlap-based contradiction detection 

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

118 # Check for contradictory patterns (very basic) 

119 pass 

120 

121 # Source citation check 

122 if "http" in response: 

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

124 if urls: 

125 findings.append( 

126 { 

127 "type": "external_source_cited", 

128 "severity": "info", 

129 "urls_found": len(urls), 

130 } 

131 ) 

132 

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

134 score = 0.0 

135 for finding in findings: 

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

137 score += 0.3 

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

139 score += 0.1 

140 

141 return { 

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

143 "findings": findings, 

144 "is_clean": score < 0.3, 

145 } 

146 

147 

148# ── Multi-Round Evaluator ─────────────────────────────────────────── 

149 

150 

151@dataclass 

152class MultiRoundCase: 

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

154 

155 id: str 

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

157 category: EvalCategory = EvalCategory.CONVERSATION 

158 max_turns: int = 10 

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

160 

161 

162class MultiRoundEvaluator: 

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

164 

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

166 self._detector = detector or HallucinationDetector() 

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

168 

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

170 """Run a multi-round evaluation. 

171 

172 Args: 

173 agent: The agent to evaluate 

174 case: Multi-round test case 

175 

176 Returns: 

177 Aggregated EvalScore across all turns. 

178 """ 

179 turn_scores: list[EvalScore] = [] 

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

181 

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

183 start = time.time() 

184 try: 

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

186 except Exception as e: 

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

188 

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

190 

191 # Evaluate this turn 

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

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

194 actual_output = ( 

195 response.get("output", str(response)) 

196 if isinstance(response, dict) 

197 else str(response) 

198 ) 

199 

200 # Tool accuracy 

201 tool_score = self._score_tool_selection(expected_tools, actual_tools) 

202 

203 # Hallucination check 

204 h_result = self._detector.detect( 

205 actual_output, 

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

207 context=context, 

208 ) 

209 

210 # Response accuracy (simple substring match baseline) 

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

212 accuracy = 0.0 

213 if expected_resp: 

214 accuracy = self._score_text_match(expected_resp, actual_output) 

215 

216 turn_score = EvalScore( 

217 overall=( 

218 accuracy * 0.5 + tool_score * 0.3 + (1 - h_result["hallucination_score"]) * 0.2 

219 ), 

220 accuracy=accuracy, 

221 tool_selection=tool_score, 

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

223 latency_ms=latency, 

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

225 ) 

226 turn_scores.append(turn_score) 

227 self._round_results.append( 

228 { 

229 "case_id": case.id, 

230 "turn": i, 

231 "score": turn_score.overall, 

232 "latency_ms": latency, 

233 } 

234 ) 

235 

236 # Aggregate 

237 n = len(turn_scores) if turn_scores else 1 

238 return EvalScore( 

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

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

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

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

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

244 details={"total_turns": n}, 

245 ) 

246 

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

248 """Score tool selection accuracy.""" 

249 if not expected: 

250 return 1.0 

251 expected_set = set(expected) 

252 actual_set = set(actual) 

253 if not actual_set: 

254 return 0.0 

255 intersection = expected_set & actual_set 

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

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

258 if precision + recall == 0: 

259 return 0.0 

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

261 

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

263 """Simple text match score.""" 

264 expected_lower = expected.lower() 

265 actual_lower = actual.lower() 

266 if expected_lower == actual_lower: 

267 return 1.0 

268 if expected_lower in actual_lower or actual_lower in expected_lower: 

269 return 0.7 

270 # Token overlap 

271 e_tokens = set(expected_lower.split()) 

272 a_tokens = set(actual_lower.split()) 

273 if not e_tokens: 

274 return 0.0 

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

276 return min(overlap, 1.0) 

277 

278 

279# ── SWE-Bench Style Evaluator ─────────────────────────────────────── 

280 

281 

282class SWEBenchEvaluator: 

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

284 

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

286 1. Understand a real-world task description 

287 2. Locate the relevant code 

288 3. Make the correct edits 

289 4. Pass all tests 

290 """ 

291 

292 def __init__(self, test_runner=None): 

293 self._test_runner = test_runner 

294 

295 async def evaluate( 

296 self, 

297 agent, 

298 task: dict[str, Any], 

299 repo_path: str = "", 

300 ) -> EvalScore: 

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

302 

303 Args: 

304 agent: The agent to evaluate 

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

306 repo_path: Path to the repository 

307 

308 Returns: 

309 EvalScore with detailed results. 

310 """ 

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

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

313 

314 start = time.time() 

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

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

317 

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

319 test_passed = False 

320 if task.get("test_patch"): 

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

322 

323 # Compare patches 

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

325 patch_similarity = self._diff_similarity(expected_patch, actual_patch) 

326 

327 return EvalScore( 

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

329 accuracy=patch_similarity, 

330 efficiency=1.0, 

331 latency_ms=latency, 

332 details={ 

333 "test_passed": test_passed, 

334 "patch_similarity": patch_similarity, 

335 "repo_path": repo_path, 

336 }, 

337 ) 

338 

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

340 """Run tests for verification.""" 

341 try: 

342 import subprocess 

343 

344 result = subprocess.run( 

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

346 capture_output=True, 

347 text=True, 

348 timeout=60, 

349 cwd=repo_path, 

350 ) 

351 return result.returncode == 0 

352 except Exception: 

353 return False 

354 

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

356 """Compute similarity between two patches.""" 

357 if not patch1 or not patch2: 

358 return 0.0 

359 if patch1 == patch2: 

360 return 1.0 

361 lines1 = set(patch1.splitlines()) 

362 lines2 = set(patch2.splitlines()) 

363 if not lines1 or not lines2: 

364 return 0.0 

365 overlap = len(lines1 & lines2) 

366 total = len(lines1 | lines2) 

367 return overlap / total if total > 0 else 0.0 

368 

369 

370# ── Eval Suite Runner ─────────────────────────────────────────────── 

371 

372 

373class EvalSuiteRunner: 

374 """Orchestrates full evaluation suites. 

375 

376 Usage: 

377 runner = EvalSuiteRunner() 

378 runner.load_dataset("coding_tasks.json") 

379 runner.load_dataset("conversation_tasks.json") 

380 report = await runner.run_all(agent) 

381 runner.export_junit("report.xml") 

382 """ 

383 

384 def __init__(self): 

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

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

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

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

389 self._detector = HallucinationDetector() 

390 self._multi_eval = MultiRoundEvaluator(self._detector) 

391 self._swe_eval = SWEBenchEvaluator() 

392 

393 def load_dataset(self, path: str): 

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

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

396 dataset = GoldenDataset.from_json(path) 

397 self._datasets.append(dataset) 

398 

399 def add_dataset(self, dataset: GoldenDataset): 

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

401 self._datasets.append(dataset) 

402 

403 def add_multi_round_case(self, case: MultiRoundCase): 

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

405 self._multi_round_cases.append(case) 

406 

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

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

409 self._swe_tasks.append(task) 

410 

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

412 """Run all loaded evaluation suites. 

413 

414 Returns: 

415 List of EvalScore for each test case. 

416 """ 

417 self._results = [] 

418 

419 # Standard golden cases 

420 for dataset in self._datasets: 

421 for case in dataset.cases: 

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

423 self._results.append(score) 

424 

425 # Multi-round conversation cases 

426 for case in self._multi_round_cases: 

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

428 self._results.append(score) 

429 

430 # SWE-bench style tasks 

431 for task in self._swe_tasks: 

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

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

434 self._results.append(score) 

435 

436 return self._results 

437 

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

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

440 start = time.time() 

441 

442 try: 

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

444 except Exception as e: 

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

446 

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

448 

449 # Parse response 

450 actual_output = ( 

451 response.get("output", str(response)) if isinstance(response, dict) else str(response) 

452 ) 

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

454 

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

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

457 

458 # Tool accuracy 

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

460 

461 # Hallucination 

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

463 

464 return EvalScore( 

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

466 accuracy=accuracy, 

467 tool_selection=tool_score, 

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

469 latency_ms=latency, 

470 details={ 

471 "case_id": case.id, 

472 "category": case.category, 

473 "difficulty": case.difficulty, 

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

475 "actual": actual_output[:200], 

476 }, 

477 ) 

478 

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

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

481 if not expected: 

482 return 1.0 if not actual else 0.5 

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

484 return 1.0 

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

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

487 if not expected_set: 

488 return 0.5 

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

490 

491 # ── Reporting ── 

492 

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

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

495 if not self._results: 

496 return {"status": "no_results"} 

497 

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

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

500 

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

502 for r in self._results: 

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

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

505 

506 return { 

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

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

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

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

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

512 "by_category": { 

513 cat: sum(vals) / len(vals) if vals else 0 for cat, vals in by_category.items() 

514 }, 

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

516 "hallucination_rate": ( 

517 sum(1 for r in self._results if r.hallucination_free < 0.7) / len(self._results) 

518 if self._results 

519 else 0 

520 ), 

521 } 

522 

523 def export_json(self, path: str): 

524 """Export results as JSON.""" 

525 report = { 

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

527 "summary": self.summary(), 

528 "results": [ 

529 { 

530 "overall": r.overall, 

531 "accuracy": r.accuracy, 

532 "tool_selection": r.tool_selection, 

533 "hallucination_free": r.hallucination_free, 

534 "latency_ms": r.latency_ms, 

535 "details": r.details, 

536 } 

537 for r in self._results 

538 ], 

539 } 

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

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

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

543 

544 def export_junit(self, path: str): 

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

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

547 failed = len(self._results) - passed 

548 

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

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

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

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

553 if r.overall >= 0.5: 

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

555 else: 

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

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

558 xml += " </testcase>\n" 

559 xml += "</testsuite>\n" 

560 

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

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

563 f.write(xml) 

564 

565 def export_markdown(self, path: str): 

566 """Export results as Markdown report.""" 

567 summary = self.summary() 

568 

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

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

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

572 

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

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

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

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

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

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

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

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

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

582 

583 if summary.get("by_category"): 

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

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

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

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

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

589 

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

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

592 f.write(md) 

593 

594 

595# ── Leaderboard ───────────────────────────────────────────────────── 

596 

597 

598@dataclass 

599class LeaderboardEntry: 

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

601 

602 agent_name: str 

603 version: str 

604 score: float 

605 date: str = "" 

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

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

608 

609 

610class Leaderboard: 

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

612 

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

614 self._path = ( 

615 Path(storage_path) if storage_path else Path.home() / ".agentos" / "leaderboard.json" 

616 ) 

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

618 

619 def add_entry(self, entry: LeaderboardEntry): 

620 """Add a new leaderboard entry.""" 

621 if not entry.date: 

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

623 self._entries.append(entry) 

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

625 

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

627 """Get top N entries.""" 

628 return self._entries[:n] 

629 

630 def save(self): 

631 """Persist leaderboard to disk.""" 

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

633 data = [ 

634 { 

635 "agent_name": e.agent_name, 

636 "version": e.version, 

637 "score": e.score, 

638 "date": e.date, 

639 "category_scores": e.category_scores, 

640 } 

641 for e in self._entries 

642 ] 

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

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

645 

646 def load(self): 

647 """Load leaderboard from disk.""" 

648 if self._path.exists(): 

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

650 data = json.load(f) 

651 self._entries = [LeaderboardEntry(**entry) for entry in data] 

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

653 

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

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

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

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

658 return [{"version": e.version, "score": e.score, "date": e.date} for e in entries]