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.
Files changed (72) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +79 -0
  3. data/bin/swaig-test +310 -0
  4. data/lib/signalwire_agents/agent/agent_base.rb +1171 -0
  5. data/lib/signalwire_agents/contexts/context_builder.rb +622 -0
  6. data/lib/signalwire_agents/datamap/data_map.rb +279 -0
  7. data/lib/signalwire_agents/logging.rb +92 -0
  8. data/lib/signalwire_agents/prefabs/concierge.rb +92 -0
  9. data/lib/signalwire_agents/prefabs/faq_bot.rb +67 -0
  10. data/lib/signalwire_agents/prefabs/info_gatherer.rb +79 -0
  11. data/lib/signalwire_agents/prefabs/receptionist.rb +74 -0
  12. data/lib/signalwire_agents/prefabs/survey.rb +75 -0
  13. data/lib/signalwire_agents/relay/action.rb +291 -0
  14. data/lib/signalwire_agents/relay/call.rb +523 -0
  15. data/lib/signalwire_agents/relay/client.rb +671 -0
  16. data/lib/signalwire_agents/relay/constants.rb +124 -0
  17. data/lib/signalwire_agents/relay/message.rb +137 -0
  18. data/lib/signalwire_agents/relay/relay_event.rb +670 -0
  19. data/lib/signalwire_agents/rest/http_client.rb +147 -0
  20. data/lib/signalwire_agents/rest/namespaces/addresses.rb +19 -0
  21. data/lib/signalwire_agents/rest/namespaces/calling.rb +179 -0
  22. data/lib/signalwire_agents/rest/namespaces/chat.rb +18 -0
  23. data/lib/signalwire_agents/rest/namespaces/compat.rb +229 -0
  24. data/lib/signalwire_agents/rest/namespaces/datasphere.rb +39 -0
  25. data/lib/signalwire_agents/rest/namespaces/fabric.rb +175 -0
  26. data/lib/signalwire_agents/rest/namespaces/imported_numbers.rb +18 -0
  27. data/lib/signalwire_agents/rest/namespaces/logs.rb +46 -0
  28. data/lib/signalwire_agents/rest/namespaces/lookup.rb +18 -0
  29. data/lib/signalwire_agents/rest/namespaces/mfa.rb +26 -0
  30. data/lib/signalwire_agents/rest/namespaces/number_groups.rb +32 -0
  31. data/lib/signalwire_agents/rest/namespaces/phone_numbers.rb +20 -0
  32. data/lib/signalwire_agents/rest/namespaces/project.rb +33 -0
  33. data/lib/signalwire_agents/rest/namespaces/pubsub.rb +18 -0
  34. data/lib/signalwire_agents/rest/namespaces/queues.rb +28 -0
  35. data/lib/signalwire_agents/rest/namespaces/recordings.rb +18 -0
  36. data/lib/signalwire_agents/rest/namespaces/registry.rb +67 -0
  37. data/lib/signalwire_agents/rest/namespaces/short_codes.rb +26 -0
  38. data/lib/signalwire_agents/rest/namespaces/sip_profile.rb +22 -0
  39. data/lib/signalwire_agents/rest/namespaces/verified_callers.rb +24 -0
  40. data/lib/signalwire_agents/rest/namespaces/video.rb +129 -0
  41. data/lib/signalwire_agents/rest/signalwire_client.rb +110 -0
  42. data/lib/signalwire_agents/security/session_manager.rb +124 -0
  43. data/lib/signalwire_agents/server/agent_server.rb +260 -0
  44. data/lib/signalwire_agents/skills/builtin/api_ninjas_trivia.rb +91 -0
  45. data/lib/signalwire_agents/skills/builtin/claude_skills.rb +92 -0
  46. data/lib/signalwire_agents/skills/builtin/custom_skills.rb +54 -0
  47. data/lib/signalwire_agents/skills/builtin/datasphere.rb +141 -0
  48. data/lib/signalwire_agents/skills/builtin/datasphere_serverless.rb +107 -0
  49. data/lib/signalwire_agents/skills/builtin/datetime.rb +97 -0
  50. data/lib/signalwire_agents/skills/builtin/google_maps.rb +168 -0
  51. data/lib/signalwire_agents/skills/builtin/info_gatherer.rb +189 -0
  52. data/lib/signalwire_agents/skills/builtin/joke.rb +65 -0
  53. data/lib/signalwire_agents/skills/builtin/math.rb +176 -0
  54. data/lib/signalwire_agents/skills/builtin/mcp_gateway.rb +121 -0
  55. data/lib/signalwire_agents/skills/builtin/native_vector_search.rb +116 -0
  56. data/lib/signalwire_agents/skills/builtin/play_background_file.rb +86 -0
  57. data/lib/signalwire_agents/skills/builtin/spider.rb +142 -0
  58. data/lib/signalwire_agents/skills/builtin/swml_transfer.rb +118 -0
  59. data/lib/signalwire_agents/skills/builtin/weather_api.rb +85 -0
  60. data/lib/signalwire_agents/skills/builtin/web_search.rb +123 -0
  61. data/lib/signalwire_agents/skills/builtin/wikipedia_search.rb +109 -0
  62. data/lib/signalwire_agents/skills/skill_base.rb +58 -0
  63. data/lib/signalwire_agents/skills/skill_manager.rb +85 -0
  64. data/lib/signalwire_agents/skills/skill_registry.rb +76 -0
  65. data/lib/signalwire_agents/swaig/function_result.rb +777 -0
  66. data/lib/signalwire_agents/swml/document.rb +84 -0
  67. data/lib/signalwire_agents/swml/schema.json +12250 -0
  68. data/lib/signalwire_agents/swml/schema.rb +81 -0
  69. data/lib/signalwire_agents/swml/service.rb +304 -0
  70. data/lib/signalwire_agents/version.rb +5 -0
  71. data/lib/signalwire_agents.rb +19 -0
  72. metadata +212 -0
