Files
hermes-agent/agent/route_binding.py
Teknium 52ccae4740 refactor: move the Codex recovery rungs out of turn_recovery
turn_recovery.py crossed the 2,000-line gate once the reasoning-replay recovery
landed beside it. The stale-encrypted-reasoning rungs and the replay-verdict
reset are one topic; they move verbatim to agent/turn_recovery_codex.py.
2026-10-10 19:22:07 -07:00

152 lines
8.2 KiB
Python

"""Bind a provider route onto a live agent in place, and put the primary route back.
``bind_route_entry`` is the fallback chain's swap (client, pool, compressor, reasoning,
extra_body) shared with per-turn routes such as the voice-chat model; ``reinstall_primary_runtime``
is the inverse that ``restore_primary_runtime`` runs once its gates pass.
"""
from __future__ import annotations
import logging
from typing import Any, Dict, Optional, Tuple
from utils import base_url_host_matches
logger = logging.getLogger(__name__)
def bind_route_entry(agent: Any, entry: dict[str, Any], provider: str, model: str) -> Optional[tuple[str, str]]:
"""Swap ``agent`` onto ``entry`` (a fallback-chain-shaped dict). Returns ``(old_model,
old_provider)``, or None when the provider has no usable client. Sets ``_fallback_activated``
so the next turn's ``restore_primary_runtime`` reverts it; raises on a failed swap."""
from agent.auxiliary_client import resolve_provider_client
from agent.chat_completion_helpers import (
_fallback_api_mode_hint, _fallback_api_mode_resolved, _rebind_fallback_credential_pool,
_reresolve_fallback_reasoning_config, _rescope_fallback_extra_body, _update_fallback_context_compressor,
)
from hermes_cli.fallback_config import resolve_entry_api_key
# Pass the entry's base_url/api_key so custom endpoints (Ollama Cloud) resolve instead
# of falling through to OpenRouter defaults.
base_url_hint = (entry.get("base_url") or "").strip() or None
api_key_hint = resolve_entry_api_key(entry)
api_mode_explicit, api_mode = _fallback_api_mode_hint(entry, provider, base_url_hint)
# Ollama Cloud: OLLAMA_API_KEY from env when the entry has no key. Host match, not
# substring — GHSA-76xc-57q6-vm5m.
if base_url_hint and base_url_host_matches(base_url_hint, "ollama.com") and not api_key_hint:
from agent.secret_scope import get_secret
api_key_hint = get_secret("OLLAMA_API_KEY") or None
# raw_codex=True: the main agent needs direct responses.stream() access for Codex providers.
client, _resolved_model = resolve_provider_client(
provider, model=model, raw_codex=True, explicit_base_url=base_url_hint, explicit_api_key=api_key_hint,
api_mode=api_mode)
if client is None:
return None
if provider == "moa":
# A MoA entry means the preset itself, exactly like ``provider: moa`` in config. The client
# is the preset's aggregator and only proves the preset resolves; bind the facade with the
# same pins every other MoA build site uses (#112525, #112623).
base_url, api_mode = "moa://local", "chat_completions"
else:
try:
from hermes_cli.model_normalize import normalize_model_for_provider
model = normalize_model_for_provider(model, provider)
except Exception as norm_err: # health: allow BLE001 -- moved verbatim from try_activate_fallback; a bad catalog entry keeps the raw id
logger.warning("Could not normalize fallback model %r for provider %r: %s", model, provider, norm_err)
base_url = str(client.base_url)
from hermes_cli.providers import is_actual_route
if is_actual_route(provider, base_url):
api_mode = "chat_completions"
elif not api_mode_explicit and api_mode == "chat_completions":
api_mode = _fallback_api_mode_resolved(agent, provider, model, base_url)
old_model, old_provider, old_base_url = agent.model, agent.provider, agent.base_url
# Clear the per-config context_length override so the new model's own context window is
# resolved instead of the previous model's stale value (#22387).
agent._config_context_length = None
agent.model, agent.provider, agent.requested_provider = model, provider, provider
agent.base_url, agent.api_mode = base_url, api_mode
# reasoning_content echo opt-in travels with the active provider; restore_primary_runtime reverts it.
agent._reasoning_echo_flag = bool(entry.get("reasoning_echo", False))
if hasattr(agent, "_transport_cache"):
agent._transport_cache.clear()
from agent.turn_recovery_codex import reset_codex_reasoning_replay
reset_codex_reasoning_replay(agent)
agent._fallback_activated = True
_rebind_fallback_credential_pool(agent, provider, model)
if provider == "moa":
from agent.moa_loop import bind_moa_runtime
bind_moa_runtime(agent, model)
else:
from agent.client_lifecycle import _swap_fallback_clients
_swap_fallback_clients(agent, client, provider, model, base_url, api_mode)
from agent.agent_runtime_helpers import sync_credential_pool_entry_id
sync_credential_pool_entry_id(agent)
agent._use_prompt_caching, agent._use_native_cache_layout = agent._anthropic_prompt_cache_policy(
provider=provider, base_url=base_url, api_mode=api_mode, model=model)
agent._ensure_lmstudio_runtime_loaded() # LM Studio: preload before probing context length
_update_fallback_context_compressor(agent)
_reresolve_fallback_reasoning_config(agent)
_rescope_fallback_extra_body(agent, old_model, old_provider, old_base_url)
return old_model, old_provider
def reinstall_runtime_snapshot(agent: Any, rt: dict[str, Any]) -> None:
"""Put ``agent`` on the runtime recorded in ``rt`` (a ``_primary_runtime``-shaped snapshot):
identity, client, caching flags, compressor, reasoning and prompt identity. Credential pool
and fallback bookkeeping are the caller's. Raises on failure."""
from agent.agent_runtime_helpers import (
_apply_primary_runtime_fields, _rebuild_primary_client, _restore_runtime_capabilities,
)
_apply_primary_runtime_fields(agent, rt)
from agent.turn_recovery_codex import reset_codex_reasoning_replay
reset_codex_reasoning_replay(agent)
_restore_runtime_capabilities(agent, rt)
agent._use_prompt_caching = rt["use_prompt_caching"]
# Default to native layout for snapshots predating the native-vs-proxy split.
agent._use_native_cache_layout = rt.get(
"use_native_cache_layout",
agent.api_mode == "anthropic_messages" and agent.provider == "anthropic",
)
# An operator cache disable (_cache_disabled) must survive snapshot restoration.
if getattr(agent, "_cache_disabled", False):
agent._use_prompt_caching = False
agent._use_native_cache_layout = False
_rebuild_primary_client(agent, rt, reason="restore_primary")
agent.context_compressor.update_model(
model=rt["compressor_model"], context_length=rt["compressor_context_length"],
base_url=rt["compressor_base_url"], api_key=rt["compressor_api_key"],
provider=rt["compressor_provider"], api_mode=rt.get("compressor_api_mode", ""),
)
# Same rule as fallback activation: refresh an existing verdict only; never-probed sessions stay lazy.
if getattr(agent, "_compression_feasibility_checked", False) is True:
from agent.conversation_compression import revalidate_compression_feasibility
revalidate_compression_feasibility(agent)
# Older snapshots have no reasoning_config; keep the current value.
saved_reasoning = rt.get("reasoning_config")
if saved_reasoning is not None:
agent.reasoning_config = dict(saved_reasoning)
# Reset the stale-call circuit breaker: its streak measured the route being left.
from agent.chat_completion_helpers import _reset_stale_streak, rewrite_prompt_model_identity
_reset_stale_streak(agent)
# Undo the identity rewrite so the prompt is byte-identical to the stored copy again
# (prefix cache match).
rewrite_prompt_model_identity(agent, rt["model"], rt["provider"])
def reinstall_primary_runtime(
agent: Any, rt: dict[str, Any], primary_provider: str, primary_model: str, matches_primary,
load_primary_pool, prefetched_pool=None, prefetched: bool = False,
) -> None:
"""Put ``agent`` back on its ``_primary_runtime`` snapshot ``rt`` and clear the fallback state."""
from agent.agent_runtime_helpers import _rebind_primary_credential_pool
reinstall_runtime_snapshot(agent, rt)
_rebind_primary_credential_pool(
agent, primary_provider, primary_model, matches_primary, load_primary_pool, prefetched_pool, prefetched
)
agent._fallback_activated = False
agent._fallback_index = 0
agent._rate_limit_backoff_count = 0