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

260 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-04-11 23:20 -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 

16Tier 2 (LLM-based): 

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

18""" 

19 

20from __future__ import annotations 

21 

22import hashlib 

23import json 

24import re 

25import subprocess 

26import time 

27from collections.abc import Callable 

28from dataclasses import dataclass 

29from pathlib import Path 

30from typing import Any 

31 

32from little_loops.fsm.interpolation import ( 

33 InterpolationContext, 

34 InterpolationError, 

35 interpolate, 

36) 

37from little_loops.fsm.schema import DEFAULT_LLM_MODEL, EvaluateConfig 

38 

39 

40@dataclass 

41class EvaluationResult: 

42 """Result from an evaluator. 

43 

44 Attributes: 

45 verdict: The routing key for state transitions 

46 details: Evaluator-specific metadata for debugging/logging 

47 """ 

48 

49 verdict: str 

50 details: dict[str, Any] 

51 

52 

53# Default schema for LLM structured evaluation 

54DEFAULT_LLM_SCHEMA: dict[str, Any] = { 

55 "type": "object", 

56 "properties": { 

57 "verdict": { 

58 "type": "string", 

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

60 "description": ( 

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

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

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

64 "- partial: Made progress but not complete" 

65 ), 

66 }, 

67 "confidence": { 

68 "type": "number", 

69 "minimum": 0, 

70 "maximum": 1, 

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

72 }, 

73 "reason": { 

74 "type": "string", 

75 "description": "Brief explanation", 

76 }, 

77 }, 

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

79} 

80 

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

82 

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

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

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

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

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

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

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

90} 

91 

92 

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

94 """Map Unix exit code to verdict. 

95 

96 Args: 

97 exit_code: The process exit code 

98 

99 Returns: 

100 EvaluationResult with verdict: 

101 - 0 -> yes 

102 - 1 -> no 

103 - 2+ -> error 

104 """ 

105 if exit_code == 0: 

106 verdict = "yes" 

107 elif exit_code == 1: 

108 verdict = "no" 

109 else: 

110 verdict = "error" 

111 

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

113 

114 

115def evaluate_output_numeric( 

116 output: str, 

117 operator: str, 

118 target: float, 

119) -> EvaluationResult: 

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

121 

122 Args: 

123 output: The action stdout to parse as a number 

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

125 target: Target value to compare against 

126 

127 Returns: 

128 EvaluationResult with verdict: 

129 - Condition met -> yes 

130 - Condition not met -> no 

131 - Parse error -> error 

132 """ 

133 try: 

134 value = float(output.strip()) 

135 except ValueError: 

136 return EvaluationResult( 

137 verdict="error", 

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

139 ) 

140 

141 if operator not in _NUMERIC_OPERATORS: 

142 return EvaluationResult( 

143 verdict="error", 

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

145 ) 

146 

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

148 return EvaluationResult( 

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

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

151 ) 

152 

153 

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

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

156 

157 Args: 

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

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

160 

161 Returns: 

162 The value at the specified path 

163 

164 Raises: 

165 KeyError: If path not found in data 

166 """ 

167 if path.startswith("."): 

168 path = path[1:] 

169 parts = path.split(".") 

170 current = data 

171 for part in parts: 

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

173 current = current[part] 

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

175 idx = int(part) 

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

177 current = current[idx] 

178 else: 

179 raise KeyError(path) 

180 else: 

181 raise KeyError(path) 

182 return current 

183 

184 

185def _compare_values( 

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

187) -> EvaluationResult: 

188 """Compare numeric values using operator. 

189 

190 Args: 

191 value: The extracted value to compare 

192 operator: Comparison operator 

193 target: Target value 

194 path: JSON path for details 

195 

196 Returns: 

197 EvaluationResult with comparison result 

198 """ 

199 if operator not in _NUMERIC_OPERATORS: 

200 return EvaluationResult( 

201 verdict="error", 

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

203 ) 

204 

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

206 return EvaluationResult( 

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

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

209 ) 

210 

211 

212def evaluate_output_json( 

213 output: str, 

214 path: str, 

215 operator: str, 

216 target: Any, 

217) -> EvaluationResult: 

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

219 

220 Args: 

221 output: The action stdout containing JSON 

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

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

224 target: Target value for comparison 

225 

226 Returns: 

227 EvaluationResult with verdict: 

228 - Condition met -> yes 

229 - Condition not met -> no 

230 - Parse/path error -> error 

231 """ 

232 try: 

233 data = json.loads(output) 

