Never send an empty content block - #768
Open
eb8680 wants to merge 6 commits into
Open
Conversation
jfeser
requested changes
Sep 2, 2026
eb8680
added a commit
that referenced
this pull request
Sep 2, 2026
Two bugs, both found by @jfeser. `_is_empty_text_block` used `.strip()`, so it called a whitespace block empty while `to_content_blocks` kept one. A tool returning whitespace therefore tripped the `append_message` assert. Whitespace is content, and Anthropic accepts a block of it (checked against the live API, plain and carrying a cache breakpoint); only `""` is rejected. The predicate now tests exactly what `to_content_blocks` guarantees, as do `_mark`'s string branch and the test helpers. `to_content_blocks` returning no block for `""` meant a conversion or format spec on an empty value never ran, so `{x!r}` rendered nothing where it used to render `''`, and `{x:>5}` lost its padding. `format_as_content_blocks` now formats such a value itself, and its `flush_text` guards on the formatted text, so a hole that formats to nothing still contributes no block. The assertion on empty model output keeps its original condition and gains only the finish_reason. An empty string collapses to `None` through the `or` and is caught here; anything with text in it goes on to decoding and fails there as a `ResultDecodingError`, which is retried. Rewrote the comments and docstrings added by this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Anthropic rejects an empty text block ("messages: text content blocks
must be non-empty") and rejects it again when a cache breakpoint lands on
it ("cache_control cannot be set for empty text blocks" -- the error #762
reports). An ordinary `Skill` call reached both.
`to_content_blocks` emitted a block for the empty string, so a tool
returning `""` encoded to a single empty block -- and, being the last
block of the last input message, to the one place `_add_cache_control`
puts its second breakpoint. The trigger in a long-running session is
`exec_code`, which returns the empty string for a snippet that printed
nothing and is in the default `harness()` stack.
`to_content_blocks` no longer builds a block with nothing in it. The
linearization law is unaffected: it is stated for non-string encoded
values, and an empty string nested inside one still renders as `""`.
`HistoryBuilder.append_message` asserts the invariant, and `_mark`
declines a message with no non-empty block, letting the breakpoint fall
back to an earlier message instead of being dropped. Anthropic accepts a
tool_result whose content list is empty, confirmed live.
The assertion in `call_assistant` now also rejects a whitespace-only
reply and reports the `finish_reason`, which is the only thing that
distinguishes a truncation from a filtered response or a broken proxy.
Two claims were checked against the live API rather than assumed, and one
was wrong. Anthropic *accepts* consecutive same-role turns, so a dropped
assistant turn is a transcript-integrity bug, not a 400; the
`_assert_valid_anthropic_request` oracle therefore asserts that no turn
is dropped rather than that roles alternate, which would have pinned
provider behaviour that does not exist.
The suite could not have caught this: every offline test asserts on the
OpenAI-shaped list reaching `completion`, and the live tests run against
`EFFECTFUL_LLM_MODEL` -- an OpenAI model by default, where litellm strips
`cache_control` and empty text is tolerated. The new oracle runs
litellm's Anthropic transform offline and is applied to the existing
caching tests too.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two bugs, both found by @jfeser. `_is_empty_text_block` used `.strip()`, so it called a whitespace block empty while `to_content_blocks` kept one. A tool returning whitespace therefore tripped the `append_message` assert. Whitespace is content, and Anthropic accepts a block of it (checked against the live API, plain and carrying a cache breakpoint); only `""` is rejected. The predicate now tests exactly what `to_content_blocks` guarantees, as do `_mark`'s string branch and the test helpers. `to_content_blocks` returning no block for `""` meant a conversion or format spec on an empty value never ran, so `{x!r}` rendered nothing where it used to render `''`, and `{x:>5}` lost its padding. `format_as_content_blocks` now formats such a value itself, and its `flush_text` guards on the formatted text, so a hole that formats to nothing still contributes no block. The assertion on empty model output keeps its original condition and gains only the finish_reason. An empty string collapses to `None` through the `or` and is caught here; anything with text in it goes on to decoding and fails there as a `ResultDecodingError`, which is retried. Rewrote the comments and docstrings added by this PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`to_content_blocks` inlined the emptiness test at both of its construction sites and `_is_empty_text_block` stated it a third time, so nothing tied the three together. That is how the copies came apart in the first place: one of them said `.strip()` and the other two did not, and a tool returning whitespace tripped the assert in `append_message`. `_text_blocks` is now the only place a text block is built, and it decides by asking `_is_empty_text_block`. `to_content_blocks` and `format_as_content_blocks` both go through it, so no caller can produce a block the predicate rejects. `format_as_content_blocks` no longer fabricates a block for an empty string to keep its conversion running; it formats the string directly and lets `flush_text` decide whether anything is left to emit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving the assertion inside the try makes an empty reply a `ResultDecodingError` like any other undecodable one: `HistoryBuilder` turns it into feedback and `TenacityRetryer` retries it, then fails with the finish_reason still in the message. This is what @jfeser asked for on the PR. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The oracle asserted no empty text block reached the transformed request, no cache breakpoint sat on one, and no more than four breakpoints were present. Those are claims about the provider, taken from probes run while writing the change and checked by nothing afterwards: they can fail when our request changes, never when the belief behind them was wrong. What is left is our own invariant, checked by running the transform: no turn we put in the history is dropped on the way out. The regression test for the bug itself is unaffected -- it asserts our messages carry no empty block, which is a fact about `to_content_blocks`. Neither litellm nor the Anthropic SDK offers a local validator to check against instead. litellm's `count_tokens` support is proxy-side only, its client-side validators cover the OpenAI shape, and the one place it encodes the empty-block rule is `_sanitize_empty_text_content`, a repair that skips tool messages -- the case this bug was in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
eb8680
force-pushed
the
worktree-issue-762-empty-content-blocks
branch
from
September 4, 2026 14:25
f51c700 to
41dd6e9
Compare
eb8680
changed the base branch from
master
to
worktree-issue-775-raw-string-code
September 4, 2026 14:25
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Resolves #762
This PR prevents the
handlers.llmharness from constructing or accepting messages with empty content blocks, which provider APIs (especially Anthropic) don't like and don't respond to uniformly. This also prevents the failure in #762 from happening.