mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
fix(streaming): detect text-only stream drops with no finish_reason (#32086)
When a streaming response ends cleanly (HTTP 200) with no finish_reason after delivering text but no tool calls, the chunk collector silently stamps finish_reason='stop' and the conversation loop presents truncated text as a complete response. Three stream-drop paths now exist after chunk collection: 1. Zero-chunk → EmptyStreamError, retried (existing) 2. Tool-call in progress, no finish_reason → partial-stream-stub (existing) 3. Text-only, no finish_reason → partial-stream-stub (NEW — this fix) Path 3 routes through the same PARTIAL_STREAM_STUB_ID + FINISH_REASON_LENGTH machinery as path 2. The conversation loop shows 'Stream interrupted — requesting continuation' and injects a continue prompt, giving the model a chance to resume where the stream dropped. Observed with DeepSeek provider where CloudFront drops SSE streams mid-response after delivering partial text.
This commit is contained in:
parent
134c2ed8b3
commit
65bb16c8ce
2 changed files with 73 additions and 0 deletions
|
|
@ -2998,6 +2998,40 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
|||
_dropped_tool_names=_dropped_names or None,
|
||||
)
|
||||
|
||||
# Text-only stream drop: the upstream closed the connection (or the
|
||||
# SSE stream simply ended) with no finish_reason after delivering
|
||||
# text content but no tool calls. Without this guard the partial
|
||||
# text is silently stamped finish_reason="stop" and the turn ends as
|
||||
# if complete — the model's intended next step is lost (#32086).
|
||||
_text_only_dropped_no_finish = (
|
||||
finish_reason is None
|
||||
and content_parts
|
||||
and not tool_calls_acc
|
||||
)
|
||||
if _text_only_dropped_no_finish:
|
||||
logger.warning(
|
||||
"Stream ended with no finish_reason after delivering text "
|
||||
"with no tool calls; treating as a mid-stream drop."
|
||||
)
|
||||
full_reasoning = "".join(reasoning_parts) or None
|
||||
mock_message = SimpleNamespace(
|
||||
role=role,
|
||||
content=full_content,
|
||||
tool_calls=None,
|
||||
reasoning_content=full_reasoning,
|
||||
)
|
||||
mock_choice = SimpleNamespace(
|
||||
index=0,
|
||||
message=mock_message,
|
||||
finish_reason=FINISH_REASON_LENGTH,
|
||||
)
|
||||
return SimpleNamespace(
|
||||
id=PARTIAL_STREAM_STUB_ID,
|
||||
model=model_name,
|
||||
choices=[mock_choice],
|
||||
usage=usage_obj,
|
||||
)
|
||||
|
||||
effective_finish_reason = finish_reason or "stop"
|
||||
if has_truncated_tool_args:
|
||||
effective_finish_reason = "length"
|
||||
|
|
|
|||
|
|
@ -230,6 +230,45 @@ class TestCleanStreamEndMidToolCall:
|
|||
assert response.id.startswith("stream-")
|
||||
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
|
||||
|
||||
@patch("run_agent.AIAgent._create_request_openai_client")
|
||||
@patch("run_agent.AIAgent._close_request_openai_client")
|
||||
def test_no_finish_reason_text_only_routes_to_stub(
|
||||
self, _mock_close, mock_create, monkeypatch,
|
||||
):
|
||||
"""A clean stream-end with no finish_reason after text-only
|
||||
delivery must route through the partial-stream-stub path so the
|
||||
conversation loop continues instead of silently accepting
|
||||
truncated text as a complete response (#32086)."""
|
||||
|
||||
def _clean_ending_stream():
|
||||
yield _make_stream_chunk(content="Let me compare the ")
|
||||
yield _make_stream_chunk(content="vision configs:")
|
||||
# falls off the end — clean close, no terminator
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.chat.completions.create.side_effect = (
|
||||
lambda *a, **kw: _clean_ending_stream()
|
||||
)
|
||||
mock_create.return_value = mock_client
|
||||
|
||||
agent = _make_agent()
|
||||
agent._fire_stream_delta = lambda text: None
|
||||
|
||||
response = agent._interruptible_streaming_api_call({})
|
||||
|
||||
assert response.id == PARTIAL_STREAM_STUB_ID, (
|
||||
"A clean stream-end with no finish_reason after text-only "
|
||||
"delivery must be tagged as a partial-stream stub, not "
|
||||
"silently accepted as complete (#32086)."
|
||||
)
|
||||
assert response.choices[0].finish_reason == FINISH_REASON_LENGTH
|
||||
assert response.choices[0].message.content == "Let me compare the vision configs:"
|
||||
assert response.choices[0].message.tool_calls is None
|
||||
assert getattr(response, "_dropped_tool_names", None) is None, (
|
||||
"Text-only drops must not carry dropped tool names — there "
|
||||
"were no tool calls in flight."
|
||||
)
|
||||
|
||||
|
||||
# ── Length-continuation prompt branching ──────────────────────────────────
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue