webagents 0.1.12__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.
- webagents/__init__.py +18 -0
- webagents/agents/__init__.py +13 -0
- webagents/agents/core/__init__.py +19 -0
- webagents/agents/core/base_agent.py +1834 -0
- webagents/agents/core/handoffs.py +293 -0
- webagents/agents/handoffs/__init__.py +0 -0
- webagents/agents/interfaces/__init__.py +0 -0
- webagents/agents/lifecycle/__init__.py +0 -0
- webagents/agents/skills/__init__.py +109 -0
- webagents/agents/skills/base.py +136 -0
- webagents/agents/skills/core/__init__.py +8 -0
- webagents/agents/skills/core/guardrails/__init__.py +0 -0
- webagents/agents/skills/core/llm/__init__.py +0 -0
- webagents/agents/skills/core/llm/anthropic/__init__.py +1 -0
- webagents/agents/skills/core/llm/litellm/__init__.py +10 -0
- webagents/agents/skills/core/llm/litellm/skill.py +538 -0
- webagents/agents/skills/core/llm/openai/__init__.py +1 -0
- webagents/agents/skills/core/llm/xai/__init__.py +1 -0
- webagents/agents/skills/core/mcp/README.md +375 -0
- webagents/agents/skills/core/mcp/__init__.py +15 -0
- webagents/agents/skills/core/mcp/skill.py +731 -0
- webagents/agents/skills/core/memory/__init__.py +11 -0
- webagents/agents/skills/core/memory/long_term_memory/__init__.py +10 -0
- webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +639 -0
- webagents/agents/skills/core/memory/short_term_memory/__init__.py +9 -0
- webagents/agents/skills/core/memory/short_term_memory/skill.py +341 -0
- webagents/agents/skills/core/memory/vector_memory/skill.py +447 -0
- webagents/agents/skills/core/planning/__init__.py +9 -0
- webagents/agents/skills/core/planning/planner.py +343 -0
- webagents/agents/skills/ecosystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/crewai/__init__.py +1 -0
- webagents/agents/skills/ecosystem/database/__init__.py +1 -0
- webagents/agents/skills/ecosystem/filesystem/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/__init__.py +0 -0
- webagents/agents/skills/ecosystem/google/calendar/__init__.py +6 -0
- webagents/agents/skills/ecosystem/google/calendar/skill.py +306 -0
- webagents/agents/skills/ecosystem/n8n/__init__.py +0 -0
- webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
- webagents/agents/skills/ecosystem/web/__init__.py +0 -0
- webagents/agents/skills/ecosystem/zapier/__init__.py +0 -0
- webagents/agents/skills/robutler/__init__.py +11 -0
- webagents/agents/skills/robutler/auth/README.md +63 -0
- webagents/agents/skills/robutler/auth/__init__.py +17 -0
- webagents/agents/skills/robutler/auth/skill.py +354 -0
- webagents/agents/skills/robutler/crm/__init__.py +18 -0
- webagents/agents/skills/robutler/crm/skill.py +368 -0
- webagents/agents/skills/robutler/discovery/README.md +281 -0
- webagents/agents/skills/robutler/discovery/__init__.py +16 -0
- webagents/agents/skills/robutler/discovery/skill.py +230 -0
- webagents/agents/skills/robutler/kv/__init__.py +6 -0
- webagents/agents/skills/robutler/kv/skill.py +80 -0
- webagents/agents/skills/robutler/message_history/__init__.py +9 -0
- webagents/agents/skills/robutler/message_history/skill.py +270 -0
- webagents/agents/skills/robutler/messages/__init__.py +0 -0
- webagents/agents/skills/robutler/nli/__init__.py +13 -0
- webagents/agents/skills/robutler/nli/skill.py +687 -0
- webagents/agents/skills/robutler/notifications/__init__.py +5 -0
- webagents/agents/skills/robutler/notifications/skill.py +141 -0
- webagents/agents/skills/robutler/payments/__init__.py +41 -0
- webagents/agents/skills/robutler/payments/exceptions.py +255 -0
- webagents/agents/skills/robutler/payments/skill.py +610 -0
- webagents/agents/skills/robutler/storage/__init__.py +10 -0
- webagents/agents/skills/robutler/storage/files/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/files/skill.py +445 -0
- webagents/agents/skills/robutler/storage/json/__init__.py +9 -0
- webagents/agents/skills/robutler/storage/json/skill.py +336 -0
- webagents/agents/skills/robutler/storage/kv/skill.py +88 -0
- webagents/agents/skills/robutler/storage.py +389 -0
- webagents/agents/tools/__init__.py +0 -0
- webagents/agents/tools/decorators.py +426 -0
- webagents/agents/tracing/__init__.py +0 -0
- webagents/agents/workflows/__init__.py +0 -0
- webagents/api/__init__.py +17 -0
- webagents/api/client.py +1207 -0
- webagents/api/types.py +253 -0
- webagents/scripts/__init__.py +0 -0
- webagents/server/__init__.py +28 -0
- webagents/server/context/__init__.py +0 -0
- webagents/server/context/context_vars.py +121 -0
- webagents/server/core/__init__.py +0 -0
- webagents/server/core/app.py +843 -0
- webagents/server/core/middleware.py +69 -0
- webagents/server/core/models.py +98 -0
- webagents/server/core/monitoring.py +59 -0
- webagents/server/endpoints/__init__.py +0 -0
- webagents/server/interfaces/__init__.py +0 -0
- webagents/server/middleware.py +330 -0
- webagents/server/models.py +92 -0
- webagents/server/monitoring.py +659 -0
- webagents/utils/__init__.py +0 -0
- webagents/utils/logging.py +359 -0
- webagents-0.1.12.dist-info/METADATA +99 -0
- webagents-0.1.12.dist-info/RECORD +96 -0
- webagents-0.1.12.dist-info/WHEEL +4 -0
- webagents-0.1.12.dist-info/entry_points.txt +2 -0
- webagents-0.1.12.dist-info/licenses/LICENSE +1 -0
@@ -0,0 +1,687 @@
|
|
1
|
+
"""
|
2
|
+
NLISkill - Natural Language Interface for Agent-to-Agent Communication
|
3
|
+
|
4
|
+
Enables Robutler agents to communicate with other agents via natural language.
|
5
|
+
Provides HTTP-based communication with authorization limits and error handling.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import os
|
9
|
+
import json
|
10
|
+
import re
|
11
|
+
import asyncio
|
12
|
+
from typing import Dict, Any, List, Optional, Union
|
13
|
+
from dataclasses import dataclass
|
14
|
+
from datetime import datetime, timedelta
|
15
|
+
from urllib.parse import urljoin, urlparse
|
16
|
+
|
17
|
+
try:
|
18
|
+
import httpx
|
19
|
+
HTTPX_AVAILABLE = True
|
20
|
+
except ImportError:
|
21
|
+
HTTPX_AVAILABLE = False
|
22
|
+
httpx = None
|
23
|
+
|
24
|
+
from webagents.agents.skills.base import Skill
|
25
|
+
from webagents.agents.tools.decorators import tool, hook, prompt
|
26
|
+
from webagents.utils.logging import get_logger, log_skill_event, log_tool_execution, timer
|
27
|
+
|
28
|
+
|
29
|
+
@dataclass
|
30
|
+
class NLICommunication:
|
31
|
+
"""Record of an NLI communication"""
|
32
|
+
timestamp: datetime
|
33
|
+
target_agent_url: str
|
34
|
+
message: str
|
35
|
+
response: str
|
36
|
+
cost_usd: float
|
37
|
+
duration_ms: float
|
38
|
+
success: bool
|
39
|
+
error: Optional[str] = None
|
40
|
+
|
41
|
+
|
42
|
+
@dataclass
|
43
|
+
class AgentEndpoint:
|
44
|
+
"""Agent endpoint configuration"""
|
45
|
+
url: str
|
46
|
+
name: Optional[str] = None
|
47
|
+
description: Optional[str] = None
|
48
|
+
capabilities: List[str] = None
|
49
|
+
last_contact: Optional[datetime] = None
|
50
|
+
success_rate: float = 1.0
|
51
|
+
|
52
|
+
def __post_init__(self):
|
53
|
+
if self.capabilities is None:
|
54
|
+
self.capabilities = []
|
55
|
+
|
56
|
+
|
57
|
+
class NLISkill(Skill):
|
58
|
+
"""
|
59
|
+
Natural Language Interface skill for agent-to-agent communication
|
60
|
+
|
61
|
+
Features:
|
62
|
+
- HTTP-based communication with other Robutler agents
|
63
|
+
- Authorization limits and cost tracking
|
64
|
+
- Communication history and success rate tracking
|
65
|
+
- Automatic timeout and retry handling
|
66
|
+
- Agent endpoint discovery and management
|
67
|
+
"""
|
68
|
+
|
69
|
+
def __init__(self, config: Dict[str, Any] = None):
|
70
|
+
super().__init__(config, scope="all")
|
71
|
+
|
72
|
+
# Configuration
|
73
|
+
self.config = config or {}
|
74
|
+
self.default_timeout = self.config.get('timeout', 600.0) # 10 minutes for long-running tasks
|
75
|
+
self.max_retries = self.config.get('max_retries', 2)
|
76
|
+
self.default_authorization = self.config.get('default_authorization', 0.10) # $0.10 default
|
77
|
+
self.max_authorization = self.config.get('max_authorization', 5.00) # $5.00 max per call
|
78
|
+
|
79
|
+
# Agent communication base URL configuration
|
80
|
+
self.agent_base_url = (
|
81
|
+
os.getenv('AGENTS_BASE_URL') or
|
82
|
+
self.config.get('agent_base_url') or
|
83
|
+
'http://localhost:2224' # Default for local development (agents server)
|
84
|
+
)
|
85
|
+
|
86
|
+
# Communication tracking
|
87
|
+
self.communication_history: List[NLICommunication] = []
|
88
|
+
self.known_agents: Dict[str, AgentEndpoint] = {}
|
89
|
+
|
90
|
+
# HTTP client (will be initialized in initialize method)
|
91
|
+
self.http_client: Optional[Any] = None
|
92
|
+
|
93
|
+
# Logging
|
94
|
+
self.logger = None
|
95
|
+
|
96
|
+
def get_agent_url(self, agent_name: str) -> str:
|
97
|
+
"""Convert agent name to full URL for communication"""
|
98
|
+
agent_name = agent_name.lstrip('@') # Remove @ prefix if present
|
99
|
+
base_url = self.agent_base_url.rstrip('/')
|
100
|
+
return f"{base_url}/agents/{agent_name}"
|
101
|
+
|
102
|
+
async def initialize(self, agent) -> None:
|
103
|
+
"""Initialize NLI skill with agent context"""
|
104
|
+
from webagents.utils.logging import get_logger, log_skill_event
|
105
|
+
|
106
|
+
self.agent = agent
|
107
|
+
self.logger = get_logger('skill.robutler.nli', agent.name)
|
108
|
+
|
109
|
+
# Initialize HTTP client for agent communication
|
110
|
+
if HTTPX_AVAILABLE:
|
111
|
+
self.http_client = httpx.AsyncClient(
|
112
|
+
timeout=httpx.Timeout(self.default_timeout),
|
113
|
+
follow_redirects=True,
|
114
|
+
limits=httpx.Limits(max_keepalive_connections=10, max_connections=50)
|
115
|
+
)
|
116
|
+
self.logger.info("NLI HTTP client initialized")
|
117
|
+
else:
|
118
|
+
self.logger.warning("httpx not available - NLI functionality will be limited")
|
119
|
+
|
120
|
+
# Load known agents from config
|
121
|
+
known_agents_config = self.config.get('known_agents', [])
|
122
|
+
for agent_config in known_agents_config:
|
123
|
+
self._register_agent_endpoint(
|
124
|
+
url=agent_config['url'],
|
125
|
+
name=agent_config.get('name'),
|
126
|
+
description=agent_config.get('description'),
|
127
|
+
capabilities=agent_config.get('capabilities', [])
|
128
|
+
)
|
129
|
+
|
130
|
+
log_skill_event(self.agent.name, 'nli', 'initialized', {
|
131
|
+
'default_timeout': self.default_timeout,
|
132
|
+
'max_retries': self.max_retries,
|
133
|
+
'default_authorization': self.default_authorization,
|
134
|
+
'known_agents': len(self.known_agents),
|
135
|
+
'httpx_available': HTTPX_AVAILABLE
|
136
|
+
})
|
137
|
+
|
138
|
+
def _extract_agent_name_or_id(self, agent_url: str) -> Dict[str, Optional[str]]:
|
139
|
+
"""Extract agent UUID or name from a URL like /agents/<name>/chat/completions.
|
140
|
+
Returns dict with either {'id': uuid} or {'name': name}."""
|
141
|
+
try:
|
142
|
+
uuid_re = r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
|
143
|
+
m = re.search(rf"/agents/({uuid_re})", agent_url)
|
144
|
+
if m:
|
145
|
+
return {"id": m.group(1), "name": None}
|
146
|
+
# Name before /chat/completions
|
147
|
+
from urllib.parse import urlparse
|
148
|
+
parsed = urlparse(agent_url)
|
149
|
+
parts = [p for p in parsed.path.split('/') if p]
|
150
|
+
if len(parts) >= 3 and parts[-1] == 'completions' and parts[-2] == 'chat':
|
151
|
+
return {"id": None, "name": parts[-3]}
|
152
|
+
except Exception:
|
153
|
+
pass
|
154
|
+
return {"id": None, "name": None}
|
155
|
+
|
156
|
+
async def _mint_owner_assertion(self, target_agent_id: str, acting_user_id: Optional[str]) -> Optional[str]:
|
157
|
+
"""Mint a short-lived owner assertion (RS256) via Portal API if possible.
|
158
|
+
Requires SERVICE_TOKEN and a platform base URL; returns JWT or None on failure.
|
159
|
+
"""
|
160
|
+
if not target_agent_id:
|
161
|
+
return None
|
162
|
+
portal_base_url = os.getenv('ROBUTLER_INTERNAL_API_URL') or os.getenv('ROBUTLER_API_URL') or 'http://localhost:3000'
|
163
|
+
service_token = os.getenv('SERVICE_TOKEN') or os.getenv('ROBUTLER_API_KEY')
|
164
|
+
if not service_token:
|
165
|
+
return None
|
166
|
+
# Acting user id is strongly recommended for correct scoping
|
167
|
+
origin_user_id = acting_user_id
|
168
|
+
try:
|
169
|
+
if not HTTPX_AVAILABLE:
|
170
|
+
return None
|
171
|
+
async with httpx.AsyncClient(timeout=10.0) as client:
|
172
|
+
payload: Dict[str, Any] = {"agentId": target_agent_id, "ttlSeconds": 180}
|
173
|
+
if origin_user_id:
|
174
|
+
payload["originUserId"] = origin_user_id
|
175
|
+
resp = await client.post(
|
176
|
+
f"{portal_base_url.rstrip('/')}/api/auth/owner-assertion",
|
177
|
+
headers={
|
178
|
+
"Authorization": f"Bearer {service_token}",
|
179
|
+
"Content-Type": "application/json",
|
180
|
+
},
|
181
|
+
json=payload,
|
182
|
+
)
|
183
|
+
if resp.status_code != 200:
|
184
|
+
return None
|
185
|
+
data = resp.json()
|
186
|
+
assertion = data.get('assertion')
|
187
|
+
return assertion if isinstance(assertion, str) else None
|
188
|
+
except Exception:
|
189
|
+
return None
|
190
|
+
|
191
|
+
async def cleanup(self):
|
192
|
+
"""Cleanup NLI resources"""
|
193
|
+
if self.http_client:
|
194
|
+
await self.http_client.aclose()
|
195
|
+
self.http_client = None
|
196
|
+
|
197
|
+
def _register_agent_endpoint(self, url: str, name: str = None, description: str = None,
|
198
|
+
capabilities: List[str] = None) -> str:
|
199
|
+
"""Register a known agent endpoint"""
|
200
|
+
parsed = urlparse(url)
|
201
|
+
endpoint_key = f"{parsed.netloc}{parsed.path}"
|
202
|
+
|
203
|
+
self.known_agents[endpoint_key] = AgentEndpoint(
|
204
|
+
url=url,
|
205
|
+
name=name or f"Agent at {parsed.netloc}",
|
206
|
+
description=description,
|
207
|
+
capabilities=capabilities or []
|
208
|
+
)
|
209
|
+
|
210
|
+
return endpoint_key
|
211
|
+
|
212
|
+
def _update_agent_stats(self, agent_url: str, success: bool, duration_ms: float):
|
213
|
+
"""Update agent endpoint statistics"""
|
214
|
+
parsed = urlparse(agent_url)
|
215
|
+
endpoint_key = f"{parsed.netloc}{parsed.path}"
|
216
|
+
|
217
|
+
if endpoint_key in self.known_agents:
|
218
|
+
agent = self.known_agents[endpoint_key]
|
219
|
+
agent.last_contact = datetime.utcnow()
|
220
|
+
|
221
|
+
# Update success rate (exponential moving average)
|
222
|
+
alpha = 0.1 # Learning rate
|
223
|
+
if success:
|
224
|
+
agent.success_rate = agent.success_rate * (1 - alpha) + 1.0 * alpha
|
225
|
+
else:
|
226
|
+
agent.success_rate = agent.success_rate * (1 - alpha) + 0.0 * alpha
|
227
|
+
else:
|
228
|
+
# Register new agent endpoint
|
229
|
+
self._register_agent_endpoint(agent_url)
|
230
|
+
|
231
|
+
|
232
|
+
@prompt(priority=20, scope="all")
|
233
|
+
def nli_general_prompt(self, context: Any = None) -> str:
|
234
|
+
base_url = self.agent_base_url.rstrip('/')
|
235
|
+
return f"""You are part of a network of AI agents working for their owners. Each agent has their own name and address.
|
236
|
+
|
237
|
+
CRITICAL: When you need to communicate with another agent:
|
238
|
+
1. Convert @agentname to the full URL: {base_url}/agents/agentname
|
239
|
+
2. Use the nli_tool to send your message to that URL
|
240
|
+
3. DO NOT just store or retrieve agent names - actually communicate with them
|
241
|
+
|
242
|
+
"""
|
243
|
+
|
244
|
+
@tool(description="Communicate with other Robutler agents via natural language", scope="all")
|
245
|
+
async def nli_tool(self,
|
246
|
+
agent_url: str,
|
247
|
+
message: str,
|
248
|
+
authorized_amount: float = None,
|
249
|
+
timeout: float = None,
|
250
|
+
context=None) -> str:
|
251
|
+
"""
|
252
|
+
Natural Language Interface to communicate with other Robutler agents.
|
253
|
+
|
254
|
+
Use this tool to send natural language messages to other agents and receive their responses.
|
255
|
+
This enables agent-to-agent collaboration, delegation, and information sharing.
|
256
|
+
|
257
|
+
Args:
|
258
|
+
agent_url: Full URL of the target agent (e.g., "http://localhost:8001/agent-name")
|
259
|
+
message: Natural language message to send to the agent
|
260
|
+
authorized_amount: Maximum cost authorization in USD (default: $0.10, max: $5.00)
|
261
|
+
timeout: Request timeout in seconds (default: 30.0)
|
262
|
+
context: Request context for tracking and billing
|
263
|
+
|
264
|
+
Returns:
|
265
|
+
Response message from the target agent, or error description if failed
|
266
|
+
|
267
|
+
Examples:
|
268
|
+
- nli_tool("http://localhost:8001/coding-assistant", "Can you help me debug this Python code?")
|
269
|
+
- nli_tool("http://localhost:8002/data-analyst", "Please analyze this sales data", authorized_amount=0.50)
|
270
|
+
"""
|
271
|
+
start_time = datetime.utcnow()
|
272
|
+
|
273
|
+
# Validate and normalize parameters
|
274
|
+
if authorized_amount is None:
|
275
|
+
authorized_amount = self.default_authorization
|
276
|
+
|
277
|
+
if authorized_amount > self.max_authorization:
|
278
|
+
return f"❌ Authorized amount ${authorized_amount:.2f} exceeds maximum allowed ${self.max_authorization:.2f}"
|
279
|
+
|
280
|
+
if timeout is None:
|
281
|
+
timeout = self.default_timeout
|
282
|
+
|
283
|
+
if not HTTPX_AVAILABLE:
|
284
|
+
return "❌ HTTP client not available - install httpx to use NLI functionality"
|
285
|
+
|
286
|
+
if not self.http_client:
|
287
|
+
return "❌ NLI HTTP client not initialized"
|
288
|
+
|
289
|
+
# Prepare request payload
|
290
|
+
payload = {
|
291
|
+
"model": self.agent.name, # Identify requesting agent
|
292
|
+
"messages": [
|
293
|
+
{
|
294
|
+
"role": "user",
|
295
|
+
"content": message
|
296
|
+
}
|
297
|
+
],
|
298
|
+
"stream": False,
|
299
|
+
"temperature": 0.7,
|
300
|
+
"max_tokens": 2048
|
301
|
+
}
|
302
|
+
|
303
|
+
# Add authorization headers
|
304
|
+
headers = {
|
305
|
+
"Content-Type": "application/json",
|
306
|
+
"User-Agent": f"Robutler-NLI/{self.agent.name}",
|
307
|
+
"X-Authorization-Amount": str(authorized_amount),
|
308
|
+
"X-Origin-Agent": self.agent.name,
|
309
|
+
}
|
310
|
+
|
311
|
+
# Include Authorization if available (target agents commonly require it)
|
312
|
+
bearer = os.getenv('ROBUTLER_API_KEY') or os.getenv('SERVICE_TOKEN')
|
313
|
+
if bearer:
|
314
|
+
headers["Authorization"] = f"Bearer {bearer}"
|
315
|
+
headers["X-API-Key"] = bearer
|
316
|
+
|
317
|
+
# Try to include X-Owner-Assertion for agent-to-agent auth and forward payment token
|
318
|
+
try:
|
319
|
+
from webagents.server.context.context_vars import get_context as _gc
|
320
|
+
ctx = _gc()
|
321
|
+
acting_user_id: Optional[str] = getattr(getattr(ctx, 'auth', None), 'user_id', None) if ctx else None
|
322
|
+
|
323
|
+
# CRITICAL: Forward payment token from current context to enable agent-to-agent billing
|
324
|
+
payment_token = None
|
325
|
+
|
326
|
+
# Method 1: Check if payment_token is directly available in context
|
327
|
+
if ctx and hasattr(ctx, 'payment_token') and ctx.payment_token:
|
328
|
+
payment_token = ctx.payment_token
|
329
|
+
self.logger.debug(f"🔐 Found payment token in context.payment_token")
|
330
|
+
|
331
|
+
# Method 2: Extract from request headers (most common case)
|
332
|
+
elif ctx and hasattr(ctx, 'request') and ctx.request:
|
333
|
+
request_headers = getattr(ctx.request, 'headers', {})
|
334
|
+
if hasattr(request_headers, 'get'):
|
335
|
+
payment_token = (
|
336
|
+
request_headers.get('X-Payment-Token') or
|
337
|
+
request_headers.get('x-payment-token') or
|
338
|
+
request_headers.get('payment_token')
|
339
|
+
)
|
340
|
+
if payment_token:
|
341
|
+
self.logger.debug(f"🔐 Found payment token in request headers")
|
342
|
+
|
343
|
+
# Method 3: Check custom_data for payment context (fallback)
|
344
|
+
elif ctx and hasattr(ctx, 'custom_data') and ctx.custom_data:
|
345
|
+
payment_context = ctx.custom_data.get('payment_context')
|
346
|
+
if payment_context and hasattr(payment_context, 'payment_token'):
|
347
|
+
payment_token = payment_context.payment_token
|
348
|
+
self.logger.debug(f"🔐 Found payment token in custom_data.payment_context")
|
349
|
+
|
350
|
+
# Forward the payment token if found
|
351
|
+
if payment_token:
|
352
|
+
headers["X-Payment-Token"] = payment_token
|
353
|
+
self.logger.debug(f"🔐 Forwarding payment token for agent-to-agent communication: {payment_token[:20]}...")
|
354
|
+
else:
|
355
|
+
self.logger.debug(f"🔐 No payment token found to forward - target agent may require payment")
|
356
|
+
except Exception:
|
357
|
+
acting_user_id = None
|
358
|
+
|
359
|
+
# Resolve target agent id: UUID directly, else by name via portal
|
360
|
+
target = self._extract_agent_name_or_id(agent_url)
|
361
|
+
target_agent_id = target.get('id')
|
362
|
+
if not target_agent_id:
|
363
|
+
name_from_path = target.get('name')
|
364
|
+
if name_from_path and HTTPX_AVAILABLE:
|
365
|
+
portal_base_url = os.getenv('ROBUTLER_INTERNAL_API_URL') or os.getenv('ROBUTLER_API_URL') or 'http://localhost:3000'
|
366
|
+
bearer_lookup = os.getenv('ROBUTLER_API_KEY') or os.getenv('SERVICE_TOKEN')
|
367
|
+
try:
|
368
|
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
369
|
+
# 1) User/service /agents/:id-or-name (prefer service token if available)
|
370
|
+
if bearer_lookup:
|
371
|
+
rA = await client.get(
|
372
|
+
f"{portal_base_url.rstrip('/')}/api/agents/{name_from_path}",
|
373
|
+
headers={"Authorization": f"Bearer {bearer_lookup}"}
|
374
|
+
)
|
375
|
+
if rA.status_code == 200:
|
376
|
+
dA = rA.json()
|
377
|
+
target_agent_id = dA.get('agent', {}).get('id') or dA.get('id')
|
378
|
+
# 2) Public endpoint
|
379
|
+
if not target_agent_id:
|
380
|
+
rP = await client.get(
|
381
|
+
f"{portal_base_url.rstrip('/')}/api/agents/public/{name_from_path}"
|
382
|
+
)
|
383
|
+
if rP.status_code == 200:
|
384
|
+
dP = rP.json()
|
385
|
+
target_agent_id = dP.get('agent', {}).get('id') or dP.get('id')
|
386
|
+
# 3) By-name endpoint
|
387
|
+
if not target_agent_id:
|
388
|
+
headers = {"Authorization": f"Bearer {bearer_lookup}"} if bearer_lookup else None
|
389
|
+
rB = await client.get(
|
390
|
+
f"{portal_base_url.rstrip('/')}/api/agents/by-name/{name_from_path}",
|
391
|
+
headers=headers
|
392
|
+
)
|
393
|
+
if rB.status_code == 200:
|
394
|
+
dB = rB.json()
|
395
|
+
target_agent_id = dB.get('agent', {}).get('id') or dB.get('id')
|
396
|
+
except Exception:
|
397
|
+
pass
|
398
|
+
|
399
|
+
if target_agent_id:
|
400
|
+
assertion = await self._mint_owner_assertion(target_agent_id, acting_user_id)
|
401
|
+
if assertion:
|
402
|
+
headers["X-Owner-Assertion"] = assertion
|
403
|
+
headers["x-owner-assertion"] = assertion
|
404
|
+
|
405
|
+
# Ensure URL has correct format for chat completions
|
406
|
+
# Handle relative URLs by converting them to full URLs
|
407
|
+
if agent_url.startswith('/'):
|
408
|
+
# Convert relative URL to full URL
|
409
|
+
# Use the local agents server base URL
|
410
|
+
base_url = os.getenv('AGENTS_BASE_URL', 'http://localhost:2224')
|
411
|
+
agent_url = f"{base_url}{agent_url}"
|
412
|
+
self.logger.debug(f"Converted relative URL to full URL: {agent_url}")
|
413
|
+
|
414
|
+
parsed_url = urlparse(agent_url)
|
415
|
+
if not parsed_url.path.endswith('/chat/completions'):
|
416
|
+
if parsed_url.path.endswith('/'):
|
417
|
+
agent_url = agent_url + 'chat/completions'
|
418
|
+
else:
|
419
|
+
agent_url = agent_url + '/chat/completions'
|
420
|
+
|
421
|
+
communication = None
|
422
|
+
try:
|
423
|
+
self.logger.info(f"🔗 Sending NLI message to {agent_url}")
|
424
|
+
|
425
|
+
# Send request with retry logic
|
426
|
+
last_error = None
|
427
|
+
for attempt in range(self.max_retries + 1):
|
428
|
+
try:
|
429
|
+
response = await self.http_client.post(
|
430
|
+
agent_url,
|
431
|
+
json=payload,
|
432
|
+
headers=headers,
|
433
|
+
timeout=timeout
|
434
|
+
)
|
435
|
+
|
436
|
+
# Calculate duration
|
437
|
+
duration_ms = (datetime.utcnow() - start_time).total_seconds() * 1000
|
438
|
+
|
439
|
+
if response.status_code == 200:
|
440
|
+
response_data = response.json()
|
441
|
+
|
442
|
+
# Extract message content from OpenAI-compatible response
|
443
|
+
if 'choices' in response_data and len(response_data['choices']) > 0:
|
444
|
+
choice = response_data['choices'][0]
|
445
|
+
if 'message' in choice and 'content' in choice['message']:
|
446
|
+
agent_response = choice['message']['content']
|
447
|
+
else:
|
448
|
+
agent_response = str(response_data)
|
449
|
+
else:
|
450
|
+
agent_response = str(response_data)
|
451
|
+
|
452
|
+
# Track successful communication
|
453
|
+
communication = NLICommunication(
|
454
|
+
timestamp=start_time,
|
455
|
+
target_agent_url=agent_url,
|
456
|
+
message=message,
|
457
|
+
response=agent_response,
|
458
|
+
cost_usd=authorized_amount, # Assume full authorization used for now
|
459
|
+
duration_ms=duration_ms,
|
460
|
+
success=True
|
461
|
+
)
|
462
|
+
|
463
|
+
self._update_agent_stats(agent_url, True, duration_ms)
|
464
|
+
self.communication_history.append(communication)
|
465
|
+
try:
|
466
|
+
log_tool_execution(self.agent.name, 'nli_tool', int(duration_ms), success=True)
|
467
|
+
except Exception:
|
468
|
+
pass
|
469
|
+
|
470
|
+
self.logger.info(f"✅ NLI communication successful ({duration_ms:.0f}ms)")
|
471
|
+
|
472
|
+
return agent_response
|
473
|
+
|
474
|
+
else:
|
475
|
+
last_error = f"HTTP {response.status_code}: {response.text}"
|
476
|
+
self.logger.warning(f"❌ NLI attempt {attempt + 1} failed: {last_error}")
|
477
|
+
|
478
|
+
# Don't retry on client errors (4xx)
|
479
|
+
if 400 <= response.status_code < 500:
|
480
|
+
break
|
481
|
+
|
482
|
+
# Wait before retry (exponential backoff)
|
483
|
+
if attempt < self.max_retries:
|
484
|
+
wait_time = 2 ** attempt
|
485
|
+
await asyncio.sleep(wait_time)
|
486
|
+
|
487
|
+
except httpx.TimeoutException as e:
|
488
|
+
last_error = f"Request timeout after {timeout}s"
|
489
|
+
self.logger.warning(f"⏱️ NLI attempt {attempt + 1} timed out")
|
490
|
+
|
491
|
+
if attempt < self.max_retries:
|
492
|
+
await asyncio.sleep(2 ** attempt)
|
493
|
+
|
494
|
+
except Exception as e:
|
495
|
+
last_error = f"Request failed: {str(e)}"
|
496
|
+
self.logger.warning(f"❌ NLI attempt {attempt + 1} error: {last_error}")
|
497
|
+
|
498
|
+
if attempt < self.max_retries:
|
499
|
+
await asyncio.sleep(2 ** attempt)
|
500
|
+
|
501
|
+
# All retries failed
|
502
|
+
duration_ms = (datetime.utcnow() - start_time).total_seconds() * 1000
|
503
|
+
|
504
|
+
communication = NLICommunication(
|
505
|
+
timestamp=start_time,
|
506
|
+
target_agent_url=agent_url,
|
507
|
+
message=message,
|
508
|
+
response="",
|
509
|
+
cost_usd=0.0, # No cost if failed
|
510
|
+
duration_ms=duration_ms,
|
511
|
+
success=False,
|
512
|
+
error=last_error
|
513
|
+
)
|
514
|
+
|
515
|
+
self._update_agent_stats(agent_url, False, duration_ms)
|
516
|
+
self.communication_history.append(communication)
|
517
|
+
try:
|
518
|
+
log_tool_execution(self.agent.name, 'nli_tool', int(duration_ms), success=False)
|
519
|
+
except Exception:
|
520
|
+
pass
|
521
|
+
|
522
|
+
self.logger.error(f"❌ NLI communication failed after {self.max_retries + 1} attempts: {last_error}")
|
523
|
+
|
524
|
+
return f"❌ Failed to communicate with agent at {agent_url}: {last_error}"
|
525
|
+
|
526
|
+
except Exception as e:
|
527
|
+
duration_ms = (datetime.utcnow() - start_time).total_seconds() * 1000
|
528
|
+
error_msg = f"Unexpected error: {str(e)}"
|
529
|
+
|
530
|
+
communication = NLICommunication(
|
531
|
+
timestamp=start_time,
|
532
|
+
target_agent_url=agent_url,
|
533
|
+
message=message,
|
534
|
+
response="",
|
535
|
+
cost_usd=0.0,
|
536
|
+
duration_ms=duration_ms,
|
537
|
+
success=False,
|
538
|
+
error=error_msg
|
539
|
+
)
|
540
|
+
|
541
|
+
self.communication_history.append(communication)
|
542
|
+
self.logger.error(f"❌ NLI communication exception: {error_msg}")
|
543
|
+
try:
|
544
|
+
log_tool_execution(self.agent.name, 'nli_tool', int(duration_ms), success=False)
|
545
|
+
except Exception:
|
546
|
+
pass
|
547
|
+
|
548
|
+
return f"❌ Communication error: {error_msg}"
|
549
|
+
|
550
|
+
# @tool(description="List known agent endpoints and their statistics", scope="owner")
|
551
|
+
async def list_known_agents(self, context=None) -> str:
|
552
|
+
"""
|
553
|
+
List all known agent endpoints with their communication statistics.
|
554
|
+
|
555
|
+
Returns:
|
556
|
+
Formatted list of known agents with success rates and last contact times
|
557
|
+
"""
|
558
|
+
if not self.known_agents:
|
559
|
+
return "📝 No known agent endpoints registered"
|
560
|
+
|
561
|
+
result = ["📋 Known Agent Endpoints:\n"]
|
562
|
+
|
563
|
+
for endpoint_key, agent in self.known_agents.items():
|
564
|
+
last_contact = agent.last_contact.strftime("%Y-%m-%d %H:%M:%S") if agent.last_contact else "Never"
|
565
|
+
capabilities = ", ".join(agent.capabilities) if agent.capabilities else "Unknown"
|
566
|
+
|
567
|
+
result.append(f"🤖 **{agent.name}**")
|
568
|
+
result.append(f" URL: {agent.url}")
|
569
|
+
result.append(f" Success Rate: {agent.success_rate:.1%}")
|
570
|
+
result.append(f" Last Contact: {last_contact}")
|
571
|
+
result.append(f" Capabilities: {capabilities}")
|
572
|
+
if agent.description:
|
573
|
+
result.append(f" Description: {agent.description}")
|
574
|
+
result.append("")
|
575
|
+
|
576
|
+
return "\n".join(result)
|
577
|
+
|
578
|
+
# @tool(description="Show recent NLI communication history", scope="owner")
|
579
|
+
async def show_communication_history(self, limit: int = 10, context=None) -> str:
|
580
|
+
"""
|
581
|
+
Show recent NLI communication history with other agents.
|
582
|
+
|
583
|
+
Args:
|
584
|
+
limit: Maximum number of recent communications to show (default: 10)
|
585
|
+
|
586
|
+
Returns:
|
587
|
+
Formatted communication history
|
588
|
+
"""
|
589
|
+
if not self.communication_history:
|
590
|
+
return "📝 No NLI communications recorded"
|
591
|
+
|
592
|
+
recent_communications = self.communication_history[-limit:]
|
593
|
+
result = [f"📈 Recent NLI Communications (last {len(recent_communications)}):\n"]
|
594
|
+
|
595
|
+
for i, comm in enumerate(reversed(recent_communications), 1):
|
596
|
+
status = "✅" if comm.success else "❌"
|
597
|
+
timestamp = comm.timestamp.strftime("%H:%M:%S")
|
598
|
+
duration = f"{comm.duration_ms:.0f}ms"
|
599
|
+
cost = f"${comm.cost_usd:.3f}" if comm.cost_usd > 0 else "Free"
|
600
|
+
|
601
|
+
result.append(f"{i}. {status} [{timestamp}] {comm.target_agent_url} ({duration}, {cost})")
|
602
|
+
result.append(f" Message: {comm.message[:60]}{'...' if len(comm.message) > 60 else ''}")
|
603
|
+
|
604
|
+
if comm.success:
|
605
|
+
response_preview = comm.response[:80].replace('\n', ' ')
|
606
|
+
result.append(f" Response: {response_preview}{'...' if len(comm.response) > 80 else ''}")
|
607
|
+
else:
|
608
|
+
result.append(f" Error: {comm.error}")
|
609
|
+
|
610
|
+
result.append("")
|
611
|
+
|
612
|
+
# Add summary statistics
|
613
|
+
total_comms = len(self.communication_history)
|
614
|
+
successful_comms = sum(1 for c in self.communication_history if c.success)
|
615
|
+
success_rate = successful_comms / total_comms if total_comms > 0 else 0
|
616
|
+
total_cost = sum(c.cost_usd for c in self.communication_history)
|
617
|
+
|
618
|
+
result.extend([
|
619
|
+
f"📊 **Summary Statistics:**",
|
620
|
+
f" Total Communications: {total_comms}",
|
621
|
+
f" Success Rate: {success_rate:.1%}",
|
622
|
+
f" Total Cost: ${total_cost:.3f}"
|
623
|
+
])
|
624
|
+
|
625
|
+
return "\n".join(result)
|
626
|
+
|
627
|
+
# @tool(description="Register a new agent endpoint for future communication", scope="owner")
|
628
|
+
async def register_agent(self,
|
629
|
+
agent_url: str,
|
630
|
+
name: str = None,
|
631
|
+
description: str = None,
|
632
|
+
capabilities: str = None,
|
633
|
+
context=None) -> str:
|
634
|
+
"""
|
635
|
+
Register a new agent endpoint for future NLI communications.
|
636
|
+
|
637
|
+
Args:
|
638
|
+
agent_url: Full URL of the agent endpoint
|
639
|
+
name: Friendly name for the agent (optional)
|
640
|
+
description: Description of the agent's purpose (optional)
|
641
|
+
capabilities: Comma-separated list of agent capabilities (optional)
|
642
|
+
|
643
|
+
Returns:
|
644
|
+
Confirmation of agent registration
|
645
|
+
"""
|
646
|
+
try:
|
647
|
+
# Parse capabilities string
|
648
|
+
caps_list = []
|
649
|
+
if capabilities:
|
650
|
+
caps_list = [cap.strip() for cap in capabilities.split(',') if cap.strip()]
|
651
|
+
|
652
|
+
endpoint_key = self._register_agent_endpoint(
|
653
|
+
url=agent_url,
|
654
|
+
name=name,
|
655
|
+
description=description,
|
656
|
+
capabilities=caps_list
|
657
|
+
)
|
658
|
+
|
659
|
+
agent = self.known_agents[endpoint_key]
|
660
|
+
|
661
|
+
self.logger.info(f"📝 Registered agent endpoint: {agent.name} at {agent_url}")
|
662
|
+
|
663
|
+
return f"✅ Registered agent: {agent.name}\n" + \
|
664
|
+
f" URL: {agent.url}\n" + \
|
665
|
+
f" Capabilities: {', '.join(agent.capabilities) if agent.capabilities else 'None specified'}"
|
666
|
+
|
667
|
+
except Exception as e:
|
668
|
+
error_msg = f"Failed to register agent endpoint: {str(e)}"
|
669
|
+
self.logger.error(f"❌ {error_msg}")
|
670
|
+
return f"❌ {error_msg}"
|
671
|
+
|
672
|
+
def get_statistics(self) -> Dict[str, Any]:
|
673
|
+
"""Get NLI communication statistics"""
|
674
|
+
total_comms = len(self.communication_history)
|
675
|
+
successful_comms = sum(1 for c in self.communication_history if c.success)
|
676
|
+
total_cost = sum(c.cost_usd for c in self.communication_history)
|
677
|
+
avg_duration = sum(c.duration_ms for c in self.communication_history) / total_comms if total_comms > 0 else 0
|
678
|
+
|
679
|
+
return {
|
680
|
+
'total_communications': total_comms,
|
681
|
+
'successful_communications': successful_comms,
|
682
|
+
'success_rate': successful_comms / total_comms if total_comms > 0 else 0,
|
683
|
+
'total_cost_usd': total_cost,
|
684
|
+
'average_duration_ms': avg_duration,
|
685
|
+
'known_agents': len(self.known_agents),
|
686
|
+
'httpx_available': HTTPX_AVAILABLE
|
687
|
+
}
|