signalwire-sdk 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +259 -0
  4. data/bin/swaig-test +872 -0
  5. data/lib/signalwire/agent/agent_base.rb +2134 -0
  6. data/lib/signalwire/contexts/context_builder.rb +861 -0
  7. data/lib/signalwire/core/logging_config.rb +54 -0
  8. data/lib/signalwire/datamap/data_map.rb +315 -0
  9. data/lib/signalwire/logging.rb +92 -0
  10. data/lib/signalwire/pom/prompt_object_model.rb +269 -0
  11. data/lib/signalwire/pom/section.rb +202 -0
  12. data/lib/signalwire/prefabs/concierge.rb +92 -0
  13. data/lib/signalwire/prefabs/faq_bot.rb +67 -0
  14. data/lib/signalwire/prefabs/info_gatherer.rb +79 -0
  15. data/lib/signalwire/prefabs/receptionist.rb +74 -0
  16. data/lib/signalwire/prefabs/survey.rb +75 -0
  17. data/lib/signalwire/relay/action.rb +291 -0
  18. data/lib/signalwire/relay/call.rb +523 -0
  19. data/lib/signalwire/relay/client.rb +789 -0
  20. data/lib/signalwire/relay/constants.rb +124 -0
  21. data/lib/signalwire/relay/message.rb +137 -0
  22. data/lib/signalwire/relay/relay_event.rb +670 -0
  23. data/lib/signalwire/rest/http_client.rb +159 -0
  24. data/lib/signalwire/rest/namespaces/addresses.rb +19 -0
  25. data/lib/signalwire/rest/namespaces/calling.rb +179 -0
  26. data/lib/signalwire/rest/namespaces/chat.rb +18 -0
  27. data/lib/signalwire/rest/namespaces/compat.rb +229 -0
  28. data/lib/signalwire/rest/namespaces/datasphere.rb +39 -0
  29. data/lib/signalwire/rest/namespaces/fabric.rb +235 -0
  30. data/lib/signalwire/rest/namespaces/imported_numbers.rb +18 -0
  31. data/lib/signalwire/rest/namespaces/logs.rb +46 -0
  32. data/lib/signalwire/rest/namespaces/lookup.rb +18 -0
  33. data/lib/signalwire/rest/namespaces/mfa.rb +26 -0
  34. data/lib/signalwire/rest/namespaces/number_groups.rb +32 -0
  35. data/lib/signalwire/rest/namespaces/phone_numbers.rb +124 -0
  36. data/lib/signalwire/rest/namespaces/project.rb +33 -0
  37. data/lib/signalwire/rest/namespaces/pubsub.rb +18 -0
  38. data/lib/signalwire/rest/namespaces/queues.rb +28 -0
  39. data/lib/signalwire/rest/namespaces/recordings.rb +18 -0
  40. data/lib/signalwire/rest/namespaces/registry.rb +67 -0
  41. data/lib/signalwire/rest/namespaces/short_codes.rb +26 -0
  42. data/lib/signalwire/rest/namespaces/sip_profile.rb +22 -0
  43. data/lib/signalwire/rest/namespaces/verified_callers.rb +24 -0
  44. data/lib/signalwire/rest/namespaces/video.rb +129 -0
  45. data/lib/signalwire/rest/pagination.rb +89 -0
  46. data/lib/signalwire/rest/phone_call_handler.rb +56 -0
  47. data/lib/signalwire/rest/rest_client.rb +114 -0
  48. data/lib/signalwire/runtime.rb +98 -0
  49. data/lib/signalwire/security/session_manager.rb +124 -0
  50. data/lib/signalwire/security/webhook_middleware.rb +191 -0
  51. data/lib/signalwire/security/webhook_validator.rb +327 -0
  52. data/lib/signalwire/server/agent_server.rb +413 -0
  53. data/lib/signalwire/serverless/lambda_handler.rb +251 -0
  54. data/lib/signalwire/skills/builtin/api_ninjas_trivia.rb +99 -0
  55. data/lib/signalwire/skills/builtin/claude_skills.rb +92 -0
  56. data/lib/signalwire/skills/builtin/custom_skills.rb +54 -0
  57. data/lib/signalwire/skills/builtin/datasphere.rb +153 -0
  58. data/lib/signalwire/skills/builtin/datasphere_serverless.rb +107 -0
  59. data/lib/signalwire/skills/builtin/datetime.rb +97 -0
  60. data/lib/signalwire/skills/builtin/google_maps.rb +168 -0
  61. data/lib/signalwire/skills/builtin/info_gatherer.rb +189 -0
  62. data/lib/signalwire/skills/builtin/joke.rb +65 -0
  63. data/lib/signalwire/skills/builtin/math.rb +176 -0
  64. data/lib/signalwire/skills/builtin/mcp_gateway.rb +121 -0
  65. data/lib/signalwire/skills/builtin/native_vector_search.rb +116 -0
  66. data/lib/signalwire/skills/builtin/play_background_file.rb +86 -0
  67. data/lib/signalwire/skills/builtin/spider.rb +169 -0
  68. data/lib/signalwire/skills/builtin/swml_transfer.rb +118 -0
  69. data/lib/signalwire/skills/builtin/weather_api.rb +92 -0
  70. data/lib/signalwire/skills/builtin/web_search.rb +141 -0
  71. data/lib/signalwire/skills/builtin/wikipedia_search.rb +125 -0
  72. data/lib/signalwire/skills/skill_base.rb +82 -0
  73. data/lib/signalwire/skills/skill_manager.rb +97 -0
  74. data/lib/signalwire/skills/skill_registry.rb +258 -0
  75. data/lib/signalwire/swaig/function_result.rb +777 -0
  76. data/lib/signalwire/swml/document.rb +84 -0
  77. data/lib/signalwire/swml/schema.json +12250 -0
  78. data/lib/signalwire/swml/schema.rb +81 -0
  79. data/lib/signalwire/swml/service.rb +650 -0
  80. data/lib/signalwire/utils/schema_utils.rb +298 -0
  81. data/lib/signalwire/utils/serverless.rb +19 -0
  82. data/lib/signalwire/utils/url_validator.rb +138 -0
  83. data/lib/signalwire/version.rb +5 -0
  84. data/lib/signalwire.rb +114 -0
  85. metadata +225 -0
