Coverage for little_loops / generate_schemas.py: 97%

33 statements  

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

190 "rate_limit_exhausted", 

191 "Rate Limit Exhausted", 

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

193 { 

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

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

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

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

198 "total_wait_seconds": _number( 

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

200 ), 

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

202 }, 

203 ["state", "retries"], 

204 ), 

205 "rate_limit_storm": _schema( 

206 "rate_limit_storm", 

207 "Rate Limit Storm", 

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

209 { 

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

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

212 }, 

213 ["state", "count"], 

214 ), 

215 "rate_limit_waiting": _schema( 

216 "rate_limit_waiting", 

217 "Rate Limit Waiting", 

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

219 { 

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

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

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

223 "total_waited_seconds": _number( 

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

225 ), 

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

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

228 }, 

229 ["state", "elapsed_seconds", "next_attempt_at"], 

230 ), 

231 "throttle_warn": _schema( 

232 "throttle_warn", 

233 "Throttle Warn", 

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

235 { 

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

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

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

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

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

241 }, 

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

243 ), 

244 "throttle_hard": _schema( 

245 "throttle_hard", 

246 "Throttle Hard", 

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

248 { 

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

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

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

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

253 }, 

254 ["state", "count", "hard_max"], 

255 ), 

256 "throttle_stop": _schema( 

257 "throttle_stop", 

258 "Throttle Stop", 

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

260 { 

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

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

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

264 }, 

265 ["state", "count", "hard_max"], 

266 ), 

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

268 "learning_target_proven": _schema( 

269 "learning_target_proven", 

270 "Learning Target Proven", 

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

272 { 

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

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

275 }, 

276 ["state", "target"], 

277 ), 

278 "learning_target_stale": _schema( 

279 "learning_target_stale", 

280 "Learning Target Stale", 

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

282 { 

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

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

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

286 }, 

287 ["state", "target", "cause"], 

288 ), 

289 "learning_explore_invoked": _schema( 

290 "learning_explore_invoked", 

291 "Learning Explore Invoked", 

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

293 { 

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

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

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

297 }, 

298 ["state", "target", "attempt"], 

299 ), 

300 "learning_target_refuted": _schema( 

301 "learning_target_refuted", 

302 "Learning Target Refuted", 

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

304 { 

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

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

307 }, 

308 ["state", "target"], 

309 ), 

310 "learning_complete": _schema( 

311 "learning_complete", 

312 "Learning Complete", 

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

314 { 

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

316 "targets": { 

317 "type": "array", 

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

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

320 }, 

321 }, 

322 ["state", "targets"], 

323 ), 

324 "learning_blocked": _schema( 

325 "learning_blocked", 

326 "Learning Blocked", 

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

328 { 

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

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

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

332 }, 

333 ["state", "target", "reason"], 

334 ), 

335 "handoff_detected": _schema( 

336 "handoff_detected", 

337 "Handoff Detected", 

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

339 { 

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

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

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

343 }, 

344 ["state", "iteration", "continuation"], 

345 ), 

346 "handoff_spawned": _schema( 

347 "handoff_spawned", 

348 "Handoff Spawned", 

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

350 { 

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

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

353 }, 

354 ["pid", "state"], 

355 ), 

356 "loop_complete": _schema( 

357 "loop_complete", 

358 "Loop Complete", 

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

360 { 

361 "final_state": _str( 

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

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

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

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

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

367 "state_enter for final_state is always emitted before loop_complete " 

368 "(BUG-1226)." 

369 ), 

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

371 "terminated_by": _str( 

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

373 ), 

374 }, 

375 ["final_state", "iterations", "terminated_by"], 

376 ), 

377 # FSM Persistence (1 type) 

378 "loop_resume": _schema( 

379 "loop_resume", 

380 "Loop Resume", 

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

382 { 

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

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

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

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

387 "continuation_prompt": _nullable_str( 

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

389 ), 

390 }, 

391 ["loop", "from_state", "iteration"], 

392 ), 

393 # StateManager (2 types) 

394 "state.issue_completed": _schema( 

395 "state.issue_completed", 

396 "State: Issue Completed", 

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

398 { 

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

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

401 }, 

402 ["issue_id", "status"], 

403 ), 

404 "state.issue_failed": _schema( 

405 "state.issue_failed", 

406 "State: Issue Failed", 

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

408 { 

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

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

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

412 }, 

413 ["issue_id", "reason", "status"], 

414 ), 

415 # Issue Lifecycle (4 types) 

416 "issue.failure_captured": _schema( 

417 "issue.failure_captured", 

418 "Issue: Failure Captured", 

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

420 { 

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

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

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

424 }, 

425 ["issue_id", "file_path", "parent_issue_id"], 

426 ), 

427 "issue.closed": _schema( 

428 "issue.closed", 

429 "Issue: Closed", 

430 "Emitted when an issue is closed.", 

431 { 

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

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

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

435 }, 

436 ["issue_id", "file_path", "close_reason"], 

437 ), 

438 "issue.completed": _schema( 

439 "issue.completed", 

440 "Issue: Completed", 

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

442 { 

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

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

445 }, 

446 ["issue_id", "file_path"], 

447 ), 

448 "issue.deferred": _schema( 

449 "issue.deferred", 

450 "Issue: Deferred", 

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

452 { 

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

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

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

456 }, 

457 ["issue_id", "file_path", "reason"], 

458 ), 

459 # Parallel Orchestrator (1 type) 

460 "parallel.worker_completed": _schema( 

461 "parallel.worker_completed", 

462 "Parallel: Worker Completed", 

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

464 { 

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

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

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

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

469 }, 

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

471 ), 

472} 

473 

474 

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

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

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

478 

479 

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

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

482 

483 Args: 

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

485 

486 Returns: 

487 List of paths to generated files. 

488 """ 

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

490 generated: list[Path] = [] 

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

492 filename = event_type_to_filename(event_type) 

493 path = output_dir / filename 

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

495 generated.append(path) 

496 return generated 

497 

498 

499if __name__ == "__main__": 

500 import sys 

501 

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

503 paths = generate_schemas(output) 

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