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
@@ -332,6 +332,14 @@ module RubyLLM
332
332
  # @param temperature [Float] Override the class-level temperature
333
333
  # @param options [Hash] Agent parameters defined via the param DSL
334
334
  def initialize(model: self.class.model, temperature: self.class.temperature, **options)
335
+ # Merge tracker defaults (shared options like tenant) — explicit opts win
336
+ tracker = Thread.current[:ruby_llm_agents_tracker]
337
+ if tracker
338
+ options = tracker.defaults.merge(options)
339
+ @_track_request_id = tracker.request_id
340
+ @_track_tags = tracker.tags
341
+ end
342
+
335
343
  @ask_message = options.delete(:_ask_message)
336
344
  @parent_execution_id = options.delete(:_parent_execution_id)
337
345
  @root_execution_id = options.delete(:_root_execution_id)
@@ -506,6 +514,7 @@ module RubyLLM
506
514
  stream_block: (block if streaming_enabled?),
507
515
  parent_execution_id: @parent_execution_id,
508
516
  root_execution_id: @root_execution_id,
517
+ debug: @options[:debug],
509
518
  options: execution_options
510
519
  )
511
520
  end
@@ -721,6 +730,12 @@ module RubyLLM
721
730
  capture_response(response, context)
722
731
  result = build_result(process_response(response), response, context)
723
732
  context.output = result
733
+ rescue RubyLLM::Agents::CancelledError
734
+ context.output = Result.new(content: nil, cancelled: true)
735
+ rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
736
+ raise_with_setup_hint(e, context)
737
+ rescue RubyLLM::ModelNotFoundError => e
738
+ raise_with_model_hint(e, context)
724
739
  ensure
725
740
  Thread.current[:ruby_llm_agents_caller_context] = previous_context
726
741
  end
@@ -733,12 +748,16 @@ module RubyLLM
733
748
  effective_model = context&.model || model
734
749
  chat_opts = {model: effective_model}
735
750
 
736
- # Pass scoped RubyLLM context for thread-safe per-tenant API keys
751
+ # Use scoped RubyLLM::Context for thread-safe per-tenant API keys.
752
+ # RubyLLM::Context#chat creates a Chat with the scoped config,
753
+ # so we call .chat on the context instead of RubyLLM.chat.
737
754
  llm_ctx = context&.llm
738
- chat_opts[:context] = llm_ctx if llm_ctx.is_a?(RubyLLM::Context)
739
-
740
- client = RubyLLM.chat(**chat_opts)
741
- .with_temperature(temperature)
755
+ client = if llm_ctx.is_a?(RubyLLM::Context)
756
+ llm_ctx.chat(**chat_opts)
757
+ else
758
+ RubyLLM.chat(**chat_opts)
759
+ end
760
+ client = client.with_temperature(temperature)
742
761
 
743
762
  client = client.with_instructions(system_prompt) if system_prompt
744
763
  client = client.with_schema(schema) if schema
@@ -889,8 +908,9 @@ module RubyLLM
889
908
  # @param context [Pipeline::Context] The context
890
909
  # @return [Result] The result object
891
910
  def build_result(content, response, context)
892
- Result.new(
911
+ result_opts = {
893
912
  content: content,
913
+ agent_class_name: self.class.name,
894
914
  input_tokens: context.input_tokens,
895
915
  output_tokens: context.output_tokens,
896
916
  input_cost: context.input_cost,
@@ -907,7 +927,12 @@ module RubyLLM
907
927
  streaming: streaming_enabled?,
908
928
  attempts_count: context.attempts_made || 1,
909
929
  execution_id: context.execution_id
910
- )
930
+ }
931
+
932
+ # Attach pipeline trace when debug mode is enabled
933
+ result_opts[:trace] = context.trace if context.trace_enabled? && context.trace.any?
934
+
935
+ Result.new(**result_opts)
911
936
  end
912
937
 
913
938
  # Extracts thinking data from a response for inclusion in Result
@@ -1077,6 +1102,44 @@ module RubyLLM
1077
1102
  tool_call[key] || tool_call[key.to_s]
1078
1103
  end
1079
1104
  end
