Skip to content

fix(agent): skip empty partial-stream assistant turns - #47280

Closed
Qwinty wants to merge 2 commits into
NousResearch:mainfrom
Qwinty:fix/empty-partial-stream-stub
Closed

fix(agent): skip empty partial-stream assistant turns#47280
Qwinty wants to merge 2 commits into
NousResearch:mainfrom
Qwinty:fix/empty-partial-stream-stub

Conversation

@Qwinty

@Qwinty Qwinty commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Skips appending an empty assistant history turn when a PARTIAL_STREAM_STUB_ID has no recoverable text and no tool calls. In that case the loop still asks the model to continue from the stream interruption, but it no longer sends a synthetic assistant message with neither content nor tool_calls.

This fixes a strict Chat Completions failure observed in production with Gemini through CLIProxyAPI:

tool(result) -> assistant(content="") -> user(network-error continuation)
HTTP 400 INVALID_ARGUMENT: Request contains an invalid argument.

The bad message is created by Hermes' partial-stream recovery path before the request reaches the proxy. A proxy-side sanitizer could mask it, but Hermes has the semantic context to avoid adding the synthetic empty assistant turn at all.

Related Issue

No separate issue filed. This is related to, but distinct from:

Those cover partial-stream continuation and empty assistant content with tool_calls; this PR covers the empty partial-stream-stub case with no recoverable text and no tool calls.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • agent/conversation_loop.py
    • Detect empty PARTIAL_STREAM_STUB_ID messages before building an interim assistant history row.
    • Preserve the existing continuation prompt and retry path.
    • Leave normal output-length truncation and non-empty partial-stream stubs unchanged.
  • tests/run_agent/test_partial_stream_finish_reason.py
    • Add a regression test proving the second request contains no empty assistant turn and still continues successfully.

How to Test

  1. Reproduce with a partial-stream-stub response whose message has content=None, tool_calls=None, and finish_reason="length".
  2. Verify the continuation request ends with the existing network-error user prompt.
  3. Verify the continuation request does not include an assistant message with empty content and no tool_calls.

Commands run:

/usr/local/lib/hermes-agent/venv/bin/python -m pytest -q -o addopts='' tests/run_agent/test_partial_stream_finish_reason.py
/usr/local/lib/hermes-agent/venv/bin/python -m py_compile agent/conversation_loop.py tests/run_agent/test_partial_stream_finish_reason.py
/usr/local/lib/hermes-agent/venv/bin/python -m ruff check agent/conversation_loop.py tests/run_agent/test_partial_stream_finish_reason.py
git diff --check

Results:

  • 10 passed
  • All checks passed!
  • git diff --check clean

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(scope):, feat(scope):, etc.)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/ -q and all tests pass
  • I've added tests for my changes (required for bug fixes, strongly encouraged for features)
  • I've tested on my platform: Ubuntu 24.04

Documentation & Housekeeping

  • I've updated relevant documentation (README, docs/, docstrings) — or N/A
  • I've updated cli-config.yaml.example if I added/changed config keys — or N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — or N/A
  • I've considered cross-platform impact (Windows, macOS) per the compatibility guide — or N/A
  • I've updated tool descriptions/schemas if I changed tool behavior — or N/A

Screenshots / Logs

Observed production failure shape, redacted:

[
  {"role": "tool", "content": "..."},
  {"role": "assistant", "content": ""},
  {
    "role": "user",
    "content": "[System: The previous response was cut off by a network error mid-stream. Continue exactly where you left off. Do not restart or repeat prior text. Finish the answer directly.]"
  }
]

The upstream returned:

{"error":{"code":400,"message":"Request contains an invalid argument.","status":"INVALID_ARGUMENT"}}

@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 P2 Medium — degraded but workaround exists labels Jun 16, 2026

@tonydwb tonydwb left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

Verdict: Approved

What was changed

Fix for Gemini compatibility: when a partial-stream stub has no recoverable visible text (empty content, no tool calls) after a network reset, the loop now skips appending the empty assistant message. Previously this caused Gemini to reject the history on the next request.

The fix

Correctly gates the _build_assistant_message + messages.append call behind a check: (not _is_partial_stream_stub) or (not _trunc_content_empty). This is logically sound and prevents the empty message from being added while still requesting continuation.

Testing

New test test_empty_partial_stream_stub_skips_empty_assistant_turn covers the specific scenario. Also moves the _is_partial_stream_stub check earlier to avoid recalculating it.

Security

No concerns.


Reviewed by Hermes Agent

@Qwinty

Qwinty commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

Rebased this PR onto current upstream/main and resolved the partial-stream recovery conflicts by preserving the newer 4-continuation budget/content-filter fallback work while keeping this PR's empty-assistant-turn guard.

Local verification after rebase:

  • python -m pytest -q -o 'addopts=' tests/run_agent/test_partial_stream_finish_reason.py → 14 passed
  • python -m ruff check agent/conversation_loop.py tests/run_agent/test_partial_stream_finish_reason.py → passed

@Qwinty
Qwinty force-pushed the fix/empty-partial-stream-stub branch from 7d03cbd to 9591f18 Compare July 3, 2026 16:50
@Qwinty
Qwinty marked this pull request as ready for review July 3, 2026 16:50

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

Thanks for isolating the empty partial-stream-stub case. The underlying main-path append is still present at agent/conversation_loop.py:1904-1947.

Problems

  • The new skip at agent/conversation_loop.py:1801 can leave an initial multimodal user message (content list) directly followed by the string continuation user message. repair_message_sequence() only merges adjacent user messages when both contents are strings (agent/agent_runtime_helpers.py:529-550); ChatCompletionsTransport does not subsequently normalize role order (agent/transports/chat_completions.py:141-205). That can replace the empty-assistant failure with consecutive user roles on strict endpoints.

Suggested changes

  • Coalesce the continuation with the preceding user message using list/string-safe handling, and add a list-content regression. The current test uses a string prompt at tests/run_agent/test_partial_stream_finish_reason.py:397, so it does not cover this path.

Automated hermes-sweeper review.

Comment thread agent/conversation_loop.py
@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 14, 2026
@Qwinty
Qwinty force-pushed the fix/empty-partial-stream-stub branch from 33a9229 to d65d1dc Compare July 14, 2026 19:25
@Qwinty
Qwinty force-pushed the fix/empty-partial-stream-stub branch from d65d1dc to 9d3aafe Compare July 19, 2026 13:30
@Qwinty

Qwinty commented Jul 19, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto current upstream/main.

Kept main's _CODEX_INCOMPLETE_NUDGE and re-applied _append_continuation_user_message + empty partial-stream coalescing on top.

Local verification:

python -m pytest -q -o 'addopts=' tests/run_agent/test_partial_stream_finish_reason.py
# 15 passed

@teknium1

Copy link
Copy Markdown
Contributor

Resolved on main via PR #73028 (salvage of #68041). Your PR was submitted first for this layer — you identified the empty partial-stream-stub persistence bug a full month before anyone else, and the merged fix's loop guard is exactly your approach (skip appending the stub when there's no text and no tool calls, keep the continuation nudge). The merged version also adds two more layers on top (builder pad + send-time repair for already-poisoned sessions), which is why we merged that branch, but first-finder credit is yours. Thanks @Qwinty! Commit 309f06b.

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 sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-caching Sweeper risk: may break/degrade prompt caching or cache-key stability (invariant) sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants