ruby_llm-agents 3.15.1 → 3.16.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +29 -8
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +48 -44
  4. data/app/controllers/ruby_llm/agents/analytics_controller.rb +38 -72
  5. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +69 -56
  6. data/app/controllers/ruby_llm/agents/executions_controller.rb +25 -41
  7. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -45
  8. data/app/controllers/ruby_llm/agents/tenants_controller.rb +67 -47
  9. data/app/models/ruby_llm/agents/execution/analytics.rb +174 -133
  10. data/app/models/ruby_llm/agents/execution/breakdown.rb +243 -0
  11. data/app/models/ruby_llm/agents/execution/scopes.rb +39 -3
  12. data/app/models/ruby_llm/agents/execution.rb +23 -5
  13. data/app/services/ruby_llm/agents/agent_registry.rb +75 -12
  14. data/app/views/ruby_llm/agents/agents/index.html.erb +24 -7
  15. data/app/views/ruby_llm/agents/agents/show.html.erb +3 -2
  16. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -7
  17. data/app/views/ruby_llm/agents/executions/_list.html.erb +1 -52
  18. data/app/views/ruby_llm/agents/requests/index.html.erb +8 -57
  19. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +1 -52
  20. data/app/views/ruby_llm/agents/shared/_pagination.html.erb +66 -0
  21. data/app/views/ruby_llm/agents/shared/query_timeout.html.erb +18 -0
  22. data/app/views/ruby_llm/agents/system_config/show.html.erb +4 -0
  23. data/app/views/ruby_llm/agents/tenants/index.html.erb +1 -1
  24. data/app/views/ruby_llm/agents/tenants/show.html.erb +6 -5
  25. data/config/routes.rb +2 -2
  26. data/lib/generators/ruby_llm_agents/templates/add_executions_analytics_index_migration.rb.tt +50 -0
  27. data/lib/generators/ruby_llm_agents/templates/add_root_execution_list_index_migration.rb.tt +16 -0
  28. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +5 -0
  29. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +26 -0
  30. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +44 -0
  31. data/lib/ruby_llm/agents/core/configuration.rb +20 -0
  32. data/lib/ruby_llm/agents/core/version.rb +1 -1
  33. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +39 -1
  34. data/lib/ruby_llm/agents/rails/engine.rb +87 -7
  35. metadata +7 -2
@@ -0,0 +1,18 @@
1
+ <div class="flex items-center gap-3 mb-6">
2
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">query timed out</span>
3
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
4
+ </div>
5
+
6
+ <div class="font-mono text-xs space-y-3" role="alert">
7
+ <p class="text-gray-900 dark:text-gray-100">
8
+ This view needed more than <%= @query_timeout %>s of database time, so the query was cancelled.
9
+ </p>
10
+ <p class="text-gray-500 dark:text-gray-400">
11
+ It aggregates every execution in the selected range. Try a shorter time range or a narrower filter.
12
+ The limit is <code class="text-gray-700 dark:text-gray-300">config.dashboard_query_timeout</code>.
13
+ </p>
14
+ <p>
15
+ <%= link_to "← back to dashboard", ruby_llm_agents.root_path,
16
+ class: "text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none focus:underline" %>
17
+ </p>
18
+ </div>
@@ -106,6 +106,10 @@
106
106
  <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">recent limit</span>
107
107
  <span class="text-gray-900 dark:text-gray-200"><%= @config.recent_executions_limit %></span>
108
108
  </div>
109
+ <div class="flex items-center gap-3 py-0.5">
110
+ <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">query timeout</span>
111
+ <span class="text-gray-900 dark:text-gray-200"><%= @config.dashboard_query_timeout ? "#{@config.dashboard_query_timeout}s" : "off" %></span>
112
+ </div>
109
113
  <div class="flex items-center gap-3 py-0.5">
110
114
  <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">parent controller</span>
111
115
  <span class="text-gray-900 dark:text-gray-200"><%= @config.dashboard_parent_controller %></span>
@@ -51,7 +51,7 @@
51
51
  class: "w-20 flex-shrink-0 text-right hidden sm:block hover:text-gray-600 dark:hover:text-gray-300 #{sort_active.call('monthly_limit')}" %>
52
52
  <%= link_to "enforcement#{sort_arrow.call('enforcement')}".html_safe, sort_url.call("enforcement"),
53
53
  class: "w-16 flex-shrink-0 hidden md:block hover:text-gray-600 dark:hover:text-gray-300 #{sort_active.call('enforcement')}" %>
54
- <span class="w-16 flex-shrink-0 text-right hidden md:block">cost</span>
54
+ <span class="w-16 flex-shrink-0 text-right hidden md:block" title="Cost this month">cost/mo</span>
55
55
  <span class="w-24 flex-shrink-0 text-right">last run</span>
56
56
  </div>
57
57
 
@@ -8,7 +8,7 @@
8
8
  total_executions = @usage_stats[:total_executions]
9
9
  total_cost = @usage_stats[:total_cost]
10
10
  total_tokens = @usage_stats[:total_tokens]
11
- success_count = @tenant.executions.where(status: "success").count
11
+ success_count = @usage_stats[:success_count]
12
12
  success_rate = total_executions > 0 ? (success_count.to_f / total_executions * 100).round : 100
13
13
 
14
14
  has_any_limit = @tenant.effective_daily_limit || @tenant.effective_monthly_limit ||
@@ -64,6 +64,7 @@
64
64
  <!-- Right: stats -->
65
65
  <div class="flex-shrink-0 text-right font-mono text-xs text-gray-400 dark:text-gray-500 space-y-1">
66
66
  <div class="flex items-center justify-end gap-x-4">
67
+ <span class="text-gray-300 dark:text-gray-700">this month</span>
67
68
  <span><span class="text-gray-800 dark:text-gray-200"><%= number_with_delimiter(total_executions) %></span> runs</span>
68
69
  <span class="<%= success_rate >= 95 ? 'text-green-500' : success_rate >= 80 ? 'text-yellow-500' : 'text-red-500' %>"><%= success_rate %>% ok</span>
69
70
  <span><span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(total_cost, precision: 4) %></span> cost</span>
@@ -127,10 +128,10 @@
127
128
  <% if @error_count > 0 %>
128
129
  <div class="mt-3 font-mono text-xs text-gray-500 dark:text-gray-400">
129
130
  <span class="text-red-500">$<%= number_with_precision(@error_cost, precision: 4) %></span>
130
- wasted on <span class="text-red-500"><%= number_with_delimiter(@error_count) %></span> failed executions
131
+ wasted on <span class="text-red-500"><%= number_with_delimiter(@error_count) %></span> failed executions this month
131
132
  <% error_pct = total_cost.to_f > 0 ? (@error_cost.to_f / total_cost * 100).round(1) : 0 %>
132
133
  <% if error_pct > 0 %>
133
- (<%= error_pct %>% of total cost)
134
+ (<%= error_pct %>% of this month's cost)
134
135
  <% end %>
135
136
  </div>
136
137
  <% end %>
@@ -321,7 +322,7 @@
321
322
  <% if @usage_by_agent.present? %>
322
323
  <!-- ── usage by agent ──────────────────── -->
323
324
  <div class="flex items-center gap-3 mt-6 mb-3">
324
- <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by agent</span>
325
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by agent &middot; this month</span>
325
326
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
326
327
  </div>
327
328
 
@@ -359,7 +360,7 @@
359
360
  <% if @usage_by_model.present? %>
360
361
  <!-- ── usage by model ──────────────────── -->
361
362
  <div class="flex items-center gap-3 mt-6 mb-3">
362
- <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by model</span>
363
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by model &middot; this month</span>
363
364
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
364
365
  </div>
365
366
 
data/config/routes.rb CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  RubyLLM::Agents::Engine.routes.draw do
4
4
  root to: "dashboard#index"
5
- get "chart_data", to: "dashboard#chart_data"
5
+ get "chart_data", to: "dashboard#chart_data", defaults: {format: :json}
6
6
 
7
7
  resources :agents, only: [:index, :show, :update] do
8
8
  member do
@@ -26,6 +26,6 @@ RubyLLM::Agents::Engine.routes.draw do
26
26
  end
27
27
 
28
28
  get "analytics", to: "analytics#index", as: :analytics
29
- get "analytics/chart_data", to: "analytics#chart_data", as: :analytics_chart_data
29
+ get "analytics/chart_data", to: "analytics#chart_data", as: :analytics_chart_data, defaults: {format: :json}
30
30
  resource :system_config, only: [:show], controller: "system_config"
31
31
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to add the covering index behind the dashboard's aggregate queries
4
+ #
5
+ # Dashboard, analytics and agent stats all aggregate executions over a time
6
+ # range, reading a handful of narrow columns from each row. Fetching those
7
+ # rows from the table drags the wide ones along (metadata JSON, in practice
8
+ # most of each row), so the cost of every figure grows with the size of the
9
+ # range: seconds for 30 days on a table with millions of executions.
10
+ #
11
+ # This index stores the aggregated columns next to created_at, so those
12
+ # queries are answered from the index alone. PostgreSQL keeps them out of the
13
+ # key with INCLUDE; SQLite takes them as trailing key columns.
14
+ #
15
+ # The index is built CONCURRENTLY on PostgreSQL so executions keep being
16
+ # recorded while it builds. On a large table that takes a while; an
17
+ # interrupted build leaves an invalid index behind, which re-running this
18
+ # migration replaces.
19
+ class AddExecutionsAnalyticsIndex < ActiveRecord::Migration<%= migration_version %>
20
+ disable_ddl_transaction!
21
+
22
+ TABLE = :ruby_llm_agents_executions
23
+ INDEX = "idx_executions_analytics"
24
+ COVERED = %i[
25
+ agent_type model_id chosen_model_id status error_class tenant_id parent_execution_id
26
+ total_cost total_tokens input_tokens output_tokens duration_ms cache_hit streaming
27
+ ].freeze
28
+
29
+ def up
30
+ covered = COVERED.select { |column| column_exists?(TABLE, column) }
31
+
32
+ case connection.adapter_name.downcase
33
+ when /postg/
34
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX}"
35
+ execute "CREATE INDEX CONCURRENTLY #{INDEX} ON #{TABLE} (created_at) INCLUDE (#{covered.join(", ")})"
36
+ when /sqlite/
37
+ add_index TABLE, [:created_at, *covered], name: INDEX, if_not_exists: true
38
+ else
39
+ say "Skipping #{INDEX}: covering index is only defined for PostgreSQL and SQLite"
40
+ end
41
+ end
42
+
43
+ def down
44
+ if connection.adapter_name.downcase.include?("postg")
45
+ execute "DROP INDEX CONCURRENTLY IF EXISTS #{INDEX}"
46
+ else
47
+ remove_index TABLE, name: INDEX, if_exists: true
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to add the index behind the executions dashboard list
4
+ #
5
+ # The list page runs `WHERE parent_execution_id IS NULL ORDER BY created_at DESC`
6
+ # plus a COUNT over the same predicate. With only the single-column
7
+ # parent_execution_id and created_at indexes available, the planner has to pick
8
+ # one and filter the rest, which degrades to a sequential scan once child
9
+ # executions make up a meaningful share of the table.
10
+ class AddRootExecutionListIndex < ActiveRecord::Migration<%= migration_version %>
11
+ def change
12
+ add_index :ruby_llm_agents_executions, [:parent_execution_id, :created_at],
13
+ name: "idx_executions_parent_created_at",
14
+ if_not_exists: true
15
+ end
16
+ end
@@ -132,6 +132,11 @@ RubyLLM::Agents.configure do |config|
132
132
  # Number of recent executions shown on the dashboard home
