ruby_llm-agents 0.2.4 → 0.3.1

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 (62) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +413 -0
  3. data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
  4. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
  5. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
  6. data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
  7. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
  8. data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
  9. data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
  10. data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
  11. data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
  12. data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
  13. data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
  14. data/app/models/ruby_llm/agents/execution.rb +259 -16
  15. data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
  16. data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
  17. data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
  18. data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
  19. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
  20. data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
  21. data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
  22. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
  23. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
  24. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
  25. data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
  26. data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
  27. data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
  28. data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
  29. data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
  30. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
  31. data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
  32. data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
  33. data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
  34. data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
  35. data/config/routes.rb +7 -0
  36. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
  37. data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
  39. data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
  41. data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
  42. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
  43. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
  44. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +143 -8
  45. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
  46. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
  47. data/lib/ruby_llm/agents/alert_manager.rb +207 -0
  48. data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
  49. data/lib/ruby_llm/agents/base.rb +597 -112
  50. data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
  51. data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
  52. data/lib/ruby_llm/agents/configuration.rb +279 -1
  53. data/lib/ruby_llm/agents/engine.rb +58 -6
  54. data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
  55. data/lib/ruby_llm/agents/inflections.rb +13 -2
  56. data/lib/ruby_llm/agents/instrumentation.rb +538 -87
  57. data/lib/ruby_llm/agents/redactor.rb +130 -0
  58. data/lib/ruby_llm/agents/reliability.rb +185 -0
  59. data/lib/ruby_llm/agents/version.rb +3 -1
  60. data/lib/ruby_llm/agents.rb +52 -0
  61. metadata +41 -2
  62. data/app/controllers/ruby_llm/agents/application_controller.rb +0 -37
@@ -1,109 +1,220 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # RubyLLM::Agents::Base - Base class for LLM-powered agents
4
- #
5
- # == Creating an Agent
6
- #
7
- # class SearchAgent < ApplicationAgent
8
- # model "gemini-2.0-flash"
9
- # temperature 0.0
10
- # version "1.0"
11
- # timeout 30
12
- # cache 1.hour
13
- #
14
- # param :query, required: true
15
- # param :limit, default: 10
16
- #
17
- # def system_prompt
18
- # "You are a search assistant..."
19
- # end
20
- #
21
- # def user_prompt
22
- # query
23
- # end
24
- #
25
- # def schema
26
- # @schema ||= RubyLLM::Schema.create do
27
- # string :result
28
- # end
29
- # end
30
- # end
31
- #
32
- # == Calling an Agent
33
- #
34
- # SearchAgent.call(query: "red dress")
35
- # SearchAgent.call(query: "red dress", dry_run: true) # Debug prompts
36
- # SearchAgent.call(query: "red dress", skip_cache: true) # Bypass cache
37
- #
38
- # == Configuration DSL
39
- #
40
- # model "gemini-2.0-flash" # LLM model (default from config)
41
- # temperature 0.0 # Randomness 0.0-1.0 (default from config)
42
- # version "1.0" # Version for cache invalidation
43
- # timeout 30 # Seconds before timeout (default from config)
44
- # cache 1.hour # Enable caching with TTL (default: disabled)
45
- #
46
- # == Parameter DSL
47
- #
48
- # param :name # Optional parameter
49
- # param :query, required: true # Required - raises ArgumentError if missing
50
- # param :limit, default: 10 # Optional with default value
51
- #
52
- # == Template Methods (override in subclasses)
53
- #
54
- # user_prompt - Required. The prompt sent to the LLM.
55
- # system_prompt - Optional. System instructions for the LLM.
56
- # schema - Optional. RubyLLM::Schema for structured output.
57
- # process_response(response) - Optional. Post-process the LLM response.
58
- # cache_key_data - Optional. Override to customize cache key generation.
59
- #
60
3
  module RubyLLM
61
4
  module Agents
5
+ # Base class for LLM-powered agents
6
+ #
7
+ # Provides a DSL for configuring and executing agents that interact with
8
+ # large language models. Includes built-in support for caching, timeouts,
9
+ # structured output, and execution tracking.
10
+ #
11
+ # @example Creating an agent
12
+ # class SearchAgent < ApplicationAgent
13
+ # model "gpt-4o"
14
+ # temperature 0.0
15
+ # version "1.0"
16
+ # timeout 30
17
+ # cache 1.hour
18
+ #
19
+ # param :query, required: true
20
+ # param :limit, default: 10
21
+ #
22
+ # def system_prompt
23
+ # "You are a search assistant..."
24
+ # end
25
+ #
26
+ # def user_prompt
27
+ # "Search for: #{query}"
28
+ # end
29
+ # end
30
+ #
31
+ # @example Calling an agent
32
+ # SearchAgent.call(query: "red dress")
33
+ # SearchAgent.call(query: "red dress", dry_run: true) # Debug mode
34
+ # SearchAgent.call(query: "red dress", skip_cache: true) # Bypass cache
35
+ #
36
+ # @see RubyLLM::Agents::Instrumentation
37
+ # @api public
62
38
  class Base
