feat: per-turn primary runtime restoration and transport recovery (#4624)
Makes provider fallback turn-scoped in long-lived CLI sessions. Previously, a single transient failure pinned the session to the fallback provider for every subsequent turn. - _primary_runtime dict snapshot at __init__ (model, provider, base_url, api_mode, client_kwargs, compressor state) - _restore_primary_runtime() at top of run_conversation() — restores all state, resets fallback chain index - _try_recover_primary_transport() — one extra recovery cycle (client rebuild + cooldown) for transient transport errors on direct endpoints before fallback - Skipped for aggregator providers (OpenRouter, Nous) - 25 tests Inspired by #4612 (@betamod). Closes #4612.
This commit is contained in:
200
run_agent.py
200
run_agent.py
@@ -1236,6 +1236,34 @@ class AIAgent:
|
||||
else:
|
||||
print(f"📊 Context limit: {self.context_compressor.context_length:,} tokens (auto-compression disabled)")
|
||||
|
||||
# Snapshot primary runtime for per-turn restoration. When fallback
|
||||
# activates during a turn, the next turn restores these values so the
|
||||
# preferred model gets a fresh attempt each time. Uses a single dict
|
||||
# so new state fields are easy to add without N individual attributes.
|
||||
_cc = self.context_compressor
|
||||
self._primary_runtime = {
|
||||
"model": self.model,
|
||||
"provider": self.provider,
|
||||
"base_url": self.base_url,
|
||||
"api_mode": self.api_mode,
|
||||
"api_key": getattr(self, "api_key", ""),
|
||||
"client_kwargs": dict(self._client_kwargs),
|
||||
"use_prompt_caching": self._use_prompt_caching,
|
||||
# Compressor state that _try_activate_fallback() overwrites
|
||||
"compressor_model": _cc.model,
|
||||
"compressor_base_url": _cc.base_url,
|
||||
"compressor_api_key": getattr(_cc, "api_key", ""),
|
||||
"compressor_provider": _cc.provider,
|
||||
"compressor_context_length": _cc.context_length,
|
||||
"compressor_threshold_tokens": _cc.threshold_tokens,
|
||||
}
|
||||
if self.api_mode == "anthropic_messages":
|
||||
self._primary_runtime.update({
|
||||
"anthropic_api_key": self._anthropic_api_key,
|
||||
"anthropic_base_url": self._anthropic_base_url,
|
||||
"is_anthropic_oauth": self._is_anthropic_oauth,
|
||||
})
|
||||
|
||||
def reset_session_state(self):
|
||||
"""Reset all session-scoped token counters to 0 for a fresh session.
|
||||
|
||||
@@ -4770,6 +4798,156 @@ class AIAgent:
|
||||
logging.error("Failed to activate fallback %s: %s", fb_model, e)
|
||||
return self._try_activate_fallback() # try next in chain
|
||||
|
||||
# ── Per-turn primary restoration ─────────────────────────────────────
|
||||
|
||||
def _restore_primary_runtime(self) -> bool:
|
||||
"""Restore the primary runtime at the start of a new turn.
|
||||
|
||||
In long-lived CLI sessions a single AIAgent instance spans multiple
|
||||
turns. Without restoration, one transient failure pins the session
|
||||
to the fallback provider for every subsequent turn. Calling this at
|
||||
the top of ``run_conversation()`` makes fallback turn-scoped.
|
||||
|
||||
The gateway creates a fresh agent per message so this is a no-op
|
||||
there (``_fallback_activated`` is always False at turn start).
|
||||
"""
|
||||
if not self._fallback_activated:
|
||||
return False
|
||||
|
||||
rt = self._primary_runtime
|
||||
try:
|
||||
# ── Core runtime state ──
|
||||
self.model = rt["model"]
|
||||
self.provider = rt["provider"]
|
||||
self.base_url = rt["base_url"] # setter updates _base_url_lower
|
||||
self.api_mode = rt["api_mode"]
|
||||
self.api_key = rt["api_key"]
|
||||
self._client_kwargs = dict(rt["client_kwargs"])
|
||||
self._use_prompt_caching = rt["use_prompt_caching"]
|
||||
|
||||
# ── Rebuild client for the primary provider ──
|
||||
if self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
self._anthropic_api_key = rt["anthropic_api_key"]
|
||||
self._anthropic_base_url = rt["anthropic_base_url"]
|
||||
self._anthropic_client = build_anthropic_client(
|
||||
rt["anthropic_api_key"], rt["anthropic_base_url"],
|
||||
)
|
||||
self._is_anthropic_oauth = rt["is_anthropic_oauth"]
|
||||
self.client = None
|
||||
else:
|
||||
self.client = self._create_openai_client(
|
||||
dict(rt["client_kwargs"]),
|
||||
reason="restore_primary",
|
||||
shared=True,
|
||||
)
|
||||
|
||||
# ── Restore context compressor state ──
|
||||
cc = self.context_compressor
|
||||
cc.model = rt["compressor_model"]
|
||||
cc.base_url = rt["compressor_base_url"]
|
||||
cc.api_key = rt["compressor_api_key"]
|
||||
cc.provider = rt["compressor_provider"]
|
||||
cc.context_length = rt["compressor_context_length"]
|
||||
cc.threshold_tokens = rt["compressor_threshold_tokens"]
|
||||
|
||||
# ── Reset fallback chain for the new turn ──
|
||||
self._fallback_activated = False
|
||||
self._fallback_index = 0
|
||||
|
||||
logging.info(
|
||||
"Primary runtime restored for new turn: %s (%s)",
|
||||
self.model, self.provider,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.warning("Failed to restore primary runtime: %s", e)
|
||||
return False
|
||||
|
||||
# Which error types indicate a transient transport failure worth
|
||||
# one more attempt with a rebuilt client / connection pool.
|
||||
_TRANSIENT_TRANSPORT_ERRORS = frozenset({
|
||||
"ReadTimeout", "ConnectTimeout", "PoolTimeout",
|
||||
"ConnectError", "RemoteProtocolError",
|
||||
})
|
||||
|
||||
def _try_recover_primary_transport(
|
||||
self, api_error: Exception, *, retry_count: int, max_retries: int,
|
||||
) -> bool:
|
||||
"""Attempt one extra primary-provider recovery cycle for transient transport failures.
|
||||
|
||||
After ``max_retries`` exhaust, rebuild the primary client (clearing
|
||||
stale connection pools) and give it one more attempt before falling
|
||||
back. This is most useful for direct endpoints (custom, Z.AI,
|
||||
Anthropic, OpenAI, local models) where a TCP-level hiccup does not
|
||||
mean the provider is down.
|
||||
|
||||
Skipped for proxy/aggregator providers (OpenRouter, Nous) which
|
||||
already manage connection pools and retries server-side — if our
|
||||
retries through them are exhausted, one more rebuilt client won't help.
|
||||
"""
|
||||
if self._fallback_activated:
|
||||
return False
|
||||
|
||||
# Only for transient transport errors
|
||||
error_type = type(api_error).__name__
|
||||
if error_type not in self._TRANSIENT_TRANSPORT_ERRORS:
|
||||
return False
|
||||
|
||||
# Skip for aggregator providers — they manage their own retry infra
|
||||
if self._is_openrouter_url():
|
||||
return False
|
||||
provider_lower = (self.provider or "").strip().lower()
|
||||
if provider_lower in ("nous", "nous-research"):
|
||||
return False
|
||||
|
||||
try:
|
||||
# Close existing client to release stale connections
|
||||
if getattr(self, "client", None) is not None:
|
||||
try:
|
||||
self._close_openai_client(
|
||||
self.client, reason="primary_recovery", shared=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Rebuild from primary snapshot
|
||||
rt = self._primary_runtime
|
||||
self._client_kwargs = dict(rt["client_kwargs"])
|
||||
self.model = rt["model"]
|
||||
self.provider = rt["provider"]
|
||||
self.base_url = rt["base_url"]
|
||||
self.api_mode = rt["api_mode"]
|
||||
self.api_key = rt["api_key"]
|
||||
|
||||
if self.api_mode == "anthropic_messages":
|
||||
from agent.anthropic_adapter import build_anthropic_client
|
||||
self._anthropic_api_key = rt["anthropic_api_key"]
|
||||
self._anthropic_base_url = rt["anthropic_base_url"]
|
||||
self._anthropic_client = build_anthropic_client(
|
||||
rt["anthropic_api_key"], rt["anthropic_base_url"],
|
||||
)
|
||||
self._is_anthropic_oauth = rt["is_anthropic_oauth"]
|
||||
self.client = None
|
||||
else:
|
||||
self.client = self._create_openai_client(
|
||||
dict(rt["client_kwargs"]),
|
||||
reason="primary_recovery",
|
||||
shared=True,
|
||||
)
|
||||
|
||||
wait_time = min(3 + retry_count, 8)
|
||||
self._vprint(
|
||||
f"{self.log_prefix}🔁 Transient {error_type} on {self.provider} — "
|
||||
f"rebuilt client, waiting {wait_time}s before one last primary attempt.",
|
||||
force=True,
|
||||
)
|
||||
time.sleep(wait_time)
|
||||
return True
|
||||
except Exception as e:
|
||||
logging.warning("Primary transport recovery failed: %s", e)
|
||||
return False
|
||||
|
||||
# ── End provider fallback ──────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
@@ -6408,6 +6586,11 @@ class AIAgent:
|
||||
# Installed once, transparent when streams are healthy, prevents crash on write.
|
||||
_install_safe_stdio()
|
||||
|
||||
# If the previous turn activated fallback, restore the primary
|
||||
# runtime so this turn gets a fresh attempt with the preferred model.
|
||||
# No-op when _fallback_activated is False (gateway, first turn, etc.).
|
||||
self._restore_primary_runtime()
|
||||
|
||||
# Sanitize surrogate characters from user input. Clipboard paste from
|
||||
# rich-text editors (Google Docs, Word, etc.) can inject lone surrogates
|
||||
# that are invalid UTF-8 and crash JSON serialization in the OpenAI SDK.
|
||||
@@ -6826,10 +7009,11 @@ class AIAgent:
|
||||
api_start_time = time.time()
|
||||
retry_count = 0
|
||||
max_retries = 3
|
||||
primary_recovery_attempted = False
|
||||
max_compression_attempts = 3
|
||||
codex_auth_retry_attempted = False
|
||||
anthropic_auth_retry_attempted = False
|
||||
nous_auth_retry_attempted = False
|
||||
codex_auth_retry_attempted=False
|
||||
anthropic_auth_retry_attempted=False
|
||||
nous_auth_retry_attempted=False
|
||||
has_retried_429 = False
|
||||
restart_with_compressed_messages = False
|
||||
restart_with_length_continuation = False
|
||||
@@ -7664,6 +7848,16 @@ class AIAgent:
|
||||
}
|
||||
|
||||
if retry_count >= max_retries:
|
||||
# Before falling back, try rebuilding the primary
|
||||
# client once for transient transport errors (stale
|
||||
# connection pool, TCP reset). Only attempted once
|
||||
# per API call block.
|
||||
if not primary_recovery_attempted and self._try_recover_primary_transport(
|
||||
api_error, retry_count=retry_count, max_retries=max_retries,
|
||||
):
|
||||
primary_recovery_attempted = True
|
||||
retry_count = 0
|
||||
continue
|
||||
# Try fallback before giving up entirely
|
||||
self._emit_status(f"⚠️ Max retries ({max_retries}) exhausted — trying fallback...")
|
||||
if self._try_activate_fallback():
|
||||
|
||||
Reference in New Issue
Block a user