ruby_llm-agents 0.3.6 → 0.5.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.
- checksums.yaml +4 -4
- data/README.md +46 -13
- data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
- data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
- data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
- data/app/models/ruby_llm/agents/tenant_budget.rb +62 -7
- data/app/views/layouts/ruby_llm/agents/application.html.erb +3 -1
- data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
- data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
- data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
- data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
- data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
- data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
- data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
- data/app/views/ruby_llm/agents/executions/show.html.erb +82 -0
- data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
- data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
- data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
- data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
- data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
- data/config/routes.rb +12 -1
- data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
- data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
- data/lib/ruby_llm/agents/base/dsl.rb +65 -13
- data/lib/ruby_llm/agents/base/execution.rb +113 -6
- data/lib/ruby_llm/agents/base/reliability_dsl.rb +82 -0
- data/lib/ruby_llm/agents/base.rb +28 -0
- data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
- data/lib/ruby_llm/agents/configuration.rb +38 -1
- data/lib/ruby_llm/agents/deprecations.rb +77 -0
- data/lib/ruby_llm/agents/engine.rb +1 -0
- data/lib/ruby_llm/agents/instrumentation.rb +71 -3
- data/lib/ruby_llm/agents/reliability/breaker_manager.rb +80 -0
- data/lib/ruby_llm/agents/reliability/execution_constraints.rb +69 -0
- data/lib/ruby_llm/agents/reliability/executor.rb +124 -0
- data/lib/ruby_llm/agents/reliability/fallback_routing.rb +72 -0
- data/lib/ruby_llm/agents/reliability/retry_strategy.rb +76 -0
- data/lib/ruby_llm/agents/resolved_config.rb +348 -0
- data/lib/ruby_llm/agents/result.rb +72 -2
- data/lib/ruby_llm/agents/version.rb +1 -1
- data/lib/ruby_llm/agents.rb +6 -0
- metadata +26 -3
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Reliability
|
|
6
|
+
# Manages circuit breakers for multiple models
|
|
7
|
+
#
|
|
8
|
+
# Provides centralized access to circuit breakers with
|
|
9
|
+
# multi-tenant support and caching.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# manager = BreakerManager.new("MyAgent", config: { errors: 5, within: 60 })
|
|
13
|
+
# manager.open?("gpt-4o") # => false
|
|
14
|
+
# manager.record_failure!("gpt-4o")
|
|
15
|
+
# manager.record_success!("gpt-4o")
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class BreakerManager
|
|
19
|
+
# @param agent_type [String] The agent class name
|
|
20
|
+
# @param config [Hash, nil] Circuit breaker configuration
|
|
21
|
+
# @param tenant_id [String, nil] Optional tenant identifier
|
|
22
|
+
def initialize(agent_type, config:, tenant_id: nil)
|
|
23
|
+
@agent_type = agent_type
|
|
24
|
+
@config = config
|
|
25
|
+
@tenant_id = tenant_id
|
|
26
|
+
@breakers = {}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Gets or creates a circuit breaker for a model
|
|
30
|
+
#
|
|
31
|
+
# @param model_id [String] Model identifier
|
|
32
|
+
# @return [CircuitBreaker, nil] The circuit breaker or nil if not configured
|
|
33
|
+
def for_model(model_id)
|
|
34
|
+
return nil unless @config
|
|
35
|
+
|
|
36
|
+
@breakers[model_id] ||= CircuitBreaker.from_config(
|
|
37
|
+
@agent_type,
|
|
38
|
+
model_id,
|
|
39
|
+
@config,
|
|
40
|
+
tenant_id: @tenant_id
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Checks if a model's circuit breaker is open
|
|
45
|
+
#
|
|
46
|
+
# @param model_id [String] Model identifier
|
|
47
|
+
# @return [Boolean] true if breaker is open
|
|
48
|
+
def open?(model_id)
|
|
49
|
+
breaker = for_model(model_id)
|
|
50
|
+
breaker&.open? || false
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Records a success for a model
|
|
54
|
+
#
|
|
55
|
+
# @param model_id [String] Model identifier
|
|
56
|
+
# @return [void]
|
|
57
|
+
def record_success!(model_id)
|
|
58
|
+
for_model(model_id)&.record_success!
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Records a failure for a model
|
|
62
|
+
#
|
|
63
|
+
# @param model_id [String] Model identifier
|
|
64
|
+
# @return [Boolean] true if breaker is now open
|
|
65
|
+
def record_failure!(model_id)
|
|
66
|
+
breaker = for_model(model_id)
|
|
67
|
+
breaker&.record_failure!
|
|
68
|
+
breaker&.open? || false
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Checks if circuit breaker is configured
|
|
72
|
+
#
|
|
73
|
+
# @return [Boolean] true if config present
|
|
74
|
+
def enabled?
|
|
75
|
+
@config.present?
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Reliability
|
|
6
|
+
# Manages execution constraints like total timeout and budget
|
|
7
|
+
#
|
|
8
|
+
# Tracks elapsed time and enforces timeout limits across
|
|
9
|
+
# all retry and fallback attempts.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# constraints = ExecutionConstraints.new(total_timeout: 30)
|
|
13
|
+
# constraints.timeout_exceeded? # => false
|
|
14
|
+
# constraints.enforce_timeout! # raises if exceeded
|
|
15
|
+
# constraints.elapsed # => 5.2
|
|
16
|
+
#
|
|
17
|
+
# @api private
|
|
18
|
+
class ExecutionConstraints
|
|
19
|
+
attr_reader :total_timeout, :started_at, :deadline
|
|
20
|
+
|
|
21
|
+
# @param total_timeout [Integer, nil] Total timeout in seconds
|
|
22
|
+
def initialize(total_timeout: nil)
|
|
23
|
+
@total_timeout = total_timeout
|
|
24
|
+
@started_at = Time.current
|
|
25
|
+
@deadline = total_timeout ? @started_at + total_timeout : nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Checks if total timeout has been exceeded
|
|
29
|
+
#
|
|
30
|
+
# @return [Boolean] true if past deadline
|
|
31
|
+
def timeout_exceeded?
|
|
32
|
+
deadline && Time.current > deadline
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Returns elapsed time since start
|
|
36
|
+
#
|
|
37
|
+
# @return [Float] Elapsed seconds
|
|
38
|
+
def elapsed
|
|
39
|
+
Time.current - started_at
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Raises TotalTimeoutError if timeout exceeded
|
|
43
|
+
#
|
|
44
|
+
# @raise [TotalTimeoutError] If timeout exceeded
|
|
45
|
+
# @return [void]
|
|
46
|
+
def enforce_timeout!
|
|
47
|
+
if timeout_exceeded?
|
|
48
|
+
raise TotalTimeoutError.new(total_timeout, elapsed)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Returns remaining time until deadline
|
|
53
|
+
#
|
|
54
|
+
# @return [Float, nil] Remaining seconds or nil if no timeout
|
|
55
|
+
def remaining
|
|
56
|
+
return nil unless deadline
|
|
57
|
+
[deadline - Time.current, 0].max
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Checks if there's a timeout configured
|
|
61
|
+
#
|
|
62
|
+
# @return [Boolean] true if timeout is set
|
|
63
|
+
def has_timeout?
|
|
64
|
+
total_timeout.present?
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Reliability
|
|
6
|
+
# Coordinates reliability features during agent execution
|
|
7
|
+
#
|
|
8
|
+
# Orchestrates retry strategy, fallback routing, circuit breakers,
|
|
9
|
+
# and execution constraints into a cohesive execution flow.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# executor = Executor.new(
|
|
13
|
+
# config: { retries: { max: 3 }, fallback_models: ["gpt-4o-mini"] },
|
|
14
|
+
# primary_model: "gpt-4o",
|
|
15
|
+
# agent_type: "MyAgent"
|
|
16
|
+
# )
|
|
17
|
+
# executor.execute { |model| call_llm(model) }
|
|
18
|
+
#
|
|
19
|
+
# @api private
|
|
20
|
+
class Executor
|
|
21
|
+
attr_reader :retry_strategy, :fallback_routing, :breaker_manager, :constraints
|
|
22
|
+
|
|
23
|
+
# @param config [Hash] Reliability configuration
|
|
24
|
+
# @param primary_model [String] Primary model identifier
|
|
25
|
+
# @param agent_type [String] Agent class name
|
|
26
|
+
# @param tenant_id [String, nil] Optional tenant identifier
|
|
27
|
+
def initialize(config:, primary_model:, agent_type:, tenant_id: nil)
|
|
28
|
+
retries_config = config[:retries] || {}
|
|
29
|
+
|
|
30
|
+
@retry_strategy = RetryStrategy.new(
|
|
31
|
+
max: retries_config[:max] || 0,
|
|
32
|
+
backoff: retries_config[:backoff] || :exponential,
|
|
33
|
+
base: retries_config[:base] || 0.4,
|
|
34
|
+
max_delay: retries_config[:max_delay] || 3.0,
|
|
35
|
+
on: retries_config[:on] || []
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
@fallback_routing = FallbackRouting.new(
|
|
39
|
+
primary_model,
|
|
40
|
+
fallback_models: config[:fallback_models] || []
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
@breaker_manager = BreakerManager.new(
|
|
44
|
+
agent_type,
|
|
45
|
+
config: config[:circuit_breaker],
|
|
46
|
+
tenant_id: tenant_id
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
@constraints = ExecutionConstraints.new(
|
|
50
|
+
total_timeout: config[:total_timeout]
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
@last_error = nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Returns all models that will be tried
|
|
57
|
+
#
|
|
58
|
+
# @return [Array<String>] Model identifiers
|
|
59
|
+
def models_to_try
|
|
60
|
+
fallback_routing.models
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Executes with full reliability support
|
|
64
|
+
#
|
|
65
|
+
# Iterates through models with retries, respecting circuit breakers
|
|
66
|
+
# and timeout constraints.
|
|
67
|
+
#
|
|
68
|
+
# @yield [model] Block to execute with the current model
|
|
69
|
+
# @yieldparam model [String] The model to use for this attempt
|
|
70
|
+
# @return [Object] Result of successful execution
|
|
71
|
+
# @raise [AllModelsExhaustedError] If all models fail
|
|
72
|
+
# @raise [TotalTimeoutError] If total timeout exceeded
|
|
73
|
+
def execute
|
|
74
|
+
until fallback_routing.exhausted?
|
|
75
|
+
model = fallback_routing.current_model
|
|
76
|
+
|
|
77
|
+
# Check circuit breaker
|
|
78
|
+
if breaker_manager.open?(model)
|
|
79
|
+
fallback_routing.advance!
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Try with retries
|
|
84
|
+
result = execute_with_retries(model) { |m| yield(m) }
|
|
85
|
+
return result if result
|
|
86
|
+
|
|
87
|
+
fallback_routing.advance!
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
raise AllModelsExhaustedError.new(
|
|
91
|
+
fallback_routing.models,
|
|
92
|
+
@last_error || StandardError.new("All models failed")
|
|
93
|
+
)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def execute_with_retries(model)
|
|
99
|
+
attempt_index = 0
|
|
100
|
+
|
|
101
|
+
loop do
|
|
102
|
+
constraints.enforce_timeout!
|
|
103
|
+
|
|
104
|
+
begin
|
|
105
|
+
result = yield(model)
|
|
106
|
+
breaker_manager.record_success!(model)
|
|
107
|
+
return result
|
|
108
|
+
rescue => e
|
|
109
|
+
@last_error = e
|
|
110
|
+
breaker_manager.record_failure!(model)
|
|
111
|
+
|
|
112
|
+
if retry_strategy.retryable?(e) && retry_strategy.should_retry?(attempt_index)
|
|
113
|
+
attempt_index += 1
|
|
114
|
+
sleep(retry_strategy.delay_for(attempt_index))
|
|
115
|
+
else
|
|
116
|
+
return nil # Move to next model
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Reliability
|
|
6
|
+
# Routes execution through fallback models when primary fails
|
|
7
|
+
#
|
|
8
|
+
# Manages the model fallback chain and tracks which models have been tried.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# routing = FallbackRouting.new("gpt-4o", fallback_models: ["gpt-4o-mini"])
|
|
12
|
+
# routing.current_model # => "gpt-4o"
|
|
13
|
+
# routing.advance! # => "gpt-4o-mini"
|
|
14
|
+
# routing.exhausted? # => false
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
class FallbackRouting
|
|
18
|
+
attr_reader :models
|
|
19
|
+
|
|
20
|
+
# @param primary_model [String] The primary model identifier
|
|
21
|
+
# @param fallback_models [Array<String>] Fallback model identifiers
|
|
22
|
+
def initialize(primary_model, fallback_models: [])
|
|
23
|
+
@models = [primary_model, *fallback_models].uniq
|
|
24
|
+
@current_index = 0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Returns the current model to try
|
|
28
|
+
#
|
|
29
|
+
# @return [String, nil] Model identifier or nil if exhausted
|
|
30
|
+
def current_model
|
|
31
|
+
models[@current_index]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Advances to the next fallback model
|
|
35
|
+
#
|
|
36
|
+
# @return [String, nil] Next model or nil if exhausted
|
|
37
|
+
def advance!
|
|
38
|
+
@current_index += 1
|
|
39
|
+
current_model
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Checks if more models are available after current
|
|
43
|
+
#
|
|
44
|
+
# @return [Boolean] true if more models to try
|
|
45
|
+
def has_more?
|
|
46
|
+
@current_index < models.length - 1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Checks if all models have been exhausted
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean] true if no more models
|
|
52
|
+
def exhausted?
|
|
53
|
+
@current_index >= models.length
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Resets to the first model
|
|
57
|
+
#
|
|
58
|
+
# @return [void]
|
|
59
|
+
def reset!
|
|
60
|
+
@current_index = 0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Returns models that have been tried so far
|
|
64
|
+
#
|
|
65
|
+
# @return [Array<String>] Models already attempted
|
|
66
|
+
def tried_models
|
|
67
|
+
models[0..@current_index]
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module Reliability
|
|
6
|
+
# Handles retry logic with configurable backoff strategies
|
|
7
|
+
#
|
|
8
|
+
# Provides exponential and constant backoff with jitter,
|
|
9
|
+
# retry counting, and delay calculation.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# strategy = RetryStrategy.new(max: 3, backoff: :exponential, base: 0.4)
|
|
13
|
+
# strategy.should_retry?(attempt_index) # => true/false
|
|
14
|
+
# strategy.delay_for(attempt_index) # => 0.6 (with jitter)
|
|
15
|
+
#
|
|
16
|
+
# @api private
|
|
17
|
+
class RetryStrategy
|
|
18
|
+
attr_reader :max, :backoff, :base, :max_delay, :custom_errors
|
|
19
|
+
|
|
20
|
+
# @param max [Integer] Maximum retry attempts
|
|
21
|
+
# @param backoff [Symbol] :constant or :exponential
|
|
22
|
+
# @param base [Float] Base delay in seconds
|
|
23
|
+
# @param max_delay [Float] Maximum delay cap
|
|
24
|
+
# @param on [Array<Class>] Additional error classes to retry on
|
|
25
|
+
def initialize(max: 0, backoff: :exponential, base: 0.4, max_delay: 3.0, on: [])
|
|
26
|
+
@max = max
|
|
27
|
+
@backoff = backoff
|
|
28
|
+
@base = base
|
|
29
|
+
@max_delay = max_delay
|
|
30
|
+
@custom_errors = Array(on)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Determines if retry should occur
|
|
34
|
+
#
|
|
35
|
+
# @param attempt_index [Integer] Current attempt number (0-indexed)
|
|
36
|
+
# @return [Boolean] true if should retry
|
|
37
|
+
def should_retry?(attempt_index)
|
|
38
|
+
attempt_index < max
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Calculates delay before next retry
|
|
42
|
+
#
|
|
43
|
+
# @param attempt_index [Integer] Current attempt number
|
|
44
|
+
# @return [Float] Delay in seconds (includes jitter)
|
|
45
|
+
def delay_for(attempt_index)
|
|
46
|
+
base_delay = case backoff
|
|
47
|
+
when :constant
|
|
48
|
+
base
|
|
49
|
+
when :exponential
|
|
50
|
+
[base * (2**attempt_index), max_delay].min
|
|
51
|
+
else
|
|
52
|
+
base
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Add jitter (0-50% of base delay)
|
|
56
|
+
base_delay + (rand * base_delay * 0.5)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Checks if an error is retryable
|
|
60
|
+
#
|
|
61
|
+
# @param error [Exception] The error to check
|
|
62
|
+
# @return [Boolean] true if retryable
|
|
63
|
+
def retryable?(error)
|
|
64
|
+
RubyLLM::Agents::Reliability.retryable_error?(error, custom_errors: custom_errors)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Returns all retryable error classes
|
|
68
|
+
#
|
|
69
|
+
# @return [Array<Class>] Error classes to retry on
|
|
70
|
+
def retryable_errors
|
|
71
|
+
RubyLLM::Agents::Reliability.default_retryable_errors + custom_errors
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|