Coverage for little_loops / fsm / evaluators.py: 9%

535 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:12 -0500

1"""FSM Evaluators for loop execution. 

2 

3This module provides evaluators that interpret action output and produce 

4verdicts for state transitions. 

5 

6Supported evaluator types: 

7 

8Tier 1 (Deterministic - no API calls): 

9 exit_code: Map Unix exit codes to verdicts (0=success, 1=failure, 2+=error) 

10 output_numeric: Compare numeric output to target value 

11 output_json: Extract and compare JSON path values 

12 output_contains: Pattern matching on stdout 

13 convergence: Track progress toward a target value 

14 diff_stall: Detect stalled iterations via git diff comparison 

15 action_stall: Detect when the same action string or output repeats for N consecutive iterations 

16 harbor_scorer: Interpret Harbor-format benchmark scorer exit code and float stdout 

17 

18Tier 2 (LLM-based): 

19 llm_structured: Use LLM with structured output for natural language evaluation 

20 contract: Read producer/consumer file pairs and assert contract alignment via LLM judge 

21 

22Tier 3 (External process): 

23 mcp_result: Parse MCP tool call response envelope 

24""" 

25 

26from __future__ import annotations 

27 

28import hashlib 

29import json 

30import random 

31import re 

32import subprocess 

33import time 

34from collections.abc import Callable 

35from dataclasses import dataclass 

36from pathlib import Path 

37from typing import Any 

38 

39from little_loops.fsm.interpolation import ( 

40 InterpolationContext, 

41 InterpolationError, 

42 interpolate, 

43) 

44from little_loops.fsm.schema import DEFAULT_LLM_MODEL, EvaluateConfig 

45from little_loops.host_runner import resolve_host 

46 

47 

48@dataclass 

49class EvaluationResult: 

50 """Result from an evaluator. 

51 

52 Attributes: 

53 verdict: The routing key for state transitions 

54 details: Evaluator-specific metadata for debugging/logging 

55 """ 

56 

57 verdict: str 

58 details: dict[str, Any] 

59 

60 

61# Default schema for LLM structured evaluation 

62DEFAULT_LLM_SCHEMA: dict[str, Any] = { 

63 "type": "object", 

64 "properties": { 

65 "verdict": { 

66 "type": "string", 

67 "enum": ["yes", "no", "blocked", "partial"], 

68 "description": ( 

69 "- yes: The condition/check evaluated to true\n" 

70 "- no: The condition/check evaluated to false\n" 

71 "- blocked: Cannot proceed without external help\n" 

72 "- partial: Made progress but not complete" 

73 ), 

74 }, 

75 "confidence": { 

76 "type": "number", 

77 "minimum": 0, 

78 "maximum": 1, 

79 "description": "Confidence in this verdict (0-1)", 

80 }, 

81 "reason": { 

82 "type": "string", 

83 "description": "Brief explanation", 

84 }, 

85 }, 

86 "required": ["verdict", "confidence", "reason"], 

87} 

88 

89DEFAULT_LLM_PROMPT = "Evaluate whether this action succeeded based on its output." 

90 

91# Schema for blind A/B comparator: evaluates two anonymized outputs 

92BLIND_COMPARATOR_SCHEMA: dict[str, Any] = { 

93 "type": "object", 

94 "properties": { 

95 "verdict_a": { 

96 "type": "string", 

97 "enum": ["yes", "no"], 

98 "description": "Whether Output A meets the evaluation criteria", 

99 }, 

100 "verdict_b": { 

101 "type": "string", 

102 "enum": ["yes", "no"], 

103 "description": "Whether Output B meets the evaluation criteria", 

104 }, 

105 "confidence": { 

106 "type": "number", 

107 "minimum": 0, 

108 "maximum": 1, 

109 "description": "Confidence in these verdicts (0-1)", 

110 }, 

111 "reason": { 

112 "type": "string", 

113 "description": "Brief explanation comparing the two outputs", 

114 }, 

115 }, 

116 "required": ["verdict_a", "verdict_b", "confidence", "reason"], 

117} 

118 

119DEFAULT_BLIND_COMPARATOR_PROMPT = ( 

120 "You are evaluating two outputs (labeled 'Output A' and 'Output B') that were " 

121 "produced by independent runs of the same task. Judge whether each output meets " 

122 "the evaluation criteria below. Be objective and impartial — the labels 'A' and " 

123 "'B' are arbitrary and do not indicate which is better." 

124) 

125 

126_NUMERIC_OPERATORS: dict[str, Callable[[float, float], bool]] = { 

127 "eq": lambda v, t: v == t, 

128 "ne": lambda v, t: v != t, 

129 "lt": lambda v, t: v < t, 

130 "le": lambda v, t: v <= t, 

131 "gt": lambda v, t: v > t, 

132 "ge": lambda v, t: v >= t, 

133} 

134 

135 

136def evaluate_exit_code(exit_code: int) -> EvaluationResult: 

137 """Map Unix exit code to verdict. 

138 

139 Args: 

140 exit_code: The process exit code 

141 

142 Returns: 

143 EvaluationResult with verdict: 

144 - 0 -> yes 

145 - 1 -> no 

146 - 2+ -> error 

147 """ 

148 if exit_code == 0: 

149 verdict = "yes" 

150 elif exit_code == 1: 

151 verdict = "no" 

152 else: 

153 verdict = "error" 

154 

155 return EvaluationResult(verdict=verdict, details={"exit_code": exit_code}) 

156 

157 

158def evaluate_output_numeric( 

159 output: str, 

160 operator: str, 

161 target: float, 

162) -> EvaluationResult: 

163 """Parse stdout as number and compare to target. 

164 

165 Args: 

166 output: The action stdout to parse as a number 

167 operator: Comparison operator (eq, ne, lt, le, gt, ge) 

168 target: Target value to compare against 

169 

170 Returns: 

171 EvaluationResult with verdict: 

172 - Condition met -> yes 

173 - Condition not met -> no 

174 - Parse error -> error 

175 """ 

176 try: 

177 value = float(output.strip()) 

178 except ValueError: 

179 return EvaluationResult( 

180 verdict="error", 

181 details={"error": f"Cannot parse as number: {output[:100]}"}, 

182 ) 

183 

184 if operator not in _NUMERIC_OPERATORS: 

185 return EvaluationResult( 

186 verdict="error", 

187 details={"error": f"Unknown operator: {operator}"}, 

188 ) 

189 

190 condition_met = _NUMERIC_OPERATORS[operator](value, target) 

191 return EvaluationResult( 

192 verdict="yes" if condition_met else "no", 

193 details={"value": value, "target": target, "operator": operator}, 

194 ) 

195 

196 

197def _extract_json_path(data: Any, path: str) -> Any: 

198 """Extract value from dict using jq-style path like '.summary.failed'. 

199 

200 Args: 

201 data: The parsed JSON data (dict or list) 

202 path: Dot-separated path, optionally starting with '.' 

203 

204 Returns: 

205 The value at the specified path 

206 

207 Raises: 

208 KeyError: If path not found in data 

209 """ 

210 if path.startswith("."): 

211 path = path[1:] 

212 parts = path.split(".") 

213 current = data 

214 for part in parts: 

215 if isinstance(current, dict) and part in current: 

216 current = current[part] 

217 elif isinstance(current, list) and part.isdigit(): 

218 idx = int(part) 

219 if 0 <= idx < len(current): 

220 current = current[idx] 

221 else: 

222 raise KeyError(path) 

223 else: 

224 raise KeyError(path) 

225 return current 

226 

227 

