signalwire-agents 0.1.23__py3-none-any.whl → 0.1.24__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.
- signalwire_agents/__init__.py +1 -1
- signalwire_agents/agent_server.py +2 -1
- signalwire_agents/cli/config.py +61 -0
- signalwire_agents/cli/core/__init__.py +1 -0
- signalwire_agents/cli/core/agent_loader.py +254 -0
- signalwire_agents/cli/core/argparse_helpers.py +164 -0
- signalwire_agents/cli/core/dynamic_config.py +62 -0
- signalwire_agents/cli/execution/__init__.py +1 -0
- signalwire_agents/cli/execution/datamap_exec.py +437 -0
- signalwire_agents/cli/execution/webhook_exec.py +125 -0
- signalwire_agents/cli/output/__init__.py +1 -0
- signalwire_agents/cli/output/output_formatter.py +132 -0
- signalwire_agents/cli/output/swml_dump.py +177 -0
- signalwire_agents/cli/simulation/__init__.py +1 -0
- signalwire_agents/cli/simulation/data_generation.py +365 -0
- signalwire_agents/cli/simulation/data_overrides.py +187 -0
- signalwire_agents/cli/simulation/mock_env.py +271 -0
- signalwire_agents/cli/test_swaig.py +522 -2539
- signalwire_agents/cli/types.py +72 -0
- signalwire_agents/core/agent/__init__.py +1 -3
- signalwire_agents/core/agent/config/__init__.py +1 -3
- signalwire_agents/core/agent/prompt/manager.py +25 -7
- signalwire_agents/core/agent/tools/decorator.py +2 -0
- signalwire_agents/core/agent/tools/registry.py +8 -0
- signalwire_agents/core/agent_base.py +492 -3053
- signalwire_agents/core/function_result.py +31 -42
- signalwire_agents/core/mixins/__init__.py +28 -0
- signalwire_agents/core/mixins/ai_config_mixin.py +373 -0
- signalwire_agents/core/mixins/auth_mixin.py +287 -0
- signalwire_agents/core/mixins/prompt_mixin.py +345 -0
- signalwire_agents/core/mixins/serverless_mixin.py +368 -0
- signalwire_agents/core/mixins/skill_mixin.py +55 -0
- signalwire_agents/core/mixins/state_mixin.py +219 -0
- signalwire_agents/core/mixins/tool_mixin.py +295 -0
- signalwire_agents/core/mixins/web_mixin.py +1130 -0
- signalwire_agents/core/skill_manager.py +3 -1
- signalwire_agents/core/swaig_function.py +10 -1
- signalwire_agents/core/swml_service.py +140 -58
- signalwire_agents/skills/README.md +452 -0
- signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
- signalwire_agents/skills/datasphere/README.md +210 -0
- signalwire_agents/skills/datasphere_serverless/README.md +258 -0
- signalwire_agents/skills/datetime/README.md +132 -0
- signalwire_agents/skills/joke/README.md +149 -0
- signalwire_agents/skills/math/README.md +161 -0
- signalwire_agents/skills/native_vector_search/skill.py +33 -13
- signalwire_agents/skills/play_background_file/README.md +218 -0
- signalwire_agents/skills/spider/README.md +236 -0
- signalwire_agents/skills/spider/__init__.py +4 -0
- signalwire_agents/skills/spider/skill.py +479 -0
- signalwire_agents/skills/swml_transfer/README.md +395 -0
- signalwire_agents/skills/swml_transfer/__init__.py +1 -0
- signalwire_agents/skills/swml_transfer/skill.py +257 -0
- signalwire_agents/skills/weather_api/README.md +178 -0
- signalwire_agents/skills/web_search/README.md +163 -0
- signalwire_agents/skills/wikipedia_search/README.md +228 -0
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/METADATA +47 -2
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/RECORD +62 -22
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/entry_points.txt +1 -1
- signalwire_agents/core/agent/config/ephemeral.py +0 -176
- signalwire_agents-0.1.23.data/data/schema.json +0 -5611
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/WHEEL +0 -0
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1130 @@
|
|
1
|
+
"""
|
2
|
+
Copyright (c) 2025 SignalWire
|
3
|
+
|
4
|
+
This file is part of the SignalWire AI Agents SDK.
|
5
|
+
|
6
|
+
Licensed under the MIT License.
|
7
|
+
See LICENSE file in the project root for full license information.
|
8
|
+
"""
|
9
|
+
|
10
|
+
import os
|
11
|
+
import json
|
12
|
+
import base64
|
13
|
+
import signal
|
14
|
+
import sys
|
15
|
+
from typing import Optional, Dict, Any, Callable
|
16
|
+
from urllib.parse import urlparse, urlunparse
|
17
|
+
|
18
|
+
from fastapi import FastAPI, APIRouter, Request, Response
|
19
|
+
from fastapi.middleware.cors import CORSMiddleware
|
20
|
+
|
21
|
+
from signalwire_agents.core.logging_config import get_execution_mode
|
22
|
+
from signalwire_agents.core.function_result import SwaigFunctionResult
|
23
|
+
|
24
|
+
|
25
|
+
class WebMixin:
|
26
|
+
"""
|
27
|
+
Mixin class containing all web server and routing-related methods for AgentBase
|
28
|
+
"""
|
29
|
+
|
30
|
+
def get_app(self):
|
31
|
+
"""
|
32
|
+
Get the FastAPI application instance for deployment adapters like Lambda/Mangum
|
33
|
+
|
34
|
+
This method ensures the FastAPI app is properly initialized and configured,
|
35
|
+
then returns it for use with deployment adapters like Mangum for AWS Lambda.
|
36
|
+
|
37
|
+
Returns:
|
38
|
+
FastAPI: The configured FastAPI application instance
|
39
|
+
"""
|
40
|
+
if self._app is None:
|
41
|
+
# Initialize the app if it hasn't been created yet
|
42
|
+
# This follows the same initialization logic as serve() but without running uvicorn
|
43
|
+
from fastapi import FastAPI
|
44
|
+
from fastapi.middleware.cors import CORSMiddleware
|
45
|
+
|
46
|
+
# Create a FastAPI app with explicit redirect_slashes=False
|
47
|
+
app = FastAPI(redirect_slashes=False)
|
48
|
+
|
49
|
+
# Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
|
50
|
+
@app.get("/health")
|
51
|
+
@app.post("/health")
|
52
|
+
async def health_check():
|
53
|
+
"""Health check endpoint for Kubernetes liveness probe"""
|
54
|
+
return {
|
55
|
+
"status": "healthy",
|
56
|
+
"agent": self.get_name(),
|
57
|
+
"route": self.route,
|
58
|
+
"functions": len(self._tool_registry._swaig_functions)
|
59
|
+
}
|
60
|
+
|
61
|
+
@app.get("/ready")
|
62
|
+
@app.post("/ready")
|
63
|
+
async def readiness_check():
|
64
|
+
"""Readiness check endpoint for Kubernetes readiness probe"""
|
65
|
+
return {
|
66
|
+
"status": "ready",
|
67
|
+
"agent": self.get_name(),
|
68
|
+
"route": self.route,
|
69
|
+
"functions": len(self._tool_registry._swaig_functions)
|
70
|
+
}
|
71
|
+
|
72
|
+
# Add CORS middleware if needed
|
73
|
+
app.add_middleware(
|
74
|
+
CORSMiddleware,
|
75
|
+
allow_origins=["*"],
|
76
|
+
allow_credentials=True,
|
77
|
+
allow_methods=["*"],
|
78
|
+
allow_headers=["*"],
|
79
|
+
)
|
80
|
+
|
81
|
+
# Create router and register routes
|
82
|
+
router = self.as_router()
|
83
|
+
|
84
|
+
# Log registered routes for debugging
|
85
|
+
self.log.debug("router_routes_registered")
|
86
|
+
for route in router.routes:
|
87
|
+
if hasattr(route, "path"):
|
88
|
+
self.log.debug("router_route", path=route.path)
|
89
|
+
|
90
|
+
# Include the router
|
91
|
+
app.include_router(router, prefix=self.route)
|
92
|
+
|
93
|
+
# Register a catch-all route for debugging and troubleshooting
|
94
|
+
@app.get("/{full_path:path}")
|
95
|
+
@app.post("/{full_path:path}")
|
96
|
+
async def handle_all_routes(request: Request, full_path: str):
|
97
|
+
self.log.debug("request_received", path=full_path)
|
98
|
+
|
99
|
+
# Check if the path is meant for this agent
|
100
|
+
if not full_path.startswith(self.route.lstrip("/")):
|
101
|
+
return {"error": "Invalid route"}
|
102
|
+
|
103
|
+
# Extract the path relative to this agent's route
|
104
|
+
relative_path = full_path[len(self.route.lstrip("/")):]
|
105
|
+
relative_path = relative_path.lstrip("/")
|
106
|
+
self.log.debug("relative_path_extracted", path=relative_path)
|
107
|
+
|
108
|
+
# Log all app routes for debugging
|
109
|
+
self.log.debug("app_routes_registered")
|
110
|
+
for route in app.routes:
|
111
|
+
if hasattr(route, "path"):
|
112
|
+
self.log.debug("app_route", path=route.path)
|
113
|
+
|
114
|
+
self._app = app
|
115
|
+
|
116
|
+
return self._app
|
117
|
+
|
118
|
+
def as_router(self) -> APIRouter:
|
119
|
+
"""
|
120
|
+
Get a FastAPI router for this agent
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
FastAPI router
|
124
|
+
"""
|
125
|
+
# Create a router with explicit redirect_slashes=False
|
126
|
+
router = APIRouter(redirect_slashes=False)
|
127
|
+
|
128
|
+
# Register routes explicitly
|
129
|
+
self._register_routes(router)
|
130
|
+
|
131
|
+
# Log all registered routes for debugging
|
132
|
+
self.log.debug("routes_registered", agent=self.name)
|
133
|
+
for route in router.routes:
|
134
|
+
self.log.debug("route_registered", path=route.path)
|
135
|
+
|
136
|
+
return router
|
137
|
+
|
138
|
+
def serve(self, host: Optional[str] = None, port: Optional[int] = None) -> None:
|
139
|
+
"""
|
140
|
+
Start a web server for this agent
|
141
|
+
|
142
|
+
Args:
|
143
|
+
host: Optional host to override the default
|
144
|
+
port: Optional port to override the default
|
145
|
+
"""
|
146
|
+
import uvicorn
|
147
|
+
|
148
|
+
if self._app is None:
|
149
|
+
# Create a FastAPI app with explicit redirect_slashes=False
|
150
|
+
app = FastAPI(redirect_slashes=False)
|
151
|
+
|
152
|
+
# Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
|
153
|
+
@app.get("/health")
|
154
|
+
@app.post("/health")
|
155
|
+
async def health_check():
|
156
|
+
"""Health check endpoint for Kubernetes liveness probe"""
|
157
|
+
return {
|
158
|
+
"status": "healthy",
|
159
|
+
"agent": self.get_name(),
|
160
|
+
"route": self.route,
|
161
|
+
"functions": len(self._tool_registry._swaig_functions)
|
162
|
+
}
|
163
|
+
|
164
|
+
@app.get("/ready")
|
165
|
+
@app.post("/ready")
|
166
|
+
async def readiness_check():
|
167
|
+
"""Readiness check endpoint for Kubernetes readiness probe"""
|
168
|
+
# Check if agent is properly initialized
|
169
|
+
ready = (
|
170
|
+
hasattr(self, '_tool_registry') and
|
171
|
+
hasattr(self, 'schema_utils') and
|
172
|
+
self.schema_utils is not None
|
173
|
+
)
|
174
|
+
|
175
|
+
status_code = 200 if ready else 503
|
176
|
+
return Response(
|
177
|
+
content=json.dumps({
|
178
|
+
"status": "ready" if ready else "not_ready",
|
179
|
+
"agent": self.get_name(),
|
180
|
+
"initialized": ready
|
181
|
+
}),
|
182
|
+
status_code=status_code,
|
183
|
+
media_type="application/json"
|
184
|
+
)
|
185
|
+
|
186
|
+
# Get router for this agent
|
187
|
+
router = self.as_router()
|
188
|
+
|
189
|
+
# Register a catch-all route for debugging and troubleshooting
|
190
|
+
@app.get("/{full_path:path}")
|
191
|
+
@app.post("/{full_path:path}")
|
192
|
+
async def handle_all_routes(request: Request, full_path: str):
|
193
|
+
self.log.debug("request_received", path=full_path)
|
194
|
+
|
195
|
+
# Check if the path is meant for this agent
|
196
|
+
if not full_path.startswith(self.route.lstrip("/")):
|
197
|
+
return {"error": "Invalid route"}
|
198
|
+
|
199
|
+
# Extract the path relative to this agent's route
|
200
|
+
relative_path = full_path[len(self.route.lstrip("/")):]
|
201
|
+
relative_path = relative_path.lstrip("/")
|
202
|
+
self.log.debug("path_extracted", relative_path=relative_path)
|
203
|
+
|
204
|
+
# Perform routing based on the relative path
|
205
|
+
if not relative_path or relative_path == "/":
|
206
|
+
# Root endpoint
|
207
|
+
return await self._handle_root_request(request)
|
208
|
+
|
209
|
+
# Strip trailing slash for processing
|
210
|
+
clean_path = relative_path.rstrip("/")
|
211
|
+
|
212
|
+
# Check for standard endpoints
|
213
|
+
if clean_path == "debug":
|
214
|
+
return await self._handle_debug_request(request)
|
215
|
+
elif clean_path == "swaig":
|
216
|
+
return await self._handle_swaig_request(request, Response())
|
217
|
+
elif clean_path == "post_prompt":
|
218
|
+
return await self._handle_post_prompt_request(request)
|
219
|
+
elif clean_path == "check_for_input":
|
220
|
+
return await self._handle_check_for_input_request(request)
|
221
|
+
|
222
|
+
# Check for custom routing callbacks
|
223
|
+
if hasattr(self, '_routing_callbacks'):
|
224
|
+
for callback_path, callback_fn in self._routing_callbacks.items():
|
225
|
+
cb_path_clean = callback_path.strip("/")
|
226
|
+
if clean_path == cb_path_clean:
|
227
|
+
# Found a matching callback
|
228
|
+
request.state.callback_path = callback_path
|
229
|
+
return await self._handle_root_request(request)
|
230
|
+
|
231
|
+
# Default: 404
|
232
|
+
return {"error": "Path not found"}
|
233
|
+
|
234
|
+
# Include router with prefix (handle root route special case)
|
235
|
+
if self.route == "/":
|
236
|
+
app.include_router(router)
|
237
|
+
else:
|
238
|
+
app.include_router(router, prefix=self.route)
|
239
|
+
|
240
|
+
# Log all app routes for debugging
|
241
|
+
self.log.debug("app_routes_registered")
|
242
|
+
for route in app.routes:
|
243
|
+
if hasattr(route, "path"):
|
244
|
+
self.log.debug("app_route", path=route.path)
|
245
|
+
|
246
|
+
self._app = app
|
247
|
+
|
248
|
+
host = host or self.host
|
249
|
+
port = port or self.port
|
250
|
+
|
251
|
+
# Print the auth credentials with source
|
252
|
+
username, password, source = self.get_basic_auth_credentials(include_source=True)
|
253
|
+
|
254
|
+
# Get the proper URL using unified URL building
|
255
|
+
startup_url = self.get_full_url(include_auth=False)
|
256
|
+
|
257
|
+
# Log startup information using structured logging
|
258
|
+
self.log.info("agent_starting",
|
259
|
+
agent=self.name,
|
260
|
+
url=startup_url,
|
261
|
+
username=username,
|
262
|
+
password_length=len(password),
|
263
|
+
auth_source=source,
|
264
|
+
ssl_enabled=getattr(self, 'ssl_enabled', False))
|
265
|
+
|
266
|
+
# Print user-friendly startup message (keep this for development UX)
|
267
|
+
print(f"Agent '{self.name}' is available at:")
|
268
|
+
print(f"URL: {startup_url}")
|
269
|
+
print(f"Basic Auth: {username}:{password} (source: {source})")
|
270
|
+
|
271
|
+
# Check if SSL is enabled and start uvicorn accordingly
|
272
|
+
if getattr(self, 'ssl_enabled', False) and getattr(self, 'ssl_cert_path', None) and getattr(self, 'ssl_key_path', None):
|
273
|
+
self.log.info("starting_with_ssl", cert=self.ssl_cert_path, key=self.ssl_key_path)
|
274
|
+
uvicorn.run(
|
275
|
+
self._app,
|
276
|
+
host=host,
|
277
|
+
port=port,
|
278
|
+
ssl_certfile=self.ssl_cert_path,
|
279
|
+
ssl_keyfile=self.ssl_key_path
|
280
|
+
)
|
281
|
+
else:
|
282
|
+
uvicorn.run(self._app, host=host, port=port)
|
283
|
+
|
284
|
+
def run(self, event=None, context=None, force_mode=None, host: Optional[str] = None, port: Optional[int] = None):
|
285
|
+
"""
|
286
|
+
Smart run method that automatically detects environment and handles accordingly
|
287
|
+
|
288
|
+
Args:
|
289
|
+
event: Serverless event object (Lambda, Cloud Functions)
|
290
|
+
context: Serverless context object (Lambda, Cloud Functions)
|
291
|
+
force_mode: Override automatic mode detection for testing
|
292
|
+
host: Host override for server mode
|
293
|
+
port: Port override for server mode
|
294
|
+
|
295
|
+
Returns:
|
296
|
+
Response for serverless modes, None for server mode
|
297
|
+
"""
|
298
|
+
mode = force_mode or get_execution_mode()
|
299
|
+
|
300
|
+
try:
|
301
|
+
if mode in ['cgi', 'azure_function']:
|
302
|
+
response = self.handle_serverless_request(event, context, mode)
|
303
|
+
print(response)
|
304
|
+
return response
|
305
|
+
elif mode == 'lambda':
|
306
|
+
return self.handle_serverless_request(event, context, mode)
|
307
|
+
else:
|
308
|
+
# Server mode - use existing serve method
|
309
|
+
self.serve(host, port)
|
310
|
+
except Exception as e:
|
311
|
+
import logging
|
312
|
+
logging.error(f"Error in run method: {e}")
|
313
|
+
if mode == 'lambda':
|
314
|
+
return {
|
315
|
+
"statusCode": 500,
|
316
|
+
"headers": {"Content-Type": "application/json"},
|
317
|
+
"body": json.dumps({"error": str(e)})
|
318
|
+
}
|
319
|
+
else:
|
320
|
+
raise
|
321
|
+
|
322
|
+
def _register_routes(self, router):
|
323
|
+
"""
|
324
|
+
Register routes for this agent
|
325
|
+
|
326
|
+
This method ensures proper route registration by handling the routes
|
327
|
+
directly in AgentBase rather than inheriting from SWMLService.
|
328
|
+
|
329
|
+
Args:
|
330
|
+
router: FastAPI router to register routes with
|
331
|
+
"""
|
332
|
+
# Health check endpoints are now registered directly on the main app
|
333
|
+
|
334
|
+
# Root endpoint (handles both with and without trailing slash)
|
335
|
+
@router.get("/")
|
336
|
+
@router.post("/")
|
337
|
+
async def handle_root(request: Request, response: Response):
|
338
|
+
"""Handle GET/POST requests to the root endpoint"""
|
339
|
+
return await self._handle_root_request(request)
|
340
|
+
|
341
|
+
# Debug endpoint - Both versions
|
342
|
+
@router.get("/debug")
|
343
|
+
@router.get("/debug/")
|
344
|
+
@router.post("/debug")
|
345
|
+
@router.post("/debug/")
|
346
|
+
async def handle_debug(request: Request):
|
347
|
+
"""Handle GET/POST requests to the debug endpoint"""
|
348
|
+
return await self._handle_debug_request(request)
|
349
|
+
|
350
|
+
# SWAIG endpoint - Both versions
|
351
|
+
@router.get("/swaig")
|
352
|
+
@router.get("/swaig/")
|
353
|
+
@router.post("/swaig")
|
354
|
+
@router.post("/swaig/")
|
355
|
+
async def handle_swaig(request: Request, response: Response):
|
356
|
+
"""Handle GET/POST requests to the SWAIG endpoint"""
|
357
|
+
return await self._handle_swaig_request(request, response)
|
358
|
+
|
359
|
+
# Post prompt endpoint - Both versions
|
360
|
+
@router.get("/post_prompt")
|
361
|
+
@router.get("/post_prompt/")
|
362
|
+
@router.post("/post_prompt")
|
363
|
+
@router.post("/post_prompt/")
|
364
|
+
async def handle_post_prompt(request: Request):
|
365
|
+
"""Handle GET/POST requests to the post_prompt endpoint"""
|
366
|
+
return await self._handle_post_prompt_request(request)
|
367
|
+
|
368
|
+
# Check for input endpoint - Both versions
|
369
|
+
@router.get("/check_for_input")
|
370
|
+
@router.get("/check_for_input/")
|
371
|
+
@router.post("/check_for_input")
|
372
|
+
@router.post("/check_for_input/")
|
373
|
+
async def handle_check_for_input(request: Request):
|
374
|
+
"""Handle GET/POST requests to the check_for_input endpoint"""
|
375
|
+
return await self._handle_check_for_input_request(request)
|
376
|
+
|
377
|
+
# Register callback routes for routing callbacks if available
|
378
|
+
if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
|
379
|
+
for callback_path, callback_fn in self._routing_callbacks.items():
|
380
|
+
# Skip the root path as it's already handled
|
381
|
+
if callback_path == "/":
|
382
|
+
continue
|
383
|
+
|
384
|
+
# Register both with and without trailing slash
|
385
|
+
path = callback_path.rstrip("/")
|
386
|
+
path_with_slash = f"{path}/"
|
387
|
+
|
388
|
+
@router.get(path)
|
389
|
+
@router.get(path_with_slash)
|
390
|
+
@router.post(path)
|
391
|
+
@router.post(path_with_slash)
|
392
|
+
async def handle_callback(request: Request, response: Response, cb_path=callback_path):
|
393
|
+
"""Handle GET/POST requests to a registered callback path"""
|
394
|
+
# Store the callback path in request state for _handle_request to use
|
395
|
+
request.state.callback_path = cb_path
|
396
|
+
return await self._handle_root_request(request)
|
397
|
+
|
398
|
+
self.log.info("callback_endpoint_registered", path=callback_path)
|
399
|
+
|
400
|
+
async def _handle_root_request(self, request: Request):
|
401
|
+
"""Handle GET/POST requests to the root endpoint"""
|
402
|
+
# Debug logging to understand the state before any changes
|
403
|
+
self.log.debug("_handle_root_request entry",
|
404
|
+
proxy_url_base=getattr(self, '_proxy_url_base', None),
|
405
|
+
proxy_url_base_from_env=getattr(self, '_proxy_url_base_from_env', False),
|
406
|
+
env_var=os.environ.get('SWML_PROXY_URL_BASE'))
|
407
|
+
|
408
|
+
# Always detect proxy from current request headers - this allows mixing direct and proxied access
|
409
|
+
# Check for proxy headers
|
410
|
+
forwarded_host = request.headers.get("X-Forwarded-Host")
|
411
|
+
forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
|
412
|
+
|
413
|
+
if forwarded_host:
|
414
|
+
# Only update proxy URL if it wasn't set from environment
|
415
|
+
if not getattr(self, '_proxy_url_base_from_env', False):
|
416
|
+
# Set proxy_url_base on both self and super() to ensure it's shared
|
417
|
+
self._proxy_url_base = f"{forwarded_proto}://{forwarded_host}"
|
418
|
+
self._current_request = request # Store current request for get_full_url
|
419
|
+
if hasattr(super(), '_proxy_url_base'):
|
420
|
+
# Ensure parent class has the same proxy URL
|
421
|
+
super()._proxy_url_base = self._proxy_url_base
|
422
|
+
|
423
|
+
self.log.debug("proxy_detected_for_request", proxy_url_base=self._proxy_url_base,
|
424
|
+
source="X-Forwarded headers")
|
425
|
+
else:
|
426
|
+
self.log.debug("proxy headers present but keeping env proxy URL",
|
427
|
+
forwarded_proto=forwarded_proto,
|
428
|
+
forwarded_host=forwarded_host,
|
429
|
+
keeping_proxy_url=self._proxy_url_base,
|
430
|
+
source="environment variable")
|
431
|
+
else:
|
432
|
+
# No proxy headers - only clear proxy URL if it wasn't set from environment
|
433
|
+
if not getattr(self, '_proxy_url_base_from_env', False):
|
434
|
+
self.log.debug("No proxy headers found, clearing proxy URL",
|
435
|
+
proxy_url_base_from_env=getattr(self, '_proxy_url_base_from_env', False))
|
436
|
+
self._proxy_url_base = None
|
437
|
+
if hasattr(super(), '_proxy_url_base'):
|
438
|
+
super()._proxy_url_base = None
|
439
|
+
else:
|
440
|
+
self.log.debug("No proxy headers found, but keeping env proxy URL",
|
441
|
+
proxy_url_base=getattr(self, '_proxy_url_base', None),
|
442
|
+
proxy_url_base_from_env=True)
|
443
|
+
self._current_request = request # Store current request for get_full_url
|
444
|
+
|
445
|
+
# Try the parent class detection method if it exists
|
446
|
+
if hasattr(super(), '_detect_proxy_from_request'):
|
447
|
+
# Call the parent's detection method
|
448
|
+
super()._detect_proxy_from_request(request)
|
449
|
+
# Copy the result to our class if found
|
450
|
+
if hasattr(super(), '_proxy_url_base') and getattr(super(), '_proxy_url_base', None):
|
451
|
+
self._proxy_url_base = super()._proxy_url_base
|
452
|
+
|
453
|
+
# Check if this is a callback path request
|
454
|
+
callback_path = getattr(request.state, "callback_path", None)
|
455
|
+
|
456
|
+
req_log = self.log.bind(
|
457
|
+
endpoint="root" if not callback_path else f"callback:{callback_path}",
|
458
|
+
method=request.method,
|
459
|
+
path=request.url.path
|
460
|
+
)
|
461
|
+
|
462
|
+
req_log.debug("endpoint_called")
|
463
|
+
|
464
|
+
try:
|
465
|
+
# Check auth
|
466
|
+
if not self._check_basic_auth(request):
|
467
|
+
req_log.warning("unauthorized_access_attempt")
|
468
|
+
return Response(
|
469
|
+
content=json.dumps({"error": "Unauthorized"}),
|
470
|
+
status_code=401,
|
471
|
+
headers={"WWW-Authenticate": "Basic"},
|
472
|
+
media_type="application/json"
|
473
|
+
)
|
474
|
+
|
475
|
+
# Try to parse request body for POST
|
476
|
+
body = {}
|
477
|
+
call_id = None
|
478
|
+
|
479
|
+
if request.method == "POST":
|
480
|
+
# Check if body is empty first
|
481
|
+
raw_body = await request.body()
|
482
|
+
if raw_body:
|
483
|
+
try:
|
484
|
+
body = await request.json()
|
485
|
+
req_log.debug("request_body_received", body_size=len(str(body)))
|
486
|
+
if body:
|
487
|
+
req_log.debug("request_body")
|
488
|
+
except Exception as e:
|
489
|
+
req_log.warning("error_parsing_request_body", error=str(e))
|
490
|
+
# Continue processing with empty body
|
491
|
+
body = {}
|
492
|
+
else:
|
493
|
+
req_log.debug("empty_request_body")
|
494
|
+
|
495
|
+
# Get call_id from body if present
|
496
|
+
call_id = body.get("call_id")
|
497
|
+
else:
|
498
|
+
# Get call_id from query params for GET
|
499
|
+
call_id = request.query_params.get("call_id")
|
500
|
+
|
501
|
+
# Add call_id to logger if any
|
502
|
+
if call_id:
|
503
|
+
req_log = req_log.bind(call_id=call_id)
|
504
|
+
req_log.debug("call_id_identified")
|
505
|
+
|
506
|
+
# Check if this is a callback path and we need to apply routing
|
507
|
+
if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
|
508
|
+
callback_fn = self._routing_callbacks[callback_path]
|
509
|
+
|
510
|
+
if request.method == "POST" and body:
|
511
|
+
req_log.debug("processing_routing_callback", path=callback_path)
|
512
|
+
# Call the routing callback
|
513
|
+
try:
|
514
|
+
route = callback_fn(request, body)
|
515
|
+
if route is not None:
|
516
|
+
req_log.info("routing_request", route=route)
|
517
|
+
# Return a redirect to the new route
|
518
|
+
return Response(
|
519
|
+
status_code=307, # 307 Temporary Redirect preserves the method and body
|
520
|
+
headers={"Location": route}
|
521
|
+
)
|
522
|
+
except Exception as e:
|
523
|
+
req_log.error("error_in_routing_callback", error=str(e))
|
524
|
+
|
525
|
+
# Allow subclasses to inspect/modify the request
|
526
|
+
modifications = None
|
527
|
+
try:
|
528
|
+
modifications = self.on_swml_request(body, callback_path, request)
|
529
|
+
if modifications:
|
530
|
+
req_log.debug("request_modifications_applied")
|
531
|
+
except Exception as e:
|
532
|
+
req_log.error("error_in_request_modifier", error=str(e))
|
533
|
+
|
534
|
+
# Render SWML
|
535
|
+
swml = self._render_swml(call_id, modifications)
|
536
|
+
req_log.debug("swml_rendered", swml_size=len(swml))
|
537
|
+
|
538
|
+
# Return as JSON
|
539
|
+
req_log.info("request_successful")
|
540
|
+
return Response(
|
541
|
+
content=swml,
|
542
|
+
media_type="application/json"
|
543
|
+
)
|
544
|
+
except Exception as e:
|
545
|
+
req_log.error("request_failed", error=str(e))
|
546
|
+
return Response(
|
547
|
+
content=json.dumps({"error": str(e)}),
|
548
|
+
status_code=500,
|
549
|
+
media_type="application/json"
|
550
|
+
)
|
551
|
+
|
552
|
+
async def _handle_debug_request(self, request: Request):
|
553
|
+
"""Handle GET/POST requests to the debug endpoint"""
|
554
|
+
req_log = self.log.bind(
|
555
|
+
endpoint="debug",
|
556
|
+
method=request.method,
|
557
|
+
path=request.url.path
|
558
|
+
)
|
559
|
+
|
560
|
+
req_log.debug("endpoint_called")
|
561
|
+
|
562
|
+
try:
|
563
|
+
# Check auth
|
564
|
+
if not self._check_basic_auth(request):
|
565
|
+
req_log.warning("unauthorized_access_attempt")
|
566
|
+
return Response(
|
567
|
+
content=json.dumps({"error": "Unauthorized"}),
|
568
|
+
status_code=401,
|
569
|
+
headers={"WWW-Authenticate": "Basic"},
|
570
|
+
media_type="application/json"
|
571
|
+
)
|
572
|
+
|
573
|
+
# Get call_id from either query params (GET) or body (POST)
|
574
|
+
call_id = None
|
575
|
+
body = {}
|
576
|
+
|
577
|
+
if request.method == "POST":
|
578
|
+
try:
|
579
|
+
body = await request.json()
|
580
|
+
req_log.debug("request_body_received", body_size=len(str(body)))
|
581
|
+
call_id = body.get("call_id")
|
582
|
+
except Exception as e:
|
583
|
+
req_log.warning("error_parsing_request_body", error=str(e))
|
584
|
+
else:
|
585
|
+
call_id = request.query_params.get("call_id")
|
586
|
+
|
587
|
+
# Add call_id to logger if any
|
588
|
+
if call_id:
|
589
|
+
req_log = req_log.bind(call_id=call_id)
|
590
|
+
req_log.debug("call_id_identified")
|
591
|
+
|
592
|
+
# Allow subclasses to inspect/modify the request
|
593
|
+
modifications = None
|
594
|
+
try:
|
595
|
+
modifications = self.on_swml_request(body, None, request)
|
596
|
+
if modifications:
|
597
|
+
req_log.debug("request_modifications_applied")
|
598
|
+
except Exception as e:
|
599
|
+
req_log.error("error_in_request_modifier", error=str(e))
|
600
|
+
|
601
|
+
# Render SWML
|
602
|
+
swml = self._render_swml(call_id, modifications)
|
603
|
+
req_log.debug("swml_rendered", swml_size=len(swml))
|
604
|
+
|
605
|
+
# Return as JSON
|
606
|
+
req_log.info("request_successful")
|
607
|
+
return Response(
|
608
|
+
content=swml,
|
609
|
+
media_type="application/json",
|
610
|
+
headers={"X-Debug": "true"}
|
611
|
+
)
|
612
|
+
except Exception as e:
|
613
|
+
req_log.error("request_failed", error=str(e))
|
614
|
+
return Response(
|
615
|
+
content=json.dumps({"error": str(e)}),
|
616
|
+
status_code=500,
|
617
|
+
media_type="application/json"
|
618
|
+
)
|
619
|
+
|
620
|
+
async def _handle_swaig_request(self, request: Request, response: Response):
|
621
|
+
"""Handle GET/POST requests to the SWAIG endpoint"""
|
622
|
+
req_log = self.log.bind(
|
623
|
+
endpoint="swaig",
|
624
|
+
method=request.method,
|
625
|
+
path=request.url.path
|
626
|
+
)
|
627
|
+
|
628
|
+
req_log.debug("endpoint_called")
|
629
|
+
|
630
|
+
try:
|
631
|
+
# Check auth
|
632
|
+
if not self._check_basic_auth(request):
|
633
|
+
req_log.warning("unauthorized_access_attempt")
|
634
|
+
response.headers["WWW-Authenticate"] = "Basic"
|
635
|
+
return Response(
|
636
|
+
content=json.dumps({"error": "Unauthorized"}),
|
637
|
+
status_code=401,
|
638
|
+
headers={"WWW-Authenticate": "Basic"},
|
639
|
+
media_type="application/json"
|
640
|
+
)
|
641
|
+
|
642
|
+
# Handle differently based on method
|
643
|
+
if request.method == "GET":
|
644
|
+
# For GET requests, return the SWML document (same as root endpoint)
|
645
|
+
call_id = request.query_params.get("call_id")
|
646
|
+
swml = self._render_swml(call_id)
|
647
|
+
req_log.debug("swml_rendered", swml_size=len(swml))
|
648
|
+
return Response(
|
649
|
+
content=swml,
|
650
|
+
media_type="application/json"
|
651
|
+
)
|
652
|
+
|
653
|
+
# For POST requests, process SWAIG function calls
|
654
|
+
try:
|
655
|
+
body = await request.json()
|
656
|
+
req_log.debug("request_body_received", body_size=len(str(body)))
|
657
|
+
if body:
|
658
|
+
req_log.debug("request_body", body=json.dumps(body))
|
659
|
+
except Exception as e:
|
660
|
+
req_log.error("error_parsing_request_body", error=str(e))
|
661
|
+
body = {}
|
662
|
+
|
663
|
+
# Extract function name
|
664
|
+
function_name = body.get("function")
|
665
|
+
if not function_name:
|
666
|
+
req_log.warning("missing_function_name")
|
667
|
+
return Response(
|
668
|
+
content=json.dumps({"error": "Missing function name"}),
|
669
|
+
status_code=400,
|
670
|
+
media_type="application/json"
|
671
|
+
)
|
672
|
+
|
673
|
+
# Add function info to logger
|
674
|
+
req_log = req_log.bind(function=function_name)
|
675
|
+
req_log.debug("function_call_received")
|
676
|
+
|
677
|
+
# Extract arguments
|
678
|
+
args = {}
|
679
|
+
if "argument" in body and isinstance(body["argument"], dict):
|
680
|
+
if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
|
681
|
+
args = body["argument"]["parsed"][0]
|
682
|
+
req_log.debug("parsed_arguments", args=json.dumps(args))
|
683
|
+
elif "raw" in body["argument"]:
|
684
|
+
try:
|
685
|
+
args = json.loads(body["argument"]["raw"])
|
686
|
+
req_log.debug("raw_arguments_parsed", args=json.dumps(args))
|
687
|
+
except Exception as e:
|
688
|
+
req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
|
689
|
+
|
690
|
+
# Get call_id from body
|
691
|
+
call_id = body.get("call_id")
|
692
|
+
if call_id:
|
693
|
+
req_log = req_log.bind(call_id=call_id)
|
694
|
+
req_log.debug("call_id_identified")
|
695
|
+
|
696
|
+
# SECURITY BYPASS FOR DEBUGGING - make all functions work regardless of token
|
697
|
+
# We'll log the attempt but allow it through
|
698
|
+
token = request.query_params.get("__token") or request.query_params.get("token") # Check __token first, fallback to token
|
699
|
+
if token:
|
700
|
+
req_log.debug("token_found", token_length=len(token))
|
701
|
+
|
702
|
+
# Check token validity but don't reject the request
|
703
|
+
if hasattr(self, '_session_manager') and function_name in self._tool_registry._swaig_functions:
|
704
|
+
is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
|
705
|
+
if is_valid:
|
706
|
+
req_log.debug("token_valid")
|
707
|
+
else:
|
708
|
+
# Log but continue anyway for debugging
|
709
|
+
req_log.warning("token_invalid")
|
710
|
+
if hasattr(self._session_manager, 'debug_token'):
|
711
|
+
debug_info = self._session_manager.debug_token(token)
|
712
|
+
req_log.debug("token_debug", debug=json.dumps(debug_info))
|
713
|
+
|
714
|
+
# Check if we need to use an ephemeral agent for dynamic configuration
|
715
|
+
agent_to_use = self
|
716
|
+
if self._dynamic_config_callback and request:
|
717
|
+
# Check query params to see if this request came from a dynamically configured agent
|
718
|
+
# This would have been preserved through add_swaig_query_params
|
719
|
+
# For example, conversation_type=chat would be in the query params
|
720
|
+
agent_to_use = self._create_ephemeral_copy()
|
721
|
+
|
722
|
+
try:
|
723
|
+
# Extract request data
|
724
|
+
query_params = dict(request.query_params)
|
725
|
+
headers = dict(request.headers)
|
726
|
+
|
727
|
+
# Call the dynamic config callback with the ephemeral agent
|
728
|
+
# Pass the body as body_params for context
|
729
|
+
self._dynamic_config_callback(query_params, body, headers, agent_to_use)
|
730
|
+
|
731
|
+
except Exception as e:
|
732
|
+
req_log.error("dynamic_config_error", error=str(e))
|
733
|
+
|
734
|
+
# Call the function
|
735
|
+
try:
|
736
|
+
result = agent_to_use.on_function_call(function_name, args, body)
|
737
|
+
|
738
|
+
# Convert result to dict if needed
|
739
|
+
if isinstance(result, SwaigFunctionResult):
|
740
|
+
result_dict = result.to_dict()
|
741
|
+
elif isinstance(result, dict):
|
742
|
+
result_dict = result
|
743
|
+
else:
|
744
|
+
result_dict = {"response": str(result)}
|
745
|
+
|
746
|
+
req_log.info("function_executed_successfully")
|
747
|
+
req_log.debug("function_result", result=json.dumps(result_dict))
|
748
|
+
return result_dict
|
749
|
+
except Exception as e:
|
750
|
+
req_log.error("function_execution_error", error=str(e))
|
751
|
+
return {"error": str(e), "function": function_name}
|
752
|
+
|
753
|
+
except Exception as e:
|
754
|
+
req_log.error("request_failed", error=str(e))
|
755
|
+
return Response(
|
756
|
+
content=json.dumps({"error": str(e)}),
|
757
|
+
status_code=500,
|
758
|
+
media_type="application/json"
|
759
|
+
)
|
760
|
+
|
761
|
+
async def _handle_post_prompt_request(self, request: Request):
|
762
|
+
"""Handle GET/POST requests to the post_prompt endpoint"""
|
763
|
+
req_log = self.log.bind(
|
764
|
+
endpoint="post_prompt",
|
765
|
+
method=request.method,
|
766
|
+
path=request.url.path
|
767
|
+
)
|
768
|
+
|
769
|
+
# Only log if not suppressed
|
770
|
+
if not getattr(self, '_suppress_logs', False):
|
771
|
+
req_log.debug("endpoint_called")
|
772
|
+
|
773
|
+
try:
|
774
|
+
# Check auth
|
775
|
+
if not self._check_basic_auth(request):
|
776
|
+
req_log.warning("unauthorized_access_attempt")
|
777
|
+
return Response(
|
778
|
+
content=json.dumps({"error": "Unauthorized"}),
|
779
|
+
status_code=401,
|
780
|
+
headers={"WWW-Authenticate": "Basic"},
|
781
|
+
media_type="application/json"
|
782
|
+
)
|
783
|
+
|
784
|
+
# Extract call_id for use with token validation
|
785
|
+
call_id = request.query_params.get("call_id")
|
786
|
+
|
787
|
+
# For POST requests, try to also get call_id from body
|
788
|
+
if request.method == "POST":
|
789
|
+
try:
|
790
|
+
body_text = await request.body()
|
791
|
+
if body_text:
|
792
|
+
body_data = json.loads(body_text)
|
793
|
+
if call_id is None:
|
794
|
+
call_id = body_data.get("call_id")
|
795
|
+
# Save body_data for later use
|
796
|
+
setattr(request, "_post_prompt_body", body_data)
|
797
|
+
except Exception as e:
|
798
|
+
req_log.error("error_extracting_call_id", error=str(e))
|
799
|
+
|
800
|
+
# If we have a call_id, add it to the logger context
|
801
|
+
if call_id:
|
802
|
+
req_log = req_log.bind(call_id=call_id)
|
803
|
+
|
804
|
+
# Check token if provided
|
805
|
+
token = request.query_params.get("__token") or request.query_params.get("token") # Check __token first, fallback to token
|
806
|
+
token_validated = False
|
807
|
+
|
808
|
+
if token:
|
809
|
+
req_log.debug("token_found", token_length=len(token))
|
810
|
+
|
811
|
+
# Try to validate token, but continue processing regardless
|
812
|
+
if call_id and hasattr(self, '_session_manager'):
|
813
|
+
try:
|
814
|
+
is_valid = self._session_manager.validate_tool_token("post_prompt", token, call_id)
|
815
|
+
if is_valid:
|
816
|
+
req_log.debug("token_valid")
|
817
|
+
token_validated = True
|
818
|
+
else:
|
819
|
+
req_log.warning("invalid_token")
|
820
|
+
# Debug information for token validation issues
|
821
|
+
if hasattr(self._session_manager, 'debug_token'):
|
822
|
+
debug_info = self._session_manager.debug_token(token)
|
823
|
+
req_log.debug("token_debug", debug=json.dumps(debug_info))
|
824
|
+
except Exception as e:
|
825
|
+
req_log.error("token_validation_error", error=str(e))
|
826
|
+
|
827
|
+
# For GET requests, return the SWML document
|
828
|
+
if request.method == "GET":
|
829
|
+
# Check if we should use dynamic config via on_swml_request
|
830
|
+
modifications = self.on_swml_request(None, None, request)
|
831
|
+
swml = self._render_swml(call_id, modifications)
|
832
|
+
req_log.debug("swml_rendered", swml_size=len(swml))
|
833
|
+
return Response(
|
834
|
+
content=swml,
|
835
|
+
media_type="application/json"
|
836
|
+
)
|
837
|
+
|
838
|
+
# For POST requests, process the post-prompt data
|
839
|
+
try:
|
840
|
+
# Try to reuse the body we already parsed for call_id extraction
|
841
|
+
if hasattr(request, "_post_prompt_body"):
|
842
|
+
body = getattr(request, "_post_prompt_body")
|
843
|
+
else:
|
844
|
+
body = await request.json()
|
845
|
+
|
846
|
+
# Only log if not suppressed
|
847
|
+
if not getattr(self, '_suppress_logs', False):
|
848
|
+
req_log.debug("request_body_received", body_size=len(str(body)))
|
849
|
+
# Log the raw body directly (let the logger handle the JSON encoding)
|
850
|
+
req_log.info("post_prompt_body", body=body)
|
851
|
+
except Exception as e:
|
852
|
+
req_log.error("error_parsing_request_body", error=str(e))
|
853
|
+
body = {}
|
854
|
+
|
855
|
+
# Check if we need to use an ephemeral agent for dynamic configuration
|
856
|
+
agent_to_use = self
|
857
|
+
if self._dynamic_config_callback and request:
|
858
|
+
# Create ephemeral copy and apply dynamic config
|
859
|
+
agent_to_use = self._create_ephemeral_copy()
|
860
|
+
|
861
|
+
try:
|
862
|
+
# Extract request data
|
863
|
+
query_params = dict(request.query_params)
|
864
|
+
headers = dict(request.headers)
|
865
|
+
|
866
|
+
# Call the dynamic config callback with the ephemeral agent
|
867
|
+
self._dynamic_config_callback(query_params, body, headers, agent_to_use)
|
868
|
+
|
869
|
+
except Exception as e:
|
870
|
+
req_log.error("dynamic_config_error", error=str(e))
|
871
|
+
|
872
|
+
# Extract summary from the correct location in the request
|
873
|
+
summary = agent_to_use._find_summary_in_post_data(body, req_log)
|
874
|
+
|
875
|
+
# Call the summary handler with the summary and the full body
|
876
|
+
try:
|
877
|
+
if summary:
|
878
|
+
agent_to_use.on_summary(summary, body)
|
879
|
+
req_log.debug("summary_handler_called_successfully")
|
880
|
+
else:
|
881
|
+
# If no summary found but still want to process the data
|
882
|
+
agent_to_use.on_summary(None, body)
|
883
|
+
req_log.debug("summary_handler_called_with_null_summary")
|
884
|
+
except Exception as e:
|
885
|
+
req_log.error("error_in_summary_handler", error=str(e))
|
886
|
+
|
887
|
+
# Return success
|
888
|
+
req_log.info("request_successful")
|
889
|
+
return {"success": True}
|
890
|
+
except Exception as e:
|
891
|
+
req_log.error("request_failed", error=str(e))
|
892
|
+
return Response(
|
893
|
+
content=json.dumps({"error": str(e)}),
|
894
|
+
status_code=500,
|
895
|
+
media_type="application/json"
|
896
|
+
)
|
897
|
+
|
898
|
+
async def _handle_check_for_input_request(self, request: Request):
|
899
|
+
"""Handle GET/POST requests to the check_for_input endpoint"""
|
900
|
+
req_log = self.log.bind(
|
901
|
+
endpoint="check_for_input",
|
902
|
+
method=request.method,
|
903
|
+
path=request.url.path
|
904
|
+
)
|
905
|
+
|
906
|
+
req_log.debug("endpoint_called")
|
907
|
+
|
908
|
+
try:
|
909
|
+
# Check auth
|
910
|
+
if not self._check_basic_auth(request):
|
911
|
+
req_log.warning("unauthorized_access_attempt")
|
912
|
+
return Response(
|
913
|
+
content=json.dumps({"error": "Unauthorized"}),
|
914
|
+
status_code=401,
|
915
|
+
headers={"WWW-Authenticate": "Basic"},
|
916
|
+
media_type="application/json"
|
917
|
+
)
|
918
|
+
|
919
|
+
# For both GET and POST requests, process input check
|
920
|
+
conversation_id = None
|
921
|
+
|
922
|
+
if request.method == "POST":
|
923
|
+
try:
|
924
|
+
body = await request.json()
|
925
|
+
req_log.debug("request_body_received", body_size=len(str(body)))
|
926
|
+
conversation_id = body.get("conversation_id")
|
927
|
+
except Exception as e:
|
928
|
+
req_log.error("error_parsing_request_body", error=str(e))
|
929
|
+
else:
|
930
|
+
conversation_id = request.query_params.get("conversation_id")
|
931
|
+
|
932
|
+
if not conversation_id:
|
933
|
+
req_log.warning("missing_conversation_id")
|
934
|
+
return Response(
|
935
|
+
content=json.dumps({"error": "Missing conversation_id parameter"}),
|
936
|
+
status_code=400,
|
937
|
+
media_type="application/json"
|
938
|
+
)
|
939
|
+
|
940
|
+
# Here you would typically check for new input in some external system
|
941
|
+
# For this implementation, we'll return an empty result
|
942
|
+
return {
|
943
|
+
"status": "success",
|
944
|
+
"conversation_id": conversation_id,
|
945
|
+
"new_input": False,
|
946
|
+
"messages": []
|
947
|
+
}
|
948
|
+
except Exception as e:
|
949
|
+
req_log.error("request_failed", error=str(e))
|
950
|
+
return Response(
|
951
|
+
content=json.dumps({"error": str(e)}),
|
952
|
+
status_code=500,
|
953
|
+
media_type="application/json"
|
954
|
+
)
|
955
|
+
|
956
|
+
def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
|
957
|
+
"""
|
958
|
+
Called when SWML is requested, with request data when available
|
959
|
+
|
960
|
+
This method overrides SWMLService's on_request to properly handle SWML generation
|
961
|
+
for AI Agents.
|
962
|
+
|
963
|
+
Args:
|
964
|
+
request_data: Optional dictionary containing the parsed POST body
|
965
|
+
callback_path: Optional callback path
|
966
|
+
|
967
|
+
Returns:
|
968
|
+
None to use the default SWML rendering (which will call _render_swml)
|
969
|
+
"""
|
970
|
+
# Call on_swml_request for customization
|
971
|
+
if hasattr(self, 'on_swml_request') and callable(getattr(self, 'on_swml_request')):
|
972
|
+
return self.on_swml_request(request_data, callback_path, None)
|
973
|
+
|
974
|
+
# If no on_swml_request or it returned None, we'll proceed with default rendering
|
975
|
+
return None
|
976
|
+
|
977
|
+
def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None, request: Optional[Request] = None) -> Optional[dict]:
|
978
|
+
"""
|
979
|
+
Customization point for subclasses to modify SWML based on request data
|
980
|
+
|
981
|
+
Args:
|
982
|
+
request_data: Optional dictionary containing the parsed POST body
|
983
|
+
callback_path: Optional callback path
|
984
|
+
request: Optional FastAPI Request object for accessing query params, headers, etc.
|
985
|
+
|
986
|
+
Returns:
|
987
|
+
Optional dict with modifications to apply to the SWML document
|
988
|
+
"""
|
989
|
+
# Dynamic config is handled differently now - we don't modify the agent here
|
990
|
+
# Instead, we'll return a marker that tells _render_swml to use an ephemeral copy
|
991
|
+
# UNLESS we're already on an ephemeral agent (to prevent infinite recursion)
|
992
|
+
#
|
993
|
+
# IMPORTANT: We ALWAYS return the marker if we have a dynamic config callback,
|
994
|
+
# even if request is None. This ensures the first request gets dynamic config too.
|
995
|
+
self.log.debug("on_swml_request_called",
|
996
|
+
has_dynamic_callback=bool(self._dynamic_config_callback),
|
997
|
+
is_ephemeral=getattr(self, '_is_ephemeral', False),
|
998
|
+
has_request=bool(request))
|
999
|
+
|
1000
|
+
if self._dynamic_config_callback and not getattr(self, '_is_ephemeral', False):
|
1001
|
+
# Return a special marker that _render_swml will recognize
|
1002
|
+
self.log.debug("returning_ephemeral_marker")
|
1003
|
+
return {"__use_ephemeral_agent": True, "__request": request, "__request_data": request_data}
|
1004
|
+
|
1005
|
+
# Return None to indicate no SWML modifications needed
|
1006
|
+
return None
|
1007
|
+
|
1008
|
+
def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
|
1009
|
+
path: str = "/sip") -> None:
|
1010
|
+
"""
|
1011
|
+
Register a callback function that will be called to determine routing
|
1012
|
+
based on POST data.
|
1013
|
+
|
1014
|
+
When a routing callback is registered, an endpoint at the specified path is automatically
|
1015
|
+
created that will handle requests. This endpoint will use the callback to
|
1016
|
+
determine if the request should be processed by this service or redirected.
|
1017
|
+
|
1018
|
+
The callback should take a request object and request body dictionary and return:
|
1019
|
+
- A route string if it should be routed to a different endpoint
|
1020
|
+
- None if normal processing should continue
|
1021
|
+
|
1022
|
+
Args:
|
1023
|
+
callback_fn: The callback function to register
|
1024
|
+
path: The path where this callback should be registered (default: "/sip")
|
1025
|
+
"""
|
1026
|
+
# Normalize the path (remove trailing slash)
|
1027
|
+
normalized_path = path.rstrip("/")
|
1028
|
+
if not normalized_path.startswith("/"):
|
1029
|
+
normalized_path = f"/{normalized_path}"
|
1030
|
+
|
1031
|
+
# Store the callback with the normalized path (without trailing slash)
|
1032
|
+
self.log.info("registering_routing_callback", path=normalized_path)
|
1033
|
+
if not hasattr(self, '_routing_callbacks'):
|
1034
|
+
self._routing_callbacks = {}
|
1035
|
+
self._routing_callbacks[normalized_path] = callback_fn
|
1036
|
+
|
1037
|
+
def set_dynamic_config_callback(self, callback: Callable[[dict, dict, dict, 'AgentBase'], None]) -> 'AgentBase':
|
1038
|
+
"""
|
1039
|
+
Set a callback function for dynamic agent configuration
|
1040
|
+
|
1041
|
+
This callback receives the actual agent instance, allowing you to dynamically
|
1042
|
+
configure ANY aspect of the agent including adding skills, modifying prompts,
|
1043
|
+
changing parameters, etc. based on request data.
|
1044
|
+
|
1045
|
+
Args:
|
1046
|
+
callback: Function that takes (query_params, body_params, headers, agent)
|
1047
|
+
and configures the agent using any available methods like:
|
1048
|
+
- agent.add_skill(...)
|
1049
|
+
- agent.add_language(...)
|
1050
|
+
- agent.prompt_add_section(...)
|
1051
|
+
- agent.set_params(...)
|
1052
|
+
- agent.set_global_data(...)
|
1053
|
+
- agent.define_tool(...)
|
1054
|
+
|
1055
|
+
Example:
|
1056
|
+
def my_config(query_params, body_params, headers, agent):
|
1057
|
+
if query_params.get('tier') == 'premium':
|
1058
|
+
agent.add_skill("advanced_search")
|
1059
|
+
agent.add_language("English", "en-US", "premium_voice")
|
1060
|
+
agent.set_params({"end_of_speech_timeout": 500})
|
1061
|
+
agent.set_global_data({"tier": query_params.get('tier', 'standard')})
|
1062
|
+
|
1063
|
+
my_agent.set_dynamic_config_callback(my_config)
|
1064
|
+
"""
|
1065
|
+
self._dynamic_config_callback = callback
|
1066
|
+
return self
|
1067
|
+
|
1068
|
+
def manual_set_proxy_url(self, proxy_url: str) -> 'AgentBase':
|
1069
|
+
"""
|
1070
|
+
Manually set the proxy URL base for webhook callbacks
|
1071
|
+
|
1072
|
+
This can be called at runtime to set or update the proxy URL
|
1073
|
+
|
1074
|
+
Args:
|
1075
|
+
proxy_url: The base URL to use for webhooks (e.g., https://example.ngrok.io)
|
1076
|
+
|
1077
|
+
Returns:
|
1078
|
+
Self for method chaining
|
1079
|
+
"""
|
1080
|
+
if proxy_url:
|
1081
|
+
# Set on self
|
1082
|
+
self._proxy_url_base = proxy_url.rstrip('/')
|
1083
|
+
self._proxy_detection_done = True
|
1084
|
+
|
1085
|
+
# Set on parent if it has these attributes
|
1086
|
+
if hasattr(super(), '_proxy_url_base'):
|
1087
|
+
super()._proxy_url_base = self._proxy_url_base
|
1088
|
+
if hasattr(super(), '_proxy_detection_done'):
|
1089
|
+
super()._proxy_detection_done = True
|
1090
|
+
|
1091
|
+
self.log.info("proxy_url_manually_set", proxy_url_base=self._proxy_url_base)
|
1092
|
+
|
1093
|
+
return self
|
1094
|
+
|
1095
|
+
def setup_graceful_shutdown(self) -> None:
|
1096
|
+
"""
|
1097
|
+
Setup signal handlers for graceful shutdown (useful for Kubernetes)
|
1098
|
+
"""
|
1099
|
+
def signal_handler(signum, frame):
|
1100
|
+
self.log.info("shutdown_signal_received", signal=signum)
|
1101
|
+
|
1102
|
+
# Perform cleanup
|
1103
|
+
try:
|
1104
|
+
# Close any open resources
|
1105
|
+
if hasattr(self, '_session_manager'):
|
1106
|
+
# Could add cleanup logic here
|
1107
|
+
pass
|
1108
|
+
|
1109
|
+
self.log.info("cleanup_completed")
|
1110
|
+
except Exception as e:
|
1111
|
+
self.log.error("cleanup_error", error=str(e))
|
1112
|
+
finally:
|
1113
|
+
sys.exit(0)
|
1114
|
+
|
1115
|
+
# Register handlers for common termination signals
|
1116
|
+
signal.signal(signal.SIGTERM, signal_handler) # Kubernetes sends this
|
1117
|
+
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
|
1118
|
+
|
1119
|
+
self.log.debug("graceful_shutdown_handlers_registered")
|
1120
|
+
|
1121
|
+
def enable_debug_routes(self) -> 'AgentBase':
|
1122
|
+
"""
|
1123
|
+
Enable debug routes for testing and development
|
1124
|
+
|
1125
|
+
Returns:
|
1126
|
+
Self for method chaining
|
1127
|
+
"""
|
1128
|
+
# Debug routes are automatically registered in _register_routes
|
1129
|
+
# This method exists for backward compatibility
|
1130
|
+
return self
|