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.
Files changed (97) 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 +137 -9
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
  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 +28 -59
  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/ruby_llm/agents/application.html.erb +430 -0
  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/ruby_llm/agents/agents/show.html.erb +775 -0
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
  21. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  22. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
  23. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  24. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
  25. data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
  26. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  27. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  29. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  30. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +260 -200
  32. data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
  33. data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
  34. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  35. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
  37. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  38. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  39. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  40. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  41. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  42. data/config/routes.rb +2 -0
  43. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  44. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  45. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  46. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  47. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  48. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  49. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  50. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  51. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  52. data/lib/ruby_llm/agents/base/caching.rb +40 -0
  53. data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
  54. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  55. data/lib/ruby_llm/agents/base/execution.rb +258 -0
  56. data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
  57. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  58. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  59. data/lib/ruby_llm/agents/base.rb +37 -801
  60. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  61. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  62. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  63. data/lib/ruby_llm/agents/configuration.rb +40 -1
  64. data/lib/ruby_llm/agents/engine.rb +65 -1
  65. data/lib/ruby_llm/agents/inflections.rb +14 -0
  66. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  67. data/lib/ruby_llm/agents/reliability.rb +8 -2
  68. data/lib/ruby_llm/agents/version.rb +1 -1
  69. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  70. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  71. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  72. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  73. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  74. data/lib/ruby_llm/agents/workflow.rb +232 -0
  75. data/lib/ruby_llm/agents.rb +1 -0
  76. metadata +57 -75
  77. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  78. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  79. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  80. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  81. data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
  82. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  83. data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
  84. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
  85. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
  86. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
  87. data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
  88. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  89. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  90. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  91. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  92. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  93. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  94. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  95. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  96. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  97. /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
@@ -85,7 +86,6 @@ module RubyLLM
85
86
 
86
87
  before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? }
87
88
  before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? }
88
- after_commit :broadcast_turbo_streams, on: %i[create update]
89
89
 
90
90
  # Aggregates costs from all attempts using each attempt's model pricing
91
91
  #
@@ -124,13 +124,20 @@ module RubyLLM
124
124
  #
125
125
  # @return [Boolean] true if more than one attempt was made
126
126
  def has_retries?
127
- (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
128
133
  end
129
134
 
130
135
  # Returns whether this execution used fallback models
131
136
  #
132
137
  # @return [Boolean] true if a different model than requested succeeded
133
138
  def used_fallback?
139
+ return false unless self.class.column_names.include?("chosen_model_id")
140
+
134
141
  chosen_model_id.present? && chosen_model_id != model_id
135
142
  end
136
143
 
@@ -228,77 +235,39 @@ module RubyLLM
228
235
 
229
236
  # Returns real-time dashboard data for the Now Strip
230
237
  #
238
+ # @param range [String] Time range: "today", "7d", or "30d"
231
239
  # @return [Hash] Now strip metrics
232
- def self.now_strip_data
233
- today_scope = today
240
+ def self.now_strip_data(range: "today")
241
+ scope = case range
242
+ when "7d" then last_n_days(7)
243
+ when "30d" then last_n_days(30)
244
+ else today
245
+ end
246
+
234
247
  {
235
248
  running: running.count,
236
- success_today: today_scope.status_success.count,
237
- errors_today: today_scope.status_error.count,
238
- timeouts_today: today_scope.status_timeout.count,
239
- cost_today: today_scope.sum(:total_cost) || 0,
240
- executions_today: today_scope.count,
241
- success_rate: calculate_today_success_rate
249
+ success_today: scope.status_success.count,
250
+ errors_today: scope.status_error.count,
251
+ timeouts_today: scope.status_timeout.count,
252
+ cost_today: scope.sum(:total_cost) || 0,
253
+ executions_today: scope.count,
254
+ success_rate: calculate_period_success_rate(scope)
242
255
  }
243
256
  end
244
257
 
245
- # Calculates today's success rate
258
+ # Calculates success rate for a given scope
246
259
  #
260
+ # @param scope [ActiveRecord::Relation] The execution scope
247
261
  # @return [Float] Success rate as percentage
248
- def self.calculate_today_success_rate
249
- total = today.count
262
+ def self.calculate_period_success_rate(scope)
263
+ total = scope.count
250
264
  return 0.0 if total.zero?
251
265
 
252
- (today.successful.count.to_f / total * 100).round(1)
253
- end
254
-
255
- # Broadcasts execution changes via ActionCable for real-time dashboard updates
256
- #
257
- # Sends JSON with action, id, status, and rendered HTML partials.
258
- # The JavaScript client handles DOM updates based on the action type.
259
- #
260
- # @return [void]
261
- def broadcast_turbo_streams
262
- ActionCable.server.broadcast(
263
- "ruby_llm_agents:executions",
264
- {
265
- action: previously_new_record? ? "created" : "updated",
266
- id: id,
267
- status: status,
268
- html: render_execution_html,
269
- now_strip_html: render_now_strip_html
270
- }
271
- )
272
- rescue StandardError => e
273
- Rails.logger.error("[RubyLLM::Agents] Failed to broadcast execution: #{e.message}")
266
+ (scope.successful.count.to_f / total * 100).round(1)
274
267
  end
275
268
 
276
269
  private
277
270
 
278
- # Renders the execution item partial for broadcast
279
- #
280
- # @return [String, nil] HTML string or nil if rendering fails
281
- def render_execution_html
282
- ApplicationController.render(
283
- partial: "rubyllm/agents/dashboard/execution_item",
284
- locals: { execution: self }
285
- )
286
- rescue StandardError
287
- nil
288
- end
289
-
290
- # Renders the Now Strip values partial for broadcast
291
- #
292
- # @return [String, nil] HTML string or nil if rendering fails
293
- def render_now_strip_html
294
- ApplicationController.render(
295
- partial: "rubyllm/agents/dashboard/now_strip_values",
296
- locals: { now_strip: self.class.now_strip_data }
297
- )
298
- rescue StandardError
299
- nil
300
- end
301
-
302
271
  # Calculates and sets total_tokens from input and output
303
272
  #
304
273
  # @return [Integer] The calculated total
@@ -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