fix(inference): persist Gemini thought_signature on tool-call history (TAURI-RUST-4PK/4PJ) (#3770)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-06-22 12:16:05 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent b065e1728a
commit 9dc2127710
3 changed files with 118 additions and 2 deletions
+22 -2
View File
@@ -738,11 +738,31 @@ pub(crate) fn build_native_assistant_history(
let calls_json: Vec<serde_json::Value> = tool_calls
.iter()
.map(|tc| {
serde_json::json!({
let mut call = serde_json::json!({
"id": tc.id,
"name": tc.name,
"arguments": tc.arguments,
})
});
// Persist Gemini's per-call `thought_signature` (TAURI-RUST-4PK /
// 4PJ) into the stored assistant turn. PR #3553 threaded the
// signature through the live response→request hop and the
// stored-history *parser* (`parse_provider_tool_call_from_value`),
// but this writer — the single sink the agent loop persists every
// native tool-call turn through (engine/core.rs) — dropped it. On a
// history reload the rebuilt assistant turn therefore lacked
// `extra_content`, so the echoed `functionCall` part went out with
// no `thought_signature` and Gemini 400'd ("Function call is
// missing a thought_signature in functionCall parts"). Write it
// per-part so EVERY call in a parallel/multi-call turn round-trips,
// not just the first; `skip_serializing_if = "Option::is_none"` on
// `extra_content` keeps the stored JSON byte-identical for every
// provider that doesn't emit it.
if let Some(extra) = tc.extra_content.clone() {
if let Some(obj) = call.as_object_mut() {
obj.insert("extra_content".to_string(), extra);
}
}
call
})
.collect();
@@ -293,6 +293,58 @@ fn structured_tool_call_and_history_helpers_round_trip_expected_shapes() {
assert!(xml_history.contains("\"name\":\"echo\""));
}
/// TAURI-RUST-4PK / 4PJ: the persisted assistant-history JSON must carry each
/// tool call's `extra_content` (Gemini's `thought_signature`). This is the
/// writer that the agent loop runs *every* native tool-call turn through, so a
/// dropped signature here is exactly what re-surfaced the 400 on a history
/// reload after PR #3553 fixed only the response→request hop. Every call in a
/// parallel/multi-call turn must keep its own signature; calls without one omit
/// the field so non-Gemini providers stay byte-identical.
#[test]
fn build_native_assistant_history_persists_per_call_extra_content() {
let tool_calls = vec![
ToolCall {
id: "call-a".into(),
name: "shell".into(),
arguments: "{}".into(),
extra_content: Some(serde_json::json!({"google":{"thought_signature":"SIG_A"}})),
},
ToolCall {
id: "call-b".into(),
name: "read".into(),
arguments: "{}".into(),
extra_content: Some(serde_json::json!({"google":{"thought_signature":"SIG_B"}})),
},
// A call that never had a signature must NOT gain an empty key.
ToolCall {
id: "call-c".into(),
name: "noop".into(),
arguments: "{}".into(),
extra_content: None,
},
];
let native = build_native_assistant_history("on it", None, &tool_calls);
let json: serde_json::Value = serde_json::from_str(&native).expect("valid json");
assert_eq!(
json.pointer("/tool_calls/0/extra_content/google/thought_signature")
.and_then(|v| v.as_str()),
Some("SIG_A"),
"first parallel call's signature must be persisted"
);
assert_eq!(
json.pointer("/tool_calls/1/extra_content/google/thought_signature")
.and_then(|v| v.as_str()),
Some("SIG_B"),
"second parallel call's signature must be persisted (not just the first)"
);
assert!(
json.pointer("/tool_calls/2/extra_content").is_none(),
"a call without extra_content must omit the field, keeping non-Gemini history byte-identical"
);
}
#[test]
fn tools_to_openai_format_uses_tool_metadata() {
let tools: Vec<Box<dyn Tool>> = vec![Box::new(StubTool("echo")), Box::new(StubTool("shell"))];
@@ -1544,6 +1544,50 @@ fn convert_messages_for_native_tool_call_without_extra_content_stays_none() {
.is_none());
}
/// INVARIANT (TAURI-RUST-4PK / 4PJ): a PARALLEL multi-`functionCall` assistant
/// turn reloaded from history must echo a non-empty `thought_signature` on
/// EVERY part of the rebuilt outbound payload — not just the first. The stored
/// JSON here is the exact shape `build_native_assistant_history` now emits (per
/// the writer-side test in `agent::harness::parse_tests`). Before the fix the
/// writer dropped `extra_content`, so a reloaded multi-call turn went out with
/// missing signatures and Gemini 400'd ("Function call is missing a
/// thought_signature in functionCall parts"). Covers both the non-stream and
/// streaming paths since both persist through the single native history writer.
#[test]
fn convert_messages_for_native_echoes_signature_on_every_parallel_call() {
let stored = r#"{"content":"on it","tool_calls":[
{"id":"call_a","name":"shell","arguments":"{}","extra_content":{"google":{"thought_signature":"SIG_A"}}},
{"id":"call_b","name":"read","arguments":"{}","extra_content":{"google":{"thought_signature":"SIG_B"}}}
]}"#;
let input = vec![
ChatMessage::assistant(stored),
ChatMessage::tool(r#"{"tool_call_id":"call_a","content":"done"}"#),
ChatMessage::tool(r#"{"tool_call_id":"call_b","content":"done"}"#),
];
let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input);
let tool_calls = converted[0]
.tool_calls
.as_ref()
.expect("assistant tool_calls survive the reload");
assert_eq!(tool_calls.len(), 2, "both parallel calls survive");
let wire = serde_json::to_value(tool_calls).unwrap();
for (idx, expected) in ["SIG_A", "SIG_B"].iter().enumerate() {
let sig = wire
.pointer(&format!("/{idx}/extra_content/google/thought_signature"))
.and_then(|v| v.as_str());
assert_eq!(
sig,
Some(*expected),
"functionCall part {idx} must echo its own thought_signature on the wire"
);
assert!(
sig.is_some_and(|s| !s.is_empty()),
"functionCall part {idx} thought_signature must be non-empty"
);
}
}
/// Streaming: Gemini sends the thought_signature in the tool-call delta's
/// `extra_content` on the first chunk. The accumulator must preserve it onto the
/// aggregated tool call so it reaches history (TAURI-RUST-4PK).