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,299 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Execution
6
+ # Workflow concern for workflow-related methods and aggregate calculations
7
+ #
8
+ # Provides instance methods for determining workflow type, calculating
9
+ # aggregate statistics across child executions, and retrieving workflow
10
+ # step/branch information.
11
+ #
12
+ # @see RubyLLM::Agents::Execution
13
+ # @api public
14
+ module Workflow
15
+ extend ActiveSupport::Concern
16
+
17
+ # Returns whether this is a workflow execution (has workflow_type)
18
+ #
19
+ # @return [Boolean] true if this is a workflow execution
20
+ def workflow?
21
+ workflow_type.present?
22
+ end
23
+
24
+ # Returns whether this is a pipeline workflow
25
+ #
26
+ # @return [Boolean] true if workflow_type is "pipeline"
27
+ def pipeline_workflow?
28
+ workflow_type == "pipeline"
29
+ end
30
+
31
+ # Returns whether this is a parallel workflow
32
+ #
33
+ # @return [Boolean] true if workflow_type is "parallel"
34
+ def parallel_workflow?
35
+ workflow_type == "parallel"
36
+ end
37
+
38
+ # Returns whether this is a router workflow
39
+ #
40
+ # @return [Boolean] true if workflow_type is "router"
41
+ def router_workflow?
42
+ workflow_type == "router"
43
+ end
44
+
45
+ # Returns whether this is a root workflow execution (top-level)
46
+ #
47
+ # @return [Boolean] true if this is a workflow with no parent
48
+ def root_workflow?
49
+ workflow? && root?
50
+ end
51
+
52
+ # Returns all workflow steps/branches ordered by creation time
53
+ #
54
+ # @return [ActiveRecord::Relation] Child executions for this workflow
55
+ def workflow_steps
56
+ child_executions.order(:created_at)
57
+ end
58
+
59
+ # Returns the count of child workflow steps
60
+ #
61
+ # @return [Integer] Number of child executions
62
+ def workflow_steps_count
63
+ child_executions.count
64
+ end
65
+
66
+ # @!group Aggregate Statistics
67
+
68
+ # Returns aggregate stats for all child executions
69
+ #
70
+ # @return [Hash] Aggregated metrics including cost, tokens, duration
71
+ def workflow_aggregate_stats
72
+ return @workflow_aggregate_stats if defined?(@workflow_aggregate_stats)
73
+
74
+ children = child_executions.to_a
75
+ return empty_aggregate_stats if children.empty?
76
+
77
+ @workflow_aggregate_stats = {
78
+ total_cost: children.sum { |c| c.total_cost || 0 },
79
+ total_tokens: children.sum { |c| c.total_tokens || 0 },
80
+ input_tokens: children.sum { |c| c.input_tokens || 0 },
81
+ output_tokens: children.sum { |c| c.output_tokens || 0 },
82
+ total_duration_ms: children.sum { |c| c.duration_ms || 0 },
83
+ wall_clock_ms: calculate_wall_clock_duration(children),
84
+ steps_count: children.size,
85
+ successful_count: children.count(&:status_success?),
86
+ failed_count: children.count(&:status_error?),
87
+ timeout_count: children.count(&:status_timeout?),
88
+ running_count: children.count(&:status_running?),
89
+ success_rate: calculate_success_rate(children),
90
+ models_used: children.map(&:model_id).uniq.compact
91
+ }
92
+ end
93
+
94
+ # Returns aggregate total cost across all child executions
95
+ #
96
+ # @return [Float] Total cost in USD
97
+ def workflow_total_cost
98
+ workflow_aggregate_stats[:total_cost]
99
+ end
100
+
101
+ # Returns aggregate total tokens across all child executions
102
+ #
103
+ # @return [Integer] Total tokens used
104
+ def workflow_total_tokens
105
+ workflow_aggregate_stats[:total_tokens]
106
+ end
107
+
108
+ # Returns the wall-clock duration (from first start to last completion)
109
+ #
110
+ # @return [Integer, nil] Duration in milliseconds
111
+ def workflow_wall_clock_ms
112
+ workflow_aggregate_stats[:wall_clock_ms]
113
+ end
114
+
115
+ # Returns the sum of all step durations (may exceed wall-clock for parallel)
116
+ #
117
+ # @return [Integer] Sum of all durations in milliseconds
118
+ def workflow_sum_duration_ms
119
+ workflow_aggregate_stats[:total_duration_ms]
120
+ end
121
+
122
+ # Returns the overall workflow status based on child executions
123
+ #
124
+ # @return [Symbol] :success, :error, :timeout, :running, or :pending
125
+ def workflow_overall_status
126
+ stats = workflow_aggregate_stats
127
+ return :pending if stats[:steps_count].zero?
128
+ return :running if stats[:running_count] > 0
129
+ return :error if stats[:failed_count] > 0
130
+ return :timeout if stats[:timeout_count] > 0
131
+
132
+ :success
133
+ end
134
+
135
+ # @!endgroup
136
+
137
+ # @!group Pipeline-specific Methods
138
+
139
+ # Returns pipeline steps in order with their status
140
+ #
141
+ # @return [Array<Hash>] Array of step hashes with name, status, duration, cost
142
+ def pipeline_steps_detail
143
+ return [] unless pipeline_workflow?
144
+
145
+ workflow_steps.map do |step|
146
+ {
147
+ id: step.id,
148
+ name: step.workflow_step || step.agent_type.gsub(/Agent$/, ""),
149
+ agent_type: step.agent_type,
150
+ status: step.status,
151
+ duration_ms: step.duration_ms,
152
+ total_cost: step.total_cost,
153
+ total_tokens: step.total_tokens,
154
+ model_id: step.model_id
155
+ }
156
+ end
157
+ end
158
+
159
+ # @!endgroup
160
+
161
+ # @!group Parallel-specific Methods
162
+
163
+ # Returns parallel branches with their status and timing
164
+ #
165
+ # @return [Array<Hash>] Array of branch hashes
166
+ def parallel_branches_detail
167
+ return [] unless parallel_workflow?
168
+
169
+ branches = workflow_steps.to_a
170
+ return [] if branches.empty?
171
+
172
+ # Find min/max for timing comparison
173
+ min_duration = branches.map { |b| b.duration_ms || 0 }.min
174
+ max_duration = branches.map { |b| b.duration_ms || 0 }.max
175
+
176
+ branches.map do |branch|
177
+ duration = branch.duration_ms || 0
178
+ {
179
+ id: branch.id,
180
+ name: branch.workflow_step || branch.agent_type.gsub(/Agent$/, ""),
181
+ agent_type: branch.agent_type,
182
+ status: branch.status,
183
+ duration_ms: duration,
184
+ total_cost: branch.total_cost,
185
+ total_tokens: branch.total_tokens,
186
+ model_id: branch.model_id,
187
+ is_fastest: duration == min_duration && branches.size > 1,
188
+ is_slowest: duration == max_duration && branches.size > 1 && min_duration != max_duration
189
+ }
190
+ end
191
+ end
192
+
193
+ # @!endgroup
194
+
195
+ # @!group Router-specific Methods
196
+
197
+ # Returns router classification details
198
+ #
199
+ # @return [Hash] Classification info including method, model, timing
200
+ def router_classification_detail
201
+ return {} unless router_workflow?
202
+
203
+ result = if classification_result.is_a?(String)
204
+ begin
205
+ JSON.parse(classification_result)
206
+ rescue JSON::ParserError
207
+ {}
208
+ end
209
+ else
210
+ classification_result || {}
211
+ end
212
+
213
+ {
214
+ method: result["method"],
215
+ classifier_model: result["classifier_model"],
216
+ classification_time_ms: result["classification_time_ms"],
217
+ routed_to: routed_to,
218
+ confidence: result["confidence"]
219
+ }
220
+ end
221
+
222
+ # Returns available routes and which one was chosen
223
+ #
224
+ # @return [Hash] Routes info with chosen route highlighted
225
+ def router_routes_detail
226
+ return {} unless router_workflow?
227
+
228
+ # Get the routed execution (child)
229
+ routed_child = child_executions.first
230
+
231
+ {
232
+ chosen_route: routed_to,
233
+ routed_execution: routed_child ? {
234
+ id: routed_child.id,
235
+ agent_type: routed_child.agent_type,
236
+ status: routed_child.status,
237
+ duration_ms: routed_child.duration_ms,
238
+ total_cost: routed_child.total_cost
239
+ } : nil
240
+ }
241
+ end
242
+
243
+ # @!endgroup
244
+
245
+ private
246
+
247
+ # Returns empty aggregate stats hash
248
+ #
249
+ # @return [Hash] Empty stats with zero values
250
+ def empty_aggregate_stats
251
+ {
252
+ total_cost: 0,
253
+ total_tokens: 0,
254
+ input_tokens: 0,
255
+ output_tokens: 0,
256
+ total_duration_ms: 0,
257
+ wall_clock_ms: nil,
258
+ steps_count: 0,
259
+ successful_count: 0,
260
+ failed_count: 0,
261
+ timeout_count: 0,
262
+ running_count: 0,
263
+ success_rate: 0.0,
264
+ models_used: []
265
+ }
266
+ end
267
+
268
+ # Calculates wall-clock duration from child executions
269
+ #
270
+ # @param children [Array<Execution>] Child executions
271
+ # @return [Integer, nil] Duration in milliseconds
272
+ def calculate_wall_clock_duration(children)
273
+ started_times = children.map(&:started_at).compact
274
+ completed_times = children.map(&:completed_at).compact
275
+
276
+ return nil if started_times.empty? || completed_times.empty?
277
+
278
+ first_start = started_times.min
279
+ last_complete = completed_times.max
280
+
281
+ ((last_complete - first_start) * 1000).round
282
+ end
283
+
284
+ # Calculates success rate from children
285
+ #
286
+ # @param children [Array<Execution>] Child executions
287
+ # @return [Float] Success rate as percentage
288
+ def calculate_success_rate(children)
289
+ return 0.0 if children.empty?
290
+
291
+ completed = children.reject(&:status_running?)
292
+ return 0.0 if completed.empty?
293
+
294
+ (completed.count(&:status_success?).to_f / completed.size * 100).round(1)
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
@@ -51,6 +51,7 @@ module RubyLLM
51
51
  include Execution::Metrics