63
39
  include Instrumentation
64
40
 
65
- # Default constants (can be overridden by configuration)
66
- VERSION = "1.0".freeze
67
- CACHE_TTL = 1.hour
68
-
69
- # ==========================================================================
70
- # Class Methods (DSL)
71
- # ==========================================================================
41
+ # @!visibility private
42
+ VERSION = "1.0".freeze
43
+ # @!visibility private
44
+ CACHE_TTL = 1.hour
72
45
 
73
46
  class << self
74
- # Factory method - instantiates and calls the agent
75
- def call(*args, **kwargs)
76
- new(*args, **kwargs).call
47
+ # Factory method to instantiate and execute an agent
48
+ #
49
+ # @param args [Array] Positional arguments (reserved for future use)
50
+ # @param kwargs [Hash] Named parameters for the agent
51
+ # @option kwargs [Boolean] :dry_run Return prompt info without API call
52
+ # @option kwargs [Boolean] :skip_cache Bypass caching even if enabled
53
+ # @option kwargs [String, Array<String>] :with Attachments (files, URLs) to send with the prompt
54
+ # @yield [chunk] Yields chunks when streaming is enabled
55
+ # @yieldparam chunk [RubyLLM::Chunk] A streaming chunk with content
56
+ # @return [Object] The processed response from the agent
57
+ #
58
+ # @example Basic usage
59
+ # SearchAgent.call(query: "red dress")
60
+ #
61
+ # @example Debug mode
62
+ # SearchAgent.call(query: "red dress", dry_run: true)
63
+ #
64
+ # @example Streaming mode
65
+ # ChatAgent.call(message: "Hello") do |chunk|
66
+ # print chunk.content
67
+ # end
68
+ #
69
+ # @example With attachments
70
+ # VisionAgent.call(query: "Describe this image", with: "photo.jpg")
71
+ # VisionAgent.call(query: "Compare these", with: ["a.png", "b.png"])
72
+ def call(*args, **kwargs, &block)
73
+ new(*args, **kwargs).call(&block)
77
74
  end
78
75
 
79
- # ------------------------------------------------------------------------
80
- # Configuration DSL
81
- # ------------------------------------------------------------------------
76
+ # @!group Configuration DSL
82
77
 
78
+ # Sets or returns the LLM model for this agent class
79
+ #
80
+ # @param value [String, nil] The model identifier to set
81
+ # @return [String] The current model setting
82
+ # @example
83
+ # model "gpt-4o"
83
84
  def model(value = nil)
84
85
  @model = value if value
85
86
  @model || inherited_or_default(:model, RubyLLM::Agents.configuration.default_model)
86
87
  end
87
88
 
89
+ # Sets or returns the temperature for LLM responses
90
+ #
91
+ # @param value [Float, nil] Temperature value (0.0-2.0)
92
+ # @return [Float] The current temperature setting
93
+ # @example
94
+ # temperature 0.7
88
95
  def temperature(value = nil)
89
96
  @temperature = value if value
90
97
  @temperature || inherited_or_default(:temperature, RubyLLM::Agents.configuration.default_temperature)
91
98
  end
92
99
 
100
+ # Sets or returns the version string for cache invalidation
101
+ #
102
+ # @param value [String, nil] Version string
103
+ # @return [String] The current version
104
+ # @example
105
+ # version "2.0"
93
106
  def version(value = nil)
94
107
  @version = value if value
95
108
  @version || inherited_or_default(:version, VERSION)
96
109
  end
97
110
 
111
+ # Sets or returns the timeout in seconds for LLM requests
112
+ #
113
+ # @param value [Integer, nil] Timeout in seconds
114
+ # @return [Integer] The current timeout setting
115
+ # @example
116
+ # timeout 30
98
117
  def timeout(value = nil)
99
118
  @timeout = value if value
100
119
  @timeout || inherited_or_default(:timeout, RubyLLM::Agents.configuration.default_timeout)
101
120
  end
102
121
 
103
- # ------------------------------------------------------------------------
104
- # Parameter DSL
105
- # ------------------------------------------------------------------------
122
+ # @!endgroup
123
+
124
+ # @!group Reliability DSL
125
+
126
+ # Configures retry behavior for this agent
127
+ #
128
+ # @param max [Integer] Maximum number of retry attempts (default: 0)
129
+ # @param backoff [Symbol] Backoff strategy (:constant or :exponential)
130
+ # @param base [Float] Base delay in seconds
131
+ # @param max_delay [Float] Maximum delay between retries
132
+ # @param on [Array<Class>] Error classes to retry on (extends defaults)
133
+ # @return [Hash] The current retry configuration
134
+ # @example
135
+ # retries max: 2, backoff: :exponential, base: 0.4, max_delay: 3.0, on: [Timeout::Error]
136
+ def retries(max: nil, backoff: nil, base: nil, max_delay: nil, on: nil)
137
+ if max || backoff || base || max_delay || on
138
+ @retries_config ||= RubyLLM::Agents.configuration.default_retries.dup
139
+ @retries_config[:max] = max if max
140
+ @retries_config[:backoff] = backoff if backoff
141
+ @retries_config[:base] = base if base
142
+ @retries_config[:max_delay] = max_delay if max_delay
143
+ @retries_config[:on] = on if on
144
+ end
145
+ @retries_config || inherited_or_default(:retries_config, RubyLLM::Agents.configuration.default_retries)
146
+ end
147
+
148
+ # Returns the retry configuration for this agent
149
+ #
150
+ # @return [Hash, nil] The retry configuration
151
+ def retries_config
152
+ @retries_config || (superclass.respond_to?(:retries_config) ? superclass.retries_config : nil)
153
+ end
154
+
155
+ # Sets or returns fallback models to try when primary model fails
156
+ #
157
+ # @param models [Array<String>, nil] Model identifiers to use as fallbacks
158
+ # @return [Array<String>] The current fallback models
159
+ # @example
160
+ # fallback_models ["gpt-4o-mini", "gpt-4o"]
161
+ def fallback_models(models = nil)
162
+ @fallback_models = models if models
163
+ @fallback_models || inherited_or_default(:fallback_models, RubyLLM::Agents.configuration.default_fallback_models)
164
+ end
106
165
 
166
+ # Sets or returns the total timeout for all retry/fallback attempts
167
+ #
168
+ # @param seconds [Integer, nil] Total timeout in seconds
169
+ # @return [Integer, nil] The current total timeout
170
+ # @example
171
+ # total_timeout 20
172
+ def total_timeout(seconds = nil)
173
+ @total_timeout = seconds if seconds
174
+ @total_timeout || inherited_or_default(:total_timeout, RubyLLM::Agents.configuration.default_total_timeout)
175
+ end
176
+
177
+ # Configures circuit breaker for this agent
178
+ #
179
+ # @param errors [Integer] Number of errors to trigger open state
180
+ # @param within [Integer] Rolling window in seconds
181
+ # @param cooldown [Integer] Cooldown period in seconds when open
182
+ # @return [Hash, nil] The current circuit breaker configuration
183
+ # @example
184
+ # circuit_breaker errors: 10, within: 60, cooldown: 300
185
+ def circuit_breaker(errors: nil, within: nil, cooldown: nil)
186
+ if errors || within || cooldown
187
+ @circuit_breaker_config ||= { errors: 10, within: 60, cooldown: 300 }
188
+ @circuit_breaker_config[:errors] = errors if errors
189
+ @circuit_breaker_config[:within] = within if within
190
+ @circuit_breaker_config[:cooldown] = cooldown if cooldown
191
+ end
192
+ @circuit_breaker_config || (superclass.respond_to?(:circuit_breaker_config) ? superclass.circuit_breaker_config : nil)
193
+ end
194
+
195
+ # Returns the circuit breaker configuration for this agent
196
+ #
197
+ # @return [Hash, nil] The circuit breaker configuration
198
+ def circuit_breaker_config
199
+ @circuit_breaker_config || (superclass.respond_to?(:circuit_breaker_config) ? superclass.circuit_breaker_config : nil)
200
+ end
201
+
202
+ # @!endgroup
203
+
204
+ # @!group Parameter DSL
205
+
206
+ # Defines a parameter for the agent
207
+ #
208
+ # Creates an accessor method for the parameter that retrieves values
209
+ # from the options hash, falling back to the default value.
210
+ #
211
+ # @param name [Symbol] The parameter name
212
+ # @param required [Boolean] Whether the parameter is required
213
+ # @param default [Object, nil] Default value if not provided
214
+ # @return [void]
215
+ # @example
216
+ # param :query, required: true
217
+ # param :limit, default: 10
107
218
  def param(name, required: false, default: nil)
