Coverage for little_loops / generate_schemas.py: 0%

33 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2026-05-11 00:29 -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 "handoff_detected": _schema( 

268 "handoff_detected", 

269 "Handoff Detected", 

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

271 { 

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

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

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

275 }, 

276 ["state", "iteration", "continuation"], 

277 ), 

278 "handoff_spawned": _schema( 

279 "handoff_spawned", 

280 "Handoff Spawned", 

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

282 { 

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

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

285 }, 

286 ["pid", "state"], 

287 ), 

288 "loop_complete": _schema( 

289 "loop_complete", 

290 "Loop Complete", 

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

292 { 

293 "final_state": _str( 

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

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

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

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

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

299 "state_enter for final_state is always emitted before loop_complete " 

300 "(BUG-1226)." 

301 ), 

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

303 "terminated_by": _str( 

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

305 ), 

306 }, 

307 ["final_state", "iterations", "terminated_by"], 

308 ), 

309 # FSM Persistence (1 type) 

310 "loop_resume": _schema( 

311 "loop_resume", 

312 "Loop Resume", 

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

314 { 

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

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

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

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

319 "continuation_prompt": _nullable_str( 

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

321 ), 

322 }, 

323 ["loop", "from_state", "iteration"], 

324 ), 

325 # StateManager (2 types) 

326 "state.issue_completed": _schema( 

327 "state.issue_completed", 

328 "State: Issue Completed", 

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

330 { 

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

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

333 }, 

334 ["issue_id", "status"], 

335 ), 

336 "state.issue_failed": _schema( 

337 "state.issue_failed", 

338 "State: Issue Failed", 

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

340 { 

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

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

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

344 }, 

345 ["issue_id", "reason", "status"], 

346 ), 

347 # Issue Lifecycle (4 types) 

348 "issue.failure_captured": _schema( 

349 "issue.failure_captured", 

350 "Issue: Failure Captured", 

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

352 { 

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

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

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

356 }, 

357 ["issue_id", "file_path", "parent_issue_id"], 

358 ), 

359 "issue.closed": _schema( 

360 "issue.closed", 

361 "Issue: Closed", 

362 "Emitted when an issue is closed.", 

363 { 

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

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

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

367 }, 

368 ["issue_id", "file_path", "close_reason"], 

369 ), 

370 "issue.completed": _schema( 

371 "issue.completed", 

372 "Issue: Completed", 

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

374 { 

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

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

377 }, 

378 ["issue_id", "file_path"], 

379 ), 

380 "issue.deferred": _schema( 

381 "issue.deferred", 

382 "Issue: Deferred", 

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

384 { 

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

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

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

388 }, 

389 ["issue_id", "file_path", "reason"], 

390 ), 

391 # Parallel Orchestrator (1 type) 

392 "parallel.worker_completed": _schema( 

393 "parallel.worker_completed", 

394 "Parallel: Worker Completed", 

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

396 { 

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

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

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

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

401 }, 

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

403 ), 

404} 

405 

406 

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

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

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

410 

411 

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

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

414 

415 Args: 

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

417 

418 Returns: 

419 List of paths to generated files. 

420 """ 

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

422 generated: list[Path] = [] 

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

424 filename = event_type_to_filename(event_type) 

425 path = output_dir / filename 

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

427 generated.append(path) 

428 return generated 

429 

430 

431if __name__ == "__main__": 

432 import sys 

433 

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

435 paths = generate_schemas(output) 

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