ruby_llm-agents 0.3.4 → 0.3.5

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +80 -16
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +17 -27
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +9 -1
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +77 -20
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  21. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  22. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
  23. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  24. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  25. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  26. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  27. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
  29. data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
  30. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  32. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  33. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  34. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  35. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  36. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  37. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  39. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  41. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  42. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  43. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  44. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  45. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  46. data/lib/ruby_llm/agents/base/caching.rb +4 -7
  47. data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
  48. data/lib/ruby_llm/agents/base/execution.rb +61 -9
  49. data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
  50. data/lib/ruby_llm/agents/base.rb +26 -0
  51. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  52. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  53. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  54. data/lib/ruby_llm/agents/configuration.rb +40 -1
  55. data/lib/ruby_llm/agents/engine.rb +65 -1
  56. data/lib/ruby_llm/agents/inflections.rb +14 -0
  57. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  58. data/lib/ruby_llm/agents/reliability.rb +8 -2
  59. data/lib/ruby_llm/agents/version.rb +1 -1
  60. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  61. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  62. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  63. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  64. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  65. data/lib/ruby_llm/agents/workflow.rb +232 -0
  66. data/lib/ruby_llm/agents.rb +1 -0
  67. metadata +50 -60
  68. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  69. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
  70. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  71. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  72. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  73. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  74. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  75. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  76. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  77. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  78. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
  79. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
  80. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
  81. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  82. /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
  83. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
  84. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
