signalwire-agents 0.1.6__py3-none-any.whl → 1.0.7__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 (140) hide show
  1. signalwire_agents/__init__.py +130 -4
  2. signalwire_agents/agent_server.py +438 -32
  3. signalwire_agents/agents/bedrock.py +296 -0
  4. signalwire_agents/cli/__init__.py +18 -0
  5. signalwire_agents/cli/build_search.py +1367 -0
  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/execution/__init__.py +10 -0
  13. signalwire_agents/cli/execution/datamap_exec.py +446 -0
  14. signalwire_agents/cli/execution/webhook_exec.py +134 -0
  15. signalwire_agents/cli/init_project.py +1225 -0
  16. signalwire_agents/cli/output/__init__.py +10 -0
  17. signalwire_agents/cli/output/output_formatter.py +255 -0
  18. signalwire_agents/cli/output/swml_dump.py +186 -0
  19. signalwire_agents/cli/simulation/__init__.py +10 -0
  20. signalwire_agents/cli/simulation/data_generation.py +374 -0
  21. signalwire_agents/cli/simulation/data_overrides.py +200 -0
  22. signalwire_agents/cli/simulation/mock_env.py +282 -0
  23. signalwire_agents/cli/swaig_test_wrapper.py +52 -0
  24. signalwire_agents/cli/test_swaig.py +809 -0
  25. signalwire_agents/cli/types.py +81 -0
  26. signalwire_agents/core/__init__.py +2 -2
  27. signalwire_agents/core/agent/__init__.py +12 -0
  28. signalwire_agents/core/agent/config/__init__.py +12 -0
  29. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  30. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  31. signalwire_agents/core/agent/prompt/__init__.py +14 -0
  32. signalwire_agents/core/agent/prompt/manager.py +306 -0
  33. signalwire_agents/core/agent/routing/__init__.py +9 -0
  34. signalwire_agents/core/agent/security/__init__.py +9 -0
  35. signalwire_agents/core/agent/swml/__init__.py +9 -0
  36. signalwire_agents/core/agent/tools/__init__.py +15 -0
  37. signalwire_agents/core/agent/tools/decorator.py +97 -0
  38. signalwire_agents/core/agent/tools/registry.py +210 -0
  39. signalwire_agents/core/agent_base.py +959 -2166
  40. signalwire_agents/core/auth_handler.py +233 -0
  41. signalwire_agents/core/config_loader.py +259 -0
  42. signalwire_agents/core/contexts.py +707 -0
  43. signalwire_agents/core/data_map.py +487 -0
  44. signalwire_agents/core/function_result.py +1150 -1
  45. signalwire_agents/core/logging_config.py +376 -0
  46. signalwire_agents/core/mixins/__init__.py +28 -0
  47. signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
  48. signalwire_agents/core/mixins/auth_mixin.py +287 -0
  49. signalwire_agents/core/mixins/prompt_mixin.py +358 -0
  50. signalwire_agents/core/mixins/serverless_mixin.py +368 -0
  51. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  52. signalwire_agents/core/mixins/state_mixin.py +153 -0
  53. signalwire_agents/core/mixins/tool_mixin.py +230 -0
  54. signalwire_agents/core/mixins/web_mixin.py +1134 -0
  55. signalwire_agents/core/security/session_manager.py +174 -86
  56. signalwire_agents/core/security_config.py +333 -0
  57. signalwire_agents/core/skill_base.py +200 -0
  58. signalwire_agents/core/skill_manager.py +244 -0
  59. signalwire_agents/core/swaig_function.py +33 -9
  60. signalwire_agents/core/swml_builder.py +212 -12
  61. signalwire_agents/core/swml_handler.py +43 -13
  62. signalwire_agents/core/swml_renderer.py +123 -297
  63. signalwire_agents/core/swml_service.py +277 -260
  64. signalwire_agents/prefabs/concierge.py +6 -2
  65. signalwire_agents/prefabs/info_gatherer.py +149 -33
  66. signalwire_agents/prefabs/receptionist.py +14 -22
  67. signalwire_agents/prefabs/survey.py +6 -2
  68. signalwire_agents/schema.json +9218 -5489
  69. signalwire_agents/search/__init__.py +137 -0
  70. signalwire_agents/search/document_processor.py +1223 -0
  71. signalwire_agents/search/index_builder.py +804 -0
  72. signalwire_agents/search/migration.py +418 -0
  73. signalwire_agents/search/models.py +30 -0
  74. signalwire_agents/search/pgvector_backend.py +752 -0
  75. signalwire_agents/search/query_processor.py +502 -0
  76. signalwire_agents/search/search_engine.py +1264 -0
  77. signalwire_agents/search/search_service.py +574 -0
  78. signalwire_agents/skills/README.md +452 -0
  79. signalwire_agents/skills/__init__.py +23 -0
  80. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  81. signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
  82. signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
  83. signalwire_agents/skills/datasphere/README.md +210 -0
  84. signalwire_agents/skills/datasphere/__init__.py +12 -0
  85. signalwire_agents/skills/datasphere/skill.py +310 -0
  86. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  87. signalwire_agents/skills/datasphere_serverless/__init__.py +10 -0
  88. signalwire_agents/skills/datasphere_serverless/skill.py +237 -0
  89. signalwire_agents/skills/datetime/README.md +132 -0
  90. signalwire_agents/skills/datetime/__init__.py +10 -0
  91. signalwire_agents/skills/datetime/skill.py +126 -0
  92. signalwire_agents/skills/joke/README.md +149 -0
  93. signalwire_agents/skills/joke/__init__.py +10 -0
  94. signalwire_agents/skills/joke/skill.py +109 -0
  95. signalwire_agents/skills/math/README.md +161 -0
  96. signalwire_agents/skills/math/__init__.py +10 -0
  97. signalwire_agents/skills/math/skill.py +105 -0
  98. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  99. signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
  100. signalwire_agents/skills/mcp_gateway/skill.py +421 -0
  101. signalwire_agents/skills/native_vector_search/README.md +210 -0
  102. signalwire_agents/skills/native_vector_search/__init__.py +10 -0
  103. signalwire_agents/skills/native_vector_search/skill.py +820 -0
  104. signalwire_agents/skills/play_background_file/README.md +218 -0
  105. signalwire_agents/skills/play_background_file/__init__.py +12 -0
  106. signalwire_agents/skills/play_background_file/skill.py +242 -0
  107. signalwire_agents/skills/registry.py +459 -0
  108. signalwire_agents/skills/spider/README.md +236 -0
  109. signalwire_agents/skills/spider/__init__.py +13 -0
  110. signalwire_agents/skills/spider/skill.py +598 -0
  111. signalwire_agents/skills/swml_transfer/README.md +395 -0
  112. signalwire_agents/skills/swml_transfer/__init__.py +10 -0
  113. signalwire_agents/skills/swml_transfer/skill.py +359 -0
  114. signalwire_agents/skills/weather_api/README.md +178 -0
  115. signalwire_agents/skills/weather_api/__init__.py +12 -0
  116. signalwire_agents/skills/weather_api/skill.py +191 -0
  117. signalwire_agents/skills/web_search/README.md +163 -0
  118. signalwire_agents/skills/web_search/__init__.py +10 -0
  119. signalwire_agents/skills/web_search/skill.py +739 -0
  120. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  121. signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
  122. signalwire_agents/skills/wikipedia_search/skill.py +210 -0
  123. signalwire_agents/utils/__init__.py +14 -0
  124. signalwire_agents/utils/schema_utils.py +111 -44
  125. signalwire_agents/web/__init__.py +17 -0
  126. signalwire_agents/web/web_service.py +559 -0
  127. signalwire_agents-1.0.7.data/data/share/man/man1/sw-agent-init.1 +307 -0
  128. signalwire_agents-1.0.7.data/data/share/man/man1/sw-search.1 +483 -0
  129. signalwire_agents-1.0.7.data/data/share/man/man1/swaig-test.1 +308 -0
  130. signalwire_agents-1.0.7.dist-info/METADATA +992 -0
  131. signalwire_agents-1.0.7.dist-info/RECORD +142 -0
  132. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/WHEEL +1 -1
  133. signalwire_agents-1.0.7.dist-info/entry_points.txt +4 -0
  134. signalwire_agents/core/state/file_state_manager.py +0 -219
  135. signalwire_agents/core/state/state_manager.py +0 -101
  136. signalwire_agents-0.1.6.data/data/schema.json +0 -5611
  137. signalwire_agents-0.1.6.dist-info/METADATA +0 -199
  138. signalwire_agents-0.1.6.dist-info/RECORD +0 -34
  139. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/licenses/LICENSE +0 -0
  140. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env python3
1
2
  """
2
3
  Copyright (c) 2025 SignalWire
3
4
 
@@ -7,11 +8,9 @@ Licensed under the MIT License.
7
8
  See LICENSE file in the project root for full license information.
8
9
  """
9
10
 
11
+ # -*- coding: utf-8 -*-
10
12
  """
11
- SWMLService - Base class for SWML document creation and serving
12
-
13
- This class provides the foundation for creating and serving SWML documents.
14
- It handles schema validation, document creation, and web service functionality.
13
+ Base SWML Service for SignalWire Agents
15
14
  """
16
15
 
17
16
  import os
@@ -25,51 +24,11 @@ import types
25
24
  from typing import Dict, List, Any, Optional, Union, Callable, Tuple, Type
26
25
  from urllib.parse import urlparse
27
26
 
28
- # Import and configure structlog
29
- try:
30
- import structlog
31
-
32
- # Only configure if not already configured
33
- if not hasattr(structlog, "_configured") or not structlog._configured:
34
- structlog.configure(
35
- processors=[
36
- structlog.stdlib.filter_by_level,
37
- structlog.stdlib.add_logger_name,
38
- structlog.stdlib.add_log_level,
39
- structlog.stdlib.PositionalArgumentsFormatter(),
40
- structlog.processors.TimeStamper(fmt="iso"),
41
- structlog.processors.StackInfoRenderer(),
42
- structlog.processors.format_exc_info,
43
- structlog.processors.UnicodeDecoder(),
44
- structlog.processors.JSONRenderer()
45
- ],
46
- context_class=dict,
47
- logger_factory=structlog.stdlib.LoggerFactory(),
48
- wrapper_class=structlog.stdlib.BoundLogger,
49
- cache_logger_on_first_use=True,
50
- )
51
-
52
- # Set up root logger with structlog
53
- logging.basicConfig(
54
- format="%(message)s",
55
- stream=sys.stdout,
56
- level=logging.INFO,
57
- )
58
-
59
- # Mark as configured to avoid duplicate configuration
60
- structlog._configured = True
61
-
62
- # Create the module logger
63
- logger = structlog.get_logger("swml_service")
64
-
65
- except ImportError:
66
- # Fallback to standard logging if structlog is not available
67
- logging.basicConfig(
68
- level=logging.INFO,
69
- format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
70
- stream=sys.stdout
71
- )
72
- logger = logging.getLogger("swml_service")
27
+ # Import centralized logging system
28
+ from signalwire_agents.core.logging_config import get_logger
29
+
30
+ # Create the module logger using centralized system
31
+ logger = get_logger("swml_service")
73
32
 
