ruby_llm-agents 3.11.0 → 3.13.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 (48) 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/executions_controller.rb +5 -0
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
  6. data/app/models/ruby_llm/agents/agent_override.rb +47 -0
  7. data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
  8. data/app/models/ruby_llm/agents/execution.rb +51 -1
  9. data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  11. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +93 -4
  12. data/app/views/ruby_llm/agents/agents/show.html.erb +17 -2
  13. data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
  14. data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
  15. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
  17. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
  18. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
  19. data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +3 -2
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
  22. data/config/routes.rb +12 -4
  23. data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
  24. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
  25. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
  26. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
  27. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
  28. data/lib/ruby_llm/agents/base_agent.rb +90 -133
  29. data/lib/ruby_llm/agents/core/base.rb +9 -0
  30. data/lib/ruby_llm/agents/core/configuration.rb +93 -7
  31. data/lib/ruby_llm/agents/core/version.rb +1 -1
  32. data/lib/ruby_llm/agents/dsl/base.rb +131 -4
  33. data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
  34. data/lib/ruby_llm/agents/dsl.rb +1 -1
  35. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
  36. data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
  37. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  38. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  39. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  40. data/lib/ruby_llm/agents/rails/engine.rb +20 -4
  41. data/lib/ruby_llm/agents/routing.rb +28 -5
  42. data/lib/ruby_llm/agents/stream_event.rb +2 -10
  43. data/lib/ruby_llm/agents/tool.rb +1 -1
  44. data/lib/ruby_llm/agents.rb +1 -3
  45. data/lib/tasks/ruby_llm_agents.rake +7 -0
  46. metadata +9 -5
  47. data/lib/ruby_llm/agents/agent_tool.rb +0 -143
  48. data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
@@ -43,6 +43,13 @@ module RubyLLM
43
43
  #
44
44
  # RubyExpert.ask("What is metaprogramming?")
45
45
  #
46
+ # @example Dashboard-overridable settings
47
+ # class SupportAgent < RubyLLM::Agents::BaseAgent
48
+ # model "gpt-4o", overridable: true # can be changed from the dashboard
49
+ # temperature 0.7, overridable: true # can be changed from the dashboard
50
+ # timeout 30 # locked to code value
51
+ # end
52
+ #
46
53
  # @example Dynamic prompts with method overrides
47
54
  # class SmartAgent < RubyLLM::Agents::BaseAgent
48
55
  # def system_prompt
@@ -63,12 +70,18 @@ module RubyLLM
63
70
  # Sets or returns the LLM model for this agent class
64
71
  #
65
72
  # @param value [String, nil] The model identifier to set
73
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
66
74
  # @return [String] The current model setting
67
75
  # @example
68
76
  # model "gpt-4o"
69
- def model(value = nil)
77
+ # @example Dashboard-overridable
78
+ # model "gpt-4o", overridable: true
79
+ def model(value = nil, overridable: nil)
70
80
  @model = value if value
71
- @model || inherited_or_default(:model, default_model)
81
+ register_overridable(:model) if overridable
82
+ base = @model || inherited_or_default(:model, default_model)
83
+
84
+ apply_override(:model, base)
72
85
  end
73
86
 
74
87
  # Sets the user prompt template
@@ -206,12 +219,45 @@ module RubyLLM
206
219
  # Sets or returns the timeout in seconds for LLM requests
207
220
  #
208
221
  # @param value [Integer, nil] Timeout in seconds
222
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
209
223
  # @return [Integer] The current timeout setting
210
224
  # @example
211
225
  # timeout 30
212
- def timeout(value = nil)
226
+ # @example Dashboard-overridable
227
+ # timeout 30, overridable: true
228
+ def timeout(value = nil, overridable: nil)
213
229
  @timeout = value if value
