fix: prevent session poisoning from empty partial-stream-stub assistant turns - #73028
Merged
Conversation
…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.
Contributor
૮ >ﻌ< ა ci reviewran on d70030d ℹ️ InfoDesktop E2E visual evidence · View test artifacts · View job1 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.
This was referenced Jul 28, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
agent/conversation_loop.py(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 (continuation stitching from Silent failure: stream timeout after partial text delivery shows no error to user #32086 unchanged).agent/chat_completion_helpers.py(build_assistant_message): textless assistant turns are padded to a single space (same trick as the reasoning_content pad, [Bug]: DeepSeek V4 Flash Discord session poisoned when tool-call assistant lacks reasoning_content #15250/reasoning_content dropped in multi-turn tool calls with DeepSeek v4 (causes HTTP 400) #17400). Tool-call turns exempt.agent/conversation_loop.py(send boundary): durable repair for already-poisoned sessions — a persistedcontent:''textless assistant row is padded at request-assembly time (a DB-side pad can't survive_rows_to_conversation's whitespace strip).isinstance(str)guard skips multimodal list content. Runs beforeapply_anthropic_cache_controlso cache-marked list blocks are never touched.Validation
{"role":"assistant","content":""}persisted" "at send, replays cleanTargeted 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