Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/controllers/concerns/ruby_llm/agents/paginatable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,15 @@ module Paginatable
# @param scope [ActiveRecord::Relation] The scope to paginate
# @param ordered [Boolean] Whether to apply default descending order (default: true)
# @param sort_params [Hash, nil] Optional custom sort parameters with :column and :direction
# @param total_count [Integer, nil] Precomputed row count; skips the COUNT query
# @return [Hash] Contains :records and :pagination keys
# @option return [ActiveRecord::Relation] :records Paginated records
# @option return [Hash] :pagination Pagination metadata
# - :current_page [Integer] Current page number
# - :per_page [Integer] Records per page
# - :total_count [Integer] Total record count
# - :total_pages [Integer] Total page count
def paginate(scope, ordered: true, sort_params: nil)
def paginate(scope, ordered: true, sort_params: nil, total_count: nil)
page = [(params[:page] || 1).to_i, 1].max
per_page = RubyLLM::Agents.configuration.per_page
offset = (page - 1) * per_page
Expand All @@ -41,10 +42,12 @@ def paginate(scope, ordered: true, sort_params: nil)
elsif ordered
scope = scope.order("#{table_name}.created_at DESC")
end
total_count = scope.count
total_count ||= scope.count

{
records: scope.offset(offset).limit(per_page),
# Loaded eagerly: the views call `records.empty?` before iterating,
# which on an unloaded relation costs a second SELECT.
records: scope.offset(offset).limit(per_page).load,
pagination: {
current_page: page,
per_page: per_page,
Expand Down
17 changes: 9 additions & 8 deletions app/controllers/ruby_llm/agents/agents_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,11 +174,13 @@ def load_agent_stats
#
# @return [void]
def load_filter_options
# Single query to get all filter options (fixes N+1)
# Single DISTINCT query for all filter options. Without DISTINCT this
# plucked one row per execution the agent has ever run.
base = tenant_scoped_executions.by_agent(@agent_type)
filter_data = base
.where.not(model_id: nil)
.or(base.where.not(temperature: nil))
.distinct
.pluck(:model_id, :temperature)

@models = filter_data.map(&:first).compact.uniq.sort
Expand All @@ -192,15 +194,14 @@ def load_filter_options
# @return [void]
def load_filtered_executions
base_scope = build_filtered_scope
result = paginate(base_scope)
@filter_stats = base_scope.totals

# error_detail: the table renders error_message per row, and the full
# detail row carries every prompt and response payload.
result = paginate(base_scope.preload(:error_detail),
total_count: @filter_stats[:total_count])
@executions = result[:records]
@pagination = result[:pagination]

@filter_stats = {
total_count: result[:pagination][:total_count],
total_cost: base_scope.sum(:total_cost),
total_tokens: base_scope.sum(:total_tokens)
}
end

# Builds a filtered scope for the current agent's executions
Expand Down
5 changes: 2 additions & 3 deletions app/controllers/ruby_llm/agents/analytics_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,8 @@ def prior_period_scope(scope)
# ── Filters ──────────────────────────

def load_filter_options
base = tenant_scoped_executions
@available_agents = base.where.not(agent_type: nil).distinct.pluck(:agent_type).sort
@available_models = base.where.not(model_id: nil).distinct.pluck(:model_id).sort
@available_agents = available_agent_types
@available_models = available_model_ids
@available_tenants = if tenant_filter_enabled? && Tenant.table_exists?
Tenant.pluck(:tenant_id, :name).map { |tid, name| [tid, name.presence || tid] }.sort_by(&:last)
else
Expand Down
7 changes: 2 additions & 5 deletions app/controllers/ruby_llm/agents/dashboard_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ def index
base_scope = tenant_scoped_executions
@now_strip = build_now_strip(base_scope)
@critical_alerts = load_critical_alerts(base_scope)
@recent_executions = base_scope.includes(:detail).recent(10)
@recent_executions = base_scope.preload(:error_detail).recent(10)
@agent_stats = build_agent_comparison(base_scope)
@top_errors = build_top_errors(base_scope)
@tenant_budget = load_tenant_budget(base_scope)
Expand Down Expand Up @@ -217,10 +217,7 @@ def load_budget_status
def load_open_breakers
open_breakers = []

# Get all agents from execution history
agent_types = tenant_scoped_executions.distinct.pluck(:agent_type)

agent_types.each do |agent_type|
available_agent_types.each do |agent_type|
# Get the agent class if available
agent_class = AgentRegistry.find(agent_type)
next unless agent_class
Expand Down
53 changes: 12 additions & 41 deletions app/controllers/ruby_llm/agents/executions_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def index
#
# @return [void]
def show
@execution = tenant_scoped_executions.includes(:detail, :child_executions).find(params[:id])
@execution = tenant_scoped_executions.includes(:detail).find(params[:id])
end

# Handles filter search requests via Turbo Stream
Expand Down Expand Up @@ -83,7 +83,7 @@ def export
self.response_body = Enumerator.new do |yielder|
yielder << CSV.generate_line(CSV_COLUMNS)

filtered_executions.find_each(batch_size: 1000) do |execution|
filtered_executions.preload(:error_detail).find_each(batch_size: 1000) do |execution|
yielder << generate_csv_row(execution)
end
end
Expand Down Expand Up @@ -124,26 +124,6 @@ def load_filter_options
@statuses = Execution.statuses.keys
end

# Returns distinct agent types from execution history
#
# Memoized to avoid duplicate queries within a request.
# Uses tenant_scoped_executions to respect multi-tenancy filtering.
#
# @return [Array<String>] Agent type names
def available_agent_types
@available_agent_types ||= tenant_scoped_executions.distinct.pluck(:agent_type)
end

# Returns distinct model IDs from execution history
#
# Memoized to avoid duplicate queries within a request.
# Uses tenant_scoped_executions to respect multi-tenancy filtering.
#
# @return [Array<String>] Model IDs
def available_model_ids
@available_model_ids ||= tenant_scoped_executions.where.not(model_id: nil).distinct.pluck(:model_id).sort
end

# Loads paginated executions and associated statistics
#
# Sets @executions, @pagination, @sort_params, and @filter_stats instance variables
Expand All @@ -152,22 +132,17 @@ def available_model_ids
# @return [void]
def load_executions_with_stats
@sort_params = parse_sort_params
result = paginate(filtered_executions, sort_params: @sort_params)
scope = filtered_executions

# One aggregate query for the stats strip; its count is reused for
# pagination, so the page costs one aggregate query instead of four.
@filter_stats = scope.totals

result = paginate(scope.preload(:error_detail),
sort_params: @sort_params,
total_count: @filter_stats[:total_count])
@executions = result[:records]
@pagination = result[:pagination]
load_filter_stats
end

# Calculates aggregate statistics for the current filter
#
# @return [void]
def load_filter_stats
scope = filtered_executions
@filter_stats = {
total_count: scope.count,
total_cost: scope.sum(:total_cost) || 0,
total_tokens: scope.sum(:total_tokens) || 0
}
end

# Builds a filtered execution scope based on request params
Expand Down Expand Up @@ -212,11 +187,7 @@ def filtered_executions
scope = scope.where("attempts_count > 1") if params[:has_retries].present?

# Only show root executions - children are nested under parents
scope = scope.where(parent_execution_id: nil)

# Eager load children for grouping and detail for error_message, which
# the list renders per row (otherwise an N+1 on error rows).
scope.includes(:child_executions, :detail)
scope.where(parent_execution_id: nil)
end

# Checks whether turbo-rails is available in the host application
Expand Down
37 changes: 21 additions & 16 deletions app/controllers/ruby_llm/agents/requests_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ def index
"MIN(started_at) AS started_at",
"MAX(completed_at) AS completed_at",
"SUM(duration_ms) AS total_duration_ms",
"GROUP_CONCAT(DISTINCT agent_type) AS agent_types_list",
"GROUP_CONCAT(DISTINCT status) AS statuses_list",
"#{distinct_list_sql("agent_type")} AS agent_types_list",
"#{distinct_list_sql("status")} AS statuses_list",
"MAX(created_at) AS latest_created_at"
)
.group(:request_id)
Expand All @@ -38,16 +38,16 @@ def index
days = params[:days].to_i
scope = scope.where("created_at >= ?", days.days.ago) if days > 0

result = paginate_requests(scope)
# One query for the distinct request count and total cost; the count
# is shared with pagination instead of being run a second time.
total_requests, total_cost = Execution
.where.not(request_id: [nil, ""])
.pick(Arel.sql("COUNT(DISTINCT request_id)"), Arel.sql("COALESCE(SUM(total_cost), 0)"))
@stats = {total_requests: total_requests.to_i, total_cost: (total_cost || 0).to_d.round(6)}

result = paginate_requests(scope, total_count: @stats[:total_requests])
@requests = result[:records]
@pagination = result[:pagination]

# Stats
total_scope = Execution.where.not(request_id: [nil, ""])
@stats = {
total_requests: total_scope.distinct.count(:request_id),
total_cost: total_scope.sum(:total_cost) || 0
}
end

# Shows a single tracked request with all its executions
Expand Down Expand Up @@ -90,15 +90,20 @@ def sanitize_sort_column(column)
ALLOWED_SORT_COLUMNS.include?(column) ? column : "latest_created_at"
end

def paginate_requests(scope)
# Comma-separated DISTINCT values of a column per group.
# GROUP_CONCAT exists on SQLite and MySQL; PostgreSQL spells it STRING_AGG.
def distinct_list_sql(column)
if Execution.connection.adapter_name.downcase.include?("postg")
"STRING_AGG(DISTINCT #{column}, ',')"
else
"GROUP_CONCAT(DISTINCT #{column})"
end
end

def paginate_requests(scope, total_count:)
page = [(params[:page] || 1).to_i, 1].max
per_page = RubyLLM::Agents.configuration.per_page

total_count = Execution
.where.not(request_id: [nil, ""])
.distinct
.count(:request_id)

sorted = scope.order("#{@sort_column} #{@sort_direction.upcase}")
offset = (page - 1) * per_page

Expand Down
15 changes: 14 additions & 1 deletion app/models/ruby_llm/agents/execution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,18 @@ class Execution < ::ActiveRecord::Base
has_one :detail, class_name: "RubyLLM::Agents::ExecutionDetail",
foreign_key: :execution_id, dependent: :destroy

# Same row as :detail, but selecting only error_message.
#
# List and CSV views read nothing but the error message, while the full
# detail row carries prompts and response JSON that routinely run to
# hundreds of kilobytes each. Preloading :detail for a page of rows drags
# all of that into memory; preloading :error_detail costs a few bytes per
# row. Never use this association for anything but error_message — the
# other attributes are not selected and will raise.
has_one :error_detail, -> { select(:id, :execution_id, :error_message) },
class_name: "RubyLLM::Agents::ExecutionDetail",
foreign_key: :execution_id, inverse_of: false

# Individual tool call records (real-time tracking)
has_many :tool_executions, class_name: "RubyLLM::Agents::ToolExecution",
foreign_key: :execution_id, dependent: :destroy
Expand All @@ -94,7 +106,8 @@ class Execution < ::ActiveRecord::Base
#
# @return [String, nil]
def error_message
detail&.error_message || metadata&.dig("error_message")
row = association(:detail).loaded? ? detail : error_detail
row&.error_message || metadata&.dig("error_message")
end

# Validations
Expand Down
Loading
Loading