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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -13
  3. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
  4. data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
  6. data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
  7. data/app/models/ruby_llm/agents/tenant_budget.rb +62 -7
  8. data/app/views/layouts/ruby_llm/agents/application.html.erb +3 -1
  9. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
  10. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
  11. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
  12. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
  13. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
  14. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
  15. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +82 -0
  17. data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
  18. data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
  19. data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
  22. data/config/routes.rb +12 -1
  23. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
  24. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
  25. data/lib/ruby_llm/agents/base/dsl.rb +65 -13
  26. data/lib/ruby_llm/agents/base/execution.rb +113 -6
  27. data/lib/ruby_llm/agents/base/reliability_dsl.rb +82 -0
  28. data/lib/ruby_llm/agents/base.rb +28 -0
  29. data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
  30. data/lib/ruby_llm/agents/configuration.rb +38 -1
  31. data/lib/ruby_llm/agents/deprecations.rb +77 -0
  32. data/lib/ruby_llm/agents/engine.rb +1 -0
  33. data/lib/ruby_llm/agents/instrumentation.rb +71 -3
  34. data/lib/ruby_llm/agents/reliability/breaker_manager.rb +80 -0
  35. data/lib/ruby_llm/agents/reliability/execution_constraints.rb +69 -0
  36. data/lib/ruby_llm/agents/reliability/executor.rb +124 -0
  37. data/lib/ruby_llm/agents/reliability/fallback_routing.rb +72 -0
  38. data/lib/ruby_llm/agents/reliability/retry_strategy.rb +76 -0
  39. data/lib/ruby_llm/agents/resolved_config.rb +348 -0
  40. data/lib/ruby_llm/agents/result.rb +72 -2
  41. data/lib/ruby_llm/agents/version.rb +1 -1
  42. data/lib/ruby_llm/agents.rb +6 -0
  43. 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