133
133
  # config.recent_executions_limit = 10
134
134
 
135
+ # Seconds a single dashboard query may run before the database cancels it
136
+ # (PostgreSQL only). Keeps a wide time range on a large executions table from
137
+ # tying up a web worker and the database; nil disables it.
138
+ # config.dashboard_query_timeout = 5
139
+
135
140
  # ============================================
136
141
  # Reliability Defaults
137
142
  # ============================================
@@ -76,11 +76,37 @@ class CreateRubyLLMAgentsExecutions < ActiveRecord::Migration<%= migration_versi
76
76
  add_index :ruby_llm_agents_executions, :trace_id
77
77
  add_index :ruby_llm_agents_executions, :request_id
78
78
  add_index :ruby_llm_agents_executions, :parent_execution_id
79
+ add_index :ruby_llm_agents_executions, [:parent_execution_id, :created_at]
79
80
  add_index :ruby_llm_agents_executions, :root_execution_id
80
81
  add_index :ruby_llm_agents_executions, [:status, :created_at]
81
82
  add_index :ruby_llm_agents_executions, [:model_id, :status]
82
83
  add_index :ruby_llm_agents_executions, [:cache_hit, :created_at]
83
84
 
85
+ # Covering index behind the dashboard's aggregate queries. They read a
86
+ # time range and only these columns, so with this index they are answered
87
+ # without fetching the (much wider) table rows. PostgreSQL keeps the extra
88
+ # columns out of the key with INCLUDE; SQLite takes them as key columns.
89
+ analytics_columns = %w[
90
+ agent_type model_id chosen_model_id status error_class tenant_id parent_execution_id
91
+ total_cost total_tokens input_tokens output_tokens duration_ms cache_hit streaming
92
+ ]
93
+ case connection.adapter_name.downcase
94
+ when /postg/
95
+ reversible do |dir|
96
+ dir.up do
97
+ execute "CREATE INDEX idx_executions_analytics ON ruby_llm_agents_executions " \
98
+ "(created_at) INCLUDE (#{analytics_columns.join(", ")})"
99
+ end
100
+ # Dropped explicitly: both this and the plain created_at index are
101
+ # keyed on created_at alone, so reverting that one by column would
102
+ # otherwise be ambiguous.
103
+ dir.down { execute "DROP INDEX IF EXISTS idx_executions_analytics" }
104
+ end
105
+ when /sqlite/
106
+ add_index :ruby_llm_agents_executions, ["created_at", *analytics_columns],
107
+ name: "idx_executions_analytics"
108
+ end
109
+
84
110
  # Foreign keys for execution hierarchy
85
111
  add_foreign_key :ruby_llm_agents_executions, :ruby_llm_agents_executions,
86
112
  column: :parent_execution_id, on_delete: :nullify
@@ -117,6 +117,44 @@ module RubyLlmAgents
117
117
  )
118
118
  end
119
119
 
