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.
- agentfield/__init__.py +66 -0
- agentfield/agent.py +3569 -0
- agentfield/agent_ai.py +1125 -0
- agentfield/agent_cli.py +386 -0
- agentfield/agent_field_handler.py +494 -0
- agentfield/agent_mcp.py +534 -0
- agentfield/agent_registry.py +29 -0
- agentfield/agent_server.py +1185 -0
- agentfield/agent_utils.py +269 -0
- agentfield/agent_workflow.py +323 -0
- agentfield/async_config.py +278 -0
- agentfield/async_execution_manager.py +1227 -0
- agentfield/client.py +1447 -0
- agentfield/connection_manager.py +280 -0
- agentfield/decorators.py +527 -0
- agentfield/did_manager.py +337 -0
- agentfield/dynamic_skills.py +304 -0
- agentfield/execution_context.py +255 -0
- agentfield/execution_state.py +453 -0
- agentfield/http_connection_manager.py +429 -0
- agentfield/litellm_adapters.py +140 -0
- agentfield/logger.py +249 -0
- agentfield/mcp_client.py +204 -0
- agentfield/mcp_manager.py +340 -0
- agentfield/mcp_stdio_bridge.py +550 -0
- agentfield/memory.py +723 -0
- agentfield/memory_events.py +489 -0
- agentfield/multimodal.py +173 -0
- agentfield/multimodal_response.py +403 -0
- agentfield/pydantic_utils.py +227 -0
- agentfield/rate_limiter.py +280 -0
- agentfield/result_cache.py +441 -0
- agentfield/router.py +190 -0
- agentfield/status.py +70 -0
- agentfield/types.py +710 -0
- agentfield/utils.py +26 -0
- agentfield/vc_generator.py +464 -0
- agentfield/vision.py +198 -0
- agentfield-0.1.22rc2.dist-info/METADATA +102 -0
- agentfield-0.1.22rc2.dist-info/RECORD +42 -0
- agentfield-0.1.22rc2.dist-info/WHEEL +5 -0
- 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
|
+
]
|