agnt5 0.2.8a2__cp310-abi3-macosx_10_12_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/worker.py ADDED
@@ -0,0 +1,1151 @@
1
+ """Worker implementation for AGNT5 SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextvars
7
+ import logging
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from .function import FunctionRegistry
11
+ from .workflow import WorkflowRegistry
12
+ from ._telemetry import setup_module_logger
13
+
14
+ logger = setup_module_logger(__name__)
15
+
16
+ # Context variable to store trace metadata for propagation to LM calls
17
+ # This allows Rust LM layer to access traceparent without explicit parameter passing
18
+ _trace_metadata: contextvars.ContextVar[Dict[str, str]] = contextvars.ContextVar(
19
+ '_trace_metadata', default={}
20
+ )
21
+
22
+
23
+ class Worker:
24
+ """AGNT5 Worker for registering and running functions/workflows with the coordinator.
25
+
26
+ The Worker class manages the lifecycle of your service, including:
27
+ - Registration with the AGNT5 coordinator
28
+ - Automatic discovery of @function and @workflow decorated handlers
29
+ - Message handling and execution
30
+ - Health monitoring
31
+
32
+ Example:
33
+ ```python
34
+ from agnt5 import Worker, function
35
+
36
+ @function
37
+ async def process_data(ctx: Context, data: str) -> dict:
38
+ return {"result": data.upper()}
39
+
40
+ async def main():
41
+ worker = Worker(
42
+ service_name="data-processor",
43
+ service_version="1.0.0",
44
+ coordinator_endpoint="http://localhost:34186"
45
+ )
46
+ await worker.run()
47
+
48
+ if __name__ == "__main__":
49
+ asyncio.run(main())
50
+ ```
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ service_name: str,
56
+ service_version: str = "1.0.0",
57
+ coordinator_endpoint: Optional[str] = None,
58
+ runtime: str = "standalone",
59
+ metadata: Optional[Dict[str, str]] = None,
60
+ functions: Optional[List] = None,
61
+ workflows: Optional[List] = None,
62
+ entities: Optional[List] = None,
63
+ agents: Optional[List] = None,
64
+ tools: Optional[List] = None,
65
+ auto_register: bool = False,
66
+ auto_register_paths: Optional[List[str]] = None,
67
+ pyproject_path: Optional[str] = None,
68
+ ):
69
+ """Initialize a new Worker with explicit or automatic component registration.
70
+
71
+ The Worker supports two registration modes:
72
+
73
+ **Explicit Mode (default, production):**
74
+ - Register workflows/agents explicitly, their dependencies are auto-included
75
+ - Optionally register standalone functions/tools for direct API invocation
76
+
77
+ **Auto-Registration Mode (development):**
78
+ - Automatically discovers all decorated components in source paths
79
+ - Reads source paths from pyproject.toml or uses explicit paths
80
+ - No need to maintain import lists
81
+
82
+ Args:
83
+ service_name: Unique name for this service
84
+ service_version: Version string (semantic versioning recommended)
85
+ coordinator_endpoint: Coordinator endpoint URL (default: from env AGNT5_COORDINATOR_ENDPOINT)
86
+ runtime: Runtime type - "standalone", "docker", "kubernetes", etc.
87
+ metadata: Optional service-level metadata
88
+ functions: List of @function decorated handlers (explicit mode)
89
+ workflows: List of @workflow decorated handlers (explicit mode)
90
+ entities: List of Entity classes (explicit mode)
91
+ agents: List of Agent instances (explicit mode)
92
+ tools: List of Tool instances (explicit mode)
93
+ auto_register: Enable automatic component discovery (default: False)
94
+ auto_register_paths: Explicit source paths to scan (overrides pyproject.toml discovery)
95
+ pyproject_path: Path to pyproject.toml (default: current directory)
96
+
97
+ Example (explicit mode - production):
98
+ ```python
99
+ from agnt5 import Worker
100
+ from my_service import greet_user, order_fulfillment, ShoppingCart, analyst_agent
101
+
102
+ worker = Worker(
103
+ service_name="my-service",
104
+ workflows=[order_fulfillment],
105
+ entities=[ShoppingCart],
106
+ agents=[analyst_agent],
107
+ functions=[greet_user],
108
+ )
109
+ await worker.run()
110
+ ```
111
+
112
+ Example (auto-register mode - development):
113
+ ```python
114
+ from agnt5 import Worker
115
+
116
+ worker = Worker(
117
+ service_name="my-service",
118
+ auto_register=True, # Discovers from pyproject.toml
119
+ )
120
+ await worker.run()
121
+ ```
122
+ """
123
+ self.service_name = service_name
124
+ self.service_version = service_version
125
+ self.coordinator_endpoint = coordinator_endpoint
126
+ self.runtime = runtime
127
+ self.metadata = metadata or {}
128
+
129
+ # Import Rust worker
130
+ try:
131
+ from ._core import PyWorker, PyWorkerConfig, PyComponentInfo
132
+ self._PyWorker = PyWorker
133
+ self._PyWorkerConfig = PyWorkerConfig
134
+ self._PyComponentInfo = PyComponentInfo
135
+ except ImportError as e:
136
+ raise ImportError(
137
+ f"Failed to import Rust core worker: {e}. "
138
+ "Make sure agnt5 is properly installed with: pip install agnt5"
139
+ )
140
+
141
+ # Create Rust worker config
142
+ self._rust_config = self._PyWorkerConfig(
143
+ service_name=service_name,
144
+ service_version=service_version,
145
+ service_type=runtime,
146
+ )
147
+
148
+ # Create Rust worker instance
149
+ self._rust_worker = self._PyWorker(self._rust_config)
150
+
151
+ # Create worker-scoped entity state manager
152
+ from .entity import EntityStateManager
153
+ self._entity_state_manager = EntityStateManager()
154
+
155
+ # Component registration: auto-discover or explicit
156
+ if auto_register:
157
+ # Auto-registration mode: discover from source paths
158
+ if auto_register_paths:
159
+ source_paths = auto_register_paths
160
+ logger.info(f"Auto-registration with explicit paths: {source_paths}")
161
+ else:
162
+ source_paths = self._discover_source_paths(pyproject_path)
163
+ logger.info(f"Auto-registration with discovered paths: {source_paths}")
164
+
165
+ # Auto-discover components (will populate _explicit_components)
166
+ self._auto_discover_components(source_paths)
167
+ else:
168
+ # Explicit registration from constructor kwargs
169
+ self._explicit_components = {
170
+ 'functions': list(functions or []),
171
+ 'workflows': list(workflows or []),
172
+ 'entities': list(entities or []),
173
+ 'agents': list(agents or []),
174
+ 'tools': list(tools or []),
175
+ }
176
+
177
+ # Count explicitly registered components
178
+ total_explicit = sum(len(v) for v in self._explicit_components.values())
179
+ logger.info(
180
+ f"Worker initialized: {service_name} v{service_version} (runtime: {runtime}), "
181
+ f"{total_explicit} components explicitly registered"
182
+ )
183
+
184
+ def register_components(
185
+ self,
186
+ functions=None,
187
+ workflows=None,
188
+ entities=None,
189
+ agents=None,
190
+ tools=None,
191
+ ):
192
+ """Register additional components after Worker initialization.
193
+
194
+ This method allows incremental registration of components after the Worker
195
+ has been created. Useful for conditional or dynamic component registration.
196
+
197
+ Args:
198
+ functions: List of functions decorated with @function
199
+ workflows: List of workflows decorated with @workflow
200
+ entities: List of entity classes
201
+ agents: List of agent instances
202
+ tools: List of tool instances
203
+
204
+ Example:
205
+ ```python
206
+ worker = Worker(service_name="my-service")
207
+
208
+ # Register conditionally
209
+ if feature_enabled:
210
+ worker.register_components(workflows=[advanced_workflow])
211
+ ```
212
+ """
213
+ if functions:
214
+ self._explicit_components['functions'].extend(functions)
215
+ logger.debug(f"Incrementally registered {len(functions)} functions")
216
+
217
+ if workflows:
218
+ self._explicit_components['workflows'].extend(workflows)
219
+ logger.debug(f"Incrementally registered {len(workflows)} workflows")
220
+
221
+ if entities:
222
+ self._explicit_components['entities'].extend(entities)
223
+ logger.debug(f"Incrementally registered {len(entities)} entities")
224
+
225
+ if agents:
226
+ self._explicit_components['agents'].extend(agents)
227
+ logger.debug(f"Incrementally registered {len(agents)} agents")
228
+
229
+ if tools:
230
+ self._explicit_components['tools'].extend(tools)
231
+ logger.debug(f"Incrementally registered {len(tools)} tools")
232
+
233
+ total = sum(len(v) for v in self._explicit_components.values())
234
+ logger.info(f"Total components now registered: {total}")
235
+
236
+ def _discover_source_paths(self, pyproject_path: Optional[str] = None) -> List[str]:
237
+ """Discover source paths from pyproject.toml.
238
+
239
+ Reads pyproject.toml to find package source directories using:
240
+ - Hatch: [tool.hatch.build.targets.wheel] packages
241
+ - Maturin: [tool.maturin] python-source
242
+ - Fallback: ["src"] if not found
243
+
244
+ Args:
245
+ pyproject_path: Path to pyproject.toml (default: current directory)
246
+
247
+ Returns:
248
+ List of directory paths to scan (e.g., ["src/agnt5_benchmark"])
249
+ """
250
+ from pathlib import Path
251
+
252
+ # Python 3.11+ has tomllib in stdlib
253
+ try:
254
+ import tomllib
255
+ except ImportError:
256
+ logger.error("tomllib not available (Python 3.11+ required for auto-registration)")
257
+ return ["src"]
258
+
259
+ # Determine pyproject.toml location
260
+ if pyproject_path:
261
+ pyproject_file = Path(pyproject_path)
262
+ else:
263
+ # Look in current directory
264
+ pyproject_file = Path.cwd() / "pyproject.toml"
265
+
266
+ if not pyproject_file.exists():
267
+ logger.warning(
268
+ f"pyproject.toml not found at {pyproject_file}, "
269
+ f"defaulting to 'src/' directory"
270
+ )
271
+ return ["src"]
272
+
273
+ # Parse pyproject.toml
274
+ try:
275
+ with open(pyproject_file, "rb") as f:
276
+ config = tomllib.load(f)
277
+ except Exception as e:
278
+ logger.error(f"Failed to parse pyproject.toml: {e}")
279
+ return ["src"]
280
+
281
+ # Extract source paths based on build system
282
+ source_paths = []
283
+
284
+ # Try Hatch configuration
285
+ if "tool" in config and "hatch" in config["tool"]:
286
+ hatch_config = config["tool"]["hatch"]
287
+ if "build" in hatch_config and "targets" in hatch_config["build"]:
288
+ wheel_config = hatch_config["build"]["targets"].get("wheel", {})
289
+ packages = wheel_config.get("packages", [])
290
+ source_paths.extend(packages)
291
+
292
+ # Try Maturin configuration
293
+ if not source_paths and "tool" in config and "maturin" in config["tool"]:
294
+ maturin_config = config["tool"]["maturin"]
295
+ python_source = maturin_config.get("python-source")
296
+ if python_source:
297
+ source_paths.append(python_source)
298
+
299
+ # Fallback to src/
300
+ if not source_paths:
301
+ logger.info("No source paths in pyproject.toml, defaulting to 'src/'")
302
+ source_paths = ["src"]
303
+
304
+ logger.info(f"Discovered source paths from pyproject.toml: {source_paths}")
305
+ return source_paths
306
+
307
+ def _auto_discover_components(self, source_paths: List[str]) -> None:
308
+ """Auto-discover components by importing all Python files in source paths.
309
+
310
+ Args:
311
+ source_paths: List of directory paths to scan
312
+ """
313
+ import importlib.util
314
+ import sys
315
+ from pathlib import Path
316
+
317
+ logger.info(f"Auto-discovering components in paths: {source_paths}")
318
+
319
+ total_modules = 0
320
+
321
+ for source_path in source_paths:
322
+ path = Path(source_path)
323
+
324
+ if not path.exists():
325
+ logger.warning(f"Source path does not exist: {source_path}")
326
+ continue
327
+
328
+ # Recursively find all .py files
329
+ for py_file in path.rglob("*.py"):
330
+ # Skip __pycache__ and test files
331
+ if "__pycache__" in str(py_file) or py_file.name.startswith("test_"):
332
+ continue
333
+
334
+ # Convert path to module name
335
+ # e.g., src/agnt5_benchmark/functions.py -> agnt5_benchmark.functions
336
+ relative_path = py_file.relative_to(path.parent)
337
+ module_parts = list(relative_path.parts[:-1]) # Remove .py extension part
338
+ module_parts.append(relative_path.stem) # Add filename without .py
339
+ module_name = ".".join(module_parts)
340
+
341
+ # Import module (triggers decorators)
342
+ try:
343
+ if module_name in sys.modules:
344
+ logger.debug(f"Module already imported: {module_name}")
345
+ else:
346
+ spec = importlib.util.spec_from_file_location(module_name, py_file)
347
+ if spec and spec.loader:
348
+ module = importlib.util.module_from_spec(spec)
349
+ sys.modules[module_name] = module
350
+ spec.loader.exec_module(module)
351
+ logger.debug(f"Auto-imported: {module_name}")
352
+ total_modules += 1
353
+ except Exception as e:
354
+ logger.warning(f"Failed to import {module_name}: {e}")
355
+
356
+ logger.info(f"Auto-imported {total_modules} modules")
357
+
358
+ # Collect components from registries
359
+ from .agent import AgentRegistry
360
+ from .entity import EntityRegistry
361
+ from .tool import ToolRegistry
362
+
363
+ # Extract actual objects from registries
364
+ functions = [cfg.handler for cfg in FunctionRegistry.all().values()]
365
+ workflows = [cfg.handler for cfg in WorkflowRegistry.all().values()]
366
+ entities = [et.entity_class for et in EntityRegistry.all().values()]
367
+ agents = list(AgentRegistry.all().values())
368
+ tools = list(ToolRegistry.all().values())
369
+
370
+ self._explicit_components = {
371
+ 'functions': functions,
372
+ 'workflows': workflows,
373
+ 'entities': entities,
374
+ 'agents': agents,
375
+ 'tools': tools,
376
+ }
377
+
378
+ logger.info(
379
+ f"Auto-discovered components: "
380
+ f"{len(functions)} functions, "
381
+ f"{len(workflows)} workflows, "
382
+ f"{len(entities)} entities, "
383
+ f"{len(agents)} agents, "
384
+ f"{len(tools)} tools"
385
+ )
386
+
387
+ def _discover_components(self):
388
+ """Discover explicit components and auto-include their dependencies.
389
+
390
+ Hybrid approach:
391
+ - Explicitly registered workflows/agents are processed
392
+ - Functions called by workflows are auto-included (TODO: implement)
393
+ - Tools used by agents are auto-included
394
+ - Standalone functions/tools can be explicitly registered
395
+
396
+ Returns:
397
+ List of PyComponentInfo instances for all components
398
+ """
399
+ components = []
400
+ import json
401
+
402
+ # Import registries
403
+ from .entity import EntityRegistry
404
+ from .tool import ToolRegistry
405
+
406
+ # Track all components (explicit + auto-included)
407
+ all_functions = set(self._explicit_components['functions'])
408
+ all_tools = set(self._explicit_components['tools'])
409
+
410
+ # Auto-include agent tool dependencies
411
+ for agent in self._explicit_components['agents']:
412
+ if hasattr(agent, 'tools') and agent.tools:
413
+ # Agent.tools is a dict of {tool_name: tool_instance}
414
+ all_tools.update(agent.tools.values())
415
+ logger.debug(
416
+ f"Auto-included {len(agent.tools)} tools from agent '{agent.name}'"
417
+ )
418
+
419
+ # Log registration summary
420
+ explicit_func_count = len(self._explicit_components['functions'])
421
+ explicit_tool_count = len(self._explicit_components['tools'])
422
+ auto_func_count = len(all_functions) - explicit_func_count
423
+ auto_tool_count = len(all_tools) - explicit_tool_count
424
+
425
+ logger.info(
426
+ f"Component registration summary: "
427
+ f"{len(all_functions)} functions ({explicit_func_count} explicit, {auto_func_count} auto-included), "
428
+ f"{len(self._explicit_components['workflows'])} workflows, "
429
+ f"{len(self._explicit_components['entities'])} entities, "
430
+ f"{len(self._explicit_components['agents'])} agents, "
431
+ f"{len(all_tools)} tools ({explicit_tool_count} explicit, {auto_tool_count} auto-included)"
432
+ )
433
+
434
+ # Process functions (explicit + auto-included)
435
+ for func in all_functions:
436
+ config = FunctionRegistry.get(func.__name__)
437
+ if not config:
438
+ logger.warning(f"Function '{func.__name__}' not found in FunctionRegistry")
439
+ continue
440
+
441
+ input_schema_str = json.dumps(config.input_schema) if config.input_schema else None
442
+ output_schema_str = json.dumps(config.output_schema) if config.output_schema else None
443
+ metadata = config.metadata if config.metadata else {}
444
+
445
+ component_info = self._PyComponentInfo(
446
+ name=config.name,
447
+ component_type="function",
448
+ metadata=metadata,
449
+ config={},
450
+ input_schema=input_schema_str,
451
+ output_schema=output_schema_str,
452
+ definition=None,
453
+ )
454
+ components.append(component_info)
455
+
456
+ # Process workflows
457
+ for workflow in self._explicit_components['workflows']:
458
+ config = WorkflowRegistry.get(workflow.__name__)
459
+ if not config:
460
+ logger.warning(f"Workflow '{workflow.__name__}' not found in WorkflowRegistry")
461
+ continue
462
+
463
+ input_schema_str = json.dumps(config.input_schema) if config.input_schema else None
464
+ output_schema_str = json.dumps(config.output_schema) if config.output_schema else None
465
+ metadata = config.metadata if config.metadata else {}
466
+
467
+ component_info = self._PyComponentInfo(
468
+ name=config.name,
469
+ component_type="workflow",
470
+ metadata=metadata,
471
+ config={},
472
+ input_schema=input_schema_str,
473
+ output_schema=output_schema_str,
474
+ definition=None,
475
+ )
476
+ components.append(component_info)
477
+
478
+ # Process entities
479
+ for entity_class in self._explicit_components['entities']:
480
+ entity_type = EntityRegistry.get(entity_class.__name__)
481
+ if not entity_type:
482
+ logger.warning(f"Entity '{entity_class.__name__}' not found in EntityRegistry")
483
+ continue
484
+
485
+ # Build complete entity definition with state schema and method schemas
486
+ entity_definition = entity_type.build_entity_definition()
487
+ definition_str = json.dumps(entity_definition)
488
+
489
+ # Keep minimal metadata for backward compatibility
490
+ metadata_dict = {
491
+ "methods": json.dumps(list(entity_type._method_schemas.keys())),
492
+ }
493
+
494
+ component_info = self._PyComponentInfo(
495
+ name=entity_type.name,
496
+ component_type="entity",
497
+ metadata=metadata_dict,
498
+ config={},
499
+ input_schema=None, # Entities don't have single input/output schemas
500
+ output_schema=None,
501
+ definition=definition_str, # Complete entity definition with state and methods
502
+ )
503
+ components.append(component_info)
504
+ logger.debug(f"Registered entity '{entity_type.name}' with definition")
505
+
506
+ # Process agents
507
+ from .agent import AgentRegistry
508
+
509
+ for agent in self._explicit_components['agents']:
510
+ # Register agent in AgentRegistry for execution lookup
511
+ AgentRegistry.register(agent)
512
+ logger.debug(f"Registered agent '{agent.name}' in AgentRegistry for execution")
513
+
514
+ input_schema_str = json.dumps(agent.input_schema) if hasattr(agent, 'input_schema') and agent.input_schema else None
515
+ output_schema_str = json.dumps(agent.output_schema) if hasattr(agent, 'output_schema') and agent.output_schema else None
516
+
517
+ metadata_dict = agent.metadata if hasattr(agent, 'metadata') else {}
518
+ if hasattr(agent, 'tools'):
519
+ metadata_dict["tools"] = json.dumps(list(agent.tools.keys()))
520
+
521
+ component_info = self._PyComponentInfo(
522
+ name=agent.name,
523
+ component_type="agent",
524
+ metadata=metadata_dict,
525
+ config={},
526
+ input_schema=input_schema_str,
527
+ output_schema=output_schema_str,
528
+ definition=None,
529
+ )
530
+ components.append(component_info)
531
+
532
+ # Process tools (explicit + auto-included)
533
+ for tool in all_tools:
534
+ input_schema_str = json.dumps(tool.input_schema) if hasattr(tool, 'input_schema') and tool.input_schema else None
535
+ output_schema_str = json.dumps(tool.output_schema) if hasattr(tool, 'output_schema') and tool.output_schema else None
536
+
537
+ component_info = self._PyComponentInfo(
538
+ name=tool.name,
539
+ component_type="tool",
540
+ metadata={},
541
+ config={},
542
+ input_schema=input_schema_str,
543
+ output_schema=output_schema_str,
544
+ definition=None,
545
+ )
546
+ components.append(component_info)
547
+
548
+ logger.info(f"Discovered {len(components)} total components")
549
+ return components
550
+
551
+ def _create_message_handler(self):
552
+ """Create the message handler that will be called by Rust worker."""
553
+
554
+ def handle_message(request):
555
+ """Handle incoming execution requests - returns coroutine for Rust to await."""
556
+ # Extract request details
557
+ component_name = request.component_name
558
+ component_type = request.component_type
559
+ input_data = request.input_data
560
+
561
+ logger.debug(
562
+ f"Handling {component_type} request: {component_name}, input size: {len(input_data)} bytes"
563
+ )
564
+
565
+ # Import all registries
566
+ from .tool import ToolRegistry
567
+ from .entity import EntityRegistry
568
+ from .agent import AgentRegistry
569
+
570
+ # Route based on component type and return coroutines
571
+ if component_type == "tool":
572
+ tool = ToolRegistry.get(component_name)
573
+ if tool:
574
+ logger.debug(f"Found tool: {component_name}")
575
+ # Return coroutine, don't await it
576
+ return self._execute_tool(tool, input_data, request)
577
+
578
+ elif component_type == "entity":
579
+ entity_type = EntityRegistry.get(component_name)
580
+ if entity_type:
581
+ logger.debug(f"Found entity: {component_name}")
582
+ # Return coroutine, don't await it
583
+ return self._execute_entity(entity_type, input_data, request)
584
+
585
+ elif component_type == "agent":
586
+ agent = AgentRegistry.get(component_name)
587
+ if agent:
588
+ logger.debug(f"Found agent: {component_name}")
589
+ # Return coroutine, don't await it
590
+ return self._execute_agent(agent, input_data, request)
591
+
592
+ elif component_type == "workflow":
593
+ workflow_config = WorkflowRegistry.get(component_name)
594
+ if workflow_config:
595
+ logger.debug(f"Found workflow: {component_name}")
596
+ # Return coroutine, don't await it
597
+ return self._execute_workflow(workflow_config, input_data, request)
598
+
599
+ elif component_type == "function":
600
+ function_config = FunctionRegistry.get(component_name)
601
+ if function_config:
602
+ logger.info(f"🔥 WORKER: Received request for function: {component_name}")
603
+ # Return coroutine, don't await it
604
+ return self._execute_function(function_config, input_data, request)
605
+
606
+ # Not found - need to return an async error response
607
+ error_msg = f"Component '{component_name}' of type '{component_type}' not found"
608
+ logger.error(error_msg)
609
+
610
+ # Create async wrapper for error response
611
+ async def error_response():
612
+ return self._create_error_response(request, error_msg)
613
+
614
+ return error_response()
615
+
616
+ return handle_message
617
+
618
+ async def _execute_function(self, config, input_data: bytes, request):
619
+ """Execute a function handler (supports both regular and streaming functions)."""
620
+ import json
621
+ import inspect
622
+ import time
623
+ from .context import Context
624
+ from ._core import PyExecuteComponentResponse
625
+
626
+ exec_start = time.time()
627
+ logger.info(f"🔥 WORKER: Executing function {config.name}")
628
+
629
+ try:
630
+ # Parse input data
631
+ input_dict = json.loads(input_data.decode("utf-8")) if input_data else {}
632
+
633
+ # Store trace metadata in contextvar for LM calls to access
634
+ # The Rust worker injects traceparent into request.metadata for trace propagation
635
+ if hasattr(request, 'metadata') and request.metadata:
636
+ _trace_metadata.set(dict(request.metadata))
637
+ logger.debug(f"Trace metadata stored: traceparent={request.metadata.get('traceparent', 'N/A')}")
638
+
639
+ # Create context with runtime_context for trace correlation
640
+ ctx = Context(
641
+ run_id=f"{self.service_name}:{config.name}",
642
+ runtime_context=request.runtime_context,
643
+ )
644
+
645
+ # Create span for function execution with trace linking
646
+ from ._core import create_span
647
+
648
+ with create_span(
649
+ config.name,
650
+ "function",
651
+ request.runtime_context,
652
+ {
653
+ "function.name": config.name,
654
+ "service.name": self.service_name,
655
+ },
656
+ ) as span:
657
+ # Execute function
658
+ if input_dict:
659
+ result = config.handler(ctx, **input_dict)
660
+ else:
661
+ result = config.handler(ctx)
662
+
663
+ # Debug: Log what type result is
664
+ logger.info(f"🔥 WORKER: Function result type: {type(result).__name__}, isasyncgen: {inspect.isasyncgen(result)}, iscoroutine: {inspect.iscoroutine(result)}")
665
+
666
+ # Note: Removed flush_telemetry_py() call here - it was causing 2-second blocking delay!
667
+ # The batch span processor handles flushing automatically with 5s timeout
668
+ # We only need to flush on worker shutdown, not after each function execution
669
+
670
+ # Check if result is an async generator (streaming function)
671
+ if inspect.isasyncgen(result):
672
+ # Streaming function - return list of responses
673
+ # Rust bridge will send each response separately to coordinator
674
+ responses = []
675
+ chunk_index = 0
676
+
677
+ async for chunk in result:
678
+ # Serialize chunk
679
+ chunk_data = json.dumps(chunk).encode("utf-8")
680
+
681
+ responses.append(PyExecuteComponentResponse(
682
+ invocation_id=request.invocation_id,
683
+ success=True,
684
+ output_data=chunk_data,
685
+ state_update=None,
686
+ error_message=None,
687
+ metadata=None,
688
+ is_chunk=True,
689
+ done=False,
690
+ chunk_index=chunk_index,
691
+ ))
692
+ chunk_index += 1
693
+
694
+ # Add final "done" marker
695
+ responses.append(PyExecuteComponentResponse(
696
+ invocation_id=request.invocation_id,
697
+ success=True,
698
+ output_data=b"",
699
+ state_update=None,
700
+ error_message=None,
701
+ metadata=None,
702
+ is_chunk=True,
703
+ done=True,
704
+ chunk_index=chunk_index,
705
+ ))
706
+
707
+ logger.debug(f"Streaming function produced {len(responses)} chunks")
708
+ return responses
709
+ else:
710
+ # Regular function - await and return single response
711
+ if inspect.iscoroutine(result):
712
+ result = await result
713
+
714
+ # Serialize result
715
+ output_data = json.dumps(result).encode("utf-8")
716
+
717
+ return PyExecuteComponentResponse(
718
+ invocation_id=request.invocation_id,
719
+ success=True,
720
+ output_data=output_data,
721
+ state_update=None,
722
+ error_message=None,
723
+ metadata=None,
724
+ is_chunk=False,
725
+ done=True,
726
+ chunk_index=0,
727
+ )
728
+
729
+ except Exception as e:
730
+ # Include exception type for better error messages
731
+ error_msg = f"{type(e).__name__}: {str(e)}"
732
+ logger.error(f"Function execution failed: {error_msg}", exc_info=True)
733
+ return PyExecuteComponentResponse(
734
+ invocation_id=request.invocation_id,
735
+ success=False,
736
+ output_data=b"",
737
+ state_update=None,
738
+ error_message=error_msg,
739
+ metadata=None,
740
+ is_chunk=False,
741
+ done=True,
742
+ chunk_index=0,
743
+ )
744
+
745
+ async def _execute_workflow(self, config, input_data: bytes, request):
746
+ """Execute a workflow handler with automatic replay support."""
747
+ import json
748
+ from .workflow import WorkflowEntity, WorkflowContext
749
+ from .entity import _get_state_manager
750
+ from ._core import PyExecuteComponentResponse
751
+
752
+ try:
753
+ # Parse input data
754
+ input_dict = json.loads(input_data.decode("utf-8")) if input_data else {}
755
+
756
+ # Parse replay data from request metadata for crash recovery
757
+ completed_steps = {}
758
+ initial_state = {}
759
+
760
+ if hasattr(request, 'metadata') and request.metadata:
761
+ # Parse completed steps for replay
762
+ if "completed_steps" in request.metadata:
763
+ completed_steps_json = request.metadata["completed_steps"]
764
+ if completed_steps_json:
765
+ try:
766
+ completed_steps = json.loads(completed_steps_json)
767
+ logger.info(f"🔄 Replaying workflow with {len(completed_steps)} cached steps")
768
+ except json.JSONDecodeError:
769
+ logger.warning("Failed to parse completed_steps from metadata")
770
+
771
+ # Parse initial workflow state for replay
772
+ if "workflow_state" in request.metadata:
773
+ workflow_state_json = request.metadata["workflow_state"]
774
+ if workflow_state_json:
775
+ try:
776
+ initial_state = json.loads(workflow_state_json)
777
+ logger.info(f"🔄 Loaded workflow state: {len(initial_state)} keys")
778
+ except json.JSONDecodeError:
779
+ logger.warning("Failed to parse workflow_state from metadata")
780
+
781
+ # Create WorkflowEntity for state management
782
+ workflow_entity = WorkflowEntity(run_id=f"{self.service_name}:{config.name}")
783
+
784
+ # Load replay data into entity if provided
785
+ if completed_steps:
786
+ workflow_entity._completed_steps = completed_steps
787
+ logger.debug(f"Loaded {len(completed_steps)} completed steps into workflow entity")
788
+
789
+ if initial_state:
790
+ # Load initial state into entity's state manager
791
+ state_manager = _get_state_manager()
792
+ state_manager._states[workflow_entity._state_key] = initial_state
793
+ logger.debug(f"Loaded initial state with {len(initial_state)} keys into workflow entity")
794
+
795
+ # Create WorkflowContext with entity and runtime_context for trace correlation
796
+ ctx = WorkflowContext(
797
+ workflow_entity=workflow_entity,
798
+ run_id=f"{self.service_name}:{config.name}",
799
+ runtime_context=request.runtime_context,
800
+ )
801
+
802
+ # Create span for workflow execution with trace linking
803
+ from ._core import create_span
804
+
805
+ with create_span(
806
+ config.name,
807
+ "workflow",
808
+ request.runtime_context,
809
+ {
810
+ "workflow.name": config.name,
811
+ "service.name": self.service_name,
812
+ },
813
+ ) as span:
814
+ # Execute workflow
815
+ if input_dict:
816
+ result = await config.handler(ctx, **input_dict)
817
+ else:
818
+ result = await config.handler(ctx)
819
+
820
+ # Note: Removed flush_telemetry_py() call here - it was causing 2-second blocking delay!
821
+ # The batch span processor handles flushing automatically with 5s timeout
822
+
823
+ # Serialize result
824
+ output_data = json.dumps(result).encode("utf-8")
825
+
826
+ # Collect workflow execution metadata for durability
827
+ metadata = {}
828
+
829
+ # Add step events to metadata (for workflow durability)
830
+ # Access _step_events from the workflow entity, not the context
831
+ step_events = ctx._workflow_entity._step_events
832
+ if step_events:
833
+ metadata["step_events"] = json.dumps(step_events)
834
+ logger.debug(f"Workflow has {len(step_events)} recorded steps")
835
+
836
+ # Add final state snapshot to metadata (if state was used)
837
+ # Check if _state was initialized without triggering property getter
838
+ if hasattr(ctx, '_workflow_entity') and ctx._workflow_entity._state is not None:
839
+ if ctx._workflow_entity._state.has_changes():
840
+ state_snapshot = ctx._workflow_entity._state.get_state_snapshot()
841
+ metadata["workflow_state"] = json.dumps(state_snapshot)
842
+ logger.debug(f"Workflow state snapshot: {state_snapshot}")
843
+
844
+ logger.info(f"Workflow completed successfully with {len(step_events)} steps")
845
+
846
+ return PyExecuteComponentResponse(
847
+ invocation_id=request.invocation_id,
848
+ success=True,
849
+ output_data=output_data,
850
+ state_update=None, # Not used for workflows (use metadata instead)
851
+ error_message=None,
852
+ metadata=metadata if metadata else None, # Include step events + state
853
+ is_chunk=False,
854
+ done=True,
855
+ chunk_index=0,
856
+ )
857
+
858
+ except Exception as e:
859
+ # Include exception type for better error messages
860
+ error_msg = f"{type(e).__name__}: {str(e)}"
861
+ logger.error(f"Workflow execution failed: {error_msg}", exc_info=True)
862
+ return PyExecuteComponentResponse(
863
+ invocation_id=request.invocation_id,
864
+ success=False,
865
+ output_data=b"",
866
+ state_update=None,
867
+ error_message=error_msg,
868
+ metadata=None,
869
+ is_chunk=False,
870
+ done=True,
871
+ chunk_index=0,
872
+ )
873
+
874
+ async def _execute_tool(self, tool, input_data: bytes, request):
875
+ """Execute a tool handler."""
876
+ import json
877
+ from .context import Context
878
+ from ._core import PyExecuteComponentResponse
879
+
880
+ try:
881
+ # Parse input data
882
+ input_dict = json.loads(input_data.decode("utf-8")) if input_data else {}
883
+
884
+ # Create context with runtime_context for trace correlation
885
+ ctx = Context(
886
+ run_id=f"{self.service_name}:{tool.name}",
887
+ runtime_context=request.runtime_context,
888
+ )
889
+
890
+ # Execute tool
891
+ result = await tool.invoke(ctx, **input_dict)
892
+
893
+ # Serialize result
894
+ output_data = json.dumps(result).encode("utf-8")
895
+
896
+ return PyExecuteComponentResponse(
897
+ invocation_id=request.invocation_id,
898
+ success=True,
899
+ output_data=output_data,
900
+ state_update=None,
901
+ error_message=None,
902
+ metadata=None,
903
+ is_chunk=False,
904
+ done=True,
905
+ chunk_index=0,
906
+ )
907
+
908
+ except Exception as e:
909
+ # Include exception type for better error messages
910
+ error_msg = f"{type(e).__name__}: {str(e)}"
911
+ logger.error(f"Tool execution failed: {error_msg}", exc_info=True)
912
+ return PyExecuteComponentResponse(
913
+ invocation_id=request.invocation_id,
914
+ success=False,
915
+ output_data=b"",
916
+ state_update=None,
917
+ error_message=error_msg,
918
+ metadata=None,
919
+ is_chunk=False,
920
+ done=True,
921
+ chunk_index=0,
922
+ )
923
+
924
+ async def _execute_entity(self, entity_type, input_data: bytes, request):
925
+ """Execute an entity method."""
926
+ import json
927
+ from .context import Context
928
+ from .entity import EntityType, Entity, _entity_state_manager_ctx
929
+ from ._core import PyExecuteComponentResponse
930
+
931
+ # Set entity state manager in context for Entity instances to access
932
+ _entity_state_manager_ctx.set(self._entity_state_manager)
933
+
934
+ try:
935
+ # Parse input data
936
+ input_dict = json.loads(input_data.decode("utf-8")) if input_data else {}
937
+
938
+ # Extract entity key and method name from input
939
+ entity_key = input_dict.pop("key", None)
940
+ method_name = input_dict.pop("method", None)
941
+
942
+ if not entity_key:
943
+ raise ValueError("Entity invocation requires 'key' parameter")
944
+ if not method_name:
945
+ raise ValueError("Entity invocation requires 'method' parameter")
946
+
947
+ # Load state from platform if provided in request metadata
948
+ state_key = (entity_type.name, entity_key)
949
+ if hasattr(request, 'metadata') and request.metadata:
950
+ if "entity_state" in request.metadata:
951
+ platform_state_json = request.metadata["entity_state"]
952
+ platform_version = int(request.metadata.get("state_version", "0"))
953
+
954
+ # Load platform state into state manager
955
+ self._entity_state_manager.load_state_from_platform(
956
+ state_key,
957
+ platform_state_json,
958
+ platform_version
959
+ )
960
+ logger.info(
961
+ f"Loaded entity state from platform: {entity_type.name}/{entity_key} "
962
+ f"(version {platform_version})"
963
+ )
964
+
965
+ # Create entity instance using the stored class reference
966
+ entity_instance = entity_type.entity_class(key=entity_key)
967
+
968
+ # Get method
969
+ if not hasattr(entity_instance, method_name):
970
+ raise ValueError(f"Entity '{entity_type.name}' has no method '{method_name}'")
971
+
972
+ method = getattr(entity_instance, method_name)
973
+
974
+ # Execute method
975
+ result = await method(**input_dict)
976
+
977
+ # Serialize result
978
+ output_data = json.dumps(result).encode("utf-8")
979
+
980
+ # Capture entity state after execution with version tracking
981
+ state_dict, expected_version, new_version = \
982
+ self._entity_state_manager.get_state_for_persistence(state_key)
983
+
984
+ metadata = {}
985
+ if state_dict:
986
+ # Serialize state as JSON string for platform persistence
987
+ state_json = json.dumps(state_dict)
988
+ # Pass in metadata for Worker Coordinator to publish
989
+ metadata = {
990
+ "entity_state": state_json,
991
+ "entity_type": entity_type.name,
992
+ "entity_key": entity_key,
993
+ "expected_version": str(expected_version),
994
+ "new_version": str(new_version),
995
+ }
996
+ logger.info(
997
+ f"Captured entity state: {entity_type.name}/{entity_key} "
998
+ f"(version {expected_version} → {new_version})"
999
+ )
1000
+
1001
+ return PyExecuteComponentResponse(
1002
+ invocation_id=request.invocation_id,
1003
+ success=True,
1004
+ output_data=output_data,
1005
+ state_update=None, # TODO: Use structured StateUpdate object
1006
+ error_message=None,
1007
+ metadata=metadata, # Include state in metadata for Worker Coordinator
1008
+ is_chunk=False,
1009
+ done=True,
1010
+ chunk_index=0,
1011
+ )
1012
+
1013
+ except Exception as e:
1014
+ # Include exception type for better error messages
1015
+ error_msg = f"{type(e).__name__}: {str(e)}"
1016
+ logger.error(f"Entity execution failed: {error_msg}", exc_info=True)
1017
+ return PyExecuteComponentResponse(
1018
+ invocation_id=request.invocation_id,
1019
+ success=False,
1020
+ output_data=b"",
1021
+ state_update=None,
1022
+ error_message=error_msg,
1023
+ metadata=None,
1024
+ is_chunk=False,
1025
+ done=True,
1026
+ chunk_index=0,
1027
+ )
1028
+
1029
+ async def _execute_agent(self, agent, input_data: bytes, request):
1030
+ """Execute an agent."""
1031
+ import json
1032
+ from .context import Context
1033
+ from ._core import PyExecuteComponentResponse
1034
+
1035
+ try:
1036
+ # Parse input data
1037
+ input_dict = json.loads(input_data.decode("utf-8")) if input_data else {}
1038
+
1039
+ # Extract user message
1040
+ user_message = input_dict.get("message", "")
1041
+ if not user_message:
1042
+ raise ValueError("Agent invocation requires 'message' parameter")
1043
+
1044
+ # Create context with runtime_context for trace correlation
1045
+ ctx = Context(
1046
+ run_id=f"{self.service_name}:{agent.name}",
1047
+ runtime_context=request.runtime_context,
1048
+ )
1049
+
1050
+ # Execute agent
1051
+ agent_result = await agent.run(user_message, context=ctx)
1052
+
1053
+ # Build response
1054
+ result = {
1055
+ "output": agent_result.output,
1056
+ "tool_calls": agent_result.tool_calls,
1057
+ }
1058
+
1059
+ # Serialize result
1060
+ output_data = json.dumps(result).encode("utf-8")
1061
+
1062
+ return PyExecuteComponentResponse(
1063
+ invocation_id=request.invocation_id,
1064
+ success=True,
1065
+ output_data=output_data,
1066
+ state_update=None,
1067
+ error_message=None,
1068
+ metadata=None,
1069
+ is_chunk=False,
1070
+ done=True,
1071
+ chunk_index=0,
1072
+ )
1073
+
1074
+ except Exception as e:
1075
+ # Include exception type for better error messages
1076
+ error_msg = f"{type(e).__name__}: {str(e)}"
1077
+ logger.error(f"Agent execution failed: {error_msg}", exc_info=True)
1078
+ return PyExecuteComponentResponse(
1079
+ invocation_id=request.invocation_id,
1080
+ success=False,
1081
+ output_data=b"",
1082
+ state_update=None,
1083
+ error_message=error_msg,
1084
+ metadata=None,
1085
+ is_chunk=False,
1086
+ done=True,
1087
+ chunk_index=0,
1088
+ )
1089
+
1090
+ def _create_error_response(self, request, error_message: str):
1091
+ """Create an error response."""
1092
+ from ._core import PyExecuteComponentResponse
1093
+
1094
+ return PyExecuteComponentResponse(
1095
+ invocation_id=request.invocation_id,
1096
+ success=False,
1097
+ output_data=b"",
1098
+ state_update=None,
1099
+ error_message=error_message,
1100
+ metadata=None,
1101
+ is_chunk=False,
1102
+ done=True,
1103
+ chunk_index=0,
1104
+ )
1105
+
1106
+ async def run(self):
1107
+ """Run the worker (register and start message loop).
1108
+
1109
+ This method will:
1110
+ 1. Discover all registered @function and @workflow handlers
1111
+ 2. Register with the coordinator
1112
+ 3. Create a shared Python event loop for all function executions
1113
+ 4. Enter the message processing loop
1114
+ 5. Block until shutdown
1115
+
1116
+ This is the main entry point for your worker service.
1117
+ """
1118
+ logger.info(f"Starting worker: {self.service_name}")
1119
+
1120
+ # Discover components
1121
+ components = self._discover_components()
1122
+
1123
+ # Set components on Rust worker
1124
+ self._rust_worker.set_components(components)
1125
+
1126
+ # Set metadata
1127
+ if self.metadata:
1128
+ self._rust_worker.set_service_metadata(self.metadata)
1129
+
1130
+ # Get the current event loop to pass to Rust for concurrent Python async execution
1131
+ # This allows Rust to execute Python async functions on the same event loop
1132
+ # without spawn_blocking overhead, enabling true concurrency
1133
+ loop = asyncio.get_running_loop()
1134
+ logger.info("Passing Python event loop to Rust worker for concurrent execution")
1135
+
1136
+ # Set event loop on Rust worker
1137
+ self._rust_worker.set_event_loop(loop)
1138
+
1139
+ # Set message handler
1140
+ handler = self._create_message_handler()
1141
+ self._rust_worker.set_message_handler(handler)
1142
+
1143
+ # Initialize worker
1144
+ self._rust_worker.initialize()
1145
+
1146
+ logger.info("Worker registered successfully, entering message loop...")
1147
+
1148
+ # Run worker (this will block until shutdown)
1149
+ await self._rust_worker.run()
1150
+
1151
+ logger.info("Worker shutdown complete")