ruby_llm-agents 3.12.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e29829dcedb6a67dd1d9e55c7a45ead560e5da2fc0f75ffd04c97026e8d5b90
4
- data.tar.gz: 9c2981a6eed9459f2c7d56c47f6ff5ddcdd282bc90729ace04b8eca4f83e9e55
3
+ metadata.gz: 5fc64ee9b7db541e40c144d3b8007c0967f7ad63a7b5c404079d7c4c3398a646
4
+ data.tar.gz: 1c5b0d4c8c55390cfb5bee3ad394b47f4cae44dbd0452c5135c6ad7e7ac66a0b
5
5
  SHA512:
6
- metadata.gz: 86ed72b1e799b64259b83588ac9c9111d04539eb7225e65d517332d22ed671436214255ec0127fdcff4ef65fe283683219e88b67a956c6270a7b6a0140937a27
7
- data.tar.gz: b532f5a4a47814ed19a9929b06e506b56af651d8d1be65348c68b61aa78dc48f771c89c8d667dac06860d88283fee3a890b8d39afc4d11f5e6905ceca396c136
6
+ metadata.gz: 9c187e97882d3b4cf91dc98db12004d809db61a04bf95463aa32f1fa64b53a54d7f0800dfe105e0e6c5bf541b79bcdbe8f3d7537767b9c0d9814095f8fe6889d
7
+ data.tar.gz: 63b31bfcfcd27acaded706b78cfa96db5825e98ebd6f4a7fe4b2786ed0f15d048c706fdd157c4364618388816ab739a8c0dac34c773bcfaf0f67d9dc90397766
@@ -203,6 +203,11 @@ module RubyLLM
203
203
  model_ids = parse_array_param(:model_ids)
204
204
  scope = scope.where(model_id: model_ids) if model_ids.any?
205
205
 
206
+ # Apply tenant filter (multi-select). Narrows further within any
207
+ # auto-scope already applied by tenant_scoped_executions.
208
+ tenant_ids = parse_array_param(:tenant_ids)
209
+ scope = scope.where(tenant_id: tenant_ids) if tenant_ids.any?
210
+
206
211
  # Apply retries filter (show only executions with multiple attempts)
207
212
  scope = scope.where("attempts_count > 1") if params[:has_retries].present?
208
213
 
@@ -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) }
@@ -111,12 +111,16 @@
111
111
 
112
112
  <div class="flex items-center gap-2 pt-2">
113
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
- <% if has_overrides %>
115
- <%= link_to "reset all", ruby_llm_agents.reset_overrides_agent_path(@agent_type),
116
- method: :delete,
117
- class: "text-[10px] text-red-500 hover:text-red-400 transition-colors",
118
- data: { confirm: "Remove all dashboard overrides for this agent?" } %>
119
- <% end %>
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?" } %>
120
124
  </div>
121
125
  <% end %>
122
126
  </div>
@@ -8,10 +8,11 @@
8
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
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
10
  <span>dashboard overrides active: <strong><%= @config[:active_overrides].keys.join(", ") %></strong></span>
11
- <%= link_to "reset", ruby_llm_agents.reset_overrides_agent_path(@agent_type),
11
+ <%= button_to "reset", ruby_llm_agents.reset_overrides_agent_path(@agent_type),
12
12
  method: :delete,
13
13
  class: "ml-auto text-yellow-600 dark:text-yellow-500 hover:text-yellow-800 dark:hover:text-yellow-300",
14
- data: { confirm: "Remove all dashboard overrides?" } %>
14
+ form: { style: "display:inline" },
15
+ data: { turbo_confirm: "Remove all dashboard overrides?" } %>
15
16
  </div>
16
17
  <% end %>
17
18
 
@@ -293,8 +294,8 @@
293
294
  <% end %>
294
295
  <% end %>
295
296
 
296
- <!-- ── config (non-agent types only) ── -->
297
- <% if @config && @agent_type_kind && @agent_type_kind != "agent" %>
297
+ <!-- ── config ── -->
298
+ <% if @config && @agent_type_kind %>
298
299
  <div class="flex items-center gap-3 mt-6 mb-3">
299
300
  <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">config</span>
300
301
  <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
@@ -1,5 +1,5 @@
1
1
  <%
2
- response = @execution.response || {}
2
+ response = @execution.response_hash
3
3
  audio_src = response["audio_url"] || response[:audio_url] ||
4
4
  response["audio_data_uri"] || response[:audio_data_uri]
5
5
  audio_format = response["format"] || response[:format] ||
@@ -3,14 +3,15 @@
3
3
  selected_agents = params[:agent_types].present? ? (params[:agent_types].is_a?(Array) ? params[:agent_types] : params[:agent_types].split(",")) : []
4
4
  selected_statuses = params[:statuses].present? ? (params[:statuses].is_a?(Array) ? params[:statuses] : params[:statuses].split(",")) : []
5
5
  selected_models = params[:model_ids].present? ? (params[:model_ids].is_a?(Array) ? params[:model_ids] : params[:model_ids].split(",")) : []
