ruby_llm-agents 0.3.3 → 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.
- checksums.yaml +4 -4
- data/README.md +132 -1263
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
- data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +137 -9
- data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
- data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
- data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
- data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
- data/app/models/ruby_llm/agents/execution.rb +28 -59
- data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
- data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
- data/app/views/layouts/ruby_llm/agents/application.html.erb +430 -0
- data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
- data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
- data/app/views/ruby_llm/agents/agents/show.html.erb +775 -0
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
- data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
- data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
- data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
- data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
- data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +260 -200
- data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
- data/config/routes.rb +2 -0
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
- data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
- data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
- data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
- data/lib/ruby_llm/agents/alert_manager.rb +20 -16
- data/lib/ruby_llm/agents/base/caching.rb +40 -0
- data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
- data/lib/ruby_llm/agents/base/dsl.rb +261 -0
- data/lib/ruby_llm/agents/base/execution.rb +258 -0
- data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
- data/lib/ruby_llm/agents/base/response_building.rb +86 -0
- data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
- data/lib/ruby_llm/agents/base.rb +37 -801
- data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
- data/lib/ruby_llm/agents/cache_helper.rb +98 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
- data/lib/ruby_llm/agents/configuration.rb +40 -1
- data/lib/ruby_llm/agents/engine.rb +65 -1
- data/lib/ruby_llm/agents/inflections.rb +14 -0
- data/lib/ruby_llm/agents/instrumentation.rb +66 -0
- data/lib/ruby_llm/agents/reliability.rb +8 -2
- data/lib/ruby_llm/agents/version.rb +1 -1
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
- data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
- data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
- data/lib/ruby_llm/agents/workflow/result.rb +390 -0
- data/lib/ruby_llm/agents/workflow/router.rb +429 -0
- data/lib/ruby_llm/agents/workflow.rb +232 -0
- data/lib/ruby_llm/agents.rb +1 -0
- metadata +57 -75
- data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
- data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
- data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
- data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
- data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
- data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
- data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
- data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
- data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
- data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
- /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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|