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
@@ -25,7 +25,7 @@ import re
25
25
  import signal
26
26
  import sys
27
27
  from typing import Optional, Union, List, Dict, Any, Tuple, Callable, Type
28
- from urllib.parse import urlparse, urlencode
28
+ from urllib.parse import urlparse, urlencode, urlunparse
29
29
 
30
30
  try:
31
31
  import fastapi
@@ -44,211 +44,47 @@ except ImportError:
44
44
  "uvicorn is required. Install it with: pip install uvicorn"
45
45
  )
46
46
 
47
- try:
48
- import structlog
49
- # Configure structlog only if not already configured
50
- if not structlog.is_configured():
51
- structlog.configure(
52
- processors=[
53
- structlog.stdlib.filter_by_level,
54
- structlog.stdlib.add_logger_name,
55
- structlog.stdlib.add_log_level,
56
- structlog.stdlib.PositionalArgumentsFormatter(),
57
- structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S"),
58
- structlog.processors.StackInfoRenderer(),
59
- structlog.processors.format_exc_info,
60
- structlog.processors.UnicodeDecoder(),
61
- structlog.dev.ConsoleRenderer()
62
- ],
63
- context_class=dict,
64
- logger_factory=structlog.stdlib.LoggerFactory(),
65
- wrapper_class=structlog.stdlib.BoundLogger,
66
- cache_logger_on_first_use=True,
67
- )
68
- except ImportError:
69
- raise ImportError(
70
- "structlog is required. Install it with: pip install structlog"
71
- )
72
-
73
47
  from signalwire_agents.core.pom_builder import PomBuilder
74
48
  from signalwire_agents.core.swaig_function import SWAIGFunction
75
49
  from signalwire_agents.core.function_result import SwaigFunctionResult
76
50
  from signalwire_agents.core.swml_renderer import SwmlRenderer
77
51
  from signalwire_agents.core.security.session_manager import SessionManager
78
- from signalwire_agents.core.state import StateManager, FileStateManager
79
52
  from signalwire_agents.core.swml_service import SWMLService
80
53
  from signalwire_agents.core.swml_handler import AIVerbHandler
81
54
  from signalwire_agents.core.skill_manager import SkillManager
82
55
  from signalwire_agents.utils.schema_utils import SchemaUtils
83
56
  from signalwire_agents.core.logging_config import get_logger, get_execution_mode
84
57
 
85
- # Create a logger
86
- logger = structlog.get_logger("agent_base")
58
+ # Import refactored components
59
+ from signalwire_agents.core.agent.prompt.manager import PromptManager
60
+ from signalwire_agents.core.agent.tools.registry import ToolRegistry
61
+ from signalwire_agents.core.agent.tools.decorator import ToolDecorator
87
62
 
88
- class EphemeralAgentConfig:
89
- """
90
- An ephemeral configurator object that mimics AgentBase's configuration interface.
91
-
92
- This allows dynamic configuration callbacks to use the same familiar methods
93
- they would use during agent initialization, but for per-request configuration.
94
- """
95
-
96
- def __init__(self):
97
- # Initialize all configuration containers
98
- self._hints = []
99
- self._languages = []
100
- self._pronounce = []
101
- self._params = {}
102
- self._global_data = {}
103
- self._prompt_sections = []
104
- self._raw_prompt = None
105
- self._post_prompt = None
106
- self._function_includes = []
107
- self._native_functions = []
108
-
109
- # Mirror all the AgentBase configuration methods
110
-
111
- def add_hint(self, hint: str) -> 'EphemeralAgentConfig':
112
- """Add a simple string hint"""
113
- if isinstance(hint, str) and hint:
114
- self._hints.append(hint)
115
- return self
116
-
117
- def add_hints(self, hints: List[str]) -> 'EphemeralAgentConfig':
118
- """Add multiple string hints"""
119
- if hints and isinstance(hints, list):
120
- for hint in hints:
121
- if isinstance(hint, str) and hint:
122
- self._hints.append(hint)
123
- return self
124
-
125
- def add_language(self, name: str, code: str, voice: str, **kwargs) -> 'EphemeralAgentConfig':
126
- """Add a language configuration"""
127
- language = {
128
- "name": name,
129
- "code": code,
130
- "voice": voice
131
- }
132
-
133
- # Handle additional parameters
134
- for key, value in kwargs.items():
135
- if key in ["engine", "model", "speech_fillers", "function_fillers", "fillers"]:
136
- language[key] = value
137
-
138
- self._languages.append(language)
139
- return self
140
-
141
- def add_pronunciation(self, replace: str, with_text: str, ignore_case: bool = False) -> 'EphemeralAgentConfig':
142
- """Add a pronunciation rule"""
143
- if replace and with_text:
144
- rule = {"replace": replace, "with": with_text}
145
- if ignore_case:
146
- rule["ignore_case"] = True
147
- self._pronounce.append(rule)
148
- return self
149
-
150
- def set_param(self, key: str, value: Any) -> 'EphemeralAgentConfig':
151
- """Set a single AI parameter"""
152
- if key:
153
- self._params[key] = value
154
- return self
155
-
156
- def set_params(self, params: Dict[str, Any]) -> 'EphemeralAgentConfig':
157
- """Set multiple AI parameters"""
158
- if params and isinstance(params, dict):
159
- self._params.update(params)
160
- return self
161
-
162
- def set_global_data(self, data: Dict[str, Any]) -> 'EphemeralAgentConfig':
163
- """Set global data"""
164
- if data and isinstance(data, dict):
165
- self._global_data = data
166
- return self
167
-
168
- def update_global_data(self, data: Dict[str, Any]) -> 'EphemeralAgentConfig':
169
- """Update global data"""
170
- if data and isinstance(data, dict):
171
- self._global_data.update(data)
172
- return self
173
-
174
- def set_prompt_text(self, text: str) -> 'EphemeralAgentConfig':
175
- """Set raw prompt text"""
176
- self._raw_prompt = text
177
- return self
178
-
179
- def set_post_prompt(self, text: str) -> 'EphemeralAgentConfig':
180
- """Set post-prompt text"""
181
- self._post_prompt = text
182
- return self
183
-
184
- def prompt_add_section(self, title: str, body: str = "", bullets: Optional[List[str]] = None, **kwargs) -> 'EphemeralAgentConfig':
185
- """Add a prompt section"""
186
- section = {
187
- "title": title,
188
- "body": body
189
- }
190
- if bullets:
191
- section["bullets"] = bullets
192
-
193
- # Handle additional parameters
194
- for key, value in kwargs.items():
195
- if key in ["numbered", "numbered_bullets", "subsections"]:
196
- section[key] = value
197
-
198
- self._prompt_sections.append(section)
199
- return self
200
-
201
- def set_native_functions(self, function_names: List[str]) -> 'EphemeralAgentConfig':
202
- """Set native functions"""
203
- if function_names and isinstance(function_names, list):
204
- self._native_functions = [name for name in function_names if isinstance(name, str)]
205
- return self
206
-
207
- def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'EphemeralAgentConfig':
208
- """Add a function include"""
209
- if url and functions and isinstance(functions, list):
210
- include = {"url": url, "functions": functions}
211
- if meta_data and isinstance(meta_data, dict):
212
- include["meta_data"] = meta_data
213
- self._function_includes.append(include)
214
- return self
215
-
216
- def extract_config(self) -> Dict[str, Any]:
217
- """
218
- Extract the configuration as a dictionary for applying to the real agent.
219
-
220
- Returns:
221
- Dictionary containing all the configuration changes
222
- """
223
- config = {}
224
-
225
- if self._hints:
226
- config["hints"] = self._hints
227
- if self._languages:
228
- config["languages"] = self._languages
229
- if self._pronounce:
230
- config["pronounce"] = self._pronounce
231
- if self._params:
232
- config["params"] = self._params
233
- if self._global_data:
234
- config["global_data"] = self._global_data
235
- if self._function_includes:
236
- config["function_includes"] = self._function_includes
237
- if self._native_functions:
238
- config["native_functions"] = self._native_functions
239
-
240
- # Handle prompt sections - these should be applied to the agent's POM, not as raw config
241
- # The calling code should use these to build the prompt properly
242
- if self._prompt_sections:
243
- config["_ephemeral_prompt_sections"] = self._prompt_sections
244
- if self._raw_prompt:
245
- config["_ephemeral_raw_prompt"] = self._raw_prompt
246
- if self._post_prompt:
247
- config["_ephemeral_post_prompt"] = self._post_prompt
248
-
249
- return config
250
-
251
- class AgentBase(SWMLService):
63
+ # Import all mixins
64
+ from signalwire_agents.core.mixins.prompt_mixin import PromptMixin
65
+ from signalwire_agents.core.mixins.tool_mixin import ToolMixin
66
+ from signalwire_agents.core.mixins.web_mixin import WebMixin
67
+ from signalwire_agents.core.mixins.auth_mixin import AuthMixin
68
+ from signalwire_agents.core.mixins.skill_mixin import SkillMixin
69
+ from signalwire_agents.core.mixins.ai_config_mixin import AIConfigMixin
70
+ from signalwire_agents.core.mixins.serverless_mixin import ServerlessMixin
71
+ from signalwire_agents.core.mixins.state_mixin import StateMixin
72
+
73
+ # Create a logger using centralized system
74
+ logger = get_logger("agent_base")
75
+
76
+
77
+ class AgentBase(
78
+ AuthMixin,
79
+ WebMixin,
80
+ SWMLService,
81
+ PromptMixin,
82
+ ToolMixin,
83
+ SkillMixin,
84
+ AIConfigMixin,
85
+ ServerlessMixin,
86
+ StateMixin
87
+ ):
252
88
  """
253
89
  Base class for all SignalWire AI Agents.
254
90
 
@@ -273,23 +109,22 @@ class AgentBase(SWMLService):
273
109
  name: str,
274
110
  route: str = "/",
275
111
  host: str = "0.0.0.0",
276
- port: int = 3000,
112
+ port: Optional[int] = None,
277
113
  basic_auth: Optional[Tuple[str, str]] = None,
278
114
  use_pom: bool = True,
279
- enable_state_tracking: bool = False,
280
115
  token_expiry_secs: int = 3600,
281
116
  auto_answer: bool = True,
282
117
  record_call: bool = False,
283
118
  record_format: str = "mp4",
284
119
  record_stereo: bool = True,
285
- state_manager: Optional[StateManager] = None,
286
120
  default_webhook_url: Optional[str] = None,
287
121
  agent_id: Optional[str] = None,
288
122
  native_functions: Optional[List[str]] = None,
289
123
  schema_path: Optional[str] = None,
290
- suppress_logs: bool = False,
124
+ suppress_logs: bool = False,
291
125
  enable_post_prompt_override: bool = False,
292
- check_for_input_override: bool = False
126
+ check_for_input_override: bool = False,
127
+ config_file: Optional[str] = None
293
128
  ):
294
129
  """
295
130
  Initialize a new agent
@@ -301,13 +136,11 @@ class AgentBase(SWMLService):
301
136
  port: Port to bind the web server to
302
137
  basic_auth: Optional (username, password) tuple for basic auth
303
138
  use_pom: Whether to use POM for prompt building
304
- enable_state_tracking: Whether to register startup_hook and hangup_hook SWAIG functions to track conversation state
305
139
  token_expiry_secs: Seconds until tokens expire
306
140
  auto_answer: Whether to automatically answer calls
307
141
  record_call: Whether to record calls
308
142
  record_format: Recording format
309
143
  record_stereo: Whether to record in stereo
310
- state_manager: Optional state manager for this agent
311
144
  default_webhook_url: Optional default webhook URL for all SWAIG functions
312
145
  agent_id: Optional unique ID for this agent, generated if not provided
313
146
  native_functions: Optional list of native functions to include in the SWAIG object
@@ -315,6 +148,7 @@ class AgentBase(SWMLService):
315
148
  suppress_logs: Whether to suppress structured logs
316
149
  enable_post_prompt_override: Whether to enable post-prompt override
317
150
  check_for_input_override: Whether to enable check-for-input override
151
+ config_file: Optional path to configuration file
318
152
  """
319
153
  # Import SWMLService here to avoid circular imports
320
154
  from signalwire_agents.core.swml_service import SWMLService
@@ -322,14 +156,25 @@ class AgentBase(SWMLService):
322
156
  # If schema_path is not provided, we'll let SWMLService find it through its _find_schema_path method
323
157
  # which will be called in its __init__
324
158
 
159
+ # Load service configuration from config file before initializing SWMLService
160
+ service_config = self._load_service_config(config_file, name)
161
+
162
+ # Apply service config values, with constructor parameters taking precedence
163
+ final_route = route if route != "/" else service_config.get('route', route)
164
+ final_host = host if host != "0.0.0.0" else service_config.get('host', host)
165
+ # For port: use explicit param if provided, else config file, else let SWMLService use PORT env var
166
+ final_port = port if port is not None else service_config.get('port', None)
167
+ final_name = service_config.get('name', name)
168
+
325
169
  # Initialize the SWMLService base class
326
170
  super().__init__(
327
- name=name,
328
- route=route,
329
- host=host,
330
- port=port,
171
+ name=final_name,
172
+ route=final_route,
173
+ host=final_host,
174
+ port=final_port,
331
175
  basic_auth=basic_auth,
332
- schema_path=schema_path
176
+ schema_path=schema_path,
177
+ config_file=config_file
333
178
  )
334
179
 
335
180
  # Log the schema path if found and not suppressing logs
@@ -338,7 +183,7 @@ class AgentBase(SWMLService):
338
183
 
339
184
  # Setup logger for this instance
340
185
  self.log = logger.bind(agent=name)
341
- self.log.info("agent_initializing", route=route, host=host, port=port)
186
+ self.log.info("agent_initializing", agent=name, route=route, host=self.host, port=self.port)
342
187
 
343
188
  # Store agent-specific parameters
344
189
  self._default_webhook_url = default_webhook_url
@@ -352,8 +197,6 @@ class AgentBase(SWMLService):
352
197
 
353
198
  # Initialize prompt handling
354
199
  self._use_pom = use_pom
355
- self._raw_prompt = None
356
- self._post_prompt = None
357
200
 
358
201
  # Initialize POM if needed
359
202
  if self._use_pom:
@@ -369,11 +212,9 @@ class AgentBase(SWMLService):
369
212
  self.pom = None
370
213
 
371
214
  # Initialize tool registry (separate from SWMLService verb registry)
372
- self._swaig_functions = {}
373
215
 
374
216
  # Initialize session manager
375
217
  self._session_manager = SessionManager(token_expiry_secs=token_expiry_secs)
376
- self._enable_state_tracking = enable_state_tracking
377
218
 
378
219
  # URL override variables
379
220
  self._web_hook_url_override = None
@@ -388,21 +229,20 @@ class AgentBase(SWMLService):
388
229
  self._record_format = record_format
389
230
  self._record_stereo = record_stereo
390
231
 
232
+ # Initialize refactored managers early
233
+ self._prompt_manager = PromptManager(self)
234
+ self._tool_registry = ToolRegistry(self)
235
+
391
236
  # Process declarative PROMPT_SECTIONS if defined in subclass
392
237
  self._process_prompt_sections()
393
238
 
394
- # Initialize state manager
395
- self._state_manager = state_manager or FileStateManager()
396
239
 
397
240
  # Process class-decorated tools (using @AgentBase.tool)
398
- self._register_class_decorated_tools()
241
+ self._tool_registry.register_class_decorated_tools()
399
242
 
400
243
  # Add native_functions parameter
401
244
  self.native_functions = native_functions or []
402
245
 
403
- # Register state tracking tools if enabled
404
- if enable_state_tracking:
405
- self._register_state_tracking_tools()
406
246
 
407
247
  # Initialize new configuration containers
408
248
  self._hints = []
@@ -411,6 +251,9 @@ class AgentBase(SWMLService):
411
251
  self._params = {}
412
252
  self._global_data = {}
413
253
  self._function_includes = []
254
+ # Initialize LLM params as empty - only send if explicitly set
255
+ self._prompt_llm_params = {}
256
+ self._post_prompt_llm_params = {}
414
257
 
415
258
  # Dynamic configuration callback
416
259
  self._dynamic_config_callback = None
@@ -422,1045 +265,466 @@ class AgentBase(SWMLService):
422
265
  self._contexts_builder = None
423
266
  self._contexts_defined = False
424
267
 
425
- self.schema_utils = SchemaUtils(schema_path)
426
- if self.schema_utils and self.schema_utils.schema:
427
- self.log.debug("schema_loaded", path=self.schema_utils.schema_path)
268
+ # Initialize SWAIG query params for dynamic config
269
+ self._swaig_query_params = {}
270
+
271
+ # Initialize verb insertion points for call flow customization
272
+ self._pre_answer_verbs = [] # Verbs to run before answer (e.g., ringback, screening)
273
+ self._answer_config = {} # Configuration for the answer verb
274
+ self._post_answer_verbs = [] # Verbs to run after answer, before AI (e.g., announcements)
275
+ self._post_ai_verbs = [] # Verbs to run after AI ends (e.g., cleanup, transfers)
276
+
277
+ # Verb categories for pre-answer validation
278
+ _PRE_ANSWER_SAFE_VERBS = {
279
+ "transfer", "execute", "return", "label", "goto", "request",
280
+ "switch", "cond", "if", "eval", "set", "unset", "hangup",
281
+ "send_sms", "sleep", "stop_record_call", "stop_denoise", "stop_tap"
282
+ }
283
+ _AUTO_ANSWER_VERBS = {"play", "connect"}
284
+
285
+ @staticmethod
286
+ def _load_service_config(config_file: Optional[str], service_name: str) -> dict:
287
+ """Load service configuration from config file if available"""
288
+ from signalwire_agents.core.config_loader import ConfigLoader
289
+
290
+ # Find config file
291
+ if not config_file:
292
+ config_file = ConfigLoader.find_config_file(service_name)
293
+
294
+ if not config_file:
295
+ return {}
296
+
297
+ # Load config
298
+ config_loader = ConfigLoader([config_file])
299
+ if not config_loader.has_config():
300
+ return {}
428
301
 
302
+ # Get service section
303
+ service_config = config_loader.get_section('service')
304
+ if service_config:
305
+ return service_config
306
+
307
+ return {}
429
308
 
430
- def _process_prompt_sections(self):
309
+ def get_name(self) -> str:
431
310
  """
432
- Process declarative PROMPT_SECTIONS attribute from a subclass
311
+ Get agent name
433
312
 
