Class: SignalWire::SWML::Service

Inherits:
Object
  • Object
show all
Defined in:
lib/signalwire/swml/service.rb

Direct Known Subclasses

AgentBase

Defined Under Namespace

Classes: SecurityHeadersMiddleware, TimingSafeBasicAuth

Constant Summary collapse

SWAIG_FN_NAME =

Maximum request body size enforced on /swaig and the main route (1 MB).

/\A[a-zA-Z_][a-zA-Z0-9_]*\z/.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(name:, route: '/', host: '0.0.0.0', port: nil, basic_auth: nil, schema_path: nil, config_file: nil, schema_validation: true) ⇒ Service

Returns a new instance of Service.



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/signalwire/swml/service.rb', line 34

def initialize(name:, route: '/', host: '0.0.0.0', port: nil, basic_auth: nil,
               schema_path: nil, config_file: nil, schema_validation: true)
  @name   = name
  @route  = route.chomp('/')
  @route  = '/' if @route.empty?
  @host   = host
  @port   = port || Integer(ENV.fetch('PORT', 3000))
  @log    = Logging.logger("SWML::Service[#{name}]")
  @document = Document.new
  @routing_callbacks = {}
  @server = nil

  # Python parity:
  # - ``schema_path`` — explicit path to the SWML schema file.
  #   When nil we fall back to the schema bundled with the gem
  #   via SWML::Schema.
  # - ``config_file`` — TOML/YAML configuration override file
  #   (Python's ``ConfigLoader``). Ruby v1 stashes the path; the
  #   loader is wired by AgentBase only when needed.
  # - ``schema_validation`` — when true (default), out-bound SWML
  #   is validated against the schema. ``SWML_SKIP_SCHEMA_VALIDATION=1``
  #   env var overrides to false (Python parity).
  @schema_path        = schema_path
  @config_file        = config_file
  @schema_validation  = schema_validation && ENV['SWML_SKIP_SCHEMA_VALIDATION'] != '1'

  # SWAIG tool registry — lifted from AgentBase so any Service (sidecar,
  # non-agent verb host) can register and dispatch SWAIG functions.
  @tools = {}            # name => { definition + handler }
  @swaig_functions = {}  # name => raw hash (DataMap etc.)

  # --- auth --------------------------------------------------------
  @basic_auth = if basic_auth
                  basic_auth
                elsif ENV['SWML_BASIC_AUTH_USER'] && ENV['SWML_BASIC_AUTH_PASSWORD']
                  [ENV['SWML_BASIC_AUTH_USER'], ENV['SWML_BASIC_AUTH_PASSWORD']]
                else
                  [SecureRandom.uuid, SecureRandom.uuid]
                end

  @log.info "Service '#{@name}' initialised (route=#{@route}, port=#{@port})"
end

Dynamic Method Handling

This class handles dynamic methods through the method_missing method

#method_missing(method_name, *args, **kwargs) ⇒ Object


Verb auto-vivification via method_missing




198
199
200
201
202
203
204
205
206
# File 'lib/signalwire/swml/service.rb', line 198

def method_missing(method_name, *args, **kwargs)
  verb = method_name.to_s

  if SWML.schema.valid_verb?(verb)
    execute_verb(verb, args, kwargs)
  else
    super
  end
end

Instance Attribute Details

#config_fileObject (readonly)

Python parity:

  • “name“, “route“, “host“, “port“ — surface from SWMLService.

  • “schema_path“ — path to the SWML schema file (or nil to use the gem-bundled default).

  • “config_file“ — optional TOML/YAML config file path.

  • “schema_validation“ — boolean flag mirroring Python’s “self._schema_validation“. “SWML_SKIP_SCHEMA_VALIDATION=1“ env var forces this to false.



23
24
25
# File 'lib/signalwire/swml/service.rb', line 23

def config_file
  @config_file
end

#hostObject (readonly)

Python parity:

  • “name“, “route“, “host“, “port“ — surface from SWMLService.

  • “schema_path“ — path to the SWML schema file (or nil to use the gem-bundled default).

  • “config_file“ — optional TOML/YAML config file path.

  • “schema_validation“ — boolean flag mirroring Python’s “self._schema_validation“. “SWML_SKIP_SCHEMA_VALIDATION=1“ env var forces this to false.



23
24
25
# File 'lib/signalwire/swml/service.rb', line 23

