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

306 statements  

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

66 operator: str | None = None 

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

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

69 pattern: str | None = None 

70 negate: bool = False 

71 path: str | None = None 

72 prompt: str | None = None 

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

74 min_confidence: float = 0.5 

75 uncertain_suffix: bool = False 

76 source: str | None = None 

77 previous: str | None = None 

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

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

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

81 

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

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

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

85 

86 # Only include non-None optional fields 

87 if self.operator is not None: 

88 result["operator"] = self.operator 

89 if self.target is not None: 

90 result["target"] = self.target 

91 if self.tolerance is not None: 

92 result["tolerance"] = self.tolerance 

93 if self.pattern is not None: 

94 result["pattern"] = self.pattern 

95 if self.negate: 

96 result["negate"] = self.negate 

97 if self.path is not None: 

98 result["path"] = self.path 

99 if self.prompt is not None: 

100 result["prompt"] = self.prompt 

101 if self.schema is not None: 

102 result["schema"] = self.schema 

103 if self.min_confidence != 0.5: 

104 result["min_confidence"] = self.min_confidence 

105 if self.uncertain_suffix: 

106 result["uncertain_suffix"] = self.uncertain_suffix 

107 if self.source is not None: 

108 result["source"] = self.source 

109 if self.previous is not None: 

110 result["previous"] = self.previous 

111 if self.direction != "minimize": 

112 result["direction"] = self.direction 

113 if self.scope is not None: 

114 result["scope"] = self.scope 

115 if self.max_stall != 1: 

116 result["max_stall"] = self.max_stall 

117 

118 return result 

119 

120 @classmethod 

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

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

123 return cls( 

124 type=data["type"], 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

140 ) 

141 

142 

143@dataclass 

144class RouteConfig: 

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

146 

147 Maps verdict strings from evaluators to next state names. 

148 

149 Attributes: 

150 routes: Mapping from verdict string to next state name 

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

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

153 """ 

154 

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

156 default: str | None = None 

157 error: str | None = None 

158 

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

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

161 result = dict(self.routes) 

162 if self.default is not None: 

163 result["_"] = self.default 

164 if self.error is not None: 

165 result["_error"] = self.error 

166 return result 

167 

168 @classmethod 

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

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

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

172 return cls( 

173 routes=routes, 

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

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

176 ) 

177 

178 

179@dataclass 

180class StateConfig: 

181 """Configuration for a single FSM state. 

182 

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

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

185 

186 Attributes: 

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

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

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

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

191 ${variable} interpolation in string values. 

192 evaluate: Evaluator configuration for result interpretation 

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

194 on_yes: Shorthand for yes verdict routing 

195 on_no: Shorthand for no verdict routing 

196 on_error: Shorthand for error verdict routing 

197 on_partial: Shorthand for partial verdict routing 

198 next: Unconditional transition (no evaluation) 

199 terminal: If True, this is an end state 

200 capture: Variable name to store action output 

201 timeout: Action-level timeout in seconds 

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

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

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

205 Requires on_retry_exhausted to also be set. 

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

207 are exceeded. Required when max_retries is set. 

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

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

210 merge child captures back into parent context. 

211 """ 

212 

213 action: str | None = None 

214 action_type: str | None = None 

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

216 evaluate: EvaluateConfig | None = None 

217 route: RouteConfig | None = None 

218 on_yes: str | None = None 

219 on_no: str | None = None 

220 on_error: str | None = None 

221 on_partial: str | None = None 

222 on_blocked: str | None = None 

223 next: str | None = None 

224 terminal: bool = False 

225 capture: str | None = None 

226 timeout: int | None = None 

227 on_maintain: str | None = None 

228 max_retries: int | None = None 

229 on_retry_exhausted: str | None = None 

230 loop: str | None = None 

231 context_passthrough: bool = False 

232 agent: str | None = None 

233 tools: list[str] | None = None 

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

235 

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

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

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

239 

240 if self.action is not None: 

241 result["action"] = self.action 

242 if self.action_type is not None: 

243 result["action_type"] = self.action_type 

244 if self.params: 

245 result["params"] = self.params 

246 if self.evaluate is not None: 

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

248 if self.route is not None: 

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

250 if self.on_yes is not None: 

251 result["on_yes"] = self.on_yes 

252 if self.on_no is not None: 

253 result["on_no"] = self.on_no 

254 if self.on_error is not None: 

255 result["on_error"] = self.on_error 

256 if self.on_partial is not None: 

257 result["on_partial"] = self.on_partial 

258 if self.on_blocked is not None: 

259 result["on_blocked"] = self.on_blocked 

260 if self.next is not None: 

261 result["next"] = self.next 

262 if self.terminal: 

263 result["terminal"] = self.terminal 

264 if self.capture is not None: 

265 result["capture"] = self.capture 

266 if self.timeout is not None: 

267 result["timeout"] = self.timeout 

268 if self.on_maintain is not None: 

269 result["on_maintain"] = self.on_maintain 

270 if self.max_retries is not None: 

271 result["max_retries"] = self.max_retries 

272 if self.on_retry_exhausted is not None: 

273 result["on_retry_exhausted"] = self.on_retry_exhausted 

274 if self.loop is not None: 

275 result["loop"] = self.loop 

276 if self.context_passthrough: 

277 result["context_passthrough"] = self.context_passthrough 

278 if self.agent is not None: 

279 result["agent"] = self.agent 

280 if self.tools is not None: 

281 result["tools"] = self.tools 

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

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

284 

285 return result 

286 

287 @classmethod 

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

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

290 evaluate = None 

291 if "evaluate" in data: 

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

293 

294 route = None 

295 if "route" in data: 

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

297 

298 _known_on_keys = { 

299 "on_yes", 

300 "on_success", 

301 "on_no", 

302 "on_failure", 

303 "on_error", 

304 "on_partial", 

305 "on_blocked", 

306 "on_maintain", 

307 "on_retry_exhausted", 

308 } 

309 extra_routes = { 

310 key[3:]: val 

311 for key, val in data.items() 

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

313 } 

314 

315 return cls( 

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

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

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

319 evaluate=evaluate, 

320 route=route, 

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

337 extra_routes=extra_routes, 

338 ) 

339 

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

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

342 

343 Returns: 

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

345 """ 

346 refs: set[str] = set() 

347 

348 if self.on_yes is not None: 

349 refs.add(self.on_yes) 

350 if self.on_no is not None: 

351 refs.add(self.on_no) 

352 if self.on_error is not None: 

353 refs.add(self.on_error) 

354 if self.on_partial is not None: 

355 refs.add(self.on_partial) 

356 if self.on_blocked is not None: 

357 refs.add(self.on_blocked) 

358 if self.next is not None: 

359 refs.add(self.next) 

360 if self.on_maintain is not None: 

361 refs.add(self.on_maintain) 

362 if self.on_retry_exhausted is not None: 

363 refs.add(self.on_retry_exhausted) 

364 if self.route is not None: 

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

366 if self.route.default is not None: 

367 refs.add(self.route.default) 

368 if self.route.error is not None: 

369 refs.add(self.route.error) 

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

371 

372 return refs 

373 

374 

375@dataclass 

376class LLMConfig: 

377 """LLM evaluation configuration. 

378 

379 Settings for the llm_structured evaluator. 

380 

381 Attributes: 

382 enabled: If False, disable LLM evaluation entirely 

383 model: Model identifier for LLM calls 

384 max_tokens: Maximum tokens for evaluation response 

385 timeout: Timeout for LLM calls in seconds 

386 """ 

387 

388 enabled: bool = True 

389 model: str = DEFAULT_LLM_MODEL 

390 max_tokens: int = 256 

391 timeout: int = 1800 

392 

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

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

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

396 

397 if not self.enabled: 

398 result["enabled"] = self.enabled 

399 if self.model != DEFAULT_LLM_MODEL: 

400 result["model"] = self.model 

401 if self.max_tokens != 256: 

402 result["max_tokens"] = self.max_tokens 

403 if self.timeout != 1800: 

404 result["timeout"] = self.timeout 

405 

406 return result if result else {} 

407 

408 @classmethod 

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

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

411 return cls( 

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

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

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

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

416 ) 

417 

418 

419@dataclass 

420class LoopConfigOverrides: 

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

422 

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

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

425 

426 Attributes: 

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

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

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

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

431 """ 

432 

433 handoff_threshold: int | None = None 

434 readiness_threshold: int | None = None 

435 outcome_threshold: int | None = None 

436 max_continuations: int | None = None 

437 

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

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

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

441 

442 if self.handoff_threshold is not None: 

443 result["handoff_threshold"] = self.handoff_threshold 

444 

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

446 if self.readiness_threshold is not None: 

447 confidence_gate["readiness_threshold"] = self.readiness_threshold 

448 if self.outcome_threshold is not None: 

449 confidence_gate["outcome_threshold"] = self.outcome_threshold 

450 if confidence_gate: 

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

452 

453 if self.max_continuations is not None: 

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

455 

456 return result 

457 

458 @classmethod 

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

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

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

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

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

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

465 

466 max_continuations = None 

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

468 max_continuations = automation["max_continuations"] 

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

470 max_continuations = continuation["max_continuations"] 

471 

472 return cls( 

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

474 readiness_threshold=confidence_gate.get("readiness_threshold") 

475 if isinstance(confidence_gate, dict) 

476 else None, 

477 outcome_threshold=confidence_gate.get("outcome_threshold") 

478 if isinstance(confidence_gate, dict) 

479 else None, 

480 max_continuations=max_continuations, 

481 ) 

482 

483 

484@dataclass 

485class FSMLoop: 

486 """Complete FSM loop definition. 

487 

488 The main dataclass representing a loop configuration. 

489 

490 Attributes: 

491 name: Unique loop identifier 

492 initial: Starting state name 

493 states: Mapping from state name to StateConfig 

494 context: User-defined shared variables 

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

496 max_iterations: Safety limit for loop iterations 

497 backoff: Seconds between iterations 

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

499 maintain: If True, restart after completion 

500 llm: LLM evaluation configuration 

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

502 """ 

503 

504 name: str 

505 initial: str 

506 states: dict[str, StateConfig] 

507 description: str | None = None 

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

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

510 max_iterations: int = 50 

511 backoff: float | None = None 

512 timeout: int | None = None 

513 default_timeout: int | None = None 

514 maintain: bool = False 

515 llm: LLMConfig = field(default_factory=LLMConfig) 

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

517 input_key: str = "input" 

518 config: LoopConfigOverrides | None = None 

519 category: str = "" 

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

521 

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

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

524 result: dict[str, Any] = { 

525 "name": self.name, 

526 "initial": self.initial, 

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

528 } 

529 

530 if self.description is not None: 

531 result["description"] = self.description 

532 if self.context: 

533 result["context"] = self.context 

534 if self.scope: 

535 result["scope"] = self.scope 

536 if self.max_iterations != 50: 

537 result["max_iterations"] = self.max_iterations 

538 if self.backoff is not None: 

539 result["backoff"] = self.backoff 

540 if self.timeout is not None: 

541 result["timeout"] = self.timeout 

542 if self.default_timeout is not None: 

543 result["default_timeout"] = self.default_timeout 

544 if self.maintain: 

545 result["maintain"] = self.maintain 

546 if self.on_handoff != "pause": 

547 result["on_handoff"] = self.on_handoff 

548 if self.input_key != "input": 

549 result["input_key"] = self.input_key 

550 

551 llm_dict = self.llm.to_dict() 

552 if llm_dict: 

553 result["llm"] = llm_dict 

554 

555 if self.config is not None: 

556 config_dict = self.config.to_dict() 

557 if config_dict: 

558 result["config"] = config_dict 

559 

560 if self.category: 

561 result["category"] = self.category 

562 if self.labels: 

563 result["labels"] = self.labels 

564 

565 return result 

566 

567 @classmethod 

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

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

570 states = { 

571 name: StateConfig.from_dict(state_data) 

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

573 } 

574 

575 llm = LLMConfig() 

576 if "llm" in data: 

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

578 

579 loop_config = None 

580 if "config" in data: 

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

582 

583 return cls( 

584 name=data["name"], 

585 initial=data["initial"], 

586 states=states, 

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

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

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

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

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

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

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

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

595 llm=llm, 

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

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

598 config=loop_config, 

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

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

601 ) 

602 

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

604 """Get all defined state names. 

605 

606 Returns: 

607 Set of all state names in this FSM. 

608 """ 

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

610 

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

612 """Get all terminal state names. 

613 

614 Returns: 

615 Set of state names where terminal=True. 

616 """ 

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

618 

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

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

621 

622 This includes the initial state and all states referenced 

623 in routing configurations. 

624 

625 Returns: 

626 Set of all referenced state names. 

627 """ 

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

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

630 refs.update(state.get_referenced_states()) 

631 return refs