74
33
  try:
75
34
  import fastapi
@@ -83,6 +42,7 @@ except ImportError:
83
42
 
84
43
  from signalwire_agents.utils.schema_utils import SchemaUtils
85
44
  from signalwire_agents.core.swml_handler import VerbHandlerRegistry, SWMLVerbHandler
45
+ from signalwire_agents.core.security_config import SecurityConfig
86
46
 
87
47
 
88
48
  class SWMLService:
@@ -106,7 +66,8 @@ class SWMLService:
106
66
  host: str = "0.0.0.0",
107
67
  port: int = 3000,
108
68
  basic_auth: Optional[Tuple[str, str]] = None,
109
- schema_path: Optional[str] = None
69
+ schema_path: Optional[str] = None,
70
+ config_file: Optional[str] = None
110
71
  ):
111
72
  """
112
73
  Initialize a new SWML service
@@ -118,21 +79,34 @@ class SWMLService:
118
79
  port: Port to bind the web server to
119
80
  basic_auth: Optional (username, password) tuple for basic auth
120
81
  schema_path: Optional path to the schema file
82
+ config_file: Optional path to configuration file
121
83
  """
122
84
  self.name = name
123
85
  self.route = route.rstrip("/") # Ensure no trailing slash
124
86
  self.host = host
125
87
  self.port = port
126
- self.ssl_enabled = False
127
- self.domain = None
88
+
89
+ # Initialize logger for this instance FIRST before using it
90
+ self.log = logger.bind(service=name)
91
+
92
+ # Load unified security configuration with optional config file
93
+ self.security = SecurityConfig(config_file=config_file, service_name=name)
94
+ self.security.log_config("SWMLService")
95
+
96
+ # For backward compatibility, expose SSL settings as instance attributes
97
+ self.ssl_enabled = self.security.ssl_enabled
98
+ self.domain = self.security.domain
99
+ self.ssl_cert_path = self.security.ssl_cert_path
100
+ self.ssl_key_path = self.security.ssl_key_path
128
101
 
129
102
  # Initialize proxy detection attributes
130
103
  self._proxy_url_base = os.environ.get('SWML_PROXY_URL_BASE')
104
+ self._proxy_url_base_from_env = bool(self._proxy_url_base) # Track if it came from environment
105
+ if self._proxy_url_base:
106
+ self.log.warning("SWML_PROXY_URL_BASE is set in environment - This overrides SSL configuration and port settings. Remove this variable to use automatic detection.",
107
+ proxy_url_base=self._proxy_url_base)
131
108
  self._proxy_detection_done = False
132
109
  self._proxy_debug = os.environ.get('SWML_PROXY_DEBUG', '').lower() in ('true', '1', 'yes')
133
-
134
- # Initialize logger for this instance
135
- self.log = logger.bind(service=name)
136
110
  self.log.info("service_initializing", route=self.route, host=host, port=port)
137
111
 
138
112
  # Set basic auth credentials
@@ -140,18 +114,8 @@ class SWMLService:
140
114
  # Use provided credentials
141
115
  self._basic_auth = basic_auth
142
116
  else:
143
- # Check environment variables first
144
- env_user = os.environ.get('SWML_BASIC_AUTH_USER')
145
- env_pass = os.environ.get('SWML_BASIC_AUTH_PASSWORD')
146
-
147
- if env_user and env_pass:
148
- # Use environment variables
149
- self._basic_auth = (env_user, env_pass)
150
- else:
151
- # Generate random credentials as fallback
152
- username = f"user_{secrets.token_hex(4)}"
153
- password = secrets.token_urlsafe(16)
154
- self._basic_auth = (username, password)
117
+ # Use unified security config for auth credentials
118
+ self._basic_auth = self.security.get_basic_auth()
155
119
 
156
120
  # Find the schema file if not provided
157
121
  if schema_path is None:
@@ -188,17 +152,21 @@ class SWMLService:
188
152
  """
189
153
  Create auto-vivified methods for all verbs at initialization time
190
154
  """
191
- print("Creating auto-vivified methods for all verbs")
155
+ self.log.debug("creating_verb_methods")
192
156
 
193
157
  # Get all verb names from the schema
158
+ if not self.schema_utils:
159
+ self.log.warning("no_schema_utils_available")
160
+ return
161
+
194
162
  verb_names = self.schema_utils.get_all_verb_names()
195
- print(f"Found {len(verb_names)} verbs in schema")
163
+ self.log.debug("found_verbs_in_schema", count=len(verb_names))
196
164
 
197
165
  # Create a method for each verb
198
166
  for verb_name in verb_names:
199
167
  # Skip verbs that already have specific methods
200
168
  if hasattr(self, verb_name):
201
- print(f"Skipping {verb_name} - already has a method")
169
+ self.log.debug("skipping_verb_has_method", verb=verb_name)
202
170
  continue
203
171
 
204
172
  # Handle sleep verb specially since it takes an integer directly
@@ -210,7 +178,7 @@ class SWMLService:
210
178
  Args:
211
179
  duration: The amount of time to sleep in milliseconds
