Coverage for little_loops / generate_schemas.py: 0%

33 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-06-16 13:08 -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 "input_tokens": _int("Input tokens consumed (prompt/slash_command only)"), 

142 "output_tokens": _int("Output tokens generated (prompt/slash_command only)"), 

143 "cache_read_tokens": _int("Cache read tokens consumed (prompt/slash_command only)"), 

144 "cache_creation_tokens": _int( 

145 "Cache creation tokens written (prompt/slash_command only)" 

146 ), 

147 "model": _str("Model ID reported by the host CLI (prompt/slash_command only)"), 

148 }, 

149 ["exit_code", "duration_ms", "is_prompt"], 

150 ), 

151 "action_error": _schema( 

152 "action_error", 

153 "Action Error", 

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

155 { 

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

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

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

159 }, 

160 ["state", "error", "route"], 

161 ), 

162 "evaluate": _schema( 

163 "evaluate", 

164 "Evaluate", 

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

166 { 

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

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

169 }, 

170 ["type", "verdict"], 

171 ), 

172 "retry_exhausted": _schema( 

173 "retry_exhausted", 

174 "Retry Exhausted", 

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

176 { 

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

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

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

180 }, 

181 ["state", "retries", "next"], 

182 ), 

183 "cycle_detected": _schema( 

184 "cycle_detected", 

185 "Cycle Detected", 

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

187 { 

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

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

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

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

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

193 }, 

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

195 ), 

196 "stall_detected": _schema( 

197 "stall_detected", 

198 "Stall Detected", 

199 '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.', 

200 { 

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

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

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

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

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

206 }, 

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

208 ), 

209 "rate_limit_exhausted": _schema( 

210 "rate_limit_exhausted", 

211 "Rate Limit Exhausted", 

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

213 { 

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

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

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

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

218 "total_wait_seconds": _number( 

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

220 ), 

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

222 }, 

223 ["state", "retries"], 

224 ), 

225 "rate_limit_storm": _schema( 

226 "rate_limit_storm", 

227 "Rate Limit Storm", 

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

229 { 

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

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

232 }, 

233 ["state", "count"], 

234 ), 

235 "rate_limit_waiting": _schema( 

236 "rate_limit_waiting", 

237 "Rate Limit Waiting", 

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

239 { 

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

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

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

243 "total_waited_seconds": _number( 

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

245 ), 

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

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

248 }, 

249 ["state", "elapsed_seconds", "next_attempt_at"], 

250 ), 

251 "throttle_warn": _schema( 

252 "throttle_warn", 

253 "Throttle Warn", 

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

255 { 

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

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

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

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

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

261 }, 

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

263 ), 

264 "throttle_hard": _schema( 

265 "throttle_hard", 

266 "Throttle Hard", 

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

268 { 

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

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

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

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

273 }, 

274 ["state", "count", "hard_max"], 

275 ), 

276 "throttle_stop": _schema( 

277 "throttle_stop", 

278 "Throttle Stop", 

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

280 { 

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

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

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

284 }, 

285 ["state", "count", "hard_max"], 

286 ), 

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

288 "learning_target_proven": _schema( 

289 "learning_target_proven", 

290 "Learning Target Proven", 

291 "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).", 

292 { 

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

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

295 }, 

296 ["state", "target"], 

297 ), 

298 "learning_target_stale": _schema( 

299 "learning_target_stale", 

300 "Learning Target Stale", 

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

302 { 

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

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

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

306 }, 

307 ["state", "target", "cause"], 

308 ), 

309 "learning_explore_invoked": _schema( 

310 "learning_explore_invoked", 

311 "Learning Explore Invoked", 

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

313 { 

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

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

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

317 }, 

318 ["state", "target", "attempt"], 

319 ), 

320 "learning_target_refuted": _schema( 

321 "learning_target_refuted", 

322 "Learning Target Refuted", 

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

324 { 

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

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

327 }, 

328 ["state", "target"], 

329 ), 

330 "learning_complete": _schema( 

331 "learning_complete", 

332 "Learning Complete", 

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

334 { 

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

336 "targets": { 

337 "type": "array", 

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

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

340 }, 

341 }, 

342 ["state", "targets"], 

343 ), 

