ruby_llm-agents 0.3.6 → 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +46 -13
  3. data/app/controllers/ruby_llm/agents/api_configurations_controller.rb +214 -0
  4. data/app/controllers/ruby_llm/agents/{settings_controller.rb → system_config_controller.rb} +3 -3
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +109 -0
  6. data/app/models/ruby_llm/agents/api_configuration.rb +386 -0
  7. data/app/models/ruby_llm/agents/tenant_budget.rb +62 -7
  8. data/app/views/layouts/ruby_llm/agents/application.html.erb +3 -1
  9. data/app/views/ruby_llm/agents/api_configurations/_api_key_field.html.erb +34 -0
  10. data/app/views/ruby_llm/agents/api_configurations/_form.html.erb +288 -0
  11. data/app/views/ruby_llm/agents/api_configurations/edit.html.erb +95 -0
  12. data/app/views/ruby_llm/agents/api_configurations/edit_tenant.html.erb +97 -0
  13. data/app/views/ruby_llm/agents/api_configurations/show.html.erb +211 -0
  14. data/app/views/ruby_llm/agents/api_configurations/tenant.html.erb +179 -0
  15. data/app/views/ruby_llm/agents/dashboard/_action_center.html.erb +1 -1
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +82 -0
  17. data/app/views/ruby_llm/agents/{settings → system_config}/show.html.erb +1 -1
  18. data/app/views/ruby_llm/agents/tenants/_form.html.erb +150 -0
  19. data/app/views/ruby_llm/agents/tenants/edit.html.erb +13 -0
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +129 -0
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +374 -0
  22. data/config/routes.rb +12 -1
  23. data/lib/generators/ruby_llm_agents/api_configuration_generator.rb +100 -0
  24. data/lib/generators/ruby_llm_agents/templates/create_api_configurations_migration.rb.tt +90 -0
  25. data/lib/ruby_llm/agents/base/dsl.rb +65 -13
  26. data/lib/ruby_llm/agents/base/execution.rb +113 -6
  27. data/lib/ruby_llm/agents/base/reliability_dsl.rb +82 -0
  28. data/lib/ruby_llm/agents/base.rb +28 -0
  29. data/lib/ruby_llm/agents/budget_tracker.rb +285 -23
  30. data/lib/ruby_llm/agents/configuration.rb +38 -1
  31. data/lib/ruby_llm/agents/deprecations.rb +77 -0
  32. data/lib/ruby_llm/agents/engine.rb +1 -0
  33. data/lib/ruby_llm/agents/instrumentation.rb +71 -3
  34. data/lib/ruby_llm/agents/reliability/breaker_manager.rb +80 -0
  35. data/lib/ruby_llm/agents/reliability/execution_constraints.rb +69 -0
  36. data/lib/ruby_llm/agents/reliability/executor.rb +124 -0
  37. data/lib/ruby_llm/agents/reliability/fallback_routing.rb +72 -0
  38. data/lib/ruby_llm/agents/reliability/retry_strategy.rb +76 -0
  39. data/lib/ruby_llm/agents/resolved_config.rb +348 -0
  40. data/lib/ruby_llm/agents/result.rb +72 -2
  41. data/lib/ruby_llm/agents/version.rb +1 -1
  42. data/lib/ruby_llm/agents.rb +6 -0
  43. metadata +26 -3
@@ -36,11 +36,12 @@ module RubyLLM
36
36
  #
37
37
  # @param agent_type [String] The agent class name
38
38
  # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
39
+ # @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
39
40
  # @raise [Reliability::BudgetExceededError] If hard cap is exceeded
40
41
  # @return [void]
41
- def check_budget!(agent_type, tenant_id: nil)
42
+ def check_budget!(agent_type, tenant_id: nil, tenant_config: nil)
42
43
  tenant_id = resolve_tenant_id(tenant_id)
43
- budget_config = resolve_budget_config(tenant_id)
44
+ budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
44
45
 
45
46
  return unless budget_config[:enabled]
46
47
  return unless budget_config[:enforcement] == :hard
@@ -48,13 +49,31 @@ module RubyLLM
48
49
  check_budget_limits!(agent_type, tenant_id, budget_config)
49
50
  end
50
51
 
