ruby_llm-agents 3.7.2 → 3.9.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +14 -141
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +12 -166
  5. data/app/controllers/ruby_llm/agents/executions_controller.rb +1 -1
  6. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -0
  7. data/app/helpers/ruby_llm/agents/application_helper.rb +38 -0
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +302 -103
  9. data/app/models/ruby_llm/agents/execution.rb +76 -54
  10. data/app/models/ruby_llm/agents/execution_detail.rb +2 -0
  11. data/app/models/ruby_llm/agents/tenant.rb +39 -0
  12. data/app/services/ruby_llm/agents/agent_registry.rb +98 -0
  13. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  14. data/app/views/ruby_llm/agents/executions/_list.html.erb +3 -17
  15. data/app/views/ruby_llm/agents/requests/index.html.erb +153 -0
  16. data/app/views/ruby_llm/agents/requests/show.html.erb +136 -0
  17. data/config/routes.rb +2 -0
  18. data/lib/generators/ruby_llm_agents/agent_generator.rb +2 -2
  19. data/lib/generators/ruby_llm_agents/demo_generator.rb +102 -0
  20. data/lib/generators/ruby_llm_agents/doctor_generator.rb +196 -0
  21. data/lib/generators/ruby_llm_agents/install_generator.rb +7 -19
  22. data/lib/generators/ruby_llm_agents/templates/add_dashboard_performance_indexes_migration.rb.tt +23 -0
  23. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +27 -80
  24. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +18 -51
  25. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +19 -17
  26. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +3 -0
  27. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +25 -0
  28. data/lib/ruby_llm/agents/base_agent.rb +71 -4
  29. data/lib/ruby_llm/agents/core/base.rb +4 -0
  30. data/lib/ruby_llm/agents/core/configuration.rb +11 -0
  31. data/lib/ruby_llm/agents/core/instrumentation.rb +15 -19
  32. data/lib/ruby_llm/agents/core/version.rb +1 -1
  33. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +4 -4
  34. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +19 -11
  35. data/lib/ruby_llm/agents/pipeline/builder.rb +8 -4
  36. data/lib/ruby_llm/agents/pipeline/context.rb +69 -1
  37. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +58 -4
  38. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +21 -17
  39. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +40 -26
  40. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +126 -120
  41. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +13 -11
  42. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +29 -31
  43. data/lib/ruby_llm/agents/providers/inception/capabilities.rb +107 -0
  44. data/lib/ruby_llm/agents/providers/inception/chat.rb +17 -0
  45. data/lib/ruby_llm/agents/providers/inception/configuration.rb +9 -0
  46. data/lib/ruby_llm/agents/providers/inception/models.rb +38 -0
  47. data/lib/ruby_llm/agents/providers/inception/registry.rb +45 -0
  48. data/lib/ruby_llm/agents/providers/inception.rb +50 -0
  49. data/lib/ruby_llm/agents/rails/engine.rb +11 -0
  50. data/lib/ruby_llm/agents/results/background_removal_result.rb +7 -1
  51. data/lib/ruby_llm/agents/results/base.rb +28 -4
  52. data/lib/ruby_llm/agents/results/embedding_result.rb +4 -0
  53. data/lib/ruby_llm/agents/results/image_analysis_result.rb +11 -3
  54. data/lib/ruby_llm/agents/results/image_edit_result.rb +7 -1
  55. data/lib/ruby_llm/agents/results/image_generation_result.rb +7 -1
  56. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +7 -1
  57. data/lib/ruby_llm/agents/results/image_transform_result.rb +7 -1
  58. data/lib/ruby_llm/agents/results/image_upscale_result.rb +7 -1
  59. data/lib/ruby_llm/agents/results/image_variation_result.rb +7 -1
  60. data/lib/ruby_llm/agents/results/speech_result.rb +6 -0
  61. data/lib/ruby_llm/agents/results/trackable.rb +25 -0
  62. data/lib/ruby_llm/agents/results/transcription_result.rb +6 -0
  63. data/lib/ruby_llm/agents/text/embedder.rb +8 -1
  64. data/lib/ruby_llm/agents/track_report.rb +127 -0
  65. data/lib/ruby_llm/agents/tracker.rb +32 -0
  66. data/lib/ruby_llm/agents.rb +212 -0
  67. data/lib/tasks/ruby_llm_agents.rake +6 -0
  68. metadata +17 -2
