signalwire_agents 1.1.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/README.md +79 -0
- data/bin/swaig-test +310 -0
- data/lib/signalwire_agents/agent/agent_base.rb +1171 -0
- data/lib/signalwire_agents/contexts/context_builder.rb +622 -0
- data/lib/signalwire_agents/datamap/data_map.rb +279 -0
- data/lib/signalwire_agents/logging.rb +92 -0
- data/lib/signalwire_agents/prefabs/concierge.rb +92 -0
- data/lib/signalwire_agents/prefabs/faq_bot.rb +67 -0
- data/lib/signalwire_agents/prefabs/info_gatherer.rb +79 -0
- data/lib/signalwire_agents/prefabs/receptionist.rb +74 -0
- data/lib/signalwire_agents/prefabs/survey.rb +75 -0
- data/lib/signalwire_agents/relay/action.rb +291 -0
- data/lib/signalwire_agents/relay/call.rb +523 -0
- data/lib/signalwire_agents/relay/client.rb +671 -0
- data/lib/signalwire_agents/relay/constants.rb +124 -0
- data/lib/signalwire_agents/relay/message.rb +137 -0
- data/lib/signalwire_agents/relay/relay_event.rb +670 -0
- data/lib/signalwire_agents/rest/http_client.rb +147 -0
- data/lib/signalwire_agents/rest/namespaces/addresses.rb +19 -0
- data/lib/signalwire_agents/rest/namespaces/calling.rb +179 -0
- data/lib/signalwire_agents/rest/namespaces/chat.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/compat.rb +229 -0
- data/lib/signalwire_agents/rest/namespaces/datasphere.rb +39 -0
- data/lib/signalwire_agents/rest/namespaces/fabric.rb +175 -0
- data/lib/signalwire_agents/rest/namespaces/imported_numbers.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/logs.rb +46 -0
- data/lib/signalwire_agents/rest/namespaces/lookup.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/mfa.rb +26 -0
- data/lib/signalwire_agents/rest/namespaces/number_groups.rb +32 -0
- data/lib/signalwire_agents/rest/namespaces/phone_numbers.rb +20 -0
- data/lib/signalwire_agents/rest/namespaces/project.rb +33 -0
- data/lib/signalwire_agents/rest/namespaces/pubsub.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/queues.rb +28 -0
- data/lib/signalwire_agents/rest/namespaces/recordings.rb +18 -0
- data/lib/signalwire_agents/rest/namespaces/registry.rb +67 -0
- data/lib/signalwire_agents/rest/namespaces/short_codes.rb +26 -0
- data/lib/signalwire_agents/rest/namespaces/sip_profile.rb +22 -0
- data/lib/signalwire_agents/rest/namespaces/verified_callers.rb +24 -0
- data/lib/signalwire_agents/rest/namespaces/video.rb +129 -0
- data/lib/signalwire_agents/rest/signalwire_client.rb +110 -0
- data/lib/signalwire_agents/security/session_manager.rb +124 -0
- data/lib/signalwire_agents/server/agent_server.rb +260 -0
- data/lib/signalwire_agents/skills/builtin/api_ninjas_trivia.rb +91 -0
- data/lib/signalwire_agents/skills/builtin/claude_skills.rb +92 -0
- data/lib/signalwire_agents/skills/builtin/custom_skills.rb +54 -0
- data/lib/signalwire_agents/skills/builtin/datasphere.rb +141 -0
- data/lib/signalwire_agents/skills/builtin/datasphere_serverless.rb +107 -0
- data/lib/signalwire_agents/skills/builtin/datetime.rb +97 -0
- data/lib/signalwire_agents/skills/builtin/google_maps.rb +168 -0
- data/lib/signalwire_agents/skills/builtin/info_gatherer.rb +189 -0
- data/lib/signalwire_agents/skills/builtin/joke.rb +65 -0
- data/lib/signalwire_agents/skills/builtin/math.rb +176 -0
- data/lib/signalwire_agents/skills/builtin/mcp_gateway.rb +121 -0
- data/lib/signalwire_agents/skills/builtin/native_vector_search.rb +116 -0
- data/lib/signalwire_agents/skills/builtin/play_background_file.rb +86 -0
- data/lib/signalwire_agents/skills/builtin/spider.rb +142 -0
- data/lib/signalwire_agents/skills/builtin/swml_transfer.rb +118 -0
- data/lib/signalwire_agents/skills/builtin/weather_api.rb +85 -0
- data/lib/signalwire_agents/skills/builtin/web_search.rb +123 -0
- data/lib/signalwire_agents/skills/builtin/wikipedia_search.rb +109 -0
- data/lib/signalwire_agents/skills/skill_base.rb +58 -0
- data/lib/signalwire_agents/skills/skill_manager.rb +85 -0
- data/lib/signalwire_agents/skills/skill_registry.rb +76 -0
- data/lib/signalwire_agents/swaig/function_result.rb +777 -0
- data/lib/signalwire_agents/swml/document.rb +84 -0
- data/lib/signalwire_agents/swml/schema.json +12250 -0
- data/lib/signalwire_agents/swml/schema.rb +81 -0
- data/lib/signalwire_agents/swml/service.rb +304 -0
- data/lib/signalwire_agents/version.rb +5 -0
- data/lib/signalwire_agents.rb +19 -0
- metadata +212 -0
|
@@ -0,0 +1,1171 @@
|
|
|
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 '../swml/document'
|
|
15
|
+
require_relative '../swml/schema'
|
|
16
|
+
require_relative '../swml/service'
|
|
17
|
+
require_relative '../swaig/function_result'
|
|
18
|
+
require_relative '../security/session_manager'
|
|
19
|
+
require_relative '../contexts/context_builder'
|
|
20
|
+
require_relative '../skills/skill_base'
|
|
21
|
+
require_relative '../skills/skill_manager'
|
|
22
|
+
require_relative '../skills/skill_registry'
|
|
23
|
+
|
|
24
|
+
module SignalWireAgents
|
|
25
|
+
# Central agent class that composes SWML rendering, tool dispatch,
|
|
26
|
+
# prompt management, AI config, and HTTP serving.
|
|
27
|
+
#
|
|
28
|
+
# AgentBase extends SWMLService with agent-specific capabilities:
|
|
29
|
+
# - Prompt management (POM sections and raw text)
|
|
30
|
+
# - Tool (SWAIG function) registration & dispatch
|
|
31
|
+
# - AI configuration (hints, languages, pronunciations, params)
|
|
32
|
+
# - Verb management (pre/post answer, post-AI)
|
|
33
|
+
# - Context & step workflows
|
|
34
|
+
# - Skill integration
|
|
35
|
+
# - Dynamic configuration via per-request ephemeral copies
|
|
36
|
+
#
|
|
37
|
+
# All configuration methods return +self+ for method chaining.
|
|
38
|
+
class AgentBase
|
|
39
|
+
attr_reader :name, :route, :host, :port, :logger
|
|
40
|
+
|
|
41
|
+
# Maximum request body size (1 MB)
|
|
42
|
+
MAX_BODY_SIZE = 1_048_576
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Construction
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
def initialize(name: 'agent', route: '/', host: '0.0.0.0', port: nil,
|
|
49
|
+
basic_auth: nil, auto_answer: true, record_call: false,
|
|
50
|
+
record_format: 'mp4', record_stereo: true,
|
|
51
|
+
token_expiry_secs: 3600)
|
|
52
|
+
@name = name
|
|
53
|
+
@route = route.to_s.chomp('/')
|
|
54
|
+
@route = '/' if @route.empty?
|
|
55
|
+
@host = host
|
|
56
|
+
@port = port || Integer(ENV.fetch('PORT', 3000))
|
|
57
|
+
@logger = Logging.logger("AgentBase[#{name}]")
|
|
58
|
+
|
|
59
|
+
# --- auth ---------------------------------------------------------
|
|
60
|
+
@basic_auth = if basic_auth
|
|
61
|
+
basic_auth
|
|
62
|
+
elsif ENV['SWML_BASIC_AUTH_USER'] && ENV['SWML_BASIC_AUTH_PASSWORD']
|
|
63
|
+
[ENV['SWML_BASIC_AUTH_USER'], ENV['SWML_BASIC_AUTH_PASSWORD']]
|
|
64
|
+
else
|
|
65
|
+
[SecureRandom.uuid, SecureRandom.uuid]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# --- call settings ------------------------------------------------
|
|
69
|
+
@auto_answer = auto_answer
|
|
70
|
+
@record_call = record_call
|
|
71
|
+
@record_format = record_format
|
|
72
|
+
@record_stereo = record_stereo
|
|
73
|
+
|
|
74
|
+
# --- session manager ----------------------------------------------
|
|
75
|
+
@session_manager = Security::SessionManager.new(token_expiry_secs: token_expiry_secs)
|
|
76
|
+
|
|
77
|
+
# --- prompt state -------------------------------------------------
|
|
78
|
+
@prompt_text = nil # raw text mode
|
|
79
|
+
@prompt_pom = nil # direct POM array
|
|
80
|
+
@pom_sections = [] # built via prompt_add_section
|
|
81
|
+
@post_prompt_text = nil
|
|
82
|
+
|
|
83
|
+
# --- tools --------------------------------------------------------
|
|
84
|
+
@tools = {} # name => { definition + handler }
|
|
85
|
+
@swaig_functions = {} # name => raw hash (DataMap etc.)
|
|
86
|
+
|
|
87
|
+
# --- AI config ----------------------------------------------------
|
|
88
|
+
@hints = []
|
|
89
|
+
@languages = []
|
|
90
|
+
@pronounce = []
|
|
91
|
+
@params = {}
|
|
92
|
+
@global_data = {}
|
|
93
|
+
@native_functions = []
|
|
94
|
+
@function_includes = []
|
|
95
|
+
@internal_fillers = {}
|
|
96
|
+
@prompt_llm_params = {}
|
|
97
|
+
@post_prompt_llm_params = {}
|
|
98
|
+
|
|
99
|
+
# --- debug --------------------------------------------------------
|
|
100
|
+
@debug_events_enabled = false
|
|
101
|
+
@debug_events_level = 1
|
|
102
|
+
@debug_event_callback = nil
|
|
103
|
+
|
|
104
|
+
# --- verbs --------------------------------------------------------
|
|
105
|
+
@pre_answer_verbs = [] # [[verb_name, config], ...]
|
|
106
|
+
@answer_config = {}
|
|
107
|
+
@post_answer_verbs = []
|
|
108
|
+
@post_ai_verbs = []
|
|
109
|
+
|
|
110
|
+
# --- contexts -----------------------------------------------------
|
|
111
|
+
@context_builder = nil
|
|
112
|
+
|
|
113
|
+
# --- skills -------------------------------------------------------
|
|
114
|
+
@skill_manager = Skills::SkillManager.new
|
|
115
|
+
@loaded_skills = {} # skill_name => SkillBase
|
|
116
|
+
|
|
117
|
+
# --- web ----------------------------------------------------------
|
|
118
|
+
@dynamic_config_callback = nil
|
|
119
|
+
@proxy_url_base = ENV['SWML_PROXY_URL_BASE']
|
|
120
|
+
@web_hook_url_override = nil
|
|
121
|
+
@post_prompt_url_override = nil
|
|
122
|
+
@swaig_query_params = {}
|
|
123
|
+
@debug_routes_enabled = false
|
|
124
|
+
@summary_callback = nil
|
|
125
|
+
|
|
126
|
+
# --- SIP ----------------------------------------------------------
|
|
127
|
+
@sip_routing_enabled = false
|
|
128
|
+
@sip_auto_map = false
|
|
129
|
+
@sip_path = '/sip'
|
|
130
|
+
@sip_usernames = []
|
|
131
|
+
|
|
132
|
+
@logger.info "Agent '#{@name}' initialised (route=#{@route}, port=#{@port})"
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# ==================================================================
|
|
136
|
+
# Prompt methods
|
|
137
|
+
# ==================================================================
|
|
138
|
+
|
|
139
|
+
# Set prompt as raw text. Clears any POM state.
|
|
140
|
+
def set_prompt_text(text)
|
|
141
|
+
@prompt_text = text
|
|
142
|
+
@pom_sections = []
|
|
143
|
+
@prompt_pom = nil
|
|
144
|
+
self
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Set post-prompt text.
|
|
148
|
+
def set_post_prompt(text)
|
|
149
|
+
@post_prompt_text = text
|
|
150
|
+
self
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Set POM array directly.
|
|
154
|
+
def set_prompt_pom(pom)
|
|
155
|
+
@prompt_pom = pom
|
|
156
|
+
@prompt_text = nil
|
|
157
|
+
@pom_sections = []
|
|
158
|
+
self
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Add a POM section.
|
|
162
|
+
def prompt_add_section(title, body = nil, bullets: nil)
|
|
163
|
+
@prompt_text = nil
|
|
164
|
+
@prompt_pom = nil
|
|
165
|
+
section = { 'title' => title }
|
|
166
|
+
section['body'] = body if body
|
|
167
|
+
section['bullets'] = bullets if bullets
|
|
168
|
+
@pom_sections << section
|
|
169
|
+
self
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Append text to an existing POM section's body.
|
|
173
|
+
def prompt_add_to_section(title, text)
|
|
174
|
+
sec = @pom_sections.find { |s| s['title'] == title }
|
|
175
|
+
if sec
|
|
176
|
+
sec['body'] = (sec['body'] || '') + text
|
|
177
|
+
end
|
|
178
|
+
self
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Add a subsection under a parent section.
|
|
182
|
+
def prompt_add_subsection(parent_title, title, body = nil, bullets: nil)
|
|
183
|
+
parent = @pom_sections.find { |s| s['title'] == parent_title }
|
|
184
|
+
if parent
|
|
185
|
+
parent['subsections'] ||= []
|
|
186
|
+
sub = { 'title' => title }
|
|
187
|
+
sub['body'] = body if body
|
|
188
|
+
sub['bullets'] = bullets if bullets
|
|
189
|
+
parent['subsections'] << sub
|
|
190
|
+
end
|
|
191
|
+
self
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Check whether a POM section with the given title exists.
|
|
195
|
+
def prompt_has_section?(title)
|
|
196
|
+
@pom_sections.any? { |s| s['title'] == title }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Return the current prompt: either a string (text mode) or an array (POM).
|
|
200
|
+
def get_prompt
|
|
201
|
+
return @prompt_text if @prompt_text
|
|
202
|
+
return @prompt_pom if @prompt_pom
|
|
203
|
+
return @pom_sections.dup unless @pom_sections.empty?
|
|
204
|
+
nil
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# ==================================================================
|
|
208
|
+
# Tool methods
|
|
209
|
+
# ==================================================================
|
|
210
|
+
|
|
211
|
+
# Define a tool with a block handler.
|
|
212
|
+
#
|
|
213
|
+
# @param name [String]
|
|
214
|
+
# @param description [String]
|
|
215
|
+
# @param parameters [Hash] JSON-Schema of parameters
|
|
216
|
+
# @param secure [Boolean]
|
|
217
|
+
# @param fillers [Hash, nil] language_code => [phrases]
|
|
218
|
+
# @param swaig_fields [Hash, nil] extra fields merged into definition
|
|
219
|
+
# @yield [args, raw_data] the tool handler
|
|
220
|
+
def define_tool(name:, description:, parameters: {}, secure: false,
|
|
221
|
+
fillers: nil, swaig_fields: nil, &handler)
|
|
222
|
+
# Normalise parameters into JSON-Schema form
|
|
223
|
+
param_schema = _normalise_parameters(parameters)
|
|
224
|
+
|
|
225
|
+
tool_def = {
|
|
226
|
+
'function' => name,
|
|
227
|
+
'description' => description,
|
|
228
|
+
'parameters' => param_schema
|
|
229
|
+
}
|
|
230
|
+
tool_def['fillers'] = fillers if fillers && !fillers.empty?
|
|
231
|
+
|
|
232
|
+
# Merge extra swaig fields
|
|
233
|
+
if swaig_fields.is_a?(Hash)
|
|
234
|
+
swaig_fields.each { |k, v| tool_def[k.to_s] = v }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
@tools[name] = {
|
|
238
|
+
definition: tool_def,
|
|
239
|
+
handler: handler,
|
|
240
|
+
secure: secure
|
|
241
|
+
}
|
|
242
|
+
self
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Register a raw SWAIG function definition (e.g. from DataMap#to_swaig_function).
|
|
246
|
+
def register_swaig_function(func_def)
|
|
247
|
+
fname = func_def['function'] || func_def[:function]
|
|
248
|
+
return self unless fname
|
|
249
|
+
@swaig_functions[fname] = func_def.transform_keys(&:to_s)
|
|
250
|
+
self
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Return an array of all tool definitions (for SWML rendering).
|
|
254
|
+
def define_tools
|
|
255
|
+
defs = @tools.values.map { |t| t[:definition].dup }
|
|
256
|
+
defs + @swaig_functions.values.map(&:dup)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Dispatch a function call to the registered handler.
|
|
260
|
+
def on_function_call(name, args, raw_data)
|
|
261
|
+
tool = @tools[name]
|
|
262
|
+
unless tool
|
|
263
|
+
return { 'response' => "Function '#{name}' not found" }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# Validate secure token if needed
|
|
267
|
+
if tool[:secure]
|
|
268
|
+
call_id = raw_data && (raw_data['call_id'] || (raw_data['call'] && raw_data['call']['call_id']))
|
|
269
|
+
token = raw_data && raw_data['meta_data_token']
|
|
270
|
+
if call_id && token
|
|
271
|
+
unless @session_manager.validate_token(name, token, call_id)
|
|
272
|
+
return { 'response' => 'Invalid or expired token' }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
result = tool[:handler].call(args, raw_data)
|
|
278
|
+
if result.respond_to?(:to_h)
|
|
279
|
+
result.to_h
|
|
280
|
+
elsif result.is_a?(Hash)
|
|
281
|
+
result
|
|
282
|
+
else
|
|
283
|
+
{ 'response' => result.to_s }
|
|
284
|
+
end
|
|
285
|
+
rescue => e
|
|
286
|
+
@logger.error "Tool '#{name}' error: #{e.message}"
|
|
287
|
+
{ 'response' => "Error executing '#{name}': #{e.message}" }
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# ==================================================================
|
|
291
|
+
# AI Config methods
|
|
292
|
+
# ==================================================================
|
|
293
|
+
|
|
294
|
+
def add_hint(hint)
|
|
295
|
+
@hints << hint if hint.is_a?(String) && !hint.empty?
|
|
296
|
+
self
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def add_hints(hints)
|
|
300
|
+
if hints.is_a?(Array)
|
|
301
|
+
hints.each { |h| add_hint(h) }
|
|
302
|
+
end
|
|
303
|
+
self
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def add_pattern_hint(pattern, hint: nil, language: 'en-US')
|
|
307
|
+
entry = { 'pattern' => pattern }
|
|
308
|
+
entry['hint'] = hint if hint
|
|
309
|
+
entry['language'] = language if language
|
|
310
|
+
@hints << entry
|
|
311
|
+
self
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def add_language(config)
|
|
315
|
+
@languages << config if config.is_a?(Hash)
|
|
316
|
+
self
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def set_languages(languages)
|
|
320
|
+
@languages = languages.dup if languages.is_a?(Array)
|
|
321
|
+
self
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def add_pronunciation(phrase, pronunciation, language_code: 'en-US')
|
|
325
|
+
rule = { 'replace' => phrase, 'with' => pronunciation }
|
|
326
|
+
rule['ignore_case'] = false
|
|
327
|
+
@pronounce << rule
|
|
328
|
+
self
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def set_pronunciations(pronunciations)
|
|
332
|
+
@pronounce = pronunciations.dup if pronunciations.is_a?(Array)
|
|
333
|
+
self
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def set_param(key, value)
|
|
337
|
+
@params[key.to_s] = value
|
|
338
|
+
self
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def set_params(params)
|
|
342
|
+
if params.is_a?(Hash)
|
|
343
|
+
params.each { |k, v| @params[k.to_s] = v }
|
|
344
|
+
end
|
|
345
|
+
self
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def set_global_data(data)
|
|
349
|
+
@global_data.merge!(data) if data.is_a?(Hash)
|
|
350
|
+
self
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def update_global_data(data)
|
|
354
|
+
set_global_data(data)
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def set_native_functions(names)
|
|
358
|
+
@native_functions = names.dup if names.is_a?(Array)
|
|
359
|
+
self
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def set_internal_fillers(fillers)
|
|
363
|
+
@internal_fillers.merge!(fillers) if fillers.is_a?(Hash)
|
|
364
|
+
self
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def add_internal_filler(func_name, lang_code, fillers)
|
|
368
|
+
if func_name && lang_code && fillers.is_a?(Array) && !fillers.empty?
|
|
369
|
+
@internal_fillers[func_name] ||= {}
|
|
370
|
+
@internal_fillers[func_name][lang_code] = fillers
|
|
371
|
+
end
|
|
372
|
+
self
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def enable_debug_events(level = 1)
|
|
376
|
+
@debug_events_enabled = true
|
|
377
|
+
@debug_events_level = level
|
|
378
|
+
self
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def add_function_include(url, functions, meta_data: nil)
|
|
382
|
+
include = { 'url' => url, 'functions' => functions }
|
|
383
|
+
include['meta_data'] = meta_data if meta_data.is_a?(Hash)
|
|
384
|
+
@function_includes << include
|
|
385
|
+
self
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
def set_function_includes(includes)
|
|
389
|
+
@function_includes = includes.dup if includes.is_a?(Array)
|
|
390
|
+
self
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def set_prompt_llm_params(**params)
|
|
394
|
+
@prompt_llm_params.merge!(params.transform_keys(&:to_s))
|
|
395
|
+
self
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
def set_post_prompt_llm_params(**params)
|
|
399
|
+
@post_prompt_llm_params.merge!(params.transform_keys(&:to_s))
|
|
400
|
+
self
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# ==================================================================
|
|
404
|
+
# Verb management
|
|
405
|
+
# ==================================================================
|
|
406
|
+
|
|
407
|
+
def add_pre_answer_verb(verb_name, config)
|
|
408
|
+
@pre_answer_verbs << [verb_name.to_s, config]
|
|
409
|
+
self
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
def clear_pre_answer_verbs
|
|
413
|
+
@pre_answer_verbs = []
|
|
414
|
+
self
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def add_answer_verb(config)
|
|
418
|
+
@answer_config = config
|
|
419
|
+
self
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def add_post_answer_verb(verb_name, config)
|
|
423
|
+
@post_answer_verbs << [verb_name.to_s, config]
|
|
424
|
+
self
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
def clear_post_answer_verbs
|
|
428
|
+
@post_answer_verbs = []
|
|
429
|
+
self
|
|
430
|
+
end
|
|
431
|
+
|
|
432
|
+
def add_post_ai_verb(verb_name, config)
|
|
433
|
+
@post_ai_verbs << [verb_name.to_s, config]
|
|
434
|
+
self
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def clear_post_ai_verbs
|
|
438
|
+
@post_ai_verbs = []
|
|
439
|
+
self
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# ==================================================================
|
|
443
|
+
# Contexts
|
|
444
|
+
# ==================================================================
|
|
445
|
+
|
|
446
|
+
# Returns the ContextBuilder, creating one lazily.
|
|
447
|
+
def define_contexts
|
|
448
|
+
@context_builder ||= Contexts::ContextBuilder.new
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
alias contexts define_contexts
|
|
452
|
+
|
|
453
|
+
# ==================================================================
|
|
454
|
+
# Skill integration
|
|
455
|
+
# ==================================================================
|
|
456
|
+
|
|
457
|
+
# Load and register a skill by name.
|
|
458
|
+
def add_skill(skill_name, params = {})
|
|
459
|
+
# Ensure builtins are registered
|
|
460
|
+
Skills::SkillRegistry.register_builtins!
|
|
461
|
+
|
|
462
|
+
factory = Skills::SkillRegistry.get_factory(skill_name)
|
|
463
|
+
raise ArgumentError, "Unknown skill: '#{skill_name}'" unless factory
|
|
464
|
+
|
|
465
|
+
skill = factory.call(params)
|
|
466
|
+
@skill_manager.load(skill.instance_key, skill)
|
|
467
|
+
@loaded_skills[skill_name] = skill
|
|
468
|
+
|
|
469
|
+
# Register tools from the skill
|
|
470
|
+
tool_defs = skill.register_tools
|
|
471
|
+
if tool_defs.is_a?(Array)
|
|
472
|
+
tool_defs.each do |td|
|
|
473
|
+
td_name = td[:name] || td['name']
|
|
474
|
+
td_desc = td[:description] || td['description']
|
|
475
|
+
td_params = td[:parameters] || td['parameters'] || {}
|
|
476
|
+
td_handler = td[:handler] || td['handler']
|
|
477
|
+
next unless td_name && td_handler
|
|
478
|
+
|
|
479
|
+
define_tool(
|
|
480
|
+
name: td_name,
|
|
481
|
+
description: td_desc || '',
|
|
482
|
+
parameters: td_params,
|
|
483
|
+
&td_handler
|
|
484
|
+
)
|
|
485
|
+
end
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
# Merge hints
|
|
489
|
+
skill_hints = skill.get_hints
|
|
490
|
+
@hints.concat(skill_hints) if skill_hints.is_a?(Array) && !skill_hints.empty?
|
|
491
|
+
|
|
492
|
+
# Merge global data
|
|
493
|
+
skill_data = skill.get_global_data
|
|
494
|
+
@global_data.merge!(skill_data) if skill_data.is_a?(Hash) && !skill_data.empty?
|
|
495
|
+
|
|
496
|
+
# Merge prompt sections
|
|
497
|
+
skill_sections = skill.get_prompt_sections
|
|
498
|
+
if skill_sections.is_a?(Array) && !skill_sections.empty?
|
|
499
|
+
@prompt_text = nil # switch to POM mode
|
|
500
|
+
@prompt_pom = nil
|
|
501
|
+
skill_sections.each do |sec|
|
|
502
|
+
@pom_sections << sec
|
|
503
|
+
end
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
self
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def remove_skill(skill_name)
|
|
510
|
+
skill = @loaded_skills.delete(skill_name)
|
|
511
|
+
@skill_manager.unload(skill.instance_key) if skill
|
|
512
|
+
self
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
def list_skills
|
|
516
|
+
@loaded_skills.keys
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def has_skill?(skill_name)
|
|
520
|
+
@loaded_skills.key?(skill_name)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
# ==================================================================
|
|
524
|
+
# Web / HTTP configuration
|
|
525
|
+
# ==================================================================
|
|
526
|
+
|
|
527
|
+
def set_dynamic_config_callback(callable = nil, &block)
|
|
528
|
+
@dynamic_config_callback = callable || block
|
|
529
|
+
self
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
def manual_set_proxy_url(url)
|
|
533
|
+
@proxy_url_base = url
|
|
534
|
+
self
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def set_web_hook_url(url)
|
|
538
|
+
@web_hook_url_override = url
|
|
539
|
+
self
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
def set_post_prompt_url(url)
|
|
543
|
+
@post_prompt_url_override = url
|
|
544
|
+
self
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def add_swaig_query_params(params)
|
|
548
|
+
@swaig_query_params.merge!(params) if params.is_a?(Hash)
|
|
549
|
+
self
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def clear_swaig_query_params
|
|
553
|
+
@swaig_query_params = {}
|
|
554
|
+
self
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
def enable_debug_routes
|
|
558
|
+
@debug_routes_enabled = true
|
|
559
|
+
self
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
# ==================================================================
|
|
563
|
+
# SIP
|
|
564
|
+
# ==================================================================
|
|
565
|
+
|
|
566
|
+
def enable_sip_routing(auto_map: true, path: '/sip')
|
|
567
|
+
@sip_routing_enabled = true
|
|
568
|
+
@sip_auto_map = auto_map
|
|
569
|
+
@sip_path = path
|
|
570
|
+
self
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
def register_sip_username(username)
|
|
574
|
+
@sip_usernames << username
|
|
575
|
+
self
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Extract a SIP username from a SIP URI string.
|
|
579
|
+
#
|
|
580
|
+
# Parses URIs of the form "sip:user@domain" and returns the user part.
|
|
581
|
+
# Handles optional "sip:" or "sips:" scheme prefixes.
|
|
582
|
+
#
|
|
583
|
+
# @param sip_uri [String] a SIP URI, e.g. "sip:alice@example.com"
|
|
584
|
+
# @return [String, nil] the username, or nil if the URI cannot be parsed
|
|
585
|
+
def self.extract_sip_username(sip_uri)
|
|
586
|
+
return nil if sip_uri.nil? || sip_uri.empty?
|
|
587
|
+
|
|
588
|
+
# Strip optional sip:/sips: scheme
|
|
589
|
+
uri = sip_uri.to_s.strip
|
|
590
|
+
uri = uri.sub(%r{\Asips?:}, '')
|
|
591
|
+
|
|
592
|
+
# Extract user part before @
|
|
593
|
+
if uri.include?('@')
|
|
594
|
+
user = uri.split('@', 2).first
|
|
595
|
+
user && !user.empty? ? user : nil
|
|
596
|
+
else
|
|
597
|
+
nil
|
|
598
|
+
end
|
|
599
|
+
end
|
|
600
|
+
|
|
601
|
+
# Extract the SIP username from request body data.
|
|
602
|
+
#
|
|
603
|
+
# Looks for SIP URI in common request body fields
|
|
604
|
+
# (e.g., "to", "from", "sip_uri", "call.to", "call.from").
|
|
605
|
+
#
|
|
606
|
+
# @param request_data [Hash] the parsed request body
|
|
607
|
+
# @return [String, nil] the extracted SIP username, or nil
|
|
608
|
+
def self.extract_sip_username_from_request(request_data)
|
|
609
|
+
return nil unless request_data.is_a?(Hash)
|
|
610
|
+
|
|
611
|
+
# Check common SIP URI fields
|
|
612
|
+
candidates = [
|
|
613
|
+
request_data['to'],
|
|
614
|
+
request_data['from'],
|
|
615
|
+
request_data['sip_uri'],
|
|
616
|
+
request_data.dig('call', 'to'),
|
|
617
|
+
request_data.dig('call', 'from')
|
|
618
|
+
].compact
|
|
619
|
+
|
|
620
|
+
candidates.each do |uri|
|
|
621
|
+
username = extract_sip_username(uri.to_s)
|
|
622
|
+
return username if username
|
|
623
|
+
end
|
|
624
|
+
|
|
625
|
+
nil
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
# ==================================================================
|
|
629
|
+
# Lifecycle
|
|
630
|
+
# ==================================================================
|
|
631
|
+
|
|
632
|
+
def on_summary(&block)
|
|
633
|
+
@summary_callback = block
|
|
634
|
+
self
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
def on_debug_event(&block)
|
|
638
|
+
@debug_event_callback = block
|
|
639
|
+
self
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
# Start the HTTP server (blocking).
|
|
643
|
+
def run
|
|
644
|
+
serve
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
def serve
|
|
648
|
+
require 'webrick'
|
|
649
|
+
@logger.info "Starting server on #{@host}:#{@port} ..."
|
|
650
|
+
user, _pass = @basic_auth
|
|
651
|
+
@logger.info "Basic-auth credentials — user: #{user} password: [REDACTED]"
|
|
652
|
+
|
|
653
|
+
@server = ::WEBrick::HTTPServer.new(
|
|
654
|
+
Host: @host,
|
|
655
|
+
Port: @port,
|
|
656
|
+
Logger: WEBrick::Log.new($stderr, WEBrick::Log::WARN),
|
|
657
|
+
AccessLog: []
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Rack 3+ moved Handler to the rackup gem
|
|
661
|
+
handler = begin
|
|
662
|
+
require 'rackup/handler/webrick'
|
|
663
|
+
Rackup::Handler::WEBrick
|
|
664
|
+
rescue LoadError
|
|
665
|
+
require 'rack/handler/webrick'
|
|
666
|
+
Rack::Handler::WEBrick
|
|
667
|
+
end
|
|
668
|
+
@server.mount '/', handler, rack_app
|
|
669
|
+
|
|
670
|
+
trap('INT') { @server.shutdown }
|
|
671
|
+
trap('TERM') { @server.shutdown }
|
|
672
|
+
|
|
673
|
+
@server.start
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
# Return a Rack-compatible application for mounting.
|
|
677
|
+
def rack_app
|
|
678
|
+
@rack_app ||= _build_rack_app
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
alias as_rack_app rack_app
|
|
682
|
+
|
|
683
|
+
# ==================================================================
|
|
684
|
+
# SWML Rendering
|
|
685
|
+
# ==================================================================
|
|
686
|
+
|
|
687
|
+
# Build the complete SWML document hash.
|
|
688
|
+
#
|
|
689
|
+
# @param request_data [Hash, nil] parsed request body
|
|
690
|
+
# @param request [Rack::Request, nil] the HTTP request
|
|
691
|
+
# @return [Hash]
|
|
692
|
+
def render_swml(request_data = nil, request: nil)
|
|
693
|
+
agent = self
|
|
694
|
+
|
|
695
|
+
# Dynamic config: clone into ephemeral copy
|
|
696
|
+
if @dynamic_config_callback
|
|
697
|
+
agent = _create_ephemeral_copy
|
|
698
|
+
begin
|
|
699
|
+
query_params = request ? _parse_query_string(request) : {}
|
|
700
|
+
body_params = request_data || {}
|
|
701
|
+
headers = request ? _extract_headers(request) : {}
|
|
702
|
+
@dynamic_config_callback.call(query_params, body_params, headers, agent)
|
|
703
|
+
rescue => e
|
|
704
|
+
@logger.error "Dynamic config error: #{e.message}"
|
|
705
|
+
end
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
agent._render_swml_internal
|
|
709
|
+
end
|
|
710
|
+
|
|
711
|
+
# @api private
|
|
712
|
+
def _render_swml_internal
|
|
713
|
+
sections_main = []
|
|
714
|
+
|
|
715
|
+
# PHASE 1: Pre-answer verbs
|
|
716
|
+
@pre_answer_verbs.each do |verb_name, config|
|
|
717
|
+
sections_main << { verb_name => config }
|
|
718
|
+
end
|
|
719
|
+
|
|
720
|
+
# PHASE 2: Answer verb
|
|
721
|
+
if @auto_answer
|
|
722
|
+
answer_conf = @answer_config.empty? ? {} : @answer_config
|
|
723
|
+
sections_main << { 'answer' => answer_conf }
|
|
724
|
+
end
|
|
725
|
+
|
|
726
|
+
# PHASE 3: Post-answer verbs
|
|
727
|
+
if @record_call
|
|
728
|
+
sections_main << {
|
|
729
|
+
'record_call' => {
|
|
730
|
+
'format' => @record_format,
|
|
731
|
+
'stereo' => @record_stereo
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
end
|
|
735
|
+
@post_answer_verbs.each do |verb_name, config|
|
|
736
|
+
sections_main << { verb_name => config }
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
# PHASE 4: AI verb
|
|
740
|
+
ai_config = _build_ai_config
|
|
741
|
+
sections_main << { 'ai' => ai_config }
|
|
742
|
+
|
|
743
|
+
# PHASE 5: Post-AI verbs
|
|
744
|
+
@post_ai_verbs.each do |verb_name, config|
|
|
745
|
+
sections_main << { verb_name => config }
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
{
|
|
749
|
+
'version' => '1.0.0',
|
|
750
|
+
'sections' => {
|
|
751
|
+
'main' => sections_main
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
end
|
|
755
|
+
|
|
756
|
+
# Returns [username, password]
|
|
757
|
+
def get_basic_auth_credentials
|
|
758
|
+
@basic_auth.dup
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
# ==================================================================
|
|
762
|
+
# Private helpers
|
|
763
|
+
# ==================================================================
|
|
764
|
+
|
|
765
|
+
private
|
|
766
|
+
|
|
767
|
+
# Build the AI verb configuration hash.
|
|
768
|
+
def _build_ai_config
|
|
769
|
+
ai = {}
|
|
770
|
+
|
|
771
|
+
# --- prompt -------------------------------------------------------
|
|
772
|
+
prompt = get_prompt
|
|
773
|
+
if prompt.is_a?(Array) && !prompt.empty?
|
|
774
|
+
prompt_obj = { 'pom' => prompt }
|
|
775
|
+
prompt_obj.merge!(@prompt_llm_params) unless @prompt_llm_params.empty?
|
|
776
|
+
ai['prompt'] = prompt_obj
|
|
777
|
+
elsif prompt.is_a?(String) && !prompt.empty?
|
|
778
|
+
prompt_obj = { 'text' => prompt }
|
|
779
|
+
prompt_obj.merge!(@prompt_llm_params) unless @prompt_llm_params.empty?
|
|
780
|
+
ai['prompt'] = prompt_obj
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
# --- post-prompt --------------------------------------------------
|
|
784
|
+
if @post_prompt_text && !@post_prompt_text.empty?
|
|
785
|
+
pp_obj = { 'text' => @post_prompt_text }
|
|
786
|
+
pp_obj.merge!(@post_prompt_llm_params) unless @post_prompt_llm_params.empty?
|
|
787
|
+
ai['post_prompt'] = pp_obj
|
|
788
|
+
|
|
789
|
+
# post_prompt_url
|
|
790
|
+
if @post_prompt_url_override
|
|
791
|
+
ai['post_prompt_url'] = @post_prompt_url_override
|
|
792
|
+
else
|
|
793
|
+
ai['post_prompt_url'] = _build_webhook_url('post_prompt')
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# --- SWAIG --------------------------------------------------------
|
|
798
|
+
swaig = {}
|
|
799
|
+
|
|
800
|
+
# default webhook url
|
|
801
|
+
default_url = @web_hook_url_override || _build_webhook_url('swaig', @swaig_query_params.empty? ? nil : @swaig_query_params)
|
|
802
|
+
swaig['defaults'] = { 'web_hook_url' => default_url }
|
|
803
|
+
|
|
804
|
+
# functions
|
|
805
|
+
functions = _build_functions_array
|
|
806
|
+
swaig['functions'] = functions unless functions.empty?
|
|
807
|
+
|
|
808
|
+
# native functions
|
|
809
|
+
swaig['native_functions'] = @native_functions unless @native_functions.empty?
|
|
810
|
+
|
|
811
|
+
# includes
|
|
812
|
+
swaig['includes'] = @function_includes unless @function_includes.empty?
|
|
813
|
+
|
|
814
|
+
# internal_fillers
|
|
815
|
+
swaig['internal_fillers'] = @internal_fillers unless @internal_fillers.empty?
|
|
816
|
+
|
|
817
|
+
ai['SWAIG'] = swaig unless swaig.keys == ['defaults'] && functions.empty?
|
|
818
|
+
|
|
819
|
+
# --- hints --------------------------------------------------------
|
|
820
|
+
ai['hints'] = @hints.dup unless @hints.empty?
|
|
821
|
+
|
|
822
|
+
# --- languages ----------------------------------------------------
|
|
823
|
+
ai['languages'] = @languages.dup unless @languages.empty?
|
|
824
|
+
|
|
825
|
+
# --- pronunciations -----------------------------------------------
|
|
826
|
+
ai['pronounce'] = @pronounce.dup unless @pronounce.empty?
|
|
827
|
+
|
|
828
|
+
# --- params -------------------------------------------------------
|
|
829
|
+
merged_params = @params.dup
|
|
830
|
+
if @debug_events_enabled
|
|
831
|
+
merged_params['debug_webhook_url'] = _build_webhook_url('debug_events')
|
|
832
|
+
merged_params['debug_webhook_level'] = @debug_events_level
|
|
833
|
+
end
|
|
834
|
+
ai['params'] = merged_params unless merged_params.empty?
|
|
835
|
+
|
|
836
|
+
# --- global_data --------------------------------------------------
|
|
837
|
+
ai['global_data'] = @global_data.dup unless @global_data.empty?
|
|
838
|
+
|
|
839
|
+
# --- contexts -----------------------------------------------------
|
|
840
|
+
if @context_builder
|
|
841
|
+
begin
|
|
842
|
+
ai['contexts'] = @context_builder.to_h
|
|
843
|
+
rescue ArgumentError
|
|
844
|
+
# invalid context config — skip silently
|
|
845
|
+
end
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
ai
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# Build the functions array for the SWAIG section.
|
|
852
|
+
def _build_functions_array
|
|
853
|
+
functions = []
|
|
854
|
+
|
|
855
|
+
@tools.each do |name, tool|
|
|
856
|
+
func_entry = tool[:definition].dup
|
|
857
|
+
# Add per-function webhook URL if it has a token or query params
|
|
858
|
+
if tool[:secure] || !@swaig_query_params.empty?
|
|
859
|
+
qp = @swaig_query_params.dup
|
|
860
|
+
if tool[:secure]
|
|
861
|
+
# Note: token is generated per-call; in render we can't know call_id yet,
|
|
862
|
+
# so secure tools get per-function URLs at request time.
|
|
863
|
+
# For now, the default webhook URL handles dispatch.
|
|
864
|
+
end
|
|
865
|
+
func_entry['web_hook_url'] = _build_webhook_url('swaig', qp) unless qp.empty?
|
|
866
|
+
end
|
|
867
|
+
functions << func_entry
|
|
868
|
+
end
|
|
869
|
+
|
|
870
|
+
@swaig_functions.each do |_name, func_def|
|
|
871
|
+
functions << func_def.dup
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
functions
|
|
875
|
+
end
|
|
876
|
+
|
|
877
|
+
# Build a webhook URL with optional query params.
|
|
878
|
+
def _build_webhook_url(endpoint, query_params = nil)
|
|
879
|
+
base = _base_url
|
|
880
|
+
path = @route == '/' ? "/#{endpoint}" : "#{@route}/#{endpoint}"
|
|
881
|
+
|
|
882
|
+
url = "#{base}#{path}"
|
|
883
|
+
|
|
884
|
+
if query_params && !query_params.empty?
|
|
885
|
+
qs = URI.encode_www_form(query_params)
|
|
886
|
+
url = "#{url}?#{qs}"
|
|
887
|
+
end
|
|
888
|
+
|
|
889
|
+
url
|
|
890
|
+
end
|
|
891
|
+
|
|
892
|
+
# Compute the base URL (with auth embedded).
|
|
893
|
+
def _base_url
|
|
894
|
+
return @proxy_url_base if @proxy_url_base && !@proxy_url_base.empty?
|
|
895
|
+
|
|
896
|
+
user, pass = @basic_auth
|
|
897
|
+
"http://#{user}:#{pass}@#{@host}:#{@port}"
|
|
898
|
+
end
|
|
899
|
+
|
|
900
|
+
# Normalise tool parameters into JSON-Schema form.
|
|
901
|
+
def _normalise_parameters(params)
|
|
902
|
+
return params if params.is_a?(Hash) && params['type'] == 'object'
|
|
903
|
+
return { 'type' => 'object', 'properties' => {} } if params.nil? || params.empty?
|
|
904
|
+
|
|
905
|
+
# If the hash looks like {name => {type, description}}, wrap it.
|
|
906
|
+
if params.is_a?(Hash) && !params.key?('type')
|
|
907
|
+
{ 'type' => 'object', 'properties' => params.transform_keys(&:to_s) }
|
|
908
|
+
else
|
|
909
|
+
params
|
|
910
|
+
end
|
|
911
|
+
end
|
|
912
|
+
|
|
913
|
+
# Create an ephemeral deep copy for dynamic config.
|
|
914
|
+
def _create_ephemeral_copy
|
|
915
|
+
copy = dup
|
|
916
|
+
# Deep-copy mutable collections
|
|
917
|
+
copy.instance_variable_set(:@pom_sections, @pom_sections.map(&:dup))
|
|
918
|
+
copy.instance_variable_set(:@tools, @tools.transform_values(&:dup))
|
|
919
|
+
copy.instance_variable_set(:@swaig_functions, @swaig_functions.transform_values(&:dup))
|
|
920
|
+
copy.instance_variable_set(:@hints, @hints.dup)
|
|
921
|
+
copy.instance_variable_set(:@languages, @languages.map { |l| l.dup })
|
|
922
|
+
copy.instance_variable_set(:@pronounce, @pronounce.map { |p| p.dup })
|
|
923
|
+
copy.instance_variable_set(:@params, @params.dup)
|
|
924
|
+
copy.instance_variable_set(:@global_data, @global_data.dup)
|
|
925
|
+
copy.instance_variable_set(:@native_functions, @native_functions.dup)
|
|
926
|
+
copy.instance_variable_set(:@function_includes, @function_includes.map { |i| i.dup })
|
|
927
|
+
copy.instance_variable_set(:@internal_fillers, _deep_dup_hash(@internal_fillers))
|
|
928
|
+
copy.instance_variable_set(:@prompt_llm_params, @prompt_llm_params.dup)
|
|
929
|
+
copy.instance_variable_set(:@post_prompt_llm_params, @post_prompt_llm_params.dup)
|
|
930
|
+
copy.instance_variable_set(:@pre_answer_verbs, @pre_answer_verbs.map(&:dup))
|
|
931
|
+
copy.instance_variable_set(:@post_answer_verbs, @post_answer_verbs.map(&:dup))
|
|
932
|
+
copy.instance_variable_set(:@post_ai_verbs, @post_ai_verbs.map(&:dup))
|
|
933
|
+
copy.instance_variable_set(:@answer_config, @answer_config.dup)
|
|
934
|
+
copy.instance_variable_set(:@swaig_query_params, @swaig_query_params.dup)
|
|
935
|
+
copy.instance_variable_set(:@loaded_skills, @loaded_skills.dup)
|
|
936
|
+
# Don't copy the dynamic config callback to prevent infinite recursion
|
|
937
|
+
copy.instance_variable_set(:@dynamic_config_callback, nil)
|
|
938
|
+
copy
|
|
939
|
+
end
|
|
940
|
+
|
|
941
|
+
# Deep-dup a hash of hashes
|
|
942
|
+
def _deep_dup_hash(hash)
|
|
943
|
+
hash.each_with_object({}) do |(k, v), result|
|
|
944
|
+
result[k] = v.is_a?(Hash) ? v.dup : v
|
|
945
|
+
end
|
|
946
|
+
end
|
|
947
|
+
|
|
948
|
+
# Parse query string from Rack request
|
|
949
|
+
def _parse_query_string(request)
|
|
950
|
+
return {} unless request.respond_to?(:env)
|
|
951
|
+
|
|
952
|
+
qs = request.env['QUERY_STRING'] || ''
|
|
953
|
+
URI.decode_www_form(qs).to_h
|
|
954
|
+
rescue
|
|
955
|
+
{}
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
# Extract headers from Rack request
|
|
959
|
+
def _extract_headers(request)
|
|
960
|
+
return {} unless request.respond_to?(:env)
|
|
961
|
+
|
|
962
|
+
request.env.select { |k, _| k.start_with?('HTTP_') }
|
|
963
|
+
.transform_keys { |k| k.sub('HTTP_', '').downcase.tr('_', '-') }
|
|
964
|
+
rescue
|
|
965
|
+
{}
|
|
966
|
+
end
|
|
967
|
+
|
|
968
|
+
# ==================================================================
|
|
969
|
+
# Rack app
|
|
970
|
+
# ==================================================================
|
|
971
|
+
|
|
972
|
+
def _build_rack_app
|
|
973
|
+
agent = self
|
|
974
|
+
main_route = @route
|
|
975
|
+
|
|
976
|
+
Rack::Builder.new do
|
|
977
|
+
# --- public endpoints (no auth) --------------------------------
|
|
978
|
+
map '/health' do
|
|
979
|
+
run ->(_env) {
|
|
980
|
+
body = JSON.generate({ status: 'healthy' })
|
|
981
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
982
|
+
}
|
|
983
|
+
end
|
|
984
|
+
|
|
985
|
+
map '/ready' do
|
|
986
|
+
run ->(_env) {
|
|
987
|
+
body = JSON.generate({ status: 'ready' })
|
|
988
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
989
|
+
}
|
|
990
|
+
end
|
|
991
|
+
|
|
992
|
+
# --- authenticated endpoints -----------------------------------
|
|
993
|
+
map main_route do
|
|
994
|
+
use AgentSecurityHeadersMiddleware
|
|
995
|
+
use AgentBodyLimitMiddleware, AgentBase::MAX_BODY_SIZE
|
|
996
|
+
use AgentTimingSafeBasicAuth, agent
|
|
997
|
+
|
|
998
|
+
run ->(env) {
|
|
999
|
+
request = Rack::Request.new(env)
|
|
1000
|
+
sub_path = env['PATH_INFO'] || '/'
|
|
1001
|
+
sub_path = '/' if sub_path.empty?
|
|
1002
|
+
|
|
1003
|
+
request_data = nil
|
|
1004
|
+
if request.post? || request.put?
|
|
1005
|
+
body = request.body.read
|
|
1006
|
+
request_data = JSON.parse(body) rescue nil
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
case sub_path
|
|
1010
|
+
when '/swaig'
|
|
1011
|
+
agent._handle_swaig(request_data, env)
|
|
1012
|
+
when '/post_prompt'
|
|
1013
|
+
agent._handle_post_prompt(request_data, env)
|
|
1014
|
+
when '/debug_events'
|
|
1015
|
+
agent._handle_debug_events(request_data, env)
|
|
1016
|
+
else
|
|
1017
|
+
# SWML endpoint
|
|
1018
|
+
swml = agent.render_swml(request_data, request: request)
|
|
1019
|
+
body = JSON.generate(swml)
|
|
1020
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
1021
|
+
end
|
|
1022
|
+
}
|
|
1023
|
+
end
|
|
1024
|
+
end
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
# These methods must be accessible from the Rack lambda
|
|
1028
|
+
public
|
|
1029
|
+
|
|
1030
|
+
# Handle SWAIG function dispatch.
|
|
1031
|
+
# @api private
|
|
1032
|
+
def _handle_swaig(request_data, _env)
|
|
1033
|
+
unless request_data
|
|
1034
|
+
body = JSON.generate({ 'response' => 'No request data' })
|
|
1035
|
+
return [400, { 'content-type' => 'application/json' }, [body]]
|
|
1036
|
+
end
|
|
1037
|
+
|
|
1038
|
+
func_name = request_data['function']
|
|
1039
|
+
unless func_name
|
|
1040
|
+
body = JSON.generate({ 'response' => 'No function specified' })
|
|
1041
|
+
return [400, { 'content-type' => 'application/json' }, [body]]
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# Extract args from argument.parsed[0]
|
|
1045
|
+
args = {}
|
|
1046
|
+
if request_data['argument'].is_a?(Hash)
|
|
1047
|
+
parsed = request_data['argument']['parsed']
|
|
1048
|
+
args = parsed.first if parsed.is_a?(Array) && !parsed.empty?
|
|
1049
|
+
end
|
|
1050
|
+
args ||= {}
|
|
1051
|
+
|
|
1052
|
+
result = on_function_call(func_name, args, request_data)
|
|
1053
|
+
body = JSON.generate(result)
|
|
1054
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
1055
|
+
end
|
|
1056
|
+
|
|
1057
|
+
# Handle post_prompt callback.
|
|
1058
|
+
# @api private
|
|
1059
|
+
def _handle_post_prompt(request_data, _env)
|
|
1060
|
+
if @summary_callback && request_data
|
|
1061
|
+
begin
|
|
1062
|
+
post_prompt_data = request_data['post_prompt_data']
|
|
1063
|
+
summary = nil
|
|
1064
|
+
if post_prompt_data.is_a?(Hash)
|
|
1065
|
+
summary = post_prompt_data['parsed'] || post_prompt_data['raw']
|
|
1066
|
+
end
|
|
1067
|
+
@summary_callback.call(summary, request_data)
|
|
1068
|
+
rescue => e
|
|
1069
|
+
@logger.error "Post-prompt callback error: #{e.message}"
|
|
1070
|
+
end
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
body = JSON.generate({ 'status' => 'ok' })
|
|
1074
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
1075
|
+
end
|
|
1076
|
+
|
|
1077
|
+
# Handle debug events.
|
|
1078
|
+
# @api private
|
|
1079
|
+
def _handle_debug_events(request_data, _env)
|
|
1080
|
+
if @debug_event_callback && request_data
|
|
1081
|
+
begin
|
|
1082
|
+
event_type = request_data['event_type'] || 'unknown'
|
|
1083
|
+
@debug_event_callback.call(event_type, request_data)
|
|
1084
|
+
rescue => e
|
|
1085
|
+
@logger.error "Debug event callback error: #{e.message}"
|
|
1086
|
+
end
|
|
1087
|
+
end
|
|
1088
|
+
|
|
1089
|
+
body = JSON.generate({ 'status' => 'ok' })
|
|
1090
|
+
[200, { 'content-type' => 'application/json' }, [body]]
|
|
1091
|
+
end
|
|
1092
|
+
|
|
1093
|
+
private
|
|
1094
|
+
|
|
1095
|
+
# ==================================================================
|
|
1096
|
+
# Rack Middleware
|
|
1097
|
+
# ==================================================================
|
|
1098
|
+
|
|
1099
|
+
class AgentSecurityHeadersMiddleware
|
|
1100
|
+
HEADERS = {
|
|
1101
|
+
'x-content-type-options' => 'nosniff',
|
|
1102
|
+
'x-frame-options' => 'DENY',
|
|
1103
|
+
'cache-control' => 'no-store, no-cache, must-revalidate'
|
|
1104
|
+
}.freeze
|
|
1105
|
+
|
|
1106
|
+
def initialize(app)
|
|
1107
|
+
@app = app
|
|
1108
|
+
end
|
|
1109
|
+
|
|
1110
|
+
def call(env)
|
|
1111
|
+
status, headers, body = @app.call(env)
|
|
1112
|
+
HEADERS.each { |k, v| headers[k] = v }
|
|
1113
|
+
[status, headers, body]
|
|
1114
|
+
end
|
|
1115
|
+
end
|
|
1116
|
+
|
|
1117
|
+
class AgentBodyLimitMiddleware
|
|
1118
|
+
def initialize(app, max_size)
|
|
1119
|
+
@app = app
|
|
1120
|
+
@max_size = max_size
|
|
1121
|
+
end
|
|
1122
|
+
|
|
1123
|
+
def call(env)
|
|
1124
|
+
if env['CONTENT_LENGTH'] && env['CONTENT_LENGTH'].to_i > @max_size
|
|
1125
|
+
body = JSON.generate({ 'error' => 'Request body too large' })
|
|
1126
|
+
return [413, { 'content-type' => 'application/json' }, [body]]
|
|
1127
|
+
end
|
|
1128
|
+
@app.call(env)
|
|
1129
|
+
end
|
|
1130
|
+
end
|
|
1131
|
+
|
|
1132
|
+
class AgentTimingSafeBasicAuth
|
|
1133
|
+
def initialize(app, agent)
|
|
1134
|
+
@app = app
|
|
1135
|
+
@agent = agent
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
def call(env)
|
|
1139
|
+
auth = Rack::Auth::Basic::Request.new(env)
|
|
1140
|
+
unless auth.provided? && auth.basic?
|
|
1141
|
+
return _unauthorized
|
|
1142
|
+
end
|
|
1143
|
+
|
|
1144
|
+
user, pass = @agent.get_basic_auth_credentials
|
|
1145
|
+
input_user, input_pass = auth.credentials
|
|
1146
|
+
|
|
1147
|
+
user_ok = Rack::Utils.secure_compare(user.to_s, input_user.to_s)
|
|
1148
|
+
pass_ok = Rack::Utils.secure_compare(pass.to_s, input_pass.to_s)
|
|
1149
|
+
|
|
1150
|
+
if user_ok && pass_ok
|
|
1151
|
+
@app.call(env)
|
|
1152
|
+
else
|
|
1153
|
+
_unauthorized
|
|
1154
|
+
end
|
|
1155
|
+
end
|
|
1156
|
+
|
|
1157
|
+
private
|
|
1158
|
+
|
|
1159
|
+
def _unauthorized
|
|
1160
|
+
[
|
|
1161
|
+
401,
|
|
1162
|
+
{
|
|
1163
|
+
'content-type' => 'text/plain',
|
|
1164
|
+
'www-authenticate' => 'Basic realm="SignalWire Agent"'
|
|
1165
|
+
},
|
|
1166
|
+
['Unauthorized']
|
|
1167
|
+
]
|
|
1168
|
+
end
|
|
1169
|
+
end
|
|
1170
|
+
end
|
|
1171
|
+
end
|