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
@@ -5,18 +5,28 @@ module RubyLLM
5
5
  class Execution
6
6
  # Analytics concern for advanced reporting and analysis
7
7
  #
8
- # Provides class methods for:
9
- # - Daily reports with key metrics
10
- # - Cost breakdown by agent type
11
- # - Performance stats for specific agents
12
- # - Version comparison
13
- # - Trend analysis over time
8
+ # Provides class methods for generating reports, analyzing trends,
9
+ # comparing versions, and building chart data.
14
10
  #
11
+ # @see RubyLLM::Agents::Execution::Scopes
12
+ # @api public
15
13
  module Analytics
16
14
  extend ActiveSupport::Concern
17
15
 
18
16
  class_methods do
19
- # Daily report with key metrics
17
+ # Generates a daily report with key metrics for today
18
+ #
19
+ # @return [Hash] Report containing:
20
+ # - :date [Date] Current date
21
+ # - :total_executions [Integer] Total execution count
22
+ # - :successful [Integer] Successful execution count
23
+ # - :failed [Integer] Failed execution count
24
+ # - :total_cost [Float] Sum of all costs
25
+ # - :total_tokens [Integer] Sum of all tokens
26
+ # - :avg_duration_ms [Integer] Average duration
27
+ # - :error_rate [Float] Percentage of failures
28
+ # - :by_agent [Hash] Counts grouped by agent type
29
+ # - :top_errors [Hash] Top 5 error classes
20
30
  def daily_report
21
31
  scope = today
22
32
 
@@ -35,7 +45,10 @@ module RubyLLM
35
45
  }
36
46
  end
37
47
 
38
- # Cost breakdown by agent type
48
+ # Returns cost breakdown grouped by agent type
49
+ #
50
+ # @param period [Symbol] Time scope (:today, :this_week, :this_month, :all_time)
51
+ # @return [Hash{String => Float}] Agent types mapped to total cost, sorted descending
39
52
  def cost_by_agent(period: :today)
40
53
  public_send(period)
41
54
  .group(:agent_type)
@@ -44,7 +57,11 @@ module RubyLLM
44
57
  .to_h
45
58
  end
46
59
 
47
- # Performance stats for specific agent
60
+ # Returns performance statistics for a specific agent
61
+ #
62
+ # @param agent_type [String] The agent class name
63
+ # @param period [Symbol] Time scope (:today, :this_week, :this_month, :all_time)
64
+ # @return [Hash] Statistics including count, costs, tokens, duration, rates
48
65
  def stats_for(agent_type, period: :today)
49
66
  scope = by_agent(agent_type).public_send(period)
50
67
  count = scope.count
@@ -64,7 +81,13 @@ module RubyLLM
64
81
  }
65
82
  end
66
83
 
67
- # Compare versions of the same agent
84
+ # Compares performance between two agent versions
85
+ #
86
+ # @param agent_type [String] The agent class name
87
+ # @param version1 [String] First version to compare (baseline)
88
+ # @param version2 [String] Second version to compare
89
+ # @param period [Symbol] Time scope for comparison
90
+ # @return [Hash] Comparison data with stats for each version and improvement percentages
68
91
  def compare_versions(agent_type, version1, version2, period: :this_week)
69
92
  base_scope = by_agent(agent_type).public_send(period)
70
93
 
@@ -84,7 +107,38 @@ module RubyLLM
84
107
  }
85
108
  end
86
109
 
87
- # Trend analysis over time
110
+ # Returns daily trend data for a specific agent version
111
+ #
112
+ # Used for sparkline charts in version comparison.
113
+ #
114
+ # @param agent_type [String] The agent class name
115
+ # @param version [String] The version to analyze
116
+ # @param days [Integer] Number of days to analyze
117
+ # @return [Array<Hash>] Daily metrics sorted oldest to newest
118
+ def version_trend_data(agent_type, version, days: 14)
119
+ scope = by_agent(agent_type).by_version(version)
120
+
121
+ (0...days).map do |days_ago|
122
+ date = days_ago.days.ago.to_date
123
+ day_scope = scope.where(created_at: date.beginning_of_day..date.end_of_day)
124
+ count = day_scope.count
125
+
126
+ {
127
+ date: date,
128
+ count: count,
129
+ success_rate: calculate_success_rate(day_scope),
130
+ avg_cost: count > 0 ? ((day_scope.total_cost_sum || 0) / count).round(6) : 0,
131
+ avg_duration_ms: day_scope.avg_duration&.round || 0,
132
+ avg_tokens: day_scope.avg_tokens&.round || 0
133
+ }
134
+ end.reverse
135
+ end
136
+
137
+ # Analyzes trends over a time period
138
+ #
139
+ # @param agent_type [String, nil] Filter to specific agent, or nil for all
140
+ # @param days [Integer] Number of days to analyze
141
+ # @return [Array<Hash>] Daily metrics sorted oldest to newest
88
142
  def trend_analysis(agent_type: nil, days: 7)
89
143
  scope = agent_type ? by_agent(agent_type) : all
90
144
 
@@ -102,11 +156,96 @@ module RubyLLM
102
156
  end.reverse
103
157
  end
104
158
 
105
- # Chart data: Hourly activity chart for today showing success/failed
159
+ # Builds hourly activity chart data for today
160
+ #
161
+ # Cached for 5 minutes to reduce database load.
162
+ #
163
+ # @return [Array<Hash>] Chart series with success and failed counts per hour
106
164
  def hourly_activity_chart
165
+ # No caching - always fresh data based on latest execution
166
+ build_hourly_activity_data
167
+ end
168
+
169
+ # Returns chart data as arrays for Highcharts live updates
170
+ # Format: { categories: [...], series: [...] }
171
+ def hourly_activity_chart_json
172
+ # Always use current time as reference so chart shows "now" on the right
173
+ reference_time = Time.current.beginning_of_hour
174
+
175
+ categories = []
176
+ success_data = []
177
+ failed_data = []
178
+
179
+ # Create entries for the last 24 hours ending at current hour
180
+ (23.downto(0)).each do |hours_ago|
181
+ start_time = reference_time - hours_ago.hours
182
+ end_time = start_time + 1.hour
183
+ categories << start_time.in_time_zone.strftime("%H:%M")
184
+
185
+ hour_scope = where(created_at: start_time...end_time)
186
+ success_data << hour_scope.successful.count
187
+ failed_data << hour_scope.failed.count
188
+ end
189
+
190
+ {
191
+ categories: categories,
192
+ series: [
193
+ { name: "Success", data: success_data },
194
+ { name: "Failed", data: failed_data }
195
+ ]
196
+ }
197
+ end
198
+
199
+ # Builds the hourly activity data structure
200
+ # Shows the last 24 hours with current hour on the right
201
+ #
202
+ # @return [Array<Hash>] Success and failed series data
203
+ # @api private
204
+ def build_hourly_activity_data
107
205
  success_data = {}
108
206
  failed_data = {}
109
207
 
208
+ # Use current time as reference so chart shows "now" on the right
209
+ reference_time = Time.current.beginning_of_hour
210
+
211
+ # Create entries for the last 24 hours ending at current hour
212
+ (23.downto(0)).each do |hours_ago|
213
+ start_time = reference_time - hours_ago.hours
214
+ end_time = start_time + 1.hour
215
+ time_label = start_time.in_time_zone.strftime("%H:%M")
216
+
217
+ hour_scope = where(created_at: start_time...end_time)
218
+ success_data[time_label] = hour_scope.successful.count
219
+ failed_data[time_label] = hour_scope.failed.count
220
+ end
221
+
222
+ [
223
+ { name: "Success", data: success_data },
224
+ { name: "Failed", data: failed_data }
225
+ ]
226
+ end
227
+
228
+ # Retrieves hourly cost data for chart display
229
+ #
230
+ # Returns two series (input cost and output cost) with hourly breakdowns
231
+ # for the current day. Results are cached for 5 minutes.
232
+ #
233
+ # @return [Array<Hash>] Chart series with input and output cost per hour
234
+ def hourly_cost_chart
235
+ cache_key = "ruby_llm_agents/hourly_cost/#{Date.current}"
236
+ Rails.cache.fetch(cache_key, expires_in: 5.minutes) do
237
+ build_hourly_cost_data
238
+ end
239
+ end
240
+
241
+ # Builds the hourly cost data structure (uncached)
242
+ #
243
+ # @return [Array<Hash>] Input and output cost series data
244
+ # @api private
245
+ def build_hourly_cost_data
246
+ input_cost_data = {}
247
+ output_cost_data = {}
248
+
110
249
  # Create entries for each hour of the day (0-23)
111
250
  (0..23).each do |hour|
112
251
  time_label = format("%02d:00", hour)
@@ -114,33 +253,93 @@ module RubyLLM
114
253
  end_time = start_time + 1.hour
115
254
 
116
255
  hour_scope = where(created_at: start_time...end_time)
117
- total = hour_scope.count
118
- failed = hour_scope.failed.count
119
-
120
- success_data[time_label] = total - failed
121
- failed_data[time_label] = failed
256
+ input_cost_data[time_label] = (hour_scope.sum(:input_cost) || 0).round(6)
257
+ output_cost_data[time_label] = (hour_scope.sum(:output_cost) || 0).round(6)
122
258
  end
123
259
 
124
260
  [
125
- { name: "Success", data: success_data },
126
- { name: "Failed", data: failed_data }
261
+ { name: "Input Cost", data: input_cost_data },
262
+ { name: "Output Cost", data: output_cost_data }
127
263
  ]
128
264
  end
129
265
 
130
- private
266
+ # Cache hit rate percentage
267
+ #
268
+ # @return [Float] Percentage of executions that were cache hits (0.0-100.0)
269
+ def cache_hit_rate
270
+ total = count
271
+ return 0.0 if total.zero?
272
+
273
+ (cached.count.to_f / total * 100).round(1)
274
+ end
275
+
276
+ # Streaming execution rate percentage
277
+ #
278
+ # @return [Float] Percentage of executions that used streaming (0.0-100.0)
279
+ def streaming_rate
280
+ total = count
281
+ return 0.0 if total.zero?
282
+
283
+ (streaming.count.to_f / total * 100).round(1)
284
+ end
285
+
286
+ # Average time to first token for streaming executions
287
+ #
288
+ # @return [Integer, nil] Average TTFT in milliseconds, or nil if no data
289
+ def avg_time_to_first_token
290
+ streaming.where.not(time_to_first_token_ms: nil).average(:time_to_first_token_ms)&.round(0)
291
+ end
292
+
293
+ # Finish reason distribution
294
+ #
295
+ # @return [Hash{String => Integer}] Counts grouped by finish reason, sorted descending
296
+ def finish_reason_distribution
297
+ group(:finish_reason).count.sort_by { |_, v| -v }.to_h
298
+ end
299
+
300
+ # Rate limited execution count
301
+ #
302
+ # @return [Integer] Number of executions that were rate limited
303
+ def rate_limited_count
304
+ where(rate_limited: true).count
305
+ end
306
+
307
+ # Rate limited rate percentage
308
+ #
309
+ # @return [Float] Percentage of executions that were rate limited (0.0-100.0)
310
+ def rate_limited_rate
311
+ total = count
312
+ return 0.0 if total.zero?
313
+
314
+ (rate_limited_count.to_f / total * 100).round(1)
315
+ end
316
+
317
+ private
131
318
 
319
+ # Calculates success rate percentage for a scope
320
+ #
321
+ # @param scope [ActiveRecord::Relation] The scope to calculate from
322
+ # @return [Float] Success rate as percentage (0.0-100.0)
132
323
  def calculate_success_rate(scope)
133
324
  total = scope.count
134
325
  return 0.0 if total.zero?
135
326
  (scope.successful.count.to_f / total * 100).round(2)
136
327
  end
137
328
 
329
+ # Calculates error rate percentage for a scope
330
+ #
331
+ # @param scope [ActiveRecord::Relation] The scope to calculate from
332
+ # @return [Float] Error rate as percentage (0.0-100.0)
138
333
  def calculate_error_rate(scope)
139
334
  total = scope.count
140
335
  return 0.0 if total.zero?
141
336
  (scope.failed.count.to_f / total * 100).round(2)
142
337
  end
143
338
 
339
+ # Calculates statistics for an arbitrary scope
340
+ #
341
+ # @param scope [ActiveRecord::Relation] The scope to analyze
342
+ # @return [Hash] Statistics hash
144
343
  def stats_for_scope(scope)
145
344
  count = scope.count
146
345
  total_cost = scope.total_cost_sum || 0
@@ -155,6 +354,11 @@ module RubyLLM
155
354
  }
156
355
  end
157
356
 
357
+ # Calculates percentage change between two values
358
+ #
359
+ # @param old_value [Numeric, nil] Baseline value
360
+ # @param new_value [Numeric] New value
361
+ # @return [Float] Percentage change (negative = improvement for costs/duration)
158
362
  def percent_change(old_value, new_value)
159
363
  return 0.0 if old_value.nil? || old_value.zero?
160
364
  ((new_value - old_value).to_f / old_value * 100).round(2)
@@ -5,16 +5,22 @@ module RubyLLM
5
5
  class Execution
6
6
  # Metrics concern for cost calculations and performance metrics
7
7
  #
