diff --git a/crates/forge_app/src/dto/anthropic/request.rs b/crates/forge_app/src/dto/anthropic/request.rs index c9d7c2ade7..1ff2f5d97f 100644 --- a/crates/forge_app/src/dto/anthropic/request.rs +++ b/crates/forge_app/src/dto/anthropic/request.rs @@ -158,7 +158,7 @@ impl TryFrom for Message { } if !chat_message.content.is_empty() { - // note: Anthropic does not allow empty text content. + // NOTE: Anthropic does not allow empty text content. content.push(Content::Text { text: chat_message.content, cache_control: None }); } if let Some(tool_calls) = chat_message.tool_calls { diff --git a/crates/forge_app/src/dto/anthropic/transforms/drop_invalid_toolcalls.rs b/crates/forge_app/src/dto/anthropic/transforms/drop_invalid_toolcalls.rs new file mode 100644 index 0000000000..ad83d7ae3a --- /dev/null +++ b/crates/forge_app/src/dto/anthropic/transforms/drop_invalid_toolcalls.rs @@ -0,0 +1,139 @@ +use forge_domain::Transformer; +use serde_json::json; + +use crate::dto::anthropic::{Content, Request}; + +/// Transformer that normalizes ToolUse content to ensure inputs are always +/// objects. +/// - Preserves `Content::Text` and other content types as-is +/// - For `Content::ToolUse`: +/// - If input is already an object, keeps it unchanged +/// - If input is None, keeps it as None +/// - If input is non-object (string, array, number, etc.), wraps it in +/// `{"json": value}` +pub struct DropInvalidToolUse; + +impl Transformer for DropInvalidToolUse { + type Value = Request; + + fn transform(&mut self, mut request: Self::Value) -> Self::Value { + for message in request.get_messages_mut() { + for content in &mut message.content { + if let Content::ToolUse { input, .. } = content { + *input = match input.take() { + Some(value) if value.is_object() => Some(value), + Some(value) => Some(json!({ "json": value })), + None => None, + }; + } + } + } + + request + } +} + +#[cfg(test)] +mod tests { + use forge_domain::{ + Context, ContextMessage, ModelId, Role, TextMessage, ToolCallArguments, ToolCallFull, + Transformer, + }; + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + use crate::dto::anthropic::Request; + + fn transform_tool_call(json_args: &str) -> Request { + let fixture = Context::default().messages(vec![ContextMessage::Text(TextMessage { + role: Role::User, + content: "Hello".to_string(), + tool_calls: Some(vec![ + ToolCallFull::new("test_tool") + .call_id("call_123") + .arguments(ToolCallArguments::from_json(json_args)), + ]), + model: Some(ModelId::new("claude-3-5-sonnet-20241022")), + reasoning_details: None, + })]); + DropInvalidToolUse.transform(Request::try_from(fixture).unwrap()) + } + + fn get_tool_input(request: &Request) -> &Option { + if let Content::ToolUse { input, .. } = &request.messages[0].content[1] { + input + } else { + panic!("Expected ToolUse content") + } + } + + #[test] + fn test_preserves_tool_use_with_object_input() { + let actual = transform_tool_call(r#"{"key": "value"}"#); + assert_eq!(get_tool_input(&actual), &Some(json!({"key": "value"}))); + } + + #[test] + fn test_wraps_tool_use_with_string_input() { + let actual = transform_tool_call(r#""string_value""#); + assert_eq!( + get_tool_input(&actual), + &Some(json!({"json": "string_value"})) + ); + } + + #[test] + fn test_wraps_tool_use_with_array_input() { + let actual = transform_tool_call(r#"[1, 2, 3]"#); + assert_eq!(get_tool_input(&actual), &Some(json!({"json": [1, 2, 3]}))); + } + + #[test] + fn test_wraps_tool_use_with_number_input() { + let actual = transform_tool_call(r#"42"#); + assert_eq!(get_tool_input(&actual), &Some(json!({"json": 42}))); + } + + #[test] + fn test_wraps_tool_use_with_none_input() { + let request = Request::default().messages(vec![crate::dto::anthropic::Message { + role: crate::dto::anthropic::Role::User, + content: vec![Content::ToolUse { + id: "call_123".to_string(), + name: "test_tool".to_string(), + input: None, + cache_control: None, + }], + }]); + let actual = DropInvalidToolUse.transform(request); + + if let Content::ToolUse { input, .. } = &actual.messages[0].content[0] { + assert_eq!(input, &None); + } + } + + #[test] + fn test_empty_messages_remain_empty() { + let actual = DropInvalidToolUse.transform(Request::try_from(Context::default()).unwrap()); + assert_eq!(actual.messages.len(), 0); + } + + #[test] + fn test_preserves_text_content() { + let fixture = Context::default().messages(vec![ContextMessage::Text(TextMessage { + role: Role::User, + content: "Hello".to_string(), + tool_calls: None, + model: None, + reasoning_details: None, + })]); + let actual = DropInvalidToolUse.transform(Request::try_from(fixture).unwrap()); + + assert_eq!(actual.messages.len(), 1); + assert!(matches!( + actual.messages[0].content[0], + Content::Text { .. } + )); + } +} diff --git a/crates/forge_app/src/dto/anthropic/transforms/mod.rs b/crates/forge_app/src/dto/anthropic/transforms/mod.rs index 33361c1ec4..3c88d555f9 100644 --- a/crates/forge_app/src/dto/anthropic/transforms/mod.rs +++ b/crates/forge_app/src/dto/anthropic/transforms/mod.rs @@ -1,5 +1,7 @@ +mod drop_invalid_toolcalls; mod reasoning_transform; mod set_cache; +pub use drop_invalid_toolcalls::DropInvalidToolUse; pub use reasoning_transform::ReasoningTransform; pub use set_cache::SetCache; diff --git a/crates/forge_services/src/provider/anthropic.rs b/crates/forge_services/src/provider/anthropic.rs index e6328af7b6..303a961901 100644 --- a/crates/forge_services/src/provider/anthropic.rs +++ b/crates/forge_services/src/provider/anthropic.rs @@ -6,7 +6,7 @@ use forge_app::domain::{ ChatCompletionMessage, Context, Model, ModelId, ResultStream, Transformer, }; use forge_app::dto::anthropic::{ - EventData, ListModelResponse, ReasoningTransform, Request, SetCache, + DropInvalidToolUse, EventData, ListModelResponse, ReasoningTransform, Request, SetCache, }; use reqwest::Url; use tracing::debug; @@ -67,7 +67,7 @@ impl Anthropic { .stream(true) .max_tokens(max_tokens as u64); - let request = SetCache.transform(request); + let request = DropInvalidToolUse.pipe(SetCache).transform(request); let url = &self.chat_url; debug!(url = %url, model = %model, "Connecting Upstream");