228def _compare_values( 

229 value: int | float, operator: str, target: int | float, path: str 

230) -> EvaluationResult: 

231 """Compare numeric values using operator. 

232 

233 Args: 

234 value: The extracted value to compare 

235 operator: Comparison operator 

236 target: Target value 

237 path: JSON path for details 

238 

239 Returns: 

240 EvaluationResult with comparison result 

241 """ 

242 if operator not in _NUMERIC_OPERATORS: 

243 return EvaluationResult( 

244 verdict="error", 

245 details={"error": f"Unknown operator: {operator}"}, 

246 ) 

247 

248 condition_met = _NUMERIC_OPERATORS[operator](value, target) 

249 return EvaluationResult( 

250 verdict="yes" if condition_met else "no", 

251 details={"value": value, "path": path, "target": target, "operator": operator}, 

252 ) 

253 

254 

255def evaluate_output_json( 

256 output: str, 

257 path: str, 

258 operator: str, 

259 target: Any, 

260) -> EvaluationResult: 

261 """Parse JSON and extract value at path, then compare. 

262 

263 Args: 

264 output: The action stdout containing JSON 

265 path: jq-style dot notation path (e.g., '.summary.failed') 

266 operator: Comparison operator (eq, ne, lt, le, gt, ge) 

267 target: Target value for comparison 

268 

269 Returns: 

270 EvaluationResult with verdict: 

271 - Condition met -> yes 

272 - Condition not met -> no 

273 - Parse/path error -> error 

274 """ 

275 try: 

276 data = json.loads(output) 

277 except json.JSONDecodeError as e: 

278 return EvaluationResult( 

279 verdict="error", 

280 details={"error": f"Invalid JSON: {e}"}, 

281 ) 

282 

283 try: 

284 value = _extract_json_path(data, path) 

285 except KeyError: 

286 return EvaluationResult( 

287 verdict="error", 

288 details={"error": f"Path not found: {path}"}, 

289 ) 

290 

291 # Use numeric comparison if both values are numeric 

292 if isinstance(value, (int, float)) and isinstance(target, (int, float)): 

293 return _compare_values(value, operator, target, path) 

294 

295 # For non-numeric values, only eq and ne are supported 

296 if operator == "eq": 

297 verdict = "yes" if value == target else "no" 

298 elif operator == "ne": 

299 verdict = "yes" if value != target else "no" 

300 else: 

301 return EvaluationResult( 

302 verdict="error", 

303 details={"error": f"Operator {operator} not supported for non-numeric values"}, 

304 ) 

305 

306 return EvaluationResult( 

307 verdict=verdict, 

308 details={"value": value, "path": path, "target": target, "operator": operator}, 

309 ) 

310 

311 

312def evaluate_output_contains( 

313 output: str, 

314 pattern: str, 

315 negate: bool = False, 

316) -> EvaluationResult: 

317 """Check if pattern exists in output. 

318 

319 Pattern can be regex or substring. If regex fails to compile, 

320 falls back to substring matching. 

321 

322 Args: 

323 output: The action stdout to search 

324 pattern: Regex pattern or substring 

325 negate: If True, invert the match result 

326 

327 Returns: 

328 EvaluationResult with verdict: 

329 - Found (negate=False) -> yes 

330 - Found (negate=True) -> no 

331 - Not found (negate=False) -> no 

332 - Not found (negate=True) -> yes 

333 """ 

334 # Try regex first, fall back to substring 

335 try: 

336 matched = bool(re.search(pattern, output)) 

337 except re.error: 

338 matched = pattern in output 

339 

340 if negate: 

341 verdict = "no" if matched else "yes" 

342 else: 

343 verdict = "yes" if matched else "no" 

344 

345 return EvaluationResult( 

346 verdict=verdict, 

347 details={"matched": matched, "pattern": pattern, "negate": negate}, 

348 ) 

349 

350 

351def evaluate_convergence( 

352 current: float, 

353 previous: float | None, 

354 target: float, 

355 tolerance: float = 0, 

356 direction: str = "minimize", 

357) -> EvaluationResult: 

358 """Compare current value to target and previous. 

359 

360 Args: 

361 current: Current metric value 

362 previous: Previous metric value (None if first iteration) 

363 target: Target value to reach 

364 tolerance: Acceptable distance from target 

365 direction: 'minimize' or 'maximize' 

366 

367 Returns: 

368 EvaluationResult with verdict: 

369 - Value within tolerance of target -> target 

370 - Value improved toward target -> progress 

371 - Value unchanged or worsened -> stall 

372 """ 

373 # Check if target reached (within tolerance) 

374 if abs(current - target) <= tolerance: 

375 return EvaluationResult( 

376 verdict="target", 

377 details={"current": current, "target": target, "delta": 0}, 

378 ) 

379 

380 # First iteration has no previous value 

381 if previous is None: 

382 return EvaluationResult( 

383 verdict="progress", 

384 details={ 

385 "current": current, 

386 "previous": None, 

387 "target": target, 

388 "delta": None, 

389 }, 

390 ) 

391 

392 # Calculate progress 

393 delta = current - previous 

394 

395 if direction == "minimize": 

396 # For minimizing, negative delta is progress 

397 made_progress = delta < 0 

398 else: 

399 # For maximizing, positive delta is progress 

400 made_progress = delta > 0 

401 

402 verdict = "progress" if made_progress else "stall" 

403 

404 return EvaluationResult( 

405 verdict=verdict, 

406 details={ 

407 "current": current, 

408 "previous": previous, 

409 "target": target, 

410 "delta": delta, 

411 "direction": direction, 

412 }, 

413 ) 

414 

415 

416def evaluate_classify( 

417 output: str, 

418 line: str | int | None = None, 

419) -> EvaluationResult: 

420 """Read a token from stdout and return it as the verdict. 

421 

422 Intended for single-state multi-way routing: the action prints exactly one 

423 token to stdout and the route: table maps that token to the next state. 

424 

425 Args: 

426 output: The action stdout to read the token from 

427 line: Which line to select. 'last' (default) picks the last non-empty 

428 line; 'first' picks the first non-empty line; an integer index 

429 selects that line (0-based, negative indices supported). 

430 

431 Returns: 

432 EvaluationResult with verdict = trimmed token, or "" when output is 

433 empty (which _route() maps to the route.default fallback). 

434 """ 

435 lines = [ln for ln in output.splitlines() if ln.strip()] 

436 if not lines: 

437 return EvaluationResult( 

438 verdict="", 

439 details={"token": "", "line": line, "source_lines": 0}, 

440 ) 

441 

442 selector = line if line is not None else "last" 

443 if selector == "last": 

444 selected = lines[-1] 

445 elif selector == "first": 

446 selected = lines[0] 

447 elif isinstance(selector, int): 

448 try: 

449 selected = lines[selector] 

450 except IndexError: 

451 return EvaluationResult( 

452 verdict="", 

453 details={ 

454 "token": "", 

455 "line": line, 

456 "source_lines": len(lines), 

457 "error": "index out of range", 

458 }, 

459 ) 

460 else: 

461 selected = lines[-1] 

462 

463 token = selected.strip() 

464 return EvaluationResult( 

465 verdict=token, 

466 details={"token": token, "line": line}, 

467 ) 

468 

469 

470def evaluate_diff_stall( 

471 scope: list[str] | None = None, 

472 max_stall: int = 1, 

473) -> EvaluationResult: 

