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
@@ -39,45 +39,47 @@ module RubyLLM
39
39
  def call(context)
40
40
  context.started_at = Time.current
41
41
 
42
- # Create "running" record immediately (SYNC - must appear on dashboard)
43
- execution = create_running_execution(context)
44
- context.execution_id = execution&.id
45
- emit_start_notification(context)
46
- status_update_completed = false
47
- raised_exception = nil
48
-
49
- begin
50
- @app.call(context)
51
- context.completed_at = Time.current
52
-
53
- begin
54
- complete_execution(execution, context, status: "success")
55
- status_update_completed = true
56
- rescue
57
- # Let ensure block handle via mark_execution_failed!
58
- end
59
-
60
- emit_complete_notification(context, "success")
61
- rescue => e
62
- context.completed_at = Time.current
63
- context.error = e
64
- raised_exception = e
42
+ trace(context) do
43
+ # Create "running" record immediately (SYNC - must appear on dashboard)
44
+ execution = create_running_execution(context)
45
+ context.execution_id = execution&.id
46
+ emit_start_notification(context)
47
+ status_update_completed = false
48
+ raised_exception = nil
65
49
 
66
50
  begin
67
- complete_execution(execution, context, status: determine_error_status(e))
68
- status_update_completed = true
69
- rescue
70
- # Let ensure block handle via mark_execution_failed!
51
+ @app.call(context)
52
+ context.completed_at = Time.current
53
+
54
+ begin
55
+ complete_execution(execution, context, status: "success")
56
+ status_update_completed = true
57
+ rescue
58
+ # Let ensure block handle via mark_execution_failed!
59
+ end
60
+
61
+ emit_complete_notification(context, "success")
62
+ rescue => e
63
+ context.completed_at = Time.current
64
+ context.error = e
65
+ raised_exception = e
66
+
67
+ begin
68
+ complete_execution(execution, context, status: determine_error_status(e))
69
+ status_update_completed = true
70
+ rescue
71
+ # Let ensure block handle via mark_execution_failed!
72
+ end
73
+
74
+ emit_complete_notification(context, determine_error_status(e))
75
+ raise
76
+ ensure
77
+ # Emergency fallback if update failed
78
+ mark_execution_failed!(execution, error: raised_exception || $!) unless status_update_completed
71
79
  end
72
80
 
73
- emit_complete_notification(context, determine_error_status(e))
74
- raise
75
- ensure
76
- # Emergency fallback if update failed
77
- mark_execution_failed!(execution, error: raised_exception || $!) unless status_update_completed
81
+ context
78
82
  end
79
-
80
- context
81
83
  end
82
84
 
83
85
  private
@@ -111,7 +113,7 @@ module RubyLLM
111
113
 
112
114
  execution
113
115
  rescue => e
114
- error("Failed to create running execution record: #{e.message}")
116
+ error("Failed to create running execution record: #{e.message}", context)
115
117
  nil
116
118
  end
117
119
 
@@ -142,7 +144,7 @@ module RubyLLM
142
144
  # Save detail data (prompts, responses, tool calls, etc.)
143
145
  save_execution_details(execution, context, status)
144
146
  rescue => e
145
- error("Failed to complete execution record: #{e.message}")
147
+ error("Failed to complete execution record: #{e.message}", context)
146
148
  raise # Re-raise for ensure block to handle via mark_execution_failed!
147
149
  end
148
150
 
@@ -158,7 +160,7 @@ module RubyLLM
158
160
  return unless execution&.id
159
161
  return unless execution.status == "running"
160
162
 
161
- error_message = error ? "#{error.class}: #{error.message}".truncate(1000) : "Unknown error"
163
+ error_message = build_error_message(error)
162
164
 
