ouroboros-ai 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of ouroboros-ai might be problematic. Click here for more details.

Files changed (81) hide show
  1. ouroboros/__init__.py +15 -0
  2. ouroboros/__main__.py +9 -0
  3. ouroboros/bigbang/__init__.py +39 -0
  4. ouroboros/bigbang/ambiguity.py +464 -0
  5. ouroboros/bigbang/interview.py +530 -0
  6. ouroboros/bigbang/seed_generator.py +610 -0
  7. ouroboros/cli/__init__.py +9 -0
  8. ouroboros/cli/commands/__init__.py +7 -0
  9. ouroboros/cli/commands/config.py +79 -0
  10. ouroboros/cli/commands/init.py +425 -0
  11. ouroboros/cli/commands/run.py +201 -0
  12. ouroboros/cli/commands/status.py +85 -0
  13. ouroboros/cli/formatters/__init__.py +31 -0
  14. ouroboros/cli/formatters/panels.py +157 -0
  15. ouroboros/cli/formatters/progress.py +112 -0
  16. ouroboros/cli/formatters/tables.py +166 -0
  17. ouroboros/cli/main.py +60 -0
  18. ouroboros/config/__init__.py +81 -0
  19. ouroboros/config/loader.py +292 -0
  20. ouroboros/config/models.py +332 -0
  21. ouroboros/core/__init__.py +62 -0
  22. ouroboros/core/ac_tree.py +401 -0
  23. ouroboros/core/context.py +472 -0
  24. ouroboros/core/errors.py +246 -0
  25. ouroboros/core/seed.py +212 -0
  26. ouroboros/core/types.py +205 -0
  27. ouroboros/evaluation/__init__.py +110 -0
  28. ouroboros/evaluation/consensus.py +350 -0
  29. ouroboros/evaluation/mechanical.py +351 -0
  30. ouroboros/evaluation/models.py +235 -0
  31. ouroboros/evaluation/pipeline.py +286 -0
  32. ouroboros/evaluation/semantic.py +302 -0
  33. ouroboros/evaluation/trigger.py +278 -0
  34. ouroboros/events/__init__.py +5 -0
  35. ouroboros/events/base.py +80 -0
  36. ouroboros/events/decomposition.py +153 -0
  37. ouroboros/events/evaluation.py +248 -0
  38. ouroboros/execution/__init__.py +44 -0
  39. ouroboros/execution/atomicity.py +451 -0
  40. ouroboros/execution/decomposition.py +481 -0
  41. ouroboros/execution/double_diamond.py +1386 -0
  42. ouroboros/execution/subagent.py +275 -0
  43. ouroboros/observability/__init__.py +63 -0
  44. ouroboros/observability/drift.py +383 -0
  45. ouroboros/observability/logging.py +504 -0
  46. ouroboros/observability/retrospective.py +338 -0
  47. ouroboros/orchestrator/__init__.py +78 -0
  48. ouroboros/orchestrator/adapter.py +391 -0
  49. ouroboros/orchestrator/events.py +278 -0
  50. ouroboros/orchestrator/runner.py +597 -0
  51. ouroboros/orchestrator/session.py +486 -0
  52. ouroboros/persistence/__init__.py +23 -0
  53. ouroboros/persistence/checkpoint.py +511 -0
  54. ouroboros/persistence/event_store.py +183 -0
  55. ouroboros/persistence/migrations/__init__.py +1 -0
  56. ouroboros/persistence/migrations/runner.py +100 -0
  57. ouroboros/persistence/migrations/scripts/001_initial.sql +20 -0
  58. ouroboros/persistence/schema.py +56 -0
  59. ouroboros/persistence/uow.py +230 -0
  60. ouroboros/providers/__init__.py +28 -0
  61. ouroboros/providers/base.py +133 -0
  62. ouroboros/providers/claude_code_adapter.py +212 -0
  63. ouroboros/providers/litellm_adapter.py +316 -0
  64. ouroboros/py.typed +0 -0
  65. ouroboros/resilience/__init__.py +67 -0
  66. ouroboros/resilience/lateral.py +595 -0
  67. ouroboros/resilience/stagnation.py +727 -0
  68. ouroboros/routing/__init__.py +60 -0
  69. ouroboros/routing/complexity.py +272 -0
  70. ouroboros/routing/downgrade.py +664 -0
  71. ouroboros/routing/escalation.py +340 -0
  72. ouroboros/routing/router.py +204 -0
  73. ouroboros/routing/tiers.py +247 -0
  74. ouroboros/secondary/__init__.py +40 -0
  75. ouroboros/secondary/scheduler.py +467 -0
  76. ouroboros/secondary/todo_registry.py +483 -0
  77. ouroboros_ai-0.1.0.dist-info/METADATA +607 -0
  78. ouroboros_ai-0.1.0.dist-info/RECORD +81 -0
  79. ouroboros_ai-0.1.0.dist-info/WHEEL +4 -0
  80. ouroboros_ai-0.1.0.dist-info/entry_points.txt +2 -0
  81. ouroboros_ai-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,467 @@
