ruby_llm-agents 3.5.4 → 3.6.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 +4 -0
  3. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +155 -10
  4. data/app/helpers/ruby_llm/agents/application_helper.rb +15 -1
  5. data/app/models/ruby_llm/agents/execution/replayable.rb +124 -0
  6. data/app/models/ruby_llm/agents/execution/scopes.rb +42 -1
  7. data/app/models/ruby_llm/agents/execution.rb +50 -1
  8. data/app/models/ruby_llm/agents/tenant/budgetable.rb +28 -4
  9. data/app/views/layouts/ruby_llm/agents/application.html.erb +41 -28
  10. data/app/views/ruby_llm/agents/agents/show.html.erb +16 -1
  11. data/app/views/ruby_llm/agents/dashboard/_top_tenants.html.erb +47 -0
  12. data/app/views/ruby_llm/agents/dashboard/index.html.erb +397 -100
  13. data/lib/generators/ruby_llm_agents/rename_agent_generator.rb +53 -0
  14. data/lib/generators/ruby_llm_agents/templates/rename_agent_migration.rb.tt +19 -0
  15. data/lib/ruby_llm/agents/agent_tool.rb +125 -0
  16. data/lib/ruby_llm/agents/audio/speaker.rb +5 -3
  17. data/lib/ruby_llm/agents/audio/speech_pricing.rb +63 -187
  18. data/lib/ruby_llm/agents/audio/transcriber.rb +5 -3
  19. data/lib/ruby_llm/agents/audio/transcription_pricing.rb +5 -7
  20. data/lib/ruby_llm/agents/base_agent.rb +144 -5
  21. data/lib/ruby_llm/agents/core/configuration.rb +178 -53
  22. data/lib/ruby_llm/agents/core/errors.rb +3 -77
  23. data/lib/ruby_llm/agents/core/instrumentation.rb +0 -17
  24. data/lib/ruby_llm/agents/core/version.rb +1 -1
  25. data/lib/ruby_llm/agents/dsl/base.rb +0 -8
  26. data/lib/ruby_llm/agents/dsl/queryable.rb +124 -0
  27. data/lib/ruby_llm/agents/dsl.rb +1 -0
  28. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +2 -1
  29. data/lib/ruby_llm/agents/image/generator/pricing.rb +75 -217
  30. data/lib/ruby_llm/agents/image/generator.rb +5 -3
  31. data/lib/ruby_llm/agents/infrastructure/attempt_tracker.rb +8 -0
  32. data/lib/ruby_llm/agents/infrastructure/circuit_breaker.rb +4 -2
  33. data/lib/ruby_llm/agents/pipeline/builder.rb +43 -0
  34. data/lib/ruby_llm/agents/pipeline/context.rb +11 -1
  35. data/lib/ruby_llm/agents/pipeline/executor.rb +1 -25
  36. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +26 -1
  37. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +18 -0
  38. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +130 -3
  39. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +29 -0
  40. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +11 -4
  41. data/lib/ruby_llm/agents/pipeline.rb +0 -92
  42. data/lib/ruby_llm/agents/results/background_removal_result.rb +11 -1
  43. data/lib/ruby_llm/agents/results/base.rb +23 -1
  44. data/lib/ruby_llm/agents/results/embedding_result.rb +14 -1
  45. data/lib/ruby_llm/agents/results/image_analysis_result.rb +11 -1
  46. data/lib/ruby_llm/agents/results/image_edit_result.rb +11 -1
  47. data/lib/ruby_llm/agents/results/image_generation_result.rb +12 -3
  48. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +11 -1
  49. data/lib/ruby_llm/agents/results/image_transform_result.rb +11 -1
  50. data/lib/ruby_llm/agents/results/image_upscale_result.rb +11 -1
  51. data/lib/ruby_llm/agents/results/image_variation_result.rb +11 -1
  52. data/lib/ruby_llm/agents/results/speech_result.rb +20 -1
  53. data/lib/ruby_llm/agents/results/transcription_result.rb +20 -1
  54. data/lib/ruby_llm/agents/text/embedder.rb +23 -18
  55. data/lib/ruby_llm/agents.rb +70 -5
  56. data/lib/tasks/ruby_llm_agents.rake +21 -0
  57. metadata +7 -6
  58. data/lib/ruby_llm/agents/infrastructure/reliability/breaker_manager.rb +0 -80
  59. data/lib/ruby_llm/agents/infrastructure/reliability/execution_constraints.rb +0 -69
  60. data/lib/ruby_llm/agents/infrastructure/reliability/executor.rb +0 -125
  61. data/lib/ruby_llm/agents/infrastructure/reliability/fallback_routing.rb +0 -72
  62. data/lib/ruby_llm/agents/infrastructure/reliability/retry_strategy.rb +0 -82
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module DSL
6
+ # Adds execution querying capabilities to agent classes.
7
+ #
8
+ # Mixed into BaseAgent via `extend DSL::Queryable`, making all methods
9
+ # available as class methods on agent classes.
10
+ #
11
+ # @example Basic queries
12
+ # SupportAgent.executions.successful.recent
13
+ # SupportAgent.executions.today.expensive(0.50)
14
+ #
15
+ # @example Convenience methods
16
+ # SupportAgent.last_run
17
+ # SupportAgent.stats
18
+ # SupportAgent.total_spent(since: 1.week)
19
+ #
20
+ module Queryable
21
+ # Returns an ActiveRecord::Relation scoped to this agent's executions.
22
+ #
23
+ # @return [ActiveRecord::Relation]
24
+ #
25
+ # @example
26
+ # SupportAgent.executions.successful.last(5)
27
+ # SupportAgent.executions.where("total_cost > ?", 0.01)
28
+ #
29
+ def executions
30
+ RubyLLM::Agents::Execution.by_agent(name)
31
+ end
32
+
33
+ # Returns the most recent execution for this agent.
34
+ #
35
+ # @return [RubyLLM::Agents::Execution, nil]
36
+ #
37
+ def last_run
38
+ executions.order(created_at: :desc).first
39
+ end
40
+
41
+ # Returns recent failed executions.
42
+ #
43
+ # @param since [ActiveSupport::Duration] Time window (default: 24.hours)
44
+ # @return [ActiveRecord::Relation]
45
+ #
46
+ def failures(since: 24.hours)
47
+ executions.failed.where("created_at > ?", since.ago)
48
+ end
49
+
50
+ # Returns total cost spent by this agent.
51
+ #
52
+ # @param since [ActiveSupport::Duration, nil] Optional time window
53
+ # @return [BigDecimal] Total cost in USD
54
+ #
55
+ def total_spent(since: nil)
56
+ scope = executions
57
+ scope = scope.where("created_at > ?", since.ago) if since
58
+ scope.sum(:total_cost)
59
+ end
60
+
61
+ # Returns a stats summary hash for this agent.
62
+ #
63
+ # @param since [ActiveSupport::Duration, nil] Time window
64
+ # @return [Hash] Stats summary
65
+ #
66
+ def stats(since: nil)
67
+ scope = executions
68
+ scope = scope.where("created_at > ?", since.ago) if since
69
+
70
+ total = scope.count
71
+ successful = scope.successful.count
72
+
73
+ {
74
+ total: total,
75
+ successful: successful,
76
+ failed: scope.failed.count,
77
+ success_rate: total.zero? ? 0.0 : (successful.to_f / total * 100).round(1),
78
+ avg_duration_ms: scope.average(:duration_ms)&.round,
79
+ avg_cost: total.zero? ? 0 : (scope.sum(:total_cost).to_f / total).round(6),
80
+ total_cost: scope.sum(:total_cost),
81
+ total_tokens: scope.sum(:total_tokens),
82
+ avg_tokens: scope.average(:total_tokens)&.round
83
+ }
84
+ end
85
+
86
+ # Returns cost breakdown by model for this agent.
87
+ #
88
+ # @param since [ActiveSupport::Duration, nil] Time window
89
+ # @return [Hash{String => Hash}] Costs per model
90
+ #
91
+ def cost_by_model(since: nil)
92
+ scope = executions
93
+ scope = scope.where("created_at > ?", since.ago) if since
94
+
95
+ scope.group(:model_id).pluck(
96
+ :model_id,
97
+ Arel.sql("COUNT(*)"),
98
+ Arel.sql("SUM(total_cost)"),
99
+ Arel.sql("AVG(total_cost)")
100
+ ).each_with_object({}) do |(model, count, total, avg), hash|
101
+ hash[model] = {
102
+ count: count,
103
+ total_cost: total&.to_f&.round(6) || 0,
104
+ avg_cost: avg&.to_f&.round(6) || 0
105
+ }
106
+ end
107
+ end
108
+
109
+ # Returns executions matching specific parameter values.
110
+ #
111
+ # @param params [Hash] Parameter key-value pairs to match
112
+ # @return [ActiveRecord::Relation]
113
+ #
114
+ def with_params(**params)
115
+ scope = executions
116
+ params.each do |key, value|
117
+ scope = scope.with_parameter(key, value)
118
+ end
119
+ scope
120
+ end
121
+ end
122
+ end
123
+ end
124
+ end
@@ -3,6 +3,7 @@
3
3
  require_relative "dsl/base"
4
4
  require_relative "dsl/reliability"
5
5
  require_relative "dsl/caching"
6
+ require_relative "dsl/queryable"
6
7
 
7
8
  module RubyLLM
8
9
  module Agents
@@ -85,7 +85,8 @@ module RubyLLM
85
85
  if config.async_logging && defined?(ExecutionLoggerJob)
86
86
  ExecutionLoggerJob.perform_later(execution_data)
87
87
  else
88
- RubyLLM::Agents::Execution.create!(execution_data)
88
+ execution = RubyLLM::Agents::Execution.create!(execution_data)
89
+ result.execution_id = execution.id if result.respond_to?(:execution_id=)
89
90
  end
90
91
  rescue => e
91
92
  Rails.logger.error("[RubyLLM::Agents] Failed to record #{execution_type} execution: #{e.message}") if defined?(Rails)
@@ -1,17 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
4
- require "json"
3
+ require_relative "../../pricing/data_store"
4
+ require_relative "../../pricing/ruby_llm_adapter"
5
+ require_relative "../../pricing/litellm_adapter"
5
6
 
6
7
  module RubyLLM
7
8
  module Agents
8
9
  class ImageGenerator
9
- # Dynamic pricing resolution for image generation models
10
+ # Dynamic pricing resolution for image generation models.
10
11
  #
11
- # Uses a three-tier strategy:
12
- # 1. LiteLLM JSON (primary) - comprehensive, community-maintained
13
- # 2. Configurable pricing table - user overrides
14
- # 3. Hardcoded fallbacks - last resort
12
+ # Uses a three-tier strategy (no hardcoded prices):
13
+ # 1. Configurable pricing table - user overrides
14
+ # 2. RubyLLM gem (local, no HTTP) - model registry pricing
15
+ # 3. LiteLLM (via shared DataStore) - comprehensive, community-maintained
16
+ #
17
+ # When no pricing is found, returns 0 to signal unknown cost.
15
18
  #
16
19
  # @example Get price for a model
17
20
  # Pricing.cost_per_image("gpt-image-1", size: "1024x1024", quality: "hd")
@@ -24,9 +27,6 @@ module RubyLLM
24
27
  module Pricing
25
28
  extend self
26
29
 
27
- LITELLM_PRICING_URL = "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json"
28
- DEFAULT_CACHE_TTL = 24 * 60 * 60 # 24 hours in seconds
29
-
30
30
  # Calculate total cost for image generation
31
31
  #
32
32
  # @param model_id [String] The model identifier
@@ -44,170 +44,50 @@ module RubyLLM
44
44
  # @param model_id [String] The model identifier
45
45
  # @param size [String] Image size
46
46
  # @param quality [String] Quality setting
47
- # @return [Float] Cost per image in USD
47
+ # @return [Float] Cost per image in USD (0 if unknown)
48
48
  def cost_per_image(model_id, size: nil, quality: nil)
49
- # 1. Try LiteLLM pricing data
50
- if (litellm_price = from_litellm(model_id, size, quality))
51
- return litellm_price
52
- end
53
-
54
- # 2. Try configurable pricing table
49
+ # Tier 1: User-configurable pricing table
55
50
  if (config_price = from_config(model_id, size, quality))
56
51
  return config_price
57
52
  end
58
53
 
59
- # 3. Fall back to hardcoded estimates
60
- fallback_price(model_id, size, quality)
54
+ # Tier 2: RubyLLM gem (local, no HTTP)
55
+ if (ruby_llm_price = from_ruby_llm(model_id))
56
+ return ruby_llm_price
57
+ end
58
+
59
+ # Tier 3: LiteLLM (via shared DataStore + adapter)
60
+ if (litellm_price = from_litellm(model_id, size, quality))
61
+ return litellm_price
62
+ end
63
+
64
+ # No pricing found — return user-configured default or 0
65
+ config.default_image_cost || 0
61
66
  end
62
67
 
63
- # Refresh pricing data from LiteLLM
68
+ # Refresh pricing data
64
69
  #
65
- # @return [Hash] The fetched pricing data
70
+ # @return [void]
66
71
  def refresh!
67
- @litellm_data = nil
68
- @litellm_fetched_at = nil
69
- litellm_data
72
+ Agents::Pricing::DataStore.refresh!
70
73
  end
71
74
 
72
- # Get all known pricing for debugging/display
75
+ # Expose all known pricing for debugging/console inspection
73
76
  #
74
77
  # @return [Hash] Merged pricing from all sources
75
78
  def all_pricing
76
79
  {
77
80
  litellm: litellm_image_models,
78
- configured: config.image_model_pricing || {},
79
- fallbacks: fallback_pricing_table
81
+ configured: config.image_model_pricing || {}
80
82
  }
81
83
  end
82
84
 
83
85
  private
84
86
 
85
- # Fetch from LiteLLM JSON
86
- def from_litellm(model_id, size, quality)
87
- data = litellm_data
88
- return nil unless data
89
-
90
- # Try exact match first
91
- model_data = find_litellm_model(data, model_id, size, quality)
92
- return nil unless model_data
93
-
94
- extract_litellm_price(model_data, size, quality)
95
- end
96
-
97
- def find_litellm_model(data, model_id, size, quality)
98
- normalized = normalize_model_id(model_id)
99
-
100
- # Try various key formats LiteLLM uses
101
- candidates = [
102
- model_id,
103
- normalized,
104
- "#{size}/#{model_id}",
105
- "#{size}/#{quality}/#{model_id}",
106
- "aiml/#{normalized}",
107
- "together_ai/#{normalized}"
108
- ]
109
-
110
- candidates.each do |key|
111
- return data[key] if data[key]
112
- end
113
-
114
- # Fuzzy match by model name pattern
115
- data.find do |key, _value|
116
- key_lower = key.to_s.downcase
117
- normalized_lower = normalized.downcase
118
-
119
- key_lower.include?(normalized_lower) ||
120
- normalized_lower.include?(key_lower.split("/").last.to_s)
121
- end&.last
122
- end
123
-
124
- def extract_litellm_price(model_data, size, quality)
125
- # LiteLLM uses different pricing fields for images
126
- if model_data["input_cost_per_image"]
127
- return model_data["input_cost_per_image"]
128
- end
129
-
130
- if model_data["input_cost_per_pixel"] && size
131
- width, height = size.split("x").map(&:to_i)
132
- pixels = width * height
133
- return (model_data["input_cost_per_pixel"] * pixels).round(6)
134
- end
135
-
136
- # Some models have quality-based pricing
137
- if quality == "hd" && model_data["input_cost_per_image_hd"]
138
- return model_data["input_cost_per_image_hd"]
139
- end
140
-
141
- nil
142
- end
143
-
144
- def litellm_data
145
- return @litellm_data if @litellm_data && !cache_expired?
146
-
147
- @litellm_data = fetch_litellm_data
148
- @litellm_fetched_at = Time.now
149
- @litellm_data
150
- end
151
-
152
- def fetch_litellm_data
153
- # Use Rails cache if available
154
- if defined?(Rails) && Rails.cache
155
- Rails.cache.fetch("litellm_pricing_data", expires_in: cache_ttl) do
156
- fetch_from_url
157
- end
158
- else
159
- fetch_from_url
160
- end
161
- rescue => e
162
- warn "[RubyLLM::Agents] Failed to fetch LiteLLM pricing: #{e.message}"
163
- {}
164
- end
165
-
166
- def fetch_from_url
167
- uri = URI(config.litellm_pricing_url || LITELLM_PRICING_URL)
168
- http = Net::HTTP.new(uri.host, uri.port)
169
- http.use_ssl = uri.scheme == "https"
170
- http.open_timeout = 5
171
- http.read_timeout = 10
172
-
173
- request = Net::HTTP::Get.new(uri)
174
- response = http.request(request)
87
+ # ============================================================
88
+ # Tier 1: User-configurable pricing table
89
+ # ============================================================
175
90
 
