ruby_llm-agents 3.10.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 (36) 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 +158 -37
  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 -0
  26. data/lib/ruby_llm/agents/pipeline/context.rb +11 -2
  27. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  28. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  29. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  30. data/lib/ruby_llm/agents/routing/result.rb +60 -9
  31. data/lib/ruby_llm/agents/routing.rb +19 -0
  32. data/lib/ruby_llm/agents/stream_event.rb +58 -0
  33. data/lib/ruby_llm/agents/tool.rb +1 -1
  34. data/lib/ruby_llm/agents.rb +2 -2
  35. metadata +7 -2
  36. data/lib/ruby_llm/agents/agent_tool.rb +0 -125
@@ -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")
@@ -47,6 +47,8 @@ module RubyLLM
47
47
  extend DSL::Reliability
48
48
  extend DSL::Caching
49
49
  extend DSL::Queryable
50
+ extend DSL::Knowledge
51
+ include DSL::Knowledge::InstanceMethods
50
52
  include CacheHelper
51
53
 
52
54
  class << self
@@ -170,6 +172,7 @@ module RubyLLM
170
172
  tools: tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s },
171
173
  parameters: params.transform_values { |v| v.slice(:type, :required, :default, :desc) },
172
174
  thinking: thinking_config,
175
+ cache_prompts: cache_prompts || nil,
173
176
  caching: caching_config,
174
177
  reliability: reliability_configured? ? reliability_config : nil
175
178
  }.compact
@@ -232,12 +235,18 @@ module RubyLLM
232
235
  # Enables or returns streaming mode for this agent
233
236
  #
234
237
  # @param value [Boolean, nil] Whether to enable streaming
238
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
235
239
  # @return [Boolean] The current streaming setting
236
- def streaming(value = nil)
240
+ def streaming(value = nil, overridable: nil)
237
241
  @streaming = value unless value.nil?
238
- return @streaming unless @streaming.nil?
242
+ register_overridable(:streaming) if overridable
243
+ base = if @streaming.nil?
244
+ superclass.respond_to?(:streaming) ? superclass.streaming : default_streaming
245
+ else
246
+ @streaming
247
+ end
239
248
 
240
- superclass.respond_to?(:streaming) ? superclass.streaming : default_streaming
249
+ apply_override(:streaming, base)
241
250
  end
242
251
 
243
252
  # @!endgroup
@@ -246,10 +255,10 @@ module RubyLLM
246
255
 
247
256
  # Sets or returns the tools available to this agent
248
257
  #
249
- # @param tool_classes [Array<Class>] Tool classes to make available
258
+ # @param tool_classes [Class, Array<Class>] Tool classes to make available
250
259
  # @return [Array<Class>] The current tools
251
- def tools(tool_classes = nil)
252
- @tools = Array(tool_classes) if tool_classes
260
+ def tools(*tool_classes)
261
+ @tools = tool_classes.flatten if tool_classes.any?
253
262
  @tools || (superclass.respond_to?(:tools) ? superclass.tools : [])
254
263
  end
255
264
 
@@ -260,10 +269,14 @@ module RubyLLM
260
269
  # Sets or returns the temperature for LLM responses
261
270
  #
262
271
  # @param value [Float, nil] Temperature value (0.0-2.0)
272
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
263
273
  # @return [Float] The current temperature setting
264
- def temperature(value = nil)
274
+ def temperature(value = nil, overridable: nil)
265
275
  @temperature = value if value
266
- @temperature || (superclass.respond_to?(:temperature) ? superclass.temperature : default_temperature)
276
+ register_overridable(:temperature) if overridable
277
+ base = @temperature || (superclass.respond_to?(:temperature) ? superclass.temperature : default_temperature)
278
+
279
+ apply_override(:temperature, base)
267
280
  end
268
281
 
269
282
  # @!endgroup
@@ -387,14 +400,19 @@ module RubyLLM
387
400
  # System prompt for LLM instructions
388
401
  #
389
402
  # If a class-level `system` DSL is defined, it will be used.
390
- # Otherwise returns nil.
403
+ # Knowledge entries declared via `knows` are auto-appended.
391
404
  #
392
405
  # @return [String, nil] System instructions, or nil for none
393
406
  def system_prompt
394
407
  system_config = self.class.system_config
395
- return resolve_prompt_from_config(system_config) if system_config
408
+ base = system_config ? resolve_prompt_from_config(system_config) : nil
396
409
 
397
- nil
410
+ knowledge = compiled_knowledge
411
+ if knowledge.present?
412
+ base ? "#{base}\n\n#{knowledge}" : knowledge
413
+ else
414
+ base
415
+ end
398
416
  end
399
417
 
400
418
  # Assistant prefill to prime the model's response
@@ -512,6 +530,7 @@ module RubyLLM
512
530
  tenant: resolve_tenant,
513
531
  skip_cache: @options[:skip_cache],
