ruby_llm-agents 0.2.3 → 0.3.0

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +273 -0
  3. data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
  4. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
  5. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
  6. data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
  9. data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
  10. data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
  12. data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
  13. data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
  14. data/app/models/ruby_llm/agents/execution.rb +259 -16
  15. data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
  16. data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
  17. data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
  18. data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
  19. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
  20. data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
  21. data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
  22. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
  23. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
  24. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
  25. data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
  26. data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
  27. data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
  28. data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
  29. data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
  30. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
  31. data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
  32. data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
  33. data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
  34. data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
  35. data/config/routes.rb +7 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
  37. data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
  39. data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
  41. data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
  42. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
  43. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
  44. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +139 -8
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +207 -0
  48. data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
  49. data/lib/ruby_llm/agents/base.rb +580 -112
  50. data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
  51. data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
  52. data/lib/ruby_llm/agents/configuration.rb +279 -1
  53. data/lib/ruby_llm/agents/engine.rb +59 -6
  54. data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
  55. data/lib/ruby_llm/agents/inflections.rb +13 -2
  56. data/lib/ruby_llm/agents/instrumentation.rb +538 -87
  57. data/lib/ruby_llm/agents/redactor.rb +130 -0
  58. data/lib/ruby_llm/agents/reliability.rb +185 -0
  59. data/lib/ruby_llm/agents/version.rb +3 -1
  60. data/lib/ruby_llm/agents.rb +52 -0
  61. metadata +41 -2
  62. data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