52
+ # Checks if the current token usage exceeds budget limits
53
+ #
54
+ # @param agent_type [String] The agent class name
55
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
56
+ # @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
57
+ # @raise [Reliability::BudgetExceededError] If hard cap is exceeded
58
+ # @return [void]
59
+ def check_token_budget!(agent_type, tenant_id: nil, tenant_config: nil)
60
+ tenant_id = resolve_tenant_id(tenant_id)
61
+ budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
62
+
63
+ return unless budget_config[:enabled]
64
+ return unless budget_config[:enforcement] == :hard
65
+
66
+ check_token_limits!(agent_type, tenant_id, budget_config)
67
+ end
68
+
51
69
  # Records spend and checks for soft cap alerts
52
70
  #
53
71
  # @param agent_type [String] The agent class name
54
72
  # @param amount [Float] The amount spent in USD
55
73
  # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
74
+ # @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
56
75
  # @return [void]
57
- def record_spend!(agent_type, amount, tenant_id: nil)
76
+ def record_spend!(agent_type, amount, tenant_id: nil, tenant_config: nil)
58
77
  return if amount.nil? || amount <= 0
59
78
 
60
79
  tenant_id = resolve_tenant_id(tenant_id)
@@ -66,10 +85,33 @@ module RubyLLM
66
85
  increment_spend(:agent, :monthly, amount, agent_type: agent_type, tenant_id: tenant_id)
67
86
 
68
87
  # Check for soft cap alerts
69
- budget_config = resolve_budget_config(tenant_id)
88
+ budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
70
89
  check_soft_cap_alerts(agent_type, tenant_id, budget_config) if budget_config[:enabled]
71
90
  end
72
91
 
92
+ # Records token usage and checks for soft cap alerts
93
+ #
94
+ # @param agent_type [String] The agent class name
95
+ # @param tokens [Integer] The number of tokens used
96
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
97
+ # @param tenant_config [Hash, nil] Optional runtime tenant config (takes priority over resolver/DB)
98
+ # @return [void]
99
+ def record_tokens!(agent_type, tokens, tenant_id: nil, tenant_config: nil)
100
+ return if tokens.nil? || tokens <= 0
101
+
102
+ tenant_id = resolve_tenant_id(tenant_id)
103
+
104
+ # Increment all relevant token counters
105
+ increment_tokens(:global, :daily, tokens, tenant_id: tenant_id)
106
+ increment_tokens(:global, :monthly, tokens, tenant_id: tenant_id)
107
+ increment_tokens(:agent, :daily, tokens, agent_type: agent_type, tenant_id: tenant_id)
108
+ increment_tokens(:agent, :monthly, tokens, agent_type: agent_type, tenant_id: tenant_id)
109
+
110
+ # Check for soft cap alerts
111
+ budget_config = resolve_budget_config(tenant_id, runtime_config: tenant_config)
112
+ check_soft_token_alerts(agent_type, tenant_id, budget_config) if budget_config[:enabled]
113
+ end
114
+
73
115
  # Returns the current spend for a scope and period
74
116
  #
75
117
  # @param scope [Symbol] :global or :agent
@@ -83,6 +125,17 @@ module RubyLLM
83
125
  (BudgetTracker.cache_read(key) || 0).to_f
84
126
  end
85
127
 
128
+ # Returns the current token usage for a period (global only)
129
+ #
130
+ # @param period [Symbol] :daily or :monthly
131
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
132
+ # @return [Integer] Current token usage
133
+ def current_tokens(period, tenant_id: nil)
134
+ tenant_id = resolve_tenant_id(tenant_id)
135
+ key = token_cache_key(period, tenant_id: tenant_id)
136
+ (BudgetTracker.cache_read(key) || 0).to_i
137
+ end
138
+
86
139
  # Returns the remaining budget for a scope and period
87
140
  #
88
141
  # @param scope [Symbol] :global or :agent
@@ -110,6 +163,27 @@ module RubyLLM
110
163
  [limit - current_spend(scope, period, agent_type: agent_type, tenant_id: tenant_id), 0].max
111
164
  end
112
165
 
