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

437 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-22 16:19 -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 """ 

55 

56 type: Literal[ 

57 "exit_code", 

58 "output_numeric", 

59 "output_json", 

60 "output_contains", 

61 "convergence", 

62 "diff_stall", 

63 "llm_structured", 

64 "mcp_result", 

65 "harbor_scorer", 

66 ] 

67 operator: str | None = None 

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

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

70 pattern: str | None = None 

71 negate: bool = False 

72 path: str | None = None 

73 prompt: str | None = None 

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

75 min_confidence: float = 0.5 

76 uncertain_suffix: bool = False 

77 source: str | None = None 

78 previous: str | None = None 

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

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

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

82 

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

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

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

86 

87 # Only include non-None optional fields 

88 if self.operator is not None: 

89 result["operator"] = self.operator 

90 if self.target is not None: 

91 result["target"] = self.target 

92 if self.tolerance is not None: 

93 result["tolerance"] = self.tolerance 

94 if self.pattern is not None: 

95 result["pattern"] = self.pattern 

96 if self.negate: 

97 result["negate"] = self.negate 

98 if self.path is not None: 

99 result["path"] = self.path 

100 if self.prompt is not None: 

101 result["prompt"] = self.prompt 

102 if self.schema is not None: 

103 result["schema"] = self.schema 

104 if self.min_confidence != 0.5: 

105 result["min_confidence"] = self.min_confidence 

106 if self.uncertain_suffix: 

107 result["uncertain_suffix"] = self.uncertain_suffix 

108 if self.source is not None: 

109 result["source"] = self.source 

110 if self.previous is not None: 

111 result["previous"] = self.previous 

112 if self.direction != "minimize": 

113 result["direction"] = self.direction 

114 if self.scope is not None: 

115 result["scope"] = self.scope 

116 if self.max_stall != 1: 

117 result["max_stall"] = self.max_stall 

118 

119 return result 

120 

121 @classmethod 

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

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

124 return cls( 

125 type=data["type"], 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

141 ) 

142 

143 

144@dataclass 

145class RouteConfig: 

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

147 

148 Maps verdict strings from evaluators to next state names. 

149 

150 Attributes: 

151 routes: Mapping from verdict string to next state name 

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

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

154 """ 

155 

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

157 default: str | None = None 

158 error: str | None = None 

159 

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

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

162 result = dict(self.routes) 

163 if self.default is not None: 

164 result["_"] = self.default 

165 if self.error is not None: 

166 result["_error"] = self.error 

167 return result 

168 

169 @classmethod 

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

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

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

173 return cls( 

174 routes=routes, 

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

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

177 ) 

178 

179 

180@dataclass 

181class ParameterSpec: 

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

183 

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

185 

186 Attributes: 

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

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

189 exclusive with 'default'). 

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

191 Only valid when required is False. 

192 description: Human-readable description of the parameter. 

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

194 """ 

195 

196 type: str 

197 required: bool = False 

198 default: Any = None 

199 description: str | None = None 

200 values: list[Any] | None = None 

201 

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

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

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

205 if self.required: 

206 result["required"] = self.required 

207 if self.default is not None: 

208 result["default"] = self.default 

209 if self.description is not None: 

210 result["description"] = self.description 

211 if self.values is not None: 

212 result["values"] = self.values 

213 return result 

214 

215 @classmethod 

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

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

218 return cls( 

219 type=data["type"], 

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

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

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

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

224 ) 

225 

226 

227@dataclass 

228class ThrottleConfig: 

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

230 

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

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

233 

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

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

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

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

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

239 

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

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

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

243 """ 

244 

245 normal_max: int | None = None 

246 warn_max: int | None = None 

247 hard_max: int | None = None 

248 

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

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

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

252 if self.normal_max is not None: 

253 result["normal_max"] = self.normal_max 

254 if self.warn_max is not None: 

255 result["warn_max"] = self.warn_max 

256 if self.hard_max is not None: 

257 result["hard_max"] = self.hard_max 

258 return result 

259 

260 @classmethod 

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

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

263 return cls( 

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

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

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

267 ) 

268 

269 

270@dataclass 

271class LearningConfig: 

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

273 

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

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

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

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

278 

279 Attributes: 

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

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

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

283 state to advance via on_yes. 

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

285 before the state routes to on_blocked / on_no with reason 

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

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

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

289 already exempt from throttle hard_max via FSMExecutor._check_throttle. 

290 """ 

291 

292 targets: list[str] 

293 max_retries: int = 2 

294 

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

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

297 return {"targets": list(self.targets), "max_retries": self.max_retries} 

298 

299 @classmethod 

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

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

302 return cls( 

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

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

305 ) 

306 

307 

308@dataclass 

309class StateConfig: 

310 """Configuration for a single FSM state. 

311 

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

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

314 

315 Attributes: 

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

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

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

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

320 ${variable} interpolation in string values. 

321 evaluate: Evaluator configuration for result interpretation 

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

323 on_yes: Shorthand for yes verdict routing 

324 on_no: Shorthand for no verdict routing 

325 on_error: Shorthand for error verdict routing 

326 on_partial: Shorthand for partial verdict routing 

327 next: Unconditional transition (no evaluation) 

328 terminal: If True, this is an end state 

329 capture: Variable name to store action output 

330 timeout: Action-level timeout in seconds 

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

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

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

334 Requires on_retry_exhausted to also be set. 

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

336 are exceeded. Required when max_retries is set. 

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

338 state before transitioning to on_rate_limit_exhausted. Requires 

339 on_rate_limit_exhausted to also be set. 

340 on_rate_limit_exhausted: State to transition to when max_rate_limit_retries 

341 consecutive rate-limit retries are exceeded. Required when 

342 max_rate_limit_retries is set. 

343 rate_limit_backoff_base_seconds: Base seconds for exponential backoff between 

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

345 Defaults to 30. 

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

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

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

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

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

351 next retry attempt. When unset, defaults from 

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

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

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

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

356 'with_' for explicit named bindings. 

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

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

359 is set. Mutually exclusive with context_passthrough. 

360 """ 

361 

362 action: str | None = None 

363 action_type: str | None = None 

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

365 evaluate: EvaluateConfig | None = None 

366 route: RouteConfig | None = None 

367 on_yes: str | None = None 

368 on_no: str | None = None 

369 on_error: str | None = None 

370 on_partial: str | None = None 

371 on_blocked: str | None = None 

372 next: str | None = None 

373 terminal: bool = False 

374 capture: str | None = None 

375 timeout: int | None = None 

376 on_maintain: str | None = None 

377 max_retries: int | None = None 

378 on_retry_exhausted: str | None = None 

379 max_rate_limit_retries: int | None = None 

380 on_rate_limit_exhausted: str | None = None 

381 rate_limit_backoff_base_seconds: int | None = None 

382 rate_limit_max_wait_seconds: int | None = None 

383 rate_limit_long_wait_ladder: list[int] | None = None 

384 loop: str | None = None 

385 context_passthrough: bool = False 

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

387 agent: str | None = None 

388 tools: list[str] | None = None 

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

390 type: str | None = None 

391 throttle: ThrottleConfig | None = None 

392 on_throttle_hard: str | None = None 

393 learning: LearningConfig | None = None 

394 

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

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

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

398 

399 if self.action is not None: 

400 result["action"] = self.action 

401 if self.action_type is not None: 

402 result["action_type"] = self.action_type 

403 if self.params: 

404 result["params"] = self.params 

405 if self.evaluate is not None: 

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

407 if self.route is not None: 

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

409 if self.on_yes is not None: 

410 result["on_yes"] = self.on_yes 

411 if self.on_no is not None: 

412 result["on_no"] = self.on_no 

413 if self.on_error is not None: 

414 result["on_error"] = self.on_error 

415 if self.on_partial is not None: 

416 result["on_partial"] = self.on_partial 

417 if self.on_blocked is not None: 

418 result["on_blocked"] = self.on_blocked 

419 if self.next is not None: 

420 result["next"] = self.next 

421 if self.terminal: 

422 result["terminal"] = self.terminal 

423 if self.capture is not None: 

424 result["capture"] = self.capture 

425 if self.timeout is not None: 

426 result["timeout"] = self.timeout 

427 if self.on_maintain is not None: 

428 result["on_maintain"] = self.on_maintain 

429 if self.max_retries is not None: 

430 result["max_retries"] = self.max_retries 

431 if self.on_retry_exhausted is not None: 

432 result["on_retry_exhausted"] = self.on_retry_exhausted 

433 if self.max_rate_limit_retries is not None: 

434 result["max_rate_limit_retries"] = self.max_rate_limit_retries 

435 if self.on_rate_limit_exhausted is not None: 

436 result["on_rate_limit_exhausted"] = self.on_rate_limit_exhausted 

437 if self.rate_limit_backoff_base_seconds is not None: 

438 result["rate_limit_backoff_base_seconds"] = self.rate_limit_backoff_base_seconds 

439 if self.rate_limit_max_wait_seconds is not None: 

440 result["rate_limit_max_wait_seconds"] = self.rate_limit_max_wait_seconds 

441 if self.rate_limit_long_wait_ladder is not None: 

442 result["rate_limit_long_wait_ladder"] = self.rate_limit_long_wait_ladder 

443 if self.loop is not None: 

444 result["loop"] = self.loop 

445 if self.context_passthrough: 

446 result["context_passthrough"] = self.context_passthrough 

447 if self.with_: 

448 result["with"] = self.with_ 

449 if self.agent is not None: 

450 result["agent"] = self.agent 

451 if self.tools is not None: 

452 result["tools"] = self.tools 

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

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

455 if self.type is not None: 

456 result["type"] = self.type 

457 if self.throttle is not None: 

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

459 if self.on_throttle_hard is not None: 

460 result["on_throttle_hard"] = self.on_throttle_hard 

461 if self.learning is not None: 

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

463 

464 return result 

465 

466 @classmethod 

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

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

469 evaluate = None 

470 if "evaluate" in data: 

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

472 

473 route = None 

474 if "route" in data: 

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

476 

477 throttle = None 

478 if "throttle" in data: 

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

480 

481 learning = None 

482 if "learning" in data: 

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

484 

485 _known_on_keys = { 

486 "on_yes", 

487 "on_success", 

488 "on_no", 

489 "on_failure", 

490 "on_error", 

491 "on_partial", 

492 "on_blocked", 

493 "on_maintain", 

494 "on_retry_exhausted", 

495 "on_rate_limit_exhausted", 

496 "on_throttle_hard", 

497 } 

498 extra_routes = { 

499 key[3:]: val 

500 for key, val in data.items() 

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

502 } 

503 

504 return cls( 

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

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

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

508 evaluate=evaluate, 

509 route=route, 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

532 extra_routes=extra_routes, 

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

534 throttle=throttle, 

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

536 learning=learning, 

537 ) 

538 

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

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

541 

542 Returns: 

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

544 """ 

545 refs: set[str] = set() 

546 

547 if self.on_yes is not None: 

548 refs.add(self.on_yes) 

549 if self.on_no is not None: 

550 refs.add(self.on_no) 

551 if self.on_error is not None: 

552 refs.add(self.on_error) 

553 if self.on_partial is not None: 

554 refs.add(self.on_partial) 

555 if self.on_blocked is not None: 

556 refs.add(self.on_blocked) 

557 if self.next is not None: 

558 refs.add(self.next) 

559 if self.on_maintain is not None: 

560 refs.add(self.on_maintain) 

561 if self.on_retry_exhausted is not None: 

562 refs.add(self.on_retry_exhausted) 

563 if self.on_rate_limit_exhausted is not None: 

564 refs.add(self.on_rate_limit_exhausted) 

565 if self.on_throttle_hard is not None: 