6
+ selected_tenants = params[:tenant_ids].present? ? (params[:tenant_ids].is_a?(Array) ? params[:tenant_ids] : params[:tenant_ids].split(",")) : []
6
7
 
7
- has_filters = selected_agents.any? || selected_statuses.any? || params[:days].present? || selected_models.any? || params[:tenant_id].present?
8
+ has_filters = selected_agents.any? || selected_statuses.any? || params[:days].present? || selected_models.any? || selected_tenants.any?
8
9
  active_filter_count = [
9
10
  selected_agents.any? ? 1 : 0,
10
11
  selected_statuses.any? ? 1 : 0,
11
12
  params[:days].present? ? 1 : 0,
12
13
  selected_models.any? ? 1 : 0,
13
- params[:tenant_id].present? ? 1 : 0
14
+ selected_tenants.any? ? 1 : 0
14
15
  ].sum
15
16
 
16
17
  status_options = [
@@ -89,11 +90,14 @@
89
90
  <%# Tenant Filter %>
90
91
  <% if tenant_filter_enabled? && available_tenants.any? %>
91
92
  <div class="md:w-auto">
92
- <%= render "ruby_llm/agents/shared/select_dropdown",
93
- name: "tenant_id",
94
- filter_id: "tenant_id",
95
- options: [{ value: "", label: "All Tenants" }] + available_tenants.map { |t| { value: t, label: t.truncate(15) } },
96
- selected: params[:tenant_id],
93
+ <%= render "ruby_llm/agents/shared/filter_dropdown",
94
+ name: "tenant_ids[]",
95
+ filter_id: "tenant_ids",
96
+ label: "Tenants",
97
+ all_label: "All Tenants",
98
+ options: available_tenants,
99
+ selected: selected_tenants,
100
+ searchable: true,
97
101
  icon: "M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4",
98
102
  width: "w-40",
99
103
  full_width: true %>
@@ -173,7 +177,7 @@
173
177
  <span class="md:hidden">Clear</span>
174
178
  <% end %>
175
179
  <% end %>
176
- <%= link_to ruby_llm_agents.export_executions_path(agent_types: selected_agents.presence, statuses: selected_statuses.presence, days: params[:days].presence, model_ids: selected_models.presence, tenant_id: params[:tenant_id].presence),
180
+ <%= link_to ruby_llm_agents.export_executions_path(agent_types: selected_agents.presence, statuses: selected_statuses.presence, days: params[:days].presence, model_ids: selected_models.presence, tenant_ids: selected_tenants.presence),
177
181
  class: "flex-1 md:flex-initial flex items-center justify-center gap-2 px-3 py-2 md:p-2 text-sm md:text-base text-gray-600 md:text-gray-400 dark:text-gray-300 md:dark:text-gray-400 bg-gray-50 md:bg-transparent dark:bg-gray-700 md:dark:bg-transparent hover:text-gray-600 md:hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-600 md:dark:hover:bg-gray-700 rounded-lg transition-colors",
178
182
  title: "Export CSV" do %>
179
183
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -57,6 +57,16 @@
57
57
  </div>
58
58
  <% end %>
59
59
 
60
+ <!-- Soft-purge banner -->
61
+ <% if @execution.soft_purged? %>
62
+ <div class="flex items-start gap-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded px-3 py-2 mb-2">
63
+ <span class="text-amber-600 dark:text-amber-400 text-xs font-mono leading-5">
64
+ &#9888; Execution details were purged<% if @execution.soft_purged_at %> on <%= @execution.soft_purged_at.strftime("%b %d, %Y") %><% end %>.
65
+ Cost, token, and timing analytics are preserved.
66
+ </span>
67
+ </div>
68
+ <% end %>
69
+
60
70
  <!-- Stats inline row -->
61
71
  <div class="flex flex-wrap items-center gap-x-4 gap-y-1 font-mono text-xs text-gray-400 dark:text-gray-500 mb-2">
62
72
  <span><span class="text-gray-800 dark:text-gray-200"><%= number_to_human_short(@execution.duration_ms || 0) %>ms</span> duration</span>
@@ -70,8 +80,8 @@
70
80
  <!-- ── audio player ──────────────────── -->
71
81
  <% if @execution.agent_type.to_s.match?(/Speaker|Narrator|Transcriber/i) ||
72
82
  @execution.metadata&.dig("audio_duration_seconds").present? ||
73
- @execution.response&.dig("audio_data_uri").present? ||
74
- @execution.response&.dig("audio_url").present? %>
83
+ @execution.response_hash["audio_data_uri"].present? ||
84
+ @execution.response_hash["audio_url"].present? %>
75
85
  <%= render "ruby_llm/agents/executions/audio_player" %>
76
86
  <% end %>
77
87
 
@@ -254,16 +264,18 @@
254
264
  <% end %>
255
265
 
256
266
  <!-- ── parameters ──────────────────── -->
257
- <div class="flex items-center gap-3 mt-6 mb-3">
258
- <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">parameters</span>
259
- <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
260
- <button
261
- type="button"
262
- data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.parameters || {})) %>"
263
- class="copy-json-btn font-mono text-xs text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
264
- >copy</button>
265
- </div>
266
- <pre class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded p-4 text-xs overflow-x-auto font-mono"><%= highlight_json(@execution.parameters || {}) %></pre>
267
+ <% if @execution.detail && @execution.parameters.present? %>
268
+ <div class="flex items-center gap-3 mt-6 mb-3">
269
+ <span class="text-[10px] font-medium text-gray-400 dark:text-gray-600 uppercase tracking-widest font-mono">parameters</span>
270
+ <div class="flex-1 border-t border-gray-200 dark:border-gray-800"></div>
271
+ <button
272
+ type="button"
273
+ data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.parameters)) %>"
274
+ class="copy-json-btn font-mono text-xs text-gray-400 dark:text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
275
+ >copy</button>
276
+ </div>
277
+ <pre class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded p-4 text-xs overflow-x-auto font-mono"><%= highlight_json(@execution.parameters) %></pre>
278
+ <% end %>
267
279
 
268
280
  <!-- ── response ──────────────────────── -->
269
281
  <% if @execution.response.present? %>
@@ -280,6 +292,7 @@
280
292
  <% end %>
281
293
 
282
294
  <!-- ── tool calls ──────────────────── -->
295
+ <% if @execution.detail %>
283
296
  <% tool_calls = @execution.tool_calls || [] %>
284
297
  <% tool_call_count = tool_calls.size %>
285
298
  <div x-data="{ expanded: <%= tool_call_count <= 3 && tool_call_count > 0 %> }">
@@ -374,6 +387,7 @@
374
387
  <p class="text-xs text-gray-400 dark:text-gray-600 font-mono italic">no tool calls</p>
375
388
  <% end %>
376
389
  </div>
390
+ <% end %>
377
391
 
378
392
  <!-- ── metadata ──────────────────────── -->
379
393
  <% if @execution.metadata.present? && @execution.metadata.any? %>
@@ -10,11 +10,13 @@
10
10
  # { value: "success", label: "Success", color: "bg-green-500" },
11
11
  # { value: "error", label: "Error", color: "bg-red-500" }
12
12
  # ],
13
- # selected: ["success"]
13
+ # selected: ["success"],
14
+ # searchable: true # renders a client-side search input above the options
14
15
 
15
16
  selected = local_assigns[:selected] || []
16
17
  show_all = local_assigns.fetch(:show_all_option, true)
17
18
  all_label = local_assigns[:all_label] || "All"
19
+ searchable = local_assigns.fetch(:searchable, false)
18
20
 
19
21
  current_label = if selected.empty?
20
22
  label
@@ -32,8 +34,31 @@
32
34
 
33
35
  has_selection = selected.any?
34
36
  %>
35
- <div class="relative" x-data="{ open: false }" @click.outside="open = false" data-filter="<%= filter_id %>">
36
- <button type="button" @click="open = !open"
37
+ <%#
38
+ Multi-select behavior:
39
+ - Checkbox click: toggle that option, mark dirty, keep dropdown open.
40
+ - Label click: clear other selections, check only this one, close, submit.
41
+ - Close dropdown (outside click, button toggle, Escape): if dirty, submit.
42
+ This batches checkbox picks into a single request when the user closes
43
+ the dropdown — no reliance on persisting open state across reload.
44
+ %>
45
+ <div class="relative"
46
+ x-data="{
47
+ open: false,
48
+ query: '',
49
+ dirty: false,
50
+ closeAndApply() {
51
+ this.open = false;
52
+ if (this.dirty) {
53
+ this.dirty = false;
54
+ this.$el.closest('form').requestSubmit();
55
+ }
56
+ }
57
+ }"
58
+ @click.outside="closeAndApply()"
59
+ @keydown.escape.window="if (open) closeAndApply()"
60
+ data-filter="<%= filter_id %>">
61
+ <button type="button" @click="open ? closeAndApply() : open = true"
37
62
  class="flex items-center gap-1.5 px-2 py-1 font-mono text-xs rounded
38
63
  hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors
39
64
  <%= has_selection ? 'text-gray-900 dark:text-gray-100' : 'text-gray-400 dark:text-gray-500' %>">
@@ -50,7 +75,19 @@
50
75
  x-transition:enter-start="opacity-0 scale-95" x-transition:enter-end="opacity-100 scale-100"
51
76
  x-transition:leave="transition ease-in duration-75"
52
77
  x-transition:leave-start="opacity-100 scale-100" x-transition:leave-end="opacity-0 scale-95"
53
- class="absolute z-20 mt-1 min-w-max bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 max-h-64 overflow-y-auto">
78
+ class="absolute z-20 mt-1 min-w-max w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1">
79
+
80
+ <% if searchable %>
81
+ <div class="px-2 py-1.5 border-b border-gray-100 dark:border-gray-700 sticky top-0 bg-white dark:bg-gray-800">
82
+ <input type="text" x-model="query" @click.stop
83
+ placeholder="Search…"
84
+ x-ref="<%= filter_id %>_search"
85
+ x-effect="if (open) $nextTick(() => $refs['<%= filter_id %>_search']?.focus())"
86
+ class="w-full px-2 py-1 text-xs font-mono rounded border border-gray-200 dark:border-gray-700 bg-transparent text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500">
87
+ </div>
88
+ <% end %>
89
+
90
+ <div class="max-h-56 overflow-y-auto">
54
91
 
