Skip to content

fix(anthropic): non-whitespace placeholder for synthesized leading user turn - #71935

Open
acke wants to merge 1 commit into
NousResearch:mainfrom
acke:fix/anthropic-whitespace-leading-user-turn
Open

acke wants to merge 1 commit into
NousResearch:mainfrom
acke:fix/anthropic-whitespace-leading-user-turn

Conversation

@acke

@acke acke commented Jul 26, 2026

Copy link
Copy Markdown

Problem

_ensure_leading_user_turn() in agent/anthropic_adapter.py prepends a synthetic user turn when a compacted message window starts with an assistant message (Anthropic requires messages[0].role == "user"). The placeholder text was a single space " ".

Anthropic's Messages API rejects empty/whitespace-only text content blocks:

HTTP 400 invalid_request_error
messages: text content blocks must contain non-whitespace text

Because messages[0] is re-sent on every turn, any conversation whose compacted window leads with an assistant turn (e.g. after a second context compaction where protect_head has decayed to 0) wedges permanently behind the same 400. Both the primary model and the fallback receive the identical malformed payload and fail identically — a non-retryable client error — so the user only ever sees repeated "model provider failed / switching to fallback" banners with no recovery.

Observed live in a long-lived gateway (Discord) session: every inbound message 400'd across restarts until the placeholder was fixed. It never self-heals because the offending block is structural, not content the user can edit away.

Fix

Use the module's existing _EMPTY_TEXT_PLACEHOLDER ("(empty)") — the same non-whitespace placeholder already used by the sanitize paths (_safe_text, _sanitize_replay_block, _convert_assistant_message). This is a one-line behavioral change; the surrounding whitespace-coercion machinery was already in place (ref #69512) but this synthesis site was missed.

Tests

  • New TestEnsureLeadingUserTurn in tests/agent/test_anthropic_whitespace_text_blocks.py (4 cases): prepends when first is assistant, placeholder is non-whitespace, no-op when already user-led, empty list untouched.
  • Updated two change-detector assertions in tests/agent/test_anthropic_adapter.py that had frozen the buggy " " value to assert _EMPTY_TEXT_PLACEHOLDER instead.

scripts/run_tests.sh tests/agent/test_anthropic_whitespace_text_blocks.py tests/agent/test_anthropic_adapter.py → 198 passed. (3 unrelated TestRunOauthSetupToken failures are pre-existing on main — a pytest-9 MagicMock/json.loads interaction — and are untouched by this change.)

Ref #69512.

…er turn

_ensure_leading_user_turn() prepends a user turn when a compacted window
starts with an assistant message (Anthropic requires messages[0].role ==
user). It used a single space " " as the placeholder text, but Anthropic's
Messages API rejects empty/whitespace-only text content blocks with HTTP
400 "messages: text content blocks must contain non-whitespace text".

Because messages[0] is re-sent on every turn, any conversation whose
compacted window leads with an assistant turn wedges permanently behind
the same 400 — both primary and fallback models fail identically, so the
user only sees repeated 'model provider failed' banners. Observed in a
long-lived gateway (Discord) session after a second context compaction.

Use the module's existing _EMPTY_TEXT_PLACEHOLDER ("(empty)") instead,
mirroring the sanitize paths (_safe_text / _sanitize_replay_block).

Adds TestEnsureLeadingUserTurn regression coverage and updates two
change-detector assertions in test_anthropic_adapter.py that had frozen
the buggy " " value.

Ref NousResearch#69512.
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint provider/anthropic Anthropic native Messages API area/compression Context compression and continuation sessions P2 Medium — degraded but workaround exists sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state duplicate This issue or pull request already exists labels Jul 26, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

This was generated by AI during triage.

Duplicate of #70081: both current diffs replace the same whitespace-only synthesized leading user turn with a printable sentinel. #70910 remains related because it adds a broader final assembled-request backstop.

@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for isolating the synthesis-site gap. The premise remains valid on current main: agent/anthropic_adapter.py:2613 still inserts " " as the synthetic leading user text block, and convert_messages_to_anthropic() calls that helper on the returned provider message list at agent/anthropic_adapter.py:2674. The adapter already defines the appropriate sentinel at agent/anthropic_adapter.py:1931.

The proposed replacement with _EMPTY_TEXT_PLACEHOLDER is correctly scoped and preserves the existing role-normalization behavior. Current coverage also still exercises the compaction-shaped conversion path, but freezes the buggy literal at tests/agent/test_anthropic_adapter.py:871-883.

The branch predates substantial test pruning after base eb52760564dbba2e5971fa54bd67384e281cd3b8; salvage should reconcile the test hunks against the remaining current test rather than preserve removed fixtures verbatim.

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 30, 2026

@GottZ GottZ 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.

This was generated by AI during triage.

Summary

Four PRs address the same assembly-time Anthropic whitespace failure. #70081, #70159, and #71935 replace only the invalid synthetic leading-user placeholder, while #70910 fixes that root cause with the canonical sentinel and additionally guards pre-shaped message blocks and cache-controlled system blocks in the fully assembled request.

Related pull requests

  • #70081 duplicate — (+46/-3) — close as duplicate of #70910: it fixes the synthesized leading-user block but hard-codes "(empty)" instead of using _EMPTY_TEXT_PLACEHOLDER; despite the keep_open review on #70081, #70910 contains the same source fix using the requested canonical constant and covers additional assembly-time blank-block paths.
  • #70159 duplicate — (+8/-3) — close as duplicate of #70910: it changes the same synthesized block to "." and updates the existing tests to assert non-whitespace behavior; despite the keep_open review on #70159, #70910 fixes the identical site with _EMPTY_TEXT_PLACEHOLDER and adds coverage for blank pre-shaped and system blocks.
  • #70910 best fix — (+165/-3) — keep open with a salvage path: this is the recorded best fix for #70909, using _EMPTY_TEXT_PLACEHOLDER at the proven synthesis site and adding a final assembled-request guard for the independently reproduced pre-shaped-content and cache-controlled-system cases. Preserve the root-cause hunk, the final text-block-only guard, and focused regressions while reconciling the test additions with current main.
  • #71935 fixes — (+48/-3) — close as duplicate of #70910: its source hunk correctly uses _EMPTY_TEXT_PLACEHOLDER, but its scope remains limited to the synthesized leading turn. Despite the MAINTAINER-BOT keep_open verdict on #71935, the complete diff of #70910 contains that same fix plus tested request-level protection for two additional blank-block paths, so #71935 has no distinct implementation to salvage.

Duplicates

#70081, #70159, and #71935 duplicate the synthesized-leading-user fix already contained in #70910; #70081 and #71935 are especially close at the source level, while #70159 differs only by choosing "." rather than the canonical placeholder.

Suggested consolidation

Keep #70910 open with a salvage path: retain its canonical _EMPTY_TEXT_PLACEHOLDER root-cause fix, final text-block-only request guard, and focused regressions, rebasing or reconciling the tests 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 graph

flowchart 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
    P71935 -->|fixes| I70909
    class I70909 open
    class P70081 open
    class P70159 open
    class P71935 open
    class P71935 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"
Loading

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/compression Context compression and continuation sessions comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint duplicate This issue or pull request already exists P2 Medium — degraded but workaround exists provider/anthropic Anthropic native Messages API sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants