ruby_llm-agents 3.11.0 → 3.13.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/agents_controller.rb +74 -0
  3. data/app/controllers/ruby_llm/agents/analytics_controller.rb +304 -0
  4. data/app/controllers/ruby_llm/agents/executions_controller.rb +5 -0
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
  6. data/app/models/ruby_llm/agents/agent_override.rb +47 -0
  7. data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
  8. data/app/models/ruby_llm/agents/execution.rb +51 -1
  9. data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  11. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +93 -4
  12. data/app/views/ruby_llm/agents/agents/show.html.erb +17 -2
  13. data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
  14. data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
  15. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
  17. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
  18. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
  19. data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +3 -2
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
  22. data/config/routes.rb +12 -4
  23. data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
  24. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
  25. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
  26. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
  27. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
  28. data/lib/ruby_llm/agents/base_agent.rb +90 -133
  29. data/lib/ruby_llm/agents/core/base.rb +9 -0
  30. data/lib/ruby_llm/agents/core/configuration.rb +93 -7
  31. data/lib/ruby_llm/agents/core/version.rb +1 -1
  32. data/lib/ruby_llm/agents/dsl/base.rb +131 -4
  33. data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
  34. data/lib/ruby_llm/agents/dsl.rb +1 -1
  35. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
  36. data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
  37. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  38. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  39. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  40. data/lib/ruby_llm/agents/rails/engine.rb +20 -4
  41. data/lib/ruby_llm/agents/routing.rb +28 -5
  42. data/lib/ruby_llm/agents/stream_event.rb +2 -10
  43. data/lib/ruby_llm/agents/tool.rb +1 -1
  44. data/lib/ruby_llm/agents.rb +1 -3
  45. data/lib/tasks/ruby_llm_agents.rake +7 -0
  46. metadata +9 -5
  47. data/lib/ruby_llm/agents/agent_tool.rb +0 -143
  48. data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
@@ -79,12 +79,24 @@ module RubyLLM
79
79
  foreign_key: :execution_id, dependent: :destroy
80
80
 
81
81
  # Delegations so existing code keeps working transparently
82
- delegate :system_prompt, :user_prompt, :assistant_prompt, :response, :error_message,
82
+ delegate :system_prompt, :user_prompt, :assistant_prompt, :response,
83
83
  :messages_summary, :tool_calls, :attempts, :fallback_chain,
84
84
  :parameters, :routed_to, :classification_result,
85
85
  :cached_at, :cache_creation_tokens,
86
86
  to: :detail, prefix: false, allow_nil: true
87
87
 
88
+ # Error message reader that survives soft purge.
89
+ #
90
+ # Prefers detail.error_message when the detail row still exists, otherwise
91
+ # falls back to the truncated copy stored in metadata by the retention
92
+ # job. This lets error-rate trend analysis continue working past the
93
+ # soft-purge window.
94
+ #
95
+ # @return [String, nil]
96
+ def error_message
97
+ detail&.error_message || metadata&.dig("error_message")
98
+ end
99
+
88
100
  # Validations
89
101
  validates :agent_type, :model_id, :started_at, presence: true
90
102
  validates :status, inclusion: {in: statuses.keys}
@@ -215,6 +227,44 @@ module RubyLLM
215
227
  metadata&.dig("rate_limited") == true
216
228
  end
217
229
 
230
+ # Returns the response payload as a Hash, regardless of how agents wrote it.
231
+ #
232
+ # The `execution_details.response` column is declared as JSON, but agents
233
+ # may write plain strings (chat text), arrays (embeddings), or nil. Views
234
+ # that want to look up specific keys (audio_url, image_url, etc.) need a
235
+ # Hash they can safely `.dig` into. This reader returns an empty hash when
236
+ # the stored response isn't a Hash, so callers don't need type guards.
237
+ #
238
+ # @return [Hash] Parsed response hash, or empty hash if not hash-shaped
239
+ def response_hash
240
+ raw = response
241
+ raw.is_a?(Hash) ? raw : {}
242
+ end
243
+
244
+ # Returns whether this execution has had its detail payload soft-purged.
245
+ #
246
+ # Soft-purged executions retain all analytics columns (cost, tokens,
247
+ # timing, status) but the large payloads (prompts, responses, tool
248
+ # calls, attempts) stored in execution_details and tool_executions
249
+ # have been destroyed by the retention job.
250
+ #
251
+ # @return [Boolean] true if the retention job has soft-purged this execution
252
+ def soft_purged?
253
+ metadata&.key?("soft_purged_at") == true
254
+ end
255
+
256
+ # Returns when this execution was soft-purged, if ever.
257
+ #
258
+ # @return [Time, nil] Parsed timestamp or nil if not soft-purged
259
+ def soft_purged_at
260
+ raw = metadata&.dig("soft_purged_at")
261
+ return nil if raw.blank?
262
+
263
+ Time.iso8601(raw)
264
+ rescue ArgumentError
265
+ nil
266
+ end
267
+
218
268
  # Convenience accessors for niche fields stored in metadata JSON
219
269
  %w[span_id response_cache_key fallback_reason].each do |field|
220
270
  define_method(field) { metadata&.dig(field) }
@@ -70,7 +70,14 @@ module RubyLLM
70
70
  }
71
71
 
72
72
  type = detect_agent_type(agent_class)
73
- base.merge(type_config_for(agent_class, type))
73
+ config = base.merge(type_config_for(agent_class, type))
74
+
75
+ # Include dashboard override metadata
76
+ config[:overridable_fields] = safe_call(agent_class, :overridable_fields) || []
77
+ config[:active_overrides] = safe_call(agent_class, :active_overrides) || {}
78
+ config[:has_overrides] = config[:active_overrides].any?
79
+
80
+ config
74
81
  end
75
82
 
76
83
  private
@@ -296,7 +296,8 @@
296
296
  [ruby_llm_agents.root_path, "dashboard"],
297
297
  [ruby_llm_agents.agents_path, "agents"],
298
298
  [ruby_llm_agents.executions_path, "executions"],
299
- [ruby_llm_agents.requests_path, "requests"]
299
+ [ruby_llm_agents.requests_path, "requests"],
300
+ [ruby_llm_agents.analytics_path, "analytics"]
300
301
  ]
301
302
  nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
302
303
  nav_items.each do |path, label| %>
@@ -347,7 +348,8 @@
347
348
  [ruby_llm_agents.root_path, "dashboard"],
348
349
  [ruby_llm_agents.agents_path, "agents"],
349
350
  [ruby_llm_agents.executions_path, "executions"],
350
- [ruby_llm_agents.requests_path, "requests"]
351
+ [ruby_llm_agents.requests_path, "requests"],
352
+ [ruby_llm_agents.analytics_path, "analytics"]
351
353
  ]
352
354
  mobile_nav_items << [ruby_llm_agents.tenants_path, "tenants"] if tenant_filter_enabled?
353
355
  mobile_nav_items.each do |path, label| %>
@@ -7,24 +7,44 @@
7
7
  has_total_timeout = config[:total_timeout].present?
8
8
  has_circuit_breaker = config[:circuit_breaker].present?
9
9
  has_any_reliability = has_retries || has_fallbacks || has_total_timeout || has_circuit_breaker
10
+
11
+ overridable = config[:overridable_fields] || []
12
+ overrides = config[:active_overrides] || {}
13
+ has_overrides = overrides.any?
10
14
  %>
11
15
 
12
- <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 font-mono text-xs">
16
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 font-mono text-xs" x-data="{ editing: false }">
13
17
  <!-- Basic -->
14
18
  <div>
15
- <span class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">basic</span>
16
- <div class="mt-1.5 space-y-0.5">
19
+ <div class="flex items-center gap-2">
20
+ <span class="text-[10px] text-gray-400 dark:text-gray-600 uppercase tracking-wider">basic</span>
21
+ <% if overridable.any? %>
22
+ <button @click="editing = !editing" class="text-[10px] text-gray-400 dark:text-gray-600 hover:text-gray-700 dark:hover:text-gray-300 transition-colors" x-text="editing ? 'cancel' : 'edit'"></button>
23
+ <% end %>
24
+ </div>
25
+
26
+ <%# Read-only view %>
27
+ <div class="mt-1.5 space-y-0.5" x-show="!editing">
17
28
  <div class="flex items-center gap-3 py-1">
18
29
  <span class="w-20 text-gray-400 dark:text-gray-600">model</span>
19
30
  <span class="text-gray-900 dark:text-gray-200"><%= config[:model] %></span>
31
+ <% if overrides["model"] %>
32
+ <span class="text-[10px] text-yellow-600 dark:text-yellow-500">overridden</span>
33
+ <% end %>
20
34
  </div>
21
35
  <div class="flex items-center gap-3 py-1">
22
36
  <span class="w-20 text-gray-400 dark:text-gray-600">temperature</span>
23
37
  <span class="text-gray-900 dark:text-gray-200"><%= config[:temperature] %></span>
38
+ <% if overrides["temperature"] %>
39
+ <span class="text-[10px] text-yellow-600 dark:text-yellow-500">overridden</span>
40
+ <% end %>
24
41
  </div>
