ruby_llm-agents 0.3.6 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 40771a5bbdc5c7baea26c0900c6715e3e1033569154ebeb0d7cd60eff83d1389
4
- data.tar.gz: bedbf16ff8717ebcdca79aa256f9aae489677baad1b848baa38d4d485b87c803
3
+ metadata.gz: 3d8bfa346ee4010060508948c994bc719f5f1ff40dd2eec3aeb04500f2054341
4
+ data.tar.gz: 5546ca87104e12ba0522c2a6161ed2917355fb6fffcc7cc6dffd9920963b1037
5
5
  SHA512:
6
- metadata.gz: 3202614bf8c8209a1e9a589137440c804d2fcdc8fa2045840e4429963e06a0b4b1b25f82da99f6e1a5e3090afa1b149dec500d87ce59c09de946a3293e728962
7
- data.tar.gz: '08d565511fc883fdb83377e4993dbd699a5ecce23cbfdc454c6372279fc275165dddfc5c0b4b4d1010f1dde3c4acdaca4f260d0c07b6cc7b9ad81e7b13cd4b41'
6
+ metadata.gz: 391c1202b7bb677337329b5bd5358e9cb5bc4589d37cf48291bb4ff427d8ba1e3e7db99e8787d079ecd2f08382a4aa9940c573ad33c3be2040aa2e6ec21eb353
7
+ data.tar.gz: 9b1ae37b9ca74f060377d32232a9c26a33e411d285ac658be9508348d75316582aa47e309b85c3258fcaf33a1d5e103fdef2aa1090569b86140b046ee7e975fc
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "reliability_dsl"
4
+
3
5
  module RubyLLM
4
6
  module Agents
5
7
  class Base
@@ -74,6 +76,31 @@ module RubyLLM
74
76
 
75
77
  # @!group Reliability DSL
76
78
 
79
+ # Configures reliability features using a block syntax
80
+ #
81
+ # Groups all reliability configuration in a single block for clarity.
82
+ # Individual methods (retries, fallback_models, etc.) remain available
83
+ # for backward compatibility.
84
+ #
85
+ # @yield Block containing reliability configuration
86
+ # @return [void]
87
+ # @example
88
+ # reliability do
89
+ # retries max: 3, backoff: :exponential
90
+ # fallback_models "gpt-4o-mini"
91
+ # total_timeout 30
92
+ # circuit_breaker errors: 5
93
+ # end
94
+ def reliability(&block)
95
+ builder = ReliabilityDSL.new
96
+ builder.instance_eval(&block)
97
+
98
+ @retries_config = builder.retries_config if builder.retries_config
99
+ @fallback_models = builder.fallback_models_list if builder.fallback_models_list.any?
100
+ @total_timeout = builder.total_timeout_value if builder.total_timeout_value
101
+ @circuit_breaker_config = builder.circuit_breaker_config if builder.circuit_breaker_config
102
+ end
103
+
77
104
  # Configures retry behavior for this agent
78
105
  #
79
106
  # @param max [Integer] Maximum number of retry attempts (default: 0)
@@ -162,13 +189,18 @@ module RubyLLM
162
189
  # @param name [Symbol] The parameter name
163
190
  # @param required [Boolean] Whether the parameter is required
164
191
  # @param default [Object, nil] Default value if not provided
192
+ # @param type [Class, nil] Optional type for validation (e.g., String, Integer, Array)
165
193
  # @return [void]
166
- # @example
194
+ # @example Without type (accepts anything)
167
195
  # param :query, required: true
168
- # param :limit, default: 10
169
- def param(name, required: false, default: nil)
196
+ # param :data, default: {}
197
+ # @example With type validation
198
+ # param :limit, default: 10, type: Integer
199
+ # param :name, type: String
200
+ # param :tags, type: Array
201
+ def param(name, required: false, default: nil, type: nil)
170
202
  @params ||= {}
171
- @params[name] = { required: required, default: default }
203
+ @params[name] = { required: required, default: default, type: type }
172
204
  define_method(name) do
173
205
  @options[name] || @options[name.to_s] || self.class.params.dig(name, :default)
174
206
  end
@@ -186,17 +218,37 @@ module RubyLLM
186
218
 
187
219
  # @!group Caching DSL
188
220
 
189
- # Enables caching for this agent with optional TTL
221
+ # Enables caching for this agent with explicit TTL
222
+ #
223
+ # This is the preferred method for enabling caching.
190
224
  #
191
225
  # @param ttl [ActiveSupport::Duration] Time-to-live for cached responses
192
226
  # @return [void]
193
227
  # @example
194
- # cache 1.hour
195
- def cache(ttl = CACHE_TTL)
228
+ # cache_for 1.hour
229
+ # cache_for 30.minutes
230
+ def cache_for(ttl)
196
231
  @cache_enabled = true
197
232
  @cache_ttl = ttl
198
233
  end
199
234
 
235
+ # Enables caching for this agent with optional TTL
236
+ #
237
+ # @deprecated Use {#cache_for} instead for clarity.
238
+ # This method will be removed in version 1.0.
239
+ # @param ttl [ActiveSupport::Duration] Time-to-live for cached responses
240
+ # @return [void]
241
+ # @example
242
+ # cache 1.hour # deprecated
243
+ # cache_for 1.hour # preferred
244
+ def cache(ttl = CACHE_TTL)
245
+ RubyLLM::Agents::Deprecations.warn(
246
+ "cache(ttl) is deprecated. Use cache_for(ttl) instead for clarity.",
247
+ caller
248
+ )
249
+ cache_for(ttl)
250
+ end
251
+
200
252
  # Returns whether caching is enabled for this agent
201
253
  #
202
254
  # @return [Boolean] true if caching is enabled
@@ -243,13 +295,13 @@ module RubyLLM
243
295
  #
244
296
  # @param tool_classes [Array<Class>] Tool classes to make available
245
297
  # @return [Array<Class>] The current tools
298
+ # @example With array (preferred)
299
+ # tools [WeatherTool, SearchTool, CalculatorTool]
246
300
  # @example Single tool
247
- # tools WeatherTool
248
- # @example Multiple tools
249
- # tools WeatherTool, SearchTool, CalculatorTool
250
- def tools(*tool_classes)
251
- if tool_classes.any?
252
- @tools = tool_classes.flatten
301
+ # tools [WeatherTool]
302
+ def tools(tool_classes = nil)
303
+ if tool_classes
304
+ @tools = Array(tool_classes)
253
305
  end
254
306
  @tools || inherited_or_default(:tools, RubyLLM::Agents.configuration.default_tools)
255
307
  end
@@ -62,7 +62,7 @@ module RubyLLM
62
62
  reset_accumulated_tool_calls!
63
63
 
64
64
  Timeout.timeout(self.class.timeout) do
65
- if self.class.streaming && block_given?
65
+ if streaming_enabled? && block_given?
66
66
  execute_with_streaming(current_client, &block)
67
67
  else
68
68
  response = current_client.ask(user_prompt, **ask_options)
@@ -177,6 +177,16 @@ module RubyLLM
177
177
  config[:circuit_breaker].present?
178
178
  end
179
179
 
180
+ # Returns whether streaming is enabled for this execution
181
+ #
182
+ # Checks both class-level DSL setting and instance-level override
183
+ # (set by the stream class method).
184
+ #
185
+ # @return [Boolean] true if streaming is enabled
186
+ def streaming_enabled?
187
+ @force_streaming || self.class.streaming
188
+ end
189
+
180
190
  # Returns options to pass to the ask method
181
191
  #
182
192
  # Currently supports :with for attachments (images, PDFs, etc.)
@@ -188,14 +198,28 @@ module RubyLLM
188
198
  opts
189
199
  end
190
200
 
191
- # Validates that all required parameters are present
201
+ # Validates that all required parameters are present and types match
192
202
  #
193
- # @raise [ArgumentError] If required parameters are missing
203
+ # @raise [ArgumentError] If required parameters are missing or types don't match
194
204
  # @return [void]
195
205
  def validate_required_params!
196
- required = self.class.params.select { |_, v| v[:required] }.keys
197
- missing = required.reject { |p| @options.key?(p) || @options.key?(p.to_s) }
198
- raise ArgumentError, "#{self.class} missing required params: #{missing.join(', ')}" if missing.any?
206
+ self.class.params.each do |name, config|
207
+ value = @options[name] || @options[name.to_s]
208
+ has_value = @options.key?(name) || @options.key?(name.to_s)
209
+
210
+ # Check required
211
+ if config[:required] && !has_value
212
+ raise ArgumentError, "#{self.class} missing required param: #{name}"
213
+ end
214
+
215
+ # Check type if specified and value is present (not nil)
216
+ if config[:type] && has_value && !value.nil?
217
+ unless value.is_a?(config[:type])
218
+ raise ArgumentError,
219
+ "#{self.class} expected #{config[:type]} for :#{name}, got #{value.class}"
220
+ end
221
+ end
222
+ end
199
223
  end
200
224
 
201
225
  # Builds and configures the RubyLLM client
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # DSL builder for reliability configuration
7
+ #
8
+ # Provides a block-based configuration syntax for grouping
9
+ # all reliability settings together.
10
+ #
11
+ # @example Basic usage
12
+ # class MyAgent < ApplicationAgent
13
+ # reliability do
14
+ # retries max: 3, backoff: :exponential
15
+ # fallback_models "gpt-4o-mini"
16
+ # total_timeout 30
17
+ # circuit_breaker errors: 5, within: 60
18
+ # end
19
+ # end
20
+ #
21
+ # @api public
22
+ class ReliabilityDSL
23
+ attr_reader :retries_config, :fallback_models_list, :total_timeout_value, :circuit_breaker_config
24
+
25
+ def initialize
26
+ @retries_config = nil
27
+ @fallback_models_list = []
28
+ @total_timeout_value = nil
29
+ @circuit_breaker_config = nil
30
+ end
31
+
32
+ # Configures retry behavior
33
+ #
34
+ # @param max [Integer] Maximum retry attempts
35
+ # @param backoff [Symbol] :constant or :exponential
36
+ # @param base [Float] Base delay in seconds
37
+ # @param max_delay [Float] Maximum delay between retries
38
+ # @param on [Array<Class>] Additional error classes to retry on
39
+ # @return [void]
40
+ def retries(max: 0, backoff: :exponential, base: 0.4, max_delay: 3.0, on: [])
41
+ @retries_config = {
42
+ max: max,
43
+ backoff: backoff,
44
+ base: base,
45
+ max_delay: max_delay,
46
+ on: on
47
+ }
48
+ end
49
+
50
+ # Sets fallback models
51
+ #
52
+ # @param models [Array<String>] Model identifiers
53
+ # @return [void]
54
+ def fallback_models(*models)
55
+ @fallback_models_list = models.flatten
56
+ end
57
+
58
+ # Sets total timeout across all retry/fallback attempts
59
+ #
60
+ # @param seconds [Integer] Total timeout in seconds
61
+ # @return [void]
62
+ def total_timeout(seconds)
63
+ @total_timeout_value = seconds
64
+ end
65
+
66
+ # Configures circuit breaker
67
+ #
68
+ # @param errors [Integer] Failure threshold
69
+ # @param within [Integer] Rolling window in seconds
70
+ # @param cooldown [Integer] Cooldown period in seconds
71
+ # @return [void]
72
+ def circuit_breaker(errors: 10, within: 60, cooldown: 300)
73
+ @circuit_breaker_config = {
74
+ errors: errors,
75
+ within: within,
76
+ cooldown: cooldown
77
+ }
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -83,6 +83,33 @@ module RubyLLM
83
83
  def call(*args, **kwargs, &block)
84
84
  new(*args, **kwargs).call(&block)
85
85
  end
86
+
87
+ # Streams agent execution, yielding chunks as they arrive
88
+ #
89
+ # A more explicit alternative to passing a block to call.
90
+ # Forces streaming mode for this invocation regardless of class setting.
91
+ #
92
+ # @param kwargs [Hash] Agent parameters
93
+ # @yield [chunk] Yields each chunk as it arrives
94
+ # @yieldparam chunk [RubyLLM::Chunk] Streaming chunk with content
95
+ # @return [Result] The final result after streaming completes
96
+ # @raise [ArgumentError] If no block is provided
97
+ #
98
+ # @example Basic streaming
99
+ # MyAgent.stream(query: "test") do |chunk|
100
+ # print chunk.content
101
+ # end
102
+ #
103
+ # @example With result metadata
104
+ # result = MyAgent.stream(query: "test") { |c| print c.content }
105
+ # puts "\nTokens: #{result.total_tokens}"
106
+ def stream(**kwargs, &block)
107
+ raise ArgumentError, "Block required for streaming" unless block_given?
108
+
109
+ instance = new(**kwargs)
110
+ instance.instance_variable_set(:@force_streaming, true)
111
+ instance.call(&block)
112
+ end
86
113
  end
87
114
 
88
115
  # @!attribute [r] model
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Manages deprecation warnings with configurable behavior
6
+ #
7
+ # Provides a centralized mechanism for deprecation warnings that can be
8
+ # configured to raise exceptions in test environments or emit warnings
9
+ # in production.
10
+ #
11
+ # @example Emitting a deprecation warning
12
+ # Deprecations.warn("cache(ttl) is deprecated, use cache_for(ttl) instead")
13
+ #
14
+ # @example Enabling strict mode in tests
15
+ # RubyLLM::Agents::Deprecations.raise_on_deprecation = true
16
+ #
17
+ # @api public
18
+ module Deprecations
19
+ # Error raised when deprecation warnings are configured to raise
20
+ #
21
+ # @api public
22
+ class DeprecationError < StandardError; end
23
+
24
+ class << self
25
+ # @!attribute [rw] raise_on_deprecation
26
+ # @return [Boolean] Whether to raise exceptions instead of warnings
27
+ attr_accessor :raise_on_deprecation
28
+
29
+ # @!attribute [rw] silenced
30
+ # @return [Boolean] Whether to silence all deprecation warnings
31
+ attr_accessor :silenced
32
+
33
+ # Emits a deprecation warning or raises an error
34
+ #
35
+ # @param message [String] The deprecation message
36
+ # @param callstack [Array<String>] The call stack (defaults to caller)
37
+ # @return [void]
38
+ # @raise [DeprecationError] If raise_on_deprecation is true
39
+ def warn(message, callstack = caller)
40
+ return if silenced
41
+
42
+ full_message = "[RubyLLM::Agents DEPRECATION] #{message}"
43
+
44
+ if raise_on_deprecation
45
+ raise DeprecationError, full_message
46
+ elsif defined?(Rails) && Rails.respond_to?(:application) && Rails.application
47
+ # Use Rails deprecator if available (Rails 7.1+)
48
+ if Rails.application.respond_to?(:deprecators)
49
+ Rails.application.deprecators[:ruby_llm_agents]&.warn(full_message, callstack) ||
50
+ ::Kernel.warn("#{full_message}\n #{callstack.first}")
51
+ else
52
+ ::Kernel.warn("#{full_message}\n #{callstack.first}")
53
+ end
54
+ else
55
+ ::Kernel.warn("#{full_message}\n #{callstack.first}")
56
+ end
57
+ end
58
+
59
+ # Temporarily silence deprecation warnings within a block
60
+ #
61
+ # @yield Block to execute with silenced warnings
62
+ # @return [Object] The return value of the block
63
+ def silence
64
+ old_silenced = silenced
65
+ self.silenced = true
66
+ yield
67
+ ensure
68
+ self.silenced = old_silenced
69
+ end
70
+ end
71
+
72
+ # Reset to defaults
73
+ self.raise_on_deprecation = false
74
+ self.silenced = false
75
+ end
76
+ end
77
+ end
@@ -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
@@ -220,8 +220,78 @@ module RubyLLM
220
220
  }
221
221
  end
222
222
 
