ruby_llm-agents 0.3.4 → 0.3.5

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 (84) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +80 -16
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +17 -27
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +9 -1
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/{rubyllm → ruby_llm}/agents/application.html.erb +91 -29
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/{rubyllm → ruby_llm}/agents/agents/show.html.erb +77 -20
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  21. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  22. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/index.html.erb +9 -6
  23. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  24. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  25. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  26. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  27. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +137 -126
  29. data/app/views/{rubyllm → ruby_llm}/agents/shared/_breadcrumbs.html.erb +2 -2
  30. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  32. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  33. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  34. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  35. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  36. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  37. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  38. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  39. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  40. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  41. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  42. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  43. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  44. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  45. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  46. data/lib/ruby_llm/agents/base/caching.rb +4 -7
  47. data/lib/ruby_llm/agents/base/cost_calculation.rb +5 -3
  48. data/lib/ruby_llm/agents/base/execution.rb +61 -9
  49. data/lib/ruby_llm/agents/base/reliability_execution.rb +14 -9
  50. data/lib/ruby_llm/agents/base.rb +26 -0
  51. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  52. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  53. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  54. data/lib/ruby_llm/agents/configuration.rb +40 -1
  55. data/lib/ruby_llm/agents/engine.rb +65 -1
  56. data/lib/ruby_llm/agents/inflections.rb +14 -0
  57. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  58. data/lib/ruby_llm/agents/reliability.rb +8 -2
  59. data/lib/ruby_llm/agents/version.rb +1 -1
  60. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  61. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  62. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  63. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  64. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  65. data/lib/ruby_llm/agents/workflow.rb +232 -0
  66. data/lib/ruby_llm/agents.rb +1 -0
  67. metadata +50 -60
  68. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  69. data/app/views/rubyllm/agents/dashboard/_agent_comparison.html.erb +0 -46
  70. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  71. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  72. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  73. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  74. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  75. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  76. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  77. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  78. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_budgets_bar.html.erb +0 -0
  79. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_now_strip.html.erb +0 -0
  80. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_top_errors.html.erb +0 -0
  81. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  82. /data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +0 -0
  83. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_nav_link.html.erb +0 -0
  84. /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
@@ -120,6 +120,19 @@ module RubyLlmAgents
120
120
  )
121
121
  end
122
122
 
123
+ def create_add_workflow_migration
124
+ # Check if columns already exist
125
+ if column_exists?(:ruby_llm_agents_executions, :workflow_id)
126
+ say_status :skip, "workflow_id column already exists", :yellow
127
+ return
128
+ end
129
+
130
+ migration_template(
131
+ "add_workflow_migration.rb.tt",
132
+ File.join(db_migrate_path, "add_workflow_to_ruby_llm_agents_executions.rb")
133
+ )
134
+ end
135
+
123
136
  def show_post_upgrade_message
124
137
  say ""
125
138
  say "RubyLLM::Agents upgrade migration created!", :green
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "net/http"
3
+ require "faraday"
4
4
  require "json"
5
5
 
6
6
  module RubyLLM
@@ -177,30 +177,34 @@ module RubyLLM
177
177
  end
178
178
  end
179
179
 
180
- # Posts JSON to a URL
180
+ # Posts JSON to a URL using Faraday
181
181
  #
182
182
  # @param url [String] The URL
183
183
  # @param payload [Hash] The payload
184
- # @return [Net::HTTPResponse]
184
+ # @return [Faraday::Response]
185
185
  def post_json(url, payload)
186
- uri = URI.parse(url)
187
- http = Net::HTTP.new(uri.host, uri.port)
188
- http.use_ssl = uri.scheme == "https"
189
- http.open_timeout = 5
190
- http.read_timeout = 10
191
-
192
- request = Net::HTTP::Post.new(uri.request_uri)
193
- request["Content-Type"] = "application/json"
194
- request.body = payload.to_json
195
-
196
- response = http.request(request)
186
+ response = http_client.post(url) do |req|
187
+ req.headers["Content-Type"] = "application/json"
188
+ req.body = payload.to_json
189
+ end
197
190
 
198
- unless response.is_a?(Net::HTTPSuccess)
199
- Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook returned #{response.code}: #{response.body}")
191
+ unless response.success?
192
+ Rails.logger.warn("[RubyLLM::Agents::AlertManager] Webhook returned #{response.status}: #{response.body}")
200
193
  end
201
194
 
202
195
  response
203
196
  end
197
+
198
+ # Returns a configured Faraday HTTP client
199
+ #
200
+ # @return [Faraday::Connection]
201
+ def http_client
202
+ @http_client ||= Faraday.new do |conn|
203
+ conn.options.open_timeout = 5
204
+ conn.options.timeout = 10
205
+ conn.adapter Faraday.default_adapter
206
+ end
207
+ end
204
208
  end
205
209
  end
206
210
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../cache_helper"
4
+
3
5
  module RubyLLM
4
6
  module Agents
5
7
  class Base
@@ -8,17 +10,12 @@ module RubyLLM
8
10
  # Handles cache key generation and store access for
9
11
  # caching agent execution results.
10
12
  module Caching
11
- # Returns the configured cache store
12
- #
13
- # @return [ActiveSupport::Cache::Store] The cache store
14
- def cache_store
15
- RubyLLM::Agents.configuration.cache_store
16
- end
13
+ include CacheHelper
17
14
 
18
15
  # Generates the full cache key for this agent invocation
19
16
  #
20
17
  # @return [String] Cache key in format "ruby_llm_agent/ClassName/version/hash"
21
- def cache_key
18
+ def agent_cache_key
22
19
  ["ruby_llm_agent", self.class.name, self.class.version, cache_key_hash].join("/")
23
20
  end
24
21
 
@@ -65,7 +65,8 @@ module RubyLLM
65
65
  # @param model_id [String] The model identifier
66
66
  # @return [Object, nil] Model info or nil
67
67
  def resolve_model_info(model_id)
68
- RubyLLM::Models.resolve(model_id)
68
+ model_obj, _provider = RubyLLM::Models.resolve(model_id)
69
+ model_obj
69
70
  rescue StandardError
70
71
  nil
71
72
  end
@@ -73,8 +74,9 @@ module RubyLLM
73
74
  # Records cost from an attempt to the budget tracker
74
75
  #
75
76
  # @param attempt_tracker [AttemptTracker] The attempt tracker
77
+ # @param tenant_id [String, nil] Optional tenant identifier for multi-tenant tracking
76
78
  # @return [void]
77
- def record_attempt_cost(attempt_tracker)
79
+ def record_attempt_cost(attempt_tracker, tenant_id: nil)
78
80
  successful = attempt_tracker.successful_attempt
79
81
  return unless successful
80
82
 
@@ -93,7 +95,7 @@ module RubyLLM
93
95
  total_cost = (input_tokens / 1_000_000.0 * input_price) +
94
96
  (output_tokens / 1_000_000.0 * output_price)
95
97
 
96
- BudgetTracker.record_spend!(self.class.name, total_cost)
98
+ BudgetTracker.record_spend!(self.class.name, total_cost, tenant_id: tenant_id)
97
99
  rescue StandardError => e
98
100
  Rails.logger.warn("[RubyLLM::Agents] Failed to record budget spend: #{e.message}")
99
101
  end
@@ -20,7 +20,17 @@ module RubyLLM
20
20
  return dry_run_response if @options[:dry_run]
21
21
  return uncached_call(&block) if @options[:skip_cache] || !self.class.cache_enabled?
22
22
 
23
- # Note: Cached responses don't stream (already complete)
23
+ cache_key = agent_cache_key
24
+
25
+ # Check for cache hit BEFORE fetch to record it
26
+ if cache_store.exist?(cache_key)
27
+ started_at = Time.current
28
+ cached_result = cache_store.read(cache_key)
29
+ record_cache_hit_execution(cache_key, cached_result, started_at) if cached_result
30
+ return cached_result
31
+ end
32
+
33
+ # Cache miss - execute and store
24
34
  cache_store.fetch(cache_key, expires_in: self.class.cache_ttl) do
25
35
  uncached_call(&block)
26
36
  end
@@ -105,7 +115,7 @@ module RubyLLM
105
115
  attachments: @options[:with],
106
116
  schema: schema&.class&.name,
107
117
  streaming: self.class.streaming,
108
- tools: self.class.tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
118
+ tools: resolved_tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
109
119
  },
110
120
  model_id: model,
111
121
  temperature: temperature,
@@ -113,6 +123,37 @@ module RubyLLM
113
123
  )
114
124
  end
115
125
 
126
+ # Resolves tools for this execution
127
+ #
128
+ # Checks for instance method override first (for dynamic tools),
129
+ # then falls back to class-level DSL configuration. This allows
130
+ # agents to define tools dynamically based on runtime context.
131
+ #
132
+ # @return [Array<Class>] Tool classes to use
133
+ def resolved_tools
134
+ # Check if instance defines tools method (not inherited from class singleton)
135
+ if self.class.instance_methods(false).include?(:tools)
136
+ tools
137
+ else
138
+ self.class.tools
139
+ end
140
+ end
141
+
142
+ # Resolves messages for this execution
143
+ #
144
+ # Priority order:
145
+ # 1. @override_messages (set via with_messages)
146
+ # 2. :messages option passed at call time
147
+ # 3. messages template method defined in subclass
148
+ #
149
+ # @return [Array<Hash>] Messages to apply to conversation
150
+ def resolved_messages
151
+ return @override_messages if @override_messages&.any?
152
+ return @options[:messages] if @options[:messages]&.any?
153
+
154
+ messages
155
+ end
156
+
116
157
  # Returns the consolidated reliability configuration for this agent instance
117
158
  #
118
159
  # @return [Hash] Reliability config with :retries, :fallback_models, :total_timeout, :circuit_breaker
@@ -166,7 +207,8 @@ module RubyLLM
166
207
  .with_temperature(temperature)
167
208
  client = client.with_instructions(system_prompt) if system_prompt
168
209
  client = client.with_schema(schema) if schema
169
- client = client.with_tools(*self.class.tools) if self.class.tools.any?
210
+ client = client.with_tools(*resolved_tools) if resolved_tools.any?
211
+ client = apply_messages(client, resolved_messages) if resolved_messages.any?
170
212
  client
171
213
  end
172
214
 
@@ -180,14 +222,26 @@ module RubyLLM
180
222
  .with_temperature(temperature)
181
223
  client = client.with_instructions(system_prompt) if system_prompt
182
224
  client = client.with_schema(schema) if schema
183
- client = client.with_tools(*self.class.tools) if self.class.tools.any?
225
+ client = client.with_tools(*resolved_tools) if resolved_tools.any?
226
+ client = apply_messages(client, resolved_messages) if resolved_messages.any?
184
227
  client
185
228
  end
186
229
 
187
- # Builds a client with pre-populated conversation history
230
+ # Applies conversation history to the client
188
231
  #
189
- # Useful for multi-turn conversations or providing context.
232
+ # @param client [RubyLLM::Chat] The chat client
233
+ # @param msgs [Array<Hash>] Messages with :role and :content keys
234
+ # @return [RubyLLM::Chat] Client with messages applied
235
+ def apply_messages(client, msgs)
236
+ msgs.reduce(client) do |c, message|
237
+ c.with_message(message[:role].to_s, message[:content])
238
+ end
239
+ end
240
+
241
+ # Builds a client with pre-populated conversation history
190
242
  #
243
+ # @deprecated Use resolved_messages and apply_messages instead.
244
+ # Override the messages template method or pass messages: option to call.
191
245
  # @param messages [Array<Hash>] Messages with :role and :content keys
192
246
  # @return [RubyLLM::Chat] Client with messages added
193
247
  # @example
@@ -196,9 +250,7 @@ module RubyLLM
196
250
  # { role: "assistant", content: "Hi there!" }
197
251
  # ])
198
252
  def build_client_with_messages(messages)
199
- messages.reduce(build_client) do |client, message|
200
- client.with_message(message[:role], message[:content])
201
- end
253
+ apply_messages(build_client, messages)
202
254
  end
203
255
  end
204
256
  end