212
180
  """
213
- print(f"Executing auto-vivified method for 'sleep'")
181
+ self.log.debug("executing_sleep_verb", duration=duration)
214
182
  # Sleep verb takes a direct integer parameter in SWML
215
183
  if duration is not None:
216
184
  return self_instance.add_verb("sleep", duration)
@@ -226,7 +194,7 @@ class SWMLService:
226
194
  # Also cache it for later
227
195
  self._verb_methods_cache[verb_name] = sleep_method
228
196
 
229
- print(f"Created special method for {verb_name}")
197
+ self.log.debug("created_special_method", verb=verb_name)
230
198
  continue
231
199
 
232
200
  # Generate the method implementation for normal verbs
@@ -235,7 +203,7 @@ class SWMLService:
235
203
  """
236
204
  Dynamically generated method for SWML verb
237
205
  """
238
- print(f"Executing auto-vivified method for '{name}'")
206
+ self.log.debug("executing_verb_method", verb=name, kwargs_count=len(kwargs))
239
207
  config = {}
240
208
  for key, value in kwargs.items():
241
209
  if value is not None:
@@ -260,7 +228,7 @@ class SWMLService:
260
228
  # Also cache it for later
261
229
  self._verb_methods_cache[verb_name] = method
262
230
 
263
- print(f"Created method for {verb_name}")
231
+ self.log.debug("created_verb_method", verb=verb_name)
264
232
 
265
233
  def __getattr__(self, name: str) -> Any:
266
234
  """
@@ -280,21 +248,26 @@ class SWMLService:
280
248
  Raises:
281
249
  AttributeError: If name is not a valid SWML verb
282
250
  """
283
- print(f"DEBUG: __getattr__ called for '{name}'")
251
+ self.log.debug("getattr_called", attribute=name)
284
252
 
285
253
  # Simple version to match our test script
286
254
  # First check if this is a valid SWML verb
255
+ if not self.schema_utils:
256
+ msg = f"'{self.__class__.__name__}' object has no attribute '{name}' (no schema available)"
257
+ self.log.debug("getattr_no_schema", attribute=name)
258
+ raise AttributeError(msg)
259
+
287
260
  verb_names = self.schema_utils.get_all_verb_names()
288
261
 
289
262
  if name in verb_names:
290
- print(f"DEBUG: '{name}' is a valid verb")
263
+ self.log.debug("getattr_valid_verb", verb=name)
291
264
 
292
265
  # Check if we already have this method in the cache
293
266
  if not hasattr(self, '_verb_methods_cache'):
294
267
  self._verb_methods_cache = {}
295
268
 
296
269
  if name in self._verb_methods_cache:
297
- print(f"DEBUG: Using cached method for '{name}'")
270
+ self.log.debug("getattr_cached_method", verb=name)
298
271
  return types.MethodType(self._verb_methods_cache[name], self)
299
272
 
300
273
  # Handle sleep verb specially since it takes an integer directly
@@ -306,7 +279,7 @@ class SWMLService:
306
279
  Args:
307
280
  duration: The amount of time to sleep in milliseconds
308
281
  """
309
- print(f"DEBUG: Executing auto-vivified method for 'sleep'")
282
+ self.log.debug("executing_sleep_method", duration=duration)
310
283
  # Sleep verb takes a direct integer parameter in SWML
311
284
  if duration is not None:
312
285
  return self_instance.add_verb("sleep", duration)
@@ -317,7 +290,7 @@ class SWMLService:
317
290
  raise TypeError("sleep() missing required argument: 'duration'")
318
291
 
319
292
  # Cache the method for future use
320
- print(f"DEBUG: Caching special method for '{name}'")
293
+ self.log.debug("caching_sleep_method", verb=name)
321
294
  self._verb_methods_cache[name] = sleep_method
322
295
 
323
296
  # Return the bound method
@@ -328,7 +301,7 @@ class SWMLService:
328
301
  """
329
302
  Dynamically generated method for SWML verb
330
303
  """
331
- print(f"DEBUG: Executing auto-vivified method for '{name}'")
304
+ self.log.debug("executing_dynamic_verb", verb=name, kwargs_count=len(kwargs))
332
305
  config = {}
333
306
  for key, value in kwargs.items():
334
307
  if value is not None:
@@ -343,7 +316,7 @@ class SWMLService:
343
316
  verb_method.__doc__ = f"Add the {name} verb to the document."
344
317
 
345
318
  # Cache the method for future use
346
- print(f"DEBUG: Caching method for '{name}'")
319
+ self.log.debug("caching_verb_method", verb=name)
347
320
  self._verb_methods_cache[name] = verb_method
348
321
 
349
322
  # Return the bound method
@@ -351,7 +324,7 @@ class SWMLService:
351
324
 
352
325
  # Not a valid verb
353
326
  msg = f"'{self.__class__.__name__}' object has no attribute '{name}'"
354
- print(f"DEBUG: {msg}")
327
+ self.log.debug("getattr_invalid_attribute", attribute=name, error=msg)
355
328
  raise AttributeError(msg)
356
329
 
357
330
  def _find_schema_path(self) -> Optional[str]:
@@ -565,58 +538,41 @@ class SWMLService:
565
538
 
566
539
  def as_router(self) -> APIRouter:
567
540
  """
568
- Get a FastAPI router for this service
541
+ Create a FastAPI router for this service
569
542
 
570
543
  Returns:
571
- FastAPI router
544
+ APIRouter: FastAPI router
572
545
  """
