diff --git a/lib/ruby_llm/providers/gemini/chat.rb b/lib/ruby_llm/providers/gemini/chat.rb index bab33f79a..dba9006c1 100644 --- a/lib/ruby_llm/providers/gemini/chat.rb +++ b/lib/ruby_llm/providers/gemini/chat.rb @@ -216,8 +216,8 @@ def collect_tool_parts while tool_message?(@messages[index]) tool_message = @messages[index] - tool_name = @tool_call_names.delete(tool_message.tool_call_id) - parts.concat(format_tool_result(tool_message, tool_name)) + tool_metadata = @tool_call_names.delete(tool_message.tool_call_id) + parts.concat(format_tool_result(tool_message, tool_metadata)) index += 1 end @@ -230,7 +230,10 @@ def build_tool_response(parts) def remember_tool_calls current_message.tool_calls.each do |tool_call_id, tool_call| - @tool_call_names[tool_call_id] = tool_call.name + @tool_call_names[tool_call_id] = { + name: tool_call.name, + thought_signature: tool_call.thought_signature + } end end @@ -241,8 +244,8 @@ def build_standard_message(message) } end - def format_tool_result(message, tool_name) - @format_tool_result.call(message, tool_name) + def format_tool_result(message, tool_metadata) + @format_tool_result.call(message, tool_metadata) end end diff --git a/lib/ruby_llm/providers/gemini/tools.rb b/lib/ruby_llm/providers/gemini/tools.rb index aab5341e4..debc0d7d2 100644 --- a/lib/ruby_llm/providers/gemini/tools.rb +++ b/lib/ruby_llm/providers/gemini/tools.rb @@ -21,22 +21,37 @@ def format_tool_call(msg) parts.concat(formatted_content.is_a?(Array) ? formatted_content : [formatted_content]) end + first_call = true msg.tool_calls.each_value do |tool_call| - parts << { + part = { functionCall: { name: tool_call.name, args: tool_call.arguments } } + # Per Gemini docs: only the first functionCall has the thoughtSignature + if first_call && tool_call.thought_signature + part[:thoughtSignature] = tool_call.thought_signature + first_call = false + end + parts << part end parts end - def format_tool_result(msg, function_name = nil) + def format_tool_result(msg, function_metadata = nil) + if function_metadata.is_a?(Hash) + function_name = function_metadata[:name] + thought_signature = function_metadata[:thought_signature] + else + function_name = function_metadata + thought_signature = nil + end + function_name ||= msg.tool_call_id - [{ + response_part = { functionResponse: { name: function_name, response: { @@ -44,7 +59,11 @@ def format_tool_result(msg, function_name = nil) content: Media.format_content(msg.content) } } - }] + } + + response_part[:thoughtSignature] = thought_signature if thought_signature + + [response_part] end def extract_tool_calls(data) # rubocop:disable Metrics/PerceivedComplexity @@ -65,7 +84,8 @@ def extract_tool_calls(data) # rubocop:disable Metrics/PerceivedComplexity result[id] = ToolCall.new( id:, name: function_data['name'], - arguments: function_data['args'] || {} + arguments: function_data['args'] || {}, + thought_signature: part['thoughtSignature'] ) end diff --git a/lib/ruby_llm/tool_call.rb b/lib/ruby_llm/tool_call.rb index bb9c1857e..046f4d856 100644 --- a/lib/ruby_llm/tool_call.rb +++ b/lib/ruby_llm/tool_call.rb @@ -2,21 +2,27 @@ module RubyLLM # Represents a function call from an AI model to a Tool. + # @attr_reader thought_signature [String, nil] Gemini 3's encrypted reasoning context + # for preserving model state across multi-turn function calling conversations. + # See: https://ai.google.dev/gemini-api/docs/thought-signatures class ToolCall - attr_reader :id, :name, :arguments + attr_reader :id, :name, :arguments, :thought_signature - def initialize(id:, name:, arguments: {}) + def initialize(id:, name:, arguments: {}, thought_signature: nil) @id = id @name = name @arguments = arguments + @thought_signature = thought_signature end def to_h - { + hash = { id: @id, name: @name, arguments: @arguments } + hash[:thought_signature] = @thought_signature if @thought_signature + hash end end end diff --git a/spec/ruby_llm/providers/gemini/tools_spec.rb b/spec/ruby_llm/providers/gemini/tools_spec.rb index b5afc29e4..59be0d24c 100644 --- a/spec/ruby_llm/providers/gemini/tools_spec.rb +++ b/spec/ruby_llm/providers/gemini/tools_spec.rb @@ -31,6 +31,46 @@ expect(tool_calls.values.map(&:name)).to eq(%w[weather best_language_to_learn]) expect(tool_calls.values.last.arguments).to eq({}) end + + it 'extracts thought_signature from Gemini 3 responses' do + data = { + 'candidates' => [ + { + 'content' => { + 'parts' => [ + { + 'functionCall' => { 'name' => 'weather', 'args' => { 'city' => 'Berlin' } }, + 'thoughtSignature' => 'gemini-signature-abc123' + } + ] + } + } + ] + } + + tool_calls = test_obj.extract_tool_calls(data) + + expect(tool_calls&.size).to eq(1) + expect(tool_calls.values.first.thought_signature).to eq('gemini-signature-abc123') + end + + it 'handles missing thought_signature gracefully' do + data = { + 'candidates' => [ + { + 'content' => { + 'parts' => [ + { 'functionCall' => { 'name' => 'test_tool', 'args' => {} } } + ] + } + } + ] + } + + tool_calls = test_obj.extract_tool_calls(data) + + expect(tool_calls.values.first.thought_signature).to be_nil + end end describe '#format_tool_call' do @@ -48,6 +88,47 @@ expect(result[1][:functionCall]).to eq(name: 'weather', args: { 'latitude' => '52.5200' }) expect(result[2][:functionCall]).to eq(name: 'best_language_to_learn', args: {}) end + + it 'includes thought_signature in first functionCall part for Gemini 3' do + tool_calls = { + 'a' => RubyLLM::ToolCall.new(id: 'a', name: 'weather', arguments: {}, + thought_signature: 'gemini-signature-xyz') + } + message = RubyLLM::Message.new(role: :assistant, content: nil, tool_calls:) + + result = test_obj.format_tool_call(message) + + expect(result.length).to eq(1) + expect(result.first[:functionCall]).to eq(name: 'weather', args: {}) + expect(result.first[:thoughtSignature]).to eq('gemini-signature-xyz') + end + + it 'only includes thought_signature on first call for parallel calls' do + tool_calls = { + 'a' => RubyLLM::ToolCall.new(id: 'a', name: 'tool_one', arguments: {}, + thought_signature: 'signature-first'), + 'b' => RubyLLM::ToolCall.new(id: 'b', name: 'tool_two', arguments: {}, + thought_signature: nil) + } + message = RubyLLM::Message.new(role: :assistant, content: nil, tool_calls:) + + result = test_obj.format_tool_call(message) + + expect(result.length).to eq(2) + expect(result.first[:thoughtSignature]).to eq('signature-first') + expect(result.last).not_to have_key(:thoughtSignature) + end + + it 'excludes thought_signature when nil' do + tool_calls = { + 'a' => RubyLLM::ToolCall.new(id: 'a', name: 'test_tool', arguments: {}) + } + message = RubyLLM::Message.new(role: :assistant, content: nil, tool_calls:) + + result = test_obj.format_tool_call(message) + + expect(result.first).not_to have_key(:thoughtSignature) + end end describe '#format_tool_result' do @@ -72,5 +153,45 @@ } ]) end + + it 'includes thought_signature in response when metadata is a hash' do + message = RubyLLM::Message.new( + role: :tool, + content: 'Result payload', + tool_call_id: 'uuid-123' + ) + metadata = { name: 'weather', thought_signature: 'signature-xyz' } + + result = test_obj.format_tool_result(message, metadata) + + expect(result.first[:functionResponse][:name]).to eq('weather') + expect(result.first[:thoughtSignature]).to eq('signature-xyz') + end + + it 'handles string metadata for backward compatibility' do + message = RubyLLM::Message.new( + role: :tool, + content: 'Result payload', + tool_call_id: 'uuid-123' + ) + + result = test_obj.format_tool_result(message, 'weather') + + expect(result.first[:functionResponse][:name]).to eq('weather') + expect(result.first).not_to have_key(:thoughtSignature) + end + + it 'excludes thought_signature when nil' do + message = RubyLLM::Message.new( + role: :tool, + content: 'Result payload', + tool_call_id: 'uuid-123' + ) + metadata = { name: 'test_tool', thought_signature: nil } + + result = test_obj.format_tool_result(message, metadata) + + expect(result.first).not_to have_key(:thoughtSignature) + end end end diff --git a/spec/ruby_llm/tool_call_spec.rb b/spec/ruby_llm/tool_call_spec.rb new file mode 100644 index 000000000..3f735d295 --- /dev/null +++ b/spec/ruby_llm/tool_call_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe RubyLLM::ToolCall do + describe '#initialize' do + it 'creates a tool call with required attributes' do + tool_call = described_class.new(id: 'test-id', name: 'test_tool', arguments: { foo: 'bar' }) + + expect(tool_call.id).to eq('test-id') + expect(tool_call.name).to eq('test_tool') + expect(tool_call.arguments).to eq({ foo: 'bar' }) + end + + it 'defaults arguments to empty hash' do + tool_call = described_class.new(id: 'test-id', name: 'test_tool') + + expect(tool_call.arguments).to eq({}) + end + + it 'accepts and stores thought_signature parameter' do + tool_call = described_class.new( + id: 'test-id', + name: 'test_tool', + arguments: {}, + thought_signature: 'gemini-signature-123' + ) + + expect(tool_call.thought_signature).to eq('gemini-signature-123') + end + + it 'defaults thought_signature to nil' do + tool_call = described_class.new(id: 'test-id', name: 'test_tool') + + expect(tool_call.thought_signature).to be_nil + end + end + + describe '#to_h' do + it 'returns hash representation of tool call' do + tool_call = described_class.new(id: 'test-id', name: 'test_tool', arguments: { foo: 'bar' }) + + expect(tool_call.to_h).to eq({ + id: 'test-id', + name: 'test_tool', + arguments: { foo: 'bar' } + }) + end + + it 'includes thought_signature in hash when present' do + tool_call = described_class.new( + id: 'test-id', + name: 'test_tool', + arguments: {}, + thought_signature: 'signature-123' + ) + + hash = tool_call.to_h + expect(hash[:thought_signature]).to eq('signature-123') + end + + it 'excludes thought_signature from hash when nil' do + tool_call = described_class.new(id: 'test-id', name: 'test_tool', arguments: {}) + + hash = tool_call.to_h + expect(hash).not_to have_key(:thought_signature) + end + end +end