ruby_llm-agents 0.3.5 → 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: 6dbcf1c63db9bd3aad406eaa802c632f932f7dc5ebe0eaf2cbec51c492cf89ed
4
- data.tar.gz: 4b6b7d2c440371147c9e754a91f86fb5712aa388584d7f6bef9204f1c10c49b6
3
+ metadata.gz: 3d8bfa346ee4010060508948c994bc719f5f1ff40dd2eec3aeb04500f2054341
4
+ data.tar.gz: 5546ca87104e12ba0522c2a6161ed2917355fb6fffcc7cc6dffd9920963b1037
5
5
  SHA512:
6
- metadata.gz: da89ade6f5a60c77253c7a9647447085632126dfc4d231aa7afec1eca6dc3a82abe5850575db7fb1c23ca71708bf685d74dc138c9b5a277b5f760377e664aec7
7
- data.tar.gz: f9551e2dc805d7d76ea42d90098a863bc6abe3451801ab8c893696dd4662c8fc804b4820d3dbb61feb9c90a42d7a0111ab1e2440b94a419df4ef78818ea981a2
6
+ metadata.gz: 391c1202b7bb677337329b5bd5358e9cb5bc4589d37cf48291bb4ff427d8ba1e3e7db99e8787d079ecd2f08382a4aa9940c573ad33c3be2040aa2e6ec21eb353
7
+ data.tar.gz: 9b1ae37b9ca74f060377d32232a9c26a33e411d285ac658be9508348d75316582aa47e309b85c3258fcaf33a1d5e103fdef2aa1090569b86140b046ee7e975fc
@@ -58,7 +58,7 @@ module RubyLLM
58
58
  #
59
59
  # @return [void]
60
60
  def show
61
- @agent_type = params[:id]
61
+ @agent_type = CGI.unescape(params[:id])
62
62
  @agent_class = AgentRegistry.find(@agent_type)
63
63
  @agent_active = @agent_class.present?
64
64
 
@@ -215,6 +215,7 @@ module RubyLLM
215
215
  model: @agent_class.model,
216
216
  temperature: @agent_class.temperature,
217
217
  version: @agent_class.version,
218
+ description: @agent_class.respond_to?(:description) ? @agent_class.description : nil,
218
219
  timeout: @agent_class.timeout,
219
220
  cache_enabled: @agent_class.cache_enabled?,
220
221
  cache_ttl: @agent_class.cache_ttl,
@@ -140,6 +140,7 @@ module RubyLLM
140
140
  workflow_type: workflow_type,
141
141
  workflow_children: workflow_children,
142
142
  version: safe_call(agent_class, :version) || "N/A",
143
+ description: safe_call(agent_class, :description),
143
144
  model: safe_call(agent_class, :model) || (is_workflow ? "workflow" : "N/A"),
144
145
  temperature: safe_call(agent_class, :temperature),
145
146
  timeout: safe_call(agent_class, :timeout),
@@ -1,10 +1,14 @@
1
- <%= link_to ruby_llm_agents.agent_path(agent[:name]), class: "block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow" do %>
1
+ <%= link_to ruby_llm_agents.agent_path(ERB::Util.url_encode(agent[:name])), class: "block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow" do %>
2
2
  <div class="p-4 sm:p-5">
3
3
  <!-- Row 1: Agent name + badge + model/timestamp -->
4
4
  <div class="flex items-center justify-between gap-2">
5
5
  <div class="flex items-center gap-2 min-w-0">
6
6
  <h3 class="font-semibold text-gray-900 dark:text-gray-100 truncate">
7
- <%= agent[:name].gsub(/Agent$/, '') %>
7
+ <% name_parts = agent[:name].gsub(/Agent$/, '').split('::') %>
8
+ <% if name_parts.length > 1 %>
9
+ <span class="text-gray-400 dark:text-gray-500 font-normal"><%= name_parts[0..-2].join('::') %>::</span>
10
+ <% end %>
11
+ <%= name_parts.last %>
8
12
  </h3>
9
13
  <span class="hidden sm:inline text-sm text-gray-500 dark:text-gray-400">v<%= agent[:version] %></span>
10
14
  <% if agent[:active] %>
@@ -25,6 +29,13 @@
25
29
  </span>
26
30
  </div>
27
31
 
32
+ <!-- Description -->
33
+ <% if agent[:description].present? %>
34
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
35
+ <%= agent[:description] %>
36
+ </p>
37
+ <% end %>
38
+
28
39
  <!-- Row 2: Stats -->
29
40
  <div class="mt-2 sm:mt-3 sm:border-t sm:border-gray-100 sm:dark:border-gray-700 sm:pt-3">
30
41
  <!-- Mobile: compact inline -->