566 refs.add(self.on_throttle_hard) 

567 if self.route is not None: 

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

569 if self.route.default is not None: 

570 refs.add(self.route.default) 

571 if self.route.error is not None: 

572 refs.add(self.route.error) 

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

574 

575 return refs 

576 

577 

578@dataclass 

579class LLMConfig: 

580 """LLM evaluation configuration. 

581 

582 Settings for the llm_structured evaluator. 

583 

584 Attributes: 

585 enabled: If False, disable LLM evaluation entirely 

586 model: Model identifier for LLM calls 

587 max_tokens: Maximum tokens for evaluation response 

588 timeout: Timeout for LLM calls in seconds 

589 """ 

590 

591 enabled: bool = True 

592 model: str = DEFAULT_LLM_MODEL 

593 max_tokens: int = 256 

594 timeout: int = 1800 

595 

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

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

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

599 

600 if not self.enabled: 

601 result["enabled"] = self.enabled 

602 if self.model != DEFAULT_LLM_MODEL: 

603 result["model"] = self.model 

604 if self.max_tokens != 256: 

605 result["max_tokens"] = self.max_tokens 

606 if self.timeout != 1800: 

607 result["timeout"] = self.timeout 

608 

609 return result if result else {} 

610 

611 @classmethod 

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

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

614 return cls( 

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

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

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

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

619 ) 

620 

621 

622@dataclass 

623class LoopConfigOverrides: 

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

625 

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

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

628 

629 Attributes: 

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

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

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

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

634 """ 

635 

636 handoff_threshold: int | None = None 

637 readiness_threshold: int | None = None 

638 outcome_threshold: int | None = None 

639 max_continuations: int | None = None 

640 

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

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

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

644 

645 if self.handoff_threshold is not None: 

646 result["handoff_threshold"] = self.handoff_threshold 

647 

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

649 if self.readiness_threshold is not None: 

650 confidence_gate["readiness_threshold"] = self.readiness_threshold 

651 if self.outcome_threshold is not None: 

652 confidence_gate["outcome_threshold"] = self.outcome_threshold 

653 if confidence_gate: 

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

655 

656 if self.max_continuations is not None: 

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

658 

659 return result 

660 

661 @classmethod 

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

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

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

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

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

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

668 

669 max_continuations = None 

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

671 max_continuations = automation["max_continuations"] 

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

673 max_continuations = continuation["max_continuations"] 

674 

675 return cls( 

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

677 readiness_threshold=confidence_gate.get("readiness_threshold") 

678 if isinstance(confidence_gate, dict) 

679 else None, 

680 outcome_threshold=confidence_gate.get("outcome_threshold") 

681 if isinstance(confidence_gate, dict) 

682 else None, 

683 max_continuations=max_continuations, 

684 ) 

685 

686 

687@dataclass 

688class CommandEntry: 

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

690 

691 Attributes: 

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

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

694 """ 

695 

696 cmd: str 

697 comment: str 

698 

699 

700@dataclass 

701class TargetStateSpec: 

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

703 

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

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

706 

707 Attributes: 

708 name: State name within the target loop 

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

710 eval_fragment: Eval fragment identifier used during optimization 

711 """ 

712 

713 name: str 

714 examples_file: str 

715 eval_fragment: str 

716 

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

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

719 return { 

720 "name": self.name, 

721 "examples_file": self.examples_file, 

722 "eval": self.eval_fragment, 

723 } 

724 

725 @classmethod 

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

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

728 return cls( 

729 name=data["name"], 

730 examples_file=data["examples_file"], 

731 eval_fragment=data["eval"], 

732 ) 

733 

734 

735@dataclass 

736class TargetFileSpec: 

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

738 

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

740 to optimize within that file. 

741 

742 Attributes: 

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

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

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

746 """ 

747 

748 file: str | None = None 

749 glob: str | None = None 

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

751 

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

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

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

755 if self.file is not None: 

756 result["file"] = self.file 

757 if self.glob is not None: 

