Skip to content

fix(agent): emit recovery-path final response through stream_delta_callback - #31477

Closed
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/agent-stream-recovery-sites-31449
Closed

fix(agent): emit recovery-path final response through stream_delta_callback#31477
briandevans wants to merge 3 commits into
NousResearch:mainfrom
briandevans:fix/agent-stream-recovery-sites-31449

Conversation

@briandevans

Copy link
Copy Markdown
Contributor

What does this PR do?

Closes the same blank-stream gap teknium1 fixed at the guardrail-halt site in #31448, but at the two structurally-identical sibling sites in agent/conversation_loop.py that #31449 calls out:

  • partial_stream_recovery (~L3566) — final_response = _recovered (text reconstructed from a partial stream). The recovered text wasn't necessarily streamed in full this turn.
  • fallback_prior_turn_content (~L3593) — final_response = agent._strip_think_blocks(fallback).strip() where fallback is the previous turn's content. That text was streamed on the previous SSE response, so the current SSE writer drains an empty queue.

Both sites set _response_was_previewed = True (so the gateway suppresses its own final send) and then break — but never actually push the assembled text through agent.stream_delta_callback. SSE/TUI clients see a finish chunk with zero content delta, indistinguishable from a crash for Open WebUI and similar clients.

The fix mirrors teknium1's pattern from #31448: push final_response and then None through the callback inside a try/except, immediately before break. No _safe_print here — unlike the guardrail-halt site, the CLI has already seen the partial / prior-turn content on screen, and re-printing would look like a stray echo.

Mirrors the post-execute pattern from #31448's guardrail-halt site. Helper extraction (teknium1's optional follow-up suggestion in #31449) is intentionally left for a separate refactor pass so this PR doesn't conflict with #31448's still-open diff on the same file.

Related Issue

Fixes #31449

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 — at both partial_stream_recovery and fallback_prior_turn_content sites, push final_response and then None through stream_delta_callback before break. Mirrors the try/except pattern from fix(agent): emit guardrail-halt message to client before closing stream #31448.

  • tests/run_agent/test_run_agent.py — two new tests under TestRunConversation:

    • test_partial_stream_recovery_emits_final_response_through_stream_callback
    • test_fallback_prior_turn_content_emits_final_response_through_stream_callback

    Each asserts (a) the recovered text reaches the callback as a text delta, (b) the callback is closed with None so the SSE writer can drain, and (c) response_previewed remains True so the gateway suppresses its own final send.

How to Test

  1. Focused tests for the two new branches:
    uv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest \
      tests/run_agent/test_run_agent.py::TestRunConversation::test_partial_stream_recovery_emits_final_response_through_stream_callback \
      tests/run_agent/test_run_agent.py::TestRunConversation::test_fallback_prior_turn_content_emits_final_response_through_stream_callback -v
    
  2. Adjacent regression coverage (existing partial-stream / guardrail / fallback / streaming tests):
    uv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest \
      tests/run_agent/test_run_agent.py \
      tests/run_agent/test_streaming.py \
      tests/run_agent/test_partial_stream_finish_reason.py \
      tests/run_agent/test_tool_call_guardrail_runtime.py -q
    
    341 + 54 = 395 tests pass locally.
  3. Gateway suppression coverage (the response_previewed consumer side):
    uv run --with pytest --with pytest-xdist --with pytest-asyncio python3 -m pytest \
      tests/gateway/test_duplicate_reply_suppression.py \
      tests/gateway/test_run_progress_topics.py -q
    
    52 tests pass locally — gateway's existing suppress-on-response_previewed logic still triggers, so this fix does not introduce duplicate delivery for streamed clients.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (fix(agent): ...)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix (no unrelated commits)
  • I've run focused tests for the touched code and all pass
  • I've added tests for my changes (regression for both sibling sites)
  • I've tested on my platform: macOS 15.x

Documentation & Housekeeping

  • I've updated relevant documentation — N/A (no user-facing API change)
  • I've updated cli-config.yaml.example if I added/changed config keys — N/A
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) — N/A (pure Python control-flow, no platform branches)
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A

Related / Positioning

This is the explicit follow-up issue teknium1 filed in #31449 to keep #31448 narrowly scoped to the reported guardrail-halt bug. The recovery-path sites have the same failure shape (blank Open WebUI bubble) but trigger less frequently, so they're worth a separate PR rather than bundling onto #31448.

