mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(inference): preserve Gemini thought_signature across tool-call turns (TAURI-RUST-4PK) (#3548) (#3553)
This commit is contained in:
@@ -26,6 +26,7 @@ fn native_dispatcher_roundtrip() {
|
||||
id: "tc1".into(),
|
||||
name: "file_read".into(),
|
||||
arguments: "{\"path\":\"a.txt\"}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
@@ -268,6 +269,7 @@ fn assistant_tool_calls(id: &str) -> ConversationMessage {
|
||||
id: id.into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
}
|
||||
@@ -397,6 +399,7 @@ fn assistant_tool_calls_multi(ids: &[&str]) -> ConversationMessage {
|
||||
id: (*id).into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
})
|
||||
.collect(),
|
||||
reasoning_content: None,
|
||||
@@ -413,6 +416,7 @@ fn native_dispatcher_serializes_reasoning_content_for_tool_call_turns() {
|
||||
id: "tc-1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: Some("chain-of-thought replay blob".into()),
|
||||
},
|
||||
|
||||
@@ -80,6 +80,7 @@ async fn native_tool_call_decodes_json_encoded_arguments_string() {
|
||||
id: "c1".into(),
|
||||
name: "captured".into(),
|
||||
arguments: "{\"city\":\"Berlin\",\"n\":3}".to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
@@ -143,6 +144,7 @@ async fn documents_silent_drop_of_non_json_arguments_string() {
|
||||
name: "captured".into(),
|
||||
// Not valid JSON — the model "meant" a plain string.
|
||||
arguments: "world".to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
@@ -353,6 +355,7 @@ async fn native_tool_calls_take_precedence_over_xml_in_text() {
|
||||
id: "c1".into(),
|
||||
name: "tool_a".into(),
|
||||
arguments: "{\"src\":\"native\"}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -261,6 +261,7 @@ fn structured_tool_call_and_history_helpers_round_trip_expected_shapes() {
|
||||
id: "call-1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{\"value\":\"hello\"}".into(),
|
||||
extra_content: None,
|
||||
}];
|
||||
|
||||
let parsed = parse_structured_tool_calls(&tool_calls);
|
||||
|
||||
@@ -456,6 +456,8 @@ impl Agent {
|
||||
.unwrap_or_else(|| format!("parsed-{}-{}", iteration + 1, idx + 1)),
|
||||
name: call.name.clone(),
|
||||
arguments: call.arguments.to_string(),
|
||||
// Prompt-based tool calls carry no provider extra_content.
|
||||
extra_content: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -312,6 +312,7 @@ fn helper_paths_cover_no_overlap_native_calls_and_truncation() {
|
||||
id: "native-1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}];
|
||||
let response = crate::openhuman::inference::provider::ChatResponse {
|
||||
text: Some(String::new()),
|
||||
|
||||
@@ -461,6 +461,7 @@ async fn turn_with_native_dispatcher_handles_tool_results_variant() {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
@@ -604,6 +605,7 @@ async fn turn_dispatches_spawn_subagent_through_full_path() {
|
||||
"prompt": "find out about X"
|
||||
})
|
||||
.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -78,6 +78,9 @@ fn persisted_tool_calls(
|
||||
id,
|
||||
name: c.name.clone(),
|
||||
arguments: c.arguments.to_string(),
|
||||
// Prompt-parsed calls carry no provider extra_content; the
|
||||
// native (Gemini) path returns early above, preserving it.
|
||||
extra_content: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -414,6 +414,7 @@ fn trim_history_snaps_past_orphaned_tool_results() {
|
||||
id: "call_x".into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
},
|
||||
|
||||
@@ -302,6 +302,7 @@ fn tool_response(name: &str, args: &str) -> ChatResponse {
|
||||
id: "call-1".into(),
|
||||
name: name.into(),
|
||||
arguments: args.into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -263,6 +263,7 @@ impl Provider for KeywordScriptedProvider {
|
||||
id: format!("call_{id}"),
|
||||
name: c.name.clone(),
|
||||
arguments: c.arguments.to_string(),
|
||||
extra_content: None,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -372,6 +372,7 @@ mod tests {
|
||||
id: "1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{\"value\":\"x\"}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
};
|
||||
@@ -396,6 +397,7 @@ mod tests {
|
||||
id: id.into(),
|
||||
name: "f".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -398,6 +398,7 @@ async fn run_tool_call_loop_persists_native_tool_results_as_tool_messages() {
|
||||
id: "call-1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -382,6 +382,7 @@ async fn turn_executes_single_tool_then_returns() {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "hello from tool"}"#.into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
text_response("I ran the tool"),
|
||||
]));
|
||||
@@ -412,16 +413,19 @@ async fn turn_handles_multi_step_tool_chain() {
|
||||
id: "tc1".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc2".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
tool_response(vec![ToolCall {
|
||||
id: "tc3".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
text_response("Done after 3 calls"),
|
||||
]));
|
||||
@@ -460,6 +464,7 @@ async fn turn_emits_checkpoint_at_max_iterations() {
|
||||
id: format!("tc{i}"),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "loop"}"#.into(),
|
||||
extra_content: None,
|
||||
}]));
|
||||
}
|
||||
|
||||
@@ -504,6 +509,7 @@ async fn turn_handles_unknown_tool_gracefully() {
|
||||
id: "tc1".into(),
|
||||
name: "nonexistent_tool".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
text_response("I couldn't find that tool"),
|
||||
]));
|
||||
@@ -544,6 +550,7 @@ async fn turn_recovers_from_tool_failure() {
|
||||
id: "tc1".into(),
|
||||
name: "fail".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
text_response("Tool failed but I recovered"),
|
||||
]));
|
||||
@@ -568,6 +575,7 @@ async fn turn_recovers_from_tool_error() {
|
||||
id: "tc1".into(),
|
||||
name: "panicker".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
text_response("I recovered from the error"),
|
||||
]));
|
||||
@@ -794,6 +802,7 @@ async fn turn_preserves_text_alongside_tool_calls() {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "hi"}"#.into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
@@ -835,16 +844,19 @@ async fn turn_handles_multiple_tools_in_one_response() {
|
||||
id: "tc1".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
},
|
||||
ToolCall {
|
||||
id: "tc2".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
},
|
||||
ToolCall {
|
||||
id: "tc3".into(),
|
||||
name: "counter".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
},
|
||||
]),
|
||||
text_response("All 3 done"),
|
||||
@@ -982,6 +994,7 @@ async fn history_contains_all_expected_entries_after_tool_loop() {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "tool-out"}"#.into(),
|
||||
extra_content: None,
|
||||
}]),
|
||||
text_response("final answer"),
|
||||
]));
|
||||
@@ -1087,6 +1100,7 @@ async fn native_dispatcher_handles_stringified_arguments() {
|
||||
id: "tc1".into(),
|
||||
name: "echo".into(),
|
||||
arguments: r#"{"message": "hello"}"#.into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
@@ -1176,6 +1190,7 @@ fn conversation_message_serialization_roundtrip() {
|
||||
id: "tc1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: Some("thinking".into()),
|
||||
},
|
||||
@@ -1302,6 +1317,7 @@ fn xml_dispatcher_converts_history_to_provider_messages() {
|
||||
id: "tc1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
},
|
||||
@@ -1337,11 +1353,13 @@ fn native_dispatcher_converts_tool_results_to_tool_messages() {
|
||||
id: "tc1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
},
|
||||
ToolCall {
|
||||
id: "tc2".into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
},
|
||||
],
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -500,6 +500,7 @@ fn tool_response(name: &str, arguments: serde_json::Value) -> ChatResponse {
|
||||
id: format!("call-{name}"),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -14,6 +14,7 @@ fn call(id: &str) -> ConversationMessage {
|
||||
id: id.into(),
|
||||
name: "t".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
}
|
||||
|
||||
@@ -115,6 +115,7 @@ mod tests {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
}
|
||||
|
||||
@@ -269,6 +269,7 @@ mod tests {
|
||||
id: id.into(),
|
||||
name: "t".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ fn call(id: &str) -> ConversationMessage {
|
||||
id: id.into(),
|
||||
name: "t".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
}
|
||||
@@ -217,6 +218,7 @@ fn transcript_renders_all_message_variants() {
|
||||
id: "1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: r#"{"cmd":"ls"}"#.into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
},
|
||||
|
||||
@@ -251,6 +251,8 @@ impl EventMapper {
|
||||
id: call_id,
|
||||
name,
|
||||
arguments,
|
||||
// Claude Code CLI events carry no OpenAI-compat extra_content.
|
||||
extra_content: None,
|
||||
});
|
||||
}
|
||||
Vec::new()
|
||||
|
||||
@@ -199,6 +199,9 @@ impl OpenAiCompatibleProvider {
|
||||
tc.arguments,
|
||||
)),
|
||||
}),
|
||||
// Echo Gemini's thought_signature back on
|
||||
// the next turn (TAURI-RUST-4PK).
|
||||
extra_content: tc.extra_content,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -405,6 +408,10 @@ impl OpenAiCompatibleProvider {
|
||||
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name,
|
||||
arguments,
|
||||
// Preserve Gemini's thought_signature (TAURI-RUST-4PK) so it
|
||||
// can be echoed on the next turn; None for providers that
|
||||
// don't send extra_content.
|
||||
extra_content: tc.extra_content,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -420,6 +427,8 @@ impl OpenAiCompatibleProvider {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name: name.clone(),
|
||||
arguments: normalize_function_arguments(function.arguments.clone()),
|
||||
// Legacy `function_call` shape carries no extra_content.
|
||||
extra_content: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,9 @@ pub(crate) fn parse_provider_tool_call_from_value(
|
||||
arguments: normalize_function_arguments(Some(serde_json::Value::String(
|
||||
call.arguments,
|
||||
))),
|
||||
// Preserve Gemini's thought_signature through the stored-history
|
||||
// recovery path so it is echoed on the next turn (TAURI-RUST-4PK).
|
||||
extra_content: call.extra_content,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -176,6 +179,9 @@ pub(crate) fn parse_provider_tool_call_from_value(
|
||||
id,
|
||||
name: name.to_string(),
|
||||
arguments: normalize_function_arguments(function.get("arguments").cloned()),
|
||||
// Carry Gemini's thought_signature if the stored value had one
|
||||
// (TAURI-RUST-4PK).
|
||||
extra_content: value.get("extra_content").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -438,6 +438,9 @@ impl Provider for OpenAiCompatibleProvider {
|
||||
id: tc.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
|
||||
name,
|
||||
arguments,
|
||||
// Non-streaming response: preserve Gemini's thought_signature
|
||||
// so it round-trips on the next turn (TAURI-RUST-4PK).
|
||||
extra_content: tc.extra_content,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -254,6 +254,16 @@ impl OpenAiCompatibleProvider {
|
||||
let idx = tc.index.unwrap_or(0);
|
||||
let entry = tool_accum.entry(idx).or_default();
|
||||
|
||||
// Capture the first non-null extra_content seen for
|
||||
// this index (Gemini's thought_signature, TAURI-RUST-4PK).
|
||||
if entry.extra_content.is_none() {
|
||||
if let Some(ec) = tc.extra_content.as_ref() {
|
||||
if !ec.is_null() {
|
||||
entry.extra_content = Some(ec.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(id) = tc.id.as_ref() {
|
||||
if entry.id.is_none() {
|
||||
log::debug!(
|
||||
@@ -380,6 +390,9 @@ impl OpenAiCompatibleProvider {
|
||||
)
|
||||
},
|
||||
}),
|
||||
// Carry Gemini's thought_signature through to parse_native_response
|
||||
// so it lands on the harness ToolCall (TAURI-RUST-4PK).
|
||||
extra_content: c.extra_content,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
||||
@@ -1157,6 +1157,7 @@ fn parse_native_response_preserves_tool_call_id() {
|
||||
r#"{"command":"pwd"}"#.to_string(),
|
||||
)),
|
||||
}),
|
||||
extra_content: None,
|
||||
}]),
|
||||
function_call: None,
|
||||
reasoning_content: None,
|
||||
@@ -1183,6 +1184,7 @@ fn parse_native_response_captures_reasoning_content() {
|
||||
name: Some("shell".to_string()),
|
||||
arguments: Some(serde_json::Value::String("{}".to_string())),
|
||||
}),
|
||||
extra_content: None,
|
||||
}]),
|
||||
function_call: None,
|
||||
reasoning_content: Some(" weighing the options ".to_string()),
|
||||
@@ -1234,6 +1236,189 @@ fn convert_messages_for_native_maps_tool_result_payload() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── TAURI-RUST-4PK: Gemini thought_signature round-trip ──────────────────────
|
||||
|
||||
/// The wire `ToolCall` must capture Gemini's `extra_content` from the response
|
||||
/// and re-emit it verbatim on the request, so the thought_signature survives the
|
||||
/// round-trip. A tool call without it omits the field entirely (non-Gemini
|
||||
/// providers stay byte-identical on the wire).
|
||||
#[test]
|
||||
fn tool_call_wire_round_trips_extra_content() {
|
||||
let json = r#"{"id":"call_g","type":"function","function":{"name":"shell","arguments":"{}"},"extra_content":{"google":{"thought_signature":"SIG123"}}}"#;
|
||||
let tc: ToolCall = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(
|
||||
tc.extra_content
|
||||
.as_ref()
|
||||
.and_then(|v| v.pointer("/google/thought_signature"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("SIG123"),
|
||||
"extra_content must be captured from the Gemini response"
|
||||
);
|
||||
let reemitted = serde_json::to_value(&tc).unwrap();
|
||||
assert_eq!(
|
||||
reemitted
|
||||
.pointer("/extra_content/google/thought_signature")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("SIG123"),
|
||||
"extra_content must be echoed verbatim on the request body"
|
||||
);
|
||||
|
||||
let bare: ToolCall = serde_json::from_str(
|
||||
r#"{"id":"c","type":"function","function":{"name":"x","arguments":"{}"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(bare.extra_content.is_none());
|
||||
assert!(
|
||||
serde_json::to_value(&bare)
|
||||
.unwrap()
|
||||
.get("extra_content")
|
||||
.is_none(),
|
||||
"providers that don't send extra_content keep a byte-identical wire body"
|
||||
);
|
||||
}
|
||||
|
||||
/// `parse_native_response` lifts the tool-call `extra_content` onto the harness
|
||||
/// ToolCall so it can be persisted and echoed (TAURI-RUST-4PK).
|
||||
#[test]
|
||||
fn parse_native_response_captures_tool_call_extra_content() {
|
||||
let message = ResponseMessage {
|
||||
content: None,
|
||||
tool_calls: Some(vec![ToolCall {
|
||||
id: Some("call_g".to_string()),
|
||||
kind: Some("function".to_string()),
|
||||
function: Some(Function {
|
||||
name: Some("shell".to_string()),
|
||||
arguments: Some(serde_json::Value::String("{}".to_string())),
|
||||
}),
|
||||
extra_content: Some(serde_json::json!({"google":{"thought_signature":"SIG_RESP"}})),
|
||||
}]),
|
||||
function_call: None,
|
||||
reasoning_content: None,
|
||||
};
|
||||
let parsed =
|
||||
OpenAiCompatibleProvider::parse_native_response(wrap_message(message), "google").unwrap();
|
||||
assert_eq!(parsed.tool_calls.len(), 1);
|
||||
assert_eq!(
|
||||
parsed.tool_calls[0]
|
||||
.extra_content
|
||||
.as_ref()
|
||||
.and_then(|v| v.pointer("/google/thought_signature"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("SIG_RESP"),
|
||||
"the signature must land on the harness ToolCall"
|
||||
);
|
||||
}
|
||||
|
||||
/// On rebuild, a persisted assistant tool-call message whose stored JSON carries
|
||||
/// `extra_content` must re-emit it on the wire tool_calls, so Gemini sees the
|
||||
/// signature on the follow-up turn (TAURI-RUST-4PK).
|
||||
#[test]
|
||||
fn convert_messages_for_native_echoes_tool_call_extra_content() {
|
||||
let input = vec![
|
||||
ChatMessage::assistant(
|
||||
r#"{"content":"on it","tool_calls":[{"id":"call_g","name":"shell","arguments":"{}","extra_content":{"google":{"thought_signature":"SIG_ECHO"}}}]}"#,
|
||||
),
|
||||
ChatMessage::tool(r#"{"tool_call_id":"call_g","content":"done"}"#),
|
||||
];
|
||||
let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input);
|
||||
let tool_calls = converted[0]
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.expect("assistant tool_calls present");
|
||||
assert_eq!(
|
||||
tool_calls[0]
|
||||
.extra_content
|
||||
.as_ref()
|
||||
.and_then(|v| v.pointer("/google/thought_signature"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("SIG_ECHO"),
|
||||
"stored extra_content must be echoed back on the rebuilt request"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(tool_calls)
|
||||
.unwrap()
|
||||
.pointer("/0/extra_content/google/thought_signature")
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("SIG_ECHO"),
|
||||
"echoed signature must appear on the serialized wire body"
|
||||
);
|
||||
}
|
||||
|
||||
/// A non-Gemini stored tool call (no `extra_content`) rebuilds with the field
|
||||
/// omitted — every other provider's wire body stays byte-identical.
|
||||
#[test]
|
||||
fn convert_messages_for_native_tool_call_without_extra_content_stays_none() {
|
||||
let input = vec![
|
||||
ChatMessage::assistant(
|
||||
r#"{"content":"on it","tool_calls":[{"id":"call_x","name":"shell","arguments":"{}"}]}"#,
|
||||
),
|
||||
ChatMessage::tool(r#"{"tool_call_id":"call_x","content":"done"}"#),
|
||||
];
|
||||
let converted = OpenAiCompatibleProvider::convert_messages_for_native(&input);
|
||||
let tool_calls = converted[0]
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.expect("assistant tool_calls present");
|
||||
assert!(tool_calls[0].extra_content.is_none());
|
||||
assert!(serde_json::to_value(&tool_calls[0])
|
||||
.unwrap()
|
||||
.get("extra_content")
|
||||
.is_none());
|
||||
}
|
||||
|
||||
/// 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).
|
||||
#[tokio::test]
|
||||
async fn streaming_tool_call_captures_extra_content() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let body = "data: {\"choices\":[{\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_g\",\"type\":\"function\",\"function\":{\"name\":\"shell\",\"arguments\":\"{}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"SIG_STREAM\"}}}]}}]}\n\n\
|
||||
data: [DONE]\n\n";
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/chat/completions"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_raw(body, "text/event-stream"))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let provider =
|
||||
OpenAiCompatibleProvider::new("google", &mock_server.uri(), None, AuthStyle::None);
|
||||
let request = NativeChatRequest {
|
||||
model: "models/gemini-3.5-flash".to_string(),
|
||||
messages: vec![NativeMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some("hi".into()),
|
||||
tool_call_id: None,
|
||||
tool_calls: None,
|
||||
reasoning_content: None,
|
||||
}],
|
||||
temperature: Some(0.7),
|
||||
stream: Some(true),
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
thread_id: None,
|
||||
stream_options: Some(super::compatible_types::OpenAiStreamOptions {
|
||||
include_usage: true,
|
||||
}),
|
||||
options: None,
|
||||
frequency_penalty: None,
|
||||
};
|
||||
let (delta_tx, _delta_rx) = tokio::sync::mpsc::channel(64);
|
||||
let resp = provider
|
||||
.stream_native_chat(None, &request, &delta_tx, 0)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.tool_calls.len(), 1);
|
||||
assert_eq!(
|
||||
resp.tool_calls[0]
|
||||
.extra_content
|
||||
.as_ref()
|
||||
.and_then(|v| v.pointer("/google/thought_signature"))
|
||||
.and_then(|v| v.as_str()),
|
||||
Some("SIG_STREAM"),
|
||||
"streaming must preserve the thought_signature onto the aggregated tool call"
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper: roles in serialized order.
|
||||
fn roles(messages: &[NativeMessage]) -> Vec<&str> {
|
||||
messages.iter().map(|m| m.role.as_str()).collect()
|
||||
@@ -1550,6 +1735,7 @@ fn enforce_invariants_clears_reasoning_when_assistant_collapses_to_text() {
|
||||
name: Some("web_fetch".to_string()),
|
||||
arguments: Some(serde_json::Value::String("{}".to_string())),
|
||||
}),
|
||||
extra_content: None,
|
||||
}]),
|
||||
reasoning_content: Some("deep reasoning".to_string()),
|
||||
},
|
||||
@@ -1897,6 +2083,7 @@ fn response_with_tool_call_object_arguments_deserializes() {
|
||||
name: Some("get_weather".to_string()),
|
||||
arguments: Some(serde_json::json!({"location":"London","unit":"c"})),
|
||||
}),
|
||||
extra_content: None,
|
||||
}]),
|
||||
function_call: None,
|
||||
}),
|
||||
|
||||
@@ -411,6 +411,20 @@ pub(crate) struct ToolCall {
|
||||
#[serde(rename = "type")]
|
||||
pub(crate) kind: Option<String>,
|
||||
pub(crate) function: Option<Function>,
|
||||
/// Provider-specific passthrough metadata attached to a tool call.
|
||||
///
|
||||
/// Google's Gemini OpenAI-compat endpoint returns a cryptographically
|
||||
/// signed reasoning token here as
|
||||
/// `extra_content.google.thought_signature`, and **requires** it echoed
|
||||
/// back verbatim on the assistant tool-call turn of every subsequent
|
||||
/// request — otherwise it 400s with "Function call is missing a
|
||||
/// thought_signature" (TAURI-RUST-4PK). Captured on the response and
|
||||
/// re-emitted on the request as an opaque value so any future
|
||||
/// `extra_content.*` keys round-trip unchanged. `skip_serializing_if`
|
||||
/// keeps the wire body byte-identical for every provider that doesn't
|
||||
/// send it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub(crate) extra_content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
@@ -517,6 +531,12 @@ pub(crate) struct StreamToolCallDelta {
|
||||
pub(crate) kind: Option<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) function: Option<StreamToolCallFunction>,
|
||||
/// Provider passthrough metadata (Gemini's `extra_content`, carrying
|
||||
/// `google.thought_signature`). Arrives on the first chunk for a given
|
||||
/// tool-call index; accumulated and re-emitted so the signature survives
|
||||
/// the streaming path (TAURI-RUST-4PK).
|
||||
#[serde(default)]
|
||||
pub(crate) extra_content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -545,4 +565,8 @@ pub(crate) struct StreamingToolCall {
|
||||
pub(crate) arguments: String,
|
||||
pub(crate) emitted_start: bool,
|
||||
pub(crate) emitted_chars: usize,
|
||||
/// First non-null `extra_content` seen for this tool-call index (Gemini's
|
||||
/// thought_signature). Re-emitted on the aggregated [`ToolCall`] so it can
|
||||
/// be echoed on the next turn (TAURI-RUST-4PK).
|
||||
pub(crate) extra_content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
@@ -59,6 +59,14 @@ pub struct ToolCall {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub arguments: String,
|
||||
/// Provider-specific passthrough metadata for this call, captured from the
|
||||
/// response and echoed back verbatim on the next assistant turn. Carries
|
||||
/// Google Gemini's required `extra_content.google.thought_signature` so
|
||||
/// multi-turn tool calling round-trips without a 400 (TAURI-RUST-4PK).
|
||||
/// `None`/omitted for every provider that doesn't emit it, so non-Gemini
|
||||
/// history stays byte-identical.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub extra_content: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Token usage information returned by the provider after an inference call.
|
||||
|
||||
@@ -55,6 +55,7 @@ fn chat_response_helpers() {
|
||||
id: "1".into(),
|
||||
name: "shell".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
@@ -69,6 +70,7 @@ fn tool_call_serialization() {
|
||||
id: "call_123".into(),
|
||||
name: "file_read".into(),
|
||||
arguments: r#"{"path":"test.txt"}"#.into(),
|
||||
extra_content: None,
|
||||
};
|
||||
let json = serde_json::to_string(&tc).unwrap();
|
||||
assert!(json.contains("call_123"));
|
||||
|
||||
@@ -86,6 +86,7 @@ fn tool_call(id: &str, name: &str, args: serde_json::Value) -> ChatResponse {
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
arguments: args.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -97,6 +97,7 @@ fn tool_call_resp(id: &str, name: &str, args: serde_json::Value) -> ChatResponse
|
||||
id: id.into(),
|
||||
name: name.into(),
|
||||
arguments: args.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -223,6 +223,7 @@ fn tool_response(name: &str, arguments: serde_json::Value) -> ChatResponse {
|
||||
id: "round21-call".to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: Some("scripted tool use".to_string()),
|
||||
|
||||
@@ -262,6 +262,7 @@ fn tool_response(id: &str, name: &str, arguments: serde_json::Value) -> ChatResp
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: Some(UsageInfo {
|
||||
input_tokens: 7,
|
||||
|
||||
@@ -187,6 +187,7 @@ fn tool_call(id: &str, name: &str, arguments: serde_json::Value) -> ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -225,6 +225,7 @@ fn tool_response(name: &str, arguments: serde_json::Value) -> ChatResponse {
|
||||
id: "round18-call".to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: Some("test reasoning".to_string()),
|
||||
|
||||
@@ -487,6 +487,7 @@ fn native_tool_response(id: &str, name: &str, args: serde_json::Value) -> ChatRe
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: args.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: Some(UsageInfo {
|
||||
input_tokens: 21,
|
||||
|
||||
@@ -330,6 +330,7 @@ fn native_tool_response(name: &str, arguments: serde_json::Value) -> ChatRespons
|
||||
id: format!("call-{name}"),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: Some(UsageInfo {
|
||||
input_tokens: 13,
|
||||
|
||||
@@ -290,6 +290,7 @@ fn native_tool_response(name: &str, arguments: &str) -> ChatResponse {
|
||||
id: "round20-native-1".to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: Some(UsageInfo {
|
||||
input_tokens: 7_000,
|
||||
|
||||
@@ -53,6 +53,7 @@ impl Provider for MockCalendarProvider {
|
||||
"timeMax": "2026-05-04T00:00:00Z"
|
||||
})
|
||||
.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -218,6 +218,7 @@ impl Provider for StubProvider {
|
||||
id: "call_1".into(),
|
||||
name: "composio_list_tools".into(),
|
||||
arguments: json!({ "toolkits": ["gmail"] }).to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -3798,11 +3798,13 @@ fn agent_dispatchers_and_host_runtime_cover_public_edge_paths() {
|
||||
id: "call-ok".into(),
|
||||
name: "search_docs".into(),
|
||||
arguments: "{\"query\":\"native\"}".into(),
|
||||
extra_content: None,
|
||||
},
|
||||
ToolCall {
|
||||
id: "call-bad-json".into(),
|
||||
name: "search_docs".into(),
|
||||
arguments: "{not-json".into(),
|
||||
extra_content: None,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
@@ -3835,6 +3837,7 @@ fn agent_dispatchers_and_host_runtime_cover_public_edge_paths() {
|
||||
id: "call-1".into(),
|
||||
name: "search_docs".into(),
|
||||
arguments: "{\"query\":\"paired\"}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: Some("thinking".into()),
|
||||
},
|
||||
@@ -3848,6 +3851,7 @@ fn agent_dispatchers_and_host_runtime_cover_public_edge_paths() {
|
||||
id: "missing-result".into(),
|
||||
name: "search_docs".into(),
|
||||
arguments: "{}".into(),
|
||||
extra_content: None,
|
||||
}],
|
||||
reasoning_content: None,
|
||||
},
|
||||
|
||||
@@ -671,6 +671,7 @@ impl Provider for Round22Provider {
|
||||
id: "round22-call".to_string(),
|
||||
name: "round22_tool".to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: None,
|
||||
|
||||
@@ -218,6 +218,7 @@ fn tool_response(id: &str, name: &str, arguments: serde_json::Value) -> ChatResp
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
usage: None,
|
||||
reasoning_content: Some(format!("need {name}")),
|
||||
|
||||
@@ -725,6 +725,7 @@ async fn round16_agent_builder_turn_uses_public_harness_paths() {
|
||||
id: "call-round16".into(),
|
||||
name: "echo".into(),
|
||||
arguments: json!({ "message": "builder" }).to_string(),
|
||||
extra_content: None,
|
||||
}],
|
||||
),
|
||||
response(Some("builder final"), Vec::new()),
|
||||
|
||||
Reference in New Issue
Block a user