signalwire-sdk 2.0.0

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 (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. metadata +225 -0
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../skill_base'
4
+ require_relative '../skill_registry'
5
+ require_relative '../../datamap/data_map'
6
+
7
+ module SignalWire
8
+ module Skills
9
+ module Builtin
10
+ class WeatherApiSkill < SkillBase
11
+ def name; 'weather_api'; end
12
+ def description; 'Get current weather information from WeatherAPI.com'; end
13
+
14
+ def setup
15
+ @api_key = get_param('api_key', env_var: 'WEATHER_API_KEY')
16
+ @tool_name = get_param('tool_name', default: 'get_weather')
17
+ @temp_unit = get_param('temperature_unit', default: 'fahrenheit')
18
+ return false unless @api_key && !@api_key.empty?
19
+ true
20
+ end
21
+
22
+ def register_tools
23
+ if @temp_unit == 'celsius'
24
+ temp_field = 'temp_c'
25
+ feels_field = 'feelslike_c'
26
+ unit_name = 'Celsius'
27
+ else
28
+ temp_field = 'temp_f'
29
+ feels_field = 'feelslike_f'
30
+ unit_name = 'Fahrenheit'
31
+ end
32
+
33
+ response_template =
34
+ "Tell the user the current weather conditions. " \
35
+ "Express all temperatures in #{unit_name} using natural language numbers " \
36
+ "without abbreviations or symbols for clear text-to-speech pronunciation. " \
37
+ "Current conditions: ${current.condition.text}. " \
38
+ "Temperature: ${current.#{temp_field}} degrees #{unit_name}. " \
39
+ "Wind: ${current.wind_dir} at ${current.wind_mph} miles per hour. " \
40
+ "Cloud coverage: ${current.cloud} percent. " \
41
+ "Feels like: ${current.#{feels_field}} degrees #{unit_name}."
42
+
43
+ # Default to the WeatherAPI.com host; WEATHER_API_BASE_URL
44
+ # overrides for tests and the audit fixture. The `/v1/current.json`
45
+ # path is preserved so the audit can match on `current.json`.
46
+ base = ENV['WEATHER_API_BASE_URL']
47
+ base = 'https://api.weatherapi.com' if base.nil? || base.empty?
48
+ base = base.sub(/\/$/, '')
49
+
50
+ tool = {
51
+ 'function' => @tool_name,
52
+ 'description' => 'Get current weather information for any location',
53
+ 'parameters' => {
54
+ 'type' => 'object',
55
+ 'properties' => {
56
+ 'location' => { 'type' => 'string', 'description' => 'The city, state, country, or location to get weather for' }
57
+ },
58
+ 'required' => ['location']
59
+ },
60
+ 'data_map' => {
61
+ 'webhooks' => [
62
+ {
63
+ 'url' => "#{base}/v1/current.json?key=#{@api_key}&q=${lc:enc:args.location}&aqi=no",
64
+ 'method' => 'GET',
65
+ 'output' => Swaig::FunctionResult.new(response_template).to_h
66
+ }
67
+ ],
68
+ 'error_keys' => ['error'],
69
+ 'output' => Swaig::FunctionResult.new(
70
+ 'Sorry, I cannot get weather information right now. Please try again later or check if the location name is correct.'
71
+ ).to_h
72
+ }
73
+ }
74
+
75
+ [{ datamap: tool }]
76
+ end
77
+
78
+ def get_parameter_schema
79
+ {
80
+ 'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'WEATHER_API_KEY' },
81
+ 'tool_name' => { 'type' => 'string', 'default' => 'get_weather' },
82
+ 'temperature_unit' => { 'type' => 'string', 'default' => 'fahrenheit', 'enum' => %w[fahrenheit celsius] }
83
+ }
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ SignalWire::Skills::SkillRegistry.register('weather_api') do |params|
91
+ SignalWire::Skills::Builtin::WeatherApiSkill.new(params)
92
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ require_relative '../skill_base'
8
+ require_relative '../skill_registry'
9
+
10
+ module SignalWire
11
+ module Skills
12
+ module Builtin
13
+ class WebSearchSkill < SkillBase
14
+ def name; 'web_search'; end
15
+ def description; 'Search the web for information using Google Custom Search API'; end
16
+ def version; '2.0.0'; end
17
+ def supports_multiple_instances?; true; end
18
+
19
+ def setup
20
+ @api_key = get_param('api_key', env_var: 'GOOGLE_SEARCH_API_KEY')
21
+ @search_engine_id = get_param('search_engine_id', env_var: 'GOOGLE_SEARCH_ENGINE_ID')
22
+ @num_results = (get_param('num_results', default: 3)).to_i
23
+ @tool_name = get_param('tool_name', default: 'web_search')
24
+ @no_results_msg = get_param('no_results_message',
25
+ default: "I couldn't find quality results for that query. Try rephrasing your search.")
26
+
27
+ # Optional prefix/postfix wrapped around every non-empty search
28
+ # result. Use these to give the calling agent a mechanical cue
29
+ # (e.g. "tell the user this came from a public web search")
30
+ # without needing prompt-side rules. Mirrors Python parity.
31
+ @response_prefix = get_param('response_prefix', default: '')
32
+ @response_postfix = get_param('response_postfix', default: '')
33
+
34
+ return false unless @api_key && !@api_key.empty?
35
+ return false unless @search_engine_id && !@search_engine_id.empty?
36
+ true
37
+ end
38
+
39
+ def instance_key; "web_search_#{@tool_name}"; end
40
+
41
+ def register_tools
42
+ [
43
+ {
44
+ name: @tool_name,
45
+ description: 'Search the web for high-quality information, automatically filtering low-quality results',
46
+ parameters: {
47
+ 'query' => { 'type' => 'string', 'description' => 'The search query - what you want to find information about' }
48
+ },
49
+ handler: method(:handle_search)
50
+ }
51
+ ]
52
+ end
53
+
54
+ def get_global_data
55
+ { 'web_search_enabled' => true, 'search_provider' => 'Google Custom Search', 'quality_filtering' => true }
56
+ end
57
+
58
+ def get_prompt_sections
59
+ [
60
+ {
61
+ 'title' => 'Web Search Capability (Quality Enhanced)',
62
+ 'body' => "You can search the internet for high-quality information using the #{@tool_name} tool.",
63
+ 'bullets' => [
64
+ "Use the #{@tool_name} tool when users ask for information you need to look up",
65
+ 'The search automatically filters out low-quality results like empty pages',
66
+ 'Results are ranked by content quality, relevance, and domain reputation',
67
+ 'Summarize the high-quality results in a clear, helpful way'
68
+ ]
69
+ }
70
+ ]
71
+ end
72
+
73
+ def get_parameter_schema
74
+ {
75
+ 'api_key' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'GOOGLE_SEARCH_API_KEY' },
76
+ 'search_engine_id' => { 'type' => 'string', 'required' => true, 'hidden' => true, 'env_var' => 'GOOGLE_SEARCH_ENGINE_ID' },
77
+ 'num_results' => { 'type' => 'integer', 'default' => 3, 'min' => 1, 'max' => 10 },
78
+ 'no_results_message' => { 'type' => 'string' },
79
+ 'response_prefix' => { 'type' => 'string', 'default' => '' },
80
+ 'response_postfix' => { 'type' => 'string', 'default' => '' }
81
+ }
82
+ end
83
+
84
+ private
85
+
86
+ def handle_search(args, _raw_data)
87
+ query = (args['query'] || '').strip
88
+ if query.empty?
89
+ return Swaig::FunctionResult.new('Please provide a search query. What would you like me to search for?')
90
+ end
91
+
92
+ begin
93
+ results = google_search(query, @num_results)
94
+ if results.empty?
95
+ return Swaig::FunctionResult.new(@no_results_msg)
96
+ end
97
+
98
+ formatted = results.map.with_index(1) do |r, i|
99
+ "=== RESULT #{i} ===\nTitle: #{r['title']}\nURL: #{r['url']}\nSnippet: #{r['snippet']}\n#{'=' * 50}"
100
+ end.join("\n\n")
101
+
102
+ response = "Web search results for '#{query}':\n\n#{formatted}"
103
+ response = "#{@response_prefix}\n\n#{response}" unless @response_prefix.nil? || @response_prefix.empty?
104
+ response = "#{response}\n\n#{@response_postfix}" unless @response_postfix.nil? || @response_postfix.empty?
105
+ Swaig::FunctionResult.new(response)
106
+ rescue => e
107
+ Swaig::FunctionResult.new("Sorry, I encountered an error while searching: #{e.message}")
108
+ end
109
+ end
110
+
111
+ def google_search(query, num)
112
+ # Default to Google CSE; WEB_SEARCH_BASE_URL overrides for tests
113
+ # and the audit fixture (matches Rust SDK's behavior — env var is
114
+ # the *host*, the `/customsearch/v1` path is appended below so
115
+ # the audit can match on `customsearch` in req.path).
116
+ base = ENV['WEB_SEARCH_BASE_URL']
117
+ base = 'https://www.googleapis.com' if base.nil? || base.empty?
118
+ uri = URI("#{base.sub(/\/$/, '')}/customsearch/v1")
119
+ uri.query = URI.encode_www_form(
120
+ key: @api_key,
121
+ cx: @search_engine_id,
122
+ q: query,
123
+ num: [num, 10].min
124
+ )
125
+
126
+ response = Net::HTTP.get_response(uri)
127
+ return [] unless response.is_a?(Net::HTTPSuccess)
128
+
129
+ data = JSON.parse(response.body)
130
+ (data['items'] || []).first(num).map do |item|
131
+ { 'title' => item['title'] || '', 'url' => item['link'] || '', 'snippet' => item['snippet'] || '' }
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ SignalWire::Skills::SkillRegistry.register('web_search') do |params|
140
+ SignalWire::Skills::Builtin::WebSearchSkill.new(params)
141
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+
7
+ require_relative '../skill_base'
8
+ require_relative '../skill_registry'
9
+
10
+ module SignalWire
11
+ module Skills
12
+ module Builtin
13
+ class WikipediaSearchSkill < SkillBase
14
+ def name; 'wikipedia_search'; end
15
+ def description; 'Search Wikipedia for information about a topic and get article summaries'; end
16
+
17
+ def setup
18
+ @num_results = [1, (get_param('num_results', default: 1)).to_i].max
19
+ @no_results_msg = get_param('no_results_message',
20
+ default: "I couldn't find any Wikipedia articles for that query. Try rephrasing your search or using different keywords.")
21
+ true
22
+ end
23
+
24
+ def register_tools
25
+ [
26
+ {
27
+ name: 'search_wiki',
28
+ description: 'Search Wikipedia for information about a topic and get article summaries',
29
+ parameters: {
30
+ 'query' => { 'type' => 'string', 'description' => 'The search term or topic to look up on Wikipedia' }
31
+ },
32
+ handler: method(:handle_search)
33
+ }
34
+ ]
35
+ end
36
+
37
+ def get_prompt_sections
38
+ [
39
+ {
40
+ 'title' => 'Wikipedia Search',
41
+ 'body' => "You can search Wikipedia for factual information using search_wiki. This will return up to #{@num_results || 1} Wikipedia article summaries.",
42
+ 'bullets' => [
43
+ 'Use search_wiki for factual, encyclopedic information',
44
+ 'Great for answering questions about people, places, concepts, and history',
45
+ 'Returns reliable, well-sourced information from Wikipedia articles'
46
+ ]
47
+ }
48
+ ]
49
+ end
50
+
51
+ def get_parameter_schema
52
+ {
53
+ 'num_results' => { 'type' => 'integer', 'default' => 1, 'min' => 1, 'max' => 5 },
54
+ 'no_results_message' => { 'type' => 'string' }
55
+ }
56
+ end
57
+
58
+ private
59
+
60
+ def handle_search(args, _raw_data)
61
+ query = (args['query'] || '').strip
62
+ if query.empty?
63
+ return Swaig::FunctionResult.new('Please provide a search query for Wikipedia.')
64
+ end
65
+
66
+ begin
67
+ result = search_wiki(query)
68
+ Swaig::FunctionResult.new(result)
69
+ rescue => e
70
+ Swaig::FunctionResult.new("Error searching Wikipedia: #{e.message}")
71
+ end
72
+ end
73
+
74
+ def search_wiki(query)
75
+ # Default to en.wikipedia.org host; WIKIPEDIA_BASE_URL overrides
76
+ # for tests and the audit fixture. The env var is the *host*; the
77
+ # `/w/api.php` path is appended below so audit_skills_dispatch
78
+ # can match on `api.php` in req.path.
79
+ base = ENV['WIKIPEDIA_BASE_URL']
80
+ base = 'https://en.wikipedia.org' if base.nil? || base.empty?
81
+ api_endpoint = "#{base.sub(/\/$/, '')}/w/api.php"
82
+
83
+ # Step 1: Search
84
+ search_uri = URI("#{api_endpoint}?action=query&list=search&format=json&srsearch=#{URI.encode_www_form_component(query)}&srlimit=#{@num_results}")
85
+ search_resp = Net::HTTP.get_response(search_uri)
86
+ return @no_results_msg unless search_resp.is_a?(Net::HTTPSuccess)
87
+
88
+ search_data = JSON.parse(search_resp.body)
89
+ results = search_data.dig('query', 'search') || []
90
+ return @no_results_msg if results.empty?
91
+
92
+ # Step 2: Get extracts. If the upstream returns extracts
93
+ # (production behavior on en.wikipedia.org), prefer those; if the
94
+ # response shape doesn't include `query.pages` (test fixtures,
95
+ # truncated responses), fall back to the snippet from step 1.
96
+ articles = results.first(@num_results).filter_map do |r|
97
+ title = r['title']
98
+ snippet = (r['snippet'] || '').gsub(/<[^>]+>/, '').strip
99
+ extract_uri = URI("#{api_endpoint}?action=query&prop=extracts&exintro&explaintext&format=json&titles=#{URI.encode_www_form_component(title)}")
100
+ extract_resp = Net::HTTP.get_response(extract_uri)
101
+
102
+ extract = nil
103
+ if extract_resp.is_a?(Net::HTTPSuccess)
104
+ pages = JSON.parse(extract_resp.body).dig('query', 'pages') || {}
105
+ page = pages.values.first
106
+ extract = page&.dig('extract')&.strip
107
+ end
108
+
109
+ content = extract && !extract.empty? ? extract : snippet
110
+ next if content.nil? || content.empty?
111
+
112
+ "**#{title}**\n\n#{content}"
113
+ end
114
+
115
+ return @no_results_msg if articles.empty?
116
+ articles.join("\n\n#{'=' * 50}\n\n")
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+
123
+ SignalWire::Skills::SkillRegistry.register('wikipedia_search') do |params|
124
+ SignalWire::Skills::Builtin::WikipediaSearchSkill.new(params)
125
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+
8
+ require_relative '../swaig/function_result'
9
+ require_relative '../logging'
10
+
11
+ module SignalWire
12
+ module Skills
13
+ # Base class for all skills. Subclasses override the metadata methods
14
+ # and +register_tools+ to supply tool hashes.
15
+ class SkillBase
16
+ # Python parity:
17
+ # - ``params`` — params hash passed at construction
18
+ # - ``agent`` — owning AgentBase instance (or nil for standalone)
19
+ # - ``logger`` — namespaced logger ``signalwire.skills.<name>``
20
+ # - ``swaig_fields`` — extra SWAIG fields merged into tool defs;
21
+ # pulled out of ``params`` if provided
22
+ attr_reader :params, :agent, :logger, :swaig_fields
23
+
24
+ def name; raise NotImplementedError, "#{self.class}#name"; end
25
+ def description; raise NotImplementedError, "#{self.class}#description"; end
26
+ def version; '1.0.0'; end
27
+ def required_env_vars; []; end
28
+ def supports_multiple_instances?; false; end
29
+
30
+ # Python parity: ``SkillBase.__init__(self, agent, params=None)``.
31
+ # First positional arg is the owning AgentBase (or nil for
32
+ # standalone). The second is the params hash. We accept the legacy
33
+ # 1-arg form for backwards compatibility (``DateTimeSkill.new({...})``).
34
+ def initialize(agent = nil, params = nil)
35
+ # Backwards compat: a single Hash means params-only (no agent).
36
+ if agent.is_a?(Hash) && params.nil?
37
+ params = agent
38
+ agent = nil
39
+ end
40
+ @agent = agent
41
+ @params = (params || {}).transform_keys(&:to_s)
42
+ # Python: pop swaig_fields out of params for separate access.
43
+ @swaig_fields = @params.delete('swaig_fields') || {}
44
+ @logger = ::SignalWire::Logging.logger("signalwire.skills.#{begin
45
+ name
46
+ rescue NotImplementedError
47
+ self.class.name
48
+ end}")
49
+ end
50
+
51
+ # Called once after construction. Return +true+ if the skill is ready.
52
+ def setup; true; end
53
+
54
+ # Return an Array of tool definition hashes. Each hash should have:
55
+ # :name, :description, :parameters, :handler (lambda/proc)
56
+ def register_tools; []; end
57
+
58
+ # Speech recognition hints.
59
+ def get_hints; []; end
60
+
61
+ # Global data to merge into the agent.
62
+ def get_global_data; {}; end
63
+
64
+ # Prompt sections to add to the agent.
65
+ def get_prompt_sections; []; end
66
+
67
+ # Called when the skill is unloaded.
68
+ def cleanup; end
69
+
70
+ # Unique key for tracking this skill instance.
71
+ def instance_key; name; end
72
+
73
+ # Parameter schema for GUI / validation.
74
+ def get_parameter_schema; {}; end
75
+
76
+ # Helper to get a param with env-var fallback.
77
+ def get_param(key, env_var: nil, default: nil)
78
+ @params[key.to_s] || @params[key.to_sym.to_s] || (env_var && ENV[env_var]) || default
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright (c) 2025 SignalWire
4
+ #
5
+ # Licensed under the MIT License.
6
+ # See LICENSE file in the project root for full license information.
7
+
8
+ require 'thread'
9
+ require_relative 'skill_base'
10
+ require_relative '../logging'
11
+
12
+ module SignalWire
13
+ module Skills
14
+ # Thread-safe lifecycle manager for loaded skill instances.
15
+ #
16
+ # manager = SkillManager.new
17
+ # manager.load('datetime', DateTimeSkill.new)
18
+ # manager.get('datetime') #=> <DateTimeSkill>
19
+ # manager.unload('datetime')
20
+ #
21
+ class SkillManager
22
+ # Python parity:
23
+ # - ``agent`` — owning AgentBase instance (or nil)
24
+ # - ``logger`` — namespaced logger
25
+ attr_reader :agent, :logger
26
+
27
+ # Python parity: ``SkillManager.__init__(self, agent)`` —
28
+ # SkillManager keeps a back-pointer to its agent so loaded
29
+ # skills can attach prompt sections / SWAIG tools directly.
30
+ # Ruby allows nil for standalone use (tests, registry tools).
31
+ def initialize(agent = nil)
32
+ @agent = agent
33
+ @skills = {} # instance_key => SkillBase instance
34
+ @mutex = Mutex.new
35
+ @logger = ::SignalWire::Logging.logger('signalwire.skill_manager')
36
+ end
37
+
38
+ # Load a skill instance. Calls +setup+ on the skill; raises if it fails.
39
+ # @param key [String] the instance key
40
+ # @param skill [SkillBase] the skill instance
41
+ # @return [SkillBase] the loaded skill
42
+ def load(key, skill)
43
+ @mutex.synchronize do
44
+ raise ArgumentError, "Skill already loaded: #{key}" if @skills.key?(key)
45
+
46
+ unless skill.setup
47
+ raise "Skill setup failed for '#{key}'"
48
+ end
49
+
50
+ @skills[key] = skill
51
+ end
52
+ skill
53
+ end
54
+
55
+ # Unload a skill by instance key. Calls +cleanup+ on it.
56
+ # @param key [String]
57
+ # @return [SkillBase, nil] the removed skill, or nil
58
+ def unload(key)
59
+ @mutex.synchronize do
60
+ skill = @skills.delete(key)
61
+ skill&.cleanup
62
+ skill
63
+ end
64
+ end
65
+
66
+ # Retrieve a loaded skill.
67
+ # @param key [String]
68
+ # @return [SkillBase, nil]
69
+ def get(key)
70
+ @mutex.synchronize { @skills[key] }
71
+ end
72
+
73
+ # @return [Boolean]
74
+ def loaded?(key)
75
+ @mutex.synchronize { @skills.key?(key) }
76
+ end
77
+
78
+ # @return [Array<String>]
79
+ def loaded_keys
80
+ @mutex.synchronize { @skills.keys.dup }
81
+ end
82
+
83
+ # @return [Integer]
84
+ def size
85
+ @mutex.synchronize { @skills.size }
86
+ end
87
+
88
+ # Unload all skills.
89
+ def clear
90
+ @mutex.synchronize do
91
+ @skills.each_value(&:cleanup)
92
+ @skills.clear
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end