108
219
  @params ||= {}
109
220
  @params[name] = { required: required, default: default }
@@ -112,41 +223,116 @@ module RubyLLM
112
223
  end
113
224
  end
114
225
 
226
+ # Returns all defined parameters including inherited ones
227
+ #
228
+ # @return [Hash{Symbol => Hash}] Parameter definitions
115
229
  def params
116
230
  parent = superclass.respond_to?(:params) ? superclass.params : {}
117
231
  parent.merge(@params || {})
118
232
  end
119
233
 
120
- # ------------------------------------------------------------------------
121
- # Caching DSL
122
- # ------------------------------------------------------------------------
234
+ # @!endgroup
235
+
236
+ # @!group Caching DSL
123
237
 
238
+ # Enables caching for this agent with optional TTL
239
+ #
240
+ # @param ttl [ActiveSupport::Duration] Time-to-live for cached responses
241
+ # @return [void]
242
+ # @example
243
+ # cache 1.hour
124
244
  def cache(ttl = CACHE_TTL)
125
245
  @cache_enabled = true
126
246
  @cache_ttl = ttl
127
247
  end
128
248
 
249
+ # Returns whether caching is enabled for this agent
250
+ #
251
+ # @return [Boolean] true if caching is enabled
129
252
  def cache_enabled?
130
253
  @cache_enabled || false
131
254
  end
132
255
 
256
+ # Returns the cache TTL for this agent
257
+ #
258
+ # @return [ActiveSupport::Duration] The cache TTL
133
259
  def cache_ttl
134
260
  @cache_ttl || CACHE_TTL
135
261
  end
136
262
 
263
+ # @!endgroup
264
+
265
+ # @!group Streaming DSL
266
+
267
+ # Enables or returns streaming mode for this agent
268
+ #
269
+ # When streaming is enabled and a block is passed to call,
270
+ # chunks will be yielded to the block as they arrive.
271
+ #
272
+ # @param value [Boolean, nil] Whether to enable streaming
273
+ # @return [Boolean] The current streaming setting
274
+ # @example
275
+ # streaming true
276
+ def streaming(value = nil)
277
+ @streaming = value unless value.nil?
278
+ return @streaming unless @streaming.nil?
279
+
280
+ inherited_or_default(:streaming, RubyLLM::Agents.configuration.default_streaming)
281
+ end
282
+
283
+ # @!endgroup
284
+
285
+ # @!group Tools DSL
286
+
287
+ # Sets or returns the tools available to this agent
288
+ #
289
+ # Tools are RubyLLM::Tool classes that the model can invoke.
290
+ # The agent will automatically execute tool calls and continue
291
+ # until the model produces a final response.
292
+ #
293
+ # @param tool_classes [Array<Class>] Tool classes to make available
294
+ # @return [Array<Class>] The current tools
295
+ # @example Single tool
296
+ # tools WeatherTool
297
+ # @example Multiple tools
298
+ # tools WeatherTool, SearchTool, CalculatorTool
299
+ def tools(*tool_classes)
300
+ if tool_classes.any?
301
+ @tools = tool_classes.flatten
302
+ end
303
+ @tools || inherited_or_default(:tools, RubyLLM::Agents.configuration.default_tools)
304
+ end
305
+
306
+ # @!endgroup
307
+
137
308
  private
138
309
 
310
+ # Looks up setting from superclass or uses default
311
+ #
312
+ # @param method [Symbol] The method to call on superclass
313
+ # @param default [Object] Default value if not found
314
+ # @return [Object] The resolved value
139
315
  def inherited_or_default(method, default)
140
316
  superclass.respond_to?(method) ? superclass.send(method) : default
141
317
  end
142
318
  end
143
319
 
144
- # ==========================================================================
145
- # Instance Methods
146
- # ==========================================================================
147
-
148
- attr_reader :model, :temperature, :client
149
-
320
+ # @!attribute [r] model
321
+ # @return [String] The LLM model being used
322
+ # @!attribute [r] temperature
323
+ # @return [Float] The temperature setting
324
+ # @!attribute [r] client
325
+ # @return [RubyLLM::Chat] The configured RubyLLM client
326
+ # @!attribute [r] time_to_first_token_ms
327
+ # @return [Integer, nil] Time to first token in milliseconds (streaming only)
328
+ attr_reader :model, :temperature, :client, :time_to_first_token_ms
329
+
330
+ # Creates a new agent instance
331
+ #
332
+ # @param model [String] Override the class-level model setting
333
+ # @param temperature [Float] Override the class-level temperature
334
+ # @param options [Hash] Agent parameters defined via the param DSL
335
+ # @raise [ArgumentError] If required parameters are missing
150
336
  def initialize(model: self.class.model, temperature: self.class.temperature, **options)