120
+ # Add the composite index behind the executions dashboard list
121
+ def create_add_root_execution_list_index_migration
122
+ unless table_exists?(:ruby_llm_agents_executions)
123
+ say_status :skip, "executions table does not exist yet", :yellow
124
+ return
125
+ end
126
+
127
+ if index_exists?(:ruby_llm_agents_executions, [:parent_execution_id, :created_at])
128
+ say_status :skip, "root execution list index already exists", :yellow
129
+ return
130
+ end
131
+
132
+ say_status :upgrade, "Adding root execution list index", :blue
133
+ migration_template(
134
+ "add_root_execution_list_index_migration.rb.tt",
135
+ File.join(db_migrate_path, "add_root_execution_list_index.rb")
136
+ )
137
+ end
138
+
139
+ # Add the covering index behind the dashboard's aggregate queries
140
+ def create_add_executions_analytics_index_migration
141
+ unless table_exists?(:ruby_llm_agents_executions)
142
+ say_status :skip, "executions table does not exist yet", :yellow
143
+ return
144
+ end
145
+
146
+ if index_name_exists?(:ruby_llm_agents_executions, "idx_executions_analytics")
147
+ say_status :skip, "executions analytics index already exists", :yellow
148
+ return
149
+ end
150
+
151
+ say_status :upgrade, "Adding executions analytics index", :blue
152
+ migration_template(
153
+ "add_executions_analytics_index_migration.rb.tt",
154
+ File.join(db_migrate_path, "add_executions_analytics_index.rb")
155
+ )
156
+ end
157
+
120
158
  # Create overrides table for dashboard-managed agent settings
121
159
  def create_overrides_migration
122
160
  if table_exists?(:ruby_llm_agents_overrides)
@@ -231,5 +269,11 @@ module RubyLlmAgents
231
269
  rescue
232
270
  false
233
271
  end
272
+
273
+ def index_name_exists?(table, name)
274
+ ActiveRecord::Base.connection.index_name_exists?(table, name)
275
+ rescue
276
+ false
277
+ end
234
278
  end
235
279
  end
@@ -109,6 +109,14 @@ module RubyLLM
109
109
  # Number of recent executions shown on the dashboard home.
110
110
  # @return [Integer] Limit for recent executions (default: 10)
111
111
 
112
+ # @!attribute [rw] dashboard_query_timeout
113
+ # Seconds a single dashboard query may run before the database cancels
114
+ # it. A cancelled query renders a "narrow the time range" notice
115
+ # instead of leaving the request (and the database) grinding on a scan
116
+ # of the whole executions table. Enforced on PostgreSQL only; a
117
+ # stricter statement_timeout already set on the connection wins.
118
+ # @return [Numeric, nil] Timeout in seconds, or nil to disable (default: 5)
119
+
112
120
  # @!attribute [rw] job_retry_attempts
113
121
  # Number of retry attempts for the async logging job on failure.
114
122
  # @return [Integer] Retry attempts (default: 3)
@@ -494,6 +502,7 @@ module RubyLLM
494
502
  :anomaly_duration_threshold,
495
503
  :per_page,
496
504
  :recent_executions_limit,
505
+ :dashboard_query_timeout,
497
506
  :job_retry_attempts,
498
507
  :messages_summary_max_length,
499
508
  :dashboard_auth,
@@ -560,6 +569,15 @@ module RubyLLM
560
569
  @recent_executions_limit = value
561
570
  end
562
571
 
572
+ # Sets dashboard_query_timeout with validation
573
+ #
574
+ # @param value [Numeric, nil] Seconds (must be > 0), or nil to disable
575
+ # @raise [ArgumentError] If value is not positive
576
+ def dashboard_query_timeout=(value)
577
+ validate_positive!(:dashboard_query_timeout, value) unless value.nil?
578
+ @dashboard_query_timeout = value
579
+ end
580
+
563
581
  # Sets job_retry_attempts with validation
564
582
  #
565
583
  # @param value [Integer] Retry attempts (must be >= 0)
@@ -702,6 +720,7 @@ module RubyLLM
702
720
  @basic_auth_password = nil
703
721
  @per_page = 25
704
722
  @recent_executions_limit = 10
723
+ @dashboard_query_timeout = 5
705
724
  @job_retry_attempts = 3
706
725
 
707
726
  # Reliability defaults (all disabled by default for backward compatibility)
