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