agnt5 0.2.8a10__cp310-abi3-manylinux_2_34_x86_64.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 agnt5 might be problematic. Click here for more details.
- agnt5/__init__.py +91 -0
- agnt5/_compat.py +16 -0
- agnt5/_core.abi3.so +0 -0
- agnt5/_retry_utils.py +169 -0
- agnt5/_schema_utils.py +312 -0
- agnt5/_telemetry.py +182 -0
- agnt5/agent.py +1685 -0
- agnt5/client.py +741 -0
- agnt5/context.py +178 -0
- agnt5/entity.py +795 -0
- agnt5/exceptions.py +102 -0
- agnt5/function.py +321 -0
- agnt5/lm.py +813 -0
- agnt5/tool.py +648 -0
- agnt5/tracing.py +196 -0
- agnt5/types.py +110 -0
- agnt5/version.py +19 -0
- agnt5/worker.py +1619 -0
- agnt5/workflow.py +1048 -0
- agnt5-0.2.8a10.dist-info/METADATA +25 -0
- agnt5-0.2.8a10.dist-info/RECORD +22 -0
- agnt5-0.2.8a10.dist-info/WHEEL +4 -0
agnt5/entity.py
ADDED
|
@@ -0,0 +1,795 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entity component for stateful operations with single-writer consistency.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
import json
|
|
10
|
+
from typing import Any, Dict, Optional, Tuple, get_type_hints
|
|
11
|
+
|
|
12
|
+
from ._schema_utils import extract_function_metadata, extract_function_schemas
|
|
13
|
+
from .exceptions import ExecutionError
|
|
14
|
+
from ._telemetry import setup_module_logger
|
|
15
|
+
|
|
16
|
+
logger = setup_module_logger(__name__)
|
|
17
|
+
|
|
18
|
+
# Context variable for worker-scoped state adapter
|
|
19
|
+
# This is set by Worker before entity execution and accessed by Entity instances
|
|
20
|
+
_entity_state_adapter_ctx: contextvars.ContextVar[Optional["EntityStateAdapter"]] = \
|
|
21
|
+
contextvars.ContextVar('_entity_state_adapter', default=None)
|
|
22
|
+
|
|
23
|
+
# Global entity registry
|
|
24
|
+
_ENTITY_REGISTRY: Dict[str, "EntityType"] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EntityStateAdapter:
|
|
28
|
+
"""
|
|
29
|
+
Thin Python adapter providing Pythonic interface to Rust EntityStateManager core.
|
|
30
|
+
|
|
31
|
+
This adapter provides language-specific concerns only:
|
|
32
|
+
- Worker-local asyncio.Lock for coarse-grained coordination
|
|
33
|
+
- Type conversions between Python dict and JSON bytes
|
|
34
|
+
- Pythonic async/await API over Rust core
|
|
35
|
+
|
|
36
|
+
All business logic (caching, version tracking, retry logic, gRPC) lives in the Rust core.
|
|
37
|
+
This keeps the Python layer simple (~150 LOC) and enables sharing business logic across SDKs.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, rust_core=None):
|
|
41
|
+
"""
|
|
42
|
+
Initialize entity state adapter.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
rust_core: Rust EntityStateManager instance (from _core module).
|
|
46
|
+
If None, operates in standalone/testing mode with in-memory state.
|
|
47
|
+
"""
|
|
48
|
+
self._rust_core = rust_core
|
|
49
|
+
# Worker-local locks for coarse-grained coordination within this worker
|
|
50
|
+
self._local_locks: Dict[Tuple[str, str], asyncio.Lock] = {}
|
|
51
|
+
|
|
52
|
+
# Standalone mode: in-memory state storage when no Rust core
|
|
53
|
+
# This enables testing without the full platform stack
|
|
54
|
+
if rust_core is None:
|
|
55
|
+
self._standalone_states: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
|
56
|
+
self._standalone_versions: Dict[Tuple[str, str], int] = {}
|
|
57
|
+
logger.debug("Created EntityStateAdapter in standalone mode (in-memory state)")
|
|
58
|
+
else:
|
|
59
|
+
logger.debug("Created EntityStateAdapter with Rust core")
|
|
60
|
+
|
|
61
|
+
def get_local_lock(self, state_key: Tuple[str, str]) -> asyncio.Lock:
|
|
62
|
+
"""
|
|
63
|
+
Get worker-local asyncio.Lock for single-writer guarantee within this worker.
|
|
64
|
+
|
|
65
|
+
This provides coarse-grained coordination for operations within the same worker.
|
|
66
|
+
Cross-worker conflicts are handled by the Rust core via optimistic concurrency.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
state_key: Tuple of (entity_type, entity_key)
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
asyncio.Lock for this worker-local operation
|
|
73
|
+
"""
|
|
74
|
+
if state_key not in self._local_locks:
|
|
75
|
+
self._local_locks[state_key] = asyncio.Lock()
|
|
76
|
+
return self._local_locks[state_key]
|
|
77
|
+
|
|
78
|
+
async def load_state(self, entity_type: str, entity_key: str) -> Dict[str, Any]:
|
|
79
|
+
"""
|
|
80
|
+
Load entity state (Rust handles cache-first logic and platform load).
|
|
81
|
+
|
|
82
|
+
In standalone mode (no Rust core), uses in-memory state storage.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
entity_type: Type of entity (e.g., "ShoppingCart", "Counter")
|
|
86
|
+
entity_key: Unique key for entity instance
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
State dictionary (empty dict if not found)
|
|
90
|
+
"""
|
|
91
|
+
if not self._rust_core:
|
|
92
|
+
# Standalone mode - return from in-memory storage
|
|
93
|
+
state_key = (entity_type, entity_key)
|
|
94
|
+
return self._standalone_states.get(state_key, {}).copy()
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
# Rust checks cache first, loads from platform if needed
|
|
98
|
+
state_json_bytes, version = await self._rust_core.py_get_cached_or_load(entity_type, entity_key)
|
|
99
|
+
|
|
100
|
+
# Convert bytes to dict
|
|
101
|
+
if state_json_bytes:
|
|
102
|
+
state_json = state_json_bytes.decode('utf-8') if isinstance(state_json_bytes, bytes) else state_json_bytes
|
|
103
|
+
return json.loads(state_json)
|
|
104
|
+
else:
|
|
105
|
+
return {}
|
|
106
|
+
except Exception as e:
|
|
107
|
+
logger.warning(f"Failed to load state for {entity_type}:{entity_key}: {e}")
|
|
108
|
+
return {}
|
|
109
|
+
|
|
110
|
+
async def save_state(
|
|
111
|
+
self,
|
|
112
|
+
entity_type: str,
|
|
113
|
+
entity_key: str,
|
|
114
|
+
state: Dict[str, Any],
|
|
115
|
+
expected_version: int
|
|
116
|
+
) -> int:
|
|
117
|
+
"""
|
|
118
|
+
Save entity state (Rust handles version check and platform save).
|
|
119
|
+
|
|
120
|
+
In standalone mode (no Rust core), stores in-memory with version tracking.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
entity_type: Type of entity
|
|
124
|
+
entity_key: Unique key for entity instance
|
|
125
|
+
state: State dictionary to save
|
|
126
|
+
expected_version: Expected current version (for optimistic locking)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
New version number after save
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
RuntimeError: If version conflict or platform error
|
|
133
|
+
"""
|
|
134
|
+
if not self._rust_core:
|
|
135
|
+
# Standalone mode - store in memory with version tracking
|
|
136
|
+
state_key = (entity_type, entity_key)
|
|
137
|
+
current_version = self._standalone_versions.get(state_key, 0)
|
|
138
|
+
|
|
139
|
+
# Optimistic locking check (even in standalone mode for consistency)
|
|
140
|
+
if current_version != expected_version:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"Version conflict: expected {expected_version}, got {current_version}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
# Store state and increment version
|
|
146
|
+
new_version = expected_version + 1
|
|
147
|
+
self._standalone_states[state_key] = state.copy()
|
|
148
|
+
self._standalone_versions[state_key] = new_version
|
|
149
|
+
return new_version
|
|
150
|
+
|
|
151
|
+
# Convert dict to JSON bytes
|
|
152
|
+
state_json = json.dumps(state).encode('utf-8')
|
|
153
|
+
|
|
154
|
+
# Rust handles optimistic locking and platform save
|
|
155
|
+
new_version = await self._rust_core.py_save_state(
|
|
156
|
+
entity_type,
|
|
157
|
+
entity_key,
|
|
158
|
+
state_json,
|
|
159
|
+
expected_version
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return new_version
|
|
163
|
+
|
|
164
|
+
async def load_with_version(self, entity_type: str, entity_key: str) -> Tuple[Dict[str, Any], int]:
|
|
165
|
+
"""
|
|
166
|
+
Load entity state with version (for update operations).
|
|
167
|
+
|
|
168
|
+
In standalone mode (no Rust core), loads from in-memory storage with version.
|
|
169
|
+
|
|
170
|
+
Args:
|
|
171
|
+
entity_type: Type of entity
|
|
172
|
+
entity_key: Unique key for entity instance
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Tuple of (state_dict, version)
|
|
176
|
+
"""
|
|
177
|
+
if not self._rust_core:
|
|
178
|
+
# Standalone mode - return from in-memory storage with version
|
|
179
|
+
state_key = (entity_type, entity_key)
|
|
180
|
+
state = self._standalone_states.get(state_key, {}).copy()
|
|
181
|
+
version = self._standalone_versions.get(state_key, 0)
|
|
182
|
+
return state, version
|
|
183
|
+
|
|
184
|
+
try:
|
|
185
|
+
state_json_bytes, version = await self._rust_core.py_get_cached_or_load(entity_type, entity_key)
|
|
186
|
+
|
|
187
|
+
if state_json_bytes:
|
|
188
|
+
state_json = state_json_bytes.decode('utf-8') if isinstance(state_json_bytes, bytes) else state_json_bytes
|
|
189
|
+
state = json.loads(state_json)
|
|
190
|
+
else:
|
|
191
|
+
state = {}
|
|
192
|
+
|
|
193
|
+
return state, version
|
|
194
|
+
except Exception as e:
|
|
195
|
+
logger.warning(f"Failed to load state with version for {entity_type}:{entity_key}: {e}")
|
|
196
|
+
return {}, 0
|
|
197
|
+
|
|
198
|
+
async def invalidate_cache(self, entity_type: str, entity_key: str) -> None:
|
|
199
|
+
"""
|
|
200
|
+
Invalidate cache entry for specific entity.
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
entity_type: Type of entity
|
|
204
|
+
entity_key: Unique key for entity instance
|
|
205
|
+
"""
|
|
206
|
+
if self._rust_core:
|
|
207
|
+
await self._rust_core.py_invalidate_cache(entity_type, entity_key)
|
|
208
|
+
|
|
209
|
+
async def clear_cache(self) -> None:
|
|
210
|
+
"""Clear entire cache (useful for testing)."""
|
|
211
|
+
if self._rust_core:
|
|
212
|
+
await self._rust_core.py_clear_cache()
|
|
213
|
+
|
|
214
|
+
def clear_all(self) -> None:
|
|
215
|
+
"""Clear all local locks (for testing)."""
|
|
216
|
+
self._local_locks.clear()
|
|
217
|
+
logger.debug("Cleared EntityStateAdapter local locks")
|
|
218
|
+
|
|
219
|
+
async def get_state(self, entity_type: str, key: str) -> Optional[Dict[str, Any]]:
|
|
220
|
+
"""Get state for debugging/testing."""
|
|
221
|
+
state, _ = await self.load_with_version(entity_type, key)
|
|
222
|
+
return state if state else None
|
|
223
|
+
|
|
224
|
+
def get_all_keys(self, entity_type: str) -> list[str]:
|
|
225
|
+
"""
|
|
226
|
+
Get all keys for an entity type (testing/debugging only).
|
|
227
|
+
|
|
228
|
+
Only works in standalone mode. Returns empty list in production mode.
|
|
229
|
+
"""
|
|
230
|
+
if not hasattr(self, '_standalone_states'):
|
|
231
|
+
return []
|
|
232
|
+
|
|
233
|
+
keys = []
|
|
234
|
+
for (etype, ekey) in self._standalone_states.keys():
|
|
235
|
+
if etype == entity_type:
|
|
236
|
+
keys.append(ekey)
|
|
237
|
+
return keys
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _get_state_adapter() -> EntityStateAdapter:
|
|
241
|
+
"""
|
|
242
|
+
Get the current entity state adapter from context.
|
|
243
|
+
|
|
244
|
+
The state adapter must be set by Worker before entity execution.
|
|
245
|
+
This ensures proper worker-scoped state isolation.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
EntityStateAdapter instance
|
|
249
|
+
|
|
250
|
+
Raises:
|
|
251
|
+
RuntimeError: If called outside of Worker context (state adapter not set)
|
|
252
|
+
"""
|
|
253
|
+
adapter = _entity_state_adapter_ctx.get()
|
|
254
|
+
if adapter is None:
|
|
255
|
+
raise RuntimeError(
|
|
256
|
+
"Entity requires state adapter context.\n\n"
|
|
257
|
+
"In production:\n"
|
|
258
|
+
" Entities run automatically through Worker.\n\n"
|
|
259
|
+
"In tests, use one of:\n"
|
|
260
|
+
" Option 1 - Decorator:\n"
|
|
261
|
+
" @with_entity_context\n"
|
|
262
|
+
" async def test_cart():\n"
|
|
263
|
+
" cart = ShoppingCart('key')\n"
|
|
264
|
+
" await cart.add_item(...)\n\n"
|
|
265
|
+
" Option 2 - Fixture:\n"
|
|
266
|
+
" async def test_cart(entity_context):\n"
|
|
267
|
+
" cart = ShoppingCart('key')\n"
|
|
268
|
+
" await cart.add_item(...)\n\n"
|
|
269
|
+
"See: https://docs.agnt5.dev/sdk/entities#testing"
|
|
270
|
+
)
|
|
271
|
+
return adapter
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
# ============================================================================
|
|
277
|
+
# Testing Helpers
|
|
278
|
+
# ============================================================================
|
|
279
|
+
|
|
280
|
+
def with_entity_context(func):
|
|
281
|
+
"""
|
|
282
|
+
Decorator that sets up entity state adapter for tests.
|
|
283
|
+
|
|
284
|
+
Usage:
|
|
285
|
+
@with_entity_context
|
|
286
|
+
async def test_shopping_cart():
|
|
287
|
+
cart = ShoppingCart(key="test")
|
|
288
|
+
await cart.add_item("item", 1, 10.0)
|
|
289
|
+
assert cart.state.get("items")
|
|
290
|
+
"""
|
|
291
|
+
@functools.wraps(func)
|
|
292
|
+
async def wrapper(*args, **kwargs):
|
|
293
|
+
adapter = EntityStateAdapter()
|
|
294
|
+
token = _entity_state_adapter_ctx.set(adapter)
|
|
295
|
+
try:
|
|
296
|
+
return await func(*args, **kwargs)
|
|
297
|
+
finally:
|
|
298
|
+
_entity_state_adapter_ctx.reset(token)
|
|
299
|
+
adapter.clear_all()
|
|
300
|
+
return wrapper
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def create_entity_context():
|
|
304
|
+
"""
|
|
305
|
+
Create an entity context for testing (can be used as pytest fixture).
|
|
306
|
+
|
|
307
|
+
Usage in conftest.py or test file:
|
|
308
|
+
import pytest
|
|
309
|
+
from agnt5.entity import create_entity_context
|
|
310
|
+
|
|
311
|
+
@pytest.fixture
|
|
312
|
+
def entity_context():
|
|
313
|
+
adapter, token = create_entity_context()
|
|
314
|
+
yield adapter
|
|
315
|
+
# Cleanup happens automatically
|
|
316
|
+
|
|
317
|
+
Returns:
|
|
318
|
+
Tuple of (EntityStateAdapter, context_token)
|
|
319
|
+
"""
|
|
320
|
+
adapter = EntityStateAdapter()
|
|
321
|
+
token = _entity_state_adapter_ctx.set(adapter)
|
|
322
|
+
return adapter, token
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def extract_state_schema(entity_class: type) -> Optional[Dict[str, Any]]:
|
|
326
|
+
"""
|
|
327
|
+
Extract JSON schema from entity class for state structure documentation.
|
|
328
|
+
|
|
329
|
+
The schema can be provided in multiple ways (in order of preference):
|
|
330
|
+
1. Explicit _state_schema class attribute (most explicit)
|
|
331
|
+
2. Docstring with state description
|
|
332
|
+
3. Type annotations on __init__ method (least explicit, basic types only)
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
entity_class: The Entity subclass to extract schema from
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
JSON schema dict or None if no schema could be extracted
|
|
339
|
+
|
|
340
|
+
Examples:
|
|
341
|
+
# Option 1: Explicit schema (recommended)
|
|
342
|
+
class ShoppingCart(Entity):
|
|
343
|
+
_state_schema = {
|
|
344
|
+
"type": "object",
|
|
345
|
+
"properties": {
|
|
346
|
+
"items": {"type": "array", "description": "Cart items"},
|
|
347
|
+
"total": {"type": "number", "description": "Cart total"}
|
|
348
|
+
},
|
|
349
|
+
"description": "Shopping cart state"
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
# Option 2: Docstring
|
|
353
|
+
class ShoppingCart(Entity):
|
|
354
|
+
'''
|
|
355
|
+
Shopping cart entity.
|
|
356
|
+
|
|
357
|
+
State:
|
|
358
|
+
items (list): List of cart items
|
|
359
|
+
total (float): Total cart value
|
|
360
|
+
'''
|
|
361
|
+
|
|
362
|
+
# Option 3: Type hints (basic extraction)
|
|
363
|
+
class ShoppingCart(Entity):
|
|
364
|
+
def __init__(self, key: str):
|
|
365
|
+
super().__init__(key)
|
|
366
|
+
self.items: list = []
|
|
367
|
+
self.total: float = 0.0
|
|
368
|
+
"""
|
|
369
|
+
# Option 1: Check for explicit _state_schema attribute
|
|
370
|
+
if hasattr(entity_class, '_state_schema'):
|
|
371
|
+
schema = entity_class._state_schema
|
|
372
|
+
logger.debug(f"Found explicit _state_schema for {entity_class.__name__}")
|
|
373
|
+
return schema
|
|
374
|
+
|
|
375
|
+
# Option 2: Extract from docstring (basic parsing)
|
|
376
|
+
if entity_class.__doc__:
|
|
377
|
+
doc = entity_class.__doc__.strip()
|
|
378
|
+
if "State:" in doc or "state:" in doc.lower():
|
|
379
|
+
# Found state documentation - create basic schema
|
|
380
|
+
logger.debug(f"Found state documentation in docstring for {entity_class.__name__}")
|
|
381
|
+
return {
|
|
382
|
+
"type": "object",
|
|
383
|
+
"description": f"State structure for {entity_class.__name__} (see docstring for details)"
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
# Option 3: Try to extract from __init__ type hints (very basic)
|
|
387
|
+
try:
|
|
388
|
+
init_method = entity_class.__init__
|
|
389
|
+
type_hints = get_type_hints(init_method)
|
|
390
|
+
# Remove 'key' and 'return' from hints
|
|
391
|
+
state_hints = {k: v for k, v in type_hints.items() if k not in ('key', 'return')}
|
|
392
|
+
|
|
393
|
+
if state_hints:
|
|
394
|
+
logger.debug(f"Extracted type hints from __init__ for {entity_class.__name__}")
|
|
395
|
+
properties = {}
|
|
396
|
+
for name, type_hint in state_hints.items():
|
|
397
|
+
# Basic type mapping
|
|
398
|
+
if type_hint == str:
|
|
399
|
+
properties[name] = {"type": "string"}
|
|
400
|
+
elif type_hint == int:
|
|
401
|
+
properties[name] = {"type": "integer"}
|
|
402
|
+
elif type_hint == float:
|
|
403
|
+
properties[name] = {"type": "number"}
|
|
404
|
+
elif type_hint == bool:
|
|
405
|
+
properties[name] = {"type": "boolean"}
|
|
406
|
+
elif type_hint == list or str(type_hint).startswith('list'):
|
|
407
|
+
properties[name] = {"type": "array"}
|
|
408
|
+
elif type_hint == dict or str(type_hint).startswith('dict'):
|
|
409
|
+
properties[name] = {"type": "object"}
|
|
410
|
+
else:
|
|
411
|
+
properties[name] = {"type": "object", "description": str(type_hint)}
|
|
412
|
+
|
|
413
|
+
if properties:
|
|
414
|
+
return {
|
|
415
|
+
"type": "object",
|
|
416
|
+
"properties": properties,
|
|
417
|
+
"description": f"State structure inferred from type hints for {entity_class.__name__}"
|
|
418
|
+
}
|
|
419
|
+
except Exception as e:
|
|
420
|
+
logger.debug(f"Could not extract type hints from {entity_class.__name__}: {e}")
|
|
421
|
+
|
|
422
|
+
# No schema could be extracted
|
|
423
|
+
logger.debug(f"No state schema found for {entity_class.__name__}")
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class EntityRegistry:
|
|
428
|
+
"""Registry for entity types."""
|
|
429
|
+
|
|
430
|
+
@staticmethod
|
|
431
|
+
def register(entity_type: "EntityType") -> None:
|
|
432
|
+
"""Register an entity type."""
|
|
433
|
+
if entity_type.name in _ENTITY_REGISTRY:
|
|
434
|
+
logger.warning(f"Overwriting existing entity type '{entity_type.name}'")
|
|
435
|
+
_ENTITY_REGISTRY[entity_type.name] = entity_type
|
|
436
|
+
logger.debug(f"Registered entity type '{entity_type.name}'")
|
|
437
|
+
|
|
438
|
+
@staticmethod
|
|
439
|
+
def get(name: str) -> Optional["EntityType"]:
|
|
440
|
+
"""Get entity type by name."""
|
|
441
|
+
return _ENTITY_REGISTRY.get(name)
|
|
442
|
+
|
|
443
|
+
@staticmethod
|
|
444
|
+
def all() -> Dict[str, "EntityType"]:
|
|
445
|
+
"""Get all registered entities."""
|
|
446
|
+
return _ENTITY_REGISTRY.copy()
|
|
447
|
+
|
|
448
|
+
@staticmethod
|
|
449
|
+
def clear() -> None:
|
|
450
|
+
"""Clear all registered entities."""
|
|
451
|
+
_ENTITY_REGISTRY.clear()
|
|
452
|
+
logger.debug("Cleared entity registry")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
class EntityType:
|
|
456
|
+
"""
|
|
457
|
+
Metadata about an Entity class.
|
|
458
|
+
|
|
459
|
+
Stores entity name, method schemas, state schema, and metadata for Worker auto-discovery
|
|
460
|
+
and platform integration. Created automatically when Entity subclasses are defined.
|
|
461
|
+
"""
|
|
462
|
+
|
|
463
|
+
def __init__(self, name: str, entity_class: type):
|
|
464
|
+
"""
|
|
465
|
+
Initialize entity type metadata.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
name: Entity type name (class name)
|
|
469
|
+
entity_class: Reference to the Entity class
|
|
470
|
+
"""
|
|
471
|
+
self.name = name
|
|
472
|
+
self.entity_class = entity_class
|
|
473
|
+
self._method_schemas: Dict[str, Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]] = {}
|
|
474
|
+
self._method_metadata: Dict[str, Dict[str, str]] = {}
|
|
475
|
+
self._state_schema: Optional[Dict[str, Any]] = None
|
|
476
|
+
logger.debug("Created entity type: %s", name)
|
|
477
|
+
|
|
478
|
+
def set_state_schema(self, schema: Optional[Dict[str, Any]]) -> None:
|
|
479
|
+
"""
|
|
480
|
+
Set the state schema for this entity type.
|
|
481
|
+
|
|
482
|
+
Args:
|
|
483
|
+
schema: JSON schema describing the entity's state structure
|
|
484
|
+
"""
|
|
485
|
+
self._state_schema = schema
|
|
486
|
+
if schema:
|
|
487
|
+
logger.debug(f"Set state schema for {self.name}")
|
|
488
|
+
|
|
489
|
+
def build_entity_definition(self) -> Dict[str, Any]:
|
|
490
|
+
"""
|
|
491
|
+
Build complete entity definition for platform registration.
|
|
492
|
+
|
|
493
|
+
Returns:
|
|
494
|
+
Dictionary with entity name, state schema, and method schemas
|
|
495
|
+
"""
|
|
496
|
+
# Build method schemas dict
|
|
497
|
+
method_schemas = {}
|
|
498
|
+
for method_name, (input_schema, output_schema) in self._method_schemas.items():
|
|
499
|
+
method_metadata = self._method_metadata.get(method_name, {})
|
|
500
|
+
method_schemas[method_name] = {
|
|
501
|
+
"input_schema": input_schema,
|
|
502
|
+
"output_schema": output_schema,
|
|
503
|
+
"description": method_metadata.get("description", ""),
|
|
504
|
+
"metadata": method_metadata
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
# Build complete definition
|
|
508
|
+
definition = {
|
|
509
|
+
"entity_name": self.name,
|
|
510
|
+
"methods": method_schemas
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
# Add state schema if available
|
|
514
|
+
if self._state_schema:
|
|
515
|
+
definition["state_schema"] = self._state_schema
|
|
516
|
+
|
|
517
|
+
return definition
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
# ============================================================================
|
|
521
|
+
# Class-Based Entity API (Cloudflare Durable Objects style)
|
|
522
|
+
# ============================================================================
|
|
523
|
+
|
|
524
|
+
class EntityState:
|
|
525
|
+
"""
|
|
526
|
+
Simple state interface for Entity instances.
|
|
527
|
+
|
|
528
|
+
Provides a clean API for state management:
|
|
529
|
+
self.state.get(key, default)
|
|
530
|
+
self.state.set(key, value)
|
|
531
|
+
self.state.delete(key)
|
|
532
|
+
self.state.clear()
|
|
533
|
+
|
|
534
|
+
State operations are synchronous and backed by an internal dict.
|
|
535
|
+
"""
|
|
536
|
+
|
|
537
|
+
def __init__(self, state_dict: Dict[str, Any]):
|
|
538
|
+
"""
|
|
539
|
+
Initialize state wrapper with a state dict.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
state_dict: Dictionary to use for state storage
|
|
543
|
+
"""
|
|
544
|
+
self._state = state_dict
|
|
545
|
+
|
|
546
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
547
|
+
"""Get value from state."""
|
|
548
|
+
return self._state.get(key, default)
|
|
549
|
+
|
|
550
|
+
def set(self, key: str, value: Any) -> None:
|
|
551
|
+
"""Set value in state."""
|
|
552
|
+
self._state[key] = value
|
|
553
|
+
|
|
554
|
+
def delete(self, key: str) -> None:
|
|
555
|
+
"""Delete key from state."""
|
|
556
|
+
self._state.pop(key, None)
|
|
557
|
+
|
|
558
|
+
def clear(self) -> None:
|
|
559
|
+
"""Clear all state."""
|
|
560
|
+
self._state.clear()
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _create_entity_method_wrapper(entity_type: str, method):
|
|
564
|
+
"""
|
|
565
|
+
Create a wrapper for an entity method that provides single-writer consistency.
|
|
566
|
+
|
|
567
|
+
This wrapper implements hybrid locking:
|
|
568
|
+
1. Local lock (asyncio.Lock) for worker-scoped single-writer guarantee
|
|
569
|
+
2. Optimistic concurrency (via Rust) for cross-worker conflicts
|
|
570
|
+
3. Loads state via adapter (Rust handles cache + platform)
|
|
571
|
+
4. Executes the method with clean EntityState interface
|
|
572
|
+
5. Saves state via adapter (Rust handles version check + retry)
|
|
573
|
+
|
|
574
|
+
Args:
|
|
575
|
+
entity_type: Name of the entity type (class name)
|
|
576
|
+
method: The async method to wrap
|
|
577
|
+
|
|
578
|
+
Returns:
|
|
579
|
+
Wrapped async method with single-writer consistency
|
|
580
|
+
"""
|
|
581
|
+
@functools.wraps(method)
|
|
582
|
+
async def entity_method_wrapper(self, *args, **kwargs):
|
|
583
|
+
"""Execute entity method with hybrid locking (local + optimistic)."""
|
|
584
|
+
state_key = (entity_type, self._key)
|
|
585
|
+
|
|
586
|
+
# Get state adapter
|
|
587
|
+
adapter = _get_state_adapter()
|
|
588
|
+
|
|
589
|
+
# Local lock for worker-scoped single-writer guarantee
|
|
590
|
+
lock = adapter.get_local_lock(state_key)
|
|
591
|
+
|
|
592
|
+
async with lock:
|
|
593
|
+
# Load state with version (Rust handles cache-first + platform load)
|
|
594
|
+
state_dict, current_version = await adapter.load_with_version(entity_type, self._key)
|
|
595
|
+
|
|
596
|
+
logger.debug(
|
|
597
|
+
"Loaded state for %s:%s (version %d)",
|
|
598
|
+
entity_type, self._key, current_version
|
|
599
|
+
)
|
|
600
|
+
|
|
601
|
+
# Set up EntityState on instance for method access
|
|
602
|
+
self._state = EntityState(state_dict)
|
|
603
|
+
|
|
604
|
+
try:
|
|
605
|
+
# Execute method
|
|
606
|
+
logger.debug("Executing %s:%s.%s", entity_type, self._key, method.__name__)
|
|
607
|
+
result = await method(self, *args, **kwargs)
|
|
608
|
+
logger.debug("Completed %s:%s.%s", entity_type, self._key, method.__name__)
|
|
609
|
+
|
|
610
|
+
# Save state after successful execution
|
|
611
|
+
# Rust handles optimistic locking (version check)
|
|
612
|
+
try:
|
|
613
|
+
new_version = await adapter.save_state(
|
|
614
|
+
entity_type,
|
|
615
|
+
self._key,
|
|
616
|
+
state_dict,
|
|
617
|
+
current_version
|
|
618
|
+
)
|
|
619
|
+
logger.info(
|
|
620
|
+
"Saved state for %s:%s (version %d -> %d)",
|
|
621
|
+
entity_type, self._key, current_version, new_version
|
|
622
|
+
)
|
|
623
|
+
except Exception as e:
|
|
624
|
+
logger.error(
|
|
625
|
+
"Failed to save state for %s:%s: %s",
|
|
626
|
+
entity_type, self._key, e
|
|
627
|
+
)
|
|
628
|
+
# Don't fail the method execution just because persistence failed
|
|
629
|
+
# The state is still in the local dict for this execution
|
|
630
|
+
|
|
631
|
+
return result
|
|
632
|
+
|
|
633
|
+
except Exception as e:
|
|
634
|
+
logger.error(
|
|
635
|
+
"Error in %s:%s.%s: %s",
|
|
636
|
+
entity_type, self._key, method.__name__, e,
|
|
637
|
+
exc_info=True
|
|
638
|
+
)
|
|
639
|
+
raise ExecutionError(
|
|
640
|
+
f"Entity method {method.__name__} failed: {e}"
|
|
641
|
+
) from e
|
|
642
|
+
finally:
|
|
643
|
+
# Clear state reference after execution
|
|
644
|
+
self._state = None
|
|
645
|
+
|
|
646
|
+
return entity_method_wrapper
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class Entity:
|
|
650
|
+
"""
|
|
651
|
+
Base class for stateful entities with single-writer consistency.
|
|
652
|
+
|
|
653
|
+
Entities provide a class-based API where:
|
|
654
|
+
- State is accessed via self.state (clean, synchronous API)
|
|
655
|
+
- Methods are regular async methods on the class
|
|
656
|
+
- Each instance is bound to a unique key
|
|
657
|
+
- Single-writer consistency per key is guaranteed automatically
|
|
658
|
+
|
|
659
|
+
Example:
|
|
660
|
+
```python
|
|
661
|
+
from agnt5 import Entity
|
|
662
|
+
|
|
663
|
+
class ShoppingCart(Entity):
|
|
664
|
+
async def add_item(self, item_id: str, quantity: int, price: float) -> dict:
|
|
665
|
+
items = self.state.get("items", {})
|
|
666
|
+
items[item_id] = {"quantity": quantity, "price": price}
|
|
667
|
+
self.state.set("items", items)
|
|
668
|
+
return {"total_items": len(items)}
|
|
669
|
+
|
|
670
|
+
async def get_total(self) -> float:
|
|
671
|
+
items = self.state.get("items", {})
|
|
672
|
+
return sum(item["quantity"] * item["price"] for item in items.values())
|
|
673
|
+
|
|
674
|
+
# Usage
|
|
675
|
+
cart = ShoppingCart(key="user-123")
|
|
676
|
+
await cart.add_item("item-abc", quantity=2, price=29.99)
|
|
677
|
+
total = await cart.get_total()
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
Note:
|
|
681
|
+
Methods are automatically wrapped to provide single-writer consistency per key.
|
|
682
|
+
State operations are synchronous for simplicity.
|
|
683
|
+
"""
|
|
684
|
+
|
|
685
|
+
def __init__(self, key: str):
|
|
686
|
+
"""
|
|
687
|
+
Initialize an entity instance.
|
|
688
|
+
|
|
689
|
+
Args:
|
|
690
|
+
key: Unique identifier for this entity instance
|
|
691
|
+
"""
|
|
692
|
+
self._key = key
|
|
693
|
+
self._entity_type = self.__class__.__name__
|
|
694
|
+
self._state_key = (self._entity_type, key)
|
|
695
|
+
|
|
696
|
+
# State will be initialized during method execution by wrapper
|
|
697
|
+
self._state = None
|
|
698
|
+
|
|
699
|
+
logger.debug("Created Entity instance: %s:%s", self._entity_type, key)
|
|
700
|
+
|
|
701
|
+
@property
|
|
702
|
+
def state(self) -> EntityState:
|
|
703
|
+
"""
|
|
704
|
+
Get the state interface for this entity.
|
|
705
|
+
|
|
706
|
+
Available operations:
|
|
707
|
+
- self.state.get(key, default)
|
|
708
|
+
- self.state.set(key, value)
|
|
709
|
+
- self.state.delete(key)
|
|
710
|
+
- self.state.clear()
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
EntityState for synchronous state operations
|
|
714
|
+
|
|
715
|
+
Raises:
|
|
716
|
+
RuntimeError: If accessed outside of an entity method
|
|
717
|
+
"""
|
|
718
|
+
if self._state is None:
|
|
719
|
+
raise RuntimeError(
|
|
720
|
+
f"Entity state can only be accessed within entity methods.\n\n"
|
|
721
|
+
f"You tried to access state on {self._entity_type}(key='{self._key}') "
|
|
722
|
+
f"outside of a method call.\n\n"
|
|
723
|
+
f"❌ Wrong:\n"
|
|
724
|
+
f" cart = ShoppingCart(key='user-123')\n"
|
|
725
|
+
f" items = cart.state.get('items') # Error!\n\n"
|
|
726
|
+
f"✅ Correct:\n"
|
|
727
|
+
f" class ShoppingCart(Entity):\n"
|
|
728
|
+
f" async def get_items(self):\n"
|
|
729
|
+
f" return self.state.get('items', {{}}) # Works!\n\n"
|
|
730
|
+
f" cart = ShoppingCart(key='user-123')\n"
|
|
731
|
+
f" items = await cart.get_items() # Call method instead"
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
# Type narrowing: after the raise, self._state is guaranteed to be not None
|
|
735
|
+
assert self._state is not None
|
|
736
|
+
return self._state
|
|
737
|
+
|
|
738
|
+
@property
|
|
739
|
+
def key(self) -> str:
|
|
740
|
+
"""Get the entity instance key."""
|
|
741
|
+
return self._key
|
|
742
|
+
|
|
743
|
+
@property
|
|
744
|
+
def entity_type(self) -> str:
|
|
745
|
+
"""Get the entity type name."""
|
|
746
|
+
return self._entity_type
|
|
747
|
+
|
|
748
|
+
def __init_subclass__(cls, **kwargs):
|
|
749
|
+
"""
|
|
750
|
+
Auto-register Entity subclasses and wrap methods.
|
|
751
|
+
|
|
752
|
+
This is called automatically when a class inherits from Entity.
|
|
753
|
+
It performs three tasks:
|
|
754
|
+
1. Extracts state schema from the class
|
|
755
|
+
2. Wraps all public async methods with single-writer consistency
|
|
756
|
+
3. Registers the entity type with metadata for platform discovery
|
|
757
|
+
"""
|
|
758
|
+
super().__init_subclass__(**kwargs)
|
|
759
|
+
|
|
760
|
+
# Don't register the base Entity class itself
|
|
761
|
+
if cls.__name__ == 'Entity':
|
|
762
|
+
return
|
|
763
|
+
|
|
764
|
+
# Don't register SDK's built-in base classes (these are meant to be extended by users)
|
|
765
|
+
if cls.__name__ in ('SessionEntity', 'MemoryEntity'):
|
|
766
|
+
return
|
|
767
|
+
|
|
768
|
+
# Create an EntityType for this class, storing the class reference
|
|
769
|
+
entity_type = EntityType(cls.__name__, entity_class=cls)
|
|
770
|
+
|
|
771
|
+
# Extract and set state schema
|
|
772
|
+
state_schema = extract_state_schema(cls)
|
|
773
|
+
if state_schema:
|
|
774
|
+
entity_type.set_state_schema(state_schema)
|
|
775
|
+
logger.debug(f"Extracted state schema for {cls.__name__}")
|
|
776
|
+
|
|
777
|
+
# Wrap all public async methods and register them
|
|
778
|
+
for name, method in inspect.getmembers(cls, predicate=inspect.iscoroutinefunction):
|
|
779
|
+
if not name.startswith('_'):
|
|
780
|
+
# Extract schemas from the method
|
|
781
|
+
input_schema, output_schema = extract_function_schemas(method)
|
|
782
|
+
method_metadata = extract_function_metadata(method)
|
|
783
|
+
|
|
784
|
+
# Store in entity type
|
|
785
|
+
entity_type._method_schemas[name] = (input_schema, output_schema)
|
|
786
|
+
entity_type._method_metadata[name] = method_metadata
|
|
787
|
+
|
|
788
|
+
# Wrap the method with single-writer consistency
|
|
789
|
+
# This happens once at class definition time (not per-call)
|
|
790
|
+
wrapped_method = _create_entity_method_wrapper(cls.__name__, method)
|
|
791
|
+
setattr(cls, name, wrapped_method)
|
|
792
|
+
|
|
793
|
+
# Register the entity type
|
|
794
|
+
EntityRegistry.register(entity_type)
|
|
795
|
+
logger.debug(f"Auto-registered Entity subclass: {cls.__name__}")
|