ruby_llm-agents 0.2.3 → 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 +59 -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
@@ -3,21 +3,39 @@
3
3
  module RubyLLM
4
4
  module Agents
5
5
  class Execution
6
- # Scopes concern for common query patterns
6
+ # Query scopes for filtering and aggregating executions
7
7
  #
8
- # Provides chainable scopes for:
9
- # - Time-based filtering (today, this_week, last_n_days)
10
- # - Agent-based filtering (by_agent, by_version, by_model)
11
- # - Status filtering (successful, failed, errors, timeouts)
12
- # - Performance filtering (expensive, slow, high_token)
13
- # - JSONB parameter queries
14
- # - Aggregations (total_cost_sum, avg_duration)
8
+ # All scopes are chainable and return ActiveRecord::Relation objects.
15
9
  #
10
+ # @example Chaining scopes
11
+ # Execution.by_agent("SearchAgent").today.successful
12
+ # Execution.expensive(2.00).slow(10_000)
13
+ #
14
+ # @see RubyLLM::Agents::Execution::Analytics
15
+ # @api public
16
16
  module Scopes
17
17
  extend ActiveSupport::Concern
18
18
 
19
19
  included do
20
- # Time-based scopes
20
+ # @!group Time-based Scopes
21
+
22
+ # @!method recent(limit = 100)
23
+ # Returns most recent executions
24
+ # @param limit [Integer] Maximum records to return
25
+ # @return [ActiveRecord::Relation]
26
+
27
+ # @!method today
28
+ # Returns executions from today
29
+ # @return [ActiveRecord::Relation]
30
+
31
+ # @!method this_week
32
+ # Returns executions from current week
33
+ # @return [ActiveRecord::Relation]
34
+
35
+ # @!method last_n_days(n)
36
+ # Returns executions from the last n days
37
+ # @param n [Integer] Number of days
38
+ # @return [ActiveRecord::Relation]
21
39
  scope :recent, ->(limit = 100) { order(created_at: :desc).limit(limit) }
22
40
  scope :oldest, ->(limit = 100) { order(created_at: :asc).limit(limit) }
23
41
  scope :all_time, -> { all } # Explicit scope for all-time queries (used by analytics)
@@ -27,12 +45,43 @@ module RubyLLM
27
45
  scope :this_month, -> { where("created_at >= ?", Time.current.beginning_of_month) }
28
46
  scope :last_n_days, ->(n) { where("created_at >= ?", n.days.ago) }
29
47
 
30
- # Agent-based scopes
48
+ # @!endgroup
49
+
50
+ # @!group Agent-based Scopes
51
+
52
+ # @!method by_agent(agent_type)
53
+ # Filters to a specific agent type
54
+ # @param agent_type [String] The agent class name
55
+ # @return [ActiveRecord::Relation]
56
+
57
+ # @!method by_version(version)
58
+ # Filters to a specific agent version
59
+ # @param version [String] The version string
60
+ # @return [ActiveRecord::Relation]
61
+
62
+ # @!method by_model(model_id)
63
+ # Filters to a specific LLM model
64
+ # @param model_id [String] The model identifier
65
+ # @return [ActiveRecord::Relation]
31
66
  scope :by_agent, ->(agent_type) { where(agent_type: agent_type.to_s) }
32
67
  scope :by_version, ->(version) { where(agent_version: version.to_s) }
33
68
  scope :by_model, ->(model_id) { where(model_id: model_id.to_s) }
34
69
 
35
- # Status scopes
70
+ # @!endgroup
71
+
72
+ # @!group Status Scopes
73
+
74
+ # @!method successful
75
+ # Returns executions with success status
76
+ # @return [ActiveRecord::Relation]
77
+
78
+ # @!method failed
79
+ # Returns executions with error or timeout status
80
+ # @return [ActiveRecord::Relation]
81
+
82
+ # @!method errors
83
+ # Returns executions with error status only
84
+ # @return [ActiveRecord::Relation]
36
85
  scope :running, -> { where(status: "running") }
37
86
  scope :in_progress, -> { running } # alias
38
87
  scope :completed, -> { where.not(status: "running") }
@@ -41,12 +90,37 @@ module RubyLLM
41
90
  scope :errors, -> { where(status: "error") }
42
91
  scope :timeouts, -> { where(status: "timeout") }
43
92
 
