ruby_llm-agents 3.11.0 → 3.12.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/agents_controller.rb +74 -0
  3. data/app/controllers/ruby_llm/agents/analytics_controller.rb +304 -0
  4. data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
  5. data/app/models/ruby_llm/agents/agent_override.rb +47 -0
  6. data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
  7. data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
  8. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  9. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +89 -4
  10. data/app/views/ruby_llm/agents/agents/show.html.erb +14 -0
  11. data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
  12. data/app/views/ruby_llm/agents/tenants/index.html.erb +3 -2
  13. data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
  14. data/config/routes.rb +12 -4
  15. data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
  16. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
  17. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
  18. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
  19. data/lib/ruby_llm/agents/base_agent.rb +90 -133
  20. data/lib/ruby_llm/agents/core/base.rb +9 -0
  21. data/lib/ruby_llm/agents/core/configuration.rb +5 -1
  22. data/lib/ruby_llm/agents/core/version.rb +1 -1
  23. data/lib/ruby_llm/agents/dsl/base.rb +131 -4
  24. data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
  25. data/lib/ruby_llm/agents/dsl.rb +1 -1
  26. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  27. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  28. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  29. data/lib/ruby_llm/agents/stream_event.rb +2 -10
  30. data/lib/ruby_llm/agents/tool.rb +1 -1
  31. data/lib/ruby_llm/agents.rb +0 -3
  32. metadata +6 -3
  33. data/lib/ruby_llm/agents/agent_tool.rb +0 -143
  34. data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
@@ -34,6 +34,11 @@
34
34
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
35
35
  </svg>
36
36
  <% end %>
37
+ <%= button_to refresh_counters_tenant_path(@tenant), method: :post, class: "text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 transition-colors", title: "Refresh budget counters", form: { style: "display:inline" } do %>
38
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
39
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
40
+ </svg>
41
+ <% end %>
37
42
  <%= render "ruby_llm/agents/shared/doc_link" %>
38
43
  </div>
39
44
  <div class="font-mono text-xs text-gray-400 dark:text-gray-500 flex items-center gap-1.5 flex-wrap">
@@ -70,6 +75,153 @@
70
75
  </div>
71
76
  <div class="border-t border-gray-200 dark:border-gray-800 mb-2"></div>
72
77
 
