agentfield 0.1.22rc2__py3-none-any.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.
Files changed (42) hide show
  1. agentfield/__init__.py +66 -0
  2. agentfield/agent.py +3569 -0
  3. agentfield/agent_ai.py +1125 -0
  4. agentfield/agent_cli.py +386 -0
  5. agentfield/agent_field_handler.py +494 -0
  6. agentfield/agent_mcp.py +534 -0
  7. agentfield/agent_registry.py +29 -0
  8. agentfield/agent_server.py +1185 -0
  9. agentfield/agent_utils.py +269 -0
  10. agentfield/agent_workflow.py +323 -0
  11. agentfield/async_config.py +278 -0
  12. agentfield/async_execution_manager.py +1227 -0
  13. agentfield/client.py +1447 -0
  14. agentfield/connection_manager.py +280 -0
  15. agentfield/decorators.py +527 -0
  16. agentfield/did_manager.py +337 -0
  17. agentfield/dynamic_skills.py +304 -0
  18. agentfield/execution_context.py +255 -0
  19. agentfield/execution_state.py +453 -0
  20. agentfield/http_connection_manager.py +429 -0
  21. agentfield/litellm_adapters.py +140 -0
  22. agentfield/logger.py +249 -0
  23. agentfield/mcp_client.py +204 -0
  24. agentfield/mcp_manager.py +340 -0
  25. agentfield/mcp_stdio_bridge.py +550 -0
  26. agentfield/memory.py +723 -0
  27. agentfield/memory_events.py +489 -0
  28. agentfield/multimodal.py +173 -0
  29. agentfield/multimodal_response.py +403 -0
  30. agentfield/pydantic_utils.py +227 -0
  31. agentfield/rate_limiter.py +280 -0
  32. agentfield/result_cache.py +441 -0
  33. agentfield/router.py +190 -0
  34. agentfield/status.py +70 -0
  35. agentfield/types.py +710 -0
  36. agentfield/utils.py +26 -0
  37. agentfield/vc_generator.py +464 -0
  38. agentfield/vision.py +198 -0
  39. agentfield-0.1.22rc2.dist-info/METADATA +102 -0
  40. agentfield-0.1.22rc2.dist-info/RECORD +42 -0
  41. agentfield-0.1.22rc2.dist-info/WHEEL +5 -0
  42. agentfield-0.1.22rc2.dist-info/top_level.txt +1 -0
