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.
Files changed (64) hide show
  1. signalwire_agents/__init__.py +1 -1
  2. signalwire_agents/agent_server.py +2 -1
  3. signalwire_agents/cli/config.py +61 -0
  4. signalwire_agents/cli/core/__init__.py +1 -0
  5. signalwire_agents/cli/core/agent_loader.py +254 -0
  6. signalwire_agents/cli/core/argparse_helpers.py +164 -0
  7. signalwire_agents/cli/core/dynamic_config.py +62 -0
  8. signalwire_agents/cli/execution/__init__.py +1 -0
  9. signalwire_agents/cli/execution/datamap_exec.py +437 -0
  10. signalwire_agents/cli/execution/webhook_exec.py +125 -0
  11. signalwire_agents/cli/output/__init__.py +1 -0
  12. signalwire_agents/cli/output/output_formatter.py +132 -0
  13. signalwire_agents/cli/output/swml_dump.py +177 -0
  14. signalwire_agents/cli/simulation/__init__.py +1 -0
  15. signalwire_agents/cli/simulation/data_generation.py +365 -0
  16. signalwire_agents/cli/simulation/data_overrides.py +187 -0
  17. signalwire_agents/cli/simulation/mock_env.py +271 -0
  18. signalwire_agents/cli/test_swaig.py +522 -2539
  19. signalwire_agents/cli/types.py +72 -0
  20. signalwire_agents/core/agent/__init__.py +1 -3
  21. signalwire_agents/core/agent/config/__init__.py +1 -3
  22. signalwire_agents/core/agent/prompt/manager.py +25 -7
  23. signalwire_agents/core/agent/tools/decorator.py +2 -0
  24. signalwire_agents/core/agent/tools/registry.py +8 -0
  25. signalwire_agents/core/agent_base.py +492 -3053
  26. signalwire_agents/core/function_result.py +31 -42
  27. signalwire_agents/core/mixins/__init__.py +28 -0
  28. signalwire_agents/core/mixins/ai_config_mixin.py +373 -0
  29. signalwire_agents/core/mixins/auth_mixin.py +287 -0
  30. signalwire_agents/core/mixins/prompt_mixin.py +345 -0
  31. signalwire_agents/core/mixins/serverless_mixin.py +368 -0
  32. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  33. signalwire_agents/core/mixins/state_mixin.py +219 -0
  34. signalwire_agents/core/mixins/tool_mixin.py +295 -0
  35. signalwire_agents/core/mixins/web_mixin.py +1130 -0
  36. signalwire_agents/core/skill_manager.py +3 -1
  37. signalwire_agents/core/swaig_function.py +10 -1
  38. signalwire_agents/core/swml_service.py +140 -58
  39. signalwire_agents/skills/README.md +452 -0
  40. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  41. signalwire_agents/skills/datasphere/README.md +210 -0
  42. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  43. signalwire_agents/skills/datetime/README.md +132 -0
  44. signalwire_agents/skills/joke/README.md +149 -0
  45. signalwire_agents/skills/math/README.md +161 -0
  46. signalwire_agents/skills/native_vector_search/skill.py +33 -13
  47. signalwire_agents/skills/play_background_file/README.md +218 -0
  48. signalwire_agents/skills/spider/README.md +236 -0
  49. signalwire_agents/skills/spider/__init__.py +4 -0
  50. signalwire_agents/skills/spider/skill.py +479 -0
  51. signalwire_agents/skills/swml_transfer/README.md +395 -0
  52. signalwire_agents/skills/swml_transfer/__init__.py +1 -0
  53. signalwire_agents/skills/swml_transfer/skill.py +257 -0
  54. signalwire_agents/skills/weather_api/README.md +178 -0
  55. signalwire_agents/skills/web_search/README.md +163 -0
  56. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  57. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/METADATA +47 -2
  58. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/RECORD +62 -22
  59. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/entry_points.txt +1 -1
  60. signalwire_agents/core/agent/config/ephemeral.py +0 -176
  61. signalwire_agents-0.1.23.data/data/schema.json +0 -5611
  62. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/WHEEL +0 -0
  63. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.24.dist-info}/licenses/LICENSE +0 -0
  64. {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