From 9aa8eec4c8a36746d37b4dfcc2648f9d607df3fd Mon Sep 17 00:00:00 2001 From: Edgars Beigarts Date: Thu, 19 Mar 2026 17:12:26 +0200 Subject: [PATCH 1/2] Add test for dynamic tool registration during tool execution Verifies that calling chat.with_tool from within a tool execute method (the ToolSearch pattern) does not corrupt the message sequence sent to the provider. Before the fix, to_llm reset messages from the database mid-conversation, inserting a stray empty assistant message between the tool_calls and tool result. Co-Authored-By: Claude Opus 4.6 (1M context) --- spec/ruby_llm/active_record/acts_as_spec.rb | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/spec/ruby_llm/active_record/acts_as_spec.rb b/spec/ruby_llm/active_record/acts_as_spec.rb index 1bd648ec9..cbe6e0560 100644 --- a/spec/ruby_llm/active_record/acts_as_spec.rb +++ b/spec/ruby_llm/active_record/acts_as_spec.rb @@ -106,6 +106,70 @@ def execute(expression:) result = chat.with_tool(Calculator) expect(result).to eq(chat) end + + it 'supports dynamically adding tools during tool execution' do + # A tool that dynamically registers another tool on the chat when executed. + # This simulates the "ToolSearch" pattern where a tool discovers and registers + # new tools mid-conversation via chat.with_tool. + dynamic_tool = Class.new(RubyLLM::Tool) do + description 'Searches for tools and makes them available' + param :query, type: :string, desc: 'Search query' + + attr_accessor :chat_ref + + def execute(query:) + chat_ref.with_tool(Calculator) + "Found calculator tool for: #{query}" + end + end + + chat = Chat.create!(model: model) + tool_instance = dynamic_tool.new + tool_instance.chat_ref = chat + chat.with_tool(tool_instance) + + llm_chat = chat.instance_variable_get(:@chat) + provider = llm_chat.instance_variable_get(:@provider) + + # First response: model calls the dynamic tool search + search_tool_call = RubyLLM::ToolCall.new( + id: 'call_1', + name: tool_instance.name, + arguments: { 'query' => 'calculator' } + ) + + # Capture messages sent to the provider on each complete call to verify + # that no extra empty assistant message is inserted between tool_calls + # and tool results (the actual bug this test guards against). + messages_per_call = [] + call_count = 0 + allow(provider).to receive(:complete) do |messages, **_kwargs, &_block| + messages_per_call << messages.map { |m| { role: m.role.to_s, content: m.content.to_s } } + call_count += 1 + case call_count + when 1 + RubyLLM::Message.new( + role: :assistant, content: '', + tool_calls: { search_tool_call.id => search_tool_call } + ) + else + RubyLLM::Message.new( + role: :assistant, content: 'Found it!' + ) + end + end + + response = chat.ask('Find me a calculator') + expect(response.content).to eq('Found it!') + + # On the second provider call, verify no stray empty assistant message + # was inserted between the tool_calls assistant and the tool result. + # The bug caused messages to be: [user, assistant(tool_calls), assistant(""), tool] + # Correct should be: [user, assistant(tool_calls), tool] + second_call_roles = messages_per_call[1].map { |m| m[:role] } + assistant_count = second_call_roles.count('assistant') + expect(assistant_count).to eq(1), "Expected 1 assistant message but got #{assistant_count}: #{second_call_roles}" + end end describe 'model switching' do From fd290a209d48af1f4f6e71bf5152f8c971f81f9d Mon Sep 17 00:00:00 2001 From: Edgars Beigarts Date: Thu, 19 Mar 2026 16:40:56 +0200 Subject: [PATCH 2/2] Fix ActiveRecord with_tool/with_tools resetting messages mid-conversation When a tool dynamically registers new tools during execution (e.g., a ToolSearch tool that calls chat.with_tool to make discovered tools available), the ActiveRecord with_tool/with_tools methods were calling to_llm which reset all messages from the database. This inserted an extra empty assistant message between the tool_calls message and its tool result, violating the OpenAI API contract and causing a BadRequestError. Add a reset_messages parameter to to_llm so that with_tool/with_tools can initialize the chat object without disrupting the in-flight message sequence. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/ruby_llm/active_record/chat_methods.rb | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/lib/ruby_llm/active_record/chat_methods.rb b/lib/ruby_llm/active_record/chat_methods.rb index 75a14afd4..2fdf7d121 100644 --- a/lib/ruby_llm/active_record/chat_methods.rb +++ b/lib/ruby_llm/active_record/chat_methods.rb @@ -75,19 +75,23 @@ def resolve_model_from_strings # rubocop:disable Metrics/PerceivedComplexity public - def to_llm + def to_llm(reset_messages: true) model_record = model_association @chat ||= (context || RubyLLM).chat( model: model_record.model_id, provider: model_record.provider.to_sym, assume_model_exists: assume_model_exists || false ) - @chat.reset_messages! - ordered_messages = order_messages_for_llm(messages_association.to_a) - ordered_messages.each do |msg| - @chat.add_message(msg.to_llm) + if reset_messages + @chat.reset_messages! + + ordered_messages = order_messages_for_llm(messages_association.to_a) + ordered_messages.each do |msg| + @chat.add_message(msg.to_llm) + end end + reapply_runtime_instructions(@chat) setup_persistence_callbacks @@ -110,12 +114,12 @@ def with_runtime_instructions(instructions, append: false, replace: nil) end def with_tool(...) - to_llm.with_tool(...) + to_llm(reset_messages: false).with_tool(...) self end def with_tools(...) - to_llm.with_tools(...) + to_llm(reset_messages: false).with_tools(...) self end