474 """Detect stalled iterations by comparing git diff --stat between runs. 

475 

476 On first call, snapshots the current diff and returns 'yes'. 

477 On subsequent calls, compares current diff to the previous snapshot. 

478 If the diff is identical for max_stall consecutive iterations, returns 

479 'no' (stalled). If different, resets the stall counter and returns 

480 'yes' (progress). 

481 

482 State is persisted in /tmp using a key derived from the scope argument, 

483 so different loops with different scopes maintain independent stall counters. 

484 

485 Args: 

486 scope: Optional list of paths to limit the git diff to. Defaults to 

487 the entire working tree. 

488 max_stall: Number of consecutive no-change iterations before stall 

489 verdict. Defaults to 1. 

490 

491 Returns: 

492 EvaluationResult with verdict: 

493 - yes: diff changed since last iteration (progress made) 

494 - no: diff unchanged for max_stall iterations (stalled) 

495 - error: git command failed or timed out 

496 """ 

497 cmd = ["git", "diff", "--stat"] 

498 if scope: 

499 cmd += ["--"] + scope 

500 

501 try: 

502 proc = subprocess.run(cmd, capture_output=True, text=True, timeout=30) 

503 except subprocess.TimeoutExpired: 

504 return EvaluationResult(verdict="error", details={"error": "git diff timed out"}) 

505 except FileNotFoundError: 

506 return EvaluationResult(verdict="error", details={"error": "git not found in PATH"}) 

507 

508 if proc.returncode != 0: 

509 return EvaluationResult( 

510 verdict="error", 

511 details={"error": f"git diff failed: {proc.stderr[:200]}"}, 

512 ) 

513 

514 current_diff = proc.stdout 

515 

516 # Derive a stable cache key from the scope so independent loops don't collide 

517 scope_str = "|".join(sorted(scope)) if scope else "_root_" 

518 cache_key = hashlib.md5(scope_str.encode()).hexdigest()[:12] 

519 loops_tmp = Path.cwd() / ".loops" / "tmp" 

520 loops_tmp.mkdir(parents=True, exist_ok=True) 

521 state_file = loops_tmp / f"ll-diff-stall-{cache_key}.txt" 

522 count_file = loops_tmp / f"ll-diff-stall-{cache_key}.count" 

523 

524 # Read previous snapshot and stall count 

525 previous_diff: str | None = None 

526 stall_count = 0 

527 try: 

528 previous_diff = state_file.read_text() 

529 stall_count = int(count_file.read_text().strip()) 

530 except (FileNotFoundError, ValueError): 

531 pass 

532 

533 # First iteration: save snapshot and report progress 

534 if previous_diff is None: 

535 state_file.write_text(current_diff) 

536 count_file.write_text("0") 

537 return EvaluationResult( 

538 verdict="yes", 

539 details={"stall_count": 0, "max_stall": max_stall, "diff_changed": True}, 

540 ) 

541 

542 if current_diff == previous_diff: 

543 stall_count += 1 

544 count_file.write_text(str(stall_count)) 

545 if stall_count >= max_stall: 

546 return EvaluationResult( 

547 verdict="no", 

548 details={"stall_count": stall_count, "max_stall": max_stall, "diff_changed": False}, 

549 ) 

550 # Not yet at max_stall threshold — still report yes so loop continues 

551 return EvaluationResult( 

552 verdict="yes", 

553 details={"stall_count": stall_count, "max_stall": max_stall, "diff_changed": False}, 

554 ) 

555 else: 

556 # Progress: update snapshot and reset counter 

557 state_file.write_text(current_diff) 

558 count_file.write_text("0") 

559 return EvaluationResult( 

560 verdict="yes", 

561 details={"stall_count": 0, "max_stall": max_stall, "diff_changed": True}, 

562 ) 

563 

564 

565def evaluate_action_stall( 

566 track: list[str] | None = None, 

567 max_repeat: int = 2, 

568 context: InterpolationContext | None = None, 

569) -> EvaluationResult: 

570 """Detect when the same action string or output repeats for N consecutive iterations. 

571 

572 On first call, snapshots the hashed values of the tracked context keys and returns 

573 'yes'. On subsequent calls, compares the current hash to the previous snapshot. 

574 If the hash is identical for max_repeat consecutive iterations, returns 'no' 

575 (stalled). If different, resets the stall counter and returns 'yes' (progress). 

576 

577 State is persisted in .loops/tmp using a key derived from the tracked keys, 

578 so different states/loops maintain independent stall counters. 

579 

580 Args: 

581 track: Context keys to track. Defaults to ["action"] when None. 

582 max_repeat: Number of consecutive identical-hash iterations before stall verdict. 

583 Defaults to 2. 

584 context: Runtime interpolation context for resolving tracked keys. 

585 

586 Returns: 

587 EvaluationResult with verdict: 

588 - yes: tracked values changed since last iteration (progress made) 

589 - no: tracked values identical for max_repeat iterations (stalled) 

590 """ 

591 effective_track: list[str] = track if track is not None else ["action"] 

592 

593 # Resolve each tracked key from context and hash the combined values. 

594 # Keys may be bare names (e.g. "action") or namespaced (e.g. "context.action"). 

595 # Try namespaced forms first: context.<key>, captured.<key>, then bare ${key}. 

596 parts: list[str] = [] 

597 for key in effective_track: 

598 value: str = "" 

599 if context is not None: 

600 # If key already contains a dot it's already namespaced; use as-is. 

601 if "." in key: 

602 try: 

603 value = str(interpolate(f"${{{key}}}", context)) 

604 except InterpolationError: 

605 value = "" 

606 else: 

607 # Try context.<key> first, then captured.<key>, then give up. 

608 resolved = False 

609 for namespace in ("context", "captured", "prev", "result"): 

610 try: 

611 value = str(interpolate(f"${{{namespace}.{key}}}", context)) 

612 resolved = True 

613 break 

614 except InterpolationError: 

615 continue 

616 if not resolved: 

617 value = "" 

618 parts.append(f"{key}={value}") 

619 

620 combined = "|".join(parts) 

621 current_hash = hashlib.md5(combined.encode()).hexdigest() 

622 

623 # Derive a stable cache key from the tracked keys 

624 track_str = "|".join(sorted(effective_track)) 

625 cache_key = hashlib.md5(track_str.encode()).hexdigest()[:12] 

626 loops_tmp = Path.cwd() / ".loops" / "tmp" 

627 loops_tmp.mkdir(parents=True, exist_ok=True) 

628 state_file = loops_tmp / f"ll-action-stall-{cache_key}.txt" 

629 count_file = loops_tmp / f"ll-action-stall-{cache_key}.count" 

630 

631 # Read previous hash and stall count 

632 previous_hash: str | None = None 

633 stall_count = 0 

634 try: 

635 previous_hash = state_file.read_text().strip() 

636 stall_count = int(count_file.read_text().strip()) 

637 except (FileNotFoundError, ValueError): 

638 pass 

639 

640 # First iteration: save hash and report progress 

641 if previous_hash is None: 

642 state_file.write_text(current_hash) 

643 count_file.write_text("0") 

644 return EvaluationResult( 

645 verdict="yes", 

646 details={ 

647 "stall_count": 0, 

648 "max_repeat": max_repeat, 

649 "hash_changed": True, 

650 "tracked_keys": effective_track, 

651 }, 

652 ) 

653 

654 hash_changed = current_hash != previous_hash 

655 

656 if hash_changed: 

657 # Progress: update snapshot and reset counter 

658 state_file.write_text(current_hash) 

659 count_file.write_text("0") 

660 return EvaluationResult( 

661 verdict="yes", 

662 details={ 

663 "stall_count": 0, 

664 "max_repeat": max_repeat, 

665 "hash_changed": True, 

666 "tracked_keys": effective_track, 

667 }, 

668 ) 