55
92
  <% if show_all %>
56
93
  <div class="px-3 py-1.5 border-b border-gray-100 dark:border-gray-700">
@@ -60,7 +97,7 @@
60
97
  <%= selected.empty? ? 'checked' : '' %>
61
98
  @change="
62
99
  $el.closest('[data-filter]').querySelectorAll('.filter-cb').forEach(c => c.checked = false);
63
- $el.closest('form').requestSubmit();
100
+ dirty = true;
64
101
  ">
65
102
  <span class="text-gray-700 dark:text-gray-200"><%= all_label %></span>
66
103
  </label>
@@ -68,13 +105,14 @@
68
105
  <% end %>
69
106
 
70
107
  <% options.each do |option| %>
71
- <div class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700">
108
+ <div class="flex items-center gap-2 px-3 py-1.5 font-mono text-xs text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700"
109
+ <% if searchable %>x-show="query === '' || '<%= j(option[:label].to_s.downcase) %>'.includes(query.toLowerCase())"<% end %>>
72
110
  <input type="checkbox"
73
111
  name="<%= name %>"
74
112
  value="<%= option[:value] %>"
75
113
  class="filter-cb rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-700"
76
114
  <%= selected.include?(option[:value].to_s) ? 'checked' : '' %>
77
- @change="$el.closest('form').requestSubmit()">
115
+ @change="dirty = true">
78
116
  <span class="flex-1 flex items-center gap-1.5 cursor-pointer"
79
117
  @click.prevent="
80
118
  $el.closest('[data-filter]').querySelectorAll('.filter-cb').forEach(c => c.checked = false);
@@ -89,5 +127,6 @@
89
127
  </span>
90
128
  </div>
91
129
  <% end %>
130
+ </div>
92
131
  </div>
93
132
  </div>
@@ -6,8 +6,8 @@
6
6
  class="bg-transparent border border-gray-200 dark:border-gray-800 rounded px-2 py-0.5 text-xs font-mono text-gray-900 dark:text-gray-100 focus:ring-1 focus:ring-gray-400 dark:focus:ring-gray-600 focus:border-gray-400 dark:focus:border-gray-600">
7
7
  <option value="">all tenants</option>
8
8
  <% available_tenants.each do |tenant| %>
9
- <option value="<%= tenant %>" <%= 'selected' if tenant == current_tenant_id %>>
10
- <%= tenant %>
9
+ <option value="<%= tenant[:value] %>" <%= 'selected' if tenant[:value] == current_tenant_id %>>
10
+ <%= tenant[:label] %>
11
11
  </option>
12
12
  <% end %>
13
13
  </select>
@@ -51,8 +51,12 @@
51
51
  <span class="badge badge-sm <%= @config.async_logging ? 'badge-success' : 'badge-timeout' %>"><%= @config.async_logging ? 'on' : 'off' %></span>
52
52
  </div>
53
53
  <div class="flex items-center gap-3 py-0.5">
54
- <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">retention</span>
55
- <span class="text-gray-900 dark:text-gray-200"><%= @config.retention_period.inspect %></span>
54
+ <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">soft purge after</span>
55
+ <span class="text-gray-900 dark:text-gray-200"><%= @config.soft_purge_after ? @config.soft_purge_after.inspect : "disabled" %></span>
56
+ </div>
57
+ <div class="flex items-center gap-3 py-0.5">
58
+ <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">hard purge after</span>
59
+ <span class="text-gray-900 dark:text-gray-200"><%= @config.hard_purge_after ? @config.hard_purge_after.inspect : "disabled" %></span>
56
60
  </div>
57
61
  <div class="flex items-center gap-3 py-0.5">
58
62
  <span class="w-36 flex-shrink-0 text-gray-500 dark:text-gray-400">job retries</span>
@@ -67,7 +67,33 @@ RubyLLM::Agents.configure do |config|
67
67
  # Number of retry attempts for the async logging job on failure
68
68
  # config.job_retry_attempts = 3
69
69
 
70
- # Retention period for execution records (used by cleanup tasks)
70
+ # ============================================
71
+ # Data Retention
72
+ # ============================================
73
+ #
74
+ # Two-tier purge of execution records. Run the retention job on a schedule
75
+ # (e.g. daily via cron, sidekiq-cron, or the `whenever` gem):
76
+ #
77
+ # RubyLLM::Agents::RetentionJob.perform_later
78
+ # # or: rake ruby_llm_agents:purge
79
+ #
80
+ # Soft purge: deletes prompts, responses, tool calls, attempts, and other
81
+ # large payloads in execution_details and tool_executions. The executions
82
+ # row is preserved so cost, token, and latency analytics stay intact. A
83
+ # truncated copy of error_message is kept in executions.metadata.
84
+ #
85
+ # Hard purge: deletes the executions row entirely (cascades remove any
86
+ # remaining dependents). Use a longer window — this removes the execution
87
+ # from analytics.
88
+ #
89
+ # Set either to nil to disable that tier. soft_purge_after must be less
90
+ # than hard_purge_after when both are set.
91
+ #
92
+ # config.soft_purge_after = 30.days
93
+ # config.hard_purge_after = 365.days
94
+
95
+ # Deprecated: retention_period is an alias for hard_purge_after. Prefer the
96
+ # two-tier settings above.
71
97
  # config.retention_period = 30.days