434
- This auto-vivifies section methods and bootstraps the prompt
435
- from class declaration, allowing for declarative agents.
313
+ Returns:
314
+ Agent name
436
315
  """
437
- # Skip if no PROMPT_SECTIONS defined or not using POM
438
- cls = self.__class__
439
- if not hasattr(cls, 'PROMPT_SECTIONS') or cls.PROMPT_SECTIONS is None or not self._use_pom:
440
- return
441
-
442
- sections = cls.PROMPT_SECTIONS
443
-
444
- # If sections is a dictionary mapping section names to content
445
- if isinstance(sections, dict):
446
- for title, content in sections.items():
447
- # Handle different content types
448
- if isinstance(content, str):
449
- # Plain text - add as body
450
- self.prompt_add_section(title, body=content)
451
- elif isinstance(content, list) and content: # Only add if non-empty
452
- # List of strings - add as bullets
453
- self.prompt_add_section(title, bullets=content)
454
- elif isinstance(content, dict):
455
- # Dictionary with body/bullets/subsections
456
- body = content.get('body', '')
457
- bullets = content.get('bullets', [])
458
- numbered = content.get('numbered', False)
459
- numbered_bullets = content.get('numberedBullets', False)
460
-
461
- # Only create section if it has content
462
- if body or bullets or 'subsections' in content:
463
- # Create the section
464
- self.prompt_add_section(
465
- title,
466
- body=body,
467
- bullets=bullets if bullets else None,
468
- numbered=numbered,
469
- numbered_bullets=numbered_bullets
470
- )
471
-
472
- # Process subsections if any
473
- subsections = content.get('subsections', [])
474
- for subsection in subsections:
475
- if 'title' in subsection:
476
- sub_title = subsection['title']
477
- sub_body = subsection.get('body', '')
478
- sub_bullets = subsection.get('bullets', [])
479
-
480
- # Only add subsection if it has content
481
- if sub_body or sub_bullets:
482
- self.prompt_add_subsection(
483
- title,
484
- sub_title,
485
- body=sub_body,
486
- bullets=sub_bullets if sub_bullets else None
487
- )
488
- # If sections is a list of section objects, use the POM format directly
489
- elif isinstance(sections, list):
490
- if self.pom:
491
- # Process each section using auto-vivifying methods
492
- for section in sections:
493
- if 'title' in section:
494
- title = section['title']
495
- body = section.get('body', '')
496
- bullets = section.get('bullets', [])
497
- numbered = section.get('numbered', False)
498
- numbered_bullets = section.get('numberedBullets', False)
499
-
500
- # Only create section if it has content
501
- if body or bullets or 'subsections' in section:
502
- self.prompt_add_section(
503
- title,
504
- body=body,
505
- bullets=bullets if bullets else None,
506
- numbered=numbered,
507
- numbered_bullets=numbered_bullets
508
- )
509
-
510
- # Process subsections if any
511
- subsections = section.get('subsections', [])
512
- for subsection in subsections:
513
- if 'title' in subsection:
514
- sub_title = subsection['title']
515
- sub_body = subsection.get('body', '')
516
- sub_bullets = subsection.get('bullets', [])
517
-
518
- # Only add subsection if it has content
519
- if sub_body or sub_bullets:
520
- self.prompt_add_subsection(
521
- title,
522
- sub_title,
523
- body=sub_body,
524
- bullets=sub_bullets if sub_bullets else None
525
- )
526
-
527
- # ----------------------------------------------------------------------
528
- # Prompt Building Methods
529
- # ----------------------------------------------------------------------
316
+ return self.name
530
317
 
531
- def define_contexts(self) -> 'ContextBuilder':
318
+ def get_full_url(self, include_auth: bool = False) -> str:
532
319
  """
533
- Define contexts and steps for this agent (alternative to POM/prompt)
534
-
320
+ Get the full URL for this agent's endpoint
321
+
322
+ Args:
323
+ include_auth: Whether to include authentication credentials in the URL
324
+
535
325
  Returns:
536
- ContextBuilder for method chaining
537
-
538
- Note:
539
- Contexts can coexist with traditional prompts. The restriction is only
540
- that you can't mix POM sections with raw text in the main prompt.
326
+ Full URL including host, port, and route (with auth if requested)
541
327
  """
542
- # Import here to avoid circular imports
543
- from signalwire_agents.core.contexts import ContextBuilder
544
-
545
- if self._contexts_builder is None:
546
- self._contexts_builder = ContextBuilder(self)
547
- self._contexts_defined = True
328
+ # If _proxy_url_base is set (e.g., from request URL detection), use it
329
+ if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
330
+ base_url = self._proxy_url_base.rstrip('/')
331
+ # Add authentication if requested
332
+ if include_auth:
333
+ username, password = self.get_basic_auth_credentials()
334
+ if username and password:
335
+ from urllib.parse import urlparse, urlunparse
336
+ parsed = urlparse(base_url)
337
+ base_url = urlunparse((
338
+ parsed.scheme,
339
+ f"{username}:{password}@{parsed.netloc}",
340
+ parsed.path,
341
+ parsed.params,
342
+ parsed.query,
343
+ parsed.fragment
344
+ ))
345
+ return base_url
346
+
347
+ mode = get_execution_mode()
348
+
349
+ if mode == 'cgi':
350
+ protocol = 'https' if os.getenv('HTTPS') == 'on' else 'http'
351
+ host = os.getenv('HTTP_HOST') or os.getenv('SERVER_NAME') or 'localhost'
352
+ script_name = os.getenv('SCRIPT_NAME', '')
353
+ base_url = f"{protocol}://{host}{script_name}"
354
+ elif mode == 'lambda':
355
+ # AWS Lambda Function URL format
356
+ lambda_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
357
+ if lambda_url:
358
+ base_url = lambda_url.rstrip('/')
359
+ else:
360
+ # Fallback construction for Lambda
361
+ region = os.getenv('AWS_REGION', 'us-east-1')
362
+ function_name = os.getenv('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
363
+ base_url = f"https://{function_name}.lambda-url.{region}.on.aws"
364
+ elif mode == 'google_cloud_function':
365
+ # Google Cloud Functions URL format
366
+ project_id = os.getenv('GOOGLE_CLOUD_PROJECT') or os.getenv('GCP_PROJECT')
367
+ region = os.getenv('FUNCTION_REGION') or os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
368
+ service_name = os.getenv('K_SERVICE') or os.getenv('FUNCTION_TARGET', 'unknown')
369
+
370
+ if project_id:
371
+ base_url = f"https://{region}-{project_id}.cloudfunctions.net/{service_name}"
372
+ else:
373
+ # Fallback for local testing or incomplete environment
374
+ base_url = f"https://localhost:8080"
375
+ elif mode == 'azure_function':
376
+ # Azure Functions URL format
377
+ function_app_name = os.getenv('WEBSITE_SITE_NAME') or os.getenv('AZURE_FUNCTIONS_APP_NAME')
378
+ function_name = os.getenv('AZURE_FUNCTION_NAME', 'unknown')
379
+
380
+ if function_app_name:
381
+ base_url = f"https://{function_app_name}.azurewebsites.net/api/{function_name}"
382
+ else:
383
+ # Fallback for local testing
384
+ base_url = f"https://localhost:7071/api/{function_name}"
385
+ else:
386
+ # Server mode - use the SWMLService's unified URL building
387
+ # Build the full URL using the parent's method
388
+ base_url = self._build_full_url(endpoint="", include_auth=include_auth)
389
+ return base_url
390
+
391
+ # For serverless modes, add authentication if requested
392
+ if include_auth:
393
+ username, password = self.get_basic_auth_credentials()
394
+ if username and password:
395
+ # Parse URL to insert auth
396
+ from urllib.parse import urlparse, urlunparse
397
+ parsed = urlparse(base_url)
398
+ # Reconstruct with auth
399
+ base_url = urlunparse((
400
+ parsed.scheme,
401
+ f"{username}:{password}@{parsed.netloc}",
402
+ parsed.path,
403
+ parsed.params,
404
+ parsed.query,
405
+ parsed.fragment
406
+ ))
407
+
408
+ # Add route for serverless modes
409
+ if self.route and self.route != "/" and not base_url.endswith(self.route):
410
+ base_url = f"{base_url}/{self.route.lstrip('/')}"
548
411
 
549
- return self._contexts_builder
412
+ return base_url
550
413
 
551
- def _validate_prompt_mode_exclusivity(self):
414
+ def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
552
415
  """
553
- Validate that POM sections and raw text are not mixed in the main prompt
554
-
555
- Note: This does NOT prevent contexts from being used alongside traditional prompts
416
+ Called when a post-prompt summary is received
417
+
418
+ Args:
419
+ summary: The summary object or None if no summary was found
420
+ raw_data: The complete raw POST data from the request
421
+ """
422
+ # Default implementation does nothing
423
+ pass
424
+
425
+ # ==================== Call Flow Verb Insertion Methods ====================
426
+
427
+ def add_pre_answer_verb(self, verb_name: str, config: Dict[str, Any]) -> 'AgentBase':
428
+ """
429
+ Add a verb to run before the call is answered.
430
+
431
+ Pre-answer verbs execute while the call is still ringing. Only certain
432
+ verbs are safe to use before answering:
433
+
434
+ Safe verbs: transfer, execute, return, label, goto, request, switch,
435
+ cond, if, eval, set, unset, hangup, send_sms, sleep,
436
+ stop_record_call, stop_denoise, stop_tap
437
+
438
+ Verbs with auto_answer option (play, connect): Must include
439
+ "auto_answer": False in config to prevent automatic answering.
440
+
441
+ Args:
442
+ verb_name: The SWML verb name (e.g., "play", "sleep", "request")
443
+ config: Verb configuration dictionary
444
+
445
+ Returns:
446
+ Self for method chaining
447
+
448
+ Raises:
449
+ ValueError: If verb is not safe for pre-answer use
450
+
451
+ Example:
452
+ # Play ringback tone before answering
453
+ agent.add_pre_answer_verb("play", {
454
+ "urls": ["ring:us"],
455
+ "auto_answer": False
456
+ })
556
457
  """
557
- # Only check for mixing POM sections with raw text in the main prompt
558
- if self._raw_prompt and (self.pom and hasattr(self.pom, 'sections') and self.pom.sections):
458
+ # Validate verb is safe for pre-answer use
459
+ if verb_name in self._AUTO_ANSWER_VERBS:
460
+ if not config.get("auto_answer") is False:
461
+ self.log.warning(
462
+ "pre_answer_verb_will_answer",
463
+ verb=verb_name,
464
+ hint=f"Add 'auto_answer': False to prevent {verb_name} from answering the call"
465
+ )
466
+ elif verb_name not in self._PRE_ANSWER_SAFE_VERBS:
559
467
  raise ValueError(
560
- "Cannot mix raw text prompt with POM sections in the main prompt. "
561
- "Use either set_prompt_text() OR prompt_add_section() methods, not both."
468
+ f"Verb '{verb_name}' is not safe for pre-answer use. "
469
+ f"Safe verbs: {', '.join(sorted(self._PRE_ANSWER_SAFE_VERBS))}"
562
470
  )
563
-
564
- def set_prompt_text(self, text: str) -> 'AgentBase':
471
+
472
+ self._pre_answer_verbs.append((verb_name, config))
473
+ return self
474
+
475
+ def add_answer_verb(self, config: Optional[Dict[str, Any]] = None) -> 'AgentBase':
565
476
  """
566
- Set the prompt as raw text instead of using POM
567
-
477
+ Configure the answer verb.
478
+
479
+ The answer verb connects the call. Use this method to customize
480
+ answer behavior, such as setting max_duration.
481
+
568
482
  Args:
569
- text: The raw prompt text
570
-
483
+ config: Optional answer verb configuration (e.g., {"max_duration": 3600})
484
+
571
485
  Returns:
572
486
  Self for method chaining
487
+
488
+ Example:
489
+ # Set maximum call duration to 1 hour
490
+ agent.add_answer_verb({"max_duration": 3600})
573
491
  """
574
- self._validate_prompt_mode_exclusivity()
575
- self._raw_prompt = text
492
+ self._answer_config = config or {}
576
493
  return self
577
-
578
- def set_post_prompt(self, text: str) -> 'AgentBase':
494
+
495
+ def add_post_answer_verb(self, verb_name: str, config: Dict[str, Any]) -> 'AgentBase':
579
496
  """
580
- Set the post-prompt text for summary generation
581
-
497
+ Add a verb to run after the call is answered but before the AI starts.
498
+
499
+ Post-answer verbs run after the call is connected. Common uses include
500
+ welcome messages, legal disclaimers, and hold music.
501
+
582
502
  Args:
583
- text: The post-prompt text
584
-
503
+ verb_name: The SWML verb name (e.g., "play", "sleep")
504
+ config: Verb configuration dictionary
505
+
585
506
  Returns:
586
507
  Self for method chaining
508
+
509
+ Example:
510
+ # Play welcome message
511
+ agent.add_post_answer_verb("play", {
512
+ "url": "say:Welcome to Acme Corporation."
513
+ })
514
+ # Brief pause
515
+ agent.add_post_answer_verb("sleep", {"time": 500})
587
516
  """
588
- self._post_prompt = text
517
+ self._post_answer_verbs.append((verb_name, config))
589
518
  return self
590
-
591
- def set_prompt_pom(self, pom: List[Dict[str, Any]]) -> 'AgentBase':
519
+
520
+ def add_post_ai_verb(self, verb_name: str, config: Dict[str, Any]) -> 'AgentBase':
592
521
  """
593
- Set the prompt as a POM dictionary
594
-
522
+ Add a verb to run after the AI conversation ends.
523
+
524
+ Post-AI verbs run when the AI completes its conversation. Common uses
525
+ include clean disconnects, transfers, and logging.
526
+
595
527
  Args:
596
- pom: POM dictionary structure
597
-
528
+ verb_name: The SWML verb name (e.g., "hangup", "transfer", "request")
529
+ config: Verb configuration dictionary
530
+
598
531
  Returns:
599
532
  Self for method chaining
533
+
534
+ Example:
535
+ # Log call completion and hang up
536
+ agent.add_post_ai_verb("request", {
537
+ "url": "https://api.example.com/call-complete",
538
+ "method": "POST"
539
+ })
540
+ agent.add_post_ai_verb("hangup", {})
600
541
  """
601
- if self._use_pom:
602
- self.pom = pom
603
- else:
604
- raise ValueError("use_pom must be True to use set_prompt_pom")
542
+ self._post_ai_verbs.append((verb_name, config))
605
543
  return self
606
-
607
- def prompt_add_section(
608
- self,
609
- title: str,
610
- body: str = "",
611
- bullets: Optional[List[str]] = None,
612
- numbered: bool = False,
613
- numbered_bullets: bool = False,
614
- subsections: Optional[List[Dict[str, Any]]] = None
615
- ) -> 'AgentBase':
544
+
545
+ def clear_pre_answer_verbs(self) -> 'AgentBase':
616
546
  """
617
- Add a section to the prompt
618
-
619
- Args:
620
- title: Section title
621
- body: Optional section body text
622
- bullets: Optional list of bullet points
623
- numbered: Whether this section should be numbered
624
- numbered_bullets: Whether bullets should be numbered
625
- subsections: Optional list of subsection objects
626
-
547
+ Remove all pre-answer verbs.
548
+
627
549
  Returns:
628
550
  Self for method chaining
629
551
  """
630
- self._validate_prompt_mode_exclusivity()
631
- if self._use_pom and self.pom:
632
- # Create parameters for add_section based on what's supported
633
- kwargs = {}
634
-
635
- # Start with basic parameters
636
- kwargs['title'] = title
637
- kwargs['body'] = body
638
- if bullets:
639
- kwargs['bullets'] = bullets
640
-
641
- # Add optional parameters if they look supported
642
- if hasattr(self.pom, 'add_section'):
643
- sig = inspect.signature(self.pom.add_section)
644
- if 'numbered' in sig.parameters:
645
- kwargs['numbered'] = numbered
646
- if 'numberedBullets' in sig.parameters:
647
- kwargs['numberedBullets'] = numbered_bullets
648
-
649
- # Create the section
650
- section = self.pom.add_section(**kwargs)
651
-
652
- # Now add subsections if provided, by calling add_subsection on the section
653
- if subsections:
654
- for subsection in subsections:
655
- if 'title' in subsection:
656
- section.add_subsection(
657
- title=subsection.get('title'),
658
- body=subsection.get('body', ''),
659
- bullets=subsection.get('bullets', [])
660
- )
661
-
552
+ self._pre_answer_verbs = []
662
553
  return self
663
-
664
- def prompt_add_to_section(
665
- self,
666
- title: str,
667
- body: Optional[str] = None,
668
- bullet: Optional[str] = None,
669
- bullets: Optional[List[str]] = None
670
- ) -> 'AgentBase':
554
+
555
+ def clear_post_answer_verbs(self) -> 'AgentBase':
671
556
  """
672
- Add content to an existing section (creating it if needed)
673
-
674
- Args:
675
- title: Section title
676
- body: Optional text to append to section body
677
- bullet: Optional single bullet point to add
678
- bullets: Optional list of bullet points to add
679
-
557
+ Remove all post-answer verbs.
558
+
680
559
  Returns:
681
560
  Self for method chaining
682
561
  """
683
- if self._use_pom and self.pom:
684
- self.pom.add_to_section(
685
- title=title,
686
- body=body,
687
- bullet=bullet,
688
- bullets=bullets
689
- )
562
+ self._post_answer_verbs = []
690
563
  return self
691
-
692
- def prompt_add_subsection(
693
- self,
694
- parent_title: str,
695
- title: str,
696
- body: str = "",
697
- bullets: Optional[List[str]] = None
698
- ) -> 'AgentBase':
564
+
565
+ def clear_post_ai_verbs(self) -> 'AgentBase':
699
566
  """
700
- Add a subsection to an existing section (creating parent if needed)
701
-
702
- Args:
703
- parent_title: Parent section title
704
- title: Subsection title
705
- body: Optional subsection body text
706
- bullets: Optional list of bullet points
707
-
567
+ Remove all post-AI verbs.
568
+
708
569
  Returns:
709
570
  Self for method chaining
710
571
  """
711
- if self._use_pom and self.pom:
712
- # First find or create the parent section
713
- parent_section = None
714
-
715
- # Try to find the parent section by title
716
- if hasattr(self.pom, 'sections'):
717
- for section in self.pom.sections:
718
- if hasattr(section, 'title') and section.title == parent_title:
719
- parent_section = section
720
- break
721
-
722
- # If parent section not found, create it
723
- if not parent_section:
724
- parent_section = self.pom.add_section(title=parent_title)
725
-
726
- # Now call add_subsection on the parent section object, not on POM
727
- parent_section.add_subsection(
728
- title=title,
729
- body=body,
730
- bullets=bullets or []
731
- )
732
-
572
+ self._post_ai_verbs = []
733
573
  return self
734
-
735
- # ----------------------------------------------------------------------
736
- # Tool/Function Management
737
- # ----------------------------------------------------------------------
738
-
739
- def define_tool(
740
- self,
741
- name: str,
742
- description: str,
743
- parameters: Dict[str, Any],
744
- handler: Callable,
745
- secure: bool = True,
746
- fillers: Optional[Dict[str, List[str]]] = None,
747
- webhook_url: Optional[str] = None,
748
- **swaig_fields
749
- ) -> 'AgentBase':
574
+
575
+ # ==================== End Call Flow Verb Insertion Methods ====================
576
+
577
+ def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
750
578
  """
751
- Define a SWAIG function that the AI can call
579
+ Enable SIP-based routing for this agent
580
+
581
+ This allows the agent to automatically route SIP requests based on SIP usernames.
582
+ When enabled, an endpoint at the specified path is automatically created
583
+ that will handle SIP requests and deliver them to this agent.
752
584
 
753
585
  Args:
754
- name: Function name (must be unique)
755
- description: Function description for the AI
756
- parameters: JSON Schema of parameters
757
- handler: Function to call when invoked
758
- secure: Whether to require token validation
759
- fillers: Optional dict mapping language codes to arrays of filler phrases
760
- webhook_url: Optional external webhook URL to use instead of local handling
761
- **swaig_fields: Additional SWAIG fields to include in function definition
762
-
586
+ auto_map: Whether to automatically map common SIP usernames to this agent
587
+ (based on the agent name and route path)
588
+ path: The path to register the SIP routing endpoint (default: "/sip")
589
+
763
590
  Returns:
764
591
  Self for method chaining
765
592
  """
766
- if name in self._swaig_functions:
767
- raise ValueError(f"Tool with name '{name}' already exists")
593
+ # Create a routing callback that handles SIP usernames
594
+ def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
595
+ # Extract SIP username from the request body
596
+ sip_username = self.extract_sip_username(body)
597
+
598
+ if sip_username:
599
+ self.log.info("sip_username_extracted", username=sip_username)
600
+
601
+ # Check if this username is registered with this agent
602
+ if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
603
+ self.log.info("sip_username_matched", username=sip_username)
604
+ # This route is already being handled by the agent, no need to redirect
605
+ return None
606
+ else:
607
+ self.log.info("sip_username_not_matched", username=sip_username)
608
+ # Not registered with this agent, let routing continue
609
+
610
+ return None
611
+
612
+ # Register the callback with the SWMLService, specifying the path
613
+ self.register_routing_callback(sip_routing_callback, path=path)
614
+
615
+ # Auto-map common usernames if requested
616
+ if auto_map:
617
+ self.auto_map_sip_usernames()
768
618
 
769
- self._swaig_functions[name] = SWAIGFunction(
770
- name=name,
771
- description=description,
772
- parameters=parameters,
773
- handler=handler,
774
- secure=secure,
775
- fillers=fillers,
776
- webhook_url=webhook_url,
777
- **swaig_fields
778
- )
779
619
  return self
780
620
 
781
- def register_swaig_function(self, function_dict: Dict[str, Any]) -> 'AgentBase':
621
+ def register_sip_username(self, sip_username: str) -> 'AgentBase':
782
622
  """
783
- Register a raw SWAIG function dictionary (e.g., from DataMap.to_swaig_function())
623
+ Register a SIP username that should be routed to this agent
784
624
 
785
625
  Args:
786
- function_dict: Complete SWAIG function definition dictionary
626
+ sip_username: SIP username to register
787
627
 
788
628
  Returns:
789
629
  Self for method chaining
790
630
  """
791
- function_name = function_dict.get('function')
792
- if not function_name:
793
- raise ValueError("Function dictionary must contain 'function' field with the function name")
631
+ if not hasattr(self, '_sip_usernames'):
632
+ self._sip_usernames = set()
794
633
 
795
- if function_name in self._swaig_functions:
796
- raise ValueError(f"Tool with name '{function_name}' already exists")
634
+ self._sip_usernames.add(sip_username.lower())
635
+ self.log.info("sip_username_registered", username=sip_username)
797
636
 
798
- # Store the raw function dictionary for data_map tools
799
- # These don't have handlers since they execute on SignalWire's server
800
- self._swaig_functions[function_name] = function_dict
801
637
  return self
802
638
 
803
- def _tool_decorator(self, name=None, **kwargs):
804
- """
805
- Decorator for defining SWAIG tools in a class
806
-
807
- Used as:
808
-
809
- @agent.tool(name="example_function", parameters={...})
810
- def example_function(self, param1):
811
- # ...
812
- """
813
- def decorator(func):
814
- nonlocal name
815
- if name is None:
816
- name = func.__name__
817
-
818
- parameters = kwargs.pop("parameters", {})
819
- description = kwargs.pop("description", func.__doc__ or f"Function {name}")
820
- secure = kwargs.pop("secure", True)
821
- fillers = kwargs.pop("fillers", None)
822
- webhook_url = kwargs.pop("webhook_url", None)
823
-
824
- self.define_tool(
825
- name=name,
826
- description=description,
827
- parameters=parameters,
828
- handler=func,
829
- secure=secure,
830
- fillers=fillers,
831
- webhook_url=webhook_url,
832
- **kwargs # Pass through any additional swaig_fields
833
- )
834
- return func
835
- return decorator
836
-
837
- def _register_class_decorated_tools(self):
639
+ def auto_map_sip_usernames(self) -> 'AgentBase':
838
640
  """
839
- Register tools defined with @AgentBase.tool class decorator
641
+ Automatically register common SIP usernames based on this agent's
642
+ name and route
840
643
 
841
- This method scans the class for methods decorated with @AgentBase.tool
842
- and registers them automatically.
644
+ Returns:
645
+ Self for method chaining
843
646
  """
844
- # Get the class of this instance
845
- cls = self.__class__
846
-
847
- # Loop through all attributes in the class
848
- for name in dir(cls):
849
- # Get the attribute
850
- attr = getattr(cls, name)
647
+ # Register username based on agent name
648
+ clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
649
+ if clean_name:
650
+ self.register_sip_username(clean_name)
851
651
 
852
- # Check if it's a method decorated with @AgentBase.tool
853
- if inspect.ismethod(attr) or inspect.isfunction(attr):
854
- if hasattr(attr, "_is_tool") and getattr(attr, "_is_tool", False):
855
- # Extract tool information
856
- tool_name = getattr(attr, "_tool_name", name)
857
- tool_params = getattr(attr, "_tool_params", {})
858
-
859
- # Extract known parameters and pass through the rest as swaig_fields
860
- tool_params_copy = tool_params.copy()
861
- description = tool_params_copy.pop("description", attr.__doc__ or f"Function {tool_name}")
862
- parameters = tool_params_copy.pop("parameters", {})
863
- secure = tool_params_copy.pop("secure", True)
864
- fillers = tool_params_copy.pop("fillers", None)
865
- webhook_url = tool_params_copy.pop("webhook_url", None)
866
-
867
- # Register the tool with any remaining params as swaig_fields
868
- self.define_tool(
869
- name=tool_name,
870
- description=description,
871
- parameters=parameters,
872
- handler=attr.__get__(self, cls), # Bind the method to this instance
873
- secure=secure,
874
- fillers=fillers,
875
- webhook_url=webhook_url,
876
- **tool_params_copy # Pass through any additional swaig_fields
877
- )
878
-
879
- @classmethod
880
- def tool(cls, name=None, **kwargs):
881
- """
882
- Class method decorator for defining SWAIG tools
883
-
884
- Used as:
885
-
886
- @AgentBase.tool(name="example_function", parameters={...})
887
- def example_function(self, param1):
888
- # ...
889
- """
890
- def decorator(func):
891
- setattr(func, "_is_tool", True)
892
- setattr(func, "_tool_name", name or func.__name__)
893
- setattr(func, "_tool_params", kwargs)
894
- return func
895
- return decorator
896
-
897
- # ----------------------------------------------------------------------
898
- # Override Points for Subclasses
899
- # ----------------------------------------------------------------------
900
-
901
- def get_name(self) -> str:
902
- """
903
- Get agent name
904
-
905
- Returns:
906
- Agent name
907
- """
908
- return self.name
909
-
910
- def get_app(self):
911
- """
912
- Get the FastAPI application instance for deployment adapters like Lambda/Mangum
913
-
914
- This method ensures the FastAPI app is properly initialized and configured,
915
- then returns it for use with deployment adapters like Mangum for AWS Lambda.
916
-
917
- Returns:
918
- FastAPI: The configured FastAPI application instance
919
- """
920
- if self._app is None:
921
- # Initialize the app if it hasn't been created yet
922
- # This follows the same initialization logic as serve() but without running uvicorn
923
- from fastapi import FastAPI
924
- from fastapi.middleware.cors import CORSMiddleware
925
-
926
- # Create a FastAPI app with explicit redirect_slashes=False
927
- app = FastAPI(redirect_slashes=False)
928
-
929
- # Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
930
- @app.get("/health")
931
- @app.post("/health")
932
- async def health_check():
933
- """Health check endpoint for Kubernetes liveness probe"""
934
- return {
935
- "status": "healthy",
936
- "agent": self.get_name(),
937
- "route": self.route,
938
- "functions": len(self._swaig_functions)
939
- }
940
-
941
- @app.get("/ready")
942
- @app.post("/ready")
943
- async def readiness_check():
944
- """Readiness check endpoint for Kubernetes readiness probe"""
945
- return {
946
- "status": "ready",
947
- "agent": self.get_name(),
948
- "route": self.route,
949
- "functions": len(self._swaig_functions)
950
- }
951
-
952
- # Add CORS middleware if needed
953
- app.add_middleware(
954
- CORSMiddleware,
955
- allow_origins=["*"],
956
- allow_credentials=True,
957
- allow_methods=["*"],
958
- allow_headers=["*"],
959
- )
960
-
961
- # Create router and register routes
962
- router = self.as_router()
963
-
964
- # Log registered routes for debugging
965
- self.log.debug("router_routes_registered")
966
- for route in router.routes:
967
- if hasattr(route, "path"):
968
- self.log.debug("router_route", path=route.path)
969
-
970
- # Include the router
971
- app.include_router(router, prefix=self.route)
972
-
973
- # Register a catch-all route for debugging and troubleshooting
974
- @app.get("/{full_path:path}")
975
- @app.post("/{full_path:path}")
976
- async def handle_all_routes(request: Request, full_path: str):
977
- self.log.debug("request_received", path=full_path)
978
-
979
- # Check if the path is meant for this agent
980
- if not full_path.startswith(self.route.lstrip("/")):
981
- return {"error": "Invalid route"}
982
-
983
- # Extract the path relative to this agent's route
984
- relative_path = full_path[len(self.route.lstrip("/")):]
985
- relative_path = relative_path.lstrip("/")
986
- self.log.debug("relative_path_extracted", path=relative_path)
987
-
988
- return {"error": "Path not found"}
989
-
990
- # Log all app routes for debugging
991
- self.log.debug("app_routes_registered")
992
- for route in app.routes:
993
- if hasattr(route, "path"):
994
- self.log.debug("app_route", path=route.path)
652
+ # Register username based on route (without slashes)
653
+ clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
654
+ if clean_route and clean_route != clean_name:
655
+ self.register_sip_username(clean_route)
995
656
 
996
- self._app = app
997
-
998
- return self._app
999
-
1000
- def get_prompt(self) -> Union[str, List[Dict[str, Any]]]:
1001
- """
1002
- Get the prompt for the agent
1003
-
1004
- Returns:
1005
- Either a string prompt or a POM object as list of dicts
1006
- """
1007
- # If using POM, return the POM structure
1008
- if self._use_pom and self.pom:
1009
- try:
1010
- # Try different methods that might be available on the POM implementation
1011
- if hasattr(self.pom, 'render_dict'):
1012
- return self.pom.render_dict()
1013
- elif hasattr(self.pom, 'to_dict'):
1014
- return self.pom.to_dict()
1015
- elif hasattr(self.pom, 'to_list'):
1016
- return self.pom.to_list()
1017
- elif hasattr(self.pom, 'render'):
1018
- render_result = self.pom.render()
1019
- # If render returns a string, we need to convert it to JSON
1020
- if isinstance(render_result, str):
1021
- try:
1022
- import json
1023
- return json.loads(render_result)
1024
- except:
1025
- # If we can't parse as JSON, fall back to raw text
1026
- pass
1027
- return render_result
1028
- else:
1029
- # Last resort: attempt to convert the POM object directly to a list/dict
1030
- # This assumes the POM object has a reasonable __str__ or __repr__ method
1031
- pom_data = self.pom.__dict__
1032
- if '_sections' in pom_data and isinstance(pom_data['_sections'], list):
1033
- return pom_data['_sections']
1034
- # Fall through to default if nothing worked
1035
- except Exception as e:
1036
- self.log.error("pom_rendering_failed", error=str(e))
1037
- # Fall back to raw text if POM fails
657
+ # Register common variations if they make sense
658
+ if len(clean_name) > 3:
659
+ # Register without vowels
660
+ no_vowels = re.sub(r'[aeiou]', '', clean_name)
661
+ if no_vowels != clean_name and len(no_vowels) > 2:
662
+ self.register_sip_username(no_vowels)
1038
663
 
1039
- # Return raw text (either explicitly set or default)
1040
- return self._raw_prompt or f"You are {self.name}, a helpful AI assistant."
1041
-
1042
- def get_post_prompt(self) -> Optional[str]:
1043
- """
1044
- Get the post-prompt for the agent
1045
-
1046
- Returns:
1047
- Post-prompt text or None if not set
1048
- """
1049
- return self._post_prompt
1050
-
1051
- def define_tools(self) -> List[SWAIGFunction]:
1052
- """
1053
- Define the tools this agent can use
1054
-
1055
- Returns:
1056
- List of SWAIGFunction objects or raw dictionaries (for data_map tools)
1057
-
1058
- This method can be overridden by subclasses.
1059
- """
1060
- tools = []
1061
- for func in self._swaig_functions.values():
1062
- if isinstance(func, dict):
1063
- # Raw dictionary from register_swaig_function (e.g., DataMap)
1064
- tools.append(func)
1065
- else:
1066
- # SWAIGFunction object from define_tool
1067
- tools.append(func)
1068
- return tools
1069
-
1070
- def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
1071
- """
1072
- Called when a post-prompt summary is received
1073
-
1074
- Args:
1075
- summary: The summary object or None if no summary was found
1076
- raw_data: The complete raw POST data from the request
1077
- """
1078
- # Default implementation does nothing
1079
- pass
664
+ return self
1080
665
 
1081
- def on_function_call(self, name: str, args: Dict[str, Any], raw_data: Optional[Dict[str, Any]] = None) -> Any:
666
+ def set_web_hook_url(self, url: str) -> 'AgentBase':
1082
667
  """
1083
- Called when a SWAIG function is invoked
668
+ Override the default web_hook_url with a supplied URL string
1084
669
 
1085
670
  Args:
1086
- name: Function name
1087
- args: Function arguments
1088
- raw_data: Raw request data
671
+ url: The URL to use for SWAIG function webhooks
1089
672
 
1090
673
  Returns:
1091
- Function result
674
+ Self for method chaining
1092
675
  """
1093
- # Check if the function is registered
1094
- if name not in self._swaig_functions:
1095
- # If the function is not found, return an error
1096
- return {"response": f"Function '{name}' not found"}
1097
-
1098
- # Get the function
1099
- func = self._swaig_functions[name]
1100
-
1101
- # Check if this is a data_map function (raw dictionary)
1102
- if isinstance(func, dict):
1103
- # Data_map functions execute on SignalWire's server, not here
1104
- # This should never be called, but if it is, return an error
1105
- return {"response": f"Data map function '{name}' should be executed by SignalWire server, not locally"}
1106
-
1107
- # Check if this is an external webhook function
1108
- if hasattr(func, 'webhook_url') and func.webhook_url:
1109
- # External webhook functions should be called directly by SignalWire, not locally
1110
- return {"response": f"External webhook function '{name}' should be executed by SignalWire at {func.webhook_url}, not locally"}
1111
-
1112
- # Call the handler for regular SWAIG functions
1113
- try:
1114
- result = func.handler(args, raw_data)
1115
- if result is None:
1116
- # If the handler returns None, create a default response
1117
- result = SwaigFunctionResult("Function executed successfully")
1118
- return result
1119
- except Exception as e:
1120
- # If the handler raises an exception, return an error response
1121
- return {"response": f"Error executing function '{name}': {str(e)}"}
676
+ self._web_hook_url_override = url
677
+ return self
1122
678
 
1123
- def validate_basic_auth(self, username: str, password: str) -> bool:
679
+ def set_post_prompt_url(self, url: str) -> 'AgentBase':
1124
680
  """
1125
- Validate basic auth credentials
681
+ Override the default post_prompt_url with a supplied URL string
1126
682
 
1127
683
  Args:
1128
- username: Username from request
1129
- password: Password from request
684
+ url: The URL to use for post-prompt summary delivery
1130
685
 
1131
686
  Returns:
1132
- True if valid, False otherwise
1133
-
1134
- This method can be overridden by subclasses.
687
+ Self for method chaining
1135
688
  """
1136
- return (username, password) == self._basic_auth
689
+ self._post_prompt_url_override = url
690
+ return self
1137
691
 
1138
- def _create_tool_token(self, tool_name: str, call_id: str) -> str:
692
+ def add_swaig_query_params(self, params: Dict[str, str]) -> 'AgentBase':
1139
693
  """
1140
- Create a secure token for a tool call
694
+ Add query parameters that will be included in all SWAIG webhook URLs
1141
695
 
1142
- Args:
1143
- tool_name: Name of the tool
1144
- call_id: Call ID for this session
1145
-
1146
- Returns:
1147
- Secure token string
1148
- """
1149
- try:
1150
- # Ensure we have a session manager
1151
- if not hasattr(self, '_session_manager'):
1152
- self.log.error("no_session_manager")
1153
- return ""
1154
-
1155
- # Create the token using the session manager
1156
- return self._session_manager.create_tool_token(tool_name, call_id)
1157
- except Exception as e:
1158
- self.log.error("token_creation_error", error=str(e), tool=tool_name, call_id=call_id)
1159
- return ""
1160
-
1161
- def validate_tool_token(self, function_name: str, token: str, call_id: str) -> bool:
1162
- """
1163
- Validate a tool token
696
+ This is particularly useful for preserving dynamic configuration state
697
+ across SWAIG callbacks. For example, if your dynamic config adds skills
698
+ based on query parameters, you can pass those same parameters through
699
+ to the SWAIG webhook so the same configuration is applied.
1164
700
 
1165
701
  Args:
1166
- function_name: Name of the function/tool
1167
- token: Token to validate
1168
- call_id: Call ID for the session
702
+ params: Dictionary of query parameters to add to SWAIG URLs
1169
703
 
1170
704
  Returns:
1171
- True if token is valid, False otherwise
1172
- """
1173
- try:
1174
- # Skip validation for non-secure tools
1175
- if function_name not in self._swaig_functions:
1176
- self.log.warning("unknown_function", function=function_name)
1177
- return False
1178
-
1179
- # Get the function and check if it's secure
1180
- func = self._swaig_functions[function_name]
1181
- is_secure = True # Default to secure
1182
-
1183
- if isinstance(func, dict):
1184
- # For raw dictionaries (DataMap functions), they're always secure
1185
- is_secure = True
1186
- else:
1187
- # For SWAIGFunction objects, check the secure attribute
1188
- is_secure = func.secure
1189
-
1190
- # Always allow non-secure functions
1191
- if not is_secure:
1192
- self.log.debug("non_secure_function_allowed", function=function_name)
1193
- return True
1194
-
1195
- # Check if we have a session manager
1196
- if not hasattr(self, '_session_manager'):
1197
- self.log.error("no_session_manager")
1198
- return False
1199
-
1200
- # Handle missing token
1201
- if not token:
1202
- self.log.warning("missing_token", function=function_name)
1203
- return False
1204
-
1205
- # For debugging: Log token details
1206
- try:
1207
- # Capture original parameters
1208
- self.log.debug("token_validate_input",
1209
- function=function_name,
1210
- call_id=call_id,
1211
- token_length=len(token))
1212
-
1213
- # Try to decode token for debugging
1214
- if hasattr(self._session_manager, 'debug_token'):
1215
- debug_info = self._session_manager.debug_token(token)
1216
- self.log.debug("token_debug", debug_info=debug_info)
1217
-
1218
- # Extract token components
1219
- if debug_info.get("valid_format") and "components" in debug_info:
1220
- components = debug_info["components"]
1221
- token_call_id = components.get("call_id")
1222
- token_function = components.get("function")
1223
- token_expiry = components.get("expiry")
1224
-
1225
- # Log parameter mismatches
1226
- if token_function != function_name:
1227
- self.log.warning("token_function_mismatch",
1228
- expected=function_name,
1229
- actual=token_function)
1230
-
1231
- if token_call_id != call_id:
1232
- self.log.warning("token_call_id_mismatch",
1233
- expected=call_id,
1234
- actual=token_call_id)
1235
-
1236
- # Check expiration
1237
- if debug_info.get("status", {}).get("is_expired"):
1238
- self.log.warning("token_expired",
1239
- expires_in=debug_info["status"].get("expires_in_seconds"))
1240
- except Exception as e:
1241
- self.log.error("token_debug_error", error=str(e))
1242
-
1243
- # Use call_id from token if the provided one is empty
1244
- if not call_id and hasattr(self._session_manager, 'debug_token'):
1245
- try:
1246
- debug_info = self._session_manager.debug_token(token)
1247
- if debug_info.get("valid_format") and "components" in debug_info:
1248
- token_call_id = debug_info["components"].get("call_id")
1249
- if token_call_id:
1250
- self.log.debug("using_call_id_from_token", call_id=token_call_id)
1251
- is_valid = self._session_manager.validate_tool_token(function_name, token, token_call_id)
1252
- if is_valid:
1253
- self.log.debug("token_valid_with_extracted_call_id")
1254
- return True
1255
- except Exception as e:
1256
- self.log.error("error_using_call_id_from_token", error=str(e))
1257
-
1258
- # Normal validation with provided call_id
1259
- is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
1260
-
1261
- if is_valid:
1262
- self.log.debug("token_valid", function=function_name)
1263
- else:
1264
- self.log.warning("token_invalid", function=function_name)
1265
-
1266
- return is_valid
1267
- except Exception as e:
1268
- self.log.error("token_validation_error", error=str(e), function=function_name)
1269
- return False
1270
-
1271
- # ----------------------------------------------------------------------
1272
- # Web Server and Routing
1273
- # ----------------------------------------------------------------------
1274
-
1275
- def get_basic_auth_credentials(self, include_source: bool = False) -> Union[Tuple[str, str], Tuple[str, str, str]]:
1276
- """
1277
- Get the basic auth credentials
1278
-
1279
- Args:
1280
- include_source: Whether to include the source of the credentials
705
+ Self for method chaining
1281
706
 
1282
- Returns:
1283
- If include_source is False:
1284
- (username, password) tuple
1285
- If include_source is True:
1286
- (username, password, source) tuple, where source is one of:
1287
- "provided", "environment", or "generated"
707
+ Example:
708
+ def dynamic_config(query_params, body_params, headers, agent):
709
+ if query_params.get('tier') == 'premium':
710
+ agent.add_skill('advanced_search')
711
+ # Preserve the tier param so SWAIG callbacks work
712
+ agent.add_swaig_query_params({'tier': 'premium'})
1288
713
  """
1289
- username, password = self._basic_auth
1290
-
1291
- if not include_source:
1292
- return (username, password)
1293
-
1294
- # Determine source of credentials
1295
- env_user = os.environ.get('SWML_BASIC_AUTH_USER')
1296
- env_pass = os.environ.get('SWML_BASIC_AUTH_PASSWORD')
1297
-
1298
- # More robust source detection
1299
- if env_user and env_pass and username == env_user and password == env_pass:
1300
- source = "environment"
1301
- elif username.startswith("user_") and len(password) > 20: # Format of generated credentials
1302
- source = "generated"
1303
- else:
1304
- source = "provided"
1305
-
1306
- return (username, password, source)
714
+ if params and isinstance(params, dict):
715
+ self._swaig_query_params.update(params)
716
+ return self
1307
717
 
1308
- def get_full_url(self, include_auth: bool = False) -> str:
1309
- """
1310
- Get the full URL for this agent's endpoint
1311
-
1312
- Args:
1313
- include_auth: Whether to include authentication credentials in the URL
1314
-
1315
- Returns:
1316
- Full URL including host, port, and route (with auth if requested)
1317
- """
1318
- mode = get_execution_mode()
1319
-
1320
- if mode == 'cgi':
1321
- protocol = 'https' if os.getenv('HTTPS') == 'on' else 'http'
1322
- host = os.getenv('HTTP_HOST') or os.getenv('SERVER_NAME') or 'localhost'
1323
- script_name = os.getenv('SCRIPT_NAME', '')
1324
- base_url = f"{protocol}://{host}{script_name}"
1325
- elif mode == 'lambda':
1326
- function_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
1327
- if function_url and ('amazonaws.com' in function_url or 'on.aws' in function_url):
1328
- base_url = function_url.rstrip('/')
1329
- else:
1330
- api_id = os.getenv('AWS_API_GATEWAY_ID')
1331
- if api_id:
1332
- region = os.getenv('AWS_REGION', 'us-east-1')
1333
- stage = os.getenv('AWS_API_GATEWAY_STAGE', 'prod')
1334
- base_url = f"https://{api_id}.execute-api.{region}.amazonaws.com/{stage}"
1335
- else:
1336
- import logging
1337
- logging.warning("Lambda mode detected but no URL configuration found")
1338
- base_url = "https://lambda-url-not-configured"
1339
- elif mode == 'cloud_function':
1340
- function_url = os.getenv('FUNCTION_URL')
1341
- if function_url:
1342
- base_url = function_url
1343
- else:
1344
- project = os.getenv('GOOGLE_CLOUD_PROJECT')
1345
- if project:
1346
- region = os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
1347
- service = os.getenv('K_SERVICE', 'function')
1348
- base_url = f"https://{region}-{project}.cloudfunctions.net/{service}"
1349
- else:
1350
- import logging
1351
- logging.warning("Cloud Function mode detected but no URL configuration found")
1352
- base_url = "https://cloud-function-url-not-configured"
1353
- else:
1354
- # Server mode - preserve existing logic
1355
- if self._proxy_url_base:
1356
- proxy_base = self._proxy_url_base.rstrip('/')
1357
- route = self.route if self.route.startswith('/') else f"/{self.route}"
1358
- base_url = f"{proxy_base}{route}"
1359
- else:
1360
- if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
1361
- host = "localhost"
1362
- else:
1363
- host = self.host
1364
- base_url = f"http://{host}:{self.port}{self.route}"
1365
-
1366
- # Add auth if requested (only for server mode)
1367
- if include_auth and mode == 'server':
1368
- username, password = self._basic_auth
1369
- url = urlparse(base_url)
1370
- return url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
1371
-
1372
- return base_url
1373
-
1374
- def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
718
+ def clear_swaig_query_params(self) -> 'AgentBase':
1375
719
  """
1376
- Helper method to build webhook URLs consistently
720
+ Clear all SWAIG query parameters
1377
721
 
1378
- Args:
1379
- endpoint: The endpoint path (e.g., "swaig", "post_prompt")
1380
- query_params: Optional query parameters to append
1381
-
1382
722
  Returns:
1383
- Fully constructed webhook URL
723
+ Self for method chaining
1384
724
  """
1385
- # Check for serverless environment and use appropriate URL generation
1386
- mode = get_execution_mode()
1387
-
1388
- if mode != 'server':
1389
- # In serverless mode, use the serverless-appropriate URL
1390
- base_url = self.get_full_url()
1391
-
1392
- # For serverless, we don't need auth in webhook URLs since auth is handled differently
1393
- # and we want to return the actual platform URL
1394
-
1395
- # Ensure the endpoint has a trailing slash to prevent redirects
1396
- if endpoint in ["swaig", "post_prompt"]:
1397
- endpoint = f"{endpoint}/"
1398
-
1399
- # Build the full webhook URL
1400
- url = f"{base_url}/{endpoint}"
1401
-
1402
- # Add query parameters if any (only if they have values)
1403
- if query_params:
1404
- # Remove any call_id from query params
1405
- filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
1406
- if filtered_params:
1407
- params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
1408
- url = f"{url}?{params}"
1409
-
1410
- return url
1411
-
1412
- # Server mode - use existing logic with proxy/auth support
1413
- # Use the parent class's implementation if available and has the same method
1414
- if hasattr(super(), '_build_webhook_url'):
1415
- # Ensure _proxy_url_base is synchronized
1416
- if getattr(self, '_proxy_url_base', None) and hasattr(super(), '_proxy_url_base'):
1417
- super()._proxy_url_base = self._proxy_url_base
1418
-
1419
- # Call parent's implementation
1420
- return super()._build_webhook_url(endpoint, query_params)
1421
-
1422
- # Otherwise, fall back to our own implementation for server mode
1423
- # Base URL construction
1424
- if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
1425
- # For proxy URLs
1426
- base = self._proxy_url_base.rstrip('/')
1427
-
1428
- # Always add auth credentials
1429
- username, password = self._basic_auth
1430
- url = urlparse(base)
1431
- base = url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
1432
- else:
1433
- # For local URLs
1434
- if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
1435
- host = "localhost"
1436
- else:
1437
- host = self.host
1438
-
1439
- # Always include auth credentials
1440
- username, password = self._basic_auth
1441
- base = f"http://{username}:{password}@{host}:{self.port}"
1442
-
1443
- # Ensure the endpoint has a trailing slash to prevent redirects
1444
- if endpoint in ["swaig", "post_prompt"]:
1445
- endpoint = f"{endpoint}/"
1446
-
1447
- # Simple path - use the route directly with the endpoint
1448
- path = f"{self.route}/{endpoint}"
1449
-
1450
- # Construct full URL
1451
- url = f"{base}{path}"
1452
-
1453
- # Add query parameters if any (only if they have values)
1454
- # But NEVER add call_id parameter - it should be in the body, not the URL
1455
- if query_params:
1456
- # Remove any call_id from query params
1457
- filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
1458
- if filtered_params:
1459
- params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
1460
- url = f"{url}?{params}"
1461
-
1462
- return url
1463
-
725
+ self._swaig_query_params = {}
726
+ return self
727
+
1464
728
  def _render_swml(self, call_id: str = None, modifications: Optional[dict] = None) -> str:
1465
729
  """
1466
730
  Render the complete SWML document using SWMLService methods
@@ -1472,52 +736,114 @@ class AgentBase(SWMLService):
1472
736
  Returns:
1473
737
  SWML document as a string
1474
738
  """
739
+ self.log.debug("_render_swml_called",
740
+ call_id=call_id,
741
+ has_modifications=bool(modifications),
742
+ use_ephemeral=bool(modifications and modifications.get("__use_ephemeral_agent")),
743
+ has_dynamic_callback=bool(self._dynamic_config_callback))
744
+
745
+ # Check if we need to use an ephemeral agent for dynamic configuration
746
+ agent_to_use = self
747
+ if modifications and modifications.get("__use_ephemeral_agent"):
748
+ # Create an ephemeral copy for this request
749
+ self.log.debug("creating_ephemeral_agent",
750
+ original_sections=len(self._prompt_manager._sections) if hasattr(self._prompt_manager, '_sections') else 0)
751
+ agent_to_use = self._create_ephemeral_copy()
752
+ self.log.debug("ephemeral_agent_created",
753
+ ephemeral_sections=len(agent_to_use._prompt_manager._sections) if hasattr(agent_to_use._prompt_manager, '_sections') else 0)
754
+
755
+ # Extract the request data
756
+ request = modifications.get("__request")
757
+ request_data = modifications.get("__request_data", {})
758
+
759
+ if self._dynamic_config_callback:
760
+ try:
761
+ # Extract request data
762
+ if request:
763
+ query_params = dict(request.query_params)
764
+ headers = dict(request.headers)
765
+ else:
766
+ # No request object - use empty defaults
767
+ query_params = {}
768
+ headers = {}
769
+ body_params = request_data
770
+
771
+ # Call the dynamic config callback with the ephemeral agent
772
+ # This allows FULL dynamic configuration including adding skills
773
+ self.log.debug("calling_dynamic_config_on_ephemeral", has_request=bool(request))
774
+ self._dynamic_config_callback(query_params, body_params, headers, agent_to_use)
775
+ self.log.debug("dynamic_config_complete",
776
+ ephemeral_sections_after=len(agent_to_use._prompt_manager._sections) if hasattr(agent_to_use._prompt_manager, '_sections') else 0)
777
+
778
+ except Exception as e:
779
+ self.log.error("dynamic_config_error", error=str(e))
780
+
781
+ # Clear the special markers so they don't affect rendering
782
+ modifications = None
783
+
1475
784
  # Reset the document to a clean state
1476
- self.reset_document()
785
+ agent_to_use.reset_document()
1477
786
 
1478
787
  # Get prompt
1479
- prompt = self.get_prompt()
788
+ prompt = agent_to_use.get_prompt()
1480
789
  prompt_is_pom = isinstance(prompt, list)
1481
790
 
1482
791
  # Get post-prompt
1483
- post_prompt = self.get_post_prompt()
792
+ post_prompt = agent_to_use.get_post_prompt()
1484
793
 
1485
794
  # Generate a call ID if needed
1486
- if self._enable_state_tracking and call_id is None:
1487
- call_id = self._session_manager.create_session()
795
+ if call_id is None:
796
+ call_id = agent_to_use._session_manager.create_session()
797
+ self.log.debug("generated_call_id", call_id=call_id)
798
+ else:
799
+ self.log.debug("using_provided_call_id", call_id=call_id)
1488
800
 
1489
- # Empty query params - no need to include call_id in URLs
1490
- query_params = {}
801
+ # Start with any SWAIG query params that were set
802
+ query_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
1491
803
 
1492
804
  # Get the default webhook URL with auth
1493
- default_webhook_url = self._build_webhook_url("swaig", query_params)
805
+ default_webhook_url = agent_to_use._build_webhook_url("swaig", query_params)
1494
806
 
1495
807
  # Use override if set
1496
- if hasattr(self, '_web_hook_url_override') and self._web_hook_url_override:
1497
- default_webhook_url = self._web_hook_url_override
808
+ if hasattr(agent_to_use, '_web_hook_url_override') and agent_to_use._web_hook_url_override:
809
+ default_webhook_url = agent_to_use._web_hook_url_override
1498
810
 
1499
811
  # Prepare SWAIG object (correct format)
1500
812
  swaig_obj = {}
1501
813
 
1502
- # Add defaults if we have functions
1503
- if self._swaig_functions:
1504
- swaig_obj["defaults"] = {
1505
- "web_hook_url": default_webhook_url
1506
- }
1507
-
1508
814
  # Add native_functions if any are defined
1509
- if self.native_functions:
1510
- swaig_obj["native_functions"] = self.native_functions
815
+ if agent_to_use.native_functions:
816
+ swaig_obj["native_functions"] = agent_to_use.native_functions
1511
817
 
1512
818
  # Add includes if any are defined
1513
- if self._function_includes:
1514
- swaig_obj["includes"] = self._function_includes
819
+ if agent_to_use._function_includes:
820
+ swaig_obj["includes"] = agent_to_use._function_includes
821
+
822
+ # Add internal_fillers if any are defined
823
+ if hasattr(agent_to_use, '_internal_fillers') and agent_to_use._internal_fillers:
824
+ swaig_obj["internal_fillers"] = agent_to_use._internal_fillers
1515
825
 
1516
826
  # Create functions array
1517
827
  functions = []
1518
828
 
829
+ # Debug logging to see what functions we have
830
+ self.log.debug("checking_swaig_functions",
831
+ agent_name=agent_to_use.name,
832
+ is_ephemeral=getattr(agent_to_use, '_is_ephemeral', False),
833
+ registry_id=id(agent_to_use._tool_registry),
834
+ agent_id=id(agent_to_use),
835
+ function_count=len(agent_to_use._tool_registry._swaig_functions) if hasattr(agent_to_use._tool_registry, '_swaig_functions') else 0,
836
+ functions=list(agent_to_use._tool_registry._swaig_functions.keys()) if hasattr(agent_to_use._tool_registry, '_swaig_functions') else [])
837
+
1519
838
  # Add each function to the functions array
1520
- for name, func in self._swaig_functions.items():
839
+ # Check if the registry has the _swaig_functions attribute
840
+ if not hasattr(agent_to_use._tool_registry, '_swaig_functions'):
841
+ self.log.warning("tool_registry_missing_swaig_functions",
842
+ registry_id=id(agent_to_use._tool_registry),
843
+ agent_id=id(agent_to_use))
844
+ agent_to_use._tool_registry._swaig_functions = {}
845
+
846
+ for name, func in agent_to_use._tool_registry._swaig_functions.items():
1521
847
  if isinstance(func, dict):
1522
848
  # For raw dictionaries (DataMap functions), use the entire dictionary as-is
1523
849
  # This preserves data_map and any other special fields
@@ -1531,76 +857,133 @@ class AgentBase(SWMLService):
1531
857
  # Check if it's secure and get token for secure functions when we have a call_id
1532
858
  token = None
1533
859
  if func.secure and call_id:
1534
- token = self._create_tool_token(tool_name=name, call_id=call_id)
860
+ token = agent_to_use._create_tool_token(tool_name=name, call_id=call_id)
861
+ self.log.debug("created_token_for_function", function=name, call_id=call_id, token_prefix=token[:20] if token else None)
1535
862
 
1536
863
  # Prepare function entry
1537
864
  function_entry = {
1538
865
  "function": name,
1539
866
  "description": func.description,
1540
- "parameters": {
1541
- "type": "object",
1542
- "properties": func.parameters
1543
- }
867
+ "parameters": func._ensure_parameter_structure()
1544
868
  }
1545
869
 
1546
- # Add fillers if present
1547
- if func.fillers:
870
+ # Add wait_file if present (audio/video file URL)
871
+ if hasattr(func, 'wait_file') and func.wait_file:
872
+ wait_file_url = func.wait_file
873
+ # If wait_file is a relative URL, convert it to absolute using agent's base URL
874
+ if wait_file_url and not wait_file_url.startswith(('http://', 'https://', '//')):
875
+ # Build full URL using the agent's base URL
876
+ base_url = agent_to_use._get_base_url(include_auth=False)
877
+ # Handle relative paths appropriately
878
+ if not wait_file_url.startswith('/'):
879
+ wait_file_url = '/' + wait_file_url
880
+ wait_file_url = f"{base_url}{wait_file_url}"
881
+ function_entry["wait_file"] = wait_file_url
882
+
883
+ # Add fillers if present (text phrases to say while processing)
884
+ if hasattr(func, 'fillers') and func.fillers:
1548
885
  function_entry["fillers"] = func.fillers
1549
886
 
887
+ # Add wait_file_loops if present
888
+ if hasattr(func, 'wait_file_loops') and func.wait_file_loops is not None:
889
+ function_entry["wait_file_loops"] = func.wait_file_loops
890
+
1550
891
  # Handle webhook URL
1551
892
  if hasattr(func, 'webhook_url') and func.webhook_url:
1552
893
  # External webhook function - use the provided URL directly
1553
894
  function_entry["web_hook_url"] = func.webhook_url
1554
- elif token:
1555
- # Local function with token - build local webhook URL
1556
- token_params = {"token": token}
1557
- function_entry["web_hook_url"] = self._build_webhook_url("swaig", token_params)
895
+ elif token or agent_to_use._swaig_query_params:
896
+ # Local function with token OR SWAIG query params - build local webhook URL
897
+ # Start with SWAIG query params
898
+ url_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
899
+ if token:
900
+ url_params["__token"] = token # Use __token to avoid collision
901
+ function_entry["web_hook_url"] = agent_to_use._build_webhook_url("swaig", url_params)
1558
902
 
1559
903
  functions.append(function_entry)
1560
904
 
1561
905
  # Add functions array to SWAIG object if we have any
1562
906
  if functions:
1563
907
  swaig_obj["functions"] = functions
908
+ # Add defaults section now that we know we have functions
909
+ if "defaults" not in swaig_obj:
910
+ swaig_obj["defaults"] = {
911
+ "web_hook_url": default_webhook_url
912
+ }
1564
913
 
1565
914
  # Add post-prompt URL with token if we have a post-prompt
1566
915
  post_prompt_url = None
1567
916
  if post_prompt:
1568
917
  # Create a token for post_prompt if we have a call_id
1569
- query_params = {}
1570
- if call_id and hasattr(self, '_session_manager'):
918
+ # Start with SWAIG query params
919
+ query_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
920
+ if call_id and hasattr(agent_to_use, '_session_manager'):
1571
921
  try:
1572
- token = self._session_manager.create_tool_token("post_prompt", call_id)
922
+ token = agent_to_use._session_manager.create_tool_token("post_prompt", call_id)
1573
923
  if token:
1574
- query_params["token"] = token
924
+ query_params["__token"] = token # Use __token to avoid collision
1575
925
  except Exception as e:
1576
- self.log.error("post_prompt_token_creation_error", error=str(e))
926
+ agent_to_use.log.error("post_prompt_token_creation_error", error=str(e))
1577
927
 
1578
928
  # Build the URL with the token (if any)
1579
- post_prompt_url = self._build_webhook_url("post_prompt", query_params)
929
+ post_prompt_url = agent_to_use._build_webhook_url("post_prompt", query_params)
1580
930
 
1581
931
  # Use override if set
1582
- if hasattr(self, '_post_prompt_url_override') and self._post_prompt_url_override:
1583
- post_prompt_url = self._post_prompt_url_override
932
+ if hasattr(agent_to_use, '_post_prompt_url_override') and agent_to_use._post_prompt_url_override:
933
+ post_prompt_url = agent_to_use._post_prompt_url_override
1584
934
 
1585
- # Add answer verb with auto-answer enabled
1586
- self.add_answer_verb()
935
+ # ========== PHASE 1: PRE-ANSWER VERBS ==========
936
+ # These run while the call is still ringing
937
+ for verb_name, verb_config in agent_to_use._pre_answer_verbs:
938
+ agent_to_use.add_verb(verb_name, verb_config)
939
+
940
+ # ========== PHASE 2: ANSWER VERB ==========
941
+ # Only add answer verb if auto_answer is enabled
942
+ if agent_to_use._auto_answer:
943
+ agent_to_use.add_verb("answer", agent_to_use._answer_config)
944
+
945
+ # ========== PHASE 3: POST-ANSWER VERBS ==========
946
+ # These run after answer but before AI
947
+
948
+ # Add recording if enabled (this is a post-answer verb)
949
+ if agent_to_use._record_call:
950
+ agent_to_use.add_verb("record_call", {
951
+ "format": agent_to_use._record_format,
952
+ "stereo": agent_to_use._record_stereo
953
+ })
954
+
955
+ # Add user-defined post-answer verbs
956
+ for verb_name, verb_config in agent_to_use._post_answer_verbs:
957
+ agent_to_use.add_verb(verb_name, verb_config)
1587
958
 
1588
959
  # Use the AI verb handler to build and validate the AI verb config
1589
960
  ai_config = {}
1590
961
 
1591
962
  # Get the AI verb handler
1592
- ai_handler = self.verb_registry.get_handler("ai")
963
+ ai_handler = agent_to_use.verb_registry.get_handler("ai")
1593
964
  if ai_handler:
1594
965
  try:
1595
966
  # Check if we're in contexts mode
1596
- if self._contexts_defined and self._contexts_builder:
1597
- # Generate contexts instead of prompt
1598
- contexts_dict = self._contexts_builder.to_dict()
967
+ if agent_to_use._contexts_defined and agent_to_use._contexts_builder:
968
+ # Generate contexts and combine with base prompt
969
+ contexts_dict = agent_to_use._contexts_builder.to_dict()
970
+
971
+ # Determine base prompt (required when using contexts)
972
+ base_prompt_text = None
973
+ base_prompt_pom = None
1599
974
 
1600
- # Build AI config with contexts
975
+ if prompt_is_pom:
976
+ base_prompt_pom = prompt
977
+ elif prompt:
978
+ base_prompt_text = prompt
979
+ else:
980
+ # Provide default base prompt if none exists
981
+ base_prompt_text = f"You are {agent_to_use.name}, a helpful AI assistant that follows structured workflows."
982
+
983
+ # Build AI config with base prompt + contexts
1601
984
  ai_config = ai_handler.build_config(
1602
- prompt_text=None,
1603
- prompt_pom=None,
985
+ prompt_text=base_prompt_text,
986
+ prompt_pom=base_prompt_pom,
1604
987
  contexts=contexts_dict,
1605
988
  post_prompt=post_prompt,
1606
989
  post_prompt_url=post_prompt_url,
@@ -1619,28 +1002,54 @@ class AgentBase(SWMLService):
1619
1002
  # Add new configuration parameters to the AI config
1620
1003
 
1621
1004
  # Add hints if any
1622
- if self._hints:
1623
- ai_config["hints"] = self._hints
1005
+ if agent_to_use._hints:
1006
+ ai_config["hints"] = agent_to_use._hints
1624
1007
 
1625
1008
  # Add languages if any
1626
- if self._languages:
1627
- ai_config["languages"] = self._languages
1009
+ if agent_to_use._languages:
1010
+ ai_config["languages"] = agent_to_use._languages
1628
1011
 
1629
1012
  # Add pronunciation rules if any
1630
- if self._pronounce:
1631
- ai_config["pronounce"] = self._pronounce
1013
+ if agent_to_use._pronounce:
1014
+ ai_config["pronounce"] = agent_to_use._pronounce
1632
1015
 
1633
1016
  # Add params if any
1634
- if self._params:
1635
- ai_config["params"] = self._params
1017
+ if agent_to_use._params:
1018
+ ai_config["params"] = agent_to_use._params
1636
1019
 
1637
1020
  # Add global_data if any
1638
- if self._global_data:
1639
- ai_config["global_data"] = self._global_data
1021
+ if agent_to_use._global_data:
1022
+ ai_config["global_data"] = agent_to_use._global_data
1023
+
1024
+ # Always add LLM parameters to prompt
1025
+ if "prompt" in ai_config:
1026
+ # Only add LLM params if explicitly set
1027
+ if agent_to_use._prompt_llm_params:
1028
+ if isinstance(ai_config["prompt"], dict):
1029
+ ai_config["prompt"].update(agent_to_use._prompt_llm_params)
1030
+ elif isinstance(ai_config["prompt"], str):
1031
+ # Convert string prompt to dict format
1032
+ ai_config["prompt"] = {
1033
+ "text": ai_config["prompt"],
1034
+ **agent_to_use._prompt_llm_params
1035
+ }
1036
+
1037
+ # Only add LLM parameters to post_prompt if explicitly set
1038
+ if post_prompt and "post_prompt" in ai_config:
1039
+ # Only add LLM params if explicitly set
1040
+ if agent_to_use._post_prompt_llm_params:
1041
+ if isinstance(ai_config["post_prompt"], dict):
1042
+ ai_config["post_prompt"].update(agent_to_use._post_prompt_llm_params)
1043
+ elif isinstance(ai_config["post_prompt"], str):
1044
+ # Convert string post_prompt to dict format
1045
+ ai_config["post_prompt"] = {
1046
+ "text": ai_config["post_prompt"],
1047
+ **agent_to_use._post_prompt_llm_params
1048
+ }
1640
1049
 
1641
1050
  except ValueError as e:
1642
- if not self._suppress_logs:
1643
- self.log.error("ai_verb_config_error", error=str(e))
1051
+ if not agent_to_use._suppress_logs:
1052
+ agent_to_use.log.error("ai_verb_config_error", error=str(e))
1644
1053
  else:
1645
1054
  # Fallback if no handler (shouldn't happen but just in case)
1646
1055
  ai_config = {
@@ -1658,24 +1067,30 @@ class AgentBase(SWMLService):
1658
1067
  ai_config["SWAIG"] = swaig_obj
1659
1068
 
1660
1069
  # Add the new configurations if not already added by the handler
1661
- if self._hints and "hints" not in ai_config:
1662
- ai_config["hints"] = self._hints
1663
-
1664
- if self._languages and "languages" not in ai_config:
1665
- ai_config["languages"] = self._languages
1070
+ if agent_to_use._hints and "hints" not in ai_config:
1071
+ ai_config["hints"] = agent_to_use._hints
1666
1072
 
1667
- if self._pronounce and "pronounce" not in ai_config:
1668
- ai_config["pronounce"] = self._pronounce
1073
+ if agent_to_use._languages and "languages" not in ai_config:
1074
+ ai_config["languages"] = agent_to_use._languages
1669
1075
 
1670
- if self._params and "params" not in ai_config:
1671
- ai_config["params"] = self._params
1076
+ if agent_to_use._pronounce and "pronounce" not in ai_config:
1077
+ ai_config["pronounce"] = agent_to_use._pronounce
1672
1078
 
1673
- if self._global_data and "global_data" not in ai_config:
1674
- ai_config["global_data"] = self._global_data
1079
+ if agent_to_use._params and "params" not in ai_config:
1080
+ ai_config["params"] = agent_to_use._params
1675
1081
 
1082
+ if agent_to_use._global_data and "global_data" not in ai_config:
1083
+ ai_config["global_data"] = agent_to_use._global_data
1084
+
1085
+ # ========== PHASE 4: AI VERB ==========
1676
1086
  # Add the AI verb to the document
1677
- self.add_verb("ai", ai_config)
1678
-
1087
+ agent_to_use.add_verb("ai", ai_config)
1088
+
1089
+ # ========== PHASE 5: POST-AI VERBS ==========
1090
+ # These run after the AI conversation ends
1091
+ for verb_name, verb_config in agent_to_use._post_ai_verbs:
1092
+ agent_to_use.add_verb(verb_name, verb_config)
1093
+
1679
1094
  # Apply any modifications from the callback to agent state
1680
1095
  if modifications and isinstance(modifications, dict):
1681
1096
  # Handle global_data modifications by updating the AI config directly
@@ -1690,1457 +1105,74 @@ class AgentBase(SWMLService):
1690
1105
  ai_config[key] = value
1691
1106
 
1692
1107
  # Clear and rebuild the document with the modified AI config
1693
- self.reset_document()
1694
- self.add_answer_verb()
1695
- self.add_verb("ai", ai_config)
1696
-
1697
- # Return the rendered document as a string
1698
- return self.render_document()
1699
-
1700
- def _check_basic_auth(self, request: Request) -> bool:
1701
- """
1702
- Check basic auth from a request
1703
-
1704
- Args:
1705
- request: FastAPI request object
1706
-
1707
- Returns:
1708
- True if auth is valid, False otherwise
1709
- """
1710
- auth_header = request.headers.get("Authorization")
1711
- if not auth_header or not auth_header.startswith("Basic "):
1712
- return False
1713
-
1714
- try:
1715
- # Decode the base64 credentials
1716
- credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
1717
- username, password = credentials.split(":", 1)
1718
- return self.validate_basic_auth(username, password)
1719
- except Exception:
1720
- return False
1721
-
1722
- def as_router(self) -> APIRouter:
1723
- """
1724
- Get a FastAPI router for this agent
1725
-
1726
- Returns:
1727
- FastAPI router
1728
- """
1729
- # Create a router with explicit redirect_slashes=False
1730
- router = APIRouter(redirect_slashes=False)
1731
-
1732
- # Register routes explicitly
1733
- self._register_routes(router)
1734
-
1735
- # Log all registered routes for debugging
1736
- self.log.debug("routes_registered", agent=self.name)
1737
- for route in router.routes:
1738
- self.log.debug("route_registered", path=route.path)
1739
-
1740
- return router
1741
-
1742
- def serve(self, host: Optional[str] = None, port: Optional[int] = None) -> None:
1743
- """
1744
- Start a web server for this agent
1745
-
1746
- Args:
1747
- host: Optional host to override the default
1748
- port: Optional port to override the default
1749
- """
1750
- import uvicorn
1751
-
1752
- if self._app is None:
1753
- # Create a FastAPI app with explicit redirect_slashes=False
1754
- app = FastAPI(redirect_slashes=False)
1755
-
1756
- # Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
1757
- @app.get("/health")
1758
- @app.post("/health")
1759
- async def health_check():
1760
- """Health check endpoint for Kubernetes liveness probe"""
1761
- return {
1762
- "status": "healthy",
1763
- "agent": self.get_name(),
1764
- "route": self.route,
1765
- "functions": len(self._swaig_functions)
1766
- }
1767
-
1768
- @app.get("/ready")
1769
- @app.post("/ready")
1770
- async def readiness_check():
1771
- """Readiness check endpoint for Kubernetes readiness probe"""
1772
- # Check if agent is properly initialized
1773
- ready = (
1774
- hasattr(self, '_swaig_functions') and
1775
- hasattr(self, 'schema_utils') and
1776
- self.schema_utils is not None
1777
- )
1778
-
1779
- status_code = 200 if ready else 503
1780
- return Response(
1781
- content=json.dumps({
1782
- "status": "ready" if ready else "not_ready",
1783
- "agent": self.get_name(),
1784
- "initialized": ready
1785
- }),
1786
- status_code=status_code,
1787
- media_type="application/json"
1788
- )
1789
-
1790
- # Get router for this agent
1791
- router = self.as_router()
1792
-
1793
- # Register a catch-all route for debugging and troubleshooting
1794
- @app.get("/{full_path:path}")
1795
- @app.post("/{full_path:path}")
1796
- async def handle_all_routes(request: Request, full_path: str):
1797
- self.log.debug("request_received", path=full_path)
1798
-
1799
- # Check if the path is meant for this agent
1800
- if not full_path.startswith(self.route.lstrip("/")):
1801
- return {"error": "Invalid route"}
1802
-
1803
- # Extract the path relative to this agent's route
1804
- relative_path = full_path[len(self.route.lstrip("/")):]
1805
- relative_path = relative_path.lstrip("/")
1806
- self.log.debug("path_extracted", relative_path=relative_path)
1807
-
1808
- # Perform routing based on the relative path
1809
- if not relative_path or relative_path == "/":
1810
- # Root endpoint
1811
- return await self._handle_root_request(request)
1812
-
1813
- # Strip trailing slash for processing
1814
- clean_path = relative_path.rstrip("/")
1815
-
1816
- # Check for standard endpoints
1817
- if clean_path == "debug":
1818
- return await self._handle_debug_request(request)
1819
- elif clean_path == "swaig":
1820
- return await self._handle_swaig_request(request, Response())
1821
- elif clean_path == "post_prompt":
1822
- return await self._handle_post_prompt_request(request)
1823
- elif clean_path == "check_for_input":
1824
- return await self._handle_check_for_input_request(request)
1825
-
1826
- # Check for custom routing callbacks
1827
- if hasattr(self, '_routing_callbacks'):
1828
- for callback_path, callback_fn in self._routing_callbacks.items():
1829
- cb_path_clean = callback_path.strip("/")
1830
- if clean_path == cb_path_clean:
1831
- # Found a matching callback
1832
- request.state.callback_path = callback_path
1833
- return await self._handle_root_request(request)
1834
-
1835
- # Default: 404
1836
- return {"error": "Path not found"}
1837
-
1838
- # Include router with prefix
1839
- app.include_router(router, prefix=self.route)
1840
-
1841
- # Log all app routes for debugging
1842
- self.log.debug("app_routes_registered")
1843
- for route in app.routes:
1844
- if hasattr(route, "path"):
1845
- self.log.debug("app_route", path=route.path)
1846
-
1847
- self._app = app
1848
-
1849
- host = host or self.host
1850
- port = port or self.port
1851
-
1852
- # Print the auth credentials with source
1853
- username, password, source = self.get_basic_auth_credentials(include_source=True)
1854
-
1855
- # Log startup information using structured logging
1856
- self.log.info("agent_starting",
1857
- agent=self.name,
1858
- url=f"http://{host}:{port}{self.route}",
1859
- username=username,
1860
- password_length=len(password),
1861
- auth_source=source)
1862
-
1863
- # Print user-friendly startup message (keep this for development UX)
1864
- print(f"Agent '{self.name}' is available at:")
1865
- print(f"URL: http://{host}:{port}{self.route}")
1866
- print(f"Basic Auth: {username}:{password} (source: {source})")
1867
-
1868
- uvicorn.run(self._app, host=host, port=port)
1869
-
1870
- def run(self, event=None, context=None, force_mode=None, host: Optional[str] = None, port: Optional[int] = None):
1871
- """
1872
- Smart run method that automatically detects environment and handles accordingly
1873
-
1874
- Args:
1875
- event: Serverless event object (Lambda, Cloud Functions)
1876
- context: Serverless context object (Lambda, Cloud Functions)
1877
- force_mode: Override automatic mode detection for testing
1878
- host: Host override for server mode
1879
- port: Port override for server mode
1880
-
1881
- Returns:
1882
- Response for serverless modes, None for server mode
1883
- """
1884
- mode = force_mode or get_execution_mode()
1885
-
1886
- try:
1887
- if mode in ['cgi', 'cloud_function', 'azure_function']:
1888
- response = self.handle_serverless_request(event, context, mode)
1889
- print(response)
1890
- return response
1891
- elif mode == 'lambda':
1892
- return self.handle_serverless_request(event, context, mode)
1893
- else:
1894
- # Server mode - use existing serve method
1895
- self.serve(host, port)
1896
- except Exception as e:
1897
- import logging
1898
- logging.error(f"Error in run method: {e}")
1899
- if mode == 'lambda':
1900
- return {
1901
- "statusCode": 500,
1902
- "headers": {"Content-Type": "application/json"},
1903
- "body": json.dumps({"error": str(e)})
1904
- }
1905
- else:
1906
- raise
1907
-
1908
- def handle_serverless_request(self, event=None, context=None, mode=None):
1909
- """
1910
- Handle serverless environment requests (CGI, Lambda, Cloud Functions)
1911
-
1912
- Args:
1913
- event: Serverless event object (Lambda, Cloud Functions)
1914
- context: Serverless context object (Lambda, Cloud Functions)
1915
- mode: Override execution mode (from force_mode in run())
1916
-
1917
- Returns:
1918
- Response appropriate for the serverless platform
1919
- """
1920
- if mode is None:
1921
- mode = get_execution_mode()
1922
-
1923
- try:
1924
- if mode == 'cgi':
1925
- path_info = os.getenv('PATH_INFO', '').strip('/')
1926
- if not path_info:
1927
- return self._render_swml()
1928
- else:
1929
- # Parse CGI request for SWAIG function call
1930
- args = {}
1931
- call_id = None
1932
- raw_data = None
1933
-
1934
- # Try to parse POST data from stdin for CGI
1935
- import sys
1936
- content_length = os.getenv('CONTENT_LENGTH')
1937
- if content_length and content_length.isdigit():
1938
- try:
1939
- post_data = sys.stdin.read(int(content_length))
1940
- if post_data:
1941
- raw_data = json.loads(post_data)
1942
- call_id = raw_data.get("call_id")
1943
-
1944
- # Extract arguments like the FastAPI handler does
1945
- if "argument" in raw_data and isinstance(raw_data["argument"], dict):
1946
- if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
1947
- args = raw_data["argument"]["parsed"][0]
1948
- elif "raw" in raw_data["argument"]:
1949
- try:
1950
- args = json.loads(raw_data["argument"]["raw"])
1951
- except Exception:
1952
- pass
1953
- except Exception:
1954
- # If parsing fails, continue with empty args
1955
- pass
1956
-
1957
- return self._execute_swaig_function(path_info, args, call_id, raw_data)
1958
-
1959
- elif mode == 'lambda':
1960
- if event:
1961
- path = event.get('pathParameters', {}).get('proxy', '') if event.get('pathParameters') else ''
1962
- if not path:
1963
- swml_response = self._render_swml()
1964
- return {
1965
- "statusCode": 200,
1966
- "headers": {"Content-Type": "application/json"},
1967
- "body": swml_response
1968
- }
1969
- else:
1970
- # Parse Lambda event for SWAIG function call
1971
- args = {}
1972
- call_id = None
1973
- raw_data = None
1974
-
1975
- # Parse request body if present
1976
- body_content = event.get('body')
1977
- if body_content:
1978
- try:
1979
- if isinstance(body_content, str):
1980
- raw_data = json.loads(body_content)
1981
- else:
1982
- raw_data = body_content
1983
-
1984
- call_id = raw_data.get("call_id")
1985
-
1986
- # Extract arguments like the FastAPI handler does
1987
- if "argument" in raw_data and isinstance(raw_data["argument"], dict):
1988
- if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
1989
- args = raw_data["argument"]["parsed"][0]
1990
- elif "raw" in raw_data["argument"]:
1991
- try:
1992
- args = json.loads(raw_data["argument"]["raw"])
1993
- except Exception:
1994
- pass
1995
- except Exception:
1996
- # If parsing fails, continue with empty args
1997
- pass
1998
-
1999
- result = self._execute_swaig_function(path, args, call_id, raw_data)
2000
- return {
2001
- "statusCode": 200,
2002
- "headers": {"Content-Type": "application/json"},
2003
- "body": json.dumps(result) if isinstance(result, dict) else str(result)
2004
- }
2005
- else:
2006
- # Handle case when event is None (direct Lambda call with no event)
2007
- swml_response = self._render_swml()
2008
- return {
2009
- "statusCode": 200,
2010
- "headers": {"Content-Type": "application/json"},
2011
- "body": swml_response
2012
- }
2013
-
2014
- elif mode in ['cloud_function', 'azure_function']:
2015
- return self._handle_cloud_function_request(event)
2016
-
2017
- except Exception as e:
2018
- import logging
2019
- logging.error(f"Error in serverless request handler: {e}")
2020
- if mode == 'lambda':
2021
- return {
2022
- "statusCode": 500,
2023
- "headers": {"Content-Type": "application/json"},
2024
- "body": json.dumps({"error": str(e)})
2025
- }
2026
- else:
2027
- raise
2028
-
2029
- def _handle_cloud_function_request(self, request):
2030
- """
2031
- Handle Cloud Function specific requests
2032
-
2033
- Args:
2034
- request: Cloud Function request object
2035
-
2036
- Returns:
2037
- Cloud Function response
2038
- """
2039
- # Platform-specific implementation would go here
2040
- # For now, return basic SWML response
2041
- return self._render_swml()
2042
-
2043
- def _execute_swaig_function(self, function_name: str, args: Optional[Dict[str, Any]] = None, call_id: Optional[str] = None, raw_data: Optional[Dict[str, Any]] = None):
2044
- """
2045
- Execute a SWAIG function in serverless context
2046
-
2047
- Args:
2048
- function_name: Name of the function to execute
2049
- args: Function arguments dictionary
2050
- call_id: Optional call ID
2051
- raw_data: Optional raw request data
2052
-
2053
- Returns:
2054
- Function execution result
2055
- """
2056
- import structlog
2057
-
2058
- # Use the existing logger
2059
- req_log = self.log.bind(
2060
- endpoint="serverless_swaig",
2061
- function=function_name
2062
- )
2063
-
2064
- if call_id:
2065
- req_log = req_log.bind(call_id=call_id)
2066
-
2067
- req_log.debug("serverless_function_call_received")
2068
-
2069
- try:
2070
- # Validate function exists
2071
- if function_name not in self._swaig_functions:
2072
- req_log.warning("function_not_found", available_functions=list(self._swaig_functions.keys()))
2073
- return {"error": f"Function '{function_name}' not found"}
2074
-
2075
- # Use empty args if not provided
2076
- if args is None:
2077
- args = {}
2078
-
2079
- # Use empty raw_data if not provided, but include function call structure
2080
- if raw_data is None:
2081
- raw_data = {
2082
- "function": function_name,
2083
- "argument": {
2084
- "parsed": [args] if args else [],
2085
- "raw": json.dumps(args) if args else "{}"
2086
- }
2087
- }
2088
- if call_id:
2089
- raw_data["call_id"] = call_id
2090
-
2091
- req_log.debug("executing_function", args=json.dumps(args))
2092
-
2093
- # Call the function using the existing on_function_call method
2094
- result = self.on_function_call(function_name, args, raw_data)
2095
-
2096
- # Convert result to dict if needed (same logic as in _handle_swaig_request)
2097
- if isinstance(result, SwaigFunctionResult):
2098
- result_dict = result.to_dict()
2099
- elif isinstance(result, dict):
2100
- result_dict = result
2101
- else:
2102
- result_dict = {"response": str(result)}
2103
-
2104
- req_log.info("serverless_function_executed_successfully")
2105
- req_log.debug("function_result", result=json.dumps(result_dict))
2106
- return result_dict
2107
-
2108
- except Exception as e:
2109
- req_log.error("serverless_function_execution_error", error=str(e))
2110
- return {"error": str(e), "function": function_name}
2111
-
2112
- def setup_graceful_shutdown(self) -> None:
2113
- """
2114
- Setup signal handlers for graceful shutdown (useful for Kubernetes)
2115
- """
2116
- def signal_handler(signum, frame):
2117
- self.log.info("shutdown_signal_received", signal=signum)
2118
-
2119
- # Perform cleanup
2120
- try:
2121
- # Close any open resources
2122
- if hasattr(self, '_session_manager'):
2123
- # Could add cleanup logic here
2124
- pass
2125
-
2126
- self.log.info("cleanup_completed")
2127
- except Exception as e:
2128
- self.log.error("cleanup_error", error=str(e))
2129
- finally:
2130
- sys.exit(0)
2131
-
2132
- # Register handlers for common termination signals
2133
- signal.signal(signal.SIGTERM, signal_handler) # Kubernetes sends this
2134
- signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
2135
-
2136
- self.log.debug("graceful_shutdown_handlers_registered")
2137
-
2138
- def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
2139
- """
2140
- Customization point for subclasses to modify SWML based on request data
2141
-
2142
- Args:
2143
- request_data: Optional dictionary containing the parsed POST body
2144
- callback_path: Optional callback path
2145
-
2146
- Returns:
2147
- Optional dict with modifications to apply to the SWML document
2148
- """
2149
- # Default implementation does nothing
2150
- return None
2151
-
2152
- def _register_routes(self, router):
2153
- """
2154
- Register routes for this agent
2155
-
2156
- This method ensures proper route registration by handling the routes
2157
- directly in AgentBase rather than inheriting from SWMLService.
2158
-
2159
- Args:
2160
- router: FastAPI router to register routes with
2161
- """
2162
- # Health check endpoints are now registered directly on the main app
2163
-
2164
- # Root endpoint (handles both with and without trailing slash)
2165
- @router.get("/")
2166
- @router.post("/")
2167
- async def handle_root(request: Request, response: Response):
2168
- """Handle GET/POST requests to the root endpoint"""
2169
- return await self._handle_root_request(request)
2170
-
2171
- # Debug endpoint - Both versions
2172
- @router.get("/debug")
2173
- @router.get("/debug/")
2174
- @router.post("/debug")
2175
- @router.post("/debug/")
2176
- async def handle_debug(request: Request):
2177
- """Handle GET/POST requests to the debug endpoint"""
2178
- return await self._handle_debug_request(request)
2179
-
2180
- # SWAIG endpoint - Both versions
2181
- @router.get("/swaig")
2182
- @router.get("/swaig/")
2183
- @router.post("/swaig")
2184
- @router.post("/swaig/")
2185
- async def handle_swaig(request: Request, response: Response):
2186
- """Handle GET/POST requests to the SWAIG endpoint"""
2187
- return await self._handle_swaig_request(request, response)
2188
-
2189
- # Post prompt endpoint - Both versions
2190
- @router.get("/post_prompt")
2191
- @router.get("/post_prompt/")
2192
- @router.post("/post_prompt")
2193
- @router.post("/post_prompt/")
2194
- async def handle_post_prompt(request: Request):
2195
- """Handle GET/POST requests to the post_prompt endpoint"""
2196
- return await self._handle_post_prompt_request(request)
2197
-
2198
- # Check for input endpoint - Both versions
2199
- @router.get("/check_for_input")
2200
- @router.get("/check_for_input/")
2201
- @router.post("/check_for_input")
2202
- @router.post("/check_for_input/")
2203
- async def handle_check_for_input(request: Request):
2204
- """Handle GET/POST requests to the check_for_input endpoint"""
2205
- return await self._handle_check_for_input_request(request)
2206
-
2207
- # Register callback routes for routing callbacks if available
2208
- if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
2209
- for callback_path, callback_fn in self._routing_callbacks.items():
2210
- # Skip the root path as it's already handled
2211
- if callback_path == "/":
2212
- continue
2213
-
2214
- # Register both with and without trailing slash
2215
- path = callback_path.rstrip("/")
2216
- path_with_slash = f"{path}/"
2217
-
2218
- @router.get(path)
2219
- @router.get(path_with_slash)
2220
- @router.post(path)
2221
- @router.post(path_with_slash)
2222
- async def handle_callback(request: Request, response: Response, cb_path=callback_path):
2223
- """Handle GET/POST requests to a registered callback path"""
2224
- # Store the callback path in request state for _handle_request to use
2225
- request.state.callback_path = cb_path
2226
- return await self._handle_root_request(request)
2227
-
2228
- self.log.info("callback_endpoint_registered", path=callback_path)
2229
-
2230
- # ----------------------------------------------------------------------
2231
- # AI Verb Configuration Methods
2232
- # ----------------------------------------------------------------------
2233
-
2234
- def add_hint(self, hint: str) -> 'AgentBase':
2235
- """
2236
- Add a simple string hint to help the AI agent understand certain words better
2237
-
2238
- Args:
2239
- hint: The hint string to add
2240
-
2241
- Returns:
2242
- Self for method chaining
2243
- """
2244
- if isinstance(hint, str) and hint:
2245
- self._hints.append(hint)
2246
- return self
2247
-
2248
- def add_hints(self, hints: List[str]) -> 'AgentBase':
2249
- """
2250
- Add multiple string hints
2251
-
2252
- Args:
2253
- hints: List of hint strings
2254
-
2255
- Returns:
2256
- Self for method chaining
2257
- """
2258
- if hints and isinstance(hints, list):
2259
- for hint in hints:
2260
- if isinstance(hint, str) and hint:
2261
- self._hints.append(hint)
2262
- return self
2263
-
2264
- def add_pattern_hint(self,
2265
- hint: str,
2266
- pattern: str,
2267
- replace: str,
2268
- ignore_case: bool = False) -> 'AgentBase':
2269
- """
2270
- Add a complex hint with pattern matching
2271
-
2272
- Args:
2273
- hint: The hint to match
2274
- pattern: Regular expression pattern
2275
- replace: Text to replace the hint with
2276
- ignore_case: Whether to ignore case when matching
2277
-
2278
- Returns:
2279
- Self for method chaining
2280
- """
2281
- if hint and pattern and replace:
2282
- self._hints.append({
2283
- "hint": hint,
2284
- "pattern": pattern,
2285
- "replace": replace,
2286
- "ignore_case": ignore_case
2287
- })
2288
- return self
2289
-
2290
- def add_language(self,
2291
- name: str,
2292
- code: str,
2293
- voice: str,
2294
- speech_fillers: Optional[List[str]] = None,
2295
- function_fillers: Optional[List[str]] = None,
2296
- engine: Optional[str] = None,
2297
- model: Optional[str] = None) -> 'AgentBase':
2298
- """
2299
- Add a language configuration to support multilingual conversations
2300
-
2301
- Args:
2302
- name: Name of the language (e.g., "English", "French")
2303
- code: Language code (e.g., "en-US", "fr-FR")
2304
- voice: TTS voice to use. Can be a simple name (e.g., "en-US-Neural2-F")
2305
- or a combined format "engine.voice:model" (e.g., "elevenlabs.josh:eleven_turbo_v2_5")
2306
- speech_fillers: Optional list of filler phrases for natural speech
2307
- function_fillers: Optional list of filler phrases during function calls
2308
- engine: Optional explicit engine name (e.g., "elevenlabs", "rime")
2309
- model: Optional explicit model name (e.g., "eleven_turbo_v2_5", "arcana")
2310
-
2311
- Returns:
2312
- Self for method chaining
2313
-
2314
- Examples:
2315
- # Simple voice name
2316
- agent.add_language("English", "en-US", "en-US-Neural2-F")
2317
-
2318
- # Explicit parameters
2319
- agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
2320
-
2321
- # Combined format
2322
- agent.add_language("English", "en-US", "elevenlabs.josh:eleven_turbo_v2_5")
2323
- """
2324
- language = {
2325
- "name": name,
2326
- "code": code
2327
- }
2328
-
2329
- # Handle voice formatting (either explicit params or combined string)
2330
- if engine or model:
2331
- # Use explicit parameters if provided
2332
- language["voice"] = voice
2333
- if engine:
2334
- language["engine"] = engine
2335
- if model:
2336
- language["model"] = model
2337
- elif "." in voice and ":" in voice:
2338
- # Parse combined string format: "engine.voice:model"
2339
- try:
2340
- engine_voice, model_part = voice.split(":", 1)
2341
- engine_part, voice_part = engine_voice.split(".", 1)
2342
-
2343
- language["voice"] = voice_part
2344
- language["engine"] = engine_part
2345
- language["model"] = model_part
2346
- except ValueError:
2347
- # If parsing fails, use the voice string as-is
2348
- language["voice"] = voice
2349
- else:
2350
- # Simple voice string
2351
- language["voice"] = voice
2352
-
2353
- # Add fillers if provided
2354
- if speech_fillers and function_fillers:
2355
- language["speech_fillers"] = speech_fillers
2356
- language["function_fillers"] = function_fillers
2357
- elif speech_fillers or function_fillers:
2358
- # If only one type of fillers is provided, use the deprecated "fillers" field
2359
- fillers = speech_fillers or function_fillers
2360
- language["fillers"] = fillers
2361
-
2362
- self._languages.append(language)
2363
- return self
2364
-
2365
- def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
2366
- """
2367
- Set all language configurations at once
2368
-
2369
- Args:
2370
- languages: List of language configuration dictionaries
2371
-
2372
- Returns:
2373
- Self for method chaining
2374
- """
2375
- if languages and isinstance(languages, list):
2376
- self._languages = languages
2377
- return self
2378
-
2379
- def add_pronunciation(self,
2380
- replace: str,
2381
- with_text: str,
2382
- ignore_case: bool = False) -> 'AgentBase':
2383
- """
2384
- Add a pronunciation rule to help the AI speak certain words correctly
2385
-
2386
- Args:
2387
- replace: The expression to replace
2388
- with_text: The phonetic spelling to use instead
2389
- ignore_case: Whether to ignore case when matching
2390
-
2391
- Returns:
2392
- Self for method chaining
2393
- """
2394
- if replace and with_text:
2395
- rule = {
2396
- "replace": replace,
2397
- "with": with_text
2398
- }
2399
- if ignore_case:
2400
- rule["ignore_case"] = True
2401
-
2402
- self._pronounce.append(rule)
2403
- return self
2404
-
2405
- def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
2406
- """
2407
- Set all pronunciation rules at once
2408
-
2409
- Args:
2410
- pronunciations: List of pronunciation rule dictionaries
2411
-
2412
- Returns:
2413
- Self for method chaining
2414
- """
2415
- if pronunciations and isinstance(pronunciations, list):
2416
- self._pronounce = pronunciations
2417
- return self
2418
-
2419
- def set_param(self, key: str, value: Any) -> 'AgentBase':
2420
- """
2421
- Set a single AI parameter
2422
-
2423
- Args:
2424
- key: Parameter name
2425
- value: Parameter value
2426
-
2427
- Returns:
2428
- Self for method chaining
2429
- """
2430
- if key:
2431
- self._params[key] = value
2432
- return self
2433
-
2434
- def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
2435
- """
2436
- Set multiple AI parameters at once
2437
-
2438
- Args:
2439
- params: Dictionary of parameter name/value pairs
2440
-
2441
- Returns:
2442
- Self for method chaining
2443
- """
2444
- if params and isinstance(params, dict):
2445
- self._params.update(params)
2446
- return self
2447
-
2448
- def set_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
2449
- """
2450
- Set the global data available to the AI throughout the conversation
2451
-
2452
- Args:
2453
- data: Dictionary of global data
2454
-
2455
- Returns:
2456
- Self for method chaining
2457
- """
2458
- if data and isinstance(data, dict):
2459
- self._global_data = data
2460
- return self
2461
-
2462
- def update_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
2463
- """
2464
- Update the global data with new values
2465
-
2466
- Args:
2467
- data: Dictionary of global data to update
2468
-
2469
- Returns:
2470
- Self for method chaining
2471
- """
2472
- if data and isinstance(data, dict):
2473
- self._global_data.update(data)
2474
- return self
2475
-
2476
- def set_native_functions(self, function_names: List[str]) -> 'AgentBase':
2477
- """
2478
- Set the list of native functions to enable
2479
-
2480
- Args:
2481
- function_names: List of native function names
2482
-
2483
- Returns:
2484
- Self for method chaining
2485
- """
2486
- if function_names and isinstance(function_names, list):
2487
- self.native_functions = [name for name in function_names if isinstance(name, str)]
2488
- return self
2489
-
2490
- def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
2491
- """
2492
- Add a remote function include to the SWAIG configuration
2493
-
2494
- Args:
2495
- url: URL to fetch remote functions from
2496
- functions: List of function names to include
2497
- meta_data: Optional metadata to include with the function include
2498
-
2499
- Returns:
2500
- Self for method chaining
2501
- """
2502
- if url and functions and isinstance(functions, list):
2503
- include = {
2504
- "url": url,
2505
- "functions": functions
2506
- }
2507
- if meta_data and isinstance(meta_data, dict):
2508
- include["meta_data"] = meta_data
2509
-
2510
- self._function_includes.append(include)
2511
- return self
2512
-
2513
- def set_function_includes(self, includes: List[Dict[str, Any]]) -> 'AgentBase':
2514
- """
2515
- Set the complete list of function includes
2516
-
2517
- Args:
2518
- includes: List of include objects, each with url and functions properties
2519
-
2520
- Returns:
2521
- Self for method chaining
2522
- """
2523
- if includes and isinstance(includes, list):
2524
- # Validate each include has required properties
2525
- valid_includes = []
2526
- for include in includes:
2527
- if isinstance(include, dict) and "url" in include and "functions" in include:
2528
- if isinstance(include["functions"], list):
2529
- valid_includes.append(include)
2530
-
2531
- self._function_includes = valid_includes
2532
- return self
2533
-
2534
- def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
2535
- """
2536
- Enable SIP-based routing for this agent
2537
-
2538
- This allows the agent to automatically route SIP requests based on SIP usernames.
2539
- When enabled, an endpoint at the specified path is automatically created
2540
- that will handle SIP requests and deliver them to this agent.
2541
-
2542
- Args:
2543
- auto_map: Whether to automatically map common SIP usernames to this agent
2544
- (based on the agent name and route path)
2545
- path: The path to register the SIP routing endpoint (default: "/sip")
2546
-
2547
- Returns:
2548
- Self for method chaining
2549
- """
2550
- # Create a routing callback that handles SIP usernames
2551
- def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
2552
- # Extract SIP username from the request body
2553
- sip_username = self.extract_sip_username(body)
2554
-
2555
- if sip_username:
2556
- self.log.info("sip_username_extracted", username=sip_username)
2557
-
2558
- # Check if this username is registered with this agent
2559
- if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
2560
- self.log.info("sip_username_matched", username=sip_username)
2561
- # This route is already being handled by the agent, no need to redirect
2562
- return None
2563
- else:
2564
- self.log.info("sip_username_not_matched", username=sip_username)
2565
- # Not registered with this agent, let routing continue
2566
-
2567
- return None
2568
-
2569
- # Register the callback with the SWMLService, specifying the path
2570
- self.register_routing_callback(sip_routing_callback, path=path)
2571
-
2572
- # Auto-map common usernames if requested
2573
- if auto_map:
2574
- self.auto_map_sip_usernames()
2575
-
2576
- return self
2577
-
2578
- def register_sip_username(self, sip_username: str) -> 'AgentBase':
2579
- """
2580
- Register a SIP username that should be routed to this agent
2581
-
2582
- Args:
2583
- sip_username: SIP username to register
2584
-
2585
- Returns:
2586
- Self for method chaining
2587
- """
2588
- if not hasattr(self, '_sip_usernames'):
2589
- self._sip_usernames = set()
2590
-
2591
- self._sip_usernames.add(sip_username.lower())
2592
- self.log.info("sip_username_registered", username=sip_username)
2593
-
2594
- return self
2595
-
2596
- def auto_map_sip_usernames(self) -> 'AgentBase':
2597
- """
2598
- Automatically register common SIP usernames based on this agent's
2599
- name and route
2600
-
2601
- Returns:
2602
- Self for method chaining
2603
- """
2604
- # Register username based on agent name
2605
- clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
2606
- if clean_name:
2607
- self.register_sip_username(clean_name)
2608
-
2609
- # Register username based on route (without slashes)
2610
- clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
2611
- if clean_route and clean_route != clean_name:
2612
- self.register_sip_username(clean_route)
2613
-
2614
- # Register common variations if they make sense
2615
- if len(clean_name) > 3:
2616
- # Register without vowels
2617
- no_vowels = re.sub(r'[aeiou]', '', clean_name)
2618
- if no_vowels != clean_name and len(no_vowels) > 2:
2619
- self.register_sip_username(no_vowels)
2620
-
2621
- return self
2622
-
2623
- def set_web_hook_url(self, url: str) -> 'AgentBase':
2624
- """
2625
- Override the default web_hook_url with a supplied URL string
2626
-
2627
- Args:
2628
- url: The URL to use for SWAIG function webhooks
2629
-
2630
- Returns:
2631
- Self for method chaining
2632
- """
2633
- self._web_hook_url_override = url
2634
- return self
2635
-
2636
- def set_post_prompt_url(self, url: str) -> 'AgentBase':
2637
- """
2638
- Override the default post_prompt_url with a supplied URL string
2639
-
2640
- Args:
2641
- url: The URL to use for post-prompt summary delivery
2642
-
2643
- Returns:
2644
- Self for method chaining
2645
- """
2646
- self._post_prompt_url_override = url
2647
- return self
2648
-
2649
- async def _handle_swaig_request(self, request: Request, response: Response):
2650
- """Handle GET/POST requests to the SWAIG endpoint"""
2651
- req_log = self.log.bind(
2652
- endpoint="swaig",
2653
- method=request.method,
2654
- path=request.url.path
2655
- )
2656
-
2657
- req_log.debug("endpoint_called")
2658
-
2659
- try:
2660
- # Check auth
2661
- if not self._check_basic_auth(request):
2662
- req_log.warning("unauthorized_access_attempt")
2663
- response.headers["WWW-Authenticate"] = "Basic"
2664
- return Response(
2665
- content=json.dumps({"error": "Unauthorized"}),
2666
- status_code=401,
2667
- headers={"WWW-Authenticate": "Basic"},
2668
- media_type="application/json"
2669
- )
2670
-
2671
- # Handle differently based on method
2672
- if request.method == "GET":
2673
- # For GET requests, return the SWML document (same as root endpoint)
2674
- call_id = request.query_params.get("call_id")
2675
- swml = self._render_swml(call_id)
2676
- req_log.debug("swml_rendered", swml_size=len(swml))
2677
- return Response(
2678
- content=swml,
2679
- media_type="application/json"
2680
- )
2681
-
2682
- # For POST requests, process SWAIG function calls
2683
- try:
2684
- body = await request.json()
2685
- req_log.debug("request_body_received", body_size=len(str(body)))
2686
- if body:
2687
- req_log.debug("request_body", body=json.dumps(body))
2688
- except Exception as e:
2689
- req_log.error("error_parsing_request_body", error=str(e))
2690
- body = {}
2691
-
2692
- # Extract function name
2693
- function_name = body.get("function")
2694
- if not function_name:
2695
- req_log.warning("missing_function_name")
2696
- return Response(
2697
- content=json.dumps({"error": "Missing function name"}),
2698
- status_code=400,
2699
- media_type="application/json"
2700
- )
2701
-
2702
- # Add function info to logger
2703
- req_log = req_log.bind(function=function_name)
2704
- req_log.debug("function_call_received")
2705
-
2706
- # Extract arguments
2707
- args = {}
2708
- if "argument" in body and isinstance(body["argument"], dict):
2709
- if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
2710
- args = body["argument"]["parsed"][0]
2711
- req_log.debug("parsed_arguments", args=json.dumps(args))
2712
- elif "raw" in body["argument"]:
2713
- try:
2714
- args = json.loads(body["argument"]["raw"])
2715
- req_log.debug("raw_arguments_parsed", args=json.dumps(args))
2716
- except Exception as e:
2717
- req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
2718
-
2719
- # Get call_id from body
2720
- call_id = body.get("call_id")
2721
- if call_id:
2722
- req_log = req_log.bind(call_id=call_id)
2723
- req_log.debug("call_id_identified")
2724
-
2725
- # SECURITY BYPASS FOR DEBUGGING - make all functions work regardless of token
2726
- # We'll log the attempt but allow it through
2727
- token = request.query_params.get("token")
2728
- if token:
2729
- req_log.debug("token_found", token_length=len(token))
2730
-
2731
- # Check token validity but don't reject the request
2732
- if hasattr(self, '_session_manager') and function_name in self._swaig_functions:
2733
- is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
2734
- if is_valid:
2735
- req_log.debug("token_valid")
2736
- else:
2737
- # Log but continue anyway for debugging
2738
- req_log.warning("token_invalid")
2739
- if hasattr(self._session_manager, 'debug_token'):
2740
- debug_info = self._session_manager.debug_token(token)
2741
- req_log.debug("token_debug", debug=json.dumps(debug_info))
2742
-
2743
- # Call the function
2744
- try:
2745
- result = self.on_function_call(function_name, args, body)
2746
-
2747
- # Convert result to dict if needed
2748
- if isinstance(result, SwaigFunctionResult):
2749
- result_dict = result.to_dict()
2750
- elif isinstance(result, dict):
2751
- result_dict = result
2752
- else:
2753
- result_dict = {"response": str(result)}
2754
-
2755
- req_log.info("function_executed_successfully")
2756
- req_log.debug("function_result", result=json.dumps(result_dict))
2757
- return result_dict
2758
- except Exception as e:
2759
- req_log.error("function_execution_error", error=str(e))
2760
- return {"error": str(e), "function": function_name}
2761
-
2762
- except Exception as e:
2763
- req_log.error("request_failed", error=str(e))
2764
- return Response(
2765
- content=json.dumps({"error": str(e)}),
2766
- status_code=500,
2767
- media_type="application/json"
2768
- )
2769
-
2770
- async def _handle_root_request(self, request: Request):
2771
- """Handle GET/POST requests to the root endpoint"""
2772
- # Auto-detect proxy on first request if not explicitly configured
2773
- if not getattr(self, '_proxy_detection_done', False) and not getattr(self, '_proxy_url_base', None):
2774
- # Check for proxy headers
2775
- forwarded_host = request.headers.get("X-Forwarded-Host")
2776
- forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
2777
-
2778
- if forwarded_host:
2779
- # Set proxy_url_base on both self and super() to ensure it's shared
2780
- self._proxy_url_base = f"{forwarded_proto}://{forwarded_host}"
2781
- if hasattr(super(), '_proxy_url_base'):
2782
- # Ensure parent class has the same proxy URL
2783
- super()._proxy_url_base = self._proxy_url_base
2784
-
2785
- self.log.info("proxy_auto_detected", proxy_url_base=self._proxy_url_base,
2786
- source="X-Forwarded headers")
2787
- self._proxy_detection_done = True
2788
-
2789
- # Also set the detection flag on parent
2790
- if hasattr(super(), '_proxy_detection_done'):
2791
- super()._proxy_detection_done = True
2792
- # If no explicit proxy headers, try the parent class detection method if it exists
2793
- elif hasattr(super(), '_detect_proxy_from_request'):
2794
- # Call the parent's detection method
2795
- super()._detect_proxy_from_request(request)
2796
- # Copy the result to our class
2797
- if hasattr(super(), '_proxy_url_base') and getattr(super(), '_proxy_url_base', None):
2798
- self._proxy_url_base = super()._proxy_url_base
2799
- self._proxy_detection_done = True
2800
-
2801
- # Check if this is a callback path request
2802
- callback_path = getattr(request.state, "callback_path", None)
2803
-
2804
- req_log = self.log.bind(
2805
- endpoint="root" if not callback_path else f"callback:{callback_path}",
2806
- method=request.method,
2807
- path=request.url.path
2808
- )
2809
-
2810
- req_log.debug("endpoint_called")
2811
-
2812
- try:
2813
- # Check auth
2814
- if not self._check_basic_auth(request):
2815
- req_log.warning("unauthorized_access_attempt")
2816
- return Response(
2817
- content=json.dumps({"error": "Unauthorized"}),
2818
- status_code=401,
2819
- headers={"WWW-Authenticate": "Basic"},
2820
- media_type="application/json"
2821
- )
2822
-
2823
- # Try to parse request body for POST
2824
- body = {}
2825
- call_id = None
2826
-
2827
- if request.method == "POST":
2828
- # Check if body is empty first
2829
- raw_body = await request.body()
2830
- if raw_body:
2831
- try:
2832
- body = await request.json()
2833
- req_log.debug("request_body_received", body_size=len(str(body)))
2834
- if body:
2835
- req_log.debug("request_body")
2836
- except Exception as e:
2837
- req_log.warning("error_parsing_request_body", error=str(e))
2838
- # Continue processing with empty body
2839
- body = {}
2840
- else:
2841
- req_log.debug("empty_request_body")
2842
-
2843
- # Get call_id from body if present
2844
- call_id = body.get("call_id")
2845
- else:
2846
- # Get call_id from query params for GET
2847
- call_id = request.query_params.get("call_id")
2848
-
2849
- # Add call_id to logger if any
2850
- if call_id:
2851
- req_log = req_log.bind(call_id=call_id)
2852
- req_log.debug("call_id_identified")
2853
-
2854
- # Check if this is a callback path and we need to apply routing
2855
- if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
2856
- callback_fn = self._routing_callbacks[callback_path]
2857
-
2858
- if request.method == "POST" and body:
2859
- req_log.debug("processing_routing_callback", path=callback_path)
2860
- # Call the routing callback
2861
- try:
2862
- route = callback_fn(request, body)
2863
- if route is not None:
2864
- req_log.info("routing_request", route=route)
2865
- # Return a redirect to the new route
2866
- return Response(
2867
- status_code=307, # 307 Temporary Redirect preserves the method and body
2868
- headers={"Location": route}
2869
- )
2870
- except Exception as e:
2871
- req_log.error("error_in_routing_callback", error=str(e))
2872
-
2873
- # Allow subclasses to inspect/modify the request
2874
- modifications = None
2875
- try:
2876
- modifications = self.on_swml_request(body, callback_path, request)
2877
- if modifications:
2878
- req_log.debug("request_modifications_applied")
2879
- except Exception as e:
2880
- req_log.error("error_in_request_modifier", error=str(e))
2881
-
2882
- # Render SWML
2883
- swml = self._render_swml(call_id, modifications)
2884
- req_log.debug("swml_rendered", swml_size=len(swml))
2885
-
2886
- # Return as JSON
2887
- req_log.info("request_successful")
2888
- return Response(
2889
- content=swml,
2890
- media_type="application/json"
2891
- )
2892
- except Exception as e:
2893
- req_log.error("request_failed", error=str(e))
2894
- return Response(
2895
- content=json.dumps({"error": str(e)}),
2896
- status_code=500,
2897
- media_type="application/json"
2898
- )
2899
-
2900
- async def _handle_debug_request(self, request: Request):
2901
- """Handle GET/POST requests to the debug endpoint"""
2902
- req_log = self.log.bind(
2903
- endpoint="debug",
2904
- method=request.method,
2905
- path=request.url.path
2906
- )
2907
-
2908
- req_log.debug("endpoint_called")
1108
+ agent_to_use.reset_document()
1109
+
1110
+ # Rebuild with 5-phase approach
1111
+ # PHASE 1: Pre-answer verbs
1112
+ for verb_name, verb_config in agent_to_use._pre_answer_verbs:
1113
+ agent_to_use.add_verb(verb_name, verb_config)
1114
+
1115
+ # PHASE 2: Answer verb (if auto_answer enabled)
1116
+ if agent_to_use._auto_answer:
1117
+ agent_to_use.add_verb("answer", agent_to_use._answer_config)
1118
+
1119
+ # PHASE 3: Post-answer verbs
1120
+ if agent_to_use._record_call:
1121
+ agent_to_use.add_verb("record_call", {
1122
+ "format": agent_to_use._record_format,
1123
+ "stereo": agent_to_use._record_stereo
1124
+ })
1125
+ for verb_name, verb_config in agent_to_use._post_answer_verbs:
1126
+ agent_to_use.add_verb(verb_name, verb_config)
1127
+
1128
+ # PHASE 4: AI verb
1129
+ agent_to_use.add_verb("ai", ai_config)
1130
+
1131
+ # PHASE 5: Post-AI verbs
1132
+ for verb_name, verb_config in agent_to_use._post_ai_verbs:
1133
+ agent_to_use.add_verb(verb_name, verb_config)
2909
1134
 
2910
- try:
2911
- # Check auth
2912
- if not self._check_basic_auth(request):
2913
- req_log.warning("unauthorized_access_attempt")
2914
- return Response(
2915
- content=json.dumps({"error": "Unauthorized"}),
2916
- status_code=401,
2917
- headers={"WWW-Authenticate": "Basic"},
2918
- media_type="application/json"
2919
- )
2920
-
2921
- # Get call_id from either query params (GET) or body (POST)
2922
- call_id = None
2923
- body = {}
2924
-
2925
- if request.method == "POST":
2926
- try:
2927
- body = await request.json()
2928
- req_log.debug("request_body_received", body_size=len(str(body)))
2929
- call_id = body.get("call_id")
2930
- except Exception as e:
2931
- req_log.warning("error_parsing_request_body", error=str(e))
2932
- else:
2933
- call_id = request.query_params.get("call_id")
2934
-
2935
- # Add call_id to logger if any
2936
- if call_id:
2937
- req_log = req_log.bind(call_id=call_id)
2938
- req_log.debug("call_id_identified")
2939
-
2940
- # Allow subclasses to inspect/modify the request
2941
- modifications = None
2942
- try:
2943
- modifications = self.on_swml_request(body, None, request)
2944
- if modifications:
2945
- req_log.debug("request_modifications_applied")
2946
- except Exception as e:
2947
- req_log.error("error_in_request_modifier", error=str(e))
2948
-
2949
- # Render SWML
2950
- swml = self._render_swml(call_id, modifications)
2951
- req_log.debug("swml_rendered", swml_size=len(swml))
2952
-
2953
- # Return as JSON
2954
- req_log.info("request_successful")
2955
- return Response(
2956
- content=swml,
2957
- media_type="application/json",
2958
- headers={"X-Debug": "true"}
2959
- )
2960
- except Exception as e:
2961
- req_log.error("request_failed", error=str(e))
2962
- return Response(
2963
- content=json.dumps({"error": str(e)}),
2964
- status_code=500,
2965
- media_type="application/json"
2966
- )
1135
+ # Return the rendered document as a string
1136
+ return agent_to_use.render_document()
2967
1137
 
2968
- async def _handle_post_prompt_request(self, request: Request):
2969
- """Handle GET/POST requests to the post_prompt endpoint"""
2970
- req_log = self.log.bind(
2971
- endpoint="post_prompt",
2972
- method=request.method,
2973
- path=request.url.path
2974
- )
2975
-
2976
- # Only log if not suppressed
2977
- if not getattr(self, '_suppress_logs', False):
2978
- req_log.debug("endpoint_called")
1138
+ def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
1139
+ """
1140
+ Helper method to build webhook URLs consistently
2979
1141
 