163
165
  update_data = {
164
166
  status: "error",
@@ -176,13 +178,35 @@ module RubyLLM
176
178
  else
177
179
  RubyLLM::Agents::ExecutionDetail.create!(detail_attrs.merge(execution_id: execution.id))
178
180
  end
179
- rescue
180
- # Non-critical
181
+ rescue => e
182
+ debug("Failed to store error detail: #{e.message}")
181
183
  end
182
184
  rescue => e
183
185
  error("CRITICAL: Failed emergency status update for execution #{execution&.id}: #{e.message}")
184
186
  end
185
187
 
188
+ # Builds an informative error message including backtrace context
189
+ #
190
+ # Preserves the error class, message, and the most relevant
191
+ # backtrace frames (up to 10) so developers can trace the
192
+ # failure origin without needing to reproduce it.
193
+ #
194
+ # @param error [Exception, nil] The exception
195
+ # @return [String] Formatted error message with backtrace
196
+ def build_error_message(error)
197
+ return "Unknown error" unless error
198
+
199
+ parts = ["#{error.class}: #{error.message}"]
200
+
201
+ if error.backtrace&.any?
202
+ relevant_frames = error.backtrace.first(10)
203
+ parts << "Backtrace (first #{relevant_frames.size} frames):"
204
+ parts.concat(relevant_frames.map { |frame| " #{frame}" })
205
+ end
206
+
207
+ parts.join("\n").truncate(5000)
208
+ end
209
+
186
210
  # Determines the status based on error type
187
211
  #
188
212
  # @param error [Exception] The exception that occurred
@@ -205,8 +229,8 @@ module RubyLLM
205
229
  tenant_id: context.tenant_id,
206
230
  execution_id: context.execution_id
207
231
  )
208
- rescue
209
- # Never let notifications break execution
232
+ rescue => e
233
+ debug("Start notification failed: #{e.message}", context)
210
234
  end
211
235
 
212
236
  # Emits an AS::Notification for execution completion or error
@@ -242,8 +266,8 @@ module RubyLLM
242
266
  error_class: context.error&.class&.name,
243
267
  error_message: context.error&.message
244
268
  )
245
- rescue
246
- # Never let notifications break execution
269
+ rescue => e
270
+ debug("Complete notification failed: #{e.message}", context)
247
271
  end
248
272
 
249
273
  # Builds data for initial running execution record
@@ -295,6 +319,9 @@ module RubyLLM
295
319
  data[:root_execution_id] = context.root_execution_id || context.parent_execution_id
296
320
  end
297
321
 
322
+ # Inject tracker request_id and tags
323
+ inject_tracker_data(context, data)
324
+
298
325
  data
299
326
  end
300
327
 
@@ -321,7 +348,8 @@ module RubyLLM
321
348
 
322
349
  context_meta = begin
323
350
  context.metadata.dup
324
- rescue
351
+ rescue => e
352
+ debug("Failed to read context metadata: #{e.message}", context)
325
353
  {}
326
354
  end
327
355
  context_meta.transform_keys!(&:to_s)
@@ -364,7 +392,7 @@ module RubyLLM
364
392
  end
365
393
 
366
394
  if context.error
367
- detail_data[:error_message] = truncate_error_message(context.error.message)
395
+ detail_data[:error_message] = build_error_message(context.error)
368
396
  end
369
397
 
370
398
  if context[:tool_calls].present?
@@ -391,7 +419,7 @@ module RubyLLM
391
419
  execution.create_detail!(detail_data)
392
420
  end
393
421
  rescue => e
394
- error("Failed to save execution details: #{e.message}")
422
+ error("Failed to save execution details: #{e.message}", context)
395
423
  end
396
424
 
397
425
  # Persists execution data to database (legacy fallback)
@@ -411,75 +439,22 @@ module RubyLLM
411
439
  create_execution_record(data)
412
440
  end
413
441
  rescue => e
414
- error("Failed to record execution: #{e.message}")
442
+ error("Failed to record execution: #{e.message}", context)
415
443
  end
416
444
 
417
- # Builds execution data hash
445
+ # Builds execution data hash for the legacy single-step persistence path.
446
+ #
447
+ # Composes from build_running_execution_data and build_completion_data
448
+ # to avoid duplication.
418
449
  #
419
450
  # @param context [Context] The execution context
420
451
  # @param status [String] "success" or "error"