72
98
 
73
99
  # ============================================
@@ -50,9 +50,26 @@ module RubyLLM
50
50
  # When false, executions are logged synchronously.
51
51
  # @return [Boolean] Enable async logging (default: true)
52
52
 
53
+ # @!attribute [rw] soft_purge_after
54
+ # How long to keep full execution details (prompts, responses, tool calls,
55
+ # attempts) before the retention job destroys them. The executions row is
56
+ # preserved so cost, token, and latency analytics remain intact. A
57
+ # truncated copy of the error message is stamped into metadata for
58
+ # long-term error-rate trend analysis.
59
+ # Set to nil to disable soft purging.
60
+ # @return [ActiveSupport::Duration, nil] Soft-purge window (default: 30.days)
61
+
62
+ # @!attribute [rw] hard_purge_after
63
+ # How long to keep the executions row itself before the retention job
64
+ # destroys it entirely. Must be greater than soft_purge_after when both
65
+ # are set. Set to nil to retain executions indefinitely.
66
+ # @return [ActiveSupport::Duration, nil] Hard-purge window (default: 365.days)
67
+
53
68
  # @!attribute [rw] retention_period
54
- # How long to retain execution records before cleanup.
55
- # @return [ActiveSupport::Duration] Retention period (default: 30.days)
69
+ # Deprecated. Alias for hard_purge_after, kept for backward compatibility.
70
+ # Prefer configuring soft_purge_after and hard_purge_after explicitly.
71
+ # @return [ActiveSupport::Duration, nil] Hard-purge window
72
+ # @deprecated Use {#hard_purge_after} instead.
56
73
 
57
74
  # @!attribute [rw] anomaly_cost_threshold
58
75
  # Cost threshold in dollars that triggers anomaly logging.
@@ -379,7 +396,6 @@ module RubyLLM
379
396
  # Attributes without validation (simple accessors)
380
397
  attr_accessor :default_model,
381
398
  :async_logging,
382
- :retention_period,
383
399
  :dashboard_parent_controller,
384
400
  :basic_auth_username,
385
401
  :basic_auth_password,
@@ -464,7 +480,9 @@ module RubyLLM
464
480
  :tenant_resolver,
465
481
  :tenant_config_resolver,
466
482
  :default_retries,
467
- :budgets
483
+ :budgets,
484
+ :soft_purge_after,
485
+ :hard_purge_after
468
486
 
469
487
  attr_writer :cache_store
470
488
 
@@ -594,6 +612,44 @@ module RubyLLM
594
612
  @default_embedding_batch_size = value
595
613
  end
596
614
 
615
+ # Sets soft_purge_after with validation
616
+ #
617
+ # @param value [ActiveSupport::Duration, Numeric, nil] Window or nil to disable
618
+ # @raise [ArgumentError] If value is not a Duration/Numeric or nil, or is negative
619
+ def soft_purge_after=(value)
620
+ validate_purge_window!(:soft_purge_after, value)
621
+ @soft_purge_after = value
622
+ validate_purge_ordering!
623
+ end
624
+
625
+ # Sets hard_purge_after with validation
626
+ #
627
+ # @param value [ActiveSupport::Duration, Numeric, nil] Window or nil to disable
628
+ # @raise [ArgumentError] If value is not a Duration/Numeric or nil, or is negative
629
+ def hard_purge_after=(value)
630
+ validate_purge_window!(:hard_purge_after, value)
631
+ @hard_purge_after = value
632
+ validate_purge_ordering!
633
+ end
634
+
635
+ # Deprecated alias for hard_purge_after.
636
+ #
637
+ # @return [ActiveSupport::Duration, nil]
638
+ # @deprecated Use {#hard_purge_after} instead.
639
+ def retention_period
640
+ hard_purge_after
641
+ end
642
+
643
+ # Deprecated setter for retention_period (maps to hard_purge_after).
644
+ #
645
+ # @param value [ActiveSupport::Duration, Numeric, nil]
646
+ # @deprecated Use {#hard_purge_after=} instead.
647
+ def retention_period=(value)
648
+ warn "[DEPRECATION] RubyLLM::Agents config.retention_period is deprecated. " \
649
+ "Use config.hard_purge_after instead (and set config.soft_purge_after for two-tier retention)."
650
+ self.hard_purge_after = value
651
+ end
652
+
597
653
  # Sets default_embedding_dimensions with validation
598
654
  #
599
655
  # @param value [Integer, nil] Dimensions (must be nil or > 0)
@@ -616,7 +672,8 @@ module RubyLLM
616
672
  @default_timeout = 60
617
673
  @cache_store = nil
618
674
  @async_logging = true
