Skip to content

fix(models): an empty Anthropic end_turn is a silent turn, not an API error - #48

Closed
rezaho wants to merge 1 commit into
s17-deferred-tool-loadingfrom
fix/anthropic-silent-turn
Closed

fix(models): an empty Anthropic end_turn is a silent turn, not an API error#48
rezaho wants to merge 1 commit into
s17-deferred-tool-loadingfrom
fix/anthropic-silent-turn

Conversation

@rezaho

@rezaho rezaho commented Jul 12, 2026

Copy link
Copy Markdown
Owner

The bug

Both Anthropic adapters raised a typed ModelAPIError when the model finished normally (stop_reason: end_turn) with no content blocks, and the classifier pinned that error non-retryable. Every such turn died terminally.

But that response is a success. A model instructed to stay quiet when it has nothing to report — a heartbeat that found no work, an inbox check that found no mail — obeys by ending the turn with zero content blocks. The provider bills it as a successful, well-formed response meaning "I have nothing to say."

Confirmed on the wire against the live API:

message_start   content: []
message_delta   stop_reason: end_turn, output_tokens: 2
message_stop

No content_block_start at all. Nothing was dropped by the stream reader — nothing was sent.

Downstream this terminally killed a real agent's recurring task on every firing, and it was self-reinforcing: the dead turn persisted nothing, so each replay showed the model another in-context example of staying silent.

The root

The raise was a symptom. HarmonizedResponse cannot represent the outcome — its validator rejects content=None with no tool_calls, the adapters normalize empty text to None, and the empty-output branch had only two arms (deterministic truncation → placeholder; everything else → raise). The typed error was originally added to replace an opaque UNKNOWN ValidationError, which improved the error's quality while cementing a success as an error class.

The fix

The validator already blesses the shape this needs — it checks content is None specifically, with a comment noting that an empty string is a valid response from some providers. The API-key adapter already uses that exact escape for thinking-only responses.

So the empty-output branch gains its correct third arm, beside the two that already exist rather than as a parallel path:

terminal outcome
max_tokens / model_context_window_exceeded truncation placeholder (unchanged)
end_turn content="" — a silent turn (new)
refusal, no terminal at all typed ModelAPIError (unchanged)

Refusal and a stream that closed without any terminal keep raising — those are genuine anomalies, and preserving that distinction is the value the typed-error work added. The now-unreachable end_turn classification arm in exceptions.py is deleted.

Both adapters get the arm, so one provider keeps one behaviour.

Tests

The test asserting that an empty end_turn raises is inverted, not removed — it encoded the wrong contract, and deleting it quietly would hide that this PR reverses a recorded decision. Its replacement pins the silent turn, and a twin covers the API-key adapter.

Framework suite: green (1362 passed; the 3 unrelated coordination/ failures and the readline collection errors are pre-existing on Windows and fail identically without this change).

… error

A model that runs to natural completion and produces no content blocks is
returning a success: "I have nothing to say." Both Anthropic adapters raised
a typed ModelAPIError on it instead, and the classifier pinned that error
non-retryable — so every silent turn died terminally.

This is a behaviour callers explicitly ask for. An agent instructed to stay
quiet when it has nothing to report (a heartbeat that found no work, an inbox
check that found no mail) obeys, ends the turn with zero content blocks and
stop_reason 'end_turn', and the provider bills it as a successful response.
Confirmed on the wire: message_start (content []) -> message_delta
(stop_reason end_turn, output_tokens 2) -> message_stop, no content_block_start.

The raise was a symptom. The root is that HarmonizedResponse could not
represent the outcome: its validator rejects content=None with no tool_calls,
the adapters normalize empty text to None, and the empty-output branch had only
two arms (deterministic truncation -> placeholder; everything else -> raise).
The typed error was added to replace an UNKNOWN ValidationError, which improved
the error's quality while cementing a success as an error class.

The validator already blesses the shape this needs: it checks `content is None`
specifically, because an empty string is a valid response from some providers.
The API-key adapter already uses that escape for thinking-only responses. So the
empty-output branch gains its correct third arm — end_turn harmonizes to
content="" — beside the two that exist, rather than a parallel path.

Refusal and a stream that closed with no terminal at all keep raising: those are
genuine anomalies, and that distinction is the value the typed-error work added.

The test asserting empty end_turn raises is INVERTED, not removed: it encoded
the wrong contract. Its replacement pins the silent turn, and a twin covers the
API-key adapter so one provider keeps one behaviour. The now-unreachable
end_turn classification arm is deleted.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request updates the Anthropic adapters to treat empty responses with an 'end_turn' stop reason as successful 'silent turns' by harmonizing them to an empty string instead of raising a ModelAPIError. The reviewer pointed out a latent gap in the OAuth adapter where thinking-only responses would still trigger validation failures because they bypass the empty-output block, and provided a code suggestion to resolve this inconsistency.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +902 to 917
content = text_content if text_content else None
if not text_content and not tool_calls and not raw_response.get("thinking"):
if finish_reason == "length":
text_content = (
content = (
"[Response truncated due to token limit. Please increase max_tokens "
"or continue the conversation.]"
)
elif stop_reason_raw == "end_turn":
# A silent turn: the model ran to natural completion and produced
# nothing. Callers ask for this (an agent told to stay quiet when
# it has nothing to report), the provider bills it as a success,
# and the empty STRING is the content shape that carries it — the
# validator's rejection is of None, never of "". The API-key twin
# uses the same escape for its thinking-only responses.
content = ""
else:

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

By checking not raw_response.get("thinking") in the outer if condition, a thinking-only response (which has no text content and no tool calls) will skip this block entirely. This leaves content as None, which subsequently triggers a validation failure in HarmonizedResponse since it requires either content or tool calls. We can close this latent gap and align the OAuth adapter's behavior with the standard Anthropic adapter by allowing the block to execute when there is no text content and no tool calls, and then setting content = "" if it is either an end_turn or a thinking-only response.

        content = text_content if text_content else None
        if not text_content and not tool_calls:
            if finish_reason == "length":
                content = (
                    "[Response truncated due to token limit. Please increase max_tokens "
                    "or continue the conversation.]"
                )
            elif stop_reason_raw == "end_turn" or raw_response.get("thinking"):
                # A silent turn or thinking-only response: the model ran to natural
                # completion and produced nothing, or only thinking. The empty STRING
                # is the content shape that carries it to avoid validation failure.
                content = ""
            else:

@rezaho

rezaho commented Aug 2, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #49, which is merged. This PR's commit c0b2b6a ("an empty Anthropic end_turn is a silent turn, not an API error") is contained in that merge and is now on main — verified with git branch -r --contains c0b2b6a. Closing rather than merging to avoid a redundant merge commit; the fix itself shipped.

@rezaho rezaho closed this Aug 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant