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>
This commit is contained in:
teknium1
2026-09-12 07:06:12 -07:00
committed by Teknium
parent 12fe7684e1
commit dfd4aa4a94
3 changed files with 104 additions and 7 deletions
+8 -3
View File
@@ -272,8 +272,9 @@ def settle_unrecovered_error(
# ``FailoverReason.billing`` (402) is deliberately NOT excluded: pool rotation and
# eager fallback already gave up, so retrying only burns paid requests on a depleted
# balance. Mirrors 401/403.
is_local_validation_error = _is_local_validation_error(api_error)
is_client_error = (
_is_local_validation_error(api_error)
is_local_validation_error
or (
not classified.retryable
and not classified.should_compress
@@ -304,12 +305,16 @@ def settle_unrecovered_error(
)
retry_count = 0
return _verdict("continue")
# ``should_fallback=False`` marks a deterministic failure no other provider can fix (the
# model's own malformed tool-call JSON, #12770): skip the cascade. Local validation errors
# carry no classifier verdict and keep their historical fallback.
fallback_allowed = classified.should_fallback or is_local_validation_error
# Announce the fallback only when a chain exists, else "trying fallback..." lies
# before a silent abort.
if agent._has_pending_fallback():
if fallback_allowed and agent._has_pending_fallback():
_label = _NONRETRYABLE_LABELS.get(classified.reason, f"Non-retryable error (HTTP {status_code})")
agent._buffer_status(f"⚠️ {_label} — trying fallback...")
if agent._try_activate_fallback():
if fallback_allowed and agent._try_activate_fallback():
# Direct ``return _verdict("break")`` is load-bearing: the restart handler
# re-runs the pre-API preflight against the fallback's context window.
active_system_prompt = _arm_fallback_restart(agent, api_messages, active_system_prompt, _retry)