514
532
  stream_block: (block if streaming_enabled?),
533
+ stream_events: @options[:stream_events] == true,
515
534
  parent_execution_id: @parent_execution_id,
516
535
  root_execution_id: @root_execution_id,
517
536
  debug: @options[:debug],
@@ -552,36 +571,33 @@ module RubyLLM
552
571
  end
553
572
  end
554
573
 
555
- # Resolves tools for this execution
574
+ # Returns the description for a tool class
556
575
  #
557
- # Agent classes in the tools list are automatically wrapped as
558
- # RubyLLM::Tool subclasses via AgentTool.for. Regular tool classes
559
- # pass through unchanged.
576
+ # @param tool [Class] A tool class
577
+ # @return [String] The tool's description
578
+ def tool_description_for(tool)
579
+ if tool.respond_to?(:description) && tool.description
580
+ tool.description
581
+ elsif tool.is_a?(Class) && tool < RubyLLM::Tool
582
+ tool.new.respond_to?(:description) ? tool.new.description : tool.name.to_s
583
+ else
584
+ tool.name.to_s
585
+ end
586
+ end
587
+
588
+ # Resolves tools for this execution
560
589
  #
561
590
  # @return [Array<Class>] Tool classes to use
562
591
  # @raise [ArgumentError] If duplicate tool names are detected
563
592
  def resolved_tools
564
- raw = if self.class.method_defined?(:tools, false)
593
+ all_tools = if self.class.method_defined?(:tools, false)
565
594
  tools
566
595
  else
567
596
  self.class.tools
568
597
  end
569
598
 
570
- wrapped = raw.map { |tool_class| wrap_if_agent(tool_class) }
571
- detect_duplicate_tool_names!(wrapped)
572
- wrapped
573
- end
574
-
575
- # Wraps an agent class as a tool, or returns the tool class as-is.
576
- #
577
- # @param tool_class [Class] A tool or agent class
578
- # @return [Class] The original or wrapped class
579
- def wrap_if_agent(tool_class)
580
- if tool_class.respond_to?(:ancestors) && tool_class.ancestors.include?(RubyLLM::Agents::BaseAgent)
581
- AgentTool.for(tool_class)
582
- else
583
- tool_class
584
- end
599
+ detect_duplicate_tool_names!(all_tools)
600
+ all_tools
585
601
  end
586
602
 
587
603
  # Raises if two tools resolve to the same name.
@@ -720,9 +736,10 @@ module RubyLLM
720
736
  # @param context [Pipeline::Context] The execution context
721
737
  # @return [void] Sets context.output with the result
722
738
  def execute(context)
739
+ @context = context
723
740
  client = build_client(context)
724
741
 
725
- # Make context available to AgentTool instances during tool execution
742
+ # Make context available to Tool instances during tool execution
726
743
  previous_context = Thread.current[:ruby_llm_agents_caller_context]
727
744
  Thread.current[:ruby_llm_agents_caller_context] = context
728
745
 
@@ -759,9 +776,20 @@ module RubyLLM
759
776
  end
760
777
  client = client.with_temperature(temperature)
761
778
 
762
- client = client.with_instructions(system_prompt) if system_prompt
779
+ use_prompt_caching = self.class.cache_prompts && anthropic_model?(effective_model)
780
+
781
+ if system_prompt
782
+ sys_content = if use_prompt_caching
783
+ RubyLLM::Providers::Anthropic::Content.new(system_prompt, cache: true)
784
+ else
785
+ system_prompt
786
+ end
787
+ client = client.with_instructions(sys_content)
788
+ end
789
+
763
790
  client = client.with_schema(schema) if schema
764
791
  client = client.with_tools(*resolved_tools) if resolved_tools.any?
792
+ apply_tool_prompt_caching(client) if use_prompt_caching && resolved_tools.any?
765
793
  client = setup_tool_tracking(client) if resolved_tools.any?
766
794
  client = apply_messages(client, resolved_messages) if resolved_messages.any?
767
795
  client = client.with_thinking(**resolved_thinking) if resolved_thinking
@@ -817,7 +845,11 @@ module RubyLLM
817
845
 
818
846
  response = client.complete do |chunk|
819
847
  first_chunk_at ||= Time.current
820
- context.stream_block.call(chunk)
848
+ if context.stream_events?
849
+ context.stream_block.call(StreamEvent.new(:chunk, {content: chunk.content}))
850
+ else
851
+ context.stream_block.call(chunk)
852
+ end
821
853
  end
822
854
 
823
855
  if first_chunk_at
@@ -843,7 +875,11 @@ module RubyLLM
843
875
 
844
876
  response = client.ask(user_prompt, **ask_opts) do |chunk|
845
877
  first_chunk_at ||= Time.current
846
- context.stream_block.call(chunk)
878
+ if context.stream_events?
879
+ context.stream_block.call(StreamEvent.new(:chunk, {content: chunk.content}))
880
+ else
881
+ context.stream_block.call(chunk)
882
+ end
847
883
  end