214
- @timeout || inherited_or_default(:timeout, default_timeout)
230
+ register_overridable(:timeout) if overridable
231
+ base = @timeout || inherited_or_default(:timeout, default_timeout)
232
+
233
+ apply_override(:timeout, base)
234
+ end
235
+
236
+ # Enables Anthropic prompt caching for this agent
237
+ #
238
+ # When enabled, adds cache_control breakpoints to the system prompt
239
+ # and the last tool definition so Anthropic caches them across
240
+ # multi-turn agent loops. This reduces input token costs by ~90%
241
+ # on subsequent calls within the same cache window (~5 minutes).
242
+ #
243
+ # Only takes effect when the resolved model is served by Anthropic.
244
+ # Non-Anthropic models silently ignore this setting.
245
+ #
246
+ # @param value [Boolean, nil] Whether to enable prompt caching
247
+ # @return [Boolean] The current setting
248
+ #
249
+ # @example
250
+ # class BuildAgent < ApplicationAgent
251
+ # cache_prompts true
252
+ # system "You are a build assistant."
253
+ # tools BuildTool, TestTool, DeployTool
254
+ # end
255
+ #
256
+ def cache_prompts(value = nil)
257
+ @cache_prompts = value unless value.nil?
258
+ return @cache_prompts if defined?(@cache_prompts) && !@cache_prompts.nil?
259
+
260
+ inherited_or_default(:cache_prompts, false)
215
261
  end
216
262
 
217
263
  # Sets or returns the response schema for structured output
@@ -259,6 +305,47 @@ module RubyLLM
259
305
 
260
306
  # @!endgroup
261
307
 
308
+ # @!group Dashboard Override Support
309
+
310
+ # Returns which fields are overridable for this agent
311
+ #
312
+ # @return [Array<Symbol>] The list of overridable field names
313
+ def overridable_fields
314
+ own = @overridable_fields || []
315
+ inherited = superclass.respond_to?(:overridable_fields) ? superclass.overridable_fields : []
316
+ (own + inherited).uniq
317
+ end
318
+
319
+ # Returns true if any field is overridable from the dashboard
320
+ #
321
+ # @return [Boolean]
322
+ def overridable?
323
+ overridable_fields.any?
324
+ end
325
+
326
+ # Returns the currently active dashboard overrides for this agent
327
+ #
328
+ # Only returns overrides for fields that are declared overridable.
329
+ #
330
+ # @return [Hash{String => Object}] Active override values
331
+ def active_overrides
332
+ return {} unless overridable?
333
+
334
+ raw = load_overrides
335
+ raw.select { |field, _| overridable_fields.include?(field.to_sym) }
336
+ end
337
+
338
+ # Clears the in-memory override cache so the next access reloads from DB
339
+ #
340
+ # Called automatically by AgentOverride after_save/after_destroy callbacks.
341
+ #
342
+ # @return [void]
343
+ def clear_override_cache!
344
+ @_override_cache = nil
345
+ end
346
+
347
+ # @!endgroup
348
+
262
349
  private
263
350
 
264
351
  # Auto-registers parameters found in prompt template placeholders
@@ -310,6 +397,46 @@ module RubyLLM
310
397
  rescue
311
398
  120
312
399
  end
400
+
401
+ # Registers a field as overridable from the dashboard
402
+ #
403
+ # @param field [Symbol] The field name
404
+ # @return [void]
405
+ def register_overridable(field)
406
+ @overridable_fields = (@overridable_fields || []) | [field]
407
+ end
408
+
409
+ # Applies a dashboard override if the field is overridable and an override exists
410
+ #
411
+ # @param field [Symbol] The field name
412
+ # @param base [Object] The code-defined value to use as fallback
413
+ # @return [Object] The override value, or the base value
414
+ def apply_override(field, base)
415
+ return base unless overridable_fields.include?(field)
416
+
417
+ override = resolve_override(field)
418
+ override.nil? ? base : override
419
+ end
420
+
421
+ # Fetches the override value for a single field from the cached override hash
422
+ #
423
+ # @param field [Symbol] The field name
424
+ # @return [Object, nil] The override value, or nil
425
+ def resolve_override(field)
426
+ @_override_cache = load_overrides unless defined?(@_override_cache) && @_override_cache
427
+ @_override_cache[field.to_s]
428
+ end
429
+
430
+ # Loads all overrides for this agent from the database
431
+ #
432
+ # @return [Hash{String => Object}] The override settings hash
433
+ def load_overrides
434
+ return {} unless defined?(RubyLLM::Agents::AgentOverride)
435
+
436
+ RubyLLM::Agents::AgentOverride.find_by(agent_type: name)&.settings || {}
437
+ rescue
438
+ {}
439
+ end
313
440
  end
314
441
  end
315
442
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module DSL
6
+ # Knowledge DSL for declaring domain knowledge to inject into system prompts.
7
+ #
8
+ # Supports two modes:
9
+ # - Static: `knows :name` loads from a file resolved via `knowledge_path`
10
+ # - Dynamic: `knows(:name) { ... }` evaluates a block at call time via instance_exec
11
+ #
12
+ # Optional `if:` condition gates inclusion without forcing static content into blocks.
13
+ #
14
+ # @example Static knowledge from files
15
+ # class MyAgent < RubyLLM::Agents::Base
16
+ # knowledge_path "knowledge"
17
+ # knows :refund_policy
18
+ # knows :pricing
19
+ # end
20
+ #
21
+ # @example Dynamic knowledge
22
+ # class MyAgent < RubyLLM::Agents::Base
23
+ # knows :recent_tickets do
24
+ # Ticket.recent.pluck(:summary)
25
+ # end
26
+ # end
27
+ #
28
+ # @example Conditional knowledge
29
+ # class MyAgent < RubyLLM::Agents::Base
30
+ # param :region, required: true
31
+ # knows :hipaa, if: -> { region == "us" }
32
+ # end
33
+ #
34
+ module Knowledge
35
+ # Registers one or more knowledge entries.
36
+ #
37
+ # @overload knows(name, **options, &block)
38
+ # Register a single entry (static or dynamic)
39
+ # @param name [Symbol] Identifier for this knowledge entry
40
+ # @param options [Hash] Options including `if:` condition lambda
41
+ # @param block [Proc] Optional block for dynamic knowledge (evaluated via instance_exec)
42
+ #
43
+ # @overload knows(name, *more_names)
44
+ # Register multiple static entries at once
45
+ # @param name [Symbol] First entry name
46
+ # @param more_names [Array<Symbol>] Additional entry names
47
+ def knows(name, *more_names, **options, &block)
48
+ if more_names.any?
49
+ # Multi-arg form: knows :a, :b, :c — all static, no block/options
50
+ [name, *more_names].each do |n|
51
+ knowledge_entries.reject! { |e| e[:name] == n }
52
+ knowledge_entries << {name: n, loader: nil, options: {}}
53
+ end
54
+ else
55
+ knowledge_entries.reject! { |e| e[:name] == name }
56
+ knowledge_entries << {
57
+ name: name,
58
+ loader: block,
59
+ options: options
60
+ }
61
+ end
62
+ end
63
+
64
+ # Sets or returns the base path for static knowledge files.
65
+ #
66
+ # @param path [String, nil] Path to set, or nil to read
67
+ # @return [String, nil] The resolved knowledge path
68
+ def knowledge_path(path = nil)
69
+ if path
70
+ @knowledge_path = path
71
+ else
72
+ @knowledge_path ||
73
+ inherited_or_default(:knowledge_path, nil) ||
74
+ RubyLLM::Agents.configuration.knowledge_path
75
+ end
76
+ end
77
+
78
+ # Returns the list of registered knowledge entries, inheriting from superclass.
79
+ #
80
+ # @return [Array<Hash>] Knowledge entries
81
+ def knowledge_entries
82
+ @knowledge_entries ||= if superclass.respond_to?(:knowledge_entries)
83
+ superclass.knowledge_entries.dup
84
+ else
85
+ []
86
+ end
87
+ end
88
+
89
+ # Instance methods mixed into agent instances via include.
90
+ module InstanceMethods
91
+ # Compiles all knowledge entries into a single string with headings and separators.
92
+ #
93
+ # @return [String] Compiled knowledge (empty string if no entries resolve)
94
+ def compiled_knowledge
95
+ self.class.knowledge_entries.filter_map { |entry|
96
+ content = resolve_knowledge(entry)
97
+ next if content.blank?
98
+
99
+ heading = entry[:name].to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
100
+ "## #{heading}\n\n#{content}"
101
+ }.join("\n\n---\n\n")
102
+ end
103
+
104
+ private
105
+
106
+ def resolve_knowledge(entry)
107
+ if (condition = entry[:options][:if])
108
+ return nil unless instance_exec(&condition)
109
+ end
110
+
111
+ if entry[:loader]
112
+ resolve_dynamic_knowledge(entry)
113
+ else
114
+ resolve_static_knowledge(entry)
115
+ end
116
+ end
117
+
118
+ def resolve_dynamic_knowledge(entry)
119
+ result = instance_exec(&entry[:loader])
120
+ case result
121
+ when Array
122
+ result.map { |r| "- #{r}" }.join("\n")
123
+ when String
124
+ result
125
+ when nil
126
+ nil
127
+ else
128
+ result.to_s
129
+ end
130
+ end
131
+
132
+ def resolve_static_knowledge(entry)
133
+ path = find_knowledge_file(entry[:name])
134
+ return nil unless path && File.exist?(path)
135
+ File.read(path)
136
+ end
137
+
138
+ def find_knowledge_file(name)
139
+ base_path = self.class.knowledge_path
140
+ return nil unless base_path
141
+
142
+ candidates = [
143
+ File.join(base_path, "#{name}.md"),
144
+ File.join(base_path, name.to_s)
145
+ ]
146
+
147
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
148
+ candidates.map! { |c| Rails.root.join(c).to_s }
149
+ end
150
+
151
+ candidates.find { |c| File.exist?(c) }
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -4,7 +4,7 @@ require_relative "dsl/base"
4
4
  require_relative "dsl/reliability"
