ruby_llm-agents 3.8.0 → 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 (45) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -0
  4. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  5. data/app/views/ruby_llm/agents/requests/index.html.erb +153 -0
  6. data/app/views/ruby_llm/agents/requests/show.html.erb +136 -0
  7. data/config/routes.rb +2 -0
  8. data/lib/generators/ruby_llm_agents/agent_generator.rb +2 -2
  9. data/lib/generators/ruby_llm_agents/demo_generator.rb +102 -0
  10. data/lib/generators/ruby_llm_agents/doctor_generator.rb +196 -0
  11. data/lib/generators/ruby_llm_agents/install_generator.rb +7 -19
  12. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +27 -80
  13. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +18 -51
  14. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +19 -17
  15. data/lib/ruby_llm/agents/base_agent.rb +68 -7
  16. data/lib/ruby_llm/agents/core/base.rb +4 -0
  17. data/lib/ruby_llm/agents/core/configuration.rb +10 -0
  18. data/lib/ruby_llm/agents/core/version.rb +1 -1
  19. data/lib/ruby_llm/agents/pipeline/context.rb +26 -0
  20. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +58 -4
  21. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +17 -15
  22. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +34 -22
  23. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +105 -50
  24. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +7 -5
  25. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +6 -4
  26. data/lib/ruby_llm/agents/rails/engine.rb +11 -0
  27. data/lib/ruby_llm/agents/results/background_removal_result.rb +7 -1
  28. data/lib/ruby_llm/agents/results/base.rb +24 -2
  29. data/lib/ruby_llm/agents/results/embedding_result.rb +4 -0
  30. data/lib/ruby_llm/agents/results/image_analysis_result.rb +7 -1
  31. data/lib/ruby_llm/agents/results/image_edit_result.rb +7 -1
  32. data/lib/ruby_llm/agents/results/image_generation_result.rb +7 -1
  33. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +7 -1
  34. data/lib/ruby_llm/agents/results/image_transform_result.rb +7 -1
  35. data/lib/ruby_llm/agents/results/image_upscale_result.rb +7 -1
  36. data/lib/ruby_llm/agents/results/image_variation_result.rb +7 -1
  37. data/lib/ruby_llm/agents/results/speech_result.rb +6 -0
  38. data/lib/ruby_llm/agents/results/trackable.rb +25 -0
  39. data/lib/ruby_llm/agents/results/transcription_result.rb +6 -0
  40. data/lib/ruby_llm/agents/text/embedder.rb +7 -4
  41. data/lib/ruby_llm/agents/track_report.rb +127 -0
  42. data/lib/ruby_llm/agents/tracker.rb +32 -0
  43. data/lib/ruby_llm/agents.rb +208 -0
  44. data/lib/tasks/ruby_llm_agents.rake +6 -0
  45. metadata +10 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2d24a8046e58bdadf37d70ddb1effb038f69b1fa01c4dd7f9e08bf819527d94
4
- data.tar.gz: 0f620acc86ebe001c151c6a95ee784463bb1e10eb66532e7f6d2684ca2021f06
3
+ metadata.gz: 6925b14509a50c3bbf5efb8d0e37f5a38b4d2f52f13f3c7ecd7af588f832c5c1
4
+ data.tar.gz: 4332d03ebaf4bd94f3e314656e7f6755475c5ad45661a8c1e76e6e324d37ec2e
5
5
  SHA512:
6
- metadata.gz: 9bd06ce2399f86359e00a115d269af22d3f73a0ad8a1bc0d21e315c4a41b0a023bd59b9b9504ac3902d65c76195467f6ae232caed955b5b247439e27aca59b7e
7
- data.tar.gz: bb2b29a3b6d7c3c4b0312103fe98bd8c3744dc42415280fda8bbb41054c414f489954c1ee869f6b21cb4e98a470eb7f7e9cf8fc63cc05c0810d4f1b635c9bd55
6
+ metadata.gz: e12ea68a14b7c9ec683ae7b07a14d9d422922fc8e5233d7296fd42bc6100097bb929da87b6c52fe55929779a17c04e59253fa490c40147558392a32a79023fa6
7
+ data.tar.gz: 9d0d5d249ee0bb6bac741d5c1087ade27ba0a6d032184bb01e1ecfc0d43f79453fbd12cd22e9e4f3eeca7b457bfb242ebca7ebc4e010f44387b9ee8a13bf0865
data/README.md CHANGED
@@ -206,7 +206,7 @@ puts run.summary
206
206
 
207
207
  ## Quick Start
208
208
 
209
- ### Installation
209
+ ### 1. Install
210
210
 
211
211
  ```ruby
212
212
  # Gemfile
@@ -219,29 +219,49 @@ rails generate ruby_llm_agents:install
219
219
  rails db:migrate
220
220
  ```
221
221
 
222
- ### Configure API Keys
222
+ ### 2. Set one API key
223
223
 
224
- Configure all provider API keys in one place (v2.1+):
224
+ Uncomment one line in `config/initializers/ruby_llm_agents.rb`:
225
225
 
226
226
  ```ruby
227
- # config/initializers/ruby_llm_agents.rb
228
227
  RubyLLM::Agents.configure do |config|
229
228
  config.openai_api_key = ENV["OPENAI_API_KEY"]
230
- config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
231
- config.gemini_api_key = ENV["GOOGLE_API_KEY"]
232
229
  end
233
230
  ```
234
231
 
235
- Or use environment variables directly (auto-detected by RubyLLM):
232
+ Then set the environment variable (e.g., in `.env` or Rails credentials).
233
+
234
+ <details>
235
+ <summary>Other providers</summary>
236
236
 
237
+ ```ruby
238
+ config.anthropic_api_key = ENV["ANTHROPIC_API_KEY"]
239
+ config.gemini_api_key = ENV["GOOGLE_API_KEY"]
240
+ config.deepseek_api_key = ENV["DEEPSEEK_API_KEY"]
241
+ # ... see initializer for full list
242
+ ```
243
+
244
+ Or use environment variables directly (auto-detected by RubyLLM):
237
245
  ```bash
238
- # .env
239
246
  OPENAI_API_KEY=sk-...
240
247
  ANTHROPIC_API_KEY=sk-ant-...
241
- GOOGLE_API_KEY=...
248
+ ```
249
+ </details>
250
+
251
+ ### 3. Verify setup
252
+
253
+ ```bash
254
+ rails ruby_llm_agents:doctor
255
+ ```
256
+
257
+ ### 4. Try it
258
+
259
+ ```bash
260
+ rails generate ruby_llm_agents:demo
261
+ bin/rails runner bin/smoke_test_agent
242
262
  ```
243
263
 
244
- ### Generate an Agent
264
+ Or generate a custom agent:
245
265
 