166
+ # Returns the remaining token budget for a period (global only)
167
+ #
168
+ # @param period [Symbol] :daily or :monthly
169
+ # @param tenant_id [String, nil] Optional tenant identifier (uses resolver if not provided)
170
+ # @return [Integer, nil] Remaining token budget, or nil if no limit configured
171
+ def remaining_token_budget(period, tenant_id: nil)
172
+ tenant_id = resolve_tenant_id(tenant_id)
173
+ budget_config = resolve_budget_config(tenant_id)
174
+
175
+ limit = case period
176
+ when :daily
177
+ budget_config[:global_daily_tokens]
178
+ when :monthly
179
+ budget_config[:global_monthly_tokens]
180
+ end
181
+
182
+ return nil unless limit
183
+
184
+ [limit - current_tokens(period, tenant_id: tenant_id), 0].max
185
+ end
186
+
113
187
  # Returns a summary of all budget statuses
114
188
  #
115
189
  # @param agent_type [String, nil] Optional agent type for per-agent budgets
@@ -123,10 +197,14 @@ module RubyLLM
123
197
  tenant_id: tenant_id,
124
198
  enabled: budget_config[:enabled],
125
199
  enforcement: budget_config[:enforcement],
200
+ # Cost budgets
126
201
  global_daily: budget_status(:global, :daily, budget_config[:global_daily], tenant_id: tenant_id),
127
202
  global_monthly: budget_status(:global, :monthly, budget_config[:global_monthly], tenant_id: tenant_id),
128
203
  per_agent_daily: agent_type ? budget_status(:agent, :daily, budget_config[:per_agent_daily]&.dig(agent_type), agent_type: agent_type, tenant_id: tenant_id) : nil,
129
204
  per_agent_monthly: agent_type ? budget_status(:agent, :monthly, budget_config[:per_agent_monthly]&.dig(agent_type), agent_type: agent_type, tenant_id: tenant_id) : nil,
205
+ # Token budgets (global only)
206
+ global_daily_tokens: token_status(:daily, budget_config[:global_daily_tokens], tenant_id: tenant_id),
207
+ global_monthly_tokens: token_status(:monthly, budget_config[:global_monthly_tokens], tenant_id: tenant_id),
130
208
  forecast: calculate_forecast(tenant_id: tenant_id)
131
209
  }.compact
132
210
  end
@@ -225,41 +303,86 @@ module RubyLLM
225
303
 
226
304
  # Resolves budget configuration for a tenant
227
305
  #
306
+ # Priority order:
307
+ # 1. runtime_config (passed to run())
308
+ # 2. tenant_config_resolver (configured lambda)
309
+ # 3. TenantBudget database record
310
+ # 4. Global configuration
311
+ #
228
312
  # @param tenant_id [String, nil] The tenant identifier
313
+ # @param runtime_config [Hash, nil] Runtime config passed to run()
229
314
  # @return [Hash] Budget configuration
230
- def resolve_budget_config(tenant_id)
315
+ def resolve_budget_config(tenant_id, runtime_config: nil)
231
316
  config = RubyLLM::Agents.configuration
232
317
 
318
+ # Priority 1: Runtime config passed directly to run()
319
+ if runtime_config.present?
320
+ return normalize_budget_config(runtime_config, config)
321
+ end
322
+
233
323
  # If multi-tenancy is disabled or no tenant, use global config
234
324
  if tenant_id.nil? || !config.multi_tenancy_enabled?
235
- return {
236
- enabled: config.budgets_enabled?,
237
- enforcement: config.budget_enforcement,
238
- global_daily: config.budgets&.dig(:global_daily),
239
- global_monthly: config.budgets&.dig(:global_monthly),
240
- per_agent_daily: config.budgets&.dig(:per_agent_daily),
241
- per_agent_monthly: config.budgets&.dig(:per_agent_monthly)
242
- }
325
+ return global_budget_config(config)
326
+ end
327
+
328
+ # Priority 2: tenant_config_resolver lambda
329
+ if config.tenant_config_resolver.present?
330
+ resolved_config = config.tenant_config_resolver.call(tenant_id)
331
+ if resolved_config.present?
332
+ return normalize_budget_config(resolved_config, config)
333
+ end
243
334
  end
244
335
 
