Coverage for little_loops / generate_schemas.py: 0%
33 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-15 17:27 -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 "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}
528def event_type_to_filename(event_type: str) -> str:
529 """Convert event type to safe filename (replace '.' with '_')."""
530 return event_type.replace(".", "_") + ".json"
533def generate_schemas(output_dir: Path) -> list[Path]:
534 """Generate JSON Schema files for all 23 LLEvent types.
536 Args:
537 output_dir: Directory to write schema files into. Created if it doesn't exist.
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
552if __name__ == "__main__":
553 import sys
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}/")