@@ -269,11 +269,14 @@ module RubyLLM
269
269
  # Returns whether this execution made tool calls
270
270
  #
271
271
  # @return [Boolean] true if tool calls were made
272
- def has_tool_calls?
272
+ def tool_calls?
273
273
  tool_calls_count.to_i > 0
274
274
  end
275
+ alias_method :has_tool_calls?, :tool_calls?
275
276
 
276
277
  # Returns real-time dashboard data for the Now Strip
278
+ # Optimized: 3 queries (current aggregate + previous aggregate + running count)
279
+ # instead of ~15 individual count/sum/average queries.
277
280
  #
278
281
  # @param range [String] Time range: "today", "7d", "30d", or "90d"
279
282
  # @return [Hash] Now strip metrics with period-over-period comparisons
@@ -292,38 +295,31 @@ module RubyLLM
292
295
  else yesterday
293
296
  end
294
297
 
295
- current = {
296
- running: running.count,
297
- success_today: current_scope.status_success.count,
298
- errors_today: current_scope.status_error.count,
299
- timeouts_today: current_scope.status_timeout.count,
300
- cost_today: current_scope.sum(:total_cost) || 0,
301
- executions_today: current_scope.count,
302
- success_rate: calculate_period_success_rate(current_scope),
303
- avg_duration_ms: current_scope.avg_duration&.round || 0,
304
- total_tokens: current_scope.total_tokens_sum || 0
305
- }
306
-
307
- previous = {
308
- success: previous_scope.status_success.count,
309
- errors: previous_scope.status_error.count,
310
- cost: previous_scope.sum(:total_cost) || 0,
311
- avg_duration_ms: previous_scope.avg_duration&.round || 0,
312
- total_tokens: previous_scope.total_tokens_sum || 0
313
- }
298
+ curr = aggregate_period_stats(current_scope)
299
+ prev = aggregate_period_stats(previous_scope)
314
300
 
315
- current.merge(
301
+ {
302
+ running: running.count,
303
+ success_today: curr[:success],
304
+ errors_today: curr[:errors],
305
+ timeouts_today: curr[:timeouts],
306
+ cost_today: curr[:cost],
307
+ executions_today: curr[:total],
308
+ success_rate: curr[:success_rate],
309
+ avg_duration_ms: curr[:avg_duration_ms],
310
+ total_tokens: curr[:tokens],
316
311
  comparisons: {
317
- success_change: pct_change(previous[:success], current[:success_today]),
318
- errors_change: pct_change(previous[:errors], current[:errors_today]),
319
- cost_change: pct_change(previous[:cost], current[:cost_today]),
320
- duration_change: pct_change(previous[:avg_duration_ms], current[:avg_duration_ms]),
321
- tokens_change: pct_change(previous[:total_tokens], current[:total_tokens])
312
+ success_change: pct_change(prev[:success], curr[:success]),
313
+ errors_change: pct_change(prev[:errors], curr[:errors]),
314
+ cost_change: pct_change(prev[:cost], curr[:cost]),
315
+ duration_change: pct_change(prev[:avg_duration_ms], curr[:avg_duration_ms]),
316
+ tokens_change: pct_change(prev[:tokens], curr[:tokens])
322
317
  }
323
- )
318
+ }
324
319
  end
325
320
 
326
321
  # Returns Now Strip data for a custom date range
322
+ # Optimized: 3 queries instead of ~15.
327
323
  #
328
324
  # Compares the selected range against the same-length window
329
325
  # immediately preceding it.
@@ -338,35 +334,27 @@ module RubyLLM
338
334
  previous_to = from - 1.day
339
335
  previous_scope = where(created_at: previous_from.beginning_of_day..previous_to.end_of_day)
340
336
 
341
- current = {
342
- running: running.count,
343
- success_today: current_scope.status_success.count,
344
- errors_today: current_scope.status_error.count,
345
- timeouts_today: current_scope.status_timeout.count,
346
- cost_today: current_scope.sum(:total_cost) || 0,
347
- executions_today: current_scope.count,
348
- success_rate: calculate_period_success_rate(current_scope),
349
- avg_duration_ms: current_scope.avg_duration&.round || 0,
350
- total_tokens: current_scope.total_tokens_sum || 0
351
- }
352
-
353
- previous = {
354
- success: previous_scope.status_success.count,
355
- errors: previous_scope.status_error.count,
356
- cost: previous_scope.sum(:total_cost) || 0,
357
- avg_duration_ms: previous_scope.avg_duration&.round || 0,
358
- total_tokens: previous_scope.total_tokens_sum || 0
359
- }
337
+ curr = aggregate_period_stats(current_scope)
338
+ prev = aggregate_period_stats(previous_scope)
360
339
 
