signalwire-agents 0.1.6__py3-none-any.whl → 1.0.7__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. signalwire_agents/__init__.py +130 -4
  2. signalwire_agents/agent_server.py +438 -32
  3. signalwire_agents/agents/bedrock.py +296 -0
  4. signalwire_agents/cli/__init__.py +18 -0
  5. signalwire_agents/cli/build_search.py +1367 -0
  6. signalwire_agents/cli/config.py +80 -0
  7. signalwire_agents/cli/core/__init__.py +10 -0
  8. signalwire_agents/cli/core/agent_loader.py +470 -0
  9. signalwire_agents/cli/core/argparse_helpers.py +179 -0
  10. signalwire_agents/cli/core/dynamic_config.py +71 -0
  11. signalwire_agents/cli/core/service_loader.py +303 -0
  12. signalwire_agents/cli/execution/__init__.py +10 -0
  13. signalwire_agents/cli/execution/datamap_exec.py +446 -0
  14. signalwire_agents/cli/execution/webhook_exec.py +134 -0
  15. signalwire_agents/cli/init_project.py +1225 -0
  16. signalwire_agents/cli/output/__init__.py +10 -0
  17. signalwire_agents/cli/output/output_formatter.py +255 -0
  18. signalwire_agents/cli/output/swml_dump.py +186 -0
  19. signalwire_agents/cli/simulation/__init__.py +10 -0
  20. signalwire_agents/cli/simulation/data_generation.py +374 -0
  21. signalwire_agents/cli/simulation/data_overrides.py +200 -0
  22. signalwire_agents/cli/simulation/mock_env.py +282 -0
  23. signalwire_agents/cli/swaig_test_wrapper.py +52 -0
  24. signalwire_agents/cli/test_swaig.py +809 -0
  25. signalwire_agents/cli/types.py +81 -0
  26. signalwire_agents/core/__init__.py +2 -2
  27. signalwire_agents/core/agent/__init__.py +12 -0
  28. signalwire_agents/core/agent/config/__init__.py +12 -0
  29. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  30. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  31. signalwire_agents/core/agent/prompt/__init__.py +14 -0
  32. signalwire_agents/core/agent/prompt/manager.py +306 -0
  33. signalwire_agents/core/agent/routing/__init__.py +9 -0
  34. signalwire_agents/core/agent/security/__init__.py +9 -0
  35. signalwire_agents/core/agent/swml/__init__.py +9 -0
  36. signalwire_agents/core/agent/tools/__init__.py +15 -0
  37. signalwire_agents/core/agent/tools/decorator.py +97 -0
  38. signalwire_agents/core/agent/tools/registry.py +210 -0
  39. signalwire_agents/core/agent_base.py +959 -2166
  40. signalwire_agents/core/auth_handler.py +233 -0
  41. signalwire_agents/core/config_loader.py +259 -0
  42. signalwire_agents/core/contexts.py +707 -0
  43. signalwire_agents/core/data_map.py +487 -0
  44. signalwire_agents/core/function_result.py +1150 -1
  45. signalwire_agents/core/logging_config.py +376 -0
  46. signalwire_agents/core/mixins/__init__.py +28 -0
  47. signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
  48. signalwire_agents/core/mixins/auth_mixin.py +287 -0
  49. signalwire_agents/core/mixins/prompt_mixin.py +358 -0
  50. signalwire_agents/core/mixins/serverless_mixin.py +368 -0
  51. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  52. signalwire_agents/core/mixins/state_mixin.py +153 -0
  53. signalwire_agents/core/mixins/tool_mixin.py +230 -0
  54. signalwire_agents/core/mixins/web_mixin.py +1134 -0
  55. signalwire_agents/core/security/session_manager.py +174 -86
  56. signalwire_agents/core/security_config.py +333 -0
  57. signalwire_agents/core/skill_base.py +200 -0
  58. signalwire_agents/core/skill_manager.py +244 -0
  59. signalwire_agents/core/swaig_function.py +33 -9
  60. signalwire_agents/core/swml_builder.py +212 -12
  61. signalwire_agents/core/swml_handler.py +43 -13
  62. signalwire_agents/core/swml_renderer.py +123 -297
  63. signalwire_agents/core/swml_service.py +277 -260
  64. signalwire_agents/prefabs/concierge.py +6 -2
  65. signalwire_agents/prefabs/info_gatherer.py +149 -33
  66. signalwire_agents/prefabs/receptionist.py +14 -22
  67. signalwire_agents/prefabs/survey.py +6 -2
  68. signalwire_agents/schema.json +9218 -5489
  69. signalwire_agents/search/__init__.py +137 -0
  70. signalwire_agents/search/document_processor.py +1223 -0
  71. signalwire_agents/search/index_builder.py +804 -0
  72. signalwire_agents/search/migration.py +418 -0
  73. signalwire_agents/search/models.py +30 -0
  74. signalwire_agents/search/pgvector_backend.py +752 -0
  75. signalwire_agents/search/query_processor.py +502 -0
  76. signalwire_agents/search/search_engine.py +1264 -0
  77. signalwire_agents/search/search_service.py +574 -0
  78. signalwire_agents/skills/README.md +452 -0
  79. signalwire_agents/skills/__init__.py +23 -0
  80. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  81. signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
  82. signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
  83. signalwire_agents/skills/datasphere/README.md +210 -0
  84. signalwire_agents/skills/datasphere/__init__.py +12 -0
  85. signalwire_agents/skills/datasphere/skill.py +310 -0
  86. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  87. signalwire_agents/skills/datasphere_serverless/__init__.py +10 -0
  88. signalwire_agents/skills/datasphere_serverless/skill.py +237 -0
  89. signalwire_agents/skills/datetime/README.md +132 -0
  90. signalwire_agents/skills/datetime/__init__.py +10 -0
  91. signalwire_agents/skills/datetime/skill.py +126 -0
  92. signalwire_agents/skills/joke/README.md +149 -0
  93. signalwire_agents/skills/joke/__init__.py +10 -0
  94. signalwire_agents/skills/joke/skill.py +109 -0
  95. signalwire_agents/skills/math/README.md +161 -0
  96. signalwire_agents/skills/math/__init__.py +10 -0
  97. signalwire_agents/skills/math/skill.py +105 -0
  98. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  99. signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
  100. signalwire_agents/skills/mcp_gateway/skill.py +421 -0
  101. signalwire_agents/skills/native_vector_search/README.md +210 -0
  102. signalwire_agents/skills/native_vector_search/__init__.py +10 -0
  103. signalwire_agents/skills/native_vector_search/skill.py +820 -0
  104. signalwire_agents/skills/play_background_file/README.md +218 -0
  105. signalwire_agents/skills/play_background_file/__init__.py +12 -0
  106. signalwire_agents/skills/play_background_file/skill.py +242 -0
  107. signalwire_agents/skills/registry.py +459 -0
  108. signalwire_agents/skills/spider/README.md +236 -0
  109. signalwire_agents/skills/spider/__init__.py +13 -0
  110. signalwire_agents/skills/spider/skill.py +598 -0
  111. signalwire_agents/skills/swml_transfer/README.md +395 -0
  112. signalwire_agents/skills/swml_transfer/__init__.py +10 -0
  113. signalwire_agents/skills/swml_transfer/skill.py +359 -0
  114. signalwire_agents/skills/weather_api/README.md +178 -0
  115. signalwire_agents/skills/weather_api/__init__.py +12 -0
  116. signalwire_agents/skills/weather_api/skill.py +191 -0
  117. signalwire_agents/skills/web_search/README.md +163 -0
  118. signalwire_agents/skills/web_search/__init__.py +10 -0
  119. signalwire_agents/skills/web_search/skill.py +739 -0
  120. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  121. signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
  122. signalwire_agents/skills/wikipedia_search/skill.py +210 -0
  123. signalwire_agents/utils/__init__.py +14 -0
  124. signalwire_agents/utils/schema_utils.py +111 -44
  125. signalwire_agents/web/__init__.py +17 -0
  126. signalwire_agents/web/web_service.py +559 -0
  127. signalwire_agents-1.0.7.data/data/share/man/man1/sw-agent-init.1 +307 -0
  128. signalwire_agents-1.0.7.data/data/share/man/man1/sw-search.1 +483 -0
  129. signalwire_agents-1.0.7.data/data/share/man/man1/swaig-test.1 +308 -0
  130. signalwire_agents-1.0.7.dist-info/METADATA +992 -0
  131. signalwire_agents-1.0.7.dist-info/RECORD +142 -0
  132. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/WHEEL +1 -1
  133. signalwire_agents-1.0.7.dist-info/entry_points.txt +4 -0
  134. signalwire_agents/core/state/file_state_manager.py +0 -219
  135. signalwire_agents/core/state/state_manager.py +0 -101
  136. signalwire_agents-0.1.6.data/data/schema.json +0 -5611
  137. signalwire_agents-0.1.6.dist-info/METADATA +0 -199
  138. signalwire_agents-0.1.6.dist-info/RECORD +0 -34
  139. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/licenses/LICENSE +0 -0
  140. {signalwire_agents-0.1.6.dist-info → signalwire_agents-1.0.7.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env python3
1
2
  """
2
3
  Copyright (c) 2025 SignalWire
3
4
 
@@ -7,24 +8,24 @@ Licensed under the MIT License.
7
8
  See LICENSE file in the project root for full license information.
8
9
  """
9
10
 
11
+ # -*- coding: utf-8 -*-
10
12
  """
11
- AgentBase - Core foundation class for all SignalWire AI Agents
13
+ Base class for all SignalWire AI Agents
12
14
  """
13
15
 
14
- import functools
15
- import inspect
16
16
  import os
17
- import sys
17
+ import json
18
+ import time
18
19
  import uuid
19
- import tempfile
20
- import traceback
21
- from typing import Dict, List, Any, Optional, Union, Callable, Tuple, Type, TypeVar
22
20
  import base64
23
- import secrets
24
- from urllib.parse import urlparse
25
- import json
26
- from datetime import datetime
21
+ import logging
22
+ import inspect
23
+ import functools
27
24
  import re
25
+ import signal
26
+ import sys
27
+ from typing import Optional, Union, List, Dict, Any, Tuple, Callable, Type
28
+ from urllib.parse import urlparse, urlencode, urlunparse
28
29
 
29
30
  try:
30
31
  import fastapi
@@ -43,45 +44,47 @@ except ImportError:
43
44
  "uvicorn is required. Install it with: pip install uvicorn"
44
45
  )
45
46
 
46
- try:
47
- import structlog
48
- # Configure structlog only if not already configured
49
- if not structlog.is_configured():
50
- structlog.configure(
51
- processors=[
52
- structlog.stdlib.filter_by_level,
53
- structlog.stdlib.add_logger_name,
54
- structlog.stdlib.add_log_level,
55
- structlog.stdlib.PositionalArgumentsFormatter(),
56
- structlog.processors.TimeStamper(fmt="iso"),
57
- structlog.processors.StackInfoRenderer(),
58
- structlog.processors.format_exc_info,
59
- structlog.processors.UnicodeDecoder(),
60
- structlog.processors.JSONRenderer()
61
- ],
62
- context_class=dict,
63
- logger_factory=structlog.stdlib.LoggerFactory(),
64
- wrapper_class=structlog.stdlib.BoundLogger,
65
- cache_logger_on_first_use=True,
66
- )
67
- except ImportError:
68
- raise ImportError(
69
- "structlog is required. Install it with: pip install structlog"
70
- )
71
-
72
47
  from signalwire_agents.core.pom_builder import PomBuilder
73
48
  from signalwire_agents.core.swaig_function import SWAIGFunction
74
49
  from signalwire_agents.core.function_result import SwaigFunctionResult
75
50
  from signalwire_agents.core.swml_renderer import SwmlRenderer
76
51
  from signalwire_agents.core.security.session_manager import SessionManager
77
- from signalwire_agents.core.state import StateManager, FileStateManager
78
52
  from signalwire_agents.core.swml_service import SWMLService
79
53
  from signalwire_agents.core.swml_handler import AIVerbHandler
54
+ from signalwire_agents.core.skill_manager import SkillManager
55
+ from signalwire_agents.utils.schema_utils import SchemaUtils
56
+ from signalwire_agents.core.logging_config import get_logger, get_execution_mode
57
+
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
62
+
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
80
72
 
81
- # Create a logger
82
- logger = structlog.get_logger("agent_base")
73
+ # Create a logger using centralized system
74
+ logger = get_logger("agent_base")
83
75
 
84
- class AgentBase(SWMLService):
76
+
77
+ class AgentBase(
78
+ AuthMixin,
79
+ WebMixin,
80
+ SWMLService,
81
+ PromptMixin,
82
+ ToolMixin,
83
+ SkillMixin,
84
+ AIConfigMixin,
85
+ ServerlessMixin,
86
+ StateMixin
87
+ ):
85
88
  """
86
89
  Base class for all SignalWire AI Agents.
87
90
 
@@ -109,18 +112,19 @@ class AgentBase(SWMLService):
109
112
  port: int = 3000,
110
113
  basic_auth: Optional[Tuple[str, str]] = None,
111
114
  use_pom: bool = True,
112
- enable_state_tracking: bool = False,
113
- token_expiry_secs: int = 600,
115
+ token_expiry_secs: int = 3600,
114
116
  auto_answer: bool = True,
115
117
  record_call: bool = False,
116
118
  record_format: str = "mp4",
117
119
  record_stereo: bool = True,
118
- state_manager: Optional[StateManager] = None,
119
120
  default_webhook_url: Optional[str] = None,
120
121
  agent_id: Optional[str] = None,
121
122
  native_functions: Optional[List[str]] = None,
122
123
  schema_path: Optional[str] = None,
123
- suppress_logs: bool = False
124
+ suppress_logs: bool = False,
125
+ enable_post_prompt_override: bool = False,
126
+ check_for_input_override: bool = False,
127
+ config_file: Optional[str] = None
124
128
  ):
125
129
  """
126
130
  Initialize a new agent
@@ -132,18 +136,19 @@ class AgentBase(SWMLService):
132
136
  port: Port to bind the web server to
133
137
  basic_auth: Optional (username, password) tuple for basic auth
134
138
  use_pom: Whether to use POM for prompt building
135
- enable_state_tracking: Whether to register startup_hook and hangup_hook SWAIG functions to track conversation state
136
139
  token_expiry_secs: Seconds until tokens expire
137
140
  auto_answer: Whether to automatically answer calls
138
141
  record_call: Whether to record calls
139
142
  record_format: Recording format
140
143
  record_stereo: Whether to record in stereo
141
- state_manager: Optional state manager for this agent
142
144
  default_webhook_url: Optional default webhook URL for all SWAIG functions
143
145
  agent_id: Optional unique ID for this agent, generated if not provided
144
146
  native_functions: Optional list of native functions to include in the SWAIG object
145
147
  schema_path: Optional path to the schema file
146
148
  suppress_logs: Whether to suppress structured logs
