diff --git a/src/openhuman/agent/dispatcher_tests.rs b/src/openhuman/agent/dispatcher_tests.rs index d9fbc8deb..d95997bd9 100644 --- a/src/openhuman/agent/dispatcher_tests.rs +++ b/src/openhuman/agent/dispatcher_tests.rs @@ -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()), }, diff --git a/src/openhuman/agent/harness/bughunt_tests.rs b/src/openhuman/agent/harness/bughunt_tests.rs index d057148c1..9c287bbf0 100644 --- a/src/openhuman/agent/harness/bughunt_tests.rs +++ b/src/openhuman/agent/harness/bughunt_tests.rs @@ -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, diff --git a/src/openhuman/agent/harness/parse_tests.rs b/src/openhuman/agent/harness/parse_tests.rs index fca17e808..37dab74b9 100644 --- a/src/openhuman/agent/harness/parse_tests.rs +++ b/src/openhuman/agent/harness/parse_tests.rs @@ -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); diff --git a/src/openhuman/agent/harness/session/runtime.rs b/src/openhuman/agent/harness/session/runtime.rs index d913926e0..ac2647ae1 100644 --- a/src/openhuman/agent/harness/session/runtime.rs +++ b/src/openhuman/agent/harness/session/runtime.rs @@ -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() } diff --git a/src/openhuman/agent/harness/session/runtime_tests.rs b/src/openhuman/agent/harness/session/runtime_tests.rs index 252f7f23d..b85139ef0 100644 --- a/src/openhuman/agent/harness/session/runtime_tests.rs +++ b/src/openhuman/agent/harness/session/runtime_tests.rs @@ -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()), diff --git a/src/openhuman/agent/harness/session/tests.rs b/src/openhuman/agent/harness/session/tests.rs index 68434ce93..c4a69e6af 100644 --- a/src/openhuman/agent/harness/session/tests.rs +++ b/src/openhuman/agent/harness/session/tests.rs @@ -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, diff --git a/src/openhuman/agent/harness/session/turn_engine_adapter.rs b/src/openhuman/agent/harness/session/turn_engine_adapter.rs index f16d790ac..ead2bf9ff 100644 --- a/src/openhuman/agent/harness/session/turn_engine_adapter.rs +++ b/src/openhuman/agent/harness/session/turn_engine_adapter.rs @@ -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() diff --git a/src/openhuman/agent/harness/session/turn_tests.rs b/src/openhuman/agent/harness/session/turn_tests.rs index 81a7f3466..ca611ce6b 100644 --- a/src/openhuman/agent/harness/session/turn_tests.rs +++ b/src/openhuman/agent/harness/session/turn_tests.rs @@ -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, }, diff --git a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs index c4f00afad..9db62a92e 100644 --- a/src/openhuman/agent/harness/subagent_runner/ops_tests.rs +++ b/src/openhuman/agent/harness/subagent_runner/ops_tests.rs @@ -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, diff --git a/src/openhuman/agent/harness/test_support.rs b/src/openhuman/agent/harness/test_support.rs index b4bcaaf50..f40641af6 100644 --- a/src/openhuman/agent/harness/test_support.rs +++ b/src/openhuman/agent/harness/test_support.rs @@ -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() diff --git a/src/openhuman/agent/harness/token_budget.rs b/src/openhuman/agent/harness/token_budget.rs index cb4024a2a..754e81a27 100644 --- a/src/openhuman/agent/harness/token_budget.rs +++ b/src/openhuman/agent/harness/token_budget.rs @@ -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, } } diff --git a/src/openhuman/agent/harness/tool_loop_tests.rs b/src/openhuman/agent/harness/tool_loop_tests.rs index d60c402ac..5af2dd8a7 100644 --- a/src/openhuman/agent/harness/tool_loop_tests.rs +++ b/src/openhuman/agent/harness/tool_loop_tests.rs @@ -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, diff --git a/src/openhuman/agent/tests.rs b/src/openhuman/agent/tests.rs index b54640bed..947f117c0 100644 --- a/src/openhuman/agent/tests.rs +++ b/src/openhuman/agent/tests.rs @@ -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, diff --git a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs index 631ecb066..c959b86bd 100644 --- a/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs +++ b/src/openhuman/agent_orchestration/tools/spawn_parallel_agents_tests.rs @@ -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, diff --git a/src/openhuman/context/manager_tests.rs b/src/openhuman/context/manager_tests.rs index 1b125b747..933c9307c 100644 --- a/src/openhuman/context/manager_tests.rs +++ b/src/openhuman/context/manager_tests.rs @@ -14,6 +14,7 @@ fn call(id: &str) -> ConversationMessage { id: id.into(), name: "t".into(), arguments: "{}".into(), + extra_content: None, }], reasoning_content: None, } diff --git a/src/openhuman/context/microcompact.rs b/src/openhuman/context/microcompact.rs index d7c76f587..dad448718 100644 --- a/src/openhuman/context/microcompact.rs +++ b/src/openhuman/context/microcompact.rs @@ -115,6 +115,7 @@ mod tests { id: id.into(), name: name.into(), arguments: "{}".into(), + extra_content: None, }], reasoning_content: None, } diff --git a/src/openhuman/context/pipeline.rs b/src/openhuman/context/pipeline.rs index 68156b04c..d83b5c758 100644 --- a/src/openhuman/context/pipeline.rs +++ b/src/openhuman/context/pipeline.rs @@ -269,6 +269,7 @@ mod tests { id: id.into(), name: "t".into(), arguments: "{}".into(), + extra_content: None, }], reasoning_content: None, } diff --git a/src/openhuman/context/summarizer_tests.rs b/src/openhuman/context/summarizer_tests.rs index 8c452e01a..33a0d77c8 100644 --- a/src/openhuman/context/summarizer_tests.rs +++ b/src/openhuman/context/summarizer_tests.rs @@ -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, }, diff --git a/src/openhuman/inference/provider/claude_code/event_mapper.rs b/src/openhuman/inference/provider/claude_code/event_mapper.rs index ddf88eb52..01c85bf55 100644 --- a/src/openhuman/inference/provider/claude_code/event_mapper.rs +++ b/src/openhuman/inference/provider/claude_code/event_mapper.rs @@ -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() diff --git a/src/openhuman/inference/provider/compatible_helpers.rs b/src/openhuman/inference/provider/compatible_helpers.rs index adbaab4b0..4eaa863cc 100644 --- a/src/openhuman/inference/provider/compatible_helpers.rs +++ b/src/openhuman/inference/provider/compatible_helpers.rs @@ -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::>(); @@ -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::>(); @@ -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, }); } } diff --git a/src/openhuman/inference/provider/compatible_parse.rs b/src/openhuman/inference/provider/compatible_parse.rs index f3a41196b..49c5907d6 100644 --- a/src/openhuman/inference/provider/compatible_parse.rs +++ b/src/openhuman/inference/provider/compatible_parse.rs @@ -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(), }) } diff --git a/src/openhuman/inference/provider/compatible_provider_impl.rs b/src/openhuman/inference/provider/compatible_provider_impl.rs index 21a949340..cde8f9da9 100644 --- a/src/openhuman/inference/provider/compatible_provider_impl.rs +++ b/src/openhuman/inference/provider/compatible_provider_impl.rs @@ -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::>(); diff --git a/src/openhuman/inference/provider/compatible_stream_native.rs b/src/openhuman/inference/provider/compatible_stream_native.rs index cce09b2d3..ebcbcb701 100644 --- a/src/openhuman/inference/provider/compatible_stream_native.rs +++ b/src/openhuman/inference/provider/compatible_stream_native.rs @@ -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(); diff --git a/src/openhuman/inference/provider/compatible_tests.rs b/src/openhuman/inference/provider/compatible_tests.rs index 3a4d89d51..91cefe9cb 100644 --- a/src/openhuman/inference/provider/compatible_tests.rs +++ b/src/openhuman/inference/provider/compatible_tests.rs @@ -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, }), diff --git a/src/openhuman/inference/provider/compatible_types.rs b/src/openhuman/inference/provider/compatible_types.rs index ade2234a7..f37133f97 100644 --- a/src/openhuman/inference/provider/compatible_types.rs +++ b/src/openhuman/inference/provider/compatible_types.rs @@ -411,6 +411,20 @@ pub(crate) struct ToolCall { #[serde(rename = "type")] pub(crate) kind: Option, pub(crate) function: Option, + /// 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, } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -517,6 +531,12 @@ pub(crate) struct StreamToolCallDelta { pub(crate) kind: Option, #[serde(default)] pub(crate) function: Option, + /// 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, } #[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, } diff --git a/src/openhuman/inference/provider/traits.rs b/src/openhuman/inference/provider/traits.rs index 90954a0f2..82a5d504d 100644 --- a/src/openhuman/inference/provider/traits.rs +++ b/src/openhuman/inference/provider/traits.rs @@ -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, } /// Token usage information returned by the provider after an inference call. diff --git a/src/openhuman/inference/provider/traits_tests.rs b/src/openhuman/inference/provider/traits_tests.rs index 266be1c9e..2ca4c45c9 100644 --- a/src/openhuman/inference/provider/traits_tests.rs +++ b/src/openhuman/inference/provider/traits_tests.rs @@ -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")); diff --git a/src/openhuman/workflows/e2e_plumbing_tests.rs b/src/openhuman/workflows/e2e_plumbing_tests.rs index 349e4d7f1..4157f5e92 100644 --- a/src/openhuman/workflows/e2e_plumbing_tests.rs +++ b/src/openhuman/workflows/e2e_plumbing_tests.rs @@ -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, diff --git a/src/openhuman/workflows/e2e_run_tests.rs b/src/openhuman/workflows/e2e_run_tests.rs index f996e179a..dabf196f5 100644 --- a/src/openhuman/workflows/e2e_run_tests.rs +++ b/src/openhuman/workflows/e2e_run_tests.rs @@ -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, diff --git a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs index 92a2de541..40a9bf228 100644 --- a/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs +++ b/tests/agent_archivist_debug_round21_raw_coverage_e2e.rs @@ -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()), diff --git a/tests/agent_harness_leftovers_raw_coverage_e2e.rs b/tests/agent_harness_leftovers_raw_coverage_e2e.rs index fd618d27c..5bb55a24e 100644 --- a/tests/agent_harness_leftovers_raw_coverage_e2e.rs +++ b/tests/agent_harness_leftovers_raw_coverage_e2e.rs @@ -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, diff --git a/tests/agent_harness_raw_coverage_e2e.rs b/tests/agent_harness_raw_coverage_e2e.rs index ae7a542b3..ecc5711b0 100644 --- a/tests/agent_harness_raw_coverage_e2e.rs +++ b/tests/agent_harness_raw_coverage_e2e.rs @@ -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, } } diff --git a/tests/agent_prompts_subagent_raw_coverage_e2e.rs b/tests/agent_prompts_subagent_raw_coverage_e2e.rs index 1983ef08a..564dd5afd 100644 --- a/tests/agent_prompts_subagent_raw_coverage_e2e.rs +++ b/tests/agent_prompts_subagent_raw_coverage_e2e.rs @@ -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()), diff --git a/tests/agent_session_turn_raw_coverage_e2e.rs b/tests/agent_session_turn_raw_coverage_e2e.rs index c8b48493f..437ff3648 100644 --- a/tests/agent_session_turn_raw_coverage_e2e.rs +++ b/tests/agent_session_turn_raw_coverage_e2e.rs @@ -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, diff --git a/tests/agent_tool_loop_raw_coverage_e2e.rs b/tests/agent_tool_loop_raw_coverage_e2e.rs index 4c2739ba4..d92b2b2e0 100644 --- a/tests/agent_tool_loop_raw_coverage_e2e.rs +++ b/tests/agent_tool_loop_raw_coverage_e2e.rs @@ -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, diff --git a/tests/agent_turn_builder_leftovers_raw_coverage_e2e.rs b/tests/agent_turn_builder_leftovers_raw_coverage_e2e.rs index 3499e7ebd..92ecb16b3 100644 --- a/tests/agent_turn_builder_leftovers_raw_coverage_e2e.rs +++ b/tests/agent_turn_builder_leftovers_raw_coverage_e2e.rs @@ -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, diff --git a/tests/calendar_grounding_e2e.rs b/tests/calendar_grounding_e2e.rs index 91762624e..4e796964a 100644 --- a/tests/calendar_grounding_e2e.rs +++ b/tests/calendar_grounding_e2e.rs @@ -53,6 +53,7 @@ impl Provider for MockCalendarProvider { "timeMax": "2026-05-04T00:00:00Z" }) .to_string(), + extra_content: None, }], usage: None, reasoning_content: None, diff --git a/tests/composio_list_tools_stack_overflow_regression.rs b/tests/composio_list_tools_stack_overflow_regression.rs index 2667f85de..6d7dc625c 100644 --- a/tests/composio_list_tools_stack_overflow_regression.rs +++ b/tests/composio_list_tools_stack_overflow_regression.rs @@ -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, diff --git a/tests/inference_agent_raw_coverage_e2e.rs b/tests/inference_agent_raw_coverage_e2e.rs index cb7922a1e..031d1a7b0 100644 --- a/tests/inference_agent_raw_coverage_e2e.rs +++ b/tests/inference_agent_raw_coverage_e2e.rs @@ -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, }, diff --git a/tests/inference_provider_admin_round22_raw_coverage_e2e.rs b/tests/inference_provider_admin_round22_raw_coverage_e2e.rs index 0730975f8..47e122525 100644 --- a/tests/inference_provider_admin_round22_raw_coverage_e2e.rs +++ b/tests/inference_provider_admin_round22_raw_coverage_e2e.rs @@ -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, diff --git a/tests/monitor_agent_e2e.rs b/tests/monitor_agent_e2e.rs index 8b4a218fd..a5430536f 100644 --- a/tests/monitor_agent_e2e.rs +++ b/tests/monitor_agent_e2e.rs @@ -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}")), diff --git a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs index 5b6f2205c..e4bb94b88 100644 --- a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs +++ b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs @@ -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()),