245
- # Look up tenant-specific budget from database (if table exists)
336
+ # Priority 3: Look up tenant-specific budget from database
246
337
  tenant_budget = lookup_tenant_budget(tenant_id)
247
338
 
248
339
  if tenant_budget
249
340
  tenant_budget.to_budget_config
250
341
  else
251
- # Fall back to global config for unknown tenants
252
- {
253
- enabled: config.budgets_enabled?,
254
- enforcement: config.budget_enforcement,
255
- global_daily: config.budgets&.dig(:global_daily),
256
- global_monthly: config.budgets&.dig(:global_monthly),
257
- per_agent_daily: config.budgets&.dig(:per_agent_daily),
258
- per_agent_monthly: config.budgets&.dig(:per_agent_monthly)
259
- }
342
+ # Priority 4: Fall back to global config for unknown tenants
343
+ global_budget_config(config)
260
344
  end
261
345
  end
262
346
 
347
+ # Builds global budget config from configuration
348
+ #
349
+ # @param config [Configuration] The configuration object
350
+ # @return [Hash] Budget configuration
351
+ def global_budget_config(config)
352
+ {
353
+ enabled: config.budgets_enabled?,
354
+ enforcement: config.budget_enforcement,
355
+ global_daily: config.budgets&.dig(:global_daily),
356
+ global_monthly: config.budgets&.dig(:global_monthly),
357
+ per_agent_daily: config.budgets&.dig(:per_agent_daily),
358
+ per_agent_monthly: config.budgets&.dig(:per_agent_monthly),
359
+ global_daily_tokens: config.budgets&.dig(:global_daily_tokens),
360
+ global_monthly_tokens: config.budgets&.dig(:global_monthly_tokens)
361
+ }
362
+ end
363
+
364
+ # Normalizes runtime/resolver config to standard budget config format
365
+ #
366
+ # @param raw_config [Hash] Raw config from runtime or resolver
367
+ # @param global_config [Configuration] Global config for fallbacks
368
+ # @return [Hash] Normalized budget configuration
369
+ def normalize_budget_config(raw_config, global_config)
370
+ enforcement = raw_config[:enforcement]&.to_sym || global_config.budget_enforcement
371
+
372
+ {
373
+ enabled: enforcement != :none,
374
+ enforcement: enforcement,
375
+ # Cost/budget limits (USD)
376
+ global_daily: raw_config[:daily_budget_limit],
377
+ global_monthly: raw_config[:monthly_budget_limit],
378
+ per_agent_daily: raw_config[:per_agent_daily] || {},
379
+ per_agent_monthly: raw_config[:per_agent_monthly] || {},
380
+ # Token limits
381
+ global_daily_tokens: raw_config[:daily_token_limit],
382
+ global_monthly_tokens: raw_config[:monthly_token_limit]
383
+ }
384
+ end
385
+
263
386
  # Safely looks up tenant budget, handling missing table
264
387
  #
265
388
  # @param tenant_id [String] The tenant identifier
@@ -465,6 +588,145 @@ module RubyLLM
465
588
  timestamp: Date.current.to_s
466
589
  })
467
590
  end