149
+ enable_post_prompt_override: Whether to enable post-prompt override
150
+ check_for_input_override: Whether to enable check-for-input override
151
+ config_file: Optional path to configuration file
147
152
  """
148
153
  # Import SWMLService here to avoid circular imports
149
154
  from signalwire_agents.core.swml_service import SWMLService
@@ -151,19 +156,29 @@ class AgentBase(SWMLService):
151
156
  # If schema_path is not provided, we'll let SWMLService find it through its _find_schema_path method
152
157
  # which will be called in its __init__
153
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
+ final_port = port if port != 3000 else service_config.get('port', port)
166
+ final_name = service_config.get('name', name)
167
+
154
168
  # Initialize the SWMLService base class
155
169
  super().__init__(
156
- name=name,
157
- route=route,
158
- host=host,
159
- port=port,
170
+ name=final_name,
171
+ route=final_route,
172
+ host=final_host,
173
+ port=final_port,
160
174
  basic_auth=basic_auth,
161
- schema_path=schema_path
175
+ schema_path=schema_path,
176
+ config_file=config_file
162
177
  )
163
178
 
164
179
  # Log the schema path if found and not suppressing logs
165
180
  if self.schema_utils and self.schema_utils.schema_path and not suppress_logs:
166
- print(f"Using schema.json at: {self.schema_utils.schema_path}")
181
+ self.log.debug("using_schema_path", path=self.schema_utils.schema_path)
167
182
 
168
183
  # Setup logger for this instance
169
184
  self.log = logger.bind(agent=name)
@@ -181,8 +196,6 @@ class AgentBase(SWMLService):
181
196
 
182
197
  # Initialize prompt handling
183
198
  self._use_pom = use_pom
184
- self._raw_prompt = None
185
- self._post_prompt = None
186
199
 
187
200
  # Initialize POM if needed
188
201
  if self._use_pom:
@@ -198,11 +211,9 @@ class AgentBase(SWMLService):
198
211
  self.pom = None
199
212
 
200
213
  # Initialize tool registry (separate from SWMLService verb registry)
201
- self._swaig_functions: Dict[str, SWAIGFunction] = {}
202
214
 
203
215
  # Initialize session manager
204
216
  self._session_manager = SessionManager(token_expiry_secs=token_expiry_secs)
205
- self._enable_state_tracking = enable_state_tracking
206
217
 
207
218
  # URL override variables
208
219
  self._web_hook_url_override = None
@@ -217,21 +228,20 @@ class AgentBase(SWMLService):
217
228
  self._record_format = record_format
218
229
  self._record_stereo = record_stereo
219
230
 
231
+ # Initialize refactored managers early
232
+ self._prompt_manager = PromptManager(self)
233
+ self._tool_registry = ToolRegistry(self)
234
+
220
235
  # Process declarative PROMPT_SECTIONS if defined in subclass
221
236
  self._process_prompt_sections()
222
237
 
223
- # Initialize state manager
224
- self._state_manager = state_manager or FileStateManager()
225
238
 
226
239
  # Process class-decorated tools (using @AgentBase.tool)
227
- self._register_class_decorated_tools()
240
+ self._tool_registry.register_class_decorated_tools()
228
241
 
229
242
  # Add native_functions parameter
230
243
  self.native_functions = native_functions or []
231
244
 
232
- # Register state tracking tools if enabled
233
- if enable_state_tracking:
234
- self._register_state_tracking_tools()
235
245
 
236
246
  # Initialize new configuration containers
237
247
  self._hints = []
@@ -240,646 +250,464 @@ class AgentBase(SWMLService):
240
250
  self._params = {}
241
251
  self._global_data = {}
242
252
  self._function_includes = []
253
+ # Initialize LLM params as empty - only send if explicitly set
254
+ self._prompt_llm_params = {}
255
+ self._post_prompt_llm_params = {}
256
+
257
+ # Dynamic configuration callback
258
+ self._dynamic_config_callback = None
259
+
260
+ # Initialize skill manager
261
+ self.skill_manager = SkillManager(self)
262
+
263
+ # Initialize contexts system
264
+ self._contexts_builder = None
265
+ self._contexts_defined = False
266
+
267
+ # Initialize SWAIG query params for dynamic config
268
+ self._swaig_query_params = {}
269
+
270
+ # Initialize verb insertion points for call flow customization
271
+ self._pre_answer_verbs = [] # Verbs to run before answer (e.g., ringback, screening)
272
+ self._answer_config = {} # Configuration for the answer verb
273
+ self._post_answer_verbs = [] # Verbs to run after answer, before AI (e.g., announcements)
274
+ self._post_ai_verbs = [] # Verbs to run after AI ends (e.g., cleanup, transfers)
275
+
276
+ # Verb categories for pre-answer validation
277
+ _PRE_ANSWER_SAFE_VERBS = {
278
+ "transfer", "execute", "return", "label", "goto", "request",
279
+ "switch", "cond", "if", "eval", "set", "unset", "hangup",
280
+ "send_sms", "sleep", "stop_record_call", "stop_denoise", "stop_tap"
281
+ }
282
+ _AUTO_ANSWER_VERBS = {"play", "connect"}
283
+
284
+ @staticmethod
285
+ def _load_service_config(config_file: Optional[str], service_name: str) -> dict:
286
+ """Load service configuration from config file if available"""
287
+ from signalwire_agents.core.config_loader import ConfigLoader
288
+
289
+ # Find config file
290
+ if not config_file:
291
+ config_file = ConfigLoader.find_config_file(service_name)
292
+
293
+ if not config_file:
294
+ return {}
295
+
296
+ # Load config
297
+ config_loader = ConfigLoader([config_file])
298
+ if not config_loader.has_config():
299
+ return {}
300
+
301
+ # Get service section
302
+ service_config = config_loader.get_section('service')
303
+ if service_config:
304
+ return service_config
305
+
306
+ return {}
243
307
 
244
- def _process_prompt_sections(self):
308
+ def get_name(self) -> str:
245
309
  """
246
- Process declarative PROMPT_SECTIONS attribute from a subclass
310
+ Get agent name
247
311
 
248
- This auto-vivifies section methods and bootstraps the prompt
249
- from class declaration, allowing for declarative agents.
312
+ Returns:
313
+ Agent name
250
314
  """
251
- # Skip if no PROMPT_SECTIONS defined or not using POM
252
- cls = self.__class__
253
- if not hasattr(cls, 'PROMPT_SECTIONS') or cls.PROMPT_SECTIONS is None or not self._use_pom:
254
- return
255
-
256
- sections = cls.PROMPT_SECTIONS
257
-
258
- # If sections is a dictionary mapping section names to content
259
- if isinstance(sections, dict):
260
- for title, content in sections.items():
261
- # Handle different content types
262
- if isinstance(content, str):
263
- # Plain text - add as body
264
- self.prompt_add_section(title, body=content)
265
- elif isinstance(content, list) and content: # Only add if non-empty
266
- # List of strings - add as bullets
267
- self.prompt_add_section(title, bullets=content)
268
- elif isinstance(content, dict):
269
- # Dictionary with body/bullets/subsections
270
- body = content.get('body', '')
271
- bullets = content.get('bullets', [])
272
- numbered = content.get('numbered', False)
273
- numbered_bullets = content.get('numberedBullets', False)
274
-
275
- # Only create section if it has content
276
- if body or bullets or 'subsections' in content:
277
- # Create the section
278
- self.prompt_add_section(
279
- title,
280
- body=body,
281
- bullets=bullets if bullets else None,
282
- numbered=numbered,
283
- numbered_bullets=numbered_bullets
284
- )
285
-
286
- # Process subsections if any
287
- subsections = content.get('subsections', [])
288
- for subsection in subsections:
289
- if 'title' in subsection:
290
- sub_title = subsection['title']
291
- sub_body = subsection.get('body', '')
292
- sub_bullets = subsection.get('bullets', [])
293
-
294
- # Only add subsection if it has content
295
- if sub_body or sub_bullets:
296
- self.prompt_add_subsection(
297
- title,
298
- sub_title,
299
- body=sub_body,
300
- bullets=sub_bullets if sub_bullets else None
301
- )
302
- # If sections is a list of section objects, use the POM format directly
303
- elif isinstance(sections, list):
304
- if self.pom:
305
- # Process each section using auto-vivifying methods
306
- for section in sections:
307
- if 'title' in section:
308
- title = section['title']
309
- body = section.get('body', '')
310
- bullets = section.get('bullets', [])
311
- numbered = section.get('numbered', False)
312
- numbered_bullets = section.get('numberedBullets', False)
313
-
314
- # Only create section if it has content
315
- if body or bullets or 'subsections' in section:
316
- self.prompt_add_section(
317
- title,
318
- body=body,
319
- bullets=bullets if bullets else None,
320
- numbered=numbered,
321
- numbered_bullets=numbered_bullets
322
- )
323
-
324
- # Process subsections if any
325
- subsections = section.get('subsections', [])
326
- for subsection in subsections:
327
- if 'title' in subsection:
328
- sub_title = subsection['title']
329
- sub_body = subsection.get('body', '')
330
- sub_bullets = subsection.get('bullets', [])
331
-
332
- # Only add subsection if it has content
333
- if sub_body or sub_bullets:
334
- self.prompt_add_subsection(
335
- title,
336
- sub_title,
337
- body=sub_body,
338
- bullets=sub_bullets if sub_bullets else None
339
- )
340
-
341
- # ----------------------------------------------------------------------
342
- # Prompt Building Methods
343
- # ----------------------------------------------------------------------
315
+ return self.name
344
316
 
345
- def set_prompt_text(self, text: str) -> 'AgentBase':
317
+ def get_full_url(self, include_auth: bool = False) -> str:
346
318
  """
347
- Set the prompt as raw text instead of using POM
319
+ Get the full URL for this agent's endpoint
348
320
 
349
321
  Args:
350
- text: The raw prompt text
322
+ include_auth: Whether to include authentication credentials in the URL
351
323
 
324
+ Returns:
325
+ Full URL including host, port, and route (with auth if requested)
326
+ """
327
+ mode = get_execution_mode()
328
+
329
+ if mode == 'cgi':
330
+ protocol = 'https' if os.getenv('HTTPS') == 'on' else 'http'
331
+ host = os.getenv('HTTP_HOST') or os.getenv('SERVER_NAME') or 'localhost'
332
+ script_name = os.getenv('SCRIPT_NAME', '')
333
+ base_url = f"{protocol}://{host}{script_name}"
334
+ elif mode == 'lambda':
335
+ # AWS Lambda Function URL format
336
+ lambda_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
337
+ if lambda_url:
338
+ base_url = lambda_url.rstrip('/')
339
+ else:
340
+ # Fallback construction for Lambda
341
+ region = os.getenv('AWS_REGION', 'us-east-1')
342
+ function_name = os.getenv('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
343
+ base_url = f"https://{function_name}.lambda-url.{region}.on.aws"
344
+ elif mode == 'google_cloud_function':
345
+ # Google Cloud Functions URL format
346
+ project_id = os.getenv('GOOGLE_CLOUD_PROJECT') or os.getenv('GCP_PROJECT')
347
+ region = os.getenv('FUNCTION_REGION') or os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
348
+ service_name = os.getenv('K_SERVICE') or os.getenv('FUNCTION_TARGET', 'unknown')
349
+
350
+ if project_id:
351
+ base_url = f"https://{region}-{project_id}.cloudfunctions.net/{service_name}"
352
+ else:
353
+ # Fallback for local testing or incomplete environment
354
+ base_url = f"https://localhost:8080"
355
+ elif mode == 'azure_function':
356
+ # Azure Functions URL format
357
+ function_app_name = os.getenv('WEBSITE_SITE_NAME') or os.getenv('AZURE_FUNCTIONS_APP_NAME')
358
+ function_name = os.getenv('AZURE_FUNCTION_NAME', 'unknown')
359
+
360
+ if function_app_name:
361
+ base_url = f"https://{function_app_name}.azurewebsites.net/api/{function_name}"
362
+ else:
363
+ # Fallback for local testing
364
+ base_url = f"https://localhost:7071/api/{function_name}"
365
+ else:
366
+ # Server mode - use the SWMLService's unified URL building
367
+ # Build the full URL using the parent's method
368
+ base_url = self._build_full_url(endpoint="", include_auth=include_auth)
369
+ return base_url
370
+
371
+ # For serverless modes, add authentication if requested
372
+ if include_auth:
373
+ username, password = self.get_basic_auth_credentials()
374
+ if username and password:
375
+ # Parse URL to insert auth
376
+ from urllib.parse import urlparse, urlunparse
377
+ parsed = urlparse(base_url)
378
+ # Reconstruct with auth
379
+ base_url = urlunparse((
380
+ parsed.scheme,
381
+ f"{username}:{password}@{parsed.netloc}",
382
+ parsed.path,
383
+ parsed.params,
384
+ parsed.query,
385
+ parsed.fragment
386
+ ))
387
+
388
+ # Add route for serverless modes
389
+ if self.route and self.route != "/" and not base_url.endswith(self.route):
390
+ base_url = f"{base_url}/{self.route.lstrip('/')}"
391
+
392
+ return base_url
393
+
394
+ def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
395
+ """
396
+ Called when a post-prompt summary is received
397
+
398
+ Args:
399
+ summary: The summary object or None if no summary was found
400
+ raw_data: The complete raw POST data from the request
401
+ """
402
+ # Default implementation does nothing
403
+ pass
404
+
405
+ # ==================== Call Flow Verb Insertion Methods ====================
406
+
407
+ def add_pre_answer_verb(self, verb_name: str, config: Dict[str, Any]) -> 'AgentBase':
408
+ """
409
+ Add a verb to run before the call is answered.
410
+
411
+ Pre-answer verbs execute while the call is still ringing. Only certain
412
+ verbs are safe to use before answering:
413
+
414
+ Safe verbs: transfer, execute, return, label, goto, request, switch,
415
+ cond, if, eval, set, unset, hangup, send_sms, sleep,
416
+ stop_record_call, stop_denoise, stop_tap
417
+
418
+ Verbs with auto_answer option (play, connect): Must include
419
+ "auto_answer": False in config to prevent automatic answering.
420
+
421
+ Args:
422
+ verb_name: The SWML verb name (e.g., "play", "sleep", "request")
423
+ config: Verb configuration dictionary
424
+
352
425
  Returns:
353
426
  Self for method chaining
427
+
428
+ Raises:
429
+ ValueError: If verb is not safe for pre-answer use
430
+
431
+ Example:
432
+ # Play ringback tone before answering
433
+ agent.add_pre_answer_verb("play", {
434
+ "urls": ["ring:us"],
435
+ "auto_answer": False
436
+ })
354
437
  """
355
- self._raw_prompt = text
438
+ # Validate verb is safe for pre-answer use
439
+ if verb_name in self._AUTO_ANSWER_VERBS:
440
+ if not config.get("auto_answer") is False:
441
+ self.log.warning(
442
+ "pre_answer_verb_will_answer",
443
+ verb=verb_name,
444
+ hint=f"Add 'auto_answer': False to prevent {verb_name} from answering the call"
445
+ )
446
+ elif verb_name not in self._PRE_ANSWER_SAFE_VERBS:
447
+ raise ValueError(
448
+ f"Verb '{verb_name}' is not safe for pre-answer use. "
449
+ f"Safe verbs: {', '.join(sorted(self._PRE_ANSWER_SAFE_VERBS))}"
450
+ )
451
+
452
+ self._pre_answer_verbs.append((verb_name, config))
356
453
  return self
357
-
358
- def set_prompt_pom(self, pom: List[Dict[str, Any]]) -> 'AgentBase':
454
+
455
+ def add_answer_verb(self, config: Optional[Dict[str, Any]] = None) -> 'AgentBase':
359
456
  """
360
- Set the prompt as a POM dictionary
361
-
457
+ Configure the answer verb.
458
+
459
+ The answer verb connects the call. Use this method to customize
460
+ answer behavior, such as setting max_duration.
461
+
362
462
  Args:
363
- pom: POM dictionary structure
364
-
463
+ config: Optional answer verb configuration (e.g., {"max_duration": 3600})
464
+
365
465
  Returns:
366
466
  Self for method chaining
467
+
468
+ Example:
469
+ # Set maximum call duration to 1 hour
470
+ agent.add_answer_verb({"max_duration": 3600})
367
471
  """
368
- if self._use_pom:
369
- self.pom = pom
370
- else:
371
- raise ValueError("use_pom must be True to use set_prompt_pom")
472
+ self._answer_config = config or {}
372
473
  return self
373
-
374
- def prompt_add_section(
375
- self,
376
- title: str,
377
- body: str = "",
378
- bullets: Optional[List[str]] = None,
379
- numbered: bool = False,
380
- numbered_bullets: bool = False,
381
- subsections: Optional[List[Dict[str, Any]]] = None
382
- ) -> 'AgentBase':
474
+
475
+ def add_post_answer_verb(self, verb_name: str, config: Dict[str, Any]) -> 'AgentBase':
383
476
  """
384
- Add a section to the prompt
385
-
477
+ Add a verb to run after the call is answered but before the AI starts.
478
+
479
+ Post-answer verbs run after the call is connected. Common uses include
480
+ welcome messages, legal disclaimers, and hold music.
481
+
386
482
  Args:
387
- title: Section title
388
- body: Optional section body text
389
- bullets: Optional list of bullet points
390
- numbered: Whether this section should be numbered
391
- numbered_bullets: Whether bullets should be numbered
392
- subsections: Optional list of subsection objects
393
-
483
+ verb_name: The SWML verb name (e.g., "play", "sleep")
484
+ config: Verb configuration dictionary
485
+
394
486
  Returns:
395
487
  Self for method chaining
488
+
489
+ Example:
490
+ # Play welcome message
491
+ agent.add_post_answer_verb("play", {
492
+ "url": "say:Welcome to Acme Corporation."
493
+ })
494
+ # Brief pause
495
+ agent.add_post_answer_verb("sleep", {"time": 500})
396
496
  """
397
- if self._use_pom and self.pom:
398
- # Create parameters for add_section based on what's supported
399
- kwargs = {}
400
-
401
- # Start with basic parameters
402
- kwargs['title'] = title
403
- kwargs['body'] = body
404
- if bullets:
405
- kwargs['bullets'] = bullets
406
-
407
- # Add optional parameters if they look supported
408
- if hasattr(self.pom, 'add_section'):
409
- sig = inspect.signature(self.pom.add_section)
410
- if 'numbered' in sig.parameters:
411
- kwargs['numbered'] = numbered
412
- if 'numberedBullets' in sig.parameters:
413
- kwargs['numberedBullets'] = numbered_bullets
414
-
415
- # Create the section
416
- section = self.pom.add_section(**kwargs)
417
-
418
- # Now add subsections if provided, by calling add_subsection on the section
419
- if subsections:
420
- for subsection in subsections:
421
- if 'title' in subsection:
422
- section.add_subsection(
423
- title=subsection.get('title'),
424
- body=subsection.get('body', ''),
425
- bullets=subsection.get('bullets', [])
426
- )
427
-
497
+ self._post_answer_verbs.append((verb_name, config))
428
498
  return self
429
-
430
- def prompt_add_to_section(
431
- self,
432
- title: str,
433
- body: Optional[str] = None,
434
- bullet: Optional[str] = None,
435
- bullets: Optional[List[str]] = None
436
- ) -> 'AgentBase':
499
+
500
+ def add_post_ai_verb(self, verb_name: str, config: Dict[str, Any]) -> 'AgentBase':
437
501
  """
438
- Add content to an existing section (creating it if needed)
439
-
502
+ Add a verb to run after the AI conversation ends.
503
+
504
+ Post-AI verbs run when the AI completes its conversation. Common uses
505
+ include clean disconnects, transfers, and logging.
506
+
440
507
  Args:
441
- title: Section title
442
- body: Optional text to append to section body
443
- bullet: Optional single bullet point to add
444
- bullets: Optional list of bullet points to add
445
-
508
+ verb_name: The SWML verb name (e.g., "hangup", "transfer", "request")
509
+ config: Verb configuration dictionary
510
+
446
511
  Returns:
447
512
  Self for method chaining
513
+
514
+ Example:
515
+ # Log call completion and hang up
516
+ agent.add_post_ai_verb("request", {
517
+ "url": "https://api.example.com/call-complete",
518
+ "method": "POST"
519
+ })
520
+ agent.add_post_ai_verb("hangup", {})
448
521
  """
449
- if self._use_pom and self.pom:
450
- self.pom.add_to_section(
451
- title=title,
452
- body=body,
453
- bullet=bullet,
454
- bullets=bullets
455
- )
522
+ self._post_ai_verbs.append((verb_name, config))
456
523
  return self
457
-
458
- def prompt_add_subsection(
459
- self,
460
- parent_title: str,
461
- title: str,
462
- body: str = "",
463
- bullets: Optional[List[str]] = None
464
- ) -> 'AgentBase':
524
+
525
+ def clear_pre_answer_verbs(self) -> 'AgentBase':
526
+ """
527
+ Remove all pre-answer verbs.
528
+
529
+ Returns:
530
+ Self for method chaining
531
+ """
532
+ self._pre_answer_verbs = []
533
+ return self
534
+
535
+ def clear_post_answer_verbs(self) -> 'AgentBase':
536
+ """
537
+ Remove all post-answer verbs.
538
+
539
+ Returns:
540
+ Self for method chaining
541
+ """
542
+ self._post_answer_verbs = []
543
+ return self
544
+
545
+ def clear_post_ai_verbs(self) -> 'AgentBase':
546
+ """
547
+ Remove all post-AI verbs.
548
+
549
+ Returns:
550
+ Self for method chaining
551
+ """
552
+ self._post_ai_verbs = []
553
+ return self
554
+
555
+ # ==================== End Call Flow Verb Insertion Methods ====================
556
+
557
+ def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
465
558
  """
466
- Add a subsection to an existing section (creating parent if needed)
559
+ Enable SIP-based routing for this agent
560
+
561
+ This allows the agent to automatically route SIP requests based on SIP usernames.
562
+ When enabled, an endpoint at the specified path is automatically created
563
+ that will handle SIP requests and deliver them to this agent.
467
564
 
468
565
  Args:
469
- parent_title: Parent section title
470
- title: Subsection title
471
- body: Optional subsection body text
472
- bullets: Optional list of bullet points
473
-
566
+ auto_map: Whether to automatically map common SIP usernames to this agent
567
+ (based on the agent name and route path)
568
+ path: The path to register the SIP routing endpoint (default: "/sip")
569
+
474
570
  Returns:
475
571
  Self for method chaining
476
572
  """
477
- if self._use_pom and self.pom:
478
- # First find or create the parent section
479
- parent_section = None
480
-
481
- # Try to find the parent section by title
482
- if hasattr(self.pom, 'sections'):
483
- for section in self.pom.sections:
484
- if hasattr(section, 'title') and section.title == parent_title:
485
- parent_section = section
486
- break
573
+ # Create a routing callback that handles SIP usernames
574
+ def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
575
+ # Extract SIP username from the request body
576
+ sip_username = self.extract_sip_username(body)
487
577
 
488
- # If parent section not found, create it
489
- if not parent_section:
490
- parent_section = self.pom.add_section(title=parent_title)
578
+ if sip_username:
579
+ self.log.info("sip_username_extracted", username=sip_username)
580
+
581
+ # Check if this username is registered with this agent
582
+ if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
583
+ self.log.info("sip_username_matched", username=sip_username)
584
+ # This route is already being handled by the agent, no need to redirect
585
+ return None
586
+ else:
587
+ self.log.info("sip_username_not_matched", username=sip_username)
588
+ # Not registered with this agent, let routing continue
589
+
590
+ return None
491
591
 
492
- # Now call add_subsection on the parent section object, not on POM
493
- parent_section.add_subsection(
494
- title=title,
495
- body=body,
496
- bullets=bullets or []
497
- )
592
+ # Register the callback with the SWMLService, specifying the path
593
+ self.register_routing_callback(sip_routing_callback, path=path)
594
+
595
+ # Auto-map common usernames if requested
596
+ if auto_map:
597
+ self.auto_map_sip_usernames()
498
598
 
499
599
  return self
500
600
 
501
- # ----------------------------------------------------------------------
502
- # Tool/Function Management
503
- # ----------------------------------------------------------------------
504
-
505
- def define_tool(
506
- self,
507
- name: str,
508
- description: str,
509
- parameters: Dict[str, Any],
510
- handler: Callable,
511
- secure: bool = True,
512
- fillers: Optional[Dict[str, List[str]]] = None
513
- ) -> 'AgentBase':
601
+ def register_sip_username(self, sip_username: str) -> 'AgentBase':
514
602
  """
515
- Define a SWAIG function that the AI can call
603
+ Register a SIP username that should be routed to this agent
516
604
 
517
605
  Args:
518
- name: Function name (must be unique)
519
- description: Function description for the AI
520
- parameters: JSON Schema of parameters
521
- handler: Function to call when invoked
522
- secure: Whether to require token validation
523
- fillers: Optional dict mapping language codes to arrays of filler phrases
606
+ sip_username: SIP username to register
524
607
 
525
608
  Returns:
526
609
  Self for method chaining
527
610
  """
528
- if name in self._swaig_functions:
529
- raise ValueError(f"Tool with name '{name}' already exists")
611
+ if not hasattr(self, '_sip_usernames'):
612
+ self._sip_usernames = set()
530
613
 
531
- self._swaig_functions[name] = SWAIGFunction(
532
- name=name,
533
- description=description,
534
- parameters=parameters,
535
- handler=handler,
536
- secure=secure,
537
- fillers=fillers
538
- )
614
+ self._sip_usernames.add(sip_username.lower())
615
+ self.log.info("sip_username_registered", username=sip_username)
616
+
539
617
  return self
540
618
 
541
- def _tool_decorator(self, name=None, **kwargs):
619
+ def auto_map_sip_usernames(self) -> 'AgentBase':
542
620
  """
543
- Decorator for defining SWAIG tools in a class
544
-
545
- Used as:
621
+ Automatically register common SIP usernames based on this agent's
622
+ name and route
546
623
 
547
- @agent.tool(name="example_function", parameters={...})
548
- def example_function(self, param1):
549
- # ...
624
+ Returns:
625
+ Self for method chaining
550
626
  """
551
- def decorator(func):
552
- nonlocal name
553
- if name is None:
554
- name = func.__name__
555
-
556
- parameters = kwargs.get("parameters", {})
557
- description = kwargs.get("description", func.__doc__ or f"Function {name}")
558
- secure = kwargs.get("secure", True)
559
- fillers = kwargs.get("fillers", None)
627
+ # Register username based on agent name
628
+ clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
629
+ if clean_name:
630
+ self.register_sip_username(clean_name)
560
631
 
561
- self.define_tool(
562
- name=name,
563
- description=description,
564
- parameters=parameters,
565
- handler=func,
566
- secure=secure,
567
- fillers=fillers
568
- )
569
- return func
570
- return decorator
571
-
572
- @classmethod
573
- def tool(cls, name=None, **kwargs):
574
- """
575
- Class method decorator for defining SWAIG tools
576
-
577
- Used as:
578
-
579
- @AgentBase.tool(name="example_function", parameters={...})
580
- def example_function(self, param1):
581
- # ...
582
- """
583
- def decorator(func):
584
- setattr(func, "_is_tool", True)
585
- setattr(func, "_tool_name", name or func.__name__)
586
- setattr(func, "_tool_params", kwargs)
587
- return func
588
- return decorator
589
-
590
- # ----------------------------------------------------------------------
591
- # Override Points for Subclasses
592
- # ----------------------------------------------------------------------
632
+ # Register username based on route (without slashes)
633
+ clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
634
+ if clean_route and clean_route != clean_name:
635
+ self.register_sip_username(clean_route)
636
+
637
+ # Register common variations if they make sense
638
+ if len(clean_name) > 3:
639
+ # Register without vowels
640
+ no_vowels = re.sub(r'[aeiou]', '', clean_name)
641
+ if no_vowels != clean_name and len(no_vowels) > 2:
642
+ self.register_sip_username(no_vowels)
643
+
644
+ return self
593
645
 
594
- def get_name(self) -> str:
646
+ def set_web_hook_url(self, url: str) -> 'AgentBase':
595
647
  """
596
- Get the agent name
648
+ Override the default web_hook_url with a supplied URL string
597
649
 
650
+ Args:
651
+ url: The URL to use for SWAIG function webhooks
652
+
598
653
  Returns:
599
- Agent name/identifier
654
+ Self for method chaining
600
655
  """
601
- return self.name
656
+ self._web_hook_url_override = url
657
+ return self
602
658
 
603
- def get_prompt(self) -> Union[str, List[Dict[str, Any]]]:
659
+ def set_post_prompt_url(self, url: str) -> 'AgentBase':
604
660
  """
605
- Get the prompt for the agent
661
+ Override the default post_prompt_url with a supplied URL string
606
662
 
663
+ Args:
664
+ url: The URL to use for post-prompt summary delivery
665
+
607
666
  Returns:
608
- Either a string prompt or a POM object as list of dicts
667
+ Self for method chaining
609
668
  """
610
- # If using POM, return the POM structure
611
- if self._use_pom and self.pom:
612
- try:
613
- # Try different methods that might be available on the POM implementation
614
- if hasattr(self.pom, 'render_dict'):
615
- return self.pom.render_dict()
616
- elif hasattr(self.pom, 'to_dict'):
617
- return self.pom.to_dict()
618
- elif hasattr(self.pom, 'to_list'):
619
- return self.pom.to_list()
620
- elif hasattr(self.pom, 'render'):
621
- render_result = self.pom.render()
622
- # If render returns a string, we need to convert it to JSON
623
- if isinstance(render_result, str):
624
- try:
625
- import json
626
- return json.loads(render_result)
627
- except:
628
- # If we can't parse as JSON, fall back to raw text
629
- pass
630
- return render_result
631
- else:
632
- # Last resort: attempt to convert the POM object directly to a list/dict
633
- # This assumes the POM object has a reasonable __str__ or __repr__ method
634
- pom_data = self.pom.__dict__
635
- if '_sections' in pom_data and isinstance(pom_data['_sections'], list):
636
- return pom_data['_sections']
637
- # Fall through to default if nothing worked
638
- except Exception as e:
639
- print(f"Error rendering POM: {e}")
640
- # Fall back to raw text if POM fails
641
-
642
- # Return raw text (either explicitly set or default)
643
- return self._raw_prompt or f"You are {self.name}, a helpful AI assistant."
669
+ self._post_prompt_url_override = url
670
+ return self
644
671
 
645
- def get_post_prompt(self) -> Optional[str]:
672
+ def add_swaig_query_params(self, params: Dict[str, str]) -> 'AgentBase':
646
673
  """
647
- Get the post-prompt for the agent
674
+ Add query parameters that will be included in all SWAIG webhook URLs
648
675
 
676
+ This is particularly useful for preserving dynamic configuration state
677
+ across SWAIG callbacks. For example, if your dynamic config adds skills
678
+ based on query parameters, you can pass those same parameters through
679
+ to the SWAIG webhook so the same configuration is applied.
680
+
681
+ Args:
682
+ params: Dictionary of query parameters to add to SWAIG URLs
683
+
649
684
  Returns:
650
- Post-prompt text or None if not set
685
+ Self for method chaining
686
+
687
+ Example:
688
+ def dynamic_config(query_params, body_params, headers, agent):
689
+ if query_params.get('tier') == 'premium':
690
+ agent.add_skill('advanced_search')
691
+ # Preserve the tier param so SWAIG callbacks work
692
+ agent.add_swaig_query_params({'tier': 'premium'})
651
693
  """
652
- return self._post_prompt
694
+ if params and isinstance(params, dict):
695
+ self._swaig_query_params.update(params)
696
+ return self
653
697
 
654
- def define_tools(self) -> List[SWAIGFunction]:
698
+ def clear_swaig_query_params(self) -> 'AgentBase':
655
699
  """
656
- Define the tools this agent can use
700
+ Clear all SWAIG query parameters
657
701
 
658
702
  Returns:
659
- List of SWAIGFunction objects
660
-
661
- This method can be overridden by subclasses.
703
+ Self for method chaining
662
704
  """
663
- return list(self._swaig_functions.values())
705
+ self._swaig_query_params = {}
706
+ return self
664
707
 
665
- def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
708
+ def _render_swml(self, call_id: str = None, modifications: Optional[dict] = None) -> str:
666
709
  """
667
- Called when a post-prompt summary is received
668
-
669
- Args:
670
- summary: The summary object or None if no summary was found
671
- raw_data: The complete raw POST data from the request
672
- """
673
- # Default implementation does nothing
674
- pass
675
-
676
- def on_function_call(self, name: str, args: Dict[str, Any], raw_data: Optional[Dict[str, Any]] = None) -> Any:
677
- """
678
- Called when a SWAIG function is invoked
679
-
680
- Args:
681
- name: Function name
682
- args: Function arguments
683
- raw_data: Raw request data
684
-
685
- Returns:
686
- Function result
687
- """
688
- # Check if the function is registered
689
- if name not in self._swaig_functions:
690
- # If the function is not found, return an error
691
- return {"response": f"Function '{name}' not found"}
692
-
693
- # Get the function
694
- func = self._swaig_functions[name]
695
-
696
- # Call the handler
697
- try:
698
- result = func.handler(args, raw_data)
699
- if result is None:
700
- # If the handler returns None, create a default response
701
- result = SwaigFunctionResult("Function executed successfully")
702
- return result
703
- except Exception as e:
704
- # If the handler raises an exception, return an error response
705
- return {"response": f"Error executing function '{name}': {str(e)}"}
706
-
707
- def validate_basic_auth(self, username: str, password: str) -> bool:
708
- """
709
- Validate basic auth credentials
710
-
711
- Args:
712
- username: Username from request
713
- password: Password from request
714
-
715
- Returns:
716
- True if valid, False otherwise
717
-
718
- This method can be overridden by subclasses.
719
- """
720
- return (username, password) == self._basic_auth
721
-
722
- def _create_tool_token(self, tool_name: str, call_id: str) -> str:
723
- """
724
- Create a secure token for a tool call
725
-
726
- Args:
727
- tool_name: Name of the tool
728
- call_id: Call ID for this session
729
-
730
- Returns:
731
- Secure token string
732
- """
733
- return self._session_manager.create_tool_token(tool_name, call_id)
734
-
735
- def validate_tool_token(self, function_name: str, token: str, call_id: str) -> bool:
736
- """
737
- Validate a tool token
738
-
739
- Args:
740
- function_name: Name of the function/tool
741
- token: Token to validate
742
- call_id: Call ID for the session
743
-
744
- Returns:
745
- True if token is valid, False otherwise
746
- """
747
- # Skip validation for non-secure tools
748
- if function_name not in self._swaig_functions:
749
- return False
750
-
751
- if not self._swaig_functions[function_name].secure:
752
- return True
753
-
754
- return self._session_manager.validate_tool_token(function_name, token, call_id)
755
-
756
- # ----------------------------------------------------------------------
757
- # Web Server and Routing
758
- # ----------------------------------------------------------------------
759
-
760
- def get_basic_auth_credentials(self, include_source: bool = False) -> Union[Tuple[str, str], Tuple[str, str, str]]:
761
- """
762
- Get the basic auth credentials
763
-
764
- Args:
765
- include_source: Whether to include the source of the credentials
766
-
767
- Returns:
768
- If include_source is False:
769
- (username, password) tuple
770
- If include_source is True:
771
- (username, password, source) tuple, where source is one of:
772
- "provided", "environment", or "generated"
773
- """
774
- username, password = self._basic_auth
775
-
776
- if not include_source:
777
- return (username, password)
778
-
779
- # Determine source of credentials
780
- env_user = os.environ.get('SWML_BASIC_AUTH_USER')
781
- env_pass = os.environ.get('SWML_BASIC_AUTH_PASSWORD')
782
-
783
- # More robust source detection
784
- if env_user and env_pass and username == env_user and password == env_pass:
785
- source = "environment"
786
- elif username.startswith("user_") and len(password) > 20: # Format of generated credentials
787
- source = "generated"
788
- else:
789
- source = "provided"
790
-
791
- return (username, password, source)
792
-
793
- def get_full_url(self, include_auth: bool = False) -> str:
794
- """
795
- Get the full URL for this agent's endpoint
796
-
797
- Args:
798
- include_auth: Whether to include authentication credentials in the URL
799
-
800
- Returns:
801
- Full URL including host, port, and route (with auth if requested)
802
- """
803
- # Start with the base URL (either proxy or local)
804
- if self._proxy_url_base:
805
- # Use the proxy URL base from environment, ensuring we don't duplicate the route
806
- # Strip any trailing slashes from proxy base
807
- proxy_base = self._proxy_url_base.rstrip('/')
808
- # Make sure route starts with a slash for consistency
809
- route = self.route if self.route.startswith('/') else f"/{self.route}"
810
- base_url = f"{proxy_base}{route}"
811
- else:
812
- # Default local URL
813
- if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
814
- host = "localhost"
815
- else:
816
- host = self.host
817
-
818
- base_url = f"http://{host}:{self.port}{self.route}"
819
-
820
- # Add auth if requested
821
- if include_auth:
822
- username, password = self._basic_auth
823
- url = urlparse(base_url)
824
- return url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
825
-
826
- return base_url
827
-
828
- def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
829
- """
830
- Helper method to build webhook URLs consistently
831
-
832
- Args:
833
- endpoint: The endpoint path (e.g., "swaig", "post_prompt")
834
- query_params: Optional query parameters to append
835
-
836
- Returns:
837
- Fully constructed webhook URL
838
- """
839
- # Base URL construction
840
- if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
841
- # For proxy URLs
842
- base = self._proxy_url_base.rstrip('/')
843
-
844
- # Always add auth credentials
845
- username, password = self._basic_auth
846
- url = urlparse(base)
847
- base = url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
848
- else:
849
- # For local URLs
850
- if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
851
- host = "localhost"
852
- else:
853
- host = self.host
854
-
855
- # Always include auth credentials
856
- username, password = self._basic_auth
857
- base = f"http://{username}:{password}@{host}:{self.port}"
858
-
859
- # Ensure the endpoint has a trailing slash to prevent redirects
860
- if endpoint in ["swaig", "post_prompt"]:
861
- endpoint = f"{endpoint}/"
862
-
863
- # Simple path - use the route directly with the endpoint
864
- path = f"{self.route}/{endpoint}"
865
-
866
- # Construct full URL
867
- url = f"{base}{path}"
868
-
869
- # Add query parameters if any (only if they have values)
870
- # But NEVER add call_id parameter - it should be in the body, not the URL
871
- if query_params:
872
- # Remove any call_id from query params
873
- filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
874
- if filtered_params:
875
- params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
876
- url = f"{url}?{params}"
877
-
878
- return url
879
-
880
- def _render_swml(self, call_id: str = None, modifications: Optional[dict] = None) -> str:
881
- """
882
- Render the complete SWML document using SWMLService methods
710
+ Render the complete SWML document using SWMLService methods
883
711
 
884
712
  Args:
885
713
  call_id: Optional call ID for session-specific tokens
@@ -888,136 +716,320 @@ class AgentBase(SWMLService):
888
716
  Returns:
889
717
  SWML document as a string
890
718
  """
719
+ self.log.debug("_render_swml_called",
720
+ call_id=call_id,
721
+ has_modifications=bool(modifications),
722
+ use_ephemeral=bool(modifications and modifications.get("__use_ephemeral_agent")),
723
+ has_dynamic_callback=bool(self._dynamic_config_callback))
724
+
725
+ # Check if we need to use an ephemeral agent for dynamic configuration
726
+ agent_to_use = self
727
+ if modifications and modifications.get("__use_ephemeral_agent"):
728
+ # Create an ephemeral copy for this request
729
+ self.log.debug("creating_ephemeral_agent",
730
+ original_sections=len(self._prompt_manager._sections) if hasattr(self._prompt_manager, '_sections') else 0)
731
+ agent_to_use = self._create_ephemeral_copy()
732
+ self.log.debug("ephemeral_agent_created",
733
+ ephemeral_sections=len(agent_to_use._prompt_manager._sections) if hasattr(agent_to_use._prompt_manager, '_sections') else 0)
734
+
735
+ # Extract the request data
736
+ request = modifications.get("__request")
737
+ request_data = modifications.get("__request_data", {})
738
+
739
+ if self._dynamic_config_callback:
740
+ try:
741
+ # Extract request data
742
+ if request:
743
+ query_params = dict(request.query_params)
744
+ headers = dict(request.headers)
745
+ else:
746
+ # No request object - use empty defaults
747
+ query_params = {}
748
+ headers = {}
749
+ body_params = request_data
750
+
751
+ # Call the dynamic config callback with the ephemeral agent
752
+ # This allows FULL dynamic configuration including adding skills
753
+ self.log.debug("calling_dynamic_config_on_ephemeral", has_request=bool(request))
754
+ self._dynamic_config_callback(query_params, body_params, headers, agent_to_use)
755
+ self.log.debug("dynamic_config_complete",
756
+ ephemeral_sections_after=len(agent_to_use._prompt_manager._sections) if hasattr(agent_to_use._prompt_manager, '_sections') else 0)
757
+
758
+ except Exception as e:
759
+ self.log.error("dynamic_config_error", error=str(e))
760
+
761
+ # Clear the special markers so they don't affect rendering
762
+ modifications = None
763
+
891
764
  # Reset the document to a clean state
892
- self.reset_document()
765
+ agent_to_use.reset_document()
893
766
 
894
767
  # Get prompt
895
- prompt = self.get_prompt()
768
+ prompt = agent_to_use.get_prompt()
896
769
  prompt_is_pom = isinstance(prompt, list)
897
770
 
898
771
  # Get post-prompt
899
- post_prompt = self.get_post_prompt()
772
+ post_prompt = agent_to_use.get_post_prompt()
900
773
 
901
774
  # Generate a call ID if needed
902
- if self._enable_state_tracking and call_id is None:
903
- call_id = self._session_manager.create_session()
775
+ if call_id is None:
776
+ call_id = agent_to_use._session_manager.create_session()
777
+ self.log.debug("generated_call_id", call_id=call_id)
778
+ else:
779
+ self.log.debug("using_provided_call_id", call_id=call_id)
904
780
 
905
- # Empty query params - no need to include call_id in URLs
906
- query_params = {}
781
+ # Start with any SWAIG query params that were set
782
+ query_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
907
783
 
908
784
  # Get the default webhook URL with auth
909
- default_webhook_url = self._build_webhook_url("swaig", query_params)
785
+ default_webhook_url = agent_to_use._build_webhook_url("swaig", query_params)
910
786
 
911
787
  # Use override if set
912
- if hasattr(self, '_web_hook_url_override') and self._web_hook_url_override:
913
- default_webhook_url = self._web_hook_url_override
788
+ if hasattr(agent_to_use, '_web_hook_url_override') and agent_to_use._web_hook_url_override:
789
+ default_webhook_url = agent_to_use._web_hook_url_override
914
790
 
915
791
  # Prepare SWAIG object (correct format)
916
792
  swaig_obj = {}
917
793
 
918
- # Add defaults if we have functions
919
- if self._swaig_functions:
920
- swaig_obj["defaults"] = {
921
- "web_hook_url": default_webhook_url
922
- }
923
-
924
794
  # Add native_functions if any are defined
925
- if self.native_functions:
926
- swaig_obj["native_functions"] = self.native_functions
795
+ if agent_to_use.native_functions:
796
+ swaig_obj["native_functions"] = agent_to_use.native_functions
927
797
 
928
798
  # Add includes if any are defined
929
- if self._function_includes:
930
- swaig_obj["includes"] = self._function_includes
799
+ if agent_to_use._function_includes:
800
+ swaig_obj["includes"] = agent_to_use._function_includes
801
+
802
+ # Add internal_fillers if any are defined
803
+ if hasattr(agent_to_use, '_internal_fillers') and agent_to_use._internal_fillers:
804
+ swaig_obj["internal_fillers"] = agent_to_use._internal_fillers
931
805
 
932
806
  # Create functions array
933
807
  functions = []
934
808
 
809
+ # Debug logging to see what functions we have
810
+ self.log.debug("checking_swaig_functions",
811
+ agent_name=agent_to_use.name,
812
+ is_ephemeral=getattr(agent_to_use, '_is_ephemeral', False),
813
+ registry_id=id(agent_to_use._tool_registry),
814
+ agent_id=id(agent_to_use),
815
+ function_count=len(agent_to_use._tool_registry._swaig_functions) if hasattr(agent_to_use._tool_registry, '_swaig_functions') else 0,
816
+ functions=list(agent_to_use._tool_registry._swaig_functions.keys()) if hasattr(agent_to_use._tool_registry, '_swaig_functions') else [])
817
+
935
818
  # Add each function to the functions array
936
- for name, func in self._swaig_functions.items():
937
- # Get token for secure functions when we have a call_id
938
- token = None
939
- if func.secure and call_id:
940
- token = self._create_tool_token(tool_name=name, call_id=call_id)
819
+ # Check if the registry has the _swaig_functions attribute
820
+ if not hasattr(agent_to_use._tool_registry, '_swaig_functions'):
821
+ self.log.warning("tool_registry_missing_swaig_functions",
822
+ registry_id=id(agent_to_use._tool_registry),
823
+ agent_id=id(agent_to_use))
824
+ agent_to_use._tool_registry._swaig_functions = {}
825
+
826
+ for name, func in agent_to_use._tool_registry._swaig_functions.items():
827
+ if isinstance(func, dict):
828
+ # For raw dictionaries (DataMap functions), use the entire dictionary as-is
829
+ # This preserves data_map and any other special fields
830
+ function_entry = func.copy()
831
+
832
+ # Ensure the function name is set correctly
833
+ function_entry["function"] = name
941
834
 
942
- # Prepare function entry
943
- function_entry = {
944
- "function": name,
945
- "description": func.description,
946
- "parameters": {
947
- "type": "object",
948
- "properties": func.parameters
835
+ else:
836
+ # For SWAIGFunction objects, build the entry manually
837
+ # Check if it's secure and get token for secure functions when we have a call_id
838
+ token = None
839
+ if func.secure and call_id:
840
+ token = agent_to_use._create_tool_token(tool_name=name, call_id=call_id)
841
+ self.log.debug("created_token_for_function", function=name, call_id=call_id, token_prefix=token[:20] if token else None)
842
+
843
+ # Prepare function entry
844
+ function_entry = {
845
+ "function": name,
846
+ "description": func.description,
847
+ "parameters": func._ensure_parameter_structure()
949
848
  }
950
- }
951
-
952
- # Add fillers if present
953
- if func.fillers:
954
- function_entry["fillers"] = func.fillers
955
849
 
956
- # Add token to URL if we have one
957
- if token:
958
- # Create token params without call_id
959
- token_params = {"token": token}
960
- function_entry["web_hook_url"] = self._build_webhook_url("swaig", token_params)
850
+ # Add wait_file if present (audio/video file URL)
851
+ if hasattr(func, 'wait_file') and func.wait_file:
852
+ wait_file_url = func.wait_file
853
+ # If wait_file is a relative URL, convert it to absolute using agent's base URL
854
+ if wait_file_url and not wait_file_url.startswith(('http://', 'https://', '//')):
855
+ # Build full URL using the agent's base URL
856
+ base_url = agent_to_use._get_base_url(include_auth=False)
857
+ # Handle relative paths appropriately
858
+ if not wait_file_url.startswith('/'):
859
+ wait_file_url = '/' + wait_file_url
860
+ wait_file_url = f"{base_url}{wait_file_url}"
861
+ function_entry["wait_file"] = wait_file_url
961
862
 
962
- functions.append(function_entry)
863
+ # Add fillers if present (text phrases to say while processing)
864
+ if hasattr(func, 'fillers') and func.fillers:
865
+ function_entry["fillers"] = func.fillers
866
+
867
+ # Add wait_file_loops if present
868
+ if hasattr(func, 'wait_file_loops') and func.wait_file_loops is not None:
869
+ function_entry["wait_file_loops"] = func.wait_file_loops
870
+
871
+ # Handle webhook URL
872
+ if hasattr(func, 'webhook_url') and func.webhook_url:
873
+ # External webhook function - use the provided URL directly
874
+ function_entry["web_hook_url"] = func.webhook_url
875
+ elif token or agent_to_use._swaig_query_params:
876
+ # Local function with token OR SWAIG query params - build local webhook URL
877
+ # Start with SWAIG query params
878
+ url_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
879
+ if token:
880
+ url_params["__token"] = token # Use __token to avoid collision
881
+ function_entry["web_hook_url"] = agent_to_use._build_webhook_url("swaig", url_params)
963
882
 
883
+ functions.append(function_entry)
884
+
964
885
  # Add functions array to SWAIG object if we have any
965
886
  if functions:
966
887
  swaig_obj["functions"] = functions
888
+ # Add defaults section now that we know we have functions
889
+ if "defaults" not in swaig_obj:
890
+ swaig_obj["defaults"] = {
891
+ "web_hook_url": default_webhook_url
892
+ }
967
893
 
968
- # Add post-prompt URL if we have a post-prompt
894
+ # Add post-prompt URL with token if we have a post-prompt
969
895
  post_prompt_url = None
970
896
  if post_prompt:
971
- post_prompt_url = self._build_webhook_url("post_prompt", {})
897
+ # Create a token for post_prompt if we have a call_id
898
+ # Start with SWAIG query params
899
+ query_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
900
+ if call_id and hasattr(agent_to_use, '_session_manager'):
901
+ try:
902
+ token = agent_to_use._session_manager.create_tool_token("post_prompt", call_id)
903
+ if token:
904
+ query_params["__token"] = token # Use __token to avoid collision
905
+ except Exception as e:
906
+ agent_to_use.log.error("post_prompt_token_creation_error", error=str(e))
907
+
908
+ # Build the URL with the token (if any)
909
+ post_prompt_url = agent_to_use._build_webhook_url("post_prompt", query_params)
972
910
 
973
911
  # Use override if set
974
- if hasattr(self, '_post_prompt_url_override') and self._post_prompt_url_override:
975
- post_prompt_url = self._post_prompt_url_override
976
-
977
- # Add answer verb with auto-answer enabled
978
- self.add_answer_verb()
912
+ if hasattr(agent_to_use, '_post_prompt_url_override') and agent_to_use._post_prompt_url_override:
913
+ post_prompt_url = agent_to_use._post_prompt_url_override
914
+
915
+ # ========== PHASE 1: PRE-ANSWER VERBS ==========
916
+ # These run while the call is still ringing
917
+ for verb_name, verb_config in agent_to_use._pre_answer_verbs:
918
+ agent_to_use.add_verb(verb_name, verb_config)
919
+
920
+ # ========== PHASE 2: ANSWER VERB ==========
921
+ # Only add answer verb if auto_answer is enabled
922
+ if agent_to_use._auto_answer:
923
+ agent_to_use.add_verb("answer", agent_to_use._answer_config)
924
+
925
+ # ========== PHASE 3: POST-ANSWER VERBS ==========
926
+ # These run after answer but before AI
927
+
928
+ # Add recording if enabled (this is a post-answer verb)
929
+ if agent_to_use._record_call:
930
+ agent_to_use.add_verb("record_call", {
931
+ "format": agent_to_use._record_format,
932
+ "stereo": agent_to_use._record_stereo
933
+ })
934
+
935
+ # Add user-defined post-answer verbs
936
+ for verb_name, verb_config in agent_to_use._post_answer_verbs:
937
+ agent_to_use.add_verb(verb_name, verb_config)
979
938
 
980
939
  # Use the AI verb handler to build and validate the AI verb config
981
940
  ai_config = {}
982
941
 
983
942
  # Get the AI verb handler
984
- ai_handler = self.verb_registry.get_handler("ai")
943
+ ai_handler = agent_to_use.verb_registry.get_handler("ai")
985
944
  if ai_handler:
986
945
  try:
987
- # Build AI config using the proper handler
988
- ai_config = ai_handler.build_config(
989
- prompt_text=None if prompt_is_pom else prompt,
990
- prompt_pom=prompt if prompt_is_pom else None,
991
- post_prompt=post_prompt,
992
- post_prompt_url=post_prompt_url,
993
- swaig=swaig_obj if swaig_obj else None
994
- )
946
+ # Check if we're in contexts mode
947
+ if agent_to_use._contexts_defined and agent_to_use._contexts_builder:
948
+ # Generate contexts and combine with base prompt
949
+ contexts_dict = agent_to_use._contexts_builder.to_dict()
950
+
951
+ # Determine base prompt (required when using contexts)
952
+ base_prompt_text = None
953
+ base_prompt_pom = None
954
+
955
+ if prompt_is_pom:
956
+ base_prompt_pom = prompt
957
+ elif prompt:
958
+ base_prompt_text = prompt
959
+ else:
960
+ # Provide default base prompt if none exists
961
+ base_prompt_text = f"You are {agent_to_use.name}, a helpful AI assistant that follows structured workflows."
962
+
963
+ # Build AI config with base prompt + contexts
964
+ ai_config = ai_handler.build_config(
965
+ prompt_text=base_prompt_text,
966
+ prompt_pom=base_prompt_pom,
967
+ contexts=contexts_dict,
968
+ post_prompt=post_prompt,
969
+ post_prompt_url=post_prompt_url,
970
+ swaig=swaig_obj if swaig_obj else None
971
+ )
972
+ else:
973
+ # Build AI config using the traditional prompt approach
974
+ ai_config = ai_handler.build_config(
975
+ prompt_text=None if prompt_is_pom else prompt,
976
+ prompt_pom=prompt if prompt_is_pom else None,
977
+ post_prompt=post_prompt,
978
+ post_prompt_url=post_prompt_url,
979
+ swaig=swaig_obj if swaig_obj else None
980
+ )
995
981
 
996
982
  # Add new configuration parameters to the AI config
997
983
 
998
984
  # Add hints if any
999
- if self._hints:
1000
- ai_config["hints"] = self._hints
985
+ if agent_to_use._hints:
986
+ ai_config["hints"] = agent_to_use._hints
1001
987
 
1002
988
  # Add languages if any
1003
- if self._languages:
1004
- ai_config["languages"] = self._languages
989
+ if agent_to_use._languages:
990
+ ai_config["languages"] = agent_to_use._languages
1005
991
 
1006
992
  # Add pronunciation rules if any
1007
- if self._pronounce:
1008
- ai_config["pronounce"] = self._pronounce
993
+ if agent_to_use._pronounce:
994
+ ai_config["pronounce"] = agent_to_use._pronounce
1009
995
 
1010
996
  # Add params if any
1011
- if self._params:
1012
- ai_config["params"] = self._params
997
+ if agent_to_use._params:
998
+ ai_config["params"] = agent_to_use._params
1013
999
 
1014
1000
  # Add global_data if any
1015
- if self._global_data:
1016
- ai_config["global_data"] = self._global_data
1001
+ if agent_to_use._global_data:
1002
+ ai_config["global_data"] = agent_to_use._global_data
1003
+
1004
+ # Always add LLM parameters to prompt
1005
+ if "prompt" in ai_config:
1006
+ # Only add LLM params if explicitly set
1007
+ if agent_to_use._prompt_llm_params:
1008
+ if isinstance(ai_config["prompt"], dict):
1009
+ ai_config["prompt"].update(agent_to_use._prompt_llm_params)
1010
+ elif isinstance(ai_config["prompt"], str):
1011
+ # Convert string prompt to dict format
1012
+ ai_config["prompt"] = {
1013
+ "text": ai_config["prompt"],
1014
+ **agent_to_use._prompt_llm_params
1015
+ }
1016
+
1017
+ # Only add LLM parameters to post_prompt if explicitly set
1018
+ if post_prompt and "post_prompt" in ai_config:
1019
+ # Only add LLM params if explicitly set
1020
+ if agent_to_use._post_prompt_llm_params:
1021
+ if isinstance(ai_config["post_prompt"], dict):
1022
+ ai_config["post_prompt"].update(agent_to_use._post_prompt_llm_params)
1023
+ elif isinstance(ai_config["post_prompt"], str):
1024
+ # Convert string post_prompt to dict format
1025
+ ai_config["post_prompt"] = {
1026
+ "text": ai_config["post_prompt"],
1027
+ **agent_to_use._post_prompt_llm_params
1028
+ }
1017
1029
 
1018
1030
  except ValueError as e:
1019
- if not self._suppress_logs:
1020
- print(f"Error building AI verb configuration: {str(e)}")
1031
+ if not agent_to_use._suppress_logs:
1032
+ agent_to_use.log.error("ai_verb_config_error", error=str(e))
1021
1033
  else:
1022
1034
  # Fallback if no handler (shouldn't happen but just in case)
1023
1035
  ai_config = {
@@ -1035,274 +1047,286 @@ class AgentBase(SWMLService):
1035
1047
  ai_config["SWAIG"] = swaig_obj
1036
1048
 
1037
1049
  # Add the new configurations if not already added by the handler
1038
- if self._hints and "hints" not in ai_config:
1039
- ai_config["hints"] = self._hints
1050
+ if agent_to_use._hints and "hints" not in ai_config:
1051
+ ai_config["hints"] = agent_to_use._hints
1040
1052
 
1041
- if self._languages and "languages" not in ai_config:
1042
- ai_config["languages"] = self._languages
1053
+ if agent_to_use._languages and "languages" not in ai_config:
1054
+ ai_config["languages"] = agent_to_use._languages
1043
1055
 
1044
- if self._pronounce and "pronounce" not in ai_config:
1045
- ai_config["pronounce"] = self._pronounce
1056
+ if agent_to_use._pronounce and "pronounce" not in ai_config:
1057
+ ai_config["pronounce"] = agent_to_use._pronounce
1046
1058
 
1047
- if self._params and "params" not in ai_config:
1048
- ai_config["params"] = self._params
1049
-
1050
- if self._global_data and "global_data" not in ai_config:
1051
- ai_config["global_data"] = self._global_data
1059
+ if agent_to_use._params and "params" not in ai_config:
1060
+ ai_config["params"] = agent_to_use._params
1052
1061
 
1062
+ if agent_to_use._global_data and "global_data" not in ai_config:
1063
+ ai_config["global_data"] = agent_to_use._global_data
1064
+
1065
+ # ========== PHASE 4: AI VERB ==========
1053
1066
  # Add the AI verb to the document
1054
- self.add_verb("ai", ai_config)
1055
-
1056
- # Apply any modifications from the callback
1067
+ agent_to_use.add_verb("ai", ai_config)
1068
+
1069
+ # ========== PHASE 5: POST-AI VERBS ==========
1070
+ # These run after the AI conversation ends
1071
+ for verb_name, verb_config in agent_to_use._post_ai_verbs:
1072
+ agent_to_use.add_verb(verb_name, verb_config)
1073
+
1074
+ # Apply any modifications from the callback to agent state
1057
1075
  if modifications and isinstance(modifications, dict):
1058
- # We need a way to apply modifications to the document
1059
- # Get the current document
1060
- document = self.get_document()
1061
-
1062
- # Simple recursive update function
1063
- def update_dict(target, source):
1064
- for key, value in source.items():
1065
- if isinstance(value, dict) and key in target and isinstance(target[key], dict):
1066
- update_dict(target[key], value)
1067
- else:
1068
- target[key] = value
1069
-
1070
- # Apply modifications to the document
1071
- update_dict(document, modifications)
1072
-
1073
- # Since we can't directly set the document in SWMLService,
1074
- # we'll need to reset and rebuild if there are modifications
1075
- self.reset_document()
1076
-
1077
- # Add the modified document's sections
1078
- for section_name, section_content in document["sections"].items():
1079
- if section_name != "main": # Main section is created by default
1080
- self.add_section(section_name)
1081
-
1082
- # Add each verb to the section
1083
- for verb_obj in section_content:
1084
- for verb_name, verb_config in verb_obj.items():
1085
- self.add_verb_to_section(section_name, verb_name, verb_config)
1076
+ # Handle global_data modifications by updating the AI config directly
1077
+ if "global_data" in modifications:
1078
+ if modifications["global_data"]:
1079
+ # Merge the modification global_data with existing global_data
1080
+ ai_config["global_data"] = {**ai_config.get("global_data", {}), **modifications["global_data"]}
1081
+
1082
+ # Handle other modifications by updating the AI config
1083
+ for key, value in modifications.items():
1084
+ if key != "global_data": # global_data handled above
1085
+ ai_config[key] = value
1086
+
1087
+ # Clear and rebuild the document with the modified AI config
1088
+ agent_to_use.reset_document()
1089
+
1090
+ # Rebuild with 5-phase approach
1091
+ # PHASE 1: Pre-answer verbs
1092
+ for verb_name, verb_config in agent_to_use._pre_answer_verbs:
1093
+ agent_to_use.add_verb(verb_name, verb_config)
1094
+
1095
+ # PHASE 2: Answer verb (if auto_answer enabled)
1096
+ if agent_to_use._auto_answer:
1097
+ agent_to_use.add_verb("answer", agent_to_use._answer_config)
1098
+
1099
+ # PHASE 3: Post-answer verbs
1100
+ if agent_to_use._record_call:
1101
+ agent_to_use.add_verb("record_call", {
1102
+ "format": agent_to_use._record_format,
1103
+ "stereo": agent_to_use._record_stereo
1104
+ })
1105
+ for verb_name, verb_config in agent_to_use._post_answer_verbs:
1106
+ agent_to_use.add_verb(verb_name, verb_config)
1107
+
1108
+ # PHASE 4: AI verb
1109
+ agent_to_use.add_verb("ai", ai_config)
1110
+
1111
+ # PHASE 5: Post-AI verbs
1112
+ for verb_name, verb_config in agent_to_use._post_ai_verbs:
1113
+ agent_to_use.add_verb(verb_name, verb_config)
1086
1114
 
1087
1115
  # Return the rendered document as a string
1088
- return self.render_document()
1116
+ return agent_to_use.render_document()
1089
1117
 
1090
- def _check_basic_auth(self, request: Request) -> bool:
1118
+ def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
1091
1119
  """
1092
- Check basic auth from a request
1120
+ Helper method to build webhook URLs consistently
1093
1121
 
1094
1122
  Args:
1095
- request: FastAPI request object
1123
+ endpoint: The endpoint path (e.g., "swaig", "post_prompt")
1124
+ query_params: Optional query parameters to append
1096
1125
 
1097
1126
  Returns:
1098
- True if auth is valid, False otherwise
1127
+ Fully constructed webhook URL
1099
1128
  """
1100
- auth_header = request.headers.get("Authorization")
1101
- if not auth_header or not auth_header.startswith("Basic "):
1102
- return False
1129
+ # Check for serverless environment and use appropriate URL generation
1130
+ mode = get_execution_mode()
1131
+
1132
+ if mode != 'server':
1133
+ # In serverless mode, use the serverless-appropriate URL with auth
1134
+ base_url = self.get_full_url(include_auth=True)
1103
1135
 
1104
- try:
1105
- # Decode the base64 credentials
1106
- credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
1107
- username, password = credentials.split(":", 1)
1108
- return self.validate_basic_auth(username, password)
1109
- except Exception:
1110
- return False
1136
+ # Ensure the endpoint has a trailing slash to prevent redirects
1137
+ if endpoint in ["swaig", "post_prompt"]:
1138
+ endpoint = f"{endpoint}/"
1139
+
1140
+ # Build the full webhook URL
1141
+ url = f"{base_url}/{endpoint}"
1142
+
1143
+ # Add query parameters if any (only if they have values)
1144
+ if query_params:
1145
+ # Remove any call_id from query params
1146
+ filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
1147
+ if filtered_params:
1148
+ params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
1149
+ url = f"{url}?{params}"
1150
+
1151
+ return url
1152
+
1153
+ # Server mode - use the parent class's implementation from SWMLService
1154
+ # which properly handles SWML_PROXY_URL_BASE environment variable
1155
+ return super()._build_webhook_url(endpoint, query_params)
1111
1156
 
1112
- def as_router(self) -> APIRouter:
1157
+ def _find_summary_in_post_data(self, body, logger):
1113
1158
  """
1114
- Get a FastAPI router for this agent
1159
+ Attempt to find a summary in the post-prompt response data
1115
1160
 
1161
+ Args:
1162
+ body: The request body
1163
+ logger: Logger instance
1164
+
1116
1165
  Returns:
1117
- FastAPI router
1166
+ Summary data or None if not found
1167
+ """
1168
+ if not body:
1169
+ return None
1170
+
1171
+ # Various ways to get summary data
1172
+ if "summary" in body:
1173
+ return body["summary"]
1174
+
1175
+ if "post_prompt_data" in body:
1176
+ pdata = body["post_prompt_data"]
1177
+ if isinstance(pdata, dict):
1178
+ if "parsed" in pdata and isinstance(pdata["parsed"], list) and pdata["parsed"]:
1179
+ return pdata["parsed"][0]
1180
+ elif "raw" in pdata and pdata["raw"]:
1181
+ try:
1182
+ # Try to parse JSON from raw text
1183
+ parsed = json.loads(pdata["raw"])
1184
+ return parsed
1185
+ except:
1186
+ return pdata["raw"]
1187
+
1188
+ return None
1189
+
1190
+ def _create_ephemeral_copy(self):
1118
1191
  """
1119
- # Get the base router from SWMLService
1120
- router = super().as_router()
1192
+ Create a lightweight copy of this agent for ephemeral configuration.
1121
1193
 
1122
- # Override the root endpoint to use our SWML rendering
1123
- @router.get("/")
1124
- @router.post("/")
1125
- async def handle_root_no_slash(request: Request):
1126
- return await self._handle_root_request(request)
1127
-
1128
- # Root endpoint - with trailing slash
1129
- @router.get("/")
1130
- @router.post("/")
1131
- async def handle_root_with_slash(request: Request):
1132
- return await self._handle_root_request(request)
1133
-
1134
- # Debug endpoint - without trailing slash
1135
- @router.get("/debug")
1136
- @router.post("/debug")
1137
- async def handle_debug_no_slash(request: Request):
1138
- return await self._handle_debug_request(request)
1139
-
1140
- # Debug endpoint - with trailing slash
1141
- @router.get("/debug/")
1142
- @router.post("/debug/")
1143
- async def handle_debug_with_slash(request: Request):
1144
- return await self._handle_debug_request(request)
1145
-
1146
- # SWAIG endpoint - without trailing slash
1147
- @router.get("/swaig")
1148
- @router.post("/swaig")
1149
- async def handle_swaig_no_slash(request: Request):
1150
- return await self._handle_swaig_request(request)
1151
-
1152
- # SWAIG endpoint - with trailing slash
1153
- @router.get("/swaig/")
1154
- @router.post("/swaig/")
1155
- async def handle_swaig_with_slash(request: Request):
1156
- return await self._handle_swaig_request(request)
1157
-
1158
- # Post-prompt endpoint - without trailing slash
1159
- @router.get("/post_prompt")
1160
- @router.post("/post_prompt")
1161
- async def handle_post_prompt_no_slash(request: Request):
1162
- return await self._handle_post_prompt_request(request)
1163
-
1164
- # Post-prompt endpoint - with trailing slash
1165
- @router.get("/post_prompt/")
1166
- @router.post("/post_prompt/")
1167
- async def handle_post_prompt_with_slash(request: Request):
1168
- return await self._handle_post_prompt_request(request)
1169
-
1170
- self._router = router
1171
- return router
1194
+ This creates a partial copy that shares most resources but has independent
1195
+ configuration for SWML generation. Used when dynamic configuration callbacks
1196
+ need to modify the agent without affecting the persistent state.
1197
+
1198
+ Returns:
1199
+ A lightweight copy of the agent suitable for ephemeral modifications
1200
+ """
1201
+ import copy
1202
+
1203
+ # Create a new instance of the same class
1204
+ cls = self.__class__
1205
+ ephemeral_agent = cls.__new__(cls)
1206
+
1207
+ # Copy all attributes as shallow references first
1208
+ for key, value in self.__dict__.items():
1209
+ setattr(ephemeral_agent, key, value)
1210
+
1211
+ # Deep copy only the configuration that affects SWML generation
1212
+ # These are the parts that dynamic config might modify
1213
+ ephemeral_agent._params = copy.deepcopy(self._params)
1214
+ ephemeral_agent._hints = copy.deepcopy(self._hints)
1215
+ ephemeral_agent._languages = copy.deepcopy(self._languages)
1216
+ ephemeral_agent._pronounce = copy.deepcopy(self._pronounce)
1217
+ ephemeral_agent._global_data = copy.deepcopy(self._global_data)
1218
+ ephemeral_agent._function_includes = copy.deepcopy(self._function_includes)
1219
+
1220
+ # Deep copy verb insertion points for call flow customization
1221
+ ephemeral_agent._pre_answer_verbs = copy.deepcopy(self._pre_answer_verbs)
1222
+ ephemeral_agent._answer_config = copy.deepcopy(self._answer_config)
1223
+ ephemeral_agent._post_answer_verbs = copy.deepcopy(self._post_answer_verbs)
1224
+ ephemeral_agent._post_ai_verbs = copy.deepcopy(self._post_ai_verbs)
1225
+
1226
+ # Deep copy LLM parameters
1227
+ ephemeral_agent._prompt_llm_params = copy.deepcopy(self._prompt_llm_params)
1228
+ ephemeral_agent._post_prompt_llm_params = copy.deepcopy(self._post_prompt_llm_params)
1229
+
1230
+ # Copy internal fillers if they exist
1231
+ if hasattr(self, '_internal_fillers'):
1232
+ ephemeral_agent._internal_fillers = copy.deepcopy(self._internal_fillers)
1233
+
1234
+ # Don't deep copy _contexts_builder - it has a circular reference to the agent
1235
+ # The contexts are already copied via _prompt_manager._contexts (below)
1236
+ # Just copy the flag indicating contexts are defined
1237
+ if hasattr(self, '_contexts_defined'):
1238
+ ephemeral_agent._contexts_defined = self._contexts_defined
1239
+
1240
+ # Deep copy the POM object if it exists to prevent sharing prompt sections
1241
+ if hasattr(self, 'pom') and self.pom:
1242
+ ephemeral_agent.pom = copy.deepcopy(self.pom)
1243
+ # Handle native_functions which might be stored as an attribute or property
1244
+ if hasattr(self, '_native_functions'):
1245
+ ephemeral_agent._native_functions = copy.deepcopy(self._native_functions)
1246
+ elif hasattr(self, 'native_functions'):
1247
+ ephemeral_agent.native_functions = copy.deepcopy(self.native_functions)
1248
+ ephemeral_agent._swaig_query_params = copy.deepcopy(self._swaig_query_params)
1249
+
1250
+ # Create new manager instances that point to the ephemeral agent
1251
+ # This breaks the circular reference and allows independent modification
1252
+ from signalwire_agents.core.agent.prompt.manager import PromptManager
1253
+ from signalwire_agents.core.agent.tools.registry import ToolRegistry
1254
+
1255
+ # Create new prompt manager for the ephemeral agent
1256
+ ephemeral_agent._prompt_manager = PromptManager(ephemeral_agent)
1257
+ # Copy ALL PromptManager state
1258
+ if hasattr(self._prompt_manager, '_sections'):
1259
+ ephemeral_agent._prompt_manager._sections = copy.deepcopy(self._prompt_manager._sections)
1260
+ ephemeral_agent._prompt_manager._prompt_text = copy.deepcopy(self._prompt_manager._prompt_text)
1261
+ ephemeral_agent._prompt_manager._post_prompt_text = copy.deepcopy(self._prompt_manager._post_prompt_text)
1262
+ ephemeral_agent._prompt_manager._contexts = copy.deepcopy(self._prompt_manager._contexts)
1263
+
1264
+ # Create new tool registry for the ephemeral agent
1265
+ ephemeral_agent._tool_registry = ToolRegistry(ephemeral_agent)
1266
+ # Copy the SWAIG functions - we need a shallow copy here because
1267
+ # the functions themselves can be shared, we just need a new dict
1268
+ if hasattr(self._tool_registry, '_swaig_functions'):
1269
+ ephemeral_agent._tool_registry._swaig_functions = self._tool_registry._swaig_functions.copy()
1270
+ if hasattr(self._tool_registry, '_tool_instances'):
1271
+ ephemeral_agent._tool_registry._tool_instances = self._tool_registry._tool_instances.copy()
1272
+
1273
+ # Create a new skill manager for the ephemeral agent
1274
+ # This is important because skills register tools with the agent's registry
1275
+ from signalwire_agents.core.skill_manager import SkillManager
1276
+ ephemeral_agent.skill_manager = SkillManager(ephemeral_agent)
1277
+
1278
+ # Copy any already loaded skills from the original agent
1279
+ # This ensures skills loaded during __init__ are available in the ephemeral agent
1280
+ if hasattr(self.skill_manager, 'loaded_skills'):
1281
+ for skill_key, skill_instance in self.skill_manager.loaded_skills.items():
1282
+ # Re-load the skill in the ephemeral agent's context
1283
+ # We need to get the skill name and params from the existing instance
1284
+ skill_name = skill_instance.SKILL_NAME
1285
+ skill_params = getattr(skill_instance, 'params', {})
1286
+ try:
1287
+ ephemeral_agent.skill_manager.load_skill(skill_name, type(skill_instance), skill_params)
1288
+ except Exception as e:
1289
+ self.log.warning("failed_to_copy_skill_to_ephemeral",
1290
+ skill_name=skill_name,
1291
+ error=str(e))
1292
+
1293
+ # Re-bind the tool decorator method to the new instance
1294
+ ephemeral_agent.tool = ephemeral_agent._tool_decorator
1295
+
1296
+ # Share the logger but bind it to indicate ephemeral copy
1297
+ ephemeral_agent.log = self.log.bind(ephemeral=True)
1298
+
1299
+ # Mark this as an ephemeral agent to prevent double application of dynamic config
1300
+ ephemeral_agent._is_ephemeral = True
1301
+
1302
+ return ephemeral_agent
1172
1303
 
1173
- async def _handle_root_request(self, request: Request):
1174
- """Handle GET/POST requests to the root endpoint"""
1175
- # Auto-detect proxy on first request if not explicitly configured
1176
- if not getattr(self, '_proxy_detection_done', False) and not getattr(self, '_proxy_url_base', None):
1177
- # Check for proxy headers
1178
- forwarded_host = request.headers.get("X-Forwarded-Host")
1179
- forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
1180
-
1181
- if forwarded_host:
1182
- self._proxy_url_base = f"{forwarded_proto}://{forwarded_host}"
1183
- self.log.info("proxy_auto_detected", proxy_url_base=self._proxy_url_base,
1184
- source="X-Forwarded headers")
1185
- self._proxy_detection_done = True
1186
- # If no explicit proxy headers, try the parent class detection method if it exists
1187
- elif hasattr(super(), '_detect_proxy_from_request'):
1188
- super()._detect_proxy_from_request(request)
1189
- self._proxy_detection_done = True
1190
-
1191
- # Check if this is a callback path request
1192
- callback_path = getattr(request.state, "callback_path", None)
1193
-
1194
- req_log = self.log.bind(
1195
- endpoint="root" if not callback_path else f"callback:{callback_path}",
1196
- method=request.method,
1197
- path=request.url.path
1198
- )
1304
+ async def _handle_request(self, request: Request, response: Response):
1305
+ """
1306
+ Override SWMLService's _handle_request to use AgentBase's _render_swml
1199
1307
 
1200
- req_log.debug("endpoint_called")
1308
+ This ensures that when routes are handled by SWMLService's router,
1309
+ they still use AgentBase's SWML rendering logic.
1310
+ """
1311
+ # Use WebMixin's implementation if available
1312
+ if hasattr(super(), '_handle_root_request'):
1313
+ return await self._handle_root_request(request)
1201
1314
 
1315
+ # Fallback to basic implementation
1202
1316
  try:
1203
- # Check auth
1204
- if not self._check_basic_auth(request):
1205
- req_log.warning("unauthorized_access_attempt")
1206
- return Response(
1207
- content=json.dumps({"error": "Unauthorized"}),
1208
- status_code=401,
1209
- headers={"WWW-Authenticate": "Basic"},
1210
- media_type="application/json"
1211
- )
1212
-
1213
- # Try to parse request body for POST
1317
+ # Parse body if POST request
1214
1318
  body = {}
1215
- call_id = None
1216
-
1217
1319
  if request.method == "POST":
1218
- # Check if body is empty first
1219
- raw_body = await request.body()
1220
- if raw_body:
1221
- try:
1222
- body = await request.json()
1223
- req_log.debug("request_body_received", body_size=len(str(body)))
1224
- if body:
1225
- req_log.debug("request_body", body=json.dumps(body, indent=2))
1226
- except Exception as e:
1227
- req_log.warning("error_parsing_request_body", error=str(e), traceback=traceback.format_exc())
1228
- req_log.debug("raw_request_body", body=raw_body.decode('utf-8', errors='replace'))
1229
- # Continue processing with empty body
1230
- body = {}
1231
- else:
1232
- req_log.debug("empty_request_body")
1233
-
1234
- # Get call_id from body if present
1235
- call_id = body.get("call_id")
1236
- else:
1237
- # Get call_id from query params for GET
1238
- call_id = request.query_params.get("call_id")
1239
-
1240
- # Add call_id to logger if any
1241
- if call_id:
1242
- req_log = req_log.bind(call_id=call_id)
1243
- req_log.debug("call_id_identified")
1244
-
1245
- # Check if this is a callback path and we need to apply routing
1246
- if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
1247
- callback_fn = self._routing_callbacks[callback_path]
1248
-
1249
- if request.method == "POST" and body:
1250
- req_log.debug("processing_routing_callback", path=callback_path)
1251
- # Call the routing callback
1252
- try:
1253
- route = callback_fn(request, body)
1254
- if route is not None:
1255
- req_log.info("routing_request", route=route)
1256
- # Return a redirect to the new route
1257
- return Response(
1258
- status_code=307, # 307 Temporary Redirect preserves the method and body
1259
- headers={"Location": route}
1260
- )
1261
- except Exception as e:
1262
- req_log.error("error_in_routing_callback", error=str(e), traceback=traceback.format_exc())
1263
-
1264
- # Allow subclasses to inspect/modify the request
1265
- modifications = None
1266
- if body:
1267
1320
  try:
1268
- modifications = self.on_swml_request(body)
1269
- if modifications:
1270
- req_log.debug("request_modifications_applied")
1271
- except Exception as e:
1272
- req_log.error("error_in_request_modifier", error=str(e), traceback=traceback.format_exc())
1321
+ body = await request.json()
1322
+ except:
1323
+ pass
1273
1324
 
1274
- # Render SWML
1275
- swml = self._render_swml(call_id, modifications)
1276
- req_log.debug("swml_rendered", swml_size=len(swml))
1325
+ # Get call_id
1326
+ call_id = body.get("call_id") if body else request.query_params.get("call_id")
1277
1327
 
1278
- # Return as JSON
1279
- req_log.info("request_successful")
1280
- return Response(
1281
- content=swml,
1282
- media_type="application/json"
1283
- )
1284
- except Exception as e:
1285
- req_log.error("request_failed", error=str(e), traceback=traceback.format_exc())
1286
- return Response(
1287
- content=json.dumps({"error": str(e), "traceback": traceback.format_exc()}),
1288
- status_code=500,
1289
- media_type="application/json"
1290
- )
1291
-
1292
- async def _handle_debug_request(self, request: Request):
1293
- """Handle GET/POST requests to the debug endpoint"""
1294
- req_log = self.log.bind(
1295
- endpoint="debug",
1296
- method=request.method,
1297
- path=request.url.path
1298
- )
1299
-
1300
- req_log.debug("endpoint_called")
1301
-
1302
- try:
1303
1328
  # Check auth
1304
1329
  if not self._check_basic_auth(request):
1305
- req_log.warning("unauthorized_access_attempt")
1306
1330
  return Response(
1307
1331
  content=json.dumps({"error": "Unauthorized"}),
1308
1332
  status_code=401,
@@ -1310,1248 +1334,17 @@ class AgentBase(SWMLService):
1310
1334
  media_type="application/json"
1311
1335
  )
1312
1336
 
1313
- # Get call_id from either query params (GET) or body (POST)
1314
- call_id = None
1315
- body = {}
1316
-
1317
- if request.method == "POST":
1318
- try:
1319
- body = await request.json()
1320
- req_log.debug("request_body_received", body_size=len(str(body)))
1321
- if body:
1322
- req_log.debug("request_body", body=json.dumps(body, indent=2))
1323
- call_id = body.get("call_id")
1324
- except Exception as e:
1325
- req_log.warning("error_parsing_request_body", error=str(e), traceback=traceback.format_exc())
1326
- try:
1327
- body_text = await request.body()
1328
- req_log.debug("raw_request_body", body=body_text.decode('utf-8', errors='replace'))
1329
- except:
1330
- pass
1331
- else:
1332
- call_id = request.query_params.get("call_id")
1333
-
1334
- # Add call_id to logger if any
1335
- if call_id:
1336
- req_log = req_log.bind(call_id=call_id)
1337
- req_log.debug("call_id_identified")
1338
-
1339
- # Allow subclasses to inspect/modify the request
1340
- modifications = None
1341
- if body:
1342
- modifications = self.on_swml_request(body)
1343
- if modifications:
1344
- req_log.debug("request_modifications_applied")
1345
-
1346
- # Render SWML
1347
- swml = self._render_swml(call_id, modifications)
1348
- req_log.debug("swml_rendered", swml_size=len(swml))
1337
+ # Render SWML using AgentBase's method
1338
+ swml = self._render_swml(call_id)
1349
1339
 
1350
- # Return as JSON
1351
- req_log.info("request_successful")
1352
1340
  return Response(
1353
1341
  content=swml,
1354
- media_type="application/json",
1355
- headers={"X-Debug": "true"}
1342
+ media_type="application/json"
1356
1343
  )
1357
1344
  except Exception as e:
1358
- req_log.error("request_failed", error=str(e), traceback=traceback.format_exc())
1359
1345
  return Response(
1360
- content=json.dumps({"error": str(e), "traceback": traceback.format_exc()}),
1346
+ content=json.dumps({"error": str(e)}),
1361
1347
  status_code=500,
1362
1348
  media_type="application/json"
1363
1349
  )
1364
1350
 
1365
- async def _handle_swaig_request(self, request: Request):
1366
- """Handle GET/POST requests to the SWAIG endpoint"""
1367
- req_log = self.log.bind(
1368
- endpoint="swaig",
1369
- method=request.method,
1370
- path=request.url.path
1371
- )
1372
-
1373
- req_log.debug("endpoint_called")
1374
-
1375
- try:
1376
- # Check auth
1377
- if not self._check_basic_auth(request):
1378
- req_log.warning("unauthorized_access_attempt")
1379
- return Response(
1380
- content=json.dumps({"error": "Unauthorized"}),
1381
- status_code=401,
1382
- headers={"WWW-Authenticate": "Basic"},
1383
- media_type="application/json"
1384
- )
1385
-
1386
- # Handle differently based on method
1387
- if request.method == "GET":
1388
- # For GET requests, return the SWML document (same as root endpoint)
1389
- call_id = request.query_params.get("call_id")
1390
- swml = self._render_swml(call_id)
1391
- req_log.debug("swml_rendered", swml_size=len(swml))
1392
- return Response(
1393
- content=swml,
1394
- media_type="application/json"
1395
- )
1396
-
1397
- # For POST requests, process SWAIG function calls
1398
- try:
1399
- body = await request.json()
1400
- req_log.debug("request_body_received", body_size=len(str(body)))
1401
- if body:
1402
- req_log.debug("request_body", body=json.dumps(body, indent=2))
1403
- except Exception as e:
1404
- req_log.error("error_parsing_request_body", error=str(e), traceback=traceback.format_exc())
1405
- body = {}
1406
-
1407
- # Extract function name
1408
- function_name = body.get("function")
1409
- if not function_name:
1410
- req_log.warning("missing_function_name")
1411
- return Response(
1412
- content=json.dumps({"error": "Missing function name"}),
1413
- status_code=400,
1414
- media_type="application/json"
1415
- )
1416
-
1417
- # Add function info to logger
1418
- req_log = req_log.bind(function=function_name)
1419
- req_log.debug("function_call_received")
1420
-
1421
- # Extract arguments
1422
- args = {}
1423
- if "argument" in body and isinstance(body["argument"], dict):
1424
- if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
1425
- args = body["argument"]["parsed"][0]
1426
- req_log.debug("parsed_arguments", args=json.dumps(args, indent=2))
1427
- elif "raw" in body["argument"]:
1428
- try:
1429
- args = json.loads(body["argument"]["raw"])
1430
- req_log.debug("raw_arguments_parsed", args=json.dumps(args, indent=2))
1431
- except Exception as e:
1432
- req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
1433
-
1434
- # Get call_id from body
1435
- call_id = body.get("call_id")
1436
- if call_id:
1437
- req_log = req_log.bind(call_id=call_id)
1438
- req_log.debug("call_id_identified")
1439
-
1440
- # Call the function
1441
- try:
1442
- result = self.on_function_call(function_name, args, body)
1443
-
1444
- # Convert result to dict if needed
1445
- if isinstance(result, SwaigFunctionResult):
1446
- result_dict = result.to_dict()
1447
- elif isinstance(result, dict):
1448
- result_dict = result
1449
- else:
1450
- result_dict = {"response": str(result)}
1451
-
1452
- req_log.info("function_executed_successfully")
1453
- req_log.debug("function_result", result=json.dumps(result_dict, indent=2))
1454
- return result_dict
1455
- except Exception as e:
1456
- req_log.error("function_execution_error", error=str(e), traceback=traceback.format_exc())
1457
- return {"error": str(e), "function": function_name}
1458
-
1459
- except Exception as e:
1460
- req_log.error("request_failed", error=str(e), traceback=traceback.format_exc())
1461
- return Response(
1462
- content=json.dumps({"error": str(e)}),
1463
- status_code=500,
1464
- media_type="application/json"
1465
- )
1466
-
1467
- async def _handle_post_prompt_request(self, request: Request):
1468
- """Handle GET/POST requests to the post_prompt endpoint"""
1469
- req_log = self.log.bind(
1470
- endpoint="post_prompt",
1471
- method=request.method,
1472
- path=request.url.path
1473
- )
1474
-
1475
- # Only log if not suppressed
1476
- if not self._suppress_logs:
1477
- req_log.debug("endpoint_called")
1478
-
1479
- try:
1480
- # Check auth
1481
- if not self._check_basic_auth(request):
1482
- req_log.warning("unauthorized_access_attempt")
1483
- return Response(
1484
- content=json.dumps({"error": "Unauthorized"}),
1485
- status_code=401,
1486
- headers={"WWW-Authenticate": "Basic"},
1487
- media_type="application/json"
1488
- )
1489
-
1490
- # For GET requests, return the SWML document (same as root endpoint)
1491
- if request.method == "GET":
1492
- call_id = request.query_params.get("call_id")
1493
- swml = self._render_swml(call_id)
1494
- req_log.debug("swml_rendered", swml_size=len(swml))
1495
- return Response(
1496
- content=swml,
1497
- media_type="application/json"
1498
- )
1499
-
1500
- # For POST requests, process the post-prompt data
1501
- try:
1502
- body = await request.json()
1503
-
1504
- # Only log if not suppressed
1505
- if not self._suppress_logs:
1506
- req_log.debug("request_body_received", body_size=len(str(body)))
1507
- # Log the raw body as properly formatted JSON (not Python dict representation)
1508
- print("POST_PROMPT_BODY: " + json.dumps(body))
1509
- except Exception as e:
1510
- req_log.error("error_parsing_request_body", error=str(e), traceback=traceback.format_exc())
1511
- body = {}
1512
-
1513
- # Extract summary from the correct location in the request
1514
- summary = self._find_summary_in_post_data(body, req_log)
1515
-
1516
- # Save state if call_id is provided
1517
- call_id = body.get("call_id")
1518
- if call_id and summary:
1519
- req_log = req_log.bind(call_id=call_id)
1520
-
1521
- # Check if state manager has the right methods
1522
- try:
1523
- if hasattr(self._state_manager, 'get_state'):
1524
- state = self._state_manager.get_state(call_id) or {}
1525
- state["summary"] = summary
1526
- if hasattr(self._state_manager, 'update_state'):
1527
- self._state_manager.update_state(call_id, state)
1528
- req_log.debug("state_updated_with_summary")
1529
- except Exception as e:
1530
- req_log.warning("state_update_failed", error=str(e))
1531
-
1532
- # Call the summary handler with the summary and the full body
1533
- try:
1534
- if summary:
1535
- self.on_summary(summary, body)
1536
- req_log.debug("summary_handler_called_successfully")
1537
- else:
1538
- # If no summary found but still want to process the data
1539
- self.on_summary(None, body)
1540
- req_log.debug("summary_handler_called_with_null_summary")
1541
- except Exception as e:
1542
- req_log.error("error_in_summary_handler", error=str(e), traceback=traceback.format_exc())
1543
-
1544
- # Return success
1545
- req_log.info("request_successful")
1546
- return {"success": True}
1547
- except Exception as e:
1548
- req_log.error("request_failed", error=str(e), traceback=traceback.format_exc())
1549
- return Response(
1550
- content=json.dumps({"error": str(e)}),
1551
- status_code=500,
1552
- media_type="application/json"
1553
- )
1554
-
1555
- def _find_summary_in_post_data(self, body, logger):
1556
- """
1557
- Extensive search for the summary in the post data
1558
-
1559
- Args:
1560
- body: The POST request body
1561
- logger: The logger instance to use
1562
-
1563
- Returns:
1564
- The summary if found, None otherwise
1565
- """
1566
- summary = None
1567
-
1568
- # Check all the locations where the summary might be found
1569
-
1570
- # 1. First check post_prompt_data.parsed array (new standard location)
1571
- post_prompt_data = body.get("post_prompt_data", {})
1572
- if post_prompt_data:
1573
- if not self._suppress_logs:
1574
- logger.debug("checking_post_prompt_data", data_type=type(post_prompt_data).__name__)
1575
-
1576
- # Check for parsed array first (this is the most common location)
1577
- if isinstance(post_prompt_data, dict) and "parsed" in post_prompt_data:
1578
- parsed = post_prompt_data.get("parsed")
1579
- if isinstance(parsed, list) and len(parsed) > 0:
1580
- # The summary is the first item in the parsed array
1581
- summary = parsed[0]
1582
- print("SUMMARY_FOUND: " + json.dumps(summary))
1583
- return summary
1584
-
1585
- # Check raw field - it might contain a JSON string
1586
- if isinstance(post_prompt_data, dict) and "raw" in post_prompt_data:
1587
- raw = post_prompt_data.get("raw")
1588
- if isinstance(raw, str):
1589
- try:
1590
- # Try to parse the raw field as JSON
1591
- parsed_raw = json.loads(raw)
1592
- if not self._suppress_logs:
1593
- print("SUMMARY_FOUND_RAW: " + json.dumps(parsed_raw))
1594
- return parsed_raw
1595
- except:
1596
- pass
1597
-
1598
- # Direct access to substituted field
1599
- if isinstance(post_prompt_data, dict) and "substituted" in post_prompt_data:
1600
- summary = post_prompt_data.get("substituted")
1601
- if not self._suppress_logs:
1602
- print("SUMMARY_FOUND_SUBSTITUTED: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_SUBSTITUTED: {summary}")
1603
- return summary
1604
-
1605
- # Check for nested data structure
1606
- if isinstance(post_prompt_data, dict) and "data" in post_prompt_data:
1607
- data = post_prompt_data.get("data")
1608
- if isinstance(data, dict):
1609
- if "substituted" in data:
1610
- summary = data.get("substituted")
1611
- if not self._suppress_logs:
1612
- print("SUMMARY_FOUND_DATA_SUBSTITUTED: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_DATA_SUBSTITUTED: {summary}")
1613
- return summary
1614
-
1615
- # Try text field
1616
- if "text" in data:
1617
- summary = data.get("text")
1618
- if not self._suppress_logs:
1619
- print("SUMMARY_FOUND_DATA_TEXT: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_DATA_TEXT: {summary}")
1620
- return summary
1621
-
1622
- # 2. Check ai_response (legacy location)
1623
- ai_response = body.get("ai_response", {})
1624
- if ai_response and isinstance(ai_response, dict):
1625
- if "summary" in ai_response:
1626
- summary = ai_response.get("summary")
1627
- if not self._suppress_logs:
1628
- print("SUMMARY_FOUND_AI_RESPONSE: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_AI_RESPONSE: {summary}")
1629
- return summary
1630
-
1631
- # 3. Look for direct fields at the top level
1632
- for field in ["substituted", "summary", "content", "text", "result", "output"]:
1633
- if field in body:
1634
- summary = body.get(field)
1635
- if not self._suppress_logs:
1636
- print(f"SUMMARY_FOUND_TOP_LEVEL_{field}: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_TOP_LEVEL_{field}: {summary}")
1637
- return summary
1638
-
1639
- # 4. Recursively search for summary-like fields up to 3 levels deep
1640
- def recursive_search(data, path="", depth=0):
1641
- if depth > 3 or not isinstance(data, dict): # Limit recursion depth
1642
- return None
1643
-
1644
- # Check if any key looks like it might contain a summary
1645
- for key in data.keys():
1646
- if key.lower() in ["summary", "substituted", "output", "result", "content", "text"]:
1647
- value = data.get(key)
1648
- curr_path = f"{path}.{key}" if path else key
1649
- if not self._suppress_logs:
1650
- logger.info(f"potential_summary_found_at_{curr_path}",
1651
- value_type=type(value).__name__)
1652
- if isinstance(value, (str, dict, list)):
1653
- return value
1654
-
1655
- # Recursively check nested dictionaries
1656
- for key, value in data.items():
1657
- if isinstance(value, dict):
1658
- curr_path = f"{path}.{key}" if path else key
1659
- result = recursive_search(value, curr_path, depth + 1)
1660
- if result:
1661
- return result
1662
-
1663
- return None
1664
-
1665
- # Perform recursive search
1666
- recursive_result = recursive_search(body)
1667
- if recursive_result:
1668
- summary = recursive_result
1669
- if not self._suppress_logs:
1670
- print("SUMMARY_FOUND_RECURSIVE: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_RECURSIVE: {summary}")
1671
- return summary
1672
-
1673
- # No summary found
1674
- if not self._suppress_logs:
1675
- print("NO_SUMMARY_FOUND")
1676
- return None
1677
-
1678
- def _register_routes(self, app):
1679
- """Register all routes for the agent, with both slash variants and both HTTP methods"""
1680
-
1681
- self.log.info("registering_routes", path=self.route)
1682
-
1683
- # Root endpoint - without trailing slash
1684
- @app.get(f"{self.route}")
1685
- @app.post(f"{self.route}")
1686
- async def handle_root_no_slash(request: Request):
1687
- return await self._handle_root_request(request)
1688
-
1689
- # Root endpoint - with trailing slash
1690
- @app.get(f"{self.route}/")
1691
- @app.post(f"{self.route}/")
1692
- async def handle_root_with_slash(request: Request):
1693
- return await self._handle_root_request(request)
1694
-
1695
- # Debug endpoint - without trailing slash
1696
- @app.get(f"{self.route}/debug")
1697
- @app.post(f"{self.route}/debug")
1698
- async def handle_debug_no_slash(request: Request):
1699
- return await self._handle_debug_request(request)
1700
-
1701
- # Debug endpoint - with trailing slash
1702
- @app.get(f"{self.route}/debug/")
1703
- @app.post(f"{self.route}/debug/")
1704
- async def handle_debug_with_slash(request: Request):
1705
- return await self._handle_debug_request(request)
1706
-
1707
- # SWAIG endpoint - without trailing slash
1708
- @app.get(f"{self.route}/swaig")
1709
- @app.post(f"{self.route}/swaig")
1710
- async def handle_swaig_no_slash(request: Request):
1711
- return await self._handle_swaig_request(request)
1712
-
1713
- # SWAIG endpoint - with trailing slash
1714
- @app.get(f"{self.route}/swaig/")
1715
- @app.post(f"{self.route}/swaig/")
1716
- async def handle_swaig_with_slash(request: Request):
1717
- return await self._handle_swaig_request(request)
1718
-
1719
- # Post-prompt endpoint - without trailing slash
1720
- @app.get(f"{self.route}/post_prompt")
1721
- @app.post(f"{self.route}/post_prompt")
1722
- async def handle_post_prompt_no_slash(request: Request):
1723
- return await self._handle_post_prompt_request(request)
1724
-
1725
- # Post-prompt endpoint - with trailing slash
1726
- @app.get(f"{self.route}/post_prompt/")
1727
- @app.post(f"{self.route}/post_prompt/")
1728
- async def handle_post_prompt_with_slash(request: Request):
1729
- return await self._handle_post_prompt_request(request)
1730
-
1731
- # Register routes for all routing callbacks
1732
- if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
1733
- for callback_path, callback_fn in self._routing_callbacks.items():
1734
- # Skip the root path as it's already handled
1735
- if callback_path == "/":
1736
- continue
1737
-
1738
- # Register the endpoint without trailing slash
1739
- callback_route = callback_path
1740
- self.log.info("registering_callback_route", path=callback_route)
1741
-
1742
- @app.get(callback_route)
1743
- @app.post(callback_route)
1744
- async def handle_callback_no_slash(request: Request, path_param=callback_route):
1745
- # Store the callback path in request state for _handle_root_request to use
1746
- request.state.callback_path = path_param
1747
- return await self._handle_root_request(request)
1748
-
1749
- # Register the endpoint with trailing slash if it doesn't already have one
1750
- if not callback_route.endswith('/'):
1751
- slash_route = f"{callback_route}/"
1752
-
1753
- @app.get(slash_route)
1754
- @app.post(slash_route)
1755
- async def handle_callback_with_slash(request: Request, path_param=callback_route):
1756
- # Store the callback path in request state for _handle_root_request to use
1757
- request.state.callback_path = path_param
1758
- return await self._handle_root_request(request)
1759
-
1760
- # Log all registered routes
1761
- routes = [f"{route.methods} {route.path}" for route in app.routes]
1762
- self.log.debug("routes_registered", routes=routes)
1763
-
1764
- def _register_class_decorated_tools(self):
1765
- """
1766
- Register all tools decorated with @AgentBase.tool
1767
- """
1768
- for name in dir(self):
1769
- attr = getattr(self, name)
1770
- if callable(attr) and hasattr(attr, "_is_tool"):
1771
- # Get tool parameters
1772
- tool_name = getattr(attr, "_tool_name", name)
1773
- tool_params = getattr(attr, "_tool_params", {})
1774
-
1775
- # Extract parameters
1776
- parameters = tool_params.get("parameters", {})
1777
- description = tool_params.get("description", attr.__doc__ or f"Function {tool_name}")
1778
- secure = tool_params.get("secure", True)
1779
- fillers = tool_params.get("fillers", None)
1780
-
1781
- # Create a wrapper that binds the method to this instance
1782
- def make_wrapper(method):
1783
- @functools.wraps(method)
1784
- def wrapper(args, raw_data=None):
1785
- return method(args, raw_data)
1786
- return wrapper
1787
-
1788
- # Register the tool
1789
- self.define_tool(
1790
- name=tool_name,
1791
- description=description,
1792
- parameters=parameters,
1793
- handler=make_wrapper(attr),
1794
- secure=secure,
1795
- fillers=fillers
1796
- )
1797
-
1798
- # State Management Methods
1799
- def get_state(self, call_id: str) -> Optional[Dict[str, Any]]:
1800
- """
1801
- Get the state for a call
1802
-
1803
- Args:
1804
- call_id: Call ID to get state for
1805
-
1806
- Returns:
1807
- Call state or None if not found
1808
- """
1809
- try:
1810
- if hasattr(self._state_manager, 'get_state'):
1811
- return self._state_manager.get_state(call_id)
1812
- return None
1813
- except Exception as e:
1814
- logger.warning("get_state_failed", error=str(e))
1815
- return None
1816
-
1817
- def set_state(self, call_id: str, data: Dict[str, Any]) -> bool:
1818
- """
1819
- Set the state for a call
1820
-
1821
- Args:
1822
- call_id: Call ID to set state for
1823
- data: State data to set
1824
-
1825
- Returns:
1826
- True if state was set, False otherwise
1827
- """
1828
- try:
1829
- if hasattr(self._state_manager, 'set_state'):
1830
- return self._state_manager.set_state(call_id, data)
1831
- return False
1832
- except Exception as e:
1833
- logger.warning("set_state_failed", error=str(e))
1834
- return False
1835
-
1836
- def update_state(self, call_id: str, data: Dict[str, Any]) -> bool:
1837
- """
1838
- Update the state for a call
1839
-
1840
- Args:
1841
- call_id: Call ID to update state for
1842
- data: State data to update
1843
-
1844
- Returns:
1845
- True if state was updated, False otherwise
1846
- """
1847
- try:
1848
- if hasattr(self._state_manager, 'update_state'):
1849
- return self._state_manager.update_state(call_id, data)
1850
- return self.set_state(call_id, data)
1851
- except Exception as e:
1852
- logger.warning("update_state_failed", error=str(e))
1853
- return False
1854
-
1855
- def clear_state(self, call_id: str) -> bool:
1856
- """
1857
- Clear the state for a call
1858
-
1859
- Args:
1860
- call_id: Call ID to clear state for
1861
-
1862
- Returns:
1863
- True if state was cleared, False otherwise
1864
- """
1865
- try:
1866
- if hasattr(self._state_manager, 'clear_state'):
1867
- return self._state_manager.clear_state(call_id)
1868
- return False
1869
- except Exception as e:
1870
- logger.warning("clear_state_failed", error=str(e))
1871
- return False
1872
-
1873
- def cleanup_expired_state(self) -> int:
1874
- """
1875
- Clean up expired state
1876
-
1877
- Returns:
1878
- Number of expired state entries removed
1879
- """
1880
- try:
1881
- if hasattr(self._state_manager, 'cleanup_expired'):
1882
- return self._state_manager.cleanup_expired()
1883
- return 0
1884
- except Exception as e:
1885
- logger.warning("cleanup_expired_state_failed", error=str(e))
1886
- return 0
1887
-
1888
- def _register_state_tracking_tools(self):
1889
- """
1890
- Register tools for tracking conversation state
1891
- """
1892
- # Register startup hook
1893
- self.define_tool(
1894
- name="startup_hook",
1895
- description="Called when the conversation starts",
1896
- parameters={},
1897
- handler=self._startup_hook_handler,
1898
- secure=False
1899
- )
1900
-
1901
- # Register hangup hook
1902
- self.define_tool(
1903
- name="hangup_hook",
1904
- description="Called when the conversation ends",
1905
- parameters={},
1906
- handler=self._hangup_hook_handler,
1907
- secure=False
1908
- )
1909
-
1910
- def _startup_hook_handler(self, args, raw_data):
1911
- """
1912
- Handler for the startup hook
1913
-
1914
- Args:
1915
- args: Function arguments
1916
- raw_data: Raw request data
1917
-
1918
- Returns:
1919
- Function result
1920
- """
1921
- # Extract call ID
1922
- call_id = raw_data.get("call_id") if raw_data else None
1923
- if not call_id:
1924
- return SwaigFunctionResult("Error: Missing call_id")
1925
-
1926
- # Activate the session
1927
- self._session_manager.activate_session(call_id)
1928
-
1929
- # Initialize state
1930
- self.set_state(call_id, {
1931
- "start_time": datetime.now().isoformat(),
1932
- "events": []
1933
- })
1934
-
1935
- return SwaigFunctionResult("Call started and session activated")
1936
-
1937
- def _hangup_hook_handler(self, args, raw_data):
1938
- """
1939
- Handler for the hangup hook
1940
-
1941
- Args:
1942
- args: Function arguments
1943
- raw_data: Raw request data
1944
-
1945
- Returns:
1946
- Function result
1947
- """
1948
- # Extract call ID
1949
- call_id = raw_data.get("call_id") if raw_data else None
1950
- if not call_id:
1951
- return SwaigFunctionResult("Error: Missing call_id")
1952
-
1953
- # End the session
1954
- self._session_manager.end_session(call_id)
1955
-
1956
- # Update state
1957
- state = self.get_state(call_id) or {}
1958
- state["end_time"] = datetime.now().isoformat()
1959
- self.update_state(call_id, state)
1960
-
1961
- return SwaigFunctionResult("Call ended and session deactivated")
1962
-
1963
- def set_post_prompt(self, text: str) -> 'AgentBase':
1964
- """
1965
- Set the post-prompt for the agent
1966
-
1967
- Args:
1968
- text: Post-prompt text
1969
-
1970
- Returns:
1971
- Self for method chaining
1972
- """
1973
- self._post_prompt = text
1974
- return self
1975
-
1976
- def set_auto_answer(self, enabled: bool) -> 'AgentBase':
1977
- """
1978
- Set whether to automatically answer calls
1979
-
1980
- Args:
1981
- enabled: Whether to auto-answer
1982
-
1983
- Returns:
1984
- Self for method chaining
1985
- """
1986
- self._auto_answer = enabled
1987
- return self
1988
-
1989
- def set_call_recording(self,
1990
- enabled: bool,
1991
- format: str = "mp4",
1992
- stereo: bool = True) -> 'AgentBase':
1993
- """
1994
- Set call recording parameters
1995
-
1996
- Args:
1997
- enabled: Whether to record calls
1998
- format: Recording format
1999
- stereo: Whether to record in stereo
2000
-
2001
- Returns:
2002
- Self for method chaining
2003
- """
2004
- self._record_call = enabled
2005
- self._record_format = format
2006
- self._record_stereo = stereo
2007
- return self
2008
-
2009
- def add_native_function(self, function_name: str) -> 'AgentBase':
2010
- """
2011
- Add a native function to the list of enabled native functions
2012
-
2013
- Args:
2014
- function_name: Name of native function to enable
2015
-
2016
- Returns:
2017
- Self for method chaining
2018
- """
2019
- if function_name and isinstance(function_name, str):
2020
- if not self.native_functions:
2021
- self.native_functions = []
2022
- if function_name not in self.native_functions:
2023
- self.native_functions.append(function_name)
2024
- return self
2025
-
2026
- def remove_native_function(self, function_name: str) -> 'AgentBase':
2027
- """
2028
- Remove a native function from the SWAIG object
2029
-
2030
- Args:
2031
- function_name: Name of the native function
2032
-
2033
- Returns:
2034
- Self for method chaining
2035
- """
2036
- if function_name in self.native_functions:
2037
- self.native_functions.remove(function_name)
2038
- return self
2039
-
2040
- def get_native_functions(self) -> List[str]:
2041
- """
2042
- Get the list of native functions
2043
-
2044
- Returns:
2045
- List of native function names
2046
- """
2047
- return self.native_functions.copy()
2048
-
2049
- def has_section(self, title: str) -> bool:
2050
- """
2051
- Check if a section exists in the prompt
2052
-
2053
- Args:
2054
- title: Section title
2055
-
2056
- Returns:
2057
- True if the section exists, False otherwise
2058
- """
2059
- if not self._use_pom or not self.pom:
2060
- return False
2061
-
2062
- return self.pom.has_section(title)
2063
-
2064
- def on_swml_request(self, request_data: Optional[dict] = None) -> Optional[dict]:
2065
- """
2066
- Called when SWML is requested, with request data when available.
2067
-
2068
- Subclasses can override this to inspect or modify SWML based on the request.
2069
-
2070
- Args:
2071
- request_data: Optional dictionary containing the parsed POST body
2072
-
2073
- Returns:
2074
- Optional dict to modify/augment the SWML document
2075
- """
2076
- # Default implementation does nothing
2077
- return None
2078
-
2079
- def serve(self, host: Optional[str] = None, port: Optional[int] = None) -> None:
2080
- """
2081
- Start a web server for this agent
2082
-
2083
- Args:
2084
- host: Optional host to override the default
2085
- port: Optional port to override the default
2086
- """
2087
- import uvicorn
2088
-
2089
- # Create a FastAPI app with no automatic redirects
2090
- app = FastAPI(redirect_slashes=False)
2091
-
2092
- # Register all routes
2093
- self._register_routes(app)
2094
-
2095
- host = host or self.host
2096
- port = port or self.port
2097
-
2098
- # Print the auth credentials with source
2099
- username, password, source = self.get_basic_auth_credentials(include_source=True)
2100
- self.log.info("starting_server",
2101
- url=f"http://{host}:{port}{self.route}",
2102
- username=username,
2103
- password="*" * len(password),
2104
- auth_source=source)
2105
-
2106
- print(f"Agent '{self.name}' is available at:")
2107
- print(f"URL: http://{host}:{port}{self.route}")
2108
- print(f"Basic Auth: {username}:{password} (source: {source})")
2109
-
2110
- # Check if SIP usernames are registered and print that info
2111
- if hasattr(self, '_sip_usernames') and self._sip_usernames:
2112
- print(f"Registered SIP usernames: {', '.join(sorted(self._sip_usernames))}")
2113
-
2114
- # Check if callback endpoints are registered and print them
2115
- if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
2116
- for path in sorted(self._routing_callbacks.keys()):
2117
- if hasattr(self, '_sip_usernames') and path == "/sip":
2118
- print(f"SIP endpoint: http://{host}:{port}{path}")
2119
- else:
2120
- print(f"Callback endpoint: http://{host}:{port}{path}")
2121
-
2122
- # Configure Uvicorn for production
2123
- uvicorn_log_config = uvicorn.config.LOGGING_CONFIG
2124
- uvicorn_log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
2125
- uvicorn_log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
2126
-
2127
- # Start the server
2128
- try:
2129
- # Run the server
2130
- uvicorn.run(
2131
- app,
2132
- host=host,
2133
- port=port,
2134
- log_config=uvicorn_log_config
2135
- )
2136
- except KeyboardInterrupt:
2137
- self.log.info("server_shutdown")
2138
- print("\nStopping the agent.")
2139
-
2140
- # ----------------------------------------------------------------------
2141
- # AI Verb Configuration Methods
2142
- # ----------------------------------------------------------------------
2143
-
2144
- def add_hint(self, hint: str) -> 'AgentBase':
2145
- """
2146
- Add a simple string hint to help the AI agent understand certain words better
2147
-
2148
- Args:
2149
- hint: The hint string to add
2150
-
2151
- Returns:
2152
- Self for method chaining
2153
- """
2154
- if isinstance(hint, str) and hint:
2155
- self._hints.append(hint)
2156
- return self
2157
-
2158
- def add_hints(self, hints: List[str]) -> 'AgentBase':
2159
- """
2160
- Add multiple string hints
2161
-
2162
- Args:
2163
- hints: List of hint strings
2164
-
2165
- Returns:
2166
- Self for method chaining
2167
- """
2168
- if hints and isinstance(hints, list):
2169
- for hint in hints:
2170
- if isinstance(hint, str) and hint:
2171
- self._hints.append(hint)
2172
- return self
2173
-
2174
- def add_pattern_hint(self,
2175
- hint: str,
2176
- pattern: str,
2177
- replace: str,
2178
- ignore_case: bool = False) -> 'AgentBase':
2179
- """
2180
- Add a complex hint with pattern matching
2181
-
2182
- Args:
2183
- hint: The hint to match
2184
- pattern: Regular expression pattern
2185
- replace: Text to replace the hint with
2186
- ignore_case: Whether to ignore case when matching
2187
-
2188
- Returns:
2189
- Self for method chaining
2190
- """
2191
- if hint and pattern and replace:
2192
- self._hints.append({
2193
- "hint": hint,
2194
- "pattern": pattern,
2195
- "replace": replace,
2196
- "ignore_case": ignore_case
2197
- })
2198
- return self
2199
-
2200
- def add_language(self,
2201
- name: str,
2202
- code: str,
2203
- voice: str,
2204
- speech_fillers: Optional[List[str]] = None,
2205
- function_fillers: Optional[List[str]] = None,
2206
- engine: Optional[str] = None,
2207
- model: Optional[str] = None) -> 'AgentBase':
2208
- """
2209
- Add a language configuration to support multilingual conversations
2210
-
2211
- Args:
2212
- name: Name of the language (e.g., "English", "French")
2213
- code: Language code (e.g., "en-US", "fr-FR")
2214
- voice: TTS voice to use. Can be a simple name (e.g., "en-US-Neural2-F")
2215
- or a combined format "engine.voice:model" (e.g., "elevenlabs.josh:eleven_turbo_v2_5")
2216
- speech_fillers: Optional list of filler phrases for natural speech
2217
- function_fillers: Optional list of filler phrases during function calls
2218
- engine: Optional explicit engine name (e.g., "elevenlabs", "rime")
2219
- model: Optional explicit model name (e.g., "eleven_turbo_v2_5", "arcana")
2220
-
2221
- Returns:
2222
- Self for method chaining
2223
-
2224
- Examples:
2225
- # Simple voice name
2226
- agent.add_language("English", "en-US", "en-US-Neural2-F")
2227
-
2228
- # Explicit parameters
2229
- agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
2230
-
2231
- # Combined format
2232
- agent.add_language("English", "en-US", "elevenlabs.josh:eleven_turbo_v2_5")
2233
- """
2234
- language = {
2235
- "name": name,
2236
- "code": code
2237
- }
2238
-
2239
- # Handle voice formatting (either explicit params or combined string)
2240
- if engine or model:
2241
- # Use explicit parameters if provided
2242
- language["voice"] = voice
2243
- if engine:
2244
- language["engine"] = engine
2245
- if model:
2246
- language["model"] = model
2247
- elif "." in voice and ":" in voice:
2248
- # Parse combined string format: "engine.voice:model"
2249
- try:
2250
- engine_voice, model_part = voice.split(":", 1)
2251
- engine_part, voice_part = engine_voice.split(".", 1)
2252
-
2253
- language["voice"] = voice_part
2254
- language["engine"] = engine_part
2255
- language["model"] = model_part
2256
- except ValueError:
2257
- # If parsing fails, use the voice string as-is
2258
- language["voice"] = voice
2259
- else:
2260
- # Simple voice string
2261
- language["voice"] = voice
2262
-
2263
- # Add fillers if provided
2264
- if speech_fillers and function_fillers:
2265
- language["speech_fillers"] = speech_fillers
2266
- language["function_fillers"] = function_fillers
2267
- elif speech_fillers or function_fillers:
2268
- # If only one type of fillers is provided, use the deprecated "fillers" field
2269
- fillers = speech_fillers or function_fillers
2270
- language["fillers"] = fillers
2271
-
2272
- self._languages.append(language)
2273
- return self
2274
-
2275
- def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
2276
- """
2277
- Set all language configurations at once
2278
-
2279
- Args:
2280
- languages: List of language configuration dictionaries
2281
-
2282
- Returns:
2283
- Self for method chaining
2284
- """
2285
- if languages and isinstance(languages, list):
2286
- self._languages = languages
2287
- return self
2288
-
2289
- def add_pronunciation(self,
2290
- replace: str,
2291
- with_text: str,
2292
- ignore_case: bool = False) -> 'AgentBase':
2293
- """
2294
- Add a pronunciation rule to help the AI speak certain words correctly
2295
-
2296
- Args:
2297
- replace: The expression to replace
2298
- with_text: The phonetic spelling to use instead
2299
- ignore_case: Whether to ignore case when matching
2300
-
2301
- Returns:
2302
- Self for method chaining
2303
- """
2304
- if replace and with_text:
2305
- rule = {
2306
- "replace": replace,
2307
- "with": with_text
2308
- }
2309
- if ignore_case:
2310
- rule["ignore_case"] = True
2311
-
2312
- self._pronounce.append(rule)
2313
- return self
2314
-
2315
- def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
2316
- """
2317
- Set all pronunciation rules at once
2318
-
2319
- Args:
2320
- pronunciations: List of pronunciation rule dictionaries
2321
-
2322
- Returns:
2323
- Self for method chaining
2324
- """
2325
- if pronunciations and isinstance(pronunciations, list):
2326
- self._pronounce = pronunciations
2327
- return self
2328
-
2329
- def set_param(self, key: str, value: Any) -> 'AgentBase':
2330
- """
2331
- Set a single AI parameter
2332
-
2333
- Args:
2334
- key: Parameter name
2335
- value: Parameter value
2336
-
2337
- Returns:
2338
- Self for method chaining
2339
- """
2340
- if key:
2341
- self._params[key] = value
2342
- return self
2343
-
2344
- def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
2345
- """
2346
- Set multiple AI parameters at once
2347
-
2348
- Args:
2349
- params: Dictionary of parameter name/value pairs
2350
-
2351
- Returns:
2352
- Self for method chaining
2353
- """
2354
- if params and isinstance(params, dict):
2355
- self._params.update(params)
2356
- return self
2357
-
2358
- def set_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
2359
- """
2360
- Set the global data available to the AI throughout the conversation
2361
-
2362
- Args:
2363
- data: Dictionary of global data
2364
-
2365
- Returns:
2366
- Self for method chaining
2367
- """
2368
- if data and isinstance(data, dict):
2369
- self._global_data = data
2370
- return self
2371
-
2372
- def update_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
2373
- """
2374
- Update the global data with new values
2375
-
2376
- Args:
2377
- data: Dictionary of global data to update
2378
-
2379
- Returns:
2380
- Self for method chaining
2381
- """
2382
- if data and isinstance(data, dict):
2383
- self._global_data.update(data)
2384
- return self
2385
-
2386
- def set_native_functions(self, function_names: List[str]) -> 'AgentBase':
2387
- """
2388
- Set the list of native functions to enable
2389
-
2390
- Args:
2391
- function_names: List of native function names
2392
-
2393
- Returns:
2394
- Self for method chaining
2395
- """
2396
- if function_names and isinstance(function_names, list):
2397
- self.native_functions = [name for name in function_names if isinstance(name, str)]
2398
- return self
2399
-
2400
- def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
2401
- """
2402
- Add a remote function include to the SWAIG configuration
2403
-
2404
- Args:
2405
- url: URL to fetch remote functions from
2406
- functions: List of function names to include
2407
- meta_data: Optional metadata to include with the function include
2408
-
2409
- Returns:
2410
- Self for method chaining
2411
- """
2412
- if url and functions and isinstance(functions, list):
2413
- include = {
2414
- "url": url,
2415
- "functions": functions
2416
- }
2417
- if meta_data and isinstance(meta_data, dict):
2418
- include["meta_data"] = meta_data
2419
-
2420
- self._function_includes.append(include)
2421
- return self
2422
-
2423
- def set_function_includes(self, includes: List[Dict[str, Any]]) -> 'AgentBase':
2424
- """
2425
- Set the complete list of function includes
2426
-
2427
- Args:
2428
- includes: List of include objects, each with url and functions properties
2429
-
2430
- Returns:
2431
- Self for method chaining
2432
- """
2433
- if includes and isinstance(includes, list):
2434
- # Validate each include has required properties
2435
- valid_includes = []
2436
- for include in includes:
2437
- if isinstance(include, dict) and "url" in include and "functions" in include:
2438
- if isinstance(include["functions"], list):
2439
- valid_includes.append(include)
2440
-
2441
- self._function_includes = valid_includes
2442
- return self
2443
-
2444
- def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
2445
- """
2446
- Enable SIP-based routing for this agent
2447
-
2448
- This allows the agent to automatically route SIP requests based on SIP usernames.
2449
- When enabled, an endpoint at the specified path is automatically created
2450
- that will handle SIP requests and deliver them to this agent.
2451
-
2452
- Args:
2453
- auto_map: Whether to automatically map common SIP usernames to this agent
2454
- (based on the agent name and route path)
2455
- path: The path to register the SIP routing endpoint (default: "/sip")
2456
-
2457
- Returns:
2458
- Self for method chaining
2459
- """
2460
- # Create a routing callback that handles SIP usernames
2461
- def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
2462
- # Extract SIP username from the request body
2463
- sip_username = self.extract_sip_username(body)
2464
-
2465
- if sip_username:
2466
- self.log.info("sip_username_extracted", username=sip_username)
2467
-
2468
- # Check if this username is registered with this agent
2469
- if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
2470
- self.log.info("sip_username_matched", username=sip_username)
2471
- # This route is already being handled by the agent, no need to redirect
2472
- return None
2473
- else:
2474
- self.log.info("sip_username_not_matched", username=sip_username)
2475
- # Not registered with this agent, let routing continue
2476
-
2477
- return None
2478
-
2479
- # Register the callback with the SWMLService, specifying the path
2480
- self.register_routing_callback(sip_routing_callback, path=path)
2481
-
2482
- # Auto-map common usernames if requested
2483
- if auto_map:
2484
- self.auto_map_sip_usernames()
2485
-
2486
- return self
2487
-
2488
- def register_sip_username(self, sip_username: str) -> 'AgentBase':
2489
- """
2490
- Register a SIP username that should be routed to this agent
2491
-
2492
- Args:
2493
- sip_username: SIP username to register
2494
-
2495
- Returns:
2496
- Self for method chaining
2497
- """
2498
- if not hasattr(self, '_sip_usernames'):
2499
- self._sip_usernames = set()
2500
-
2501
- self._sip_usernames.add(sip_username.lower())
2502
- self.log.info("sip_username_registered", username=sip_username)
2503
-
2504
- return self
2505
-
2506
- def auto_map_sip_usernames(self) -> 'AgentBase':
2507
- """
2508
- Automatically register common SIP usernames based on this agent's
2509
- name and route
2510
-
2511
- Returns:
2512
- Self for method chaining
2513
- """
2514
- # Register username based on agent name
2515
- clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
2516
- if clean_name:
2517
- self.register_sip_username(clean_name)
2518
-
2519
- # Register username based on route (without slashes)
2520
- clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
2521
- if clean_route and clean_route != clean_name:
2522
- self.register_sip_username(clean_route)
2523
-
2524
- # Register common variations if they make sense
2525
- if len(clean_name) > 3:
2526
- # Register without vowels
2527
- no_vowels = re.sub(r'[aeiou]', '', clean_name)
2528
- if no_vowels != clean_name and len(no_vowels) > 2:
2529
- self.register_sip_username(no_vowels)
2530
-
2531
- return self
2532
-
2533
- def set_web_hook_url(self, url: str) -> 'AgentBase':
2534
- """
2535
- Override the default web_hook_url with a supplied URL string
2536
-
2537
- Args:
2538
- url: The URL to use for SWAIG function webhooks
2539
-
2540
- Returns:
2541
- Self for method chaining
2542
- """
2543
- self._web_hook_url_override = url
2544
- return self
2545
-
2546
- def set_post_prompt_url(self, url: str) -> 'AgentBase':
2547
- """
2548
- Override the default post_prompt_url with a supplied URL string
2549
-
2550
- Args:
2551
- url: The URL to use for post-prompt summary delivery
2552
-
2553
- Returns:
2554
- Self for method chaining
2555
- """
2556
- self._post_prompt_url_override = url
2557
- return self