246
266
  ```bash
247
267
  rails generate ruby_llm_agents:agent SearchIntent query:required
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Controller for browsing tracked request groups
6
+ #
7
+ # Provides listing and detail views for executions grouped by
8
+ # request_id, as set by RubyLLM::Agents.track blocks.
9
+ #
10
+ # @api private
11
+ class RequestsController < ApplicationController
12
+ include Paginatable
13
+
14
+ # Lists all tracked requests with aggregated stats
15
+ #
16
+ # @return [void]
17
+ def index
18
+ @sort_column = sanitize_sort_column(params[:sort])
19
+ @sort_direction = (params[:direction] == "asc") ? "asc" : "desc"
20
+
21
+ scope = Execution
22
+ .where.not(request_id: [nil, ""])
23
+ .select(
24
+ "request_id",
25
+ "COUNT(*) AS call_count",
26
+ "SUM(total_cost) AS total_cost",
27
+ "SUM(total_tokens) AS total_tokens",
28
+ "MIN(started_at) AS started_at",
29
+ "MAX(completed_at) AS completed_at",
30
+ "SUM(duration_ms) AS total_duration_ms",
31
+ "GROUP_CONCAT(DISTINCT agent_type) AS agent_types_list",
32
+ "GROUP_CONCAT(DISTINCT status) AS statuses_list",
33
+ "MAX(created_at) AS latest_created_at"
34
+ )
35
+ .group(:request_id)
36
+
37
+ # Apply time filter
38
+ days = params[:days].to_i
39
+ scope = scope.where("created_at >= ?", days.days.ago) if days > 0
40
+
41
+ result = paginate_requests(scope)
42
+ @requests = result[:records]
43
+ @pagination = result[:pagination]
44
+
45
+ # Stats
46
+ total_scope = Execution.where.not(request_id: [nil, ""])
47
+ @stats = {
48
+ total_requests: total_scope.distinct.count(:request_id),
49
+ total_cost: total_scope.sum(:total_cost) || 0
50
+ }
51
+ end
52
+
53
+ # Shows a single tracked request with all its executions
54
+ #
55
+ # @return [void]
56
+ def show
57
+ @request_id = params[:id]
58
+ @executions = Execution
59
+ .where(request_id: @request_id)
60
+ .order(started_at: :asc)
61
+
62
+ if @executions.empty?
63
+ redirect_to ruby_llm_agents.requests_path,
64
+ alert: "Request not found: #{@request_id}"
65
+ return
66
+ end
67
+
68
+ @summary = {
69
+ call_count: @executions.count,
70
+ total_cost: @executions.sum(:total_cost) || 0,
71
+ total_tokens: @executions.sum(:total_tokens) || 0,
72
+ started_at: @executions.minimum(:started_at),
73
+ completed_at: @executions.maximum(:completed_at),
74
+ agent_types: @executions.distinct.pluck(:agent_type),
75
+ models_used: @executions.distinct.pluck(:model_id),
76
+ all_successful: @executions.where.not(status: "success").count.zero?,
77
+ error_count: @executions.where(status: "error").count
78
+ }
79
+
80
+ if @summary[:started_at] && @summary[:completed_at]
81
+ @summary[:duration_ms] = ((@summary[:completed_at] - @summary[:started_at]) * 1000).to_i
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ ALLOWED_SORT_COLUMNS = %w[latest_created_at call_count total_cost total_tokens total_duration_ms].freeze
88
+
89
+ def sanitize_sort_column(column)
90
+ ALLOWED_SORT_COLUMNS.include?(column) ? column : "latest_created_at"
91
+ end
92
+
93
+ def paginate_requests(scope)
94
+ page = [(params[:page] || 1).to_i, 1].max
95
+ per_page = RubyLLM::Agents.configuration.per_page
96
+
97
+ total_count = Execution
98
+ .where.not(request_id: [nil, ""])
99
+ .distinct
100
+ .count(:request_id)
101
+
102
+ sorted = scope.order("#{@sort_column} #{@sort_direction.upcase}")
103
+ offset = (page - 1) * per_page
104
+
105
+ {
106
+ records: sorted.offset(offset).limit(per_page),
107
+ pagination: {
108
+ current_page: page,
109
+ per_page: per_page,
110
+ total_count: total_count,
111
+ total_pages: (total_count.to_f / per_page).ceil
112
+ }
113
+ }
114
+ end
115
+ end
116
+ end
117
+ end
@@ -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| %>
@@ -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 %>
@@ -0,0 +1,136 @@
1
+ <div class="flex items-center gap-3 mb-6">
2
+ <%= 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 %>
3
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
4
+ <path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7"/>
5
+ </svg>
6
+ <% end %>
7
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">request</span>
8
+ <code class="font-mono text-xs text-gray-900 dark:text-gray-200 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded"><%= @request_id %></code>
9
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
10
+ <% if @summary[:all_successful] %>
11
+ <span class="badge badge-sm badge-success">all ok</span>
12
+ <% elsif @summary[:error_count] > 0 %>
13
+ <span class="badge badge-sm badge-error"><%= @summary[:error_count] %> error<%= @summary[:error_count] > 1 ? "s" : "" %></span>
14
+ <% end %>
15
+ </div>
16
+
17
+ <!-- Summary Stats -->
18
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
19
+ <div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg px-4 py-3">
20
+ <div class="font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">calls</div>
21
+ <div class="font-mono text-lg text-gray-900 dark:text-gray-100"><%= @summary[:call_count] %></div>
22
+ </div>
23
+ <div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg px-4 py-3">
24
+ <div class="font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">total cost</div>
25
+ <div class="font-mono text-lg text-gray-900 dark:text-gray-100">$<%= number_with_precision(@summary[:total_cost] || 0, precision: 4) %></div>
26
+ </div>
27
+ <div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg px-4 py-3">
28
+ <div class="font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">tokens</div>
29
+ <div class="font-mono text-lg text-gray-900 dark:text-gray-100"><%= number_with_delimiter(@summary[:total_tokens] || 0) %></div>
30
+ </div>
31
+ <div class="bg-gray-50 dark:bg-gray-800/50 rounded-lg px-4 py-3">
32
+ <div class="font-mono text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider mb-1">duration</div>
33
+ <div class="font-mono text-lg text-gray-900 dark:text-gray-100">
34
+ <%= @summary[:duration_ms] ? format_duration_ms(@summary[:duration_ms]) : "—" %>
35
+ </div>
36
+ </div>
37
+ </div>
38
+
39
+ <!-- Agents & Models -->
40
+ <div class="flex flex-wrap gap-2 mb-6">
41
+ <% @summary[:agent_types].each do |agent_type| %>
42
+ <%= link_to agent_type.gsub(/Agent$/, ""),
43
+ ruby_llm_agents.agent_path(agent_type),
44
+ 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" %>
45
+ <% end %>
46
+ <span class="w-px h-4 bg-gray-200 dark:bg-gray-700 self-center"></span>
47
+ <% @summary[:models_used].each do |model| %>
48
+ <span class="font-mono text-xs px-2 py-0.5 rounded bg-gray-50 dark:bg-gray-800/30 text-gray-400 dark:text-gray-600"><%= model %></span>
49
+ <% end %>
50
+ </div>
51
+
52
+ <!-- Execution Timeline -->
53
+ <div class="mb-2">
54
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">execution timeline</span>
55
+ </div>
56
+
57
+ <div class="space-y-px">
58
+ <!-- Column headers -->
59
+ <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">
60
+ <span class="w-5 flex-shrink-0 text-center">#</span>
61
+ <span class="flex-1 min-w-0">agent</span>
62
+ <span class="w-20 flex-shrink-0">status</span>
63
+ <span class="flex-1 min-w-0 hidden lg:block">model</span>
64
+ <span class="w-16 flex-shrink-0 text-right">duration</span>
65
+ <span class="w-16 flex-shrink-0 text-right hidden md:block">tokens</span>
66
+ <span class="w-16 flex-shrink-0 text-right hidden md:block">cost</span>
67
+ </div>
68
+
69
+ <div class="font-mono text-xs">
70
+ <% @executions.each_with_index do |execution, idx| %>
71
+ <%
72
+ status_badge_class = case execution.status.to_s
73
+ when "running" then "badge-running"
74
+ when "success" then "badge-success"
75
+ when "error" then "badge-error"
76
+ when "timeout" then "badge-timeout"
77
+ else "badge-timeout"
78
+ end
79
+ %>
80
+ <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"
81
+ onclick="window.location='<%= ruby_llm_agents.execution_path(execution) %>'">
82
+ <span class="w-5 flex-shrink-0 text-center text-gray-300 dark:text-gray-700"><%= idx + 1 %></span>
83
+ <span class="flex-1 min-w-0 truncate">
84
+ <%= link_to execution.agent_type.gsub(/Agent$/, ""),
85
+ ruby_llm_agents.agent_path(execution.agent_type),
86
+ class: "text-gray-900 dark:text-gray-200 hover:underline",
87
+ onclick: "event.stopPropagation()" %>
88
+ </span>
89
+ <span class="w-20 flex-shrink-0">
90
+ <span class="badge badge-sm <%= status_badge_class %>"><%= execution.status %></span>
91
+ </span>
92
+ <span class="flex-1 min-w-0 truncate text-gray-400 dark:text-gray-600 hidden lg:inline" title="<%= execution.model_id %>"><%= execution.model_id %></span>
93
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400">
94
+ <% if execution.status_running? %>
95
+ <span class="text-blue-500 animate-pulse">...</span>
96
+ <% else %>
97
+ <%= execution.duration_ms ? format_duration_ms(execution.duration_ms) : "—" %>
98
+ <% end %>
99
+ </span>
100
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline"><%= number_with_delimiter(execution.total_tokens || 0) %></span>
101
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400 hidden md:inline">$<%= number_with_precision(execution.total_cost || 0, precision: 4) %></span>
102
+ </div>
103
+ <% if execution.status_error? && execution.error_class.present? %>
104
+ <div class="flex items-center gap-1 pl-8 py-0.5 text-red-400 dark:text-red-500/70 text-xs font-mono">
105
+ <span class="text-gray-300 dark:text-gray-700">&#x2514;</span>
106
+ <span class="truncate"><%= execution.error_class.split("::").last %><%= execution.error_message.present? ? ": #{truncate(execution.error_message, length: 100)}" : "" %></span>
107
+ </div>
108
+ <% end %>
109
+ <% end %>
110
+ </div>
111
+ </div>
112
+
113
+ <!-- Timing Details -->
114
+ <% if @summary[:started_at] %>
115
+ <div class="mt-6 pt-4 border-t border-gray-200 dark:border-gray-800">
116
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">timing</span>
117
+ <div class="mt-2 font-mono text-xs text-gray-500 dark:text-gray-400 space-y-1">
118
+ <div class="flex gap-4">
119
+ <span class="text-gray-400 dark:text-gray-600 w-20">started</span>
120
+ <span><%= @summary[:started_at].strftime("%Y-%m-%d %H:%M:%S.%L") %></span>
121
+ </div>
122
+ <% if @summary[:completed_at] %>
123
+ <div class="flex gap-4">
124
+ <span class="text-gray-400 dark:text-gray-600 w-20">completed</span>
125
+ <span><%= @summary[:completed_at].strftime("%Y-%m-%d %H:%M:%S.%L") %></span>
126
+ </div>
127
+ <% end %>
128
+ <% if @summary[:duration_ms] %>
129
+ <div class="flex gap-4">
130
+ <span class="text-gray-400 dark:text-gray-600 w-20">wall clock</span>
131
+ <span><%= format_duration_ms(@summary[:duration_ms]) %></span>
132
+ </div>
133
+ <% end %>
134
+ </div>
135
+ </div>
136
+ <% end %>
data/config/routes.rb CHANGED
@@ -13,6 +13,8 @@ RubyLLM::Agents::Engine.routes.draw do
13
13
  end
14
14
  end
15
15
 
16
+ resources :requests, only: [:index, :show]
17
+
16
18
  resources :tenants, only: [:index, :show, :edit, :update]
17
19
 
18
20
  # Redirect old analytics route to dashboard
@@ -21,8 +21,8 @@ module RubyLlmAgents
21
21
 
22
22
  argument :params, type: :array, default: [], banner: "param[:required|:default] param[:required|:default]"
23
23
 
24
- class_option :model, type: :string, default: "gemini-2.0-flash",
25
- desc: "The LLM model to use"
24
+ class_option :model, type: :string, default: "default",
25
+ desc: "The LLM model to use (omit to use configured default)"
26
26
  class_option :temperature, type: :numeric, default: 0.0,
27
27
  desc: "The temperature setting (0.0-1.0)"
28
28
  class_option :cache, type: :string, default: nil,
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module RubyLlmAgents
6
+ # Demo generator — scaffolds a working HelloAgent with a smoke-test script.
7
+ #
8
+ # Usage:
9
+ # rails generate ruby_llm_agents:demo
10
+ #
11
+ # Creates:
12
+ # - app/agents/hello_agent.rb — minimal working agent
13
+ # - bin/smoke_test_agent — one-command verification script
14
+ #
15
+ class DemoGenerator < ::Rails::Generators::Base
16
+ source_root File.expand_path("templates", __dir__)
17
+
18
+ def ensure_base_class
19
+ agents_dir = "app/agents"
20
+ empty_directory agents_dir
21
+
22
+ base_class_path = "#{agents_dir}/application_agent.rb"
23
+ unless File.exist?(File.join(destination_root, base_class_path))
24
+ template "application_agent.rb.tt", base_class_path
25
+ end
26
+ end
27
+
28
+ def create_hello_agent
29
+ create_file "app/agents/hello_agent.rb", <<~RUBY
30
+ # frozen_string_literal: true
31
+
32
+ class HelloAgent < ApplicationAgent
33
+ system "You are a friendly assistant. Keep responses under 2 sentences."
34
+
35
+ prompt "Say hello to {name} and tell them one fun fact."
36
+ end
37
+ RUBY
38
+ end
39
+
40
+ def create_smoke_test
41
+ create_file "bin/smoke_test_agent", <<~RUBY
42
+ #!/usr/bin/env ruby
43
+ # frozen_string_literal: true
44
+
45
+ # Smoke test — verifies your RubyLLM::Agents setup end-to-end.
46
+ #
47
+ # Usage:
48
+ # bin/rails runner bin/smoke_test_agent
49
+ #
50
+ puts "Running RubyLLM::Agents smoke test..."
51
+ puts ""
52
+
53
+ # 1. Check configuration
54
+ config = RubyLLM::Agents.configuration
55
+ model = config.default_model
56
+ puts "Default model: \#{model}"
57
+
58
+ # 2. Dry-run (no API call)
59
+ puts ""
60
+ puts "Dry run:"
61
+ dry = HelloAgent.call(name: "World", dry_run: true)
62
+ puts " System prompt: \#{dry.system_prompt[0..80]}..."
63
+ puts " User prompt: \#{dry.user_prompt}"
64
+ puts " Model: \#{dry.model}"
65
+ puts " Dry run OK!"
66
+
67
+ # 3. Live call
68
+ puts ""
69
+ puts "Live call (calling \#{model})..."
70
+ begin
71
+ result = HelloAgent.call(name: "World")
72
+ puts " Response: \#{result.content}"
73
+ puts ""
74
+ puts "Success! Your setup is working."
75
+ rescue => e
76
+ puts " Error: \#{e.class}: \#{e.message}"
77
+ puts ""
78
+ puts "The dry run worked but the live call failed."
79
+ puts "This usually means your API key is missing or invalid."
80
+ puts ""
81
+ puts "Run 'rails ruby_llm_agents:doctor' for detailed diagnostics."
82
+ exit 1
83
+ end
84
+ RUBY
85
+
86
+ chmod "bin/smoke_test_agent", 0o755
87
+ end
88
+
89
+ def show_next_steps
90
+ say ""
91
+ say "Demo agent created!", :green
92
+ say ""
93
+ say "Try it:"
94
+ say " bin/rails runner bin/smoke_test_agent"
95
+ say ""
96
+ say "Or in the Rails console:"
97
+ say " HelloAgent.call(name: \"World\")"
98
+ say " HelloAgent.call(name: \"World\", dry_run: true)"
99
+ say ""
100
+ end
101
+ end
102
+ end