Coverage for little_loops / generate_schemas.py: 0%

33 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-28 13:07 -0500

1"""JSON Schema generation for all 23 LLEvent types. 

2 

3Generates one JSON Schema (draft-07) file per event type to docs/reference/schemas/. 

4Schemas validate the flat wire format: {"event": type, "ts": timestamp, ...payload}. 

5 

6Usage: 

7 python -m little_loops.generate_schemas [--output OUTPUT_DIR] 

8 

9Or via CLI: 

10 ll-generate-schemas [--output OUTPUT_DIR] 

11""" 

12 

13from __future__ import annotations 

14 

15import json 

16from pathlib import Path 

17from typing import Any 

18 

19# --------------------------------------------------------------------------- 

20# Schema building helpers 

21# --------------------------------------------------------------------------- 

22 

23_DRAFT07 = "http://json-schema.org/draft-07/schema#" 

24 

25_BASE_PROPS: dict[str, Any] = { 

26 "event": {"type": "string", "description": "Event type identifier"}, 

27 "ts": {"type": "string", "format": "date-time", "description": "ISO 8601 timestamp"}, 

28} 

29 

30_BASE_REQUIRED = ["event", "ts"] 

31 

32 

33def _str(description: str) -> dict[str, Any]: 

34 return {"type": "string", "description": description} 

35 

36 

37def _int(description: str) -> dict[str, Any]: 

38 return {"type": "integer", "description": description} 

39 

40 

41def _number(description: str) -> dict[str, Any]: 

42 return {"type": "number", "description": description} 

43 

44 

45def _bool(description: str) -> dict[str, Any]: 

46 return {"type": "boolean", "description": description} 

47 

48 

49def _nullable_str(description: str) -> dict[str, Any]: 

50 return {"type": ["string", "null"], "description": description} 

51 

52 

53def _nullable_bool(description: str) -> dict[str, Any]: 

54 return {"type": ["boolean", "null"], "description": description} 

55 

56 

57def _schema( 

58 event_type: str, 

59 title: str, 

60 description: str, 

61 extra_props: dict[str, Any], 

62 extra_required: list[str] | None = None, 

63) -> dict[str, Any]: 

64 """Build a complete JSON Schema dict for an event type.""" 

65 return { 

66 "$schema": _DRAFT07, 

67 "$id": f"little-loops://event-{event_type}.json", 

68 "title": title, 

69 "description": description, 

70 "type": "object", 

71 "required": _BASE_REQUIRED + (extra_required or []), 

72 "properties": {**_BASE_PROPS, **extra_props}, 

73 "additionalProperties": True, 

74 } 

75 

76 

77# --------------------------------------------------------------------------- 

78# Schema definitions — all 26 LLEvent types 

79# Source of truth: docs/reference/EVENT-SCHEMA.md 

80# --------------------------------------------------------------------------- 

81 