669 else: 

670 # Same hash as last time 

671 stall_count += 1 

672 count_file.write_text(str(stall_count)) 

673 if stall_count >= max_repeat: 

674 return EvaluationResult( 

675 verdict="no", 

676 details={ 

677 "stall_count": stall_count, 

678 "max_repeat": max_repeat, 

679 "hash_changed": False, 

680 "tracked_keys": effective_track, 

681 "repeated_hash": current_hash, 

682 }, 

683 ) 

684 # Not yet at max_repeat threshold — still report yes so loop continues 

685 return EvaluationResult( 

686 verdict="yes", 

687 details={ 

688 "stall_count": stall_count, 

689 "max_repeat": max_repeat, 

690 "hash_changed": False, 

691 "tracked_keys": effective_track, 

692 }, 

693 ) 

694 

695 

696def evaluate_mcp_result(output: str, exit_code: int) -> EvaluationResult: 

697 """Evaluate an MCP tool call result from the mcp-call subprocess. 

698 

699 Maps exit codes and MCP response envelope fields to routing verdicts. 

700 

701 Exit code conventions (set by mcp-call): 

702 0 → parse isError from JSON envelope 

703 1 → tool_error (tool ran but isError: true) 

704 124 → timeout (transport-level timeout) 

705 127 → not_found (server or tool missing from .mcp.json) 

706 

707 Args: 

708 output: stdout from mcp-call (MCP response envelope JSON) 

709 exit_code: Exit code from mcp-call subprocess 

710 

711 Returns: 

712 EvaluationResult with verdict: 

713 - success → isError: false 

714 - tool_error → isError: true 

715 - not_found → server/tool not in .mcp.json (exit 127) 

716 - timeout → transport-level timeout (exit 124) 

717 """ 

718 if exit_code == 127: 

719 return EvaluationResult( 

720 verdict="not_found", 

721 details={"exit_code": exit_code, "error": "Server or tool not found in .mcp.json"}, 

722 ) 

723 

724 if exit_code == 124: 

725 return EvaluationResult( 

726 verdict="timeout", 

727 details={"exit_code": exit_code, "error": "MCP tool call timed out"}, 

728 ) 

729 

730 # Parse MCP envelope JSON from stdout 

731 try: 

732 envelope = json.loads(output.strip()) if output.strip() else {} 

733 except json.JSONDecodeError: 

734 return EvaluationResult( 

735 verdict="tool_error", 

736 details={ 

737 "exit_code": exit_code, 

738 "error": f"Invalid JSON from mcp-call: {output[:200]}", 

739 }, 

740 ) 

741 

742 is_error = envelope.get("isError", exit_code != 0) 

743 

744 if is_error: 

745 return EvaluationResult( 

746 verdict="tool_error", 

747 details={"exit_code": exit_code, "envelope": envelope}, 

748 ) 

749 

750 return EvaluationResult( 

751 verdict="success", 

752 details={"exit_code": exit_code, "envelope": envelope}, 

753 ) 

754 

755 

756def evaluate_harbor_scorer(output: str, exit_code: int) -> EvaluationResult: 

757 """Evaluate a Harbor-format benchmark scorer result. 

758 

759 The scorer is a shell command that prints a float score (0.0–1.0) to stdout 

760 and exits 0 on success or non-zero on failure. 

761 

762 Args: 

763 output: stdout from the scorer subprocess (expected: a bare float) 

764 exit_code: Exit code from the scorer subprocess 

765 

766 Returns: 

767 EvaluationResult with verdict: 

768 - yes → exit 0 and stdout parses as a float 

769 - no → exit non-zero (scorer determined failure) 

770 - error → exit 0 but stdout is not parseable as a float 

771 """ 

772 if exit_code != 0: 

773 return EvaluationResult( 

774 verdict="no", 

775 details={"exit_code": exit_code}, 

776 ) 

777 

778 try: 

779 score = float(output.strip()) 

780 except (ValueError, AttributeError): 

781 return EvaluationResult( 

782 verdict="error", 

783 details={ 

784 "exit_code": exit_code, 

785 "error": f"Scorer stdout is not a float: {output[:200]}", 

786 }, 

787 ) 

788 

789 return EvaluationResult( 

790 verdict="yes", 

791 details={"score": score, "exit_code": 0}, 

792 ) 

793 

794 

795def evaluate_llm_structured( 

796 output: str, 

797 prompt: str | None = None, 

798 schema: dict[str, Any] | None = None, 

799 min_confidence: float = 0.5, 

800 uncertain_suffix: bool = False, 

801 model: str = DEFAULT_LLM_MODEL, 

802 max_tokens: int = 256, 

803 timeout: int = 1800, 

804) -> EvaluationResult: 

805 """Evaluate action output using LLM with structured output via Claude CLI. 

806 

807 This is the ONLY place in the FSM system that uses LLM structured output. 

808 Requires the ``claude`` CLI to be installed and authenticated. 

809 

810 Args: 

811 output: Action stdout to evaluate 

812 prompt: Custom evaluation prompt (defaults to basic success check) 

813 schema: Custom JSON schema for structured response 

814 min_confidence: Minimum confidence threshold (0-1) 

815 uncertain_suffix: If True, append _uncertain to low-confidence verdicts 

816 model: Model identifier (CLI aliases like "sonnet" or full names) 

817 max_tokens: Maximum tokens for response (passed to --max-turns is not 

818 applicable; kept for signature compat) 

819 timeout: Timeout in seconds 

820 

821 Returns: 

822 EvaluationResult with verdict from LLM and confidence/reason in details 

823 """ 

824 effective_schema = schema or DEFAULT_LLM_SCHEMA 

825 effective_prompt = prompt or DEFAULT_LLM_PROMPT 

826 

827 # Truncate output to avoid context limits (keep last 4000 chars) 

828 truncated = output[-4000:] if len(output) > 4000 else output 

829 

830 user_prompt = f"{effective_prompt}\n\n<action_output>\n{truncated}\n</action_output>" 

831 

832 invocation = resolve_host().build_blocking_json(prompt=user_prompt, model=model) 

833 # Builder drops json_schema (Protocol surface only) and omits the 

834 # claude-CLI-specific --no-session-persistence flag; augment at call site. 

835 args = list(invocation.args) + [ 

836 "--json-schema", 

837 json.dumps(effective_schema), 

838 "--no-session-persistence", 

839 ] 

840 

841 t0 = time.monotonic() 

842 try: 

843 proc = subprocess.run( 

844 [invocation.binary, *args], capture_output=True, text=True, timeout=timeout 

845 ) 

846 except subprocess.TimeoutExpired: 

847 return EvaluationResult( 

848 verdict="error", 

849 details={"error": "LLM evaluation timeout", "timeout": True}, 

850 ) 

851 except FileNotFoundError: 

852 return EvaluationResult( 

853 verdict="error", 

854 details={ 

855 "error": f"{invocation.binary} CLI not found. Install the active host CLI (see LL_HOST_CLI).", 

856 "missing_dependency": True, 

857 }, 

858 ) 

859 llm_latency_ms = int((time.monotonic() - t0) * 1000) 

860 

861 if proc.returncode != 0: 

862 return EvaluationResult( 

863 verdict="error", 

864 details={ 

865 "error": f"{invocation.binary} CLI error: {proc.stderr.strip()}", 

866 "api_error": True, 

867 }, 

868 ) 

869 

870 # Guard: empty stdout with exit 0 (API error not reflected in exit code) 

871 if not proc.stdout.strip(): 

872 stderr_info = proc.stderr.strip()[:200] if proc.stderr else "" 

