ruby_llm-agents 1.0.0 → 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 (139) 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/services/ruby_llm/agents/agent_registry.rb +18 -12
  16. data/app/views/layouts/ruby_llm/agents/application.html.erb +72 -76
  17. data/app/views/ruby_llm/agents/agents/_agent.html.erb +0 -12
  18. data/app/views/ruby_llm/agents/agents/_sortable_header.html.erb +56 -0
  19. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +5 -15
  20. data/app/views/ruby_llm/agents/agents/index.html.erb +271 -100
  21. data/app/views/ruby_llm/agents/agents/show.html.erb +1 -0
  22. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.html.erb +107 -0
  23. data/app/views/ruby_llm/agents/alert_mailer/alert_notification.text.erb +18 -0
  24. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +4 -1
  25. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +66 -359
  26. data/app/views/ruby_llm/agents/dashboard/_model_comparison.html.erb +56 -0
  27. data/app/views/ruby_llm/agents/dashboard/_model_cost_breakdown.html.erb +115 -0
  28. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +35 -60
  29. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +17 -6
  30. data/app/views/ruby_llm/agents/dashboard/index.html.erb +373 -72
  31. data/app/views/ruby_llm/agents/executions/_execution.html.erb +0 -1
  32. data/app/views/ruby_llm/agents/executions/_filters.html.erb +51 -39
  33. data/app/views/ruby_llm/agents/executions/_list.html.erb +53 -195
  34. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +5 -20
  35. data/app/views/ruby_llm/agents/executions/index.html.erb +7 -83
  36. data/app/views/ruby_llm/agents/executions/show.html.erb +10 -20
  37. data/app/views/ruby_llm/agents/shared/_agent_type_badge.html.erb +2 -1
  38. data/app/views/ruby_llm/agents/shared/_doc_link.html.erb +12 -0
  39. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +3 -15
  40. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +1 -1
  41. data/app/views/ruby_llm/agents/shared/_select_dropdown.html.erb +1 -1
  42. data/app/views/ruby_llm/agents/shared/_sortable_header.html.erb +53 -0
  43. data/app/views/ruby_llm/agents/shared/_status_badge.html.erb +7 -0
  44. data/app/views/ruby_llm/agents/shared/_status_dot.html.erb +1 -1
  45. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +9 -35
  46. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -1
  47. data/app/views/ruby_llm/agents/tenants/index.html.erb +4 -1
  48. data/app/views/ruby_llm/agents/workflows/_step_performance.html.erb +7 -15
  49. data/app/views/ruby_llm/agents/workflows/_structure_dsl.html.erb +539 -0
  50. data/app/views/ruby_llm/agents/workflows/_workflow_diagram.html.erb +920 -0
  51. data/app/views/ruby_llm/agents/workflows/index.html.erb +179 -0
  52. data/app/views/ruby_llm/agents/workflows/show.html.erb +164 -139
  53. data/config/routes.rb +1 -1
  54. data/lib/generators/ruby_llm_agents/agent_generator.rb +6 -36
  55. data/lib/generators/ruby_llm_agents/background_remover_generator.rb +7 -37
  56. data/lib/generators/ruby_llm_agents/embedder_generator.rb +5 -38
  57. data/lib/generators/ruby_llm_agents/image_analyzer_generator.rb +7 -37
  58. data/lib/generators/ruby_llm_agents/image_editor_generator.rb +7 -37
  59. data/lib/generators/ruby_llm_agents/image_generator_generator.rb +8 -41
  60. data/lib/generators/ruby_llm_agents/image_pipeline_generator.rb +18 -46
  61. data/lib/generators/ruby_llm_agents/image_transformer_generator.rb +7 -37
  62. data/lib/generators/ruby_llm_agents/image_upscaler_generator.rb +7 -37
  63. data/lib/generators/ruby_llm_agents/image_variator_generator.rb +7 -37
  64. data/lib/generators/ruby_llm_agents/install_generator.rb +33 -56
  65. data/lib/generators/ruby_llm_agents/migrate_structure_generator.rb +480 -0
  66. data/lib/generators/ruby_llm_agents/restructure_generator.rb +2 -2
  67. data/lib/generators/ruby_llm_agents/speaker_generator.rb +8 -39
  68. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +5 -8
  69. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +40 -42
  70. data/lib/generators/ruby_llm_agents/templates/application_background_remover.rb.tt +20 -22
  71. data/lib/generators/ruby_llm_agents/templates/application_embedder.rb.tt +24 -26
  72. data/lib/generators/ruby_llm_agents/templates/application_image_analyzer.rb.tt +20 -22
  73. data/lib/generators/ruby_llm_agents/templates/application_image_editor.rb.tt +19 -17
  74. data/lib/generators/ruby_llm_agents/templates/application_image_generator.rb.tt +31 -33
  75. data/lib/generators/ruby_llm_agents/templates/application_image_pipeline.rb.tt +125 -127
  76. data/lib/generators/ruby_llm_agents/templates/application_image_transformer.rb.tt +20 -18
  77. data/lib/generators/ruby_llm_agents/templates/application_image_upscaler.rb.tt +19 -17
  78. data/lib/generators/ruby_llm_agents/templates/application_image_variator.rb.tt +19 -17
  79. data/lib/generators/ruby_llm_agents/templates/application_speaker.rb.tt +38 -40
  80. data/lib/generators/ruby_llm_agents/templates/application_transcriber.rb.tt +42 -44
  81. data/lib/generators/ruby_llm_agents/templates/application_workflow.rb.tt +48 -0
  82. data/lib/generators/ruby_llm_agents/templates/background_remover.rb.tt +19 -21
  83. data/lib/generators/ruby_llm_agents/templates/embedder.rb.tt +19 -21
  84. data/lib/generators/ruby_llm_agents/templates/image_analyzer.rb.tt +20 -22
  85. data/lib/generators/ruby_llm_agents/templates/image_editor.rb.tt +15 -17
  86. data/lib/generators/ruby_llm_agents/templates/image_generator.rb.tt +25 -27
  87. data/lib/generators/ruby_llm_agents/templates/image_pipeline.rb.tt +19 -21
  88. data/lib/generators/ruby_llm_agents/templates/image_transformer.rb.tt +20 -22
  89. data/lib/generators/ruby_llm_agents/templates/image_upscaler.rb.tt +17 -19
  90. data/lib/generators/ruby_llm_agents/templates/image_variator.rb.tt +15 -17
  91. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +87 -24
  92. data/lib/generators/ruby_llm_agents/templates/skills/BACKGROUND_REMOVERS.md.tt +21 -27
  93. data/lib/generators/ruby_llm_agents/templates/skills/EMBEDDERS.md.tt +46 -54
  94. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_ANALYZERS.md.tt +31 -39
  95. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_EDITORS.md.tt +22 -28
  96. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_GENERATORS.md.tt +53 -63
  97. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_PIPELINES.md.tt +46 -56
  98. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_TRANSFORMERS.md.tt +23 -31
  99. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_UPSCALERS.md.tt +22 -30
  100. data/lib/generators/ruby_llm_agents/templates/skills/IMAGE_VARIATORS.md.tt +23 -31
  101. data/lib/generators/ruby_llm_agents/templates/skills/SPEAKERS.md.tt +38 -46
  102. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +7 -7
  103. data/lib/generators/ruby_llm_agents/templates/skills/TRANSCRIBERS.md.tt +59 -71
  104. data/lib/generators/ruby_llm_agents/templates/skills/WORKFLOWS.md.tt +274 -23
  105. data/lib/generators/ruby_llm_agents/templates/speaker.rb.tt +29 -31
  106. data/lib/generators/ruby_llm_agents/templates/transcriber.rb.tt +28 -30
  107. data/lib/generators/ruby_llm_agents/transcriber_generator.rb +10 -43
  108. data/lib/ruby_llm/agents/core/configuration.rb +55 -43
  109. data/lib/ruby_llm/agents/core/version.rb +1 -1
  110. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +26 -0
  111. data/lib/ruby_llm/agents/pipeline.rb +69 -0
  112. data/lib/ruby_llm/agents/workflow/approval.rb +205 -0
  113. data/lib/ruby_llm/agents/workflow/approval_store.rb +179 -0
  114. data/lib/ruby_llm/agents/workflow/dsl/executor.rb +467 -0
  115. data/lib/ruby_llm/agents/workflow/dsl/input_schema.rb +244 -0
  116. data/lib/ruby_llm/agents/workflow/dsl/iteration_executor.rb +289 -0
  117. data/lib/ruby_llm/agents/workflow/dsl/parallel_group.rb +107 -0
  118. data/lib/ruby_llm/agents/workflow/dsl/route_builder.rb +150 -0
  119. data/lib/ruby_llm/agents/workflow/dsl/schedule_helpers.rb +187 -0
  120. data/lib/ruby_llm/agents/workflow/dsl/step_config.rb +352 -0
  121. data/lib/ruby_llm/agents/workflow/dsl/step_executor.rb +415 -0
  122. data/lib/ruby_llm/agents/workflow/dsl/wait_config.rb +257 -0
  123. data/lib/ruby_llm/agents/workflow/dsl/wait_executor.rb +317 -0
  124. data/lib/ruby_llm/agents/workflow/dsl.rb +576 -0
  125. data/lib/ruby_llm/agents/workflow/instrumentation.rb +2 -7
  126. data/lib/ruby_llm/agents/workflow/notifiers/base.rb +117 -0
  127. data/lib/ruby_llm/agents/workflow/notifiers/email.rb +117 -0
  128. data/lib/ruby_llm/agents/workflow/notifiers/slack.rb +180 -0
  129. data/lib/ruby_llm/agents/workflow/notifiers/webhook.rb +121 -0
  130. data/lib/ruby_llm/agents/workflow/notifiers.rb +70 -0
  131. data/lib/ruby_llm/agents/workflow/orchestrator.rb +190 -23
  132. data/lib/ruby_llm/agents/workflow/result.rb +202 -0
  133. data/lib/ruby_llm/agents/workflow/throttle_manager.rb +206 -0
  134. data/lib/ruby_llm/agents/workflow/wait_result.rb +213 -0
  135. metadata +37 -6
  136. data/app/views/ruby_llm/agents/dashboard/_execution_item.html.erb +0 -66
  137. data/lib/ruby_llm/agents/workflow/parallel.rb +0 -299
  138. data/lib/ruby_llm/agents/workflow/pipeline.rb +0 -306
  139. data/lib/ruby_llm/agents/workflow/router.rb +0 -429