@@ -0,0 +1,622 @@
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 SignalWireAgents
9
+ module Contexts
10
+ MAX_CONTEXTS = 50
11
+ MAX_STEPS_PER_CONTEXT = 100
12
+
13
+ # Represents a single question in a gather_info configuration.
14
+ class GatherQuestion
15
+ attr_accessor :key, :question, :type, :confirm, :prompt, :functions
16
+
17
+ def initialize(key:, question:, type: 'string', confirm: false, prompt: nil, functions: nil)
18
+ @key = key
19
+ @question = question
20
+ @type = type
21
+ @confirm = confirm
22
+ @prompt = prompt
23
+ @functions = functions
24
+ end
25
+
26
+ def to_h
27
+ h = { "key" => @key, "question" => @question }
28
+ h["type"] = @type if @type != 'string'
29
+ h["confirm"] = true if @confirm
30
+ h["prompt"] = @prompt if @prompt
31
+ h["functions"] = @functions if @functions
32
+ h
33
+ end
34
+ end
35
+
36
+ # Configuration for gathering information in a step via the C-side gather_info system.
37
+ class GatherInfo
38
+ attr_accessor :output_key, :completion_action, :prompt
39
+ attr_reader :questions
40
+
41
+ def initialize(output_key: nil, completion_action: nil, prompt: nil)
42
+ @output_key = output_key
43
+ @completion_action = completion_action
44
+ @prompt = prompt
45
+ @questions = []
46
+ end
47
+
48
+ # Add a question. Returns +self+ for chaining.
49
+ def add_question(key:, question:, **opts)
50
+ @questions << GatherQuestion.new(
51
+ key: key,
52
+ question: question,
53
+ type: opts.fetch(:type, 'string'),
54
+ confirm: opts.fetch(:confirm, false),
55
+ prompt: opts[:prompt],
56
+ functions: opts[:functions]
57
+ )
58
+ self
59
+ end
60
+
61
+ def to_h
62
+ raise ArgumentError, "gather_info must have at least one question" if @questions.empty?
63
+
64
+ h = { "questions" => @questions.map(&:to_h) }
65
+ h["prompt"] = @prompt if @prompt
66
+ h["output_key"] = @output_key if @output_key
67
+ h["completion_action"] = @completion_action if @completion_action
68
+ h
69
+ end
70
+ end
71
+
72
+ # Represents a single step within a context.
73
+ #
74
+ # All mutator methods return +self+ for fluent chaining.
75
+ class Step
76
+ attr_reader :name
77
+
78
+ def initialize(name)
79
+ @name = name
80
+ @text = nil
81
+ @step_criteria = nil
82
+ @functions = nil # nil | "none" | Array<String>
83
+ @valid_steps = nil
84
+ @valid_contexts = nil
85
+ @sections = []
86
+ @gather_info = nil
87
+
88
+ # Behavior flags
89
+ @end = false
90
+ @skip_user_turn = false
91
+ @skip_to_next_step = false
92
+
93
+ # Reset object for context-switching from steps
94
+ @reset_system_prompt = nil
95
+ @reset_user_prompt = nil
96
+ @reset_consolidate = false
97
+ @reset_full_reset = false
98
+ end
99
+
100
+ # Set the step's prompt text directly. Mutually exclusive with POM sections.
101
+ def set_text(text)
102
+ raise ArgumentError, "Cannot use set_text when POM sections have been added" if @sections.any?
103
+
104
+ @text = text
105
+ self
106
+ end
107
+
108
+ # Add a POM section (title + body). Mutually exclusive with +set_text+.
109
+ def add_section(title, body)
110
+ raise ArgumentError, "Cannot add POM sections when set_text has been used" unless @text.nil?
111
+
112
+ @sections << { "title" => title, "body" => body }
113
+ self
114
+ end
115
+
116
+ # Add a POM section with bullet points. Mutually exclusive with +set_text+.
117
+ def add_bullets(title, bullets)
118
+ raise ArgumentError, "Cannot add POM sections when set_text has been used" unless @text.nil?
119
+
120
+ @sections << { "title" => title, "bullets" => bullets }
121
+ self
122
+ end
123
+
124
+ def set_step_criteria(criteria)
125
+ @step_criteria = criteria
126
+ self
127
+ end
128
+
129
+ # @param functions [String, Array<String>] "none" to disable all, or list of names
130
+ def set_functions(functions)
131
+ @functions = functions
132
+ self
133
+ end
134
+
135
+ def set_valid_steps(steps)
136
+ @valid_steps = steps
137
+ self
138
+ end
139
+
140
+ def set_valid_contexts(contexts)
141
+ @valid_contexts = contexts
142
+ self
143
+ end
144
+
145
+ def set_end(is_end)
146
+ @end = is_end
147
+ self
148
+ end
149
+
150
+ def set_skip_user_turn(skip)
151
+ @skip_user_turn = skip
152
+ self
153
+ end
154
+
155
+ def set_skip_to_next_step(skip)
156
+ @skip_to_next_step = skip
157
+ self
158
+ end
159
+
160
+ # Enable info gathering for this step. Returns +self+.
161
+ # After calling this, use +add_gather_question+ to define questions.
162
+ def set_gather_info(output_key: nil, completion_action: nil, prompt: nil)
163
+ @gather_info = GatherInfo.new(
164
+ output_key: output_key,
165
+ completion_action: completion_action,
166
+ prompt: prompt
167
+ )
168
+ self
169
+ end
170
+
171
+ # Add a question to this step's gather_info configuration.
172
+ # +set_gather_info+ must be called first.
173
+ def add_gather_question(key:, question:, **opts)
174
+ raise ArgumentError, "Must call set_gather_info before add_gather_question" if @gather_info.nil?
175
+
176
+ @gather_info.add_question(key: key, question: question, **opts)
177
+ self
178
+ end
179
+
180
+ # Remove all POM sections and direct text.
181
+ def clear_sections
182
+ @sections = []
183
+ @text = nil
184
+ self
185
+ end
186
+
187
+ def set_reset_system_prompt(prompt)
188
+ @reset_system_prompt = prompt
189
+ self
190
+ end
191
+
192
+ def set_reset_user_prompt(prompt)
193
+ @reset_user_prompt = prompt
194
+ self
195
+ end
196
+
197
+ def set_reset_consolidate(val)
198
+ @reset_consolidate = val
199
+ self
200
+ end
201
+
202
+ def set_reset_full_reset(val)
203
+ @reset_full_reset = val
204
+ self
205
+ end
206
+
207
+ def to_h
208
+ step_h = {
209
+ "name" => @name,
210
+ "text" => render_text
211
+ }
212
+
213
+ step_h["step_criteria"] = @step_criteria if @step_criteria
214
+ step_h["functions"] = @functions unless @functions.nil?
215
+ step_h["valid_steps"] = @valid_steps if @valid_steps
216
+ step_h["valid_contexts"] = @valid_contexts if @valid_contexts
217
+ step_h["end"] = true if @end
218
+ step_h["skip_user_turn"] = true if @skip_user_turn
219
+ step_h["skip_to_next_step"] = true if @skip_to_next_step
220
+
221
+ reset = {}
222
+ reset["system_prompt"] = @reset_system_prompt if @reset_system_prompt
223
+ reset["user_prompt"] = @reset_user_prompt if @reset_user_prompt
224
+ reset["consolidate"] = @reset_consolidate if @reset_consolidate
225
+ reset["full_reset"] = @reset_full_reset if @reset_full_reset
226
+ step_h["reset"] = reset if reset.any?
227
+
228
+ step_h["gather_info"] = @gather_info.to_h if @gather_info
229
+
230
+ step_h
231
+ end
232
+
233
+ private
234
+
235
+ def render_text
236
+ return @text if @text
237
+
238
+ raise ArgumentError, "Step '#{@name}' has no text or POM sections defined" if @sections.empty?
239
+
240
+ parts = []
241
+ @sections.each do |section|
242
+ if section.key?("bullets")
243
+ parts << "## #{section['title']}"
244
+ section["bullets"].each { |b| parts << "- #{b}" }
245
+ else
246
+ parts << "## #{section['title']}"
247
+ parts << section["body"]
248
+ end
249
+ parts << "" # spacing
250
+ end
251
+ parts.join("\n").strip
252
+ end
253
+ end
254
+
255
+ # Represents a single context containing multiple steps.
256
+ class Context
257
+ attr_reader :name
258
+
259
+ def initialize(name)
260
+ @name = name
261
+ @steps = {} # name => Step
262
+ @step_order = []
263
+
264
+ # Navigation
265
+ @valid_contexts = nil
266
+ @valid_steps = nil
267
+
268
+ # Context entry parameters
269
+ @post_prompt = nil
270
+ @system_prompt = nil
271
+ @system_prompt_sections = []
272
+ @consolidate = false
273
+ @full_reset = false
274
+ @user_prompt = nil
275
+ @isolated = false
276
+
277
+ # Context prompt
278
+ @prompt_text = nil
279
+ @prompt_sections = []
280
+
281
+ # Fillers
282
+ @enter_fillers = nil
283
+ @exit_fillers = nil
284
+ end
285
+
286
+ # Add a new step. Returns the new Step object (not self).
287
+ def add_step(name)
288
+ raise ArgumentError, "Step '#{name}' already exists in context '#{@name}'" if @steps.key?(name)
289
+ raise ArgumentError, "Maximum steps per context (#{MAX_STEPS_PER_CONTEXT}) exceeded" if @steps.size >= MAX_STEPS_PER_CONTEXT
290
+
291
+ step = Step.new(name)
292
+ @steps[name] = step
293
+ @step_order << name
294
+ step
295
+ end
296
+
297
+ # Get an existing step by name. Returns Step or nil.
298
+ def get_step(name)
299
+ @steps[name]
300
+ end
301
+
302
+ # Remove a step by name. Returns self.
303
+ def remove_step(name)
304
+ if @steps.key?(name)
305
+ @steps.delete(name)
306
+ @step_order.delete(name)
307
+ end
308
+ self
309
+ end
310
+
311
+ # Move an existing step to a specific position. Returns self.
312
+ def move_step(name, position)
313
+ raise ArgumentError, "Step '#{name}' not found in context '#{@name}'" unless @steps.key?(name)
314
+
315
+ @step_order.delete(name)
316
+ @step_order.insert(position, name)
317
+ self
318
+ end
319
+
320
+ def set_valid_contexts(contexts)
321
+ @valid_contexts = contexts
322
+ self
323
+ end
324
+
325
+ def set_valid_steps(steps)
326
+ @valid_steps = steps
327
+ self
328
+ end
329
+
330
+ def set_post_prompt(prompt)
331
+ @post_prompt = prompt
332
+ self
333
+ end
334
+
335
+ def set_system_prompt(prompt)
336
+ raise ArgumentError, "Cannot use set_system_prompt when POM system sections exist" if @system_prompt_sections.any?
337
+
338
+ @system_prompt = prompt
339
+ self
340
+ end
341
+
342
+ def set_prompt(prompt)
343
+ raise ArgumentError, "Cannot use set_prompt when POM prompt sections exist" if @prompt_sections.any?
344
+
345
+ @prompt_text = prompt
346
+ self
347
+ end
348
+
349
+ def set_consolidate(val)
350
+ @consolidate = val
351
+ self
352
+ end
353
+
354
+ def set_full_reset(val)
355
+ @full_reset = val
356
+ self
357
+ end
358
+
359
+ def set_user_prompt(prompt)
360
+ @user_prompt = prompt
361
+ self
362
+ end
363
+
364
+ def set_isolated(val)
365
+ @isolated = val
366
+ self
367
+ end
368
+
369
+ # Add a POM section to the context prompt.
370
+ def add_section(title, body)
371
+ raise ArgumentError, "Cannot add POM sections when set_prompt has been used" unless @prompt_text.nil?
372
+
373
+ @prompt_sections << { "title" => title, "body" => body }
374
+ self
375
+ end
376
+
377
+ # Add a POM section with bullets to the context prompt.
378
+ def add_bullets(title, bullets)
379
+ raise ArgumentError, "Cannot add POM sections when set_prompt has been used" unless @prompt_text.nil?
380
+
381
+ @prompt_sections << { "title" => title, "bullets" => bullets }
382
+ self
383
+ end
384
+
385
+ # Add a POM section to the system prompt.
386
+ def add_system_section(title, body)
387
+ raise ArgumentError, "Cannot add POM system sections when set_system_prompt has been used" unless @system_prompt.nil?
388
+
389
+ @system_prompt_sections << { "title" => title, "body" => body }
390
+ self
391
+ end
392
+
393
+ # Add a POM section with bullets to the system prompt.
394
+ def add_system_bullets(title, bullets)
395
+ raise ArgumentError, "Cannot add POM system sections when set_system_prompt has been used" unless @system_prompt.nil?
396
+
397
+ @system_prompt_sections << { "title" => title, "bullets" => bullets }
398
+ self
399
+ end
400
+
401
+ def set_enter_fillers(fillers)
402
+ @enter_fillers = fillers if fillers.is_a?(Hash) && fillers.any?
403
+ self
404
+ end
405
+
406
+ def set_exit_fillers(fillers)
407
+ @exit_fillers = fillers if fillers.is_a?(Hash) && fillers.any?
408
+ self
409
+ end
410
+
411
+ def add_enter_filler(lang_code, fillers)
412
+ if lang_code && fillers.is_a?(Array) && fillers.any?
413
+ @enter_fillers ||= {}
414
+ @enter_fillers[lang_code] = fillers
415
+ end
416
+ self
417
+ end
418
+
419
+ def add_exit_filler(lang_code, fillers)
420
+ if lang_code && fillers.is_a?(Array) && fillers.any?
421
+ @exit_fillers ||= {}
422
+ @exit_fillers[lang_code] = fillers
423
+ end
424
+ self
425
+ end
426
+
427
+ def to_h
428
+ raise ArgumentError, "Context '#{@name}' has no steps defined" if @steps.empty?
429
+
430
+ ctx = {
431
+ "steps" => @step_order.map { |n| @steps[n].to_h }
432
+ }
433
+
434
+ ctx["valid_contexts"] = @valid_contexts if @valid_contexts
435
+ ctx["valid_steps"] = @valid_steps if @valid_steps
436
+ ctx["post_prompt"] = @post_prompt if @post_prompt
437
+
438
+ sys = render_system_prompt
439
+ ctx["system_prompt"] = sys if sys
440
+
441
+ ctx["consolidate"] = @consolidate if @consolidate
442
+ ctx["full_reset"] = @full_reset if @full_reset
443
+ ctx["user_prompt"] = @user_prompt if @user_prompt
444
+ ctx["isolated"] = @isolated if @isolated
445
+
446
+ if @prompt_sections.any?
447
+ ctx["pom"] = @prompt_sections
448
+ elsif @prompt_text
449
+ ctx["prompt"] = @prompt_text
450
+ end
451
+
452
+ ctx["enter_fillers"] = @enter_fillers if @enter_fillers
453
+ ctx["exit_fillers"] = @exit_fillers if @exit_fillers
454
+
455
+ ctx
456
+ end
457
+
458
+ # Expose internal state for validation
459
+ # @api private
460
+ def _steps; @steps; end
461
+ def _step_order; @step_order; end
462
+
463
+ private
464
+
465
+ def render_system_prompt
466
+ return @system_prompt if @system_prompt
467
+ return nil if @system_prompt_sections.empty?
468
+
469
+ render_sections(@system_prompt_sections)
470
+ end
471
+
472
+ def render_sections(sections)
473
+ parts = []
474
+ sections.each do |s|
475
+ if s.key?("bullets")
476
+ parts << "## #{s['title']}"
477
+ s["bullets"].each { |b| parts << "- #{b}" }
478
+ else
479
+ parts << "## #{s['title']}"
480
+ parts << s["body"]
481
+ end
482
+ parts << ""
483
+ end
484
+ parts.join("\n").strip
485
+ end
486
+ end
487
+
488
+ # Main builder that holds multiple contexts and validates the configuration.
489
+ class ContextBuilder
490
+ def initialize
491
+ @contexts = {} # name => Context
492
+ @context_order = []
493
+ end
494
+
495
+ # Add a new context. Returns the Context object.
496
+ def add_context(name)
497
+ raise ArgumentError, "Context '#{name}' already exists" if @contexts.key?(name)
498
+ raise ArgumentError, "Maximum number of contexts (#{MAX_CONTEXTS}) exceeded" if @contexts.size >= MAX_CONTEXTS
499
+
500
+ ctx = Context.new(name)
501
+ @contexts[name] = ctx
502
+ @context_order << name
503
+ ctx
504
+ end
505
+
506
+ # Get an existing context by name. Returns Context or nil.
507
+ def get_context(name)
508
+ @contexts[name]
509
+ end
510
+
511
+ # Validate the full configuration. Raises ArgumentError on problems.
512
+ def validate!
513
+ raise ArgumentError, "At least one context must be defined" if @contexts.empty?
514
+
515
+ # Single context must be named "default"
516
+ if @contexts.size == 1
517
+ ctx_name = @contexts.keys.first
518
+ raise ArgumentError, "When using a single context, it must be named 'default'" if ctx_name != 'default'
519
+ end
520
+
521
+ # Each context must have at least one step
522
+ @contexts.each do |ctx_name, ctx|
523
+ raise ArgumentError, "Context '#{ctx_name}' must have at least one step" if ctx._steps.empty?
524
+ end
525
+
526
+ # Validate step references in valid_steps
527
+ @contexts.each do |ctx_name, ctx|
528
+ ctx._steps.each do |step_name, step|
529
+ step_h = step.to_h
530
+ if step_h["valid_steps"]
531
+ step_h["valid_steps"].each do |vs|
532
+ next if vs == "next"
533
+ unless ctx._steps.key?(vs)
534
+ raise ArgumentError,
535
+ "Step '#{step_name}' in context '#{ctx_name}' references unknown step '#{vs}'"
536
+ end
537
+ end
538
+ end
539
+ end
540
+ end
541
+
542
+ # Validate context references at context level
543
+ @contexts.each do |ctx_name, ctx|
544
+ ctx_h = ctx.to_h
545
+ if ctx_h["valid_contexts"]
546
+ ctx_h["valid_contexts"].each do |vc|
547
+ unless @contexts.key?(vc)
548
+ raise ArgumentError,
549
+ "Context '#{ctx_name}' references unknown context '#{vc}'"
550
+ end
551
+ end
552
+ end
553
+ end
554
+
555
+ # Validate context references at step level
556
+ @contexts.each do |ctx_name, ctx|
557
+ ctx._steps.each do |step_name, step|
558
+ step_h = step.to_h
559
+ if step_h["valid_contexts"]
560
+ step_h["valid_contexts"].each do |vc|
561
+ unless @contexts.key?(vc)
562
+ raise ArgumentError,
563
+ "Step '#{step_name}' in context '#{ctx_name}' references unknown context '#{vc}'"
564
+ end
565
+ end
566
+ end
567
+ end
568
+ end
569
+
570
+ # Validate gather_info configurations
571
+ @contexts.each do |ctx_name, ctx|
572
+ ctx._steps.each do |step_name, step|
573
+ step_h = step.to_h
574
+ next unless step_h.key?("gather_info")
575
+
576
+ gi = step_h["gather_info"]
577
+ questions = gi["questions"] || []
578
+ raise ArgumentError,
579
+ "Step '#{step_name}' in context '#{ctx_name}' has gather_info with no questions" if questions.empty?
580
+
581
+ keys_seen = Set.new
582
+ questions.each do |q|
583
+ raise ArgumentError,
584
+ "Step '#{step_name}' in context '#{ctx_name}' has duplicate gather_info question key '#{q['key']}'" if keys_seen.include?(q["key"])
585
+ keys_seen << q["key"]
586
+ end
587
+
588
+ action = gi["completion_action"]
589
+ if action
590
+ if action == "next_step"
591
+ idx = ctx._step_order.index(step_name)
592
+ if idx >= ctx._step_order.size - 1
593
+ raise ArgumentError,
594
+ "Step '#{step_name}' in context '#{ctx_name}' has gather_info completion_action='next_step' but it is the last step"
595
+ end
596
+ elsif !ctx._steps.key?(action)
597
+ raise ArgumentError,
598
+ "Step '#{step_name}' in context '#{ctx_name}' has gather_info completion_action='#{action}' but step '#{action}' does not exist"
599
+ end
600
+ end
601
+ end
602
+ end
603
+
604
+ true
605
+ end
606
+
607
+ def to_h
608
+ validate!
609
+ result = {}
610
+ @context_order.each do |name|
611
+ result[name] = @contexts[name].to_h
612
+ end
613
+ result
614
+ end
615
+ end
616
+
617
+ # Helper to create a standalone context (not via ContextBuilder).
618
+ def self.create_simple_context(name = 'default')
619
+ Context.new(name)
620
+ end
621
+ end
622
+ end