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/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__}")