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