agnt5 0.2.8a10__cp310-abi3-manylinux_2_34_x86_64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of agnt5 might be problematic. Click here for more details.

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