151
337
  @model = model
152
338
  @temperature = temperature
@@ -155,39 +341,90 @@ module RubyLLM
155
341
  @client = build_client
156
342
  end
157
343
 
158
- # Main entry point
159
- def call
344
+ # Executes the agent and returns the processed response
345
+ #
346
+ # Handles caching, dry-run mode, and delegates to uncached_call
347
+ # for actual LLM execution.
348
+ #
349
+ # @yield [chunk] Yields chunks when streaming is enabled
350
+ # @yieldparam chunk [RubyLLM::Chunk] A streaming chunk with content
351
+ # @return [Object] The processed LLM response
352
+ def call(&block)
160
353
  return dry_run_response if @options[:dry_run]
161
- return uncached_call if @options[:skip_cache] || !self.class.cache_enabled?
354
+ return uncached_call(&block) if @options[:skip_cache] || !self.class.cache_enabled?
162
355
 
356
+ # Note: Cached responses don't stream (already complete)
163
357
  cache_store.fetch(cache_key, expires_in: self.class.cache_ttl) do
164
- uncached_call
358
+ uncached_call(&block)
165
359
  end
166
360
  end
167
361
 
168
- # --------------------------------------------------------------------------
169
- # Template Methods (override in subclasses)
170
- # --------------------------------------------------------------------------
362
+ # @!group Template Methods (override in subclasses)
171
363
 
364
+ # User prompt to send to the LLM
365
+ #
366
+ # @abstract Subclasses must implement this method
367
+ # @return [String] The user prompt
368
+ # @raise [NotImplementedError] If not overridden in subclass
172
369
  def user_prompt
173
370
  raise NotImplementedError, "#{self.class} must implement #user_prompt"
174
371
  end
175
372
 
373
+ # System prompt for LLM instructions
374
+ #
375
+ # @return [String, nil] System instructions, or nil for none
176
376
  def system_prompt
177
377
  nil
178
378
  end
179
379
 
380
+ # Response schema for structured output
381
+ #
382
+ # @return [RubyLLM::Schema, nil] Schema definition, or nil for free-form
180
383
  def schema
181
384
  nil
182
385
  end
183
386
 
387
+ # Post-processes the LLM response
388
+ #
389
+ # Override to transform the response before returning to the caller.
390
+ # Default implementation symbolizes hash keys.
391
+ #
392
+ # @param response [RubyLLM::Message] The raw response from the LLM
393
+ # @return [Object] The processed result
184
394
  def process_response(response)
185
395
  content = response.content
186
396
  return content unless content.is_a?(Hash)
187
397
  content.transform_keys(&:to_sym)
188
398
  end
189
399
 
190
- # Debug mode - returns prompt info without API call
400
+ # @!endgroup
401
+
402
+ # Returns the consolidated reliability configuration for this agent instance
403
+ #
404
+ # @return [Hash] Reliability config with :retries, :fallback_models, :total_timeout, :circuit_breaker
405
+ def reliability_config
406
+ default_retries = RubyLLM::Agents.configuration.default_retries
407
+ {
408
+ retries: self.class.retries || default_retries,
409
+ fallback_models: self.class.fallback_models,
410
+ total_timeout: self.class.total_timeout,
411
+ circuit_breaker: self.class.circuit_breaker_config
412
+ }
413
+ end
414
+
415
+ # Returns whether any reliability features are enabled for this agent
416
+ #
417
+ # @return [Boolean] true if retries, fallbacks, or circuit breaker is configured
418
+ def reliability_enabled?
419
+ config = reliability_config
420
+ (config[:retries]&.dig(:max) || 0) > 0 ||
421
+ config[:fallback_models]&.any? ||
422
+ config[:circuit_breaker].present?
423
+ end
424
+
425
+ # Returns prompt info without making an API call (debug mode)
426
+ #
427
+ # @return [Hash] Agent configuration and prompt info
191
428
  def dry_run_response
