signalwire-agents 0.1.6__py3-none-any.whl → 0.1.8__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.
@@ -1,3 +1,4 @@
1
+ #!/usr/bin/env python3
1
2
  """
2
3
  Copyright (c) 2025 SignalWire
3
4
 
@@ -7,24 +8,21 @@ 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
27
- import re
21
+ import logging
22
+ import inspect
23
+ import functools
24
+ from typing import Optional, Union, List, Dict, Any, Tuple, Callable, Type
25
+ from urllib.parse import urlparse, urlencode
28
26
 
29
27
  try:
30
28
  import fastapi
@@ -81,6 +79,169 @@ from signalwire_agents.core.swml_handler import AIVerbHandler
81
79
  # Create a logger
82
80
  logger = structlog.get_logger("agent_base")
83
81
 
82
+ class EphemeralAgentConfig:
83
+ """
84
+ An ephemeral configurator object that mimics AgentBase's configuration interface.
85
+
86
+ This allows dynamic configuration callbacks to use the same familiar methods
87
+ they would use during agent initialization, but for per-request configuration.
88
+ """
89
+
90
+ def __init__(self):
91
+ # Initialize all configuration containers
92
+ self._hints = []
93
+ self._languages = []
94
+ self._pronounce = []
95
+ self._params = {}
96
+ self._global_data = {}
97
+ self._prompt_sections = []
98
+ self._raw_prompt = None
99
+ self._post_prompt = None
100
+ self._function_includes = []
101
+ self._native_functions = []
102
+
103
+ # Mirror all the AgentBase configuration methods
104
+
105
+ def add_hint(self, hint: str) -> 'EphemeralAgentConfig':
106
+ """Add a simple string hint"""
107
+ if isinstance(hint, str) and hint:
108
+ self._hints.append(hint)
109
+ return self
110
+
111
+ def add_hints(self, hints: List[str]) -> 'EphemeralAgentConfig':
112
+ """Add multiple string hints"""
113
+ if hints and isinstance(hints, list):
114
+ for hint in hints:
115
+ if isinstance(hint, str) and hint:
116
+ self._hints.append(hint)
117
+ return self
118
+
119
+ def add_language(self, name: str, code: str, voice: str, **kwargs) -> 'EphemeralAgentConfig':
120
+ """Add a language configuration"""
121
+ language = {
122
+ "name": name,
123
+ "code": code,
124
+ "voice": voice
125
+ }
126
+
127
+ # Handle additional parameters
128
+ for key, value in kwargs.items():
129
+ if key in ["engine", "model", "speech_fillers", "function_fillers", "fillers"]:
130
+ language[key] = value
131
+
132
+ self._languages.append(language)
133
+ return self
134
+
135
+ def add_pronunciation(self, replace: str, with_text: str, ignore_case: bool = False) -> 'EphemeralAgentConfig':
136
+ """Add a pronunciation rule"""
137
+ if replace and with_text:
138
+ rule = {"replace": replace, "with": with_text}
139
+ if ignore_case:
140
+ rule["ignore_case"] = True
141
+ self._pronounce.append(rule)
142
+ return self
143
+
144
+ def set_param(self, key: str, value: Any) -> 'EphemeralAgentConfig':
145
+ """Set a single AI parameter"""
146
+ if key:
147
+ self._params[key] = value
148
+ return self
149
+
150
+ def set_params(self, params: Dict[str, Any]) -> 'EphemeralAgentConfig':
151
+ """Set multiple AI parameters"""
152
+ if params and isinstance(params, dict):
153
+ self._params.update(params)
154
+ return self
155
+
156
+ def set_global_data(self, data: Dict[str, Any]) -> 'EphemeralAgentConfig':
157
+ """Set global data"""
158
+ if data and isinstance(data, dict):
159
+ self._global_data = data
160
+ return self
161
+
162
+ def update_global_data(self, data: Dict[str, Any]) -> 'EphemeralAgentConfig':
163
+ """Update global data"""
164
+ if data and isinstance(data, dict):
165
+ self._global_data.update(data)
166
+ return self
167
+
168
+ def set_prompt_text(self, text: str) -> 'EphemeralAgentConfig':
169
+ """Set raw prompt text"""
170
+ self._raw_prompt = text
171
+ return self
172
+
173
+ def set_post_prompt(self, text: str) -> 'EphemeralAgentConfig':
174
+ """Set post-prompt text"""
175
+ self._post_prompt = text
176
+ return self
177
+
178
+ def prompt_add_section(self, title: str, body: str = "", bullets: Optional[List[str]] = None, **kwargs) -> 'EphemeralAgentConfig':
179
+ """Add a prompt section"""
180
+ section = {
181
+ "title": title,
182
+ "body": body
183
+ }
184
+ if bullets:
185
+ section["bullets"] = bullets
186
+
187
+ # Handle additional parameters
188
+ for key, value in kwargs.items():
189
+ if key in ["numbered", "numbered_bullets", "subsections"]:
190
+ section[key] = value
191
+
192
+ self._prompt_sections.append(section)
193
+ return self
194
+
195
+ def set_native_functions(self, function_names: List[str]) -> 'EphemeralAgentConfig':
196
+ """Set native functions"""
197
+ if function_names and isinstance(function_names, list):
198
+ self._native_functions = [name for name in function_names if isinstance(name, str)]
199
+ return self
200
+
201
+ def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'EphemeralAgentConfig':
202
+ """Add a function include"""
203
+ if url and functions and isinstance(functions, list):
204
+ include = {"url": url, "functions": functions}
205
+ if meta_data and isinstance(meta_data, dict):
206
+ include["meta_data"] = meta_data
207
+ self._function_includes.append(include)
208
+ return self
209
+
210
+ def extract_config(self) -> Dict[str, Any]:
211
+ """
212
+ Extract the configuration as a dictionary for applying to the real agent.
213
+
214
+ Returns:
215
+ Dictionary containing all the configuration changes
216
+ """
217
+ config = {}
218
+
219
+ if self._hints:
220
+ config["hints"] = self._hints
221
+ if self._languages:
222
+ config["languages"] = self._languages
223
+ if self._pronounce:
224
+ config["pronounce"] = self._pronounce
225
+ if self._params:
226
+ config["params"] = self._params
227
+ if self._global_data:
228
+ config["global_data"] = self._global_data
229
+ if self._function_includes:
230
+ config["function_includes"] = self._function_includes
231
+ if self._native_functions:
232
+ config["native_functions"] = self._native_functions
233
+
234
+ # Handle prompt sections - these should be applied to the agent's POM, not as raw config
235
+ # The calling code should use these to build the prompt properly
236
+ if self._prompt_sections:
237
+ config["_ephemeral_prompt_sections"] = self._prompt_sections
238
+ if self._raw_prompt:
239
+ config["_ephemeral_raw_prompt"] = self._raw_prompt
240
+ if self._post_prompt:
241
+ config["_ephemeral_post_prompt"] = self._post_prompt
242
+
243
+ return config
244
+
84
245
  class AgentBase(SWMLService):
85
246
  """
86
247
  Base class for all SignalWire AI Agents.
@@ -110,7 +271,7 @@ class AgentBase(SWMLService):
110
271
  basic_auth: Optional[Tuple[str, str]] = None,
111
272
  use_pom: bool = True,
112
273
  enable_state_tracking: bool = False,
113
- token_expiry_secs: int = 600,
274
+ token_expiry_secs: int = 3600,
114
275
  auto_answer: bool = True,
115
276
  record_call: bool = False,
116
277
  record_format: str = "mp4",
@@ -240,6 +401,9 @@ class AgentBase(SWMLService):
240
401
  self._params = {}
241
402
  self._global_data = {}
242
403
  self._function_includes = []
404
+
405
+ # Dynamic configuration callback
406
+ self._dynamic_config_callback = None
243
407
 
244
408
  def _process_prompt_sections(self):
245
409
  """
@@ -355,6 +519,19 @@ class AgentBase(SWMLService):
355
519
  self._raw_prompt = text
356
520
  return self
357
521
 
522
+ def set_post_prompt(self, text: str) -> 'AgentBase':
523
+ """
524
+ Set the post-prompt text for summary generation
525
+
526
+ Args:
527
+ text: The post-prompt text
528
+
529
+ Returns:
530
+ Self for method chaining
531
+ """
532
+ self._post_prompt = text
533
+ return self
534
+
358
535
  def set_prompt_pom(self, pom: List[Dict[str, Any]]) -> 'AgentBase':
359
536
  """
360
537
  Set the prompt as a POM dictionary
@@ -569,6 +746,44 @@ class AgentBase(SWMLService):
569
746
  return func
570
747
  return decorator
571
748
 
749
+ def _register_class_decorated_tools(self):
750
+ """
751
+ Register tools defined with @AgentBase.tool class decorator
752
+
753
+ This method scans the class for methods decorated with @AgentBase.tool
754
+ and registers them automatically.
755
+ """
756
+ # Get the class of this instance
757
+ cls = self.__class__
758
+
759
+ # Loop through all attributes in the class
760
+ for name in dir(cls):
761
+ # Get the attribute
762
+ attr = getattr(cls, name)
763
+
764
+ # Check if it's a method decorated with @AgentBase.tool
765
+ if inspect.ismethod(attr) or inspect.isfunction(attr):
766
+ if hasattr(attr, "_is_tool") and getattr(attr, "_is_tool", False):
767
+ # Extract tool information
768
+ tool_name = getattr(attr, "_tool_name", name)
769
+ tool_params = getattr(attr, "_tool_params", {})
770
+
771
+ # Get description and parameters
772
+ description = tool_params.get("description", attr.__doc__ or f"Function {tool_name}")
773
+ parameters = tool_params.get("parameters", {})
774
+ secure = tool_params.get("secure", True)
775
+ fillers = tool_params.get("fillers", None)
776
+
777
+ # Register the tool
778
+ self.define_tool(
779
+ name=tool_name,
780
+ description=description,
781
+ parameters=parameters,
782
+ handler=attr.__get__(self, cls), # Bind the method to this instance
783
+ secure=secure,
784
+ fillers=fillers
785
+ )
786
+
572
787
  @classmethod
