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
« 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.
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 # 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}
475def event_type_to_filename(event_type: str) -> str:
476 """Convert event type to safe filename (replace '.' with '_')."""
477 return event_type.replace(".", "_") + ".json"
480def generate_schemas(output_dir: Path) -> list[Path]:
481 """Generate JSON Schema files for all 23 LLEvent types.
483 Args:
484 output_dir: Directory to write schema files into. Created if it doesn't exist.
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
499if __name__ == "__main__":
500 import sys
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}/")