223
- # Delegate hash methods to content for backward compatibility
224
- delegate :[], :dig, :keys, :values, :each, :map, to: :content, allow_nil: true
223
+ # @!group Deprecated Hash Delegation
224
+ #
225
+ # These methods delegate to content for backward compatibility but are deprecated.
226
+ # Use result.content directly instead.
227
+ #
228
+ # @deprecated Access content directly via {#content} instead.
229
+ # These methods will be removed in version 1.0.
230
+ #
231
+ # @example Migration
232
+ # # Before (deprecated)
233
+ # result[:key]
234
+ # result.dig(:nested, :key)
235
+ #
236
+ # # After (recommended)
237
+ # result.content[:key]
238
+ # result.content.dig(:nested, :key)
239
+
240
+ # @deprecated Use result.content[:key] instead
241
+ def [](key)
242
+ RubyLLM::Agents::Deprecations.warn(
243
+ "Result#[] is deprecated. Use result.content[:key] instead.",
244
+ caller
245
+ )
246
+ content&.[](key)
247
+ end
248
+
249
+ # @deprecated Use result.content.dig(...) instead
250
+ def dig(*keys)
251
+ RubyLLM::Agents::Deprecations.warn(
252
+ "Result#dig is deprecated. Use result.content.dig(...) instead.",
253
+ caller
254
+ )
255
+ content&.dig(*keys)
256
+ end
257
+
258
+ # @deprecated Use result.content.keys instead
259
+ def keys
260
+ RubyLLM::Agents::Deprecations.warn(
261
+ "Result#keys is deprecated. Use result.content.keys instead.",
262
+ caller
263
+ )
264
+ content&.keys
265
+ end
266
+
267
+ # @deprecated Use result.content.values instead
268
+ def values
269
+ RubyLLM::Agents::Deprecations.warn(
270
+ "Result#values is deprecated. Use result.content.values instead.",
271
+ caller
272
+ )
273
+ content&.values
274
+ end
275
+
276
+ # @deprecated Use result.content.each instead
277
+ def each(&block)
278
+ RubyLLM::Agents::Deprecations.warn(
279
+ "Result#each is deprecated. Use result.content.each instead.",
280
+ caller
281
+ )
282
+ content&.each(&block)
283
+ end
284
+
285
+ # @deprecated Use result.content.map instead
286
+ def map(&block)
287
+ RubyLLM::Agents::Deprecations.warn(
288
+ "Result#map is deprecated. Use result.content.map instead.",
289
+ caller
290
+ )
291
+ content&.map(&block)
292
+ end
293
+
294
+ # @!endgroup
225
295
 
226
296
  # Custom to_json that returns content as JSON for backward compatibility
227
297
  #
@@ -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.6"
7
+ VERSION = "0.4.0"
8
8
  end
9
9
  end
@@ -5,7 +5,13 @@ require "ruby_llm"
5
5
 
6
6
  require_relative "agents/version"
7
7
  require_relative "agents/configuration"
8
+ require_relative "agents/deprecations"
8
9
  require_relative "agents/reliability"
10
+ require_relative "agents/reliability/retry_strategy"
11
+ require_relative "agents/reliability/fallback_routing"
12
+ require_relative "agents/reliability/breaker_manager"
13
+ require_relative "agents/reliability/execution_constraints"
14
+ require_relative "agents/reliability/executor"
9
15
  require_relative "agents/redactor"
10
16
  require_relative "agents/circuit_breaker"
11
17
  require_relative "agents/budget_tracker"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.6
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90
@@ -140,6 +140,7 @@ files:
140
140
  - lib/ruby_llm/agents/base/cost_calculation.rb
141
141
  - lib/ruby_llm/agents/base/dsl.rb
142
142
  - lib/ruby_llm/agents/base/execution.rb
143
+ - lib/ruby_llm/agents/base/reliability_dsl.rb
143
144
  - lib/ruby_llm/agents/base/reliability_execution.rb
144
145
  - lib/ruby_llm/agents/base/response_building.rb
145
146
  - lib/ruby_llm/agents/base/tool_tracking.rb
@@ -147,12 +148,18 @@ files:
147
148
  - lib/ruby_llm/agents/cache_helper.rb
148
149
  - lib/ruby_llm/agents/circuit_breaker.rb
149
150
  - lib/ruby_llm/agents/configuration.rb
151
+ - lib/ruby_llm/agents/deprecations.rb
150
152
  - lib/ruby_llm/agents/engine.rb
151
153
  - lib/ruby_llm/agents/execution_logger_job.rb
152
154
  - lib/ruby_llm/agents/inflections.rb
153
155
  - lib/ruby_llm/agents/instrumentation.rb
154
156
  - lib/ruby_llm/agents/redactor.rb
155
157
  - lib/ruby_llm/agents/reliability.rb
158
+ - lib/ruby_llm/agents/reliability/breaker_manager.rb
159
+ - lib/ruby_llm/agents/reliability/execution_constraints.rb
160
+ - lib/ruby_llm/agents/reliability/executor.rb
161
+ - lib/ruby_llm/agents/reliability/fallback_routing.rb
162
+ - lib/ruby_llm/agents/reliability/retry_strategy.rb
156
163
  - lib/ruby_llm/agents/result.rb
157
164
  - lib/ruby_llm/agents/version.rb
158
165
  - lib/ruby_llm/agents/workflow.rb