mcal-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.
mcal/core/retry.py ADDED
@@ -0,0 +1,188 @@
1
+ """
2
+ Retry utilities for LLM API calls (Issue #38).
3
+
4
+ Provides exponential backoff retry decorators for handling transient
5
+ LLM API failures including rate limits, server errors, and timeouts.
6
+ """
7
+
8
+ import logging
9
+ import functools
10
+ from typing import Type, Tuple, Callable, Any
11
+ from tenacity import (
12
+ retry,
13
+ stop_after_attempt,
14
+ wait_exponential_jitter,
15
+ retry_if_exception_type,
16
+ before_sleep_log,
17
+ RetryError,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ # =============================================================================
24
+ # Retryable Exception Types
25
+ # =============================================================================
26
+
27
+ class LLMRetryableError(Exception):
28
+ """Base class for retryable LLM errors."""
29
+ pass
30
+
31
+
32
+ class LLMRateLimitError(LLMRetryableError):
33
+ """Rate limit exceeded (HTTP 429)."""
34
+ pass
35
+
36
+
37
+ class LLMServerError(LLMRetryableError):
38
+ """Server-side error (HTTP 5xx)."""
39
+ pass
40
+
41
+
42
+ class LLMTimeoutError(LLMRetryableError):
43
+ """Connection or request timeout."""
44
+ pass
45
+
46
+
47
+ class LLMThrottlingError(LLMRetryableError):
48
+ """AWS/Cloud provider throttling."""
49
+ pass
50
+
51
+
52
+ # Default retryable exceptions tuple
53
+ RETRYABLE_EXCEPTIONS: Tuple[Type[Exception], ...] = (
54
+ LLMRateLimitError,
55
+ LLMServerError,
56
+ LLMTimeoutError,
57
+ LLMThrottlingError,
58
+ )
59
+
60
+
61
+ # =============================================================================
62
+ # Retry Decorator Factory
63
+ # =============================================================================
64
+
65
+ def llm_retry(
66
+ max_attempts: int = 3,
67
+ min_wait: float = 1.0,
68
+ max_wait: float = 10.0,
69
+ jitter: float = 1.0,
70
+ retryable_exceptions: Tuple[Type[Exception], ...] = RETRYABLE_EXCEPTIONS,
71
+ ):
72
+ """
73
+ Create a retry decorator for LLM API calls.
74
+
75
+ Uses exponential backoff with jitter to handle transient failures:
76
+ - HTTP 429 (Rate Limited)
77
+ - HTTP 500, 502, 503, 504 (Server Errors)
78
+ - Connection timeouts
79
+ - AWS throttling exceptions
80
+
81
+ Args:
82
+ max_attempts: Maximum number of retry attempts (default: 3)
83
+ min_wait: Minimum wait time in seconds (default: 1.0)
84
+ max_wait: Maximum wait time in seconds (default: 10.0)
85
+ jitter: Random jitter added to wait time (default: 1.0)
86
+ retryable_exceptions: Tuple of exception types to retry
87
+
88
+ Returns:
89
+ Decorator function
90
+
91
+ Example:
92
+ @llm_retry(max_attempts=3)
93
+ async def call_llm(prompt: str) -> str:
94
+ ...
95
+ """
96
+ return retry(
97
+ stop=stop_after_attempt(max_attempts),
98
+ wait=wait_exponential_jitter(initial=min_wait, max=max_wait, jitter=jitter),
99
+ retry=retry_if_exception_type(retryable_exceptions),
100
+ before_sleep=before_sleep_log(logger, logging.WARNING),
101
+ reraise=True,
102
+ )
103
+
104
+
105
+ def llm_retry_sync(
106
+ max_attempts: int = 3,
107
+ min_wait: float = 1.0,
108
+ max_wait: float = 10.0,
109
+ jitter: float = 1.0,
110
+ retryable_exceptions: Tuple[Type[Exception], ...] = RETRYABLE_EXCEPTIONS,
111
+ ):
112
+ """
113
+ Synchronous version of llm_retry for sync functions.
114
+
115
+ Same parameters as llm_retry().
116
+ """
117
+ return retry(
118
+ stop=stop_after_attempt(max_attempts),
119
+ wait=wait_exponential_jitter(initial=min_wait, max=max_wait, jitter=jitter),
120
+ retry=retry_if_exception_type(retryable_exceptions),
121
+ before_sleep=before_sleep_log(logger, logging.WARNING),
122
+ reraise=True,
123
+ )
124
+
125
+
126
+ # =============================================================================
127
+ # Error Classification Helpers
128
+ # =============================================================================
129
+
130
+ def classify_http_error(status_code: int, message: str = "") -> Exception:
131
+ """
132
+ Classify an HTTP error into the appropriate exception type.
133
+
134
+ Args:
135
+ status_code: HTTP status code
136
+ message: Error message
137
+
138
+ Returns:
139
+ Appropriate exception instance
140
+ """
141
+ if status_code == 429:
142
+ return LLMRateLimitError(f"Rate limited: {message}")
143
+ elif status_code in (500, 502, 503, 504):
144
+ return LLMServerError(f"Server error ({status_code}): {message}")
145
+ else:
146
+ # Non-retryable client error (4xx except 429)
147
+ return RuntimeError(f"HTTP {status_code}: {message}")
148
+
149
+
150
+ def classify_boto_error(error_code: str, message: str = "") -> Exception:
151
+ """
152
+ Classify a boto3/botocore error into the appropriate exception type.
153
+
154
+ Args:
155
+ error_code: AWS error code (e.g., 'ThrottlingException')
156
+ message: Error message
157
+
158
+ Returns:
159
+ Appropriate exception instance
160
+ """
161
+ throttling_codes = {
162
+ 'ThrottlingException',
163
+ 'Throttling',
164
+ 'TooManyRequestsException',
165
+ 'RequestThrottled',
166
+ 'ProvisionedThroughputExceededException',
167
+ 'ServiceUnavailableException',
168
+ 'ModelStreamErrorException',
169
+ }
170
+
171
+ server_error_codes = {
172
+ 'InternalServerException',
173
+ 'ServiceException',
174
+ 'InternalFailure',
175
+ }
176
+
177
+ if error_code in throttling_codes:
178
+ return LLMThrottlingError(f"AWS throttling ({error_code}): {message}")
179
+ elif error_code in server_error_codes:
180
+ return LLMServerError(f"AWS server error ({error_code}): {message}")
181
+ else:
182
+ # Non-retryable AWS error
183
+ return RuntimeError(f"AWS error ({error_code}): {message}")
184
+
185
+
186
+ def is_retryable_exception(exc: Exception) -> bool:
187
+ """Check if an exception is retryable."""
188
+ return isinstance(exc, RETRYABLE_EXCEPTIONS)
mcal/core/storage.py ADDED
@@ -0,0 +1,456 @@
1
+ """
2
+ MCAL Storage Layer
3
+
4
+ Provides persistence for intent graphs and decision trails across sessions.
5
+ This enables cross-session reasoning preservation - the core value proposition.
6
+ """
7
+
8
+ import json
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Optional, Dict, Any, List
12
+ from datetime import datetime, timezone
13
+
14
+ from .models import (
15
+ IntentGraph,
16
+ IntentNode,
17
+ IntentEdge,
18
+ IntentType,
19
+ IntentStatus,
20
+ EdgeRelation,
21
+ DecisionTrail,
22
+ Alternative,
23
+ Evidence,
24
+ EvidenceSource,
25
+ TradeOff,
26
+ )
27
+ from .unified_extractor import UnifiedGraph
28
+
29
+
30
+ def _utc_now() -> datetime:
31
+ """Return current UTC time (timezone-aware)."""
32
+ return datetime.now(timezone.utc)
33
+
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+
38
+ class MCALStorage:
39
+ """
40
+ Persistent storage for MCAL data structures.
41
+
42
+ Stores:
43
+ - Intent graphs per user (with versioning)
44
+ - Decision trails per user
45
+ - Session metadata
46
+
47
+ File structure:
48
+ ~/.mcal/
49
+ users/
50
+ {user_id}/
51
+ intent_graph.json # Legacy 3-Pillar
52
+ decisions.json # Legacy 3-Pillar
53
+ unified_graph.json # Unified Deep (Issue #25)
54
+ sessions.json
55
+ """
56
+
57
+ def __init__(self, base_path: Optional[Path] = None):
58
+ """
59
+ Initialize storage.
60
+
61
+ Args:
62
+ base_path: Base directory for storage (default: ~/.mcal)
63
+ """
64
+ if base_path is None:
65
+ base_path = Path.home() / ".mcal"
66
+
67
+ self.base_path = Path(base_path)
68
+ self.users_path = self.base_path / "users"
69
+ self.users_path.mkdir(parents=True, exist_ok=True)
70
+ logger.info(f"MCAL storage initialized at {self.base_path}")
71
+
72
+ def _get_user_path(self, user_id: str) -> Path:
73
+ """Get path for user's data directory."""
74
+ user_path = self.users_path / user_id
75
+ user_path.mkdir(parents=True, exist_ok=True)
76
+ return user_path
77
+
78
+ # =========================================================================
79
+ # Intent Graph Persistence
80
+ # =========================================================================
81
+
82
+ def save_intent_graph(self, user_id: str, graph: IntentGraph) -> None:
83
+ """
84
+ Save intent graph for user.
85
+
86
+ Args:
87
+ user_id: User identifier
88
+ graph: IntentGraph to save
89
+ """
90
+ user_path = self._get_user_path(user_id)
91
+ graph_path = user_path / "intent_graph.json"
92
+
93
+ # Serialize graph
94
+ data = self._serialize_intent_graph(graph)
95
+ data["_metadata"] = {
96
+ "user_id": user_id,
97
+ "saved_at": _utc_now().isoformat(),
98
+ "node_count": len(graph.nodes),
99
+ "edge_count": len(graph.edges)
100
+ }
101
+
102
+ with open(graph_path, 'w') as f:
103
+ json.dump(data, f, indent=2)
104
+
105
+ logger.info(f"Saved intent graph for {user_id}: {len(graph.nodes)} nodes, {len(graph.edges)} edges")
106
+
107
+ def load_intent_graph(self, user_id: str) -> Optional[IntentGraph]:
108
+ """
109
+ Load intent graph for user.
110
+
111
+ Args:
112
+ user_id: User identifier
113
+
114
+ Returns:
115
+ IntentGraph or None if not found
116
+ """
117
+ user_path = self._get_user_path(user_id)
118
+ graph_path = user_path / "intent_graph.json"
119
+
120
+ if not graph_path.exists():
121
+ logger.debug(f"No existing intent graph for {user_id}")
122
+ return None
123
+
124
+ try:
125
+ with open(graph_path, 'r') as f:
126
+ data = json.load(f)
127
+
128
+ graph = self._deserialize_intent_graph(data)
129
+ logger.info(f"Loaded intent graph for {user_id}: {len(graph.nodes)} nodes")
130
+ return graph
131
+
132
+ except Exception as e:
133
+ logger.error(f"Failed to load intent graph for {user_id}: {e}")
134
+ return None
135
+
136
+ def _serialize_intent_graph(self, graph: IntentGraph) -> dict:
137
+ """Serialize IntentGraph to JSON-compatible dict."""
138
+ return {
139
+ "session_id": graph.session_id,
140
+ "nodes": {
141
+ node_id: {
142
+ "id": node.id,
143
+ "type": node.type.value,
144
+ "content": node.content,
145
+ "status": node.status.value,
146
+ "confidence": node.confidence,
147
+ "evidence": node.evidence,
148
+ "created_at": node.created_at.isoformat() if node.created_at else None,
149
+ "updated_at": node.updated_at.isoformat() if node.updated_at else None,
150
+ }
151
+ for node_id, node in graph.nodes.items()
152
+ },
153
+ "edges": [
154
+ {
155
+ "id": edge.id,
156
+ "source": edge.source,
157
+ "target": edge.target,
158
+ "relation": edge.relation.value,
159
+ }
160
+ for edge in graph.edges
161
+ ]
162
+ }
163
+
164
+ def _deserialize_intent_graph(self, data: dict) -> IntentGraph:
165
+ """Deserialize JSON dict to IntentGraph."""
166
+ graph = IntentGraph(session_id=data.get("session_id"))
167
+
168
+ # Reconstruct nodes
169
+ for node_id, node_data in data.get("nodes", {}).items():
170
+ node = IntentNode(
171
+ type=IntentType(node_data["type"]),
172
+ content=node_data["content"],
173
+ status=IntentStatus(node_data["status"]),
174
+ confidence=node_data.get("confidence", 0.8),
175
+ evidence=node_data.get("evidence", [])
176
+ )
177
+ # Override auto-generated ID with stored ID
178
+ node.id = node_data["id"]
179
+ graph.nodes[node.id] = node
180
+
181
+ # Reconstruct edges
182
+ for edge_data in data.get("edges", []):
183
+ edge = IntentEdge(
184
+ source=edge_data["source"],
185
+ target=edge_data["target"],
186
+ relation=EdgeRelation(edge_data["relation"])
187
+ )
188
+ edge.id = edge_data.get("id", edge.id)
189
+ graph.edges.append(edge)
190
+
191
+ return graph
192
+
193
+ # =========================================================================
194
+ # Unified Graph Persistence (Issue #25)
195
+ # =========================================================================
196
+
197
+ def save_unified_graph(self, user_id: str, graph: UnifiedGraph) -> None:
198
+ """
199
+ Save unified graph for user.
200
+
201
+ Args:
202
+ user_id: User identifier
203
+ graph: UnifiedGraph to save
204
+ """
205
+ user_path = self._get_user_path(user_id)
206
+ graph_path = user_path / "unified_graph.json"
207
+
208
+ # Use existing to_dict() method
209
+ data = graph.to_dict()
210
+ data["_metadata"] = {
211
+ "user_id": user_id,
212
+ "saved_at": _utc_now().isoformat(),
213
+ "node_count": len(graph.nodes),
214
+ "edge_count": len(graph.edges),
215
+ "version": "unified_deep_v1"
216
+ }
217
+
218
+ with open(graph_path, 'w') as f:
219
+ json.dump(data, f, indent=2)
220
+
221
+ logger.info(f"Saved unified graph for {user_id}: {len(graph.nodes)} nodes, {len(graph.edges)} edges")
222
+
223
+ def load_unified_graph(self, user_id: str) -> Optional[UnifiedGraph]:
224
+ """
225
+ Load unified graph for user.
226
+
227
+ Args:
228
+ user_id: User identifier
229
+
230
+ Returns:
231
+ UnifiedGraph or None if not found
232
+ """
233
+ user_path = self._get_user_path(user_id)
234
+ graph_path = user_path / "unified_graph.json"
235
+
236
+ if not graph_path.exists():
237
+ logger.debug(f"No existing unified graph for {user_id}")
238
+ return None
239
+
240
+ try:
241
+ with open(graph_path, 'r') as f:
242
+ data = json.load(f)
243
+
244
+ # Use existing from_dict() method
245
+ graph = UnifiedGraph.from_dict(data)
246
+ logger.info(f"Loaded unified graph for {user_id}: {len(graph.nodes)} nodes")
247
+ return graph
248
+
249
+ except Exception as e:
250
+ logger.error(f"Failed to load unified graph for {user_id}: {e}")
251
+ return None
252
+
253
+ def delete_unified_graph(self, user_id: str) -> bool:
254
+ """
255
+ Delete unified graph for user.
256
+
257
+ Args:
258
+ user_id: User identifier
259
+
260
+ Returns:
261
+ True if deleted, False if not found
262
+ """
263
+ user_path = self._get_user_path(user_id)
264
+ graph_path = user_path / "unified_graph.json"
265
+
266
+ if graph_path.exists():
267
+ graph_path.unlink()
268
+ logger.info(f"Deleted unified graph for {user_id}")
269
+ return True
270
+ return False
271
+
272
+ # =========================================================================
273
+ # Decision Trail Persistence
274
+ # =========================================================================
275
+
276
+ def save_decisions(self, user_id: str, decisions: List[DecisionTrail]) -> None:
277
+ """
278
+ Save decision trails for user.
279
+
280
+ Args:
281
+ user_id: User identifier
282
+ decisions: List of DecisionTrail objects
283
+ """
284
+ user_path = self._get_user_path(user_id)
285
+ decisions_path = user_path / "decisions.json"
286
+
287
+ data = {
288
+ "decisions": [
289
+ self._serialize_decision(decision)
290
+ for decision in decisions
291
+ ],
292
+ "_metadata": {
293
+ "user_id": user_id,
294
+ "saved_at": _utc_now().isoformat(),
295
+ "decision_count": len(decisions)
296
+ }
297
+ }
298
+
299
+ with open(decisions_path, 'w') as f:
300
+ json.dump(data, f, indent=2)
301
+
302
+ logger.info(f"Saved {len(decisions)} decisions for {user_id}")
303
+
304
+ def load_decisions(self, user_id: str) -> List[DecisionTrail]:
305
+ """
306
+ Load decision trails for user.
307
+
308
+ Args:
309
+ user_id: User identifier
310
+
311
+ Returns:
312
+ List of DecisionTrail objects
313
+ """
314
+ user_path = self._get_user_path(user_id)
315
+ decisions_path = user_path / "decisions.json"
316
+
317
+ if not decisions_path.exists():
318
+ logger.debug(f"No existing decisions for {user_id}")
319
+ return []
320
+
321
+ try:
322
+ with open(decisions_path, 'r') as f:
323
+ data = json.load(f)
324
+
325
+ decisions = [
326
+ self._deserialize_decision(decision_data)
327
+ for decision_data in data.get("decisions", [])
328
+ ]
329
+
330
+ logger.info(f"Loaded {len(decisions)} decisions for {user_id}")
331
+ return decisions
332
+
333
+ except Exception as e:
334
+ logger.error(f"Failed to load decisions for {user_id}: {e}")
335
+ return []
336
+
337
+ def _serialize_decision(self, decision: DecisionTrail) -> dict:
338
+ """Serialize DecisionTrail to JSON-compatible dict."""
339
+ return {
340
+ "id": decision.id,
341
+ "decision": decision.decision,
342
+ "context": decision.context,
343
+ "rationale": decision.rationale,
344
+ "alternatives": [
345
+ {
346
+ "option": alt.option,
347
+ "pros": alt.pros,
348
+ "cons": alt.cons,
349
+ "rejection_reason": alt.rejection_reason
350
+ }
351
+ for alt in decision.alternatives
352
+ ],
353
+ "evidence": [
354
+ {
355
+ "claim": ev.claim,
356
+ "source": ev.source.value,
357
+ "turn_id": ev.turn_id
358
+ }
359
+ for ev in decision.evidence
360
+ ],
361
+ "trade_offs": [
362
+ {
363
+ "gained": to.gained,
364
+ "sacrificed": to.sacrificed,
365
+ "justification": to.justification
366
+ }
367
+ for to in decision.trade_offs
368
+ ],
369
+ "confidence": decision.confidence,
370
+ "related_goals": decision.related_goals,
371
+ "dependencies": decision.dependencies,
372
+ "created_at": decision.created_at.isoformat() if decision.created_at else None,
373
+ "is_valid": decision.is_valid,
374
+ "invalidated_by": decision.invalidated_by
375
+ }
376
+
377
+ def _deserialize_decision(self, data: dict) -> DecisionTrail:
378
+ """Deserialize JSON dict to DecisionTrail."""
379
+ # Reconstruct alternatives
380
+ alternatives = [
381
+ Alternative(
382
+ option=alt["option"],
383
+ pros=alt.get("pros", []),
384
+ cons=alt.get("cons", []),
385
+ rejection_reason=alt.get("rejection_reason", "")
386
+ )
387
+ for alt in data.get("alternatives", [])
388
+ ]
389
+
390
+ # Reconstruct evidence
391
+ evidence = [
392
+ Evidence(
393
+ claim=ev["claim"],
394
+ source=EvidenceSource(ev.get("source", "inferred")),
395
+ turn_id=ev.get("turn_id")
396
+ )
397
+ for ev in data.get("evidence", [])
398
+ ]
399
+
400
+ # Reconstruct trade-offs
401
+ trade_offs = [
402
+ TradeOff(
403
+ gained=to["gained"],
404
+ sacrificed=to["sacrificed"],
405
+ justification=to.get("justification", "")
406
+ )
407
+ for to in data.get("trade_offs", [])
408
+ ]
409
+
410
+ # Create decision with all fields
411
+ decision = DecisionTrail(
412
+ id=data["id"],
413
+ decision=data["decision"],
414
+ context=data.get("context", ""),
415
+ rationale=data.get("rationale", ""),
416
+ alternatives=alternatives,
417
+ evidence=evidence,
418
+ trade_offs=trade_offs,
419
+ confidence=data.get("confidence", 0.8),
420
+ related_goals=data.get("related_goals", []),
421
+ dependencies=data.get("dependencies", []),
422
+ invalidated_by=data.get("invalidated_by")
423
+ )
424
+
425
+ return decision
426
+
427
+ # =========================================================================
428
+ # Utility Methods
429
+ # =========================================================================
430
+
431
+ def clear_user_data(self, user_id: str) -> None:
432
+ """Clear all stored data for a user."""
433
+ import shutil
434
+ user_path = self._get_user_path(user_id)
435
+ if user_path.exists():
436
+ shutil.rmtree(user_path)
437
+ logger.info(f"Cleared all data for {user_id}")
438
+
439
+ def list_users(self) -> list[str]:
440
+ """List all users with stored data."""
441
+ if not self.users_path.exists():
442
+ return []
443
+ return [d.name for d in self.users_path.iterdir() if d.is_dir()]
444
+
445
+ def get_user_summary(self, user_id: str) -> dict:
446
+ """Get summary of stored data for a user."""
447
+ graph = self.load_intent_graph(user_id)
448
+ decisions = self.load_decisions(user_id)
449
+
450
+ return {
451
+ "user_id": user_id,
452
+ "has_intent_graph": graph is not None,
453
+ "node_count": len(graph.nodes) if graph else 0,
454
+ "decision_count": len(decisions),
455
+ "active_goals": len(graph.get_active_goals()) if graph else 0
456
+ }