ruby_llm-agents 0.3.3 → 0.3.4

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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +68 -4
  3. data/app/models/ruby_llm/agents/execution/analytics.rb +114 -13
  4. data/app/models/ruby_llm/agents/execution.rb +19 -58
  5. data/app/views/layouts/rubyllm/agents/application.html.erb +92 -350
  6. data/app/views/rubyllm/agents/agents/show.html.erb +331 -385
  7. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +46 -0
  8. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -90
  9. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +79 -5
  10. data/app/views/rubyllm/agents/dashboard/_top_errors.html.erb +49 -0
  11. data/app/views/rubyllm/agents/dashboard/index.html.erb +76 -121
  12. data/app/views/rubyllm/agents/executions/show.html.erb +134 -85
  13. data/app/views/rubyllm/agents/settings/show.html.erb +1 -1
  14. data/app/views/rubyllm/agents/shared/_breadcrumbs.html.erb +48 -0
  15. data/app/views/rubyllm/agents/shared/_nav_link.html.erb +27 -0
  16. data/config/routes.rb +2 -0
  17. data/lib/ruby_llm/agents/base/caching.rb +43 -0
  18. data/lib/ruby_llm/agents/base/cost_calculation.rb +103 -0
  19. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  20. data/lib/ruby_llm/agents/base/execution.rb +206 -0
  21. data/lib/ruby_llm/agents/base/reliability_execution.rb +131 -0
  22. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  23. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  24. data/lib/ruby_llm/agents/base.rb +15 -805
  25. data/lib/ruby_llm/agents/version.rb +1 -1
  26. metadata +12 -20
  27. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  28. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  29. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  30. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  31. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
