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