@@ -1031,6 +1050,7 @@ module RubyLLM
1031
1050
  dashboard: {
1032
1051
  per_page: per_page,
1033
1052
  recent_executions_limit: recent_executions_limit,
1053
+ dashboard_query_timeout: dashboard_query_timeout,
1034
1054
  dashboard_parent_controller: dashboard_parent_controller,
1035
1055
  basic_auth_username: basic_auth_username,
1036
1056
  dashboard_auth: dashboard_auth&.class&.name
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.15.1"
7
+ VERSION = "3.16.0"
8
8
  end
9
9
  end
@@ -172,6 +172,12 @@ module RubyLLM
172
172
  # Falls back to creating a new record if the initial record is nil.
173
173
  # Errors are re-raised to allow the ensure block to handle them.
174
174
  #
175
+ # The record is locked and reloaded before updating: the instance
176
+ # held here was created when the pipeline started and may be stale —
177
+ # a concurrent transition (e.g. operator cancellation or a timeout
178
+ # sweep) can have written a terminal status in the meantime, and the
179
+ # completion must not overwrite it.
180
+ #
175
181
  # @param execution [Execution, nil] The execution record to update
176
182
  # @param context [Context] The execution context
177
183
  # @param status [String] Final status ("success", "error", "timeout")
@@ -188,7 +194,7 @@ module RubyLLM
188
194
  end
189
195
 
190
196
  update_data = build_completion_data(context, status)
191
- execution.update!(update_data)
197
+ return unless complete_running_execution(execution, update_data)
192
198
 
193
199
  # Save detail data (prompts, responses, tool calls, etc.)
194
200
  save_execution_details(execution, context, status)
@@ -197,6 +203,31 @@ module RubyLLM
197
203
  raise # Re-raise for ensure block to handle via mark_execution_failed!
198
204
  end
199
205
 
206
+ # Applies the completion update unless the execution already left
207
+ # "running" state.
208
+ #
209
+ # Locks the row and reloads it, so the status check and the update
210
+ # are atomic with respect to concurrent status transitions: whichever
211
+ # side wins the row lock first keeps the row. A completion that finds
212
+ # a terminal status is dropped instead of overwriting it.
213
+ #
214
+ # @param execution [Execution] The execution record to update
215
+ # @param update_data [Hash] The completion attributes
216
+ # @return [Boolean] Whether the completion update was applied
217
+ def complete_running_execution(execution, update_data)
218
+ return execution.update!(update_data) unless execution.persisted?
219
+
220
+ execution.with_lock do
221
+ if execution.status == "running"
222
+ execution.update!(update_data)
223
+ true
224
+ else
225
+ debug("Skipped completion for execution #{execution.id}: already #{execution.status}")
226
+ false
227
+ end
228
+ end
229
+ end
230
+
200
231
  # Emergency fallback to mark execution as failed
201
232
  #
202
233
  # Uses update_all to bypass ActiveRecord callbacks and validations,
@@ -431,6 +462,13 @@ module RubyLLM
431
462
  data[:attempts_count] = context[:reliability_attempts].size
432
463
  end
433
464
 
465
+ # Carry the tracker request_id/tags that
466
+ # build_running_execution_data injected through the metadata
467
+ # rebuild — otherwise a completed execution loses them. Only
468
+ # merge into a rebuild that actually produced metadata: when
469
+ # none did, the running record's metadata stays untouched.
470
+ inject_tracker_data(context, data) if data[:metadata]
471
+
434
472
  data
435
473
  end
436
474
 
@@ -53,6 +53,7 @@ module RubyLLM
53
53
  layout "ruby_llm/agents/application"
54
54
  helper RubyLLM::Agents::ApplicationHelper
55
55
  before_action :authenticate_dashboard!
56
+ around_action :limit_query_time, if: -> { request.get? }
56
57
 
57
58
  rescue_from ::ActiveRecord::StatementInvalid do |e|
58
59
  if e.message.include?("ruby_llm_agents_")
@@ -65,8 +66,54 @@ module RubyLLM
65
66
  end
66
67
  end
67
68
 
69
+ # Declared after the StatementInvalid handler so it wins: a cancelled
70
+ # query is a StatementInvalid too, and must not be reported as a
71
+ # pending migration.
72
+ rescue_from ::ActiveRecord::QueryCanceled do
73
+ @query_timeout = RubyLLM::Agents.configuration.dashboard_query_timeout
74
+
75
+ if request.format.json?
76
+ render json: {error: "query_timeout"}, status: :service_unavailable
77
+ else
78
+ render "ruby_llm/agents/shared/query_timeout", formats: [:html], status: :service_unavailable
79
+ end
80
+ end
81
+
68
82
  private
69
83
 
84
+ # Runs the page under config.dashboard_query_timeout
85
+ #
86
+ # Every dashboard page aggregates over a user-chosen window of the
87
+ # executions table. Without a ceiling, a wide window on a large
88
+ # table keeps running after the web server has timed the request
89
+ # out — the user sees a 500 and the database keeps scanning. With
90
+ # it, the database cancels the query and the QueryCanceled handler
91
+ # above explains what to narrow.
92
+ #
93
+ # @return [void]
94
+ # @api private
95
+ def limit_query_time(&block)
96
+ RubyLLM::Agents::Execution.with_statement_timeout(
97
+ RubyLLM::Agents.configuration.dashboard_query_timeout, &block
98
+ )
99
+ end
100
+
101
+ # Caches an expensive dashboard aggregate for a short while
102
+ #
103
+ # Dashboard figures are read far more often than they meaningfully
104
+ # change (reloads, pagination, several people watching the same
105
+ # page), and each one is a scan of a time window. The current
106
+ # tenant is always part of the key.
107
+ #
108
+ # @param key [Array] Identifies the figure and its filters
109
+ # @param expires_in [ActiveSupport::Duration] How stale it may get
110
+ # @return [Object] The block's value (nil is cached too)
111
+ # @api private
112
+ def cached_stats(*key, expires_in: 1.minute, &block)
113
+ Rails.cache.fetch(["ruby_llm_agents", "stats", current_tenant_id, *key],
114
+ expires_in: expires_in, race_condition_ttl: 10.seconds, &block)
115
+ end
116
+
70
117
  # Authenticates dashboard access using configured method
71
118
  #
72
119
  # Authentication priority:
@@ -160,18 +207,12 @@ module RubyLLM
160
207
  # configured name; legacy or string-only tenants fall back to the raw
161
208
  # tenant_id so nothing disappears from the filter.
162
209
  #
163
- # Two queries total — one DISTINCT pluck on executions, one pluck on
164
- # tenants — regardless of how many tenant_ids exist.
165
- #
166
210
  # @return [Array<Hash>] Entries shaped as { value:, label: }
167
211
  # @api public
168
212
  def available_tenants
169
213
  return @available_tenants if defined?(@available_tenants)
170
214
 
171
- tenant_ids = RubyLLM::Agents::Execution
172
- .where.not(tenant_id: nil)
173
- .distinct
174
- .pluck(:tenant_id)
215
+ tenant_ids = cached_filter_options(:tenant_id)
175
216
 
176
217
  names_by_id = RubyLLM::Agents::Tenant
177
218
  .where(tenant_id: tenant_ids)
@@ -183,6 +224,45 @@ module RubyLLM
183
224
  .sort_by { |t| t[:label].downcase }
184
225
  end
185
226
  helper_method :available_tenants
227
+
228
+ # Distinct agent types in the execution history, sorted
229
+ #
230
+ # @return [Array<String>]
231
+ # @api public
232
+ def available_agent_types
233
+ cached_filter_options(:agent_type)
234
+ end
235
+
236
+ # Distinct model IDs in the execution history, sorted
237
+ #
238
+ # @return [Array<String>]
239
+ # @api public
240
+ def available_model_ids
241
+ cached_filter_options(:model_id)
242
+ end
243
+
244
+ # Distinct values of an executions column, for a filter dropdown
245
+ #
246
+ # Read with Execution.distinct_values, which walks the column's
247
+ # index rather than scanning the table, and cached on top of that:
248
+ # the lists only change when a brand-new agent, model or tenant
249
+ # appears, and several pages need two or three of them per request.
250
+ # A new value shows up in the dropdown within five minutes;
251
+ # filtering by it via URL params works immediately.
252
+ #
253
+ # The lists are deliberately not narrowed to the current tenant.
254
+ # Doing so means reading every one of that tenant's executions, and
255
+ # agent and model names are application configuration rather than
256
+ # tenant data.
257
+ #
258
+ # @param column [Symbol] :agent_type, :model_id or :tenant_id
259
+ # @return [Array<String>]
260
+ # @api private
261
+ def cached_filter_options(column)
262
+ Rails.cache.fetch(["ruby_llm_agents", "filter_options", column], expires_in: 5.minutes) do
263
+ RubyLLM::Agents::Execution.distinct_values(column)
264
+ end
265
+ end
186
266
  end)
