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,1171 @@
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 'json'
9
+ require 'securerandom'
10
+ require 'openssl'
11
+ require 'rack'
12
+ require 'uri'
13
+ require_relative '../logging'
14
+ require_relative '../swml/document'
15
+ require_relative '../swml/schema'
16
+ require_relative '../swml/service'
17
+ require_relative '../swaig/function_result'
18
+ require_relative '../security/session_manager'
19
+ require_relative '../contexts/context_builder'
20
+ require_relative '../skills/skill_base'
21
+ require_relative '../skills/skill_manager'
22
+ require_relative '../skills/skill_registry'
23
+
24
+ module SignalWireAgents
25
+ # Central agent class that composes SWML rendering, tool dispatch,
26
+ # prompt management, AI config, and HTTP serving.
27
+ #
28
+ # AgentBase extends SWMLService with agent-specific capabilities:
29
+ # - Prompt management (POM sections and raw text)
30
+ # - Tool (SWAIG function) registration & dispatch
31
+ # - AI configuration (hints, languages, pronunciations, params)
32
+ # - Verb management (pre/post answer, post-AI)
33
+ # - Context & step workflows
34
+ # - Skill integration
35
+ # - Dynamic configuration via per-request ephemeral copies
36
+ #
37
+ # All configuration methods return +self+ for method chaining.
38
+ class AgentBase
39
+ attr_reader :name, :route, :host, :port, :logger
40
+
41
+ # Maximum request body size (1 MB)
42
+ MAX_BODY_SIZE = 1_048_576
43
+
44
+ # ------------------------------------------------------------------
45
+ # Construction
46
+ # ------------------------------------------------------------------
47
+
48
+ def initialize(name: 'agent', route: '/', host: '0.0.0.0', port: nil,
49
+ basic_auth: nil, auto_answer: true, record_call: false,
50
+ record_format: 'mp4', record_stereo: true,
51
+ token_expiry_secs: 3600)
52
+ @name = name
53
+ @route = route.to_s.chomp('/')
54
+ @route = '/' if @route.empty?
55
+ @host = host
56
+ @port = port || Integer(ENV.fetch('PORT', 3000))
57
+ @logger = Logging.logger("AgentBase[#{name}]")
58
+
59
+ # --- auth ---------------------------------------------------------
60
+ @basic_auth = if basic_auth
61
+ basic_auth
62
+ elsif ENV['SWML_BASIC_AUTH_USER'] && ENV['SWML_BASIC_AUTH_PASSWORD']
63
+ [ENV['SWML_BASIC_AUTH_USER'], ENV['SWML_BASIC_AUTH_PASSWORD']]
64
+ else
65
+ [SecureRandom.uuid, SecureRandom.uuid]
66
+ end
67
+
68
+ # --- call settings ------------------------------------------------
69
+ @auto_answer = auto_answer
70
+ @record_call = record_call
71
+ @record_format = record_format
72
+ @record_stereo = record_stereo
73
+
74
+ # --- session manager ----------------------------------------------
75
+ @session_manager = Security::SessionManager.new(token_expiry_secs: token_expiry_secs)
76
+
77
+ # --- prompt state -------------------------------------------------
78
+ @prompt_text = nil # raw text mode
79
+ @prompt_pom = nil # direct POM array
80
+ @pom_sections = [] # built via prompt_add_section
81
+ @post_prompt_text = nil
82
+
83
+ # --- tools --------------------------------------------------------
84
+ @tools = {} # name => { definition + handler }
85
+ @swaig_functions = {} # name => raw hash (DataMap etc.)
86
+
87
+ # --- AI config ----------------------------------------------------
88
+ @hints = []
89
+ @languages = []
90
+ @pronounce = []
91
+ @params = {}
92
+ @global_data = {}
93
+ @native_functions = []
94
+ @function_includes = []
95
+ @internal_fillers = {}
96
+ @prompt_llm_params = {}
97
+ @post_prompt_llm_params = {}
98
+
99
+ # --- debug --------------------------------------------------------
100
+ @debug_events_enabled = false
101
+ @debug_events_level = 1
102
+ @debug_event_callback = nil
103
+
104
+ # --- verbs --------------------------------------------------------
105
+ @pre_answer_verbs = [] # [[verb_name, config], ...]
106
+ @answer_config = {}
107
+ @post_answer_verbs = []
108
+ @post_ai_verbs = []
109
+
110
+ # --- contexts -----------------------------------------------------
111
+ @context_builder = nil
112
+
113
+ # --- skills -------------------------------------------------------
114
+ @skill_manager = Skills::SkillManager.new
115
+ @loaded_skills = {} # skill_name => SkillBase
116
+
117
+ # --- web ----------------------------------------------------------
118
+ @dynamic_config_callback = nil
119
+ @proxy_url_base = ENV['SWML_PROXY_URL_BASE']
120
+ @web_hook_url_override = nil
121
+ @post_prompt_url_override = nil
122
+ @swaig_query_params = {}
123
+ @debug_routes_enabled = false
124
+ @summary_callback = nil
125
+
126
+ # --- SIP ----------------------------------------------------------
127
+ @sip_routing_enabled = false
128
+ @sip_auto_map = false
129
+ @sip_path = '/sip'
130
+ @sip_usernames = []
131
+
132
+ @logger.info "Agent '#{@name}' initialised (route=#{@route}, port=#{@port})"
133
+ end
134
+
135
+ # ==================================================================
136
+ # Prompt methods
137
+ # ==================================================================
138
+
139
+ # Set prompt as raw text. Clears any POM state.
140
+ def set_prompt_text(text)
141
+ @prompt_text = text
142
+ @pom_sections = []
143
+ @prompt_pom = nil
144
+ self
145
+ end
146
+
147
+ # Set post-prompt text.
148
+ def set_post_prompt(text)
149
+ @post_prompt_text = text
150
+ self
151
+ end
152
+
153
+ # Set POM array directly.
154
+ def set_prompt_pom(pom)
155
+ @prompt_pom = pom
156
+ @prompt_text = nil
157
+ @pom_sections = []
158
+ self
159
+ end
160
+
161
+ # Add a POM section.
162
+ def prompt_add_section(title, body = nil, bullets: nil)
163
+ @prompt_text = nil
164
+ @prompt_pom = nil
165
+ section = { 'title' => title }
166
+ section['body'] = body if body
167
+ section['bullets'] = bullets if bullets
168
+ @pom_sections << section
169
+ self
170
+ end
171
+
172
+ # Append text to an existing POM section's body.
173
+ def prompt_add_to_section(title, text)
174
+ sec = @pom_sections.find { |s| s['title'] == title }
175
+ if sec
176
+ sec['body'] = (sec['body'] || '') + text
177
+ end
178
+ self
179
+ end
180
+
181
+ # Add a subsection under a parent section.
182
+ def prompt_add_subsection(parent_title, title, body = nil, bullets: nil)
183
+ parent = @pom_sections.find { |s| s['title'] == parent_title }
184
+ if parent
185
+ parent['subsections'] ||= []
186
+ sub = { 'title' => title }
187
+ sub['body'] = body if body
188
+ sub['bullets'] = bullets if bullets
189
+ parent['subsections'] << sub
190
+ end
191
+ self
192
+ end
193
+
194
+ # Check whether a POM section with the given title exists.
195
+ def prompt_has_section?(title)
196
+ @pom_sections.any? { |s| s['title'] == title }
197
+ end
198
+
199
+ # Return the current prompt: either a string (text mode) or an array (POM).
200
+ def get_prompt
201
+ return @prompt_text if @prompt_text
202
+ return @prompt_pom if @prompt_pom
203
+ return @pom_sections.dup unless @pom_sections.empty?
204
+ nil
205
+ end
206
+
207
+ # ==================================================================
208
+ # Tool methods
209
+ # ==================================================================
210
+
211
+ # Define a tool with a block handler.
212
+ #
213
+ # @param name [String]
214
+ # @param description [String]
215
+ # @param parameters [Hash] JSON-Schema of parameters
216
+ # @param secure [Boolean]
217
+ # @param fillers [Hash, nil] language_code => [phrases]
218
+ # @param swaig_fields [Hash, nil] extra fields merged into definition
219
+ # @yield [args, raw_data] the tool handler
220
+ def define_tool(name:, description:, parameters: {}, secure: false,
221
+ fillers: nil, swaig_fields: nil, &handler)
222
+ # Normalise parameters into JSON-Schema form
223
+ param_schema = _normalise_parameters(parameters)
224
+
225
+ tool_def = {
226
+ 'function' => name,
227
+ 'description' => description,
228
+ 'parameters' => param_schema
229
+ }
230
+ tool_def['fillers'] = fillers if fillers && !fillers.empty?
231
+
232
+ # Merge extra swaig fields
233
+ if swaig_fields.is_a?(Hash)
234
+ swaig_fields.each { |k, v| tool_def[k.to_s] = v }
235
+ end
236
+
237
+ @tools[name] = {
238
+ definition: tool_def,
239
+ handler: handler,
240
+ secure: secure
241
+ }
242
+ self
243
+ end
244
+
245
+ # Register a raw SWAIG function definition (e.g. from DataMap#to_swaig_function).
246
+ def register_swaig_function(func_def)
247
+ fname = func_def['function'] || func_def[:function]
248
+ return self unless fname
249
+ @swaig_functions[fname] = func_def.transform_keys(&:to_s)
250
+ self
251
+ end
252
+
253
+ # Return an array of all tool definitions (for SWML rendering).
254
+ def define_tools
255
+ defs = @tools.values.map { |t| t[:definition].dup }
256
+ defs + @swaig_functions.values.map(&:dup)
257
+ end
258
+
259
+ # Dispatch a function call to the registered handler.
260
+ def on_function_call(name, args, raw_data)
261
+ tool = @tools[name]
262
+ unless tool
263
+ return { 'response' => "Function '#{name}' not found" }
264
+ end
265
+
266
+ # Validate secure token if needed
267
+ if tool[:secure]
268
+ call_id = raw_data && (raw_data['call_id'] || (raw_data['call'] && raw_data['call']['call_id']))
269
+ token = raw_data && raw_data['meta_data_token']
270
+ if call_id && token
271
+ unless @session_manager.validate_token(name, token, call_id)
272
+ return { 'response' => 'Invalid or expired token' }
273
+ end
274
+ end
275
+ end
276
+
277
+ result = tool[:handler].call(args, raw_data)
278
+ if result.respond_to?(:to_h)
279
+ result.to_h
280
+ elsif result.is_a?(Hash)
281
+ result
282
+ else
283
+ { 'response' => result.to_s }
284
+ end
285
+ rescue => e
286
+ @logger.error "Tool '#{name}' error: #{e.message}"
287
+ { 'response' => "Error executing '#{name}': #{e.message}" }
288
+ end
289
+
290
+ # ==================================================================
291
+ # AI Config methods
292
+ # ==================================================================
293
+
294
+ def add_hint(hint)
295
+ @hints << hint if hint.is_a?(String) && !hint.empty?
296
+ self
297
+ end
298
+
299
+ def add_hints(hints)
300
+ if hints.is_a?(Array)
301
+ hints.each { |h| add_hint(h) }
302
+ end
303
+ self
304
+ end
305
+
306
+ def add_pattern_hint(pattern, hint: nil, language: 'en-US')
307
+ entry = { 'pattern' => pattern }
308
+ entry['hint'] = hint if hint
309
+ entry['language'] = language if language
310
+ @hints << entry
311
+ self
312
+ end
313
+
314
+ def add_language(config)
315
+ @languages << config if config.is_a?(Hash)
316
+ self
317
+ end
318
+
319
+ def set_languages(languages)
320
+ @languages = languages.dup if languages.is_a?(Array)
321
+ self
322
+ end
323
+
324
+ def add_pronunciation(phrase, pronunciation, language_code: 'en-US')
325
+ rule = { 'replace' => phrase, 'with' => pronunciation }
326
+ rule['ignore_case'] = false
327
+ @pronounce << rule
328
+ self
329
+ end
330
+
331
+ def set_pronunciations(pronunciations)
332
+ @pronounce = pronunciations.dup if pronunciations.is_a?(Array)
333
+ self
334
+ end
335
+
336
+ def set_param(key, value)
337
+ @params[key.to_s] = value
338
+ self
339
+ end
340
+
341
+ def set_params(params)
342
+ if params.is_a?(Hash)
343
+ params.each { |k, v| @params[k.to_s] = v }
344
+ end
345
+ self
346
+ end
347
+
348
+ def set_global_data(data)
349
+ @global_data.merge!(data) if data.is_a?(Hash)
350
+ self
351
+ end
352
+
353
+ def update_global_data(data)
354
+ set_global_data(data)
355
+ end
356
+
357
+ def set_native_functions(names)
358
+ @native_functions = names.dup if names.is_a?(Array)
359
+ self
360
+ end
361
+
362
+ def set_internal_fillers(fillers)
363
+ @internal_fillers.merge!(fillers) if fillers.is_a?(Hash)
364
+ self
365
+ end
366
+
367
+ def add_internal_filler(func_name, lang_code, fillers)
368
+ if func_name && lang_code && fillers.is_a?(Array) && !fillers.empty?
369
+ @internal_fillers[func_name] ||= {}
370
+ @internal_fillers[func_name][lang_code] = fillers
371
+ end
372
+ self
373
+ end
374
+
375
+ def enable_debug_events(level = 1)
376
+ @debug_events_enabled = true
377
+ @debug_events_level = level
378
+ self
379
+ end
380
+
381
+ def add_function_include(url, functions, meta_data: nil)
382
+ include = { 'url' => url, 'functions' => functions }
383
+ include['meta_data'] = meta_data if meta_data.is_a?(Hash)
384
+ @function_includes << include
385
+ self
386
+ end
387
+
388
+ def set_function_includes(includes)
389
+ @function_includes = includes.dup if includes.is_a?(Array)
390
+ self
391
+ end
392
+
393
+ def set_prompt_llm_params(**params)
394
+ @prompt_llm_params.merge!(params.transform_keys(&:to_s))
395
+ self
396
+ end
397
+
398
+ def set_post_prompt_llm_params(**params)
399
+ @post_prompt_llm_params.merge!(params.transform_keys(&:to_s))
400
+ self
401
+ end
402
+
403
+ # ==================================================================
404
+ # Verb management
405
+ # ==================================================================
406
+
407
+ def add_pre_answer_verb(verb_name, config)
408
+ @pre_answer_verbs << [verb_name.to_s, config]
409
+ self
410
+ end
411
+
412
+ def clear_pre_answer_verbs
413
+ @pre_answer_verbs = []
414
+ self
415
+ end
416
+
417
+ def add_answer_verb(config)
418
+ @answer_config = config
419
+ self
420
+ end
421
+
422
+ def add_post_answer_verb(verb_name, config)
423
+ @post_answer_verbs << [verb_name.to_s, config]
424
+ self
425
+ end
426
+
427
+ def clear_post_answer_verbs
428
+ @post_answer_verbs = []
429
+ self
430
+ end
431
+
432
+ def add_post_ai_verb(verb_name, config)
433
+ @post_ai_verbs << [verb_name.to_s, config]
434
+ self
435
+ end
436
+
437
+ def clear_post_ai_verbs
438
+ @post_ai_verbs = []
439
+ self
440
+ end
441
+
442
+ # ==================================================================
443
+ # Contexts
444
+ # ==================================================================
445
+
446
+ # Returns the ContextBuilder, creating one lazily.
447
+ def define_contexts
448
+ @context_builder ||= Contexts::ContextBuilder.new
449
+ end
450
+
451
+ alias contexts define_contexts
452
+
453
+ # ==================================================================
454
+ # Skill integration
455
+ # ==================================================================
456
+
457
+ # Load and register a skill by name.
458
+ def add_skill(skill_name, params = {})
459
+ # Ensure builtins are registered
460
+ Skills::SkillRegistry.register_builtins!
461
+
462
+ factory = Skills::SkillRegistry.get_factory(skill_name)
463
+ raise ArgumentError, "Unknown skill: '#{skill_name}'" unless factory
464
+
465
+ skill = factory.call(params)
466
+ @skill_manager.load(skill.instance_key, skill)
467
+ @loaded_skills[skill_name] = skill
468
+
469
+ # Register tools from the skill
470
+ tool_defs = skill.register_tools
471
+ if tool_defs.is_a?(Array)
472
+ tool_defs.each do |td|
473
+ td_name = td[:name] || td['name']
474
+ td_desc = td[:description] || td['description']
475
+ td_params = td[:parameters] || td['parameters'] || {}
476
+ td_handler = td[:handler] || td['handler']
477
+ next unless td_name && td_handler
478
+
479
+ define_tool(
480
+ name: td_name,
481
+ description: td_desc || '',
482
+ parameters: td_params,
483
+ &td_handler
484
+ )
485
+ end
486
+ end
487
+
488
+ # Merge hints
489
+ skill_hints = skill.get_hints
490
+ @hints.concat(skill_hints) if skill_hints.is_a?(Array) && !skill_hints.empty?
491
+
492
+ # Merge global data
493
+ skill_data = skill.get_global_data
494
+ @global_data.merge!(skill_data) if skill_data.is_a?(Hash) && !skill_data.empty?
495
+
496
+ # Merge prompt sections
497
+ skill_sections = skill.get_prompt_sections
498
+ if skill_sections.is_a?(Array) && !skill_sections.empty?
499
+ @prompt_text = nil # switch to POM mode
500
+ @prompt_pom = nil
501
+ skill_sections.each do |sec|
502
+ @pom_sections << sec
503
+ end
504
+ end
505
+
506
+ self
507
+ end
508
+
509
+ def remove_skill(skill_name)
510
+ skill = @loaded_skills.delete(skill_name)
511
+ @skill_manager.unload(skill.instance_key) if skill
512
+ self
513
+ end
514
+
515
+ def list_skills
516
+ @loaded_skills.keys
517
+ end
518
+
519
+ def has_skill?(skill_name)
520
+ @loaded_skills.key?(skill_name)
521
+ end
522
+
523
+ # ==================================================================
524
+ # Web / HTTP configuration
525
+ # ==================================================================
526
+
527
+ def set_dynamic_config_callback(callable = nil, &block)
528
+ @dynamic_config_callback = callable || block
529
+ self
530
+ end
531
+
532
+ def manual_set_proxy_url(url)
533
+ @proxy_url_base = url
534
+ self
535
+ end
536
+
537
+ def set_web_hook_url(url)
538
+ @web_hook_url_override = url
539
+ self
540
+ end
541
+
542
+ def set_post_prompt_url(url)
543
+ @post_prompt_url_override = url
544
+ self
545
+ end
546
+
547
+ def add_swaig_query_params(params)
548
+ @swaig_query_params.merge!(params) if params.is_a?(Hash)
549
+ self
550
+ end
551
+
552
+ def clear_swaig_query_params
553
+ @swaig_query_params = {}
554
+ self
555
+ end
556
+
557
+ def enable_debug_routes
558
+ @debug_routes_enabled = true
559
+ self
560
+ end
561
+
562
+ # ==================================================================
563
+ # SIP
564
+ # ==================================================================
565
+
566
+ def enable_sip_routing(auto_map: true, path: '/sip')
567
+ @sip_routing_enabled = true
568
+ @sip_auto_map = auto_map
569
+ @sip_path = path
570
+ self
571
+ end
572
+
573
+ def register_sip_username(username)
574
+ @sip_usernames << username
575
+ self
576
+ end
577
+
578
+ # Extract a SIP username from a SIP URI string.
579
+ #
580
+ # Parses URIs of the form "sip:user@domain" and returns the user part.
581
+ # Handles optional "sip:" or "sips:" scheme prefixes.
582
+ #
583
+ # @param sip_uri [String] a SIP URI, e.g. "sip:alice@example.com"
584
+ # @return [String, nil] the username, or nil if the URI cannot be parsed
585
+ def self.extract_sip_username(sip_uri)
586
+ return nil if sip_uri.nil? || sip_uri.empty?
587
+
588
+ # Strip optional sip:/sips: scheme
589
+ uri = sip_uri.to_s.strip
590
+ uri = uri.sub(%r{\Asips?:}, '')
591
+
592
+ # Extract user part before @
593
+ if uri.include?('@')
594
+ user = uri.split('@', 2).first
595
+ user && !user.empty? ? user : nil
596
+ else
597
+ nil
598
+ end
599
+ end
600
+
601
+ # Extract the SIP username from request body data.
602
+ #
603
+ # Looks for SIP URI in common request body fields
604
+ # (e.g., "to", "from", "sip_uri", "call.to", "call.from").
605
+ #
606
+ # @param request_data [Hash] the parsed request body
607
+ # @return [String, nil] the extracted SIP username, or nil
608
+ def self.extract_sip_username_from_request(request_data)
609
+ return nil unless request_data.is_a?(Hash)
610
+
611
+ # Check common SIP URI fields
612
+ candidates = [
613
+ request_data['to'],
614
+ request_data['from'],
615
+ request_data['sip_uri'],
616
+ request_data.dig('call', 'to'),
617
+ request_data.dig('call', 'from')
618
+ ].compact
619
+
620
+ candidates.each do |uri|
621
+ username = extract_sip_username(uri.to_s)
622
+ return username if username
623
+ end
624
+
625
+ nil
626
+ end
627
+
628
+ # ==================================================================
629
+ # Lifecycle
630
+ # ==================================================================
631
+
632
+ def on_summary(&block)
633
+ @summary_callback = block
634
+ self
635
+ end
636
+
637
+ def on_debug_event(&block)
638
+ @debug_event_callback = block
639
+ self
640
+ end
641
+
642
+ # Start the HTTP server (blocking).
643
+ def run
644
+ serve
645
+ end
646
+
647
+ def serve
648
+ require 'webrick'
649
+ @logger.info "Starting server on #{@host}:#{@port} ..."
650
+ user, _pass = @basic_auth
651
+ @logger.info "Basic-auth credentials — user: #{user} password: [REDACTED]"
652
+
653
+ @server = ::WEBrick::HTTPServer.new(
654
+ Host: @host,
655
+ Port: @port,
656
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
657
+ AccessLog: []
658
+ )
659
+
660
+ # Rack 3+ moved Handler to the rackup gem
661
+ handler = begin
662
+ require 'rackup/handler/webrick'
663
+ Rackup::Handler::WEBrick
664
+ rescue LoadError
665
+ require 'rack/handler/webrick'
666
+ Rack::Handler::WEBrick
667
+ end
668
+ @server.mount '/', handler, rack_app
669
+
670
+ trap('INT') { @server.shutdown }
671
+ trap('TERM') { @server.shutdown }
672
+
673
+ @server.start
674
+ end
675
+
676
+ # Return a Rack-compatible application for mounting.
677
+ def rack_app
678
+ @rack_app ||= _build_rack_app
679
+ end
680
+
681
+ alias as_rack_app rack_app
682
+
683
+ # ==================================================================
684
+ # SWML Rendering
685
+ # ==================================================================
686
+
687
+ # Build the complete SWML document hash.
688
+ #
689
+ # @param request_data [Hash, nil] parsed request body
690
+ # @param request [Rack::Request, nil] the HTTP request
691
+ # @return [Hash]
692
+ def render_swml(request_data = nil, request: nil)
693
+ agent = self
694
+
695
+ # Dynamic config: clone into ephemeral copy
696
+ if @dynamic_config_callback
697
+ agent = _create_ephemeral_copy
698
+ begin
699
+ query_params = request ? _parse_query_string(request) : {}
700
+ body_params = request_data || {}
701
+ headers = request ? _extract_headers(request) : {}
702
+ @dynamic_config_callback.call(query_params, body_params, headers, agent)
703
+ rescue => e
704
+ @logger.error "Dynamic config error: #{e.message}"
705
+ end
706
+ end
707
+
708
+ agent._render_swml_internal
709
+ end
710
+
711
+ # @api private
712
+ def _render_swml_internal
713
+ sections_main = []
714
+
715
+ # PHASE 1: Pre-answer verbs
716
+ @pre_answer_verbs.each do |verb_name, config|
717
+ sections_main << { verb_name => config }
718
+ end
719
+
720
+ # PHASE 2: Answer verb
721
+ if @auto_answer
722
+ answer_conf = @answer_config.empty? ? {} : @answer_config
723
+ sections_main << { 'answer' => answer_conf }
724
+ end
725
+
726
+ # PHASE 3: Post-answer verbs
727
+ if @record_call
728
+ sections_main << {
729
+ 'record_call' => {
730
+ 'format' => @record_format,
731
+ 'stereo' => @record_stereo
732
+ }
733
+ }
734
+ end
735
+ @post_answer_verbs.each do |verb_name, config|
736
+ sections_main << { verb_name => config }
737
+ end
738
+
739
+ # PHASE 4: AI verb
740
+ ai_config = _build_ai_config
741
+ sections_main << { 'ai' => ai_config }
742
+
743
+ # PHASE 5: Post-AI verbs
744
+ @post_ai_verbs.each do |verb_name, config|
745
+ sections_main << { verb_name => config }
746
+ end
747
+
748
+ {
749
+ 'version' => '1.0.0',
750
+ 'sections' => {
751
+ 'main' => sections_main
752
+ }
753
+ }
754
+ end
755
+
756
+ # Returns [username, password]
757
+ def get_basic_auth_credentials
758
+ @basic_auth.dup
759
+ end
760
+
761
+ # ==================================================================
762
+ # Private helpers
763
+ # ==================================================================
764
+
765
+ private
766
+
767
+ # Build the AI verb configuration hash.
768
+ def _build_ai_config
769
+ ai = {}
770
+
771
+ # --- prompt -------------------------------------------------------
772
+ prompt = get_prompt
773
+ if prompt.is_a?(Array) && !prompt.empty?
774
+ prompt_obj = { 'pom' => prompt }
775
+ prompt_obj.merge!(@prompt_llm_params) unless @prompt_llm_params.empty?
776
+ ai['prompt'] = prompt_obj
777
+ elsif prompt.is_a?(String) && !prompt.empty?
778
+ prompt_obj = { 'text' => prompt }
779
+ prompt_obj.merge!(@prompt_llm_params) unless @prompt_llm_params.empty?
780
+ ai['prompt'] = prompt_obj
781
+ end
782
+
783
+ # --- post-prompt --------------------------------------------------
784
+ if @post_prompt_text && !@post_prompt_text.empty?
785
+ pp_obj = { 'text' => @post_prompt_text }
786
+ pp_obj.merge!(@post_prompt_llm_params) unless @post_prompt_llm_params.empty?
787
+ ai['post_prompt'] = pp_obj
788
+
789
+ # post_prompt_url
790
+ if @post_prompt_url_override
791
+ ai['post_prompt_url'] = @post_prompt_url_override
792
+ else
793
+ ai['post_prompt_url'] = _build_webhook_url('post_prompt')
794
+ end
795
+ end
796
+
797
+ # --- SWAIG --------------------------------------------------------
798
+ swaig = {}
799
+
800
+ # default webhook url
801
+ default_url = @web_hook_url_override || _build_webhook_url('swaig', @swaig_query_params.empty? ? nil : @swaig_query_params)
802
+ swaig['defaults'] = { 'web_hook_url' => default_url }
803
+
804
+ # functions
805
+ functions = _build_functions_array
806
+ swaig['functions'] = functions unless functions.empty?
807
+
808
+ # native functions
809
+ swaig['native_functions'] = @native_functions unless @native_functions.empty?
810
+
811
+ # includes
812
+ swaig['includes'] = @function_includes unless @function_includes.empty?
813
+
814
+ # internal_fillers
815
+ swaig['internal_fillers'] = @internal_fillers unless @internal_fillers.empty?
816
+
817
+ ai['SWAIG'] = swaig unless swaig.keys == ['defaults'] && functions.empty?
818
+
819
+ # --- hints --------------------------------------------------------
820
+ ai['hints'] = @hints.dup unless @hints.empty?
821
+
822
+ # --- languages ----------------------------------------------------
823
+ ai['languages'] = @languages.dup unless @languages.empty?
824
+
825
+ # --- pronunciations -----------------------------------------------
826
+ ai['pronounce'] = @pronounce.dup unless @pronounce.empty?
827
+
828
+ # --- params -------------------------------------------------------
829
+ merged_params = @params.dup
830
+ if @debug_events_enabled
831
+ merged_params['debug_webhook_url'] = _build_webhook_url('debug_events')
832
+ merged_params['debug_webhook_level'] = @debug_events_level
833
+ end
834
+ ai['params'] = merged_params unless merged_params.empty?
835
+
836
+ # --- global_data --------------------------------------------------
837
+ ai['global_data'] = @global_data.dup unless @global_data.empty?
838
+
839
+ # --- contexts -----------------------------------------------------
840
+ if @context_builder
841
+ begin
842
+ ai['contexts'] = @context_builder.to_h
843
+ rescue ArgumentError
844
+ # invalid context config — skip silently
845
+ end
846
+ end
847
+
848
+ ai
849
+ end
850
+
851
+ # Build the functions array for the SWAIG section.
852
+ def _build_functions_array
853
+ functions = []
854
+
855
+ @tools.each do |name, tool|
856
+ func_entry = tool[:definition].dup
857
+ # Add per-function webhook URL if it has a token or query params
858
+ if tool[:secure] || !@swaig_query_params.empty?
859
+ qp = @swaig_query_params.dup
860
+ if tool[:secure]
861
+ # Note: token is generated per-call; in render we can't know call_id yet,
862
+ # so secure tools get per-function URLs at request time.
863
+ # For now, the default webhook URL handles dispatch.
864
+ end
865
+ func_entry['web_hook_url'] = _build_webhook_url('swaig', qp) unless qp.empty?
866
+ end
867
+ functions << func_entry
868
+ end
869
+
870
+ @swaig_functions.each do |_name, func_def|
871
+ functions << func_def.dup
872
+ end
873
+
874
+ functions
875
+ end
876
+
877
+ # Build a webhook URL with optional query params.
878
+ def _build_webhook_url(endpoint, query_params = nil)
879
+ base = _base_url
880
+ path = @route == '/' ? "/#{endpoint}" : "#{@route}/#{endpoint}"
881
+
882
+ url = "#{base}#{path}"
883
+
884
+ if query_params && !query_params.empty?
885
+ qs = URI.encode_www_form(query_params)
886
+ url = "#{url}?#{qs}"
887
+ end
888
+
889
+ url
890
+ end
891
+
892
+ # Compute the base URL (with auth embedded).
893
+ def _base_url
894
+ return @proxy_url_base if @proxy_url_base && !@proxy_url_base.empty?
895
+
896
+ user, pass = @basic_auth
897
+ "http://#{user}:#{pass}@#{@host}:#{@port}"
898
+ end
899
+
900
+ # Normalise tool parameters into JSON-Schema form.
901
+ def _normalise_parameters(params)
902
+ return params if params.is_a?(Hash) && params['type'] == 'object'
903
+ return { 'type' => 'object', 'properties' => {} } if params.nil? || params.empty?
904
+
905
+ # If the hash looks like {name => {type, description}}, wrap it.
906
+ if params.is_a?(Hash) && !params.key?('type')
907
+ { 'type' => 'object', 'properties' => params.transform_keys(&:to_s) }
908
+ else
909
+ params
910
+ end
911
+ end
912
+
913
+ # Create an ephemeral deep copy for dynamic config.
914
+ def _create_ephemeral_copy
915
+ copy = dup
916
+ # Deep-copy mutable collections
917
+ copy.instance_variable_set(:@pom_sections, @pom_sections.map(&:dup))
918
+ copy.instance_variable_set(:@tools, @tools.transform_values(&:dup))
919
+ copy.instance_variable_set(:@swaig_functions, @swaig_functions.transform_values(&:dup))
920
+ copy.instance_variable_set(:@hints, @hints.dup)
921
+ copy.instance_variable_set(:@languages, @languages.map { |l| l.dup })
922
+ copy.instance_variable_set(:@pronounce, @pronounce.map { |p| p.dup })
923
+ copy.instance_variable_set(:@params, @params.dup)
924
+ copy.instance_variable_set(:@global_data, @global_data.dup)
925
+ copy.instance_variable_set(:@native_functions, @native_functions.dup)
926
+ copy.instance_variable_set(:@function_includes, @function_includes.map { |i| i.dup })
927
+ copy.instance_variable_set(:@internal_fillers, _deep_dup_hash(@internal_fillers))
928
+ copy.instance_variable_set(:@prompt_llm_params, @prompt_llm_params.dup)
929
+ copy.instance_variable_set(:@post_prompt_llm_params, @post_prompt_llm_params.dup)
930
+ copy.instance_variable_set(:@pre_answer_verbs, @pre_answer_verbs.map(&:dup))
931
+ copy.instance_variable_set(:@post_answer_verbs, @post_answer_verbs.map(&:dup))
932
+ copy.instance_variable_set(:@post_ai_verbs, @post_ai_verbs.map(&:dup))
933
+ copy.instance_variable_set(:@answer_config, @answer_config.dup)
934
+ copy.instance_variable_set(:@swaig_query_params, @swaig_query_params.dup)
935
+ copy.instance_variable_set(:@loaded_skills, @loaded_skills.dup)
936
+ # Don't copy the dynamic config callback to prevent infinite recursion
937
+ copy.instance_variable_set(:@dynamic_config_callback, nil)
938
+ copy
939
+ end
940
+
941
+ # Deep-dup a hash of hashes
942
+ def _deep_dup_hash(hash)
943
+ hash.each_with_object({}) do |(k, v), result|
944
+ result[k] = v.is_a?(Hash) ? v.dup : v
945
+ end
946
+ end
947
+
948
+ # Parse query string from Rack request
949
+ def _parse_query_string(request)
950
+ return {} unless request.respond_to?(:env)
951
+
952
+ qs = request.env['QUERY_STRING'] || ''
953
+ URI.decode_www_form(qs).to_h
954
+ rescue
955
+ {}
956
+ end
957
+
958
+ # Extract headers from Rack request
959
+ def _extract_headers(request)
960
+ return {} unless request.respond_to?(:env)
961
+
962
+ request.env.select { |k, _| k.start_with?('HTTP_') }
963
+ .transform_keys { |k| k.sub('HTTP_', '').downcase.tr('_', '-') }
964
+ rescue
965
+ {}
966
+ end
967
+
968
+ # ==================================================================
969
+ # Rack app
970
+ # ==================================================================
971
+
972
+ def _build_rack_app
973
+ agent = self
974
+ main_route = @route
975
+
976
+ Rack::Builder.new do
977
+ # --- public endpoints (no auth) --------------------------------
978
+ map '/health' do
979
+ run ->(_env) {
980
+ body = JSON.generate({ status: 'healthy' })
981
+ [200, { 'content-type' => 'application/json' }, [body]]
982
+ }
983
+ end
984
+
985
+ map '/ready' do
986
+ run ->(_env) {
987
+ body = JSON.generate({ status: 'ready' })
988
+ [200, { 'content-type' => 'application/json' }, [body]]
989
+ }
990
+ end
991
+
992
+ # --- authenticated endpoints -----------------------------------
993
+ map main_route do
994
+ use AgentSecurityHeadersMiddleware
995
+ use AgentBodyLimitMiddleware, AgentBase::MAX_BODY_SIZE
996
+ use AgentTimingSafeBasicAuth, agent
997
+
998
+ run ->(env) {
999
+ request = Rack::Request.new(env)
1000
+ sub_path = env['PATH_INFO'] || '/'
1001
+ sub_path = '/' if sub_path.empty?
1002
+
1003
+ request_data = nil
1004
+ if request.post? || request.put?
1005
+ body = request.body.read
1006
+ request_data = JSON.parse(body) rescue nil
1007
+ end
1008
+
1009
+ case sub_path
1010
+ when '/swaig'
1011
+ agent._handle_swaig(request_data, env)
1012
+ when '/post_prompt'
1013
+ agent._handle_post_prompt(request_data, env)
1014
+ when '/debug_events'
1015
+ agent._handle_debug_events(request_data, env)
1016
+ else
1017
+ # SWML endpoint
1018
+ swml = agent.render_swml(request_data, request: request)
1019
+ body = JSON.generate(swml)
1020
+ [200, { 'content-type' => 'application/json' }, [body]]
1021
+ end
1022
+ }
1023
+ end
1024
+ end
1025
+ end
1026
+
1027
+ # These methods must be accessible from the Rack lambda
1028
+ public
1029
+
1030
+ # Handle SWAIG function dispatch.
1031
+ # @api private
1032
+ def _handle_swaig(request_data, _env)
1033
+ unless request_data
1034
+ body = JSON.generate({ 'response' => 'No request data' })
1035
+ return [400, { 'content-type' => 'application/json' }, [body]]
1036
+ end
1037
+
1038
+ func_name = request_data['function']
1039
+ unless func_name
1040
+ body = JSON.generate({ 'response' => 'No function specified' })
1041
+ return [400, { 'content-type' => 'application/json' }, [body]]
1042
+ end
1043
+
1044
+ # Extract args from argument.parsed[0]
1045
+ args = {}
1046
+ if request_data['argument'].is_a?(Hash)
1047
+ parsed = request_data['argument']['parsed']
1048
+ args = parsed.first if parsed.is_a?(Array) && !parsed.empty?
1049
+ end
1050
+ args ||= {}
1051
+
1052
+ result = on_function_call(func_name, args, request_data)
1053
+ body = JSON.generate(result)
1054
+ [200, { 'content-type' => 'application/json' }, [body]]
1055
+ end
1056
+
1057
+ # Handle post_prompt callback.
1058
+ # @api private
1059
+ def _handle_post_prompt(request_data, _env)
1060
+ if @summary_callback && request_data
1061
+ begin
1062
+ post_prompt_data = request_data['post_prompt_data']
1063
+ summary = nil
1064
+ if post_prompt_data.is_a?(Hash)
1065
+ summary = post_prompt_data['parsed'] || post_prompt_data['raw']
1066
+ end
1067
+ @summary_callback.call(summary, request_data)
1068
+ rescue => e
1069
+ @logger.error "Post-prompt callback error: #{e.message}"
1070
+ end
1071
+ end
1072
+
1073
+ body = JSON.generate({ 'status' => 'ok' })
1074
+ [200, { 'content-type' => 'application/json' }, [body]]
1075
+ end
1076
+
1077
+ # Handle debug events.
1078
+ # @api private
1079
+ def _handle_debug_events(request_data, _env)
1080
+ if @debug_event_callback && request_data
1081
+ begin
1082
+ event_type = request_data['event_type'] || 'unknown'
1083
+ @debug_event_callback.call(event_type, request_data)
1084
+ rescue => e
1085
+ @logger.error "Debug event callback error: #{e.message}"
1086
+ end
1087
+ end
1088
+
1089
+ body = JSON.generate({ 'status' => 'ok' })
1090
+ [200, { 'content-type' => 'application/json' }, [body]]
1091
+ end
1092
+
1093
+ private
1094
+
1095
+ # ==================================================================
1096
+ # Rack Middleware
1097
+ # ==================================================================
1098
+
1099
+ class AgentSecurityHeadersMiddleware
1100
+ HEADERS = {
1101
+ 'x-content-type-options' => 'nosniff',
1102
+ 'x-frame-options' => 'DENY',
1103
+ 'cache-control' => 'no-store, no-cache, must-revalidate'
1104
+ }.freeze
1105
+
1106
+ def initialize(app)
1107
+ @app = app
1108
+ end
1109
+
1110
+ def call(env)
1111
+ status, headers, body = @app.call(env)
1112
+ HEADERS.each { |k, v| headers[k] = v }
1113
+ [status, headers, body]
1114
+ end
1115
+ end
1116
+
1117
+ class AgentBodyLimitMiddleware
1118
+ def initialize(app, max_size)
1119
+ @app = app
1120
+ @max_size = max_size
1121
+ end
1122
+
1123
+ def call(env)
1124
+ if env['CONTENT_LENGTH'] && env['CONTENT_LENGTH'].to_i > @max_size
1125
+ body = JSON.generate({ 'error' => 'Request body too large' })
1126
+ return [413, { 'content-type' => 'application/json' }, [body]]
1127
+ end
1128
+ @app.call(env)
1129
+ end
1130
+ end
1131
+
1132
+ class AgentTimingSafeBasicAuth
1133
+ def initialize(app, agent)
1134
+ @app = app
1135
+ @agent = agent
1136
+ end
1137
+
1138
+ def call(env)
1139
+ auth = Rack::Auth::Basic::Request.new(env)
1140
+ unless auth.provided? && auth.basic?
1141
+ return _unauthorized
1142
+ end
1143
+
1144
+ user, pass = @agent.get_basic_auth_credentials
1145
+ input_user, input_pass = auth.credentials
1146
+
1147
+ user_ok = Rack::Utils.secure_compare(user.to_s, input_user.to_s)
1148
+ pass_ok = Rack::Utils.secure_compare(pass.to_s, input_pass.to_s)
1149
+
1150
+ if user_ok && pass_ok
1151
+ @app.call(env)
1152
+ else
1153
+ _unauthorized
1154
+ end
1155
+ end
1156
+
1157
+ private
1158
+
1159
+ def _unauthorized
1160
+ [
1161
+ 401,
1162
+ {
1163
+ 'content-type' => 'text/plain',
1164
+ 'www-authenticate' => 'Basic realm="SignalWire Agent"'
1165
+ },
1166
+ ['Unauthorized']
1167
+ ]
1168
+ end
1169
+ end
1170
+ end
1171
+ end