signalwire_agents 1.1.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 (72) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +79 -0
  3. data/bin/swaig-test +310 -0
  4. data/lib/signalwire_agents/agent/agent_base.rb +1171 -0
  5. data/lib/signalwire_agents/contexts/context_builder.rb +622 -0
  6. data/lib/signalwire_agents/datamap/data_map.rb +279 -0
  7. data/lib/signalwire_agents/logging.rb +92 -0
  8. data/lib/signalwire_agents/prefabs/concierge.rb +92 -0
  9. data/lib/signalwire_agents/prefabs/faq_bot.rb +67 -0
  10. data/lib/signalwire_agents/prefabs/info_gatherer.rb +79 -0
  11. data/lib/signalwire_agents/prefabs/receptionist.rb +74 -0
  12. data/lib/signalwire_agents/prefabs/survey.rb +75 -0
  13. data/lib/signalwire_agents/relay/action.rb +291 -0
  14. data/lib/signalwire_agents/relay/call.rb +523 -0
  15. data/lib/signalwire_agents/relay/client.rb +671 -0
  16. data/lib/signalwire_agents/relay/constants.rb +124 -0
  17. data/lib/signalwire_agents/relay/message.rb +137 -0
  18. data/lib/signalwire_agents/relay/relay_event.rb +670 -0
  19. data/lib/signalwire_agents/rest/http_client.rb +147 -0
  20. data/lib/signalwire_agents/rest/namespaces/addresses.rb +19 -0
  21. data/lib/signalwire_agents/rest/namespaces/calling.rb +179 -0
  22. data/lib/signalwire_agents/rest/namespaces/chat.rb +18 -0
  23. data/lib/signalwire_agents/rest/namespaces/compat.rb +229 -0
  24. data/lib/signalwire_agents/rest/namespaces/datasphere.rb +39 -0
  25. data/lib/signalwire_agents/rest/namespaces/fabric.rb +175 -0
  26. data/lib/signalwire_agents/rest/namespaces/imported_numbers.rb +18 -0
  27. data/lib/signalwire_agents/rest/namespaces/logs.rb +46 -0
  28. data/lib/signalwire_agents/rest/namespaces/lookup.rb +18 -0
  29. data/lib/signalwire_agents/rest/namespaces/mfa.rb +26 -0
  30. data/lib/signalwire_agents/rest/namespaces/number_groups.rb +32 -0
  31. data/lib/signalwire_agents/rest/namespaces/phone_numbers.rb +20 -0
  32. data/lib/signalwire_agents/rest/namespaces/project.rb +33 -0
  33. data/lib/signalwire_agents/rest/namespaces/pubsub.rb +18 -0
  34. data/lib/signalwire_agents/rest/namespaces/queues.rb +28 -0
  35. data/lib/signalwire_agents/rest/namespaces/recordings.rb +18 -0
  36. data/lib/signalwire_agents/rest/namespaces/registry.rb +67 -0
  37. data/lib/signalwire_agents/rest/namespaces/short_codes.rb +26 -0
  38. data/lib/signalwire_agents/rest/namespaces/sip_profile.rb +22 -0
  39. data/lib/signalwire_agents/rest/namespaces/verified_callers.rb +24 -0
  40. data/lib/signalwire_agents/rest/namespaces/video.rb +129 -0
  41. data/lib/signalwire_agents/rest/signalwire_client.rb +110 -0
  42. data/lib/signalwire_agents/security/session_manager.rb +124 -0
  43. data/lib/signalwire_agents/server/agent_server.rb +260 -0
  44. data/lib/signalwire_agents/skills/builtin/api_ninjas_trivia.rb +91 -0
  45. data/lib/signalwire_agents/skills/builtin/claude_skills.rb +92 -0
  46. data/lib/signalwire_agents/skills/builtin/custom_skills.rb +54 -0
  47. data/lib/signalwire_agents/skills/builtin/datasphere.rb +141 -0
  48. data/lib/signalwire_agents/skills/builtin/datasphere_serverless.rb +107 -0
  49. data/lib/signalwire_agents/skills/builtin/datetime.rb +97 -0
  50. data/lib/signalwire_agents/skills/builtin/google_maps.rb +168 -0
  51. data/lib/signalwire_agents/skills/builtin/info_gatherer.rb +189 -0
  52. data/lib/signalwire_agents/skills/builtin/joke.rb +65 -0
  53. data/lib/signalwire_agents/skills/builtin/math.rb +176 -0
  54. data/lib/signalwire_agents/skills/builtin/mcp_gateway.rb +121 -0
  55. data/lib/signalwire_agents/skills/builtin/native_vector_search.rb +116 -0
  56. data/lib/signalwire_agents/skills/builtin/play_background_file.rb +86 -0
  57. data/lib/signalwire_agents/skills/builtin/spider.rb +142 -0
  58. data/lib/signalwire_agents/skills/builtin/swml_transfer.rb +118 -0
  59. data/lib/signalwire_agents/skills/builtin/weather_api.rb +85 -0
  60. data/lib/signalwire_agents/skills/builtin/web_search.rb +123 -0
  61. data/lib/signalwire_agents/skills/builtin/wikipedia_search.rb +109 -0
  62. data/lib/signalwire_agents/skills/skill_base.rb +58 -0
  63. data/lib/signalwire_agents/skills/skill_manager.rb +85 -0
  64. data/lib/signalwire_agents/skills/skill_registry.rb +76 -0
  65. data/lib/signalwire_agents/swaig/function_result.rb +777 -0
  66. data/lib/signalwire_agents/swml/document.rb +84 -0
  67. data/lib/signalwire_agents/swml/schema.json +12250 -0
  68. data/lib/signalwire_agents/swml/schema.rb +81 -0
  69. data/lib/signalwire_agents/swml/service.rb +304 -0
  70. data/lib/signalwire_agents/version.rb +5 -0
  71. data/lib/signalwire_agents.rb +19 -0
  72. metadata +212 -0