573
- router = APIRouter()
546
+ router = APIRouter(redirect_slashes=False)
574
547
 
575
- # Root endpoint - without trailing slash
576
- @router.get("")
577
- @router.post("")
578
- async def handle_root_no_slash(request: Request, response: Response):
579
- """Handle GET/POST requests to the root endpoint"""
580
- return await self._handle_request(request, response)
581
-
582
- # Root endpoint - with trailing slash
548
+ # Root endpoint with and without trailing slash
583
549
  @router.get("/")
584
550
  @router.post("/")
585
- async def handle_root_with_slash(request: Request, response: Response):
586
- """Handle GET/POST requests to the root endpoint with trailing slash"""
551
+ async def handle_root(request: Request, response: Response):
552
+ """Handle requests to the root endpoint"""
587
553
  return await self._handle_request(request, response)
588
554
 
589
- # Add endpoints for all registered routing callbacks
555
+ # Register routing callbacks as needed
590
556
  if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
591
557
  for callback_path, callback_fn in self._routing_callbacks.items():
592
- # Skip the root path as it's already handled
558
+ # Skip the root path which is already handled
593
559
  if callback_path == "/":
594
560
  continue
595
561
 
596
- # Register the endpoint without trailing slash
597
- @router.get(callback_path)
598
- @router.post(callback_path)
599
- async def handle_callback_no_slash(request: Request, response: Response, cb_path=callback_path):
600
- """Handle GET/POST requests to a registered callback path"""
601
- # Store the callback path in request state for _handle_request to use
562
+ # Register both versions: with and without trailing slash
563
+ path = callback_path.rstrip("/")
564
+ path_with_slash = f"{path}/"
565
+
566
+ @router.get(path)
567
+ @router.get(path_with_slash)
568
+ @router.post(path)
569
+ @router.post(path_with_slash)
570
+ async def handle_callback(request: Request, response: Response, cb_path=callback_path):
571
+ """Handle requests to callback endpoints"""
572
+ # Store the callback path in the request state
602
573
  request.state.callback_path = cb_path
603
574
  return await self._handle_request(request, response)
604
-
605
- # Register the endpoint with trailing slash if it doesn't already have one
606
- if not callback_path.endswith('/'):
607
- slash_path = f"{callback_path}/"
608
-
609
- @router.get(slash_path)
610
- @router.post(slash_path)
611
- async def handle_callback_with_slash(request: Request, response: Response, cb_path=callback_path):
612
- """Handle GET/POST requests to a registered callback path with trailing slash"""
613
- # Store the callback path in request state for _handle_request to use
614
- request.state.callback_path = cb_path
615
- return await self._handle_request(request, response)
616
-
617
- self.log.info("callback_endpoint_registered", path=callback_path)
618
575
 
619
- self._router = router
620
576
  return router
621
577
 
622
578
  def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
@@ -696,10 +652,8 @@ class SWMLService:
696
652
  Returns:
697
653
  Response with SWML document or error
698
654
  """
699
- # Auto-detect proxy on first request if not explicitly configured
700
- if not self._proxy_detection_done and not self._proxy_url_base:
701
- self._detect_proxy_from_request(request)
702
- self._proxy_detection_done = True
655
+ # Always detect proxy from current request - allows mixing direct and proxied access
656
+ self._detect_proxy_from_request(request)
703
657
 
704
658
  # Check auth
705
659
  if not self._check_basic_auth(request):
@@ -789,64 +743,126 @@ class SWMLService:
789
743
  Start a web server for this service
790
744
 
791
745
  Args:
792
- host: Optional host to override the default
793
- port: Optional port to override the default
746
+ host: Host to bind to (defaults to self.host)
747
+ port: Port to bind to (defaults to self.port)
794
748
  ssl_cert: Path to SSL certificate file
795
- ssl_key: Path to SSL private key file
796
- ssl_enabled: Whether to enable SSL/HTTPS
797
- domain: Domain name for the SSL certificate and external URLs
749
+ ssl_key: Path to SSL key file
750
+ ssl_enabled: Whether to enable SSL
751
+ domain: Domain name for SSL certificate
798
752
  """
799
753
  import uvicorn
800
754
 
801
- # Determine SSL settings from parameters or environment variables
802
- self.ssl_enabled = ssl_enabled if ssl_enabled is not None else os.environ.get('SWML_SSL_ENABLED', '').lower() in ('true', '1', 'yes')
803
- ssl_cert_path = ssl_cert or os.environ.get('SWML_SSL_CERT_PATH', '')
804
- ssl_key_path = ssl_key or os.environ.get('SWML_SSL_KEY_PATH', '')
805
- self.domain = domain or os.environ.get('SWML_DOMAIN', '')
755
+ # Store SSL configuration (override environment if explicitly provided)
756
+ if ssl_enabled is not None:
757
+ self.ssl_enabled = ssl_enabled
758
+ if domain is not None:
759
+ self.domain = domain
760
+
761
+ # Set SSL paths (use provided paths or fall back to environment)
762
+ ssl_cert_path = ssl_cert or getattr(self, 'ssl_cert_path', None)
763
+ ssl_key_path = ssl_key or getattr(self, 'ssl_key_path', None)
806
764
 
807
765
  # Validate SSL configuration if enabled
808
766
  if self.ssl_enabled:
809
- if not ssl_cert_path or not os.path.exists(ssl_cert_path):
810
- self.log.warning("ssl_cert_not_found", path=ssl_cert_path)
811
- self.ssl_enabled = False
812
- elif not ssl_key_path or not os.path.exists(ssl_key_path):
813
- self.log.warning("ssl_key_not_found", path=ssl_key_path)
767
+ is_valid, error = self.security.validate_ssl_config()
768
+ if not is_valid:
769
+ self.log.warning("ssl_config_invalid", error=error)
814
770
  self.ssl_enabled = False
815
771
  elif not self.domain:
816
772
  self.log.warning("ssl_domain_not_specified")
817
773
  # We'll continue, but URLs might not be correctly generated
818
774
 
819
775
  if self._app is None:
820
- app = FastAPI()
776
+ # Use redirect_slashes=False to be consistent with AgentBase
777
+ app = FastAPI(redirect_slashes=False)
821
778
  router = self.as_router()
822
- app.include_router(router, prefix=self.route)
779
+
780
+ # Normalize the route to ensure it starts with a slash and doesn't end with one
781
+ # This avoids the FastAPI error about prefixes ending with slashes
782
+ normalized_route = "/" + self.route.strip("/")
783
+
784
+ # Include router with the normalized prefix (handle root route special case)
785
+ if normalized_route == "/":
786
+ app.include_router(router)
787
+ else:
788
+ app.include_router(router, prefix=normalized_route)
789
+
790
+ # Add a catch-all route handler that will handle both /path and /path/ formats
791
+ # This provides the same behavior without using a trailing slash in the prefix
792
+ @app.get("/{full_path:path}")
793
+ @app.post("/{full_path:path}")
794
+ async def handle_all_routes(request: Request, response: Response, full_path: str):
795
+ # Get our route path without leading slash for comparison
796
+ route_path = normalized_route.lstrip("/")
797
+ route_with_slash = route_path + "/"
798
+
799
+ # Log the incoming path for debugging
800
+ self.log.debug("catch_all_route_hit", path=full_path, route=route_path)
801
+
802
+ # Check for exact match to our route (without trailing slash)
803
+ if full_path == route_path:
804
+ # This is our exact route - handle it directly
805
+ return await self._handle_request(request, response)
806
+
807
+ # Check for our route with a trailing slash or subpaths
808
+ elif full_path == route_with_slash or full_path.startswith(route_with_slash):
809
+ # This is our route with a trailing slash
810
+ # Extract the path after our route prefix
811
+ sub_path = full_path[len(route_with_slash):]
812
+
813
+ # Forward to the appropriate handler in our router
814
+ if not sub_path:
815
+ # Root endpoint
816
+ return await self._handle_request(request, response)
817
+
818
+ # Check for routing callbacks if there are any
819
+ if hasattr(self, '_routing_callbacks'):
820
+ for callback_path, callback_fn in self._routing_callbacks.items():
821
+ cb_path_clean = callback_path.strip("/")
822
+ if sub_path == cb_path_clean or sub_path.startswith(cb_path_clean + "/"):
823
+ # Store the callback path in request state for handlers to use
824
+ request.state.callback_path = callback_path
825
+ return await self._handle_request(request, response)
826
+
827
+ # Not our route or not matching our patterns
828
+ self.log.debug("no_route_match", path=full_path)
829
+ return {"error": "Path not found"}
830
+
831
+ # Log all routes for debugging
832
+ self.log.debug("registered_routes", service=self.name)
833
+ for route in app.routes:
834
+ if hasattr(route, "path"):
835
+ self.log.debug("route_registered", path=route.path)
836
+
823
837
  self._app = app
824
838
 
825
839
  host = host or self.host
826
840
  port = port or self.port
827
841
 
828
- # Print the auth credentials
842
+ # Get the auth credentials
829
843
  username, password = self._basic_auth
830
844
 
831
- # Use correct protocol and host in displayed URL
832
- protocol = "https" if self.ssl_enabled else "http"
833
- display_host = self.domain if self.ssl_enabled and self.domain else f"{host}:{port}"
845
+ # Get the proper URL using unified URL building
846
+ startup_url = self._build_full_url(include_auth=False)
834
847
 
835
848
  self.log.info("starting_server",
836
- url=f"{protocol}://{display_host}{self.route}",
849
+ url=startup_url,
837
850
  ssl_enabled=self.ssl_enabled,
838
851
  username=username,
839
852
  password_length=len(password))
840
853
 
854
+ # Print user-friendly startup message (keep for UX)
841
855
  print(f"Service '{self.name}' is available at:")
842
- print(f"URL: {protocol}://{display_host}{self.route}")
856
+ print(f"URL: {startup_url}")
857
+ print(f"URL with trailing slash: {startup_url}/")
843
858
  print(f"Basic Auth: {username}:{password}")
844
859
 
845
- # Check if SIP routing is enabled and print additional info
860
+ # Check if SIP routing is enabled and log additional info
846
861
  if self._routing_callbacks:
847
862
  print(f"Callback endpoints:")
848
863
  for path in self._routing_callbacks:
849
- print(f"{protocol}://{display_host}{path}")
864
+ callback_url = self._build_full_url(endpoint=path.lstrip('/'), include_auth=False)
865
+ print(f" {callback_url}")
850
866
 
851
867
  # Start uvicorn with or without SSL
852
868
  if self.ssl_enabled and ssl_cert_path and ssl_key_path:
@@ -922,149 +938,146 @@ class SWMLService:
922
938
 
923
939
  return username, password
924
940
 