176
- if response.is_a?(Net::HTTPSuccess)
177
- JSON.parse(response.body)
178
- else
179
- {}
180
- end
181
- rescue => e
182
- warn "[RubyLLM::Agents] HTTP error fetching LiteLLM pricing: #{e.message}"
183
- {}
184
- end
185
-
186
- def cache_expired?
187
- return true unless @litellm_fetched_at
188
- Time.now - @litellm_fetched_at > cache_ttl
189
- end
190
-
191
- def cache_ttl
192
- ttl = config.litellm_pricing_cache_ttl
193
- return DEFAULT_CACHE_TTL unless ttl
194
-
195
- # Handle ActiveSupport::Duration
196
- ttl.respond_to?(:to_i) ? ttl.to_i : ttl
197
- end
198
-
199
- # Get image-specific models from LiteLLM data
200
- def litellm_image_models
201
- litellm_data.select do |key, value|
202
- value.is_a?(Hash) && (
203
- value["input_cost_per_image"] ||
204
- value["input_cost_per_pixel"] ||
205
- key.to_s.match?(/dall-e|flux|sdxl|stable|imagen|image/i)
206
- )
207
- end
208
- end
209
-
210
- # Fetch from configurable pricing table
211
91
  def from_config(model_id, size, quality)
212
92
  table = config.image_model_pricing
213
93
  return nil unless table
@@ -251,80 +131,58 @@ module RubyLLM
251
131
  pricing[:base] || pricing["base"] || pricing[:default] || pricing["default"] || pricing[:standard] || pricing["standard"]
252
132
  end
253
133
 
254
- # Hardcoded fallback prices
255
- def fallback_price(model_id, size, quality)
256
- normalized = normalize_model_id(model_id)
134
+ # ============================================================
135
+ # Tier 2: RubyLLM gem (local, no HTTP)
136
+ # ============================================================
257
137
 
258
- case normalized
259
- when /gpt-image-1|dall-e-3/i
260
- dalle3_price(size, quality)
261
- when /dall-e-2/i
262
- dalle2_price(size)
263
- when /imagen/i
264
- 0.02
265
- when /flux.*pro.*ultra/i
266
- 0.063
267
- when /flux.*pro/i
268
- 0.05
269
- when /flux.*dev/i
270
- 0.025
271
- when /flux.*schnell/i
272
- 0.003
273
- when /sdxl.*lightning/i
274
- 0.002
275
- when /sdxl|stable-diffusion-xl/i
276
- 0.04
277
- when /stable-diffusion/i
278
- 0.02
279
- when /ideogram/i
280
- 0.04
281
- when /recraft/i
282
- 0.04
283
- when /real-esrgan|upscal/i
284
- 0.01
285
- when /blip|caption|analyz/i
286
- 0.001
287
- when /segment|background|rembg/i
288
- 0.01
289
- else
290
- config.default_image_cost || 0.04
291
- end
138
+ def from_ruby_llm(model_id)
139
+ data = Agents::Pricing::RubyLLMAdapter.find_model(model_id)
140
+ return nil unless data
141
+
142
+ data[:input_cost_per_image]
292
143
  end
293
144
 
294
- def dalle3_price(size, quality)
295
- pixels = parse_pixels(size)
296
- is_large = pixels && pixels >= 1_000_000
145
+ # ============================================================
146
+ # Tier 3: LiteLLM (via shared DataStore + adapter)
147
+ # ============================================================
297
148
 
298
- case quality
299
- when "hd"
300
- is_large ? 0.12 : 0.08
301
- else
302
- is_large ? 0.08 : 0.04
303
- end
149
+ def from_litellm(model_id, size, quality)
150
+ data = Agents::Pricing::LiteLLMAdapter.find_model(model_id)
151
+ return nil unless data
152
+
153
+ extract_image_price(data, size, quality)
304
154
  end
305
155
 
306
- def dalle2_price(size)
307
- case size
308
- when "1024x1024" then 0.02
309
- when "512x512" then 0.018
310
- when "256x256" then 0.016
311
- else 0.02
156
+ def extract_image_price(data, size, quality)
157
+ # Check quality-specific pricing first when HD requested
158
+ if quality == "hd" && data[:input_cost_per_image_hd]
159
+ return data[:input_cost_per_image_hd]
312
160
  end
161
+
162
+ if data[:input_cost_per_image]
163
+ return data[:input_cost_per_image]
164
+ end
165
+
166
+ if data[:input_cost_per_pixel] && size
167
+ width, height = size.split("x").map(&:to_i)
168
+ pixels = width * height
169
+ return (data[:input_cost_per_pixel] * pixels).round(6)
170
+ end
171
+
172
+ nil
313
173
  end
314
174
 