@@ -0,0 +1,861 @@
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
+ module SignalWire
9
+ module Contexts
10
+ MAX_CONTEXTS = 50
11
+ MAX_STEPS_PER_CONTEXT = 100
12
+
13
+ # Reserved tool names auto-injected by the runtime when contexts/steps
14
+ # are present. User-defined SWAIG tools must not collide with these
15
+ # names.
16
+ #
17
+ # - next_step / change_context are injected when valid_steps or
18
+ # valid_contexts is set so the model can navigate the flow.
19
+ # - gather_submit is injected while a step's gather_info is
20
+ # collecting answers.
21
+ #
22
+ # ContextBuilder#validate! rejects any agent that registers a user
23
+ # tool sharing one of these names — the runtime would never call the
24
+ # user tool because the native one wins.
25
+ RESERVED_NATIVE_TOOL_NAMES = %w[next_step change_context gather_submit].freeze
26
+
27
+ # Represents a single question in a gather_info configuration.
28
+ class GatherQuestion
29
+ attr_accessor :key, :question, :type, :confirm, :prompt, :functions
30
+
31
+ def initialize(key:, question:, type: 'string', confirm: false, prompt: nil, functions: nil)
32
+ @key = key
33
+ @question = question
34
+ @type = type
35
+ @confirm = confirm
36
+ @prompt = prompt
37
+ @functions = functions
38
+ end
39
+
40
+ def to_h
41
+ h = { "key" => @key, "question" => @question }
42
+ h["type"] = @type if @type != 'string'
43
+ h["confirm"] = true if @confirm
44
+ h["prompt"] = @prompt if @prompt
45
+ h["functions"] = @functions if @functions
46
+ h
47
+ end
48
+ end
49
+
50
+ # Configuration for gathering information in a step via the C-side gather_info system.
51
+ class GatherInfo
52
+ attr_accessor :output_key, :completion_action, :prompt
53
+ attr_reader :questions
54
+
55
+ def initialize(output_key: nil, completion_action: nil, prompt: nil)
56
+ @output_key = output_key
57
+ @completion_action = completion_action
58
+ @prompt = prompt
59
+ @questions = []
60
+ end
61
+
62
+ # Add a question. Returns +self+ for chaining.
63
+ def add_question(key:, question:, **opts)
64
+ @questions << GatherQuestion.new(
65
+ key: key,
66
+ question: question,
67
+ type: opts.fetch(:type, 'string'),
68
+ confirm: opts.fetch(:confirm, false),
69
+ prompt: opts[:prompt],
70
+ functions: opts[:functions]
71
+ )
72
+ self
73
+ end
74
+
75
+ def to_h
76
+ raise ArgumentError, "gather_info must have at least one question" if @questions.empty?
77
+
78
+ h = { "questions" => @questions.map(&:to_h) }
79
+ h["prompt"] = @prompt if @prompt
80
+ h["output_key"] = @output_key if @output_key
81
+ h["completion_action"] = @completion_action if @completion_action
82
+ h
83
+ end
84
+ end
85
+
86
+ # Represents a single step within a context.
87
+ #
88
+ # All mutator methods return +self+ for fluent chaining.
89
+ class Step
90
+ attr_reader :name
91
+
92
+ def initialize(name)
93
+ @name = name
94
+ @text = nil
95
+ @step_criteria = nil
96
+ @functions = nil # nil | "none" | Array<String>
97
+ @valid_steps = nil
98
+ @valid_contexts = nil
99
+ @sections = []
100
+ @gather_info = nil
101
+
102
+ # Behavior flags
103
+ @end = false
104
+ @skip_user_turn = false
105
+ @skip_to_next_step = false
106
+
107
+ # Reset object for context-switching from steps
108
+ @reset_system_prompt = nil
109
+ @reset_user_prompt = nil
110
+ @reset_consolidate = false
111
+ @reset_full_reset = false
112
+ end
113
+
114
+ # Set the step's prompt text directly. Mutually exclusive with POM sections.
115
+ def set_text(text)
116
+ raise ArgumentError, "Cannot use set_text when POM sections have been added" if @sections.any?
117
+
118
+ @text = text
119
+ self
120
+ end
121
+
122
+ # Add a POM section (title + body). Mutually exclusive with +set_text+.
123
+ def add_section(title, body)
124
+ raise ArgumentError, "Cannot add POM sections when set_text has been used" unless @text.nil?
125
+
126
+ @sections << { "title" => title, "body" => body }
127
+ self
128
+ end
129
+
130
+ # Add a POM section with bullet points. Mutually exclusive with +set_text+.
131
+ def add_bullets(title, bullets)
132
+ raise ArgumentError, "Cannot add POM sections when set_text has been used" unless @text.nil?
133
+
134
+ @sections << { "title" => title, "bullets" => bullets }
135
+ self
136
+ end
137
+
138
+ def set_step_criteria(criteria)
139
+ @step_criteria = criteria
140
+ self
141
+ end
142
+
143
+ # Set which non-internal functions are callable while this step is
144
+ # active.
145
+ #
146
+ # IMPORTANT — inheritance behavior:
147
+ # If you do NOT call this method, the step inherits whichever
148
+ # function set was active on the previous step (or the previous
149
+ # context's last step). The server-side runtime only resets the
150
+ # active set when a step explicitly declares its +functions+
151
+ # field. This is the most common source of bugs in multi-step
152
+ # agents: forgetting +set_functions+ on a later step lets the
153
+ # previous step's tools leak through. Best practice is to call
154
+ # +set_functions+ explicitly on every step that should differ
155
+ # from the previous one.
156
+ #
157
+ # Keep the per-step active set small: LLM tool selection accuracy
158
+ # degrades noticeably past ~7-8 simultaneously-active tools per
159
+ # call. Use per-step whitelisting to partition large tool
160
+ # collections.
161
+ #
162
+ # Internal functions (e.g. +gather_submit+, hangup hook) are
163
+ # ALWAYS protected and cannot be deactivated by this whitelist.
164
+ # The native navigation tools +next_step+ and +change_context+ are
165
+ # injected automatically when +set_valid_steps+/+set_valid_contexts+
166
+ # is used; they are not affected by this list and do not need to
167
+ # appear in it.
168
+ #
169
+ # @param functions [String, Array<String>] one of:
170
+ # - Array<String> — whitelist of function names allowed in this
171
+ # step. Functions not in the list become inactive.
172
+ # - [] — explicit disable-all (no user functions callable).
173
+ # - "none" — synonym for [], same effect.
174
+ def set_functions(functions)
175
+ @functions = functions
176
+ self
177
+ end
178
+
179
+ def set_valid_steps(steps)
180
+ @valid_steps = steps
181
+ self
182
+ end
183
+
184
+ def set_valid_contexts(contexts)
185
+ @valid_contexts = contexts
186
+ self
187
+ end
188
+
189
+ # Mark this step as terminal for the step flow.
190
+ #
191
+ # IMPORTANT: +is_end+ = true does NOT end the conversation or hang
192
+ # up the call. It exits step mode entirely after this step
193
+ # executes — clearing the steps list, current step index,
194
+ # valid_steps, and valid_contexts. The agent keeps running, but
195
+ # operates only under the base system prompt and the
196
+ # context-level prompt; no more step instructions are injected
197
+ # and no more +next_step+ tool is offered.
198
+ #
199
+ # To actually end the call, call a hangup tool or define a
200
+ # hangup hook.
201
+ def set_end(is_end)
202
+ @end = is_end
203
+ self
204
+ end
205
+
206
+ def set_skip_user_turn(skip)
207
+ @skip_user_turn = skip
208
+ self
209
+ end
210
+
211
+ def set_skip_to_next_step(skip)
212
+ @skip_to_next_step = skip
213
+ self
214
+ end
215
+
216
+ # Enable info gathering for this step. Returns +self+.
217
+ # After calling this, use +add_gather_question+ to define questions.
218
+ def set_gather_info(output_key: nil, completion_action: nil, prompt: nil)
219
+ @gather_info = GatherInfo.new(
220
+ output_key: output_key,
221
+ completion_action: completion_action,
222
+ prompt: prompt
223
+ )
224
+ self
225
+ end
226
+
227
+ # Add a question to this step's gather_info configuration.
228
+ # +set_gather_info+ must be called first.
229
+ #
230
+ # IMPORTANT — gather mode locks function access:
231
+ # While the model is asking gather questions, the runtime
232
+ # forcibly deactivates ALL of the step's other functions. The
233
+ # only callable tools during a gather question are:
234
+ #
235
+ # - +gather_submit+ (the native answer-submission tool)
236
+ # - Whatever names you pass in this question's +functions:+
237
+ # option
238
+ #
239
+ # +next_step+ and +change_context+ are also filtered out — the
240
+ # model cannot navigate away until the gather completes. This
241
+ # is by design: it forces a tight ask → submit → next-question
242
+ # loop.
243
+ #
244
+ # If a question needs to call out to a tool (e.g. validate an
245
+ # email, geocode a ZIP), pass that tool name in this question's
246
+ # +functions:+ option. Functions listed here are active ONLY for
247
+ # this question.
248
+ # Python parity: ``add_gather_question(key, question, type='string',
249
+ # confirm=False, prompt=None, functions=None)``. Ruby exposes the
250
+ # same parameter set as keyword args.
251
+ def add_gather_question(key:, question:, type: 'string', confirm: false,
252
+ prompt: nil, functions: nil)
253
+ raise ArgumentError, "Must call set_gather_info before add_gather_question" if @gather_info.nil?
254
+
255
+ @gather_info.add_question(
256
+ key: key,
257
+ question: question,
258
+ type: type,
259
+ confirm: confirm,
260
+ prompt: prompt,
261
+ functions: functions
262
+ )
263
+ self
264
+ end
265
+
266
+ # Remove all POM sections and direct text.
267
+ def clear_sections
268
+ @sections = []
269
+ @text = nil
270
+ self
271
+ end
272
+
273
+ def set_reset_system_prompt(prompt)
274
+ @reset_system_prompt = prompt
275
+ self
276
+ end
277
+
278
+ def set_reset_user_prompt(prompt)
279
+ @reset_user_prompt = prompt
280
+ self
281
+ end
282
+
283
+ def set_reset_consolidate(val)
284
+ @reset_consolidate = val
285
+ self
286
+ end
287
+
288
+ def set_reset_full_reset(val)
289
+ @reset_full_reset = val
290
+ self
291
+ end
292
+
293
+ def to_h
294
+ step_h = {
295
+ "name" => @name,
296
+ "text" => render_text
297
+ }
298
+
299
+ step_h["step_criteria"] = @step_criteria if @step_criteria
300
+ step_h["functions"] = @functions unless @functions.nil?
301
+ step_h["valid_steps"] = @valid_steps if @valid_steps
302
+ step_h["valid_contexts"] = @valid_contexts if @valid_contexts
303
+ step_h["end"] = true if @end
304
+ step_h["skip_user_turn"] = true if @skip_user_turn
305
+ step_h["skip_to_next_step"] = true if @skip_to_next_step
306
+
307
+ reset = {}
308
+ reset["system_prompt"] = @reset_system_prompt if @reset_system_prompt
309
+ reset["user_prompt"] = @reset_user_prompt if @reset_user_prompt
310
+ reset["consolidate"] = @reset_consolidate if @reset_consolidate
311
+ reset["full_reset"] = @reset_full_reset if @reset_full_reset
312
+ step_h["reset"] = reset if reset.any?
313
+
314
+ step_h["gather_info"] = @gather_info.to_h if @gather_info
315
+
316
+ step_h
317
+ end
318
+
319
+ private
320
+
321
+ def render_text
322
+ return @text if @text
323
+
324
+ raise ArgumentError, "Step '#{@name}' has no text or POM sections defined" if @sections.empty?
325
+
326
+ parts = []
327
+ @sections.each do |section|
328
+ if section.key?("bullets")
329
+ parts << "## #{section['title']}"
330
+ section["bullets"].each { |b| parts << "- #{b}" }
331
+ else
332
+ parts << "## #{section['title']}"
333
+ parts << section["body"]
334
+ end
335
+ parts << "" # spacing
336
+ end
337
+ parts.join("\n").strip
338
+ end
339
+ end
340
+
341
+ # Represents a single context containing multiple steps.
342
+ class Context
343
+ attr_reader :name
344
+
345
+ def initialize(name)
346
+ @name = name
347
+ @steps = {} # name => Step
348
+ @step_order = []
349
+
350
+ # Navigation
351
+ @valid_contexts = nil
352
+ @valid_steps = nil
353
+ @initial_step = nil
354
+
355
+ # Context entry parameters
356
+ @post_prompt = nil
357
+ @system_prompt = nil
358
+ @system_prompt_sections = []
359
+ @consolidate = false
360
+ @full_reset = false
361
+ @user_prompt = nil
362
+ @isolated = false
363
+
364
+ # Context prompt
365
+ @prompt_text = nil
366
+ @prompt_sections = []
367
+
368
+ # Fillers
369
+ @enter_fillers = nil
370
+ @exit_fillers = nil
371
+ end
372
+
373
+ # Add a new step. Returns the new Step object (not self).
374
+ #
375
+ # Python parity: ``Context.add_step(name, *, task=None, bullets=None,
376
+ # criteria=None, functions=None, valid_steps=None)``. The optional
377
+ # keyword arguments give a one-call configuration shortcut:
378
+ #
379
+ # ctx.add_step("greet",
380
+ # task: "Greet the caller",
381
+ # bullets: ["Say hi", "Ask how can I help"],
382
+ # criteria: "User has been greeted",
383
+ # functions: ["weather"],
384
+ # valid_steps: ["help"])
385
+ #
386
+ # Without the optional args this stays the bare ``add_step("greet")``
387
+ # form that returns a Step for further fluent configuration.
388
+ def add_step(name, task: nil, bullets: nil, criteria: nil,
389
+ functions: nil, valid_steps: nil)
390
+ raise ArgumentError, "Step '#{name}' already exists in context '#{@name}'" if @steps.key?(name)
391
+ raise ArgumentError, "Maximum steps per context (#{MAX_STEPS_PER_CONTEXT}) exceeded" if @steps.size >= MAX_STEPS_PER_CONTEXT
392
+
393
+ step = Step.new(name)
394
+ @steps[name] = step
395
+ @step_order << name
396
+
397
+ step.add_section('Task', task) unless task.nil?
398
+ step.add_bullets('Process', bullets) unless bullets.nil?
399
+ step.set_step_criteria(criteria) unless criteria.nil?
400
+ step.set_functions(functions) unless functions.nil?
401
+ step.set_valid_steps(valid_steps) unless valid_steps.nil?
402
+
403
+ step
404
+ end
405
+
406
+ # Get an existing step by name. Returns Step or nil.
407
+ def get_step(name)
408
+ @steps[name]
409
+ end
410
+
411
+ # Remove a step by name. Returns self.
412
+ def remove_step(name)
413
+ if @steps.key?(name)
414
+ @steps.delete(name)
415
+ @step_order.delete(name)
416
+ end
417
+ self
418
+ end
419
+
420
+ # Move an existing step to a specific position. Returns self.
421
+ def move_step(name, position)
422
+ raise ArgumentError, "Step '#{name}' not found in context '#{@name}'" unless @steps.key?(name)
423
+
424
+ @step_order.delete(name)
425
+ @step_order.insert(position, name)
426
+ self
427
+ end
428
+
429
+ # Set which step the context starts on when entered.
430
+ #
431
+ # By default, a context starts on its first step (index 0). Use
432
+ # this to skip a preamble step on re-entry via +change_context+.
433
+ #
434
+ # @param step_name [String] name of the step to start on.
435
+ def set_initial_step(step_name)
436
+ @initial_step = step_name
437
+ self
438
+ end
439
+
440
+ def set_valid_contexts(contexts)
441
+ @valid_contexts = contexts
442
+ self
443
+ end
444
+
445
+ def set_valid_steps(steps)
446
+ @valid_steps = steps
447
+ self
448
+ end
449
+
450
+ def set_post_prompt(prompt)
451
+ @post_prompt = prompt
452
+ self
453
+ end
454
+
455
+ def set_system_prompt(prompt)
456
+ raise ArgumentError, "Cannot use set_system_prompt when POM system sections exist" if @system_prompt_sections.any?
457
+
458
+ @system_prompt = prompt
459
+ self
460
+ end
461
+
462
+ def set_prompt(prompt)
463
+ raise ArgumentError, "Cannot use set_prompt when POM prompt sections exist" if @prompt_sections.any?
464
+
465
+ @prompt_text = prompt
466
+ self
467
+ end
468
+
469
+ def set_consolidate(val)
470
+ @consolidate = val
471
+ self
472
+ end
473
+
474
+ def set_full_reset(val)
475
+ @full_reset = val
476
+ self
477
+ end
478
+
479
+ def set_user_prompt(prompt)
480
+ @user_prompt = prompt
481
+ self
482
+ end
483
+
484
+ # Mark this context as isolated — entering it wipes conversation
485
+ # history.
486
+ #
487
+ # When +val+ = true and the context is entered via change_context,
488
+ # the runtime wipes the conversation array. The model starts
489
+ # fresh with only the new context's system_prompt + step
490
+ # instructions, with no memory of prior turns.
491
+ #
492
+ # EXCEPTION — reset overrides the wipe:
493
+ # If the context also has a reset configuration (via
494
+ # +set_consolidate+ or +set_full_reset+), the wipe is skipped in
495
+ # favor of the reset behavior. Use reset with consolidate=true
496
+ # to summarize prior history into a single message instead of
497
+ # dropping it entirely.
498
+ #
499
+ # Use cases: switching to a sensitive billing flow that should
500
+ # not see prior small-talk; handing off to a different agent
501
+ # persona; resetting after a long off-topic detour.
502
+ def set_isolated(val)
503
+ @isolated = val
504
+ self
505
+ end
506
+
507
+ # Add a POM section to the context prompt.
508
+ def add_section(title, body)
509
+ raise ArgumentError, "Cannot add POM sections when set_prompt has been used" unless @prompt_text.nil?
510
+
511
+ @prompt_sections << { "title" => title, "body" => body }
512
+ self
513
+ end
514
+
515
+ # Add a POM section with bullets to the context prompt.
516
+ def add_bullets(title, bullets)
517
+ raise ArgumentError, "Cannot add POM sections when set_prompt has been used" unless @prompt_text.nil?
518
+
519
+ @prompt_sections << { "title" => title, "bullets" => bullets }
520
+ self
521
+ end
522
+
523
+ # Add a POM section to the system prompt.
524
+ def add_system_section(title, body)
525
+ raise ArgumentError, "Cannot add POM system sections when set_system_prompt has been used" unless @system_prompt.nil?
526
+
527
+ @system_prompt_sections << { "title" => title, "body" => body }
528
+ self
529
+ end
530
+
531
+ # Add a POM section with bullets to the system prompt.
532
+ def add_system_bullets(title, bullets)
533
+ raise ArgumentError, "Cannot add POM system sections when set_system_prompt has been used" unless @system_prompt.nil?
534
+
535
+ @system_prompt_sections << { "title" => title, "bullets" => bullets }
536
+ self
537
+ end
538
+
539
+ def set_enter_fillers(fillers)
540
+ @enter_fillers = fillers if fillers.is_a?(Hash) && fillers.any?
541
+ self
542
+ end
543
+
544
+ def set_exit_fillers(fillers)
545
+ @exit_fillers = fillers if fillers.is_a?(Hash) && fillers.any?
546
+ self
547
+ end
548
+
549
+ def add_enter_filler(lang_code, fillers)
550
+ if lang_code && fillers.is_a?(Array) && fillers.any?
551
+ @enter_fillers ||= {}
552
+ @enter_fillers[lang_code] = fillers
553
+ end
554
+ self
555
+ end
556
+
557
+ def add_exit_filler(lang_code, fillers)
558
+ if lang_code && fillers.is_a?(Array) && fillers.any?
559
+ @exit_fillers ||= {}
560
+ @exit_fillers[lang_code] = fillers
561
+ end
562
+ self
563
+ end
564
+
565
+ def to_h
566
+ raise ArgumentError, "Context '#{@name}' has no steps defined" if @steps.empty?
567
+
568
+ ctx = {
569
+ "steps" => @step_order.map { |n| @steps[n].to_h }
570
+ }
571
+
572
+ ctx["valid_contexts"] = @valid_contexts if @valid_contexts
573
+ ctx["valid_steps"] = @valid_steps if @valid_steps
574
+ ctx["initial_step"] = @initial_step if @initial_step
575
+ ctx["post_prompt"] = @post_prompt if @post_prompt
576
+
577
+ sys = render_system_prompt
578
+ ctx["system_prompt"] = sys if sys
579
+
580
+ ctx["consolidate"] = @consolidate if @consolidate
581
+ ctx["full_reset"] = @full_reset if @full_reset
582
+ ctx["user_prompt"] = @user_prompt if @user_prompt
583
+ ctx["isolated"] = @isolated if @isolated
584
+
585
+ if @prompt_sections.any?
586
+ ctx["pom"] = @prompt_sections
587
+ elsif @prompt_text
588
+ ctx["prompt"] = @prompt_text
589
+ end
590
+
591
+ ctx["enter_fillers"] = @enter_fillers if @enter_fillers
592
+ ctx["exit_fillers"] = @exit_fillers if @exit_fillers
593
+
594
+ ctx
595
+ end
596
+
597
+ # Expose internal state for validation
598
+ # @api private
599
+ def _steps; @steps; end
600
+ def _step_order; @step_order; end
601
+ def _initial_step; @initial_step; end
602
+
603
+ private
604
+
605
+ def render_system_prompt
606
+ return @system_prompt if @system_prompt
607
+ return nil if @system_prompt_sections.empty?
608
+
609
+ render_sections(@system_prompt_sections)
610
+ end
611
+
612
+ def render_sections(sections)
613
+ parts = []
614
+ sections.each do |s|
615
+ if s.key?("bullets")
616
+ parts << "## #{s['title']}"
617
+ s["bullets"].each { |b| parts << "- #{b}" }
618
+ else
619
+ parts << "## #{s['title']}"
620
+ parts << s["body"]
621
+ end
622
+ parts << ""
623
+ end
624
+ parts.join("\n").strip
625
+ end
626
+ end
627
+
628
+ # Builder for multi-step, multi-context AI agent workflows.
629
+ #
630
+ # A ContextBuilder owns one or more Contexts; each Context owns an
631
+ # ordered list of Steps. Only one context and one step is active at
632
+ # a time. Per chat turn, the runtime injects the current step's
633
+ # instructions as a system message, then asks the LLM for a
634
+ # response.
635
+ #
636
+ # == Native tools auto-injected by the runtime
637
+ #
638
+ # When a step (or its enclosing context) declares +valid_steps+ or
639
+ # +valid_contexts+, the runtime auto-injects two native tools so
640
+ # the model can navigate the flow:
641
+ #
642
+ # - +next_step(step: enum)+ — present when valid_steps is set
643
+ # - +change_context(context: enum)+ — present when valid_contexts is set
644
+ #
645
+ # Their +enum+ schemas are rewritten on every turn to match
646
+ # whatever valid_steps / valid_contexts apply to the current step.
647
+ # You do NOT need to define these tools yourself; they appear
648
+ # automatically.
649
+ #
650
+ # A third native tool — +gather_submit+ — is injected during
651
+ # gather_info questioning (see Step#set_gather_info /
652
+ # Step#add_gather_question).
653
+ #
654
+ # These three names — +next_step+, +change_context+,
655
+ # +gather_submit+ — are reserved. +validate!+ will reject any agent
656
+ # that defines a SWAIG tool with one of these names. See
657
+ # RESERVED_NATIVE_TOOL_NAMES.
658
+ #
659
+ # == Function whitelisting (Step#set_functions)
660
+ #
661
+ # Each step may declare a +functions+ whitelist. The whitelist is
662
+ # applied in-memory at the start of each LLM turn. CRITICALLY: if a
663
+ # step does NOT declare a +functions+ field, it INHERITS the
664
+ # previous step's active set. See Step#set_functions for details
665
+ # and examples.
666
+ class ContextBuilder
667
+ # Python parity: ``ContextBuilder.__init__(self, agent)`` accepts
668
+ # an owning agent so ``validate!`` can introspect registered
669
+ # SWAIG tools when checking for reserved-name collisions.
670
+ # Ruby allows nil for standalone use (tests, idiom of building
671
+ # a builder before attaching).
672
+ def initialize(agent = nil)
673
+ @contexts = {} # name => Context
674
+ @context_order = []
675
+ @agent = agent
676
+ end
677
+
678
+ # Attach an agent reference so +validate!+ can check
679
+ # user-defined tool names against RESERVED_NATIVE_TOOL_NAMES.
680
+ # Called internally by AgentBase#define_contexts.
681
+ def attach_agent(agent)
682
+ @agent = agent
683
+ self
684
+ end
685
+
686
+ # Remove all contexts, returning the builder to its initial state.
687
+ # Use this in a dynamic config callback when you need to rebuild
688
+ # contexts from scratch for a specific request.
689
+ def reset
690
+ @contexts.clear
691
+ @context_order.clear
692
+ self
693
+ end
694
+
695
+ # Add a new context. Returns the Context object.
696
+ def add_context(name)
697
+ raise ArgumentError, "Context '#{name}' already exists" if @contexts.key?(name)
698
+ raise ArgumentError, "Maximum number of contexts (#{MAX_CONTEXTS}) exceeded" if @contexts.size >= MAX_CONTEXTS
699
+
700
+ ctx = Context.new(name)
701
+ @contexts[name] = ctx
702
+ @context_order << name
703
+ ctx
704
+ end
705
+
706
+ # Get an existing context by name. Returns Context or nil.
707
+ def get_context(name)
708
+ @contexts[name]
709
+ end
710
+
711
+ # Validate the full configuration. Raises ArgumentError on problems.
712
+ def validate!
713
+ raise ArgumentError, "At least one context must be defined" if @contexts.empty?
714
+
715
+ # Single context must be named "default"
716
+ if @contexts.size == 1
717
+ ctx_name = @contexts.keys.first
718
+ raise ArgumentError, "When using a single context, it must be named 'default'" if ctx_name != 'default'
719
+ end
720
+
721
+ # Each context must have at least one step
722
+ @contexts.each do |ctx_name, ctx|
723
+ raise ArgumentError, "Context '#{ctx_name}' must have at least one step" if ctx._steps.empty?
724
+ end
725
+
726
+ # Validate initial_step references a real step in the context
727
+ @contexts.each do |ctx_name, ctx|
728
+ is = ctx._initial_step
729
+ if is && !ctx._steps.key?(is)
730
+ available = ctx._steps.keys.sort
731
+ raise ArgumentError,
732
+ "Context '#{ctx_name}' has initial_step='#{is}' but that step does " \
733
+ "not exist. Available steps: #{available.inspect}"
734
+ end
735
+ end
736
+
737
+ # Validate step references in valid_steps
738
+ @contexts.each do |ctx_name, ctx|
739
+ ctx._steps.each do |step_name, step|
740
+ step_h = step.to_h
741
+ if step_h["valid_steps"]
742
+ step_h["valid_steps"].each do |vs|
743
+ next if vs == "next"
744
+ unless ctx._steps.key?(vs)
745
+ raise ArgumentError,
746
+ "Step '#{step_name}' in context '#{ctx_name}' references unknown step '#{vs}'"
747
+ end
748
+ end
749
+ end
750
+ end
751
+ end
752
+
753
+ # Validate context references at context level
754
+ @contexts.each do |ctx_name, ctx|
755
+ ctx_h = ctx.to_h
756
+ if ctx_h["valid_contexts"]
757
+ ctx_h["valid_contexts"].each do |vc|
758
+ unless @contexts.key?(vc)
759
+ raise ArgumentError,
760
+ "Context '#{ctx_name}' references unknown context '#{vc}'"
761
+ end
762
+ end
763
+ end
764
+ end
765
+
766
+ # Validate context references at step level
767
+ @contexts.each do |ctx_name, ctx|
768
+ ctx._steps.each do |step_name, step|
769
+ step_h = step.to_h
770
+ if step_h["valid_contexts"]
771
+ step_h["valid_contexts"].each do |vc|
772
+ unless @contexts.key?(vc)
773
+ raise ArgumentError,
774
+ "Step '#{step_name}' in context '#{ctx_name}' references unknown context '#{vc}'"
775
+ end
776
+ end
777
+ end
778
+ end
779
+ end
780
+
781
+ # Validate gather_info configurations
782
+ @contexts.each do |ctx_name, ctx|
783
+ ctx._steps.each do |step_name, step|
784
+ step_h = step.to_h
785
+ next unless step_h.key?("gather_info")
786
+
787
+ gi = step_h["gather_info"]
788
+ questions = gi["questions"] || []
789
+ raise ArgumentError,
790
+ "Step '#{step_name}' in context '#{ctx_name}' has gather_info with no questions" if questions.empty?
791
+
792
+ keys_seen = Set.new
793
+ questions.each do |q|
794
+ raise ArgumentError,
795
+ "Step '#{step_name}' in context '#{ctx_name}' has duplicate gather_info question key '#{q['key']}'" if keys_seen.include?(q["key"])
796
+ keys_seen << q["key"]
797
+ end
798
+
799
+ action = gi["completion_action"]
800
+ if action
801
+ if action == "next_step"
802
+ idx = ctx._step_order.index(step_name)
803
+ if idx >= ctx._step_order.size - 1
804
+ raise ArgumentError,
805
+ "Step '#{step_name}' in context '#{ctx_name}' has gather_info " \
806
+ "completion_action='next_step' but it is the last step in the " \
807
+ "context. Either (1) add another step after '#{step_name}', " \
808
+ "(2) set completion_action to the name of an existing step in " \
809
+ "this context to jump to it, or (3) set completion_action=nil " \
810
+ "(default) to stay in '#{step_name}' after gathering completes."
811
+ end
812
+ elsif !ctx._steps.key?(action)
813
+ available = ctx._steps.keys.sort
814
+ raise ArgumentError,
815
+ "Step '#{step_name}' in context '#{ctx_name}' has gather_info " \
816
+ "completion_action='#{action}' but '#{action}' is not a step in " \
817
+ "this context. Valid options: 'next_step' (advance to the next " \
818
+ "sequential step), nil (stay in the current step), or one of " \
819
+ "#{available.inspect}."
820
+ end
821
+ end
822
+ end
823
+ end
824
+
825
+ # Validate that user-defined tools do not collide with reserved
826
+ # native tool names. The runtime auto-injects next_step /
827
+ # change_context / gather_submit when contexts/steps are
828
+ # present, so user tools sharing those names would never be
829
+ # called.
830
+ if @agent && @agent.respond_to?(:list_tool_names)
831
+ registered = @agent.list_tool_names.to_a
832
+ colliding = registered.select { |n| RESERVED_NATIVE_TOOL_NAMES.include?(n) }.sort.uniq
833
+ if colliding.any?
834
+ raise ArgumentError,
835
+ "Tool name(s) #{colliding.inspect} collide with reserved native " \
836
+ "tools auto-injected by contexts/steps. The names " \
837
+ "#{RESERVED_NATIVE_TOOL_NAMES.sort.inspect} are reserved and " \
838
+ "cannot be used for user-defined SWAIG tools when contexts/steps " \
839
+ "are in use. Rename your tool(s) to avoid the collision."
840
+ end
841
+ end
842
+
843
+ true
844
+ end
845
+
846
+ def to_h
847
+ validate!
848
+ result = {}
849
+ @context_order.each do |name|
850
+ result[name] = @contexts[name].to_h
851
+ end
852
+ result
853
+ end
854
+ end
855
+
856
+ # Helper to create a standalone context (not via ContextBuilder).
857
+ def self.create_simple_context(name = 'default')
858
+ Context.new(name)
859
+ end
860
+ end
861
+ end