signalwire-agents 0.1.5__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.
- signalwire_agents/__init__.py +1 -1
- signalwire_agents/core/agent_base.py +1267 -1238
- signalwire_agents/core/security/session_manager.py +174 -86
- signalwire_agents/core/swml_service.py +195 -50
- signalwire_agents/prefabs/concierge.py +9 -2
- signalwire_agents/prefabs/faq_bot.py +3 -0
- signalwire_agents/prefabs/info_gatherer.py +3 -0
- signalwire_agents/prefabs/receptionist.py +3 -0
- signalwire_agents/prefabs/survey.py +9 -2
- {signalwire_agents-0.1.5.dist-info → signalwire_agents-0.1.7.dist-info}/METADATA +2 -1
- {signalwire_agents-0.1.5.dist-info → signalwire_agents-0.1.7.dist-info}/RECORD +15 -15
- {signalwire_agents-0.1.5.data → signalwire_agents-0.1.7.data}/data/schema.json +0 -0
- {signalwire_agents-0.1.5.dist-info → signalwire_agents-0.1.7.dist-info}/WHEEL +0 -0
- {signalwire_agents-0.1.5.dist-info → signalwire_agents-0.1.7.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-0.1.5.dist-info → signalwire_agents-0.1.7.dist-info}/top_level.txt +0 -0
@@ -1,3 +1,4 @@
|
|
1
|
+
#!/usr/bin/env python3
|
1
2
|
"""
|
2
3
|
Copyright (c) 2025 SignalWire
|
3
4
|
|
@@ -7,24 +8,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
|
-
|
13
|
+
Base class for all SignalWire AI Agents
|
12
14
|
"""
|
13
15
|
|
14
|
-
import functools
|
15
|
-
import inspect
|
16
16
|
import os
|
17
|
-
import
|
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
|
24
|
-
|
25
|
-
import
|
26
|
-
from
|
27
|
-
import
|
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 =
|
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
|
-
|
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
|
-
|
748
|
-
|
749
|
-
|
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
|
-
|
752
|
-
|
880
|
+
# Normal validation with provided call_id
|
881
|
+
is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
|
753
882
|
|
754
|
-
|
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
|
-
|
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,276 +1274,655 @@ class AgentBase(SWMLService):
|
|
1116
1274
|
Returns:
|
1117
1275
|
FastAPI router
|
1118
1276
|
"""
|
1119
|
-
#
|
1120
|
-
router =
|
1277
|
+
# Create a router with explicit redirect_slashes=False
|
1278
|
+
router = APIRouter(redirect_slashes=False)
|
1121
1279
|
|
1122
|
-
#
|
1123
|
-
|
1124
|
-
|
1125
|
-
|
1126
|
-
|
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
|
-
|
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
|
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 -
|
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
|
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 -
|
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
|
1156
|
-
|
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
|
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
|
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
|
-
|
1171
|
-
|
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
|
-
|
1174
|
-
|
1175
|
-
|
1176
|
-
|
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
|
1177
1473
|
|
1178
|
-
|
1179
|
-
|
1180
|
-
|
1181
|
-
|
1182
|
-
|
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
|
1183
1487
|
|
1184
|
-
|
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
|
1185
1507
|
|
1186
|
-
|
1187
|
-
|
1188
|
-
|
1189
|
-
|
1190
|
-
|
1191
|
-
content=json.dumps({"error": "Unauthorized"}),
|
1192
|
-
status_code=401,
|
1193
|
-
headers={"WWW-Authenticate": "Basic"},
|
1194
|
-
media_type="application/json"
|
1195
|
-
)
|
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
|
1196
1513
|
|
1197
|
-
|
1198
|
-
|
1199
|
-
|
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
|
1536
|
+
|
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")
|
1200
1546
|
|
1201
|
-
|
1202
|
-
|
1203
|
-
raw_body = await request.body()
|
1204
|
-
if raw_body:
|
1205
|
-
try:
|
1206
|
-
body = await request.json()
|
1207
|
-
req_log.debug("request_body_received", body_size=len(str(body)))
|
1208
|
-
if body:
|
1209
|
-
req_log.debug("request_body", body=json.dumps(body, indent=2))
|
1210
|
-
except Exception as e:
|
1211
|
-
req_log.warning("error_parsing_request_body", error=str(e), traceback=traceback.format_exc())
|
1212
|
-
req_log.debug("raw_request_body", body=raw_body.decode('utf-8', errors='replace'))
|
1213
|
-
# Continue processing with empty body
|
1214
|
-
body = {}
|
1215
|
-
else:
|
1216
|
-
req_log.debug("empty_request_body")
|
1217
|
-
|
1218
|
-
# Get call_id from body if present
|
1219
|
-
call_id = body.get("call_id")
|
1220
|
-
else:
|
1221
|
-
# Get call_id from query params for GET
|
1222
|
-
call_id = request.query_params.get("call_id")
|
1223
|
-
|
1224
|
-
# Add call_id to logger if any
|
1225
|
-
if call_id:
|
1226
|
-
req_log = req_log.bind(call_id=call_id)
|
1227
|
-
req_log.debug("call_id_identified")
|
1228
|
-
|
1229
|
-
# Check if this is a callback path and we need to apply routing
|
1230
|
-
if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
|
1231
|
-
callback_fn = self._routing_callbacks[callback_path]
|
1232
|
-
|
1233
|
-
if request.method == "POST" and body:
|
1234
|
-
req_log.debug("processing_routing_callback", path=callback_path)
|
1235
|
-
# Call the routing callback
|
1236
|
-
try:
|
1237
|
-
route = callback_fn(request, body)
|
1238
|
-
if route is not None:
|
1239
|
-
req_log.info("routing_request", route=route)
|
1240
|
-
# Return a redirect to the new route
|
1241
|
-
return Response(
|
1242
|
-
status_code=307, # 307 Temporary Redirect preserves the method and body
|
1243
|
-
headers={"Location": route}
|
1244
|
-
)
|
1245
|
-
except Exception as e:
|
1246
|
-
req_log.error("error_in_routing_callback", error=str(e), traceback=traceback.format_exc())
|
1547
|
+
Returns:
|
1548
|
+
Self for method chaining
|
1247
1549
|
|
1248
|
-
|
1249
|
-
|
1250
|
-
|
1251
|
-
try:
|
1252
|
-
modifications = self.on_swml_request(body)
|
1253
|
-
if modifications:
|
1254
|
-
req_log.debug("request_modifications_applied")
|
1255
|
-
except Exception as e:
|
1256
|
-
req_log.error("error_in_request_modifier", error=str(e), traceback=traceback.format_exc())
|
1550
|
+
Examples:
|
1551
|
+
# Simple voice name
|
1552
|
+
agent.add_language("English", "en-US", "en-US-Neural2-F")
|
1257
1553
|
|
1258
|
-
#
|
1259
|
-
|
1260
|
-
req_log.debug("swml_rendered", swml_size=len(swml))
|
1554
|
+
# Explicit parameters
|
1555
|
+
agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
|
1261
1556
|
|
1262
|
-
#
|
1263
|
-
|
1264
|
-
|
1265
|
-
|
1266
|
-
|
1267
|
-
|
1268
|
-
|
1269
|
-
req_log.error("request_failed", error=str(e), traceback=traceback.format_exc())
|
1270
|
-
return Response(
|
1271
|
-
content=json.dumps({"error": str(e), "traceback": traceback.format_exc()}),
|
1272
|
-
status_code=500,
|
1273
|
-
media_type="application/json"
|
1274
|
-
)
|
1275
|
-
|
1276
|
-
async def _handle_debug_request(self, request: Request):
|
1277
|
-
"""Handle GET/POST requests to the debug endpoint"""
|
1278
|
-
req_log = self.log.bind(
|
1279
|
-
endpoint="debug",
|
1280
|
-
method=request.method,
|
1281
|
-
path=request.url.path
|
1282
|
-
)
|
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
|
+
}
|
1283
1564
|
|
1284
|
-
|
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"
|
1575
|
+
try:
|
1576
|
+
engine_voice, model_part = voice.split(":", 1)
|
1577
|
+
engine_part, voice_part = engine_voice.split(".", 1)
|
1578
|
+
|
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
|
1285
1588
|
|
1286
|
-
|
1287
|
-
|
1288
|
-
|
1289
|
-
|
1290
|
-
|
1291
|
-
|
1292
|
-
|
1293
|
-
|
1294
|
-
|
1295
|
-
|
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
|
1600
|
+
|
1601
|
+
def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
|
1602
|
+
"""
|
1603
|
+
Set all language configurations at once
|
1604
|
+
|
1605
|
+
Args:
|
1606
|
+
languages: List of language configuration dictionaries
|
1296
1607
|
|
1297
|
-
|
1298
|
-
|
1299
|
-
|
1608
|
+
Returns:
|
1609
|
+
Self for method chaining
|
1610
|
+
"""
|
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':
|
1619
|
+
"""
|
1620
|
+
Add a pronunciation rule to help the AI speak certain words correctly
|
1621
|
+
|
1622
|
+
Args:
|
1623
|
+
replace: The expression to replace
|
1624
|
+
with_text: The phonetic spelling to use instead
|
1625
|
+
ignore_case: Whether to ignore case when matching
|
1300
1626
|
|
1301
|
-
|
1302
|
-
|
1303
|
-
|
1304
|
-
|
1305
|
-
|
1306
|
-
|
1307
|
-
|
1308
|
-
|
1309
|
-
|
1310
|
-
|
1311
|
-
body_text = await request.body()
|
1312
|
-
req_log.debug("raw_request_body", body=body_text.decode('utf-8', errors='replace'))
|
1313
|
-
except:
|
1314
|
-
pass
|
1315
|
-
else:
|
1316
|
-
call_id = request.query_params.get("call_id")
|
1627
|
+
Returns:
|
1628
|
+
Self for method chaining
|
1629
|
+
"""
|
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
|
1317
1637
|
|
1318
|
-
|
1319
|
-
|
1320
|
-
|
1321
|
-
|
1322
|
-
|
1323
|
-
|
1324
|
-
|
1325
|
-
|
1326
|
-
|
1327
|
-
if modifications:
|
1328
|
-
req_log.debug("request_modifications_applied")
|
1329
|
-
|
1330
|
-
# Render SWML
|
1331
|
-
swml = self._render_swml(call_id, modifications)
|
1332
|
-
req_log.debug("swml_rendered", swml_size=len(swml))
|
1638
|
+
self._pronounce.append(rule)
|
1639
|
+
return self
|
1640
|
+
|
1641
|
+
def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
|
1642
|
+
"""
|
1643
|
+
Set all pronunciation rules at once
|
1644
|
+
|
1645
|
+
Args:
|
1646
|
+
pronunciations: List of pronunciation rule dictionaries
|
1333
1647
|
|
1334
|
-
|
1335
|
-
|
1336
|
-
|
1337
|
-
|
1338
|
-
|
1339
|
-
|
1340
|
-
|
1341
|
-
|
1342
|
-
|
1343
|
-
|
1344
|
-
content=json.dumps({"error": str(e), "traceback": traceback.format_exc()}),
|
1345
|
-
status_code=500,
|
1346
|
-
media_type="application/json"
|
1347
|
-
)
|
1348
|
-
|
1349
|
-
async def _handle_swaig_request(self, request: Request):
|
1350
|
-
"""Handle GET/POST requests to the SWAIG endpoint"""
|
1351
|
-
req_log = self.log.bind(
|
1352
|
-
endpoint="swaig",
|
1353
|
-
method=request.method,
|
1354
|
-
path=request.url.path
|
1355
|
-
)
|
1648
|
+
Returns:
|
1649
|
+
Self for method chaining
|
1650
|
+
"""
|
1651
|
+
if pronunciations and isinstance(pronunciations, list):
|
1652
|
+
self._pronounce = pronunciations
|
1653
|
+
return self
|
1654
|
+
|
1655
|
+
def set_param(self, key: str, value: Any) -> 'AgentBase':
|
1656
|
+
"""
|
1657
|
+
Set a single AI parameter
|
1356
1658
|
|
1357
|
-
|
1659
|
+
Args:
|
1660
|
+
key: Parameter name
|
1661
|
+
value: Parameter value
|
1662
|
+
|
1663
|
+
Returns:
|
1664
|
+
Self for method chaining
|
1665
|
+
"""
|
1666
|
+
if key:
|
1667
|
+
self._params[key] = value
|
1668
|
+
return self
|
1669
|
+
|
1670
|
+
def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
|
1671
|
+
"""
|
1672
|
+
Set multiple AI parameters at once
|
1358
1673
|
|
1359
|
-
|
1360
|
-
|
1361
|
-
if not self._check_basic_auth(request):
|
1362
|
-
req_log.warning("unauthorized_access_attempt")
|
1363
|
-
return Response(
|
1364
|
-
content=json.dumps({"error": "Unauthorized"}),
|
1365
|
-
status_code=401,
|
1366
|
-
headers={"WWW-Authenticate": "Basic"},
|
1367
|
-
media_type="application/json"
|
1368
|
-
)
|
1674
|
+
Args:
|
1675
|
+
params: Dictionary of parameter name/value pairs
|
1369
1676
|
|
1370
|
-
|
1371
|
-
|
1372
|
-
|
1373
|
-
|
1374
|
-
|
1375
|
-
|
1376
|
-
|
1377
|
-
|
1378
|
-
|
1379
|
-
|
1677
|
+
Returns:
|
1678
|
+
Self for method chaining
|
1679
|
+
"""
|
1680
|
+
if params and isinstance(params, dict):
|
1681
|
+
self._params.update(params)
|
1682
|
+
return self
|
1683
|
+
|
1684
|
+
def set_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
|
1685
|
+
"""
|
1686
|
+
Set the global data available to the AI throughout the conversation
|
1687
|
+
|
1688
|
+
Args:
|
1689
|
+
data: Dictionary of global data
|
1380
1690
|
|
1381
|
-
|
1691
|
+
Returns:
|
1692
|
+
Self for method chaining
|
1693
|
+
"""
|
1694
|
+
if data and isinstance(data, dict):
|
1695
|
+
self._global_data = data
|
1696
|
+
return self
|
1697
|
+
|
1698
|
+
def update_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
|
1699
|
+
"""
|
1700
|
+
Update the global data with new values
|
1701
|
+
|
1702
|
+
Args:
|
1703
|
+
data: Dictionary of global data to update
|
1704
|
+
|
1705
|
+
Returns:
|
1706
|
+
Self for method chaining
|
1707
|
+
"""
|
1708
|
+
if data and isinstance(data, dict):
|
1709
|
+
self._global_data.update(data)
|
1710
|
+
return self
|
1711
|
+
|
1712
|
+
def set_native_functions(self, function_names: List[str]) -> 'AgentBase':
|
1713
|
+
"""
|
1714
|
+
Set the list of native functions to enable
|
1715
|
+
|
1716
|
+
Args:
|
1717
|
+
function_names: List of native function names
|
1718
|
+
|
1719
|
+
Returns:
|
1720
|
+
Self for method chaining
|
1721
|
+
"""
|
1722
|
+
if function_names and isinstance(function_names, list):
|
1723
|
+
self.native_functions = [name for name in function_names if isinstance(name, str)]
|
1724
|
+
return self
|
1725
|
+
|
1726
|
+
def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
|
1727
|
+
"""
|
1728
|
+
Add a remote function include to the SWAIG configuration
|
1729
|
+
|
1730
|
+
Args:
|
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
|
1734
|
+
|
1735
|
+
Returns:
|
1736
|
+
Self for method chaining
|
1737
|
+
"""
|
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)
|
1747
|
+
return self
|
1748
|
+
|
1749
|
+
def set_function_includes(self, includes: List[Dict[str, Any]]) -> 'AgentBase':
|
1750
|
+
"""
|
1751
|
+
Set the complete list of function includes
|
1752
|
+
|
1753
|
+
Args:
|
1754
|
+
includes: List of include objects, each with url and functions properties
|
1755
|
+
|
1756
|
+
Returns:
|
1757
|
+
Self for method chaining
|
1758
|
+
"""
|
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
|
1768
|
+
return self
|
1769
|
+
|
1770
|
+
def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
|
1771
|
+
"""
|
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.
|
1777
|
+
|
1778
|
+
Args:
|
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
|
+
|
1783
|
+
Returns:
|
1784
|
+
Self for method chaining
|
1785
|
+
"""
|
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
|
1804
|
+
|
1805
|
+
# Register the callback with the SWMLService, specifying the path
|
1806
|
+
self.register_routing_callback(sip_routing_callback, path=path)
|
1807
|
+
|
1808
|
+
# Auto-map common usernames if requested
|
1809
|
+
if auto_map:
|
1810
|
+
self.auto_map_sip_usernames()
|
1811
|
+
|
1812
|
+
return self
|
1813
|
+
|
1814
|
+
def register_sip_username(self, sip_username: str) -> 'AgentBase':
|
1815
|
+
"""
|
1816
|
+
Register a SIP username that should be routed to this agent
|
1817
|
+
|
1818
|
+
Args:
|
1819
|
+
sip_username: SIP username to register
|
1820
|
+
|
1821
|
+
Returns:
|
1822
|
+
Self for method chaining
|
1823
|
+
"""
|
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)
|
1829
|
+
|
1830
|
+
return self
|
1831
|
+
|
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
|
1858
|
+
|
1859
|
+
def set_web_hook_url(self, url: str) -> 'AgentBase':
|
1860
|
+
"""
|
1861
|
+
Override the default web_hook_url with a supplied URL string
|
1862
|
+
|
1863
|
+
Args:
|
1864
|
+
url: The URL to use for SWAIG function webhooks
|
1865
|
+
|
1866
|
+
Returns:
|
1867
|
+
Self for method chaining
|
1868
|
+
"""
|
1869
|
+
self._web_hook_url_override = url
|
1870
|
+
return self
|
1871
|
+
|
1872
|
+
def set_post_prompt_url(self, url: str) -> 'AgentBase':
|
1873
|
+
"""
|
1874
|
+
Override the default post_prompt_url with a supplied URL string
|
1875
|
+
|
1876
|
+
Args:
|
1877
|
+
url: The URL to use for post-prompt summary delivery
|
1878
|
+
|
1879
|
+
Returns:
|
1880
|
+
Self for method chaining
|
1881
|
+
"""
|
1882
|
+
self._post_prompt_url_override = url
|
1883
|
+
return self
|
1884
|
+
|
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
|
1382
1919
|
try:
|
1383
1920
|
body = await request.json()
|
1384
1921
|
req_log.debug("request_body_received", body_size=len(str(body)))
|
1385
1922
|
if body:
|
1386
|
-
req_log.debug("request_body", body=json.dumps(body
|
1923
|
+
req_log.debug("request_body", body=json.dumps(body))
|
1387
1924
|
except Exception as e:
|
1388
|
-
req_log.error("error_parsing_request_body", error=str(e)
|
1925
|
+
req_log.error("error_parsing_request_body", error=str(e))
|
1389
1926
|
body = {}
|
1390
1927
|
|
1391
1928
|
# Extract function name
|
@@ -1407,11 +1944,11 @@ class AgentBase(SWMLService):
|
|
1407
1944
|
if "argument" in body and isinstance(body["argument"], dict):
|
1408
1945
|
if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
|
1409
1946
|
args = body["argument"]["parsed"][0]
|
1410
|
-
req_log.debug("parsed_arguments", args=json.dumps(args
|
1947
|
+
req_log.debug("parsed_arguments", args=json.dumps(args))
|
1411
1948
|
elif "raw" in body["argument"]:
|
1412
1949
|
try:
|
1413
1950
|
args = json.loads(body["argument"]["raw"])
|
1414
|
-
req_log.debug("raw_arguments_parsed", args=json.dumps(args
|
1951
|
+
req_log.debug("raw_arguments_parsed", args=json.dumps(args))
|
1415
1952
|
except Exception as e:
|
1416
1953
|
req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
|
1417
1954
|
|
@@ -1421,6 +1958,24 @@ class AgentBase(SWMLService):
|
|
1421
1958
|
req_log = req_log.bind(call_id=call_id)
|
1422
1959
|
req_log.debug("call_id_identified")
|
1423
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
|
+
|
1424
1979
|
# Call the function
|
1425
1980
|
try:
|
1426
1981
|
result = self.on_function_call(function_name, args, body)
|
@@ -1434,31 +1989,61 @@ class AgentBase(SWMLService):
|
|
1434
1989
|
result_dict = {"response": str(result)}
|
1435
1990
|
|
1436
1991
|
req_log.info("function_executed_successfully")
|
1437
|
-
req_log.debug("function_result", result=json.dumps(result_dict
|
1992
|
+
req_log.debug("function_result", result=json.dumps(result_dict))
|
1438
1993
|
return result_dict
|
1439
1994
|
except Exception as e:
|
1440
|
-
req_log.error("function_execution_error", error=str(e)
|
1995
|
+
req_log.error("function_execution_error", error=str(e))
|
1441
1996
|
return {"error": str(e), "function": function_name}
|
1442
1997
|
|
1443
1998
|
except Exception as e:
|
1444
|
-
req_log.error("request_failed", error=str(e)
|
1999
|
+
req_log.error("request_failed", error=str(e))
|
1445
2000
|
return Response(
|
1446
2001
|
content=json.dumps({"error": str(e)}),
|
1447
2002
|
status_code=500,
|
1448
2003
|
media_type="application/json"
|
1449
2004
|
)
|
1450
|
-
|
1451
|
-
async def
|
1452
|
-
"""Handle GET/POST requests to the
|
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
|
+
|
1453
2040
|
req_log = self.log.bind(
|
1454
|
-
endpoint="
|
2041
|
+
endpoint="root" if not callback_path else f"callback:{callback_path}",
|
1455
2042
|
method=request.method,
|
1456
2043
|
path=request.url.path
|
1457
2044
|
)
|
1458
2045
|
|
1459
|
-
|
1460
|
-
if not self._suppress_logs:
|
1461
|
-
req_log.debug("endpoint_called")
|
2046
|
+
req_log.debug("endpoint_called")
|
1462
2047
|
|
1463
2048
|
try:
|
1464
2049
|
# Check auth
|
@@ -1471,48 +2056,246 @@ class AgentBase(SWMLService):
|
|
1471
2056
|
media_type="application/json"
|
1472
2057
|
)
|
1473
2058
|
|
1474
|
-
#
|
1475
|
-
|
1476
|
-
|
1477
|
-
|
1478
|
-
|
1479
|
-
|
1480
|
-
|
1481
|
-
|
1482
|
-
|
1483
|
-
|
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
|
+
)
|
2144
|
+
|
2145
|
+
req_log.debug("endpoint_called")
|
2146
|
+
|
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
|
+
)
|
2157
|
+
|
2158
|
+
# Get call_id from either query params (GET) or body (POST)
|
2159
|
+
call_id = None
|
2160
|
+
body = {}
|
2161
|
+
|
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")
|
2171
|
+
|
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))
|
2187
|
+
|
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
|
+
)
|
2210
|
+
|
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
|
+
|
1484
2279
|
# For POST requests, process the post-prompt data
|
1485
2280
|
try:
|
1486
|
-
body
|
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()
|
1487
2286
|
|
1488
2287
|
# Only log if not suppressed
|
1489
|
-
if not self
|
2288
|
+
if not getattr(self, '_suppress_logs', False):
|
1490
2289
|
req_log.debug("request_body_received", body_size=len(str(body)))
|
1491
|
-
# Log the raw body
|
1492
|
-
|
2290
|
+
# Log the raw body directly (let the logger handle the JSON encoding)
|
2291
|
+
req_log.info("post_prompt_body", body=body)
|
1493
2292
|
except Exception as e:
|
1494
|
-
req_log.error("error_parsing_request_body", error=str(e)
|
2293
|
+
req_log.error("error_parsing_request_body", error=str(e))
|
1495
2294
|
body = {}
|
1496
2295
|
|
1497
2296
|
# Extract summary from the correct location in the request
|
1498
2297
|
summary = self._find_summary_in_post_data(body, req_log)
|
1499
2298
|
|
1500
|
-
# Save state if call_id is provided
|
1501
|
-
call_id = body.get("call_id")
|
1502
|
-
if call_id and summary:
|
1503
|
-
req_log = req_log.bind(call_id=call_id)
|
1504
|
-
|
1505
|
-
# Check if state manager has the right methods
|
1506
|
-
try:
|
1507
|
-
if hasattr(self._state_manager, 'get_state'):
|
1508
|
-
state = self._state_manager.get_state(call_id) or {}
|
1509
|
-
state["summary"] = summary
|
1510
|
-
if hasattr(self._state_manager, 'update_state'):
|
1511
|
-
self._state_manager.update_state(call_id, state)
|
1512
|
-
req_log.debug("state_updated_with_summary")
|
1513
|
-
except Exception as e:
|
1514
|
-
req_log.warning("state_update_failed", error=str(e))
|
1515
|
-
|
1516
2299
|
# Call the summary handler with the summary and the full body
|
1517
2300
|
try:
|
1518
2301
|
if summary:
|
@@ -1523,1019 +2306,265 @@ class AgentBase(SWMLService):
|
|
1523
2306
|
self.on_summary(None, body)
|
1524
2307
|
req_log.debug("summary_handler_called_with_null_summary")
|
1525
2308
|
except Exception as e:
|
1526
|
-
req_log.error("error_in_summary_handler", error=str(e)
|
2309
|
+
req_log.error("error_in_summary_handler", error=str(e))
|
1527
2310
|
|
1528
2311
|
# Return success
|
1529
2312
|
req_log.info("request_successful")
|
1530
2313
|
return {"success": True}
|
1531
2314
|
except Exception as e:
|
1532
|
-
req_log.error("request_failed", error=str(e)
|
2315
|
+
req_log.error("request_failed", error=str(e))
|
1533
2316
|
return Response(
|
1534
2317
|
content=json.dumps({"error": str(e)}),
|
1535
2318
|
status_code=500,
|
1536
2319
|
media_type="application/json"
|
1537
2320
|
)
|
1538
|
-
|
1539
|
-
def
|
1540
|
-
"""
|
1541
|
-
|
1542
|
-
|
1543
|
-
|
1544
|
-
|
1545
|
-
|
1546
|
-
|
1547
|
-
Returns:
|
1548
|
-
The summary if found, None otherwise
|
1549
|
-
"""
|
1550
|
-
summary = None
|
1551
|
-
|
1552
|
-
# Check all the locations where the summary might be found
|
1553
|
-
|
1554
|
-
# 1. First check post_prompt_data.parsed array (new standard location)
|
1555
|
-
post_prompt_data = body.get("post_prompt_data", {})
|
1556
|
-
if post_prompt_data:
|
1557
|
-
if not self._suppress_logs:
|
1558
|
-
logger.debug("checking_post_prompt_data", data_type=type(post_prompt_data).__name__)
|
1559
|
-
|
1560
|
-
# Check for parsed array first (this is the most common location)
|
1561
|
-
if isinstance(post_prompt_data, dict) and "parsed" in post_prompt_data:
|
1562
|
-
parsed = post_prompt_data.get("parsed")
|
1563
|
-
if isinstance(parsed, list) and len(parsed) > 0:
|
1564
|
-
# The summary is the first item in the parsed array
|
1565
|
-
summary = parsed[0]
|
1566
|
-
print("SUMMARY_FOUND: " + json.dumps(summary))
|
1567
|
-
return summary
|
1568
|
-
|
1569
|
-
# Check raw field - it might contain a JSON string
|
1570
|
-
if isinstance(post_prompt_data, dict) and "raw" in post_prompt_data:
|
1571
|
-
raw = post_prompt_data.get("raw")
|
1572
|
-
if isinstance(raw, str):
|
1573
|
-
try:
|
1574
|
-
# Try to parse the raw field as JSON
|
1575
|
-
parsed_raw = json.loads(raw)
|
1576
|
-
if not self._suppress_logs:
|
1577
|
-
print("SUMMARY_FOUND_RAW: " + json.dumps(parsed_raw))
|
1578
|
-
return parsed_raw
|
1579
|
-
except:
|
1580
|
-
pass
|
1581
|
-
|
1582
|
-
# Direct access to substituted field
|
1583
|
-
if isinstance(post_prompt_data, dict) and "substituted" in post_prompt_data:
|
1584
|
-
summary = post_prompt_data.get("substituted")
|
1585
|
-
if not self._suppress_logs:
|
1586
|
-
print("SUMMARY_FOUND_SUBSTITUTED: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_SUBSTITUTED: {summary}")
|
1587
|
-
return summary
|
1588
|
-
|
1589
|
-
# Check for nested data structure
|
1590
|
-
if isinstance(post_prompt_data, dict) and "data" in post_prompt_data:
|
1591
|
-
data = post_prompt_data.get("data")
|
1592
|
-
if isinstance(data, dict):
|
1593
|
-
if "substituted" in data:
|
1594
|
-
summary = data.get("substituted")
|
1595
|
-
if not self._suppress_logs:
|
1596
|
-
print("SUMMARY_FOUND_DATA_SUBSTITUTED: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_DATA_SUBSTITUTED: {summary}")
|
1597
|
-
return summary
|
1598
|
-
|
1599
|
-
# Try text field
|
1600
|
-
if "text" in data:
|
1601
|
-
summary = data.get("text")
|
1602
|
-
if not self._suppress_logs:
|
1603
|
-
print("SUMMARY_FOUND_DATA_TEXT: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_DATA_TEXT: {summary}")
|
1604
|
-
return summary
|
1605
|
-
|
1606
|
-
# 2. Check ai_response (legacy location)
|
1607
|
-
ai_response = body.get("ai_response", {})
|
1608
|
-
if ai_response and isinstance(ai_response, dict):
|
1609
|
-
if "summary" in ai_response:
|
1610
|
-
summary = ai_response.get("summary")
|
1611
|
-
if not self._suppress_logs:
|
1612
|
-
print("SUMMARY_FOUND_AI_RESPONSE: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_AI_RESPONSE: {summary}")
|
1613
|
-
return summary
|
1614
|
-
|
1615
|
-
# 3. Look for direct fields at the top level
|
1616
|
-
for field in ["substituted", "summary", "content", "text", "result", "output"]:
|
1617
|
-
if field in body:
|
1618
|
-
summary = body.get(field)
|
1619
|
-
if not self._suppress_logs:
|
1620
|
-
print(f"SUMMARY_FOUND_TOP_LEVEL_{field}: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_TOP_LEVEL_{field}: {summary}")
|
1621
|
-
return summary
|
1622
|
-
|
1623
|
-
# 4. Recursively search for summary-like fields up to 3 levels deep
|
1624
|
-
def recursive_search(data, path="", depth=0):
|
1625
|
-
if depth > 3 or not isinstance(data, dict): # Limit recursion depth
|
1626
|
-
return None
|
1627
|
-
|
1628
|
-
# Check if any key looks like it might contain a summary
|
1629
|
-
for key in data.keys():
|
1630
|
-
if key.lower() in ["summary", "substituted", "output", "result", "content", "text"]:
|
1631
|
-
value = data.get(key)
|
1632
|
-
curr_path = f"{path}.{key}" if path else key
|
1633
|
-
if not self._suppress_logs:
|
1634
|
-
logger.info(f"potential_summary_found_at_{curr_path}",
|
1635
|
-
value_type=type(value).__name__)
|
1636
|
-
if isinstance(value, (str, dict, list)):
|
1637
|
-
return value
|
1638
|
-
|
1639
|
-
# Recursively check nested dictionaries
|
1640
|
-
for key, value in data.items():
|
1641
|
-
if isinstance(value, dict):
|
1642
|
-
curr_path = f"{path}.{key}" if path else key
|
1643
|
-
result = recursive_search(value, curr_path, depth + 1)
|
1644
|
-
if result:
|
1645
|
-
return result
|
1646
|
-
|
1647
|
-
return None
|
1648
|
-
|
1649
|
-
# Perform recursive search
|
1650
|
-
recursive_result = recursive_search(body)
|
1651
|
-
if recursive_result:
|
1652
|
-
summary = recursive_result
|
1653
|
-
if not self._suppress_logs:
|
1654
|
-
print("SUMMARY_FOUND_RECURSIVE: " + json.dumps(summary) if isinstance(summary, (dict, list)) else f"SUMMARY_FOUND_RECURSIVE: {summary}")
|
1655
|
-
return summary
|
1656
|
-
|
1657
|
-
# No summary found
|
1658
|
-
if not self._suppress_logs:
|
1659
|
-
print("NO_SUMMARY_FOUND")
|
1660
|
-
return None
|
1661
|
-
|
1662
|
-
def _register_routes(self, app):
|
1663
|
-
"""Register all routes for the agent, with both slash variants and both HTTP methods"""
|
1664
|
-
|
1665
|
-
self.log.info("registering_routes", path=self.route)
|
1666
|
-
|
1667
|
-
# Root endpoint - without trailing slash
|
1668
|
-
@app.get(f"{self.route}")
|
1669
|
-
@app.post(f"{self.route}")
|
1670
|
-
async def handle_root_no_slash(request: Request):
|
1671
|
-
return await self._handle_root_request(request)
|
1672
|
-
|
1673
|
-
# Root endpoint - with trailing slash
|
1674
|
-
@app.get(f"{self.route}/")
|
1675
|
-
@app.post(f"{self.route}/")
|
1676
|
-
async def handle_root_with_slash(request: Request):
|
1677
|
-
return await self._handle_root_request(request)
|
1678
|
-
|
1679
|
-
# Debug endpoint - without trailing slash
|
1680
|
-
@app.get(f"{self.route}/debug")
|
1681
|
-
@app.post(f"{self.route}/debug")
|
1682
|
-
async def handle_debug_no_slash(request: Request):
|
1683
|
-
return await self._handle_debug_request(request)
|
1684
|
-
|
1685
|
-
# Debug endpoint - with trailing slash
|
1686
|
-
@app.get(f"{self.route}/debug/")
|
1687
|
-
@app.post(f"{self.route}/debug/")
|
1688
|
-
async def handle_debug_with_slash(request: Request):
|
1689
|
-
return await self._handle_debug_request(request)
|
1690
|
-
|
1691
|
-
# SWAIG endpoint - without trailing slash
|
1692
|
-
@app.get(f"{self.route}/swaig")
|
1693
|
-
@app.post(f"{self.route}/swaig")
|
1694
|
-
async def handle_swaig_no_slash(request: Request):
|
1695
|
-
return await self._handle_swaig_request(request)
|
1696
|
-
|
1697
|
-
# SWAIG endpoint - with trailing slash
|
1698
|
-
@app.get(f"{self.route}/swaig/")
|
1699
|
-
@app.post(f"{self.route}/swaig/")
|
1700
|
-
async def handle_swaig_with_slash(request: Request):
|
1701
|
-
return await self._handle_swaig_request(request)
|
1702
|
-
|
1703
|
-
# Post-prompt endpoint - without trailing slash
|
1704
|
-
@app.get(f"{self.route}/post_prompt")
|
1705
|
-
@app.post(f"{self.route}/post_prompt")
|
1706
|
-
async def handle_post_prompt_no_slash(request: Request):
|
1707
|
-
return await self._handle_post_prompt_request(request)
|
1708
|
-
|
1709
|
-
# Post-prompt endpoint - with trailing slash
|
1710
|
-
@app.get(f"{self.route}/post_prompt/")
|
1711
|
-
@app.post(f"{self.route}/post_prompt/")
|
1712
|
-
async def handle_post_prompt_with_slash(request: Request):
|
1713
|
-
return await self._handle_post_prompt_request(request)
|
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
|
+
)
|
1714
2329
|
|
1715
|
-
|
1716
|
-
if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
|
1717
|
-
for callback_path, callback_fn in self._routing_callbacks.items():
|
1718
|
-
# Skip the root path as it's already handled
|
1719
|
-
if callback_path == "/":
|
1720
|
-
continue
|
1721
|
-
|
1722
|
-
# Register the endpoint without trailing slash
|
1723
|
-
callback_route = callback_path
|
1724
|
-
self.log.info("registering_callback_route", path=callback_route)
|
1725
|
-
|
1726
|
-
@app.get(callback_route)
|
1727
|
-
@app.post(callback_route)
|
1728
|
-
async def handle_callback_no_slash(request: Request, path_param=callback_route):
|
1729
|
-
# Store the callback path in request state for _handle_root_request to use
|
1730
|
-
request.state.callback_path = path_param
|
1731
|
-
return await self._handle_root_request(request)
|
1732
|
-
|
1733
|
-
# Register the endpoint with trailing slash if it doesn't already have one
|
1734
|
-
if not callback_route.endswith('/'):
|
1735
|
-
slash_route = f"{callback_route}/"
|
1736
|
-
|
1737
|
-
@app.get(slash_route)
|
1738
|
-
@app.post(slash_route)
|
1739
|
-
async def handle_callback_with_slash(request: Request, path_param=callback_route):
|
1740
|
-
# Store the callback path in request state for _handle_root_request to use
|
1741
|
-
request.state.callback_path = path_param
|
1742
|
-
return await self._handle_root_request(request)
|
1743
|
-
|
1744
|
-
# Log all registered routes
|
1745
|
-
routes = [f"{route.methods} {route.path}" for route in app.routes]
|
1746
|
-
self.log.debug("routes_registered", routes=routes)
|
1747
|
-
|
1748
|
-
def _register_class_decorated_tools(self):
|
1749
|
-
"""
|
1750
|
-
Register all tools decorated with @AgentBase.tool
|
1751
|
-
"""
|
1752
|
-
for name in dir(self):
|
1753
|
-
attr = getattr(self, name)
|
1754
|
-
if callable(attr) and hasattr(attr, "_is_tool"):
|
1755
|
-
# Get tool parameters
|
1756
|
-
tool_name = getattr(attr, "_tool_name", name)
|
1757
|
-
tool_params = getattr(attr, "_tool_params", {})
|
1758
|
-
|
1759
|
-
# Extract parameters
|
1760
|
-
parameters = tool_params.get("parameters", {})
|
1761
|
-
description = tool_params.get("description", attr.__doc__ or f"Function {tool_name}")
|
1762
|
-
secure = tool_params.get("secure", True)
|
1763
|
-
fillers = tool_params.get("fillers", None)
|
1764
|
-
|
1765
|
-
# Create a wrapper that binds the method to this instance
|
1766
|
-
def make_wrapper(method):
|
1767
|
-
@functools.wraps(method)
|
1768
|
-
def wrapper(args, raw_data=None):
|
1769
|
-
return method(args, raw_data)
|
1770
|
-
return wrapper
|
1771
|
-
|
1772
|
-
# Register the tool
|
1773
|
-
self.define_tool(
|
1774
|
-
name=tool_name,
|
1775
|
-
description=description,
|
1776
|
-
parameters=parameters,
|
1777
|
-
handler=make_wrapper(attr),
|
1778
|
-
secure=secure,
|
1779
|
-
fillers=fillers
|
1780
|
-
)
|
1781
|
-
|
1782
|
-
# State Management Methods
|
1783
|
-
def get_state(self, call_id: str) -> Optional[Dict[str, Any]]:
|
1784
|
-
"""
|
1785
|
-
Get the state for a call
|
2330
|
+
req_log.debug("endpoint_called")
|
1786
2331
|
|
1787
|
-
Args:
|
1788
|
-
call_id: Call ID to get state for
|
1789
|
-
|
1790
|
-
Returns:
|
1791
|
-
Call state or None if not found
|
1792
|
-
"""
|
1793
2332
|
try:
|
1794
|
-
|
1795
|
-
|
1796
|
-
|
1797
|
-
|
1798
|
-
|
1799
|
-
|
1800
|
-
|
1801
|
-
|
1802
|
-
|
1803
|
-
Set the state for a call
|
1804
|
-
|
1805
|
-
Args:
|
1806
|
-
call_id: Call ID to set state for
|
1807
|
-
data: State data to set
|
1808
|
-
|
1809
|
-
Returns:
|
1810
|
-
True if state was set, False otherwise
|
1811
|
-
"""
|
1812
|
-
try:
|
1813
|
-
if hasattr(self._state_manager, 'set_state'):
|
1814
|
-
return self._state_manager.set_state(call_id, data)
|
1815
|
-
return False
|
1816
|
-
except Exception as e:
|
1817
|
-
logger.warning("set_state_failed", error=str(e))
|
1818
|
-
return False
|
1819
|
-
|
1820
|
-
def update_state(self, call_id: str, data: Dict[str, Any]) -> bool:
|
1821
|
-
"""
|
1822
|
-
Update the state for a call
|
1823
|
-
|
1824
|
-
Args:
|
1825
|
-
call_id: Call ID to update state for
|
1826
|
-
data: State data to update
|
1827
|
-
|
1828
|
-
Returns:
|
1829
|
-
True if state was updated, False otherwise
|
1830
|
-
"""
|
1831
|
-
try:
|
1832
|
-
if hasattr(self._state_manager, 'update_state'):
|
1833
|
-
return self._state_manager.update_state(call_id, data)
|
1834
|
-
return self.set_state(call_id, data)
|
1835
|
-
except Exception as e:
|
1836
|
-
logger.warning("update_state_failed", error=str(e))
|
1837
|
-
return False
|
1838
|
-
|
1839
|
-
def clear_state(self, call_id: str) -> bool:
|
1840
|
-
"""
|
1841
|
-
Clear the state for a call
|
1842
|
-
|
1843
|
-
Args:
|
1844
|
-
call_id: Call ID to clear state for
|
1845
|
-
|
1846
|
-
Returns:
|
1847
|
-
True if state was cleared, False otherwise
|
1848
|
-
"""
|
1849
|
-
try:
|
1850
|
-
if hasattr(self._state_manager, 'clear_state'):
|
1851
|
-
return self._state_manager.clear_state(call_id)
|
1852
|
-
return False
|
1853
|
-
except Exception as e:
|
1854
|
-
logger.warning("clear_state_failed", error=str(e))
|
1855
|
-
return False
|
1856
|
-
|
1857
|
-
def cleanup_expired_state(self) -> int:
|
1858
|
-
"""
|
1859
|
-
Clean up expired state
|
1860
|
-
|
1861
|
-
Returns:
|
1862
|
-
Number of expired state entries removed
|
1863
|
-
"""
|
1864
|
-
try:
|
1865
|
-
if hasattr(self._state_manager, 'cleanup_expired'):
|
1866
|
-
return self._state_manager.cleanup_expired()
|
1867
|
-
return 0
|
1868
|
-
except Exception as e:
|
1869
|
-
logger.warning("cleanup_expired_state_failed", error=str(e))
|
1870
|
-
return 0
|
1871
|
-
|
1872
|
-
def _register_state_tracking_tools(self):
|
1873
|
-
"""
|
1874
|
-
Register tools for tracking conversation state
|
1875
|
-
"""
|
1876
|
-
# Register startup hook
|
1877
|
-
self.define_tool(
|
1878
|
-
name="startup_hook",
|
1879
|
-
description="Called when the conversation starts",
|
1880
|
-
parameters={},
|
1881
|
-
handler=self._startup_hook_handler,
|
1882
|
-
secure=False
|
1883
|
-
)
|
1884
|
-
|
1885
|
-
# Register hangup hook
|
1886
|
-
self.define_tool(
|
1887
|
-
name="hangup_hook",
|
1888
|
-
description="Called when the conversation ends",
|
1889
|
-
parameters={},
|
1890
|
-
handler=self._hangup_hook_handler,
|
1891
|
-
secure=False
|
1892
|
-
)
|
1893
|
-
|
1894
|
-
def _startup_hook_handler(self, args, raw_data):
|
1895
|
-
"""
|
1896
|
-
Handler for the startup hook
|
1897
|
-
|
1898
|
-
Args:
|
1899
|
-
args: Function arguments
|
1900
|
-
raw_data: Raw request data
|
1901
|
-
|
1902
|
-
Returns:
|
1903
|
-
Function result
|
1904
|
-
"""
|
1905
|
-
# Extract call ID
|
1906
|
-
call_id = raw_data.get("call_id") if raw_data else None
|
1907
|
-
if not call_id:
|
1908
|
-
return SwaigFunctionResult("Error: Missing call_id")
|
1909
|
-
|
1910
|
-
# Activate the session
|
1911
|
-
self._session_manager.activate_session(call_id)
|
1912
|
-
|
1913
|
-
# Initialize state
|
1914
|
-
self.set_state(call_id, {
|
1915
|
-
"start_time": datetime.now().isoformat(),
|
1916
|
-
"events": []
|
1917
|
-
})
|
1918
|
-
|
1919
|
-
return SwaigFunctionResult("Call started and session activated")
|
1920
|
-
|
1921
|
-
def _hangup_hook_handler(self, args, raw_data):
|
1922
|
-
"""
|
1923
|
-
Handler for the hangup hook
|
1924
|
-
|
1925
|
-
Args:
|
1926
|
-
args: Function arguments
|
1927
|
-
raw_data: Raw request data
|
1928
|
-
|
1929
|
-
Returns:
|
1930
|
-
Function result
|
1931
|
-
"""
|
1932
|
-
# Extract call ID
|
1933
|
-
call_id = raw_data.get("call_id") if raw_data else None
|
1934
|
-
if not call_id:
|
1935
|
-
return SwaigFunctionResult("Error: Missing call_id")
|
1936
|
-
|
1937
|
-
# End the session
|
1938
|
-
self._session_manager.end_session(call_id)
|
1939
|
-
|
1940
|
-
# Update state
|
1941
|
-
state = self.get_state(call_id) or {}
|
1942
|
-
state["end_time"] = datetime.now().isoformat()
|
1943
|
-
self.update_state(call_id, state)
|
1944
|
-
|
1945
|
-
return SwaigFunctionResult("Call ended and session deactivated")
|
1946
|
-
|
1947
|
-
def set_post_prompt(self, text: str) -> 'AgentBase':
|
1948
|
-
"""
|
1949
|
-
Set the post-prompt for the agent
|
1950
|
-
|
1951
|
-
Args:
|
1952
|
-
text: Post-prompt text
|
1953
|
-
|
1954
|
-
Returns:
|
1955
|
-
Self for method chaining
|
1956
|
-
"""
|
1957
|
-
self._post_prompt = text
|
1958
|
-
return self
|
1959
|
-
|
1960
|
-
def set_auto_answer(self, enabled: bool) -> 'AgentBase':
|
1961
|
-
"""
|
1962
|
-
Set whether to automatically answer calls
|
1963
|
-
|
1964
|
-
Args:
|
1965
|
-
enabled: Whether to auto-answer
|
1966
|
-
|
1967
|
-
Returns:
|
1968
|
-
Self for method chaining
|
1969
|
-
"""
|
1970
|
-
self._auto_answer = enabled
|
1971
|
-
return self
|
1972
|
-
|
1973
|
-
def set_call_recording(self,
|
1974
|
-
enabled: bool,
|
1975
|
-
format: str = "mp4",
|
1976
|
-
stereo: bool = True) -> 'AgentBase':
|
1977
|
-
"""
|
1978
|
-
Set call recording parameters
|
1979
|
-
|
1980
|
-
Args:
|
1981
|
-
enabled: Whether to record calls
|
1982
|
-
format: Recording format
|
1983
|
-
stereo: Whether to record in stereo
|
1984
|
-
|
1985
|
-
Returns:
|
1986
|
-
Self for method chaining
|
1987
|
-
"""
|
1988
|
-
self._record_call = enabled
|
1989
|
-
self._record_format = format
|
1990
|
-
self._record_stereo = stereo
|
1991
|
-
return self
|
1992
|
-
|
1993
|
-
def add_native_function(self, function_name: str) -> 'AgentBase':
|
1994
|
-
"""
|
1995
|
-
Add a native function to the list of enabled native functions
|
1996
|
-
|
1997
|
-
Args:
|
1998
|
-
function_name: Name of native function to enable
|
1999
|
-
|
2000
|
-
Returns:
|
2001
|
-
Self for method chaining
|
2002
|
-
"""
|
2003
|
-
if function_name and isinstance(function_name, str):
|
2004
|
-
if not self.native_functions:
|
2005
|
-
self.native_functions = []
|
2006
|
-
if function_name not in self.native_functions:
|
2007
|
-
self.native_functions.append(function_name)
|
2008
|
-
return self
|
2009
|
-
|
2010
|
-
def remove_native_function(self, function_name: str) -> 'AgentBase':
|
2011
|
-
"""
|
2012
|
-
Remove a native function from the SWAIG object
|
2013
|
-
|
2014
|
-
Args:
|
2015
|
-
function_name: Name of the native function
|
2016
|
-
|
2017
|
-
Returns:
|
2018
|
-
Self for method chaining
|
2019
|
-
"""
|
2020
|
-
if function_name in self.native_functions:
|
2021
|
-
self.native_functions.remove(function_name)
|
2022
|
-
return self
|
2023
|
-
|
2024
|
-
def get_native_functions(self) -> List[str]:
|
2025
|
-
"""
|
2026
|
-
Get the list of native functions
|
2027
|
-
|
2028
|
-
Returns:
|
2029
|
-
List of native function names
|
2030
|
-
"""
|
2031
|
-
return self.native_functions.copy()
|
2032
|
-
|
2033
|
-
def has_section(self, title: str) -> bool:
|
2034
|
-
"""
|
2035
|
-
Check if a section exists in the prompt
|
2036
|
-
|
2037
|
-
Args:
|
2038
|
-
title: Section title
|
2039
|
-
|
2040
|
-
Returns:
|
2041
|
-
True if the section exists, False otherwise
|
2042
|
-
"""
|
2043
|
-
if not self._use_pom or not self.pom:
|
2044
|
-
return False
|
2045
|
-
|
2046
|
-
return self.pom.has_section(title)
|
2047
|
-
|
2048
|
-
def on_swml_request(self, request_data: Optional[dict] = None) -> Optional[dict]:
|
2049
|
-
"""
|
2050
|
-
Called when SWML is requested, with request data when available.
|
2051
|
-
|
2052
|
-
Subclasses can override this to inspect or modify SWML based on the request.
|
2053
|
-
|
2054
|
-
Args:
|
2055
|
-
request_data: Optional dictionary containing the parsed POST body
|
2056
|
-
|
2057
|
-
Returns:
|
2058
|
-
Optional dict to modify/augment the SWML document
|
2059
|
-
"""
|
2060
|
-
# Default implementation does nothing
|
2061
|
-
return None
|
2062
|
-
|
2063
|
-
def serve(self, host: Optional[str] = None, port: Optional[int] = None) -> None:
|
2064
|
-
"""
|
2065
|
-
Start a web server for this agent
|
2066
|
-
|
2067
|
-
Args:
|
2068
|
-
host: Optional host to override the default
|
2069
|
-
port: Optional port to override the default
|
2070
|
-
"""
|
2071
|
-
import uvicorn
|
2072
|
-
|
2073
|
-
# Create a FastAPI app with no automatic redirects
|
2074
|
-
app = FastAPI(redirect_slashes=False)
|
2075
|
-
|
2076
|
-
# Register all routes
|
2077
|
-
self._register_routes(app)
|
2078
|
-
|
2079
|
-
host = host or self.host
|
2080
|
-
port = port or self.port
|
2081
|
-
|
2082
|
-
# Print the auth credentials with source
|
2083
|
-
username, password, source = self.get_basic_auth_credentials(include_source=True)
|
2084
|
-
self.log.info("starting_server",
|
2085
|
-
url=f"http://{host}:{port}{self.route}",
|
2086
|
-
username=username,
|
2087
|
-
password="*" * len(password),
|
2088
|
-
auth_source=source)
|
2089
|
-
|
2090
|
-
print(f"Agent '{self.name}' is available at:")
|
2091
|
-
print(f"URL: http://{host}:{port}{self.route}")
|
2092
|
-
print(f"Basic Auth: {username}:{password} (source: {source})")
|
2093
|
-
|
2094
|
-
# Check if SIP usernames are registered and print that info
|
2095
|
-
if hasattr(self, '_sip_usernames') and self._sip_usernames:
|
2096
|
-
print(f"Registered SIP usernames: {', '.join(sorted(self._sip_usernames))}")
|
2097
|
-
|
2098
|
-
# Check if callback endpoints are registered and print them
|
2099
|
-
if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
|
2100
|
-
for path in sorted(self._routing_callbacks.keys()):
|
2101
|
-
if hasattr(self, '_sip_usernames') and path == "/sip":
|
2102
|
-
print(f"SIP endpoint: http://{host}:{port}{path}")
|
2103
|
-
else:
|
2104
|
-
print(f"Callback endpoint: http://{host}:{port}{path}")
|
2105
|
-
|
2106
|
-
# Configure Uvicorn for production
|
2107
|
-
uvicorn_log_config = uvicorn.config.LOGGING_CONFIG
|
2108
|
-
uvicorn_log_config["formatters"]["access"]["fmt"] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
2109
|
-
uvicorn_log_config["formatters"]["default"]["fmt"] = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
2110
|
-
|
2111
|
-
# Start the server
|
2112
|
-
try:
|
2113
|
-
# Run the server
|
2114
|
-
uvicorn.run(
|
2115
|
-
app,
|
2116
|
-
host=host,
|
2117
|
-
port=port,
|
2118
|
-
log_config=uvicorn_log_config
|
2119
|
-
)
|
2120
|
-
except KeyboardInterrupt:
|
2121
|
-
self.log.info("server_shutdown")
|
2122
|
-
print("\nStopping the agent.")
|
2123
|
-
|
2124
|
-
# ----------------------------------------------------------------------
|
2125
|
-
# AI Verb Configuration Methods
|
2126
|
-
# ----------------------------------------------------------------------
|
2127
|
-
|
2128
|
-
def add_hint(self, hint: str) -> 'AgentBase':
|
2129
|
-
"""
|
2130
|
-
Add a simple string hint to help the AI agent understand certain words better
|
2131
|
-
|
2132
|
-
Args:
|
2133
|
-
hint: The hint string to add
|
2134
|
-
|
2135
|
-
Returns:
|
2136
|
-
Self for method chaining
|
2137
|
-
"""
|
2138
|
-
if isinstance(hint, str) and hint:
|
2139
|
-
self._hints.append(hint)
|
2140
|
-
return self
|
2141
|
-
|
2142
|
-
def add_hints(self, hints: List[str]) -> 'AgentBase':
|
2143
|
-
"""
|
2144
|
-
Add multiple string hints
|
2145
|
-
|
2146
|
-
Args:
|
2147
|
-
hints: List of hint strings
|
2148
|
-
|
2149
|
-
Returns:
|
2150
|
-
Self for method chaining
|
2151
|
-
"""
|
2152
|
-
if hints and isinstance(hints, list):
|
2153
|
-
for hint in hints:
|
2154
|
-
if isinstance(hint, str) and hint:
|
2155
|
-
self._hints.append(hint)
|
2156
|
-
return self
|
2157
|
-
|
2158
|
-
def add_pattern_hint(self,
|
2159
|
-
hint: str,
|
2160
|
-
pattern: str,
|
2161
|
-
replace: str,
|
2162
|
-
ignore_case: bool = False) -> 'AgentBase':
|
2163
|
-
"""
|
2164
|
-
Add a complex hint with pattern matching
|
2165
|
-
|
2166
|
-
Args:
|
2167
|
-
hint: The hint to match
|
2168
|
-
pattern: Regular expression pattern
|
2169
|
-
replace: Text to replace the hint with
|
2170
|
-
ignore_case: Whether to ignore case when matching
|
2171
|
-
|
2172
|
-
Returns:
|
2173
|
-
Self for method chaining
|
2174
|
-
"""
|
2175
|
-
if hint and pattern and replace:
|
2176
|
-
self._hints.append({
|
2177
|
-
"hint": hint,
|
2178
|
-
"pattern": pattern,
|
2179
|
-
"replace": replace,
|
2180
|
-
"ignore_case": ignore_case
|
2181
|
-
})
|
2182
|
-
return self
|
2183
|
-
|
2184
|
-
def add_language(self,
|
2185
|
-
name: str,
|
2186
|
-
code: str,
|
2187
|
-
voice: str,
|
2188
|
-
speech_fillers: Optional[List[str]] = None,
|
2189
|
-
function_fillers: Optional[List[str]] = None,
|
2190
|
-
engine: Optional[str] = None,
|
2191
|
-
model: Optional[str] = None) -> 'AgentBase':
|
2192
|
-
"""
|
2193
|
-
Add a language configuration to support multilingual conversations
|
2194
|
-
|
2195
|
-
Args:
|
2196
|
-
name: Name of the language (e.g., "English", "French")
|
2197
|
-
code: Language code (e.g., "en-US", "fr-FR")
|
2198
|
-
voice: TTS voice to use. Can be a simple name (e.g., "en-US-Neural2-F")
|
2199
|
-
or a combined format "engine.voice:model" (e.g., "elevenlabs.josh:eleven_turbo_v2_5")
|
2200
|
-
speech_fillers: Optional list of filler phrases for natural speech
|
2201
|
-
function_fillers: Optional list of filler phrases during function calls
|
2202
|
-
engine: Optional explicit engine name (e.g., "elevenlabs", "rime")
|
2203
|
-
model: Optional explicit model name (e.g., "eleven_turbo_v2_5", "arcana")
|
2204
|
-
|
2205
|
-
Returns:
|
2206
|
-
Self for method chaining
|
2207
|
-
|
2208
|
-
Examples:
|
2209
|
-
# Simple voice name
|
2210
|
-
agent.add_language("English", "en-US", "en-US-Neural2-F")
|
2211
|
-
|
2212
|
-
# Explicit parameters
|
2213
|
-
agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
|
2214
|
-
|
2215
|
-
# Combined format
|
2216
|
-
agent.add_language("English", "en-US", "elevenlabs.josh:eleven_turbo_v2_5")
|
2217
|
-
"""
|
2218
|
-
language = {
|
2219
|
-
"name": name,
|
2220
|
-
"code": code
|
2221
|
-
}
|
2222
|
-
|
2223
|
-
# Handle voice formatting (either explicit params or combined string)
|
2224
|
-
if engine or model:
|
2225
|
-
# Use explicit parameters if provided
|
2226
|
-
language["voice"] = voice
|
2227
|
-
if engine:
|
2228
|
-
language["engine"] = engine
|
2229
|
-
if model:
|
2230
|
-
language["model"] = model
|
2231
|
-
elif "." in voice and ":" in voice:
|
2232
|
-
# Parse combined string format: "engine.voice:model"
|
2233
|
-
try:
|
2234
|
-
engine_voice, model_part = voice.split(":", 1)
|
2235
|
-
engine_part, voice_part = engine_voice.split(".", 1)
|
2236
|
-
|
2237
|
-
language["voice"] = voice_part
|
2238
|
-
language["engine"] = engine_part
|
2239
|
-
language["model"] = model_part
|
2240
|
-
except ValueError:
|
2241
|
-
# If parsing fails, use the voice string as-is
|
2242
|
-
language["voice"] = voice
|
2243
|
-
else:
|
2244
|
-
# Simple voice string
|
2245
|
-
language["voice"] = voice
|
2246
|
-
|
2247
|
-
# Add fillers if provided
|
2248
|
-
if speech_fillers and function_fillers:
|
2249
|
-
language["speech_fillers"] = speech_fillers
|
2250
|
-
language["function_fillers"] = function_fillers
|
2251
|
-
elif speech_fillers or function_fillers:
|
2252
|
-
# If only one type of fillers is provided, use the deprecated "fillers" field
|
2253
|
-
fillers = speech_fillers or function_fillers
|
2254
|
-
language["fillers"] = fillers
|
2255
|
-
|
2256
|
-
self._languages.append(language)
|
2257
|
-
return self
|
2258
|
-
|
2259
|
-
def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
|
2260
|
-
"""
|
2261
|
-
Set all language configurations at once
|
2262
|
-
|
2263
|
-
Args:
|
2264
|
-
languages: List of language configuration dictionaries
|
2265
|
-
|
2266
|
-
Returns:
|
2267
|
-
Self for method chaining
|
2268
|
-
"""
|
2269
|
-
if languages and isinstance(languages, list):
|
2270
|
-
self._languages = languages
|
2271
|
-
return self
|
2272
|
-
|
2273
|
-
def add_pronunciation(self,
|
2274
|
-
replace: str,
|
2275
|
-
with_text: str,
|
2276
|
-
ignore_case: bool = False) -> 'AgentBase':
|
2277
|
-
"""
|
2278
|
-
Add a pronunciation rule to help the AI speak certain words correctly
|
2279
|
-
|
2280
|
-
Args:
|
2281
|
-
replace: The expression to replace
|
2282
|
-
with_text: The phonetic spelling to use instead
|
2283
|
-
ignore_case: Whether to ignore case when matching
|
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
|
+
)
|
2284
2342
|
|
2285
|
-
|
2286
|
-
|
2287
|
-
"""
|
2288
|
-
if replace and with_text:
|
2289
|
-
rule = {
|
2290
|
-
"replace": replace,
|
2291
|
-
"with": with_text
|
2292
|
-
}
|
2293
|
-
if ignore_case:
|
2294
|
-
rule["ignore_case"] = True
|
2343
|
+
# For both GET and POST requests, process input check
|
2344
|
+
conversation_id = None
|
2295
2345
|
|
2296
|
-
|
2297
|
-
|
2298
|
-
|
2299
|
-
|
2300
|
-
|
2301
|
-
|
2302
|
-
|
2303
|
-
|
2304
|
-
|
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")
|
2305
2355
|
|
2306
|
-
|
2307
|
-
|
2308
|
-
|
2309
|
-
|
2310
|
-
|
2311
|
-
|
2312
|
-
|
2313
|
-
def set_param(self, key: str, value: Any) -> 'AgentBase':
|
2314
|
-
"""
|
2315
|
-
Set a single AI parameter
|
2316
|
-
|
2317
|
-
Args:
|
2318
|
-
key: Parameter name
|
2319
|
-
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
|
+
)
|
2320
2363
|
|
2321
|
-
|
2322
|
-
|
2323
|
-
|
2324
|
-
|
2325
|
-
|
2326
|
-
|
2327
|
-
|
2328
|
-
|
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):
|
2329
2381
|
"""
|
2330
|
-
|
2382
|
+
Attempt to find a summary in the post-prompt response data
|
2331
2383
|
|
2332
2384
|
Args:
|
2333
|
-
|
2385
|
+
body: The request body
|
2386
|
+
logger: Logger instance
|
2334
2387
|
|
2335
2388
|
Returns:
|
2336
|
-
|
2389
|
+
Summary data or None if not found
|
2337
2390
|
"""
|
2338
|
-
if
|
2339
|
-
|
2340
|
-
return self
|
2391
|
+
if not body:
|
2392
|
+
return None
|
2341
2393
|
|
2342
|
-
|
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):
|
2343
2414
|
"""
|
2344
|
-
|
2415
|
+
Register special tools for state tracking
|
2345
2416
|
|
2346
|
-
|
2347
|
-
|
2348
|
-
|
2349
|
-
Returns:
|
2350
|
-
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.
|
2351
2420
|
"""
|
2352
|
-
|
2353
|
-
|
2354
|
-
|
2355
|
-
|
2356
|
-
|
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):
|
2357
2440
|
"""
|
2358
|
-
|
2441
|
+
Handle the startup hook function call
|
2359
2442
|
|
2360
2443
|
Args:
|
2361
|
-
|
2444
|
+
args: Function arguments (empty for this hook)
|
2445
|
+
raw_data: Raw request data containing call_id
|
2362
2446
|
|
2363
2447
|
Returns:
|
2364
|
-
|
2448
|
+
Success response
|
2365
2449
|
"""
|
2366
|
-
if
|
2367
|
-
|
2368
|
-
|
2369
|
-
|
2370
|
-
|
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):
|
2371
2460
|
"""
|
2372
|
-
|
2461
|
+
Handle the hangup hook function call
|
2373
2462
|
|
2374
2463
|
Args:
|
2375
|
-
|
2464
|
+
args: Function arguments (empty for this hook)
|
2465
|
+
raw_data: Raw request data containing call_id
|
2376
2466
|
|
2377
2467
|
Returns:
|
2378
|
-
|
2468
|
+
Success response
|
2379
2469
|
"""
|
2380
|
-
if
|
2381
|
-
|
2382
|
-
|
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")
|
2383
2478
|
|
2384
|
-
def
|
2479
|
+
def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
|
2385
2480
|
"""
|
2386
|
-
|
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.
|
2387
2485
|
|
2388
2486
|
Args:
|
2389
|
-
|
2390
|
-
|
2391
|
-
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
|
2392
2489
|
|
2393
2490
|
Returns:
|
2394
|
-
|
2491
|
+
None to use the default SWML rendering (which will call _render_swml)
|
2395
2492
|
"""
|
2396
|
-
|
2397
|
-
|
2398
|
-
|
2399
|
-
"functions": functions
|
2400
|
-
}
|
2401
|
-
if meta_data and isinstance(meta_data, dict):
|
2402
|
-
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)
|
2403
2496
|
|
2404
|
-
|
2405
|
-
|
2406
|
-
|
2407
|
-
|
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]:
|
2408
2503
|
"""
|
2409
|
-
|
2504
|
+
Customization point for subclasses to modify SWML based on request data
|
2410
2505
|
|
2411
2506
|
Args:
|
2412
|
-
|
2507
|
+
request_data: Optional dictionary containing the parsed POST body
|
2508
|
+
callback_path: Optional callback path
|
2413
2509
|
|
2414
2510
|
Returns:
|
2415
|
-
|
2511
|
+
Optional dict with modifications to apply to the SWML document
|
2416
2512
|
"""
|
2417
|
-
|
2418
|
-
|
2419
|
-
valid_includes = []
|
2420
|
-
for include in includes:
|
2421
|
-
if isinstance(include, dict) and "url" in include and "functions" in include:
|
2422
|
-
if isinstance(include["functions"], list):
|
2423
|
-
valid_includes.append(include)
|
2424
|
-
|
2425
|
-
self._function_includes = valid_includes
|
2426
|
-
return self
|
2513
|
+
# Default implementation does nothing
|
2514
|
+
return None
|
2427
2515
|
|
2428
|
-
def
|
2516
|
+
def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
|
2517
|
+
path: str = "/sip") -> None:
|
2429
2518
|
"""
|
2430
|
-
|
2519
|
+
Register a callback function that will be called to determine routing
|
2520
|
+
based on POST data.
|
2431
2521
|
|
2432
|
-
|
2433
|
-
|
2434
|
-
|
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.
|
2435
2525
|
|
2436
|
-
|
2437
|
-
|
2438
|
-
|
2439
|
-
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
|
2440
2529
|
|
2441
|
-
|
2442
|
-
|
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':
|
2443
2546
|
"""
|
2444
|
-
|
2445
|
-
def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
|
2446
|
-
# Extract SIP username from the request body
|
2447
|
-
sip_username = self.extract_sip_username(body)
|
2448
|
-
|
2449
|
-
if sip_username:
|
2450
|
-
self.log.info("sip_username_extracted", username=sip_username)
|
2451
|
-
|
2452
|
-
# Check if this username is registered with this agent
|
2453
|
-
if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
|
2454
|
-
self.log.info("sip_username_matched", username=sip_username)
|
2455
|
-
# This route is already being handled by the agent, no need to redirect
|
2456
|
-
return None
|
2457
|
-
else:
|
2458
|
-
self.log.info("sip_username_not_matched", username=sip_username)
|
2459
|
-
# Not registered with this agent, let routing continue
|
2460
|
-
|
2461
|
-
return None
|
2462
|
-
|
2463
|
-
# Register the callback with the SWMLService, specifying the path
|
2464
|
-
self.register_routing_callback(sip_routing_callback, path=path)
|
2465
|
-
|
2466
|
-
# Auto-map common usernames if requested
|
2467
|
-
if auto_map:
|
2468
|
-
self.auto_map_sip_usernames()
|
2469
|
-
|
2470
|
-
return self
|
2547
|
+
Manually set the proxy URL base for webhook callbacks
|
2471
2548
|
|
2472
|
-
|
2473
|
-
"""
|
2474
|
-
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
|
2475
2550
|
|
2476
2551
|
Args:
|
2477
|
-
|
2552
|
+
proxy_url: The base URL to use for webhooks (e.g., https://example.ngrok.io)
|
2478
2553
|
|
2479
2554
|
Returns:
|
2480
2555
|
Self for method chaining
|
2481
2556
|
"""
|
2482
|
-
if
|
2483
|
-
|
2484
|
-
|
2485
|
-
|
2486
|
-
self.log.info("sip_username_registered", username=sip_username)
|
2487
|
-
|
2488
|
-
return self
|
2489
|
-
|
2490
|
-
def auto_map_sip_usernames(self) -> 'AgentBase':
|
2491
|
-
"""
|
2492
|
-
Automatically register common SIP usernames based on this agent's
|
2493
|
-
name and route
|
2494
|
-
|
2495
|
-
Returns:
|
2496
|
-
Self for method chaining
|
2497
|
-
"""
|
2498
|
-
# Register username based on agent name
|
2499
|
-
clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
|
2500
|
-
if clean_name:
|
2501
|
-
self.register_sip_username(clean_name)
|
2502
|
-
|
2503
|
-
# Register username based on route (without slashes)
|
2504
|
-
clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
|
2505
|
-
if clean_route and clean_route != clean_name:
|
2506
|
-
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
|
2507
2561
|
|
2508
|
-
|
2509
|
-
|
2510
|
-
|
2511
|
-
|
2512
|
-
|
2513
|
-
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
|
2514
2567
|
|
2515
|
-
|
2516
|
-
|
2517
|
-
def set_web_hook_url(self, url: str) -> 'AgentBase':
|
2518
|
-
"""
|
2519
|
-
Override the default web_hook_url with a supplied URL string
|
2520
|
-
|
2521
|
-
Args:
|
2522
|
-
url: The URL to use for SWAIG function webhooks
|
2523
|
-
|
2524
|
-
Returns:
|
2525
|
-
Self for method chaining
|
2526
|
-
"""
|
2527
|
-
self._web_hook_url_override = url
|
2528
|
-
return self
|
2529
|
-
|
2530
|
-
def set_post_prompt_url(self, url: str) -> 'AgentBase':
|
2531
|
-
"""
|
2532
|
-
Override the default post_prompt_url with a supplied URL string
|
2533
|
-
|
2534
|
-
Args:
|
2535
|
-
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)
|
2536
2569
|
|
2537
|
-
Returns:
|
2538
|
-
Self for method chaining
|
2539
|
-
"""
|
2540
|
-
self._post_prompt_url_override = url
|
2541
2570
|
return self
|