361
- current.merge(
340
+ {
341
+ running: running.count,
342
+ success_today: curr[:success],
343
+ errors_today: curr[:errors],
344
+ timeouts_today: curr[:timeouts],
345
+ cost_today: curr[:cost],
346
+ executions_today: curr[:total],
347
+ success_rate: curr[:success_rate],
348
+ avg_duration_ms: curr[:avg_duration_ms],
349
+ total_tokens: curr[:tokens],
362
350
  comparisons: {
363
- success_change: pct_change(previous[:success], current[:success_today]),
364
- errors_change: pct_change(previous[:errors], current[:errors_today]),
365
- cost_change: pct_change(previous[:cost], current[:cost_today]),
366
- duration_change: pct_change(previous[:avg_duration_ms], current[:avg_duration_ms]),
367
- tokens_change: pct_change(previous[:total_tokens], current[:total_tokens])
351
+ success_change: pct_change(prev[:success], curr[:success]),
352
+ errors_change: pct_change(prev[:errors], curr[:errors]),
353
+ cost_change: pct_change(prev[:cost], curr[:cost]),
354
+ duration_change: pct_change(prev[:avg_duration_ms], curr[:avg_duration_ms]),
355
+ tokens_change: pct_change(prev[:tokens], curr[:tokens])
368
356
  }
369
- )
357
+ }
370
358
  end
371
359
 
372
360
  # Calculates percentage change between old and new values
@@ -390,6 +378,39 @@ module RubyLLM
390
378
  (scope.successful.count.to_f / total * 100).round(1)
391
379
  end
392
380
 
381
+ # Returns aggregate stats for a scope in a single query using conditional aggregation
382
+ #
383
+ # Replaces ~9 individual count/sum/average queries with one SQL query.
384
+ #
385
+ # @param scope [ActiveRecord::Relation] Time-filtered scope
386
+ # @return [Hash] Aggregated metrics
387
+ def self.aggregate_period_stats(scope)
388
+ total, success, errors, timeouts, cost, avg_dur, tokens = scope.pick(
389
+ Arel.sql("COUNT(*)"),
390
+ Arel.sql("SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END)"),
391
+ Arel.sql("SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END)"),
392
+ Arel.sql("SUM(CASE WHEN status = 'timeout' THEN 1 ELSE 0 END)"),
393
+ Arel.sql("COALESCE(SUM(total_cost), 0)"),
394
+ Arel.sql("AVG(duration_ms)"),
395
+ Arel.sql("COALESCE(SUM(total_tokens), 0)")
396
+ )
397
+
398
+ total = total.to_i
399
+ success = success.to_i
400
+
401
+ {
402
+ total: total,
403
+ success: success,
404
+ errors: errors.to_i,
405
+ timeouts: timeouts.to_i,
406
+ cost: cost.to_f,
407
+ avg_duration_ms: avg_dur.to_i,
408
+ tokens: tokens.to_i,
409
+ success_rate: (total > 0) ? (success.to_f / total * 100).round(1) : 0.0
410
+ }
411
+ end
412
+ private_class_method :aggregate_period_stats
413
+
393
414
  private
394
415
 
395
416
  # Calculates and sets total_tokens from input and output
@@ -420,7 +441,8 @@ module RubyLLM
420
441
  return nil unless lookup_model_id
421
442
 
422
443
  RubyLLM::Models.find(lookup_model_id)
423
- rescue
444
+ rescue => e
445
+ Rails.logger.debug("[RubyLLM::Agents] Model lookup failed for #{lookup_model_id}: #{e.message}") if defined?(Rails) && Rails.logger
424
446
  nil
425
447
  end
426
448
  end
@@ -13,6 +13,8 @@ module RubyLLM
13
13
  self.table_name = "ruby_llm_agents_execution_details"
14
14
 
15
15
  belongs_to :execution, class_name: "RubyLLM::Agents::Execution"
16
+
17
+ validates :execution_id, presence: true
16
18
  end
17
19
  end
18
20
  end
@@ -53,6 +53,45 @@ module RubyLLM
53
53
  scope :linked, -> { where.not(tenant_record_type: nil) }
54
54
  scope :unlinked, -> { where(tenant_record_type: nil) }
55
55
 
56
+ # Returns top tenants by monthly spend for dashboard display
57
+ #
58
+ # Ensures counter resets are current before returning data.
59
+ #
60
+ # @param limit [Integer] Max tenants to return
61
+ # @return [Array<Hash>, nil] Tenant spend data or nil if none
62
+ def self.top_by_spend(limit: 5)
63
+ return nil unless table_exists?
64
+
65
+ tenants = active
66
+ .where("monthly_cost_spent > 0 OR monthly_executions_count > 0")
67
+ .order(monthly_cost_spent: :desc)
68
+ .limit(limit)
69
+
70
+ return nil if tenants.empty?
71
+
72
+ tenants.map do |tenant|
73
+ tenant.ensure_daily_reset!
74
+ tenant.ensure_monthly_reset!
75
+
76
+ monthly_limit = tenant.effective_monthly_limit
77
+ daily_limit = tenant.effective_daily_limit
78
+
79
+ {
80
+ id: tenant.id,
81
+ tenant_id: tenant.tenant_id,
82
+ name: tenant.display_name,
83
+ enforcement: tenant.effective_enforcement,
84
+ monthly_spend: tenant.monthly_cost_spent,
85
+ monthly_limit: monthly_limit,
86
+ monthly_percentage: (monthly_limit.to_f > 0) ? (tenant.monthly_cost_spent / monthly_limit * 100).round(1) : 0,
87
+ daily_spend: tenant.daily_cost_spent,
88
+ daily_limit: daily_limit,
89
+ daily_percentage: (daily_limit.to_f > 0) ? (tenant.daily_cost_spent / daily_limit * 100).round(1) : 0,
90
+ monthly_executions: tenant.monthly_executions_count
91
+ }
92
+ end
93
+ end
94
+
56
95
  # Find tenant for given record or ID
57
96
  #
58
97
  # Supports multiple lookup strategies:
@@ -54,8 +54,106 @@ module RubyLLM
54
54
  end
55
55
  end
56
56
 
57
+ # Extracts full configuration for an agent class
58
+ #
59
+ # Combines base config with type-specific config for display.
60
+ #
61
+ # @param agent_class [Class] The agent class
62
+ # @return [Hash] Configuration hash
63
+ def config_for(agent_class)
64
+ return {} unless agent_class
65
+
66
+ base = {
67
+ model: safe_call(agent_class, :model),
68
+ version: safe_call(agent_class, :version),
69
+ description: safe_call(agent_class, :description)
70
+ }
71
+
72
+ type = detect_agent_type(agent_class)
73
+ base.merge(type_config_for(agent_class, type))
74
+ end
75
+
57
76
  private
58
77
 
