Coverage for little_loops / fsm / schema.py: 37%

549 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-15 17:27 -0500

1"""FSM loop schema dataclasses. 

2 

3This module defines the type-safe dataclasses that represent FSM loop 

4definitions. These match the universal FSM schema described in the 

5design documentation. 

6 

7The schema supports: 

8- Multiple evaluator types (exit_code, output_numeric, etc.) 

9- Two-layer transition system (evaluate + route) 

10- Both shorthand (on_success/on_failure) and full routing syntax 

11- Context variables and captured values 

12- LLM evaluation configuration 

13""" 

14 

15from __future__ import annotations 

16 

17from dataclasses import dataclass, field 

18from typing import Any, Literal 

19 

20# Default LLM model for structured evaluation 

21DEFAULT_LLM_MODEL: str = "sonnet" 

22 

23 

24@dataclass 

25class EvaluateConfig: 

26 """Evaluator configuration for action result interpretation. 

27 

28 The evaluator determines how to interpret an action's output and 

29 produce a verdict string for routing. 

30 

31 Attributes: 

32 type: Evaluator type. One of: 

33 - exit_code: Map exit codes to verdicts (default for shell) 

34 - output_numeric: Compare numeric output to target 

35 - output_json: Extract and compare JSON path value 

36 - output_contains: Pattern matching on stdout 

37 - convergence: Compare current vs previous value toward target 

38 - llm_structured: Use LLM with structured output (default for slash) 

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

40 target: Target value for comparison 

41 tolerance: Acceptable distance from target (for convergence) 

42 pattern: Pattern string for output_contains 

43 negate: If True, invert the match result (output_contains) 

44 path: JSON path for output_json (jq-style) 

45 prompt: Custom prompt for llm_structured 

46 schema: Custom JSON schema for llm_structured response 

47 min_confidence: Minimum confidence threshold for llm_structured 

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

49 source: Override default source (current action output) 

50 previous: Previous value reference for convergence 

51 direction: Optimization direction for convergence (minimize/maximize) 

52 scope: Paths to limit git diff to for diff_stall evaluator 

53 max_stall: Consecutive no-change iterations before failure (diff_stall) 

54 pairs: List of producer/consumer pair dicts for contract evaluator 

55 """ 

56 

57 type: Literal[ 

58 "exit_code", 

59 "output_numeric", 

60 "output_json", 

61 "output_contains", 

62 "convergence", 

63 "diff_stall", 

64 "action_stall", 

65 "llm_structured", 

66 "mcp_result", 

67 "harbor_scorer", 

68 "comparator", 

69 "contract", 

70 ] 

71 operator: str | None = None 

72 target: int | float | str | None = None 

73 tolerance: float | str | None = None # str for interpolation (e.g., "${context.tolerance}") 

74 pattern: str | None = None 

75 negate: bool = False 

76 path: str | None = None 

77 prompt: str | None = None 

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

79 min_confidence: float = 0.5 

80 uncertain_suffix: bool = False 

81 source: str | None = None 

82 previous: str | None = None 

83 direction: Literal["minimize", "maximize"] = "minimize" 

84 scope: list[str] | None = None # for diff_stall: limit git diff to these paths 

85 max_stall: int = 1 # for diff_stall: consecutive no-change iterations before failure 

86 track: list[str] | None = None # for action_stall: context keys to track (default: ["action"]) 

87 max_repeat: int = 2 # for action_stall: consecutive identical iterations before failure 

88 baseline_path: str | None = None # for comparator: path to .loops/baselines/<loop>/ dir 

89 auto_promote: bool = False # for comparator: write output to baseline on yes verdict 

90 min_pairs: int = 1 # for comparator: number of blind A/B comparisons to run 

91 pairs: list[dict] | None = None # for contract: list of producer/consumer pair dicts 

92 

93 def to_dict(self) -> dict[str, Any]: 

94 """Convert to dictionary for JSON/YAML serialization.""" 

95 result: dict[str, Any] = {"type": self.type} 

96 

97 # Only include non-None optional fields 

98 if self.operator is not None: 

99 result["operator"] = self.operator 

100 if self.target is not None: 

101 result["target"] = self.target 

102 if self.tolerance is not None: 

103 result["tolerance"] = self.tolerance 

104 if self.pattern is not None: 

105 result["pattern"] = self.pattern 

106 if self.negate: 

107 result["negate"] = self.negate 

108 if self.path is not None: 

109 result["path"] = self.path 

110 if self.prompt is not None: 

111 result["prompt"] = self.prompt 

112 if self.schema is not None: 

113 result["schema"] = self.schema 

114 if self.min_confidence != 0.5: 

115 result["min_confidence"] = self.min_confidence 

116 if self.uncertain_suffix: 

117 result["uncertain_suffix"] = self.uncertain_suffix 

118 if self.source is not None: 

119 result["source"] = self.source 

120 if self.previous is not None: 

121 result["previous"] = self.previous 

122 if self.direction != "minimize": 

123 result["direction"] = self.direction 

124 if self.scope is not None: 

125 result["scope"] = self.scope 

126 if self.max_stall != 1: 

127 result["max_stall"] = self.max_stall 

128 if self.track is not None: 

129 result["track"] = self.track 

130 if self.max_repeat != 2: 

131 result["max_repeat"] = self.max_repeat 

132 if self.baseline_path is not None: 

133 result["baseline_path"] = self.baseline_path 

134 if self.auto_promote: 

135 result["auto_promote"] = self.auto_promote 

136 if self.min_pairs != 1: 

137 result["min_pairs"] = self.min_pairs 

138 if self.pairs is not None: 

139 result["pairs"] = self.pairs 

140 

141 return result 

142 

143 @classmethod 

144 def from_dict(cls, data: dict[str, Any]) -> EvaluateConfig: 

145 """Create from dictionary (JSON/YAML deserialization).""" 

146 return cls( 

147 type=data["type"], 

148 operator=data.get("operator"), 

149 target=data.get("target"), 

150 tolerance=data.get("tolerance"), 

151 pattern=data.get("pattern"), 

152 negate=data.get("negate", False), 

153 path=data.get("path"), 

154 prompt=data.get("prompt"), 

155 schema=data.get("schema"), 

156 min_confidence=data.get("min_confidence", 0.5), 

157 uncertain_suffix=data.get("uncertain_suffix", False), 

158 source=data.get("source"), 

159 previous=data.get("previous"), 

160 direction=data.get("direction", "minimize"), 

161 scope=data.get("scope"), 

162 max_stall=data.get("max_stall", 1), 

163 track=data.get("track"), 

164 max_repeat=data.get("max_repeat", 2), 

165 baseline_path=data.get("baseline_path"), 

166 auto_promote=data.get("auto_promote", False), 

167 min_pairs=data.get("min_pairs", 1), 

168 pairs=data.get("pairs"), 

169 ) 

170 

171 

172@dataclass 

173class RouteConfig: 

174 """Routing table configuration for verdict-to-state mapping. 

175 

176 Maps verdict strings from evaluators to next state names. 

177 

178 Attributes: 

179 routes: Mapping from verdict string to next state name 

180 default: Default state for unmatched verdicts (the "_" key) 

181 error: State for evaluation/execution errors (the "_error" key) 

182 """ 

183 

184 routes: dict[str, str] = field(default_factory=dict) 

185 default: str | None = None 

186 error: str | None = None 

187 

188 def to_dict(self) -> dict[str, Any]: 

189 """Convert to dictionary for JSON/YAML serialization.""" 

190 result = dict(self.routes) 

191 if self.default is not None: 

192 result["_"] = self.default 

193 if self.error is not None: 

194 result["_error"] = self.error 

195 return result 

196 

197 @classmethod 

198 def from_dict(cls, data: dict[str, Any]) -> RouteConfig: 

199 """Create from dictionary (JSON/YAML deserialization).""" 

200 routes = {k: v for k, v in data.items() if not k.startswith("_")} 

201 return cls( 

202 routes=routes, 

203 default=data.get("_"), 

204 error=data.get("_error"), 

205 ) 

206 

207 

208@dataclass 

209class ParameterSpec: 

210 """Specification for a single loop input parameter. 

211 

212 Declares a typed input that callers bind via the 'with:' block on sub-loop states. 

213 

214 Attributes: 

215 type: Parameter type. One of: string, integer, number, boolean, enum, path. 

216 required: If True, callers must supply this parameter in 'with:' (mutually 

217 exclusive with 'default'). 

218 default: Default value used when the caller does not bind the parameter. 

219 Only valid when required is False. 

220 description: Human-readable description of the parameter. 

221 values: Allowed values for 'enum' type parameters. 

222 """ 

223 

224 type: str 

225 required: bool = False 

226 default: Any = None 

227 description: str | None = None 

228 values: list[Any] | None = None 

229 

230 def to_dict(self) -> dict[str, Any]: 

231 """Convert to dictionary for JSON/YAML serialization.""" 

232 result: dict[str, Any] = {"type": self.type} 

233 if self.required: 

234 result["required"] = self.required 

235 if self.default is not None: 

236 result["default"] = self.default 

237 if self.description is not None: 

238 result["description"] = self.description 

239 if self.values is not None: 

240 result["values"] = self.values 

241 return result 

242 

243 @classmethod 

244 def from_dict(cls, data: dict[str, Any]) -> ParameterSpec: 

245 """Create from dictionary (JSON/YAML deserialization).""" 

246 return cls( 

247 type=data["type"], 

248 required=data.get("required", False), 

249 default=data.get("default"), 

250 description=data.get("description"), 

251 values=data.get("values"), 

252 ) 

253 

254 

255@dataclass 

256class ThrottleConfig: 

257 """Per-state tool-call progressive throttling configuration. 

258 

259 Counts successful tool calls within a single state visit and escalates 

260 restrictions to self-throttle runaway states before provider limits are reached. 

261 

262 Thresholds fall back to executor module defaults when not set: 

263 - normal_max: _DEFAULT_THROTTLE_NORMAL_MAX (3) — calls 1..normal_max pass through 

264 - warn_max: _DEFAULT_THROTTLE_WARN_MAX (8) — calls at warn_max inject a warning event 

265 - hard_max: _DEFAULT_THROTTLE_HARD_MAX (12) — calls at hard_max route to on_throttle_hard 

266 - calls > hard_max: hard stop, loop marked stuck 

267 

268 States with type="learning" (FEAT-1283) are exempt from the hard_max hard-stop because 

269 they legitimately make N tool calls per visit (one per unproven target). The warn_max 

270 warning still applies so users can see the state is doing significant work. 

271 """ 

272 

273 normal_max: int | None = None 

274 warn_max: int | None = None 

275 hard_max: int | None = None 

276 

277 def to_dict(self) -> dict[str, Any]: 

278 """Convert to dictionary for JSON/YAML serialization.""" 

279 result: dict[str, Any] = {} 

280 if self.normal_max is not None: 

281 result["normal_max"] = self.normal_max 

282 if self.warn_max is not None: 

283 result["warn_max"] = self.warn_max 

284 if self.hard_max is not None: 

285 result["hard_max"] = self.hard_max 

286 return result 

287 

288 @classmethod 

289 def from_dict(cls, data: dict[str, Any]) -> ThrottleConfig: 

290 """Create from dictionary (JSON/YAML deserialization).""" 

291 return cls( 

292 normal_max=data.get("normal_max"), 

293 warn_max=data.get("warn_max"), 

294 hard_max=data.get("hard_max"), 

295 ) 

296 

297 

298@dataclass 

299class LearningConfig: 

300 """Per-state configuration for FEAT-1283 `type: learning` dispatch. 

301 

302 Declares the list of external-API/SDK targets a learning state must prove 

303 against the learning-tests registry (ENH-1282) before advancing. On a 

304 missing or stale record, the state invokes `/ll:explore-api <target>` up to 

305 `max_retries` times before transitioning to a blocked target. 

306 

307 Attributes: 

308 targets: Ordered list of target identifiers (e.g. "Anthropic SDK 

309 streaming"). Each is slugified internally when looking up the 

310 registry record. All targets must reach status="proven" for the 

311 state to advance via on_yes. 

312 targets_csv: Runtime-interpolated comma-separated target string (e.g. 

313 "${context.targets}"). Resolved and CSV-split by the executor at 

314 execution time. Alternative to the static ``targets`` list for loops 

315 that receive targets as a context CSV string. Exactly one of 

316 ``targets`` (non-empty) or ``targets_csv`` must be set. 

317 max_retries: Maximum number of `/ll:explore-api` invocations per target 

318 before the state routes to on_blocked / on_no with reason 

319 ``retries_exhausted``. Counts only re-exploration attempts; the 

320 initial registry lookup is free. Distinct from ENH-1115's throttle 

321 counter, which measures tool-call volume; learning states are 

322 already exempt from throttle hard_max via FSMExecutor._check_throttle. 

323 max_retries_expr: Runtime-interpolated retry limit (e.g. 

324 "${context.max_retries}"). Resolved via interpolate() and int()-cast 

325 at execution time. Takes precedence over ``max_retries`` when set. 

326 """ 

327 

328 targets: list[str] = field(default_factory=list) 

329 targets_csv: str | None = None 

330 max_retries: int = 2 

331 max_retries_expr: str | None = None 

332 

333 def to_dict(self) -> dict[str, Any]: 

334 """Convert to dictionary for JSON/YAML serialization.""" 

335 result: dict[str, Any] = {"targets": list(self.targets), "max_retries": self.max_retries} 

336 if self.targets_csv is not None: 

337 result["targets_csv"] = self.targets_csv 

338 if self.max_retries_expr is not None: 

339 result["max_retries_expr"] = self.max_retries_expr 

340 return result 

341 

342 @classmethod 

343 def from_dict(cls, data: dict[str, Any]) -> LearningConfig: 

344 """Create from dictionary (JSON/YAML deserialization).""" 

345 return cls( 

346 targets=list(data.get("targets") or []), 

347 targets_csv=data.get("targets_csv") or None, 

348 max_retries=int(data.get("max_retries", 2)), 

349 max_retries_expr=data.get("max_retries_expr") or None, 

350 ) 

351 

352 

353@dataclass 

354class StateConfig: 

355 """Configuration for a single FSM state. 

356 

357 States can have actions, evaluators, and routing. Supports both 

358 shorthand (on_success/on_failure) and full routing table syntax. 

359 

360 Attributes: 

361 action: Command to execute (shell, slash command, or "server/tool-name" for mcp_tool) 

362 action_type: How to execute the action (prompt, slash_command, shell, mcp_tool). 

363 If None, uses heuristic: / prefix = slash_command, else = shell. 

364 params: MCP tool arguments (only used with action_type: mcp_tool). Supports 

365 ${variable} interpolation in string values. 

366 evaluate: Evaluator configuration for result interpretation 

367 route: Full routing table (verdict -> state mapping) 

368 on_yes: Shorthand for yes verdict routing 

369 on_no: Shorthand for no verdict routing 

370 on_error: Shorthand for error verdict routing 

371 on_partial: Shorthand for partial verdict routing 

372 next: Unconditional transition (no evaluation) 

373 terminal: If True, this is an end state 

374 capture: Variable name to store action output 

375 timeout: Action-level timeout in seconds 

376 on_maintain: State to transition to when maintain=True and loop completes 

377 max_retries: Max consecutive re-entries before transitioning to on_retry_exhausted. 

378 A value of N allows N retries after the initial execution (N+1 total entries). 

379 Requires on_retry_exhausted to also be set. 

380 on_retry_exhausted: State to transition to when max_retries consecutive re-entries 

381 are exceeded. Required when max_retries is set. 

382 max_rate_limit_retries: Max consecutive 429/rate-limit in-place retries for this 

383 state before transitioning to on_rate_limit_exhausted. Requires 

384 on_rate_limit_exhausted to also be set. 

385 on_rate_limit_exhausted: State to transition to when max_rate_limit_retries 

386 consecutive rate-limit retries are exceeded. Required when 

387 max_rate_limit_retries is set. 

388 rate_limit_backoff_base_seconds: Base seconds for exponential backoff between 

389 rate-limit retries; actual sleep is base * 2^(attempt-1) + uniform(0, base). 

390 Defaults to 30. 

391 rate_limit_max_wait_seconds: Total wall-clock budget (seconds) for rate-limit 

392 handling in this state before routing to on_rate_limit_exhausted. When unset, 

393 defaults from commands.rate_limits.max_wait_seconds (21600 = 6h). 

394 rate_limit_long_wait_ladder: Backoff ladder (seconds) for the long-wait tier used 

395 once the short-tier retry budget is spent. Each entry is the sleep before the 

396 next retry attempt. When unset, defaults from 

397 commands.rate_limits.long_wait_ladder ([300, 900, 1800, 3600]). 

398 loop: Name of a loop YAML to execute as a sub-FSM. Mutually exclusive with action. 

399 context_passthrough: When True, pass parent context variables to child loop and 

400 merge child captures back into parent context. Legacy escape hatch; prefer 

401 'with_' for explicit named bindings. 

402 with_: Explicit parameter bindings for sub-loop calls (YAML key: 'with'). Maps 

403 declared child parameter names to parent expressions. Only valid when 'loop' 

404 is set. Mutually exclusive with context_passthrough. 

405 fragment_name: Original fragment name from the 'fragment:' YAML key. Populated by 

406 resolve_fragments; None for non-fragment states. Used for validation and debugging. 

407 fragment_bindings: Parameter bindings for fragment references (YAML key 'with' on a 

408 fragment state, renamed to 'fragment_bindings' by resolve_fragments). Distinct from 

409 with_ (sub-loop bindings) and params (MCP tool args). Only valid when fragment_name 

410 is set. 

411 fragment_parameters: Parsed ParameterSpec declarations from the fragment definition's 

412 'parameters:' block. Populated by resolve_fragments for validation and runtime checks. 

413 """ 

414 

415 action: str | None = None 

416 action_type: str | None = None 

417 params: dict[str, Any] = field(default_factory=dict) 

418 evaluate: EvaluateConfig | None = None 

419 route: RouteConfig | None = None 

420 on_yes: str | None = None 

421 on_no: str | None = None 

422 on_error: str | None = None 

423 on_partial: str | None = None 

424 on_blocked: str | None = None 

425 next: str | None = None 

426 terminal: bool = False 

427 capture: str | None = None 

428 append_to_messages: str | None = None 

429 timeout: int | None = None 

430 on_maintain: str | None = None 

431 max_retries: int | None = None 

432 on_retry_exhausted: str | None = None 

433 retryable_exit_codes: list[int] | None = None 

434 max_rate_limit_retries: int | None = None 

435 on_rate_limit_exhausted: str | None = None 

436 rate_limit_backoff_base_seconds: int | None = None 

437 rate_limit_max_wait_seconds: int | None = None 

438 rate_limit_long_wait_ladder: list[int] | None = None 

439 loop: str | None = None 

440 context_passthrough: bool = False 

441 with_: dict[str, Any] = field(default_factory=dict) 

442 fragment_name: str | None = None 

443 fragment_bindings: dict[str, Any] = field(default_factory=dict) 

444 fragment_parameters: dict[str, ParameterSpec] = field(default_factory=dict) 

445 agent: str | None = None 

446 tools: list[str] | None = None 

447 model: str | None = None 

448 extra_routes: dict[str, str] = field(default_factory=dict) 

449 type: str | None = None 

450 throttle: ThrottleConfig | None = None 

451 on_throttle_hard: str | None = None 

452 learning: LearningConfig | None = None 

453 

454 def to_dict(self) -> dict[str, Any]: 

455 """Convert to dictionary for JSON/YAML serialization.""" 

456 result: dict[str, Any] = {} 

457 

458 if self.action is not None: 

459 result["action"] = self.action 

460 if self.action_type is not None: 

461 result["action_type"] = self.action_type 

462 if self.params: 

463 result["params"] = self.params 

464 if self.evaluate is not None: 

465 result["evaluate"] = self.evaluate.to_dict() 

466 if self.route is not None: 

467 result["route"] = self.route.to_dict() 

468 if self.on_yes is not None: 

469 result["on_yes"] = self.on_yes 

470 if self.on_no is not None: 

471 result["on_no"] = self.on_no 

472 if self.on_error is not None: 

473 result["on_error"] = self.on_error 

474 if self.on_partial is not None: 

475 result["on_partial"] = self.on_partial 

476 if self.on_blocked is not None: 

477 result["on_blocked"] = self.on_blocked 

478 if self.next is not None: 

479 result["next"] = self.next 

480 if self.terminal: 

481 result["terminal"] = self.terminal 

482 if self.capture is not None: 

483 result["capture"] = self.capture 

484 if self.append_to_messages is not None: 

485 result["append_to_messages"] = self.append_to_messages 

486 if self.timeout is not None: 

487 result["timeout"] = self.timeout 

488 if self.on_maintain is not None: 

489 result["on_maintain"] = self.on_maintain 

490 if self.max_retries is not None: 

491 result["max_retries"] = self.max_retries 

492 if self.on_retry_exhausted is not None: 

493 result["on_retry_exhausted"] = self.on_retry_exhausted 

494 if self.retryable_exit_codes is not None: 

495 result["retryable_exit_codes"] = self.retryable_exit_codes 

496 if self.max_rate_limit_retries is not None: 

497 result["max_rate_limit_retries"] = self.max_rate_limit_retries 

498 if self.on_rate_limit_exhausted is not None: 

499 result["on_rate_limit_exhausted"] = self.on_rate_limit_exhausted 

500 if self.rate_limit_backoff_base_seconds is not None: 

501 result["rate_limit_backoff_base_seconds"] = self.rate_limit_backoff_base_seconds 

502 if self.rate_limit_max_wait_seconds is not None: 

503 result["rate_limit_max_wait_seconds"] = self.rate_limit_max_wait_seconds 

504 if self.rate_limit_long_wait_ladder is not None: 

505 result["rate_limit_long_wait_ladder"] = self.rate_limit_long_wait_ladder 

506 if self.loop is not None: 

507 result["loop"] = self.loop 

508 if self.context_passthrough: 

509 result["context_passthrough"] = self.context_passthrough 

510 if self.with_: 

511 result["with"] = self.with_ 

512 if self.fragment_bindings: 

513 result["fragment_bindings"] = self.fragment_bindings 

514 if self.agent is not None: 

515 result["agent"] = self.agent 

516 if self.tools is not None: 

517 result["tools"] = self.tools 

518 if self.model is not None: 

519 result["model"] = self.model 

520 for verdict, target in self.extra_routes.items(): 

521 result[f"on_{verdict}"] = target 

522 if self.type is not None: 

523 result["type"] = self.type 

524 if self.throttle is not None: 

525 result["throttle"] = self.throttle.to_dict() 

526 if self.on_throttle_hard is not None: 

527 result["on_throttle_hard"] = self.on_throttle_hard 

528 if self.learning is not None: 

529 result["learning"] = self.learning.to_dict() 

530 

531 return result 

532 

533 @classmethod 

534 def from_dict(cls, data: dict[str, Any]) -> StateConfig: 

535 """Create from dictionary (JSON/YAML deserialization).""" 

536 evaluate = None 

537 if "evaluate" in data: 

538 evaluate = EvaluateConfig.from_dict(data["evaluate"]) 

539 

540 route = None 

541 if "route" in data: 

542 route = RouteConfig.from_dict(data["route"]) 

543 

544 throttle = None 

545 if "throttle" in data: 

546 throttle = ThrottleConfig.from_dict(data["throttle"]) 

547 

548 learning = None 

549 if "learning" in data: 

550 learning = LearningConfig.from_dict(data["learning"]) 

551 

552 _known_on_keys = { 

553 "on_yes", 

554 "on_success", 

555 "on_no", 

556 "on_failure", 

557 "on_error", 

558 "on_partial", 

559 "on_blocked", 

560 "on_maintain", 

561 "on_retry_exhausted", 

562 "on_rate_limit_exhausted", 

563 "on_throttle_hard", 

564 } 

565 extra_routes = { 

566 key[3:]: val 

567 for key, val in data.items() 

568 if key.startswith("on_") and key not in _known_on_keys and isinstance(val, str) 

569 } 

570 

571 return cls( 

572 action=data.get("action"), 

573 action_type=data.get("action_type"), 

574 params=data.get("params", {}), 

575 evaluate=evaluate, 

576 route=route, 

577 on_yes=data.get("on_yes") or data.get("on_success"), 

578 on_no=data.get("on_no") or data.get("on_failure"), 

579 on_error=data.get("on_error"), 

580 on_partial=data.get("on_partial"), 

581 on_blocked=data.get("on_blocked"), 

582 next=data.get("next"), 

583 terminal=data.get("terminal", False), 

584 capture=data.get("capture"), 

585 append_to_messages=data.get("append_to_messages"), 

586 timeout=data.get("timeout"), 

587 on_maintain=data.get("on_maintain"), 

588 max_retries=data.get("max_retries"), 

589 on_retry_exhausted=data.get("on_retry_exhausted"), 

590 retryable_exit_codes=data.get("retryable_exit_codes"), 

591 max_rate_limit_retries=data.get("max_rate_limit_retries"), 

592 on_rate_limit_exhausted=data.get("on_rate_limit_exhausted"), 

593 rate_limit_backoff_base_seconds=data.get("rate_limit_backoff_base_seconds"), 

594 rate_limit_max_wait_seconds=data.get("rate_limit_max_wait_seconds"), 

595 rate_limit_long_wait_ladder=data.get("rate_limit_long_wait_ladder"), 

596 loop=data.get("loop"), 

597 context_passthrough=data.get("context_passthrough", False), 

598 with_=data.get("with", {}), 

599 agent=data.get("agent"), 

600 tools=data.get("tools"), 

601 model=data.get("model"), 

602 extra_routes=extra_routes, 

603 type=data.get("type"), 

604 throttle=throttle, 

605 on_throttle_hard=data.get("on_throttle_hard"), 

606 learning=learning, 

607 fragment_name=data.get("fragment_name"), 

608 fragment_bindings=data.get("fragment_bindings", {}), 

609 fragment_parameters={ 

610 name: ParameterSpec.from_dict(spec) 

611 for name, spec in data.get("fragment_parameters", {}).items() 

612 }, 

613 ) 

614 

615 def get_referenced_states(self) -> set[str]: 

616 """Get all state names referenced by this state configuration. 

617 

618 Returns: 

619 Set of state names that this state can transition to. 

620 """ 

621 refs: set[str] = set() 

622 

623 if self.on_yes is not None: 

624 refs.add(self.on_yes) 

625 if self.on_no is not None: 

626 refs.add(self.on_no) 

627 if self.on_error is not None: 

628 refs.add(self.on_error) 

629 if self.on_partial is not None: 

630 refs.add(self.on_partial) 

631 if self.on_blocked is not None: 

632 refs.add(self.on_blocked) 

633 if self.next is not None: 

634 refs.add(self.next) 

635 if self.on_maintain is not None: 

636 refs.add(self.on_maintain) 

637 if self.on_retry_exhausted is not None: 

638 refs.add(self.on_retry_exhausted) 

639 if self.on_rate_limit_exhausted is not None: 

640 refs.add(self.on_rate_limit_exhausted) 

641 if self.on_throttle_hard is not None: 

642 refs.add(self.on_throttle_hard) 

643 if self.route is not None: 

644 refs.update(self.route.routes.values()) 

645 if self.route.default is not None: 

646 refs.add(self.route.default) 

647 if self.route.error is not None: 

648 refs.add(self.route.error) 

649 refs.update(self.extra_routes.values()) 

650 

651 return refs 

652 

653 

654@dataclass 

655class LLMConfig: 

656 """LLM evaluation configuration. 

657 

658 Settings for the llm_structured evaluator. 

659 

660 Attributes: 

661 enabled: If False, disable LLM evaluation entirely 

662 model: Model identifier for LLM calls 

663 max_tokens: Maximum tokens for evaluation response 

664 timeout: Timeout for LLM calls in seconds 

665 """ 

666 

667 enabled: bool = True 

668 model: str = DEFAULT_LLM_MODEL 

669 max_tokens: int = 256 

670 timeout: int = 1800 

671 

672 def to_dict(self) -> dict[str, Any]: 

673 """Convert to dictionary for JSON/YAML serialization.""" 

674 result: dict[str, Any] = {} 

675 

676 if not self.enabled: 

677 result["enabled"] = self.enabled 

678 if self.model != DEFAULT_LLM_MODEL: 

679 result["model"] = self.model 

680 if self.max_tokens != 256: 

681 result["max_tokens"] = self.max_tokens 

682 if self.timeout != 1800: 

683 result["timeout"] = self.timeout 

684 

685 return result if result else {} 

686 

687 @classmethod 

688 def from_dict(cls, data: dict[str, Any]) -> LLMConfig: 

689 """Create from dictionary (JSON/YAML deserialization).""" 

690 return cls( 

691 enabled=data.get("enabled", True), 

692 model=data.get("model", DEFAULT_LLM_MODEL), 

693 max_tokens=data.get("max_tokens", 256), 

694 timeout=data.get("timeout", 1800), 

695 ) 

696 

697 

698@dataclass 

699class LoopConfigOverrides: 

700 """Per-loop ll-config overrides embedded in the loop YAML definition. 

701 

702 All fields are optional (None = use global ll-config default). 

703 Precedence: CLI flags > YAML config block > global ll-config > schema defaults. 

704 

705 Attributes: 

706 handoff_threshold: Override for LL_HANDOFF_THRESHOLD env var (1-100) 

707 readiness_threshold: Override for commands.confidence_gate.readiness_threshold (1-100) 

708 outcome_threshold: Override for commands.confidence_gate.outcome_threshold (1-100) 

709 max_continuations: Override for automation.max_continuations (>=1) 

710 """ 