2980
- try:
2981
- # Check auth
2982
- if not self._check_basic_auth(request):
2983
- req_log.warning("unauthorized_access_attempt")
2984
- return Response(
2985
- content=json.dumps({"error": "Unauthorized"}),
2986
- status_code=401,
2987
- headers={"WWW-Authenticate": "Basic"},
2988
- media_type="application/json"
2989
- )
2990
-
2991
- # Extract call_id for use with token validation
2992
- call_id = request.query_params.get("call_id")
2993
-
2994
- # For POST requests, try to also get call_id from body
2995
- if request.method == "POST":
2996
- try:
2997
- body_text = await request.body()
2998
- if body_text:
2999
- body_data = json.loads(body_text)
3000
- if call_id is None:
3001
- call_id = body_data.get("call_id")
3002
- # Save body_data for later use
3003
- setattr(request, "_post_prompt_body", body_data)
3004
- except Exception as e:
3005
- req_log.error("error_extracting_call_id", error=str(e))
3006
-
3007
- # If we have a call_id, add it to the logger context
3008
- if call_id:
3009
- req_log = req_log.bind(call_id=call_id)
3010
-
3011
- # Check token if provided
3012
- token = request.query_params.get("token")
3013
- token_validated = False
1142
+ Args:
1143
+ endpoint: The endpoint path (e.g., "swaig", "post_prompt")
1144
+ query_params: Optional query parameters to append
3014
1145
 