78
+ <!-- ── period comparison ──────────────── -->
79
+ <% if @period_comparison %>
80
+ <%
81
+ pc = @period_comparison
82
+ tm = pc[:this_month]
83
+ lm = pc[:last_month]
84
+ %>
85
+ <div class="flex items-center gap-3 mt-6 mb-3">
86
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">this month vs last month</span>
87
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
88
+ </div>
89
+
90
+ <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 font-mono text-xs">
91
+ <% [
92
+ ["cost", tm[:cost], lm[:cost], pc[:cost_change], "$"],
93
+ ["avg cost/run", pc[:avg_cost_this], pc[:avg_cost_last], pc[:avg_cost_change], "$"],
94
+ ["runs", tm[:executions], lm[:executions], pc[:executions_change], ""],
95
+ ["tokens", tm[:tokens], lm[:tokens], pc[:tokens_change], ""]
96
+ ].each do |label, current, previous, change, prefix| %>
97
+ <div class="space-y-0.5">
98
+ <div class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider"><%= label %></div>
99
+ <div class="text-gray-900 dark:text-gray-200">
100
+ <% if prefix == "$" %>
101
+ $<%= number_with_precision(current, precision: 4) %>
102
+ <% else %>
103
+ <%= number_with_delimiter(current) %>
104
+ <% end %>
105
+ </div>
106
+ <div class="flex items-center gap-1">
107
+ <% if change != 0 %>
108
+ <span class="<%= change > 0 ? 'text-red-500' : 'text-green-500' %>">
109
+ <%= change > 0 ? "+" : "" %><%= change %>%
110
+ </span>
111
+ <% else %>
112
+ <span class="text-gray-400 dark:text-gray-600">—</span>
113
+ <% end %>
114
+ <span class="text-gray-400 dark:text-gray-600">
115
+ vs
116
+ <% if prefix == "$" %>
117
+ $<%= number_with_precision(previous, precision: 4) %>
118
+ <% else %>
119
+ <%= number_with_delimiter(previous) %>
120
+ <% end %>
121
+ </span>
122
+ </div>
123
+ </div>
124
+ <% end %>
125
+ </div>
126
+
127
+ <% if @error_count > 0 %>
128
+ <div class="mt-3 font-mono text-xs text-gray-500 dark:text-gray-400">
129
+ <span class="text-red-500">$<%= number_with_precision(@error_cost, precision: 4) %></span>
130
+ wasted on <span class="text-red-500"><%= number_with_delimiter(@error_count) %></span> failed executions
131
+ <% error_pct = total_cost.to_f > 0 ? (@error_cost.to_f / total_cost * 100).round(1) : 0 %>
132
+ <% if error_pct > 0 %>
133
+ (<%= error_pct %>% of total cost)
134
+ <% end %>
135
+ </div>
136
+ <% end %>
137
+ <% end %>
138
+
139
+ <!-- ── 30d cost trend ──────────────── -->
140
+ <% if @daily_trend.present? %>
141
+ <div class="flex items-center gap-3 mt-6 mb-2">
142
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">last 30 days</span>
143
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
144
+ </div>
145
+ <div id="tenant-trend-chart" style="width: 100%; height: 160px;"></div>
146
+ <script>
147
+ (function() {
148
+ const trendData = <%= raw @daily_trend.map { |date, stats|
149
+ { date: date.to_s, cost: (stats[:cost] || 0).to_f.round(6),
150
+ tokens: (stats[:tokens] || 0).to_i, count: (stats[:count] || 0).to_i }
151
+ }.to_json %>;
152
+
153
+ const costData = trendData.map(d => [new Date(d.date).getTime(), d.cost]);
154
+ const countData = trendData.map(d => [new Date(d.date).getTime(), d.count]);
155
+
156
+ function renderChart() {
157
+ window.__initHighchartsDefaults && window.__initHighchartsDefaults();
158
+ Highcharts.chart('tenant-trend-chart', {
159
+ chart: { backgroundColor: 'transparent', spacing: [5, 0, 0, 0] },
160
+ title: { text: null },
161
+ xAxis: {
162
+ type: 'datetime',
163
+ labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '{value:%b %d}' },
164
+ lineColor: 'transparent', tickLength: 0, gridLineWidth: 0
165
+ },
166
+ yAxis: [
167
+ {
168
+ title: { text: null }, min: 0,
169
+ labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' }, format: '${value}' },
170
+ gridLineColor: chartColorAlpha('rgba(107, 114, 128, 0.08)', 146, 131, 116, 0.08)
171
+ },
172
+ {
173
+ title: { text: null }, min: 0, opposite: true, allowDecimals: false,
174
+ labels: { style: { color: chartColor('#6B7280', '#928374'), fontSize: '9px', fontFamily: 'ui-monospace, monospace' } },
175
+ gridLineWidth: 0
176
+ }
177
+ ],
178
+ legend: { enabled: false },
179
+ credits: { enabled: false },
180
+ tooltip: {
181
+ backgroundColor: chartColorAlpha('rgba(0, 0, 0, 0.85)', 40, 40, 40, 0.95),
182
+ borderColor: 'transparent', borderRadius: 3,
183
+ style: { color: chartColor('#E5E7EB', '#ebdbb2'), fontSize: '10px', fontFamily: 'ui-monospace, monospace' },
184
+ shared: true,
185
+ formatter: function() {
186
+ let html = '<span style="color:' + chartColor('#9CA3AF', '#bdae93') + '">' + Highcharts.dateFormat('%b %d', this.x) + '</span>';
187
+ let d = trendData.find(d => new Date(d.date).getTime() === this.x);
188
+ if (d) {
189
+ html += '<br/>cost: <b>$' + d.cost.toFixed(4) + '</b>';
190
+ html += '<br/>runs: <b>' + d.count + '</b>';
191
+ html += '<br/>tokens: <b>' + d.tokens.toLocaleString() + '</b>';
192
+ }
193
+ return html;
194
+ }
195
+ },
196
+ series: [
197
+ {
198
+ name: 'cost', type: 'areaspline', yAxis: 0, data: costData,
199
+ color: chartColor('#8B5CF6', '#d3869b'),
200
+ lineWidth: 1.5, marker: { enabled: false, states: { hover: { enabled: true, radius: 2 } } },
201
+ fillColor: { linearGradient: { x1: 0, y1: 0, x2: 0, y2: 1 },
202
+ stops: [[0, chartColorAlpha('rgba(139, 92, 246, 0.12)', 211, 134, 155, 0.12)], [1, chartColorAlpha('rgba(139, 92, 246, 0)', 211, 134, 155, 0)]] }
203
+ },
204
+ {
205
+ name: 'runs', type: 'column', yAxis: 1, data: countData,
206
+ color: chartColorAlpha('rgba(107, 114, 128, 0.15)', 146, 131, 116, 0.15),
207
+ borderWidth: 0, pointPadding: 0.1, groupPadding: 0
208
+ }
209
+ ]
210
+ });
211
+ }
212
+
213
+ function waitForHighcharts(attempts) {
214
+ if (typeof Highcharts !== 'undefined') { renderChart(); }
215
+ else if (attempts > 0) { setTimeout(function() { waitForHighcharts(attempts - 1); }, 100); }
216
+ }
217
+
218
+ document.readyState === 'loading'
219
+ ? document.addEventListener('DOMContentLoaded', function() { waitForHighcharts(50); })
220
+ : waitForHighcharts(50);
221
+ })();
222
+ </script>
223
+ <% end %>
224
+
73
225
  <% if has_any_limit %>