agentfield/agent.py ADDED
@@ -0,0 +1,3569 @@
1
+ import asyncio
2
+ import inspect
3
+ import os
4
+ import re
5
+ import socket
6
+ import threading
7
+ import time
8
+ import urllib.parse
9
+ import sys
10
+ from contextlib import asynccontextmanager
11
+ from datetime import datetime, timezone
12
+ from functools import wraps
13
+ from typing import (
14
+ Any,
15
+ Awaitable,
16
+ Callable,
17
+ List,
18
+ Optional,
19
+ Set,
20
+ Union,
21
+ get_type_hints,
22
+ Type,
23
+ Dict,
24
+ Literal,
25
+ )
26
+ from agentfield.agent_ai import AgentAI
27
+ from agentfield.agent_cli import AgentCLI
28
+ from agentfield.agent_field_handler import AgentFieldHandler
29
+ from agentfield.agent_mcp import AgentMCP
30
+ from agentfield.agent_registry import clear_current_agent, set_current_agent
31
+ from agentfield.agent_server import AgentServer
32
+ from agentfield.agent_workflow import AgentWorkflow
33
+ from agentfield.client import AgentFieldClient
34
+ from agentfield.dynamic_skills import DynamicMCPSkillManager
35
+ from agentfield.execution_context import (
36
+ ExecutionContext,
37
+ get_current_context,
38
+ reset_execution_context,
39
+ set_execution_context,
40
+ )
41
+ from agentfield.did_manager import DIDManager
42
+ from agentfield.vc_generator import VCGenerator
43
+ from agentfield.mcp_client import MCPClientRegistry
44
+ from agentfield.mcp_manager import MCPManager
45
+ from agentfield.memory import MemoryClient, MemoryInterface
46
+ from agentfield.memory_events import MemoryEventClient
47
+ from agentfield.logger import log_debug, log_error, log_info, log_warn
48
+ from agentfield.router import AgentRouter
49
+ from agentfield.connection_manager import ConnectionManager
50
+ from agentfield.types import (
51
+ AgentStatus,
52
+ AIConfig,
53
+ DiscoveryResult,
54
+ MemoryConfig,
55
+ )
56
+ from agentfield.multimodal_response import MultimodalResponse
57
+ from agentfield.async_config import AsyncConfig
58
+ from agentfield.async_execution_manager import AsyncExecutionManager
59
+ from agentfield.pydantic_utils import convert_function_args, should_convert_args
60
+ from fastapi import FastAPI, Request, HTTPException
61
+ from fastapi.encoders import jsonable_encoder
62
+ from fastapi.responses import JSONResponse
63
+ from pydantic import create_model, BaseModel, ValidationError
64
+
65
+ # Import aiohttp for fire-and-forget HTTP calls
66
+ try:
67
+ import aiohttp
68
+ except ImportError:
69
+ aiohttp = None
70
+
71
+
72
+ def _detect_container_ip() -> Optional[str]:
73
+ """
74
+ Detect the external IP address when running in a containerized environment.
75
+
76
+ Returns:
77
+ External IP address if detected, None otherwise
78
+ """
79
+ try:
80
+ # Try to get IP from container metadata (works in many hosted environments)
81
+ import requests
82
+
83
+ # Try AWS metadata service
84
+ try:
85
+ response = requests.get(
86
+ "http://169.254.169.254/latest/meta-data/public-ipv4", timeout=2
87
+ )
88
+ if response.status_code == 200:
89
+ return response.text.strip()
90
+ except Exception:
91
+ pass
92
+
93
+ # Try Google metadata service
94
+ try:
95
+ response = requests.get(
96
+ "http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
97
+ headers={"Metadata-Flavor": "Google"},
98
+ timeout=2,
99
+ )
100
+ if response.status_code == 200:
101
+ return response.text.strip()
102
+ except Exception:
103
+ pass
104
+
105
+ # Try Azure metadata service
106
+ try:
107
+ response = requests.get(
108
+ "http://169.254.169.254/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2021-02-01",
109
+ headers={"Metadata": "true"},
110
+ timeout=2,
111
+ )
112
+ if response.status_code == 200:
113
+ import json
114
+
115
+ data = json.loads(response.text)
116
+ return data
117
+ except Exception:
118
+ pass
119
+
120
+ # Fallback: try to get external IP via external service
121
+ try:
122
+ response = requests.get("https://api.ipify.org", timeout=5)
123
+ if response.status_code == 200:
124
+ return response.text.strip()
125
+ except Exception:
126
+ pass
127
+
128
+ except ImportError:
129
+ pass
130
+
131
+ return None
132
+
133
+
134
+ def _detect_local_ip() -> Optional[str]:
135
+ """
136
+ Detect the local IP address of the machine.
137
+
138
+ Returns:
139
+ Local IP address if detected, None otherwise
140
+ """
141
+ try:
142
+ # Connect to a remote address to determine local IP
143
+ with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s:
144
+ s.connect(("8.8.8.8", 80))
145
+ return s.getsockname()[0]
146
+ except Exception:
147
+ return None
148
+
149
+
150
+ def _is_running_in_container() -> bool:
151
+ """
152
+ Detect if the application is running inside a container.
153
+
154
+ Returns:
155
+ True if running in a container, False otherwise
156
+ """
157
+ try:
158
+ # Check for Docker container indicators
159
+ if os.path.exists("/.dockerenv"):
160
+ return True
161
+
162
+ # Check cgroup for container indicators
163
+ try:
164
+ with open("/proc/1/cgroup", "r") as f:
165
+ content = f.read()
166
+ if (
167
+ "docker" in content
168
+ or "containerd" in content
169
+ or "kubepods" in content
170
+ ):
171
+ return True
172
+ except Exception:
173
+ pass
174
+
175
+ # Check for Kubernetes environment variables
176
+ if any(key.startswith("KUBERNETES_") for key in os.environ):
177
+ return True
178
+
179
+ # Check for common container environment variables
180
+ container_vars = ["CONTAINER", "DOCKER_CONTAINER", "RAILWAY_ENVIRONMENT"]
181
+ if any(var in os.environ for var in container_vars):
182
+ return True
183
+
184
+ except Exception:
185
+ pass
186
+
187
+ return False
188
+
189
+
190
+ def _normalize_candidate(candidate: str, port: int) -> Optional[str]:
191
+ """Normalize a callback candidate into scheme://host:port form."""
192
+ if not candidate:
193
+ return None
194
+
195
+ candidate = candidate.strip()
196
+ if not candidate:
197
+ return None
198
+
199
+ # Ensure we have a scheme so urlparse behaves predictably
200
+ if "://" not in candidate:
201
+ candidate = f"http://{candidate}"
202
+
203
+ try:
204
+ parsed = urllib.parse.urlparse(candidate)
205
+ except Exception:
206
+ return None
207
+
208
+ scheme = parsed.scheme or "http"
209
+
210
+ host = parsed.hostname or ""
211
+ if not host:
212
+ # Some inputs might be bare hostnames found in .path
213
+ host = parsed.path
214
+
215
+ host = host.strip("[]") # We'll add brackets for IPv6 later if needed
216
+ if not host:
217
+ return None
218
+
219
+ # Determine port precedence: explicit candidate port, fallback parameter
220
+ candidate_port = parsed.port
221
+ if not candidate_port and port:
222
+ candidate_port = port
223
+
224
+ # IPv6 addresses need brackets
225
+ if ":" in host and not host.startswith("[") and not host.endswith("]"):
226
+ host = f"[{host}]"
227
+
228
+ if candidate_port:
229
+ netloc = f"{host}:{candidate_port}"
230
+ else:
231
+ netloc = host
232
+
233
+ return f"{scheme}://{netloc}"
234
+
235
+
236
+ def _build_callback_candidates(
237
+ callback_url: Optional[str], port: int, *, include_defaults: bool = True
238
+ ) -> List[str]:
239
+ """Assemble a prioritized list of callback URL candidates."""
240
+
241
+ candidates: List[str] = []
242
+ seen: Set[str] = set()
243
+
244
+ def add_candidate(raw: Optional[str]):
245
+ normalized = _normalize_candidate(raw or "", port)
246
+ if normalized and normalized not in seen:
247
+ candidates.append(normalized)
248
+ seen.add(normalized)
249
+
250
+ # 1. Explicit configuration
251
+ add_candidate(callback_url)
252
+
253
+ # 2. Environment override
254
+ env_callback_url = os.getenv("AGENT_CALLBACK_URL")
255
+ add_candidate(env_callback_url)
256
+
257
+ # 3. Container/platform-specific hints
258
+ if _is_running_in_container():
259
+ railway_service_name = os.getenv("RAILWAY_SERVICE_NAME")
260
+ railway_environment = os.getenv("RAILWAY_ENVIRONMENT")
261
+ if railway_service_name and railway_environment:
262
+ add_candidate(f"http://{railway_service_name}.railway.internal:{port}")
263
+
264
+ external_ip = _detect_container_ip()
265
+ if external_ip:
266
+ add_candidate(f"http://{external_ip}:{port}")
267
+
268
+ # 4. Local network hints
269
+ local_ip = _detect_local_ip()
270
+ if local_ip and local_ip not in {"127.0.0.1", "0.0.0.0"}:
271
+ add_candidate(f"http://{local_ip}:{port}")
272
+
273
+ hostname = socket.gethostname()
274
+ if hostname:
275
+ add_candidate(f"http://{hostname}:{port}")
276
+
277
+ # Make host.docker.internal available even on Linux once mapped via extra_hosts
278
+ add_candidate(f"http://host.docker.internal:{port}")
279
+
280
+ # 5. Default fallbacks
281
+ if include_defaults:
282
+ add_candidate(f"http://localhost:{port}")
283
+ add_candidate(f"http://127.0.0.1:{port}")
284
+
285
+ return candidates
286
+
287
+
288
+ def _resolve_callback_url(callback_url: Optional[str], port: int) -> str:
289
+ """
290
+ Resolve the callback URL using the configuration hierarchy.
291
+
292
+ Priority:
293
+ 1. Explicit callback_url parameter
294
+ 2. AGENT_CALLBACK_URL environment variable
295
+ 3. Auto-detection for containerized environments
296
+ 4. Fallback to localhost
297
+
298
+ Args:
299
+ callback_url: Explicit callback URL from constructor
300
+ port: Port the agent will listen on
301
+
302
+ Returns:
303
+ Resolved callback URL
304
+ """
305
+ candidates = _build_callback_candidates(callback_url, port)
306
+ if candidates:
307
+ return candidates[0]
308
+ return f"http://localhost:{port}"
309
+
310
+
311
+ class Agent(FastAPI):
312
+ """
313
+ AgentField Agent - FastAPI subclass for creating AI agent nodes.
314
+
315
+ The Agent class is the core component of the AgentField SDK that enables developers to create
316
+ intelligent agent nodes. It inherits from FastAPI to provide HTTP endpoints and integrates
317
+ with the AgentField ecosystem for distributed AI workflows.
318
+
319
+ Key Features:
320
+ - Decorator-based reasoner and skill registration
321
+ - Cross-agent communication via the AgentField execution gateway
322
+ - Memory interface for persistent and session-based storage
323
+ - MCP (Model Context Protocol) server integration
324
+ - Automatic workflow tracking and DAG building
325
+ - FastAPI-based HTTP API with automatic schema generation
326
+
327
+ Example:
328
+ ```python
329
+ from agentfield import Agent
330
+
331
+ # Create an agent instance
332
+ app = Agent(
333
+ node_id="my_agent",
334
+ agentfield_server="http://localhost:8080"
335
+ )
336
+
337
+ # Define a reasoner (AI-powered function)
338
+ @app.reasoner()
339
+ async def analyze_sentiment(text: str) -> dict:
340
+ result = await app.ai(
341
+ prompt=f"Analyze sentiment of: {text}",
342
+ response_model={"sentiment": "positive|negative|neutral", "confidence": "float"}
343
+ )
344
+ return result
345
+
346
+ # Define a skill (deterministic function)
347
+ @app.skill()
348
+ def format_response(sentiment: str, confidence: float) -> str:
349
+ return f"Sentiment: {sentiment} (confidence: {confidence:.2f})"
350
+
351
+ # Start the agent server
352
+ if __name__ == "__main__":
353
+ app.serve(port=8001)
354
+ ```
355
+ """
356
+
357
+ def __init__(
358
+ self,
359
+ node_id: str,
360
+ agentfield_server: str = "http://localhost:8080",
361
+ version: str = "1.0.0",
362
+ ai_config: Optional[AIConfig] = None,
363
+ memory_config: Optional[MemoryConfig] = None,
364
+ dev_mode: bool = False,
365
+ async_config: Optional[AsyncConfig] = None,
366
+ callback_url: Optional[str] = None,
367
+ auto_register: bool = True,
368
+ vc_enabled: Optional[bool] = True,
369
+ api_key: Optional[str] = None,
370
+ **kwargs,
371
+ ):
372
+ """
373
+ Initialize a new AgentField Agent instance.
374
+
375
+ Sets log level to DEBUG if dev_mode is True, else INFO.
376
+ """
377
+ # Set logging level based on dev_mode
378
+ from agentfield.logger import set_log_level
379
+
380
+ set_log_level("DEBUG" if dev_mode else "INFO")
381
+
382
+ """
383
+ Creates a new agent node that can host reasoners (AI-powered functions) and skills
384
+ (deterministic functions) while integrating with the AgentField ecosystem for distributed
385
+ AI workflows and cross-agent communication.
386
+
387
+ Args:
388
+ node_id (str): Unique identifier for this agent node. Used for routing and
389
+ cross-agent communication. Should be descriptive and unique
390
+ within your AgentField ecosystem.
391
+ agentfield_server (str, optional): URL of the AgentField server for registration and
392
+ execution gateway. Defaults to "http://localhost:8080".
393
+ version (str, optional): Version string for this agent. Used for compatibility
394
+ checking and deployment tracking. Defaults to "1.0.0".
395
+ ai_config (AIConfig, optional): Configuration for AI/LLM integration. If not
396
+ provided, will be loaded from environment variables.
397
+ memory_config (MemoryConfig, optional): Configuration for memory behavior including
398
+ auto-injection patterns and retention policies.
399
+ Defaults to session-based memory.
400
+ dev_mode (bool, optional): Enable development mode with verbose logging and
401
+ debugging features. Defaults to False.
402
+ async_config (AsyncConfig, optional): Configuration for async execution behavior.
403
+ callback_url (str, optional): Explicit callback URL for AgentField server to reach this agent.
404
+ If not provided, will use AGENT_CALLBACK_URL environment variable,
405
+ auto-detection for containers, or fallback to localhost.
406
+ vc_enabled (bool | None, optional): Controls default VC generation policy for this agent node.
407
+ True enables VCs for all reasoners/skills (default), False disables,
408
+ and None defers entirely to platform defaults.
409
+ api_key (str, optional): API key for authenticating with the AgentField control plane.
410
+ When set, will be sent as X-API-Key header on all requests.
411
+ **kwargs: Additional keyword arguments passed to FastAPI constructor.
412
+
413
+ Example:
414
+ ```python
415
+ # Basic agent setup
416
+ app = Agent(node_id="sentiment_analyzer")
417
+
418
+ # Advanced configuration
419
+ app = Agent(
420
+ node_id="advanced_agent",
421
+ agentfield_server="https://agentfield.company.com",
422
+ version="2.1.0",
423
+ ai_config=AIConfig(
424
+ provider="openai",
425
+ model="gpt-4",
426
+ api_key="your-key"
427
+ ),
428
+ memory_config=MemoryConfig(
429
+ auto_inject=["user_context", "conversation_history"],
430
+ memory_retention="persistent",
431
+ cache_results=True
432
+ ),
433
+ dev_mode=True
434
+ )
435
+ ```
436
+
437
+ Note:
438
+ The agent automatically initializes all necessary handlers for MCP integration,
439
+ memory management, workflow tracking, and server functionality. MCP servers
440
+ are discovered and started automatically if present in the agent directory.
441
+ """
442
+ super().__init__(**kwargs)
443
+
444
+ self.node_id = node_id
445
+ self.agentfield_server = agentfield_server
446
+ self.version = version
447
+ self.reasoners = []
448
+ self.skills = []
449
+ self._agent_vc_enabled: Optional[bool] = vc_enabled
450
+ self._reasoner_vc_overrides: Dict[str, bool] = {}
451
+ self._skill_vc_overrides: Dict[str, bool] = {}
452
+ # Track declared return types separately to avoid polluting JSON metadata
453
+ self._reasoner_return_types: Dict[str, Type] = {}
454
+ self.base_url = None
455
+ self.callback_candidates: List[str] = []
456
+ self.callback_url = callback_url # Store the explicit callback URL
457
+ self._heartbeat_thread = None
458
+ self._heartbeat_stop_event = threading.Event()
459
+ self.dev_mode = dev_mode
460
+ self.agentfield_connected = False
461
+ self.auto_register = (
462
+ auto_register # Auto-register on first invocation (serverless mode)
463
+ )
464
+
465
+ # 🔥 FIX: Resolve callback URL immediately if provided
466
+ # This ensures base_url is available before serve() is called
467
+ if self.callback_url:
468
+ # Use a default port for initial resolution - will be updated during serve()
469
+ self.base_url = _resolve_callback_url(self.callback_url, 8000)
470
+ if self.dev_mode:
471
+ log_debug(f"Early callback URL resolution: {self.base_url}")
472
+
473
+ # Initialize async configuration
474
+ self.async_config = async_config or AsyncConfig.from_environment()
475
+
476
+ # Store API key for authentication
477
+ self.api_key = api_key
478
+
479
+ # Initialize AgentFieldClient with async configuration and API key
480
+ self.client = AgentFieldClient(
481
+ base_url=agentfield_server, async_config=self.async_config, api_key=api_key
482
+ )
483
+ self._current_execution_context: Optional[ExecutionContext] = None
484
+
485
+ # Initialize async execution manager (will be lazily created when needed)
486
+ self._async_execution_manager: Optional[AsyncExecutionManager] = None
487
+
488
+ # Fast lifecycle management
489
+ self._current_status: AgentStatus = AgentStatus.STARTING
490
+ self._shutdown_requested = False
491
+ self._mcp_initialization_complete = False
492
+ self._start_time = time.time() # Track start time for uptime calculation
493
+
494
+ # Initialize AI and Memory configurations
495
+ self.ai_config = ai_config if ai_config else AIConfig.from_env()
496
+ self.memory_config = (
497
+ memory_config
498
+ if memory_config
499
+ else MemoryConfig(
500
+ auto_inject=[], memory_retention="session", cache_results=False
501
+ )
502
+ )
503
+
504
+ # Add MCP management
505
+ self.mcp_manager: Optional[MCPManager] = None
506
+ self.mcp_client_registry: Optional[MCPClientRegistry] = None
507
+ self.dynamic_skill_manager: Optional[DynamicMCPSkillManager] = None
508
+ self.memory_event_client: Optional[MemoryEventClient] = None
509
+
510
+ # Add DID management
511
+ self.did_manager: Optional[DIDManager] = None
512
+ self.vc_generator: Optional[VCGenerator] = None
513
+ self.did_enabled = False
514
+
515
+ # Add connection management for resilient AgentField server connectivity
516
+ self.connection_manager: Optional[ConnectionManager] = None
517
+
518
+ # Initialize handlers
519
+ self.ai_handler = AgentAI(self)
520
+ self.cli_handler = AgentCLI(self)
521
+ self.mcp_handler = AgentMCP(self)
522
+ self.agentfield_handler = AgentFieldHandler(self)
523
+ self.workflow_handler = AgentWorkflow(self)
524
+ self.server_handler = AgentServer(self)
525
+
526
+ # Register this agent instance for enhanced decorator system
527
+ set_current_agent(self)
528
+
529
+ # Initialize MCP components through the handler
530
+ try:
531
+ agent_dir = self.mcp_handler._detect_agent_directory()
532
+ self.mcp_manager = MCPManager(agent_dir, self.dev_mode)
533
+ self.mcp_client_registry = MCPClientRegistry(self.dev_mode)
534
+
535
+ if self.dev_mode:
536
+ log_debug(f"Initialized MCP Manager in {agent_dir}")
537
+
538
+ # Initialize Dynamic Skill Manager when both MCP components are available
539
+ if self.mcp_manager and self.mcp_client_registry:
540
+ self.dynamic_skill_manager = DynamicMCPSkillManager(self, self.dev_mode)
541
+ if self.dev_mode:
542
+ log_debug("Dynamic MCP skill manager initialized")
543
+
544
+ except Exception as e:
545
+ if self.dev_mode:
546
+ log_error(f"Failed to initialize MCP Manager: {e}")
547
+ self.mcp_manager = None
548
+ self.mcp_client_registry = None
549
+ self.dynamic_skill_manager = None
550
+
551
+ # Initialize DID components
552
+ self._initialize_did_system()
553
+
554
+ # Setup standard AgentField routes and memory event listeners
555
+ self.server_handler.setup_agentfield_routes()
556
+ self._register_memory_event_listeners()
557
+
558
+ # Register this agent instance for automatic workflow tracking
559
+ set_current_agent(self)
560
+
561
+ # Limit concurrent outbound calls to avoid overloading the local runtime.
562
+ default_limit = max(1, min(self.async_config.connection_pool_size, 256))
563
+ max_calls_env = os.getenv("AGENTFIELD_AGENT_MAX_CONCURRENT_CALLS")
564
+ if max_calls_env:
565
+ try:
566
+ parsed_limit = int(max_calls_env)
567
+ self._max_concurrent_calls = max(1, parsed_limit)
568
+ except ValueError:
569
+ self._max_concurrent_calls = default_limit
570
+ log_warn(
571
+ f"Invalid AGENTFIELD_AGENT_MAX_CONCURRENT_CALLS='{max_calls_env}', defaulting to {default_limit}"
572
+ )
573
+ else:
574
+ self._max_concurrent_calls = default_limit
575
+ self._call_semaphore: Optional[asyncio.Semaphore] = None
576
+ self._call_semaphore_guard = threading.Lock()
577
+
578
+ def handle_serverless(self, event: dict, adapter: Optional[Callable] = None) -> dict:
579
+ """
580
+ Universal serverless handler for executing reasoners and skills.
581
+
582
+ This method enables agents to run in serverless environments (AWS Lambda,
583
+ Google Cloud Functions, Cloud Run, Kubernetes Jobs, etc.) by providing
584
+ a simple entry point that parses the event, executes the target function,
585
+ and returns the result.
586
+
587
+ Special Endpoints:
588
+ - /discover: Returns agent metadata for AgentField server registration
589
+ - /execute: Executes reasoners and skills
590
+
591
+ Args:
592
+ event (dict): Serverless event containing:
593
+ - path: Request path (/discover or /execute)
594
+ - action: Alternative to path (discover or execute)
595
+ - reasoner: Name of the reasoner to execute (for execution)
596
+ - input: Input parameters for the function (for execution)
597
+
598
+ Returns:
599
+ dict: Execution result with status and output, or discovery metadata
600
+
601
+ Example:
602
+ ```python
603
+ # AWS Lambda handler with API Gateway
604
+ from agentfield import Agent
605
+
606
+ app = Agent("my_agent", auto_register=False)
607
+
608
+ @app.reasoner()
609
+ async def analyze(text: str) -> dict:
610
+ return {"result": text.upper()}
611
+
612
+ def lambda_handler(event, context):
613
+ # Handle both discovery and execution
614
+ return app.handle_serverless(event)
615
+ ```
616
+ """
617
+ import asyncio
618
+
619
+ if adapter:
620
+ try:
621
+ event = adapter(event) or event
622
+ except Exception as exc: # pragma: no cover - adapter failures
623
+ return {
624
+ "statusCode": 400,
625
+ "body": {"error": f"serverless adapter failed: {exc}"},
626
+ }
627
+
628
+ # Check if this is a discovery request
629
+ path = event.get("path") or event.get("rawPath") or ""
630
+ action = event.get("action", "")
631
+
632
+ if path == "/discover" or path.endswith("/discover") or action == "discover":
633
+ # Return agent metadata for AgentField server registration
634
+ return self._handle_discovery()
635
+
636
+ # Auto-register with AgentField if needed (for execution requests)
637
+ if self.auto_register and not self.agentfield_connected:
638
+ try:
639
+ # Attempt registration (non-blocking)
640
+ self.agentfield_handler._register_agent()
641
+ self.agentfield_connected = True
642
+ except Exception as e:
643
+ if self.dev_mode:
644
+ log_warn(f"Auto-registration failed: {e}")
645
+
646
+ # Serverless invocations arrive via the control plane; mark as connected so
647
+ # cross-agent calls can route through the gateway without a lease loop.
648
+ self.agentfield_connected = True
649
+ # Serverless handlers should avoid async execute polling; force sync path.
650
+ if getattr(self.async_config, "enable_async_execution", True):
651
+ self.async_config.enable_async_execution = False
652
+
653
+ # Parse event format for execution
654
+ reasoner_name = (
655
+ event.get("reasoner") or event.get("target") or event.get("skill")
656
+ )
657
+ if not reasoner_name and path:
658
+ # Support paths like /execute/<target> or /reasoners/<name>
659
+ cleaned_path = path.split("?", 1)[0].strip("/")
660
+ parts = cleaned_path.split("/")
661
+ if parts and parts[0] not in ("", "discover"):
662
+ if len(parts) >= 2 and parts[0] in ("execute", "reasoners", "skills"):
663
+ reasoner_name = parts[1]
664
+ elif parts[0] in ("execute", "reasoners", "skills"):
665
+ reasoner_name = None
666
+ elif parts:
667
+ reasoner_name = parts[-1]
668
+
669
+ input_data = event.get("input") or event.get("input_data", {})
670
+ execution_context_data = (
671
+ event.get("execution_context") or event.get("executionContext") or {}
672
+ )
673
+
674
+ if not reasoner_name:
675
+ return {
676
+ "statusCode": 400,
677
+ "body": {"error": "Missing 'reasoner' or 'target' in event"},
678
+ }
679
+
680
+ # Create execution context
681
+ exec_id = execution_context_data.get(
682
+ "execution_id", f"exec_{int(time.time() * 1000)}"
683
+ )
684
+ run_id = execution_context_data.get("run_id") or execution_context_data.get(
685
+ "workflow_id"
686
+ )
687
+ if not run_id:
688
+ run_id = f"wf_{int(time.time() * 1000)}"
689
+ workflow_id = execution_context_data.get("workflow_id", run_id)
690
+
691
+ execution_context = ExecutionContext(
692
+ run_id=run_id,
693
+ execution_id=exec_id,
694
+ agent_instance=self,
695
+ agent_node_id=self.node_id,
696
+ reasoner_name=reasoner_name,
697
+ parent_execution_id=execution_context_data.get("parent_execution_id"),
698
+ session_id=execution_context_data.get("session_id"),
699
+ actor_id=execution_context_data.get("actor_id"),
700
+ caller_did=execution_context_data.get("caller_did"),
701
+ target_did=execution_context_data.get("target_did"),
702
+ agent_node_did=execution_context_data.get(
703
+ "agent_node_did", execution_context_data.get("agent_did")
704
+ ),
705
+ workflow_id=workflow_id,
706
+ parent_workflow_id=execution_context_data.get("parent_workflow_id"),
707
+ root_workflow_id=execution_context_data.get("root_workflow_id"),
708
+ )
709
+
710
+ # Set execution context
711
+ self._current_execution_context = execution_context
712
+
713
+ try:
714
+ # Find and execute the target function
715
+ if hasattr(self, reasoner_name):
716
+ func = getattr(self, reasoner_name)
717
+
718
+ # Execute function (sync or async)
719
+ if asyncio.iscoroutinefunction(func):
720
+ result = asyncio.run(func(**input_data))
721
+ else:
722
+ result = func(**input_data)
723
+
724
+ return {"statusCode": 200, "body": result}
725
+ else:
726
+ return {
727
+ "statusCode": 404,
728
+ "body": {"error": f"Function '{reasoner_name}' not found"},
729
+ }
730
+
731
+ except Exception as e:
732
+ return {"statusCode": 500, "body": {"error": str(e)}}
733
+ finally:
734
+ # Clean up execution context
735
+ self._current_execution_context = None
736
+
737
+ def _handle_discovery(self) -> dict:
738
+ """
739
+ Handle discovery requests for serverless agent registration.
740
+
741
+ Returns agent metadata including reasoners, skills, and configuration
742
+ for automatic registration with the AgentField server.
743
+
744
+ Returns:
745
+ dict: Agent metadata for registration
746
+ """
747
+ return {
748
+ "node_id": self.node_id,
749
+ "version": self.version,
750
+ "deployment_type": "serverless",
751
+ "reasoners": [
752
+ {
753
+ "id": r["id"],
754
+ "input_schema": r.get("input_schema", {}),
755
+ "output_schema": r.get("output_schema", {}),
756
+ "memory_config": r.get("memory_config", {}),
757
+ "tags": r.get("tags", []),
758
+ }
759
+ for r in self.reasoners
760
+ ],
761
+ "skills": [
762
+ {
763
+ "id": s["id"],
764
+ "input_schema": s.get("input_schema", {}),
765
+ "tags": s.get("tags", []),
766
+ }
767
+ for s in self.skills
768
+ ],
769
+ }
770
+
771
+ def _initialize_did_system(self):
772
+ """Initialize DID and VC components."""
773
+ try:
774
+ # Initialize DID Manager
775
+ self.did_manager = DIDManager(
776
+ self.agentfield_server, self.node_id, self.api_key
777
+ )
778
+
779
+ # Initialize VC Generator
780
+ self.vc_generator = VCGenerator(self.agentfield_server, self.api_key)
781
+
782
+ if self.dev_mode:
783
+ log_debug("DID system initialized")
784
+
785
+ except Exception as e:
786
+ if self.dev_mode:
787
+ log_error(f"Failed to initialize DID system: {e}")
788
+ self.did_manager = None
789
+ self.vc_generator = None
790
+
791
+ def _register_memory_event_listeners(self):
792
+ """Scans for methods decorated with @on_change and registers them as listeners."""
793
+ if not self.memory_event_client:
794
+ self.memory_event_client = MemoryEventClient(
795
+ self.agentfield_server, self._get_current_execution_context(), self.api_key
796
+ )
797
+
798
+ for name, method in inspect.getmembers(self, predicate=inspect.ismethod):
799
+ if hasattr(method, "_memory_event_listener"):
800
+ patterns = getattr(method, "_memory_event_patterns", [])
801
+
802
+ async def listener(event):
803
+ # This is a simplified listener, a more robust implementation
804
+ # would handle pattern matching on the client side as well.
805
+ await method(event)
806
+
807
+ self.memory_event_client.subscribe(patterns, listener)
808
+
809
+ @property
810
+ def memory(self) -> Optional[MemoryInterface]:
811
+ """
812
+ Get the memory interface for the current execution context.
813
+
814
+ The memory interface provides access to persistent and session-based storage
815
+ that is automatically scoped to the current execution context. This enables
816
+ agents to store and retrieve data across function calls, workflow steps,
817
+ and even across different agent interactions.
818
+
819
+ Memory is automatically scoped by:
820
+ - Execution context (workflow instance)
821
+ - Agent node ID
822
+ - Session information
823
+ - User context (if available)
824
+
825
+ Returns:
826
+ MemoryInterface: Interface for memory operations if execution context is available.
827
+ None: If no execution context is available (e.g., outside of reasoner/skill execution).
828
+
829
+ Example:
830
+ ```python
831
+ @app.reasoner()
832
+ async def analyze_conversation(message: str) -> dict:
833
+ '''Analyze message with conversation history context.'''
834
+
835
+ # Store current message in conversation history
836
+ history = app.memory.get("conversation.history", [])
837
+ history.append({
838
+ "message": message,
839
+ "timestamp": datetime.now().isoformat(),
840
+ "role": "user"
841
+ })
842
+ app.memory.set("conversation.history", history)
843
+
844
+ # Get user preferences for analysis
845
+ user_prefs = app.memory.get("user.analysis_preferences", {
846
+ "sentiment_analysis": True,
847
+ "topic_extraction": True,
848
+ "language_detection": False
849
+ })
850
+
851
+ # Perform analysis based on preferences and history
852
+ analysis_prompt = f'''
853
+ Analyze this message: "{message}"
854
+
855
+ Previous conversation context:
856
+ {json.dumps(history[-5:], indent=2)} # Last 5 messages
857
+
858
+ Analysis preferences: {user_prefs}
859
+ '''
860
+
861
+ result = await app.ai(
862
+ system="You are a conversation analyst.",
863
+ user=analysis_prompt,
864
+ schema=ConversationAnalysis
865
+ )
866
+
867
+ # Store analysis results
868
+ app.memory.set("conversation.last_analysis", result.model_dump())
869
+
870
+ return result
871
+
872
+ @app.skill()
873
+ def get_conversation_summary() -> dict:
874
+ '''Get summary of current conversation.'''
875
+
876
+ history = app.memory.get("conversation.history", [])
877
+ last_analysis = app.memory.get("conversation.last_analysis", {})
878
+
879
+ return {
880
+ "message_count": len(history),
881
+ "last_analysis": last_analysis,
882
+ "conversation_started": history[0]["timestamp"] if history else None
883
+ }
884
+ ```
885
+
886
+ Memory Operations:
887
+ - `app.memory.get(key, default=None)`: Retrieve value by key
888
+ - `app.memory.set(key, value)`: Store value by key
889
+ - `app.memory.delete(key)`: Remove value by key
890
+ - `app.memory.exists(key)`: Check if key exists
891
+ - `app.memory.keys(pattern="*")`: List keys matching pattern
892
+ - `app.memory.clear(pattern="*")`: Clear keys matching pattern
893
+
894
+ Memory Scopes:
895
+ - Session: Data persists for the duration of a user session
896
+ - Workflow: Data persists for the duration of a workflow execution
897
+ - Agent: Data persists across all executions for this agent
898
+ - Global: Data shared across all agents (use with caution)
899
+
900
+ Note:
901
+ - Memory is automatically cleaned up based on retention policies
902
+ - Large objects should be stored efficiently (consider serialization)
903
+ - Memory operations are atomic and thread-safe
904
+ - Memory events can trigger `@on_change` listeners
905
+ """
906
+ if not self._current_execution_context:
907
+ return None
908
+
909
+ memory_client = MemoryClient(
910
+ self.client, self._current_execution_context, agent_node_id=self.node_id
911
+ )
912
+ if not self.memory_event_client:
913
+ self.memory_event_client = MemoryEventClient(
914
+ self.agentfield_server, self._get_current_execution_context(), self.api_key
915
+ )
916
+ return MemoryInterface(memory_client, self.memory_event_client)
917
+
918
+ @property
919
+ def ctx(self) -> Optional[ExecutionContext]:
920
+ """
921
+ Get the current execution context.
922
+
923
+ The execution context contains metadata about the current execution including:
924
+ - workflow_id: Unique identifier for the current workflow
925
+ - execution_id: Unique identifier for this specific execution
926
+ - run_id: Identifier for the current run
927
+ - session_id: Session identifier (if available)
928
+ - actor_id: Actor/user identifier (if available)
929
+ - parent_execution_id: Parent execution for nested calls
930
+
931
+ Returns:
932
+ ExecutionContext: The current execution context if available.
933
+ None: If no execution context is available (e.g., outside of reasoner/skill execution).
934
+
935
+ Example:
936
+ ```python
937
+ @app.reasoner()
938
+ async def handle_ticket(ticket_id: str):
939
+ # Access workflow ID for scoped memory
940
+ await app.memory.workflow(app.ctx.workflow_id).set(
941
+ "ticket_status", "processing"
942
+ )
943
+
944
+ # Access session ID for user-scoped data
945
+ if app.ctx.session_id:
946
+ user_history = await app.memory.session(app.ctx.session_id).get("history")
947
+
948
+ return {"ticket_id": ticket_id, "workflow": app.ctx.workflow_id}
949
+ ```
950
+ """
951
+ # Check thread-local context first (set during active reasoner/skill execution)
952
+ thread_local_ctx = get_current_context()
953
+ if thread_local_ctx:
954
+ return thread_local_ctx
955
+ # Only return agent-level context if it was set during an actual execution
956
+ # (i.e., has registered=True), not the default context created at init time
957
+ if self._current_execution_context and self._current_execution_context.registered:
958
+ return self._current_execution_context
959
+ return None
960
+
961
+ def _populate_execution_context_with_did(
962
+ self, execution_context, did_execution_context
963
+ ):
964
+ """
965
+ Populate the execution context with DID information.
966
+
967
+ Args:
968
+ execution_context: The main ExecutionContext
969
+ did_execution_context: The DIDExecutionContext with DID info
970
+ """
971
+ if did_execution_context:
972
+ execution_context.session_id = did_execution_context.session_id
973
+ execution_context.caller_did = did_execution_context.caller_did
974
+ execution_context.target_did = did_execution_context.target_did
975
+ execution_context.agent_node_did = did_execution_context.agent_node_did
976
+
977
+ def _agent_vc_default(self) -> bool:
978
+ """Resolve the agent-level VC default, falling back to enabled."""
979
+ return True if self._agent_vc_enabled is None else self._agent_vc_enabled
980
+
981
+ def _set_reasoner_vc_override(
982
+ self, reasoner_id: str, value: Optional[bool]
983
+ ) -> None:
984
+ if value is None:
985
+ self._reasoner_vc_overrides.pop(reasoner_id, None)
986
+ else:
987
+ self._reasoner_vc_overrides[reasoner_id] = value
988
+
989
+ def _set_skill_vc_override(self, skill_id: str, value: Optional[bool]) -> None:
990
+ if value is None:
991
+ self._skill_vc_overrides.pop(skill_id, None)
992
+ else:
993
+ self._skill_vc_overrides[skill_id] = value
994
+
995
+ def _effective_component_vc_setting(
996
+ self, component_id: str, overrides: Dict[str, bool]
997
+ ) -> bool:
998
+ if component_id in overrides:
999
+ return overrides[component_id]
1000
+ return self._agent_vc_default()
1001
+
1002
+ def _should_generate_vc(
1003
+ self, component_id: str, overrides: Dict[str, bool]
1004
+ ) -> bool:
1005
+ if (
1006
+ not self.did_enabled
1007
+ or not self.vc_generator
1008
+ or not self.vc_generator.is_enabled()
1009
+ ):
1010
+ return False
1011
+ return self._effective_component_vc_setting(component_id, overrides)
1012
+
1013
+ def _build_vc_metadata(self) -> Dict[str, Any]:
1014
+ """Produce a serializable VC policy snapshot for control-plane visibility."""
1015
+ effective_reasoners = {
1016
+ reasoner["id"]: self._effective_component_vc_setting(
1017
+ reasoner["id"], self._reasoner_vc_overrides
1018
+ )
1019
+ for reasoner in self.reasoners
1020
+ if "id" in reasoner
1021
+ }
1022
+ effective_skills = {
1023
+ skill["id"]: self._effective_component_vc_setting(
1024
+ skill["id"], self._skill_vc_overrides
1025
+ )
1026
+ for skill in self.skills
1027
+ if "id" in skill
1028
+ }
1029
+
1030
+ return {
1031
+ "agent_default": self._agent_vc_default(),
1032
+ "reasoner_overrides": dict(self._reasoner_vc_overrides),
1033
+ "skill_overrides": dict(self._skill_vc_overrides),
1034
+ "effective_reasoners": effective_reasoners,
1035
+ "effective_skills": effective_skills,
1036
+ }
1037
+
1038
+ async def _generate_vc_async(
1039
+ self,
1040
+ vc_generator,
1041
+ did_execution_context,
1042
+ function_name,
1043
+ input_data,
1044
+ output_data,
1045
+ status="success",
1046
+ error_message=None,
1047
+ duration_ms=0,
1048
+ ):
1049
+ """
1050
+ Generate VC asynchronously without blocking execution.
1051
+
1052
+ Args:
1053
+ vc_generator: VCGenerator instance
1054
+ did_execution_context: DID execution context
1055
+ function_name: Name of the executed function
1056
+ input_data: Input data for the execution
1057
+ output_data: Output data from the execution
1058
+ status: Execution status
1059
+ error_message: Error message if any
1060
+ duration_ms: Execution duration in milliseconds
1061
+ """
1062
+ try:
1063
+ if vc_generator and vc_generator.is_enabled():
1064
+ vc = vc_generator.generate_execution_vc(
1065
+ execution_context=did_execution_context,
1066
+ input_data=input_data,
1067
+ output_data=output_data,
1068
+ status=status,
1069
+ error_message=error_message,
1070
+ duration_ms=duration_ms,
1071
+ )
1072
+ if vc and self.dev_mode:
1073
+ log_debug(f"Generated VC {vc.vc_id} for {function_name}")
1074
+ except Exception as e:
1075
+ if self.dev_mode:
1076
+ log_error(f"Failed to generate VC for {function_name}: {e}")
1077
+
1078
+ def _build_callback_discovery_payload(self) -> Optional[Dict[str, Any]]:
1079
+ """Prepare discovery metadata for agent registration."""
1080
+
1081
+ if not self.callback_candidates:
1082
+ return None
1083
+
1084
+ payload: Dict[str, Any] = {
1085
+ "mode": "python-sdk:auto",
1086
+ "preferred": self.base_url,
1087
+ "callback_candidates": self.callback_candidates,
1088
+ "container": _is_running_in_container(),
1089
+ "submitted_at": datetime.utcnow().isoformat() + "Z",
1090
+ }
1091
+
1092
+ return payload
1093
+
1094
+ def _apply_discovery_response(self, payload: Optional[Dict[str, Any]]) -> None:
1095
+ """Update agent networking state from AgentField discovery response."""
1096
+
1097
+ if not payload:
1098
+ return
1099
+
1100
+ discovery_section = (
1101
+ payload.get("callback_discovery") if isinstance(payload, dict) else None
1102
+ )
1103
+
1104
+ resolved = None
1105
+ if isinstance(payload, dict):
1106
+ resolved = payload.get("resolved_base_url")
1107
+ if not resolved and isinstance(discovery_section, dict):
1108
+ resolved = (
1109
+ discovery_section.get("resolved")
1110
+ or discovery_section.get("selected")
1111
+ or discovery_section.get("preferred")
1112
+ )
1113
+
1114
+ if resolved and resolved != self.base_url:
1115
+ log_debug(f"Applying resolved callback URL from AgentField: {resolved}")
1116
+ self.base_url = resolved
1117
+
1118
+ if isinstance(discovery_section, dict):
1119
+ candidates = discovery_section.get("candidates")
1120
+ if isinstance(candidates, list):
1121
+ normalized = []
1122
+ for candidate in candidates:
1123
+ if isinstance(candidate, str):
1124
+ normalized.append(candidate)
1125
+ # Ensure resolved URL is first when present
1126
+ if resolved and resolved in normalized:
1127
+ normalized.remove(resolved)
1128
+ normalized.insert(0, resolved)
1129
+ elif resolved:
1130
+ normalized.insert(0, resolved)
1131
+
1132
+ if normalized:
1133
+ self.callback_candidates = normalized
1134
+
1135
+ def _register_agent_with_did(self) -> bool:
1136
+ """
1137
+ Register agent with DID system.
1138
+
1139
+ Returns:
1140
+ True if registration successful, False otherwise
1141
+ """
1142
+ if self.dev_mode:
1143
+ log_debug(f"Registering agent with DID system: {self.node_id}")
1144
+
1145
+ if not self.did_manager:
1146
+ if self.dev_mode:
1147
+ log_debug(f"No DID manager available for agent: {self.node_id}")
1148
+ return False
1149
+
1150
+ try:
1151
+ # Prepare reasoner and skill definitions for DID registration
1152
+ reasoner_defs = []
1153
+ for reasoner in self.reasoners:
1154
+ reasoner_defs.append(
1155
+ {
1156
+ "id": reasoner["id"],
1157
+ "input_schema": reasoner["input_schema"],
1158
+ "output_schema": reasoner["output_schema"],
1159
+ "tags": reasoner.get("tags", []),
1160
+ }
1161
+ )
1162
+
1163
+ skill_defs = []
1164
+ for skill in self.skills:
1165
+ skill_defs.append(
1166
+ {
1167
+ "id": skill["id"],
1168
+ "input_schema": skill["input_schema"],
1169
+ "tags": skill.get("tags", []),
1170
+ }
1171
+ )
1172
+
1173
+ log_debug(
1174
+ "Calling did_manager.register_agent() with "
1175
+ f"{len(reasoner_defs)} reasoners and {len(skill_defs)} skills"
1176
+ )
1177
+
1178
+ # Register with DID system
1179
+ success = self.did_manager.register_agent(reasoner_defs, skill_defs)
1180
+ if success:
1181
+ self.did_enabled = True
1182
+ if self.dev_mode:
1183
+ log_debug(f"DID registration successful for agent: {self.node_id}")
1184
+ # Enable VC generation
1185
+ if self.vc_generator:
1186
+ self.vc_generator.set_enabled(True)
1187
+ if self.dev_mode:
1188
+ log_info(f"Agent {self.node_id} registered with DID system")
1189
+ log_info(f"DID: {self.did_manager.get_agent_did()}")
1190
+ else:
1191
+ if self.dev_mode:
1192
+ log_warn(f"Failed to register agent {self.node_id} with DID system")
1193
+
1194
+ return success
1195
+
1196
+ except Exception as e:
1197
+ if self.dev_mode:
1198
+ log_error(f"Error registering agent with DID system: {e}")
1199
+ return False
1200
+
1201
+ def _register_mcp_servers_with_registry(self) -> None:
1202
+ """
1203
+ Placeholder for MCP server registration - functionality removed.
1204
+ """
1205
+ if self.dev_mode:
1206
+ log_debug("MCP server registration disabled - old modules removed")
1207
+
1208
+ def _setup_agentfield_routes(self):
1209
+ """Delegate to server handler for route setup"""
1210
+ return self.server_handler.setup_agentfield_routes()
1211
+
1212
+ def reasoner(
1213
+ self,
1214
+ path: Optional[str] = None,
1215
+ name: Optional[str] = None,
1216
+ tags: Optional[List[str]] = None,
1217
+ *,
1218
+ vc_enabled: Optional[bool] = None,
1219
+ ):
1220
+ """
1221
+ Decorator to register a reasoner function.
1222
+
1223
+ A reasoner is an AI-powered function that takes input and produces structured output using LLMs.
1224
+ It automatically handles input/output schema generation and integrates with the AgentField's AI capabilities.
1225
+
1226
+ Args:
1227
+ path (str, optional): The API endpoint path for this reasoner. Defaults to /reasoners/{function_name}.
1228
+ name (str, optional): Explicit AgentField registration ID. Defaults to the function name.
1229
+ tags (List[str] | None, optional): Organizational tags that travel with the reasoner metadata.
1230
+ vc_enabled (bool | None, optional): Override VC generation for this reasoner. True forces VC creation,
1231
+ False disables it, and None inherits the agent-level policy.
1232
+ """
1233
+
1234
+ direct_registration: Optional[Callable] = None
1235
+ decorator_path = path
1236
+ decorator_name = name
1237
+ decorator_tags = tags
1238
+
1239
+ if decorator_path and (
1240
+ inspect.isfunction(decorator_path) or inspect.ismethod(decorator_path)
1241
+ ):
1242
+ direct_registration = decorator_path
1243
+ decorator_path = None
1244
+
1245
+ def decorator(func: Callable) -> Callable:
1246
+ # Extract function metadata
1247
+ func_name = func.__name__
1248
+ reasoner_id = decorator_name or func_name
1249
+ endpoint_path = decorator_path or f"/reasoners/{func_name}"
1250
+
1251
+ # Get type hints for input/output schemas
1252
+ type_hints = get_type_hints(func)
1253
+ sig = inspect.signature(func)
1254
+
1255
+ # Create input schema from function parameters
1256
+ input_fields = {}
1257
+ for param_name, param in sig.parameters.items():
1258
+ if param_name not in ["self", "execution_context"]:
1259
+ param_type = type_hints.get(param_name, str)
1260
+ default_value = (
1261
+ param.default
1262
+ if param.default is not inspect.Parameter.empty
1263
+ else ...
1264
+ )
1265
+ input_fields[param_name] = (param_type, default_value)
1266
+
1267
+ InputSchema = create_model(f"{func_name}Input", **input_fields)
1268
+
1269
+ # Persist VC override preference
1270
+ self._set_reasoner_vc_override(reasoner_id, vc_enabled)
1271
+
1272
+ # Get output schema from return type hint
1273
+ return_type = type_hints.get("return", dict)
1274
+
1275
+ # Create FastAPI endpoint
1276
+ @self.post(endpoint_path, response_model=return_type)
1277
+ async def endpoint(input_data: InputSchema, request: Request):
1278
+ async def run_reasoner() -> Any:
1279
+ return await self._execute_reasoner_endpoint(
1280
+ reasoner_id=reasoner_id,
1281
+ func=func,
1282
+ signature=sig,
1283
+ input_model=input_data,
1284
+ request=request,
1285
+ )
1286
+
1287
+ execution_id_header = request.headers.get("X-Execution-ID")
1288
+ if execution_id_header and self.agentfield_server:
1289
+ asyncio.create_task(
1290
+ self._execute_async_with_callback(
1291
+ reasoner_coro=run_reasoner,
1292
+ execution_id=execution_id_header,
1293
+ reasoner_name=reasoner_id,
1294
+ )
1295
+ )
1296
+ return JSONResponse(
1297
+ status_code=202,
1298
+ content={
1299
+ "status": "processing",
1300
+ "execution_id": execution_id_header,
1301
+ },
1302
+ )
1303
+
1304
+ return await run_reasoner()
1305
+
1306
+ # 🔥 ENHANCED: Comprehensive function replacement for unified tracking
1307
+ original_func = func
1308
+
1309
+ async def tracked_func(*args, **kwargs):
1310
+ """Enhanced tracked function with unified execution pipeline and context inheritance"""
1311
+ # 🔥 CRITICAL FIX: Always use workflow tracking for direct reasoner calls
1312
+ # The previous logic was preventing workflow notifications for direct calls
1313
+
1314
+ # Check if we're in an enhanced decorator context first
1315
+ current_context = get_current_context()
1316
+
1317
+ if current_context:
1318
+ # We're in a context managed by the enhanced decorator system
1319
+ # Use the enhanced decorator's tracking mechanism
1320
+ from agentfield.decorators import _execute_with_tracking
1321
+
1322
+ return await _execute_with_tracking(original_func, *args, **kwargs)
1323
+ else:
1324
+ # 🔥 FIX: Always use the agent's workflow handler for tracking
1325
+ # This ensures that direct reasoner calls get proper workflow notifications
1326
+ return await self.workflow_handler.execute_with_tracking(
1327
+ original_func, args, kwargs
1328
+ )
1329
+
1330
+ # 🔥 FIX: Store reference to original function for FastAPI endpoint access
1331
+ setattr(tracked_func, "_original_func", original_func)
1332
+ setattr(tracked_func, "_is_tracked_replacement", True)
1333
+
1334
+ resolved_tags: List[str] = []
1335
+ if decorator_tags:
1336
+ resolved_tags = list(decorator_tags)
1337
+ else:
1338
+ decorator_tag_attr = getattr(original_func, "_reasoner_tags", None)
1339
+ if decorator_tag_attr:
1340
+ if isinstance(decorator_tag_attr, (list, tuple, set)):
1341
+ resolved_tags = [str(tag) for tag in decorator_tag_attr]
1342
+ else:
1343
+ resolved_tags = [str(decorator_tag_attr)]
1344
+ setattr(tracked_func, "_reasoner_tags", resolved_tags)
1345
+
1346
+ # Register reasoner metadata
1347
+ output_schema = {}
1348
+ if hasattr(return_type, "model_json_schema"):
1349
+ # If it's a Pydantic model, get its schema
1350
+ output_schema = return_type.model_json_schema()
1351
+ elif hasattr(return_type, "__annotations__"):
1352
+ # If it's a typed class, create a simple schema
1353
+ output_schema = {"type": "object", "properties": {}}
1354
+ else:
1355
+ # Default schema for basic types
1356
+ output_schema = {"type": "object"}
1357
+
1358
+ # Store reasoner metadata for registration (JSON serializable only)
1359
+ reasoner_metadata = {
1360
+ "id": reasoner_id,
1361
+ "input_schema": InputSchema.model_json_schema(),
1362
+ "output_schema": output_schema,
1363
+ "memory_config": self.memory_config.to_dict(),
1364
+ "return_type_hint": getattr(return_type, "__name__", str(return_type)),
1365
+ }
1366
+ reasoner_metadata["tags"] = resolved_tags
1367
+ reasoner_metadata["vc_enabled"] = self._effective_component_vc_setting(
1368
+ reasoner_id, self._reasoner_vc_overrides
1369
+ )
1370
+
1371
+ self.reasoners.append(reasoner_metadata)
1372
+ # Preserve the actual return type for local schema reconstruction
1373
+ self._reasoner_return_types[reasoner_id] = return_type
1374
+
1375
+ # 🔥 CRITICAL: Comprehensive function replacement (re-enabled for workflow tracking)
1376
+ self.workflow_handler.replace_function_references(
1377
+ original_func, tracked_func, func_name
1378
+ )
1379
+
1380
+ if reasoner_id != func_name:
1381
+ setattr(self, reasoner_id, getattr(self, func_name, tracked_func))
1382
+
1383
+ # The `ai` method is available via `self.ai` within the Agent class.
1384
+ # If you need to expose it directly on the decorated function,
1385
+ # consider a different pattern (e.g., a wrapper class or a global registry).
1386
+ return tracked_func
1387
+
1388
+ if direct_registration:
1389
+ return decorator(direct_registration)
1390
+ if direct_registration:
1391
+ return decorator(direct_registration)
1392
+
1393
+ return decorator
1394
+
1395
+ async def _execute_reasoner_endpoint(
1396
+ self,
1397
+ *,
1398
+ reasoner_id: str,
1399
+ func: Callable,
1400
+ signature: inspect.Signature,
1401
+ input_model: BaseModel,
1402
+ request: Request,
1403
+ ) -> Any:
1404
+ import asyncio
1405
+ import time
1406
+
1407
+ execution_context = ExecutionContext.from_request(request, self.node_id)
1408
+ payload_dict = input_model.model_dump()
1409
+
1410
+ self._current_execution_context = execution_context
1411
+ context_token = set_execution_context(execution_context)
1412
+ self._set_as_current()
1413
+
1414
+ if hasattr(self, "workflow_handler") and self.workflow_handler:
1415
+ execution_context.reasoner_name = reasoner_id
1416
+ await self.workflow_handler.notify_call_start(
1417
+ execution_context.execution_id,
1418
+ execution_context,
1419
+ reasoner_id,
1420
+ payload_dict,
1421
+ parent_execution_id=execution_context.parent_execution_id,
1422
+ )
1423
+
1424
+ start_time = time.time()
1425
+
1426
+ did_execution_context = None
1427
+ if self.did_enabled and self.did_manager:
1428
+ session_identifier = (
1429
+ execution_context.session_id or execution_context.workflow_id
1430
+ )
1431
+ did_execution_context = self.did_manager.create_execution_context(
1432
+ execution_context.execution_id,
1433
+ execution_context.workflow_id,
1434
+ session_identifier,
1435
+ "agent",
1436
+ reasoner_id,
1437
+ )
1438
+ self._populate_execution_context_with_did(
1439
+ execution_context, did_execution_context
1440
+ )
1441
+
1442
+ try:
1443
+ try:
1444
+ if should_convert_args(func):
1445
+ converted_args, converted_kwargs = convert_function_args(
1446
+ func, (), payload_dict
1447
+ )
1448
+ args = converted_args
1449
+ kwargs = converted_kwargs
1450
+ else:
1451
+ args, kwargs = (), payload_dict
1452
+ except ValidationError as exc:
1453
+ raise ValidationError(
1454
+ f"Pydantic validation failed for reasoner '{reasoner_id}': {exc}",
1455
+ model=getattr(exc, "model", None),
1456
+ ) from exc
1457
+ except Exception as exc: # pragma: no cover - best effort log
1458
+ if self.dev_mode:
1459
+ log_debug(
1460
+ f"⚠️ Warning: Failed to convert arguments for {reasoner_id}: {exc}"
1461
+ )
1462
+ args, kwargs = (), payload_dict
1463
+
1464
+ if "execution_context" in signature.parameters:
1465
+ kwargs["execution_context"] = execution_context
1466
+
1467
+ if asyncio.iscoroutinefunction(func):
1468
+ result = await func(*args, **kwargs)
1469
+ else:
1470
+ result = func(*args, **kwargs)
1471
+
1472
+ if did_execution_context and self._should_generate_vc(
1473
+ reasoner_id, self._reasoner_vc_overrides
1474
+ ):
1475
+ if self.dev_mode:
1476
+ log_debug(
1477
+ f"Triggering VC generation for execution: {did_execution_context.execution_id}"
1478
+ )
1479
+ end_time = time.time()
1480
+ duration_ms = int((end_time - start_time) * 1000)
1481
+ asyncio.create_task(
1482
+ self._generate_vc_async(
1483
+ self.vc_generator,
1484
+ did_execution_context,
1485
+ reasoner_id,
1486
+ payload_dict,
1487
+ result,
1488
+ "success",
1489
+ None,
1490
+ duration_ms,
1491
+ )
1492
+ )
1493
+
1494
+ if hasattr(self, "workflow_handler") and self.workflow_handler:
1495
+ end_time = time.time()
1496
+ await self.workflow_handler.notify_call_complete(
1497
+ execution_context.execution_id,
1498
+ execution_context.workflow_id,
1499
+ result,
1500
+ int((end_time - start_time) * 1000),
1501
+ execution_context,
1502
+ input_data=payload_dict,
1503
+ parent_execution_id=execution_context.parent_execution_id,
1504
+ )
1505
+
1506
+ return result
1507
+ except asyncio.CancelledError as cancel_err:
1508
+ if hasattr(self, "workflow_handler") and self.workflow_handler:
1509
+ end_time = time.time()
1510
+ await self.workflow_handler.notify_call_error(
1511
+ execution_context.execution_id,
1512
+ execution_context.workflow_id,
1513
+ "Execution cancelled by upstream client",
1514
+ int((end_time - start_time) * 1000),
1515
+ execution_context,
1516
+ input_data=payload_dict,
1517
+ parent_execution_id=execution_context.parent_execution_id,
1518
+ )
1519
+ raise cancel_err
1520
+ except HTTPException as http_exc:
1521
+ if hasattr(self, "workflow_handler") and self.workflow_handler:
1522
+ end_time = time.time()
1523
+ detail = getattr(http_exc, "detail", None) or str(http_exc)
1524
+ await self.workflow_handler.notify_call_error(
1525
+ execution_context.execution_id,
1526
+ execution_context.workflow_id,
1527
+ detail,
1528
+ int((end_time - start_time) * 1000),
1529
+ execution_context,
1530
+ input_data=payload_dict,
1531
+ parent_execution_id=execution_context.parent_execution_id,
1532
+ )
1533
+ raise
1534
+ except Exception as exc:
1535
+ if hasattr(self, "workflow_handler") and self.workflow_handler:
1536
+ end_time = time.time()
1537
+ await self.workflow_handler.notify_call_error(
1538
+ execution_context.execution_id,
1539
+ execution_context.workflow_id,
1540
+ str(exc),
1541
+ int((end_time - start_time) * 1000),
1542
+ execution_context,
1543
+ input_data=payload_dict,
1544
+ parent_execution_id=execution_context.parent_execution_id,
1545
+ )
1546
+ raise
1547
+ finally:
1548
+ reset_execution_context(context_token)
1549
+ self._current_execution_context = None
1550
+ self._clear_current()
1551
+
1552
+ async def _execute_async_with_callback(
1553
+ self,
1554
+ *,
1555
+ reasoner_coro: Callable[[], Awaitable[Any]],
1556
+ execution_id: str,
1557
+ reasoner_name: str,
1558
+ ) -> None:
1559
+ if not execution_id:
1560
+ return
1561
+ callback_url = self._build_execution_callback_url(execution_id)
1562
+ if not callback_url:
1563
+ log_warn("Unable to construct callback URL for execution updates")
1564
+ return
1565
+
1566
+ start_time = time.time()
1567
+ try:
1568
+ result = await reasoner_coro()
1569
+ payload = {
1570
+ "status": "succeeded",
1571
+ "result": jsonable_encoder(result),
1572
+ "duration_ms": int((time.time() - start_time) * 1000),
1573
+ "completed_at": datetime.now(timezone.utc).isoformat(),
1574
+ "execution_id": execution_id,
1575
+ "reasoner": reasoner_name,
1576
+ }
1577
+ log_info(f"Execution {execution_id} completed asynchronously")
1578
+ except Exception as exc:
1579
+ payload = {
1580
+ "status": "failed",
1581
+ "error": str(exc),
1582
+ "duration_ms": int((time.time() - start_time) * 1000),
1583
+ "completed_at": datetime.now(timezone.utc).isoformat(),
1584
+ "execution_id": execution_id,
1585
+ "reasoner": reasoner_name,
1586
+ }
1587
+ log_error(f"Execution {execution_id} failed asynchronously: {exc}")
1588
+ await self._post_execution_status(callback_url, payload, execution_id)
1589
+
1590
+ async def _post_execution_status(
1591
+ self,
1592
+ callback_url: str,
1593
+ payload: Dict[str, Any],
1594
+ execution_id: str,
1595
+ max_retries: int = 5,
1596
+ ) -> None:
1597
+ if not self.client:
1598
+ log_error("AgentField client unavailable; cannot send status updates")
1599
+ return
1600
+
1601
+ safe_payload = jsonable_encoder(payload)
1602
+ for attempt in range(max_retries):
1603
+ try:
1604
+ response = await self.client._async_request(
1605
+ "POST",
1606
+ callback_url,
1607
+ json=safe_payload,
1608
+ headers={"Content-Type": "application/json"},
1609
+ )
1610
+ if 200 <= response.status_code < 300:
1611
+ if self.dev_mode:
1612
+ log_debug(
1613
+ f"Sent async status update for {execution_id} (attempt {attempt + 1})"
1614
+ )
1615
+ return
1616
+ log_warn(
1617
+ f"Async status update failed with {response.status_code} for execution {execution_id}"
1618
+ )
1619
+ except Exception as exc: # pragma: no cover - network errors
1620
+ log_warn(
1621
+ f"Async status update attempt {attempt + 1} failed for {execution_id}: {exc}"
1622
+ )
1623
+ if attempt < max_retries - 1:
1624
+ await asyncio.sleep(2**attempt)
1625
+ log_error(f"Failed to deliver async status for {execution_id} after retries")
1626
+
1627
+ def _build_execution_callback_url(self, execution_id: str) -> Optional[str]:
1628
+ if not self.agentfield_server or not execution_id:
1629
+ return None
1630
+ return (
1631
+ self.agentfield_server.rstrip("/")
1632
+ + f"/api/v1/executions/{execution_id}/status"
1633
+ )
1634
+
1635
+ def on_change(self, pattern: Union[str, List[str]]):
1636
+ """
1637
+ Decorator to mark a function as a memory event listener.
1638
+
1639
+ This decorator allows functions to automatically respond to changes in the agent's
1640
+ memory system. When memory data matching the specified patterns is modified,
1641
+ the decorated function will be called with the change event details.
1642
+
1643
+ Args:
1644
+ pattern (Union[str, List[str]]): Memory path pattern(s) to listen for changes.
1645
+ Supports glob-style patterns for flexible matching.
1646
+ Examples: "user.*", ["session.current_user", "workflow.status"]
1647
+
1648
+ Returns:
1649
+ Callable: The decorated function configured as a memory event listener.
1650
+
1651
+ Example:
1652
+ ```python
1653
+ @app.on_change("user.preferences.*")
1654
+ async def handle_preference_change(event):
1655
+ '''React to user preference changes.'''
1656
+ log_info(f"User preference changed: {event.key} = {event.data}")
1657
+
1658
+ # Update related systems
1659
+ if event.path.endswith("theme"):
1660
+ await update_ui_theme(event.data)
1661
+ elif event.path.endswith("language"):
1662
+ await update_localization(event.data)
1663
+
1664
+ @app.on_change(["session.user_id", "session.permissions"])
1665
+ async def handle_session_change(event):
1666
+ '''React to session-related changes.'''
1667
+ if event.path == "session.user_id":
1668
+ # User logged in/out
1669
+ await initialize_user_context(event.data)
1670
+ elif event.path == "session.permissions":
1671
+ # Permissions updated
1672
+ await refresh_access_controls(event.data)
1673
+
1674
+ # Memory changes trigger the listeners automatically
1675
+ app.memory.set("user.preferences.theme", "dark") # Triggers handle_preference_change
1676
+ app.memory.set("session.user_id", 12345) # Triggers handle_session_change
1677
+ ```
1678
+
1679
+ Note:
1680
+ - Listeners are called asynchronously when memory changes occur
1681
+ - Multiple patterns can be specified to listen for different memory paths
1682
+ - Event object contains key, previous_data, data, and timestamp
1683
+ - Listeners should be lightweight to avoid blocking memory operations
1684
+ """
1685
+
1686
+ def decorator(func: Callable) -> Callable:
1687
+ @wraps(func)
1688
+ async def wrapper(*args, **kwargs):
1689
+ return await func(*args, **kwargs)
1690
+
1691
+ # Attach metadata to the function
1692
+ setattr(wrapper, "_memory_event_listener", True)
1693
+ setattr(
1694
+ wrapper,
1695
+ "_memory_event_patterns",
1696
+ pattern if isinstance(pattern, list) else [pattern],
1697
+ )
1698
+ return wrapper
1699
+
1700
+ return decorator
1701
+
1702
+ def skill(
1703
+ self,
1704
+ tags: Optional[List[str]] = None,
1705
+ path: Optional[str] = None,
1706
+ name: Optional[str] = None,
1707
+ *,
1708
+ vc_enabled: Optional[bool] = None,
1709
+ ):
1710
+ """
1711
+ Decorator to register a skill function.
1712
+
1713
+ A skill is a deterministic function designed for business logic, integrations, data processing,
1714
+ and non-AI operations. Skills are ideal for tasks that require consistent, predictable behavior
1715
+ such as API calls, database operations, calculations, or data transformations.
1716
+
1717
+ The decorator automatically:
1718
+ - Generates input/output schemas from type hints
1719
+ - Creates FastAPI endpoints with proper validation
1720
+ - Integrates with workflow tracking and execution context
1721
+ - Enables cross-agent communication via the AgentField execution gateway
1722
+ - Provides access to execution context and memory system
1723
+
1724
+ Args:
1725
+ tags (List[str], optional): A list of tags for organizing and categorizing skills.
1726
+ Useful for grouping related functionality (e.g., ["database", "user_management"]).
1727
+ path (str, optional): Custom API endpoint path for this skill.
1728
+ Defaults to "/skills/{function_name}".
1729
+ name (str, optional): Explicit AgentField registration ID. Defaults to the function name.
1730
+ vc_enabled (bool | None, optional): Override VC generation for this skill. True forces VC creation,
1731
+ False disables it, and None inherits the agent-level policy.
1732
+
1733
+ Returns:
1734
+ Callable: The decorated function with enhanced AgentField integration.
1735
+
1736
+ Example:
1737
+ ```python
1738
+ from typing import Dict, List
1739
+ from pydantic import BaseModel
1740
+
1741
+ class UserData(BaseModel):
1742
+ id: int
1743
+ name: str
1744
+ email: str
1745
+ created_at: str
1746
+
1747
+ @app.skill(tags=["database", "user_management"])
1748
+ def get_user_profile(user_id: int) -> "UserData":
1749
+ '''Retrieve user profile from database.'''
1750
+
1751
+ # Deterministic database operation
1752
+ user = database.get_user(user_id)
1753
+ if not user:
1754
+ raise ValueError(f"User {user_id} not found")
1755
+
1756
+ return UserData(
1757
+ id=user.id,
1758
+ name=user.name,
1759
+ email=user.email,
1760
+ created_at=user.created_at.isoformat()
1761
+ )
1762
+
1763
+ @app.skill(tags=["api", "external"])
1764
+ async def send_notification(
1765
+ user_id: int,
1766
+ message: str,
1767
+ channel: str = "email"
1768
+ ) -> Dict[str, str]:
1769
+ '''Send notification via external service.'''
1770
+
1771
+ # External API integration
1772
+ response = await notification_service.send(
1773
+ user_id=user_id,
1774
+ message=message,
1775
+ channel=channel
1776
+ )
1777
+
1778
+ return {
1779
+ "status": "sent",
1780
+ "notification_id": response.id,
1781
+ "channel": channel
1782
+ }
1783
+
1784
+ # Usage in another agent:
1785
+ user = await app.call(
1786
+ "user_agent.get_user_profile",
1787
+ user_id=123
1788
+ )
1789
+
1790
+ await app.call(
1791
+ "notification_agent.send_notification",
1792
+ user_id=123,
1793
+ message="Welcome to our platform!",
1794
+ channel="email"
1795
+ )
1796
+ ```
1797
+
1798
+ Note:
1799
+ - Skills should be deterministic and side-effect aware
1800
+ - Skills can access `app.memory` for persistent storage
1801
+ - Execution context is automatically injected if the function accepts it
1802
+ - All skills are automatically tracked in workflow DAGs
1803
+ - Use skills for reliable, repeatable operations
1804
+ """
1805
+
1806
+ direct_registration: Optional[Callable] = None
1807
+ decorator_tags = tags
1808
+ decorator_path = path
1809
+ decorator_name = name
1810
+
1811
+ if decorator_tags and (
1812
+ inspect.isfunction(decorator_tags) or inspect.ismethod(decorator_tags)
1813
+ ):
1814
+ direct_registration = decorator_tags
1815
+ decorator_tags = None
1816
+
1817
+ def decorator(func: Callable) -> Callable:
1818
+ # Extract function metadata
1819
+ func_name = func.__name__
1820
+ skill_id = decorator_name or func_name
1821
+ endpoint_path = decorator_path or f"/skills/{func_name}"
1822
+ self._set_skill_vc_override(skill_id, vc_enabled)
1823
+
1824
+ # Get type hints for input schema
1825
+ type_hints = get_type_hints(func)
1826
+ sig = inspect.signature(func)
1827
+
1828
+ # Create input schema from function parameters
1829
+ input_fields = {}
1830
+ for param_name, param in sig.parameters.items():
1831
+ if param_name not in ["self", "execution_context"]:
1832
+ param_type = type_hints.get(param_name, str)
1833
+ default_value = (
1834
+ param.default
1835
+ if param.default is not inspect.Parameter.empty
1836
+ else ...
1837
+ )
1838
+ input_fields[param_name] = (param_type, default_value)
1839
+
1840
+ InputSchema = create_model(f"{func_name}Input", **input_fields)
1841
+
1842
+ # Get output schema from return type hint
1843
+ return_type = type_hints.get("return", dict)
1844
+
1845
+ # Create FastAPI endpoint
1846
+ @self.post(endpoint_path, response_model=return_type)
1847
+ async def endpoint(input_data: InputSchema, request: Request):
1848
+ # Extract execution context from request headers
1849
+ execution_context = ExecutionContext.from_request(request, self.node_id)
1850
+
1851
+ # Store current context for use in app.call()
1852
+ self._current_execution_context = execution_context
1853
+ context_token = None
1854
+ context_token = set_execution_context(execution_context)
1855
+ self._set_as_current()
1856
+
1857
+ # Create DID execution context if DID system is enabled
1858
+ did_execution_context = None
1859
+ if self.did_enabled and self.did_manager:
1860
+ session_identifier = (
1861
+ execution_context.session_id or execution_context.workflow_id
1862
+ )
1863
+ did_execution_context = self.did_manager.create_execution_context(
1864
+ execution_context.execution_id,
1865
+ execution_context.workflow_id,
1866
+ session_identifier,
1867
+ "agent", # caller function
1868
+ skill_id, # target function
1869
+ )
1870
+ # Populate execution context with DID information
1871
+ self._populate_execution_context_with_did(
1872
+ execution_context, did_execution_context
1873
+ )
1874
+
1875
+ # Convert input to function arguments
1876
+ input_payload = input_data.model_dump()
1877
+
1878
+ # 🔥 NEW: Automatic Pydantic model conversion (FastAPI-like behavior)
1879
+ # Use the original function for type hint inspection
1880
+ original_func = getattr(func, "_original_func", func)
1881
+ try:
1882
+ if should_convert_args(original_func):
1883
+ _converted_args, converted_kwargs = convert_function_args(
1884
+ original_func, (), input_payload
1885
+ )
1886
+ kwargs = converted_kwargs
1887
+ else:
1888
+ kwargs = dict(input_payload)
1889
+ except ValidationError as e:
1890
+ # Re-raise validation errors with context
1891
+ raise ValidationError(
1892
+ f"Pydantic validation failed for skill '{skill_id}': {e}",
1893
+ model=getattr(e, "model", None),
1894
+ ) from e
1895
+ except Exception as e:
1896
+ # Log conversion errors but continue with original args for backward compatibility
1897
+ if self.dev_mode:
1898
+ log_warn(
1899
+ f"Failed to convert arguments for skill '{skill_id}': {e}"
1900
+ )
1901
+ kwargs = dict(input_payload)
1902
+
1903
+ # Inject execution context if the function accepts it
1904
+ if "execution_context" in sig.parameters:
1905
+ kwargs["execution_context"] = execution_context
1906
+
1907
+ # Record start time for VC generation
1908
+ start_time = time.time()
1909
+ handler = getattr(self, "workflow_handler", None)
1910
+ if handler:
1911
+ execution_context.reasoner_name = skill_id
1912
+ await handler.notify_call_start(
1913
+ execution_context.execution_id,
1914
+ execution_context,
1915
+ skill_id,
1916
+ input_payload,
1917
+ parent_execution_id=execution_context.parent_execution_id,
1918
+ )
1919
+
1920
+ # 🔥 FIX: Call the original function directly to prevent double tracking
1921
+ # The FastAPI endpoint already handles tracking, so we don't want the tracked wrapper
1922
+ # (original_func already retrieved above for type hint inspection)
1923
+ try:
1924
+ if asyncio.iscoroutinefunction(original_func):
1925
+ result = await original_func(**kwargs)
1926
+ else:
1927
+ result = original_func(**kwargs)
1928
+
1929
+ duration_ms = int((time.time() - start_time) * 1000)
1930
+
1931
+ # Generate VC asynchronously if DID is enabled
1932
+ if did_execution_context and self._should_generate_vc(
1933
+ skill_id, self._skill_vc_overrides
1934
+ ):
1935
+ asyncio.create_task(
1936
+ self._generate_vc_async(
1937
+ self.vc_generator,
1938
+ did_execution_context,
1939
+ skill_id,
1940
+ input_payload,
1941
+ result,
1942
+ "success",
1943
+ None,
1944
+ duration_ms,
1945
+ )
1946
+ )
1947
+
1948
+ if handler:
1949
+ await handler.notify_call_complete(
1950
+ execution_context.execution_id,
1951
+ execution_context.workflow_id,
1952
+ result,
1953
+ duration_ms,
1954
+ execution_context,
1955
+ input_data=input_payload,
1956
+ parent_execution_id=execution_context.parent_execution_id,
1957
+ )
1958
+
1959
+ return result
1960
+ except asyncio.CancelledError as cancel_err:
1961
+ duration_ms = int((time.time() - start_time) * 1000)
1962
+ if handler:
1963
+ await handler.notify_call_error(
1964
+ execution_context.execution_id,
1965
+ execution_context.workflow_id,
1966
+ "Execution cancelled by upstream client",
1967
+ duration_ms,
1968
+ execution_context,
1969
+ input_data=input_payload,
1970
+ parent_execution_id=execution_context.parent_execution_id,
1971
+ )
1972
+ raise cancel_err
1973
+ except HTTPException as http_exc:
1974
+ duration_ms = int((time.time() - start_time) * 1000)
1975
+ detail = getattr(http_exc, "detail", None) or str(http_exc)
1976
+ if handler:
1977
+ await handler.notify_call_error(
1978
+ execution_context.execution_id,
1979
+ execution_context.workflow_id,
1980
+ detail,
1981
+ duration_ms,
1982
+ execution_context,
1983
+ input_data=input_payload,
1984
+ parent_execution_id=execution_context.parent_execution_id,
1985
+ )
1986
+ raise
1987
+ except Exception as exc:
1988
+ duration_ms = int((time.time() - start_time) * 1000)
1989
+ if handler:
1990
+ await handler.notify_call_error(
1991
+ execution_context.execution_id,
1992
+ execution_context.workflow_id,
1993
+ str(exc),
1994
+ duration_ms,
1995
+ execution_context,
1996
+ input_data=input_payload,
1997
+ parent_execution_id=execution_context.parent_execution_id,
1998
+ )
1999
+ raise
2000
+ finally:
2001
+ if context_token is not None:
2002
+ reset_execution_context(context_token)
2003
+ self._current_execution_context = None
2004
+ self._clear_current()
2005
+
2006
+ def _build_invocation_payload(args: tuple, kwargs: dict) -> Dict[str, Any]:
2007
+ try:
2008
+ bound = sig.bind_partial(*args, **kwargs)
2009
+ bound.apply_defaults()
2010
+ payload = {
2011
+ name: value
2012
+ for name, value in bound.arguments.items()
2013
+ if name != "self"
2014
+ }
2015
+ return payload
2016
+ except Exception:
2017
+ payload = {f"arg_{idx}": value for idx, value in enumerate(args)}
2018
+ payload.update({k: v for k, v in kwargs.items() if k != "self"})
2019
+ return payload
2020
+
2021
+ self.skills.append(
2022
+ {
2023
+ "id": skill_id,
2024
+ "input_schema": InputSchema.model_json_schema(),
2025
+ "tags": decorator_tags or [],
2026
+ "vc_enabled": self._effective_component_vc_setting(
2027
+ skill_id, self._skill_vc_overrides
2028
+ ),
2029
+ }
2030
+ )
2031
+
2032
+ original_func = func
2033
+ is_async = asyncio.iscoroutinefunction(original_func)
2034
+
2035
+ async def _run_async_skill(*args, **kwargs):
2036
+ current_context = get_current_context()
2037
+ if not current_context or not self.workflow_handler:
2038
+ return await original_func(*args, **kwargs)
2039
+
2040
+ child_context = current_context.create_child_context()
2041
+ child_context.reasoner_name = skill_id
2042
+ token = set_execution_context(child_context)
2043
+ previous_ctx = self._current_execution_context
2044
+ self._current_execution_context = child_context
2045
+ input_payload = _build_invocation_payload(args, kwargs)
2046
+
2047
+ await self.workflow_handler.notify_call_start(
2048
+ child_context.execution_id,
2049
+ child_context,
2050
+ skill_id,
2051
+ input_payload,
2052
+ parent_execution_id=current_context.execution_id,
2053
+ )
2054
+
2055
+ start_time = time.time()
2056
+ try:
2057
+ result = await original_func(*args, **kwargs)
2058
+ duration_ms = int((time.time() - start_time) * 1000)
2059
+ await self.workflow_handler.notify_call_complete(
2060
+ child_context.execution_id,
2061
+ child_context.workflow_id,
2062
+ result,
2063
+ duration_ms,
2064
+ child_context,
2065
+ input_data=input_payload,
2066
+ parent_execution_id=current_context.execution_id,
2067
+ )
2068
+ return result
2069
+ except Exception as exc:
2070
+ duration_ms = int((time.time() - start_time) * 1000)
2071
+ await self.workflow_handler.notify_call_error(
2072
+ child_context.execution_id,
2073
+ child_context.workflow_id,
2074
+ str(exc),
2075
+ duration_ms,
2076
+ child_context,
2077
+ input_data=input_payload,
2078
+ parent_execution_id=current_context.execution_id,
2079
+ )
2080
+ raise
2081
+ finally:
2082
+ reset_execution_context(token)
2083
+ self._current_execution_context = previous_ctx
2084
+
2085
+ def _run_sync_skill(*args, **kwargs):
2086
+ current_context = get_current_context()
2087
+ if not current_context or not self.agentfield_server:
2088
+ return original_func(*args, **kwargs)
2089
+
2090
+ child_context = current_context.create_child_context()
2091
+ child_context.reasoner_name = skill_id
2092
+ token = set_execution_context(child_context)
2093
+ previous_ctx = self._current_execution_context
2094
+ self._current_execution_context = child_context
2095
+
2096
+ input_payload = _build_invocation_payload(args, kwargs)
2097
+ start_time = time.time()
2098
+
2099
+ self._emit_workflow_event_sync(
2100
+ child_context,
2101
+ skill_id,
2102
+ status="running",
2103
+ input_data=input_payload,
2104
+ parent_execution_id=current_context.execution_id,
2105
+ )
2106
+
2107
+ try:
2108
+ result = original_func(*args, **kwargs)
2109
+ duration_ms = int((time.time() - start_time) * 1000)
2110
+ self._emit_workflow_event_sync(
2111
+ child_context,
2112
+ skill_id,
2113
+ status="succeeded",
2114
+ input_data=input_payload,
2115
+ result=result,
2116
+ duration_ms=duration_ms,
2117
+ parent_execution_id=current_context.execution_id,
2118
+ )
2119
+ return result
2120
+ except Exception as exc:
2121
+ duration_ms = int((time.time() - start_time) * 1000)
2122
+ self._emit_workflow_event_sync(
2123
+ child_context,
2124
+ skill_id,
2125
+ status="failed",
2126
+ input_data=input_payload,
2127
+ error=str(exc),
2128
+ duration_ms=duration_ms,
2129
+ parent_execution_id=current_context.execution_id,
2130
+ )
2131
+ raise
2132
+ finally:
2133
+ reset_execution_context(token)
2134
+ self._current_execution_context = previous_ctx
2135
+
2136
+ if is_async:
2137
+ tracked_callable = _run_async_skill
2138
+ else:
2139
+ tracked_callable = _run_sync_skill
2140
+
2141
+ setattr(tracked_callable, "_original_func", original_func)
2142
+ setattr(tracked_callable, "_is_tracked_replacement", True)
2143
+
2144
+ if skill_id != func_name:
2145
+ setattr(self, skill_id, getattr(self, func_name, tracked_callable))
2146
+ else:
2147
+ setattr(self, func_name, tracked_callable)
2148
+
2149
+ return tracked_callable
2150
+
2151
+ if direct_registration:
2152
+ return decorator(direct_registration)
2153
+
2154
+ return decorator
2155
+
2156
+ def include_router(
2157
+ self,
2158
+ router,
2159
+ prefix: str = "",
2160
+ tags: Optional[List[str]] = None,
2161
+ ) -> None:
2162
+ """Augment FastAPI's include_router to understand AgentRouter."""
2163
+
2164
+ if isinstance(router, AgentRouter):
2165
+ router._attach_agent(self)
2166
+ normalized_prefix = prefix.rstrip("/") if prefix else ""
2167
+
2168
+ def _replace_module_reference(
2169
+ original_func: Callable, tracked_func: Callable
2170
+ ) -> None:
2171
+ module_name = getattr(original_func, "__module__", None)
2172
+ attr_name = getattr(original_func, "__name__", None)
2173
+ if not module_name or not attr_name:
2174
+ return
2175
+ module = sys.modules.get(module_name)
2176
+ if module is None:
2177
+ return
2178
+ current = getattr(module, attr_name, None)
2179
+ if current is original_func:
2180
+ setattr(module, attr_name, tracked_func)
2181
+
2182
+ def _sanitize_prefix_for_id(value: Optional[str]) -> List[str]:
2183
+ if not value:
2184
+ return []
2185
+
2186
+ cleaned = value.strip("/")
2187
+ if not cleaned:
2188
+ return []
2189
+
2190
+ segments: List[str] = []
2191
+ for segment in cleaned.split("/"):
2192
+ sanitized = re.sub(r"[^0-9a-zA-Z]+", "_", segment)
2193
+ sanitized = re.sub(r"_+", "_", sanitized).strip("_")
2194
+ if sanitized:
2195
+ segments.append(sanitized.lower())
2196
+ return segments
2197
+
2198
+ def _build_prefixed_name(parts: List[str], base: str) -> str:
2199
+ if not parts:
2200
+ return base
2201
+ prefix_part = "_".join(parts)
2202
+ return f"{prefix_part}_{base}"
2203
+
2204
+ def _normalize_component_path(
2205
+ path_value: Optional[str], component: str, component_id: str
2206
+ ) -> str:
2207
+ """Ensure router-registered components map to /reasoners/{id} style paths."""
2208
+
2209
+ marker = f"/{component}/"
2210
+ if not path_value:
2211
+ return marker + component_id
2212
+
2213
+ idx = path_value.find(marker)
2214
+ if idx == -1:
2215
+ return path_value
2216
+
2217
+ # Preserve any include_router prefix (everything up to and including marker)
2218
+ prefix_part = path_value[: idx + len(marker)]
2219
+ if path_value.endswith(component_id) and path_value.startswith(
2220
+ prefix_part
2221
+ ):
2222
+ # Already normalized
2223
+ return path_value
2224
+
2225
+ return f"{prefix_part}{component_id}"
2226
+
2227
+ namespace_segments = _sanitize_prefix_for_id(getattr(router, "prefix", ""))
2228
+
2229
+ for entry in router.reasoners:
2230
+ if entry.get("registered"):
2231
+ continue
2232
+
2233
+ func = entry["func"]
2234
+ default_path = f"/reasoners/{func.__name__}"
2235
+ auto_path = entry.get("path") is None
2236
+ resolved_path = router._combine_path(
2237
+ default=default_path,
2238
+ custom=entry.get("path"),
2239
+ override_prefix=normalized_prefix,
2240
+ )
2241
+
2242
+ merged_tags: List[str] = []
2243
+ if tags:
2244
+ merged_tags.extend(tags)
2245
+ merged_tags.extend(entry.get("tags", []))
2246
+ tag_arg: Optional[List[str]] = merged_tags if merged_tags else None
2247
+
2248
+ entry_kwargs = dict(entry.get("kwargs", {}))
2249
+ explicit_reasoner_name = entry_kwargs.pop("name", None)
2250
+ reasoner_id = explicit_reasoner_name or _build_prefixed_name(
2251
+ namespace_segments,
2252
+ func.__name__,
2253
+ )
2254
+
2255
+ if auto_path:
2256
+ resolved_path = _normalize_component_path(
2257
+ resolved_path, "reasoners", reasoner_id
2258
+ )
2259
+
2260
+ decorated = self.reasoner(
2261
+ path=resolved_path,
2262
+ name=reasoner_id,
2263
+ tags=tag_arg,
2264
+ **entry_kwargs,
2265
+ )(func)
2266
+ _replace_module_reference(func, decorated)
2267
+ entry["func"] = decorated
2268
+ entry["registered"] = True
2269
+
2270
+ for entry in router.skills:
2271
+ if entry.get("registered"):
2272
+ continue
2273
+
2274
+ func = entry["func"]
2275
+ default_path = f"/skills/{func.__name__}"
2276
+ auto_path = entry.get("path") is None
2277
+ resolved_path = router._combine_path(
2278
+ default=default_path,
2279
+ custom=entry.get("path"),
2280
+ override_prefix=normalized_prefix,
2281
+ )
2282
+
2283
+ merged_tags: List[str] = []
2284
+ if tags:
2285
+ merged_tags.extend(tags)
2286
+ merged_tags.extend(entry.get("tags", []))
2287
+ tag_arg: Optional[List[str]] = merged_tags if merged_tags else None
2288
+
2289
+ entry_kwargs = entry.get("kwargs", {})
2290
+ explicit_skill_name = entry_kwargs.get("name")
2291
+ skill_id = explicit_skill_name or _build_prefixed_name(
2292
+ namespace_segments,
2293
+ func.__name__,
2294
+ )
2295
+
2296
+ if auto_path:
2297
+ resolved_path = _normalize_component_path(
2298
+ resolved_path, "skills", skill_id
2299
+ )
2300
+
2301
+ decorated = self.skill(
2302
+ tags=tag_arg,
2303
+ path=resolved_path,
2304
+ name=skill_id,
2305
+ )(func)
2306
+ _replace_module_reference(func, decorated)
2307
+ entry["func"] = decorated
2308
+ entry["registered"] = True
2309
+
2310
+ return
2311
+
2312
+ return super().include_router(router, prefix=prefix, tags=tags)
2313
+
2314
+ async def ai( # pragma: no cover - relies on external LLM services
2315
+ self,
2316
+ *args: Any,
2317
+ system: Optional[str] = None,
2318
+ user: Optional[str] = None,
2319
+ schema: Optional[Type[BaseModel]] = None,
2320
+ model: Optional[str] = None,
2321
+ temperature: Optional[float] = None,
2322
+ max_tokens: Optional[int] = None,
2323
+ stream: Optional[bool] = None,
2324
+ response_format: Optional[Union[Literal["auto", "json", "text"], Dict]] = None,
2325
+ context: Optional[Dict] = None,
2326
+ memory_scope: Optional[List[str]] = None,
2327
+ **kwargs,
2328
+ ) -> Any:
2329
+ """
2330
+ AI interface for LLM interactions with direct keyword argument support.
2331
+
2332
+ This method provides direct access to the AI functionality, allowing users to
2333
+ call `app.ai(...)` with keyword arguments for seamless LLM interactions.
2334
+
2335
+ Args:
2336
+ *args: Flexible inputs - text, images, audio, files, or mixed content.
2337
+ - str: Text content, URLs, or file paths (auto-detected).
2338
+ - bytes: Binary data (images, audio, documents).
2339
+ - dict: Structured input with explicit keys (e.g., {"image": "url"}).
2340
+ - list: Multimodal conversation or content list.
2341
+ system (str, optional): System prompt for AI behavior.
2342
+ user (str, optional): User message (alternative to positional args).
2343
+ schema (Type[BaseModel], optional): Pydantic model for structured output validation.
2344
+ model (str, optional): Override default model (e.g., "gpt-4", "claude-3").
2345
+ temperature (float, optional): Creativity level (0.0-2.0).
2346
+ max_tokens (int, optional): Maximum response length.
2347
+ stream (bool, optional): Enable streaming response.
2348
+ response_format (str, optional): Desired response format ('auto', 'json', 'text').
2349
+ context (Dict, optional): Additional context data to pass to the LLM.
2350
+ memory_scope (List[str], optional): Memory scopes to inject (e.g., ['workflow', 'session', 'reasoner']).
2351
+ **kwargs: Additional provider-specific parameters to pass to the LLM.
2352
+
2353
+ Returns:
2354
+ Any: The AI response - raw text, structured object (if schema), or a stream.
2355
+
2356
+ Example:
2357
+ ```python
2358
+ # Direct usage with keyword arguments
2359
+ response = await app.ai(
2360
+ system="You are a helpful assistant",
2361
+ user="What is the capital of France?",
2362
+ model="gpt-4",
2363
+ temperature=0.7
2364
+ )
2365
+
2366
+ # Structured output
2367
+ class SentimentResult(BaseModel):
2368
+ sentiment: str
2369
+ confidence: float
2370
+
2371
+ result = await app.ai(
2372
+ "Analyze sentiment of: I love this!",
2373
+ schema=SentimentResult
2374
+ )
2375
+
2376
+ # Multimodal input
2377
+ response = await app.ai(
2378
+ "Describe this image:",
2379
+ "https://example.com/image.jpg"
2380
+ )
2381
+
2382
+ # Simple text input
2383
+ response = await app.ai("Summarize this document.")
2384
+ ```
2385
+ """
2386
+ return await self.ai_handler.ai(
2387
+ *args,
2388
+ system=system,
2389
+ user=user,
2390
+ schema=schema,
2391
+ model=model,
2392
+ temperature=temperature,
2393
+ max_tokens=max_tokens,
2394
+ stream=stream,
2395
+ response_format=response_format,
2396
+ context=context,
2397
+ memory_scope=memory_scope,
2398
+ **kwargs,
2399
+ )
2400
+
2401
+ def _ensure_call_semaphore(self) -> asyncio.Semaphore:
2402
+ semaphore = getattr(self, "_call_semaphore", None)
2403
+ if semaphore is None:
2404
+ guard = getattr(self, "_call_semaphore_guard", None)
2405
+ if guard is None:
2406
+ guard = threading.Lock()
2407
+ setattr(self, "_call_semaphore_guard", guard)
2408
+ max_calls = max(1, getattr(self, "_max_concurrent_calls", 1))
2409
+ with guard:
2410
+ semaphore = getattr(self, "_call_semaphore", None)
2411
+ if semaphore is None:
2412
+ semaphore = asyncio.Semaphore(max_calls)
2413
+ setattr(self, "_call_semaphore", semaphore)
2414
+ return semaphore
2415
+
2416
+ @asynccontextmanager
2417
+ async def _limit_outbound_calls(self):
2418
+ semaphore = self._ensure_call_semaphore()
2419
+ await semaphore.acquire()
2420
+ try:
2421
+ yield
2422
+ finally:
2423
+ semaphore.release()
2424
+
2425
+ async def ai_with_audio( # pragma: no cover - relies on external audio services
2426
+ self,
2427
+ *args: Any,
2428
+ voice: Optional[str] = None,
2429
+ format: Optional[str] = None,
2430
+ model: Optional[str] = None,
2431
+ mode: Optional[str] = None,
2432
+ **kwargs,
2433
+ ) -> "MultimodalResponse":
2434
+ """
2435
+ AI interface optimized for audio generation.
2436
+
2437
+ This method is specifically designed for generating audio content from text prompts.
2438
+ It automatically configures the AI request for audio output and returns a
2439
+ MultimodalResponse with convenient audio access methods.
2440
+
2441
+ Args:
2442
+ *args: Text prompts or multimodal inputs for audio generation.
2443
+ voice (str, optional): Voice to use for audio generation.
2444
+ Available options: alloy, echo, fable, onyx, nova, shimmer.
2445
+ format (str, optional): Audio format (wav, mp3). Defaults to wav.
2446
+ model (str, optional): Model to use for audio generation.
2447
+ Defaults to gpt-4o-audio-preview.
2448
+ **kwargs: Additional parameters passed to the AI method.
2449
+
2450
+ Returns:
2451
+ MultimodalResponse: Response object with audio content and convenient access methods.
2452
+
2453
+ Example:
2454
+ ```python
2455
+ # Basic audio generation
2456
+ response = await app.ai_with_audio("Explain quantum computing")
2457
+ response.audio.save("explanation.wav")
2458
+
2459
+ # Custom voice and format
2460
+ response = await app.ai_with_audio(
2461
+ "Tell a bedtime story",
2462
+ voice="nova",
2463
+ format="mp3"
2464
+ )
2465
+ response.audio.play()
2466
+ ```
2467
+ """
2468
+ # Only pass parameters that are not None
2469
+ audio_kwargs = {}
2470
+ if voice is not None:
2471
+ audio_kwargs["voice"] = voice
2472
+ if format is not None:
2473
+ audio_kwargs["format"] = format
2474
+ if model is not None:
2475
+ audio_kwargs["model"] = model
2476
+ if mode is not None:
2477
+ audio_kwargs["mode"] = mode
2478
+
2479
+ return await self.ai_handler.ai_with_audio(*args, **audio_kwargs, **kwargs)
2480
+
2481
+ async def ai_with_vision( # pragma: no cover - relies on external vision services
2482
+ self,
2483
+ *args: Any,
2484
+ size: Optional[str] = None,
2485
+ quality: Optional[str] = None,
2486
+ style: Optional[str] = None,
2487
+ model: Optional[str] = None,
2488
+ **kwargs,
2489
+ ) -> "MultimodalResponse":
2490
+ """
2491
+ AI interface optimized for image generation and vision tasks.
2492
+
2493
+ This method is designed for generating images from text prompts or analyzing
2494
+ visual content. It returns a MultimodalResponse with convenient image access methods.
2495
+
2496
+ Args:
2497
+ *args: Text prompts or multimodal inputs for image generation/analysis.
2498
+ size (str, optional): Image size (e.g., "1024x1024", "1792x1024", "1024x1792").
2499
+ quality (str, optional): Image quality ("standard" or "hd").
2500
+ style (str, optional): Image style ("vivid" or "natural") for DALL-E 3.
2501
+ model (str, optional): Model to use for image generation. Defaults to dall-e-3.
2502
+ **kwargs: Additional parameters passed to the AI method.
2503
+
2504
+ Returns:
2505
+ MultimodalResponse: Response object with image content and convenient access methods.
2506
+
2507
+ Example:
2508
+ ```python
2509
+ # Basic image generation
2510
+ response = await app.ai_with_vision("A serene mountain landscape")
2511
+ response.images[0].save("landscape.png")
2512
+
2513
+ # High-quality image with custom size
2514
+ response = await app.ai_with_vision(
2515
+ "Futuristic cityscape",
2516
+ size="1792x1024",
2517
+ quality="hd",
2518
+ style="vivid"
2519
+ )
2520
+ response.images[0].show()
2521
+ ```
2522
+ """
2523
+ # Only pass parameters that are not None
2524
+ vision_kwargs = {}
2525
+ if size is not None:
2526
+ vision_kwargs["size"] = size
2527
+ if quality is not None:
2528
+ vision_kwargs["quality"] = quality
2529
+ if style is not None:
2530
+ vision_kwargs["style"] = style
2531
+ if model is not None:
2532
+ vision_kwargs["model"] = model
2533
+
2534
+ return await self.ai_handler.ai_with_vision(*args, **vision_kwargs, **kwargs)
2535
+
2536
+ async def ai_with_multimodal( # pragma: no cover - relies on external multimodal services
2537
+ self,
2538
+ *args: Any,
2539
+ modalities: Optional[List[str]] = None,
2540
+ audio_config: Optional[Dict] = None,
2541
+ image_config: Optional[Dict] = None,
2542
+ model: Optional[str] = None,
2543
+ **kwargs,
2544
+ ) -> "MultimodalResponse":
2545
+ """
2546
+ AI interface with explicit multimodal control.
2547
+
2548
+ This method provides fine-grained control over multimodal AI interactions,
2549
+ allowing you to specify exactly which output modalities you want and
2550
+ configure them individually.
2551
+
2552
+ Args:
2553
+ *args: Multimodal inputs (text, images, audio, files).
2554
+ modalities (List[str], optional): Desired output modalities
2555
+ (e.g., ["text", "audio", "image"]).
2556
+ audio_config (Dict, optional): Audio generation configuration
2557
+ (voice, format, etc.).
2558
+ image_config (Dict, optional): Image generation configuration
2559
+ (size, quality, style, etc.).
2560
+ model (str, optional): Model to use for multimodal generation.
2561
+ **kwargs: Additional parameters passed to the AI method.
2562
+
2563
+ Returns:
2564
+ MultimodalResponse: Response object with all requested modalities.
2565
+
2566
+ Example:
2567
+ ```python
2568
+ # Request specific modalities
2569
+ response = await app.ai_with_multimodal(
2570
+ "Create a presentation about AI",
2571
+ modalities=["text", "audio"],
2572
+ audio_config={"voice": "alloy", "format": "wav"}
2573
+ )
2574
+
2575
+ # Save all generated content
2576
+ files = response.save_all("./output", prefix="ai_presentation")
2577
+ ```
2578
+ """
2579
+ return await self.ai_handler.ai_with_multimodal(
2580
+ *args,
2581
+ modalities=modalities,
2582
+ audio_config=audio_config,
2583
+ image_config=image_config,
2584
+ model=model,
2585
+ **kwargs,
2586
+ )
2587
+
2588
+ async def call(self, target: str, *args, **kwargs) -> dict:
2589
+ """
2590
+ Initiates a cross-agent call to another reasoner or skill via the AgentField execution gateway.
2591
+
2592
+ This method allows agents to seamlessly communicate and utilize reasoners/skills
2593
+ deployed on other agent nodes within the AgentField ecosystem. It properly propagates
2594
+ workflow tracking headers and maintains execution context for DAG building.
2595
+
2596
+ **Return Type**: Always returns JSON/dict objects, similar to calling any REST API.
2597
+ No automatic schema conversion is performed - developers can convert to Pydantic
2598
+ models manually if needed.
2599
+
2600
+ The method supports both positional and keyword arguments for maximum flexibility:
2601
+ - Pure keyword arguments (recommended): call("target", param1=value1, param2=value2)
2602
+ - Mixed positional and keyword: call("target", value1, value2, param3=value3)
2603
+ - Pure positional (auto-mapped): call("target", value1, value2, value3)
2604
+
2605
+ Args:
2606
+ target (str): The full target ID in format "node_id.reasoner_name" or "node_id.skill_name"
2607
+ (e.g., "classification_team.classify_ticket", "support_agent.send_email").
2608
+ *args: Positional arguments to pass to the target reasoner/skill. These will be
2609
+ automatically mapped to the target function's parameter names in order.
2610
+ **kwargs: Keyword arguments to pass to the target reasoner/skill.
2611
+
2612
+ Returns:
2613
+ dict: The result from the target reasoner/skill execution as JSON/dict.
2614
+ Always returns dict objects, like calling any REST API.
2615
+
2616
+ Examples:
2617
+ # Reasoner call - returns dict (convert to Pydantic manually if needed)
2618
+ result: dict = await app.call("sentiment_agent.analyze_sentiment",
2619
+ message="I love this product!",
2620
+ customer_id="cust_123")
2621
+ sentiment = SentimentResult(**result) # Manual conversion if needed
2622
+ log_info(sentiment.confidence)
2623
+
2624
+ # Skill call - returns dict
2625
+ result: dict = await app.call("notification_agent.send_email",
2626
+ "user@example.com", # positional: to
2627
+ "Welcome!", # positional: subject
2628
+ body="Thank you for signing up.") # keyword
2629
+
2630
+ # All calls return dict - consistent behavior
2631
+ analysis: dict = await app.call("content_agent.analyze_content",
2632
+ "This is great content!", # content
2633
+ "blog_post") # content_type
2634
+
2635
+ # Error handling
2636
+ try:
2637
+ result = await app.call("some_agent.some_reasoner", data="test")
2638
+ # result is always a dict
2639
+ except Exception as e:
2640
+ log_error(f"Call failed: {e}")
2641
+ """
2642
+ # Handle argument mapping for flexibility
2643
+ final_kwargs = kwargs.copy()
2644
+
2645
+ if args:
2646
+ # If positional arguments are provided, we need to map them to parameter names
2647
+ # For cross-agent calls, we don't have direct access to the target function signature,
2648
+ # so we'll use a simple mapping strategy:
2649
+
2650
+ # Try to get parameter names from the target (if it's a local reasoner/skill)
2651
+ if "." in target:
2652
+ node_id, function_name = target.split(".", 1)
2653
+
2654
+ # If calling a local function (same node), try to get its signature
2655
+ if node_id == self.node_id and hasattr(self, function_name):
2656
+ try:
2657
+ func = getattr(self, function_name)
2658
+ sig = inspect.signature(func)
2659
+ param_names = [
2660
+ name
2661
+ for name, param in sig.parameters.items()
2662
+ if name not in ["self", "execution_context"]
2663
+ ]
2664
+
2665
+ # Map positional args to parameter names
2666
+ for i, arg in enumerate(args):
2667
+ if i < len(param_names):
2668
+ param_name = param_names[i]
2669
+ if (
2670
+ param_name not in final_kwargs
2671
+ ): # Don't override explicit kwargs
2672
+ final_kwargs[param_name] = arg
2673
+ else:
2674
+ # More args than parameters - use generic names
2675
+ final_kwargs[f"arg_{i}"] = arg
2676
+
2677
+ except Exception:
2678
+ # Fallback to generic parameter names if signature inspection fails
2679
+ for i, arg in enumerate(args):
2680
+ final_kwargs[f"arg_{i}"] = arg
2681
+ else:
2682
+ # Cross-agent call - use generic parameter names
2683
+ # The receiving agent will need to handle the mapping
2684
+ for i, arg in enumerate(args):
2685
+ final_kwargs[f"arg_{i}"] = arg
2686
+ else:
2687
+ # Simple function name without node_id - use generic names
2688
+ for i, arg in enumerate(args):
2689
+ final_kwargs[f"arg_{i}"] = arg
2690
+
2691
+ # Get current execution context
2692
+ current_context = self._get_current_execution_context()
2693
+
2694
+ # 🔧 DEBUG: Validate context before creating child
2695
+ if self.dev_mode:
2696
+ from agentfield.execution_context import get_current_context
2697
+ from agentfield.logger import log_debug
2698
+
2699
+ log_debug(f"🔍 CALL_DEBUG: Making cross-agent call to {target}")
2700
+ log_debug(f" Current execution_id: {current_context.execution_id}")
2701
+ log_debug(
2702
+ f" Thread-local context exists: {get_current_context() is not None}"
2703
+ )
2704
+ log_debug(
2705
+ f" Agent-level context exists: {self._current_execution_context is not None}"
2706
+ )
2707
+
2708
+ # Prepare headers with proper workflow tracking
2709
+ headers = current_context.to_headers()
2710
+
2711
+ # Ensure the current execution is the parent for sub-calls (not the inherited parent)
2712
+ # This fixes workflow graph attribution for local skill calls
2713
+ headers["X-Parent-Execution-ID"] = current_context.execution_id
2714
+
2715
+ # DISABLED: Same-agent call detection - Force all calls through AgentField server
2716
+ # This ensures all app.call() requests go through the AgentField server for proper
2717
+ # workflow tracking, execution context, and distributed processing
2718
+ from agentfield.logger import log_debug
2719
+
2720
+ log_debug(f"Cross-agent call to: {target}")
2721
+
2722
+ # Check if AgentField server is available for cross-agent calls
2723
+ if not self.agentfield_connected:
2724
+ from agentfield.logger import log_warn
2725
+
2726
+ log_warn(
2727
+ f"AgentField server unavailable - cannot make cross-agent call to {target}"
2728
+ )
2729
+ raise Exception(
2730
+ f"Cross-agent call to {target} failed: AgentField server unavailable. Agent is running in local mode."
2731
+ )
2732
+
2733
+ # Use the enhanced AgentFieldClient to make the call via execution gateway
2734
+ try:
2735
+ async with self._limit_outbound_calls():
2736
+ # Check for non-serializable parameters and convert them
2737
+ serialization_issues = []
2738
+ for key, value in final_kwargs.items():
2739
+ try:
2740
+ import json
2741
+
2742
+ json.dumps(value, default=str) # Test serialization
2743
+ except (TypeError, ValueError) as se:
2744
+ serialization_issues.append(
2745
+ f"{key}: {type(value).__name__} - {str(se)}"
2746
+ )
2747
+
2748
+ # Try to convert common non-serializable types
2749
+ if hasattr(value, "value"): # Enum with .value attribute
2750
+ final_kwargs[key] = value.value
2751
+ elif hasattr(value, "__dict__"): # Object with attributes
2752
+ final_kwargs[key] = value.__dict__
2753
+ else:
2754
+ final_kwargs[key] = str(value)
2755
+
2756
+ if serialization_issues and self.dev_mode:
2757
+ log_debug(
2758
+ f"Converted {len(serialization_issues)} non-serializable parameters"
2759
+ )
2760
+
2761
+ import asyncio
2762
+ import time
2763
+
2764
+ # Determine how long we're willing to wait for long-running executions.
2765
+ max_timeout = getattr(self.async_config, "max_execution_timeout", None)
2766
+ default_timeout = getattr(
2767
+ self.async_config, "default_execution_timeout", None
2768
+ )
2769
+ execution_timeout = max_timeout or default_timeout or 600.0
2770
+ # Guard against misconfiguration resulting in non-positive values.
2771
+ if execution_timeout <= 0:
2772
+ execution_timeout = 600.0
2773
+
2774
+ start_time = time.time()
2775
+
2776
+ # Check if async execution is enabled and available
2777
+ use_async_execution = (
2778
+ self.async_config.enable_async_execution
2779
+ and self.agentfield_connected
2780
+ )
2781
+
2782
+ if use_async_execution:
2783
+ try:
2784
+ if self.dev_mode:
2785
+ log_debug(f"Using async execution for target: {target}")
2786
+
2787
+ execution_id = await self.client.execute_async(
2788
+ target=target,
2789
+ input_data=final_kwargs,
2790
+ headers=headers,
2791
+ timeout=execution_timeout,
2792
+ )
2793
+
2794
+ result = await self.client.wait_for_execution_result(
2795
+ execution_id=execution_id,
2796
+ timeout=execution_timeout,
2797
+ )
2798
+
2799
+ elapsed_time = time.time() - start_time
2800
+ if self.dev_mode:
2801
+ log_debug(
2802
+ f"Async execute call completed in {elapsed_time:.2f} seconds"
2803
+ )
2804
+
2805
+ if isinstance(result, dict) and "result" in result:
2806
+ return result["result"]
2807
+ return result
2808
+
2809
+ except Exception as async_error:
2810
+ if self.dev_mode:
2811
+ log_debug(
2812
+ f"Async execution failed: {type(async_error).__name__}: {str(async_error)}"
2813
+ )
2814
+
2815
+ if not self.async_config.fallback_to_sync:
2816
+ raise async_error
2817
+
2818
+ if self.dev_mode:
2819
+ log_debug(
2820
+ f"Falling back to sync execution for target: {target}"
2821
+ )
2822
+
2823
+ # Sync execution path (either by choice or as fallback)
2824
+ if self.dev_mode and use_async_execution:
2825
+ log_debug(f"Using sync execution as fallback for target: {target}")
2826
+ elif self.dev_mode:
2827
+ log_debug(f"Using sync execution for target: {target}")
2828
+
2829
+ # Wrap the execute call with timeout and progress monitoring
2830
+ async def execute_with_monitoring():
2831
+ try:
2832
+ result = await self.client.execute(
2833
+ target=target, input_data=final_kwargs, headers=headers
2834
+ )
2835
+ return result
2836
+ except Exception as exec_error:
2837
+ if self.dev_mode:
2838
+ log_debug(
2839
+ f"Client execute failed: {type(exec_error).__name__}: {str(exec_error)}"
2840
+ )
2841
+ raise
2842
+
2843
+ # Add a timeout to prevent infinite hangs using configured allowance for long workflows
2844
+ try:
2845
+ result = await asyncio.wait_for(
2846
+ execute_with_monitoring(), timeout=execution_timeout
2847
+ )
2848
+ elapsed_time = time.time() - start_time
2849
+ if self.dev_mode:
2850
+ log_debug(
2851
+ f"Sync execute call completed in {elapsed_time:.2f} seconds"
2852
+ )
2853
+ except asyncio.TimeoutError:
2854
+ elapsed_time = time.time() - start_time
2855
+ log_debug(
2856
+ f"Execute call timed out after {elapsed_time:.2f} seconds (limit {execution_timeout:.0f}s)"
2857
+ )
2858
+ raise Exception(
2859
+ f"Cross-agent call to {target} timed out after {int(execution_timeout)} seconds"
2860
+ )
2861
+
2862
+ # Extract the actual result from the response and return as dict
2863
+ if isinstance(result, dict):
2864
+ if result.get("result") is not None:
2865
+ extracted_result = result["result"]
2866
+ elif "body" in result:
2867
+ extracted_result = result["body"]
2868
+ else:
2869
+ extracted_result = result
2870
+ else:
2871
+ extracted_result = result
2872
+
2873
+ # Always return dict/JSON - no schema conversion
2874
+ return extracted_result
2875
+
2876
+ except Exception as e:
2877
+ if self.dev_mode:
2878
+ log_debug(
2879
+ f"Cross-agent call failed: {target} - {type(e).__name__}: {str(e)}"
2880
+ )
2881
+ raise
2882
+
2883
+ async def _get_async_execution_manager(self) -> AsyncExecutionManager:
2884
+ """
2885
+ Get or create the async execution manager instance.
2886
+
2887
+ Returns:
2888
+ AsyncExecutionManager: The async execution manager instance
2889
+ """
2890
+ if self._async_execution_manager is None:
2891
+ # Create async execution manager with the same base URL as the client
2892
+ self._async_execution_manager = AsyncExecutionManager(
2893
+ base_url=self.agentfield_server, config=self.async_config
2894
+ )
2895
+ # Start the manager
2896
+ await self._async_execution_manager.start()
2897
+
2898
+ if self.dev_mode:
2899
+ log_debug("AsyncExecutionManager initialized and started")
2900
+
2901
+ return self._async_execution_manager
2902
+
2903
+ async def _cleanup_async_resources(self) -> None:
2904
+ """
2905
+ Clean up async execution manager resources.
2906
+
2907
+ This method should be called during agent shutdown to properly
2908
+ clean up async execution resources.
2909
+ """
2910
+ if self._async_execution_manager is not None:
2911
+ try:
2912
+ await self._async_execution_manager.stop()
2913
+ self._async_execution_manager = None
2914
+ if self.dev_mode:
2915
+ log_debug("AsyncExecutionManager stopped and cleaned up")
2916
+ except Exception as e:
2917
+ if self.dev_mode:
2918
+ log_debug(f"Error cleaning up AsyncExecutionManager: {e}")
2919
+
2920
+ if getattr(self, "client", None) is not None:
2921
+ try:
2922
+ await self.client.aclose()
2923
+ if self.dev_mode:
2924
+ log_debug("AgentFieldClient resources closed")
2925
+ except Exception as e:
2926
+ if self.dev_mode:
2927
+ log_debug(f"Error closing AgentFieldClient resources: {e}")
2928
+
2929
+ def note(self, message: str, tags: List[str] = None) -> None:
2930
+ """
2931
+ Add a note to the current execution for debugging and tracking purposes.
2932
+
2933
+ This method sends a note to the AgentField server asynchronously without blocking
2934
+ the current execution. The note is automatically associated with the current
2935
+ execution context and can be viewed in the AgentField UI for debugging and monitoring.
2936
+
2937
+ Args:
2938
+ message (str): The note message to log
2939
+ tags (List[str], optional): Optional tags to categorize the note
2940
+
2941
+ Example:
2942
+ ```python
2943
+ @app.reasoner()
2944
+ async def process_data(data: str) -> dict:
2945
+ app.note("Starting data processing", ["debug", "processing"])
2946
+
2947
+ # Process data...
2948
+ result = await some_processing(data)
2949
+
2950
+ app.note(f"Processing completed with {len(result)} items", ["info"])
2951
+ return result
2952
+ ```
2953
+
2954
+ Note:
2955
+ This method is fire-and-forget and runs asynchronously in the background.
2956
+ It will not block the current execution or raise exceptions that would
2957
+ interrupt the workflow.
2958
+ """
2959
+ if tags is None:
2960
+ tags = []
2961
+
2962
+ # Fire-and-forget async task
2963
+ import asyncio
2964
+
2965
+ async def _send_note():
2966
+ try:
2967
+ # Get current execution context
2968
+ current_context = self._get_current_execution_context()
2969
+
2970
+ # Prepare headers with execution context
2971
+ headers = current_context.to_headers()
2972
+ headers["Content-Type"] = "application/json"
2973
+
2974
+ # Prepare payload
2975
+ payload = {
2976
+ "message": message,
2977
+ "tags": tags,
2978
+ "timestamp": time.time(),
2979
+ "agent_node_id": self.node_id,
2980
+ }
2981
+
2982
+ # Make async HTTP request to backend - use UI API endpoint to match frontend
2983
+ try:
2984
+ import aiohttp
2985
+
2986
+ timeout = aiohttp.ClientTimeout(total=5.0) # 5 second timeout
2987
+ # Use UI API base URL to match where frontend fetches notes from
2988
+ # Replace the last occurrence of /api/v1 with /api/ui/v1
2989
+ ui_api_base = self.client.api_base.replace("/api/v1", "/api/ui/v1")
2990
+
2991
+ if self.dev_mode:
2992
+ from agentfield.logger import log_debug
2993
+
2994
+ log_debug(
2995
+ f"NOTE DEBUG: Original api_base: {self.client.api_base}"
2996
+ )
2997
+ log_debug(f"NOTE DEBUG: UI api_base: {ui_api_base}")
2998
+ log_debug(
2999
+ f"NOTE DEBUG: Full URL: {ui_api_base}/executions/note"
3000
+ )
3001
+ log_debug(f"NOTE DEBUG: Payload: {payload}")
3002
+ log_debug(f"NOTE DEBUG: Headers: {headers}")
3003
+
3004
+ async with aiohttp.ClientSession(timeout=timeout) as session:
3005
+ async with session.post(
3006
+ f"{ui_api_base}/executions/note",
3007
+ json=payload,
3008
+ headers=headers,
3009
+ ) as response:
3010
+ if self.dev_mode:
3011
+ from agentfield.logger import log_debug
3012
+
3013
+ response_text = await response.text()
3014
+ log_debug(
3015
+ f"NOTE DEBUG: Response status: {response.status}"
3016
+ )
3017
+ log_debug(f"NOTE DEBUG: Response text: {response_text}")
3018
+ if response.status == 200:
3019
+ log_debug(
3020
+ f"✅ Note successfully sent to {ui_api_base}/executions/note"
3021
+ )
3022
+ else:
3023
+ log_debug(
3024
+ f"❌ Note failed with status {response.status}: {response_text}"
3025
+ )
3026
+ except ImportError:
3027
+ # Fallback to requests if aiohttp not available
3028
+ import requests
3029
+
3030
+ try:
3031
+ # Use UI API base URL to match where frontend fetches notes from
3032
+ ui_api_base = self.client.api_base.replace(
3033
+ "/api/v1", "/api/ui/v1"
3034
+ )
3035
+
3036
+ if self.dev_mode:
3037
+ from agentfield.logger import log_debug
3038
+
3039
+ log_debug(
3040
+ f"NOTE DEBUG (requests): Original api_base: {self.client.api_base}"
3041
+ )
3042
+ log_debug(
3043
+ f"NOTE DEBUG (requests): UI api_base: {ui_api_base}"
3044
+ )
3045
+ log_debug(
3046
+ f"NOTE DEBUG (requests): Full URL: {ui_api_base}/executions/note"
3047
+ )
3048
+
3049
+ response = requests.post(
3050
+ f"{ui_api_base}/executions/note",
3051
+ json=payload,
3052
+ headers=headers,
3053
+ timeout=5.0,
3054
+ )
3055
+ if self.dev_mode:
3056
+ from agentfield.logger import log_debug
3057
+
3058
+ log_debug(
3059
+ f"NOTE DEBUG (requests): Response status: {response.status_code}"
3060
+ )
3061
+ log_debug(
3062
+ f"NOTE DEBUG (requests): Response text: {response.text}"
3063
+ )
3064
+ if response.status_code == 200:
3065
+ log_debug(
3066
+ f"✅ Note successfully sent to {ui_api_base}/executions/note"
3067
+ )
3068
+ else:
3069
+ log_debug(
3070
+ f"❌ Note failed with status {response.status_code}: {response.text}"
3071
+ )
3072
+ except Exception as e:
3073
+ if self.dev_mode:
3074
+ from agentfield.logger import log_debug
3075
+
3076
+ log_debug(f"Note request failed: {type(e).__name__}: {e}")
3077
+
3078
+ except Exception as e:
3079
+ # Silently handle errors to avoid interrupting main workflow
3080
+ if self.dev_mode:
3081
+ from agentfield.logger import log_debug
3082
+
3083
+ log_debug(f"Failed to send note: {type(e).__name__}: {e}")
3084
+
3085
+ # Create task without awaiting (fire-and-forget)
3086
+ try:
3087
+ # Try to get current event loop
3088
+ loop = asyncio.get_event_loop()
3089
+ if loop.is_running():
3090
+ # If we're in an async context, create a task
3091
+ loop.create_task(_send_note())
3092
+ else:
3093
+ # If no loop is running, run in a new thread
3094
+ import threading
3095
+
3096
+ thread = threading.Thread(target=lambda: asyncio.run(_send_note()))
3097
+ thread.daemon = True
3098
+ thread.start()
3099
+ except RuntimeError:
3100
+ # No event loop available, run in a new thread
3101
+ import threading
3102
+
3103
+ thread = threading.Thread(target=lambda: asyncio.run(_send_note()))
3104
+ thread.daemon = True
3105
+ thread.start()
3106
+
3107
+ def _get_current_execution_context(self) -> ExecutionContext:
3108
+ """
3109
+ Get the current execution context, creating a new one if none exists.
3110
+
3111
+ This method checks thread-local context first (most reliable) and falls back
3112
+ to agent-level context for proper parent-child relationship tracking.
3113
+
3114
+ Returns:
3115
+ ExecutionContext: Current or new execution context
3116
+ """
3117
+ # Check thread-local context first (most reliable)
3118
+ from agentfield.execution_context import get_current_context
3119
+
3120
+ thread_local_context = get_current_context()
3121
+
3122
+ if thread_local_context:
3123
+ # Sync agent-level with thread-local
3124
+ self._current_execution_context = thread_local_context
3125
+ return thread_local_context
3126
+
3127
+ # Fall back to agent-level context
3128
+ if self._current_execution_context:
3129
+ return self._current_execution_context
3130
+
3131
+ # Create new context if none exists and cache it
3132
+ new_context = ExecutionContext.create_new(
3133
+ agent_node_id=self.node_id, workflow_name=f"{self.node_id}_workflow"
3134
+ )
3135
+ self._current_execution_context = new_context
3136
+ return new_context
3137
+
3138
+ def _get_target_return_type(self, target: str) -> Optional[Type]:
3139
+ """
3140
+ Get the return type for a target reasoner.
3141
+
3142
+ Args:
3143
+ target: Target in format 'node_id.reasoner_name'
3144
+
3145
+ Returns:
3146
+ The return type class if found, None otherwise
3147
+ """
3148
+ function_name = target.split(".", 1)[-1] if "." in target else target
3149
+
3150
+ # Prefer the dedicated mapping populated during decorator registration
3151
+ return_type_map = getattr(self, "_reasoner_return_types", None)
3152
+ if return_type_map:
3153
+ return_type = return_type_map.get(function_name)
3154
+ if return_type is not None:
3155
+ return return_type
3156
+
3157
+ # Fallback for legacy metadata that may still include return_type directly
3158
+ for reasoner in self.reasoners:
3159
+ if reasoner.get("id") == function_name:
3160
+ stored_type = reasoner.get("return_type")
3161
+ if stored_type is not None:
3162
+ return stored_type
3163
+
3164
+ return None
3165
+
3166
+ def _convert_response_to_schema(self, response_data: Any, return_type: Type) -> Any:
3167
+ """
3168
+ Convert JSON response data back to the original Pydantic schema.
3169
+
3170
+ Args:
3171
+ response_data: The JSON response data (usually a dict)
3172
+ return_type: The target return type to convert to
3173
+
3174
+ Returns:
3175
+ The converted response in the original schema format
3176
+ """
3177
+ try:
3178
+ # Import here to avoid circular imports
3179
+ from pydantic import BaseModel
3180
+
3181
+ # If return_type is a Pydantic model, convert the dict to the model
3182
+ if (
3183
+ isinstance(return_type, type)
3184
+ and issubclass(return_type, BaseModel)
3185
+ and isinstance(response_data, dict)
3186
+ ):
3187
+ return return_type(**response_data)
3188
+
3189
+ # If it's not a Pydantic model or not a dict, return as-is
3190
+ return response_data
3191
+
3192
+ except Exception as e:
3193
+ # If conversion fails, log the error and return the original data
3194
+ if self.dev_mode:
3195
+ log_error(f"Schema conversion failed for {return_type}: {e}")
3196
+ log_debug(f"Schema conversion response data: {response_data}")
3197
+ return response_data
3198
+
3199
+ @classmethod
3200
+ def get_current(cls) -> Optional["Agent"]:
3201
+ """
3202
+ Get the current agent instance.
3203
+
3204
+ This method is used by auto-generated MCP skills to access the current
3205
+ agent's execution context. It uses a thread-local storage pattern to
3206
+ track the current agent instance.
3207
+
3208
+ Returns:
3209
+ Current Agent instance or None if no agent is active
3210
+ """
3211
+ # For now, we'll use a simple class variable approach
3212
+ # In a more complex implementation, this could use thread-local storage
3213
+ return getattr(cls, "_current_agent", None)
3214
+
3215
+ def _set_as_current(self) -> None:
3216
+ """Set this agent as the current agent instance."""
3217
+ Agent._current_agent = self
3218
+ set_current_agent(self)
3219
+
3220
+ def _clear_current(self) -> None:
3221
+ """Clear the current agent instance."""
3222
+ if hasattr(Agent, "_current_agent"):
3223
+ delattr(Agent, "_current_agent")
3224
+ # Also clear from thread-local storage
3225
+ clear_current_agent()
3226
+
3227
+ def _emit_workflow_event_sync(
3228
+ self,
3229
+ context: ExecutionContext,
3230
+ component_id: str,
3231
+ status: str,
3232
+ *,
3233
+ input_data: Optional[Dict[str, Any]] = None,
3234
+ result: Optional[Any] = None,
3235
+ error: Optional[str] = None,
3236
+ duration_ms: Optional[int] = None,
3237
+ parent_execution_id: Optional[str] = None,
3238
+ ) -> None:
3239
+ """Best-effort synchronous workflow event emitter for local skill calls."""
3240
+
3241
+ if not self.agentfield_server:
3242
+ return
3243
+
3244
+ try:
3245
+ import requests
3246
+ except ImportError:
3247
+ if self.dev_mode:
3248
+ log_warn(
3249
+ "requests library unavailable, skipping workflow event emission"
3250
+ )
3251
+ return
3252
+
3253
+ payload: Dict[str, Any] = {
3254
+ "execution_id": context.execution_id,
3255
+ "workflow_id": context.workflow_id,
3256
+ "run_id": context.run_id,
3257
+ "reasoner_id": component_id,
3258
+ "type": component_id,
3259
+ "agent_node_id": self.node_id,
3260
+ "status": status,
3261
+ "parent_execution_id": parent_execution_id,
3262
+ "parent_workflow_id": context.parent_workflow_id or context.workflow_id,
3263
+ }
3264
+
3265
+ if input_data is not None:
3266
+ payload["input_data"] = jsonable_encoder(input_data)
3267
+ if result is not None:
3268
+ payload["result"] = jsonable_encoder(result)
3269
+ if error is not None:
3270
+ payload["error"] = error
3271
+ if duration_ms is not None:
3272
+ payload["duration_ms"] = duration_ms
3273
+
3274
+ url = self.agentfield_server.rstrip("/") + "/api/v1/workflow/executions/events"
3275
+ try:
3276
+ headers = {"Content-Type": "application/json"}
3277
+ if self.api_key:
3278
+ headers["X-API-Key"] = self.api_key
3279
+ response = requests.post(url, json=payload, headers=headers, timeout=5)
3280
+ if response.status_code >= 400 and self.dev_mode:
3281
+ log_warn(
3282
+ f"Workflow event ({status}) for {component_id} failed: {response.status_code} {response.text}"
3283
+ )
3284
+ except Exception as exc:
3285
+ if self.dev_mode:
3286
+ log_warn(f"Failed to emit workflow event for {component_id}: {exc}")
3287
+
3288
+ def _setup_signal_handlers(
3289
+ self,
3290
+ ) -> None: # pragma: no cover - requires signal integration
3291
+ """Delegate to server handler for signal setup"""
3292
+ return self.server_handler.setup_signal_handlers()
3293
+
3294
+ def _signal_handler(
3295
+ self, signum: int, frame
3296
+ ) -> None: # pragma: no cover - runtime signal handling
3297
+ """Delegate to server handler for signal handling"""
3298
+ return self.server_handler.signal_handler(signum, frame)
3299
+
3300
+ def __del__(self) -> None: # pragma: no cover - destructor best effort
3301
+ """
3302
+ Destructor to ensure cleanup happens even if signals are missed.
3303
+
3304
+ This serves as a fallback cleanup mechanism.
3305
+ """
3306
+ try:
3307
+ # Cleanup async execution manager if it exists
3308
+ if (
3309
+ hasattr(self, "_async_execution_manager")
3310
+ and self._async_execution_manager
3311
+ ):
3312
+ try:
3313
+ # Try to cleanup async resources in a new event loop
3314
+ import asyncio
3315
+
3316
+ asyncio.run(self._cleanup_async_resources())
3317
+ except Exception:
3318
+ # Ignore async cleanup errors in destructor
3319
+ pass
3320
+
3321
+ # Only attempt cleanup if we have an MCP handler
3322
+ if hasattr(self, "mcp_handler") and self.mcp_handler:
3323
+ self.mcp_handler._cleanup_mcp_servers()
3324
+ # Clear agent from thread-local storage as final cleanup
3325
+ clear_current_agent()
3326
+ except Exception:
3327
+ # Ignore errors in destructor to prevent warnings during garbage collection
3328
+ pass
3329
+
3330
+ def discover(
3331
+ self,
3332
+ agent: Optional[str] = None,
3333
+ node_id: Optional[str] = None,
3334
+ agent_ids: Optional[List[str]] = None,
3335
+ node_ids: Optional[List[str]] = None,
3336
+ reasoner: Optional[str] = None,
3337
+ skill: Optional[str] = None,
3338
+ tags: Optional[List[str]] = None,
3339
+ include_input_schema: bool = False,
3340
+ include_output_schema: bool = False,
3341
+ include_descriptions: bool = True,
3342
+ include_examples: bool = False,
3343
+ format: str = "json",
3344
+ health_status: Optional[str] = None,
3345
+ limit: Optional[int] = None,
3346
+ offset: Optional[int] = None,
3347
+ ) -> "DiscoveryResult":
3348
+ """
3349
+ Discover available agent capabilities from the control plane.
3350
+ """
3351
+
3352
+ if not self.client:
3353
+ raise RuntimeError("AgentField client is not configured")
3354
+
3355
+ return self.client.discover_capabilities(
3356
+ agent=agent,
3357
+ node_id=node_id,
3358
+ agent_ids=agent_ids,
3359
+ node_ids=node_ids,
3360
+ reasoner=reasoner,
3361
+ skill=skill,
3362
+ tags=tags,
3363
+ include_input_schema=include_input_schema,
3364
+ include_output_schema=include_output_schema,
3365
+ include_descriptions=include_descriptions,
3366
+ include_examples=include_examples,
3367
+ format=format,
3368
+ health_status=health_status,
3369
+ limit=limit,
3370
+ offset=offset,
3371
+ )
3372
+
3373
+ def run(self, **serve_kwargs):
3374
+ """
3375
+ Universal entry point - auto-detects CLI vs server mode.
3376
+
3377
+ This method intelligently determines whether to run in CLI mode or server mode
3378
+ based on command-line arguments. It provides a seamless developer experience
3379
+ where the same code can be used for both interactive CLI usage and production
3380
+ server deployment.
3381
+
3382
+ CLI mode is activated when sys.argv contains commands like:
3383
+ - 'call': Execute a specific function
3384
+ - 'list': List all available functions
3385
+ - 'shell': Launch interactive IPython shell
3386
+ - 'help': Show help for a specific function
3387
+
3388
+ Server mode is activated otherwise, starting the FastAPI server.
3389
+
3390
+ Args:
3391
+ **serve_kwargs: Keyword arguments passed to serve() method in server mode.
3392
+ Common options include:
3393
+ - port: Server port (default: auto-detected)
3394
+ - host: Server host (default: "0.0.0.0")
3395
+ - dev: Enable development mode (default: False)
3396
+ - auto_port: Auto-find available port (default: False)
3397
+
3398
+ Example:
3399
+ ```python
3400
+ from agentfield import Agent
3401
+
3402
+ app = Agent(node_id="my_agent")
3403
+
3404
+ @app.reasoner()
3405
+ async def analyze(text: str) -> dict:
3406
+ return {"result": text.upper()}
3407
+
3408
+ @app.skill()
3409
+ def get_status() -> dict:
3410
+ return {"status": "active"}
3411
+
3412
+ if __name__ == "__main__":
3413
+ # Single entry point for both CLI and server
3414
+ app.run()
3415
+
3416
+ # CLI usage:
3417
+ # python main.py list
3418
+ # python main.py call analyze --text "hello world"
3419
+ # python main.py shell
3420
+ # python main.py help analyze
3421
+
3422
+ # Server usage:
3423
+ # python main.py
3424
+ # python main.py --port 8080 --dev
3425
+ ```
3426
+
3427
+ Note:
3428
+ - CLI mode runs functions directly without starting a server
3429
+ - Server mode starts the FastAPI server for production use
3430
+ - The mode is automatically detected from command-line arguments
3431
+ - No code changes needed to switch between modes
3432
+ """
3433
+ import sys
3434
+
3435
+ # Check if CLI mode is requested
3436
+ if len(sys.argv) > 1 and sys.argv[1] in ["call", "list", "shell", "help"]:
3437
+ # Run in CLI mode
3438
+ self.cli_handler.run_cli()
3439
+ else:
3440
+ # Run in server mode
3441
+ self.serve(**serve_kwargs)
3442
+
3443
+ def serve( # pragma: no cover - requires full server runtime integration
3444
+ self,
3445
+ port: Optional[int] = None,
3446
+ host: str = "0.0.0.0",
3447
+ dev: bool = False,
3448
+ heartbeat_interval: int = 2,
3449
+ auto_port: bool = False,
3450
+ **kwargs,
3451
+ ):
3452
+ """
3453
+ Start the agent node server with intelligent port management and AgentField integration.
3454
+
3455
+ This method launches the agent as a FastAPI server that can receive reasoner and skill
3456
+ requests from other agents via the AgentField execution gateway. It handles automatic
3457
+ registration with the AgentField server, heartbeat management, and graceful shutdown.
3458
+
3459
+ The server provides:
3460
+ - RESTful endpoints for all registered reasoners and skills
3461
+ - Health check endpoints for monitoring
3462
+ - MCP server status and management endpoints
3463
+ - Automatic AgentField server registration and heartbeat
3464
+ - Graceful shutdown with proper cleanup
3465
+
3466
+ Args:
3467
+ port (int, optional): The port on which the agent server will listen.
3468
+ If None, uses the port from agent configuration or auto-discovers.
3469
+ Common ports: 8000, 8001, 8080, etc.
3470
+ host (str): The host address for the agent server. Defaults to "0.0.0.0".
3471
+ Use "127.0.0.1" for localhost-only access.
3472
+ dev (bool): If True, enables development mode features including:
3473
+ - Enhanced logging and debug output
3474
+ - Auto-reload on code changes (if supported)
3475
+ - Detailed error messages
3476
+ - MCP server debugging information
3477
+ heartbeat_interval (int): The interval in seconds for sending heartbeats to the AgentField server.
3478
+ Defaults to 2 seconds. Lower values provide faster failure detection
3479
+ but increase network overhead.
3480
+ auto_port (bool): If True, automatically find an available port starting from the
3481
+ specified port (or default). Useful for development environments
3482
+ where multiple agents may be running.
3483
+ **kwargs: Additional keyword arguments to pass to `uvicorn.run`, such as:
3484
+ - reload: Enable auto-reload on code changes
3485
+ - workers: Number of worker processes
3486
+ - log_level: Logging level ("debug", "info", "warning", "error")
3487
+ - ssl_keyfile: Path to SSL key file for HTTPS
3488
+ - ssl_certfile: Path to SSL certificate file for HTTPS
3489
+
3490
+ Example:
3491
+ ```python
3492
+ # Basic agent server
3493
+ app = Agent("my_agent")
3494
+
3495
+ @app.reasoner()
3496
+ async def process_data(data: str) -> dict:
3497
+ '''Process incoming data and return results.'''
3498
+ return {"processed": data.upper(), "length": len(data)}
3499
+
3500
+ @app.skill()
3501
+ def get_status() -> dict:
3502
+ '''Get current agent status.'''
3503
+ return {"status": "active", "timestamp": datetime.now().isoformat()}
3504
+
3505
+ # Start server on default port
3506
+ app.serve()
3507
+
3508
+ # Start server with custom configuration
3509
+ app.serve(
3510
+ port=8080,
3511
+ host="127.0.0.1",
3512
+ dev=True,
3513
+ heartbeat_interval=5,
3514
+ auto_port=True,
3515
+ reload=True,
3516
+ log_level="debug"
3517
+ )
3518
+
3519
+ # Production server with SSL
3520
+ app.serve(
3521
+ port=443,
3522
+ host="0.0.0.0",
3523
+ ssl_keyfile="/path/to/key.pem",
3524
+ ssl_certfile="/path/to/cert.pem",
3525
+ workers=4
3526
+ )
3527
+ ```
3528
+
3529
+ Server Endpoints:
3530
+ Once running, the agent exposes these endpoints:
3531
+ - `POST /reasoners/{reasoner_name}`: Execute reasoner functions
3532
+ - `POST /skills/{skill_name}`: Execute skill functions
3533
+ - `GET /health`: Health check endpoint
3534
+ - `GET /mcp/status`: MCP server status and management
3535
+ - `GET /docs`: Interactive API documentation (Swagger UI)
3536
+ - `GET /redoc`: Alternative API documentation
3537
+
3538
+ Integration with AgentField:
3539
+ - Automatically registers with AgentField server on startup
3540
+ - Sends periodic heartbeats to maintain connection
3541
+ - Receives execution requests via AgentField's routing system
3542
+ - Participates in workflow tracking and DAG building
3543
+ - Handles cross-agent communication seamlessly
3544
+
3545
+ Lifecycle:
3546
+ 1. Server initialization and route setup
3547
+ 2. MCP server startup (if configured)
3548
+ 3. AgentField server registration
3549
+ 4. Heartbeat loop starts
3550
+ 5. Ready to receive requests
3551
+ 6. Graceful shutdown on SIGINT/SIGTERM
3552
+ 7. MCP server cleanup
3553
+ 8. AgentField server deregistration
3554
+
3555
+ Note:
3556
+ - The server runs indefinitely until interrupted (Ctrl+C)
3557
+ - All registered reasoners and skills become available as REST endpoints
3558
+ - Memory and execution context are automatically managed
3559
+ - MCP servers are started and managed automatically
3560
+ - Use `dev=True` for development, `dev=False` for production
3561
+ """
3562
+ return self.server_handler.serve(
3563
+ port=port,
3564
+ host=host,
3565
+ dev=dev,
3566
+ heartbeat_interval=heartbeat_interval,
3567
+ auto_port=auto_port,
3568
+ **kwargs,
3569
+ )