diff --git a/docs/_core_features/tools.md b/docs/_core_features/tools.md index b8c9d1388..b138c2b7d 100644 --- a/docs/_core_features/tools.md +++ b/docs/_core_features/tools.md @@ -85,6 +85,15 @@ end > ``` {: .note } +> If a model attempts to call a tool that doesn't exist (sometimes called "tool hallucination"), RubyLLM handles this gracefully by: +> +> 1. Returning an error message to the model indicating which tool it tried to call +> 2. Listing the actually available tools +> 3. Allowing the conversation to continue so the model can correct itself +> +> This prevents crashes and gives the model a chance to use the correct tool or respond appropriately. +{: .note } + ## Declaring Parameters RubyLLM ships with two complementary approaches: diff --git a/lib/ruby_llm/chat.rb b/lib/ruby_llm/chat.rb index bf35ed5eb..3fe6c2213 100644 --- a/lib/ruby_llm/chat.rb +++ b/lib/ruby_llm/chat.rb @@ -209,6 +209,13 @@ def handle_tool_calls(response, &) # rubocop:disable Metrics/PerceivedComplexity def execute_tool(tool_call) tool = tools[tool_call.name.to_sym] + if tool.nil? + return { + error: "Model tried to call unavailable tool `#{tool_call.name}`. " \ + "Available tools: #{tools.keys.to_json}." + } + end + args = tool_call.arguments tool.call(args) end diff --git a/spec/ruby_llm/chat_tools_spec.rb b/spec/ruby_llm/chat_tools_spec.rb index 385cef2bb..de6abb9f9 100644 --- a/spec/ruby_llm/chat_tools_spec.rb +++ b/spec/ruby_llm/chat_tools_spec.rb @@ -171,6 +171,40 @@ def tool_result_message_for(chat, tool_call) expect(response.content).to include('15') expect(response.content).to include('10') end + + it "#{provider}/#{model} deals with non-existent tool calls" do + hallucinated_tool_call = RubyLLM::ToolCall.new( + id: 'call_1', + name: 'list_tools', + arguments: {} + ) + + tool_results_received = [] + + chat = RubyLLM.chat(model: model, provider: provider) + .with_tool(Weather) + .on_tool_result { |result| tool_results_received << result } + + final_answer = 'The `list_tools` tool is not supported, but I see you have the `weather` tool.' + allow(chat.instance_variable_get(:@provider)).to receive(:complete).and_return( + RubyLLM::Message.new( + role: :assistant, + content: '', + tool_calls: { hallucinated_tool_call.id => hallucinated_tool_call } + ), + RubyLLM::Message.new( + role: :assistant, + content: final_answer + ) + ) + + response = chat.ask('What tools do you support?') + expect(response.content).to eq(final_answer) + expect(tool_results_received).to eq([ + { error: 'Model tried to call unavailable tool `list_tools`. ' \ + 'Available tools: ["weather"].' } + ]) + end end describe 'thought signatures' do