5
5
  require_relative "dsl/caching"
6
6
  require_relative "dsl/queryable"
7
- require_relative "dsl/agents"
7
+ require_relative "dsl/knowledge"
8
8
 
9
9
  module RubyLLM
10
10
  module Agents
@@ -95,12 +95,13 @@ module RubyLLM
95
95
  def record_failed_execution(error, started_at)
96
96
  return unless defined?(RubyLLM::Agents::Execution)
97
97
 
98
- execution_data = build_failed_execution_data(error, started_at)
98
+ execution_data, detail_data = build_failed_execution_data(error, started_at)
99
99
 
100
100
  if config.async_logging && defined?(ExecutionLoggerJob)
101
- ExecutionLoggerJob.perform_later(execution_data)
101
+ ExecutionLoggerJob.perform_later(execution_data.merge(_detail_data: detail_data))
102
102
  else
103
- RubyLLM::Agents::Execution.create!(execution_data)
103
+ execution = RubyLLM::Agents::Execution.create!(execution_data)
104
+ execution.create_detail!(detail_data) if detail_data.present?
104
105
  end
105
106
  rescue => e
106
107
  Rails.logger.error("[RubyLLM::Agents] Failed to record failed #{execution_type} execution: #{e.message}") if defined?(Rails)
@@ -124,7 +125,7 @@ module RubyLLM
124
125
  end
125
126
 
126
127
  def build_failed_execution_data(error, started_at)