@@ -15,13 +15,17 @@
15
15
  %>
16
16
 
17
17
  <div x-data="{ expanded: false }" class="block bg-white dark:bg-gray-800 rounded-lg shadow hover:shadow-md transition-shadow border-l-4 <%= colors[:border] %>">
18
- <%= link_to ruby_llm_agents.agent_path(workflow[:name]), class: "block p-4 sm:p-5" do %>
18
+ <%= link_to ruby_llm_agents.agent_path(ERB::Util.url_encode(workflow[:name])), class: "block p-4 sm:p-5" do %>
19
19
  <!-- Header Row -->
20
20
  <div class="flex items-center justify-between gap-2">
21
21
  <div class="flex items-center gap-2 min-w-0">
22
22
  <%= render "ruby_llm/agents/shared/workflow_type_badge", workflow_type: workflow[:workflow_type], size: :sm %>
23
23
  <h3 class="font-semibold text-gray-900 dark:text-gray-100 truncate">
24
- <%= workflow[:name].gsub(/Workflow$/, '').gsub(/Pipeline$|Parallel$|Router$/, '') %>
24
+ <% name_parts = workflow[:name].gsub(/Workflow$/, '').gsub(/Pipeline$|Parallel$|Router$/, '').split('::') %>
25
+ <% if name_parts.length > 1 %>
26
+ <span class="text-gray-400 dark:text-gray-500 font-normal"><%= name_parts[0..-2].join('::') %>::</span>
27
+ <% end %>
28
+ <%= name_parts.last %>
25
29
  </h3>
26
30
  <span class="hidden sm:inline text-sm text-gray-500 dark:text-gray-400">v<%= workflow[:version] %></span>
27
31
  <% if workflow[:active] %>
@@ -35,6 +39,13 @@
35
39
  </span>
36
40
  </div>
37
41
 
42
+ <!-- Description -->
43
+ <% if workflow[:description].present? %>
44
+ <p class="mt-1 text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
45
+ <%= workflow[:description] %>
46
+ </p>
47
+ <% end %>
48
+
38
49
  <!-- Stats Row -->
39
50
  <div class="mt-2 sm:mt-3 sm:border-t sm:border-gray-100 sm:dark:border-gray-700 sm:pt-3">
40
51
  <% success_rate = workflow[:success_rate] || 0 %>
@@ -45,6 +45,11 @@
45
45
  <%= @config[:model] %> &middot; temp <%= @config[:temperature] %>
46
46
  &middot; timeout <%= @config[:timeout] %>s
47
47
  </p>
48
+ <% if @config[:description].present? %>
49
+ <p class="text-gray-600 dark:text-gray-300 mt-2">
50
+ <%= @config[:description] %>
51
+ </p>
52
+ <% end %>
48
53
  <% end %>
49
54
  </div>
50
55
 
@@ -29,16 +29,21 @@ module RubyLlmAgents
29
29
  desc: "Cache TTL (e.g., '1.hour', '30.minutes')"
30
30
 
31
31
  def create_agent_file
32
- template "agent.rb.tt", "app/agents/#{file_name}_agent.rb"
32
+ # Support nested paths: "chat/support" -> "app/agents/chat/support_agent.rb"
33
+ # Rails' class_name handles namespacing: "chat/support" -> "Chat::Support"
34
+ agent_path = name.underscore
35
+ template "agent.rb.tt", "app/agents/#{agent_path}_agent.rb"
33
36
  end
34
37
 
35
38
  def show_usage
39
+ # Build full class name from path (e.g., "chat/support" -> "Chat::Support")
40
+ full_class_name = name.split('/').map(&:camelize).join("::")
36
41
  say ""
37
- say "Agent #{class_name}Agent created!", :green
42
+ say "Agent #{full_class_name}Agent created!", :green
38
43
  say ""
39
44
  say "Usage:"
40
- say " #{class_name}Agent.call(#{usage_params})"
41
- say " #{class_name}Agent.call(#{usage_params}, dry_run: true)"
45
+ say " #{full_class_name}Agent.call(#{usage_params})"
46
+ say " #{full_class_name}Agent.call(#{usage_params}, dry_run: true)"
42
47
  say ""
43
48
  end
44
49
 
@@ -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
@@ -48,6 +50,17 @@ module RubyLLM
48
50
  @version || inherited_or_default(:version, VERSION)
49
51
  end
50
52
 
53
+ # Sets or returns the description for this agent class
54
+ #
55
+ # @param value [String, nil] The description text
56
+ # @return [String, nil] The current description
57
+ # @example
58
+ # description "Searches the knowledge base for relevant documents"
59
+ def description(value = nil)
60
+ @description = value if value
61
+ @description || inherited_or_default(:description, nil)
62
+ end
63
+
51
64
  # Sets or returns the timeout in seconds for LLM requests