234 except json.JSONDecodeError as e: 

235 return EvaluationResult( 

236 verdict="error", 

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

238 ) 

239 

240 try: 

241 value = _extract_json_path(data, path) 

242 except KeyError: 

243 return EvaluationResult( 

244 verdict="error", 

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

246 ) 

247 

248 # Use numeric comparison if both values are numeric 

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

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

251 

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

253 if operator == "eq": 

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

255 elif operator == "ne": 

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

257 else: 

258 return EvaluationResult( 

259 verdict="error", 

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

261 ) 

262 

263 return EvaluationResult( 

264 verdict=verdict, 

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

266 ) 

267 

268 

269def evaluate_output_contains( 

270 output: str, 

271 pattern: str, 

272 negate: bool = False, 

273) -> EvaluationResult: 

274 """Check if pattern exists in output. 

275 

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

277 falls back to substring matching. 

278 

279 Args: 

280 output: The action stdout to search 

281 pattern: Regex pattern or substring 

282 negate: If True, invert the match result 

283 

284 Returns: 

285 EvaluationResult with verdict: 

286 - Found (negate=False) -> yes 

287 - Found (negate=True) -> no 

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

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

290 """ 

291 # Try regex first, fall back to substring 

292 try: 

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

294 except re.error: 

295 matched = pattern in output 

296 

297 if negate: 

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

299 else: 

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

301 

302 return EvaluationResult( 

303 verdict=verdict, 

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

305 ) 

306 

307 

308def evaluate_convergence( 

309 current: float, 

310 previous: float | None, 

311 target: float, 

312 tolerance: float = 0, 

313 direction: str = "minimize", 

314) -> EvaluationResult: 

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

316 

317 Args: 

318 current: Current metric value 

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

320 target: Target value to reach 

321 tolerance: Acceptable distance from target 

322 direction: 'minimize' or 'maximize' 

323 

324 Returns: 

325 EvaluationResult with verdict: 

326 - Value within tolerance of target -> target 

327 - Value improved toward target -> progress 

328 - Value unchanged or worsened -> stall 

329 """ 

330 # Check if target reached (within tolerance) 

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

332 return EvaluationResult( 

333 verdict="target", 

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

335 ) 

336 

337 # First iteration has no previous value 

338 if previous is None: 

339 return EvaluationResult( 

340 verdict="progress", 

341 details={ 

342 "current": current, 

343 "previous": None, 

344 "target": target, 

345 "delta": None, 

346 }, 

347 ) 

348 

349 # Calculate progress 

350 delta = current - previous 

351 

352 if direction == "minimize": 

353 # For minimizing, negative delta is progress 

354 made_progress = delta < 0 

355 else: 

356 # For maximizing, positive delta is progress 

357 made_progress = delta > 0 

358 

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

360 

361 return EvaluationResult( 

362 verdict=verdict, 

363 details={ 

364 "current": current, 

365 "previous": previous, 

366 "target": target, 

367 "delta": delta, 

368 "direction": direction, 

369 }, 

370 ) 

371 

372 

373def evaluate_diff_stall( 

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

375 max_stall: int = 1, 

376) -> EvaluationResult: 

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

378 

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

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

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

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

383 'yes' (progress). 

384 

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

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

387 

388 Args: 

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

390 the entire working tree. 

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

392 verdict. Defaults to 1. 

393 

394 Returns: 

395 EvaluationResult with verdict: 

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

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

398 - error: git command failed or timed out 

399 """ 

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

401 if scope: 

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

403 

404 try: 

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

406 except subprocess.TimeoutExpired: 

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

408 except FileNotFoundError: 

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

410 

411 if proc.returncode != 0: 

412 return EvaluationResult( 

413 verdict="error", 

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

415 ) 

416 

417 current_diff = proc.stdout 

418 

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

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

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

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

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

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

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

426 

427 # Read previous snapshot and stall count 

428 previous_diff: str | None = None 

429 stall_count = 0 

430 try: 

431 previous_diff = state_file.read_text() 

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

433 except (FileNotFoundError, ValueError): 

434 pass 

435 

436 # First iteration: save snapshot and report progress 

437 if previous_diff is None: 

438 state_file.write_text(current_diff) 

439 count_file.write_text("0") 

440 return EvaluationResult( 

441 verdict="yes", 

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

443 ) 

444 

445 if current_diff == previous_diff: 

446 stall_count += 1 

447 count_file.write_text(str(stall_count)) 

448 if stall_count >= max_stall: 

