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,2134 @@
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 '../runtime'
15
+ require_relative '../swml/document'
16
+ require_relative '../swml/schema'
17
+ require_relative '../swml/service'
18
+ require_relative '../swaig/function_result'
19
+ require_relative '../security/session_manager'
20
+ require_relative '../security/webhook_validator'
21
+ require_relative '../security/webhook_middleware'
22
+ require_relative '../contexts/context_builder'
23
+ require_relative '../skills/skill_base'
24
+ require_relative '../skills/skill_manager'
25
+ require_relative '../skills/skill_registry'
26
+
27
+ module SignalWire
28
+ # Central agent class that composes SWML rendering, tool dispatch,
29
+ # prompt management, AI config, and HTTP serving.
30
+ #
31
+ # AgentBase extends SWMLService with agent-specific capabilities:
32
+ # - Prompt management (POM sections and raw text)
33
+ # - Tool (SWAIG function) registration & dispatch
34
+ # - AI configuration (hints, languages, pronunciations, params)
35
+ # - Verb management (pre/post answer, post-AI)
36
+ # - Context & step workflows
37
+ # - Skill integration
38
+ # - Dynamic configuration via per-request ephemeral copies
39
+ #
40
+ # All configuration methods return +self+ for method chaining.
41
+ class AgentBase < SWML::Service
42
+ # Python parity:
43
+ # - ``logger`` — agent-specific structured logger (Python: ``self.log``).
44
+ # - ``skill_manager`` — owning SkillManager (Python's ``self.skill_manager``).
45
+ # - ``agent_id`` — UUID identifier from constructor or auto-generated.
46
+ # - ``default_webhook_url`` — base URL for SWAIG webhook fallbacks.
47
+ # - ``native_functions`` — names of built-in SWAIG functions to advertise.
48
+ # - ``use_pom`` — whether prompt-object-model rendering is enabled.
49
+ attr_reader :logger, :skill_manager, :agent_id, :default_webhook_url,
50
+ :native_functions, :use_pom, :signing_key
51
+
52
+ # Maximum request body size (1 MB)
53
+ MAX_BODY_SIZE = 1_048_576
54
+
55
+ # ------------------------------------------------------------------
56
+ # Construction
57
+ # ------------------------------------------------------------------
58
+
59
+ def initialize(name: 'agent', route: '/', host: '0.0.0.0', port: nil,
60
+ basic_auth: nil,
61
+ use_pom: true,
62
+ token_expiry_secs: 3600,
63
+ auto_answer: true, record_call: false,
64
+ record_format: 'mp4', record_stereo: true,
65
+ default_webhook_url: nil,
66
+ agent_id: nil,
67
+ native_functions: nil,
68
+ schema_path: nil,
69
+ suppress_logs: false,
70
+ enable_post_prompt_override: false,
71
+ check_for_input_override: false,
72
+ config_file: nil,
73
+ schema_validation: true,
74
+ signing_key: nil,
75
+ trust_proxy_for_signature: false)
76
+ # Resolve auth before super so we can warn about auto-generated
77
+ # passwords. Service's built-in auth fallback uses a fresh UUID per
78
+ # process, which is fine, but we want the agent-specific warning.
79
+ password_auto_generated = false
80
+ resolved_auth = if basic_auth
81
+ basic_auth
82
+ elsif ENV['SWML_BASIC_AUTH_USER'] && ENV['SWML_BASIC_AUTH_PASSWORD']
83
+ [ENV['SWML_BASIC_AUTH_USER'], ENV['SWML_BASIC_AUTH_PASSWORD']]
84
+ else
85
+ password_auto_generated = true
86
+ [(ENV['SWML_BASIC_AUTH_USER'] || SecureRandom.uuid), SecureRandom.uuid]
87
+ end
88
+
89
+ super(name: name, route: route, host: host, port: port, basic_auth: resolved_auth,
90
+ schema_path: schema_path, config_file: config_file,
91
+ schema_validation: schema_validation)
92
+ @logger = Logging.logger("AgentBase[#{name}]")
93
+ @suppress_logs = suppress_logs
94
+
95
+ if password_auto_generated
96
+ # Warn loudly so external callers (tests, RPC clients, MCP)
97
+ # know why they are getting HTTP 401. This is the silent cause
98
+ # of every external caller failing when .env wasn't loaded —
99
+ # the password lives only in this process and changes on every
100
+ # restart.
101
+ @logger.warn(
102
+ "basic_auth_password_autogenerated: username=#{@basic_auth[0].inspect}. " \
103
+ "No SWML_BASIC_AUTH_PASSWORD found in environment and no basic_auth " \
104
+ "passed to the agent constructor. The SDK generated a random " \
105
+ "password that exists only in this process; external callers will " \
106
+ "get HTTP 401 unless they read the value from this process's env. " \
107
+ "To fix, set SWML_BASIC_AUTH_USER and SWML_BASIC_AUTH_PASSWORD in " \
108
+ "your environment, or pass basic_auth: [user, pass] to " \
109
+ "AgentBase.new."
110
+ )
111
+ end
112
+
113
+ # --- call settings ------------------------------------------------
114
+ @auto_answer = auto_answer
115
+ @record_call = record_call
116
+ @record_format = record_format
117
+ @record_stereo = record_stereo
118
+
119
+ # --- POM / prompt-mode flags --------------------------------------
120
+ # Python parity: AgentBase constructor stores ``use_pom`` to
121
+ # toggle POM-vs-raw prompt rendering. Ruby's POM is implicit (we
122
+ # always have a @pom_sections array), but we honour the flag so
123
+ # set_prompt_pom and friends can refuse when POM mode is off.
124
+ @use_pom = use_pom
125
+
126
+ # --- agent identity / config --------------------------------------
127
+ # Python parity: ``agent_id`` (optional explicit UUID) and
128
+ # ``default_webhook_url`` (used when SWAIG functions don't carry
129
+ # an explicit URL). ``native_functions`` lists native SWAIG
130
+ # callables that should appear in the SWAIG block alongside
131
+ # user tools.
132
+ @agent_id = agent_id || SecureRandom.uuid
133
+ @default_webhook_url = default_webhook_url
134
+ @native_functions = native_functions || []
135
+
136
+ # --- override / validation flags ----------------------------------
137
+ # Python parity: ``enable_post_prompt_override`` and
138
+ # ``check_for_input_override`` are wired through the FastAPI
139
+ # endpoint dispatcher. Ruby stashes them so subclass-controlled
140
+ # routes can opt into the same behaviour.
141
+ @enable_post_prompt_override = enable_post_prompt_override
142
+ @check_for_input_override = check_for_input_override
143
+
144
+ # --- session manager ----------------------------------------------
145
+ @session_manager = Security::SessionManager.new(token_expiry_secs: token_expiry_secs)
146
+
147
+ # --- webhook signature validation (porting-sdk/webhooks.md) -------
148
+ # Resolution order: explicit constructor arg → SIGNALWIRE_SIGNING_KEY env.
149
+ # When set, _build_rack_app mounts WebhookMiddleware on the signed
150
+ # routes (POST /, /swaig, /post_prompt). When unset, the SDK logs a
151
+ # warning so production users notice unsigned traffic is being
152
+ # accepted.
153
+ @signing_key = signing_key || ENV['SIGNALWIRE_SIGNING_KEY']
154
+ @trust_proxy_for_signature = trust_proxy_for_signature
155
+ if @signing_key && !@signing_key.empty?
156
+ @logger.info('webhook_signature_validation_enabled') unless @suppress_logs
157
+ else
158
+ unless @suppress_logs
159
+ @logger.warn(
160
+ '[signalwire] webhook signature validation is disabled — ' \
161
+ 'set signing_key or SIGNALWIRE_SIGNING_KEY to enable'
162
+ )
163
+ end
164
+ end
165
+
166
+ # --- prompt state -------------------------------------------------
167
+ @prompt_text = nil # raw text mode
168
+ @prompt_pom = nil # direct POM array
169
+ @pom_sections = [] # built via prompt_add_section
170
+ @post_prompt_text = nil
171
+
172
+ # --- tools --------------------------------------------------------
173
+ # @tools and @swaig_functions are now initialised by Service (parent).
174
+ # AgentBase's enhanced define_tool overrides Service's plain version.
175
+
176
+ # --- AI config ----------------------------------------------------
177
+ @hints = []
178
+ @languages = []
179
+ @pronounce = []
180
+ @params = {}
181
+ @global_data = {}
182
+ @function_includes = []
183
+ @internal_fillers = {}
184
+ @prompt_llm_params = {}
185
+ @post_prompt_llm_params = {}
186
+
187
+ # --- debug --------------------------------------------------------
188
+ @debug_events_enabled = false
189
+ @debug_events_level = 1
190
+ @debug_event_callback = nil
191
+
192
+ # --- verbs --------------------------------------------------------
193
+ @pre_answer_verbs = [] # [[verb_name, config], ...]
194
+ @answer_config = {}
195
+ @post_answer_verbs = []
196
+ @post_ai_verbs = []
197
+
198
+ # --- contexts -----------------------------------------------------
199
+ @context_builder = nil
200
+
201
+ # --- skills -------------------------------------------------------
202
+ # Python parity: ``SkillManager(agent)`` keeps a back-pointer
203
+ # to the owning agent so loaded skills can attach SWAIG tools
204
+ # and prompt sections directly through the manager.
205
+ @skill_manager = Skills::SkillManager.new(self)
206
+ @loaded_skills = {} # skill_name => SkillBase
207
+
208
+ # --- web ----------------------------------------------------------
209
+ @dynamic_config_callback = nil
210
+ @proxy_url_base = ENV['SWML_PROXY_URL_BASE']
211
+ @web_hook_url_override = nil
212
+ @post_prompt_url_override = nil
213
+ @swaig_query_params = {}
214
+ @debug_routes_enabled = false
215
+ @summary_callback = nil
216
+
217
+ # --- SIP ----------------------------------------------------------
218
+ @sip_routing_enabled = false
219
+ @sip_auto_map = false
220
+ @sip_path = '/sip'
221
+ @sip_usernames = []
222
+
223
+ # --- MCP ----------------------------------------------------------
224
+ @mcp_servers = [] # external MCP server configs
225
+ @mcp_server_enabled = false # expose /mcp endpoint
226
+
227
+ @logger.info "Agent '#{@name}' initialised (route=#{@route}, port=#{@port})"
228
+ end
229
+
230
+ # ==================================================================
231
+ # Prompt methods
232
+ # ==================================================================
233
+
234
+ # Set prompt as raw text. Clears any POM state.
235
+ def set_prompt_text(text)
236
+ @prompt_text = text
237
+ @pom_sections = []
238
+ @prompt_pom = nil
239
+ self
240
+ end
241
+
242
+ # Set post-prompt text.
243
+ def set_post_prompt(text)
244
+ @post_prompt_text = text
245
+ self
246
+ end
247
+
248
+ # Set POM array directly.
249
+ def set_prompt_pom(pom)
250
+ @prompt_pom = pom
251
+ @prompt_text = nil
252
+ @pom_sections = []
253
+ self
254
+ end
255
+
256
+ # Add a POM section.
257
+ #
258
+ # Python parity:
259
+ # ``prompt_add_section(title, body="", bullets=None,
260
+ # numbered=False, numbered_bullets=False, subsections=None)``.
261
+ #
262
+ # @param title [String] section title
263
+ # @param body [String, nil] optional body text
264
+ # @param bullets [Array<String>, nil] optional bullet items
265
+ # @param numbered [Boolean] render as a numbered top-level entry
266
+ # @param numbered_bullets [Boolean] render bullets as numbered
267
+ # @param subsections [Array<Hash>, nil] optional pre-rendered
268
+ # subsection hashes (each ``{title:, body:, bullets:}``)
269
+ def prompt_add_section(title, body = nil, bullets: nil,
270
+ numbered: false, numbered_bullets: false,
271
+ subsections: nil)
272
+ @prompt_text = nil
273
+ @prompt_pom = nil
274
+ section = { 'title' => title }
275
+ section['body'] = body if body
276
+ section['bullets'] = bullets if bullets
277
+ section['numbered'] = true if numbered
278
+ section['numbered_bullets'] = true if numbered_bullets
279
+
280
+ if subsections.is_a?(Array) && !subsections.empty?
281
+ section['subsections'] = subsections.map do |sub|
282
+ h = { 'title' => sub['title'] || sub[:title] }
283
+ h['body'] = sub['body'] || sub[:body] if (sub['body'] || sub[:body])
284
+ h['bullets'] = sub['bullets'] || sub[:bullets] if (sub['bullets'] || sub[:bullets])
285
+ h
286
+ end
287
+ end
288
+
289
+ @pom_sections << section
290
+ self
291
+ end
292
+
293
+ # Append content to an existing POM section, creating it if absent.
294
+ #
295
+ # Python parity:
296
+ # ``prompt_add_to_section(title, body=None, bullet=None,
297
+ # bullets=None)``. Supports appending body text, a single bullet,
298
+ # or a list of bullets.
299
+ #
300
+ # @param title [String] section title
301
+ # @param body [String, nil] body text to append
302
+ # @param bullet [String, nil] single bullet to append
303
+ # @param bullets [Array<String>, nil] bullets to append
304
+ #
305
+ # **Backwards compat:** the original Ruby signature was
306
+ # ``prompt_add_to_section(title, text)``. When called with two
307
+ # positional arguments the second becomes ``body``; this preserves
308
+ # existing call sites while still supporting Python's keyword form.
309
+ def prompt_add_to_section(title, body_arg = nil, body: nil, bullet: nil, bullets: nil)
310
+ effective_body = body || body_arg
311
+
312
+ sec = @pom_sections.find { |s| s['title'] == title }
313
+ unless sec
314
+ sec = { 'title' => title }
315
+ @pom_sections << sec
316
+ end
317
+
318
+ if effective_body
319
+ sec['body'] = (sec['body'] || '') + effective_body.to_s
320
+ end
321
+
322
+ to_add = []
323
+ to_add << bullet if bullet
324
+ to_add.concat(bullets) if bullets.is_a?(Array)
325
+ unless to_add.empty?
326
+ sec['bullets'] = (sec['bullets'] || []) + to_add
327
+ end
328
+
329
+ self
330
+ end
331
+
332
+ # Add a subsection under a parent section.
333
+ def prompt_add_subsection(parent_title, title, body = nil, bullets: nil)
334
+ parent = @pom_sections.find { |s| s['title'] == parent_title }
335
+ if parent
336
+ parent['subsections'] ||= []
337
+ sub = { 'title' => title }
338
+ sub['body'] = body if body
339
+ sub['bullets'] = bullets if bullets
340
+ parent['subsections'] << sub
341
+ end
342
+ self
343
+ end
344
+
345
+ # Check whether a POM section with the given title exists.
346
+ def prompt_has_section?(title)
347
+ @pom_sections.any? { |s| s['title'] == title }
348
+ end
349
+
350
+ # Return the current prompt: either a string (text mode) or an array (POM).
351
+ def get_prompt
352
+ return @prompt_text if @prompt_text
353
+ return @prompt_pom if @prompt_pom
354
+ return @pom_sections.dup unless @pom_sections.empty?
355
+ nil
356
+ end
357
+
358
+ # Read-only snapshot of the agent's POM as a typed
359
+ # {SignalWire::POM::PromptObjectModel} instance.
360
+ #
361
+ # Python parity: ``agent.pom`` instance attribute (agent_base.py
362
+ # line 209) is a ``PromptObjectModel`` instance. Returns ``nil`` when
363
+ # raw-text prompt mode is in effect (``set_prompt_text`` was called)
364
+ # — mirrors Python's ``self.pom = None when use_pom=False``.
365
+ #
366
+ # The returned PromptObjectModel is a fresh build of the agent's
367
+ # current section state, so caller mutations do not leak into agent
368
+ # state. Use ``agent.pom.to_h`` to retrieve the legacy
369
+ # array-of-hashes representation.
370
+ def pom
371
+ return nil if @prompt_text
372
+
373
+ sections = @prompt_pom || @pom_sections
374
+ pom = SignalWire::POM::PromptObjectModel.new
375
+ sections.each do |sec|
376
+ # Each section is a Hash with possibly String or Symbol keys.
377
+ h = sec.transform_keys(&:to_s)
378
+ kwargs = {
379
+ body: h.fetch('body', ''),
380
+ bullets: h['bullets'] || [],
381
+ numbered: h['numbered'],
382
+ numbered_bullets: h['numbered_bullets'] || h['numberedBullets'] || false
383
+ }
384
+ section = pom.add_section(h['title'], **kwargs)
385
+ (h['subsections'] || []).each do |sub|
386
+ sh = sub.transform_keys(&:to_s)
387
+ section.add_subsection(
388
+ sh['title'],
389
+ body: sh.fetch('body', ''),
390
+ bullets: sh['bullets'] || [],
391
+ numbered: sh['numbered'] || false,
392
+ numbered_bullets: sh['numbered_bullets'] || sh['numberedBullets'] || false
393
+ )
394
+ end
395
+ end
396
+ pom
397
+ end
398
+
399
+ # Returns the post-prompt text whatever set_post_prompt stored, or
400
+ # nil when no post-prompt has been set.
401
+ #
402
+ # Mirrors Python's PromptManager#get_post_prompt /
403
+ # PromptMixin#get_post_prompt — used by SWML rendering when a
404
+ # post-prompt is configured.
405
+ def get_post_prompt
406
+ @post_prompt_text
407
+ end
408
+
409
+ # Returns the raw prompt text whatever set_prompt_text stored, or
410
+ # nil when no raw prompt has been set. Distinct from #get_prompt
411
+ # which may return the POM array when use_pom is true.
412
+ #
413
+ # Mirrors Python's PromptManager#get_raw_prompt.
414
+ def get_raw_prompt
415
+ @prompt_text
416
+ end
417
+
418
+ # Returns the contexts dictionary as a serialised hash, or nil when
419
+ # no contexts have been defined yet.
420
+ #
421
+ # Mirrors Python's PromptManager#get_contexts which returns the
422
+ # contexts dict or None.
423
+ def get_contexts
424
+ return nil if @context_builder.nil?
425
+ @context_builder.to_h
426
+ end
427
+
428
+ # ==================================================================
429
+ # Tool methods
430
+ # ==================================================================
431
+
432
+ # Register a SWAIG tool (function) that the AI can invoke during a
433
+ # call.
434
+ #
435
+ # == How this becomes a tool the model sees
436
+ #
437
+ # A SWAIG function is *exactly the same concept* as a "tool" in
438
+ # native OpenAI / Anthropic tool calling. On every LLM turn, the
439
+ # SDK renders each registered SWAIG function into the OpenAI tool
440
+ # schema:
441
+ #
442
+ # {
443
+ # "type": "function",
444
+ # "function": {
445
+ # "name": "your_name_here",
446
+ # "description": "your description text",
447
+ # "parameters": { ... your JSON schema ... }
448
+ # }
449
+ # }
450
+ #
451
+ # That schema is sent to the model as part of the same API call
452
+ # that produces the next assistant message. The model reads:
453
+ #
454
+ # - the function +description+ to decide WHEN to call this tool
455
+ # - each parameter +description+ (inside +parameters+) to decide
456
+ # HOW to fill in that argument from the user's utterance
457
+ #
458
+ # This means *descriptions are prompt engineering*, not developer
459
+ # comments. A vague description is the #1 cause of "the model has
460
+ # the right tool but doesn't call it" failures.
461
+ #
462
+ # == Bad vs good descriptions
463
+ #
464
+ # BAD : description: "Lookup function"
465
+ # GOOD: description: "Look up a customer's account details by " \
466
+ # "account number. Use this BEFORE quoting " \
467
+ # "any account-specific info (balance, plan, " \
468
+ # "status). Do not use for general product " \
469
+ # "questions."
470
+ #
471
+ # BAD : parameters: { id: { type: 'string', description: 'the id' } }
472
+ # GOOD: parameters: { account_number: { type: 'string',
473
+ # description: "The customer's 8-digit account " \
474
+ # "number, no dashes or spaces. Ask the user if they " \
475
+ # "don't provide it." } }
476
+ #
477
+ # == Tool count matters
478
+ #
479
+ # LLM tool selection accuracy degrades past ~7-8
480
+ # simultaneously-active tools per call. Use
481
+ # Contexts::Step#set_functions to partition tools across steps so
482
+ # only the relevant subset is active at any moment.
483
+ #
484
+ # @param name [String] function name (snake_case verb recommended)
485
+ # @param description [String] LLM-facing description of when to
486
+ # call this tool
487
+ # @param parameters [Hash] JSON-Schema properties with LLM-facing
488
+ # descriptions for each parameter
489
+ # @param secure [Boolean]
490
+ # @param fillers [Hash, nil] language_code => [phrases]
491
+ # @param swaig_fields [Hash, nil] extra fields merged into definition
492
+ # @yield [args, raw_data] the tool handler
493
+ # Define a SWAIG tool.
494
+ #
495
+ # Python parity:
496
+ # ``define_tool(name, description, parameters, handler,
497
+ # secure=True, fillers=None, wait_file=None, wait_file_loops=None,
498
+ # webhook_url=None, required=None, is_typed_handler=False,
499
+ # **swaig_fields)``.
500
+ #
501
+ # @param name [String] tool name
502
+ # @param description [String] LLM-facing description
503
+ # @param parameters [Hash] JSON-Schema parameters
504
+ # @param handler [Proc, nil] explicit handler (alternative to a
505
+ # block); kept for backward compat
506
+ # @param secure [Boolean] require token validation. Ruby defaults
507
+ # to ``false`` (kept for backward compat); Python defaults to
508
+ # ``True``. Pass ``secure: true`` to match Python.
509
+ # @param fillers [Hash, nil] language-keyed filler phrases
510
+ # @param wait_file [String, nil] URL of audio file to play while
511
+ # the tool runs server-side
512
+ # @param wait_file_loops [Integer, nil] loop count for ``wait_file``
513
+ # @param webhook_url [String, nil] external endpoint to use
514
+ # instead of dispatching to the local handler
515
+ # @param required [Array<String>, nil] required parameter names
516
+ # @param is_typed_handler [Boolean] handler accepts type-coerced
517
+ # keyword args (parity flag; Ruby uses dynamic typing so this
518
+ # is a no-op at runtime but is preserved for surface parity)
519
+ # @param swaig_fields [Hash, nil] additional fields merged into
520
+ # the SWAIG function definition
521
+ # @yield [args, raw_data] tool handler body (alternative to
522
+ # passing ``handler:``)
523
+ def define_tool(name:, description:, parameters: {}, handler: nil,
524
+ secure: false, fillers: nil,
525
+ wait_file: nil, wait_file_loops: nil,
526
+ webhook_url: nil, required: nil,
527
+ is_typed_handler: false,
528
+ swaig_fields: nil, &block)
529
+ # Block is canonical — falls back to explicit handler kwarg.
530
+ effective_handler = block || handler
531
+
532
+ # Normalise parameters into JSON-Schema form
533
+ param_schema = _normalise_parameters(parameters)
534
+
535
+ # If the caller supplied required: (Python parity), inject it
536
+ # into the parameter schema so SWML rendering carries the list.
537
+ if required.is_a?(Array) && !required.empty?
538
+ if param_schema.is_a?(Hash) && param_schema['type'] == 'object'
539
+ existing = param_schema['required'] || []
540
+ param_schema['required'] = (existing + required).uniq
541
+ end
542
+ end
543
+
544
+ tool_def = {
545
+ 'function' => name,
546
+ 'description' => description,
547
+ 'parameters' => param_schema
548
+ }
549
+ tool_def['fillers'] = fillers if fillers && !fillers.empty?
550
+ tool_def['wait_file'] = wait_file if wait_file
551
+ tool_def['wait_file_loops'] = wait_file_loops if wait_file_loops
552
+ tool_def['webhook_url'] = webhook_url if webhook_url
553
+ tool_def['is_typed_handler'] = true if is_typed_handler
554
+
555
+ # Merge extra swaig fields
556
+ if swaig_fields.is_a?(Hash)
557
+ swaig_fields.each { |k, v| tool_def[k.to_s] = v }
558
+ end
559
+
560
+ @tools[name] = {
561
+ definition: tool_def,
562
+ handler: effective_handler,
563
+ secure: secure
564
+ }
565
+ self
566
+ end
567
+
568
+ # Register a raw SWAIG function definition (e.g. from DataMap#to_swaig_function).
569
+ def register_swaig_function(func_def)
570
+ fname = func_def['function'] || func_def[:function]
571
+ return self unless fname
572
+ @swaig_functions[fname] = func_def.transform_keys(&:to_s)
573
+ self
574
+ end
575
+
576
+ # Return an array of all tool definitions (for SWML rendering).
577
+ def define_tools
578
+ defs = @tools.values.map { |t| t[:definition].dup }
579
+ defs + @swaig_functions.values.map(&:dup)
580
+ end
581
+
582
+ # Mint a per-call SWAIG-function token via the agent's SessionManager.
583
+ #
584
+ # Python parity: state_mixin.StateMixin#_create_tool_token —
585
+ # delegates to SessionManager#create_token and returns "" on any
586
+ # raised error (Python rescues all exceptions and returns "").
587
+ def create_tool_token(tool_name, call_id)
588
+ @session_manager.create_token(tool_name, call_id)
589
+ rescue StandardError
590
+ ''
591
+ end
592
+
593
+ # Validate a per-call SWAIG-function token. Returns false when the
594
+ # function is not registered, when the SessionManager rejects the
595
+ # token, or on any underlying exception.
596
+ #
597
+ # Python parity: state_mixin.StateMixin#validate_tool_token —
598
+ # rejects unknown function names up-front and rescues exceptions.
599
+ def validate_tool_token(function_name, token, call_id)
600
+ return false unless has_function(function_name)
601
+ @session_manager.validate_token(function_name, token, call_id)
602
+ rescue StandardError
603
+ false
604
+ end
605
+
606
+ # Dispatch a function call to the registered handler.
607
+ def on_function_call(name, args, raw_data)
608
+ tool = @tools[name]
609
+ unless tool
610
+ return { 'response' => "Function '#{name}' not found" }
611
+ end
612
+
613
+ # Validate secure token if needed
614
+ if tool[:secure]
615
+ call_id = raw_data && (raw_data['call_id'] || (raw_data['call'] && raw_data['call']['call_id']))
616
+ token = raw_data && raw_data['meta_data_token']
617
+ if call_id && token
618
+ unless @session_manager.validate_token(name, token, call_id)
619
+ return { 'response' => 'Invalid or expired token' }
620
+ end
621
+ end
622
+ end
623
+
624
+ result = tool[:handler].call(args, raw_data)
625
+ if result.is_a?(Hash)
626
+ result
627
+ elsif result.respond_to?(:to_h) && !result.nil?
628
+ # FunctionResult-like object that responds to to_h.
629
+ result.to_h
630
+ else
631
+ # Neither a Hash nor a FunctionResult-like object. Warn and
632
+ # fall back to wrapping the stringified value, matching
633
+ # Python's web_mixin / serverless_mixin / tool_mixin behavior.
634
+ @logger.warn(
635
+ "unexpected_function_result_type: function=#{name.inspect} " \
636
+ "result_type=#{result.class.name.inspect}. SWAIG function " \
637
+ "returned a value that is neither a FunctionResult (responds " \
638
+ "to to_h) nor a Hash; falling back to wrapping the " \
639
+ "stringified value. The AI will see the stringified value as " \
640
+ "its tool response. Return a " \
641
+ "SignalWire::SWAIG::FunctionResult object or a Hash with at " \
642
+ "least a 'response' key."
643
+ )
644
+ { 'response' => result.to_s }
645
+ end
646
+ rescue => e
647
+ @logger.error "Tool '#{name}' error: #{e.message}"
648
+ { 'response' => "Error executing '#{name}': #{e.message}" }
649
+ end
650
+
651
+ # ==================================================================
652
+ # AI Config methods
653
+ # ==================================================================
654
+
655
+ def add_hint(hint)
656
+ @hints << hint if hint.is_a?(String) && !hint.empty?
657
+ self
658
+ end
659
+
660
+ def add_hints(hints)
661
+ if hints.is_a?(Array)
662
+ hints.each { |h| add_hint(h) }
663
+ end
664
+ self
665
+ end
666
+
667
+ # Add a complex (pattern-matched) hint.
668
+ #
669
+ # Python parity:
670
+ # ``add_pattern_hint(hint, pattern, replace, ignore_case=False)``.
671
+ # Ruby supports both the Python-style positional form and the
672
+ # legacy keyword form (``add_pattern_hint(pattern, hint:, language:)``)
673
+ # for backward compat.
674
+ #
675
+ # @overload add_pattern_hint(hint, pattern, replace, ignore_case: false)
676
+ # @param hint [String] hint to match
677
+ # @param pattern [String] regex pattern
678
+ # @param replace [String] replacement text
679
+ # @param ignore_case [Boolean] match without regard to case
680
+ # @overload add_pattern_hint(pattern, hint:, language: 'en-US')
681
+ # Legacy Ruby form — pattern + optional hint string and language.
682
+ def add_pattern_hint(*args, hint: nil, pattern: nil, replace: nil,
683
+ ignore_case: false, language: 'en-US')
684
+ # Three positional args = Python positional shape.
685
+ if args.length == 3
686
+ h_hint, h_pattern, h_replace = args
687
+ @hints << {
688
+ 'hint' => h_hint,
689
+ 'pattern' => h_pattern,
690
+ 'replace' => h_replace,
691
+ 'ignore_case' => ignore_case
692
+ }
693
+ return self
694
+ end
695
+
696
+ # Single positional ≡ legacy ``add_pattern_hint(pattern, hint:, language:)``.
697
+ if args.length == 1 && pattern.nil? && replace.nil?
698
+ legacy_pattern = args.first
699
+ entry = { 'pattern' => legacy_pattern }
700
+ entry['hint'] = hint if hint
701
+ entry['language'] = language if language
702
+ @hints << entry
703
+ return self
704
+ end
705
+
706
+ # Pure-keyword form (Python-named keywords)
707
+ if pattern && hint && replace
708
+ @hints << {
709
+ 'hint' => hint,
710
+ 'pattern' => pattern,
711
+ 'replace' => replace,
712
+ 'ignore_case' => ignore_case
713
+ }
714
+ return self
715
+ end
716
+
717
+ raise ArgumentError, 'add_pattern_hint: pass either (hint, pattern, replace) or use legacy (pattern, hint:, language:) form'
718
+ end
719
+
720
+ # Add a language configuration.
721
+ #
722
+ # Python parity: ``add_language(name, code, voice, speech_fillers=None,
723
+ # function_fillers=None, engine=None, model=None)``. Ruby supports
724
+ # both the Python-style positional shape AND the original
725
+ # ``add_language(config)`` hash form.
726
+ #
727
+ # Voice argument can be either a simple voice id (``"en-US-Neural2-F"``)
728
+ # or a combined ``"engine.voice:model"`` string
729
+ # (``"elevenlabs.josh:eleven_turbo_v2_5"``); the combined form is
730
+ # parsed into ``engine``/``voice``/``model`` keys when ``engine``
731
+ # and ``model`` aren't supplied explicitly.
732
+ #
733
+ # @overload add_language(config)
734
+ # @param config [Hash] preformed language config
735
+ # @overload add_language(name, code, voice, speech_fillers: nil,
736
+ # function_fillers: nil, engine: nil, model: nil, params: nil)
737
+ # @param name [String] language name (e.g. ``"English"``)
738
+ # @param code [String] BCP47 language code (e.g. ``"en-US"``)
739
+ # @param voice [String] voice id or ``engine.voice:model`` string
740
+ # @param speech_fillers [Array<String>, nil] filler phrases for
741
+ # natural speech
742
+ # @param function_fillers [Array<String>, nil] filler phrases
743
+ # during function calls
744
+ # @param engine [String, nil] explicit engine override
745
+ # @param model [String, nil] explicit model override
746
+ # @param params [Hash, nil] optional per-language params (engine-
747
+ # specific tuning, voice settings, etc.). Emitted as the language
748
+ # object's ``params`` key in SWML; the key is only emitted when
749
+ # non-empty so existing entries stay byte-identical.
750
+ def add_language(name_or_config, code = nil, voice = nil,
751
+ speech_fillers: nil, function_fillers: nil,
752
+ engine: nil, model: nil, params: nil)
753
+ # Hash form (legacy / direct config)
754
+ if name_or_config.is_a?(Hash) && code.nil? && voice.nil?
755
+ @languages << name_or_config
756
+ return self
757
+ end
758
+
759
+ raise ArgumentError, 'add_language: name, code, voice are required (or pass a Hash)' if code.nil? || voice.nil?
760
+
761
+ lang = { 'name' => name_or_config, 'code' => code }
762
+
763
+ if engine || model
764
+ lang['voice'] = voice
765
+ lang['engine'] = engine if engine
766
+ lang['model'] = model if model
767
+ elsif voice.is_a?(String) && voice.include?('.') && voice.include?(':')
768
+ # "engine.voice:model"
769
+ engine_voice, model_part = voice.split(':', 2)
770
+ engine_part, voice_part = engine_voice.split('.', 2)
771
+ lang['voice'] = voice_part
772
+ lang['engine'] = engine_part
773
+ lang['model'] = model_part
774
+ else
775
+ lang['voice'] = voice
776
+ end
777
+
778
+ if speech_fillers && function_fillers
779
+ lang['speech_fillers'] = speech_fillers
780
+ lang['function_fillers'] = function_fillers
781
+ elsif speech_fillers || function_fillers
782
+ lang['fillers'] = speech_fillers || function_fillers
783
+ end
784
+
785
+ # Per-language params (engine-specific tuning, voice settings,
786
+ # etc.). Only emit the key when non-empty so we don't pollute
787
+ # SWML with empty objects.
788
+ lang['params'] = params if params.is_a?(Hash) && !params.empty?
789
+
790
+ @languages << lang
791
+ self
792
+ end
793
+
794
+ # Set (or replace) the per-language ``params`` dict on an
795
+ # already-added language. Useful when language entries are built up
796
+ # via add_language first and engine-specific tuning is added later
797
+ # (e.g. from a config loader). Returns self for chaining.
798
+ #
799
+ # @param code [String] language code as previously passed to
800
+ # ``add_language`` (e.g. ``"en-US"``).
801
+ # @param params [Hash] engine-specific params hash to attach.
802
+ # Empty hash removes the key.
803
+ # @return [self] No-op if the code isn't found.
804
+ def set_language_params(code, params)
805
+ @languages.each do |lang|
806
+ next unless lang.is_a?(Hash) && lang['code'] == code
807
+ if params.is_a?(Hash) && !params.empty?
808
+ lang['params'] = params
809
+ else
810
+ lang.delete('params')
811
+ end
812
+ break
813
+ end
814
+ self
815
+ end
816
+
817
+ # Read the per-language ``params`` hash for a previously-added
818
+ # language.
819
+ #
820
+ # @param code [String] language code as previously passed to ``add_language``.
821
+ # @return [Hash, nil] the params hash if set, ``nil`` otherwise
822
+ # (including when the code is unknown).
823
+ def get_language_params(code)
824
+ @languages.each do |lang|
825
+ return lang['params'] if lang.is_a?(Hash) && lang['code'] == code
826
+ end
827
+ nil
828
+ end
829
+
830
+ def set_languages(languages)
831
+ @languages = languages.dup if languages.is_a?(Array)
832
+ self
833
+ end
834
+
835
+ def add_pronunciation(phrase, pronunciation, language_code: 'en-US')
836
+ rule = { 'replace' => phrase, 'with' => pronunciation }
837
+ rule['ignore_case'] = false
838
+ @pronounce << rule
839
+ self
840
+ end
841
+
842
+ def set_pronunciations(pronunciations)
843
+ @pronounce = pronunciations.dup if pronunciations.is_a?(Array)
844
+ self
845
+ end
846
+
847
+ def set_param(key, value)
848
+ @params[key.to_s] = value
849
+ self
850
+ end
851
+
852
+ def set_params(params)
853
+ if params.is_a?(Hash)
854
+ params.each { |k, v| @params[k.to_s] = v }
855
+ end
856
+ self
857
+ end
858
+
859
+ def set_global_data(data)
860
+ @global_data.merge!(data) if data.is_a?(Hash)
861
+ self
862
+ end
863
+
864
+ def update_global_data(data)
865
+ set_global_data(data)
866
+ end
867
+
868
+ def set_native_functions(names)
869
+ @native_functions = names.dup if names.is_a?(Array)
870
+ self
871
+ end
872
+
873
+ # The complete set of internal SWAIG function names that accept
874
+ # fillers, matching the SWAIGInternalFiller schema definition.
875
+ #
876
+ # Any name outside this set is silently ignored by the runtime —
877
+ # +set_internal_fillers+ and +add_internal_filler+ warn if you pass
878
+ # an unknown name.
879
+ #
880
+ # Notable absences: +change_step+, +gather_submit+, or arbitrary
881
+ # user-defined SWAIG function names are NOT supported.
882
+ SUPPORTED_INTERNAL_FILLER_NAMES = %w[
883
+ hangup
884
+ check_time
885
+ wait_for_user
886
+ wait_seconds
887
+ adjust_response_latency
888
+ next_step
889
+ change_context
890
+ get_visual_input
891
+ get_ideal_strategy
892
+ ].freeze
893
+
894
+ # Set internal fillers for native SWAIG functions.
895
+ #
896
+ # Internal fillers are short phrases the AI agent speaks (via TTS)
897
+ # while an internal/native function is running, so the caller
898
+ # doesn't hear dead air during transitions or background work.
899
+ #
900
+ # Supported function names (match the SWAIGInternalFiller schema):
901
+ # +hangup+, +check_time+, +wait_for_user+, +wait_seconds+,
902
+ # +adjust_response_latency+, +next_step+, +change_context+,
903
+ # +get_visual_input+, +get_ideal_strategy+. See
904
+ # SUPPORTED_INTERNAL_FILLER_NAMES.
905
+ #
906
+ # Notably NOT supported: +change_step+, +gather_submit+, or
907
+ # arbitrary user-defined SWAIG function names. The runtime only
908
+ # honors fillers for the names listed above; everything else is
909
+ # silently ignored at the SWML level. This method warns at
910
+ # registration time if you pass an unknown name so you catch the
911
+ # typo early.
912
+ #
913
+ # Expected format: +{ function_name => { language_code => [phrases] } }+
914
+ def set_internal_fillers(fillers)
915
+ if fillers.is_a?(Hash)
916
+ unknown = (fillers.keys.map(&:to_s) - SUPPORTED_INTERNAL_FILLER_NAMES).sort
917
+ if unknown.any?
918
+ @logger.warn(
919
+ "unknown_internal_filler_names: #{unknown.inspect}. " \
920
+ "set_internal_fillers received names that the SWML schema " \
921
+ "does not recognize. Those entries will be ignored by the " \
922
+ "runtime. Supported names: #{SUPPORTED_INTERNAL_FILLER_NAMES.sort.inspect}."
923
+ )
924
+ end
925
+ @internal_fillers.merge!(fillers)
926
+ end
927
+ self
928
+ end
929
+
930
+ # Add internal fillers for a single internal function and language.
931
+ #
932
+ # See +set_internal_fillers+ for the complete list of supported
933
+ # +func_name+ values (SUPPORTED_INTERNAL_FILLER_NAMES) and what
934
+ # fillers do. Names outside the supported set log a warning and are
935
+ # stored but the runtime will not play them.
936
+ def add_internal_filler(func_name, lang_code, fillers)
937
+ if func_name && lang_code && fillers.is_a?(Array) && !fillers.empty?
938
+ unless SUPPORTED_INTERNAL_FILLER_NAMES.include?(func_name.to_s)
939
+ @logger.warn(
940
+ "unknown_internal_filler_name: #{func_name.inspect}. " \
941
+ "add_internal_filler received a function name the SWML " \
942
+ "schema does not recognize. The entry will be stored but " \
943
+ "the runtime will not play these fillers. Supported " \
944
+ "names: #{SUPPORTED_INTERNAL_FILLER_NAMES.sort.inspect}."
945
+ )
946
+ end
947
+ @internal_fillers[func_name] ||= {}
948
+ @internal_fillers[func_name][lang_code] = fillers
949
+ end
950
+ self
951
+ end
952
+
953
+ def enable_debug_events(level = 1)
954
+ @debug_events_enabled = true
955
+ @debug_events_level = level
956
+ self
957
+ end
958
+
959
+ def add_function_include(url, functions, meta_data: nil)
960
+ include = { 'url' => url, 'functions' => functions }
961
+ include['meta_data'] = meta_data if meta_data.is_a?(Hash)
962
+ @function_includes << include
963
+ self
964
+ end
965
+
966
+ def set_function_includes(includes)
967
+ @function_includes = includes.dup if includes.is_a?(Array)
968
+ self
969
+ end
970
+
971
+ def set_prompt_llm_params(**params)
972
+ @prompt_llm_params.merge!(params.transform_keys(&:to_s))
973
+ self
974
+ end
975
+
976
+ def set_post_prompt_llm_params(**params)
977
+ @post_prompt_llm_params.merge!(params.transform_keys(&:to_s))
978
+ self
979
+ end
980
+
981
+ # ==================================================================
982
+ # Verb management
983
+ # ==================================================================
984
+
985
+ def add_pre_answer_verb(verb_name, config)
986
+ @pre_answer_verbs << [verb_name.to_s, config]
987
+ self
988
+ end
989
+
990
+ def clear_pre_answer_verbs
991
+ @pre_answer_verbs = []
992
+ self
993
+ end
994
+
995
+ def add_answer_verb(config)
996
+ @answer_config = config
997
+ self
998
+ end
999
+
1000
+ def add_post_answer_verb(verb_name, config)
1001
+ @post_answer_verbs << [verb_name.to_s, config]
1002
+ self
1003
+ end
1004
+
1005
+ def clear_post_answer_verbs
1006
+ @post_answer_verbs = []
1007
+ self
1008
+ end
1009
+
1010
+ def add_post_ai_verb(verb_name, config)
1011
+ @post_ai_verbs << [verb_name.to_s, config]
1012
+ self
1013
+ end
1014
+
1015
+ def clear_post_ai_verbs
1016
+ @post_ai_verbs = []
1017
+ self
1018
+ end
1019
+
1020
+ # ==================================================================
1021
+ # Contexts
1022
+ # ==================================================================
1023
+
1024
+ # Define / retrieve the ContextBuilder for this agent.
1025
+ #
1026
+ # Python parity: ``define_contexts(contexts)`` accepts either a
1027
+ # ``ContextBuilder`` (calls ``.to_dict()`` to materialise) or a
1028
+ # raw ``dict`` and stores it on the agent. Ruby supports both
1029
+ # forms PLUS the original lazy-getter idiom:
1030
+ #
1031
+ # 1. **Lazy getter** (Ruby idiom) — ``agent.define_contexts``
1032
+ # returns the existing builder, creating one if needed.
1033
+ # 2. **Override with builder** — ``agent.define_contexts(other_cb)``
1034
+ # replaces the current builder with the supplied one (Python
1035
+ # parity).
1036
+ # 3. **Override with hash** — ``agent.define_contexts({...})``
1037
+ # builds a fresh builder using the provided contexts hash
1038
+ # (Python parity for raw-dict input).
1039
+ #
1040
+ # @param contexts [SignalWire::Contexts::ContextBuilder, Hash, nil]
1041
+ # optional override
1042
+ # @return [SignalWire::Contexts::ContextBuilder] the active builder
1043
+ def define_contexts(contexts = nil)
1044
+ if contexts.is_a?(Contexts::ContextBuilder)
1045
+ @context_builder = contexts
1046
+ @context_builder.attach_agent(self) if @context_builder.respond_to?(:attach_agent)
1047
+ return @context_builder
1048
+ end
1049
+
1050
+ if contexts.is_a?(Hash)
1051
+ cb = Contexts::ContextBuilder.new(self)
1052
+ contexts.each do |name, body|
1053
+ ctx = cb.add_context(name.to_s)
1054
+ steps = (body.is_a?(Hash) ? body['steps'] : nil) || []
1055
+ steps.each do |step_h|
1056
+ step_name = step_h['name'] || step_h[:name] || raise(ArgumentError, 'step missing name')
1057
+ step = ctx.add_step(step_name)
1058
+ step.set_text(step_h['text']) if step_h['text']
1059
+ end
1060
+ end
1061
+ @context_builder = cb
1062
+ return cb
1063
+ end
1064
+
1065
+ unless contexts.nil?
1066
+ raise ArgumentError, 'contexts must be a ContextBuilder, Hash, or nil'
1067
+ end
1068
+
1069
+ @context_builder ||= begin
1070
+ cb = Contexts::ContextBuilder.new(self)
1071
+ cb.attach_agent(self) if cb.respond_to?(:attach_agent)
1072
+ cb
1073
+ end
1074
+ end
1075
+
1076
+ alias contexts define_contexts
1077
+
1078
+ # Remove all contexts, returning the agent to a no-contexts state.
1079
+ # This is a convenience wrapper around +define_contexts.reset+.
1080
+ # Use it in a dynamic config callback when you need to rebuild
1081
+ # contexts from scratch for a specific request.
1082
+ def reset_contexts
1083
+ @context_builder&.reset
1084
+ self
1085
+ end
1086
+
1087
+ # Return the names of all registered SWAIG tools in insertion
1088
+ # order. Used by ContextBuilder#validate! to detect collisions with
1089
+ # reserved native tool names.
1090
+ def list_tool_names
1091
+ (@tools.keys + @swaig_functions.keys).uniq
1092
+ end
1093
+
1094
+ # ==================================================================
1095
+ # Skill integration
1096
+ # ==================================================================
1097
+
1098
+ # Load and register a skill by name.
1099
+ def add_skill(skill_name, params = {})
1100
+ # Ensure builtins are registered
1101
+ Skills::SkillRegistry.register_builtins!
1102
+
1103
+ factory = Skills::SkillRegistry.get_factory(skill_name)
1104
+ raise ArgumentError, "Unknown skill: '#{skill_name}'" unless factory
1105
+
1106
+ skill = factory.call(params)
1107
+ @skill_manager.load(skill.instance_key, skill)
1108
+ @loaded_skills[skill_name] = skill
1109
+
1110
+ # Register tools from the skill
1111
+ tool_defs = skill.register_tools
1112
+ if tool_defs.is_a?(Array)
1113
+ tool_defs.each do |td|
1114
+ td_name = td[:name] || td['name']
1115
+ td_desc = td[:description] || td['description']
1116
+ td_params = td[:parameters] || td['parameters'] || {}
1117
+ td_handler = td[:handler] || td['handler']
1118
+ next unless td_name && td_handler
1119
+
1120
+ define_tool(
1121
+ name: td_name,
1122
+ description: td_desc || '',
1123
+ parameters: td_params,
1124
+ &td_handler
1125
+ )
1126
+ end
1127
+ end
1128
+
1129
+ # Merge hints
1130
+ skill_hints = skill.get_hints
1131
+ @hints.concat(skill_hints) if skill_hints.is_a?(Array) && !skill_hints.empty?
1132
+
1133
+ # Merge global data
1134
+ skill_data = skill.get_global_data
1135
+ @global_data.merge!(skill_data) if skill_data.is_a?(Hash) && !skill_data.empty?
1136
+
1137
+ # Merge prompt sections
1138
+ skill_sections = skill.get_prompt_sections
1139
+ if skill_sections.is_a?(Array) && !skill_sections.empty?
1140
+ @prompt_text = nil # switch to POM mode
1141
+ @prompt_pom = nil
1142
+ skill_sections.each do |sec|
1143
+ @pom_sections << sec
1144
+ end
1145
+ end
1146
+
1147
+ self
1148
+ end
1149
+
1150
+ def remove_skill(skill_name)
1151
+ skill = @loaded_skills.delete(skill_name)
1152
+ @skill_manager.unload(skill.instance_key) if skill
1153
+ self
1154
+ end
1155
+
1156
+ def list_skills
1157
+ @loaded_skills.keys
1158
+ end
1159
+
1160
+ def has_skill?(skill_name)
1161
+ @loaded_skills.key?(skill_name)
1162
+ end
1163
+
1164
+ # ==================================================================
1165
+ # Web / HTTP configuration
1166
+ # ==================================================================
1167
+
1168
+ def set_dynamic_config_callback(callable = nil, &block)
1169
+ @dynamic_config_callback = callable || block
1170
+ self
1171
+ end
1172
+
1173
+ def manual_set_proxy_url(url)
1174
+ @proxy_url_base = url
1175
+ self
1176
+ end
1177
+
1178
+ def set_web_hook_url(url)
1179
+ @web_hook_url_override = url
1180
+ self
1181
+ end
1182
+
1183
+ def set_post_prompt_url(url)
1184
+ @post_prompt_url_override = url
1185
+ self
1186
+ end
1187
+
1188
+ def add_swaig_query_params(params)
1189
+ @swaig_query_params.merge!(params) if params.is_a?(Hash)
1190
+ self
1191
+ end
1192
+
1193
+ def clear_swaig_query_params
1194
+ @swaig_query_params = {}
1195
+ self
1196
+ end
1197
+
1198
+ def enable_debug_routes
1199
+ @debug_routes_enabled = true
1200
+ self
1201
+ end
1202
+
1203
+ # ==================================================================
1204
+ # SIP
1205
+ # ==================================================================
1206
+
1207
+ def enable_sip_routing(auto_map: true, path: '/sip')
1208
+ @sip_routing_enabled = true
1209
+ @sip_auto_map = auto_map
1210
+ @sip_path = path
1211
+ self
1212
+ end
1213
+
1214
+ def register_sip_username(username)
1215
+ @sip_usernames << username
1216
+ self
1217
+ end
1218
+
1219
+ # Extract a SIP username from a SIP URI string.
1220
+ #
1221
+ # Parses URIs of the form "sip:user@domain" and returns the user part.
1222
+ # Handles optional "sip:" or "sips:" scheme prefixes.
1223
+ #
1224
+ # @param sip_uri [String] a SIP URI, e.g. "sip:alice@example.com"
1225
+ # @return [String, nil] the username, or nil if the URI cannot be parsed
1226
+ def self.extract_sip_username(sip_uri)
1227
+ return nil if sip_uri.nil? || sip_uri.empty?
1228
+
1229
+ # Strip optional sip:/sips: scheme
1230
+ uri = sip_uri.to_s.strip
1231
+ uri = uri.sub(%r{\Asips?:}, '')
1232
+
1233
+ # Extract user part before @
1234
+ if uri.include?('@')
1235
+ user = uri.split('@', 2).first
1236
+ user && !user.empty? ? user : nil
1237
+ else
1238
+ nil
1239
+ end
1240
+ end
1241
+
1242
+ # Extract the SIP username from request body data.
1243
+ #
1244
+ # Looks for SIP URI in common request body fields
1245
+ # (e.g., "to", "from", "sip_uri", "call.to", "call.from").
1246
+ #
1247
+ # @param request_data [Hash] the parsed request body
1248
+ # @return [String, nil] the extracted SIP username, or nil
1249
+ def self.extract_sip_username_from_request(request_data)
1250
+ return nil unless request_data.is_a?(Hash)
1251
+
1252
+ # Check common SIP URI fields
1253
+ candidates = [
1254
+ request_data['to'],
1255
+ request_data['from'],
1256
+ request_data['sip_uri'],
1257
+ request_data.dig('call', 'to'),
1258
+ request_data.dig('call', 'from')
1259
+ ].compact
1260
+
1261
+ candidates.each do |uri|
1262
+ username = extract_sip_username(uri.to_s)
1263
+ return username if username
1264
+ end
1265
+
1266
+ nil
1267
+ end
1268
+
1269
+ # ==================================================================
1270
+ # MCP integration
1271
+ # ==================================================================
1272
+
1273
+ # Add an external MCP server for tool discovery and invocation.
1274
+ #
1275
+ # @param url [String] MCP server HTTP endpoint URL
1276
+ # @param headers [Hash, nil] optional HTTP headers
1277
+ # @param resources [Boolean] whether to fetch resources into global_data
1278
+ # @param resource_vars [Hash, nil] variables for URI template substitution
1279
+ # @return [self]
1280
+ def add_mcp_server(url, headers: nil, resources: false, resource_vars: nil)
1281
+ server = { 'url' => url }
1282
+ server['headers'] = headers if headers && !headers.empty?
1283
+ server['resources'] = true if resources
1284
+ server['resource_vars'] = resource_vars if resource_vars && !resource_vars.empty?
1285
+ @mcp_servers << server
1286
+ self
1287
+ end
1288
+
1289
+ # Expose this agent's tools as an MCP server endpoint at /mcp.
1290
+ #
1291
+ # @return [self]
1292
+ def enable_mcp_server
1293
+ @mcp_server_enabled = true
1294
+ self
1295
+ end
1296
+
1297
+ # @api private
1298
+ # Build MCP tool list from registered tools.
1299
+ def _build_mcp_tool_list
1300
+ tools = []
1301
+ @tools.each do |name, tool|
1302
+ t = {
1303
+ 'name' => name,
1304
+ 'description' => tool[:definition]['description'] || name,
1305
+ }
1306
+ params = tool[:definition]['parameters']
1307
+ if params && !params.empty?
1308
+ t['inputSchema'] = params.key?('type') ? params : { 'type' => 'object', 'properties' => params }
1309
+ else
1310
+ t['inputSchema'] = { 'type' => 'object', 'properties' => {} }
1311
+ end
1312
+ tools << t
1313
+ end
1314
+ tools
1315
+ end
1316
+
1317
+ # @api private
1318
+ # Handle a single MCP JSON-RPC 2.0 request and return the response hash.
1319
+ def _handle_mcp_request(body)
1320
+ jsonrpc = body['jsonrpc']
1321
+ method = body['method'] || ''
1322
+ req_id = body['id']
1323
+ params = body['params'] || {}
1324
+
1325
+ unless jsonrpc == '2.0'
1326
+ return _mcp_error(req_id, -32600, 'Invalid JSON-RPC version')
1327
+ end
1328
+
1329
+ case method
1330
+ when 'initialize'
1331
+ {
1332
+ 'jsonrpc' => '2.0', 'id' => req_id,
1333
+ 'result' => {
1334
+ 'protocolVersion' => '2025-06-18',
1335
+ 'capabilities' => { 'tools' => {} },
1336
+ 'serverInfo' => { 'name' => @name, 'version' => '1.0.0' }
1337
+ }
1338
+ }
1339
+ when 'notifications/initialized'
1340
+ { 'jsonrpc' => '2.0', 'id' => req_id, 'result' => {} }
1341
+ when 'tools/list'
1342
+ {
1343
+ 'jsonrpc' => '2.0', 'id' => req_id,
1344
+ 'result' => { 'tools' => _build_mcp_tool_list }
1345
+ }
1346
+ when 'tools/call'
1347
+ tool_name = params['name'] || ''
1348
+ arguments = params['arguments'] || {}
1349
+
1350
+ tool = @tools[tool_name]
1351
+ unless tool
1352
+ return _mcp_error(req_id, -32602, "Unknown tool: #{tool_name}")
1353
+ end
1354
+
1355
+ begin
1356
+ raw_data = {
1357
+ 'function' => tool_name,
1358
+ 'argument' => { 'parsed' => [arguments] }
1359
+ }
1360
+ result = tool[:handler].call(arguments, raw_data)
1361
+
1362
+ response_text = ''
1363
+ if result.respond_to?(:to_h)
1364
+ h = result.to_h
1365
+ response_text = h['response'] || ''
1366
+ elsif result.is_a?(Hash)
1367
+ response_text = result['response'] || result.to_s
1368
+ elsif result.is_a?(String)
1369
+ response_text = result
1370
+ end
1371
+
1372
+ {
1373
+ 'jsonrpc' => '2.0', 'id' => req_id,
1374
+ 'result' => {
1375
+ 'content' => [{ 'type' => 'text', 'text' => response_text }],
1376
+ 'isError' => false
1377
+ }
1378
+ }
1379
+ rescue => e
1380
+ @logger.error "MCP tool call error: #{tool_name}: #{e.message}"
1381
+ {
1382
+ 'jsonrpc' => '2.0', 'id' => req_id,
1383
+ 'result' => {
1384
+ 'content' => [{ 'type' => 'text', 'text' => "Error: #{e.message}" }],
1385
+ 'isError' => true
1386
+ }
1387
+ }
1388
+ end
1389
+ when 'ping'
1390
+ { 'jsonrpc' => '2.0', 'id' => req_id, 'result' => {} }
1391
+ else
1392
+ _mcp_error(req_id, -32601, "Method not found: #{method}")
1393
+ end
1394
+ end
1395
+
1396
+ # @api private
1397
+ def _mcp_error(req_id, code, message)
1398
+ {
1399
+ 'jsonrpc' => '2.0',
1400
+ 'id' => req_id,
1401
+ 'error' => { 'code' => code, 'message' => message }
1402
+ }
1403
+ end
1404
+
1405
+ # ==================================================================
1406
+ # Lifecycle
1407
+ # ==================================================================
1408
+
1409
+ # Python parity: ``on_summary(self, summary, raw_data=None)`` is a
1410
+ # virtual hook called when a post-prompt summary is received.
1411
+ # Ruby supports two equivalent shapes:
1412
+ #
1413
+ # 1. **Registration** (Ruby idiom) — pass a block to install a
1414
+ # callback. The block receives ``(summary, raw_data)`` when a
1415
+ # summary is delivered. ``on_summary { |sum, raw| ... }``
1416
+ # 2. **Override** (Python idiom) — subclass and override
1417
+ # ``on_summary(summary, raw_data = nil)``. Default
1418
+ # implementation calls the registered block (if any) and
1419
+ # otherwise no-ops.
1420
+ #
1421
+ # @param summary [Hash, nil] the post-prompt summary
1422
+ # @param raw_data [Hash, nil] the complete raw POST data
1423
+ # @yield [summary, raw_data] optional callback registration
1424
+ def on_summary(summary = nil, raw_data = nil, &block)
1425
+ if block
1426
+ @summary_callback = block
1427
+ return self
1428
+ end
1429
+
1430
+ @summary_callback&.call(summary, raw_data)
1431
+ nil
1432
+ end
1433
+
1434
+ def on_debug_event(&block)
1435
+ @debug_event_callback = block
1436
+ self
1437
+ end
1438
+
1439
+ # Universal run method — mirrors Python's
1440
+ # ``WebMixin.run(event=None, context=None, force_mode=None,
1441
+ # host=None, port=None)``.
1442
+ #
1443
+ # Detects execution mode (server / lambda / cgi) and routes
1444
+ # accordingly. ``force_mode`` overrides auto-detection.
1445
+ #
1446
+ # @param event [Object, nil] serverless event
1447
+ # @param context [Object, nil] serverless context
1448
+ # @param force_mode [String, nil] one of ``"server"``, ``"lambda"``,
1449
+ # ``"cgi"``
1450
+ # @param host [String, nil] override bind host (server mode)
1451
+ # @param port [Integer, nil] override bind port (server mode)
1452
+ def run(event: nil, context: nil, force_mode: nil, host: nil, port: nil)
1453
+ mode = force_mode || _detect_run_mode
1454
+
1455
+ case mode
1456
+ when 'lambda'
1457
+ _run_lambda(event, context)
1458
+ when 'cgi'
1459
+ _run_cgi
1460
+ else
1461
+ serve(host: host, port: port)
1462
+ end
1463
+ end
1464
+
1465
+ # @api private
1466
+ def _detect_run_mode
1467
+ return 'lambda' if ENV['AWS_LAMBDA_FUNCTION_NAME'] && !ENV['AWS_LAMBDA_FUNCTION_NAME'].empty?
1468
+ return 'cgi' if ENV['GATEWAY_INTERFACE']
1469
+ 'server'
1470
+ end
1471
+
1472
+ # @api private
1473
+ def _run_lambda(event, _context)
1474
+ require 'stringio'
1475
+ event ||= {}
1476
+ path = event['path'] || event['rawPath'] || '/'
1477
+ method = event['httpMethod'] || event.dig('requestContext', 'http', 'method') || 'GET'
1478
+ body = event['body'] || ''
1479
+ env = {
1480
+ 'PATH_INFO' => path,
1481
+ 'REQUEST_METHOD' => method,
1482
+ 'QUERY_STRING' => '',
1483
+ 'rack.input' => StringIO.new(body),
1484
+ 'rack.errors' => $stderr
1485
+ }
1486
+ status, headers, response_body = rack_app.call(env)
1487
+ body_str = response_body.respond_to?(:join) ? response_body.join : response_body.to_s
1488
+ {
1489
+ 'statusCode' => Integer(status),
1490
+ 'headers' => headers,
1491
+ 'body' => body_str
1492
+ }
1493
+ end
1494
+
1495
+ # @api private
1496
+ def _run_cgi
1497
+ require 'stringio'
1498
+ env = {
1499
+ 'PATH_INFO' => ENV['PATH_INFO'] || '/',
1500
+ 'REQUEST_METHOD' => ENV['REQUEST_METHOD'] || 'GET',
1501
+ 'QUERY_STRING' => ENV['QUERY_STRING'] || '',
1502
+ 'rack.input' => StringIO.new(''),
1503
+ 'rack.errors' => $stderr
1504
+ }
1505
+ status, headers, body = rack_app.call(env)
1506
+ body_str = body.respond_to?(:join) ? body.join : body.to_s
1507
+ out = +"Status: #{status}\r\n"
1508
+ headers.each { |k, v| out << "#{k}: #{v}\r\n" }
1509
+ out << "\r\n"
1510
+ out << body_str
1511
+ out
1512
+ end
1513
+
1514
+ # Start the HTTP server (blocking).
1515
+ #
1516
+ # Python parity: ``serve(host=None, port=None)``. ``host`` /
1517
+ # ``port`` overrides default to constructor-supplied values.
1518
+ def serve(host: nil, port: nil)
1519
+ require 'webrick'
1520
+ bind_host = host || @host
1521
+ bind_port = port || @port
1522
+ @logger.info "Starting server on #{bind_host}:#{bind_port} ..."
1523
+ user, _pass = @basic_auth
1524
+ @logger.info "Basic-auth credentials — user: #{user} password: [REDACTED]"
1525
+
1526
+ @server = ::WEBrick::HTTPServer.new(
1527
+ Host: bind_host,
1528
+ Port: bind_port,
1529
+ Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
1530
+ AccessLog: []
1531
+ )
1532
+
1533
+ # Rack 3+ moved Handler to the rackup gem
1534
+ handler = begin
1535
+ require 'rackup/handler/webrick'
1536
+ Rackup::Handler::WEBrick
1537
+ rescue LoadError
1538
+ require 'rack/handler/webrick'
1539
+ Rack::Handler::WEBrick
1540
+ end
1541
+ @server.mount '/', handler, rack_app
1542
+
1543
+ trap('INT') { @server.shutdown }
1544
+ trap('TERM') { @server.shutdown }
1545
+
1546
+ @server.start
1547
+ end
1548
+
1549
+ # Return a Rack-compatible application for mounting.
1550
+ def rack_app
1551
+ @rack_app ||= _build_rack_app
1552
+ end
1553
+
1554
+ alias as_rack_app rack_app
1555
+
1556
+ # ==================================================================
1557
+ # SWML Rendering
1558
+ # ==================================================================
1559
+
1560
+ # Build the complete SWML document hash.
1561
+ #
1562
+ # @param request_data [Hash, nil] parsed request body
1563
+ # @param request [Rack::Request, nil] the HTTP request
1564
+ # @return [Hash]
1565
+ def render_swml(request_data = nil, request: nil)
1566
+ agent = self
1567
+
1568
+ # Dynamic config: clone into ephemeral copy
1569
+ if @dynamic_config_callback
1570
+ agent = _create_ephemeral_copy
1571
+ begin
1572
+ query_params = request ? _parse_query_string(request) : {}
1573
+ body_params = request_data || {}
1574
+ headers = request ? _extract_headers(request) : {}
1575
+ @dynamic_config_callback.call(query_params, body_params, headers, agent)
1576
+ rescue => e
1577
+ @logger.error "Dynamic config error: #{e.message}"
1578
+ end
1579
+ end
1580
+
1581
+ agent._render_swml_internal
1582
+ end
1583
+
1584
+ # @api private
1585
+ def _render_swml_internal
1586
+ sections_main = []
1587
+
1588
+ # PHASE 1: Pre-answer verbs
1589
+ @pre_answer_verbs.each do |verb_name, config|
1590
+ sections_main << { verb_name => config }
1591
+ end
1592
+
1593
+ # PHASE 2: Answer verb
1594
+ if @auto_answer
1595
+ answer_conf = @answer_config.empty? ? {} : @answer_config
1596
+ sections_main << { 'answer' => answer_conf }
1597
+ end
1598
+
1599
+ # PHASE 3: Post-answer verbs
1600
+ if @record_call
1601
+ sections_main << {
1602
+ 'record_call' => {
1603
+ 'format' => @record_format,
1604
+ 'stereo' => @record_stereo
1605
+ }
1606
+ }
1607
+ end
1608
+ @post_answer_verbs.each do |verb_name, config|
1609
+ sections_main << { verb_name => config }
1610
+ end
1611
+
1612
+ # PHASE 4: AI verb
1613
+ ai_config = _build_ai_config
1614
+ sections_main << { 'ai' => ai_config }
1615
+
1616
+ # PHASE 5: Post-AI verbs
1617
+ @post_ai_verbs.each do |verb_name, config|
1618
+ sections_main << { verb_name => config }
1619
+ end
1620
+
1621
+ {
1622
+ 'version' => '1.0.0',
1623
+ 'sections' => {
1624
+ 'main' => sections_main
1625
+ }
1626
+ }
1627
+ end
1628
+
1629
+ # Get the configured basic-auth credentials.
1630
+ #
1631
+ # Python parity: ``get_basic_auth_credentials(include_source=False)``.
1632
+ # When ``include_source`` is true, returns a 3-tuple ``[user,
1633
+ # pass, source]`` (``"environment"`` / ``"auto-generated"`` /
1634
+ # ``"provided"``). Otherwise returns ``[user, pass]``.
1635
+ def get_basic_auth_credentials(include_source: false)
1636
+ u, p = @basic_auth
1637
+ return [u, p] unless include_source
1638
+
1639
+ env_user = ENV['SWML_BASIC_AUTH_USER']
1640
+ env_pass = ENV['SWML_BASIC_AUTH_PASSWORD']
1641
+ source =
1642
+ if env_user && !env_user.empty? && env_pass && !env_pass.empty? && u == env_user && p == env_pass
1643
+ 'environment'
1644
+ elsif u&.start_with?('user_') && p && p.length > 20
1645
+ 'auto-generated'
1646
+ else
1647
+ 'provided'
1648
+ end
1649
+ [u, p, source]
1650
+ end
1651
+
1652
+ # ==================================================================
1653
+ # Private helpers
1654
+ # ==================================================================
1655
+
1656
+ private
1657
+
1658
+ # Build the AI verb configuration hash.
1659
+ def _build_ai_config
1660
+ ai = {}
1661
+
1662
+ # --- prompt -------------------------------------------------------
1663
+ prompt = get_prompt
1664
+ if prompt.is_a?(Array) && !prompt.empty?
1665
+ prompt_obj = { 'pom' => prompt }
1666
+ prompt_obj.merge!(@prompt_llm_params) unless @prompt_llm_params.empty?
1667
+ ai['prompt'] = prompt_obj
1668
+ elsif prompt.is_a?(String) && !prompt.empty?
1669
+ prompt_obj = { 'text' => prompt }
1670
+ prompt_obj.merge!(@prompt_llm_params) unless @prompt_llm_params.empty?
1671
+ ai['prompt'] = prompt_obj
1672
+ end
1673
+
1674
+ # --- post-prompt --------------------------------------------------
1675
+ if @post_prompt_text && !@post_prompt_text.empty?
1676
+ pp_obj = { 'text' => @post_prompt_text }
1677
+ pp_obj.merge!(@post_prompt_llm_params) unless @post_prompt_llm_params.empty?
1678
+ ai['post_prompt'] = pp_obj
1679
+
1680
+ # post_prompt_url
1681
+ if @post_prompt_url_override
1682
+ ai['post_prompt_url'] = @post_prompt_url_override
1683
+ else
1684
+ ai['post_prompt_url'] = _build_webhook_url('post_prompt')
1685
+ end
1686
+ end
1687
+
1688
+ # --- SWAIG --------------------------------------------------------
1689
+ swaig = {}
1690
+
1691
+ # default webhook url
1692
+ default_url = @web_hook_url_override || _build_webhook_url('swaig', @swaig_query_params.empty? ? nil : @swaig_query_params)
1693
+ swaig['defaults'] = { 'web_hook_url' => default_url }
1694
+
1695
+ # functions
1696
+ functions = _build_functions_array
1697
+ swaig['functions'] = functions unless functions.empty?
1698
+
1699
+ # native functions
1700
+ swaig['native_functions'] = @native_functions unless @native_functions.empty?
1701
+
1702
+ # includes
1703
+ swaig['includes'] = @function_includes unless @function_includes.empty?
1704
+
1705
+ # internal_fillers
1706
+ swaig['internal_fillers'] = @internal_fillers unless @internal_fillers.empty?
1707
+
1708
+ ai['SWAIG'] = swaig unless swaig.keys == ['defaults'] && functions.empty?
1709
+
1710
+ # --- hints --------------------------------------------------------
1711
+ ai['hints'] = @hints.dup unless @hints.empty?
1712
+
1713
+ # --- languages ----------------------------------------------------
1714
+ ai['languages'] = @languages.dup unless @languages.empty?
1715
+
1716
+ # --- pronunciations -----------------------------------------------
1717
+ ai['pronounce'] = @pronounce.dup unless @pronounce.empty?
1718
+
1719
+ # --- params -------------------------------------------------------
1720
+ merged_params = @params.dup
1721
+ if @debug_events_enabled
1722
+ merged_params['debug_webhook_url'] = _build_webhook_url('debug_events')
1723
+ merged_params['debug_webhook_level'] = @debug_events_level
1724
+ end
1725
+ ai['params'] = merged_params unless merged_params.empty?
1726
+
1727
+ # --- global_data --------------------------------------------------
1728
+ ai['global_data'] = @global_data.dup unless @global_data.empty?
1729
+
1730
+ # --- contexts -----------------------------------------------------
1731
+ if @context_builder
1732
+ begin
1733
+ ai['contexts'] = @context_builder.to_h
1734
+ rescue ArgumentError
1735
+ # invalid context config — skip silently
1736
+ end
1737
+ end
1738
+
1739
+ # --- MCP servers ---------------------------------------------------
1740
+ ai['mcp_servers'] = @mcp_servers.map(&:dup) unless @mcp_servers.empty?
1741
+
1742
+ ai
1743
+ end
1744
+
1745
+ # Build the functions array for the SWAIG section.
1746
+ def _build_functions_array
1747
+ functions = []
1748
+
1749
+ @tools.each do |name, tool|
1750
+ func_entry = tool[:definition].dup
1751
+ # Add per-function webhook URL if it has a token or query params
1752
+ if tool[:secure] || !@swaig_query_params.empty?
1753
+ qp = @swaig_query_params.dup
1754
+ if tool[:secure]
1755
+ # Note: token is generated per-call; in render we can't know call_id yet,
1756
+ # so secure tools get per-function URLs at request time.
1757
+ # For now, the default webhook URL handles dispatch.
1758
+ end
1759
+ func_entry['web_hook_url'] = _build_webhook_url('swaig', qp) unless qp.empty?
1760
+ end
1761
+ functions << func_entry
1762
+ end
1763
+
1764
+ @swaig_functions.each do |_name, func_def|
1765
+ functions << func_def.dup
1766
+ end
1767
+
1768
+ functions
1769
+ end
1770
+
1771
+ # Build a webhook URL with optional query params.
1772
+ def _build_webhook_url(endpoint, query_params = nil)
1773
+ base = _base_url
1774
+ path = @route == '/' ? "/#{endpoint}" : "#{@route}/#{endpoint}"
1775
+
1776
+ url = "#{base}#{path}"
1777
+
1778
+ if query_params && !query_params.empty?
1779
+ qs = URI.encode_www_form(query_params)
1780
+ url = "#{url}?#{qs}"
1781
+ end
1782
+
1783
+ url
1784
+ end
1785
+
1786
+ # Compute the base URL for webhook construction.
1787
+ #
1788
+ # Precedence (matches the Python SDK):
1789
+ # 1. +SWML_PROXY_URL_BASE+ or a call to +manual_set_proxy_url+
1790
+ # (an explicit override always wins)
1791
+ # 2. AWS Lambda-derived URL when execution mode is +:lambda+
1792
+ # (either +AWS_LAMBDA_FUNCTION_URL+ or the Function URL built
1793
+ # from +AWS_LAMBDA_FUNCTION_NAME+ + +AWS_REGION+)
1794
+ # 3. +http://user:pass@host:port+ for local server mode
1795
+ #
1796
+ # This method intentionally returns only the *base* — the agent's
1797
+ # +@route+ is appended by {#_build_webhook_url}. Never bake the
1798
+ # route into the base here, or a non-root agent deployed behind a
1799
+ # proxy will have its mount point silently dropped from webhook
1800
+ # URLs.
1801
+ def _base_url
1802
+ return @proxy_url_base.chomp('/') if @proxy_url_base && !@proxy_url_base.empty?
1803
+
1804
+ if Runtime.lambda?
1805
+ lambda_base = Runtime.lambda_base_url
1806
+ if lambda_base
1807
+ user, pass = @basic_auth
1808
+ return _embed_auth(lambda_base, user, pass)
1809
+ end
1810
+ end
1811
+
1812
+ user, pass = @basic_auth
1813
+ "http://#{user}:#{pass}@#{@host}:#{@port}"
1814
+ end
1815
+
1816
+ # Embed basic-auth credentials into +base+ immediately after the
1817
+ # scheme. Returns +base+ untouched when either credential is blank
1818
+ # or the URL already contains an @-delimited userinfo component.
1819
+ def _embed_auth(base, user, pass)
1820
+ return base if user.nil? || user.empty? || pass.nil? || pass.empty?
1821
+
1822
+ uri = URI.parse(base)
1823
+ return base if uri.userinfo && !uri.userinfo.empty?
1824
+
1825
+ uri.userinfo = "#{URI.encode_www_form_component(user)}:#{URI.encode_www_form_component(pass)}"
1826
+ uri.to_s
1827
+ rescue URI::InvalidURIError
1828
+ base
1829
+ end
1830
+
1831
+ # Normalise tool parameters into JSON-Schema form.
1832
+ def _normalise_parameters(params)
1833
+ return params if params.is_a?(Hash) && params['type'] == 'object'
1834
+ return { 'type' => 'object', 'properties' => {} } if params.nil? || params.empty?
1835
+
1836
+ # If the hash looks like {name => {type, description}}, wrap it.
1837
+ if params.is_a?(Hash) && !params.key?('type')
1838
+ { 'type' => 'object', 'properties' => params.transform_keys(&:to_s) }
1839
+ else
1840
+ params
1841
+ end
1842
+ end
1843
+
1844
+ # Create an ephemeral deep copy for dynamic config.
1845
+ def _create_ephemeral_copy
1846
+ copy = dup
1847
+ # Deep-copy mutable collections
1848
+ copy.instance_variable_set(:@pom_sections, @pom_sections.map(&:dup))
1849
+ copy.instance_variable_set(:@tools, @tools.transform_values(&:dup))
1850
+ copy.instance_variable_set(:@swaig_functions, @swaig_functions.transform_values(&:dup))
1851
+ copy.instance_variable_set(:@hints, @hints.dup)
1852
+ copy.instance_variable_set(:@languages, @languages.map { |l| l.dup })
1853
+ copy.instance_variable_set(:@pronounce, @pronounce.map { |p| p.dup })
1854
+ copy.instance_variable_set(:@params, @params.dup)
1855
+ copy.instance_variable_set(:@global_data, @global_data.dup)
1856
+ copy.instance_variable_set(:@native_functions, @native_functions.dup)
1857
+ copy.instance_variable_set(:@function_includes, @function_includes.map { |i| i.dup })
1858
+ copy.instance_variable_set(:@internal_fillers, _deep_dup_hash(@internal_fillers))
1859
+ copy.instance_variable_set(:@prompt_llm_params, @prompt_llm_params.dup)
1860
+ copy.instance_variable_set(:@post_prompt_llm_params, @post_prompt_llm_params.dup)
1861
+ copy.instance_variable_set(:@pre_answer_verbs, @pre_answer_verbs.map(&:dup))
1862
+ copy.instance_variable_set(:@post_answer_verbs, @post_answer_verbs.map(&:dup))
1863
+ copy.instance_variable_set(:@post_ai_verbs, @post_ai_verbs.map(&:dup))
1864
+ copy.instance_variable_set(:@answer_config, @answer_config.dup)
1865
+ copy.instance_variable_set(:@swaig_query_params, @swaig_query_params.dup)
1866
+ copy.instance_variable_set(:@loaded_skills, @loaded_skills.dup)
1867
+ copy.instance_variable_set(:@mcp_servers, @mcp_servers.map(&:dup))
1868
+ copy.instance_variable_set(:@mcp_server_enabled, @mcp_server_enabled)
1869
+ # Don't copy the dynamic config callback to prevent infinite recursion
1870
+ copy.instance_variable_set(:@dynamic_config_callback, nil)
1871
+ copy
1872
+ end
1873
+
1874
+ # Deep-dup a hash of hashes
1875
+ def _deep_dup_hash(hash)
1876
+ hash.each_with_object({}) do |(k, v), result|
1877
+ result[k] = v.is_a?(Hash) ? v.dup : v
1878
+ end
1879
+ end
1880
+
1881
+ # Parse query string from Rack request
1882
+ def _parse_query_string(request)
1883
+ return {} unless request.respond_to?(:env)
1884
+
1885
+ qs = request.env['QUERY_STRING'] || ''
1886
+ URI.decode_www_form(qs).to_h
1887
+ rescue
1888
+ {}
1889
+ end
1890
+
1891
+ # Extract headers from Rack request
1892
+ def _extract_headers(request)
1893
+ return {} unless request.respond_to?(:env)
1894
+
1895
+ request.env.select { |k, _| k.start_with?('HTTP_') }
1896
+ .transform_keys { |k| k.sub('HTTP_', '').downcase.tr('_', '-') }
1897
+ rescue
1898
+ {}
1899
+ end
1900
+
1901
+ # ==================================================================
1902
+ # Rack app
1903
+ # ==================================================================
1904
+
1905
+ def _build_rack_app
1906
+ agent = self
1907
+ main_route = @route
1908
+ signing_key = @signing_key
1909
+ trust_proxy = @trust_proxy_for_signature
1910
+
1911
+ Rack::Builder.new do
1912
+ # --- public endpoints (no auth) --------------------------------
1913
+ map '/health' do
1914
+ run ->(_env) {
1915
+ body = JSON.generate({ status: 'healthy' })
1916
+ [200, { 'content-type' => 'application/json' }, [body]]
1917
+ }
1918
+ end
1919
+
1920
+ map '/ready' do
1921
+ run ->(_env) {
1922
+ body = JSON.generate({ status: 'ready' })
1923
+ [200, { 'content-type' => 'application/json' }, [body]]
1924
+ }
1925
+ end
1926
+
1927
+ # --- authenticated endpoints -----------------------------------
1928
+ map main_route do
1929
+ use AgentSecurityHeadersMiddleware
1930
+ use AgentBodyLimitMiddleware, AgentBase::MAX_BODY_SIZE
1931
+ # Webhook signature validation runs BEFORE basic auth so a
1932
+ # spoofed but unsigned request is rejected with 403 (the spec)
1933
+ # rather than 401 (which would expose that the key is missing).
1934
+ # Only POSTs to the signed routes go through.
1935
+ if signing_key && !signing_key.empty?
1936
+ use SignalWire::Security::WebhookMiddleware,
1937
+ signing_key: signing_key,
1938
+ trust_proxy: trust_proxy,
1939
+ paths: ['/', '/swaig', '/post_prompt'],
1940
+ methods: ['POST']
1941
+ end
1942
+ use AgentTimingSafeBasicAuth, agent
1943
+
1944
+ run ->(env) {
1945
+ request = Rack::Request.new(env)
1946
+ sub_path = env['PATH_INFO'] || '/'
1947
+ sub_path = '/' if sub_path.empty?
1948
+
1949
+ request_data = nil
1950
+ if request.post? || request.put?
1951
+ # Prefer the raw body stashed by WebhookMiddleware (which
1952
+ # already read+rewound the stream). Fall back to reading the
1953
+ # rack input directly when no validator was mounted.
1954
+ body = env['signalwire.raw_body']
1955
+ if body.nil?
1956
+ body = request.body.read
1957
+ request.body.rewind if request.body.respond_to?(:rewind)
1958
+ end
1959
+ request_data = JSON.parse(body) rescue nil
1960
+ end
1961
+
1962
+ # /swaig — handled by Service (lifted from AgentBase). The dispatch
1963
+ # uses on_function_call which AgentBase overrides for token validation.
1964
+ if sub_path == '/swaig'
1965
+ next agent.send(:_handle_swaig_endpoint, request, request_data, env)
1966
+ end
1967
+
1968
+ # AgentBase's extra routes (/post_prompt, /debug_events, /mcp).
1969
+ extra = agent.handle_additional_route(sub_path, request_data, env)
1970
+ next extra if extra
1971
+
1972
+ # SWML endpoint
1973
+ swml = agent.render_swml(request_data, request: request)
1974
+ body = JSON.generate(swml)
1975
+ [200, { 'content-type' => 'application/json' }, [body]]
1976
+ }
1977
+ end
1978
+ end
1979
+ end
1980
+
1981
+ # Override Service's hook to add agent-specific routes.
1982
+ public
1983
+
1984
+ def handle_additional_route(sub_path, request_data, env)
1985
+ case sub_path
1986
+ when '/post_prompt' then _handle_post_prompt(request_data, env)
1987
+ when '/debug_events' then _handle_debug_events(request_data, env)
1988
+ when '/mcp' then _handle_mcp_endpoint(request_data, env)
1989
+ end
1990
+ end
1991
+
1992
+ private
1993
+
1994
+ # These methods must be accessible from the Rack lambda
1995
+ public
1996
+
1997
+ # _handle_swaig is now provided by Service (lifted as _handle_swaig_endpoint).
1998
+ # AgentBase still hooks the dispatch path via the on_function_call override
1999
+ # below, which adds session-token validation on top of Service's plain
2000
+ # registry lookup.
2001
+
2002
+ # Handle post_prompt callback.
2003
+ # @api private
2004
+ def _handle_post_prompt(request_data, _env)
2005
+ if @summary_callback && request_data
2006
+ begin
2007
+ post_prompt_data = request_data['post_prompt_data']
2008
+ summary = nil
2009
+ if post_prompt_data.is_a?(Hash)
2010
+ summary = post_prompt_data['parsed'] || post_prompt_data['raw']
2011
+ end
2012
+ @summary_callback.call(summary, request_data)
2013
+ rescue => e
2014
+ @logger.error "Post-prompt callback error: #{e.message}"
2015
+ end
2016
+ end
2017
+
2018
+ body = JSON.generate({ 'status' => 'ok' })
2019
+ [200, { 'content-type' => 'application/json' }, [body]]
2020
+ end
2021
+
2022
+ # Handle debug events.
2023
+ # @api private
2024
+ def _handle_debug_events(request_data, _env)
2025
+ if @debug_event_callback && request_data
2026
+ begin
2027
+ event_type = request_data['event_type'] || 'unknown'
2028
+ @debug_event_callback.call(event_type, request_data)
2029
+ rescue => e
2030
+ @logger.error "Debug event callback error: #{e.message}"
2031
+ end
2032
+ end
2033
+
2034
+ body = JSON.generate({ 'status' => 'ok' })
2035
+ [200, { 'content-type' => 'application/json' }, [body]]
2036
+ end
2037
+
2038
+ # Handle MCP JSON-RPC 2.0 endpoint.
2039
+ # @api private
2040
+ def _handle_mcp_endpoint(request_data, _env)
2041
+ unless @mcp_server_enabled
2042
+ body = JSON.generate({ 'error' => 'MCP server not enabled' })
2043
+ return [404, { 'content-type' => 'application/json' }, [body]]
2044
+ end
2045
+
2046
+ unless request_data
2047
+ body = JSON.generate(_mcp_error(nil, -32700, 'Parse error'))
2048
+ return [400, { 'content-type' => 'application/json' }, [body]]
2049
+ end
2050
+
2051
+ resp = _handle_mcp_request(request_data)
2052
+ body = JSON.generate(resp)
2053
+ [200, { 'content-type' => 'application/json' }, [body]]
2054
+ end
2055
+
2056
+ private
2057
+
2058
+ # ==================================================================
2059
+ # Rack Middleware
2060
+ # ==================================================================
2061
+
2062
+ class AgentSecurityHeadersMiddleware
2063
+ HEADERS = {
2064
+ 'x-content-type-options' => 'nosniff',
2065
+ 'x-frame-options' => 'DENY',
2066
+ 'cache-control' => 'no-store, no-cache, must-revalidate'
2067
+ }.freeze
2068
+
2069
+ def initialize(app)
2070
+ @app = app
2071
+ end
2072
+
2073
+ def call(env)
2074
+ status, headers, body = @app.call(env)
2075
+ HEADERS.each { |k, v| headers[k] = v }
2076
+ [status, headers, body]
2077
+ end
2078
+ end
2079
+
2080
+ class AgentBodyLimitMiddleware
2081
+ def initialize(app, max_size)
2082
+ @app = app
2083
+ @max_size = max_size
2084
+ end
2085
+
2086
+ def call(env)
2087
+ if env['CONTENT_LENGTH'] && env['CONTENT_LENGTH'].to_i > @max_size
2088
+ body = JSON.generate({ 'error' => 'Request body too large' })
2089
+ return [413, { 'content-type' => 'application/json' }, [body]]
2090
+ end
2091
+ @app.call(env)
2092
+ end
2093
+ end
2094
+
2095
+ class AgentTimingSafeBasicAuth
2096
+ def initialize(app, agent)
2097
+ @app = app
2098
+ @agent = agent
2099
+ end
2100
+
2101
+ def call(env)
2102
+ auth = Rack::Auth::Basic::Request.new(env)
2103
+ unless auth.provided? && auth.basic?
2104
+ return _unauthorized
2105
+ end
2106
+
2107
+ user, pass = @agent.get_basic_auth_credentials
2108
+ input_user, input_pass = auth.credentials
2109
+
2110
+ user_ok = Rack::Utils.secure_compare(user.to_s, input_user.to_s)
2111
+ pass_ok = Rack::Utils.secure_compare(pass.to_s, input_pass.to_s)
2112
+
2113
+ if user_ok && pass_ok
2114
+ @app.call(env)
2115
+ else
2116
+ _unauthorized
2117
+ end
2118
+ end
2119
+
2120
+ private
2121
+
2122
+ def _unauthorized
2123
+ [
2124
+ 401,
2125
+ {
2126
+ 'content-type' => 'text/plain',
2127
+ 'www-authenticate' => 'Basic realm="SignalWire Agent"'
2128
+ },
2129
+ ['Unauthorized']
2130
+ ]
2131
+ end
2132
+ end
2133
+ end
2134
+ end