Files
hermes-agent/tests/agent/test_malformed_tool_args_no_fallback.py
T
teknium1 dfd4aa4a94 fix(agent): malformed tool-call-argument 400s no longer walk the fallback chain
When a proxy (Ollama, OpenRouter) rejects the MODEL's own unparseable
tool-call JSON with `400 invalid tool call arguments`, the classifier returned
the generic format_error verdict (`should_fallback=True`) and the non-retryable
client-error path cascaded through every fallback provider: 4-5 sequential
calls, 20-60s per occurrence, ending on a model that produced the same broken
JSON (#12770).

- error_classifier: explicit `_MALFORMED_TOOL_ARGS_PATTERNS` checked before the
  request-validation and overflow heuristics, returning format_error with
  `retryable=False, should_fallback=False`.
- turn_api_error: the client-error settlement honours `should_fallback`; the
  verdicts that legitimately reach that branch (policy block, TLS chain, MoA
  shape/preset errors) now state `should_fallback=True` explicitly, so the gate
  changes behaviour only for the new verdict. Local validation errors keep
  their historical fallback.

Fixes #12770. Pattern list and gating approach from #16022 by @cuyua9 (stale
base); tests trimmed to two invariants.

Co-authored-by: cuyua9 <2114364329@qq.com>
2026-09-12 08:27:11 -07:00

76 lines
3.0 KiB
Python

"""A 400 for the model's own malformed tool-call JSON must not walk the fallback chain (#12770)."""
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from agent.error_classifier import FailoverReason, classify_api_error
from agent.turn_api_error import settle_unrecovered_error
class _Err(Exception):
status_code = 400
response = None
def __init__(self, message, body=None):
super().__init__(message)
self.body = body or {"error": {"message": message, "type": "invalid_request_error"}}
@pytest.mark.parametrize("wording", ["invalid tool call arguments", "Invalid function_call arguments"])
def test_malformed_tool_args_400_is_terminal_without_fallback(wording):
"""Large session included: the bare-ish message must not read as context overflow either."""
verdict = classify_api_error(_Err(f"Error code: 400 - {wording}"), provider="ollama",
approx_tokens=90_000, context_length=128_000, num_messages=120)
assert verdict.reason is FailoverReason.format_error
assert (verdict.retryable, verdict.should_compress, verdict.should_fallback) == (False, False, False)
# Unrelated request-shape 400s keep their fallback (another provider may accept the request).
assert classify_api_error(_Err("Unsupported parameter: 'max_tokens'")).should_fallback is True
class _Agent:
"""Only the fallback seam is real; every other helper the terminal path touches is a no-op."""
log_prefix = ""
verbose = False
provider = "ollama"
_fallback_chain = [object()]
_fallback_index = 0
_credential_pool = None
def __init__(self):
self.activated = []
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 test_client_error_settlement_skips_fallback_when_classifier_says_so():
agent = _Agent()
retry = SimpleNamespace(copilot_stale_cred_retry_attempted=False, primary_recovery_attempted=False)
err = _Err("invalid tool call arguments")
classified = classify_api_error(err, provider="ollama")
with patch("agent.conversation_loop._is_copilot_provider", lambda a: False):
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="ollama", _base="http://127.0.0.1:11434/v1", _model="glm", 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,
)
assert verdict.action == "return"
assert verdict.result["failure_reason"] == FailoverReason.format_error.value
assert agent.activated == []