Skip to content

Never send an empty content block - #768

Open
eb8680 wants to merge 6 commits into
worktree-issue-775-raw-string-codefrom
worktree-issue-762-empty-content-blocks
Open

Never send an empty content block#768
eb8680 wants to merge 6 commits into
worktree-issue-775-raw-string-codefrom
worktree-issue-762-empty-content-blocks

Conversation

@eb8680

@eb8680 eb8680 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Resolves #762

This PR prevents the handlers.llm harness 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.

@eb8680
eb8680 requested a review from jfeser September 2, 2026 03:56

@jfeser jfeser left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few chatgpt comments:

Comment thread effectful/handlers/llm/harness/durability/transaction.py Outdated
Comment thread effectful/handlers/llm/harness/hooks.py Outdated
Comment thread effectful/handlers/llm/harness/serialization.py
Comment thread effectful/handlers/llm/harness/serialization.py Outdated
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>
eb8680 and others added 6 commits September 4, 2026 10:20
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
eb8680 force-pushed the worktree-issue-762-empty-content-blocks branch from f51c700 to 41dd6e9 Compare September 4, 2026 14:25
@eb8680
eb8680 changed the base branch from master to worktree-issue-775-raw-string-code September 4, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

litellm.completion errors with prompt cache metadata for empty assistant message

2 participants