187
267
  end
188
268
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.15.1
4
+ version: 3.16.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90
@@ -90,6 +90,7 @@ files:
90
90
  - app/models/ruby_llm/agents/agent_override.rb
91
91
  - app/models/ruby_llm/agents/execution.rb
92
92
  - app/models/ruby_llm/agents/execution/analytics.rb
93
+ - app/models/ruby_llm/agents/execution/breakdown.rb
93
94
  - app/models/ruby_llm/agents/execution/metrics.rb
94
95
  - app/models/ruby_llm/agents/execution/replayable.rb
95
96
  - app/models/ruby_llm/agents/execution/scopes.rb
@@ -131,11 +132,13 @@ files:
131
132
  - app/views/ruby_llm/agents/shared/_doc_link.html.erb
132
133
  - app/views/ruby_llm/agents/shared/_executions_table.html.erb
133
134
  - app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb
135
+ - app/views/ruby_llm/agents/shared/_pagination.html.erb
134
136
  - app/views/ruby_llm/agents/shared/_select_dropdown.html.erb
135
137
  - app/views/ruby_llm/agents/shared/_sortable_header.html.erb
136
138
  - app/views/ruby_llm/agents/shared/_status_badge.html.erb
137
139
  - app/views/ruby_llm/agents/shared/_status_dot.html.erb
