Files
hermes-agent/tests/agent/test_retry_exhaustion_partial_retention.py
kshitijk4poor bfeafa2d68 fix(agent): collapse orphan continuation trail on retry exhaustion (#119001)
Ports #119027::finalize_continuation_partial: when a stream drop entered the
continuation path and the next request exhausted retries before a token, the
_length_continuation_fragment/_nudge rows were persisted as-is, so resume
replayed a dangling synthetic user nudge. Collapse them into one assistant row
before persistence and feed the collapsed text to the #119081 partial-retention
path (the fragment rows are gone by then).

Co-authored-by: fangliquan <fangliquan@qq.com>
2026-09-24 22:33:58 +05:30

141 lines
5.4 KiB
Python

"""#119001: a 429 / network error that exhausts retries AFTER partial output was
delivered must retain that output instead of ending the turn error-only.
Repro: attempt 1 streams text then dies mid-stream (partial-stub path appends a
``_length_continuation_fragment`` + nudge to ``messages``); the continuation
attempt 429s before the stream starts and every retry re-hits the limit.
``build_api_request`` resets ``_current_streamed_assistant_text`` per attempt,
so the only record of the delivered text is the fragment rows — the terminal
builders below must recover it, flag ``partial``, and keep ``final_response``
distinct from ``error`` (the gateway/desktop retention contract).
"""
from __future__ import annotations
from agent.error_classifier import classify_api_error
from agent.turn_recovery import (
max_retries_exhausted_result,
nonretryable_client_error_result,
)
class _Agent:
log_prefix = ""
verbose = False
verbose_logging = False
provider = "openrouter"
model = "m"
base_url = "https://openrouter.ai/api/v1"
_current_streamed_assistant_text = ""
def _summarize_api_error(self, error):
return str(error)
def _has_pending_fallback(self):
return False
def _try_activate_fallback(self, **_kw):
return False
def __getattr__(self, name):
return lambda *args, **kwargs: None
class _Http(Exception):
def __init__(self, status_code, message):
super().__init__(message)
self.status_code = status_code
PARTIAL = "Here is the first half of the report, already shown to the user."
def _messages_with_fragment():
return [
{"role": "user", "content": "write a long report"},
{
"role": "assistant",
"content": PARTIAL,
"_length_continuation_fragment": True,
},
{
"role": "user",
"content": "continue where you left off",
"_length_continuation_nudge": True,
},
]
def _exhausted(messages, agent=None):
error = _Http(429, "HTTP 429: RequestBurstTooFast — slow down traffic growth")
classified = classify_api_error(error, provider="openrouter", model="m")
assert classified.reason.value == "rate_limit"
return max_retries_exhausted_result(
agent or _Agent(), error, classified, max_retries=3, is_rate_limited=True,
error_msg=str(error).lower(), api_kwargs=None, api_messages=[], messages=messages,
conversation_history=None, api_call_count=3, approx_tokens=10, provider="openrouter",
base_url="https://openrouter.ai/api/v1", model="m",
)
def test_exhausted_429_keeps_delivered_partial():
result = _exhausted(_messages_with_fragment())
assert result.get("partial") is True
assert PARTIAL in result["final_response"]
# Gateway retention contract: final must differ from the error string.
assert result["final_response"].strip() != str(result["error"]).strip()
assert result["failure_reason"] == "rate_limit"
assert result["failed"] is True
def test_exhausted_429_without_partial_is_unchanged():
result = _exhausted([])
assert "partial" not in result
assert "/retry" in result["final_response"]
assert result["failure_reason"] == "rate_limit"
def test_exhausted_429_live_accumulator_counts_as_delivered():
agent = _Agent()
agent._current_streamed_assistant_text = "fresh streamed text"
result = _exhausted([], agent=agent)
assert result.get("partial") is True
assert "fresh streamed text" in result["final_response"]
def test_whitespace_only_fragment_is_not_partial():
messages = _messages_with_fragment()
messages[1] = dict(messages[1], content=" ")
result = _exhausted(messages)
assert "partial" not in result
def test_nonretryable_terminal_keeps_delivered_partial():
error = _Http(400, "HTTP 400: Bad request")
classified = classify_api_error(error, provider="openrouter", model="m")
result = nonretryable_client_error_result(
_Agent(), error, classified, status_code=400, api_kwargs=None, api_messages=[],
messages=_messages_with_fragment(), conversation_history=None, api_call_count=1,
approx_tokens=10, provider="openrouter",
base_url="https://openrouter.ai/api/v1", model="m",
)
assert result.get("partial") is True
assert PARTIAL in result["final_response"]
assert result["final_response"].strip() != str(result["error"]).strip()
def test_exhausted_429_collapses_continuation_trail_into_one_assistant_row():
messages = _messages_with_fragment()
error = _Http(429, "HTTP 429: RequestBurstTooFast — slow down traffic growth")
classified = classify_api_error(error, provider="openrouter", model="m")
result = max_retries_exhausted_result(
_Agent(), error, classified, max_retries=3, is_rate_limited=True,
error_msg=str(error).lower(), api_kwargs=None, api_messages=[], messages=messages,
conversation_history=None, api_call_count=3, approx_tokens=10, provider="openrouter",
base_url="https://openrouter.ai/api/v1", model="m", current_turn_user_idx=0,
)
# No dangling synthetic nudge: the turn persists as user -> one assistant row.
assert [m["role"] for m in messages] == ["user", "assistant"]
assert messages[1]["content"] == PARTIAL
assert not any(m.get("_length_continuation_nudge") for m in messages)
assert result.get("partial") is True and PARTIAL in result["final_response"]