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,429 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Workflow
|
|
6
|
+
# Conditional routing workflow pattern
|
|
7
|
+
#
|
|
8
|
+
# Classifies input and routes to the appropriate specialized agent.
|
|
9
|
+
# Uses a fast, cheap model for classification by default, then
|
|
10
|
+
# delegates to the selected route's agent.
|
|
11
|
+
#
|
|
12
|
+
# @example Basic router
|
|
13
|
+
# class SupportRouter < RubyLLM::Agents::Workflow::Router
|
|
14
|
+
# version "1.0"
|
|
15
|
+
#
|
|
16
|
+
# classifier_model "gpt-4o-mini"
|
|
17
|
+
#
|
|
18
|
+
# route :billing, to: BillingAgent, description: "Billing, charges, refunds"
|
|
19
|
+
# route :technical, to: TechSupportAgent, description: "Bugs, errors, crashes"
|
|
20
|
+
# route :sales, to: SalesAgent, description: "Pricing, plans, upgrades"
|
|
21
|
+
# route :default, to: GeneralAgent # Fallback
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# result = SupportRouter.call(message: "I was charged twice")
|
|
25
|
+
# result.routed_to # :billing
|
|
26
|
+
# result.classification_cost # Cost of classifier
|
|
27
|
+
# result.content # BillingAgent's response
|
|
28
|
+
#
|
|
29
|
+
# @example With custom classification
|
|
30
|
+
# class CustomRouter < RubyLLM::Agents::Workflow::Router
|
|
31
|
+
# route :fast, to: FastAgent, description: "Quick tasks"
|
|
32
|
+
# route :slow, to: SlowAgent, description: "Complex tasks"
|
|
33
|
+
#
|
|
34
|
+
# def classify(input)
|
|
35
|
+
# # Custom classification logic
|
|
36
|
+
# input[:message].length > 100 ? :slow : :fast
|
|
37
|
+
# end
|
|
38
|
+
# end
|
|
39
|
+
#
|
|
40
|
+
# @example With input transformation
|
|
41
|
+
# class TransformRouter < RubyLLM::Agents::Workflow::Router
|
|
42
|
+
# route :analyze, to: AnalyzerAgent, description: "Analysis requests"
|
|
43
|
+
#
|
|
44
|
+
# def before_route(input, chosen_route)
|
|
45
|
+
# input.merge(route_context: chosen_route, priority: "high")
|
|
46
|
+
# end
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# @api public
|
|
50
|
+
class Router < Workflow
|
|
51
|
+
class << self
|
|
52
|
+
# Returns the defined routes
|
|
53
|
+
#
|
|
54
|
+
# @return [Hash<Symbol, Hash>] Route configurations
|
|
55
|
+
def routes
|
|
56
|
+
@routes ||= {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Inherits routes from parent class
|
|
60
|
+
def inherited(subclass)
|
|
61
|
+
super
|
|
62
|
+
subclass.instance_variable_set(:@routes, routes.dup)
|
|
63
|
+
subclass.instance_variable_set(:@classifier_model, @classifier_model)
|
|
64
|
+
subclass.instance_variable_set(:@classifier_temperature, @classifier_temperature)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Defines a route
|
|
68
|
+
#
|
|
69
|
+
# @param name [Symbol] Route identifier
|
|
70
|
+
# @param to [Class] The agent class to route to
|
|
71
|
+
# @param description [String, nil] Description for LLM classification
|
|
72
|
+
# @param match [Proc, nil] Lambda for rule-based matching (bypasses LLM)
|
|
73
|
+
# @return [void]
|
|
74
|
+
#
|
|
75
|
+
# @example Basic route
|
|
76
|
+
# route :billing, to: BillingAgent, description: "Billing and payment issues"
|
|
77
|
+
#
|
|
78
|
+
# @example Default/fallback route
|
|
79
|
+
# route :default, to: GeneralAgent
|
|
80
|
+
#
|
|
81
|
+
# @example With rule-based matching
|
|
82
|
+
# route :urgent, to: UrgentAgent, match: ->(input) { input[:priority] == "urgent" }
|
|
83
|
+
def route(name, to:, description: nil, match: nil)
|
|
84
|
+
routes[name] = {
|
|
85
|
+
agent: to,
|
|
86
|
+
description: description,
|
|
87
|
+
match: match
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Sets or returns the classifier model
|
|
92
|
+
#
|
|
93
|
+
# @param value [String, nil] Model ID
|
|
94
|
+
# @return [String] The classifier model
|
|
95
|
+
def classifier_model(value = nil)
|
|
96
|
+
if value
|
|
97
|
+
@classifier_model = value
|
|
98
|
+
else
|
|
99
|
+
@classifier_model || RubyLLM::Agents.configuration.default_model || "gpt-4o-mini"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Sets or returns the classifier temperature
|
|
104
|
+
#
|
|
105
|
+
# @param value [Float, nil] Temperature (0.0-2.0)
|
|
106
|
+
# @return [Float] The classifier temperature
|
|
107
|
+
def classifier_temperature(value = nil)
|
|
108
|
+
if value
|
|
109
|
+
@classifier_temperature = value
|
|
110
|
+
else
|
|
111
|
+
@classifier_temperature || 0.0
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Executes the router workflow
|
|
117
|
+
#
|
|
118
|
+
# Classifies input and routes to the appropriate agent.
|
|
119
|
+
#
|
|
120
|
+
# @yield [chunk] Yields chunks when streaming (passed to routed agent)
|
|
121
|
+
# @return [WorkflowResult] The router result
|
|
122
|
+
def call(&block)
|
|
123
|
+
instrument_workflow do
|
|
124
|
+
execute_router(&block)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Override to provide custom classification logic
|
|
129
|
+
#
|
|
130
|
+
# @param input [Hash] The input to classify
|
|
131
|
+
# @return [Symbol] The route name to use
|
|
132
|
+
def classify(input)
|
|
133
|
+
# First, try rule-based matching
|
|
134
|
+
rule_match = try_rule_matching(input)
|
|
135
|
+
return rule_match if rule_match
|
|
136
|
+
|
|
137
|
+
# Fall back to LLM classification
|
|
138
|
+
llm_classify(input)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
protected
|
|
142
|
+
|
|
143
|
+
# Hook to transform input before routing
|
|
144
|
+
#
|
|
145
|
+
# @param input [Hash] Original input
|
|
146
|
+
# @param chosen_route [Symbol] The selected route
|
|
147
|
+
# @return [Hash] Transformed input for the routed agent
|
|
148
|
+
def before_route(input, chosen_route)
|
|
149
|
+
input
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
private
|
|
153
|
+
|
|
154
|
+
# Executes the router logic
|
|
155
|
+
#
|
|
156
|
+
# @return [WorkflowResult] The router result
|
|
157
|
+
def execute_router(&block)
|
|
158
|
+
# Classify the input
|
|
159
|
+
classification_start = Time.current
|
|
160
|
+
chosen_route, classifier_result = perform_classification(options)
|
|
161
|
+
classification_time = ((Time.current - classification_start) * 1000).round
|
|
162
|
+
|
|
163
|
+
# Validate route exists
|
|
164
|
+
route_config = self.class.routes[chosen_route]
|
|
165
|
+
unless route_config
|
|
166
|
+
# Fall back to default route
|
|
167
|
+
chosen_route = :default
|
|
168
|
+
route_config = self.class.routes[:default]
|
|
169
|
+
|
|
170
|
+
unless route_config
|
|
171
|
+
raise RouterError, "Route '#{chosen_route}' not found and no default route defined"
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Transform input for the routed agent
|
|
176
|
+
routed_input = before_route(options, chosen_route)
|
|
177
|
+
|
|
178
|
+
# Execute the routed agent
|
|
179
|
+
agent_result = execute_agent(
|
|
180
|
+
route_config[:agent],
|
|
181
|
+
routed_input,
|
|
182
|
+
step_name: chosen_route,
|
|
183
|
+
&block
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
build_router_result(
|
|
187
|
+
content: agent_result.content,
|
|
188
|
+
routed_to: chosen_route,
|
|
189
|
+
routed_result: agent_result,
|
|
190
|
+
classifier_result: classifier_result,
|
|
191
|
+
classification_time_ms: classification_time
|
|
192
|
+
)
|
|
193
|
+
rescue RouterError
|
|
194
|
+
# Re-raise configuration errors
|
|
195
|
+
raise
|
|
196
|
+
rescue StandardError => e
|
|
197
|
+
build_router_result(
|
|
198
|
+
content: nil,
|
|
199
|
+
routed_to: nil,
|
|
200
|
+
classifier_result: classifier_result,
|
|
201
|
+
error: e,
|
|
202
|
+
status: "error"
|
|
203
|
+
)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Performs classification and returns route + classifier result
|
|
207
|
+
#
|
|
208
|
+
# @param input [Hash] Input to classify
|
|
209
|
+
# @return [Array<Symbol, Result>] Route name and classifier result
|
|
210
|
+
def perform_classification(input)
|
|
211
|
+
# Check if subclass overrides classify completely
|
|
212
|
+
if self.class.instance_method(:classify).owner != Router
|
|
213
|
+
# Custom classify method - no classifier_result
|
|
214
|
+
route = classify(input)
|
|
215
|
+
[route, nil]
|
|
216
|
+
else
|
|
217
|
+
classify_with_result(input)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Classifies input and returns both route and result
|
|
222
|
+
#
|
|
223
|
+
# @param input [Hash] Input to classify
|
|
224
|
+
# @return [Array<Symbol, Result>] Route name and classifier result
|
|
225
|
+
def classify_with_result(input)
|
|
226
|
+
# First, try rule-based matching
|
|
227
|
+
rule_match = try_rule_matching(input)
|
|
228
|
+
return [rule_match, nil] if rule_match
|
|
229
|
+
|
|
230
|
+
# Fall back to LLM classification
|
|
231
|
+
result = llm_classify_with_result(input)
|
|
232
|
+
route = parse_classification(result.content)
|
|
233
|
+
[route, result]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Tries rule-based matching before LLM classification
|
|
237
|
+
#
|
|
238
|
+
# @param input [Hash] Input to match
|
|
239
|
+
# @return [Symbol, nil] Matched route or nil
|
|
240
|
+
def try_rule_matching(input)
|
|
241
|
+
self.class.routes.each do |name, config|
|
|
242
|
+
next unless config[:match]
|
|
243
|
+
next if name == :default
|
|
244
|
+
|
|
245
|
+
begin
|
|
246
|
+
return name if config[:match].call(input)
|
|
247
|
+
rescue StandardError => e
|
|
248
|
+
Rails.logger.warn("[RubyLLM::Agents::Router] Match rule for #{name} failed: #{e.message}")
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
nil
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
# Classifies input using LLM (returns only route)
|
|
255
|
+
#
|
|
256
|
+
# @param input [Hash] Input to classify
|
|
257
|
+
# @return [Symbol] The classified route
|
|
258
|
+
def llm_classify(input)
|
|
259
|
+
result = llm_classify_with_result(input)
|
|
260
|
+
parse_classification(result.content)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Classifies input using LLM and returns full result
|
|
264
|
+
#
|
|
265
|
+
# @param input [Hash] Input to classify
|
|
266
|
+
# @return [Result] The classifier result
|
|
267
|
+
def llm_classify_with_result(input)
|
|
268
|
+
prompt = build_classifier_prompt(input)
|
|
269
|
+
|
|
270
|
+
# Use RubyLLM directly for classification
|
|
271
|
+
client = RubyLLM.chat
|
|
272
|
+
.with_model(self.class.classifier_model)
|
|
273
|
+
.with_temperature(self.class.classifier_temperature)
|
|
274
|
+
|
|
275
|
+
response = client.ask(prompt)
|
|
276
|
+
|
|
277
|
+
# Build a simple result for tracking
|
|
278
|
+
build_classifier_result(response)
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
# Builds the classification prompt
|
|
282
|
+
#
|
|
283
|
+
# @param input [Hash] Input to classify
|
|
284
|
+
# @return [String] The classification prompt
|
|
285
|
+
def build_classifier_prompt(input)
|
|
286
|
+
routes_with_descriptions = self.class.routes
|
|
287
|
+
.reject { |name, _| name == :default }
|
|
288
|
+
.select { |_, config| config[:description] }
|
|
289
|
+
|
|
290
|
+
if routes_with_descriptions.empty?
|
|
291
|
+
raise RouterError, "No routes with descriptions defined. Add descriptions or override #classify"
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
routes_desc = routes_with_descriptions.map do |name, config|
|
|
295
|
+
"- #{name}: #{config[:description]}"
|
|
296
|
+
end.join("\n")
|
|
297
|
+
|
|
298
|
+
# Extract the main content to classify
|
|
299
|
+
content = extract_classifiable_content(input)
|
|
300
|
+
|
|
301
|
+
<<~PROMPT
|
|
302
|
+
Classify the following input into exactly one category.
|
|
303
|
+
|
|
304
|
+
Categories:
|
|
305
|
+
#{routes_desc}
|
|
306
|
+
|
|
307
|
+
Input: #{content}
|
|
308
|
+
|
|
309
|
+
Respond with ONLY the category name, nothing else. The response must be exactly one of: #{routes_with_descriptions.keys.join(', ')}
|
|
310
|
+
PROMPT
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Extracts the main content from input for classification
|
|
314
|
+
#
|
|
315
|
+
# @param input [Hash] The input
|
|
316
|
+
# @return [String] Content to classify
|
|
317
|
+
def extract_classifiable_content(input)
|
|
318
|
+
# Try common keys
|
|
319
|
+
%i[message text content query input prompt].each do |key|
|
|
320
|
+
return input[key].to_s if input[key].present?
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Fall back to first string value or serialized input
|
|
324
|
+
input.values.find { |v| v.is_a?(String) && v.present? } || input.to_json
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Parses the LLM classification response
|
|
328
|
+
#
|
|
329
|
+
# @param content [String] The LLM response
|
|
330
|
+
# @return [Symbol] The parsed route name
|
|
331
|
+
def parse_classification(content)
|
|
332
|
+
return :default unless content
|
|
333
|
+
|
|
334
|
+
# Clean up response
|
|
335
|
+
cleaned = content.to_s.strip.downcase.gsub(/[^a-z0-9_]/, "")
|
|
336
|
+
|
|
337
|
+
# Find matching route
|
|
338
|
+
self.class.routes.keys.find { |name| name.to_s == cleaned } || :default
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
# Builds a result object for the classifier
|
|
342
|
+
#
|
|
343
|
+
# @param response [RubyLLM::Message] The LLM response
|
|
344
|
+
# @return [Result] Simple result for tracking
|
|
345
|
+
def build_classifier_result(response)
|
|
346
|
+
RubyLLM::Agents::Result.new(
|
|
347
|
+
content: response.content,
|
|
348
|
+
input_tokens: response.input_tokens,
|
|
349
|
+
output_tokens: response.output_tokens,
|
|
350
|
+
total_cost: calculate_classifier_cost(response),
|
|
351
|
+
model_id: self.class.classifier_model,
|
|
352
|
+
temperature: self.class.classifier_temperature
|
|
353
|
+
)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Calculates classifier cost
|
|
357
|
+
#
|
|
358
|
+
# @param response [RubyLLM::Message] The LLM response
|
|
359
|
+
# @return [Float] Cost in USD
|
|
360
|
+
def calculate_classifier_cost(response)
|
|
361
|
+
model_info, _provider = RubyLLM::Models.resolve(self.class.classifier_model)
|
|
362
|
+
return 0.0 unless model_info&.pricing
|
|
363
|
+
|
|
364
|
+
input_price = model_info.pricing.text_tokens&.input || 0
|
|
365
|
+
output_price = model_info.pricing.text_tokens&.output || 0
|
|
366
|
+
|
|
367
|
+
input_cost = ((response.input_tokens || 0) / 1_000_000.0) * input_price
|
|
368
|
+
output_cost = ((response.output_tokens || 0) / 1_000_000.0) * output_price
|
|
369
|
+
|
|
370
|
+
input_cost + output_cost
|
|
371
|
+
rescue StandardError
|
|
372
|
+
0.0
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Builds the final router result
|
|
376
|
+
#
|
|
377
|
+
# @param content [Object] Final content
|
|
378
|
+
# @param routed_to [Symbol] The selected route
|
|
379
|
+
# @param routed_result [Result, nil] The routed agent's result
|
|
380
|
+
# @param classifier_result [Result, nil] The classifier result
|
|
381
|
+
# @param classification_time_ms [Integer, nil] Classification time
|
|
382
|
+
# @param error [Exception, nil] Error if failed
|
|
383
|
+
# @param status [String] Final status
|
|
384
|
+
# @return [WorkflowResult] The router result
|
|
385
|
+
def build_router_result(content:, routed_to:, routed_result: nil, classifier_result: nil,
|
|
386
|
+
classification_time_ms: nil, error: nil, status: nil)
|
|
387
|
+
# Build branches hash with routed result
|
|
388
|
+
branches = {}
|
|
389
|
+
branches[routed_to] = routed_result if routed_to && routed_result
|
|
390
|
+
|
|
391
|
+
# Build classification info
|
|
392
|
+
classification = {
|
|
393
|
+
route: routed_to,
|
|
394
|
+
classifier_model: self.class.classifier_model,
|
|
395
|
+
classification_time_ms: classification_time_ms,
|
|
396
|
+
method: classifier_result ? "llm" : "rule"
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
final_status = status || (error ? "error" : "success")
|
|
400
|
+
|
|
401
|
+
result = Workflow::Result.new(
|
|
402
|
+
content: content,
|
|
403
|
+
workflow_type: self.class.name,
|
|
404
|
+
workflow_id: workflow_id,
|
|
405
|
+
routed_to: routed_to,
|
|
406
|
+
classification: classification,
|
|
407
|
+
classifier_result: classifier_result,
|
|
408
|
+
branches: branches,
|
|
409
|
+
status: final_status,
|
|
410
|
+
started_at: @workflow_started_at,
|
|
411
|
+
completed_at: Time.current,
|
|
412
|
+
duration_ms: (((Time.current - @workflow_started_at) * 1000).round if @workflow_started_at)
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if error
|
|
416
|
+
result.instance_variable_set(:@error_class, error.class.name)
|
|
417
|
+
result.instance_variable_set(:@error_message, error.message)
|
|
418
|
+
result.instance_variable_set(:@errors, { routing: error })
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
result
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Error raised for router-specific issues
|
|
426
|
+
class RouterError < StandardError; end
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
end
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "workflow/result"
|
|
4
|
+
require_relative "workflow/instrumentation"
|
|
5
|
+
require_relative "workflow/pipeline"
|
|
6
|
+
require_relative "workflow/parallel"
|
|
7
|
+
require_relative "workflow/router"
|
|
8
|
+
|
|
9
|
+
module RubyLLM
|
|
10
|
+
module Agents
|
|
11
|
+
# Base class for workflow orchestration
|
|
12
|
+
#
|
|
13
|
+
# Provides shared functionality for composing multiple agents into
|
|
14
|
+
# coordinated workflows. Subclasses implement specific patterns:
|
|
15
|
+
# - Pipeline: Sequential execution with data flowing between steps
|
|
16
|
+
# - Parallel: Concurrent execution with result aggregation
|
|
17
|
+
# - Router: Conditional dispatch based on classification
|
|
18
|
+
#
|
|
19
|
+
# @example Creating a custom workflow
|
|
20
|
+
# class MyWorkflow < RubyLLM::Agents::Workflow
|
|
21
|
+
# version "1.0"
|
|
22
|
+
# # ... workflow-specific DSL
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @see RubyLLM::Agents::Workflow::Pipeline
|
|
26
|
+
# @see RubyLLM::Agents::Workflow::Parallel
|
|
27
|
+
# @see RubyLLM::Agents::Workflow::Router
|
|
28
|
+
# @api public
|
|
29
|
+
class Workflow
|
|
30
|
+
include Workflow::Instrumentation
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# @!attribute [rw] version
|
|
34
|
+
# @return [String] Version identifier for the workflow
|
|
35
|
+
attr_accessor :_version
|
|
36
|
+
|
|
37
|
+
# @!attribute [rw] timeout
|
|
38
|
+
# @return [Integer, nil] Total timeout for the entire workflow in seconds
|
|
39
|
+
attr_accessor :_timeout
|
|
40
|
+
|
|
41
|
+
# @!attribute [rw] max_cost
|
|
42
|
+
# @return [Float, nil] Maximum cost threshold for the workflow
|
|
43
|
+
attr_accessor :_max_cost
|
|
44
|
+
|
|
45
|
+
# Sets or returns the workflow version
|
|
46
|
+
#
|
|
47
|
+
# @param value [String, nil] Version string to set
|
|
48
|
+
# @return [String] The current version
|
|
49
|
+
def version(value = nil)
|
|
50
|
+
if value
|
|
51
|
+
self._version = value
|
|
52
|
+
else
|
|
53
|
+
_version || "1.0"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sets or returns the workflow timeout
|
|
58
|
+
#
|
|
59
|
+
# @param value [Integer, ActiveSupport::Duration, nil] Timeout to set
|
|
60
|
+
# @return [Integer, nil] The current timeout in seconds
|
|
61
|
+
def timeout(value = nil)
|
|
62
|
+
if value
|
|
63
|
+
self._timeout = value.is_a?(ActiveSupport::Duration) ? value.to_i : value
|
|
64
|
+
else
|
|
65
|
+
_timeout
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Sets or returns the maximum cost threshold
|
|
70
|
+
#
|
|
71
|
+
# @param value [Float, nil] Max cost in USD
|
|
72
|
+
# @return [Float, nil] The current max cost
|
|
73
|
+
def max_cost(value = nil)
|
|
74
|
+
if value
|
|
75
|
+
self._max_cost = value.to_f
|
|
76
|
+
else
|
|
77
|
+
_max_cost
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Factory method to instantiate and execute a workflow
|
|
82
|
+
#
|
|
83
|
+
# @param kwargs [Hash] Parameters to pass to the workflow
|
|
84
|
+
# @yield [chunk] Optional block for streaming support
|
|
85
|
+
# @return [WorkflowResult] The workflow result with aggregate metrics
|
|
86
|
+
def call(**kwargs, &block)
|
|
87
|
+
new(**kwargs).call(&block)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @!attribute [r] options
|
|
92
|
+
# @return [Hash] The options passed to the workflow
|
|
93
|
+
attr_reader :options
|
|
94
|
+
|
|
95
|
+
# @!attribute [r] workflow_id
|
|
96
|
+
# @return [String] Unique identifier for this workflow execution
|
|
97
|
+
attr_reader :workflow_id
|
|
98
|
+
|
|
99
|
+
# @!attribute [r] execution_id
|
|
100
|
+
# @return [Integer, nil] The ID of the root execution record
|
|
101
|
+
attr_reader :execution_id
|
|
102
|
+
|
|
103
|
+
# Creates a new workflow instance
|
|
104
|
+
#
|
|
105
|
+
# @param kwargs [Hash] Parameters for the workflow
|
|
106
|
+
def initialize(**kwargs)
|
|
107
|
+
@options = kwargs
|
|
108
|
+
@workflow_id = SecureRandom.uuid
|
|
109
|
+
@execution_id = nil
|
|
110
|
+
@accumulated_cost = 0.0
|
|
111
|
+
@step_results = {}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Executes the workflow
|
|
115
|
+
#
|
|
116
|
+
# @abstract Subclasses must implement this method
|
|
117
|
+
# @yield [chunk] Optional block for streaming support
|
|
118
|
+
# @return [WorkflowResult] The workflow result
|
|
119
|
+
def call(&block)
|
|
120
|
+
raise NotImplementedError, "#{self.class} must implement #call"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
protected
|
|
124
|
+
|
|
125
|
+
# Executes a single agent within the workflow context
|
|
126
|
+
#
|
|
127
|
+
# Passes execution metadata for proper tracking and hierarchy.
|
|
128
|
+
#
|
|
129
|
+
# @param agent_class [Class] The agent class to execute
|
|
130
|
+
# @param input [Hash] Parameters to pass to the agent
|
|
131
|
+
# @param step_name [String, Symbol] Name of the workflow step
|
|
132
|
+
# @yield [chunk] Optional block for streaming
|
|
133
|
+
# @return [Result] The agent result
|
|
134
|
+
def execute_agent(agent_class, input, step_name: nil, &block)
|
|
135
|
+
metadata = {
|
|
136
|
+
parent_execution_id: execution_id,
|
|
137
|
+
root_execution_id: root_execution_id,
|
|
138
|
+
workflow_id: workflow_id,
|
|
139
|
+
workflow_type: self.class.name,
|
|
140
|
+
workflow_step: step_name&.to_s
|
|
141
|
+
}.compact
|
|
142
|
+
|
|
143
|
+
# Merge workflow metadata with any existing metadata
|
|
144
|
+
merged_input = input.merge(
|
|
145
|
+
execution_metadata: metadata.merge(input[:execution_metadata] || {})
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
result = agent_class.call(**merged_input, &block)
|
|
149
|
+
|
|
150
|
+
# Track accumulated cost for max_cost enforcement
|
|
151
|
+
@accumulated_cost += result.total_cost if result.respond_to?(:total_cost) && result.total_cost
|
|
152
|
+
|
|
153
|
+
# Check cost threshold
|
|
154
|
+
check_cost_threshold!
|
|
155
|
+
|
|
156
|
+
result
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Returns the root execution ID for the workflow
|
|
160
|
+
#
|
|
161
|
+
# @return [Integer, nil] The root execution ID
|
|
162
|
+
def root_execution_id
|
|
163
|
+
@root_execution_id || execution_id
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Sets the root execution ID
|
|
167
|
+
#
|
|
168
|
+
# @param id [Integer] The root execution ID
|
|
169
|
+
def root_execution_id=(id)
|
|
170
|
+
@root_execution_id = id
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Checks if accumulated cost exceeds the threshold
|
|
174
|
+
#
|
|
175
|
+
# @raise [WorkflowCostExceededError] If cost exceeds max_cost
|
|
176
|
+
def check_cost_threshold!
|
|
177
|
+
return unless self.class.max_cost
|
|
178
|
+
return if @accumulated_cost <= self.class.max_cost
|
|
179
|
+
|
|
180
|
+
raise WorkflowCostExceededError.new(
|
|
181
|
+
"Workflow cost ($#{@accumulated_cost.round(4)}) exceeded maximum ($#{self.class.max_cost})",
|
|
182
|
+
accumulated_cost: @accumulated_cost,
|
|
183
|
+
max_cost: self.class.max_cost
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Hook for subclasses to transform input before a step
|
|
188
|
+
#
|
|
189
|
+
# @param step_name [Symbol] The step name
|
|
190
|
+
# @param context [Hash] Current workflow context
|
|
191
|
+
# @return [Hash] Transformed input for the step
|
|
192
|
+
def before_step(step_name, context)
|
|
193
|
+
method_name = :"before_#{step_name}"
|
|
194
|
+
if respond_to?(method_name, true)
|
|
195
|
+
send(method_name, context)
|
|
196
|
+
else
|
|
197
|
+
extract_step_input(context)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Extracts input for the next step from context
|
|
202
|
+
#
|
|
203
|
+
# Default behavior: use the last step's content or original input
|
|
204
|
+
#
|
|
205
|
+
# @param context [Hash] Current workflow context
|
|
206
|
+
# @return [Hash] Input for the next step
|
|
207
|
+
def extract_step_input(context)
|
|
208
|
+
# Get the last non-input result
|
|
209
|
+
last_result = context.except(:input).values.last
|
|
210
|
+
|
|
211
|
+
if last_result.is_a?(Result) || last_result.is_a?(Workflow::Result)
|
|
212
|
+
# If content is a hash, use it; otherwise wrap it
|
|
213
|
+
content = last_result.content
|
|
214
|
+
content.is_a?(Hash) ? content : { input: content }
|
|
215
|
+
else
|
|
216
|
+
context[:input] || {}
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Error raised when workflow cost exceeds the configured maximum
|
|
222
|
+
class WorkflowCostExceededError < StandardError
|
|
223
|
+
attr_reader :accumulated_cost, :max_cost
|
|
224
|
+
|
|
225
|
+
def initialize(message, accumulated_cost:, max_cost:)
|
|
226
|
+
super(message)
|
|
227
|
+
@accumulated_cost = accumulated_cost
|
|
228
|
+
@max_cost = max_cost
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|