fix(agent): round-trip Gemini tool-call thought_signature

Gemini 3.x returns an opaque `thought_signature` inside each tool call's
OpenAI-compat `extra_content` field and *requires* it to be echoed back
with the same tool call on the following turn. buzz-agent's `ToolCall`
only stored id/name/arguments, so the signature was dropped and every
follow-up turn after a tool call was rejected with HTTP 400
("Function call is missing a thought_signature") — surfaced by buzz-acp
as `-32603 Internal error`, which made any agentic (tool-using) Gemini
run fail on its second turn.

Carry the opaque `extra_content` on `ToolCall`: capture it when parsing
the OpenAI-chat response and re-attach it when replaying assistant
history. The payload is treated as an opaque blob, so it is `None` and
omitted for providers that don't emit it (Anthropic, standard OpenAI).

Verified against the live Gemini OpenAI-compat endpoint: a two-turn
tool-calling exchange fails with 400 when the signature is dropped and
succeeds when it is echoed back.

Co-authored-by: Atish Patel <atish@squareup.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Atish Patel <atish@squareup.com>
This commit is contained in:
Atish Patel
2026-07-19 21:56:08 -05:00
co-authored by Claude Opus 4.8
parent bf25546cce
commit e573b8f3e1
3 changed files with 148 additions and 7 deletions
+1
View File
@@ -679,6 +679,7 @@ pub(crate) fn push_hook_outputs_as_tool_results(
provider_id: provider_id.clone(),
name: tool_name,
arguments: serde_json::json!({}),
extra_content: None,
}],
});
history.push(HistoryItem::ToolResult(ToolResult {
+139 -7
View File
@@ -532,11 +532,18 @@ fn openai_body(
let calls: Vec<Value> = tool_calls
.iter()
.map(|c| {
json!({
let mut call = json!({
"id": c.provider_id, "type": "function",
"function": { "name": c.name,
"arguments": serde_json::to_string(&c.arguments)
.unwrap_or_else(|_| "{}".into()) } })
.unwrap_or_else(|_| "{}".into()) } });
// Echo back the provider's opaque `extra_content`
// (Gemini 3.x requires the `thought_signature` here
// or it rejects the follow-up turn with HTTP 400).
if let Some(extra) = &c.extra_content {
call["extra_content"] = extra.clone();
}
call
})
.collect();
msg.insert("tool_calls".into(), Value::Array(calls));
@@ -962,11 +969,12 @@ fn parse_openai(v: Value) -> Result<LlmResponse, AgentError> {
let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}");
let args: Value = serde_json::from_str(raw)
.map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?;
tool_calls.push(make_tool_call(
str_field(tc, "id"),
str_field(f, "name"),
args,
)?);
let mut call = make_tool_call(str_field(tc, "id"), str_field(f, "name"), args)?;
// Preserve the opaque `extra_content` (Gemini 3.x `thought_signature`)
// so it can be echoed back on the next turn. Absent for providers
// that don't emit it.
call.extra_content = tc.get("extra_content").cloned();
tool_calls.push(call);
}
}
let input_tokens = openai_chat_input_tokens(&v);
@@ -998,6 +1006,7 @@ fn make_tool_call(id: String, name: String, args: Value) -> Result<ToolCall, Age
provider_id: id,
name,
arguments,
extra_content: None,
})
}
@@ -1287,6 +1296,7 @@ mod tests {
provider_id: "toolu_1".into(),
name: "dev__view_image".into(),
arguments: serde_json::json!({"source":"x.png"}),
extra_content: None,
}],
},
HistoryItem::ToolResult(ToolResult {
@@ -1336,6 +1346,7 @@ mod tests {
provider_id: "call_abc".into(),
name: "dev__shell".into(),
arguments: serde_json::json!({"command": "ls"}),
extra_content: None,
}],
},
HistoryItem::ToolResult(ToolResult {
@@ -1434,6 +1445,7 @@ mod tests {
provider_id: "call_x".into(),
name: "t".into(),
arguments: serde_json::json!({}),
extra_content: None,
}],
},
];
@@ -1627,11 +1639,13 @@ mod tests {
provider_id: "toolu_a".into(),
name: "dev__view_image".into(),
arguments: serde_json::json!({"source": "a.png"}),
extra_content: None,
},
ToolCall {
provider_id: "toolu_b".into(),
name: "dev__view_image".into(),
arguments: serde_json::json!({"source": "b.png"}),
extra_content: None,
},
],
},
@@ -1883,6 +1897,124 @@ mod tests {
assert_eq!(body["reasoning_effort"], "medium");
}
#[test]
fn parse_openai_captures_tool_call_extra_content() {
// Gemini 3.x returns a `thought_signature` on the tool call's
// `extra_content`. It must be captured verbatim so it can round-trip.
let v = serde_json::json!({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "dev__shell", "arguments": "{\"command\":\"ls\"}" },
"extra_content": { "google": { "thought_signature": "SIG123" } }
}]
}
}]
});
let resp = parse_openai(v).unwrap();
assert_eq!(resp.tool_calls.len(), 1);
assert_eq!(
resp.tool_calls[0].extra_content,
Some(serde_json::json!({ "google": { "thought_signature": "SIG123" } }))
);
}
#[test]
fn parse_openai_tool_call_without_extra_content_is_none() {
// Standard OpenAI / Anthropic-compat hosts omit `extra_content`.
let v = serde_json::json!({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": { "name": "dev__shell", "arguments": "{}" }
}]
}
}]
});
let resp = parse_openai(v).unwrap();
assert_eq!(resp.tool_calls[0].extra_content, None);
}
#[test]
fn openai_body_roundtrips_tool_call_extra_content() {
// The captured `extra_content` must be echoed back on the replayed
// assistant turn, or Gemini rejects the follow-up with HTTP 400.
let sig = serde_json::json!({ "google": { "thought_signature": "SIG123" } });
let history = vec![
HistoryItem::User("call the tool".into()),
HistoryItem::Assistant {
text: String::new(),
tool_calls: vec![ToolCall {
provider_id: "call_1".into(),
name: "dev__shell".into(),
arguments: serde_json::json!({ "command": "ls" }),
extra_content: Some(sig.clone()),
}],
},
HistoryItem::ToolResult(ToolResult {
provider_id: "call_1".into(),
content: vec![ToolResultContent::Text("file.txt".into())],
is_error: false,
}),
];
let body = openai_body(
&cfg(Provider::Gemini),
"system",
&history,
&[],
"model",
None,
);
// Assistant message is messages[2] (system, user, assistant, tool).
let assistant = &body["messages"][2];
assert_eq!(assistant["role"], "assistant");
assert_eq!(assistant["tool_calls"][0]["extra_content"], sig);
}
#[test]
fn openai_body_omits_extra_content_when_absent() {
// A tool call with no `extra_content` must not emit the key at all.
let history = vec![
HistoryItem::User("call the tool".into()),
HistoryItem::Assistant {
text: String::new(),
tool_calls: vec![ToolCall {
provider_id: "call_1".into(),
name: "dev__shell".into(),
arguments: serde_json::json!({ "command": "ls" }),
extra_content: None,
}],
},
HistoryItem::ToolResult(ToolResult {
provider_id: "call_1".into(),
content: vec![ToolResultContent::Text("file.txt".into())],
is_error: false,
}),
];
let body = openai_body(
&cfg(Provider::OpenAi),
"system",
&history,
&[],
"model",
None,
);
assert!(
body["messages"][2]["tool_calls"][0]
.get("extra_content")
.is_none(),
"extra_content must be omitted when the tool call has none"
);
}
#[test]
fn responses_body_omits_reasoning_when_effort_none() {
let body = responses_body(
+8
View File
@@ -108,6 +108,14 @@ pub struct ToolCall {
pub provider_id: String,
pub name: String,
pub arguments: Value,
/// Opaque provider round-trip payload from the OpenAI-compat `extra_content`
/// field on a tool call. Gemini 3.x returns a `thought_signature` here and
/// *requires* it to be echoed back with the same tool call on the following
/// turn — otherwise the request is rejected with HTTP 400
/// ("Function call is missing a thought_signature"). Preserved verbatim and
/// re-attached when replaying assistant history. `None` for providers that
/// don't emit it (Anthropic, standard OpenAI).
pub extra_content: Option<Value>,
}
#[derive(Debug, Clone)]