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