fix(anthropic): filter blank text blocks in both normal and replay paths - #63228
fix(anthropic): filter blank text blocks in both normal and replay paths#63228ygd58 wants to merge 1 commit into
Conversation
Bedrock and strict Anthropic-compatible endpoints reject text blocks where 'text' is empty or whitespace-only with HTTP 400. The normal list-content path in _convert_assistant_message() extended blocks without filtering, and _sanitize_replay_block() returned blank text blocks on the ordered-replay fast path. Fix: 1. Normal path: replace blocks.extend() with a filtering loop that drops any text block whose .strip() is empty, preserving tool_use and other block types. 2. Replay path: _sanitize_replay_block() now returns None for text blocks with empty or whitespace-only text, so the replay loop skips them via 'if clean is None: continue'. Both paths now share the same non-empty-text invariant. Added 4 regression tests in TestBlankTextBlockFiltering: - normal path: empty text + tool_calls -> text dropped, tool kept - normal path: whitespace-only text filtered - normal path: non-empty text preserved (no false positive) - replay path: empty text in anthropic_content_blocks dropped 177/177 tests pass (173 existing + 4 new). Addresses maintainer review feedback on the earlier salvage PR.
Superset of / follow-up to #57985 (which fixes only the normal |
teknium1
left a comment
There was a problem hiding this comment.
Thanks for covering both the normal and ordered-replay paths; the empty-block premise is still present on current main.
Problems
- The normal-path condition calls
.strip()onblk.get("text", ""), but_convert_content_part_to_anthropic()can preserveNonefrom input text blocks (agent/anthropic_adapter.py:1733-1740). That would turn an invalid provider payload into anAttributeError. - The filter drops an attached cache marker.
agent/prompt_caching.py:46-49marks the final content part; dropping that blank part without relocatingcache_controlloses the breakpoint. The same concern applies when ordered replay drops a cached blank text block. - Scalar whitespace content remains unfiltered:
agent/anthropic_adapter.py:1982-1988accepts a truthy whitespace string and emits it as a text block.
Suggested changes
- Apply a type-safe blank-text normalization across scalar, list, and replay paths, and add
None/scalar-whitespace regressions. - Transfer a dropped block's cache marker to a surviving cacheable block or the non-empty fallback, with normal and replay cache tests.
Automated hermes-sweeper review.
| # "text" is empty or whitespace-only. The ordered-replay path | ||
| # enforces the same invariant via _sanitize_replay_block(). | ||
| for blk in converted_content: | ||
| if ( |
There was a problem hiding this comment.
_convert_content_part_to_anthropic() preserves None when an input_text or stored text part explicitly has text: null (agent/anthropic_adapter.py:1733-1740). This .strip() will raise for that input; use a type-safe predicate and add a nullable-text regression.
| for blk in converted_content: | ||
| if ( | ||
| isinstance(blk, dict) | ||
| and blk.get("type") == "text" |
There was a problem hiding this comment.
A blank final content part can own the prompt-cache marker because agent/prompt_caching.py:46-49 annotates the last list item. Dropping it here loses that breakpoint; transfer its cache_control to the surviving cacheable block or fallback and cover the cached case.
Ports #63228 forward onto current main per teknium1's review. Bedrock and strict Anthropic-compatible endpoints reject text blocks where text is empty or whitespace-only with HTTP 400. The normal list-content path extended blocks without filtering, and the ordered-replay fast path (_sanitize_replay_block) returned blank text blocks unfiltered. Per review, fixes three gaps in the original port: 1. Type safety: the normal-path filter used blk.get('text', '').strip(), which crashes with AttributeError when text is explicitly None (not absent) -- .get()'s default only applies when the key is missing. _convert_content_part_to_anthropic() can preserve None from an invalid upstream input text block. Now uses (blk.get('text') or '').strip() on both paths. 2. Cache marker loss: prompt_caching.py's _apply_cache_marker() sets cache_control directly on content[-1] for list content. If that last part happens to be blank text, dropping it without relocating cache_control silently loses the breakpoint. Both the normal and replay paths now capture a dropped block's cache_control and reapply it to the new last surviving cacheable block via the existing _apply_assistant_cache_control_to_last_cacheable_block() helper (setdefault semantics, so it never clobbers a legitimately-placed marker). 3. Scalar whitespace: the non-list content branch (blocks.append({'type': 'text', 'text': str(content)})) accepted a truthy whitespace-only string unfiltered. Now filtered the same way as list-content blocks. 8/8 new tests pass in TestBlankTextBlockFiltering (including None-safety, scalar-whitespace, and cache_control-relocation regressions on both paths); 186/186 in the full tests/agent/test_anthropic_adapter.py file.
Ports #63228 forward onto current main per teknium1's review. Bedrock and strict Anthropic-compatible endpoints reject text blocks where text is empty or whitespace-only with HTTP 400. The normal list-content path extended blocks without filtering, and the ordered-replay fast path (_sanitize_replay_block) returned blank text blocks unfiltered. Per review, fixes three gaps in the original port: 1. Type safety: the normal-path filter used blk.get('text', '').strip(), which crashes with AttributeError when text is explicitly None (not absent) -- .get()'s default only applies when the key is missing. _convert_content_part_to_anthropic() can preserve None from an invalid upstream input text block. Now uses (blk.get('text') or '').strip() on both paths. 2. Cache marker loss: prompt_caching.py's _apply_cache_marker() sets cache_control directly on content[-1] for list content. If that last part happens to be blank text, dropping it without relocating cache_control silently loses the breakpoint. Both the normal and replay paths now capture a dropped block's cache_control and reapply it to the new last surviving cacheable block via the existing _apply_assistant_cache_control_to_last_cacheable_block() helper (setdefault semantics, so it never clobbers a legitimately-placed marker). 3. Scalar whitespace: the non-list content branch (blocks.append({'type': 'text', 'text': str(content)})) accepted a truthy whitespace-only string unfiltered. Now filtered the same way as list-content blocks. 8/8 new tests pass in TestBlankTextBlockFiltering (including None-safety, scalar-whitespace, and cache_control-relocation regressions on both paths); 186/186 in the full tests/agent/test_anthropic_adapter.py file.
Ports NousResearch#63228 forward onto current main per teknium1's review. Bedrock and strict Anthropic-compatible endpoints reject text blocks where text is empty or whitespace-only with HTTP 400. The normal list-content path extended blocks without filtering, and the ordered-replay fast path (_sanitize_replay_block) returned blank text blocks unfiltered. Per review, fixes three gaps in the original port: 1. Type safety: the normal-path filter used blk.get('text', '').strip(), which crashes with AttributeError when text is explicitly None (not absent) -- .get()'s default only applies when the key is missing. _convert_content_part_to_anthropic() can preserve None from an invalid upstream input text block. Now uses (blk.get('text') or '').strip() on both paths. 2. Cache marker loss: prompt_caching.py's _apply_cache_marker() sets cache_control directly on content[-1] for list content. If that last part happens to be blank text, dropping it without relocating cache_control silently loses the breakpoint. Both the normal and replay paths now capture a dropped block's cache_control and reapply it to the new last surviving cacheable block via the existing _apply_assistant_cache_control_to_last_cacheable_block() helper (setdefault semantics, so it never clobbers a legitimately-placed marker). 3. Scalar whitespace: the non-list content branch (blocks.append({'type': 'text', 'text': str(content)})) accepted a truthy whitespace-only string unfiltered. Now filtered the same way as list-content blocks. 8/8 new tests pass in TestBlankTextBlockFiltering (including None-safety, scalar-whitespace, and cache_control-relocation regressions on both paths); 186/186 in the full tests/agent/test_anthropic_adapter.py file.
Problem
Bedrock and strict Anthropic-compatible endpoints reject text blocks where text is empty or whitespace-only with HTTP 400. The normal list-content path in _convert_assistant_message() extended blocks without filtering, and _sanitize_replay_block() returned blank text blocks on the ordered-replay fast path.
Fix
Both paths now share the same non-empty-text invariant.
Verification
4 regression tests in TestBlankTextBlockFiltering: normal path empty text filtered, whitespace-only filtered, non-empty preserved (no false positive), replay path empty text filtered. 177/177 tests pass (173 existing + 4 new).
Addresses maintainer review feedback on the earlier salvage PR.