ruby_llm-agents 3.12.0 → 3.14.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 (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/controllers/ruby_llm/agents/analytics_controller.rb +8 -0
  4. data/app/controllers/ruby_llm/agents/executions_controller.rb +8 -2
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +8 -2
  6. data/app/models/ruby_llm/agents/execution.rb +63 -3
  7. data/app/models/ruby_llm/agents/tenant.rb +30 -2
  8. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +10 -6
  9. data/app/views/ruby_llm/agents/agents/show.html.erb +5 -4
  10. data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
  11. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
  12. data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
  13. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
  14. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
  15. data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
  16. data/app/views/ruby_llm/agents/tenants/_form.html.erb +16 -7
  17. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
  18. data/lib/ruby_llm/agents/base_agent.rb +189 -21
  19. data/lib/ruby_llm/agents/core/configuration.rb +96 -6
  20. data/lib/ruby_llm/agents/core/llm_tenant.rb +40 -0
  21. data/lib/ruby_llm/agents/core/version.rb +1 -1
  22. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
  23. data/lib/ruby_llm/agents/infrastructure/execution_logger_job.rb +4 -2
  24. data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
  25. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +52 -1
  26. data/lib/ruby_llm/agents/rails/engine.rb +20 -4
  27. data/lib/ruby_llm/agents/routing.rb +28 -5
  28. data/lib/ruby_llm/agents.rb +1 -0
  29. data/lib/tasks/ruby_llm_agents.rake +7 -0
  30. metadata +4 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e29829dcedb6a67dd1d9e55c7a45ead560e5da2fc0f75ffd04c97026e8d5b90
4
- data.tar.gz: 9c2981a6eed9459f2c7d56c47f6ff5ddcdd282bc90729ace04b8eca4f83e9e55
3
+ metadata.gz: d974a549e2d99bbcd16c8345547d16c16aff7ae602da503974d9db1f32be8ce6
4
+ data.tar.gz: 9947da35f39521706e7c7bef349dcf9f6a63f5f732409ff1774ce0607bd6429e
5
5
  SHA512:
6
- metadata.gz: 86ed72b1e799b64259b83588ac9c9111d04539eb7225e65d517332d22ed671436214255ec0127fdcff4ef65fe283683219e88b67a956c6270a7b6a0140937a27
7
- data.tar.gz: b532f5a4a47814ed19a9929b06e506b56af651d8d1be65348c68b61aa78dc48f771c89c8d667dac06860d88283fee3a890b8d39afc4d11f5e6905ceca396c136
6
+ metadata.gz: 8aa785d541d2e00d237a71d5a652643e7e2da83750f8264d754c00fce6c2e33fc7d6d47caa75c1ac94659fd05703a9c488a229064cd1d01157b6845e4ef3970e
7
+ data.tar.gz: 89bf72286b0a6e70418268457beea87df58bb16514a170730518adeadae84eac6d6ff6553e9726e481b00c2358ad9d909c9f20170bcf41224eb7efdaf3d7f1ac
data/README.md CHANGED
@@ -317,7 +317,7 @@ mount RubyLLM::Agents::Engine => "/agents"
317
317
 
318
318
  - **Ruby** >= 3.1.0
319
319
  - **Rails** >= 7.0
320
- - **RubyLLM** >= 1.12.0
320
+ - **RubyLLM** >= 1.16.0
321
321
 
322
322
  ## Contributing
323
323
 
@@ -35,6 +35,7 @@ module RubyLLM
35
35
  @days = range_to_days(@selected_range)
36
36
  parse_custom_dates if @selected_range == "custom"
37
37
 
38
+ set_active_filters
38
39
  current_scope = apply_filters(time_scoped(tenant_scoped_executions))
39
40
  prior_scope = apply_filters(prior_period_scope(tenant_scoped_executions))
40
41
 
@@ -114,6 +115,13 @@ module RubyLLM
114
115
  []
115
116
  end
116
117
 
118
+ set_active_filters
119
+ end
120
+
121
+ # Resolves the active agent/model/tenant filters from request params.
122
+ # Shared by index (which also loads dropdown options) and chart_data
123
+ # (JSON only) so apply_filters behaves consistently on both endpoints.
124
+ def set_active_filters
117
125
  @filter_agent = params[:agent].presence
118
126
  @filter_model = params[:model].presence
119
127
  @filter_tenant = params[:filter_tenant].presence
@@ -203,14 +203,20 @@ 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
 
209
214
  # Only show root executions - children are nested under parents
210
215
  scope = scope.where(parent_execution_id: nil)
211
216
 
212
- # Eager load children for grouping
213
- scope.includes(:child_executions)
217
+ # Eager load children for grouping and detail for error_message, which
218
+ # the list renders per row (otherwise an N+1 on error rows).
219
+ scope.includes(:child_executions, :detail)
214
220
  end
215
221
 
216
222
  # Checks whether turbo-rails is available in the host application
@@ -19,7 +19,9 @@ module RubyLLM
19
19
  # @return [void]
20
20
  def index
21
21
  @sort_params = parse_tenant_sort_params
22
- scope = TenantBudget.all
22
+ # Eager-load tenant_record so display_name's live name resolution does
23
+ # not issue a query per row.
24
+ scope = TenantBudget.all.includes(:tenant_record)
23
25
 
24
26
  if params[:q].present?
25
27
  @search_query = params[:q].to_s.strip
@@ -67,7 +69,11 @@ module RubyLLM
67
69
  # @return [void]
68
70
  def update
69
71
  @tenant = TenantBudget.find(params[:id])
70
- if @tenant.update(tenant_params)
72
+ attrs = tenant_params
73
+ # Linked tenants derive their name live from the host record, so ignore
74
+ # any submitted name — it would be overwritten on the next record sync.
75
+ attrs = attrs.except(:name) if @tenant.linked?
76
+ if @tenant.update(attrs)
71
77
  redirect_to tenant_path(@tenant), notice: "Tenant updated successfully"
72
78
  else
73
79
  render :edit, status: :unprocessable_entity
@@ -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}
@@ -95,7 +107,13 @@ module RubyLLM
95
107
  validates :finish_reason, inclusion: {in: FINISH_REASONS}, allow_nil: true