52
52
  include Execution::Scopes
53
53
  include Execution::Analytics
54
+ include Execution::Workflow
54
55
 
55
56
  # Status enum
56
57
  # - running: execution in progress
@@ -123,13 +124,20 @@ module RubyLLM
123
124
  #
124
125
  # @return [Boolean] true if more than one attempt was made
125
126
  def has_retries?
126
- (attempts_count || 0) > 1
127
+ count = if self.class.column_names.include?("attempts_count")
128
+ attempts_count
129
+ elsif self.class.column_names.include?("attempts")
130
+ attempts&.size
131
+ end
132
+ (count || 0) > 1
127
133
  end
128
134
 
129
135
  # Returns whether this execution used fallback models
130
136
  #
131
137
  # @return [Boolean] true if a different model than requested succeeded
132
138
  def used_fallback?
139
+ return false unless self.class.column_names.include?("chosen_model_id")
140
+
133
141
  chosen_model_id.present? && chosen_model_id != model_id
134
142
  end
135
143
 
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Database-backed budget configuration for multi-tenant environments
6
+ #
7
+ # Stores per-tenant budget limits that override the global configuration.
8
+ # Supports runtime updates without application restarts.
9
+ #
10
+ # @!attribute [rw] tenant_id
11
+ # @return [String] Unique identifier for the tenant
12
+ # @!attribute [rw] daily_limit
13
+ # @return [BigDecimal, nil] Daily budget limit in USD
14
+ # @!attribute [rw] monthly_limit
15
+ # @return [BigDecimal, nil] Monthly budget limit in USD
16
+ # @!attribute [rw] per_agent_daily
17
+ # @return [Hash] Per-agent daily limits: { "AgentName" => limit }
18
+ # @!attribute [rw] per_agent_monthly
19
+ # @return [Hash] Per-agent monthly limits: { "AgentName" => limit }
20
+ # @!attribute [rw] enforcement
21
+ # @return [String] Enforcement mode: "none", "soft", or "hard"
22
+ # @!attribute [rw] inherit_global_defaults
23
+ # @return [Boolean] Whether to fall back to global config for unset limits
24
+ #
25
+ # @example Creating a tenant budget
26
+ # TenantBudget.create!(
27
+ # tenant_id: "acme_corp",
28
+ # daily_limit: 50.0,
29
+ # monthly_limit: 500.0,
30
+ # per_agent_daily: { "ContentAgent" => 10.0 },
31
+ # enforcement: "hard"
32
+ # )
33
+ #
34
+ # @example Fetching budget for a tenant
35
+ # budget = TenantBudget.for_tenant("acme_corp")
36
+ # budget.effective_daily_limit # => 50.0
37
+ #
38
+ # @see RubyLLM::Agents::BudgetTracker
39
+ # @api public
40
+ class TenantBudget < ::ActiveRecord::Base
41
+ self.table_name = "ruby_llm_agents_tenant_budgets"
42
+
43
+ # Valid enforcement modes
44
+ ENFORCEMENT_MODES = %w[none soft hard].freeze
45
+
46
+ # Validations
47
+ validates :tenant_id, presence: true, uniqueness: true
48
+ validates :enforcement, inclusion: { in: ENFORCEMENT_MODES }, allow_nil: true
49
+ validates :daily_limit, :monthly_limit,
50
+ numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
51
+
52
+ # Finds a budget for the given tenant
53
+ #
54
+ # @param tenant_id [String] The tenant identifier
55
+ # @return [TenantBudget, nil] The budget record or nil if not found
56
+ def self.for_tenant(tenant_id)
57
+ return nil if tenant_id.blank?
58
+
59
+ find_by(tenant_id: tenant_id)
60
+ end
61
+
62
+ # Returns the effective daily limit, considering inheritance
63
+ #
64
+ # @return [Float, nil] The daily limit or nil if not set
65
+ def effective_daily_limit
66
+ return daily_limit if daily_limit.present?
67
+ return nil unless inherit_global_defaults
68
+
69
+ global_config&.dig(:global_daily)
70
+ end
71
+
72
+ # Returns the effective monthly limit, considering inheritance
73
+ #
74
+ # @return [Float, nil] The monthly limit or nil if not set
75
+ def effective_monthly_limit
76
+ return monthly_limit if monthly_limit.present?
77
+ return nil unless inherit_global_defaults
78
+
79
+ global_config&.dig(:global_monthly)
80
+ end
81
+
82
+ # Returns the effective per-agent daily limit
83
+ #
84
+ # @param agent_type [String] The agent class name
85
+ # @return [Float, nil] The limit or nil if not set
86
+ def effective_per_agent_daily(agent_type)
87
+ limit = per_agent_daily&.dig(agent_type)
88
+ return limit if limit.present?
89
+ return nil unless inherit_global_defaults
90
+
91
+ global_config&.dig(:per_agent_daily, agent_type)
92
+ end
93
+
94
+ # Returns the effective per-agent monthly limit
95
+ #
96
+ # @param agent_type [String] The agent class name
97
+ # @return [Float, nil] The limit or nil if not set
98
+ def effective_per_agent_monthly(agent_type)
99
+ limit = per_agent_monthly&.dig(agent_type)
100
+ return limit if limit.present?
101
+ return nil unless inherit_global_defaults
102
+
103
+ global_config&.dig(:per_agent_monthly, agent_type)
104
+ end
105
+
106
+ # Returns the effective enforcement mode
107
+ #
108
+ # @return [Symbol] :none, :soft, or :hard
109
+ def effective_enforcement
110
+ return enforcement.to_sym if enforcement.present?
111
+ return :soft unless inherit_global_defaults
112
+
113
+ RubyLLM::Agents.configuration.budget_enforcement
114
+ end
115
+
116
+ # Checks if budget enforcement is enabled for this tenant
117
+ #
118
+ # @return [Boolean] true if enforcement is :soft or :hard
119
+ def budgets_enabled?
120
+ effective_enforcement != :none
121
+ end
122
+
123
+ # Returns a hash suitable for BudgetTracker
124
+ #
125
+ # @return [Hash] Budget configuration hash
126
+ def to_budget_config
127
+ {
128
+ enabled: budgets_enabled?,
129
+ enforcement: effective_enforcement,
130
+ global_daily: effective_daily_limit,
131
+ global_monthly: effective_monthly_limit,
132
+ per_agent_daily: merged_per_agent_daily,
133
+ per_agent_monthly: merged_per_agent_monthly
134
+ }
135
+ end
136
+
137
+ private
138
+
139
+ # Returns the global budgets configuration
140
+ #
141
+ # @return [Hash, nil] Global budget config
142
+ def global_config
143
+ RubyLLM::Agents.configuration.budgets
144
+ end
145
+
146
+ # Merges per-agent daily limits with global defaults
147
+ #
148
+ # @return [Hash] Merged per-agent daily limits
149
+ def merged_per_agent_daily
150
+ return per_agent_daily || {} unless inherit_global_defaults
151
+
152
+ (global_config&.dig(:per_agent_daily) || {}).merge(per_agent_daily || {})
153
+ end
154
+
155
+ # Merges per-agent monthly limits with global defaults
156
+ #
157
+ # @return [Hash] Merged per-agent monthly limits
158
+ def merged_per_agent_monthly
159
+ return per_agent_monthly || {} unless inherit_global_defaults
160
+
161
+ (global_config&.dig(:per_agent_monthly) || {}).merge(per_agent_monthly || {})
162
+ end
163
+ end
164
+ end
165
+ end
@@ -95,6 +95,28 @@ module RubyLLM
95
95
  end
