ruby_llm-agents 0.3.3 → 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.
- checksums.yaml +4 -4
- data/README.md +132 -1263
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
- data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +137 -9
- data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
- data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
- data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
- data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
- data/app/models/ruby_llm/agents/execution.rb +28 -59
- data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
- data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
- data/app/views/layouts/ruby_llm/agents/application.html.erb +430 -0
- data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
- data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
- data/app/views/ruby_llm/agents/agents/show.html.erb +775 -0
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
- data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
- data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
- data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
- data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
- data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +260 -200
- data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
- data/config/routes.rb +2 -0
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
- data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
- data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
- data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
- data/lib/ruby_llm/agents/alert_manager.rb +20 -16
- data/lib/ruby_llm/agents/base/caching.rb +40 -0
- data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
- data/lib/ruby_llm/agents/base/dsl.rb +261 -0
- data/lib/ruby_llm/agents/base/execution.rb +258 -0
- data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
- data/lib/ruby_llm/agents/base/response_building.rb +86 -0
- data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
- data/lib/ruby_llm/agents/base.rb +37 -801
- data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
- data/lib/ruby_llm/agents/cache_helper.rb +98 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
- data/lib/ruby_llm/agents/configuration.rb +40 -1
- data/lib/ruby_llm/agents/engine.rb +65 -1
- data/lib/ruby_llm/agents/inflections.rb +14 -0
- data/lib/ruby_llm/agents/instrumentation.rb +66 -0
- data/lib/ruby_llm/agents/reliability.rb +8 -2
- data/lib/ruby_llm/agents/version.rb +1 -1
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
- data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
- data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
- data/lib/ruby_llm/agents/workflow/result.rb +390 -0
- data/lib/ruby_llm/agents/workflow/router.rb +429 -0
- data/lib/ruby_llm/agents/workflow.rb +232 -0
- data/lib/ruby_llm/agents.rb +1 -0
- metadata +57 -75
- data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
- data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
- data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
- data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
- data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
- data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
- data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
- data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
- data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
- data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Workflow
|
|
6
|
+
# Instrumentation concern for workflow execution tracking
|
|
7
|
+
#
|
|
8
|
+
# Provides comprehensive workflow tracking including:
|
|
9
|
+
# - Root execution record creation for the workflow
|
|
10
|
+
# - Timing metrics (started_at, completed_at, duration_ms)
|
|
11
|
+
# - Aggregate token usage and cost across all steps/branches
|
|
12
|
+
# - Workflow-specific metadata (workflow_id, workflow_type)
|
|
13
|
+
# - Error handling with proper status updates
|
|
14
|
+
#
|
|
15
|
+
# @api private
|
|
16
|
+
module Instrumentation
|
|
17
|
+
extend ActiveSupport::Concern
|
|
18
|
+
|
|
19
|
+
included do
|
|
20
|
+
# @!attribute [rw] execution_id
|
|
21
|
+
# The ID of the workflow's root execution record
|
|
22
|
+
# @return [Integer, nil]
|
|
23
|
+
attr_accessor :execution_id
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Wraps workflow execution with comprehensive metrics tracking
|
|
27
|
+
#
|
|
28
|
+
# Creates a root execution record for the workflow and tracks
|
|
29
|
+
# aggregate metrics from all child executions.
|
|
30
|
+
#
|
|
31
|
+
# @yield The block containing the workflow execution
|
|
32
|
+
# @return [WorkflowResult] The workflow result
|
|
33
|
+
def instrument_workflow(&block)
|
|
34
|
+
started_at = Time.current
|
|
35
|
+
@workflow_started_at = started_at
|
|
36
|
+
|
|
37
|
+
# Create workflow execution record
|
|
38
|
+
execution = create_workflow_execution(started_at)
|
|
39
|
+
@execution_id = execution&.id
|
|
40
|
+
@root_execution_id = execution&.id
|
|
41
|
+
|
|
42
|
+
begin
|
|
43
|
+
result = if self.class.timeout
|
|
44
|
+
Timeout.timeout(self.class.timeout) { yield }
|
|
45
|
+
else
|
|
46
|
+
yield
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
complete_workflow_execution(
|
|
50
|
+
execution,
|
|
51
|
+
completed_at: Time.current,
|
|
52
|
+
status: result.status,
|
|
53
|
+
result: result
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
result
|
|
57
|
+
rescue Timeout::Error => e
|
|
58
|
+
complete_workflow_execution(
|
|
59
|
+
execution,
|
|
60
|
+
completed_at: Time.current,
|
|
61
|
+
status: "timeout",
|
|
62
|
+
error: e
|
|
63
|
+
)
|
|
64
|
+
raise
|
|
65
|
+
rescue WorkflowCostExceededError => e
|
|
66
|
+
complete_workflow_execution(
|
|
67
|
+
execution,
|
|
68
|
+
completed_at: Time.current,
|
|
69
|
+
status: "error",
|
|
70
|
+
error: e
|
|
71
|
+
)
|
|
72
|
+
raise
|
|
73
|
+
rescue StandardError => e
|
|
74
|
+
complete_workflow_execution(
|
|
75
|
+
execution,
|
|
76
|
+
completed_at: Time.current,
|
|
77
|
+
status: "error",
|
|
78
|
+
error: e
|
|
79
|
+
)
|
|
80
|
+
raise
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
# Creates the initial workflow execution record
|
|
87
|
+
#
|
|
88
|
+
# @param started_at [Time] When the workflow started
|
|
89
|
+
# @return [RubyLLM::Agents::Execution, nil] The created record
|
|
90
|
+
def create_workflow_execution(started_at)
|
|
91
|
+
RubyLLM::Agents::Execution.create!(
|
|
92
|
+
agent_type: self.class.name,
|
|
93
|
+
agent_version: self.class.version,
|
|
94
|
+
model_id: "workflow",
|
|
95
|
+
temperature: nil,
|
|
96
|
+
started_at: started_at,
|
|
97
|
+
status: "running",
|
|
98
|
+
parameters: Redactor.redact(options),
|
|
99
|
+
metadata: workflow_metadata,
|
|
100
|
+
workflow_id: workflow_id,
|
|
101
|
+
workflow_type: workflow_type_name
|
|
102
|
+
)
|
|
103
|
+
rescue StandardError => e
|
|
104
|
+
Rails.logger.error("[RubyLLM::Agents::Workflow] Failed to create workflow execution: #{e.message}")
|
|
105
|
+
nil
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Updates the workflow execution record with completion data
|
|
109
|
+
#
|
|
110
|
+
# @param execution [Execution, nil] The execution record
|
|
111
|
+
# @param completed_at [Time] When the workflow completed
|
|
112
|
+
# @param status [String] Final status
|
|
113
|
+
# @param result [WorkflowResult, nil] The workflow result
|
|
114
|
+
# @param error [Exception, nil] The error if failed
|
|
115
|
+
def complete_workflow_execution(execution, completed_at:, status:, result: nil, error: nil)
|
|
116
|
+
return unless execution
|
|
117
|
+
|
|
118
|
+
started_at = execution.started_at
|
|
119
|
+
duration_ms = ((completed_at - started_at) * 1000).round
|
|
120
|
+
|
|
121
|
+
update_data = {
|
|
122
|
+
completed_at: completed_at,
|
|
123
|
+
duration_ms: duration_ms,
|
|
124
|
+
status: status
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
# Add aggregate metrics from result
|
|
128
|
+
if result
|
|
129
|
+
update_data.merge!(
|
|
130
|
+
input_tokens: result.input_tokens,
|
|
131
|
+
output_tokens: result.output_tokens,
|
|
132
|
+
total_tokens: result.total_tokens,
|
|
133
|
+
cached_tokens: result.cached_tokens,
|
|
134
|
+
input_cost: result.input_cost,
|
|
135
|
+
output_cost: result.output_cost,
|
|
136
|
+
total_cost: result.total_cost
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
# Store step/branch results summary
|
|
140
|
+
update_data[:response] = build_response_summary(result)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Add error data if failed
|
|
144
|
+
if error
|
|
145
|
+
update_data.merge!(
|
|
146
|
+
error_class: error.class.name,
|
|
147
|
+
error_message: error.message.to_s.truncate(65535)
|
|
148
|
+
)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
execution.update!(update_data)
|
|
152
|
+
rescue StandardError => e
|
|
153
|
+
Rails.logger.error("[RubyLLM::Agents::Workflow] Failed to update workflow execution #{execution&.id}: #{e.message}")
|
|
154
|
+
mark_workflow_failed!(execution, error: error || e)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Emergency fallback to mark workflow as failed
|
|
158
|
+
#
|
|
159
|
+
# @param execution [Execution, nil] The execution record
|
|
160
|
+
# @param error [Exception, nil] The error
|
|
161
|
+
def mark_workflow_failed!(execution, error: nil)
|
|
162
|
+
return unless execution&.id
|
|
163
|
+
|
|
164
|
+
update_data = {
|
|
165
|
+
status: "error",
|
|
166
|
+
completed_at: Time.current,
|
|
167
|
+
error_class: error&.class&.name || "UnknownError",
|
|
168
|
+
error_message: error&.message&.to_s&.truncate(65535) || "Unknown error"
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
execution.class.where(id: execution.id, status: "running").update_all(update_data)
|
|
172
|
+
rescue StandardError => e
|
|
173
|
+
Rails.logger.error("[RubyLLM::Agents::Workflow] CRITICAL: Failed to mark workflow #{execution&.id} as failed: #{e.message}")
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Builds a summary of step/branch results for storage
|
|
177
|
+
#
|
|
178
|
+
# @param result [WorkflowResult] The workflow result
|
|
179
|
+
# @return [Hash] Summary data
|
|
180
|
+
def build_response_summary(result)
|
|
181
|
+
summary = {
|
|
182
|
+
workflow_type: result.workflow_type,
|
|
183
|
+
status: result.status
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if result.steps.any?
|
|
187
|
+
summary[:steps] = result.steps.transform_values do |r|
|
|
188
|
+
{
|
|
189
|
+
status: r.respond_to?(:success?) ? (r.success? ? "success" : "error") : "unknown",
|
|
190
|
+
total_cost: r.respond_to?(:total_cost) ? r.total_cost : 0,
|
|
191
|
+
duration_ms: r.respond_to?(:duration_ms) ? r.duration_ms : nil
|
|
192
|
+
}
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
if result.branches.any?
|
|
197
|
+
summary[:branches] = result.branches.transform_values do |r|
|
|
198
|
+
next { status: "error" } if r.nil?
|
|
199
|
+
|
|
200
|
+
{
|
|
201
|
+
status: r.respond_to?(:success?) ? (r.success? ? "success" : "error") : "unknown",
|
|
202
|
+
total_cost: r.respond_to?(:total_cost) ? r.total_cost : 0,
|
|
203
|
+
duration_ms: r.respond_to?(:duration_ms) ? r.duration_ms : nil
|
|
204
|
+
}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
if result.routed_to
|
|
209
|
+
summary[:routed_to] = result.routed_to
|
|
210
|
+
summary[:classification_cost] = result.classification_cost
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
summary
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Returns workflow-specific metadata
|
|
217
|
+
#
|
|
218
|
+
# @return [Hash] Workflow metadata
|
|
219
|
+
def workflow_metadata
|
|
220
|
+
base_metadata = {
|
|
221
|
+
workflow_id: workflow_id,
|
|
222
|
+
workflow_type: workflow_type_name
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
# Allow subclasses to add custom metadata
|
|
226
|
+
if respond_to?(:execution_metadata, true)
|
|
227
|
+
base_metadata.merge(execution_metadata)
|
|
228
|
+
else
|
|
229
|
+
base_metadata
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Returns the workflow type name for storage
|
|
234
|
+
#
|
|
235
|
+
# @return [String] The workflow type (pipeline, parallel, router)
|
|
236
|
+
def workflow_type_name
|
|
237
|
+
case self
|
|
238
|
+
when Workflow::Pipeline then "pipeline"
|
|
239
|
+
when Workflow::Parallel then "parallel"
|
|
240
|
+
when Workflow::Router then "router"
|
|
241
|
+
else "workflow"
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Hook for subclasses to add custom metadata
|
|
246
|
+
#
|
|
247
|
+
# @return [Hash] Custom metadata
|
|
248
|
+
def execution_metadata
|
|
249
|
+
{}
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,282 @@
|
|
|
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
|
+
should_abort = false
|
|
153
|
+
|
|
154
|
+
# Create thread pool based on concurrency setting
|
|
155
|
+
threads = self.class.branches.map do |name, config|
|
|
156
|
+
Thread.new do
|
|
157
|
+
Thread.current.name = "parallel-#{name}"
|
|
158
|
+
Thread.current[:branch_name] = name
|
|
159
|
+
|
|
160
|
+
begin
|
|
161
|
+
# Check if we should abort early
|
|
162
|
+
if self.class.fail_fast? && should_abort
|
|
163
|
+
mutex.synchronize { results[name] = nil }
|
|
164
|
+
next
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Build input for this branch
|
|
168
|
+
branch_input = build_branch_input(name, config)
|
|
169
|
+
|
|
170
|
+
# Execute the branch
|
|
171
|
+
result = execute_agent(
|
|
172
|
+
config[:agent],
|
|
173
|
+
branch_input,
|
|
174
|
+
step_name: name,
|
|
175
|
+
&block
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
mutex.synchronize do
|
|
179
|
+
results[name] = result
|
|
180
|
+
|
|
181
|
+
# Check for failure
|
|
182
|
+
if result.respond_to?(:error?) && result.error? && !config[:optional]
|
|
183
|
+
should_abort = true if self.class.fail_fast?
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
rescue StandardError => e
|
|
187
|
+
mutex.synchronize do
|
|
188
|
+
errors[name] = e
|
|
189
|
+
results[name] = nil
|
|
190
|
+
should_abort = true if self.class.fail_fast? && !config[:optional]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Apply concurrency limit if set
|
|
197
|
+
if self.class.concurrency
|
|
198
|
+
threads.each_slice(self.class.concurrency) do |thread_batch|
|
|
199
|
+
thread_batch.each(&:join)
|
|
200
|
+
end
|
|
201
|
+
else
|
|
202
|
+
threads.each(&:join)
|
|
203
|
+
end
|
|
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
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
end
|