ruby_llm-agents 3.7.2 → 3.9.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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/app/controllers/ruby_llm/agents/agents_controller.rb +14 -141
  4. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +12 -166
  5. data/app/controllers/ruby_llm/agents/executions_controller.rb +1 -1
  6. data/app/controllers/ruby_llm/agents/requests_controller.rb +117 -0
  7. data/app/helpers/ruby_llm/agents/application_helper.rb +38 -0
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +302 -103
  9. data/app/models/ruby_llm/agents/execution.rb +76 -54
  10. data/app/models/ruby_llm/agents/execution_detail.rb +2 -0
  11. data/app/models/ruby_llm/agents/tenant.rb +39 -0
  12. data/app/services/ruby_llm/agents/agent_registry.rb +98 -0
  13. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  14. data/app/views/ruby_llm/agents/executions/_list.html.erb +3 -17
  15. data/app/views/ruby_llm/agents/requests/index.html.erb +153 -0
  16. data/app/views/ruby_llm/agents/requests/show.html.erb +136 -0
  17. data/config/routes.rb +2 -0
  18. data/lib/generators/ruby_llm_agents/agent_generator.rb +2 -2
  19. data/lib/generators/ruby_llm_agents/demo_generator.rb +102 -0
  20. data/lib/generators/ruby_llm_agents/doctor_generator.rb +196 -0
  21. data/lib/generators/ruby_llm_agents/install_generator.rb +7 -19
  22. data/lib/generators/ruby_llm_agents/templates/add_dashboard_performance_indexes_migration.rb.tt +23 -0
  23. data/lib/generators/ruby_llm_agents/templates/agent.rb.tt +27 -80
  24. data/lib/generators/ruby_llm_agents/templates/application_agent.rb.tt +18 -51
  25. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +19 -17
  26. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +3 -0
  27. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +25 -0
  28. data/lib/ruby_llm/agents/base_agent.rb +71 -4
  29. data/lib/ruby_llm/agents/core/base.rb +4 -0
  30. data/lib/ruby_llm/agents/core/configuration.rb +11 -0
  31. data/lib/ruby_llm/agents/core/instrumentation.rb +15 -19
  32. data/lib/ruby_llm/agents/core/version.rb +1 -1
  33. data/lib/ruby_llm/agents/infrastructure/alert_manager.rb +4 -4
  34. data/lib/ruby_llm/agents/infrastructure/budget_tracker.rb +19 -11
  35. data/lib/ruby_llm/agents/pipeline/builder.rb +8 -4
  36. data/lib/ruby_llm/agents/pipeline/context.rb +69 -1
  37. data/lib/ruby_llm/agents/pipeline/middleware/base.rb +58 -4
  38. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +21 -17
  39. data/lib/ruby_llm/agents/pipeline/middleware/cache.rb +40 -26
  40. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +126 -120
  41. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +13 -11
  42. data/lib/ruby_llm/agents/pipeline/middleware/tenant.rb +29 -31
  43. data/lib/ruby_llm/agents/providers/inception/capabilities.rb +107 -0
  44. data/lib/ruby_llm/agents/providers/inception/chat.rb +17 -0
  45. data/lib/ruby_llm/agents/providers/inception/configuration.rb +9 -0
  46. data/lib/ruby_llm/agents/providers/inception/models.rb +38 -0
  47. data/lib/ruby_llm/agents/providers/inception/registry.rb +45 -0
  48. data/lib/ruby_llm/agents/providers/inception.rb +50 -0
  49. data/lib/ruby_llm/agents/rails/engine.rb +11 -0
  50. data/lib/ruby_llm/agents/results/background_removal_result.rb +7 -1
  51. data/lib/ruby_llm/agents/results/base.rb +28 -4
  52. data/lib/ruby_llm/agents/results/embedding_result.rb +4 -0
  53. data/lib/ruby_llm/agents/results/image_analysis_result.rb +11 -3
  54. data/lib/ruby_llm/agents/results/image_edit_result.rb +7 -1
  55. data/lib/ruby_llm/agents/results/image_generation_result.rb +7 -1
  56. data/lib/ruby_llm/agents/results/image_pipeline_result.rb +7 -1
  57. data/lib/ruby_llm/agents/results/image_transform_result.rb +7 -1
  58. data/lib/ruby_llm/agents/results/image_upscale_result.rb +7 -1
  59. data/lib/ruby_llm/agents/results/image_variation_result.rb +7 -1
  60. data/lib/ruby_llm/agents/results/speech_result.rb +6 -0
  61. data/lib/ruby_llm/agents/results/trackable.rb +25 -0
  62. data/lib/ruby_llm/agents/results/transcription_result.rb +6 -0
  63. data/lib/ruby_llm/agents/text/embedder.rb +8 -1
  64. data/lib/ruby_llm/agents/track_report.rb +127 -0
  65. data/lib/ruby_llm/agents/tracker.rb +32 -0
  66. data/lib/ruby_llm/agents.rb +212 -0
  67. data/lib/tasks/ruby_llm_agents.rake +6 -0
  68. metadata +17 -2
@@ -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]
@@ -136,6 +160,24 @@ module RubyLLM
136
160
  (@input_tokens || 0) + (@output_tokens || 0)
137
161
  end
138
162
 
