ruby_llm-agents 3.12.0 → 3.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -1
  3. data/app/controllers/ruby_llm/agents/analytics_controller.rb +8 -0
  4. data/app/controllers/ruby_llm/agents/executions_controller.rb +8 -2
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +8 -2
  6. data/app/models/ruby_llm/agents/execution.rb +63 -3
  7. data/app/models/ruby_llm/agents/tenant.rb +30 -2
  8. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +10 -6
  9. data/app/views/ruby_llm/agents/agents/show.html.erb +5 -4
  10. data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
  11. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
  12. data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
  13. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
  14. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
  15. data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
  16. data/app/views/ruby_llm/agents/tenants/_form.html.erb +16 -7
  17. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
  18. data/lib/ruby_llm/agents/base_agent.rb +189 -21
  19. data/lib/ruby_llm/agents/core/configuration.rb +96 -6
  20. data/lib/ruby_llm/agents/core/llm_tenant.rb +40 -0
  21. data/lib/ruby_llm/agents/core/version.rb +1 -1
  22. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
  23. data/lib/ruby_llm/agents/infrastructure/execution_logger_job.rb +4 -2
  24. data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
  25. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +52 -1
  26. data/lib/ruby_llm/agents/rails/engine.rb +20 -4
  27. data/lib/ruby_llm/agents/routing.rb +28 -5
  28. data/lib/ruby_llm/agents.rb +1 -0
  29. data/lib/tasks/ruby_llm_agents.rake +7 -0
  30. metadata +4 -3
@@ -48,7 +48,7 @@ module RubyLLM
48
48
  raised_exception = nil
49
49
 
50
50
  begin
51
- @app.call(context)
51
+ capture_llm_requests(context) { @app.call(context) }
52
52
  context.completed_at = Time.current
53
53
 
54
54
  begin
@@ -84,6 +84,55 @@ module RubyLLM
84
84
 
85
85
  private
86
86
 
87
+ # Fiber-local stack of in-flight request accumulators, innermost last.
88
+ REQUEST_CAPTURE_STACK = :ruby_llm_agents_request_capture
89
+
90
+ # Captures real HTTP-level provider latency for the LLM call(s) made
91
+ # while running the rest of the pipeline.
92
+ #
93
+ # ruby_llm 1.16 emits a "request.ruby_llm" event per HTTP request and
94
+ # its Railtie wires ActiveSupport::Notifications as the instrumenter
95
+ # in Rails, so we subscribe for the duration of the downstream call
96
+ # and accumulate provider time and request count (retries/fallbacks
97
+ # add up). This is distinct from the total pipeline duration, which
98
+ # also includes middleware and tool execution. The values are stored
99
+ # in context metadata and persisted with the execution.
100
+ #
101
+ # AS::Notifications subscriptions are process-global, so a naive
102
+ # subscriber would also see events from other executions running
103
+ # concurrently (other threads) or nested inside this one (agent-as-
104
+ # tool). To attribute each request to exactly one execution, we keep
105
+ # a fiber-local stack of accumulators and only credit the innermost
106
+ # one on the thread that actually emitted the event — the callback
107
+ # runs synchronously on the emitting thread, so its top-of-stack is
108
+ # the execution whose LLM call fired.
109
+ #
110
+ # @param context [Context] The execution context
111
+ # @return [Object] The downstream call's return value
112
+ def capture_llm_requests(context)
113
+ return yield unless defined?(ActiveSupport::Notifications)
114
+
115
+ accumulator = {ms: 0.0, count: 0}
116
+ stack = (Thread.current[REQUEST_CAPTURE_STACK] ||= [])
117
+ stack.push(accumulator)
118
+
119
+ callback = lambda do |_name, started, finished, _id, _payload|
120
+ top = Thread.current[REQUEST_CAPTURE_STACK]&.last
121
+ next unless top.equal?(accumulator)
122
+
123
+ accumulator[:ms] += (finished - started) * 1000.0
124
+ accumulator[:count] += 1
125
+ end
126
+
127
+ ActiveSupport::Notifications.subscribed(callback, "request.ruby_llm") { yield }
128
+ ensure
129
+ stack&.pop
130
+ if accumulator && accumulator[:count].positive?
131
+ context[:llm_request_ms] = accumulator[:ms].round
132
+ context[:llm_request_count] = accumulator[:count]
133
+ end
134
+ end
135
+
87
136
  # Creates initial execution record with 'running' status
88
137
  #
89
138
  # Creates the record synchronously so it appears on the dashboard immediately.
@@ -339,6 +388,8 @@ module RubyLLM
339
388
  cache_hit: context.cached?,
340
389
  input_tokens: context.input_tokens || 0,
341
390
  output_tokens: context.output_tokens || 0,
391
+ input_cost: context.input_cost,
392
+ output_cost: context.output_cost,
342
393
  total_cost: context.total_cost || 0,
343
394
  attempts_count: context.attempts_made,
344
395
  chosen_model_id: context.model_used,
@@ -33,6 +33,7 @@ module RubyLLM
33
33
  # @api private
34
34
  config.to_prepare do
35
35
  require_relative "../infrastructure/execution_logger_job"
36
+ require_relative "../infrastructure/retention_job"
36
37
  require_relative "../core/instrumentation"
37
38
  require_relative "../core/base"
38
39
 
@@ -153,18 +154,33 @@ module RubyLLM
153
154
  end
154
155
  helper_method :tenant_scoped_executions
155
156
 
156
- # Returns list of available tenants for filtering dropdown
157
+ # Returns the list of tenants for the dropdown as label/value pairs.
157
158
  #