3015
- if token:
3016
- req_log.debug("token_found", token_length=len(token))
3017
-
3018
- # Try to validate token, but continue processing regardless
3019
- # for backward compatibility with existing implementations
3020
- if call_id and hasattr(self, '_session_manager'):
3021
- try:
3022
- is_valid = self._session_manager.validate_tool_token("post_prompt", token, call_id)
3023
- if is_valid:
3024
- req_log.debug("token_valid")
3025
- token_validated = True
3026
- else:
3027
- req_log.warning("invalid_token")
3028
- # Debug information for token validation issues
3029
- if hasattr(self._session_manager, 'debug_token'):
3030
- debug_info = self._session_manager.debug_token(token)
3031
- req_log.debug("token_debug", debug=json.dumps(debug_info))
3032
- except Exception as e:
3033
- req_log.error("token_validation_error", error=str(e))
3034
-
3035
- # For GET requests, return the SWML document
3036
- if request.method == "GET":
3037
- swml = self._render_swml(call_id)
3038
- req_log.debug("swml_rendered", swml_size=len(swml))
3039
- return Response(
3040
- content=swml,
3041
- media_type="application/json"
3042
- )
1146
+ Returns:
1147
+ Fully constructed webhook URL
1148
+ """
1149
+ # Check for serverless environment and use appropriate URL generation
1150
+ mode = get_execution_mode()
1151
+
1152
+ if mode != 'server':
1153
+ # In serverless mode, use the serverless-appropriate URL with auth
1154
+ base_url = self.get_full_url(include_auth=True)
3043
1155
 
3044
- # For POST requests, process the post-prompt data
3045
- try:
3046
- # Try to reuse the body we already parsed for call_id extraction
3047
- if hasattr(request, "_post_prompt_body"):
3048
- body = getattr(request, "_post_prompt_body")
3049
- else:
3050
- body = await request.json()
3051
-
3052
- # Only log if not suppressed
3053
- if not getattr(self, '_suppress_logs', False):
3054
- req_log.debug("request_body_received", body_size=len(str(body)))
3055
- # Log the raw body directly (let the logger handle the JSON encoding)
3056
- req_log.info("post_prompt_body", body=body)
3057
- except Exception as e:
3058
- req_log.error("error_parsing_request_body", error=str(e))
3059
- body = {}
1156
+ # Ensure the endpoint has a trailing slash to prevent redirects
1157
+ if endpoint in ["swaig", "post_prompt"]:
1158
+ endpoint = f"{endpoint}/"
3060
1159
 
3061
- # Extract summary from the correct location in the request
3062
- summary = self._find_summary_in_post_data(body, req_log)
1160
+ # Build the full webhook URL
1161
+ url = f"{base_url}/{endpoint}"
3063
1162
 
3064
- # Call the summary handler with the summary and the full body
3065
- try:
3066
- if summary:
3067
- self.on_summary(summary, body)
3068
- req_log.debug("summary_handler_called_successfully")
3069
- else:
3070
- # If no summary found but still want to process the data
3071
- self.on_summary(None, body)
3072
- req_log.debug("summary_handler_called_with_null_summary")
3073
- except Exception as e:
3074
- req_log.error("error_in_summary_handler", error=str(e))
1163
+ # Add query parameters if any (only if they have values)
1164
+ if query_params:
1165
+ # Remove any call_id from query params
1166
+ filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
1167
+ if filtered_params:
1168
+ params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
1169
+ url = f"{url}?{params}"
3075
1170
 
3076
- # Return success
3077
- req_log.info("request_successful")
3078
- return {"success": True}
3079
- except Exception as e:
3080
- req_log.error("request_failed", error=str(e))
3081
- return Response(
3082
- content=json.dumps({"error": str(e)}),
3083
- status_code=500,
3084
- media_type="application/json"
3085
- )
3086
-
3087
- async def _handle_check_for_input_request(self, request: Request):
3088
- """Handle GET/POST requests to the check_for_input endpoint"""
3089
- req_log = self.log.bind(
3090
- endpoint="check_for_input",
3091
- method=request.method,
3092
- path=request.url.path
3093
- )
3094
-
3095
- req_log.debug("endpoint_called")
1171
+ return url
3096
1172
 
3097
- try:
3098
- # Check auth
3099
- if not self._check_basic_auth(request):
3100
- req_log.warning("unauthorized_access_attempt")
3101
- return Response(
3102
- content=json.dumps({"error": "Unauthorized"}),
3103
- status_code=401,
3104
- headers={"WWW-Authenticate": "Basic"},
3105
- media_type="application/json"
3106
- )
3107
-
3108
- # For both GET and POST requests, process input check
3109
- conversation_id = None
3110
-
3111
- if request.method == "POST":
3112
- try:
3113
- body = await request.json()
3114
- req_log.debug("request_body_received", body_size=len(str(body)))
3115
- conversation_id = body.get("conversation_id")
3116
- except Exception as e:
3117
- req_log.error("error_parsing_request_body", error=str(e))
3118
- else:
3119
- conversation_id = request.query_params.get("conversation_id")
3120
-
3121
- if not conversation_id:
3122
- req_log.warning("missing_conversation_id")
3123
- return Response(
3124
- content=json.dumps({"error": "Missing conversation_id parameter"}),
3125
- status_code=400,
3126
- media_type="application/json"
3127
- )
3128
-
3129
- # Here you would typically check for new input in some external system
3130
- # For this implementation, we'll return an empty result
3131
- return {
3132
- "status": "success",
3133
- "conversation_id": conversation_id,
3134
- "new_input": False,
3135
- "messages": []
3136
- }
3137
- except Exception as e:
3138
- req_log.error("request_failed", error=str(e))
3139
- return Response(
3140
- content=json.dumps({"error": str(e)}),
3141
- status_code=500,
3142
- media_type="application/json"
3143
- )
1173
+ # Server mode - use the parent class's implementation from SWMLService
1174
+ # which properly handles SWML_PROXY_URL_BASE environment variable
1175
+ return super()._build_webhook_url(endpoint, query_params)
3144
1176
 
3145
1177
  def _find_summary_in_post_data(self, body, logger):
3146
1178
  """
@@ -3174,268 +1206,165 @@ class AgentBase(SWMLService):
3174
1206
  return pdata["raw"]
3175
1207
 
3176
1208
  return None
3177
-
3178
- def _register_state_tracking_tools(self):
3179
- """
3180
- Register special tools for state tracking
3181
-
3182
- This adds startup_hook and hangup_hook SWAIG functions that automatically
3183
- activate and deactivate the session when called. These are useful for
3184
- tracking call state and cleaning up resources when a call ends.
3185
- """
3186
- # Register startup hook to activate session
3187
- self.define_tool(
3188
- name="startup_hook",
3189
- description="Called when a new conversation starts to initialize state",
3190
- parameters={},
3191
- handler=lambda args, raw_data: self._handle_startup_hook(args, raw_data),
3192
- secure=False # No auth needed for this system function
3193
- )
3194
-
3195
- # Register hangup hook to end session
3196
- self.define_tool(
3197
- name="hangup_hook",
3198
- description="Called when conversation ends to clean up resources",
3199
- parameters={},
3200
- handler=lambda args, raw_data: self._handle_hangup_hook(args, raw_data),
3201
- secure=False # No auth needed for this system function
3202
- )
3203
-
3204
- def _handle_startup_hook(self, args, raw_data):
3205
- """
3206
- Handle the startup hook function call
3207
-
3208
- Args:
3209
- args: Function arguments (empty for this hook)
3210
- raw_data: Raw request data containing call_id
3211
-
3212
- Returns:
3213
- Success response
3214
- """
3215
- call_id = raw_data.get("call_id") if raw_data else None
3216
- if call_id:
3217
- self.log.info("session_activated", call_id=call_id)
3218
- self._session_manager.activate_session(call_id)
3219
- return SwaigFunctionResult("Session activated")
3220
- else:
3221
- self.log.warning("session_activation_failed", error="No call_id provided")
3222
- return SwaigFunctionResult("Failed to activate session: No call_id provided")
3223
1209
 
3224
- def _handle_hangup_hook(self, args, raw_data):
1210
+ def _create_ephemeral_copy(self):
3225
1211
  """
3226
- Handle the hangup hook function call
1212
+ Create a lightweight copy of this agent for ephemeral configuration.
3227
1213
 
3228
- Args:
3229
- args: Function arguments (empty for this hook)
3230
- raw_data: Raw request data containing call_id
3231
-
3232
- Returns:
3233
- Success response
3234
- """
3235
- call_id = raw_data.get("call_id") if raw_data else None
3236
- if call_id:
3237
- self.log.info("session_ended", call_id=call_id)
3238
- self._session_manager.end_session(call_id)
3239
- return SwaigFunctionResult("Session ended")
3240
- else:
3241
- self.log.warning("session_end_failed", error="No call_id provided")
3242
- return SwaigFunctionResult("Failed to end session: No call_id provided")
3243
-
3244
- def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
3245
- """
3246
- Called when SWML is requested, with request data when available
3247
-
3248
- This method overrides SWMLService's on_request to properly handle SWML generation
3249
- for AI Agents. It forwards the call to on_swml_request for compatibility.
3250
-
3251
- Args:
3252
- request_data: Optional dictionary containing the parsed POST body
3253
- callback_path: Optional callback path
3254
-
3255
- Returns:
3256
- None to use the default SWML rendering (which will call _render_swml)
3257
- """
3258
- # First try to call on_swml_request if it exists (backward compatibility)
3259
- if hasattr(self, 'on_swml_request') and callable(getattr(self, 'on_swml_request')):
3260
- return self.on_swml_request(request_data, callback_path, None)
3261
-
3262
- # If no on_swml_request or it returned None, we'll proceed with default rendering
3263
- # We're not returning any modifications here because _render_swml will be called
3264
- # to generate the complete SWML document
3265
- return None
3266
-
3267
- def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None, request: Optional[Request] = None) -> Optional[dict]:
3268
- """
3269
- Customization point for subclasses to modify SWML based on request data
1214
+ This creates a partial copy that shares most resources but has independent
1215
+ configuration for SWML generation. Used when dynamic configuration callbacks
1216
+ need to modify the agent without affecting the persistent state.
3270
1217
 
3271
- Args:
3272
- request_data: Optional dictionary containing the parsed POST body
3273
- callback_path: Optional callback path
3274
- request: Optional FastAPI Request object for accessing query params, headers, etc.
3275
-
3276
1218
  Returns:
3277
- Optional dict with modifications to apply to the SWML document
1219
+ A lightweight copy of the agent suitable for ephemeral modifications
3278
1220
  """
3279
- # Handle dynamic configuration callback if set
3280
- if self._dynamic_config_callback and request:
3281
- try:
3282
- # Extract request data
3283
- query_params = dict(request.query_params)
3284
- body_params = request_data or {}
3285
- headers = dict(request.headers)
3286
-
3287
- # Create ephemeral configurator
3288
- agent_config = EphemeralAgentConfig()
3289
-
3290
- # Call the user's configuration callback
3291
- self._dynamic_config_callback(query_params, body_params, headers, agent_config)
3292
-
3293
- # Extract the configuration
3294
- config = agent_config.extract_config()
3295
- if config:
3296
- # Handle ephemeral prompt sections by applying them to this agent instance
3297
- if "_ephemeral_prompt_sections" in config:
3298
- for section in config["_ephemeral_prompt_sections"]:
3299
- self.prompt_add_section(
3300
- section["title"],
3301
- section.get("body", ""),
3302
- section.get("bullets"),
3303
- **{k: v for k, v in section.items() if k not in ["title", "body", "bullets"]}
3304
- )
3305
- del config["_ephemeral_prompt_sections"]
3306
-
3307
- if "_ephemeral_raw_prompt" in config:
3308
- self._raw_prompt = config["_ephemeral_raw_prompt"]
3309
- del config["_ephemeral_raw_prompt"]
3310
-
3311
- if "_ephemeral_post_prompt" in config:
3312
- self._post_prompt = config["_ephemeral_post_prompt"]
3313
- del config["_ephemeral_post_prompt"]
3314
-
3315
- return config
3316
-
3317
- except Exception as e:
3318
- self.log.error("dynamic_config_error", error=str(e))
1221
+ import copy
3319
1222
 
3320
- # Default implementation does nothing
3321
- return None
1223
+ # Create a new instance of the same class
1224
+ cls = self.__class__
1225
+ ephemeral_agent = cls.__new__(cls)
1226
+
1227
+ # Copy all attributes as shallow references first
1228
+ for key, value in self.__dict__.items():
1229
+ setattr(ephemeral_agent, key, value)
1230
+
1231
+ # Deep copy only the configuration that affects SWML generation
1232
+ # These are the parts that dynamic config might modify
1233
+ ephemeral_agent._params = copy.deepcopy(self._params)
1234
+ ephemeral_agent._hints = copy.deepcopy(self._hints)
1235
+ ephemeral_agent._languages = copy.deepcopy(self._languages)
1236
+ ephemeral_agent._pronounce = copy.deepcopy(self._pronounce)
1237
+ ephemeral_agent._global_data = copy.deepcopy(self._global_data)
1238
+ ephemeral_agent._function_includes = copy.deepcopy(self._function_includes)
3322
1239
 
3323
- def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
3324
- path: str = "/sip") -> None:
3325
- """
3326
- Register a callback function that will be called to determine routing
3327
- based on POST data.
3328
-
3329
- When a routing callback is registered, an endpoint at the specified path is automatically
3330
- created that will handle requests. This endpoint will use the callback to
3331
- determine if the request should be processed by this service or redirected.
1240
+ # Deep copy verb insertion points for call flow customization
1241
+ ephemeral_agent._pre_answer_verbs = copy.deepcopy(self._pre_answer_verbs)
1242
+ ephemeral_agent._answer_config = copy.deepcopy(self._answer_config)
1243
+ ephemeral_agent._post_answer_verbs = copy.deepcopy(self._post_answer_verbs)
1244
+ ephemeral_agent._post_ai_verbs = copy.deepcopy(self._post_ai_verbs)
1245
+
1246
+ # Deep copy LLM parameters
1247
+ ephemeral_agent._prompt_llm_params = copy.deepcopy(self._prompt_llm_params)
1248
+ ephemeral_agent._post_prompt_llm_params = copy.deepcopy(self._post_prompt_llm_params)
1249
+
1250
+ # Copy internal fillers if they exist
1251
+ if hasattr(self, '_internal_fillers'):
1252
+ ephemeral_agent._internal_fillers = copy.deepcopy(self._internal_fillers)
1253
+
1254
+ # Don't deep copy _contexts_builder - it has a circular reference to the agent
1255
+ # The contexts are already copied via _prompt_manager._contexts (below)
1256
+ # Just copy the flag indicating contexts are defined
1257
+ if hasattr(self, '_contexts_defined'):
1258
+ ephemeral_agent._contexts_defined = self._contexts_defined
1259
+
1260
+ # Deep copy the POM object if it exists to prevent sharing prompt sections
1261
+ if hasattr(self, 'pom') and self.pom:
1262
+ ephemeral_agent.pom = copy.deepcopy(self.pom)
1263
+ # Handle native_functions which might be stored as an attribute or property
1264
+ if hasattr(self, '_native_functions'):
1265
+ ephemeral_agent._native_functions = copy.deepcopy(self._native_functions)
1266
+ elif hasattr(self, 'native_functions'):
1267
+ ephemeral_agent.native_functions = copy.deepcopy(self.native_functions)
1268
+ ephemeral_agent._swaig_query_params = copy.deepcopy(self._swaig_query_params)
1269
+
1270
+ # Create new manager instances that point to the ephemeral agent
1271
+ # This breaks the circular reference and allows independent modification
1272
+ from signalwire_agents.core.agent.prompt.manager import PromptManager
1273
+ from signalwire_agents.core.agent.tools.registry import ToolRegistry
1274
+
1275
+ # Create new prompt manager for the ephemeral agent
1276
+ ephemeral_agent._prompt_manager = PromptManager(ephemeral_agent)
1277
+ # Copy ALL PromptManager state
1278
+ if hasattr(self._prompt_manager, '_sections'):
1279
+ ephemeral_agent._prompt_manager._sections = copy.deepcopy(self._prompt_manager._sections)
1280
+ ephemeral_agent._prompt_manager._prompt_text = copy.deepcopy(self._prompt_manager._prompt_text)
1281
+ ephemeral_agent._prompt_manager._post_prompt_text = copy.deepcopy(self._prompt_manager._post_prompt_text)
1282
+ ephemeral_agent._prompt_manager._contexts = copy.deepcopy(self._prompt_manager._contexts)
1283
+
1284
+ # Create new tool registry for the ephemeral agent
1285
+ ephemeral_agent._tool_registry = ToolRegistry(ephemeral_agent)
1286
+ # Copy the SWAIG functions - we need a shallow copy here because
1287
+ # the functions themselves can be shared, we just need a new dict
1288
+ if hasattr(self._tool_registry, '_swaig_functions'):
1289
+ ephemeral_agent._tool_registry._swaig_functions = self._tool_registry._swaig_functions.copy()
1290
+ if hasattr(self._tool_registry, '_tool_instances'):
1291
+ ephemeral_agent._tool_registry._tool_instances = self._tool_registry._tool_instances.copy()
1292
+
1293
+ # Create a new skill manager for the ephemeral agent
1294
+ # This is important because skills register tools with the agent's registry
1295
+ from signalwire_agents.core.skill_manager import SkillManager
1296
+ ephemeral_agent.skill_manager = SkillManager(ephemeral_agent)
1297
+
1298
+ # Copy any already loaded skills from the original agent
1299
+ # This ensures skills loaded during __init__ are available in the ephemeral agent
1300
+ if hasattr(self.skill_manager, 'loaded_skills'):
1301
+ for skill_key, skill_instance in self.skill_manager.loaded_skills.items():
1302
+ # Re-load the skill in the ephemeral agent's context
1303
+ # We need to get the skill name and params from the existing instance
1304
+ skill_name = skill_instance.SKILL_NAME
1305
+ skill_params = getattr(skill_instance, 'params', {})
1306
+ try:
1307
+ ephemeral_agent.skill_manager.load_skill(skill_name, type(skill_instance), skill_params)
1308
+ except Exception as e:
1309
+ self.log.warning("failed_to_copy_skill_to_ephemeral",
1310
+ skill_name=skill_name,
1311
+ error=str(e))
3332
1312
 
3333
- The callback should take a request object and request body dictionary and return:
3334
- - A route string if it should be routed to a different endpoint
3335
- - None if normal processing should continue
1313
+ # Re-bind the tool decorator method to the new instance
1314
+ ephemeral_agent.tool = ephemeral_agent._tool_decorator
3336
1315
 
3337
- Args:
3338
- callback_fn: The callback function to register
3339
- path: The path where this callback should be registered (default: "/sip")
3340
- """
3341
- # Normalize the path (remove trailing slash)
3342
- normalized_path = path.rstrip("/")
3343
- if not normalized_path.startswith("/"):
3344
- normalized_path = f"/{normalized_path}"
3345
-
3346
- # Store the callback with the normalized path (without trailing slash)
3347
- self.log.info("registering_routing_callback", path=normalized_path)
3348
- if not hasattr(self, '_routing_callbacks'):
3349
- self._routing_callbacks = {}
3350
- self._routing_callbacks[normalized_path] = callback_fn
3351
-
3352
- def set_dynamic_config_callback(self, callback: Callable[[dict, dict, dict, EphemeralAgentConfig], None]) -> 'AgentBase':
3353
- """
3354
- Set a callback function for dynamic agent configuration
1316
+ # Share the logger but bind it to indicate ephemeral copy
1317
+ ephemeral_agent.log = self.log.bind(ephemeral=True)
3355
1318
 
3356
- This callback receives an EphemeralAgentConfig object that provides the same
3357
- configuration methods as AgentBase, allowing you to dynamically configure
3358
- the agent's voice, prompt, parameters, etc. based on request data.
1319
+ # Mark this as an ephemeral agent to prevent double application of dynamic config
1320
+ ephemeral_agent._is_ephemeral = True
3359
1321
 
3360
- Args:
3361
- callback: Function that takes (query_params, body_params, headers, agent_config)
3362
- and configures the agent_config object using familiar methods like:
3363
- - agent_config.add_language(...)
3364
- - agent_config.prompt_add_section(...)
3365
- - agent_config.set_params(...)
3366
- - agent_config.set_global_data(...)
3367
-
3368
- Example:
3369
- def my_config(query_params, body_params, headers, agent):
3370
- if query_params.get('tier') == 'premium':
3371
- agent.add_language("English", "en-US", "premium_voice")
3372
- agent.set_params({"end_of_speech_timeout": 500})
3373
- agent.set_global_data({"tier": query_params.get('tier', 'standard')})
3374
-
3375
- my_agent.set_dynamic_config_callback(my_config)
3376
- """
3377
- self._dynamic_config_callback = callback
3378
- return self
3379
-
3380
- def manual_set_proxy_url(self, proxy_url: str) -> 'AgentBase':
1322
+ return ephemeral_agent
1323
+
1324
+ async def _handle_request(self, request: Request, response: Response):
3381
1325
  """
3382
- Manually set the proxy URL base for webhook callbacks
1326
+ Override SWMLService's _handle_request to use AgentBase's _render_swml
3383
1327
 
3384
- This can be called at runtime to set or update the proxy URL
3385
-
3386
- Args:
3387
- proxy_url: The base URL to use for webhooks (e.g., https://example.ngrok.io)
3388
-
3389
- Returns:
3390
- Self for method chaining
1328
+ This ensures that when routes are handled by SWMLService's router,
1329
+ they still use AgentBase's SWML rendering logic.
3391
1330
  """
3392
- if proxy_url:
3393
- # Set on self
3394
- self._proxy_url_base = proxy_url.rstrip('/')
3395
- self._proxy_detection_done = True
1331
+ # Use WebMixin's implementation if available
1332
+ if hasattr(super(), '_handle_root_request'):
1333
+ return await self._handle_root_request(request)
1334
+
1335
+ # Fallback to basic implementation
1336
+ try:
1337
+ # Parse body if POST request
1338
+ body = {}
1339
+ if request.method == "POST":
1340
+ try:
1341
+ body = await request.json()
1342
+ except:
1343
+ pass
3396
1344
 
3397
- # Set on parent if it has these attributes
3398
- if hasattr(super(), '_proxy_url_base'):
3399
- super()._proxy_url_base = self._proxy_url_base
3400
- if hasattr(super(), '_proxy_detection_done'):
3401
- super()._proxy_detection_done = True
3402
-
3403
- self.log.info("proxy_url_manually_set", proxy_url_base=self._proxy_url_base)
1345
+ # Get call_id
1346
+ call_id = body.get("call_id") if body else request.query_params.get("call_id")
3404
1347
 
3405
- return self
3406
-
3407
- # ----------------------------------------------------------------------
3408
- # Skill Management Methods
3409
- # ----------------------------------------------------------------------
3410
-
3411
- def add_skill(self, skill_name: str, params: Optional[Dict[str, Any]] = None) -> 'AgentBase':
3412
- """
3413
- Add a skill to this agent
3414
-
3415
- Args:
3416
- skill_name: Name of the skill to add
3417
- params: Optional parameters to pass to the skill for configuration
1348
+ # Check auth
1349
+ if not self._check_basic_auth(request):
1350
+ return Response(
1351
+ content=json.dumps({"error": "Unauthorized"}),
1352
+ status_code=401,
1353
+ headers={"WWW-Authenticate": "Basic"},
1354
+ media_type="application/json"
1355
+ )
3418
1356
 
3419
- Returns:
3420
- Self for method chaining
1357
+ # Render SWML using AgentBase's method
1358
+ swml = self._render_swml(call_id)
3421
1359
 
3422
- Raises:
3423
- ValueError: If skill not found or failed to load with detailed error message
3424
- """
3425
- success, error_message = self.skill_manager.load_skill(skill_name, params=params)
3426
- if not success:
3427
- raise ValueError(f"Failed to load skill '{skill_name}': {error_message}")
3428
- return self
3429
-
3430
- def remove_skill(self, skill_name: str) -> 'AgentBase':
3431
- """Remove a skill from this agent"""
3432
- self.skill_manager.unload_skill(skill_name)
3433
- return self
3434
-
3435
- def list_skills(self) -> List[str]:
3436
- """List currently loaded skills"""
3437
- return self.skill_manager.list_loaded_skills()
3438
-
3439
- def has_skill(self, skill_name: str) -> bool:
3440
- """Check if skill is loaded"""
3441
- return self.skill_manager.has_skill(skill_name)
1360
+ return Response(
1361
+ content=swml,
1362
+ media_type="application/json"
1363
+ )
1364
+ except Exception as e:
1365
+ return Response(
1366
+ content=json.dumps({"error": str(e)}),
1367
+ status_code=500,
1368
+ media_type="application/json"
1369
+ )
1370
+