Skip to content

fix(agent): inject streamed final_response into messages before persistence (#31269) - #31314

Closed
xxxigm wants to merge 2 commits into
NousResearch:mainfrom
xxxigm:fix/31269-bridge-assistant-reply-persistence
Closed

fix(agent): inject streamed final_response into messages before persistence (#31269)#31314
xxxigm wants to merge 2 commits into
NousResearch:mainfrom
xxxigm:fix/31269-bridge-assistant-reply-persistence

Conversation

@xxxigm

@xxxigm xxxigm commented May 24, 2026

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #31269: bridge-worker silently drops assistant replies from state.db.

The bridge worker (hermes_bridge.py) and state.db flush by index — _flush_messages_to_session_db writes messages[_last_flushed_db_idx:], so anything not in the structured messages list silently never reaches disk. A few break paths in run_conversation set final_response from already-streamed bytes WITHOUT appending the matching {"role": "assistant", "content": ...} dict:

  • Partial-stream recovery (conversation_loop.py:~3543) — stream died mid-flight, the streamed buffer is recovered as the reply, break.
  • Prior-turn-content fallback (conversation_loop.py:~3569) — empty follow-up after housekeeping tools, the earlier turn's text is reused, break.

In both cases the user saw the reply in the WebUI (it streamed through the Socket.IO callback) but state.db ended up with only the user message. The diagnostic in the issue captured this exactly:

[hermes-bridge-worker:default] [DBG-BUG2] NOTHING TO FLUSH! flush_from=490 msg_len=490 last_asst_role=assistant

— the slice was empty because the dict never landed.

Two small additions wired together:

  1. Safety-net helper (agent/conversation_loop.py) — adds module-level _ensure_final_response_in_messages(messages, final_response) that appends a structured {"role": "assistant", "content": <text>, "_injected_from_final_response": True} dict when the messages tail doesn't already carry the streamed reply. Idempotent on the happy text-response path (the loop already appended its own dict at line ~3833). Whitespace-tolerant matching avoids spurious double-injection. Skipped for None / empty / whitespace final_response and for the "(empty)" user-facing failure sentinel — those have their own persistence semantics. Wired in right before the FINAL _persist_session (after scaffolding cleanup so it doesn't fight the empty-response sentinel pop).

  2. Regression tests (tests/run_agent/test_final_response_injection_31269.py) — 15 focused tests including the bridge-worker diagnostic replay (_last_flushed_db_idx=1 covering the user turn, no assistant dict, asserts the SQLite append actually receives the assistant row).

Related Issue

Fixes #31269

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • agent/conversation_loop.py — new _EMPTY_RESPONSE_SENTINEL constant and _ensure_final_response_in_messages helper; integration call between _drop_trailing_empty_response_scaffolding and the FINAL _persist_session at run_conversation's tail; debug-level log when injection happened so post-mortems can correlate with the turn's exit reason.
  • tests/run_agent/test_final_response_injection_31269.py — 15 unit + end-to-end regression tests (NEW FILE).

Backwards compatible: every existing exit path keeps working, the helper is idempotent, the new _injected_from_final_response flag is a transparent debugging marker that no existing consumer reads.

How to Test

# New regression suite
python -m pytest tests/run_agent/test_final_response_injection_31269.py -v
# expected: 15 passed

# Adjacent run_agent suites — confirms no regression in persistence /
# dedup / scaffolding-cleanup paths
python -m pytest tests/run_agent/test_run_agent.py tests/run_agent/test_860_dedup.py tests/run_agent/test_final_response_injection_31269.py -q
# expected: 362 passed

# Focused conversation-loop / partial-stream / persist sweep
python -m pytest tests/run_agent -k 'conversation_loop or partial_stream or persist or session_db' -q
# expected: 33 passed

End-to-end behaviour after the fix (TypeScript WebUI → bridge worker → state.db):

User sends \"what's 2+2?\"
  ↓
Bridge worker → AIAgent.run_conversation()
  ↓ partial-stream recovery: stream died, _current_streamed_assistant_text = \"2+2 is 4.\"
  ↓ final_response = \"2+2 is 4.\"; break
  ↓ _drop_trailing_empty_response_scaffolding(messages)  # no scaffolding present
  ↓ _ensure_final_response_in_messages(messages, \"2+2 is 4.\")  # injects
  ↓     messages = [user(\"what's 2+2?\"), assistant(\"2+2 is 4.\", _injected=True)]
  ↓ _persist_session(messages, ...)
  ↓ _flush_messages_to_session_db: messages[1:] writes 1 row → state.db ✅

Checklist

  • Conventional Commits (fix(agent):, test(agent):)
  • 2 focused commits, single author
  • 15 new tests pass; 362 adjacent run_agent tests pass; 33 conversation-loop / persist tests pass
  • Tested on macOS 15.6 (darwin 24.6.0)
  • No new config keys, no schema migration
  • Idempotent on the happy text-response path (no duplicate writes)
  • Skips empty / sentinel final_response so empty-response semantics are preserved

xxxigm added 2 commits May 24, 2026 12:05
…stence (NousResearch#31269)

The bridge worker (``hermes_bridge.py``) and ``state.db`` flush by
index — ``_flush_messages_to_session_db`` writes
``messages[_last_flushed_db_idx:]`` so anything not in the structured
``messages`` list silently never reaches disk.

A few break paths in ``run_conversation`` set ``final_response`` from
already-streamed bytes WITHOUT appending the matching structured
``{"role": "assistant", "content": ...}`` dict:

  * partial-stream recovery (line ~3543) — stream died mid-flight, the
    streamed buffer is recovered as the reply.
  * prior-turn-content fallback (line ~3569) — empty follow-up after
    housekeeping tools, the earlier turn's text is reused.

In both cases the user *saw* the reply in the WebUI (it streamed
through the Socket.IO callback) but ``state.db`` ended up with only
the user message. The diagnostic in NousResearch#31269 captured this exactly:
``[DBG-BUG2] NOTHING TO FLUSH! flush_from=490 msg_len=490
last_asst_role=assistant`` — the slice was empty because the dict
never landed.

Add a safety net ``_ensure_final_response_in_messages`` helper called
right before the FINAL ``_persist_session`` (after scaffolding
cleanup so we don't fight the empty-response sentinel pop).  Idempotent
on the happy text-response path: when the loop already appended the
structured dict (line ~3833), the helper sees a matching tail and
no-ops.  Whitespace differences between ``final_response`` and the
existing tail content are tolerated to avoid spurious double-injection.

Skips injection for empty / whitespace-only text and for the
``"(empty)"`` user-facing failure sentinel — those paths have their
own persistence semantics that must not be overridden.

The injected message carries an ``_injected_from_final_response`` flag
so future debugging / analytics can tell apart genuine model dicts
from safety-net rebuilds without affecting any existing consumer of
the messages list.
…e injection

15 focused tests on ``_ensure_final_response_in_messages`` plus the
end-to-end ``_persist_session`` round-trip:

* ``TestEnsureFinalResponseInMessages`` — direct unit coverage of the
  helper's branches: missing tail, partial-stream-recovery shape (tool
  result tail), happy-path no-op, whitespace-tolerant matching, empty
  / whitespace / non-string ``final_response``, ``"(empty)"`` sentinel
  passthrough, ``assistant(tool_calls)`` tail injection, empty
  messages list, content-disagreement preservation.

* ``TestEndToEndBridgePersistence`` — replays the diagnostic from the
  issue (``_last_flushed_db_idx=1`` covering the user turn, no
  assistant dict in messages) and asserts the SQLite append actually
  receives the assistant row after injection.  Also asserts the happy
  path produces exactly one assistant write (no duplication when the
  conversation loop already appended its own dict).
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint labels May 24, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competing fix with #31291 for the same issue #31269 — both inject _ensure_final_response_in_messages() into conversation_loop.py before persistence to fix bridge-worker silently dropping assistant replies from state.db. Recommend consolidating into a single PR.

@teknium1

teknium1 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

Thanks @xxxigm — clean root-cause writeup, and the two break paths you identified (partial_stream_recovery, fallback_prior_turn_content in conversation_loop.py) are exactly right. Closing because the mechanism this PR proposes — injecting the recovered final_response into messages before persistence (_ensure_final_response_in_messages()) — is one we evaluated and deliberately did not adopt.

The origin issue #31269 was closed NOT_PLANNED, and the competing fix #31291 (same helper, same two paths) was closed as implemented_on_main with the note: "The PR's proposed _ensure_final_response_in_messages() helper was not adopted, and the linked issue discussion correctly identified the bridge/cursor path as the real failure. Since current main now fixes that observable persistence failure with stronger flush semantics, this PR is redundant."

The real bug was at the flush layer, and that's where it was fixed:

Backfilling the recovered fragment into durable history as a complete assistant turn also works against #53987, which intentionally leaves response_was_previewed=False so the gateway surfaces the truncation + continue-flow rather than persisting a dead-mid-sentence turn as final.

Closing as superseded — the observable persistence failure is covered on current main. Same close we already made on the identical #31291. Appreciated the diligence.

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

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P2 Medium — degraded but workaround exists type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Bridge-worker silently drops assistant replies from state.db (streamed responses not in messages list)

3 participants