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
@@ -3,32 +3,62 @@
3
3
  require_relative "result"
4
4
  require_relative "instrumentation"
5
5
  require_relative "thread_pool"
6
- require_relative "pipeline"
7
- require_relative "parallel"
8
- require_relative "router"
6
+ require_relative "dsl"
7
+ require_relative "dsl/executor"
9
8
 
10
9
  module RubyLLM
11
10
  module Agents
12
11
  # Base class for workflow orchestration
13
12
  #
14
13
  # Provides shared functionality for composing multiple agents into
15
- # coordinated workflows. Subclasses implement specific patterns:
16
- # - Pipeline: Sequential execution with data flowing between steps
17
- # - Parallel: Concurrent execution with result aggregation
18
- # - Router: Conditional dispatch based on classification
14
+ # coordinated workflows using the DSL:
15
+ # - Sequential steps with data flowing between them
16
+ # - Parallel execution with result aggregation
17
+ # - Conditional routing based on step results
19
18
  #
20
- # @example Creating a custom workflow
21
- # class MyWorkflow < RubyLLM::Agents::Workflow
22
- # version "1.0"
23
- # # ... workflow-specific DSL
19
+ # @example Minimal workflow
20
+ # class SimpleWorkflow < RubyLLM::Agents::Workflow
21
+ # step :fetch, FetcherAgent
22
+ # step :process, ProcessorAgent
23
+ # step :save, SaverAgent
24
+ # end
25
+ #
26
+ # @example Full-featured workflow
27
+ # class OrderWorkflow < RubyLLM::Agents::Workflow
28
+ # description "Process customer orders end-to-end"
29
+ #
30
+ # input do
31
+ # required :order_id, String
32
+ # optional :priority, String, default: "normal"
33
+ # end
34
+ #
35
+ # step :fetch, FetcherAgent, timeout: 1.minute
36
+ # step :validate, ValidatorAgent
37
+ #
38
+ # step :process, on: -> { validate.tier } do |route|
39
+ # route.premium PremiumAgent
40
+ # route.standard StandardAgent
41
+ # route.default DefaultAgent
42
+ # end
43
+ #
44
+ # parallel do
45
+ # step :analyze, AnalyzerAgent
46
+ # step :summarize, SummarizerAgent
47
+ # end
48
+ #
49
+ # step :notify, NotifierAgent, if: :should_notify?
50
+ #
51
+ # private
52
+ #
53
+ # def should_notify?
54
+ # input.callback_url.present?
55
+ # end
24
56
  # end
25
57
  #
26
- # @see RubyLLM::Agents::Workflow::Pipeline
27
- # @see RubyLLM::Agents::Workflow::Parallel
28
- # @see RubyLLM::Agents::Workflow::Router
29
58
  # @api public
30
59
  class Workflow
31
60
  include Workflow::Instrumentation
61
+ include Workflow::DSL
32
62
 
33
63
  class << self
34
64
  # @!attribute [rw] version
@@ -47,6 +77,10 @@ module RubyLLM
47
77
  # @return [String, nil] Description of the workflow
48
78
  attr_accessor :_description
49
79
 
80
+ # @!attribute [rw] max_recursion_depth
81
+ # @return [Integer] Maximum recursion depth for self-referential workflows
82
+ attr_accessor :_max_recursion_depth
83
+
50
84
  # Sets or returns the workflow version
51
85
  #
52
86
  # @param value [String, nil] Version string to set
@@ -95,13 +129,34 @@ module RubyLLM
95
129
  end
96
130
  end
97
131
 
132
+ # Sets or returns the maximum recursion depth
133
+ #
134
+ # @param value [Integer, nil] Max depth to set
135
+ # @return [Integer] The current max recursion depth (default: 10)
136
+ def max_recursion_depth(value = nil)
137
+ if value
138
+ self._max_recursion_depth = value.to_i
139
+ else
140
+ _max_recursion_depth || 10
141
+ end
142
+ end
143
+
98
144
  # Factory method to instantiate and execute a workflow
99
145
  #
146
+ # Supports both hash and keyword argument styles:
147
+ # MyWorkflow.call(order_id: "123")
148
+ # MyWorkflow.call({ order_id: "123" })
149
+ #
150
+ # @param input [Hash] Input hash (optional)
100
151
  # @param kwargs [Hash] Parameters to pass to the workflow