421
- # @return [Hash] Execution data
452
+ # @return [Hash] Execution data with _detail_data for detail record
422
453
  def build_execution_data(context, status)
423
- # Merge metadata: agent metadata (base) < middleware metadata (overlay)
424
- agent_meta = safe_agent_metadata(context)
425
- merged_metadata = agent_meta.transform_keys(&:to_s)
426
-
427
- context_meta = begin
428
- context.metadata.dup
429
- rescue
430
- {}
431
- end
432
- context_meta.transform_keys!(&:to_s)
433
- merged_metadata.merge!(context_meta)
434
-
435
- if context.cached? && context[:cache_key]
436
- merged_metadata["response_cache_key"] = context[:cache_key]
437
- end
438
-
439
- data = {
440
- agent_type: context.agent_class&.name,
441
- model_id: context.model,
442
- status: determine_status(context, status),
443
- duration_ms: context.duration_ms,
444
- started_at: context.started_at,
445
- completed_at: context.completed_at,
446
- cache_hit: context.cached?,
447
- input_tokens: context.input_tokens || 0,
448
- output_tokens: context.output_tokens || 0,
449
- total_cost: context.total_cost || 0,
450
- attempts_count: context.attempts_made,
451
- metadata: merged_metadata
452
- }
453
-
454
- # Extract tracing fields from agent metadata to dedicated columns
455
- if agent_meta.any?
456
- data[:trace_id] = agent_meta[:trace_id] if agent_meta[:trace_id]
457
- data[:request_id] = agent_meta[:request_id] if agent_meta[:request_id]
458
- data[:parent_execution_id] = agent_meta[:parent_execution_id] if agent_meta[:parent_execution_id]
459
- data[:root_execution_id] = agent_meta[:root_execution_id] if agent_meta[:root_execution_id]
460
- end
461
-
462
- # Add tenant_id only if multi-tenancy is enabled and tenant is set
463
- if global_config.multi_tenancy_enabled? && context.tenant_id.present?
464
- data[:tenant_id] = context.tenant_id
465
- end
466
-
467
- # Error class on execution
468
- if context.error
469
- data[:error_class] = context.error.class.name
470
- end
471
-
472
- # Tool calls count on execution
473
- if context[:tool_calls].present?
474
- data[:tool_calls_count] = context[:tool_calls].size
475
- end
476
-
477
- # Attempts count on execution
478
- if context[:reliability_attempts].present?
479
- data[:attempts_count] = context[:reliability_attempts].size
480
- end
454
+ data = build_running_execution_data(context)
455
+ .merge(build_completion_data(context, determine_status(context, status)))
481
456
 
482
- # Store detail data for separate creation
457
+ # Build detail data for separate creation
483
458
  detail_data = {parameters: sanitize_parameters(context)}
484
459
  if global_config.persist_prompts
485
460
  exec_opts = context.options[:options] || {}
@@ -487,18 +462,16 @@ module RubyLLM
487
462
  detail_data[:user_prompt] = context.input.to_s.presence
488
463
  detail_data[:assistant_prompt] = exec_opts[:assistant_prefill] if assistant_prompt_column_exists?
489
464
  end
490
- detail_data[:error_message] = truncate_error_message(context.error.message) if context.error
465
+ detail_data[:error_message] = build_error_message(context.error) if context.error
491
466
  detail_data[:tool_calls] = context[:tool_calls] if context[:tool_calls].present?
492
467
  detail_data[:attempts] = context[:reliability_attempts] if context[:reliability_attempts].present?
493
468
  if global_config.persist_responses && context.output.respond_to?(:content)
494
469
  detail_data[:response] = serialize_response(context)
495
470
  end
496
471
 
497
- # Persist audio data for Speaker executions
498
472
  maybe_persist_audio_response(context, detail_data)
499
473
 
500
474
  data[:_detail_data] = detail_data
501
-
502
475
  data
503
476
  end
504
477
 
@@ -527,7 +500,8 @@ module RubyLLM
527
500
 
528
501
  params = begin
