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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c2a8bd149077abc08185f8bc5c59d03323ba6adce25f1feed23dfc35d17376de
4
- data.tar.gz: 4e9d466a76aa4565a6936a8d9cc7499b4b18aa6efb1e9c40baa0a28c35ca656d
3
+ metadata.gz: 26167fa0299a5be7e45f5742829e8d30b6b5eb6c96ce341af7895358897b5e88
4
+ data.tar.gz: ea883ffe277ac2595b2866cdc427a95876bf0fcb4f3b6f5d9678b7c1e54279cb
5
5
  SHA512:
6
- metadata.gz: 3758ac407012134aab9fcf89f0ad7895b3cb38dd9b385772bb8102cea93a4d9247e9b3083e5eaa7e7f2d1969c4079d1b4286168c00353976af84effa6272ec2b
7
- data.tar.gz: 3787b8665b33714ac6f4221e7a79753563d4b4553b8868a8dba2d9a1b2d3e310c43c3f27064fb7e0aa1bf9addad865b26626127d7862ed52221637c6b81eaa91
6
+ metadata.gz: 8d2f5c7e95d86da7c22ec97e90b205ec2b61918cf5d9c7fedcfca86275515d76409429ea4bdb5fe524dd58ae4691ef7d5602edee6233d77ad243a1e1ae9778e6
7
+ data.tar.gz: b32a708e9576ee8378a298febd4657cf72cccac481e5b31e929ef160c71be45a32a02093be06d7d62403cc03f08bb48465c8a217cd3f808f406149fa4ba6a92a
data/README.md CHANGED
@@ -16,6 +16,7 @@ A powerful Rails engine for building, managing, and monitoring LLM-powered agent
16
16
  - **🎯 Type Safety** - Structured output with RubyLLM::Schema integration
17
17
  - **⚡ Real-time Streaming** - Stream LLM responses with time-to-first-token tracking
18
18
  - **📎 Attachments** - Send images, PDFs, and files to vision-capable models
19
+ - **📋 Rich Results** - Access token counts, costs, timing, and model info from every execution
19
20
  - **🔄 Reliability** - Automatic retries, model fallbacks, and circuit breakers for resilient agents
20
21
  - **💵 Budget Controls** - Daily/monthly spending limits with hard and soft enforcement
21
22
  - **🔔 Alerts** - Slack, webhook, and custom notifications for budget and circuit breaker events
@@ -271,6 +272,93 @@ VisionAgent.call(question: "test", with: "image.png", dry_run: true)
271
272
  # => { ..., attachments: "image.png", ... }