96
96
  end
97
97
 
98
+ # Returns only regular agents (non-workflows)
99
+ #
100
+ # @return [Array<Hash>] Agent info hashes for non-workflow agents
101
+ def agents_only
102
+ all_with_details.reject { |a| a[:is_workflow] }
103
+ end
104
+
105
+ # Returns only workflows
106
+ #
107
+ # @return [Array<Hash>] Agent info hashes for workflows only
108
+ def workflows_only
109
+ all_with_details.select { |a| a[:is_workflow] }
110
+ end
111
+
112
+ # Returns workflows filtered by type
113
+ #
114
+ # @param type [String, Symbol] The workflow type (pipeline, parallel, router)
115
+ # @return [Array<Hash>] Filtered workflow info hashes
116
+ def workflows_by_type(type)
117
+ workflows_only.select { |w| w[:workflow_type] == type.to_s }
118
+ end
119
+
98
120
  # Builds detailed info hash for an agent
99
121
  #
100
122
  # @param agent_type [String] The agent class name
@@ -103,17 +125,27 @@ module RubyLLM
103
125
  agent_class = find(agent_type)
104
126
  stats = fetch_stats(agent_type)
105
127
 
128
+ # Check if this is a workflow class vs a regular agent
129
+ is_workflow = agent_class&.ancestors&.any? { |a| a.name&.include?("Workflow") }
130
+
131
+ # Determine specific workflow type and children
132
+ workflow_type = is_workflow ? detect_workflow_type(agent_class) : nil
133
+ workflow_children = is_workflow ? extract_workflow_children(agent_class) : []
134
+
106
135
  {
107
136
  name: agent_type,
108
137
  class: agent_class,
109
138
  active: agent_class.present?,
110
- version: agent_class&.version || "N/A",
111
- model: agent_class&.model || "N/A",
112
- temperature: agent_class&.temperature,
113
- timeout: agent_class&.timeout,
114
- cache_enabled: agent_class&.cache_enabled? || false,
115
- cache_ttl: agent_class&.cache_ttl,
116
- params: agent_class&.params || {},
139
+ is_workflow: is_workflow,
140
+ workflow_type: workflow_type,
141
+ workflow_children: workflow_children,
142
+ version: safe_call(agent_class, :version) || "N/A",
143
+ model: safe_call(agent_class, :model) || (is_workflow ? "workflow" : "N/A"),
144
+ temperature: safe_call(agent_class, :temperature),
145
+ timeout: safe_call(agent_class, :timeout),
146
+ cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
147
+ cache_ttl: safe_call(agent_class, :cache_ttl),
148
+ params: safe_call(agent_class, :params) || {},
117
149
  execution_count: stats[:count],
118
150
  total_cost: stats[:total_cost],
119
151
  total_tokens: stats[:total_tokens],
@@ -124,6 +156,20 @@ module RubyLLM
124
156
  }