192
429
  {
193
430
  dry_run: true,
@@ -197,70 +434,318 @@ module RubyLLM
197
434
  timeout: self.class.timeout,
198
435
  system_prompt: system_prompt,
199
436
  user_prompt: user_prompt,
200
- schema: schema&.class&.name
437
+ attachments: @options[:with],
438
+ schema: schema&.class&.name,
439
+ streaming: self.class.streaming,
440
+ tools: self.class.tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
201
441
  }
202
442
  end
203
443
 
204
- # ==========================================================================
205
- # Private Methods
206
- # ==========================================================================
207
-
208
444
  private
209
445
 
210
- def uncached_call
211
- instrument_execution do
212
- Timeout.timeout(self.class.timeout) do
213
- response = client.ask(user_prompt)
446
+ # Executes the agent without caching
447
+ #
448
+ # Routes to reliability-enabled execution if configured, otherwise
449
+ # uses simple single-attempt execution.
450
+ #
451
+ # @yield [chunk] Yields chunks when streaming is enabled
452
+ # @return [Object] The processed response
453
+ def uncached_call(&block)
454
+ if reliability_enabled?
455
+ execute_with_reliability(&block)
456
+ else
457
+ instrument_execution { execute_single_attempt(&block) }
458
+ end
459
+ end
460
+
461
+ # Executes a single LLM attempt with timeout
462
+ #
463
+ # @param model_override [String, nil] Optional model to use instead of default
464
+ # @yield [chunk] Yields chunks when streaming is enabled
465
+ # @return [Object] The processed response
466
+ def execute_single_attempt(model_override: nil, &block)
467
+ current_client = model_override ? build_client_with_model(model_override) : client
468
+ @execution_started_at ||= Time.current
469
+
470
+ Timeout.timeout(self.class.timeout) do
471
+ if self.class.streaming && block_given?
472
+ execute_with_streaming(current_client, &block)
473
+ else
474
+ response = current_client.ask(user_prompt, **ask_options)
214
475
  process_response(capture_response(response))
215
476
  end
216
477
  end
217
478
  end
218
479
 