573
788
  def tool(cls, name=None, **kwargs):
574
789
  """
@@ -730,7 +945,17 @@ class AgentBase(SWMLService):
730
945
  Returns:
731
946
  Secure token string
732
947
  """
733
- return self._session_manager.create_tool_token(tool_name, call_id)
948
+ try:
949
+ # Ensure we have a session manager
950
+ if not hasattr(self, '_session_manager'):
951
+ self.log.error("no_session_manager")
952
+ return ""
953
+
954
+ # Create the token using the session manager
955
+ return self._session_manager.create_tool_token(tool_name, call_id)
956
+ except Exception as e:
957
+ self.log.error("token_creation_error", error=str(e), tool=tool_name, call_id=call_id)
958
+ return ""
734
959
 
735
960
  def validate_tool_token(self, function_name: str, token: str, call_id: str) -> bool:
736
961
  """
@@ -744,14 +969,92 @@ class AgentBase(SWMLService):
744
969
  Returns:
745
970
  True if token is valid, False otherwise
746
971
  """
747
- # Skip validation for non-secure tools
748
- if function_name not in self._swaig_functions:
749
- return False
972
+ try:
973
+ # Skip validation for non-secure tools
974
+ if function_name not in self._swaig_functions:
975
+ self.log.warning("unknown_function", function=function_name)
976
+ return False
977
+
978
+ # Always allow non-secure functions
979
+ if not self._swaig_functions[function_name].secure:
980
+ self.log.debug("non_secure_function_allowed", function=function_name)
981
+ return True
982
+
983
+ # Check if we have a session manager
984
+ if not hasattr(self, '_session_manager'):
985
+ self.log.error("no_session_manager")
986
+ return False
987
+
988
+ # Handle missing token
989
+ if not token:
990
+ self.log.warning("missing_token", function=function_name)
991
+ return False
992
+
993
+ # For debugging: Log token details
994
+ try:
995
+ # Capture original parameters
996
+ self.log.debug("token_validate_input",
997
+ function=function_name,
998
+ call_id=call_id,
999
+ token_length=len(token))
1000
+
1001
+ # Try to decode token for debugging
1002
+ if hasattr(self._session_manager, 'debug_token'):
1003
+ debug_info = self._session_manager.debug_token(token)
1004
+ self.log.debug("token_debug", debug_info=debug_info)
1005
+
1006
+ # Extract token components
1007
+ if debug_info.get("valid_format") and "components" in debug_info:
1008
+ components = debug_info["components"]
1009
+ token_call_id = components.get("call_id")
1010
+ token_function = components.get("function")
1011
+ token_expiry = components.get("expiry")
1012
+
1013
+ # Log parameter mismatches
1014
+ if token_function != function_name:
1015
+ self.log.warning("token_function_mismatch",
1016
+ expected=function_name,
1017
+ actual=token_function)
1018
+
1019
+ if token_call_id != call_id:
1020
+ self.log.warning("token_call_id_mismatch",
1021
+ expected=call_id,
1022
+ actual=token_call_id)
1023
+
1024
+ # Check expiration
1025
+ if debug_info.get("status", {}).get("is_expired"):
1026
+ self.log.warning("token_expired",
1027
+ expires_in=debug_info["status"].get("expires_in_seconds"))
1028
+ except Exception as e:
1029
+ self.log.error("token_debug_error", error=str(e))
1030
+
1031
+ # Use call_id from token if the provided one is empty
1032
+ if not call_id and hasattr(self._session_manager, 'debug_token'):
1033
+ try:
1034
+ debug_info = self._session_manager.debug_token(token)
1035
+ if debug_info.get("valid_format") and "components" in debug_info:
1036
+ token_call_id = debug_info["components"].get("call_id")
1037
+ if token_call_id:
1038
+ self.log.debug("using_call_id_from_token", call_id=token_call_id)
1039
+ is_valid = self._session_manager.validate_tool_token(function_name, token, token_call_id)
1040
+ if is_valid:
1041
+ self.log.debug("token_valid_with_extracted_call_id")
1042
+ return True
1043
+ except Exception as e:
1044
+ self.log.error("error_using_call_id_from_token", error=str(e))
750
1045
 
751
- if not self._swaig_functions[function_name].secure:
752
- return True
1046
+ # Normal validation with provided call_id
1047
+ is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
753
1048
 
754
- return self._session_manager.validate_tool_token(function_name, token, call_id)
1049
+ if is_valid:
1050
+ self.log.debug("token_valid", function=function_name)
1051
+ else:
1052
+ self.log.warning("token_invalid", function=function_name)
1053
+
1054
+ return is_valid
1055
+ except Exception as e:
1056
+ self.log.error("token_validation_error", error=str(e), function=function_name)
1057
+ return False
755
1058
 
756
1059
  # ----------------------------------------------------------------------
757
1060
  # Web Server and Routing
@@ -836,6 +1139,16 @@ class AgentBase(SWMLService):
836
1139
  Returns:
837
1140
  Fully constructed webhook URL
838
1141
  """
1142
+ # Use the parent class's implementation if available and has the same method
1143
+ if hasattr(super(), '_build_webhook_url'):
1144
+ # Ensure _proxy_url_base is synchronized
1145
+ if getattr(self, '_proxy_url_base', None) and hasattr(super(), '_proxy_url_base'):
1146
+ super()._proxy_url_base = self._proxy_url_base
1147
+
1148
+ # Call parent's implementation
1149
+ return super()._build_webhook_url(endpoint, query_params)
1150
+
1151
+ # Otherwise, fall back to our own implementation
839
1152
  # Base URL construction
840
1153
  if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
841
1154
  # For proxy URLs
@@ -965,15 +1278,26 @@ class AgentBase(SWMLService):
965
1278
  if functions:
966
1279
  swaig_obj["functions"] = functions
967
1280
 
968
- # Add post-prompt URL if we have a post-prompt
1281
+ # Add post-prompt URL with token if we have a post-prompt
969
1282
  post_prompt_url = None
970
1283
  if post_prompt:
971
- post_prompt_url = self._build_webhook_url("post_prompt", {})
1284
+ # Create a token for post_prompt if we have a call_id
1285
+ query_params = {}
1286
+ if call_id and hasattr(self, '_session_manager'):
1287
+ try:
1288
+ token = self._session_manager.create_tool_token("post_prompt", call_id)
1289
+ if token:
1290
+ query_params["token"] = token
1291
+ except Exception as e:
1292
+ self.log.error("post_prompt_token_creation_error", error=str(e))
1293
+
1294
+ # Build the URL with the token (if any)
1295
+ post_prompt_url = self._build_webhook_url("post_prompt", query_params)
972
1296
 
973
1297
  # Use override if set
974
1298
  if hasattr(self, '_post_prompt_url_override') and self._post_prompt_url_override:
975
1299
  post_prompt_url = self._post_prompt_url_override
976
-
1300
+
977
1301
  # Add answer verb with auto-answer enabled
978
1302
  self.add_answer_verb()
979
1303
 
@@ -1053,36 +1377,23 @@ class AgentBase(SWMLService):
1053
1377
  # Add the AI verb to the document
1054
1378
  self.add_verb("ai", ai_config)
1055
1379
 
1056
- # Apply any modifications from the callback
1380
+ # Apply any modifications from the callback to agent state
1057
1381
  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
1382
+ # Handle global_data modifications by updating the AI config directly
1383
+ if "global_data" in modifications:
1384
+ if modifications["global_data"]:
1385
+ # Merge the modification global_data with existing global_data
1386
+ ai_config["global_data"] = {**ai_config.get("global_data", {}), **modifications["global_data"]}
1387
+
1388
+ # Handle other modifications by updating the AI config
1389
+ for key, value in modifications.items():
1390
+ if key != "global_data": # global_data handled above
1391
+ ai_config[key] = value
1392
+
1393
+ # Clear and rebuild the document with the modified AI config
1075
1394
  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)
1395
+ self.add_answer_verb()
1396
+ self.add_verb("ai", ai_config)
1086
1397
 
1087
1398
  # Return the rendered document as a string
1088
1399
  return self.render_document()
@@ -1116,1442 +1427,1368 @@ class AgentBase(SWMLService):
1116
1427
  Returns:
1117
1428
  FastAPI router
1118
1429
  """
1119
- # Get the base router from SWMLService
1120
- router = super().as_router()
1430
+ # Create a router with explicit redirect_slashes=False
1431
+ router = APIRouter(redirect_slashes=False)
1121
1432
 
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)
1433
+ # Register routes explicitly
1434
+ self._register_routes(router)
1435
+
1436
+ # Log all registered routes for debugging
1437
+ print(f"Registered routes for {self.name}:")
1438
+ for route in router.routes:
1439
+ print(f" {route.path}")
1440
+
1441
+ return router
1442
+
1443
+ def serve(self, host: Optional[str] = None, port: Optional[int] = None) -> None:
1444
+ """
1445
+ Start a web server for this agent
1446
+
1447
+ Args:
1448
+ host: Optional host to override the default
1449
+ port: Optional port to override the default
1450
+ """
1451
+ import uvicorn
1452
+
1453
+ if self._app is None:
1454
+ # Create a FastAPI app with explicit redirect_slashes=False
1455
+ app = FastAPI(redirect_slashes=False)
1456
+
1457
+ # Get router for this agent
1458
+ router = self.as_router()
1459
+
1460
+ # Register a catch-all route for debugging and troubleshooting
1461
+ @app.get("/{full_path:path}")
1462
+ @app.post("/{full_path:path}")
1463
+ async def handle_all_routes(request: Request, full_path: str):
1464
+ print(f"Received request for path: {full_path}")
1465
+
1466
+ # Check if the path is meant for this agent
1467
+ if not full_path.startswith(self.route.lstrip("/")):
1468
+ return {"error": "Invalid route"}
1469
+
1470
+ # Extract the path relative to this agent's route
1471
+ relative_path = full_path[len(self.route.lstrip("/")):]
1472
+ relative_path = relative_path.lstrip("/")
1473
+ print(f"Relative path: {relative_path}")
1474
+
1475
+ # Perform routing based on the relative path
1476
+ if not relative_path or relative_path == "/":
1477
+ # Root endpoint
1478
+ return await self._handle_root_request(request)
1479
+
1480
+ # Strip trailing slash for processing
1481
+ clean_path = relative_path.rstrip("/")
1482
+
1483
+ # Check for standard endpoints
1484
+ if clean_path == "debug":
1485
+ return await self._handle_debug_request(request)
1486
+ elif clean_path == "swaig":
1487
+ return await self._handle_swaig_request(request, Response())
1488
+ elif clean_path == "post_prompt":
1489
+ return await self._handle_post_prompt_request(request)
1490
+ elif clean_path == "check_for_input":
1491
+ return await self._handle_check_for_input_request(request)
1492
+
1493
+ # Check for custom routing callbacks
1494
+ if hasattr(self, '_routing_callbacks'):
1495
+ for callback_path, callback_fn in self._routing_callbacks.items():
1496
+ cb_path_clean = callback_path.strip("/")
1497
+ if clean_path == cb_path_clean:
1498
+ # Found a matching callback
1499
+ request.state.callback_path = callback_path
1500
+ return await self._handle_root_request(request)
1501
+
1502
+ # Default: 404
1503
+ return {"error": "Path not found"}
1504
+
1505
+ # Include router with prefix
1506
+ app.include_router(router, prefix=self.route)
1507
+
1508
+ # Print all app routes for debugging
1509
+ print(f"All app routes:")
1510
+ for route in app.routes:
1511
+ if hasattr(route, "path"):
1512
+ print(f" {route.path}")
1513
+
1514
+ self._app = app
1515
+
1516
+ host = host or self.host
1517
+ port = port or self.port
1518
+
1519
+ # Print the auth credentials with source
1520
+ username, password, source = self.get_basic_auth_credentials(include_source=True)
1521
+ print(f"Agent '{self.name}' is available at:")
1522
+ print(f"URL: http://{host}:{port}{self.route}")
1523
+ print(f"Basic Auth: {username}:{password} (source: {source})")
1524
+
1525
+ uvicorn.run(self._app, host=host, port=port)
1526
+
1527
+ def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
1528
+ """
1529
+ Customization point for subclasses to modify SWML based on request data
1530
+
1531
+ Args:
1532
+ request_data: Optional dictionary containing the parsed POST body
1533
+ callback_path: Optional callback path
1127
1534
 
1128
- # Root endpoint - with trailing slash
1535
+ Returns:
1536
+ Optional dict with modifications to apply to the SWML document
1537
+ """
1538
+ # Default implementation does nothing
1539
+ return None
1540
+
1541
+ def _register_routes(self, router):
1542
+ """
1543
+ Register routes for this agent
1544
+
1545
+ This method ensures proper route registration by handling the routes
1546
+ directly in AgentBase rather than inheriting from SWMLService.
1547
+
1548
+ Args:
1549
+ router: FastAPI router to register routes with
1550
+ """
1551
+ # Root endpoint (handles both with and without trailing slash)
1129
1552
  @router.get("/")
1130
1553
  @router.post("/")
1131
- async def handle_root_with_slash(request: Request):
1554
+ async def handle_root(request: Request, response: Response):
1555
+ """Handle GET/POST requests to the root endpoint"""
1132
1556
  return await self._handle_root_request(request)
1133
1557
 
1134
- # Debug endpoint - without trailing slash
1558
+ # Debug endpoint - Both versions
1135
1559
  @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
1560
  @router.get("/debug/")
1561
+ @router.post("/debug")
1142
1562
  @router.post("/debug/")
1143
- async def handle_debug_with_slash(request: Request):
1563
+ async def handle_debug(request: Request):
1564
+ """Handle GET/POST requests to the debug endpoint"""
1144
1565
  return await self._handle_debug_request(request)
1145
1566
 
1146
- # SWAIG endpoint - without trailing slash
1567
+ # SWAIG endpoint - Both versions
1147
1568
  @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
1569
  @router.get("/swaig/")
1570
+ @router.post("/swaig")
1154
1571
  @router.post("/swaig/")
1155
- async def handle_swaig_with_slash(request: Request):
1156
- return await self._handle_swaig_request(request)
1572
+ async def handle_swaig(request: Request, response: Response):
1573
+ """Handle GET/POST requests to the SWAIG endpoint"""
1574
+ return await self._handle_swaig_request(request, response)
1157
1575
 
1158
- # Post-prompt endpoint - without trailing slash
1576
+ # Post prompt endpoint - Both versions
1159
1577
  @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
1578
  @router.get("/post_prompt/")
1579
+ @router.post("/post_prompt")
1166
1580
  @router.post("/post_prompt/")
1167
- async def handle_post_prompt_with_slash(request: Request):
1581
+ async def handle_post_prompt(request: Request):
1582
+ """Handle GET/POST requests to the post_prompt endpoint"""
1168
1583
  return await self._handle_post_prompt_request(request)
1584
+
1585
+ # Check for input endpoint - Both versions
1586
+ @router.get("/check_for_input")
1587
+ @router.get("/check_for_input/")
1588
+ @router.post("/check_for_input")
1589
+ @router.post("/check_for_input/")
1590
+ async def handle_check_for_input(request: Request):
1591
+ """Handle GET/POST requests to the check_for_input endpoint"""
1592
+ return await self._handle_check_for_input_request(request)
1169
1593
 
1170
- self._router = router
1171
- return router
1594
+ # Register callback routes for routing callbacks if available
1595
+ if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
1596
+ for callback_path, callback_fn in self._routing_callbacks.items():
1597
+ # Skip the root path as it's already handled
1598
+ if callback_path == "/":
1599
+ continue
1600
+
1601
+ # Register both with and without trailing slash
1602
+ path = callback_path.rstrip("/")
1603
+ path_with_slash = f"{path}/"
1604
+
1605
+ @router.get(path)
1606
+ @router.get(path_with_slash)
1607
+ @router.post(path)
1608
+ @router.post(path_with_slash)
1609
+ async def handle_callback(request: Request, response: Response, cb_path=callback_path):
1610
+ """Handle GET/POST requests to a registered callback path"""
1611
+ # Store the callback path in request state for _handle_request to use
1612
+ request.state.callback_path = cb_path
1613
+ return await self._handle_root_request(request)
1614
+
1615
+ self.log.info("callback_endpoint_registered", path=callback_path)
1172
1616
 
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
1617
+ @classmethod
1618
+
1619
+ # ----------------------------------------------------------------------
1620
+ # AI Verb Configuration Methods
1621
+ # ----------------------------------------------------------------------
1622
+
1623
+ def add_hint(self, hint: str) -> 'AgentBase':
1624
+ """
1625
+ Add a simple string hint to help the AI agent understand certain words better
1190
1626
 
1191
- # Check if this is a callback path request
1192
- callback_path = getattr(request.state, "callback_path", None)
1627
+ Args:
1628
+ hint: The hint string to add
1629
+
1630
+ Returns:
1631
+ Self for method chaining
1632
+ """
1633
+ if isinstance(hint, str) and hint:
1634
+ self._hints.append(hint)
1635
+ return self
1636
+
1637
+ def add_hints(self, hints: List[str]) -> 'AgentBase':
1638
+ """
1639
+ Add multiple string hints
1193
1640
 
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
- )
1199
-
1200
- req_log.debug("endpoint_called")
1201
-
1202
- 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
1214
- body = {}
1215
- call_id = None
1216
-
1217
- 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
- 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())
1273
-
1274
- # Render SWML
1275
- swml = self._render_swml(call_id, modifications)
1276
- req_log.debug("swml_rendered", swml_size=len(swml))
1277
-
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
- # Check auth
1304
- if not self._check_basic_auth(request):
1305
- req_log.warning("unauthorized_access_attempt")
1306
- return Response(
1307
- content=json.dumps({"error": "Unauthorized"}),
1308
- status_code=401,
1309
- headers={"WWW-Authenticate": "Basic"},
1310
- media_type="application/json"
1311
- )
1312
-
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))
1349
-
1350
- # Return as JSON
1351
- req_log.info("request_successful")
1352
- return Response(
1353
- content=swml,
1354
- media_type="application/json",
1355
- headers={"X-Debug": "true"}
1356
- )
1357
- except Exception as e:
1358
- req_log.error("request_failed", error=str(e), traceback=traceback.format_exc())
1359
- return Response(
1360
- content=json.dumps({"error": str(e), "traceback": traceback.format_exc()}),
1361
- status_code=500,
1362
- media_type="application/json"
1363
- )
1364
-
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())
1641
+ Args:
1642
+ hints: List of hint strings
1543
1643
 
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
- )
1644
+ Returns:
1645
+ Self for method chaining
1646
+ """
1647
+ if hints and isinstance(hints, list):
1648
+ for hint in hints:
1649
+ if isinstance(hint, str) and hint:
1650
+ self._hints.append(hint)
1651
+ return self
1554
1652
 
