Files
hermes-agent/tests/agent/test_outer_error_retry_bound.py
teknium1 76e88cae2a test: repoint cross-test imports at the moved modules; opt one backoff test out of the merged agent fixture
Twenty-three tests imported helpers from sibling test modules by dotted
path (tests.run_agent.test_run_agent, tests.cli.test_cli_init,
tests.gateway.test_42039_duplicate_user_message); those follow the moves
and renames. The run_agent autouse fixture that zeroes jittered_backoff
now covers all of tests/agent, where one pre-existing test asserts the
real backoff is positive — it opts out via a real_retry_backoff marker,
the same pattern real_concurrent_gate and real_agent_prewarm use.
2026-09-13 09:18:02 -07:00

206 lines
8.6 KiB
Python

"""Tests for issue #92450 — outer-loop error retries must be bounded even
when the turn budget (``max_iterations``) is unlimited.
Before the fix, the outer ``except`` in ``run_conversation`` only left the
loop on a local-processing error (#66267) or when
``api_call_count >= agent.max_iterations - 1``. With the default budget now
unlimited (``sys.maxsize``), a permanent failure that escaped the inner
retry/fallback machinery spun forever (~64 retries/s measured in the issue),
pegged a core, and overwrote days of rotated agent.log history within
minutes.
Injection points reflect the real escape path: exceptions raised INSIDE the
inner retry loop (transport errors from ``create()``, normalization,
fallbacks, ...) never reach the outer handler — that machinery recovers or
terminates on its own. The outer handler sees failures from the final
response assembly, e.g. ``_build_assistant_message`` (a chat-completion-
helpers callable, so such crashes classify as RETRYABLE, never local —
exactly the spin shape reported in the issue).
The fix adds a small per-turn cap on total outer-loop exceptions
(``_MAX_OUTER_LOOP_ERRORS``, scaled down by a tiny explicit
``max_iterations``). These tests pin the behavior contract:
* repeated escaping errors must terminate the turn within the cap;
* a turn that recovers after early escaping failures must complete normally;
* local-processing errors still exit immediately (#66267 unchanged);
* a finite ``max_iterations`` still exhausts via the original near-limit path.
"""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
@pytest.fixture()
def loop_agent():
"""AIAgent with a mocked OpenAI client (mirrors test_run_agent's fixture)
so we can stage responses on ``.chat.completions.create``."""
from run_agent import AIAgent
from tests.agent.test_run_agent import _mock_response
with (
patch("model_tools.get_tool_definitions", return_value=[]),
patch("model_tools.check_toolset_requirements", return_value={}),
patch("agent.process_bootstrap.OpenAI"),
):
agent = AIAgent(
api_key="test-key-1234567890",
base_url="https://openrouter.ai/api/v1",
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
)
agent.client = MagicMock()
agent._cached_system_prompt = "You are helpful."
agent._use_prompt_caching = False
agent.tool_delay = 0
agent.compression_enabled = False
agent.save_trajectories = False
# Every API call itself SUCCEEDS — #92450's spin happens after the
# response arrives, when final-response assembly keeps crashing.
agent.client.chat.completions.create.side_effect = (
lambda *a, **k: _mock_response(content="ok", finish_reason="stop")
)
return agent
class _PermanentError(Exception):
"""An error type no recovery path classifies as retryable-local."""
def _make_local_frame_raiser():
"""Compile a raiser whose frame filename lives in a local-processing
module, so the production traceback classifier (#66267) sees it as a
deterministic local bug — without mocking away the classifier itself."""
namespace = {}
code = compile(
"def _raise(exc):\n raise exc\n",
filename="/agent/agent_runtime_helpers.py",
mode="exec",
)
exec(code, namespace)
return namespace["_raise"]
class TestOuterErrorRetryBound:
def test_repeated_escaping_errors_are_bounded(self, loop_agent):
"""A permanent final-assembly failure that escapes every retry layer
must end the turn within the per-turn error cap instead of spinning
forever under the unlimited default budget."""
boom = _PermanentError("permanent assembly contract violation")
with (
patch.object(
loop_agent, "_build_assistant_message", side_effect=boom
),
patch.object(loop_agent, "_persist_session"),
patch.object(loop_agent, "_save_trajectory"),
patch.object(loop_agent, "_cleanup_task_resources"),
):
result = loop_agent.run_conversation("hello")
# The bound path returns an apology as final_response with
# failed=True (the turn did not complete successfully), so the
# meaningful assertions are: bounded call count + dedicated exit
# reason + apology text + failed flag.
assert result["api_calls"] <= 8, (
"The loop must give up within the per-turn error bound instead "
"of retrying an unlimited-budget failure forever."
)
assert result["turn_exit_reason"].startswith("repeated_outer_errors"), (
f"unexpected exit reason: {result['turn_exit_reason']}"
)
assert "repeated errors" in (result["final_response"] or "")
assert result["failed"] is True
assert result["completed"] is False
def test_recovery_after_failures_completes_normally(self, loop_agent):
"""Early escaping failures followed by success must NOT trip the new
bound — the turn completes and the reply is delivered."""
good_msg = {
"role": "assistant",
"content": "Recovered fine.",
"finish_reason": "stop",
}
with (
patch.object(
loop_agent,
"_build_assistant_message",
side_effect=[
_PermanentError("transient assembly hiccup"),
_PermanentError("transient assembly hiccup"),
dict(good_msg),
],
),
patch.object(loop_agent, "_persist_session"),
patch.object(loop_agent, "_save_trajectory"),
patch.object(loop_agent, "_cleanup_task_resources"),
):
result = loop_agent.run_conversation("hello")
# final_response comes from the raw provider content ("ok"), so the
# recovery contract is: clean text_response exit exactly on the 3rd
# call, marked completed — proving the two earlier escapes did not
# trip the new bound.
assert result["completed"] is True, (
f"turn should recover: exit={result.get('turn_exit_reason')}"
)
assert result["failed"] is False
assert result["api_calls"] == 3
assert result["turn_exit_reason"].startswith("text_response"), (
f"unexpected exit reason: {result['turn_exit_reason']}"
)
def test_local_processing_error_still_exits_immediately(self, loop_agent):
"""#66267 regression guard: a deterministic local bug exits on first
occurrence — the new counter must not delay or change that exit."""
raiser = _make_local_frame_raiser()
boom = TypeError("list content fed into a str regex helper")
with (
patch.object(
loop_agent,
"_strip_think_blocks",
side_effect=lambda text: raiser(boom),
),
patch.object(loop_agent, "_persist_session"),
patch.object(loop_agent, "_save_trajectory"),
patch.object(loop_agent, "_cleanup_task_resources"),
):
result = loop_agent.run_conversation("hello")
assert result["turn_exit_reason"].startswith("local_processing_error"), (
f"unexpected exit reason: {result['turn_exit_reason']}"
)
assert result["api_calls"] == 1, (
"A local processing error must stop immediately on first "
"occurrence, not consume retries."
)
def test_finite_budget_still_governs_near_limit_exit(self, loop_agent):
"""With a small explicit max_iterations the ORIGINAL near-limit path
fires (unchanged reason string), not the new repeated-error path."""
loop_agent.max_iterations = 3
boom = _PermanentError("permanent assembly failure")
with (
patch.object(
loop_agent, "_build_assistant_message", side_effect=boom
),
patch.object(loop_agent, "_persist_session"),
patch.object(loop_agent, "_save_trajectory"),
patch.object(loop_agent, "_cleanup_task_resources"),
):
result = loop_agent.run_conversation("hello")
# Budget of 3 → the legacy guard fires on error #2 (api_call_count 2
# >= 3 - 1); the new cap would fire at error #3, so the legacy path
# must win here.
assert result["turn_exit_reason"].startswith(
"error_near_max_iterations"
), f"legacy near-limit path must govern: {result['turn_exit_reason']}"