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