@@ -0,0 +1,279 @@
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
+
10
+ module SignalWireAgents
11
+ # Fluent builder for server-side DataMap tools.
12
+ #
13
+ # DataMap tools execute on SignalWire servers without requiring webhook
14
+ # endpoints. This class provides a chainable API for building data_map
15
+ # configurations that become SWAIG function definitions.
16
+ #
17
+ # All mutator methods return +self+ so calls can be chained:
18
+ #
19
+ # dm = DataMap.new('get_weather')
20
+ # .purpose('Get current weather')
21
+ # .parameter('location', 'string', 'City name', required: true)
22
+ # .webhook('GET', 'https://api.weather.com/v1/current?q=${location}')
23
+ # .output(Swaig::FunctionResult.new('Weather: ${response.current.temp_f}F'))
24
+ #
25
+ class DataMap
26
+ attr_reader :function_name
27
+
28
+ def initialize(function_name)
29
+ @function_name = function_name
30
+ @purpose_text = ''
31
+ @parameters = {} # name => { "type" => ..., "description" => ... }
32
+ @required_params = []
33
+ @expressions = []
34
+ @webhooks = []
35
+ @fallback_output = nil
36
+ @global_error_keys = []
37
+ end
38
+
39
+ # Set the function description / purpose.
40
+ def purpose(desc)
41
+ @purpose_text = desc
42
+ self
43
+ end
44
+
45
+ # Alias for +purpose+.
46
+ def description(desc)
47
+ purpose(desc)
48
+ end
49
+
50
+ # Add a typed parameter to the function signature.
51
+ #
52
+ # @param name [String]
53
+ # @param type [String] JSON-Schema type (string, number, boolean, array, object)
54
+ # @param desc [String] Human-readable description
55
+ # @param required [Boolean] whether the parameter is required
56
+ # @param enum [Array<String>, nil] optional list of allowed values
57
+ def parameter(name, type, desc, required: false, enum: nil)
58
+ param_def = { "type" => type, "description" => desc }
59
+ param_def["enum"] = enum if enum && !enum.empty?
60
+ @parameters[name] = param_def
61
+ @required_params << name if required && !@required_params.include?(name)
62
+ self
63
+ end
64
+
65
+ # Add an expression (pattern-matching rule).
66
+ #
67
+ # @param test_value [String] template string to test, e.g. "${args.command}"
68
+ # @param pattern [String, Regexp] regex pattern to match against
69
+ # @param output [Swaig::FunctionResult, Hash] result when pattern matches
70
+ # @param nomatch_output [Swaig::FunctionResult, Hash, nil] result when pattern does not match
71
+ def expression(test_value, pattern, output, nomatch_output: nil)
72
+ pattern_str = pattern.is_a?(Regexp) ? pattern.source : pattern.to_s
73
+ output_h = output.respond_to?(:to_h) ? output.to_h : output
74
+
75
+ expr_def = {
76
+ "string" => test_value,
77
+ "pattern" => pattern_str,
78
+ "output" => output_h
79
+ }
80
+
81
+ if nomatch_output
82
+ nomatch_h = nomatch_output.respond_to?(:to_h) ? nomatch_output.to_h : nomatch_output
83
+ expr_def["nomatch-output"] = nomatch_h
84
+ end
85
+
86
+ @expressions << expr_def
87
+ self
88
+ end
89
+
90
+ # Add a webhook (HTTP call) to the data_map pipeline.
91
+ #
92
+ # @param method [String] HTTP method (GET, POST, PUT, DELETE, etc.)
93
+ # @param url [String] endpoint URL (may contain ${variable} substitutions)
94
+ # @param headers [Hash, nil] optional HTTP headers
95
+ # @param form_param [String, nil] send JSON body as a single form parameter
96
+ # @param input_args_as_params [Boolean] merge function arguments into params
97
+ # @param require_args [Array<String>, nil] only execute when these args are present
98
+ def webhook(method, url, headers: nil, form_param: nil, input_args_as_params: false, require_args: nil)
99
+ wh = {
100
+ "url" => url,
101
+ "method" => method.upcase
102
+ }
103
+ wh["headers"] = headers if headers
104
+ wh["form_param"] = form_param if form_param
105
+ wh["input_args_as_params"] = true if input_args_as_params
106
+ wh["require_args"] = require_args if require_args
107
+ @webhooks << wh
108
+ self
109
+ end
110
+
111
+ # Add expressions to run after the most-recently-added webhook completes.
112
+ def webhook_expressions(expressions)
113
+ raise ArgumentError, "Must add webhook before setting webhook expressions" if @webhooks.empty?
114
+
115
+ @webhooks.last["expressions"] = expressions
116
+ self
117
+ end
118
+
119
+ # Set the request body for the most-recently-added webhook (POST / PUT).
120
+ def body(data)
121
+ raise ArgumentError, "Must add webhook before setting body" if @webhooks.empty?
122
+
123
+ @webhooks.last["body"] = data
124
+ self
125
+ end
126
+
127
+ # Set request params for the most-recently-added webhook.
128
+ def params(data)
129
+ raise ArgumentError, "Must add webhook before setting params" if @webhooks.empty?
130
+
131
+ @webhooks.last["params"] = data
132
+ self
133
+ end
134
+
135
+ # Configure array processing on the most-recently-added webhook response.
136
+ #
137
+ # @param config [Hash] must include keys: input_key, output_key, append. Optional: max.
138
+ def foreach(config)
139
+ raise ArgumentError, "Must add webhook before setting foreach" if @webhooks.empty?
140
+ raise ArgumentError, "foreach config must be a Hash" unless config.is_a?(Hash)
141
+
142
+ required_keys = %w[input_key output_key append]
143
+ missing = required_keys - config.keys.map(&:to_s)
144
+ raise ArgumentError, "foreach config missing required keys: #{missing.inspect}" unless missing.empty?
145
+
146
+ @webhooks.last["foreach"] = config
147
+ self
148
+ end
149
+
150
+ # Set the output result for the most-recently-added webhook.
151
+ #
152
+ # @param result [Swaig::FunctionResult, Hash]
153
+ def output(result)
154
+ raise ArgumentError, "Must add webhook before setting output" if @webhooks.empty?
155
+
156
+ @webhooks.last["output"] = result.respond_to?(:to_h) ? result.to_h : result
157
+ self
158
+ end
159
+
160
+ # Set a fallback output used when all webhooks fail.
161
+ #
162
+ # @param result [Swaig::FunctionResult, Hash]
163
+ def fallback_output(result)
164
+ @fallback_output = result.respond_to?(:to_h) ? result.to_h : result
165
+ self
166
+ end
167
+
168
+ # Set error keys on the most-recently-added webhook, or at the top level
169
+ # if no webhook has been added yet.
170
+ def error_keys(keys)
171
+ if @webhooks.any?
172
+ @webhooks.last["error_keys"] = keys
173
+ else
174
+ @global_error_keys = keys
175
+ end
176
+ self
177
+ end
178
+
179
+ # Set top-level error keys (applies to all webhooks).
180
+ def global_error_keys(keys)
181
+ @global_error_keys = keys
182
+ self
183
+ end
184
+
185
+ # Serialize this DataMap into a complete SWAIG function definition Hash.
186
+ #
187
+ # @return [Hash] with keys: "function", "description", "parameters", "data_map"
188
+ def to_swaig_function
189
+ # Build parameter schema
190
+ if @parameters.any?
191
+ param_schema = {
192
+ "type" => "object",
193
+ "properties" => @parameters.dup
194
+ }
195
+ param_schema["required"] = @required_params.dup if @required_params.any?
196
+ else
197
+ param_schema = { "type" => "object", "properties" => {} }
198
+ end
199
+
200
+ # Build data_map
201
+ data_map = {}
202
+ data_map["expressions"] = @expressions if @expressions.any?
203
+ data_map["webhooks"] = @webhooks if @webhooks.any?
204
+ data_map["output"] = @fallback_output if @fallback_output
205
+ data_map["error_keys"] = @global_error_keys if @global_error_keys.any?
206
+
207
+ {
208
+ "function" => @function_name,
209
+ "description" => @purpose_text.empty? ? "Execute #{@function_name}" : @purpose_text,
210
+ "parameters" => param_schema,
211
+ "data_map" => data_map
212
+ }
213
+ end
214
+
215
+ # ----------------------------------------------------------------
216
+ # Class-level convenience constructors
217
+ # ----------------------------------------------------------------
218
+
219
+ # Build a simple API-calling tool in one shot.
220
+ #
221
+ # @param name [String]
222
+ # @param url [String]
223
+ # @param response_template [String]
224
+ # @param parameters [Hash, nil] name => { "type" => ..., "description" => ..., "required" => bool }
225
+ # @param method [String] HTTP method (default GET)
226
+ # @param headers [Hash, nil]
227
+ # @param body [Hash, nil]
228
+ # @param error_keys [Array<String>, nil]
229
+ # @return [DataMap]
230
+ def self.create_simple_api_tool(name:, url:, response_template:, parameters: nil,
231
+ method: 'GET', headers: nil, body: nil, error_keys: nil)
232
+ dm = new(name)
233
+
234
+ if parameters
235
+ parameters.each do |pname, pdef|
236
+ dm.parameter(
237
+ pname,
238
+ pdef.fetch("type", "string"),
239
+ pdef.fetch("description", "#{pname} parameter"),
240
+ required: pdef.fetch("required", false)
241
+ )
242
+ end
243
+ end
244
+
245
+ dm.webhook(method, url, headers: headers)
246
+ dm.body(body) if body
247
+ dm.error_keys(error_keys) if error_keys
248
+ dm.output(Swaig::FunctionResult.new(response_template))
249
+ dm
250
+ end
251
+
252
+ # Build an expression-only tool (no HTTP calls).
253
+ #
254
+ # @param name [String]
255
+ # @param patterns [Hash] test_value => [pattern, Swaig::FunctionResult]
256
+ # @param parameters [Hash, nil] same format as +create_simple_api_tool+
257
+ # @return [DataMap]
258
+ def self.create_expression_tool(name:, patterns:, parameters: nil)
259
+ dm = new(name)
260
+
261
+ if parameters
262
+ parameters.each do |pname, pdef|
263
+ dm.parameter(
264
+ pname,
265
+ pdef.fetch("type", "string"),
266
+ pdef.fetch("description", "#{pname} parameter"),
267
+ required: pdef.fetch("required", false)
268
+ )
269
+ end
270
+ end
271
+
272
+ patterns.each do |test_value, (pattern, result)|
273
+ dm.expression(test_value, pattern, result)
274
+ end
275
+
276
+ dm
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SignalWireAgents
4
+ module Logging
5
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3, off: 4 }.freeze
6
+
7
+ # Returns the current global log level, derived from:
8
+ # 1. SIGNALWIRE_LOG_MODE=off -> :off (suppresses everything)
9
+ # 2. SIGNALWIRE_LOG_LEVEL env -> the named level
10
+ # 3. Default -> :info
11
+ def self.global_level
12
+ @global_level || resolve_level_from_env
13
+ end
14
+
15
+ def self.global_level=(level)
16
+ level = level.to_sym if level.is_a?(String)
17
+ raise ArgumentError, "Unknown log level: #{level}" unless LEVELS.key?(level)
18
+
19
+ @global_level = level
20
+ end
21
+
22
+ def self.reset!
23
+ @global_level = nil
24
+ end
25
+
26
+ def self.suppressed?
27
+ global_level == :off
28
+ end
29
+
30
+ # Convenience factory
31
+ def self.logger(name)
32
+ Logger.new(name)
33
+ end
34
+
35
+ # -------------------------------------------------------------------
36
+ class Logger
37
+ attr_reader :name
38
+
39
+ def initialize(name)
40
+ @name = name
41
+ @output = $stderr
42
+ end
43
+
44
+ def debug(msg)
45
+ log(:debug, msg)
46
+ end
47
+
48
+ def info(msg)
49
+ log(:info, msg)
50
+ end
51
+
52
+ def warn(msg)
53
+ log(:warn, msg)
54
+ end
55
+
56
+ def error(msg)
57
+ log(:error, msg)
58
+ end
59
+
60
+ private
61
+
62
+ def log(level, msg)
63
+ return if Logging.suppressed?
64
+ return if LEVELS[level] < LEVELS[Logging.global_level]
65
+
66
+ timestamp = Time.now.strftime('%Y-%m-%d %H:%M:%S')
67
+ @output.puts "[#{timestamp}] #{level.upcase} [#{@name}] #{msg}"
68
+ end
69
+ end
70
+
71
+ # -------------------------------------------------------------------
72
+ # Private helpers
73
+ # -------------------------------------------------------------------
74
+ private_class_method def self.resolve_level_from_env
75
+ if ENV['SIGNALWIRE_LOG_MODE']&.downcase == 'off'
76
+ @global_level = :off
77
+ return :off
78
+ end
79
+
80
+ raw = ENV['SIGNALWIRE_LOG_LEVEL']
81
+ if raw
82
+ sym = raw.downcase.to_sym
83
+ if LEVELS.key?(sym)
84
+ @global_level = sym
85
+ return sym
86
+ end
87
+ end
88
+
89
+ :info # default — not cached so env changes take effect
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,92 @@
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
+
10
+ module SignalWireAgents
11
+ module Prefabs
12
+ # Prefab agent for providing virtual concierge services.
13
+ #
14
+ # agent = Concierge.new(
15
+ # venue_name: 'Grand Hotel',
16
+ # services: ['room service', 'spa bookings'],
17
+ # amenities: { 'pool' => { 'hours' => '7 AM - 10 PM', 'location' => '2nd Floor' } }
18
+ # )
19
+ #
20
+ class Concierge
21
+ attr_reader :venue_name, :services, :amenities, :name, :route
22
+
23
+ def initialize(venue_name:, services:, amenities:, hours_of_operation: nil,
24
+ special_instructions: nil, welcome_message: nil,
25
+ name: 'concierge', route: '/concierge', **_opts)
26
+ @venue_name = venue_name
27
+ @services = services || []
28
+ @amenities = (amenities || {}).transform_keys(&:to_s)
29
+ @hours = hours_of_operation
30
+ @instructions = special_instructions || []
31
+ @welcome = welcome_message || "Welcome to #{venue_name}! How can I assist you today?"
32
+ @name = name
33
+ @route = route
34
+ end
35
+
36
+ def tools
37
+ %w[get_amenity_info get_service_info]
38
+ end
39
+
40
+ def prompt_sections
41
+ amenity_bullets = @amenities.map { |k, v| "#{k}: #{v.is_a?(Hash) ? v.map { |a, b| "#{a}: #{b}" }.join(', ') : v}" }
42
+ service_bullets = @services.map { |s| s.to_s }
43
+
44
+ sections = [
45
+ {
46
+ 'title' => "#{@venue_name} Concierge",
47
+ 'body' => @welcome,
48
+ 'bullets' => service_bullets + amenity_bullets
49
+ }
50
+ ]
51
+
52
+ if @hours
53
+ sections << {
54
+ 'title' => 'Hours of Operation',
55
+ 'body' => @hours.is_a?(Hash) ? @hours.map { |k, v| "#{k}: #{v}" }.join('; ') : @hours.to_s
56
+ }
57
+ end
58
+
59
+ sections
60
+ end
61
+
62
+ def global_data
63
+ {
64
+ 'venue_name' => @venue_name,
65
+ 'services' => @services,
66
+ 'amenities' => @amenities
67
+ }
68
+ end
69
+
70
+ def handle_amenity_info(args, _raw_data)
71
+ amenity = (args['amenity'] || '').downcase
72
+ info = @amenities.find { |k, _v| k.downcase == amenity }&.last
73
+ if info
74
+ detail = info.is_a?(Hash) ? info.map { |k, v| "#{k}: #{v}" }.join(', ') : info.to_s
75
+ Swaig::FunctionResult.new("#{amenity.capitalize}: #{detail}")
76
+ else
77
+ Swaig::FunctionResult.new("I don't have information about '#{amenity}'. Available amenities: #{@amenities.keys.join(', ')}")
78
+ end
79
+ end
80
+
81
+ def handle_service_info(args, _raw_data)
82
+ service = (args['service'] || '').downcase
83
+ match = @services.find { |s| s.downcase.include?(service) }
84
+ if match
85
+ Swaig::FunctionResult.new("#{match} is available at #{@venue_name}.")
86
+ else
87
+ Swaig::FunctionResult.new("Available services: #{@services.join(', ')}")
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,67 @@
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
+
10
+ module SignalWireAgents
11
+ module Prefabs
12
+ # Prefab agent for answering frequently asked questions.
13
+ #
14
+ # agent = FaqBot.new(
15
+ # faqs: [
16
+ # { 'question' => 'What is SignalWire?', 'answer' => 'A cloud communications platform.' }
17
+ # ]
18
+ # )
19
+ #
20
+ class FaqBot
21
+ attr_reader :faqs, :name, :route
22
+
23
+ def initialize(faqs:, suggest_related: true, persona: nil,
24
+ name: 'faq_bot', route: '/faq', **_opts)
25
+ raise ArgumentError, 'faqs must be a non-empty Array' unless faqs.is_a?(Array) && !faqs.empty?
26
+
27
+ @faqs = faqs.map { |f| f.transform_keys(&:to_s) }
28
+ @suggest_related = suggest_related
29
+ @persona = persona || 'You are a helpful FAQ bot that provides accurate answers to common questions.'
30
+ @name = name
31
+ @route = route
32
+ end
33
+
34
+ def tools
35
+ %w[search_faq]
36
+ end
37
+
38
+ def prompt_sections
39
+ bullets = @faqs.map { |f| "Q: #{f['question']}" }
40
+ [
41
+ {
42
+ 'title' => 'FAQ Bot',
43
+ 'body' => @persona,
44
+ 'bullets' => bullets
45
+ }
46
+ ]
47
+ end
48
+
49
+ def global_data
50
+ {
51
+ 'faqs' => @faqs,
52
+ 'suggest_related' => @suggest_related
53
+ }
54
+ end
55
+
56
+ def handle_search(args, _raw_data)
57
+ query = (args['query'] || '').downcase
58
+ match = @faqs.find { |f| f['question'].downcase.include?(query) || query.include?(f['question'].downcase) }
59
+ if match
60
+ Swaig::FunctionResult.new(match['answer'])
61
+ else
62
+ Swaig::FunctionResult.new("I don't have a specific answer for that. Here are the topics I can help with: #{@faqs.map { |f| f['question'] }.join('; ')}")
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,79 @@
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
+
10
+ module SignalWireAgents
11
+ module Prefabs
12
+ # Prefab agent for collecting answers to a series of questions.
13
+ #
14
+ # agent = InfoGatherer.new(
15
+ # questions: [
16
+ # { 'key_name' => 'full_name', 'question_text' => 'What is your full name?' },
17
+ # { 'key_name' => 'email', 'question_text' => 'Email?', 'confirm' => true }
18
+ # ]
19
+ # )
20
+ #
21
+ class InfoGatherer
22
+ attr_reader :questions, :name, :route
23
+
24
+ def initialize(questions:, name: 'info_gatherer', route: '/info_gatherer', **_opts)
25
+ raise ArgumentError, 'questions must be a non-empty Array' unless questions.is_a?(Array) && !questions.empty?
26
+ questions.each_with_index do |q, i|
27
+ raise ArgumentError, "Question #{i} missing key_name" unless q['key_name'] || q[:key_name]
28
+ raise ArgumentError, "Question #{i} missing question_text" unless q['question_text'] || q[:question_text]
29
+ end
30
+
31
+ @questions = questions.map { |q| q.transform_keys(&:to_s) }
32
+ @name = name
33
+ @route = route
34
+ end
35
+
36
+ # Tool definitions this prefab provides.
37
+ def tools
38
+ %w[start_questions submit_answer]
39
+ end
40
+
41
+ # Build the prompt sections.
42
+ def prompt_sections
43
+ [
44
+ {
45
+ 'title' => 'Info Gatherer',
46
+ 'body' => 'You need to gather answers to a series of questions. ' \
47
+ 'Call start_questions to get the first question, then submit_answer after each response.'
48
+ }
49
+ ]
50
+ end
51
+
52
+ # Global data for initial state.
53
+ def global_data
54
+ {
55
+ 'info_gatherer' => {
56
+ 'questions' => @questions,
57
+ 'question_index' => 0,
58
+ 'answers' => []
59
+ }
60
+ }
61
+ end
62
+
63
+ # Tool handler: start_questions
64
+ def handle_start(_args, _raw_data)
65
+ q = @questions.first
66
+ Swaig::FunctionResult.new(
67
+ "[Question 1 of #{@questions.size}]: \"#{q['question_text']}\""
68
+ )
69
+ end
70
+
71
+ # Tool handler: submit_answer
72
+ def handle_submit(args, _raw_data)
73
+ answer = args['answer'] || ''
74
+ # In a real implementation, state would be tracked via global_data.
75
+ Swaig::FunctionResult.new("Answer recorded: #{answer}")
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,74 @@
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
+
10
+ module SignalWireAgents
11
+ module Prefabs
12
+ # Prefab agent for greeting callers and transferring them to departments.
13
+ #
14
+ # agent = Receptionist.new(
15
+ # departments: [
16
+ # { 'name' => 'sales', 'description' => 'Product inquiries', 'number' => '+15551235555' },
17
+ # { 'name' => 'support', 'description' => 'Technical help', 'number' => '+15551236666' }
18
+ # ]
19
+ # )
20
+ #
21
+ class Receptionist
22
+ attr_reader :departments, :name, :route, :greeting
23
+
24
+ def initialize(departments:, name: 'receptionist', route: '/receptionist',
25
+ greeting: 'Thank you for calling. How can I help you today?', **_opts)
26
+ raise ArgumentError, 'departments must be a non-empty Array' unless departments.is_a?(Array) && !departments.empty?
27
+ departments.each_with_index do |d, i|
28
+ d = d.transform_keys(&:to_s)
29
+ raise ArgumentError, "Department #{i} missing 'name'" unless d['name']
30
+ raise ArgumentError, "Department #{i} missing 'number'" unless d['number']
31
+ end
32
+
33
+ @departments = departments.map { |d| d.transform_keys(&:to_s) }
34
+ @greeting = greeting
35
+ @name = name
36
+ @route = route
37
+ end
38
+
39
+ def tools
40
+ %w[transfer_to_department collect_caller_info]
41
+ end
42
+
43
+ def prompt_sections
44
+ bullets = @departments.map { |d| "#{d['name']}: #{d['description'] || d['name']} (#{d['number']})" }
45
+ [
46
+ {
47
+ 'title' => 'Receptionist',
48
+ 'body' => @greeting,
49
+ 'bullets' => bullets
50
+ }
51
+ ]
52
+ end
53
+
54
+ def global_data
55
+ {
56
+ 'departments' => @departments,
57
+ 'caller_info' => {}
58
+ }
59
+ end
60
+
61
+ def handle_transfer(args, _raw_data)
62
+ dept_name = args['department']
63
+ dept = @departments.find { |d| d['name'] == dept_name }
64
+ if dept
65
+ result = Swaig::FunctionResult.new("Transferring you to #{dept_name} now.")
66
+ result.connect(dept['number'])
67
+ result
68
+ else
69
+ Swaig::FunctionResult.new("I couldn't find that department. Available departments: #{@departments.map { |d| d['name'] }.join(', ')}")
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end