Coverage for little_loops / generate_schemas.py: 0%
33 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-05-28 13:07 -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 "stall_detected": _schema(
190 "stall_detected",
191 "Stall Detected",
192 '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.',
193 {
194 "state": _str("State name whose triple repeated"),
195 "exit_code": _int("Action exit code observed in the repeating triple"),
196 "verdict": _str("Evaluator verdict observed in the repeating triple"),
197 "consecutive": _int("Number of consecutive identical triples that fired the detector"),
198 "action": _str('Either "abort" or "route:<target_state>"'),
199 },
200 ["state", "exit_code", "verdict", "consecutive", "action"],
201 ),
202 "rate_limit_exhausted": _schema(
203 "rate_limit_exhausted",
204 "Rate Limit Exhausted",
205 "Emitted when the wall-clock rate-limit budget is spent across short + long tiers.",
206 {
207 "state": _str("State name that exhausted rate-limit retries"),
208 "retries": _int("Total rate-limit retries attempted (short + long)"),
209 "short_retries": _int("Retries attempted in the short-burst tier"),
210 "long_retries": _int("Retries attempted in the long-wait tier"),
211 "total_wait_seconds": _number(
212 "Accumulated wall-clock seconds spent in rate-limit waits"
213 ),
214 "next": _nullable_str("Next state the FSM transitions to, or null if none"),
215 },
216 ["state", "retries"],
217 ),
218 "rate_limit_storm": _schema(
219 "rate_limit_storm",
220 "Rate Limit Storm",
221 "Emitted when consecutive rate_limit_exhausted events reach the storm threshold.",
222 {
223 "state": _str("State name that triggered the storm threshold"),
224 "count": _int("Consecutive rate_limit_exhausted count at emission time"),
225 },
226 ["state", "count"],
227 ),
228 "rate_limit_waiting": _schema(
229 "rate_limit_waiting",
230 "Rate Limit Waiting",
231 "Heartbeat emitted every ~60s during a long-wait rate-limit sleep so UIs can show live progress.",
232 {
233 "state": _str("State name currently waiting on rate-limit recovery"),
234 "elapsed_seconds": _number("Wall-clock seconds elapsed in the current tier's sleep"),
235 "next_attempt_at": _number("Unix timestamp when this sleep is scheduled to end"),
236 "total_waited_seconds": _number(
237 "Accumulated wall-clock seconds across all rate-limit waits for this state"
238 ),
239 "budget_seconds": _int("Configured rate_limit_max_wait_seconds budget"),
240 "tier": _str("Wait tier identifier (currently only 'long_wait')"),
241 },
242 ["state", "elapsed_seconds", "next_attempt_at"],
243 ),
244 "throttle_warn": _schema(
245 "throttle_warn",
246 "Throttle Warn",
247 "Emitted when a state's tool-call count reaches warn_max within a single state visit.",
248 {
249 "state": _str("State name where throttle warning was triggered"),
250 "count": _int("Current tool-call count at time of emission"),
251 "normal_max": _int("Configured normal_max threshold for this state"),
252 "warn_max": _int("Configured warn_max threshold for this state"),
253 "hard_max": _int("Configured hard_max threshold for this state"),
254 },
255 ["state", "count", "warn_max", "hard_max"],
256 ),
257 "throttle_hard": _schema(
258 "throttle_hard",
259 "Throttle Hard",
260 "Emitted when a state's tool-call count reaches hard_max, triggering transition to on_throttle_hard.",
261 {
262 "state": _str("State name where hard throttle was triggered"),
263 "count": _int("Current tool-call count at time of emission"),
264 "hard_max": _int("Configured hard_max threshold for this state"),
265 "next": _str("Target state (on_throttle_hard or on_error, or null)"),
266 },
267 ["state", "count", "hard_max"],
268 ),
269 "throttle_stop": _schema(
270 "throttle_stop",
271 "Throttle Stop",
272 "Emitted when a state's tool-call count exceeds hard_max with no on_throttle_hard target, causing a hard stop.",
273 {
274 "state": _str("State name where stop throttle was triggered"),
275 "count": _int("Current tool-call count at time of emission"),
276 "hard_max": _int("Configured hard_max threshold for this state"),
277 },
278 ["state", "count", "hard_max"],
279 ),
280 # FEAT-1283: type=learning state dispatch events
281 "learning_target_proven": _schema(
282 "learning_target_proven",
283 "Learning Target Proven",
284 "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).",
285 {
286 "state": _str("State name executing the learning dispatch"),
287 "target": _str("Target identifier (e.g. 'Anthropic SDK streaming')"),
288 },
289 ["state", "target"],
290 ),
291 "learning_target_stale": _schema(
292 "learning_target_stale",
293 "Learning Target Stale",
294 "Emitted when a target's registry record is missing or has status='stale', immediately before /ll:explore-api is invoked to (re-)prove it.",
295 {
296 "state": _str("State name executing the learning dispatch"),
297 "target": _str("Target identifier"),
298 "cause": _str("Why the record was treated as stale: 'missing' or 'stale'"),
299 },
300 ["state", "target", "cause"],
301 ),
302 "learning_explore_invoked": _schema(
303 "learning_explore_invoked",
304 "Learning Explore Invoked",
305 "Emitted just before the learning state invokes /ll:explore-api for a target. Pairs with action_start/action_complete from the underlying skill invocation.",
306 {
307 "state": _str("State name executing the learning dispatch"),
308 "target": _str("Target identifier being explored"),
309 "attempt": _int("Attempt number, 1-based, capped by learning.max_retries"),
310 },
311 ["state", "target", "attempt"],
312 ),
313 "learning_target_refuted": _schema(
314 "learning_target_refuted",
315 "Learning Target Refuted",
316 "Emitted when a target's registry record has status='refuted'. Routes to on_blocked / on_no.",
317 {
318 "state": _str("State name executing the learning dispatch"),
319 "target": _str("Target identifier"),
320 },
321 ["state", "target"],
322 ),
323 "learning_complete": _schema(
324 "learning_complete",
325 "Learning Complete",
326 "Emitted when every target in a learning state has been proven. The state transitions via on_yes.",
327 {
328 "state": _str("State name executing the learning dispatch"),
329 "targets": {
330 "type": "array",
331 "description": "List of target identifiers that were all proven",
332 "items": {"type": "string"},
333 },
334 },
335 ["state", "targets"],
336 ),
337 "learning_blocked": _schema(
338 "learning_blocked",
339 "Learning Blocked",
340 "Emitted when a learning state cannot advance: a target is refuted, or /ll:explore-api retries are exhausted without proving the target.",
341 {
342 "state": _str("State name executing the learning dispatch"),
343 "target": _str("Target that blocked progress"),
344 "reason": _str("'refuted' or 'retries_exhausted'"),
345 },
346 ["state", "target", "reason"],
347 ),
348 "handoff_detected": _schema(
349 "handoff_detected",
350 "Handoff Detected",
351 "Emitted when a context-limit handoff is detected in a prompt action.",
352 {
353 "state": _str("State name where handoff was detected"),
354 "iteration": _int("Iteration count at handoff"),
355 "continuation": _str("Continuation prompt text"),
356 },
357 ["state", "iteration", "continuation"],
358 ),
359 "handoff_spawned": _schema(
360 "handoff_spawned",
361 "Handoff Spawned",
362 "Emitted when a new process is spawned to continue after a handoff.",
363 {
364 "pid": _int("Process ID of the spawned continuation process"),
365 "state": _str("State name the continuation will resume from"),
366 },
367 ["pid", "state"],
368 ),
369 "loop_complete": _schema(
370 "loop_complete",
371 "Loop Complete",
372 "Emitted when an FSM loop finishes execution.",
373 {
374 "final_state": _str(
375 "Name of the state at termination. Usually the last state entered; "
376 "when terminated_by='timeout' this may be a state that was routed to "
377 "but never entered — with one exception: if that pending state is a "
378 "shell action, the executor flushes it (emits state_enter with "
379 "flushed=true and runs its action) before honoring the timeout, so "
380 "state_enter for final_state is always emitted before loop_complete "
381 "(BUG-1226)."
382 ),
383 "iterations": _int("Total number of iterations executed"),
384 "terminated_by": _str(
385 "What caused loop termination (e.g. terminal_state, max_iterations)"
386 ),
387 },
388 ["final_state", "iterations", "terminated_by"],
389 ),
390 "max_iterations_summary": _schema(
391 "max_iterations_summary",
392 "Max Iterations Summary",
393 "Emitted when the iteration cap fires and on_max_iterations is set; "
394 "signals that a summary state will run before the loop terminates.",
395 {
396 "summary_state": _str("Name of the summary state the executor transitions to"),
397 "iterations": _int("Iteration count at which the cap fired"),
398 },
399 ["summary_state", "iterations"],
400 ),
401 # FSM Persistence (1 type)
402 "loop_resume": _schema(
403 "loop_resume",
404 "Loop Resume",
405 "Emitted when a previously interrupted loop resumes from a persisted checkpoint.",
406 {
407 "loop": _str("Loop name"),
408 "from_state": _str("State the loop resumes from"),
409 "iteration": _int("Iteration count at resume"),
410 "from_handoff": _bool("True if resuming from a context-limit handoff"),
411 "continuation_prompt": _nullable_str(
412 "Continuation prompt text (only present when from_handoff is true)"
413 ),
414 },
415 ["loop", "from_state", "iteration"],
416 ),
417 # StateManager (2 types)
418 "state.issue_completed": _schema(
419 "state.issue_completed",
420 "State: Issue Completed",
421 "Emitted by StateManager when an issue transitions to completed status.",
422 {
423 "issue_id": _str("Issue identifier"),
424 "status": {"type": "string", "enum": ["completed"], "description": "Completion status"},
425 },
426 ["issue_id", "status"],
427 ),
428 "state.issue_failed": _schema(
429 "state.issue_failed",
430 "State: Issue Failed",
431 "Emitted by StateManager when an issue transitions to failed status.",
432 {
433 "issue_id": _str("Issue identifier"),
434 "reason": _str("Failure reason description"),
435 "status": {"type": "string", "enum": ["failed"], "description": "Failure status"},
436 },
437 ["issue_id", "reason", "status"],
438 ),
439 # Issue Lifecycle (6 types)
440 "issue.failure_captured": _schema(
441 "issue.failure_captured",
442 "Issue: Failure Captured",
443 "Emitted when an issue failure is captured and persisted as a bug report.",
444 {
445 "issue_id": _str("Issue identifier"),
446 "file_path": _str("Path to the issue file"),
447 "parent_issue_id": _str("Identifier of the parent issue that failed"),
448 },
449 ["issue_id", "file_path", "parent_issue_id"],
450 ),
451 "issue.closed": _schema(
452 "issue.closed",
453 "Issue: Closed",
454 "Emitted when an issue is closed.",
455 {
456 "issue_id": _str("Issue identifier"),
457 "file_path": _str("Path to the issue file"),
458 "close_reason": _str("Reason the issue was closed"),
459 },
460 ["issue_id", "file_path", "close_reason"],
461 ),
462 "issue.completed": _schema(
463 "issue.completed",
464 "Issue: Completed",
465 "Emitted when an issue is successfully completed.",
466 {
467 "issue_id": _str("Issue identifier"),
468 "file_path": _str("Path to the completed issue file"),
469 },
470 ["issue_id", "file_path"],
471 ),
472 "issue.deferred": _schema(
473 "issue.deferred",
474 "Issue: Deferred",
475 "Emitted when an issue is deferred (parked for later).",
476 {
477 "issue_id": _str("Issue identifier"),
478 "file_path": _str("Path to the deferred issue file"),
479 "reason": _str("Reason the issue was deferred"),
480 },
481 ["issue_id", "file_path", "reason"],
482 ),
483 "issue.skipped": _schema(
484 "issue.skipped",
485 "Issue: Skipped",
486 "Emitted when an issue is skipped during automated processing.",
487 {
488 "issue_id": _str("Issue identifier"),
489 "file_path": _str("Path to the issue file"),
490 "reason": _str("Reason the issue was skipped"),
491 },
492 ["issue_id", "file_path", "reason"],
493 ),
494 "issue.started": _schema(
495 "issue.started",
496 "Issue: Started",
497 "Emitted when a deferred issue is undeferred and returned to active processing.",
498 {
499 "issue_id": _str("Issue identifier"),
500 "file_path": _str("Path to the issue file"),
501 "reason": _str("Reason the issue was restarted"),
502 },
503 ["issue_id", "file_path", "reason"],
504 ),
505 # Parallel Orchestrator (1 type)
506 "parallel.worker_completed": _schema(
507 "parallel.worker_completed",
508 "Parallel: Worker Completed",
509 "Emitted by the parallel orchestrator when a worker finishes processing an issue.",
510 {
511 "issue_id": _str("Issue identifier processed by the worker"),
512 "worker_name": _str("Worker name or identifier"),
513 "status": _str("Completion status (e.g. completed, failed, deferred)"),
514 "duration_seconds": {"type": "number", "description": "Wall-clock time in seconds"},
515 },
516 ["issue_id", "worker_name", "status", "duration_seconds"],
517 ),
518}
521def event_type_to_filename(event_type: str) -> str:
522 """Convert event type to safe filename (replace '.' with '_')."""
523 return event_type.replace(".", "_") + ".json"
526def generate_schemas(output_dir: Path) -> list[Path]:
527 """Generate JSON Schema files for all 23 LLEvent types.
529 Args:
530 output_dir: Directory to write schema files into. Created if it doesn't exist.
532 Returns:
533 List of paths to generated files.
534 """
535 output_dir.mkdir(parents=True, exist_ok=True)
536 generated: list[Path] = []
537 for event_type, schema in SCHEMA_DEFINITIONS.items():
538 filename = event_type_to_filename(event_type)
539 path = output_dir / filename
540 path.write_text(json.dumps(schema, indent=2) + "\n")
541 generated.append(path)
542 return generated
545if __name__ == "__main__":
546 import sys
548 output = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("docs/reference/schemas")
549 paths = generate_schemas(output)
550 print(f"Generated {len(paths)} schemas in {output}/")