Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions lib/ruby_llm/active_record/chat_methods.rb
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ def to_llm
provider: model_record.provider.to_sym,
assume_model_exists: assume_model_exists || false
)
@chat.messages = messages_association.to_a
@chat.messages = eager_load_messages
reapply_runtime_instructions(@chat)

setup_persistence_callbacks
Expand Down Expand Up @@ -282,7 +282,7 @@ def cleanup_failed_messages

def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
messages_association.reload
last = messages_association.order(:id).last
last = eager_load_messages.max_by(&:id)

return unless last&.tool_call? || last&.tool_result?

Expand All @@ -301,6 +301,22 @@ def cleanup_orphaned_tool_results # rubocop:disable Metrics/PerceivedComplexity
end
end

def eager_load_messages
assoc = messages_association
messages = assoc.to_a
return messages unless assoc.respond_to?(:klass)

msg_class = assoc.klass
associations = [
msg_class.tool_calls_association_name,
:parent_tool_call,
msg_class.model_association_name
].compact

::ActiveRecord::Associations::Preloader.new(records: messages, associations: associations).call
messages
end

def find_or_create_model_record(model_info)
model_class = self.class.model_class.constantize
model_class.find_or_create_by!(
Expand Down
42 changes: 42 additions & 0 deletions spec/ruby_llm/active_record/acts_as_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -1136,6 +1136,48 @@ class ToolCall < ActiveRecord::Base # rubocop:disable RSpec/LeakyConstantDeclara
end
end

describe 'strict_loading compatibility' do
# Verify that to_llm and cleanup_orphaned_tool_results eager-load message
# associations, preventing N+1 queries with strict_loading enabled.

let(:strict_loading_state) { {} }

let!(:chat_with_tool_calls) do
chat = Chat.create!(model: model)
chat.messages.create!(role: 'user', content: "What's 2 + 2?")
assistant_msg = chat.messages.create!(role: 'assistant', content: nil)
tool_call = assistant_msg.tool_calls.create!(
tool_call_id: 'call_strict_1',
name: 'calculator',
arguments: { expression: '2 + 2' }.to_json
)
chat.messages.create!(role: 'tool', content: '4', parent_tool_call: tool_call)
chat.messages.create!(role: 'assistant', content: 'The answer is 4.')
chat
end

before do
strict_loading_state[:by_default] = ApplicationRecord.strict_loading_by_default
strict_loading_state[:mode] = ApplicationRecord.strict_loading_mode

ApplicationRecord.strict_loading_by_default = true
ApplicationRecord.strict_loading_mode = :n_plus_one_only
end

after do
ApplicationRecord.strict_loading_by_default = strict_loading_state[:by_default]
ApplicationRecord.strict_loading_mode = strict_loading_state[:mode]
end

it 'to_llm does not raise StrictLoadingViolationError' do
expect { chat_with_tool_calls.reload.to_llm }.not_to raise_error
end

it 'cleanup_orphaned_tool_results does not raise StrictLoadingViolationError' do
expect { chat_with_tool_calls.reload.send(:cleanup_orphaned_tool_results) }.not_to raise_error
end
end

describe 'extended thinking persistence' do
def thinking_config_for(provider)
case provider
Expand Down
Loading