From your code sample you have probably just introduced a cross site scripting attack into your application. If I were to get your code to process something like <script src="evil.example.com"></script>
your code would decode it to valid HTML and not re-encode the <
and >
back to entities. (The angle brackets in the code are not ASCII angle brackets.)
If you are truncating a string that contains any HTML tags or entities you will probably break something if you use a simple solution. You might be better off building a solution based on an HTML parsing module. If you are only looking at text inside an element with no elements inside it you can grab the text, truncate it and then replace it back into the element. If you have to deal with mixed content it will be more complicated.
But in the interest of bad solutions:
#treats each entity as one character "2 < 4" is 5 characters long
$trunc_len = $len - 3;
$str =~ s/^((?>(?:[^&]|&[^\s;]+;?){$trunc_len}))(?:[^&]|&[^\s;]+;?){4,}/$1.../;
#abuses proceadural nature of the regexp engine
#treats each input character as on character "2 < 4" is 8 characters long
$str =~ s/^( (?:[^&]|&[^\s;]+;?)+ )(?(?{ $found = (pos() > ( $found ? $len - 3 : $len ))})(?!)).*$(?(?{pos() < $len })(?!))/$1.../x;
Both are fairly permissive in what is an entity to allow for common browser quirks.