591
+
592
+ # Increments the token counter for a period
593
+ #
594
+ # @param scope [Symbol] :global (only global supported for tokens)
595
+ # @param period [Symbol] :daily or :monthly
596
+ # @param tokens [Integer] Tokens to add
597
+ # @param tenant_id [String, nil] The tenant identifier
598
+ # @return [Integer] New total
599
+ def increment_tokens(scope, period, tokens, agent_type: nil, tenant_id: nil)
600
+ # For now, we only track global token usage (not per-agent)
601
+ key = token_cache_key(period, tenant_id: tenant_id)
602
+ ttl = period == :daily ? 1.day : 31.days
603
+
604
+ current = (BudgetTracker.cache_read(key) || 0).to_i
605
+ new_total = current + tokens
606
+ BudgetTracker.cache_write(key, new_total, expires_in: ttl)
607
+ new_total
608
+ end
609
+
610
+ # Generates a cache key for token tracking
611
+ #
612
+ # @param period [Symbol] :daily or :monthly
613
+ # @param tenant_id [String, nil] The tenant identifier
614
+ # @return [String] Cache key
615
+ def token_cache_key(period, tenant_id: nil)
616
+ date_part = period == :daily ? Date.current.to_s : Date.current.strftime("%Y-%m")
617
+ tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
618
+
619
+ BudgetTracker.cache_key("tokens", tenant_part, date_part)
620
+ end
621
+
622
+ # Checks token limits and raises error if exceeded
623
+ #
624
+ # @param agent_type [String] The agent class name
625
+ # @param tenant_id [String, nil] The tenant identifier
626
+ # @param budget_config [Hash] The budget configuration
627
+ # @raise [Reliability::BudgetExceededError] If limit exceeded
628
+ # @return [void]
629
+ def check_token_limits!(agent_type, tenant_id, budget_config)
630
+ # Check global daily token budget
631
+ if budget_config[:global_daily_tokens]
632
+ current = current_tokens(:daily, tenant_id: tenant_id)
633
+ if current >= budget_config[:global_daily_tokens]
634
+ raise Reliability::BudgetExceededError.new(
635
+ :global_daily_tokens,
636
+ budget_config[:global_daily_tokens],
637
+ current,
638
+ tenant_id: tenant_id
639
+ )
640
+ end
641
+ end
642
+
643
+ # Check global monthly token budget
644
+ if budget_config[:global_monthly_tokens]
645
+ current = current_tokens(:monthly, tenant_id: tenant_id)
646
+ if current >= budget_config[:global_monthly_tokens]
647
+ raise Reliability::BudgetExceededError.new(
648
+ :global_monthly_tokens,
649
+ budget_config[:global_monthly_tokens],
650
+ current,
651
+ tenant_id: tenant_id
652
+ )
653
+ end
654
+ end
655
+ end
656
+
657
+ # Checks for soft cap token alerts after recording usage
658
+ #
659
+ # @param agent_type [String] The agent class name
660
+ # @param tenant_id [String, nil] The tenant identifier
661
+ # @param budget_config [Hash] Budget configuration
662
+ # @return [void]
663
+ def check_soft_token_alerts(agent_type, tenant_id, budget_config)
664
+ config = RubyLLM::Agents.configuration
665
+ return unless config.alerts_enabled?
666
+ return unless config.alert_events.include?(:token_soft_cap) || config.alert_events.include?(:token_hard_cap)
667
+
668
+ # Check global daily tokens
669
+ check_token_alert(:global_daily_tokens, budget_config[:global_daily_tokens],
670
+ current_tokens(:daily, tenant_id: tenant_id),
671
+ agent_type, tenant_id, budget_config)
672
+
673
+ # Check global monthly tokens
674
+ check_token_alert(:global_monthly_tokens, budget_config[:global_monthly_tokens],
675
+ current_tokens(:monthly, tenant_id: tenant_id),
676
+ agent_type, tenant_id, budget_config)
677
+ end
678
+
679
+ # Checks if a token alert should be fired
680
+ #
681
+ # @param scope [Symbol] Token scope
682
+ # @param limit [Integer, nil] Token limit
683
+ # @param current [Integer] Current token usage
684
+ # @param agent_type [String] Agent type
685
+ # @param tenant_id [String, nil] The tenant identifier
686
+ # @param budget_config [Hash] Budget configuration
687
+ # @return [void]
688
+ def check_token_alert(scope, limit, current, agent_type, tenant_id, budget_config)
689
+ return unless limit
690
+ return if current <= limit
691
+
692
+ event = budget_config[:enforcement] == :hard ? :token_hard_cap : :token_soft_cap
693
+ config = RubyLLM::Agents.configuration
694
+ return unless config.alert_events.include?(event)
695
+
696
+ # Prevent duplicate alerts
697
+ tenant_part = tenant_id.present? ? "tenant:#{tenant_id}" : "global"
698
+ alert_key = BudgetTracker.cache_key("token_alert", tenant_part, scope, Date.current.to_s)
699
+ return if BudgetTracker.cache_exist?(alert_key)
700
+
701
+ BudgetTracker.cache_write(alert_key, true, expires_in: 1.hour)
702
+
703
+ AlertManager.notify(event, {
704
+ scope: scope,
705
+ limit: limit,
706
+ total: current,
707
+ agent_type: agent_type,
708
+ tenant_id: tenant_id,
709
+ timestamp: Date.current.to_s
710
+ })
711
+ end
712
+
713
+ # Returns token status for a period
714
+ #
715
+ # @param period [Symbol] :daily or :monthly
716
+ # @param limit [Integer, nil] The token limit
717
+ # @param tenant_id [String, nil] The tenant identifier
718
+ # @return [Hash, nil] Status hash or nil if no limit
719
+ def token_status(period, limit, tenant_id: nil)
720
+ return nil unless limit
721
+
722
+ current = current_tokens(period, tenant_id: tenant_id)
723
+ {
724
+ limit: limit,
725
+ current: current,
726
+ remaining: [limit - current, 0].max,
727
+ percentage_used: ((current.to_f / limit) * 100).round(2)
728
+ }
729
+ end
468
730
  end
469
731
  end
470
732
  end
@@ -195,6 +195,35 @@ module RubyLLM
195
195
  # @example Using request store
196
196
  # config.tenant_resolver = -> { RequestStore[:tenant_id] }
197
197
 
198
+ # @!attribute [rw] tenant_config_resolver
199
+ # Lambda that returns tenant configuration without querying the database.
200
+ # Called when resolving tenant budget config. If set, this takes priority
201
+ # over the TenantBudget database lookup.
202
+ # @return [Proc, nil] Tenant config resolver lambda (default: nil)
203
+ # @example Using an external tenant service
204
+ # config.tenant_config_resolver = ->(tenant_id) {
205
+ # tenant = Tenant.find(tenant_id)
206
+ # {
207
+ # name: tenant.name,
208
+ # daily_limit: tenant.subscription.daily_budget,
209
+ # monthly_limit: tenant.subscription.monthly_budget,
210
+ # daily_token_limit: tenant.subscription.daily_tokens,
211
+ # monthly_token_limit: tenant.subscription.monthly_tokens,
212
+ # enforcement: tenant.subscription.hard_limits? ? :hard : :soft
213
+ # }
214
+ # }
215
+
216
+ # @!attribute [rw] persist_messages_summary
217
+ # Whether to persist a summary of conversation messages in execution records.
218
+ # When true, stores message count and first/last messages (truncated).
219
+ # Set to false to disable message summary persistence.
220
+ # @return [Boolean] Enable messages summary persistence (default: true)
221
+
222
+ # @!attribute [rw] messages_summary_max_length
223
+ # Maximum character length for message content in the summary.
224
+ # Content exceeding this length will be truncated with "...".
225
+ # @return [Integer] Max length for message content (default: 500)
226
+
198
227
  attr_accessor :default_model,
199
228
  :default_temperature,
200
229
  :default_timeout,
@@ -220,7 +249,10 @@ module RubyLLM
220
249
  :persist_responses,
221
250
  :redaction,
222
251
  :multi_tenancy_enabled,
223
- :tenant_resolver
252
+ :tenant_resolver,
253
+ :tenant_config_resolver,
254
+ :persist_messages_summary,
255
+ :messages_summary_max_length
224
256
 
225
257
  attr_writer :cache_store
226
258
 
@@ -264,6 +296,11 @@ module RubyLLM
264
296
  # Multi-tenancy defaults (disabled for backward compatibility)
265
297
  @multi_tenancy_enabled = false
266
298
  @tenant_resolver = -> { nil }
299
+ @tenant_config_resolver = nil
300
+
301
+ # Messages summary defaults
302
+ @persist_messages_summary = true
303
+ @messages_summary_max_length = 500
267
304
  end
268
305
 
