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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +259 -0
- data/bin/swaig-test +872 -0
- data/lib/signalwire/agent/agent_base.rb +2134 -0
- data/lib/signalwire/contexts/context_builder.rb +861 -0
- data/lib/signalwire/core/logging_config.rb +54 -0
- data/lib/signalwire/datamap/data_map.rb +315 -0
- data/lib/signalwire/logging.rb +92 -0
- data/lib/signalwire/pom/prompt_object_model.rb +269 -0
- data/lib/signalwire/pom/section.rb +202 -0
- data/lib/signalwire/prefabs/concierge.rb +92 -0
- data/lib/signalwire/prefabs/faq_bot.rb +67 -0
- data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
- data/lib/signalwire/prefabs/receptionist.rb +74 -0
- data/lib/signalwire/prefabs/survey.rb +75 -0
- data/lib/signalwire/relay/action.rb +291 -0
- data/lib/signalwire/relay/call.rb +523 -0
- data/lib/signalwire/relay/client.rb +789 -0
- data/lib/signalwire/relay/constants.rb +124 -0
- data/lib/signalwire/relay/message.rb +137 -0
- data/lib/signalwire/relay/relay_event.rb +670 -0
- data/lib/signalwire/rest/http_client.rb +159 -0
- data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
- data/lib/signalwire/rest/namespaces/calling.rb +179 -0
- data/lib/signalwire/rest/namespaces/chat.rb +18 -0
- data/lib/signalwire/rest/namespaces/compat.rb +229 -0
- data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
- data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
- data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
- data/lib/signalwire/rest/namespaces/logs.rb +46 -0
- data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
- data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
- data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
- data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
- data/lib/signalwire/rest/namespaces/project.rb +33 -0
- data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
- data/lib/signalwire/rest/namespaces/queues.rb +28 -0
- data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
- data/lib/signalwire/rest/namespaces/registry.rb +67 -0
- data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
- data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
- data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
- data/lib/signalwire/rest/namespaces/video.rb +129 -0
- data/lib/signalwire/rest/pagination.rb +89 -0
- data/lib/signalwire/rest/phone_call_handler.rb +56 -0
- data/lib/signalwire/rest/rest_client.rb +114 -0
- data/lib/signalwire/runtime.rb +98 -0
- data/lib/signalwire/security/session_manager.rb +124 -0
- data/lib/signalwire/security/webhook_middleware.rb +191 -0
- data/lib/signalwire/security/webhook_validator.rb +327 -0
- data/lib/signalwire/server/agent_server.rb +413 -0
- data/lib/signalwire/serverless/lambda_handler.rb +251 -0
- data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
- data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
- data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
- data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
- data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
- data/lib/signalwire/skills/builtin/datetime.rb +97 -0
- data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
- data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
- data/lib/signalwire/skills/builtin/joke.rb +65 -0
- data/lib/signalwire/skills/builtin/math.rb +176 -0
- data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
- data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
- data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
- data/lib/signalwire/skills/builtin/spider.rb +169 -0
- data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
- data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
- data/lib/signalwire/skills/builtin/web_search.rb +141 -0
- data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
- data/lib/signalwire/skills/skill_base.rb +82 -0
- data/lib/signalwire/skills/skill_manager.rb +97 -0
- data/lib/signalwire/skills/skill_registry.rb +258 -0
- data/lib/signalwire/swaig/function_result.rb +777 -0
- data/lib/signalwire/swml/document.rb +84 -0
- data/lib/signalwire/swml/schema.json +12250 -0
- data/lib/signalwire/swml/schema.rb +81 -0
- data/lib/signalwire/swml/service.rb +650 -0
- data/lib/signalwire/utils/schema_utils.rb +298 -0
- data/lib/signalwire/utils/serverless.rb +19 -0
- data/lib/signalwire/utils/url_validator.rb +138 -0
- data/lib/signalwire/version.rb +5 -0
- data/lib/signalwire.rb +114 -0
- 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
|