925
- # Keep the existing methods for backward compatibility
926
-
927
- def add_answer_verb(self, max_duration: Optional[int] = None, codecs: Optional[str] = None) -> bool:
928
- """
929
- Add an answer verb to the current document
930
941
 
931
- Args:
932
- max_duration: Maximum duration in seconds
933
- codecs: Comma-separated list of codecs
934
-
935
- Returns:
936
- True if added successfully, False otherwise
937
- """
938
- config = {}
939
- if max_duration is not None:
940
- config["max_duration"] = max_duration
941
- if codecs is not None:
942
- config["codecs"] = codecs
943
-
944
- return self.add_verb("answer", config)
945
-
946
- def add_hangup_verb(self, reason: Optional[str] = None) -> bool:
942
+ def _get_base_url(self, include_auth: bool = True) -> str:
947
943
  """
948
- Add a hangup verb to the current document
944
+ Get the base URL for this service, using proxy info if available or falling back to configured values
945
+
946
+ This is the central method for URL building that handles both startup configuration
947
+ and per-request proxy detection.
949
948
 
950
949
  Args:
951
- reason: Hangup reason (hangup, busy, decline)
950
+ include_auth: Whether to include authentication credentials in the URL
952
951
 
953
952
  Returns:
954
- True if added successfully, False otherwise
953
+ Base URL string (protocol://[auth@]host[:port])
955
954
  """
956
- config = {}
957
- if reason is not None:
958
- config["reason"] = reason
955
+ # Debug logging to understand state
956
+ self.log.debug("_get_base_url called",
957
+ has_proxy_url_base=hasattr(self, '_proxy_url_base'),
958
+ proxy_url_base=getattr(self, '_proxy_url_base', None),
959
+ proxy_url_base_from_env=getattr(self, '_proxy_url_base_from_env', False),
960
+ env_var=os.environ.get('SWML_PROXY_URL_BASE'),
961
+ include_auth=include_auth,
962
+ caller=inspect.stack()[1].function if len(inspect.stack()) > 1 else "unknown")
963
+
964
+ # Check if we have proxy information from a request
965
+ if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
966
+ base = self._proxy_url_base.rstrip('/')
967
+ self.log.debug("Using proxy URL base", proxy_url_base=base)
968
+
969
+ # Add auth credentials if requested
970
+ if include_auth:
971
+ username, password = self._basic_auth
972
+ url = urlparse(base)
973
+ base = url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
959
974
 
960
- return self.add_verb("hangup", config)
961
-
962
- def add_ai_verb(self,
963
- prompt_text: Optional[str] = None,
964
- prompt_pom: Optional[List[Dict[str, Any]]] = None,
965
- post_prompt: Optional[str] = None,
966
- post_prompt_url: Optional[str] = None,
967
- swaig: Optional[Dict[str, Any]] = None,
968
- **kwargs) -> bool:
969
- """
970
- Add an AI verb to the current document
975
+ return base
971
976
 
972
- Args:
973
- prompt_text: Simple prompt text
974
- prompt_pom: Prompt object model
975
- post_prompt: Post-prompt text
976
- post_prompt_url: Post-prompt URL
977
- swaig: SWAIG configuration
978
- **kwargs: Additional parameters
979
-
980
- Returns:
981
- True if added successfully, False otherwise
982
- """
983
- config = {}
977
+ # No proxy, use configured values
978
+ # Determine protocol based on SSL settings
979
+ protocol = "https" if self.ssl_enabled else "http"
984
980
 
985
- # Handle prompt
986
- if prompt_text is not None:
987
- config["prompt"] = prompt_text
988
- elif prompt_pom is not None:
989
- config["prompt"] = prompt_pom
981
+ # Debug logging
982
+ self.log.debug("_get_base_url",
983
+ ssl_enabled=self.ssl_enabled,
984
+ domain=self.domain,
985
+ port=self.port,
986
+ protocol=protocol)
987
+
988
+ # Determine host part
989
+ if self.ssl_enabled and self.domain:
990
+ # Use domain for SSL
991
+ if protocol == "https" and self.port == 443:
992
+ host_part = self.domain # Don't include port for standard HTTPS
993
+ elif protocol == "http" and self.port == 80:
994
+ host_part = self.domain # Don't include port for standard HTTP
995
+ else:
996
+ host_part = f"{self.domain}:{self.port}"
997
+ self.log.debug("Using domain with port", domain=self.domain, port=self.port, host_part=host_part)
998
+ else:
999
+ # Use configured host
1000
+ if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
1001
+ host = "localhost"
1002
+ else:
1003
+ host = self.host
990
1004
 
991
- # Handle post prompt
992
- if post_prompt is not None:
993
- config["post_prompt"] = post_prompt
1005
+ # Include port unless it's the standard port for the protocol
1006
+ if (protocol == "https" and self.port == 443) or (protocol == "http" and self.port == 80):
1007
+ host_part = host
1008
+ else:
1009
+ host_part = f"{host}:{self.port}"
994
1010
 
995
- # Handle post prompt URL
996
- if post_prompt_url is not None:
997
- config["post_prompt_url"] = post_prompt_url
998
-
999
- # Handle SWAIG
1000
- if swaig is not None:
1001
- config["SWAIG"] = swaig
1002
-
1003
- # Handle additional parameters
1004
- for key, value in kwargs.items():
1005
- if value is not None:
1006
- config[key] = value
1007
-
1008
- return self.add_verb("ai", config)
1011
+ # Build base URL
1012
+ if include_auth:
1013
+ username, password = self._basic_auth
1014
+ base = f"{protocol}://{username}:{password}@{host_part}"
1015
+ else:
1016
+ base = f"{protocol}://{host_part}"
1009
1017
 