101
152
  # @yield [chunk] Optional block for streaming support
102
153
  # @return [WorkflowResult] The workflow result with aggregate metrics
103
- def call(**kwargs, &block)
104
- new(**kwargs).call(&block)
154
+ def call(input = nil, **kwargs, &block)
155
+ # Support both call(hash) and call(**kwargs) patterns
156
+ merged_input = input.is_a?(Hash) ? input.merge(kwargs) : kwargs
157
+ # Pass input to constructor to maintain backward compatibility with
158
+ # legacy subclasses that override call without arguments
159
+ new(**merged_input).call(&block)
105
160
  end
106
161
  end
107
162
 
@@ -117,6 +172,14 @@ module RubyLLM
117
172
  # @return [Integer, nil] The ID of the root execution record
118
173
  attr_reader :execution_id
119
174
 
175
+ # @!attribute [r] step_results
176
+ # @return [Hash<Symbol, Result>] Results from executed steps
177
+ attr_reader :step_results
178
+
179
+ # @!attribute [r] recursion_depth
180
+ # @return [Integer] Current recursion depth for self-referential workflows
181
+ attr_reader :recursion_depth
182
+
120
183
  # Creates a new workflow instance
121
184
  #
122
185
  # @param kwargs [Hash] Parameters for the workflow
@@ -126,17 +189,94 @@ module RubyLLM
126
189
  @execution_id = nil
127
190
  @accumulated_cost = 0.0
128
191
  @step_results = {}
192
+ @validated_input = nil
193
+
194
+ # Extract recursion context from execution_metadata
195
+ metadata = kwargs[:execution_metadata] || {}
196
+ @recursion_depth = metadata[:recursion_depth] || 0
197
+ @remaining_timeout = metadata[:remaining_timeout]
198
+ @remaining_cost_budget = metadata[:remaining_cost_budget]
199
+
200
+ # Check recursion depth
201
+ check_recursion_depth!
129
202
  end
130
203
 
131
204
  # Executes the workflow
132
205
  #
133
- # @abstract Subclasses must implement this method
206
+ # When using the new DSL with `step` declarations, this method
207
+ # automatically executes the workflow using the DSL executor.
208
+ # For legacy subclasses (Pipeline, Parallel, Router), this raises
209
+ # NotImplementedError to be overridden.
210
+ #
211
+ # Supports both hash and keyword argument styles:
212
+ # workflow.call(order_id: "123")
213
+ # workflow.call({ order_id: "123" })
214
+ #
215
+ # @param input [Hash] Input hash (optional)
216
+ # @param kwargs [Hash] Keyword arguments for input
134
217
  # @yield [chunk] Optional block for streaming support
135
218
  # @return [WorkflowResult] The workflow result
136
- def call(&block)
137
- raise NotImplementedError, "#{self.class} must implement #call"
219
+ def call(input = nil, **kwargs, &block)
220
+ # Merge input sources: constructor options, hash arg, keyword args
221
+ merged_input = @options.merge(input.is_a?(Hash) ? input : {}).merge(kwargs)
222
+ @options = merged_input
223
+
224
+ # Use DSL executor if steps are defined with the new DSL
225
+ if self.class.step_configs.any?
226
+ instrument_workflow do
227
+ execute_with_dsl(&block)
228
+ end
229
+ else
230
+ raise NotImplementedError, "#{self.class} must implement #call or define steps"
231
+ end
232
+ end
233
+
234
+ # Validates workflow input and executes a dry run
235
+ #
236
+ # Returns information about the workflow without executing agents.
237
+ # Supports both positional hash and keyword arguments.
238
+ #
239
+ # @param input_hash [Hash] Input hash (optional)
240
+ # @param input [Hash] Keyword arguments for input
241
+ # @return [Hash] Validation results and workflow structure
242
+ def self.dry_run(input_hash = nil, **input)
243
+ input = input_hash.merge(input) if input_hash.is_a?(Hash)
244
+ errors = []
245
+
246
+ # Validate input if schema defined
247
+ if input_schema
248
+ begin
249
+ input_schema.validate!(input)
250
+ rescue DSL::InputSchema::ValidationError => e
251
+ errors.concat(e.errors)
252
+ end
253
+ end
254
+
255
+ # Validate configuration
256
+ errors.concat(validate_configuration)
257
+
258
+ {
259
+ valid: errors.empty?,
260
+ input_errors: errors,
261
+ steps: step_metadata.map { |s| s[:name] },
262
+ agents: step_metadata.map { |s| s[:agent] }.compact,
263
+ parallel_groups: parallel_groups.map(&:to_h),
264
+ warnings: validate_configuration
265
+ }
266
+ end
267
+
268
+ private
269
+
270
+ # Executes the workflow using the DSL executor
271
+ #
272
+ # @return [WorkflowResult] The workflow result
273
+ def execute_with_dsl(&block)
274
+ executor = DSL::Executor.new(self)
275
+ executor.execute(&block)
138
276
  end
