puffinflow 2.dev0__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.
Files changed (55) hide show
  1. puffinflow/__init__.py +132 -0
  2. puffinflow/core/__init__.py +110 -0
  3. puffinflow/core/agent/__init__.py +320 -0
  4. puffinflow/core/agent/base.py +1635 -0
  5. puffinflow/core/agent/checkpoint.py +50 -0
  6. puffinflow/core/agent/context.py +521 -0
  7. puffinflow/core/agent/decorators/__init__.py +90 -0
  8. puffinflow/core/agent/decorators/builder.py +454 -0
  9. puffinflow/core/agent/decorators/flexible.py +714 -0
  10. puffinflow/core/agent/decorators/inspection.py +144 -0
  11. puffinflow/core/agent/dependencies.py +57 -0
  12. puffinflow/core/agent/scheduling/__init__.py +21 -0
  13. puffinflow/core/agent/scheduling/builder.py +160 -0
  14. puffinflow/core/agent/scheduling/exceptions.py +35 -0
  15. puffinflow/core/agent/scheduling/inputs.py +137 -0
  16. puffinflow/core/agent/scheduling/parser.py +209 -0
  17. puffinflow/core/agent/scheduling/scheduler.py +413 -0
  18. puffinflow/core/agent/state.py +141 -0
  19. puffinflow/core/config.py +62 -0
  20. puffinflow/core/coordination/__init__.py +137 -0
  21. puffinflow/core/coordination/agent_group.py +359 -0
  22. puffinflow/core/coordination/agent_pool.py +629 -0
  23. puffinflow/core/coordination/agent_team.py +577 -0
  24. puffinflow/core/coordination/coordinator.py +720 -0
  25. puffinflow/core/coordination/deadlock.py +1759 -0
  26. puffinflow/core/coordination/fluent_api.py +421 -0
  27. puffinflow/core/coordination/primitives.py +478 -0
  28. puffinflow/core/coordination/rate_limiter.py +520 -0
  29. puffinflow/core/observability/__init__.py +47 -0
  30. puffinflow/core/observability/agent.py +139 -0
  31. puffinflow/core/observability/alerting.py +73 -0
  32. puffinflow/core/observability/config.py +127 -0
  33. puffinflow/core/observability/context.py +88 -0
  34. puffinflow/core/observability/core.py +147 -0
  35. puffinflow/core/observability/decorators.py +105 -0
  36. puffinflow/core/observability/events.py +71 -0
  37. puffinflow/core/observability/interfaces.py +196 -0
  38. puffinflow/core/observability/metrics.py +137 -0
  39. puffinflow/core/observability/tracing.py +209 -0
  40. puffinflow/core/reliability/__init__.py +27 -0
  41. puffinflow/core/reliability/bulkhead.py +96 -0
  42. puffinflow/core/reliability/circuit_breaker.py +149 -0
  43. puffinflow/core/reliability/leak_detector.py +122 -0
  44. puffinflow/core/resources/__init__.py +77 -0
  45. puffinflow/core/resources/allocation.py +790 -0
  46. puffinflow/core/resources/pool.py +645 -0
  47. puffinflow/core/resources/quotas.py +567 -0
  48. puffinflow/core/resources/requirements.py +217 -0
  49. puffinflow/version.py +21 -0
  50. puffinflow-2.dev0.dist-info/METADATA +334 -0
  51. puffinflow-2.dev0.dist-info/RECORD +55 -0
  52. puffinflow-2.dev0.dist-info/WHEEL +5 -0
  53. puffinflow-2.dev0.dist-info/entry_points.txt +3 -0
  54. puffinflow-2.dev0.dist-info/licenses/LICENSE +21 -0
  55. puffinflow-2.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,567 @@
