diff --git a/crates/goose-cli/src/session/output.rs b/crates/goose-cli/src/session/output.rs index e190d30cc..a87a86804 100644 --- a/crates/goose-cli/src/session/output.rs +++ b/crates/goose-cli/src/session/output.rs @@ -100,6 +100,17 @@ pub fn render_message(message: &Message) { MessageContent::Image(image) => { println!("Image: [data: {}, type: {}]", image.data, image.mime_type); } + MessageContent::Thinking(thinking) => { + if std::env::var("GOOSE_CLI_SHOW_THINKING").is_ok() { + println!("\n{}", style("Thinking:").dim().italic()); + print_markdown(&thinking.thinking, theme); + } + } + MessageContent::RedactedThinking(_) => { + // For redacted thinking, print thinking was redacted + println!("\n{}", style("Thinking:").dim().italic()); + print_markdown("Thinking was redacted", theme); + } } } println!(); diff --git a/crates/goose-server/src/routes/reply.rs b/crates/goose-server/src/routes/reply.rs index 03166c173..b398c4eab 100644 --- a/crates/goose-server/src/routes/reply.rs +++ b/crates/goose-server/src/routes/reply.rs @@ -133,6 +133,7 @@ fn convert_messages(incoming: Vec) -> Vec { } // Protocol-specific message formatting +// Based on https://sdk.vercel.ai/docs/ai-sdk-ui/stream-protocol#data-stream-protocol struct ProtocolFormatter; impl ProtocolFormatter { @@ -166,6 +167,25 @@ impl ProtocolFormatter { format!("3:{}\n", encoded_error) } + fn format_reasoning(reasoning_text: &str) -> String { + let encoded_text = serde_json::to_string(reasoning_text).unwrap_or_else(|_| String::new()); + format!("g:{}\n", encoded_text) + } + + fn format_reasoning_signature(signature: &str) -> String { + let response = json!({ + "signature": signature + }); + format!("j:{}\n", response) + } + + fn format_redacted_reasoning(data: &str) -> String { + let response = json!({ + "data": data + }); + format!("i:{}\n", response) + } + fn format_finish(reason: &str) -> String { // Finish messages start with "d:" let finish = json!({ @@ -247,6 +267,18 @@ async fn stream_message( .await?; } } + MessageContent::Thinking(content) => { + tx.send(ProtocolFormatter::format_reasoning(&content.thinking)) + .await?; + tx.send(ProtocolFormatter::format_reasoning_signature( + &content.signature, + )) + .await?; + } + MessageContent::RedactedThinking(content) => { + tx.send(ProtocolFormatter::format_redacted_reasoning(&content.data)) + .await?; + } MessageContent::ToolConfirmationRequest(_) => { // skip tool confirmation requests } diff --git a/crates/goose/src/message.rs b/crates/goose/src/message.rs index 3e10e9d69..55389a1aa 100644 --- a/crates/goose/src/message.rs +++ b/crates/goose/src/message.rs @@ -34,6 +34,17 @@ pub struct ToolConfirmationRequest { pub prompt: Option, } +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct ThinkingContent { + pub thinking: String, + pub signature: String, +} + +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] +pub struct RedactedThinkingContent { + pub data: String, +} + #[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] /// Content passed inside a message, which can be both simple content and tool content pub enum MessageContent { @@ -42,6 +53,8 @@ pub enum MessageContent { ToolRequest(ToolRequest), ToolResponse(ToolResponse), ToolConfirmationRequest(ToolConfirmationRequest), + Thinking(ThinkingContent), + RedactedThinking(RedactedThinkingContent), } impl MessageContent { @@ -87,6 +100,17 @@ impl MessageContent { prompt, }) } + + pub fn thinking, S2: Into>(thinking: S1, signature: S2) -> Self { + MessageContent::Thinking(ThinkingContent { + thinking: thinking.into(), + signature: signature.into(), + }) + } + + pub fn redacted_thinking>(data: S) -> Self { + MessageContent::RedactedThinking(RedactedThinkingContent { data: data.into() }) + } pub fn as_tool_request(&self) -> Option<&ToolRequest> { if let MessageContent::ToolRequest(ref tool_request) = self { Some(tool_request) @@ -133,6 +157,22 @@ impl MessageContent { _ => None, } } + + /// Get the thinking content if this is a ThinkingContent variant + pub fn as_thinking(&self) -> Option<&ThinkingContent> { + match self { + MessageContent::Thinking(thinking) => Some(thinking), + _ => None, + } + } + + /// Get the redacted thinking content if this is a RedactedThinkingContent variant + pub fn as_redacted_thinking(&self) -> Option<&RedactedThinkingContent> { + match self { + MessageContent::RedactedThinking(redacted) => Some(redacted), + _ => None, + } + } } impl From for MessageContent { @@ -222,6 +262,20 @@ impl Message { )) } + /// Add thinking content to the message + pub fn with_thinking, S2: Into>( + self, + thinking: S1, + signature: S2, + ) -> Self { + self.with_content(MessageContent::thinking(thinking, signature)) + } + + /// Add redacted thinking content to the message + pub fn with_redacted_thinking>(self, data: S) -> Self { + self.with_content(MessageContent::redacted_thinking(data)) + } + /// Get the concatenated text content of the message, separated by newlines pub fn as_concat_text(&self) -> String { self.content diff --git a/crates/goose/src/providers/anthropic.rs b/crates/goose/src/providers/anthropic.rs index 3cfd51a26..c69497e0b 100644 --- a/crates/goose/src/providers/anthropic.rs +++ b/crates/goose/src/providers/anthropic.rs @@ -17,6 +17,8 @@ pub const ANTHROPIC_KNOWN_MODELS: &[&str] = &[ "claude-3-5-sonnet-latest", "claude-3-5-haiku-latest", "claude-3-opus-latest", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-latest", ]; pub const ANTHROPIC_DOC_URL: &str = "https://docs.anthropic.com/en/docs/about-claude/models"; @@ -64,6 +66,13 @@ impl AnthropicProvider { ProviderError::RequestFailed(format!("Failed to construct endpoint URL: {e}")) })?; + if std::env::var("GOOSE_DEBUG").is_ok() { + println!( + "\nRequest:\n{}\n", + serde_json::to_string_pretty(&payload).unwrap() + ); + } + let response = self .client .post(url) @@ -76,6 +85,13 @@ impl AnthropicProvider { let status = response.status(); let payload: Option = response.json().await.ok(); + if std::env::var("GOOSE_DEBUG").is_ok() { + println!( + "\nResponse:\n{}\n", + serde_json::to_string_pretty(&payload).unwrap() + ); + } + // https://docs.anthropic.com/en/api/errors match status { StatusCode::OK => payload.ok_or_else( || ProviderError::RequestFailed("Response body is not valid JSON".to_string()) ), diff --git a/crates/goose/src/providers/formats/anthropic.rs b/crates/goose/src/providers/formats/anthropic.rs index 4eadf4bcd..2b1aec650 100644 --- a/crates/goose/src/providers/formats/anthropic.rs +++ b/crates/goose/src/providers/formats/anthropic.rs @@ -60,6 +60,19 @@ pub fn format_messages(messages: &[Message]) -> Vec { MessageContent::ToolConfirmationRequest(_tool_confirmation_request) => { // Skip tool confirmation requests } + MessageContent::Thinking(thinking) => { + content.push(json!({ + "type": "thinking", + "thinking": thinking.thinking, + "signature": thinking.signature + })); + } + MessageContent::RedactedThinking(redacted) => { + content.push(json!({ + "type": "redacted_thinking", + "data": redacted.data + })); + } MessageContent::Image(_) => continue, // Anthropic doesn't support image content yet } } @@ -179,6 +192,24 @@ pub fn response_to_message(response: Value) -> Result { let tool_call = ToolCall::new(name, input.clone()); message = message.with_tool_request(id, Ok(tool_call)); } + Some("thinking") => { + let thinking = block + .get("thinking") + .and_then(|t| t.as_str()) + .ok_or_else(|| anyhow!("Missing thinking content"))?; + let signature = block + .get("signature") + .and_then(|s| s.as_str()) + .ok_or_else(|| anyhow!("Missing thinking signature"))?; + message = message.with_thinking(thinking, signature); + } + Some("redacted_thinking") => { + let data = block + .get("data") + .and_then(|d| d.as_str()) + .ok_or_else(|| anyhow!("Missing redacted_thinking data"))?; + message = message.with_redacted_thinking(data); + } _ => continue, } } @@ -243,10 +274,13 @@ pub fn create_request( return Err(anyhow!("No valid messages to send to Anthropic API")); } + // https://docs.anthropic.com/en/docs/about-claude/models/all-models#model-comparison-table + // Claude 3.7 supports max output tokens up to 8192 + let max_tokens = model_config.max_tokens.unwrap_or(8192); let mut payload = json!({ "model": model_config.model_name, "messages": anthropic_messages, - "max_tokens": model_config.max_tokens.unwrap_or(4096) + "max_tokens": max_tokens, }); // Add system message if present @@ -265,12 +299,38 @@ pub fn create_request( .insert("tools".to_string(), json!(tool_specs)); } - // Add temperature if specified + // Add temperature if specified and not using extended thinking model if let Some(temp) = model_config.temperature { + // Claude 3.7 models with thinking enabled don't support temperature + if !model_config.model_name.starts_with("claude-3-7-sonnet-") { + payload + .as_object_mut() + .unwrap() + .insert("temperature".to_string(), json!(temp)); + } + } + + // Add thinking parameters for claude-3-7-sonnet model + let is_thinking_enabled = std::env::var("ANTHROPIC_THINKING_ENABLED").is_ok(); + if model_config.model_name.starts_with("claude-3-7-sonnet-") && is_thinking_enabled { + // Minimum budget_tokens is 1024 + let budget_tokens = std::env::var("ANTHROPIC_THINKING_BUDGET") + .unwrap_or_else(|_| "16000".to_string()) + .parse() + .unwrap_or(16000); + payload .as_object_mut() .unwrap() - .insert("temperature".to_string(), json!(temp)); + .insert("max_tokens".to_string(), json!(max_tokens + budget_tokens)); + + payload.as_object_mut().unwrap().insert( + "thinking".to_string(), + json!({ + "type": "enabled", + "budget_tokens": budget_tokens + }), + ); } Ok(payload) @@ -361,6 +421,80 @@ mod tests { Ok(()) } + #[test] + fn test_parse_thinking_response() -> Result<()> { + let response = json!({ + "id": "msg_456", + "type": "message", + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "This is a step-by-step thought process...", + "signature": "EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8gZzu16i7Se4EiEbmlKqNH1GtwcX1BMK6iLu8bxWn5wPVIFBIMnptdlVal7ZX5iNPFGgwWjX+BntcEOHky4HciMFVef7FpQeqnuiL1Xt7J4OLHZSyu4tcr809AxAbclcJ5dm1xE5gZrUO+/v60cnJM2ipQp4B8/3eHI03KSV6bZR/vMrBSYCV+aa/f5KHX2cRtLGp/Ba+3Tk/efbsg01WSduwAIbR4coVrZLnGJXNyVTFW/Be2kLy/ECZnx8cqvU3oQOg=" + }, + { + "type": "redacted_thinking", + "data": "EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP" + }, + { + "type": "text", + "text": "I've analyzed the problem and here's the solution." + } + ], + "model": "claude-3-7-sonnet-20250219", + "stop_reason": "end_turn", + "stop_sequence": null, + "usage": { + "input_tokens": 10, + "output_tokens": 45, + "cache_creation_input_tokens": 0, + "cache_read_input_tokens": 0, + } + }); + + let message = response_to_message(response.clone())?; + let usage = get_usage(&response)?; + + assert_eq!(message.content.len(), 3); + + if let MessageContent::Thinking(thinking) = &message.content[0] { + assert_eq!( + thinking.thinking, + "This is a step-by-step thought process..." + ); + assert!(thinking + .signature + .starts_with("EuYBCkQYAiJAVbJNBoH7HQiDcMwwAMhWqNyoe4G2xHRprK8ICM8g")); + } else { + panic!("Expected Thinking content at index 0"); + } + + if let MessageContent::RedactedThinking(redacted) = &message.content[1] { + assert_eq!( + redacted.data, + "EmwKAhgBEgy3va3pzix/LafPsn4aDFIT2Xlxh0L5L8rLVyIwxtE3rAFBa8cr3qpP" + ); + } else { + panic!("Expected RedactedThinking content at index 1"); + } + + if let MessageContent::Text(text) = &message.content[2] { + assert_eq!( + text.text, + "I've analyzed the problem and here's the solution." + ); + } else { + panic!("Expected Text content at index 2"); + } + + assert_eq!(usage.input_tokens, Some(10)); + assert_eq!(usage.output_tokens, Some(45)); + assert_eq!(usage.total_tokens, Some(55)); + + Ok(()) + } + #[test] fn test_message_to_anthropic_spec() { let messages = vec![ @@ -436,4 +570,47 @@ mod tests { assert_eq!(spec_array[0]["text"], system); assert!(spec_array[0].get("cache_control").is_some()); } + + #[test] + fn test_create_request_with_thinking() -> Result<()> { + // Save the original env var value if it exists + let original_value = std::env::var("ANTHROPIC_THINKING_ENABLED").ok(); + + // Set the env var for this test + std::env::set_var("ANTHROPIC_THINKING_ENABLED", "true"); + + // Execute the test + let result = (|| { + let model_config = ModelConfig::new("claude-3-7-sonnet-20250219".to_string()); + let system = "You are a helpful assistant."; + let messages = vec![Message::user().with_text("Hello")]; + let tools = vec![]; + + let payload = create_request(&model_config, system, &messages, &tools)?; + + // Verify basic structure + assert_eq!(payload["model"], "claude-3-7-sonnet-20250219"); + assert_eq!(payload["messages"][0]["role"], "user"); + assert_eq!(payload["messages"][0]["content"][0]["text"], "Hello"); + + // Verify thinking parameters + assert!(payload.get("thinking").is_some()); + assert_eq!(payload["thinking"]["type"], "enabled"); + assert!(payload["thinking"]["budget_tokens"].as_i64().unwrap() >= 1024); + + // Temperature should not be present for 3.7 models with thinking + assert!(payload.get("temperature").is_none()); + + Ok(()) + })(); + + // Restore the original env var state + match original_value { + Some(val) => std::env::set_var("ANTHROPIC_THINKING_ENABLED", val), + None => std::env::remove_var("ANTHROPIC_THINKING_ENABLED"), + } + + // Return the test result + result + } } diff --git a/crates/goose/src/providers/formats/bedrock.rs b/crates/goose/src/providers/formats/bedrock.rs index dcedf31ba..dd32b0a41 100644 --- a/crates/goose/src/providers/formats/bedrock.rs +++ b/crates/goose/src/providers/formats/bedrock.rs @@ -34,6 +34,14 @@ pub fn to_bedrock_message_content(content: &MessageContent) -> Result { bail!("Image content is not supported by Bedrock provider yet") } + MessageContent::Thinking(_) => { + // Thinking blocks are not supported in Bedrock - skip + bedrock::ContentBlock::Text("".to_string()) + } + MessageContent::RedactedThinking(_) => { + // Redacted thinking blocks are not supported in Bedrock - skip + bedrock::ContentBlock::Text("".to_string()) + } MessageContent::ToolRequest(tool_req) => { let tool_use_id = tool_req.id.to_string(); let tool_use = if let Ok(call) = tool_req.tool_call.as_ref() { diff --git a/crates/goose/src/providers/formats/openai.rs b/crates/goose/src/providers/formats/openai.rs index f78304163..7273d6fea 100644 --- a/crates/goose/src/providers/formats/openai.rs +++ b/crates/goose/src/providers/formats/openai.rs @@ -44,6 +44,14 @@ pub fn format_messages(messages: &[Message], image_format: &ImageFormat) -> Vec< } } } + MessageContent::Thinking(_) => { + // Thinking blocks are not directly used in OpenAI format + continue; + } + MessageContent::RedactedThinking(_) => { + // Redacted thinking blocks are not directly used in OpenAI format + continue; + } MessageContent::ToolRequest(request) => match &request.tool_call { Ok(tool_call) => { let sanitized_name = sanitize_function_name(&tool_call.name);