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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -110,7 +108,7 @@ class AgentBase(SWMLService):
110
108
  basic_auth: Optional[Tuple[str, str]] = None,
111
109
  use_pom: bool = True,
112
110
  enable_state_tracking: bool = False,
113
- token_expiry_secs: int = 600,
111
+ token_expiry_secs: int = 3600,
114
112
  auto_answer: bool = True,
115
113
  record_call: bool = False,
116
114
  record_format: str = "mp4",
@@ -355,6 +353,19 @@ class AgentBase(SWMLService):
355
353
  self._raw_prompt = text
356
354
  return self
357
355
 
356
+ def set_post_prompt(self, text: str) -> 'AgentBase':
357
+ """
358
+ Set the post-prompt text for summary generation
359
+
360
+ Args:
361
+ text: The post-prompt text
362
+
363
+ Returns:
364
+ Self for method chaining
365
+ """
366
+ self._post_prompt = text
367
+ return self
368
+
358
369
  def set_prompt_pom(self, pom: List[Dict[str, Any]]) -> 'AgentBase':
359
370
  """
360
371
  Set the prompt as a POM dictionary
@@ -569,6 +580,44 @@ class AgentBase(SWMLService):
569
580
  return func
570
581
  return decorator
571
582
 
583
+ def _register_class_decorated_tools(self):
584
+ """
585
+ Register tools defined with @AgentBase.tool class decorator
586
+
587
+ This method scans the class for methods decorated with @AgentBase.tool
588
+ and registers them automatically.
589
+ """
590
+ # Get the class of this instance
591
+ cls = self.__class__
592
+
593
+ # Loop through all attributes in the class
594
+ for name in dir(cls):
595
+ # Get the attribute
596
+ attr = getattr(cls, name)
597
+
598
+ # Check if it's a method decorated with @AgentBase.tool
599
+ if inspect.ismethod(attr) or inspect.isfunction(attr):
600
+ if hasattr(attr, "_is_tool") and getattr(attr, "_is_tool", False):
601
+ # Extract tool information
602
+ tool_name = getattr(attr, "_tool_name", name)
603
+ tool_params = getattr(attr, "_tool_params", {})
604
+
605
+ # Get description and parameters
606
+ description = tool_params.get("description", attr.__doc__ or f"Function {tool_name}")
607
+ parameters = tool_params.get("parameters", {})
608
+ secure = tool_params.get("secure", True)
609
+ fillers = tool_params.get("fillers", None)
610
+
611
+ # Register the tool
612
+ self.define_tool(
613
+ name=tool_name,
614
+ description=description,
615
+ parameters=parameters,
616
+ handler=attr.__get__(self, cls), # Bind the method to this instance
617
+ secure=secure,
618
+ fillers=fillers
619
+ )
620
+
572
621
  @classmethod
573
622
  def tool(cls, name=None, **kwargs):
574
623
  """
@@ -730,7 +779,17 @@ class AgentBase(SWMLService):
730
779
  Returns:
731
780
  Secure token string
732
781
  """
733
- return self._session_manager.create_tool_token(tool_name, call_id)
782
+ try:
783
+ # Ensure we have a session manager
784
+ if not hasattr(self, '_session_manager'):
785
+ self.log.error("no_session_manager")
786
+ return ""
787
+
788
+ # Create the token using the session manager
789
+ return self._session_manager.create_tool_token(tool_name, call_id)
790
+ except Exception as e:
791
+ self.log.error("token_creation_error", error=str(e), tool=tool_name, call_id=call_id)
792
+ return ""
734
793
 
735
794
  def validate_tool_token(self, function_name: str, token: str, call_id: str) -> bool:
736
795
  """
@@ -744,14 +803,92 @@ class AgentBase(SWMLService):
744
803
  Returns:
745
804
  True if token is valid, False otherwise
746
805
  """
747
- # Skip validation for non-secure tools
748
- if function_name not in self._swaig_functions:
749
- return False
806
+ try:
807
+ # Skip validation for non-secure tools
808
+ if function_name not in self._swaig_functions:
809
+ self.log.warning("unknown_function", function=function_name)
810
+ return False
811
+
812
+ # Always allow non-secure functions
813
+ if not self._swaig_functions[function_name].secure:
814
+ self.log.debug("non_secure_function_allowed", function=function_name)
815
+ return True
816
+
817
+ # Check if we have a session manager
818
+ if not hasattr(self, '_session_manager'):
819
+ self.log.error("no_session_manager")
820
+ return False
821
+
822
+ # Handle missing token
823
+ if not token:
824
+ self.log.warning("missing_token", function=function_name)
825
+ return False
826
+
827
+ # For debugging: Log token details
828
+ try:
829
+ # Capture original parameters
830
+ self.log.debug("token_validate_input",
831
+ function=function_name,
832
+ call_id=call_id,
833
+ token_length=len(token))
834
+
835
+ # Try to decode token for debugging
836
+ if hasattr(self._session_manager, 'debug_token'):
837
+ debug_info = self._session_manager.debug_token(token)
838
+ self.log.debug("token_debug", debug_info=debug_info)
839
+
840
+ # Extract token components
841
+ if debug_info.get("valid_format") and "components" in debug_info:
842
+ components = debug_info["components"]
843
+ token_call_id = components.get("call_id")
844
+ token_function = components.get("function")
845
+ token_expiry = components.get("expiry")
846
+
847
+ # Log parameter mismatches
848
+ if token_function != function_name:
849
+ self.log.warning("token_function_mismatch",
850
+ expected=function_name,
851
+ actual=token_function)
852
+
853
+ if token_call_id != call_id:
854
+ self.log.warning("token_call_id_mismatch",
855
+ expected=call_id,
856
+ actual=token_call_id)
857
+
858
+ # Check expiration
859
+ if debug_info.get("status", {}).get("is_expired"):
860
+ self.log.warning("token_expired",
861
+ expires_in=debug_info["status"].get("expires_in_seconds"))
862
+ except Exception as e:
863
+ self.log.error("token_debug_error", error=str(e))
864
+
865
+ # Use call_id from token if the provided one is empty
866
+ if not call_id and hasattr(self._session_manager, 'debug_token'):
867
+ try:
868
+ debug_info = self._session_manager.debug_token(token)
869
+ if debug_info.get("valid_format") and "components" in debug_info:
870
+ token_call_id = debug_info["components"].get("call_id")
871
+ if token_call_id:
872
+ self.log.debug("using_call_id_from_token", call_id=token_call_id)
873
+ is_valid = self._session_manager.validate_tool_token(function_name, token, token_call_id)
874
+ if is_valid:
875
+ self.log.debug("token_valid_with_extracted_call_id")
876
+ return True
877
+ except Exception as e:
878
+ self.log.error("error_using_call_id_from_token", error=str(e))
750
879
 
751
- if not self._swaig_functions[function_name].secure:
752
- return True
880
+ # Normal validation with provided call_id
881
+ is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
753
882
 
754
- return self._session_manager.validate_tool_token(function_name, token, call_id)
883
+ if is_valid:
884
+ self.log.debug("token_valid", function=function_name)
885
+ else:
886
+ self.log.warning("token_invalid", function=function_name)
887
+
888
+ return is_valid
889
+ except Exception as e:
890
+ self.log.error("token_validation_error", error=str(e), function=function_name)
891
+ return False
755
892
 
756
893
  # ----------------------------------------------------------------------
757
894
  # Web Server and Routing
@@ -836,6 +973,16 @@ class AgentBase(SWMLService):
836
973
  Returns:
837
974
  Fully constructed webhook URL
838
975
  """
976
+ # Use the parent class's implementation if available and has the same method
977
+ if hasattr(super(), '_build_webhook_url'):
978
+ # Ensure _proxy_url_base is synchronized
979
+ if getattr(self, '_proxy_url_base', None) and hasattr(super(), '_proxy_url_base'):
980
+ super()._proxy_url_base = self._proxy_url_base
981
+
982
+ # Call parent's implementation
983
+ return super()._build_webhook_url(endpoint, query_params)
984
+
985
+ # Otherwise, fall back to our own implementation
839
986
  # Base URL construction
840
987
  if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
841
988
  # For proxy URLs
@@ -965,15 +1112,26 @@ class AgentBase(SWMLService):
965
1112
  if functions:
966
1113
  swaig_obj["functions"] = functions
967
1114
 
968
- # Add post-prompt URL if we have a post-prompt
1115
+ # Add post-prompt URL with token if we have a post-prompt
969
1116
  post_prompt_url = None
970
1117
  if post_prompt:
971
- post_prompt_url = self._build_webhook_url("post_prompt", {})
1118
+ # Create a token for post_prompt if we have a call_id
1119
+ query_params = {}
1120
+ if call_id and hasattr(self, '_session_manager'):
1121
+ try:
1122
+ token = self._session_manager.create_tool_token("post_prompt", call_id)
1123
+ if token:
1124
+ query_params["token"] = token
1125
+ except Exception as e:
1126
+ self.log.error("post_prompt_token_creation_error", error=str(e))
1127
+
1128
+ # Build the URL with the token (if any)
1129
+ post_prompt_url = self._build_webhook_url("post_prompt", query_params)
972
1130
 
973
1131
  # Use override if set
974
1132
  if hasattr(self, '_post_prompt_url_override') and self._post_prompt_url_override:
975
1133
  post_prompt_url = self._post_prompt_url_override
976
-
1134
+
977
1135
  # Add answer verb with auto-answer enabled
978
1136
  self.add_answer_verb()
979
1137
 
@@ -1116,1442 +1274,1297 @@ class AgentBase(SWMLService):
1116
1274
  Returns:
1117
1275
  FastAPI router