def host
  @host
end

#nameObject (readonly)

Python parity:

  • “name“, “route“, “host“, “port“ — surface from SWMLService.

  • “schema_path“ — path to the SWML schema file (or nil to use the gem-bundled default).

  • “config_file“ — optional TOML/YAML config file path.

  • “schema_validation“ — boolean flag mirroring Python’s “self._schema_validation“. “SWML_SKIP_SCHEMA_VALIDATION=1“ env var forces this to false.



23
24
25
# File 'lib/signalwire/swml/service.rb', line 23

def name
  @name
end

#portObject (readonly)

Python parity:

  • “name“, “route“, “host“, “port“ — surface from SWMLService.

  • “schema_path“ — path to the SWML schema file (or nil to use the gem-bundled default).

  • “config_file“ — optional TOML/YAML config file path.

  • “schema_validation“ — boolean flag mirroring Python’s “self._schema_validation“. “SWML_SKIP_SCHEMA_VALIDATION=1“ env var forces this to false.



23
24
25
# File 'lib/signalwire/swml/service.rb', line 23

def port
  @port
end

#routeObject (readonly)

Python parity:

  • “name“, “route“, “host“, “port“ — surface from SWMLService.

  • “schema_path“ — path to the SWML schema file (or nil to use the gem-bundled default).

  • “config_file“ — optional TOML/YAML config file path.

  • “schema_validation“ — boolean flag mirroring Python’s “self._schema_validation“. “SWML_SKIP_SCHEMA_VALIDATION=1“ env var forces this to false.



23
24
25
# File 'lib/signalwire/swml/service.rb', line 23

def route
  @route
end

#schema_pathObject (readonly)

Python parity:

  • “name“, “route“, “host“, “port“ — surface from SWMLService.

  • “schema_path“ — path to the SWML schema file (or nil to use the gem-bundled default).

  • “config_file“ — optional TOML/YAML config file path.

  • “schema_validation“ — boolean flag mirroring Python’s “self._schema_validation“. “SWML_SKIP_SCHEMA_VALIDATION=1“ env var forces this to false.



23
24
25
# File 'lib/signalwire/swml/service.rb', line 23

def schema_path
  @schema_path
end

#schema_validationObject (readonly)

Python parity:

  • “name“, “route“, “host“, “port“ — surface from SWMLService.

  • “schema_path“ — path to the SWML schema file (or nil to use the gem-bundled default).

  • “config_file“ — optional TOML/YAML config file path.

  • “schema_validation“ — boolean flag mirroring Python’s “self._schema_validation“. “SWML_SKIP_SCHEMA_VALIDATION=1“ env var forces this to false.



23
24
25
# File 'lib/signalwire/swml/service.rb', line 23

def schema_validation
  @schema_validation
end

Instance Method Details

#define_tool(name:, description:, parameters: {}, secure: false, &handler) ⇒ Object

Define a SWAIG function the AI can call. Tool descriptions and parameter descriptions are LLM-facing prompt engineering — see PORTING_GUIDE for guidance.



84
85
86
87
88
89
90
91
92
93
94
95
# File 'lib/signalwire/swml/service.rb', line 84

def define_tool(name:, description:, parameters: {}, secure: false, &handler)
  @tools[name] = {
    definition: {
      'function'    => name,
      'description' => description,
      'parameters'  => parameters,
    },
    handler:    handler,
    secure:     secure,
  }
  self
end

#define_toolsObject

Return an array of all tool definitions (for SWML rendering).



106
107
108
109
# File 'lib/signalwire/swml/service.rb', line 106

def define_tools
  defs = @tools.values.map { |t| t[:definition].dup }
  defs + @swaig_functions.values.map(&:dup)
end

#documentObject

Expose the underlying document (useful for tests and subclasses).



352
353
354
# File 'lib/signalwire/swml/service.rb', line 352

def document
  @document
end

#execute_verb(verb_name, args = [], kwargs = {}) ⇒ Object

Execute a SWML verb, adding it to the current document.

For most verbs the config is a keyword-args Hash. The sleep verb is special: it also accepts a bare Integer.



216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
# File 'lib/signalwire/swml/service.rb', line 216

def execute_verb(verb_name, args = [], kwargs = {})
  verb_name = verb_name.to_s

  if verb_name == 'sleep'
    # Accept sleep(2000) or sleep(duration: 2000)
    value = if args.length == 1 && args.first.is_a?(Integer)
              args.first
            elsif kwargs.key?(:duration)
              kwargs[:duration]
            elsif !kwargs.empty?
              kwargs.values.first
            else
              raise ArgumentError, "sleep requires an integer duration"
            end
    @document.add_verb(verb_name, value)
  else
    config = kwargs.transform_keys(&:to_s).reject { |_, v| v.nil? }
    @document.add_verb(verb_name, config)
  end
end

#get_all_functionsObject

Snapshot of all registered SWAIG functions keyed by name. (Python parity: ToolRegistry#get_all_functions.)



145
146
147
148
149
150
# File 'lib/signalwire/swml/service.rb', line 145

def get_all_functions
  out = {}
  @tools.each { |k, v| out[k] = v }
  @swaig_functions.each { |k, v| out[k] = v }
  out
end

#get_basic_auth_credentials(include_source: false) ⇒ Array(String, String)

Get the configured basic-auth credentials.

Python parity: “get_basic_auth_credentials(include_source=False)“. When “include_source“ is true, returns a 3-tuple “[user, pass, source]“ where “source“ is one of ““environment”“, ““auto-generated”“, or ““provided”“. Otherwise returns the 2-tuple “[user, pass]“.

Parameters:

  • include_source (Boolean) (defaults to: false)

Returns:

  • (Array(String, String))

    or [Array(String, String, String)]



251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
# File 'lib/signalwire/swml/service.rb', line 251

def get_basic_auth_credentials(include_source: false)
  u, p = @basic_auth
  return [u, p] unless include_source

  env_user = ENV['SWML_BASIC_AUTH_USER']
  env_pass = ENV['SWML_BASIC_AUTH_PASSWORD']
  source =
    if env_user && !env_user.empty? && env_pass && !env_pass.empty? && u == env_user && p == env_pass
      'environment'
    elsif u&.start_with?('user_') && p && p.length > 20
      'auto-generated'
    else
      'provided'
    end
  [u, p, source]
end

#get_basic_auth_credentials_with_sourceArray(String, String, String)

Backwards-compat alias for the legacy 3-tuple-only form.

Returns:

  • (Array(String, String, String))


284
285
286
# File 'lib/signalwire/swml/service.rb', line 284

def get_basic_auth_credentials_with_source
  get_basic_auth_credentials(include_source: true)
end

#get_full_url(include_auth: false) ⇒ Object

Build the full URL for this service.

get_full_url                       # => "http://0.0.0.0:3000/"
get_full_url(include_auth: true)   # => "http://user:pass@0.0.0.0:3000/"


292
293
294
295
296
297
# File 'lib/signalwire/swml/service.rb', line 292

def get_full_url(include_auth: false)
  scheme = 'http'
  auth   = include_auth ? "#{@basic_auth[0]}:#{@basic_auth[1]}@" : ''
  path   = @route == '/' ? '/' : @route
  "#{scheme}://#{auth}#{@host}:#{@port}#{path}"
end

#get_function(name) ⇒ Object

Get a registered SWAIG function by name, or nil when absent. (Python parity: ToolRegistry#get_function.)



139
140
141
# File 'lib/signalwire/swml/service.rb', line 139

def get_function(name)
  @tools[name] || @swaig_functions[name]
end

#handle_additional_route(_sub_path, _request_data, _env) ⇒ Array?

Extension point: register additional Rack routes after Service mounts /health, /ready, /swaig, and the main route. AgentBase uses this to add /post_prompt, /debug_events, /mcp.

Parameters:

  • sub_path (String)

    The sub-path under the main route

  • request_data (Hash, nil)

    Parsed JSON body

  • env (Hash)

    The Rack env

Returns:

  • (Array, nil)

    A Rack response triple, or nil if not handled



190
191
192
# File 'lib/signalwire/swml/service.rb', line 190

def handle_additional_route(_sub_path, _request_data, _env)
  nil
end

#has_function(name) ⇒ Object

Whether a SWAIG function with the given name is registered. (Python parity: ToolRegistry#has_function.)



133
134
135
# File 'lib/signalwire/swml/service.rb', line 133

def has_function(name)
  @tools.key?(name) || @swaig_functions.key?(name)
end

#list_tool_namesObject

List registered SWAIG tool names in registration order.



127
128
129
# File 'lib/signalwire/swml/service.rb', line 127

def list_tool_names
  @tools.keys
end

#on_function_call(name, args, raw_data) ⇒ Object

Dispatch a function call to the registered handler. Default plain implementation — AgentBase overrides with token validation.



113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/signalwire/swml/service.rb', line 113

def on_function_call(name, args, raw_data)
  tool = @tools[name]
  return nil unless tool && tool[:handler]
  result = tool[:handler].call(args, raw_data)
  if result.is_a?(Hash)
    result
  elsif result.respond_to?(:to_h) && !result.nil?
    result.to_h
  else
    { 'response' => result.to_s }
  end
end

#on_request(request_data = nil, callback_path = nil, request: nil) ⇒ Object

Customization hook called when SWML is requested. Default delegates to #on_swml_request and returns its result. Subclasses typically override on_swml_request rather than this method.

Return nil to use the default SWML rendering, or a Hash of modifications to merge into the document.

Python parity: WebMixin#on_request(request_data, callback_path). The Python third request argument is FastAPI-specific and intentionally not mirrored. Python parity: “on_request(request_data, callback_path)“. The third Python parameter (“request“) — a FastAPI “Request“ —is propagated through Ruby as the optional “request:“ keyword so subclasses can read query/header info when a Rack-style request is available. Default: delegate to “on_swml_request“.



323
324
325
# File 'lib/signalwire/swml/service.rb', line 323

def on_request(request_data = nil, callback_path = nil, request: nil)
  on_swml_request(request_data, callback_path, request: request)
end

#on_swml_request(request_data = nil, callback_path = nil, request: nil) ⇒ Object

Customization point for subclasses to modify SWML based on request data. The default returns nil (no modification).

Python parity: “on_swml_request(request_data, callback_path, request)“. The “request:“ keyword carries the Rack request (or FastAPI “Request“ analogue) for subclasses that need query params or headers.



335
336
337
# File 'lib/signalwire/swml/service.rb', line 335

def on_swml_request(request_data = nil, callback_path = nil, request: nil)
  nil
end

#rack_appObject

Returns a Rack-compatible application.



371
372
373
# File 'lib/signalwire/swml/service.rb', line 371

def rack_app
  @rack_app ||= build_rack_app
end

#register_routing_callback(path, &block) ⇒ Object


Routing callbacks & request handling




303
304
305
# File 'lib/signalwire/swml/service.rb', line 303

def register_routing_callback(path, &block)
  @routing_callbacks[path] = block
end

#register_swaig_function(func_def) ⇒ Object

Register a raw SWAIG function definition (e.g. from DataMap#to_swaig_function).



98
99
100
101
102
103
# File 'lib/signalwire/swml/service.rb', line 98

def register_swaig_function(func_def)
  fname = func_def['function'] || func_def[:function]
  return self unless fname
  @swaig_functions[fname] = func_def.transform_keys(&:to_s)
  self
end

#remove_function(name) ⇒ Object

Remove a registered SWAIG function. Returns true on success, false when the function was not registered. (Python parity: ToolRegistry#remove_function.)



155
156
157
158
159
160
161
162
163
164
165
# File 'lib/signalwire/swml/service.rb', line 155

def remove_function(name)
  if @tools.key?(name)
    @tools.delete(name)
    true
  elsif @swaig_functions.key?(name)
    @swaig_functions.delete(name)
    true
  else
    false
  end
end

#renderObject


Render the current SWML document




343
344
345
# File 'lib/signalwire/swml/service.rb', line 343

def render
  @document.render
end

#render_main_swml(_request_data = nil, request: nil) ⇒ Object

Extension point: handle GET /swaig (returns the SWML document by default). AgentBase overrides to render with prompts + dynamic config.



178
179
180
# File 'lib/signalwire/swml/service.rb', line 178

def render_main_swml(_request_data = nil, request: nil)
  @document.to_h
end

#render_prettyObject



347
348
349
# File 'lib/signalwire/swml/service.rb', line 347

def render_pretty
  @document.render_pretty
end

#respond_to_missing?(method_name, include_private = false) ⇒ Boolean

Returns:

  • (Boolean)


208
209
210
# File 'lib/signalwire/swml/service.rb', line 208

def respond_to_missing?(method_name, include_private = false)
  SWML.schema.valid_verb?(method_name.to_s) || super
end

#schema_utilsObject

SchemaUtils helper bound to this Service. Mirrors Python’s self.schema_utils public instance attribute on SWMLService. Built lazily on first access.



359
360
361
362
363
364
# File 'lib/signalwire/swml/service.rb', line 359

def schema_utils
  @schema_utils ||= begin
    require_relative '../utils/schema_utils'
    ::SignalWire::Utils::SchemaUtils.new
  end
end

#serve(host: nil, port: nil, ssl_cert: nil, ssl_key: nil, ssl_enabled: nil, domain: nil) ⇒ Object

Start serving (blocking).

Python parity: “serve(host=None, port=None, ssl_cert=None, ssl_key=None, ssl_enabled=None, domain=None)“. When SSL parameters are supplied the server is started with HTTPS bindings; otherwise plain HTTP. “host“/“port“ overrides default to the constructor-provided values.

Parameters:

  • host (String, nil) (defaults to: nil)

    override bind host

  • port (Integer, nil) (defaults to: nil)

    override bind port

  • ssl_cert (String, nil) (defaults to: nil)

    PEM cert path

  • ssl_key (String, nil) (defaults to: nil)

    PEM key path

  • ssl_enabled (Boolean, nil) (defaults to: nil)

    explicit SSL enable

  • domain (String, nil) (defaults to: nil)

    domain for SSL config



390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
# File 'lib/signalwire/swml/service.rb', line 390

def serve(host: nil, port: nil, ssl_cert: nil, ssl_key: nil,
          ssl_enabled: nil, domain: nil)
  require 'webrick'

  bind_host = host || @host
  bind_port = port || @port

  if !ssl_enabled.nil?
    @ssl_enabled = ssl_enabled
  end
  @domain = domain if domain
  @ssl_cert_path = ssl_cert if ssl_cert
  @ssl_key_path  = ssl_key  if ssl_key

  @log.info "Starting server on #{bind_host}:#{bind_port} ..."

  user, _pass = @basic_auth
  @log.info "Basic-auth credentials — user: #{user}  password: [REDACTED]"

  webrick_opts = {
    Host: bind_host,
    Port: bind_port,
    Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
    AccessLog: []
  }

  if @ssl_enabled && @ssl_cert_path && @ssl_key_path
    require 'webrick/https'
    require 'openssl'
    webrick_opts[:SSLEnable]      = true
    webrick_opts[:SSLCertificate] = OpenSSL::X509::Certificate.new(File.read(@ssl_cert_path))
    webrick_opts[:SSLPrivateKey]  = OpenSSL::PKey::RSA.new(File.read(@ssl_key_path))
  end

  @server = ::WEBrick::HTTPServer.new(**webrick_opts)

  # Rack 3+ moved Handler to the rackup gem.
  handler = begin
              require 'rackup/handler/webrick'
              Rackup::Handler::WEBrick
            rescue LoadError
              require 'rack/handler/webrick'
              Rack::Handler::WEBrick
            end
  @server.mount '/', handler, rack_app

  trap('INT')  { stop }
  trap('TERM') { stop }

  @server.start
end

#stopObject

Gracefully stop the server.



443
444
445
# File 'lib/signalwire/swml/service.rb', line 443

def stop
  @server&.shutdown
end

#swaig_pre_dispatch(_request_data, _func_name, _env) ⇒ Object

Extension point: invoked between argument parsing and function dispatch on POST /swaig. Returns [target, short_circuit]. If short_circuit is non-nil, it’s returned as the SWAIG response without calling on_function_call. AgentBase overrides to add session-token validation and ephemeral dynamic-config copies.



172
173
174
# File 'lib/signalwire/swml/service.rb', line 172

def swaig_pre_dispatch(_request_data, _func_name, _env)
  [self, nil]
end

#validate_basic_auth(username, password) ⇒ Object

Validate provided basic-auth credentials against the configured ones using a constant-time comparison. Python parity: AuthMixin#validate_basic_auth(username, password).



271
272
273
274
275
276
277
278
279
280
# File 'lib/signalwire/swml/service.rb', line 271

def validate_basic_auth(username, password)
  require 'openssl'
  u, p = @basic_auth
  return false if u.nil? || p.nil?
  OpenSSL.fixed_length_secure_compare(username, u) &&
    OpenSSL.fixed_length_secure_compare(password, p)
rescue ArgumentError
  # fixed_length_secure_compare raises on length mismatch
  false
end