44
- # Performance scopes
93
+ # @!endgroup
94
+
95
+ # @!group Performance Scopes
96
+
97
+ # @!method expensive(threshold_dollars = 1.00)
98
+ # Returns executions exceeding cost threshold
99
+ # @param threshold_dollars [Float] Cost threshold in USD
100
+ # @return [ActiveRecord::Relation]
101
+
102
+ # @!method slow(threshold_ms = 5000)
103
+ # Returns executions exceeding duration threshold
104
+ # @param threshold_ms [Integer] Duration threshold in milliseconds
105
+ # @return [ActiveRecord::Relation]
106
+
107
+ # @!method high_token(threshold = 10_000)
108
+ # Returns executions exceeding token threshold
109
+ # @param threshold [Integer] Token count threshold
110
+ # @return [ActiveRecord::Relation]
45
111
  scope :expensive, ->(threshold_dollars = 1.00) { where("total_cost >= ?", threshold_dollars) }
46
112
  scope :slow, ->(threshold_ms = 5000) { where("duration_ms >= ?", threshold_ms) }
47
113
  scope :high_token, ->(threshold = 10_000) { where("total_tokens >= ?", threshold) }
48
114
 
49
- # Parameter-based scopes (JSONB queries)
115
+ # @!endgroup
116
+
117
+ # @!group Parameter Scopes
118
+
119
+ # @!method with_parameter(key, value = nil)
120
+ # Filters by JSONB parameter key/value
121
+ # @param key [String, Symbol] Parameter key to check
122
+ # @param value [Object, nil] Optional value to match
123
+ # @return [ActiveRecord::Relation]
50
124
  scope :with_parameter, ->(key, value = nil) do
51
125
  if value
52
126
  where("parameters @> ?", { key => value }.to_json)
@@ -55,25 +129,171 @@ module RubyLLM
55
129
  end
56
130
  end
57
131
 
