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.
Files changed (96) hide show
  1. webagents/__init__.py +18 -0
  2. webagents/agents/__init__.py +13 -0
  3. webagents/agents/core/__init__.py +19 -0
  4. webagents/agents/core/base_agent.py +1834 -0
  5. webagents/agents/core/handoffs.py +293 -0
  6. webagents/agents/handoffs/__init__.py +0 -0
  7. webagents/agents/interfaces/__init__.py +0 -0
  8. webagents/agents/lifecycle/__init__.py +0 -0
  9. webagents/agents/skills/__init__.py +109 -0
  10. webagents/agents/skills/base.py +136 -0
  11. webagents/agents/skills/core/__init__.py +8 -0
  12. webagents/agents/skills/core/guardrails/__init__.py +0 -0
  13. webagents/agents/skills/core/llm/__init__.py +0 -0
  14. webagents/agents/skills/core/llm/anthropic/__init__.py +1 -0
  15. webagents/agents/skills/core/llm/litellm/__init__.py +10 -0
  16. webagents/agents/skills/core/llm/litellm/skill.py +538 -0
  17. webagents/agents/skills/core/llm/openai/__init__.py +1 -0
  18. webagents/agents/skills/core/llm/xai/__init__.py +1 -0
  19. webagents/agents/skills/core/mcp/README.md +375 -0
  20. webagents/agents/skills/core/mcp/__init__.py +15 -0
  21. webagents/agents/skills/core/mcp/skill.py +731 -0
  22. webagents/agents/skills/core/memory/__init__.py +11 -0
  23. webagents/agents/skills/core/memory/long_term_memory/__init__.py +10 -0
  24. webagents/agents/skills/core/memory/long_term_memory/memory_skill.py +639 -0
  25. webagents/agents/skills/core/memory/short_term_memory/__init__.py +9 -0
  26. webagents/agents/skills/core/memory/short_term_memory/skill.py +341 -0
  27. webagents/agents/skills/core/memory/vector_memory/skill.py +447 -0
  28. webagents/agents/skills/core/planning/__init__.py +9 -0
  29. webagents/agents/skills/core/planning/planner.py +343 -0
  30. webagents/agents/skills/ecosystem/__init__.py +0 -0
  31. webagents/agents/skills/ecosystem/crewai/__init__.py +1 -0
  32. webagents/agents/skills/ecosystem/database/__init__.py +1 -0
  33. webagents/agents/skills/ecosystem/filesystem/__init__.py +0 -0
  34. webagents/agents/skills/ecosystem/google/__init__.py +0 -0
  35. webagents/agents/skills/ecosystem/google/calendar/__init__.py +6 -0
  36. webagents/agents/skills/ecosystem/google/calendar/skill.py +306 -0
  37. webagents/agents/skills/ecosystem/n8n/__init__.py +0 -0
  38. webagents/agents/skills/ecosystem/openai_agents/__init__.py +0 -0
  39. webagents/agents/skills/ecosystem/web/__init__.py +0 -0
  40. webagents/agents/skills/ecosystem/zapier/__init__.py +0 -0
  41. webagents/agents/skills/robutler/__init__.py +11 -0
  42. webagents/agents/skills/robutler/auth/README.md +63 -0
  43. webagents/agents/skills/robutler/auth/__init__.py +17 -0
  44. webagents/agents/skills/robutler/auth/skill.py +354 -0
  45. webagents/agents/skills/robutler/crm/__init__.py +18 -0
  46. webagents/agents/skills/robutler/crm/skill.py +368 -0
  47. webagents/agents/skills/robutler/discovery/README.md +281 -0
  48. webagents/agents/skills/robutler/discovery/__init__.py +16 -0
  49. webagents/agents/skills/robutler/discovery/skill.py +230 -0
  50. webagents/agents/skills/robutler/kv/__init__.py +6 -0
  51. webagents/agents/skills/robutler/kv/skill.py +80 -0
  52. webagents/agents/skills/robutler/message_history/__init__.py +9 -0
  53. webagents/agents/skills/robutler/message_history/skill.py +270 -0
  54. webagents/agents/skills/robutler/messages/__init__.py +0 -0
  55. webagents/agents/skills/robutler/nli/__init__.py +13 -0
  56. webagents/agents/skills/robutler/nli/skill.py +687 -0
  57. webagents/agents/skills/robutler/notifications/__init__.py +5 -0
  58. webagents/agents/skills/robutler/notifications/skill.py +141 -0
  59. webagents/agents/skills/robutler/payments/__init__.py +41 -0
  60. webagents/agents/skills/robutler/payments/exceptions.py +255 -0
  61. webagents/agents/skills/robutler/payments/skill.py +610 -0
  62. webagents/agents/skills/robutler/storage/__init__.py +10 -0
  63. webagents/agents/skills/robutler/storage/files/__init__.py +9 -0
  64. webagents/agents/skills/robutler/storage/files/skill.py +445 -0
  65. webagents/agents/skills/robutler/storage/json/__init__.py +9 -0
  66. webagents/agents/skills/robutler/storage/json/skill.py +336 -0
  67. webagents/agents/skills/robutler/storage/kv/skill.py +88 -0
  68. webagents/agents/skills/robutler/storage.py +389 -0
  69. webagents/agents/tools/__init__.py +0 -0
  70. webagents/agents/tools/decorators.py +426 -0
  71. webagents/agents/tracing/__init__.py +0 -0
  72. webagents/agents/workflows/__init__.py +0 -0
  73. webagents/api/__init__.py +17 -0
  74. webagents/api/client.py +1207 -0
  75. webagents/api/types.py +253 -0
  76. webagents/scripts/__init__.py +0 -0
  77. webagents/server/__init__.py +28 -0
  78. webagents/server/context/__init__.py +0 -0
  79. webagents/server/context/context_vars.py +121 -0
  80. webagents/server/core/__init__.py +0 -0
  81. webagents/server/core/app.py +843 -0
  82. webagents/server/core/middleware.py +69 -0
  83. webagents/server/core/models.py +98 -0
  84. webagents/server/core/monitoring.py +59 -0
  85. webagents/server/endpoints/__init__.py +0 -0
  86. webagents/server/interfaces/__init__.py +0 -0
  87. webagents/server/middleware.py +330 -0
  88. webagents/server/models.py +92 -0
  89. webagents/server/monitoring.py +659 -0
  90. webagents/utils/__init__.py +0 -0
  91. webagents/utils/logging.py +359 -0
  92. webagents-0.1.12.dist-info/METADATA +99 -0
  93. webagents-0.1.12.dist-info/RECORD +96 -0
  94. webagents-0.1.12.dist-info/WHEEL +4 -0
  95. webagents-0.1.12.dist-info/entry_points.txt +2 -0
  96. 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
+ }