ruby_llm-agents 0.3.4 → 0.3.6

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 (86) 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 +23 -3
  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 +119 -7
  14. data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
  15. data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +13 -2
  16. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  17. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +136 -0
  18. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  19. data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +82 -20
  20. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  21. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  22. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  23. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
  24. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  25. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  26. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  27. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  28. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  29. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
  30. data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
  31. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  32. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  33. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  34. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  35. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  37. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  38. data/lib/generators/ruby_llm_agents/agent_generator.rb +9 -4
  39. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  41. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  42. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  43. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  44. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  48. data/lib/ruby_llm/agents/base/caching.rb +4 -7
  49. data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
  50. data/lib/ruby_llm/agents/base/dsl.rb +11 -0
  51. data/lib/ruby_llm/agents/base/execution.rb +62 -9
  52. data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
  53. data/lib/ruby_llm/agents/base.rb +26 -0
  54. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  55. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  56. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  57. data/lib/ruby_llm/agents/configuration.rb +40 -1
  58. data/lib/ruby_llm/agents/engine.rb +65 -1
  59. data/lib/ruby_llm/agents/inflections.rb +14 -0
  60. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  61. data/lib/ruby_llm/agents/reliability.rb +8 -2
  62. data/lib/ruby_llm/agents/version.rb +1 -1
  63. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  64. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  65. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  66. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  67. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  68. data/lib/ruby_llm/agents/workflow.rb +248 -0
  69. data/lib/ruby_llm/agents.rb +1 -0
  70. metadata +50 -60
  71. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  72. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
  73. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  74. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  75. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  76. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  77. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  78. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  79. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  80. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
  81. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
  82. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
  83. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  84. /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
  85. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
  86. /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,248 @@
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
+ # @!attribute [rw] description
46
+ # @return [String, nil] Description of the workflow
47
+ attr_accessor :_description
48
+
49
+ # Sets or returns the workflow version
50
+ #
51
+ # @param value [String, nil] Version string to set
52
+ # @return [String] The current version
53
+ def version(value = nil)
54
+ if value
55
+ self._version = value
56
+ else
57
+ _version || "1.0"
58
+ end
59
+ end
60
+
61
+ # Sets or returns the workflow timeout
62
+ #
63
+ # @param value [Integer, ActiveSupport::Duration, nil] Timeout to set
64
+ # @return [Integer, nil] The current timeout in seconds
65
+ def timeout(value = nil)
66
+ if value
67
+ self._timeout = value.is_a?(ActiveSupport::Duration) ? value.to_i : value
68
+ else
69
+ _timeout
70
+ end
71
+ end
72
+
73
+ # Sets or returns the maximum cost threshold
74
+ #
75
+ # @param value [Float, nil] Max cost in USD
76
+ # @return [Float, nil] The current max cost
77
+ def max_cost(value = nil)
78
+ if value
79
+ self._max_cost = value.to_f
80
+ else
81
+ _max_cost
82
+ end
83
+ end
84
+
85
+ # Sets or returns the workflow description
86
+ #
87
+ # @param value [String, nil] Description text to set
88
+ # @return [String, nil] The current description
89
+ def description(value = nil)
90
+ if value
91
+ self._description = value
92
+ else
93
+ _description
94
+ end
95
+ end
96
+
97
+ # Factory method to instantiate and execute a workflow
98
+ #
99
+ # @param kwargs [Hash] Parameters to pass to the workflow
100
+ # @yield [chunk] Optional block for streaming support
101
+ # @return [WorkflowResult] The workflow result with aggregate metrics
102
+ def call(**kwargs, &block)
103
+ new(**kwargs).call(&block)
104
+ end
105
+ end
106
+
107
+ # @!attribute [r] options
108
+ # @return [Hash] The options passed to the workflow
109
+ attr_reader :options
110
+
111
+ # @!attribute [r] workflow_id
112
+ # @return [String] Unique identifier for this workflow execution
113
+ attr_reader :workflow_id
114
+
115
+ # @!attribute [r] execution_id
116
+ # @return [Integer, nil] The ID of the root execution record
117
+ attr_reader :execution_id
118
+
119
+ # Creates a new workflow instance
120
+ #
121
+ # @param kwargs [Hash] Parameters for the workflow
122
+ def initialize(**kwargs)
123
+ @options = kwargs
124
+ @workflow_id = SecureRandom.uuid
125
+ @execution_id = nil
126
+ @accumulated_cost = 0.0
127
+ @step_results = {}
128
+ end
129
+
130
+ # Executes the workflow
131
+ #
132
+ # @abstract Subclasses must implement this method
133
+ # @yield [chunk] Optional block for streaming support
134
+ # @return [WorkflowResult] The workflow result
135
+ def call(&block)
136
+ raise NotImplementedError, "#{self.class} must implement #call"
137
+ end
138
+
139
+ protected
140
+
141
+ # Executes a single agent within the workflow context
142
+ #
143
+ # Passes execution metadata for proper tracking and hierarchy.
144
+ #
145
+ # @param agent_class [Class] The agent class to execute
146
+ # @param input [Hash] Parameters to pass to the agent
147
+ # @param step_name [String, Symbol] Name of the workflow step
148
+ # @yield [chunk] Optional block for streaming
149
+ # @return [Result] The agent result
150
+ def execute_agent(agent_class, input, step_name: nil, &block)
151
+ metadata = {
152
+ parent_execution_id: execution_id,
153
+ root_execution_id: root_execution_id,
154
+ workflow_id: workflow_id,
155
+ workflow_type: self.class.name,
156
+ workflow_step: step_name&.to_s
157
+ }.compact
158
+
159
+ # Merge workflow metadata with any existing metadata
160
+ merged_input = input.merge(
161
+ execution_metadata: metadata.merge(input[:execution_metadata] || {})
162
+ )
163
+
164
+ result = agent_class.call(**merged_input, &block)
165
+
166
+ # Track accumulated cost for max_cost enforcement
167
+ @accumulated_cost += result.total_cost if result.respond_to?(:total_cost) && result.total_cost
168
+
169
+ # Check cost threshold
170
+ check_cost_threshold!
171
+
172
+ result
173
+ end
174
+
175
+ # Returns the root execution ID for the workflow
176
+ #
177
+ # @return [Integer, nil] The root execution ID
178
+ def root_execution_id
179
+ @root_execution_id || execution_id
180
+ end
181
+
182
+ # Sets the root execution ID
183
+ #
184
+ # @param id [Integer] The root execution ID
185
+ def root_execution_id=(id)
186
+ @root_execution_id = id
187
+ end
188
+
189
+ # Checks if accumulated cost exceeds the threshold
190
+ #
191
+ # @raise [WorkflowCostExceededError] If cost exceeds max_cost
192
+ def check_cost_threshold!
193
+ return unless self.class.max_cost
194
+ return if @accumulated_cost <= self.class.max_cost
195
+
196
+ raise WorkflowCostExceededError.new(
197
+ "Workflow cost ($#{@accumulated_cost.round(4)}) exceeded maximum ($#{self.class.max_cost})",
198
+ accumulated_cost: @accumulated_cost,
199
+ max_cost: self.class.max_cost
200
+ )
201
+ end
202
+
203
+ # Hook for subclasses to transform input before a step
204
+ #
205
+ # @param step_name [Symbol] The step name
206
+ # @param context [Hash] Current workflow context
207
+ # @return [Hash] Transformed input for the step
208
+ def before_step(step_name, context)
209
+ method_name = :"before_#{step_name}"
210
+ if respond_to?(method_name, true)
211
+ send(method_name, context)
212
+ else
213
+ extract_step_input(context)
214
+ end
215
+ end
216
+
217
+ # Extracts input for the next step from context
218
+ #
219
+ # Default behavior: use the last step's content or original input
220
+ #
221
+ # @param context [Hash] Current workflow context
222
+ # @return [Hash] Input for the next step
223
+ def extract_step_input(context)
224
+ # Get the last non-input result
225
+ last_result = context.except(:input).values.last
226
+
227
+ if last_result.is_a?(Result) || last_result.is_a?(Workflow::Result)
228
+ # If content is a hash, use it; otherwise wrap it
229
+ content = last_result.content
230
+ content.is_a?(Hash) ? content : { input: content }
231
+ else
232
+ context[:input] || {}
233
+ end
234
+ end
235
+ end
236
+
237
+ # Error raised when workflow cost exceeds the configured maximum
238
+ class WorkflowCostExceededError < StandardError
239
+ attr_reader :accumulated_cost, :max_cost
240
+
241
+ def initialize(message, accumulated_cost:, max_cost:)
242
+ super(message)
243
+ @accumulated_cost = accumulated_cost
244
+ @max_cost = max_cost
245
+ end
246
+ end
247
+ end
248
+ 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"