873 error_msg = f"{invocation.binary} CLI returned empty output" 

874 if stderr_info: 

875 error_msg += f" (stderr: {stderr_info})" 

876 return EvaluationResult( 

877 verdict="error", 

878 details={"error": error_msg, "empty_output": True}, 

879 ) 

880 

881 # Parse the CLI JSON envelope and extract structured result. 

882 # With --json-schema the envelope is: 

883 # success: {"type":"result","subtype":"success","structured_output":{...},...} 

884 # failure: {"type":"result","subtype":"error_max_structured_output_retries",...} 

885 # If stdout is JSONL (multiple JSON objects), use the last non-empty line. 

886 try: 

887 stdout = proc.stdout.strip() 

888 try: 

889 envelope = json.loads(stdout) 

890 except json.JSONDecodeError: 

891 # Try JSONL: take the last non-empty line 

892 lines = [line for line in stdout.split("\n") if line.strip()] 

893 if not lines: 

894 raise 

895 envelope = json.loads(lines[-1]) 

896 

897 # Check structured-output retry exhaustion (--json-schema failure mode) 

898 if envelope.get("subtype") == "error_max_structured_output_retries": 

899 return EvaluationResult( 

900 verdict="error", 

901 details={ 

902 "error": "Claude CLI could not produce valid structured output after retries", 

903 "api_error": True, 

904 }, 

905 ) 

906 

907 # Check legacy is_error flag (some CLI versions exit 0 but report error in envelope) 

908 if envelope.get("is_error", False): 

909 err_text = str(envelope.get("result", "") or "")[:200] 

910 return EvaluationResult( 

911 verdict="error", 

912 details={"error": f"Claude CLI reported error: {err_text}", "api_error": True}, 

913 ) 

914 

915 # --json-schema mode returns validated dict in "structured_output" 

916 if isinstance(envelope.get("structured_output"), dict): 

917 llm_result: dict[str, Any] = envelope["structured_output"] 

918 else: 

919 raw_result = envelope.get("result", "") 

920 if isinstance(raw_result, dict): 

921 llm_result = raw_result 

922 elif raw_result: 

923 llm_result = json.loads(raw_result) 

924 elif "verdict" in envelope: 

925 llm_result = envelope 

926 else: 

927 raw_preview = proc.stdout[:300] 

928 return EvaluationResult( 

929 verdict="error", 

930 details={ 

931 "error": "Empty result field in Claude CLI response", 

932 "raw_preview": raw_preview, 

933 }, 

934 ) 

935 except (json.JSONDecodeError, TypeError, ValueError) as e: 

936 raw_preview = proc.stdout[:300] if proc.stdout else "(empty)" 

937 return EvaluationResult( 

938 verdict="error", 

939 details={"error": f"Failed to parse LLM response: {e}", "raw_preview": raw_preview}, 

940 ) 

941 

942 # Build result with confidence handling 

943 verdict = str(llm_result.get("verdict", "error")) 

944 confidence = float(llm_result.get("confidence", 1.0)) 

945 confident = confidence >= min_confidence 

946 

947 # Optionally modify verdict for low confidence 

948 if uncertain_suffix and not confident: 

949 verdict = f"{verdict}_uncertain" 

950 

951 return EvaluationResult( 

952 verdict=verdict, 

953 details={ 

954 "confidence": confidence, 

955 "confident": confident, 

956 "reason": llm_result.get("reason", ""), 

957 "raw": llm_result, 

958 "llm_model": model, 

959 "llm_latency_ms": llm_latency_ms, 

960 "llm_prompt": user_prompt[:500], 

961 "llm_raw_output": proc.stdout[:500] if proc.stdout else "", 

962 }, 

963 ) 

964 

965 

966def evaluate_blind_comparator( 

967 output_harness: str, 

968 output_baseline: str, 

969 prompt: str | None = None, 

970 model: str = DEFAULT_LLM_MODEL, 

971 timeout: int = 1800, 

972) -> dict[str, Any]: 

973 """Blindly evaluate two outputs, returning pass/fail for each arm. 

974 

975 Outputs are randomly labeled "Output A" / "Output B" so the LLM judge 

976 cannot distinguish the harness arm from the baseline arm. The mapping is 

977 de-anonymized after judgment so callers receive harness/baseline verdicts. 

978 

979 Args: 

980 output_harness: stdout from the harness (gated) arm 

981 output_baseline: stdout from the baseline (ungated) arm 

982 prompt: Custom evaluation prompt (appended to default framing) 

983 model: Model identifier for the judge 

984 timeout: Timeout in seconds 

985 

986 Returns: 

987 Dict with keys: harness_pass (bool), baseline_pass (bool), 

988 confidence (float), reason (str), raw (dict with A/B verdicts) 

989 """ 

990 effective_prompt = prompt or DEFAULT_BLIND_COMPARATOR_PROMPT 

991 

992 # Truncate outputs to avoid context limits 

993 truncated_harness = output_harness[-4000:] if len(output_harness) > 4000 else output_harness 

994 truncated_baseline = output_baseline[-4000:] if len(output_baseline) > 4000 else output_baseline 

995 

996 # Randomize order: coin flip determines whether harness→A / baseline→B 

997 harness_is_a = random.choice([True, False]) 

998 if harness_is_a: 

999 output_a, output_b = truncated_harness, truncated_baseline 

1000 else: 

1001 output_a, output_b = truncated_baseline, truncated_harness 

1002 

1003 user_prompt = ( 

1004 f"{effective_prompt}\n\n" 

1005 f"<output_a>\n{output_a}\n</output_a>\n\n" 

1006 f"<output_b>\n{output_b}\n</output_b>" 

1007 ) 

1008 

1009 invocation = resolve_host().build_blocking_json(prompt=user_prompt, model=model) 

1010 args = list(invocation.args) + [ 

1011 "--json-schema", 

1012 json.dumps(BLIND_COMPARATOR_SCHEMA), 

1013 "--no-session-persistence", 

1014 ] 

1015 

1016 try: 

1017 proc = subprocess.run( 

1018 [invocation.binary, *args], capture_output=True, text=True, timeout=timeout 

1019 ) 

1020 except subprocess.TimeoutExpired: 

1021 # On timeout, both fail — conservative default 

1022 return { 

1023 "harness_pass": False, 

1024 "baseline_pass": False, 

1025 "confidence": 0.0, 

1026 "reason": "LLM evaluation timed out", 

1027 "raw": {"verdict_a": "timeout", "verdict_b": "timeout"}, 

1028 "error": "timeout", 

1029 } 

1030 except FileNotFoundError: 

1031 return { 

1032 "harness_pass": False, 

1033 "baseline_pass": False, 

1034 "confidence": 0.0, 

1035 "reason": f"{invocation.binary} CLI not found", 

1036 "raw": {"verdict_a": "error", "verdict_b": "error"}, 

1037 "error": "missing_cli", 

1038 } 

1039 

1040 if proc.returncode != 0: 

1041 return { 

1042 "harness_pass": False, 

1043 "baseline_pass": False, 

1044 "confidence": 0.0, 

1045 "reason": f"Judge CLI error: {proc.stderr.strip()[:200]}", 

1046 "raw": {"verdict_a": "error", "verdict_b": "error"}, 

1047 "error": "api_error", 

1048 } 

1049 

1050 if not proc.stdout.strip(): 

1051 return { 

1052 "harness_pass": False, 

1053 "baseline_pass": False, 

1054 "confidence": 0.0, 

1055 "reason": "Judge returned empty output", 

1056 "raw": {"verdict_a": "error", "verdict_b": "error"}, 

1057 "error": "empty_output", 

1058 } 