315
- def fallback_pricing_table
316
- {
317
- "gpt-image-1" => {standard: 0.04, hd: 0.08, large_hd: 0.12},
318
- "dall-e-3" => {standard: 0.04, hd: 0.08, large_hd: 0.12},
319
- "dall-e-2" => {"1024x1024" => 0.02, "512x512" => 0.018, "256x256" => 0.016},
320
- "flux-pro" => 0.05,
321
- "flux-dev" => 0.025,
322
- "flux-schnell" => 0.003,
323
- "sdxl" => 0.04,
324
- "stable-diffusion-3.5" => 0.03,
325
- "imagen-3" => 0.02,
326
- "ideogram-2" => 0.04
327
- }
175
+ def litellm_image_models
176
+ data = Agents::Pricing::DataStore.litellm_data
177
+ return {} unless data.is_a?(Hash)
178
+
179
+ data.select do |key, value|
180
+ value.is_a?(Hash) && (
181
+ value["input_cost_per_image"] ||
182
+ value["input_cost_per_pixel"] ||
183
+ key.to_s.match?(/dall-e|flux|sdxl|stable|imagen|image/i)
184
+ )
185
+ end
328
186
  end
329
187
 
330
188
  def parse_pixels(size)
@@ -248,7 +248,8 @@ module RubyLLM
248
248
  images: images,
249
249
  started_at: context.started_at || execution_started_at,
250
250
  completed_at: execution_completed_at,
251
- tenant_id: context.tenant_id
251
+ tenant_id: context.tenant_id,
252
+ execution_id: context.execution_id
252
253
  )
253
254
 
254
255
  # Update context with cost info
@@ -348,7 +349,7 @@ module RubyLLM
348
349
  end
349
350
 
350
351
  # Build successful result
351
- def build_result(images:, started_at:, completed_at:, tenant_id:)
352
+ def build_result(images:, started_at:, completed_at:, tenant_id:, execution_id: nil)
352
353
  ImageGenerationResult.new(
353
354
  images: images,
354
355
  prompt: prompt,
@@ -359,7 +360,8 @@ module RubyLLM
359
360
  started_at: started_at,
360
361
  completed_at: completed_at,
361
362
  tenant_id: tenant_id,
362
- generator_class: self.class.name
363
+ generator_class: self.class.name,
364
+ execution_id: execution_id
363
365
  )
364
366
  end
365
367
 
@@ -121,6 +121,8 @@ module RubyLLM
121
121
 
122
122
  # Finds the last failed attempt
123
123
  #
124
+ # Useful for debugging in rails console.
125
+ #
124
126
  # @return [Hash, nil] The last failed attempt or nil
125
127
  def last_failed_attempt
126
128
  @attempts.reverse.find { |a| a[:error_class].present? }
@@ -189,6 +191,8 @@ module RubyLLM
189
191
 
190
192
  # Calculates total duration across all attempts
191
193
  #
194
+ # Useful for debugging in rails console.
195
+ #
192
196
  # @return [Integer] Total duration in milliseconds
193
197
  def total_duration_ms
194
198
  @attempts.sum { |a| a[:duration_ms] || 0 }
@@ -203,6 +207,8 @@ module RubyLLM
203
207
 
204
208
  # Returns the number of failed attempts
205
209
  #
210
+ # Useful for debugging in rails console.
211
+ #
206
212
  # @return [Integer] Number of failed attempts
207
213
  def failed_attempts_count
208
214
  @attempts.count { |a| a[:error_class].present? }
@@ -210,6 +216,8 @@ module RubyLLM
210
216
 
211
217
  # Returns the number of short-circuited attempts
212
218
  #
219
+ # Useful for debugging in rails console.
220
+ #
213
221
  # @return [Integer] Number of short-circuited attempts
214
222
  def short_circuited_count
215
223
  @attempts.count { |a| a[:short_circuited] }
@@ -123,17 +123,19 @@ module RubyLLM
123
123
 
124
124
  # Returns the time remaining until the breaker closes
125
125
  #
126
+ # Useful for debugging circuit breaker state in rails console.
127
+ #
126
128
  # @return [Integer, nil] Seconds until cooldown expires, or nil if not open
127
129
  def time_until_close
128
130
  return nil unless open?
129
131
 
130
- # We can't easily get TTL from Rails.cache, so this is an approximation
131
- # In a real implementation, you might store the open time as well
132
132
  cooldown_seconds