529
502
  context.agent_instance.send(:options)
530
- rescue
503
+ rescue => e
504
+ debug("Failed to extract agent options: #{e.message}", context)
531
505
  {}
532
506
  end
533
507
  params = params.dup
@@ -558,10 +532,38 @@ module RubyLLM
558
532
  result = context.agent_instance.metadata
559
533
  result.is_a?(Hash) ? result : {}
560
534
  rescue => e
561
- debug("Failed to retrieve agent metadata: #{e.message}")
535
+ debug("Failed to retrieve agent metadata: #{e.message}", context)
562
536
  {}
563
537
  end
564
538
 
539
+ # Injects tracker request_id and tags into execution data
540
+ #
541
+ # Reads @_track_request_id and @_track_tags from the agent instance,
542
+ # which are set by BaseAgent#initialize when a Tracker is active.
543
+ #
544
+ # @param context [Context] The execution context
545
+ # @param data [Hash] The execution data hash to modify
546
+ def inject_tracker_data(context, data)
547
+ agent = context.agent_instance
548
+ return unless agent
549
+
550
+ # Inject request_id
551
+ track_request_id = agent.instance_variable_get(:@_track_request_id)
552
+ if track_request_id && data[:request_id].blank?
553
+ data[:request_id] = track_request_id
554
+ end
555
+
556
+ # Merge tracker tags into metadata
557
+ track_tags = agent.instance_variable_get(:@_track_tags)
558
+ if track_tags.is_a?(Hash) && track_tags.any?
559
+ data[:metadata] = (data[:metadata] || {}).merge(
560
+ "tags" => track_tags.transform_keys(&:to_s)
561
+ )
562
+ end
563
+ rescue
564
+ # Never let tracker data injection break execution
565
+ end
566
+
565
567
  # Sensitive parameter keys that should be redacted
