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,467 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../wait_result"
4
+ require_relative "../throttle_manager"
5
+ require_relative "../approval"
6
+ require_relative "../approval_store"
7
+ require_relative "../notifiers"
8
+
9
+ module RubyLLM
10
+ module Agents
11
+ class Workflow
12
+ module DSL
13
+ # Main executor for workflows using the refined DSL
14
+ #
15
+ # Handles the execution of steps in order, including sequential
16
+ # steps and parallel groups, with full support for routing,
17
+ # conditions, retries, and error handling.
18
+ #
19
+ # @api private
20
+ class Executor
21
+ attr_reader :workflow, :results, :errors, :status
22
+
23
+ # @param workflow [Workflow] The workflow instance
24
+ def initialize(workflow)
25
+ @workflow = workflow
26
+ @results = {}
27
+ @errors = {}
28
+ @status = "success"
29
+ @halted = false
30
+ @skip_next_step = false
31
+ @throttle_manager = ThrottleManager.new
32
+ @wait_results = {}
33
+ end
34
+
35
+ # Executes all workflow steps
36
+ #
37
+ # @yield [chunk] Streaming callback
38
+ # @return [Workflow::Result] The workflow result
39
+ def execute(&block)
40
+ @workflow_started_at = Time.current
41
+
42
+ # Validate input schema before execution
43
+ validate_input!
44
+
45
+ run_hooks(:before_workflow)
46
+
47
+ catch(:halt_workflow) do
48
+ execute_steps(&block)
49
+ end
50
+
51
+ run_hooks(:after_workflow)
52
+
53
+ build_result
54
+ rescue InputSchema::ValidationError
55
+ # Re-raise validation errors - these should not be caught
56
+ raise
57
+ rescue StandardError => e
58
+ @status = "error"
59
+ @errors[:workflow] = e
60
+ build_result(error: e)
61
+ end
62
+
63
+ private
64
+
65
+ # Validates input against the schema if defined
66
+ #
67
+ # This is called at the start of execution to fail fast on invalid input.
68
+ # Also populates the validated_input for later access.
69
+ #
70
+ # @raise [InputSchema::ValidationError] If input validation fails
71
+ def validate_input!
72
+ schema = workflow.class.input_schema
73
+ return unless schema
74
+
75
+ # This will raise ValidationError if input is invalid
76
+ validated = schema.validate!(workflow.options)
77
+ workflow.instance_variable_set(:@validated_input, OpenStruct.new(validated))
78
+ end
79
+
80
+ def execute_steps(&block)
81
+ previous_result = nil
82
+
83
+ workflow.class.step_order.each do |item|
84
+ break if @halted
85
+
86
+ # Handle skip_next from wait timeout
87
+ if @skip_next_step
88
+ @skip_next_step = false
89
+ next if item.is_a?(Symbol)
90
+ end
91
+
92
+ case item
93
+ when Symbol
94
+ previous_result = execute_single_step(item, previous_result, &block)
95
+ when ParallelGroup
96
+ previous_result = execute_parallel_group(item, &block)
97
+ when WaitConfig
98
+ wait_result = execute_wait_step(item)
99
+ @wait_results[item.object_id] = wait_result
100
+ handle_wait_result(wait_result)
101
+ end
102
+ end
103
+ end
104
+
105
+ def execute_single_step(step_name, previous_result, &block)
106
+ config = workflow.class.step_configs[step_name]
107
+ return previous_result unless config
108
+
109
+ # Apply throttling if configured
110
+ apply_throttle(step_name, config)
111
+
112
+ run_hooks(:before_step, step_name, workflow.step_results)
113
+ run_hooks(:on_step_start, step_name, config.resolve_input(workflow, previous_result))
114
+
115
+ started_at = Time.current
116
+
117
+ result = catch(:skip_step) do
118
+ executor = StepExecutor.new(workflow, config)
119
+ executor.execute(previous_result, &block)
120
+ end
121
+
122
+ # Handle skip_step catch
123
+ if result.is_a?(Hash) && result[:skipped]
124
+ result = if result[:default]
125
+ SimpleResult.new(content: result[:default], success: true)
126
+ else
127
+ SkippedResult.new(step_name, reason: result[:reason])
128
+ end
129
+ end
130
+
131
+ duration_ms = ((Time.current - started_at) * 1000).round
132
+
133
+ @results[step_name] = result
134
+ workflow.instance_variable_get(:@step_results)[step_name] = result
135
+
136
+ # Update status based on result
137
+ update_status_from_result(step_name, result, config)
138
+
139
+ run_hooks(:after_step, step_name, result, duration_ms)
140
+ run_hooks(:on_step_complete, step_name, result, duration_ms)
141
+
142
+ # Return nil on error for critical steps to prevent passing bad data
143
+ if result.respond_to?(:error?) && result.error? && config.critical?
144
+ @halted = true
145
+ return nil
146
+ end
147
+
148
+ result
149
+ rescue StandardError => e
150
+ handle_step_error(step_name, e, config)
151
+ end
152
+
153
+ def execute_parallel_group(group, &block)
154
+ results_mutex = Mutex.new
155
+ group_results = {}
156
+ group_errors = {}
157
+
158
+ # Determine pool size
159
+ pool_size = group.concurrency || group.step_names.size
160
+ pool = create_executor_pool(pool_size)
161
+
162
+ # Get the last result before this parallel group for input
163
+ last_sequential_step = workflow.class.step_order
164
+ .take_while { |item| item != group }
165
+ .select { |item| item.is_a?(Symbol) }
166
+ .last
167
+ previous_result = last_sequential_step ? @results[last_sequential_step] : nil
168
+
169
+ group.step_names.each do |step_name|
170
+ pool.post do
171
+ Thread.current.name = "parallel-#{step_name}"
172
+
173
+ begin
174
+ config = workflow.class.step_configs[step_name]
175
+ next unless config
176
+
177
+ executor = StepExecutor.new(workflow, config)
178
+ result = executor.execute(previous_result, &block)
179
+
180
+ results_mutex.synchronize do
181
+ group_results[step_name] = result
182
+ @results[step_name] = result
183
+ workflow.instance_variable_get(:@step_results)[step_name] = result
184
+
185
+ # Fail-fast handling
186
+ if group.fail_fast? && result.respond_to?(:error?) && result.error? && config.critical?
187
+ pool.abort! if pool.respond_to?(:abort!)
188
+ end
189
+ end
190
+ rescue StandardError => e
191
+ results_mutex.synchronize do
192
+ group_errors[step_name] = e
193
+ @errors[step_name] = e
194
+
195
+ if group.fail_fast?
196
+ pool.abort! if pool.respond_to?(:abort!)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
202
+
203
+ pool.wait_for_completion
204
+ pool.shutdown
205
+
206
+ # Update overall status
207
+ update_parallel_status(group, group_results, group_errors)
208
+
209
+ # Return combined results as a hash-like object
210
+ ParallelGroupResult.new(group.name, group_results)
211
+ end
212
+
213
+ def create_executor_pool(size)
214
+ config = RubyLLM::Agents.configuration
215
+
216
+ if config.respond_to?(:async_context?) && config.async_context?
217
+ AsyncExecutor.new(max_concurrent: size)
218
+ else
219
+ ThreadPool.new(size: size)
220
+ end
221
+ end
222
+
223
+ # Executes a wait step
224
+ #
225
+ # @param wait_config [WaitConfig] The wait configuration
226
+ # @return [WaitResult] The wait result
227
+ def execute_wait_step(wait_config)
228
+ executor = WaitExecutor.new(wait_config, workflow)
229
+ executor.execute
230
+ rescue StandardError => e
231
+ # Return a failed result instead of crashing
232
+ Workflow::WaitResult.timeout(
233
+ wait_config.type,
234
+ 0,
235
+ :fail,
236
+ error: "#{e.class}: #{e.message}"
237
+ )
238
+ end
239
+
240
+ # Handles the result of a wait step
241
+ #
242
+ # @param wait_result [WaitResult] The wait result
243
+ # @return [void]
244
+ def handle_wait_result(wait_result)
245
+ if wait_result.timeout? && wait_result.timeout_action == :fail
246
+ @status = "error"
247
+ @halted = true
248
+ @errors[:wait] = "Wait timed out: #{wait_result.type}"
249
+ elsif wait_result.rejected?
250
+ @status = "error"
251
+ @halted = true
252
+ @errors[:wait] = "Approval rejected: #{wait_result.rejection_reason}"
253
+ elsif wait_result.should_skip_next?
254
+ @skip_next_step = true
255
+ end
256
+ end
257
+
258
+ # Applies throttling for a step if configured
259
+ #
260
+ # @param step_name [Symbol] The step name
261
+ # @param config [StepConfig] The step configuration
262
+ # @return [void]
263
+ def apply_throttle(step_name, config)
264
+ return unless config.throttled?
265
+
266
+ if config.throttle
267
+ @throttle_manager.throttle("step:#{step_name}", config.throttle)
268
+ elsif config.rate_limit
269
+ @throttle_manager.rate_limit(
270
+ "step:#{step_name}",
271
+ calls: config.rate_limit[:calls],
272
+ per: config.rate_limit[:per]
273
+ )
274
+ end
275
+ end
276
+
277
+ def handle_step_error(step_name, error, config)
278
+ @errors[step_name] = error
279
+
280
+ run_hooks(:on_step_error, step_name, error)
281
+ run_hooks(:on_step_failure, step_name, error, workflow.step_results)
282
+
283
+ # Build error result
284
+ error_result = Pipeline::ErrorResult.new(
285
+ step_name: step_name,
286
+ error_class: error.class.name,
287
+ error_message: error.message
288
+ )
289
+
290
+ @results[step_name] = error_result
291
+ workflow.instance_variable_get(:@step_results)[step_name] = error_result
292
+
293
+ if config.optional?
294
+ @status = "partial" if @status == "success"
295
+ config.default_value ? SimpleResult.new(content: config.default_value, success: true) : nil
296
+ else
297
+ @status = "error"
298
+ @halted = true
299
+ nil
300
+ end
301
+ end
302
+
303
+ def update_status_from_result(step_name, result, config)
304
+ return unless result.respond_to?(:error?) && result.error?
305
+
306
+ if config.optional?
307
+ @status = "partial" if @status == "success"
308
+ else
309
+ @status = "error"
310
+ end
311
+ end
312
+
313
+ def update_parallel_status(group, group_results, group_errors)
314
+ # Check for errors
315
+ group.step_names.each do |step_name|
316
+ config = workflow.class.step_configs[step_name]
317
+
318
+ if group_errors[step_name]
319
+ if config&.optional?
320
+ @status = "partial" if @status == "success"
321
+ else
322
+ @status = "error"
323
+ end
324
+ elsif group_results[step_name]&.respond_to?(:error?) && group_results[step_name].error?
325
+ if config&.optional?
326
+ @status = "partial" if @status == "success"
327
+ else
328
+ @status = "error"
329
+ end
330
+ end
331
+ end
332
+ end
333
+
334
+ def run_hooks(hook_name, *args)
335
+ workflow.send(:run_hooks, hook_name, *args)
336
+ end
337
+
338
+ def build_result(error: nil)
339
+ # Get final content from last successful step
340
+ final_content = extract_final_content
341
+
342
+ # Validate output if schema defined
343
+ if workflow.class.output_schema && final_content
344
+ begin
345
+ workflow.class.output_schema.validate!(final_content)
346
+ rescue InputSchema::ValidationError => e
347
+ @errors[:output_validation] = e
348
+ @status = "error" if @status == "success"
349
+ end
350
+ end
351
+
352
+ Workflow::Result.new(
353
+ content: final_content,
354
+ workflow_type: workflow.class.name,
355
+ workflow_id: workflow.workflow_id,
356
+ steps: @results,
357
+ errors: @errors,
358
+ status: @status,
359
+ error_class: error&.class&.name,
360
+ error_message: error&.message,
361
+ started_at: @workflow_started_at,
362
+ completed_at: Time.current,
363
+ duration_ms: (((Time.current - @workflow_started_at) * 1000).round if @workflow_started_at)
364
+ )
365
+ end
366
+
367
+ def extract_final_content
368
+ # Find the last successful result
369
+ workflow.class.step_order.reverse.each do |item|
370
+ case item
371
+ when Symbol
372
+ result = @results[item]
373
+ next if result.nil?
374
+ next if result.respond_to?(:skipped?) && result.skipped?
375
+ next if result.respond_to?(:error?) && result.error?
376
+ return result.content if result.respond_to?(:content)
377
+ when ParallelGroup
378
+ # For parallel groups, return the combined content
379
+ group_content = {}
380
+ item.step_names.each do |step_name|
381
+ result = @results[step_name]
382
+ next if result.nil? || (result.respond_to?(:error?) && result.error?)
383
+ group_content[step_name] = result.respond_to?(:content) ? result.content : result
384
+ end
385
+ return group_content if group_content.any?
386
+ when WaitConfig
387
+ # Wait steps don't contribute content, skip them
388
+ next
389
+ end
390
+ end
391
+
392
+ nil
393
+ end
394
+ end
395
+
396
+ # Result wrapper for parallel group execution
397
+ #
398
+ # Provides access to individual step results within a parallel group.
399
+ #
400
+ # @api private
401
+ class ParallelGroupResult
402
+ attr_reader :name, :results
403
+
404
+ def initialize(name, results)
405
+ @name = name
406
+ @results = results
407
+ end
408
+
409
+ def content
410
+ @results.transform_values { |r| r&.content }
411
+ end
412
+
413
+ def [](key)
414
+ @results[key]
415
+ end
416
+
417
+ def success?
418
+ @results.values.all? { |r| r.nil? || !r.respond_to?(:error?) || !r.error? }
419
+ end
420
+
421
+ def error?
422
+ !success?
423
+ end
424
+
425
+ def to_h
426
+ content
427
+ end
428
+
429
+ def method_missing(name, *args, &block)
430
+ if @results.key?(name)
431
+ @results[name]
432
+ elsif content.key?(name)
433
+ content[name]
434
+ else
435
+ super
436
+ end
437
+ end
438
+
439
+ def respond_to_missing?(name, include_private = false)
440
+ @results.key?(name) || content.key?(name) || super
441
+ end
442
+
443
+ # Token/cost aggregation
444
+ def input_tokens
445
+ @results.values.compact.sum { |r| r.respond_to?(:input_tokens) ? r.input_tokens : 0 }
446
+ end
447
+
448
+ def output_tokens
449
+ @results.values.compact.sum { |r| r.respond_to?(:output_tokens) ? r.output_tokens : 0 }
450
+ end
451
+
452
+ def total_tokens
453
+ input_tokens + output_tokens
454
+ end
455
+
456
+ def cached_tokens
457
+ @results.values.compact.sum { |r| r.respond_to?(:cached_tokens) ? r.cached_tokens : 0 }
458
+ end
459
+
460
+ def total_cost
461
+ @results.values.compact.sum { |r| r.respond_to?(:total_cost) ? r.total_cost : 0.0 }
462
+ end
463
+ end
464
+ end
465
+ end
466
+ end
467
+ end
@@ -0,0 +1,244 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Defines and validates input schema for a workflow
8
+ #
9
+ # Provides a DSL for declaring required and optional input parameters
10
+ # with type validation and default values.
11
+ #
12
+ # @example Defining input schema
13
+ # class MyWorkflow < RubyLLM::Agents::Workflow
14
+ # input do
15
+ # required :order_id, String
16
+ # required :user_id, Integer
17
+ # optional :priority, String, default: "normal"
18
+ # optional :expedited, Boolean, default: false
19
+ # end
20
+ # end
21
+ #
22
+ # @api private
23
+ class InputSchema
24
+ # Error raised when input validation fails
25
+ class ValidationError < StandardError
26
+ attr_reader :errors
27
+
28
+ def initialize(message, errors: [])
29
+ super(message)
30
+ @errors = errors
31
+ end
32
+ end
33
+
34
+ # Represents a single field in the schema
35
+ class Field
36
+ attr_reader :name, :type, :required, :default, :options
37
+
38
+ def initialize(name, type, required:, default: nil, **options)
39
+ @name = name
40
+ @type = type
41
+ @required = required
42
+ @default = default
43
+ @options = options
44
+ end
45
+
46
+ def required?
47
+ @required
48
+ end
49
+
50
+ def optional?
51
+ !@required
52
+ end
53
+
54
+ def has_default?
55
+ !@default.nil? || @options.key?(:default)
56
+ end
57
+
58
+ def validate(value)
59
+ errors = []
60
+
61
+ # Check required
62
+ if required? && value.nil?
63
+ errors << "#{name} is required"
64
+ return errors
65
+ end
66
+
67
+ # Skip validation for nil optional values
68
+ return errors if value.nil? && optional?
69
+
70
+ # Type validation
71
+ unless valid_type?(value)
72
+ errors << "#{name} must be a #{type_description}"
73
+ end
74
+
75
+ # Enum validation
76
+ if options[:in] && !options[:in].include?(value)
77
+ errors << "#{name} must be one of: #{options[:in].join(', ')}"
78
+ end
79
+
80
+ # Custom validation
81
+ if options[:validate] && !options[:validate].call(value)
82
+ errors << "#{name} failed custom validation"
83
+ end
84
+
85
+ errors
86
+ end
87
+
88
+ def to_h
89
+ {
90
+ name: name,
91
+ type: type_description,
92
+ required: required?,
93
+ default: default,
94
+ options: options.except(:validate)
95
+ }.compact
96
+ end
97
+
98
+ private
99
+
100
+ def valid_type?(value)
101
+ return true if type.nil?
102
+
103
+ case type
104
+ when :boolean, "Boolean"
105
+ value == true || value == false
106
+ else
107
+ value.is_a?(type)
108
+ end
109
+ end
110
+
111
+ def type_description
112
+ case type
113
+ when :boolean, "Boolean"
114
+ "Boolean"
115
+ when Class
116
+ type.name
117
+ else
118
+ type.to_s
119
+ end
120
+ end
121
+ end
122
+
123
+ def initialize
124
+ @fields = {}
125
+ end
126
+
127
+ # Defines a required field
128
+ #
129
+ # @param name [Symbol] Field name
130
+ # @param type [Class, Symbol] Expected type
131
+ # @param options [Hash] Additional options
132
+ # @return [void]
133
+ def required(name, type = nil, **options)
134
+ @fields[name] = Field.new(name, type, required: true, **options)
135
+ end
136
+
137
+ # Defines an optional field
138
+ #
139
+ # @param name [Symbol] Field name
140
+ # @param type [Class, Symbol] Expected type
141
+ # @param default [Object] Default value
142
+ # @param options [Hash] Additional options
143
+ # @return [void]
144
+ def optional(name, type = nil, default: nil, **options)
145
+ @fields[name] = Field.new(name, type, required: false, default: default, **options)
146
+ end
147
+
148
+ # Returns all fields
149
+ #
150
+ # @return [Hash<Symbol, Field>]
151
+ attr_reader :fields
152
+
153
+ # Returns required field names
154
+ #
155
+ # @return [Array<Symbol>]
156
+ def required_fields
157
+ @fields.select { |_, f| f.required? }.keys
158
+ end
159
+
160
+ # Returns optional field names
161
+ #
162
+ # @return [Array<Symbol>]
163
+ def optional_fields
164
+ @fields.select { |_, f| f.optional? }.keys
165
+ end
166
+
167
+ # Validates input against the schema
168
+ #
169
+ # @param input [Hash] Input data to validate
170
+ # @return [Hash] Validated and normalized input
171
+ # @raise [ValidationError] If validation fails
172
+ def validate!(input)
173
+ errors = []
174
+ normalized = {}
175
+
176
+ @fields.each do |name, field|
177
+ value = input.key?(name) ? input[name] : field.default
178
+ field_errors = field.validate(value)
179
+ errors.concat(field_errors)
180
+ normalized[name] = value unless value.nil? && field.optional?
181
+ end
182
+
183
+ # Include any extra fields not in schema
184
+ input.each do |key, value|
185
+ normalized[key] = value unless @fields.key?(key)
186
+ end
187
+
188
+ if errors.any?
189
+ raise ValidationError.new(
190
+ "Input validation failed: #{errors.join(', ')}",
191
+ errors: errors
192
+ )
193
+ end
194
+
195
+ normalized
196
+ end
197
+
198
+ # Applies defaults to input without validation
199
+ #
200
+ # @param input [Hash] Input data
201
+ # @return [Hash] Input with defaults applied
202
+ def apply_defaults(input)
203
+ result = input.dup
204
+ @fields.each do |name, field|
205
+ result[name] = field.default if !result.key?(name) && field.has_default?
206
+ end
207
+ result
208
+ end
209
+
210
+ # Converts to hash for serialization
211
+ #
212
+ # @return [Hash]
213
+ def to_h
214
+ {
215
+ fields: @fields.transform_values(&:to_h)
216
+ }
217
+ end
218
+
219
+ # Returns whether the schema is empty
220
+ #
221
+ # @return [Boolean]
222
+ def empty?
223
+ @fields.empty?
224
+ end
225
+ end
226
+
227
+ # Output schema for workflow results
228
+ #
229
+ # Similar to InputSchema but for validating workflow output.
230
+ class OutputSchema < InputSchema
231
+ # Validates output against the schema
232
+ #
233
+ # @param output [Hash] Output data to validate
234
+ # @return [Hash] Validated output
235
+ # @raise [ValidationError] If validation fails
236
+ def validate!(output)
237
+ output_hash = output.is_a?(Hash) ? output : { result: output }
238
+ super(output_hash)
239
+ end
240
+ end
241
+ end
242
+ end
243
+ end
244
+ end