269
306
  # Returns the configured cache store, falling back to Rails.cache
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Manages deprecation warnings with configurable behavior
6
+ #
7
+ # Provides a centralized mechanism for deprecation warnings that can be
8
+ # configured to raise exceptions in test environments or emit warnings
9
+ # in production.
10
+ #
11
+ # @example Emitting a deprecation warning
12
+ # Deprecations.warn("cache(ttl) is deprecated, use cache_for(ttl) instead")
13
+ #
14
+ # @example Enabling strict mode in tests
15
+ # RubyLLM::Agents::Deprecations.raise_on_deprecation = true
16
+ #
17
+ # @api public
18
+ module Deprecations
19
+ # Error raised when deprecation warnings are configured to raise
20
+ #
21
+ # @api public
22
+ class DeprecationError < StandardError; end
23
+
24
+ class << self
25
+ # @!attribute [rw] raise_on_deprecation
26
+ # @return [Boolean] Whether to raise exceptions instead of warnings
27
+ attr_accessor :raise_on_deprecation
28
+
29
+ # @!attribute [rw] silenced
30
+ # @return [Boolean] Whether to silence all deprecation warnings
31
+ attr_accessor :silenced
32
+
33
+ # Emits a deprecation warning or raises an error
34
+ #
35
+ # @param message [String] The deprecation message
36
+ # @param callstack [Array<String>] The call stack (defaults to caller)
37
+ # @return [void]
38
+ # @raise [DeprecationError] If raise_on_deprecation is true
39
+ def warn(message, callstack = caller)
40
+ return if silenced
41
+
42
+ full_message = "[RubyLLM::Agents DEPRECATION] #{message}"
43
+
44
+ if raise_on_deprecation
45
+ raise DeprecationError, full_message
46
+ elsif defined?(Rails) && Rails.respond_to?(:application) && Rails.application
47
+ # Use Rails deprecator if available (Rails 7.1+)
48
+ if Rails.application.respond_to?(:deprecators)
49
+ Rails.application.deprecators[:ruby_llm_agents]&.warn(full_message, callstack) ||
50
+ ::Kernel.warn("#{full_message}\n #{callstack.first}")
51
+ else
52
+ ::Kernel.warn("#{full_message}\n #{callstack.first}")
53
+ end
54
+ else
55
+ ::Kernel.warn("#{full_message}\n #{callstack.first}")
56
+ end
57
+ end
58
+
59
+ # Temporarily silence deprecation warnings within a block
60
+ #
61
+ # @yield Block to execute with silenced warnings
62
+ # @return [Object] The return value of the block
63
+ def silence
64
+ old_silenced = silenced
65
+ self.silenced = true
66
+ yield
67
+ ensure
68
+ self.silenced = old_silenced
69
+ end
70
+ end
71
+
72
+ # Reset to defaults
73
+ self.raise_on_deprecation = false
74
+ self.silenced = false
75
+ end
76
+ end
77
+ end
@@ -34,6 +34,7 @@ module RubyLLM
34
34
  config.to_prepare do
35
35
  require_relative "execution_logger_job"
36
36
  require_relative "instrumentation"
37
+ require_relative "resolved_config"
37
38
  require_relative "base"
38
39
  require_relative "workflow"
39
40
 
@@ -245,7 +245,9 @@ module RubyLLM
245
245
  metadata: metadata,
246
246
  system_prompt: config.persist_prompts ? redacted_system_prompt : nil,
247
247
  user_prompt: config.persist_prompts ? redacted_user_prompt : nil,
248
- streaming: self.class.streaming
248
+ streaming: self.class.streaming,
249
+ messages_count: resolved_messages.size,
250
+ messages_summary: config.persist_messages_summary ? messages_summary : {}
249
251
  }
250
252
 
251
253
  # Extract tracing fields from metadata if present
@@ -326,6 +328,9 @@ module RubyLLM
326
328
  Rails.logger.warn("[RubyLLM::Agents] Cost calculation failed: #{cost_error.message}")
327
329
  end
328
330
  end
331
+
332
+ # Record token usage for budget tracking
333
+ record_token_usage(execution)
329
334
  rescue ActiveRecord::RecordInvalid => e
330
335
  Rails.logger.error("[RubyLLM::Agents] Validation failed for execution #{execution&.id}: #{e.record.errors.full_messages.join(', ')}")
331
336
  if Rails.env.development? || Rails.env.test?
@@ -415,6 +420,9 @@ module RubyLLM
415
420
  Rails.logger.warn("[RubyLLM::Agents] Cost calculation failed: #{cost_error.message}")
416
421
  end
417
422
  end
423
+
424
+ # Record token usage for budget tracking
425
+ record_token_usage(execution)
418
426
  rescue ActiveRecord::RecordInvalid => e
419
427
  Rails.logger.error("[RubyLLM::Agents] Validation failed for execution #{execution&.id}: #{e.record.errors.full_messages.join(', ')}")