Audited siblings: enumerated every break site in agent/conversation_loop.py that re-assigns final_response to text not (fully) streamed this turn. The three sites that match are: the guardrail-halt site (handled by #31448), partial_stream_recovery, and fallback_prior_turn_content. The other break sites in the same loop either return the assistant message's own content (already streamed this turn) or set final_response = "(empty)" after exhausted retries (no real content to deliver). No widening needed beyond the two sites in this PR.

Copilot AI review requested due to automatic review settings May 24, 2026 12:22

Copilot AI 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.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

This PR addresses a regression where, on certain “empty final response” exit paths, the agent returns a recovered final_response but fails to emit that text through stream_delta_callback, causing SSE/TUI clients to see a finish chunk with no content.

Changes:

  • Emit recovered/prior-turn final text via agent.stream_delta_callback(...) for partial_stream_recovery and fallback_prior_turn_content exit reasons.
  • Add regression tests ensuring the recovered text is streamed and the callback is closed (None) for both exit paths.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
tests/run_agent/test_run_agent.py Adds regression tests to validate recovered responses are streamed and callback is closed.
agent/conversation_loop.py Pushes recovered/prior-turn final text through stream_delta_callback before breaking out of the loop.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +3123 to +3126
# Callback should also have been closed so the SSE writer can drain.
assert deltas[-1] is None, (
f"stream_delta_callback was not closed with None; deltas={deltas!r}"
)
Comment thread tests/run_agent/test_run_agent.py Outdated
Comment on lines +3172 to +3174
assert deltas[-1] is None, (
f"stream_delta_callback was not closed with None; deltas={deltas!r}"
)
Comment thread agent/conversation_loop.py Outdated
Comment on lines +3587 to +3592
if final_response and agent.stream_delta_callback:
try:
agent.stream_delta_callback(final_response)
agent.stream_delta_callback(None)
except Exception:
pass
Comment thread agent/conversation_loop.py Outdated
Comment on lines +3625 to +3630
if final_response and agent.stream_delta_callback:
try:
agent.stream_delta_callback(final_response)
agent.stream_delta_callback(None)
except Exception:
pass
Comment thread agent/conversation_loop.py Outdated
Comment on lines +3578 to +3586
# Push the recovered text through the streaming
# callback so SSE/TUI clients see it before the
# finish chunk lands. The recovered text wasn't
# necessarily streamed in full this turn — the
# current SSE writer drains an empty queue and
# emits a finish chunk with zero content delta
# otherwise, indistinguishable from a crash
# (#31449, mirrors the guardrail-halt site in
# #31448).
Comment thread agent/conversation_loop.py Outdated
Comment on lines +3619 to +3624
# Push the prior-turn content through the streaming
# callback so SSE/TUI clients see it before the
# finish chunk lands. The text was streamed on the
# *previous* SSE response, so the current SSE writer
# drains an empty queue otherwise (#31449, mirrors
# the guardrail-halt site in #31448).
@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 comp/gateway Gateway runner, session dispatch, delivery labels May 24, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competes with #31478 for fixing #31449. Both address recovery-path text not streamed to SSE/TUI clients.

This PR covers partial_stream_recovery + fallback_prior_turn_content with inline callbacks and None sentinel flush. #31478 also covers the guardrail-halt site and uses a helper function but omits the None sentinel.

@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot All review findings addressed in f61a584e7:

  • Extracted helper (_flush_synthesized_final_to_stream) — both branches now call the same code path, so future changes to closure/error-handling semantics stay in sync.
  • try/finally around emit + close — the None close sentinel always fires, even if callback(text) raises. SSE writers drain instead of hanging on a half-emitted recovery turn.
  • Emit and close in independent try/except blockslogger.debug(..., exc_info=True) replaces the silent except Exception: pass so callback failures surface in debug logs without breaking the surrounding turn-exit flow.
  • Test assertions tightenedassert deltas and deltas[-1] is None so an empty callback list fails the intended assertion with a clear message instead of an IndexError traceback.

One deliberate hold: the empty-final_response guard (if not text: return) is preserved. Closing the stream when there is no synthesized text to deliver would double-fire None against the same writer that L3457 already closed before tool execution; the recovery branches are only entered with real text to deliver in the bug scenario from #31449.

The 3 failing CI tests (test_discord_role_config_bypasses_gateway_allowlist, test_msgraph_webhook::test_connect_requires_client_state, test_feishu_approval_buttons::TestCardActionCallbackResponse::*) reproduce on clean origin/main (3bace07) with identical errors — they are baseline failures, not in touched code.

@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from f61a584 to 3332d5e Compare May 24, 2026 14:10
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 3332d5e to ea765ae Compare May 27, 2026 00:12
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from ea765ae to 7bc8fbb Compare May 27, 2026 23:18
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 7bc8fbb to 00467c9 Compare May 29, 2026 18:15
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 00467c9 to 3412031 Compare May 30, 2026 16:16
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 3412031 to 755334b Compare May 30, 2026 17:17
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 755334b to 5203da0 Compare May 31, 2026 02:14
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 5203da0 to fa311dc Compare May 31, 2026 04:19
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from fa311dc to a9b76e2 Compare May 31, 2026 11:14
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from e5c78ed to 6a95677 Compare June 3, 2026 02:20
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 6a95677 to 619e69c Compare June 3, 2026 04:17
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 619e69c to 49f9de7 Compare June 3, 2026 07:17
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 49f9de7 to 0d05ec3 Compare June 3, 2026 10:17
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 0d05ec3 to d5a00ed Compare June 3, 2026 22:15
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from d5a00ed to 93958cb Compare June 4, 2026 02:16
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 93958cb to 5440566 Compare June 4, 2026 03:19
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 5440566 to 8cdf7cd Compare June 4, 2026 09:19
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 8cdf7cd to 1e01604 Compare June 4, 2026 18:21
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 1e01604 to 6e8a67b Compare June 5, 2026 00:16
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 6e8a67b to 5824803 Compare June 5, 2026 08:18
@briandevans
briandevans force-pushed the fix/agent-stream-recovery-sites-31449 branch from 5824803 to 35d0037 Compare June 5, 2026 11:17
@briandevans

Copy link
Copy Markdown
Contributor Author

@copilot All findings addressed.

  • conversation_loop.py (dedup + blanket-except): the two recovery break sites (partial_stream_recovery, fallback_prior_turn_content) now both call the extracted _flush_synthesized_final_to_stream(agent, text) helper (commit 97a07229d). It emits optional text and always attempts the None close in a try/finally, with emit and close guarded independently and logged at logger.debug(..., exc_info=True) instead of except Exception: pass.
  • test_run_agent.py (IndexError on empty deltas): both assertion sites now use assert deltas and deltas[-1] is None so a callback that never fired reports the intended message instead of an IndexError traceback. The second site (test_fallback_prior_turn_content_emits_final_response_through_stream_callback) is fixed in commit 7bddfe3ee; the first was fixed in 97a07229d.

CI reds on this PR are baseline-only (test_compression_concurrent_sessions lock-serialization + s6-overlay build 504), not in touched code.

@briandevans

Copy link
Copy Markdown
Contributor Author

@alt-glitch heads-up — #31478 was closed on 2026-06-04 without merging, so this is now the only open fix remaining for #31449. It's green across CI and covers both recovery-path sites that weren't streaming to SSE/TUI clients (partial_stream_recovery + the fallback branch), routed through a single shared flush helper. Ready for review whenever you have a moment.

…llback

When the conversation loop exits via `partial_stream_recovery` (~L3566)
or `fallback_prior_turn_content` (~L3593), `final_response` is assigned
to text that was not (fully) streamed this turn:

- `partial_stream_recovery` recovers from `_current_streamed_assistant_text`
  after a partial stream died — only what landed before the disconnect
  was streamed.
- `fallback_prior_turn_content` reuses the previous turn's user-visible
  content (delivered alongside housekeeping tool calls) — that text was
  streamed on the *previous* SSE response, so the current SSE writer
  drains an empty queue.

Both sites then `break` out of the loop without pushing the assembled
text through `agent.stream_delta_callback`.  SSE/TUI clients see a
finish chunk with zero content delta — indistinguishable from a crash
for Open WebUI and similar clients.  `_response_was_previewed = True`
is already set, so the gateway also suppresses its own final send,
making the user-visible failure complete.

Mirrors the pattern teknium1 used at the guardrail-halt site in NousResearch#31448:
push `final_response` and then `None` through the callback inside a
try/except.  Unlike NousResearch#31448, no `_safe_print` here — the CLI has already
seen the partial / prior-turn content on screen, and re-printing would
look like a stray echo.

Tests cover both sites and assert (a) the recovered text reaches the
callback as a text delta, (b) the callback is closed with `None` so
the SSE writer can drain, and (c) `response_previewed` is still set
so the gateway suppresses duplicate delivery.

Refs NousResearch#31449
Addresses Copilot review on NousResearch#31477:

- Extract `_flush_synthesized_final_to_stream(agent, text)` helper to
  remove the duplicated emit-then-close logic across the
  partial_stream_recovery and fallback_prior_turn_content branches.
- Use `try/finally` so the `None` close sentinel always fires when
  `callback(text)` raises — SSE writers still drain instead of hanging
  on a half-emitted recovery turn.
- Split emit-text and close into independent `try/except` blocks so a
  single bad callback can't break the surrounding turn-exit flow; both
  log at debug level via `logger.debug(..., exc_info=True)` instead of
  silently swallowing exceptions.
- Robustify regression tests so `deltas[-1] is None` only runs when
  `deltas` is non-empty — failing the intended assertion with a clear
  message instead of an `IndexError` traceback if the callback is
  never invoked.

Behavior is unchanged for non-empty `final_response`. Empty
`final_response` is still a no-op (no synthesized text to deliver and
no streamed content this turn that would otherwise be left dangling).
@briandevans

Copy link
Copy Markdown
Contributor Author

Re-verified against current main: issue #31449's recovery-path delivery is already handled. Commit e860a40e1 (the salvage of #41498) landed the gateway-fallback design — it leaves response_previewed=false on partial_stream_recovery so the gateway delivers the recovered fragment plus the turn-completion explainer, and the fallback_prior_turn_content path already previews its content. This PR takes the opposite callback-emission approach (stream_delta_callback flush + response_previewed=true), which now conflicts with that design — combining the two double-delivers on the gateway, and this PR's regression test (asserting response_previewed is True) contradicts main's (which asserts False). The user-facing gap is fixed on main, so this change is no longer needed. Closing as superseded — thanks!

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 comp/gateway Gateway runner, session dispatch, delivery 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.

guardrail-halt-style silent stream close at partial_stream_recovery + fallback_prior_turn_content sites

3 participants