signalwire-agents 0.1.31__py3-none-any.whl → 0.1.33__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.
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Copyright (c) 2025 SignalWire
4
+
5
+ This file is part of the SignalWire AI Agents SDK.
6
+
7
+ Licensed under the MIT License.
8
+ See LICENSE file in the project root for full license information.
9
+ """
10
+
11
+ """
12
+ Service discovery and loading functionality - new simplified approach
13
+ """
14
+
15
+ import importlib.util
16
+ from pathlib import Path
17
+ from typing import List, Dict, Any, Optional, Callable
18
+ import asyncio
19
+ import sys
20
+ import io
21
+ import contextlib
22
+
23
+ # Import after checking if available
24
+ try:
25
+ from signalwire_agents.core.agent_base import AgentBase
26
+ from signalwire_agents.core.swml_service import SWMLService
27
+ from fastapi import Request, Response
28
+ DEPENDENCIES_AVAILABLE = True
29
+ except ImportError:
30
+ AgentBase = None
31
+ SWMLService = None
32
+ Request = None
33
+ Response = None
34
+ DEPENDENCIES_AVAILABLE = False
35
+
36
+
37
+ class ServiceCapture:
38
+ """Captures SWMLService instances when they try to run/serve"""
39
+
40
+ def __init__(self):
41
+ self.captured_services: List[SWMLService] = []
42
+ self.original_methods = {}
43
+
44
+ def capture(self, service_path: str, suppress_output: bool = False) -> List[SWMLService]:
45
+ """
46
+ Execute a service file and capture any services that try to run
47
+
48
+ Args:
49
+ service_path: Path to the Python file
50
+ suppress_output: If True, suppress stdout during module execution
51
+
52
+ Returns:
53
+ List of captured SWMLService instances
54
+ """
55
+ if not DEPENDENCIES_AVAILABLE:
56
+ raise ImportError("Required dependencies not available. Please install signalwire-agents package.")
57
+
58
+ service_path = Path(service_path).resolve()
59
+
60
+ if not service_path.exists():
61
+ raise FileNotFoundError(f"Service file not found: {service_path}")
62
+
63
+ if not service_path.suffix == '.py':
64
+ raise ValueError(f"Service file must be a Python file (.py): {service_path}")
65
+
66
+ # Reset captured services
67
+ self.captured_services = []
68
+
69
+ # Apply patches
70
+ self._apply_patches()
71
+
72
+ # Context manager for optional stdout suppression
73
+ stdout_context = io.StringIO() if suppress_output else None
74
+
75
+ try:
76
+ with contextlib.redirect_stdout(stdout_context) if suppress_output else contextlib.nullcontext():
77
+ # Load and execute the module
78
+ spec = importlib.util.spec_from_file_location("__main__", service_path)
79
+ module = importlib.util.module_from_spec(spec)
80
+
81
+ # Set __name__ to "__main__" to trigger if __name__ == "__main__": blocks
82
+ module.__name__ = "__main__"
83
+
84
+ try:
85
+ spec.loader.exec_module(module)
86
+ except Exception as e:
87
+ # Module might have called run/serve which we intercepted
88
+ if not self.captured_services:
89
+ # If we didn't capture anything, the error is real
90
+ raise ImportError(f"Failed to load service module: {e}")
91
+ finally:
92
+ # Always restore original methods
93
+ self._restore_patches()
94
+
95
+ return self.captured_services
96
+
97
+ def _apply_patches(self):
98
+ """Apply patches to capture services"""
99
+
100
+ # Store reference to self for use in closures
101
+ capture_self = self
102
+
103
+ def mock_run(service_instance, *args, **kwargs):
104
+ """Capture service when run() is called"""
105
+ capture_self.captured_services.append(service_instance)
106
+ # Don't print during stdout suppression
107
+ # Don't actually run - we're just capturing
108
+ return service_instance
109
+
110
+ def mock_serve(service_instance, *args, **kwargs):
111
+ """Capture service when serve() is called"""
112
+ capture_self.captured_services.append(service_instance)
113
+ # Don't print during stdout suppression
114
+ # Don't actually serve - we're just capturing
115
+ return service_instance
116
+
117
+ # Apply patches to both SWMLService and AgentBase
118
+ for base_class in [SWMLService, AgentBase]:
119
+ if base_class:
120
+ if hasattr(base_class, 'run'):
121
+ self.original_methods[(base_class, 'run')] = base_class.run
122
+ base_class.run = mock_run
123
+
124
+ if hasattr(base_class, 'serve'):
125
+ self.original_methods[(base_class, 'serve')] = base_class.serve
126
+ base_class.serve = mock_serve
127
+
128
+ def _restore_patches(self):
129
+ """Restore original methods"""
130
+ for (base_class, method_name), original_method in self.original_methods.items():
131
+ setattr(base_class, method_name, original_method)
132
+ self.original_methods.clear()
133
+
134
+
135
+ async def simulate_request_to_service(
136
+ service: SWMLService,
137
+ method: str = "POST",
138
+ body: Optional[dict] = None,
139
+ query_params: Optional[dict] = None,
140
+ headers: Optional[dict] = None
141
+ ) -> dict:
142
+ """
143
+ Simulate a request to a SWMLService instance
144
+
145
+ Args:
146
+ service: The SWMLService instance
147
+ method: HTTP method (GET or POST)
148
+ body: Request body for POST requests
149
+ query_params: Query parameters
150
+ headers: Request headers
151
+
152
+ Returns:
153
+ The service's response as a dict
154
+ """
155
+ # Create a mock request
156
+ from signalwire_agents.cli.simulation.mock_env import create_mock_request
157
+
158
+ request = create_mock_request(
159
+ method=method,
160
+ headers=headers or {},
161
+ query_params=query_params or {},
162
+ body=body or {}
163
+ )
164
+
165
+ # Create a mock response
166
+ response = Response()
167
+
168
+ # Call the service's request handler
169
+ result = await service._handle_request(request, response)
170
+
171
+ # If result is a Response object, extract the content
172
+ if hasattr(result, 'body'):
173
+ # FastAPI Response
174
+ import json
175
+ return json.loads(result.body.decode())
176
+ elif isinstance(result, dict):
177
+ return result
178
+ else:
179
+ # Try to get content from response
180
+ return {"error": "Unable to parse response"}
181
+
182
+
183
+ def load_and_simulate_service(
184
+ service_path: str,
185
+ route: Optional[str] = None,
186
+ method: str = "POST",
187
+ body: Optional[dict] = None,
188
+ query_params: Optional[dict] = None,
189
+ headers: Optional[dict] = None,
190
+ suppress_output: bool = False
191
+ ) -> dict:
192
+ """
193
+ Load a service file and simulate a request to it
194
+
195
+ This is the main entry point that combines loading and request simulation
196
+
197
+ Args:
198
+ service_path: Path to the service file
199
+ route: Optional route to request (for multi-service files)
200
+ method: HTTP method
201
+ body: Request body
202
+ query_params: Query parameters
203
+ headers: Request headers
204
+
205
+ Returns:
206
+ The service's response
207
+ """
208
+ # Capture services from the file
209
+ capturer = ServiceCapture()
210
+ services = capturer.capture(service_path, suppress_output=suppress_output)
211
+
212
+ if not services:
213
+ raise ValueError(f"No services found in {service_path}")
214
+
215
+ # Select the appropriate service
216
+ if len(services) == 1:
217
+ service = services[0]
218
+ else:
219
+ # Multiple services - need to select by route
220
+ if not route:
221
+ # List available routes
222
+ routes = [s.route for s in services]
223
+ raise ValueError(
224
+ f"Multiple services found. Please specify a route.\n"
225
+ f"Available routes: {', '.join(routes)}"
226
+ )
227
+
228
+ # Find service by route
229
+ service = None
230
+ for s in services:
231
+ if s.route == route:
232
+ service = s
233
+ break
234
+
235
+ if not service:
236
+ available = [s.route for s in services]
237
+ raise ValueError(
238
+ f"No service found for route '{route}'.\n"
239
+ f"Available routes: {', '.join(available)}"
240
+ )
241
+
242
+ # Simulate the request
243
+ return asyncio.run(simulate_request_to_service(
244
+ service,
245
+ method=method,
246
+ body=body,
247
+ query_params=query_params,
248
+ headers=headers
249
+ ))
250
+
251
+
252
+ # Backward compatibility
253
+ def load_agent_from_file(agent_path: str, agent_class_name: Optional[str] = None, suppress_output: bool = False) -> 'AgentBase':
254
+ """
255
+ Backward compatibility wrapper
256
+
257
+ Note: This still uses the direct extraction approach for compatibility
258
+ """
259
+ # Use the new service capture but ensure we get an AgentBase
260
+ capturer = ServiceCapture()
261
+ services = capturer.capture(agent_path, suppress_output=suppress_output)
262
+
263
+ # Filter to only agents
264
+ agents = [s for s in services if isinstance(s, AgentBase)]
265
+
266
+ if not agents:
267
+ raise ValueError(f"No agents found in {agent_path}")
268
+
269
+ if len(agents) == 1:
270
+ return agents[0]
271
+
272
+ # Multiple agents - try to match by class name
273
+ if agent_class_name:
274
+ for agent in agents:
275
+ if agent.__class__.__name__ == agent_class_name:
276
+ return agent
277
+
278
+ # Return first agent
279
+ return agents[0]
280
+
281
+
282
+ def discover_agents_in_file(agent_path: str) -> List[Dict[str, Any]]:
283
+ """
284
+ Backward compatibility wrapper
285
+ """
286
+ capturer = ServiceCapture()
287
+ services = capturer.capture(agent_path)
288
+
289
+ # Convert to old format
290
+ agents_found = []
291
+ for service in services:
292
+ if isinstance(service, AgentBase):
293
+ agents_found.append({
294
+ 'name': service.name,
295
+ 'class_name': service.__class__.__name__,
296
+ 'type': 'instance',
297
+ 'agent_name': service.name,
298
+ 'route': service.route,
299
+ 'description': service.__class__.__doc__,
300
+ 'object': service
301
+ })
302
+
303
+ return agents_found
@@ -61,7 +61,58 @@ def display_agent_tools(agent: 'AgentBase', verbose: bool = False) -> None:
61
61
  param_desc = param_def.get('description', 'No description')
62
62
  is_required = param_name in required_fields
63
63
  required_marker = " (required)" if is_required else ""
64
- print(f" {param_name} ({param_type}){required_marker}: {param_desc}")
64
+
65
+ # Build constraint details
66
+ constraints = []
67
+
68
+ # Enum values
69
+ if 'enum' in param_def:
70
+ constraints.append(f"options: {', '.join(map(str, param_def['enum']))}")
71
+
72
+ # Numeric constraints
73
+ if 'minimum' in param_def:
74
+ constraints.append(f"min: {param_def['minimum']}")
75
+ if 'maximum' in param_def:
76
+ constraints.append(f"max: {param_def['maximum']}")
77
+ if 'exclusiveMinimum' in param_def:
78
+ constraints.append(f"min (exclusive): {param_def['exclusiveMinimum']}")
79
+ if 'exclusiveMaximum' in param_def:
80
+ constraints.append(f"max (exclusive): {param_def['exclusiveMaximum']}")
81
+ if 'multipleOf' in param_def:
82
+ constraints.append(f"multiple of: {param_def['multipleOf']}")
83
+
84
+ # String constraints
85
+ if 'minLength' in param_def:
86
+ constraints.append(f"min length: {param_def['minLength']}")
87
+ if 'maxLength' in param_def:
88
+ constraints.append(f"max length: {param_def['maxLength']}")
89
+ if 'pattern' in param_def:
90
+ constraints.append(f"pattern: {param_def['pattern']}")
91
+ if 'format' in param_def:
92
+ constraints.append(f"format: {param_def['format']}")
93
+
94
+ # Array constraints
95
+ if param_type == 'array':
96
+ if 'minItems' in param_def:
97
+ constraints.append(f"min items: {param_def['minItems']}")
98
+ if 'maxItems' in param_def:
99
+ constraints.append(f"max items: {param_def['maxItems']}")
100
+ if 'uniqueItems' in param_def and param_def['uniqueItems']:
101
+ constraints.append("unique items")
102
+ if 'items' in param_def and 'type' in param_def['items']:
103
+ constraints.append(f"item type: {param_def['items']['type']}")
104
+
105
+ # Default value
106
+ if 'default' in param_def:
107
+ constraints.append(f"default: {param_def['default']}")
108
+
109
+ # Format the type with constraints
110
+ if constraints:
111
+ param_type_full = f"{param_type} [{', '.join(constraints)}]"
112
+ else:
113
+ param_type_full = param_type
114
+
115
+ print(f" {param_name} ({param_type_full}){required_marker}: {param_desc}")
65
116
  else:
66
117
  print(f" Parameters: None")
67
118
  else:
@@ -103,7 +154,58 @@ def display_agent_tools(agent: 'AgentBase', verbose: bool = False) -> None:
103
154
  param_desc = param_def.get('description', 'No description')
104
155
  is_required = param_name in required_fields
105
156
  required_marker = " (required)" if is_required else ""
106
- print(f" {param_name} ({param_type}){required_marker}: {param_desc}")
157
+
158
+ # Build constraint details
159
+ constraints = []
160
+
161
+ # Enum values
162
+ if 'enum' in param_def:
163
+ constraints.append(f"options: {', '.join(map(str, param_def['enum']))}")
164
+
165
+ # Numeric constraints
166
+ if 'minimum' in param_def:
167
+ constraints.append(f"min: {param_def['minimum']}")
168
+ if 'maximum' in param_def:
169
+ constraints.append(f"max: {param_def['maximum']}")
170
+ if 'exclusiveMinimum' in param_def:
171
+ constraints.append(f"min (exclusive): {param_def['exclusiveMinimum']}")
172
+ if 'exclusiveMaximum' in param_def:
173
+ constraints.append(f"max (exclusive): {param_def['exclusiveMaximum']}")
174
+ if 'multipleOf' in param_def:
175
+ constraints.append(f"multiple of: {param_def['multipleOf']}")
176
+
177
+ # String constraints
178
+ if 'minLength' in param_def:
179
+ constraints.append(f"min length: {param_def['minLength']}")
180
+ if 'maxLength' in param_def:
181
+ constraints.append(f"max length: {param_def['maxLength']}")
182
+ if 'pattern' in param_def:
183
+ constraints.append(f"pattern: {param_def['pattern']}")
184
+ if 'format' in param_def:
185
+ constraints.append(f"format: {param_def['format']}")
186
+
187
+ # Array constraints
188
+ if param_type == 'array':
189
+ if 'minItems' in param_def:
190
+ constraints.append(f"min items: {param_def['minItems']}")
191
+ if 'maxItems' in param_def:
192
+ constraints.append(f"max items: {param_def['maxItems']}")
193
+ if 'uniqueItems' in param_def and param_def['uniqueItems']:
194
+ constraints.append("unique items")
195
+ if 'items' in param_def and 'type' in param_def['items']:
196
+ constraints.append(f"item type: {param_def['items']['type']}")
197
+
198
+ # Default value
199
+ if 'default' in param_def:
200
+ constraints.append(f"default: {param_def['default']}")
201
+
202
+ # Format the type with constraints
203
+ if constraints:
204
+ param_type_full = f"{param_type} [{', '.join(constraints)}]"
205
+ else:
206
+ param_type_full = param_type
207
+
208
+ print(f" {param_name} ({param_type_full}){required_marker}: {param_desc}")
107
209
  else:
108
210
  print(f" Parameters: None")
109
211
  else:
@@ -30,17 +30,22 @@ if TYPE_CHECKING:
30
30
  original_print = print
31
31
 
32
32
 
33
- def setup_raw_mode_suppression():
34
- """Set up output suppression for raw mode using central logging system"""
33
+ def setup_output_suppression():
34
+ """Set up output suppression for SWML dumping"""
35
35
  # The central logging system is already configured via environment variable
36
36
  # Just suppress any remaining warnings
37
37
  warnings.filterwarnings("ignore")
38
38
 
39
- # Capture and suppress print statements in raw mode if needed
39
+ # Capture and suppress print statements
40
40
  def suppressed_print(*args, **kwargs):
41
- pass
41
+ # If file is specified (like stderr), allow it
42
+ if 'file' in kwargs and kwargs['file'] is not sys.stdout:
43
+ original_print(*args, **kwargs)
44
+ else:
45
+ # Suppress stdout prints
46
+ pass
42
47
 
43
- # Replace print function globally for raw mode
48
+ # Replace print function globally
44
49
  import builtins
45
50
  builtins.print = suppressed_print
46
51
 
@@ -153,21 +158,16 @@ def handle_dump_swml(agent: 'AgentBase', args: argparse.Namespace) -> int:
153
158
  swml_doc = agent._render_swml()
154
159
 
155
160
  if args.raw:
156
- # Temporarily restore print for JSON output
157
- if '--raw' in sys.argv and 'original_print' in globals():
158
- import builtins
159
- builtins.print = original_print
160
-
161
161
  # Output only the raw JSON for piping to jq/yq
162
- print(swml_doc)
162
+ original_print(swml_doc)
163
163
  else:
164
164
  # Output formatted JSON (like raw but pretty-printed)
165
165
  try:
166
166
  swml_parsed = json.loads(swml_doc)
167
- print(json.dumps(swml_parsed, indent=2))
167
+ original_print(json.dumps(swml_parsed, indent=2))
168
168
  except json.JSONDecodeError:
169
169
  # If not valid JSON, show raw
170
- print(swml_doc)
170
+ original_print(swml_doc)
171
171
 
172
172
  return 0
173
173
 
@@ -109,6 +109,8 @@ class MockRequest:
109
109
  self.query_params = MockQueryParams(query_params)
110
110
  self._json_body = json_body or {}
111
111
  self._body = json.dumps(self._json_body).encode('utf-8')
112
+ # Add state object for request state (used by FastAPI)
113
+ self.state = type('State', (), {})()
112
114
 
113
115
  async def json(self) -> Dict[str, Any]:
114
116
  """Return the JSON body"""
@@ -0,0 +1,52 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Copyright (c) 2025 SignalWire
4
+
5
+ This file is part of the SignalWire AI Agents SDK.
6
+
7
+ Licensed under the MIT License.
8
+ See LICENSE file in the project root for full license information.
9
+ """
10
+
11
+ """
12
+ Wrapper script for swaig-test that sets environment variables before importing any modules.
13
+ This allows proper control of logging before the logging system is initialized.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+ import subprocess
19
+
20
+
21
+ def main():
22
+ """Main entry point for the swaig-test command"""
23
+ # Determine if we should show logs based on arguments
24
+ args = sys.argv[1:]
25
+
26
+ # Check for verbose flag
27
+ show_logs = '--verbose' in args or '-v' in args
28
+
29
+ # Special cases that always suppress logs
30
+ force_suppress = '--dump-swml' in args or '--raw' in args
31
+
32
+ # Set logging mode
33
+ if force_suppress:
34
+ os.environ['SIGNALWIRE_LOG_MODE'] = 'off'
35
+ elif show_logs:
36
+ os.environ['SIGNALWIRE_LOG_MODE'] = 'default'
37
+ else:
38
+ # Default: suppress logs unless verbose is requested
39
+ os.environ['SIGNALWIRE_LOG_MODE'] = 'off'
40
+
41
+ # Execute the actual implementation
42
+ # Use sys.executable to ensure we use the same Python interpreter
43
+ # Add -W ignore::RuntimeWarning to suppress the sys.modules warning
44
+ cmd = [sys.executable, '-W', 'ignore::RuntimeWarning', '-m', 'signalwire_agents.cli.test_swaig'] + args
45
+
46
+ # Run the command and exit with its exit code
47
+ result = subprocess.run(cmd)
48
+ sys.exit(result.returncode)
49
+
50
+
51
+ if __name__ == '__main__':
52
+ main()
@@ -34,7 +34,7 @@ from .config import (
34
34
  HELP_DESCRIPTION, HELP_EPILOG_SHORT
35
35
  )
36
36
  from .core.argparse_helpers import CustomArgumentParser, parse_function_arguments
37
- from .core.agent_loader import discover_agents_in_file, load_agent_from_file
37
+ from .core.agent_loader import discover_agents_in_file, load_agent_from_file, load_service_from_file
38
38
  from .core.dynamic_config import apply_dynamic_config
39
39
  from .simulation.mock_env import ServerlessSimulator, create_mock_request, load_env_file
40
40
  from .simulation.data_generation import (
@@ -44,7 +44,7 @@ from .simulation.data_generation import (
44
44
  from .simulation.data_overrides import apply_overrides, apply_convenience_mappings
45
45
  from .execution.datamap_exec import execute_datamap_function
46
46
  from .execution.webhook_exec import execute_external_webhook_function
47
- from .output.swml_dump import handle_dump_swml, setup_raw_mode_suppression
47
+ from .output.swml_dump import handle_dump_swml, setup_output_suppression
48
48
  from .output.output_formatter import display_agent_tools, format_result
49
49
 
50
50
 
@@ -221,9 +221,9 @@ swaig-test agent.py --simulate-serverless cgi --cgi-host example.com --dump-swml
221
221
 
222
222
  def main():
223
223
  """Main entry point for the CLI tool"""
224
- # Check for --raw flag and set up suppression early
225
- if "--raw" in sys.argv:
226
- setup_raw_mode_suppression()
224
+ # Set up suppression early if we're dumping SWML
225
+ if "--dump-swml" in sys.argv:
226
+ setup_output_suppression()
227
227
 
228
228
  # Check for help sections early
229
229
  if "--help-platforms" in sys.argv:
@@ -284,6 +284,10 @@ def main():
284
284
  "--agent-class",
285
285
  help="Specify agent class (required if file has multiple agents)"
286
286
  )
287
+ common.add_argument(
288
+ "--route",
289
+ help="Specify service by route (e.g., /healthcare, /finance)"
290
+ )
287
291
 
288
292
  # Actions (choose one)
289
293
  actions = parser.add_argument_group('actions (choose one)')
@@ -444,6 +448,9 @@ def main():
444
448
  args.tool_name = None
445
449
 
446
450
  # Validate arguments
451
+ if args.route and args.agent_class:
452
+ parser.error("Cannot specify both --route and --agent-class. Choose one.")
453
+
447
454
  if not args.list_tools and not args.dump_swml and not args.list_agents:
448
455
  if not args.tool_name:
449
456
  # If no tool_name and no special flags, default to listing tools
@@ -556,7 +563,13 @@ def main():
556
563
 
557
564
  # Load the agent
558
565
  try:
559
- agent = load_agent_from_file(args.agent_path, args.agent_class)
566
+ # Determine which identifier to use
567
+ service_identifier = args.route if args.route else args.agent_class
568
+ prefer_route = bool(args.route)
569
+
570
+ # Use load_service_from_file which handles both routes and class names
571
+ from signalwire_agents.cli.core.agent_loader import load_service_from_file
572
+ agent = load_service_from_file(args.agent_path, service_identifier, prefer_route)
560
573
  except ValueError as e:
561
574
  error_msg = str(e)
562
575
  if "Multiple agent classes found" in error_msg and args.list_tools and not args.agent_class:
@@ -615,6 +628,7 @@ def main():
615
628
  print("\n" + "=" * 60)
616
629
  print(f"\nTo use a specific agent, run:")
617
630
  print(f" swaig-test {args.agent_path} --agent-class <AgentClassName>")
631
+ print(f" swaig-test {args.agent_path} --route <route>")
618
632
  return 0
619
633
  except Exception as discover_error:
620
634
  print(f"Error discovering agents: {discover_error}")