848
884
 
849
885
  if first_chunk_at
@@ -867,6 +903,14 @@ module RubyLLM
867
903
  # Store tracked tool calls in context for instrumentation
868
904
  context[:tool_calls] = @tracked_tool_calls if @tracked_tool_calls.any?
869
905
 
906
+ # Capture Anthropic prompt caching metrics
907
+ if response.respond_to?(:cached_tokens) && response.cached_tokens&.positive?
908
+ context[:cached_tokens] = response.cached_tokens
909
+ end
910
+ if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens&.positive?
911
+ context[:cache_creation_tokens] = response.cache_creation_tokens
912
+ end
913
+
870
914
  calculate_costs(response, context) if context.input_tokens
871
915
  end
872
916
 
@@ -901,6 +945,37 @@ module RubyLLM
901
945
  nil
902
946
  end
903
947
 
948
+ # Checks whether the given model is served by Anthropic
949
+ #
950
+ # Looks up the model's provider in the registry, falling back to
951
+ # model ID pattern matching when the registry is unavailable.
952
+ #
953
+ # @param model_id [String] The model ID
954
+ # @return [Boolean]
955
+ def anthropic_model?(model_id)
956
+ info = find_model_info(model_id)
957
+ return info.provider.to_s == "anthropic" if info&.provider
958
+
959
+ # Fallback: match common Anthropic model ID patterns
960
+ model_id.to_s.match?(/\Aclaude/i)
961
+ end
962
+
963
+ # Adds cache_control to the last tool so Anthropic caches all tool definitions
964
+ #
965
+ # Uses a singleton method override on the last tool instance so the
966
+ # cache_control is merged into the API payload by RubyLLM's
967
+ # Tools.function_for without mutating the tool class.
968
+ #
969
+ # @param client [RubyLLM::Chat] The chat client with tools already added
970
+ def apply_tool_prompt_caching(client)
971
+ last_tool = client.tools.values.last
972
+ return unless last_tool
973
+
974
+ last_tool.define_singleton_method(:provider_params) do
975
+ super().merge(cache_control: {type: "ephemeral"})
976
+ end
977
+ end
978
+
904
979
  # Builds a Result object from the response
905
980
  #
906
981
  # @param content [Object] The processed content
@@ -993,8 +1068,54 @@ module RubyLLM
993
1068
  # @return [RubyLLM::Chat] Client with tracking callbacks
994
1069
  def setup_tool_tracking(client)
995
1070
  client
996
- .on_tool_call { |tool_call| start_tracking_tool_call(tool_call) }
997
- .on_tool_result { |result| complete_tool_call_tracking(result) }
1071
+ .on_tool_call do |tool_call|
1072
+ start_tracking_tool_call(tool_call)
1073
+ emit_stream_event(:tool_start, tool_call_start_data(tool_call))
1074
+ end
1075
+ .on_tool_result do |result|
1076
+ end_data = tool_call_end_data(result)
1077
+ complete_tool_call_tracking(result)
1078
+ emit_stream_event(:tool_end, end_data)
1079
+ end
1080
+ end
1081
+
1082
+ # Emits a StreamEvent to the caller's stream block when stream_events is enabled
1083
+ #
1084
+ # @param type [Symbol] Event type (:chunk, :tool_start, :tool_end, :error)
1085
+ # @param data [Hash] Event-specific data
1086
+ def emit_stream_event(type, data)
1087
+ return unless @context&.stream_block && @context.stream_events?
1088
+
1089
+ @context.stream_block.call(StreamEvent.new(type, data))
1090
+ end
1091
+
1092
+ # Builds data hash for a tool_start event
1093
+ #
1094
+ # @param tool_call [Object] The tool call object from RubyLLM
1095
+ # @return [Hash] Event data
1096
+ def tool_call_start_data(tool_call)
1097
+ {
1098
+ tool_name: extract_tool_call_value(tool_call, :name),
1099
+ input: extract_tool_call_value(tool_call, :arguments) || {}
1100
+ }.compact
1101
+ end
1102
+
1103
+ # Builds data hash for a tool_end event from the pending tool call
1104
+ #
1105
+ # @param result [Object] The tool result
1106
+ # @return [Hash] Event data
1107
+ def tool_call_end_data(result)
1108
+ return {} unless @pending_tool_call
1109
+
1110
+ started_at = @pending_tool_call[:started_at]
1111
+ duration_ms = started_at ? ((Time.current - started_at) * 1000).to_i : nil
1112
+ result_data = extract_tool_result(result)
1113
+
1114
+ {
1115
+ tool_name: @pending_tool_call[:name],
1116
+ status: result_data[:status],
1117
+ duration_ms: duration_ms
1118
+ }.compact
998
1119
  end
999
1120
 
1000
1121
  # Starts tracking a tool call