139
277
 
278
+ public
279
+
140
280
  protected
141
281
 
142
282
  # Executes a single agent within the workflow context
@@ -191,13 +331,29 @@ module RubyLLM
191
331
  #
192
332
  # @raise [WorkflowCostExceededError] If cost exceeds max_cost
193
333
  def check_cost_threshold!
194
- return unless self.class.max_cost
195
- return if @accumulated_cost <= self.class.max_cost
334
+ # Check against remaining budget if we're in a sub-workflow
335
+ effective_max = @remaining_cost_budget || self.class.max_cost
336
+ return unless effective_max
337
+ return if @accumulated_cost <= effective_max
196
338
 
197
339
  raise WorkflowCostExceededError.new(
198
- "Workflow cost ($#{@accumulated_cost.round(4)}) exceeded maximum ($#{self.class.max_cost})",
340
+ "Workflow cost ($#{@accumulated_cost.round(4)}) exceeded maximum ($#{effective_max})",
199
341
  accumulated_cost: @accumulated_cost,
200
- max_cost: self.class.max_cost
342
+ max_cost: effective_max
343
+ )
344
+ end
345
+
346
+ # Checks if recursion depth exceeds the maximum
347
+ #
348
+ # @raise [RecursionDepthExceededError] If depth exceeds max
349
+ def check_recursion_depth!
350
+ max_depth = self.class.max_recursion_depth
351
+ return if @recursion_depth <= max_depth
352
+
353
+ raise RecursionDepthExceededError.new(
354
+ "Workflow recursion depth (#{@recursion_depth}) exceeded maximum (#{max_depth})",
355
+ current_depth: @recursion_depth,
356
+ max_depth: max_depth
201
357
  )
202
358
  end
203
359
 
@@ -245,5 +401,16 @@ module RubyLLM
245
401
  @max_cost = max_cost
246
402
  end
247
403
  end
404
+
405
+ # Error raised when workflow recursion depth exceeds the maximum
406
+ class RecursionDepthExceededError < StandardError
407
+ attr_reader :current_depth, :max_depth
408
+
409
+ def initialize(message, current_depth:, max_depth:)
410
+ super(message)
411
+ @current_depth = current_depth
412
+ @max_depth = max_depth
413
+ end
414
+ end
248
415
  end
249
416
  end
@@ -385,6 +385,208 @@ module RubyLLM
385
385
  { skipped: true, step_name: step_name, reason: reason }
386
386
  end
387
387
  end
