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,472 @@
1
+ """Context management and compression for Ouroboros.
2
+
3
+ This module provides context size monitoring, compression, and preservation
4
+ of critical information during long-running workflows.
5
+
6
+ Key features:
7
+ - Token counting using LiteLLM
8
+ - Automatic compression when context exceeds limits (100K tokens or 6 hours)
9
+ - Preservation of critical info (seed, current AC, recent history, key facts)
10
+ - Fallback to aggressive truncation on compression failures
11
+ - Comprehensive observability with before/after metrics
12
+ """
13
+
14
+ from dataclasses import dataclass, field
15
+ from datetime import UTC, datetime
16
+ from typing import Any
17
+
18
+ import litellm
19
+ import structlog
20
+
21
+ from ouroboros.core.errors import ProviderError
22
+ from ouroboros.core.types import Result
23
+ from ouroboros.providers.base import CompletionConfig, Message, MessageRole
24
+ from ouroboros.providers.litellm_adapter import LiteLLMAdapter
25
+
26
+ log = structlog.get_logger()
27
+
28
+ # Context compression thresholds
29
+ MAX_TOKENS = 100_000 # NFR7: Max 100,000 tokens
30
+ MAX_AGE_HOURS = 6 # Trigger compression after 6 hours
31
+ RECENT_HISTORY_COUNT = 3 # Preserve last 3 iterations
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ContextMetrics:
36
+ """Metrics about context size and age.
37
+
38
+ Attributes:
39
+ token_count: Current token count.
40
+ age_hours: Age of context in hours.
41
+ created_at: When the context was created.
42
+ needs_compression: Whether compression is needed.
43
+ """
44
+
45
+ token_count: int
46
+ age_hours: float
47
+ created_at: datetime
48
+ needs_compression: bool
49
+
50
+
51
+ @dataclass(frozen=True, slots=True)
52
+ class CompressionResult:
53
+ """Result of a compression operation.
54
+
55
+ Attributes:
56
+ compressed_context: The compressed context data.
57
+ before_tokens: Token count before compression.
58
+ after_tokens: Token count after compression.
59
+ compression_ratio: Ratio of compression (after/before).
60
+ method: Compression method used ('llm' or 'truncate').
61
+ """
62
+
63
+ compressed_context: dict[str, Any]
64
+ before_tokens: int
65
+ after_tokens: int
66
+ compression_ratio: float
67
+ method: str
68
+
69
+
70
+ @dataclass(slots=True)
71
+ class WorkflowContext:
72
+ """Context for a running workflow.
73
+
74
+ Attributes:
75
+ seed_summary: Initial seed/goal for the workflow.
76
+ current_ac: Current acceptance criteria being worked on.
77
+ history: Historical iterations/events.
78
+ key_facts: Important facts extracted from history.
79
+ created_at: When this context was created.
80
+ metadata: Additional metadata.
81
+ """
82
+
83
+ seed_summary: str
84
+ current_ac: str
85
+ history: list[dict[str, Any]] = field(default_factory=list)
86
+ key_facts: list[str] = field(default_factory=list)
87
+ created_at: datetime = field(default_factory=lambda: datetime.now(UTC))
88
+ metadata: dict[str, Any] = field(default_factory=dict)
89
+
90
+ def to_dict(self) -> dict[str, Any]:
91
+ """Convert context to dictionary format.
92
+
93
+ Returns:
94
+ Dictionary representation of the context.
95
+ """
96
+ return {
97
+ "seed_summary": self.seed_summary,
98
+ "current_ac": self.current_ac,
99
+ "history": self.history,
100
+ "key_facts": self.key_facts,
101
+ "created_at": self.created_at.isoformat(),
102
+ "metadata": self.metadata,
103
+ }
104
+
105
+ @classmethod
106
+ def from_dict(cls, data: dict[str, Any]) -> WorkflowContext:
107
+ """Create context from dictionary.
108
+
109
+ Args:
110
+ data: Dictionary representation of context.
111
+
112
+ Returns:
113
+ WorkflowContext instance.
114
+ """
115
+ created_at_str = data.get("created_at")
116
+ created_at = (
117
+ datetime.fromisoformat(created_at_str)
118
+ if created_at_str
119
+ else datetime.now(UTC)
120
+ )
121
+
122
+ return cls(
123
+ seed_summary=data.get("seed_summary", ""),
124
+ current_ac=data.get("current_ac", ""),
125
+ history=data.get("history", []),
126
+ key_facts=data.get("key_facts", []),
127
+ created_at=created_at,
128
+ metadata=data.get("metadata", {}),
129
+ )
130
+
131
+
132
+ @dataclass(frozen=True, slots=True)
133
+ class FilteredContext:
134
+ """Filtered context for SubAgent isolation.
135
+
136
+ This contains only the information a SubAgent needs, isolating it
137
+ from the full workflow context. The frozen dataclass ensures immutability,
138
+ preventing SubAgent actions from modifying the main context (AC 3).
139
+
140
+ Attributes:
141
+ current_ac: The acceptance criteria for this SubAgent.
142
+ relevant_facts: Facts relevant to this SubAgent's task (key_facts).
143
+ parent_summary: Summary of parent context (seed_summary).
144
+ recent_history: Recent history items (last RECENT_HISTORY_COUNT).
145
+ """
146
+
147
+ current_ac: str
148
+ relevant_facts: list[str]
149
+ parent_summary: str = ""
150
+ recent_history: list[dict[str, Any]] = field(default_factory=list)
151
+
152
+
153
+ def count_tokens(text: str, model: str = "gpt-4") -> int:
154
+ """Count tokens in text using LiteLLM's token counter.
155
+
156
+ Args:
157
+ text: The text to count tokens for.
158
+ model: The model to use for tokenization. Default 'gpt-4'.
159
+
160
+ Returns:
161
+ The number of tokens in the text.
162
+ """
163
+ try:
164
+ return litellm.token_counter(model=model, text=text)
165
+ except Exception as e:
166
+ # Fallback to rough estimation if token counting fails
167
+ log.warning(
168
+ "context.token_count.failed",
169
+ error=str(e),
170
+ using_fallback=True,
171
+ )
172
+ # Rough estimate: ~4 characters per token
173
+ return len(text) // 4
174
+
175
+
176
+ def count_context_tokens(context: WorkflowContext, model: str = "gpt-4") -> int:
177
+ """Count total tokens in a workflow context.
178
+
179
+ Args:
180
+ context: The workflow context to measure.
181
+ model: The model to use for tokenization. Default 'gpt-4'.
182
+
183
+ Returns:
184
+ Total token count for the context.
185
+ """
186
+ # Convert context to a string representation
187
+ context_str = f"""
188
+ Seed: {context.seed_summary}
189
+
190
+ Current AC: {context.current_ac}
191
+
192
+ Key Facts:
193
+ {chr(10).join(f"- {fact}" for fact in context.key_facts)}
194
+
195
+ History ({len(context.history)} items):
196
+ {chr(10).join(str(item) for item in context.history)}
197
+ """
198
+ return count_tokens(context_str, model)
199
+
200
+
201
+ def get_context_metrics(context: WorkflowContext, model: str = "gpt-4") -> ContextMetrics:
202
+ """Get metrics about a workflow context.
203
+
204
+ Args:
205
+ context: The workflow context to analyze.
206
+ model: The model to use for tokenization. Default 'gpt-4'.
207
+
208
+ Returns:
209
+ ContextMetrics with size and age information.
210
+ """
211
+ token_count = count_context_tokens(context, model)
212
+ age = datetime.now(UTC) - context.created_at
213
+ age_hours = age.total_seconds() / 3600
214
+
215
+ needs_compression = token_count > MAX_TOKENS or age_hours > MAX_AGE_HOURS
216
+
217
+ return ContextMetrics(
218
+ token_count=token_count,
219
+ age_hours=age_hours,
220
+ created_at=context.created_at,
221
+ needs_compression=needs_compression,
222
+ )
223
+
224
+
225
+ async def compress_context_with_llm(
226
+ context: WorkflowContext,
227
+ llm_adapter: LiteLLMAdapter,
228
+ model: str = "gpt-4",
229
+ ) -> Result[str, ProviderError]:
230
+ """Compress context using LLM summarization.
231
+
232
+ Args:
233
+ context: The workflow context to compress.
234
+ llm_adapter: LLM adapter for making completion requests.
235
+ model: The model to use for summarization.
236
+
237
+ Returns:
238
+ Result containing the compressed summary or a ProviderError.
239
+ """
240
+ # Build summarization prompt
241
+ # Exclude recent history items from summarization
242
+ items_to_summarize = (
243
+ context.history[:-RECENT_HISTORY_COUNT] if len(context.history) > RECENT_HISTORY_COUNT else []
244
+ )
245
+ history_text = "\n".join(f"{i + 1}. {item}" for i, item in enumerate(items_to_summarize))
246
+
247
+ prompt = f"""Summarize the following workflow history, preserving key facts and decisions.
248
+ Focus on what was accomplished and what important information should be retained.
249
+
250
+ SEED/GOAL:
251
+ {context.seed_summary}
252
+
253
+ HISTORY TO SUMMARIZE:
254
+ {history_text}
255
+
256
+ CURRENT KEY FACTS:
257
+ {chr(10).join(f"- {fact}" for fact in context.key_facts)}
258
+
259
+ Provide a concise summary that captures:
260
+ 1. Key accomplishments and milestones
261
+ 2. Important decisions made
262
+ 3. Critical facts that must be preserved
263
+ 4. Any blockers or issues encountered
264
+
265
+ Keep the summary focused and factual. Omit unnecessary details."""
266
+
267
+ messages = [Message(role=MessageRole.USER, content=prompt)]
268
+ config = CompletionConfig(
269
+ model=model,
270
+ temperature=0.3, # Lower temperature for more consistent summaries
271
+ max_tokens=2000, # Limit summary size
272
+ )
273
+
274
+ log.debug(
275
+ "context.compression.llm.started",
276
+ model=model,
277
+ history_items=len(context.history),
278
+ )
279
+
280
+ result = await llm_adapter.complete(messages, config)
281
+
282
+ if result.is_ok:
283
+ log.debug(
284
+ "context.compression.llm.completed",
285
+ summary_tokens=result.value.usage.completion_tokens,
286
+ )
287
+ return Result.ok(result.value.content)
288
+ else:
289
+ log.warning(
290
+ "context.compression.llm.failed",
291
+ error=str(result.error),
292
+ )
293
+ return Result.err(result.error)
294
+
295
+
296
+ async def compress_context(
297
+ context: WorkflowContext,
298
+ llm_adapter: LiteLLMAdapter,
299
+ model: str = "gpt-4",
300
+ ) -> Result[CompressionResult, str]:
301
+ """Compress a workflow context when it exceeds limits.
302
+
303
+ This function implements the full compression logic:
304
+ 1. Try LLM-based summarization of history
305
+ 2. Preserve critical info (seed, current AC, recent history, key facts)
306
+ 3. Fall back to aggressive truncation on LLM failure
307
+ 4. Log compression events with metrics
308
+
309
+ Args:
310
+ context: The workflow context to compress.
311
+ llm_adapter: LLM adapter for making completion requests.
312
+ model: The model to use for summarization and token counting.
313
+
314
+ Returns:
315
+ Result containing CompressionResult or error message.
316
+ """
317
+ before_tokens = count_context_tokens(context, model)
318
+
319
+ log.info(
320
+ "context.compression.started",
321
+ before_tokens=before_tokens,
322
+ history_items=len(context.history),
323
+ age_hours=get_context_metrics(context, model).age_hours,
324
+ )
325
+
326
+ # Try LLM-based compression first
327
+ summary_result = await compress_context_with_llm(context, llm_adapter, model)
328
+
329
+ if summary_result.is_ok:
330
+ # LLM compression succeeded
331
+ summary = summary_result.value
332
+
333
+ # Build compressed context preserving critical info
334
+ compressed_context = {
335
+ "seed_summary": context.seed_summary, # Always preserve
336
+ "current_ac": context.current_ac, # Always preserve
337
+ "history_summary": summary, # Compressed history
338
+ "recent_history": context.history[-RECENT_HISTORY_COUNT:], # Last 3 items
339
+ "key_facts": context.key_facts, # Always preserve
340
+ "metadata": {
341
+ **context.metadata,
342
+ "compression_timestamp": datetime.now(UTC).isoformat(),
343
+ "original_history_count": len(context.history),
344
+ },
345
+ }
346
+
347
+ # Count tokens in compressed context
348
+ compressed_str = f"""
349
+ Seed: {compressed_context['seed_summary']}
350
+ Current AC: {compressed_context['current_ac']}
351
+ Summary: {compressed_context['history_summary']}
352
+ Recent: {compressed_context['recent_history']}
353
+ Facts: {compressed_context['key_facts']}
354
+ """
355
+ after_tokens = count_tokens(compressed_str, model)
356
+ compression_ratio = after_tokens / before_tokens if before_tokens > 0 else 1.0
357
+
358
+ log.info(
359
+ "context.compression.completed",
360
+ method="llm",
361
+ before_tokens=before_tokens,
362
+ after_tokens=after_tokens,
363
+ compression_ratio=compression_ratio,
364
+ reduction_percent=int((1 - compression_ratio) * 100),
365
+ )
366
+
367
+ return Result.ok(
368
+ CompressionResult(
369
+ compressed_context=compressed_context,
370
+ before_tokens=before_tokens,
371
+ after_tokens=after_tokens,
372
+ compression_ratio=compression_ratio,
373
+ method="llm",
374
+ )
375
+ )
376
+ else:
377
+ # LLM compression failed - fall back to aggressive truncation
378
+ log.warning(
379
+ "context.compression.llm_failed.using_fallback",
380
+ error=str(summary_result.error),
381
+ )
382
+
383
+ # Fallback: Keep only seed + current AC (most critical info)
384
+ compressed_context = {
385
+ "seed_summary": context.seed_summary,
386
+ "current_ac": context.current_ac,
387
+ "key_facts": context.key_facts[:5], # Keep top 5 facts only
388
+ "metadata": {
389
+ **context.metadata,
390
+ "compression_timestamp": datetime.now(UTC).isoformat(),
391
+ "compression_method": "aggressive_truncation",
392
+ "compression_reason": "llm_failure",
393
+ "original_history_count": len(context.history),
394
+ },
395
+ }
396
+
397
+ compressed_str = f"""
398
+ Seed: {compressed_context['seed_summary']}
399
+ Current AC: {compressed_context['current_ac']}
400
+ Facts: {compressed_context['key_facts']}
401
+ """
402
+ after_tokens = count_tokens(compressed_str, model)
403
+ compression_ratio = after_tokens / before_tokens if before_tokens > 0 else 1.0
404
+
405
+ log.warning(
406
+ "context.compression.completed.fallback",
407
+ method="truncate",
408
+ before_tokens=before_tokens,
409
+ after_tokens=after_tokens,
410
+ compression_ratio=compression_ratio,
411
+ reduction_percent=int((1 - compression_ratio) * 100),
412
+ )
413
+
414
+ return Result.ok(
415
+ CompressionResult(
416
+ compressed_context=compressed_context,
417
+ before_tokens=before_tokens,
418
+ after_tokens=after_tokens,
419
+ compression_ratio=compression_ratio,
420
+ method="truncate",
421
+ )
422
+ )
423
+
424
+
425
+ def create_filtered_context(
426
+ context: WorkflowContext,
427
+ subagent_ac: str,
428
+ relevant_fact_keywords: list[str] | None = None,
429
+ ) -> FilteredContext:
430
+ """Create a filtered context for SubAgent isolation.
431
+
432
+ Creates an immutable FilteredContext containing only the information
433
+ a SubAgent needs to execute its task, isolating it from the full
434
+ workflow context. This prevents context pollution (AC 3).
435
+
436
+ The filtered context includes (per AC 2):
437
+ - current_ac: The SubAgent's specific acceptance criterion
438
+ - relevant_facts: Filtered key_facts (all or by keywords)
439
+ - parent_summary: Summary including seed_summary
440
+ - recent_history: Last RECENT_HISTORY_COUNT history items
441
+
442
+ Args:
443
+ context: The full workflow context.
444
+ subagent_ac: The acceptance criteria for this SubAgent.
445
+ relevant_fact_keywords: Optional keywords to filter relevant facts.
446
+
447
+ Returns:
448
+ FilteredContext with only information relevant to the SubAgent.
449
+ """
450
+ # Filter facts by keywords if provided
451
+ if relevant_fact_keywords:
452
+ relevant_facts = [
453
+ fact
454
+ for fact in context.key_facts
455
+ if any(keyword.lower() in fact.lower() for keyword in relevant_fact_keywords)
456
+ ]
457
+ else:
458
+ # If no keywords, include all facts (SubAgent might need them)
459
+ relevant_facts = list(context.key_facts) # Copy to ensure isolation
460
+
461
+ # Create parent summary from seed
462
+ parent_summary = f"Parent Goal: {context.seed_summary}"
463
+
464
+ # Extract recent history (last RECENT_HISTORY_COUNT items)
465
+ recent_history = list(context.history[-RECENT_HISTORY_COUNT:])
466
+
467
+ return FilteredContext(
468
+ current_ac=subagent_ac,
469
+ relevant_facts=relevant_facts,
470
+ parent_summary=parent_summary,
471
+ recent_history=recent_history,
472
+ )