1059 

1060 try: 

1061 stdout = proc.stdout.strip() 

1062 try: 

1063 envelope = json.loads(stdout) 

1064 except json.JSONDecodeError: 

1065 lines = [line for line in stdout.split("\n") if line.strip()] 

1066 if not lines: 

1067 raise 

1068 envelope = json.loads(lines[-1]) 

1069 

1070 if envelope.get("subtype") == "error_max_structured_output_retries": 

1071 return { 

1072 "harness_pass": False, 

1073 "baseline_pass": False, 

1074 "confidence": 0.0, 

1075 "reason": "Judge could not produce valid structured output after retries", 

1076 "raw": {"verdict_a": "error", "verdict_b": "error"}, 

1077 "error": "retry_exhausted", 

1078 } 

1079 

1080 if envelope.get("is_error", False): 

1081 err_text = str(envelope.get("result", "") or "")[:200] 

1082 return { 

1083 "harness_pass": False, 

1084 "baseline_pass": False, 

1085 "confidence": 0.0, 

1086 "reason": f"Judge reported error: {err_text}", 

1087 "raw": {"verdict_a": "error", "verdict_b": "error"}, 

1088 "error": "api_error", 

1089 } 

1090 

1091 if isinstance(envelope.get("structured_output"), dict): 

1092 result: dict[str, Any] = envelope["structured_output"] 

1093 else: 

1094 raw_result = envelope.get("result", "") 

1095 if isinstance(raw_result, dict): 

1096 result = raw_result 

1097 elif raw_result: 

1098 result = json.loads(raw_result) 

1099 else: 

1100 return { 

1101 "harness_pass": False, 

1102 "baseline_pass": False, 

1103 "confidence": 0.0, 

1104 "reason": "Empty result field in judge response", 

1105 "raw": {"verdict_a": "error", "verdict_b": "error"}, 

1106 "error": "empty_result", 

1107 } 

1108 except (json.JSONDecodeError, TypeError, ValueError): 

1109 return { 

1110 "harness_pass": False, 

1111 "baseline_pass": False, 

1112 "confidence": 0.0, 

1113 "reason": "Failed to parse judge response", 

1114 "raw": {"verdict_a": "error", "verdict_b": "error"}, 

1115 "error": "parse_error", 

1116 } 

1117 

1118 # De-anonymize 

1119 verdict_a = str(result.get("verdict_a", "no")) 

1120 verdict_b = str(result.get("verdict_b", "no")) 

1121 confidence = float(result.get("confidence", 0.0)) 

1122 reason = str(result.get("reason", "")) 

1123 

1124 if harness_is_a: 

1125 harness_pass = verdict_a == "yes" 

1126 baseline_pass = verdict_b == "yes" 

1127 else: 

1128 harness_pass = verdict_b == "yes" 

1129 baseline_pass = verdict_a == "yes" 

1130 

1131 return { 

1132 "harness_pass": harness_pass, 

1133 "baseline_pass": baseline_pass, 

1134 "confidence": confidence, 

1135 "reason": reason, 

1136 "raw": {"verdict_a": verdict_a, "verdict_b": verdict_b, "harness_is_a": harness_is_a}, 

1137 } 

1138 

1139 

1140def evaluate_contract( 

1141 config: EvaluateConfig, 

1142 context: InterpolationContext, 

1143 model: str = DEFAULT_LLM_MODEL, 

1144 timeout: int = 1800, 

1145) -> EvaluationResult: 

1146 """Evaluate producer/consumer contract alignment using an LLM judge. 

1147 

1148 Reads each producer/consumer file pair, applies optional regex extraction, 

1149 then asks an LLM judge whether the producer satisfies the consumer contract. 

1150 Returns yes only when all pairs align; any failure routes no/error. 

1151 

1152 Args: 

1153 config: EvaluateConfig with type="contract" and pairs list 

1154 context: Interpolation context (unused by this evaluator directly) 

1155 model: LLM model identifier 

1156 timeout: Subprocess timeout in seconds 

1157 

1158 Returns: 

1159 EvaluationResult with verdict yes/no/error and pair_results in details 

1160 """ 

1161 pairs = config.pairs 

1162 if not pairs: 

1163 return EvaluationResult( 

1164 verdict="error", 

1165 details={"error": "contract evaluator requires at least one pair in evaluate.pairs"}, 

1166 ) 

1167 

1168 contract_schema = { 

1169 "type": "object", 

1170 "properties": { 

1171 "verdict": {"type": "string", "enum": ["yes", "no"]}, 

1172 "confidence": {"type": "number"}, 

1173 "reason": {"type": "string"}, 

1174 }, 

1175 "required": ["verdict", "confidence", "reason"], 

1176 } 

1177 

1178 pair_results: list[dict[str, Any]] = [] 

1179 

1180 for pair in pairs: 

1181 producer_path = pair.get("producer", "") 

1182 consumer_path = pair.get("consumer", "") 

1183 producer_pattern = pair.get("producer_pattern") 

1184 consumer_pattern = pair.get("consumer_pattern") 

1185 contract_rule = pair.get("contract", "the producer and consumer must be compatible") 

1186 

1187 # Read producer file 

1188 try: 

1189 producer_content = Path(producer_path).read_text() 

1190 except OSError as e: 

1191 pair_results.append( 

1192 { 

1193 "producer": producer_path, 

1194 "consumer": consumer_path, 

1195 "verdict": "error", 

1196 "error": f"cannot read producer file: {e}", 

1197 } 

1198 ) 

1199 continue 

1200 

1201 # Read consumer file 

1202 try: 

1203 consumer_content = Path(consumer_path).read_text() 

1204 except OSError as e: 

1205 pair_results.append( 

1206 { 

1207 "producer": producer_path, 

1208 "consumer": consumer_path, 

1209 "verdict": "error", 

1210 "error": f"cannot read consumer file: {e}", 

1211 } 

1212 ) 

1213 continue 

1214 

1215 # Apply optional regex extraction 

1216 if producer_pattern: 

1217 matches = re.findall(producer_pattern, producer_content, re.DOTALL) 

1218 if not matches: 

1219 pair_results.append( 

1220 { 

1221 "producer": producer_path, 

1222 "consumer": consumer_path, 

1223 "verdict": "error", 

1224 "error": f"producer_pattern matched nothing in {producer_path}", 

1225 } 

1226 ) 

1227 continue 

1228 producer_slice = "\n".join(matches) 

1229 else: 

1230 producer_slice = ( 

1231 producer_content[-4000:] if len(producer_content) > 4000 else producer_content 

1232 ) 

1233 

1234 if consumer_pattern: 

1235 matches = re.findall(consumer_pattern, consumer_content, re.DOTALL) 

1236 if not matches: 

1237 pair_results.append( 

1238 { 

1239 "producer": producer_path, 

1240 "consumer": consumer_path, 

1241 "verdict": "error", 

1242 "error": f"consumer_pattern matched nothing in {consumer_path}", 

1243 } 

1244 ) 

1245 continue 

1246 consumer_slice = "\n".join(matches) 

1247 else: 

1248 consumer_slice = ( 

1249 consumer_content[-4000:] if len(consumer_content) > 4000 else consumer_content 

1250 ) 

1251 