78
+ # Extracts type-specific configuration
79
+ #
80
+ # @param agent_class [Class] The agent class
81
+ # @param type [String] The detected agent type
82
+ # @return [Hash] Type-specific config
83
+ def type_config_for(agent_class, type)
84
+ case type
85
+ when "embedder"
86
+ {
87
+ dimensions: safe_call(agent_class, :dimensions),
88
+ batch_size: safe_call(agent_class, :batch_size),
89
+ cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
90
+ cache_ttl: safe_call(agent_class, :cache_ttl)
91
+ }
92
+ when "speaker"
93
+ {
94
+ provider: safe_call(agent_class, :provider),
95
+ voice: safe_call(agent_class, :voice),
96
+ voice_id: safe_call(agent_class, :voice_id),
97
+ speed: safe_call(agent_class, :speed),
98
+ output_format: safe_call(agent_class, :output_format),
99
+ streaming: safe_call(agent_class, :streaming?),
100
+ ssml_enabled: safe_call(agent_class, :ssml_enabled?),
101
+ cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
102
+ cache_ttl: safe_call(agent_class, :cache_ttl)
103
+ }
104
+ when "transcriber"
105
+ {
106
+ language: safe_call(agent_class, :language),
107
+ output_format: safe_call(agent_class, :output_format),
108
+ include_timestamps: safe_call(agent_class, :include_timestamps),
109
+ cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
110
+ cache_ttl: safe_call(agent_class, :cache_ttl),
111
+ fallback_models: safe_call(agent_class, :fallback_models)
112
+ }
113
+ when "image_generator"
114
+ {
115
+ size: safe_call(agent_class, :size),
116
+ quality: safe_call(agent_class, :quality),
117
+ style: safe_call(agent_class, :style),
118
+ content_policy: safe_call(agent_class, :content_policy),
119
+ template: safe_call(agent_class, :template_string),
120
+ negative_prompt: safe_call(agent_class, :negative_prompt),
121
+ seed: safe_call(agent_class, :seed),
122
+ guidance_scale: safe_call(agent_class, :guidance_scale),
123
+ steps: safe_call(agent_class, :steps),
124
+ cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
125
+ cache_ttl: safe_call(agent_class, :cache_ttl)
126
+ }
127
+ when "router"
128
+ routes = safe_call(agent_class, :routes) || {}
129
+ {
130
+ temperature: safe_call(agent_class, :temperature),
131
+ timeout: safe_call(agent_class, :timeout),
132
+ cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
133
+ cache_ttl: safe_call(agent_class, :cache_ttl),
134
+ default_route: safe_call(agent_class, :default_route_name),
135
+ routes: routes.transform_values { |v| v[:description] },
136
+ route_count: routes.size,
137
+ retries: safe_call(agent_class, :retries),
138
+ fallback_models: safe_call(agent_class, :fallback_models),
139
+ total_timeout: safe_call(agent_class, :total_timeout),
140
+ circuit_breaker: safe_call(agent_class, :circuit_breaker_config)
141
+ }
142
+ else # base agent
143
+ {
144
+ temperature: safe_call(agent_class, :temperature),
145
+ timeout: safe_call(agent_class, :timeout),
146
+ cache_enabled: safe_call(agent_class, :cache_enabled?) || false,
147
+ cache_ttl: safe_call(agent_class, :cache_ttl),
148
+ params: safe_call(agent_class, :params) || {},
149
+ retries: safe_call(agent_class, :retries),
150
+ fallback_models: safe_call(agent_class, :fallback_models),
151
+ total_timeout: safe_call(agent_class, :total_timeout),
152
+ circuit_breaker: safe_call(agent_class, :circuit_breaker_config)
153
+ }
154
+ end
155
+ end
156
+
59
157
  # Finds agent classes from the file system
60
158
  #
61
159
  # @return [Array<String>] Agent class names
@@ -295,7 +295,8 @@
295
295
  <% nav_items = [
296
296
  [ruby_llm_agents.root_path, "dashboard"],
297
297
  [ruby_llm_agents.agents_path, "agents"],
298
- [ruby_llm_agents.executions_path, "executions"]
298
+ [ruby_llm_agents.executions_path, "executions"],
299
+ [ruby_llm_agents.requests_path, "requests"]
299
300
  ]
300
301
  nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
301
302
  nav_items.each do |path, label| %>
@@ -345,7 +346,8 @@
345
346
  <% mobile_nav_items = [
346
347
  [ruby_llm_agents.root_path, "dashboard"],
347
348
  [ruby_llm_agents.agents_path, "agents"],
348
- [ruby_llm_agents.executions_path, "executions"]
349
+ [ruby_llm_agents.executions_path, "executions"],
350
+ [ruby_llm_agents.requests_path, "requests"]
349
351
  ]
350
352
  mobile_nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
351
353
  mobile_nav_items.each do |path, label| %>
@@ -2,26 +2,12 @@
2
2
  <% show_tenant_column = tenant_filter_enabled? && current_tenant_id.blank? %>
3
3
 
4
4
  <%
5
- # Inline sort helper
5
+ # Sort params for the sort_header_link helper
6
6
  sort_column = @sort_params[:column]
7
7
  sort_direction = @sort_params[:direction]
8
8
 