219
- # --------------------------------------------------------------------------
220
- # Caching
221
- # --------------------------------------------------------------------------
480
+ # Executes an LLM request with streaming enabled
481
+ #
482
+ # Yields chunks to the provided block as they arrive and tracks
483
+ # time to first token for latency analysis.
484
+ #
485
+ # @param current_client [RubyLLM::Chat] The configured client
486
+ # @yield [chunk] Yields each chunk as it arrives
487
+ # @yieldparam chunk [RubyLLM::Chunk] A streaming chunk
488
+ # @return [Object] The processed response
489
+ def execute_with_streaming(current_client, &block)
490
+ first_chunk_at = nil
491
+
492
+ response = current_client.ask(user_prompt, **ask_options) do |chunk|
493
+ first_chunk_at ||= Time.current
494
+ yield chunk if block_given?
495
+ end
496
+
497
+ if first_chunk_at && @execution_started_at
498
+ @time_to_first_token_ms = ((first_chunk_at - @execution_started_at) * 1000).to_i
499
+ end
500
+
501
+ process_response(capture_response(response))
502
+ end
503
+
504
+ # Executes the agent with retry/fallback/circuit breaker support
505
+ #
506
+ # @yield [chunk] Yields chunks when streaming is enabled
507
+ # @return [Object] The processed response
508
+ # @raise [Reliability::AllModelsExhaustedError] If all models fail
509
+ # @raise [Reliability::BudgetExceededError] If budget limits exceeded
510
+ # @raise [Reliability::TotalTimeoutError] If total timeout exceeded
511
+ def execute_with_reliability(&block)
512
+ config = reliability_config
513
+ models_to_try = [model, *config[:fallback_models]].uniq
514
+ total_deadline = config[:total_timeout] ? Time.current + config[:total_timeout] : nil
515
+ started_at = Time.current
516
+
517
+ # Pre-check budget
518
+ BudgetTracker.check_budget!(self.class.name) if RubyLLM::Agents.configuration.budgets_enabled?
519
+
520
+ instrument_execution_with_attempts(models_to_try: models_to_try) do |attempt_tracker|
521
+ last_error = nil
522
+
523
+ models_to_try.each do |current_model|
524
+ # Check circuit breaker
525
+ breaker = get_circuit_breaker(current_model)
526
+ if breaker&.open?
527
+ attempt_tracker.record_short_circuit(current_model)
528
+ next
529
+ end
530
+
531
+ retries_remaining = config[:retries]&.dig(:max) || 0
532
+ attempt_index = 0
533
+
534
+ loop do
535
+ # Check total timeout
536
+ if total_deadline && Time.current > total_deadline
537
+ elapsed = Time.current - started_at
538
+ raise Reliability::TotalTimeoutError.new(config[:total_timeout], elapsed)
539
+ end
540
+
541
+ attempt = attempt_tracker.start_attempt(current_model)
542
+
543
+ begin
544
+ result = execute_single_attempt(model_override: current_model, &block)
545
+ attempt_tracker.complete_attempt(attempt, success: true, response: @last_response)
546
+
547
+ # Record success in circuit breaker
548
+ breaker&.record_success!
549
+
550
+ # Record budget spend
551
+ if @last_response && RubyLLM::Agents.configuration.budgets_enabled?
552
+ record_attempt_cost(attempt_tracker)
553
+ end
554
+
555
+ # Use throw instead of return to allow instrument_execution_with_attempts
556
+ # to properly complete the execution record before returning
557
+ throw :execution_success, result
558
+
559
+ rescue *retryable_errors(config) => e
560
+ last_error = e
561
+ attempt_tracker.complete_attempt(attempt, success: false, error: e)
562
+ breaker&.record_failure!
563
+
564
+ if retries_remaining > 0 && !past_deadline?(total_deadline)
565
+ retries_remaining -= 1
566
+ attempt_index += 1
567
+ retries_config = config[:retries] || {}
568
+ delay = Reliability.calculate_backoff(
569
+ strategy: retries_config[:backoff] || :exponential,
570
+ base: retries_config[:base] || 0.4,
571
+ max_delay: retries_config[:max_delay] || 3.0,
572
+ attempt: attempt_index
573
+ )
574
+ sleep(delay)
575
+ else
576
+ break # Move to next model
577
+ end
578
+
579
+ rescue StandardError => e
580
+ # Non-retryable error - record and move to next model
581
+ last_error = e
582
+ attempt_tracker.complete_attempt(attempt, success: false, error: e)
583
+ breaker&.record_failure!
584
+ break
585
+ end
586
+ end
587
+ end
588
+
589
+ # All models exhausted
590
+ raise Reliability::AllModelsExhaustedError.new(models_to_try, last_error)
591
+ end
592
+ end
593
+
594
+ # Returns the list of retryable error classes
595
+ #
596
+ # @param config [Hash] Reliability configuration
597
+ # @return [Array<Class>] Error classes to retry on
598
+ def retryable_errors(config)
599
+ custom_errors = config[:retries]&.dig(:on) || []
600
+ Reliability.default_retryable_errors + custom_errors
601
+ end
602
+
603
+ # Checks if the total deadline has passed
604
+ #
605
+ # @param deadline [Time, nil] The deadline
606
+ # @return [Boolean] true if past deadline
607
+ def past_deadline?(deadline)
608
+ deadline && Time.current > deadline
609
+ end
610
+
611
+ # Gets or creates a circuit breaker for a model
612
+ #
613
+ # @param model_id [String] The model identifier
614
+ # @return [CircuitBreaker, nil] The circuit breaker or nil if not configured
615
+ def get_circuit_breaker(model_id)
616
+ config = reliability_config[:circuit_breaker]
617
+ return nil unless config
618
+
619
+ CircuitBreaker.from_config(self.class.name, model_id, config)
620
+ end
621
+
622
+ # Records cost from an attempt to the budget tracker
623
+ #
624
+ # @param attempt_tracker [AttemptTracker] The attempt tracker
625
+ # @return [void]
626
+ def record_attempt_cost(attempt_tracker)
627
+ successful = attempt_tracker.successful_attempt
628
+ return unless successful
222
629
 
