ruby_llm-agents 0.3.1 → 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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +88 -0
  3. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +68 -4
  4. data/app/models/ruby_llm/agents/execution/analytics.rb +114 -13
  5. data/app/models/ruby_llm/agents/execution/scopes.rb +10 -0
  6. data/app/models/ruby_llm/agents/execution.rb +26 -58
  7. data/app/views/layouts/rubyllm/agents/application.html.erb +103 -352
  8. data/app/views/rubyllm/agents/agents/_agent.html.erb +87 -0
  9. data/app/views/rubyllm/agents/agents/index.html.erb +2 -71
  10. data/app/views/rubyllm/agents/agents/show.html.erb +349 -416
  11. data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +7 -7
  12. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +46 -0
  13. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -90
  14. data/app/views/rubyllm/agents/dashboard/_execution_item.html.erb +54 -39
  15. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +79 -5
  16. data/app/views/rubyllm/agents/dashboard/_top_errors.html.erb +49 -0
  17. data/app/views/rubyllm/agents/dashboard/index.html.erb +76 -151
  18. data/app/views/rubyllm/agents/executions/show.html.erb +256 -93
  19. data/app/views/rubyllm/agents/settings/show.html.erb +1 -1
  20. data/app/views/rubyllm/agents/shared/_breadcrumbs.html.erb +48 -0
  21. data/app/views/rubyllm/agents/shared/_nav_link.html.erb +27 -0
  22. data/config/routes.rb +2 -0
  23. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +28 -0
  24. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +7 -0
  25. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  26. data/lib/ruby_llm/agents/base/caching.rb +43 -0
  27. data/lib/ruby_llm/agents/base/cost_calculation.rb +103 -0
  28. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  29. data/lib/ruby_llm/agents/base/execution.rb +206 -0
  30. data/lib/ruby_llm/agents/base/reliability_execution.rb +131 -0
  31. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  32. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  33. data/lib/ruby_llm/agents/base.rb +19 -619
  34. data/lib/ruby_llm/agents/instrumentation.rb +36 -3
  35. data/lib/ruby_llm/agents/result.rb +235 -0
  36. data/lib/ruby_llm/agents/version.rb +1 -1
  37. data/lib/ruby_llm/agents.rb +1 -0
  38. metadata +15 -20
  39. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  40. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  41. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  42. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  43. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
@@ -1,5 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "base/dsl"
4
+ require_relative "base/caching"
5
+ require_relative "base/cost_calculation"
6
+ require_relative "base/tool_tracking"
7
+ require_relative "base/response_building"
8
+ require_relative "base/execution"
9
+ require_relative "base/reliability_execution"
10
+
3
11
  module RubyLLM
4
12
  module Agents
5
13
  # Base class for LLM-powered agents
@@ -37,11 +45,14 @@ module RubyLLM
37
45
  # @api public
38
46
  class Base
39
47
  include Instrumentation
48
+ include Caching
49
+ include CostCalculation
50
+ include ToolTracking
51
+ include ResponseBuilding
52
+ include Execution
53
+ include ReliabilityExecution
40
54
 
41
- # @!visibility private
42
- VERSION = "1.0".freeze
43
- # @!visibility private
44
- CACHE_TTL = 1.hour
55
+ extend DSL
45
56
 
46
57
  class << self
47
58
  # Factory method to instantiate and execute an agent
@@ -72,249 +83,6 @@ module RubyLLM
72
83
  def call(*args, **kwargs, &block)
73
84
  new(*args, **kwargs).call(&block)
74
85
  end
75
-
76
- # @!group Configuration DSL
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"
84
- def model(value = nil)
85
- @model = value if value
86
- @model || inherited_or_default(:model, RubyLLM::Agents.configuration.default_model)
87
- end
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
95
- def temperature(value = nil)
96
- @temperature = value if value
97
- @temperature || inherited_or_default(:temperature, RubyLLM::Agents.configuration.default_temperature)
98
- end
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"
106
- def version(value = nil)
107
- @version = value if value
108
- @version || inherited_or_default(:version, VERSION)
109
- end
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
117
- def timeout(value = nil)
118
- @timeout = value if value
119
- @timeout || inherited_or_default(:timeout, RubyLLM::Agents.configuration.default_timeout)
120
- end
121
-
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
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
218
- def param(name, required: false, default: nil)
219
- @params ||= {}
220
- @params[name] = { required: required, default: default }
221
- define_method(name) do
222
- @options[name] || @options[name.to_s] || self.class.params.dig(name, :default)
223
- end
224
- end
225
-
226
- # Returns all defined parameters including inherited ones
227
- #
228
- # @return [Hash{Symbol => Hash}] Parameter definitions
229
- def params
230
- parent = superclass.respond_to?(:params) ? superclass.params : {}
231
- parent.merge(@params || {})
232
- end
233
-
234
- # @!endgroup
235
-
236
- # @!group Caching DSL
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
244
- def cache(ttl = CACHE_TTL)
245
- @cache_enabled = true
246
- @cache_ttl = ttl
247
- end
248
-
249
- # Returns whether caching is enabled for this agent
250
- #
251
- # @return [Boolean] true if caching is enabled
252
- def cache_enabled?
253
- @cache_enabled || false
254
- end
255
-
256
- # Returns the cache TTL for this agent
257
- #
258
- # @return [ActiveSupport::Duration] The cache TTL
259
- def cache_ttl
260
- @cache_ttl || CACHE_TTL
261
- end
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
-
308
- private
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
315
- def inherited_or_default(method, default)
316
- superclass.respond_to?(method) ? superclass.send(method) : default
317
- end
318
86
  end
319
87
 
320
88
  # @!attribute [r] model
@@ -325,7 +93,9 @@ module RubyLLM
325
93
  # @return [RubyLLM::Chat] The configured RubyLLM client
326
94
  # @!attribute [r] time_to_first_token_ms
327
95
  # @return [Integer, nil] Time to first token in milliseconds (streaming only)
328
- attr_reader :model, :temperature, :client, :time_to_first_token_ms
96
+ # @!attribute [r] accumulated_tool_calls
97
+ # @return [Array<Hash>] Tool calls accumulated during execution
98
+ attr_reader :model, :temperature, :client, :time_to_first_token_ms, :accumulated_tool_calls
329
99
 
330
100
  # Creates a new agent instance
331
101
  #
@@ -337,28 +107,11 @@ module RubyLLM
337
107
  @model = model
338
108
  @temperature = temperature
339
109
  @options = options
110
+ @accumulated_tool_calls = []
340
111
  validate_required_params!
341
112
  @client = build_client
342
113
  end
343
114
 
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)
353
- return dry_run_response if @options[:dry_run]
354
- return uncached_call(&block) if @options[:skip_cache] || !self.class.cache_enabled?
355
-
356
- # Note: Cached responses don't stream (already complete)
357
- cache_store.fetch(cache_key, expires_in: self.class.cache_ttl) do
358
- uncached_call(&block)
359
- end
360
- end
361
-
362
115
  # @!group Template Methods (override in subclasses)
363
116
 
364
117
  # User prompt to send to the LLM
@@ -398,359 +151,6 @@ module RubyLLM
398
151
  end
399
152
 
400
153
  # @!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
428
- def dry_run_response
429
- {
430
- dry_run: true,
431
- agent: self.class.name,
432
- model: model,
433
- temperature: temperature,
434
- timeout: self.class.timeout,
435
- system_prompt: system_prompt,
436
- user_prompt: user_prompt,
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 }
441
- }
442
- end
443
-
444
- private
445
-
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)
475
- process_response(capture_response(response))
476
- end
477
- end
478
- end
479
-
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
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
677
- def cache_store
678
- RubyLLM::Agents.configuration.cache_store
679
- end
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"
684
- def cache_key
685
- ["ruby_llm_agent", self.class.name, self.class.version, cache_key_hash].join("/")
686
- end
687
-
688
- # Generates a hash of the cache key data
689
- #
690
- # @return [String] SHA256 hex digest of the cache key data
691
- def cache_key_hash
692
- Digest::SHA256.hexdigest(cache_key_data.to_json)
693
- end
694
-
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
700
- def cache_key_data
701
- @options.except(:skip_cache, :dry_run, :with)
702
- end
703
-
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
714
-
715
- # Validates that all required parameters are present
716
- #
717
- # @raise [ArgumentError] If required parameters are missing
718
- # @return [void]
719
- def validate_required_params!
720
- required = self.class.params.select { |_, v| v[:required] }.keys
721
- missing = required.reject { |p| @options.key?(p) || @options.key?(p.to_s) }
722
- raise ArgumentError, "#{self.class} missing required params: #{missing.join(', ')}" if missing.any?
723
- end
724
-
725
- # Builds and configures the RubyLLM client
726
- #
727
- # @return [RubyLLM::Chat] Configured chat client
728
- def build_client
729
- client = RubyLLM.chat
730
- .with_model(model)
731
- .with_temperature(temperature)
732
- client = client.with_instructions(system_prompt) if system_prompt
733
- client = client.with_schema(schema) if schema
734
- client = client.with_tools(*self.class.tools) if self.class.tools.any?
735
- client
736
- end
737
-
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
- # ])
749
- def build_client_with_messages(messages)
750
- messages.reduce(build_client) do |client, message|
751
- client.with_message(message[:role], message[:content])
752
- end
753
- end
754
154
  end
755
155
  end
756
156
  end