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,415 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Executes individual workflow steps with retry, timeout, and error handling
8
+ #
9
+ # Responsible for:
10
+ # - Evaluating step conditions
11
+ # - Building step input
12
+ # - Executing agents with timeout
13
+ # - Handling retries with backoff
14
+ # - Executing fallback agents
15
+ # - Invoking error handlers
16
+ #
17
+ # @api private
18
+ class StepExecutor
19
+ attr_reader :workflow, :config
20
+
21
+ # @param workflow [Workflow] The workflow instance
22
+ # @param config [StepConfig] The step configuration
23
+ def initialize(workflow, config)
24
+ @workflow = workflow
25
+ @config = config
26
+ end
27
+
28
+ # Executes the step
29
+ #
30
+ # @param previous_result [Result, nil] Previous step result
31
+ # @yield [chunk] Streaming callback
32
+ # @return [Result, SkippedResult] Step result
33
+ def execute(previous_result = nil, &block)
34
+ # Check conditions
35
+ unless config.should_execute?(workflow)
36
+ return create_skipped_result("condition not met")
37
+ end
38
+
39
+ # Execute with timeout wrapper if configured
40
+ if config.timeout
41
+ execute_with_timeout(previous_result, &block)
42
+ else
43
+ execute_step(previous_result, &block)
44
+ end
45
+ end
46
+
47
+ private
48
+
49
+ def execute_with_timeout(previous_result, &block)
50
+ Timeout.timeout(config.timeout) do
51
+ execute_step(previous_result, &block)
52
+ end
53
+ rescue Timeout::Error => e
54
+ handle_step_error(e, previous_result, &block)
55
+ end
56
+
57
+ def execute_step(previous_result, &block)
58
+ execute_with_retry(previous_result, &block)
59
+ rescue StandardError => e
60
+ handle_step_error(e, previous_result, &block)
61
+ end
62
+
63
+ def execute_with_retry(previous_result, &block)
64
+ retry_config = config.retry_config
65
+ max_attempts = [retry_config[:max], 0].max + 1
66
+ attempts = 0
67
+
68
+ begin
69
+ attempts += 1
70
+ execute_agent_or_block(previous_result, &block)
71
+ rescue *retry_config[:on] => e
72
+ if attempts < max_attempts
73
+ sleep_with_backoff(retry_config, attempts)
74
+ retry
75
+ else
76
+ raise
77
+ end
78
+ end
79
+ end
80
+
81
+ def execute_agent_or_block(previous_result, &block)
82
+ if config.routing?
83
+ execute_routed_step(previous_result, &block)
84
+ elsif config.iteration?
85
+ execute_iteration_step(previous_result, &block)
86
+ elsif config.workflow?
87
+ execute_workflow_step(previous_result, &block)
88
+ elsif config.custom_block?
89
+ execute_block_step(previous_result)
90
+ else
91
+ execute_agent_step(previous_result, &block)
92
+ end
93
+ end
94
+
95
+ def execute_routed_step(previous_result, &block)
96
+ route = config.resolve_route(workflow)
97
+ agent_class = route[:agent]
98
+ route_options = route[:options] || {}
99
+
100
+ # Build input - use route-specific input if provided
101
+ step_input = if route_options[:input]
102
+ workflow.instance_exec(&route_options[:input])
103
+ else
104
+ config.resolve_input(workflow, previous_result)
105
+ end
106
+
107
+ # Execute the routed agent
108
+ workflow.send(:execute_agent, agent_class, step_input, step_name: config.name, &block)
109
+ end
110
+
111
+ def execute_block_step(previous_result)
112
+ # Create a block context that provides helper methods
113
+ context = BlockContext.new(workflow, config, previous_result)
114
+ result = context.instance_exec(&config.block)
115
+
116
+ # If block returns a Result, use it; otherwise wrap it
117
+ if result.is_a?(Result) || result.is_a?(Workflow::Result)
118
+ result
119
+ else
120
+ SimpleResult.new(content: result, success: true)
121
+ end
122
+ end
123
+
124
+ def execute_agent_step(previous_result, &block)
125
+ step_input = config.resolve_input(workflow, previous_result)
126
+ workflow.send(:execute_agent, config.agent, step_input, step_name: config.name, &block)
127
+ end
128
+
129
+ def execute_workflow_step(previous_result, &block)
130
+ step_input = config.resolve_input(workflow, previous_result)
131
+
132
+ # Build execution metadata for the sub-workflow
133
+ parent_metadata = {
134
+ parent_execution_id: workflow.execution_id,
135
+ root_execution_id: workflow.send(:root_execution_id),
136
+ workflow_id: workflow.workflow_id,
137
+ workflow_type: workflow.class.name,
138
+ workflow_step: config.name.to_s,
139
+ remaining_timeout: calculate_remaining_timeout,
140
+ remaining_cost_budget: calculate_remaining_cost_budget,
141
+ recursion_depth: (workflow.instance_variable_get(:@recursion_depth) || 0) + (self_referential_workflow? ? 1 : 0)
142
+ }.compact
143
+
144
+ # Merge execution metadata into input
145
+ merged_input = step_input.merge(
146
+ execution_metadata: parent_metadata.merge(step_input[:execution_metadata] || {})
147
+ )
148
+
149
+ # Execute the sub-workflow
150
+ result = config.agent.call(**merged_input, &block)
151
+
152
+ # Track accumulated cost
153
+ if result.respond_to?(:total_cost) && result.total_cost
154
+ workflow.instance_variable_set(
155
+ :@accumulated_cost,
156
+ (workflow.instance_variable_get(:@accumulated_cost) || 0.0) + result.total_cost
157
+ )
158
+ workflow.send(:check_cost_threshold!)
159
+ end
160
+
161
+ # Wrap in SubWorkflowResult for proper tracking
162
+ SubWorkflowResult.new(
163
+ content: result.content,
164
+ sub_workflow_result: result,
165
+ workflow_type: config.agent.name,
166
+ step_name: config.name
167
+ )
168
+ end
169
+
170
+ def execute_iteration_step(previous_result, &block)
171
+ executor = IterationExecutor.new(workflow, config, previous_result)
172
+ executor.execute(&block)
173
+ end
174
+
175
+ def calculate_remaining_timeout
176
+ workflow_timeout = workflow.class.timeout
177
+ return nil unless workflow_timeout
178
+
179
+ started_at = workflow.instance_variable_get(:@workflow_started_at)
180
+ return workflow_timeout unless started_at
181
+
182
+ elapsed = Time.current - started_at
183
+ remaining = workflow_timeout - elapsed
184
+ remaining > 0 ? remaining.to_i : 1
185
+ end
186
+
187
+ def calculate_remaining_cost_budget
188
+ max_cost = workflow.class.max_cost
189
+ return nil unless max_cost
190
+
191
+ accumulated = workflow.instance_variable_get(:@accumulated_cost) || 0.0
192
+ remaining = max_cost - accumulated
193
+ remaining > 0 ? remaining : 0.0
194
+ end
195
+
196
+ def self_referential_workflow?
197
+ config.agent == workflow.class
198
+ end
199
+
200
+ def handle_step_error(error, previous_result, &block)
201
+ # Try fallbacks first
202
+ if config.fallbacks.any?
203
+ fallback_result = try_fallbacks(previous_result, &block)
204
+ return fallback_result if fallback_result
205
+ end
206
+
207
+ # Try error handler
208
+ if config.error_handler
209
+ handler_result = invoke_error_handler(error)
210
+ return handler_result if handler_result.is_a?(Result) || handler_result.is_a?(Workflow::Result)
211
+ end
212
+
213
+ # If optional, return default or error result
214
+ if config.optional?
215
+ if config.default_value
216
+ return SimpleResult.new(content: config.default_value, success: true)
217
+ else
218
+ # Return an error result so status is set to "partial"
219
+ return Pipeline::ErrorResult.new(
220
+ step_name: config.name,
221
+ error_class: error.class.name,
222
+ error_message: error.message
223
+ )
224
+ end
225
+ end
226
+
227
+ # Re-raise for critical steps
228
+ raise
229
+ end
230
+
231
+ def try_fallbacks(previous_result, &block)
232
+ step_input = config.resolve_input(workflow, previous_result)
233
+
234
+ config.fallbacks.each do |fallback_agent|
235
+ begin
236
+ return workflow.send(:execute_agent, fallback_agent, step_input, step_name: config.name, &block)
237
+ rescue StandardError
238
+ # Continue to next fallback
239
+ next
240
+ end
241
+ end
242
+
243
+ nil
244
+ end
245
+
246
+ def invoke_error_handler(error)
247
+ handler = config.error_handler
248
+
249
+ case handler
250
+ when Symbol
251
+ workflow.send(handler, error)
252
+ when Proc
253
+ workflow.instance_exec(error, &handler)
254
+ end
255
+ end
256
+
257
+ def sleep_with_backoff(retry_config, attempt)
258
+ base_delay = retry_config[:delay] || 1
259
+
260
+ delay = case retry_config[:backoff]
261
+ when :exponential
262
+ base_delay * (2**(attempt - 1))
263
+ when :linear
264
+ base_delay * attempt
265
+ else
266
+ base_delay
267
+ end
268
+
269
+ sleep(delay)
270
+ end
271
+
272
+ def create_skipped_result(reason)
273
+ SkippedResult.new(config.name, reason: reason)
274
+ end
275
+ end
276
+
277
+ # Context for executing custom block steps
278
+ #
279
+ # Provides helper methods available inside step blocks.
280
+ #
281
+ # @api private
282
+ class BlockContext
283
+ def initialize(workflow, config, previous_result)
284
+ @workflow = workflow
285
+ @config = config
286
+ @previous_result = previous_result
287
+ end
288
+
289
+ # Executes an agent within the block
290
+ #
291
+ # @param agent_class [Class] Agent to execute
292
+ # @param input [Hash] Input for the agent
293
+ # @return [Result] Agent result
294
+ def agent(agent_class, **input)
295
+ @workflow.send(:execute_agent, agent_class, input, step_name: @config.name)
296
+ end
297
+
298
+ # Skips the current step
299
+ #
300
+ # @param reason [String] Skip reason
301
+ # @param default [Object] Default value to use
302
+ # @raise [StepSkipped]
303
+ def skip!(reason = nil, default: nil)
304
+ throw :skip_step, { skipped: true, reason: reason, default: default }
305
+ end
306
+
307
+ # Halts the workflow successfully
308
+ #
309
+ # @param result [Hash] Final result
310
+ # @raise [WorkflowHalted]
311
+ def halt!(result = {})
312
+ throw :halt_workflow, { halted: true, result: result }
313
+ end
314
+
315
+ # Fails the current step
316
+ #
317
+ # @param message [String] Error message
318
+ # @raise [StepFailedError]
319
+ def fail!(message)
320
+ raise StepFailedError, message
321
+ end
322
+
323
+ # Triggers a retry of the current step
324
+ #
325
+ # @param reason [String] Retry reason
326
+ # @raise [RetryStep]
327
+ def retry!(reason = nil)
328
+ raise RetryStep, reason
329
+ end
330
+
331
+ # Access workflow input
332
+ def input
333
+ @workflow.input
334
+ end
335
+
336
+ # Access previous step result
337
+ def previous
338
+ @previous_result
339
+ end
340
+
341
+ # Delegate missing methods to workflow (for accessing step results)
342
+ def method_missing(name, *args, &block)
343
+ if @workflow.respond_to?(name, true)
344
+ @workflow.send(name, *args, &block)
345
+ else
346
+ super
347
+ end
348
+ end
349
+
350
+ def respond_to_missing?(name, include_private = false)
351
+ @workflow.respond_to?(name, include_private) || super
352
+ end
353
+ end
354
+
355
+ # Simple result wrapper for block steps
356
+ #
357
+ # @api private
358
+ class SimpleResult
359
+ attr_reader :content
360
+
361
+ def initialize(content:, success: true)
362
+ @content = content
363
+ @success = success
364
+ end
365
+
366
+ def success?
367
+ @success
368
+ end
369
+
370
+ def error?
371
+ !@success
372
+ end
373
+
374
+ def input_tokens
375
+ 0
376
+ end
377
+
378
+ def output_tokens
379
+ 0
380
+ end
381
+
382
+ def total_tokens
383
+ 0
384
+ end
385
+
386
+ def cached_tokens
387
+ 0
388
+ end
389
+
390
+ def input_cost
391
+ 0.0
392
+ end
393
+
394
+ def output_cost
395
+ 0.0
396
+ end
397
+
398
+ def total_cost
399
+ 0.0
400
+ end
401
+
402
+ def to_h
403
+ { content: content, success: success? }
404
+ end
405
+ end
406
+
407
+ # Error raised when a step explicitly fails
408
+ class StepFailedError < StandardError; end
409
+
410
+ # Error to trigger step retry
411
+ class RetryStep < StandardError; end
412
+ end
413
+ end
414
+ end
415
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ module DSL
7
+ # Configuration object for a workflow wait step
8
+ #
9
+ # Holds all the configuration options for a wait step including
10
+ # the type (delay, until, schedule, approval), duration, conditions,
11
+ # timeout settings, and notification options.
12
+ #
13
+ # @example Simple delay
14
+ # WaitConfig.new(type: :delay, duration: 5.seconds)
15
+ #
16
+ # @example Conditional wait
17
+ # WaitConfig.new(
18
+ # type: :until,
19
+ # condition: -> { payment.confirmed? },
20
+ # poll_interval: 5.seconds,
21
+ # timeout: 10.minutes
22
+ # )
23
+ #
24
+ # @example Human approval
25
+ # WaitConfig.new(
26
+ # type: :approval,
27
+ # name: :manager_approval,
28
+ # notify: [:email, :slack],
29
+ # timeout: 24.hours
30
+ # )
31
+ #
32
+ # @api private
33
+ class WaitConfig
34
+ TYPES = %i[delay until schedule approval].freeze
35
+
36
+ attr_reader :type, :duration, :condition, :name, :options
37
+
38
+ # @param type [Symbol] Wait type (:delay, :until, :schedule, :approval)
39
+ # @param duration [ActiveSupport::Duration, Integer, nil] Duration for delay
40
+ # @param condition [Proc, nil] Condition for until/schedule waits
41
+ # @param name [Symbol, nil] Name for approval waits
42
+ # @param options [Hash] Additional options
43
+ def initialize(type:, duration: nil, condition: nil, name: nil, **options)
44
+ raise ArgumentError, "Unknown wait type: #{type}" unless TYPES.include?(type)
45
+
46
+ @type = type
47
+ @duration = duration
48
+ @condition = condition
49
+ @name = name
50
+ @options = options
51
+ end
52
+
53
+ # Returns whether this is a simple delay
54
+ #
55
+ # @return [Boolean]
56
+ def delay?
57
+ type == :delay
58
+ end
59
+
60
+ # Returns whether this is a conditional wait
61
+ #
62
+ # @return [Boolean]
63
+ def conditional?
64
+ type == :until
65
+ end
66
+
67
+ # Returns whether this is a scheduled wait
68
+ #
69
+ # @return [Boolean]
70
+ def scheduled?
71
+ type == :schedule
72
+ end
73
+
74
+ # Returns whether this is an approval wait
75
+ #
76
+ # @return [Boolean]
77
+ def approval?
78
+ type == :approval
79
+ end
80
+
81
+ # Returns the poll interval for conditional waits
82
+ #
83
+ # @return [ActiveSupport::Duration, Integer] Default: 1 second
84
+ def poll_interval
85
+ options[:poll_interval] || 1
86
+ end
87
+
88
+ # Returns the timeout for the wait
89
+ #
90
+ # @return [ActiveSupport::Duration, Integer, nil]
91
+ def timeout
92
+ options[:timeout]
93
+ end
94
+
95
+ # Returns the action to take on timeout
96
+ #
97
+ # @return [Symbol] :fail, :continue, or :skip_next (default: :fail)
98
+ def on_timeout
99
+ options[:on_timeout] || :fail
100
+ end
101
+
102
+ # Returns the backoff multiplier for exponential backoff
103
+ #
104
+ # @return [Numeric, nil]
105
+ def backoff
106
+ options[:backoff]
107
+ end
108
+
109
+ # Returns the maximum poll interval when using backoff
110
+ #
111
+ # @return [ActiveSupport::Duration, Integer, nil]
112
+ def max_interval
113
+ options[:max_interval]
114
+ end
115
+
116
+ # Returns whether this wait uses exponential backoff
117
+ #
118
+ # @return [Boolean]
119
+ def exponential_backoff?
120
+ backoff.present?
121
+ end
122
+
123
+ # Returns the notification channels for approval waits
124
+ #
125
+ # @return [Array<Symbol>]
126
+ def notify_channels
127
+ Array(options[:notify])
128
+ end
129
+
130
+ # Returns the message for approval notifications
131
+ #
132
+ # @return [String, Proc, nil]
133
+ def message
134
+ options[:message]
135
+ end
136
+
137
+ # Returns the reminder interval
138
+ #
139
+ # @return [ActiveSupport::Duration, Integer, nil]
140
+ def reminder_after
141
+ options[:reminder_after]
142
+ end
143
+
144
+ # Returns the reminder repeat interval
145
+ #
146
+ # @return [ActiveSupport::Duration, Integer, nil]
147
+ def reminder_interval
148
+ options[:reminder_interval]
149
+ end
150
+
151
+ # Returns the escalation target on timeout
152
+ #
153
+ # @return [Symbol, nil]
154
+ def escalate_to
155
+ options[:escalate_to]
156
+ end
157
+
158
+ # Returns the list of approvers
159
+ #
160
+ # @return [Array<String, Symbol>]
161
+ def approvers
162
+ Array(options[:approvers])
163
+ end
164
+
165
+ # Returns the timezone for scheduled waits
166
+ #
167
+ # @return [String, nil]
168
+ def timezone
169
+ options[:timezone]
170
+ end
171
+
172
+ # Returns the condition for executing this wait
173
+ #
174
+ # @return [Symbol, Proc, nil]
175
+ def if_condition
176
+ options[:if]
177
+ end
178
+
179
+ # Returns the negative condition for executing this wait
180
+ #
181
+ # @return [Symbol, Proc, nil]
182
+ def unless_condition
183
+ options[:unless]
184
+ end
185
+
186
+ # Evaluates whether this wait should execute
187
+ #
188
+ # @param workflow [Workflow] The workflow instance
189
+ # @return [Boolean]
190
+ def should_execute?(workflow)
191
+ passes_if = if_condition.nil? || evaluate_condition(workflow, if_condition)
192
+ passes_unless = unless_condition.nil? || !evaluate_condition(workflow, unless_condition)
193
+ passes_if && passes_unless
194
+ end
195
+
196
+ # Returns a UI-friendly label for this wait
197
+ #
198
+ # @return [String]
199
+ def ui_label
200
+ case type
201
+ when :delay
202
+ "Wait #{format_duration(duration)}"
203
+ when :until
204
+ "Wait until condition"
205
+ when :schedule
206
+ "Wait until scheduled time"
207
+ when :approval
208
+ "Awaiting #{name || 'approval'}"
209
+ end
210
+ end
211
+
212
+ # Converts to hash for serialization
213
+ #
214
+ # @return [Hash]
215
+ def to_h
216
+ {
217
+ type: type,
218
+ duration: duration,
219
+ name: name,
220
+ poll_interval: poll_interval,
221
+ timeout: timeout,
222
+ on_timeout: on_timeout,
223
+ backoff: backoff,
224
+ max_interval: max_interval,
225
+ notify: notify_channels,
226
+ approvers: approvers,
227
+ ui_label: ui_label
228
+ }.compact
229
+ end
230
+
231
+ private
232
+
233
+ def evaluate_condition(workflow, condition)
234
+ case condition
235
+ when Symbol then workflow.send(condition)
236
+ when Proc then workflow.instance_exec(&condition)
237
+ else condition
238
+ end
239
+ end
240
+
241
+ def format_duration(dur)
242
+ return "unknown" unless dur
243
+
244
+ seconds = dur.respond_to?(:to_i) ? dur.to_i : dur
245
+ if seconds >= 3600
246
+ "#{seconds / 3600}h"
247
+ elsif seconds >= 60
248
+ "#{seconds / 60}m"
249
+ else
250
+ "#{seconds}s"
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end