272
273
  ```
273
274
 
275
+ ### Execution Results
276
+
277
+ Every agent call returns a `Result` object with full execution metadata:
278
+
279
+ ```ruby
280
+ result = SearchAgent.call(query: "red dress")
281
+
282
+ # Access the processed response
283
+ result.content # => { refined_query: "red dress", ... }
284
+
285
+ # Token usage
286
+ result.input_tokens # => 150
287
+ result.output_tokens # => 50
288
+ result.total_tokens # => 200
289
+ result.cached_tokens # => 0
290
+
291
+ # Cost calculation
292
+ result.input_cost # => 0.000150
293
+ result.output_cost # => 0.000100
294
+ result.total_cost # => 0.000250
295
+
296
+ # Model info
297
+ result.model_id # => "gpt-4o"
298
+ result.chosen_model_id # => "gpt-4o" (may differ if fallback used)
299
+ result.temperature # => 0.0
300
+
301
+ # Timing
302
+ result.duration_ms # => 1234
303
+ result.started_at # => 2025-11-27 10:30:00 UTC
304
+ result.completed_at # => 2025-11-27 10:30:01 UTC
305
+ result.time_to_first_token_ms # => 245 (streaming only)
306
+
307
+ # Status
308
+ result.finish_reason # => "stop", "length", "tool_calls", etc.
309
+ result.streaming? # => false
310
+ result.success? # => true
311
+ result.truncated? # => false (true if hit max_tokens)
312
+
313
+ # Tool calls (for agents with tools)
314
+ result.tool_calls # => [{ "id" => "call_abc", "name" => "search", "arguments" => {...} }]
315
+ result.tool_calls_count # => 1
316
+ result.has_tool_calls? # => true
317
+
318
+ # Reliability info
319
+ result.attempts_count # => 1
320
+ result.used_fallback? # => false
321
+ ```
322
+
323
+ #### Backward Compatibility
324
+
325
+ The Result object delegates hash methods to content, so existing code continues to work:
326
+
327
+ ```ruby
328
+ # Old style (still works)
329
+ result[:refined_query]
330
+ result.dig(:nested, :key)
331
+
332
+ # New style (access metadata)
333
+ result.content[:refined_query]
334
+ result.total_cost
335
+ ```
336
+
337
+ #### Full Metadata Hash
338
+
339
+ ```ruby
340
+ result.to_h
341
+ # => {
342
+ # content: { refined_query: "red dress", ... },
343
+ # input_tokens: 150,
344
+ # output_tokens: 50,
345
+ # total_tokens: 200,
346
+ # cached_tokens: 0,
347
+ # input_cost: 0.000150,
348
+ # output_cost: 0.000100,
349
+ # total_cost: 0.000250,
350
+ # model_id: "gpt-4o",
351
+ # chosen_model_id: "gpt-4o",
352
+ # temperature: 0.0,
353
+ # duration_ms: 1234,
354
+ # finish_reason: "stop",
355
+ # streaming: false,
356
+ # tool_calls: [...],
357
+ # tool_calls_count: 0,
358
+ # ...
359
+ # }
360
+ ```
361
+
274
362
  ## Usage Guide
275
363
 
276
364
  ### Agent DSL
@@ -14,25 +14,89 @@ module RubyLLM
14
14
  # Renders the main dashboard view
15
15
  #
16
16
  # Loads now strip data, critical alerts, hourly activity,
17
- # and recent executions for real-time monitoring.
17
+ # recent executions, agent comparison, and top errors.
18
18
  #
19
19
  # @return [void]
20
20
  def index
21
- @now_strip = Execution.now_strip_data
21
+ @selected_range = params[:range].presence || "today"
22
+ @days = range_to_days(@selected_range)
23
+ @now_strip = Execution.now_strip_data(range: @selected_range)
22
24
  @critical_alerts = load_critical_alerts
23
25
  @hourly_activity = Execution.hourly_activity_chart
24
26
  @recent_executions = Execution.recent(10)
27
+ @agent_stats = build_agent_comparison
28
+ @top_errors = build_top_errors
25
29
  end
26
30
 
27
31
  # Returns chart data as JSON for live updates
28
32
  #
29
- # @return [JSON] Chart data with categories and series
33
+ # @param range [String] Time range: "today", "7d", or "30d"
34
+ # @return [JSON] Chart data with series
30
35
  def chart_data
31
- render json: Execution.hourly_activity_chart_json
36
+ range = params[:range].presence || "today"
37
+ render json: Execution.activity_chart_json(range: range)
32
38
  end
33
39
 
34
40
  private
35
41
 
42
+ # Converts range parameter to number of days
43
+ #
44
+ # @param range [String] Range parameter (today, 7d, 30d)
45
+ # @return [Integer] Number of days
46
+ def range_to_days(range)
47
+ case range
48
+ when "today" then 1
49
+ when "7d" then 7
50
+ when "30d" then 30
51
+ else 1
52
+ end
53
+ end
54
+
55
+ # Builds per-agent comparison statistics
56
+ #
57
+ # @return [Array<Hash>] Array of agent stats sorted by cost descending
58
+ def build_agent_comparison
59
+ scope = Execution.last_n_days(@days)
60
+ agent_types = scope.distinct.pluck(:agent_type)
61
+
62
+ agent_types.map do |agent_type|
63
+ agent_scope = scope.where(agent_type: agent_type)
64
+ count = agent_scope.count
65
+ total_cost = agent_scope.sum(:total_cost) || 0
66
+ successful = agent_scope.successful.count
67
+
68
+ {
69
+ agent_type: agent_type,
70
+ executions: count,
71
+ total_cost: total_cost,
72
+ avg_cost: count > 0 ? (total_cost / count).round(6) : 0,
73
+ avg_duration_ms: agent_scope.average(:duration_ms)&.round || 0,
74
+ success_rate: count > 0 ? (successful.to_f / count * 100).round(1) : 0
75
+ }
76
+ end.sort_by { |a| -(a[:total_cost] || 0) }
77
+ end
78
+
79
+ # Builds top errors list
80
+ #
81
+ # @return [Array<Hash>] Top 5 error classes with counts
82
+ def build_top_errors
83
+ scope = Execution.last_n_days(@days).where(status: "error")
84
+ total_errors = scope.count
85
+
86
+ scope.group(:error_class)
87
+ .select("error_class, COUNT(*) as count, MAX(created_at) as last_seen")
88
+ .order("count DESC")
89
+ .limit(5)
90
+ .map do |row|
91
+ {
92
+ error_class: row.error_class || "Unknown Error",
93
+ count: row.count,
94
+ percentage: total_errors > 0 ? (row.count.to_f / total_errors * 100).round(1) : 0,
95
+ last_seen: row.last_seen
96
+ }
97
+ end
98
+ end
99
+
36
100
  # Fetches cached daily statistics for the dashboard
37
101
  #
38
102
  # Results are cached for 1 minute to reduce database load while
@@ -167,35 +167,136 @@ module RubyLLM
167
167
  end
168
168
 
169
169
  # Returns chart data as arrays for Highcharts live updates
170
- # Format: { categories: [...], series: [...] }
170
+ # Format: { categories: [...], series: [...], range: ... }
171
+ #
172
+ # @param range [String] Time range: "today" (hourly), "7d" or "30d" (daily)
173
+ def activity_chart_json(range: "today")
174
+ case range
175
+ when "7d"
176
+ build_daily_chart_data(7)
177
+ when "30d"
178
+ build_daily_chart_data(30)
179
+ else
180
+ build_hourly_chart_data
181
+ end
182
+ end
183
+
184
+ # Alias for backwards compatibility
171
185
  def hourly_activity_chart_json
172
- # Always use current time as reference so chart shows "now" on the right
173
- reference_time = Time.current.beginning_of_hour
186
+ activity_chart_json(range: "today")
187
+ end
174
188
 
175
- categories = []
189
+ private
190
+
191
+ # Builds hourly chart data for last 24 hours
192
+ # Optimized: Single GROUP BY query instead of 72 individual queries
193
+ def build_hourly_chart_data
194
+ reference_time = Time.current.beginning_of_hour
195
+ start_time = reference_time - 23.hours
196
+
197
+ # Single query with GROUP BY - reduces 72 queries to 1
198
+ results = where(created_at: start_time..(reference_time + 1.hour))
199
+ .group(Arel.sql("DATE_TRUNC('hour', created_at)"))
200
+ .select(
201
+ Arel.sql("DATE_TRUNC('hour', created_at) as time_bucket"),
202
+ Arel.sql("COUNT(*) FILTER (WHERE status = 'success') as success_count"),
203
+ Arel.sql("COUNT(*) FILTER (WHERE status IN ('error', 'timeout')) as failed_count"),
204
+ Arel.sql("COALESCE(SUM(total_cost), 0) as total_cost")
205
+ )
206
+ .index_by { |r| r.time_bucket.to_time.beginning_of_hour }
207
+
208
+ # Build arrays for all 24 hours (fill missing with zeros)
176
209
  success_data = []
177
210
  failed_data = []
211
+ cost_data = []
212
+ total_success = 0
213
+ total_failed = 0
214
+ total_cost = 0.0
178
215
 
179
- # Create entries for the last 24 hours ending at current hour
180
216
  (23.downto(0)).each do |hours_ago|
181
- start_time = reference_time - hours_ago.hours
182
- end_time = start_time + 1.hour
183
- categories << start_time.in_time_zone.strftime("%H:%M")
217
+ bucket_time = (reference_time - hours_ago.hours).beginning_of_hour
218
+ row = results[bucket_time]
184
219
 
185
- hour_scope = where(created_at: start_time...end_time)
186
- success_data << hour_scope.successful.count
187
- failed_data << hour_scope.failed.count
220
+ s = row&.success_count.to_i
221
+ f = row&.failed_count.to_i
222
+ c = row&.total_cost.to_f
223
+
224
+ success_data << s
225
+ failed_data << f
226
+ cost_data << c.round(4)
227
+
228
+ total_success += s
229
+ total_failed += f
230
+ total_cost += c
188
231
  end
189
232
 
190
233
  {
191
- categories: categories,
234
+ range: "today",
235
+ totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
192
236
  series: [
193
237
  { name: "Success", data: success_data },
194
- { name: "Failed", data: failed_data }
238
+ { name: "Failed", data: failed_data },
239
+ { name: "Cost", data: cost_data }
195
240
  ]
196
241
  }
197
242
  end
198
243
 
244
+ # Builds daily chart data for specified number of days
245
+ # Optimized: Single GROUP BY query instead of 3*days individual queries
246
+ def build_daily_chart_data(days)
247
+ end_date = Date.current
248
+ start_date = (days - 1).days.ago.to_date
249
+
250
+ # Single query with GROUP BY - reduces 3*days queries to 1
251
+ results = where(created_at: start_date.beginning_of_day..end_date.end_of_day)
252
+ .group(Arel.sql("DATE_TRUNC('day', created_at)"))
253
+ .select(
254
+ Arel.sql("DATE_TRUNC('day', created_at) as time_bucket"),
255
+ Arel.sql("COUNT(*) FILTER (WHERE status = 'success') as success_count"),
256
+ Arel.sql("COUNT(*) FILTER (WHERE status IN ('error', 'timeout')) as failed_count"),
257
+ Arel.sql("COALESCE(SUM(total_cost), 0) as total_cost")
258
+ )
259
+ .index_by { |r| r.time_bucket.to_date }
260
+
261
+ # Build arrays for all days (fill missing with zeros)
262
+ success_data = []
263
+ failed_data = []
264
+ cost_data = []
265
+ total_success = 0
266
+ total_failed = 0
267
+ total_cost = 0.0
268
+
269
+ (days - 1).downto(0).each do |days_ago|
270
+ date = days_ago.days.ago.to_date
271
+ row = results[date]
272
+
273
+ s = row&.success_count.to_i
274
+ f = row&.failed_count.to_i
275
+ c = row&.total_cost.to_f
276
+
277
+ success_data << s
278
+ failed_data << f
279
+ cost_data << c.round(4)
280
+
281
+ total_success += s
282
+ total_failed += f
283
+ total_cost += c
284
+ end
285
+
286
+ {
287
+ range: "#{days}d",
288
+ days: days,
289
+ totals: { success: total_success, failed: total_failed, cost: total_cost.round(4) },
290
+ series: [
291
+ { name: "Success", data: success_data },
292
+ { name: "Failed", data: failed_data },
293
+ { name: "Cost", data: cost_data }
294
+ ]
295
+ }
296
+ end
297
+
298
+ public
299
+
199
300
  # Builds the hourly activity data structure
200
301
  # Shows the last 24 hours with current hour on the right
201
302
  #
@@ -256,6 +256,16 @@ module RubyLLM
256
256
  scope :content_filtered, -> { where(finish_reason: "content_filter") }
257
257
  scope :tool_calls, -> { where(finish_reason: "tool_calls") }
258
258
 
259
+ # @!method with_tool_calls
260
+ # Returns executions that made tool calls
261
+ # @return [ActiveRecord::Relation]
262
+
263
+ # @!method without_tool_calls
264
+ # Returns executions that did not make tool calls
265
+ # @return [ActiveRecord::Relation]
266
+ scope :with_tool_calls, -> { where("tool_calls_count > 0") }
267
+ scope :without_tool_calls, -> { where(tool_calls_count: 0) }
268
+
259
269
  # @!endgroup
260
270
  end
261
271
 
@@ -85,7 +85,6 @@ module RubyLLM
85
85
 
86
86
  before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? }
87
87
  before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? }
88
- after_commit :broadcast_turbo_streams, on: %i[create update]
89
88
 
90
89
  # Aggregates costs from all attempts using each attempt's model pricing
91
90
  #
@@ -219,79 +218,48 @@ module RubyLLM
219
218
  finish_reason == "content_filter"
220
219
  end
221
220
 
221
+ # Returns whether this execution made tool calls
222
+ #
223
+ # @return [Boolean] true if tool calls were made
224
+ def has_tool_calls?
225
+ tool_calls_count.to_i > 0
226
+ end
227
+
222
228
  # Returns real-time dashboard data for the Now Strip
223
229
  #
230
+ # @param range [String] Time range: "today", "7d", or "30d"
224
231
  # @return [Hash] Now strip metrics
225
- def self.now_strip_data
226
- today_scope = today
232
+ def self.now_strip_data(range: "today")
233
+ scope = case range
234
+ when "7d" then last_n_days(7)
235
+ when "30d" then last_n_days(30)
236
+ else today
237
+ end
238
+
227
239
  {
228
240
  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
241
+ success_today: scope.status_success.count,
242
+ errors_today: scope.status_error.count,
243
+ timeouts_today: scope.status_timeout.count,
244
+ cost_today: scope.sum(:total_cost) || 0,
245
+ executions_today: scope.count,
246
+ success_rate: calculate_period_success_rate(scope)
235
247
  }
236
248
  end
237
249
 
238
- # Calculates today's success rate
250
+ # Calculates success rate for a given scope
239
251
  #
252
+ # @param scope [ActiveRecord::Relation] The execution scope
240
253
  # @return [Float] Success rate as percentage
241
- def self.calculate_today_success_rate
242
- total = today.count
254
+ def self.calculate_period_success_rate(scope)
255
+ total = scope.count
243
256
  return 0.0 if total.zero?
244
257
 
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
255
- ActionCable.server.broadcast(
256
- "ruby_llm_agents:executions",
257
- {
258
- action: previously_new_record? ? "created" : "updated",
259
- id: id,
260
- status: status,
261
- html: render_execution_html,
262
- now_strip_html: render_now_strip_html
263
- }
264
- )
265
- rescue StandardError => e
266
- Rails.logger.error("[RubyLLM::Agents] Failed to broadcast execution: #{e.message}")
258
+ (scope.successful.count.to_f / total * 100).round(1)
267
259
  end
268
260
 
269
261
  private
270
262
 
271
- # Renders the execution item partial for broadcast
272
- #
273
- # @return [String, nil] HTML string or nil if rendering fails
274
- def render_execution_html
275
- ApplicationController.render(
276
- partial: "rubyllm/agents/dashboard/execution_item",
277
- locals: { execution: self }
278
- )
279
- rescue StandardError
280
- nil
281
- end
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
263
  # Calculates and sets total_tokens from input and output
296
264
  #
297
265
  # @return [Integer] The calculated total