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,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
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "csv"
4
+ require "ruby_llm"
4
5
 
5
6
  require_relative "agents/version"
6
7
  require_relative "agents/configuration"