25
42
  <div class="flex items-center gap-3 py-1">
26
43
  <span class="w-20 text-gray-400 dark:text-gray-600">timeout</span>
27
44
  <span class="text-gray-900 dark:text-gray-200"><%= config[:timeout] %>s</span>
45
+ <% if overrides["timeout"] %>
46
+ <span class="text-[10px] text-yellow-600 dark:text-yellow-500">overridden</span>
47
+ <% end %>
28
48
  </div>
29
49
  <div class="flex items-center gap-3 py-1">
30
50
  <span class="w-20 text-gray-400 dark:text-gray-600">cache</span>
@@ -36,6 +56,75 @@
36
56
  <% end %>
37
57
  </div>
38
58
  </div>
59
+
60
+ <%# Edit form (only shown when editing) %>
61
+ <% if overridable.any? %>
62
+ <div x-show="editing" x-cloak>
63
+ <%= form_tag ruby_llm_agents.agent_path(@agent_type), method: :patch, class: "mt-1.5 space-y-1.5" do %>
64
+ <% if overridable.include?(:model) %>
65
+ <div class="flex items-center gap-3 py-1">
66
+ <label class="w-20 text-gray-400 dark:text-gray-600" for="override_model">model</label>
67
+ <input type="text" name="override[model]" id="override_model"
68
+ value="<%= overrides["model"] || config[:model] %>"
69
+ class="bg-transparent border border-gray-300 dark:border-gray-700 rounded px-2 py-0.5 text-gray-900 dark:text-gray-200 text-xs font-mono w-48 focus:outline-none focus:border-gray-500 dark:focus:border-gray-400">
70
+ </div>
71
+ <% else %>
72
+ <div class="flex items-center gap-3 py-1">
73
+ <span class="w-20 text-gray-400 dark:text-gray-600">model</span>
74
+ <span class="text-gray-900 dark:text-gray-200"><%= config[:model] %></span>
75
+ <span class="text-[10px] text-gray-400 dark:text-gray-600">locked</span>
76
+ </div>
77
+ <% end %>
78
+
79
+ <% if overridable.include?(:temperature) %>
80
+ <div class="flex items-center gap-3 py-1">
81
+ <label class="w-20 text-gray-400 dark:text-gray-600" for="override_temperature">temperature</label>
82
+ <input type="number" name="override[temperature]" id="override_temperature"
83
+ value="<%= overrides["temperature"] || config[:temperature] %>"
84
+ step="0.1" min="0" max="2"
85
+ class="bg-transparent border border-gray-300 dark:border-gray-700 rounded px-2 py-0.5 text-gray-900 dark:text-gray-200 text-xs font-mono w-24 focus:outline-none focus:border-gray-500 dark:focus:border-gray-400">
86
+ </div>
87
+ <% else %>
88
+ <div class="flex items-center gap-3 py-1">
89
+ <span class="w-20 text-gray-400 dark:text-gray-600">temperature</span>
90
+ <span class="text-gray-900 dark:text-gray-200"><%= config[:temperature] %></span>
91
+ <span class="text-[10px] text-gray-400 dark:text-gray-600">locked</span>
92
+ </div>
93
+ <% end %>
94
+
95
+ <% if overridable.include?(:timeout) %>
96
+ <div class="flex items-center gap-3 py-1">
97
+ <label class="w-20 text-gray-400 dark:text-gray-600" for="override_timeout">timeout</label>
98
+ <input type="number" name="override[timeout]" id="override_timeout"
99
+ value="<%= overrides["timeout"] || config[:timeout] %>"
100
+ min="1" max="600"
101
+ class="bg-transparent border border-gray-300 dark:border-gray-700 rounded px-2 py-0.5 text-gray-900 dark:text-gray-200 text-xs font-mono w-24 focus:outline-none focus:border-gray-500 dark:focus:border-gray-400">
102
+ <span class="text-gray-400 dark:text-gray-600">s</span>
103
+ </div>
104
+ <% else %>
105
+ <div class="flex items-center gap-3 py-1">
106
+ <span class="w-20 text-gray-400 dark:text-gray-600">timeout</span>
107
+ <span class="text-gray-900 dark:text-gray-200"><%= config[:timeout] %>s</span>
108
+ <span class="text-[10px] text-gray-400 dark:text-gray-600">locked</span>
109
+ </div>
110
+ <% end %>
111
+
112
+ <div class="flex items-center gap-2 pt-2">
113
+ <button type="submit" class="text-[10px] px-2 py-0.5 rounded bg-gray-800 dark:bg-gray-200 text-white dark:text-gray-900 hover:bg-gray-700 dark:hover:bg-gray-300 transition-colors">save overrides</button>
114
+ </div>
115
+ <% end %>
116
+
117
+ <% if has_overrides %>
118
+ <div class="mt-1.5">
119
+ <%= button_to "reset all", ruby_llm_agents.reset_overrides_agent_path(@agent_type),
120
+ method: :delete,
121
+ class: "text-[10px] text-red-500 hover:text-red-400 transition-colors",
122
+ form: { style: "display:inline" },
123
+ data: { turbo_confirm: "Remove all dashboard overrides for this agent?" } %>
124
+ </div>
125
+ <% end %>
126
+ </div>
127
+ <% end %>
39
128
  </div>