1252 judge_prompt = ( 

1253 f"You are evaluating whether a producer output satisfies a consumer contract.\n\n" 

1254 f"Contract rule: {contract_rule}\n\n" 

1255 f'<producer path="{producer_path}">\n{producer_slice}\n</producer>\n\n' 

1256 f'<consumer path="{consumer_path}">\n{consumer_slice}\n</consumer>\n\n' 

1257 "Does the producer satisfy the consumer contract? " 

1258 "Consider field names, types, casing, and structure. " 

1259 "Answer yes if aligned, no if mismatched." 

1260 ) 

1261 

1262 invocation = resolve_host().build_blocking_json(prompt=judge_prompt, model=model) 

1263 args = list(invocation.args) + [ 

1264 "--json-schema", 

1265 json.dumps(contract_schema), 

1266 "--no-session-persistence", 

1267 ] 

1268 

1269 t0 = time.monotonic() 

1270 try: 

1271 proc = subprocess.run( 

1272 [invocation.binary, *args], capture_output=True, text=True, timeout=timeout 

1273 ) 

1274 except subprocess.TimeoutExpired: 

1275 pair_results.append( 

1276 { 

1277 "producer": producer_path, 

1278 "consumer": consumer_path, 

1279 "verdict": "error", 

1280 "error": "LLM judge timed out", 

1281 "llm_latency_ms": int((time.monotonic() - t0) * 1000), 

1282 } 

1283 ) 

1284 continue 

1285 except FileNotFoundError: 

1286 return EvaluationResult( 

1287 verdict="error", 

1288 details={ 

1289 "error": f"{invocation.binary} CLI not found. Install the active host CLI (see LL_HOST_CLI).", 

1290 "missing_dependency": True, 

1291 }, 

1292 ) 

1293 llm_latency_ms = int((time.monotonic() - t0) * 1000) 

1294 

1295 if proc.returncode != 0: 

1296 pair_results.append( 

1297 { 

1298 "producer": producer_path, 

1299 "consumer": consumer_path, 

1300 "verdict": "error", 

1301 "error": f"CLI error: {proc.stderr.strip()}", 

1302 "llm_latency_ms": llm_latency_ms, 

1303 } 

1304 ) 

1305 continue 

1306 

1307 if not proc.stdout.strip(): 

1308 pair_results.append( 

1309 { 

1310 "producer": producer_path, 

1311 "consumer": consumer_path, 

1312 "verdict": "error", 

1313 "error": "CLI returned empty output", 

1314 "llm_latency_ms": llm_latency_ms, 

1315 } 

1316 ) 

1317 continue 

1318 

1319 try: 

1320 stdout = proc.stdout.strip() 

1321 try: 

1322 envelope = json.loads(stdout) 

1323 except json.JSONDecodeError: 

1324 lines = [line for line in stdout.split("\n") if line.strip()] 

1325 if not lines: 

1326 raise 

1327 envelope = json.loads(lines[-1]) 

1328 

1329 if envelope.get("subtype") == "error_max_structured_output_retries": 

1330 pair_results.append( 

1331 { 

1332 "producer": producer_path, 

1333 "consumer": consumer_path, 

1334 "verdict": "error", 

1335 "error": "Claude CLI could not produce valid structured output after retries", 

1336 "llm_latency_ms": llm_latency_ms, 

1337 } 

1338 ) 

1339 continue 

1340 

1341 if envelope.get("is_error", False): 

1342 err_text = str(envelope.get("result", "") or "")[:200] 

1343 pair_results.append( 

1344 { 

1345 "producer": producer_path, 

1346 "consumer": consumer_path, 

1347 "verdict": "error", 

1348 "error": f"Claude CLI reported error: {err_text}", 

1349 "llm_latency_ms": llm_latency_ms, 

1350 } 

1351 ) 

1352 continue 

1353 

1354 if isinstance(envelope.get("structured_output"), dict): 

1355 llm_result: dict[str, Any] = envelope["structured_output"] 

1356 else: 

1357 raw_result = envelope.get("result", "") 

1358 if isinstance(raw_result, dict): 

1359 llm_result = raw_result 

1360 elif raw_result: 

1361 llm_result = json.loads(raw_result) 

1362 elif "verdict" in envelope: 

1363 llm_result = envelope 

1364 else: 

1365 pair_results.append( 

1366 { 

1367 "producer": producer_path, 

1368 "consumer": consumer_path, 

1369 "verdict": "error", 

1370 "error": "empty result field in CLI response", 

1371 "llm_latency_ms": llm_latency_ms, 

1372 } 

1373 ) 

1374 continue 

1375 

1376 except (json.JSONDecodeError, TypeError, ValueError) as e: 

1377 pair_results.append( 

1378 { 

1379 "producer": producer_path, 

1380 "consumer": consumer_path, 

1381 "verdict": "error", 

1382 "error": f"failed to parse LLM response: {e}", 

1383 "llm_latency_ms": llm_latency_ms, 

1384 } 

1385 ) 

1386 continue 

1387 

1388 pair_results.append( 

1389 { 

1390 "producer": producer_path, 

1391 "consumer": consumer_path, 

1392 "verdict": str(llm_result.get("verdict", "error")), 

1393 "confidence": float(llm_result.get("confidence", 1.0)), 

1394 "reason": llm_result.get("reason", ""), 

1395 "llm_latency_ms": llm_latency_ms, 

1396 } 

1397 ) 

1398 

1399 # Aggregate: yes only if all pairs aligned; error takes precedence over no 

1400 if any(p["verdict"] == "error" for p in pair_results): 

1401 overall = "error" 

1402 elif all(p["verdict"] == "yes" for p in pair_results): 

1403 overall = "yes" 

1404 else: 

1405 overall = "no" 

1406 

1407 return EvaluationResult( 

1408 verdict=overall, 

1409 details={"pair_results": pair_results}, 

1410 ) 

1411 

1412 

1413def evaluate_comparator( 

1414 config: EvaluateConfig, 

1415 output: str, 

1416 context: InterpolationContext, 

1417) -> EvaluationResult: 

1418 """Evaluate using blind A/B comparison against a stored baseline.""" 

1419 from pathlib import Path 

1420 

1421 if config.baseline_path is None: 

1422 return EvaluationResult( 

1423 verdict="no_baseline", 

1424 details={"reason": "No baseline_path configured"}, 

1425 ) 

1426 

1427 baseline_file = Path(config.baseline_path) / "output.txt" 

1428 if not baseline_file.exists(): 

1429 if config.auto_promote: 

1430 baseline_file.parent.mkdir(parents=True, exist_ok=True) 

1431 baseline_file.write_text(output) 

1432 return EvaluationResult( 

1433 verdict="yes", 

1434 details={ 

1435 "reason": "No baseline found; current output promoted as new baseline.", 

1436 "bootstrapped": True, 

1437 }, 

1438 ) 

1439 return EvaluationResult( 

1440 verdict="no_baseline", 

1441 details={"reason": f"Baseline file not found: {baseline_file}"}, 

1442 ) 

1443 

1444 baseline_text = baseline_file.read_text() 

1445 min_pairs = max(1, config.min_pairs if config.min_pairs is not None else 1) 

1446 harness_wins = 0 

1447 baseline_wins = 0 

1448 last_reason = "" 

1449 last_raw: dict[str, Any] = {} 

1450 

1451 for _ in range(min_pairs): 

1452 result = evaluate_blind_comparator(output, baseline_text, prompt=config.prompt) 

1453 if result.get("harness_pass"): 

1454 harness_wins += 1 

1455 if result.get("baseline_pass"): 

1456 baseline_wins += 1 

1457 last_reason = result.get("reason", "") 

1458 last_raw = result.get("raw", {}) 

1459 

1460 if harness_wins > baseline_wins: 

1461 verdict = "yes" 

1462 elif baseline_wins > harness_wins: 