388
+
389
+ # Result wrapper for sub-workflow execution
390
+ #
391
+ # Wraps a nested workflow result while providing access to
392
+ # aggregate metrics and the underlying workflow result.
393
+ #
394
+ # @api public
395
+ class SubWorkflowResult
396
+ attr_reader :content, :sub_workflow_result, :workflow_type, :step_name
397
+
398
+ def initialize(content:, sub_workflow_result:, workflow_type:, step_name:)
399
+ @content = content
400
+ @sub_workflow_result = sub_workflow_result
401
+ @workflow_type = workflow_type
402
+ @step_name = step_name
403
+ end
404
+
405
+ def success?
406
+ sub_workflow_result.respond_to?(:success?) ? sub_workflow_result.success? : true
407
+ end
408
+
409
+ def error?
410
+ sub_workflow_result.respond_to?(:error?) ? sub_workflow_result.error? : false
411
+ end
412
+
413
+ def skipped?
414
+ false
415
+ end
416
+
417
+ # Delegate metrics to sub-workflow result
418
+ def input_tokens
419
+ sub_workflow_result.respond_to?(:input_tokens) ? sub_workflow_result.input_tokens : 0
420
+ end
421
+
422
+ def output_tokens
423
+ sub_workflow_result.respond_to?(:output_tokens) ? sub_workflow_result.output_tokens : 0
424
+ end
425
+
426
+ def total_tokens
427
+ input_tokens + output_tokens
428
+ end
429
+
430
+ def cached_tokens
431
+ sub_workflow_result.respond_to?(:cached_tokens) ? sub_workflow_result.cached_tokens : 0
432
+ end
433
+
434
+ def input_cost
435
+ sub_workflow_result.respond_to?(:input_cost) ? sub_workflow_result.input_cost : 0.0
436
+ end
437
+
438
+ def output_cost
439
+ sub_workflow_result.respond_to?(:output_cost) ? sub_workflow_result.output_cost : 0.0
440
+ end
441
+
442
+ def total_cost
443
+ sub_workflow_result.respond_to?(:total_cost) ? sub_workflow_result.total_cost : 0.0
444
+ end
445
+
446
+ # Access sub-workflow steps
447
+ def steps
448
+ sub_workflow_result.respond_to?(:steps) ? sub_workflow_result.steps : {}
449
+ end
450
+
451
+ def to_h
452
+ {
453
+ content: content,
454
+ workflow_type: workflow_type,
455
+ step_name: step_name,
456
+ sub_workflow: sub_workflow_result.respond_to?(:to_h) ? sub_workflow_result.to_h : sub_workflow_result,
457
+ input_tokens: input_tokens,
458
+ output_tokens: output_tokens,
459
+ total_cost: total_cost
460
+ }
461
+ end
462
+
463
+ # Delegate hash access to content
464
+ def [](key)
465
+ content.is_a?(Hash) ? content[key] : nil
466
+ end
467
+
468
+ def dig(*keys)
469
+ content.is_a?(Hash) ? content.dig(*keys) : nil
470
+ end
471
+ end
472
+
473
+ # Result wrapper for iteration execution
474
+ #
475
+ # Tracks results for each item in an iteration with
476
+ # aggregate success/failure counts and metrics.
477
+ #
478
+ # @api public
479
+ class IterationResult
480
+ attr_reader :step_name, :item_results, :errors
481
+
482
+ def initialize(step_name:, item_results: [], errors: {})
483
+ @step_name = step_name
484
+ @item_results = item_results
485
+ @errors = errors
486
+ end
487
+
488
+ def content
489
+ item_results.map do |result|
490
+ result.respond_to?(:content) ? result.content : result
491
+ end
492
+ end
493
+
494
+ def success?
495
+ errors.empty? && item_results.all? do |r|
496
+ !r.respond_to?(:error?) || !r.error?
497
+ end
498
+ end
499
+
500
+ def error?
501
+ !success?
502
+ end
503
+
504
+ def partial?
505
+ errors.any? && item_results.any? do |r|
506
+ !r.respond_to?(:error?) || !r.error?
507
+ end
508
+ end
509
+
510
+ def skipped?
511
+ false
512
+ end
513
+
514
+ def successful_count
515
+ item_results.count { |r| !r.respond_to?(:error?) || !r.error? }
516
+ end
517
+
518
+ def failed_count
519
+ errors.size + item_results.count { |r| r.respond_to?(:error?) && r.error? }
520
+ end
521
+
522
+ def total_count
523
+ item_results.size + errors.size
524
+ end
525
+
526
+ # Aggregate metrics across all items
527
+ def input_tokens
528
+ item_results.sum { |r| r.respond_to?(:input_tokens) ? r.input_tokens : 0 }
529
+ end
530
+
531
+ def output_tokens
532
+ item_results.sum { |r| r.respond_to?(:output_tokens) ? r.output_tokens : 0 }
533
+ end
534
+
535
+ def total_tokens
536
+ input_tokens + output_tokens
537
+ end
538
+
539
+ def cached_tokens
540
+ item_results.sum { |r| r.respond_to?(:cached_tokens) ? r.cached_tokens : 0 }
541
+ end
542
+
543
+ def input_cost
544
+ item_results.sum { |r| r.respond_to?(:input_cost) ? r.input_cost : 0.0 }
545
+ end
546
+
547
+ def output_cost
548
+ item_results.sum { |r| r.respond_to?(:output_cost) ? r.output_cost : 0.0 }
549
+ end
550
+
551
+ def total_cost
552
+ item_results.sum { |r| r.respond_to?(:total_cost) ? r.total_cost : 0.0 }
553
+ end
554
+
555
+ def to_h
556
+ {
557
+ step_name: step_name,
558
+ total_count: total_count,
559
+ successful_count: successful_count,
560
+ failed_count: failed_count,
561
+ success: success?,
562
+ items: item_results.map { |r| r.respond_to?(:to_h) ? r.to_h : r },
563
+ errors: errors.transform_values { |e| { class: e.class.name, message: e.message } },
564
+ input_tokens: input_tokens,
565
+ output_tokens: output_tokens,
566
+ total_cost: total_cost
567
+ }
568
+ end
569
+
570
+ # Access individual item results by index
571
+ def [](index)
572
+ item_results[index]
573
+ end
574
+
575
+ def each(&block)
576
+ item_results.each(&block)
577
+ end
578
+
579
+ def map(&block)
580
+ item_results.map(&block)
581
+ end
582
+
583
+ include Enumerable
584
+
585
+ # Empty iteration result factory
586
+ def self.empty(step_name)
587
+ new(step_name: step_name, item_results: [], errors: {})
588
+ end
589
+ end
388
590
  end
389
591
  end
390
592
  end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ # Manages rate limiting and throttling for workflow steps
7
+ #
8
+ # Provides two modes of rate limiting:
9
+ # 1. Throttle: Ensures minimum time between executions of the same step
10
+ # 2. Rate limit: Limits the number of calls within a time window (token bucket)
11
+ #
12
+ # Thread-safe using a Mutex for concurrent access.
13
+ #
14
+ # @example Using throttle
15
+ # manager = ThrottleManager.new
16
+ # manager.throttle("step:fetch", 1.0) # Wait at least 1 second between calls
17
+ #
18
+ # @example Using rate limit
19
+ # manager = ThrottleManager.new
20
+ # manager.rate_limit("api:external", calls: 10, per: 60) # 10 calls per minute
21
+ #
22
+ # @api private
23
+ class ThrottleManager
24
+ def initialize
25
+ @last_execution = {}
26
+ @rate_limiters = {}
27
+ @mutex = Mutex.new
28
+ end
29
+
30
+ # Throttle execution to ensure minimum time between calls
31
+ #
32
+ # Blocks the current thread if necessary to maintain the minimum interval.
33
+ #
34
+ # @param key [String] Unique identifier for the throttle target
35
+ # @param duration [Float, Integer] Minimum seconds between executions
36
+ # @return [Float] Actual seconds waited (0 if no wait needed)
37
+ def throttle(key, duration)
38
+ duration_seconds = normalize_duration(duration)
39
+
40
+ @mutex.synchronize do
41
+ last = @last_execution[key]
42
+ waited = 0
43
+
44
+ if last
45
+ elapsed = Time.now - last
46
+ remaining = duration_seconds - elapsed
47
+
48
+ if remaining > 0
49
+ @mutex.sleep(remaining)
50
+ waited = remaining
51
+ end
52
+ end
53
+
54
+ @last_execution[key] = Time.now
55
+ waited
56
+ end
57
+ end
58
+
59
+ # Check if a call would be throttled without actually waiting
60
+ #
61
+ # @param key [String] Unique identifier for the throttle target
62
+ # @param duration [Float, Integer] Minimum seconds between executions
63
+ # @return [Float] Seconds until next allowed execution (0 if ready)
64
+ def throttle_remaining(key, duration)
65
+ duration_seconds = normalize_duration(duration)
66
+
67
+ @mutex.synchronize do
68
+ last = @last_execution[key]
69
+ return 0 unless last
70
+
71
+ elapsed = Time.now - last
72
+ remaining = duration_seconds - elapsed
73
+ [remaining, 0].max
74
+ end
75
+ end
76
+
77
+ # Apply rate limiting using a token bucket algorithm
78
+ #
79
+ # Blocks until a token is available if the rate limit is exceeded.
80
+ #
81
+ # @param key [String] Unique identifier for the rate limit target
82
+ # @param calls [Integer] Number of calls allowed per window
83
+ # @param per [Float, Integer] Time window in seconds
84
+ # @return [Float] Seconds waited (0 if no wait needed)
85
+ def rate_limit(key, calls:, per:)
86
+ per_seconds = normalize_duration(per)
87
+ bucket = get_or_create_bucket(key, calls, per_seconds)
88
+
89
+ @mutex.synchronize do
90
+ waited = bucket.acquire
91
+ waited
92
+ end
93
+ end
94
+
95
+ # Check if a call would be rate limited without consuming a token
96
+ #
97
+ # @param key [String] Unique identifier for the rate limit target
98
+ # @param calls [Integer] Number of calls allowed per window
99
+ # @param per [Float, Integer] Time window in seconds
100
+ # @return [Boolean] true if a call would be allowed immediately
101
+ def rate_limit_available?(key, calls:, per:)
102
+ per_seconds = normalize_duration(per)
103
+ bucket = get_or_create_bucket(key, calls, per_seconds)
104
+
105
+ @mutex.synchronize do
106
+ bucket.available?
107
+ end
108
+ end
109
+
110
+ # Reset throttle state for a specific key
111
+ #
112
+ # @param key [String] The throttle key to reset
113
+ # @return [void]
114
+ def reset_throttle(key)
115
+ @mutex.synchronize do
116
+ @last_execution.delete(key)
117
+ end
118
+ end
119
+
120
+ # Reset rate limiter state for a specific key
121
+ #
122
+ # @param key [String] The rate limiter key to reset
123
+ # @return [void]
124
+ def reset_rate_limit(key)
125
+ @mutex.synchronize do
126
+ @rate_limiters.delete(key)
127
+ end
128
+ end
129
+
130
+ # Reset all throttle and rate limit state
131
+ #
132
+ # @return [void]
133
+ def reset_all!
134
+ @mutex.synchronize do
135
+ @last_execution.clear
136
+ @rate_limiters.clear
137
+ end
138
+ end
139
+
140
+ private
141
+
142
+ def normalize_duration(duration)
143
+ if duration.respond_to?(:to_f)
144
+ duration.to_f
145
+ else
146
+ duration.to_i.to_f
147
+ end
148
+ end
149
+
150
+ def get_or_create_bucket(key, calls, per)
151
+ @rate_limiters[key] ||= TokenBucket.new(calls, per)
152
+ end
153
+
154
+ # Simple token bucket implementation for rate limiting
155
+ #
156
+ # @api private
157
+ class TokenBucket
158
+ def initialize(capacity, refill_time)
159
+ @capacity = capacity
160
+ @refill_time = refill_time
161
+ @tokens = capacity.to_f
162
+ @last_refill = Time.now
163
+ end
164
+
165
+ # Try to acquire a token, waiting if necessary
166
+ #
167
+ # @return [Float] Seconds waited
168
+ def acquire
169
+ refill
170
+ waited = 0
171
+
172
+ if @tokens < 1
173
+ # Calculate wait time for next token
174
+ tokens_needed = 1 - @tokens
175
+ wait_time = tokens_needed * @refill_time / @capacity
176
+ sleep(wait_time)
177
+ waited = wait_time
178
+ refill
179
+ end
180
+
181
+ @tokens -= 1
182
+ waited
183
+ end
184
+
185
+ # Check if a token is available without consuming it
186
+ #
187
+ # @return [Boolean]
188
+ def available?
189
+ refill
190
+ @tokens >= 1
191
+ end
192
+
193
+ private
194
+
195
+ def refill
196
+ now = Time.now
197
+ elapsed = now - @last_refill
198
+ refill_amount = elapsed * @capacity / @refill_time
199
+ @tokens = [@tokens + refill_amount, @capacity].min
200
+ @last_refill = now
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end