@@ -0,0 +1,360 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Cache-based budget tracking for cost governance
6
+ #
7
+ # Tracks spending against configured budget limits using cache counters.
8
+ # Supports daily and monthly budgets at both global and per-agent levels.
9
+ #
10
+ # Note: Uses best-effort enforcement with cache counters. In high-concurrency
11
+ # scenarios, slight overruns may occur due to race conditions. This is an
12
+ # acceptable trade-off for performance.
13
+ #
14
+ # @example Checking budget before execution
15
+ # BudgetTracker.check_budget!("MyAgent") # raises BudgetExceededError if over limit
16
+ #
17
+ # @example Recording spend after execution
18
+ # BudgetTracker.record_spend!("MyAgent", 0.05)
19
+ #
20
+ # @see RubyLLM::Agents::Configuration
21
+ # @see RubyLLM::Agents::Reliability::BudgetExceededError
22
+ # @api public
23
+ module BudgetTracker
24
+ class << self
25
+ # Checks if the current spend exceeds budget limits
26
+ #
27
+ # @param agent_type [String] The agent class name
28
+ # @raise [Reliability::BudgetExceededError] If hard cap is exceeded
29
+ # @return [void]
30
+ def check_budget!(agent_type)
31
+ config = RubyLLM::Agents.configuration
32
+ return unless config.budgets_enabled?
33
+
34
+ budgets = config.budgets
35
+ enforcement = config.budget_enforcement
36
+
37
+ # Only block on hard enforcement
38
+ return unless enforcement == :hard
39
+
40
+ # Check global daily budget
41
+ if budgets[:global_daily]
42
+ current = current_spend(:global, :daily)
43
+ if current >= budgets[:global_daily]
44
+ raise Reliability::BudgetExceededError.new(:global_daily, budgets[:global_daily], current)
45
+ end
46
+ end
47
+
48
+ # Check global monthly budget
49
+ if budgets[:global_monthly]
50
+ current = current_spend(:global, :monthly)
51
+ if current >= budgets[:global_monthly]
52
+ raise Reliability::BudgetExceededError.new(:global_monthly, budgets[:global_monthly], current)
53
+ end
54
+ end
55
+
56
+ # Check per-agent daily budget
57
+ agent_daily_limit = budgets[:per_agent_daily]&.dig(agent_type)
58
+ if agent_daily_limit
59
+ current = current_spend(:agent, :daily, agent_type: agent_type)
60
+ if current >= agent_daily_limit
61
+ raise Reliability::BudgetExceededError.new(:per_agent_daily, agent_daily_limit, current, agent_type: agent_type)
62
+ end
63
+ end
64
+
65
+ # Check per-agent monthly budget
66
+ agent_monthly_limit = budgets[:per_agent_monthly]&.dig(agent_type)
67
+ if agent_monthly_limit
68
+ current = current_spend(:agent, :monthly, agent_type: agent_type)
69
+ if current >= agent_monthly_limit
70
+ raise Reliability::BudgetExceededError.new(:per_agent_monthly, agent_monthly_limit, current, agent_type: agent_type)
71
+ end
72
+ end
73
+ end
74
+
75
+ # Records spend and checks for soft cap alerts
76
+ #
77
+ # @param agent_type [String] The agent class name
78
+ # @param amount [Float] The amount spent in USD
79
+ # @return [void]
80
+ def record_spend!(agent_type, amount)
81
+ return if amount.nil? || amount <= 0
82
+
83
+ config = RubyLLM::Agents.configuration
84
+ budgets = config.budgets
85
+
86
+ # Increment all relevant counters
87
+ increment_spend(:global, :daily, amount)
88
+ increment_spend(:global, :monthly, amount)
89
+ increment_spend(:agent, :daily, amount, agent_type: agent_type)
90
+ increment_spend(:agent, :monthly, amount, agent_type: agent_type)
91
+
92
+ # Check for soft cap alerts if budgets are configured
93
+ return unless budgets.is_a?(Hash)
94
+
95
+ check_soft_cap_alerts(agent_type, budgets, config)
96
+ end
97
+
98
+ # Returns the current spend for a scope and period
99
+ #
100
+ # @param scope [Symbol] :global or :agent
101
+ # @param period [Symbol] :daily or :monthly
102
+ # @param agent_type [String, nil] Required when scope is :agent
103
+ # @return [Float] Current spend in USD
104
+ def current_spend(scope, period, agent_type: nil)
105
+ key = cache_key(scope, period, agent_type: agent_type)
106
+ (cache_store.read(key) || 0).to_f
107
+ end
108
+
109
+ # Returns the remaining budget for a scope and period
110
+ #
111
+ # @param scope [Symbol] :global or :agent
112
+ # @param period [Symbol] :daily or :monthly
113
+ # @param agent_type [String, nil] Required when scope is :agent
114
+ # @return [Float, nil] Remaining budget in USD, or nil if no limit configured
115
+ def remaining_budget(scope, period, agent_type: nil)
116
+ config = RubyLLM::Agents.configuration
117
+ budgets = config.budgets
118
+ return nil unless budgets.is_a?(Hash)
119
+
120
+ limit = case [scope, period]
121
+ when [:global, :daily]
122
+ budgets[:global_daily]
123
+ when [:global, :monthly]
124
+ budgets[:global_monthly]
125
+ when [:agent, :daily]
126
+ budgets[:per_agent_daily]&.dig(agent_type)
127
+ when [:agent, :monthly]
128
+ budgets[:per_agent_monthly]&.dig(agent_type)
129
+ end
130
+
131
+ return nil unless limit
132
+
133
+ [limit - current_spend(scope, period, agent_type: agent_type), 0].max
134
+ end
135
+
136
+ # Returns a summary of all budget statuses
137
+ #
138
+ # @param agent_type [String, nil] Optional agent type for per-agent budgets
139
+ # @return [Hash] Budget status information
140
+ def status(agent_type: nil)
141
+ config = RubyLLM::Agents.configuration
142
+ budgets = config.budgets || {}
143
+
144
+ {
145
+ enabled: config.budgets_enabled?,
146
+ enforcement: config.budget_enforcement,
147
+ global_daily: budget_status(:global, :daily, budgets[:global_daily]),
148
+ global_monthly: budget_status(:global, :monthly, budgets[:global_monthly]),
149
+ per_agent_daily: agent_type ? budget_status(:agent, :daily, budgets[:per_agent_daily]&.dig(agent_type), agent_type: agent_type) : nil,
150
+ per_agent_monthly: agent_type ? budget_status(:agent, :monthly, budgets[:per_agent_monthly]&.dig(agent_type), agent_type: agent_type) : nil,
151
+ forecast: calculate_forecast
152
+ }.compact
153
+ end
154
+
155
+ # Calculates budget forecasts based on current spending trends
156
+ #
157
+ # @return [Hash, nil] Forecast information
158
+ def calculate_forecast
159
+ config = RubyLLM::Agents.configuration
160
+ budgets = config.budgets || {}
161
+
162
+ return nil unless config.budgets_enabled?
163
+ return nil unless budgets[:global_daily] || budgets[:global_monthly]
164
+
165
+ daily_current = current_spend(:global, :daily)
166
+ monthly_current = current_spend(:global, :monthly)
167
+
168
+ # Calculate hours elapsed today and days elapsed this month
169
+ hours_elapsed = Time.current.hour + (Time.current.min / 60.0)
170
+ hours_elapsed = [hours_elapsed, 1].max # Avoid division by zero
171
+ days_in_month = Time.current.end_of_month.day
172
+ day_of_month = Time.current.day
173
+ days_elapsed = day_of_month - 1 + (hours_elapsed / 24.0)
174
+ days_elapsed = [days_elapsed, 1].max
175
+
176
+ forecast = {}
177
+
178
+ # Daily forecast
179
+ if budgets[:global_daily]
180
+ daily_rate = daily_current / hours_elapsed
181
+ projected_daily = daily_rate * 24
182
+ forecast[:daily] = {
183
+ current: daily_current.round(4),
184
+ projected: projected_daily.round(4),
185
+ limit: budgets[:global_daily],
186
+ on_track: projected_daily <= budgets[:global_daily],
187
+ hours_remaining: (24 - hours_elapsed).round(1),
188
+ rate_per_hour: daily_rate.round(6)
189
+ }
190
+ end
191
+
192
+ # Monthly forecast
193
+ if budgets[:global_monthly]
194
+ monthly_rate = monthly_current / days_elapsed
195
+ projected_monthly = monthly_rate * days_in_month
196
+ days_remaining = days_in_month - day_of_month
197
+ forecast[:monthly] = {
198
+ current: monthly_current.round(4),
199
+ projected: projected_monthly.round(4),
200
+ limit: budgets[:global_monthly],
201
+ on_track: projected_monthly <= budgets[:global_monthly],
202
+ days_remaining: days_remaining,
203
+ rate_per_day: monthly_rate.round(4)
204
+ }
205
+ end
206
+
207
+ forecast.presence
208
+ end
209
+
210
+ # Resets all budget counters (useful for testing)
211
+ #
212
+ # @return [void]
213
+ def reset!
214
+ # Note: This is a simple implementation. In production, you might want
215
+ # to iterate over all known keys or use cache namespacing.
216
+ today = Date.current.to_s
217
+ month = Date.current.strftime("%Y-%m")
218
+
219
+ cache_store.delete("ruby_llm_agents:budget:global:#{today}")
220
+ cache_store.delete("ruby_llm_agents:budget:global:#{month}")
221
+ end
222
+
223
+ private
224
+
225
+ # Increments the spend counter for a scope and period
226
+ #
227
+ # @param scope [Symbol] :global or :agent
228
+ # @param period [Symbol] :daily or :monthly
229
+ # @param amount [Float] Amount to add
230
+ # @param agent_type [String, nil] Required when scope is :agent
231
+ # @return [Float] New total
232
+ def increment_spend(scope, period, amount, agent_type: nil)
233
+ key = cache_key(scope, period, agent_type: agent_type)
234
+ ttl = period == :daily ? 1.day : 31.days
235
+
236
+ if cache_store.respond_to?(:increment)
237
+ # Ensure key exists with TTL
238
+ cache_store.write(key, 0, expires_in: ttl, unless_exist: true)
239
+ # Note: increment typically works with integers, so we multiply by 1000000
240
+ # to store as cents of a cent, then divide when reading
241
+ # For simplicity, we use read-modify-write here
242
+ current = (cache_store.read(key) || 0).to_f
243
+ new_total = current + amount
244
+ cache_store.write(key, new_total, expires_in: ttl)
245
+ new_total
246
+ else
247
+ current = (cache_store.read(key) || 0).to_f
248
+ new_total = current + amount
249
+ cache_store.write(key, new_total, expires_in: ttl)
250
+ new_total
251
+ end
252
+ end
253
+
254
+ # Generates a cache key for budget tracking
255
+ #
256
+ # @param scope [Symbol] :global or :agent
257
+ # @param period [Symbol] :daily or :monthly
258
+ # @param agent_type [String, nil] Required when scope is :agent
259
+ # @return [String] Cache key
260
+ def cache_key(scope, period, agent_type: nil)
261
+ date_part = period == :daily ? Date.current.to_s : Date.current.strftime("%Y-%m")
262
+
263
+ case scope
264
+ when :global
265
+ "ruby_llm_agents:budget:global:#{date_part}"
266
+ when :agent
267
+ "ruby_llm_agents:budget:agent:#{agent_type}:#{date_part}"
268
+ else
269
+ raise ArgumentError, "Unknown scope: #{scope}"
270
+ end
271
+ end
272
+
273
+ # Returns budget status for a scope/period
274
+ #
275
+ # @param scope [Symbol] :global or :agent
276
+ # @param period [Symbol] :daily or :monthly
277
+ # @param limit [Float, nil] The budget limit
278
+ # @param agent_type [String, nil] Required when scope is :agent
279
+ # @return [Hash, nil] Status hash or nil if no limit
280
+ def budget_status(scope, period, limit, agent_type: nil)
281
+ return nil unless limit
282
+
283
+ current = current_spend(scope, period, agent_type: agent_type)
284
+ {
285
+ limit: limit,
286
+ current: current.round(6),
287
+ remaining: [limit - current, 0].max.round(6),
288
+ percentage_used: ((current / limit) * 100).round(2)
289
+ }
290
+ end
291
+
292
+ # Checks for soft cap alerts after recording spend
293
+ #
294
+ # @param agent_type [String] The agent class name
295
+ # @param budgets [Hash] Budget configuration
296
+ # @param config [Configuration] The configuration
297
+ # @return [void]
298
+ def check_soft_cap_alerts(agent_type, budgets, config)
299
+ return unless config.alerts_enabled?
300
+ return unless config.alert_events.include?(:budget_soft_cap) || config.alert_events.include?(:budget_hard_cap)
301
+
302
+ # Check global daily
303
+ check_budget_alert(:global_daily, budgets[:global_daily], current_spend(:global, :daily), agent_type, config)
304
+
305
+ # Check global monthly
306
+ check_budget_alert(:global_monthly, budgets[:global_monthly], current_spend(:global, :monthly), agent_type, config)
307
+
308
+ # Check per-agent daily
309
+ agent_daily_limit = budgets[:per_agent_daily]&.dig(agent_type)
310
+ if agent_daily_limit
311
+ check_budget_alert(:per_agent_daily, agent_daily_limit, current_spend(:agent, :daily, agent_type: agent_type), agent_type, config)
312
+ end
313
+
314
+ # Check per-agent monthly
315
+ agent_monthly_limit = budgets[:per_agent_monthly]&.dig(agent_type)
316
+ if agent_monthly_limit
317
+ check_budget_alert(:per_agent_monthly, agent_monthly_limit, current_spend(:agent, :monthly, agent_type: agent_type), agent_type, config)
318
+ end
319
+ end
320
+
321
+ # Checks if an alert should be fired for a budget
322
+ #
323
+ # @param scope [Symbol] Budget scope
324
+ # @param limit [Float, nil] Budget limit
325
+ # @param current [Float] Current spend
326
+ # @param agent_type [String] Agent type
327
+ # @param config [Configuration] Configuration
328
+ # @return [void]
329
+ def check_budget_alert(scope, limit, current, agent_type, config)
330
+ return unless limit
331
+ return if current <= limit
332
+
333
+ event = config.budget_enforcement == :hard ? :budget_hard_cap : :budget_soft_cap
334
+ return unless config.alert_events.include?(event)
335
+
336
+ # Prevent duplicate alerts by using a cache key
337
+ alert_key = "ruby_llm_agents:budget_alert:#{scope}:#{Date.current}"
338
+ return if cache_store.exist?(alert_key)
339
+
340
+ cache_store.write(alert_key, true, expires_in: 1.hour)
341
+
342
+ AlertManager.notify(event, {
343
+ scope: scope,
344
+ limit: limit,
345
+ total: current.round(6),
346
+ agent_type: agent_type,
347
+ timestamp: Date.current.to_s
348
+ })
349
+ end
350
+
351
+ # Returns the cache store
352
+ #
353
+ # @return [ActiveSupport::Cache::Store]
354
+ def cache_store
355
+ RubyLLM::Agents.configuration.cache_store
356
+ end
357
+ end
358
+ end
359
+ end
360
+ end
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Cache-based circuit breaker for protecting against cascading failures
6
+ #
7
+ # Implements a simple circuit breaker pattern using Rails.cache:
8
+ # - Tracks failure counts in a rolling window
9
+ # - Opens the breaker when failure threshold is reached
10
+ # - Stays open for a cooldown period
11
+ # - Automatically closes after cooldown expires
12
+ #
13
+ # @example Basic usage
14
+ # breaker = CircuitBreaker.new("MyAgent", "gpt-4o", errors: 10, within: 60, cooldown: 300)
15
+ # breaker.open? # => false
16
+ # breaker.record_failure!
17
+ # # ... after 10 failures within 60 seconds ...
18
+ # breaker.open? # => true
19
+ #
20
+ # @see RubyLLM::Agents::Reliability
21
+ # @api public
22
+ class CircuitBreaker
23
+ attr_reader :agent_type, :model_id, :errors_threshold, :window_seconds, :cooldown_seconds
24
+
25
+ # @param agent_type [String] The agent class name
26
+ # @param model_id [String] The model identifier
27
+ # @param errors [Integer] Number of errors to trigger open state (default: 10)
28
+ # @param within [Integer] Rolling window in seconds (default: 60)
29
+ # @param cooldown [Integer] Cooldown period in seconds when open (default: 300)
30
+ def initialize(agent_type, model_id, errors: 10, within: 60, cooldown: 300)
31
+ @agent_type = agent_type
32
+ @model_id = model_id
33
+ @errors_threshold = errors
34
+ @window_seconds = within
35
+ @cooldown_seconds = cooldown
36
+ end
37
+
38
+ # Creates a CircuitBreaker from a configuration hash
39
+ #
40
+ # @param agent_type [String] The agent class name
41
+ # @param model_id [String] The model identifier
42
+ # @param config [Hash] Configuration with :errors, :within, :cooldown keys
43
+ # @return [CircuitBreaker] A new circuit breaker instance
44
+ def self.from_config(agent_type, model_id, config)
45
+ return nil unless config.is_a?(Hash)
46
+
47
+ new(
48
+ agent_type,
49
+ model_id,
50
+ errors: config[:errors] || 10,
51
+ within: config[:within] || 60,
52
+ cooldown: config[:cooldown] || 300
53
+ )
54
+ end
55
+
56
+ # Checks if the circuit breaker is currently open
57
+ #
58
+ # @return [Boolean] true if the breaker is open and requests should be blocked
59
+ def open?
60
+ cache_store.exist?(open_key)
61
+ end
62
+
63
+ # Records a failed attempt and potentially opens the breaker
64
+ #
65
+ # Increments the failure counter and checks if the threshold has been reached.
66
+ # If the threshold is exceeded, opens the breaker for the cooldown period.
67
+ #
68
+ # @return [Boolean] true if the breaker is now open
69
+ def record_failure!
70
+ # Increment the failure counter (atomic operation)
71
+ count = increment_failure_count
72
+
73
+ # Check if we should open the breaker
74
+ if count >= errors_threshold && !open?
75
+ open_breaker!
76
+ true
77
+ else
78
+ open?
79
+ end
80
+ end
81
+
82
+ # Records a successful attempt
83
+ #
84
+ # Optionally resets the failure counter to reduce false positives.
85
+ #
86
+ # @param reset_counter [Boolean] Whether to reset the failure counter (default: true)
87
+ # @return [void]
88
+ def record_success!(reset_counter: true)
89
+ cache_store.delete(count_key) if reset_counter
90
+ end
91
+
92
+ # Manually resets the circuit breaker
93
+ #
94
+ # Clears both the open flag and the failure counter.
95
+ #
96
+ # @return [void]
97
+ def reset!
98
+ cache_store.delete(open_key)
99
+ cache_store.delete(count_key)
100
+ end
101
+
102
+ # Returns the current failure count
103
+ #
104
+ # @return [Integer] The current failure count in the rolling window
105
+ def failure_count
106
+ cache_store.read(count_key).to_i
107
+ end
108
+
109
+ # Returns the time remaining until the breaker closes
110
+ #
111
+ # @return [Integer, nil] Seconds until cooldown expires, or nil if not open
112
+ def time_until_close
113
+ return nil unless open?
114
+
115
+ # We can't easily get TTL from Rails.cache, so this is an approximation
116
+ # In a real implementation, you might store the open time as well
117
+ cooldown_seconds
118
+ end
119
+
120
+ # Returns status information for the circuit breaker
121
+ #
122
+ # @return [Hash] Status information including open state and failure count
123
+ def status
124
+ {
125
+ agent_type: agent_type,
126
+ model_id: model_id,
127
+ open: open?,
128
+ failure_count: failure_count,
129
+ errors_threshold: errors_threshold,
130
+ window_seconds: window_seconds,
131
+ cooldown_seconds: cooldown_seconds
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ # Increments the failure counter with TTL
138
+ #
139
+ # @return [Integer] The new failure count
140
+ def increment_failure_count
141
+ # Use increment if available (atomic), otherwise read-modify-write
142
+ if cache_store.respond_to?(:increment)
143
+ # First write if doesn't exist
144
+ cache_store.write(count_key, 0, expires_in: window_seconds, unless_exist: true)
145
+ cache_store.increment(count_key)
146
+ else
147
+ # Fallback for cache stores without increment
148
+ current = cache_store.read(count_key).to_i
149
+ new_count = current + 1
150
+ cache_store.write(count_key, new_count, expires_in: window_seconds)
151
+ new_count
152
+ end
153
+ end
154
+
155
+ # Opens the circuit breaker
156
+ #
157
+ # @return [void]
158
+ def open_breaker!
159
+ cache_store.write(open_key, Time.current.to_s, expires_in: cooldown_seconds)
160
+
161
+ # Fire alert if configured
162
+ if RubyLLM::Agents.configuration.alerts_enabled? &&
163
+ RubyLLM::Agents.configuration.alert_events.include?(:breaker_open)
164
+ AlertManager.notify(:breaker_open, {
165
+ agent_type: agent_type,
166
+ model_id: model_id,
167
+ errors: errors_threshold,
168
+ within: window_seconds,
169
+ cooldown: cooldown_seconds,
170
+ timestamp: Time.current.iso8601
171
+ })
172
+ end
173
+ end
174
+
175
+ # Returns the cache key for the failure counter
176
+ #
177
+ # @return [String] Cache key
178
+ def count_key
179
+ "ruby_llm_agents:cb:count:#{agent_type}:#{model_id}"
180
+ end
181
+
182
+ # Returns the cache key for the open flag
183
+ #
184
+ # @return [String] Cache key
185
+ def open_key
186
+ "ruby_llm_agents:cb:open:#{agent_type}:#{model_id}"
187
+ end
188
+
189
+ # Returns the cache store
190
+ #
191
+ # @return [ActiveSupport::Cache::Store] The cache store
192
+ def cache_store
193
+ RubyLLM::Agents.configuration.cache_store
194
+ end
195
+ end
196
+ end
197
+ end