hermes-agent/tests/run_agent/test_stream_stale_circuit_breaker.py
dsad 985e19c110 fix(agent): add cross-turn stream-stale circuit breaker (#58962)
A session wedged against an unresponsive OpenAI-compatible provider can hit the stale-stream detector on every turn and loop forever, burning the full 180s x retries each turn with no response. Issue #58962 reports 494 consecutive failures over 3+ days on a single session.

The streaming retry path already caps retries WITHIN a turn (HERMES_STREAM_RETRIES, default 2) but has no cross-turn cap. Once a session's conversation state makes every turn stale, it retries indefinitely across turns and never notifies the user.

Add a per-session consecutive-stale-stream counter on the agent:
- incremented on every stale-stream kill in the outer poll loop;
- reset to 0 only when a stream actually completes;
- when it reaches HERMES_STREAM_STALE_GIVEUP (default 5), the next turn aborts immediately with a clear, actionable RuntimeError instead of spending 180s x retries again.

This is distinct from the existing stale-stream work (local-provider hard ceiling #44938, backoff/parse-error #60031): those bound a single hung stream, while this bounds repeated cross-turn staleness and surfaces a user-visible error.

Adds tests/run_agent/test_stream_stale_circuit_breaker.py covering the short-circuit, the success-reset, and the increment.
2026-07-08 01:50:14 +05:30

125 lines
4.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cross-turn stream-stale circuit breaker (issue #58962).
A session wedged against an unresponsive provider can hit the stale-stream
detector on every turn and loop forever, burning the full 180s×retries each
turn with no response (observed: 494 consecutive failures over 3+ days).
These tests cover the guard added to ``interruptible_streaming_api_call``:
- a session that has already tripped the consecutive-stale threshold short
circuits immediately (no network attempt, no 180s wait) with a clear error;
- a successful stream resets the consecutive-stale streak;
- a stale-stream kill increments the consecutive-stale streak.
The harness mirrors tests/run_agent/test_28161_anthropic_stream_pool_cleanup.py.
"""
import threading
import httpx
import pytest
from unittest.mock import MagicMock
from types import SimpleNamespace
def _make_anthropic_agent(**kwargs):
from run_agent import AIAgent
defaults = dict(
api_key="test-key",
base_url="https://example.com/v1",
model="claude-opus-4-7",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
defaults.update(kwargs)
agent = AIAgent(**defaults)
agent.api_mode = "anthropic_messages"
agent._anthropic_client = MagicMock()
agent._anthropic_api_key = "test-anthropic-key"
return agent
def _good_stream_cm():
"""Context manager whose stream yields no events and returns a valid message."""
cm = MagicMock()
stream = MagicMock()
stream.__iter__ = MagicMock(return_value=iter([]))
msg = MagicMock()
msg.content = []
msg.stop_reason = "end_turn"
msg.usage = SimpleNamespace(input_tokens=10, output_tokens=5)
stream.get_final_message = MagicMock(return_value=msg)
cm.__enter__ = MagicMock(return_value=stream)
cm.__exit__ = MagicMock(return_value=False)
return cm
class TestStreamStaleCircuitBreaker:
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
def test_short_circuits_when_streak_at_threshold(self, monkeypatch):
"""A session already past the consecutive-stale threshold must abort
immediately without opening a stream or waiting out the stale timeout."""
monkeypatch.setenv("HERMES_STREAM_STALE_GIVEUP", "3")
agent = _make_anthropic_agent()
agent._consecutive_stale_streams = 3 # simulate prior wedged turns
# The stream must never be opened on the short-circuit path.
with pytest.raises(RuntimeError, match="unresponsive"):
agent._interruptible_streaming_api_call({})
agent._anthropic_client.messages.stream.assert_not_called()
# The streak is NOT reset on the short-circuit so subsequent turns
# keep failing fast instead of re-attempting forever.
assert agent._consecutive_stale_streams == 3
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
def test_success_resets_streak(self, monkeypatch):
"""A stream that completes successfully clears the consecutive-stale
streak so a recovered provider resumes normally."""
monkeypatch.setenv("HERMES_STREAM_STALE_GIVEUP", "3")
agent = _make_anthropic_agent()
agent._consecutive_stale_streams = 2 # below the giveup=3 threshold
agent._anthropic_client.messages.stream.return_value = _good_stream_cm()
resp = agent._interruptible_streaming_api_call({})
assert resp is not None
assert agent._consecutive_stale_streams == 0
@pytest.mark.filterwarnings("ignore::pytest.PytestUnhandledThreadExceptionWarning")
def test_stale_kill_increments_streak(self, monkeypatch):
"""Each stale-stream kill increments the consecutive-stale streak so a
wedged session eventually trips the breaker."""
monkeypatch.setenv("HERMES_STREAM_STALE_TIMEOUT", "0.1")
monkeypatch.setenv("HERMES_STREAM_STALE_GIVEUP", "50")
agent = _make_anthropic_agent()
agent._consecutive_stale_streams = 0
unblock = threading.Event()
def _blocking_gen():
unblock.wait(timeout=5.0)
raise httpx.ConnectError("connection dropped after close()")
yield # make this a generator so next() triggers the wait
def _stream_side_effect(*args, **kwargs):
cm = MagicMock()
stream = MagicMock()
stream.__iter__ = MagicMock(return_value=_blocking_gen())
cm.__enter__ = MagicMock(return_value=stream)
cm.__exit__ = MagicMock(return_value=False)
return cm
# Every attempt blocks, trips the stale detector, and fails.
agent._anthropic_client.messages.stream.side_effect = _stream_side_effect
agent._anthropic_client.close.side_effect = unblock.set
with pytest.raises(Exception):
agent._interruptible_streaming_api_call({})
# At least one stale kill happened; the streak must have advanced.
assert agent._consecutive_stale_streams >= 1