96
108
 
97
109
  before_save :calculate_total_tokens, if: -> { input_tokens_changed? || output_tokens_changed? }
98
- before_save :calculate_total_cost, if: -> { input_cost_changed? || output_cost_changed? }
110
+ # Derive total_cost from its components only when the caller did not set an
111
+ # explicit total in the same save. The pipeline records a
112
+ # cache/reasoning-aware total alongside input_cost/output_cost, and that
113
+ # richer value must not be overwritten with the text-only input+output sum.
114
+ # (Deriving from metadata is unsafe — metadata merges user-supplied agent
115
+ # data and a colliding key would corrupt the total.)
116
+ before_save :calculate_total_cost, if: -> { (input_cost_changed? || output_cost_changed?) && !total_cost_changed? }
99
117
 
100
118
  # Aggregates costs from all attempts using each attempt's model pricing
101
119
  #
@@ -215,6 +233,44 @@ module RubyLLM
215
233
  metadata&.dig("rate_limited") == true
216
234
  end
217
235
 
236
+ # Returns the response payload as a Hash, regardless of how agents wrote it.
237
+ #
238
+ # The `execution_details.response` column is declared as JSON, but agents
239
+ # may write plain strings (chat text), arrays (embeddings), or nil. Views
240
+ # that want to look up specific keys (audio_url, image_url, etc.) need a
241
+ # Hash they can safely `.dig` into. This reader returns an empty hash when
242
+ # the stored response isn't a Hash, so callers don't need type guards.
243
+ #
244
+ # @return [Hash] Parsed response hash, or empty hash if not hash-shaped
245
+ def response_hash
246
+ raw = response
247
+ raw.is_a?(Hash) ? raw : {}
248
+ end
249
+
250
+ # Returns whether this execution has had its detail payload soft-purged.
251
+ #
252
+ # Soft-purged executions retain all analytics columns (cost, tokens,
253
+ # timing, status) but the large payloads (prompts, responses, tool
254
+ # calls, attempts) stored in execution_details and tool_executions
255
+ # have been destroyed by the retention job.
256
+ #
257
+ # @return [Boolean] true if the retention job has soft-purged this execution
258
+ def soft_purged?
259
+ metadata&.key?("soft_purged_at") == true
260
+ end
261
+
262
+ # Returns when this execution was soft-purged, if ever.
263
+ #
264
+ # @return [Time, nil] Parsed timestamp or nil if not soft-purged
265
+ def soft_purged_at
266
+ raw = metadata&.dig("soft_purged_at")
267
+ return nil if raw.blank?
268
+
269
+ Time.iso8601(raw)
270
+ rescue ArgumentError
271
+ nil
272
+ end
273
+
218
274
  # Convenience accessors for niche fields stored in metadata JSON
