ruby_llm-agents 3.8.0 → 3.10.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -0
  4. data/app/models/ruby_llm/agents/execution.rb +4 -0
  5. data/app/models/ruby_llm/agents/tool_execution.rb +25 -0
  6. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  7. data/app/views/ruby_llm/agents/requests/index.html.erb +153 -0
  8. data/app/views/ruby_llm/agents/requests/show.html.erb +136 -0
  9. data/config/routes.rb +2 -0
  10. data/lib/generators/ruby_llm_agents/agent_generator.rb +2 -2
  11. data/lib/generators/ruby_llm_agents/demo_generator.rb +102 -0
  12. data/lib/generators/ruby_llm_agents/doctor_generator.rb +196 -0
  13. data/lib/generators/ruby_llm_agents/install_generator.rb +7 -19
  14. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +27 -80
  15. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +18 -51
  16. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +19 -17
  17. data/lib/ruby_llm/agents/base_agent.rb +70 -7
  18. data/lib/ruby_llm/agents/core/base.rb +4 -0
  19. data/lib/ruby_llm/agents/core/configuration.rb +12 -0
  20. data/lib/ruby_llm/agents/core/errors.rb +3 -0
  21. data/lib/ruby_llm/agents/core/version.rb +1 -1
  22. data/lib/ruby_llm/agents/pipeline/context.rb +26 -0
  23. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +58 -4
  24. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +17 -17
  25. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +34 -22
  26. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +105 -50
  27. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +7 -5
  28. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +6 -4
  29. data/lib/ruby_llm/agents/rails/engine.rb +11 -0
  30. data/lib/ruby_llm/agents/results/background_removal_result.rb +7 -1
  31. data/lib/ruby_llm/agents/results/base.rb +39 -2
  32. data/lib/ruby_llm/agents/results/embedding_result.rb +4 -0
  33. data/lib/ruby_llm/agents/results/image_analysis_result.rb +7 -1
  34. data/lib/ruby_llm/agents/results/image_edit_result.rb +7 -1
  35. data/lib/ruby_llm/agents/results/image_generation_result.rb +7 -1
  36. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +7 -1
  37. data/lib/ruby_llm/agents/results/image_transform_result.rb +7 -1
  38. data/lib/ruby_llm/agents/results/image_upscale_result.rb +7 -1
  39. data/lib/ruby_llm/agents/results/image_variation_result.rb +7 -1
  40. data/lib/ruby_llm/agents/results/speech_result.rb +6 -0
  41. data/lib/ruby_llm/agents/results/trackable.rb +25 -0
  42. data/lib/ruby_llm/agents/results/transcription_result.rb +6 -0
  43. data/lib/ruby_llm/agents/text/embedder.rb +7 -4
  44. data/lib/ruby_llm/agents/tool.rb +169 -0
  45. data/lib/ruby_llm/agents/tool_context.rb +71 -0
  46. data/lib/ruby_llm/agents/track_report.rb +127 -0
  47. data/lib/ruby_llm/agents/tracker.rb +32 -0
  48. data/lib/ruby_llm/agents.rb +212 -0
  49. data/lib/tasks/ruby_llm_agents.rake +6 -0
  50. metadata +13 -2
@@ -23,6 +23,8 @@ module RubyLLM
23
23
  #
24
24
  # @api public
25
25
  class SpeechResult
26
+ include Trackable
27
+
26
28
  # @!group Audio Content
27
29
 
28
30
  # @!attribute [r] audio
@@ -229,6 +231,10 @@ module RubyLLM
229
231
 
230
232
  # Execution record
231
233
  @execution_id = attributes[:execution_id]
234
+
235
+ # Tracking
236
+ @agent_class_name = attributes[:agent_class_name]
237
+ register_with_tracker
232
238
  end
233
239
 
234
240
  # Loads the associated Execution record from the database
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Mixin that registers a result object with the active Tracker.
6
+ #
7
+ # Included in every result class so that RubyLLM::Agents.track
8
+ # can collect results automatically.
9
+ #
10
+ # @api private
11
+ module Trackable
12
+ def self.included(base)
13
+ base.attr_reader :agent_class_name unless base.method_defined?(:agent_class_name)
14
+ end
15
+
16
+ private
17
+
18
+ # Call from the end of initialize to register with the active tracker
19
+ def register_with_tracker
20
+ tracker = Thread.current[:ruby_llm_agents_tracker]
21
+ tracker << self if tracker
22
+ end
23
+ end
24
+ end
25
+ end
@@ -26,6 +26,8 @@ module RubyLLM
26
26
  #
27
27
  # @api public
28
28
  class TranscriptionResult
29
+ include Trackable
30
+
29
31
  # @!group Content
30
32
 
31
33
  # @!attribute [r] text
@@ -250,6 +252,10 @@ module RubyLLM
250
252
 