9
- sort_link = ->(column, label, align: "left", extra_class: "") {
10
- is_active = column == sort_column
11
- next_dir = is_active && sort_direction == "asc" ? "desc" : "asc"
12
- url = url_for(request.query_parameters.merge(sort: column, direction: next_dir, page: 1))
13
-
14
- arrow = if is_active && sort_direction == "asc"
15
- raw('<svg class="w-2.5 h-2.5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>')
16
- elsif is_active
17
- raw('<svg class="w-2.5 h-2.5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>')
18
- else
19
- ""
20
- end
21
-
22
- active_class = is_active ? "text-gray-700 dark:text-gray-300" : ""
23
-
24
- raw(%(<a href="#{url}" class="group inline-flex items-center gap-0.5 hover:text-gray-700 dark:hover:text-gray-300 #{active_class} #{extra_class}"><span>#{label}</span><span class="#{is_active ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'} transition-opacity">#{arrow}</span></a>))
9
+ sort_link = ->(column, label, extra_class: "") {
10
+ sort_header_link(column, label, current_column: sort_column, current_direction: sort_direction, extra_class: extra_class)
25
11
  }
26
12
  %>
27
13
 
@@ -0,0 +1,153 @@
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">requests</span>
3
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
4
+ <span class="font-mono text-[10px] text-gray-400 dark:text-gray-600">
5
+ <%= number_with_delimiter(@stats[:total_requests]) %> tracked
6
+ &middot;
7
+ $<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %> total
8
+ </span>
9
+ </div>
10
+
11
+ <%
12
+ sort_column = @sort_column
13
+ sort_direction = @sort_direction
14
+
15
+ sort_link = ->(column, label, extra_class: "") {
16
+ is_active = column == sort_column
17
+ next_dir = is_active && sort_direction == "asc" ? "desc" : "asc"
18
+ url = url_for(request.query_parameters.merge(sort: column, direction: next_dir, page: 1))
19
+
20
+ arrow = if is_active && sort_direction == "asc"
21
+ raw('<svg class="w-2.5 h-2.5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 15l7-7 7 7"/></svg>')
22
+ elsif is_active
23
+ raw('<svg class="w-2.5 h-2.5 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/></svg>')
24
+ else
25
+ ""
26
+ end
27
+
28
+ active_class = is_active ? "text-gray-700 dark:text-gray-300" : ""
29
+ raw(%(<a href="#{url}" class="group inline-flex items-center gap-0.5 hover:text-gray-700 dark:hover:text-gray-300 #{active_class} #{extra_class}"><span>#{label}</span><span class="#{is_active ? 'opacity-100' : 'opacity-0 group-hover:opacity-50'} transition-opacity">#{arrow}</span></a>))
30
+ }
31
+ %>
32
+
33
+ <% if @requests.empty? %>
34
+ <div class="font-mono text-xs text-gray-400 dark:text-gray-600 py-8 text-center">
35
+ No tracked requests found. Use <code class="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">RubyLLM::Agents.track { ... }</code> to start tracking.
36
+ </div>
37
+ <% else %>
38
+ <!-- Column headers -->
39
+ <div class="flex items-center gap-3 px-2 -mx-2 font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">
40
+ <span class="w-36 flex-shrink-0">request_id</span>
41
+ <span class="flex-1 min-w-0">agents</span>
42
+ <span class="w-12 flex-shrink-0 text-right"><%= sort_link.call("call_count", "calls", extra_class: "justify-end w-full") %></span>
43
+ <span class="w-14 flex-shrink-0 text-right hidden sm:block">status</span>
44
+ <span class="w-16 flex-shrink-0 text-right hidden md:block"><%= sort_link.call("total_duration_ms", "duration", extra_class: "justify-end w-full") %></span>
45
+ <span class="w-16 flex-shrink-0 text-right hidden md:block"><%= sort_link.call("total_tokens", "tokens", extra_class: "justify-end w-full") %></span>
46
+ <span class="w-16 flex-shrink-0 text-right"><%= sort_link.call("total_cost", "cost", extra_class: "justify-end w-full") %></span>
47
+ <span class="w-24 flex-shrink-0 text-right"><%= sort_link.call("latest_created_at", "time", extra_class: "justify-end w-full") %></span>
48
+ </div>
49
+
50
+ <!-- Rows -->
51
+ <div class="font-mono text-xs space-y-px">
52
+ <% @requests.each do |req| %>
53
+ <%
54
+ statuses = (req.statuses_list || "").split(",")
55
+ has_errors = statuses.include?("error") || statuses.include?("timeout")
56
+ all_success = statuses == ["success"]
57
+ status_class = if has_errors
58
+ "badge-error"
59
+ elsif all_success
60
+ "badge-success"
61
+ else
62
+ "badge-running"
63
+ end
64
+ status_label = if has_errors
65
+ "errors"
66
+ elsif all_success
67
+ "ok"
68
+ else
69
+ "mixed"
70
+ end
71
+ agent_names = (req.agent_types_list || "").split(",").map { |a| a.gsub(/Agent$/, "") }
72
+ %>
73
+ <div class="group flex items-center gap-3 py-1.5 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50 cursor-pointer"
74
+ onclick="window.location='<%= ruby_llm_agents.request_path(req.request_id) %>'">
75
+ <span class="w-36 flex-shrink-0 truncate text-gray-900 dark:text-gray-200" title="<%= req.request_id %>">
76
+ <%= truncate(req.request_id, length: 20) %>
77
+ </span>
78
+ <span class="flex-1 min-w-0 truncate text-gray-400 dark:text-gray-600">
79
+ <%= agent_names.first(3).join(", ") %><%= agent_names.size > 3 ? " +#{agent_names.size - 3}" : "" %>
80
+ </span>
81
+ <span class="w-12 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= req.call_count %></span>
82
+ <span class="w-14 flex-shrink-0 text-right hidden sm:block">
83
+ <span class="badge badge-sm <%= status_class %>"><%= status_label %></span>
84
+ </span>
85
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline">
86
+ <%= req.total_duration_ms ? format_duration_ms(req.total_duration_ms.to_i) : "—" %>
87
+ </span>
88
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline"><%= number_with_delimiter(req.total_tokens || 0) %></span>
89
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400">$<%= number_with_precision(req.total_cost || 0, precision: 4) %></span>
90
+ <span class="w-24 flex-shrink-0 text-gray-400 dark:text-gray-600 text-right whitespace-nowrap">
91
+ <% if req.respond_to?(:latest_created_at) && req.latest_created_at %>
92
+ <%= time_ago_in_words(req.latest_created_at) %>
93
+ <% else %>
94
+
95
+ <% end %>
96
+ </span>
97
+ </div>
98
+ <% end %>
99
+ </div>
100
+
101
+ <%# Pagination %>
102
+ <% if @pagination && @pagination[:total_pages] > 1 %>
103
+ <%
104
+ current_page = @pagination[:current_page]
105
+ total_pages = @pagination[:total_pages]
106
+ total_count = @pagination[:total_count]
107
+ per_page = @pagination[:per_page]
108
+ from_record = ((current_page - 1) * per_page) + 1
109
+ to_record = [current_page * per_page, total_count].min
110
+ %>
111
+ <div class="flex items-center justify-between font-mono text-xs mt-4 pt-4 border-t border-gray-200 dark:border-gray-800">
112
+ <span class="text-gray-400 dark:text-gray-600"><%= from_record %>-<%= to_record %> of <%= number_with_delimiter(total_count) %></span>
113
+ <nav class="flex items-center gap-1">
114
+ <% if current_page > 1 %>
115
+ <%= link_to "prev", url_for(request.query_parameters.merge(page: current_page - 1)),
116
+ class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
117
+ <% else %>
118
+ <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">prev</span>
119
+ <% end %>
120
+
121
+ <%
122
+ window = 2
123
+ pages_to_show = []
124
+ (1..total_pages).each do |page|
125
+ if page <= 1 || page >= total_pages || (page >= current_page - window && page <= current_page + window)
126
+ pages_to_show << page
127
+ elsif pages_to_show.last != :gap
128
+ pages_to_show << :gap
129
+ end
130
+ end
131
+ %>
132
+
133
+ <% pages_to_show.each do |page| %>
134
+ <% if page == :gap %>
135
+ <span class="px-1 text-gray-400 dark:text-gray-600">...</span>
136
+ <% elsif page == current_page %>
137
+ <span class="px-2 py-0.5 text-gray-900 dark:text-gray-100"><%= page %></span>
138
+ <% else %>
139
+ <%= link_to page, url_for(request.query_parameters.merge(page: page)),
140
+ class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
141
+ <% end %>
142
+ <% end %>
143
+
144
+ <% if current_page < total_pages %>
145
+ <%= link_to "next", url_for(request.query_parameters.merge(page: current_page + 1)),
146
+ class: "px-2 py-0.5 text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
147
+ <% else %>
148
+ <span class="px-2 py-0.5 text-gray-300 dark:text-gray-700">next</span>
149
+ <% end %>
150
+ </nav>
151
+ </div>
152
+ <% end %>
153
+ <% end %>