163
+ # Returns a RubyLLM interface scoped to tenant API keys when present.
164
+ #
165
+ # When tenant API keys are stored on this context (by the Tenant middleware),
166
+ # returns a RubyLLM::Context with a cloned config that has tenant-specific
167
+ # keys applied. This avoids mutating global RubyLLM configuration, making
168
+ # multi-tenant LLM calls thread-safe.
169
+ #
170
+ # When no tenant API keys are present, returns the RubyLLM module directly
171
+ # (which uses the global configuration).
172
+ #
173
+ # @return [RubyLLM::Context, RubyLLM] Scoped context or global module
174
+ def llm
175
+ api_keys = self[:tenant_api_keys]
176
+ return RubyLLM if api_keys.nil? || api_keys.empty?
177
+
178
+ @llm_context ||= build_llm_context(api_keys)
179
+ end
180
+
139
181
  # Custom metadata storage - read
140
182
  #
141
183
  # @param key [Symbol, String] The metadata key
@@ -212,11 +254,31 @@ module RubyLLM
212
254
  # Preserve execution hierarchy
213
255
  new_ctx.parent_execution_id = @parent_execution_id
214
256
  new_ctx.root_execution_id = @root_execution_id
257
+ # Preserve trace across retries
258
+ new_ctx.trace = @trace
215
259
  new_ctx
216
260
  end
217
261
 
218
262
  private
219
263
 
264
+ # Builds a RubyLLM::Context with tenant-specific API keys
265
+ #
266
+ # Clones the global RubyLLM config and overlays tenant API keys,
267
+ # then wraps it in a RubyLLM::Context for thread-safe per-request use.
268
+ #
269
+ # @param api_keys [Hash] Provider => key mappings (e.g., {openai: "sk-..."})
270
+ # @return [RubyLLM::Context] Context with tenant-scoped configuration
271
+ def build_llm_context(api_keys)
272
+ config = RubyLLM.config.dup
273
+ api_keys.each do |provider, key|
274
+ next if key.nil? || (key.respond_to?(:empty?) && key.empty?)
275
+
276
+ setter = "#{provider}_api_key="
277
+ config.public_send(setter, key) if config.respond_to?(setter)
278
+ end
279
+ RubyLLM::Context.new(config)
280
+ end
281
+
220
282
  # Extracts agent_type from the agent class
221
283
  #
222
284
  # @param agent_class [Class] The agent class
@@ -227,7 +289,13 @@ module RubyLLM
227
289
  if agent_class.respond_to?(:agent_type)
228
290
  agent_class.agent_type
229
291
  else
230
- # Infer from class name as fallback
292
+ if defined?(RubyLLM::Agents::Deprecations)
293
+ RubyLLM::Agents::Deprecations.warn(
294
+ "#{agent_class.name || agent_class} does not define `agent_type`. " \
295
+ "Guessing from class name. Define `self.agent_type` to silence this warning.",
296
+ caller
297
+ )
298
+ end
231
299
  infer_agent_type(agent_class)
232
300
  end
233
301
  end
@@ -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
@@ -64,8 +66,8 @@ module RubyLLM
64
66
  tenant_id: context.tenant_id
65
67
  }.merge(extras)
66
68
  )
67
- rescue
68
- # Never let notifications break execution
69
+ rescue => e
70
+ debug("Budget notification failed: #{e.message}", context)
69
71
  end
70
72
 
71
73
  # Returns whether budgets are enabled globally
@@ -73,7 +75,8 @@ module RubyLLM
73
75
  # @return [Boolean]
74
76
  def budgets_enabled?
75
77
  global_config.budgets_enabled?
76
- rescue
78
+ rescue => e
79
+ debug("Failed to check budgets_enabled config: #{e.message}")
77
80
  false
78
81
  end
79
82
 
@@ -104,7 +107,8 @@ module RubyLLM
104
107
  emit_budget_notification("ruby_llm_agents.budget.exceeded", context)
105
108
  raise
106
109
  rescue => e
107
- error("Budget check failed: #{e.message}")
110
+ # Log at error level so unexpected failures are visible in logs
111
+ error("Budget check failed: #{e.class}: #{e.message}", context)
108
112
  end
109
113
 
110
114
  # Records spend after execution
@@ -143,7 +147,7 @@ module RubyLLM
143
147
  )
144
148
  end
145
149
  rescue => e
146
- error("Failed to record spend: #{e.message}")
150
+ error("Failed to record spend: #{e.message}", context)
147
151
  end
148
152
  end
149
153
  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)
57
+
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
50
64
 
51
- # Execute the chain
52
- @app.call(context)
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
@@ -73,8 +85,8 @@ module RubyLLM
73
85
  agent_type: @agent_class&.name,
74
86
  cache_key: cache_key
75
87
  )
76
- rescue
77
- # Never let notifications break execution
88
+ rescue => e
89
+ debug("Cache notification failed: #{e.message}")
78
90
  end
79
91
 
80
92
  # Returns whether caching is enabled for this agent
@@ -89,7 +101,8 @@ module RubyLLM
89
101
  # @return [ActiveSupport::Cache::Store, nil]
90
102
  def cache_store
91
103
  global_config.cache_store
92
- rescue
104
+ rescue => e
105
+ debug("Failed to access cache_store config: #{e.message}")
93
106
  nil
94
107
  end
95
108
 
@@ -148,7 +161,8 @@ module RubyLLM
148
161
  else
149
162
  input.to_json
150
163
  end
151
- rescue
164
+ rescue => e
165
+ debug("Failed to serialize input for cache key: #{e.message}")
152
166
  input.to_s
153
167
  end
154
168