82SCHEMA_DEFINITIONS: dict[str, dict[str, Any]] = { 

83 # FSM Executor (15 types) 

84 "loop_start": _schema( 

85 "loop_start", 

86 "Loop Start", 

87 "Emitted when an FSM loop begins execution.", 

88 {"loop": _str("Loop name")}, 

89 ["loop"], 

90 ), 

91 "state_enter": _schema( 

92 "state_enter", 

93 "State Enter", 

94 "Emitted when the FSM enters a state.", 

95 { 

96 "state": _str("State name"), 

97 "iteration": _int("Iteration count (1-based)"), 

98 }, 

99 ["state", "iteration"], 

100 ), 

101 "route": _schema( 

102 "route", 

103 "Route", 

104 "Emitted when the FSM transitions between states.", 

105 { 

106 "from": _str("Source state name"), 

107 "to": _str("Destination state name"), 

108 "reason": _str("Optional transition reason"), 

109 }, 

110 ["from", "to"], 

111 ), 

112 "action_start": _schema( 

113 "action_start", 

114 "Action Start", 

115 "Emitted when a state action begins.", 

116 { 

117 "action": _str("Action name or command"), 

118 "is_prompt": _bool("True if action is a Claude prompt, false for shell command"), 

119 }, 

120 ["action", "is_prompt"], 

121 ), 

122 "action_output": _schema( 

123 "action_output", 

124 "Action Output", 

125 "Emitted for each line of output from a running action.", 

126 {"line": _str("Output line text")}, 

127 ["line"], 

128 ), 

129 "action_complete": _schema( 

130 "action_complete", 

131 "Action Complete", 

132 "Emitted when an action finishes.", 

133 { 

134 "exit_code": _int("Process exit code (0 = success)"), 

135 "duration_ms": _int("Execution duration in milliseconds"), 

136 "output_preview": _nullable_str("Short preview of output, null if none"), 

137 "is_prompt": _bool("True if action was a Claude prompt"), 

138 "session_jsonl": _nullable_str( 

139 "Path to Claude session JSONL file (prompt-only, null for shell commands)" 

140 ), 

141 }, 

142 ["exit_code", "duration_ms", "is_prompt"], 

143 ), 

144 "action_error": _schema( 

145 "action_error", 

146 "Action Error", 

147 "Emitted when an action raises an unhandled exception that is routed to on_error.", 

148 { 

149 "state": _str("State name whose action raised"), 

150 "error": _str("String representation of the raised exception"), 

151 "route": _str("Route taken in response to the exception (always 'on_error')"), 

152 }, 

153 ["state", "error", "route"], 

154 ), 

155 "evaluate": _schema( 

156 "evaluate", 

157 "Evaluate", 

158 "Emitted when an evaluator runs against action output.", 

159 { 

160 "type": _str("Evaluator type identifier"), 

161 "verdict": _str("Evaluator verdict (e.g. pass, fail, retry)"), 

162 }, 

163 ["type", "verdict"], 

164 ), 

165 "retry_exhausted": _schema( 

166 "retry_exhausted", 

167 "Retry Exhausted", 

168 "Emitted when all retries for a state are exhausted.", 

169 { 

170 "state": _str("State name that exhausted retries"), 

171 "retries": _int("Number of retries attempted"), 

172 "next": _str("Next state the FSM transitions to"), 

173 }, 

174 ["state", "retries", "next"], 

175 ), 

176 "cycle_detected": _schema( 

177 "cycle_detected", 

178 "Cycle Detected", 

179 "Emitted when the same edge is traversed too many times, indicating a tight infinite loop.", 

180 { 

181 "edge": _str("Edge key (from_state->to_state) that triggered detection"), 

182 "from": _str("Source state of the cyclic edge"), 

183 "to": _str("Target state of the cyclic edge"), 

184 "count": _int("Number of times this edge was traversed"), 

185 "max": _int("Configured max_edge_revisits limit"), 

186 }, 

187 ["edge", "from", "to", "count", "max"], 

188 ), 

189 "stall_detected": _schema( 

190 "stall_detected", 

191 "Stall Detected", 

192 'Emitted when the FSM stall detector observes `window` consecutive iterations with an identical (state, exit_code, verdict) triple. Either terminates the run (action="abort") or routes to a recovery state (action="route:<state>"). See FEAT-1637.', 

193 { 

194 "state": _str("State name whose triple repeated"), 

195 "exit_code": _int("Action exit code observed in the repeating triple"), 

196 "verdict": _str("Evaluator verdict observed in the repeating triple"), 

197 "consecutive": _int("Number of consecutive identical triples that fired the detector"), 

198 "action": _str('Either "abort" or "route:<target_state>"'), 

199 }, 

200 ["state", "exit_code", "verdict", "consecutive", "action"], 

201 ), 

202 "rate_limit_exhausted": _schema( 

203 "rate_limit_exhausted", 

204 "Rate Limit Exhausted", 

205 "Emitted when the wall-clock rate-limit budget is spent across short + long tiers.", 

206 { 

207 "state": _str("State name that exhausted rate-limit retries"), 

208 "retries": _int("Total rate-limit retries attempted (short + long)"), 

209 "short_retries": _int("Retries attempted in the short-burst tier"), 

210 "long_retries": _int("Retries attempted in the long-wait tier"), 

211 "total_wait_seconds": _number( 

212 "Accumulated wall-clock seconds spent in rate-limit waits" 

213 ), 

214 "next": _nullable_str("Next state the FSM transitions to, or null if none"), 

215 }, 

216 ["state", "retries"], 

217 ), 

218 "rate_limit_storm": _schema( 

219 "rate_limit_storm", 

220 "Rate Limit Storm", 

221 "Emitted when consecutive rate_limit_exhausted events reach the storm threshold.", 

222 { 

223 "state": _str("State name that triggered the storm threshold"), 

224 "count": _int("Consecutive rate_limit_exhausted count at emission time"), 

225 }, 

226 ["state", "count"], 

227 ), 

228 "rate_limit_waiting": _schema( 

229 "rate_limit_waiting", 

230 "Rate Limit Waiting", 

231 "Heartbeat emitted every ~60s during a long-wait rate-limit sleep so UIs can show live progress.", 

232 { 

233 "state": _str("State name currently waiting on rate-limit recovery"), 

234 "elapsed_seconds": _number("Wall-clock seconds elapsed in the current tier's sleep"), 

235 "next_attempt_at": _number("Unix timestamp when this sleep is scheduled to end"), 

236 "total_waited_seconds": _number( 

237 "Accumulated wall-clock seconds across all rate-limit waits for this state" 

238 ), 

239 "budget_seconds": _int("Configured rate_limit_max_wait_seconds budget"), 

240 "tier": _str("Wait tier identifier (currently only 'long_wait')"), 

241 }, 

242 ["state", "elapsed_seconds", "next_attempt_at"], 

243 ), 

244 "throttle_warn": _schema( 

245 "throttle_warn", 

246 "Throttle Warn", 

247 "Emitted when a state's tool-call count reaches warn_max within a single state visit.", 

248 { 

249 "state": _str("State name where throttle warning was triggered"), 

250 "count": _int("Current tool-call count at time of emission"), 

251 "normal_max": _int("Configured normal_max threshold for this state"), 

252 "warn_max": _int("Configured warn_max threshold for this state"), 

253 "hard_max": _int("Configured hard_max threshold for this state"), 

254 }, 

255 ["state", "count", "warn_max", "hard_max"], 

256 ), 

257 "throttle_hard": _schema( 

258 "throttle_hard", 

259 "Throttle Hard", 

260 "Emitted when a state's tool-call count reaches hard_max, triggering transition to on_throttle_hard.", 

261 { 

262 "state": _str("State name where hard throttle was triggered"), 

263 "count": _int("Current tool-call count at time of emission"), 

264 "hard_max": _int("Configured hard_max threshold for this state"), 

265 "next": _str("Target state (on_throttle_hard or on_error, or null)"), 

266 }, 

267 ["state", "count", "hard_max"], 

268 ), 

269 "throttle_stop": _schema( 

270 "throttle_stop", 

271 "Throttle Stop", 

272 "Emitted when a state's tool-call count exceeds hard_max with no on_throttle_hard target, causing a hard stop.", 

273 { 

274 "state": _str("State name where stop throttle was triggered"), 

275 "count": _int("Current tool-call count at time of emission"), 

276 "hard_max": _int("Configured hard_max threshold for this state"), 

277 }, 

278 ["state", "count", "hard_max"], 

279 ), 

280 # FEAT-1283: type=learning state dispatch events 

281 "learning_target_proven": _schema( 

282 "learning_target_proven", 

283 "Learning Target Proven", 

284 "Emitted when a target's learning-tests registry record is found with status='proven'. The state will advance to the next target (or to on_yes when all targets are proven).", 

285 { 

286 "state": _str("State name executing the learning dispatch"), 

287 "target": _str("Target identifier (e.g. 'Anthropic SDK streaming')"), 

288 }, 

289 ["state", "target"], 

290 ), 

291 "learning_target_stale": _schema( 

292 "learning_target_stale", 

293 "Learning Target Stale", 

294 "Emitted when a target's registry record is missing or has status='stale', immediately before /ll:explore-api is invoked to (re-)prove it.", 

295 { 

296 "state": _str("State name executing the learning dispatch"), 

297 "target": _str("Target identifier"), 

298 "cause": _str("Why the record was treated as stale: 'missing' or 'stale'"), 

299 }, 

300 ["state", "target", "cause"], 

301 ), 

302 "learning_explore_invoked": _schema( 

303 "learning_explore_invoked", 

304 "Learning Explore Invoked", 

305 "Emitted just before the learning state invokes /ll:explore-api for a target. Pairs with action_start/action_complete from the underlying skill invocation.", 

306 { 

307 "state": _str("State name executing the learning dispatch"), 

308 "target": _str("Target identifier being explored"), 

309 "attempt": _int("Attempt number, 1-based, capped by learning.max_retries"), 

310 }, 

311 ["state", "target", "attempt"], 

312 ), 

313 "learning_target_refuted": _schema( 

314 "learning_target_refuted", 

315 "Learning Target Refuted", 

316 "Emitted when a target's registry record has status='refuted'. Routes to on_blocked / on_no.", 

317 { 

318 "state": _str("State name executing the learning dispatch"), 

319 "target": _str("Target identifier"), 

320 }, 

321 ["state", "target"], 

322 ), 

323 "learning_complete": _schema( 

324 "learning_complete", 

325 "Learning Complete", 

326 "Emitted when every target in a learning state has been proven. The state transitions via on_yes.", 

327 { 

328 "state": _str("State name executing the learning dispatch"), 

329 "targets": { 

330 "type": "array", 

331 "description": "List of target identifiers that were all proven", 

332 "items": {"type": "string"}, 

333 }, 

334 }, 

335 ["state", "targets"], 

336 ), 

337 "learning_blocked": _schema( 

338 "learning_blocked", 

339 "Learning Blocked", 

340 "Emitted when a learning state cannot advance: a target is refuted, or /ll:explore-api retries are exhausted without proving the target.", 

341 { 

342 "state": _str("State name executing the learning dispatch"), 

343 "target": _str("Target that blocked progress"), 

344 "reason": _str("'refuted' or 'retries_exhausted'"), 

345 }, 

346 ["state", "target", "reason"], 

347 ), 

348 "handoff_detected": _schema( 

349 "handoff_detected", 

350 "Handoff Detected", 

351 "Emitted when a context-limit handoff is detected in a prompt action.", 

352 { 

353 "state": _str("State name where handoff was detected"), 

354 "iteration": _int("Iteration count at handoff"), 

355 "continuation": _str("Continuation prompt text"), 

356 }, 

357 ["state", "iteration", "continuation"], 

358 ), 

359 "handoff_spawned": _schema( 

360 "handoff_spawned", 

361 "Handoff Spawned", 

362 "Emitted when a new process is spawned to continue after a handoff.", 

363 { 

364 "pid": _int("Process ID of the spawned continuation process"), 

365 "state": _str("State name the continuation will resume from"), 

366 }, 

367 ["pid", "state"], 

368 ), 

369 "loop_complete": _schema( 

370 "loop_complete", 

371 "Loop Complete", 

372 "Emitted when an FSM loop finishes execution.", 

373 { 

374 "final_state": _str( 

375 "Name of the state at termination. Usually the last state entered; " 

376 "when terminated_by='timeout' this may be a state that was routed to " 

377 "but never entered — with one exception: if that pending state is a " 

378 "shell action, the executor flushes it (emits state_enter with " 

379 "flushed=true and runs its action) before honoring the timeout, so " 

380 "state_enter for final_state is always emitted before loop_complete " 

381 "(BUG-1226)." 

382 ), 

383 "iterations": _int("Total number of iterations executed"), 

384 "terminated_by": _str( 

385 "What caused loop termination (e.g. terminal_state, max_iterations)" 

386 ), 

387 }, 

388 ["final_state", "iterations", "terminated_by"], 

389 ), 

390 "max_iterations_summary": _schema( 

391 "max_iterations_summary", 

392 "Max Iterations Summary", 

393 "Emitted when the iteration cap fires and on_max_iterations is set; " 

394 "signals that a summary state will run before the loop terminates.", 

395 { 

396 "summary_state": _str("Name of the summary state the executor transitions to"), 

397 "iterations": _int("Iteration count at which the cap fired"), 

398 }, 

399 ["summary_state", "iterations"], 

400 ), 

401 # FSM Persistence (1 type) 

402 "loop_resume": _schema( 

403 "loop_resume", 

404 "Loop Resume", 

405 "Emitted when a previously interrupted loop resumes from a persisted checkpoint.", 

406 { 

407 "loop": _str("Loop name"), 

408 "from_state": _str("State the loop resumes from"), 

409 "iteration": _int("Iteration count at resume"), 

410 "from_handoff": _bool("True if resuming from a context-limit handoff"), 

411 "continuation_prompt": _nullable_str( 

412 "Continuation prompt text (only present when from_handoff is true)" 

413 ), 

414 }, 

415 ["loop", "from_state", "iteration"], 

416 ), 

417 # StateManager (2 types) 

418 "state.issue_completed": _schema( 

419 "state.issue_completed", 

420 "State: Issue Completed", 

421 "Emitted by StateManager when an issue transitions to completed status.", 

422 { 

423 "issue_id": _str("Issue identifier"), 

424 "status": {"type": "string", "enum": ["completed"], "description": "Completion status"}, 

425 }, 

426 ["issue_id", "status"], 

427 ), 

428 "state.issue_failed": _schema( 

429 "state.issue_failed", 

430 "State: Issue Failed", 

431 "Emitted by StateManager when an issue transitions to failed status.", 

432 { 

433 "issue_id": _str("Issue identifier"), 

434 "reason": _str("Failure reason description"), 

435 "status": {"type": "string", "enum": ["failed"], "description": "Failure status"}, 

436 }, 

437 ["issue_id", "reason", "status"], 

438 ), 

439 # Issue Lifecycle (6 types) 

440 "issue.failure_captured": _schema( 

441 "issue.failure_captured", 

442 "Issue: Failure Captured", 

443 "Emitted when an issue failure is captured and persisted as a bug report.", 

444 { 

445 "issue_id": _str("Issue identifier"), 

446 "file_path": _str("Path to the issue file"), 

447 "parent_issue_id": _str("Identifier of the parent issue that failed"), 

448 }, 

449 ["issue_id", "file_path", "parent_issue_id"], 

450 ), 

451 "issue.closed": _schema( 

452 "issue.closed", 

453 "Issue: Closed", 

454 "Emitted when an issue is closed.", 

455 { 

456 "issue_id": _str("Issue identifier"), 

457 "file_path": _str("Path to the issue file"), 

458 "close_reason": _str("Reason the issue was closed"), 

459 }, 

460 ["issue_id", "file_path", "close_reason"], 

461 ), 

462 "issue.completed": _schema( 

463 "issue.completed", 

464 "Issue: Completed", 

465 "Emitted when an issue is successfully completed.", 

466 { 

467 "issue_id": _str("Issue identifier"), 

468 "file_path": _str("Path to the completed issue file"), 

469 }, 

470 ["issue_id", "file_path"], 

471 ), 

472 "issue.deferred": _schema( 

473 "issue.deferred", 

474 "Issue: Deferred", 

475 "Emitted when an issue is deferred (parked for later).", 

476 { 

477 "issue_id": _str("Issue identifier"), 

478 "file_path": _str("Path to the deferred issue file"), 

479 "reason": _str("Reason the issue was deferred"), 

480 }, 

481 ["issue_id", "file_path", "reason"], 

482 ), 

483 "issue.skipped": _schema( 

484 "issue.skipped", 

485 "Issue: Skipped", 

486 "Emitted when an issue is skipped during automated processing.", 

487 { 

488 "issue_id": _str("Issue identifier"), 

489 "file_path": _str("Path to the issue file"), 

490 "reason": _str("Reason the issue was skipped"), 

491 }, 

492 ["issue_id", "file_path", "reason"], 

493 ), 

494 "issue.started": _schema( 

495 "issue.started", 

496 "Issue: Started", 

497 "Emitted when a deferred issue is undeferred and returned to active processing.", 

498 { 

499 "issue_id": _str("Issue identifier"), 

500 "file_path": _str("Path to the issue file"), 

501 "reason": _str("Reason the issue was restarted"), 

502 }, 

503 ["issue_id", "file_path", "reason"], 

504 ), 

505 # Parallel Orchestrator (1 type) 

506 "parallel.worker_completed": _schema( 

507 "parallel.worker_completed", 

508 "Parallel: Worker Completed", 

509 "Emitted by the parallel orchestrator when a worker finishes processing an issue.", 

510 { 

511 "issue_id": _str("Issue identifier processed by the worker"), 

512 "worker_name": _str("Worker name or identifier"), 

513 "status": _str("Completion status (e.g. completed, failed, deferred)"), 

514 "duration_seconds": {"type": "number", "description": "Wall-clock time in seconds"}, 

515 }, 

516 ["issue_id", "worker_name", "status", "duration_seconds"], 

517 ), 

518} 

519 

520 

521def event_type_to_filename(event_type: str) -> str: 

522 """Convert event type to safe filename (replace '.' with '_').""" 

523 return event_type.replace(".", "_") + ".json" 

524 

525 

526def generate_schemas(output_dir: Path) -> list[Path]: 

527 """Generate JSON Schema files for all 23 LLEvent types. 

528 

529 Args: 

530 output_dir: Directory to write schema files into. Created if it doesn't exist. 

531 

532 Returns: 

533 List of paths to generated files. 

534 """ 

535 output_dir.mkdir(parents=True, exist_ok=True) 

536 generated: list[Path] = [] 

537 for event_type, schema in SCHEMA_DEFINITIONS.items(): 

538 filename = event_type_to_filename(event_type) 

539 path = output_dir / filename 

540 path.write_text(json.dumps(schema, indent=2) + "\n") 

541 generated.append(path) 

542 return generated 

543 

544 

545if __name__ == "__main__": 

546 import sys 

547 

548 output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("docs/reference/schemas") 

549 paths = generate_schemas(output) 

550 print(f"Generated {len(paths)} schemas in {output}/")