diff --git a/rust/crates/api/benches/request_building.rs b/rust/crates/api/benches/request_building.rs index 23493477..c0d97c96 100644 --- a/rust/crates/api/benches/request_building.rs +++ b/rust/crates/api/benches/request_building.rs @@ -38,6 +38,7 @@ fn create_sample_request(message_count: usize) -> MessageRequest { id: format!("call_{}", i), name: "read_file".to_string(), input: json!({"path": format!("/tmp/file{}", i)}), + thought_signature: None, }, ], }), @@ -57,6 +58,7 @@ fn create_sample_request(message_count: usize) -> MessageRequest { id: format!("call_{}", i), name: "write_file".to_string(), input: json!({"path": format!("/tmp/out{}", i), "content": "data"}), + thought_signature: None, }], }), } @@ -105,11 +107,13 @@ fn bench_translate_message(c: &mut Criterion) { id: "call_1".to_string(), name: "read_file".to_string(), input: json!({"path": "/tmp/test"}), + thought_signature: None, }, InputContentBlock::ToolUse { id: "call_2".to_string(), name: "write_file".to_string(), input: json!({"path": "/tmp/out", "content": "data"}), + thought_signature: None, }, ], }; diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index cb6b329e..b10e737b 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -586,6 +586,21 @@ impl StreamState { } } + if let Some(delta_extra) = &choice.delta.extra_content { + if let Some(delta_sig) = delta_extra + .get("google") + .and_then(|g| g.get("thought_signature")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + for state in self.tool_calls.values_mut() { + if state.thought_signature.is_none() { + state.thought_signature.get_or_insert(delta_sig.to_string()); + } + } + } + } + if let Some(finish_reason) = choice.finish_reason { self.stop_reason = Some(normalize_finish_reason(&finish_reason)); if finish_reason == "tool_calls" { @@ -693,6 +708,7 @@ struct ToolCallState { id: Option, name: Option, arguments: String, + thought_signature: Option, emitted_len: usize, started: bool, stopped: bool, @@ -710,6 +726,24 @@ impl ToolCallState { if let Some(arguments) = tool_call.function.arguments { self.arguments.push_str(&arguments); } + + if let Some(sig) = tool_call.thought_signature.filter(|s| !s.is_empty()) { + self.thought_signature.get_or_insert(sig); + } + + // https://ai.google.dev/gemini-api/docs/thought-signatures + if self.thought_signature.is_none() { + if let Some(sig) = tool_call + .extra_content + .as_ref() + .and_then(|ec| ec.get("google")) + .and_then(|g| g.get("thought_signature")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + self.thought_signature.get_or_insert(sig.to_string()); + } + } } const fn block_index(&self, offset: u32) -> u32 { @@ -731,6 +765,7 @@ impl ToolCallState { id, name, input: json!({}), + thought_signature: self.thought_signature.clone(), }, })) } @@ -782,6 +817,10 @@ struct ChatMessage { struct ResponseToolCall { id: String, function: ResponseToolFunction, + #[serde(default)] + thought_signature: Option, + #[serde(default)] + extra_content: Option, } #[derive(Debug, Deserialize)] @@ -852,6 +891,8 @@ struct ChunkDelta { thinking: Option, #[serde(default, deserialize_with = "deserialize_null_as_empty_vec")] tool_calls: Vec, + #[serde(default)] + extra_content: Option, } #[derive(Debug, Default, Deserialize)] @@ -860,7 +901,7 @@ struct ThinkingDelta { content: Option, } -#[derive(Debug, Deserialize)] +#[derive(Debug, Default, Deserialize)] struct DeltaToolCall { #[serde(default)] index: u32, @@ -868,6 +909,10 @@ struct DeltaToolCall { id: Option, #[serde(default)] function: DeltaFunction, + #[serde(default)] + thought_signature: Option, + #[serde(default)] + extra_content: Option, } #[derive(Debug, Default, Deserialize)] @@ -923,6 +968,22 @@ pub fn model_requires_reasoning_content_in_history(model: &str) -> bool { canonical.starts_with("deepseek-v4") } +/// Dummy thought signature accepted by Gemini as a validation bypass for +/// conversation history that lacks a real signature. Source: +/// - LiteLLM: https://github.com/BerriAI/litellm/pull/16812 +/// - Google: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs +const GEMINI_DUMMY_THOUGHT_SIGNATURE: &str = "c2tpcF90aG91Z2h0X3NpZ25hdHVyZV92YWxpZGF0b3I="; + +/// Returns true if the model is a Gemini model (Gemini 2.5+, 3+ etc) that +/// requires `thought_signature` on function calls in conversation history. +#[must_use] +pub fn is_gemini_model(model: &str) -> bool { + let lowered = model.to_ascii_lowercase(); + let canonical = lowered.rsplit('/').next().unwrap_or(lowered.as_str()); + canonical.starts_with("gemini") +} + + /// Strip routing prefix (e.g., "openai/gpt-4" → "gpt-4") for the wire. /// The prefix is used only to select transport; the backend expects the /// bare model id. Use `local/` to force OpenAI-compatible routing while @@ -1216,14 +1277,32 @@ pub fn translate_message(message: &InputMessage, model: &str) -> Vec { InputContentBlock::Thinking { thinking: value, .. } => reasoning.push_str(value), - InputContentBlock::ToolUse { id, name, input } => tool_calls.push(json!({ - "id": id, - "type": "function", - "function": { - "name": name, - "arguments": input.to_string(), + InputContentBlock::ToolUse { id, name, input, thought_signature } => { + let mut tc = json!({ + "id": id, + "type": "function", + "function": { + "name": name, + "arguments": input.to_string(), + } + }); + + let sig_for_gemini = thought_signature.clone().or_else(|| { + if is_gemini_model(model) { + Some(GEMINI_DUMMY_THOUGHT_SIGNATURE.to_string()) + } else { + None + } + }); + if let Some(sig) = sig_for_gemini { + tc["extra_content"] = json!({ + "google": { + "thought_signature": sig + } + }); } - })), + tool_calls.push(tc); + } InputContentBlock::ToolResult { .. } => {} } } @@ -1468,10 +1547,22 @@ fn normalize_response( content.push(OutputContentBlock::Text { text }); } for tool_call in choice.message.tool_calls { + let thought_signature = tool_call.thought_signature.or_else(|| { + tool_call + .extra_content + .as_ref() + .and_then(|ec| ec.get("google")) + .and_then(|g| g.get("thought_signature")) + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + .map(String::from) + }); + content.push(OutputContentBlock::ToolUse { id: tool_call.id, name: tool_call.function.name, input: parse_tool_arguments(&tool_call.function.arguments), + thought_signature, }); } @@ -1866,6 +1957,7 @@ mod tests { id: "call_1".to_string(), name: "get_weather".to_string(), input: json!({"city": "Paris"}), + thought_signature: None, }], }], stream: false, @@ -1943,6 +2035,7 @@ mod tests { reasoning_content: Some("think".to_string()), thinking: None, tool_calls: Vec::new(), + extra_content: None, }, finish_reason: None, }], @@ -1960,6 +2053,7 @@ mod tests { reasoning_content: None, thinking: None, tool_calls: Vec::new(), + extra_content: None, }, finish_reason: Some("stop".to_string()), }], @@ -2504,6 +2598,7 @@ mod tests { id: "call_1".to_string(), name: "read_file".to_string(), input: serde_json::json!({"path": "/tmp/test"}), + thought_signature: None, }], }], stream: false, @@ -2722,6 +2817,7 @@ mod tests { id: "call_1".to_string(), name: "read_file".to_string(), input: serde_json::json!({"path": "/tmp/test"}), + thought_signature: None, }], }, InputMessage { diff --git a/rust/crates/api/src/types.rs b/rust/crates/api/src/types.rs index 3ec7f879..adc5f4dc 100644 --- a/rust/crates/api/src/types.rs +++ b/rust/crates/api/src/types.rs @@ -100,6 +100,8 @@ pub enum InputContentBlock { id: String, name: String, input: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + thought_signature: Option, }, ToolResult { tool_use_id: String, @@ -167,6 +169,8 @@ pub enum OutputContentBlock { id: String, name: String, input: Value, + #[serde(default, skip_serializing_if = "Option::is_none")] + thought_signature: Option, }, Thinking { #[serde(default)] diff --git a/rust/crates/claw-analog/src/lib.rs b/rust/crates/claw-analog/src/lib.rs index 33e4863f..5fbfe4ee 100644 --- a/rust/crates/claw-analog/src/lib.rs +++ b/rust/crates/claw-analog/src/lib.rs @@ -768,7 +768,7 @@ fn tool_calls_for_json(content: &[OutputContentBlock]) -> Vec { content .iter() .filter_map(|b| { - if let OutputContentBlock::ToolUse { id, name, input } = b { + if let OutputContentBlock::ToolUse { id, name, input, .. } = b { Some(json!({ "id": id, "name": name, @@ -1474,7 +1474,7 @@ async fn stream_to_message_response( block_kind.insert(index, BlockKind::Text); text_buf.insert(index, text); } - OutputContentBlock::ToolUse { id, name, input } => { + OutputContentBlock::ToolUse { id, name, input, .. } => { let json = if input.as_object().is_some_and(|m| m.is_empty()) { String::new() } else { @@ -1523,7 +1523,7 @@ async fn stream_to_message_response( Some(BlockKind::Tool { id, name, json }) => { let input = serde_json::from_str::(&json) .unwrap_or_else(|_| json!({ "raw": json })); - finished.insert(idx, OutputContentBlock::ToolUse { id, name, input }); + finished.insert(idx, OutputContentBlock::ToolUse { id, name, input, thought_signature: None }); } None => {} } @@ -1581,7 +1581,7 @@ fn collect_tool_uses(content: &[OutputContentBlock]) -> Vec> { content .iter() .filter_map(|b| { - if let OutputContentBlock::ToolUse { id, name, input } = b { + if let OutputContentBlock::ToolUse { id, name, input, .. } = b { Some(ToolUse { id: id.as_str(), name: name.as_str(), @@ -1601,10 +1601,11 @@ fn output_to_input_blocks(blocks: &[OutputContentBlock]) -> Vec { Some(InputContentBlock::Text { text: text.clone() }) } - OutputContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { + OutputContentBlock::ToolUse { id, name, input, .. } => Some(InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: input.clone(), + thought_signature: None, }), OutputContentBlock::Thinking { .. } | OutputContentBlock::RedactedThinking { .. } => { None diff --git a/rust/crates/mock-anthropic-service/src/lib.rs b/rust/crates/mock-anthropic-service/src/lib.rs index 68968eed..85f3e856 100644 --- a/rust/crates/mock-anthropic-service/src/lib.rs +++ b/rust/crates/mock-anthropic-service/src/lib.rs @@ -746,6 +746,7 @@ fn tool_message_response_many(id: &str, tool_uses: &[ToolUseMessage<'_>]) -> Mes id: tool_use.tool_id.to_string(), name: tool_use.tool_name.to_string(), input: tool_use.input.clone(), + thought_signature: None, }) .collect(), model: DEFAULT_MODEL.to_string(), diff --git a/rust/crates/runtime/src/compact.rs b/rust/crates/runtime/src/compact.rs index 797d3a6b..42f23015 100644 --- a/rust/crates/runtime/src/compact.rs +++ b/rust/crates/runtime/src/compact.rs @@ -780,6 +780,7 @@ mod tests { id: tool_id.to_string(), name: "search".to_string(), input: "{\"q\":\"*.rs\"}".to_string(), + thought_signature: None, }, ])) .unwrap(); diff --git a/rust/crates/runtime/src/conversation.rs b/rust/crates/runtime/src/conversation.rs index ea50e3bd..fe636f8f 100644 --- a/rust/crates/runtime/src/conversation.rs +++ b/rust/crates/runtime/src/conversation.rs @@ -37,6 +37,7 @@ pub enum AssistantEvent { id: String, name: String, input: String, + thought_signature: Option, }, Usage(TokenUsage), PromptCache(PromptCacheEvent), @@ -381,7 +382,7 @@ where .blocks .iter() .filter_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { id, name, input, .. } => { Some((id.clone(), name.clone(), input.clone())) } _ => None, @@ -741,9 +742,9 @@ fn build_assistant_message( }); } AssistantEvent::TextDelta(delta) => text.push_str(&delta), - AssistantEvent::ToolUse { id, name, input } => { + AssistantEvent::ToolUse { id, name, input, thought_signature } => { flush_text_block(&mut text, &mut blocks); - blocks.push(ContentBlock::ToolUse { id, name, input }); + blocks.push(ContentBlock::ToolUse { id, name, input, thought_signature }); } AssistantEvent::Usage(value) => usage = Some(value), AssistantEvent::PromptCache(event) => prompt_cache_events.push(event), @@ -880,6 +881,7 @@ mod tests { id: "tool-1".to_string(), name: "add".to_string(), input: "2,2".to_string(), + thought_signature: None, }, AssistantEvent::Usage(TokenUsage { input_tokens: 20, @@ -1046,6 +1048,7 @@ mod tests { id: "tool-1".to_string(), name: "blocked".to_string(), input: "secret".to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ]) @@ -1091,6 +1094,7 @@ mod tests { id: "tool-1".to_string(), name: "blocked".to_string(), input: r#"{"path":"secret.txt"}"#.to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ]) @@ -1153,6 +1157,7 @@ mod tests { id: "tool-1".to_string(), name: "blocked".to_string(), input: r#"{"path":"secret.txt"}"#.to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ]) @@ -1213,6 +1218,7 @@ mod tests { id: "tool-1".to_string(), name: "add".to_string(), input: r#"{"lhs":2,"rhs":2}"#.to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ]), @@ -1288,6 +1294,7 @@ mod tests { id: "tool-1".to_string(), name: "fail".to_string(), input: r#"{"path":"README.md"}"#.to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ]), @@ -1755,6 +1762,7 @@ mod tests { id: "tool-1".to_string(), name: "echo".to_string(), input: "payload".to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ]; @@ -1778,6 +1786,7 @@ mod tests { id: "tool-1".to_string(), name: "echo".to_string(), input: "payload".to_string(), + thought_signature: None, }, ] ); @@ -1811,6 +1820,7 @@ mod tests { id: "tool-1".to_string(), name: "echo".to_string(), input: "payload".to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ]) diff --git a/rust/crates/runtime/src/session.rs b/rust/crates/runtime/src/session.rs index 0cc32c03..620c748c 100644 --- a/rust/crates/runtime/src/session.rs +++ b/rust/crates/runtime/src/session.rs @@ -42,6 +42,7 @@ pub enum ContentBlock { id: String, name: String, input: String, + thought_signature: Option, }, ToolResult { tool_use_id: String, @@ -817,7 +818,7 @@ impl ContentBlock { ); } } - Self::ToolUse { id, name, input } => { + Self::ToolUse { id, name, input, thought_signature } => { object.insert( "type".to_string(), JsonValue::String("tool_use".to_string()), @@ -825,6 +826,12 @@ impl ContentBlock { object.insert("id".to_string(), JsonValue::String(id.clone())); object.insert("name".to_string(), JsonValue::String(name.clone())); object.insert("input".to_string(), JsonValue::String(input.clone())); + if let Some(sig) = thought_signature { + object.insert( + "thought_signature".to_string(), + JsonValue::String(sig.clone()), + ); + } } Self::ToolResult { tool_use_id, @@ -874,6 +881,7 @@ impl ContentBlock { id: required_string(object, "id")?, name: required_string(object, "name")?, input: required_string(object, "input")?, + thought_signature: object.get("thought_signature").and_then(JsonValue::as_str).map(String::from) }), "tool_result" => Ok(Self::ToolResult { tool_use_id: required_string(object, "tool_use_id")?, @@ -1069,7 +1077,7 @@ fn persisted_block_json(block: &ContentBlock) -> JsonValue { ); } } - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { id, name, input, thought_signature } => { object.insert( "type".to_string(), JsonValue::String("tool_use".to_string()), @@ -1083,6 +1091,12 @@ fn persisted_block_json(block: &ContentBlock) -> JsonValue { "input".to_string(), JsonValue::String(sanitize_jsonl_field(input)), ); + if let Some(sig) = thought_signature { + object.insert( + "thought_signature".to_string(), + JsonValue::String(sanitize_jsonl_field(sig)), + ); + } } ContentBlock::ToolResult { tool_use_id, @@ -1433,6 +1447,7 @@ mod tests { id: "tool-1".to_string(), name: "bash".to_string(), input: "echo hi".to_string(), + thought_signature: None, }, ], Some(TokenUsage { @@ -1596,6 +1611,7 @@ mod tests { id: "tool-1".to_string(), name: "bash".to_string(), input: format!("Authorization: Bearer {secret}"), + thought_signature: None, }, ])) .expect("tool use should append"); diff --git a/rust/crates/runtime/src/trident.rs b/rust/crates/runtime/src/trident.rs index 2346a4ea..e09ef09d 100644 --- a/rust/crates/runtime/src/trident.rs +++ b/rust/crates/runtime/src/trident.rs @@ -686,6 +686,7 @@ mod tests { id: "1".to_string(), name: "read_file".to_string(), input: r#"{"path":"src/main.rs"}"#.to_string(), + thought_signature: None, }]), ConversationMessage::tool_result( "1", @@ -697,6 +698,7 @@ mod tests { id: "2".to_string(), name: "edit_file".to_string(), input: r#"{"path":"src/main.rs","old":"old","new":"new"}"#.to_string(), + thought_signature: None, }]), ConversationMessage::tool_result( "2", @@ -718,6 +720,7 @@ mod tests { id: "1".to_string(), name: "read_file".to_string(), input: r#"{"path":"src/main.rs"}"#.to_string(), + thought_signature: None, }]), ConversationMessage::tool_result( "1", @@ -746,6 +749,7 @@ mod tests { id: "t".to_string(), name: "bash".to_string(), input: r#"{"command":"ls"}"#.to_string(), + thought_signature: None, }, ])); @@ -764,6 +768,7 @@ mod tests { id: format!("read_{i}"), name: "read_file".to_string(), input: format!(r#"{{"path":"src/{i}.rs"}}"#), + thought_signature: None, }, ])); messages.push(ConversationMessage::tool_result( @@ -789,6 +794,7 @@ mod tests { id: "1".to_string(), name: "read_file".to_string(), input: r#"{"path":"src/main.rs"}"#.to_string(), + thought_signature: None, }]), ConversationMessage::tool_result( "1", @@ -800,6 +806,7 @@ mod tests { id: "2".to_string(), name: "edit_file".to_string(), input: r#"{"path":"src/main.rs","old":"buggy","new":"fixed"}"#.to_string(), + thought_signature: None, }]), ConversationMessage::tool_result( "2", diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index a4f14a2e..ad8c8ed9 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -10414,7 +10414,7 @@ fn render_last_tool_debug_report(session: &Session) -> Result { + ContentBlock::ToolUse { id, name, input, .. } => { Some((id.clone(), name.clone(), input.clone())) } _ => None, @@ -10751,7 +10751,7 @@ fn render_export_text(session: &Session) -> String { match block { ContentBlock::Text { text } => lines.push(text.clone()), ContentBlock::Thinking { .. } => {} - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { id, name, input, .. } => { lines.push(format!("[tool_use id={id} name={name}] {input}")); } ContentBlock::ToolResult { @@ -10989,7 +10989,7 @@ fn render_session_markdown(session: &Session, session_id: &str, session_path: &P } } ContentBlock::Thinking { .. } => {} - ContentBlock::ToolUse { id, name, input } => { + ContentBlock::ToolUse { id, name, input, .. } => { lines.push(format!( "**Tool call** `{name}` _(id `{}`)_", short_tool_id(id) @@ -11872,7 +11872,7 @@ impl AnthropicRuntimeClient { let renderer = TerminalRenderer::new(); let mut markdown_stream = MarkdownStreamState::default(); let mut events = Vec::new(); - let mut pending_tool: Option<(String, String, String)> = None; + let mut pending_tool: Option<(String, String, String, Option)> = None; // 累积 reasoning_content 到 Thinking 块(修复 DeepSeek V4 reasoning_content 协议 bug) let mut pending_thinking: Option<(String, Option)> = None; let mut block_has_thinking_summary = false; @@ -11948,7 +11948,7 @@ impl AnthropicRuntimeClient { } } ContentBlockDelta::InputJsonDelta { partial_json } => { - if let Some((_, _, input)) = &mut pending_tool { + if let Some((_, _, input, _)) = &mut pending_tool { input.push_str(&partial_json); } } @@ -11983,7 +11983,7 @@ impl AnthropicRuntimeClient { signature, }); } - if let Some((id, name, input)) = pending_tool.take() { + if let Some((id, name, input, thought_signature)) = pending_tool.take() { if let Some(progress_reporter) = &self.progress_reporter { progress_reporter.mark_tool_phase(&name, &input); } @@ -11991,7 +11991,7 @@ impl AnthropicRuntimeClient { writeln!(out, "\n{}", format_tool_call_start(&name, &input)) .and_then(|()| out.flush()) .map_err(|error| RuntimeError::new(error.to_string()))?; - events.push(AssistantEvent::ToolUse { id, name, input }); + events.push(AssistantEvent::ToolUse { id, name, input, thought_signature }); } } ApiStreamEvent::MessageDelta(delta) => { @@ -12167,7 +12167,7 @@ fn collect_tool_uses(summary: &runtime::TurnSummary) -> Vec { .iter() .flat_map(|message| message.blocks.iter()) .filter_map(|block| match block { - ContentBlock::ToolUse { id, name, input } => Some(json!({ + ContentBlock::ToolUse { id, name, input, .. } => Some(json!({ "id": id, "name": name, "input": input, @@ -12868,7 +12868,7 @@ fn push_output_block( block: OutputContentBlock, out: &mut (impl Write + ?Sized), events: &mut Vec, - pending_tool: &mut Option<(String, String, String)>, + pending_tool: &mut Option<(String, String, String, Option)>, streaming_tool_input: bool, block_has_thinking_summary: &mut bool, ) -> Result<(), RuntimeError> { @@ -12882,7 +12882,7 @@ fn push_output_block( events.push(AssistantEvent::TextDelta(text)); } } - OutputContentBlock::ToolUse { id, name, input } => { + OutputContentBlock::ToolUse { id, name, input, thought_signature } => { // During streaming, the initial content_block_start has an empty input ({}). // The real input arrives via input_json_delta events. In // non-streaming responses, preserve a legitimate empty object. @@ -12894,7 +12894,7 @@ fn push_output_block( } else { input.to_string() }; - *pending_tool = Some((id, name, initial_input)); + *pending_tool = Some((id, name, initial_input, thought_signature)); } OutputContentBlock::Thinking { thinking, .. } => { render_thinking_block_summary(out, Some(thinking.chars().count()), false)?; @@ -12925,8 +12925,8 @@ fn response_to_events( false, &mut block_has_thinking_summary, )?; - if let Some((id, name, input)) = pending_tool.take() { - events.push(AssistantEvent::ToolUse { id, name, input }); + if let Some((id, name, input, thought_signature)) = pending_tool.take() { + events.push(AssistantEvent::ToolUse { id, name, input, thought_signature }); } } @@ -13130,11 +13130,12 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { signature: signature.clone(), }) } - ContentBlock::ToolUse { id, name, input } => Some(InputContentBlock::ToolUse { + ContentBlock::ToolUse { id, name, input, thought_signature, .. } => Some(InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: serde_json::from_str(input) .unwrap_or_else(|_| serde_json::json!({ "raw": input })), + thought_signature: thought_signature.clone(), }), ContentBlock::ToolResult { tool_use_id, @@ -15728,6 +15729,7 @@ mod tests { id: "toolu_abcdefghijklmnop".to_string(), name: "bash".to_string(), input: r#"{"command":"ls -la"}"#.to_string(), + thought_signature: None, }, ]), ConversationMessage { @@ -17669,6 +17671,7 @@ UU conflicted.rs", id: "tool-1".to_string(), name: "bash".to_string(), input: "{\"command\":\"pwd\"}".to_string(), + thought_signature: None, }]), ConversationMessage { role: MessageRole::Tool, @@ -18059,6 +18062,7 @@ UU conflicted.rs", id: "tool-1".to_string(), name: "read_file".to_string(), input: json!({}), + thought_signature: None, }, &mut out, &mut events, @@ -18071,7 +18075,7 @@ UU conflicted.rs", assert!(events.is_empty()); assert_eq!( pending_tool, - Some(("tool-1".to_string(), "read_file".to_string(), String::new(),)) + Some(("tool-1".to_string(), "read_file".to_string(), String::new(), None)) ); } @@ -18088,6 +18092,7 @@ UU conflicted.rs", id: "tool-1".to_string(), name: "read_file".to_string(), input: json!({}), + thought_signature: None, }], stop_reason: Some("tool_use".to_string()), stop_sequence: None, @@ -18123,6 +18128,7 @@ UU conflicted.rs", id: "tool-2".to_string(), name: "read_file".to_string(), input: json!({ "path": "rust/Cargo.toml" }), + thought_signature: None, }], stop_reason: Some("tool_use".to_string()), stop_sequence: None, diff --git a/rust/crates/tools/src/lib.rs b/rust/crates/tools/src/lib.rs index 15dff721..1089f819 100644 --- a/rust/crates/tools/src/lib.rs +++ b/rust/crates/tools/src/lib.rs @@ -5203,7 +5203,7 @@ async fn stream_with_provider( ) -> Result, ApiError> { let mut stream = client.stream_message(message_request).await?; let mut events = Vec::new(); - let mut pending_tools: BTreeMap = BTreeMap::new(); + let mut pending_tools: BTreeMap)> = BTreeMap::new(); let mut pending_thinking: BTreeMap)> = BTreeMap::new(); let mut saw_stop = false; @@ -5238,7 +5238,7 @@ async fn stream_with_provider( } } ContentBlockDelta::InputJsonDelta { partial_json } => { - if let Some((_, _, input)) = pending_tools.get_mut(&delta.index) { + if let Some((_, _, input, _)) = pending_tools.get_mut(&delta.index) { input.push_str(&partial_json); } } @@ -5262,8 +5262,8 @@ async fn stream_with_provider( signature, }); } - if let Some((id, name, input)) = pending_tools.remove(&stop.index) { - events.push(AssistantEvent::ToolUse { id, name, input }); + if let Some((id, name, input, thought_signature)) = pending_tools.remove(&stop.index) { + events.push(AssistantEvent::ToolUse { id, name, input, thought_signature }); } } ApiStreamEvent::MessageDelta(delta) => { @@ -5371,11 +5371,12 @@ fn convert_messages(messages: &[ConversationMessage]) -> Vec { thinking: thinking.clone(), signature: signature.clone(), }, - ContentBlock::ToolUse { id, name, input } => InputContentBlock::ToolUse { + ContentBlock::ToolUse { id, name, input, thought_signature } => InputContentBlock::ToolUse { id: id.clone(), name: name.clone(), input: serde_json::from_str(input) .unwrap_or_else(|_| serde_json::json!({ "raw": input })), + thought_signature: thought_signature.clone(), }, ContentBlock::ToolResult { tool_use_id, @@ -5406,7 +5407,7 @@ fn push_output_block( block: OutputContentBlock, block_index: u32, events: &mut Vec, - pending_tools: &mut BTreeMap, + pending_tools: &mut BTreeMap)>, pending_thinking: &mut BTreeMap)>, streaming_tool_input: bool, ) { @@ -5416,7 +5417,7 @@ fn push_output_block( events.push(AssistantEvent::TextDelta(text)); } } - OutputContentBlock::ToolUse { id, name, input } => { + OutputContentBlock::ToolUse { id, name, input, thought_signature } => { let initial_input = if streaming_tool_input && input.is_object() && input.as_object().is_some_and(serde_json::Map::is_empty) @@ -5425,7 +5426,7 @@ fn push_output_block( } else { input.to_string() }; - pending_tools.insert(block_index, (id, name, initial_input)); + pending_tools.insert(block_index, (id, name, initial_input, thought_signature)); } OutputContentBlock::Thinking { thinking, @@ -5459,8 +5460,8 @@ fn response_to_events(response: MessageResponse) -> Vec { &mut pending_thinking, false, ); - if let Some((id, name, input)) = pending_tools.remove(&index) { - events.push(AssistantEvent::ToolUse { id, name, input }); + if let Some((id, name, input, thought_signature)) = pending_tools.remove(&index) { + events.push(AssistantEvent::ToolUse { id, name, input, thought_signature }); } } @@ -8066,6 +8067,7 @@ mod tests { id: "tool-1".to_string(), name: "read_file".to_string(), input: json!({}), + thought_signature: None, }, 1, &mut events, @@ -8078,6 +8080,7 @@ mod tests { id: "tool-2".to_string(), name: "grep_search".to_string(), input: json!({}), + thought_signature: None, }, 2, &mut events, @@ -9358,6 +9361,7 @@ mod tests { id: "tool-1".to_string(), name: "read_file".to_string(), input: json!({ "path": self.input_path }).to_string(), + thought_signature: None, }, AssistantEvent::MessageStop, ])