1010
- def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
1018
+ return base
1019
+
1020
+ def _build_full_url(self, endpoint: str = "", include_auth: bool = True, query_params: Optional[Dict[str, str]] = None) -> str:
1011
1021
  """
1012
- Helper method to build webhook URLs consistently
1022
+ Build the full URL for this service or a specific endpoint
1023
+
1024
+ This is the internal implementation used by both get_full_url (for AgentBase compatibility)
1025
+ and _build_webhook_url.
1013
1026
 
1014
1027
  Args:
1015
- endpoint: The endpoint path (e.g., "swaig", "post_prompt")
1028
+ endpoint: Optional endpoint path (e.g., "swaig", "post_prompt")
1029
+ include_auth: Whether to include authentication credentials in the URL
1016
1030
  query_params: Optional query parameters to append
1017
1031
 
1018
1032
  Returns:
1019
- Fully constructed webhook URL
1033
+ Full URL string
1020
1034
  """
1021
- # Base URL construction
1022
- if hasattr(self, '_proxy_url_base') and getattr(self, '_proxy_url_base', None):
1023
- # For proxy URLs
1024
- base = self._proxy_url_base.rstrip('/')
1025
-
1026
- # Always add auth credentials
1027
- username, password = self._basic_auth
1028
- url = urlparse(base)
1029
- base = url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
1035
+ # Get base URL using central method
1036
+ base = self._get_base_url(include_auth=include_auth)
1037
+
1038
+ # Build path
1039
+ if endpoint:
1040
+ # Ensure endpoint doesn't start with slash
1041
+ endpoint = endpoint.lstrip('/')
1042
+ # Add trailing slash to endpoint to prevent redirects
1043
+ if not endpoint.endswith('/'):
1044
+ endpoint = f"{endpoint}/"
1045
+ path = f"{self.route}/{endpoint}"
1030
1046
  else:
1031
- # Determine protocol based on SSL settings
1032
- protocol = "https" if getattr(self, 'ssl_enabled', False) else "http"
1033
-
1034
- # Use domain if available and SSL is enabled
1035
- if getattr(self, 'ssl_enabled', False) and getattr(self, 'domain', None):
1036
- host_part = self.domain
1037
- else:
1038
- # For local URLs
1039
- if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
1040
- host = "localhost"
1041
- else:
1042
- host = self.host
1043
-
1044
- host_part = f"{host}:{self.port}"
1045
-
1046
- # Always include auth credentials
1047
- username, password = self._basic_auth
1048
- base = f"{protocol}://{username}:{password}@{host_part}"
1047
+ # Just the route itself
1048
+ path = self.route if self.route != "/" else ""
1049
1049
 
1050
- # Ensure the endpoint has a trailing slash to prevent redirects
1051
- if endpoint and not endpoint.endswith('/'):
1052
- endpoint = f"{endpoint}/"
1053
-
1054
- # Simple path - use the route directly with the endpoint
1055
- path = f"{self.route}/{endpoint}"
1056
-
1057
1050
  # Construct full URL
1058
1051
  url = f"{base}{path}"
1059
1052
 
1060
- # Add query parameters if any (only if they have values)
1053
+ # Add query parameters if any
1061
1054
  if query_params:
1062
1055
  filtered_params = {k: v for k, v in query_params.items() if v}
1063
1056
  if filtered_params:
1064
1057
  params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
1065
1058
  url = f"{url}?{params}"
1059
+
1060
+ return url
1061
+
1062
+ def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
1063
+ """
1064
+ Helper method to build webhook URLs consistently
1065
+
1066
+ Args:
1067
+ endpoint: The endpoint path (e.g., "swaig", "post_prompt")
1068
+ query_params: Optional query parameters to append
1066
1069
 
1067
- return url
1070
+ Returns:
1071
+ Fully constructed webhook URL
1072
+ """
1073
+ self.log.debug("_build_webhook_url called",
1074
+ endpoint=endpoint,
1075
+ query_params=query_params,
1076
+ proxy_url_base=getattr(self, '_proxy_url_base', None),
1077
+ proxy_url_base_from_env=getattr(self, '_proxy_url_base_from_env', False))
1078
+
1079
+ # Use the central URL building method
1080
+ return self._build_full_url(endpoint=endpoint, include_auth=True, query_params=query_params)
1068
1081
 
1069
1082
  def _detect_proxy_from_request(self, request: Request) -> None:
1070
1083
  """
@@ -1074,6 +1087,10 @@ class SWMLService:
1074
1087
  Args:
1075
1088
  request: FastAPI Request object
1076
1089
  """
1090
+ # If SWML_PROXY_URL_BASE was already set (e.g., from environment), don't override it
1091
+ if self._proxy_url_base:
1092
+ return
1093
+
1077
1094
  # First check for standard X-Forwarded headers (used by most proxies including ngrok)
1078
1095
  forwarded_host = request.headers.get("X-Forwarded-Host")
1079
1096
  forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
@@ -1158,4 +1175,4 @@ class SWMLService:
1158
1175
  if proxy_url:
1159
1176
  self._proxy_url_base = proxy_url.rstrip('/')
1160
1177
  self.log.info("proxy_url_manually_set", proxy_url_base=self._proxy_url_base)
1161
- self._proxy_detection_done = True
1178
+ self._proxy_detection_done = True