@@ -0,0 +1,306 @@
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
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Workflow
6
+ # Result wrapper for workflow executions with aggregate metrics
7
+ #
8
+ # Extends the base Result class with workflow-specific data including
9
+ # step results, branch results, routing information, and aggregated
10
+ # token/cost metrics across all child executions.
11
+ #
12
+ # @example Pipeline result
13
+ # result = ContentPipeline.call(text: "input")
14
+ # result.content # Final output
15
+ # result.steps[:extract] # Individual step result
16
+ # result.total_cost # Sum of all steps
17
+ #
18
+ # @example Parallel result
19
+ # result = ReviewAnalyzer.call(text: "review")
20
+ # result.branches[:sentiment] # Branch result
21
+ # result.failed_branches # [:toxicity] if it failed
22
+ #
23
+ # @example Router result
24
+ # result = SupportRouter.call(message: "billing issue")
25
+ # result.routed_to # :billing
26
+ # result.classification # Classification details
27
+ #
28
+ # @api public
29
+ class Result
30
+ extend ActiveSupport::Delegation
31
+
32
+ # @!attribute [r] content
33
+ # @return [Object] The final processed content
34
+ attr_reader :content
35
+
36
+ # @!attribute [r] workflow_type
37
+ # @return [String] The workflow class name
38
+ attr_reader :workflow_type
39
+
40
+ # @!attribute [r] workflow_id
41
+ # @return [String] Unique identifier for this workflow execution
42
+ attr_reader :workflow_id
43
+
44
+ # @!group Step/Branch Results
45
+
46
+ # @!attribute [r] steps
47
+ # @return [Hash<Symbol, Result>] Results from pipeline steps
48
+ attr_reader :steps
49
+
50
+ # @!attribute [r] branches
51
+ # @return [Hash<Symbol, Result>] Results from parallel branches
52
+ attr_reader :branches
53
+
54
+ # @!endgroup
55
+
56
+ # @!group Router Results
57
+
58
+ # @!attribute [r] routed_to
59
+ # @return [Symbol, nil] The route that was selected
60
+ attr_reader :routed_to
61
+
62
+ # @!attribute [r] classification
63
+ # @return [Hash, nil] Classification details from router
64
+ attr_reader :classification
65
+
66
+ # @!attribute [r] classifier_result
67
+ # @return [Result, nil] The classifier agent's result
68
+ attr_reader :classifier_result
69
+
70
+ # @!endgroup
71
+
72
+ # @!group Timing
73
+
74
+ # @!attribute [r] started_at
75
+ # @return [Time] When the workflow started
76
+ attr_reader :started_at
77
+
78
+ # @!attribute [r] completed_at
79
+ # @return [Time] When the workflow completed
80
+ attr_reader :completed_at
81
+
82
+ # @!attribute [r] duration_ms
83
+ # @return [Integer] Total workflow duration in milliseconds
84
+ attr_reader :duration_ms
85
+
86
+ # @!endgroup
87
+
88
+ # @!group Status
89
+
90
+ # @!attribute [r] status
91
+ # @return [String] Workflow status: "success", "error", "partial"
92
+ attr_reader :status
93
+
94
+ # @!attribute [r] error_class
95
+ # @return [String, nil] Error class if failed
96
+ attr_reader :error_class
97
+
98
+ # @!attribute [r] error_message
99
+ # @return [String, nil] Error message if failed
100
+ attr_reader :error_message
101
+
102
+ # @!attribute [r] errors
103
+ # @return [Hash<Symbol, Exception>] Errors by step/branch name
104
+ attr_reader :errors
105
+
106
+ # @!endgroup
107
+
108
+ # Creates a new WorkflowResult
109
+ #
110
+ # @param content [Object] The final processed content
111
+ # @param options [Hash] Additional result metadata
112
+ def initialize(content:, **options)
113
+ @content = content
114
+ @workflow_type = options[:workflow_type]
115
+ @workflow_id = options[:workflow_id]
116
+
117
+ # Step/branch results
118
+ @steps = options[:steps] || {}
119
+ @branches = options[:branches] || {}
120
+
121
+ # Router results
122
+ @routed_to = options[:routed_to]
123
+ @classification = options[:classification]
124
+ @classifier_result = options[:classifier_result]
125
+
126
+ # Timing
127
+ @started_at = options[:started_at]
128
+ @completed_at = options[:completed_at]
129
+ @duration_ms = options[:duration_ms]
130
+
131
+ # Status
132
+ @status = options[:status] || "success"
133
+ @error_class = options[:error_class]
134
+ @error_message = options[:error_message]
135
+ @errors = options[:errors] || {}
136
+ end
137
+
138
+ # Returns all child results (steps + branches + classifier)
139
+ #
140
+ # @return [Array<Result>] All child results
141
+ def child_results
142
+ results = []
143
+ results.concat(steps.values) if steps.any?
144
+ results.concat(branches.values) if branches.any?
145
+ results << classifier_result if classifier_result
146
+ results.compact
147
+ end
148
+
149
+ # @!group Aggregate Metrics
150
+
151
+ # Returns total input tokens across all child executions
152
+ #
153
+ # @return [Integer] Total input tokens
154
+ def input_tokens
155
+ child_results.sum { |r| r.input_tokens || 0 }
156
+ end
157
+
158
+ # Returns total output tokens across all child executions
159
+ #
160
+ # @return [Integer] Total output tokens
161
+ def output_tokens
162
+ child_results.sum { |r| r.output_tokens || 0 }
163
+ end
164
+
165
+ # Returns total tokens across all child executions
166
+ #
167
+ # @return [Integer] Total tokens
168
+ def total_tokens
169
+ input_tokens + output_tokens
170
+ end
171
+
172
+ # Returns total cached tokens across all child executions
173
+ #
174
+ # @return [Integer] Total cached tokens
175
+ def cached_tokens
176
+ child_results.sum { |r| r.cached_tokens || 0 }
177
+ end
178
+
179
+ # Returns total input cost across all child executions
180
+ #
181
+ # @return [Float] Total input cost in USD
182
+ def input_cost
183
+ child_results.sum { |r| r.input_cost || 0.0 }
184
+ end
185
+
186
+ # Returns total output cost across all child executions
187
+ #
188
+ # @return [Float] Total output cost in USD
189
+ def output_cost
190
+ child_results.sum { |r| r.output_cost || 0.0 }
191
+ end
192
+
193
+ # Returns total cost across all child executions
194
+ #
195
+ # @return [Float] Total cost in USD
196
+ def total_cost
197
+ child_results.sum { |r| r.total_cost || 0.0 }
198
+ end
199
+
200
+ # Returns classification cost (router workflows only)
201
+ #
202
+ # @return [Float] Classification cost in USD
203
+ def classification_cost
204
+ classifier_result&.total_cost || 0.0
205
+ end
206
+
207
+ # @!endgroup
208
+
209
+ # @!group Status Helpers
210
+
211
+ # Returns whether the workflow succeeded
212
+ #
213
+ # @return [Boolean] true if status is "success"
214
+ def success?
215
+ status == "success"
216
+ end
217
+
218
+ # Returns whether the workflow failed
219
+ #
220
+ # @return [Boolean] true if status is "error"
221
+ def error?
222
+ status == "error"
223
+ end
224
+
225
+ # Returns whether the workflow partially succeeded
226
+ #
227
+ # @return [Boolean] true if status is "partial"
228
+ def partial?
229
+ status == "partial"
230
+ end
231
+
232
+ # @!endgroup
233
+
234
+ # @!group Pipeline Helpers
235
+
236
+ # Returns whether all pipeline steps succeeded
237
+ #
238
+ # @return [Boolean] true if all steps successful
239
+ def all_steps_successful?
240
+ return true if steps.empty?
241
+
242
+ steps.values.all? { |r| r.respond_to?(:success?) ? r.success? : true }
243
+ end
244
+
245
+ # Returns the names of failed steps
246
+ #
247
+ # @return [Array<Symbol>] Failed step names
248
+ def failed_steps
249
+ steps.select { |_, r| r.respond_to?(:error?) && r.error? }.keys
250
+ end
251
+
252
+ # Returns the names of skipped steps
253
+ #
254
+ # @return [Array<Symbol>] Skipped step names
255
+ def skipped_steps
256
+ steps.select { |_, r| r.respond_to?(:skipped?) && r.skipped? }.keys
257
+ end
258
+
259
+ # @!endgroup
260
+
261
+ # @!group Parallel Helpers
262
+
263
+ # Returns whether all parallel branches succeeded
264
+ #
265
+ # @return [Boolean] true if all branches successful
266
+ def all_branches_successful?
267
+ return true if branches.empty?
268
+
269
+ branches.values.all? { |r| r.nil? || (r.respond_to?(:success?) ? r.success? : true) }
270
+ end
271
+
272
+ # Returns the names of failed branches
273
+ #
274
+ # @return [Array<Symbol>] Failed branch names
275
+ def failed_branches
276
+ failed = branches.select { |_, r| r.respond_to?(:error?) && r.error? }.keys
277
+ failed += errors.keys
278
+ failed.uniq
279
+ end
280
+
281
+ # Returns the names of successful branches
282
+ #
283
+ # @return [Array<Symbol>] Successful branch names
284
+ def successful_branches
285
+ branches.select { |_, r| r.respond_to?(:success?) && r.success? }.keys
286
+ end
287
+
288
+ # @!endgroup
289
+
290
+ # Converts the result to a hash
291
+ #
292
+ # @return [Hash] All result data
293
+ def to_h
294
+ {
295
+ content: content,
296
+ workflow_type: workflow_type,
297
+ workflow_id: workflow_id,
298
+ status: status,
299
+ steps: steps.transform_values { |r| r.respond_to?(:to_h) ? r.to_h : r },
300
+ branches: branches.transform_values { |r| r.respond_to?(:to_h) ? r.to_h : r },
301
+ routed_to: routed_to,
302
+ classification: classification,
303
+ input_tokens: input_tokens,
304
+ output_tokens: output_tokens,
305
+ total_tokens: total_tokens,
306
+ cached_tokens: cached_tokens,
307
+ input_cost: input_cost,
308
+ output_cost: output_cost,
309
+ total_cost: total_cost,
310
+ started_at: started_at,
311
+ completed_at: completed_at,
312
+ duration_ms: duration_ms,
313
+ error_class: error_class,
314
+ error_message: error_message,
315
+ errors: errors.transform_values { |e| { class: e.class.name, message: e.message } }
316
+ }
317
+ end
318
+
319
+ # Delegate hash methods to content for convenience
320
+ delegate :[], :dig, :keys, :values, :each, :map, to: :content, allow_nil: true
321
+
322
+ # Custom to_json that includes workflow metadata
323
+ #
324
+ # @param args [Array] Arguments passed to to_json
325
+ # @return [String] JSON representation
326
+ def to_json(*args)
327
+ to_h.to_json(*args)
328
+ end
329
+ end
330
+
331
+ # Represents a skipped step result
332
+ class SkippedResult
333
+ attr_reader :step_name, :reason
334
+
335
+ def initialize(step_name, reason: nil)
336
+ @step_name = step_name
337
+ @reason = reason
338
+ end
339
+
340
+ def content
341
+ nil
342
+ end
343
+
344
+ def success?
345
+ true
346
+ end
347
+
348
+ def error?
349
+ false
350
+ end
351
+
352
+ def skipped?
353
+ true
354
+ end
355
+
356
+ def input_tokens
357
+ 0
358
+ end
359
+
360
+ def output_tokens
361
+ 0
362
+ end
363
+
364
+ def total_tokens
365
+ 0
366
+ end
367
+
368
+ def cached_tokens
369
+ 0
370
+ end
371
+
372
+ def input_cost
373
+ 0.0
374
+ end
375
+
376
+ def output_cost
377
+ 0.0
378
+ end
379
+
380
+ def total_cost
381
+ 0.0
382
+ end
383
+
384
+ def to_h
385
+ { skipped: true, step_name: step_name, reason: reason }
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end