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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/app/controllers/ruby_llm/agents/analytics_controller.rb +8 -0
- data/app/controllers/ruby_llm/agents/executions_controller.rb +8 -2
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +8 -2
- data/app/models/ruby_llm/agents/execution.rb +63 -3
- data/app/models/ruby_llm/agents/tenant.rb +30 -2
- data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +10 -6
- data/app/views/ruby_llm/agents/agents/show.html.erb +5 -4
- data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
- data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
- data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
- data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
- data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
- data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
- data/app/views/ruby_llm/agents/tenants/_form.html.erb +16 -7
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
- data/lib/ruby_llm/agents/base_agent.rb +189 -21
- data/lib/ruby_llm/agents/core/configuration.rb +96 -6
- data/lib/ruby_llm/agents/core/llm_tenant.rb +40 -0
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
- data/lib/ruby_llm/agents/infrastructure/execution_logger_job.rb +4 -2
- data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +52 -1
- data/lib/ruby_llm/agents/rails/engine.rb +20 -4
- data/lib/ruby_llm/agents/routing.rb +28 -5
- data/lib/ruby_llm/agents.rb +1 -0
- data/lib/tasks/ruby_llm_agents.rake +7 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d974a549e2d99bbcd16c8345547d16c16aff7ae602da503974d9db1f32be8ce6
|
|
4
|
+
data.tar.gz: 9947da35f39521706e7c7bef349dcf9f6a63f5f732409ff1774ce0607bd6429e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8aa785d541d2e00d237a71d5a652643e7e2da83750f8264d754c00fce6c2e33fc7d6d47caa75c1ac94659fd05703a9c488a229064cd1d01157b6845e4ef3970e
|
|
7
|
+
data.tar.gz: 89bf72286b0a6e70418268457beea87df58bb16514a170730518adeadae84eac6d6ff6553e9726e481b00c2358ad9d909c9f20170bcf41224eb7efdaf3d7f1ac
|
data/README.md
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
<%=
|
|
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
|
-
|
|
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
|
|
297
|
-
<% if @config && @agent_type_kind
|
|
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>
|
|
@@ -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? ||
|
|
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
|
-
|
|
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/
|
|
93
|
-
name: "
|
|
94
|
-
filter_id: "
|
|
95
|
-
|
|
96
|
-
|
|
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,
|
|
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
|
+
⚠ 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.
|
|
74
|
-
@execution.
|
|
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
|
-
|
|
258
|
-
<
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
</
|
|
266
|
-
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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="
|
|
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">
|
|
55
|
-
<span class="text-gray-900 dark:text-gray-200"><%= @config.
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
#
|
|
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
|
# ============================================
|