1105
+
1106
+ # Re-raises auth errors with actionable setup guidance
1107
+ def raise_with_setup_hint(error, context)
1108
+ effective_model = context&.model || model
1109
+ provider = detect_provider(effective_model)
1110
+
1111
+ hint = "#{self.class.name} failed: #{error.message}\n\n" \
1112
+ "The API key for #{provider || "your provider"} is missing or invalid.\n" \
1113
+ "Fix: Set the key in config/initializers/ruby_llm_agents.rb\n" \
1114
+ " or run: rails ruby_llm_agents:doctor"
1115
+
1116
+ raise RubyLLM::Agents::ConfigurationError, hint
1117
+ end
1118
+
1119
+ # Re-raises model errors with actionable guidance
1120
+ def raise_with_model_hint(error, context)
1121
+ effective_model = context&.model || model
1122
+
1123
+ hint = "#{self.class.name} failed: #{error.message}\n\n" \
1124
+ "Model '#{effective_model}' was not found.\n" \
1125
+ "Fix: Check the model name or set a default in your initializer:\n" \
1126
+ " config.default_model = \"gpt-4o\""
1127
+
1128
+ raise RubyLLM::Agents::ConfigurationError, hint
1129
+ end
1130
+
1131
+ # Best-effort provider detection from model name
1132
+ def detect_provider(model_id)
1133
+ return nil unless model_id
1134
+
1135
+ case model_id.to_s
1136
+ when /gpt|o[1-9]|dall-e|whisper|tts/i then "OpenAI"
1137
+ when /claude/i then "Anthropic"
1138
+ when /gemini|gemma/i then "Google (Gemini)"
1139
+ when /deepseek/i then "DeepSeek"
1140
+ when /mistral|mixtral/i then "Mistral"
1141
+ end
1142
+ end
1080
1143
  end
1081
1144
  end
1082
1145
  end
@@ -87,6 +87,10 @@ module RubyLLM
87
87
  run_callbacks(:after, context, response)
88
88
 
89
89
  context.output = build_result(processed_content, response, context)
90
+ rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
91
+ raise_with_setup_hint(e, context)
92
+ rescue RubyLLM::ModelNotFoundError => e
93
+ raise_with_model_hint(e, context)
90
94
  end
91
95
 
92
96
  # Returns the resolved tenant ID for tracking
@@ -387,6 +387,7 @@ module RubyLLM
387
387
  :default_total_timeout,
388
388
  :default_streaming,
389
389
  :default_tools,
390
+ :default_tool_timeout,
390
391
  :default_thinking,
391
392
  :on_alert,
392
393
  :persist_prompts,
@@ -639,6 +640,7 @@ module RubyLLM
639
640
  # Streaming, tools, and thinking defaults
640
641
  @default_streaming = false
641
642
  @default_tools = []
643
+ @default_tool_timeout = nil
642
644
  @default_thinking = nil
643
645
 
644
646
  # Governance defaults
@@ -819,6 +821,16 @@ module RubyLLM
819
821
  tenant_resolver&.call
820
822
  end
821
823
 
824
+ # Returns a concise string representation for debugging
825
+ #
826
+ # @return [String] Summary of key configuration values
827
+ def inspect
828
+ "#<#{self.class} model=#{default_model.inspect} temperature=#{default_temperature} " \
829
+ "timeout=#{default_timeout} streaming=#{default_streaming} " \
830
+ "multi_tenancy=#{multi_tenancy_enabled} async_logging=#{async_logging} " \
831
+ "track_executions=#{track_executions}>"
832
+ end
833
+
822
834
  # Returns whether the async gem is available
823
835
  #
824
836
  # @return [Boolean] true if async gem is loaded
@@ -29,6 +29,9 @@ module RubyLLM
29
29
  # Raised when an execution cannot be replayed
30
30
  class ReplayError < Error; end
31
31
 
32
+ # Raised when an agent execution is cancelled via on_cancelled
33
+ class CancelledError < Error; end
34
+
32
35
  # Raised when the TTS API returns an error response
33
36
  class SpeechApiError < Error
34
37
  attr_reader :status, :response_body
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.8.0"
7
+ VERSION = "3.10.0"
8
8
  end
9
9
  end
@@ -46,6 +46,9 @@ module RubyLLM
46
46
  # Response metadata
47
47
  attr_accessor :model_used, :finish_reason, :time_to_first_token_ms
48
48
 
49
+ # Debug trace (set when debug: true is passed)
50
+ attr_accessor :trace
51
+
49
52
  # Streaming support
50
53
  attr_accessor :stream_block, :skip_cache
51
54
 
@@ -85,6 +88,10 @@ module RubyLLM
85
88
  @skip_cache = skip_cache
86
89
  @stream_block = stream_block
87
90
 
91
+ # Debug trace
92
+ @trace = []
93
+ @trace_enabled = options[:debug] == true
94
+
88
95
  # Initialize tracking fields
89
96
  @attempt = 0
90
97
  @attempts_made = 0
@@ -108,6 +115,23 @@ module RubyLLM
108
115
  ((@completed_at - @started_at) * 1000).to_i
109
116
  end
110
117
 
