fix(anthropic): synthesized leading user turn must be non-whitespace - #70081
Conversation
_ensure_leading_user_turn() prepends a filler user message when the
converted message list opens on a non-user role (e.g. a resumed or
double-compacted session whose active window starts on an assistant
tool-call turn). The filler used a single space (" ") as its text.
Anthropic's Messages API rejects any text content block whose text is
empty or whitespace-only:
HTTP 400: messages: text content blocks must contain non-whitespace text
so every such resume 400s before a single token is generated. Two
existing tests asserted the " " value but only checked output *shape*,
never that the API would accept it — which is why this shipped.
Fix: use the "(empty)" sentinel already used by every other
empty-content path in this adapter (lines ~2061/2136/2325/2368) and by
the Bedrock adapter's _EMPTY_TEXT_PLACEHOLDER. Update the two existing
tests to assert non-whitespace, and add a regression test for the
assistant-tool-call-leading shape that triggered it in the wild.
teknium1
left a comment
There was a problem hiding this comment.
Thanks for the focused fix. Current main still inserts the whitespace-only synthesized user block in agent/anthropic_adapter.py:2613, and the conversion path invokes that helper at agent/anthropic_adapter.py:2674, so the reported defect remains present.
Suggested changes
- Prefer the existing
_EMPTY_TEXT_PLACEHOLDERconstant fromagent/anthropic_adapter.py:1931instead of repeating the"(empty)"literal. The proposed.strip() != ""assertions are the useful behavioral contract; current main still freezes the invalid literal attests/agent/test_anthropic_adapter.py:883.
The patch is narrow, preserves role ordering, and introduces no tool, configuration, or prompt-cache surface. This is an automated hermes-sweeper review.
| @@ -2427,7 +2427,15 @@ def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None: | |||
| (convert_messages_to_converse). | |||
There was a problem hiding this comment.
Prefer _EMPTY_TEXT_PLACEHOLDER here instead of duplicating its current "(empty)" value. The module defines the shared sentinel for this provider's non-whitespace fallback paths, so using it prevents this synthesized-turn path from drifting.
SummaryFour open PRs address Issue #70909's Anthropic request-assembly failure. #70081, #70159, and #71935 fix only the whitespace-only synthesized leading-user block; #70910 includes that source fix plus a final guard for blank text blocks elsewhere in the assembled system prompt and messages. Related pull requests
Duplicates#70081, #70159, and #71935 duplicate the synthesized leading-user fix contained in #70910. #70081 and #71935 are source-level equivalents using the canonical behavior, while #70159 differs mainly by introducing "." instead of Suggested consolidationKeep open with a salvage path: #70910, preserving its canonical synthesis-site fix, final assembled-request text-block guard, and focused regressions while reconciling tests with current main. Close #70081, #70159, and #71935 as duplicates of #70910; this recommendation explicitly departs from the COMMENTED keep_open reviews on #70081 and #70159 and the MAINTAINER-BOT keep_open verdict on #71935 because their diffs contain only the narrower synthesis-site change already included in #70910. Do not merge any PR from this consolidation. 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)"])
subgraph Dup70081 ["PRs duplicating each other"]
P70081["PR #70081 (open)"]
P70159["PR #70159 (open)"]
P71935["PR #71935 (open)"]
end
P70081 -->|fixes| I70909
class I70909 open
class P70081 open
class P70159 open
class P71935 open
class P70081 target
click I70909 "https://github.com/NousResearch/hermes-agent/issues/70909"
click P70081 "https://github.com/NousResearch/hermes-agent/pull/70081"
click P70159 "https://github.com/NousResearch/hermes-agent/pull/70159"
click P71935 "https://github.com/NousResearch/hermes-agent/pull/71935"
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. |
Problem
Resuming a session whose active window opens on a non-user role (e.g. a resumed or double-compacted session whose first live message is an assistant tool-call turn) fails on the very first request with:
Non-retryable — the whole request is rejected before any tokens are generated, so the session becomes unresumable.
Root cause
_ensure_leading_user_turn()(inagent/anthropic_adapter.py) backstops Anthropic's requirement thatmessages[0].role == "user"by prepending a filler user turn. That filler used a single space as its text:A single space is whitespace-only, which the Messages API rejects with the 400 above. The bug is latent: it only fires when the converted message list actually opens on a non-user role, which happens on resumed/compacted sessions whose active head is an assistant turn.
Two existing tests (
test_leading_assistant_after_compaction_gets_user_turn_prepended,test_double_compaction_no_system_in_messages_leads_with_user) asserted the" "value, but only checked output shape — never that Anthropic would accept it. That's why it shipped.Fix
Use the
"(empty)"sentinel already used by every other empty-content path in this same adapter (lines ~2061 / 2136 / 2325 / 2368) and by the Bedrock adapter's_EMPTY_TEXT_PLACEHOLDER = "(empty)". The synthesized leading user turn now always carries printable text.Tests
" "to"(empty)"and added a.strip() != ""invariant so a future whitespace regression fails loudly.test_synthesized_leading_user_turn_is_non_whitespacereproducing the real-world shape (assistant tool-call turn asmessages[0]), asserting the property that actually matters to the API: non-whitespace filler text.Verification
Reproduced against a real affected session by rebuilding its active window and running it through
convert_messages_to_anthropic(): before the fix, one{"type":"text","text":" "}block was emitted asmessages[0]; after, zero empty/whitespace text blocks across the full converted payload. The three relevant tests pass against the patched adapter.