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