118
+ # Is debug tracing enabled?
119
+ #
120
+ # @return [Boolean]
121
+ def trace_enabled?
122
+ @trace_enabled
123
+ end
124
+
125
+ # Adds a trace entry for a middleware execution
126
+ #
127
+ # @param middleware_name [String] Name of the middleware
128
+ # @param started_at [Time] When the middleware started
129
+ # @param duration_ms [Float] How long the middleware took in ms
130
+ # @param action [String, nil] Optional action description (e.g., "cache hit")
131
+ def add_trace(middleware_name, started_at:, duration_ms:, action: nil)
132
+ @trace << {middleware: middleware_name, started_at: started_at, duration_ms: duration_ms, action: action}.compact
133
+ end
134
+
111
135
  # Was the result served from cache?
112
136
  #
113
137
  # @return [Boolean]
@@ -230,6 +254,8 @@ module RubyLLM
230
254
  # Preserve execution hierarchy
231
255
  new_ctx.parent_execution_id = @parent_execution_id
232
256
  new_ctx.root_execution_id = @root_execution_id
257
+ # Preserve trace across retries
258
+ new_ctx.trace = @trace
233
259
  new_ctx
234
260
  end
235
261
 
@@ -41,6 +41,8 @@ module RubyLLM
41
41
  # @abstract Subclass and implement {#call}
42
42
  #
43
43
  class Base
44
+ LOG_TAG = "[RubyLLM::Agents::Pipeline]"
45
+
44
46
  # @param app [#call] The next handler in the chain
45
47
  # @param agent_class [Class] The agent class (for reading DSL config)
46
48
  def initialize(app, agent_class)
@@ -100,22 +102,74 @@ module RubyLLM
100
102
  RubyLLM::Agents.configuration
101
103
  end
102
104
 
105
+ # Builds a log prefix with context from the execution
106
+ #
107
+ # Includes agent type, execution ID, and tenant when available
108
+ # so log messages can be traced through the full pipeline.
109
+ #
110
+ # @param context [Context, nil] The execution context
111
+ # @return [String] Formatted log prefix
112
+ def log_prefix(context = nil)
113
+ return LOG_TAG unless context
114
+
115
+ parts = [LOG_TAG]
116
+ parts << context.agent_class.name if context.agent_class
117
+ parts << "exec=#{context.execution_id}" if context.execution_id
118
+ parts << "tenant=#{context.tenant_id}" if context.tenant_id
119
+ parts.join(" ")
120
+ end
121
+
103
122
  # Log a debug message if Rails logger is available
104
123
  #
105
124
  # @param message [String] The message to log
106
- def debug(message)
125
+ # @param context [Context, nil] Optional execution context for structured prefix
126
+ def debug(message, context = nil)
107
127
  return unless defined?(Rails) && Rails.logger
108
128
 
109
- Rails.logger.debug("[RubyLLM::Agents::Pipeline] #{message}")
129
+ Rails.logger.debug("#{log_prefix(context)} #{message}")
110
130
  end
111
131
 
112
132
  # Log an error message if Rails logger is available
113
133
  #
114
134
  # @param message [String] The message to log
115
- def error(message)
135
+ # @param context [Context, nil] Optional execution context for structured prefix
136
+ def error(message, context = nil)
137
+ return unless defined?(Rails) && Rails.logger
138
+
139
+ Rails.logger.error("#{log_prefix(context)} #{message}")
140
+ end
141
+
142
+ # Traces middleware execution when debug mode is enabled.
143
+ #
144
+ # Wraps a block with timing instrumentation. When tracing is not
145
+ # enabled, yields directly with zero overhead.
146
+ #
147
+ # @param context [Context] The execution context
148
+ # @param action [String, nil] Optional action description
149
+ # @yield The block to trace
150
+ # @return [Object] The block's return value
151
+ def trace(context, action: nil)
152
+ unless context.trace_enabled?
153
+ return yield
154
+ end
155
+
156
+ middleware_name = self.class.name&.split("::")&.last || self.class.to_s
157
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
158
+ result = yield
159
+ duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at) * 1000).round(2)
160
+ context.add_trace(middleware_name, started_at: Time.current, duration_ms: duration_ms, action: action)
161
+ debug("#{middleware_name} completed in #{duration_ms}ms#{" (#{action})" if action}", context)
162
+ result
163
+ end
164
+
165
+ # Log a warning message if Rails logger is available
166
+ #
167
+ # @param message [String] The message to log
168
+ # @param context [Context, nil] Optional execution context for structured prefix
169
+ def warn(message, context = nil)
116
170
  return unless defined?(Rails) && Rails.logger
117
171
 
118
- Rails.logger.error("[RubyLLM::Agents::Pipeline] #{message}")
172
+ Rails.logger.warn("#{log_prefix(context)} #{message}")
119
173
  end
120
174
  end
121
175
  end
@@ -32,21 +32,23 @@ module RubyLLM
32
32
  def call(context)
33
33
  return @app.call(context) unless budgets_enabled?
34
34
 
35
- # Check budget before execution
36
- check_budget!(context)
35
+ trace(context) do
36
+ # Check budget before execution
37
+ check_budget!(context)
37
38
 
38
- # Execute the chain
39
- @app.call(context)
39
+ # Execute the chain
40
+ @app.call(context)
40
41
 
41
- # Record spend after successful execution (if not cached)
42
- if context.success? && !context.cached?
43
- record_spend!(context)
44
- emit_budget_notification("ruby_llm_agents.budget.record", context,
45
- total_cost: context.total_cost,
46
- total_tokens: context.total_tokens)
47
- end
42
+ # Record spend after successful execution (if not cached)
43
+ if context.success? && !context.cached?
44
+ record_spend!(context)
45
+ emit_budget_notification("ruby_llm_agents.budget.record", context,
46
+ total_cost: context.total_cost,
47
+ total_tokens: context.total_tokens)
48
+ end
48
49
 
49
- context
50
+ context
51
+ end
50
52
  end
51
53
 
52
54
  private
@@ -65,7 +67,7 @@ module RubyLLM
65
67
  }.merge(extras)
66
68
  )