132
+ # @!endgroup
133
+
134
+ # @!group Search Scopes
135
+
136
+ # @!method search(query)
137
+ # Free-text search across error fields and parameters
138
+ # @param query [String] Search query
139
+ # @return [ActiveRecord::Relation]
140
+ scope :search, ->(query) do
141
+ return all if query.blank?
142
+
143
+ sanitized_query = "%#{sanitize_sql_like(query)}%"
144
+ # Use database-agnostic case-insensitive search
145
+ # PostgreSQL: ILIKE, SQLite: LIKE with LOWER() + ESCAPE clause
146
+ if connection.adapter_name.downcase.include?("postgresql")
147
+ where(
148
+ "error_class ILIKE :q OR error_message ILIKE :q OR CAST(parameters AS TEXT) ILIKE :q",
149
+ q: sanitized_query
150
+ )
151
+ else
152
+ # SQLite and other databases need ESCAPE clause for backslash to work
153
+ sanitized_query_lower = sanitized_query.downcase
154
+ where(
155
+ "LOWER(error_class) LIKE :q ESCAPE '\\' OR " \
156
+ "LOWER(error_message) LIKE :q ESCAPE '\\' OR " \
157
+ "LOWER(CAST(parameters AS TEXT)) LIKE :q ESCAPE '\\'",
158
+ q: sanitized_query_lower
159
+ )
160
+ end
161
+ end
162
+
163
+ # @!endgroup
164
+
165
+ # @!group Tracing Scopes
166
+
167
+ # @!method by_trace(trace_id)
168
+ # Filters to a specific distributed trace
169
+ # @param trace_id [String] The trace identifier
170
+ # @return [ActiveRecord::Relation]
171
+
172
+ # @!method by_request(request_id)
173
+ # Filters to a specific request
174
+ # @param request_id [String] The request identifier
175
+ # @return [ActiveRecord::Relation]
176
+
177
+ # @!method root_executions
178
+ # Returns only root (top-level) executions
179
+ # @return [ActiveRecord::Relation]
180
+
181
+ # @!method child_executions
182
+ # Returns only child (nested) executions
183
+ # @return [ActiveRecord::Relation]
184
+ scope :by_trace, ->(trace_id) { where(trace_id: trace_id) }
185
+ scope :by_request, ->(request_id) { where(request_id: request_id) }
186
+ scope :root_executions, -> { where(parent_execution_id: nil) }
187
+ scope :child_executions, -> { where.not(parent_execution_id: nil) }
188
+ scope :children_of, ->(execution_id) { where(parent_execution_id: execution_id) }
189
+
190
+ # @!endgroup
191
+
192
+ # @!group Routing and Retry Scopes
193
+
194
+ # @!method with_fallback
195
+ # Returns executions that used a fallback model
196
+ # @return [ActiveRecord::Relation]
197
+
198
+ # @!method retryable_errors
199
+ # Returns executions with retryable errors
200
+ # @return [ActiveRecord::Relation]
201
+
202
+ # @!method rate_limited
203
+ # Returns executions that were rate limited
204
+ # @return [ActiveRecord::Relation]
205
+ scope :with_fallback, -> { where.not(fallback_reason: nil) }
206
+ scope :retryable_errors, -> { where(retryable: true) }
207
+ scope :rate_limited, -> { where(rate_limited: true) }
208
+ scope :by_fallback_reason, ->(reason) { where(fallback_reason: reason) }
209
+
210
+ # @!endgroup
211
+
212
+ # @!group Caching Scopes
213
+
214
+ # @!method cached
215
+ # Returns executions that were cache hits
216
+ # @return [ActiveRecord::Relation]
217
+
218
+ # @!method cache_miss
219
+ # Returns executions that were cache misses
220
+ # @return [ActiveRecord::Relation]
221
+ scope :cached, -> { where(cache_hit: true) }
222
+ scope :cache_miss, -> { where(cache_hit: [false, nil]) }
223
+
224
+ # @!endgroup
225
+
226
+ # @!group Streaming Scopes
227
+
228
+ # @!method streaming
229
+ # Returns executions that used streaming
230
+ # @return [ActiveRecord::Relation]
231
+
232
+ # @!method non_streaming
233
+ # Returns executions that did not use streaming
234
+ # @return [ActiveRecord::Relation]
235
+ scope :streaming, -> { where(streaming: true) }
236
+ scope :non_streaming, -> { where(streaming: [false, nil]) }
237
+
238
+ # @!endgroup
239
+
240
+ # @!group Finish Reason Scopes
241
+
242
+ # @!method by_finish_reason(reason)
243
+ # Filters by finish reason
244
+ # @param reason [String] The finish reason (stop, length, content_filter, tool_calls)
245
+ # @return [ActiveRecord::Relation]
246
+
247
+ # @!method truncated
248
+ # Returns executions that hit max_tokens limit
249
+ # @return [ActiveRecord::Relation]
250
+
251
+ # @!method content_filtered
252
+ # Returns executions blocked by safety filter
253
+ # @return [ActiveRecord::Relation]
254
+ scope :by_finish_reason, ->(reason) { where(finish_reason: reason) }
255
+ scope :truncated, -> { where(finish_reason: "length") }
256
+ scope :content_filtered, -> { where(finish_reason: "content_filter") }
257
+ scope :tool_calls, -> { where(finish_reason: "tool_calls") }
258
+
259
+ # @!endgroup
58
260
  end
59
261
 
60
- # Aggregation methods (not scopes - these return values, not relations)
262
+ # @!group Aggregation Methods
263
+ #
264
+ # These methods return scalar values, not relations.
265
+ # They can be called on scoped relations.
266
+
61
267
  class_methods do
268
+ # Returns sum of total_cost for the current scope
269
+ #
270
+ # @return [Float, nil] Total cost in USD
62
271
  def total_cost_sum
63
272
  sum(:total_cost)
64
273
  end
65
274
 
275
+ # Returns sum of total_tokens for the current scope
276
+ #
277
+ # @return [Integer, nil] Total token count
66
278
  def total_tokens_sum
67
279
  sum(:total_tokens)
68
280
  end
69
281
 
282
+ # Returns average duration for the current scope
283
+ #
284
+ # @return [Float, nil] Average duration in milliseconds
70
285
  def avg_duration
71
286
  average(:duration_ms)
72
287
  end
73
288
 
289
+ # Returns average token count for the current scope
290
+ #
291
+ # @return [Float, nil] Average tokens per execution
74
292
  def avg_tokens
75
293
  average(:total_tokens)
76
294
  end
295
+
296
+ # @!endgroup
77
297
  end
78
298
  end
79
299
  end
@@ -2,18 +2,49 @@
2
2
 
3
3
  module RubyLLM
4
4
  module Agents