52
65
  #
53
66
  # @param value [Integer, nil] Timeout in seconds
@@ -63,6 +76,31 @@ module RubyLLM
63
76
 
64
77
  # @!group Reliability DSL
65
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
+
66
104
  # Configures retry behavior for this agent
67
105
  #
68
106
  # @param max [Integer] Maximum number of retry attempts (default: 0)
@@ -151,13 +189,18 @@ module RubyLLM
151
189
  # @param name [Symbol] The parameter name
152
190
  # @param required [Boolean] Whether the parameter is required
153
191
  # @param default [Object, nil] Default value if not provided
192
+ # @param type [Class, nil] Optional type for validation (e.g., String, Integer, Array)
154
193
  # @return [void]
155
- # @example
194
+ # @example Without type (accepts anything)
156
195
  # param :query, required: true
157
- # param :limit, default: 10
158
- 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)
159
202
  @params ||= {}
160
- @params[name] = { required: required, default: default }
203
+ @params[name] = { required: required, default: default, type: type }
161
204
  define_method(name) do
162
205
  @options[name] || @options[name.to_s] || self.class.params.dig(name, :default)
163
206
  end
@@ -175,17 +218,37 @@ module RubyLLM
175
218
 
176
219
  # @!group Caching DSL
177
220
 
178
- # 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.
179
224
  #
180
225
  # @param ttl [ActiveSupport::Duration] Time-to-live for cached responses
181
226
  # @return [void]
182
227
  # @example
183
- # cache 1.hour
184
- def cache(ttl = CACHE_TTL)
228
+ # cache_for 1.hour
229
+ # cache_for 30.minutes
230
+ def cache_for(ttl)
185
231
  @cache_enabled = true
186
232
  @cache_ttl = ttl
187
233
  end
188
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
+
189
252
  # Returns whether caching is enabled for this agent
190
253
  #
191
254
  # @return [Boolean] true if caching is enabled
@@ -232,13 +295,13 @@ module RubyLLM
232
295
  #
233
296
  # @param tool_classes [Array<Class>] Tool classes to make available
234
297
  # @return [Array<Class>] The current tools
298
+ # @example With array (preferred)
299
+ # tools [WeatherTool, SearchTool, CalculatorTool]
235
300
  # @example Single tool
236
- # tools WeatherTool
237
- # @example Multiple tools
238
- # tools WeatherTool, SearchTool, CalculatorTool
239
- def tools(*tool_classes)
240
- if tool_classes.any?
241
- @tools = tool_classes.flatten
301
+ # tools [WeatherTool]
302
+ def tools(tool_classes = nil)
303
+ if tool_classes
304
+ @tools = Array(tool_classes)
242
305
  end
243
306
  @tools || inherited_or_default(:tools, RubyLLM::Agents.configuration.default_tools)
244
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
@@ -233,9 +257,10 @@ module RubyLLM
233
257
  # @param msgs [Array<Hash>] Messages with :role and :content keys
234
258
  # @return [RubyLLM::Chat] Client with messages applied
235
259
  def apply_messages(client, msgs)
236
- msgs.reduce(client) do |c, message|
237
- c.with_message(message[:role].to_s, message[:content])
260
+ msgs.each do |message|
261
+ client.add_message(role: message[:role].to_sym, content: message[:content])
238
262
  end
263
+ client
239
264
  end
240
265
 
241
266
  # Builds a client with pre-populated conversation history
@@ -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.5"
7
+ VERSION = "0.4.0"
8
8
  end
9
9
  end
@@ -42,6 +42,10 @@ module RubyLLM
42
42
  # @return [Float, nil] Maximum cost threshold for the workflow
43
43
  attr_accessor :_max_cost
44
44
 
45
+ # @!attribute [rw] description
46
+ # @return [String, nil] Description of the workflow
47
+ attr_accessor :_description
48
+
45
49
  # Sets or returns the workflow version
46
50
  #
47
51
  # @param value [String, nil] Version string to set
@@ -78,6 +82,18 @@ module RubyLLM
78
82
  end
79
83
  end
80
84
 
85
+ # Sets or returns the workflow description
86
+ #
87
+ # @param value [String, nil] Description text to set
88
+ # @return [String, nil] The current description
89
+ def description(value = nil)
90
+ if value
91
+ self._description = value
92
+ else
93
+ _description
94
+ end
95
+ end
96
+
81
97
  # Factory method to instantiate and execute a workflow
82
98
  #
83
99
  # @param kwargs [Hash] Parameters to pass to the workflow
@@ -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.5
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