251
253
  # Execution record
252
254
  @execution_id = attributes[:execution_id]
255
+
256
+ # Tracking
257
+ @agent_class_name = attributes[:agent_class_name]
258
+ register_with_tracker
253
259
  end
254
260
 
255
261
  # Loads the associated Execution record from the database
@@ -337,11 +337,14 @@ module RubyLLM
337
337
  embed_options = {model: context&.model || resolved_model}
338
338
  embed_options[:dimensions] = resolved_dimensions if resolved_dimensions
339
339
 
340
- # Pass scoped RubyLLM context for thread-safe per-tenant API keys
340
+ # Use scoped RubyLLM::Context for thread-safe per-tenant API keys.
341
+ # RubyLLM::Context#embed creates an Embedding with the scoped config.
341
342
  llm_ctx = context&.llm
342
- embed_options[:context] = llm_ctx if llm_ctx.is_a?(RubyLLM::Context)
343
-
344
- response = RubyLLM.embed(preprocessed, **embed_options)
343
+ response = if llm_ctx.is_a?(RubyLLM::Context)
344
+ llm_ctx.embed(preprocessed, **embed_options)
345
+ else
346
+ RubyLLM.embed(preprocessed, **embed_options)
347
+ end
345
348
 
346
349
  # ruby_llm returns vectors as an array (even for single text)