219
275
  %w[span_id response_cache_key fallback_reason].each do |field|
220
276
  define_method(field) { metadata&.dig(field) }
@@ -424,7 +480,11 @@ module RubyLLM
424
480
  self.total_tokens = (input_tokens || 0) + (output_tokens || 0)
425
481
  end
426
482
 
427
- # Calculates and sets total_cost from input and output costs
483
+ # Calculates and sets total_cost from input and output costs.
484
+ #
485
+ # Only runs when the caller did not provide an explicit total_cost (see
486
+ # the before_save guard), so a cache/reasoning-aware total supplied by the
487
+ # pipeline is preserved rather than collapsed to the text-only sum.
428
488
  #
429
489
  # @return [BigDecimal] The calculated total
430
490
  def calculate_total_cost
@@ -145,11 +145,17 @@ module RubyLLM
145
145
  alias_method :for_tenant!, :for!
146
146
  end
147
147
 
148
- # Display name (name or tenant_id fallback)
148
+ # Display name.
149
+ #
150
+ # For tenants linked to a host model (Account, Organization, ...) the name
151
+ # is resolved live from that record, so a renamed record is reflected
152
+ # immediately instead of showing the snapshot taken when the tenant was
153
+ # created. Unlinked (string-id) tenants fall back to the stored name
154
+ # column, and tenant_id is the final fallback so this is never blank.
149
155
  #
150
156
  # @return [String]
151
157
  def display_name
152
- name.presence || tenant_id
158
+ linked_record_name.presence || name.presence || tenant_id
153
159
  end
154
160
 
155
161
  # Check if tenant is linked to a user model
@@ -179,6 +185,28 @@ module RubyLLM
179
185
  def activate!
180
186
  update!(active: true)
181
187
  end
188
+
189
+ private
190
+
191
+ # Live display name from the linked host record (Account/Organization),
192
+ # or nil when this tenant is unlinked or the record is unavailable.
193
+ # Prefers the model's llm_tenant_name (which honours the configured name
194
+ # method), falling back to a plain #name. Never raises — name resolution
195
+ # must not break rendering.
196
+ #
197
+ # @return [String, nil]
198
+ def linked_record_name
199
+ record = tenant_record
200
+ return nil unless record
201
+
202
+ if record.respond_to?(:llm_tenant_name)
203
+ record.llm_tenant_name
204
+ elsif record.respond_to?(:name)
205
+ record.name
206
+ end
207
+ rescue
208
+ nil
209
+ end
182
210
  end
183
211
  end
184
212
  end
@@ -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>
@@ -17,13 +17,22 @@
17
17
 
18
18
  <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
19
19
  <div>
20
- <%= f.label :name, "display name", class: "block text-xs font-mono text-gray-500 dark:text-gray-400 mb-1" %>
21
- <%= f.text_field :name,
22
- class: "w-full px-3 py-2 bg-transparent border border-gray-200 dark:border-gray-800 rounded text-sm 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 placeholder-gray-400 dark:placeholder-gray-600",
23
- placeholder: "e.g., Acme Corporation" %>
24
- <p class="mt-1 text-[10px] font-mono text-gray-400 dark:text-gray-600">
25
- Falls back to tenant ID if not set.
26
- </p>
20
+ <label class="block text-xs font-mono text-gray-500 dark:text-gray-400 mb-1">display name</label>
21
+ <% if tenant.linked? %>
22
+ <p class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded text-sm font-mono text-gray-500 dark:text-gray-400">
23
+ <%= tenant.display_name %>
24
+ </p>
25
+ <p class="mt-1 text-[10px] font-mono text-gray-400 dark:text-gray-600">
26
+ Managed by the linked <%= tenant.tenant_record_type %>. Renaming it updates this automatically.
27
+ </p>
28
+ <% else %>
29
+ <%= f.text_field :name,
30
+ class: "w-full px-3 py-2 bg-transparent border border-gray-200 dark:border-gray-800 rounded text-sm 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 placeholder-gray-400 dark:placeholder-gray-600",
31
+ placeholder: "e.g., Acme Corporation" %>
32
+ <p class="mt-1 text-[10px] font-mono text-gray-400 dark:text-gray-600">
33
+ Falls back to tenant ID if not set.
34
+ </p>
35
+ <% end %>
27
36
  </div>
28
37
 
29
38
  <div>
@@ -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
  # ============================================