40
129
 
41
130
  <!-- Reliability -->
@@ -56,7 +145,7 @@
56
145
  <span class="w-1.5 h-1.5 rounded-full flex-shrink-0 <%= has_fallbacks ? 'bg-green-500' : 'bg-gray-400' %>"></span>
57
146
  <span class="w-16 text-gray-400 dark:text-gray-600">fallbacks</span>
58
147
  <% if has_fallbacks %>
59
- <span class="text-gray-900 dark:text-gray-200 truncate"><%= fallback_models.join(" ") %></span>
148
+ <span class="text-gray-900 dark:text-gray-200 truncate"><%= fallback_models.join(" &rarr; ") %></span>
60
149
  <% else %>
61
150
  <span class="text-gray-400 dark:text-gray-600">&mdash;</span>
62
151
  <% end %>
@@ -3,6 +3,19 @@
3
3
  <%= link_to "← agents", ruby_llm_agents.agents_path(subtab: params[:subtab]), class: "text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" %>
4
4
  </nav>
5
5
 
6
+ <%# Override banner %>
7
+ <% if @config && @config[:has_overrides] %>
8
+ <div class="flex items-center gap-2 px-3 py-1.5 mb-4 rounded border border-yellow-300 dark:border-yellow-700 bg-yellow-50 dark:bg-yellow-900/20 font-mono text-xs text-yellow-700 dark:text-yellow-400">
9
+ <svg class="w-3.5 h-3.5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>
10
+ <span>dashboard overrides active: <strong><%= @config[:active_overrides].keys.join(", ") %></strong></span>
11
+ <%= button_to "reset", ruby_llm_agents.reset_overrides_agent_path(@agent_type),
12
+ method: :delete,
13
+ class: "ml-auto text-yellow-600 dark:text-yellow-500 hover:text-yellow-800 dark:hover:text-yellow-300",
14
+ form: { style: "display:inline" },
15
+ data: { turbo_confirm: "Remove all dashboard overrides?" } %>
16
+ </div>
17
+ <% end %>
18
+
6
19
  <!-- ── agent header ──────────────── -->
7
20
  <div class="flex items-start justify-between gap-8 mb-2">
8
21
  <!-- Left: identity -->
@@ -67,6 +80,8 @@
67
80
  <span><span class="text-gray-800 dark:text-gray-200">$<%= number_with_precision(@stats[:total_cost] || 0, precision: 4) %></span> cost</span>
68
81
  </div>
69
82
  <div class="flex items-center justify-end gap-x-4 text-gray-500 dark:text-gray-600">
83
+ <span><span class="text-gray-600 dark:text-gray-400">$<%= number_with_precision(@stats[:avg_cost] || 0, precision: 4) %></span> avg cost</span>
84
+ <span><span class="text-gray-600 dark:text-gray-400"><%= number_with_delimiter(@stats[:avg_tokens] || 0) %></span> avg tokens</span>
70
85
  <span><span class="text-gray-600 dark:text-gray-400"><%= number_with_delimiter(@stats[:total_tokens] || 0) %></span> tokens</span>
71
86
  <span><span class="text-gray-600 dark:text-gray-400"><%= number_with_delimiter(@stats[:avg_duration_ms] || 0) %></span>ms avg</span>
72
87
  <% if (@config && @config[:cache_enabled]) || @cache_hit_rate > 0 %>
@@ -279,8 +294,8 @@
279
294
  <% end %>
280
295
  <% end %>
281
296
 
282
- <!-- ── config (non-agent types only) ── -->
283
- <% if @config && @agent_type_kind && @agent_type_kind != "agent" %>
297
+ <!-- ── config ── -->
298
+ <% if @config && @agent_type_kind %>
284
299
  <div class="flex items-center gap-3 mt-6 mb-3">
285
300
  <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">config</span>
286
301
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>