@@ -21,15 +21,19 @@ module RubyLLM
21
21
  total_deadline = config[:total_timeout] ? Time.current + config[:total_timeout] : nil
22
22
  started_at = Time.current
23
23
 
24
- # Pre-check budget
25
- BudgetTracker.check_budget!(self.class.name) if RubyLLM::Agents.configuration.budgets_enabled?
24
+ # Get current tenant_id for multi-tenancy support
25
+ global_config = RubyLLM::Agents.configuration
26
+ tenant_id = global_config.multi_tenancy_enabled? ? global_config.current_tenant_id : nil
27
+
28
+ # Pre-check budget (tenant_id is resolved automatically if not passed)
29
+ BudgetTracker.check_budget!(self.class.name, tenant_id: tenant_id) if global_config.budgets_enabled?
26
30
 
27
31
  instrument_execution_with_attempts(models_to_try: models_to_try) do |attempt_tracker|
28
32
  last_error = nil
29
33
 
30
34
  models_to_try.each do |current_model|
31
- # Check circuit breaker
32
- breaker = get_circuit_breaker(current_model)
35
+ # Check circuit breaker (with tenant isolation if enabled)
36
+ breaker = get_circuit_breaker(current_model, tenant_id: tenant_id)
33
37
  if breaker&.open?
34
38
  attempt_tracker.record_short_circuit(current_model)
35
39
  next
@@ -54,9 +58,9 @@ module RubyLLM
54
58
  # Record success in circuit breaker
55
59
  breaker&.record_success!
56
60
 
57
- # Record budget spend
58
- if @last_response && RubyLLM::Agents.configuration.budgets_enabled?
59
- record_attempt_cost(attempt_tracker)
61
+ # Record budget spend (with tenant isolation if enabled)
62
+ if @last_response && global_config.budgets_enabled?
63
+ record_attempt_cost(attempt_tracker, tenant_id: tenant_id)
60
64
  end
61
65
 
62
66
  # Use throw instead of return to allow instrument_execution_with_attempts
@@ -118,12 +122,13 @@ module RubyLLM
118
122
  # Gets or creates a circuit breaker for a model
119
123
  #
120
124
  # @param model_id [String] The model identifier
125
+ # @param tenant_id [String, nil] Optional tenant identifier for multi-tenant isolation
121
126
  # @return [CircuitBreaker, nil] The circuit breaker or nil if not configured
122
- def get_circuit_breaker(model_id)
127
+ def get_circuit_breaker(model_id, tenant_id: nil)
123
128
  config = reliability_config[:circuit_breaker]
124
129
  return nil unless config
125
130
 
126
- CircuitBreaker.from_config(self.class.name, model_id, config)
131
+ CircuitBreaker.from_config(self.class.name, model_id, config, tenant_id: tenant_id)
127
132
  end
128
133
  end
129
134
  end
@@ -137,6 +137,20 @@ module RubyLLM
137
137
  nil
138
138
  end
139
139
 
140
+ # Conversation history for multi-turn conversations
141
+ #
142
+ # Override in subclass to provide conversation history.
143
+ # Messages will be added to the chat before the user_prompt.
144
+ #
145
+ # @return [Array<Hash>] Array of messages with :role and :content keys
146
+ # @example
147
+ # def messages
148
+ # [{ role: :user, content: "Hello" }, { role: :assistant, content: "Hi!" }]
149
+ # end
150
+ def messages
151
+ []
152
+ end
153
+
140
154
  # Post-processes the LLM response
141
155
  #
142
156
  # Override to transform the response before returning to the caller.
@@ -151,6 +165,18 @@ module RubyLLM
151
165
  end
152
166
 
153
167
  # @!endgroup
168
+
169
+ # Sets conversation history and rebuilds the client
170
+ #
171
+ # @param msgs [Array<Hash>] Messages with :role and :content keys
172
+ # @return [self] Returns self for chaining
173
+ # @example
174
+ # agent.with_messages([{ role: :user, content: "Hi" }]).call
175
+ def with_messages(msgs)
176
+ @override_messages = msgs
177
+ @client = build_client
178
+ self
179
+ end
154
180
  end
155
181
  end
156
182
  end