5
- # Execution model for tracking agent executions
5
+ # ActiveRecord model for tracking agent executions
6
6
  #
7
- # Stores all agent execution data including:
8
- # - Agent identification (type, version)
9
- # - Model configuration (model_id, temperature)
10
- # - Timing (started_at, completed_at, duration_ms)
11
- # - Token usage (input_tokens, output_tokens, cached_tokens)
12
- # - Costs (input_cost, output_cost, total_cost in dollars)
13
- # - Status (success, error, timeout)
14
- # - Parameters and metadata (JSONB)
15
- # - Error tracking (error_class, error_message)
7
+ # Stores comprehensive execution data for observability and analytics.
16
8
  #
9
+ # @!attribute [rw] agent_type
10
+ # @return [String] Full class name of the agent (e.g., "SearchAgent")
11
+ # @!attribute [rw] agent_version
12
+ # @return [String] Version string for cache invalidation
13
+ # @!attribute [rw] model_id
14
+ # @return [String] LLM model identifier used
15
+ # @!attribute [rw] temperature
16
+ # @return [Float] Temperature setting used (0.0-2.0)
17
+ # @!attribute [rw] status
18
+ # @return [String] Execution status: "running", "success", "error", "timeout"
19
+ # @!attribute [rw] started_at
20
+ # @return [Time] When execution started
21
+ # @!attribute [rw] completed_at
22
+ # @return [Time, nil] When execution completed
23
+ # @!attribute [rw] duration_ms
24
+ # @return [Integer, nil] Execution duration in milliseconds
25
+ # @!attribute [rw] input_tokens
26
+ # @return [Integer, nil] Number of input tokens
27
+ # @!attribute [rw] output_tokens
28
+ # @return [Integer, nil] Number of output tokens
29
+ # @!attribute [rw] total_tokens
30
+ # @return [Integer, nil] Sum of input and output tokens
31
+ # @!attribute [rw] input_cost
32
+ # @return [BigDecimal, nil] Cost of input tokens in USD
33
+ # @!attribute [rw] output_cost
34
+ # @return [BigDecimal, nil] Cost of output tokens in USD
35
+ # @!attribute [rw] total_cost
36
+ # @return [BigDecimal, nil] Total cost in USD
37
+ # @!attribute [rw] parameters
38
+ # @return [Hash] Sanitized parameters passed to the agent
39
+ # @!attribute [rw] metadata
40
+ # @return [Hash] Custom metadata from execution_metadata hook
41
+ # @!attribute [rw] error_class
42
+ # @return [String, nil] Exception class name if failed
43
+ # @!attribute [rw] error_message
44
+ # @return [String, nil] Exception message if failed
45
+ #
46
+ # @see RubyLLM::Agents::Instrumentation
47
+ # @api public
17
48
  class Execution < ::ActiveRecord::Base
18
49
  self.table_name = "ruby_llm_agents_executions"
19
50
 
@@ -28,6 +59,18 @@ module RubyLLM
28
59
  # - timeout: completed due to timeout
29
60
  enum :status, %w[running success error timeout].index_by(&:itself), prefix: true
30
61
 
62
+ # Allowed finish reasons from LLM providers
63
+ FINISH_REASONS = %w[stop length content_filter tool_calls other].freeze
64
+
65
+ # Allowed fallback reasons for model switching
66
+ FALLBACK_REASONS = %w[price_limit quality_fail rate_limit timeout safety error other].freeze
67
+
68
+ # Execution hierarchy associations
69
+ belongs_to :parent_execution, class_name: "RubyLLM::Agents::Execution", optional: true
70
+ belongs_to :root_execution, class_name: "RubyLLM::Agents::Execution", optional: true
71
+ has_many :child_executions, class_name: "RubyLLM::Agents::Execution",
72
+ foreign_key: :parent_execution_id, dependent: :nullify, inverse_of: :parent_execution
73
+
31
74
  # Validations
32
75
  validates :agent_type, :model_id, :started_at, presence: true
33
76
  validates :status, inclusion: { in: statuses.keys }
@@ -36,21 +79,187 @@ module RubyLLM
36
79
  validates :input_tokens, :output_tokens, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
37
80
  validates :duration_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
38
81
  validates :input_cost, :output_cost, :total_cost, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