347
350
  vectors = response.vectors
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ # Base class for tools that need access to the agent's execution context.
8
+ #
9
+ # Inherits from RubyLLM::Tool and adds:
10
+ # - `context` accessor: read agent params, tenant, execution ID
11
+ # - `timeout` DSL: per-tool timeout in seconds
12
+ # - Error handling: exceptions become error strings for the LLM
13
+ # - Tool execution tracking: records each tool call in the database
14
+ #
15
+ # Users implement `execute()` — the standard RubyLLM convention.
16
+ # This class overrides `call()` to wrap execution with its features.
17
+ #
18
+ # @example Defining a tool
19
+ # class BashTool < RubyLLM::Agents::Tool
20
+ # description "Run a shell command"
21
+ # timeout 30
22
+ #
23
+ # param :command, desc: "The command to run", required: true
24
+ #
25
+ # def execute(command:)
26
+ # context.container_id # reads agent param
27
+ # # ... run command ...
28
+ # end
29
+ # end
30
+ #
31
+ # @example Using with an agent
32
+ # class CodingAgent < ApplicationAgent
33
+ # param :container_id, required: true
34
+ # tools [BashTool]
35
+ # end
36
+ #
37
+ # CodingAgent.call(query: "list files", container_id: "abc123")
38
+ #
39
+ class Tool < RubyLLM::Tool
40
+ # The execution context, set before each call.
41
+ # Provides access to agent params, tenant, execution ID.
42
+ #
43
+ # @return [ToolContext, nil]
44
+ attr_reader :context
45
+
46
+ class << self
47
+ # Sets or gets the per-tool timeout in seconds.
48
+ #
49
+ # @param value [Integer, nil] Timeout in seconds (setter)
50
+ # @return [Integer, nil] The configured timeout (getter)
51
+ def timeout(value = nil)
52
+ if value
53
+ @timeout = value
54
+ else
55
+ @timeout
56
+ end
57
+ end
58
+ end
59
+
60
+ # Wraps RubyLLM's call() with context, timeout, tracking, and error handling.
61
+ #
62
+ # RubyLLM's Chat calls tool.call(args) during the tool loop.
63
+ # We set up context, create a tracking record, apply timeout,
64
+ # then delegate to super (which validates args and calls execute).
65
+ #
66
+ # @param args [Hash] Tool arguments from the LLM
67
+ # @return [String, Tool::Halt] The tool result or a Halt signal
68
+ def call(args)
69
+ pipeline_context = Thread.current[:ruby_llm_agents_caller_context]
70
+ @context = pipeline_context ? ToolContext.new(pipeline_context) : nil
71
+
72
+ record = start_tool_tracking(pipeline_context, args)
73
+
74
+ check_cancelled!(pipeline_context)
75
+
76
+ timeout_seconds = self.class.timeout
77
+ timeout_seconds ||= RubyLLM::Agents.configuration.default_tool_timeout
78
+
79
+ result = if timeout_seconds
80
+ Timeout.timeout(timeout_seconds) { super }
81
+ else
82
+ super
83
+ end
84
+
85
+ complete_tool_tracking(record, result, status: "success")
86
+ result
87
+ rescue Timeout::Error
88
+ complete_tool_tracking(record, nil, status: "timed_out", error: "Timed out after #{timeout_seconds}s")
89
+ "TIMEOUT: Tool did not complete within #{timeout_seconds}s."
90
+ rescue RubyLLM::Agents::CancelledError
91
+ complete_tool_tracking(record, nil, status: "cancelled")
92
+ raise # Let cancellation propagate to BaseAgent
93
+ rescue => e
94
+ complete_tool_tracking(record, nil, status: "error", error: e.message)
95
+ "ERROR (#{e.class}): #{e.message}"
96
+ end
97
+
98
+ private
99
+
100
+ # Creates a "running" ToolExecution record before the tool runs.
101
+ # Silently skips if no execution_id or ToolExecution is not available.
102
+ #
103
+ # @param pipeline_context [Pipeline::Context, nil]
104
+ # @param args [Hash] The tool arguments
105
+ # @return [ToolExecution, nil]
106
+ def start_tool_tracking(pipeline_context, args)
107
+ return nil unless pipeline_context&.execution_id
108
+ return nil unless defined?(ToolExecution)
109
+
110
+ @tool_iteration = (@tool_iteration || 0) + 1
111
+
112
+ ToolExecution.create!(
113
+ execution_id: pipeline_context.execution_id,
114
+ tool_name: name,
115
+ iteration: @tool_iteration,
116
+ status: "running",
117
+ input: normalize_input(args),
118
+ started_at: Time.current
119
+ )
120
+ rescue => e
121
+ # Don't let tracking failures break tool execution
122
+ Rails.logger.debug("[RubyLLM::Agents::Tool] Tracking failed: #{e.message}") if defined?(Rails) && Rails.logger
123
+ nil
124
+ end
125
+
126
+ # Updates the ToolExecution record after the tool completes.
127
+ #
128
+ # @param record [ToolExecution, nil]
129
+ # @param result [Object, nil] The tool result
130
+ # @param status [String] Final status
131
+ # @param error [String, nil] Error message
132
+ def complete_tool_tracking(record, result, status:, error: nil)
133
+ return unless record
134
+
135
+ completed_at = Time.current
136
+ duration_ms = record.started_at ? ((completed_at - record.started_at) * 1000).to_i : nil
137
+ output_str = result.is_a?(RubyLLM::Tool::Halt) ? result.content.to_s : result.to_s
138
+
139
+ record.update!(
140
+ status: status,
141
+ output: truncate_output(output_str),
142
+ output_bytes: output_str.bytesize,
143
+ error_message: error,
144
+ completed_at: completed_at,
145
+ duration_ms: duration_ms
146
+ )
147
+ rescue => e
148
+ Rails.logger.debug("[RubyLLM::Agents::Tool] Tracking update failed: #{e.message}") if defined?(Rails) && Rails.logger
149
+ end
150
+
151
+ def check_cancelled!(pipeline_context)
152
+ return unless pipeline_context
153
+ on_cancelled = pipeline_context[:on_cancelled]
154
+ return unless on_cancelled.respond_to?(:call)
155
+ raise CancelledError, "Execution cancelled" if on_cancelled.call
156
+ end
157
+
158
+ def normalize_input(args)
159
+ return {} if args.nil?
160
+ args.respond_to?(:to_h) ? args.to_h : {}
161
+ end
162
+
163
+ def truncate_output(str)
164
+ max = RubyLLM::Agents.configuration.try(:tool_result_max_length) || 10_000
165
+ (str.length > max) ? str[0, max] + "... (truncated)" : str
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Read-only wrapper around Pipeline::Context for tool authors.
6
+ #
7
+ # Exposes agent params and execution metadata to tools without
8
+ # leaking pipeline internals. Supports both method-style and
9
+ # hash-style access to agent params.
10
+ #
11
+ # @example Method-style access
12
+ # context.container_id # reads agent param
13
+ # context.tenant_id # fixed attribute
14
+ #
15
+ # @example Hash-style access
16
+ # context[:container_id]
17
+ #
18
+ class ToolContext
19
+ # Execution record ID — links tool calls to the agent execution
20
+ #
21
+ # @return [Integer, nil]
22
+ def id
23
+ @ctx.execution_id
24
+ end
25
+
26
+ # Tenant ID from the pipeline context
27
+ #
28
+ # @return [String, nil]
29
+ def tenant_id
30
+ @ctx.tenant_id
31
+ end
32
+
33
+ # Agent class name
34
+ #
35
+ # @return [String, nil]
36
+ def agent_type
37
+ @ctx.agent_class&.name
38
+ end
39
+
40
+ # Hash-style access to agent params
41
+ #
42
+ # @param key [Symbol, String] The param key
43
+ # @return [Object, nil]
44
+ def [](key)
45
+ @agent_options[key.to_sym] || @agent_options[key.to_s]
46
+ end
47
+
48
+ def initialize(pipeline_context)
49
+ @ctx = pipeline_context
50
+ @agent_options = @ctx.agent_instance&.send(:options) || {}
51
+ end
52
+
53
+ private
54
+
55
+ # Method-style access to agent params
56
+ def method_missing(method_name, *args)
57
+ key = method_name.to_sym
58
+ if @agent_options.key?(key) || @agent_options.key?(key.to_s)
59
+ self[key]
60
+ else
61
+ super
62
+ end
63
+ end
64
+
65
+ def respond_to_missing?(method_name, include_private = false)
66
+ key = method_name.to_sym
67
+ @agent_options.key?(key) || @agent_options.key?(key.to_s) || super
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Aggregated read-only report returned by RubyLLM::Agents.track.
6
+ #
7
+ # Provides totals and breakdowns across all agent calls made
8
+ # inside the tracked block.
9
+ #
10
+ # @example
11
+ # report = RubyLLM::Agents.track do
12
+ # TranscribeAgent.call(with: audio_path)
13
+ # ChatAgent.call(message: "hello")
14
+ # end
15
+ # report.total_cost # => 0.0078
16
+ # report.call_count # => 2
17
+ #
18
+ # @api public
19
+ class TrackReport
20
+ attr_reader :value, :error, :results, :request_id
21
+ attr_reader :started_at, :completed_at
22
+
23
+ def initialize(value:, error:, results:, request_id:, started_at:, completed_at:)
24
+ @value = value
25
+ @error = error
26
+ @results = results.freeze
27
+ @request_id = request_id
28
+ @started_at = started_at
29
+ @completed_at = completed_at
30
+ end
31
+
32
+ def successful?
33
+ @error.nil?
34
+ end
35
+
36
+ def failed?
37
+ !successful?
38
+ end
39
+
40
+ def call_count
41
+ @results.size
42
+ end
43
+
44
+ def total_cost
45
+ @results.sum { |r| r.total_cost || 0 }
46
+ end
47
+
48
+ def input_cost
49
+ @results.sum { |r| r.input_cost || 0 }
50
+ end
51
+
52
+ def output_cost
53
+ @results.sum { |r| r.output_cost || 0 }
54
+ end
55
+
56
+ def total_tokens
57
+ @results.sum { |r| r.total_tokens }
58
+ end
59
+
60
+ def input_tokens
61
+ @results.sum { |r| r.input_tokens || 0 }
62
+ end
63
+
64
+ def output_tokens
65
+ @results.sum { |r| r.output_tokens || 0 }
66
+ end
67
+
68
+ def duration_ms
69
+ return nil unless @started_at && @completed_at
70
+ ((@completed_at - @started_at) * 1000).to_i
71
+ end
72
+
73
+ def all_successful?
74
+ @results.all?(&:success?)
75
+ end
76
+
77
+ def any_errors?
78
+ @results.any?(&:error?)
79
+ end
80
+
81
+ def errors
82
+ @results.select(&:error?)
83
+ end
84
+
85
+ def successful
86
+ @results.select(&:success?)
87
+ end
88
+
89
+ def models_used
90
+ @results.filter_map(&:chosen_model_id).uniq
91
+ end
92
+
93
+ def cost_breakdown
94
+ @results.map do |r|
95
+ {
96
+ agent: r.respond_to?(:agent_class_name) ? r.agent_class_name : nil,
97
+ model: r.chosen_model_id,
98
+ cost: r.total_cost || 0,
99
+ tokens: r.total_tokens,
100
+ duration_ms: r.duration_ms
101
+ }
102
+ end
103
+ end
104
+
105
+ def to_h
106
+ {
107
+ successful: successful?,
108
+ value: value,
109
+ error: error&.message,
110
+ request_id: request_id,
111
+ call_count: call_count,
112
+ total_cost: total_cost,
113
+ input_cost: input_cost,
114
+ output_cost: output_cost,
115
+ total_tokens: total_tokens,
116
+ input_tokens: input_tokens,
117
+ output_tokens: output_tokens,
118
+ duration_ms: duration_ms,
119
+ started_at: started_at,
120
+ completed_at: completed_at,
121
+ models_used: models_used,
122
+ cost_breakdown: cost_breakdown
123
+ }
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Internal collector used by RubyLLM::Agents.track to accumulate
6
+ # Result objects produced during a tracked block.
7
+ #
8
+ # Not part of the public API — users interact with TrackReport instead.
9
+ #
10
+ # @api private
11
+ class Tracker
12
+ attr_reader :results, :defaults, :request_id, :tags
13
+
14
+ def initialize(defaults: {}, request_id: nil, tags: {})
15
+ @results = []
16
+ @defaults = defaults
17
+ @request_id = request_id || generate_request_id
18
+ @tags = tags
19
+ end
20
+
21
+ def <<(result)
22
+ @results << result
23
+ end
24
+
25
+ private
26
+
27
+ def generate_request_id
28
+ "track_#{SecureRandom.hex(8)}"
29
+ end
30
+ end
31
+ end
32
+ end