566
568
  SENSITIVE_KEYS = %w[
567
569
  password token api_key secret credential auth key
@@ -580,7 +582,7 @@ module RubyLLM
580
582
  def truncate_error_message(message)
581
583
  return "" if message.nil?
582
584
 
583
- message.to_s.truncate(1000)
585
+ message.to_s.truncate(5000)
584
586
  rescue
585
587
  message.to_s[0, 1000]
586
588
  end
@@ -607,7 +609,7 @@ module RubyLLM
607
609
 
608
610
  response_data
609
611
  rescue => e
610
- error("Failed to serialize response: #{e.message}")
612
+ error("Failed to serialize response: #{e.message}", context)
611
613
  nil
612
614
  end
613
615
 
@@ -634,7 +636,7 @@ module RubyLLM
634
636
 
635
637
  detail_data[:response] = serialize_audio_response(context.output)
636
638
  rescue => e
637
- error("Failed to persist audio response: #{e.message}")
639
+ error("Failed to persist audio response: #{e.message}", context)
638
640
  end
639
641
 
640
642
  # Serializes a SpeechResult into a hash for the response column
@@ -689,7 +691,8 @@ module RubyLLM
689
691
  else
690
692
  cfg.track_executions
691
693
  end
692
- rescue
694
+ rescue => e
695
+ debug("Failed to check tracking config: #{e.message}", context)
693
696
  false
694
697
  end
695
698
 
@@ -698,7 +701,8 @@ module RubyLLM
698
701
  # @return [Boolean]
699
702
  def track_cache_hits?
700
703
  global_config.respond_to?(:track_cache_hits) && global_config.track_cache_hits
701
- rescue
704
+ rescue => e
705
+ debug("Failed to check track_cache_hits config: #{e.message}")
702
706
  false
703
707
  end
704
708
 
@@ -707,7 +711,8 @@ module RubyLLM
707
711
  # @return [Boolean]
708
712
  def async_logging?
709
713
  global_config.async_logging && defined?(Infrastructure::ExecutionLoggerJob)
710
- rescue
714
+ rescue => e
715
+ debug("Failed to check async_logging config: #{e.message}")
711
716
  false
712
717
  end
713
718
 
@@ -722,7 +727,8 @@ module RubyLLM
722
727
  @_assistant_prompt_column_exists = begin
723
728
  defined?(RubyLLM::Agents::ExecutionDetail) &&
724
729
  RubyLLM::Agents::ExecutionDetail.column_names.include?("assistant_prompt")
725
- rescue
730
+ rescue => e
731
+ debug("Failed to check assistant_prompt column: #{e.message}")
726
732
  false
727
733
  end
728
734
  end
@@ -41,11 +41,13 @@ module RubyLLM
41
41
  def call(context)
42
42
  return @app.call(context) unless reliability_enabled?
43
43
 
44
- config = reliability_config
45
- models_to_try = build_models_list(context, config)
46
- total_deadline = calculate_deadline(config)
44
+ trace(context) do
45
+ config = reliability_config
46
+ models_to_try = build_models_list(context, config)
47
+ total_deadline = calculate_deadline(config)
47
48
 
48
- execute_with_reliability(context, models_to_try, config, total_deadline)
49
+ execute_with_reliability(context, models_to_try, config, total_deadline)
50
+ end
49
51
  end
50
52
 
51
53
  private
@@ -103,7 +105,7 @@ module RubyLLM
103
105
  # Check circuit breaker for this model
104
106
  breaker = get_circuit_breaker(current_model, context)
105
107
  if breaker&.open?
106
- debug("Circuit breaker open for #{current_model}, skipping")
108
+ debug("Circuit breaker open for #{current_model}, skipping", context)
107
109
  tracker.record_short_circuit(current_model)
108
110
  next
109
111
  end
@@ -237,7 +239,7 @@ module RubyLLM
237
239
  return false if attempt_index >= max_retries
238
240
  return false if total_deadline && Time.current > total_deadline
239
241
  # Don't retry if fallback models are available — move to next model instead
240
- return false if has_fallback_models?(config)
242
+ return false if fallback_models?(config)
241
243
 
242
244
  retryable_error?(error, config)
243
245
  end
@@ -256,7 +258,7 @@ module RubyLLM
256
258
  #
257
259
  # @param config [Hash] The reliability configuration
258
260
  # @return [Boolean]
259
- def has_fallback_models?(config)
261
+ def fallback_models?(config)
260
262
  fallbacks = config[:fallback_models]
261
263
  fallbacks.is_a?(Array) && fallbacks.any?
262
264
  end
@@ -317,8 +319,8 @@ module RubyLLM
317
319
  event,
318
320
  {agent_type: @agent_class&.name}.merge(extras)
319
321
  )
320
- rescue
321
- # Never let notifications break execution
322
+ rescue => e
323
+ debug("Reliability notification failed: #{e.message}")
322
324
  end
323
325
 
324
326
  # Sleeps without blocking other fibers when in async context
@@ -333,8 +335,8 @@ module RubyLLM
333
335
  else
334
336
  sleep(seconds)
335
337
  end
336
- rescue
337
- # Fall back to regular sleep if async detection fails
338
+ rescue => e
339
+ debug("Async sleep failed, falling back to regular sleep: #{e.message}")
338
340
  sleep(seconds)
339
341
  end
340
342
  end
@@ -41,10 +41,12 @@ module RubyLLM
41
41
  # @param context [Context] The execution context
42
42
  # @return [Context] The context with tenant fields populated
43
43
  def call(context)
44
- resolve_tenant!(context)
45
- ensure_tenant_record!(context)
46
- apply_api_configuration!(context)
47
- @app.call(context)
44
+ trace(context) do
45
+ resolve_tenant!(context)
46
+ ensure_tenant_record!(context)
47
+ apply_api_configuration!(context)
48
+ @app.call(context)
49
+ end
48
50
  end
49
51
 
50
52
  private
@@ -112,7 +114,7 @@ module RubyLLM
112
114
  ensure_tenant_for_model!(tenant_object)
113
115
  else
114
116
  # For hash-based or string tenants, ensure a minimal record exists
