Files
hermes-agent/tests/agent/test_reasoning_disable_recovery.py
teknium1 64dceb9c26 fix: drop the reasoning fields when the route rejects an enabled reasoning level
A structured reasoning-field 400 on an ENABLED config (#100536:
`reasoning.effort: max` on a Responses relay) reached the
reasoning_mandatory rung, which only knows how to drop a disable: for an
enabled config _reasoning_config_for_wire returned the config unchanged,
so the one-shot retry resent the identical request (a guaranteed second
400) under a notice that blamed 'disabling reasoning'.

Record what the wire builder actually sent (agent._wire_reasoning_config)
and, when the rejected request carried an enabled effort, omit the
reasoning fields for the rest of the session — the route default, as the
auxiliary ladder's _without_reasoning_fields already does — under a
'rejects reasoning effort <x>' notice. A rejected disable keeps the
existing contract: the user's own config goes out verbatim on the retry.

Test drives classify_api_error -> recover_after_classification ->
_build_api_kwargs_for_mode and asserts the retry's reasoning_config is
None; control keeps the disable copy and the verbatim resend.
2026-09-19 01:19:57 -07:00

164 lines
6.9 KiB
Python

"""Main-loop recovery for a route that rejects a reasoning disable by field name (#114460).
The classifier maps the rejection to ``reasoning_mandatory``; ``recover_after_classification``
drops the disable once; a SECOND rejection in the same turn means the configured reasoning
controls themselves are refused, so the settle path takes the fallback chain instead of
replaying the identical request until ``max_retries``.
"""
from types import SimpleNamespace
from unittest.mock import patch
from agent.error_classifier import FailoverReason, classify_api_error
from agent.turn_retry_state import TurnRetryState
_REVERSED_400 = "reasoning_effort 'none' unsupported; use minimal|low|medium|high|xhigh"
class _FakeApiError(Exception):
def __init__(self, status_code, message):
super().__init__(f"Error code: {status_code} - {{'error': {{'message': {message!r}}}}}")
self.status_code = status_code
self.message = message
self.body = {"error": {"message": message, "type": "invalid_request_error"}}
class _Agent:
log_prefix = ""
verbose = False
provider = "custom"
model = "chat-only"
_fallback_chain = [object()]
_fallback_index = 0
_credential_pool = None
def __init__(self):
self.activated = []
self._reasoning_disable_rejected = False
def _recover_with_credential_pool(self, **kwargs):
return False, False
def _has_pending_fallback(self):
return True
def _try_activate_fallback(self, **kwargs):
self.activated.append(True)
return True
def _summarize_api_error(self, error):
return str(error)
def __getattr__(self, name):
return lambda *args, **kwargs: None
def _settle(disable_drop_attempted):
from agent.turn_api_error import settle_unrecovered_error
agent = _Agent()
err = _FakeApiError(400, _REVERSED_400)
classified = classify_api_error(err, provider="custom", model=agent.model)
assert classified.reason == FailoverReason.reasoning_mandatory
retry = SimpleNamespace(
reasoning_mandatory_retry_attempted=disable_drop_attempted, image_shrink_retry_attempted=False,
copilot_stale_cred_retry_attempted=False, primary_recovery_attempted=False,
restart_with_redirected_messages=False,
)
with patch("agent.conversation_loop._is_copilot_provider", lambda a: False), patch(
"agent.conversation_loop._arm_fallback_restart", lambda agent, msgs, prompt, retry: prompt
), patch("agent.turn_api_error.compute_error_backoff", lambda *a, **k: 0), patch(
"agent.turn_api_error.interruptible_backoff_sleep", lambda *a, **k: None
):
verdict = settle_unrecovered_error(
agent, api_error=err, classified=classified, _retry=retry, status_code=400, error_msg=str(err),
is_context_length_error=False, is_rate_limited=False, _is_zai_coding_overload=False,
_provider="custom", _base="http://relay.example/v1", _model=agent.model, messages=[],
api_messages=[], api_kwargs={}, active_system_prompt="", conversation_history=None,
approx_tokens=10, retry_count=0, max_retries=3, compression_attempts=0, api_call_count=1,
)
return agent, verdict
def test_spent_disable_drop_falls_back_instead_of_replaying_the_request():
agent, verdict = _settle(disable_drop_attempted=True)
assert verdict.action == "break"
assert agent.activated == [True]
# Control: before the drop has run the verdict stays retryable (the rung gets its shot;
# the one-shot drop itself is pre-existing ``turn_recovery`` behaviour, not asserted here).
agent, verdict = _settle(disable_drop_attempted=False)
assert verdict.action == "fallthrough"
assert agent.activated == []
# #100536: a Responses relay rejects an unsupported reasoning LEVEL (``reasoning.effort: max`` on
# an ENABLED config) with a message-less structured 400. The classifier routes it to
# reasoning_mandatory; the rung must drop the effort for the retry instead of resending it.
_STRUCTURED_LEVEL_400 = (
"Error code: 400 - {'error': {'param': 'reasoning.effort', "
"'error_code': 'invalid_reasoning_effort', 'retryable': False}}"
)
class _WireAgent(_Agent):
api_mode = "chat_completions"
base_url = "http://relay.example/v1"
request_overrides = None
def __init__(self, reasoning_config):
super().__init__()
self.reasoning_config = reasoning_config
self.notices = []
# ``_Agent.__getattr__`` fabricates callables for unknown names; wire flags must read False.
self._ephemeral_reasoning_off = False
self._reasoning_effort_rejected = False
self._fast_until = 0.0
self.service_tier = None
def _vprint(self, line, **kwargs):
self.notices.append(line)
def _wire_reasoning_config(agent):
"""What the main-loop request builder hands the transport as ``reasoning_config``."""
from agent.chat_completion_helpers import _build_api_kwargs_for_mode
def _capture(agent, api_messages, tools_for_api, reasoning_config, request_overrides, cache_scope_id):
return {"reasoning_config": reasoning_config}
with patch("agent.chat_completion_helpers._build_chat_completions_kwargs", _capture):
return _build_api_kwargs_for_mode(agent, [], [])["reasoning_config"]
def _recover(agent, message):
from agent.turn_recovery import recover_after_classification
err = _FakeApiError(400, message)
classified = classify_api_error(err, provider="custom", model=agent.model)
assert classified.reason == FailoverReason.reasoning_mandatory
retry = TurnRetryState()
with patch("hermes_cli.models_reasoning_caps.refresh_reasoning_caps_async", lambda provider: None):
retry_now, _ = recover_after_classification(
agent, err, classified, retry, status_code=400, error_context={}, messages=[], api_messages=[],
)
assert retry_now is True
return agent
def test_rejected_reasoning_level_retries_without_the_effort():
agent = _WireAgent({"enabled": True, "effort": "max"})
assert _wire_reasoning_config(agent) == {"enabled": True, "effort": "max"} # the request that 400ed
_recover(agent, _STRUCTURED_LEVEL_400)
assert _wire_reasoning_config(agent) is None, "retry must not resend reasoning.effort=max"
assert any("rejects reasoning effort max" in n for n in agent.notices)
assert not any("rejects disabling reasoning" in n for n in agent.notices)
# Control: a rejected DISABLE keeps the existing contract — the user's own enabled config
# goes out verbatim on the retry and the notice names the disable.
agent = _WireAgent({"enabled": True, "effort": "high"})
agent._ephemeral_reasoning_off = True
assert _wire_reasoning_config(agent) == {"enabled": False, "effort": "none"} # the request that 400ed
_recover(agent, _REVERSED_400)
assert _wire_reasoning_config(agent) == {"enabled": True, "effort": "high"}
assert any("rejects disabling reasoning" in n for n in agent.notices)