1463 verdict = "no" 

1464 else: 

1465 verdict = "tie" 

1466 

1467 if config.auto_promote and verdict == "yes": 

1468 baseline_file.write_text(output) 

1469 

1470 return EvaluationResult( 

1471 verdict=verdict, 

1472 details={ 

1473 "harness_wins": harness_wins, 

1474 "baseline_wins": baseline_wins, 

1475 "min_pairs": min_pairs, 

1476 "reason": last_reason, 

1477 "raw": last_raw, 

1478 }, 

1479 ) 

1480 

1481 

1482def evaluate( 

1483 config: EvaluateConfig, 

1484 output: str, 

1485 exit_code: int, 

1486 context: InterpolationContext, 

1487) -> EvaluationResult: 

1488 """Dispatch to appropriate evaluator based on config type. 

1489 

1490 Args: 

1491 config: Evaluator configuration with type and parameters 

1492 output: Action stdout 

1493 exit_code: Action exit code 

1494 context: Runtime context for variable interpolation 

1495 

1496 Returns: 

1497 EvaluationResult from the appropriate evaluator 

1498 

1499 Raises: 

1500 ValueError: If evaluator type is unknown 

1501 """ 

1502 eval_type = config.type 

1503 

1504 # BUG-1640: Action-level timeouts (exit_code=124) short-circuit to "error" 

1505 # so loop authors' on_error: branches fire instead of being routed via 

1506 # on_no: based on truncated output. mcp_result is exempted because it has 

1507 # its own established "timeout" verdict (see evaluate_mcp_result). 

1508 if exit_code == 124 and eval_type != "mcp_result": 

1509 return EvaluationResult( 

1510 verdict="error", 

1511 details={"exit_code": exit_code, "error": "action timed out"}, 

1512 ) 

1513 

1514 # BUG-1815: Non-timeout non-zero exit codes short-circuit to "error" for 

1515 # evaluator types that don't intrinsically check exit codes. Exit-code-aware 

1516 # evaluators (exit_code, mcp_result, harbor_scorer, diff_stall, llm_structured) 

1517 # are exempt because they handle exit codes via their own logic. 

1518 _EXIT_CODE_AWARE_EVALUATORS: frozenset[str] = frozenset( 

1519 { 

1520 "exit_code", 

1521 "mcp_result", 

1522 "harbor_scorer", 

1523 "diff_stall", 

1524 "action_stall", 

1525 "llm_structured", 

1526 "contract", 

1527 } 

1528 ) 

1529 if exit_code != 0 and eval_type not in _EXIT_CODE_AWARE_EVALUATORS: 

1530 return EvaluationResult( 

1531 verdict="error", 

1532 details={ 

1533 "exit_code": exit_code, 

1534 "error": f"action exited with code {exit_code}", 

1535 }, 

1536 ) 

1537 

1538 if eval_type == "exit_code": 

1539 return evaluate_exit_code(exit_code) 

1540 

1541 elif eval_type == "output_numeric": 

1542 if config.target is None: 

1543 raise ValueError("output_numeric evaluator requires 'target' to be set") 

1544 elif isinstance(config.target, str): 

1545 try: 

1546 resolved = interpolate(config.target, context) if context else config.target 

1547 numeric_target = float(resolved) 

1548 except (InterpolationError, ValueError) as e: 

1549 raise ValueError( 

1550 f"output_numeric target must be numeric, got: {config.target!r}" 

1551 ) from e 

1552 else: 

1553 numeric_target = float(config.target) 

1554 return evaluate_output_numeric( 

1555 output=output, 

1556 operator=config.operator or "eq", 

1557 target=numeric_target, 

1558 ) 

1559 

1560 elif eval_type == "output_json": 

1561 return evaluate_output_json( 

1562 output=output, 

1563 path=config.path or "", 

1564 operator=config.operator or "eq", 

1565 target=config.target, 

1566 ) 

1567 

1568 elif eval_type == "output_contains": 

1569 return evaluate_output_contains( 

1570 output=output, 

1571 pattern=config.pattern or "", 

1572 negate=config.negate, 

1573 ) 

1574 

1575 elif eval_type == "convergence": 

1576 # Resolve previous value from interpolation if configured 

1577 previous: float | None = None 

1578 if config.previous: 

1579 try: 

1580 previous = float(interpolate(config.previous, context)) 

1581 except (InterpolationError, ValueError): 

1582 # Previous unavailable on first iteration, continue with None 

1583 pass 

1584 

1585 # Parse current value from output 

1586 try: 

1587 current = float(output.strip()) 

1588 except ValueError: 

1589 return EvaluationResult( 

1590 verdict="error", 

1591 details={"error": f"Cannot parse output as number: {output[:100]}"}, 

1592 ) 

1593 

1594 # Resolve target (may be interpolated string like "${context.target}") 

1595 convergence_target: float 

1596 if isinstance(config.target, str): 

1597 try: 

1598 convergence_target = float(interpolate(config.target, context)) 

1599 except (InterpolationError, ValueError) as e: 

1600 return EvaluationResult( 

1601 verdict="error", 

1602 details={"error": f"Cannot resolve target: {e}"}, 

1603 ) 

1604 else: 

1605 if config.target is None: 

1606 raise ValueError("convergence evaluator requires 'target' to be set") 

1607 convergence_target = float(config.target) 

1608 

1609 # Resolve tolerance (may be interpolated string) 

1610 tolerance: float = 0.0 

1611 if config.tolerance is not None: 

1612 if isinstance(config.tolerance, str): 

1613 try: 

1614 tolerance = float(interpolate(config.tolerance, context)) 

1615 except (InterpolationError, ValueError): 

1616 tolerance = 0.0 

1617 else: 

1618 tolerance = float(config.tolerance) 

1619 

1620 return evaluate_convergence( 

1621 current=current, 

1622 previous=previous, 

1623 target=convergence_target, 

1624 tolerance=tolerance, 

1625 direction=config.direction, 

1626 ) 

1627 

1628 elif eval_type == "diff_stall": 

1629 return evaluate_diff_stall( 

1630 scope=config.scope, 

1631 max_stall=config.max_stall, 

1632 ) 

1633 

1634 elif eval_type == "action_stall": 

1635 return evaluate_action_stall( 

1636 track=config.track, 

1637 max_repeat=config.max_repeat, 

1638 context=context, 

1639 ) 

1640 

1641 elif eval_type == "llm_structured": 

1642 prompt = config.prompt 

1643 if prompt and context: 

1644 try: 

1645 prompt = interpolate(prompt, context) 

1646 except InterpolationError: 

1647 pass # Use raw prompt on resolution failure 

1648 return evaluate_llm_structured( 

1649 output=output, 

1650 prompt=prompt, 

1651 schema=config.schema, 

1652 min_confidence=config.min_confidence, 

1653 uncertain_suffix=config.uncertain_suffix, 

1654 ) 

1655 

1656 elif eval_type == "mcp_result": 

1657 return evaluate_mcp_result(output=output, exit_code=exit_code) 

1658 

1659 elif eval_type == "harbor_scorer": 

1660 return evaluate_harbor_scorer(output=output, exit_code=exit_code) 

1661 

1662 elif eval_type == "comparator": 

1663 return evaluate_comparator(config=config, output=output, context=context) 

1664 

1665 elif eval_type == "contract": 

1666 return evaluate_contract(config=config, context=context) 

1667 

1668 elif eval_type == "classify": 

1669 return evaluate_classify(output=output, line=config.line) 

1670 

1671 else: 

1672 raise ValueError(f"Unknown evaluator type: {eval_type}")