ruby_llm-agents 0.2.4 → 0.3.0

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