138
140
  - app/views/ruby_llm/agents/shared/_tenant_filter.html.erb
141
+ - app/views/ruby_llm/agents/shared/query_timeout.html.erb
139
142
  - app/views/ruby_llm/agents/system_config/show.html.erb
140
143
  - app/views/ruby_llm/agents/tenants/_form.html.erb
141
144
  - app/views/ruby_llm/agents/tenants/edit.html.erb
@@ -165,8 +168,10 @@ files:
165
168
  - lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt
166
169
  - lib/generators/ruby_llm_agents/templates/add_dashboard_performance_indexes_migration.rb.tt
167
170
  - lib/generators/ruby_llm_agents/templates/add_execution_type_migration.rb.tt
171
+ - lib/generators/ruby_llm_agents/templates/add_executions_analytics_index_migration.rb.tt
168
172
  - lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt
169
173
  - lib/generators/ruby_llm_agents/templates/add_prompts_migration.rb.tt
174
+ - lib/generators/ruby_llm_agents/templates/add_root_execution_list_index_migration.rb.tt
170
175
  - lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt
171
176
  - lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt
172
177
  - lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt
@@ -359,7 +364,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
359
364
  - !ruby/object:Gem::Version
360
365
  version: '0'
361
366
  requirements: []
362
- rubygems_version: 4.0.7
367
+ rubygems_version: 3.6.9
363
368
  specification_version: 4
364
369
  summary: Agent framework for building LLM-powered agents with RubyLLM
365
370
  test_files: []