158
- # @return [Array<String>] Unique tenant IDs from executions
159
+ # Tenants that have a matching row in ruby_llm_agents_tenants get their
160
+ # configured name; legacy or string-only tenants fall back to the raw
161
+ # tenant_id so nothing disappears from the filter.
162
+ #
163
+ # Two queries total — one DISTINCT pluck on executions, one pluck on
164
+ # tenants — regardless of how many tenant_ids exist.
165
+ #
166
+ # @return [Array<Hash>] Entries shaped as { value:, label: }
159
167
  # @api public
160
168
  def available_tenants
161
169
  return @available_tenants if defined?(@available_tenants)
162
170
 
163
- @available_tenants = RubyLLM::Agents::Execution
171
+ tenant_ids = RubyLLM::Agents::Execution
164
172
  .where.not(tenant_id: nil)
165
173
  .distinct
166
174
  .pluck(:tenant_id)
167
- .sort
175
+
176
+ names_by_id = RubyLLM::Agents::Tenant
177
+ .where(tenant_id: tenant_ids)
178
+ .pluck(:tenant_id, :name)
179
+ .to_h
180
+
181
+ @available_tenants = tenant_ids
182
+ .map { |id| {value: id, label: (names_by_id[id].presence || id).to_s} }
183
+ .sort_by { |t| t[:label].downcase }
168
184
  end
169
185
  helper_method :available_tenants
170
186
  end)
@@ -115,6 +115,15 @@ module RubyLLM
115
115
  @ask_message || options[:message] || super
116
116
  end
117
117
 
118
+ # Override call to capture the caller's stream block so it can be
119
+ # forwarded to the delegated agent. Without this, chunks from the
120
+ # delegated agent are swallowed because build_result has no access
121
+ # to the original block.
122
+ def call(&block)
123
+ @delegation_stream_block = block
124
+ super
125
+ end
126
+
118
127
  # Override process_response to parse the route from LLM output.
119
128
  def process_response(response)
120
129
  raw = response.content.to_s.strip.downcase.gsub(/[^a-z0-9_]/, "")
@@ -131,25 +140,39 @@ module RubyLLM
131
140
  end
132
141
 
133
142
  # Override build_result to return a RoutingResult.
134
- # Auto-delegates to the mapped agent when the route has an `agent:` mapping.
143
+ # Auto-delegates to the mapped agent when the route has an `agent:` mapping,
144
+ # unless the caller opts out with `auto_delegate: false`.
135
145
  def build_result(content, response, context)
136
146
  base = super
137
147
 
138
- # Auto-delegate to the mapped agent
139
148
  agent_class = content[:agent_class]
140
- if agent_class
141
- content[:delegated_result] = agent_class.call(**delegation_params)
149
+ if agent_class && auto_delegate?
150
+ content[:delegated_result] = if @delegation_stream_block
151
+ agent_class.call(**delegation_params, &@delegation_stream_block)
152
+ else
153
+ agent_class.call(**delegation_params)
154
+ end
142
155
  end
143
156
 
144
157
  RoutingResult.new(base_result: base, route_data: content)
145
158
  end
146
159
 
160
+ # Whether auto-delegation to the mapped agent is enabled for this call.
161
+ # Defaults to true. Pass `auto_delegate: false` to receive a
162
+ # classification-only RoutingResult with `delegated? == false` and
163
+ # `agent_class` set so the caller can invoke it manually.
164
+ #
165
+ # @return [Boolean]
166
+ def auto_delegate?
167
+ @options.fetch(:auto_delegate, true)
168
+ end
169
+
147
170
  # Builds params to forward to the delegated agent.
148
171
  # Forwards original message and custom params, excludes routing internals.
149
172
  #
150
173
  # @return [Hash] Params for the delegated agent
151
174
  def delegation_params
152
- forward = @options.except(:dry_run, :skip_cache, :debug, :stream_events)
175
+ forward = @options.except(:dry_run, :skip_cache, :debug, :stream_events, :auto_delegate)
153
176
  forward[:_parent_execution_id] = @parent_execution_id if @parent_execution_id
154
177
  forward[:_root_execution_id] = @root_execution_id if @root_execution_id
155
178
  forward
@@ -96,6 +96,7 @@ if defined?(Rails)
96
96
  require_relative "agents/core/inflections"
97
97
  require_relative "agents/core/instrumentation"
98
98
  require_relative "agents/infrastructure/execution_logger_job"
99
+ require_relative "agents/infrastructure/retention_job"
99
100
  end
100
101
  require_relative "agents/rails/engine" if defined?(Rails::Engine)
101
102
 
@@ -7,6 +7,13 @@ namespace :ruby_llm_agents do
7
7
  RubyLlmAgents::DoctorGenerator.start([])
8
8
  end
9
9
 
10
+ desc "Run the retention job synchronously (soft + hard purges per configuration)"
11
+ task purge: :environment do
12
+ result = RubyLLM::Agents::RetentionJob.new.perform
13
+ puts "Soft purged: #{result[:soft_purged]} executions (details destroyed)"
14
+ puts "Hard purged: #{result[:hard_purged]} executions (rows destroyed)"
15
+ end
16
+
10
17
  desc "Rename an agent type in execution records. Usage: rake ruby_llm_agents:rename_agent FROM=OldName TO=NewName [DRY_RUN=1]"
11
18
  task rename_agent: :environment do
12
19
  from = ENV["FROM"]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.12.0
4
+ version: 3.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: 1.12.0
32
+ version: 1.16.0
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: 1.12.0
39
+ version: 1.16.0
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