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
@@ -1,299 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module Agents
5
- class Workflow
6
- # Concurrent workflow execution pattern
7
- #
8
- # Executes multiple agents simultaneously and aggregates their results.
9
- # Supports fail-fast behavior, optional branches, and custom aggregation.
10
- #
11
- # @example Basic parallel execution
12
- # class ReviewAnalyzer < RubyLLM::Agents::Workflow::Parallel
13
- # version "1.0"
14
- #
15
- # branch :sentiment, agent: SentimentAgent
16
- # branch :summary, agent: SummaryAgent
17
- # branch :categories, agent: CategoryAgent
18
- # end
19
- #
20
- # result = ReviewAnalyzer.call(text: "Great product!")
21
- # result.branches[:sentiment].content # "positive"
22
- # result.branches[:summary].content # "User liked the product"
23
- #
24
- # @example With optional branches and custom aggregation
25
- # class FullAnalyzer < RubyLLM::Agents::Workflow::Parallel
26
- # branch :sentiment, agent: SentimentAgent
27
- # branch :toxicity, agent: ToxicityAgent, optional: true
28
- #
29
- # def aggregate(results)
30
- # {
31
- # sentiment: results[:sentiment]&.content,
32
- # toxicity: results[:toxicity]&.content,
33
- # safe: results[:toxicity]&.content != "toxic"
34
- # }
35
- # end
36
- # end
37
- #
38
- # @example With fail-fast enabled
39
- # class CriticalAnalyzer < RubyLLM::Agents::Workflow::Parallel
40
- # fail_fast true # Stop all branches on first failure
41
- #
42
- # branch :auth, agent: AuthValidator
43
- # branch :sanity, agent: SanityChecker
44
- # end
45
- #
46
- # @api public
47
- class Parallel < Workflow
48
- class << self
49
- # Returns the defined branches
50
- #
51
- # @return [Hash<Symbol, Hash>] Branch configurations
52
- def branches
53
- @branches ||= {}
54
- end
55
-
56
- # Inherits branches from parent class
57
- def inherited(subclass)
58
- super
59
- subclass.instance_variable_set(:@branches, branches.dup)
60
- subclass.instance_variable_set(:@fail_fast, @fail_fast)
61
- subclass.instance_variable_set(:@concurrency, @concurrency)
62
- end
63
-
64
- # Defines a parallel branch
65
- #
66
- # @param name [Symbol] Branch identifier
67
- # @param agent [Class] The agent class to execute
68
- # @param optional [Boolean] If true, branch failure won't fail the workflow
69
- # @param input [Proc, nil] Lambda to transform input for this branch
70
- # @return [void]
71
- #
72
- # @example Basic branch
73
- # branch :analyze, agent: AnalyzerAgent
74
- #
75
- # @example Optional branch
76
- # branch :enrich, agent: EnricherAgent, optional: true
77
- #
78
- # @example With custom input
79
- # branch :summarize, agent: SummaryAgent, input: ->(opts) { { text: opts[:content] } }
80
- def branch(name, agent:, optional: false, input: nil)
81
- branches[name] = {
82
- agent: agent,
83
- optional: optional,
84
- input: input
85
- }
86
- end
87
-
88
- # Sets or returns fail-fast behavior
89
- #
90
- # When true, cancels remaining branches when any required branch fails.
91
- #
92
- # @param value [Boolean, nil] Whether to fail fast
93
- # @return [Boolean] Current fail-fast setting
94
- def fail_fast(value = nil)
95
- if value.nil?
96
- @fail_fast || false
97
- else
98
- @fail_fast = value
99
- end
100
- end
101
-
102
- # Alias for checking fail_fast setting
103
- def fail_fast?
104
- fail_fast
105
- end
106
-
107
- # Sets or returns concurrency limit
108
- #
109
- # @param value [Integer, nil] Max concurrent branches (nil = unlimited)
110
- # @return [Integer, nil] Current concurrency limit
111
- def concurrency(value = nil)
112
- if value.nil?
113
- @concurrency
114
- else
115
- @concurrency = value
116
- end
117
- end
118
- end
119
-
120
- # Executes the parallel workflow
121
- #
122
- # Runs all branches concurrently and aggregates results.
123
- #
124
- # @yield [chunk] Yields chunks when streaming (not typically used in parallel)
125
- # @return [WorkflowResult] The parallel result
126
- def call(&block)
127
- instrument_workflow do
128
- execute_parallel(&block)
129
- end
130
- end
131
-
132
- # Aggregates branch results into final content
133
- #
134
- # Override this method to customize how results are combined.
135
- #
136
- # @param results [Hash<Symbol, Result>] Branch results
137
- # @return [Object] Aggregated content
138
- def aggregate(results)
139
- # Default: return hash of branch contents
140
- results.transform_values { |r| r&.content }
141
- end
142
-
143
- private
144
-
145
- # Executes all branches in parallel
146
- #
147
- # @return [WorkflowResult] The parallel result
148
- def execute_parallel(&block)
149
- results = {}
150
- errors = {}
151
- mutex = Mutex.new
152
-
153
- # Determine pool size based on concurrency setting
154
- pool_size = self.class.concurrency || self.class.branches.size
155
-
156
- # Use async executor when in async context, otherwise use thread pool
157
- pool = create_executor(pool_size)
158
-
159
- # Submit all branches to the pool
160
- self.class.branches.each do |name, config|
161
- pool.post do
162
- Thread.current.name = "parallel-#{name}"
163
- Thread.current[:branch_name] = name
164
-
165
- begin
166
- # Check if pool was aborted (fail-fast triggered)
167
- if pool.aborted?
168
- mutex.synchronize { results[name] = nil }
169
- next
170
- end
171
-
172
- # Build input for this branch
173
- branch_input = build_branch_input(name, config)
174
-
175
- # Execute the branch
176
- result = execute_agent(
177
- config[:agent],
178
- branch_input,
179
- step_name: name,
180
- &block
181
- )
182
-
183
- mutex.synchronize do
184
- results[name] = result
185
-
186
- # Check for failure and trigger fail-fast if needed
187
- if result.respond_to?(:error?) && result.error? && !config[:optional]
188
- pool.abort! if self.class.fail_fast?
189
- end
190
- end
191
- rescue StandardError => e
192
- mutex.synchronize do
193
- errors[name] = e
194
- results[name] = nil
195
- pool.abort! if self.class.fail_fast? && !config[:optional]
196
- end
197
- end
198
- end
199
- end
200
-
201
- # Wait for all branches to complete
202
- pool.wait_for_completion
203
- pool.shutdown
204
-
205
- # Determine overall status
206
- status = determine_parallel_status(results, errors)
207
-
208
- # Aggregate results
209
- final_content = begin
210
- aggregate(results)
211
- rescue StandardError => e
212
- errors[:aggregate] = e
213
- results.transform_values { |r| r&.content }
214
- end
215
-
216
- build_parallel_result(
217
- content: final_content,
218
- branches: results,
219
- errors: errors,
220
- status: status
221
- )
222
- end
223
-
224
- # Builds input for a specific branch
225
- #
226
- # @param name [Symbol] Branch name
227
- # @param config [Hash] Branch configuration
228
- # @return [Hash] Input for the branch
229
- def build_branch_input(name, config)
230
- if config[:input]
231
- config[:input].call(options)
232
- elsif respond_to?(:"before_#{name}", true)
233
- send(:"before_#{name}", options)
234
- else
235
- options.dup
236
- end
237
- end
238
-
239
- # Determines the overall parallel status
240
- #
241
- # @param results [Hash] Branch results
242
- # @param errors [Hash] Branch errors
243
- # @return [String] Status: "success", "partial", or "error"
244
- def determine_parallel_status(results, errors)
245
- required_branches = self.class.branches.reject { |_, c| c[:optional] }.keys
246
- failed_required = required_branches.select do |name|
247
- errors[name] || (results[name].respond_to?(:error?) && results[name].error?)
248
- end
249
-
250
- if failed_required.any?
251
- "error"
252
- elsif errors.any?
253
- "partial"
254
- else
255
- "success"
256
- end
257
- end
258
-
259
- # Builds the final parallel result
260
- #
261
- # @param content [Object] Aggregated content
262
- # @param branches [Hash] Branch results
263
- # @param errors [Hash] Branch errors
264
- # @param status [String] Final status
265
- # @return [WorkflowResult] The parallel result
266
- def build_parallel_result(content:, branches:, errors:, status:)
267
- Workflow::Result.new(
268
- content: content,
269
- workflow_type: self.class.name,
270
- workflow_id: workflow_id,
271
- branches: branches,
272
- errors: errors,
273
- status: status,
274
- started_at: @workflow_started_at,
275
- completed_at: Time.current,
276
- duration_ms: (((Time.current - @workflow_started_at) * 1000).round if @workflow_started_at)
277
- )
278
- end
279
-
280
- # Creates the appropriate executor based on context
281
- #
282
- # Uses AsyncExecutor when in async context for fiber-based concurrency,
283
- # otherwise falls back to ThreadPool for traditional thread-based execution.
284
- #
285
- # @param size [Integer] Number of concurrent workers/fibers
286
- # @return [ThreadPool, AsyncExecutor] The executor instance
287
- def create_executor(size)
288
- config = RubyLLM::Agents.configuration
289
-
290
- if config.async_context?
291
- AsyncExecutor.new(max_concurrent: size)
292
- else
293
- ThreadPool.new(size: size)
294
- end
295
- end
296
- end
297
- end
298
- end
299
- end
@@ -1,306 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module RubyLLM
4
- module Agents
5
- class Workflow
6
- # Sequential workflow execution pattern
7
- #
8
- # Executes agents in order, passing each step's output as input to the next.
9
- # Supports conditional step skipping, error handling, and input transformation.
10
- #
11
- # @example Basic pipeline
12
- # class ContentPipeline < RubyLLM::Agents::Workflow::Pipeline
13
- # version "1.0"
14
- #
15
- # step :extract, agent: ExtractorAgent
16
- # step :validate, agent: ValidatorAgent
17
- # step :format, agent: FormatterAgent
18
- # end
19
- #
20
- # result = ContentPipeline.call(text: "raw input")
21
- # result.steps[:extract].content # First step output
22
- # result.content # Final output
23
- #
24
- # @example With conditional skipping
25
- # class ConditionalPipeline < RubyLLM::Agents::Workflow::Pipeline
26
- # step :check, agent: CheckerAgent
27
- # step :process, agent: ProcessorAgent, skip_on: ->(ctx) { ctx[:check].content[:skip] }
28
- # step :finalize, agent: FinalizerAgent
29
- # end
30
- #
31
- # @example With input transformation
32
- # class TransformPipeline < RubyLLM::Agents::Workflow::Pipeline
33
- # step :analyze, agent: AnalyzerAgent
34
- # step :enrich, agent: EnricherAgent
35
- #
36
- # def before_enrich(context)
37
- # { data: context[:analyze].content, extra_field: "value" }
38
- # end
39
- # end
40
- #
41
- # @api public
42
- class Pipeline < Workflow
43
- # Simple error result for failed steps
44
- # @api private
45
- class ErrorResult
46
- attr_reader :error_class, :error_message, :step_name
47
-
48
- def initialize(step_name:, error_class:, error_message:)
49
- @step_name = step_name
50
- @error_class = error_class
51
- @error_message = error_message
52
- end
53
-
54
- def content = nil
55
- def success? = false
56
- def error? = true
57
- def input_tokens = 0
58
- def output_tokens = 0
59
- def total_tokens = 0
60
- def cached_tokens = 0
61
- def input_cost = 0.0
62
- def output_cost = 0.0
63
- def total_cost = 0.0
64
-
65
- def to_h
66
- {
67
- error: true,
68
- step_name: step_name,
69
- error_class: error_class,
70
- error_message: error_message
71
- }
72
- end
73
- end
74
-
75
- class << self
76
- # Returns the defined steps
77
- #
78
- # @return [Hash<Symbol, Hash>] Step configurations
79
- def steps
80
- @steps ||= {}
81
- end
82
-
83
- # Inherits steps from parent class
84
- def inherited(subclass)
85
- super
86
- subclass.instance_variable_set(:@steps, steps.dup)
87
- end
88
-
89
- # Defines a pipeline step
90
- #
91
- # @param name [Symbol] Step identifier
92
- # @param agent [Class] The agent class to execute
93
- # @param skip_on [Proc, nil] Lambda to determine if step should be skipped
94
- # @param continue_on_error [Boolean] Whether to continue if step fails
95
- # @param optional [Boolean] Mark step as optional (alias for continue_on_error)
96
- # @return [void]
97
- #
98
- # @example Basic step
99
- # step :process, agent: ProcessorAgent
100
- #
101
- # @example With skip condition
102
- # step :validate, agent: ValidatorAgent, skip_on: ->(ctx) { ctx[:input][:skip_validation] }
103
- #
104
- # @example Optional step (won't fail pipeline)
105
- # step :enrich, agent: EnricherAgent, optional: true
106
- def step(name, agent:, skip_on: nil, continue_on_error: false, optional: false)
107
- steps[name] = {
108
- agent: agent,
109
- skip_on: skip_on,
110
- continue_on_error: continue_on_error || optional,
111
- index: steps.size
112
- }
113
- end
114
- end
115
-
116
- # Executes the pipeline
117
- #
118
- # Runs each step sequentially, passing output to the next step.
119
- # Tracks all step results and builds aggregate metrics.
120
- #
121
- # @yield [chunk] Yields chunks when streaming (passed to individual agents)
122
- # @return [WorkflowResult] The pipeline result
123
- def call(&block)
124
- instrument_workflow do
125
- execute_pipeline(&block)
126
- end
127
- end
128
-
129
- private
130
-
131
- # Executes the pipeline steps
132
- #
133
- # @return [WorkflowResult] The pipeline result
134
- def execute_pipeline(&block)
135
- context = { input: options }
136
- step_results = {}
137
- errors = {}
138
- last_successful_result = nil
139
- status = "success"
140
-
141
- self.class.steps.each do |name, config|
142
- # Check skip condition
143
- if should_skip_step?(config, context)
144
- step_results[name] = SkippedResult.new(name, reason: "skip_on condition met")
145
- next
146
- end
147
-
148
- begin
149
- # Build input for this step
150
- step_input = before_step(name, context)
151
-
152
- # Execute the step
153
- result = execute_agent(
154
- config[:agent],
155
- step_input,
156
- step_name: name,
157
- &block
158
- )
159
-
160
- step_results[name] = result
161
- context[name] = result
162
- last_successful_result = result
163
-
164
- # Check if step failed
165
- if result.respond_to?(:error?) && result.error?
166
- errors[name] = StandardError.new(result.error_message || "Step failed")
167
-
168
- unless config[:continue_on_error]
169
- status = "error"
170
- break
171
- end
172
-
173
- status = "partial" if status == "success"
174
- end
175
- rescue StandardError => e
176
- # Handle step execution errors
177
- errors[name] = e
178
- step_results[name] = build_error_result(name, e)
179
- context[name] = step_results[name]
180
-
181
- # Check continue_on_error first - if true, continue without calling handler
182
- if config[:continue_on_error]
183
- status = "partial" if status == "success"
184
- next
185
- end
186
-
187
- # Call error handler hook for non-optional steps
188
- action = on_step_failure(name, e, context)
189
-
190
- case action
191
- when :skip
192
- status = "partial" if status == "success"
193
- next
194
- when :abort
195
- status = "error"
196
- break
197
- when Result, Workflow::Result
198
- # Use the returned result as the step result
199
- step_results[name] = action
200
- context[name] = action
201
- last_successful_result = action
202
- status = "partial" if status == "success"
203
- else
204
- status = "error"
205
- break
206
- end
207
- end
208
- end
209
-
210
- # Build final content from last successful step
211
- final_content = extract_final_content(last_successful_result, context)
212
-
213
- build_pipeline_result(
214
- content: final_content,
215
- steps: step_results,
216
- errors: errors,
217
- status: status
218
- )
219
- end
220
-
221
- # Checks if a step should be skipped
222
- #
223
- # @param config [Hash] Step configuration
224
- # @param context [Hash] Current workflow context
225
- # @return [Boolean] true if step should be skipped
226
- def should_skip_step?(config, context)
227
- return false unless config[:skip_on]
228
-
229
- config[:skip_on].call(context)
230
- rescue StandardError => e
231
- Rails.logger.warn("[RubyLLM::Agents::Pipeline] skip_on evaluation failed: #{e.message}")
232
- false
233
- end
234
-
235
- # Hook for handling step failures
236
- #
237
- # Override in subclass to customize error handling.
238
- #
239
- # @param step_name [Symbol] The failed step
240
- # @param error [Exception] The error
241
- # @param context [Hash] Current workflow context
242
- # @return [Symbol, Result] :skip to continue, :abort to stop, or a fallback Result
243
- def on_step_failure(step_name, error, context)
244
- # Default: check if method exists for specific step
245
- method_name = :"on_#{step_name}_failure"
246
- if respond_to?(method_name, true)
247
- send(method_name, error, context)
248
- else
249
- :abort
250
- end
251
- end
252
-
253
- # Extracts the final content from the pipeline
254
- #
255
- # @param last_result [Result, nil] The last successful result
256
- # @param context [Hash] The workflow context
257
- # @return [Object] The final content
258
- def extract_final_content(last_result, context)
259
- if last_result.respond_to?(:content)
260
- last_result.content
261
- elsif context.keys.size > 1
262
- # Return the last non-input context entry
263
- last_key = context.keys.reject { |k| k == :input }.last
264
- context[last_key]&.content
265
- else
266
- nil
267
- end
268
- end
269
-
270
- # Builds an error result for a failed step
271
- #
272
- # @param step_name [Symbol] The step name
273
- # @param error [Exception] The error
274
- # @return [ErrorResult] Error result object
275
- def build_error_result(step_name, error)
276
- ErrorResult.new(
277
- step_name: step_name,
278
- error_class: error.class.name,
279
- error_message: error.message
280
- )
281
- end
282
-
283
- # Builds the final pipeline result
284
- #
285
- # @param content [Object] Final content
286
- # @param steps [Hash] Step results
287
- # @param errors [Hash] Step errors
288
- # @param status [String] Final status
289
- # @return [WorkflowResult] The pipeline result
290
- def build_pipeline_result(content:, steps:, errors:, status:)
291
- Workflow::Result.new(
292
- content: content,
293
- workflow_type: self.class.name,
294
- workflow_id: workflow_id,
295
- steps: steps,
296
- errors: errors,
297
- status: status,
298
- started_at: @workflow_started_at,
299
- completed_at: Time.current,
300
- duration_ms: (((Time.current - @workflow_started_at) * 1000).round if @workflow_started_at)
301
- )
302
- end
303
- end
304
- end
305
- end
306
- end