74
226
  <!-- ── budget ──────────────────────── -->
75
227
  <div class="flex items-center gap-3 mt-6 mb-3">
@@ -166,6 +318,79 @@
166
318
  </div>
167
319
  <% end %>
168
320
 
321
+ <% if @usage_by_agent.present? %>
322
+ <!-- ── usage by agent ──────────────────── -->
323
+ <div class="flex items-center gap-3 mt-6 mb-3">
324
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by agent</span>
325
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
326
+ </div>
327
+
328
+ <!-- Column headers -->
329
+ <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">
330
+ <span class="flex-[2] min-w-0">agent</span>
331
+ <span class="w-16 flex-shrink-0 text-right">runs</span>
332
+ <span class="w-24 flex-shrink-0 text-right">cost</span>
333
+ <span class="w-20 flex-shrink-0 text-right">avg cost</span>
334
+ <span class="w-24 flex-shrink-0 text-right">tokens</span>
335
+ <span class="w-20 flex-shrink-0 text-right">avg tokens</span>
336
+ </div>
337
+
338
+ <div class="font-mono text-xs space-y-px">
339
+ <% @usage_by_agent.sort_by { |_, v| -v[:cost] }.each do |agent_type, stats| %>
340
+ <%
341
+ avg_cost = stats[:count] > 0 ? (stats[:cost].to_f / stats[:count]) : 0
342
+ avg_tokens = stats[:count] > 0 ? (stats[:tokens].to_f / stats[:count]).round : 0
343
+ %>
344
+ <div class="flex items-center gap-3 py-1 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50">
345
+ <span class="flex-[2] min-w-0 truncate">
346
+ <%= link_to agent_type.to_s.demodulize, ruby_llm_agents.agent_path(agent_type),
347
+ class: "text-gray-900 dark:text-gray-200 hover:text-gray-600 dark:hover:text-gray-400" %>
348
+ </span>
349
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= number_with_delimiter(stats[:count]) %></span>
350
+ <span class="w-24 flex-shrink-0 text-right text-gray-800 dark:text-gray-200">$<%= number_with_precision(stats[:cost], precision: 4) %></span>
351
+ <span class="w-20 flex-shrink-0 text-right text-gray-500 dark:text-gray-400">$<%= number_with_precision(avg_cost, precision: 4) %></span>
352
+ <span class="w-24 flex-shrink-0 text-right text-gray-600 dark:text-gray-400"><%= number_with_delimiter(stats[:tokens]) %></span>
353
+ <span class="w-20 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= number_with_delimiter(avg_tokens) %></span>
354
+ </div>
355
+ <% end %>
356
+ </div>
357
+ <% end %>
358
+
359
+ <% if @usage_by_model.present? %>
360
+ <!-- ── usage by model ──────────────────── -->
361
+ <div class="flex items-center gap-3 mt-6 mb-3">
362
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">usage by model</span>
363
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
364
+ </div>
365
+
366
+ <!-- Column headers -->
367
+ <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">
368
+ <span class="flex-[2] min-w-0">model</span>
369
+ <span class="w-16 flex-shrink-0 text-right">runs</span>
370
+ <span class="w-24 flex-shrink-0 text-right">cost</span>
371
+ <span class="w-20 flex-shrink-0 text-right">avg cost</span>
372
+ <span class="w-24 flex-shrink-0 text-right">tokens</span>
373
+ <span class="w-16 flex-shrink-0 text-right">% of cost</span>
374
+ </div>
375
+
376
+ <div class="font-mono text-xs space-y-px">
377
+ <% @usage_by_model.sort_by { |_, v| -v[:cost] }.each do |model_id, stats| %>
378
+ <%
379
+ avg_cost = stats[:count] > 0 ? (stats[:cost].to_f / stats[:count]) : 0
380
+ cost_pct = total_cost.to_f > 0 ? (stats[:cost].to_f / total_cost * 100).round(1) : 0
381
+ %>
382
+ <div class="flex items-center gap-3 py-1 px-2 -mx-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800/50">
383
+ <span class="flex-[2] min-w-0 truncate text-gray-900 dark:text-gray-200"><%= model_id %></span>
384
+ <span class="w-16 flex-shrink-0 text-right text-gray-500 dark:text-gray-400"><%= number_with_delimiter(stats[:count]) %></span>
385
+ <span class="w-24 flex-shrink-0 text-right text-gray-800 dark:text-gray-200">$<%= number_with_precision(stats[:cost], precision: 4) %></span>
386
+ <span class="w-20 flex-shrink-0 text-right text-gray-500 dark:text-gray-400">$<%= number_with_precision(avg_cost, precision: 4) %></span>
387
+ <span class="w-24 flex-shrink-0 text-right text-gray-600 dark:text-gray-400"><%= number_with_delimiter(stats[:tokens]) %></span>
388
+ <span class="w-16 flex-shrink-0 text-right text-gray-400 dark:text-gray-600"><%= cost_pct %>%</span>
389
+ </div>
390
+ <% end %>
391
+ </div>
392
+ <% end %>
393
+
169
394
  <!-- ── recent executions ──────────────────── -->
