diff --git a/crates/forge_app/src/orch.rs b/crates/forge_app/src/orch.rs index a81145ac80..e63ce75f1e 100644 --- a/crates/forge_app/src/orch.rs +++ b/crates/forge_app/src/orch.rs @@ -11,7 +11,7 @@ use tokio::sync::Notify; use tracing::warn; use crate::agent::AgentService; -use crate::transformers::ModelSpecificReasoning; +use crate::transformers::{DropReasoningOnlyMessages, ModelSpecificReasoning}; use crate::{EnvironmentInfra, TemplateEngine}; #[derive(Clone, Setters)] @@ -214,6 +214,12 @@ impl> Orc .pipe( ModelSpecificReasoning::new(model_id.as_str()) .when(|_| model_id.as_str().to_lowercase().contains("claude")), + ) + // Drop reasoning-only assistant turns; Anthropic and Bedrock both reject + // messages whose final content block is `thinking`. + .pipe( + DropReasoningOnlyMessages + .when(|_| model_id.as_str().to_lowercase().contains("claude")), ); let response = self .services diff --git a/crates/forge_app/src/transformers/drop_reasoning_only_messages.rs b/crates/forge_app/src/transformers/drop_reasoning_only_messages.rs new file mode 100644 index 0000000000..b55f1d425d --- /dev/null +++ b/crates/forge_app/src/transformers/drop_reasoning_only_messages.rs @@ -0,0 +1,129 @@ +use forge_domain::{Context, ContextMessage, Role, Transformer}; + +/// Drops assistant messages whose only content is reasoning. +/// +/// Anthropic rejects an assistant message whose final content block is +/// `thinking`, and Bedrock applies the same constraint. A message with +/// `reasoning_details` but no text or tool calls serializes to that invalid +/// shape. It typically comes from a turn that was aborted mid-tool-call, +/// compacted away, or cut short by a stream disconnect; the stranded +/// reasoning has nothing to anchor, so dropping the whole message is the +/// safe replay shape. +pub(crate) struct DropReasoningOnlyMessages; + +impl Transformer for DropReasoningOnlyMessages { + type Value = Context; + + fn transform(&mut self, mut context: Self::Value) -> Self::Value { + context + .messages + .retain(|entry| !is_reasoning_only(&entry.message)); + context + } +} + +fn is_reasoning_only(message: &ContextMessage) -> bool { + let ContextMessage::Text(msg) = message else { + return false; + }; + if msg.role != Role::Assistant { + return false; + } + let has_text = !msg.content.is_empty(); + let has_tool_calls = msg.tool_calls.as_ref().is_some_and(|tc| !tc.is_empty()); + let has_reasoning = msg.reasoning_details.is_some(); + has_reasoning && !has_text && !has_tool_calls +} + +#[cfg(test)] +mod tests { + use forge_domain::{ + Context, ContextMessage, ReasoningFull, Role, TextMessage, ToolCallArguments, ToolCallFull, + ToolCallId, Transformer, + }; + use pretty_assertions::assert_eq; + + use super::*; + + fn signed_reasoning() -> Vec { + vec![ReasoningFull { + text: Some("let me think".to_string()), + signature: Some("sig_abc".to_string()), + ..Default::default() + }] + } + + #[test] + fn test_drops_reasoning_only_assistant_message() { + let fixture = Context::default().add_message(ContextMessage::Text( + TextMessage::new(Role::Assistant, "").reasoning_details(signed_reasoning()), + )); + + let actual = DropReasoningOnlyMessages.transform(fixture); + + assert!(actual.messages.is_empty()); + } + + #[test] + fn test_keeps_assistant_message_with_text() { + let fixture = Context::default().add_message(ContextMessage::Text( + TextMessage::new(Role::Assistant, "hello").reasoning_details(signed_reasoning()), + )); + + let actual = DropReasoningOnlyMessages.transform(fixture); + + assert_eq!(actual.messages.len(), 1); + } + + #[test] + fn test_keeps_assistant_message_with_tool_call() { + let tool_call = ToolCallFull::new("demo") + .call_id(ToolCallId::new("call_1")) + .arguments(ToolCallArguments::from_json("{}")); + let fixture = Context::default().add_message(ContextMessage::Text( + TextMessage::new(Role::Assistant, "") + .tool_calls(vec![tool_call]) + .reasoning_details(signed_reasoning()), + )); + + let actual = DropReasoningOnlyMessages.transform(fixture); + + assert_eq!(actual.messages.len(), 1); + } + + #[test] + fn test_drops_when_tool_calls_is_empty_vec() { + // `Some(vec![])` is semantically "no tool calls"; treat like `None`. + let fixture = Context::default().add_message(ContextMessage::Text( + TextMessage::new(Role::Assistant, "") + .tool_calls(Vec::::new()) + .reasoning_details(signed_reasoning()), + )); + + let actual = DropReasoningOnlyMessages.transform(fixture); + + assert!(actual.messages.is_empty()); + } + + #[test] + fn test_leaves_user_messages_untouched() { + let fixture = Context::default() + .add_message(ContextMessage::Text(TextMessage::new(Role::User, "hi"))); + + let actual = DropReasoningOnlyMessages.transform(fixture); + + assert_eq!(actual.messages.len(), 1); + } + + #[test] + fn test_leaves_assistant_without_reasoning_untouched() { + // Empty assistant messages without reasoning are out of scope for this + // transform; preserving them is the caller's decision. + let fixture = Context::default() + .add_message(ContextMessage::Text(TextMessage::new(Role::Assistant, ""))); + + let actual = DropReasoningOnlyMessages.transform(fixture); + + assert_eq!(actual.messages.len(), 1); + } +} diff --git a/crates/forge_app/src/transformers/mod.rs b/crates/forge_app/src/transformers/mod.rs index a8b84543ea..56a5ef1d33 100644 --- a/crates/forge_app/src/transformers/mod.rs +++ b/crates/forge_app/src/transformers/mod.rs @@ -1,9 +1,11 @@ mod compaction; mod dedupe_role; +mod drop_reasoning_only_messages; mod drop_role; mod model_specific_reasoning; mod strip_working_dir; mod trim_context_summary; pub use compaction::SummaryTransformer; +pub(crate) use drop_reasoning_only_messages::DropReasoningOnlyMessages; pub(crate) use model_specific_reasoning::ModelSpecificReasoning;