1555
- def _find_summary_in_post_data(self, body, logger):
1653
+ def add_pattern_hint(self,
1654
+ hint: str,
1655
+ pattern: str,
1656
+ replace: str,
1657
+ ignore_case: bool = False) -> 'AgentBase':
1556
1658
  """
1557
- Extensive search for the summary in the post data
1659
+ Add a complex hint with pattern matching
1558
1660
 
1559
1661
  Args:
1560
- body: The POST request body
1561
- logger: The logger instance to use
1662
+ hint: The hint to match
1663
+ pattern: Regular expression pattern
1664
+ replace: Text to replace the hint with
1665
+ ignore_case: Whether to ignore case when matching
1562
1666
 
1563
1667
  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
1668
+ Self for method chaining
1669
+ """
1670
+ if hint and pattern and replace:
1671
+ self._hints.append({
1672
+ "hint": hint,
1673
+ "pattern": pattern,
1674
+ "replace": replace,
1675
+ "ignore_case": ignore_case
1676
+ })
1677
+ return self
1677
1678
 
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)
1679
+ def add_language(self,
1680
+ name: str,
1681
+ code: str,
1682
+ voice: str,
1683
+ speech_fillers: Optional[List[str]] = None,
1684
+ function_fillers: Optional[List[str]] = None,
1685
+ engine: Optional[str] = None,
1686
+ model: Optional[str] = None) -> 'AgentBase':
1687
+ """
1688
+ Add a language configuration to support multilingual conversations
1682
1689
 
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)
1690
+ Args:
1691
+ name: Name of the language (e.g., "English", "French")
1692
+ code: Language code (e.g., "en-US", "fr-FR")
1693
+ voice: TTS voice to use. Can be a simple name (e.g., "en-US-Neural2-F")
1694
+ or a combined format "engine.voice:model" (e.g., "elevenlabs.josh:eleven_turbo_v2_5")
1695
+ speech_fillers: Optional list of filler phrases for natural speech
1696
+ function_fillers: Optional list of filler phrases during function calls
1697
+ engine: Optional explicit engine name (e.g., "elevenlabs", "rime")
1698
+ model: Optional explicit model name (e.g., "eleven_turbo_v2_5", "arcana")
1694
1699
 
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
+ Returns:
1701
+ Self for method chaining
1700
1702
 
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)
1703
+ Examples:
1704
+ # Simple voice name
1705
+ agent.add_language("English", "en-US", "en-US-Neural2-F")
1706
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)
1707
+ # Explicit parameters
1708
+ agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
1724
1709
 
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
1710
+ # Combined format
1711
+ agent.add_language("English", "en-US", "elevenlabs.josh:eleven_turbo_v2_5")
1767
1712
  """
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
1713
+ language = {
1714
+ "name": name,
1715
+ "code": code
1716
+ }
1717
+
1718
+ # Handle voice formatting (either explicit params or combined string)
1719
+ if engine or model:
1720
+ # Use explicit parameters if provided
1721
+ language["voice"] = voice
1722
+ if engine:
1723
+ language["engine"] = engine
1724
+ if model:
1725
+ language["model"] = model
1726
+ elif "." in voice and ":" in voice:
1727
+ # Parse combined string format: "engine.voice:model"
1728
+ try:
1729
+ engine_voice, model_part = voice.split(":", 1)
1730
+ engine_part, voice_part = engine_voice.split(".", 1)
1787
1731
 
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
1732
+ language["voice"] = voice_part
1733
+ language["engine"] = engine_part
1734
+ language["model"] = model_part
1735
+ except ValueError:
1736
+ # If parsing fails, use the voice string as-is
1737
+ language["voice"] = voice
1738
+ else:
1739
+ # Simple voice string
1740
+ language["voice"] = voice
1802
1741
 
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
1742
+ # Add fillers if provided
1743
+ if speech_fillers and function_fillers:
1744
+ language["speech_fillers"] = speech_fillers
1745
+ language["function_fillers"] = function_fillers
1746
+ elif speech_fillers or function_fillers:
1747
+ # If only one type of fillers is provided, use the deprecated "fillers" field
1748
+ fillers = speech_fillers or function_fillers
1749
+ language["fillers"] = fillers
1816
1750
 
1817
- def set_state(self, call_id: str, data: Dict[str, Any]) -> bool:
1751
+ self._languages.append(language)
1752
+ return self
1753
+
1754
+ def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
1818
1755
  """
1819
- Set the state for a call
1756
+ Set all language configurations at once
1820
1757
 
1821
1758
  Args:
1822
- call_id: Call ID to set state for
1823
- data: State data to set
1759
+ languages: List of language configuration dictionaries
1824
1760
 
1825
1761
  Returns:
1826
- True if state was set, False otherwise
1762
+ Self for method chaining
1827
1763
  """
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:
1764
+ if languages and isinstance(languages, list):
1765
+ self._languages = languages
1766
+ return self
1767
+
1768
+ def add_pronunciation(self,
1769
+ replace: str,
1770
+ with_text: str,
1771
+ ignore_case: bool = False) -> 'AgentBase':
1837
1772
  """
1838
- Update the state for a call
1773
+ Add a pronunciation rule to help the AI speak certain words correctly
1839
1774
 
1840
1775
  Args:
1841
- call_id: Call ID to update state for
1842
- data: State data to update
1776
+ replace: The expression to replace
1777
+ with_text: The phonetic spelling to use instead
1778
+ ignore_case: Whether to ignore case when matching
1843
1779
 
1844
1780
  Returns:
1845
- True if state was updated, False otherwise
1781
+ Self for method chaining
1846
1782
  """
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:
1783
+ if replace and with_text:
1784
+ rule = {
1785
+ "replace": replace,
1786
+ "with": with_text
1787
+ }
1788
+ if ignore_case:
1789
+ rule["ignore_case"] = True
1790
+
1791
+ self._pronounce.append(rule)
1792
+ return self
1793
+
1794
+ def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
1856
1795
  """
1857
- Clear the state for a call
1796
+ Set all pronunciation rules at once
1858
1797
 
1859
1798
  Args:
1860
- call_id: Call ID to clear state for
1799
+ pronunciations: List of pronunciation rule dictionaries
1861
1800
 
1862
1801
  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
1802
+ Self for method chaining
1879
1803
  """
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
1804
+ if pronunciations and isinstance(pronunciations, list):
1805
+ self._pronounce = pronunciations
1806
+ return self
1887
1807
 
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):
1808
+ def set_param(self, key: str, value: Any) -> 'AgentBase':
1911
1809
  """
1912
- Handler for the startup hook
1810
+ Set a single AI parameter
1913
1811
 
1914
1812
  Args:
1915
- args: Function arguments
1916
- raw_data: Raw request data
1813
+ key: Parameter name
1814
+ value: Parameter value
1917
1815
 
1918
1816
  Returns:
1919
- Function result
1817
+ Self for method chaining
1920
1818
  """
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):
1819
+ if key:
1820
+ self._params[key] = value
1821
+ return self
1822
+
1823
+ def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
1938
1824
  """
1939
- Handler for the hangup hook
1825
+ Set multiple AI parameters at once
1940
1826
 
1941
1827
  Args:
1942
- args: Function arguments
1943
- raw_data: Raw request data
1828
+ params: Dictionary of parameter name/value pairs
1944
1829
 
1945
1830
  Returns:
1946
- Function result
1831
+ Self for method chaining
1947
1832
  """
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")
1833
+ if params and isinstance(params, dict):
1834
+ self._params.update(params)
1835
+ return self
1962
1836
 
1963
- def set_post_prompt(self, text: str) -> 'AgentBase':
1837
+ def set_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
1964
1838
  """
1965
- Set the post-prompt for the agent
1839
+ Set the global data available to the AI throughout the conversation
1966
1840
 
1967
1841
  Args:
1968
- text: Post-prompt text
1842
+ data: Dictionary of global data
1969
1843
 
1970
1844
  Returns:
1971
1845
  Self for method chaining
1972
1846
  """
1973
- self._post_prompt = text
1847
+ if data and isinstance(data, dict):
1848
+ self._global_data = data
1974
1849
  return self
1975
-
1976
- def set_auto_answer(self, enabled: bool) -> 'AgentBase':
1850
+
1851
+ def update_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
1977
1852
  """
1978
- Set whether to automatically answer calls
1853
+ Update the global data with new values
1979
1854
 
1980
1855
  Args:
1981
- enabled: Whether to auto-answer
1856
+ data: Dictionary of global data to update
1982
1857
 
1983
1858
  Returns:
1984
1859
  Self for method chaining
1985
1860
  """
1986
- self._auto_answer = enabled
1861
+ if data and isinstance(data, dict):
1862
+ self._global_data.update(data)
1987
1863
  return self
1988
-
1989
- def set_call_recording(self,
1990
- enabled: bool,
1991
- format: str = "mp4",
1992
- stereo: bool = True) -> 'AgentBase':
1864
+
1865
+ def set_native_functions(self, function_names: List[str]) -> 'AgentBase':
1993
1866
  """
1994
- Set call recording parameters
1867
+ Set the list of native functions to enable
1995
1868
 
1996
1869
  Args:
1997
- enabled: Whether to record calls
1998
- format: Recording format
1999
- stereo: Whether to record in stereo
1870
+ function_names: List of native function names
2000
1871
 
2001
1872
  Returns:
2002
1873
  Self for method chaining
2003
1874
  """
2004
- self._record_call = enabled
2005
- self._record_format = format
2006
- self._record_stereo = stereo
1875
+ if function_names and isinstance(function_names, list):
1876
+ self.native_functions = [name for name in function_names if isinstance(name, str)]
2007
1877
  return self
2008
-
2009
- def add_native_function(self, function_name: str) -> 'AgentBase':
1878
+
1879
+ def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
2010
1880
  """
2011
- Add a native function to the list of enabled native functions
1881
+ Add a remote function include to the SWAIG configuration
2012
1882
 
2013
1883
  Args:
2014
- function_name: Name of native function to enable
1884
+ url: URL to fetch remote functions from
1885
+ functions: List of function names to include
1886
+ meta_data: Optional metadata to include with the function include
2015
1887
 
2016
1888
  Returns:
2017
1889
  Self for method chaining
2018
1890
  """
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)
1891
+ if url and functions and isinstance(functions, list):
1892
+ include = {
1893
+ "url": url,
1894
+ "functions": functions
1895
+ }
1896
+ if meta_data and isinstance(meta_data, dict):
1897
+ include["meta_data"] = meta_data
1898
+
1899
+ self._function_includes.append(include)
2024
1900
  return self
2025
1901
 
2026
- def remove_native_function(self, function_name: str) -> 'AgentBase':
1902
+ def set_function_includes(self, includes: List[Dict[str, Any]]) -> 'AgentBase':
2027
1903
  """
2028
- Remove a native function from the SWAIG object
1904
+ Set the complete list of function includes
2029
1905
 
2030
1906
  Args:
2031
- function_name: Name of the native function
1907
+ includes: List of include objects, each with url and functions properties
2032
1908
 
2033
1909
  Returns:
2034
1910
  Self for method chaining
2035
1911
  """
2036
- if function_name in self.native_functions:
2037
- self.native_functions.remove(function_name)
1912
+ if includes and isinstance(includes, list):
1913
+ # Validate each include has required properties
1914
+ valid_includes = []
1915
+ for include in includes:
1916
+ if isinstance(include, dict) and "url" in include and "functions" in include:
1917
+ if isinstance(include["functions"], list):
1918
+ valid_includes.append(include)
1919
+
1920
+ self._function_includes = valid_includes
2038
1921
  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
1922
 
2049
- def has_section(self, title: str) -> bool:
1923
+ def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
2050
1924
  """
2051
- Check if a section exists in the prompt
1925
+ Enable SIP-based routing for this agent
1926
+
1927
+ This allows the agent to automatically route SIP requests based on SIP usernames.
1928
+ When enabled, an endpoint at the specified path is automatically created
1929
+ that will handle SIP requests and deliver them to this agent.
2052
1930
 
2053
1931
  Args:
2054
- title: Section title
2055
-
1932
+ auto_map: Whether to automatically map common SIP usernames to this agent
1933
+ (based on the agent name and route path)
1934
+ path: The path to register the SIP routing endpoint (default: "/sip")
1935
+
2056
1936
  Returns:
2057
- True if the section exists, False otherwise
1937
+ Self for method chaining
2058
1938
  """
2059
- if not self._use_pom or not self.pom:
2060
- return False
1939
+ # Create a routing callback that handles SIP usernames
1940
+ def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
1941
+ # Extract SIP username from the request body
1942
+ sip_username = self.extract_sip_username(body)
1943
+
1944
+ if sip_username:
1945
+ self.log.info("sip_username_extracted", username=sip_username)
1946
+
1947
+ # Check if this username is registered with this agent
1948
+ if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
1949
+ self.log.info("sip_username_matched", username=sip_username)
1950
+ # This route is already being handled by the agent, no need to redirect
1951
+ return None
1952
+ else:
1953
+ self.log.info("sip_username_not_matched", username=sip_username)
1954
+ # Not registered with this agent, let routing continue
1955
+
1956
+ return None
1957
+
1958
+ # Register the callback with the SWMLService, specifying the path
1959
+ self.register_routing_callback(sip_routing_callback, path=path)
1960
+
1961
+ # Auto-map common usernames if requested
1962
+ if auto_map:
1963
+ self.auto_map_sip_usernames()
2061
1964
 
2062
- return self.pom.has_section(title)
1965
+ return self
2063
1966
 
2064
- def on_swml_request(self, request_data: Optional[dict] = None) -> Optional[dict]:
1967
+ def register_sip_username(self, sip_username: str) -> 'AgentBase':
2065
1968
  """
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.
1969
+ Register a SIP username that should be routed to this agent
2069
1970
 
2070
1971
  Args:
2071
- request_data: Optional dictionary containing the parsed POST body
1972
+ sip_username: SIP username to register
2072
1973
 
2073
1974
  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
1975
+ Self for method chaining
2086
1976
  """
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))}")
1977
+ if not hasattr(self, '_sip_usernames'):
1978
+ self._sip_usernames = set()
1979
+
1980
+ self._sip_usernames.add(sip_username.lower())
1981
+ self.log.info("sip_username_registered", username=sip_username)
2113
1982
 
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}")
1983
+ return self
2121
1984
 
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"
1985
+ def auto_map_sip_usernames(self) -> 'AgentBase':
1986
+ """
1987
+ Automatically register common SIP usernames based on this agent's
1988
+ name and route
2126
1989
 
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
- # ----------------------------------------------------------------------
1990
+ Returns:
1991
+ Self for method chaining
1992
+ """
1993
+ # Register username based on agent name
1994
+ clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
1995
+ if clean_name:
1996
+ self.register_sip_username(clean_name)
1997
+
1998
+ # Register username based on route (without slashes)
1999
+ clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
2000
+ if clean_route and clean_route != clean_name:
2001
+ self.register_sip_username(clean_route)
2002
+
2003
+ # Register common variations if they make sense
2004
+ if len(clean_name) > 3:
2005
+ # Register without vowels
2006
+ no_vowels = re.sub(r'[aeiou]', '', clean_name)
2007
+ if no_vowels != clean_name and len(no_vowels) > 2:
2008
+ self.register_sip_username(no_vowels)
2009
+
2010
+ return self
2143
2011
 
2144
- def add_hint(self, hint: str) -> 'AgentBase':
2012
+ def set_web_hook_url(self, url: str) -> 'AgentBase':
2145
2013
  """
2146
- Add a simple string hint to help the AI agent understand certain words better
2014
+ Override the default web_hook_url with a supplied URL string
2147
2015
 
2148
2016
  Args:
2149
- hint: The hint string to add
2017
+ url: The URL to use for SWAIG function webhooks
2150
2018
 
2151
2019
  Returns:
2152
2020
  Self for method chaining
2153
2021
  """
2154
- if isinstance(hint, str) and hint:
2155
- self._hints.append(hint)
2022
+ self._web_hook_url_override = url
2156
2023
  return self
2157
-
2158
- def add_hints(self, hints: List[str]) -> 'AgentBase':
2024
+
2025
+ def set_post_prompt_url(self, url: str) -> 'AgentBase':
2159
2026
  """
2160
- Add multiple string hints
2027
+ Override the default post_prompt_url with a supplied URL string
2161
2028
 
2162
2029
  Args:
2163
- hints: List of hint strings
2030
+ url: The URL to use for post-prompt summary delivery
2164
2031
 
2165
2032
  Returns:
2166
2033
  Self for method chaining
2167
2034
  """
2168
- if hints and isinstance(hints, list):
2169
- for hint in hints:
2170
- if isinstance(hint, str) and hint:
2171
- self._hints.append(hint)
2035
+ self._post_prompt_url_override = url
2172
2036
  return self
2173
2037
 
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
2038
+ async def _handle_swaig_request(self, request: Request, response: Response):
2039
+ """Handle GET/POST requests to the SWAIG endpoint"""
2040
+ req_log = self.log.bind(
2041
+ endpoint="swaig",
2042
+ method=request.method,
2043
+ path=request.url.path
2044
+ )
2045
+
2046
+ req_log.debug("endpoint_called")
2047
+
2048
+ try:
2049
+ # Check auth
2050
+ if not self._check_basic_auth(request):
2051
+ req_log.warning("unauthorized_access_attempt")
2052
+ response.headers["WWW-Authenticate"] = "Basic"
2053
+ return Response(
2054
+ content=json.dumps({"error": "Unauthorized"}),
2055
+ status_code=401,
2056
+ headers={"WWW-Authenticate": "Basic"},
2057
+ media_type="application/json"
2058
+ )
2059
+
2060
+ # Handle differently based on method
2061
+ if request.method == "GET":
2062
+ # For GET requests, return the SWML document (same as root endpoint)
2063
+ call_id = request.query_params.get("call_id")
2064
+ swml = self._render_swml(call_id)
2065
+ req_log.debug("swml_rendered", swml_size=len(swml))
2066
+ return Response(
2067
+ content=swml,
2068
+ media_type="application/json"
2069
+ )
2070
+
2071
+ # For POST requests, process SWAIG function calls
2072
+ try:
2073
+ body = await request.json()
2074
+ req_log.debug("request_body_received", body_size=len(str(body)))
2075
+ if body:
2076
+ req_log.debug("request_body", body=json.dumps(body))
2077
+ except Exception as e:
2078
+ req_log.error("error_parsing_request_body", error=str(e))
2079
+ body = {}
2080
+
2081
+ # Extract function name
2082
+ function_name = body.get("function")
2083
+ if not function_name:
2084
+ req_log.warning("missing_function_name")
2085
+ return Response(
2086
+ content=json.dumps({"error": "Missing function name"}),
2087
+ status_code=400,
2088
+ media_type="application/json"
2089
+ )
2090
+
2091
+ # Add function info to logger
2092
+ req_log = req_log.bind(function=function_name)
2093
+ req_log.debug("function_call_received")
2094
+
2095
+ # Extract arguments
2096
+ args = {}
2097
+ if "argument" in body and isinstance(body["argument"], dict):
2098
+ if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
2099
+ args = body["argument"]["parsed"][0]
2100
+ req_log.debug("parsed_arguments", args=json.dumps(args))
2101
+ elif "raw" in body["argument"]:
2102
+ try:
2103
+ args = json.loads(body["argument"]["raw"])
2104
+ req_log.debug("raw_arguments_parsed", args=json.dumps(args))
2105
+ except Exception as e:
2106
+ req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
2107
+
2108
+ # Get call_id from body
2109
+ call_id = body.get("call_id")
2110
+ if call_id:
2111
+ req_log = req_log.bind(call_id=call_id)
2112
+ req_log.debug("call_id_identified")
2113
+
2114
+ # SECURITY BYPASS FOR DEBUGGING - make all functions work regardless of token
2115
+ # We'll log the attempt but allow it through
2116
+ token = request.query_params.get("token")
2117
+ if token:
2118
+ req_log.debug("token_found", token_length=len(token))
2119
+
2120
+ # Check token validity but don't reject the request
2121
+ if hasattr(self, '_session_manager') and function_name in self._swaig_functions:
2122
+ is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
2123
+ if is_valid:
2124
+ req_log.debug("token_valid")
2125
+ else:
2126
+ # Log but continue anyway for debugging
2127
+ req_log.warning("token_invalid")
2128
+ if hasattr(self._session_manager, 'debug_token'):
2129
+ debug_info = self._session_manager.debug_token(token)
2130
+ req_log.debug("token_debug", debug=json.dumps(debug_info))
2131
+
2132
+ # Call the function
2133
+ try:
2134
+ result = self.on_function_call(function_name, args, body)
2135
+
2136
+ # Convert result to dict if needed
2137
+ if isinstance(result, SwaigFunctionResult):
2138
+ result_dict = result.to_dict()
2139
+ elif isinstance(result, dict):
2140
+ result_dict = result
2141
+ else:
2142
+ result_dict = {"response": str(result)}
2143
+
2144
+ req_log.info("function_executed_successfully")
2145
+ req_log.debug("function_result", result=json.dumps(result_dict))
2146
+ return result_dict
2147
+ except Exception as e:
2148
+ req_log.error("function_execution_error", error=str(e))
2149
+ return {"error": str(e), "function": function_name}
2150
+
2151
+ except Exception as e:
2152
+ req_log.error("request_failed", error=str(e))
2153
+ return Response(
2154
+ content=json.dumps({"error": str(e)}),
2155
+ status_code=500,
2156
+ media_type="application/json"
2157
+ )
2158
+
2159
+ async def _handle_root_request(self, request: Request):
2160
+ """Handle GET/POST requests to the root endpoint"""
2161
+ # Auto-detect proxy on first request if not explicitly configured
2162
+ if not getattr(self, '_proxy_detection_done', False) and not getattr(self, '_proxy_url_base', None):
2163
+ # Check for proxy headers
2164
+ forwarded_host = request.headers.get("X-Forwarded-Host")
2165
+ forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
2166
+
2167
+ if forwarded_host:
2168
+ # Set proxy_url_base on both self and super() to ensure it's shared
2169
+ self._proxy_url_base = f"{forwarded_proto}://{forwarded_host}"
2170
+ if hasattr(super(), '_proxy_url_base'):
2171
+ # Ensure parent class has the same proxy URL
2172
+ super()._proxy_url_base = self._proxy_url_base
2173
+
2174
+ self.log.info("proxy_auto_detected", proxy_url_base=self._proxy_url_base,
2175
+ source="X-Forwarded headers")
2176
+ self._proxy_detection_done = True
2177
+
2178
+ # Also set the detection flag on parent
2179
+ if hasattr(super(), '_proxy_detection_done'):
2180
+ super()._proxy_detection_done = True
2181
+ # If no explicit proxy headers, try the parent class detection method if it exists
2182
+ elif hasattr(super(), '_detect_proxy_from_request'):
2183
+ # Call the parent's detection method
2184
+ super()._detect_proxy_from_request(request)
2185
+ # Copy the result to our class
2186
+ if hasattr(super(), '_proxy_url_base') and getattr(super(), '_proxy_url_base', None):
2187
+ self._proxy_url_base = super()._proxy_url_base
2188
+ self._proxy_detection_done = True
2189
+
2190
+ # Check if this is a callback path request
2191
+ callback_path = getattr(request.state, "callback_path", None)
2192
+
2193
+ req_log = self.log.bind(
2194
+ endpoint="root" if not callback_path else f"callback:{callback_path}",
2195
+ method=request.method,
2196
+ path=request.url.path
2197
+ )
2198
+
2199
+ req_log.debug("endpoint_called")
2200
+
2201
+ try:
2202
+ # Check auth
2203
+ if not self._check_basic_auth(request):
2204
+ req_log.warning("unauthorized_access_attempt")
2205
+ return Response(
2206
+ content=json.dumps({"error": "Unauthorized"}),
2207
+ status_code=401,
2208
+ headers={"WWW-Authenticate": "Basic"},
2209
+ media_type="application/json"
2210
+ )
2211
+
2212
+ # Try to parse request body for POST
2213
+ body = {}
2214
+ call_id = None
2215
+
2216
+ if request.method == "POST":
2217
+ # Check if body is empty first
2218
+ raw_body = await request.body()
2219
+ if raw_body:
2220
+ try:
2221
+ body = await request.json()
2222
+ req_log.debug("request_body_received", body_size=len(str(body)))
2223
+ if body:
2224
+ req_log.debug("request_body")
2225
+ except Exception as e:
2226
+ req_log.warning("error_parsing_request_body", error=str(e))
2227
+ # Continue processing with empty body
2228
+ body = {}
2229
+ else:
2230
+ req_log.debug("empty_request_body")
2231
+
2232
+ # Get call_id from body if present
2233
+ call_id = body.get("call_id")
2234
+ else:
2235
+ # Get call_id from query params for GET
2236
+ call_id = request.query_params.get("call_id")
2237
+
2238
+ # Add call_id to logger if any
2239
+ if call_id:
2240
+ req_log = req_log.bind(call_id=call_id)
2241
+ req_log.debug("call_id_identified")
2242
+
2243
+ # Check if this is a callback path and we need to apply routing
2244
+ if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
2245
+ callback_fn = self._routing_callbacks[callback_path]
2246
+
2247
+ if request.method == "POST" and body:
2248
+ req_log.debug("processing_routing_callback", path=callback_path)
2249
+ # Call the routing callback
2250
+ try:
2251
+ route = callback_fn(request, body)
2252
+ if route is not None:
2253
+ req_log.info("routing_request", route=route)
2254
+ # Return a redirect to the new route
2255
+ return Response(
2256
+ status_code=307, # 307 Temporary Redirect preserves the method and body
2257
+ headers={"Location": route}
2258
+ )
2259
+ except Exception as e:
2260
+ req_log.error("error_in_routing_callback", error=str(e))
2261
+
2262
+ # Allow subclasses to inspect/modify the request
2263
+ modifications = None
2264
+ try:
2265
+ modifications = self.on_swml_request(body, callback_path, request)
2266
+ if modifications:
2267
+ req_log.debug("request_modifications_applied")
2268
+ except Exception as e:
2269
+ req_log.error("error_in_request_modifier", error=str(e))
2270
+
2271
+ # Render SWML
2272
+ swml = self._render_swml(call_id, modifications)
2273
+ req_log.debug("swml_rendered", swml_size=len(swml))
2274
+
2275
+ # Return as JSON
2276
+ req_log.info("request_successful")
2277
+ return Response(
2278
+ content=swml,
2279
+ media_type="application/json"
2280
+ )
2281
+ except Exception as e:
2282
+ req_log.error("request_failed", error=str(e))
2283
+ return Response(
2284
+ content=json.dumps({"error": str(e)}),
2285
+ status_code=500,
2286
+ media_type="application/json"
2287
+ )
2288
+
2289
+ async def _handle_debug_request(self, request: Request):
2290
+ """Handle GET/POST requests to the debug endpoint"""
2291
+ req_log = self.log.bind(
2292
+ endpoint="debug",
2293
+ method=request.method,
2294
+ path=request.url.path
2295
+ )
2181
2296
 
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
2297
+ req_log.debug("endpoint_called")
2210
2298
 
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")
2299
+ try:
2300
+ # Check auth
2301
+ if not self._check_basic_auth(request):
2302
+ req_log.warning("unauthorized_access_attempt")
2303
+ return Response(
2304
+ content=json.dumps({"error": "Unauthorized"}),
2305
+ status_code=401,
2306
+ headers={"WWW-Authenticate": "Basic"},
2307
+ media_type="application/json"
2308
+ )
2220
2309
 
2221
- Returns:
2222
- Self for method chaining
2310
+ # Get call_id from either query params (GET) or body (POST)
2311
+ call_id = None
2312
+ body = {}
2223
2313
 
2224
- Examples:
2225
- # Simple voice name
2226
- agent.add_language("English", "en-US", "en-US-Neural2-F")
2314
+ if request.method == "POST":
2315
+ try:
2316
+ body = await request.json()
2317
+ req_log.debug("request_body_received", body_size=len(str(body)))
2318
+ call_id = body.get("call_id")
2319
+ except Exception as e:
2320
+ req_log.warning("error_parsing_request_body", error=str(e))
2321
+ else:
2322
+ call_id = request.query_params.get("call_id")
2227
2323
 
2228
- # Explicit parameters
2229
- agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
2324
+ # Add call_id to logger if any
2325
+ if call_id:
2326
+ req_log = req_log.bind(call_id=call_id)
2327
+ req_log.debug("call_id_identified")
2328
+
2329
+ # Allow subclasses to inspect/modify the request
2330
+ modifications = None
2331
+ try:
2332
+ modifications = self.on_swml_request(body, None, request)
2333
+ if modifications:
2334
+ req_log.debug("request_modifications_applied")
2335
+ except Exception as e:
2336
+ req_log.error("error_in_request_modifier", error=str(e))
2337
+
2338
+ # Render SWML
2339
+ swml = self._render_swml(call_id, modifications)
2340
+ req_log.debug("swml_rendered", swml_size=len(swml))
2230
2341
 
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
- }
2342
+ # Return as JSON
2343
+ req_log.info("request_successful")
2344
+ return Response(
2345
+ content=swml,
2346
+ media_type="application/json",
2347
+ headers={"X-Debug": "true"}
2348
+ )
2349
+ except Exception as e:
2350
+ req_log.error("request_failed", error=str(e))
2351
+ return Response(
2352
+ content=json.dumps({"error": str(e)}),
2353
+ status_code=500,
2354
+ media_type="application/json"
2355
+ )
2356
+
2357
+ async def _handle_post_prompt_request(self, request: Request):
2358
+ """Handle GET/POST requests to the post_prompt endpoint"""
2359
+ req_log = self.log.bind(
2360
+ endpoint="post_prompt",
2361
+ method=request.method,
2362
+ path=request.url.path
2363
+ )
2238
2364
 
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"
2365
+ # Only log if not suppressed
2366
+ if not getattr(self, '_suppress_logs', False):
2367
+ req_log.debug("endpoint_called")
2368
+
2369
+ try:
2370
+ # Check auth
2371
+ if not self._check_basic_auth(request):
2372
+ req_log.warning("unauthorized_access_attempt")
2373
+ return Response(
2374
+ content=json.dumps({"error": "Unauthorized"}),
2375
+ status_code=401,
2376
+ headers={"WWW-Authenticate": "Basic"},
2377
+ media_type="application/json"
2378
+ )
2379
+
2380
+ # Extract call_id for use with token validation
2381
+ call_id = request.query_params.get("call_id")
2382
+
2383
+ # For POST requests, try to also get call_id from body
2384
+ if request.method == "POST":
2385
+ try:
2386
+ body_text = await request.body()
2387
+ if body_text:
2388
+ body_data = json.loads(body_text)
2389
+ if call_id is None:
2390
+ call_id = body_data.get("call_id")
2391
+ # Save body_data for later use
2392
+ setattr(request, "_post_prompt_body", body_data)
2393
+ except Exception as e:
2394
+ req_log.error("error_extracting_call_id", error=str(e))
2395
+
2396
+ # If we have a call_id, add it to the logger context
2397
+ if call_id:
2398
+ req_log = req_log.bind(call_id=call_id)
2399
+
2400
+ # Check token if provided
2401
+ token = request.query_params.get("token")
2402
+ token_validated = False
2403
+
2404
+ if token:
2405
+ req_log.debug("token_found", token_length=len(token))
2406
+
2407
+ # Try to validate token, but continue processing regardless
2408
+ # for backward compatibility with existing implementations
2409
+ if call_id and hasattr(self, '_session_manager'):
2410
+ try:
2411
+ is_valid = self._session_manager.validate_tool_token("post_prompt", token, call_id)
2412
+ if is_valid:
2413
+ req_log.debug("token_valid")
2414
+ token_validated = True
2415
+ else:
2416
+ req_log.warning("invalid_token")
2417
+ # Debug information for token validation issues
2418
+ if hasattr(self._session_manager, 'debug_token'):
2419
+ debug_info = self._session_manager.debug_token(token)
2420
+ req_log.debug("token_debug", debug=json.dumps(debug_info))
2421
+ except Exception as e:
2422
+ req_log.error("token_validation_error", error=str(e))
2423
+
2424
+ # For GET requests, return the SWML document
2425
+ if request.method == "GET":
2426
+ swml = self._render_swml(call_id)
2427
+ req_log.debug("swml_rendered", swml_size=len(swml))
2428
+ return Response(
2429
+ content=swml,
2430
+ media_type="application/json"
2431
+ )
2432
+
2433
+ # For POST requests, process the post-prompt data
2249
2434
  try:
2250
- engine_voice, model_part = voice.split(":", 1)
2251
- engine_part, voice_part = engine_voice.split(".", 1)
2435
+ # Try to reuse the body we already parsed for call_id extraction
2436
+ if hasattr(request, "_post_prompt_body"):
2437
+ body = getattr(request, "_post_prompt_body")
2438
+ else:
2439
+ body = await request.json()
2252
2440
 
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
2441
+ # Only log if not suppressed
2442
+ if not getattr(self, '_suppress_logs', False):
2443
+ req_log.debug("request_body_received", body_size=len(str(body)))
2444
+ # Log the raw body directly (let the logger handle the JSON encoding)
2445
+ req_log.info("post_prompt_body", body=body)
2446
+ except Exception as e:
2447
+ req_log.error("error_parsing_request_body", error=str(e))
2448
+ body = {}
2449
+
2450
+ # Extract summary from the correct location in the request
2451
+ summary = self._find_summary_in_post_data(body, req_log)
2452
+
2453
+ # Call the summary handler with the summary and the full body
2454
+ try:
2455
+ if summary:
2456
+ self.on_summary(summary, body)
2457
+ req_log.debug("summary_handler_called_successfully")
2458
+ else:
2459
+ # If no summary found but still want to process the data
2460
+ self.on_summary(None, body)
2461
+ req_log.debug("summary_handler_called_with_null_summary")
2462
+ except Exception as e:
2463
+ req_log.error("error_in_summary_handler", error=str(e))
2464
+
2465
+ # Return success
2466
+ req_log.info("request_successful")
2467
+ return {"success": True}
2468
+ except Exception as e:
2469
+ req_log.error("request_failed", error=str(e))
2470
+ return Response(
2471
+ content=json.dumps({"error": str(e)}),
2472
+ status_code=500,
2473
+ media_type="application/json"
2474
+ )
2475
+
2476
+ async def _handle_check_for_input_request(self, request: Request):
2477
+ """Handle GET/POST requests to the check_for_input endpoint"""
2478
+ req_log = self.log.bind(
2479
+ endpoint="check_for_input",
2480
+ method=request.method,
2481
+ path=request.url.path
2482
+ )
2271
2483
 
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
2484
+ req_log.debug("endpoint_called")
2278
2485
 
2279
- Args:
2280
- languages: List of language configuration dictionaries
2486
+ try:
2487
+ # Check auth
2488
+ if not self._check_basic_auth(request):
2489
+ req_log.warning("unauthorized_access_attempt")
2490
+ return Response(
2491
+ content=json.dumps({"error": "Unauthorized"}),
2492
+ status_code=401,
2493
+ headers={"WWW-Authenticate": "Basic"},
2494
+ media_type="application/json"
2495
+ )
2281
2496
 
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
2497
+ # For both GET and POST requests, process input check
2498
+ conversation_id = None
2499
+
2500
+ if request.method == "POST":
2501
+ try:
2502
+ body = await request.json()
2503
+ req_log.debug("request_body_received", body_size=len(str(body)))
2504
+ conversation_id = body.get("conversation_id")
2505
+ except Exception as e:
2506
+ req_log.error("error_parsing_request_body", error=str(e))
2507
+ else:
2508
+ conversation_id = request.query_params.get("conversation_id")
2300
2509
 
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
2510
+ if not conversation_id:
2511
+ req_log.warning("missing_conversation_id")
2512
+ return Response(
2513
+ content=json.dumps({"error": "Missing conversation_id parameter"}),
2514
+ status_code=400,
2515
+ media_type="application/json"
2516
+ )
2311
2517
 
2312
- self._pronounce.append(rule)
2313
- return self
2314
-
2315
- def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
2518
+ # Here you would typically check for new input in some external system
2519
+ # For this implementation, we'll return an empty result
2520
+ return {
2521
+ "status": "success",
2522
+ "conversation_id": conversation_id,
2523
+ "new_input": False,
2524
+ "messages": []
2525
+ }
2526
+ except Exception as e:
2527
+ req_log.error("request_failed", error=str(e))
2528
+ return Response(
2529
+ content=json.dumps({"error": str(e)}),
2530
+ status_code=500,
2531
+ media_type="application/json"
2532
+ )
2533
+
2534
+ def _find_summary_in_post_data(self, body, logger):
2316
2535
  """
2317
- Set all pronunciation rules at once
2536
+ Attempt to find a summary in the post-prompt response data
2318
2537
 
2319
2538
  Args:
2320
- pronunciations: List of pronunciation rule dictionaries
2539
+ body: The request body
2540
+ logger: Logger instance
2321
2541
 
2322
2542
  Returns:
2323
- Self for method chaining
2543
+ Summary data or None if not found
2324
2544
  """
2325
- if pronunciations and isinstance(pronunciations, list):
2326
- self._pronounce = pronunciations
2327
- return self
2545
+ if not body:
2546
+ return None
2328
2547
 
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
2548
+ # Various ways to get summary data
2549
+ if "summary" in body:
2550
+ return body["summary"]
2551
+
2552
+ if "post_prompt_data" in body:
2553
+ pdata = body["post_prompt_data"]
2554
+ if isinstance(pdata, dict):
2555
+ if "parsed" in pdata and isinstance(pdata["parsed"], list) and pdata["parsed"]:
2556
+ return pdata["parsed"][0]
2557
+ elif "raw" in pdata and pdata["raw"]:
2558
+ try:
2559
+ # Try to parse JSON from raw text
2560
+ parsed = json.loads(pdata["raw"])
2561
+ return parsed
2562
+ except:
2563
+ return pdata["raw"]
2564
+
2565
+ return None
2343
2566
 
2344
- def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
2567
+ def _register_state_tracking_tools(self):
2345
2568
  """
2346
- Set multiple AI parameters at once
2569
+ Register special tools for state tracking
2347
2570
 
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':
2571
+ This adds startup_hook and hangup_hook SWAIG functions that automatically
2572
+ activate and deactivate the session when called. These are useful for
2573
+ tracking call state and cleaning up resources when a call ends.
2359
2574
  """
2360
- Set the global data available to the AI throughout the conversation
2575
+ # Register startup hook to activate session
2576
+ self.define_tool(
2577
+ name="startup_hook",
2578
+ description="Called when a new conversation starts to initialize state",
2579
+ parameters={},
2580
+ handler=lambda args, raw_data: self._handle_startup_hook(args, raw_data),
2581
+ secure=False # No auth needed for this system function
2582
+ )
2361
2583
 
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':
2584
+ # Register hangup hook to end session
2585
+ self.define_tool(
2586
+ name="hangup_hook",
2587
+ description="Called when conversation ends to clean up resources",
2588
+ parameters={},
2589
+ handler=lambda args, raw_data: self._handle_hangup_hook(args, raw_data),
2590
+ secure=False # No auth needed for this system function
2591
+ )
2592
+
2593
+ def _handle_startup_hook(self, args, raw_data):
2373
2594
  """
2374
- Update the global data with new values
2595
+ Handle the startup hook function call
2375
2596
 
2376
2597
  Args:
2377
- data: Dictionary of global data to update
2598
+ args: Function arguments (empty for this hook)
2599
+ raw_data: Raw request data containing call_id
2378
2600
 
2379
2601
  Returns:
2380
- Self for method chaining
2602
+ Success response
2381
2603
  """
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':
2604
+ call_id = raw_data.get("call_id") if raw_data else None
2605
+ if call_id:
2606
+ self.log.info("session_activated", call_id=call_id)
2607
+ self._session_manager.activate_session(call_id)
2608
+ return SwaigFunctionResult("Session activated")
2609
+ else:
2610
+ self.log.warning("session_activation_failed", error="No call_id provided")
2611
+ return SwaigFunctionResult("Failed to activate session: No call_id provided")
2612
+
2613
+ def _handle_hangup_hook(self, args, raw_data):
2387
2614
  """
2388
- Set the list of native functions to enable
2615
+ Handle the hangup hook function call
2389
2616
 
2390
2617
  Args:
2391
- function_names: List of native function names
2618
+ args: Function arguments (empty for this hook)
2619
+ raw_data: Raw request data containing call_id
2392
2620
 
2393
2621
  Returns:
2394
- Self for method chaining
2622
+ Success response
2395
2623
  """
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
2624
+ call_id = raw_data.get("call_id") if raw_data else None
2625
+ if call_id:
2626
+ self.log.info("session_ended", call_id=call_id)
2627
+ self._session_manager.end_session(call_id)
2628
+ return SwaigFunctionResult("Session ended")
2629
+ else:
2630
+ self.log.warning("session_end_failed", error="No call_id provided")
2631
+ return SwaigFunctionResult("Failed to end session: No call_id provided")
2399
2632
 
2400
- def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
2633
+ def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
2401
2634
  """
2402
- Add a remote function include to the SWAIG configuration
2635
+ Called when SWML is requested, with request data when available
2403
2636
 
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
2637
+ This method overrides SWMLService's on_request to properly handle SWML generation
2638
+ for AI Agents. It forwards the call to on_swml_request for compatibility.
2426
2639
 
2427
2640
  Args:
2428
- includes: List of include objects, each with url and functions properties
2641
+ request_data: Optional dictionary containing the parsed POST body
2642
+ callback_path: Optional callback path
2429
2643
 
2430
2644
  Returns:
2431
- Self for method chaining
2645
+ None to use the default SWML rendering (which will call _render_swml)
2432
2646
  """
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)
2647
+ # First try to call on_swml_request if it exists (backward compatibility)
2648
+ if hasattr(self, 'on_swml_request') and callable(getattr(self, 'on_swml_request')):
2649
+ return self.on_swml_request(request_data, callback_path, None)
2440
2650
 
2441
- self._function_includes = valid_includes
2442
- return self
2443
-
2444
- def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
2651
+ # If no on_swml_request or it returned None, we'll proceed with default rendering
2652
+ # We're not returning any modifications here because _render_swml will be called
2653
+ # to generate the complete SWML document
2654
+ return None
2655
+
2656
+ def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None, request: Optional[Request] = None) -> Optional[dict]:
2445
2657
  """
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.
2658
+ Customization point for subclasses to modify SWML based on request data
2451
2659
 
2452
2660
  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
-
2661
+ request_data: Optional dictionary containing the parsed POST body
2662
+ callback_path: Optional callback path
2663
+ request: Optional FastAPI Request object for accessing query params, headers, etc.
2664
+
2457
2665
  Returns:
2458
- Self for method chaining
2666
+ Optional dict with modifications to apply to the SWML document
2459
2667
  """
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)
2668
+ # Handle dynamic configuration callback if set
2669
+ if self._dynamic_config_callback and request:
2670
+ try:
2671
+ # Extract request data
2672
+ query_params = dict(request.query_params)
2673
+ body_params = request_data or {}
2674
+ headers = dict(request.headers)
2467
2675
 
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
2676
+ # Create ephemeral configurator
2677
+ agent_config = EphemeralAgentConfig()
2678
+
2679
+ # Call the user's configuration callback
2680
+ self._dynamic_config_callback(query_params, body_params, headers, agent_config)
2681
+
2682
+ # Extract the configuration
2683
+ config = agent_config.extract_config()
2684
+ if config:
2685
+ # Handle ephemeral prompt sections by applying them to this agent instance
2686
+ if "_ephemeral_prompt_sections" in config:
2687
+ for section in config["_ephemeral_prompt_sections"]:
2688
+ self.prompt_add_section(
2689
+ section["title"],
2690
+ section.get("body", ""),
2691
+ section.get("bullets"),
2692
+ **{k: v for k, v in section.items() if k not in ["title", "body", "bullets"]}
2693
+ )
2694
+ del config["_ephemeral_prompt_sections"]
2476
2695
 
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
2696
+ if "_ephemeral_raw_prompt" in config:
2697
+ self._raw_prompt = config["_ephemeral_raw_prompt"]
2698
+ del config["_ephemeral_raw_prompt"]
2699
+
2700
+ if "_ephemeral_post_prompt" in config:
2701
+ self._post_prompt = config["_ephemeral_post_prompt"]
2702
+ del config["_ephemeral_post_prompt"]
2703
+
2704
+ return config
2705
+
2706
+ except Exception as e:
2707
+ self.log.error("dynamic_config_error", error=str(e))
2491
2708
 
2492
- Args:
2493
- sip_username: SIP username to register
2494
-
2495
- Returns:
2496
- Self for method chaining
2709
+ # Default implementation does nothing
2710
+ return None
2711
+
2712
+ def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
2713
+ path: str = "/sip") -> None:
2497
2714
  """
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)
2715
+ Register a callback function that will be called to determine routing
2716
+ based on POST data.
2503
2717
 
2504
- return self
2718
+ When a routing callback is registered, an endpoint at the specified path is automatically
2719
+ created that will handle requests. This endpoint will use the callback to
2720
+ determine if the request should be processed by this service or redirected.
2505
2721
 
2506
- def auto_map_sip_usernames(self) -> 'AgentBase':
2507
- """
2508
- Automatically register common SIP usernames based on this agent's
2509
- name and route
2722
+ The callback should take a request object and request body dictionary and return:
2723
+ - A route string if it should be routed to a different endpoint
2724
+ - None if normal processing should continue
2510
2725
 
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
2726
+ Args:
2727
+ callback_fn: The callback function to register
2728
+ path: The path where this callback should be registered (default: "/sip")
2729
+ """
2730
+ # Normalize the path (remove trailing slash)
2731
+ normalized_path = path.rstrip("/")
2732
+ if not normalized_path.startswith("/"):
2733
+ normalized_path = f"/{normalized_path}"
2734
+
2735
+ # Store the callback with the normalized path (without trailing slash)
2736
+ self.log.info("registering_routing_callback", path=normalized_path)
2737
+ if not hasattr(self, '_routing_callbacks'):
2738
+ self._routing_callbacks = {}
2739
+ self._routing_callbacks[normalized_path] = callback_fn
2532
2740
 
2533
- def set_web_hook_url(self, url: str) -> 'AgentBase':
2741
+ def set_dynamic_config_callback(self, callback: Callable[[dict, dict, dict, EphemeralAgentConfig], None]) -> 'AgentBase':
2534
2742
  """
2535
- Override the default web_hook_url with a supplied URL string
2743
+ Set a callback function for dynamic agent configuration
2744
+
2745
+ This callback receives an EphemeralAgentConfig object that provides the same
2746
+ configuration methods as AgentBase, allowing you to dynamically configure
2747
+ the agent's voice, prompt, parameters, etc. based on request data.
2536
2748
 
2537
2749
  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
2750
+ callback: Function that takes (query_params, body_params, headers, agent_config)
2751
+ and configures the agent_config object using familiar methods like:
2752
+ - agent_config.add_language(...)
2753
+ - agent_config.prompt_add_section(...)
2754
+ - agent_config.set_params(...)
2755
+ - agent_config.set_global_data(...)
2756
+
2757
+ Example:
2758
+ def my_config(query_params, body_params, headers, agent):
2759
+ if query_params.get('tier') == 'premium':
2760
+ agent.add_language("English", "en-US", "premium_voice")
2761
+ agent.set_params({"end_of_speech_timeout": 500})
2762
+ agent.set_global_data({"tier": query_params.get('tier', 'standard')})
2763
+
2764
+ my_agent.set_dynamic_config_callback(my_config)
2765
+ """
2766
+ self._dynamic_config_callback = callback
2544
2767
  return self
2545
-
2546
- def set_post_prompt_url(self, url: str) -> 'AgentBase':
2768
+
2769
+ def manual_set_proxy_url(self, proxy_url: str) -> 'AgentBase':
2547
2770
  """
2548
- Override the default post_prompt_url with a supplied URL string
2771
+ Manually set the proxy URL base for webhook callbacks
2772
+
2773
+ This can be called at runtime to set or update the proxy URL
2549
2774
 
2550
2775
  Args:
2551
- url: The URL to use for post-prompt summary delivery
2776
+ proxy_url: The base URL to use for webhooks (e.g., https://example.ngrok.io)
2552
2777
 
2553
2778
  Returns:
2554
2779
  Self for method chaining
2555
2780
  """
2556
- self._post_prompt_url_override = url
2781
+ if proxy_url:
2782
+ # Set on self
2783
+ self._proxy_url_base = proxy_url.rstrip('/')
2784
+ self._proxy_detection_done = True
2785
+
2786
+ # Set on parent if it has these attributes
2787
+ if hasattr(super(), '_proxy_url_base'):
2788
+ super()._proxy_url_base = self._proxy_url_base
2789
+ if hasattr(super(), '_proxy_detection_done'):
2790
+ super()._proxy_detection_done = True
2791
+
2792
+ self.log.info("proxy_url_manually_set", proxy_url_base=self._proxy_url_base)
2793
+
2557
2794
  return self