170
395
  <div class="flex items-center gap-3 mt-6 mb-3">
171
396
  <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">recent executions</span>
data/config/routes.rb CHANGED
@@ -4,7 +4,11 @@ RubyLLM::Agents::Engine.routes.draw do
4
4
  root to: "dashboard#index"
5
5
  get "chart_data", to: "dashboard#chart_data"
6
6
 
7
- resources :agents, only: [:index, :show]
7
+ resources :agents, only: [:index, :show, :update] do
8
+ member do
9
+ delete :reset_overrides
10
+ end
11
+ end
8
12
 
9
13
  resources :executions, only: [:index, :show] do
10
14
  collection do
@@ -15,9 +19,13 @@ RubyLLM::Agents::Engine.routes.draw do
15
19
 
16
20
  resources :requests, only: [:index, :show]
17
21
 
18
- resources :tenants, only: [:index, :show, :edit, :update]
22
+ resources :tenants, only: [:index, :show, :edit, :update] do
23
+ member do
24
+ post :refresh_counters
25
+ end
26
+ end
19
27
 
20
- # Redirect old analytics route to dashboard
21
- get "analytics", to: redirect("/")
28
+ get "analytics", to: "analytics#index", as: :analytics
29
+ get "analytics/chart_data", to: "analytics#chart_data", as: :analytics_chart_data
22
30
  resource :system_config, only: [:show], controller: "system_config"
23
31
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to create the agent overrides table for dashboard-managed settings
4
+ #
5
+ # This table stores per-agent setting overrides that are managed through
6
+ # the dashboard UI. Only fields declared as `overridable: true` in the
7
+ # agent DSL can be overridden.
8
+ #
9
+ # Run with: rails db:migrate
10
+ class CreateRubyLLMAgentsOverrides < ActiveRecord::Migration<%= migration_version %>
11
+ def change
12
+ create_table :ruby_llm_agents_overrides do |t|
13
+ # The agent class name (e.g., "SupportAgent")
14
+ t.string :agent_type, null: false
15
+
16
+ # JSON hash of overridden settings
17
+ # Format: { "model" => "gpt-4o-mini", "temperature" => 0.5 }
18
+ t.json :settings, null: false, default: {}
19
+
20
+ # Who last changed the override (optional audit trail)
21
+ t.string :updated_by
22
+
23
+ t.timestamps
24
+ end
25
+
26
+ add_index :ruby_llm_agents_overrides, :agent_type, unique: true
27
+ end
28
+ end
@@ -110,7 +110,7 @@ streaming true # Enable streaming by default
110
110
  ### Tools
111
111
 
112
112
  ```ruby
113
- tools [SearchTool, CalculatorTool] # Make tools available to agent
113
+ tools SearchTool, CalculatorTool # Make tools available to agent
114
114
  ```
115
115
 
116
116
  ### Extended Thinking
@@ -72,7 +72,7 @@ module Agents
72
72
  class ResearchAgent < ApplicationAgent
73
73
  model "gpt-4o"
74
74
 
75
- tools [SearchTool, CalculatorTool, WebFetchTool]
75
+ tools SearchTool, CalculatorTool, WebFetchTool
76
76
 
77
77
  param :question, required: true
78
78
 
@@ -117,6 +117,20 @@ module RubyLlmAgents
117
117
  )
118
118
  end
119
119
 
120
+ # Create overrides table for dashboard-managed agent settings
121
+ def create_overrides_migration
122
+ if table_exists?(:ruby_llm_agents_overrides)
123
+ say_status :skip, "ruby_llm_agents_overrides table already exists", :yellow
124
+ return
125
+ end
126
+
127
+ say_status :upgrade, "Creating agent overrides table", :blue
128
+ migration_template(
129
+ "create_overrides_migration.rb.tt",
130
+ File.join(db_migrate_path, "create_ruby_llm_agents_overrides.rb")
131
+ )
132
+ end
133
+
120
134
  def suggest_config_consolidation
121
135
  ruby_llm_initializer = File.join(destination_root, "config/initializers/ruby_llm.rb")
122
136
  agents_initializer = File.join(destination_root, "config/initializers/ruby_llm_agents.rb")