711 

712 handoff_threshold: int | None = None 

713 readiness_threshold: int | None = None 

714 outcome_threshold: int | None = None 

715 max_continuations: int | None = None 

716 

717 def to_dict(self) -> dict[str, Any]: 

718 """Convert to dictionary for JSON/YAML serialization (skip-if-None).""" 

719 result: dict[str, Any] = {} 

720 

721 if self.handoff_threshold is not None: 

722 result["handoff_threshold"] = self.handoff_threshold 

723 

724 confidence_gate: dict[str, Any] = {} 

725 if self.readiness_threshold is not None: 

726 confidence_gate["readiness_threshold"] = self.readiness_threshold 

727 if self.outcome_threshold is not None: 

728 confidence_gate["outcome_threshold"] = self.outcome_threshold 

729 if confidence_gate: 

730 result["commands"] = {"confidence_gate": confidence_gate} 

731 

732 if self.max_continuations is not None: 

733 result["automation"] = {"max_continuations": self.max_continuations} 

734 

735 return result 

736 

737 @classmethod 

738 def from_dict(cls, data: dict[str, Any]) -> LoopConfigOverrides: 

739 """Create from dictionary (JSON/YAML deserialization).""" 

740 commands = data.get("commands", {}) 

741 confidence_gate = commands.get("confidence_gate", {}) if isinstance(commands, dict) else {} 

742 automation = data.get("automation", {}) 

743 continuation = data.get("continuation", {}) 

744 

745 max_continuations = None 

746 if isinstance(automation, dict) and "max_continuations" in automation: 

747 max_continuations = automation["max_continuations"] 

748 elif isinstance(continuation, dict) and "max_continuations" in continuation: 

749 max_continuations = continuation["max_continuations"] 

750 

751 return cls( 

752 handoff_threshold=data.get("handoff_threshold"), 

753 readiness_threshold=confidence_gate.get("readiness_threshold") 

754 if isinstance(confidence_gate, dict) 

755 else None, 

756 outcome_threshold=confidence_gate.get("outcome_threshold") 

757 if isinstance(confidence_gate, dict) 

758 else None, 

759 max_continuations=max_continuations, 

760 ) 

761 

762 

763@dataclass 

764class CommandEntry: 

765 """A single command entry for the loop's Commands display section. 

766 

767 Attributes: 

768 cmd: Full command string to display (e.g., "ll-loop run my-loop --param x=1") 

769 comment: Short description shown as a comment (e.g., "run with parameter x") 

770 """ 

771 

772 cmd: str 

773 comment: str 

774 

775 

776@dataclass 

777class TargetStateSpec: 

778 """Per-state targeting specification for harness-optimize APO (ENH-1552). 

779 

780 Names a single FSM state inside a target loop file and associates it with 

781 the examples file and eval fragment used during that state's optimization. 

782 

783 Attributes: 

784 name: State name within the target loop 

785 examples_file: Path to the examples YAML file for this state 

786 eval_fragment: Eval fragment identifier used during optimization 

787 """ 

788 

789 name: str 

790 examples_file: str 

791 eval_fragment: str 

792 

793 def to_dict(self) -> dict[str, Any]: 

794 """Convert to dictionary for JSON/YAML serialization.""" 

795 return { 

796 "name": self.name, 

797 "examples_file": self.examples_file, 

798 "eval": self.eval_fragment, 

799 } 

800 

801 @classmethod 

802 def from_dict(cls, data: dict[str, Any]) -> TargetStateSpec: 

803 """Create from dictionary (JSON/YAML deserialization).""" 

804 return cls( 

805 name=data["name"], 

806 examples_file=data["examples_file"], 

807 eval_fragment=data["eval"], 

808 ) 

809 

810 

811@dataclass 

812class TargetFileSpec: 

813 """Per-file targeting specification for harness-optimize APO (ENH-1552). 

814 

815 Associates a loop YAML file (or glob pattern) with the list of states 

816 to optimize within that file. 

817 

818 Attributes: 

819 file: Explicit path to a loop YAML file (mutually exclusive with glob) 

820 glob: Glob pattern matching one or more loop YAML files 

821 states: States within the matched file(s) to target 

822 """ 

823 

824 file: str | None = None 

825 glob: str | None = None 

826 states: list[TargetStateSpec] = field(default_factory=list) 

827 

828 def to_dict(self) -> dict[str, Any]: 

829 """Convert to dictionary for JSON/YAML serialization.""" 

830 result: dict[str, Any] = {} 

831 if self.file is not None: 

832 result["file"] = self.file 

833 if self.glob is not None: 

834 result["glob"] = self.glob 

835 if self.states: 

836 result["states"] = [s.to_dict() for s in self.states] 

837 return result 

838 

839 @classmethod 

840 def from_dict(cls, data: dict[str, Any]) -> TargetFileSpec: 

841 """Create from dictionary (JSON/YAML deserialization).""" 

842 return cls( 

843 file=data.get("file"), 

844 glob=data.get("glob"), 

845 states=[TargetStateSpec.from_dict(s) for s in (data.get("states") or [])], 

846 ) 

847 

848 

849@dataclass 

850class RepeatedFailureConfig: 

851 """Configuration for the FSM stall detector (FEAT-1637). 

852 

853 When N consecutive iterations produce an identical 

854 `(state_name, exit_code, eval_verdict)` triple, the FSM either 

855 aborts the run or routes to a configured recovery state. 

856 

857 Attributes: 

858 window: Consecutive iterations with identical triple required to 

859 fire (default 3). 

860 on_repeated_failure: Either the literal ``"abort"`` (terminate 

861 with ``terminated_by="stall_detected"``) or the name of a 

862 declared state to route to. 

863 """ 

864 

865 window: int = 3 

866 on_repeated_failure: str = "abort" 

867 progress_paths: list[str] = field(default_factory=list) 

868 exclude_paths: list[str] = field(default_factory=list) 

869 

870 def to_dict(self) -> dict[str, Any]: 

871 """Convert to dictionary for JSON/YAML serialization (skip-if-default).""" 

872 result: dict[str, Any] = {} 

873 if self.window != 3: 

874 result["window"] = self.window 

875 if self.on_repeated_failure != "abort": 

876 result["on_repeated_failure"] = self.on_repeated_failure 

877 if self.progress_paths: 

878 result["progress_paths"] = self.progress_paths 

879 if self.exclude_paths: 

880 result["exclude_paths"] = self.exclude_paths 

881 return result 

882 

883 @classmethod 

884 def from_dict(cls, data: dict[str, Any]) -> RepeatedFailureConfig: 

885 """Create from dictionary (JSON/YAML deserialization).""" 

886 return cls( 

887 window=data.get("window", 3), 

888 on_repeated_failure=data.get("on_repeated_failure", "abort"), 

889 progress_paths=data.get("progress_paths", []), 

890 exclude_paths=data.get("exclude_paths", []), 

891 ) 

892 

893 

894@dataclass 

895class CircuitConfig: 

896 """Top-level ``circuit:`` block grouping loop-level safety knobs. 

897 

898 Currently exposes ``repeated_failure`` (the stall detector). Future 

899 safety knobs (e.g. global timeouts, panic-stop guards) should be 

900 added here rather than as separate top-level keys. 

901 

902 Attributes: 

903 repeated_failure: Stall-detector configuration, or None to disable. 

904 """ 

905 

906 repeated_failure: RepeatedFailureConfig | None = None 

907 

908 def to_dict(self) -> dict[str, Any]: 

909 """Convert to dictionary for JSON/YAML serialization (skip-if-None).""" 

910 result: dict[str, Any] = {} 

911 if self.repeated_failure is not None: 

912 rf_dict = self.repeated_failure.to_dict() 

913 # Always emit the key when configured (even if all fields are defaults) 

914 # so the round-trip preserves "repeated_failure was set" intent. 

915 result["repeated_failure"] = rf_dict 

916 return result 

917 

918 @classmethod 

919 def from_dict(cls, data: dict[str, Any]) -> CircuitConfig: 

920 """Create from dictionary (JSON/YAML deserialization).""" 

921 rf = None 

922 if "repeated_failure" in data and data["repeated_failure"] is not None: 

923 rf = RepeatedFailureConfig.from_dict(data["repeated_failure"]) 

924 return cls(repeated_failure=rf) 

925 

926 

927@dataclass 

928class FSMLoop: 

929 """Complete FSM loop definition. 

930 

931 The main dataclass representing a loop configuration. 

932 

933 Attributes: 

934 name: Unique loop identifier 

935 initial: Starting state name 

936 states: Mapping from state name to StateConfig 

937 context: User-defined shared variables 

938 scope: Paths this loop operates on (for concurrency control). Supports ${context.<var>} template variables that are resolved at runtime. 

939 max_iterations: Safety limit for loop iterations 

940 backoff: Seconds between iterations 

941 timeout: Max total runtime in seconds (loop-level) 

942 maintain: If True, restart after completion 

943 llm: LLM evaluation configuration 

944 on_handoff: Behavior when handoff signal detected (pause/spawn/terminate) 

945 commands: Optional override for the Commands section in ll-loop show 

946 """ 

947 

948 name: str 

949 initial: str 

950 states: dict[str, StateConfig] 

951 description: str | None = None 

952 context: dict[str, Any] = field(default_factory=dict) 

953 parameters: dict[str, ParameterSpec] = field(default_factory=dict) 

954 scope: list[str] = field(default_factory=list) 

955 max_iterations: int = 50 

956 on_max_iterations: str | None = None 

957 max_edge_revisits: int = 100 

958 backoff: float | None = None 

959 timeout: int | None = None 

960 default_timeout: int | None = None 

961 maintain: bool = False 

962 llm: LLMConfig = field(default_factory=LLMConfig) 

963 on_handoff: Literal["pause", "spawn", "terminate"] = "pause" 

964 input_key: str = "input" 

965 config: LoopConfigOverrides | None = None 

966 category: str = "" 

967 labels: list[str] = field(default_factory=list) 

968 # Audience axis for `ll-loop list` filtering (orthogonal to `category`, which 

969 # is topical). "public" = user-facing (default); "internal" = delegated-only 

970 # sub-loop, never run directly; "example" = demo/template. See ENH (loop-list 

971 # visibility tiers). 

972 visibility: str = "public" 

973 required_inputs: list[str] = field(default_factory=list) 

974 commands: list[CommandEntry] = field(default_factory=list) 

975 targets: list[TargetFileSpec] = field(default_factory=list) 

976 circuit: CircuitConfig | None = None 

977 meta_self_eval_ok: bool = False 

978 shared_state_ok: bool = False 

979 partial_route_ok: bool = False 

980 artifact_versioning: bool = False 

981 artifact_versioning_ok: bool = False 

982 generator_fix_ok: bool = False 

983 # Populated from the raw `import:` list by from_dict(); not serialized by to_dict() 

984 imports: list[str] = field(default_factory=list) 

985 

986 def to_dict(self) -> dict[str, Any]: 

987 """Convert to dictionary for JSON/YAML serialization.""" 

988 result: dict[str, Any] = { 

989 "name": self.name, 

990 "initial": self.initial, 

991 "states": {name: state.to_dict() for name, state in self.states.items()}, 

992 } 

993 

994 if self.description is not None: 

995 result["description"] = self.description 

996 if self.context: 

997 result["context"] = self.context 

998 if self.parameters: 

999 result["parameters"] = {name: spec.to_dict() for name, spec in self.parameters.items()} 

1000 if self.scope: 

1001 result["scope"] = self.scope 

1002 if self.max_iterations != 50: 

1003 result["max_iterations"] = self.max_iterations 

1004 if self.max_edge_revisits != 100: 

1005 result["max_edge_revisits"] = self.max_edge_revisits 

1006 if self.backoff is not None: 

1007 result["backoff"] = self.backoff 