420
428
  if Rails.env.development? || Rails.env.test?
@@ -440,6 +448,8 @@ module RubyLLM
440
448
  # @param error [Exception, nil] The exception if failed
441
449
  # @return [void]
442
450
  def legacy_log_execution(completed_at:, status:, response: nil, error: nil)
451
+ config = RubyLLM::Agents.configuration
452
+
443
453
  execution_data = {
444
454
  agent_type: self.class.name,
445
455
  agent_version: self.class.version,
@@ -452,7 +462,9 @@ module RubyLLM
452
462
  parameters: sanitized_parameters,
453
463
  metadata: execution_metadata,
454
464
  system_prompt: safe_system_prompt,
455
- user_prompt: safe_user_prompt
465
+ user_prompt: safe_user_prompt,
466
+ messages_count: resolved_messages.size,
467
+ messages_summary: config.persist_messages_summary ? messages_summary : {}
456
468
  }
457
469
 
458
470
  # Add response data if available (using safe extraction)
@@ -516,6 +528,38 @@ module RubyLLM
516
528
  Redactor.redact_string(prompt)
517
529
  end
518
530
 
531
+ # Returns a summary of messages (first and last, truncated)
532
+ #
533
+ # Creates a summary of the conversation messages containing the first
534
+ # and last messages (if different) with content truncated for storage.
535
+ #
536
+ # @return [Hash] Summary with :first and :last message hashes, or empty hash
537
+ def messages_summary
538
+ msgs = resolved_messages
539
+ return {} if msgs.blank?
540
+
541
+ max_len = RubyLLM::Agents.configuration.messages_summary_max_length || 500
542
+
543
+ summary = {}
544
+
545
+ if msgs.first
546
+ summary[:first] = {
547
+ role: msgs.first[:role].to_s,
548
+ content: Redactor.redact_string(msgs.first[:content].to_s).truncate(max_len)
549
+ }
550
+ end
551
+
552
+ # Only add last if there are multiple messages and last is different from first
553
+ if msgs.size > 1 && msgs.last
554
+ summary[:last] = {
555
+ role: msgs.last[:role].to_s,
556
+ content: Redactor.redact_string(msgs.last[:content].to_s).truncate(max_len)
557
+ }
558
+ end
559
+
560
+ summary
561
+ end
562
+
519
563
  # Returns the response with redaction applied
520
564
  #
521
565
  # @param response [RubyLLM::Message] The LLM response
@@ -773,7 +817,9 @@ module RubyLLM
773
817
  total_cost: 0,
774
818
  parameters: redacted_parameters,
775
819
  metadata: execution_metadata,
776
- streaming: self.class.streaming
820
+ streaming: self.class.streaming,
821
+ messages_count: resolved_messages.size,
822
+ messages_summary: config.persist_messages_summary ? messages_summary : {}
777
823
  }
778
824
 
779
825
  # Add tracing fields from metadata if present
@@ -798,6 +844,28 @@ module RubyLLM
798
844
  Rails.logger.error("[RubyLLM::Agents] Failed to record cache hit execution: #{e.message}")
799
845
  end
800
846
 
847
+ # Records token usage to the BudgetTracker
848
+ #
849
+ # @param execution [Execution] The completed execution record
850
+ # @return [void]
851
+ def record_token_usage(execution)
852
+ return unless execution&.total_tokens && execution.total_tokens > 0
853
+
854
+ begin
855
+ tenant_id = respond_to?(:resolved_tenant_id) ? resolved_tenant_id : nil
856
+ tenant_config = respond_to?(:runtime_tenant_config) ? runtime_tenant_config : nil
857
+
858
+ BudgetTracker.record_tokens!(
859
+ self.class.name,
860
+ execution.total_tokens,
861
+ tenant_id: tenant_id,
862
+ tenant_config: tenant_config
863
+ )
864
+ rescue StandardError => e
865
+ Rails.logger.warn("[RubyLLM::Agents] Failed to record token usage: #{e.message}")
866
+ end
867
+ end
868
+
801
869
  # Emergency fallback to mark execution as failed
802
870
  #
803
871
  # Uses update_all to bypass ActiveRecord callbacks and validations,