1118
1276
  """
1119
- # Get the base router from SWMLService
1120
- router = super().as_router()
1277
+ # Create a router with explicit redirect_slashes=False
1278
+ router = APIRouter(redirect_slashes=False)
1121
1279
 
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)
1280
+ # Register routes explicitly
1281
+ self._register_routes(router)
1282
+
1283
+ # Log all registered routes for debugging
1284
+ print(f"Registered routes for {self.name}:")
1285
+ for route in router.routes:
1286
+ print(f" {route.path}")
1287
+
1288
+ return router
1289
+
1290
+ def serve(self, host: Optional[str] = None, port: Optional[int] = None) -> None:
1291
+ """
1292
+ Start a web server for this agent
1293
+
1294
+ Args:
1295
+ host: Optional host to override the default
1296
+ port: Optional port to override the default
1297
+ """
1298
+ import uvicorn
1299
+
1300
+ if self._app is None:
1301
+ # Create a FastAPI app with explicit redirect_slashes=False
1302
+ app = FastAPI(redirect_slashes=False)
1303
+
1304
+ # Get router for this agent
1305
+ router = self.as_router()
1306
+
1307
+ # Register a catch-all route for debugging and troubleshooting
1308
+ @app.get("/{full_path:path}")
1309
+ @app.post("/{full_path:path}")
1310
+ async def handle_all_routes(request: Request, full_path: str):
1311
+ print(f"Received request for path: {full_path}")
1312
+
1313
+ # Check if the path is meant for this agent
1314
+ if not full_path.startswith(self.route.lstrip("/")):
1315
+ return {"error": "Invalid route"}
1316
+
1317
+ # Extract the path relative to this agent's route
1318
+ relative_path = full_path[len(self.route.lstrip("/")):]
1319
+ relative_path = relative_path.lstrip("/")
1320
+ print(f"Relative path: {relative_path}")
1321
+
1322
+ # Perform routing based on the relative path
1323
+ if not relative_path or relative_path == "/":
1324
+ # Root endpoint
1325
+ return await self._handle_root_request(request)
1326
+
1327
+ # Strip trailing slash for processing
1328
+ clean_path = relative_path.rstrip("/")
1329
+
1330
+ # Check for standard endpoints
1331
+ if clean_path == "debug":
1332
+ return await self._handle_debug_request(request)
1333
+ elif clean_path == "swaig":
1334
+ return await self._handle_swaig_request(request, Response())
1335
+ elif clean_path == "post_prompt":
1336
+ return await self._handle_post_prompt_request(request)
1337
+ elif clean_path == "check_for_input":
1338
+ return await self._handle_check_for_input_request(request)
1339
+
1340
+ # Check for custom routing callbacks
1341
+ if hasattr(self, '_routing_callbacks'):
1342
+ for callback_path, callback_fn in self._routing_callbacks.items():
1343
+ cb_path_clean = callback_path.strip("/")
1344
+ if clean_path == cb_path_clean:
1345
+ # Found a matching callback
1346
+ request.state.callback_path = callback_path
1347
+ return await self._handle_root_request(request)
1348
+
1349
+ # Default: 404
1350
+ return {"error": "Path not found"}
1351
+
1352
+ # Include router with prefix
1353
+ app.include_router(router, prefix=self.route)
1354
+
1355
+ # Print all app routes for debugging
1356
+ print(f"All app routes:")
1357
+ for route in app.routes:
1358
+ if hasattr(route, "path"):
1359
+ print(f" {route.path}")
1360
+
1361
+ self._app = app
1362
+
1363
+ host = host or self.host
1364
+ port = port or self.port
1365
+
1366
+ # Print the auth credentials with source
1367
+ username, password, source = self.get_basic_auth_credentials(include_source=True)
1368
+ print(f"Agent '{self.name}' is available at:")
1369
+ print(f"URL: http://{host}:{port}{self.route}")
1370
+ print(f"Basic Auth: {username}:{password} (source: {source})")
1371
+
1372
+ uvicorn.run(self._app, host=host, port=port)
1373
+
1374
+ def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
1375
+ """
1376
+ Customization point for subclasses to modify SWML based on request data
1377
+
1378
+ Args:
1379
+ request_data: Optional dictionary containing the parsed POST body
1380
+ callback_path: Optional callback path
1127
1381
 
1128
- # Root endpoint - with trailing slash
1382
+ Returns:
1383
+ Optional dict with modifications to apply to the SWML document
1384
+ """
1385
+ # Default implementation does nothing
1386
+ return None
1387
+
1388
+ def _register_routes(self, router):
1389
+ """
1390
+ Register routes for this agent
1391
+
1392
+ This method ensures proper route registration by handling the routes
1393
+ directly in AgentBase rather than inheriting from SWMLService.
1394
+
1395
+ Args:
1396
+ router: FastAPI router to register routes with
1397
+ """
1398
+ # Root endpoint (handles both with and without trailing slash)
1129
1399
  @router.get("/")
1130
1400
  @router.post("/")
1131
- async def handle_root_with_slash(request: Request):
1401
+ async def handle_root(request: Request, response: Response):
1402
+ """Handle GET/POST requests to the root endpoint"""
1132
1403
  return await self._handle_root_request(request)
1133
1404
 
1134
- # Debug endpoint - without trailing slash
1405
+ # Debug endpoint - Both versions
1135
1406
  @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
1407
  @router.get("/debug/")
1408
+ @router.post("/debug")
1142
1409
  @router.post("/debug/")
1143
- async def handle_debug_with_slash(request: Request):
1410
+ async def handle_debug(request: Request):
1411
+ """Handle GET/POST requests to the debug endpoint"""
1144
1412
  return await self._handle_debug_request(request)
1145
1413
 
1146
- # SWAIG endpoint - without trailing slash
1414
+ # SWAIG endpoint - Both versions
1147
1415
  @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
1416
  @router.get("/swaig/")
1417
+ @router.post("/swaig")
1154
1418
  @router.post("/swaig/")
1155
- async def handle_swaig_with_slash(request: Request):
1156
- return await self._handle_swaig_request(request)
1419
+ async def handle_swaig(request: Request, response: Response):
1420
+ """Handle GET/POST requests to the SWAIG endpoint"""
1421
+ return await self._handle_swaig_request(request, response)
1157
1422
 
1158
- # Post-prompt endpoint - without trailing slash
1423
+ # Post prompt endpoint - Both versions
1159
1424
  @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
1425
  @router.get("/post_prompt/")
1426
+ @router.post("/post_prompt")
1166
1427
  @router.post("/post_prompt/")
1167
- async def handle_post_prompt_with_slash(request: Request):
1428
+ async def handle_post_prompt(request: Request):
1429
+ """Handle GET/POST requests to the post_prompt endpoint"""
1168
1430
  return await self._handle_post_prompt_request(request)
1431
+
1432
+ # Check for input endpoint - Both versions
1433
+ @router.get("/check_for_input")
1434
+ @router.get("/check_for_input/")
1435
+ @router.post("/check_for_input")
1436
+ @router.post("/check_for_input/")
1437
+ async def handle_check_for_input(request: Request):
1438
+ """Handle GET/POST requests to the check_for_input endpoint"""
1439
+ return await self._handle_check_for_input_request(request)
1169
1440
 
1170
- self._router = router
1171
- return router
1441
+ # Register callback routes for routing callbacks if available
1442
+ if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
1443
+ for callback_path, callback_fn in self._routing_callbacks.items():
1444
+ # Skip the root path as it's already handled
1445
+ if callback_path == "/":
1446
+ continue
1447
+
1448
+ # Register both with and without trailing slash
1449
+ path = callback_path.rstrip("/")
1450
+ path_with_slash = f"{path}/"
1451
+
1452
+ @router.get(path)
1453
+ @router.get(path_with_slash)
1454
+ @router.post(path)
1455
+ @router.post(path_with_slash)
1456
+ async def handle_callback(request: Request, response: Response, cb_path=callback_path):
1457
+ """Handle GET/POST requests to a registered callback path"""
1458
+ # Store the callback path in request state for _handle_request to use
1459
+ request.state.callback_path = cb_path
1460
+ return await self._handle_root_request(request)
1461
+
1462
+ self.log.info("callback_endpoint_registered", path=callback_path)
1172
1463
 
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
1464
+ @classmethod
1465
+
1466
+ # ----------------------------------------------------------------------
1467
+ # AI Verb Configuration Methods
1468
+ # ----------------------------------------------------------------------
1469
+
1470
+ def add_hint(self, hint: str) -> 'AgentBase':
1471
+ """
1472
+ Add a simple string hint to help the AI agent understand certain words better
1190
1473
 
1191
- # Check if this is a callback path request
1192
- callback_path = getattr(request.state, "callback_path", None)
1474
+ Args:
1475
+ hint: The hint string to add
1476
+
1477
+ Returns:
1478
+ Self for method chaining
1479
+ """
1480
+ if isinstance(hint, str) and hint:
1481
+ self._hints.append(hint)
1482
+ return self
1483
+
1484
+ def add_hints(self, hints: List[str]) -> 'AgentBase':
1485
+ """
1486
+ Add multiple string hints
1193
1487
 
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
- )
1488
+ Args:
1489
+ hints: List of hint strings
1490
+
1491
+ Returns:
1492
+ Self for method chaining
1493
+ """
1494
+ if hints and isinstance(hints, list):
1495
+ for hint in hints:
1496
+ if isinstance(hint, str) and hint:
1497
+ self._hints.append(hint)
1498
+ return self
1499
+
1500
+ def add_pattern_hint(self,
1501
+ hint: str,
1502
+ pattern: str,
1503
+ replace: str,
1504
+ ignore_case: bool = False) -> 'AgentBase':
1505
+ """
1506
+ Add a complex hint with pattern matching
1199
1507
 
1200
- req_log.debug("endpoint_called")
1508
+ Args:
1509
+ hint: The hint to match
1510
+ pattern: Regular expression pattern
1511
+ replace: Text to replace the hint with
1512
+ ignore_case: Whether to ignore case when matching
1513
+
1514
+ Returns:
1515
+ Self for method chaining
1516
+ """
1517
+ if hint and pattern and replace:
1518
+ self._hints.append({
1519
+ "hint": hint,
1520
+ "pattern": pattern,
1521
+ "replace": replace,
1522
+ "ignore_case": ignore_case
1523
+ })
1524
+ return self
1525
+
1526
+ def add_language(self,
1527
+ name: str,
1528
+ code: str,
1529
+ voice: str,
1530
+ speech_fillers: Optional[List[str]] = None,
1531
+ function_fillers: Optional[List[str]] = None,
1532
+ engine: Optional[str] = None,
1533
+ model: Optional[str] = None) -> 'AgentBase':
1534
+ """
1535
+ Add a language configuration to support multilingual conversations
1201
1536
 
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
- )
1537
+ Args:
1538
+ name: Name of the language (e.g., "English", "French")
1539
+ code: Language code (e.g., "en-US", "fr-FR")
1540
+ voice: TTS voice to use. Can be a simple name (e.g., "en-US-Neural2-F")
1541
+ or a combined format "engine.voice:model" (e.g., "elevenlabs.josh:eleven_turbo_v2_5")
1542
+ speech_fillers: Optional list of filler phrases for natural speech
1543
+ function_fillers: Optional list of filler phrases during function calls
1544
+ engine: Optional explicit engine name (e.g., "elevenlabs", "rime")
1545
+ model: Optional explicit model name (e.g., "eleven_turbo_v2_5", "arcana")
1212
1546
 
1213
- # Try to parse request body for POST
1214
- body = {}
1215
- call_id = None
1547
+ Returns:
1548
+ Self for method chaining
1216
1549
 
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")
1550
+ Examples:
1551
+ # Simple voice name
1552
+ agent.add_language("English", "en-US", "en-US-Neural2-F")
1439
1553
 
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
- )
1554
+ # Explicit parameters
1555
+ agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
1466
1556
 
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")
1557
+ # Combined format
1558
+ agent.add_language("English", "en-US", "elevenlabs.josh:eleven_turbo_v2_5")
1559
+ """
1560
+ language = {
1561
+ "name": name,
1562
+ "code": code
1563
+ }
1478
1564
 
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
1565
+ # Handle voice formatting (either explicit params or combined string)
1566
+ if engine or model:
1567
+ # Use explicit parameters if provided
1568
+ language["voice"] = voice
1569
+ if engine:
1570
+ language["engine"] = engine
1571
+ if model:
1572
+ language["model"] = model
1573
+ elif "." in voice and ":" in voice:
1574
+ # Parse combined string format: "engine.voice:model"
1501
1575
  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)
1576
+ engine_voice, model_part = voice.split(":", 1)
1577
+ engine_part, voice_part = engine_voice.split(".", 1)
1520
1578
 
1521
- # Check if state manager has the right methods
1522
- try:
1523
- if hasattr(self._state_manager, 'get_state'):
1524
- state = self._state_manager.get_state(call_id) or {}
1525
- state["summary"] = summary
1526
- if hasattr(self._state_manager, 'update_state'):
1527
- self._state_manager.update_state(call_id, state)
1528
- req_log.debug("state_updated_with_summary")
1529
- except Exception as e:
1530
- req_log.warning("state_update_failed", error=str(e))
1531
-
1532
- # Call the summary handler with the summary and the full body
1533
- try:
1534
- if summary:
1535
- self.on_summary(summary, body)
1536
- req_log.debug("summary_handler_called_successfully")
1537
- else:
1538
- # If no summary found but still want to process the data
1539
- self.on_summary(None, body)
1540
- req_log.debug("summary_handler_called_with_null_summary")
1541
- except Exception as e:
1542
- req_log.error("error_in_summary_handler", error=str(e), traceback=traceback.format_exc())
1543
-
1544
- # Return success
1545
- req_log.info("request_successful")
1546
- return {"success": True}
1547
- except Exception as e:
1548
- req_log.error("request_failed", error=str(e), traceback=traceback.format_exc())
1549
- return Response(
1550
- content=json.dumps({"error": str(e)}),
1551
- status_code=500,
1552
- media_type="application/json"
1553
- )
1554
-
1555
- def _find_summary_in_post_data(self, body, logger):
1556
- """
1557
- Extensive search for the summary in the post data
1558
-
1559
- Args:
1560
- body: The POST request body
1561
- logger: The logger instance to use
1562
-
1563
- Returns:
1564
- The summary if found, None otherwise
1565
- """
1566
- summary = None
1567
-
1568
- # Check all the locations where the summary might be found
1569
-
1570
- # 1. First check post_prompt_data.parsed array (new standard location)
1571
- post_prompt_data = body.get("post_prompt_data", {})
1572
- if post_prompt_data:
1573
- if not self._suppress_logs:
1574
- logger.debug("checking_post_prompt_data", data_type=type(post_prompt_data).__name__)
1575
-
1576
- # Check for parsed array first (this is the most common location)
1577
- if isinstance(post_prompt_data, dict) and "parsed" in post_prompt_data:
1578
- parsed = post_prompt_data.get("parsed")
1579
- if isinstance(parsed, list) and len(parsed) > 0:
1580
- # The summary is the first item in the parsed array
1581
- summary = parsed[0]
1582
- print("SUMMARY_FOUND: " + json.dumps(summary))
1583
- return summary
1584
-
1585
- # Check raw field - it might contain a JSON string
1586
- if isinstance(post_prompt_data, dict) and "raw" in post_prompt_data:
1587
- raw = post_prompt_data.get("raw")
1588
- if isinstance(raw, str):
1589
- try:
1590
- # Try to parse the raw field as JSON
1591
- parsed_raw = json.loads(raw)
1592
- if not self._suppress_logs:
1593
- print("SUMMARY_FOUND_RAW: " + json.dumps(parsed_raw))
1594
- return parsed_raw
1595
- except:
1596
- pass
1597
-
1598
- # Direct access to substituted field
1599
- if isinstance(post_prompt_data, dict) and "substituted" in post_prompt_data:
1600
- summary = post_prompt_data.get("substituted")
1601
- if not self._suppress_logs:
1602
- print("SUMMARY_FOUND_SUBSTITUTED: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_SUBSTITUTED: {summary}")
1603
- return summary
1604
-
1605
- # Check for nested data structure
1606
- if isinstance(post_prompt_data, dict) and "data" in post_prompt_data:
1607
- data = post_prompt_data.get("data")
1608
- if isinstance(data, dict):
1609
- if "substituted" in data:
1610
- summary = data.get("substituted")
1611
- if not self._suppress_logs:
1612
- print("SUMMARY_FOUND_DATA_SUBSTITUTED: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_DATA_SUBSTITUTED: {summary}")
1613
- return summary
1614
-
1615
- # Try text field
1616
- if "text" in data:
1617
- summary = data.get("text")
1618
- if not self._suppress_logs:
1619
- print("SUMMARY_FOUND_DATA_TEXT: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_DATA_TEXT: {summary}")
1620
- return summary
1621
-
1622
- # 2. Check ai_response (legacy location)
1623
- ai_response = body.get("ai_response", {})
1624
- if ai_response and isinstance(ai_response, dict):
1625
- if "summary" in ai_response:
1626
- summary = ai_response.get("summary")
1627
- if not self._suppress_logs:
1628
- print("SUMMARY_FOUND_AI_RESPONSE: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_AI_RESPONSE: {summary}")
1629
- return summary
1630
-
1631
- # 3. Look for direct fields at the top level
1632
- for field in ["substituted", "summary", "content", "text", "result", "output"]:
1633
- if field in body:
1634
- summary = body.get(field)
1635
- if not self._suppress_logs:
1636
- print(f"SUMMARY_FOUND_TOP_LEVEL_{field}: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_TOP_LEVEL_{field}: {summary}")
1637
- return summary
1638
-
1639
- # 4. Recursively search for summary-like fields up to 3 levels deep
1640
- def recursive_search(data, path="", depth=0):
1641
- if depth > 3 or not isinstance(data, dict): # Limit recursion depth
1642
- return None
1643
-
1644
- # Check if any key looks like it might contain a summary
1645
- for key in data.keys():
1646
- if key.lower() in ["summary", "substituted", "output", "result", "content", "text"]:
1647
- value = data.get(key)
1648
- curr_path = f"{path}.{key}" if path else key
1649
- if not self._suppress_logs:
1650
- logger.info(f"potential_summary_found_at_{curr_path}",
1651
- value_type=type(value).__name__)
1652
- if isinstance(value, (str, dict, list)):
1653
- return value
1654
-
1655
- # Recursively check nested dictionaries
1656
- for key, value in data.items():
1657
- if isinstance(value, dict):
1658
- curr_path = f"{path}.{key}" if path else key
1659
- result = recursive_search(value, curr_path, depth + 1)
1660
- if result:
1661
- return result
1662
-
1663
- return None
1664
-
1665
- # Perform recursive search
1666
- recursive_result = recursive_search(body)
1667
- if recursive_result:
1668
- summary = recursive_result
1669
- if not self._suppress_logs:
1670
- print("SUMMARY_FOUND_RECURSIVE: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_RECURSIVE: {summary}")
1671
- return summary
1672
-
1673
- # No summary found
1674
- if not self._suppress_logs:
1675
- print("NO_SUMMARY_FOUND")
1676
- return None
1677
-
1678
- def _register_routes(self, app):
1679
- """Register all routes for the agent, with both slash variants and both HTTP methods"""
1680
-
1681
- self.log.info("registering_routes", path=self.route)
1682
-
1683
- # Root endpoint - without trailing slash
1684
- @app.get(f"{self.route}")
1685
- @app.post(f"{self.route}")
1686
- async def handle_root_no_slash(request: Request):
1687
- return await self._handle_root_request(request)
1688
-
1689
- # Root endpoint - with trailing slash
1690
- @app.get(f"{self.route}/")
1691
- @app.post(f"{self.route}/")
1692
- async def handle_root_with_slash(request: Request):
1693
- return await self._handle_root_request(request)
1694
-
1695
- # Debug endpoint - without trailing slash
1696
- @app.get(f"{self.route}/debug")
1697
- @app.post(f"{self.route}/debug")
1698
- async def handle_debug_no_slash(request: Request):
1699
- return await self._handle_debug_request(request)
1700
-
1701
- # Debug endpoint - with trailing slash
1702
- @app.get(f"{self.route}/debug/")
1703
- @app.post(f"{self.route}/debug/")
1704
- async def handle_debug_with_slash(request: Request):
1705
- return await self._handle_debug_request(request)
1706
-
1707
- # SWAIG endpoint - without trailing slash
1708
- @app.get(f"{self.route}/swaig")
1709
- @app.post(f"{self.route}/swaig")
1710
- async def handle_swaig_no_slash(request: Request):
1711
- return await self._handle_swaig_request(request)
1712
-
1713
- # SWAIG endpoint - with trailing slash
1714
- @app.get(f"{self.route}/swaig/")
1715
- @app.post(f"{self.route}/swaig/")
1716
- async def handle_swaig_with_slash(request: Request):
1717
- return await self._handle_swaig_request(request)
1718
-
1719
- # Post-prompt endpoint - without trailing slash
1720
- @app.get(f"{self.route}/post_prompt")
1721
- @app.post(f"{self.route}/post_prompt")
1722
- async def handle_post_prompt_no_slash(request: Request):
1723
- return await self._handle_post_prompt_request(request)
1724
-
1725
- # Post-prompt endpoint - with trailing slash
1726
- @app.get(f"{self.route}/post_prompt/")
1727
- @app.post(f"{self.route}/post_prompt/")
1728
- async def handle_post_prompt_with_slash(request: Request):
1729
- return await self._handle_post_prompt_request(request)
1579
+ language["voice"] = voice_part
1580
+ language["engine"] = engine_part
1581
+ language["model"] = model_part
1582
+ except ValueError:
1583
+ # If parsing fails, use the voice string as-is
1584
+ language["voice"] = voice
1585
+ else:
1586
+ # Simple voice string
1587
+ language["voice"] = voice
1730
1588
 
1731
- # Register routes for all routing callbacks
1732
- if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
1733
- for callback_path, callback_fn in self._routing_callbacks.items():
1734
- # Skip the root path as it's already handled
1735
- if callback_path == "/":
1736
- continue
1737
-
1738
- # Register the endpoint without trailing slash
1739
- callback_route = callback_path
1740
- self.log.info("registering_callback_route", path=callback_route)
1741
-
1742
- @app.get(callback_route)
1743
- @app.post(callback_route)
1744
- async def handle_callback_no_slash(request: Request, path_param=callback_route):
1745
- # Store the callback path in request state for _handle_root_request to use
1746
- request.state.callback_path = path_param
1747
- return await self._handle_root_request(request)
1748
-
1749
- # Register the endpoint with trailing slash if it doesn't already have one
1750
- if not callback_route.endswith('/'):
1751
- slash_route = f"{callback_route}/"
1752
-
1753
- @app.get(slash_route)
1754
- @app.post(slash_route)
1755
- async def handle_callback_with_slash(request: Request, path_param=callback_route):
1756
- # Store the callback path in request state for _handle_root_request to use
1757
- request.state.callback_path = path_param
1758
- return await self._handle_root_request(request)
1759
-
1760
- # Log all registered routes
1761
- routes = [f"{route.methods} {route.path}" for route in app.routes]
1762
- self.log.debug("routes_registered", routes=routes)
1763
-
1764
- def _register_class_decorated_tools(self):
1765
- """
1766
- Register all tools decorated with @AgentBase.tool
1767
- """
1768
- for name in dir(self):
1769
- attr = getattr(self, name)
1770
- if callable(attr) and hasattr(attr, "_is_tool"):
1771
- # Get tool parameters
1772
- tool_name = getattr(attr, "_tool_name", name)
1773
- tool_params = getattr(attr, "_tool_params", {})
1774
-
1775
- # Extract parameters
1776
- parameters = tool_params.get("parameters", {})
1777
- description = tool_params.get("description", attr.__doc__ or f"Function {tool_name}")
1778
- secure = tool_params.get("secure", True)
1779
- fillers = tool_params.get("fillers", None)
1780
-
1781
- # Create a wrapper that binds the method to this instance
1782
- def make_wrapper(method):
1783
- @functools.wraps(method)
1784
- def wrapper(args, raw_data=None):
1785
- return method(args, raw_data)
1786
- return wrapper
1787
-
1788
- # Register the tool
1789
- self.define_tool(
1790
- name=tool_name,
1791
- description=description,
1792
- parameters=parameters,
1793
- handler=make_wrapper(attr),
1794
- secure=secure,
1795
- fillers=fillers
1796
- )
1589
+ # Add fillers if provided
1590
+ if speech_fillers and function_fillers:
1591
+ language["speech_fillers"] = speech_fillers
1592
+ language["function_fillers"] = function_fillers
1593
+ elif speech_fillers or function_fillers:
1594
+ # If only one type of fillers is provided, use the deprecated "fillers" field
1595
+ fillers = speech_fillers or function_fillers
1596
+ language["fillers"] = fillers
1597
+
1598
+ self._languages.append(language)
1599
+ return self
1797
1600
 
1798
- # State Management Methods
1799
- def get_state(self, call_id: str) -> Optional[Dict[str, Any]]:
1601
+ def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
1800
1602
  """
1801
- Get the state for a call
1603
+ Set all language configurations at once
1802
1604
 
1803
1605
  Args:
1804
- call_id: Call ID to get state for
1606
+ languages: List of language configuration dictionaries
1805
1607
 
1806
1608
  Returns:
1807
- Call state or None if not found
1609
+ Self for method chaining
1808
1610
  """
1809
- try:
1810
- if hasattr(self._state_manager, 'get_state'):
1811
- return self._state_manager.get_state(call_id)
1812
- return None
1813
- except Exception as e:
1814
- logger.warning("get_state_failed", error=str(e))
1815
- return None
1816
-
1817
- def set_state(self, call_id: str, data: Dict[str, Any]) -> bool:
1611
+ if languages and isinstance(languages, list):
1612
+ self._languages = languages
1613
+ return self
1614
+
1615
+ def add_pronunciation(self,
1616
+ replace: str,
1617
+ with_text: str,
1618
+ ignore_case: bool = False) -> 'AgentBase':
1818
1619
  """
1819
- Set the state for a call
1620
+ Add a pronunciation rule to help the AI speak certain words correctly
1820
1621
 
1821
1622
  Args:
1822
- call_id: Call ID to set state for
1823
- data: State data to set
1623
+ replace: The expression to replace
1624
+ with_text: The phonetic spelling to use instead
1625
+ ignore_case: Whether to ignore case when matching
1824
1626
 
1825
1627
  Returns:
1826
- True if state was set, False otherwise
1827
- """
1828
- try:
1829
- if hasattr(self._state_manager, 'set_state'):
1830
- return self._state_manager.set_state(call_id, data)
1831
- return False
1832
- except Exception as e:
1833
- logger.warning("set_state_failed", error=str(e))
1834
- return False
1835
-
1836
- def update_state(self, call_id: str, data: Dict[str, Any]) -> bool:
1628
+ Self for method chaining
1837
1629
  """
1838
- Update the state for a call
1839
-
1840
- Args:
1841
- call_id: Call ID to update state for
1842
- data: State data to update
1630
+ if replace and with_text:
1631
+ rule = {
1632
+ "replace": replace,
1633
+ "with": with_text
1634
+ }
1635
+ if ignore_case:
1636
+ rule["ignore_case"] = True
1843
1637
 
1844
- Returns:
1845
- True if state was updated, False otherwise
1846
- """
1847
- try:
1848
- if hasattr(self._state_manager, 'update_state'):
1849
- return self._state_manager.update_state(call_id, data)
1850
- return self.set_state(call_id, data)
1851
- except Exception as e:
1852
- logger.warning("update_state_failed", error=str(e))
1853
- return False
1854
-
1855
- def clear_state(self, call_id: str) -> bool:
1638
+ self._pronounce.append(rule)
1639
+ return self
1640
+
1641
+ def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
1856
1642
  """
1857
- Clear the state for a call
1643
+ Set all pronunciation rules at once
1858
1644
 
1859
1645
  Args:
1860
- call_id: Call ID to clear state for
1646
+ pronunciations: List of pronunciation rule dictionaries
1861
1647
 
1862
1648
  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
1649
+ Self for method chaining
1879
1650
  """
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
1651
+ if pronunciations and isinstance(pronunciations, list):
1652
+ self._pronounce = pronunciations
1653
+ return self
1887
1654
 
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):
1655
+ def set_param(self, key: str, value: Any) -> 'AgentBase':
1911
1656
  """
1912
- Handler for the startup hook
1657
+ Set a single AI parameter
1913
1658
 
1914
1659
  Args:
1915
- args: Function arguments
1916
- raw_data: Raw request data
1660
+ key: Parameter name
1661
+ value: Parameter value
1917
1662
 
1918
1663
  Returns:
1919
- Function result
1664
+ Self for method chaining
1920
1665
  """
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):
1666
+ if key:
1667
+ self._params[key] = value
1668
+ return self
1669
+
1670
+ def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
1938
1671
  """
1939
- Handler for the hangup hook
1672
+ Set multiple AI parameters at once
1940
1673
 
1941
1674
  Args:
1942
- args: Function arguments
1943
- raw_data: Raw request data
1675
+ params: Dictionary of parameter name/value pairs
1944
1676
 
1945
1677
  Returns:
1946
- Function result
1678
+ Self for method chaining
1947
1679
  """
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")
1680
+ if params and isinstance(params, dict):
1681
+ self._params.update(params)
1682
+ return self
1962
1683
 
1963
- def set_post_prompt(self, text: str) -> 'AgentBase':
1684
+ def set_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
1964
1685
  """
1965
- Set the post-prompt for the agent
1686
+ Set the global data available to the AI throughout the conversation
1966
1687
 
1967
1688
  Args:
1968
- text: Post-prompt text
1689
+ data: Dictionary of global data
1969
1690
 
1970
1691
  Returns:
1971
1692
  Self for method chaining
1972
1693
  """
1973
- self._post_prompt = text
1694
+ if data and isinstance(data, dict):
1695
+ self._global_data = data
1974
1696
  return self
1975
-
1976
- def set_auto_answer(self, enabled: bool) -> 'AgentBase':
1697
+
1698
+ def update_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
1977
1699
  """
1978
- Set whether to automatically answer calls
1700
+ Update the global data with new values
1979
1701
 
1980
1702
  Args:
1981
- enabled: Whether to auto-answer
1703
+ data: Dictionary of global data to update
1982
1704
 
1983
1705
  Returns:
1984
1706
  Self for method chaining
1985
1707
  """
1986
- self._auto_answer = enabled
1708
+ if data and isinstance(data, dict):
1709
+ self._global_data.update(data)
1987
1710
  return self
1988
-
1989
- def set_call_recording(self,
1990
- enabled: bool,
1991
- format: str = "mp4",
1992
- stereo: bool = True) -> 'AgentBase':
1711
+
1712
+ def set_native_functions(self, function_names: List[str]) -> 'AgentBase':
1993
1713
  """
1994
- Set call recording parameters
1714
+ Set the list of native functions to enable
1995
1715
 
1996
1716
  Args:
1997
- enabled: Whether to record calls
1998
- format: Recording format
1999
- stereo: Whether to record in stereo
1717
+ function_names: List of native function names
2000
1718
 
2001
1719
  Returns:
2002
1720
  Self for method chaining
2003
1721
  """
2004
- self._record_call = enabled
2005
- self._record_format = format
2006
- self._record_stereo = stereo
1722
+ if function_names and isinstance(function_names, list):
1723
+ self.native_functions = [name for name in function_names if isinstance(name, str)]
2007
1724
  return self
2008
-
2009
- def add_native_function(self, function_name: str) -> 'AgentBase':
1725
+
1726
+ def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
2010
1727
  """
2011
- Add a native function to the list of enabled native functions
1728
+ Add a remote function include to the SWAIG configuration
2012
1729
 
2013
1730
  Args:
2014
- function_name: Name of native function to enable
1731
+ url: URL to fetch remote functions from
1732
+ functions: List of function names to include
1733
+ meta_data: Optional metadata to include with the function include
2015
1734
 
2016
1735
  Returns:
2017
1736
  Self for method chaining
2018
1737
  """
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)
1738
+ if url and functions and isinstance(functions, list):
1739
+ include = {
1740
+ "url": url,
1741
+ "functions": functions
1742
+ }
1743
+ if meta_data and isinstance(meta_data, dict):
1744
+ include["meta_data"] = meta_data
1745
+
1746
+ self._function_includes.append(include)
2024
1747
  return self
2025
1748
 
2026
- def remove_native_function(self, function_name: str) -> 'AgentBase':
1749
+ def set_function_includes(self, includes: List[Dict[str, Any]]) -> 'AgentBase':
2027
1750
  """
2028
- Remove a native function from the SWAIG object
1751
+ Set the complete list of function includes
2029
1752
 
2030
1753
  Args:
2031
- function_name: Name of the native function
1754
+ includes: List of include objects, each with url and functions properties
2032
1755
 
2033
1756
  Returns:
2034
1757
  Self for method chaining
2035
1758
  """
2036
- if function_name in self.native_functions:
2037
- self.native_functions.remove(function_name)
1759
+ if includes and isinstance(includes, list):
1760
+ # Validate each include has required properties
1761
+ valid_includes = []
1762
+ for include in includes:
1763
+ if isinstance(include, dict) and "url" in include and "functions" in include:
1764
+ if isinstance(include["functions"], list):
1765
+ valid_includes.append(include)
1766
+
1767
+ self._function_includes = valid_includes
2038
1768
  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
1769
 
2049
- def has_section(self, title: str) -> bool:
1770
+ def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
2050
1771
  """
2051
- Check if a section exists in the prompt
1772
+ Enable SIP-based routing for this agent
1773
+
1774
+ This allows the agent to automatically route SIP requests based on SIP usernames.
1775
+ When enabled, an endpoint at the specified path is automatically created
1776
+ that will handle SIP requests and deliver them to this agent.
2052
1777
 
2053
1778
  Args:
2054
- title: Section title
2055
-
1779
+ auto_map: Whether to automatically map common SIP usernames to this agent
1780
+ (based on the agent name and route path)
1781
+ path: The path to register the SIP routing endpoint (default: "/sip")
1782
+
2056
1783
  Returns:
2057
- True if the section exists, False otherwise
1784
+ Self for method chaining
2058
1785
  """
2059
- if not self._use_pom or not self.pom:
2060
- return False
1786
+ # Create a routing callback that handles SIP usernames
1787
+ def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
1788
+ # Extract SIP username from the request body
1789
+ sip_username = self.extract_sip_username(body)
1790
+
1791
+ if sip_username:
1792
+ self.log.info("sip_username_extracted", username=sip_username)
1793
+
1794
+ # Check if this username is registered with this agent
1795
+ if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
1796
+ self.log.info("sip_username_matched", username=sip_username)
1797
+ # This route is already being handled by the agent, no need to redirect
1798
+ return None
1799
+ else:
1800
+ self.log.info("sip_username_not_matched", username=sip_username)
1801
+ # Not registered with this agent, let routing continue
1802
+
1803
+ return None
2061
1804
 
2062
- return self.pom.has_section(title)
1805
+ # Register the callback with the SWMLService, specifying the path
1806
+ self.register_routing_callback(sip_routing_callback, path=path)
2063
1807
 
2064
- def on_swml_request(self, request_data: Optional[dict] = None) -> Optional[dict]:
2065
- """
2066
- Called when SWML is requested, with request data when available.
1808
+ # Auto-map common usernames if requested
1809
+ if auto_map:
1810
+ self.auto_map_sip_usernames()
1811
+
1812
+ return self
2067
1813
 
2068
- Subclasses can override this to inspect or modify SWML based on the request.
1814
+ def register_sip_username(self, sip_username: str) -> 'AgentBase':
1815
+ """
1816
+ Register a SIP username that should be routed to this agent
2069
1817
 
2070
1818
  Args:
2071
- request_data: Optional dictionary containing the parsed POST body
1819
+ sip_username: SIP username to register
2072
1820
 
2073
1821
  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
1822
+ Self for method chaining
2086
1823
  """
2087
- import uvicorn
2088
-
2089
- # Create a FastAPI app with no automatic redirects
2090
- app = FastAPI(redirect_slashes=False)
2091
-
2092
- # Register all routes
2093
- self._register_routes(app)
2094
-
2095
- host = host or self.host
2096
- port = port or self.port
2097
-
2098
- # Print the auth credentials with source
2099
- username, password, source = self.get_basic_auth_credentials(include_source=True)
2100
- self.log.info("starting_server",
2101
- url=f"http://{host}:{port}{self.route}",
2102
- username=username,
2103
- password="*" * len(password),
2104
- auth_source=source)
2105
-
2106
- print(f"Agent '{self.name}' is available at:")
2107
- print(f"URL: http://{host}:{port}{self.route}")
2108
- print(f"Basic Auth: {username}:{password} (source: {source})")
2109
-
2110
- # Check if SIP usernames are registered and print that info
2111
- if hasattr(self, '_sip_usernames') and self._sip_usernames:
2112
- print(f"Registered SIP usernames: {', '.join(sorted(self._sip_usernames))}")
2113
-
2114
- # Check if callback endpoints are registered and print them
2115
- if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
2116
- for path in sorted(self._routing_callbacks.keys()):
2117
- if hasattr(self, '_sip_usernames') and path == "/sip":
2118
- print(f"SIP endpoint: http://{host}:{port}{path}")
2119
- else:
2120
- print(f"Callback endpoint: http://{host}:{port}{path}")
1824
+ if not hasattr(self, '_sip_usernames'):
1825
+ self._sip_usernames = set()
1826
+
1827
+ self._sip_usernames.add(sip_username.lower())
1828
+ self.log.info("sip_username_registered", username=sip_username)
2121
1829
 
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"
1830
+ return self
2126
1831
 
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
- # ----------------------------------------------------------------------
1832
+ def auto_map_sip_usernames(self) -> 'AgentBase':
1833
+ """
1834
+ Automatically register common SIP usernames based on this agent's
1835
+ name and route
1836
+
1837
+ Returns:
1838
+ Self for method chaining
1839
+ """
1840
+ # Register username based on agent name
1841
+ clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
1842
+ if clean_name:
1843
+ self.register_sip_username(clean_name)
1844
+
1845
+ # Register username based on route (without slashes)
1846
+ clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
1847
+ if clean_route and clean_route != clean_name:
1848
+ self.register_sip_username(clean_route)
1849
+
1850
+ # Register common variations if they make sense
1851
+ if len(clean_name) > 3:
1852
+ # Register without vowels
1853
+ no_vowels = re.sub(r'[aeiou]', '', clean_name)
1854
+ if no_vowels != clean_name and len(no_vowels) > 2:
1855
+ self.register_sip_username(no_vowels)
1856
+
1857
+ return self
2143
1858
 
2144
- def add_hint(self, hint: str) -> 'AgentBase':
1859
+ def set_web_hook_url(self, url: str) -> 'AgentBase':
2145
1860
  """
2146
- Add a simple string hint to help the AI agent understand certain words better
1861
+ Override the default web_hook_url with a supplied URL string
2147
1862
 
2148
1863
  Args:
2149
- hint: The hint string to add
1864
+ url: The URL to use for SWAIG function webhooks
2150
1865
 
2151
1866
  Returns:
2152
1867
  Self for method chaining
2153
1868
  """
2154
- if isinstance(hint, str) and hint:
2155
- self._hints.append(hint)
1869
+ self._web_hook_url_override = url
2156
1870
  return self
2157
-
2158
- def add_hints(self, hints: List[str]) -> 'AgentBase':
1871
+
1872
+ def set_post_prompt_url(self, url: str) -> 'AgentBase':
2159
1873
  """
2160
- Add multiple string hints
1874
+ Override the default post_prompt_url with a supplied URL string
2161
1875
 
2162
1876
  Args:
2163
- hints: List of hint strings
1877
+ url: The URL to use for post-prompt summary delivery
2164
1878
 
2165
1879
  Returns:
2166
1880
  Self for method chaining
2167
1881
  """
2168
- if hints and isinstance(hints, list):
2169
- for hint in hints:
2170
- if isinstance(hint, str) and hint:
2171
- self._hints.append(hint)
1882
+ self._post_prompt_url_override = url
2172
1883
  return self
2173
1884
 
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
1885
+ async def _handle_swaig_request(self, request: Request, response: Response):
1886
+ """Handle GET/POST requests to the SWAIG endpoint"""
1887
+ req_log = self.log.bind(
1888
+ endpoint="swaig",
1889
+ method=request.method,
1890
+ path=request.url.path
1891
+ )
1892
+
1893
+ req_log.debug("endpoint_called")
1894
+
1895
+ try:
1896
+ # Check auth
1897
+ if not self._check_basic_auth(request):
1898
+ req_log.warning("unauthorized_access_attempt")
1899
+ response.headers["WWW-Authenticate"] = "Basic"
1900
+ return Response(
1901
+ content=json.dumps({"error": "Unauthorized"}),
1902
+ status_code=401,
1903
+ headers={"WWW-Authenticate": "Basic"},
1904
+ media_type="application/json"
1905
+ )
1906
+
1907
+ # Handle differently based on method
1908
+ if request.method == "GET":
1909
+ # For GET requests, return the SWML document (same as root endpoint)
1910
+ call_id = request.query_params.get("call_id")
1911
+ swml = self._render_swml(call_id)
1912
+ req_log.debug("swml_rendered", swml_size=len(swml))
1913
+ return Response(
1914
+ content=swml,
1915
+ media_type="application/json"
1916
+ )
1917
+
1918
+ # For POST requests, process SWAIG function calls
1919
+ try:
1920
+ body = await request.json()
1921
+ req_log.debug("request_body_received", body_size=len(str(body)))
1922
+ if body:
1923
+ req_log.debug("request_body", body=json.dumps(body))
1924
+ except Exception as e:
1925
+ req_log.error("error_parsing_request_body", error=str(e))
1926
+ body = {}
1927
+
1928
+ # Extract function name
1929
+ function_name = body.get("function")
1930
+ if not function_name:
1931
+ req_log.warning("missing_function_name")
1932
+ return Response(
1933
+ content=json.dumps({"error": "Missing function name"}),
1934
+ status_code=400,
1935
+ media_type="application/json"
1936
+ )
1937
+
1938
+ # Add function info to logger
1939
+ req_log = req_log.bind(function=function_name)
1940
+ req_log.debug("function_call_received")
1941
+
1942
+ # Extract arguments
1943
+ args = {}
1944
+ if "argument" in body and isinstance(body["argument"], dict):
1945
+ if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
1946
+ args = body["argument"]["parsed"][0]
1947
+ req_log.debug("parsed_arguments", args=json.dumps(args))
1948
+ elif "raw" in body["argument"]:
1949
+ try:
1950
+ args = json.loads(body["argument"]["raw"])
1951
+ req_log.debug("raw_arguments_parsed", args=json.dumps(args))
1952
+ except Exception as e:
1953
+ req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
1954
+
1955
+ # Get call_id from body
1956
+ call_id = body.get("call_id")
1957
+ if call_id:
1958
+ req_log = req_log.bind(call_id=call_id)
1959
+ req_log.debug("call_id_identified")
1960
+
1961
+ # SECURITY BYPASS FOR DEBUGGING - make all functions work regardless of token
1962
+ # We'll log the attempt but allow it through
1963
+ token = request.query_params.get("token")
1964
+ if token:
1965
+ req_log.debug("token_found", token_length=len(token))
1966
+
1967
+ # Check token validity but don't reject the request
1968
+ if hasattr(self, '_session_manager') and function_name in self._swaig_functions:
1969
+ is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
1970
+ if is_valid:
1971
+ req_log.debug("token_valid")
1972
+ else:
1973
+ # Log but continue anyway for debugging
1974
+ req_log.warning("token_invalid")
1975
+ if hasattr(self._session_manager, 'debug_token'):
1976
+ debug_info = self._session_manager.debug_token(token)
1977
+ req_log.debug("token_debug", debug=json.dumps(debug_info))
1978
+
1979
+ # Call the function
1980
+ try:
1981
+ result = self.on_function_call(function_name, args, body)
1982
+
1983
+ # Convert result to dict if needed
1984
+ if isinstance(result, SwaigFunctionResult):
1985
+ result_dict = result.to_dict()
1986
+ elif isinstance(result, dict):
1987
+ result_dict = result
1988
+ else:
1989
+ result_dict = {"response": str(result)}
1990
+
1991
+ req_log.info("function_executed_successfully")
1992
+ req_log.debug("function_result", result=json.dumps(result_dict))
1993
+ return result_dict
1994
+ except Exception as e:
1995
+ req_log.error("function_execution_error", error=str(e))
1996
+ return {"error": str(e), "function": function_name}
1997
+
1998
+ except Exception as e:
1999
+ req_log.error("request_failed", error=str(e))
2000
+ return Response(
2001
+ content=json.dumps({"error": str(e)}),
2002
+ status_code=500,
2003
+ media_type="application/json"
2004
+ )
2005
+
2006
+ async def _handle_root_request(self, request: Request):
2007
+ """Handle GET/POST requests to the root endpoint"""
2008
+ # Auto-detect proxy on first request if not explicitly configured
2009
+ if not getattr(self, '_proxy_detection_done', False) and not getattr(self, '_proxy_url_base', None):
2010
+ # Check for proxy headers
2011
+ forwarded_host = request.headers.get("X-Forwarded-Host")
2012
+ forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
2013
+
2014
+ if forwarded_host:
2015
+ # Set proxy_url_base on both self and super() to ensure it's shared
2016
+ self._proxy_url_base = f"{forwarded_proto}://{forwarded_host}"
2017
+ if hasattr(super(), '_proxy_url_base'):
2018
+ # Ensure parent class has the same proxy URL
2019
+ super()._proxy_url_base = self._proxy_url_base
2020
+
2021
+ self.log.info("proxy_auto_detected", proxy_url_base=self._proxy_url_base,
2022
+ source="X-Forwarded headers")
2023
+ self._proxy_detection_done = True
2024
+
2025
+ # Also set the detection flag on parent
2026
+ if hasattr(super(), '_proxy_detection_done'):
2027
+ super()._proxy_detection_done = True
2028
+ # If no explicit proxy headers, try the parent class detection method if it exists
2029
+ elif hasattr(super(), '_detect_proxy_from_request'):
2030
+ # Call the parent's detection method
2031
+ super()._detect_proxy_from_request(request)
2032
+ # Copy the result to our class
2033
+ if hasattr(super(), '_proxy_url_base') and getattr(super(), '_proxy_url_base', None):
2034
+ self._proxy_url_base = super()._proxy_url_base
2035
+ self._proxy_detection_done = True
2036
+
2037
+ # Check if this is a callback path request
2038
+ callback_path = getattr(request.state, "callback_path", None)
2039
+
2040
+ req_log = self.log.bind(
2041
+ endpoint="root" if not callback_path else f"callback:{callback_path}",
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
+ return Response(
2053
+ content=json.dumps({"error": "Unauthorized"}),
2054
+ status_code=401,
2055
+ headers={"WWW-Authenticate": "Basic"},
2056
+ media_type="application/json"
2057
+ )
2058
+
2059
+ # Try to parse request body for POST
2060
+ body = {}
2061
+ call_id = None
2062
+
2063
+ if request.method == "POST":
2064
+ # Check if body is empty first
2065
+ raw_body = await request.body()
2066
+ if raw_body:
2067
+ try:
2068
+ body = await request.json()
2069
+ req_log.debug("request_body_received", body_size=len(str(body)))
2070
+ if body:
2071
+ req_log.debug("request_body")
2072
+ except Exception as e:
2073
+ req_log.warning("error_parsing_request_body", error=str(e))
2074
+ # Continue processing with empty body
2075
+ body = {}
2076
+ else:
2077
+ req_log.debug("empty_request_body")
2078
+
2079
+ # Get call_id from body if present
2080
+ call_id = body.get("call_id")
2081
+ else:
2082
+ # Get call_id from query params for GET
2083
+ call_id = request.query_params.get("call_id")
2084
+
2085
+ # Add call_id to logger if any
2086
+ if call_id:
2087
+ req_log = req_log.bind(call_id=call_id)
2088
+ req_log.debug("call_id_identified")
2089
+
2090
+ # Check if this is a callback path and we need to apply routing
2091
+ if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
2092
+ callback_fn = self._routing_callbacks[callback_path]
2093
+
2094
+ if request.method == "POST" and body:
2095
+ req_log.debug("processing_routing_callback", path=callback_path)
2096
+ # Call the routing callback
2097
+ try:
2098
+ route = callback_fn(request, body)
2099
+ if route is not None:
2100
+ req_log.info("routing_request", route=route)
2101
+ # Return a redirect to the new route
2102
+ return Response(
2103
+ status_code=307, # 307 Temporary Redirect preserves the method and body
2104
+ headers={"Location": route}
2105
+ )
2106
+ except Exception as e:
2107
+ req_log.error("error_in_routing_callback", error=str(e))
2108
+
2109
+ # Allow subclasses to inspect/modify the request
2110
+ modifications = None
2111
+ if body:
2112
+ try:
2113
+ modifications = self.on_swml_request(body, callback_path)
2114
+ if modifications:
2115
+ req_log.debug("request_modifications_applied")
2116
+ except Exception as e:
2117
+ req_log.error("error_in_request_modifier", error=str(e))
2118
+
2119
+ # Render SWML
2120
+ swml = self._render_swml(call_id)
2121
+ req_log.debug("swml_rendered", swml_size=len(swml))
2122
+
2123
+ # Return as JSON
2124
+ req_log.info("request_successful")
2125
+ return Response(
2126
+ content=swml,
2127
+ media_type="application/json"
2128
+ )
2129
+ except Exception as e:
2130
+ req_log.error("request_failed", error=str(e))
2131
+ return Response(
2132
+ content=json.dumps({"error": str(e)}),
2133
+ status_code=500,
2134
+ media_type="application/json"
2135
+ )
2136
+
2137
+ async def _handle_debug_request(self, request: Request):
2138
+ """Handle GET/POST requests to the debug endpoint"""
2139
+ req_log = self.log.bind(
2140
+ endpoint="debug",
2141
+ method=request.method,
2142
+ path=request.url.path
2143
+ )
2181
2144
 
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
2145
+ req_log.debug("endpoint_called")
2210
2146
 
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")
2147
+ try:
2148
+ # Check auth
2149
+ if not self._check_basic_auth(request):
2150
+ req_log.warning("unauthorized_access_attempt")
2151
+ return Response(
2152
+ content=json.dumps({"error": "Unauthorized"}),
2153
+ status_code=401,
2154
+ headers={"WWW-Authenticate": "Basic"},
2155
+ media_type="application/json"
2156
+ )
2220
2157
 
2221
- Returns:
2222
- Self for method chaining
2158
+ # Get call_id from either query params (GET) or body (POST)
2159
+ call_id = None
2160
+ body = {}
2223
2161
 
2224
- Examples:
2225
- # Simple voice name
2226
- agent.add_language("English", "en-US", "en-US-Neural2-F")
2162
+ if request.method == "POST":
2163
+ try:
2164
+ body = await request.json()
2165
+ req_log.debug("request_body_received", body_size=len(str(body)))
2166
+ call_id = body.get("call_id")
2167
+ except Exception as e:
2168
+ req_log.warning("error_parsing_request_body", error=str(e))
2169
+ else:
2170
+ call_id = request.query_params.get("call_id")
2227
2171
 
2228
- # Explicit parameters
2229
- agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
2172
+ # Add call_id to logger if any
2173
+ if call_id:
2174
+ req_log = req_log.bind(call_id=call_id)
2175
+ req_log.debug("call_id_identified")
2176
+
2177
+ # Allow subclasses to inspect/modify the request
2178
+ modifications = None
2179
+ if body:
2180
+ modifications = self.on_swml_request(body)
2181
+ if modifications:
2182
+ req_log.debug("request_modifications_applied")
2183
+
2184
+ # Render SWML
2185
+ swml = self._render_swml(call_id)
2186
+ req_log.debug("swml_rendered", swml_size=len(swml))
2230
2187
 
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
- }
2188
+ # Return as JSON
2189
+ req_log.info("request_successful")
2190
+ return Response(
2191
+ content=swml,
2192
+ media_type="application/json",
2193
+ headers={"X-Debug": "true"}
2194
+ )
2195
+ except Exception as e:
2196
+ req_log.error("request_failed", error=str(e))
2197
+ return Response(
2198
+ content=json.dumps({"error": str(e)}),
2199
+ status_code=500,
2200
+ media_type="application/json"
2201
+ )
2202
+
2203
+ async def _handle_post_prompt_request(self, request: Request):
2204
+ """Handle GET/POST requests to the post_prompt endpoint"""
2205
+ req_log = self.log.bind(
2206
+ endpoint="post_prompt",
2207
+ method=request.method,
2208
+ path=request.url.path
2209
+ )
2238
2210
 
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"
2211
+ # Only log if not suppressed
2212
+ if not getattr(self, '_suppress_logs', False):
2213
+ req_log.debug("endpoint_called")
2214
+
2215
+ try:
2216
+ # Check auth
2217
+ if not self._check_basic_auth(request):
2218
+ req_log.warning("unauthorized_access_attempt")
2219
+ return Response(
2220
+ content=json.dumps({"error": "Unauthorized"}),
2221
+ status_code=401,
2222
+ headers={"WWW-Authenticate": "Basic"},
2223
+ media_type="application/json"
2224
+ )
2225
+
2226
+ # Extract call_id for use with token validation
2227
+ call_id = request.query_params.get("call_id")
2228
+
2229
+ # For POST requests, try to also get call_id from body
2230
+ if request.method == "POST":
2231
+ try:
2232
+ body_text = await request.body()
2233
+ if body_text:
2234
+ body_data = json.loads(body_text)
2235
+ if call_id is None:
2236
+ call_id = body_data.get("call_id")
2237
+ # Save body_data for later use
2238
+ setattr(request, "_post_prompt_body", body_data)
2239
+ except Exception as e:
2240
+ req_log.error("error_extracting_call_id", error=str(e))
2241
+
2242
+ # If we have a call_id, add it to the logger context
2243
+ if call_id:
2244
+ req_log = req_log.bind(call_id=call_id)
2245
+
2246
+ # Check token if provided
2247
+ token = request.query_params.get("token")
2248
+ token_validated = False
2249
+
2250
+ if token:
2251
+ req_log.debug("token_found", token_length=len(token))
2252
+
2253
+ # Try to validate token, but continue processing regardless
2254
+ # for backward compatibility with existing implementations
2255
+ if call_id and hasattr(self, '_session_manager'):
2256
+ try:
2257
+ is_valid = self._session_manager.validate_tool_token("post_prompt", token, call_id)
2258
+ if is_valid:
2259
+ req_log.debug("token_valid")
2260
+ token_validated = True
2261
+ else:
2262
+ req_log.warning("invalid_token")
2263
+ # Debug information for token validation issues
2264
+ if hasattr(self._session_manager, 'debug_token'):
2265
+ debug_info = self._session_manager.debug_token(token)
2266
+ req_log.debug("token_debug", debug=json.dumps(debug_info))
2267
+ except Exception as e:
2268
+ req_log.error("token_validation_error", error=str(e))
2269
+
2270
+ # For GET requests, return the SWML document
2271
+ if request.method == "GET":
2272
+ swml = self._render_swml(call_id)
2273
+ req_log.debug("swml_rendered", swml_size=len(swml))
2274
+ return Response(
2275
+ content=swml,
2276
+ media_type="application/json"
2277
+ )
2278
+
2279
+ # For POST requests, process the post-prompt data
2249
2280
  try:
2250
- engine_voice, model_part = voice.split(":", 1)
2251
- engine_part, voice_part = engine_voice.split(".", 1)
2281
+ # Try to reuse the body we already parsed for call_id extraction
2282
+ if hasattr(request, "_post_prompt_body"):
2283
+ body = getattr(request, "_post_prompt_body")
2284
+ else:
2285
+ body = await request.json()
2252
2286
 
2253
- language["voice"] = voice_part
2254
- language["engine"] = engine_part
2255
- language["model"] = model_part
2256
- except ValueError:
2257
- # If parsing fails, use the voice string as-is
2258
- language["voice"] = voice
2259
- else:
2260
- # Simple voice string
2261
- language["voice"] = voice
2262
-
2263
- # Add fillers if provided
2264
- if speech_fillers and function_fillers:
2265
- language["speech_fillers"] = speech_fillers
2266
- language["function_fillers"] = function_fillers
2267
- elif speech_fillers or function_fillers:
2268
- # If only one type of fillers is provided, use the deprecated "fillers" field
2269
- fillers = speech_fillers or function_fillers
2270
- language["fillers"] = fillers
2271
-
2272
- self._languages.append(language)
2273
- return self
2274
-
2275
- def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
2276
- """
2277
- Set all language configurations at once
2278
-
2279
- Args:
2280
- languages: List of language configuration dictionaries
2287
+ # Only log if not suppressed
2288
+ if not getattr(self, '_suppress_logs', False):
2289
+ req_log.debug("request_body_received", body_size=len(str(body)))
2290
+ # Log the raw body directly (let the logger handle the JSON encoding)
2291
+ req_log.info("post_prompt_body", body=body)
2292
+ except Exception as e:
2293
+ req_log.error("error_parsing_request_body", error=str(e))
2294
+ body = {}
2295
+
2296
+ # Extract summary from the correct location in the request
2297
+ summary = self._find_summary_in_post_data(body, req_log)
2281
2298
 
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
2299
+ # Call the summary handler with the summary and the full body
2300
+ try:
2301
+ if summary:
2302
+ self.on_summary(summary, body)
2303
+ req_log.debug("summary_handler_called_successfully")
2304
+ else:
2305
+ # If no summary found but still want to process the data
2306
+ self.on_summary(None, body)
2307
+ req_log.debug("summary_handler_called_with_null_summary")
2308
+ except Exception as e:
2309
+ req_log.error("error_in_summary_handler", error=str(e))
2310
+
2311
+ # Return success
2312
+ req_log.info("request_successful")
2313
+ return {"success": True}
2314
+ except Exception as e:
2315
+ req_log.error("request_failed", error=str(e))
2316
+ return Response(
2317
+ content=json.dumps({"error": str(e)}),
2318
+ status_code=500,
2319
+ media_type="application/json"
2320
+ )
2321
+
2322
+ async def _handle_check_for_input_request(self, request: Request):
2323
+ """Handle GET/POST requests to the check_for_input endpoint"""
2324
+ req_log = self.log.bind(
2325
+ endpoint="check_for_input",
2326
+ method=request.method,
2327
+ path=request.url.path
2328
+ )
2295
2329
 
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
2330
+ req_log.debug("endpoint_called")
2331
+
2332
+ try:
2333
+ # Check auth
2334
+ if not self._check_basic_auth(request):
2335
+ req_log.warning("unauthorized_access_attempt")
2336
+ return Response(
2337
+ content=json.dumps({"error": "Unauthorized"}),
2338
+ status_code=401,
2339
+ headers={"WWW-Authenticate": "Basic"},
2340
+ media_type="application/json"
2341
+ )
2300
2342
 
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
2343
+ # For both GET and POST requests, process input check
2344
+ conversation_id = None
2311
2345
 
2312
- self._pronounce.append(rule)
2313
- return self
2314
-
2315
- def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
2316
- """
2317
- Set all pronunciation rules at once
2318
-
2319
- Args:
2320
- pronunciations: List of pronunciation rule dictionaries
2346
+ if request.method == "POST":
2347
+ try:
2348
+ body = await request.json()
2349
+ req_log.debug("request_body_received", body_size=len(str(body)))
2350
+ conversation_id = body.get("conversation_id")
2351
+ except Exception as e:
2352
+ req_log.error("error_parsing_request_body", error=str(e))
2353
+ else:
2354
+ conversation_id = request.query_params.get("conversation_id")
2321
2355
 
2322
- Returns:
2323
- Self for method chaining
2324
- """
2325
- if pronunciations and isinstance(pronunciations, list):
2326
- self._pronounce = pronunciations
2327
- return self
2328
-
2329
- def set_param(self, key: str, value: Any) -> 'AgentBase':
2330
- """
2331
- Set a single AI parameter
2332
-
2333
- Args:
2334
- key: Parameter name
2335
- value: Parameter value
2356
+ if not conversation_id:
2357
+ req_log.warning("missing_conversation_id")
2358
+ return Response(
2359
+ content=json.dumps({"error": "Missing conversation_id parameter"}),
2360
+ status_code=400,
2361
+ media_type="application/json"
2362
+ )
2336
2363
 
2337
- Returns:
2338
- Self for method chaining
2339
- """
2340
- if key:
2341
- self._params[key] = value
2342
- return self
2343
-
2344
- def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
2364
+ # Here you would typically check for new input in some external system
2365
+ # For this implementation, we'll return an empty result
2366
+ return {
2367
+ "status": "success",
2368
+ "conversation_id": conversation_id,
2369
+ "new_input": False,
2370
+ "messages": []
2371
+ }
2372
+ except Exception as e:
2373
+ req_log.error("request_failed", error=str(e))
2374
+ return Response(
2375
+ content=json.dumps({"error": str(e)}),
2376
+ status_code=500,
2377
+ media_type="application/json"
2378
+ )
2379
+
2380
+ def _find_summary_in_post_data(self, body, logger):
2345
2381
  """
2346
- Set multiple AI parameters at once
2382
+ Attempt to find a summary in the post-prompt response data
2347
2383
 
2348
2384
  Args:
2349
- params: Dictionary of parameter name/value pairs
2385
+ body: The request body
2386
+ logger: Logger instance
2350
2387
 
2351
2388
  Returns:
2352
- Self for method chaining
2389
+ Summary data or None if not found
2353
2390
  """
2354
- if params and isinstance(params, dict):
2355
- self._params.update(params)
2356
- return self
2391
+ if not body:
2392
+ return None
2357
2393
 
2358
- def set_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
2394
+ # Various ways to get summary data
2395
+ if "summary" in body:
2396
+ return body["summary"]
2397
+
2398
+ if "post_prompt_data" in body:
2399
+ pdata = body["post_prompt_data"]
2400
+ if isinstance(pdata, dict):
2401
+ if "parsed" in pdata and isinstance(pdata["parsed"], list) and pdata["parsed"]:
2402
+ return pdata["parsed"][0]
2403
+ elif "raw" in pdata and pdata["raw"]:
2404
+ try:
2405
+ # Try to parse JSON from raw text
2406
+ parsed = json.loads(pdata["raw"])
2407
+ return parsed
2408
+ except:
2409
+ return pdata["raw"]
2410
+
2411
+ return None
2412
+
2413
+ def _register_state_tracking_tools(self):
2359
2414
  """
2360
- Set the global data available to the AI throughout the conversation
2415
+ Register special tools for state tracking
2361
2416
 
2362
- Args:
2363
- data: Dictionary of global data
2364
-
2365
- Returns:
2366
- Self for method chaining
2417
+ This adds startup_hook and hangup_hook SWAIG functions that automatically
2418
+ activate and deactivate the session when called. These are useful for
2419
+ tracking call state and cleaning up resources when a call ends.
2367
2420
  """
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':
2421
+ # Register startup hook to activate session
2422
+ self.define_tool(
2423
+ name="startup_hook",
2424
+ description="Called when a new conversation starts to initialize state",
2425
+ parameters={},
2426
+ handler=lambda args, raw_data: self._handle_startup_hook(args, raw_data),
2427
+ secure=False # No auth needed for this system function
2428
+ )
2429
+
2430
+ # Register hangup hook to end session
2431
+ self.define_tool(
2432
+ name="hangup_hook",
2433
+ description="Called when conversation ends to clean up resources",
2434
+ parameters={},
2435
+ handler=lambda args, raw_data: self._handle_hangup_hook(args, raw_data),
2436
+ secure=False # No auth needed for this system function
2437
+ )
2438
+
2439
+ def _handle_startup_hook(self, args, raw_data):
2373
2440
  """
2374
- Update the global data with new values
2441
+ Handle the startup hook function call
2375
2442
 
2376
2443
  Args:
2377
- data: Dictionary of global data to update
2444
+ args: Function arguments (empty for this hook)
2445
+ raw_data: Raw request data containing call_id
2378
2446
 
2379
2447
  Returns:
2380
- Self for method chaining
2448
+ Success response
2381
2449
  """
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':
2450
+ call_id = raw_data.get("call_id") if raw_data else None
2451
+ if call_id:
2452
+ self.log.info("session_activated", call_id=call_id)
2453
+ self._session_manager.activate_session(call_id)
2454
+ return SwaigFunctionResult("Session activated")
2455
+ else:
2456
+ self.log.warning("session_activation_failed", error="No call_id provided")
2457
+ return SwaigFunctionResult("Failed to activate session: No call_id provided")
2458
+
2459
+ def _handle_hangup_hook(self, args, raw_data):
2387
2460
  """
2388
- Set the list of native functions to enable
2461
+ Handle the hangup hook function call
2389
2462
 
2390
2463
  Args:
2391
- function_names: List of native function names
2464
+ args: Function arguments (empty for this hook)
2465
+ raw_data: Raw request data containing call_id
2392
2466
 
2393
2467
  Returns:
2394
- Self for method chaining
2468
+ Success response
2395
2469
  """
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
2470
+ call_id = raw_data.get("call_id") if raw_data else None
2471
+ if call_id:
2472
+ self.log.info("session_ended", call_id=call_id)
2473
+ self._session_manager.end_session(call_id)
2474
+ return SwaigFunctionResult("Session ended")
2475
+ else:
2476
+ self.log.warning("session_end_failed", error="No call_id provided")
2477
+ return SwaigFunctionResult("Failed to end session: No call_id provided")
2399
2478
 
2400
- def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
2479
+ def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
2401
2480
  """
2402
- Add a remote function include to the SWAIG configuration
2481
+ Called when SWML is requested, with request data when available
2482
+
2483
+ This method overrides SWMLService's on_request to properly handle SWML generation
2484
+ for AI Agents. It forwards the call to on_swml_request for compatibility.
2403
2485
 
2404
2486
  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
2487
+ request_data: Optional dictionary containing the parsed POST body
2488
+ callback_path: Optional callback path
2408
2489
 
2409
2490
  Returns:
2410
- Self for method chaining
2491
+ None to use the default SWML rendering (which will call _render_swml)
2411
2492
  """
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
2493
+ # First try to call on_swml_request if it exists (backward compatibility)
2494
+ if hasattr(self, 'on_swml_request') and callable(getattr(self, 'on_swml_request')):
2495
+ return self.on_swml_request(request_data, callback_path)
2419
2496
 
2420
- self._function_includes.append(include)
2421
- return self
2422
-
2423
- def set_function_includes(self, includes: List[Dict[str, Any]]) -> 'AgentBase':
2497
+ # If no on_swml_request or it returned None, we'll proceed with default rendering
2498
+ # We're not returning any modifications here because _render_swml will be called
2499
+ # to generate the complete SWML document
2500
+ return None
2501
+
2502
+ def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
2424
2503
  """
2425
- Set the complete list of function includes
2504
+ Customization point for subclasses to modify SWML based on request data
2426
2505
 
2427
2506
  Args:
2428
- includes: List of include objects, each with url and functions properties
2507
+ request_data: Optional dictionary containing the parsed POST body
2508
+ callback_path: Optional callback path
2429
2509
 
2430
2510
  Returns:
2431
- Self for method chaining
2511
+ Optional dict with modifications to apply to the SWML document
2432
2512
  """
2433
- if includes and isinstance(includes, list):
2434
- # Validate each include has required properties
2435
- valid_includes = []
2436
- for include in includes:
2437
- if isinstance(include, dict) and "url" in include and "functions" in include:
2438
- if isinstance(include["functions"], list):
2439
- valid_includes.append(include)
2440
-
2441
- self._function_includes = valid_includes
2442
- return self
2513
+ # Default implementation does nothing
2514
+ return None
2443
2515
 
2444
- def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
2516
+ def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
2517
+ path: str = "/sip") -> None:
2445
2518
  """
2446
- Enable SIP-based routing for this agent
2519
+ Register a callback function that will be called to determine routing
2520
+ based on POST data.
2447
2521
 
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.
2522
+ When a routing callback is registered, an endpoint at the specified path is automatically
2523
+ created that will handle requests. This endpoint will use the callback to
2524
+ determine if the request should be processed by this service or redirected.
2451
2525
 
2452
- Args:
2453
- auto_map: Whether to automatically map common SIP usernames to this agent
2454
- (based on the agent name and route path)
2455
- path: The path to register the SIP routing endpoint (default: "/sip")
2526
+ The callback should take a request object and request body dictionary and return:
2527
+ - A route string if it should be routed to a different endpoint
2528
+ - None if normal processing should continue
2456
2529
 
2457
- Returns:
2458
- Self for method chaining
2530
+ Args:
2531
+ callback_fn: The callback function to register
2532
+ path: The path where this callback should be registered (default: "/sip")
2533
+ """
2534
+ # Normalize the path (remove trailing slash)
2535
+ normalized_path = path.rstrip("/")
2536
+ if not normalized_path.startswith("/"):
2537
+ normalized_path = f"/{normalized_path}"
2538
+
2539
+ # Store the callback with the normalized path (without trailing slash)
2540
+ self.log.info("registering_routing_callback", path=normalized_path)
2541
+ if not hasattr(self, '_routing_callbacks'):
2542
+ self._routing_callbacks = {}
2543
+ self._routing_callbacks[normalized_path] = callback_fn
2544
+
2545
+ def manual_set_proxy_url(self, proxy_url: str) -> 'AgentBase':
2459
2546
  """
2460
- # Create a routing callback that handles SIP usernames
2461
- def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
2462
- # Extract SIP username from the request body
2463
- sip_username = self.extract_sip_username(body)
2464
-
2465
- if sip_username:
2466
- self.log.info("sip_username_extracted", username=sip_username)
2467
-
2468
- # Check if this username is registered with this agent
2469
- if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
2470
- self.log.info("sip_username_matched", username=sip_username)
2471
- # This route is already being handled by the agent, no need to redirect
2472
- return None
2473
- else:
2474
- self.log.info("sip_username_not_matched", username=sip_username)
2475
- # Not registered with this agent, let routing continue
2476
-
2477
- return None
2478
-
2479
- # Register the callback with the SWMLService, specifying the path
2480
- self.register_routing_callback(sip_routing_callback, path=path)
2481
-
2482
- # Auto-map common usernames if requested
2483
- if auto_map:
2484
- self.auto_map_sip_usernames()
2485
-
2486
- return self
2547
+ Manually set the proxy URL base for webhook callbacks
2487
2548
 
2488
- def register_sip_username(self, sip_username: str) -> 'AgentBase':
2489
- """
2490
- Register a SIP username that should be routed to this agent
2549
+ This can be called at runtime to set or update the proxy URL
2491
2550
 
2492
2551
  Args:
2493
- sip_username: SIP username to register
2552
+ proxy_url: The base URL to use for webhooks (e.g., https://example.ngrok.io)
2494
2553
 
2495
2554
  Returns:
2496
2555
  Self for method chaining
2497
2556
  """
2498
- if not hasattr(self, '_sip_usernames'):
2499
- self._sip_usernames = set()
2500
-
2501
- self._sip_usernames.add(sip_username.lower())
2502
- self.log.info("sip_username_registered", username=sip_username)
2503
-
2504
- return self
2505
-
2506
- def auto_map_sip_usernames(self) -> 'AgentBase':
2507
- """
2508
- Automatically register common SIP usernames based on this agent's
2509
- name and route
2510
-
2511
- Returns:
2512
- Self for method chaining
2513
- """
2514
- # Register username based on agent name
2515
- clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
2516
- if clean_name:
2517
- self.register_sip_username(clean_name)
2518
-
2519
- # Register username based on route (without slashes)
2520
- clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
2521
- if clean_route and clean_route != clean_name:
2522
- self.register_sip_username(clean_route)
2557
+ if proxy_url:
2558
+ # Set on self
2559
+ self._proxy_url_base = proxy_url.rstrip('/')
2560
+ self._proxy_detection_done = True
2523
2561
 
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)
2562
+ # Set on parent if it has these attributes
2563
+ if hasattr(super(), '_proxy_url_base'):
2564
+ super()._proxy_url_base = self._proxy_url_base
2565
+ if hasattr(super(), '_proxy_detection_done'):
2566
+ super()._proxy_detection_done = True
2530
2567
 
2531
- return self
2532
-
2533
- def set_web_hook_url(self, url: str) -> 'AgentBase':
2534
- """
2535
- Override the default web_hook_url with a supplied URL string
2536
-
2537
- Args:
2538
- url: The URL to use for SWAIG function webhooks
2539
-
2540
- Returns:
2541
- Self for method chaining
2542
- """
2543
- self._web_hook_url_override = url
2544
- return self
2545
-
2546
- def set_post_prompt_url(self, url: str) -> 'AgentBase':
2547
- """
2548
- Override the default post_prompt_url with a supplied URL string
2549
-
2550
- Args:
2551
- url: The URL to use for post-prompt summary delivery
2568
+ self.log.info("proxy_url_manually_set", proxy_url_base=self._proxy_url_base)
2552
2569
 
2553
- Returns:
2554
- Self for method chaining
2555
- """
2556
- self._post_prompt_url_override = url
2557
2570
  return self