ruby_llm-agents 3.5.5 → 3.6.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 +4 -0
  3. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +155 -10
  4. data/app/helpers/ruby_llm/agents/application_helper.rb +15 -1
  5. data/app/models/ruby_llm/agents/execution/replayable.rb +124 -0
  6. data/app/models/ruby_llm/agents/execution/scopes.rb +42 -1
  7. data/app/models/ruby_llm/agents/execution.rb +50 -1
  8. data/app/models/ruby_llm/agents/tenant/budgetable.rb +28 -4
  9. data/app/views/layouts/ruby_llm/agents/application.html.erb +41 -28
  10. data/app/views/ruby_llm/agents/agents/show.html.erb +16 -1
  11. data/app/views/ruby_llm/agents/dashboard/_top_tenants.html.erb +47 -0
  12. data/app/views/ruby_llm/agents/dashboard/index.html.erb +397 -100
  13. data/lib/generators/ruby_llm_agents/rename_agent_generator.rb +53 -0
  14. data/lib/generators/ruby_llm_agents/templates/rename_agent_migration.rb.tt +19 -0
  15. data/lib/ruby_llm/agents/agent_tool.rb +125 -0
  16. data/lib/ruby_llm/agents/audio/speaker.rb +5 -3
  17. data/lib/ruby_llm/agents/audio/speech_pricing.rb +63 -187
  18. data/lib/ruby_llm/agents/audio/transcriber.rb +5 -3
  19. data/lib/ruby_llm/agents/audio/transcription_pricing.rb +5 -7
  20. data/lib/ruby_llm/agents/base_agent.rb +144 -5
  21. data/lib/ruby_llm/agents/core/configuration.rb +178 -53
  22. data/lib/ruby_llm/agents/core/errors.rb +3 -77
  23. data/lib/ruby_llm/agents/core/instrumentation.rb +0 -17
  24. data/lib/ruby_llm/agents/core/version.rb +1 -1
  25. data/lib/ruby_llm/agents/dsl/base.rb +0 -8
  26. data/lib/ruby_llm/agents/dsl/queryable.rb +124 -0
  27. data/lib/ruby_llm/agents/dsl.rb +1 -0
  28. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +2 -1
  29. data/lib/ruby_llm/agents/image/generator/pricing.rb +75 -217
  30. data/lib/ruby_llm/agents/image/generator.rb +5 -3
  31. data/lib/ruby_llm/agents/infrastructure/attempt_tracker.rb +8 -0
  32. data/lib/ruby_llm/agents/infrastructure/circuit_breaker.rb +4 -2
  33. data/lib/ruby_llm/agents/pipeline/builder.rb +43 -0
  34. data/lib/ruby_llm/agents/pipeline/context.rb +11 -1
  35. data/lib/ruby_llm/agents/pipeline/executor.rb +1 -25
  36. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +26 -1
  37. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +18 -0
  38. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +90 -0
  39. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +29 -0
  40. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +11 -4
  41. data/lib/ruby_llm/agents/pipeline.rb +0 -92
  42. data/lib/ruby_llm/agents/results/background_removal_result.rb +11 -1
  43. data/lib/ruby_llm/agents/results/base.rb +23 -1
  44. data/lib/ruby_llm/agents/results/embedding_result.rb +14 -1
  45. data/lib/ruby_llm/agents/results/image_analysis_result.rb +11 -1
  46. data/lib/ruby_llm/agents/results/image_edit_result.rb +11 -1
  47. data/lib/ruby_llm/agents/results/image_generation_result.rb +12 -3
  48. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +11 -1
  49. data/lib/ruby_llm/agents/results/image_transform_result.rb +11 -1
  50. data/lib/ruby_llm/agents/results/image_upscale_result.rb +11 -1
  51. data/lib/ruby_llm/agents/results/image_variation_result.rb +11 -1
  52. data/lib/ruby_llm/agents/results/speech_result.rb +20 -1
  53. data/lib/ruby_llm/agents/results/transcription_result.rb +20 -1
  54. data/lib/ruby_llm/agents/text/embedder.rb +23 -18
  55. data/lib/ruby_llm/agents.rb +70 -5
  56. data/lib/tasks/ruby_llm_agents.rake +21 -0
  57. metadata +7 -6
  58. data/lib/ruby_llm/agents/infrastructure/reliability/breaker_manager.rb +0 -80
  59. data/lib/ruby_llm/agents/infrastructure/reliability/execution_constraints.rb +0 -69
  60. data/lib/ruby_llm/agents/infrastructure/reliability/executor.rb +0 -125
  61. data/lib/ruby_llm/agents/infrastructure/reliability/fallback_routing.rb +0 -72
  62. data/lib/ruby_llm/agents/infrastructure/reliability/retry_strategy.rb +0 -82
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fcd75502c369dc11b47f538e49ab52b0a04fa742c0df7b889915ee3b6003eebb
4
- data.tar.gz: 14d32d454e00c7bf2f0c9d1f61c0d8c16b82fd6d63fd3d72bc5d263c2f143ab0
3
+ metadata.gz: fa3658e8503a35a228e1b1d370c7e9308efb9ec5d0ce7e7a287afa4d69c891a5
4
+ data.tar.gz: 8bd69d29c6dbdc13f1ba2df4a11bb3032effadf198afff3dc7f2982abf2f62d2
5
5
  SHA512:
6
- metadata.gz: 8d53fe803e960be5b11e92bae6ad6923b3f648359ec476b9f56c46a355e2f71e75c63b71b546ecd170ee8e923300d6c11889ac8573071a891c8b21570094378e
7
- data.tar.gz: 674ba879084fdff24c48c02b0374cff6a1a7218420582d88ad76731f53c8fa9e41b97c081a721de8578d69c8a2348a124c283910b48a8b8b144e44d3ea4ce585
6
+ metadata.gz: f5c1c97afd3b6448c4b9467b969aa8e521fb578e3c84b2985798d4a02fb78cccf624e771459adb9b06ff42018c705deb322b510288db12e7faaf771e19126206
7
+ data.tar.gz: b4ad0b9e03fef2df24bd70d0ae72af5d866d87daa6352e2948900c9996c135915fa104e0849c00c88fd13ea126fb6643eada544c87960ea1103716c7286ee4b9
data/README.md CHANGED
@@ -182,7 +182,11 @@ result.save("logo.png")
182
182
  | **Image Operations** | Generation, analysis, editing, pipelines with cost tracking | [Images](https://github.com/adham90/ruby_llm-agents/wiki/Image-Generation) |
183
183
  | **Routing** | Message classification and routing with auto-generated prompts, inline classify | [Routing](https://github.com/adham90/ruby_llm-agents/wiki/Routing) |
184
184
  | **Audio** | Text-to-speech (OpenAI, ElevenLabs), speech-to-text, dynamic pricing, 28+ output formats, dashboard audio playback | [Audio](https://github.com/adham90/ruby_llm-agents/wiki/Audio) |
185
+ | **Agent Composition** | Use agents as tools in other agents with automatic hierarchy tracking | [Tools](https://github.com/adham90/ruby_llm-agents/wiki/Tools) |
186
+ | **Queryable Agents** | Query execution history from agent classes with stats, replay, and cost breakdown | [Querying](https://github.com/adham90/ruby_llm-agents/wiki/Querying-Executions) |
185
187
  | **Alerts** | Slack, webhook, and custom notifications | [Alerts](https://github.com/adham90/ruby_llm-agents/wiki/Alerts) |
188
+ | **AS::Notifications** | 11 instrumentation events across execution, cache, budget, and reliability | [Events](https://github.com/adham90/ruby_llm-agents/wiki/ActiveSupport-Notifications) |
189
+ | **Custom Middleware** | Inject custom middleware globally or per-agent with positioning control | [Middleware](https://github.com/adham90/ruby_llm-agents/wiki/Custom-Middleware) |
186
190
 
187
191
  ## Quick Start
188
192
 
@@ -18,43 +18,131 @@ module RubyLLM
18
18
  #
19
19
  # @return [void]
20
20
  def index
21
- @selected_range = params[:range].presence || "today"
21
+ @selected_range = sanitize_range(params[:range])
22
22
  @days = range_to_days(@selected_range)
23
+ parse_custom_dates if @selected_range == "custom"
23
24
  base_scope = tenant_scoped_executions
24
- @now_strip = base_scope.now_strip_data(range: @selected_range)
25
+ @now_strip = build_now_strip(base_scope)
25
26
  @critical_alerts = load_critical_alerts(base_scope)
26
27
  @recent_executions = base_scope.recent(10)
27
28
  @agent_stats = build_agent_comparison(base_scope)
28
29
  @top_errors = build_top_errors(base_scope)
29
30
  @tenant_budget = load_tenant_budget(base_scope)
30
31
  @model_stats = build_model_stats(base_scope)
32
+ @cache_savings = build_cache_savings(base_scope)
33
+ @top_tenants = build_top_tenants
31
34
  end
32
35
 
33
36
  # Returns chart data as JSON for live updates
34
37
  #
35
- # @param range [String] Time range: "today", "7d", or "30d"
38
+ # @param range [String] Time range: "today", "7d", "30d", "90d", or "custom"
36
39
  # @return [JSON] Chart data with series
37
40
  def chart_data
38
- range = params[:range].presence || "today"
39
- data = tenant_scoped_executions.activity_chart_json(range: range)
41
+ range = sanitize_range(params[:range])
42
+ scope = tenant_scoped_executions
43
+
44
+ data = if range == "custom"
45
+ from = parse_date(params[:from])
46
+ to = parse_date(params[:to])
47
+ if from && to
48
+ from, to = [from, to].sort
49
+ to = [to, Date.current].min
50
+ scope.activity_chart_json_for_dates(from: from, to: to)
51
+ else
52
+ scope.activity_chart_json(range: "today")
53
+ end
54
+ else
55
+ scope.activity_chart_json(range: range)
56
+ end
57
+
40
58
  render json: data
41
59
  end
42
60
 
43
61
  private
44
62
 
63
+ # Whitelists valid range values, defaulting to "today"
64
+ #
65
+ # @param range [String, nil] Raw range parameter
66
+ # @return [String] Sanitized range value
67
+ def sanitize_range(range)
68
+ %w[today 7d 30d 90d custom].include?(range) ? range : "today"
69
+ end
70
+
45
71
  # Converts range parameter to number of days
46
72
  #
47
- # @param range [String] Range parameter (today, 7d, 30d)
48
- # @return [Integer] Number of days
73
+ # @param range [String] Range parameter (today, 7d, 30d, 90d, custom)
74
+ # @return [Integer, nil] Number of days, or nil for custom
49
75
  def range_to_days(range)
50
76
  case range
51
77
  when "today" then 1
52
78
  when "7d" then 7
53
79
  when "30d" then 30
80
+ when "90d" then 90
81
+ when "custom" then nil
54
82
  else 1
55
83
  end
56
84
  end
57
85
 
86
+ # Safely parses a date string with validation
87
+ #
88
+ # Rejects future dates and dates more than 1 year ago.
89
+ #
90
+ # @param value [String, nil] Date string (YYYY-MM-DD)
91
+ # @return [Date, nil] Parsed date or nil if invalid
92
+ def parse_date(value)
93
+ return nil if value.blank?
94
+ date = Date.parse(value)
95
+ return nil if date > Date.current
96
+ return nil if date < 1.year.ago.to_date
97
+ date
98
+ rescue ArgumentError
99
+ nil
100
+ end
101
+
102
+ # Parses custom date range params and sets instance variables
103
+ #
104
+ # Falls back to "today" if dates are missing or invalid.
105
+ #
106
+ # @return [void]
107
+ def parse_custom_dates
108
+ from = parse_date(params[:from])
109
+ to = parse_date(params[:to])
110
+
111
+ if from && to
112
+ from, to = [from, to].sort
113
+ @custom_from = from
114
+ @custom_to = [to, Date.current].min
115
+ @days = (@custom_to - @custom_from).to_i + 1
116
+ else
117
+ @selected_range = "today"
118
+ @days = 1
119
+ end
120
+ end
121
+
122
+ # Returns the correct time scope for the current range
123
+ #
124
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter
125
+ # @return [ActiveRecord::Relation] Time-scoped relation
126
+ def time_scoped(base_scope)
127
+ if @selected_range == "custom" && @custom_from && @custom_to
128
+ base_scope.where(created_at: @custom_from.beginning_of_day..@custom_to.end_of_day)
129
+ else
130
+ base_scope.last_n_days(@days)
131
+ end
132
+ end
133
+
134
+ # Routes to the correct now_strip_data method based on range
135
+ #
136
+ # @param base_scope [ActiveRecord::Relation] Base scope
137
+ # @return [Hash] Now strip metrics
138
+ def build_now_strip(base_scope)
139
+ if @selected_range == "custom" && @custom_from && @custom_to
140
+ base_scope.now_strip_data_for_dates(from: @custom_from, to: @custom_to)
141
+ else
142
+ base_scope.now_strip_data(range: @selected_range)
143
+ end
144
+ end
145
+
58
146
  # Builds per-agent comparison statistics for all agent types
59
147
  #
60
148
  # Creates separate instance variables for each agent type:
@@ -67,7 +155,7 @@ module RubyLLM
67
155
  # @param base_scope [ActiveRecord::Relation] Base scope to filter from
68
156
  # @return [Array<Hash>] Array of base agent stats (for backward compatibility)
69
157
  def build_agent_comparison(base_scope = Execution)
70
- scope = base_scope.last_n_days(@days)
158
+ scope = time_scoped(base_scope)
71
159
 
72
160
  # Get ALL agents from registry (file system + execution history)
73
161
  all_agent_types = AgentRegistry.all
@@ -111,7 +199,7 @@ module RubyLLM
111
199
  # @param base_scope [ActiveRecord::Relation] Base scope to filter from
112
200
  # @return [Array<Hash>] Array of model stats sorted by total cost descending
113
201
  def build_model_stats(base_scope = Execution)
114
- scope = base_scope.last_n_days(@days).where.not(model_id: nil)
202
+ scope = time_scoped(base_scope).where.not(model_id: nil)
115
203
 
116
204
  # Batch fetch stats grouped by model
117
205
  counts = scope.group(:model_id).count
@@ -147,7 +235,7 @@ module RubyLLM
147
235
  # @param base_scope [ActiveRecord::Relation] Base scope to filter from
148
236
  # @return [Array<Hash>] Top 5 error classes with counts
149
237
  def build_top_errors(base_scope = Execution)
150
- scope = base_scope.last_n_days(@days).where(status: "error")
238
+ scope = time_scoped(base_scope).where(status: "error")
151
239
  total_errors = scope.count
152
240
 
153
241
  scope.group(:error_class)
@@ -347,6 +435,63 @@ module RubyLLM
347
435
  alerts.take(3)
348
436
  end
349
437
 
438
+ # Builds cache savings statistics for the dashboard
439
+ #
440
+ # @param base_scope [ActiveRecord::Relation] Base scope to filter from
441
+ # @return [Hash] Cache savings data with count, estimated savings, and hit rate
442
+ def build_cache_savings(base_scope)
443
+ scope = time_scoped(base_scope)
444
+ total_count = scope.count
445
+ return {count: 0, estimated_savings: 0, hit_rate: 0, total_executions: 0} if total_count.zero?
446
+
447
+ cached_scope = scope.cached
448
+ cache_count = cached_scope.count
449
+ estimated_savings = cached_scope.sum(:total_cost)
450
+
451
+ {
452
+ count: cache_count,
453
+ estimated_savings: estimated_savings,
454
+ hit_rate: (cache_count.to_f / total_count * 100).round(1),
455
+ total_executions: total_count
456
+ }
457
+ end
458
+
459
+ # Builds top tenants list for the dashboard overview
460
+ #
461
+ # @return [Array<Hash>, nil] Top 5 tenants by monthly spend, or nil if none
462
+ def build_top_tenants
463
+ return nil unless Tenant.table_exists?
464
+
465
+ tenants = Tenant.active
466
+ .where("monthly_cost_spent > 0 OR monthly_executions_count > 0")
467
+ .order(monthly_cost_spent: :desc)
468
+ .limit(5)
469
+
470
+ return nil if tenants.empty?
471
+
472
+ tenants.map do |tenant|
473
+ tenant.ensure_daily_reset!
474
+ tenant.ensure_monthly_reset!
475
+
476
+ monthly_limit = tenant.effective_monthly_limit
477
+ daily_limit = tenant.effective_daily_limit
478
+
479
+ {
480
+ id: tenant.id,
481
+ tenant_id: tenant.tenant_id,
482
+ name: tenant.display_name,
483
+ enforcement: tenant.effective_enforcement,
484
+ monthly_spend: tenant.monthly_cost_spent,
485
+ monthly_limit: monthly_limit,
486
+ monthly_percentage: (monthly_limit.to_f > 0) ? (tenant.monthly_cost_spent / monthly_limit * 100).round(1) : 0,
487
+ daily_spend: tenant.daily_cost_spent,
488
+ daily_limit: daily_limit,
489
+ daily_percentage: (daily_limit.to_f > 0) ? (tenant.daily_cost_spent / daily_limit * 100).round(1) : 0,
490
+ monthly_executions: tenant.monthly_executions_count
491
+ }
492
+ end
493
+ end
494
+
350
495
  # Batch fetches execution stats for all agents in a time period
351
496
  #
352
497
  # @param scope [ActiveRecord::Relation] Base scope with time filter
@@ -286,7 +286,7 @@ module RubyLLM
286
286
 
287
287
  # Returns human-readable display name for time range
288
288
  #
289
- # @param range [String] Range parameter (today, 7d, 30d)
289
+ # @param range [String] Range parameter (today, 7d, 30d, 90d, custom)
290
290
  # @return [String] Human-readable range name
291
291
  # @example
292
292
  # range_display_name("7d") #=> "7 Days"
@@ -295,10 +295,24 @@ module RubyLLM
295
295
  when "today" then "Today"
296
296
  when "7d" then "7 Days"
297
297
  when "30d" then "30 Days"
298
+ when "90d" then "90 Days"
299
+ when "custom" then "Custom"
298
300
  else "Today"
299
301
  end
300
302
  end
301
303
 
304
+ # Returns preset range options for the time range dropdown
305
+ #
306
+ # @return [Array<Hash>] Array of {value:, label:} pairs
307
+ def range_presets
308
+ [
309
+ {value: "today", label: "Today"},
310
+ {value: "7d", label: "7 Days"},
311
+ {value: "30d", label: "30 Days"},
312
+ {value: "90d", label: "90 Days"}
313
+ ]
314
+ end
315
+
302
316
  # Formats milliseconds to human-readable duration
303
317
  #
304
318
  # @param ms [Numeric, nil] Duration in milliseconds
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Execution
6
+ # Adds replay capability to execution records.
7
+ #
8
+ # Allows re-executing a previous run with the same inputs,
9
+ # or with tweaked parameters for A/B testing and debugging.
10
+ #
11
+ # @example Replay with same settings
12
+ # run = SupportAgent.last_run
13
+ # new_run = run.replay
14
+ #
15
+ # @example Replay with different model
16
+ # run.replay(model: "claude-sonnet-4-6")
17
+ #
18
+ # @example Compare two models
19
+ # run1 = SupportAgent.last_run
20
+ # run2 = run1.replay(model: "gpt-4o-mini")
21
+ # puts "Original: #{run1.total_cost} | Replay: #{run2.total_cost}"
22
+ #
23
+ module Replayable
24
+ extend ActiveSupport::Concern
25
+
26
+ # Re-executes this agent run with the same (or overridden) inputs.
27
+ #
28
+ # Loads the original agent class, reconstructs its parameters from
29
+ # the execution detail record, and executes through the full pipeline.
30
+ # The new execution is tracked separately and linked via metadata.
31
+ #
32
+ # @param model [String, nil] Override the model
33
+ # @param temperature [Float, nil] Override the temperature
34
+ # @param overrides [Hash] Additional parameter overrides
35
+ # @return [Object] The result from the new execution
36
+ #
37
+ # @raise [ReplayError] If the agent class cannot be resolved or detail is missing
38
+ #
39
+ def replay(model: nil, temperature: nil, **overrides)
40
+ validate_replayable!
41
+ agent_klass = resolve_agent_class
42
+ params = build_replay_params(overrides)
43
+
44
+ opts = params.merge(_replay_source_id: id)
45
+ opts[:model] = model if model
46
+ opts[:temperature] = temperature if temperature
47
+
48
+ agent_klass.call(**opts)
49
+ end
50
+
51
+ # Returns whether this execution can be replayed.
52
+ #
53
+ # @return [Boolean]
54
+ #
55
+ def replayable?
56
+ return false if agent_type.blank?
57
+ return false if detail.nil?
58
+
59
+ resolve_agent_class
60
+ true
61
+ rescue
62
+ false
63
+ end
64
+
65
+ # Returns all executions that are replays of this one.
66
+ #
67
+ # @return [ActiveRecord::Relation]
68
+ #
69
+ def replays
70
+ self.class.replays_of(id)
71
+ end
72
+
73
+ # Returns the original execution this was replayed from.
74
+ #
75
+ # @return [RubyLLM::Agents::Execution, nil]
76
+ #
77
+ def replay_source
78
+ source_id = metadata&.dig("replay_source_id")
79
+ return nil unless source_id
80
+
81
+ self.class.find_by(id: source_id)
82
+ end
83
+
84
+ # Returns whether this execution is a replay of another.
85
+ #
86
+ # @return [Boolean]
87
+ #
88
+ def replay?
89
+ metadata&.dig("replay_source_id").present?
90
+ end
91
+
92
+ private
93
+
94
+ def validate_replayable!
95
+ if agent_type.blank?
96
+ raise ReplayError, "Cannot replay: execution has no agent_type"
97
+ end
98
+
99
+ if detail.nil?
100
+ raise ReplayError,
101
+ "Cannot replay execution ##{id}: no detail record " \
102
+ "(prompts and parameters are required for replay)"
103
+ end
104
+
105
+ resolve_agent_class
106
+ end
107
+
108
+ def resolve_agent_class
109
+ agent_type.constantize
110
+ rescue NameError => e
111
+ raise ReplayError,
112
+ "Cannot replay execution ##{id}: agent class '#{agent_type}' " \
113
+ "not found (#{e.message})"
114
+ end
115
+
116
+ def build_replay_params(overrides)
117
+ original_params = detail.parameters || {}
118
+ symbolized = original_params.transform_keys(&:to_sym)
119
+ symbolized.merge(overrides)
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -83,7 +83,10 @@ module RubyLLM
83
83
  # Filters to a specific LLM model
84
84
  # @param model_id [String] The model identifier
85
85
  # @return [ActiveRecord::Relation]
86
- scope :by_agent, ->(agent_type) { where(agent_type: agent_type.to_s) }
86
+ scope :by_agent, ->(agent_type) {
87
+ names = resolve_agent_names(agent_type)
88
+ where(agent_type: names)
89
+ }
87
90
  scope :by_model, ->(model_id) { where(model_id: model_id.to_s) }
88
91
 
89
92
  # @!endgroup
@@ -265,6 +268,18 @@ module RubyLLM
265
268
  scope :without_tool_calls, -> { where(tool_calls_count: 0) }
266
269
 
267
270
  # @!endgroup
271
+
272
+ # @!group Replay Scopes
273
+
274
+ # @!method replays_of(execution_id)
275
+ # Returns executions that are replays of the given execution
276
+ # @param execution_id [Integer, String] The source execution ID
277
+ # @return [ActiveRecord::Relation]
278
+ scope :replays_of, ->(execution_id) {
279
+ metadata_value("replay_source_id", execution_id.to_s)
280
+ }
281
+
282
+ # @!endgroup
268
283
  end
269
284
 
270
285
  # @!group Aggregation Methods
@@ -273,6 +288,32 @@ module RubyLLM
273
288
  # They can be called on scoped relations.
274
289
 
275
290
  class_methods do
291
+ # Resolves all known names for an agent, including aliases
292
+ #
293
+ # Accepts a class (uses all_agent_names), or a string (looks up
294
+ # the class in ObjectSpace to check for aliases, falls back to
295
+ # the string itself).
296
+ #
297
+ # @param agent_type [Class, String] Agent class or class name
298
+ # @return [Array<String>] All names to query
299
+ def resolve_agent_names(agent_type)
300
+ if agent_type.is_a?(Class) && agent_type.respond_to?(:all_agent_names)
301
+ agent_type.all_agent_names
302
+ else
303
+ name = agent_type.to_s
304
+ klass = begin
305
+ name.constantize
306
+ rescue NameError
307
+ nil
308
+ end
309
+ if klass&.respond_to?(:all_agent_names)
310
+ klass.all_agent_names
311
+ else
312
+ [name]
313
+ end
314
+ end
315
+ end
316
+
276
317
  # Database-agnostic JSON metadata queries
277
318
  # These fields (fallback_reason, retryable, rate_limited, etc.) are stored
278
319
  # in the metadata JSON column rather than as direct columns.
@@ -49,6 +49,7 @@ module RubyLLM
49
49
  include Execution::Metrics
50
50
  include Execution::Scopes
51
51
  include Execution::Analytics
52
+ include Execution::Replayable
52
53
 
53
54
  # Status enum
54
55
  # - running: execution in progress
@@ -274,18 +275,20 @@ module RubyLLM
274
275
 
275
276
  # Returns real-time dashboard data for the Now Strip
276
277
  #
277
- # @param range [String] Time range: "today", "7d", or "30d"
278
+ # @param range [String] Time range: "today", "7d", "30d", or "90d"
278
279
  # @return [Hash] Now strip metrics with period-over-period comparisons
279
280
  def self.now_strip_data(range: "today")
280
281
  current_scope = case range
281
282
  when "7d" then last_n_days(7)
282
283
  when "30d" then last_n_days(30)
284
+ when "90d" then last_n_days(90)
283
285
  else today
284
286
  end
285
287
 
286
288
  previous_scope = case range
287
289
  when "7d" then where(created_at: 14.days.ago.beginning_of_day..7.days.ago.beginning_of_day)
288
290
  when "30d" then where(created_at: 60.days.ago.beginning_of_day..30.days.ago.beginning_of_day)
291
+ when "90d" then where(created_at: 180.days.ago.beginning_of_day..90.days.ago.beginning_of_day)
289
292
  else yesterday
290
293
  end
291
294
 
@@ -320,6 +323,52 @@ module RubyLLM
320
323
  )
321
324
  end
322
325
 
326
+ # Returns Now Strip data for a custom date range
327
+ #
328
+ # Compares the selected range against the same-length window
329
+ # immediately preceding it.
330
+ #
331
+ # @param from [Date] Start date (inclusive)
332
+ # @param to [Date] End date (inclusive)
333
+ # @return [Hash] Now strip metrics with period-over-period comparisons
334
+ def self.now_strip_data_for_dates(from:, to:)
335
+ span_days = (to - from).to_i + 1
336
+ current_scope = where(created_at: from.beginning_of_day..to.end_of_day)
337
+ previous_from = from - span_days.days
338
+ previous_to = from - 1.day
339
+ previous_scope = where(created_at: previous_from.beginning_of_day..previous_to.end_of_day)
340
+
341
+ current = {
342
+ running: running.count,
343
+ success_today: current_scope.status_success.count,
344
+ errors_today: current_scope.status_error.count,
345
+ timeouts_today: current_scope.status_timeout.count,
346
+ cost_today: current_scope.sum(:total_cost) || 0,
347
+ executions_today: current_scope.count,
348
+ success_rate: calculate_period_success_rate(current_scope),
349
+ avg_duration_ms: current_scope.avg_duration&.round || 0,
350
+ total_tokens: current_scope.total_tokens_sum || 0
351
+ }
352
+
353
+ previous = {
354
+ success: previous_scope.status_success.count,
355
+ errors: previous_scope.status_error.count,
356
+ cost: previous_scope.sum(:total_cost) || 0,
357
+ avg_duration_ms: previous_scope.avg_duration&.round || 0,
358
+ total_tokens: previous_scope.total_tokens_sum || 0
359
+ }
360
+
361
+ current.merge(
362
+ comparisons: {
363
+ success_change: pct_change(previous[:success], current[:success_today]),
364
+ errors_change: pct_change(previous[:errors], current[:errors_today]),
365
+ cost_change: pct_change(previous[:cost], current[:cost_today]),
366
+ duration_change: pct_change(previous[:avg_duration_ms], current[:avg_duration_ms]),
367
+ tokens_change: pct_change(previous[:total_tokens], current[:total_tokens])
368
+ }
369
+ )
370
+ end
371
+
323
372
  # Calculates percentage change between old and new values
324
373
  #
325
374
  # @param old_val [Numeric, nil] Previous period value
@@ -125,26 +125,32 @@ module RubyLLM
125
125
 
126
126
  # Returns the effective per-agent daily limit
127
127
  #
128
+ # Checks the current name and all aliases for matching limits.
129
+ #
128
130
  # @param agent_type [String] The agent class name
129
131
  # @return [Float, nil] The limit or nil if not set
130
132
  def effective_per_agent_daily(agent_type)
131
- limit = per_agent_daily&.dig(agent_type)
133
+ names = resolve_agent_names(agent_type)
134
+ limit = names.lazy.filter_map { |n| per_agent_daily&.dig(n) }.first
132
135
  return limit if limit.present?
133
136
  return nil unless inherit_global_defaults
134
137
 
135
- global_config&.dig(:per_agent_daily, agent_type)
138
+ names.lazy.filter_map { |n| global_config&.dig(:per_agent_daily, n) }.first
136
139
  end
137
140
 
138
141
  # Returns the effective per-agent monthly limit
139
142
  #
143
+ # Checks the current name and all aliases for matching limits.
144
+ #
140
145
  # @param agent_type [String] The agent class name
141
146
  # @return [Float, nil] The limit or nil if not set
142
147
  def effective_per_agent_monthly(agent_type)
143
- limit = per_agent_monthly&.dig(agent_type)
148
+ names = resolve_agent_names(agent_type)
149
+ limit = names.lazy.filter_map { |n| per_agent_monthly&.dig(n) }.first
144
150
  return limit if limit.present?
145
151
  return nil unless inherit_global_defaults
146
152
 
147
- global_config&.dig(:per_agent_monthly, agent_type)
153
+ names.lazy.filter_map { |n| global_config&.dig(:per_agent_monthly, n) }.first
148
154
  end
149
155
 
150
156
  # Budget status checks
@@ -352,6 +358,24 @@ module RubyLLM
352
358
  RubyLLM::Agents.configuration.budgets
353
359
  end
354
360
 
361
+ # Resolves all known names for an agent (current name + aliases)
362
+ #
363
+ # @param agent_type [String] The agent class name
364
+ # @return [Array<String>] All names to check
365
+ def resolve_agent_names(agent_type)
366
+ name = agent_type.to_s
367
+ klass = begin
368
+ name.constantize
369
+ rescue NameError
370
+ nil
371
+ end
372
+ if klass&.respond_to?(:all_agent_names)
373
+ klass.all_agent_names
374
+ else
375
+ [name]
376
+ end
377
+ end
378
+
355
379
  # Merges per-agent daily limits with global defaults
356
380
  #
357
381
  # @return [Hash]