1
+ """Quota management for resource allocation."""
2
+
3
+ import asyncio
4
+ import time
5
+ from collections import defaultdict
6
+ from dataclasses import dataclass, field
7
+ from datetime import datetime, timedelta
8
+ from enum import Enum
9
+ from typing import Any, Optional, Union
10
+
11
+ import structlog
12
+
13
+ from .requirements import ResourceType
14
+
15
+ logger = structlog.get_logger(__name__)
16
+
17
+
18
+ class QuotaScope(Enum):
19
+ """Scope of quota enforcement."""
20
+
21
+ AGENT = "agent" # Per-agent quotas
22
+ POOL = "pool" # Per-pool quotas
23
+ WORKFLOW = "workflow" # Per-workflow quotas
24
+ STATE = "state" # Per-state quotas
25
+ USER = "user" # Per-user quotas (for multi-tenancy)
26
+ GLOBAL = "global" # Global system quotas
27
+
28
+
29
+ class QuotaPolicy(Enum):
30
+ """Quota enforcement policies."""
31
+
32
+ HARD = "hard" # Strict enforcement, reject if exceeds
33
+ SOFT = "soft" # Allow temporary exceed with warning
34
+ BURST = "burst" # Allow burst up to certain limit
35
+ RATE_LIMIT = "rate_limit" # Limit rate of resource usage
36
+
37
+
38
+ @dataclass
39
+ class QuotaLimit:
40
+ """Definition of a quota limit."""
41
+
42
+ resource_type: ResourceType
43
+ limit: float
44
+ scope: QuotaScope
45
+ policy: QuotaPolicy = QuotaPolicy.HARD
46
+ burst_limit: Optional[float] = None # For burst policy
47
+ rate_limit: Optional[float] = None # Requests per second
48
+ window_size: timedelta = field(default_factory=lambda: timedelta(minutes=1))
49
+ cooldown: timedelta = field(default_factory=lambda: timedelta(minutes=5))
50
+
51
+ def __post_init__(self) -> None:
52
+ if self.policy == QuotaPolicy.BURST and self.burst_limit is None:
53
+ self.burst_limit = self.limit * 1.5
54
+ if self.policy == QuotaPolicy.RATE_LIMIT and self.rate_limit is None:
55
+ self.rate_limit = self.limit
56
+
57
+
58
+ @dataclass
59
+ class QuotaUsage:
60
+ """Track quota usage."""
61
+
62
+ current: float = 0.0
63
+ peak: float = 0.0
64
+ total_allocated: float = 0.0
65
+ total_released: float = 0.0
66
+ allocations: int = 0
67
+ violations: int = 0
68
+ last_violation: Optional[datetime] = None
69
+ last_reset: datetime = field(default_factory=datetime.utcnow)
70
+
71
+ # For rate limiting
72
+ request_times: list[float] = field(default_factory=list)
73
+
74
+ def reset(self) -> None:
75
+ """Reset usage statistics."""
76
+ self.current = 0.0
77
+ self.allocations = 0
78
+ self.last_reset = datetime.utcnow()
79
+ self.request_times.clear()
80
+
81
+ def add_allocation(self, amount: float) -> None:
82
+ """Record an allocation."""
83
+ self.current += amount
84
+ self.total_allocated += amount
85
+ self.peak = max(self.peak, self.current)
86
+ self.allocations += 1
87
+ self.request_times.append(time.time())
88
+
89
+ def remove_allocation(self, amount: float) -> None:
90
+ """Record a release."""
91
+ self.current = max(0, self.current - amount)
92
+ self.total_released += amount
93
+
94
+ def record_violation(self) -> None:
95
+ """Record a quota violation."""
96
+ self.violations += 1
97
+ self.last_violation = datetime.utcnow()
98
+
99
+
100
+ @dataclass
101
+ class QuotaMetrics:
102
+ """Metrics for quota usage."""
103
+
104
+ scope: QuotaScope
105
+ scope_id: str
106
+ resource_type: ResourceType
107
+ usage: QuotaUsage
108
+ limit: QuotaLimit
109
+
110
+ @property
111
+ def utilization(self) -> float:
112
+ """Get current utilization percentage."""
113
+ if self.limit.limit == 0:
114
+ return 0.0
115
+ return (self.usage.current / self.limit.limit) * 100
116
+
117
+ @property
118
+ def is_exceeded(self) -> bool:
119
+ """Check if quota is exceeded."""
120
+ return self.usage.current > self.limit.limit
121
+
122
+ def to_dict(self) -> dict[str, Any]:
123
+ """Convert to dictionary."""
124
+ return {
125
+ "scope": self.scope.value,
126
+ "scope_id": self.scope_id,
127
+ "resource_type": self.resource_type.name,
128
+ "current_usage": self.usage.current,
129
+ "limit": self.limit.limit,
130
+ "utilization": self.utilization,
131
+ "peak_usage": self.usage.peak,
132
+ "allocations": self.usage.allocations,
133
+ "violations": self.usage.violations,
134
+ "policy": self.limit.policy.value,
135
+ }
136
+
137
+
138
+ class QuotaExceededError(Exception):
139
+ """Raised when quota is exceeded."""
140
+
141
+ def __init__(
142
+ self,
143
+ scope: QuotaScope,
144
+ scope_id: str,
145
+ resource_type: ResourceType,
146
+ requested: float,
147
+ available: float,
148
+ ):
149
+ self.scope = scope
150
+ self.scope_id = scope_id
151
+ self.resource_type = resource_type
152
+ self.requested = requested
153
+ self.available = available
154
+ super().__init__(
155
+ f"Quota exceeded for {scope.value} '{scope_id}': "
156
+ f"requested {requested} {resource_type.name}, "
157
+ f"available {available}"
158
+ )
159
+
160
+
161
+ class QuotaManager:
162
+ """Manages resource quotas across different scopes."""
163
+
164
+ def __init__(self) -> None:
165
+ # Quota limits by scope
166
+ self._limits: dict[QuotaScope, dict[str, dict[ResourceType, QuotaLimit]]] = {
167
+ scope: defaultdict(dict) for scope in QuotaScope
168
+ }
169
+
170
+ # Usage tracking
171
+ self._usage: dict[QuotaScope, dict[str, dict[ResourceType, QuotaUsage]]] = {
172
+ scope: defaultdict(lambda: defaultdict(QuotaUsage)) for scope in QuotaScope
173
+ }
174
+
175
+ # Locks for thread safety
176
+ self._locks: dict[str, asyncio.Lock] = {}
177
+
178
+ # Background tasks
179
+ self._cleanup_task: Optional[asyncio.Task] = None
180
+ self._running = False
181
+
182
+ async def start(self) -> None:
183
+ """Start the quota manager."""
184
+ self._running = True
185
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
186
+ logger.info("quota_manager_started")
187
+
188
+ async def stop(self) -> None:
189
+ """Stop the quota manager."""
190
+ self._running = False
191
+ if self._cleanup_task:
192
+ self._cleanup_task.cancel()
193
+ await asyncio.gather(self._cleanup_task, return_exceptions=True)
194
+ logger.info("quota_manager_stopped")
195
+
196
+ def set_quota(
197
+ self,
198
+ scope: QuotaScope,
199
+ scope_id: str,
200
+ resource_type: ResourceType,
201
+ limit: Union[float, QuotaLimit],
202
+ ) -> None:
203
+ """Set a quota limit."""
204
+ if isinstance(limit, (int, float)):
205
+ quota_limit = QuotaLimit(
206
+ resource_type=resource_type, limit=float(limit), scope=scope
207
+ )
208
+ else:
209
+ quota_limit = limit
210
+
211
+ self._limits[scope][scope_id][resource_type] = quota_limit
212
+
213
+ logger.info(
214
+ "quota_set",
215
+ scope=scope.value,
216
+ scope_id=scope_id,
217
+ resource_type=resource_type.name,
218
+ limit=quota_limit.limit,
219
+ policy=quota_limit.policy.value,
220
+ )
221
+
222
+ def remove_quota(
223
+ self,
224
+ scope: QuotaScope,
225
+ scope_id: str,
226
+ resource_type: Optional[ResourceType] = None,
227
+ ) -> None:
228
+ """Remove a quota limit."""
229
+ if resource_type:
230
+ self._limits[scope][scope_id].pop(resource_type, None)
231
+ self._usage[scope][scope_id].pop(resource_type, None)
232
+ else:
233
+ # Remove all quotas for this scope_id
234
+ self._limits[scope].pop(scope_id, None)
235
+ self._usage[scope].pop(scope_id, None)
236
+
237
+ async def check_quota(
238
+ self,
239
+ scope: QuotaScope,
240
+ scope_id: str,
241
+ resource_type: ResourceType,
242
+ requested: float,
243
+ ) -> bool:
244
+ """
245
+ Check if allocation would exceed quota.
246
+
247
+ Returns:
248
+ True if allocation is allowed, False otherwise
249
+ """
250
+ # Get lock for this scope_id
251
+ lock_key = f"{scope.value}:{scope_id}"
252
+ if lock_key not in self._locks:
253
+ self._locks[lock_key] = asyncio.Lock()
254
+
255
+ async with self._locks[lock_key]:
256
+ # Check if quota exists
257
+ if scope_id not in self._limits[scope]:
258
+ return True # No quota set
259
+
260
+ if resource_type not in self._limits[scope][scope_id]:
261
+ return True # No quota for this resource
262
+
263
+ limit = self._limits[scope][scope_id][resource_type]
264
+ usage = self._usage[scope][scope_id][resource_type]
265
+
266
+ # Check based on policy
267
+ if limit.policy == QuotaPolicy.HARD:
268
+ return usage.current + requested <= limit.limit
269
+
270
+ elif limit.policy == QuotaPolicy.SOFT:
271
+ # Allow but warn if exceeding
272
+ if usage.current + requested > limit.limit:
273
+ logger.warning(
274
+ "soft_quota_exceeded",
275
+ scope=scope.value,
276
+ scope_id=scope_id,
277
+ resource_type=resource_type.name,
278
+ current=usage.current,
279
+ requested=requested,
280
+ limit=limit.limit,
281
+ )
282
+ return True
283
+
284
+ elif limit.policy == QuotaPolicy.BURST:
285
+ # Allow burst up to burst_limit
286
+ return (
287
+ limit.burst_limit is not None
288
+ and usage.current + requested <= limit.burst_limit
289
+ )
290
+
291
+ else: # limit.policy == QuotaPolicy.RATE_LIMIT
292
+ # Check rate limit
293
+ return self._check_rate_limit(usage, limit)
294
+
295
+ def _check_rate_limit(self, usage: QuotaUsage, limit: QuotaLimit) -> bool:
296
+ """Check if rate limit is exceeded."""
297
+ current_time = time.time()
298
+ window_start = current_time - limit.window_size.total_seconds()
299
+
300
+ # Remove old requests outside window
301
+ usage.request_times = [t for t in usage.request_times if t > window_start]
302
+
303
+ # Check rate
304
+ requests_in_window = len(usage.request_times)
305
+ max_requests = (limit.rate_limit or 0.0) * limit.window_size.total_seconds()
306
+
307
+ return requests_in_window < max_requests
308
+
309
+ async def allocate(
310
+ self,
311
+ scope: QuotaScope,
312
+ scope_id: str,
313
+ resource_type: ResourceType,
314
+ amount: float,
315
+ ) -> bool:
316
+ """
317
+ Allocate resources against quota.
318
+
319
+ Returns:
320
+ True if allocation succeeded, False otherwise
321
+
322
+ Raises:
323
+ QuotaExceededError: If hard quota is exceeded
324
+ """
325
+ # Check quota
326
+ if not await self.check_quota(scope, scope_id, resource_type, amount):
327
+ # Get current usage for error message
328
+ usage = self._usage[scope][scope_id][resource_type]
329
+ limit = self._limits[scope][scope_id][resource_type]
330
+
331
+ if limit.policy == QuotaPolicy.HARD:
332
+ raise QuotaExceededError(
333
+ scope, scope_id, resource_type, amount, limit.limit - usage.current
334
+ )
335
+ else:
336
+ usage.record_violation()
337
+ return False
338
+
339
+ # Record allocation
340
+ lock_key = f"{scope.value}:{scope_id}"
341
+ async with self._locks[lock_key]:
342
+ usage = self._usage[scope][scope_id][resource_type]
343
+ usage.add_allocation(amount)
344
+
345
+ return True
346
+
347
+ async def release(
348
+ self,
349
+ scope: QuotaScope,
350
+ scope_id: str,
351
+ resource_type: ResourceType,
352
+ amount: float,
353
+ ) -> None:
354
+ """Release allocated resources."""
355
+ lock_key = f"{scope.value}:{scope_id}"
356
+ if lock_key not in self._locks:
357
+ return
358
+
359
+ async with self._locks[lock_key]:
360
+ if (
361
+ scope_id in self._usage[scope]
362
+ and resource_type in self._usage[scope][scope_id]
363
+ ):
364
+ usage = self._usage[scope][scope_id][resource_type]
365
+ usage.remove_allocation(amount)
366
+
367
+ def get_usage(
368
+ self,
369
+ scope: QuotaScope,
370
+ scope_id: str,
371
+ resource_type: Optional[ResourceType] = None,
372
+ ) -> Union[QuotaUsage, dict[ResourceType, QuotaUsage]]:
373
+ """Get current usage for a scope."""
374
+ if scope_id not in self._usage[scope]:
375
+ return {} if resource_type is None else QuotaUsage()
376
+
377
+ if resource_type:
378
+ return self._usage[scope][scope_id].get(resource_type, QuotaUsage())
379
+ else:
380
+ return dict(self._usage[scope][scope_id])
381
+
382
+ def get_metrics(
383
+ self, scope: Optional[QuotaScope] = None, scope_id: Optional[str] = None
384
+ ) -> list[QuotaMetrics]:
385
+ """Get quota metrics."""
386
+ metrics = []
387
+
388
+ scopes = [scope] if scope else list(QuotaScope)
389
+
390
+ for s in scopes:
391
+ scope_ids = [scope_id] if scope_id else list(self._limits[s].keys())
392
+
393
+ for sid in scope_ids:
394
+ if sid not in self._limits[s]:
395
+ continue
396
+
397
+ for resource_type, limit in self._limits[s][sid].items():
398
+ usage = self._usage[s][sid].get(resource_type, QuotaUsage())
399
+
400
+ metrics.append(
401
+ QuotaMetrics(
402
+ scope=s,
403
+ scope_id=sid,
404
+ resource_type=resource_type,
405
+ usage=usage,
406
+ limit=limit,
407
+ )
408
+ )
409
+
410
+ return metrics
411
+
412
+ def reset_usage(
413
+ self,
414
+ scope: QuotaScope,
415
+ scope_id: str,
416
+ resource_type: Optional[ResourceType] = None,
417
+ ) -> None:
418
+ """Reset usage statistics."""
419
+ if scope_id in self._usage[scope]:
420
+ if resource_type:
421
+ if resource_type in self._usage[scope][scope_id]:
422
+ self._usage[scope][scope_id][resource_type].reset()
423
+ else:
424
+ for usage in self._usage[scope][scope_id].values():
425
+ usage.reset()
426
+
427
+ async def _cleanup_expired(self) -> None:
428
+ """Clean up expired usage data."""
429
+ current_time = time.time()
430
+
431
+ # Clean up old rate limit data
432
+ for scope in self._usage.values():
433
+ for scope_id_usage in scope.values():
434
+ for usage in scope_id_usage.values():
435
+ # Keep only recent request times
436
+ cutoff = current_time - 3600 # Keep 1 hour
437
+ usage.request_times = [t for t in usage.request_times if t > cutoff]
438
+
439
+ logger.debug("quota_cleanup_completed")
440
+
441
+ async def _cleanup_loop(self) -> None:
442
+ """Periodic cleanup of old usage data."""
443
+ while self._running:
444
+ try:
445
+ await asyncio.sleep(3600) # Run hourly
446
+ await self._cleanup_expired()
447
+
448
+ except asyncio.CancelledError:
449
+ break
450
+ except Exception as e:
451
+ logger.error("quota_cleanup_error", error=str(e))
452
+
453
+ def apply_quota_policy(
454
+ self,
455
+ policy_name: str,
456
+ quotas: dict[ResourceType, float],
457
+ scope: QuotaScope = QuotaScope.AGENT,
458
+ policy: QuotaPolicy = QuotaPolicy.HARD,
459
+ ) -> dict[str, Any]:
460
+ """Apply a quota policy to multiple scope IDs."""
461
+ # This would be used to apply standard quota templates
462
+ return {
463
+ "policy_name": policy_name,
464
+ "quotas": quotas,
465
+ "scope": scope,
466
+ "policy": policy,
467
+ }
468
+
469
+
470
+ class QuotaEnforcer:
471
+ """Enforces quotas during resource allocation."""
472
+
473
+ def __init__(self, quota_manager: QuotaManager):
474
+ self.quota_manager = quota_manager
475
+
476
+ async def check_all_quotas(
477
+ self, requests: list[tuple[QuotaScope, str, ResourceType, float]]
478
+ ) -> tuple[bool, list[str]]:
479
+ """
480
+ Check multiple quota requests.
481
+
482
+ Returns:
483
+ Tuple of (all_allowed, list_of_violations)
484
+ """
485
+ violations = []
486
+
487
+ for scope, scope_id, resource_type, amount in requests:
488
+ try:
489
+ if not await self.quota_manager.check_quota(
490
+ scope, scope_id, resource_type, amount
491
+ ):
492
+ violations.append(
493
+ f"{scope.value} '{scope_id}' exceeds "
494
+ f"{resource_type.name} quota"
495
+ )
496
+ except QuotaExceededError as e:
497
+ violations.append(str(e))
498
+
499
+ return len(violations) == 0, violations
500
+
501
+ async def allocate_with_quotas(
502
+ self, allocations: list[tuple[QuotaScope, str, ResourceType, float]]
503
+ ) -> tuple[bool, list[tuple[QuotaScope, str, ResourceType, float]]]:
504
+ """
505
+ Allocate resources with quota enforcement.
506
+
507
+ Returns:
508
+ Tuple of (success, list_of_allocated_resources)
509
+ """
510
+ allocated = []
511
+
512
+ try:
513
+ # First check all quotas
514
+ allowed, violations = await self.check_all_quotas(allocations)
515
+ if not allowed:
516
+ return False, []
517
+
518
+ # Allocate all
519
+ for scope, scope_id, resource_type, amount in allocations:
520
+ await self.quota_manager.allocate(
521
+ scope, scope_id, resource_type, amount
522
+ )
523
+ allocated.append((scope, scope_id, resource_type, amount))
524
+
525
+ return True, allocated
526
+
527
+ except Exception as e:
528
+ # Rollback allocations
529
+ for scope, scope_id, resource_type, amount in allocated:
530
+ await self.quota_manager.release(scope, scope_id, resource_type, amount)
531
+
532
+ logger.error("quota_allocation_failed", error=str(e))
533
+ return False, []
534
+
535
+
536
+ # Predefined quota policies
537
+ class QuotaPolicies:
538
+ """Common quota policies."""
539
+
540
+ SMALL_AGENT = {
541
+ ResourceType.CPU: 2.0,
542
+ ResourceType.MEMORY: 512.0,
543
+ ResourceType.IO: 10.0,
544
+ ResourceType.NETWORK: 10.0,
545
+ }
546
+
547
+ MEDIUM_AGENT = {
548
+ ResourceType.CPU: 4.0,
549
+ ResourceType.MEMORY: 2048.0,
550
+ ResourceType.IO: 50.0,
551
+ ResourceType.NETWORK: 50.0,
552
+ }
553
+
554
+ LARGE_AGENT = {
555
+ ResourceType.CPU: 8.0,
556
+ ResourceType.MEMORY: 8192.0,
557
+ ResourceType.IO: 100.0,
558
+ ResourceType.NETWORK: 100.0,
559
+ }
560
+
561
+ GPU_AGENT = {
562
+ ResourceType.CPU: 4.0,
563
+ ResourceType.MEMORY: 16384.0,
564
+ ResourceType.GPU: 1.0,
565
+ ResourceType.IO: 100.0,
566
+ ResourceType.NETWORK: 100.0,
567
+ }