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 +4 -4
- data/app/controllers/ruby_llm/agents/executions_controller.rb +5 -0
- data/app/models/ruby_llm/agents/execution.rb +51 -1
- 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/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
- data/lib/ruby_llm/agents/core/configuration.rb +88 -6
- 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/retention_job.rb +118 -0
- 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: 5fc64ee9b7db541e40c144d3b8007c0967f7ad63a7b5c404079d7c4c3398a646
|
|
4
|
+
data.tar.gz: 1c5b0d4c8c55390cfb5bee3ad394b47f4cae44dbd0452c5135c6ad7e7ac66a0b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
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
|
-
|
|
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>
|
|
@@ -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
|
# ============================================
|
|
@@ -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
|
-
#
|
|
55
|
-
#
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
157
|
+
# Returns the list of tenants for the dropdown as label/value pairs.
|
|
157
158
|
#
|
|
158
|
-
#
|
|
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
|
-
|
|
171
|
+
tenant_ids = RubyLLM::Agents::Execution
|
|
164
172
|
.where.not(tenant_id: nil)
|
|
165
173
|
.distinct
|
|
166
174
|
.pluck(:tenant_id)
|
|
167
|
-
|
|
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] =
|
|
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
|
data/lib/ruby_llm/agents.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|