fix(anthropic): prepended leading user turn must be non-whitespace (#70909) - #70910
fix(anthropic): prepended leading user turn must be non-whitespace (#70909)#70910SHL0MS wants to merge 2 commits into
Conversation
A blank/whitespace-only text block anywhere in an Anthropic request triggers HTTP 400 'text content blocks must contain non-whitespace text', which then replays every turn and wedges the session (looks like an endless 'thinking' with no reply). The per-message converters already coerce blanks they produce (NousResearch#69512), but a blank text block can be synthesized AFTER them: a context-compression summary message, a role merge, or an upstream message whose content arrived pre-shaped as content blocks. Those slip past the per-message guards. Add _coerce_blank_text_blocks(), a final backstop in convert_messages_to_anthropic that walks the fully-assembled request (system prompt + every message) and coerces any empty/whitespace text block to the non-whitespace placeholder. Only text blocks are touched; thinking/tool_use/image blocks obey different schema rules and are left intact. Tests assert the guarantee on the assembled request (pre-shaped blank block, whitespace-only summary-style turn, blank cache_control system block) and that real text is untouched; verified failing without the guard, passing with it. Existing whitespace/thinking-order suites unchanged.
Root cause of the HTTP 400 'text content blocks must contain non-whitespace text' wedge: _ensure_leading_user_turn prepends a user turn when messages[0] is not a user message (post-compaction histories start with an assistant summary), and the placeholder text was a bare single space. A space-only text block is exactly what Anthropic rejects, so the guard added for NousResearch#52160 trips this 400 itself and every retry replays it — the session hangs with no reply. Bedrock's equivalent prepend already uses the shared non-whitespace placeholder; the Anthropic adapter's docstring claims to mirror it but diverged on this detail. Use _EMPTY_TEXT_PLACEHOLDER to match. Verified against a real wedged history: the assembled request carried exactly one blank text block, at index 0, the prepended turn. With this change the same history assembles with zero blank blocks. The two NousResearch#52160 tests froze the buggy literal (assert content == [{'text': ' '}]), which would have blocked this fix. Rewritten to assert the invariant: one text block whose text is non-whitespace.
|
Ran this against current main (0157180) — the diff still applies cleanly despite the stale base. Three of the five new guard tests fail on clean main (the leading-turn |
SummaryFour PRs address the same assembly-time Anthropic whitespace failure. #70081, #70159, and #71935 only replace the invalid synthetic leading-user placeholder, while #70910 uses the canonical sentinel at that source and additionally guards pre-shaped message blocks and cache-controlled system blocks in the fully assembled request. Related pull requests
Duplicates#70081, #70159, and #71935 duplicate the synthesized-leading-user fix contained in #70910; #70081 and #71935 are closest at the source level, while #70159 differs primarily by introducing a new "." literal. Suggested consolidationKeep #70910 open with a salvage path: retain its canonical root-cause fix, final text-block-only request guard, and focused regressions, rebasing or reconciling the test additions against current main as needed. Close #70081, #70159, and #71935 as duplicates of #70910 because their diffs cover only the root-cause subset and add no independent behavior beyond the recorded best fix. Complex graphflowchart LR
classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
classDef best stroke-width:3px,stroke:#b45309
classDef target stroke-width:3px,stroke:#4338ca
I70909(["issue #70909 (open)"])
P70910["PR #70910 (open)"]
P70910 -->|best fix| I70909
class I70909 open
class P70910 open
class P70910 best
class P70910 target
click I70909 "https://github.com/NousResearch/hermes-agent/issues/70909"
click P70910 "https://github.com/NousResearch/hermes-agent/pull/70910"
Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label). Cross-PR triage: Reviewed 4 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 21 kB of PR diffs, 13 kB of issue/PR text, 4 kB of discussion (8 comments), 8 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch. |
|
Independent confirmation of this root cause from a production deployment, in case it helps move this along — I hit it in the wild before finding this PR, diagnosed it from the other end, and landed on exactly the block you identify here. Deployment: Hermes Agent v0.19.1 (2026.7.30) · upstream What matched your diagnosis exactly. From
That is Two details that support "synthesized, not stored", if you need more evidence for review:
Blast radius, since it may affect priority. It is permanent, not transient. 28 identical failures over ~4 hours; the agent never answered again on its own. Seven sibling tenants on the same image, host, upstream and model were unaffected throughout, so it is per-session rather than environmental. Two things make it worse for end users:
Workaround for anyone stuck before this merges: delete the session's entry from I filed #83037 before searching properly — closing it as a duplicate of #70909 and pointing at this PR. Happy to share the sanitized request dump privately with a maintainer if a second reproduction is useful for review; it contains end-user conversation content so I have not attached it. |
|
Seems to have been fixed by #77509 |
Residual from PR #70910 after #77509 landed the message-list scrub: a whitespace-only system content block carrying a cache_control marker still reached the wire and 400'd the whole request ("text content blocks must contain non-whitespace text"), wedging the session on every retry. The block cannot be dropped (it carries the cache breakpoint), so coerce its text to the shared non-whitespace placeholder when extracting the system param, copying the block so caller message dicts are never mutated. Adds SHL0MS's request-level regression suite from #70910; four of its five cases already pass on main via #77509 — the system-block case fails without this fix.
|
Merged via #86643 with your authorship preserved (rebase merge). On current main, #77509 had already landed the request-level scrub, so 4 of your 5 regression cases passed — the salvage carried the remaining residual: a whitespace-only system block with cache_control still reached the wire; it is now coerced to the shared non-whitespace placeholder at system extraction (the block can't be dropped without losing the cache breakpoint). Your full regression suite from this PR landed as tests/agent/test_anthropic_request_blank_block_guard.py. Thanks @SHL0MS for the forensic diagnosis, and thanks for the detailed production evidence in the thread — the wedged-session workaround documented there helped scope blast radius. |
Summary
Fixes #70909. A session wedges behind
HTTP 400: messages: text content blocks must contain non-whitespace text— endless "thinking", no reply, every retry replaying the same 400.Root cause (proven, self-inflicted):
_ensure_leading_user_turnprepends a user turn whenmessages[0]is not a user message — which is the normal shape after context compaction emits an assistant summary first. The placeholder it inserted was a bare single space:A space-only text block is exactly what the Messages API rejects, so the guard added for #52160 trips this 400 itself. Bedrock's equivalent prepend already uses the shared non-whitespace placeholder (
bedrock_adapter.py), and this function's docstring claims to mirror it, but it diverged on the one detail that matters.Verified against a real wedged history: the assembled request carried exactly one blank text block — at index 0, the prepended turn. Stored rows were clean (no blank
content/api_content/reasoning*; every stored assistant row run through_convert_assistant_messageproduced zero blanks), which is what pointed at assembly-time synthesis. With this change the same history assembles with zero blank blocks.Changes
_ensure_leading_user_turnuses_EMPTY_TEXT_PLACEHOLDERinstead of" ", matching Bedrock._coerce_blank_text_blocks()at the end ofconvert_messages_to_anthropicwalks the fully-assembled request (system prompt + every message) and coerces any remaining blanktextblock. Independently justified: with the backstop disabled the root-cause fix handles the leading-turn case, but two other real paths still leak blanks — content that arrives pre-shaped as blocks with a blank text part, and a blankcache_controlsystem block (neither is walked by the per-message converters). Onlytextblocks are touched;thinking/tool_use/imageobey different schema rules.assert content == [{"type": "text", "text": " "}]), which would have blocked this fix. Rewritten to assert the invariant: one text block whose text is non-whitespace.Testing
Each layer verified independently: disabling the backstop leaves the leading-turn test passing (root cause genuinely fixed) while the pre-shaped-block and system-block tests fail (backstop not redundant). The 3
TestRunOauthSetupTokenfailures in that file are pre-existing on cleanmain(MagicMock/JSON in credential mocking), unrelated to this change.Root cause identified and both layers verified against a real wedged history.