125
157
  end
126
158
 
159
+ # Safely calls a method on a class, returning nil if method doesn't exist
160
+ #
161
+ # @param klass [Class, nil] The class to call the method on
162
+ # @param method_name [Symbol] The method to call
163
+ # @return [Object, nil] The result or nil
164
+ def safe_call(klass, method_name)
165
+ return nil unless klass
166
+ return nil unless klass.respond_to?(method_name)
167
+
168
+ klass.public_send(method_name)
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
127
173
  # Fetches statistics for an agent
128
174
  #
129
175
  # @param agent_type [String] The agent class name
@@ -143,6 +189,71 @@ module RubyLLM
143
189
  rescue StandardError
144
190
  nil
145
191
  end
192
+
193
+ # Detects the specific workflow type from class hierarchy
194
+ #
195
+ # @param agent_class [Class, nil] The agent class
196
+ # @return [String, nil] "pipeline", "parallel", "router", or nil
197
+ def detect_workflow_type(agent_class)
198
+ return nil unless agent_class
199
+
200
+ ancestors = agent_class.ancestors.map { |a| a.name.to_s }
201
+
202
+ if ancestors.include?("RubyLLM::Agents::Workflow::Pipeline")
203
+ "pipeline"
204
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Parallel")
205
+ "parallel"
206
+ elsif ancestors.include?("RubyLLM::Agents::Workflow::Router")
207
+ "router"
208
+ end
209
+ end
210
+
211
+ # Extracts child agents from workflow DSL configuration
212
+ #
213
+ # @param agent_class [Class, nil] The workflow class
214
+ # @return [Array<Hash>] Array of child info hashes with :name, :agent, :type, :optional keys
215
+ def extract_workflow_children(agent_class)
216
+ return [] unless agent_class
217
+
218
+ children = []
219
+
220
+ if agent_class.respond_to?(:steps) && agent_class.steps.any?
221
+ # Pipeline workflow - extract steps
222
+ agent_class.steps.each do |name, config|
223
+ children << {
224
+ name: name,
225
+ agent: config[:agent]&.name,
226
+ type: "step",
227
+ optional: config[:continue_on_error] || false
228
+ }
229
+ end
230
+ elsif agent_class.respond_to?(:branches) && agent_class.branches.any?
231
+ # Parallel workflow - extract branches
232
+ agent_class.branches.each do |name, config|
233
+ children << {
234
+ name: name,
235
+ agent: config[:agent]&.name,
236
+ type: "branch",
237
+ optional: config[:optional] || false
238
+ }
239
+ end
240
+ elsif agent_class.respond_to?(:routes) && agent_class.routes.any?
241
+ # Router workflow - extract routes
242
+ agent_class.routes.each do |name, config|
243
+ children << {
244
+ name: name,
245
+ agent: config[:agent]&.name,
246
+ type: "route",
247
+ description: config[:description]
248
+ }
249
+ end
250
+ end
251
+
252
+ children
253
+ rescue StandardError => e
254
+ Rails.logger.error("[RubyLLM::Agents] Error extracting workflow children: #{e.message}")
255
+ []
256
+ end
146
257
  end
147
258
  end
148
259
  end