449 return EvaluationResult( 

450 verdict="no", 

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

452 ) 

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

454 return EvaluationResult( 

455 verdict="yes", 

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

457 ) 

458 else: 

459 # Progress: update snapshot and reset counter 

460 state_file.write_text(current_diff) 

461 count_file.write_text("0") 

462 return EvaluationResult( 

463 verdict="yes", 

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

465 ) 

466 

467 

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

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

470 

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

472 

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

474 0 → parse isError from JSON envelope 

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

476 124 → timeout (transport-level timeout) 

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

478 

479 Args: 

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

481 exit_code: Exit code from mcp-call subprocess 

482 

483 Returns: 

484 EvaluationResult with verdict: 

485 - success → isError: false 

486 - tool_error → isError: true 

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

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

489 """ 

490 if exit_code == 127: 

491 return EvaluationResult( 

492 verdict="not_found", 

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

494 ) 

495 

496 if exit_code == 124: 

497 return EvaluationResult( 

498 verdict="timeout", 

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

500 ) 

501 

502 # Parse MCP envelope JSON from stdout 

503 try: 

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

505 except json.JSONDecodeError: 

506 return EvaluationResult( 

507 verdict="tool_error", 

508 details={ 

509 "exit_code": exit_code, 

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

511 }, 

512 ) 

513 

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

515 

516 if is_error: 

517 return EvaluationResult( 

518 verdict="tool_error", 

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

520 ) 

521 

522 return EvaluationResult( 

523 verdict="success", 

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

525 ) 

526 

527 

528def evaluate_llm_structured( 

529 output: str, 

530 prompt: str | None = None, 

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

532 min_confidence: float = 0.5, 

533 uncertain_suffix: bool = False, 

534 model: str = DEFAULT_LLM_MODEL, 

535 max_tokens: int = 256, 

536 timeout: int = 1800, 

537) -> EvaluationResult: 

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

539 

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

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

542 

543 Args: 

544 output: Action stdout to evaluate 

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

546 schema: Custom JSON schema for structured response 

547 min_confidence: Minimum confidence threshold (0-1) 

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

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

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

551 applicable; kept for signature compat) 

552 timeout: Timeout in seconds 

553 

554 Returns: 

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

556 """ 

557 effective_schema = schema or DEFAULT_LLM_SCHEMA 

558 effective_prompt = prompt or DEFAULT_LLM_PROMPT 

559 

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

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

562 

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

564 

565 cmd = [ 

566 "claude", 

567 "-p", 

568 user_prompt, 

569 "--output-format", 

570 "json", 

571 "--json-schema", 

572 json.dumps(effective_schema), 

573 "--model", 

574 model, 

575 "--dangerously-skip-permissions", 

576 "--no-session-persistence", 

577 ] 

578 

579 t0 = time.monotonic() 

580 try: 

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

582 except subprocess.TimeoutExpired: 

583 return EvaluationResult( 

584 verdict="error", 

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

586 ) 

587 except FileNotFoundError: 

588 return EvaluationResult( 

589 verdict="error", 

590 details={ 

591 "error": "claude CLI not found. Install from https://docs.anthropic.com/en/docs/claude-code", 

592 "missing_dependency": True, 

593 }, 

594 ) 

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

596 

597 if proc.returncode != 0: 

598 return EvaluationResult( 

599 verdict="error", 

600 details={"error": f"Claude CLI error: {proc.stderr.strip()}", "api_error": True}, 

601 ) 

602 

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

604 if not proc.stdout.strip(): 

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

606 error_msg = "Claude CLI returned empty output" 

607 if stderr_info: 

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

609 return EvaluationResult( 

610 verdict="error", 

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

612 ) 

613 

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

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

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

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

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

619 try: 

620 stdout = proc.stdout.strip() 

621 try: 

622 envelope = json.loads(stdout) 

623 except json.JSONDecodeError: 

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

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

626 if not lines: 

627 raise 

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

629 

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

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

632 return EvaluationResult( 

633 verdict="error", 

634 details={ 

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

636 "api_error": True, 

637 }, 

638 ) 

639 

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

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

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

643 return EvaluationResult( 

644 verdict="error", 

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

646 ) 

647 

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

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

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

651 else: 

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

653 if isinstance(raw_result, dict): 

654 llm_result = raw_result 

655 elif raw_result: 

656 llm_result = json.loads(raw_result) 

657 elif "verdict" in envelope: 

658 llm_result = envelope 

659 else: 

660 raw_preview = proc.stdout[:300] 

