signalwire-agents 0.1.13__py3-none-any.whl → 1.0.17.dev4__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 (143) hide show
  1. signalwire_agents/__init__.py +99 -15
  2. signalwire_agents/agent_server.py +248 -60
  3. signalwire_agents/agents/bedrock.py +296 -0
  4. signalwire_agents/cli/__init__.py +9 -0
  5. signalwire_agents/cli/build_search.py +951 -41
  6. signalwire_agents/cli/config.py +80 -0
  7. signalwire_agents/cli/core/__init__.py +10 -0
  8. signalwire_agents/cli/core/agent_loader.py +470 -0
  9. signalwire_agents/cli/core/argparse_helpers.py +179 -0
  10. signalwire_agents/cli/core/dynamic_config.py +71 -0
  11. signalwire_agents/cli/core/service_loader.py +303 -0
  12. signalwire_agents/cli/dokku.py +2320 -0
  13. signalwire_agents/cli/execution/__init__.py +10 -0
  14. signalwire_agents/cli/execution/datamap_exec.py +446 -0
  15. signalwire_agents/cli/execution/webhook_exec.py +134 -0
  16. signalwire_agents/cli/init_project.py +2636 -0
  17. signalwire_agents/cli/output/__init__.py +10 -0
  18. signalwire_agents/cli/output/output_formatter.py +255 -0
  19. signalwire_agents/cli/output/swml_dump.py +186 -0
  20. signalwire_agents/cli/simulation/__init__.py +10 -0
  21. signalwire_agents/cli/simulation/data_generation.py +374 -0
  22. signalwire_agents/cli/simulation/data_overrides.py +200 -0
  23. signalwire_agents/cli/simulation/mock_env.py +282 -0
  24. signalwire_agents/cli/swaig_test_wrapper.py +52 -0
  25. signalwire_agents/cli/test_swaig.py +566 -2366
  26. signalwire_agents/cli/types.py +81 -0
  27. signalwire_agents/core/__init__.py +2 -2
  28. signalwire_agents/core/agent/__init__.py +12 -0
  29. signalwire_agents/core/agent/config/__init__.py +12 -0
  30. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  31. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  32. signalwire_agents/core/agent/prompt/__init__.py +14 -0
  33. signalwire_agents/core/agent/prompt/manager.py +306 -0
  34. signalwire_agents/core/agent/routing/__init__.py +9 -0
  35. signalwire_agents/core/agent/security/__init__.py +9 -0
  36. signalwire_agents/core/agent/swml/__init__.py +9 -0
  37. signalwire_agents/core/agent/tools/__init__.py +15 -0
  38. signalwire_agents/core/agent/tools/decorator.py +97 -0
  39. signalwire_agents/core/agent/tools/registry.py +210 -0
  40. signalwire_agents/core/agent_base.py +845 -2916
  41. signalwire_agents/core/auth_handler.py +233 -0
  42. signalwire_agents/core/config_loader.py +259 -0
  43. signalwire_agents/core/contexts.py +418 -0
  44. signalwire_agents/core/data_map.py +3 -15
  45. signalwire_agents/core/function_result.py +116 -44
  46. signalwire_agents/core/logging_config.py +162 -18
  47. signalwire_agents/core/mixins/__init__.py +28 -0
  48. signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
  49. signalwire_agents/core/mixins/auth_mixin.py +280 -0
  50. signalwire_agents/core/mixins/prompt_mixin.py +358 -0
  51. signalwire_agents/core/mixins/serverless_mixin.py +460 -0
  52. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  53. signalwire_agents/core/mixins/state_mixin.py +153 -0
  54. signalwire_agents/core/mixins/tool_mixin.py +230 -0
  55. signalwire_agents/core/mixins/web_mixin.py +1142 -0
  56. signalwire_agents/core/security_config.py +333 -0
  57. signalwire_agents/core/skill_base.py +84 -1
  58. signalwire_agents/core/skill_manager.py +62 -20
  59. signalwire_agents/core/swaig_function.py +18 -5
  60. signalwire_agents/core/swml_builder.py +207 -11
  61. signalwire_agents/core/swml_handler.py +27 -21
  62. signalwire_agents/core/swml_renderer.py +123 -312
  63. signalwire_agents/core/swml_service.py +171 -203
  64. signalwire_agents/mcp_gateway/__init__.py +29 -0
  65. signalwire_agents/mcp_gateway/gateway_service.py +564 -0
  66. signalwire_agents/mcp_gateway/mcp_manager.py +513 -0
  67. signalwire_agents/mcp_gateway/session_manager.py +218 -0
  68. signalwire_agents/prefabs/concierge.py +0 -3
  69. signalwire_agents/prefabs/faq_bot.py +0 -3
  70. signalwire_agents/prefabs/info_gatherer.py +0 -3
  71. signalwire_agents/prefabs/receptionist.py +0 -3
  72. signalwire_agents/prefabs/survey.py +0 -3
  73. signalwire_agents/schema.json +9218 -5489
  74. signalwire_agents/search/__init__.py +7 -1
  75. signalwire_agents/search/document_processor.py +490 -31
  76. signalwire_agents/search/index_builder.py +307 -37
  77. signalwire_agents/search/migration.py +418 -0
  78. signalwire_agents/search/models.py +30 -0
  79. signalwire_agents/search/pgvector_backend.py +748 -0
  80. signalwire_agents/search/query_processor.py +162 -31
  81. signalwire_agents/search/search_engine.py +916 -35
  82. signalwire_agents/search/search_service.py +376 -53
  83. signalwire_agents/skills/README.md +452 -0
  84. signalwire_agents/skills/__init__.py +14 -2
  85. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  86. signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
  87. signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
  88. signalwire_agents/skills/datasphere/README.md +210 -0
  89. signalwire_agents/skills/datasphere/skill.py +84 -3
  90. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  91. signalwire_agents/skills/datasphere_serverless/__init__.py +9 -0
  92. signalwire_agents/skills/datasphere_serverless/skill.py +82 -1
  93. signalwire_agents/skills/datetime/README.md +132 -0
  94. signalwire_agents/skills/datetime/__init__.py +9 -0
  95. signalwire_agents/skills/datetime/skill.py +20 -7
  96. signalwire_agents/skills/joke/README.md +149 -0
  97. signalwire_agents/skills/joke/__init__.py +9 -0
  98. signalwire_agents/skills/joke/skill.py +21 -0
  99. signalwire_agents/skills/math/README.md +161 -0
  100. signalwire_agents/skills/math/__init__.py +9 -0
  101. signalwire_agents/skills/math/skill.py +18 -4
  102. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  103. signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
  104. signalwire_agents/skills/mcp_gateway/skill.py +421 -0
  105. signalwire_agents/skills/native_vector_search/README.md +210 -0
  106. signalwire_agents/skills/native_vector_search/__init__.py +9 -0
  107. signalwire_agents/skills/native_vector_search/skill.py +569 -101
  108. signalwire_agents/skills/play_background_file/README.md +218 -0
  109. signalwire_agents/skills/play_background_file/__init__.py +12 -0
  110. signalwire_agents/skills/play_background_file/skill.py +242 -0
  111. signalwire_agents/skills/registry.py +395 -40
  112. signalwire_agents/skills/spider/README.md +236 -0
  113. signalwire_agents/skills/spider/__init__.py +13 -0
  114. signalwire_agents/skills/spider/skill.py +598 -0
  115. signalwire_agents/skills/swml_transfer/README.md +395 -0
  116. signalwire_agents/skills/swml_transfer/__init__.py +10 -0
  117. signalwire_agents/skills/swml_transfer/skill.py +359 -0
  118. signalwire_agents/skills/weather_api/README.md +178 -0
  119. signalwire_agents/skills/weather_api/__init__.py +12 -0
  120. signalwire_agents/skills/weather_api/skill.py +191 -0
  121. signalwire_agents/skills/web_search/README.md +163 -0
  122. signalwire_agents/skills/web_search/__init__.py +9 -0
  123. signalwire_agents/skills/web_search/skill.py +586 -112
  124. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  125. signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
  126. signalwire_agents/skills/{wikipedia → wikipedia_search}/skill.py +33 -3
  127. signalwire_agents/web/__init__.py +17 -0
  128. signalwire_agents/web/web_service.py +559 -0
  129. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-agent-init.1 +400 -0
  130. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-search.1 +483 -0
  131. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/swaig-test.1 +308 -0
  132. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/METADATA +347 -215
  133. signalwire_agents-1.0.17.dev4.dist-info/RECORD +147 -0
  134. signalwire_agents-1.0.17.dev4.dist-info/entry_points.txt +6 -0
  135. signalwire_agents/core/state/file_state_manager.py +0 -219
  136. signalwire_agents/core/state/state_manager.py +0 -101
  137. signalwire_agents/skills/wikipedia/__init__.py +0 -9
  138. signalwire_agents-0.1.13.data/data/schema.json +0 -5611
  139. signalwire_agents-0.1.13.dist-info/RECORD +0 -67
  140. signalwire_agents-0.1.13.dist-info/entry_points.txt +0 -3
  141. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/WHEEL +0 -0
  142. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/licenses/LICENSE +0 -0
  143. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1142 @@
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 in ['lambda', 'google_cloud_function']:
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
+ if not call_id and "call" in body:
498
+ # Sometimes it might be nested under 'call'
499
+ call_id = body.get("call", {}).get("call_id")
500
+ req_log.debug("extracted_call_id_from_body", call_id=call_id, body_keys=list(body.keys()))
501
+ else:
502
+ # Get call_id from query params for GET
503
+ call_id = request.query_params.get("call_id")
504
+
505
+ # Add call_id to logger if any
506
+ if call_id:
507
+ req_log = req_log.bind(call_id=call_id)
508
+ req_log.debug("call_id_identified")
509
+
510
+ # Check if this is a callback path and we need to apply routing
511
+ if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
512
+ callback_fn = self._routing_callbacks[callback_path]
513
+
514
+ if request.method == "POST" and body:
515
+ req_log.debug("processing_routing_callback", path=callback_path)
516
+ # Call the routing callback
517
+ try:
518
+ route = callback_fn(request, body)
519
+ if route is not None:
520
+ req_log.info("routing_request", route=route)
521
+ # Return a redirect to the new route
522
+ return Response(
523
+ status_code=307, # 307 Temporary Redirect preserves the method and body
524
+ headers={"Location": route}
525
+ )
526
+ except Exception as e:
527
+ req_log.error("error_in_routing_callback", error=str(e))
528
+
529
+ # Allow subclasses to inspect/modify the request
530
+ modifications = None
531
+ try:
532
+ modifications = self.on_swml_request(body, callback_path, request)
533
+ if modifications:
534
+ req_log.debug("request_modifications_applied")
535
+ except Exception as e:
536
+ req_log.error("error_in_request_modifier", error=str(e))
537
+
538
+ # Render SWML
539
+ swml = self._render_swml(call_id, modifications)
540
+ req_log.debug("swml_rendered", swml_size=len(swml))
541
+
542
+ # Return as JSON
543
+ req_log.info("request_successful")
544
+ return Response(
545
+ content=swml,
546
+ media_type="application/json"
547
+ )
548
+ except Exception as e:
549
+ req_log.error("request_failed", error=str(e))
550
+ return Response(
551
+ content=json.dumps({"error": str(e)}),
552
+ status_code=500,
553
+ media_type="application/json"
554
+ )
555
+
556
+ async def _handle_debug_request(self, request: Request):
557
+ """Handle GET/POST requests to the debug endpoint"""
558
+ req_log = self.log.bind(
559
+ endpoint="debug",
560
+ method=request.method,
561
+ path=request.url.path
562
+ )
563
+
564
+ req_log.debug("endpoint_called")
565
+
566
+ try:
567
+ # Check auth
568
+ if not self._check_basic_auth(request):
569
+ req_log.warning("unauthorized_access_attempt")
570
+ return Response(
571
+ content=json.dumps({"error": "Unauthorized"}),
572
+ status_code=401,
573
+ headers={"WWW-Authenticate": "Basic"},
574
+ media_type="application/json"
575
+ )
576
+
577
+ # Get call_id from either query params (GET) or body (POST)
578
+ call_id = None
579
+ body = {}
580
+
581
+ if request.method == "POST":
582
+ try:
583
+ body = await request.json()
584
+ req_log.debug("request_body_received", body_size=len(str(body)))
585
+ call_id = body.get("call_id")
586
+ except Exception as e:
587
+ req_log.warning("error_parsing_request_body", error=str(e))
588
+ else:
589
+ call_id = request.query_params.get("call_id")
590
+
591
+ # Add call_id to logger if any
592
+ if call_id:
593
+ req_log = req_log.bind(call_id=call_id)
594
+ req_log.debug("call_id_identified")
595
+
596
+ # Allow subclasses to inspect/modify the request
597
+ modifications = None
598
+ try:
599
+ modifications = self.on_swml_request(body, None, request)
600
+ if modifications:
601
+ req_log.debug("request_modifications_applied")
602
+ except Exception as e:
603
+ req_log.error("error_in_request_modifier", error=str(e))
604
+
605
+ # Render SWML
606
+ swml = self._render_swml(call_id, modifications)
607
+ req_log.debug("swml_rendered", swml_size=len(swml))
608
+
609
+ # Return as JSON
610
+ req_log.info("request_successful")
611
+ return Response(
612
+ content=swml,
613
+ media_type="application/json",
614
+ headers={"X-Debug": "true"}
615
+ )
616
+ except Exception as e:
617
+ req_log.error("request_failed", error=str(e))
618
+ return Response(
619
+ content=json.dumps({"error": str(e)}),
620
+ status_code=500,
621
+ media_type="application/json"
622
+ )
623
+
624
+ async def _handle_swaig_request(self, request: Request, response: Response):
625
+ """Handle GET/POST requests to the SWAIG endpoint"""
626
+ req_log = self.log.bind(
627
+ endpoint="swaig",
628
+ method=request.method,
629
+ path=request.url.path
630
+ )
631
+
632
+ req_log.debug("endpoint_called")
633
+
634
+ try:
635
+ # Check auth
636
+ if not self._check_basic_auth(request):
637
+ req_log.warning("unauthorized_access_attempt")
638
+ response.headers["WWW-Authenticate"] = "Basic"
639
+ return Response(
640
+ content=json.dumps({"error": "Unauthorized"}),
641
+ status_code=401,
642
+ headers={"WWW-Authenticate": "Basic"},
643
+ media_type="application/json"
644
+ )
645
+
646
+ # Handle differently based on method
647
+ if request.method == "GET":
648
+ # For GET requests, return the SWML document (same as root endpoint)
649
+ call_id = request.query_params.get("call_id")
650
+ swml = self._render_swml(call_id)
651
+ req_log.debug("swml_rendered", swml_size=len(swml))
652
+ return Response(
653
+ content=swml,
654
+ media_type="application/json"
655
+ )
656
+
657
+ # For POST requests, process SWAIG function calls
658
+ try:
659
+ body = await request.json()
660
+ req_log.debug("request_body_received", body_size=len(str(body)))
661
+ if body:
662
+ req_log.debug("request_body", body=json.dumps(body))
663
+ except Exception as e:
664
+ req_log.error("error_parsing_request_body", error=str(e))
665
+ body = {}
666
+
667
+ # Extract function name
668
+ function_name = body.get("function")
669
+ if not function_name:
670
+ req_log.warning("missing_function_name")
671
+ return Response(
672
+ content=json.dumps({"error": "Missing function name"}),
673
+ status_code=400,
674
+ media_type="application/json"
675
+ )
676
+
677
+ # Add function info to logger
678
+ req_log = req_log.bind(function=function_name)
679
+ req_log.debug("function_call_received")
680
+
681
+ # Extract arguments
682
+ args = {}
683
+ if "argument" in body and isinstance(body["argument"], dict):
684
+ if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
685
+ args = body["argument"]["parsed"][0]
686
+ req_log.debug("parsed_arguments", args=json.dumps(args))
687
+ elif "raw" in body["argument"]:
688
+ try:
689
+ args = json.loads(body["argument"]["raw"])
690
+ req_log.debug("raw_arguments_parsed", args=json.dumps(args))
691
+ except Exception as e:
692
+ req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
693
+
694
+ # Get call_id from body
695
+ call_id = body.get("call_id")
696
+ if call_id:
697
+ req_log = req_log.bind(call_id=call_id)
698
+ req_log.debug("call_id_identified")
699
+
700
+ # SECURITY BYPASS FOR DEBUGGING - make all functions work regardless of token
701
+ # We'll log the attempt but allow it through
702
+ token = request.query_params.get("__token") or request.query_params.get("token") # Check __token first, fallback to token
703
+ if token:
704
+ req_log.debug("token_found", token_length=len(token))
705
+
706
+ # Check token validity but don't reject the request
707
+ if hasattr(self, '_session_manager') and function_name in self._tool_registry._swaig_functions:
708
+ is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
709
+ if is_valid:
710
+ req_log.debug("token_valid")
711
+ else:
712
+ # Log but continue anyway for debugging
713
+ req_log.warning("token_invalid")
714
+ if hasattr(self._session_manager, 'debug_token'):
715
+ debug_info = self._session_manager.debug_token(token)
716
+ req_log.debug("token_debug", debug=json.dumps(debug_info))
717
+
718
+ # Check if we need to use an ephemeral agent for dynamic configuration
719
+ agent_to_use = self
720
+ if self._dynamic_config_callback and request:
721
+ # Check query params to see if this request came from a dynamically configured agent
722
+ # This would have been preserved through add_swaig_query_params
723
+ # For example, conversation_type=chat would be in the query params
724
+ agent_to_use = self._create_ephemeral_copy()
725
+
726
+ try:
727
+ # Extract request data
728
+ query_params = dict(request.query_params)
729
+ headers = dict(request.headers)
730
+
731
+ # Call the dynamic config callback with the ephemeral agent
732
+ # Pass the body as body_params for context
733
+ self._dynamic_config_callback(query_params, body, headers, agent_to_use)
734
+
735
+ except Exception as e:
736
+ req_log.error("dynamic_config_error", error=str(e))
737
+
738
+ # Call the function
739
+ try:
740
+ result = agent_to_use.on_function_call(function_name, args, body)
741
+
742
+ # Convert result to dict if needed
743
+ if isinstance(result, SwaigFunctionResult):
744
+ result_dict = result.to_dict()
745
+ elif isinstance(result, dict):
746
+ result_dict = result
747
+ else:
748
+ result_dict = {"response": str(result)}
749
+
750
+ req_log.info("function_executed_successfully")
751
+ req_log.debug("function_result", result=json.dumps(result_dict))
752
+ return result_dict
753
+ except Exception as e:
754
+ req_log.error("function_execution_error", error=str(e))
755
+ return {"error": str(e), "function": function_name}
756
+
757
+ except Exception as e:
758
+ req_log.error("request_failed", error=str(e))
759
+ return Response(
760
+ content=json.dumps({"error": str(e)}),
761
+ status_code=500,
762
+ media_type="application/json"
763
+ )
764
+
765
+ async def _handle_post_prompt_request(self, request: Request):
766
+ """Handle GET/POST requests to the post_prompt endpoint"""
767
+ req_log = self.log.bind(
768
+ endpoint="post_prompt",
769
+ method=request.method,
770
+ path=request.url.path
771
+ )
772
+
773
+ # Only log if not suppressed
774
+ if not getattr(self, '_suppress_logs', False):
775
+ req_log.debug("endpoint_called")
776
+
777
+ try:
778
+ # Check auth
779
+ if not self._check_basic_auth(request):
780
+ req_log.warning("unauthorized_access_attempt")
781
+ return Response(
782
+ content=json.dumps({"error": "Unauthorized"}),
783
+ status_code=401,
784
+ headers={"WWW-Authenticate": "Basic"},
785
+ media_type="application/json"
786
+ )
787
+
788
+ # Extract call_id for use with token validation
789
+ call_id = request.query_params.get("call_id")
790
+
791
+ # For POST requests, try to also get call_id from body
792
+ if request.method == "POST":
793
+ try:
794
+ body_text = await request.body()
795
+ if body_text:
796
+ body_data = json.loads(body_text)
797
+ if call_id is None:
798
+ call_id = body_data.get("call_id")
799
+ # Save body_data for later use
800
+ setattr(request, "_post_prompt_body", body_data)
801
+ except Exception as e:
802
+ req_log.error("error_extracting_call_id", error=str(e))
803
+
804
+ # If we have a call_id, add it to the logger context
805
+ if call_id:
806
+ req_log = req_log.bind(call_id=call_id)
807
+
808
+ # Check token if provided
809
+ token = request.query_params.get("__token") or request.query_params.get("token") # Check __token first, fallback to token
810
+ token_validated = False
811
+
812
+ if token:
813
+ req_log.debug("token_found", token_length=len(token))
814
+
815
+ # Try to validate token, but continue processing regardless
816
+ if call_id and hasattr(self, '_session_manager'):
817
+ try:
818
+ is_valid = self._session_manager.validate_tool_token("post_prompt", token, call_id)
819
+ if is_valid:
820
+ req_log.debug("token_valid")
821
+ token_validated = True
822
+ else:
823
+ req_log.warning("invalid_token")
824
+ # Debug information for token validation issues
825
+ if hasattr(self._session_manager, 'debug_token'):
826
+ debug_info = self._session_manager.debug_token(token)
827
+ req_log.debug("token_debug", debug=json.dumps(debug_info))
828
+ except Exception as e:
829
+ req_log.error("token_validation_error", error=str(e))
830
+
831
+ # For GET requests, return the SWML document
832
+ if request.method == "GET":
833
+ # Check if we should use dynamic config via on_swml_request
834
+ modifications = self.on_swml_request(None, None, request)
835
+ swml = self._render_swml(call_id, modifications)
836
+ req_log.debug("swml_rendered", swml_size=len(swml))
837
+ return Response(
838
+ content=swml,
839
+ media_type="application/json"
840
+ )
841
+
842
+ # For POST requests, process the post-prompt data
843
+ try:
844
+ # Try to reuse the body we already parsed for call_id extraction
845
+ if hasattr(request, "_post_prompt_body"):
846
+ body = getattr(request, "_post_prompt_body")
847
+ else:
848
+ body = await request.json()
849
+
850
+ # Only log if not suppressed
851
+ if not getattr(self, '_suppress_logs', False):
852
+ req_log.debug("request_body_received", body_size=len(str(body)))
853
+ # Log the raw body directly (let the logger handle the JSON encoding)
854
+ req_log.info("post_prompt_body", body=body)
855
+ except Exception as e:
856
+ req_log.error("error_parsing_request_body", error=str(e))
857
+ body = {}
858
+
859
+ # Check if we need to use an ephemeral agent for dynamic configuration
860
+ agent_to_use = self
861
+ if self._dynamic_config_callback and request:
862
+ # Create ephemeral copy and apply dynamic config
863
+ agent_to_use = self._create_ephemeral_copy()
864
+
865
+ try:
866
+ # Extract request data
867
+ query_params = dict(request.query_params)
868
+ headers = dict(request.headers)
869
+
870
+ # Call the dynamic config callback with the ephemeral agent
871
+ self._dynamic_config_callback(query_params, body, headers, agent_to_use)
872
+
873
+ except Exception as e:
874
+ req_log.error("dynamic_config_error", error=str(e))
875
+
876
+ # Extract summary from the correct location in the request
877
+ summary = agent_to_use._find_summary_in_post_data(body, req_log)
878
+
879
+ # Call the summary handler with the summary and the full body
880
+ result = None
881
+ try:
882
+ if summary:
883
+ result = agent_to_use.on_summary(summary, body)
884
+ req_log.debug("summary_handler_called_successfully")
885
+ else:
886
+ # If no summary found but still want to process the data
887
+ result = agent_to_use.on_summary(None, body)
888
+ req_log.debug("summary_handler_called_with_null_summary")
889
+ except Exception as e:
890
+ req_log.error("error_in_summary_handler", error=str(e))
891
+
892
+ # For fetch_conversation, return the result from on_summary
893
+ # SignalWire expects conversation_summary in the response
894
+ action = body.get("action", "")
895
+ if action == "fetch_conversation" and result is not None:
896
+ req_log.info("request_successful", action=action, returning_result=True)
897
+ return result
898
+
899
+ # Return success for save/post actions
900
+ req_log.info("request_successful")
901
+ return {"success": True}
902
+ except Exception as e:
903
+ req_log.error("request_failed", error=str(e))
904
+ return Response(
905
+ content=json.dumps({"error": str(e)}),
906
+ status_code=500,
907
+ media_type="application/json"
908
+ )
909
+
910
+ async def _handle_check_for_input_request(self, request: Request):
911
+ """Handle GET/POST requests to the check_for_input endpoint"""
912
+ req_log = self.log.bind(
913
+ endpoint="check_for_input",
914
+ method=request.method,
915
+ path=request.url.path
916
+ )
917
+
918
+ req_log.debug("endpoint_called")
919
+
920
+ try:
921
+ # Check auth
922
+ if not self._check_basic_auth(request):
923
+ req_log.warning("unauthorized_access_attempt")
924
+ return Response(
925
+ content=json.dumps({"error": "Unauthorized"}),
926
+ status_code=401,
927
+ headers={"WWW-Authenticate": "Basic"},
928
+ media_type="application/json"
929
+ )
930
+
931
+ # For both GET and POST requests, process input check
932
+ conversation_id = None
933
+
934
+ if request.method == "POST":
935
+ try:
936
+ body = await request.json()
937
+ req_log.debug("request_body_received", body_size=len(str(body)))
938
+ conversation_id = body.get("conversation_id")
939
+ except Exception as e:
940
+ req_log.error("error_parsing_request_body", error=str(e))
941
+ else:
942
+ conversation_id = request.query_params.get("conversation_id")
943
+
944
+ if not conversation_id:
945
+ req_log.warning("missing_conversation_id")
946
+ return Response(
947
+ content=json.dumps({"error": "Missing conversation_id parameter"}),
948
+ status_code=400,
949
+ media_type="application/json"
950
+ )
951
+
952
+ # Here you would typically check for new input in some external system
953
+ # For this implementation, we'll return an empty result
954
+ return {
955
+ "status": "success",
956
+ "conversation_id": conversation_id,
957
+ "new_input": False,
958
+ "messages": []
959
+ }
960
+ except Exception as e:
961
+ req_log.error("request_failed", error=str(e))
962
+ return Response(
963
+ content=json.dumps({"error": str(e)}),
964
+ status_code=500,
965
+ media_type="application/json"
966
+ )
967
+
968
+ def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
969
+ """
970
+ Called when SWML is requested, with request data when available
971
+
972
+ This method overrides SWMLService's on_request to properly handle SWML generation
973
+ for AI Agents.
974
+
975
+ Args:
976
+ request_data: Optional dictionary containing the parsed POST body
977
+ callback_path: Optional callback path
978
+
979
+ Returns:
980
+ None to use the default SWML rendering (which will call _render_swml)
981
+ """
982
+ # Call on_swml_request for customization
983
+ if hasattr(self, 'on_swml_request') and callable(getattr(self, 'on_swml_request')):
984
+ return self.on_swml_request(request_data, callback_path, None)
985
+
986
+ # If no on_swml_request or it returned None, we'll proceed with default rendering
987
+ return None
988
+
989
+ def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None, request: Optional[Request] = None) -> Optional[dict]:
990
+ """
991
+ Customization point for subclasses to modify SWML based on request data
992
+
993
+ Args:
994
+ request_data: Optional dictionary containing the parsed POST body
995
+ callback_path: Optional callback path
996
+ request: Optional FastAPI Request object for accessing query params, headers, etc.
997
+
998
+ Returns:
999
+ Optional dict with modifications to apply to the SWML document
1000
+ """
1001
+ # Dynamic config is handled differently now - we don't modify the agent here
1002
+ # Instead, we'll return a marker that tells _render_swml to use an ephemeral copy
1003
+ # UNLESS we're already on an ephemeral agent (to prevent infinite recursion)
1004
+ #
1005
+ # IMPORTANT: We ALWAYS return the marker if we have a dynamic config callback,
1006
+ # even if request is None. This ensures the first request gets dynamic config too.
1007
+ self.log.debug("on_swml_request_called",
1008
+ has_dynamic_callback=bool(self._dynamic_config_callback),
1009
+ is_ephemeral=getattr(self, '_is_ephemeral', False),
1010
+ has_request=bool(request))
1011
+
1012
+ if self._dynamic_config_callback and not getattr(self, '_is_ephemeral', False):
1013
+ # Return a special marker that _render_swml will recognize
1014
+ self.log.debug("returning_ephemeral_marker")
1015
+ return {"__use_ephemeral_agent": True, "__request": request, "__request_data": request_data}
1016
+
1017
+ # Return None to indicate no SWML modifications needed
1018
+ return None
1019
+
1020
+ def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
1021
+ path: str = "/sip") -> None:
1022
+ """
1023
+ Register a callback function that will be called to determine routing
1024
+ based on POST data.
1025
+
1026
+ When a routing callback is registered, an endpoint at the specified path is automatically
1027
+ created that will handle requests. This endpoint will use the callback to
1028
+ determine if the request should be processed by this service or redirected.
1029
+
1030
+ The callback should take a request object and request body dictionary and return:
1031
+ - A route string if it should be routed to a different endpoint
1032
+ - None if normal processing should continue
1033
+
1034
+ Args:
1035
+ callback_fn: The callback function to register
1036
+ path: The path where this callback should be registered (default: "/sip")
1037
+ """
1038
+ # Normalize the path (remove trailing slash)
1039
+ normalized_path = path.rstrip("/")
1040
+ if not normalized_path.startswith("/"):
1041
+ normalized_path = f"/{normalized_path}"
1042
+
1043
+ # Store the callback with the normalized path (without trailing slash)
1044
+ self.log.info("registering_routing_callback", path=normalized_path)
1045
+ if not hasattr(self, '_routing_callbacks'):
1046
+ self._routing_callbacks = {}
1047
+ self._routing_callbacks[normalized_path] = callback_fn
1048
+
1049
+ def set_dynamic_config_callback(self, callback: Callable[[dict, dict, dict, 'AgentBase'], None]) -> 'AgentBase':
1050
+ """
1051
+ Set a callback function for dynamic agent configuration
1052
+
1053
+ This callback receives the actual agent instance, allowing you to dynamically
1054
+ configure ANY aspect of the agent including adding skills, modifying prompts,
1055
+ changing parameters, etc. based on request data.
1056
+
1057
+ Args:
1058
+ callback: Function that takes (query_params, body_params, headers, agent)
1059
+ and configures the agent using any available methods like:
1060
+ - agent.add_skill(...)
1061
+ - agent.add_language(...)
1062
+ - agent.prompt_add_section(...)
1063
+ - agent.set_params(...)
1064
+ - agent.set_global_data(...)
1065
+ - agent.define_tool(...)
1066
+
1067
+ Example:
1068
+ def my_config(query_params, body_params, headers, agent):
1069
+ if query_params.get('tier') == 'premium':
1070
+ agent.add_skill("advanced_search")
1071
+ agent.add_language("English", "en-US", "premium_voice")
1072
+ agent.set_params({"end_of_speech_timeout": 500})
1073
+ agent.set_global_data({"tier": query_params.get('tier', 'standard')})
1074
+
1075
+ my_agent.set_dynamic_config_callback(my_config)
1076
+ """
1077
+ self._dynamic_config_callback = callback
1078
+ return self
1079
+
1080
+ def manual_set_proxy_url(self, proxy_url: str) -> 'AgentBase':
1081
+ """
1082
+ Manually set the proxy URL base for webhook callbacks
1083
+
1084
+ This can be called at runtime to set or update the proxy URL
1085
+
1086
+ Args:
1087
+ proxy_url: The base URL to use for webhooks (e.g., https://example.ngrok.io)
1088
+
1089
+ Returns:
1090
+ Self for method chaining
1091
+ """
1092
+ if proxy_url:
1093
+ # Set on self
1094
+ self._proxy_url_base = proxy_url.rstrip('/')
1095
+ self._proxy_detection_done = True
1096
+
1097
+ # Set on parent if it has these attributes
1098
+ if hasattr(super(), '_proxy_url_base'):
1099
+ super()._proxy_url_base = self._proxy_url_base
1100
+ if hasattr(super(), '_proxy_detection_done'):
1101
+ super()._proxy_detection_done = True
1102
+
1103
+ self.log.info("proxy_url_manually_set", proxy_url_base=self._proxy_url_base)
1104
+
1105
+ return self
1106
+
1107
+ def setup_graceful_shutdown(self) -> None:
1108
+ """
1109
+ Setup signal handlers for graceful shutdown (useful for Kubernetes)
1110
+ """
1111
+ def signal_handler(signum, frame):
1112
+ self.log.info("shutdown_signal_received", signal=signum)
1113
+
1114
+ # Perform cleanup
1115
+ try:
1116
+ # Close any open resources
1117
+ if hasattr(self, '_session_manager'):
1118
+ # Could add cleanup logic here
1119
+ pass
1120
+
1121
+ self.log.info("cleanup_completed")
1122
+ except Exception as e:
1123
+ self.log.error("cleanup_error", error=str(e))
1124
+ finally:
1125
+ sys.exit(0)
1126
+
1127
+ # Register handlers for common termination signals
1128
+ signal.signal(signal.SIGTERM, signal_handler) # Kubernetes sends this
1129
+ signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
1130
+
1131
+ self.log.debug("graceful_shutdown_handlers_registered")
1132
+
1133
+ def enable_debug_routes(self) -> 'AgentBase':
1134
+ """
1135
+ Enable debug routes for testing and development
1136
+
1137
+ Returns:
1138
+ Self for method chaining
1139
+ """
1140
+ # Debug routes are automatically registered in _register_routes
1141
+ # This method exists for backward compatibility
1142
+ return self