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
« 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.
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}.
6Usage:
7 python -m little_loops.generate_schemas [--output OUTPUT_DIR]
9Or via CLI:
10 ll-generate-schemas [--output OUTPUT_DIR]
11"""
13from __future__ import annotations
15import json
16from pathlib import Path
17from typing import Any
19# ---------------------------------------------------------------------------
20# Schema building helpers
21# ---------------------------------------------------------------------------
23_DRAFT07 = "http://json-schema.org/draft-07/schema#"
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}
30_BASE_REQUIRED = ["event", "ts"]
33def _str(description: str) -> dict[str, Any]:
34 return {"type": "string", "description": description}
37def _int(description: str) -> dict[str, Any]:
38 return {"type": "integer", "description": description}
41def _number(description: str) -> dict[str, Any]:
42 return {"type": "number", "description": description}
45def _bool(description: str) -> dict[str, Any]:
46 return {"type": "boolean", "description": description}
49def _nullable_str(description: str) -> dict[str, Any]:
50 return {"type": ["string", "null"], "description": description}
53def _nullable_bool(description: str) -> dict[str, Any]:
54 return {"type": ["boolean", "null"], "description": description}
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 }
77# ---------------------------------------------------------------------------
78# Schema definitions — all 26 LLEvent types
79# Source of truth: docs/reference/EVENT-SCHEMA.md
80# ---------------------------------------------------------------------------
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}
407def event_type_to_filename(event_type: str) -> str:
408 """Convert event type to safe filename (replace '.' with '_')."""
409 return event_type.replace(".", "_") + ".json"
412def generate_schemas(output_dir: Path) -> list[Path]:
413 """Generate JSON Schema files for all 23 LLEvent types.
415 Args:
416 output_dir: Directory to write schema files into. Created if it doesn't exist.
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
431if __name__ == "__main__":
432 import sys
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}/")