115
- RubyLLM::Agents::Tenant.find_or_create_by!(tenant_id: context.tenant_id)
117
+ find_or_create_tenant!(context.tenant_id)
116
118
  end
117
119
  rescue => e
118
120
  # Don't fail the execution if tenant record creation fails
@@ -145,6 +147,18 @@ module RubyLLM
145
147
  enforcement: options[:enforcement]&.to_s || "soft",
146
148
  inherit_global_defaults: options.fetch(:inherit_global, true)
147
149
  )
150
+ rescue ActiveRecord::RecordNotUnique
151
+ # Race condition: another thread created the record — safe to ignore
152
+ end
153
+
154
+ # Finds or creates a tenant record, handling race conditions
155
+ #
156
+ # @param tenant_id [String] The tenant identifier
157
+ def find_or_create_tenant!(tenant_id)
158
+ RubyLLM::Agents::Tenant.find_or_create_by!(tenant_id: tenant_id)
159
+ rescue ActiveRecord::RecordNotUnique
160
+ # Another thread/process created the record — just find it
161
+ RubyLLM::Agents::Tenant.find_by!(tenant_id: tenant_id)
148
162
  end
149
163
 
150
164
  # Checks if the tenants table exists (memoized)
@@ -154,7 +168,8 @@ module RubyLLM
154
168
  return @tenant_table_exists if defined?(@tenant_table_exists)
155
169
 
156
170
  @tenant_table_exists = ::ActiveRecord::Base.connection.table_exists?(:ruby_llm_agents_tenants)
157
- rescue
171
+ rescue => e
172
+ debug("Failed to check tenant table existence: #{e.message}")
158
173
  @tenant_table_exists = false
159
174
  end
160
175
 
@@ -178,7 +193,11 @@ module RubyLLM
178
193
  apply_tenant_object_api_keys!(context)
179
194
  end
180
195
 
181
- # Applies API keys from tenant object's llm_api_keys method
196
+ # Stores tenant API keys on the context for thread-safe per-request use.
197
+ #
198
+ # Instead of mutating the global RubyLLM configuration (which is not
199
+ # thread-safe), keys are stored on the context. The Pipeline::Context#llm
200
+ # method creates a scoped RubyLLM::Context with these keys when needed.
182
201
  #
183
202
  # @param context [Context] The execution context
184
203
  def apply_tenant_object_api_keys!(context)
@@ -188,34 +207,12 @@ module RubyLLM
188
207
  api_keys = tenant_object.llm_api_keys
189
208
  return if api_keys.blank?
190
209
 
191
- apply_api_keys_to_ruby_llm(api_keys)
210
+ context[:tenant_api_keys] = api_keys
192
211
  rescue => e
193
212
  # Log but don't fail if API key extraction fails
194
213
  warn_api_key_error("tenant object", e)
195
214
  end
196
215
 
197
- # Applies a hash of API keys to RubyLLM configuration
198
- #
199
- # @param api_keys [Hash] Hash of provider => key mappings
200
- def apply_api_keys_to_ruby_llm(api_keys)
201
- RubyLLM.configure do |config|
202
- api_keys.each do |provider, key|
203
- next if key.blank?
204
-
205
- setter = api_key_setter_for(provider)
206
- config.public_send(setter, key) if config.respond_to?(setter)
207
- end
208
- end
209
- end
210
-
211
- # Returns the setter method name for a provider's API key
212
- #
213
- # @param provider [Symbol, String] Provider name (e.g., :openai, :anthropic)
214
- # @return [String] Setter method name (e.g., "openai_api_key=")
215
- def api_key_setter_for(provider)
216
- "#{provider}_api_key="
217
- end
218
-
219
216
  # Logs a warning about API key resolution failure
220
217
  #
221
218
  # @param source [String] Source that failed
@@ -236,7 +233,8 @@ module RubyLLM
236
233
  return nil unless tenant.respond_to?(:llm_config)
237
234
 
238
235
  tenant.llm_config
239
- rescue
236
+ rescue => e
237
+ debug("Failed to extract tenant config: #{e.message}")
240
238
  nil
241
239
  end
242
240
  end