1008 if self.timeout is not None: 

1009 result["timeout"] = self.timeout 

1010 if self.default_timeout is not None: 

1011 result["default_timeout"] = self.default_timeout 

1012 if self.maintain: 

1013 result["maintain"] = self.maintain 

1014 if self.on_handoff != "pause": 

1015 result["on_handoff"] = self.on_handoff 

1016 if self.on_max_iterations is not None: 

1017 result["on_max_iterations"] = self.on_max_iterations 

1018 if self.input_key != "input": 

1019 result["input_key"] = self.input_key 

1020 

1021 llm_dict = self.llm.to_dict() 

1022 if llm_dict: 

1023 result["llm"] = llm_dict 

1024 

1025 if self.config is not None: 

1026 config_dict = self.config.to_dict() 

1027 if config_dict: 

1028 result["config"] = config_dict 

1029 

1030 if self.category: 

1031 result["category"] = self.category 

1032 if self.labels: 

1033 result["labels"] = self.labels 

1034 if self.visibility and self.visibility != "public": 

1035 result["visibility"] = self.visibility 

1036 if self.required_inputs: 

1037 result["required_inputs"] = self.required_inputs 

1038 if self.commands: 

1039 result["commands"] = [{"cmd": e.cmd, "comment": e.comment} for e in self.commands] 

1040 if self.targets: 

1041 result["targets"] = [t.to_dict() for t in self.targets] 

1042 

1043 if self.circuit is not None: 

1044 circuit_dict = self.circuit.to_dict() 

1045 if circuit_dict: 

1046 result["circuit"] = circuit_dict 

1047 

1048 if self.meta_self_eval_ok: 

1049 result["meta_self_eval_ok"] = self.meta_self_eval_ok 

1050 if self.shared_state_ok: 

1051 result["shared_state_ok"] = self.shared_state_ok 

1052 if self.partial_route_ok: 

1053 result["partial_route_ok"] = self.partial_route_ok 

1054 if self.artifact_versioning: 

1055 result["artifact_versioning"] = self.artifact_versioning 

1056 if self.artifact_versioning_ok: 

1057 result["artifact_versioning_ok"] = self.artifact_versioning_ok 

1058 if self.generator_fix_ok: 

1059 result["generator_fix_ok"] = self.generator_fix_ok 

1060 

1061 return result 

1062 

1063 @classmethod 

1064 def from_dict(cls, data: dict[str, Any]) -> FSMLoop: 

1065 """Create from dictionary (JSON/YAML deserialization).""" 

1066 states = { 

1067 name: StateConfig.from_dict(state_data) 

1068 for name, state_data in data.get("states", {}).items() 

1069 } 

1070 

1071 llm = LLMConfig() 

1072 if "llm" in data: 

1073 llm = LLMConfig.from_dict(data["llm"]) 

1074 

1075 loop_config = None 

1076 if "config" in data: 

1077 loop_config = LoopConfigOverrides.from_dict(data["config"]) 

1078 

1079 circuit = None 

1080 if "circuit" in data and data["circuit"] is not None: 

1081 circuit = CircuitConfig.from_dict(data["circuit"]) 

1082 

1083 parameters = { 

1084 name: ParameterSpec.from_dict(spec) for name, spec in data.get("parameters", {}).items() 

1085 } 

1086 

1087 return cls( 

1088 name=data["name"], 

1089 initial=data["initial"], 

1090 states=states, 

1091 description=data.get("description"), 

1092 context=data.get("context", {}), 

1093 parameters=parameters, 

1094 scope=data.get("scope", []), 

1095 max_iterations=data.get("max_iterations", 50), 

1096 on_max_iterations=data.get("on_max_iterations"), 

1097 max_edge_revisits=data.get("max_edge_revisits", 100), 

1098 backoff=data.get("backoff"), 

1099 timeout=data.get("timeout"), 

1100 default_timeout=data.get("default_timeout"), 

1101 maintain=data.get("maintain", False), 

1102 llm=llm, 

1103 on_handoff=data.get("on_handoff", "pause"), 

1104 input_key=data.get("input_key", "input"), 

1105 config=loop_config, 

1106 category=data.get("category", ""), 

1107 labels=data.get("labels", []), 

1108 visibility=data.get("visibility", "public"), 

1109 required_inputs=data.get("required_inputs", []), 

1110 commands=[CommandEntry(**e) for e in data.get("commands", [])], 

1111 targets=[TargetFileSpec.from_dict(t) for t in (data.get("targets") or [])], 

1112 circuit=circuit, 

1113 meta_self_eval_ok=data.get("meta_self_eval_ok", False), 

1114 shared_state_ok=data.get("shared_state_ok", False), 

1115 partial_route_ok=data.get("partial_route_ok", False), 

1116 artifact_versioning=data.get("artifact_versioning", False), 

1117 artifact_versioning_ok=data.get("artifact_versioning_ok", False), 

1118 generator_fix_ok=data.get("generator_fix_ok", False), 

1119 imports=data.get("import", []), 

1120 ) 

1121 

1122 def get_all_state_names(self) -> set[str]: 

1123 """Get all defined state names. 

1124 

1125 Returns: 

1126 Set of all state names in this FSM. 

1127 """ 

1128 return set(self.states.keys()) 

1129 

1130 def get_terminal_states(self) -> set[str]: 

1131 """Get all terminal state names. 

1132 

1133 Returns: 

1134 Set of state names where terminal=True. 

1135 """ 

1136 return {name for name, state in self.states.items() if state.terminal} 

1137 

1138 def get_all_referenced_states(self) -> set[str]: 

1139 """Get all state names referenced by transitions. 

1140 

1141 This includes the initial state and all states referenced 

1142 in routing configurations. 

1143 

1144 Returns: 

1145 Set of all referenced state names. 

1146 """ 

1147 refs: set[str] = {self.initial} 

1148 for state in self.states.values(): 

1149 refs.update(state.get_referenced_states()) 

1150 if self.on_max_iterations is not None: 

1151 refs.add(self.on_max_iterations) 

1152 return refs