diff --git a/crates/goose-providers/src/retry.rs b/crates/goose-providers/src/retry.rs index 8660a9f1cf..49afd9bfe8 100644 --- a/crates/goose-providers/src/retry.rs +++ b/crates/goose-providers/src/retry.rs @@ -81,11 +81,27 @@ impl RetryConfig { } } +/// Substrings marking a `RequestFailed` (4xx) as deterministically permanent: +/// Anthropic rejects signed `thinking`/`redacted_thinking` blocks as immutable +/// once a thinking model's config changes mid-conversation, and the identical +/// payload is rebuilt on every retry — so retrying can never succeed. +const PERMANENT_REQUEST_FAILURE_MARKERS: &[&str] = &[ + "blocks in the latest assistant message cannot be modified", + "must remain as they were in the original response", +]; + +fn is_permanent_request_failure(message: &str) -> bool { + PERMANENT_REQUEST_FAILURE_MARKERS + .iter() + .any(|marker| message.contains(marker)) +} + pub fn should_retry(error: &ProviderError, config: &RetryConfig) -> bool { match error { ProviderError::RateLimitExceeded { .. } | ProviderError::ServerError(_) | ProviderError::NetworkError(_) => true, + ProviderError::RequestFailed(message) if is_permanent_request_failure(message) => false, ProviderError::RequestFailed(_) => !config.transient_only, _ => false, } @@ -254,6 +270,33 @@ mod tests { assert!(should_retry(&error, &config)); } + #[test] + fn never_retries_permanent_thinking_block_400() { + let config = RetryConfig::default(); + let error = ProviderError::RequestFailed( + "Bad request (400): {\"message\":\"messages.3.content.1: `thinking` or \ + `redacted_thinking` blocks in the latest assistant message cannot be \ + modified. These blocks must remain as they were in the original \ + response.\"}" + .into(), + ); + assert!(!should_retry(&error, &config)); + } + + #[test] + fn permanent_request_failure_marker_detection() { + assert!(is_permanent_request_failure( + "messages.3.content.1: `thinking` blocks in the latest assistant message \ + cannot be modified" + )); + assert!(is_permanent_request_failure( + "These blocks must remain as they were in the original response." + )); + assert!(!is_permanent_request_failure( + "Bad request (400): model not found" + )); + } + #[test] fn transient_only_skips_request_failed() { let config = RetryConfig::default().transient_only();