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.
- agentfield/__init__.py +66 -0
- agentfield/agent.py +3569 -0
- agentfield/agent_ai.py +1125 -0
- agentfield/agent_cli.py +386 -0
- agentfield/agent_field_handler.py +494 -0
- agentfield/agent_mcp.py +534 -0
- agentfield/agent_registry.py +29 -0
- agentfield/agent_server.py +1185 -0
- agentfield/agent_utils.py +269 -0
- agentfield/agent_workflow.py +323 -0
- agentfield/async_config.py +278 -0
- agentfield/async_execution_manager.py +1227 -0
- agentfield/client.py +1447 -0
- agentfield/connection_manager.py +280 -0
- agentfield/decorators.py +527 -0
- agentfield/did_manager.py +337 -0
- agentfield/dynamic_skills.py +304 -0
- agentfield/execution_context.py +255 -0
- agentfield/execution_state.py +453 -0
- agentfield/http_connection_manager.py +429 -0
- agentfield/litellm_adapters.py +140 -0
- agentfield/logger.py +249 -0
- agentfield/mcp_client.py +204 -0
- agentfield/mcp_manager.py +340 -0
- agentfield/mcp_stdio_bridge.py +550 -0
- agentfield/memory.py +723 -0
- agentfield/memory_events.py +489 -0
- agentfield/multimodal.py +173 -0
- agentfield/multimodal_response.py +403 -0
- agentfield/pydantic_utils.py +227 -0
- agentfield/rate_limiter.py +280 -0
- agentfield/result_cache.py +441 -0
- agentfield/router.py +190 -0
- agentfield/status.py +70 -0
- agentfield/types.py +710 -0
- agentfield/utils.py +26 -0
- agentfield/vc_generator.py +464 -0
- agentfield/vision.py +198 -0
- agentfield-0.1.22rc2.dist-info/METADATA +102 -0
- agentfield-0.1.22rc2.dist-info/RECORD +42 -0
- agentfield-0.1.22rc2.dist-info/WHEEL +5 -0
- 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
|
+
)
|