344 "learning_blocked": _schema( 

345 "learning_blocked", 

346 "Learning Blocked", 

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

348 { 

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

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

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

352 }, 

353 ["state", "target", "reason"], 

354 ), 

355 "handoff_detected": _schema( 

356 "handoff_detected", 

357 "Handoff Detected", 

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

359 { 

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

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

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

363 }, 

364 ["state", "iteration", "continuation"], 

365 ), 

366 "handoff_spawned": _schema( 

367 "handoff_spawned", 

368 "Handoff Spawned", 

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

370 { 

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

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

373 }, 

374 ["pid", "state"], 

375 ), 

376 "loop_complete": _schema( 

377 "loop_complete", 

378 "Loop Complete", 

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

380 { 

381 "final_state": _str( 

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

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

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

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

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

387 "state_enter for final_state is always emitted before loop_complete " 

388 "(BUG-1226)." 

389 ), 

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

391 "terminated_by": _str( 

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

393 ), 

394 }, 

395 ["final_state", "iterations", "terminated_by"], 

396 ), 

397 "max_iterations_summary": _schema( 

398 "max_iterations_summary", 

399 "Max Iterations Summary", 

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

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

402 { 

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

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

405 }, 

406 ["summary_state", "iterations"], 

407 ), 

408 # FSM Persistence (1 type) 

409 "loop_resume": _schema( 

410 "loop_resume", 

411 "Loop Resume", 

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

413 { 

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

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

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

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

418 "continuation_prompt": _nullable_str( 

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

420 ), 

421 }, 

422 ["loop", "from_state", "iteration"], 

423 ), 

424 # StateManager (2 types) 

425 "state.issue_completed": _schema( 

426 "state.issue_completed", 

427 "State: Issue Completed", 

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

429 { 

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

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

432 }, 

433 ["issue_id", "status"], 

434 ), 

435 "state.issue_failed": _schema( 

436 "state.issue_failed", 

437 "State: Issue Failed", 

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

439 { 

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

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

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

443 }, 

444 ["issue_id", "reason", "status"], 

445 ), 

446 # Issue Lifecycle (6 types) 

447 "issue.failure_captured": _schema( 

448 "issue.failure_captured", 

449 "Issue: Failure Captured", 

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

451 { 

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

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

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

455 }, 

456 ["issue_id", "file_path", "parent_issue_id"], 

457 ), 

458 "issue.closed": _schema( 

459 "issue.closed", 

460 "Issue: Closed", 

461 "Emitted when an issue is closed.", 

462 { 

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

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

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

466 }, 

467 ["issue_id", "file_path", "close_reason"], 

468 ), 

469 "issue.completed": _schema( 

470 "issue.completed", 

471 "Issue: Completed", 

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

473 { 

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

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

476 }, 

477 ["issue_id", "file_path"], 

478 ), 

479 "issue.deferred": _schema( 

480 "issue.deferred", 

481 "Issue: Deferred", 

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

483 { 

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

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

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

487 }, 

488 ["issue_id", "file_path", "reason"], 

489 ), 

490 "issue.skipped": _schema( 

491 "issue.skipped", 

492 "Issue: Skipped", 

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

494 { 

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

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

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

498 }, 

499 ["issue_id", "file_path", "reason"], 

500 ), 

501 "issue.started": _schema( 

502 "issue.started", 

503 "Issue: Started", 

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

505 { 

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

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

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

509 }, 

510 ["issue_id", "file_path", "reason"], 

511 ), 

512 # Parallel Orchestrator (1 type) 

513 "parallel.worker_completed": _schema( 

514 "parallel.worker_completed", 

515 "Parallel: Worker Completed", 

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

517 { 

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

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

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

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

522 }, 

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

524 ), 

525} 

526 

527 

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

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

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

531 

532 

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

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

535 

536 Args: 

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

538 

539 Returns: 

540 List of paths to generated files. 

541 """ 

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

543 generated: list[Path] = [] 

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

545 filename = event_type_to_filename(event_type) 

546 path = output_dir / filename 

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

548 generated.append(path) 

549 return generated 

550 

551 

552if __name__ == "__main__": 

553 import sys 

554 

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

556 paths = generate_schemas(output) 

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