ruby_llm-agents 1.0.0 → 1.2.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 (152) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +9 -3
  3. data/app/controllers/concerns/ruby_llm/agents/sortable.rb +58 -0
  4. data/app/controllers/ruby_llm/agents/agents_controller.rb +59 -16
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +144 -20
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +13 -16
  7. data/app/controllers/ruby_llm/agents/workflows_controller.rb +279 -90
  8. data/app/helpers/ruby_llm/agents/application_helper.rb +100 -0
  9. data/app/mailers/ruby_llm/agents/alert_mailer.rb +84 -0
  10. data/app/mailers/ruby_llm/agents/application_mailer.rb +28 -0
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +170 -20
  12. data/app/models/ruby_llm/agents/execution/scopes.rb +0 -31
  13. data/app/models/ruby_llm/agents/execution/workflow.rb +0 -129
  14. data/app/models/ruby_llm/agents/execution.rb +50 -14
  15. data/app/models/ruby_llm/agents/tenant/budgetable.rb +277 -0
  16. data/app/models/ruby_llm/agents/tenant/configurable.rb +135 -0
  17. data/app/models/ruby_llm/agents/tenant/trackable.rb +310 -0
  18. data/app/models/ruby_llm/agents/tenant.rb +146 -0
  19. data/app/models/ruby_llm/agents/tenant_budget.rb +12 -253
  20. data/app/services/ruby_llm/agents/agent_registry.rb +18 -12
  21. data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
  22. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
  23. data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
  24. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
  25. data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
  26. data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
  27. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
  28. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
  29. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
  30. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
  31. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
  32. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
  33. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
  34. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
  35. data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
  36. data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
  37. data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
  38. data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
  39. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
  40. data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
  41. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
  42. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
  43. data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
  44. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
  45. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
  46. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
  47. data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
  48. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
  49. data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
  50. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
  51. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
  52. data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
  53. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
  54. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
  55. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
  56. data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
  57. data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
  58. data/config/routes.rb +1 -1
  59. data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
  60. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
  61. data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
  62. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
  63. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
  64. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
  65. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
  66. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
  67. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
  68. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
  69. data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
  70. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
  71. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +42 -22
  72. data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
  73. data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
  74. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +13 -2
  75. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
  76. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
  77. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
  78. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
  79. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
  80. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
  81. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
  82. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
  83. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
  84. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
  85. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
  86. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
  87. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
  88. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
  89. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
  90. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +11 -0
  91. data/lib/generators/ruby_llm_agents/templates/create_tenants_migration.rb.tt +72 -0
  92. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
  93. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
  94. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
  95. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
  96. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
  97. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
  98. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
  99. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
  100. data/lib/generators/ruby_llm_agents/templates/rename_tenant_budgets_to_tenants_migration.rb.tt +34 -0
  101. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
  102. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
  103. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
  104. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
  105. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
  106. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
  107. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
  108. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
  109. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
  110. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
  111. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
  112. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
  113. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
  114. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
  115. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
  116. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
  117. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
  118. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +26 -0
  119. data/lib/ruby_llm/agents/core/configuration.rb +55 -43
  120. data/lib/ruby_llm/agents/core/llm_tenant.rb +60 -60
  121. data/lib/ruby_llm/agents/core/version.rb +1 -1
  122. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
  123. data/lib/ruby_llm/agents/infrastructure/budget/config_resolver.rb +4 -2
  124. data/lib/ruby_llm/agents/pipeline.rb +69 -0
  125. data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
  126. data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
  127. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
  128. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
  129. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
  130. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
  131. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
  132. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
  133. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
  134. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
  135. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
  136. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
  137. data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
  138. data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
  139. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
  140. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
  141. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
  142. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
  143. data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
  144. data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
  145. data/lib/ruby_llm/agents/workflow/result.rb +202 -0
  146. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
  147. data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
  148. metadata +43 -6
  149. data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
  150. data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
  151. data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
  152. data/lib/ruby_llm/agents/workflow/router.rb +0 -429
@@ -0,0 +1,289 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Executes iteration steps with sequential or parallel processing
8
+ #
9
+ # Handles `each:` option on steps to process collections with support for:
10
+ # - Sequential iteration
11
+ # - Parallel iteration with configurable concurrency
12
+ # - Fail-fast behavior
13
+ # - Continue-on-error behavior
14
+ #
15
+ # @api private
16
+ class IterationExecutor
17
+ attr_reader :workflow, :config, :previous_result
18
+
19
+ # @param workflow [Workflow] The workflow instance
20
+ # @param config [StepConfig] The step configuration
21
+ # @param previous_result [Result, nil] Previous step result
22
+ def initialize(workflow, config, previous_result)
23
+ @workflow = workflow
24
+ @config = config
25
+ @previous_result = previous_result
26
+ end
27
+
28
+ # Executes the iteration
29
+ #
30
+ # @yield [chunk] Streaming callback
31
+ # @return [IterationResult] Aggregated results for all items
32
+ def execute(&block)
33
+ items = resolve_items
34
+ return Workflow::IterationResult.empty(config.name) if items.empty?
35
+
36
+ if config.iteration_concurrency && config.iteration_concurrency > 1
37
+ execute_parallel(items, &block)
38
+ else
39
+ execute_sequential(items, &block)
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def resolve_items
46
+ source = config.each_source
47
+ items = workflow.instance_exec(&source)
48
+ Array(items)
49
+ rescue StandardError => e
50
+ raise IterationSourceError, "Failed to resolve iteration source: #{e.message}"
51
+ end
52
+
53
+ def execute_sequential(items, &block)
54
+ item_results = []
55
+ errors = {}
56
+
57
+ items.each_with_index do |item, index|
58
+ begin
59
+ result = execute_for_item(item, index, &block)
60
+ item_results << result
61
+
62
+ # Check for fail-fast on error
63
+ if config.iteration_fail_fast? && result.respond_to?(:error?) && result.error?
64
+ break
65
+ end
66
+ rescue StandardError => e
67
+ if config.iteration_fail_fast?
68
+ errors[index] = e
69
+ break
70
+ elsif config.continue_on_error?
71
+ errors[index] = e
72
+ # Continue to next item
73
+ else
74
+ raise
75
+ end
76
+ end
77
+ end
78
+
79
+ Workflow::IterationResult.new(
80
+ step_name: config.name,
81
+ item_results: item_results,
82
+ errors: errors
83
+ )
84
+ end
85
+
86
+ def execute_parallel(items, &block)
87
+ results_mutex = Mutex.new
88
+ item_results = Array.new(items.size)
89
+ errors = {}
90
+ aborted = false
91
+
92
+ pool = create_executor_pool(config.iteration_concurrency)
93
+
94
+ items.each_with_index do |item, index|
95
+ pool.post do
96
+ next if aborted
97
+
98
+ begin
99
+ result = execute_for_item(item, index, &block)
100
+
101
+ results_mutex.synchronize do
102
+ item_results[index] = result
103
+
104
+ # Check for fail-fast
105
+ if config.iteration_fail_fast? && result.respond_to?(:error?) && result.error?
106
+ aborted = true
107
+ pool.abort! if pool.respond_to?(:abort!)
108
+ end
109
+ end
110
+ rescue StandardError => e
111
+ results_mutex.synchronize do
112
+ errors[index] = e
113
+
114
+ if config.iteration_fail_fast?
115
+ aborted = true
116
+ pool.abort! if pool.respond_to?(:abort!)
117
+ end
118
+ end
119
+
120
+ raise unless config.continue_on_error? || config.iteration_fail_fast?
121
+ end
122
+ end
123
+ end
124
+
125
+ pool.wait_for_completion
126
+ pool.shutdown
127
+
128
+ # Remove nil entries from results (unfilled due to abort)
129
+ item_results.compact!
130
+
131
+ Workflow::IterationResult.new(
132
+ step_name: config.name,
133
+ item_results: item_results,
134
+ errors: errors
135
+ )
136
+ end
137
+
138
+ def execute_for_item(item, index, &block)
139
+ if config.custom_block?
140
+ execute_block_for_item(item, index, &block)
141
+ elsif config.workflow?
142
+ execute_workflow_for_item(item, index, &block)
143
+ else
144
+ execute_agent_for_item(item, index, &block)
145
+ end
146
+ end
147
+
148
+ def execute_block_for_item(item, index, &block)
149
+ context = IterationContext.new(workflow, config, previous_result, item, index)
150
+ result = context.instance_exec(item, &config.block)
151
+
152
+ # If block returns a Result, use it; otherwise wrap it
153
+ if result.is_a?(Workflow::Result) || result.is_a?(RubyLLM::Agents::Result)
154
+ result
155
+ else
156
+ SimpleResult.new(content: result, success: true)
157
+ end
158
+ end
159
+
160
+ def execute_agent_for_item(item, index, &block)
161
+ # Build input for this item
162
+ step_input = build_item_input(item, index)
163
+ workflow.send(:execute_agent, config.agent, step_input, step_name: config.name, &block)
164
+ end
165
+
166
+ def execute_workflow_for_item(item, index, &block)
167
+ step_input = build_item_input(item, index)
168
+
169
+ # Build execution metadata
170
+ parent_metadata = {
171
+ parent_execution_id: workflow.execution_id,
172
+ root_execution_id: workflow.send(:root_execution_id),
173
+ workflow_id: workflow.workflow_id,
174
+ workflow_type: workflow.class.name,
175
+ workflow_step: config.name.to_s,
176
+ iteration_index: index,
177
+ recursion_depth: (workflow.instance_variable_get(:@recursion_depth) || 0) + (config.agent == workflow.class ? 1 : 0)
178
+ }.compact
179
+
180
+ merged_input = step_input.merge(
181
+ execution_metadata: parent_metadata.merge(step_input[:execution_metadata] || {})
182
+ )
183
+
184
+ result = config.agent.call(**merged_input, &block)
185
+
186
+ # Track accumulated cost
187
+ if result.respond_to?(:total_cost) && result.total_cost
188
+ workflow.instance_variable_set(
189
+ :@accumulated_cost,
190
+ (workflow.instance_variable_get(:@accumulated_cost) || 0.0) + result.total_cost
191
+ )
192
+ workflow.send(:check_cost_threshold!)
193
+ end
194
+
195
+ Workflow::SubWorkflowResult.new(
196
+ content: result.content,
197
+ sub_workflow_result: result,
198
+ workflow_type: config.agent.name,
199
+ step_name: config.name
200
+ )
201
+ end
202
+
203
+ def build_item_input(item, index)
204
+ # If there's an input mapper, use it with item context
205
+ if config.input_mapper
206
+ # Create a temporary context that has access to item and index
207
+ context = IterationInputContext.new(workflow, item, index)
208
+ context.instance_exec(&config.input_mapper)
209
+ else
210
+ # Default: wrap item in a hash
211
+ item.is_a?(Hash) ? item : { item: item, index: index }
212
+ end
213
+ end
214
+
215
+ def create_executor_pool(size)
216
+ config_obj = RubyLLM::Agents.configuration
217
+
218
+ if config_obj.respond_to?(:async_context?) && config_obj.async_context?
219
+ AsyncExecutor.new(max_concurrent: size)
220
+ else
221
+ ThreadPool.new(size: size)
222
+ end
223
+ end
224
+ end
225
+
226
+ # Context for executing iteration block steps
227
+ #
228
+ # Extends BlockContext with item and index access.
229
+ #
230
+ # @api private
231
+ class IterationContext < BlockContext
232
+ attr_reader :item, :index
233
+
234
+ def initialize(workflow, config, previous_result, item, index)
235
+ super(workflow, config, previous_result)
236
+ @item = item
237
+ @index = index
238
+ end
239
+
240
+ # Access the current item being processed
241
+ def current_item
242
+ @item
243
+ end
244
+
245
+ # Access the current iteration index
246
+ def current_index
247
+ @index
248
+ end
249
+ end
250
+
251
+ # Context for building iteration input
252
+ #
253
+ # Provides access to item and index for input mappers.
254
+ #
255
+ # @api private
256
+ class IterationInputContext
257
+ def initialize(workflow, item, index)
258
+ @workflow = workflow
259
+ @item = item
260
+ @index = index
261
+ end
262
+
263
+ attr_reader :item, :index
264
+
265
+ # Access workflow input
266
+ def input
267
+ @workflow.input
268
+ end
269
+
270
+ # Delegate to workflow for step results access
271
+ def method_missing(name, *args, &block)
272
+ if @workflow.respond_to?(name, true)
273
+ @workflow.send(name, *args, &block)
274
+ else
275
+ super
276
+ end
277
+ end
278
+
279
+ def respond_to_missing?(name, include_private = false)
280
+ @workflow.respond_to?(name, include_private) || super
281
+ end
282
+ end
283
+
284
+ # Error raised when iteration source resolution fails
285
+ class IterationSourceError < StandardError; end
286
+ end
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Represents a group of steps that execute in parallel
8
+ #
9
+ # Parallel groups allow multiple steps to run concurrently and
10
+ # their results to be available to subsequent steps.
11
+ #
12
+ # @example Basic parallel group
13
+ # parallel do
14
+ # step :sentiment, SentimentAgent
15
+ # step :keywords, KeywordAgent
16
+ # step :entities, EntityAgent
17
+ # end
18
+ #
19
+ # @example Named parallel group
20
+ # parallel :analysis do
21
+ # step :sentiment, SentimentAgent
22
+ # step :keywords, KeywordAgent
23
+ # end
24
+ #
25
+ # step :combine, CombinerAgent,
26
+ # input: -> { { analysis: analysis } }
27
+ #
28
+ # @api private
29
+ class ParallelGroup
30
+ attr_reader :name, :step_names, :options
31
+
32
+ # @param name [Symbol, nil] Optional name for the group
33
+ # @param step_names [Array<Symbol>] Names of steps in the group
34
+ # @param options [Hash] Group options
35
+ def initialize(name: nil, step_names: [], options: {})
36
+ @name = name
37
+ @step_names = step_names
38
+ @options = options
39
+ end
40
+
41
+ # Adds a step to the group
42
+ #
43
+ # @param step_name [Symbol]
44
+ # @return [void]
45
+ def add_step(step_name)
46
+ @step_names << step_name
47
+ end
48
+
49
+ # Returns the number of steps in the group
50
+ #
51
+ # @return [Integer]
52
+ def size
53
+ @step_names.size
54
+ end
55
+
56
+ # Returns whether the group is empty
57
+ #
58
+ # @return [Boolean]
59
+ def empty?
60
+ @step_names.empty?
61
+ end
62
+
63
+ # Returns the fail-fast setting for this group
64
+ #
65
+ # @return [Boolean]
66
+ def fail_fast?
67
+ options[:fail_fast] == true
68
+ end
69
+
70
+ # Returns the concurrency limit for this group
71
+ #
72
+ # @return [Integer, nil]
73
+ def concurrency
74
+ options[:concurrency]
75
+ end
76
+
77
+ # Returns the timeout for the entire group
78
+ #
79
+ # @return [Integer, nil]
80
+ def timeout
81
+ options[:timeout]
82
+ end
83
+
84
+ # Converts to hash for serialization
85
+ #
86
+ # @return [Hash]
87
+ def to_h
88
+ {
89
+ name: name,
90
+ step_names: step_names,
91
+ fail_fast: fail_fast?,
92
+ concurrency: concurrency,
93
+ timeout: timeout
94
+ }.compact
95
+ end
96
+
97
+ # String representation
98
+ #
99
+ # @return [String]
100
+ def inspect
101
+ "#<ParallelGroup name=#{name.inspect} steps=#{step_names.inspect}>"
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Builder for defining routing options in a step
8
+ #
9
+ # Used with the `on:` option to route to different agents based on
10
+ # a runtime value. Supports a fluent interface for defining routes.
11
+ #
12
+ # @example Basic routing
13
+ # step :process, on: -> { enrich.tier } do |route|
14
+ # route.premium PremiumAgent
15
+ # route.standard StandardAgent
16
+ # route.default DefaultAgent
17
+ # end
18
+ #
19
+ # @example With per-route options
20
+ # step :process, on: -> { enrich.tier } do |route|
21
+ # route.premium PremiumAgent, input: -> { { vip: true } }, timeout: 5.minutes
22
+ # route.standard StandardAgent
23
+ # route.default DefaultAgent
24
+ # end
25
+ #
26
+ # @api private
27
+ class RouteBuilder
28
+ # Error raised when no route matches and no default is defined
29
+ class NoRouteError < StandardError
30
+ attr_reader :value, :available_routes
31
+
32
+ def initialize(message, value: nil, available_routes: [])
33
+ super(message)
34
+ @value = value
35
+ @available_routes = available_routes
36
+ end
37
+ end
38
+
39
+ def initialize
40
+ @routes = {}
41
+ @default = nil
42
+ end
43
+
44
+ # Returns all defined routes
45
+ #
46
+ # @return [Hash<Symbol, Hash>]
47
+ attr_reader :routes
48
+
49
+ # Returns or sets the default route
50
+ #
51
+ # When called with no arguments, returns the current default.
52
+ # When called with an agent, sets the default route.
53
+ #
54
+ # @param agent [Class, nil] Agent class for the default route
55
+ # @param options [Hash] Route options
56
+ # @return [Hash, nil]
57
+ def default(agent = nil, **options)
58
+ if agent.nil? && options.empty?
59
+ @default
60
+ else
61
+ @default = { agent: agent, options: options }
62
+ end
63
+ end
64
+
65
+ # Handles dynamic route definitions
66
+ #
67
+ # Any method call becomes a route definition.
68
+ #
69
+ # @param name [Symbol] Route name
70
+ # @param agent [Class] Agent class for this route
71
+ # @param options [Hash] Route options
72
+ # @return [void]
73
+ def method_missing(name, agent = nil, **options)
74
+ if name == :default
75
+ @default = { agent: agent, options: options }
76
+ else
77
+ @routes[name.to_sym] = { agent: agent, options: options }
78
+ end
79
+ end
80
+
81
+ def respond_to_missing?(name, include_private = false)
82
+ true
83
+ end
84
+
85
+ # Resolves the route for a given value
86
+ #
87
+ # @param value [Object] The routing key value
88
+ # @return [Hash] Route configuration with :agent and :options
89
+ # @raise [NoRouteError] If no route matches and no default is defined
90
+ def resolve(value)
91
+ key = normalize_key(value)
92
+
93
+ route = @routes[key] || @default
94
+
95
+ unless route
96
+ raise NoRouteError.new(
97
+ "No route defined for value: #{value.inspect} (normalized: #{key}). " \
98
+ "Available routes: #{@routes.keys.join(', ')}",
99
+ value: value,
100
+ available_routes: @routes.keys
101
+ )
102
+ end
103
+
104
+ route
105
+ end
106
+
107
+ # Returns all route names
108
+ #
109
+ # @return [Array<Symbol>]
110
+ def route_names
111
+ @routes.keys
112
+ end
113
+
114
+ # Checks if a route exists
115
+ #
116
+ # @param name [Symbol] Route name
117
+ # @return [Boolean]
118
+ def route_exists?(name)
119
+ @routes.key?(name.to_sym) || @default.present?
120
+ end
121
+
122
+ # Converts to hash for serialization
123
+ #
124
+ # @return [Hash]
125
+ def to_h
126
+ {
127
+ routes: @routes.transform_values do |r|
128
+ { agent: r[:agent]&.name, options: r[:options] }
129
+ end,
130
+ default: @default ? { agent: @default[:agent]&.name, options: @default[:options] } : nil
131
+ }
132
+ end
133
+
134
+ private
135
+
136
+ def normalize_key(value)
137
+ case value
138
+ when Symbol then value
139
+ when String then value.to_sym
140
+ when TrueClass then :true
141
+ when FalseClass then :false
142
+ when NilClass then :nil
143
+ else value.to_s.to_sym
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end