Skip to content
Open
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
13 changes: 8 additions & 5 deletions lib/ruby_llm/providers/gemini/chat.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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

Expand Down
30 changes: 25 additions & 5 deletions lib/ruby_llm/providers/gemini/tools.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,30 +21,49 @@ 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: {
name: function_name,
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
Expand All @@ -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

Expand Down
12 changes: 9 additions & 3 deletions lib/ruby_llm/tool_call.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
121 changes: 121 additions & 0 deletions spec/ruby_llm/providers/gemini/tools_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
69 changes: 69 additions & 0 deletions spec/ruby_llm/tool_call_spec.rb
Original file line number Diff line number Diff line change
@@ -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