661 return EvaluationResult( 

662 verdict="error", 

663 details={ 

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

665 "raw_preview": raw_preview, 

666 }, 

667 ) 

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

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

670 return EvaluationResult( 

671 verdict="error", 

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

673 ) 

674 

675 # Build result with confidence handling 

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

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

678 confident = confidence >= min_confidence 

679 

680 # Optionally modify verdict for low confidence 

681 if uncertain_suffix and not confident: 

682 verdict = f"{verdict}_uncertain" 

683 

684 return EvaluationResult( 

685 verdict=verdict, 

686 details={ 

687 "confidence": confidence, 

688 "confident": confident, 

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

690 "raw": llm_result, 

691 "llm_model": model, 

692 "llm_latency_ms": llm_latency_ms, 

693 "llm_prompt": user_prompt[:500], 

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

695 }, 

696 ) 

697 

698 

699def evaluate( 

700 config: EvaluateConfig, 

701 output: str, 

702 exit_code: int, 

703 context: InterpolationContext, 

704) -> EvaluationResult: 

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

706 

707 Args: 

708 config: Evaluator configuration with type and parameters 

709 output: Action stdout 

710 exit_code: Action exit code 

711 context: Runtime context for variable interpolation 

712 

713 Returns: 

714 EvaluationResult from the appropriate evaluator 

715 

716 Raises: 

717 ValueError: If evaluator type is unknown 

718 """ 

719 eval_type = config.type 

720 

721 if eval_type == "exit_code": 

722 return evaluate_exit_code(exit_code) 

723 

724 elif eval_type == "output_numeric": 

725 if config.target is None: 

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

727 elif isinstance(config.target, str): 

728 try: 

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

730 numeric_target = float(resolved) 

731 except (InterpolationError, ValueError) as e: 

732 raise ValueError( 

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

734 ) from e 

735 else: 

736 numeric_target = float(config.target) 

737 return evaluate_output_numeric( 

738 output=output, 

739 operator=config.operator or "eq", 

740 target=numeric_target, 

741 ) 

742 

743 elif eval_type == "output_json": 

744 return evaluate_output_json( 

745 output=output, 

746 path=config.path or "", 

747 operator=config.operator or "eq", 

748 target=config.target, 

749 ) 

750 

751 elif eval_type == "output_contains": 

752 return evaluate_output_contains( 

753 output=output, 

754 pattern=config.pattern or "", 

755 negate=config.negate, 

756 ) 

757 

758 elif eval_type == "convergence": 

759 # Resolve previous value from interpolation if configured 

760 previous: float | None = None 

761 if config.previous: 

762 try: 

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

764 except (InterpolationError, ValueError): 

765 # Previous unavailable on first iteration, continue with None 

766 pass 

767 

768 # Parse current value from output 

769 try: 

770 current = float(output.strip()) 

771 except ValueError: 

772 return EvaluationResult( 

773 verdict="error", 

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

775 ) 

776 

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

778 convergence_target: float 

779 if isinstance(config.target, str): 

780 try: 

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

782 except (InterpolationError, ValueError) as e: 

783 return EvaluationResult( 

784 verdict="error", 

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

786 ) 

787 else: 

788 if config.target is None: 

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

790 convergence_target = float(config.target) 

791 

792 # Resolve tolerance (may be interpolated string) 

793 tolerance: float = 0.0 

794 if config.tolerance is not None: 

795 if isinstance(config.tolerance, str): 

796 try: 

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

798 except (InterpolationError, ValueError): 

799 tolerance = 0.0 

800 else: 

801 tolerance = float(config.tolerance) 

802 

803 return evaluate_convergence( 

804 current=current, 

805 previous=previous, 

806 target=convergence_target, 

807 tolerance=tolerance, 

808 direction=config.direction, 

809 ) 

810 

811 elif eval_type == "diff_stall": 

812 return evaluate_diff_stall( 

813 scope=config.scope, 

814 max_stall=config.max_stall, 

815 ) 

816 

817 elif eval_type == "llm_structured": 

818 prompt = config.prompt 

819 if prompt and context: 

820 try: 

821 prompt = interpolate(prompt, context) 

822 except InterpolationError: 

823 pass # Use raw prompt on resolution failure 

824 return evaluate_llm_structured( 

825 output=output, 

826 prompt=prompt, 

827 schema=config.schema, 

828 min_confidence=config.min_confidence, 

829 uncertain_suffix=config.uncertain_suffix, 

830 ) 

831 

832 elif eval_type == "mcp_result": 

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

834 

835 else: 

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