8
- # Provides methods for:
9
- # - Calculating costs from token usage via RubyLLM pricing
10
- # - Human-readable duration formatting
11
- # - Performance metrics (tokens/second, cost per 1K tokens)
12
- # - Formatted cost display helpers
8
+ # Provides instance methods for calculating costs from token usage,
9
+ # formatting durations, and computing performance metrics.
13
10
  #
11
+ # @see RubyLLM::Agents::Execution::Analytics
12
+ # @api public
14
13
  module Metrics
15
14
  extend ActiveSupport::Concern
16
15
 
17
- # Calculate costs from token usage and model pricing
16
+ # Calculates and sets input/output costs from token usage
17
+ #
18
+ # Uses RubyLLM's built-in pricing data to calculate costs.
19
+ # Sets input_cost and output_cost attributes (total_cost is calculated by callback).
20
+ #
21
+ # @param model_info [RubyLLM::Model, nil] Optional pre-resolved model info
22
+ # @return [void]
23
+ # @note Requires input_tokens and output_tokens to be set
18
24
  def calculate_costs!(model_info = nil)
19
25
  return unless input_tokens && output_tokens
20
26
 
@@ -30,55 +36,65 @@ module RubyLLM
30
36
  self.output_cost = ((output_tokens / 1_000_000.0) * output_price_per_million).round(6)
31
37
  end
32
38
 
33
- # Human-readable duration
39
+ # Returns execution duration in seconds
40
+ #
41
+ # @return [Float, nil] Duration in seconds with 2 decimal places, or nil
34
42
  def duration_seconds
35
43
  duration_ms ? (duration_ms / 1000.0).round(2) : nil
36
44
  end
37
45
 
38
- # Tokens per second
46
+ # Calculates throughput as tokens processed per second
47
+ #
48
+ # @return [Float, nil] Tokens per second, or nil if data unavailable
39
49
  def tokens_per_second
40
50
  return nil unless duration_ms && duration_ms > 0 && total_tokens
41
51
  (total_tokens / duration_seconds.to_f).round(2)
42
52
  end
43
53
 
44
- # Cost per 1K tokens (for comparison, in dollars)
54
+ # Calculates cost efficiency as cost per 1,000 tokens
55
+ #
56
+ # Useful for comparing cost efficiency across different models.
57
+ #
58
+ # @return [Float, nil] Cost per 1K tokens in USD, or nil if data unavailable
45
59
  def cost_per_1k_tokens
46
60
  return nil unless total_tokens && total_tokens > 0 && total_cost
47
61
  (total_cost / total_tokens.to_f * 1000).round(6)
48
62
  end
49
63
 
50
- # ==============================================================================
51
- # Cost Display Helpers
52
- # ==============================================================================
53
- #
54
- # Format cost as currency string
55
- # Example: format_cost(0.000045) => "$0.000045"
56
- #
64
+ # @!group Cost Display Helpers
57
65
 
66
+ # Returns input_cost formatted as currency
67
+ #
68
+ # @return [String, nil] Formatted cost (e.g., "$0.000045") or nil
58
69
  def formatted_input_cost
59
70
  format_cost(input_cost)
60
71
  end
61
72
 
73
+ # Returns output_cost formatted as currency
74
+ #
75
+ # @return [String, nil] Formatted cost (e.g., "$0.000045") or nil
62
76
  def formatted_output_cost
63
77
  format_cost(output_cost)
64
78
  end
65
79
 
80
+ # Returns total_cost formatted as currency
81
+ #
82
+ # @return [String, nil] Formatted cost (e.g., "$0.000045") or nil
66
83
  def formatted_total_cost
67
84
  format_cost(total_cost)
68
85
  end
69
86
 
70
- private
87
+ # @!endgroup
71
88
 
72
- def resolve_model_info
73
- return nil unless model_id
89
+ private
74
90
 
75
- model, _provider = RubyLLM::Models.resolve(model_id)
76
- model
77
- rescue RubyLLM::ModelNotFoundError
78
- Rails.logger.warn("[RubyLLM::Agents] Model not found for pricing: #{model_id}")
79
- nil
80
- end
91
+ # NOTE: resolve_model_info is defined in Execution class (execution.rb)
92
+ # It accepts an optional model_id parameter, defaulting to self.model_id
81
93
 
94
+ # Formats a cost value as currency string
95
+ #
96
+ # @param cost [Float, nil] Cost in USD
97
+ # @return [String, nil] Formatted string or nil
82
98
  def format_cost(cost)
83
99
  return nil unless cost
84
100
  format("$%.6f", cost)