signalwire-agents 0.1.23__py3-none-any.whl → 0.1.25__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- signalwire_agents/__init__.py +1 -1
- signalwire_agents/agent_server.py +2 -1
- signalwire_agents/cli/config.py +61 -0
- signalwire_agents/cli/core/__init__.py +1 -0
- signalwire_agents/cli/core/agent_loader.py +254 -0
- signalwire_agents/cli/core/argparse_helpers.py +164 -0
- signalwire_agents/cli/core/dynamic_config.py +62 -0
- signalwire_agents/cli/execution/__init__.py +1 -0
- signalwire_agents/cli/execution/datamap_exec.py +437 -0
- signalwire_agents/cli/execution/webhook_exec.py +125 -0
- signalwire_agents/cli/output/__init__.py +1 -0
- signalwire_agents/cli/output/output_formatter.py +132 -0
- signalwire_agents/cli/output/swml_dump.py +177 -0
- signalwire_agents/cli/simulation/__init__.py +1 -0
- signalwire_agents/cli/simulation/data_generation.py +365 -0
- signalwire_agents/cli/simulation/data_overrides.py +187 -0
- signalwire_agents/cli/simulation/mock_env.py +271 -0
- signalwire_agents/cli/test_swaig.py +522 -2539
- signalwire_agents/cli/types.py +72 -0
- signalwire_agents/core/agent/__init__.py +1 -3
- signalwire_agents/core/agent/config/__init__.py +1 -3
- signalwire_agents/core/agent/prompt/manager.py +25 -7
- signalwire_agents/core/agent/tools/decorator.py +2 -0
- signalwire_agents/core/agent/tools/registry.py +8 -0
- signalwire_agents/core/agent_base.py +492 -3053
- signalwire_agents/core/function_result.py +31 -42
- signalwire_agents/core/mixins/__init__.py +28 -0
- signalwire_agents/core/mixins/ai_config_mixin.py +373 -0
- signalwire_agents/core/mixins/auth_mixin.py +287 -0
- signalwire_agents/core/mixins/prompt_mixin.py +345 -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 +219 -0
- signalwire_agents/core/mixins/tool_mixin.py +295 -0
- signalwire_agents/core/mixins/web_mixin.py +1130 -0
- signalwire_agents/core/skill_manager.py +3 -1
- signalwire_agents/core/swaig_function.py +10 -1
- signalwire_agents/core/swml_service.py +140 -58
- signalwire_agents/skills/README.md +452 -0
- signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
- signalwire_agents/skills/datasphere/README.md +210 -0
- signalwire_agents/skills/datasphere_serverless/README.md +258 -0
- signalwire_agents/skills/datetime/README.md +132 -0
- signalwire_agents/skills/joke/README.md +149 -0
- signalwire_agents/skills/math/README.md +161 -0
- signalwire_agents/skills/native_vector_search/skill.py +33 -13
- signalwire_agents/skills/play_background_file/README.md +218 -0
- signalwire_agents/skills/spider/README.md +236 -0
- signalwire_agents/skills/spider/__init__.py +4 -0
- signalwire_agents/skills/spider/skill.py +479 -0
- signalwire_agents/skills/swml_transfer/README.md +395 -0
- signalwire_agents/skills/swml_transfer/__init__.py +1 -0
- signalwire_agents/skills/swml_transfer/skill.py +257 -0
- signalwire_agents/skills/weather_api/README.md +178 -0
- signalwire_agents/skills/web_search/README.md +163 -0
- signalwire_agents/skills/wikipedia_search/README.md +228 -0
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/METADATA +47 -2
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/RECORD +62 -22
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/entry_points.txt +1 -1
- signalwire_agents/core/agent/config/ephemeral.py +0 -176
- signalwire_agents-0.1.23.data/data/schema.json +0 -5611
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/WHEEL +0 -0
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/licenses/LICENSE +0 -0
- {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/top_level.txt +0 -0
@@ -44,8 +44,6 @@ except ImportError:
|
|
44
44
|
"uvicorn is required. Install it with: pip install uvicorn"
|
45
45
|
)
|
46
46
|
|
47
|
-
|
48
|
-
|
49
47
|
from signalwire_agents.core.pom_builder import PomBuilder
|
50
48
|
from signalwire_agents.core.swaig_function import SWAIGFunction
|
51
49
|
from signalwire_agents.core.function_result import SwaigFunctionResult
|
@@ -59,16 +57,35 @@ from signalwire_agents.utils.schema_utils import SchemaUtils
|
|
59
57
|
from signalwire_agents.core.logging_config import get_logger, get_execution_mode
|
60
58
|
|
61
59
|
# Import refactored components
|
62
|
-
from signalwire_agents.core.agent.config.ephemeral import EphemeralAgentConfig
|
63
60
|
from signalwire_agents.core.agent.prompt.manager import PromptManager
|
64
61
|
from signalwire_agents.core.agent.tools.registry import ToolRegistry
|
65
62
|
from signalwire_agents.core.agent.tools.decorator import ToolDecorator
|
66
63
|
|
64
|
+
# Import all mixins
|
65
|
+
from signalwire_agents.core.mixins.prompt_mixin import PromptMixin
|
66
|
+
from signalwire_agents.core.mixins.tool_mixin import ToolMixin
|
67
|
+
from signalwire_agents.core.mixins.web_mixin import WebMixin
|
68
|
+
from signalwire_agents.core.mixins.auth_mixin import AuthMixin
|
69
|
+
from signalwire_agents.core.mixins.skill_mixin import SkillMixin
|
70
|
+
from signalwire_agents.core.mixins.ai_config_mixin import AIConfigMixin
|
71
|
+
from signalwire_agents.core.mixins.serverless_mixin import ServerlessMixin
|
72
|
+
from signalwire_agents.core.mixins.state_mixin import StateMixin
|
73
|
+
|
67
74
|
# Create a logger using centralized system
|
68
75
|
logger = get_logger("agent_base")
|
69
76
|
|
70
77
|
|
71
|
-
class AgentBase(
|
78
|
+
class AgentBase(
|
79
|
+
AuthMixin,
|
80
|
+
WebMixin,
|
81
|
+
SWMLService,
|
82
|
+
PromptMixin,
|
83
|
+
ToolMixin,
|
84
|
+
SkillMixin,
|
85
|
+
AIConfigMixin,
|
86
|
+
ServerlessMixin,
|
87
|
+
StateMixin
|
88
|
+
):
|
72
89
|
"""
|
73
90
|
Base class for all SignalWire AI Agents.
|
74
91
|
|
@@ -243,1034 +260,376 @@ class AgentBase(SWMLService):
|
|
243
260
|
self._contexts_builder = None
|
244
261
|
self._contexts_defined = False
|
245
262
|
|
263
|
+
# Initialize SWAIG query params for dynamic config
|
264
|
+
self._swaig_query_params = {}
|
265
|
+
|
246
266
|
self.schema_utils = SchemaUtils(schema_path)
|
247
267
|
if self.schema_utils and self.schema_utils.schema:
|
248
268
|
self.log.debug("schema_loaded", path=self.schema_utils.schema_path)
|
249
|
-
|
250
269
|
|
251
|
-
def
|
270
|
+
def get_name(self) -> str:
|
252
271
|
"""
|
253
|
-
|
272
|
+
Get agent name
|
254
273
|
|
255
|
-
|
256
|
-
|
274
|
+
Returns:
|
275
|
+
Agent name
|
257
276
|
"""
|
258
|
-
|
259
|
-
cls = self.__class__
|
260
|
-
if not hasattr(cls, 'PROMPT_SECTIONS') or cls.PROMPT_SECTIONS is None or not self._use_pom:
|
261
|
-
return
|
262
|
-
|
263
|
-
sections = cls.PROMPT_SECTIONS
|
264
|
-
|
265
|
-
# If sections is a dictionary mapping section names to content
|
266
|
-
if isinstance(sections, dict):
|
267
|
-
for title, content in sections.items():
|
268
|
-
# Handle different content types
|
269
|
-
if isinstance(content, str):
|
270
|
-
# Plain text - add as body
|
271
|
-
self.prompt_add_section(title, body=content)
|
272
|
-
elif isinstance(content, list) and content: # Only add if non-empty
|
273
|
-
# List of strings - add as bullets
|
274
|
-
self.prompt_add_section(title, bullets=content)
|
275
|
-
elif isinstance(content, dict):
|
276
|
-
# Dictionary with body/bullets/subsections
|
277
|
-
body = content.get('body', '')
|
278
|
-
bullets = content.get('bullets', [])
|
279
|
-
numbered = content.get('numbered', False)
|
280
|
-
numbered_bullets = content.get('numberedBullets', False)
|
281
|
-
|
282
|
-
# Only create section if it has content
|
283
|
-
if body or bullets or 'subsections' in content:
|
284
|
-
# Create the section
|
285
|
-
self.prompt_add_section(
|
286
|
-
title,
|
287
|
-
body=body,
|
288
|
-
bullets=bullets if bullets else None,
|
289
|
-
numbered=numbered,
|
290
|
-
numbered_bullets=numbered_bullets
|
291
|
-
)
|
292
|
-
|
293
|
-
# Process subsections if any
|
294
|
-
subsections = content.get('subsections', [])
|
295
|
-
for subsection in subsections:
|
296
|
-
if 'title' in subsection:
|
297
|
-
sub_title = subsection['title']
|
298
|
-
sub_body = subsection.get('body', '')
|
299
|
-
sub_bullets = subsection.get('bullets', [])
|
300
|
-
|
301
|
-
# Only add subsection if it has content
|
302
|
-
if sub_body or sub_bullets:
|
303
|
-
self.prompt_add_subsection(
|
304
|
-
title,
|
305
|
-
sub_title,
|
306
|
-
body=sub_body,
|
307
|
-
bullets=sub_bullets if sub_bullets else None
|
308
|
-
)
|
309
|
-
# If sections is a list of section objects, use the POM format directly
|
310
|
-
elif isinstance(sections, list):
|
311
|
-
if self.pom:
|
312
|
-
# Process each section using auto-vivifying methods
|
313
|
-
for section in sections:
|
314
|
-
if 'title' in section:
|
315
|
-
title = section['title']
|
316
|
-
body = section.get('body', '')
|
317
|
-
bullets = section.get('bullets', [])
|
318
|
-
numbered = section.get('numbered', False)
|
319
|
-
numbered_bullets = section.get('numberedBullets', False)
|
320
|
-
|
321
|
-
# Only create section if it has content
|
322
|
-
if body or bullets or 'subsections' in section:
|
323
|
-
self.prompt_add_section(
|
324
|
-
title,
|
325
|
-
body=body,
|
326
|
-
bullets=bullets if bullets else None,
|
327
|
-
numbered=numbered,
|
328
|
-
numbered_bullets=numbered_bullets
|
329
|
-
)
|
330
|
-
|
331
|
-
# Process subsections if any
|
332
|
-
subsections = section.get('subsections', [])
|
333
|
-
for subsection in subsections:
|
334
|
-
if 'title' in subsection:
|
335
|
-
sub_title = subsection['title']
|
336
|
-
sub_body = subsection.get('body', '')
|
337
|
-
sub_bullets = subsection.get('bullets', [])
|
338
|
-
|
339
|
-
# Only add subsection if it has content
|
340
|
-
if sub_body or sub_bullets:
|
341
|
-
self.prompt_add_subsection(
|
342
|
-
title,
|
343
|
-
sub_title,
|
344
|
-
body=sub_body,
|
345
|
-
bullets=sub_bullets if sub_bullets else None
|
346
|
-
)
|
347
|
-
|
348
|
-
# ----------------------------------------------------------------------
|
349
|
-
# Prompt Building Methods
|
350
|
-
# ----------------------------------------------------------------------
|
277
|
+
return self.name
|
351
278
|
|
352
|
-
def
|
279
|
+
def get_full_url(self, include_auth: bool = False) -> str:
|
353
280
|
"""
|
354
|
-
|
281
|
+
Get the full URL for this agent's endpoint
|
355
282
|
|
356
283
|
Args:
|
357
|
-
|
284
|
+
include_auth: Whether to include authentication credentials in the URL
|
358
285
|
|
359
286
|
Returns:
|
360
|
-
|
361
|
-
|
362
|
-
Note:
|
363
|
-
Contexts can coexist with traditional prompts. The restriction is only
|
364
|
-
that you can't mix POM sections with raw text in the main prompt.
|
287
|
+
Full URL including host, port, and route (with auth if requested)
|
365
288
|
"""
|
366
|
-
|
367
|
-
|
368
|
-
|
369
|
-
|
370
|
-
|
371
|
-
|
372
|
-
|
373
|
-
|
289
|
+
mode = get_execution_mode()
|
290
|
+
|
291
|
+
if mode == 'cgi':
|
292
|
+
protocol = 'https' if os.getenv('HTTPS') == 'on' else 'http'
|
293
|
+
host = os.getenv('HTTP_HOST') or os.getenv('SERVER_NAME') or 'localhost'
|
294
|
+
script_name = os.getenv('SCRIPT_NAME', '')
|
295
|
+
base_url = f"{protocol}://{host}{script_name}"
|
296
|
+
elif mode == 'lambda':
|
297
|
+
# AWS Lambda Function URL format
|
298
|
+
lambda_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
|
299
|
+
if lambda_url:
|
300
|
+
base_url = lambda_url.rstrip('/')
|
301
|
+
else:
|
302
|
+
# Fallback construction for Lambda
|
303
|
+
region = os.getenv('AWS_REGION', 'us-east-1')
|
304
|
+
function_name = os.getenv('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
|
305
|
+
base_url = f"https://{function_name}.lambda-url.{region}.on.aws"
|
306
|
+
elif mode == 'google_cloud_function':
|
307
|
+
# Google Cloud Functions URL format
|
308
|
+
project_id = os.getenv('GOOGLE_CLOUD_PROJECT') or os.getenv('GCP_PROJECT')
|
309
|
+
region = os.getenv('FUNCTION_REGION') or os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
|
310
|
+
service_name = os.getenv('K_SERVICE') or os.getenv('FUNCTION_TARGET', 'unknown')
|
374
311
|
|
375
|
-
if
|
376
|
-
|
377
|
-
|
312
|
+
if project_id:
|
313
|
+
base_url = f"https://{region}-{project_id}.cloudfunctions.net/{service_name}"
|
314
|
+
else:
|
315
|
+
# Fallback for local testing or incomplete environment
|
316
|
+
base_url = f"https://localhost:8080"
|
317
|
+
elif mode == 'azure_function':
|
318
|
+
# Azure Functions URL format
|
319
|
+
function_app_name = os.getenv('WEBSITE_SITE_NAME') or os.getenv('AZURE_FUNCTIONS_APP_NAME')
|
320
|
+
function_name = os.getenv('AZURE_FUNCTION_NAME', 'unknown')
|
378
321
|
|
379
|
-
|
380
|
-
|
381
|
-
|
382
|
-
|
383
|
-
|
322
|
+
if function_app_name:
|
323
|
+
base_url = f"https://{function_app_name}.azurewebsites.net/api/{function_name}"
|
324
|
+
else:
|
325
|
+
# Fallback for local testing
|
326
|
+
base_url = f"https://localhost:7071/api/{function_name}"
|
327
|
+
else:
|
328
|
+
# Server mode - use the SWMLService's unified URL building
|
329
|
+
# Build the full URL using the parent's method
|
330
|
+
base_url = self._build_full_url(endpoint="", include_auth=include_auth)
|
331
|
+
return base_url
|
384
332
|
|
385
|
-
|
386
|
-
|
387
|
-
|
388
|
-
|
333
|
+
# For serverless modes, add authentication if requested
|
334
|
+
if include_auth:
|
335
|
+
username, password = self.get_basic_auth_credentials()
|
336
|
+
if username and password:
|
337
|
+
# Parse URL to insert auth
|
338
|
+
from urllib.parse import urlparse, urlunparse
|
339
|
+
parsed = urlparse(base_url)
|
340
|
+
# Reconstruct with auth
|
341
|
+
base_url = urlunparse((
|
342
|
+
parsed.scheme,
|
343
|
+
f"{username}:{password}@{parsed.netloc}",
|
344
|
+
parsed.path,
|
345
|
+
parsed.params,
|
346
|
+
parsed.query,
|
347
|
+
parsed.fragment
|
348
|
+
))
|
349
|
+
|
350
|
+
# Add route for serverless modes
|
351
|
+
if self.route and self.route != "/" and not base_url.endswith(self.route):
|
352
|
+
base_url = f"{base_url}/{self.route.lstrip('/')}"
|
353
|
+
|
354
|
+
return base_url
|
389
355
|
|
390
|
-
def
|
356
|
+
def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
|
391
357
|
"""
|
392
|
-
|
358
|
+
Called when a post-prompt summary is received
|
393
359
|
|
394
360
|
Args:
|
395
|
-
|
396
|
-
|
397
|
-
Returns:
|
398
|
-
Self for method chaining
|
361
|
+
summary: The summary object or None if no summary was found
|
362
|
+
raw_data: The complete raw POST data from the request
|
399
363
|
"""
|
400
|
-
|
401
|
-
|
364
|
+
# Default implementation does nothing
|
365
|
+
pass
|
402
366
|
|
403
|
-
def
|
367
|
+
def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
|
404
368
|
"""
|
405
|
-
|
369
|
+
Enable SIP-based routing for this agent
|
370
|
+
|
371
|
+
This allows the agent to automatically route SIP requests based on SIP usernames.
|
372
|
+
When enabled, an endpoint at the specified path is automatically created
|
373
|
+
that will handle SIP requests and deliver them to this agent.
|
406
374
|
|
407
375
|
Args:
|
408
|
-
|
409
|
-
|
376
|
+
auto_map: Whether to automatically map common SIP usernames to this agent
|
377
|
+
(based on the agent name and route path)
|
378
|
+
path: The path to register the SIP routing endpoint (default: "/sip")
|
379
|
+
|
410
380
|
Returns:
|
411
381
|
Self for method chaining
|
412
382
|
"""
|
413
|
-
|
383
|
+
# Create a routing callback that handles SIP usernames
|
384
|
+
def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
|
385
|
+
# Extract SIP username from the request body
|
386
|
+
sip_username = self.extract_sip_username(body)
|
387
|
+
|
388
|
+
if sip_username:
|
389
|
+
self.log.info("sip_username_extracted", username=sip_username)
|
390
|
+
|
391
|
+
# Check if this username is registered with this agent
|
392
|
+
if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
|
393
|
+
self.log.info("sip_username_matched", username=sip_username)
|
394
|
+
# This route is already being handled by the agent, no need to redirect
|
395
|
+
return None
|
396
|
+
else:
|
397
|
+
self.log.info("sip_username_not_matched", username=sip_username)
|
398
|
+
# Not registered with this agent, let routing continue
|
399
|
+
|
400
|
+
return None
|
401
|
+
|
402
|
+
# Register the callback with the SWMLService, specifying the path
|
403
|
+
self.register_routing_callback(sip_routing_callback, path=path)
|
404
|
+
|
405
|
+
# Auto-map common usernames if requested
|
406
|
+
if auto_map:
|
407
|
+
self.auto_map_sip_usernames()
|
408
|
+
|
414
409
|
return self
|
415
410
|
|
416
|
-
def
|
411
|
+
def register_sip_username(self, sip_username: str) -> 'AgentBase':
|
417
412
|
"""
|
418
|
-
|
413
|
+
Register a SIP username that should be routed to this agent
|
419
414
|
|
420
415
|
Args:
|
421
|
-
|
416
|
+
sip_username: SIP username to register
|
422
417
|
|
423
418
|
Returns:
|
424
419
|
Self for method chaining
|
425
420
|
"""
|
426
|
-
self
|
421
|
+
if not hasattr(self, '_sip_usernames'):
|
422
|
+
self._sip_usernames = set()
|
423
|
+
|
424
|
+
self._sip_usernames.add(sip_username.lower())
|
425
|
+
self.log.info("sip_username_registered", username=sip_username)
|
426
|
+
|
427
427
|
return self
|
428
428
|
|
429
|
-
def
|
430
|
-
self,
|
431
|
-
title: str,
|
432
|
-
body: str = "",
|
433
|
-
bullets: Optional[List[str]] = None,
|
434
|
-
numbered: bool = False,
|
435
|
-
numbered_bullets: bool = False,
|
436
|
-
subsections: Optional[List[Dict[str, Any]]] = None
|
437
|
-
) -> 'AgentBase':
|
429
|
+
def auto_map_sip_usernames(self) -> 'AgentBase':
|
438
430
|
"""
|
439
|
-
|
431
|
+
Automatically register common SIP usernames based on this agent's
|
432
|
+
name and route
|
440
433
|
|
441
|
-
Args:
|
442
|
-
title: Section title
|
443
|
-
body: Optional section body text
|
444
|
-
bullets: Optional list of bullet points
|
445
|
-
numbered: Whether this section should be numbered
|
446
|
-
numbered_bullets: Whether bullets should be numbered
|
447
|
-
subsections: Optional list of subsection objects
|
448
|
-
|
449
434
|
Returns:
|
450
435
|
Self for method chaining
|
451
436
|
"""
|
452
|
-
|
453
|
-
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
437
|
+
# Register username based on agent name
|
438
|
+
clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
|
439
|
+
if clean_name:
|
440
|
+
self.register_sip_username(clean_name)
|
441
|
+
|
442
|
+
# Register username based on route (without slashes)
|
443
|
+
clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
|
444
|
+
if clean_route and clean_route != clean_name:
|
445
|
+
self.register_sip_username(clean_route)
|
446
|
+
|
447
|
+
# Register common variations if they make sense
|
448
|
+
if len(clean_name) > 3:
|
449
|
+
# Register without vowels
|
450
|
+
no_vowels = re.sub(r'[aeiou]', '', clean_name)
|
451
|
+
if no_vowels != clean_name and len(no_vowels) > 2:
|
452
|
+
self.register_sip_username(no_vowels)
|
453
|
+
|
460
454
|
return self
|
461
|
-
|
462
|
-
def
|
463
|
-
self,
|
464
|
-
title: str,
|
465
|
-
body: Optional[str] = None,
|
466
|
-
bullet: Optional[str] = None,
|
467
|
-
bullets: Optional[List[str]] = None
|
468
|
-
) -> 'AgentBase':
|
455
|
+
|
456
|
+
def set_web_hook_url(self, url: str) -> 'AgentBase':
|
469
457
|
"""
|
470
|
-
|
458
|
+
Override the default web_hook_url with a supplied URL string
|
471
459
|
|
472
460
|
Args:
|
473
|
-
|
474
|
-
body: Optional text to append to section body
|
475
|
-
bullet: Optional single bullet point to add
|
476
|
-
bullets: Optional list of bullet points to add
|
461
|
+
url: The URL to use for SWAIG function webhooks
|
477
462
|
|
478
463
|
Returns:
|
479
464
|
Self for method chaining
|
480
465
|
"""
|
481
|
-
self.
|
482
|
-
title=title,
|
483
|
-
body=body,
|
484
|
-
bullet=bullet,
|
485
|
-
bullets=bullets
|
486
|
-
)
|
466
|
+
self._web_hook_url_override = url
|
487
467
|
return self
|
488
|
-
|
489
|
-
def
|
490
|
-
self,
|
491
|
-
parent_title: str,
|
492
|
-
title: str,
|
493
|
-
body: str = "",
|
494
|
-
bullets: Optional[List[str]] = None
|
495
|
-
) -> 'AgentBase':
|
468
|
+
|
469
|
+
def set_post_prompt_url(self, url: str) -> 'AgentBase':
|
496
470
|
"""
|
497
|
-
|
471
|
+
Override the default post_prompt_url with a supplied URL string
|
498
472
|
|
499
473
|
Args:
|
500
|
-
|
501
|
-
title: Subsection title
|
502
|
-
body: Optional subsection body text
|
503
|
-
bullets: Optional list of bullet points
|
474
|
+
url: The URL to use for post-prompt summary delivery
|
504
475
|
|
505
476
|
Returns:
|
506
477
|
Self for method chaining
|
507
478
|
"""
|
508
|
-
self.
|
509
|
-
parent_title=parent_title,
|
510
|
-
title=title,
|
511
|
-
body=body,
|
512
|
-
bullets=bullets
|
513
|
-
)
|
479
|
+
self._post_prompt_url_override = url
|
514
480
|
return self
|
515
481
|
|
516
|
-
def
|
482
|
+
def add_swaig_query_params(self, params: Dict[str, str]) -> 'AgentBase':
|
517
483
|
"""
|
518
|
-
|
484
|
+
Add query parameters that will be included in all SWAIG webhook URLs
|
485
|
+
|
486
|
+
This is particularly useful for preserving dynamic configuration state
|
487
|
+
across SWAIG callbacks. For example, if your dynamic config adds skills
|
488
|
+
based on query parameters, you can pass those same parameters through
|
489
|
+
to the SWAIG webhook so the same configuration is applied.
|
519
490
|
|
520
491
|
Args:
|
521
|
-
|
492
|
+
params: Dictionary of query parameters to add to SWAIG URLs
|
522
493
|
|
523
494
|
Returns:
|
524
|
-
|
495
|
+
Self for method chaining
|
496
|
+
|
497
|
+
Example:
|
498
|
+
def dynamic_config(query_params, body_params, headers, agent):
|
499
|
+
if query_params.get('tier') == 'premium':
|
500
|
+
agent.add_skill('advanced_search')
|
501
|
+
# Preserve the tier param so SWAIG callbacks work
|
502
|
+
agent.add_swaig_query_params({'tier': 'premium'})
|
525
503
|
"""
|
526
|
-
|
527
|
-
|
528
|
-
|
529
|
-
# Tool/Function Management
|
530
|
-
# ----------------------------------------------------------------------
|
504
|
+
if params and isinstance(params, dict):
|
505
|
+
self._swaig_query_params.update(params)
|
506
|
+
return self
|
531
507
|
|
532
|
-
def
|
533
|
-
self,
|
534
|
-
name: str,
|
535
|
-
description: str,
|
536
|
-
parameters: Dict[str, Any],
|
537
|
-
handler: Callable,
|
538
|
-
secure: bool = True,
|
539
|
-
fillers: Optional[Dict[str, List[str]]] = None,
|
540
|
-
webhook_url: Optional[str] = None,
|
541
|
-
**swaig_fields
|
542
|
-
) -> 'AgentBase':
|
508
|
+
def clear_swaig_query_params(self) -> 'AgentBase':
|
543
509
|
"""
|
544
|
-
|
510
|
+
Clear all SWAIG query parameters
|
545
511
|
|
546
|
-
Args:
|
547
|
-
name: Function name (must be unique)
|
548
|
-
description: Function description for the AI
|
549
|
-
parameters: JSON Schema of parameters
|
550
|
-
handler: Function to call when invoked
|
551
|
-
secure: Whether to require token validation
|
552
|
-
fillers: Optional dict mapping language codes to arrays of filler phrases
|
553
|
-
webhook_url: Optional external webhook URL to use instead of local handling
|
554
|
-
**swaig_fields: Additional SWAIG fields to include in function definition
|
555
|
-
|
556
512
|
Returns:
|
557
513
|
Self for method chaining
|
558
514
|
"""
|
559
|
-
self.
|
560
|
-
name=name,
|
561
|
-
description=description,
|
562
|
-
parameters=parameters,
|
563
|
-
handler=handler,
|
564
|
-
secure=secure,
|
565
|
-
fillers=fillers,
|
566
|
-
webhook_url=webhook_url,
|
567
|
-
**swaig_fields
|
568
|
-
)
|
515
|
+
self._swaig_query_params = {}
|
569
516
|
return self
|
570
517
|
|
571
|
-
def
|
518
|
+
def _render_swml(self, call_id: str = None, modifications: Optional[dict] = None) -> str:
|
572
519
|
"""
|
573
|
-
|
520
|
+
Render the complete SWML document using SWMLService methods
|
574
521
|
|
575
522
|
Args:
|
576
|
-
|
523
|
+
call_id: Optional call ID for session-specific tokens
|
524
|
+
modifications: Optional dict of modifications to apply to the SWML
|
577
525
|
|
578
526
|
Returns:
|
579
|
-
|
580
|
-
"""
|
581
|
-
self._tool_registry.register_swaig_function(function_dict)
|
582
|
-
return self
|
583
|
-
|
584
|
-
def _tool_decorator(self, name=None, **kwargs):
|
527
|
+
SWML document as a string
|
585
528
|
"""
|
586
|
-
|
529
|
+
self.log.debug("_render_swml_called",
|
530
|
+
has_modifications=bool(modifications),
|
531
|
+
use_ephemeral=bool(modifications and modifications.get("__use_ephemeral_agent")),
|
532
|
+
has_dynamic_callback=bool(self._dynamic_config_callback))
|
533
|
+
|
534
|
+
# Check if we need to use an ephemeral agent for dynamic configuration
|
535
|
+
agent_to_use = self
|
536
|
+
if modifications and modifications.get("__use_ephemeral_agent"):
|
537
|
+
# Create an ephemeral copy for this request
|
538
|
+
self.log.debug("creating_ephemeral_agent",
|
539
|
+
original_sections=len(self._prompt_manager._sections) if hasattr(self._prompt_manager, '_sections') else 0)
|
540
|
+
agent_to_use = self._create_ephemeral_copy()
|
541
|
+
self.log.debug("ephemeral_agent_created",
|
542
|
+
ephemeral_sections=len(agent_to_use._prompt_manager._sections) if hasattr(agent_to_use._prompt_manager, '_sections') else 0)
|
543
|
+
|
544
|
+
# Extract the request data
|
545
|
+
request = modifications.get("__request")
|
546
|
+
request_data = modifications.get("__request_data", {})
|
547
|
+
|
548
|
+
if self._dynamic_config_callback:
|
549
|
+
try:
|
550
|
+
# Extract request data
|
551
|
+
if request:
|
552
|
+
query_params = dict(request.query_params)
|
553
|
+
headers = dict(request.headers)
|
554
|
+
else:
|
555
|
+
# No request object - use empty defaults
|
556
|
+
query_params = {}
|
557
|
+
headers = {}
|
558
|
+
body_params = request_data
|
559
|
+
|
560
|
+
# Call the dynamic config callback with the ephemeral agent
|
561
|
+
# This allows FULL dynamic configuration including adding skills
|
562
|
+
self.log.debug("calling_dynamic_config_on_ephemeral", has_request=bool(request))
|
563
|
+
self._dynamic_config_callback(query_params, body_params, headers, agent_to_use)
|
564
|
+
self.log.debug("dynamic_config_complete",
|
565
|
+
ephemeral_sections_after=len(agent_to_use._prompt_manager._sections) if hasattr(agent_to_use._prompt_manager, '_sections') else 0)
|
566
|
+
|
567
|
+
except Exception as e:
|
568
|
+
self.log.error("dynamic_config_error", error=str(e))
|
569
|
+
|
570
|
+
# Clear the special markers so they don't affect rendering
|
571
|
+
modifications = None
|
587
572
|
|
588
|
-
|
573
|
+
# Reset the document to a clean state
|
574
|
+
agent_to_use.reset_document()
|
589
575
|
|
590
|
-
|
591
|
-
|
592
|
-
|
593
|
-
"""
|
594
|
-
return ToolDecorator.create_instance_decorator(self._tool_registry)(name, **kwargs)
|
595
|
-
|
596
|
-
|
597
|
-
@classmethod
|
598
|
-
def tool(cls, name=None, **kwargs):
|
599
|
-
"""
|
600
|
-
Class method decorator for defining SWAIG tools
|
576
|
+
# Get prompt
|
577
|
+
prompt = agent_to_use.get_prompt()
|
578
|
+
prompt_is_pom = isinstance(prompt, list)
|
601
579
|
|
602
|
-
|
580
|
+
# Get post-prompt
|
581
|
+
post_prompt = agent_to_use.get_post_prompt()
|
603
582
|
|
604
|
-
|
605
|
-
|
606
|
-
|
607
|
-
|
608
|
-
|
609
|
-
|
610
|
-
# ----------------------------------------------------------------------
|
611
|
-
# Override Points for Subclasses
|
612
|
-
# ----------------------------------------------------------------------
|
613
|
-
|
614
|
-
def get_name(self) -> str:
|
615
|
-
"""
|
616
|
-
Get agent name
|
583
|
+
# Generate a call ID if needed
|
584
|
+
if agent_to_use._enable_state_tracking and call_id is None:
|
585
|
+
call_id = agent_to_use._session_manager.create_session()
|
586
|
+
|
587
|
+
# Start with any SWAIG query params that were set
|
588
|
+
query_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
|
617
589
|
|
618
|
-
|
619
|
-
|
620
|
-
"""
|
621
|
-
return self.name
|
622
|
-
|
623
|
-
def get_app(self):
|
624
|
-
"""
|
625
|
-
Get the FastAPI application instance for deployment adapters like Lambda/Mangum
|
590
|
+
# Get the default webhook URL with auth
|
591
|
+
default_webhook_url = agent_to_use._build_webhook_url("swaig", query_params)
|
626
592
|
|
627
|
-
|
628
|
-
|
593
|
+
# Use override if set
|
594
|
+
if hasattr(agent_to_use, '_web_hook_url_override') and agent_to_use._web_hook_url_override:
|
595
|
+
default_webhook_url = agent_to_use._web_hook_url_override
|
629
596
|
|
630
|
-
|
631
|
-
|
632
|
-
"""
|
633
|
-
if self._app is None:
|
634
|
-
# Initialize the app if it hasn't been created yet
|
635
|
-
# This follows the same initialization logic as serve() but without running uvicorn
|
636
|
-
from fastapi import FastAPI
|
637
|
-
from fastapi.middleware.cors import CORSMiddleware
|
638
|
-
|
639
|
-
# Create a FastAPI app with explicit redirect_slashes=False
|
640
|
-
app = FastAPI(redirect_slashes=False)
|
641
|
-
|
642
|
-
# Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
|
643
|
-
@app.get("/health")
|
644
|
-
@app.post("/health")
|
645
|
-
async def health_check():
|
646
|
-
"""Health check endpoint for Kubernetes liveness probe"""
|
647
|
-
return {
|
648
|
-
"status": "healthy",
|
649
|
-
"agent": self.get_name(),
|
650
|
-
"route": self.route,
|
651
|
-
"functions": len(self._tool_registry._swaig_functions)
|
652
|
-
}
|
653
|
-
|
654
|
-
@app.get("/ready")
|
655
|
-
@app.post("/ready")
|
656
|
-
async def readiness_check():
|
657
|
-
"""Readiness check endpoint for Kubernetes readiness probe"""
|
658
|
-
return {
|
659
|
-
"status": "ready",
|
660
|
-
"agent": self.get_name(),
|
661
|
-
"route": self.route,
|
662
|
-
"functions": len(self._tool_registry._swaig_functions)
|
663
|
-
}
|
664
|
-
|
665
|
-
# Add CORS middleware if needed
|
666
|
-
app.add_middleware(
|
667
|
-
CORSMiddleware,
|
668
|
-
allow_origins=["*"],
|
669
|
-
allow_credentials=True,
|
670
|
-
allow_methods=["*"],
|
671
|
-
allow_headers=["*"],
|
672
|
-
)
|
673
|
-
|
674
|
-
# Create router and register routes
|
675
|
-
router = self.as_router()
|
676
|
-
|
677
|
-
# Log registered routes for debugging
|
678
|
-
self.log.debug("router_routes_registered")
|
679
|
-
for route in router.routes:
|
680
|
-
if hasattr(route, "path"):
|
681
|
-
self.log.debug("router_route", path=route.path)
|
682
|
-
|
683
|
-
# Include the router
|
684
|
-
app.include_router(router, prefix=self.route)
|
685
|
-
|
686
|
-
# Register a catch-all route for debugging and troubleshooting
|
687
|
-
@app.get("/{full_path:path}")
|
688
|
-
@app.post("/{full_path:path}")
|
689
|
-
async def handle_all_routes(request: Request, full_path: str):
|
690
|
-
self.log.debug("request_received", path=full_path)
|
691
|
-
|
692
|
-
# Check if the path is meant for this agent
|
693
|
-
if not full_path.startswith(self.route.lstrip("/")):
|
694
|
-
return {"error": "Invalid route"}
|
695
|
-
|
696
|
-
# Extract the path relative to this agent's route
|
697
|
-
relative_path = full_path[len(self.route.lstrip("/")):]
|
698
|
-
relative_path = relative_path.lstrip("/")
|
699
|
-
self.log.debug("relative_path_extracted", path=relative_path)
|
700
|
-
|
701
|
-
return {"error": "Path not found"}
|
702
|
-
|
703
|
-
# Log all app routes for debugging
|
704
|
-
self.log.debug("app_routes_registered")
|
705
|
-
for route in app.routes:
|
706
|
-
if hasattr(route, "path"):
|
707
|
-
self.log.debug("app_route", path=route.path)
|
708
|
-
|
709
|
-
self._app = app
|
597
|
+
# Prepare SWAIG object (correct format)
|
598
|
+
swaig_obj = {}
|
710
599
|
|
711
|
-
|
712
|
-
|
713
|
-
|
714
|
-
"""
|
715
|
-
Get the prompt for the agent
|
716
|
-
|
717
|
-
Returns:
|
718
|
-
Either a string prompt or a POM object as list of dicts
|
719
|
-
"""
|
720
|
-
# First check if prompt manager has a prompt
|
721
|
-
prompt_result = self._prompt_manager.get_prompt()
|
722
|
-
if prompt_result is not None:
|
723
|
-
return prompt_result
|
724
|
-
|
725
|
-
# If using POM, return the POM structure
|
726
|
-
if self._use_pom and self.pom:
|
727
|
-
try:
|
728
|
-
# Try different methods that might be available on the POM implementation
|
729
|
-
if hasattr(self.pom, 'render_dict'):
|
730
|
-
return self.pom.render_dict()
|
731
|
-
elif hasattr(self.pom, 'to_dict'):
|
732
|
-
return self.pom.to_dict()
|
733
|
-
elif hasattr(self.pom, 'to_list'):
|
734
|
-
return self.pom.to_list()
|
735
|
-
elif hasattr(self.pom, 'render'):
|
736
|
-
render_result = self.pom.render()
|
737
|
-
# If render returns a string, we need to convert it to JSON
|
738
|
-
if isinstance(render_result, str):
|
739
|
-
try:
|
740
|
-
import json
|
741
|
-
return json.loads(render_result)
|
742
|
-
except:
|
743
|
-
# If we can't parse as JSON, fall back to raw text
|
744
|
-
pass
|
745
|
-
return render_result
|
746
|
-
else:
|
747
|
-
# Last resort: attempt to convert the POM object directly to a list/dict
|
748
|
-
# This assumes the POM object has a reasonable __str__ or __repr__ method
|
749
|
-
pom_data = self.pom.__dict__
|
750
|
-
if '_sections' in pom_data and isinstance(pom_data['_sections'], list):
|
751
|
-
return pom_data['_sections']
|
752
|
-
# Fall through to default if nothing worked
|
753
|
-
except Exception as e:
|
754
|
-
self.log.error("pom_rendering_failed", error=str(e))
|
755
|
-
# Fall back to raw text if POM fails
|
756
|
-
|
757
|
-
# Return default text
|
758
|
-
return f"You are {self.name}, a helpful AI assistant."
|
759
|
-
|
760
|
-
def get_post_prompt(self) -> Optional[str]:
|
761
|
-
"""
|
762
|
-
Get the post-prompt for the agent
|
763
|
-
|
764
|
-
Returns:
|
765
|
-
Post-prompt text or None if not set
|
766
|
-
"""
|
767
|
-
return self._prompt_manager.get_post_prompt()
|
768
|
-
|
769
|
-
def define_tools(self) -> List[SWAIGFunction]:
|
770
|
-
"""
|
771
|
-
Define the tools this agent can use
|
772
|
-
|
773
|
-
Returns:
|
774
|
-
List of SWAIGFunction objects or raw dictionaries (for data_map tools)
|
775
|
-
|
776
|
-
This method can be overridden by subclasses.
|
777
|
-
"""
|
778
|
-
tools = []
|
779
|
-
for func in self._tool_registry._swaig_functions.values():
|
780
|
-
if isinstance(func, dict):
|
781
|
-
# Raw dictionary from register_swaig_function (e.g., DataMap)
|
782
|
-
tools.append(func)
|
783
|
-
else:
|
784
|
-
# SWAIGFunction object from define_tool
|
785
|
-
tools.append(func)
|
786
|
-
return tools
|
787
|
-
|
788
|
-
def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
|
789
|
-
"""
|
790
|
-
Called when a post-prompt summary is received
|
791
|
-
|
792
|
-
Args:
|
793
|
-
summary: The summary object or None if no summary was found
|
794
|
-
raw_data: The complete raw POST data from the request
|
795
|
-
"""
|
796
|
-
# Default implementation does nothing
|
797
|
-
pass
|
798
|
-
|
799
|
-
def on_function_call(self, name: str, args: Dict[str, Any], raw_data: Optional[Dict[str, Any]] = None) -> Any:
|
800
|
-
"""
|
801
|
-
Called when a SWAIG function is invoked
|
802
|
-
|
803
|
-
Args:
|
804
|
-
name: Function name
|
805
|
-
args: Function arguments
|
806
|
-
raw_data: Raw request data
|
807
|
-
|
808
|
-
Returns:
|
809
|
-
Function result
|
810
|
-
"""
|
811
|
-
# Check if the function is registered
|
812
|
-
if name not in self._tool_registry._swaig_functions:
|
813
|
-
# If the function is not found, return an error
|
814
|
-
return {"response": f"Function '{name}' not found"}
|
815
|
-
|
816
|
-
# Get the function
|
817
|
-
func = self._tool_registry._swaig_functions[name]
|
818
|
-
|
819
|
-
# Check if this is a data_map function (raw dictionary)
|
820
|
-
if isinstance(func, dict):
|
821
|
-
# Data_map functions execute on SignalWire's server, not here
|
822
|
-
# This should never be called, but if it is, return an error
|
823
|
-
return {"response": f"Data map function '{name}' should be executed by SignalWire server, not locally"}
|
824
|
-
|
825
|
-
# Check if this is an external webhook function
|
826
|
-
if hasattr(func, 'webhook_url') and func.webhook_url:
|
827
|
-
# External webhook functions should be called directly by SignalWire, not locally
|
828
|
-
return {"response": f"External webhook function '{name}' should be executed by SignalWire at {func.webhook_url}, not locally"}
|
829
|
-
|
830
|
-
# Call the handler for regular SWAIG functions
|
831
|
-
try:
|
832
|
-
result = func.handler(args, raw_data)
|
833
|
-
if result is None:
|
834
|
-
# If the handler returns None, create a default response
|
835
|
-
result = SwaigFunctionResult("Function executed successfully")
|
836
|
-
return result
|
837
|
-
except Exception as e:
|
838
|
-
# If the handler raises an exception, return an error response
|
839
|
-
return {"response": f"Error executing function '{name}': {str(e)}"}
|
840
|
-
|
841
|
-
def validate_basic_auth(self, username: str, password: str) -> bool:
|
842
|
-
"""
|
843
|
-
Validate basic auth credentials
|
844
|
-
|
845
|
-
Args:
|
846
|
-
username: Username from request
|
847
|
-
password: Password from request
|
848
|
-
|
849
|
-
Returns:
|
850
|
-
True if valid, False otherwise
|
851
|
-
|
852
|
-
This method can be overridden by subclasses.
|
853
|
-
"""
|
854
|
-
return (username, password) == self._basic_auth
|
855
|
-
|
856
|
-
def _create_tool_token(self, tool_name: str, call_id: str) -> str:
|
857
|
-
"""
|
858
|
-
Create a secure token for a tool call
|
859
|
-
|
860
|
-
Args:
|
861
|
-
tool_name: Name of the tool
|
862
|
-
call_id: Call ID for this session
|
863
|
-
|
864
|
-
Returns:
|
865
|
-
Secure token string
|
866
|
-
"""
|
867
|
-
try:
|
868
|
-
# Ensure we have a session manager
|
869
|
-
if not hasattr(self, '_session_manager'):
|
870
|
-
self.log.error("no_session_manager")
|
871
|
-
return ""
|
872
|
-
|
873
|
-
# Create the token using the session manager
|
874
|
-
return self._session_manager.create_tool_token(tool_name, call_id)
|
875
|
-
except Exception as e:
|
876
|
-
self.log.error("token_creation_error", error=str(e), tool=tool_name, call_id=call_id)
|
877
|
-
return ""
|
878
|
-
|
879
|
-
def validate_tool_token(self, function_name: str, token: str, call_id: str) -> bool:
|
880
|
-
"""
|
881
|
-
Validate a tool token
|
882
|
-
|
883
|
-
Args:
|
884
|
-
function_name: Name of the function/tool
|
885
|
-
token: Token to validate
|
886
|
-
call_id: Call ID for the session
|
887
|
-
|
888
|
-
Returns:
|
889
|
-
True if token is valid, False otherwise
|
890
|
-
"""
|
891
|
-
try:
|
892
|
-
# Skip validation for non-secure tools
|
893
|
-
if function_name not in self._tool_registry._swaig_functions:
|
894
|
-
self.log.warning("unknown_function", function=function_name)
|
895
|
-
return False
|
896
|
-
|
897
|
-
# Get the function and check if it's secure
|
898
|
-
func = self._tool_registry._swaig_functions[function_name]
|
899
|
-
is_secure = True # Default to secure
|
900
|
-
|
901
|
-
if isinstance(func, dict):
|
902
|
-
# For raw dictionaries (DataMap functions), they're always secure
|
903
|
-
is_secure = True
|
904
|
-
else:
|
905
|
-
# For SWAIGFunction objects, check the secure attribute
|
906
|
-
is_secure = func.secure
|
907
|
-
|
908
|
-
# Always allow non-secure functions
|
909
|
-
if not is_secure:
|
910
|
-
self.log.debug("non_secure_function_allowed", function=function_name)
|
911
|
-
return True
|
912
|
-
|
913
|
-
# Check if we have a session manager
|
914
|
-
if not hasattr(self, '_session_manager'):
|
915
|
-
self.log.error("no_session_manager")
|
916
|
-
return False
|
917
|
-
|
918
|
-
# Handle missing token
|
919
|
-
if not token:
|
920
|
-
self.log.warning("missing_token", function=function_name)
|
921
|
-
return False
|
922
|
-
|
923
|
-
# For debugging: Log token details
|
924
|
-
try:
|
925
|
-
# Capture original parameters
|
926
|
-
self.log.debug("token_validate_input",
|
927
|
-
function=function_name,
|
928
|
-
call_id=call_id,
|
929
|
-
token_length=len(token))
|
930
|
-
|
931
|
-
# Try to decode token for debugging
|
932
|
-
if hasattr(self._session_manager, 'debug_token'):
|
933
|
-
debug_info = self._session_manager.debug_token(token)
|
934
|
-
self.log.debug("token_debug", debug_info=debug_info)
|
935
|
-
|
936
|
-
# Extract token components
|
937
|
-
if debug_info.get("valid_format") and "components" in debug_info:
|
938
|
-
components = debug_info["components"]
|
939
|
-
token_call_id = components.get("call_id")
|
940
|
-
token_function = components.get("function")
|
941
|
-
token_expiry = components.get("expiry")
|
942
|
-
|
943
|
-
# Log parameter mismatches
|
944
|
-
if token_function != function_name:
|
945
|
-
self.log.warning("token_function_mismatch",
|
946
|
-
expected=function_name,
|
947
|
-
actual=token_function)
|
948
|
-
|
949
|
-
if token_call_id != call_id:
|
950
|
-
self.log.warning("token_call_id_mismatch",
|
951
|
-
expected=call_id,
|
952
|
-
actual=token_call_id)
|
953
|
-
|
954
|
-
# Check expiration
|
955
|
-
if debug_info.get("status", {}).get("is_expired"):
|
956
|
-
self.log.warning("token_expired",
|
957
|
-
expires_in=debug_info["status"].get("expires_in_seconds"))
|
958
|
-
except Exception as e:
|
959
|
-
self.log.error("token_debug_error", error=str(e))
|
960
|
-
|
961
|
-
# Use call_id from token if the provided one is empty
|
962
|
-
if not call_id and hasattr(self._session_manager, 'debug_token'):
|
963
|
-
try:
|
964
|
-
debug_info = self._session_manager.debug_token(token)
|
965
|
-
if debug_info.get("valid_format") and "components" in debug_info:
|
966
|
-
token_call_id = debug_info["components"].get("call_id")
|
967
|
-
if token_call_id:
|
968
|
-
self.log.debug("using_call_id_from_token", call_id=token_call_id)
|
969
|
-
is_valid = self._session_manager.validate_tool_token(function_name, token, token_call_id)
|
970
|
-
if is_valid:
|
971
|
-
self.log.debug("token_valid_with_extracted_call_id")
|
972
|
-
return True
|
973
|
-
except Exception as e:
|
974
|
-
self.log.error("error_using_call_id_from_token", error=str(e))
|
975
|
-
|
976
|
-
# Normal validation with provided call_id
|
977
|
-
is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
|
978
|
-
|
979
|
-
if is_valid:
|
980
|
-
self.log.debug("token_valid", function=function_name)
|
981
|
-
else:
|
982
|
-
self.log.warning("token_invalid", function=function_name)
|
983
|
-
|
984
|
-
return is_valid
|
985
|
-
except Exception as e:
|
986
|
-
self.log.error("token_validation_error", error=str(e), function=function_name)
|
987
|
-
return False
|
988
|
-
|
989
|
-
# ----------------------------------------------------------------------
|
990
|
-
# Web Server and Routing
|
991
|
-
# ----------------------------------------------------------------------
|
992
|
-
|
993
|
-
def get_basic_auth_credentials(self, include_source: bool = False) -> Union[Tuple[str, str], Tuple[str, str, str]]:
|
994
|
-
"""
|
995
|
-
Get the basic auth credentials
|
996
|
-
|
997
|
-
Args:
|
998
|
-
include_source: Whether to include the source of the credentials
|
999
|
-
|
1000
|
-
Returns:
|
1001
|
-
If include_source is False:
|
1002
|
-
(username, password) tuple
|
1003
|
-
If include_source is True:
|
1004
|
-
(username, password, source) tuple, where source is one of:
|
1005
|
-
"provided", "environment", or "generated"
|
1006
|
-
"""
|
1007
|
-
username, password = self._basic_auth
|
1008
|
-
|
1009
|
-
if not include_source:
|
1010
|
-
return (username, password)
|
1011
|
-
|
1012
|
-
# Determine source of credentials
|
1013
|
-
env_user = os.environ.get('SWML_BASIC_AUTH_USER')
|
1014
|
-
env_pass = os.environ.get('SWML_BASIC_AUTH_PASSWORD')
|
1015
|
-
|
1016
|
-
# More robust source detection
|
1017
|
-
if env_user and env_pass and username == env_user and password == env_pass:
|
1018
|
-
source = "environment"
|
1019
|
-
elif username.startswith("user_") and len(password) > 20: # Format of generated credentials
|
1020
|
-
source = "generated"
|
1021
|
-
else:
|
1022
|
-
source = "provided"
|
1023
|
-
|
1024
|
-
return (username, password, source)
|
1025
|
-
|
1026
|
-
def get_full_url(self, include_auth: bool = False) -> str:
|
1027
|
-
"""
|
1028
|
-
Get the full URL for this agent's endpoint
|
1029
|
-
|
1030
|
-
Args:
|
1031
|
-
include_auth: Whether to include authentication credentials in the URL
|
1032
|
-
|
1033
|
-
Returns:
|
1034
|
-
Full URL including host, port, and route (with auth if requested)
|
1035
|
-
"""
|
1036
|
-
mode = get_execution_mode()
|
1037
|
-
|
1038
|
-
if mode == 'cgi':
|
1039
|
-
protocol = 'https' if os.getenv('HTTPS') == 'on' else 'http'
|
1040
|
-
host = os.getenv('HTTP_HOST') or os.getenv('SERVER_NAME') or 'localhost'
|
1041
|
-
script_name = os.getenv('SCRIPT_NAME', '')
|
1042
|
-
base_url = f"{protocol}://{host}{script_name}"
|
1043
|
-
elif mode == 'lambda':
|
1044
|
-
# AWS Lambda Function URL format
|
1045
|
-
lambda_url = os.getenv('AWS_LAMBDA_FUNCTION_URL')
|
1046
|
-
if lambda_url:
|
1047
|
-
base_url = lambda_url.rstrip('/')
|
1048
|
-
else:
|
1049
|
-
# Fallback construction for Lambda
|
1050
|
-
region = os.getenv('AWS_REGION', 'us-east-1')
|
1051
|
-
function_name = os.getenv('AWS_LAMBDA_FUNCTION_NAME', 'unknown')
|
1052
|
-
base_url = f"https://{function_name}.lambda-url.{region}.on.aws"
|
1053
|
-
elif mode == 'google_cloud_function':
|
1054
|
-
# Google Cloud Functions URL format
|
1055
|
-
project_id = os.getenv('GOOGLE_CLOUD_PROJECT') or os.getenv('GCP_PROJECT')
|
1056
|
-
region = os.getenv('FUNCTION_REGION') or os.getenv('GOOGLE_CLOUD_REGION', 'us-central1')
|
1057
|
-
service_name = os.getenv('K_SERVICE') or os.getenv('FUNCTION_TARGET', 'unknown')
|
1058
|
-
|
1059
|
-
if project_id:
|
1060
|
-
base_url = f"https://{region}-{project_id}.cloudfunctions.net/{service_name}"
|
1061
|
-
else:
|
1062
|
-
# Fallback for local testing or incomplete environment
|
1063
|
-
base_url = f"https://localhost:8080"
|
1064
|
-
elif mode == 'azure_function':
|
1065
|
-
# Azure Functions URL format
|
1066
|
-
function_app_name = os.getenv('WEBSITE_SITE_NAME') or os.getenv('AZURE_FUNCTIONS_APP_NAME')
|
1067
|
-
function_name = os.getenv('AZURE_FUNCTION_NAME', 'unknown')
|
1068
|
-
|
1069
|
-
if function_app_name:
|
1070
|
-
base_url = f"https://{function_app_name}.azurewebsites.net/api/{function_name}"
|
1071
|
-
else:
|
1072
|
-
# Fallback for local testing
|
1073
|
-
base_url = f"https://localhost:7071/api/{function_name}"
|
1074
|
-
else:
|
1075
|
-
# Server mode - check for proxy URL first
|
1076
|
-
if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
|
1077
|
-
# Use proxy URL when available (from reverse proxy detection)
|
1078
|
-
base_url = self._proxy_url_base.rstrip('/')
|
1079
|
-
else:
|
1080
|
-
# Fallback to local URL construction
|
1081
|
-
protocol = 'https' if getattr(self, 'ssl_enabled', False) else 'http'
|
1082
|
-
|
1083
|
-
# Determine host part - include port unless it's the standard port for the protocol
|
1084
|
-
if getattr(self, 'ssl_enabled', False) and getattr(self, 'domain', None):
|
1085
|
-
# Use domain, but include port if it's not the standard HTTPS port (443)
|
1086
|
-
host_part = f"{self.domain}:{self.port}" if self.port != 443 else self.domain
|
1087
|
-
else:
|
1088
|
-
# Use host:port for HTTP or when no domain is specified
|
1089
|
-
host_part = f"{self.host}:{self.port}"
|
1090
|
-
|
1091
|
-
base_url = f"{protocol}://{host_part}"
|
1092
|
-
|
1093
|
-
# Add route if not already included (for server mode)
|
1094
|
-
if mode == 'server' and self.route and not base_url.endswith(self.route):
|
1095
|
-
base_url = f"{base_url}/{self.route.lstrip('/')}"
|
1096
|
-
|
1097
|
-
# Add authentication if requested
|
1098
|
-
if include_auth:
|
1099
|
-
username, password = self.get_basic_auth_credentials()
|
1100
|
-
if username and password:
|
1101
|
-
# Parse URL to insert auth
|
1102
|
-
from urllib.parse import urlparse, urlunparse
|
1103
|
-
parsed = urlparse(base_url)
|
1104
|
-
# Reconstruct with auth
|
1105
|
-
base_url = urlunparse((
|
1106
|
-
parsed.scheme,
|
1107
|
-
f"{username}:{password}@{parsed.netloc}",
|
1108
|
-
parsed.path,
|
1109
|
-
parsed.params,
|
1110
|
-
parsed.query,
|
1111
|
-
parsed.fragment
|
1112
|
-
))
|
1113
|
-
|
1114
|
-
return base_url
|
1115
|
-
|
1116
|
-
def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
|
1117
|
-
"""
|
1118
|
-
Helper method to build webhook URLs consistently
|
1119
|
-
|
1120
|
-
Args:
|
1121
|
-
endpoint: The endpoint path (e.g., "swaig", "post_prompt")
|
1122
|
-
query_params: Optional query parameters to append
|
1123
|
-
|
1124
|
-
Returns:
|
1125
|
-
Fully constructed webhook URL
|
1126
|
-
"""
|
1127
|
-
# Check for serverless environment and use appropriate URL generation
|
1128
|
-
mode = get_execution_mode()
|
1129
|
-
|
1130
|
-
if mode != 'server':
|
1131
|
-
# In serverless mode, use the serverless-appropriate URL with auth
|
1132
|
-
base_url = self.get_full_url(include_auth=True)
|
1133
|
-
|
1134
|
-
# Ensure the endpoint has a trailing slash to prevent redirects
|
1135
|
-
if endpoint in ["swaig", "post_prompt"]:
|
1136
|
-
endpoint = f"{endpoint}/"
|
1137
|
-
|
1138
|
-
# Build the full webhook URL
|
1139
|
-
url = f"{base_url}/{endpoint}"
|
1140
|
-
|
1141
|
-
# Add query parameters if any (only if they have values)
|
1142
|
-
if query_params:
|
1143
|
-
# Remove any call_id from query params
|
1144
|
-
filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
|
1145
|
-
if filtered_params:
|
1146
|
-
params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
|
1147
|
-
url = f"{url}?{params}"
|
1148
|
-
|
1149
|
-
return url
|
1150
|
-
|
1151
|
-
# Server mode - use existing logic with proxy/auth support
|
1152
|
-
# Use the parent class's implementation if available and has the same method
|
1153
|
-
if hasattr(super(), '_build_webhook_url'):
|
1154
|
-
# Ensure _proxy_url_base is synchronized
|
1155
|
-
if getattr(self, '_proxy_url_base', None) and hasattr(super(), '_proxy_url_base'):
|
1156
|
-
super()._proxy_url_base = self._proxy_url_base
|
1157
|
-
|
1158
|
-
# Call parent's implementation
|
1159
|
-
return super()._build_webhook_url(endpoint, query_params)
|
1160
|
-
|
1161
|
-
# Otherwise, fall back to our own implementation for server mode
|
1162
|
-
# Base URL construction
|
1163
|
-
if hasattr(self, '_proxy_url_base') and self._proxy_url_base:
|
1164
|
-
# For proxy URLs
|
1165
|
-
base = self._proxy_url_base.rstrip('/')
|
1166
|
-
|
1167
|
-
# Always add auth credentials
|
1168
|
-
username, password = self._basic_auth
|
1169
|
-
url = urlparse(base)
|
1170
|
-
base = url._replace(netloc=f"{username}:{password}@{url.netloc}").geturl()
|
1171
|
-
else:
|
1172
|
-
# Determine protocol based on SSL settings
|
1173
|
-
protocol = "https" if getattr(self, 'ssl_enabled', False) else "http"
|
1174
|
-
|
1175
|
-
# Determine host part - include port unless it's the standard port for the protocol
|
1176
|
-
if getattr(self, 'ssl_enabled', False) and getattr(self, 'domain', None):
|
1177
|
-
# Use domain, but include port if it's not the standard HTTPS port (443)
|
1178
|
-
host_part = f"{self.domain}:{self.port}" if self.port != 443 else self.domain
|
1179
|
-
else:
|
1180
|
-
# For local URLs
|
1181
|
-
if self.host in ("0.0.0.0", "127.0.0.1", "localhost"):
|
1182
|
-
host = "localhost"
|
1183
|
-
else:
|
1184
|
-
host = self.host
|
1185
|
-
|
1186
|
-
host_part = f"{host}:{self.port}"
|
1187
|
-
|
1188
|
-
# Always include auth credentials
|
1189
|
-
username, password = self._basic_auth
|
1190
|
-
base = f"{protocol}://{username}:{password}@{host_part}"
|
1191
|
-
|
1192
|
-
# Ensure the endpoint has a trailing slash to prevent redirects
|
1193
|
-
if endpoint in ["swaig", "post_prompt"]:
|
1194
|
-
endpoint = f"{endpoint}/"
|
1195
|
-
|
1196
|
-
# Simple path - use the route directly with the endpoint
|
1197
|
-
path = f"{self.route}/{endpoint}"
|
1198
|
-
|
1199
|
-
# Construct full URL
|
1200
|
-
url = f"{base}{path}"
|
1201
|
-
|
1202
|
-
# Add query parameters if any (only if they have values)
|
1203
|
-
# But NEVER add call_id parameter - it should be in the body, not the URL
|
1204
|
-
if query_params:
|
1205
|
-
# Remove any call_id from query params
|
1206
|
-
filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
|
1207
|
-
if filtered_params:
|
1208
|
-
params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
|
1209
|
-
url = f"{url}?{params}"
|
1210
|
-
|
1211
|
-
return url
|
1212
|
-
|
1213
|
-
def _render_swml(self, call_id: str = None, modifications: Optional[dict] = None) -> str:
|
1214
|
-
"""
|
1215
|
-
Render the complete SWML document using SWMLService methods
|
1216
|
-
|
1217
|
-
Args:
|
1218
|
-
call_id: Optional call ID for session-specific tokens
|
1219
|
-
modifications: Optional dict of modifications to apply to the SWML
|
1220
|
-
|
1221
|
-
Returns:
|
1222
|
-
SWML document as a string
|
1223
|
-
"""
|
1224
|
-
# Reset the document to a clean state
|
1225
|
-
self.reset_document()
|
1226
|
-
|
1227
|
-
# Get prompt
|
1228
|
-
prompt = self.get_prompt()
|
1229
|
-
prompt_is_pom = isinstance(prompt, list)
|
1230
|
-
|
1231
|
-
# Get post-prompt
|
1232
|
-
post_prompt = self.get_post_prompt()
|
1233
|
-
|
1234
|
-
# Generate a call ID if needed
|
1235
|
-
if self._enable_state_tracking and call_id is None:
|
1236
|
-
call_id = self._session_manager.create_session()
|
1237
|
-
|
1238
|
-
# Empty query params - no need to include call_id in URLs
|
1239
|
-
query_params = {}
|
1240
|
-
|
1241
|
-
# Get the default webhook URL with auth
|
1242
|
-
default_webhook_url = self._build_webhook_url("swaig", query_params)
|
1243
|
-
|
1244
|
-
# Use override if set
|
1245
|
-
if hasattr(self, '_web_hook_url_override') and self._web_hook_url_override:
|
1246
|
-
default_webhook_url = self._web_hook_url_override
|
1247
|
-
|
1248
|
-
# Prepare SWAIG object (correct format)
|
1249
|
-
swaig_obj = {}
|
1250
|
-
|
1251
|
-
# Add defaults if we have functions
|
1252
|
-
if self._tool_registry._swaig_functions:
|
1253
|
-
swaig_obj["defaults"] = {
|
1254
|
-
"web_hook_url": default_webhook_url
|
1255
|
-
}
|
1256
|
-
|
1257
|
-
# Add native_functions if any are defined
|
1258
|
-
if self.native_functions:
|
1259
|
-
swaig_obj["native_functions"] = self.native_functions
|
600
|
+
# Add native_functions if any are defined
|
601
|
+
if agent_to_use.native_functions:
|
602
|
+
swaig_obj["native_functions"] = agent_to_use.native_functions
|
1260
603
|
|
1261
604
|
# Add includes if any are defined
|
1262
|
-
if
|
1263
|
-
swaig_obj["includes"] =
|
605
|
+
if agent_to_use._function_includes:
|
606
|
+
swaig_obj["includes"] = agent_to_use._function_includes
|
1264
607
|
|
1265
608
|
# Add internal_fillers if any are defined
|
1266
|
-
if hasattr(
|
1267
|
-
swaig_obj["internal_fillers"] =
|
609
|
+
if hasattr(agent_to_use, '_internal_fillers') and agent_to_use._internal_fillers:
|
610
|
+
swaig_obj["internal_fillers"] = agent_to_use._internal_fillers
|
1268
611
|
|
1269
612
|
# Create functions array
|
1270
613
|
functions = []
|
1271
614
|
|
615
|
+
# Debug logging to see what functions we have
|
616
|
+
self.log.debug("checking_swaig_functions",
|
617
|
+
agent_name=agent_to_use.name,
|
618
|
+
is_ephemeral=getattr(agent_to_use, '_is_ephemeral', False),
|
619
|
+
registry_id=id(agent_to_use._tool_registry),
|
620
|
+
agent_id=id(agent_to_use),
|
621
|
+
function_count=len(agent_to_use._tool_registry._swaig_functions) if hasattr(agent_to_use._tool_registry, '_swaig_functions') else 0,
|
622
|
+
functions=list(agent_to_use._tool_registry._swaig_functions.keys()) if hasattr(agent_to_use._tool_registry, '_swaig_functions') else [])
|
623
|
+
|
1272
624
|
# Add each function to the functions array
|
1273
|
-
|
625
|
+
# Check if the registry has the _swaig_functions attribute
|
626
|
+
if not hasattr(agent_to_use._tool_registry, '_swaig_functions'):
|
627
|
+
self.log.warning("tool_registry_missing_swaig_functions",
|
628
|
+
registry_id=id(agent_to_use._tool_registry),
|
629
|
+
agent_id=id(agent_to_use))
|
630
|
+
agent_to_use._tool_registry._swaig_functions = {}
|
631
|
+
|
632
|
+
for name, func in agent_to_use._tool_registry._swaig_functions.items():
|
1274
633
|
if isinstance(func, dict):
|
1275
634
|
# For raw dictionaries (DataMap functions), use the entire dictionary as-is
|
1276
635
|
# This preserves data_map and any other special fields
|
@@ -1284,16 +643,13 @@ class AgentBase(SWMLService):
|
|
1284
643
|
# Check if it's secure and get token for secure functions when we have a call_id
|
1285
644
|
token = None
|
1286
645
|
if func.secure and call_id:
|
1287
|
-
token =
|
646
|
+
token = agent_to_use._create_tool_token(tool_name=name, call_id=call_id)
|
1288
647
|
|
1289
648
|
# Prepare function entry
|
1290
649
|
function_entry = {
|
1291
650
|
"function": name,
|
1292
651
|
"description": func.description,
|
1293
|
-
"parameters":
|
1294
|
-
"type": "object",
|
1295
|
-
"properties": func.parameters
|
1296
|
-
}
|
652
|
+
"parameters": func._ensure_parameter_structure()
|
1297
653
|
}
|
1298
654
|
|
1299
655
|
# Add fillers if present
|
@@ -1304,51 +660,60 @@ class AgentBase(SWMLService):
|
|
1304
660
|
if hasattr(func, 'webhook_url') and func.webhook_url:
|
1305
661
|
# External webhook function - use the provided URL directly
|
1306
662
|
function_entry["web_hook_url"] = func.webhook_url
|
1307
|
-
elif token:
|
1308
|
-
# Local function with token - build local webhook URL
|
1309
|
-
|
1310
|
-
|
663
|
+
elif token or agent_to_use._swaig_query_params:
|
664
|
+
# Local function with token OR SWAIG query params - build local webhook URL
|
665
|
+
# Start with SWAIG query params
|
666
|
+
url_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
|
667
|
+
if token:
|
668
|
+
url_params["__token"] = token # Use __token to avoid collision
|
669
|
+
function_entry["web_hook_url"] = agent_to_use._build_webhook_url("swaig", url_params)
|
1311
670
|
|
1312
671
|
functions.append(function_entry)
|
1313
672
|
|
1314
673
|
# Add functions array to SWAIG object if we have any
|
1315
674
|
if functions:
|
1316
675
|
swaig_obj["functions"] = functions
|
676
|
+
# Add defaults section now that we know we have functions
|
677
|
+
if "defaults" not in swaig_obj:
|
678
|
+
swaig_obj["defaults"] = {
|
679
|
+
"web_hook_url": default_webhook_url
|
680
|
+
}
|
1317
681
|
|
1318
682
|
# Add post-prompt URL with token if we have a post-prompt
|
1319
683
|
post_prompt_url = None
|
1320
684
|
if post_prompt:
|
1321
685
|
# Create a token for post_prompt if we have a call_id
|
1322
|
-
|
1323
|
-
if
|
686
|
+
# Start with SWAIG query params
|
687
|
+
query_params = agent_to_use._swaig_query_params.copy() if agent_to_use._swaig_query_params else {}
|
688
|
+
if call_id and hasattr(agent_to_use, '_session_manager'):
|
1324
689
|
try:
|
1325
|
-
token =
|
690
|
+
token = agent_to_use._session_manager.create_tool_token("post_prompt", call_id)
|
1326
691
|
if token:
|
1327
|
-
query_params["
|
692
|
+
query_params["__token"] = token # Use __token to avoid collision
|
1328
693
|
except Exception as e:
|
1329
|
-
|
694
|
+
agent_to_use.log.error("post_prompt_token_creation_error", error=str(e))
|
1330
695
|
|
1331
696
|
# Build the URL with the token (if any)
|
1332
|
-
post_prompt_url =
|
697
|
+
post_prompt_url = agent_to_use._build_webhook_url("post_prompt", query_params)
|
1333
698
|
|
1334
699
|
# Use override if set
|
1335
|
-
if hasattr(
|
1336
|
-
post_prompt_url =
|
700
|
+
if hasattr(agent_to_use, '_post_prompt_url_override') and agent_to_use._post_prompt_url_override:
|
701
|
+
post_prompt_url = agent_to_use._post_prompt_url_override
|
1337
702
|
|
1338
703
|
# Add answer verb with auto-answer enabled
|
1339
|
-
|
704
|
+
agent_to_use.add_verb("answer", {})
|
1340
705
|
|
1341
706
|
# Use the AI verb handler to build and validate the AI verb config
|
1342
707
|
ai_config = {}
|
1343
708
|
|
1344
709
|
# Get the AI verb handler
|
1345
|
-
ai_handler =
|
710
|
+
ai_handler = agent_to_use.verb_registry.get_handler("ai")
|
1346
711
|
if ai_handler:
|
1347
712
|
try:
|
1348
713
|
# Check if we're in contexts mode
|
1349
|
-
if
|
714
|
+
if agent_to_use._contexts_defined and agent_to_use._contexts_builder:
|
1350
715
|
# Generate contexts and combine with base prompt
|
1351
|
-
contexts_dict =
|
716
|
+
contexts_dict = agent_to_use._contexts_builder.to_dict()
|
1352
717
|
|
1353
718
|
# Determine base prompt (required when using contexts)
|
1354
719
|
base_prompt_text = None
|
@@ -1360,7 +725,7 @@ class AgentBase(SWMLService):
|
|
1360
725
|
base_prompt_text = prompt
|
1361
726
|
else:
|
1362
727
|
# Provide default base prompt if none exists
|
1363
|
-
base_prompt_text = f"You are {
|
728
|
+
base_prompt_text = f"You are {agent_to_use.name}, a helpful AI assistant that follows structured workflows."
|
1364
729
|
|
1365
730
|
# Build AI config with base prompt + contexts
|
1366
731
|
ai_config = ai_handler.build_config(
|
@@ -1384,28 +749,28 @@ class AgentBase(SWMLService):
|
|
1384
749
|
# Add new configuration parameters to the AI config
|
1385
750
|
|
1386
751
|
# Add hints if any
|
1387
|
-
if
|
1388
|
-
ai_config["hints"] =
|
752
|
+
if agent_to_use._hints:
|
753
|
+
ai_config["hints"] = agent_to_use._hints
|
1389
754
|
|
1390
755
|
# Add languages if any
|
1391
|
-
if
|
1392
|
-
ai_config["languages"] =
|
756
|
+
if agent_to_use._languages:
|
757
|
+
ai_config["languages"] = agent_to_use._languages
|
1393
758
|
|
1394
759
|
# Add pronunciation rules if any
|
1395
|
-
if
|
1396
|
-
ai_config["pronounce"] =
|
760
|
+
if agent_to_use._pronounce:
|
761
|
+
ai_config["pronounce"] = agent_to_use._pronounce
|
1397
762
|
|
1398
763
|
# Add params if any
|
1399
|
-
if
|
1400
|
-
ai_config["params"] =
|
764
|
+
if agent_to_use._params:
|
765
|
+
ai_config["params"] = agent_to_use._params
|
1401
766
|
|
1402
767
|
# Add global_data if any
|
1403
|
-
if
|
1404
|
-
ai_config["global_data"] =
|
768
|
+
if agent_to_use._global_data:
|
769
|
+
ai_config["global_data"] = agent_to_use._global_data
|
1405
770
|
|
1406
771
|
except ValueError as e:
|
1407
|
-
if not
|
1408
|
-
|
772
|
+
if not agent_to_use._suppress_logs:
|
773
|
+
agent_to_use.log.error("ai_verb_config_error", error=str(e))
|
1409
774
|
else:
|
1410
775
|
# Fallback if no handler (shouldn't happen but just in case)
|
1411
776
|
ai_config = {
|
@@ -1423,23 +788,23 @@ class AgentBase(SWMLService):
|
|
1423
788
|
ai_config["SWAIG"] = swaig_obj
|
1424
789
|
|
1425
790
|
# Add the new configurations if not already added by the handler
|
1426
|
-
if
|
1427
|
-
ai_config["hints"] =
|
791
|
+
if agent_to_use._hints and "hints" not in ai_config:
|
792
|
+
ai_config["hints"] = agent_to_use._hints
|
1428
793
|
|
1429
|
-
if
|
1430
|
-
ai_config["languages"] =
|
794
|
+
if agent_to_use._languages and "languages" not in ai_config:
|
795
|
+
ai_config["languages"] = agent_to_use._languages
|
1431
796
|
|
1432
|
-
if
|
1433
|
-
ai_config["pronounce"] =
|
797
|
+
if agent_to_use._pronounce and "pronounce" not in ai_config:
|
798
|
+
ai_config["pronounce"] = agent_to_use._pronounce
|
1434
799
|
|
1435
|
-
if
|
1436
|
-
ai_config["params"] =
|
800
|
+
if agent_to_use._params and "params" not in ai_config:
|
801
|
+
ai_config["params"] = agent_to_use._params
|
1437
802
|
|
1438
|
-
if
|
1439
|
-
ai_config["global_data"] =
|
803
|
+
if agent_to_use._global_data and "global_data" not in ai_config:
|
804
|
+
ai_config["global_data"] = agent_to_use._global_data
|
1440
805
|
|
1441
806
|
# Add the AI verb to the document
|
1442
|
-
|
807
|
+
agent_to_use.add_verb("ai", ai_config)
|
1443
808
|
|
1444
809
|
# Apply any modifications from the callback to agent state
|
1445
810
|
if modifications and isinstance(modifications, dict):
|
@@ -1455,1611 +820,51 @@ class AgentBase(SWMLService):
|
|
1455
820
|
ai_config[key] = value
|
1456
821
|
|
1457
822
|
# Clear and rebuild the document with the modified AI config
|
1458
|
-
|
1459
|
-
|
1460
|
-
|
823
|
+
agent_to_use.reset_document()
|
824
|
+
agent_to_use.add_verb("answer", {})
|
825
|
+
agent_to_use.add_verb("ai", ai_config)
|
1461
826
|
|
1462
827
|
# Return the rendered document as a string
|
1463
|
-
return
|
828
|
+
return agent_to_use.render_document()
|
1464
829
|
|
1465
|
-
def
|
1466
|
-
"""
|
1467
|
-
|
1468
|
-
|
1469
|
-
Args:
|
1470
|
-
|
1471
|
-
|
1472
|
-
Returns:
|
1473
|
-
True if auth is valid, False otherwise
|
1474
|
-
"""
|
1475
|
-
auth_header = request.headers.get("Authorization")
|
1476
|
-
if not auth_header or not auth_header.startswith("Basic "):
|
1477
|
-
return False
|
1478
|
-
|
1479
|
-
try:
|
1480
|
-
# Decode the base64 credentials
|
1481
|
-
credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
|
1482
|
-
username, password = credentials.split(":", 1)
|
1483
|
-
return self.validate_basic_auth(username, password)
|
1484
|
-
except Exception:
|
1485
|
-
return False
|
1486
|
-
|
1487
|
-
def as_router(self) -> APIRouter:
|
1488
|
-
"""
|
1489
|
-
Get a FastAPI router for this agent
|
1490
|
-
|
1491
|
-
Returns:
|
1492
|
-
FastAPI router
|
1493
|
-
"""
|
1494
|
-
# Create a router with explicit redirect_slashes=False
|
1495
|
-
router = APIRouter(redirect_slashes=False)
|
1496
|
-
|
1497
|
-
# Register routes explicitly
|
1498
|
-
self._register_routes(router)
|
1499
|
-
|
1500
|
-
# Log all registered routes for debugging
|
1501
|
-
self.log.debug("routes_registered", agent=self.name)
|
1502
|
-
for route in router.routes:
|
1503
|
-
self.log.debug("route_registered", path=route.path)
|
1504
|
-
|
1505
|
-
return router
|
1506
|
-
|
1507
|
-
def serve(self, host: Optional[str] = None, port: Optional[int] = None) -> None:
|
1508
|
-
"""
|
1509
|
-
Start a web server for this agent
|
1510
|
-
|
1511
|
-
Args:
|
1512
|
-
host: Optional host to override the default
|
1513
|
-
port: Optional port to override the default
|
1514
|
-
"""
|
1515
|
-
import uvicorn
|
1516
|
-
|
1517
|
-
if self._app is None:
|
1518
|
-
# Create a FastAPI app with explicit redirect_slashes=False
|
1519
|
-
app = FastAPI(redirect_slashes=False)
|
1520
|
-
|
1521
|
-
# Add health and ready endpoints directly to the main app to avoid conflicts with catch-all
|
1522
|
-
@app.get("/health")
|
1523
|
-
@app.post("/health")
|
1524
|
-
async def health_check():
|
1525
|
-
"""Health check endpoint for Kubernetes liveness probe"""
|
1526
|
-
return {
|
1527
|
-
"status": "healthy",
|
1528
|
-
"agent": self.get_name(),
|
1529
|
-
"route": self.route,
|
1530
|
-
"functions": len(self._tool_registry._swaig_functions)
|
1531
|
-
}
|
1532
|
-
|
1533
|
-
@app.get("/ready")
|
1534
|
-
@app.post("/ready")
|
1535
|
-
async def readiness_check():
|
1536
|
-
"""Readiness check endpoint for Kubernetes readiness probe"""
|
1537
|
-
# Check if agent is properly initialized
|
1538
|
-
ready = (
|
1539
|
-
hasattr(self, '_tool_registry') and
|
1540
|
-
hasattr(self, 'schema_utils') and
|
1541
|
-
self.schema_utils is not None
|
1542
|
-
)
|
1543
|
-
|
1544
|
-
status_code = 200 if ready else 503
|
1545
|
-
return Response(
|
1546
|
-
content=json.dumps({
|
1547
|
-
"status": "ready" if ready else "not_ready",
|
1548
|
-
"agent": self.get_name(),
|
1549
|
-
"initialized": ready
|
1550
|
-
}),
|
1551
|
-
status_code=status_code,
|
1552
|
-
media_type="application/json"
|
1553
|
-
)
|
1554
|
-
|
1555
|
-
# Get router for this agent
|
1556
|
-
router = self.as_router()
|
1557
|
-
|
1558
|
-
# Register a catch-all route for debugging and troubleshooting
|
1559
|
-
@app.get("/{full_path:path}")
|
1560
|
-
@app.post("/{full_path:path}")
|
1561
|
-
async def handle_all_routes(request: Request, full_path: str):
|
1562
|
-
self.log.debug("request_received", path=full_path)
|
1563
|
-
|
1564
|
-
# Check if the path is meant for this agent
|
1565
|
-
if not full_path.startswith(self.route.lstrip("/")):
|
1566
|
-
return {"error": "Invalid route"}
|
1567
|
-
|
1568
|
-
# Extract the path relative to this agent's route
|
1569
|
-
relative_path = full_path[len(self.route.lstrip("/")):]
|
1570
|
-
relative_path = relative_path.lstrip("/")
|
1571
|
-
self.log.debug("path_extracted", relative_path=relative_path)
|
1572
|
-
|
1573
|
-
# Perform routing based on the relative path
|
1574
|
-
if not relative_path or relative_path == "/":
|
1575
|
-
# Root endpoint
|
1576
|
-
return await self._handle_root_request(request)
|
1577
|
-
|
1578
|
-
# Strip trailing slash for processing
|
1579
|
-
clean_path = relative_path.rstrip("/")
|
1580
|
-
|
1581
|
-
# Check for standard endpoints
|
1582
|
-
if clean_path == "debug":
|
1583
|
-
return await self._handle_debug_request(request)
|
1584
|
-
elif clean_path == "swaig":
|
1585
|
-
return await self._handle_swaig_request(request, Response())
|
1586
|
-
elif clean_path == "post_prompt":
|
1587
|
-
return await self._handle_post_prompt_request(request)
|
1588
|
-
elif clean_path == "check_for_input":
|
1589
|
-
return await self._handle_check_for_input_request(request)
|
1590
|
-
|
1591
|
-
# Check for custom routing callbacks
|
1592
|
-
if hasattr(self, '_routing_callbacks'):
|
1593
|
-
for callback_path, callback_fn in self._routing_callbacks.items():
|
1594
|
-
cb_path_clean = callback_path.strip("/")
|
1595
|
-
if clean_path == cb_path_clean:
|
1596
|
-
# Found a matching callback
|
1597
|
-
request.state.callback_path = callback_path
|
1598
|
-
return await self._handle_root_request(request)
|
1599
|
-
|
1600
|
-
# Default: 404
|
1601
|
-
return {"error": "Path not found"}
|
1602
|
-
|
1603
|
-
# Include router with prefix
|
1604
|
-
app.include_router(router, prefix=self.route)
|
1605
|
-
|
1606
|
-
# Log all app routes for debugging
|
1607
|
-
self.log.debug("app_routes_registered")
|
1608
|
-
for route in app.routes:
|
1609
|
-
if hasattr(route, "path"):
|
1610
|
-
self.log.debug("app_route", path=route.path)
|
1611
|
-
|
1612
|
-
self._app = app
|
1613
|
-
|
1614
|
-
host = host or self.host
|
1615
|
-
port = port or self.port
|
1616
|
-
|
1617
|
-
# Print the auth credentials with source
|
1618
|
-
username, password, source = self.get_basic_auth_credentials(include_source=True)
|
1619
|
-
|
1620
|
-
# Log startup information using structured logging
|
1621
|
-
self.log.info("agent_starting",
|
1622
|
-
agent=self.name,
|
1623
|
-
url=f"http://{host}:{port}{self.route}",
|
1624
|
-
username=username,
|
1625
|
-
password_length=len(password),
|
1626
|
-
auth_source=source)
|
1627
|
-
|
1628
|
-
# Print user-friendly startup message (keep this for development UX)
|
1629
|
-
print(f"Agent '{self.name}' is available at:")
|
1630
|
-
print(f"URL: http://{host}:{port}{self.route}")
|
1631
|
-
print(f"Basic Auth: {username}:{password} (source: {source})")
|
1632
|
-
|
1633
|
-
uvicorn.run(self._app, host=host, port=port)
|
1634
|
-
|
1635
|
-
def run(self, event=None, context=None, force_mode=None, host: Optional[str] = None, port: Optional[int] = None):
|
1636
|
-
"""
|
1637
|
-
Smart run method that automatically detects environment and handles accordingly
|
1638
|
-
|
1639
|
-
Args:
|
1640
|
-
event: Serverless event object (Lambda, Cloud Functions)
|
1641
|
-
context: Serverless context object (Lambda, Cloud Functions)
|
1642
|
-
force_mode: Override automatic mode detection for testing
|
1643
|
-
host: Host override for server mode
|
1644
|
-
port: Port override for server mode
|
1645
|
-
|
1646
|
-
Returns:
|
1647
|
-
Response for serverless modes, None for server mode
|
1648
|
-
"""
|
1649
|
-
mode = force_mode or get_execution_mode()
|
1650
|
-
|
1651
|
-
try:
|
1652
|
-
if mode in ['cgi', 'azure_function']:
|
1653
|
-
response = self.handle_serverless_request(event, context, mode)
|
1654
|
-
print(response)
|
1655
|
-
return response
|
1656
|
-
elif mode == 'lambda':
|
1657
|
-
return self.handle_serverless_request(event, context, mode)
|
1658
|
-
else:
|
1659
|
-
# Server mode - use existing serve method
|
1660
|
-
self.serve(host, port)
|
1661
|
-
except Exception as e:
|
1662
|
-
import logging
|
1663
|
-
logging.error(f"Error in run method: {e}")
|
1664
|
-
if mode == 'lambda':
|
1665
|
-
return {
|
1666
|
-
"statusCode": 500,
|
1667
|
-
"headers": {"Content-Type": "application/json"},
|
1668
|
-
"body": json.dumps({"error": str(e)})
|
1669
|
-
}
|
1670
|
-
else:
|
1671
|
-
raise
|
1672
|
-
|
1673
|
-
def _check_cgi_auth(self) -> bool:
|
1674
|
-
"""
|
1675
|
-
Check basic auth in CGI mode using environment variables
|
1676
|
-
|
1677
|
-
Returns:
|
1678
|
-
True if auth is valid, False otherwise
|
1679
|
-
"""
|
1680
|
-
# Check for HTTP_AUTHORIZATION environment variable
|
1681
|
-
auth_header = os.getenv('HTTP_AUTHORIZATION')
|
1682
|
-
if not auth_header:
|
1683
|
-
# Also check for REMOTE_USER (if web server handled auth)
|
1684
|
-
remote_user = os.getenv('REMOTE_USER')
|
1685
|
-
if remote_user:
|
1686
|
-
# If web server handled auth, trust it
|
1687
|
-
return True
|
1688
|
-
return False
|
1689
|
-
|
1690
|
-
if not auth_header.startswith('Basic '):
|
1691
|
-
return False
|
1692
|
-
|
1693
|
-
try:
|
1694
|
-
# Decode the base64 credentials
|
1695
|
-
credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
|
1696
|
-
username, password = credentials.split(":", 1)
|
1697
|
-
return self.validate_basic_auth(username, password)
|
1698
|
-
except Exception:
|
1699
|
-
return False
|
1700
|
-
|
1701
|
-
def _send_cgi_auth_challenge(self) -> str:
|
1702
|
-
"""
|
1703
|
-
Send authentication challenge in CGI mode
|
1704
|
-
|
1705
|
-
Returns:
|
1706
|
-
HTTP response with 401 status and WWW-Authenticate header
|
1707
|
-
"""
|
1708
|
-
# In CGI, we need to output the complete HTTP response
|
1709
|
-
response = "Status: 401 Unauthorized\r\n"
|
1710
|
-
response += "WWW-Authenticate: Basic realm=\"SignalWire Agent\"\r\n"
|
1711
|
-
response += "Content-Type: application/json\r\n"
|
1712
|
-
response += "\r\n"
|
1713
|
-
response += json.dumps({"error": "Unauthorized"})
|
1714
|
-
return response
|
1715
|
-
|
1716
|
-
def _check_lambda_auth(self, event) -> bool:
|
1717
|
-
"""
|
1718
|
-
Check basic auth in Lambda mode using event headers
|
1719
|
-
|
1720
|
-
Args:
|
1721
|
-
event: Lambda event object containing headers
|
1722
|
-
|
1723
|
-
Returns:
|
1724
|
-
True if auth is valid, False otherwise
|
1725
|
-
"""
|
1726
|
-
if not event or 'headers' not in event:
|
1727
|
-
return False
|
1728
|
-
|
1729
|
-
headers = event['headers']
|
1730
|
-
|
1731
|
-
# Check for authorization header (case-insensitive)
|
1732
|
-
auth_header = None
|
1733
|
-
for key, value in headers.items():
|
1734
|
-
if key.lower() == 'authorization':
|
1735
|
-
auth_header = value
|
1736
|
-
break
|
1737
|
-
|
1738
|
-
if not auth_header or not auth_header.startswith('Basic '):
|
1739
|
-
return False
|
1740
|
-
|
1741
|
-
try:
|
1742
|
-
# Decode the base64 credentials
|
1743
|
-
credentials = base64.b64decode(auth_header[6:]).decode("utf-8")
|
1744
|
-
username, password = credentials.split(":", 1)
|
1745
|
-
return self.validate_basic_auth(username, password)
|
1746
|
-
except Exception:
|
1747
|
-
return False
|
1748
|
-
|
1749
|
-
def _send_lambda_auth_challenge(self) -> dict:
|
1750
|
-
"""
|
1751
|
-
Send authentication challenge in Lambda mode
|
1752
|
-
|
1753
|
-
Returns:
|
1754
|
-
Lambda response with 401 status and WWW-Authenticate header
|
1755
|
-
"""
|
1756
|
-
return {
|
1757
|
-
"statusCode": 401,
|
1758
|
-
"headers": {
|
1759
|
-
"WWW-Authenticate": "Basic realm=\"SignalWire Agent\"",
|
1760
|
-
"Content-Type": "application/json"
|
1761
|
-
},
|
1762
|
-
"body": json.dumps({"error": "Unauthorized"})
|
1763
|
-
}
|
1764
|
-
|
1765
|
-
|
1766
|
-
def handle_serverless_request(self, event=None, context=None, mode=None):
|
1767
|
-
"""
|
1768
|
-
Handle serverless environment requests (CGI, Lambda, Cloud Functions)
|
1769
|
-
|
1770
|
-
Args:
|
1771
|
-
event: Serverless event object (Lambda, Cloud Functions)
|
1772
|
-
context: Serverless context object (Lambda, Cloud Functions)
|
1773
|
-
mode: Override execution mode (from force_mode in run())
|
1774
|
-
|
1775
|
-
Returns:
|
1776
|
-
Response appropriate for the serverless platform
|
1777
|
-
"""
|
1778
|
-
if mode is None:
|
1779
|
-
mode = get_execution_mode()
|
1780
|
-
|
1781
|
-
try:
|
1782
|
-
if mode == 'cgi':
|
1783
|
-
# Check authentication in CGI mode
|
1784
|
-
if not self._check_cgi_auth():
|
1785
|
-
return self._send_cgi_auth_challenge()
|
1786
|
-
|
1787
|
-
path_info = os.getenv('PATH_INFO', '').strip('/')
|
1788
|
-
if not path_info:
|
1789
|
-
return self._render_swml()
|
1790
|
-
else:
|
1791
|
-
# Parse CGI request for SWAIG function call
|
1792
|
-
args = {}
|
1793
|
-
call_id = None
|
1794
|
-
raw_data = None
|
1795
|
-
|
1796
|
-
# Try to parse POST data from stdin for CGI
|
1797
|
-
import sys
|
1798
|
-
content_length = os.getenv('CONTENT_LENGTH')
|
1799
|
-
if content_length and content_length.isdigit():
|
1800
|
-
try:
|
1801
|
-
post_data = sys.stdin.read(int(content_length))
|
1802
|
-
if post_data:
|
1803
|
-
raw_data = json.loads(post_data)
|
1804
|
-
call_id = raw_data.get("call_id")
|
1805
|
-
|
1806
|
-
# Extract arguments like the FastAPI handler does
|
1807
|
-
if "argument" in raw_data and isinstance(raw_data["argument"], dict):
|
1808
|
-
if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
|
1809
|
-
args = raw_data["argument"]["parsed"][0]
|
1810
|
-
elif "raw" in raw_data["argument"]:
|
1811
|
-
try:
|
1812
|
-
args = json.loads(raw_data["argument"]["raw"])
|
1813
|
-
except Exception:
|
1814
|
-
pass
|
1815
|
-
except Exception:
|
1816
|
-
# If parsing fails, continue with empty args
|
1817
|
-
pass
|
1818
|
-
|
1819
|
-
return self._execute_swaig_function(path_info, args, call_id, raw_data)
|
1820
|
-
|
1821
|
-
elif mode == 'lambda':
|
1822
|
-
# Check authentication in Lambda mode
|
1823
|
-
if not self._check_lambda_auth(event):
|
1824
|
-
return self._send_lambda_auth_challenge()
|
1825
|
-
|
1826
|
-
if event:
|
1827
|
-
path = event.get('pathParameters', {}).get('proxy', '') if event.get('pathParameters') else ''
|
1828
|
-
if not path:
|
1829
|
-
swml_response = self._render_swml()
|
1830
|
-
return {
|
1831
|
-
"statusCode": 200,
|
1832
|
-
"headers": {"Content-Type": "application/json"},
|
1833
|
-
"body": swml_response
|
1834
|
-
}
|
1835
|
-
else:
|
1836
|
-
# Parse Lambda event for SWAIG function call
|
1837
|
-
args = {}
|
1838
|
-
call_id = None
|
1839
|
-
raw_data = None
|
1840
|
-
|
1841
|
-
# Parse request body if present
|
1842
|
-
body_content = event.get('body')
|
1843
|
-
if body_content:
|
1844
|
-
try:
|
1845
|
-
if isinstance(body_content, str):
|
1846
|
-
raw_data = json.loads(body_content)
|
1847
|
-
else:
|
1848
|
-
raw_data = body_content
|
1849
|
-
|
1850
|
-
call_id = raw_data.get("call_id")
|
1851
|
-
|
1852
|
-
# Extract arguments like the FastAPI handler does
|
1853
|
-
if "argument" in raw_data and isinstance(raw_data["argument"], dict):
|
1854
|
-
if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
|
1855
|
-
args = raw_data["argument"]["parsed"][0]
|
1856
|
-
elif "raw" in raw_data["argument"]:
|
1857
|
-
try:
|
1858
|
-
args = json.loads(raw_data["argument"]["raw"])
|
1859
|
-
except Exception:
|
1860
|
-
pass
|
1861
|
-
except Exception:
|
1862
|
-
# If parsing fails, continue with empty args
|
1863
|
-
pass
|
1864
|
-
|
1865
|
-
result = self._execute_swaig_function(path, args, call_id, raw_data)
|
1866
|
-
return {
|
1867
|
-
"statusCode": 200,
|
1868
|
-
"headers": {"Content-Type": "application/json"},
|
1869
|
-
"body": json.dumps(result) if isinstance(result, dict) else str(result)
|
1870
|
-
}
|
1871
|
-
else:
|
1872
|
-
# Handle case when event is None (direct Lambda call with no event)
|
1873
|
-
swml_response = self._render_swml()
|
1874
|
-
return {
|
1875
|
-
"statusCode": 200,
|
1876
|
-
"headers": {"Content-Type": "application/json"},
|
1877
|
-
"body": swml_response
|
1878
|
-
}
|
1879
|
-
|
1880
|
-
elif mode == 'google_cloud_function':
|
1881
|
-
# Check authentication in Google Cloud Functions mode
|
1882
|
-
if not self._check_google_cloud_function_auth(event):
|
1883
|
-
return self._send_google_cloud_function_auth_challenge()
|
1884
|
-
|
1885
|
-
return self._handle_google_cloud_function_request(event)
|
1886
|
-
|
1887
|
-
elif mode == 'azure_function':
|
1888
|
-
# Check authentication in Azure Functions mode
|
1889
|
-
if not self._check_azure_function_auth(event):
|
1890
|
-
return self._send_azure_function_auth_challenge()
|
1891
|
-
|
1892
|
-
return self._handle_azure_function_request(event)
|
1893
|
-
|
1894
|
-
|
1895
|
-
except Exception as e:
|
1896
|
-
import logging
|
1897
|
-
logging.error(f"Error in serverless request handler: {e}")
|
1898
|
-
if mode == 'lambda':
|
1899
|
-
return {
|
1900
|
-
"statusCode": 500,
|
1901
|
-
"headers": {"Content-Type": "application/json"},
|
1902
|
-
"body": json.dumps({"error": str(e)})
|
1903
|
-
}
|
1904
|
-
else:
|
1905
|
-
raise
|
1906
|
-
|
1907
|
-
|
1908
|
-
def _execute_swaig_function(self, function_name: str, args: Optional[Dict[str, Any]] = None, call_id: Optional[str] = None, raw_data: Optional[Dict[str, Any]] = None):
|
1909
|
-
"""
|
1910
|
-
Execute a SWAIG function in serverless context
|
1911
|
-
|
1912
|
-
Args:
|
1913
|
-
function_name: Name of the function to execute
|
1914
|
-
args: Function arguments dictionary
|
1915
|
-
call_id: Optional call ID
|
1916
|
-
raw_data: Optional raw request data
|
1917
|
-
|
1918
|
-
Returns:
|
1919
|
-
Function execution result
|
1920
|
-
"""
|
1921
|
-
# Use the existing logger
|
1922
|
-
req_log = self.log.bind(
|
1923
|
-
endpoint="serverless_swaig",
|
1924
|
-
function=function_name
|
1925
|
-
)
|
1926
|
-
|
1927
|
-
if call_id:
|
1928
|
-
req_log = req_log.bind(call_id=call_id)
|
1929
|
-
|
1930
|
-
req_log.debug("serverless_function_call_received")
|
1931
|
-
|
1932
|
-
try:
|
1933
|
-
# Validate function exists
|
1934
|
-
if function_name not in self._tool_registry._swaig_functions:
|
1935
|
-
req_log.warning("function_not_found", available_functions=list(self._tool_registry._swaig_functions.keys()))
|
1936
|
-
return {"error": f"Function '{function_name}' not found"}
|
1937
|
-
|
1938
|
-
# Use empty args if not provided
|
1939
|
-
if args is None:
|
1940
|
-
args = {}
|
1941
|
-
|
1942
|
-
# Use empty raw_data if not provided, but include function call structure
|
1943
|
-
if raw_data is None:
|
1944
|
-
raw_data = {
|
1945
|
-
"function": function_name,
|
1946
|
-
"argument": {
|
1947
|
-
"parsed": [args] if args else [],
|
1948
|
-
"raw": json.dumps(args) if args else "{}"
|
1949
|
-
}
|
1950
|
-
}
|
1951
|
-
if call_id:
|
1952
|
-
raw_data["call_id"] = call_id
|
1953
|
-
|
1954
|
-
req_log.debug("executing_function", args=json.dumps(args))
|
1955
|
-
|
1956
|
-
# Call the function using the existing on_function_call method
|
1957
|
-
result = self.on_function_call(function_name, args, raw_data)
|
1958
|
-
|
1959
|
-
# Convert result to dict if needed (same logic as in _handle_swaig_request)
|
1960
|
-
if isinstance(result, SwaigFunctionResult):
|
1961
|
-
result_dict = result.to_dict()
|
1962
|
-
elif isinstance(result, dict):
|
1963
|
-
result_dict = result
|
1964
|
-
else:
|
1965
|
-
result_dict = {"response": str(result)}
|
1966
|
-
|
1967
|
-
req_log.info("serverless_function_executed_successfully")
|
1968
|
-
req_log.debug("function_result", result=json.dumps(result_dict))
|
1969
|
-
return result_dict
|
1970
|
-
|
1971
|
-
except Exception as e:
|
1972
|
-
req_log.error("serverless_function_execution_error", error=str(e))
|
1973
|
-
return {"error": str(e), "function": function_name}
|
1974
|
-
|
1975
|
-
def setup_graceful_shutdown(self) -> None:
|
1976
|
-
"""
|
1977
|
-
Setup signal handlers for graceful shutdown (useful for Kubernetes)
|
1978
|
-
"""
|
1979
|
-
def signal_handler(signum, frame):
|
1980
|
-
self.log.info("shutdown_signal_received", signal=signum)
|
1981
|
-
|
1982
|
-
# Perform cleanup
|
1983
|
-
try:
|
1984
|
-
# Close any open resources
|
1985
|
-
if hasattr(self, '_session_manager'):
|
1986
|
-
# Could add cleanup logic here
|
1987
|
-
pass
|
1988
|
-
|
1989
|
-
self.log.info("cleanup_completed")
|
1990
|
-
except Exception as e:
|
1991
|
-
self.log.error("cleanup_error", error=str(e))
|
1992
|
-
finally:
|
1993
|
-
sys.exit(0)
|
1994
|
-
|
1995
|
-
# Register handlers for common termination signals
|
1996
|
-
signal.signal(signal.SIGTERM, signal_handler) # Kubernetes sends this
|
1997
|
-
signal.signal(signal.SIGINT, signal_handler) # Ctrl+C
|
1998
|
-
|
1999
|
-
self.log.debug("graceful_shutdown_handlers_registered")
|
2000
|
-
|
2001
|
-
def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
|
2002
|
-
"""
|
2003
|
-
Customization point for subclasses to modify SWML based on request data
|
2004
|
-
|
2005
|
-
Args:
|
2006
|
-
request_data: Optional dictionary containing the parsed POST body
|
2007
|
-
callback_path: Optional callback path
|
2008
|
-
|
2009
|
-
Returns:
|
2010
|
-
Optional dict with modifications to apply to the SWML document
|
2011
|
-
"""
|
2012
|
-
# Default implementation does nothing
|
2013
|
-
return None
|
2014
|
-
|
2015
|
-
def _register_routes(self, router):
|
2016
|
-
"""
|
2017
|
-
Register routes for this agent
|
2018
|
-
|
2019
|
-
This method ensures proper route registration by handling the routes
|
2020
|
-
directly in AgentBase rather than inheriting from SWMLService.
|
2021
|
-
|
2022
|
-
Args:
|
2023
|
-
router: FastAPI router to register routes with
|
2024
|
-
"""
|
2025
|
-
# Health check endpoints are now registered directly on the main app
|
2026
|
-
|
2027
|
-
# Root endpoint (handles both with and without trailing slash)
|
2028
|
-
@router.get("/")
|
2029
|
-
@router.post("/")
|
2030
|
-
async def handle_root(request: Request, response: Response):
|
2031
|
-
"""Handle GET/POST requests to the root endpoint"""
|
2032
|
-
return await self._handle_root_request(request)
|
2033
|
-
|
2034
|
-
# Debug endpoint - Both versions
|
2035
|
-
@router.get("/debug")
|
2036
|
-
@router.get("/debug/")
|
2037
|
-
@router.post("/debug")
|
2038
|
-
@router.post("/debug/")
|
2039
|
-
async def handle_debug(request: Request):
|
2040
|
-
"""Handle GET/POST requests to the debug endpoint"""
|
2041
|
-
return await self._handle_debug_request(request)
|
2042
|
-
|
2043
|
-
# SWAIG endpoint - Both versions
|
2044
|
-
@router.get("/swaig")
|
2045
|
-
@router.get("/swaig/")
|
2046
|
-
@router.post("/swaig")
|
2047
|
-
@router.post("/swaig/")
|
2048
|
-
async def handle_swaig(request: Request, response: Response):
|
2049
|
-
"""Handle GET/POST requests to the SWAIG endpoint"""
|
2050
|
-
return await self._handle_swaig_request(request, response)
|
2051
|
-
|
2052
|
-
# Post prompt endpoint - Both versions
|
2053
|
-
@router.get("/post_prompt")
|
2054
|
-
@router.get("/post_prompt/")
|
2055
|
-
@router.post("/post_prompt")
|
2056
|
-
@router.post("/post_prompt/")
|
2057
|
-
async def handle_post_prompt(request: Request):
|
2058
|
-
"""Handle GET/POST requests to the post_prompt endpoint"""
|
2059
|
-
return await self._handle_post_prompt_request(request)
|
2060
|
-
|
2061
|
-
# Check for input endpoint - Both versions
|
2062
|
-
@router.get("/check_for_input")
|
2063
|
-
@router.get("/check_for_input/")
|
2064
|
-
@router.post("/check_for_input")
|
2065
|
-
@router.post("/check_for_input/")
|
2066
|
-
async def handle_check_for_input(request: Request):
|
2067
|
-
"""Handle GET/POST requests to the check_for_input endpoint"""
|
2068
|
-
return await self._handle_check_for_input_request(request)
|
2069
|
-
|
2070
|
-
# Register callback routes for routing callbacks if available
|
2071
|
-
if hasattr(self, '_routing_callbacks') and self._routing_callbacks:
|
2072
|
-
for callback_path, callback_fn in self._routing_callbacks.items():
|
2073
|
-
# Skip the root path as it's already handled
|
2074
|
-
if callback_path == "/":
|
2075
|
-
continue
|
2076
|
-
|
2077
|
-
# Register both with and without trailing slash
|
2078
|
-
path = callback_path.rstrip("/")
|
2079
|
-
path_with_slash = f"{path}/"
|
2080
|
-
|
2081
|
-
@router.get(path)
|
2082
|
-
@router.get(path_with_slash)
|
2083
|
-
@router.post(path)
|
2084
|
-
@router.post(path_with_slash)
|
2085
|
-
async def handle_callback(request: Request, response: Response, cb_path=callback_path):
|
2086
|
-
"""Handle GET/POST requests to a registered callback path"""
|
2087
|
-
# Store the callback path in request state for _handle_request to use
|
2088
|
-
request.state.callback_path = cb_path
|
2089
|
-
return await self._handle_root_request(request)
|
2090
|
-
|
2091
|
-
self.log.info("callback_endpoint_registered", path=callback_path)
|
2092
|
-
|
2093
|
-
# ----------------------------------------------------------------------
|
2094
|
-
# AI Verb Configuration Methods
|
2095
|
-
# ----------------------------------------------------------------------
|
2096
|
-
|
2097
|
-
def add_hint(self, hint: str) -> 'AgentBase':
|
2098
|
-
"""
|
2099
|
-
Add a simple string hint to help the AI agent understand certain words better
|
2100
|
-
|
2101
|
-
Args:
|
2102
|
-
hint: The hint string to add
|
2103
|
-
|
2104
|
-
Returns:
|
2105
|
-
Self for method chaining
|
2106
|
-
"""
|
2107
|
-
if isinstance(hint, str) and hint:
|
2108
|
-
self._hints.append(hint)
|
2109
|
-
return self
|
2110
|
-
|
2111
|
-
def add_hints(self, hints: List[str]) -> 'AgentBase':
|
2112
|
-
"""
|
2113
|
-
Add multiple string hints
|
2114
|
-
|
2115
|
-
Args:
|
2116
|
-
hints: List of hint strings
|
2117
|
-
|
2118
|
-
Returns:
|
2119
|
-
Self for method chaining
|
2120
|
-
"""
|
2121
|
-
if hints and isinstance(hints, list):
|
2122
|
-
for hint in hints:
|
2123
|
-
if isinstance(hint, str) and hint:
|
2124
|
-
self._hints.append(hint)
|
2125
|
-
return self
|
2126
|
-
|
2127
|
-
def add_pattern_hint(self,
|
2128
|
-
hint: str,
|
2129
|
-
pattern: str,
|
2130
|
-
replace: str,
|
2131
|
-
ignore_case: bool = False) -> 'AgentBase':
|
2132
|
-
"""
|
2133
|
-
Add a complex hint with pattern matching
|
2134
|
-
|
2135
|
-
Args:
|
2136
|
-
hint: The hint to match
|
2137
|
-
pattern: Regular expression pattern
|
2138
|
-
replace: Text to replace the hint with
|
2139
|
-
ignore_case: Whether to ignore case when matching
|
2140
|
-
|
2141
|
-
Returns:
|
2142
|
-
Self for method chaining
|
2143
|
-
"""
|
2144
|
-
if hint and pattern and replace:
|
2145
|
-
self._hints.append({
|
2146
|
-
"hint": hint,
|
2147
|
-
"pattern": pattern,
|
2148
|
-
"replace": replace,
|
2149
|
-
"ignore_case": ignore_case
|
2150
|
-
})
|
2151
|
-
return self
|
2152
|
-
|
2153
|
-
def add_language(self,
|
2154
|
-
name: str,
|
2155
|
-
code: str,
|
2156
|
-
voice: str,
|
2157
|
-
speech_fillers: Optional[List[str]] = None,
|
2158
|
-
function_fillers: Optional[List[str]] = None,
|
2159
|
-
engine: Optional[str] = None,
|
2160
|
-
model: Optional[str] = None) -> 'AgentBase':
|
2161
|
-
"""
|
2162
|
-
Add a language configuration to support multilingual conversations
|
2163
|
-
|
2164
|
-
Args:
|
2165
|
-
name: Name of the language (e.g., "English", "French")
|
2166
|
-
code: Language code (e.g., "en-US", "fr-FR")
|
2167
|
-
voice: TTS voice to use. Can be a simple name (e.g., "en-US-Neural2-F")
|
2168
|
-
or a combined format "engine.voice:model" (e.g., "elevenlabs.josh:eleven_turbo_v2_5")
|
2169
|
-
speech_fillers: Optional list of filler phrases for natural speech
|
2170
|
-
function_fillers: Optional list of filler phrases during function calls
|
2171
|
-
engine: Optional explicit engine name (e.g., "elevenlabs", "rime")
|
2172
|
-
model: Optional explicit model name (e.g., "eleven_turbo_v2_5", "arcana")
|
2173
|
-
|
2174
|
-
Returns:
|
2175
|
-
Self for method chaining
|
2176
|
-
|
2177
|
-
Examples:
|
2178
|
-
# Simple voice name
|
2179
|
-
agent.add_language("English", "en-US", "en-US-Neural2-F")
|
2180
|
-
|
2181
|
-
# Explicit parameters
|
2182
|
-
agent.add_language("English", "en-US", "josh", engine="elevenlabs", model="eleven_turbo_v2_5")
|
2183
|
-
|
2184
|
-
# Combined format
|
2185
|
-
agent.add_language("English", "en-US", "elevenlabs.josh:eleven_turbo_v2_5")
|
2186
|
-
"""
|
2187
|
-
language = {
|
2188
|
-
"name": name,
|
2189
|
-
"code": code
|
2190
|
-
}
|
2191
|
-
|
2192
|
-
# Handle voice formatting (either explicit params or combined string)
|
2193
|
-
if engine or model:
|
2194
|
-
# Use explicit parameters if provided
|
2195
|
-
language["voice"] = voice
|
2196
|
-
if engine:
|
2197
|
-
language["engine"] = engine
|
2198
|
-
if model:
|
2199
|
-
language["model"] = model
|
2200
|
-
elif "." in voice and ":" in voice:
|
2201
|
-
# Parse combined string format: "engine.voice:model"
|
2202
|
-
try:
|
2203
|
-
engine_voice, model_part = voice.split(":", 1)
|
2204
|
-
engine_part, voice_part = engine_voice.split(".", 1)
|
2205
|
-
|
2206
|
-
language["voice"] = voice_part
|
2207
|
-
language["engine"] = engine_part
|
2208
|
-
language["model"] = model_part
|
2209
|
-
except ValueError:
|
2210
|
-
# If parsing fails, use the voice string as-is
|
2211
|
-
language["voice"] = voice
|
2212
|
-
else:
|
2213
|
-
# Simple voice string
|
2214
|
-
language["voice"] = voice
|
2215
|
-
|
2216
|
-
# Add fillers if provided
|
2217
|
-
if speech_fillers and function_fillers:
|
2218
|
-
language["speech_fillers"] = speech_fillers
|
2219
|
-
language["function_fillers"] = function_fillers
|
2220
|
-
elif speech_fillers or function_fillers:
|
2221
|
-
# If only one type of fillers is provided, use the deprecated "fillers" field
|
2222
|
-
fillers = speech_fillers or function_fillers
|
2223
|
-
language["fillers"] = fillers
|
2224
|
-
|
2225
|
-
self._languages.append(language)
|
2226
|
-
return self
|
2227
|
-
|
2228
|
-
def set_languages(self, languages: List[Dict[str, Any]]) -> 'AgentBase':
|
2229
|
-
"""
|
2230
|
-
Set all language configurations at once
|
2231
|
-
|
2232
|
-
Args:
|
2233
|
-
languages: List of language configuration dictionaries
|
2234
|
-
|
2235
|
-
Returns:
|
2236
|
-
Self for method chaining
|
2237
|
-
"""
|
2238
|
-
if languages and isinstance(languages, list):
|
2239
|
-
self._languages = languages
|
2240
|
-
return self
|
2241
|
-
|
2242
|
-
def add_pronunciation(self,
|
2243
|
-
replace: str,
|
2244
|
-
with_text: str,
|
2245
|
-
ignore_case: bool = False) -> 'AgentBase':
|
2246
|
-
"""
|
2247
|
-
Add a pronunciation rule to help the AI speak certain words correctly
|
2248
|
-
|
2249
|
-
Args:
|
2250
|
-
replace: The expression to replace
|
2251
|
-
with_text: The phonetic spelling to use instead
|
2252
|
-
ignore_case: Whether to ignore case when matching
|
2253
|
-
|
2254
|
-
Returns:
|
2255
|
-
Self for method chaining
|
2256
|
-
"""
|
2257
|
-
if replace and with_text:
|
2258
|
-
rule = {
|
2259
|
-
"replace": replace,
|
2260
|
-
"with": with_text
|
2261
|
-
}
|
2262
|
-
if ignore_case:
|
2263
|
-
rule["ignore_case"] = True
|
2264
|
-
|
2265
|
-
self._pronounce.append(rule)
|
2266
|
-
return self
|
2267
|
-
|
2268
|
-
def set_pronunciations(self, pronunciations: List[Dict[str, Any]]) -> 'AgentBase':
|
2269
|
-
"""
|
2270
|
-
Set all pronunciation rules at once
|
2271
|
-
|
2272
|
-
Args:
|
2273
|
-
pronunciations: List of pronunciation rule dictionaries
|
2274
|
-
|
2275
|
-
Returns:
|
2276
|
-
Self for method chaining
|
2277
|
-
"""
|
2278
|
-
if pronunciations and isinstance(pronunciations, list):
|
2279
|
-
self._pronounce = pronunciations
|
2280
|
-
return self
|
2281
|
-
|
2282
|
-
def set_param(self, key: str, value: Any) -> 'AgentBase':
|
2283
|
-
"""
|
2284
|
-
Set a single AI parameter
|
2285
|
-
|
2286
|
-
Args:
|
2287
|
-
key: Parameter name
|
2288
|
-
value: Parameter value
|
2289
|
-
|
2290
|
-
Returns:
|
2291
|
-
Self for method chaining
|
2292
|
-
"""
|
2293
|
-
if key:
|
2294
|
-
self._params[key] = value
|
2295
|
-
return self
|
2296
|
-
|
2297
|
-
def set_params(self, params: Dict[str, Any]) -> 'AgentBase':
|
2298
|
-
"""
|
2299
|
-
Set multiple AI parameters at once
|
2300
|
-
|
2301
|
-
Args:
|
2302
|
-
params: Dictionary of parameter name/value pairs
|
2303
|
-
|
2304
|
-
Returns:
|
2305
|
-
Self for method chaining
|
2306
|
-
"""
|
2307
|
-
if params and isinstance(params, dict):
|
2308
|
-
self._params.update(params)
|
2309
|
-
return self
|
2310
|
-
|
2311
|
-
def set_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
|
2312
|
-
"""
|
2313
|
-
Set the global data available to the AI throughout the conversation
|
2314
|
-
|
2315
|
-
Args:
|
2316
|
-
data: Dictionary of global data
|
2317
|
-
|
2318
|
-
Returns:
|
2319
|
-
Self for method chaining
|
2320
|
-
"""
|
2321
|
-
if data and isinstance(data, dict):
|
2322
|
-
self._global_data = data
|
2323
|
-
return self
|
2324
|
-
|
2325
|
-
def update_global_data(self, data: Dict[str, Any]) -> 'AgentBase':
|
2326
|
-
"""
|
2327
|
-
Update the global data with new values
|
2328
|
-
|
2329
|
-
Args:
|
2330
|
-
data: Dictionary of global data to update
|
2331
|
-
|
2332
|
-
Returns:
|
2333
|
-
Self for method chaining
|
2334
|
-
"""
|
2335
|
-
if data and isinstance(data, dict):
|
2336
|
-
self._global_data.update(data)
|
2337
|
-
return self
|
2338
|
-
|
2339
|
-
def set_native_functions(self, function_names: List[str]) -> 'AgentBase':
|
2340
|
-
"""
|
2341
|
-
Set the list of native functions to enable
|
2342
|
-
|
2343
|
-
Args:
|
2344
|
-
function_names: List of native function names
|
2345
|
-
|
2346
|
-
Returns:
|
2347
|
-
Self for method chaining
|
2348
|
-
"""
|
2349
|
-
if function_names and isinstance(function_names, list):
|
2350
|
-
self.native_functions = [name for name in function_names if isinstance(name, str)]
|
2351
|
-
return self
|
2352
|
-
|
2353
|
-
def set_internal_fillers(self, internal_fillers: Dict[str, Dict[str, List[str]]]) -> 'AgentBase':
|
2354
|
-
"""
|
2355
|
-
Set internal fillers for native SWAIG functions
|
2356
|
-
|
2357
|
-
Internal fillers provide custom phrases the AI says while executing
|
2358
|
-
internal/native functions like check_time, wait_for_user, next_step, etc.
|
2359
|
-
|
2360
|
-
Args:
|
2361
|
-
internal_fillers: Dictionary mapping function names to language-specific filler phrases
|
2362
|
-
Format: {"function_name": {"language_code": ["phrase1", "phrase2"]}}
|
2363
|
-
Example: {"next_step": {"en-US": ["Moving to the next step...", "Great, let's continue..."]}}
|
2364
|
-
|
2365
|
-
Returns:
|
2366
|
-
Self for method chaining
|
2367
|
-
|
2368
|
-
Example:
|
2369
|
-
agent.set_internal_fillers({
|
2370
|
-
"next_step": {
|
2371
|
-
"en-US": ["Moving to the next step...", "Great, let's continue..."],
|
2372
|
-
"es": ["Pasando al siguiente paso...", "Excelente, continuemos..."]
|
2373
|
-
},
|
2374
|
-
"check_time": {
|
2375
|
-
"en-US": ["Let me check the time...", "Getting the current time..."]
|
2376
|
-
}
|
2377
|
-
})
|
2378
|
-
"""
|
2379
|
-
if internal_fillers and isinstance(internal_fillers, dict):
|
2380
|
-
if not hasattr(self, '_internal_fillers'):
|
2381
|
-
self._internal_fillers = {}
|
2382
|
-
self._internal_fillers.update(internal_fillers)
|
2383
|
-
return self
|
2384
|
-
|
2385
|
-
def add_internal_filler(self, function_name: str, language_code: str, fillers: List[str]) -> 'AgentBase':
|
2386
|
-
"""
|
2387
|
-
Add internal fillers for a specific function and language
|
2388
|
-
|
2389
|
-
Args:
|
2390
|
-
function_name: Name of the internal function (e.g., 'next_step', 'check_time')
|
2391
|
-
language_code: Language code (e.g., 'en-US', 'es', 'fr')
|
2392
|
-
fillers: List of filler phrases for this function and language
|
2393
|
-
|
2394
|
-
Returns:
|
2395
|
-
Self for method chaining
|
2396
|
-
|
2397
|
-
Example:
|
2398
|
-
agent.add_internal_filler("next_step", "en-US", ["Moving to the next step...", "Great, let's continue..."])
|
2399
|
-
"""
|
2400
|
-
if function_name and language_code and fillers and isinstance(fillers, list):
|
2401
|
-
if not hasattr(self, '_internal_fillers'):
|
2402
|
-
self._internal_fillers = {}
|
2403
|
-
|
2404
|
-
if function_name not in self._internal_fillers:
|
2405
|
-
self._internal_fillers[function_name] = {}
|
2406
|
-
|
2407
|
-
self._internal_fillers[function_name][language_code] = fillers
|
2408
|
-
return self
|
2409
|
-
|
2410
|
-
def add_function_include(self, url: str, functions: List[str], meta_data: Optional[Dict[str, Any]] = None) -> 'AgentBase':
|
2411
|
-
"""
|
2412
|
-
Add a remote function include to the SWAIG configuration
|
2413
|
-
|
2414
|
-
Args:
|
2415
|
-
url: URL to fetch remote functions from
|
2416
|
-
functions: List of function names to include
|
2417
|
-
meta_data: Optional metadata to include with the function include
|
2418
|
-
|
2419
|
-
Returns:
|
2420
|
-
Self for method chaining
|
2421
|
-
"""
|
2422
|
-
if url and functions and isinstance(functions, list):
|
2423
|
-
include = {
|
2424
|
-
"url": url,
|
2425
|
-
"functions": functions
|
2426
|
-
}
|
2427
|
-
if meta_data and isinstance(meta_data, dict):
|
2428
|
-
include["meta_data"] = meta_data
|
2429
|
-
|
2430
|
-
self._function_includes.append(include)
|
2431
|
-
return self
|
2432
|
-
|
2433
|
-
def set_function_includes(self, includes: List[Dict[str, Any]]) -> 'AgentBase':
|
2434
|
-
"""
|
2435
|
-
Set the complete list of function includes
|
2436
|
-
|
2437
|
-
Args:
|
2438
|
-
includes: List of include objects, each with url and functions properties
|
2439
|
-
|
2440
|
-
Returns:
|
2441
|
-
Self for method chaining
|
2442
|
-
"""
|
2443
|
-
if includes and isinstance(includes, list):
|
2444
|
-
# Validate each include has required properties
|
2445
|
-
valid_includes = []
|
2446
|
-
for include in includes:
|
2447
|
-
if isinstance(include, dict) and "url" in include and "functions" in include:
|
2448
|
-
if isinstance(include["functions"], list):
|
2449
|
-
valid_includes.append(include)
|
2450
|
-
|
2451
|
-
self._function_includes = valid_includes
|
2452
|
-
return self
|
2453
|
-
|
2454
|
-
def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
|
2455
|
-
"""
|
2456
|
-
Enable SIP-based routing for this agent
|
2457
|
-
|
2458
|
-
This allows the agent to automatically route SIP requests based on SIP usernames.
|
2459
|
-
When enabled, an endpoint at the specified path is automatically created
|
2460
|
-
that will handle SIP requests and deliver them to this agent.
|
2461
|
-
|
2462
|
-
Args:
|
2463
|
-
auto_map: Whether to automatically map common SIP usernames to this agent
|
2464
|
-
(based on the agent name and route path)
|
2465
|
-
path: The path to register the SIP routing endpoint (default: "/sip")
|
2466
|
-
|
2467
|
-
Returns:
|
2468
|
-
Self for method chaining
|
2469
|
-
"""
|
2470
|
-
# Create a routing callback that handles SIP usernames
|
2471
|
-
def sip_routing_callback(request: Request, body: Dict[str, Any]) -> Optional[str]:
|
2472
|
-
# Extract SIP username from the request body
|
2473
|
-
sip_username = self.extract_sip_username(body)
|
2474
|
-
|
2475
|
-
if sip_username:
|
2476
|
-
self.log.info("sip_username_extracted", username=sip_username)
|
2477
|
-
|
2478
|
-
# Check if this username is registered with this agent
|
2479
|
-
if hasattr(self, '_sip_usernames') and sip_username.lower() in self._sip_usernames:
|
2480
|
-
self.log.info("sip_username_matched", username=sip_username)
|
2481
|
-
# This route is already being handled by the agent, no need to redirect
|
2482
|
-
return None
|
2483
|
-
else:
|
2484
|
-
self.log.info("sip_username_not_matched", username=sip_username)
|
2485
|
-
# Not registered with this agent, let routing continue
|
2486
|
-
|
2487
|
-
return None
|
2488
|
-
|
2489
|
-
# Register the callback with the SWMLService, specifying the path
|
2490
|
-
self.register_routing_callback(sip_routing_callback, path=path)
|
2491
|
-
|
2492
|
-
# Auto-map common usernames if requested
|
2493
|
-
if auto_map:
|
2494
|
-
self.auto_map_sip_usernames()
|
2495
|
-
|
2496
|
-
return self
|
2497
|
-
|
2498
|
-
def register_sip_username(self, sip_username: str) -> 'AgentBase':
|
2499
|
-
"""
|
2500
|
-
Register a SIP username that should be routed to this agent
|
2501
|
-
|
2502
|
-
Args:
|
2503
|
-
sip_username: SIP username to register
|
2504
|
-
|
2505
|
-
Returns:
|
2506
|
-
Self for method chaining
|
2507
|
-
"""
|
2508
|
-
if not hasattr(self, '_sip_usernames'):
|
2509
|
-
self._sip_usernames = set()
|
2510
|
-
|
2511
|
-
self._sip_usernames.add(sip_username.lower())
|
2512
|
-
self.log.info("sip_username_registered", username=sip_username)
|
2513
|
-
|
2514
|
-
return self
|
2515
|
-
|
2516
|
-
def auto_map_sip_usernames(self) -> 'AgentBase':
|
2517
|
-
"""
|
2518
|
-
Automatically register common SIP usernames based on this agent's
|
2519
|
-
name and route
|
2520
|
-
|
2521
|
-
Returns:
|
2522
|
-
Self for method chaining
|
2523
|
-
"""
|
2524
|
-
# Register username based on agent name
|
2525
|
-
clean_name = re.sub(r'[^a-z0-9_]', '', self.name.lower())
|
2526
|
-
if clean_name:
|
2527
|
-
self.register_sip_username(clean_name)
|
2528
|
-
|
2529
|
-
# Register username based on route (without slashes)
|
2530
|
-
clean_route = re.sub(r'[^a-z0-9_]', '', self.route.lower())
|
2531
|
-
if clean_route and clean_route != clean_name:
|
2532
|
-
self.register_sip_username(clean_route)
|
2533
|
-
|
2534
|
-
# Register common variations if they make sense
|
2535
|
-
if len(clean_name) > 3:
|
2536
|
-
# Register without vowels
|
2537
|
-
no_vowels = re.sub(r'[aeiou]', '', clean_name)
|
2538
|
-
if no_vowels != clean_name and len(no_vowels) > 2:
|
2539
|
-
self.register_sip_username(no_vowels)
|
2540
|
-
|
2541
|
-
return self
|
2542
|
-
|
2543
|
-
def set_web_hook_url(self, url: str) -> 'AgentBase':
|
2544
|
-
"""
|
2545
|
-
Override the default web_hook_url with a supplied URL string
|
2546
|
-
|
2547
|
-
Args:
|
2548
|
-
url: The URL to use for SWAIG function webhooks
|
2549
|
-
|
2550
|
-
Returns:
|
2551
|
-
Self for method chaining
|
2552
|
-
"""
|
2553
|
-
self._web_hook_url_override = url
|
2554
|
-
return self
|
2555
|
-
|
2556
|
-
def set_post_prompt_url(self, url: str) -> 'AgentBase':
|
2557
|
-
"""
|
2558
|
-
Override the default post_prompt_url with a supplied URL string
|
2559
|
-
|
2560
|
-
Args:
|
2561
|
-
url: The URL to use for post-prompt summary delivery
|
2562
|
-
|
2563
|
-
Returns:
|
2564
|
-
Self for method chaining
|
2565
|
-
"""
|
2566
|
-
self._post_prompt_url_override = url
|
2567
|
-
return self
|
2568
|
-
|
2569
|
-
async def _handle_swaig_request(self, request: Request, response: Response):
|
2570
|
-
"""Handle GET/POST requests to the SWAIG endpoint"""
|
2571
|
-
req_log = self.log.bind(
|
2572
|
-
endpoint="swaig",
|
2573
|
-
method=request.method,
|
2574
|
-
path=request.url.path
|
2575
|
-
)
|
2576
|
-
|
2577
|
-
req_log.debug("endpoint_called")
|
2578
|
-
|
2579
|
-
try:
|
2580
|
-
# Check auth
|
2581
|
-
if not self._check_basic_auth(request):
|
2582
|
-
req_log.warning("unauthorized_access_attempt")
|
2583
|
-
response.headers["WWW-Authenticate"] = "Basic"
|
2584
|
-
return Response(
|
2585
|
-
content=json.dumps({"error": "Unauthorized"}),
|
2586
|
-
status_code=401,
|
2587
|
-
headers={"WWW-Authenticate": "Basic"},
|
2588
|
-
media_type="application/json"
|
2589
|
-
)
|
2590
|
-
|
2591
|
-
# Handle differently based on method
|
2592
|
-
if request.method == "GET":
|
2593
|
-
# For GET requests, return the SWML document (same as root endpoint)
|
2594
|
-
call_id = request.query_params.get("call_id")
|
2595
|
-
swml = self._render_swml(call_id)
|
2596
|
-
req_log.debug("swml_rendered", swml_size=len(swml))
|
2597
|
-
return Response(
|
2598
|
-
content=swml,
|
2599
|
-
media_type="application/json"
|
2600
|
-
)
|
2601
|
-
|
2602
|
-
# For POST requests, process SWAIG function calls
|
2603
|
-
try:
|
2604
|
-
body = await request.json()
|
2605
|
-
req_log.debug("request_body_received", body_size=len(str(body)))
|
2606
|
-
if body:
|
2607
|
-
req_log.debug("request_body", body=json.dumps(body))
|
2608
|
-
except Exception as e:
|
2609
|
-
req_log.error("error_parsing_request_body", error=str(e))
|
2610
|
-
body = {}
|
2611
|
-
|
2612
|
-
# Extract function name
|
2613
|
-
function_name = body.get("function")
|
2614
|
-
if not function_name:
|
2615
|
-
req_log.warning("missing_function_name")
|
2616
|
-
return Response(
|
2617
|
-
content=json.dumps({"error": "Missing function name"}),
|
2618
|
-
status_code=400,
|
2619
|
-
media_type="application/json"
|
2620
|
-
)
|
2621
|
-
|
2622
|
-
# Add function info to logger
|
2623
|
-
req_log = req_log.bind(function=function_name)
|
2624
|
-
req_log.debug("function_call_received")
|
2625
|
-
|
2626
|
-
# Extract arguments
|
2627
|
-
args = {}
|
2628
|
-
if "argument" in body and isinstance(body["argument"], dict):
|
2629
|
-
if "parsed" in body["argument"] and isinstance(body["argument"]["parsed"], list) and body["argument"]["parsed"]:
|
2630
|
-
args = body["argument"]["parsed"][0]
|
2631
|
-
req_log.debug("parsed_arguments", args=json.dumps(args))
|
2632
|
-
elif "raw" in body["argument"]:
|
2633
|
-
try:
|
2634
|
-
args = json.loads(body["argument"]["raw"])
|
2635
|
-
req_log.debug("raw_arguments_parsed", args=json.dumps(args))
|
2636
|
-
except Exception as e:
|
2637
|
-
req_log.error("error_parsing_raw_arguments", error=str(e), raw=body["argument"]["raw"])
|
2638
|
-
|
2639
|
-
# Get call_id from body
|
2640
|
-
call_id = body.get("call_id")
|
2641
|
-
if call_id:
|
2642
|
-
req_log = req_log.bind(call_id=call_id)
|
2643
|
-
req_log.debug("call_id_identified")
|
2644
|
-
|
2645
|
-
# SECURITY BYPASS FOR DEBUGGING - make all functions work regardless of token
|
2646
|
-
# We'll log the attempt but allow it through
|
2647
|
-
token = request.query_params.get("token")
|
2648
|
-
if token:
|
2649
|
-
req_log.debug("token_found", token_length=len(token))
|
2650
|
-
|
2651
|
-
# Check token validity but don't reject the request
|
2652
|
-
if hasattr(self, '_session_manager') and function_name in self._tool_registry._swaig_functions:
|
2653
|
-
is_valid = self._session_manager.validate_tool_token(function_name, token, call_id)
|
2654
|
-
if is_valid:
|
2655
|
-
req_log.debug("token_valid")
|
2656
|
-
else:
|
2657
|
-
# Log but continue anyway for debugging
|
2658
|
-
req_log.warning("token_invalid")
|
2659
|
-
if hasattr(self._session_manager, 'debug_token'):
|
2660
|
-
debug_info = self._session_manager.debug_token(token)
|
2661
|
-
req_log.debug("token_debug", debug=json.dumps(debug_info))
|
2662
|
-
|
2663
|
-
# Call the function
|
2664
|
-
try:
|
2665
|
-
result = self.on_function_call(function_name, args, body)
|
2666
|
-
|
2667
|
-
# Convert result to dict if needed
|
2668
|
-
if isinstance(result, SwaigFunctionResult):
|
2669
|
-
result_dict = result.to_dict()
|
2670
|
-
elif isinstance(result, dict):
|
2671
|
-
result_dict = result
|
2672
|
-
else:
|
2673
|
-
result_dict = {"response": str(result)}
|
2674
|
-
|
2675
|
-
req_log.info("function_executed_successfully")
|
2676
|
-
req_log.debug("function_result", result=json.dumps(result_dict))
|
2677
|
-
return result_dict
|
2678
|
-
except Exception as e:
|
2679
|
-
req_log.error("function_execution_error", error=str(e))
|
2680
|
-
return {"error": str(e), "function": function_name}
|
2681
|
-
|
2682
|
-
except Exception as e:
|
2683
|
-
req_log.error("request_failed", error=str(e))
|
2684
|
-
return Response(
|
2685
|
-
content=json.dumps({"error": str(e)}),
|
2686
|
-
status_code=500,
|
2687
|
-
media_type="application/json"
|
2688
|
-
)
|
2689
|
-
|
2690
|
-
async def _handle_root_request(self, request: Request):
|
2691
|
-
"""Handle GET/POST requests to the root endpoint"""
|
2692
|
-
# Auto-detect proxy on first request if not explicitly configured
|
2693
|
-
if not getattr(self, '_proxy_detection_done', False) and not getattr(self, '_proxy_url_base', None):
|
2694
|
-
# Check for proxy headers
|
2695
|
-
forwarded_host = request.headers.get("X-Forwarded-Host")
|
2696
|
-
forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
|
2697
|
-
|
2698
|
-
if forwarded_host:
|
2699
|
-
# Set proxy_url_base on both self and super() to ensure it's shared
|
2700
|
-
self._proxy_url_base = f"{forwarded_proto}://{forwarded_host}"
|
2701
|
-
if hasattr(super(), '_proxy_url_base'):
|
2702
|
-
# Ensure parent class has the same proxy URL
|
2703
|
-
super()._proxy_url_base = self._proxy_url_base
|
2704
|
-
|
2705
|
-
self.log.info("proxy_auto_detected", proxy_url_base=self._proxy_url_base,
|
2706
|
-
source="X-Forwarded headers")
|
2707
|
-
self._proxy_detection_done = True
|
2708
|
-
|
2709
|
-
# Also set the detection flag on parent
|
2710
|
-
if hasattr(super(), '_proxy_detection_done'):
|
2711
|
-
super()._proxy_detection_done = True
|
2712
|
-
# If no explicit proxy headers, try the parent class detection method if it exists
|
2713
|
-
elif hasattr(super(), '_detect_proxy_from_request'):
|
2714
|
-
# Call the parent's detection method
|
2715
|
-
super()._detect_proxy_from_request(request)
|
2716
|
-
# Copy the result to our class
|
2717
|
-
if hasattr(super(), '_proxy_url_base') and getattr(super(), '_proxy_url_base', None):
|
2718
|
-
self._proxy_url_base = super()._proxy_url_base
|
2719
|
-
self._proxy_detection_done = True
|
2720
|
-
|
2721
|
-
# Check if this is a callback path request
|
2722
|
-
callback_path = getattr(request.state, "callback_path", None)
|
2723
|
-
|
2724
|
-
req_log = self.log.bind(
|
2725
|
-
endpoint="root" if not callback_path else f"callback:{callback_path}",
|
2726
|
-
method=request.method,
|
2727
|
-
path=request.url.path
|
2728
|
-
)
|
2729
|
-
|
2730
|
-
req_log.debug("endpoint_called")
|
2731
|
-
|
2732
|
-
try:
|
2733
|
-
# Check auth
|
2734
|
-
if not self._check_basic_auth(request):
|
2735
|
-
req_log.warning("unauthorized_access_attempt")
|
2736
|
-
return Response(
|
2737
|
-
content=json.dumps({"error": "Unauthorized"}),
|
2738
|
-
status_code=401,
|
2739
|
-
headers={"WWW-Authenticate": "Basic"},
|
2740
|
-
media_type="application/json"
|
2741
|
-
)
|
2742
|
-
|
2743
|
-
# Try to parse request body for POST
|
2744
|
-
body = {}
|
2745
|
-
call_id = None
|
2746
|
-
|
2747
|
-
if request.method == "POST":
|
2748
|
-
# Check if body is empty first
|
2749
|
-
raw_body = await request.body()
|
2750
|
-
if raw_body:
|
2751
|
-
try:
|
2752
|
-
body = await request.json()
|
2753
|
-
req_log.debug("request_body_received", body_size=len(str(body)))
|
2754
|
-
if body:
|
2755
|
-
req_log.debug("request_body")
|
2756
|
-
except Exception as e:
|
2757
|
-
req_log.warning("error_parsing_request_body", error=str(e))
|
2758
|
-
# Continue processing with empty body
|
2759
|
-
body = {}
|
2760
|
-
else:
|
2761
|
-
req_log.debug("empty_request_body")
|
2762
|
-
|
2763
|
-
# Get call_id from body if present
|
2764
|
-
call_id = body.get("call_id")
|
2765
|
-
else:
|
2766
|
-
# Get call_id from query params for GET
|
2767
|
-
call_id = request.query_params.get("call_id")
|
2768
|
-
|
2769
|
-
# Add call_id to logger if any
|
2770
|
-
if call_id:
|
2771
|
-
req_log = req_log.bind(call_id=call_id)
|
2772
|
-
req_log.debug("call_id_identified")
|
2773
|
-
|
2774
|
-
# Check if this is a callback path and we need to apply routing
|
2775
|
-
if callback_path and hasattr(self, '_routing_callbacks') and callback_path in self._routing_callbacks:
|
2776
|
-
callback_fn = self._routing_callbacks[callback_path]
|
2777
|
-
|
2778
|
-
if request.method == "POST" and body:
|
2779
|
-
req_log.debug("processing_routing_callback", path=callback_path)
|
2780
|
-
# Call the routing callback
|
2781
|
-
try:
|
2782
|
-
route = callback_fn(request, body)
|
2783
|
-
if route is not None:
|
2784
|
-
req_log.info("routing_request", route=route)
|
2785
|
-
# Return a redirect to the new route
|
2786
|
-
return Response(
|
2787
|
-
status_code=307, # 307 Temporary Redirect preserves the method and body
|
2788
|
-
headers={"Location": route}
|
2789
|
-
)
|
2790
|
-
except Exception as e:
|
2791
|
-
req_log.error("error_in_routing_callback", error=str(e))
|
2792
|
-
|
2793
|
-
# Allow subclasses to inspect/modify the request
|
2794
|
-
modifications = None
|
2795
|
-
try:
|
2796
|
-
modifications = self.on_swml_request(body, callback_path, request)
|
2797
|
-
if modifications:
|
2798
|
-
req_log.debug("request_modifications_applied")
|
2799
|
-
except Exception as e:
|
2800
|
-
req_log.error("error_in_request_modifier", error=str(e))
|
2801
|
-
|
2802
|
-
# Render SWML
|
2803
|
-
swml = self._render_swml(call_id, modifications)
|
2804
|
-
req_log.debug("swml_rendered", swml_size=len(swml))
|
2805
|
-
|
2806
|
-
# Return as JSON
|
2807
|
-
req_log.info("request_successful")
|
2808
|
-
return Response(
|
2809
|
-
content=swml,
|
2810
|
-
media_type="application/json"
|
2811
|
-
)
|
2812
|
-
except Exception as e:
|
2813
|
-
req_log.error("request_failed", error=str(e))
|
2814
|
-
return Response(
|
2815
|
-
content=json.dumps({"error": str(e)}),
|
2816
|
-
status_code=500,
|
2817
|
-
media_type="application/json"
|
2818
|
-
)
|
2819
|
-
|
2820
|
-
async def _handle_debug_request(self, request: Request):
|
2821
|
-
"""Handle GET/POST requests to the debug endpoint"""
|
2822
|
-
req_log = self.log.bind(
|
2823
|
-
endpoint="debug",
|
2824
|
-
method=request.method,
|
2825
|
-
path=request.url.path
|
2826
|
-
)
|
2827
|
-
|
2828
|
-
req_log.debug("endpoint_called")
|
2829
|
-
|
2830
|
-
try:
|
2831
|
-
# Check auth
|
2832
|
-
if not self._check_basic_auth(request):
|
2833
|
-
req_log.warning("unauthorized_access_attempt")
|
2834
|
-
return Response(
|
2835
|
-
content=json.dumps({"error": "Unauthorized"}),
|
2836
|
-
status_code=401,
|
2837
|
-
headers={"WWW-Authenticate": "Basic"},
|
2838
|
-
media_type="application/json"
|
2839
|
-
)
|
2840
|
-
|
2841
|
-
# Get call_id from either query params (GET) or body (POST)
|
2842
|
-
call_id = None
|
2843
|
-
body = {}
|
2844
|
-
|
2845
|
-
if request.method == "POST":
|
2846
|
-
try:
|
2847
|
-
body = await request.json()
|
2848
|
-
req_log.debug("request_body_received", body_size=len(str(body)))
|
2849
|
-
call_id = body.get("call_id")
|
2850
|
-
except Exception as e:
|
2851
|
-
req_log.warning("error_parsing_request_body", error=str(e))
|
2852
|
-
else:
|
2853
|
-
call_id = request.query_params.get("call_id")
|
2854
|
-
|
2855
|
-
# Add call_id to logger if any
|
2856
|
-
if call_id:
|
2857
|
-
req_log = req_log.bind(call_id=call_id)
|
2858
|
-
req_log.debug("call_id_identified")
|
2859
|
-
|
2860
|
-
# Allow subclasses to inspect/modify the request
|
2861
|
-
modifications = None
|
2862
|
-
try:
|
2863
|
-
modifications = self.on_swml_request(body, None, request)
|
2864
|
-
if modifications:
|
2865
|
-
req_log.debug("request_modifications_applied")
|
2866
|
-
except Exception as e:
|
2867
|
-
req_log.error("error_in_request_modifier", error=str(e))
|
2868
|
-
|
2869
|
-
# Render SWML
|
2870
|
-
swml = self._render_swml(call_id, modifications)
|
2871
|
-
req_log.debug("swml_rendered", swml_size=len(swml))
|
2872
|
-
|
2873
|
-
# Return as JSON
|
2874
|
-
req_log.info("request_successful")
|
2875
|
-
return Response(
|
2876
|
-
content=swml,
|
2877
|
-
media_type="application/json",
|
2878
|
-
headers={"X-Debug": "true"}
|
2879
|
-
)
|
2880
|
-
except Exception as e:
|
2881
|
-
req_log.error("request_failed", error=str(e))
|
2882
|
-
return Response(
|
2883
|
-
content=json.dumps({"error": str(e)}),
|
2884
|
-
status_code=500,
|
2885
|
-
media_type="application/json"
|
2886
|
-
)
|
2887
|
-
|
2888
|
-
async def _handle_post_prompt_request(self, request: Request):
|
2889
|
-
"""Handle GET/POST requests to the post_prompt endpoint"""
|
2890
|
-
req_log = self.log.bind(
|
2891
|
-
endpoint="post_prompt",
|
2892
|
-
method=request.method,
|
2893
|
-
path=request.url.path
|
2894
|
-
)
|
2895
|
-
|
2896
|
-
# Only log if not suppressed
|
2897
|
-
if not getattr(self, '_suppress_logs', False):
|
2898
|
-
req_log.debug("endpoint_called")
|
2899
|
-
|
2900
|
-
try:
|
2901
|
-
# Check auth
|
2902
|
-
if not self._check_basic_auth(request):
|
2903
|
-
req_log.warning("unauthorized_access_attempt")
|
2904
|
-
return Response(
|
2905
|
-
content=json.dumps({"error": "Unauthorized"}),
|
2906
|
-
status_code=401,
|
2907
|
-
headers={"WWW-Authenticate": "Basic"},
|
2908
|
-
media_type="application/json"
|
2909
|
-
)
|
2910
|
-
|
2911
|
-
# Extract call_id for use with token validation
|
2912
|
-
call_id = request.query_params.get("call_id")
|
2913
|
-
|
2914
|
-
# For POST requests, try to also get call_id from body
|
2915
|
-
if request.method == "POST":
|
2916
|
-
try:
|
2917
|
-
body_text = await request.body()
|
2918
|
-
if body_text:
|
2919
|
-
body_data = json.loads(body_text)
|
2920
|
-
if call_id is None:
|
2921
|
-
call_id = body_data.get("call_id")
|
2922
|
-
# Save body_data for later use
|
2923
|
-
setattr(request, "_post_prompt_body", body_data)
|
2924
|
-
except Exception as e:
|
2925
|
-
req_log.error("error_extracting_call_id", error=str(e))
|
2926
|
-
|
2927
|
-
# If we have a call_id, add it to the logger context
|
2928
|
-
if call_id:
|
2929
|
-
req_log = req_log.bind(call_id=call_id)
|
2930
|
-
|
2931
|
-
# Check token if provided
|
2932
|
-
token = request.query_params.get("token")
|
2933
|
-
token_validated = False
|
830
|
+
def _build_webhook_url(self, endpoint: str, query_params: Optional[Dict[str, str]] = None) -> str:
|
831
|
+
"""
|
832
|
+
Helper method to build webhook URLs consistently
|
833
|
+
|
834
|
+
Args:
|
835
|
+
endpoint: The endpoint path (e.g., "swaig", "post_prompt")
|
836
|
+
query_params: Optional query parameters to append
|
2934
837
|
|
2935
|
-
|
2936
|
-
|
2937
|
-
|
2938
|
-
|
2939
|
-
|
2940
|
-
|
2941
|
-
|
2942
|
-
|
2943
|
-
|
2944
|
-
token_validated = True
|
2945
|
-
else:
|
2946
|
-
req_log.warning("invalid_token")
|
2947
|
-
# Debug information for token validation issues
|
2948
|
-
if hasattr(self._session_manager, 'debug_token'):
|
2949
|
-
debug_info = self._session_manager.debug_token(token)
|
2950
|
-
req_log.debug("token_debug", debug=json.dumps(debug_info))
|
2951
|
-
except Exception as e:
|
2952
|
-
req_log.error("token_validation_error", error=str(e))
|
2953
|
-
|
2954
|
-
# For GET requests, return the SWML document
|
2955
|
-
if request.method == "GET":
|
2956
|
-
swml = self._render_swml(call_id)
|
2957
|
-
req_log.debug("swml_rendered", swml_size=len(swml))
|
2958
|
-
return Response(
|
2959
|
-
content=swml,
|
2960
|
-
media_type="application/json"
|
2961
|
-
)
|
838
|
+
Returns:
|
839
|
+
Fully constructed webhook URL
|
840
|
+
"""
|
841
|
+
# Check for serverless environment and use appropriate URL generation
|
842
|
+
mode = get_execution_mode()
|
843
|
+
|
844
|
+
if mode != 'server':
|
845
|
+
# In serverless mode, use the serverless-appropriate URL with auth
|
846
|
+
base_url = self.get_full_url(include_auth=True)
|
2962
847
|
|
2963
|
-
#
|
2964
|
-
|
2965
|
-
|
2966
|
-
if hasattr(request, "_post_prompt_body"):
|
2967
|
-
body = getattr(request, "_post_prompt_body")
|
2968
|
-
else:
|
2969
|
-
body = await request.json()
|
2970
|
-
|
2971
|
-
# Only log if not suppressed
|
2972
|
-
if not getattr(self, '_suppress_logs', False):
|
2973
|
-
req_log.debug("request_body_received", body_size=len(str(body)))
|
2974
|
-
# Log the raw body directly (let the logger handle the JSON encoding)
|
2975
|
-
req_log.info("post_prompt_body", body=body)
|
2976
|
-
except Exception as e:
|
2977
|
-
req_log.error("error_parsing_request_body", error=str(e))
|
2978
|
-
body = {}
|
848
|
+
# Ensure the endpoint has a trailing slash to prevent redirects
|
849
|
+
if endpoint in ["swaig", "post_prompt"]:
|
850
|
+
endpoint = f"{endpoint}/"
|
2979
851
|
|
2980
|
-
#
|
2981
|
-
|
852
|
+
# Build the full webhook URL
|
853
|
+
url = f"{base_url}/{endpoint}"
|
2982
854
|
|
2983
|
-
#
|
2984
|
-
|
2985
|
-
|
2986
|
-
|
2987
|
-
|
2988
|
-
|
2989
|
-
|
2990
|
-
self.on_summary(None, body)
|
2991
|
-
req_log.debug("summary_handler_called_with_null_summary")
|
2992
|
-
except Exception as e:
|
2993
|
-
req_log.error("error_in_summary_handler", error=str(e))
|
855
|
+
# Add query parameters if any (only if they have values)
|
856
|
+
if query_params:
|
857
|
+
# Remove any call_id from query params
|
858
|
+
filtered_params = {k: v for k, v in query_params.items() if k != "call_id" and v}
|
859
|
+
if filtered_params:
|
860
|
+
params = "&".join([f"{k}={v}" for k, v in filtered_params.items()])
|
861
|
+
url = f"{url}?{params}"
|
2994
862
|
|
2995
|
-
|
2996
|
-
req_log.info("request_successful")
|
2997
|
-
return {"success": True}
|
2998
|
-
except Exception as e:
|
2999
|
-
req_log.error("request_failed", error=str(e))
|
3000
|
-
return Response(
|
3001
|
-
content=json.dumps({"error": str(e)}),
|
3002
|
-
status_code=500,
|
3003
|
-
media_type="application/json"
|
3004
|
-
)
|
3005
|
-
|
3006
|
-
async def _handle_check_for_input_request(self, request: Request):
|
3007
|
-
"""Handle GET/POST requests to the check_for_input endpoint"""
|
3008
|
-
req_log = self.log.bind(
|
3009
|
-
endpoint="check_for_input",
|
3010
|
-
method=request.method,
|
3011
|
-
path=request.url.path
|
3012
|
-
)
|
3013
|
-
|
3014
|
-
req_log.debug("endpoint_called")
|
863
|
+
return url
|
3015
864
|
|
3016
|
-
|
3017
|
-
|
3018
|
-
|
3019
|
-
req_log.warning("unauthorized_access_attempt")
|
3020
|
-
return Response(
|
3021
|
-
content=json.dumps({"error": "Unauthorized"}),
|
3022
|
-
status_code=401,
|
3023
|
-
headers={"WWW-Authenticate": "Basic"},
|
3024
|
-
media_type="application/json"
|
3025
|
-
)
|
3026
|
-
|
3027
|
-
# For both GET and POST requests, process input check
|
3028
|
-
conversation_id = None
|
3029
|
-
|
3030
|
-
if request.method == "POST":
|
3031
|
-
try:
|
3032
|
-
body = await request.json()
|
3033
|
-
req_log.debug("request_body_received", body_size=len(str(body)))
|
3034
|
-
conversation_id = body.get("conversation_id")
|
3035
|
-
except Exception as e:
|
3036
|
-
req_log.error("error_parsing_request_body", error=str(e))
|
3037
|
-
else:
|
3038
|
-
conversation_id = request.query_params.get("conversation_id")
|
3039
|
-
|
3040
|
-
if not conversation_id:
|
3041
|
-
req_log.warning("missing_conversation_id")
|
3042
|
-
return Response(
|
3043
|
-
content=json.dumps({"error": "Missing conversation_id parameter"}),
|
3044
|
-
status_code=400,
|
3045
|
-
media_type="application/json"
|
3046
|
-
)
|
3047
|
-
|
3048
|
-
# Here you would typically check for new input in some external system
|
3049
|
-
# For this implementation, we'll return an empty result
|
3050
|
-
return {
|
3051
|
-
"status": "success",
|
3052
|
-
"conversation_id": conversation_id,
|
3053
|
-
"new_input": False,
|
3054
|
-
"messages": []
|
3055
|
-
}
|
3056
|
-
except Exception as e:
|
3057
|
-
req_log.error("request_failed", error=str(e))
|
3058
|
-
return Response(
|
3059
|
-
content=json.dumps({"error": str(e)}),
|
3060
|
-
status_code=500,
|
3061
|
-
media_type="application/json"
|
3062
|
-
)
|
865
|
+
# Server mode - use the parent class's implementation from SWMLService
|
866
|
+
# which properly handles SWML_PROXY_URL_BASE environment variable
|
867
|
+
return super()._build_webhook_url(endpoint, query_params)
|
3063
868
|
|
3064
869
|
def _find_summary_in_post_data(self, body, logger):
|
3065
870
|
"""
|
@@ -3093,508 +898,142 @@ class AgentBase(SWMLService):
|
|
3093
898
|
return pdata["raw"]
|
3094
899
|
|
3095
900
|
return None
|
3096
|
-
|
3097
|
-
def _register_state_tracking_tools(self):
|
3098
|
-
"""
|
3099
|
-
Register special tools for state tracking
|
3100
|
-
|
3101
|
-
This adds startup_hook and hangup_hook SWAIG functions that automatically
|
3102
|
-
activate and deactivate the session when called. These are useful for
|
3103
|
-
tracking call state and cleaning up resources when a call ends.
|
3104
|
-
"""
|
3105
|
-
# Register startup hook to activate session
|
3106
|
-
self.define_tool(
|
3107
|
-
name="startup_hook",
|
3108
|
-
description="Called when a new conversation starts to initialize state",
|
3109
|
-
parameters={},
|
3110
|
-
handler=lambda args, raw_data: self._handle_startup_hook(args, raw_data),
|
3111
|
-
secure=False # No auth needed for this system function
|
3112
|
-
)
|
3113
|
-
|
3114
|
-
# Register hangup hook to end session
|
3115
|
-
self.define_tool(
|
3116
|
-
name="hangup_hook",
|
3117
|
-
description="Called when conversation ends to clean up resources",
|
3118
|
-
parameters={},
|
3119
|
-
handler=lambda args, raw_data: self._handle_hangup_hook(args, raw_data),
|
3120
|
-
secure=False # No auth needed for this system function
|
3121
|
-
)
|
3122
|
-
|
3123
|
-
def _handle_startup_hook(self, args, raw_data):
|
3124
|
-
"""
|
3125
|
-
Handle the startup hook function call
|
3126
|
-
|
3127
|
-
Args:
|
3128
|
-
args: Function arguments (empty for this hook)
|
3129
|
-
raw_data: Raw request data containing call_id
|
3130
|
-
|
3131
|
-
Returns:
|
3132
|
-
Success response
|
3133
|
-
"""
|
3134
|
-
call_id = raw_data.get("call_id") if raw_data else None
|
3135
|
-
if call_id:
|
3136
|
-
self.log.info("session_activated", call_id=call_id)
|
3137
|
-
self._session_manager.activate_session(call_id)
|
3138
|
-
return SwaigFunctionResult("Session activated")
|
3139
|
-
else:
|
3140
|
-
self.log.warning("session_activation_failed", error="No call_id provided")
|
3141
|
-
return SwaigFunctionResult("Failed to activate session: No call_id provided")
|
3142
901
|
|
3143
|
-
def
|
3144
|
-
"""
|
3145
|
-
Handle the hangup hook function call
|
3146
|
-
|
3147
|
-
Args:
|
3148
|
-
args: Function arguments (empty for this hook)
|
3149
|
-
raw_data: Raw request data containing call_id
|
3150
|
-
|
3151
|
-
Returns:
|
3152
|
-
Success response
|
3153
|
-
"""
|
3154
|
-
call_id = raw_data.get("call_id") if raw_data else None
|
3155
|
-
if call_id:
|
3156
|
-
self.log.info("session_ended", call_id=call_id)
|
3157
|
-
self._session_manager.end_session(call_id)
|
3158
|
-
return SwaigFunctionResult("Session ended")
|
3159
|
-
else:
|
3160
|
-
self.log.warning("session_end_failed", error="No call_id provided")
|
3161
|
-
return SwaigFunctionResult("Failed to end session: No call_id provided")
|
3162
|
-
|
3163
|
-
def on_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None) -> Optional[dict]:
|
902
|
+
def _create_ephemeral_copy(self):
|
3164
903
|
"""
|
3165
|
-
|
904
|
+
Create a lightweight copy of this agent for ephemeral configuration.
|
3166
905
|
|
3167
|
-
This
|
3168
|
-
for
|
3169
|
-
|
3170
|
-
Args:
|
3171
|
-
request_data: Optional dictionary containing the parsed POST body
|
3172
|
-
callback_path: Optional callback path
|
3173
|
-
|
3174
|
-
Returns:
|
3175
|
-
None to use the default SWML rendering (which will call _render_swml)
|
3176
|
-
"""
|
3177
|
-
# Call on_swml_request for customization
|
3178
|
-
if hasattr(self, 'on_swml_request') and callable(getattr(self, 'on_swml_request')):
|
3179
|
-
return self.on_swml_request(request_data, callback_path, None)
|
3180
|
-
|
3181
|
-
# If no on_swml_request or it returned None, we'll proceed with default rendering
|
3182
|
-
return None
|
3183
|
-
|
3184
|
-
def on_swml_request(self, request_data: Optional[dict] = None, callback_path: Optional[str] = None, request: Optional[Request] = None) -> Optional[dict]:
|
3185
|
-
"""
|
3186
|
-
Customization point for subclasses to modify SWML based on request data
|
906
|
+
This creates a partial copy that shares most resources but has independent
|
907
|
+
configuration for SWML generation. Used when dynamic configuration callbacks
|
908
|
+
need to modify the agent without affecting the persistent state.
|
3187
909
|
|
3188
|
-
Args:
|
3189
|
-
request_data: Optional dictionary containing the parsed POST body
|
3190
|
-
callback_path: Optional callback path
|
3191
|
-
request: Optional FastAPI Request object for accessing query params, headers, etc.
|
3192
|
-
|
3193
910
|
Returns:
|
3194
|
-
|
3195
|
-
"""
|
3196
|
-
# Handle dynamic configuration callback if set
|
3197
|
-
if self._dynamic_config_callback and request:
|
3198
|
-
try:
|
3199
|
-
# Extract request data
|
3200
|
-
query_params = dict(request.query_params)
|
3201
|
-
body_params = request_data or {}
|
3202
|
-
headers = dict(request.headers)
|
3203
|
-
|
3204
|
-
# Create ephemeral configurator
|
3205
|
-
agent_config = EphemeralAgentConfig()
|
3206
|
-
|
3207
|
-
# Call the user's configuration callback
|
3208
|
-
self._dynamic_config_callback(query_params, body_params, headers, agent_config)
|
3209
|
-
|
3210
|
-
# Extract the configuration
|
3211
|
-
config = agent_config.extract_config()
|
3212
|
-
if config:
|
3213
|
-
# Handle ephemeral prompt sections by applying them to this agent instance
|
3214
|
-
if "_ephemeral_prompt_sections" in config:
|
3215
|
-
for section in config["_ephemeral_prompt_sections"]:
|
3216
|
-
self.prompt_add_section(
|
3217
|
-
section["title"],
|
3218
|
-
section.get("body", ""),
|
3219
|
-
section.get("bullets"),
|
3220
|
-
**{k: v for k, v in section.items() if k not in ["title", "body", "bullets"]}
|
3221
|
-
)
|
3222
|
-
del config["_ephemeral_prompt_sections"]
|
3223
|
-
|
3224
|
-
if "_ephemeral_raw_prompt" in config:
|
3225
|
-
self._raw_prompt = config["_ephemeral_raw_prompt"]
|
3226
|
-
del config["_ephemeral_raw_prompt"]
|
3227
|
-
|
3228
|
-
if "_ephemeral_post_prompt" in config:
|
3229
|
-
self._post_prompt = config["_ephemeral_post_prompt"]
|
3230
|
-
del config["_ephemeral_post_prompt"]
|
3231
|
-
|
3232
|
-
return config
|
3233
|
-
|
3234
|
-
except Exception as e:
|
3235
|
-
self.log.error("dynamic_config_error", error=str(e))
|
3236
|
-
|
3237
|
-
# Default implementation does nothing
|
3238
|
-
return None
|
3239
|
-
|
3240
|
-
def register_routing_callback(self, callback_fn: Callable[[Request, Dict[str, Any]], Optional[str]],
|
3241
|
-
path: str = "/sip") -> None:
|
3242
|
-
"""
|
3243
|
-
Register a callback function that will be called to determine routing
|
3244
|
-
based on POST data.
|
3245
|
-
|
3246
|
-
When a routing callback is registered, an endpoint at the specified path is automatically
|
3247
|
-
created that will handle requests. This endpoint will use the callback to
|
3248
|
-
determine if the request should be processed by this service or redirected.
|
3249
|
-
|
3250
|
-
The callback should take a request object and request body dictionary and return:
|
3251
|
-
- A route string if it should be routed to a different endpoint
|
3252
|
-
- None if normal processing should continue
|
3253
|
-
|
3254
|
-
Args:
|
3255
|
-
callback_fn: The callback function to register
|
3256
|
-
path: The path where this callback should be registered (default: "/sip")
|
3257
|
-
"""
|
3258
|
-
# Normalize the path (remove trailing slash)
|
3259
|
-
normalized_path = path.rstrip("/")
|
3260
|
-
if not normalized_path.startswith("/"):
|
3261
|
-
normalized_path = f"/{normalized_path}"
|
3262
|
-
|
3263
|
-
# Store the callback with the normalized path (without trailing slash)
|
3264
|
-
self.log.info("registering_routing_callback", path=normalized_path)
|
3265
|
-
if not hasattr(self, '_routing_callbacks'):
|
3266
|
-
self._routing_callbacks = {}
|
3267
|
-
self._routing_callbacks[normalized_path] = callback_fn
|
3268
|
-
|
3269
|
-
def set_dynamic_config_callback(self, callback: Callable[[dict, dict, dict, EphemeralAgentConfig], None]) -> 'AgentBase':
|
3270
|
-
"""
|
3271
|
-
Set a callback function for dynamic agent configuration
|
3272
|
-
|
3273
|
-
This callback receives an EphemeralAgentConfig object that provides the same
|
3274
|
-
configuration methods as AgentBase, allowing you to dynamically configure
|
3275
|
-
the agent's voice, prompt, parameters, etc. based on request data.
|
3276
|
-
|
3277
|
-
Args:
|
3278
|
-
callback: Function that takes (query_params, body_params, headers, agent_config)
|
3279
|
-
and configures the agent_config object using familiar methods like:
|
3280
|
-
- agent_config.add_language(...)
|
3281
|
-
- agent_config.prompt_add_section(...)
|
3282
|
-
- agent_config.set_params(...)
|
3283
|
-
- agent_config.set_global_data(...)
|
3284
|
-
|
3285
|
-
Example:
|
3286
|
-
def my_config(query_params, body_params, headers, agent):
|
3287
|
-
if query_params.get('tier') == 'premium':
|
3288
|
-
agent.add_language("English", "en-US", "premium_voice")
|
3289
|
-
agent.set_params({"end_of_speech_timeout": 500})
|
3290
|
-
agent.set_global_data({"tier": query_params.get('tier', 'standard')})
|
3291
|
-
|
3292
|
-
my_agent.set_dynamic_config_callback(my_config)
|
3293
|
-
"""
|
3294
|
-
self._dynamic_config_callback = callback
|
3295
|
-
return self
|
3296
|
-
|
3297
|
-
def manual_set_proxy_url(self, proxy_url: str) -> 'AgentBase':
|
911
|
+
A lightweight copy of the agent suitable for ephemeral modifications
|
3298
912
|
"""
|
3299
|
-
|
913
|
+
import copy
|
3300
914
|
|
3301
|
-
|
3302
|
-
|
3303
|
-
|
3304
|
-
|
3305
|
-
|
3306
|
-
|
3307
|
-
|
3308
|
-
|
3309
|
-
|
3310
|
-
|
3311
|
-
|
3312
|
-
|
3313
|
-
|
3314
|
-
|
3315
|
-
|
3316
|
-
|
3317
|
-
|
3318
|
-
|
3319
|
-
|
3320
|
-
|
3321
|
-
|
3322
|
-
|
3323
|
-
|
3324
|
-
|
3325
|
-
|
3326
|
-
|
3327
|
-
|
3328
|
-
|
3329
|
-
|
3330
|
-
|
915
|
+
# Create a new instance of the same class
|
916
|
+
cls = self.__class__
|
917
|
+
ephemeral_agent = cls.__new__(cls)
|
918
|
+
|
919
|
+
# Copy all attributes as shallow references first
|
920
|
+
for key, value in self.__dict__.items():
|
921
|
+
setattr(ephemeral_agent, key, value)
|
922
|
+
|
923
|
+
# Deep copy only the configuration that affects SWML generation
|
924
|
+
# These are the parts that dynamic config might modify
|
925
|
+
ephemeral_agent._params = copy.deepcopy(self._params)
|
926
|
+
ephemeral_agent._hints = copy.deepcopy(self._hints)
|
927
|
+
ephemeral_agent._languages = copy.deepcopy(self._languages)
|
928
|
+
ephemeral_agent._pronounce = copy.deepcopy(self._pronounce)
|
929
|
+
ephemeral_agent._global_data = copy.deepcopy(self._global_data)
|
930
|
+
ephemeral_agent._function_includes = copy.deepcopy(self._function_includes)
|
931
|
+
|
932
|
+
# Deep copy the POM object if it exists to prevent sharing prompt sections
|
933
|
+
if hasattr(self, 'pom') and self.pom:
|
934
|
+
ephemeral_agent.pom = copy.deepcopy(self.pom)
|
935
|
+
# Handle native_functions which might be stored as an attribute or property
|
936
|
+
if hasattr(self, '_native_functions'):
|
937
|
+
ephemeral_agent._native_functions = copy.deepcopy(self._native_functions)
|
938
|
+
elif hasattr(self, 'native_functions'):
|
939
|
+
ephemeral_agent.native_functions = copy.deepcopy(self.native_functions)
|
940
|
+
ephemeral_agent._swaig_query_params = copy.deepcopy(self._swaig_query_params)
|
941
|
+
|
942
|
+
# Create new manager instances that point to the ephemeral agent
|
943
|
+
# This breaks the circular reference and allows independent modification
|
944
|
+
from signalwire_agents.core.agent.prompt.manager import PromptManager
|
945
|
+
from signalwire_agents.core.agent.tools.registry import ToolRegistry
|
946
|
+
|
947
|
+
# Create new prompt manager for the ephemeral agent
|
948
|
+
ephemeral_agent._prompt_manager = PromptManager(ephemeral_agent)
|
949
|
+
# Copy the prompt sections data
|
950
|
+
if hasattr(self._prompt_manager, '_sections'):
|
951
|
+
ephemeral_agent._prompt_manager._sections = copy.deepcopy(self._prompt_manager._sections)
|
952
|
+
|
953
|
+
# Create new tool registry for the ephemeral agent
|
954
|
+
ephemeral_agent._tool_registry = ToolRegistry(ephemeral_agent)
|
955
|
+
# Copy the SWAIG functions - we need a shallow copy here because
|
956
|
+
# the functions themselves can be shared, we just need a new dict
|
957
|
+
if hasattr(self._tool_registry, '_swaig_functions'):
|
958
|
+
ephemeral_agent._tool_registry._swaig_functions = self._tool_registry._swaig_functions.copy()
|
959
|
+
if hasattr(self._tool_registry, '_tool_instances'):
|
960
|
+
ephemeral_agent._tool_registry._tool_instances = self._tool_registry._tool_instances.copy()
|
961
|
+
|
962
|
+
# Create a new skill manager for the ephemeral agent
|
963
|
+
# This is important because skills register tools with the agent's registry
|
964
|
+
from signalwire_agents.core.skill_manager import SkillManager
|
965
|
+
ephemeral_agent.skill_manager = SkillManager(ephemeral_agent)
|
966
|
+
|
967
|
+
# Copy any already loaded skills from the original agent
|
968
|
+
# This ensures skills loaded during __init__ are available in the ephemeral agent
|
969
|
+
if hasattr(self.skill_manager, 'loaded_skills'):
|
970
|
+
for skill_key, skill_instance in self.skill_manager.loaded_skills.items():
|
971
|
+
# Re-load the skill in the ephemeral agent's context
|
972
|
+
# We need to get the skill name and params from the existing instance
|
973
|
+
skill_name = skill_instance.SKILL_NAME
|
974
|
+
skill_params = getattr(skill_instance, 'params', {})
|
975
|
+
try:
|
976
|
+
ephemeral_agent.skill_manager.load_skill(skill_name, type(skill_instance), skill_params)
|
977
|
+
except Exception as e:
|
978
|
+
self.log.warning("failed_to_copy_skill_to_ephemeral",
|
979
|
+
skill_name=skill_name,
|
980
|
+
error=str(e))
|
3331
981
|
|
3332
|
-
|
3333
|
-
|
3334
|
-
params: Optional parameters to pass to the skill for configuration
|
3335
|
-
|
3336
|
-
Returns:
|
3337
|
-
Self for method chaining
|
3338
|
-
|
3339
|
-
Raises:
|
3340
|
-
ValueError: If skill not found or failed to load with detailed error message
|
3341
|
-
"""
|
3342
|
-
success, error_message = self.skill_manager.load_skill(skill_name, params=params)
|
3343
|
-
if not success:
|
3344
|
-
raise ValueError(f"Failed to load skill '{skill_name}': {error_message}")
|
3345
|
-
return self
|
3346
|
-
|
3347
|
-
def remove_skill(self, skill_name: str) -> 'AgentBase':
|
3348
|
-
"""Remove a skill from this agent"""
|
3349
|
-
self.skill_manager.unload_skill(skill_name)
|
3350
|
-
return self
|
3351
|
-
|
3352
|
-
def list_skills(self) -> List[str]:
|
3353
|
-
"""List currently loaded skills"""
|
3354
|
-
return self.skill_manager.list_loaded_skills()
|
3355
|
-
|
3356
|
-
def has_skill(self, skill_name: str) -> bool:
|
3357
|
-
"""Check if skill is loaded"""
|
3358
|
-
return self.skill_manager.has_skill(skill_name)
|
3359
|
-
|
3360
|
-
def _check_google_cloud_function_auth(self, request) -> bool:
|
3361
|
-
"""
|
3362
|
-
Check basic auth in Google Cloud Functions mode using request headers
|
982
|
+
# Re-bind the tool decorator method to the new instance
|
983
|
+
ephemeral_agent.tool = ephemeral_agent._tool_decorator
|
3363
984
|
|
3364
|
-
|
3365
|
-
|
3366
|
-
|
3367
|
-
Returns:
|
3368
|
-
True if auth is valid, False otherwise
|
3369
|
-
"""
|
3370
|
-
if not hasattr(request, 'headers'):
|
3371
|
-
return False
|
3372
|
-
|
3373
|
-
# Check for authorization header (case-insensitive)
|
3374
|
-
auth_header = None
|
3375
|
-
for key in request.headers:
|
3376
|
-
if key.lower() == 'authorization':
|
3377
|
-
auth_header = request.headers[key]
|
3378
|
-
break
|
3379
|
-
|
3380
|
-
if not auth_header or not auth_header.startswith('Basic '):
|
3381
|
-
return False
|
3382
|
-
|
3383
|
-
try:
|
3384
|
-
import base64
|
3385
|
-
encoded_credentials = auth_header[6:] # Remove 'Basic '
|
3386
|
-
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
|
3387
|
-
provided_username, provided_password = decoded_credentials.split(':', 1)
|
3388
|
-
|
3389
|
-
expected_username, expected_password = self.get_basic_auth_credentials()
|
3390
|
-
return (provided_username == expected_username and
|
3391
|
-
provided_password == expected_password)
|
3392
|
-
except Exception:
|
3393
|
-
return False
|
3394
|
-
|
3395
|
-
def _check_azure_function_auth(self, req) -> bool:
|
3396
|
-
"""
|
3397
|
-
Check basic auth in Azure Functions mode using request object
|
985
|
+
# Share the logger but bind it to indicate ephemeral copy
|
986
|
+
ephemeral_agent.log = self.log.bind(ephemeral=True)
|
3398
987
|
|
3399
|
-
|
3400
|
-
|
3401
|
-
|
3402
|
-
Returns:
|
3403
|
-
True if auth is valid, False otherwise
|
3404
|
-
"""
|
3405
|
-
if not hasattr(req, 'headers'):
|
3406
|
-
return False
|
3407
|
-
|
3408
|
-
# Check for authorization header (case-insensitive)
|
3409
|
-
auth_header = None
|
3410
|
-
for key, value in req.headers.items():
|
3411
|
-
if key.lower() == 'authorization':
|
3412
|
-
auth_header = value
|
3413
|
-
break
|
3414
|
-
|
3415
|
-
if not auth_header or not auth_header.startswith('Basic '):
|
3416
|
-
return False
|
3417
|
-
|
3418
|
-
try:
|
3419
|
-
import base64
|
3420
|
-
encoded_credentials = auth_header[6:] # Remove 'Basic '
|
3421
|
-
decoded_credentials = base64.b64decode(encoded_credentials).decode('utf-8')
|
3422
|
-
provided_username, provided_password = decoded_credentials.split(':', 1)
|
3423
|
-
|
3424
|
-
expected_username, expected_password = self.get_basic_auth_credentials()
|
3425
|
-
return (provided_username == expected_username and
|
3426
|
-
provided_password == expected_password)
|
3427
|
-
except Exception:
|
3428
|
-
return False
|
3429
|
-
|
3430
|
-
def _send_google_cloud_function_auth_challenge(self):
|
3431
|
-
"""
|
3432
|
-
Send authentication challenge in Google Cloud Functions mode
|
988
|
+
# Mark this as an ephemeral agent to prevent double application of dynamic config
|
989
|
+
ephemeral_agent._is_ephemeral = True
|
3433
990
|
|
3434
|
-
|
3435
|
-
Flask-compatible response with 401 status and WWW-Authenticate header
|
3436
|
-
"""
|
3437
|
-
from flask import Response
|
3438
|
-
return Response(
|
3439
|
-
response=json.dumps({"error": "Unauthorized"}),
|
3440
|
-
status=401,
|
3441
|
-
headers={
|
3442
|
-
"WWW-Authenticate": "Basic realm=\"SignalWire Agent\"",
|
3443
|
-
"Content-Type": "application/json"
|
3444
|
-
}
|
3445
|
-
)
|
991
|
+
return ephemeral_agent
|
3446
992
|
|
3447
|
-
def
|
993
|
+
async def _handle_request(self, request: Request, response: Response):
|
3448
994
|
"""
|
3449
|
-
|
995
|
+
Override SWMLService's _handle_request to use AgentBase's _render_swml
|
3450
996
|
|
3451
|
-
|
3452
|
-
|
997
|
+
This ensures that when routes are handled by SWMLService's router,
|
998
|
+
they still use AgentBase's SWML rendering logic.
|
3453
999
|
"""
|
3454
|
-
|
3455
|
-
|
3456
|
-
|
3457
|
-
status_code=401,
|
3458
|
-
headers={
|
3459
|
-
"WWW-Authenticate": "Basic realm=\"SignalWire Agent\"",
|
3460
|
-
"Content-Type": "application/json"
|
3461
|
-
}
|
3462
|
-
)
|
3463
|
-
|
3464
|
-
def _handle_google_cloud_function_request(self, request):
|
3465
|
-
"""
|
3466
|
-
Handle Google Cloud Functions specific requests
|
1000
|
+
# Use WebMixin's implementation if available
|
1001
|
+
if hasattr(super(), '_handle_root_request'):
|
1002
|
+
return await self._handle_root_request(request)
|
3467
1003
|
|
3468
|
-
|
3469
|
-
request: Flask request object from Google Cloud Functions
|
3470
|
-
|
3471
|
-
Returns:
|
3472
|
-
Flask response object
|
3473
|
-
"""
|
1004
|
+
# Fallback to basic implementation
|
3474
1005
|
try:
|
3475
|
-
#
|
3476
|
-
|
1006
|
+
# Parse body if POST request
|
1007
|
+
body = {}
|
1008
|
+
if request.method == "POST":
|
1009
|
+
try:
|
1010
|
+
body = await request.json()
|
1011
|
+
except:
|
1012
|
+
pass
|
3477
1013
|
|
3478
|
-
|
3479
|
-
|
3480
|
-
|
3481
|
-
|
3482
|
-
|
3483
|
-
response=swml_response,
|
3484
|
-
status=200,
|
3485
|
-
headers={"Content-Type": "application/json"}
|
3486
|
-
)
|
3487
|
-
else:
|
3488
|
-
# SWAIG function call
|
3489
|
-
args = {}
|
3490
|
-
call_id = None
|
3491
|
-
raw_data = None
|
3492
|
-
|
3493
|
-
# Parse request data
|
3494
|
-
if request.method == 'POST':
|
3495
|
-
try:
|
3496
|
-
if request.is_json:
|
3497
|
-
raw_data = request.get_json()
|
3498
|
-
else:
|
3499
|
-
raw_data = json.loads(request.get_data(as_text=True))
|
3500
|
-
|
3501
|
-
call_id = raw_data.get("call_id")
|
3502
|
-
|
3503
|
-
# Extract arguments like the FastAPI handler does
|
3504
|
-
if "argument" in raw_data and isinstance(raw_data["argument"], dict):
|
3505
|
-
if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
|
3506
|
-
args = raw_data["argument"]["parsed"][0]
|
3507
|
-
elif "raw" in raw_data["argument"]:
|
3508
|
-
try:
|
3509
|
-
args = json.loads(raw_data["argument"]["raw"])
|
3510
|
-
except Exception:
|
3511
|
-
pass
|
3512
|
-
except Exception:
|
3513
|
-
# If parsing fails, continue with empty args
|
3514
|
-
pass
|
3515
|
-
|
3516
|
-
result = self._execute_swaig_function(path, args, call_id, raw_data)
|
3517
|
-
from flask import Response
|
1014
|
+
# Get call_id
|
1015
|
+
call_id = body.get("call_id") if body else request.query_params.get("call_id")
|
1016
|
+
|
1017
|
+
# Check auth
|
1018
|
+
if not self._check_basic_auth(request):
|
3518
1019
|
return Response(
|
3519
|
-
|
3520
|
-
|
3521
|
-
headers={"
|
1020
|
+
content=json.dumps({"error": "Unauthorized"}),
|
1021
|
+
status_code=401,
|
1022
|
+
headers={"WWW-Authenticate": "Basic"},
|
1023
|
+
media_type="application/json"
|
3522
1024
|
)
|
3523
|
-
|
3524
|
-
except Exception as e:
|
3525
|
-
import logging
|
3526
|
-
logging.error(f"Error in Google Cloud Function request handler: {e}")
|
3527
|
-
from flask import Response
|
3528
|
-
return Response(
|
3529
|
-
response=json.dumps({"error": str(e)}),
|
3530
|
-
status=500,
|
3531
|
-
headers={"Content-Type": "application/json"}
|
3532
|
-
)
|
3533
|
-
|
3534
|
-
def _handle_azure_function_request(self, req):
|
3535
|
-
"""
|
3536
|
-
Handle Azure Functions specific requests
|
3537
|
-
|
3538
|
-
Args:
|
3539
|
-
req: Azure Functions HttpRequest object
|
3540
|
-
|
3541
|
-
Returns:
|
3542
|
-
Azure Functions HttpResponse object
|
3543
|
-
"""
|
3544
|
-
try:
|
3545
|
-
import azure.functions as func
|
3546
1025
|
|
3547
|
-
#
|
3548
|
-
|
1026
|
+
# Render SWML using AgentBase's method
|
1027
|
+
swml = self._render_swml(call_id)
|
3549
1028
|
|
3550
|
-
|
3551
|
-
|
3552
|
-
|
3553
|
-
|
3554
|
-
body=swml_response,
|
3555
|
-
status_code=200,
|
3556
|
-
headers={"Content-Type": "application/json"}
|
3557
|
-
)
|
3558
|
-
else:
|
3559
|
-
# SWAIG function call
|
3560
|
-
args = {}
|
3561
|
-
call_id = None
|
3562
|
-
raw_data = None
|
3563
|
-
|
3564
|
-
# Parse request data
|
3565
|
-
if req.method == 'POST':
|
3566
|
-
try:
|
3567
|
-
body = req.get_body()
|
3568
|
-
if body:
|
3569
|
-
raw_data = json.loads(body.decode('utf-8'))
|
3570
|
-
call_id = raw_data.get("call_id")
|
3571
|
-
|
3572
|
-
# Extract arguments like the FastAPI handler does
|
3573
|
-
if "argument" in raw_data and isinstance(raw_data["argument"], dict):
|
3574
|
-
if "parsed" in raw_data["argument"] and isinstance(raw_data["argument"]["parsed"], list) and raw_data["argument"]["parsed"]:
|
3575
|
-
args = raw_data["argument"]["parsed"][0]
|
3576
|
-
elif "raw" in raw_data["argument"]:
|
3577
|
-
try:
|
3578
|
-
args = json.loads(raw_data["argument"]["raw"])
|
3579
|
-
except Exception:
|
3580
|
-
pass
|
3581
|
-
except Exception:
|
3582
|
-
# If parsing fails, continue with empty args
|
3583
|
-
pass
|
3584
|
-
|
3585
|
-
result = self._execute_swaig_function(path, args, call_id, raw_data)
|
3586
|
-
return func.HttpResponse(
|
3587
|
-
body=json.dumps(result) if isinstance(result, dict) else str(result),
|
3588
|
-
status_code=200,
|
3589
|
-
headers={"Content-Type": "application/json"}
|
3590
|
-
)
|
3591
|
-
|
1029
|
+
return Response(
|
1030
|
+
content=swml,
|
1031
|
+
media_type="application/json"
|
1032
|
+
)
|
3592
1033
|
except Exception as e:
|
3593
|
-
|
3594
|
-
|
3595
|
-
import azure.functions as func
|
3596
|
-
return func.HttpResponse(
|
3597
|
-
body=json.dumps({"error": str(e)}),
|
1034
|
+
return Response(
|
1035
|
+
content=json.dumps({"error": str(e)}),
|
3598
1036
|
status_code=500,
|
3599
|
-
|
1037
|
+
media_type="application/json"
|
3600
1038
|
)
|
1039
|
+
|