diff --git a/app/controllers/ruby_llm/agents/requests_controller.rb b/app/controllers/ruby_llm/agents/requests_controller.rb
new file mode 100644
index 00000000..987be19d
--- /dev/null
+++ b/app/controllers/ruby_llm/agents/requests_controller.rb
@@ -0,0 +1,117 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Agents
+ # Controller for browsing tracked request groups
+ #
+ # Provides listing and detail views for executions grouped by
+ # request_id, as set by RubyLLM::Agents.track blocks.
+ #
+ # @api private
+ class RequestsController < ApplicationController
+ include Paginatable
+
+ # Lists all tracked requests with aggregated stats
+ #
+ # @return [void]
+ def index
+ @sort_column = sanitize_sort_column(params[:sort])
+ @sort_direction = (params[:direction] == "asc") ? "asc" : "desc"
+
+ scope = Execution
+ .where.not(request_id: [nil, ""])
+ .select(
+ "request_id",
+ "COUNT(*) AS call_count",
+ "SUM(total_cost) AS total_cost",
+ "SUM(total_tokens) AS total_tokens",
+ "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",
+ "MAX(created_at) AS latest_created_at"
+ )
+ .group(:request_id)
+
+ # Apply time filter
+ days = params[:days].to_i
+ scope = scope.where("created_at >= ?", days.days.ago) if days > 0
+
+ result = paginate_requests(scope)
+ @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
+ #
+ # @return [void]
+ def show
+ @request_id = params[:id]
+ @executions = Execution
+ .where(request_id: @request_id)
+ .order(started_at: :asc)
+
+ if @executions.empty?
+ redirect_to ruby_llm_agents.requests_path,
+ alert: "Request not found: #{@request_id}"
+ return
+ end
+
+ @summary = {
+ call_count: @executions.count,
+ total_cost: @executions.sum(:total_cost) || 0,
+ total_tokens: @executions.sum(:total_tokens) || 0,
+ started_at: @executions.minimum(:started_at),
+ completed_at: @executions.maximum(:completed_at),
+ agent_types: @executions.distinct.pluck(:agent_type),
+ models_used: @executions.distinct.pluck(:model_id),
+ all_successful: @executions.where.not(status: "success").count.zero?,
+ error_count: @executions.where(status: "error").count
+ }
+
+ if @summary[:started_at] && @summary[:completed_at]
+ @summary[:duration_ms] = ((@summary[:completed_at] - @summary[:started_at]) * 1000).to_i
+ end
+ end
+
+ private
+
+ ALLOWED_SORT_COLUMNS = %w[latest_created_at call_count total_cost total_tokens total_duration_ms].freeze
+
+ def sanitize_sort_column(column)
+ ALLOWED_SORT_COLUMNS.include?(column) ? column : "latest_created_at"
+ end
+
+ def paginate_requests(scope)
+ 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
+
+ {
+ records: sorted.offset(offset).limit(per_page),
+ pagination: {
+ current_page: page,
+ per_page: per_page,
+ total_count: total_count,
+ total_pages: (total_count.to_f / per_page).ceil
+ }
+ }
+ end
+ end
+ end
+end
diff --git a/app/views/layouts/ruby_llm/agents/application.html.erb b/app/views/layouts/ruby_llm/agents/application.html.erb
index fc34a5e9..0cad5873 100644
--- a/app/views/layouts/ruby_llm/agents/application.html.erb
+++ b/app/views/layouts/ruby_llm/agents/application.html.erb
@@ -295,7 +295,8 @@
<% nav_items = [
[ruby_llm_agents.root_path, "dashboard"],
[ruby_llm_agents.agents_path, "agents"],
- [ruby_llm_agents.executions_path, "executions"]
+ [ruby_llm_agents.executions_path, "executions"],
+ [ruby_llm_agents.requests_path, "requests"]
]
nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
nav_items.each do |path, label| %>
@@ -345,7 +346,8 @@
<% mobile_nav_items = [
[ruby_llm_agents.root_path, "dashboard"],
[ruby_llm_agents.agents_path, "agents"],
- [ruby_llm_agents.executions_path, "executions"]
+ [ruby_llm_agents.executions_path, "executions"],
+ [ruby_llm_agents.requests_path, "requests"]
]
mobile_nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
mobile_nav_items.each do |path, label| %>
diff --git a/app/views/ruby_llm/agents/requests/index.html.erb b/app/views/ruby_llm/agents/requests/index.html.erb
new file mode 100644
index 00000000..8d2f37bc
--- /dev/null
+++ b/app/views/ruby_llm/agents/requests/index.html.erb
@@ -0,0 +1,153 @@
+
+
requests
+
+
+ <%= number_with_delimiter(@stats[:total_requests]) %> tracked
+ ·
+ $<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %> total
+
+
+
+<%
+ sort_column = @sort_column
+ sort_direction = @sort_direction
+
+ sort_link = ->(column, label, extra_class: "") {
+ is_active = column == sort_column
+ next_dir = is_active && sort_direction == "asc" ? "desc" : "asc"
+ url = url_for(request.query_parameters.merge(sort: column, direction: next_dir, page: 1))
+
+ arrow = if is_active && sort_direction == "asc"
+ raw(' ')
+ elsif is_active
+ raw(' ')
+ else
+ ""
+ end
+
+ active_class = is_active ? "text-gray-700 dark:text-gray-300" : ""
+ raw(%())
+ }
+%>
+
+<% if @requests.empty? %>
+
+ No tracked requests found. Use RubyLLM::Agents.track { ... } to start tracking.
+
+<% else %>
+
+
+ request_id
+ agents
+ <%= sort_link.call("call_count", "calls", extra_class: "justify-end w-full") %>
+ status
+ <%= sort_link.call("total_duration_ms", "duration", extra_class: "justify-end w-full") %>
+ <%= sort_link.call("total_tokens", "tokens", extra_class: "justify-end w-full") %>
+ <%= sort_link.call("total_cost", "cost", extra_class: "justify-end w-full") %>
+ <%= sort_link.call("latest_created_at", "time", extra_class: "justify-end w-full") %>
+
+
+
+
+ <% @requests.each do |req| %>
+ <%
+ statuses = (req.statuses_list || "").split(",")
+ has_errors = statuses.include?("error") || statuses.include?("timeout")
+ all_success = statuses == ["success"]
+ status_class = if has_errors
+ "badge-error"
+ elsif all_success
+ "badge-success"
+ else
+ "badge-running"
+ end
+ status_label = if has_errors
+ "errors"
+ elsif all_success
+ "ok"
+ else
+ "mixed"
+ end
+ agent_names = (req.agent_types_list || "").split(",").map { |a| a.gsub(/Agent$/, "") }
+ %>
+
+
+ <%= truncate(req.request_id, length: 20) %>
+
+
+ <%= agent_names.first(3).join(", ") %><%= agent_names.size > 3 ? " +#{agent_names.size - 3}" : "" %>
+
+ <%= req.call_count %>
+
+ <%= status_label %>
+
+
+ <%= req.total_duration_ms ? format_duration_ms(req.total_duration_ms.to_i) : "—" %>
+
+ <%= number_with_delimiter(req.total_tokens || 0) %>
+ $<%= number_with_precision(req.total_cost || 0, precision: 4) %>
+
+ <% if req.respond_to?(:latest_created_at) && req.latest_created_at %>
+ <%= time_ago_in_words(req.latest_created_at) %>
+ <% else %>
+ —
+ <% end %>
+
+
+ <% end %>
+
+
+ <%# Pagination %>
+ <% if @pagination && @pagination[:total_pages] > 1 %>
+ <%
+ current_page = @pagination[:current_page]
+ total_pages = @pagination[:total_pages]
+ total_count = @pagination[:total_count]
+ per_page = @pagination[:per_page]
+ from_record = ((current_page - 1) * per_page) + 1
+ to_record = [current_page * per_page, total_count].min
+ %>
+
+ <%= from_record %>-<%= to_record %> of <%= number_with_delimiter(total_count) %>
+
+ <% if current_page > 1 %>
+ <%= link_to "prev", url_for(request.query_parameters.merge(page: current_page - 1)),
+ class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
+ <% else %>
+ prev
+ <% end %>
+
+ <%
+ window = 2
+ pages_to_show = []
+ (1..total_pages).each do |page|
+ if page <= 1 || page >= total_pages || (page >= current_page - window && page <= current_page + window)
+ pages_to_show << page
+ elsif pages_to_show.last != :gap
+ pages_to_show << :gap
+ end
+ end
+ %>
+
+ <% pages_to_show.each do |page| %>
+ <% if page == :gap %>
+ ...
+ <% elsif page == current_page %>
+ <%= page %>
+ <% else %>
+ <%= link_to page, url_for(request.query_parameters.merge(page: page)),
+ class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
+ <% end %>
+ <% end %>
+
+ <% if current_page < total_pages %>
+ <%= link_to "next", url_for(request.query_parameters.merge(page: current_page + 1)),
+ class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
+ <% else %>
+ next
+ <% end %>
+
+
+ <% end %>
+<% end %>
diff --git a/app/views/ruby_llm/agents/requests/show.html.erb b/app/views/ruby_llm/agents/requests/show.html.erb
new file mode 100644
index 00000000..c8c3b864
--- /dev/null
+++ b/app/views/ruby_llm/agents/requests/show.html.erb
@@ -0,0 +1,136 @@
+
+ <%= link_to ruby_llm_agents.requests_path, class: "text-gray-400 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 transition-colors" do %>
+
+
+
+ <% end %>
+
request
+
<%= @request_id %>
+
+ <% if @summary[:all_successful] %>
+
all ok
+ <% elsif @summary[:error_count] > 0 %>
+
<%= @summary[:error_count] %> error<%= @summary[:error_count] > 1 ? "s" : "" %>
+ <% end %>
+
+
+
+
+
+
calls
+
<%= @summary[:call_count] %>
+
+
+
total cost
+
$<%= number_with_precision(@summary[:total_cost] || 0, precision: 4) %>
+
+
+
tokens
+
<%= number_with_delimiter(@summary[:total_tokens] || 0) %>
+
+
+
duration
+
+ <%= @summary[:duration_ms] ? format_duration_ms(@summary[:duration_ms]) : "—" %>
+
+
+
+
+
+
+ <% @summary[:agent_types].each do |agent_type| %>
+ <%= link_to agent_type.gsub(/Agent$/, ""),
+ ruby_llm_agents.agent_path(agent_type),
+ class: "font-mono text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 transition-colors" %>
+ <% end %>
+
+ <% @summary[:models_used].each do |model| %>
+ <%= model %>
+ <% end %>
+
+
+
+
+ execution timeline
+
+
+
+
+
+ #
+ agent
+ status
+ model
+ duration
+ tokens
+ cost
+
+
+
+ <% @executions.each_with_index do |execution, idx| %>
+ <%
+ status_badge_class = case execution.status.to_s
+ when "running" then "badge-running"
+ when "success" then "badge-success"
+ when "error" then "badge-error"
+ when "timeout" then "badge-timeout"
+ else "badge-timeout"
+ end
+ %>
+
+ <%= idx + 1 %>
+
+ <%= link_to execution.agent_type.gsub(/Agent$/, ""),
+ ruby_llm_agents.agent_path(execution.agent_type),
+ class: "text-gray-900 dark:text-gray-200 hover:underline",
+ onclick: "event.stopPropagation()" %>
+
+
+ <%= execution.status %>
+
+ <%= execution.model_id %>
+
+ <% if execution.status_running? %>
+ ...
+ <% else %>
+ <%= execution.duration_ms ? format_duration_ms(execution.duration_ms) : "—" %>
+ <% end %>
+
+ <%= number_with_delimiter(execution.total_tokens || 0) %>
+ $<%= number_with_precision(execution.total_cost || 0, precision: 4) %>
+
+ <% if execution.status_error? && execution.error_class.present? %>
+
+ └
+ <%= execution.error_class.split("::").last %><%= execution.error_message.present? ? ": #{truncate(execution.error_message, length: 100)}" : "" %>
+
+ <% end %>
+ <% end %>
+
+
+
+
+<% if @summary[:started_at] %>
+
+
timing
+
+
+ started
+ <%= @summary[:started_at].strftime("%Y-%m-%d %H:%M:%S.%L") %>
+
+ <% if @summary[:completed_at] %>
+
+ completed
+ <%= @summary[:completed_at].strftime("%Y-%m-%d %H:%M:%S.%L") %>
+
+ <% end %>
+ <% if @summary[:duration_ms] %>
+
+ wall clock
+ <%= format_duration_ms(@summary[:duration_ms]) %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/config/routes.rb b/config/routes.rb
index 3c15f7d9..08fd0138 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -13,6 +13,8 @@
end
end
+ resources :requests, only: [:index, :show]
+
resources :tenants, only: [:index, :show, :edit, :update]
# Redirect old analytics route to dashboard
diff --git a/example/README.md b/example/README.md
index 88ee31b6..ac1806d6 100644
--- a/example/README.md
+++ b/example/README.md
@@ -130,6 +130,25 @@ result.classification # Classification details
result.content # Response from routed agent
```
+### Multi-Agent Tracking
+
+```ruby
+# Track multiple agent calls, get aggregated cost/tokens/timing
+report = RubyLLM::Agents.track(request_id: "support_flow") do
+ summary = SummarizeAgent.call(text: "Long customer complaint...")
+ category = ClassifyAgent.call(text: summary.content)
+ { summary: summary.content, category: category.content }
+end
+
+report.value[:summary] # => "Customer reports billing issue"
+report.total_cost # => 0.0023 (combined cost)
+report.call_count # => 2
+report.duration_ms # => 1250
+report.request_id # => "support_flow"
+
+# View in dashboard: http://localhost:3000/agents/requests
+```
+
### Image Analyzers (Vision AI)
```ruby
diff --git a/example/app/agents/tracking_example.rb b/example/app/agents/tracking_example.rb
new file mode 100644
index 00000000..5cea11f3
--- /dev/null
+++ b/example/app/agents/tracking_example.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+# Tracking Example - Demonstrates RubyLLM::Agents.track
+#
+# The `track` block collects results from multiple agent calls,
+# aggregates cost/tokens/timing, and links executions in the
+# dashboard via a shared request_id.
+#
+# @example Basic tracking
+# report = RubyLLM::Agents.track do
+# SummarizeAgent.call(text: "Long article...")
+# ClassifyAgent.call(text: "Customer complaint")
+# end
+#
+# report.call_count # => 2
+# report.total_cost # => 0.0023
+# report.total_tokens # => 450
+# report.value # => return value of the block
+#
+# @example With shared tenant (injected into every call)
+# report = RubyLLM::Agents.track(tenant: current_user) do
+# AgentA.call(query: "hello") # gets tenant: current_user
+# AgentB.call(query: "world") # gets tenant: current_user
+# end
+#
+# @example With request_id and tags
+# report = RubyLLM::Agents.track(
+# request_id: "voice_chat_#{SecureRandom.hex(4)}",
+# tags: { feature: "voice-chat", session_id: session.id }
+# ) do
+# transcript = TranscribeAgent.call(with: audio_path)
+# reply = ChatAgent.call(message: transcript.content)
+# audio = SpeakAgent.call(text: reply.content)
+# { transcript: transcript.content, reply: reply.content }
+# end
+#
+# report.value[:reply] # => "Here's my response..."
+# report.total_cost # => cost of all 3 calls
+# report.request_id # => "voice_chat_a1b2c3d4"
+#
+# @example Error handling (track never raises)
+# report = RubyLLM::Agents.track do
+# AgentA.call(query: "ok")
+# raise "something broke"
+# end
+#
+# report.failed? # => true
+# report.error # => #
+# report.call_count # => 1 (call before the raise)
+#
+# @example Nesting (inner results bubble to outer)
+# outer = RubyLLM::Agents.track do
+# AgentA.call(query: "outer")
+# inner = RubyLLM::Agents.track do
+# AgentB.call(query: "inner")
+# end
+# inner.total_cost # cost of inner calls only
+# end
+# outer.call_count # => 2 (both outer + inner)
+#
+# @example TrackReport API
+# report.successful? # block completed without raising
+# report.failed? # block raised
+# report.value # block return value (nil if raised)
+# report.error # exception (nil if successful)
+# report.results # [Result, ...] in call order
+# report.call_count # number of agent calls
+# report.request_id # shared request_id
+# report.total_cost # sum of all costs
+# report.total_tokens # sum of all tokens
+# report.duration_ms # wall clock time of block
+# report.models_used # ["gpt-4o", "whisper-1"]
+# report.cost_breakdown # per-call breakdown
+# report.all_successful? # all results ok?
+# report.any_errors? # any result errors?
+# report.to_h # everything as a hash
+#
+# Dashboard: tracked requests appear on the "requests" page,
+# grouped by request_id with aggregate cost/token/timing stats.
+
+# Helper agents used in tracking examples
+class SummarizeAgent < ApplicationAgent
+ description "Summarizes text content"
+ model "gpt-4o-mini"
+
+ param :text, required: true
+
+ user "Summarize this in one sentence: {text}"
+
+ def metadata
+ {showcase: "tracking", role: "summarizer"}
+ end
+end
+
+class ClassifyAgent < ApplicationAgent
+ description "Classifies text into categories"
+ model "gpt-4o-mini"
+
+ param :text, required: true
+
+ user "Classify this text into one of: positive, negative, neutral. Text: {text}"
+
+ def metadata
+ {showcase: "tracking", role: "classifier"}
+ end
+end
diff --git a/example/db/seeds.rb b/example/db/seeds.rb
index f3ea475a..09bc1864 100644
--- a/example/db/seeds.rb
+++ b/example/db/seeds.rb
@@ -2229,7 +2229,290 @@ def create_moderator_execution(attrs = {})
puts " #{generator}: (class definition error - skipping)"
end
+# =============================================================================
+# TRACKED REQUESTS (multi-agent workflows grouped by request_id)
+# =============================================================================
+puts "\n#{"=" * 60}"
+puts "Creating tracked requests..."
+puts "=" * 60
+
+# Request 1: Customer support workflow — classify then respond
+req1_id = "track_customer_support_001"
+req1_base = Time.current - 15.minutes
+create_execution(
+ request_id: req1_id,
+ tenant_id: acme.llm_tenant_id,
+ agent_type: "ClassifyAgent",
+ model_id: "gpt-4o-mini",
+ status: "success",
+ started_at: req1_base,
+ input_tokens: 320,
+ output_tokens: 45,
+ parameters: {message: "My order hasn't arrived and it's been 2 weeks"},
+ response: {content: "shipping_issue"},
+ metadata: {tags: {"workflow" => "customer_support", "priority" => "high"}},
+ created_at: req1_base
+)
+create_execution(
+ request_id: req1_id,
+ tenant_id: acme.llm_tenant_id,
+ agent_type: "SummarizeAgent",
+ model_id: "gpt-4o",
+ status: "success",
+ started_at: req1_base + 2,
+ input_tokens: 850,
+ output_tokens: 320,
+ parameters: {text: "Customer reports delayed order..."},
+ response: {content: "I apologize for the delay. Let me look into your order status..."},
+ metadata: {tags: {"workflow" => "customer_support", "priority" => "high"}},
+ created_at: req1_base + 2
+)
+puts " Created request #{req1_id} (2 agents: classify + summarize)"
+
+# Request 2: Content pipeline — summarize, classify, then generate
+req2_id = "track_content_pipeline_002"
+req2_base = Time.current - 45.minutes
+create_execution(
+ request_id: req2_id,
+ tenant_id: enterprise.llm_tenant_id,
+ agent_type: "SummarizeAgent",
+ model_id: "gpt-4o",
+ status: "success",
+ started_at: req2_base,
+ input_tokens: 2400,
+ output_tokens: 450,
+ parameters: {text: "Long article about AI safety..."},
+ response: {content: "AI safety research focuses on alignment, interpretability..."},
+ metadata: {tags: {"pipeline" => "content", "source" => "blog"}},
+ created_at: req2_base
+)
+create_execution(
+ request_id: req2_id,
+ tenant_id: enterprise.llm_tenant_id,
+ agent_type: "ClassifyAgent",
+ model_id: "gpt-4o-mini",
+ status: "success",
+ started_at: req2_base + 3,
+ input_tokens: 480,
+ output_tokens: 35,
+ parameters: {message: "AI safety research focuses on alignment..."},
+ response: {content: "technology"},
+ metadata: {tags: {"pipeline" => "content", "source" => "blog"}},
+ created_at: req2_base + 3
+)
+create_execution(
+ request_id: req2_id,
+ tenant_id: enterprise.llm_tenant_id,
+ agent_type: "FullFeaturedAgent",
+ model_id: "gpt-4o",
+ status: "success",
+ started_at: req2_base + 5,
+ input_tokens: 1200,
+ output_tokens: 800,
+ parameters: {query: "Write a social media post about this AI safety summary"},
+ response: {content: "🤖 New insights on AI safety: alignment and interpretability..."},
+ metadata: {tags: {"pipeline" => "content", "source" => "blog"}},
+ created_at: req2_base + 5
+)
+puts " Created request #{req2_id} (3 agents: summarize + classify + generate)"
+
+# Request 3: Failed workflow — classify succeeds, response fails
+req3_id = "track_failed_response_003"
+req3_base = Time.current - 90.minutes
+create_execution(
+ request_id: req3_id,
+ tenant_id: startup.llm_tenant_id,
+ agent_type: "ClassifyAgent",
+ model_id: "gpt-4o-mini",
+ status: "success",
+ started_at: req3_base,
+ input_tokens: 250,
+ output_tokens: 30,
+ parameters: {message: "How do I integrate your API?"},
+ response: {content: "technical_question"},
+ metadata: {tags: {"workflow" => "support"}},
+ created_at: req3_base
+)
+create_execution(
+ request_id: req3_id,
+ tenant_id: startup.llm_tenant_id,
+ agent_type: "ReliabilityAgent",
+ model_id: "gpt-4o",
+ status: "error",
+ error_class: "RubyLLM::Error",
+ error_message: "Rate limit exceeded (429)",
+ started_at: req3_base + 1.5,
+ input_tokens: 0,
+ output_tokens: 0,
+ parameters: {query: "Explain API integration steps"},
+ metadata: {tags: {"workflow" => "support"}},
+ created_at: req3_base + 1.5
+)
+puts " Created request #{req3_id} (2 agents: classify ok + response error)"
+
+# Request 4: Large batch processing workflow
+req4_id = "track_batch_process_004"
+req4_base = Time.current - 2.hours
+%w[SummarizeAgent SummarizeAgent SummarizeAgent ClassifyAgent ClassifyAgent SchemaAgent].each_with_index do |agent, i|
+ create_execution(
+ request_id: req4_id,
+ tenant_id: acme.llm_tenant_id,
+ agent_type: agent,
+ model_id: (agent == "SchemaAgent") ? "gpt-4o" : "gpt-4o-mini",
+ status: "success",
+ started_at: req4_base + (i * 2),
+ input_tokens: rand(400..1200),
+ output_tokens: rand(100..500),
+ parameters: {text: "Batch document #{i + 1}"},
+ response: {content: "Processed batch item #{i + 1}"},
+ metadata: {tags: {"workflow" => "batch_processing", "batch_size" => "6"}},
+ created_at: req4_base + (i * 2)
+ )
+end
+puts " Created request #{req4_id} (6 agents: batch processing)"
+
+# Request 5: Real-time streaming conversation
+req5_id = "track_conversation_005"
+req5_base = Time.current - 30.minutes
+create_execution(
+ request_id: req5_id,
+ tenant_id: enterprise.llm_tenant_id,
+ agent_type: "ConversationAgent",
+ model_id: "claude-sonnet-4-20250514",
+ model_provider: "anthropic",
+ status: "success",
+ streaming: true,
+ started_at: req5_base,
+ input_tokens: 500,
+ output_tokens: 200,
+ parameters: {message: "Hello, I need help with my project"},
+ response: {content: "Hi! I'd be happy to help. What kind of project are you working on?"},
+ metadata: {tags: {"session" => "chat_abc123"}},
+ created_at: req5_base
+)
+create_execution(
+ request_id: req5_id,
+ tenant_id: enterprise.llm_tenant_id,
+ agent_type: "ConversationAgent",
+ model_id: "claude-sonnet-4-20250514",
+ model_provider: "anthropic",
+ status: "success",
+ streaming: true,
+ started_at: req5_base + 30,
+ input_tokens: 900,
+ output_tokens: 350,
+ parameters: {message: "It's a Rails app that needs AI features"},
+ response: {content: "Great choice! RubyLLM::Agents is perfect for Rails AI integration..."},
+ metadata: {tags: {"session" => "chat_abc123"}},
+ created_at: req5_base + 30
+)
+create_execution(
+ request_id: req5_id,
+ tenant_id: enterprise.llm_tenant_id,
+ agent_type: "ConversationAgent",
+ model_id: "claude-sonnet-4-20250514",
+ model_provider: "anthropic",
+ status: "success",
+ streaming: true,
+ started_at: req5_base + 75,
+ input_tokens: 1400,
+ output_tokens: 500,
+ parameters: {message: "Can you show me an example?"},
+ response: {content: "Here's a simple agent example..."},
+ metadata: {tags: {"session" => "chat_abc123"}},
+ created_at: req5_base + 75
+)
+puts " Created request #{req5_id} (3 conversation turns with Claude)"
+
+# Request 6: Multi-model workflow with Gemini
+req6_id = "track_multi_model_006"
+req6_base = Time.current - 5.minutes
+create_execution(
+ request_id: req6_id,
+ tenant_id: acme.llm_tenant_id,
+ agent_type: "ClassifyAgent",
+ model_id: "gemini-2.0-flash",
+ model_provider: "google",
+ status: "success",
+ started_at: req6_base,
+ input_tokens: 300,
+ output_tokens: 25,
+ parameters: {message: "Translate this document to French"},
+ response: {content: "translation_request"},
+ metadata: {tags: {"workflow" => "translation"}},
+ created_at: req6_base
+)
+create_execution(
+ request_id: req6_id,
+ tenant_id: acme.llm_tenant_id,
+ agent_type: "FullFeaturedAgent",
+ model_id: "gpt-4o",
+ status: "success",
+ started_at: req6_base + 1,
+ input_tokens: 1800,
+ output_tokens: 1600,
+ parameters: {query: "Translate the following to French: ..."},
+ response: {content: "Voici la traduction..."},
+ metadata: {tags: {"workflow" => "translation"}},
+ created_at: req6_base + 1
+)
+puts " Created request #{req6_id} (2 agents: Gemini classify + GPT-4o translate)"
+
+# Request 7: Running request (still in progress)
+req7_id = "track_in_progress_007"
+req7_base = Time.current - 10.seconds
+create_execution(
+ request_id: req7_id,
+ tenant_id: acme.llm_tenant_id,
+ agent_type: "SummarizeAgent",
+ model_id: "gpt-4o",
+ status: "success",
+ started_at: req7_base,
+ input_tokens: 600,
+ output_tokens: 150,
+ parameters: {text: "Summarize this report..."},
+ response: {content: "The quarterly report shows..."},
+ metadata: {tags: {"workflow" => "reporting"}},
+ created_at: req7_base
+)
+create_execution(
+ request_id: req7_id,
+ tenant_id: acme.llm_tenant_id,
+ agent_type: "FullFeaturedAgent",
+ model_id: "gpt-4o",
+ status: "running",
+ started_at: req7_base + 2,
+ input_tokens: 0,
+ output_tokens: 0,
+ parameters: {query: "Generate executive brief from summary"},
+ metadata: {tags: {"workflow" => "reporting"}},
+ created_at: req7_base + 2
+)
+puts " Created request #{req7_id} (1 done + 1 running)"
+
+# Request 8: Single-agent tracked request
+req8_id = "track_single_agent_008"
+req8_base = Time.current - 3.hours
+create_execution(
+ request_id: req8_id,
+ tenant_id: demo.llm_tenant_id,
+ agent_type: "CachingAgent",
+ model_id: "gpt-4o-mini",
+ status: "success",
+ started_at: req8_base,
+ input_tokens: 200,
+ output_tokens: 100,
+ parameters: {query: "What is the capital of France?"},
+ response: {content: "Paris"},
+ metadata: {tags: {"source" => "demo"}, cached: true},
+ created_at: req8_base
+)
+puts " Created request #{req8_id} (single cached agent)"
+
+puts " Total: 8 tracked requests created"
+
puts "\nTotal: #{Organization.count} organizations, #{RubyLLM::Agents::Execution.count} executions"
puts "\nStart the server with: bin/rails server"
puts "Then visit: http://localhost:3000/agents"
puts "\nTo test tenant filtering, append ?tenant_id=acme-corp to the URL"
+puts "To view tracked requests, visit: http://localhost:3000/agents/requests"
diff --git a/lib/ruby_llm/agents.rb b/lib/ruby_llm/agents.rb
index 48f106c1..164e2b8f 100644
--- a/lib/ruby_llm/agents.rb
+++ b/lib/ruby_llm/agents.rb
@@ -37,7 +37,12 @@
require_relative "agents/infrastructure/budget/forecaster"
require_relative "agents/infrastructure/budget/spend_recorder"
+# Tracking
+require_relative "agents/tracker"
+require_relative "agents/track_report"
+
# Results
+require_relative "agents/results/trackable"
require_relative "agents/results/base"
require_relative "agents/results/embedding_result"
require_relative "agents/results/transcription_result"
@@ -115,6 +120,69 @@ module RubyLLM
# @see RubyLLM::Agents::Configuration
module Agents
class << self
+ # Wraps a block of agent calls, collecting all Results and
+ # returning an aggregated TrackReport.
+ #
+ # Shared options (tenant, tags, request_id) are injected into
+ # every agent instantiated inside the block unless overridden.
+ #
+ # @param tenant [Hash, Object, nil] Shared tenant for all calls
+ # @param request_id [String, nil] Shared request ID (auto-generated if nil)
+ # @param tags [Hash] Tags merged into each execution's metadata
+ # @param defaults [Hash] Additional shared options for agents
+ # @yield Block containing agent calls to track
+ # @return [TrackReport] Aggregated report of all calls
+ #
+ # @example Basic usage
+ # report = RubyLLM::Agents.track do
+ # ChatAgent.call(query: "hello")
+ # SummaryAgent.call(text: "...")
+ # end
+ # report.total_cost # => 0.015
+ #
+ # @example With shared tenant
+ # report = RubyLLM::Agents.track(tenant: current_user) do
+ # AgentA.call(query: "test")
+ # end
+ def track(tenant: nil, request_id: nil, tags: {}, **defaults)
+ defaults[:tenant] = tenant if tenant
+ tracker = Tracker.new(defaults: defaults, request_id: request_id, tags: tags)
+
+ # Stack trackers for nesting support
+ previous_tracker = Thread.current[:ruby_llm_agents_tracker]
+ Thread.current[:ruby_llm_agents_tracker] = tracker
+
+ started_at = Time.current
+ value = nil
+ error = nil
+
+ begin
+ value = yield
+ rescue => e
+ error = e
+ end
+
+ completed_at = Time.current
+
+ report = TrackReport.new(
+ value: value,
+ error: error,
+ results: tracker.results,
+ request_id: tracker.request_id,
+ started_at: started_at,
+ completed_at: completed_at
+ )
+
+ # Bubble results up to parent tracker if nested
+ if previous_tracker
+ tracker.results.each { |r| previous_tracker << r }
+ end
+
+ report
+ ensure
+ Thread.current[:ruby_llm_agents_tracker] = previous_tracker
+ end
+
# Returns the global configuration instance
#
# @return [Configuration] The configuration object
diff --git a/lib/ruby_llm/agents/base_agent.rb b/lib/ruby_llm/agents/base_agent.rb
index 35e143ac..64b3651c 100644
--- a/lib/ruby_llm/agents/base_agent.rb
+++ b/lib/ruby_llm/agents/base_agent.rb
@@ -332,6 +332,14 @@ def default_temperature
# @param temperature [Float] Override the class-level temperature
# @param options [Hash] Agent parameters defined via the param DSL
def initialize(model: self.class.model, temperature: self.class.temperature, **options)
+ # Merge tracker defaults (shared options like tenant) — explicit opts win
+ tracker = Thread.current[:ruby_llm_agents_tracker]
+ if tracker
+ options = tracker.defaults.merge(options)
+ @_track_request_id = tracker.request_id
+ @_track_tags = tracker.tags
+ end
+
@ask_message = options.delete(:_ask_message)
@parent_execution_id = options.delete(:_parent_execution_id)
@root_execution_id = options.delete(:_root_execution_id)
@@ -885,6 +893,7 @@ def find_model_info(model_id)
def build_result(content, response, context)
Result.new(
content: content,
+ agent_class_name: self.class.name,
input_tokens: context.input_tokens,
output_tokens: context.output_tokens,
input_cost: context.input_cost,
diff --git a/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb b/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb
index b5cea616..6bbcdcb5 100644
--- a/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb
+++ b/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb
@@ -289,6 +289,9 @@ def build_running_execution_data(context)
data[:root_execution_id] = context.root_execution_id || context.parent_execution_id
end
+ # Inject tracker request_id and tags
+ inject_tracker_data(context, data)
+
data
end
@@ -548,6 +551,34 @@ def safe_agent_metadata(context)
{}
end
+ # Injects tracker request_id and tags into execution data
+ #
+ # Reads @_track_request_id and @_track_tags from the agent instance,
+ # which are set by BaseAgent#initialize when a Tracker is active.
+ #
+ # @param context [Context] The execution context
+ # @param data [Hash] The execution data hash to modify
+ def inject_tracker_data(context, data)
+ agent = context.agent_instance
+ return unless agent
+
+ # Inject request_id
+ track_request_id = agent.instance_variable_get(:@_track_request_id)
+ if track_request_id && data[:request_id].blank?
+ data[:request_id] = track_request_id
+ end
+
+ # Merge tracker tags into metadata
+ track_tags = agent.instance_variable_get(:@_track_tags)
+ if track_tags.is_a?(Hash) && track_tags.any?
+ data[:metadata] = (data[:metadata] || {}).merge(
+ "tags" => track_tags.transform_keys(&:to_s)
+ )
+ end
+ rescue
+ # Never let tracker data injection break execution
+ end
+
# Sensitive parameter keys that should be redacted
SENSITIVE_KEYS = %w[
password token api_key secret credential auth key
diff --git a/lib/ruby_llm/agents/results/background_removal_result.rb b/lib/ruby_llm/agents/results/background_removal_result.rb
index b0cd5ecc..7f0c7124 100644
--- a/lib/ruby_llm/agents/results/background_removal_result.rb
+++ b/lib/ruby_llm/agents/results/background_removal_result.rb
@@ -15,6 +15,8 @@ module Agents
# result.success? # => true
#
class BackgroundRemovalResult
+ include Trackable
+
attr_reader :foreground, :mask, :source_image, :model_id, :output_format,
:alpha_matting, :refine_edges,
:started_at, :completed_at, :tenant_id, :remover_class,
@@ -38,7 +40,7 @@ class BackgroundRemovalResult
# @param error_message [String, nil] Error message if failed
def initialize(foreground:, mask:, source_image:, model_id:, output_format:,
alpha_matting:, refine_edges:, started_at:, completed_at:,
- tenant_id:, remover_class:, error_class: nil, error_message: nil)
+ tenant_id:, remover_class:, error_class: nil, error_message: nil, agent_class_name: nil)
@foreground = foreground
@mask = mask
@source_image = source_image
@@ -53,6 +55,10 @@ def initialize(foreground:, mask:, source_image:, model_id:, output_format:,
@error_class = error_class
@error_message = error_message
@execution_id = nil
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/base.rb b/lib/ruby_llm/agents/results/base.rb
index 6731deb6..c26bf982 100644
--- a/lib/ruby_llm/agents/results/base.rb
+++ b/lib/ruby_llm/agents/results/base.rb
@@ -107,6 +107,11 @@ class Result
# @return [Integer, nil] Database ID of the associated Execution record
attr_reader :execution_id
+ # @!group Tracking
+ # @!attribute [r] agent_class_name
+ # @return [String, nil] The agent class that produced this result
+ attr_reader :agent_class_name
+
# Creates a new Result instance
#
# @param content [Hash, String] The processed response content
@@ -159,6 +164,13 @@ def initialize(content:, **options)
# Execution record
@execution_id = options[:execution_id]
+
+ # Tracking
+ @agent_class_name = options[:agent_class_name]
+
+ # Register with active tracker
+ tracker = Thread.current[:ruby_llm_agents_tracker]
+ tracker << self if tracker
end
# Loads the associated Execution record from the database
@@ -262,7 +274,8 @@ def to_h
thinking_text: thinking_text,
thinking_signature: thinking_signature,
thinking_tokens: thinking_tokens,
- execution_id: execution_id
+ execution_id: execution_id,
+ agent_class_name: agent_class_name
}
end
diff --git a/lib/ruby_llm/agents/results/embedding_result.rb b/lib/ruby_llm/agents/results/embedding_result.rb
index c649cbae..12be0568 100644
--- a/lib/ruby_llm/agents/results/embedding_result.rb
+++ b/lib/ruby_llm/agents/results/embedding_result.rb
@@ -25,6 +25,8 @@ module Agents
#
# @api public
class EmbeddingResult
+ include Trackable
+
# @!attribute [r] vectors
# @return [Array>] The embedding vectors
attr_reader :vectors
@@ -106,6 +108,8 @@ def initialize(attributes = {})
@error_class = attributes[:error_class]
@error_message = attributes[:error_message]
@execution_id = attributes[:execution_id]
+ @agent_class_name = attributes[:agent_class_name]
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/image_analysis_result.rb b/lib/ruby_llm/agents/results/image_analysis_result.rb
index 7bcf2b6b..18928b02 100644
--- a/lib/ruby_llm/agents/results/image_analysis_result.rb
+++ b/lib/ruby_llm/agents/results/image_analysis_result.rb
@@ -17,6 +17,8 @@ module Agents
# result.success? # => true
#
class ImageAnalysisResult
+ include Trackable
+
attr_reader :image, :model_id, :analysis_type,
:caption, :description, :tags, :objects, :colors, :text,
:raw_response, :started_at, :completed_at, :tenant_id, :analyzer_class,
@@ -43,7 +45,7 @@ class ImageAnalysisResult
# @param error_message [String, nil] Error message if failed
def initialize(image:, model_id:, analysis_type:, caption:, description:, tags:,
objects:, colors:, text:, raw_response:, started_at:, completed_at:,
- tenant_id:, analyzer_class:, error_class: nil, error_message: nil)
+ tenant_id:, analyzer_class:, error_class: nil, error_message: nil, agent_class_name: nil)
@image = image
@model_id = model_id
@analysis_type = analysis_type
@@ -61,6 +63,10 @@ def initialize(image:, model_id:, analysis_type:, caption:, description:, tags:,
@error_class = error_class
@error_message = error_message
@execution_id = nil
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/image_edit_result.rb b/lib/ruby_llm/agents/results/image_edit_result.rb
index db7a0f91..877b63be 100644
--- a/lib/ruby_llm/agents/results/image_edit_result.rb
+++ b/lib/ruby_llm/agents/results/image_edit_result.rb
@@ -17,6 +17,8 @@ module Agents
# result.success? # => true
#
class ImageEditResult
+ include Trackable
+
attr_reader :images, :source_image, :mask, :prompt, :model_id, :size,
:started_at, :completed_at, :tenant_id, :editor_class,
:error_class, :error_message
@@ -38,7 +40,7 @@ class ImageEditResult
# @param error_message [String, nil] Error message if failed
def initialize(images:, source_image:, mask:, prompt:, model_id:, size:,
started_at:, completed_at:, tenant_id:, editor_class:,
- error_class: nil, error_message: nil)
+ error_class: nil, error_message: nil, agent_class_name: nil)
@images = images
@source_image = source_image
@mask = mask
@@ -52,6 +54,10 @@ def initialize(images:, source_image:, mask:, prompt:, model_id:, size:,
@error_class = error_class
@error_message = error_message
@execution_id = nil
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/image_generation_result.rb b/lib/ruby_llm/agents/results/image_generation_result.rb
index f6591d25..de0b36b5 100644
--- a/lib/ruby_llm/agents/results/image_generation_result.rb
+++ b/lib/ruby_llm/agents/results/image_generation_result.rb
@@ -20,6 +20,8 @@ module Agents
# result.save_all("./logos")
#
class ImageGenerationResult
+ include Trackable
+
attr_reader :images, :prompt, :model_id, :size, :quality, :style,
:started_at, :completed_at, :tenant_id, :generator_class,
:error_class, :error_message, :execution_id
@@ -40,7 +42,7 @@ class ImageGenerationResult
# @param error_message [String, nil] Error message if failed
def initialize(images:, prompt:, model_id:, size:, quality:, style:,
started_at:, completed_at:, tenant_id:, generator_class:,
- error_class: nil, error_message: nil, execution_id: nil)
+ error_class: nil, error_message: nil, execution_id: nil, agent_class_name: nil)
@images = images
@prompt = prompt
@model_id = model_id
@@ -54,6 +56,10 @@ def initialize(images:, prompt:, model_id:, size:, quality:, style:,
@error_class = error_class
@error_message = error_message
@execution_id = execution_id
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/image_pipeline_result.rb b/lib/ruby_llm/agents/results/image_pipeline_result.rb
index 7df61d36..863e5110 100644
--- a/lib/ruby_llm/agents/results/image_pipeline_result.rb
+++ b/lib/ruby_llm/agents/results/image_pipeline_result.rb
@@ -21,6 +21,8 @@ module Agents
# result.analysis # => Shortcut to analyzer step result
#
class ImagePipelineResult
+ include Trackable
+
attr_reader :step_results, :started_at, :completed_at, :tenant_id,
:pipeline_class, :context, :error_class, :error_message
attr_accessor :execution_id
@@ -36,7 +38,7 @@ class ImagePipelineResult
# @param error_class [String, nil] Error class name if failed
# @param error_message [String, nil] Error message if failed
def initialize(step_results:, started_at:, completed_at:, tenant_id:,
- pipeline_class:, context:, error_class: nil, error_message: nil)
+ pipeline_class:, context:, error_class: nil, error_message: nil, agent_class_name: nil)
@step_results = step_results
@started_at = started_at
@completed_at = completed_at
@@ -46,6 +48,10 @@ def initialize(step_results:, started_at:, completed_at:, tenant_id:,
@error_class = error_class
@error_message = error_message
@execution_id = nil
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/image_transform_result.rb b/lib/ruby_llm/agents/results/image_transform_result.rb
index ef503b7c..47a61621 100644
--- a/lib/ruby_llm/agents/results/image_transform_result.rb
+++ b/lib/ruby_llm/agents/results/image_transform_result.rb
@@ -17,6 +17,8 @@ module Agents
# result.success? # => true
#
class ImageTransformResult
+ include Trackable
+
attr_reader :images, :source_image, :prompt, :model_id, :size, :strength,
:started_at, :completed_at, :tenant_id, :transformer_class,
:error_class, :error_message
@@ -38,7 +40,7 @@ class ImageTransformResult
# @param error_message [String, nil] Error message if failed
def initialize(images:, source_image:, prompt:, model_id:, size:, strength:,
started_at:, completed_at:, tenant_id:, transformer_class:,
- error_class: nil, error_message: nil)
+ error_class: nil, error_message: nil, agent_class_name: nil)
@images = images
@source_image = source_image
@prompt = prompt
@@ -52,6 +54,10 @@ def initialize(images:, source_image:, prompt:, model_id:, size:, strength:,
@error_class = error_class
@error_message = error_message
@execution_id = nil
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/image_upscale_result.rb b/lib/ruby_llm/agents/results/image_upscale_result.rb
index 843c9be6..b29fcf45 100644
--- a/lib/ruby_llm/agents/results/image_upscale_result.rb
+++ b/lib/ruby_llm/agents/results/image_upscale_result.rb
@@ -15,6 +15,8 @@ module Agents
# result.success? # => true
#
class ImageUpscaleResult
+ include Trackable
+
attr_reader :image, :source_image, :model_id, :scale, :output_size, :face_enhance,
:started_at, :completed_at, :tenant_id, :upscaler_class,
:error_class, :error_message
@@ -36,7 +38,7 @@ class ImageUpscaleResult
# @param error_message [String, nil] Error message if failed
def initialize(image:, source_image:, model_id:, scale:, output_size:, face_enhance:,
started_at:, completed_at:, tenant_id:, upscaler_class:,
- error_class: nil, error_message: nil)
+ error_class: nil, error_message: nil, agent_class_name: nil)
@image = image
@source_image = source_image
@model_id = model_id
@@ -50,6 +52,10 @@ def initialize(image:, source_image:, model_id:, scale:, output_size:, face_enha
@error_class = error_class
@error_message = error_message
@execution_id = nil
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/image_variation_result.rb b/lib/ruby_llm/agents/results/image_variation_result.rb
index abc2c020..023b14ab 100644
--- a/lib/ruby_llm/agents/results/image_variation_result.rb
+++ b/lib/ruby_llm/agents/results/image_variation_result.rb
@@ -14,6 +14,8 @@ module Agents
# result.success? # => true
#
class ImageVariationResult
+ include Trackable
+
attr_reader :images, :source_image, :model_id, :size, :variation_strength,
:started_at, :completed_at, :tenant_id, :variator_class,
:error_class, :error_message
@@ -34,7 +36,7 @@ class ImageVariationResult
# @param error_message [String, nil] Error message if failed
def initialize(images:, source_image:, model_id:, size:, variation_strength:,
started_at:, completed_at:, tenant_id:, variator_class:,
- error_class: nil, error_message: nil)
+ error_class: nil, error_message: nil, agent_class_name: nil)
@images = images
@source_image = source_image
@model_id = model_id
@@ -47,6 +49,10 @@ def initialize(images:, source_image:, model_id:, size:, variation_strength:,
@error_class = error_class
@error_message = error_message
@execution_id = nil
+
+ # Tracking
+ @agent_class_name = agent_class_name
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/speech_result.rb b/lib/ruby_llm/agents/results/speech_result.rb
index 4406e217..dca60306 100644
--- a/lib/ruby_llm/agents/results/speech_result.rb
+++ b/lib/ruby_llm/agents/results/speech_result.rb
@@ -23,6 +23,8 @@ module Agents
#
# @api public
class SpeechResult
+ include Trackable
+
# @!group Audio Content
# @!attribute [r] audio
@@ -229,6 +231,10 @@ def initialize(attributes = {})
# Execution record
@execution_id = attributes[:execution_id]
+
+ # Tracking
+ @agent_class_name = attributes[:agent_class_name]
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/results/trackable.rb b/lib/ruby_llm/agents/results/trackable.rb
new file mode 100644
index 00000000..9da03473
--- /dev/null
+++ b/lib/ruby_llm/agents/results/trackable.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Agents
+ # Mixin that registers a result object with the active Tracker.
+ #
+ # Included in every result class so that RubyLLM::Agents.track
+ # can collect results automatically.
+ #
+ # @api private
+ module Trackable
+ def self.included(base)
+ base.attr_reader :agent_class_name unless base.method_defined?(:agent_class_name)
+ end
+
+ private
+
+ # Call from the end of initialize to register with the active tracker
+ def register_with_tracker
+ tracker = Thread.current[:ruby_llm_agents_tracker]
+ tracker << self if tracker
+ end
+ end
+ end
+end
diff --git a/lib/ruby_llm/agents/results/transcription_result.rb b/lib/ruby_llm/agents/results/transcription_result.rb
index 20a39d0f..5911b1de 100644
--- a/lib/ruby_llm/agents/results/transcription_result.rb
+++ b/lib/ruby_llm/agents/results/transcription_result.rb
@@ -26,6 +26,8 @@ module Agents
#
# @api public
class TranscriptionResult
+ include Trackable
+
# @!group Content
# @!attribute [r] text
@@ -250,6 +252,10 @@ def initialize(attributes = {})
# Execution record
@execution_id = attributes[:execution_id]
+
+ # Tracking
+ @agent_class_name = attributes[:agent_class_name]
+ register_with_tracker
end
# Loads the associated Execution record from the database
diff --git a/lib/ruby_llm/agents/track_report.rb b/lib/ruby_llm/agents/track_report.rb
new file mode 100644
index 00000000..eb39163f
--- /dev/null
+++ b/lib/ruby_llm/agents/track_report.rb
@@ -0,0 +1,127 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Agents
+ # Aggregated read-only report returned by RubyLLM::Agents.track.
+ #
+ # Provides totals and breakdowns across all agent calls made
+ # inside the tracked block.
+ #
+ # @example
+ # report = RubyLLM::Agents.track do
+ # TranscribeAgent.call(with: audio_path)
+ # ChatAgent.call(message: "hello")
+ # end
+ # report.total_cost # => 0.0078
+ # report.call_count # => 2
+ #
+ # @api public
+ class TrackReport
+ attr_reader :value, :error, :results, :request_id
+ attr_reader :started_at, :completed_at
+
+ def initialize(value:, error:, results:, request_id:, started_at:, completed_at:)
+ @value = value
+ @error = error
+ @results = results.freeze
+ @request_id = request_id
+ @started_at = started_at
+ @completed_at = completed_at
+ end
+
+ def successful?
+ @error.nil?
+ end
+
+ def failed?
+ !successful?
+ end
+
+ def call_count
+ @results.size
+ end
+
+ def total_cost
+ @results.sum { |r| r.total_cost || 0 }
+ end
+
+ def input_cost
+ @results.sum { |r| r.input_cost || 0 }
+ end
+
+ def output_cost
+ @results.sum { |r| r.output_cost || 0 }
+ end
+
+ def total_tokens
+ @results.sum { |r| r.total_tokens }
+ end
+
+ def input_tokens
+ @results.sum { |r| r.input_tokens || 0 }
+ end
+
+ def output_tokens
+ @results.sum { |r| r.output_tokens || 0 }
+ end
+
+ def duration_ms
+ return nil unless @started_at && @completed_at
+ ((@completed_at - @started_at) * 1000).to_i
+ end
+
+ def all_successful?
+ @results.all?(&:success?)
+ end
+
+ def any_errors?
+ @results.any?(&:error?)
+ end
+
+ def errors
+ @results.select(&:error?)
+ end
+
+ def successful
+ @results.select(&:success?)
+ end
+
+ def models_used
+ @results.filter_map(&:chosen_model_id).uniq
+ end
+
+ def cost_breakdown
+ @results.map do |r|
+ {
+ agent: r.respond_to?(:agent_class_name) ? r.agent_class_name : nil,
+ model: r.chosen_model_id,
+ cost: r.total_cost || 0,
+ tokens: r.total_tokens,
+ duration_ms: r.duration_ms
+ }
+ end
+ end
+
+ def to_h
+ {
+ successful: successful?,
+ value: value,
+ error: error&.message,
+ request_id: request_id,
+ call_count: call_count,
+ total_cost: total_cost,
+ input_cost: input_cost,
+ output_cost: output_cost,
+ total_tokens: total_tokens,
+ input_tokens: input_tokens,
+ output_tokens: output_tokens,
+ duration_ms: duration_ms,
+ started_at: started_at,
+ completed_at: completed_at,
+ models_used: models_used,
+ cost_breakdown: cost_breakdown
+ }
+ end
+ end
+ end
+end
diff --git a/lib/ruby_llm/agents/tracker.rb b/lib/ruby_llm/agents/tracker.rb
new file mode 100644
index 00000000..3fcaa390
--- /dev/null
+++ b/lib/ruby_llm/agents/tracker.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+module RubyLLM
+ module Agents
+ # Internal collector used by RubyLLM::Agents.track to accumulate
+ # Result objects produced during a tracked block.
+ #
+ # Not part of the public API — users interact with TrackReport instead.
+ #
+ # @api private
+ class Tracker
+ attr_reader :results, :defaults, :request_id, :tags
+
+ def initialize(defaults: {}, request_id: nil, tags: {})
+ @results = []
+ @defaults = defaults
+ @request_id = request_id || generate_request_id
+ @tags = tags
+ end
+
+ def <<(result)
+ @results << result
+ end
+
+ private
+
+ def generate_request_id
+ "track_#{SecureRandom.hex(8)}"
+ end
+ end
+ end
+end
diff --git a/spec/agents/result_tracking_spec.rb b/spec/agents/result_tracking_spec.rb
new file mode 100644
index 00000000..1f689ed3
--- /dev/null
+++ b/spec/agents/result_tracking_spec.rb
@@ -0,0 +1,79 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe "Result tracking integration" do
+ describe "agent_class_name" do
+ it "stores agent_class_name when provided" do
+ result = RubyLLM::Agents::Result.new(
+ content: "test",
+ input_tokens: 100,
+ output_tokens: 50,
+ agent_class_name: "MyAgent"
+ )
+ expect(result.agent_class_name).to eq("MyAgent")
+ end
+
+ it "defaults to nil when not provided" do
+ result = RubyLLM::Agents::Result.new(content: "test", input_tokens: 10, output_tokens: 5)
+ expect(result.agent_class_name).to be_nil
+ end
+ end
+
+ describe "tracker registration" do
+ it "registers with active tracker on creation" do
+ tracker = RubyLLM::Agents::Tracker.new
+ Thread.current[:ruby_llm_agents_tracker] = tracker
+
+ result = RubyLLM::Agents::Result.new(content: "test", input_tokens: 10, output_tokens: 5)
+ expect(tracker.results).to eq([result])
+ ensure
+ Thread.current[:ruby_llm_agents_tracker] = nil
+ end
+
+ it "does nothing when no tracker is active" do
+ Thread.current[:ruby_llm_agents_tracker] = nil
+
+ result = RubyLLM::Agents::Result.new(content: "test", input_tokens: 10, output_tokens: 5)
+ expect(result).to be_a(RubyLLM::Agents::Result)
+ end
+
+ it "registers subclass results with tracker" do
+ tracker = RubyLLM::Agents::Tracker.new
+ Thread.current[:ruby_llm_agents_tracker] = tracker
+
+ result = RubyLLM::Agents::EmbeddingResult.new(
+ vectors: [[0.1, 0.2, 0.3]],
+ input_tokens: 10,
+ model_id: "text-embedding-3-small"
+ )
+ expect(tracker.results).to eq([result])
+ ensure
+ Thread.current[:ruby_llm_agents_tracker] = nil
+ end
+
+ it "registers multiple results in order" do
+ tracker = RubyLLM::Agents::Tracker.new
+ Thread.current[:ruby_llm_agents_tracker] = tracker
+
+ r1 = RubyLLM::Agents::Result.new(content: "first", input_tokens: 10, output_tokens: 5)
+ r2 = RubyLLM::Agents::Result.new(content: "second", input_tokens: 20, output_tokens: 10)
+
+ expect(tracker.results).to eq([r1, r2])
+ ensure
+ Thread.current[:ruby_llm_agents_tracker] = nil
+ end
+ end
+
+ describe "agent_class_name in to_h" do
+ it "includes agent_class_name in hash output" do
+ result = RubyLLM::Agents::Result.new(
+ content: "test",
+ input_tokens: 10,
+ output_tokens: 5,
+ agent_class_name: "SearchAgent"
+ )
+ expect(result.to_h).to include(agent_class_name: "SearchAgent")
+ end
+ end
+end
diff --git a/spec/agents/track_instrumentation_spec.rb b/spec/agents/track_instrumentation_spec.rb
new file mode 100644
index 00000000..872c68c7
--- /dev/null
+++ b/spec/agents/track_instrumentation_spec.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+# Test agent for instrumentation tests
+class InstrumentTrackTestAgent < RubyLLM::Agents::BaseAgent
+ model "gpt-4o"
+ param :query, required: true
+
+ user "Answer: {query}"
+end
+
+RSpec.describe "Track instrumentation integration" do
+ before do
+ setup_agent_mocks(content: "response", input_tokens: 100, output_tokens: 50)
+ end
+
+ describe "request_id injection" do
+ it "sets request_id on execution records" do
+ report = RubyLLM::Agents.track(request_id: "req_abc") do
+ InstrumentTrackTestAgent.call(query: "hello")
+ end
+
+ expect(report).to be_successful
+ expect(report.call_count).to eq(1)
+
+ # The execution should have the request_id
+ execution = RubyLLM::Agents::Execution.last
+ expect(execution.request_id).to eq("req_abc")
+ end
+
+ it "auto-generates request_id when none provided" do
+ RubyLLM::Agents.track do
+ InstrumentTrackTestAgent.call(query: "hello")
+ end
+
+ execution = RubyLLM::Agents::Execution.last
+ expect(execution.request_id).to start_with("track_")
+ end
+
+ it "sets same request_id on all executions in block" do
+ RubyLLM::Agents.track(request_id: "req_multi") do
+ InstrumentTrackTestAgent.call(query: "first")
+ InstrumentTrackTestAgent.call(query: "second")
+ end
+
+ executions = RubyLLM::Agents::Execution.where(request_id: "req_multi")
+ expect(executions.count).to eq(2)
+ end
+ end
+
+ describe "tags injection" do
+ it "merges tags into execution metadata" do
+ RubyLLM::Agents.track(tags: {feature: "voice-chat", session_id: "sess_1"}) do
+ InstrumentTrackTestAgent.call(query: "hello")
+ end
+
+ execution = RubyLLM::Agents::Execution.last
+ metadata = execution.metadata || {}
+ tags = metadata["tags"] || metadata[:tags]
+ expect(tags).to include("feature" => "voice-chat")
+ expect(tags).to include("session_id" => "sess_1")
+ end
+ end
+
+ describe "request_id grouping" do
+ it "allows querying executions by request_id" do
+ RubyLLM::Agents.track(request_id: "group_test") do
+ InstrumentTrackTestAgent.call(query: "a")
+ InstrumentTrackTestAgent.call(query: "b")
+ end
+
+ # Also create one outside the track block
+ InstrumentTrackTestAgent.call(query: "outside")
+
+ grouped = RubyLLM::Agents::Execution.where(request_id: "group_test")
+ expect(grouped.count).to eq(2)
+ end
+ end
+end
diff --git a/spec/agents/track_integration_spec.rb b/spec/agents/track_integration_spec.rb
new file mode 100644
index 00000000..66496b56
--- /dev/null
+++ b/spec/agents/track_integration_spec.rb
@@ -0,0 +1,214 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+# Minimal agent for track integration tests
+class TrackTestAgent < RubyLLM::Agents::BaseAgent
+ model "gpt-4o"
+ param :query, required: true
+
+ user "Answer: {query}"
+end
+
+RSpec.describe "RubyLLM::Agents.track" do
+ before { setup_agent_mocks(content: "response", input_tokens: 100, output_tokens: 50) }
+
+ describe "basic tracking" do
+ it "collects results from agent calls" do
+ report = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "hello")
+ TrackTestAgent.call(query: "world")
+ end
+
+ expect(report).to be_a(RubyLLM::Agents::TrackReport)
+ expect(report.call_count).to eq(2)
+ expect(report.results.size).to eq(2)
+ end
+
+ it "captures block return value" do
+ report = RubyLLM::Agents.track do
+ r = TrackTestAgent.call(query: "hello")
+ {answer: r.content}
+ end
+
+ expect(report.value).to eq({answer: "response"})
+ end
+
+ it "returns successful report for successful block" do
+ report = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "hello")
+ end
+
+ expect(report).to be_successful
+ expect(report).not_to be_failed
+ end
+
+ it "generates a request_id when not provided" do
+ report = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "hello")
+ end
+
+ expect(report.request_id).to start_with("track_")
+ end
+
+ it "uses provided request_id" do
+ report = RubyLLM::Agents.track(request_id: "req_abc") do
+ TrackTestAgent.call(query: "hello")
+ end
+
+ expect(report.request_id).to eq("req_abc")
+ end
+ end
+
+ describe "shared defaults" do
+ it "injects shared tenant into agent options" do
+ tenant_hash = {id: "tenant_1", object: nil}
+
+ report = RubyLLM::Agents.track(tenant: tenant_hash) do
+ TrackTestAgent.call(query: "hello")
+ end
+
+ expect(report.call_count).to eq(1)
+ expect(report).to be_successful
+ end
+
+ it "allows explicit options to override shared defaults" do
+ report = RubyLLM::Agents.track(tenant: {id: "tenant_a"}) do
+ TrackTestAgent.call(query: "default")
+ TrackTestAgent.call(query: "override", tenant: {id: "tenant_b"})
+ end
+
+ expect(report.call_count).to eq(2)
+ end
+ end
+
+ describe "error handling" do
+ it "captures errors without raising" do
+ report = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "hello")
+ raise "boom"
+ end
+
+ expect(report).to be_failed
+ expect(report.error).to be_a(RuntimeError)
+ expect(report.error.message).to eq("boom")
+ expect(report.call_count).to eq(1)
+ end
+
+ it "sets value to nil on error" do
+ report = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "hello")
+ raise "boom"
+ end
+
+ expect(report.value).to be_nil
+ end
+
+ it "captures partial results before error" do
+ report = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "first")
+ TrackTestAgent.call(query: "second")
+ raise "boom"
+ end
+
+ expect(report.call_count).to eq(2)
+ expect(report).to be_failed
+ end
+ end
+
+ describe "nesting" do
+ it "supports nested track blocks" do
+ outer = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "outer")
+
+ inner = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "inner")
+ end
+
+ expect(inner.call_count).to eq(1)
+ "outer_done"
+ end
+
+ expect(outer.call_count).to eq(2) # both outer and inner calls
+ expect(outer.value).to eq("outer_done")
+ end
+
+ it "inner block has its own report" do
+ inner_report = nil
+
+ RubyLLM::Agents.track do
+ inner_report = RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "inner_only")
+ end
+ end
+
+ expect(inner_report.call_count).to eq(1)
+ end
+
+ it "inner results bubble to outer tracker" do
+ outer = RubyLLM::Agents.track do
+ RubyLLM::Agents.track do
+ TrackTestAgent.call(query: "inner1")
+ TrackTestAgent.call(query: "inner2")
+ end
+ TrackTestAgent.call(query: "outer1")
+ end
+
+ expect(outer.call_count).to eq(3)
+ end
+ end
+
+ describe "edge cases" do
+ it "handles empty block" do
+ report = RubyLLM::Agents.track {}
+ expect(report.call_count).to eq(0)
+ expect(report.total_cost).to eq(0)
+ expect(report.value).to be_nil
+ end
+
+ it "handles block with no agent calls" do
+ report = RubyLLM::Agents.track { 1 + 1 }
+ expect(report.call_count).to eq(0)
+ expect(report.value).to eq(2)
+ end
+
+ it "cleans up thread-local on normal completion" do
+ RubyLLM::Agents.track { TrackTestAgent.call(query: "hello") }
+ expect(Thread.current[:ruby_llm_agents_tracker]).to be_nil
+ end
+
+ it "cleans up thread-local on exception in block" do
+ RubyLLM::Agents.track { raise "boom" }
+ expect(Thread.current[:ruby_llm_agents_tracker]).to be_nil
+ end
+
+ it "restores previous tracker after nested block" do
+ outer_tracker = nil
+
+ RubyLLM::Agents.track do
+ outer_tracker = Thread.current[:ruby_llm_agents_tracker]
+
+ RubyLLM::Agents.track do
+ # inner block
+ end
+
+ # After inner block, outer tracker should be restored
+ expect(Thread.current[:ruby_llm_agents_tracker]).to eq(outer_tracker)
+ end
+ end
+ end
+
+ describe "tracker defaults merging" do
+ it "stores track request_id and tags on agent instance" do
+ report = RubyLLM::Agents.track(
+ request_id: "req_xyz",
+ tags: {feature: "test"}
+ ) do
+ TrackTestAgent.call(query: "hello")
+ end
+
+ expect(report.request_id).to eq("req_xyz")
+ expect(report).to be_successful
+ end
+ end
+end
diff --git a/spec/agents/track_report_spec.rb b/spec/agents/track_report_spec.rb
new file mode 100644
index 00000000..4a0ff4e2
--- /dev/null
+++ b/spec/agents/track_report_spec.rb
@@ -0,0 +1,286 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe RubyLLM::Agents::TrackReport do
+ let(:started_at) { 1.second.ago }
+ let(:completed_at) { Time.current }
+
+ def build_result(**overrides)
+ defaults = {
+ content: "response",
+ input_tokens: 100,
+ output_tokens: 50,
+ input_cost: 0.001,
+ output_cost: 0.002,
+ total_cost: 0.003,
+ model_id: "gpt-4o",
+ chosen_model_id: "gpt-4o",
+ started_at: started_at,
+ completed_at: completed_at,
+ duration_ms: 500,
+ agent_class_name: "TestAgent"
+ }
+ RubyLLM::Agents::Result.new(**defaults.merge(overrides))
+ end
+
+ let(:results) do
+ [
+ build_result(input_tokens: 100, output_tokens: 50, input_cost: 0.001, output_cost: 0.002, total_cost: 0.003, chosen_model_id: "gpt-4o", duration_ms: 400, agent_class_name: "AgentA"),
+ build_result(input_tokens: 500, output_tokens: 200, input_cost: 0.005, output_cost: 0.010, total_cost: 0.015, chosen_model_id: "gpt-4o-mini", duration_ms: 600, agent_class_name: "AgentB"),
+ build_result(input_tokens: 300, output_tokens: 100, input_cost: 0.003, output_cost: 0.004, total_cost: 0.007, chosen_model_id: "gpt-4o", duration_ms: 500, agent_class_name: "AgentC")
+ ]
+ end
+
+ let(:report) do
+ described_class.new(
+ value: "done",
+ error: nil,
+ results: results,
+ request_id: "req_123",
+ started_at: started_at,
+ completed_at: completed_at
+ )
+ end
+
+ describe "#successful?" do
+ it "returns true when no error" do
+ expect(report).to be_successful
+ end
+
+ it "returns false when error is present" do
+ error_report = described_class.new(
+ value: nil, error: RuntimeError.new("boom"),
+ results: [], request_id: "req_1",
+ started_at: started_at, completed_at: completed_at
+ )
+ expect(error_report).not_to be_successful
+ end
+ end
+
+ describe "#failed?" do
+ it "is the inverse of successful?" do
+ expect(report).not_to be_failed
+ end
+ end
+
+ describe "#call_count" do
+ it "returns number of results" do
+ expect(report.call_count).to eq(3)
+ end
+
+ it "returns 0 for empty results" do
+ empty = described_class.new(
+ value: nil, error: nil, results: [],
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(empty.call_count).to eq(0)
+ end
+ end
+
+ describe "cost aggregation" do
+ it "sums total_cost" do
+ expect(report.total_cost).to eq(0.025)
+ end
+
+ it "sums input_cost" do
+ expect(report.input_cost).to be_within(0.0001).of(0.009)
+ end
+
+ it "sums output_cost" do
+ expect(report.output_cost).to eq(0.016)
+ end
+
+ it "handles nil costs gracefully" do
+ results_with_nil = [
+ build_result(total_cost: 0.01, input_cost: 0.005, output_cost: 0.005),
+ build_result(total_cost: nil, input_cost: nil, output_cost: nil)
+ ]
+ r = described_class.new(
+ value: nil, error: nil, results: results_with_nil,
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r.total_cost).to eq(0.01)
+ expect(r.input_cost).to eq(0.005)
+ expect(r.output_cost).to eq(0.005)
+ end
+ end
+
+ describe "token aggregation" do
+ it "sums total_tokens" do
+ expect(report.total_tokens).to eq(1250)
+ end
+
+ it "sums input_tokens" do
+ expect(report.input_tokens).to eq(900)
+ end
+
+ it "sums output_tokens" do
+ expect(report.output_tokens).to eq(350)
+ end
+
+ it "handles nil tokens gracefully" do
+ results_with_nil = [
+ build_result(input_tokens: 100, output_tokens: 50),
+ build_result(input_tokens: nil, output_tokens: nil)
+ ]
+ r = described_class.new(
+ value: nil, error: nil, results: results_with_nil,
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r.input_tokens).to eq(100)
+ expect(r.output_tokens).to eq(50)
+ end
+ end
+
+ describe "#duration_ms" do
+ it "calculates wall clock time" do
+ expect(report.duration_ms).to be_a(Integer)
+ expect(report.duration_ms).to be >= 0
+ end
+
+ it "returns nil if timestamps missing" do
+ r = described_class.new(
+ value: nil, error: nil, results: [],
+ request_id: "req_1", started_at: nil, completed_at: nil
+ )
+ expect(r.duration_ms).to be_nil
+ end
+ end
+
+ describe "#value" do
+ it "returns the block return value" do
+ expect(report.value).to eq("done")
+ end
+ end
+
+ describe "#error" do
+ it "returns nil for successful report" do
+ expect(report.error).to be_nil
+ end
+
+ it "returns the captured error" do
+ err = RuntimeError.new("something broke")
+ r = described_class.new(
+ value: nil, error: err, results: [],
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r.error).to eq(err)
+ end
+ end
+
+ describe "#request_id" do
+ it "returns the request_id" do
+ expect(report.request_id).to eq("req_123")
+ end
+ end
+
+ describe "#all_successful?" do
+ it "returns true when all results succeeded" do
+ expect(report).to be_all_successful
+ end
+
+ it "returns false when any result has an error" do
+ results_with_error = [
+ build_result,
+ build_result(error_class: "RuntimeError", error_message: "boom")
+ ]
+ r = described_class.new(
+ value: nil, error: nil, results: results_with_error,
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r).not_to be_all_successful
+ end
+ end
+
+ describe "#any_errors?" do
+ it "returns false when all succeeded" do
+ expect(report.any_errors?).to be false
+ end
+
+ it "returns true when any result has an error" do
+ results_with_error = [
+ build_result,
+ build_result(error_class: "RuntimeError", error_message: "boom")
+ ]
+ r = described_class.new(
+ value: nil, error: nil, results: results_with_error,
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r.any_errors?).to be true
+ end
+ end
+
+ describe "#errors" do
+ it "returns only error results" do
+ error_result = build_result(error_class: "RuntimeError", error_message: "boom")
+ results_mixed = [build_result, error_result]
+ r = described_class.new(
+ value: nil, error: nil, results: results_mixed,
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r.errors).to eq([error_result])
+ end
+ end
+
+ describe "#successful" do
+ it "returns only successful results" do
+ ok_result = build_result
+ error_result = build_result(error_class: "RuntimeError", error_message: "boom")
+ results_mixed = [ok_result, error_result]
+ r = described_class.new(
+ value: nil, error: nil, results: results_mixed,
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r.successful).to eq([ok_result])
+ end
+ end
+
+ describe "#models_used" do
+ it "returns unique model IDs" do
+ expect(report.models_used).to contain_exactly("gpt-4o", "gpt-4o-mini")
+ end
+ end
+
+ describe "#cost_breakdown" do
+ it "returns per-result cost data" do
+ breakdown = report.cost_breakdown
+ expect(breakdown.size).to eq(3)
+ expect(breakdown.first).to include(
+ model: "gpt-4o",
+ cost: 0.003,
+ tokens: 150,
+ duration_ms: 400
+ )
+ end
+ end
+
+ describe "#to_h" do
+ it "returns a complete hash" do
+ hash = report.to_h
+ expect(hash).to include(
+ successful: true,
+ value: "done",
+ error: nil,
+ request_id: "req_123",
+ call_count: 3,
+ total_cost: 0.025,
+ total_tokens: 1250
+ )
+ end
+
+ it "includes error message when failed" do
+ r = described_class.new(
+ value: nil, error: RuntimeError.new("boom"), results: [],
+ request_id: "req_1", started_at: started_at, completed_at: completed_at
+ )
+ expect(r.to_h[:error]).to eq("boom")
+ end
+ end
+
+ describe "results freezing" do
+ it "freezes results array" do
+ expect(report.results).to be_frozen
+ end
+ end
+end
diff --git a/spec/agents/tracker_spec.rb b/spec/agents/tracker_spec.rb
new file mode 100644
index 00000000..9e5fa633
--- /dev/null
+++ b/spec/agents/tracker_spec.rb
@@ -0,0 +1,72 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe RubyLLM::Agents::Tracker do
+ describe "#initialize" do
+ it "starts with empty results" do
+ tracker = described_class.new
+ expect(tracker.results).to eq([])
+ end
+
+ it "generates a request_id when none provided" do
+ tracker = described_class.new
+ expect(tracker.request_id).to start_with("track_")
+ expect(tracker.request_id.length).to be > 10
+ end
+
+ it "uses provided request_id" do
+ tracker = described_class.new(request_id: "req_123")
+ expect(tracker.request_id).to eq("req_123")
+ end
+
+ it "stores defaults" do
+ tracker = described_class.new(defaults: {tenant: "user_1"})
+ expect(tracker.defaults).to eq({tenant: "user_1"})
+ end
+
+ it "defaults to empty defaults hash" do
+ tracker = described_class.new
+ expect(tracker.defaults).to eq({})
+ end
+
+ it "stores tags" do
+ tracker = described_class.new(tags: {feature: "voice-chat"})
+ expect(tracker.tags).to eq({feature: "voice-chat"})
+ end
+
+ it "defaults to empty tags hash" do
+ tracker = described_class.new
+ expect(tracker.tags).to eq({})
+ end
+ end
+
+ describe "#<<" do
+ it "collects results pushed to it" do
+ tracker = described_class.new
+ result = RubyLLM::Agents::Result.new(content: "test", total_cost: 0.01, input_tokens: 100, output_tokens: 50)
+ tracker << result
+ expect(tracker.results).to eq([result])
+ end
+
+ it "maintains insertion order" do
+ tracker = described_class.new
+ r1 = RubyLLM::Agents::Result.new(content: "first", input_tokens: 10, output_tokens: 5)
+ r2 = RubyLLM::Agents::Result.new(content: "second", input_tokens: 20, output_tokens: 10)
+ r3 = RubyLLM::Agents::Result.new(content: "third", input_tokens: 30, output_tokens: 15)
+
+ tracker << r1
+ tracker << r2
+ tracker << r3
+
+ expect(tracker.results).to eq([r1, r2, r3])
+ end
+ end
+
+ describe "unique request_ids" do
+ it "generates different request_ids for different trackers" do
+ ids = 10.times.map { described_class.new.request_id }
+ expect(ids.uniq.size).to eq(10)
+ end
+ end
+end
diff --git a/spec/concerns/llm_tenant_spec.rb b/spec/concerns/llm_tenant_spec.rb
index 446c283b..4fbe8762 100644
--- a/spec/concerns/llm_tenant_spec.rb
+++ b/spec/concerns/llm_tenant_spec.rb
@@ -23,6 +23,11 @@
include RubyLLM::Agents::LLMTenant
+ # Define llm_executions so tests can stub it even with verify_partial_doubles
+ def llm_executions
+ RubyLLM::Agents::Execution.where(tenant_id: llm_tenant_id)
+ end
+
def to_s
name || "Organization ##{id}"
end
diff --git a/spec/controllers/agents_controller_spec.rb b/spec/controllers/agents_controller_spec.rb
index ea1c6b4f..88c851bb 100644
--- a/spec/controllers/agents_controller_spec.rb
+++ b/spec/controllers/agents_controller_spec.rb
@@ -134,12 +134,6 @@ def show
describe "GET #show" do
let!(:execution) { create(:execution, agent_type: "TestAgent") }
- # avg_time_to_first_token queries time_to_first_token_ms column which has been
- # moved to the metadata JSON column. Stub it to avoid SQLite errors.
- before do
- allow_any_instance_of(ActiveRecord::Relation).to receive(:avg_time_to_first_token).and_return(nil)
- end
-
it "returns http success" do
get :show, params: {id: "TestAgent"}
expect(response).to have_http_status(:success)
diff --git a/spec/controllers/requests_controller_spec.rb b/spec/controllers/requests_controller_spec.rb
new file mode 100644
index 00000000..8247c5df
--- /dev/null
+++ b/spec/controllers/requests_controller_spec.rb
@@ -0,0 +1,85 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe RubyLLM::Agents::RequestsController, type: :request do
+ let(:engine_routes) { RubyLLM::Agents::Engine.routes }
+
+ before do
+ RubyLLM::Agents.reset_configuration!
+ RubyLLM::Agents.configure do |c|
+ c.track_executions = true
+ end
+ end
+
+ describe "GET #index" do
+ it "renders successfully with no requests" do
+ get engine_routes.url_helpers.requests_path
+ expect(response).to have_http_status(:ok)
+ end
+
+ it "lists requests grouped by request_id" do
+ create(:execution, request_id: "req_001", agent_type: "AgentA")
+ create(:execution, request_id: "req_001", agent_type: "AgentB")
+ create(:execution, request_id: "req_002", agent_type: "AgentC")
+
+ get engine_routes.url_helpers.requests_path
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("req_001")
+ expect(response.body).to include("req_002")
+ end
+
+ it "excludes executions without request_id" do
+ create(:execution, request_id: nil)
+ create(:execution, request_id: "req_with_id")
+
+ get engine_routes.url_helpers.requests_path
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("req_with_id")
+ end
+
+ it "supports sorting by cost" do
+ create(:execution, request_id: "cheap_req", total_cost: 0.001)
+ create(:execution, request_id: "expensive_req", total_cost: 1.0)
+
+ get engine_routes.url_helpers.requests_path, params: {sort: "total_cost", direction: "desc"}
+ expect(response).to have_http_status(:ok)
+ end
+ end
+
+ describe "GET #show" do
+ it "shows a request detail page" do
+ create(:execution, request_id: "req_abc", agent_type: "AgentA")
+ create(:execution, request_id: "req_abc", agent_type: "AgentB")
+
+ get engine_routes.url_helpers.request_path("req_abc")
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("req_abc")
+ expect(response.body).to include("AgentA")
+ expect(response.body).to include("AgentB")
+ end
+
+ it "shows summary statistics" do
+ create(:execution, request_id: "req_stats", total_cost: 0.005, total_tokens: 100)
+ create(:execution, request_id: "req_stats", total_cost: 0.010, total_tokens: 200)
+
+ get engine_routes.url_helpers.request_path("req_stats")
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("300") # total tokens
+ end
+
+ it "redirects when request_id not found" do
+ get engine_routes.url_helpers.request_path("nonexistent_req")
+ expect(response).to redirect_to(engine_routes.url_helpers.requests_path)
+ end
+
+ it "shows error status when executions have errors" do
+ create(:execution, request_id: "req_err", status: "success")
+ create(:execution, :failed, request_id: "req_err")
+
+ get engine_routes.url_helpers.request_path("req_err")
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("error")
+ end
+ end
+end