fix: support Gemini 3's thought signatures (#5806)

Signed-off-by: Salvatore Testa <sal@withpersona.com>
This commit is contained in:
Salvatore Testa
2025-11-19 21:28:27 -08:00
committed by GitHub
parent 5f3a31dcbf
commit cfdf01567d
9 changed files with 64 additions and 6 deletions
+14
View File
@@ -512,6 +512,7 @@ mod tests {
let tool_request = ToolRequest {
id: "test-id".to_string(),
tool_call: Ok(tool_call),
thought_signature: None,
};
let result = tool_request_to_markdown(&tool_request, true);
@@ -535,6 +536,7 @@ mod tests {
let tool_request = ToolRequest {
id: "test-id".to_string(),
tool_call: Ok(tool_call),
thought_signature: None,
};
let result = tool_request_to_markdown(&tool_request, true);
@@ -662,6 +664,7 @@ mod tests {
let tool_request = ToolRequest {
id: "shell-cat".to_string(),
tool_call: Ok(tool_call),
thought_signature: None,
};
let python_code = r#"#!/usr/bin/env python3
@@ -708,6 +711,7 @@ if __name__ == "__main__":
let tool_request = ToolRequest {
id: "git-status".to_string(),
tool_call: Ok(git_status_call),
thought_signature: None,
};
let git_output = " M src/main.rs\n?? temp.txt\n A new_feature.rs";
@@ -746,6 +750,7 @@ if __name__ == "__main__":
let _tool_request = ToolRequest {
id: "cargo-build".to_string(),
tool_call: Ok(cargo_build_call),
thought_signature: None,
};
let build_output = r#" Compiling goose-cli v0.1.0 (/Users/user/goose)
@@ -790,6 +795,7 @@ warning: unused variable `x`
let _tool_request = ToolRequest {
id: "curl-api".to_string(),
tool_call: Ok(curl_call),
thought_signature: None,
};
let api_response = r#"{
@@ -838,6 +844,7 @@ warning: unused variable `x`
let tool_request = ToolRequest {
id: "editor-write".to_string(),
tool_call: Ok(editor_call),
thought_signature: None,
};
let text_content = TextContent {
@@ -878,6 +885,7 @@ warning: unused variable `x`
let _tool_request = ToolRequest {
id: "editor-view".to_string(),
tool_call: Ok(editor_call),
thought_signature: None,
};
let python_code = r#"import os
@@ -927,6 +935,7 @@ def process_data(data: List[Dict]) -> List[Dict]:
let _tool_request = ToolRequest {
id: "shell-error".to_string(),
tool_call: Ok(error_call),
thought_signature: None,
};
let error_output = r#"python: can't open file 'nonexistent_script.py': [Errno 2] No such file or directory
@@ -962,6 +971,7 @@ Command failed with exit code 2"#;
let tool_request = ToolRequest {
id: "script-exec".to_string(),
tool_call: Ok(script_call),
thought_signature: None,
};
let script_output = r#"Python 3.11.5 (main, Aug 24 2023, 15:18:16) [Clang 14.0.3 ]
@@ -1008,6 +1018,7 @@ Command failed with exit code 2"#;
let _tool_request = ToolRequest {
id: "multi-cmd".to_string(),
tool_call: Ok(multi_call),
thought_signature: None,
};
let multi_output = r#"total 24
@@ -1052,6 +1063,7 @@ drwx------ 3 user staff 96 Dec 6 16:20 com.apple.launchd.abc
let tool_request = ToolRequest {
id: "grep-search".to_string(),
tool_call: Ok(grep_call),
thought_signature: None,
};
let grep_output = r#"src/main.rs:15:async fn process_request(req: Request) -> Result<Response> {
@@ -1095,6 +1107,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul
let _tool_request = ToolRequest {
id: "json-test".to_string(),
tool_call: Ok(tool_call),
thought_signature: None,
};
let json_output = r#"{"status": "success", "data": {"count": 42}}"#;
@@ -1129,6 +1142,7 @@ src/middleware.rs:12:async fn auth_middleware(req: Request, next: Next) -> Resul
let tool_request = ToolRequest {
id: "npm-install".to_string(),
tool_call: Ok(npm_call),
thought_signature: None,
};
let npm_output = r#"added 57 packages, and audited 58 packages in 3s
+15
View File
@@ -53,6 +53,8 @@ pub struct ToolRequest {
#[serde(with = "tool_result_serde")]
#[schema(value_type = Object)]
pub tool_call: ToolResult<CallToolRequestParam>,
#[serde(skip_serializing_if = "Option::is_none")]
pub thought_signature: Option<String>,
}
impl ToolRequest {
@@ -201,6 +203,19 @@ impl MessageContent {
MessageContent::ToolRequest(ToolRequest {
id: id.into(),
tool_call,
thought_signature: None,
})
}
pub fn tool_request_with_signature<S1: Into<String>, S2: Into<String>>(
id: S1,
tool_call: ToolResult<CallToolRequestParam>,
thought_signature: Option<S2>,
) -> Self {
MessageContent::ToolRequest(ToolRequest {
id: id.into(),
tool_call,
thought_signature: thought_signature.map(|s| s.into()),
})
}
+26 -5
View File
@@ -53,9 +53,14 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
}
}
parts.push(json!({
"functionCall": function_call_part
}));
let mut part = Map::new();
part.insert("functionCall".to_string(), json!(function_call_part));
if let Some(signature) = &request.thought_signature {
part.insert("thoughtSignature".to_string(), json!(signature));
}
parts.push(json!(part));
}
Err(e) => {
parts.push(json!({"text":format!("Error: {}", e)}));
@@ -121,6 +126,12 @@ pub fn format_messages(messages: &[Message]) -> Vec<Value> {
}
}
}
MessageContent::Thinking(thinking) => {
let mut part = Map::new();
part.insert("text".to_string(), json!(thinking.thinking));
part.insert("thoughtSignature".to_string(), json!(thinking.signature));
parts.push(json!(part));
}
_ => {}
}
@@ -269,8 +280,17 @@ pub fn response_to_message(response: Value) -> Result<Message> {
.unwrap_or(&binding);
for part in parts {
let thought_signature = part
.get("thoughtSignature")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(text) = part.get("text").and_then(|v| v.as_str()) {
content.push(MessageContent::text(text.to_string()));
if let Some(sig) = thought_signature {
content.push(MessageContent::thinking(text.to_string(), sig));
} else {
content.push(MessageContent::text(text.to_string()));
}
} else if let Some(function_call) = part.get("functionCall") {
let id: String = rand::thread_rng()
.sample_iter(&Alphanumeric)
@@ -294,12 +314,13 @@ pub fn response_to_message(response: Value) -> Result<Message> {
} else {
let parameters = function_call.get("args");
if let Some(params) = parameters {
content.push(MessageContent::tool_request(
content.push(MessageContent::tool_request_with_signature(
id,
Ok(CallToolRequestParam {
name: name.into(),
arguments: Some(object(params.clone())),
}),
thought_signature,
));
}
}
+1
View File
@@ -30,6 +30,7 @@ pub const GOOGLE_KNOWN_MODELS: &[&str] = &[
"gemini-2.0-flash-exp",
"gemini-2.0-flash-preview-image-generation",
"gemini-2.0-flash-lite",
"gemini-3-pro-preview",
];
pub const GOOGLE_DOC_URL: &str = "https://ai.google.dev/gemini-api/docs/models";
@@ -113,6 +113,7 @@ mod tests {
name: "shell".into(),
arguments: Some(object!({"command": "rm -rf /"})),
}),
thought_signature: None,
}];
let results = inspector.inspect(&tool_requests, &[]).await.unwrap();
+1
View File
@@ -295,6 +295,7 @@ mod tests {
name: "test_tool".into(),
arguments: Some(object!({})),
}),
thought_signature: None,
};
let permission_result = PermissionCheckResult {
+1 -1
View File
@@ -19,7 +19,7 @@ PROVIDERS=(
"xai:grok-3"
"openai:gpt-4o:gpt-4o-mini:gpt-3.5-turbo:gpt-5"
"anthropic:claude-sonnet-4-5-20250929:claude-opus-4-1-20250805"
"google:gemini-2.5-pro:gemini-2.5-flash"
"google:gemini-2.5-pro:gemini-2.5-flash:gemini-3-pro-preview"
"tetrate:claude-sonnet-4-20250514"
)
+4
View File
@@ -4808,6 +4808,10 @@
"id": {
"type": "string"
},
"thoughtSignature": {
"type": "string",
"nullable": true
},
"toolCall": {
"type": "object"
}
+1
View File
@@ -860,6 +860,7 @@ export type ToolPermission = {
export type ToolRequest = {
id: string;
thoughtSignature?: string | null;
toolCall: {
[key: string]: unknown;
};