agnt5 0.3.0a8__cp310-abi3-manylinux_2_17_x86_64.manylinux2014_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 +119 -0
- agnt5/_compat.py +16 -0
- agnt5/_core.abi3.so +0 -0
- agnt5/_retry_utils.py +172 -0
- agnt5/_schema_utils.py +312 -0
- agnt5/_sentry.py +515 -0
- agnt5/_telemetry.py +191 -0
- agnt5/agent/__init__.py +48 -0
- agnt5/agent/context.py +458 -0
- agnt5/agent/core.py +1793 -0
- agnt5/agent/decorator.py +112 -0
- agnt5/agent/handoff.py +105 -0
- agnt5/agent/registry.py +68 -0
- agnt5/agent/result.py +39 -0
- agnt5/checkpoint.py +246 -0
- agnt5/client.py +1478 -0
- agnt5/context.py +210 -0
- agnt5/entity.py +1230 -0
- agnt5/events.py +566 -0
- agnt5/exceptions.py +102 -0
- agnt5/function.py +325 -0
- agnt5/lm.py +1033 -0
- agnt5/memory.py +521 -0
- agnt5/tool.py +657 -0
- agnt5/tracing.py +196 -0
- agnt5/types.py +110 -0
- agnt5/version.py +19 -0
- agnt5/worker.py +1982 -0
- agnt5/workflow.py +1584 -0
- agnt5-0.3.0a8.dist-info/METADATA +26 -0
- agnt5-0.3.0a8.dist-info/RECORD +32 -0
- agnt5-0.3.0a8.dist-info/WHEEL +5 -0
agnt5/entity.py
ADDED
|
@@ -0,0 +1,1230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entity component for stateful operations with single-writer consistency.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextvars
|
|
7
|
+
import functools
|
|
8
|
+
import hashlib
|
|
9
|
+
import inspect
|
|
10
|
+
import json
|
|
11
|
+
from typing import (
|
|
12
|
+
Any,
|
|
13
|
+
Dict,
|
|
14
|
+
Generic,
|
|
15
|
+
Optional,
|
|
16
|
+
Tuple,
|
|
17
|
+
Type,
|
|
18
|
+
TypeVar,
|
|
19
|
+
Union,
|
|
20
|
+
get_args,
|
|
21
|
+
get_origin,
|
|
22
|
+
get_type_hints,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from dataclasses import is_dataclass, asdict, fields as dataclass_fields
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from pydantic import BaseModel as PydanticBaseModel
|
|
29
|
+
HAS_PYDANTIC = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
PydanticBaseModel = None # type: ignore
|
|
32
|
+
HAS_PYDANTIC = False
|
|
33
|
+
|
|
34
|
+
# TypeVar for generic Entity[StateType] support
|
|
35
|
+
# StateType can be a Pydantic model, TypedDict, dataclass, or plain dict
|
|
36
|
+
StateType = TypeVar("StateType")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ============================================================================
|
|
40
|
+
# State Type Detection and Serialization Utilities
|
|
41
|
+
# ============================================================================
|
|
42
|
+
|
|
43
|
+
def _is_pydantic_model(obj_or_type) -> bool:
|
|
44
|
+
"""Check if object or type is a Pydantic model."""
|
|
45
|
+
if not HAS_PYDANTIC:
|
|
46
|
+
return False
|
|
47
|
+
if isinstance(obj_or_type, type):
|
|
48
|
+
return issubclass(obj_or_type, PydanticBaseModel)
|
|
49
|
+
return isinstance(obj_or_type, PydanticBaseModel)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _is_typed_dict(type_hint) -> bool:
|
|
53
|
+
"""Check if type hint is a TypedDict."""
|
|
54
|
+
if type_hint is None:
|
|
55
|
+
return False
|
|
56
|
+
# TypedDict classes have __annotations__ and __total__
|
|
57
|
+
return (
|
|
58
|
+
isinstance(type_hint, type) and
|
|
59
|
+
hasattr(type_hint, '__annotations__') and
|
|
60
|
+
hasattr(type_hint, '__total__')
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _get_state_type_kind(state_type: Optional[Type]) -> str:
|
|
65
|
+
"""
|
|
66
|
+
Determine the kind of state type.
|
|
67
|
+
|
|
68
|
+
Returns one of: 'pydantic', 'dataclass', 'typed_dict', 'untyped'
|
|
69
|
+
"""
|
|
70
|
+
if state_type is None:
|
|
71
|
+
return 'untyped'
|
|
72
|
+
if _is_pydantic_model(state_type):
|
|
73
|
+
return 'pydantic'
|
|
74
|
+
if is_dataclass(state_type):
|
|
75
|
+
return 'dataclass'
|
|
76
|
+
if _is_typed_dict(state_type):
|
|
77
|
+
return 'typed_dict'
|
|
78
|
+
return 'untyped'
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _state_to_dict(state: Any, state_type: Optional[Type]) -> Dict[str, Any]:
|
|
82
|
+
"""
|
|
83
|
+
Convert state object to dictionary for persistence.
|
|
84
|
+
|
|
85
|
+
Handles Pydantic models, dataclasses, TypedDicts, and plain dicts.
|
|
86
|
+
"""
|
|
87
|
+
if state is None:
|
|
88
|
+
return {}
|
|
89
|
+
|
|
90
|
+
kind = _get_state_type_kind(state_type)
|
|
91
|
+
|
|
92
|
+
if kind == 'pydantic' and _is_pydantic_model(state):
|
|
93
|
+
return state.model_dump()
|
|
94
|
+
elif kind == 'dataclass' and is_dataclass(state) and not isinstance(state, type):
|
|
95
|
+
return asdict(state)
|
|
96
|
+
elif isinstance(state, dict):
|
|
97
|
+
return state
|
|
98
|
+
else:
|
|
99
|
+
# Fallback: try to convert to dict
|
|
100
|
+
return dict(state) if hasattr(state, '__iter__') else {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _dict_to_state(data: Dict[str, Any], state_type: Optional[Type]) -> Any:
|
|
104
|
+
"""
|
|
105
|
+
Convert dictionary to typed state object.
|
|
106
|
+
|
|
107
|
+
Creates Pydantic model, dataclass, or returns dict based on state_type.
|
|
108
|
+
"""
|
|
109
|
+
if state_type is None:
|
|
110
|
+
return data
|
|
111
|
+
|
|
112
|
+
kind = _get_state_type_kind(state_type)
|
|
113
|
+
|
|
114
|
+
if kind == 'pydantic':
|
|
115
|
+
return state_type(**data)
|
|
116
|
+
elif kind == 'dataclass':
|
|
117
|
+
# Filter to only known fields for dataclass
|
|
118
|
+
known_fields = {f.name for f in dataclass_fields(state_type)}
|
|
119
|
+
filtered_data = {k: v for k, v in data.items() if k in known_fields}
|
|
120
|
+
return state_type(**filtered_data)
|
|
121
|
+
elif kind == 'typed_dict':
|
|
122
|
+
# TypedDict is just a dict with type hints
|
|
123
|
+
return data
|
|
124
|
+
else:
|
|
125
|
+
return data
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _compute_state_hash(state: Any, state_type: Optional[Type]) -> str:
|
|
129
|
+
"""
|
|
130
|
+
Compute a hash of the state for mutation detection.
|
|
131
|
+
|
|
132
|
+
Uses JSON serialization with sorted keys for deterministic hashing.
|
|
133
|
+
"""
|
|
134
|
+
kind = _get_state_type_kind(state_type)
|
|
135
|
+
|
|
136
|
+
if kind == 'pydantic' and _is_pydantic_model(state):
|
|
137
|
+
# Pydantic has optimized JSON serialization
|
|
138
|
+
json_str = state.model_dump_json(exclude_none=False)
|
|
139
|
+
elif kind == 'dataclass' and is_dataclass(state) and not isinstance(state, type):
|
|
140
|
+
json_str = json.dumps(asdict(state), sort_keys=True, default=str)
|
|
141
|
+
elif isinstance(state, dict):
|
|
142
|
+
json_str = json.dumps(state, sort_keys=True, default=str)
|
|
143
|
+
else:
|
|
144
|
+
# Fallback
|
|
145
|
+
json_str = json.dumps(_state_to_dict(state, state_type), sort_keys=True, default=str)
|
|
146
|
+
|
|
147
|
+
return hashlib.md5(json_str.encode()).hexdigest()
|
|
148
|
+
|
|
149
|
+
from ._schema_utils import extract_function_metadata, extract_function_schemas
|
|
150
|
+
from .exceptions import ExecutionError
|
|
151
|
+
from ._telemetry import setup_module_logger
|
|
152
|
+
|
|
153
|
+
logger = setup_module_logger(__name__)
|
|
154
|
+
|
|
155
|
+
# Context variable for worker-scoped state adapter
|
|
156
|
+
# This is set by Worker before entity execution and accessed by Entity instances
|
|
157
|
+
_entity_state_adapter_ctx: contextvars.ContextVar[Optional["EntityStateAdapter"]] = \
|
|
158
|
+
contextvars.ContextVar('_entity_state_adapter', default=None)
|
|
159
|
+
|
|
160
|
+
# Global entity registry
|
|
161
|
+
_ENTITY_REGISTRY: Dict[str, "EntityType"] = {}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class EntityStateAdapter:
|
|
165
|
+
"""
|
|
166
|
+
Thin Python adapter providing Pythonic interface to Rust EntityStateManager core.
|
|
167
|
+
|
|
168
|
+
This adapter provides language-specific concerns only:
|
|
169
|
+
- Worker-local asyncio.Lock for coarse-grained coordination
|
|
170
|
+
- Type conversions between Python dict and JSON bytes
|
|
171
|
+
- Pythonic async/await API over Rust core
|
|
172
|
+
|
|
173
|
+
All business logic (caching, version tracking, retry logic, gRPC) lives in the Rust core.
|
|
174
|
+
This keeps the Python layer simple (~150 LOC) and enables sharing business logic across SDKs.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, rust_core=None):
|
|
178
|
+
"""
|
|
179
|
+
Initialize entity state adapter.
|
|
180
|
+
|
|
181
|
+
Args:
|
|
182
|
+
rust_core: Rust EntityStateManager instance (from _core module).
|
|
183
|
+
If None, operates in standalone/testing mode with in-memory state.
|
|
184
|
+
"""
|
|
185
|
+
self._rust_core = rust_core
|
|
186
|
+
# Worker-local locks for coarse-grained coordination within this worker
|
|
187
|
+
self._local_locks: Dict[Tuple[str, str], asyncio.Lock] = {}
|
|
188
|
+
|
|
189
|
+
# Standalone mode: in-memory state storage when no Rust core
|
|
190
|
+
# This enables testing without the full platform stack
|
|
191
|
+
if rust_core is None:
|
|
192
|
+
self._standalone_states: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
|
193
|
+
self._standalone_versions: Dict[Tuple[str, str], int] = {}
|
|
194
|
+
logger.debug("Created EntityStateAdapter in standalone mode (in-memory state)")
|
|
195
|
+
else:
|
|
196
|
+
logger.debug("Created EntityStateAdapter with Rust core")
|
|
197
|
+
|
|
198
|
+
def get_local_lock(self, state_key: Tuple[str, str]) -> asyncio.Lock:
|
|
199
|
+
"""
|
|
200
|
+
Get worker-local asyncio.Lock for single-writer guarantee within this worker.
|
|
201
|
+
|
|
202
|
+
This provides coarse-grained coordination for operations within the same worker.
|
|
203
|
+
Cross-worker conflicts are handled by the Rust core via optimistic concurrency.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
state_key: Tuple of (entity_type, entity_key)
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
asyncio.Lock for this worker-local operation
|
|
210
|
+
"""
|
|
211
|
+
if state_key not in self._local_locks:
|
|
212
|
+
self._local_locks[state_key] = asyncio.Lock()
|
|
213
|
+
return self._local_locks[state_key]
|
|
214
|
+
|
|
215
|
+
async def load_state(
|
|
216
|
+
self,
|
|
217
|
+
entity_type: str,
|
|
218
|
+
entity_key: str,
|
|
219
|
+
scope: str = "global",
|
|
220
|
+
scope_id: str = "",
|
|
221
|
+
) -> Dict[str, Any]:
|
|
222
|
+
"""
|
|
223
|
+
Load entity state (Rust handles cache-first logic and platform load).
|
|
224
|
+
|
|
225
|
+
In standalone mode (no Rust core), uses in-memory state storage.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
entity_type: Type of entity (e.g., "ShoppingCart", "Counter")
|
|
229
|
+
entity_key: Unique key for entity instance
|
|
230
|
+
scope: Entity scope ("global", "session", "run", "user")
|
|
231
|
+
scope_id: Scope identifier (session_id, run_id, user_id) - empty for global
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
State dictionary (empty dict if not found)
|
|
235
|
+
"""
|
|
236
|
+
if not self._rust_core:
|
|
237
|
+
# Standalone mode - return from in-memory storage
|
|
238
|
+
state_key = (entity_type, entity_key)
|
|
239
|
+
return self._standalone_states.get(state_key, {}).copy()
|
|
240
|
+
|
|
241
|
+
try:
|
|
242
|
+
# Rust checks cache first, loads from platform if needed
|
|
243
|
+
state_json_bytes, version = await self._rust_core.py_get_cached_or_load(
|
|
244
|
+
entity_type, entity_key, scope, scope_id
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
# Convert bytes to dict
|
|
248
|
+
if state_json_bytes:
|
|
249
|
+
state_json = state_json_bytes.decode('utf-8') if isinstance(state_json_bytes, bytes) else state_json_bytes
|
|
250
|
+
return json.loads(state_json)
|
|
251
|
+
else:
|
|
252
|
+
return {}
|
|
253
|
+
except Exception as e:
|
|
254
|
+
logger.warning(f"Failed to load state for {entity_type}:{entity_key}: {e}")
|
|
255
|
+
return {}
|
|
256
|
+
|
|
257
|
+
async def save_state(
|
|
258
|
+
self,
|
|
259
|
+
entity_type: str,
|
|
260
|
+
entity_key: str,
|
|
261
|
+
state: Dict[str, Any],
|
|
262
|
+
expected_version: int,
|
|
263
|
+
scope: str = "global",
|
|
264
|
+
scope_id: str = "",
|
|
265
|
+
) -> int:
|
|
266
|
+
"""
|
|
267
|
+
Save entity state (Rust handles version check and platform save).
|
|
268
|
+
|
|
269
|
+
In standalone mode (no Rust core), stores in-memory with version tracking.
|
|
270
|
+
|
|
271
|
+
Args:
|
|
272
|
+
entity_type: Type of entity
|
|
273
|
+
entity_key: Unique key for entity instance
|
|
274
|
+
state: State dictionary to save
|
|
275
|
+
expected_version: Expected current version (for optimistic locking)
|
|
276
|
+
scope: Entity scope ("global", "session", "run", "user")
|
|
277
|
+
scope_id: Scope identifier (session_id, run_id, user_id) - empty for global
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
New version number after save
|
|
281
|
+
|
|
282
|
+
Raises:
|
|
283
|
+
RuntimeError: If version conflict or platform error
|
|
284
|
+
"""
|
|
285
|
+
if not self._rust_core:
|
|
286
|
+
# Standalone mode - store in memory with version tracking
|
|
287
|
+
state_key = (entity_type, entity_key)
|
|
288
|
+
current_version = self._standalone_versions.get(state_key, 0)
|
|
289
|
+
|
|
290
|
+
# Optimistic locking check (even in standalone mode for consistency)
|
|
291
|
+
if current_version != expected_version:
|
|
292
|
+
raise RuntimeError(
|
|
293
|
+
f"Version conflict: expected {expected_version}, got {current_version}"
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
# Store state and increment version
|
|
297
|
+
new_version = expected_version + 1
|
|
298
|
+
self._standalone_states[state_key] = state.copy()
|
|
299
|
+
self._standalone_versions[state_key] = new_version
|
|
300
|
+
return new_version
|
|
301
|
+
|
|
302
|
+
# Convert dict to JSON bytes
|
|
303
|
+
state_json = json.dumps(state).encode('utf-8')
|
|
304
|
+
|
|
305
|
+
# Rust handles optimistic locking and platform save
|
|
306
|
+
new_version = await self._rust_core.py_save_state(
|
|
307
|
+
entity_type,
|
|
308
|
+
entity_key,
|
|
309
|
+
state_json,
|
|
310
|
+
expected_version,
|
|
311
|
+
scope,
|
|
312
|
+
scope_id,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return new_version
|
|
316
|
+
|
|
317
|
+
async def load_with_version(
|
|
318
|
+
self,
|
|
319
|
+
entity_type: str,
|
|
320
|
+
entity_key: str,
|
|
321
|
+
scope: str = "global",
|
|
322
|
+
scope_id: str = "",
|
|
323
|
+
) -> Tuple[Dict[str, Any], int]:
|
|
324
|
+
"""
|
|
325
|
+
Load entity state with version (for update operations).
|
|
326
|
+
|
|
327
|
+
In standalone mode (no Rust core), loads from in-memory storage with version.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
entity_type: Type of entity
|
|
331
|
+
entity_key: Unique key for entity instance
|
|
332
|
+
scope: Entity scope ("global", "session", "run", "user")
|
|
333
|
+
scope_id: Scope identifier (session_id, run_id, user_id) - empty for global
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
Tuple of (state_dict, version)
|
|
337
|
+
"""
|
|
338
|
+
if not self._rust_core:
|
|
339
|
+
# Standalone mode - return from in-memory storage with version
|
|
340
|
+
state_key = (entity_type, entity_key)
|
|
341
|
+
state = self._standalone_states.get(state_key, {}).copy()
|
|
342
|
+
version = self._standalone_versions.get(state_key, 0)
|
|
343
|
+
return state, version
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
state_json_bytes, version = await self._rust_core.py_get_cached_or_load(
|
|
347
|
+
entity_type, entity_key, scope, scope_id
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
if state_json_bytes:
|
|
351
|
+
state_json = state_json_bytes.decode('utf-8') if isinstance(state_json_bytes, bytes) else state_json_bytes
|
|
352
|
+
state = json.loads(state_json)
|
|
353
|
+
else:
|
|
354
|
+
state = {}
|
|
355
|
+
|
|
356
|
+
return state, version
|
|
357
|
+
except Exception as e:
|
|
358
|
+
logger.warning(f"Failed to load state with version for {entity_type}:{entity_key}: {e}")
|
|
359
|
+
return {}, 0
|
|
360
|
+
|
|
361
|
+
async def invalidate_cache(self, entity_type: str, entity_key: str) -> None:
|
|
362
|
+
"""
|
|
363
|
+
Invalidate cache entry for specific entity.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
entity_type: Type of entity
|
|
367
|
+
entity_key: Unique key for entity instance
|
|
368
|
+
"""
|
|
369
|
+
if self._rust_core:
|
|
370
|
+
await self._rust_core.py_invalidate_cache(entity_type, entity_key)
|
|
371
|
+
|
|
372
|
+
async def clear_cache(self) -> None:
|
|
373
|
+
"""Clear entire cache (useful for testing)."""
|
|
374
|
+
if self._rust_core:
|
|
375
|
+
await self._rust_core.py_clear_cache()
|
|
376
|
+
|
|
377
|
+
def clear_all(self) -> None:
|
|
378
|
+
"""Clear all local locks (for testing)."""
|
|
379
|
+
self._local_locks.clear()
|
|
380
|
+
logger.debug("Cleared EntityStateAdapter local locks")
|
|
381
|
+
|
|
382
|
+
async def get_state(self, entity_type: str, key: str) -> Optional[Dict[str, Any]]:
|
|
383
|
+
"""Get state for debugging/testing."""
|
|
384
|
+
state, _ = await self.load_with_version(entity_type, key)
|
|
385
|
+
return state if state else None
|
|
386
|
+
|
|
387
|
+
def get_all_keys(self, entity_type: str) -> list[str]:
|
|
388
|
+
"""
|
|
389
|
+
Get all keys for an entity type (testing/debugging only).
|
|
390
|
+
|
|
391
|
+
Only works in standalone mode. Returns empty list in production mode.
|
|
392
|
+
"""
|
|
393
|
+
if not hasattr(self, '_standalone_states'):
|
|
394
|
+
return []
|
|
395
|
+
|
|
396
|
+
keys = []
|
|
397
|
+
for (etype, ekey) in self._standalone_states.keys():
|
|
398
|
+
if etype == entity_type:
|
|
399
|
+
keys.append(ekey)
|
|
400
|
+
return keys
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _get_state_adapter() -> EntityStateAdapter:
|
|
404
|
+
"""
|
|
405
|
+
Get the current entity state adapter from context.
|
|
406
|
+
|
|
407
|
+
The state adapter must be set by Worker before entity execution.
|
|
408
|
+
This ensures proper worker-scoped state isolation.
|
|
409
|
+
|
|
410
|
+
Returns:
|
|
411
|
+
EntityStateAdapter instance
|
|
412
|
+
|
|
413
|
+
Raises:
|
|
414
|
+
RuntimeError: If called outside of Worker context (state adapter not set)
|
|
415
|
+
"""
|
|
416
|
+
adapter = _entity_state_adapter_ctx.get()
|
|
417
|
+
if adapter is None:
|
|
418
|
+
raise RuntimeError(
|
|
419
|
+
"Entity requires state adapter context.\n\n"
|
|
420
|
+
"In production:\n"
|
|
421
|
+
" Entities run automatically through Worker.\n\n"
|
|
422
|
+
"In tests, use one of:\n"
|
|
423
|
+
" Option 1 - Decorator:\n"
|
|
424
|
+
" @with_entity_context\n"
|
|
425
|
+
" async def test_cart():\n"
|
|
426
|
+
" cart = ShoppingCart('key')\n"
|
|
427
|
+
" await cart.add_item(...)\n\n"
|
|
428
|
+
" Option 2 - Fixture:\n"
|
|
429
|
+
" async def test_cart(entity_context):\n"
|
|
430
|
+
" cart = ShoppingCart('key')\n"
|
|
431
|
+
" await cart.add_item(...)\n\n"
|
|
432
|
+
"See: https://docs.agnt5.dev/sdk/entities#testing"
|
|
433
|
+
)
|
|
434
|
+
return adapter
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# ============================================================================
|
|
440
|
+
# Testing Helpers
|
|
441
|
+
# ============================================================================
|
|
442
|
+
|
|
443
|
+
def with_entity_context(func):
|
|
444
|
+
"""
|
|
445
|
+
Decorator that sets up entity state adapter for tests.
|
|
446
|
+
|
|
447
|
+
Usage:
|
|
448
|
+
@with_entity_context
|
|
449
|
+
async def test_shopping_cart():
|
|
450
|
+
cart = ShoppingCart(key="test")
|
|
451
|
+
await cart.add_item("item", 1, 10.0)
|
|
452
|
+
assert cart.state.get("items")
|
|
453
|
+
"""
|
|
454
|
+
@functools.wraps(func)
|
|
455
|
+
async def wrapper(*args, **kwargs):
|
|
456
|
+
adapter = EntityStateAdapter()
|
|
457
|
+
token = _entity_state_adapter_ctx.set(adapter)
|
|
458
|
+
try:
|
|
459
|
+
return await func(*args, **kwargs)
|
|
460
|
+
finally:
|
|
461
|
+
_entity_state_adapter_ctx.reset(token)
|
|
462
|
+
adapter.clear_all()
|
|
463
|
+
return wrapper
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def create_entity_context():
|
|
467
|
+
"""
|
|
468
|
+
Create an entity context for testing (can be used as pytest fixture).
|
|
469
|
+
|
|
470
|
+
Usage in conftest.py or test file:
|
|
471
|
+
import pytest
|
|
472
|
+
from agnt5.entity import create_entity_context
|
|
473
|
+
|
|
474
|
+
@pytest.fixture
|
|
475
|
+
def entity_context():
|
|
476
|
+
adapter, token = create_entity_context()
|
|
477
|
+
yield adapter
|
|
478
|
+
# Cleanup happens automatically
|
|
479
|
+
|
|
480
|
+
Returns:
|
|
481
|
+
Tuple of (EntityStateAdapter, context_token)
|
|
482
|
+
"""
|
|
483
|
+
adapter = EntityStateAdapter()
|
|
484
|
+
token = _entity_state_adapter_ctx.set(adapter)
|
|
485
|
+
return adapter, token
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def extract_state_schema(entity_class: type) -> Optional[Dict[str, Any]]:
|
|
489
|
+
"""
|
|
490
|
+
Extract JSON schema from entity class for state structure documentation.
|
|
491
|
+
|
|
492
|
+
The schema can be provided in multiple ways (in order of preference):
|
|
493
|
+
1. Explicit _state_schema class attribute (most explicit)
|
|
494
|
+
2. Docstring with state description
|
|
495
|
+
3. Type annotations on __init__ method (least explicit, basic types only)
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
entity_class: The Entity subclass to extract schema from
|
|
499
|
+
|
|
500
|
+
Returns:
|
|
501
|
+
JSON schema dict or None if no schema could be extracted
|
|
502
|
+
|
|
503
|
+
Examples:
|
|
504
|
+
# Option 1: Explicit schema (recommended)
|
|
505
|
+
class ShoppingCart(Entity):
|
|
506
|
+
_state_schema = {
|
|
507
|
+
"type": "object",
|
|
508
|
+
"properties": {
|
|
509
|
+
"items": {"type": "array", "description": "Cart items"},
|
|
510
|
+
"total": {"type": "number", "description": "Cart total"}
|
|
511
|
+
},
|
|
512
|
+
"description": "Shopping cart state"
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
# Option 2: Docstring
|
|
516
|
+
class ShoppingCart(Entity):
|
|
517
|
+
'''
|
|
518
|
+
Shopping cart entity.
|
|
519
|
+
|
|
520
|
+
State:
|
|
521
|
+
items (list): List of cart items
|
|
522
|
+
total (float): Total cart value
|
|
523
|
+
'''
|
|
524
|
+
|
|
525
|
+
# Option 3: Type hints (basic extraction)
|
|
526
|
+
class ShoppingCart(Entity):
|
|
527
|
+
def __init__(self, key: str):
|
|
528
|
+
super().__init__(key)
|
|
529
|
+
self.items: list = []
|
|
530
|
+
self.total: float = 0.0
|
|
531
|
+
"""
|
|
532
|
+
# Option 1: Check for explicit _state_schema attribute
|
|
533
|
+
if hasattr(entity_class, '_state_schema'):
|
|
534
|
+
schema = entity_class._state_schema
|
|
535
|
+
logger.debug(f"Found explicit _state_schema for {entity_class.__name__}")
|
|
536
|
+
return schema
|
|
537
|
+
|
|
538
|
+
# Option 2: Extract from docstring (basic parsing)
|
|
539
|
+
if entity_class.__doc__:
|
|
540
|
+
doc = entity_class.__doc__.strip()
|
|
541
|
+
if "State:" in doc or "state:" in doc.lower():
|
|
542
|
+
# Found state documentation - create basic schema
|
|
543
|
+
logger.debug(f"Found state documentation in docstring for {entity_class.__name__}")
|
|
544
|
+
return {
|
|
545
|
+
"type": "object",
|
|
546
|
+
"description": f"State structure for {entity_class.__name__} (see docstring for details)"
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
# Option 3: Try to extract from __init__ type hints (very basic)
|
|
550
|
+
try:
|
|
551
|
+
init_method = entity_class.__init__
|
|
552
|
+
type_hints = get_type_hints(init_method)
|
|
553
|
+
# Remove 'key' and 'return' from hints
|
|
554
|
+
state_hints = {k: v for k, v in type_hints.items() if k not in ('key', 'return')}
|
|
555
|
+
|
|
556
|
+
if state_hints:
|
|
557
|
+
logger.debug(f"Extracted type hints from __init__ for {entity_class.__name__}")
|
|
558
|
+
properties = {}
|
|
559
|
+
for name, type_hint in state_hints.items():
|
|
560
|
+
# Basic type mapping
|
|
561
|
+
if type_hint == str:
|
|
562
|
+
properties[name] = {"type": "string"}
|
|
563
|
+
elif type_hint == int:
|
|
564
|
+
properties[name] = {"type": "integer"}
|
|
565
|
+
elif type_hint == float:
|
|
566
|
+
properties[name] = {"type": "number"}
|
|
567
|
+
elif type_hint == bool:
|
|
568
|
+
properties[name] = {"type": "boolean"}
|
|
569
|
+
elif type_hint == list or str(type_hint).startswith('list'):
|
|
570
|
+
properties[name] = {"type": "array"}
|
|
571
|
+
elif type_hint == dict or str(type_hint).startswith('dict'):
|
|
572
|
+
properties[name] = {"type": "object"}
|
|
573
|
+
else:
|
|
574
|
+
properties[name] = {"type": "object", "description": str(type_hint)}
|
|
575
|
+
|
|
576
|
+
if properties:
|
|
577
|
+
return {
|
|
578
|
+
"type": "object",
|
|
579
|
+
"properties": properties,
|
|
580
|
+
"description": f"State structure inferred from type hints for {entity_class.__name__}"
|
|
581
|
+
}
|
|
582
|
+
except Exception as e:
|
|
583
|
+
logger.debug(f"Could not extract type hints from {entity_class.__name__}: {e}")
|
|
584
|
+
|
|
585
|
+
# No schema could be extracted
|
|
586
|
+
logger.debug(f"No state schema found for {entity_class.__name__}")
|
|
587
|
+
return None
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
class EntityRegistry:
|
|
591
|
+
"""Registry for entity types."""
|
|
592
|
+
|
|
593
|
+
@staticmethod
|
|
594
|
+
def register(entity_type: "EntityType") -> None:
|
|
595
|
+
"""Register an entity type."""
|
|
596
|
+
if entity_type.name in _ENTITY_REGISTRY:
|
|
597
|
+
logger.warning(f"Overwriting existing entity type '{entity_type.name}'")
|
|
598
|
+
_ENTITY_REGISTRY[entity_type.name] = entity_type
|
|
599
|
+
logger.debug(f"Registered entity type '{entity_type.name}'")
|
|
600
|
+
|
|
601
|
+
@staticmethod
|
|
602
|
+
def get(name: str) -> Optional["EntityType"]:
|
|
603
|
+
"""Get entity type by name."""
|
|
604
|
+
return _ENTITY_REGISTRY.get(name)
|
|
605
|
+
|
|
606
|
+
@staticmethod
|
|
607
|
+
def all() -> Dict[str, "EntityType"]:
|
|
608
|
+
"""Get all registered entities."""
|
|
609
|
+
return _ENTITY_REGISTRY.copy()
|
|
610
|
+
|
|
611
|
+
@staticmethod
|
|
612
|
+
def clear() -> None:
|
|
613
|
+
"""Clear all registered entities."""
|
|
614
|
+
_ENTITY_REGISTRY.clear()
|
|
615
|
+
logger.debug("Cleared entity registry")
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
class EntityType:
|
|
619
|
+
"""
|
|
620
|
+
Metadata about an Entity class.
|
|
621
|
+
|
|
622
|
+
Stores entity name, method schemas, state schema, and metadata for Worker auto-discovery
|
|
623
|
+
and platform integration. Created automatically when Entity subclasses are defined.
|
|
624
|
+
"""
|
|
625
|
+
|
|
626
|
+
def __init__(self, name: str, entity_class: type):
|
|
627
|
+
"""
|
|
628
|
+
Initialize entity type metadata.
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
name: Entity type name (class name)
|
|
632
|
+
entity_class: Reference to the Entity class
|
|
633
|
+
"""
|
|
634
|
+
self.name = name
|
|
635
|
+
self.entity_class = entity_class
|
|
636
|
+
self._method_schemas: Dict[str, Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]] = {}
|
|
637
|
+
self._method_metadata: Dict[str, Dict[str, str]] = {}
|
|
638
|
+
self._state_schema: Optional[Dict[str, Any]] = None
|
|
639
|
+
logger.debug("Created entity type: %s", name)
|
|
640
|
+
|
|
641
|
+
def set_state_schema(self, schema: Optional[Dict[str, Any]]) -> None:
|
|
642
|
+
"""
|
|
643
|
+
Set the state schema for this entity type.
|
|
644
|
+
|
|
645
|
+
Args:
|
|
646
|
+
schema: JSON schema describing the entity's state structure
|
|
647
|
+
"""
|
|
648
|
+
self._state_schema = schema
|
|
649
|
+
if schema:
|
|
650
|
+
logger.debug(f"Set state schema for {self.name}")
|
|
651
|
+
|
|
652
|
+
def build_entity_definition(self) -> Dict[str, Any]:
|
|
653
|
+
"""
|
|
654
|
+
Build complete entity definition for platform registration.
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
Dictionary with entity name, state schema, and method schemas
|
|
658
|
+
"""
|
|
659
|
+
# Build method schemas dict
|
|
660
|
+
method_schemas = {}
|
|
661
|
+
for method_name, (input_schema, output_schema) in self._method_schemas.items():
|
|
662
|
+
method_metadata = self._method_metadata.get(method_name, {})
|
|
663
|
+
method_schemas[method_name] = {
|
|
664
|
+
"input_schema": input_schema,
|
|
665
|
+
"output_schema": output_schema,
|
|
666
|
+
"description": method_metadata.get("description", ""),
|
|
667
|
+
"metadata": method_metadata
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
# Build complete definition
|
|
671
|
+
definition = {
|
|
672
|
+
"entity_name": self.name,
|
|
673
|
+
"methods": method_schemas
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
# Add state schema if available
|
|
677
|
+
if self._state_schema:
|
|
678
|
+
definition["state_schema"] = self._state_schema
|
|
679
|
+
|
|
680
|
+
return definition
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
# ============================================================================
|
|
684
|
+
# Class-Based Entity API (Cloudflare Durable Objects style)
|
|
685
|
+
# ============================================================================
|
|
686
|
+
|
|
687
|
+
class EntityState:
|
|
688
|
+
"""
|
|
689
|
+
Simple state interface for Entity instances.
|
|
690
|
+
|
|
691
|
+
Provides a clean API for state management:
|
|
692
|
+
self.state.get(key, default)
|
|
693
|
+
self.state.set(key, value)
|
|
694
|
+
self.state.delete(key)
|
|
695
|
+
self.state.clear()
|
|
696
|
+
|
|
697
|
+
State operations are synchronous and backed by an internal dict.
|
|
698
|
+
"""
|
|
699
|
+
|
|
700
|
+
def __init__(self, state_dict: Dict[str, Any]):
|
|
701
|
+
"""
|
|
702
|
+
Initialize state wrapper with a state dict.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
state_dict: Dictionary to use for state storage
|
|
706
|
+
"""
|
|
707
|
+
self._state = state_dict
|
|
708
|
+
|
|
709
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
710
|
+
"""Get value from state."""
|
|
711
|
+
return self._state.get(key, default)
|
|
712
|
+
|
|
713
|
+
def set(self, key: str, value: Any) -> None:
|
|
714
|
+
"""Set value in state."""
|
|
715
|
+
self._state[key] = value
|
|
716
|
+
|
|
717
|
+
def delete(self, key: str) -> None:
|
|
718
|
+
"""Delete key from state."""
|
|
719
|
+
self._state.pop(key, None)
|
|
720
|
+
|
|
721
|
+
def clear(self) -> None:
|
|
722
|
+
"""Clear all state."""
|
|
723
|
+
self._state.clear()
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
class TypedEntityState(Generic[StateType]):
|
|
727
|
+
"""
|
|
728
|
+
Typed state wrapper for Entity instances.
|
|
729
|
+
|
|
730
|
+
Provides direct attribute access for typed state (Pydantic, dataclass)
|
|
731
|
+
while maintaining compatibility with the dict-based API.
|
|
732
|
+
|
|
733
|
+
For Pydantic/dataclass:
|
|
734
|
+
self.state.items # Direct attribute access
|
|
735
|
+
self.state.total = 100.0 # Direct attribute mutation
|
|
736
|
+
|
|
737
|
+
For untyped (backward compat):
|
|
738
|
+
self.state.get("items", [])
|
|
739
|
+
self.state.set("items", [...])
|
|
740
|
+
|
|
741
|
+
The underlying state object is accessible via ._typed_state for typed,
|
|
742
|
+
or ._state for the dict representation.
|
|
743
|
+
"""
|
|
744
|
+
|
|
745
|
+
def __init__(
|
|
746
|
+
self,
|
|
747
|
+
state_dict: Dict[str, Any],
|
|
748
|
+
state_type: Optional[Type[StateType]] = None
|
|
749
|
+
):
|
|
750
|
+
"""
|
|
751
|
+
Initialize typed state wrapper.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
state_dict: Dictionary representation of state (for persistence)
|
|
755
|
+
state_type: Optional type class (Pydantic model, dataclass, etc.)
|
|
756
|
+
"""
|
|
757
|
+
# Use object.__setattr__ to avoid triggering our custom __setattr__
|
|
758
|
+
object.__setattr__(self, '_state', state_dict)
|
|
759
|
+
object.__setattr__(self, '_state_type', state_type)
|
|
760
|
+
|
|
761
|
+
# Create typed state object if type is provided
|
|
762
|
+
if state_type is not None:
|
|
763
|
+
typed_state = _dict_to_state(state_dict, state_type)
|
|
764
|
+
object.__setattr__(self, '_typed_state', typed_state)
|
|
765
|
+
else:
|
|
766
|
+
object.__setattr__(self, '_typed_state', None)
|
|
767
|
+
|
|
768
|
+
def __getattr__(self, name: str) -> Any:
|
|
769
|
+
"""
|
|
770
|
+
Get attribute from typed state or fall back to dict access.
|
|
771
|
+
|
|
772
|
+
For typed state (Pydantic/dataclass): returns attribute directly
|
|
773
|
+
For untyped: raises AttributeError (use get() instead)
|
|
774
|
+
"""
|
|
775
|
+
# Don't intercept private attributes
|
|
776
|
+
if name.startswith('_'):
|
|
777
|
+
raise AttributeError(f"'{type(self).__name__}' has no attribute '{name}'")
|
|
778
|
+
|
|
779
|
+
typed_state = object.__getattribute__(self, '_typed_state')
|
|
780
|
+
if typed_state is not None:
|
|
781
|
+
return getattr(typed_state, name)
|
|
782
|
+
|
|
783
|
+
# For untyped state, provide helpful error
|
|
784
|
+
raise AttributeError(
|
|
785
|
+
f"Untyped state does not support attribute access. "
|
|
786
|
+
f"Use self.state.get('{name}') instead, or define a typed state class."
|
|
787
|
+
)
|
|
788
|
+
|
|
789
|
+
def __setattr__(self, name: str, value: Any) -> None:
|
|
790
|
+
"""
|
|
791
|
+
Set attribute on typed state and sync to dict.
|
|
792
|
+
|
|
793
|
+
For typed state: sets attribute and syncs to _state dict
|
|
794
|
+
For untyped: raises AttributeError (use set() instead)
|
|
795
|
+
"""
|
|
796
|
+
# Don't intercept private attributes
|
|
797
|
+
if name.startswith('_'):
|
|
798
|
+
object.__setattr__(self, name, value)
|
|
799
|
+
return
|
|
800
|
+
|
|
801
|
+
typed_state = object.__getattribute__(self, '_typed_state')
|
|
802
|
+
state_type = object.__getattribute__(self, '_state_type')
|
|
803
|
+
state_dict = object.__getattribute__(self, '_state')
|
|
804
|
+
|
|
805
|
+
if typed_state is not None:
|
|
806
|
+
# Set on typed state object
|
|
807
|
+
setattr(typed_state, name, value)
|
|
808
|
+
# Sync back to dict for persistence
|
|
809
|
+
state_dict.update(_state_to_dict(typed_state, state_type))
|
|
810
|
+
else:
|
|
811
|
+
raise AttributeError(
|
|
812
|
+
f"Untyped state does not support attribute assignment. "
|
|
813
|
+
f"Use self.state.set('{name}', value) instead."
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
# Dict-based API (backward compatible)
|
|
817
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
818
|
+
"""Get value from state dict."""
|
|
819
|
+
return self._state.get(key, default)
|
|
820
|
+
|
|
821
|
+
def set(self, key: str, value: Any) -> None:
|
|
822
|
+
"""Set value in state dict and sync to typed state if present."""
|
|
823
|
+
self._state[key] = value
|
|
824
|
+
# Sync to typed state if present
|
|
825
|
+
if self._typed_state is not None and self._state_type is not None:
|
|
826
|
+
object.__setattr__(
|
|
827
|
+
self,
|
|
828
|
+
'_typed_state',
|
|
829
|
+
_dict_to_state(self._state, self._state_type)
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
def delete(self, key: str) -> None:
|
|
833
|
+
"""Delete key from state."""
|
|
834
|
+
self._state.pop(key, None)
|
|
835
|
+
# Sync to typed state if present
|
|
836
|
+
if self._typed_state is not None and self._state_type is not None:
|
|
837
|
+
object.__setattr__(
|
|
838
|
+
self,
|
|
839
|
+
'_typed_state',
|
|
840
|
+
_dict_to_state(self._state, self._state_type)
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
def clear(self) -> None:
|
|
844
|
+
"""Clear all state."""
|
|
845
|
+
self._state.clear()
|
|
846
|
+
# Reset typed state
|
|
847
|
+
if self._state_type is not None:
|
|
848
|
+
object.__setattr__(
|
|
849
|
+
self,
|
|
850
|
+
'_typed_state',
|
|
851
|
+
_dict_to_state({}, self._state_type)
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
def _get_dict(self) -> Dict[str, Any]:
|
|
855
|
+
"""Get the underlying dict representation (for persistence)."""
|
|
856
|
+
if self._typed_state is not None:
|
|
857
|
+
# Sync from typed state to ensure dict is up-to-date
|
|
858
|
+
return _state_to_dict(self._typed_state, self._state_type)
|
|
859
|
+
return self._state
|
|
860
|
+
|
|
861
|
+
def _get_typed(self) -> Optional[StateType]:
|
|
862
|
+
"""Get the typed state object (Pydantic model, dataclass, etc.)."""
|
|
863
|
+
return self._typed_state
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
# Decorator for marking read-only methods (optional optimization)
|
|
867
|
+
def query(func):
|
|
868
|
+
"""
|
|
869
|
+
Mark an entity method as read-only (query).
|
|
870
|
+
|
|
871
|
+
Query methods skip hash comparison and state persistence for maximum
|
|
872
|
+
performance on high-frequency reads.
|
|
873
|
+
|
|
874
|
+
Example:
|
|
875
|
+
class Counter(Entity[CounterState]):
|
|
876
|
+
@query
|
|
877
|
+
async def get_count(self) -> int:
|
|
878
|
+
return self.state.count
|
|
879
|
+
|
|
880
|
+
Note:
|
|
881
|
+
For most cases, you don't need this decorator - the Entity automatically
|
|
882
|
+
detects whether state was mutated via hash comparison. Use @query only
|
|
883
|
+
for high-frequency reads where even the hash computation overhead matters.
|
|
884
|
+
"""
|
|
885
|
+
func._agnt5_method_type = 'query'
|
|
886
|
+
return func
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _create_entity_method_wrapper(entity_type: str, method, state_type: Optional[Type] = None):
|
|
890
|
+
"""
|
|
891
|
+
Create a wrapper for an entity method that provides single-writer consistency.
|
|
892
|
+
|
|
893
|
+
This wrapper implements:
|
|
894
|
+
1. Local lock (asyncio.Lock) for worker-scoped single-writer guarantee
|
|
895
|
+
2. Optimistic concurrency (via Rust) for cross-worker conflicts
|
|
896
|
+
3. Loads state via adapter (Rust handles cache + platform)
|
|
897
|
+
4. Executes the method with TypedEntityState interface
|
|
898
|
+
5. Hash-based mutation detection - only saves if state changed
|
|
899
|
+
6. Support for @query decorator to skip mutation detection entirely
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
entity_type: Name of the entity type (class name)
|
|
903
|
+
method: The async method to wrap
|
|
904
|
+
state_type: Optional type class for typed state (Pydantic, dataclass, etc.)
|
|
905
|
+
|
|
906
|
+
Returns:
|
|
907
|
+
Wrapped async method with single-writer consistency and mutation detection
|
|
908
|
+
"""
|
|
909
|
+
# Check if method is marked as @query (read-only)
|
|
910
|
+
is_query = getattr(method, '_agnt5_method_type', None) == 'query'
|
|
911
|
+
|
|
912
|
+
@functools.wraps(method)
|
|
913
|
+
async def entity_method_wrapper(self, *args, **kwargs):
|
|
914
|
+
"""Execute entity method with hybrid locking and mutation detection."""
|
|
915
|
+
state_key = (entity_type, self._key)
|
|
916
|
+
|
|
917
|
+
# Get state adapter
|
|
918
|
+
adapter = _get_state_adapter()
|
|
919
|
+
|
|
920
|
+
# Local lock for worker-scoped single-writer guarantee
|
|
921
|
+
lock = adapter.get_local_lock(state_key)
|
|
922
|
+
|
|
923
|
+
async with lock:
|
|
924
|
+
# Load state with version (Rust handles cache-first + platform load)
|
|
925
|
+
state_dict, current_version = await adapter.load_with_version(entity_type, self._key)
|
|
926
|
+
|
|
927
|
+
logger.debug(
|
|
928
|
+
"Loaded state for %s:%s (version %d)",
|
|
929
|
+
entity_type, self._key, current_version
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
# Set up TypedEntityState on instance for method access
|
|
933
|
+
# Use the class-level state type if available
|
|
934
|
+
effective_state_type = state_type or getattr(self.__class__, '_state_type', None)
|
|
935
|
+
self._state = TypedEntityState(state_dict, effective_state_type)
|
|
936
|
+
|
|
937
|
+
# Compute hash before method execution (skip for @query methods)
|
|
938
|
+
if not is_query and effective_state_type is not None:
|
|
939
|
+
# For typed state, compute hash of the typed object
|
|
940
|
+
original_hash = _compute_state_hash(
|
|
941
|
+
self._state._get_typed() or state_dict,
|
|
942
|
+
effective_state_type
|
|
943
|
+
)
|
|
944
|
+
elif not is_query:
|
|
945
|
+
# For untyped state, compute hash of the dict
|
|
946
|
+
original_hash = _compute_state_hash(state_dict, None)
|
|
947
|
+
else:
|
|
948
|
+
original_hash = None # Skip for @query
|
|
949
|
+
|
|
950
|
+
try:
|
|
951
|
+
# Execute method
|
|
952
|
+
logger.debug("Executing %s:%s.%s", entity_type, self._key, method.__name__)
|
|
953
|
+
result = await method(self, *args, **kwargs)
|
|
954
|
+
logger.debug("Completed %s:%s.%s", entity_type, self._key, method.__name__)
|
|
955
|
+
|
|
956
|
+
# For @query methods, skip persistence entirely
|
|
957
|
+
if is_query:
|
|
958
|
+
logger.debug(
|
|
959
|
+
"Skipping state save for query method %s:%s.%s",
|
|
960
|
+
entity_type, self._key, method.__name__
|
|
961
|
+
)
|
|
962
|
+
return result
|
|
963
|
+
|
|
964
|
+
# Get current state dict (sync from typed state if needed)
|
|
965
|
+
current_state_dict = self._state._get_dict()
|
|
966
|
+
|
|
967
|
+
# Compute hash after method execution
|
|
968
|
+
if effective_state_type is not None:
|
|
969
|
+
new_hash = _compute_state_hash(
|
|
970
|
+
self._state._get_typed() or current_state_dict,
|
|
971
|
+
effective_state_type
|
|
972
|
+
)
|
|
973
|
+
else:
|
|
974
|
+
new_hash = _compute_state_hash(current_state_dict, None)
|
|
975
|
+
|
|
976
|
+
# Only save if state actually changed (hash-based mutation detection)
|
|
977
|
+
if new_hash != original_hash:
|
|
978
|
+
try:
|
|
979
|
+
new_version = await adapter.save_state(
|
|
980
|
+
entity_type,
|
|
981
|
+
self._key,
|
|
982
|
+
current_state_dict,
|
|
983
|
+
current_version
|
|
984
|
+
)
|
|
985
|
+
logger.info(
|
|
986
|
+
"Saved state for %s:%s (version %d -> %d, hash changed)",
|
|
987
|
+
entity_type, self._key, current_version, new_version
|
|
988
|
+
)
|
|
989
|
+
except Exception as e:
|
|
990
|
+
logger.error(
|
|
991
|
+
"Failed to save state for %s:%s: %s",
|
|
992
|
+
entity_type, self._key, e
|
|
993
|
+
)
|
|
994
|
+
# Don't fail the method execution just because persistence failed
|
|
995
|
+
else:
|
|
996
|
+
logger.debug(
|
|
997
|
+
"Skipping state save for %s:%s.%s (no mutation detected)",
|
|
998
|
+
entity_type, self._key, method.__name__
|
|
999
|
+
)
|
|
1000
|
+
|
|
1001
|
+
return result
|
|
1002
|
+
|
|
1003
|
+
except Exception as e:
|
|
1004
|
+
logger.error(
|
|
1005
|
+
"Error in %s:%s.%s: %s",
|
|
1006
|
+
entity_type, self._key, method.__name__, e,
|
|
1007
|
+
exc_info=True
|
|
1008
|
+
)
|
|
1009
|
+
raise ExecutionError(
|
|
1010
|
+
f"Entity method {method.__name__} failed: {e}"
|
|
1011
|
+
) from e
|
|
1012
|
+
finally:
|
|
1013
|
+
# Clear state reference after execution
|
|
1014
|
+
self._state = None
|
|
1015
|
+
|
|
1016
|
+
return entity_method_wrapper
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
class Entity(Generic[StateType]):
|
|
1020
|
+
"""
|
|
1021
|
+
Base class for stateful entities with single-writer consistency.
|
|
1022
|
+
|
|
1023
|
+
Entities provide a class-based API where:
|
|
1024
|
+
- State is accessed via self.state (clean, synchronous API)
|
|
1025
|
+
- Methods are regular async methods on the class
|
|
1026
|
+
- Each instance is bound to a unique key
|
|
1027
|
+
- Single-writer consistency per key is guaranteed automatically
|
|
1028
|
+
|
|
1029
|
+
Supports typed state with Pydantic models for IDE autocomplete and validation:
|
|
1030
|
+
```python
|
|
1031
|
+
from agnt5 import Entity
|
|
1032
|
+
from pydantic import BaseModel
|
|
1033
|
+
|
|
1034
|
+
class CartState(BaseModel):
|
|
1035
|
+
items: dict[str, dict] = {}
|
|
1036
|
+
total: float = 0.0
|
|
1037
|
+
|
|
1038
|
+
class ShoppingCart(Entity[CartState]):
|
|
1039
|
+
async def add_item(self, item_id: str, quantity: int, price: float) -> dict:
|
|
1040
|
+
# IDE autocomplete works!
|
|
1041
|
+
self.state.items[item_id] = {"quantity": quantity, "price": price}
|
|
1042
|
+
self.state.total = sum(
|
|
1043
|
+
item["quantity"] * item["price"]
|
|
1044
|
+
for item in self.state.items.values()
|
|
1045
|
+
)
|
|
1046
|
+
return {"total_items": len(self.state.items)}
|
|
1047
|
+
|
|
1048
|
+
async def get_total(self) -> float:
|
|
1049
|
+
return self.state.total # No save triggered (auto-detected as read)
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
Also supports dataclasses:
|
|
1053
|
+
```python
|
|
1054
|
+
from dataclasses import dataclass, field
|
|
1055
|
+
|
|
1056
|
+
@dataclass
|
|
1057
|
+
class CounterState:
|
|
1058
|
+
count: int = 0
|
|
1059
|
+
history: list = field(default_factory=list)
|
|
1060
|
+
|
|
1061
|
+
class Counter(Entity[CounterState]):
|
|
1062
|
+
async def increment(self) -> int:
|
|
1063
|
+
self.state.count += 1
|
|
1064
|
+
return self.state.count
|
|
1065
|
+
```
|
|
1066
|
+
|
|
1067
|
+
And untyped state (backward compatible):
|
|
1068
|
+
```python
|
|
1069
|
+
class Counter(Entity):
|
|
1070
|
+
async def increment(self) -> int:
|
|
1071
|
+
count = self.state.get("count", 0) + 1
|
|
1072
|
+
self.state.set("count", count)
|
|
1073
|
+
return count
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
Note:
|
|
1077
|
+
Methods are automatically wrapped to provide single-writer consistency per key.
|
|
1078
|
+
State mutations are auto-detected via hash comparison - read-only methods
|
|
1079
|
+
don't trigger persistence.
|
|
1080
|
+
"""
|
|
1081
|
+
|
|
1082
|
+
# Class-level state type (set by __init_subclass__)
|
|
1083
|
+
_state_type: Optional[Type] = None
|
|
1084
|
+
|
|
1085
|
+
def __init__(self, key: str):
|
|
1086
|
+
"""
|
|
1087
|
+
Initialize an entity instance.
|
|
1088
|
+
|
|
1089
|
+
Args:
|
|
1090
|
+
key: Unique identifier for this entity instance
|
|
1091
|
+
"""
|
|
1092
|
+
self._key = key
|
|
1093
|
+
self._entity_type = self.__class__.__name__
|
|
1094
|
+
self._state_key = (self._entity_type, key)
|
|
1095
|
+
|
|
1096
|
+
# State will be initialized during method execution by wrapper
|
|
1097
|
+
self._state = None
|
|
1098
|
+
|
|
1099
|
+
logger.debug("Created Entity instance: %s:%s", self._entity_type, key)
|
|
1100
|
+
|
|
1101
|
+
@property
|
|
1102
|
+
def state(self) -> EntityState:
|
|
1103
|
+
"""
|
|
1104
|
+
Get the state interface for this entity.
|
|
1105
|
+
|
|
1106
|
+
Available operations:
|
|
1107
|
+
- self.state.get(key, default)
|
|
1108
|
+
- self.state.set(key, value)
|
|
1109
|
+
- self.state.delete(key)
|
|
1110
|
+
- self.state.clear()
|
|
1111
|
+
|
|
1112
|
+
Returns:
|
|
1113
|
+
EntityState for synchronous state operations
|
|
1114
|
+
|
|
1115
|
+
Raises:
|
|
1116
|
+
RuntimeError: If accessed outside of an entity method
|
|
1117
|
+
"""
|
|
1118
|
+
if self._state is None:
|
|
1119
|
+
raise RuntimeError(
|
|
1120
|
+
f"Entity state can only be accessed within entity methods.\n\n"
|
|
1121
|
+
f"You tried to access state on {self._entity_type}(key='{self._key}') "
|
|
1122
|
+
f"outside of a method call.\n\n"
|
|
1123
|
+
f"❌ Wrong:\n"
|
|
1124
|
+
f" cart = ShoppingCart(key='user-123')\n"
|
|
1125
|
+
f" items = cart.state.get('items') # Error!\n\n"
|
|
1126
|
+
f"✅ Correct:\n"
|
|
1127
|
+
f" class ShoppingCart(Entity):\n"
|
|
1128
|
+
f" async def get_items(self):\n"
|
|
1129
|
+
f" return self.state.get('items', {{}}) # Works!\n\n"
|
|
1130
|
+
f" cart = ShoppingCart(key='user-123')\n"
|
|
1131
|
+
f" items = await cart.get_items() # Call method instead"
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
# Type narrowing: after the raise, self._state is guaranteed to be not None
|
|
1135
|
+
assert self._state is not None
|
|
1136
|
+
return self._state
|
|
1137
|
+
|
|
1138
|
+
@property
|
|
1139
|
+
def key(self) -> str:
|
|
1140
|
+
"""Get the entity instance key."""
|
|
1141
|
+
return self._key
|
|
1142
|
+
|
|
1143
|
+
@property
|
|
1144
|
+
def entity_type(self) -> str:
|
|
1145
|
+
"""Get the entity type name."""
|
|
1146
|
+
return self._entity_type
|
|
1147
|
+
|
|
1148
|
+
def __init_subclass__(cls, **kwargs):
|
|
1149
|
+
"""
|
|
1150
|
+
Auto-register Entity subclasses and wrap methods.
|
|
1151
|
+
|
|
1152
|
+
This is called automatically when a class inherits from Entity.
|
|
1153
|
+
It performs four tasks:
|
|
1154
|
+
1. Extracts state type from generic parameter (Entity[StateType])
|
|
1155
|
+
2. Extracts state schema from the class or state type
|
|
1156
|
+
3. Wraps all public async methods with single-writer consistency
|
|
1157
|
+
4. Registers the entity type with metadata for platform discovery
|
|
1158
|
+
"""
|
|
1159
|
+
super().__init_subclass__(**kwargs)
|
|
1160
|
+
|
|
1161
|
+
# Don't register the base Entity class itself
|
|
1162
|
+
if cls.__name__ == 'Entity':
|
|
1163
|
+
return
|
|
1164
|
+
|
|
1165
|
+
# Don't register SDK's built-in base classes (these are meant to be extended by users)
|
|
1166
|
+
if cls.__name__ in ('SessionEntity', 'MemoryEntity'):
|
|
1167
|
+
return
|
|
1168
|
+
|
|
1169
|
+
# Extract state type from generic parameter (Entity[CartState])
|
|
1170
|
+
state_type = None
|
|
1171
|
+
if hasattr(cls, '__orig_bases__'):
|
|
1172
|
+
for base in cls.__orig_bases__:
|
|
1173
|
+
origin = get_origin(base)
|
|
1174
|
+
if origin is Entity or (isinstance(origin, type) and issubclass(origin, Entity)):
|
|
1175
|
+
args = get_args(base)
|
|
1176
|
+
if args:
|
|
1177
|
+
state_type = args[0]
|
|
1178
|
+
break
|
|
1179
|
+
|
|
1180
|
+
# Store state type on the class for later use
|
|
1181
|
+
cls._state_type = state_type
|
|
1182
|
+
|
|
1183
|
+
if state_type is not None:
|
|
1184
|
+
kind = _get_state_type_kind(state_type)
|
|
1185
|
+
logger.debug(
|
|
1186
|
+
f"Extracted state type for {cls.__name__}: {state_type.__name__ if hasattr(state_type, '__name__') else state_type} ({kind})"
|
|
1187
|
+
)
|
|
1188
|
+
|
|
1189
|
+
# Create an EntityType for this class, storing the class reference
|
|
1190
|
+
entity_type = EntityType(cls.__name__, entity_class=cls)
|
|
1191
|
+
|
|
1192
|
+
# Extract state schema from Pydantic model if available
|
|
1193
|
+
if state_type is not None and _is_pydantic_model(state_type):
|
|
1194
|
+
try:
|
|
1195
|
+
# Pydantic v2 has model_json_schema()
|
|
1196
|
+
pydantic_schema = state_type.model_json_schema()
|
|
1197
|
+
entity_type.set_state_schema(pydantic_schema)
|
|
1198
|
+
logger.debug(f"Extracted Pydantic state schema for {cls.__name__}")
|
|
1199
|
+
except Exception as e:
|
|
1200
|
+
logger.debug(f"Could not extract Pydantic schema for {cls.__name__}: {e}")
|
|
1201
|
+
# Fall back to basic schema extraction
|
|
1202
|
+
state_schema = extract_state_schema(cls)
|
|
1203
|
+
if state_schema:
|
|
1204
|
+
entity_type.set_state_schema(state_schema)
|
|
1205
|
+
else:
|
|
1206
|
+
# Fall back to basic schema extraction for non-Pydantic types
|
|
1207
|
+
state_schema = extract_state_schema(cls)
|
|
1208
|
+
if state_schema:
|
|
1209
|
+
entity_type.set_state_schema(state_schema)
|
|
1210
|
+
logger.debug(f"Extracted state schema for {cls.__name__}")
|
|
1211
|
+
|
|
1212
|
+
# Wrap all public async methods and register them
|
|
1213
|
+
for name, method in inspect.getmembers(cls, predicate=inspect.iscoroutinefunction):
|
|
1214
|
+
if not name.startswith('_'):
|
|
1215
|
+
# Extract schemas from the method
|
|
1216
|
+
input_schema, output_schema = extract_function_schemas(method)
|
|
1217
|
+
method_metadata = extract_function_metadata(method)
|
|
1218
|
+
|
|
1219
|
+
# Store in entity type
|
|
1220
|
+
entity_type._method_schemas[name] = (input_schema, output_schema)
|
|
1221
|
+
entity_type._method_metadata[name] = method_metadata
|
|
1222
|
+
|
|
1223
|
+
# Wrap the method with single-writer consistency and typed state
|
|
1224
|
+
# Pass state_type so wrapper can use hash-based mutation detection
|
|
1225
|
+
wrapped_method = _create_entity_method_wrapper(cls.__name__, method, state_type)
|
|
1226
|
+
setattr(cls, name, wrapped_method)
|
|
1227
|
+
|
|
1228
|
+
# Register the entity type
|
|
1229
|
+
EntityRegistry.register(entity_type)
|
|
1230
|
+
logger.debug(f"Auto-registered Entity subclass: {cls.__name__}")
|