Skip to content

fix: prevent session poisoning from empty partial-stream-stub assistant turns - #73028

Merged
teknium1 merged 4 commits into
mainfrom
hermes/hermes-a6c6e223
Jul 28, 2026
Merged

fix: prevent session poisoning from empty partial-stream-stub assistant turns#73028
teknium1 merged 4 commits into
mainfrom
hermes/hermes-a6c6e223

Conversation

@teknium1

Copy link
Copy Markdown
Contributor

Summary

Salvages PR #68041 by @0xprincess — a mid-tool-call stream drop that delivers no text no longer poisons the session: the empty partial-stream stub is not persisted as an empty assistant turn, and sessions already poisoned by older builds are repaired at the send boundary.

Root cause: an SSE stream dying mid tool-call with no text produced a partial-stream stub (content:'', no tool_calls). The length-truncation path appended it to history as {"role":"assistant","content":""} and persisted it. Strict providers reject empty assistant content on replay with HTTP 400 ("message at position N with role 'assistant' must not be empty") — and since the message is persisted, every subsequent turn re-failed. Session unrecoverable.

Changes

Validation

Before After
Empty stub in history after stream drop {"role":"assistant","content":""} persisted not appended
Resumed poisoned session replay HTTP 400 every turn padded to " " at send, replays clean
Tool-call turns / non-empty turns unchanged unchanged

Targeted tests: 470 passed, 0 failed (test_partial_stream_finish_reason.py, test_run_agent.py) + prompt-caching/turn-finalizer suites green. Sabotage-verified: reverting each of the three layers makes its regression tests fail (4 failures on the sabotaged build).

Salvaged from #68041 with @0xprincess's authorship preserved via cherry-pick.

Infographic

session poisoning fix infographic

0xprincess and others added 3 commits July 27, 2026 18:25
…nt turns

A mid-tool-call stream drop with no delivered text produces a
partial-stream stub carrying content:'' and tool_calls=None.  The
conversation loop's truncation path appended it to history as
{"role":"assistant","content":""} before the continuation nudge, and
strict providers (Moonshot/Kimi via OpenRouter) reject empty assistant
content with HTTP 400 ("the message at position N with role 'assistant'
must not be empty") on the next replay.  Because the message is
persisted, every subsequent turn re-failed — the session was
unrecoverable.

Three layers, smallest blast radius first:

1. conversation_loop (length path): an EMPTY partial-stream stub is no
   longer appended as an interim assistant message; only the
   continuation user-message is.  Stubs that delivered partial text are
   still persisted so continuation stitching is unchanged.

2. chat_completion_helpers.build_assistant_message: never serialize a
   textless assistant turn with content:'' — pad to a single space, the
   same trick as the reasoning_content pad (#15250, #17400).  Tool-call
   turns are exempt (content:'' alongside tool_calls is accepted
   everywhere).

3. conversation_loop send boundary: pad a textless assistant turn's
   empty content to a single space AFTER all content-mutating passes
   (surrogate sanitize, whitespace normalization, thinking-only drops),
   before token estimation.  This is the durable repair for sessions
   ALREADY poisoned by older builds: the persisted stub rows are rebuilt
   to '' on every reload (_rows_to_conversation strips whitespace, so a
   DB-side pad can't survive) and only a send-time pad repairs them.

Verified: 485 tests pass across the four affected files; live replay of
a real poisoned session's resumed history against Moonshot via
OpenRouter returns HTTP 200 (was HTTP 400).
The empty-content pad loop called .strip() on am.get("content") without
checking the type.  Multimodal assistant turns carry content as a list
of parts (text/image), so a session with any image-bearing turn crashed
during request assembly:

    AttributeError: 'list' object has no attribute 'strip'

Guard with isinstance(content, str) — a multimodal turn is never the
empty textless shape the pad repairs.  Adds a regression test driving a
multimodal history through the loop.
…attening

Current main flattens multimodal assistant list-content to a plain string
before the send boundary, so the original assertion (list survives to the
wire) can't hold on this base. The test now asserts the load-bearing
contract instead: no crash, and the assistant turn's text is neither
dropped nor replaced by the pad. Adds a direct unit-shape check that the
pad predicate skips list content (the exact AttributeError shape from the
original bug) and pads only textless string turns.
@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/openrouter OpenRouter aggregator provider/kimi Kimi / Moonshot P2 Medium — degraded but workaround exists area/streaming Streaming responses: gateway delivery, provider wire sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 28, 2026
@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

૮ >ﻌ< ა ci review

ran on d70030d

ℹ️ Info

Desktop E2E visual evidence · View test artifacts · View job

1 visual diff.

inline evidence upload failed.

Failed to upload diff-665a0833239e-onboarding-overlay-diff.png with gh image (exit code 1): Error uploading /home/runner/work/_temp/e2e-evidence/diff-665a0833239e-onboarding-overlay-diff.png: step 0 (get upload token): uploadToken not found on repo page — do you have write access to NousResearch/hermes-agent? (or, if NousResearch enforces SAML SSO, authorize at https://github.com/orgs/NousResearch/sso)

Commentary-phase Codex turns persist with content:'' by design (their
text is delivered via the interim assistant callback), and the Responses
wire has no 'assistant must not be empty' validation — padding them
broke test_run_conversation_codex_continues_after_commentary_phase_message
in CI. Both the builder pad and the send-time pad now skip
api_mode=codex_responses. Keying on the ACTIVE api_mode preserves the
repair for codex-written sessions replayed through a strict
chat-completions provider.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/streaming Streaming responses: gateway delivery, provider wire comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists provider/kimi Kimi / Moonshot provider/openrouter OpenRouter aggregator sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades 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.

2 participants