From 7d97fe1eadedd8c7f8cc25e9a537b7ab8c8b1dd5 Mon Sep 17 00:00:00 2001 From: Alex Hancock Date: Mon, 31 Aug 2026 15:24:39 +0000 Subject: [PATCH] feat(gdk): preserve thinking and redacted-thinking blocks across turns (#11636) --- crates/goose-sdk/src/bindings.rs | 356 +++++++++++++++++++++++++++- documentation/src/data/gdk-api.json | 24 ++ 2 files changed, 377 insertions(+), 3 deletions(-) diff --git a/crates/goose-sdk/src/bindings.rs b/crates/goose-sdk/src/bindings.rs index ffb020653..00e7d3629 100644 --- a/crates/goose-sdk/src/bindings.rs +++ b/crates/goose-sdk/src/bindings.rs @@ -204,6 +204,10 @@ pub enum MessageContent { id: String, name: String, arguments_json: String, + #[uniffi(default = None)] + provider_metadata_json: Option, + #[uniffi(default = None)] + tool_error_json: Option, }, ToolResult { id: String, @@ -247,11 +251,24 @@ impl MessageContent { id, name, arguments_json, + provider_metadata_json, + tool_error_json, } => { - let arguments = parse_json_object(arguments_json)?; - Ok(GooseMessageContent::tool_request( + let metadata = provider_metadata_json + .as_deref() + .map(parse_json_object) + .transpose()?; + let tool_call = match tool_error_json { + Some(error_json) => Err(serde_json::from_str(error_json)?), + None => { + let arguments = parse_json_object(arguments_json)?; + Ok(CallToolRequestParams::new(name.clone()).with_arguments(arguments)) + } + }; + Ok(GooseMessageContent::tool_request_with_metadata( id.clone(), - Ok(CallToolRequestParams::new(name.clone()).with_arguments(arguments)), + tool_call, + metadata.as_ref(), )) } MessageContent::ToolResult { @@ -283,6 +300,59 @@ impl MessageContent { } } } + + /// Maps provider output back onto the binding surface so callers can replay + /// an assistant turn without reparsing `message_json`. Thinking signatures + /// and redacted payloads are carried through verbatim: providers reject + /// replayed thinking blocks whose signature was dropped or altered. + fn from_goose_content(content: &GooseMessageContent) -> Option { + match content { + GooseMessageContent::Text(text) => Some(MessageContent::Text { + text: text.text.clone(), + }), + GooseMessageContent::Image(image) => Some(MessageContent::Image { + mime_type: image.mime_type.clone(), + data: base64::engine::general_purpose::STANDARD + .decode(&image.data) + .ok()?, + }), + GooseMessageContent::ToolRequest(request) => { + let provider_metadata_json = request + .metadata + .as_ref() + .and_then(|metadata| serde_json::to_string(metadata).ok()); + match &request.tool_call { + Ok(tool_call) => Some(MessageContent::ToolRequest { + id: request.id.clone(), + name: tool_call.name.to_string(), + arguments_json: serde_json::to_string( + &tool_call.arguments.clone().unwrap_or_default(), + ) + .ok()?, + provider_metadata_json, + tool_error_json: None, + }), + Err(error) => Some(MessageContent::ToolRequest { + id: request.id.clone(), + name: String::new(), + arguments_json: "{}".to_string(), + provider_metadata_json, + tool_error_json: Some(serde_json::to_string(error).ok()?), + }), + } + } + GooseMessageContent::Thinking(thinking) => Some(MessageContent::Thinking { + thinking: thinking.thinking.clone(), + signature: thinking.signature.clone(), + }), + GooseMessageContent::RedactedThinking(redacted) => { + Some(MessageContent::RedactedThinking { + data: redacted.data.clone(), + }) + } + _ => None, + } + } } fn chrono_now() -> i64 { @@ -455,6 +525,8 @@ pub enum StreamChunk { id: String, name: String, arguments_json: String, + #[uniffi(default = None)] + provider_metadata_json: Option, }, ThinkingChunk { thinking: String, @@ -537,6 +609,9 @@ impl From for GooseStreamError { #[derive(Debug, Clone, uniffi::Record)] pub struct ProviderCompletion { pub message_json: String, + /// The assistant turn as binding types, ready to append to history and + /// replay on the next request without reparsing `message_json`. + pub content: Vec, pub usage: Option, } @@ -675,6 +750,11 @@ impl ProviderHandle { Ok(ProviderCompletion { message_json: serde_json::to_string(&message)?, + content: message + .content + .iter() + .filter_map(MessageContent::from_goose_content) + .collect(), usage: Some(Usage::from_provider_usage(&usage)?), }) } @@ -1103,6 +1183,10 @@ fn message_to_chunks(message: Message) -> Vec { name: tool_call.name.to_string(), arguments_json: serde_json::to_string(&tool_call.arguments.unwrap_or_default()) .unwrap_or_else(|_| "{}".to_string()), + provider_metadata_json: request + .metadata + .as_ref() + .and_then(|metadata| serde_json::to_string(metadata).ok()), }), Err(error) => Some(StreamChunk::ErrorChunk { error: GooseStreamError { @@ -1275,4 +1359,270 @@ mod tests { assert_eq!(absent.cache_read_input_tokens, None); assert_eq!(absent.cache_creation_input_tokens, None); } + + #[test] + fn thinking_content_round_trips_with_signature() { + let original = MessageContent::Thinking { + thinking: "step one, then step two".to_string(), + signature: "ErUBCkYIBRgCIkAe0pAQ==".to_string(), + }; + + let goose = original.to_goose_content().unwrap(); + let GooseMessageContent::Thinking(thinking) = &goose else { + panic!("expected thinking content"); + }; + assert_eq!(thinking.thinking, "step one, then step two"); + assert_eq!(thinking.signature, "ErUBCkYIBRgCIkAe0pAQ=="); + + let round_tripped = MessageContent::from_goose_content(&goose).unwrap(); + let MessageContent::Thinking { + thinking, + signature, + } = round_tripped + else { + panic!("expected thinking content"); + }; + assert_eq!(thinking, "step one, then step two"); + assert_eq!(signature, "ErUBCkYIBRgCIkAe0pAQ=="); + } + + #[test] + fn redacted_thinking_content_round_trips_opaque_data() { + let original = MessageContent::RedactedThinking { + data: "EroBCkYIBRgCKkBb0pAQopaque".to_string(), + }; + + let goose = original.to_goose_content().unwrap(); + let GooseMessageContent::RedactedThinking(redacted) = &goose else { + panic!("expected redacted thinking content"); + }; + assert_eq!(redacted.data, "EroBCkYIBRgCKkBb0pAQopaque"); + + let round_tripped = MessageContent::from_goose_content(&goose).unwrap(); + let MessageContent::RedactedThinking { data } = round_tripped else { + panic!("expected redacted thinking content"); + }; + assert_eq!(data, "EroBCkYIBRgCKkBb0pAQopaque"); + } + + #[test] + fn tool_request_round_trips_provider_metadata() { + let original = MessageContent::ToolRequest { + id: "call_456".to_string(), + name: "test_tool".to_string(), + arguments_json: "{}".to_string(), + provider_metadata_json: Some( + r#"{"extra_content":{"google":{"thought_signature":"nested_sig_xyz789"}}}"# + .to_string(), + ), + tool_error_json: None, + }; + + let goose = original.to_goose_content().unwrap(); + let GooseMessageContent::ToolRequest(request) = &goose else { + panic!("expected tool request"); + }; + assert_eq!( + request.metadata.as_ref().unwrap()["extra_content"]["google"]["thought_signature"], + "nested_sig_xyz789" + ); + + let round_tripped = MessageContent::from_goose_content(&goose).unwrap(); + let MessageContent::ToolRequest { + provider_metadata_json, + .. + } = &round_tripped + else { + panic!("expected tool request"); + }; + assert_eq!( + serde_json::from_str::(provider_metadata_json.as_ref().unwrap()).unwrap(), + serde_json::json!({"extra_content":{"google":{"thought_signature":"nested_sig_xyz789"}}}) + ); + + let messages = convert_messages(vec![ProviderMessage { + role: MessageRole::Assistant, + content: vec![round_tripped], + }]) + .unwrap(); + let spec = goose_providers::formats::openai::format_messages( + &messages, + &goose_providers::images::ImageFormat::OpenAi, + ); + assert_eq!( + spec[0]["tool_calls"][0]["extra_content"]["google"]["thought_signature"], + "nested_sig_xyz789" + ); + } + + #[test] + fn completion_content_preserves_malformed_tool_requests() { + let error = rmcp::model::ErrorData { + code: rmcp::model::ErrorCode::INVALID_REQUEST, + message: std::borrow::Cow::from( + "The provided function name was empty; a tool call must name a tool".to_string(), + ), + data: None, + }; + let message = Message::assistant() + .with_tool_request("call_bad_1", Err(error)) + .with_text("done"); + + let content: Vec = message + .content + .iter() + .filter_map(MessageContent::from_goose_content) + .collect(); + + assert_eq!( + content.len(), + 2, + "the failed tool request must not be dropped" + ); + let MessageContent::ToolRequest { + id, + tool_error_json, + .. + } = &content[0] + else { + panic!("expected tool request"); + }; + assert_eq!(id, "call_bad_1"); + assert!(tool_error_json.is_some()); + + let replayed = convert_messages(vec![ProviderMessage { + role: MessageRole::Assistant, + content, + }]) + .unwrap(); + let GooseMessageContent::ToolRequest(request) = &replayed[0].content[0] else { + panic!("expected tool request"); + }; + let replayed_error = request + .tool_call + .as_ref() + .expect_err("replayed request must stay a failed tool call"); + assert_eq!(replayed_error.code, rmcp::model::ErrorCode::INVALID_REQUEST); + assert!(replayed_error.message.contains("must name a tool")); + } + + #[test] + fn streaming_tool_chunks_carry_provider_metadata() { + let mut metadata = goose_providers::conversation::message::ProviderMetadata::new(); + metadata.insert( + "extra_content".to_string(), + serde_json::json!({"google": {"thought_signature": "stream_sig_abc123"}}), + ); + + let message = Message::assistant().with_tool_request_with_metadata( + "call_stream_1", + Ok(CallToolRequestParams::new("test_tool")), + Some(&metadata), + None, + ); + + let chunks = message_to_chunks(message); + let StreamChunk::ToolChunk { + provider_metadata_json, + .. + } = chunks + .iter() + .find(|chunk| matches!(chunk, StreamChunk::ToolChunk { .. })) + .expect("expected a tool chunk") + else { + unreachable!() + }; + + assert_eq!( + serde_json::from_str::(provider_metadata_json.as_ref().unwrap()).unwrap(), + serde_json::json!({"extra_content":{"google":{"thought_signature":"stream_sig_abc123"}}}) + ); + } + + #[test] + fn thinking_blocks_survive_multi_turn_replay() { + let assistant_turn = ProviderMessage { + role: MessageRole::Assistant, + content: vec![ + MessageContent::Thinking { + thinking: "the user wants the capital".to_string(), + signature: "sig-abc123".to_string(), + }, + MessageContent::RedactedThinking { + data: "opaque-payload".to_string(), + }, + MessageContent::Text { + text: "Paris".to_string(), + }, + ], + }; + + let history = vec![ + ProviderMessage { + role: MessageRole::User, + content: vec![MessageContent::Text { + text: "what is the capital of France?".to_string(), + }], + }, + assistant_turn, + ProviderMessage { + role: MessageRole::User, + content: vec![MessageContent::Text { + text: "and of Spain?".to_string(), + }], + }, + ]; + + let messages = convert_messages(history).unwrap(); + assert!(matches!( + messages[1].content[0], + GooseMessageContent::Thinking(_) + )); + assert!(matches!( + messages[1].content[1], + GooseMessageContent::RedactedThinking(_) + )); + + let spec = goose_providers::formats::anthropic::format_messages(&messages); + let assistant = &spec[1]["content"]; + assert_eq!(assistant[0]["type"], "thinking"); + assert_eq!(assistant[0]["thinking"], "the user wants the capital"); + assert_eq!(assistant[0]["signature"], "sig-abc123"); + assert_eq!(assistant[1]["type"], "redacted_thinking"); + assert_eq!(assistant[1]["data"], "opaque-payload"); + assert!(assistant[1].get("thinking").is_none()); + assert_eq!(assistant[2]["type"], "text"); + assert_eq!(assistant[2]["text"], "Paris"); + } + + #[test] + fn completion_content_preserves_thinking_for_the_next_turn() { + let message = Message::assistant() + .with_thinking("reasoning to replay", "sig-xyz") + .with_redacted_thinking("opaque-payload") + .with_text("Madrid"); + + let content: Vec = message + .content + .iter() + .filter_map(MessageContent::from_goose_content) + .collect(); + + assert!(matches!( + &content[0], + MessageContent::Thinking { thinking, signature } + if thinking == "reasoning to replay" && signature == "sig-xyz" + )); + assert!(matches!( + &content[1], + MessageContent::RedactedThinking { data } if data == "opaque-payload" + )); + + let replayed = convert_messages(vec![ProviderMessage { + role: MessageRole::Assistant, + content, + }]) + .unwrap(); + assert_eq!(replayed[0].content, message.content); + } } diff --git a/documentation/src/data/gdk-api.json b/documentation/src/data/gdk-api.json index 0bb913fc8..fa4e81f3c 100644 --- a/documentation/src/data/gdk-api.json +++ b/documentation/src/data/gdk-api.json @@ -410,6 +410,18 @@ "type": "String", "default": null, "docs": "" + }, + { + "name": "provider_metadata_json", + "type": "Option", + "default": null, + "docs": "" + }, + { + "name": "tool_error_json", + "type": "Option", + "default": null, + "docs": "" } ] }, @@ -669,6 +681,12 @@ "type": "String", "default": null, "docs": "" + }, + { + "name": "provider_metadata_json", + "type": "Option", + "default": null, + "docs": "" } ] }, @@ -800,6 +818,12 @@ "default": null, "docs": "" }, + { + "name": "content", + "type": "Vec", + "default": null, + "docs": "The assistant turn as binding types, ready to append to history and replay on the next request without reparsing `message_json`." + }, { "name": "usage", "type": "Option",