ruby_llm-agents 3.11.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/app/controllers/ruby_llm/agents/agents_controller.rb +74 -0
- data/app/controllers/ruby_llm/agents/analytics_controller.rb +304 -0
- data/app/controllers/ruby_llm/agents/executions_controller.rb +5 -0
- data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
- data/app/models/ruby_llm/agents/agent_override.rb +47 -0
- data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
- data/app/models/ruby_llm/agents/execution.rb +51 -1
- data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
- data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
- data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +93 -4
- data/app/views/ruby_llm/agents/agents/show.html.erb +17 -2
- data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
- 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/index.html.erb +3 -2
- data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
- data/config/routes.rb +12 -4
- data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
- data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
- data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
- data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
- data/lib/ruby_llm/agents/base_agent.rb +90 -133
- data/lib/ruby_llm/agents/core/base.rb +9 -0
- data/lib/ruby_llm/agents/core/configuration.rb +93 -7
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/dsl/base.rb +131 -4
- data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
- data/lib/ruby_llm/agents/dsl.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/pipeline/middleware/budget.rb +32 -20
- data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
- data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -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/stream_event.rb +2 -10
- data/lib/ruby_llm/agents/tool.rb +1 -1
- data/lib/ruby_llm/agents.rb +1 -3
- data/lib/tasks/ruby_llm_agents.rake +7 -0
- metadata +9 -5
- data/lib/ruby_llm/agents/agent_tool.rb +0 -143
- data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
|
@@ -43,6 +43,13 @@ module RubyLLM
|
|
|
43
43
|
#
|
|
44
44
|
# RubyExpert.ask("What is metaprogramming?")
|
|
45
45
|
#
|
|
46
|
+
# @example Dashboard-overridable settings
|
|
47
|
+
# class SupportAgent < RubyLLM::Agents::BaseAgent
|
|
48
|
+
# model "gpt-4o", overridable: true # can be changed from the dashboard
|
|
49
|
+
# temperature 0.7, overridable: true # can be changed from the dashboard
|
|
50
|
+
# timeout 30 # locked to code value
|
|
51
|
+
# end
|
|
52
|
+
#
|
|
46
53
|
# @example Dynamic prompts with method overrides
|
|
47
54
|
# class SmartAgent < RubyLLM::Agents::BaseAgent
|
|
48
55
|
# def system_prompt
|
|
@@ -63,12 +70,18 @@ module RubyLLM
|
|
|
63
70
|
# Sets or returns the LLM model for this agent class
|
|
64
71
|
#
|
|
65
72
|
# @param value [String, nil] The model identifier to set
|
|
73
|
+
# @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
|
|
66
74
|
# @return [String] The current model setting
|
|
67
75
|
# @example
|
|
68
76
|
# model "gpt-4o"
|
|
69
|
-
|
|
77
|
+
# @example Dashboard-overridable
|
|
78
|
+
# model "gpt-4o", overridable: true
|
|
79
|
+
def model(value = nil, overridable: nil)
|
|
70
80
|
@model = value if value
|
|
71
|
-
|
|
81
|
+
register_overridable(:model) if overridable
|
|
82
|
+
base = @model || inherited_or_default(:model, default_model)
|
|
83
|
+
|
|
84
|
+
apply_override(:model, base)
|
|
72
85
|
end
|
|
73
86
|
|
|
74
87
|
# Sets the user prompt template
|
|
@@ -206,12 +219,45 @@ module RubyLLM
|
|
|
206
219
|
# Sets or returns the timeout in seconds for LLM requests
|
|
207
220
|
#
|
|
208
221
|
# @param value [Integer, nil] Timeout in seconds
|
|
222
|
+
# @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
|
|
209
223
|
# @return [Integer] The current timeout setting
|
|
210
224
|
# @example
|
|
211
225
|
# timeout 30
|
|
212
|
-
|
|
226
|
+
# @example Dashboard-overridable
|
|
227
|
+
# timeout 30, overridable: true
|
|
228
|
+
def timeout(value = nil, overridable: nil)
|
|
213
229
|
@timeout = value if value
|
|
214
|
-
|
|
230
|
+
register_overridable(:timeout) if overridable
|
|
231
|
+
base = @timeout || inherited_or_default(:timeout, default_timeout)
|
|
232
|
+
|
|
233
|
+
apply_override(:timeout, base)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Enables Anthropic prompt caching for this agent
|
|
237
|
+
#
|
|
238
|
+
# When enabled, adds cache_control breakpoints to the system prompt
|
|
239
|
+
# and the last tool definition so Anthropic caches them across
|
|
240
|
+
# multi-turn agent loops. This reduces input token costs by ~90%
|
|
241
|
+
# on subsequent calls within the same cache window (~5 minutes).
|
|
242
|
+
#
|
|
243
|
+
# Only takes effect when the resolved model is served by Anthropic.
|
|
244
|
+
# Non-Anthropic models silently ignore this setting.
|
|
245
|
+
#
|
|
246
|
+
# @param value [Boolean, nil] Whether to enable prompt caching
|
|
247
|
+
# @return [Boolean] The current setting
|
|
248
|
+
#
|
|
249
|
+
# @example
|
|
250
|
+
# class BuildAgent < ApplicationAgent
|
|
251
|
+
# cache_prompts true
|
|
252
|
+
# system "You are a build assistant."
|
|
253
|
+
# tools BuildTool, TestTool, DeployTool
|
|
254
|
+
# end
|
|
255
|
+
#
|
|
256
|
+
def cache_prompts(value = nil)
|
|
257
|
+
@cache_prompts = value unless value.nil?
|
|
258
|
+
return @cache_prompts if defined?(@cache_prompts) && !@cache_prompts.nil?
|
|
259
|
+
|
|
260
|
+
inherited_or_default(:cache_prompts, false)
|
|
215
261
|
end
|
|
216
262
|
|
|
217
263
|
# Sets or returns the response schema for structured output
|
|
@@ -259,6 +305,47 @@ module RubyLLM
|
|
|
259
305
|
|
|
260
306
|
# @!endgroup
|
|
261
307
|
|
|
308
|
+
# @!group Dashboard Override Support
|
|
309
|
+
|
|
310
|
+
# Returns which fields are overridable for this agent
|
|
311
|
+
#
|
|
312
|
+
# @return [Array<Symbol>] The list of overridable field names
|
|
313
|
+
def overridable_fields
|
|
314
|
+
own = @overridable_fields || []
|
|
315
|
+
inherited = superclass.respond_to?(:overridable_fields) ? superclass.overridable_fields : []
|
|
316
|
+
(own + inherited).uniq
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Returns true if any field is overridable from the dashboard
|
|
320
|
+
#
|
|
321
|
+
# @return [Boolean]
|
|
322
|
+
def overridable?
|
|
323
|
+
overridable_fields.any?
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Returns the currently active dashboard overrides for this agent
|
|
327
|
+
#
|
|
328
|
+
# Only returns overrides for fields that are declared overridable.
|
|
329
|
+
#
|
|
330
|
+
# @return [Hash{String => Object}] Active override values
|
|
331
|
+
def active_overrides
|
|
332
|
+
return {} unless overridable?
|
|
333
|
+
|
|
334
|
+
raw = load_overrides
|
|
335
|
+
raw.select { |field, _| overridable_fields.include?(field.to_sym) }
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Clears the in-memory override cache so the next access reloads from DB
|
|
339
|
+
#
|
|
340
|
+
# Called automatically by AgentOverride after_save/after_destroy callbacks.
|
|
341
|
+
#
|
|
342
|
+
# @return [void]
|
|
343
|
+
def clear_override_cache!
|
|
344
|
+
@_override_cache = nil
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# @!endgroup
|
|
348
|
+
|
|
262
349
|
private
|
|
263
350
|
|
|
264
351
|
# Auto-registers parameters found in prompt template placeholders
|
|
@@ -310,6 +397,46 @@ module RubyLLM
|
|
|
310
397
|
rescue
|
|
311
398
|
120
|
|
312
399
|
end
|
|
400
|
+
|
|
401
|
+
# Registers a field as overridable from the dashboard
|
|
402
|
+
#
|
|
403
|
+
# @param field [Symbol] The field name
|
|
404
|
+
# @return [void]
|
|
405
|
+
def register_overridable(field)
|
|
406
|
+
@overridable_fields = (@overridable_fields || []) | [field]
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# Applies a dashboard override if the field is overridable and an override exists
|
|
410
|
+
#
|
|
411
|
+
# @param field [Symbol] The field name
|
|
412
|
+
# @param base [Object] The code-defined value to use as fallback
|
|
413
|
+
# @return [Object] The override value, or the base value
|
|
414
|
+
def apply_override(field, base)
|
|
415
|
+
return base unless overridable_fields.include?(field)
|
|
416
|
+
|
|
417
|
+
override = resolve_override(field)
|
|
418
|
+
override.nil? ? base : override
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Fetches the override value for a single field from the cached override hash
|
|
422
|
+
#
|
|
423
|
+
# @param field [Symbol] The field name
|
|
424
|
+
# @return [Object, nil] The override value, or nil
|
|
425
|
+
def resolve_override(field)
|
|
426
|
+
@_override_cache = load_overrides unless defined?(@_override_cache) && @_override_cache
|
|
427
|
+
@_override_cache[field.to_s]
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Loads all overrides for this agent from the database
|
|
431
|
+
#
|
|
432
|
+
# @return [Hash{String => Object}] The override settings hash
|
|
433
|
+
def load_overrides
|
|
434
|
+
return {} unless defined?(RubyLLM::Agents::AgentOverride)
|
|
435
|
+
|
|
436
|
+
RubyLLM::Agents::AgentOverride.find_by(agent_type: name)&.settings || {}
|
|
437
|
+
rescue
|
|
438
|
+
{}
|
|
439
|
+
end
|
|
313
440
|
end
|
|
314
441
|
end
|
|
315
442
|
end
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
module DSL
|
|
6
|
+
# Knowledge DSL for declaring domain knowledge to inject into system prompts.
|
|
7
|
+
#
|
|
8
|
+
# Supports two modes:
|
|
9
|
+
# - Static: `knows :name` loads from a file resolved via `knowledge_path`
|
|
10
|
+
# - Dynamic: `knows(:name) { ... }` evaluates a block at call time via instance_exec
|
|
11
|
+
#
|
|
12
|
+
# Optional `if:` condition gates inclusion without forcing static content into blocks.
|
|
13
|
+
#
|
|
14
|
+
# @example Static knowledge from files
|
|
15
|
+
# class MyAgent < RubyLLM::Agents::Base
|
|
16
|
+
# knowledge_path "knowledge"
|
|
17
|
+
# knows :refund_policy
|
|
18
|
+
# knows :pricing
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Dynamic knowledge
|
|
22
|
+
# class MyAgent < RubyLLM::Agents::Base
|
|
23
|
+
# knows :recent_tickets do
|
|
24
|
+
# Ticket.recent.pluck(:summary)
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# @example Conditional knowledge
|
|
29
|
+
# class MyAgent < RubyLLM::Agents::Base
|
|
30
|
+
# param :region, required: true
|
|
31
|
+
# knows :hipaa, if: -> { region == "us" }
|
|
32
|
+
# end
|
|
33
|
+
#
|
|
34
|
+
module Knowledge
|
|
35
|
+
# Registers one or more knowledge entries.
|
|
36
|
+
#
|
|
37
|
+
# @overload knows(name, **options, &block)
|
|
38
|
+
# Register a single entry (static or dynamic)
|
|
39
|
+
# @param name [Symbol] Identifier for this knowledge entry
|
|
40
|
+
# @param options [Hash] Options including `if:` condition lambda
|
|
41
|
+
# @param block [Proc] Optional block for dynamic knowledge (evaluated via instance_exec)
|
|
42
|
+
#
|
|
43
|
+
# @overload knows(name, *more_names)
|
|
44
|
+
# Register multiple static entries at once
|
|
45
|
+
# @param name [Symbol] First entry name
|
|
46
|
+
# @param more_names [Array<Symbol>] Additional entry names
|
|
47
|
+
def knows(name, *more_names, **options, &block)
|
|
48
|
+
if more_names.any?
|
|
49
|
+
# Multi-arg form: knows :a, :b, :c — all static, no block/options
|
|
50
|
+
[name, *more_names].each do |n|
|
|
51
|
+
knowledge_entries.reject! { |e| e[:name] == n }
|
|
52
|
+
knowledge_entries << {name: n, loader: nil, options: {}}
|
|
53
|
+
end
|
|
54
|
+
else
|
|
55
|
+
knowledge_entries.reject! { |e| e[:name] == name }
|
|
56
|
+
knowledge_entries << {
|
|
57
|
+
name: name,
|
|
58
|
+
loader: block,
|
|
59
|
+
options: options
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Sets or returns the base path for static knowledge files.
|
|
65
|
+
#
|
|
66
|
+
# @param path [String, nil] Path to set, or nil to read
|
|
67
|
+
# @return [String, nil] The resolved knowledge path
|
|
68
|
+
def knowledge_path(path = nil)
|
|
69
|
+
if path
|
|
70
|
+
@knowledge_path = path
|
|
71
|
+
else
|
|
72
|
+
@knowledge_path ||
|
|
73
|
+
inherited_or_default(:knowledge_path, nil) ||
|
|
74
|
+
RubyLLM::Agents.configuration.knowledge_path
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns the list of registered knowledge entries, inheriting from superclass.
|
|
79
|
+
#
|
|
80
|
+
# @return [Array<Hash>] Knowledge entries
|
|
81
|
+
def knowledge_entries
|
|
82
|
+
@knowledge_entries ||= if superclass.respond_to?(:knowledge_entries)
|
|
83
|
+
superclass.knowledge_entries.dup
|
|
84
|
+
else
|
|
85
|
+
[]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Instance methods mixed into agent instances via include.
|
|
90
|
+
module InstanceMethods
|
|
91
|
+
# Compiles all knowledge entries into a single string with headings and separators.
|
|
92
|
+
#
|
|
93
|
+
# @return [String] Compiled knowledge (empty string if no entries resolve)
|
|
94
|
+
def compiled_knowledge
|
|
95
|
+
self.class.knowledge_entries.filter_map { |entry|
|
|
96
|
+
content = resolve_knowledge(entry)
|
|
97
|
+
next if content.blank?
|
|
98
|
+
|
|
99
|
+
heading = entry[:name].to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
|
|
100
|
+
"## #{heading}\n\n#{content}"
|
|
101
|
+
}.join("\n\n---\n\n")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def resolve_knowledge(entry)
|
|
107
|
+
if (condition = entry[:options][:if])
|
|
108
|
+
return nil unless instance_exec(&condition)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
if entry[:loader]
|
|
112
|
+
resolve_dynamic_knowledge(entry)
|
|
113
|
+
else
|
|
114
|
+
resolve_static_knowledge(entry)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def resolve_dynamic_knowledge(entry)
|
|
119
|
+
result = instance_exec(&entry[:loader])
|
|
120
|
+
case result
|
|
121
|
+
when Array
|
|
122
|
+
result.map { |r| "- #{r}" }.join("\n")
|
|
123
|
+
when String
|
|
124
|
+
result
|
|
125
|
+
when nil
|
|
126
|
+
nil
|
|
127
|
+
else
|
|
128
|
+
result.to_s
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def resolve_static_knowledge(entry)
|
|
133
|
+
path = find_knowledge_file(entry[:name])
|
|
134
|
+
return nil unless path && File.exist?(path)
|
|
135
|
+
File.read(path)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def find_knowledge_file(name)
|
|
139
|
+
base_path = self.class.knowledge_path
|
|
140
|
+
return nil unless base_path
|
|
141
|
+
|
|
142
|
+
candidates = [
|
|
143
|
+
File.join(base_path, "#{name}.md"),
|
|
144
|
+
File.join(base_path, name.to_s)
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
|
|
148
|
+
candidates.map! { |c| Rails.root.join(c).to_s }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
candidates.find { |c| File.exist?(c) }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
data/lib/ruby_llm/agents/dsl.rb
CHANGED
|
@@ -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,15 +33,18 @@ module RubyLLM
|
|
|
33
33
|
return @app.call(context) unless budgets_enabled?
|
|
34
34
|
|
|
35
35
|
trace(context) do
|
|
36
|
+
# Resolve tenant once for both check and record
|
|
37
|
+
tenant = resolve_tenant(context)
|
|
38
|
+
|
|
36
39
|
# Check budget before execution
|
|
37
|
-
check_budget!(context)
|
|
40
|
+
check_budget!(context, tenant)
|
|
38
41
|
|
|
39
42
|
# Execute the chain
|
|
40
43
|
@app.call(context)
|
|
41
44
|
|
|
42
45
|
# Record spend after successful execution (if not cached)
|
|
43
46
|
if context.success? && !context.cached?
|
|
44
|
-
record_spend!(context)
|
|
47
|
+
record_spend!(context, tenant)
|
|
45
48
|
emit_budget_notification("ruby_llm_agents.budget.record", context,
|
|
46
49
|
total_cost: context.total_cost,
|
|
47
50
|
total_tokens: context.total_tokens)
|
|
@@ -80,22 +83,33 @@ module RubyLLM
|
|
|
80
83
|
false
|
|
81
84
|
end
|
|
82
85
|
|
|
86
|
+
# Resolves the tenant record once for reuse across check and record
|
|
87
|
+
#
|
|
88
|
+
# @param context [Context] The execution context
|
|
89
|
+
# @return [Tenant, nil] The tenant record or nil
|
|
90
|
+
def resolve_tenant(context)
|
|
91
|
+
return nil unless context.tenant_id.present?
|
|
92
|
+
|
|
93
|
+
RubyLLM::Agents::Tenant.find_by(tenant_id: context.tenant_id)
|
|
94
|
+
rescue => e
|
|
95
|
+
debug("Tenant lookup failed: #{e.message}", context)
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
83
99
|
# Checks budget before execution
|
|
84
100
|
#
|
|
85
101
|
# For tenants, checks budget via counter columns on the tenant model.
|
|
86
102
|
# For non-tenant usage, falls back to BudgetTracker (cache-based).
|
|
87
103
|
#
|
|
88
104
|
# @param context [Context] The execution context
|
|
105
|
+
# @param tenant [Tenant, nil] Pre-resolved tenant record
|
|
89
106
|
# @raise [BudgetExceededError] If budget exceeded with hard enforcement
|
|
90
|
-
def check_budget!(context)
|
|
107
|
+
def check_budget!(context, tenant = nil)
|
|
91
108
|
emit_budget_notification("ruby_llm_agents.budget.check", context)
|
|
92
109
|
|
|
93
|
-
if
|
|
94
|
-
tenant
|
|
95
|
-
|
|
96
|
-
tenant.check_budget!(context.agent_class&.name)
|
|
97
|
-
return
|
|
98
|
-
end
|
|
110
|
+
if tenant
|
|
111
|
+
tenant.check_budget!(context.agent_class&.name)
|
|
112
|
+
return
|
|
99
113
|
end
|
|
100
114
|
|
|
101
115
|
# Fallback to cache-based checking (non-tenant or no tenant record)
|
|
@@ -117,17 +131,15 @@ module RubyLLM
|
|
|
117
131
|
# For non-tenant usage, falls back to BudgetTracker (cache-based).
|
|
118
132
|
#
|
|
119
133
|
# @param context [Context] The execution context
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
return
|
|
130
|
-
end
|
|
134
|
+
# @param tenant [Tenant, nil] Pre-resolved tenant record
|
|
135
|
+
def record_spend!(context, tenant = nil)
|
|
136
|
+
if tenant
|
|
137
|
+
tenant.record_execution!(
|
|
138
|
+
cost: context.total_cost || 0,
|
|
139
|
+
tokens: context.total_tokens || 0,
|
|
140
|
+
error: context.failed?
|
|
141
|
+
)
|
|
142
|
+
return
|
|
131
143
|
end
|
|
132
144
|
|
|
133
145
|
# Fallback for non-tenant usage
|
|
@@ -278,6 +278,7 @@ module RubyLLM
|
|
|
278
278
|
data = {
|
|
279
279
|
agent_type: context.agent_class&.name,
|
|
280
280
|
model_id: context.model,
|
|
281
|
+
model_provider: resolve_model_provider(context.model),
|
|
281
282
|
status: "running",
|
|
282
283
|
started_at: context.started_at,
|
|
283
284
|
input_tokens: 0,
|
|
@@ -339,7 +340,9 @@ module RubyLLM
|
|
|
339
340
|
input_tokens: context.input_tokens || 0,
|
|
340
341
|
output_tokens: context.output_tokens || 0,
|
|
341
342
|
total_cost: context.total_cost || 0,
|
|
342
|
-
attempts_count: context.attempts_made
|
|
343
|
+
attempts_count: context.attempts_made,
|
|
344
|
+
chosen_model_id: context.model_used,
|
|
345
|
+
finish_reason: context.finish_reason
|
|
343
346
|
}
|
|
344
347
|
|
|
345
348
|
# Merge metadata: agent metadata (base) < middleware metadata (overlay)
|
|
@@ -536,6 +539,24 @@ module RubyLLM
|
|
|
536
539
|
{}
|
|
537
540
|
end
|
|
538
541
|
|
|
542
|
+
# Resolves the provider name for a given model ID
|
|
543
|
+
#
|
|
544
|
+
# Uses RubyLLM::Models.find which is an in-process registry lookup
|
|
545
|
+
# (no API keys or network calls needed).
|
|
546
|
+
#
|
|
547
|
+
# @param model_id [String, nil] The model identifier
|
|
548
|
+
# @return [String, nil] Provider name (e.g., "openai", "anthropic") or nil
|
|
549
|
+
def resolve_model_provider(model_id)
|
|
550
|
+
return nil unless model_id
|
|
551
|
+
return nil unless defined?(RubyLLM::Models)
|
|
552
|
+
|
|
553
|
+
model_info = RubyLLM::Models.find(model_id)
|
|
554
|
+
provider = model_info&.provider
|
|
555
|
+
provider&.to_s.presence
|
|
556
|
+
rescue
|
|
557
|
+
nil
|
|
558
|
+
end
|
|
559
|
+
|
|
539
560
|
# Injects tracker request_id and tags into execution data
|
|
540
561
|
#
|
|
541
562
|
# Reads @_track_request_id and @_track_tags from the agent instance,
|
|
@@ -223,7 +223,7 @@ module RubyLLM
|
|
|
223
223
|
return unless deadline && Time.current > deadline
|
|
224
224
|
|
|
225
225
|
elapsed = Time.current - started_at
|
|
226
|
-
timeout_value = deadline - started_at
|
|
226
|
+
timeout_value = deadline - started_at
|
|
227
227
|
raise Agents::Reliability::TotalTimeoutError.new(timeout_value, elapsed)
|
|
228
228
|
end
|
|
229
229
|
|
|
@@ -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)
|