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.
Files changed (64) hide show
  1. signalwire_agents/__init__.py +1 -1
  2. signalwire_agents/agent_server.py +2 -1
  3. signalwire_agents/cli/config.py +61 -0
  4. signalwire_agents/cli/core/__init__.py +1 -0
  5. signalwire_agents/cli/core/agent_loader.py +254 -0
  6. signalwire_agents/cli/core/argparse_helpers.py +164 -0
  7. signalwire_agents/cli/core/dynamic_config.py +62 -0
  8. signalwire_agents/cli/execution/__init__.py +1 -0
  9. signalwire_agents/cli/execution/datamap_exec.py +437 -0
  10. signalwire_agents/cli/execution/webhook_exec.py +125 -0
  11. signalwire_agents/cli/output/__init__.py +1 -0
  12. signalwire_agents/cli/output/output_formatter.py +132 -0
  13. signalwire_agents/cli/output/swml_dump.py +177 -0
  14. signalwire_agents/cli/simulation/__init__.py +1 -0
  15. signalwire_agents/cli/simulation/data_generation.py +365 -0
  16. signalwire_agents/cli/simulation/data_overrides.py +187 -0
  17. signalwire_agents/cli/simulation/mock_env.py +271 -0
  18. signalwire_agents/cli/test_swaig.py +522 -2539
  19. signalwire_agents/cli/types.py +72 -0
  20. signalwire_agents/core/agent/__init__.py +1 -3
  21. signalwire_agents/core/agent/config/__init__.py +1 -3
  22. signalwire_agents/core/agent/prompt/manager.py +25 -7
  23. signalwire_agents/core/agent/tools/decorator.py +2 -0
  24. signalwire_agents/core/agent/tools/registry.py +8 -0
  25. signalwire_agents/core/agent_base.py +492 -3053
  26. signalwire_agents/core/function_result.py +31 -42
  27. signalwire_agents/core/mixins/__init__.py +28 -0
  28. signalwire_agents/core/mixins/ai_config_mixin.py +373 -0
  29. signalwire_agents/core/mixins/auth_mixin.py +287 -0
  30. signalwire_agents/core/mixins/prompt_mixin.py +345 -0
  31. signalwire_agents/core/mixins/serverless_mixin.py +368 -0
  32. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  33. signalwire_agents/core/mixins/state_mixin.py +219 -0
  34. signalwire_agents/core/mixins/tool_mixin.py +295 -0
  35. signalwire_agents/core/mixins/web_mixin.py +1130 -0
  36. signalwire_agents/core/skill_manager.py +3 -1
  37. signalwire_agents/core/swaig_function.py +10 -1
  38. signalwire_agents/core/swml_service.py +140 -58
  39. signalwire_agents/skills/README.md +452 -0
  40. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  41. signalwire_agents/skills/datasphere/README.md +210 -0
  42. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  43. signalwire_agents/skills/datetime/README.md +132 -0
  44. signalwire_agents/skills/joke/README.md +149 -0
  45. signalwire_agents/skills/math/README.md +161 -0
  46. signalwire_agents/skills/native_vector_search/skill.py +33 -13
  47. signalwire_agents/skills/play_background_file/README.md +218 -0
  48. signalwire_agents/skills/spider/README.md +236 -0
  49. signalwire_agents/skills/spider/__init__.py +4 -0
  50. signalwire_agents/skills/spider/skill.py +479 -0
  51. signalwire_agents/skills/swml_transfer/README.md +395 -0
  52. signalwire_agents/skills/swml_transfer/__init__.py +1 -0
  53. signalwire_agents/skills/swml_transfer/skill.py +257 -0
  54. signalwire_agents/skills/weather_api/README.md +178 -0
  55. signalwire_agents/skills/web_search/README.md +163 -0
  56. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  57. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/METADATA +47 -2
  58. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/RECORD +62 -22
  59. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/entry_points.txt +1 -1
  60. signalwire_agents/core/agent/config/ephemeral.py +0 -176
  61. signalwire_agents-0.1.23.data/data/schema.json +0 -5611
  62. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/WHEEL +0 -0
  63. {signalwire_agents-0.1.23.dist-info → signalwire_agents-0.1.25.dist-info}/licenses/LICENSE +0 -0
  64. {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(SWMLService):
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 _process_prompt_sections(self):
270
+ def get_name(self) -> str:
252
271
  """
253
- Process declarative PROMPT_SECTIONS attribute from a subclass
272
+ Get agent name
254
273
 
255
- This auto-vivifies section methods and bootstraps the prompt
256
- from class declaration, allowing for declarative agents.
274
+ Returns:
275
+ Agent name
257
276
  """
258
- # Skip if no PROMPT_SECTIONS defined or not using POM
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 define_contexts(self, contexts=None) -> Optional['ContextBuilder']:
279
+ def get_full_url(self, include_auth: bool = False) -> str:
353
280
  """
354
- Define contexts and steps for this agent (alternative to POM/prompt)
281
+ Get the full URL for this agent's endpoint
355
282
 
356
283
  Args:
357
- contexts: Optional context configuration (dict or ContextBuilder)
284
+ include_auth: Whether to include authentication credentials in the URL
358
285
 
359
286
  Returns:
360
- ContextBuilder for method chaining if no contexts provided
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
- if contexts is not None:
367
- # New behavior - set contexts
368
- self._prompt_manager.define_contexts(contexts)
369
- return self
370
- else:
371
- # Legacy behavior - return ContextBuilder
372
- # Import here to avoid circular imports
373
- from signalwire_agents.core.contexts import ContextBuilder
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 self._contexts_builder is None:
376
- self._contexts_builder = ContextBuilder(self)
377
- self._contexts_defined = True
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
- return self._contexts_builder
380
-
381
- def _validate_prompt_mode_exclusivity(self):
382
- """
383
- Validate that POM sections and raw text are not mixed in the main prompt
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
- Note: This does NOT prevent contexts from being used alongside traditional prompts
386
- """
387
- # Delegate to prompt manager
388
- self._prompt_manager._validate_prompt_mode_exclusivity()
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 set_prompt_text(self, text: str) -> 'AgentBase':
356
+ def on_summary(self, summary: Optional[Dict[str, Any]], raw_data: Optional[Dict[str, Any]] = None) -> None:
391
357
  """
392
- Set the prompt as raw text instead of using POM
358
+ Called when a post-prompt summary is received
393
359
 
394
360
  Args:
395
- text: The raw prompt text
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
- self._prompt_manager.set_prompt_text(text)
401
- return self
364
+ # Default implementation does nothing
365
+ pass
402
366
 
403
- def set_post_prompt(self, text: str) -> 'AgentBase':
367
+ def enable_sip_routing(self, auto_map: bool = True, path: str = "/sip") -> 'AgentBase':
404
368
  """
405
- Set the post-prompt text for summary generation
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
- text: The post-prompt text
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
- self._prompt_manager.set_post_prompt(text)
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 set_prompt_pom(self, pom: List[Dict[str, Any]]) -> 'AgentBase':
411
+ def register_sip_username(self, sip_username: str) -> 'AgentBase':
417
412
  """
418
- Set the prompt as a POM dictionary
413
+ Register a SIP username that should be routed to this agent
419
414
 
420
415
  Args:
421
- pom: POM dictionary structure
416
+ sip_username: SIP username to register
422
417
 
423
418
  Returns:
424
419
  Self for method chaining
425
420
  """
426
- self._prompt_manager.set_prompt_pom(pom)
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 prompt_add_section(
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
- Add a section to the prompt
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
- self._prompt_manager.prompt_add_section(
453
- title=title,
454
- body=body,
455
- bullets=bullets,
456
- numbered=numbered,
457
- numbered_bullets=numbered_bullets,
458
- subsections=subsections
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 prompt_add_to_section(
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
- Add content to an existing section (creating it if needed)
458
+ Override the default web_hook_url with a supplied URL string
471
459
 
472
460
  Args:
473
- title: Section title
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._prompt_manager.prompt_add_to_section(
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 prompt_add_subsection(
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
- Add a subsection to an existing section (creating parent if needed)
471
+ Override the default post_prompt_url with a supplied URL string
498
472
 
499
473
  Args:
500
- parent_title: Parent section title
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._prompt_manager.prompt_add_subsection(
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 prompt_has_section(self, title: str) -> bool:
482
+ def add_swaig_query_params(self, params: Dict[str, str]) -> 'AgentBase':
517
483
  """
518
- Check if a section exists in the prompt
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
- title: Section title to check
492
+ params: Dictionary of query parameters to add to SWAIG URLs
522
493
 
523
494
  Returns:
524
- True if section exists, False otherwise
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
- return self._prompt_manager.prompt_has_section(title)
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 define_tool(
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
- Define a SWAIG function that the AI can call
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._tool_registry.define_tool(
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 register_swaig_function(self, function_dict: Dict[str, Any]) -> 'AgentBase':
518
+ def _render_swml(self, call_id: str = None, modifications: Optional[dict] = None) -> str:
572
519
  """
573
- Register a raw SWAIG function dictionary (e.g., from DataMap.to_swaig_function())
520
+ Render the complete SWML document using SWMLService methods
574
521
 
575
522
  Args:
576
- function_dict: Complete SWAIG function definition dictionary
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
- Self for method chaining
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
- Decorator for defining SWAIG tools in a class
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
- Used as:
573
+ # Reset the document to a clean state
574
+ agent_to_use.reset_document()
589
575
 
590
- @agent.tool(name="example_function", parameters={...})
591
- def example_function(self, param1):
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
- Used as:
580
+ # Get post-prompt
581
+ post_prompt = agent_to_use.get_post_prompt()
603
582
 
604
- @AgentBase.tool(name="example_function", parameters={...})
605
- def example_function(self, param1):
606
- # ...
607
- """
608
- return ToolDecorator.create_class_decorator()(name, **kwargs)
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
- Returns:
619
- Agent name
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
- This method ensures the FastAPI app is properly initialized and configured,
628
- then returns it for use with deployment adapters like Mangum for AWS Lambda.
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
- Returns:
631
- FastAPI: The configured FastAPI application instance
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
- return self._app
712
-
713
- def get_prompt(self) -> Union[str, List[Dict[str, Any]]]:
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 self._function_includes:
1263
- swaig_obj["includes"] = self._function_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(self, '_internal_fillers') and self._internal_fillers:
1267
- swaig_obj["internal_fillers"] = self._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
- for name, func in self._tool_registry._swaig_functions.items():
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 = self._create_tool_token(tool_name=name, call_id=call_id)
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
- token_params = {"token": token}
1310
- function_entry["web_hook_url"] = self._build_webhook_url("swaig", token_params)
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
- query_params = {}
1323
- if call_id and hasattr(self, '_session_manager'):
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 = self._session_manager.create_tool_token("post_prompt", call_id)
690
+ token = agent_to_use._session_manager.create_tool_token("post_prompt", call_id)
1326
691
  if token:
1327
- query_params["token"] = token
692
+ query_params["__token"] = token # Use __token to avoid collision
1328
693
  except Exception as e:
1329
- self.log.error("post_prompt_token_creation_error", error=str(e))
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 = self._build_webhook_url("post_prompt", query_params)
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(self, '_post_prompt_url_override') and self._post_prompt_url_override:
1336
- post_prompt_url = self._post_prompt_url_override
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
- self.add_verb("answer", {})
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 = self.verb_registry.get_handler("ai")
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 self._contexts_defined and self._contexts_builder:
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 = self._contexts_builder.to_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 {self.name}, a helpful AI assistant that follows structured workflows."
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 self._hints:
1388
- ai_config["hints"] = self._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 self._languages:
1392
- ai_config["languages"] = self._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 self._pronounce:
1396
- ai_config["pronounce"] = self._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 self._params:
1400
- ai_config["params"] = self._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 self._global_data:
1404
- ai_config["global_data"] = self._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 self._suppress_logs:
1408
- self.log.error("ai_verb_config_error", error=str(e))
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 self._hints and "hints" not in ai_config:
1427
- ai_config["hints"] = self._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 self._languages and "languages" not in ai_config:
1430
- ai_config["languages"] = self._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 self._pronounce and "pronounce" not in ai_config:
1433
- ai_config["pronounce"] = self._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 self._params and "params" not in ai_config:
1436
- ai_config["params"] = self._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 self._global_data and "global_data" not in ai_config:
1439
- ai_config["global_data"] = self._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
- self.add_verb("ai", ai_config)
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
- self.reset_document()
1459
- self.add_verb("answer", {})
1460
- self.add_verb("ai", ai_config)
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 self.render_document()
828
+ return agent_to_use.render_document()
1464
829
 
1465
- def _check_basic_auth(self, request: Request) -> bool:
1466
- """
1467
- Check basic auth from a request
1468
-
1469
- Args:
1470
- request: FastAPI request object
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
- if token:
2936
- req_log.debug("token_found", token_length=len(token))
2937
-
2938
- # Try to validate token, but continue processing regardless
2939
- if call_id and hasattr(self, '_session_manager'):
2940
- try:
2941
- is_valid = self._session_manager.validate_tool_token("post_prompt", token, call_id)
2942
- if is_valid:
2943
- req_log.debug("token_valid")
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
- # For POST requests, process the post-prompt data
2964
- try:
2965
- # Try to reuse the body we already parsed for call_id extraction
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
- # Extract summary from the correct location in the request
2981
- summary = self._find_summary_in_post_data(body, req_log)
852
+ # Build the full webhook URL
853
+ url = f"{base_url}/{endpoint}"
2982
854
 
2983
- # Call the summary handler with the summary and the full body
2984
- try:
2985
- if summary:
2986
- self.on_summary(summary, body)
2987
- req_log.debug("summary_handler_called_successfully")
2988
- else:
2989
- # If no summary found but still want to process the data
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
- # Return success
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
- try:
3017
- # Check auth
3018
- if not self._check_basic_auth(request):
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 _handle_hangup_hook(self, args, raw_data):
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
- Called when SWML is requested, with request data when available
904
+ Create a lightweight copy of this agent for ephemeral configuration.
3166
905
 
3167
- This method overrides SWMLService's on_request to properly handle SWML generation
3168
- for AI Agents.
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
- Optional dict with modifications to apply to the SWML document
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
- Manually set the proxy URL base for webhook callbacks
913
+ import copy
3300
914
 
3301
- This can be called at runtime to set or update the proxy URL
3302
-
3303
- Args:
3304
- proxy_url: The base URL to use for webhooks (e.g., https://example.ngrok.io)
3305
-
3306
- Returns:
3307
- Self for method chaining
3308
- """
3309
- if proxy_url:
3310
- # Set on self
3311
- self._proxy_url_base = proxy_url.rstrip('/')
3312
- self._proxy_detection_done = True
3313
-
3314
- # Set on parent if it has these attributes
3315
- if hasattr(super(), '_proxy_url_base'):
3316
- super()._proxy_url_base = self._proxy_url_base
3317
- if hasattr(super(), '_proxy_detection_done'):
3318
- super()._proxy_detection_done = True
3319
-
3320
- self.log.info("proxy_url_manually_set", proxy_url_base=self._proxy_url_base)
3321
-
3322
- return self
3323
-
3324
- # ----------------------------------------------------------------------
3325
- # Skill Management Methods
3326
- # ----------------------------------------------------------------------
3327
-
3328
- def add_skill(self, skill_name: str, params: Optional[Dict[str, Any]] = None) -> 'AgentBase':
3329
- """
3330
- Add a skill to this agent
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
- Args:
3333
- skill_name: Name of the skill to add
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
- Args:
3365
- request: Flask request object or similar containing headers
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
- Args:
3400
- req: Azure Functions request object containing headers
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
- Returns:
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 _send_azure_function_auth_challenge(self):
993
+ async def _handle_request(self, request: Request, response: Response):
3448
994
  """
3449
- Send authentication challenge in Azure Functions mode
995
+ Override SWMLService's _handle_request to use AgentBase's _render_swml
3450
996
 
3451
- Returns:
3452
- Azure Functions response with 401 status and WWW-Authenticate header
997
+ This ensures that when routes are handled by SWMLService's router,
998
+ they still use AgentBase's SWML rendering logic.
3453
999
  """
3454
- import azure.functions as func
3455
- return func.HttpResponse(
3456
- body=json.dumps({"error": "Unauthorized"}),
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
- Args:
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
- # Get the path from the request
3476
- path = request.path.strip('/')
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
- if not path:
3479
- # Root request - return SWML
3480
- swml_response = self._render_swml()
3481
- from flask import Response
3482
- return Response(
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
- response=json.dumps(result) if isinstance(result, dict) else str(result),
3520
- status=200,
3521
- headers={"Content-Type": "application/json"}
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
- # Get the path from the request
3548
- path = req.url.split('/')[-1] if req.url else ''
1026
+ # Render SWML using AgentBase's method
1027
+ swml = self._render_swml(call_id)
3549
1028
 
3550
- if not path or path == 'api':
3551
- # Root request - return SWML
3552
- swml_response = self._render_swml()
3553
- return func.HttpResponse(
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
- import logging
3594
- logging.error(f"Error in Azure Function request handler: {e}")
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
- headers={"Content-Type": "application/json"}
1037
+ media_type="application/json"
3600
1038
  )
1039
+