758 result["glob"] = self.glob 

759 if self.states: 

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

761 return result 

762 

763 @classmethod 

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

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

766 return cls( 

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

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

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

770 ) 

771 

772 

773@dataclass 

774class FSMLoop: 

775 """Complete FSM loop definition. 

776 

777 The main dataclass representing a loop configuration. 

778 

779 Attributes: 

780 name: Unique loop identifier 

781 initial: Starting state name 

782 states: Mapping from state name to StateConfig 

783 context: User-defined shared variables 

784 scope: Paths this loop operates on (for concurrency control) 

785 max_iterations: Safety limit for loop iterations 

786 backoff: Seconds between iterations 

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

788 maintain: If True, restart after completion 

789 llm: LLM evaluation configuration 

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

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

792 """ 

793 

794 name: str 

795 initial: str 

796 states: dict[str, StateConfig] 

797 description: str | None = None 

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

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

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

801 max_iterations: int = 50 

802 max_edge_revisits: int = 100 

803 backoff: float | None = None 

804 timeout: int | None = None 

805 default_timeout: int | None = None 

806 maintain: bool = False 

807 llm: LLMConfig = field(default_factory=LLMConfig) 

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

809 input_key: str = "input" 

810 config: LoopConfigOverrides | None = None 

811 category: str = "" 

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

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

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

815 

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

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

818 result: dict[str, Any] = { 

819 "name": self.name, 

820 "initial": self.initial, 

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

822 } 

823 

824 if self.description is not None: 

825 result["description"] = self.description 

826 if self.context: 

827 result["context"] = self.context 

828 if self.parameters: 

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

830 if self.scope: 

831 result["scope"] = self.scope 

832 if self.max_iterations != 50: 

833 result["max_iterations"] = self.max_iterations 

834 if self.max_edge_revisits != 100: 

835 result["max_edge_revisits"] = self.max_edge_revisits 

836 if self.backoff is not None: 

837 result["backoff"] = self.backoff 

838 if self.timeout is not None: 

839 result["timeout"] = self.timeout 

840 if self.default_timeout is not None: 

841 result["default_timeout"] = self.default_timeout 

842 if self.maintain: 

843 result["maintain"] = self.maintain 

844 if self.on_handoff != "pause": 

845 result["on_handoff"] = self.on_handoff 

846 if self.input_key != "input": 

847 result["input_key"] = self.input_key 

848 

849 llm_dict = self.llm.to_dict() 

850 if llm_dict: 

851 result["llm"] = llm_dict 

852 

853 if self.config is not None: 

854 config_dict = self.config.to_dict() 

855 if config_dict: 

856 result["config"] = config_dict 

857 

858 if self.category: 

859 result["category"] = self.category 

860 if self.labels: 

861 result["labels"] = self.labels 

862 if self.commands: 

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

864 if self.targets: 

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

866 

867 return result 

868 

869 @classmethod 

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

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

872 states = { 

873 name: StateConfig.from_dict(state_data) 

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

875 } 

876 

877 llm = LLMConfig() 

878 if "llm" in data: 

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

880 

881 loop_config = None 

882 if "config" in data: 

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

884 

885 parameters = { 

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

887 } 

888 

889 return cls( 

890 name=data["name"], 

891 initial=data["initial"], 

892 states=states, 

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

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

895 parameters=parameters, 

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

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

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

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

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

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

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

903 llm=llm, 

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

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

906 config=loop_config, 

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

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

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

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

911 ) 

912 

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

914 """Get all defined state names. 

915 

916 Returns: 

917 Set of all state names in this FSM. 

918 """ 

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

920 

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

922 """Get all terminal state names. 

923 

924 Returns: 

925 Set of state names where terminal=True. 

926 """ 

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

928 

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

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

931 

932 This includes the initial state and all states referenced 

933 in routing configurations. 

934 

935 Returns: 

936 Set of all referenced state names. 

937 """ 

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

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

940 refs.update(state.get_referenced_states()) 

941 return refs