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.
- checksums.yaml +4 -4
- data/README.md +273 -0
- data/app/channels/ruby_llm/agents/executions_channel.rb +24 -1
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +81 -0
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +51 -0
- data/app/controllers/ruby_llm/agents/agents_controller.rb +228 -59
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +167 -12
- data/app/controllers/ruby_llm/agents/executions_controller.rb +189 -31
- data/app/controllers/ruby_llm/agents/settings_controller.rb +20 -0
- data/app/helpers/ruby_llm/agents/application_helper.rb +307 -7
- data/app/models/ruby_llm/agents/execution/analytics.rb +224 -20
- data/app/models/ruby_llm/agents/execution/metrics.rb +41 -25
- data/app/models/ruby_llm/agents/execution/scopes.rb +234 -14
- data/app/models/ruby_llm/agents/execution.rb +259 -16
- data/app/services/ruby_llm/agents/agent_registry.rb +49 -12
- data/app/views/layouts/rubyllm/agents/application.html.erb +351 -85
- data/app/views/rubyllm/agents/agents/_version_comparison.html.erb +186 -0
- data/app/views/rubyllm/agents/agents/show.html.erb +233 -10
- data/app/views/rubyllm/agents/dashboard/_action_center.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_alerts_feed.html.erb +62 -0
- data/app/views/rubyllm/agents/dashboard/_breaker_strip.html.erb +47 -0
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +165 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +10 -0
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +71 -0
- data/app/views/rubyllm/agents/dashboard/index.html.erb +215 -109
- data/app/views/rubyllm/agents/executions/_filters.html.erb +152 -155
- data/app/views/rubyllm/agents/executions/_list.html.erb +103 -12
- data/app/views/rubyllm/agents/executions/dry_run.html.erb +149 -0
- data/app/views/rubyllm/agents/executions/index.html.erb +17 -72
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +16 -2
- data/app/views/rubyllm/agents/executions/show.html.erb +693 -14
- data/app/views/rubyllm/agents/settings/show.html.erb +369 -0
- data/app/views/rubyllm/agents/shared/_filter_dropdown.html.erb +121 -0
- data/app/views/rubyllm/agents/shared/_select_dropdown.html.erb +85 -0
- data/config/routes.rb +7 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +27 -0
- data/lib/generators/ruby_llm_agents/templates/add_caching_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_finish_reason_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_routing_migration.rb.tt +19 -0
- data/lib/generators/ruby_llm_agents/templates/add_streaming_migration.rb.tt +8 -0
- data/lib/generators/ruby_llm_agents/templates/add_tracing_migration.rb.tt +34 -0
- data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +66 -4
- data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +53 -6
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +139 -8
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +38 -1
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +78 -0
- data/lib/ruby_llm/agents/alert_manager.rb +207 -0
- data/lib/ruby_llm/agents/attempt_tracker.rb +295 -0
- data/lib/ruby_llm/agents/base.rb +580 -112
- data/lib/ruby_llm/agents/budget_tracker.rb +360 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +197 -0
- data/lib/ruby_llm/agents/configuration.rb +279 -1
- data/lib/ruby_llm/agents/engine.rb +58 -6
- data/lib/ruby_llm/agents/execution_logger_job.rb +17 -6
- data/lib/ruby_llm/agents/inflections.rb +13 -2
- data/lib/ruby_llm/agents/instrumentation.rb +538 -87
- data/lib/ruby_llm/agents/redactor.rb +130 -0
- data/lib/ruby_llm/agents/reliability.rb +185 -0
- data/lib/ruby_llm/agents/version.rb +3 -1
- data/lib/ruby_llm/agents.rb +52 -0
- metadata +41 -2
- 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
|
-
#
|
|
6
|
+
# Query scopes for filtering and aggregating executions
|
|
7
7
|
#
|
|
8
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
5
|
+
# ActiveRecord model for tracking agent executions
|
|
6
6
|
#
|
|
7
|
-
# Stores
|
|
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 :
|
|
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
|
-
#
|
|
46
|
-
|
|
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
|