ruby_llm-agents 0.3.4 → 0.3.5

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +80 -16
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +17 -27
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +9 -1
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +77 -20
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  21. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  22. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
  23. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  24. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  25. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  26. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  27. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
  29. data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
  30. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  32. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  33. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  34. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  35. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  36. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  37. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  39. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  41. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  42. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  43. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  44. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  45. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  46. data/lib/ruby_llm/agents/base/caching.rb +4 -7
  47. data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
  48. data/lib/ruby_llm/agents/base/execution.rb +61 -9
  49. data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
  50. data/lib/ruby_llm/agents/base.rb +26 -0
  51. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  52. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  53. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  54. data/lib/ruby_llm/agents/configuration.rb +40 -1
  55. data/lib/ruby_llm/agents/engine.rb +65 -1
  56. data/lib/ruby_llm/agents/inflections.rb +14 -0
  57. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  58. data/lib/ruby_llm/agents/reliability.rb +8 -2
  59. data/lib/ruby_llm/agents/version.rb +1 -1
  60. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  61. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  62. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  63. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  64. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  65. data/lib/ruby_llm/agents/workflow.rb +232 -0
  66. data/lib/ruby_llm/agents.rb +1 -0
  67. metadata +50 -60
  68. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  69. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
  70. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  71. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  72. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  73. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  74. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  75. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  76. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  77. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  78. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
  79. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
  80. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
  81. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  82. /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
  83. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
  84. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Shared cache utilities for RubyLLM::Agents
6
+ #
7
+ # Provides consistent cache key generation and store access across
8
+ # BudgetTracker, CircuitBreaker, and Base caching modules.
9
+ #
10
+ # @example Using in a class method context
11
+ # extend CacheHelper
12
+ # cache_store.read(cache_key("budget", "global", "2024-01"))
13
+ #
14
+ # @example Using in an instance method context
15
+ # include CacheHelper
16
+ # cache_store.write(cache_key("agent", agent_type), data, expires_in: 1.hour)
17
+ #
18
+ # @api private
19
+ module CacheHelper
20
+ # Cache key namespace prefix
21
+ NAMESPACE = "ruby_llm_agents"
22
+
23
+ # Returns the configured cache store
24
+ #
25
+ # @return [ActiveSupport::Cache::Store]
26
+ def cache_store
27
+ RubyLLM::Agents.configuration.cache_store
28
+ end
29
+
30
+ # Generates a namespaced cache key from the given parts
31
+ #
32
+ # @param parts [Array<String, Symbol>] Key components to join
33
+ # @return [String] Namespaced cache key
34
+ # @example
35
+ # cache_key("budget", "global", "2024-01")
36
+ # # => "ruby_llm_agents:budget:global:2024-01"
37
+ def cache_key(*parts)
38
+ ([NAMESPACE] + parts.map(&:to_s)).join(":")
39
+ end
40
+
41
+ # Reads a value from the cache
42
+ #
43
+ # @param key [String] The cache key
44
+ # @return [Object, nil] The cached value or nil
45
+ def cache_read(key)
46
+ cache_store.read(key)
47
+ end
48
+
49
+ # Writes a value to the cache
50
+ #
51
+ # @param key [String] The cache key
52
+ # @param value [Object] The value to cache
53
+ # @param options [Hash] Options passed to cache store (e.g., expires_in:)
54
+ # @return [Boolean] Whether the write succeeded
55
+ def cache_write(key, value, **options)
56
+ cache_store.write(key, value, **options)
57
+ end
58
+
59
+ # Checks if a key exists in the cache
60
+ #
61
+ # @param key [String] The cache key
62
+ # @return [Boolean] True if the key exists
63
+ def cache_exist?(key)
64
+ cache_store.exist?(key)
65
+ end
66
+
67
+ # Deletes a key from the cache
68
+ #
69
+ # @param key [String] The cache key
70
+ # @return [Boolean] Whether the delete succeeded
71
+ def cache_delete(key)
72
+ cache_store.delete(key)
73
+ end
74
+
75
+ # Increments a numeric value in the cache
76
+ #
77
+ # Falls back to read-modify-write if the cache store doesn't support increment.
78
+ #
79
+ # @param key [String] The cache key
80
+ # @param amount [Numeric] The amount to increment by (default: 1)
81
+ # @param expires_in [ActiveSupport::Duration, nil] Optional TTL for the key
82
+ # @return [Numeric] The new value
83
+ def cache_increment(key, amount = 1, expires_in: nil)
84
+ if cache_store.respond_to?(:increment)
85
+ # Ensure key exists with TTL
86
+ cache_store.write(key, 0, expires_in: expires_in, unless_exist: true) if expires_in
87
+ cache_store.increment(key, amount)
88
+ else
89
+ # Fallback for cache stores without atomic increment
90
+ current = (cache_store.read(key) || 0).to_f
91
+ new_value = current + amount
92
+ cache_store.write(key, new_value, expires_in: expires_in)
93
+ new_value
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "cache_helper"
4
+
3
5
  module RubyLLM
4
6
  module Agents
5
7
  # Cache-based circuit breaker for protecting against cascading failures
@@ -10,6 +12,9 @@ module RubyLLM
10
12
  # - Stays open for a cooldown period
11
13
  # - Automatically closes after cooldown expires
12
14
  #
15
+ # In multi-tenant mode, circuit breakers are isolated per tenant,
16
+ # so one tenant's failures don't affect other tenants.
17
+ #
13
18
  # @example Basic usage
14
19
  # breaker = CircuitBreaker.new("MyAgent", "gpt-4o", errors: 10, within: 60, cooldown: 300)
15
20
  # breaker.open? # => false
@@ -17,19 +22,26 @@ module RubyLLM
17
22
  # # ... after 10 failures within 60 seconds ...
18
23
  # breaker.open? # => true
19
24
  #
25
+ # @example Multi-tenant usage
26
+ # breaker = CircuitBreaker.new("MyAgent", "gpt-4o", tenant_id: "acme", errors: 10)
27
+ # breaker.open? # Isolated to "acme" tenant
28
+ #
20
29
  # @see RubyLLM::Agents::Reliability
21
30
  # @api public
22
31
  class CircuitBreaker
23
- attr_reader :agent_type, :model_id, :errors_threshold, :window_seconds, :cooldown_seconds
32
+ include CacheHelper
33
+ attr_reader :agent_type, :model_id, :tenant_id, :errors_threshold, :window_seconds, :cooldown_seconds
24
34
 
25
35
  # @param agent_type [String] The agent class name
26
36
  # @param model_id [String] The model identifier
37
+ # @param tenant_id [String, nil] Optional tenant identifier for multi-tenant isolation
27
38
  # @param errors [Integer] Number of errors to trigger open state (default: 10)
28
39
  # @param within [Integer] Rolling window in seconds (default: 60)
29
40
  # @param cooldown [Integer] Cooldown period in seconds when open (default: 300)
30
- def initialize(agent_type, model_id, errors: 10, within: 60, cooldown: 300)
41
+ def initialize(agent_type, model_id, tenant_id: nil, errors: 10, within: 60, cooldown: 300)
31
42
  @agent_type = agent_type
32
43
  @model_id = model_id
44
+ @tenant_id = resolve_tenant_id(tenant_id)
33
45
  @errors_threshold = errors
34
46
  @window_seconds = within
35
47
  @cooldown_seconds = cooldown
@@ -40,13 +52,15 @@ module RubyLLM
40
52
  # @param agent_type [String] The agent class name
41
53
  # @param model_id [String] The model identifier
42
54
  # @param config [Hash] Configuration with :errors, :within, :cooldown keys
55
+ # @param tenant_id [String, nil] Optional tenant identifier
43
56
  # @return [CircuitBreaker] A new circuit breaker instance
44
- def self.from_config(agent_type, model_id, config)
57
+ def self.from_config(agent_type, model_id, config, tenant_id: nil)
45
58
  return nil unless config.is_a?(Hash)
46
59
 
47
60
  new(
48
61
  agent_type,
49
62
  model_id,
63
+ tenant_id: tenant_id,
50
64
  errors: config[:errors] || 10,
51
65
  within: config[:within] || 60,
52
66
  cooldown: config[:cooldown] || 300
@@ -57,7 +71,7 @@ module RubyLLM
57
71
  #
58
72
  # @return [Boolean] true if the breaker is open and requests should be blocked
59
73
  def open?
60
- cache_store.exist?(open_key)
74
+ cache_exist?(open_key)
61
75
  end
62
76
 
63
77
  # Records a failed attempt and potentially opens the breaker
@@ -86,7 +100,7 @@ module RubyLLM
86
100
  # @param reset_counter [Boolean] Whether to reset the failure counter (default: true)
87
101
  # @return [void]
88
102
  def record_success!(reset_counter: true)
89
- cache_store.delete(count_key) if reset_counter
103
+ cache_delete(count_key) if reset_counter
90
104
  end
91
105
 
92
106
  # Manually resets the circuit breaker
@@ -95,15 +109,15 @@ module RubyLLM
95
109
  #
96
110
  # @return [void]
97
111
  def reset!
98
- cache_store.delete(open_key)
99
- cache_store.delete(count_key)
112
+ cache_delete(open_key)
113
+ cache_delete(count_key)
100
114
  end
101
115
 
102
116
  # Returns the current failure count
103
117
  #
104
118
  # @return [Integer] The current failure count in the rolling window
105
119
  def failure_count
106
- cache_store.read(count_key).to_i
120
+ cache_read(count_key).to_i
107
121
  end
108
122
 
109
123
  # Returns the time remaining until the breaker closes
@@ -124,6 +138,7 @@ module RubyLLM
124
138
  {
125
139
  agent_type: agent_type,
126
140
  model_id: model_id,
141
+ tenant_id: tenant_id,
127
142
  open: open?,
128
143
  failure_count: failure_count,
129
144
  errors_threshold: errors_threshold,
@@ -134,29 +149,30 @@ module RubyLLM
134
149
 
135
150
  private
136
151
 
152
+ # Resolves the current tenant ID
153
+ #
154
+ # @param explicit_tenant_id [String, nil] Explicitly passed tenant ID
155
+ # @return [String, nil] Resolved tenant ID or nil
156
+ def resolve_tenant_id(explicit_tenant_id)
157
+ config = RubyLLM::Agents.configuration
158
+ return nil unless config.multi_tenancy_enabled?
159
+ return explicit_tenant_id if explicit_tenant_id.present?
160
+
161
+ config.tenant_resolver&.call
162
+ end
163
+
137
164
  # Increments the failure counter with TTL
138
165
  #
139
166
  # @return [Integer] The new failure count
140
167
  def increment_failure_count
141
- # Use increment if available (atomic), otherwise read-modify-write
142
- if cache_store.respond_to?(:increment)
143
- # First write if doesn't exist
144
- cache_store.write(count_key, 0, expires_in: window_seconds, unless_exist: true)
145
- cache_store.increment(count_key)
146
- else
147
- # Fallback for cache stores without increment
148
- current = cache_store.read(count_key).to_i
149
- new_count = current + 1
150
- cache_store.write(count_key, new_count, expires_in: window_seconds)
151
- new_count
152
- end
168
+ cache_increment(count_key, 1, expires_in: window_seconds)
153
169
  end
154
170
 
155
171
  # Opens the circuit breaker
156
172
  #
157
173
  # @return [void]
158
174
  def open_breaker!
159
- cache_store.write(open_key, Time.current.to_s, expires_in: cooldown_seconds)
175
+ cache_write(open_key, Time.current.to_s, expires_in: cooldown_seconds)
160
176
 
161
177
  # Fire alert if configured
162
178
  if RubyLLM::Agents.configuration.alerts_enabled? &&
@@ -164,6 +180,7 @@ module RubyLLM
164
180
  AlertManager.notify(:breaker_open, {
165
181
  agent_type: agent_type,
166
182
  model_id: model_id,
183
+ tenant_id: tenant_id,
167
184
  errors: errors_threshold,
168
185
  within: window_seconds,
169
186
  cooldown: cooldown_seconds,
@@ -176,21 +193,22 @@ module RubyLLM
176
193
  #
177
194
  # @return [String] Cache key
178
195
  def count_key
179
- "ruby_llm_agents:cb:count:#{agent_type}:#{model_id}"
196
+ if tenant_id.present?
197
+ cache_key("cb", "tenant", tenant_id, "count", agent_type, model_id)
198
+ else
199
+ cache_key("cb", "count", agent_type, model_id)
200
+ end
180
201
  end
181
202
 
182
203
  # Returns the cache key for the open flag
183
204
  #
184
205
  # @return [String] Cache key
185
206
  def open_key
186
- "ruby_llm_agents:cb:open:#{agent_type}:#{model_id}"
187
- end
188
-
189
- # Returns the cache store
190
- #
191
- # @return [ActiveSupport::Cache::Store] The cache store
192
- def cache_store
193
- RubyLLM::Agents.configuration.cache_store
207
+ if tenant_id.present?
208
+ cache_key("cb", "tenant", tenant_id, "open", agent_type, model_id)
209
+ else
210
+ cache_key("cb", "open", agent_type, model_id)
211
+ end
194
212
  end
195
213
  end
196
214
  end
@@ -178,6 +178,23 @@ module RubyLLM
178
178
  # max_value_length: 5000
179
179
  # }
180
180
 
181
+ # @!attribute [rw] multi_tenancy_enabled
182
+ # Whether multi-tenancy features are enabled.
183
+ # When false, the gem behaves exactly as before (backward compatible).
184
+ # @return [Boolean] Enable multi-tenancy (default: false)
185
+ # @example
186
+ # config.multi_tenancy_enabled = true
187
+
188
+ # @!attribute [rw] tenant_resolver
189
+ # Lambda that returns the current tenant identifier.
190
+ # Called whenever tenant context is needed for budget tracking,
191
+ # circuit breakers, and execution recording.
192
+ # @return [Proc] Tenant resolution lambda (default: -> { nil })
193
+ # @example Using Rails CurrentAttributes
194
+ # config.tenant_resolver = -> { Current.tenant&.id }
195
+ # @example Using request store
196
+ # config.tenant_resolver = -> { RequestStore[:tenant_id] }
197
+
181
198
  attr_accessor :default_model,
182
199
  :default_temperature,
183
200
  :default_timeout,
@@ -201,7 +218,9 @@ module RubyLLM
201
218
  :alerts,
202
219
  :persist_prompts,
203
220
  :persist_responses,
204
- :redaction
221
+ :redaction,
222
+ :multi_tenancy_enabled,
223
+ :tenant_resolver
205
224
 
206
225
  attr_writer :cache_store
207
226
 
@@ -241,6 +260,10 @@ module RubyLLM
241
260
  @persist_prompts = true
242
261
  @persist_responses = true
243
262
  @redaction = nil
263
+
264
+ # Multi-tenancy defaults (disabled for backward compatibility)
265
+ @multi_tenancy_enabled = false
266
+ @tenant_resolver = -> { nil }
244
267
  end
245
268
 
246
269
  # Returns the configured cache store, falling back to Rails.cache
@@ -313,6 +336,22 @@ module RubyLLM
313
336
  def redaction_max_value_length
314
337
  redaction&.dig(:max_value_length)
315
338
  end
339
+
340
+ # Returns whether multi-tenancy is enabled
341
+ #
342
+ # @return [Boolean] true if multi-tenancy is enabled
343
+ def multi_tenancy_enabled?
344
+ @multi_tenancy_enabled == true
345
+ end
346
+
347
+ # Returns the current tenant ID from the resolver
348
+ #
349
+ # @return [String, nil] Current tenant identifier or nil
350
+ def current_tenant_id
351
+ return nil unless multi_tenancy_enabled?
352
+
353
+ tenant_resolver&.call
354
+ end
316
355
  end
317
356
  end
318
357
  end
@@ -35,6 +35,7 @@ module RubyLLM
35
35
  require_relative "execution_logger_job"
36
36
  require_relative "instrumentation"
37
37
  require_relative "base"
38
+ require_relative "workflow"
38
39
 
39
40
  # Resolve the parent controller class from configuration
40
41
  # Default is ActionController::Base, but can be set to inherit from app controllers
@@ -46,7 +47,10 @@ module RubyLLM
46
47
 
47
48
  # Define the ApplicationController dynamically with the configured parent
48
49
  RubyLLM::Agents.const_set(:ApplicationController, Class.new(parent_class) do
49
- layout "rubyllm/agents/application"
50
+ # Prepend the engine's view path so templates are found correctly
51
+ prepend_view_path RubyLLM::Agents::Engine.root.join("app/views")
52
+
53
+ layout "ruby_llm/agents/application"
50
54
  helper RubyLLM::Agents::ApplicationHelper
51
55
  before_action :authenticate_dashboard!
52
56
 
@@ -93,6 +97,66 @@ module RubyLLM
93
97
  ActiveSupport::SecurityUtils.secure_compare(password, config.basic_auth_password)
94
98
  end
95
99
  end
100
+
101
+ # Returns whether multi-tenancy filtering is enabled
102
+ #
103
+ # @return [Boolean] true if multi-tenancy is enabled
104
+ # @api public
105
+ def tenant_filter_enabled?
106
+ RubyLLM::Agents.configuration.multi_tenancy_enabled?
107
+ end
108
+ helper_method :tenant_filter_enabled?
109
+
110
+ # Returns the current tenant ID for filtering
111
+ #
112
+ # Priority:
113
+ # 1. Explicit tenant_id param (for admin filtering)
114
+ # 2. Resolved from tenant_resolver
115
+ #
116
+ # @return [String, nil] Current tenant identifier
117
+ # @api public
118
+ def current_tenant_id
119
+ return @current_tenant_id if defined?(@current_tenant_id)
120
+
121
+ @current_tenant_id = if params[:tenant_id].present?
122
+ params[:tenant_id]
123
+ else
124
+ RubyLLM::Agents.configuration.current_tenant_id
125
+ end
126
+ end
127
+ helper_method :current_tenant_id
128
+
129
+ # Returns a tenant-scoped base query for executions
130
+ #
131
+ # If multi-tenancy is enabled and a tenant is selected,
132
+ # returns executions filtered by that tenant.
133
+ # Otherwise returns all executions.
134
+ #
135
+ # @return [ActiveRecord::Relation] Scoped executions
136
+ # @api public
137
+ def tenant_scoped_executions
138
+ if tenant_filter_enabled? && current_tenant_id.present?
139
+ RubyLLM::Agents::Execution.by_tenant(current_tenant_id)
140
+ else
141
+ RubyLLM::Agents::Execution.all
142
+ end
143
+ end
144
+ helper_method :tenant_scoped_executions
145
+
146
+ # Returns list of available tenants for filtering dropdown
147
+ #
148
+ # @return [Array<String>] Unique tenant IDs from executions
149
+ # @api public
150
+ def available_tenants
151
+ return @available_tenants if defined?(@available_tenants)
152
+
153
+ @available_tenants = RubyLLM::Agents::Execution
154
+ .where.not(tenant_id: nil)
155
+ .distinct
156
+ .pluck(:tenant_id)
157
+ .sort
158
+ end
159
+ helper_method :available_tenants
96
160
  end)
97
161
  end
98
162
 
@@ -12,10 +12,24 @@
12
12
  # @api private
13
13
 
14
14
  # Register "LLM" as an acronym for ActiveSupport inflector
15
+ # and add custom underscore rule for RubyLLM -> ruby_llm
15
16
  ActiveSupport::Inflector.inflections(:en) do |inflect|
16
17
  inflect.acronym "LLM"
18
+ # Ensure RubyLLM underscores correctly to ruby_llm (not rubyllm)
19
+ inflect.uncountable "ruby_llm"
17
20
  end
18
21
 
22
+ # Override underscore behavior for RubyLLM specifically
23
+ # This ensures view paths resolve correctly (ruby_llm/agents/... not rubyllm/agents/...)
24
+ module RubyLLMInflectionFix
25
+ def underscore
26
+ result = super
27
+ result.gsub("rubyllm", "ruby_llm")
28
+ end
29
+ end
30
+
31
+ String.prepend(RubyLLMInflectionFix)
32
+
19
33
  # Configure Zeitwerk to map directory names correctly
20
34
  ActiveSupport.on_load(:before_configuration) do
21
35
  Rails.autoloaders.each do |autoloader|
@@ -262,6 +262,11 @@ module RubyLLM
262
262
  execution_data[:attempts_count] = 0
263
263
  end
264
264
 
265
+ # Add tenant_id if multi-tenancy is enabled
266
+ if config.multi_tenancy_enabled?
267
+ execution_data[:tenant_id] = config.current_tenant_id
268
+ end
269
+
265
270
  RubyLLM::Agents::Execution.create!(execution_data)
266
271
  rescue StandardError => e
267
272
  # Log error but don't fail the agent execution itself
@@ -732,6 +737,67 @@ module RubyLLM
732
737
  end
733
738
  end
734
739
 
740
+ # Records an execution for a cache hit
741
+ #
742
+ # Creates a minimal execution record with cache_hit: true, 0 tokens,
743
+ # and 0 cost. This allows tracking cache hits in the dashboard.
744
+ #
745
+ # @param cache_key [String] The cache key that was hit
746
+ # @param cached_result [Object] The cached result returned
747
+ # @param started_at [Time] When the cache lookup started
748
+ # @return [void]
749
+ def record_cache_hit_execution(cache_key, cached_result, started_at)
750
+ config = RubyLLM::Agents.configuration
751
+ completed_at = Time.current
752
+ duration_ms = ((completed_at - started_at) * 1000).round
753
+
754
+ execution_data = {
755
+ agent_type: self.class.name,
756
+ agent_version: self.class.version,
757
+ model_id: model,
758
+ temperature: temperature,
759
+ status: "success",
760
+ cache_hit: true,
761
+ response_cache_key: cache_key,
762
+ cached_at: completed_at,
763
+ started_at: started_at,
764
+ completed_at: completed_at,
765
+ duration_ms: duration_ms,
766
+ input_tokens: 0,
767
+ output_tokens: 0,
768
+ cached_tokens: 0,
769
+ cache_creation_tokens: 0,
770
+ total_tokens: 0,
771
+ input_cost: 0,
772
+ output_cost: 0,
773
+ total_cost: 0,
774
+ parameters: redacted_parameters,
775
+ metadata: execution_metadata,
776
+ streaming: self.class.streaming
777
+ }
778
+
779
+ # Add tracing fields from metadata if present
780
+ metadata = execution_metadata
781
+ execution_data[:request_id] = metadata[:request_id] if metadata[:request_id]
782
+ execution_data[:trace_id] = metadata[:trace_id] if metadata[:trace_id]
783
+ execution_data[:span_id] = metadata[:span_id] if metadata[:span_id]
784
+ execution_data[:parent_execution_id] = metadata[:parent_execution_id] if metadata[:parent_execution_id]
785
+ execution_data[:root_execution_id] = metadata[:root_execution_id] if metadata[:root_execution_id]
786
+
787
+ # Add tenant_id if multi-tenancy is enabled
788
+ if config.multi_tenancy_enabled?
789
+ execution_data[:tenant_id] = config.current_tenant_id
790
+ end
791
+
792
+ if config.async_logging
793
+ RubyLLM::Agents::ExecutionLoggerJob.perform_later(execution_data)
794
+ else
795
+ RubyLLM::Agents::Execution.create!(execution_data)
796
+ end
797
+ rescue StandardError => e
798
+ Rails.logger.error("[RubyLLM::Agents] Failed to record cache hit execution: #{e.message}")
799
+ end
800
+
735
801
  # Emergency fallback to mark execution as failed
736
802
  #
737
803
  # Uses update_all to bypass ActiveRecord callbacks and validations,
@@ -42,21 +42,27 @@ module RubyLLM
42
42
  # @example
43
43
  # raise BudgetExceededError.new(:global_daily, 25.0, 27.5)
44
44
  #
45
+ # @example With tenant
46
+ # raise BudgetExceededError.new(:global_daily, 25.0, 27.5, tenant_id: "acme")
47
+ #
45
48
  # @api public
46
49
  class BudgetExceededError < Error
47
- attr_reader :scope, :limit, :current
50
+ attr_reader :scope, :limit, :current, :agent_type, :tenant_id
48
51
 
49
52
  # @param scope [Symbol] The budget scope (:global_daily, :global_monthly, :per_agent_daily, etc.)
50
53
  # @param limit [Float] The budget limit in USD
51
54
  # @param current [Float] The current spend in USD
52
55
  # @param agent_type [String, nil] The agent type for per-agent budgets
53
- def initialize(scope, limit, current, agent_type: nil)
56
+ # @param tenant_id [String, nil] The tenant identifier for multi-tenant budgets
57
+ def initialize(scope, limit, current, agent_type: nil, tenant_id: nil)
54
58
  @scope = scope
55
59
  @limit = limit
56
60
  @current = current
57
61
  @agent_type = agent_type
62
+ @tenant_id = tenant_id
58
63
 
59
64
  message = "Budget exceeded for #{scope}"
65
+ message += " (tenant: #{tenant_id})" if tenant_id
60
66
  message += " (#{agent_type})" if agent_type
61
67
  message += ": limit $#{limit}, current $#{current}"
62
68
  super(message)
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "0.3.4"
7
+ VERSION = "0.3.5"
8
8
  end
9
9
  end