619
- @retention_period = 30.days
675
+ @soft_purge_after = 30.days
676
+ @hard_purge_after = 365.days
620
677
  @anomaly_cost_threshold = 5.00
621
678
  @anomaly_duration_threshold = 10_000
622
679
  @dashboard_auth = ->(_controller) { true }
@@ -960,7 +1017,8 @@ module RubyLLM
960
1017
  },
961
1018
  logging: {
962
1019
  async_logging: async_logging,
963
- retention_period: retention_period,
1020
+ soft_purge_after: soft_purge_after,
1021
+ hard_purge_after: hard_purge_after,
964
1022
  job_retry_attempts: job_retry_attempts,
965
1023
  track_executions: track_executions,
966
1024
  track_cache_hits: track_cache_hits,
@@ -1161,6 +1219,30 @@ module RubyLLM
1161
1219
  raise ArgumentError, "budgets[:enforcement] must be :none, :soft, or :hard"
1162
1220
  end
1163
1221
  end
1222
+
1223
+ # Validates a purge-window value (Duration, Numeric seconds, or nil).
1224
+ #
1225
+ # @param attr [Symbol] Attribute name for error messages
1226
+ # @param value [ActiveSupport::Duration, Numeric, nil] Value to validate
1227
+ # @raise [ArgumentError] If value is neither nil nor a non-negative duration/number
1228
+ def validate_purge_window!(attr, value)
1229
+ return if value.nil?
1230
+ return if value.is_a?(ActiveSupport::Duration) && value.to_i >= 0
1231
+ return if value.is_a?(Numeric) && value >= 0
1232
+
1233
+ raise ArgumentError, "#{attr} must be an ActiveSupport::Duration, non-negative Numeric, or nil"
1234
+ end
1235
+
1236
+ # Ensures soft_purge_after is strictly less than hard_purge_after when both are set.
1237
+ #
1238
+ # @raise [ArgumentError] If ordering is violated
1239
+ def validate_purge_ordering!
1240
+ return if @soft_purge_after.nil? || @hard_purge_after.nil?
1241
+ return if @soft_purge_after.to_i < @hard_purge_after.to_i
1242
+
1243
+ raise ArgumentError, "soft_purge_after (#{@soft_purge_after.inspect}) must be less than " \
1244
+ "hard_purge_after (#{@hard_purge_after.inspect})"
1245
+ end
1164
1246
  end
1165
1247
  end
1166
1248
  end
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.12.0"
7
+ VERSION = "3.13.0"
8
8
  end
9
9
  end
@@ -95,12 +95,13 @@ module RubyLLM
95
95
  def record_failed_execution(error, started_at)
96
96
  return unless defined?(RubyLLM::Agents::Execution)
97
97
 
98
- execution_data = build_failed_execution_data(error, started_at)
98
+ execution_data, detail_data = build_failed_execution_data(error, started_at)
99
99
 
100
100
  if config.async_logging && defined?(ExecutionLoggerJob)
101
- ExecutionLoggerJob.perform_later(execution_data)
101
+ ExecutionLoggerJob.perform_later(execution_data.merge(_detail_data: detail_data))
102
102
  else
103
- RubyLLM::Agents::Execution.create!(execution_data)
103
+ execution = RubyLLM::Agents::Execution.create!(execution_data)
104
+ execution.create_detail!(detail_data) if detail_data.present?
104
105
  end
105
106
  rescue => e
106
107
  Rails.logger.error("[RubyLLM::Agents] Failed to record failed #{execution_type} execution: #{e.message}") if defined?(Rails)
@@ -124,7 +125,7 @@ module RubyLLM
124
125
  end
125
126
 
126
127
  def build_failed_execution_data(error, started_at)
127
- {
128
+ execution_data = {
128
129
  agent_type: self.class.name,
129
130
  tenant_id: @tenant_id,
130
131
  execution_type: execution_type,
@@ -137,9 +138,12 @@ module RubyLLM
137
138
  started_at: started_at,
138
139
  completed_at: Time.current,
139
140
  error_class: error.class.name,
140
- error_message: error.message.truncate(1000),
141
141
  metadata: {}
142
142
  }
143
+
144
+ detail_data = {error_message: error.message.to_s.truncate(1000)}
145
+
146
+ [execution_data, detail_data]
143
147
  end
144
148
 
145
149
  def build_metadata(result)
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Background job that enforces two-tier data retention on execution records.
6
+ #
7
+ # Soft pass: for executions older than {Configuration#soft_purge_after},
8
+ # destroys the associated execution_details and tool_executions rows,
9
+ # preserves a truncated copy of error_message in metadata, and stamps
10
+ # metadata["soft_purged_at"] so the dashboard can surface the state and
11
+ # the pass stays idempotent.
12
+ #
13
+ # Hard pass: for executions older than {Configuration#hard_purge_after},
14
+ # destroys the executions row itself. The foreign-key cascade removes
15
+ # any remaining details or tool_executions.
16
+ #
17
+ # Either tier may be set to nil in configuration to skip that pass.
18
+ #
19
+ # @example Enqueue manually
20
+ # RubyLLM::Agents::RetentionJob.perform_later
21
+ #
22
+ # @example Schedule daily (whenever gem)
23
+ # every 1.day, at: "3:00 am" do
24
+ # runner "RubyLLM::Agents::RetentionJob.perform_later"
25
+ # end
26
+ #
27
+ # @api public
28
+ class RetentionJob < ActiveJob::Base
29
+ queue_as :default
30
+
31
+ ERROR_MESSAGE_MAX_LENGTH = 500
32
+ BATCH_SIZE = 500
33
+
34
+ # Runs the soft and hard retention passes based on current configuration.
35
+ #
36
+ # @return [Hash] counts of rows affected in each pass
37
+ def perform
38
+ {
39
+ soft_purged: soft_purge,
40
+ hard_purged: hard_purge
41
+ }
42
+ end
43
+
44
+ private
45
+
46
+ # Destroys detail + tool_execution rows for executions older than the
47
+ # soft-purge window that have not already been soft-purged. Stamps
48
+ # metadata with the purge timestamp and preserves a truncated
49
+ # error_message for long-term error-rate analytics.
50
+ #
51
+ # The "already purged" filter runs in Ruby rather than SQL because
52
+ # JSON key-exists operators differ across SQLite/Postgres/MySQL; this
53
+ # keeps the job adapter-agnostic. We batch to bound memory.
54
+ def soft_purge
55
+ window = RubyLLM::Agents.configuration.soft_purge_after
56
+ return 0 if window.nil?
57
+
58
+ cutoff = window.ago
59
+ count = 0
60
+
61
+ Execution
62
+ .where("created_at < ?", cutoff)
63
+ .includes(:detail)
64
+ .find_in_batches(batch_size: BATCH_SIZE) do |batch|
65
+ batch.each do |execution|
66
+ next if execution.soft_purged?
67
+
68
+ purge_one(execution)
69
+ count += 1
70
+ end
71
+ end
72
+
73
+ count
74
+ end
75
+
76
+ # Destroys executions (and everything cascaded from them) older than
77
+ # the hard-purge window.
78
+ def hard_purge
79
+ window = RubyLLM::Agents.configuration.hard_purge_after
80
+ return 0 if window.nil?
81
+
82
+ cutoff = window.ago
83
+ total = 0
84
+
85
+ Execution.where("created_at < ?", cutoff).in_batches(of: BATCH_SIZE) do |batch|
86
+ total += batch.destroy_all.size
87
+ end
88
+
89
+ total
90
+ end
91
+
92
+ # Performs the soft purge for a single execution.
93
+ def purge_one(execution)
94
+ preserved_error = preserved_error_message(execution)
95
+
96
+ Execution.transaction do
97
+ execution.detail&.destroy
98
+ execution.tool_executions.destroy_all
99
+
100
+ new_metadata = (execution.metadata || {}).merge(
101
+ "soft_purged_at" => Time.current.iso8601
102
+ )
103
+ new_metadata["error_message"] = preserved_error if preserved_error
104
+
105
+ execution.update_columns(metadata: new_metadata)
106
+ end
107
+ end
108
+
109
+ # Returns a truncated copy of the detail's error_message, or nil.
110
+ def preserved_error_message(execution)
111
+ raw = execution.detail&.error_message
112
+ return nil if raw.blank?
113
+
114
+ raw.to_s.truncate(ERROR_MESSAGE_MAX_LENGTH)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -33,6 +33,7 @@ module RubyLLM
33
33
  # @api private
34
34
  config.to_prepare do
35
35
  require_relative "../infrastructure/execution_logger_job"
36
+ require_relative "../infrastructure/retention_job"
36
37
  require_relative "../core/instrumentation"
37
38
  require_relative "../core/base"
38
39
 
@@ -153,18 +154,33 @@ module RubyLLM
153
154
  end
154
155
  helper_method :tenant_scoped_executions
155
156
 
156
- # Returns list of available tenants for filtering dropdown
157
+ # Returns the list of tenants for the dropdown as label/value pairs.
157
158
  #
158
- # @return [Array<String>] Unique tenant IDs from executions
159
+ # Tenants that have a matching row in ruby_llm_agents_tenants get their
160
+ # configured name; legacy or string-only tenants fall back to the raw
161
+ # tenant_id so nothing disappears from the filter.
162
+ #
163
+ # Two queries total — one DISTINCT pluck on executions, one pluck on
164
+ # tenants — regardless of how many tenant_ids exist.
165
+ #
166
+ # @return [Array<Hash>] Entries shaped as { value:, label: }
159
167
  # @api public
160
168
  def available_tenants
161
169
  return @available_tenants if defined?(@available_tenants)
162
170
 
163
- @available_tenants = RubyLLM::Agents::Execution
171
+ tenant_ids = RubyLLM::Agents::Execution
164
172
  .where.not(tenant_id: nil)
165
173
  .distinct
166
174
  .pluck(:tenant_id)
167
- .sort
175
+
176
+ names_by_id = RubyLLM::Agents::Tenant
177
+ .where(tenant_id: tenant_ids)
178
+ .pluck(:tenant_id, :name)
179
+ .to_h
180
+
181
+ @available_tenants = tenant_ids
182
+ .map { |id| {value: id, label: (names_by_id[id].presence || id).to_s} }
183
+ .sort_by { |t| t[:label].downcase }
168
184
  end
169
185
  helper_method :available_tenants
170
186
  end)
@@ -115,6 +115,15 @@ module RubyLLM
115
115
  @ask_message || options[:message] || super
116
116
  end
117
117
 
118
+ # Override call to capture the caller's stream block so it can be
119
+ # forwarded to the delegated agent. Without this, chunks from the
120
+ # delegated agent are swallowed because build_result has no access
121
+ # to the original block.
122
+ def call(&block)
123
+ @delegation_stream_block = block
124
+ super
125
+ end
126
+
118
127
  # Override process_response to parse the route from LLM output.
119
128
  def process_response(response)
120
129
  raw = response.content.to_s.strip.downcase.gsub(/[^a-z0-9_]/, "")
@@ -131,25 +140,39 @@ module RubyLLM
131
140
  end
132
141
 
133
142
  # Override build_result to return a RoutingResult.
134
- # Auto-delegates to the mapped agent when the route has an `agent:` mapping.
143
+ # Auto-delegates to the mapped agent when the route has an `agent:` mapping,
144
+ # unless the caller opts out with `auto_delegate: false`.
135
145
  def build_result(content, response, context)
136
146
  base = super
137
147
 
138
- # Auto-delegate to the mapped agent
139
148
  agent_class = content[:agent_class]
140
- if agent_class
141
- content[:delegated_result] = agent_class.call(**delegation_params)
149
+ if agent_class && auto_delegate?
150
+ content[:delegated_result] = if @delegation_stream_block
151
+ agent_class.call(**delegation_params, &@delegation_stream_block)
152
+ else
153
+ agent_class.call(**delegation_params)
154
+ end
142
155
  end
143
156
 
144
157
  RoutingResult.new(base_result: base, route_data: content)
145
158
  end
146
159
 
160
+ # Whether auto-delegation to the mapped agent is enabled for this call.
161
+ # Defaults to true. Pass `auto_delegate: false` to receive a
162
+ # classification-only RoutingResult with `delegated? == false` and
163
+ # `agent_class` set so the caller can invoke it manually.
164
+ #
165
+ # @return [Boolean]
166
+ def auto_delegate?
167
+ @options.fetch(:auto_delegate, true)
168
+ end
169
+
147
170
  # Builds params to forward to the delegated agent.
148
171
  # Forwards original message and custom params, excludes routing internals.
149
172
  #
150
173
  # @return [Hash] Params for the delegated agent
151
174
  def delegation_params
152
- forward = @options.except(:dry_run, :skip_cache, :debug, :stream_events)
175
+ forward = @options.except(:dry_run, :skip_cache, :debug, :stream_events, :auto_delegate)
153
176
  forward[:_parent_execution_id] = @parent_execution_id if @parent_execution_id
154
177
  forward[:_root_execution_id] = @root_execution_id if @root_execution_id
155
178
  forward
@@ -96,6 +96,7 @@ if defined?(Rails)
96
96
  require_relative "agents/core/inflections"
97
97
  require_relative "agents/core/instrumentation"
98
98
  require_relative "agents/infrastructure/execution_logger_job"
99
+ require_relative "agents/infrastructure/retention_job"
99
100
  end
100
101
  require_relative "agents/rails/engine" if defined?(Rails::Engine)
101
102
 
@@ -7,6 +7,13 @@ namespace :ruby_llm_agents do
7
7
  RubyLlmAgents::DoctorGenerator.start([])
8
8
  end
9
9
 
10
+ desc "Run the retention job synchronously (soft + hard purges per configuration)"
11
+ task purge: :environment do
12
+ result = RubyLLM::Agents::RetentionJob.new.perform
13
+ puts "Soft purged: #{result[:soft_purged]} executions (details destroyed)"
14
+ puts "Hard purged: #{result[:hard_purged]} executions (rows destroyed)"
15
+ end
16
+
10
17
  desc "Rename an agent type in execution records. Usage: rake ruby_llm_agents:rename_agent FROM=OldName TO=NewName [DRY_RUN=1]"
11
18
  task rename_agent: :environment do
12
19
  from = ENV["FROM"]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.0
4
+ version: 3.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 1.12.0
32
+ version: 1.14.1
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 1.12.0
39
+ version: 1.14.1
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: csv
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -290,6 +290,7 @@ files:
290
290
  - lib/ruby_llm/agents/infrastructure/circuit_breaker.rb
291
291
  - lib/ruby_llm/agents/infrastructure/execution_logger_job.rb
292
292
  - lib/ruby_llm/agents/infrastructure/reliability.rb
293
+ - lib/ruby_llm/agents/infrastructure/retention_job.rb
293
294
  - lib/ruby_llm/agents/pipeline.rb
294
295
  - lib/ruby_llm/agents/pipeline/builder.rb
295
296
  - lib/ruby_llm/agents/pipeline/context.rb