@@ -0,0 +1,261 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # Class-level DSL for configuring agents
7
+ #
8
+ # Provides methods for setting model, temperature, timeout, caching,
9
+ # reliability, streaming, tools, and parameters.
10
+ module DSL
11
+ # @!visibility private
12
+ VERSION = "1.0"
13
+ # @!visibility private
14
+ CACHE_TTL = 1.hour
15
+
16
+ # @!group Configuration DSL
17
+
18
+ # Sets or returns the LLM model for this agent class
19
+ #
20
+ # @param value [String, nil] The model identifier to set
21
+ # @return [String] The current model setting
22
+ # @example
23
+ # model "gpt-4o"
24
+ def model(value = nil)
25
+ @model = value if value
26
+ @model || inherited_or_default(:model, RubyLLM::Agents.configuration.default_model)
27
+ end
28
+
29
+ # Sets or returns the temperature for LLM responses
30
+ #
31
+ # @param value [Float, nil] Temperature value (0.0-2.0)
32
+ # @return [Float] The current temperature setting
33
+ # @example
34
+ # temperature 0.7
35
+ def temperature(value = nil)
36
+ @temperature = value if value
37
+ @temperature || inherited_or_default(:temperature, RubyLLM::Agents.configuration.default_temperature)
38
+ end
39
+
40
+ # Sets or returns the version string for cache invalidation
41
+ #
42
+ # @param value [String, nil] Version string
43
+ # @return [String] The current version
44
+ # @example
45
+ # version "2.0"
46
+ def version(value = nil)
47
+ @version = value if value
48
+ @version || inherited_or_default(:version, VERSION)
49
+ end
50
+
51
+ # Sets or returns the timeout in seconds for LLM requests
52
+ #
53
+ # @param value [Integer, nil] Timeout in seconds
54
+ # @return [Integer] The current timeout setting
55
+ # @example
56
+ # timeout 30
57
+ def timeout(value = nil)
58
+ @timeout = value if value
59
+ @timeout || inherited_or_default(:timeout, RubyLLM::Agents.configuration.default_timeout)
60
+ end
61
+
62
+ # @!endgroup
63
+
64
+ # @!group Reliability DSL
65
+
66
+ # Configures retry behavior for this agent
67
+ #
68
+ # @param max [Integer] Maximum number of retry attempts (default: 0)
69
+ # @param backoff [Symbol] Backoff strategy (:constant or :exponential)
70
+ # @param base [Float] Base delay in seconds
71
+ # @param max_delay [Float] Maximum delay between retries
72
+ # @param on [Array<Class>] Error classes to retry on (extends defaults)
73
+ # @return [Hash] The current retry configuration
74
+ # @example
75
+ # retries max: 2, backoff: :exponential, base: 0.4, max_delay: 3.0, on: [Timeout::Error]
76
+ def retries(max: nil, backoff: nil, base: nil, max_delay: nil, on: nil)
77
+ if max || backoff || base || max_delay || on
78
+ @retries_config ||= RubyLLM::Agents.configuration.default_retries.dup
79
+ @retries_config[:max] = max if max
80
+ @retries_config[:backoff] = backoff if backoff
81
+ @retries_config[:base] = base if base
82
+ @retries_config[:max_delay] = max_delay if max_delay
83
+ @retries_config[:on] = on if on
84
+ end
85
+ @retries_config || inherited_or_default(:retries_config, RubyLLM::Agents.configuration.default_retries)
86
+ end
87
+
88
+ # Returns the retry configuration for this agent
89
+ #
90
+ # @return [Hash, nil] The retry configuration
91
+ def retries_config
92
+ @retries_config || (superclass.respond_to?(:retries_config) ? superclass.retries_config : nil)
93
+ end
94
+
95
+ # Sets or returns fallback models to try when primary model fails
96
+ #
97
+ # @param models [Array<String>, nil] Model identifiers to use as fallbacks
98
+ # @return [Array<String>] The current fallback models
99
+ # @example
100
+ # fallback_models ["gpt-4o-mini", "gpt-4o"]
101
+ def fallback_models(models = nil)
102
+ @fallback_models = models if models
103
+ @fallback_models || inherited_or_default(:fallback_models, RubyLLM::Agents.configuration.default_fallback_models)
104
+ end
105
+
106
+ # Sets or returns the total timeout for all retry/fallback attempts
107
+ #
108
+ # @param seconds [Integer, nil] Total timeout in seconds
109
+ # @return [Integer, nil] The current total timeout
110
+ # @example
111
+ # total_timeout 20
112
+ def total_timeout(seconds = nil)
113
+ @total_timeout = seconds if seconds
114
+ @total_timeout || inherited_or_default(:total_timeout, RubyLLM::Agents.configuration.default_total_timeout)
115
+ end
116
+
117
+ # Configures circuit breaker for this agent
118
+ #
119
+ # @param errors [Integer] Number of errors to trigger open state
120
+ # @param within [Integer] Rolling window in seconds
121
+ # @param cooldown [Integer] Cooldown period in seconds when open
122
+ # @return [Hash, nil] The current circuit breaker configuration
123
+ # @example
124
+ # circuit_breaker errors: 10, within: 60, cooldown: 300
125
+ def circuit_breaker(errors: nil, within: nil, cooldown: nil)
126
+ if errors || within || cooldown
127
+ @circuit_breaker_config ||= { errors: 10, within: 60, cooldown: 300 }
128
+ @circuit_breaker_config[:errors] = errors if errors
129
+ @circuit_breaker_config[:within] = within if within
130
+ @circuit_breaker_config[:cooldown] = cooldown if cooldown
131
+ end
132
+ @circuit_breaker_config || (superclass.respond_to?(:circuit_breaker_config) ? superclass.circuit_breaker_config : nil)
133
+ end
134
+
135
+ # Returns the circuit breaker configuration for this agent
136
+ #
137
+ # @return [Hash, nil] The circuit breaker configuration
138
+ def circuit_breaker_config
139
+ @circuit_breaker_config || (superclass.respond_to?(:circuit_breaker_config) ? superclass.circuit_breaker_config : nil)
140
+ end
141
+
142
+ # @!endgroup
143
+
144
+ # @!group Parameter DSL
145
+
146
+ # Defines a parameter for the agent
147
+ #
148
+ # Creates an accessor method for the parameter that retrieves values
149
+ # from the options hash, falling back to the default value.
150
+ #
151
+ # @param name [Symbol] The parameter name
152
+ # @param required [Boolean] Whether the parameter is required
153
+ # @param default [Object, nil] Default value if not provided
154
+ # @return [void]
155
+ # @example
156
+ # param :query, required: true
157
+ # param :limit, default: 10
158
+ def param(name, required: false, default: nil)
159
+ @params ||= {}
160
+ @params[name] = { required: required, default: default }
161
+ define_method(name) do
162
+ @options[name] || @options[name.to_s] || self.class.params.dig(name, :default)
163
+ end
164
+ end
165
+
166
+ # Returns all defined parameters including inherited ones
167
+ #
168
+ # @return [Hash{Symbol => Hash}] Parameter definitions
169
+ def params
170
+ parent = superclass.respond_to?(:params) ? superclass.params : {}
171
+ parent.merge(@params || {})
172
+ end
173
+
174
+ # @!endgroup
175
+
176
+ # @!group Caching DSL
177
+
178
+ # Enables caching for this agent with optional TTL
179
+ #
180
+ # @param ttl [ActiveSupport::Duration] Time-to-live for cached responses
181
+ # @return [void]
182
+ # @example
183
+ # cache 1.hour
184
+ def cache(ttl = CACHE_TTL)
185
+ @cache_enabled = true
186
+ @cache_ttl = ttl
187
+ end
188
+
189
+ # Returns whether caching is enabled for this agent
190
+ #
191
+ # @return [Boolean] true if caching is enabled
192
+ def cache_enabled?
193
+ @cache_enabled || false
194
+ end
195
+
196
+ # Returns the cache TTL for this agent
197
+ #
198
+ # @return [ActiveSupport::Duration] The cache TTL
199
+ def cache_ttl
200
+ @cache_ttl || CACHE_TTL
201
+ end
202
+
203
+ # @!endgroup
204
+
205
+ # @!group Streaming DSL
206
+
207
+ # Enables or returns streaming mode for this agent
208
+ #
209
+ # When streaming is enabled and a block is passed to call,
210
+ # chunks will be yielded to the block as they arrive.
211
+ #
212
+ # @param value [Boolean, nil] Whether to enable streaming
213
+ # @return [Boolean] The current streaming setting
214
+ # @example
215
+ # streaming true
216
+ def streaming(value = nil)
217
+ @streaming = value unless value.nil?
218
+ return @streaming unless @streaming.nil?
219
+
220
+ inherited_or_default(:streaming, RubyLLM::Agents.configuration.default_streaming)
221
+ end
222
+
223
+ # @!endgroup
224
+
225
+ # @!group Tools DSL
226
+
227
+ # Sets or returns the tools available to this agent
228
+ #
229
+ # Tools are RubyLLM::Tool classes that the model can invoke.
230
+ # The agent will automatically execute tool calls and continue
231
+ # until the model produces a final response.
232
+ #
233
+ # @param tool_classes [Array<Class>] Tool classes to make available
234
+ # @return [Array<Class>] The current tools
235
+ # @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
242
+ end
243
+ @tools || inherited_or_default(:tools, RubyLLM::Agents.configuration.default_tools)
244
+ end
245
+
246
+ # @!endgroup
247
+
248
+ private
249
+
250
+ # Looks up setting from superclass or uses default
251
+ #
252
+ # @param method [Symbol] The method to call on superclass
253
+ # @param default [Object] Default value if not found
254
+ # @return [Object] The resolved value
255
+ def inherited_or_default(method, default)
256
+ superclass.respond_to?(method) ? superclass.send(method) : default
257
+ end
258
+ end
259
+ end
260
+ end
261
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # Main execution flow for agents
7
+ #
8
+ # Handles the core execution logic including caching, streaming,
9
+ # client building, and parameter validation.
10
+ module Execution
11
+ # Executes the agent and returns the processed response
12
+ #
13
+ # Handles caching, dry-run mode, and delegates to uncached_call
14
+ # for actual LLM execution.
15
+ #
16
+ # @yield [chunk] Yields chunks when streaming is enabled
17
+ # @yieldparam chunk [RubyLLM::Chunk] A streaming chunk with content
18
+ # @return [Object] The processed LLM response
19
+ def call(&block)
20
+ return dry_run_response if @options[:dry_run]
21
+ return uncached_call(&block) if @options[:skip_cache] || !self.class.cache_enabled?
22
+
23
+ # Note: Cached responses don't stream (already complete)
24
+ cache_store.fetch(cache_key, expires_in: self.class.cache_ttl) do
25
+ uncached_call(&block)
26
+ end
27
+ end
28
+
29
+ # Executes the agent without caching
30
+ #
31
+ # Routes to reliability-enabled execution if configured, otherwise
32
+ # uses simple single-attempt execution.
33
+ #
34
+ # @yield [chunk] Yields chunks when streaming is enabled
35
+ # @return [Object] The processed response
36
+ def uncached_call(&block)
37
+ if reliability_enabled?
38
+ execute_with_reliability(&block)
39
+ else
40
+ instrument_execution { execute_single_attempt(&block) }
41
+ end
42
+ end
43
+
44
+ # Executes a single LLM attempt with timeout
45
+ #
46
+ # @param model_override [String, nil] Optional model to use instead of default
47
+ # @yield [chunk] Yields chunks when streaming is enabled
48
+ # @return [Result] A Result object with processed content and metadata
49
+ def execute_single_attempt(model_override: nil, &block)
50
+ current_client = model_override ? build_client_with_model(model_override) : client
51
+ @execution_started_at ||= Time.current
52
+ reset_accumulated_tool_calls!
53
+
54
+ Timeout.timeout(self.class.timeout) do
55
+ if self.class.streaming && block_given?
56
+ execute_with_streaming(current_client, &block)
57
+ else
58
+ response = current_client.ask(user_prompt, **ask_options)
59
+ extract_tool_calls_from_client(current_client)
60
+ capture_response(response)
61
+ build_result(process_response(response), response)
62
+ end
63
+ end
64
+ end
65
+
66
+ # Executes an LLM request with streaming enabled
67
+ #
68
+ # Yields chunks to the provided block as they arrive and tracks
69
+ # time to first token for latency analysis.
70
+ #
71
+ # @param current_client [RubyLLM::Chat] The configured client
72
+ # @yield [chunk] Yields each chunk as it arrives
73
+ # @yieldparam chunk [RubyLLM::Chunk] A streaming chunk
74
+ # @return [Result] A Result object with processed content and metadata
75
+ def execute_with_streaming(current_client, &block)
76
+ first_chunk_at = nil
77
+
78
+ response = current_client.ask(user_prompt, **ask_options) do |chunk|
79
+ first_chunk_at ||= Time.current
80
+ yield chunk if block_given?
81
+ end
82
+
83
+ if first_chunk_at && @execution_started_at
84
+ @time_to_first_token_ms = ((first_chunk_at - @execution_started_at) * 1000).to_i
85
+ end
86
+
87
+ extract_tool_calls_from_client(current_client)
88
+ capture_response(response)
89
+ build_result(process_response(response), response)
90
+ end
91
+
92
+ # Returns prompt info without making an API call (debug mode)
93
+ #
94
+ # @return [Result] A Result with dry run configuration info
95
+ def dry_run_response
96
+ Result.new(
97
+ content: {
98
+ dry_run: true,
99
+ agent: self.class.name,
100
+ model: model,
101
+ temperature: temperature,
102
+ timeout: self.class.timeout,
103
+ system_prompt: system_prompt,
104
+ user_prompt: user_prompt,
105
+ attachments: @options[:with],
106
+ schema: schema&.class&.name,
107
+ streaming: self.class.streaming,
108
+ tools: self.class.tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
109
+ },
110
+ model_id: model,
111
+ temperature: temperature,
112
+ streaming: self.class.streaming
113
+ )
114
+ end
115
+
116
+ # Returns the consolidated reliability configuration for this agent instance
117
+ #
118
+ # @return [Hash] Reliability config with :retries, :fallback_models, :total_timeout, :circuit_breaker
119
+ def reliability_config
120
+ default_retries = RubyLLM::Agents.configuration.default_retries
121
+ {
122
+ retries: self.class.retries || default_retries,
123
+ fallback_models: self.class.fallback_models,
124
+ total_timeout: self.class.total_timeout,
125
+ circuit_breaker: self.class.circuit_breaker_config
126
+ }
127
+ end
128
+
129
+ # Returns whether any reliability features are enabled for this agent
130
+ #
131
+ # @return [Boolean] true if retries, fallbacks, or circuit breaker is configured
132
+ def reliability_enabled?
133
+ config = reliability_config
134
+ (config[:retries]&.dig(:max) || 0) > 0 ||
135
+ config[:fallback_models]&.any? ||
136
+ config[:circuit_breaker].present?
137
+ end
138
+
139
+ # Returns options to pass to the ask method
140
+ #
141
+ # Currently supports :with for attachments (images, PDFs, etc.)
142
+ #
143
+ # @return [Hash] Options for the ask call
144
+ def ask_options
145
+ opts = {}
146
+ opts[:with] = @options[:with] if @options[:with]
147
+ opts
148
+ end
149
+
150
+ # Validates that all required parameters are present
151
+ #
152
+ # @raise [ArgumentError] If required parameters are missing
153
+ # @return [void]
154
+ def validate_required_params!
155
+ required = self.class.params.select { |_, v| v[:required] }.keys
156
+ missing = required.reject { |p| @options.key?(p) || @options.key?(p.to_s) }
157
+ raise ArgumentError, "#{self.class} missing required params: #{missing.join(', ')}" if missing.any?
158
+ end
159
+
160
+ # Builds and configures the RubyLLM client
161
+ #
162
+ # @return [RubyLLM::Chat] Configured chat client
163
+ def build_client
164
+ client = RubyLLM.chat
165
+ .with_model(model)
166
+ .with_temperature(temperature)
167
+ client = client.with_instructions(system_prompt) if system_prompt
168
+ client = client.with_schema(schema) if schema
169
+ client = client.with_tools(*self.class.tools) if self.class.tools.any?
170
+ client
171
+ end
172
+
173
+ # Builds a client with a specific model
174
+ #
175
+ # @param model_id [String] The model identifier
176
+ # @return [RubyLLM::Chat] Configured chat client
177
+ def build_client_with_model(model_id)
178
+ client = RubyLLM.chat
179
+ .with_model(model_id)
180
+ .with_temperature(temperature)
181
+ client = client.with_instructions(system_prompt) if system_prompt
182
+ client = client.with_schema(schema) if schema
183
+ client = client.with_tools(*self.class.tools) if self.class.tools.any?
184
+ client
185
+ end
186
+
187
+ # Builds a client with pre-populated conversation history
188
+ #
189
+ # Useful for multi-turn conversations or providing context.
190
+ #
191
+ # @param messages [Array<Hash>] Messages with :role and :content keys
192
+ # @return [RubyLLM::Chat] Client with messages added
193
+ # @example
194
+ # build_client_with_messages([
195
+ # { role: "user", content: "Hello" },
196
+ # { role: "assistant", content: "Hi there!" }
197
+ # ])
198
+ def build_client_with_messages(messages)
199
+ messages.reduce(build_client) do |client, message|
200
+ client.with_message(message[:role], message[:content])
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
206
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # Reliability execution with retry/fallback/circuit breaker support
7
+ #
8
+ # Handles executing agents with automatic retries, model fallbacks,
9
+ # circuit breaker protection, and budget enforcement.
10
+ module ReliabilityExecution
11
+ # Executes the agent with retry/fallback/circuit breaker support
12
+ #
13
+ # @yield [chunk] Yields chunks when streaming is enabled
14
+ # @return [Object] The processed response
15
+ # @raise [Reliability::AllModelsExhaustedError] If all models fail
16
+ # @raise [Reliability::BudgetExceededError] If budget limits exceeded
17
+ # @raise [Reliability::TotalTimeoutError] If total timeout exceeded
18
+ def execute_with_reliability(&block)
19
+ config = reliability_config
20
+ models_to_try = [model, *config[:fallback_models]].uniq
21
+ total_deadline = config[:total_timeout] ? Time.current + config[:total_timeout] : nil
22
+ started_at = Time.current
23
+
24
+ # Pre-check budget
25
+ BudgetTracker.check_budget!(self.class.name) if RubyLLM::Agents.configuration.budgets_enabled?
26
+
27
+ instrument_execution_with_attempts(models_to_try: models_to_try) do |attempt_tracker|
28
+ last_error = nil
29
+
30
+ models_to_try.each do |current_model|
31
+ # Check circuit breaker
32
+ breaker = get_circuit_breaker(current_model)
33
+ if breaker&.open?
34
+ attempt_tracker.record_short_circuit(current_model)
35
+ next
36
+ end
37
+
38
+ retries_remaining = config[:retries]&.dig(:max) || 0
39
+ attempt_index = 0
40
+
41
+ loop do
42
+ # Check total timeout
43
+ if total_deadline && Time.current > total_deadline
44
+ elapsed = Time.current - started_at
45
+ raise Reliability::TotalTimeoutError.new(config[:total_timeout], elapsed)
46
+ end
47
+
48
+ attempt = attempt_tracker.start_attempt(current_model)
49
+
50
+ begin
51
+ result = execute_single_attempt(model_override: current_model, &block)
52
+ attempt_tracker.complete_attempt(attempt, success: true, response: @last_response)
53
+
54
+ # Record success in circuit breaker
55
+ breaker&.record_success!
56
+
57
+ # Record budget spend
58
+ if @last_response && RubyLLM::Agents.configuration.budgets_enabled?
59
+ record_attempt_cost(attempt_tracker)
60
+ end
61
+
62
+ # Use throw instead of return to allow instrument_execution_with_attempts
63
+ # to properly complete the execution record before returning
64
+ throw :execution_success, result
65
+
66
+ rescue *retryable_errors(config) => e
67
+ last_error = e
68
+ attempt_tracker.complete_attempt(attempt, success: false, error: e)
69
+ breaker&.record_failure!
70
+
71
+ if retries_remaining > 0 && !past_deadline?(total_deadline)
72
+ retries_remaining -= 1
73
+ attempt_index += 1
74
+ retries_config = config[:retries] || {}
75
+ delay = Reliability.calculate_backoff(
76
+ strategy: retries_config[:backoff] || :exponential,
77
+ base: retries_config[:base] || 0.4,
78
+ max_delay: retries_config[:max_delay] || 3.0,
79
+ attempt: attempt_index
80
+ )
81
+ sleep(delay)
82
+ else
83
+ break # Move to next model
84
+ end
85
+
86
+ rescue StandardError => e
87
+ # Non-retryable error - record and move to next model
88
+ last_error = e
89
+ attempt_tracker.complete_attempt(attempt, success: false, error: e)
90
+ breaker&.record_failure!
91
+ break
92
+ end
93
+ end
94
+ end
95
+
96
+ # All models exhausted
97
+ raise Reliability::AllModelsExhaustedError.new(models_to_try, last_error)
98
+ end
99
+ end
100
+
101
+ # Returns the list of retryable error classes
102
+ #
103
+ # @param config [Hash] Reliability configuration
104
+ # @return [Array<Class>] Error classes to retry on
105
+ def retryable_errors(config)
106
+ custom_errors = config[:retries]&.dig(:on) || []
107
+ Reliability.default_retryable_errors + custom_errors
108
+ end
109
+
110
+ # Checks if the total deadline has passed
111
+ #
112
+ # @param deadline [Time, nil] The deadline
113
+ # @return [Boolean] true if past deadline
114
+ def past_deadline?(deadline)
115
+ deadline && Time.current > deadline
116
+ end
117
+
118
+ # Gets or creates a circuit breaker for a model
119
+ #
120
+ # @param model_id [String] The model identifier
121
+ # @return [CircuitBreaker, nil] The circuit breaker or nil if not configured
122
+ def get_circuit_breaker(model_id)
123
+ config = reliability_config[:circuit_breaker]
124
+ return nil unless config
125
+
126
+ CircuitBreaker.from_config(self.class.name, model_id, config)
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ class Base
6
+ # Result object construction from LLM responses
7
+ #
8
+ # Handles building Result objects with full execution metadata
9
+ # including tokens, costs, timing, and tool calls.
10
+ module ResponseBuilding
11
+ # Builds a Result object from processed content and response metadata
12
+ #
13
+ # @param content [Hash, String] The processed response content
14
+ # @param response [RubyLLM::Message] The raw LLM response
15
+ # @return [Result] A Result object with full execution metadata
16
+ def build_result(content, response)
17
+ completed_at = Time.current
18
+ input_tokens = result_response_value(response, :input_tokens)
19
+ output_tokens = result_response_value(response, :output_tokens)
20
+ response_model_id = result_response_value(response, :model_id)
21
+
22
+ Result.new(
23
+ content: content,
24
+ input_tokens: input_tokens,
25
+ output_tokens: output_tokens,
26
+ cached_tokens: result_response_value(response, :cached_tokens, 0),
27
+ cache_creation_tokens: result_response_value(response, :cache_creation_tokens, 0),
28
+ model_id: model,
29
+ chosen_model_id: response_model_id || model,
30
+ temperature: temperature,
31
+ started_at: @execution_started_at,
32
+ completed_at: completed_at,
33
+ duration_ms: result_duration_ms(completed_at),
34
+ time_to_first_token_ms: @time_to_first_token_ms,
35
+ finish_reason: result_finish_reason(response),
36
+ streaming: self.class.streaming,
37
+ input_cost: result_input_cost(input_tokens, response_model_id),
38
+ output_cost: result_output_cost(output_tokens, response_model_id),
39
+ total_cost: result_total_cost(input_tokens, output_tokens, response_model_id),
40
+ tool_calls: @accumulated_tool_calls,
41
+ tool_calls_count: @accumulated_tool_calls.size
42
+ )
43
+ end
44
+
45
+ # Safely extracts a value from the response object
46
+ #
47
+ # @param response [Object] The response object
48
+ # @param method [Symbol] The method to call
49
+ # @param default [Object] Default value if method doesn't exist
50
+ # @return [Object] The extracted value or default
51
+ def result_response_value(response, method, default = nil)
52
+ return default unless response.respond_to?(method)
53
+ response.send(method) || default
54
+ end
55
+
56
+ # Calculates execution duration in milliseconds
57
+ #
58
+ # @param completed_at [Time] When execution completed
59
+ # @return [Integer, nil] Duration in ms or nil
60
+ def result_duration_ms(completed_at)
61
+ return nil unless @execution_started_at
62
+ ((completed_at - @execution_started_at) * 1000).to_i
63
+ end
64
+
65
+ # Extracts finish reason from response
66
+ #
67
+ # @param response [Object] The response object
68
+ # @return [String, nil] Normalized finish reason
69
+ def result_finish_reason(response)
70
+ reason = result_response_value(response, :finish_reason) ||
71
+ result_response_value(response, :stop_reason)
72
+ return nil unless reason
73
+
74
+ # Normalize to standard values
75
+ case reason.to_s.downcase
76
+ when "stop", "end_turn" then "stop"
77
+ when "length", "max_tokens" then "length"
78
+ when "content_filter", "safety" then "content_filter"
79
+ when "tool_calls", "tool_use" then "tool_calls"
80
+ else "other"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end