Skip to content

Commit f81fb4f

Browse files
authored
Merge pull request #2155 from dgageot/board/i-ve-set-a-model-on-a-tool-to-google-gem-a37899ae
fix: use dummy thought_signature for cross-model Gemini function calls
2 parents 3a90478 + 2b65d28 commit f81fb4f

3 files changed

Lines changed: 111 additions & 12 deletions

File tree

e2e/testdata/cassettes/TestExec_Gemini_ToolCall.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,11 @@ interactions:
2020
proto_major: 2
2121
proto_minor: 0
2222
content_length: -1
23-
body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"list_directory\",\"args\": {\"path\": \"testdata/working_dir\"}}}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 868,\"candidatesTokenCount\": 20,\"totalTokenCount\": 888,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 868}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"EhemaamYOKz7nsEP58LA8Qw\"}\r\n\r\n"
23+
body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"functionCall\": {\"name\": \"list_directory\",\"args\": {\"path\": \"testdata/working_dir\"}}}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0,\"finishMessage\": \"Model generated function call(s).\"}],\"usageMetadata\": {\"promptTokenCount\": 790,\"candidatesTokenCount\": 20,\"totalTokenCount\": 810,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 790}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"7HC6aZ-dMp2Dxs0Py7flgAQ\"}\r\n\r\n"
2424
headers: {}
2525
status: 200 OK
2626
code: 200
27-
duration: 877.10825ms
27+
duration: 655.942ms
2828
- id: 1
2929
request:
3030
proto: HTTP/1.1
@@ -33,7 +33,7 @@ interactions:
3333
content_length: 0
3434
host: generativelanguage.googleapis.com
3535
body: |
36-
{"contents":[{"parts":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n"}],"role":"user"},{"parts":[{"text":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output"}],"role":"user"},{"parts":[{"text":"How many files in testdata/working_dir? Only output the number."}],"role":"user"},{"parts":[{"functionCall":{"args":{"path":"testdata/working_dir"},"name":"list_directory"}}],"role":"model"},{"parts":[{"functionResponse":{"name":"call_1338bc52-c261-432d-b5e5-9dfb8f9c13bb","response":{"result":"FILE README.me\n"}}}],"role":"user"}],"generationConfig":{"maxOutputTokens":65536,"thinkingConfig":{"thinkingBudget":0}},"toolConfig":{"functionCallingConfig":{"mode":"AUTO"}},"tools":[{"functionDeclarations":[{"description":"Get a recursive tree view of files and directories as a JSON structure.","name":"directory_tree","parameters":{"properties":{"path":{"description":"The directory path to traverse (relative to working directory)","type":"string"}},"required":["path"],"type":"object"}},{"description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","name":"edit_file","parameters":{"properties":{"edits":{"description":"Array of edit operations","items":{"properties":{"newText":{"description":"The replacement text","type":"string"},"oldText":{"description":"The exact text to replace","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"The file path to edit","type":"string"}},"required":["path","edits"],"type":"object"}},{"description":"Get a detailed listing of all files and directories in a specified path.","name":"list_directory","parameters":{"properties":{"path":{"description":"The directory path to list","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","name":"read_file","parameters":{"properties":{"path":{"description":"The file path to read","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of multiple files simultaneously.","name":"read_multiple_files","parameters":{"properties":{"json":{"description":"Whether to return the result as JSON","type":"boolean"},"paths":{"description":"Array of file paths to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","name":"search_files_content","parameters":{"properties":{"excludePatterns":{"description":"Patterns to exclude from search","items":{"type":"string"},"type":"array"},"is_regex":{"description":"If true, treat query as regex; otherwise literal text","type":"boolean"},"path":{"description":"The starting directory path","type":"string"},"query":{"description":"The text or regex pattern to search for","type":"string"}},"required":["path","query"],"type":"object"}},{"description":"Create a new file or completely overwrite an existing file with new content.","name":"write_file","parameters":{"properties":{"content":{"description":"The content to write to the file","type":"string"},"path":{"description":"The file path to write","type":"string"}},"required":["path","content"],"type":"object"}},{"description":"Create one or more new directories or nested directory structures.","name":"create_directory","parameters":{"properties":{"paths":{"description":"Array of directory paths to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Remove one or more empty directories.","name":"remove_directory","parameters":{"properties":{"paths":{"description":"Array of directory paths to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}}]}]}
36+
{"contents":[{"parts":[{"text":"You are a knowledgeable assistant that helps users with various tasks.\nBe helpful, accurate, and concise in your responses.\n"}],"role":"user"},{"parts":[{"text":"## Filesystem Tools\n\n- Relative paths resolve from the working directory; absolute paths and \"..\" work as expected\n- Prefer read_multiple_files over sequential read_file calls\n- Use search_files_content to locate code or text across files\n- Use exclude patterns in searches and max_depth in directory_tree to limit output"}],"role":"user"},{"parts":[{"text":"How many files in testdata/working_dir? Only output the number."}],"role":"user"},{"parts":[{"functionCall":{"args":{"path":"testdata/working_dir"},"name":"list_directory"},"thoughtSignature":"c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I="}],"role":"model"},{"parts":[{"functionResponse":{"name":"call_3df8565b-a1ef-4490-95f9-5d94296d7687","response":{"result":"FILE README.me\n"}}}],"role":"user"}],"generationConfig":{"maxOutputTokens":65536,"thinkingConfig":{"thinkingBudget":0}},"toolConfig":{"functionCallingConfig":{"mode":"AUTO"}},"tools":[{"functionDeclarations":[{"description":"Get a recursive tree view of files and directories as a JSON structure.","name":"directory_tree","parameters":{"properties":{"path":{"description":"The directory path to traverse (relative to working directory)","type":"string"}},"required":["path"],"type":"object"}},{"description":"Make line-based edits to a text file. Each edit replaces exact line sequences with new content.","name":"edit_file","parameters":{"properties":{"edits":{"description":"Array of edit operations","items":{"properties":{"newText":{"description":"The replacement text","type":"string"},"oldText":{"description":"The exact text to replace","type":"string"}},"required":["oldText","newText"],"type":"object"},"type":"array"},"path":{"description":"The file path to edit","type":"string"}},"required":["path","edits"],"type":"object"}},{"description":"Get a detailed listing of all files and directories in a specified path.","name":"list_directory","parameters":{"properties":{"path":{"description":"The directory path to list","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the complete contents of a file from the file system. Supports text files and images (jpg, png, gif, webp). Images are returned as image content that you can view directly.","name":"read_file","parameters":{"properties":{"path":{"description":"The file path to read","type":"string"}},"required":["path"],"type":"object"}},{"description":"Read the contents of multiple files simultaneously.","name":"read_multiple_files","parameters":{"properties":{"json":{"description":"Whether to return the result as JSON","type":"boolean"},"paths":{"description":"Array of file paths to read","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Searches for text or regex patterns in the content of files matching a GLOB pattern.","name":"search_files_content","parameters":{"properties":{"excludePatterns":{"description":"Patterns to exclude from search","items":{"type":"string"},"type":"array"},"is_regex":{"description":"If true, treat query as regex; otherwise literal text","type":"boolean"},"path":{"description":"The starting directory path","type":"string"},"query":{"description":"The text or regex pattern to search for","type":"string"}},"required":["path","query"],"type":"object"}},{"description":"Create a new file or completely overwrite an existing file with new content.","name":"write_file","parameters":{"properties":{"content":{"description":"The content to write to the file","type":"string"},"path":{"description":"The file path to write","type":"string"}},"required":["path","content"],"type":"object"}},{"description":"Create one or more new directories or nested directory structures.","name":"create_directory","parameters":{"properties":{"paths":{"description":"Array of directory paths to create","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}},{"description":"Remove one or more empty directories.","name":"remove_directory","parameters":{"properties":{"paths":{"description":"Array of directory paths to remove","items":{"type":"string"},"type":"array"}},"required":["paths"],"type":"object"}}]}]}
3737
form:
3838
alt:
3939
- sse
@@ -44,8 +44,8 @@ interactions:
4444
proto_major: 2
4545
proto_minor: 0
4646
content_length: -1
47-
body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"1\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 940,\"candidatesTokenCount\": 1,\"totalTokenCount\": 941,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 940}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"ExemaYCTKuStnsEPvaLv0AE\"}\r\n\r\n"
47+
body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"1\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 863,\"candidatesTokenCount\": 1,\"totalTokenCount\": 864,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 863}]},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"7XC6aeO8E4b3vdIP2s2PgAw\"}\r\n\r\n"
4848
headers: {}
4949
status: 200 OK
5050
code: 200
51-
duration: 300.431459ms
51+
duration: 373.12225ms

pkg/model/provider/gemini/client.go

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,21 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro
173173
}, nil
174174
}
175175

176+
// defaultThoughtSignature is a well-known sentinel that tells Gemini to skip
177+
// thought-signature validation. It is needed when replaying conversation
178+
// history generated by a non-Gemini model (e.g. during per-tool model routing).
179+
// See https://ai.google.dev/gemini-api/docs/thought-signatures
180+
var defaultThoughtSignature = []byte("skip_thought_signature_validator")
181+
182+
// thoughtSignatureOrDefault returns sig when non-empty, otherwise
183+
// [defaultThoughtSignature].
184+
func thoughtSignatureOrDefault(sig []byte) []byte {
185+
if len(sig) > 0 {
186+
return sig
187+
}
188+
return defaultThoughtSignature
189+
}
190+
176191
// convertMessagesToGemini converts chat.Messages into Gemini Contents
177192
func convertMessagesToGemini(messages []chat.Message) []*genai.Content {
178193
contents := make([]*genai.Content, 0, len(messages))
@@ -218,22 +233,18 @@ func convertMessagesToGemini(messages []chat.Message) []*genai.Content {
218233
// Handle assistant messages with tool calls
219234
if msg.Role == chat.MessageRoleAssistant && len(msg.ToolCalls) > 0 {
220235
parts := make([]*genai.Part, 0, len(msg.ToolCalls)+1)
236+
sig := thoughtSignatureOrDefault(msg.ThoughtSignature)
221237

222-
// Add text content if present
223238
if msg.Content != "" {
224-
parts = append(parts, newTextPartWithSignature(msg.Content, msg.ThoughtSignature))
239+
parts = append(parts, newTextPartWithSignature(msg.Content, sig))
225240
}
226-
227-
// Add function calls
228241
for _, tc := range msg.ToolCalls {
229242
var args map[string]any
230243
if tc.Function.Arguments != "" {
231244
_ = json.Unmarshal([]byte(tc.Function.Arguments), &args)
232245
}
233246
fc := genai.NewPartFromFunctionCall(tc.Function.Name, args)
234-
if len(msg.ThoughtSignature) > 0 {
235-
fc.ThoughtSignature = msg.ThoughtSignature
236-
}
247+
fc.ThoughtSignature = sig
237248
parts = append(parts, fc)
238249
}
239250

pkg/model/provider/gemini/client_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import (
77
"github.com/stretchr/testify/require"
88
"google.golang.org/genai"
99

10+
"github.com/docker/docker-agent/pkg/chat"
1011
"github.com/docker/docker-agent/pkg/config/latest"
1112
"github.com/docker/docker-agent/pkg/model/provider/base"
1213
"github.com/docker/docker-agent/pkg/model/provider/options"
14+
"github.com/docker/docker-agent/pkg/tools"
1315
)
1416

1517
func TestBuildConfig_Gemini25_ThinkingBudget(t *testing.T) {
@@ -410,6 +412,92 @@ func TestBuildConfig_ThinkingExplicitlyEnabled(t *testing.T) {
410412
assert.Equal(t, genai.ThinkingLevelMedium, config.ThinkingConfig.ThinkingLevel, "ThinkingLevel should be set from ThinkingBudget")
411413
}
412414

415+
func TestConvertMessagesToGemini_ThoughtSignature(t *testing.T) {
416+
t.Parallel()
417+
418+
defaultSig := thoughtSignatureOrDefault(nil) // the well-known skip sentinel
419+
realSig := []byte("real-thought-signature-from-gemini")
420+
421+
tests := []struct {
422+
name string
423+
message chat.Message
424+
wantParts int
425+
wantSig []byte
426+
}{
427+
{
428+
name: "preserves existing signature",
429+
message: chat.Message{
430+
Role: chat.MessageRoleAssistant,
431+
ThoughtSignature: realSig,
432+
ToolCalls: []tools.ToolCall{{
433+
ID: "call-1",
434+
Function: tools.FunctionCall{Name: "my_tool", Arguments: `{"key":"value"}`},
435+
}},
436+
},
437+
wantParts: 1,
438+
wantSig: realSig,
439+
},
440+
{
441+
name: "uses default when signature is nil (cross-model)",
442+
message: chat.Message{
443+
Role: chat.MessageRoleAssistant,
444+
ToolCalls: []tools.ToolCall{{
445+
ID: "call-1",
446+
Function: tools.FunctionCall{Name: "my_tool", Arguments: `{"key":"value"}`},
447+
}},
448+
},
449+
wantParts: 1,
450+
wantSig: defaultSig,
451+
},
452+
{
453+
name: "uses default when signature is empty (non-nil)",
454+
message: chat.Message{
455+
Role: chat.MessageRoleAssistant,
456+
ThoughtSignature: []byte{},
457+
ToolCalls: []tools.ToolCall{{
458+
ID: "call-1",
459+
Function: tools.FunctionCall{Name: "my_tool", Arguments: `{"key":"value"}`},
460+
}},
461+
},
462+
wantParts: 1,
463+
wantSig: defaultSig,
464+
},
465+
{
466+
name: "applies to text and all function call parts",
467+
message: chat.Message{
468+
Role: chat.MessageRoleAssistant,
469+
Content: "calling tools",
470+
ToolCalls: []tools.ToolCall{
471+
{ID: "call-1", Function: tools.FunctionCall{Name: "tool_a", Arguments: `{}`}},
472+
{ID: "call-2", Function: tools.FunctionCall{Name: "tool_b", Arguments: `{"x":1}`}},
473+
},
474+
},
475+
wantParts: 3, // text + 2 function calls
476+
wantSig: defaultSig,
477+
},
478+
}
479+
480+
for _, tt := range tests {
481+
t.Run(tt.name, func(t *testing.T) {
482+
t.Parallel()
483+
484+
contents := convertMessagesToGemini([]chat.Message{
485+
{Role: chat.MessageRoleUser, Content: "go"},
486+
tt.message,
487+
})
488+
489+
require.Len(t, contents, 2)
490+
assistant := contents[1]
491+
assert.Equal(t, genai.RoleModel, assistant.Role)
492+
require.Len(t, assistant.Parts, tt.wantParts)
493+
494+
for i, p := range assistant.Parts {
495+
assert.Equal(t, tt.wantSig, p.ThoughtSignature, "part %d", i)
496+
}
497+
})
498+
}
499+
}
500+
413501
func TestBuildConfig_ThinkingNotSet(t *testing.T) {
414502
t.Parallel()
415503

0 commit comments

Comments
 (0)