signalwire-agents 0.1.13__py3-none-any.whl → 1.0.17.dev4__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 (143) hide show
  1. signalwire_agents/__init__.py +99 -15
  2. signalwire_agents/agent_server.py +248 -60
  3. signalwire_agents/agents/bedrock.py +296 -0
  4. signalwire_agents/cli/__init__.py +9 -0
  5. signalwire_agents/cli/build_search.py +951 -41
  6. signalwire_agents/cli/config.py +80 -0
  7. signalwire_agents/cli/core/__init__.py +10 -0
  8. signalwire_agents/cli/core/agent_loader.py +470 -0
  9. signalwire_agents/cli/core/argparse_helpers.py +179 -0
  10. signalwire_agents/cli/core/dynamic_config.py +71 -0
  11. signalwire_agents/cli/core/service_loader.py +303 -0
  12. signalwire_agents/cli/dokku.py +2320 -0
  13. signalwire_agents/cli/execution/__init__.py +10 -0
  14. signalwire_agents/cli/execution/datamap_exec.py +446 -0
  15. signalwire_agents/cli/execution/webhook_exec.py +134 -0
  16. signalwire_agents/cli/init_project.py +2636 -0
  17. signalwire_agents/cli/output/__init__.py +10 -0
  18. signalwire_agents/cli/output/output_formatter.py +255 -0
  19. signalwire_agents/cli/output/swml_dump.py +186 -0
  20. signalwire_agents/cli/simulation/__init__.py +10 -0
  21. signalwire_agents/cli/simulation/data_generation.py +374 -0
  22. signalwire_agents/cli/simulation/data_overrides.py +200 -0
  23. signalwire_agents/cli/simulation/mock_env.py +282 -0
  24. signalwire_agents/cli/swaig_test_wrapper.py +52 -0
  25. signalwire_agents/cli/test_swaig.py +566 -2366
  26. signalwire_agents/cli/types.py +81 -0
  27. signalwire_agents/core/__init__.py +2 -2
  28. signalwire_agents/core/agent/__init__.py +12 -0
  29. signalwire_agents/core/agent/config/__init__.py +12 -0
  30. signalwire_agents/core/agent/deployment/__init__.py +9 -0
  31. signalwire_agents/core/agent/deployment/handlers/__init__.py +9 -0
  32. signalwire_agents/core/agent/prompt/__init__.py +14 -0
  33. signalwire_agents/core/agent/prompt/manager.py +306 -0
  34. signalwire_agents/core/agent/routing/__init__.py +9 -0
  35. signalwire_agents/core/agent/security/__init__.py +9 -0
  36. signalwire_agents/core/agent/swml/__init__.py +9 -0
  37. signalwire_agents/core/agent/tools/__init__.py +15 -0
  38. signalwire_agents/core/agent/tools/decorator.py +97 -0
  39. signalwire_agents/core/agent/tools/registry.py +210 -0
  40. signalwire_agents/core/agent_base.py +845 -2916
  41. signalwire_agents/core/auth_handler.py +233 -0
  42. signalwire_agents/core/config_loader.py +259 -0
  43. signalwire_agents/core/contexts.py +418 -0
  44. signalwire_agents/core/data_map.py +3 -15
  45. signalwire_agents/core/function_result.py +116 -44
  46. signalwire_agents/core/logging_config.py +162 -18
  47. signalwire_agents/core/mixins/__init__.py +28 -0
  48. signalwire_agents/core/mixins/ai_config_mixin.py +442 -0
  49. signalwire_agents/core/mixins/auth_mixin.py +280 -0
  50. signalwire_agents/core/mixins/prompt_mixin.py +358 -0
  51. signalwire_agents/core/mixins/serverless_mixin.py +460 -0
  52. signalwire_agents/core/mixins/skill_mixin.py +55 -0
  53. signalwire_agents/core/mixins/state_mixin.py +153 -0
  54. signalwire_agents/core/mixins/tool_mixin.py +230 -0
  55. signalwire_agents/core/mixins/web_mixin.py +1142 -0
  56. signalwire_agents/core/security_config.py +333 -0
  57. signalwire_agents/core/skill_base.py +84 -1
  58. signalwire_agents/core/skill_manager.py +62 -20
  59. signalwire_agents/core/swaig_function.py +18 -5
  60. signalwire_agents/core/swml_builder.py +207 -11
  61. signalwire_agents/core/swml_handler.py +27 -21
  62. signalwire_agents/core/swml_renderer.py +123 -312
  63. signalwire_agents/core/swml_service.py +171 -203
  64. signalwire_agents/mcp_gateway/__init__.py +29 -0
  65. signalwire_agents/mcp_gateway/gateway_service.py +564 -0
  66. signalwire_agents/mcp_gateway/mcp_manager.py +513 -0
  67. signalwire_agents/mcp_gateway/session_manager.py +218 -0
  68. signalwire_agents/prefabs/concierge.py +0 -3
  69. signalwire_agents/prefabs/faq_bot.py +0 -3
  70. signalwire_agents/prefabs/info_gatherer.py +0 -3
  71. signalwire_agents/prefabs/receptionist.py +0 -3
  72. signalwire_agents/prefabs/survey.py +0 -3
  73. signalwire_agents/schema.json +9218 -5489
  74. signalwire_agents/search/__init__.py +7 -1
  75. signalwire_agents/search/document_processor.py +490 -31
  76. signalwire_agents/search/index_builder.py +307 -37
  77. signalwire_agents/search/migration.py +418 -0
  78. signalwire_agents/search/models.py +30 -0
  79. signalwire_agents/search/pgvector_backend.py +748 -0
  80. signalwire_agents/search/query_processor.py +162 -31
  81. signalwire_agents/search/search_engine.py +916 -35
  82. signalwire_agents/search/search_service.py +376 -53
  83. signalwire_agents/skills/README.md +452 -0
  84. signalwire_agents/skills/__init__.py +14 -2
  85. signalwire_agents/skills/api_ninjas_trivia/README.md +215 -0
  86. signalwire_agents/skills/api_ninjas_trivia/__init__.py +12 -0
  87. signalwire_agents/skills/api_ninjas_trivia/skill.py +237 -0
  88. signalwire_agents/skills/datasphere/README.md +210 -0
  89. signalwire_agents/skills/datasphere/skill.py +84 -3
  90. signalwire_agents/skills/datasphere_serverless/README.md +258 -0
  91. signalwire_agents/skills/datasphere_serverless/__init__.py +9 -0
  92. signalwire_agents/skills/datasphere_serverless/skill.py +82 -1
  93. signalwire_agents/skills/datetime/README.md +132 -0
  94. signalwire_agents/skills/datetime/__init__.py +9 -0
  95. signalwire_agents/skills/datetime/skill.py +20 -7
  96. signalwire_agents/skills/joke/README.md +149 -0
  97. signalwire_agents/skills/joke/__init__.py +9 -0
  98. signalwire_agents/skills/joke/skill.py +21 -0
  99. signalwire_agents/skills/math/README.md +161 -0
  100. signalwire_agents/skills/math/__init__.py +9 -0
  101. signalwire_agents/skills/math/skill.py +18 -4
  102. signalwire_agents/skills/mcp_gateway/README.md +230 -0
  103. signalwire_agents/skills/mcp_gateway/__init__.py +10 -0
  104. signalwire_agents/skills/mcp_gateway/skill.py +421 -0
  105. signalwire_agents/skills/native_vector_search/README.md +210 -0
  106. signalwire_agents/skills/native_vector_search/__init__.py +9 -0
  107. signalwire_agents/skills/native_vector_search/skill.py +569 -101
  108. signalwire_agents/skills/play_background_file/README.md +218 -0
  109. signalwire_agents/skills/play_background_file/__init__.py +12 -0
  110. signalwire_agents/skills/play_background_file/skill.py +242 -0
  111. signalwire_agents/skills/registry.py +395 -40
  112. signalwire_agents/skills/spider/README.md +236 -0
  113. signalwire_agents/skills/spider/__init__.py +13 -0
  114. signalwire_agents/skills/spider/skill.py +598 -0
  115. signalwire_agents/skills/swml_transfer/README.md +395 -0
  116. signalwire_agents/skills/swml_transfer/__init__.py +10 -0
  117. signalwire_agents/skills/swml_transfer/skill.py +359 -0
  118. signalwire_agents/skills/weather_api/README.md +178 -0
  119. signalwire_agents/skills/weather_api/__init__.py +12 -0
  120. signalwire_agents/skills/weather_api/skill.py +191 -0
  121. signalwire_agents/skills/web_search/README.md +163 -0
  122. signalwire_agents/skills/web_search/__init__.py +9 -0
  123. signalwire_agents/skills/web_search/skill.py +586 -112
  124. signalwire_agents/skills/wikipedia_search/README.md +228 -0
  125. signalwire_agents/{core/state → skills/wikipedia_search}/__init__.py +5 -4
  126. signalwire_agents/skills/{wikipedia → wikipedia_search}/skill.py +33 -3
  127. signalwire_agents/web/__init__.py +17 -0
  128. signalwire_agents/web/web_service.py +559 -0
  129. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-agent-init.1 +400 -0
  130. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/sw-search.1 +483 -0
  131. signalwire_agents-1.0.17.dev4.data/data/share/man/man1/swaig-test.1 +308 -0
  132. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/METADATA +347 -215
  133. signalwire_agents-1.0.17.dev4.dist-info/RECORD +147 -0
  134. signalwire_agents-1.0.17.dev4.dist-info/entry_points.txt +6 -0
  135. signalwire_agents/core/state/file_state_manager.py +0 -219
  136. signalwire_agents/core/state/state_manager.py +0 -101
  137. signalwire_agents/skills/wikipedia/__init__.py +0 -9
  138. signalwire_agents-0.1.13.data/data/schema.json +0 -5611
  139. signalwire_agents-0.1.13.dist-info/RECORD +0 -67
  140. signalwire_agents-0.1.13.dist-info/entry_points.txt +0 -3
  141. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/WHEEL +0 -0
  142. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/licenses/LICENSE +0 -0
  143. {signalwire_agents-0.1.13.dist-info → signalwire_agents-1.0.17.dev4.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,333 @@
1
+ """
2
+ Copyright (c) 2025 SignalWire
3
+
4
+ This file is part of the SignalWire AI Agents SDK.
5
+
6
+ Licensed under the MIT License.
7
+ See LICENSE file in the project root for full license information.
8
+ """
9
+
10
+ import os
11
+ import secrets
12
+ from typing import Dict, Any, Optional, Tuple, List, Union
13
+ from signalwire_agents.core.logging_config import get_logger
14
+ from signalwire_agents.core.config_loader import ConfigLoader
15
+
16
+ logger = get_logger("security_config")
17
+
18
+
19
+ class SecurityConfig:
20
+ """
21
+ Unified security configuration for SignalWire services.
22
+
23
+ This class provides centralized security settings that can be used by
24
+ both SWML and Search services, ensuring consistent security behavior.
25
+ """
26
+
27
+ # Security environment variable names
28
+ SSL_ENABLED = 'SWML_SSL_ENABLED'
29
+ SSL_CERT_PATH = 'SWML_SSL_CERT_PATH'
30
+ SSL_KEY_PATH = 'SWML_SSL_KEY_PATH'
31
+ SSL_DOMAIN = 'SWML_DOMAIN'
32
+ SSL_VERIFY_MODE = 'SWML_SSL_VERIFY_MODE'
33
+
34
+ # Additional security settings
35
+ ALLOWED_HOSTS = 'SWML_ALLOWED_HOSTS'
36
+ CORS_ORIGINS = 'SWML_CORS_ORIGINS'
37
+ MAX_REQUEST_SIZE = 'SWML_MAX_REQUEST_SIZE'
38
+ RATE_LIMIT = 'SWML_RATE_LIMIT'
39
+ REQUEST_TIMEOUT = 'SWML_REQUEST_TIMEOUT'
40
+ USE_HSTS = 'SWML_USE_HSTS'
41
+ HSTS_MAX_AGE = 'SWML_HSTS_MAX_AGE'
42
+
43
+ # Authentication
44
+ BASIC_AUTH_USER = 'SWML_BASIC_AUTH_USER'
45
+ BASIC_AUTH_PASSWORD = 'SWML_BASIC_AUTH_PASSWORD'
46
+
47
+ # Defaults (secure by default)
48
+ DEFAULTS = {
49
+ SSL_ENABLED: False, # Off by default, but secure when enabled
50
+ SSL_VERIFY_MODE: 'CERT_REQUIRED',
51
+ ALLOWED_HOSTS: '*', # Accept all hosts by default for backward compatibility
52
+ CORS_ORIGINS: '*', # Accept all origins by default for backward compatibility
53
+ MAX_REQUEST_SIZE: 10 * 1024 * 1024, # 10MB
54
+ RATE_LIMIT: 60, # Requests per minute
55
+ REQUEST_TIMEOUT: 30, # Seconds
56
+ USE_HSTS: True, # Enable HSTS when HTTPS is on
57
+ HSTS_MAX_AGE: 31536000, # 1 year
58
+ }
59
+
60
+ def __init__(self, config_file: Optional[str] = None, service_name: Optional[str] = None):
61
+ """
62
+ Initialize security configuration.
63
+
64
+ Args:
65
+ config_file: Optional path to config file
66
+ service_name: Optional service name for finding service-specific config
67
+ """
68
+ # First, set defaults
69
+ self._set_defaults()
70
+
71
+ # Then load from environment variables (backward compatibility)
72
+ self.load_from_env()
73
+
74
+ # Finally, apply config file if available (highest priority)
75
+ self._load_config_file(config_file, service_name)
76
+
77
+ def _set_defaults(self):
78
+ """Set default values for all configuration"""
79
+ # SSL configuration
80
+ self.ssl_enabled = self.DEFAULTS[self.SSL_ENABLED]
81
+ self.ssl_cert_path = None
82
+ self.ssl_key_path = None
83
+ self.domain = None
84
+ self.ssl_verify_mode = self.DEFAULTS[self.SSL_VERIFY_MODE]
85
+
86
+ # Additional settings
87
+ self.allowed_hosts = self._parse_list(self.DEFAULTS[self.ALLOWED_HOSTS])
88
+ self.cors_origins = self._parse_list(self.DEFAULTS[self.CORS_ORIGINS])
89
+ self.max_request_size = self.DEFAULTS[self.MAX_REQUEST_SIZE]
90
+ self.rate_limit = self.DEFAULTS[self.RATE_LIMIT]
91
+ self.request_timeout = self.DEFAULTS[self.REQUEST_TIMEOUT]
92
+ self.use_hsts = self.DEFAULTS[self.USE_HSTS]
93
+ self.hsts_max_age = self.DEFAULTS[self.HSTS_MAX_AGE]
94
+
95
+ # Authentication
96
+ self.basic_auth_user = None
97
+ self.basic_auth_password = None
98
+
99
+ def _load_config_file(self, config_file: Optional[str], service_name: Optional[str]):
100
+ """Load configuration from config file if available"""
101
+ # Find config file
102
+ if not config_file:
103
+ config_file = ConfigLoader.find_config_file(service_name)
104
+
105
+ if not config_file:
106
+ return
107
+
108
+ # Load config
109
+ config_loader = ConfigLoader([config_file])
110
+ if not config_loader.has_config():
111
+ return
112
+
113
+ logger.info("loading_config_from_file", file=config_file)
114
+
115
+ # Get security section
116
+ security_config = config_loader.get_section('security')
117
+ if not security_config:
118
+ return
119
+
120
+ # Apply security settings (config file takes precedence)
121
+ if 'ssl_enabled' in security_config:
122
+ self.ssl_enabled = security_config['ssl_enabled']
123
+
124
+ if 'ssl_cert_path' in security_config:
125
+ self.ssl_cert_path = security_config['ssl_cert_path']
126
+
127
+ if 'ssl_key_path' in security_config:
128
+ self.ssl_key_path = security_config['ssl_key_path']
129
+
130
+ if 'domain' in security_config:
131
+ self.domain = security_config['domain']
132
+
133
+ if 'ssl_verify_mode' in security_config:
134
+ self.ssl_verify_mode = security_config['ssl_verify_mode']
135
+
136
+ # Additional settings
137
+ if 'allowed_hosts' in security_config:
138
+ self.allowed_hosts = self._parse_list(security_config['allowed_hosts'])
139
+
140
+ if 'cors_origins' in security_config:
141
+ self.cors_origins = self._parse_list(security_config['cors_origins'])
142
+
143
+ if 'max_request_size' in security_config:
144
+ self.max_request_size = int(security_config['max_request_size'])
145
+
146
+ if 'rate_limit' in security_config:
147
+ self.rate_limit = int(security_config['rate_limit'])
148
+
149
+ if 'request_timeout' in security_config:
150
+ self.request_timeout = int(security_config['request_timeout'])
151
+
152
+ if 'use_hsts' in security_config:
153
+ self.use_hsts = security_config['use_hsts']
154
+
155
+ if 'hsts_max_age' in security_config:
156
+ self.hsts_max_age = int(security_config['hsts_max_age'])
157
+
158
+ # Authentication from config
159
+ auth_config = security_config.get('auth', {})
160
+ if isinstance(auth_config, dict):
161
+ basic_auth = auth_config.get('basic', {})
162
+ if isinstance(basic_auth, dict):
163
+ if 'user' in basic_auth:
164
+ self.basic_auth_user = basic_auth['user']
165
+ if 'password' in basic_auth:
166
+ self.basic_auth_password = basic_auth['password']
167
+
168
+ def load_from_env(self):
169
+ """Load configuration from environment variables"""
170
+ # SSL configuration
171
+ ssl_enabled_env = os.environ.get(self.SSL_ENABLED, '').lower()
172
+ self.ssl_enabled = ssl_enabled_env in ('true', '1', 'yes')
173
+ self.ssl_cert_path = os.environ.get(self.SSL_CERT_PATH)
174
+ self.ssl_key_path = os.environ.get(self.SSL_KEY_PATH)
175
+ self.domain = os.environ.get(self.SSL_DOMAIN)
176
+ self.ssl_verify_mode = os.environ.get(self.SSL_VERIFY_MODE, self.DEFAULTS[self.SSL_VERIFY_MODE])
177
+
178
+ # Additional security settings
179
+ self.allowed_hosts = self._parse_list(os.environ.get(self.ALLOWED_HOSTS, self.DEFAULTS[self.ALLOWED_HOSTS]))
180
+ self.cors_origins = self._parse_list(os.environ.get(self.CORS_ORIGINS, self.DEFAULTS[self.CORS_ORIGINS]))
181
+ self.max_request_size = int(os.environ.get(self.MAX_REQUEST_SIZE, self.DEFAULTS[self.MAX_REQUEST_SIZE]))
182
+ self.rate_limit = int(os.environ.get(self.RATE_LIMIT, self.DEFAULTS[self.RATE_LIMIT]))
183
+ self.request_timeout = int(os.environ.get(self.REQUEST_TIMEOUT, self.DEFAULTS[self.REQUEST_TIMEOUT]))
184
+
185
+ # HSTS settings
186
+ use_hsts_env = os.environ.get(self.USE_HSTS, '').lower()
187
+ self.use_hsts = use_hsts_env != 'false' if use_hsts_env else self.DEFAULTS[self.USE_HSTS]
188
+ self.hsts_max_age = int(os.environ.get(self.HSTS_MAX_AGE, self.DEFAULTS[self.HSTS_MAX_AGE]))
189
+
190
+ # Authentication
191
+ self.basic_auth_user = os.environ.get(self.BASIC_AUTH_USER)
192
+ self.basic_auth_password = os.environ.get(self.BASIC_AUTH_PASSWORD)
193
+
194
+ def _parse_list(self, value: Union[str, list]) -> list:
195
+ """Parse comma-separated list from environment variable or list from config"""
196
+ if isinstance(value, list):
197
+ return value
198
+ if value == '*':
199
+ return ['*']
200
+ return [item.strip() for item in value.split(',') if item.strip()]
201
+
202
+ def validate_ssl_config(self) -> Tuple[bool, Optional[str]]:
203
+ """
204
+ Validate SSL configuration.
205
+
206
+ Returns:
207
+ Tuple of (is_valid, error_message)
208
+ """
209
+ if not self.ssl_enabled:
210
+ return True, None
211
+
212
+ if not self.ssl_cert_path:
213
+ return False, "SSL enabled but SWML_SSL_CERT_PATH not set"
214
+
215
+ if not self.ssl_key_path:
216
+ return False, "SSL enabled but SWML_SSL_KEY_PATH not set"
217
+
218
+ if not os.path.exists(self.ssl_cert_path):
219
+ return False, f"SSL certificate file not found: {self.ssl_cert_path}"
220
+
221
+ if not os.path.exists(self.ssl_key_path):
222
+ return False, f"SSL key file not found: {self.ssl_key_path}"
223
+
224
+ return True, None
225
+
226
+ def get_ssl_context_kwargs(self) -> Dict[str, Any]:
227
+ """
228
+ Get SSL context kwargs for uvicorn.
229
+
230
+ Returns:
231
+ Dictionary of SSL parameters for uvicorn
232
+ """
233
+ if not self.ssl_enabled:
234
+ return {}
235
+
236
+ is_valid, error = self.validate_ssl_config()
237
+ if not is_valid:
238
+ logger.error("ssl_validation_failed", error=error)
239
+ return {}
240
+
241
+ return {
242
+ 'ssl_certfile': self.ssl_cert_path,
243
+ 'ssl_keyfile': self.ssl_key_path,
244
+ # Additional SSL options can be added here
245
+ }
246
+
247
+ def get_basic_auth(self) -> Tuple[str, str]:
248
+ """
249
+ Get basic auth credentials, generating if not set.
250
+
251
+ Returns:
252
+ Tuple of (username, password)
253
+ """
254
+ username = self.basic_auth_user or "signalwire"
255
+ password = self.basic_auth_password or secrets.token_urlsafe(32)
256
+
257
+ return username, password
258
+
259
+ def get_security_headers(self, is_https: bool = False) -> Dict[str, str]:
260
+ """
261
+ Get security headers to add to responses.
262
+
263
+ Args:
264
+ is_https: Whether the connection is over HTTPS
265
+
266
+ Returns:
267
+ Dictionary of security headers
268
+ """
269
+ headers = {
270
+ 'X-Content-Type-Options': 'nosniff',
271
+ 'X-Frame-Options': 'DENY',
272
+ 'X-XSS-Protection': '1; mode=block',
273
+ 'Referrer-Policy': 'strict-origin-when-cross-origin',
274
+ }
275
+
276
+ # Add HSTS header if HTTPS and enabled
277
+ if is_https and self.use_hsts:
278
+ headers['Strict-Transport-Security'] = f'max-age={self.hsts_max_age}; includeSubDomains'
279
+
280
+ return headers
281
+
282
+ def should_allow_host(self, host: str) -> bool:
283
+ """
284
+ Check if a host is allowed.
285
+
286
+ Args:
287
+ host: The host to check
288
+
289
+ Returns:
290
+ True if the host is allowed
291
+ """
292
+ if '*' in self.allowed_hosts:
293
+ return True
294
+
295
+ return host in self.allowed_hosts
296
+
297
+ def get_cors_config(self) -> Dict[str, Any]:
298
+ """
299
+ Get CORS configuration for FastAPI.
300
+
301
+ Returns:
302
+ Dictionary of CORS settings
303
+ """
304
+ return {
305
+ 'allow_origins': self.cors_origins,
306
+ 'allow_credentials': True,
307
+ 'allow_methods': ['*'],
308
+ 'allow_headers': ['*'],
309
+ }
310
+
311
+ def get_url_scheme(self) -> str:
312
+ """Get the URL scheme based on SSL configuration"""
313
+ return 'https' if self.ssl_enabled else 'http'
314
+
315
+ def log_config(self, service_name: str):
316
+ """Log the current security configuration"""
317
+ logger.info(
318
+ "security_config_loaded",
319
+ service=service_name,
320
+ ssl_enabled=self.ssl_enabled,
321
+ domain=self.domain,
322
+ allowed_hosts=self.allowed_hosts,
323
+ cors_origins=self.cors_origins,
324
+ max_request_size=self.max_request_size,
325
+ rate_limit=self.rate_limit,
326
+ use_hsts=self.use_hsts,
327
+ has_basic_auth=bool(self.basic_auth_user and self.basic_auth_password)
328
+ )
329
+
330
+
331
+ # Global instance for easy access (backward compatibility)
332
+ # Services can create their own instances with specific config files
333
+ security_config = SecurityConfig()
@@ -53,6 +53,26 @@ class SkillBase(ABC):
53
53
  """Register SWAIG tools with the agent"""
54
54
  pass
55
55
 
56
+ def define_tool(self, **kwargs) -> None:
57
+ """
58
+ Wrapper method that automatically includes swaig_fields when defining tools.
59
+
60
+ This method delegates to self.agent.define_tool() but automatically merges
61
+ any swaig_fields configured for this skill. Skills should use this method
62
+ instead of calling self.agent.define_tool() directly.
63
+
64
+ Args:
65
+ **kwargs: All arguments supported by agent.define_tool()
66
+ (name, description, parameters, handler, etc.)
67
+ """
68
+ # Merge swaig_fields with any explicitly passed fields
69
+ # Explicit fields take precedence over swaig_fields
70
+ merged_kwargs = dict(self.swaig_fields)
71
+ merged_kwargs.update(kwargs)
72
+
73
+ # Call the agent's define_tool with merged arguments
74
+ return self.agent.define_tool(**merged_kwargs)
75
+
56
76
 
57
77
 
58
78
  def get_hints(self) -> List[str]:
@@ -114,4 +134,67 @@ class SkillBase(ABC):
114
134
 
115
135
  # For multi-instance skills, create key from skill name + tool name
116
136
  tool_name = self.params.get('tool_name', self.SKILL_NAME)
117
- return f"{self.SKILL_NAME}_{tool_name}"
137
+ return f"{self.SKILL_NAME}_{tool_name}"
138
+
139
+ @classmethod
140
+ def get_parameter_schema(cls) -> Dict[str, Dict[str, Any]]:
141
+ """
142
+ Get the parameter schema for this skill
143
+
144
+ This method returns metadata about all parameters the skill accepts,
145
+ including their types, descriptions, default values, and whether they
146
+ are required or should be hidden (e.g., API keys).
147
+
148
+ The base implementation provides common parameters available to all skills.
149
+ Subclasses should override this method and merge their specific parameters
150
+ with the base schema.
151
+
152
+ Returns:
153
+ Dict[str, Dict[str, Any]]: Parameter schema where keys are parameter names
154
+ and values are dictionaries containing:
155
+ - type: Parameter type ("string", "integer", "number", "boolean", "object", "array")
156
+ - description: Human-readable description
157
+ - default: Default value if not provided (optional)
158
+ - required: Whether the parameter is required (default: False)
159
+ - hidden: Whether to hide this field in UIs (for secrets/keys)
160
+ - env_var: Environment variable that can provide this value (optional)
161
+ - enum: List of allowed values (optional)
162
+ - min/max: Minimum/maximum values for numeric types (optional)
163
+
164
+ Example:
165
+ {
166
+ "tool_name": {
167
+ "type": "string",
168
+ "description": "Name for the tool when using multiple instances",
169
+ "default": "my_skill",
170
+ "required": False
171
+ },
172
+ "api_key": {
173
+ "type": "string",
174
+ "description": "API key for the service",
175
+ "required": True,
176
+ "hidden": True,
177
+ "env_var": "MY_API_KEY"
178
+ }
179
+ }
180
+ """
181
+ schema = {}
182
+
183
+ # Add swaig_fields parameter (available to all skills)
184
+ schema["swaig_fields"] = {
185
+ "type": "object",
186
+ "description": "Additional SWAIG function metadata to merge into tool definitions",
187
+ "default": {},
188
+ "required": False
189
+ }
190
+
191
+ # Add tool_name for multi-instance skills
192
+ if cls.SUPPORTS_MULTIPLE_INSTANCES:
193
+ schema["tool_name"] = {
194
+ "type": "string",
195
+ "description": "Custom name for this skill instance (for multiple instances)",
196
+ "default": cls.SKILL_NAME,
197
+ "required": False
198
+ }
199
+
200
+ return schema
@@ -8,7 +8,7 @@ See LICENSE file in the project root for full license information.
8
8
  """
9
9
 
10
10
  from typing import Dict, List, Type, Any, Optional
11
- import logging
11
+ from signalwire_agents.core.logging_config import get_logger
12
12
  from signalwire_agents.core.skill_base import SkillBase
13
13
 
14
14
  class SkillManager:
@@ -17,7 +17,7 @@ class SkillManager:
17
17
  def __init__(self, agent):
18
18
  self.agent = agent
19
19
  self.loaded_skills: Dict[str, SkillBase] = {}
20
- self.logger = logging.getLogger("skill_manager")
20
+ self.logger = get_logger("skill_manager")
21
21
 
22
22
  def load_skill(self, skill_name: str, skill_class: Type[SkillBase] = None, params: Optional[Dict[str, Any]] = None) -> tuple[bool, str]:
23
23
  """
@@ -45,6 +45,53 @@ class SkillManager:
45
45
  self.logger.error(error_msg)
46
46
  return False, error_msg
47
47
 
48
+ # Validate that the skill has a proper parameter schema
49
+ if not hasattr(skill_class, 'get_parameter_schema') or not callable(getattr(skill_class, 'get_parameter_schema')):
50
+ error_msg = f"Skill '{skill_name}' must have get_parameter_schema() classmethod"
51
+ self.logger.error(error_msg)
52
+ return False, error_msg
53
+
54
+ try:
55
+ # Validate the parameter schema
56
+ schema = skill_class.get_parameter_schema()
57
+ if not isinstance(schema, dict):
58
+ error_msg = f"Skill '{skill_name}'.get_parameter_schema() must return a dictionary"
59
+ self.logger.error(error_msg)
60
+ return False, error_msg
61
+
62
+ # Ensure it's not an empty schema
63
+ if not schema:
64
+ error_msg = f"Skill '{skill_name}'.get_parameter_schema() returned empty dictionary"
65
+ self.logger.error(error_msg)
66
+ return False, error_msg
67
+
68
+ # Check if the skill has overridden the method
69
+ from signalwire_agents.core.skill_base import SkillBase
70
+ skill_method = getattr(skill_class, 'get_parameter_schema', None)
71
+ base_method = getattr(SkillBase, 'get_parameter_schema', None)
72
+
73
+ if skill_method and base_method:
74
+ # For class methods, check the underlying function
75
+ skill_func = skill_method.__func__ if hasattr(skill_method, '__func__') else skill_method
76
+ base_func = base_method.__func__ if hasattr(base_method, '__func__') else base_method
77
+
78
+ if skill_func is base_func:
79
+ # Get base schema to check if skill added any parameters
80
+ base_schema = SkillBase.get_parameter_schema()
81
+ if set(schema.keys()) == set(base_schema.keys()):
82
+ error_msg = f"Skill '{skill_name}' must override get_parameter_schema() to define its specific parameters"
83
+ self.logger.error(error_msg)
84
+ return False, error_msg
85
+
86
+ except AttributeError as e:
87
+ error_msg = f"Skill '{skill_name}' must properly implement get_parameter_schema() classmethod"
88
+ self.logger.error(error_msg)
89
+ return False, error_msg
90
+ except Exception as e:
91
+ error_msg = f"Skill '{skill_name}'.get_parameter_schema() failed: {e}"
92
+ self.logger.error(error_msg)
93
+ return False, error_msg
94
+
48
95
  try:
49
96
  # Create skill instance with parameters to get the instance key
50
97
  skill_instance = skill_class(self.agent, params)
@@ -111,6 +158,16 @@ class SkillManager:
111
158
  self.logger.info(f"Successfully loaded skill instance '{instance_key}' (skill: '{skill_name}')")
112
159
  return True, ""
113
160
 
161
+ except ValueError as e:
162
+ # Check if this is a duplicate tool registration (expected during agent cloning)
163
+ if "already exists" in str(e):
164
+ debug_msg = f"Skill '{skill_name}' already loaded, skipping duplicate registration"
165
+ self.logger.debug(debug_msg)
166
+ return True, "" # Not an error, skill is already available
167
+ else:
168
+ error_msg = f"Error loading skill '{skill_name}': {e}"
169
+ self.logger.error(error_msg)
170
+ return False, error_msg
114
171
  except Exception as e:
115
172
  error_msg = f"Error loading skill '{skill_name}': {e}"
116
173
  self.logger.error(error_msg)
@@ -134,13 +191,6 @@ class SkillManager:
134
191
  if skill_identifier in self.loaded_skills:
135
192
  instance_key = skill_identifier
136
193
  skill_instance = self.loaded_skills[skill_identifier]
137
- else:
138
- # Try to find by skill name (for backwards compatibility)
139
- for key, instance in self.loaded_skills.items():
140
- if instance.SKILL_NAME == skill_identifier:
141
- instance_key = key
142
- skill_instance = instance
143
- break
144
194
 
145
195
  if skill_instance is None:
146
196
  self.logger.warning(f"Skill '{skill_identifier}' is not loaded")
@@ -173,11 +223,6 @@ class SkillManager:
173
223
  if skill_identifier in self.loaded_skills:
174
224
  return True
175
225
 
176
- # Try to find by skill name (for backwards compatibility)
177
- for instance in self.loaded_skills.values():
178
- if instance.SKILL_NAME == skill_identifier:
179
- return True
180
-
181
226
  return False
182
227
 
183
228
  def get_skill(self, skill_identifier: str) -> Optional[SkillBase]:
@@ -194,9 +239,6 @@ class SkillManager:
194
239
  if skill_identifier in self.loaded_skills:
195
240
  return self.loaded_skills[skill_identifier]
196
241
 
197
- # Try to find by skill name (for backwards compatibility)
198
- for instance in self.loaded_skills.values():
199
- if instance.SKILL_NAME == skill_identifier:
200
- return instance
201
-
202
- return None
242
+ return None
243
+
244
+
@@ -30,7 +30,10 @@ class SWAIGFunction:
30
30
  parameters: Dict[str, Dict] = None,
31
31
  secure: bool = False,
32
32
  fillers: Optional[Dict[str, List[str]]] = None,
33
+ wait_file: Optional[str] = None,
34
+ wait_file_loops: Optional[int] = None,
33
35
  webhook_url: Optional[str] = None,
36
+ required: Optional[List[str]] = None,
34
37
  **extra_swaig_fields
35
38
  ):
36
39
  """
@@ -42,8 +45,11 @@ class SWAIGFunction:
42
45
  description: Human-readable description of the function
43
46
  parameters: Dictionary of parameters, keys are parameter names, values are param definitions
44
47
  secure: Whether this function requires token validation
45
- fillers: Optional dictionary of filler phrases by language code
48
+ fillers: Optional dictionary of filler phrases by language code (deprecated, use wait_file)
49
+ wait_file: Optional URL to audio file to play while function executes
50
+ wait_file_loops: Optional number of times to loop the wait_file
46
51
  webhook_url: Optional external webhook URL to use instead of local handling
52
+ required: Optional list of required parameter names
47
53
  **extra_swaig_fields: Additional SWAIG fields to include in function definition
48
54
  """
49
55
  self.name = name
@@ -51,8 +57,11 @@ class SWAIGFunction:
51
57
  self.description = description
52
58
  self.parameters = parameters or {}
53
59
  self.secure = secure
54
- self.fillers = fillers
60
+ self.fillers = fillers # Text phrases to say while processing
61
+ self.wait_file = wait_file # URL to audio/video file to play while waiting
62
+ self.wait_file_loops = wait_file_loops
55
63
  self.webhook_url = webhook_url
64
+ self.required = required or []
56
65
  self.extra_swaig_fields = extra_swaig_fields
57
66
 
58
67
  # Mark as external if webhook_url is provided
@@ -73,11 +82,17 @@ class SWAIGFunction:
73
82
  return self.parameters
74
83
 
75
84
  # Otherwise, wrap the parameters in the expected structure
76
- return {
85
+ result = {
77
86
  "type": "object",
78
87
  "properties": self.parameters
79
88
  }
80
89
 
90
+ # Add required fields if specified
91
+ if self.required:
92
+ result["required"] = self.required
93
+
94
+ return result
95
+
81
96
  def __call__(self, *args, **kwargs):
82
97
  """
83
98
  Call the underlying handler function
@@ -179,5 +194,3 @@ class SWAIGFunction:
179
194
 
180
195
  return function_def
181
196
 
182
- # Add an alias for backward compatibility
183
- SwaigFunction = SWAIGFunction