ruby_llm-agents 0.2.4 → 0.3.1
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.
- checksums.yaml +4 -4
- data/README.md +413 -0
- data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
- data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
- data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
- data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
- data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
- data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
- data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
- data/app/models/ruby_llm/agents/execution.rb +259 -16
- data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
- data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
- data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
- data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
- data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
- data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
- data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
- data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
- data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
- data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
- data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
- data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
- data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
- data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
- data/config/routes.rb +7 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
- data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +143 -8
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
- data/lib/ruby_llm/agents/alert_manager.rb +207 -0
- data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
- data/lib/ruby_llm/agents/base.rb +597 -112
- data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
- data/lib/ruby_llm/agents/configuration.rb +279 -1
- data/lib/ruby_llm/agents/engine.rb +58 -6
- data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
- data/lib/ruby_llm/agents/inflections.rb +13 -2
- data/lib/ruby_llm/agents/instrumentation.rb +538 -87
- data/lib/ruby_llm/agents/redactor.rb +130 -0
- data/lib/ruby_llm/agents/reliability.rb +185 -0
- data/lib/ruby_llm/agents/version.rb +3 -1
- data/lib/ruby_llm/agents.rb +52 -0
- metadata +41 -2
- 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
|