agentfield 0.1.22rc2__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 (42) hide show
  1. agentfield/__init__.py +66 -0
  2. agentfield/agent.py +3569 -0
  3. agentfield/agent_ai.py +1125 -0
  4. agentfield/agent_cli.py +386 -0
  5. agentfield/agent_field_handler.py +494 -0
  6. agentfield/agent_mcp.py +534 -0
  7. agentfield/agent_registry.py +29 -0
  8. agentfield/agent_server.py +1185 -0
  9. agentfield/agent_utils.py +269 -0
  10. agentfield/agent_workflow.py +323 -0
  11. agentfield/async_config.py +278 -0
  12. agentfield/async_execution_manager.py +1227 -0
  13. agentfield/client.py +1447 -0
  14. agentfield/connection_manager.py +280 -0
  15. agentfield/decorators.py +527 -0
  16. agentfield/did_manager.py +337 -0
  17. agentfield/dynamic_skills.py +304 -0
  18. agentfield/execution_context.py +255 -0
  19. agentfield/execution_state.py +453 -0
  20. agentfield/http_connection_manager.py +429 -0
  21. agentfield/litellm_adapters.py +140 -0
  22. agentfield/logger.py +249 -0
  23. agentfield/mcp_client.py +204 -0
  24. agentfield/mcp_manager.py +340 -0
  25. agentfield/mcp_stdio_bridge.py +550 -0
  26. agentfield/memory.py +723 -0
  27. agentfield/memory_events.py +489 -0
  28. agentfield/multimodal.py +173 -0
  29. agentfield/multimodal_response.py +403 -0
  30. agentfield/pydantic_utils.py +227 -0
  31. agentfield/rate_limiter.py +280 -0
  32. agentfield/result_cache.py +441 -0
  33. agentfield/router.py +190 -0
  34. agentfield/status.py +70 -0
  35. agentfield/types.py +710 -0
  36. agentfield/utils.py +26 -0
  37. agentfield/vc_generator.py +464 -0
  38. agentfield/vision.py +198 -0
  39. agentfield-0.1.22rc2.dist-info/METADATA +102 -0
  40. agentfield-0.1.22rc2.dist-info/RECORD +42 -0
  41. agentfield-0.1.22rc2.dist-info/WHEEL +5 -0
  42. agentfield-0.1.22rc2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,441 @@
1
+ """
2
+ Result Cache for async execution results.
3
+
4
+ This module provides in-memory caching of completed execution results with TTL
5
+ (time-to-live) support, cache size limits, LRU eviction, thread-safe operations
6
+ for concurrent access, and cache hit/miss metrics.
7
+ """
8
+
9
+ import asyncio
10
+ import threading
11
+ import time
12
+ from collections import OrderedDict
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ from .async_config import AsyncConfig
17
+ from .execution_state import ExecutionState
18
+ from .logger import get_logger
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ @dataclass
24
+ class CacheEntry:
25
+ """Individual cache entry with metadata."""
26
+
27
+ value: Any
28
+ created_at: float = field(default_factory=time.time)
29
+ accessed_at: float = field(default_factory=time.time)
30
+ access_count: int = 0
31
+ ttl: Optional[float] = None
32
+
33
+ @property
34
+ def age(self) -> float:
35
+ """Get age of the entry in seconds."""
36
+ return time.time() - self.created_at
37
+
38
+ @property
39
+ def time_since_access(self) -> float:
40
+ """Get time since last access in seconds."""
41
+ return time.time() - self.accessed_at
42
+
43
+ @property
44
+ def is_expired(self) -> bool:
45
+ """Check if entry has expired based on TTL."""
46
+ if self.ttl is None:
47
+ return False
48
+ return self.age > self.ttl
49
+
50
+ def touch(self) -> None:
51
+ """Update access time and increment access count."""
52
+ self.accessed_at = time.time()
53
+ self.access_count += 1
54
+
55
+
56
+ @dataclass
57
+ class CacheMetrics:
58
+ """Metrics for cache performance monitoring."""
59
+
60
+ hits: int = 0
61
+ misses: int = 0
62
+ evictions: int = 0
63
+ expirations: int = 0
64
+ size: int = 0
65
+ max_size: int = 0
66
+ created_at: float = field(default_factory=time.time)
67
+
68
+ @property
69
+ def hit_rate(self) -> float:
70
+ """Calculate cache hit rate as a percentage."""
71
+ total = self.hits + self.misses
72
+ if total == 0:
73
+ return 0.0
74
+ return (self.hits / total) * 100
75
+
76
+ @property
77
+ def uptime(self) -> float:
78
+ """Get cache uptime in seconds."""
79
+ return time.time() - self.created_at
80
+
81
+ def record_hit(self) -> None:
82
+ """Record a cache hit."""
83
+ self.hits += 1
84
+
85
+ def record_miss(self) -> None:
86
+ """Record a cache miss."""
87
+ self.misses += 1
88
+
89
+ def record_eviction(self) -> None:
90
+ """Record a cache eviction."""
91
+ self.evictions += 1
92
+
93
+ def record_expiration(self) -> None:
94
+ """Record a cache expiration."""
95
+ self.expirations += 1
96
+
97
+
98
+ class ResultCache:
99
+ """
100
+ Thread-safe in-memory cache for execution results.
101
+
102
+ Provides efficient caching with:
103
+ - TTL (time-to-live) support for automatic expiration
104
+ - LRU (Least Recently Used) eviction when size limits are reached
105
+ - Thread-safe operations for concurrent access
106
+ - Comprehensive metrics for cache performance monitoring
107
+ - Configurable size limits and cleanup intervals
108
+ """
109
+
110
+ def __init__(self, config: Optional[AsyncConfig] = None):
111
+ """
112
+ Initialize the result cache.
113
+
114
+ Args:
115
+ config: AsyncConfig instance for configuration parameters
116
+ """
117
+ self.config = config or AsyncConfig()
118
+
119
+ # Thread-safe storage using OrderedDict for LRU behavior
120
+ self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
121
+ self._lock = threading.RLock() # Reentrant lock for nested operations
122
+
123
+ # Metrics and monitoring
124
+ self.metrics = CacheMetrics()
125
+ self.metrics.max_size = self.config.result_cache_max_size
126
+
127
+ # Background cleanup (event lazily allocated to avoid loop requirements during import)
128
+ self._cleanup_task: Optional[asyncio.Task] = None
129
+ self._cleanup_interval = self.config.cleanup_interval
130
+ self._shutdown_event: Optional[asyncio.Event] = None
131
+
132
+ logger.debug(
133
+ f"ResultCache initialized with max_size={self.config.result_cache_max_size}, ttl={self.config.result_cache_ttl}"
134
+ )
135
+
136
+ def __len__(self) -> int:
137
+ """Get current cache size."""
138
+ with self._lock:
139
+ return len(self._cache)
140
+
141
+ def __contains__(self, key: str) -> bool:
142
+ """Check if key exists in cache (without affecting LRU order)."""
143
+ with self._lock:
144
+ return key in self._cache and not self._cache[key].is_expired
145
+
146
+ async def __aenter__(self):
147
+ """Async context manager entry."""
148
+ await self.start()
149
+ return self
150
+
151
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
152
+ """Async context manager exit."""
153
+ await self.stop()
154
+
155
+ async def start(self) -> None:
156
+ """Start the cache and background cleanup task."""
157
+ if self.config.enable_result_caching:
158
+ self._shutdown_event = asyncio.Event()
159
+ self._cleanup_task = asyncio.create_task(self._cleanup_loop())
160
+ logger.info("ResultCache started with background cleanup")
161
+ else:
162
+ logger.info("ResultCache started (caching disabled)")
163
+
164
+ async def stop(self) -> None:
165
+ """Stop the cache and cleanup background tasks."""
166
+ if self._shutdown_event is not None:
167
+ self._shutdown_event.set()
168
+
169
+ if self._cleanup_task:
170
+ self._cleanup_task.cancel()
171
+ try:
172
+ await self._cleanup_task
173
+ except asyncio.CancelledError:
174
+ pass
175
+ self._shutdown_event = None
176
+
177
+ with self._lock:
178
+ self._cache.clear()
179
+ self.metrics.size = 0
180
+
181
+ logger.info("ResultCache stopped")
182
+
183
+ def get(self, key: str) -> Optional[Any]:
184
+ """
185
+ Get a value from the cache.
186
+
187
+ Args:
188
+ key: Cache key to retrieve
189
+
190
+ Returns:
191
+ Cached value if found and not expired, None otherwise
192
+ """
193
+ if not self.config.enable_result_caching:
194
+ self.metrics.record_miss()
195
+ return None
196
+
197
+ with self._lock:
198
+ if key not in self._cache:
199
+ self.metrics.record_miss()
200
+ return None
201
+
202
+ entry = self._cache[key]
203
+
204
+ # Check if expired
205
+ if entry.is_expired:
206
+ self._remove_entry(key)
207
+ self.metrics.record_miss()
208
+ self.metrics.record_expiration()
209
+ return None
210
+
211
+ # Update access info and move to end (most recently used)
212
+ entry.touch()
213
+ self._cache.move_to_end(key)
214
+
215
+ self.metrics.record_hit()
216
+ logger.debug(f"Cache hit for key: {key[:20]}...")
217
+ return entry.value
218
+
219
+ def set(self, key: str, value: Any, ttl: Optional[float] = None) -> None:
220
+ """
221
+ Set a value in the cache.
222
+
223
+ Args:
224
+ key: Cache key
225
+ value: Value to cache
226
+ ttl: Optional TTL override (uses config default if None)
227
+ """
228
+ if not self.config.enable_result_caching:
229
+ return
230
+
231
+ # Use config TTL if not specified
232
+ if ttl is None:
233
+ ttl = self.config.result_cache_ttl
234
+
235
+ with self._lock:
236
+ # Remove existing entry if present
237
+ if key in self._cache:
238
+ del self._cache[key]
239
+
240
+ # Create new entry
241
+ entry = CacheEntry(value=value, ttl=ttl)
242
+ self._cache[key] = entry
243
+
244
+ # Move to end (most recently used)
245
+ self._cache.move_to_end(key)
246
+
247
+ # Enforce size limit with LRU eviction
248
+ self._enforce_size_limit()
249
+
250
+ # Update metrics
251
+ self.metrics.size = len(self._cache)
252
+
253
+ logger.debug(f"Cache set for key: {key[:20]}... (ttl={ttl}s)")
254
+
255
+ def delete(self, key: str) -> bool:
256
+ """
257
+ Delete a key from the cache.
258
+
259
+ Args:
260
+ key: Cache key to delete
261
+
262
+ Returns:
263
+ True if key was found and deleted, False otherwise
264
+ """
265
+ with self._lock:
266
+ if key in self._cache:
267
+ self._remove_entry(key)
268
+ return True
269
+ return False
270
+
271
+ def clear(self) -> None:
272
+ """Clear all entries from the cache."""
273
+ with self._lock:
274
+ self._cache.clear()
275
+ self.metrics.size = 0
276
+ logger.debug("Cache cleared")
277
+
278
+ def get_execution_result(self, execution_id: str) -> Optional[Any]:
279
+ """
280
+ Get cached result for an execution.
281
+
282
+ Args:
283
+ execution_id: Execution ID to retrieve result for
284
+
285
+ Returns:
286
+ Cached execution result if available
287
+ """
288
+ return self.get(f"exec:{execution_id}")
289
+
290
+ def set_execution_result(
291
+ self, execution_id: str, result: Any, ttl: Optional[float] = None
292
+ ) -> None:
293
+ """
294
+ Cache result for an execution.
295
+
296
+ Args:
297
+ execution_id: Execution ID
298
+ result: Execution result to cache
299
+ ttl: Optional TTL override
300
+ """
301
+ self.set(f"exec:{execution_id}", result, ttl)
302
+
303
+ def cache_execution_state(self, execution_state: ExecutionState) -> None:
304
+ """
305
+ Cache a completed execution state.
306
+
307
+ Args:
308
+ execution_state: ExecutionState to cache
309
+ """
310
+ if execution_state.is_successful and execution_state.result is not None:
311
+ self.set_execution_result(
312
+ execution_state.execution_id, execution_state.result
313
+ )
314
+
315
+ def get_keys(self, pattern: Optional[str] = None) -> List[str]:
316
+ """
317
+ Get all cache keys, optionally filtered by pattern.
318
+
319
+ Args:
320
+ pattern: Optional pattern to filter keys (simple string matching)
321
+
322
+ Returns:
323
+ List of cache keys
324
+ """
325
+ with self._lock:
326
+ keys = list(self._cache.keys())
327
+
328
+ if pattern:
329
+ keys = [k for k in keys if pattern in k]
330
+
331
+ return keys
332
+
333
+ def get_stats(self) -> Dict[str, Any]:
334
+ """
335
+ Get comprehensive cache statistics.
336
+
337
+ Returns:
338
+ Dictionary with cache statistics
339
+ """
340
+ with self._lock:
341
+ # Calculate additional stats
342
+ total_entries = len(self._cache)
343
+ expired_count = sum(1 for entry in self._cache.values() if entry.is_expired)
344
+ avg_age = 0.0
345
+ avg_access_count = 0.0
346
+
347
+ if total_entries > 0:
348
+ avg_age = (
349
+ sum(entry.age for entry in self._cache.values()) / total_entries
350
+ )
351
+ avg_access_count = (
352
+ sum(entry.access_count for entry in self._cache.values())
353
+ / total_entries
354
+ )
355
+
356
+ return {
357
+ "size": total_entries,
358
+ "max_size": self.metrics.max_size,
359
+ "hits": self.metrics.hits,
360
+ "misses": self.metrics.misses,
361
+ "hit_rate": self.metrics.hit_rate,
362
+ "evictions": self.metrics.evictions,
363
+ "expirations": self.metrics.expirations,
364
+ "expired_entries": expired_count,
365
+ "average_age": avg_age,
366
+ "average_access_count": avg_access_count,
367
+ "uptime": self.metrics.uptime,
368
+ "enabled": self.config.enable_result_caching,
369
+ }
370
+
371
+ def _remove_entry(self, key: str) -> None:
372
+ """Remove an entry from cache (must be called with lock held)."""
373
+ if key in self._cache:
374
+ del self._cache[key]
375
+ self.metrics.size = len(self._cache)
376
+
377
+ def _enforce_size_limit(self) -> None:
378
+ """Enforce cache size limit using LRU eviction (must be called with lock held)."""
379
+ while len(self._cache) > self.config.result_cache_max_size:
380
+ # Remove least recently used (first item in OrderedDict)
381
+ oldest_key = next(iter(self._cache))
382
+ self._remove_entry(oldest_key)
383
+ self.metrics.record_eviction()
384
+ logger.debug(f"Evicted LRU entry: {oldest_key[:20]}...")
385
+
386
+ def _cleanup_expired(self) -> int:
387
+ """Remove expired entries from cache (must be called with lock held)."""
388
+ expired_keys = []
389
+
390
+ for key, entry in self._cache.items():
391
+ if entry.is_expired:
392
+ expired_keys.append(key)
393
+
394
+ for key in expired_keys:
395
+ self._remove_entry(key)
396
+ self.metrics.record_expiration()
397
+
398
+ return len(expired_keys)
399
+
400
+ async def _cleanup_loop(self) -> None:
401
+ """Background task for periodic cache cleanup."""
402
+ shutdown_event = self._shutdown_event
403
+ if shutdown_event is None:
404
+ shutdown_event = asyncio.Event()
405
+ shutdown_event.set()
406
+ while not shutdown_event.is_set():
407
+ try:
408
+ await asyncio.sleep(self._cleanup_interval)
409
+
410
+ with self._lock:
411
+ expired_count = self._cleanup_expired()
412
+
413
+ if expired_count > 0:
414
+ logger.debug(
415
+ f"Cleaned up {expired_count} expired cache entries"
416
+ )
417
+
418
+ # Log cache stats if performance logging is enabled
419
+ if self.config.enable_performance_logging:
420
+ stats = self.get_stats()
421
+ logger.debug(
422
+ f"Cache stats: {stats['size']}/{stats['max_size']} entries, "
423
+ f"{stats['hit_rate']:.1f}% hit rate, "
424
+ f"{stats['evictions']} evictions"
425
+ )
426
+
427
+ except asyncio.CancelledError:
428
+ break
429
+ except Exception as e:
430
+ logger.error(f"Cache cleanup error: {e}")
431
+
432
+ def __repr__(self) -> str:
433
+ """String representation of the cache."""
434
+ with self._lock:
435
+ return (
436
+ f"ResultCache("
437
+ f"size={len(self._cache)}/{self.config.result_cache_max_size}, "
438
+ f"hit_rate={self.metrics.hit_rate:.1f}%, "
439
+ f"enabled={self.config.enable_result_caching}"
440
+ f")"
441
+ )
agentfield/router.py ADDED
@@ -0,0 +1,190 @@
1
+ """AgentRouter provides FastAPI-style organization for agent reasoners and skills."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import inspect
6
+
7
+ from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING: # pragma: no cover
10
+ from .agent import Agent
11
+
12
+
13
+ class AgentRouter:
14
+ """Collects reasoners and skills before registering them on an Agent."""
15
+
16
+ def __init__(self, prefix: str = "", tags: Optional[List[str]] = None):
17
+ self.prefix = prefix.rstrip("/") if prefix else ""
18
+ self.tags = tags or []
19
+ self.reasoners: List[Dict[str, Any]] = []
20
+ self.skills: List[Dict[str, Any]] = []
21
+ self._agent: Optional["Agent"] = None
22
+
23
+ # ------------------------------------------------------------------
24
+ # Registration helpers
25
+ def reasoner(
26
+ self,
27
+ path: Optional[str] = None,
28
+ *,
29
+ tags: Optional[List[str]] = None,
30
+ **kwargs: Any,
31
+ ) -> Callable[[Callable], Callable]:
32
+ """Store a reasoner definition for later registration on an Agent."""
33
+
34
+ direct_registration: Optional[Callable] = None
35
+ decorator_path = path
36
+ decorator_tags = tags
37
+ decorator_kwargs = dict(kwargs)
38
+
39
+ if decorator_path and (
40
+ inspect.isfunction(decorator_path) or inspect.ismethod(decorator_path)
41
+ ):
42
+ direct_registration = decorator_path
43
+ decorator_path = None
44
+
45
+ def decorator(func: Callable) -> Callable:
46
+ merged_tags = self.tags + (decorator_tags or [])
47
+ self.reasoners.append(
48
+ {
49
+ "func": func,
50
+ "path": decorator_path,
51
+ "tags": merged_tags,
52
+ "kwargs": dict(decorator_kwargs),
53
+ "registered": False,
54
+ }
55
+ )
56
+ return func
57
+
58
+ if direct_registration:
59
+ return decorator(direct_registration)
60
+
61
+ return decorator
62
+
63
+ def skill(
64
+ self,
65
+ tags: Optional[List[str]] = None,
66
+ path: Optional[str] = None,
67
+ **kwargs: Any,
68
+ ) -> Callable[[Callable], Callable]:
69
+ """Store a skill definition, merging router and local tags."""
70
+
71
+ direct_registration: Optional[Callable] = None
72
+ decorator_tags = tags
73
+ decorator_path = path
74
+ decorator_kwargs = dict(kwargs)
75
+
76
+ if decorator_tags and (
77
+ inspect.isfunction(decorator_tags) or inspect.ismethod(decorator_tags)
78
+ ):
79
+ direct_registration = decorator_tags
80
+ decorator_tags = None
81
+
82
+ def decorator(func: Callable) -> Callable:
83
+ merged_tags = self.tags + (decorator_tags or [])
84
+ self.skills.append(
85
+ {
86
+ "func": func,
87
+ "path": decorator_path,
88
+ "tags": merged_tags,
89
+ "kwargs": decorator_kwargs,
90
+ "registered": False,
91
+ }
92
+ )
93
+ return func
94
+
95
+ if direct_registration:
96
+ return decorator(direct_registration)
97
+
98
+ return decorator
99
+
100
+ # ------------------------------------------------------------------
101
+ # Automatic delegation via __getattr__
102
+ def __getattr__(self, name: str) -> Any:
103
+ """
104
+ Automatically delegate any unknown attribute/method to the attached agent.
105
+
106
+ This allows AgentRouter to transparently proxy all Agent methods (like ai(),
107
+ call(), memory, note(), discover(), etc.) without explicitly defining
108
+ delegation methods for each one.
109
+
110
+ Args:
111
+ name: The attribute/method name being accessed
112
+
113
+ Returns:
114
+ The attribute/method from the attached agent
115
+
116
+ Raises:
117
+ RuntimeError: If router is not attached to an agent
118
+ AttributeError: If the agent doesn't have the requested attribute
119
+ """
120
+ # Avoid infinite recursion by accessing _agent through object.__getattribute__
121
+ try:
122
+ agent = object.__getattribute__(self, '_agent')
123
+ except AttributeError:
124
+ raise RuntimeError(
125
+ "Router not attached to an agent. Call Agent.include_router(router) first."
126
+ )
127
+
128
+ if agent is None:
129
+ raise RuntimeError(
130
+ "Router not attached to an agent. Call Agent.include_router(router) first."
131
+ )
132
+
133
+ # Delegate to the agent - will raise AttributeError if not found
134
+ return getattr(agent, name)
135
+
136
+ @property
137
+ def app(self) -> "Agent":
138
+ """Access the underlying Agent instance."""
139
+ if not self._agent:
140
+ raise RuntimeError(
141
+ "Router not attached to an agent. Call Agent.include_router(router) first."
142
+ )
143
+ return self._agent
144
+
145
+ # ------------------------------------------------------------------
146
+ # Internal helpers
147
+
148
+ def _combine_path(
149
+ self,
150
+ default: Optional[str],
151
+ custom: Optional[str],
152
+ override_prefix: Optional[str] = None,
153
+ ) -> Optional[str]:
154
+ """Return a normalized API path for a registered function."""
155
+
156
+ if custom and custom.startswith("/"):
157
+ return custom
158
+
159
+ segments: List[str] = []
160
+
161
+ prefixes: List[str] = []
162
+ for prefix in (override_prefix, self.prefix):
163
+ if prefix:
164
+ prefixes.append(prefix.strip("/"))
165
+
166
+ if custom:
167
+ segments.extend(prefixes)
168
+ segments.append(custom.strip("/"))
169
+ elif default:
170
+ stripped = default.strip("/")
171
+ if stripped.startswith("reasoners/") or stripped.startswith("skills/"):
172
+ head, *tail = stripped.split("/")
173
+ segments.append(head)
174
+ segments.extend(prefixes)
175
+ segments.extend(tail)
176
+ else:
177
+ segments.extend(prefixes)
178
+ if stripped:
179
+ segments.append(stripped)
180
+ else:
181
+ segments.extend(prefixes)
182
+
183
+ if not segments:
184
+ return default
185
+
186
+ combined = "/".join(segment for segment in segments if segment)
187
+ return f"/{combined}" if combined else "/"
188
+
189
+ def _attach_agent(self, agent: "Agent") -> None:
190
+ self._agent = agent
agentfield/status.py ADDED
@@ -0,0 +1,70 @@
1
+ """Canonical execution status utilities for the AgentField SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional, Set, Tuple
6
+
7
+ CANONICAL_STATUSES: Tuple[str, ...] = (
8
+ "pending",
9
+ "queued",
10
+ "running",
11
+ "succeeded",
12
+ "failed",
13
+ "cancelled",
14
+ "timeout",
15
+ "unknown",
16
+ )
17
+
18
+ CANONICAL_STATUS_SET: Set[str] = set(CANONICAL_STATUSES)
19
+
20
+ _STATUS_ALIASES = {
21
+ "success": "succeeded",
22
+ "successful": "succeeded",
23
+ "completed": "succeeded",
24
+ "complete": "succeeded",
25
+ "done": "succeeded",
26
+ "ok": "succeeded",
27
+ "error": "failed",
28
+ "failure": "failed",
29
+ "errored": "failed",
30
+ "canceled": "cancelled",
31
+ "cancel": "cancelled",
32
+ "timed_out": "timeout",
33
+ "wait": "queued",
34
+ "waiting": "queued",
35
+ "in_progress": "running",
36
+ "processing": "running",
37
+ }
38
+
39
+ TERMINAL_STATUSES: Set[str] = {"succeeded", "failed", "cancelled", "timeout"}
40
+
41
+
42
+ def normalize_status(status: Optional[str]) -> str:
43
+ """Return the canonical representation of a status string."""
44
+
45
+ if status is None:
46
+ return "unknown"
47
+
48
+ normalized = status.strip().lower()
49
+ if not normalized:
50
+ return "unknown"
51
+
52
+ if normalized in CANONICAL_STATUS_SET:
53
+ return normalized
54
+
55
+ return _STATUS_ALIASES.get(normalized, "unknown")
56
+
57
+
58
+ def is_terminal(status: Optional[str]) -> bool:
59
+ """Return True if the provided status represents a terminal state."""
60
+
61
+ return normalize_status(status) in TERMINAL_STATUSES
62
+
63
+
64
+ __all__ = [
65
+ "CANONICAL_STATUSES",
66
+ "CANONICAL_STATUS_SET",
67
+ "TERMINAL_STATUSES",
68
+ "normalize_status",
69
+ "is_terminal",
70
+ ]