630
+ # Calculate cost for this execution
631
+ # Note: Full cost calculation happens in instrumentation, but we
632
+ # record the spend here for budget tracking
633
+ model_info = resolve_model_info(successful[:model_id])
634
+ return unless model_info&.pricing
635
+
636
+ input_tokens = successful[:input_tokens] || 0
637
+ output_tokens = successful[:output_tokens] || 0
638
+
639
+ input_price = model_info.pricing.text_tokens&.input || 0
640
+ output_price = model_info.pricing.text_tokens&.output || 0
641
+
642
+ total_cost = (input_tokens / 1_000_000.0 * input_price) +
643
+ (output_tokens / 1_000_000.0 * output_price)
644
+
645
+ BudgetTracker.record_spend!(self.class.name, total_cost)
646
+ rescue StandardError => e
647
+ Rails.logger.warn("[RubyLLM::Agents] Failed to record budget spend: #{e.message}")
648
+ end
649
+
650
+ # Resolves model info for cost calculation
651
+ #
652
+ # @param model_id [String] The model identifier
653
+ # @return [Object, nil] Model info or nil
654
+ def resolve_model_info(model_id)
655
+ RubyLLM::Models.resolve(model_id)
656
+ rescue StandardError
657
+ nil
658
+ end
659
+
660
+ # Builds a client with a specific model
661
+ #
662
+ # @param model_id [String] The model identifier
663
+ # @return [RubyLLM::Chat] Configured chat client
664
+ def build_client_with_model(model_id)
665
+ client = RubyLLM.chat
666
+ .with_model(model_id)
667
+ .with_temperature(temperature)
668
+ client = client.with_instructions(system_prompt) if system_prompt
669
+ client = client.with_schema(schema) if schema
670
+ client = client.with_tools(*self.class.tools) if self.class.tools.any?
671
+ client
672
+ end
673
+
674
+ # Returns the configured cache store
675
+ #
676
+ # @return [ActiveSupport::Cache::Store] The cache store
223
677
  def cache_store
224
678
  RubyLLM::Agents.configuration.cache_store
225
679
  end
226
680
 
681
+ # Generates the full cache key for this agent invocation
682
+ #
683
+ # @return [String] Cache key in format "ruby_llm_agent/ClassName/version/hash"
227
684
  def cache_key
228
685
  ["ruby_llm_agent", self.class.name, self.class.version, cache_key_hash].join("/")
229
686
  end
230
687
 
688
+ # Generates a hash of the cache key data
689
+ #
690
+ # @return [String] SHA256 hex digest of the cache key data
231
691
  def cache_key_hash
232
692
  Digest::SHA256.hexdigest(cache_key_data.to_json)
233
693
  end
234
694
 
235
- # Override to customize what's included in cache key
695
+ # Returns data to include in cache key generation
696
+ #
697
+ # Override to customize what parameters affect cache invalidation.
698
+ #
699
+ # @return [Hash] Data to hash for cache key
236
700
  def cache_key_data
237
- @options.except(:skip_cache, :dry_run)
701
+ @options.except(:skip_cache, :dry_run, :with)
238
702
  end
239
703
 
240
- # --------------------------------------------------------------------------
241
- # Validation
242
- # --------------------------------------------------------------------------
704
+ # Returns options to pass to the ask method
705
+ #
706
+ # Currently supports :with for attachments (images, PDFs, etc.)
707
+ #
708
+ # @return [Hash] Options for the ask call
709
+ def ask_options
710
+ opts = {}
711
+ opts[:with] = @options[:with] if @options[:with]
712
+ opts
713
+ end
243
714
 
715
+ # Validates that all required parameters are present
716
+ #
717
+ # @raise [ArgumentError] If required parameters are missing
718
+ # @return [void]
244
719
  def validate_required_params!
245
720
  required = self.class.params.select { |_, v| v[:required] }.keys
246
721
  missing = required.reject { |p| @options.key?(p) || @options.key?(p.to_s) }
247
722
  raise ArgumentError, "#{self.class} missing required params: #{missing.join(', ')}" if missing.any?
248
723
  end
249
724
 
250
- # --------------------------------------------------------------------------
251
- # Client Building
252
- # --------------------------------------------------------------------------
253
-
725
+ # Builds and configures the RubyLLM client
726
+ #
727
+ # @return [RubyLLM::Chat] Configured chat client
254
728
  def build_client
255
729
  client = RubyLLM.chat
256
730
  .with_model(model)
257
731
  .with_temperature(temperature)
258
732
  client = client.with_instructions(system_prompt) if system_prompt
259
733
  client = client.with_schema(schema) if schema
734
+ client = client.with_tools(*self.class.tools) if self.class.tools.any?
260
735
  client
261
736
  end
262
737
 
263
- # Helper for subclasses that need conversation history
738
+ # Builds a client with pre-populated conversation history
739
+ #
740
+ # Useful for multi-turn conversations or providing context.
741
+ #
742
+ # @param messages [Array<Hash>] Messages with :role and :content keys
743
+ # @return [RubyLLM::Chat] Client with messages added
744
+ # @example
745
+ # build_client_with_messages([
746
+ # { role: "user", content: "Hello" },
747
+ # { role: "assistant", content: "Hi there!" }
748
+ # ])
264
749
  def build_client_with_messages(messages)
265
750
  messages.reduce(build_client) do |client, message|
266
751
  client.with_message(message[:role], message[:content])