webagents 0.1.0__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/__main__.py +55 -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/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.0.dist-info/METADATA +230 -0
- webagents-0.1.0.dist-info/RECORD +94 -0
- webagents-0.1.0.dist-info/WHEEL +4 -0
- webagents-0.1.0.dist-info/entry_points.txt +2 -0
- webagents-0.1.0.dist-info/licenses/LICENSE +20 -0
@@ -0,0 +1,843 @@
|
|
1
|
+
"""
|
2
|
+
FastAPI Server - WebAgents V2.0
|
3
|
+
|
4
|
+
Production FastAPI server with OpenAI compatibility, dynamic agent routing,
|
5
|
+
and comprehensive monitoring.
|
6
|
+
"""
|
7
|
+
|
8
|
+
import asyncio
|
9
|
+
import time
|
10
|
+
import uuid
|
11
|
+
from datetime import datetime
|
12
|
+
from typing import List, Dict, Any, Optional, Callable, Union, Awaitable
|
13
|
+
import inspect
|
14
|
+
|
15
|
+
import uvicorn
|
16
|
+
from fastapi import FastAPI, HTTPException, Request, Response, APIRouter
|
17
|
+
from fastapi.middleware.cors import CORSMiddleware
|
18
|
+
from ..monitoring import CONTENT_TYPE_LATEST
|
19
|
+
|
20
|
+
from .models import (
|
21
|
+
ChatCompletionRequest, ChatCompletionResponse, AgentInfoResponse,
|
22
|
+
HealthResponse, AgentListResponse, ServerStatsResponse
|
23
|
+
)
|
24
|
+
from .middleware import RequestLoggingMiddleware, RateLimitMiddleware, RateLimitRule
|
25
|
+
from .monitoring import initialize_monitoring
|
26
|
+
from ..context.context_vars import Context, set_context, create_context
|
27
|
+
from ...agents.core.base_agent import BaseAgent
|
28
|
+
from ...utils.logging import get_logger
|
29
|
+
|
30
|
+
|
31
|
+
class WebAgentsServer:
|
32
|
+
"""
|
33
|
+
FastAPI server for AI agents with OpenAI compatibility and production monitoring
|
34
|
+
|
35
|
+
Features:
|
36
|
+
- OpenAI-compatible /chat/completions endpoint
|
37
|
+
- Streaming and non-streaming support
|
38
|
+
- Dynamic agent routing via provided resolver function
|
39
|
+
- Context management middleware
|
40
|
+
- Health and discovery endpoints
|
41
|
+
- Prometheus metrics collection
|
42
|
+
- Structured logging and request tracing
|
43
|
+
"""
|
44
|
+
|
45
|
+
def __init__(
|
46
|
+
self,
|
47
|
+
agents: List[BaseAgent] = None,
|
48
|
+
dynamic_agents: Optional[Union[Callable[[str], BaseAgent], Callable[[str], Awaitable[Optional[BaseAgent]]]]] = None,
|
49
|
+
enable_cors: bool = True,
|
50
|
+
title: str = "WebAgents V2 Server",
|
51
|
+
description: str = "AI Agent Server with OpenAI Compatibility",
|
52
|
+
version: str = "2.0.0",
|
53
|
+
url_prefix: str = "",
|
54
|
+
# Middleware configuration
|
55
|
+
request_timeout: float = 300.0,
|
56
|
+
enable_rate_limiting: bool = True,
|
57
|
+
default_rate_limit: RateLimitRule = None,
|
58
|
+
user_rate_limits: Dict[str, RateLimitRule] = None,
|
59
|
+
enable_request_logging: bool = True,
|
60
|
+
# Monitoring configuration
|
61
|
+
enable_monitoring: bool = True,
|
62
|
+
enable_prometheus: bool = True,
|
63
|
+
enable_structured_logging: bool = True,
|
64
|
+
metrics_port: int = 9090
|
65
|
+
):
|
66
|
+
"""
|
67
|
+
Initialize WebAgents server
|
68
|
+
|
69
|
+
Args:
|
70
|
+
agents: List of static Agent instances (optional)
|
71
|
+
dynamic_agents: Optional function (sync or async) that takes agent_name: str and returns
|
72
|
+
BaseAgent or Optional[BaseAgent]. Server does not manage how this works internally.
|
73
|
+
enable_cors: Whether to enable CORS middleware
|
74
|
+
title: FastAPI app title
|
75
|
+
description: FastAPI app description
|
76
|
+
version: Server version
|
77
|
+
url_prefix: URL prefix for all routes (e.g., "/agents" makes all routes "/agents/...")
|
78
|
+
request_timeout: Request timeout in seconds (default: 300.0)
|
79
|
+
enable_rate_limiting: Whether to enable rate limiting (default: True)
|
80
|
+
default_rate_limit: Default rate limit rule for all clients
|
81
|
+
user_rate_limits: Per-user rate limit overrides
|
82
|
+
enable_request_logging: Whether to enable request logging (default: True)
|
83
|
+
enable_monitoring: Whether to enable monitoring system (default: True)
|
84
|
+
enable_prometheus: Whether to enable Prometheus metrics (default: True)
|
85
|
+
enable_structured_logging: Whether to enable structured logging (default: True)
|
86
|
+
metrics_port: Port for Prometheus metrics endpoint (default: 9090)
|
87
|
+
"""
|
88
|
+
self.app = FastAPI(
|
89
|
+
title=title,
|
90
|
+
description=description,
|
91
|
+
version=version
|
92
|
+
)
|
93
|
+
|
94
|
+
self.version = version
|
95
|
+
self.url_prefix = url_prefix.rstrip("/") # Remove trailing slash if present
|
96
|
+
|
97
|
+
# Create API router with prefix
|
98
|
+
self.router = APIRouter(prefix=self.url_prefix)
|
99
|
+
|
100
|
+
# Store agents by name for quick lookup
|
101
|
+
self.static_agents = {agent.name: agent for agent in (agents or [])}
|
102
|
+
|
103
|
+
# Store dynamic agent resolver (server doesn't manage how it works)
|
104
|
+
self.dynamic_agents = dynamic_agents
|
105
|
+
|
106
|
+
# Store middleware configuration
|
107
|
+
self.request_timeout = request_timeout
|
108
|
+
self.enable_rate_limiting = enable_rate_limiting
|
109
|
+
self.default_rate_limit = default_rate_limit or RateLimitRule()
|
110
|
+
self.user_rate_limits = user_rate_limits or {}
|
111
|
+
self.enable_request_logging = enable_request_logging
|
112
|
+
|
113
|
+
# Initialize monitoring system
|
114
|
+
self.enable_monitoring = enable_monitoring
|
115
|
+
if enable_monitoring:
|
116
|
+
self.monitoring = initialize_monitoring(
|
117
|
+
enable_prometheus=enable_prometheus,
|
118
|
+
enable_structured_logging=enable_structured_logging,
|
119
|
+
metrics_port=metrics_port
|
120
|
+
)
|
121
|
+
|
122
|
+
# Set server info in metrics
|
123
|
+
self.monitoring.prometheus.set_server_info(
|
124
|
+
version=version,
|
125
|
+
agents_count=len(self.static_agents),
|
126
|
+
dynamic_agents_enabled=str(self.dynamic_agents is not None),
|
127
|
+
prometheus_enabled=str(enable_prometheus),
|
128
|
+
structured_logging_enabled=str(enable_structured_logging)
|
129
|
+
)
|
130
|
+
else:
|
131
|
+
self.monitoring = None
|
132
|
+
|
133
|
+
# Server startup time
|
134
|
+
self.startup_time = datetime.utcnow()
|
135
|
+
|
136
|
+
# Initialize logger
|
137
|
+
self.logger = get_logger('server.core.app')
|
138
|
+
|
139
|
+
# Initialize middleware and endpoints
|
140
|
+
self._setup_middleware()
|
141
|
+
self._create_endpoints()
|
142
|
+
|
143
|
+
print(f"🚀 WebAgents V2 Server initialized")
|
144
|
+
print(f" URL prefix: {self.url_prefix or '(none)'}")
|
145
|
+
print(f" Static agents: {len(self.static_agents)}")
|
146
|
+
print(f" Dynamic agents: {'✅ Enabled' if self.dynamic_agents else '❌ Disabled'}")
|
147
|
+
print(f" Monitoring: {'✅ Enabled' if self.monitoring else '❌ Disabled'}")
|
148
|
+
|
149
|
+
def _setup_middleware(self):
|
150
|
+
"""Set up FastAPI middleware"""
|
151
|
+
|
152
|
+
# CORS middleware
|
153
|
+
self.app.add_middleware(
|
154
|
+
CORSMiddleware,
|
155
|
+
allow_origins=["*"],
|
156
|
+
allow_credentials=True,
|
157
|
+
allow_methods=["*"],
|
158
|
+
allow_headers=["*"],
|
159
|
+
)
|
160
|
+
|
161
|
+
# Request timeout and logging middleware
|
162
|
+
if self.enable_request_logging:
|
163
|
+
self.app.add_middleware(
|
164
|
+
RequestLoggingMiddleware,
|
165
|
+
timeout=self.request_timeout
|
166
|
+
)
|
167
|
+
|
168
|
+
# Rate limiting middleware
|
169
|
+
if self.enable_rate_limiting:
|
170
|
+
self.app.add_middleware(
|
171
|
+
RateLimitMiddleware,
|
172
|
+
default_rule=self.default_rate_limit,
|
173
|
+
user_rules=self.user_rate_limits
|
174
|
+
)
|
175
|
+
|
176
|
+
def _create_endpoints(self):
|
177
|
+
"""Create all FastAPI endpoints"""
|
178
|
+
|
179
|
+
# Health check endpoint
|
180
|
+
@self.router.get("/health", response_model=HealthResponse)
|
181
|
+
async def health_check():
|
182
|
+
"""Health check endpoint"""
|
183
|
+
uptime_seconds = (datetime.utcnow() - self.startup_time).total_seconds()
|
184
|
+
|
185
|
+
return HealthResponse(
|
186
|
+
status="healthy",
|
187
|
+
version=self.version,
|
188
|
+
uptime_seconds=uptime_seconds,
|
189
|
+
agents_count=len(self.static_agents),
|
190
|
+
dynamic_agents_enabled=self.dynamic_agents is not None
|
191
|
+
)
|
192
|
+
|
193
|
+
# Server info endpoint
|
194
|
+
@self.router.get("/info")
|
195
|
+
async def server_info():
|
196
|
+
"""Get server information"""
|
197
|
+
uptime_seconds = (datetime.utcnow() - self.startup_time).total_seconds()
|
198
|
+
|
199
|
+
# Build endpoints with prefix
|
200
|
+
endpoints = {
|
201
|
+
"health": f"{self.url_prefix}/health",
|
202
|
+
"info": f"{self.url_prefix}/info",
|
203
|
+
"stats": f"{self.url_prefix}/stats"
|
204
|
+
}
|
205
|
+
|
206
|
+
if self.monitoring and self.monitoring.enable_prometheus:
|
207
|
+
endpoints["metrics"] = f"{self.url_prefix}/metrics"
|
208
|
+
|
209
|
+
return {
|
210
|
+
"name": "WebAgents V2 Server",
|
211
|
+
"version": self.version,
|
212
|
+
"status": "running",
|
213
|
+
"uptime_seconds": uptime_seconds,
|
214
|
+
"static_agents_count": len(self.static_agents),
|
215
|
+
"dynamic_agents_enabled": self.dynamic_agents is not None,
|
216
|
+
"monitoring_enabled": self.monitoring is not None,
|
217
|
+
"endpoints": endpoints
|
218
|
+
}
|
219
|
+
|
220
|
+
# Server stats endpoint
|
221
|
+
@self.router.get("/stats")
|
222
|
+
async def server_stats():
|
223
|
+
"""Get comprehensive server statistics"""
|
224
|
+
uptime_seconds = (datetime.utcnow() - self.startup_time).total_seconds()
|
225
|
+
|
226
|
+
stats = {
|
227
|
+
"server": {
|
228
|
+
"name": "WebAgents V2 Server",
|
229
|
+
"version": self.version,
|
230
|
+
"uptime_seconds": uptime_seconds,
|
231
|
+
"startup_time": self.startup_time.isoformat()
|
232
|
+
},
|
233
|
+
"agents": {
|
234
|
+
"static_count": len(self.static_agents),
|
235
|
+
"static_names": list(self.static_agents.keys())
|
236
|
+
},
|
237
|
+
"dynamic_agents": {
|
238
|
+
"enabled": self.dynamic_agents is not None
|
239
|
+
}
|
240
|
+
}
|
241
|
+
|
242
|
+
# Add monitoring performance stats
|
243
|
+
if self.monitoring:
|
244
|
+
stats["performance"] = self.monitoring.get_performance_stats()
|
245
|
+
|
246
|
+
# Update system metrics
|
247
|
+
self.monitoring.update_system_metrics(
|
248
|
+
active_agents=len(self.static_agents),
|
249
|
+
dynamic_cache_size=0 # Server doesn't know about caching
|
250
|
+
)
|
251
|
+
|
252
|
+
return stats
|
253
|
+
|
254
|
+
# Agents listing endpoint
|
255
|
+
@self.router.get("/agents")
|
256
|
+
async def list_agents():
|
257
|
+
"""List all available agents (static and dynamic)"""
|
258
|
+
agents_list = []
|
259
|
+
|
260
|
+
# Add static agents
|
261
|
+
for agent_name, agent in self.static_agents.items():
|
262
|
+
agents_list.append({
|
263
|
+
"name": agent_name,
|
264
|
+
"type": "static",
|
265
|
+
"instructions": agent.instructions,
|
266
|
+
"scopes": agent.scopes,
|
267
|
+
"tools_count": len(agent.get_tools_for_scope("all")),
|
268
|
+
"http_handlers_count": len(agent.get_all_http_handlers()),
|
269
|
+
"status": "active"
|
270
|
+
})
|
271
|
+
|
272
|
+
# Add dynamic agents info if available
|
273
|
+
dynamic_agents_available = []
|
274
|
+
if self.dynamic_agents:
|
275
|
+
try:
|
276
|
+
# Try to get available dynamic agents
|
277
|
+
# Note: This depends on dynamic agent implementation
|
278
|
+
dynamic_agents_available = [] # Placeholder - would need dynamic agent resolver to list
|
279
|
+
except Exception:
|
280
|
+
pass
|
281
|
+
|
282
|
+
return {
|
283
|
+
"agents": agents_list,
|
284
|
+
"static_count": len(self.static_agents),
|
285
|
+
"dynamic_count": len(dynamic_agents_available),
|
286
|
+
"total_count": len(agents_list) + len(dynamic_agents_available)
|
287
|
+
}
|
288
|
+
|
289
|
+
# Prometheus metrics endpoint
|
290
|
+
if self.monitoring and self.monitoring.enable_prometheus:
|
291
|
+
@self.router.get("/metrics")
|
292
|
+
async def prometheus_metrics():
|
293
|
+
"""Prometheus metrics endpoint"""
|
294
|
+
metrics_data = self.monitoring.get_metrics_response()
|
295
|
+
return Response(
|
296
|
+
content=metrics_data,
|
297
|
+
media_type=CONTENT_TYPE_LATEST
|
298
|
+
)
|
299
|
+
|
300
|
+
# Static agent endpoints
|
301
|
+
for agent_name in self.static_agents.keys():
|
302
|
+
self._create_agent_endpoints(agent_name, is_dynamic=False)
|
303
|
+
|
304
|
+
# Dynamic agent endpoints (if resolver available)
|
305
|
+
if self.dynamic_agents:
|
306
|
+
@self.router.get("/{agent_name}", response_model=AgentInfoResponse)
|
307
|
+
async def dynamic_agent_info(agent_name: str):
|
308
|
+
return await self._handle_agent_info(agent_name, is_dynamic=True)
|
309
|
+
|
310
|
+
@self.router.post("/{agent_name}/chat/completions")
|
311
|
+
async def dynamic_chat_completion(agent_name: str, request: ChatCompletionRequest, raw_request: Request = None):
|
312
|
+
return await self._handle_chat_completion(agent_name, request, raw_request, is_dynamic=True)
|
313
|
+
|
314
|
+
@self.router.get("/{agent_name}/health")
|
315
|
+
async def dynamic_agent_health(agent_name: str):
|
316
|
+
"""Dynamic agent health check"""
|
317
|
+
try:
|
318
|
+
agent = await self._resolve_agent(agent_name, is_dynamic=True)
|
319
|
+
return {
|
320
|
+
"agent_name": agent.name,
|
321
|
+
"status": "healthy",
|
322
|
+
"type": "dynamic_agent",
|
323
|
+
"instructions_preview": agent.instructions[:100] + "..." if len(agent.instructions) > 100 else agent.instructions
|
324
|
+
}
|
325
|
+
except HTTPException:
|
326
|
+
raise
|
327
|
+
except Exception as e:
|
328
|
+
raise HTTPException(status_code=500, detail=f"Agent health check failed: {str(e)}")
|
329
|
+
|
330
|
+
# Generic dynamic HTTP handler dispatcher for @http handlers on dynamic agents.
|
331
|
+
# This allows dynamic agents to expose custom HTTP endpoints without static registration.
|
332
|
+
@self.router.api_route("/{agent_name}/{request_path:path}", methods=[
|
333
|
+
"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"
|
334
|
+
])
|
335
|
+
async def dynamic_agent_http_dispatch(
|
336
|
+
agent_name: str,
|
337
|
+
request_path: str,
|
338
|
+
request: Request
|
339
|
+
):
|
340
|
+
import re
|
341
|
+
import inspect as _inspect
|
342
|
+
import asyncio as _asyncio
|
343
|
+
|
344
|
+
# Avoid handling reserved built-in endpoints; let their specific routes match first
|
345
|
+
reserved_suffixes = {
|
346
|
+
"chat/completions", "health", "", "info", "metrics", "stats", "agents"
|
347
|
+
}
|
348
|
+
if request_path in reserved_suffixes:
|
349
|
+
raise HTTPException(status_code=404, detail="Not found")
|
350
|
+
|
351
|
+
# Resolve the dynamic agent
|
352
|
+
agent = await self._resolve_agent(agent_name, is_dynamic=True)
|
353
|
+
# Ensure skills are initialized so skill methods have agent context
|
354
|
+
try:
|
355
|
+
if hasattr(agent, '_ensure_skills_initialized'):
|
356
|
+
await agent._ensure_skills_initialized()
|
357
|
+
except Exception as e:
|
358
|
+
raise HTTPException(status_code=500, detail=f"Failed to initialize agent skills: {e}")
|
359
|
+
|
360
|
+
# Iterate agent's http handlers and find a matching subpath and method
|
361
|
+
try:
|
362
|
+
http_handlers = agent.get_all_http_handlers()
|
363
|
+
except Exception as e:
|
364
|
+
raise HTTPException(status_code=500, detail=f"Failed to load HTTP handlers: {e}")
|
365
|
+
|
366
|
+
request_method = request.method.lower()
|
367
|
+
# Normalize request_path (no leading slash)
|
368
|
+
normalized_path = request_path.lstrip("/")
|
369
|
+
|
370
|
+
# Build a matcher for handler subpaths supporting path params like {id}
|
371
|
+
def build_regex(subpath: str):
|
372
|
+
# Remove leading slash for comparison
|
373
|
+
sp = subpath.lstrip("/")
|
374
|
+
# Find parameter names and build regex pattern
|
375
|
+
param_names = re.findall(r"\{([^}]+)\}", sp)
|
376
|
+
# Replace {param} with a capture group that matches a single path segment
|
377
|
+
pattern = re.sub(r"\{[^}]+\}", r"([^/]+)", sp)
|
378
|
+
# Anchor full match
|
379
|
+
pattern = f"^{pattern}$"
|
380
|
+
return re.compile(pattern), param_names
|
381
|
+
|
382
|
+
# Attempt to match in declaration order
|
383
|
+
for handler_config in http_handlers:
|
384
|
+
try:
|
385
|
+
subpath = handler_config.get('subpath') or ''
|
386
|
+
method = (handler_config.get('method') or 'get').lower()
|
387
|
+
handler_func = handler_config.get('function')
|
388
|
+
|
389
|
+
if method != request_method:
|
390
|
+
continue
|
391
|
+
|
392
|
+
regex, param_names = build_regex(subpath)
|
393
|
+
match = regex.match(normalized_path)
|
394
|
+
if not match:
|
395
|
+
continue
|
396
|
+
|
397
|
+
# Extract path params from capture groups
|
398
|
+
path_param_values = match.groups()
|
399
|
+
path_params = {name: value for name, value in zip(param_names, path_param_values)}
|
400
|
+
|
401
|
+
# Extract query params
|
402
|
+
query_params = dict(request.query_params)
|
403
|
+
|
404
|
+
# Extract JSON body for methods that commonly have a body
|
405
|
+
body_data = {}
|
406
|
+
if request.method in ["POST", "PUT", "PATCH"]:
|
407
|
+
try:
|
408
|
+
body_data = await request.json()
|
409
|
+
except Exception:
|
410
|
+
body_data = {}
|
411
|
+
|
412
|
+
# Combine and filter parameters by handler signature
|
413
|
+
combined_params = {**path_params, **query_params, **body_data}
|
414
|
+
sig = _inspect.signature(handler_func)
|
415
|
+
filtered_params = {}
|
416
|
+
for param_name in sig.parameters:
|
417
|
+
if param_name in ('self', 'context'):
|
418
|
+
continue
|
419
|
+
if param_name in combined_params:
|
420
|
+
filtered_params[param_name] = combined_params[param_name]
|
421
|
+
|
422
|
+
# Set minimal request context for handlers that depend on it (e.g., owner scope/User ID)
|
423
|
+
try:
|
424
|
+
ctx = create_context(messages=[], stream=False, agent=agent, request=request)
|
425
|
+
set_context(ctx)
|
426
|
+
except Exception:
|
427
|
+
pass
|
428
|
+
|
429
|
+
# Call the handler function (async or sync)
|
430
|
+
if _inspect.iscoroutinefunction(handler_func):
|
431
|
+
result = await handler_func(**filtered_params)
|
432
|
+
else:
|
433
|
+
# Run sync handler directly
|
434
|
+
result = handler_func(**filtered_params)
|
435
|
+
|
436
|
+
return result
|
437
|
+
except HTTPException:
|
438
|
+
raise
|
439
|
+
except Exception as e:
|
440
|
+
# If a handler matched but failed, surface the error
|
441
|
+
# Otherwise continue searching other handlers
|
442
|
+
last_error = str(e)
|
443
|
+
return Response(status_code=500, content=str(last_error))
|
444
|
+
|
445
|
+
# No matching handler found
|
446
|
+
raise HTTPException(status_code=404, detail=f"No HTTP handler for path '/{normalized_path}' and method {request.method} on agent '{agent_name}'")
|
447
|
+
|
448
|
+
# Include the router in the main app
|
449
|
+
self.app.include_router(self.router)
|
450
|
+
|
451
|
+
def _create_agent_endpoints(self, agent_name: str, is_dynamic: bool = False):
|
452
|
+
"""Create endpoints for a specific agent"""
|
453
|
+
|
454
|
+
@self.router.get(f"/{agent_name}", response_model=AgentInfoResponse)
|
455
|
+
async def agent_info():
|
456
|
+
return await self._handle_agent_info(agent_name, is_dynamic=is_dynamic)
|
457
|
+
|
458
|
+
@self.router.post(f"/{agent_name}/chat/completions")
|
459
|
+
async def chat_completion(request: ChatCompletionRequest, raw_request: Request = None):
|
460
|
+
return await self._handle_chat_completion(agent_name, request, raw_request, is_dynamic=is_dynamic)
|
461
|
+
|
462
|
+
@self.router.get(f"/{agent_name}/health")
|
463
|
+
async def agent_health():
|
464
|
+
"""Agent health check"""
|
465
|
+
try:
|
466
|
+
agent = await self._resolve_agent(agent_name, is_dynamic=is_dynamic)
|
467
|
+
return {
|
468
|
+
"agent_name": agent.name,
|
469
|
+
"status": "healthy",
|
470
|
+
"type": "static_agent" if not is_dynamic else "dynamic_agent",
|
471
|
+
"instructions_preview": agent.instructions[:100] + "..." if len(agent.instructions) > 100 else agent.instructions
|
472
|
+
}
|
473
|
+
except HTTPException:
|
474
|
+
raise
|
475
|
+
except Exception as e:
|
476
|
+
raise HTTPException(status_code=500, detail=f"Agent health check failed: {str(e)}")
|
477
|
+
|
478
|
+
# Register HTTP handlers if agent has any
|
479
|
+
if not is_dynamic:
|
480
|
+
agent = self.static_agents.get(agent_name)
|
481
|
+
if agent:
|
482
|
+
self._register_agent_http_handlers(agent_name, agent)
|
483
|
+
|
484
|
+
def _register_agent_http_handlers(self, agent_name: str, agent: BaseAgent):
|
485
|
+
"""Register agent's HTTP handlers as FastAPI routes with dynamic parameter support"""
|
486
|
+
import inspect # Import at method level to ensure availability
|
487
|
+
import re
|
488
|
+
import asyncio
|
489
|
+
|
490
|
+
try:
|
491
|
+
http_handlers = agent.get_all_http_handlers()
|
492
|
+
|
493
|
+
for handler_config in http_handlers:
|
494
|
+
subpath = handler_config['subpath']
|
495
|
+
method = handler_config['method'].lower()
|
496
|
+
handler_func = handler_config['function']
|
497
|
+
scope = handler_config.get('scope', 'all')
|
498
|
+
description = handler_config.get('description', '')
|
499
|
+
|
500
|
+
# Create full path: /{agent_name}{subpath}
|
501
|
+
full_path = f"/{agent_name}{subpath}"
|
502
|
+
|
503
|
+
# Extract path parameter names from subpath (e.g., {param}, {user_id})
|
504
|
+
path_params = re.findall(r'\{([^}]+)\}', subpath)
|
505
|
+
|
506
|
+
def create_http_wrapper(handler_func, scope, method, path_param_names):
|
507
|
+
# Create dynamic function based on path parameters
|
508
|
+
if path_param_names:
|
509
|
+
# Create a wrapper that accepts path parameters as individual arguments
|
510
|
+
async def http_wrapper(request: Request, **kwargs):
|
511
|
+
try:
|
512
|
+
# Extract path parameters from kwargs (FastAPI automatically extracts them)
|
513
|
+
path_params = {name: kwargs.get(name) for name in path_param_names if name in kwargs}
|
514
|
+
|
515
|
+
# Extract query parameters
|
516
|
+
query_params = dict(request.query_params)
|
517
|
+
|
518
|
+
# Extract JSON body if present
|
519
|
+
body_data = {}
|
520
|
+
if request.method in ["POST", "PUT", "PATCH"]:
|
521
|
+
try:
|
522
|
+
body_data = await request.json()
|
523
|
+
except:
|
524
|
+
pass
|
525
|
+
|
526
|
+
# Combine all parameters: path params, query params, body data
|
527
|
+
all_params = {**path_params, **query_params, **body_data}
|
528
|
+
|
529
|
+
# Get function signature to handle parameters properly
|
530
|
+
sig = inspect.signature(handler_func)
|
531
|
+
|
532
|
+
# Filter parameters to only include those expected by the function
|
533
|
+
filtered_params = {}
|
534
|
+
for param_name in sig.parameters:
|
535
|
+
if param_name in ('self', 'context'):
|
536
|
+
continue
|
537
|
+
if param_name in all_params:
|
538
|
+
filtered_params[param_name] = all_params[param_name]
|
539
|
+
|
540
|
+
# Call the handler function
|
541
|
+
if asyncio.iscoroutinefunction(handler_func):
|
542
|
+
result = await handler_func(**filtered_params)
|
543
|
+
else:
|
544
|
+
result = handler_func(**filtered_params)
|
545
|
+
|
546
|
+
return result
|
547
|
+
|
548
|
+
except Exception as e:
|
549
|
+
raise HTTPException(status_code=500, detail=str(e))
|
550
|
+
|
551
|
+
# Now we need to dynamically add the correct path parameters to the wrapper signature
|
552
|
+
# Create a new function with the correct signature
|
553
|
+
|
554
|
+
# Build parameter list for the new function
|
555
|
+
params = [inspect.Parameter('request', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=Request)]
|
556
|
+
for param_name in path_param_names:
|
557
|
+
params.append(inspect.Parameter(param_name, inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str))
|
558
|
+
|
559
|
+
# Create new signature
|
560
|
+
new_sig = inspect.Signature(params)
|
561
|
+
|
562
|
+
# Create a wrapper with the correct signature
|
563
|
+
def make_wrapper():
|
564
|
+
async def wrapper(*args, **kwargs):
|
565
|
+
# Convert args to kwargs based on signature
|
566
|
+
bound_args = new_sig.bind(*args, **kwargs)
|
567
|
+
bound_args.apply_defaults()
|
568
|
+
return await http_wrapper(**bound_args.arguments)
|
569
|
+
wrapper.__signature__ = new_sig
|
570
|
+
return wrapper
|
571
|
+
|
572
|
+
return make_wrapper()
|
573
|
+
else:
|
574
|
+
# No path parameters, simpler wrapper
|
575
|
+
async def http_wrapper(request: Request):
|
576
|
+
try:
|
577
|
+
# Extract query parameters
|
578
|
+
query_params = dict(request.query_params)
|
579
|
+
|
580
|
+
# Extract JSON body if present
|
581
|
+
body_data = {}
|
582
|
+
if request.method in ["POST", "PUT", "PATCH"]:
|
583
|
+
try:
|
584
|
+
body_data = await request.json()
|
585
|
+
except:
|
586
|
+
pass
|
587
|
+
|
588
|
+
# Combine all parameters: query params, body data
|
589
|
+
all_params = {**query_params, **body_data}
|
590
|
+
|
591
|
+
# Get function signature to handle parameters properly
|
592
|
+
sig = inspect.signature(handler_func)
|
593
|
+
|
594
|
+
# Filter parameters to only include those expected by the function
|
595
|
+
filtered_params = {}
|
596
|
+
for param_name in sig.parameters:
|
597
|
+
if param_name in ('self', 'context'):
|
598
|
+
continue
|
599
|
+
if param_name in all_params:
|
600
|
+
filtered_params[param_name] = all_params[param_name]
|
601
|
+
|
602
|
+
# Call the handler function
|
603
|
+
if asyncio.iscoroutinefunction(handler_func):
|
604
|
+
result = await handler_func(**filtered_params)
|
605
|
+
else:
|
606
|
+
result = handler_func(**filtered_params)
|
607
|
+
|
608
|
+
return result
|
609
|
+
|
610
|
+
except Exception as e:
|
611
|
+
raise HTTPException(status_code=500, detail=str(e))
|
612
|
+
|
613
|
+
return http_wrapper
|
614
|
+
|
615
|
+
# Create the wrapper
|
616
|
+
wrapper = create_http_wrapper(handler_func, scope, method, path_params)
|
617
|
+
|
618
|
+
# Register with FastAPI based on HTTP method (using router instead of app directly)
|
619
|
+
# FastAPI will automatically handle path parameter extraction
|
620
|
+
if method == "get":
|
621
|
+
self.router.get(full_path, summary=description)(wrapper)
|
622
|
+
elif method == "post":
|
623
|
+
self.router.post(full_path, summary=description)(wrapper)
|
624
|
+
elif method == "put":
|
625
|
+
self.router.put(full_path, summary=description)(wrapper)
|
626
|
+
elif method == "delete":
|
627
|
+
self.router.delete(full_path, summary=description)(wrapper)
|
628
|
+
elif method == "patch":
|
629
|
+
self.router.patch(full_path, summary=description)(wrapper)
|
630
|
+
elif method == "head":
|
631
|
+
self.router.head(full_path, summary=description)(wrapper)
|
632
|
+
elif method == "options":
|
633
|
+
self.router.options(full_path, summary=description)(wrapper)
|
634
|
+
|
635
|
+
print(f"📡 Registered HTTP endpoint: {method.upper()} {self.url_prefix}{full_path}")
|
636
|
+
|
637
|
+
except Exception as e:
|
638
|
+
print(f"⚠️ Error registering HTTP handlers for agent '{agent_name}': {e}")
|
639
|
+
|
640
|
+
async def _handle_agent_info(self, agent_name: str, is_dynamic: bool = False) -> AgentInfoResponse:
|
641
|
+
"""Handle agent info requests"""
|
642
|
+
agent = await self._resolve_agent(agent_name, is_dynamic=is_dynamic)
|
643
|
+
|
644
|
+
return AgentInfoResponse(
|
645
|
+
name=agent.name,
|
646
|
+
instructions=agent.instructions,
|
647
|
+
model="webagents-v2", # Generic model identifier
|
648
|
+
endpoints={
|
649
|
+
"chat_completions": f"{self.url_prefix}/{agent_name}/chat/completions",
|
650
|
+
"health": f"{self.url_prefix}/{agent_name}/health"
|
651
|
+
}
|
652
|
+
)
|
653
|
+
|
654
|
+
async def _handle_chat_completion(self, agent_name: str, request: ChatCompletionRequest, raw_request: Request = None, is_dynamic: bool = False):
|
655
|
+
"""Handle chat completion requests"""
|
656
|
+
|
657
|
+
# Resolve the agent
|
658
|
+
agent = await self._resolve_agent(agent_name, is_dynamic=is_dynamic)
|
659
|
+
|
660
|
+
# Create context for this request
|
661
|
+
context = create_context(
|
662
|
+
messages=request.messages,
|
663
|
+
stream=request.stream,
|
664
|
+
agent=agent,
|
665
|
+
request=raw_request
|
666
|
+
)
|
667
|
+
|
668
|
+
set_context(context)
|
669
|
+
|
670
|
+
try:
|
671
|
+
# Convert Pydantic objects to dictionaries for LiteLLM compatibility
|
672
|
+
messages_dict = []
|
673
|
+
for msg in request.messages:
|
674
|
+
if hasattr(msg, 'model_dump'):
|
675
|
+
# Pydantic v2
|
676
|
+
msg_dict = msg.model_dump()
|
677
|
+
elif hasattr(msg, 'dict'):
|
678
|
+
# Pydantic v1
|
679
|
+
msg_dict = msg.dict()
|
680
|
+
else:
|
681
|
+
# Already a dict
|
682
|
+
msg_dict = msg
|
683
|
+
|
684
|
+
# Ensure required fields exist
|
685
|
+
if 'role' not in msg_dict:
|
686
|
+
msg_dict['role'] = 'user'
|
687
|
+
if 'content' not in msg_dict:
|
688
|
+
msg_dict['content'] = ''
|
689
|
+
|
690
|
+
messages_dict.append(msg_dict)
|
691
|
+
|
692
|
+
# Convert tools to dictionaries if present
|
693
|
+
tools_dict = None
|
694
|
+
if request.tools:
|
695
|
+
tools_dict = []
|
696
|
+
for tool in request.tools:
|
697
|
+
if hasattr(tool, 'model_dump'):
|
698
|
+
tools_dict.append(tool.model_dump())
|
699
|
+
elif hasattr(tool, 'dict'):
|
700
|
+
tools_dict.append(tool.dict())
|
701
|
+
else:
|
702
|
+
tools_dict.append(tool)
|
703
|
+
|
704
|
+
if request.stream:
|
705
|
+
# Handle streaming response
|
706
|
+
return await self._stream_response(agent, request, messages_dict, tools_dict)
|
707
|
+
else:
|
708
|
+
# Handle non-streaming response
|
709
|
+
response = await agent.run(
|
710
|
+
messages=messages_dict,
|
711
|
+
tools=tools_dict,
|
712
|
+
stream=False
|
713
|
+
)
|
714
|
+
return response
|
715
|
+
|
716
|
+
except Exception as e:
|
717
|
+
# Check if this is a payment-related error with custom status code
|
718
|
+
self.logger.error(f"🚨 Agent execution error: {type(e).__name__}: {str(e)}")
|
719
|
+
|
720
|
+
if hasattr(e, 'status_code') and hasattr(e, 'detail'):
|
721
|
+
self.logger.error(f" - Found status_code: {getattr(e, 'status_code', None)}, detail present: {hasattr(e, 'detail')}")
|
722
|
+
self.logger.error(f" - Raising HTTPException with status_code={getattr(e, 'status_code', 500)}")
|
723
|
+
raise HTTPException(
|
724
|
+
status_code=getattr(e, 'status_code', 500),
|
725
|
+
detail=getattr(e, 'detail')
|
726
|
+
)
|
727
|
+
else:
|
728
|
+
self.logger.error(f" - No status_code/detail attributes, defaulting to 500")
|
729
|
+
raise HTTPException(
|
730
|
+
status_code=500,
|
731
|
+
detail=f"Error executing agent '{agent_name}': {str(e)}"
|
732
|
+
)
|
733
|
+
|
734
|
+
async def _stream_response(self, agent: BaseAgent, request: ChatCompletionRequest, messages_dict: List[Dict[str, Any]], tools_dict: Optional[List[Dict[str, Any]]]):
|
735
|
+
"""Handle streaming response"""
|
736
|
+
from fastapi.responses import StreamingResponse
|
737
|
+
import json
|
738
|
+
|
739
|
+
async def generate():
|
740
|
+
try:
|
741
|
+
async for chunk in agent.run_streaming(
|
742
|
+
messages=messages_dict,
|
743
|
+
tools=tools_dict
|
744
|
+
):
|
745
|
+
# Properly serialize chunk to JSON for SSE format
|
746
|
+
try:
|
747
|
+
chunk_json = json.dumps(chunk)
|
748
|
+
yield f"data: {chunk_json}\n\n"
|
749
|
+
except Exception as json_error:
|
750
|
+
self.logger.error(f"Failed to serialize streaming chunk: {json_error}, chunk: {chunk}")
|
751
|
+
# Skip malformed chunks instead of breaking the stream
|
752
|
+
continue
|
753
|
+
yield "data: [DONE]\n\n"
|
754
|
+
except Exception as e:
|
755
|
+
self.logger.error(f"Streaming error: {e}")
|
756
|
+
error_chunk = {
|
757
|
+
"error": {
|
758
|
+
"message": str(e),
|
759
|
+
"type": "server_error"
|
760
|
+
}
|
761
|
+
}
|
762
|
+
error_json = json.dumps(error_chunk)
|
763
|
+
yield f"data: {error_json}\n\n"
|
764
|
+
|
765
|
+
return StreamingResponse(
|
766
|
+
generate(),
|
767
|
+
media_type="text/event-stream",
|
768
|
+
headers={
|
769
|
+
"Cache-Control": "no-cache",
|
770
|
+
"Connection": "keep-alive",
|
771
|
+
"Access-Control-Allow-Origin": "*",
|
772
|
+
}
|
773
|
+
)
|
774
|
+
|
775
|
+
async def _resolve_agent(self, agent_name: str, is_dynamic: bool = False) -> BaseAgent:
|
776
|
+
"""Resolve agent by name from static or dynamic sources"""
|
777
|
+
|
778
|
+
# Try static agents first
|
779
|
+
agent = self.static_agents.get(agent_name)
|
780
|
+
if agent:
|
781
|
+
return agent
|
782
|
+
|
783
|
+
# If not looking for dynamic agents, stop here
|
784
|
+
if not is_dynamic:
|
785
|
+
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
786
|
+
|
787
|
+
# Try dynamic resolver if available
|
788
|
+
if self.dynamic_agents:
|
789
|
+
try:
|
790
|
+
if asyncio.iscoroutinefunction(self.dynamic_agents):
|
791
|
+
agent = await self.dynamic_agents(agent_name)
|
792
|
+
else:
|
793
|
+
agent = self.dynamic_agents(agent_name)
|
794
|
+
|
795
|
+
if agent and agent is not False:
|
796
|
+
return agent
|
797
|
+
except Exception as e:
|
798
|
+
print(f"Error resolving dynamic agent '{agent_name}': {e}")
|
799
|
+
|
800
|
+
# Agent not found
|
801
|
+
raise HTTPException(status_code=404, detail=f"Agent '{agent_name}' not found")
|
802
|
+
|
803
|
+
# Convenience property to access the FastAPI app
|
804
|
+
@property
|
805
|
+
def fastapi_app(self) -> FastAPI:
|
806
|
+
"""Get the underlying FastAPI application"""
|
807
|
+
return self.app
|
808
|
+
|
809
|
+
|
810
|
+
# Factory function for easy server creation
|
811
|
+
def create_server(
|
812
|
+
title: str = "WebAgents V2 Server",
|
813
|
+
description: str = "AI Agent Server with OpenAI Compatibility",
|
814
|
+
version: str = "2.0.0",
|
815
|
+
agents: List[BaseAgent] = None,
|
816
|
+
dynamic_agents: Optional[Union[Callable[[str], BaseAgent], Callable[[str], Awaitable[Optional[BaseAgent]]]]] = None,
|
817
|
+
url_prefix: str = "",
|
818
|
+
**kwargs
|
819
|
+
) -> WebAgentsServer:
|
820
|
+
"""
|
821
|
+
Create a WebAgents server instance
|
822
|
+
|
823
|
+
Args:
|
824
|
+
title: Server title
|
825
|
+
description: Server description
|
826
|
+
version: Server version
|
827
|
+
agents: List of static agents
|
828
|
+
dynamic_agents: Optional dynamic agent resolver function (sync or async)
|
829
|
+
url_prefix: URL prefix for all routes (e.g., "/agents")
|
830
|
+
**kwargs: Additional server configuration
|
831
|
+
|
832
|
+
Returns:
|
833
|
+
Configured WebAgentsServer instance
|
834
|
+
"""
|
835
|
+
return WebAgentsServer(
|
836
|
+
title=title,
|
837
|
+
description=description,
|
838
|
+
version=version,
|
839
|
+
agents=agents or [],
|
840
|
+
dynamic_agents=dynamic_agents,
|
841
|
+
url_prefix=url_prefix,
|
842
|
+
**kwargs
|
843
|
+
)
|