67
69
  rescue => e
68
- debug("Budget notification failed: #{e.message}")
70
+ debug("Budget notification failed: #{e.message}", context)
69
71
  end
70
72
 
71
73
  # Returns whether budgets are enabled globally
@@ -106,7 +108,7 @@ module RubyLLM
106
108
  raise
107
109
  rescue => e
108
110
  # Log at error level so unexpected failures are visible in logs
109
- error("Budget check failed: #{e.class}: #{e.message}")
111
+ error("Budget check failed: #{e.class}: #{e.message}", context)
110
112
  end
111
113
 
112
114
  # Records spend after execution
@@ -122,7 +124,7 @@ module RubyLLM
122
124
  tenant.record_execution!(
123
125
  cost: context.total_cost || 0,
124
126
  tokens: context.total_tokens || 0,
125
- error: context.error?
127
+ error: context.failed?
126
128
  )
127
129
  return
128
130
  end
@@ -144,8 +146,6 @@ module RubyLLM
144
146
  tenant_id: context.tenant_id
145
147
  )
146
148
  end
147
- rescue => e
148
- error("Failed to record spend: #{e.message}")
149
149
  end
150
150
  end
151
151
  end
@@ -31,34 +31,46 @@ module RubyLLM
31
31
  def call(context)
32
32
  return @app.call(context) unless cache_enabled?
33
33
 
34
- cache_key = generate_cache_key(context)
35
-
36
- # Skip cache read if skip_cache is true
37
- unless context.skip_cache
38
- # Try to read from cache
39
- if (cached = cache_read(cache_key))
40
- context.output = cached
41
- context.cached = true
42
- context[:cache_key] = cache_key
43
- debug("Cache hit for #{cache_key}")
44
- emit_cache_notification("ruby_llm_agents.cache.hit", cache_key)
45
- return context
34
+ cache_action = nil
35
+ result = trace(context, action: "cache") do
36
+ cache_key = generate_cache_key(context)
37
+
38
+ # Skip cache read if skip_cache is true
39
+ unless context.skip_cache
40
+ # Try to read from cache
41
+ if (cached = cache_read(cache_key))
42
+ context.output = cached
43
+ context.cached = true
44
+ context[:cache_key] = cache_key
45
+ cache_action = "hit"
46
+ debug("Cache hit for #{cache_key}", context)
47
+ emit_cache_notification("ruby_llm_agents.cache.hit", cache_key)
48
+ next context
49
+ end
46
50
  end
47
- end
48
51
 
49
- emit_cache_notification("ruby_llm_agents.cache.miss", cache_key)
52
+ cache_action = "miss"
53
+ emit_cache_notification("ruby_llm_agents.cache.miss", cache_key)
54
+
55
+ # Execute the chain
56
+ @app.call(context)
50
57
 
51
- # Execute the chain
52
- @app.call(context)
58
+ # Cache successful results
59
+ if context.success?
60
+ cache_write(cache_key, context.output)
61
+ debug("Cache write for #{cache_key}", context)
62
+ emit_cache_notification("ruby_llm_agents.cache.write", cache_key)
63
+ end
64
+
65
+ context
66
+ end
53
67
 
54
- # Cache successful results
55
- if context.success?
56
- cache_write(cache_key, context.output)
57
- debug("Cache write for #{cache_key}")
58
- emit_cache_notification("ruby_llm_agents.cache.write", cache_key)
68
+ # Update the last trace entry with the specific cache action
69
+ if context.trace_enabled? && cache_action && context.trace.last
70
+ context.trace.last[:action] = cache_action
59
71
  end
60
72
 
61
- context
73
+ result
62
74
  end
63
75
 
64
76
  private