82
+ validates :finish_reason, inclusion: { in: FINISH_REASONS }, allow_nil: true
83
+ validates :fallback_reason, inclusion: { in: FALLBACK_REASONS }, allow_nil: true
84
+ validates :time_to_first_token_ms, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
39
85
 
40
- # Callbacks
41
86
  before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? }
42
87
  before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? }
43
- after_commit :broadcast_execution, on: %i[create update]
88
+ after_commit :broadcast_turbo_streams, on: %i[create update]
89
+
90
+ # Aggregates costs from all attempts using each attempt's model pricing
91
+ #
92
+ # Used for multi-attempt executions (retries/fallbacks) where different models
93
+ # may have been used. Calculates total cost by summing individual attempt costs.
94
+ #
95
+ # @return [void]
96
+ def aggregate_attempt_costs!
97
+ return if attempts.blank?
98
+
99
+ total_input_cost = 0
100
+ total_output_cost = 0
101
+
102
+ attempts.each do |attempt|
103
+ # Skip short-circuited attempts (no actual API call made)
104
+ next if attempt["short_circuited"]
105
+
106
+ model_info = resolve_model_info(attempt["model_id"])
107
+ next unless model_info&.pricing
108
+
109
+ input_price = model_info.pricing.text_tokens&.input || 0
110
+ output_price = model_info.pricing.text_tokens&.output || 0
111
+
112
+ input_tokens = attempt["input_tokens"] || 0
113
+ output_tokens = attempt["output_tokens"] || 0
114
+
115
+ total_input_cost += (input_tokens / 1_000_000.0) * input_price
116
+ total_output_cost += (output_tokens / 1_000_000.0) * output_price
117
+ end
118
+
119
+ self.input_cost = total_input_cost.round(6)
120
+ self.output_cost = total_output_cost.round(6)
121
+ end
122
+
123
+ # Returns whether this execution had multiple attempts
124
+ #
125
+ # @return [Boolean] true if more than one attempt was made
126
+ def has_retries?
127
+ (attempts_count || 0) > 1
128
+ end
129
+
130
+ # Returns whether this execution used fallback models
131
+ #
132
+ # @return [Boolean] true if a different model than requested succeeded
133
+ def used_fallback?
134
+ chosen_model_id.present? && chosen_model_id != model_id
135
+ end
136
+
137
+ # Returns the successful attempt data (if any)
138
+ #
139
+ # @return [Hash, nil] The successful attempt or nil
140
+ def successful_attempt
141
+ return nil if attempts.blank?
142
+
143
+ attempts.find { |a| a["error_class"].nil? && !a["short_circuited"] }
144
+ end
145
+
146
+ # Returns failed attempts
147
+ #
148
+ # @return [Array<Hash>] Failed attempt data
149
+ def failed_attempts
150
+ return [] if attempts.blank?
151
+
152
+ attempts.select { |a| a["error_class"].present? }
153
+ end
154
+
155
+ # Returns short-circuited attempts (circuit breaker blocked)
156
+ #
157
+ # @return [Array<Hash>] Short-circuited attempt data
158
+ def short_circuited_attempts
159
+ return [] if attempts.blank?
160
+
161
+ attempts.select { |a| a["short_circuited"] }
162
+ end
163
+
164
+ # Returns whether this is a root (top-level) execution
165
+ #
166
+ # @return [Boolean] true if this is a root execution
167
+ def root?
168
+ parent_execution_id.nil?
169
+ end
170
+
171
+ # Returns whether this is a child (nested) execution
172
+ #
173
+ # @return [Boolean] true if this has a parent execution
174
+ def child?
175
+ parent_execution_id.present?
176
+ end
44
177
 