133
133
  end
134
134
 
135
135
  # Returns status information for the circuit breaker
136
136
  #
137
+ # Useful for debugging circuit breaker state in rails console.
138
+ #
137
139
  # @return [Hash] Status information including open state and failure count
138
140
  def status
139
141
  {
@@ -144,6 +144,12 @@ module RubyLLM
144
144
 
145
145
  # Reliability (if agent has retries or fallbacks configured)
146
146
  builder.use(Middleware::Reliability) if reliability_enabled?(agent_class)
147
+
148
+ # Global custom middleware
149
+ apply_custom_middleware!(builder, global_middleware_entries)
150
+
151
+ # Per-agent custom middleware
152
+ apply_custom_middleware!(builder, agent_middleware_entries(agent_class))
147
153
  end
148
154
  end
149
155
 
@@ -159,6 +165,43 @@ module RubyLLM
159
165
 
160
166
  private
161
167
 
168
+ # Returns global custom middleware entries from configuration
169
+ #
170
+ # @return [Array<Hash>] Middleware entries
171
+ def global_middleware_entries
172
+ RubyLLM::Agents.configuration.middleware_stack
173
+ rescue
174
+ []
175
+ end
176
+
177
+ # Returns per-agent custom middleware entries
178
+ #
179
+ # @param agent_class [Class] The agent class
180
+ # @return [Array<Hash>] Middleware entries
181
+ def agent_middleware_entries(agent_class)
182
+ return [] unless agent_class&.respond_to?(:agent_middleware)
183
+
184
+ agent_class.agent_middleware
185
+ rescue
186
+ []
187
+ end
188
+
189
+ # Applies custom middleware entries to a builder
190
+ #
191
+ # @param builder [Builder] The builder to modify
192
+ # @param entries [Array<Hash>] Middleware entries with :klass, :before, :after
193
+ def apply_custom_middleware!(builder, entries)
194
+ entries.each do |entry|
195
+ if entry[:before]
196
+ builder.insert_before(entry[:before], entry[:klass])
197
+ elsif entry[:after]
198
+ builder.insert_after(entry[:after], entry[:klass])
199
+ else
200
+ builder.use(entry[:klass])
201
+ end
202
+ end
203
+ end
204
+
162
205
  # Check if budgets are enabled globally
163
206
  #
164
207
  # @return [Boolean]
@@ -34,6 +34,9 @@ module RubyLLM
34
34
  # Execution tracking (set by Instrumentation middleware)
35
35
  attr_accessor :started_at, :completed_at, :attempt, :attempts_made, :execution_id
36
36
 
37
+ # Execution hierarchy (agent-as-tool)
38
+ attr_accessor :parent_execution_id, :root_execution_id
39
+
37
40
  # Result data (set by core execute method)
38
41
  attr_accessor :output, :error, :cached
39
42
 
@@ -59,13 +62,17 @@ module RubyLLM
59
62
  # @param skip_cache [Boolean] Whether to skip caching
60
63
  # @param stream_block [Proc, nil] Block for streaming
61
64
  # @param options [Hash] Additional options passed to the agent
62
- def initialize(input:, agent_class:, agent_instance: nil, model: nil, tenant: nil, skip_cache: false, stream_block: nil, **options)
65
+ def initialize(input:, agent_class:, agent_instance: nil, model: nil, tenant: nil, skip_cache: false, stream_block: nil, parent_execution_id: nil, root_execution_id: nil, **options)
63
66
  @input = input
64
67
  @agent_class = agent_class
65
68
  @agent_instance = agent_instance
66
69
  @agent_type = extract_agent_type(agent_class)
67
70
  @model = model || extract_model(agent_class)
68
71
 
72
+ # Execution hierarchy
73
+ @parent_execution_id = parent_execution_id
74
+ @root_execution_id = root_execution_id
75
+
69
76
  # Store tenant in options for middleware to resolve
70
77
  @options = options.merge(tenant: tenant).compact
71
78
 
@@ -202,6 +209,9 @@ module RubyLLM
202
209
  new_ctx.tenant_config = @tenant_config
203
210
  new_ctx.started_at = @started_at
204
211
  new_ctx.attempts_made = @attempts_made
212
+ # Preserve execution hierarchy
213
+ new_ctx.parent_execution_id = @parent_execution_id
214
+ new_ctx.root_execution_id = @root_execution_id
205
215
  new_ctx
206
216
  end
207
217