1
+ """Secondary Loop Scheduler for batch TODO processing.
2
+
3
+ This module implements Story 7-2: Secondary Loop Batch Processing - automatic
4
+ TODO processing after primary goal achievement.
5
+
6
+ Design Principles:
7
+ - Activate only after primary goal completion
8
+ - Process TODOs in priority order
9
+ - Non-blocking failures: one failed TODO doesn't stop others
10
+ - Batch summary with success/failure counts
11
+ - Optional: user can skip via --skip-secondary flag
12
+
13
+ Usage:
14
+ from ouroboros.secondary import SecondaryLoopScheduler, TodoRegistry
15
+ from ouroboros.persistence import EventStore
16
+
17
+ store = EventStore("sqlite+aiosqlite:///events.db")
18
+ registry = TodoRegistry(store)
19
+ scheduler = SecondaryLoopScheduler(registry)
20
+
21
+ # Check if secondary loop should run
22
+ if scheduler.should_activate(primary_completed=True, skip_flag=False):
23
+ result = await scheduler.process_batch()
24
+ if result.is_ok:
25
+ summary = result.value
26
+ print(f"Processed: {summary.total}, Success: {summary.success_count}")
27
+ """
28
+
29
+ from dataclasses import dataclass, field
30
+ from datetime import UTC, datetime
31
+ from enum import StrEnum
32
+ from typing import Callable, Awaitable
33
+
34
+ from ouroboros.core.errors import OuroborosError
35
+ from ouroboros.core.types import Result
36
+ from ouroboros.observability.logging import get_logger
37
+ from ouroboros.secondary.todo_registry import (
38
+ Priority,
39
+ Todo,
40
+ TodoRegistry,
41
+ TodoStatus,
42
+ )
43
+
44
+ log = get_logger(__name__)
45
+
46
+
47
+ class BatchStatus(StrEnum):
48
+ """Status of the batch processing run.
49
+
50
+ Attributes:
51
+ COMPLETED: All TODOs processed (some may have failed)
52
+ PARTIAL: Processing stopped early (e.g., timeout)
53
+ SKIPPED: User chose to skip secondary loop
54
+ NO_TODOS: No pending TODOs to process
55
+ """
56
+
57
+ COMPLETED = "completed"
58
+ PARTIAL = "partial"
59
+ SKIPPED = "skipped"
60
+ NO_TODOS = "no_todos"
61
+
62
+
63
+ @dataclass(frozen=True, slots=True)
64
+ class TodoResult:
65
+ """Result of processing a single TODO.
66
+
67
+ Attributes:
68
+ todo_id: ID of the processed TODO
69
+ description: TODO description for reporting
70
+ success: Whether processing succeeded
71
+ error_message: Error details if failed
72
+ duration_ms: Processing duration in milliseconds
73
+ """
74
+
75
+ todo_id: str
76
+ description: str
77
+ success: bool
78
+ error_message: str | None = None
79
+ duration_ms: int = 0
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class BatchSummary:
84
+ """Summary of a batch processing run.
85
+
86
+ Attributes:
87
+ status: Overall batch status
88
+ total: Total TODOs processed
89
+ success_count: Number of successful TODOs
90
+ failure_count: Number of failed TODOs
91
+ skipped_count: Number of skipped TODOs
92
+ results: Individual TODO results
93
+ started_at: When batch processing started
94
+ completed_at: When batch processing completed
95
+ """
96
+
97
+ status: BatchStatus
98
+ total: int
99
+ success_count: int
100
+ failure_count: int
101
+ skipped_count: int
102
+ results: tuple[TodoResult, ...]
103
+ started_at: datetime
104
+ completed_at: datetime
105
+
106
+ @property
107
+ def duration_ms(self) -> int:
108
+ """Total batch duration in milliseconds."""
109
+ delta = self.completed_at - self.started_at
110
+ return int(delta.total_seconds() * 1000)
111
+
112
+ @property
113
+ def success_rate(self) -> float:
114
+ """Ratio of successful TODOs (0.0-1.0)."""
115
+ if self.total == 0:
116
+ return 1.0
117
+ return self.success_count / self.total
118
+
119
+ @property
120
+ def failed_todos(self) -> tuple[TodoResult, ...]:
121
+ """Return only failed TODO results."""
122
+ return tuple(r for r in self.results if not r.success)
123
+
124
+ def to_dict(self) -> dict:
125
+ """Convert to dictionary for logging/reporting."""
126
+ return {
127
+ "status": self.status.value,
128
+ "total": self.total,
129
+ "success_count": self.success_count,
130
+ "failure_count": self.failure_count,
131
+ "skipped_count": self.skipped_count,
132
+ "duration_ms": self.duration_ms,
133
+ "success_rate": f"{self.success_rate:.1%}",
134
+ }
135
+
136
+
137
+ # Type alias for TODO executor function
138
+ TodoExecutor = Callable[[Todo], Awaitable[Result[None, OuroborosError]]]
139
+
140
+
141
+ async def _default_executor(todo: Todo) -> Result[None, OuroborosError]:
142
+ """Default no-op executor for testing.
143
+
144
+ In production, this would route the TODO through the execution pipeline.
145
+
146
+ Args:
147
+ todo: The TODO to execute
148
+
149
+ Returns:
150
+ Result indicating success (always Ok for default)
151
+ """
152
+ log.info(
153
+ "todo.executed.noop",
154
+ todo_id=todo.id,
155
+ description=todo.description,
156
+ )
157
+ return Result.ok(None)
158
+
159
+
160
+ @dataclass
161
+ class SecondaryLoopScheduler:
162
+ """Scheduler for processing TODOs after primary goal completion.
163
+
164
+ Orchestrates batch processing of TODOs with priority ordering,
165
+ resilient error handling, and comprehensive reporting.
166
+
167
+ Attributes:
168
+ _registry: TodoRegistry for TODO access
169
+ _executor: Function to execute individual TODOs
170
+ _max_todos_per_batch: Maximum TODOs to process in one batch
171
+ """
172
+
173
+ _registry: TodoRegistry
174
+ _executor: TodoExecutor = field(default=_default_executor)
175
+ _max_todos_per_batch: int = 50
176
+
177
+ def should_activate(
178
+ self,
179
+ primary_completed: bool,
180
+ skip_flag: bool = False,
181
+ ) -> bool:
182
+ """Determine if secondary loop should activate.
183
+
184
+ Args:
185
+ primary_completed: Whether primary goal was achieved
186
+ skip_flag: User's --skip-secondary flag
187
+
188
+ Returns:
189
+ True if secondary loop should run
190
+ """
191
+ if skip_flag:
192
+ log.info("secondary_loop.skipped.user_flag")
193
+ return False
194
+
195
+ if not primary_completed:
196
+ log.info("secondary_loop.skipped.primary_incomplete")
197
+ return False
198
+
199
+ return True
200
+
201
+ async def process_batch(
202
+ self,
203
+ limit: int | None = None,
204
+ ) -> Result[BatchSummary, OuroborosError]:
205
+ """Process pending TODOs in a batch.
206
+
207
+ TODOs are processed in priority order. Failed TODOs are marked
208
+ as FAILED but don't block other TODOs from processing.
209
+
210
+ Args:
211
+ limit: Maximum TODOs to process (defaults to _max_todos_per_batch)
212
+
213
+ Returns:
214
+ Result containing BatchSummary or OuroborosError
215
+ """
216
+ started_at = datetime.now(UTC)
217
+ batch_limit = limit or self._max_todos_per_batch
218
+
219
+ log.info(
220
+ "secondary_loop.batch.started",
221
+ max_todos=batch_limit,
222
+ )
223
+
224
+ # Get pending TODOs
225
+ pending_result = await self._registry.get_pending(limit=batch_limit)
226
+ if pending_result.is_err:
227
+ return Result.err(pending_result.error)
228
+
229
+ todos = pending_result.value
230
+
231
+ if not todos:
232
+ log.info("secondary_loop.batch.no_todos")
233
+ return Result.ok(
234
+ BatchSummary(
235
+ status=BatchStatus.NO_TODOS,
236
+ total=0,
237
+ success_count=0,
238
+ failure_count=0,
239
+ skipped_count=0,
240
+ results=(),
241
+ started_at=started_at,
242
+ completed_at=datetime.now(UTC),
243
+ )
244
+ )
245
+
246
+ # Process each TODO
247
+ results: list[TodoResult] = []
248
+ success_count = 0
249
+ failure_count = 0
250
+
251
+ for todo in todos:
252
+ result = await self._process_single_todo(todo)
253
+ results.append(result)
254
+
255
+ if result.success:
256
+ success_count += 1
257
+ else:
258
+ failure_count += 1
259
+
260
+ completed_at = datetime.now(UTC)
261
+
262
+ summary = BatchSummary(
263
+ status=BatchStatus.COMPLETED,
264
+ total=len(todos),
265
+ success_count=success_count,
266
+ failure_count=failure_count,
267
+ skipped_count=0,
268
+ results=tuple(results),
269
+ started_at=started_at,
270
+ completed_at=completed_at,
271
+ )
272
+
273
+ log.info(
274
+ "secondary_loop.batch.completed",
275
+ **summary.to_dict(),
276
+ )
277
+
278
+ return Result.ok(summary)
279
+
280
+ async def _process_single_todo(self, todo: Todo) -> TodoResult:
281
+ """Process a single TODO with error handling.
282
+
283
+ Args:
284
+ todo: The TODO to process
285
+
286
+ Returns:
287
+ TodoResult with success/failure status
288
+ """
289
+ start_time = datetime.now(UTC)
290
+
291
+ # Mark as in progress
292
+ await self._registry.update_status(todo.id, TodoStatus.IN_PROGRESS)
293
+
294
+ log.info(
295
+ "todo.processing.started",
296
+ todo_id=todo.id,
297
+ priority=todo.priority.value,
298
+ description=todo.description[:50],
299
+ )
300
+
301
+ try:
302
+ # Execute the TODO
303
+ exec_result = await self._executor(todo)
304
+
305
+ end_time = datetime.now(UTC)
306
+ duration_ms = int((end_time - start_time).total_seconds() * 1000)
307
+
308
+ if exec_result.is_ok:
309
+ # Mark as done
310
+ await self._registry.update_status(todo.id, TodoStatus.DONE)
311
+
312
+ log.info(
313
+ "todo.processing.completed",
314
+ todo_id=todo.id,
315
+ duration_ms=duration_ms,
316
+ )
317
+
318
+ return TodoResult(
319
+ todo_id=todo.id,
320
+ description=todo.description,
321
+ success=True,
322
+ duration_ms=duration_ms,
323
+ )
324
+ else:
325
+ # Mark as failed with error
326
+ error_msg = str(exec_result.error)
327
+ await self._registry.update_status(
328
+ todo.id,
329
+ TodoStatus.FAILED,
330
+ error_msg,
331
+ )
332
+
333
+ log.warning(
334
+ "todo.processing.failed",
335
+ todo_id=todo.id,
336
+ error=error_msg,
337
+ duration_ms=duration_ms,
338
+ )
339
+
340
+ return TodoResult(
341
+ todo_id=todo.id,
342
+ description=todo.description,
343
+ success=False,
344
+ error_message=error_msg,
345
+ duration_ms=duration_ms,
346
+ )
347
+
348
+ except Exception as e:
349
+ # Catch unexpected exceptions
350
+ end_time = datetime.now(UTC)
351
+ duration_ms = int((end_time - start_time).total_seconds() * 1000)
352
+ error_msg = f"Unexpected error: {e}"
353
+
354
+ await self._registry.update_status(
355
+ todo.id,
356
+ TodoStatus.FAILED,
357
+ error_msg,
358
+ )
359
+
360
+ log.error(
361
+ "todo.processing.exception",
362
+ todo_id=todo.id,
363
+ error=str(e),
364
+ duration_ms=duration_ms,
365
+ )
366
+
367
+ return TodoResult(
368
+ todo_id=todo.id,
369
+ description=todo.description,
370
+ success=False,
371
+ error_message=error_msg,
372
+ duration_ms=duration_ms,
373
+ )
374
+
375
+ async def skip_all_pending(
376
+ self,
377
+ reason: str = "User requested skip",
378
+ ) -> Result[BatchSummary, OuroborosError]:
379
+ """Skip all pending TODOs.
380
+
381
+ Used when user explicitly chooses to skip secondary loop
382
+ or when deferring to next session.
383
+
384
+ Args:
385
+ reason: Reason for skipping
386
+
387
+ Returns:
388
+ Result containing BatchSummary or OuroborosError
389
+ """
390
+ started_at = datetime.now(UTC)
391
+
392
+ log.info(
393
+ "secondary_loop.skip_all.started",
394
+ reason=reason,
395
+ )
396
+
397
+ pending_result = await self._registry.get_pending()
398
+ if pending_result.is_err:
399
+ return Result.err(pending_result.error)
400
+
401
+ todos = pending_result.value
402
+ results: list[TodoResult] = []
403
+
404
+ for todo in todos:
405
+ await self._registry.update_status(
406
+ todo.id,
407
+ TodoStatus.SKIPPED,
408
+ reason,
409
+ )
410
+ results.append(
411
+ TodoResult(
412
+ todo_id=todo.id,
413
+ description=todo.description,
414
+ success=True, # Skipping is not a failure
415
+ )
416
+ )
417
+
418
+ completed_at = datetime.now(UTC)
419
+
420
+ summary = BatchSummary(
421
+ status=BatchStatus.SKIPPED,
422
+ total=len(todos),
423
+ success_count=0,
424
+ failure_count=0,
425
+ skipped_count=len(todos),
426
+ results=tuple(results),
427
+ started_at=started_at,
428
+ completed_at=completed_at,
429
+ )
430
+
431
+ log.info(
432
+ "secondary_loop.skip_all.completed",
433
+ skipped_count=len(todos),
434
+ )
435
+
436
+ return Result.ok(summary)
437
+
438
+ async def get_status_report(self) -> Result[dict, OuroborosError]:
439
+ """Get a status report of TODO processing.
440
+
441
+ Returns:
442
+ Result containing status dict with counts and pending items
443
+ """
444
+ stats_result = await self._registry.get_stats()
445
+ if stats_result.is_err:
446
+ return Result.err(stats_result.error)
447
+
448
+ stats = stats_result.value
449
+
450
+ pending_result = await self._registry.get_pending(limit=10)
451
+ if pending_result.is_err:
452
+ return Result.err(pending_result.error)
453
+
454
+ pending = pending_result.value
455
+
456
+ return Result.ok({
457
+ "stats": stats,
458
+ "pending_count": stats.get("pending", 0),
459
+ "next_pending": [
460
+ {
461
+ "id": t.id,
462
+ "description": t.description[:50],
463
+ "priority": t.priority.value,
464
+ }
465
+ for t in pending[:5]
466
+ ],
467
+ })