45
- # Broadcast execution changes via ActionCable
46
- def broadcast_execution
178
+ # Returns the execution tree depth
179
+ #
180
+ # @return [Integer] depth level (0 for root)
181
+ def depth
182
+ return 0 if root?
183
+
184
+ parent_execution&.depth.to_i + 1
185
+ end
186
+
187
+ # Returns whether this execution was a cache hit
188
+ #
189
+ # @return [Boolean] true if response was served from cache
190
+ def cached?
191
+ cache_hit == true
192
+ end
193
+
194
+ # Returns whether this execution was rate limited
195
+ #
196
+ # @return [Boolean] true if rate limiting occurred
197
+ def rate_limited?
198
+ rate_limited == true
199
+ end
200
+
201
+ # Returns whether this execution used streaming
202
+ #
203
+ # @return [Boolean] true if streaming was enabled
204
+ def streaming?
205
+ streaming == true
206
+ end
207
+
208
+ # Returns whether the response was truncated due to max_tokens
209
+ #
210
+ # @return [Boolean] true if hit token limit
211
+ def truncated?
212
+ finish_reason == "length"
213
+ end
214
+
215
+ # Returns whether the response was blocked by content filter
216
+ #
217
+ # @return [Boolean] true if blocked by safety filter
218
+ def content_filtered?
219
+ finish_reason == "content_filter"
220
+ end
221
+
222
+ # Returns real-time dashboard data for the Now Strip
223
+ #
224
+ # @return [Hash] Now strip metrics
225
+ def self.now_strip_data
226
+ today_scope = today
227
+ {
228
+ running: running.count,
229
+ success_today: today_scope.status_success.count,
230
+ errors_today: today_scope.status_error.count,
231
+ timeouts_today: today_scope.status_timeout.count,
232
+ cost_today: today_scope.sum(:total_cost) || 0,
233
+ executions_today: today_scope.count,
234
+ success_rate: calculate_today_success_rate
235
+ }
236
+ end
237
+
238
+ # Calculates today's success rate
239
+ #
240
+ # @return [Float] Success rate as percentage
241
+ def self.calculate_today_success_rate
242
+ total = today.count
243
+ return 0.0 if total.zero?
244
+
245
+ (today.successful.count.to_f / total * 100).round(1)
246
+ end
247
+
248
+ # Broadcasts execution changes via ActionCable for real-time dashboard updates
249
+ #
250
+ # Sends JSON with action, id, status, and rendered HTML partials.
251
+ # The JavaScript client handles DOM updates based on the action type.
252
+ #
253
+ # @return [void]
254
+ def broadcast_turbo_streams
47
255
  ActionCable.server.broadcast(
48
256
  "ruby_llm_agents:executions",
49
257
  {
50
258
  action: previously_new_record? ? "created" : "updated",
51
259
  id: id,
52
260
  status: status,
53
- html: render_execution_html
261
+ html: render_execution_html,
262
+ now_strip_html: render_now_strip_html
54
263
  }
55
264
  )
56
265
  rescue StandardError => e
@@ -59,23 +268,57 @@ module RubyLLM
59
268
 
60
269
  private
61
270
 
271
+ # Renders the execution item partial for broadcast
272
+ #
273
+ # @return [String, nil] HTML string or nil if rendering fails
62
274
  def render_execution_html
63
275
  ApplicationController.render(
64
276
  partial: "rubyllm/agents/dashboard/execution_item",
65
277
  locals: { execution: self }
66
278
  )
67
279
  rescue StandardError
68
- # Partial may not exist in all contexts
69
280
  nil
70
281
  end
71
282
 
283
+ # Renders the Now Strip values partial for broadcast
284
+ #
285
+ # @return [String, nil] HTML string or nil if rendering fails
286
+ def render_now_strip_html
287
+ ApplicationController.render(
288
+ partial: "rubyllm/agents/dashboard/now_strip_values",
289
+ locals: { now_strip: self.class.now_strip_data }
290
+ )
291
+ rescue StandardError
292
+ nil
293
+ end
294
+
295
+ # Calculates and sets total_tokens from input and output
296
+ #
297
+ # @return [Integer] The calculated total
72
298
  def calculate_total_tokens
73
299
  self.total_tokens = (input_tokens || 0) + (output_tokens || 0)
74
300
  end
75
301
 
302
+ # Calculates and sets total_cost from input and output costs
303
+ #
304
+ # @return [BigDecimal] The calculated total
76
305
  def calculate_total_cost
77
306
  self.total_cost = (input_cost || 0) + (output_cost || 0)
78
307
  end
308
+
309
+ # Resolves model info for cost calculation
310
+ #
311
+ # @param lookup_model_id [String, nil] The model identifier (defaults to self.model_id)
312
+ # @return [Object, nil] Model info or nil
313
+ def resolve_model_info(lookup_model_id = nil)
314
+ lookup_model_id ||= model_id
315
+ return nil unless lookup_model_id
316
+
317
+ model, _provider = RubyLLM::Models.resolve(lookup_model_id)
318
+ model
319
+ rescue StandardError
320
+ nil
321
+ end
79
322
  end
80
323
  end
81
324
  end