@@ -0,0 +1,576 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ostruct"
4
+ require_relative "dsl/step_config"
5
+ require_relative "dsl/route_builder"
6
+ require_relative "dsl/parallel_group"
7
+ require_relative "dsl/input_schema"
8
+ require_relative "dsl/step_executor"
9
+ require_relative "dsl/iteration_executor"
10
+ require_relative "dsl/wait_config"
11
+ require_relative "dsl/wait_executor"
12
+ require_relative "dsl/schedule_helpers"
13
+
14
+ module RubyLLM
15
+ module Agents
16
+ class Workflow
17
+ # Refined DSL for declarative workflow definition
18
+ #
19
+ # This module provides a clean, expressive syntax for defining workflows
20
+ # with minimal boilerplate for common patterns while maintaining full
21
+ # flexibility for complex scenarios.
22
+ #
23
+ # @example Minimal workflow
24
+ # class SimpleWorkflow < RubyLLM::Agents::Workflow
25
+ # step :fetch, FetcherAgent
26
+ # step :process, ProcessorAgent
27
+ # step :save, SaverAgent
28
+ # end
29
+ #
30
+ # @example Full-featured workflow
31
+ # class OrderWorkflow < RubyLLM::Agents::Workflow
32
+ # description "Process customer orders end-to-end"
33
+ #
34
+ # input do
35
+ # required :order_id, String
36
+ # optional :priority, String, default: "normal"
37
+ # end
38
+ #
39
+ # step :fetch, FetcherAgent, timeout: 1.minute
40
+ # step :validate, ValidatorAgent
41
+ #
42
+ # step :process, on: -> { validate.tier } do |route|
43
+ # route.premium PremiumAgent
44
+ # route.standard StandardAgent
45
+ # route.default DefaultAgent
46
+ # end
47
+ #
48
+ # parallel do
49
+ # step :analyze, AnalyzerAgent
50
+ # step :summarize, SummarizerAgent
51
+ # end
52
+ #
53
+ # step :notify, NotifierAgent, if: :should_notify?
54
+ #
55
+ # private
56
+ #
57
+ # def should_notify?
58
+ # input.callback_url.present?
59
+ # end
60
+ # end
61
+ #
62
+ # @api public
63
+ module DSL
64
+ def self.included(base)
65
+ base.extend(ClassMethods)
66
+ base.include(InstanceMethods)
67
+ end
68
+
69
+ # Class-level DSL methods
70
+ module ClassMethods
71
+ # Returns the ordered list of steps/groups
72
+ #
73
+ # @return [Array<Symbol, ParallelGroup>]
74
+ def step_order
75
+ @step_order ||= []
76
+ end
77
+
78
+ # Returns step configurations
79
+ #
80
+ # @return [Hash<Symbol, StepConfig>]
81
+ def step_configs
82
+ @step_configs ||= {}
83
+ end
84
+
85
+ # Returns parallel groups
86
+ #
87
+ # @return [Array<ParallelGroup>]
88
+ def parallel_groups
89
+ @parallel_groups ||= []
90
+ end
91
+
92
+ # Returns wait step configurations
93
+ #
94
+ # @return [Array<WaitConfig>]
95
+ def wait_configs
96
+ @wait_configs ||= []
97
+ end
98
+
99
+ # Returns the input schema
100
+ #
101
+ # @return [InputSchema, nil]
102
+ def input_schema
103
+ @input_schema
104
+ end
105
+
106
+ # Returns the output schema
107
+ #
108
+ # @return [OutputSchema, nil]
109
+ def output_schema
110
+ @output_schema
111
+ end
112
+
113
+ # Inherits DSL configuration from parent class
114
+ def inherited(subclass)
115
+ super
116
+ subclass.instance_variable_set(:@step_order, step_order.dup)
117
+ subclass.instance_variable_set(:@step_configs, step_configs.dup)
118
+ subclass.instance_variable_set(:@parallel_groups, parallel_groups.dup)
119
+ subclass.instance_variable_set(:@wait_configs, wait_configs.dup)
120
+ subclass.instance_variable_set(:@input_schema, input_schema&.dup)
121
+ subclass.instance_variable_set(:@output_schema, output_schema&.dup)
122
+ subclass.instance_variable_set(:@lifecycle_hooks, @lifecycle_hooks&.dup || {})
123
+ end
124
+
125
+ # Defines a workflow step
126
+ #
127
+ # @param name [Symbol] Step identifier
128
+ # @param agent [Class, nil] Agent class to execute (optional if using block)
129
+ # @param desc [String, nil] Human-readable description
130
+ # @param options [Hash] Step options (timeout, retry, if, unless, etc.)
131
+ # @yield [route] Block for routing or custom logic
132
+ # @return [void]
133
+ #
134
+ # @example Minimal step
135
+ # step :validate, ValidatorAgent
136
+ #
137
+ # @example With options
138
+ # step :fetch, FetcherAgent, timeout: 30.seconds, retry: 3
139
+ #
140
+ # @example With routing
141
+ # step :process, on: -> { classify.type } do |r|
142
+ # r.typeA AgentA
143
+ # r.typeB AgentB
144
+ # r.default DefaultAgent
145
+ # end
146
+ #
147
+ # @example With custom block
148
+ # step :custom do
149
+ # skip! "No data" if input.data.empty?
150
+ # agent CustomAgent, data: transform(input.data)
151
+ # end
152
+ def step(name, agent = nil, desc = nil, **options, &block)
153
+ # Handle positional description
154
+ description = desc.is_a?(String) ? desc : nil
155
+ if desc.is_a?(Hash)
156
+ options = desc.merge(options)
157
+ end
158
+
159
+ config = StepConfig.new(
160
+ name: name,
161
+ agent: agent,
162
+ description: description,
163
+ options: options,
164
+ block: block
165
+ )
166
+
167
+ step_configs[name] = config
168
+
169
+ # Add to order if not in a parallel block
170
+ unless @_defining_parallel
171
+ step_order << name
172
+ end
173
+ end
174
+
175
+ # Defines a group of steps that execute in parallel
176
+ #
177
+ # @param name [Symbol, nil] Optional name for the group
178
+ # @param options [Hash] Group options (fail_fast, concurrency, timeout)
179
+ # @yield Block defining the parallel steps
180
+ # @return [void]
181
+ #
182
+ # @example Unnamed parallel group
183
+ # parallel do
184
+ # step :sentiment, SentimentAgent
185
+ # step :keywords, KeywordAgent
186
+ # end
187
+ #
188
+ # @example Named parallel group
189
+ # parallel :analysis do
190
+ # step :sentiment, SentimentAgent
191
+ # step :keywords, KeywordAgent
192
+ # end
193
+ def parallel(name = nil, **options, &block)
194
+ @_defining_parallel = true
195
+ previous_step_count = step_configs.size
196
+
197
+ # Execute the block to collect step definitions
198
+ instance_eval(&block)
199
+
200
+ # Find newly added steps
201
+ new_steps = step_configs.keys.last(step_configs.size - previous_step_count)
202
+
203
+ group = ParallelGroup.new(
204
+ name: name,
205
+ step_names: new_steps,
206
+ options: options
207
+ )
208
+
209
+ parallel_groups << group
210
+ step_order << group
211
+
212
+ @_defining_parallel = false
213
+ end
214
+
215
+ # Defines a simple delay wait step
216
+ #
217
+ # @param duration [ActiveSupport::Duration, Integer, Float] Duration to wait
218
+ # @param options [Hash] Wait options (if:, unless:)
219
+ # @return [void]
220
+ #
221
+ # @example Simple delay
222
+ # wait 5.seconds
223
+ #
224
+ # @example Conditional delay
225
+ # wait 5.seconds, if: :needs_cooldown?
226
+ def wait(duration, **options)
227
+ config = WaitConfig.new(type: :delay, duration: duration, **options)
228
+ wait_configs << config
229
+ step_order << config
230
+ end
231
+
232
+ # Defines a conditional wait step that polls until a condition is met
233
+ #
234
+ # @param condition [Proc, nil] Lambda that returns true when ready to proceed
235
+ # @param time [Proc, Time, nil] Time to wait until (for scheduled waits)
236
+ # @param options [Hash] Wait options (poll_interval:, timeout:, on_timeout:, backoff:)
237
+ # @yield Block as condition (alternative to condition param)
238
+ # @return [void]
239
+ #
240
+ # @example Wait until condition
241
+ # wait_until -> { payment.confirmed? }, poll_interval: 5.seconds, timeout: 10.minutes
242
+ #
243
+ # @example With block
244
+ # wait_until(poll_interval: 1.second) { order.ready? }
245
+ #
246
+ # @example Wait until specific time
247
+ # wait_until time: -> { next_weekday_at(9, 0) }
248
+ def wait_until(condition = nil, time: nil, **options, &block)
249
+ condition ||= block
250
+
251
+ if time
252
+ config = WaitConfig.new(type: :schedule, condition: time, **options)
253
+ else
254
+ config = WaitConfig.new(type: :until, condition: condition, **options)
255
+ end
256
+
257
+ wait_configs << config
258
+ step_order << config
259
+ end
260
+
261
+ # Defines a human-in-the-loop approval wait step
262
+ #
263
+ # @param name [Symbol] Identifier for the approval point
264
+ # @param options [Hash] Approval options (notify:, message:, timeout:, approvers:, etc.)
265
+ # @return [void]
266
+ #
267
+ # @example Simple approval
268
+ # wait_for :manager_approval
269
+ #
270
+ # @example With notifications and timeout
271
+ # wait_for :review,
272
+ # notify: [:email, :slack],
273
+ # message: -> { "Please review: #{draft.title}" },
274
+ # timeout: 24.hours,
275
+ # reminder_after: 4.hours
276
+ def wait_for(name, **options)
277
+ config = WaitConfig.new(type: :approval, name: name, **options)
278
+ wait_configs << config
279
+ step_order << config
280
+ end
281
+
282
+ # Defines the input schema
283
+ #
284
+ # @yield Block defining required and optional fields
285
+ # @return [void]
286
+ #
287
+ # @example
288
+ # input do
289
+ # required :order_id, String
290
+ # optional :priority, String, default: "normal"
291
+ # end
292
+ def input(&block)
293
+ @input_schema = InputSchema.new
294
+ @input_schema.instance_eval(&block)
295
+ end
296
+
297
+ # Defines the output schema
298
+ #
299
+ # @yield Block defining required and optional fields
300
+ # @return [void]
301
+ def output(&block)
302
+ @output_schema = OutputSchema.new
303
+ @output_schema.instance_eval(&block)
304
+ end
305
+
306
+ # Registers a lifecycle hook
307
+ #
308
+ # @param hook_name [Symbol] Hook type
309
+ # @param step_name [Symbol, nil] Specific step (nil for all)
310
+ # @param method_name [Symbol, nil] Method to call
311
+ # @yield Block to execute
312
+ # @return [void]
313
+ def register_hook(hook_name, step_name = nil, method_name = nil, &block)
314
+ @lifecycle_hooks ||= {}
315
+ @lifecycle_hooks[hook_name] ||= []
316
+ @lifecycle_hooks[hook_name] << {
317
+ step: step_name,
318
+ method: method_name,
319
+ block: block
320
+ }
321
+ end
322
+
323
+ # Hooks that run before the workflow starts
324
+ def before_workflow(method_name = nil, &block)
325
+ register_hook(:before_workflow, nil, method_name, &block)
326
+ end
327
+
328
+ # Hooks that run after the workflow completes
329
+ def after_workflow(method_name = nil, &block)
330
+ register_hook(:after_workflow, nil, method_name, &block)
331
+ end
332
+
333
+ # Hooks that run before each step
334
+ def before_step(step_name = nil, method_name = nil, &block)
335
+ register_hook(:before_step, step_name, method_name, &block)
336
+ end
337
+
338
+ # Hooks that run after each step
339
+ def after_step(step_name = nil, method_name = nil, &block)
340
+ register_hook(:after_step, step_name, method_name, &block)
341
+ end
342
+
343
+ # Hooks that run when a step fails
344
+ def on_step_failure(step_name = nil, method_name = nil, &block)
345
+ register_hook(:on_step_failure, step_name, method_name, &block)
346
+ end
347
+
348
+ # Hooks that run when a step starts
349
+ def on_step_start(method_name = nil, &block)
350
+ register_hook(:on_step_start, nil, method_name, &block)
351
+ end
352
+
353
+ # Hooks that run when a step completes
354
+ def on_step_complete(method_name = nil, &block)
355
+ register_hook(:on_step_complete, nil, method_name, &block)
356
+ end
357
+
358
+ # Hooks that run when a step errors
359
+ def on_step_error(method_name = nil, &block)
360
+ register_hook(:on_step_error, nil, method_name, &block)
361
+ end
362
+
363
+ # Returns lifecycle hooks
364
+ #
365
+ # @return [Hash]
366
+ def lifecycle_hooks
367
+ @lifecycle_hooks ||= {}
368
+ end
369
+
370
+ # Returns step metadata for UI display
371
+ #
372
+ # @return [Array<Hash>]
373
+ def step_metadata
374
+ step_order.flat_map do |item|
375
+ case item
376
+ when Symbol
377
+ config = step_configs[item]
378
+ [{
379
+ name: item,
380
+ agent: config.agent&.name,
381
+ description: config.description,
382
+ ui_label: config.ui_label || item.to_s.humanize,
383
+ optional: config.optional?,
384
+ timeout: config.timeout,
385
+ routing: config.routing?,
386
+ parallel: false,
387
+ workflow: config.workflow?,
388
+ iteration: config.iteration?,
389
+ iteration_concurrency: config.iteration_concurrency,
390
+ throttle: config.throttle,
391
+ rate_limit: config.rate_limit
392
+ }.compact]
393
+ when ParallelGroup
394
+ item.step_names.map do |step_name|
395
+ config = step_configs[step_name]
396
+ {
397
+ name: step_name,
398
+ agent: config.agent&.name,
399
+ description: config.description,
400
+ ui_label: config.ui_label || step_name.to_s.humanize,
401
+ optional: config.optional?,
402
+ timeout: config.timeout,
403
+ routing: config.routing?,
404
+ parallel: true,
405
+ parallel_group: item.name,
406
+ workflow: config.workflow?,
407
+ iteration: config.iteration?,
408
+ iteration_concurrency: config.iteration_concurrency,
409
+ throttle: config.throttle,
410
+ rate_limit: config.rate_limit
411
+ }.compact
412
+ end
413
+ when WaitConfig
414
+ [{
415
+ name: item.name || "wait_#{item.type}",
416
+ type: :wait,
417
+ wait_type: item.type,
418
+ ui_label: item.ui_label,
419
+ timeout: item.timeout,
420
+ parallel: false,
421
+ duration: item.duration,
422
+ poll_interval: item.poll_interval,
423
+ on_timeout: item.on_timeout,
424
+ notify: item.notify_channels,
425
+ approvers: item.approvers
426
+ }.compact]
427
+ end
428
+ end
429
+ end
430
+
431
+ # Returns the total number of steps
432
+ #
433
+ # @return [Integer]
434
+ def total_steps
435
+ step_configs.size
436
+ end
437
+
438
+ # Validates workflow configuration
439
+ #
440
+ # @return [Array<String>] Validation errors
441
+ def validate_configuration
442
+ errors = []
443
+
444
+ step_configs.each do |name, config|
445
+ if config.agent.nil? && !config.custom_block? && !config.routing?
446
+ errors << "Step :#{name} has no agent defined"
447
+ end
448
+
449
+ if config.routing?
450
+ builder = RouteBuilder.new
451
+ config.block.call(builder)
452
+ if builder.routes.empty? && builder.default.nil?
453
+ errors << "Step :#{name} has no routes defined"
454
+ end
455
+ end
456
+ end
457
+
458
+ errors
459
+ end
460
+ end
461
+
462
+ # Instance-level DSL methods
463
+ module InstanceMethods
464
+ # Returns the validated input
465
+ #
466
+ # @return [OpenStruct] Input with accessor methods
467
+ def input
468
+ @validated_input ||= begin
469
+ schema = self.class.input_schema
470
+ validated = schema ? schema.validate!(options) : options
471
+ OpenStruct.new(validated)
472
+ end
473
+ end
474
+
475
+ # Returns a step result by name
476
+ #
477
+ # @param name [Symbol] Step name
478
+ # @return [Result, nil]
479
+ def step_result(name)
480
+ @step_results[name]
481
+ end
482
+
483
+ # Returns all step results
484
+ #
485
+ # @return [Hash<Symbol, Result>]
486
+ attr_reader :step_results
487
+
488
+ # Provides dynamic access to step results
489
+ #
490
+ # Allows accessing step results as methods:
491
+ # validate.content # Returns the :validate step result's content
492
+ #
493
+ def method_missing(name, *args, &block)
494
+ if @step_results&.key?(name)
495
+ result = @step_results[name]
496
+ # Return a proxy that allows accessing content
497
+ StepResultProxy.new(result)
498
+ else
499
+ super
500
+ end
501
+ end
502
+
503
+ def respond_to_missing?(name, include_private = false)
504
+ @step_results&.key?(name) || super
505
+ end
506
+
507
+ protected
508
+
509
+ # Executes lifecycle hooks
510
+ #
511
+ # @param hook_name [Symbol] Hook type
512
+ # @param step_name [Symbol, nil] Current step name
513
+ # @param args [Array] Arguments to pass to hooks
514
+ def run_hooks(hook_name, step_name = nil, *args)
515
+ hooks = self.class.lifecycle_hooks[hook_name] || []
516
+
517
+ hooks.each do |hook|
518
+ # Skip if hook is for a specific step and this isn't it
519
+ next if hook[:step] && hook[:step] != step_name
520
+
521
+ if hook[:method]
522
+ send(hook[:method], *args)
523
+ elsif hook[:block]
524
+ instance_exec(*args, &hook[:block])
525
+ end
526
+ end
527
+ end
528
+ end
529
+
530
+ # Proxy for accessing step results
531
+ #
532
+ # Provides convenient access to step result content and methods.
533
+ #
534
+ # @api private
535
+ class StepResultProxy
536
+ def initialize(result)
537
+ @result = result
538
+ end
539
+
540
+ # Delegate content access
541
+ def content
542
+ @result&.content
543
+ end
544
+
545
+ # Allow hash-like access to content
546
+ def [](key)
547
+ content&.[](key)
548
+ end
549
+
550
+ # Allow method access to content hash keys
551
+ def method_missing(name, *args, &block)
552
+ if @result.respond_to?(name)
553
+ @result.send(name, *args, &block)
554
+ elsif content.is_a?(Hash) && content.key?(name)
555
+ content[name]
556
+ elsif content.is_a?(Hash) && content.key?(name.to_s)
557
+ content[name.to_s]
558
+ else
559
+ super
560
+ end
561
+ end
562
+
563
+ def respond_to_missing?(name, include_private = false)
564
+ @result.respond_to?(name) ||
565
+ (content.is_a?(Hash) && (content.key?(name) || content.key?(name.to_s))) ||
566
+ super
567
+ end
568
+
569
+ def to_h
570
+ content.is_a?(Hash) ? content : { value: content }
571
+ end
572
+ end
573
+ end
574
+ end
575
+ end
576
+ end
@@ -232,14 +232,9 @@ module RubyLLM
232
232
 
233
233
  # Returns the workflow type name for storage
234
234
  #
235
- # @return [String] The workflow type (pipeline, parallel, router)
235
+ # @return [String] The workflow type
236
236
  def workflow_type_name
237
- case self
238
- when Workflow::Pipeline then "pipeline"
239
- when Workflow::Parallel then "parallel"
240
- when Workflow::Router then "router"
241
- else "workflow"
242
- end
237
+ "workflow"
243
238
  end
244
239
 
245
240
  # Hook for subclasses to add custom metadata
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module Notifiers
7
+ # Base class for approval notification adapters
8
+ #
9
+ # Subclasses should implement the #notify and optionally #remind methods
10
+ # to send notifications through their respective channels.
11
+ #
12
+ # @example Creating a custom notifier
13
+ # class SmsNotifier < Base
14
+ # def notify(approval, message)
15
+ # # Send SMS via Twilio
16
+ # end
17
+ # end
18
+ #
19
+ # @api public
20
+ class Base
21
+ # Send a notification for an approval request
22
+ #
23
+ # @param approval [Approval] The approval request
24
+ # @param message [String] The notification message
25
+ # @return [Boolean] true if notification was sent successfully
26
+ def notify(approval, message)
27
+ raise NotImplementedError, "#{self.class}#notify must be implemented"
28
+ end
29
+
30
+ # Send a reminder for a pending approval
31
+ #
32
+ # @param approval [Approval] The approval request
33
+ # @param message [String] The reminder message
34
+ # @return [Boolean] true if reminder was sent successfully
35
+ def remind(approval, message)
36
+ notify(approval, "[Reminder] #{message}")
37
+ end
38
+
39
+ # Send an escalation notice
40
+ #
41
+ # @param approval [Approval] The approval request
42
+ # @param message [String] The escalation message
43
+ # @param escalate_to [String] The escalation target
44
+ # @return [Boolean] true if escalation was sent successfully
45
+ def escalate(approval, message, escalate_to:)
46
+ notify(approval, "[Escalation to #{escalate_to}] #{message}")
47
+ end
48
+ end
49
+
50
+ # Registry for notifier instances
51
+ #
52
+ # @api private
53
+ class Registry
54
+ class << self
55
+ # Returns the registered notifiers
56
+ #
57
+ # @return [Hash<Symbol, Base>]
58
+ def notifiers
59
+ @notifiers ||= {}
60
+ end
61
+
62
+ # Register a notifier
63
+ #
64
+ # @param name [Symbol] The notifier name
65
+ # @param notifier [Base] The notifier instance
66
+ # @return [void]
67
+ def register(name, notifier)
68
+ notifiers[name.to_sym] = notifier
69
+ end
70
+
71
+ # Get a registered notifier
72
+ #
73
+ # @param name [Symbol] The notifier name
74
+ # @return [Base, nil]
75
+ def get(name)
76
+ notifiers[name.to_sym]
77
+ end
78
+
79
+ # Check if a notifier is registered
80
+ #
81
+ # @param name [Symbol] The notifier name
82
+ # @return [Boolean]
83
+ def registered?(name)
84
+ notifiers.key?(name.to_sym)
85
+ end
86
+
87
+ # Send notification through specified channels
88
+ #
89
+ # @param approval [Approval] The approval request
90
+ # @param message [String] The notification message
91
+ # @param channels [Array<Symbol>] The notification channels
92
+ # @return [Hash<Symbol, Boolean>] Results per channel
93
+ def notify_all(approval, message, channels:)
94
+ results = {}
95
+ channels.each do |channel|
96
+ notifier = get(channel)
97
+ if notifier
98
+ results[channel] = notifier.notify(approval, message)
99
+ else
100
+ results[channel] = false
101
+ end
102
+ end
103
+ results
104
+ end
105
+
106
+ # Reset the registry (useful for testing)
107
+ #
108
+ # @return [void]
109
+ def reset!
110
+ @notifiers = {}
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end