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
data/bin/swaig-test ADDED
@@ -0,0 +1,872 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Copyright (c) 2025 SignalWire
5
+ #
6
+ # Licensed under the MIT License.
7
+ # See LICENSE file in the project root for full license information.
8
+
9
+ #
10
+ # swaig-test — CLI tool for testing SWAIG agent endpoints.
11
+ #
12
+ # Two modes:
13
+ #
14
+ # (1) URL mode — hit a live agent over HTTP:
15
+ #
16
+ # swaig-test --url http://user:pass@host:port/route --dump-swml
17
+ # swaig-test --url http://user:pass@host:port/route --list-tools
18
+ # swaig-test --url http://user:pass@host:port/route --exec tool_name --param key=value
19
+ #
20
+ # (2) File mode with serverless simulation — load an agent source file
21
+ # and route invocations through the platform adapter (no HTTP server):
22
+ #
23
+ # swaig-test agent.rb --simulate-serverless lambda
24
+ # swaig-test agent.rb --simulate-serverless lambda --dump-swml
25
+ # swaig-test agent.rb --simulate-serverless lambda --exec tool_name --param key=value
26
+ #
27
+ # Only platforms the SDK has actually implemented are accepted. Today
28
+ # that's `lambda` only; `gcf`, `cgi`, and `azure` are rejected with a
29
+ # pointer to the phase-9 gap.
30
+ #
31
+ # (3) In-process file mode — `--file PATH --list-tools` loads the script
32
+ # in the current Ruby process, finds the user's SWML::Service subclass
33
+ # (including non-AgentBase services), and reads the runtime tool
34
+ # registry directly. NO HTTP, NO simulator. This is the only mode
35
+ # that surfaces SWAIG tools registered on a plain SWML::Service
36
+ # (e.g. ai_sidecar hosts, standalone SWAIG services):
37
+ #
38
+ # swaig-test --file examples/swmlservice_swaig_standalone.rb --list-tools
39
+ #
40
+
41
+ require 'net/http'
42
+ require 'uri'
43
+ require 'json'
44
+ require 'optparse'
45
+ require 'base64'
46
+
47
+ module SwaigTest
48
+ # Platforms the port has a Phase-9 handler adapter for. Anything else
49
+ # must be rejected with a clear error so users don't accidentally exercise
50
+ # the fall-through HTTP path and think the simulator worked.
51
+ IMPLEMENTED_SERVERLESS_PLATFORMS = %w[lambda].freeze
52
+
53
+ # All platforms the porting guide recognises, so the CLI can emit a
54
+ # helpful "recognised but not implemented" error rather than
55
+ # "unrecognised value" for the in-progress ones.
56
+ RECOGNISED_SERVERLESS_PLATFORMS = %w[lambda cgi gcf google_cloud_function azure azure_function].freeze
57
+
58
+ # Environment variables the simulator touches. Captured so the simulator
59
+ # can snapshot and restore them no matter what path the invocation took.
60
+ LAMBDA_SIMULATION_ENV_VARS = %w[
61
+ AWS_LAMBDA_FUNCTION_NAME
62
+ AWS_LAMBDA_FUNCTION_URL
63
+ AWS_REGION
64
+ LAMBDA_TASK_ROOT
65
+ _HANDLER
66
+ SWML_PROXY_URL_BASE
67
+ ].freeze
68
+
69
+ # Snapshot + restore the env vars a serverless simulation touches, so
70
+ # tests in the same process (and users who invoke the CLI repeatedly in
71
+ # a shell session) never inherit leaked state.
72
+ module RuntimeEnvIsolation
73
+ def self.snapshot(keys = LAMBDA_SIMULATION_ENV_VARS)
74
+ keys.each_with_object({}) { |k, h| h[k] = ENV[k] }
75
+ end
76
+
77
+ def self.restore(snapshot)
78
+ snapshot.each do |k, v|
79
+ if v.nil?
80
+ ENV.delete(k)
81
+ else
82
+ ENV[k] = v
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ # Serverless platform simulator. Responsible for:
89
+ # * setting the mode-detection env vars for the target platform,
90
+ # * clearing conflicting env vars (notably SWML_PROXY_URL_BASE),
91
+ # * restoring the original environment on deactivate.
92
+ #
93
+ # Mirrors signalwire/cli/simulation/mock_env.py's ServerlessSimulator.
94
+ class ServerlessSimulator
95
+ LAMBDA_PRESETS = {
96
+ 'AWS_LAMBDA_FUNCTION_NAME' => 'test-agent-function',
97
+ 'LAMBDA_TASK_ROOT' => '/var/task',
98
+ 'AWS_REGION' => 'us-east-1',
99
+ '_HANDLER' => 'lambda_function.handler'
100
+ }.freeze
101
+
102
+ attr_reader :platform, :overrides
103
+
104
+ def initialize(platform, overrides: {}, verbose: false, io: $stderr)
105
+ @platform = platform
106
+ @overrides = overrides || {}
107
+ @verbose = verbose
108
+ @io = io
109
+ @snapshot = nil
110
+ @active = false
111
+ end
112
+
113
+ def activate
114
+ return if @active
115
+
116
+ @snapshot = RuntimeEnvIsolation.snapshot
117
+
118
+ # Clear conflicting env vars BEFORE applying presets so that a
119
+ # SWML_PROXY_URL_BASE set in the outer shell can't override the
120
+ # platform-derived base URL during the simulation.
121
+ ENV.delete('SWML_PROXY_URL_BASE')
122
+
123
+ preset_for_platform.each { |k, v| ENV[k] = v }
124
+ @overrides.each { |k, v| ENV[k] = v }
125
+
126
+ # Match Python: warn if SWML_PROXY_URL_BASE somehow survived (e.g.
127
+ # an override re-set it). A live warning here is far cheaper than
128
+ # silently generating the wrong webhook URLs.
129
+ if ENV['SWML_PROXY_URL_BASE'] && !ENV['SWML_PROXY_URL_BASE'].empty?
130
+ @io.puts "WARNING: SWML_PROXY_URL_BASE still set after simulation clear: #{ENV['SWML_PROXY_URL_BASE'].inspect}"
131
+ end
132
+
133
+ @active = true
134
+
135
+ return unless @verbose
136
+
137
+ @io.puts "Activated #{@platform} environment simulation"
138
+ preset_for_platform.each_key do |k|
139
+ @io.puts " #{k}: #{ENV[k]}"
140
+ end
141
+ end
142
+
143
+ def deactivate
144
+ return unless @active
145
+ RuntimeEnvIsolation.restore(@snapshot) if @snapshot
146
+ @snapshot = nil
147
+ @active = false
148
+ @io.puts "Deactivated #{@platform} environment simulation" if @verbose
149
+ end
150
+
151
+ # Scope helper so callers can wrap a block in activate/deactivate
152
+ # without hand-rolling begin/ensure.
153
+ def with_simulation
154
+ activate
155
+ yield self
156
+ ensure
157
+ deactivate
158
+ end
159
+
160
+ private
161
+
162
+ def preset_for_platform
163
+ case @platform
164
+ when 'lambda' then LAMBDA_PRESETS
165
+ else {} # unreachable — validated in CLI
166
+ end
167
+ end
168
+ end
169
+
170
+ # Dispatches synthetic Lambda Function URL events into a
171
+ # SignalWire::Serverless::LambdaHandler. Used by --simulate-serverless
172
+ # lambda to exercise the adapter instead of the HTTP server.
173
+ class LambdaAdapterDispatcher
174
+ def initialize(agent)
175
+ @agent = agent
176
+ @handler = SignalWire::Serverless::LambdaHandler.for(agent)
177
+ end
178
+
179
+ # GET the agent's root route and return the response body as a
180
+ # parsed hash (the SWML document). Raises RuntimeError on non-2xx.
181
+ def dump_swml
182
+ route = @agent.route == '/' ? '/' : @agent.route
183
+ event = build_event('GET', route)
184
+ resp = @handler.call(event, nil)
185
+ ensure_success!(resp, "dump-swml GET #{route}")
186
+ decode_body(resp)
187
+ end
188
+
189
+ # POST to <route>/swaig with a SWAIG invocation. Returns the
190
+ # response body parsed as a hash.
191
+ def exec_tool(func_name, params)
192
+ route = @agent.route == '/' ? '' : @agent.route
193
+ path = "#{route}/swaig"
194
+ body = JSON.generate(
195
+ 'function' => func_name,
196
+ 'argument' => { 'parsed' => [params || {}] }
197
+ )
198
+ auth = basic_auth_header
199
+ headers = { 'content-type' => 'application/json' }
200
+ headers['authorization'] = auth if auth
201
+
202
+ event = build_event('POST', path, body: body, headers: headers)
203
+ resp = @handler.call(event, nil)
204
+ ensure_success!(resp, "exec POST #{path}")
205
+ decode_body(resp)
206
+ end
207
+
208
+ private
209
+
210
+ def build_event(method, path, body: nil, headers: nil)
211
+ base_headers = {
212
+ 'host' => ENV['AWS_LAMBDA_FUNCTION_URL']&.then { |u| URI.parse(u).host } ||
213
+ "#{ENV['AWS_LAMBDA_FUNCTION_NAME']}.lambda-url.#{ENV['AWS_REGION']}.on.aws"
214
+ }
215
+ base_headers.merge!(headers) if headers
216
+
217
+ # Ensure auth is present for authenticated endpoints (SWML, swaig).
218
+ unless base_headers.key?('authorization') || path == '/health' || path == '/ready'
219
+ auth = basic_auth_header
220
+ base_headers['authorization'] = auth if auth
221
+ end
222
+
223
+ event = {
224
+ 'version' => '2.0',
225
+ 'routeKey' => '$default',
226
+ 'rawPath' => path,
227
+ 'rawQueryString' => '',
228
+ 'headers' => base_headers,
229
+ 'requestContext' => {
230
+ 'http' => { 'method' => method, 'path' => path, 'protocol' => 'HTTP/1.1' },
231
+ 'stage' => '$default'
232
+ }
233
+ }
234
+ if body
235
+ event['body'] = body
236
+ event['isBase64Encoded'] = false
237
+ end
238
+ event
239
+ end
240
+
241
+ def basic_auth_header
242
+ # The agent auto-generates a random password at construction time;
243
+ # read it back via the public accessor so we can authenticate
244
+ # ourselves against it.
245
+ if @agent.respond_to?(:get_basic_auth_credentials)
246
+ user, pass = @agent.get_basic_auth_credentials
247
+ return "Basic " + ["#{user}:#{pass}"].pack('m0').chomp if user && pass
248
+ end
249
+ nil
250
+ end
251
+
252
+ def ensure_success!(resp, description)
253
+ status = resp['statusCode'].to_i
254
+ return if status >= 200 && status < 300
255
+ raise "Simulator: #{description} returned HTTP #{status}: #{resp['body']}"
256
+ end
257
+
258
+ def decode_body(resp)
259
+ body = resp['body'].to_s
260
+ body = Base64.decode64(body) if resp['isBase64Encoded']
261
+ JSON.parse(body)
262
+ end
263
+ end
264
+
265
+ class CLI
266
+ attr_reader :options
267
+
268
+ def initialize(argv = ARGV)
269
+ @options = {
270
+ url: nil,
271
+ agent_file: nil,
272
+ file: nil,
273
+ simulate_serverless: nil,
274
+ dump_swml: false,
275
+ list_tools: false,
276
+ exec: nil,
277
+ params: {},
278
+ raw: false,
279
+ verbose: false
280
+ }
281
+ parse_options(argv)
282
+ end
283
+
284
+ def run
285
+ validate_options!
286
+
287
+ if @options[:file]
288
+ run_in_process_file_mode
289
+ elsif @options[:simulate_serverless]
290
+ run_simulated_serverless
291
+ elsif @options[:dump_swml]
292
+ dump_swml
293
+ elsif @options[:list_tools]
294
+ list_tools
295
+ elsif @options[:exec]
296
+ exec_function
297
+ else
298
+ $stderr.puts "Error: specify --dump-swml, --list-tools, or --exec NAME"
299
+ exit 1
300
+ end
301
+ end
302
+
303
+ private
304
+
305
+ def parse_options(argv)
306
+ parser = OptionParser.new do |opts|
307
+ opts.banner = "Usage: swaig-test [agent_file] [options]"
308
+
309
+ opts.on("--url URL", "Agent URL with embedded auth (http://user:pass@host:port/route)") do |v|
310
+ @options[:url] = v
311
+ end
312
+
313
+ opts.on("--simulate-serverless PLATFORM",
314
+ "Simulate the named serverless platform (loads agent file locally). Supported: #{IMPLEMENTED_SERVERLESS_PLATFORMS.join(', ')}") do |v|
315
+ @options[:simulate_serverless] = v
316
+ end
317
+
318
+ opts.on("--file PATH", "--example PATH",
319
+ "Load a SWML::Service subclass from PATH in-process and read its tool registry directly (no HTTP, no simulator). Required for --list-tools on a non-AgentBase Service.") do |v|
320
+ @options[:file] = v
321
+ end
322
+
323
+ opts.on("--dump-swml", "GET SWML document and pretty-print it") do
324
+ @options[:dump_swml] = true
325
+ end
326
+
327
+ opts.on("--list-tools", "List available SWAIG functions") do
328
+ @options[:list_tools] = true
329
+ end
330
+
331
+ opts.on("--exec NAME", "Execute a SWAIG function by name") do |v|
332
+ @options[:exec] = v
333
+ end
334
+
335
+ opts.on("--param KEY=VALUE", "Set a parameter (repeatable)") do |v|
336
+ key, value = v.split('=', 2)
337
+ if key && value
338
+ @options[:params][key] = parse_param_value(value)
339
+ else
340
+ $stderr.puts "Warning: ignoring malformed --param '#{v}' (expected key=value)"
341
+ end
342
+ end
343
+
344
+ opts.on("--raw", "Output compact JSON instead of pretty-printed") do
345
+ @options[:raw] = true
346
+ end
347
+
348
+ opts.on("--verbose", "Show request/response details") do
349
+ @options[:verbose] = true
350
+ end
351
+
352
+ opts.on("-h", "--help", "Show this help") do
353
+ puts opts
354
+ exit 0
355
+ end
356
+ end
357
+
358
+ remaining = parser.parse(argv)
359
+
360
+ # Any leftover positional argument is treated as an agent source
361
+ # file path. This is the only way to invoke file mode, so we keep
362
+ # it outside of validate_options! for clearer error messages.
363
+ if remaining.length > 1
364
+ $stderr.puts "Error: at most one positional agent file may be given; got #{remaining.inspect}"
365
+ exit 1
366
+ elsif remaining.length == 1
367
+ @options[:agent_file] = remaining.first
368
+ end
369
+ end
370
+
371
+ def validate_options!
372
+ if @options[:file]
373
+ validate_file_mode_options!
374
+ return
375
+ end
376
+
377
+ if @options[:simulate_serverless]
378
+ validate_simulate_serverless_options!
379
+ return
380
+ end
381
+
382
+ # Classic URL mode from here down.
383
+ unless @options[:url]
384
+ $stderr.puts "Error: --url is required (or pass --file PATH for in-process file mode, or pass an agent file with --simulate-serverless PLATFORM)"
385
+ exit 1
386
+ end
387
+
388
+ actions = [@options[:dump_swml], @options[:list_tools], !!@options[:exec]].count(true)
389
+ if actions == 0
390
+ $stderr.puts "Error: specify one of --dump-swml, --list-tools, or --exec NAME"
391
+ exit 1
392
+ elsif actions > 1
393
+ $stderr.puts "Error: specify only one of --dump-swml, --list-tools, or --exec NAME"
394
+ exit 1
395
+ end
396
+ end
397
+
398
+ def validate_file_mode_options!
399
+ # In-process file mode is currently scoped to listing tools off the
400
+ # runtime registry, since that is the gap on non-AgentBase services.
401
+ # URL/simulator combinations are rejected to avoid ambiguous dispatch.
402
+ if @options[:url]
403
+ $stderr.puts "Error: --file and --url are mutually exclusive"
404
+ exit 1
405
+ end
406
+
407
+ if @options[:simulate_serverless]
408
+ $stderr.puts "Error: --file and --simulate-serverless are mutually exclusive"
409
+ exit 1
410
+ end
411
+
412
+ unless File.exist?(@options[:file])
413
+ $stderr.puts "Error: file not found: #{@options[:file]}"
414
+ exit 1
415
+ end
416
+
417
+ unless @options[:list_tools]
418
+ $stderr.puts "Error: --file mode currently supports only --list-tools"
419
+ exit 1
420
+ end
421
+
422
+ if @options[:dump_swml] || @options[:exec]
423
+ $stderr.puts "Error: --file mode does not support --dump-swml or --exec (use --url or --simulate-serverless lambda for those)"
424
+ exit 1
425
+ end
426
+ end
427
+
428
+ def validate_simulate_serverless_options!
429
+ platform = @options[:simulate_serverless]
430
+
431
+ unless IMPLEMENTED_SERVERLESS_PLATFORMS.include?(platform)
432
+ if RECOGNISED_SERVERLESS_PLATFORMS.include?(platform)
433
+ $stderr.puts "Error: --simulate-serverless #{platform.inspect} is not implemented in this SDK yet."
434
+ $stderr.puts " Phase 9 (serverless support) has only shipped for: #{IMPLEMENTED_SERVERLESS_PLATFORMS.join(', ')}."
435
+ $stderr.puts " Add the #{platform} handler adapter before enabling it here."
436
+ else
437
+ $stderr.puts "Error: unknown serverless platform #{platform.inspect}."
438
+ $stderr.puts " Supported: #{IMPLEMENTED_SERVERLESS_PLATFORMS.join(', ')}."
439
+ end
440
+ exit 1
441
+ end
442
+
443
+ unless @options[:agent_file]
444
+ $stderr.puts "Error: --simulate-serverless requires a positional agent file argument"
445
+ $stderr.puts "Usage: swaig-test AGENT_FILE --simulate-serverless #{platform} [--dump-swml | --exec NAME]"
446
+ exit 1
447
+ end
448
+
449
+ unless File.exist?(@options[:agent_file])
450
+ $stderr.puts "Error: agent file not found: #{@options[:agent_file]}"
451
+ exit 1
452
+ end
453
+
454
+ # Simulated mode cannot combine with --url (different dispatch paths).
455
+ if @options[:url]
456
+ $stderr.puts "Error: --simulate-serverless and --url are mutually exclusive"
457
+ exit 1
458
+ end
459
+
460
+ actions = [@options[:dump_swml], @options[:list_tools], !!@options[:exec]].count(true)
461
+ if actions > 1
462
+ $stderr.puts "Error: specify at most one of --dump-swml, --list-tools, or --exec NAME"
463
+ exit 1
464
+ end
465
+ end
466
+
467
+ def parse_param_value(value)
468
+ case value
469
+ when 'true' then true
470
+ when 'false' then false
471
+ when 'null', 'nil' then nil
472
+ when /\A-?\d+\z/ then value.to_i
473
+ when /\A-?\d+\.\d+\z/ then value.to_f
474
+ else value
475
+ end
476
+ end
477
+
478
+ def parsed_uri
479
+ @parsed_uri ||= URI.parse(@options[:url])
480
+ end
481
+
482
+ def base_path
483
+ path = parsed_uri.path
484
+ path = '/' if path.nil? || path.empty?
485
+ path.chomp('/')
486
+ end
487
+
488
+ def auth_header
489
+ user = parsed_uri.user
490
+ pass = parsed_uri.password
491
+ if user && pass
492
+ "Basic " + ["#{URI.decode_www_form_component(user)}:#{URI.decode_www_form_component(pass)}"].pack('m0')
493
+ else
494
+ nil
495
+ end
496
+ end
497
+
498
+ def make_request(method, path, body: nil)
499
+ full_path = base_path == '/' ? path : "#{base_path}#{path}"
500
+ full_path = '/' if full_path.empty?
501
+
502
+ uri = parsed_uri.dup
503
+ uri.path = full_path
504
+ uri.user = nil
505
+ uri.password = nil
506
+
507
+ if @options[:verbose]
508
+ $stderr.puts ">> #{method.upcase} #{uri}"
509
+ $stderr.puts ">> Authorization: Basic <redacted>" if auth_header
510
+ if body
511
+ $stderr.puts ">> Content-Type: application/json"
512
+ $stderr.puts ">> Body: #{body}"
513
+ end
514
+ $stderr.puts ""
515
+ end
516
+
517
+ http = Net::HTTP.new(uri.host, uri.port)
518
+ http.use_ssl = (uri.scheme == 'https')
519
+ http.open_timeout = 10
520
+ http.read_timeout = 30
521
+
522
+ if method == :get
523
+ req = Net::HTTP::Get.new(uri.request_uri)
524
+ else
525
+ req = Net::HTTP::Post.new(uri.request_uri)
526
+ req.body = body
527
+ req['Content-Type'] = 'application/json'
528
+ end
529
+
530
+ req['Authorization'] = auth_header if auth_header
531
+
532
+ response = http.request(req)
533
+
534
+ if @options[:verbose]
535
+ $stderr.puts "<< HTTP #{response.code} #{response.message}"
536
+ response.each_header { |k, v| $stderr.puts "<< #{k}: #{v}" }
537
+ $stderr.puts ""
538
+ end
539
+
540
+ response
541
+ end
542
+
543
+ def format_json(data)
544
+ if @options[:raw]
545
+ JSON.generate(data)
546
+ else
547
+ JSON.pretty_generate(data)
548
+ end
549
+ end
550
+
551
+ def dump_swml
552
+ response = make_request(:get, '/')
553
+
554
+ unless response.is_a?(Net::HTTPSuccess)
555
+ $stderr.puts "Error: HTTP #{response.code} #{response.message}"
556
+ $stderr.puts response.body if @options[:verbose]
557
+ exit 1
558
+ end
559
+
560
+ data = JSON.parse(response.body)
561
+ puts format_json(data)
562
+ rescue JSON::ParserError => e
563
+ $stderr.puts "Error: invalid JSON response: #{e.message}"
564
+ $stderr.puts response.body if @options[:verbose]
565
+ exit 1
566
+ end
567
+
568
+ def list_tools
569
+ response = make_request(:get, '/')
570
+
571
+ unless response.is_a?(Net::HTTPSuccess)
572
+ $stderr.puts "Error: HTTP #{response.code} #{response.message}"
573
+ exit 1
574
+ end
575
+
576
+ swml = JSON.parse(response.body)
577
+ functions = extract_functions(swml)
578
+
579
+ if functions.empty?
580
+ puts "No SWAIG functions found."
581
+ return
582
+ end
583
+
584
+ puts "SWAIG Functions:"
585
+ puts "-" * 60
586
+ functions.each do |func|
587
+ name = func['function'] || '(unnamed)'
588
+ desc = func['description'] || '(no description)'
589
+ puts " #{name}"
590
+ puts " #{desc}"
591
+
592
+ params = func['parameters']
593
+ if params.is_a?(Hash) && params['properties'].is_a?(Hash) && !params['properties'].empty?
594
+ puts " Parameters:"
595
+ params['properties'].each do |pname, pdef|
596
+ ptype = pdef['type'] || 'any' rescue 'any'
597
+ pdesc = pdef['description'] || '' rescue ''
598
+ required = (params['required'] || []).include?(pname) ? ' (required)' : ''
599
+ puts " - #{pname}: #{ptype}#{required} #{pdesc}"
600
+ end
601
+ end
602
+ puts ""
603
+ end
604
+ rescue JSON::ParserError => e
605
+ $stderr.puts "Error: invalid JSON response: #{e.message}"
606
+ exit 1
607
+ end
608
+
609
+ def exec_function
610
+ func_name = @options[:exec]
611
+ payload = {
612
+ 'function' => func_name,
613
+ 'argument' => {
614
+ 'parsed' => [@options[:params]]
615
+ }
616
+ }
617
+
618
+ response = make_request(:post, '/swaig', body: JSON.generate(payload))
619
+
620
+ unless response.is_a?(Net::HTTPSuccess)
621
+ $stderr.puts "Error: HTTP #{response.code} #{response.message}"
622
+ $stderr.puts response.body if @options[:verbose]
623
+ exit 1
624
+ end
625
+
626
+ data = JSON.parse(response.body)
627
+ puts format_json(data)
628
+ rescue JSON::ParserError => e
629
+ $stderr.puts "Error: invalid JSON response: #{e.message}"
630
+ exit 1
631
+ end
632
+
633
+ def extract_functions(swml)
634
+ functions = []
635
+
636
+ # Navigate the SWML structure to find AI → SWAIG → functions
637
+ sections = swml['sections'] || {}
638
+ main = sections['main'] || []
639
+
640
+ main.each do |verb|
641
+ if verb.is_a?(Hash) && verb.key?('ai')
642
+ ai = verb['ai']
643
+ swaig = ai['SWAIG'] || {}
644
+ funcs = swaig['functions'] || []
645
+ functions.concat(funcs)
646
+ end
647
+ end
648
+
649
+ functions
650
+ end
651
+
652
+ # --------------------------------------------------------------
653
+ # --simulate-serverless ... dispatch
654
+ # --------------------------------------------------------------
655
+
656
+ def run_simulated_serverless
657
+ require 'signalwire'
658
+
659
+ simulator = ServerlessSimulator.new(
660
+ @options[:simulate_serverless],
661
+ verbose: @options[:verbose]
662
+ )
663
+
664
+ simulator.with_simulation do
665
+ agent = load_agent_file(@options[:agent_file])
666
+ unless agent
667
+ $stderr.puts "Error: agent file #{@options[:agent_file].inspect} did not expose an AGENT constant (expected SignalWire::AgentBase)."
668
+ exit 1
669
+ end
670
+
671
+ dispatcher = LambdaAdapterDispatcher.new(agent)
672
+
673
+ if @options[:dump_swml]
674
+ puts format_json(dispatcher.dump_swml)
675
+ elsif @options[:exec]
676
+ puts format_json(dispatcher.exec_tool(@options[:exec], @options[:params]))
677
+ elsif @options[:list_tools]
678
+ swml = dispatcher.dump_swml
679
+ render_functions_list(extract_functions(swml))
680
+ else
681
+ # No sub-action: render the SWML and print it (matches the porting-guide
682
+ # "render SWML and exit" default).
683
+ puts format_json(dispatcher.dump_swml)
684
+ end
685
+ end
686
+ end
687
+
688
+ def load_agent_file(path)
689
+ # Use load rather than require so repeated CLI invocations in the
690
+ # same process (tests) don't silently no-op.
691
+ absolute = File.expand_path(path)
692
+ load(absolute)
693
+
694
+ # Preferred convention: the agent file defines a top-level
695
+ # constant named AGENT. Fall back to any SignalWire::AgentBase
696
+ # instance at top-level for forgiveness.
697
+ if Object.const_defined?(:AGENT)
698
+ candidate = Object.const_get(:AGENT)
699
+ return candidate if candidate.is_a?(SignalWire::AgentBase)
700
+ end
701
+
702
+ Object.constants.each do |c|
703
+ next if c == :AGENT
704
+ value = Object.const_get(c)
705
+ return value if value.is_a?(SignalWire::AgentBase)
706
+ end
707
+
708
+ nil
709
+ end
710
+
711
+ def render_functions_list(functions)
712
+ if functions.empty?
713
+ puts "No SWAIG functions found."
714
+ return
715
+ end
716
+ puts "SWAIG Functions:"
717
+ puts "-" * 60
718
+ functions.each do |func|
719
+ name = func['function'] || '(unnamed)'
720
+ desc = func['description'] || '(no description)'
721
+ puts " #{name}"
722
+ puts " #{desc}"
723
+
724
+ properties, required = extract_param_properties(func['parameters'])
725
+ unless properties.empty?
726
+ puts " Parameters:"
727
+ properties.each do |pname, pdef|
728
+ ptype = (pdef.is_a?(Hash) ? pdef['type'] : nil) || 'any'
729
+ pdesc = (pdef.is_a?(Hash) ? pdef['description'] : nil) || ''
730
+ req_marker = required.include?(pname.to_s) ? ' (required)' : ''
731
+ puts " - #{pname}: #{ptype}#{req_marker} #{pdesc}".rstrip
732
+ end
733
+ end
734
+ puts ""
735
+ end
736
+ end
737
+
738
+ # Accepts either JSON-Schema-shaped `{type:object, properties:{...}}` or
739
+ # the raw flat shape SWML::Service stores (`{name => {type, description}}`).
740
+ # Returns [properties_hash, required_array].
741
+ def extract_param_properties(params)
742
+ return [{}, []] unless params.is_a?(Hash)
743
+ if params['properties'].is_a?(Hash)
744
+ [params['properties'], Array(params['required']).map(&:to_s)]
745
+ elsif params['type'] == 'object'
746
+ [{}, []]
747
+ else
748
+ # Flat shape — keys are parameter names, values are schema fragments.
749
+ [params.transform_keys(&:to_s), []]
750
+ end
751
+ end
752
+
753
+ # --------------------------------------------------------------
754
+ # --file ... --list-tools dispatch (in-process, no HTTP)
755
+ # --------------------------------------------------------------
756
+ #
757
+ # Loads PATH with Kernel#load (so re-loads in the same process work
758
+ # cleanly), discovers a `SignalWire::SWML::Service` subclass, instantiates
759
+ # it (or reuses a top-level instance the script already exposed), and
760
+ # reads the runtime tool registry directly. This is the only path that
761
+ # surfaces SWAIG tools registered on a non-AgentBase Service, since URL
762
+ # mode looks for them in rendered SWML and they aren't there for plain
763
+ # Service subclasses (`render_main_swml` returns the document only).
764
+ def run_in_process_file_mode
765
+ require 'signalwire'
766
+
767
+ service = load_service_from_file(@options[:file])
768
+ unless service
769
+ $stderr.puts "Error: file #{@options[:file].inspect} did not expose a SignalWire::SWML::Service subclass."
770
+ exit 1
771
+ end
772
+
773
+ tools = collect_runtime_tools(service)
774
+ render_functions_list(tools)
775
+ end
776
+
777
+ # Find a user-defined SWML::Service subclass and return an instance.
778
+ # Selection order:
779
+ # 1. A top-level `service` or `agent` constant/variable already
780
+ # pointing at a Service instance (rare, but a script may set one).
781
+ # 2. The most recently defined Service subclass that ISN'T
782
+ # SignalWire::SWML::Service or SignalWire::AgentBase itself.
783
+ def load_service_from_file(path)
784
+ absolute = File.expand_path(path)
785
+
786
+ # Snapshot the existing Service subclasses so we can pick the one
787
+ # the file just defined, instead of accidentally grabbing a class
788
+ # left over from earlier requires (e.g. AgentBase).
789
+ service_class = SignalWire::SWML::Service
790
+ pre_existing = ObjectSpace.each_object(Class).select { |c| service_subclass?(c, service_class) }.to_set
791
+
792
+ load(absolute)
793
+
794
+ # Top-level instance fallback (some scripts assign a global).
795
+ %i[SERVICE AGENT].each do |name|
796
+ if Object.const_defined?(name)
797
+ val = Object.const_get(name)
798
+ return val if val.is_a?(service_class)
799
+ end
800
+ end
801
+
802
+ newly_defined = ObjectSpace.each_object(Class).select do |c|
803
+ service_subclass?(c, service_class) && !pre_existing.include?(c)
804
+ end
805
+
806
+ # Sort by class name so output is deterministic when a file defines
807
+ # multiple subclasses; users can disambiguate by setting `SERVICE`.
808
+ candidate = newly_defined.sort_by { |c| c.name.to_s }.last
809
+
810
+ # Fallback: if no NEW subclass was defined (e.g. running the same
811
+ # file twice in the same process), pick any user subclass.
812
+ candidate ||= ObjectSpace.each_object(Class)
813
+ .select { |c| service_subclass?(c, service_class) }
814
+ .sort_by { |c| c.name.to_s }
815
+ .last
816
+
817
+ return nil unless candidate
818
+
819
+ instantiate_service(candidate)
820
+ rescue StandardError => e
821
+ $stderr.puts "Error loading #{path}: #{e.class}: #{e.message}"
822
+ $stderr.puts e.backtrace.first(8).join("\n") if @options[:verbose]
823
+ exit 1
824
+ end
825
+
826
+ def service_subclass?(klass, service_class)
827
+ return false unless klass.is_a?(Class)
828
+ return false if klass == service_class
829
+ return false if defined?(SignalWire::AgentBase) && klass == SignalWire::AgentBase
830
+ klass.ancestors.include?(service_class)
831
+ rescue StandardError
832
+ false
833
+ end
834
+
835
+ # Try a parameterless instantiation first (the convention in the
836
+ # examples). Fall back to a few common keyword shapes if that fails.
837
+ def instantiate_service(klass)
838
+ klass.new
839
+ rescue ArgumentError
840
+ begin
841
+ klass.new(host: '127.0.0.1', port: 0)
842
+ rescue ArgumentError
843
+ klass.new(name: klass.name.to_s)
844
+ end
845
+ end
846
+
847
+ # Read the runtime tool registry off a Service instance. Service
848
+ # exposes `define_tools` (the merged definition list across @tools and
849
+ # @swaig_functions). That's the source of truth — we use it instead
850
+ # of poking @tools directly so AgentBase / future subclasses that
851
+ # override the accessor still work.
852
+ def collect_runtime_tools(service)
853
+ if service.respond_to?(:define_tools)
854
+ Array(service.define_tools).map { |d| stringify_tool(d) }
855
+ elsif service.instance_variable_defined?(:@tools)
856
+ service.instance_variable_get(:@tools).values.map { |t| stringify_tool(t[:definition]) }
857
+ else
858
+ []
859
+ end
860
+ end
861
+
862
+ def stringify_tool(defn)
863
+ return {} unless defn.is_a?(Hash)
864
+ defn.transform_keys(&:to_s)
865
+ end
866
+ end
867
+ end
868
+
869
+ # Run when invoked directly
870
+ if __FILE__ == $PROGRAM_NAME
871
+ SwaigTest::CLI.new.run
872
+ end