127
- {
128
+ execution_data = {
128
129
  agent_type: self.class.name,
129
130
  tenant_id: @tenant_id,
130
131
  execution_type: execution_type,
@@ -137,9 +138,12 @@ module RubyLLM
137
138
  started_at: started_at,
138
139
  completed_at: Time.current,
139
140
  error_class: error.class.name,
140
- error_message: error.message.truncate(1000),
141
141
  metadata: {}
142
142
  }
143
+
144
+ detail_data = {error_message: error.message.to_s.truncate(1000)}
145
+
146
+ [execution_data, detail_data]
143
147
  end
144
148
 
145
149
  def build_metadata(result)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Background job that enforces two-tier data retention on execution records.
6
+ #
7
+ # Soft pass: for executions older than {Configuration#soft_purge_after},
8
+ # destroys the associated execution_details and tool_executions rows,
9
+ # preserves a truncated copy of error_message in metadata, and stamps
10
+ # metadata["soft_purged_at"] so the dashboard can surface the state and
11
+ # the pass stays idempotent.
12
+ #
13
+ # Hard pass: for executions older than {Configuration#hard_purge_after},
14
+ # destroys the executions row itself. The foreign-key cascade removes
15
+ # any remaining details or tool_executions.
16
+ #
17
+ # Either tier may be set to nil in configuration to skip that pass.
18
+ #
19
+ # @example Enqueue manually
20
+ # RubyLLM::Agents::RetentionJob.perform_later
21
+ #
22
+ # @example Schedule daily (whenever gem)
23
+ # every 1.day, at: "3:00 am" do
24
+ # runner "RubyLLM::Agents::RetentionJob.perform_later"
25
+ # end
26
+ #
27
+ # @api public
28
+ class RetentionJob < ActiveJob::Base
29
+ queue_as :default
30
+
31
+ ERROR_MESSAGE_MAX_LENGTH = 500
32
+ BATCH_SIZE = 500
33
+
34
+ # Runs the soft and hard retention passes based on current configuration.
35
+ #
36
+ # @return [Hash] counts of rows affected in each pass
37
+ def perform
38
+ {
39
+ soft_purged: soft_purge,
40
+ hard_purged: hard_purge
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ # Destroys detail + tool_execution rows for executions older than the
47
+ # soft-purge window that have not already been soft-purged. Stamps
48
+ # metadata with the purge timestamp and preserves a truncated
49
+ # error_message for long-term error-rate analytics.
50
+ #
51
+ # The "already purged" filter runs in Ruby rather than SQL because
52
+ # JSON key-exists operators differ across SQLite/Postgres/MySQL; this
53
+ # keeps the job adapter-agnostic. We batch to bound memory.
54
+ def soft_purge
55
+ window = RubyLLM::Agents.configuration.soft_purge_after
56
+ return 0 if window.nil?
57
+
58
+ cutoff = window.ago
59
+ count = 0
60
+
61
+ Execution
62
+ .where("created_at < ?", cutoff)
63
+ .includes(:detail)
64
+ .find_in_batches(batch_size: BATCH_SIZE) do |batch|
65
+ batch.each do |execution|
66
+ next if execution.soft_purged?
67
+
68
+ purge_one(execution)
69
+ count += 1
70
+ end
71
+ end
72
+
73
+ count
74
+ end
75
+
76
+ # Destroys executions (and everything cascaded from them) older than
77
+ # the hard-purge window.
78
+ def hard_purge
79
+ window = RubyLLM::Agents.configuration.hard_purge_after
80
+ return 0 if window.nil?
81
+
82
+ cutoff = window.ago
83
+ total = 0
84
+
85
+ Execution.where("created_at < ?", cutoff).in_batches(of: BATCH_SIZE) do |batch|
86
+ total += batch.destroy_all.size
87
+ end
88
+
89
+ total
90
+ end
91
+
92
+ # Performs the soft purge for a single execution.
93
+ def purge_one(execution)
94
+ preserved_error = preserved_error_message(execution)
95
+
96
+ Execution.transaction do
97
+ execution.detail&.destroy
98
+ execution.tool_executions.destroy_all
99
+
100
+ new_metadata = (execution.metadata || {}).merge(
101
+ "soft_purged_at" => Time.current.iso8601
102
+ )
103
+ new_metadata["error_message"] = preserved_error if preserved_error
104
+
105
+ execution.update_columns(metadata: new_metadata)
106
+ end
107
+ end
108
+
109
+ # Returns a truncated copy of the detail's error_message, or nil.
110
+ def preserved_error_message(execution)
111
+ raw = execution.detail&.error_message
112
+ return nil if raw.blank?
113
+
114
+ raw.to_s.truncate(ERROR_MESSAGE_MAX_LENGTH)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -33,15 +33,18 @@ module RubyLLM
33
33
  return @app.call(context) unless budgets_enabled?
34
34
 
35
35
  trace(context) do
36
+ # Resolve tenant once for both check and record
37
+ tenant = resolve_tenant(context)
38
+
36
39
  # Check budget before execution
37
- check_budget!(context)
40
+ check_budget!(context, tenant)
38
41
 
39
42
  # Execute the chain
40
43
  @app.call(context)
41
44
 
42
45
  # Record spend after successful execution (if not cached)
43
46
  if context.success? && !context.cached?
44
- record_spend!(context)
47
+ record_spend!(context, tenant)
45
48
  emit_budget_notification("ruby_llm_agents.budget.record", context,
46
49
  total_cost: context.total_cost,
47
50
  total_tokens: context.total_tokens)
@@ -80,22 +83,33 @@ module RubyLLM
80
83
  false
81
84
  end
82
85
 
86
+ # Resolves the tenant record once for reuse across check and record
87
+ #
88
+ # @param context [Context] The execution context
89
+ # @return [Tenant, nil] The tenant record or nil
90
+ def resolve_tenant(context)
91
+ return nil unless context.tenant_id.present?
92
+
93
+ RubyLLM::Agents::Tenant.find_by(tenant_id: context.tenant_id)
94
+ rescue => e
95
+ debug("Tenant lookup failed: #{e.message}", context)
96
+ nil
97
+ end
98
+
83
99
  # Checks budget before execution
84
100
  #
85
101
  # For tenants, checks budget via counter columns on the tenant model.
86
102
  # For non-tenant usage, falls back to BudgetTracker (cache-based).
87
103
  #
88
104
  # @param context [Context] The execution context
105
+ # @param tenant [Tenant, nil] Pre-resolved tenant record
89
106
  # @raise [BudgetExceededError] If budget exceeded with hard enforcement
90
- def check_budget!(context)
107
+ def check_budget!(context, tenant = nil)
91
108
  emit_budget_notification("ruby_llm_agents.budget.check", context)
92
109
 
93
- if context.tenant_id.present?
94
- tenant = RubyLLM::Agents::Tenant.find_by(tenant_id: context.tenant_id)
95
- if tenant
96
- tenant.check_budget!(context.agent_class&.name)
97
- return
98
- end
110
+ if tenant
111
+ tenant.check_budget!(context.agent_class&.name)
112
+ return
99
113
  end
100
114
 
101
115
  # Fallback to cache-based checking (non-tenant or no tenant record)
@@ -117,17 +131,15 @@ module RubyLLM
117
131
  # For non-tenant usage, falls back to BudgetTracker (cache-based).
118
132
  #
119
133
  # @param context [Context] The execution context
120
- def record_spend!(context)
121
- if context.tenant_id.present?
122
- tenant = RubyLLM::Agents::Tenant.find_by(tenant_id: context.tenant_id)
123
- if tenant
124
- tenant.record_execution!(
125
- cost: context.total_cost || 0,
126
- tokens: context.total_tokens || 0,
127
- error: context.failed?
128
- )
129
- return
130
- end
134
+ # @param tenant [Tenant, nil] Pre-resolved tenant record
135
+ def record_spend!(context, tenant = nil)
136
+ if tenant
137
+ tenant.record_execution!(
138
+ cost: context.total_cost || 0,
139
+ tokens: context.total_tokens || 0,
140
+ error: context.failed?
141
+ )
142
+ return
131
143
  end
132
144
 
133
145
  # Fallback for non-tenant usage
@@ -278,6 +278,7 @@ module RubyLLM
278
278
  data = {
279
279
  agent_type: context.agent_class&.name,
280
280
  model_id: context.model,
281
+ model_provider: resolve_model_provider(context.model),
281
282
  status: "running",
282
283
  started_at: context.started_at,
283
284
  input_tokens: 0,
@@ -339,7 +340,9 @@ module RubyLLM
339
340
  input_tokens: context.input_tokens || 0,
340
341
  output_tokens: context.output_tokens || 0,
341
342
  total_cost: context.total_cost || 0,
342
- attempts_count: context.attempts_made
343
+ attempts_count: context.attempts_made,
344
+ chosen_model_id: context.model_used,
345
+ finish_reason: context.finish_reason
343
346
  }
344
347
 
345
348
  # Merge metadata: agent metadata (base) < middleware metadata (overlay)
@@ -536,6 +539,24 @@ module RubyLLM
536
539
  {}
537
540
  end
538
541
 
542
+ # Resolves the provider name for a given model ID
543
+ #
544
+ # Uses RubyLLM::Models.find which is an in-process registry lookup
545
+ # (no API keys or network calls needed).
546
+ #
547
+ # @param model_id [String, nil] The model identifier
548
+ # @return [String, nil] Provider name (e.g., "openai", "anthropic") or nil
549
+ def resolve_model_provider(model_id)
550
+ return nil unless model_id
551
+ return nil unless defined?(RubyLLM::Models)
552
+
553
+ model_info = RubyLLM::Models.find(model_id)
554
+ provider = model_info&.provider
555
+ provider&.to_s.presence
556
+ rescue
557
+ nil
558
+ end
559
+
539
560
  # Injects tracker request_id and tags into execution data
540
561
  #
541
562
  # Reads @_track_request_id and @_track_tags from the agent instance,
@@ -223,7 +223,7 @@ module RubyLLM
223
223
  return unless deadline && Time.current > deadline
224
224
 
225
225
  elapsed = Time.current - started_at
226
- timeout_value = deadline - started_at + elapsed
226
+ timeout_value = deadline - started_at
227
227
  raise Agents::Reliability::TotalTimeoutError.new(timeout_value, elapsed)
228
228
  end
229
229
 
@@ -33,6 +33,7 @@ module RubyLLM
33
33
  # @api private
34
34
  config.to_prepare do
35
35
  require_relative "../infrastructure/execution_logger_job"
36
+ require_relative "../infrastructure/retention_job"
36
37
  require_relative "../core/instrumentation"
37
38
  require_relative "../core/base"
38
39
 
@@ -153,18 +154,33 @@ module RubyLLM
153
154
  end
154
155
  helper_method :tenant_scoped_executions
155
156
 
156
- # Returns list of available tenants for filtering dropdown
157
+ # Returns the list of tenants for the dropdown as label/value pairs.
157
158
  #
158
- # @return [Array<String>] Unique tenant IDs from executions
159
+ # Tenants that have a matching row in ruby_llm_agents_tenants get their
160
+ # configured name; legacy or string-only tenants fall back to the raw
161
+ # tenant_id so nothing disappears from the filter.
162
+ #
163
+ # Two queries total — one DISTINCT pluck on executions, one pluck on
164
+ # tenants — regardless of how many tenant_ids exist.
165
+ #
166
+ # @return [Array<Hash>] Entries shaped as { value:, label: }
159
167
  # @api public
160
168
  def available_tenants
161
169
  return @available_tenants if defined?(@available_tenants)
162
170
 
163
- @available_tenants = RubyLLM::Agents::Execution
171
+ tenant_ids = RubyLLM::Agents::Execution
164
172
  .where.not(tenant_id: nil)
165
173
  .distinct
166
174
  .pluck(:tenant_id)
167
- .sort
175
+
176
+ names_by_id = RubyLLM::Agents::Tenant
177
+ .where(tenant_id: tenant_ids)
178
+ .pluck(:tenant_id, :name)
179
+ .to_h
180
+
181
+ @available_tenants = tenant_ids
182
+ .map { |id| {value: id, label: (names_by_id[id].presence || id).to_s} }
183
+ .sort_by { |t| t[:label].downcase }
168
184
  end
169
185
  helper_method :available_tenants
170
186
  end)