diff --git a/agent/agent_init.py b/agent/agent_init.py index a64fdfd854..be5e0808ff 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1,20 +1,11 @@ -"""Implementation of :meth:`AIAgent.__init__` — extracted as a module function. +"""Implementation of :meth:`AIAgent.__init__` as ``init_agent(agent, ...)``. -``AIAgent.__init__`` is one of the longest methods in the codebase (60+ -parameters, ~1,400 lines of attribute initialization, provider -auto-detection, credential resolution, context-engine bootstrap, etc.). -Keeping it in ``run_agent.py`` bloats that file with code that's mostly -"setup state, then forget". - -After this extraction the body lives here as ``init_agent(agent, ...)`` -and :meth:`AIAgent.__init__` is a thin wrapper that calls -``init_agent(self, ...)``. All imports the body needs at module-load -time are listed below; the body also performs many lazy imports inside -its own scope that come along unchanged. - -Symbols that tests patch on ``run_agent.*`` (``OpenAI``, ``cleanup_vm``, -etc.) are resolved through :func:`_ra` so the patch contract is -preserved. +``init_agent`` is a thin, ordered orchestrator over ``_init_*`` / ``_build_*`` +phase helpers (routing → callbacks → client → tools → session → config +sections → compression → context engine). Phase ORDER is load-bearing: later +phases read attributes earlier ones set. Symbols that tests patch on +``run_agent.*`` (``OpenAI``, ``get_tool_definitions``, ``logger``, …) are +resolved through :func:`_ra` so the patch contract is preserved. """ from __future__ import annotations @@ -27,6 +18,7 @@ import threading import time import uuid from datetime import datetime +from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional from urllib.parse import parse_qs, urlparse, urlunparse @@ -54,10 +46,7 @@ from hermes_cli.timeouts import get_provider_request_timeout from hermes_constants import get_hermes_home from utils import base_url_host_matches, is_truthy_value -# Use the same logger name as run_agent so tests patching ``run_agent.logger`` -# capture our warnings. (run_agent.py also does -# ``logger = logging.getLogger(__name__)``, which resolves to "run_agent" -# from inside that module.) +# Same logger name as run_agent so caplog/patches on "run_agent" see our records. logger = logging.getLogger("run_agent") @@ -104,46 +93,8 @@ def _ra(): return run_agent -def _moa_reference_output_allowed(agent: Any) -> bool: - """Keep MoA display events off only the machine-readable ``-Q`` surface.""" - return not ( - getattr(agent, "platform", None) == "cli" - and getattr(agent, "tool_progress_mode", "all") == "off" - ) - - -def _relay_moa_reference_event(agent: Any, event: str, **kwargs: Any) -> None: - """Relay MoA display events while preserving the ``-Q`` stdout contract.""" - if not _moa_reference_output_allowed(agent): - return - cb = getattr(agent, "tool_progress_callback", None) - if cb is None: - return - try: - if event == "moa.reference": - cb( - "moa.reference", - str(kwargs.get("label") or ""), - str(kwargs.get("text") or ""), - None, - moa_index=kwargs.get("index"), - moa_count=kwargs.get("count"), - ) - elif event == "moa.aggregating": - cb( - "moa.aggregating", - str(kwargs.get("aggregator") or ""), - None, - None, - moa_ref_count=kwargs.get("ref_count"), - ) - except Exception: - pass - - -def _normalize_route_base_url(base_url: Any) -> str: - """Canonicalize an endpoint URL for model-route identity comparisons.""" - return normalize_route_base_url(base_url) +# Canonicalize an endpoint URL for model-route identity comparisons. +_normalize_route_base_url = normalize_route_base_url def _provider_default_routes(provider: str) -> set[str]: @@ -245,16 +196,11 @@ def _context_route_mismatch( configured_routes = _provider_default_routes(configured_provider) if configured_routes: return active_route not in configured_routes - # Named/custom providers have no catalog default routes. An empty - # configured URL with a matching provider identity is still the same - # route — agent_init fills base_url from custom_providers before this - # check, but gateway display/hygiene paths historically compared the - # raw empty model.base_url and falsely dropped model.context_length, - # falling through to family defaults (e.g. qwen → 131072) on Discord - # session-reset banners while /status still showed the config pin. - if active_provider and configured_provider == active_provider: - return False - return True + # Named/custom providers have no catalog default routes: an empty + # configured URL with a matching provider identity is the same route + # (gateway display paths compare the raw empty model.base_url and must + # not drop model.context_length to family defaults). + return not (active_provider and configured_provider == active_provider) return bool( configured_provider and active_provider @@ -405,17 +351,14 @@ def _normalized_custom_base_url(value: Any) -> str: def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> bool: agent_model_norm = str(agent_model or "").strip().lower() - # Multi-model entries (v12+ `providers..models` mapping / legacy - # `models:` list): the agent's model matching ANY catalog entry counts. - # Without this, a provider whose `model`/`default_model` differs from the - # session model silently fails to match and per-provider request settings - # (extra_body, e.g. OpenAI service_tier) are dropped — billing the whole - # session at the wrong tier (July 2026 sweeper incident: flex config - # ignored, ~2.3x overbilling). + # Multi-model entries (`providers..models` mapping / legacy `models:` + # list): matching ANY catalog entry counts. Otherwise a provider whose + # `model` differs from the session model drops its extra_body (e.g. OpenAI + # service_tier) and bills the whole session at the wrong tier. models = entry.get("models") catalog: List[str] = [] if isinstance(models, dict): - catalog = [str(k).strip().lower() for k in models.keys()] + catalog = [str(k).strip().lower() for k in models] elif isinstance(models, (list, tuple)): catalog = [str(m).strip().lower() for m in models] if catalog and agent_model_norm in catalog: @@ -533,6 +476,2319 @@ def _refuse_checkpoint_required_on_codex_app_server( ) +def _parse_config_int(raw: Any, default: int) -> int: + """Strict int coercion for config knobs (compression.max_attempts & co). + + Rejects booleans (bool subclasses int — YAML ``true`` would coerce to 1) + and fractional floats ("4.7 attempts" is a mistake, not a request for 4); + accepts ints, integral floats and numeric strings; else ``default``. + """ + if isinstance(raw, bool): + return default + if isinstance(raw, int): + return raw + if isinstance(raw, float): + return int(raw) if raw.is_integer() else default + try: + return int(str(raw).strip()) + except (TypeError, ValueError): + return default + + +def _cfg_flag(cfg: Dict[str, Any], key: str, default: bool) -> bool: + """Legacy string-set truthiness used by the ``compression`` section.""" + return str(cfg.get(key, default)).lower() in {"true", "1", "yes"} + + +@dataclass +class CompressionSettings: + """Parsed ``compression`` config section (see ``_parse_compression_config``).""" + threshold: Any + autoraise_notice_enabled: Any + enabled: Any + target_ratio: Any + protect_last: Any + tail_mode: Any + min_tail_users: Any + max_attempts: Any + proactive_prune_tokens: Any + proactive_prune_min_chars: Any + proactive_prune_min_reclaim: Any + protect_first: Any + abort_on_summary_failure: Any + model_thresholds: Any + threshold_tokens: Any + checkpoint_required: Any + in_place: Any + micro_compact: Any + micro_compact_every_n_turns: Any + micro_compact_defrag_tokens: Any + codex_app_server_auto: Any + codex_responses_native: Any + codex_responses_compact_threshold: Any + idle_compact_after_seconds: Any + + +def _resolve_api_mode(agent, api_mode, provider_name, base_url): + if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}: + agent.api_mode = api_mode + elif agent.provider in {"openai-codex", "xai", "xai-oauth"}: + agent.api_mode = "codex_responses" + elif (provider_name is None) and ( + agent._base_url_hostname == "chatgpt.com" + and "/backend-api/codex" in agent._base_url_lower + ): + agent.api_mode = "codex_responses" + agent.provider = "openai-codex" + elif (provider_name is None) and agent._base_url_hostname == "api.x.ai": + agent.api_mode = "codex_responses" + agent.provider = "xai" + elif agent.provider == "anthropic" or (provider_name is None and agent._base_url_hostname == "api.anthropic.com"): + agent.api_mode = "anthropic_messages" + agent.provider = "anthropic" + elif agent._base_url_lower.rstrip("/").endswith("/anthropic"): + # Third-party Anthropic-compatible endpoints (e.g. MiniMax, DashScope) + # use a URL convention ending in /anthropic. Auto-detect these so the + # Anthropic Messages API adapter is used instead of chat completions. + agent.api_mode = "anthropic_messages" + elif agent.provider == "bedrock" or ( + agent._base_url_hostname.startswith("bedrock-runtime.") + and base_url_host_matches(agent._base_url_lower, "amazonaws.com") + ): + # AWS Bedrock — auto-detect from provider name or base URL + # (bedrock-runtime..amazonaws.com). + agent.api_mode = "bedrock_converse" + elif agent.provider in {"nous", "nous-portal", "nousresearch"}: + # Portal is dual-wire: anthropic/* → Messages, everything else → + # chat_completions. Callers that already pass api_mode win above; + # this covers direct AIAgent construction without a resolved runtime. + from hermes_cli.providers import nous_api_mode + + agent.api_mode = nous_api_mode(agent.model) + else: + # Host-mandated wire check — LAST, so the provider-slug rewrites above + # (e.g. api.anthropic.com → provider="anthropic") always win. Covers + # api.meta.ai → codex_responses (prompt caching: 0% on chat vs 93-99%). + # Deliberately URL-driven, not provider-name-driven: `providers.meta` + # may point at any OpenAI-compatible endpoint without the Responses API. + try: + from hermes_cli.providers import host_mandated_api_mode as _host_mandated_api_mode + + _mandated = _host_mandated_api_mode(base_url or "") + except Exception: + _mandated = None + if _mandated is not None: + agent.api_mode = _mandated + else: + agent.api_mode = "chat_completions" + + +def _finalize_routing(agent, api_mode, credential_pool): + # Credential-pool validation runs AFTER provider auto-detection so a pool + # scoped to "anthropic" isn't rejected for provider=None + anthropic.com URL. + if credential_pool is not None: + try: + from agent.credential_pool import credential_pool_matches_provider + + if not credential_pool_matches_provider( + credential_pool, + agent.provider, + base_url=agent.base_url, + ): + agent._credential_pool = None + except Exception: + agent._credential_pool = None + + # Eagerly warm the transport cache so import errors surface at init, + # not mid-conversation. Also validates the api_mode is registered. + try: + agent._get_transport() + except Exception: + pass # Non-fatal — transport may not exist for all modes yet + + try: + from hermes_cli.model_normalize import ( + _AGGREGATOR_PROVIDERS, + normalize_model_for_provider, + ) + + if agent.provider not in _AGGREGATOR_PROVIDERS: + agent.model = normalize_model_for_provider(agent.model, agent.provider) + except Exception: + pass + + # Auto-upgrade to Responses for GPT-5.x-style models and direct OpenAI + # URLs, unless: api_mode was explicit (user knows their endpoint), the + # runtime is ACP (`acp://` scheme — ACP clients route themselves and lack + # the Responses surface), or the URL is Azure OpenAI (serves gpt-5.x on + # /chat/completions only). Provider exceptions (e.g. Copilot gpt-5-mini) + # live in _provider_model_requires_responses_api. + if ( + api_mode is None + and agent.api_mode == "chat_completions" + and agent.provider != "copilot-acp" + and not str(agent.base_url or "").lower().startswith("acp://") + and not str(agent.base_url or "").lower().startswith("acp+tcp://") + and not agent._is_azure_openai_url() + and ( + agent._is_direct_openai_url() + or agent._provider_model_requires_responses_api( + agent.model, + provider=agent.provider, + ) + ) + ): + agent.api_mode = "codex_responses" + # Invalidate the eager-warmed transport cache — api_mode changed + # from chat_completions to codex_responses after the warm at __init__. + if hasattr(agent, "_transport_cache"): + agent._transport_cache.clear() + + # Pre-warm the OpenRouter model metadata cache (1h TTL) off-thread so the + # first pricing estimate doesn't block. Process-level Event guard: the + # gateway builds an AIAgent per message, and an unguarded spawn leaks one + # OS thread per message until "can't start new thread". + if (agent.provider == "openrouter" or agent._is_openrouter_url()) and \ + not _ra()._openrouter_prewarm_done.is_set(): + _ra()._openrouter_prewarm_done.set() + threading.Thread( + target=fetch_model_metadata, + daemon=True, + name="openrouter-prewarm", + ).start() + + +def _init_control_state(agent): + + # Tool execution state — allows _vprint during tool execution + # even when stream consumers are registered (no tokens streaming then) + agent._executing_tools = False + agent._tool_guardrails = ToolCallGuardrailController() + agent._tool_guardrail_halt_decision: ToolGuardrailDecision | None = None + + # Interrupt mechanism for breaking out of tool loops + agent._interrupt_requested = False + agent._interrupt_message = None # Optional message that triggered interrupt + # Explicit hard cancellation is separate from redirect/message state. A + # thread-safe Event makes the cause atomic for auxiliary stream pollers. + agent._hard_interrupt_requested = threading.Event() + agent._execution_thread_id: int | None = None # Set at run_conversation() start + agent._interrupt_thread_signal_pending = False + agent._client_lock = threading.RLock() + agent._model_request_active = threading.Event() + agent._supports_active_turn_redirect = True + + # /steer — inject a user note into the next tool result without + # interrupting: the drain hook appends it to the last tool result after + # the current batch finishes, preserving role alternation (no new user turn). + agent._pending_steer: Optional[str] = None + agent._pending_steer_lock = threading.Lock() + + # Active-turn redirect mechanism. A regular follow-up sent while the model + # is generating is different from a hard /stop: preserve the valid turn + # prefix, cancel only the in-flight model request, and rebuild its tail with + # the correction. The loop drains this slot at a role-safe boundary. + agent._pending_redirect: Optional[str] = None + agent._pending_redirect_lock = threading.Lock() + + # Concurrent-tool worker tids: `_set_interrupt` on `_execution_thread_id` + # alone doesn't reach ThreadPoolExecutor workers, so interrupt() / + # clear_interrupt() fan out to these explicitly. + agent._tool_worker_threads: set[int] = set() + agent._tool_worker_threads_lock = threading.Lock() + + # Subagent delegation state + agent._delegate_depth = 0 # 0 = top-level agent, incremented for children + agent._active_children = [] # Running child AIAgents (for interrupt propagation) + agent._active_children_lock = threading.Lock() + + # Background memory/skill review state (agent/background_review.py). + # ``_background_review_run`` is installed before the worker starts and + # fences its first provider-capable phase; the direct agent pointer keeps + # normal interrupt propagation available once the fork is constructed. + agent._background_review_agent = None + agent._background_review_run = None + agent._background_review_lock = threading.Lock() + + +def _init_prompt_cache_config(agent): + # Anthropic prompt caching: auto-enabled for Claude on native Anthropic, + # OpenRouter and anthropic_messages gateways (~75% input savings). Four + # breakpoints: static system prefix, full system prompt, last two messages. + # See ``_anthropic_prompt_cache_policy`` for layout-vs-transport. + agent._use_prompt_caching, agent._use_native_cache_layout = ( + agent._anthropic_prompt_cache_policy() + ) + agent._cache_disabled = False + # prompt_caching.cache_ttl: "5m" (default) or "1h" (2x write cost, pays off + # with >5-minute pauses); unknown values keep "5m". A falsy value (false / + # null / "off" / "disabled" / "no" / "none") disables caching entirely — + # for OAuth plans billing cache writes as extra usage, or proxies that add + # their own cache_control. The disable survives /model switches and + # fallback re-derivation via anthropic_prompt_cache_policy(). + agent._cache_ttl = "5m" + try: + from hermes_cli.config import load_config_readonly as _load_pc_cfg + + from agent.agent_runtime_helpers import cache_ttl_means_disabled + + _pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {} + _ttl = _pc_cfg.get("cache_ttl", "5m") + if _ttl in {"5m", "1h"}: + agent._cache_ttl = _ttl + elif cache_ttl_means_disabled(_ttl): + agent._use_prompt_caching = False + agent._use_native_cache_layout = False + agent._cache_ttl = None + agent._cache_disabled = True + except Exception: + pass + + +def _init_turn_state(agent, run_budget_seconds): + # Iteration budget: notify the LLM only on actual exhaustion (ONE message, + # one grace call, then a forced summarise request). No intermediate + # pressure warnings — they made models give up early on complex tasks. + agent._budget_exhausted_injected = False + agent._budget_grace_call = False + + # Optional wall-clock run budget (seconds per run_conversation turn). + # Explicit constructor arg wins; else resolved from config.yaml + # (agent.run_budget_seconds) further below. None = feature fully off: + # no clock reads, no injection, no stale-timeout capping. + agent.run_budget_seconds = _normalize_run_budget_seconds(run_budget_seconds) + # Wall-clock start of the CURRENT run_conversation turn. Set by + # turn_context.prepare_turn when a run budget is active; None otherwise. + agent._run_budget_started_at = None + # One-shot latch for the 80% wrap-up notice (reset each turn). + agent._run_budget_wrapup_injected = False + + # Activity tracking — updated on each API call, tool execution, and + # stream chunk. Used by the gateway timeout handler to report what the + # agent was doing when it was killed, and by the "still working" + # notifications to show progress. + agent._last_activity_ts: float = time.time() + agent._last_activity_desc: str = "initializing" + # Default / unmigrated paths and _touch_activity stamp unknown; named + # provenances are stamped by compression writers (heartbeat / timeout / cooldown). + agent._last_activity_provenance = ActivityProvenance.UNKNOWN + # Rate-limit durable SessionDB activity stamps from _touch_activity (#72016). + agent._session_activity_last_persist_mono: float = 0.0 + agent._current_tool: str | None = None + agent._api_call_count: int = 0 + # Opt-out flag for the between-turns MCP tool refresh (build_turn_context). + # Set on internal forks (e.g. background_review) that must keep ``tools[]`` + # byte-identical to a parent for provider cache parity. + agent._skip_mcp_refresh = False + # Registry generation the current tool snapshot was derived from. Lets a + # late/concurrent refresh reject a stale (older-generation) rebuild instead + # of clobbering a newer one. Set adjacent to the tool snapshot below. + agent._tool_snapshot_generation = 0 + # Rate limit tracking — updated from x-ratelimit-* response headers + # after each API call. Accessed by /usage slash command. + agent._rate_limit_state: Optional["RateLimitState"] = None + + # Credits tracking (dev-only, L0 usage-aware-credits) — updated from + # x-nous-credits-* response headers after each API call. Session-start + # remaining is latched the first time a header is ever seen so we can + # report cumulative micros spent. Surfaced behind HERMES_DEV_CREDITS. + agent._credits_state = None + agent._credits_session_start_micros = None + # Threshold-notice latch (L4): active sticky-notice keys + the crossing gates. + from agent.credits_tracker import new_credits_latch + + agent._credits_latch = new_credits_latch() + + # OpenRouter response cache hit counter — incremented when + # X-OpenRouter-Cache-Status: HIT is seen in streaming response headers. + agent._or_cache_hits: int = 0 + + +def _setup_logging(agent): + # Centralized logging — agent.log (INFO+) and errors.log (WARNING+) + # both live under ~/.hermes/logs/. Idempotent, so gateway mode + # (which creates a new AIAgent per message) won't duplicate handlers. + from hermes_logging import setup_logging, setup_verbose_logging + setup_logging(hermes_home=_ra()._hermes_home) + + if agent.verbose_logging: + setup_verbose_logging() + _ra().logger.info("Verbose logging enabled (third-party library logs suppressed)") + # Quiet mode (CLI default) deliberately does NOT raise per-logger levels: + # that would starve the root file handlers (agent.log, errors.log), since + # isEnabledFor() is checked before propagation. setup_logging() installs no + # console handler in quiet mode; any noise reduction belongs in hermes_logging. + + +def _init_stream_state(agent): + # Internal stream callback (set during streaming TTS). + # Initialized here so _vprint can reference it before run_conversation. + agent._stream_callback = None + # Deferred paragraph break flag — set after tool iterations so a + # single "\n\n" is prepended to the next real text delta. + agent._stream_needs_break = False + # Stateful scrubber for spans split across stream + # deltas (#5719). sanitize_context() alone can't survive chunk + # boundaries because the block regex needs both tags in one string. + agent._stream_context_scrubber = StreamingContextScrubber() + # Stateful scrubber for reasoning/thinking tags split across deltas (a + # per-delta regex erased '' in delta1 so delta2 leaked as content). + agent._stream_think_scrubber = StreamingThinkScrubber() + # Visible assistant text already delivered through live token callbacks + # during the current model response. Used to avoid re-sending the same + # commentary when the provider later returns it as a completed interim + # assistant message. + agent._current_streamed_assistant_text = "" + # Completed interim messages delivered during the current user turn. + # Unlike token-stream tracking, this spans Codex continuation/tool calls so + # repeated commentary is not re-sent before normalization can deduplicate it. + agent._delivered_interim_texts: set[str] = set() + + # Single-writer guard for the streaming delta sink: a superseded stream + # (reconnected past, socket abort raced) must not interleave tokens with + # the retry's stream. Each attempt claims a monotonic writer token; the + # sink drops chunks from threads holding a stale one. Threads that never + # claimed are never fenced, so only a superseded stream can be dropped. + agent._stream_writer_lock = threading.Lock() + agent._stream_writer_token = 0 + agent._stream_writer_tls = threading.local() + agent._stream_writer_dropped = 0 + + # Optional current-turn user-message override used when the API-facing + # user message intentionally differs from the persisted transcript + # (e.g. CLI voice mode adds a temporary prefix for the live call only). + agent._persist_user_message_idx = None + agent._persist_user_message_override = None + agent._persist_user_message_timestamp = None + + # Cache anthropic image-to-text fallbacks per image payload/URL so a + # single tool loop does not repeatedly re-run auxiliary vision on the + # same image history. + agent._anthropic_image_fallback_cache: Dict[str, str] = {} + + +def _bedrock_region_from_url(base_url) -> str: + """AWS region from a bedrock-runtime..amazonaws.com URL (default us-east-1).""" + m = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") + return m.group(1) if m else "us-east-1" + + +def _init_anthropic_client(agent, api_key, base_url, _provider_timeout): + """anthropic_messages: native Anthropic SDK (or AnthropicBedrock for Bedrock+Claude).""" + from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token + # Bedrock + Claude → use AnthropicBedrock SDK for full feature parity + # (prompt caching, thinking budgets, adaptive thinking). + _is_bedrock_anthropic = agent.provider == "bedrock" + if _is_bedrock_anthropic: + from agent.anthropic_adapter import build_anthropic_bedrock_client + _br_region = agent._bedrock_region = _bedrock_region_from_url(base_url) + agent._anthropic_client = build_anthropic_bedrock_client(_br_region) + agent._anthropic_api_key = "aws-sdk" + agent._anthropic_base_url = base_url + agent._is_anthropic_oauth = False + agent.api_key = "aws-sdk" + agent.client = None + agent._client_kwargs = {} + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})") + else: + # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. + # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key. + # Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401). + _is_native_anthropic = agent.provider == "anthropic" + effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "") + + # MiniMax OAuth tokens live ~15 min and the Anthropic SDK freezes + # ``api_key`` at construction, so swap in a callable token provider: + # ``build_anthropic_client`` installs an httpx hook that mints a + # fresh bearer per request (re-reading auth.json, so refreshes from + # other processes are seen). Steady-state cost: one file read + + # timestamp compare per request. + if agent.provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: + try: + from hermes_cli.auth import build_minimax_oauth_token_provider + effective_key = build_minimax_oauth_token_provider() + except Exception as _mm_exc: # noqa: BLE001 — never block startup on this + logging.getLogger(__name__).warning( + "MiniMax OAuth: failed to install per-request token provider " + "(%s); falling back to static bearer that will expire ~15min in.", + _mm_exc, + ) + + agent.api_key = effective_key + agent._anthropic_api_key = effective_key + agent._anthropic_base_url = base_url + # OAuth only for native Anthropic: third-party anthropic_messages + # providers (MiniMax, Kimi, GLM, LiteLLM) must never trip OAuth + # paths — those inject Claude-Code identity headers → 401/403. + from agent.anthropic_adapter import _is_oauth_token as _is_oat + agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False + agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout) + # No OpenAI client needed for Anthropic mode + agent.client = None + agent._client_kwargs = {} + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with model: {agent.model} (Anthropic native)") + # ``effective_key`` may be a callable Entra ID bearer provider + # (Azure Foundry) — never invoke or inspect it in the banner. + from agent.azure_identity_adapter import is_token_provider + + if is_token_provider(effective_key): + print("🔑 Using credentials: Microsoft Entra ID") + elif isinstance(effective_key, str) and len(effective_key) > 12: + print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") + + +def _init_moa_client(agent, api_key): + """provider == "moa": virtual Mixture-of-Agents facade, no real HTTP client.""" + from agent.moa_loop import build_moa_facade + agent.api_mode = "chat_completions" + + # build_moa_facade wires the reference relay ("moa.reference" / + # "moa.progress" / "moa.phase" / "moa.aggregating" events through + # tool_progress_callback) so every surface shows each reference's + # answer before the aggregator acts. Display-only, never touches + # message history; shared with fallback-restore so a restored facade + # keeps emitting. + agent.client = build_moa_facade(agent, agent.model) + agent._client_kwargs = {} + agent.api_key = api_key or "moa-virtual-provider" + agent.base_url = "moa://local" + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with MoA preset: {agent.model}") + + +def _init_bedrock_client(agent, base_url): + """bedrock_converse: boto3 directly, no OpenAI client.""" + agent._bedrock_region = _bedrock_region_from_url(base_url) + # Guardrail config — read from config.yaml at init time. + agent._bedrock_guardrail_config = None + try: + from hermes_cli.config import load_config_readonly as _load_br_cfg + _gr = _load_br_cfg().get("bedrock", {}).get("guardrail", {}) + if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"): + agent._bedrock_guardrail_config = { + "guardrailIdentifier": _gr["guardrail_identifier"], + "guardrailVersion": _gr["guardrail_version"], + } + if _gr.get("stream_processing_mode"): + agent._bedrock_guardrail_config["streamProcessingMode"] = _gr["stream_processing_mode"] + if _gr.get("trace"): + agent._bedrock_guardrail_config["trace"] = _gr["trace"] + except Exception: + pass + agent.client = None + agent._client_kwargs = {} + if not agent.quiet_mode: + _gr_label = " + Guardrails" if agent._bedrock_guardrail_config else "" + print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock, {agent._bedrock_region}{_gr_label})") + + +def _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) -> Dict[str, Any]: + """OpenAI-client kwargs from explicit CLI/gateway credentials (auth already resolved).""" + _parsed_url = urlparse(base_url) + if _parsed_url.query: + _clean_url = urlunparse(_parsed_url._replace(query="")) + _query_params = { + k: v[0] for k, v in parse_qs(_parsed_url.query).items() + } + client_kwargs = { + "api_key": api_key, + "base_url": _clean_url, + "default_query": _query_params, + } + else: + client_kwargs = {"api_key": api_key, "base_url": base_url} + if _provider_timeout is not None: + client_kwargs["timeout"] = _provider_timeout + if agent.provider == "copilot-acp": + client_kwargs["command"] = agent.acp_command + client_kwargs["args"] = agent.acp_args + effective_base = base_url + # OpenCode Zen free tier (*-free slugs, e.g. x-preview-f-free / + # "Ox Alpha"): the Zen relay serves these ANONYMOUSLY and 401s any + # unrecognized bearer — including our keyless placeholder. Send an + # empty Authorization header to override the SDK's "Bearer ". + try: + from hermes_cli.models import ( + OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, + opencode_zen_free_headers, + ) + if api_key == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER: + client_kwargs["default_headers"] = opencode_zen_free_headers() + except Exception: + pass + _headers_for = _host_default_headers_factory(effective_base) + if _headers_for is not None: + client_kwargs["default_headers"] = _headers_for(api_key, effective_base) + elif "default_headers" not in client_kwargs: + # Fall back to profile.default_headers for providers that + # declare custom headers (e.g. Vercel AI Gateway attribution, + # Kimi User-Agent on non-kimi.com endpoints). + try: + from providers import get_provider_profile as _gpf + _ph = _gpf(agent.provider) + if _ph and _ph.default_headers: + client_kwargs["default_headers"] = dict(_ph.default_headers) + except Exception: + pass + return client_kwargs + + +def _routed_client_kwargs(agent, fallback_model, _provider_timeout) -> Dict[str, Any]: + """OpenAI-client kwargs via the centralized provider router (no explicit creds). + + Falls through to the init-time fallback chain, then raises with the + missing-key / no-provider diagnostic. + """ + from agent.auxiliary_client import resolve_provider_client + _routed_client, _ = resolve_provider_client( + agent.provider or "auto", model=agent.model, raw_codex=True) + if _routed_client is not None: + client_kwargs = _client_kwargs_from_routed(_routed_client, _provider_timeout) + else: + # No credentials for the configured provider: try the + # user-configured fallback chain BEFORE failing, whichever + # provider failed (an exhausted single-entry pool must not die + # with a misleading "No LLM provider configured"). Only + # explicitly named providers keep the missing-key diagnostic. + _explicit = (agent.provider or "").strip().lower() + _fb_resolved = False + for _fb in _fallback_entries(fallback_model): + try: + from hermes_cli.fallback_config import resolve_entry_api_key + _fb_explicit_key = resolve_entry_api_key(_fb) + _fb_client, _fb_model = resolve_provider_client( + _fb["provider"], model=_fb["model"], raw_codex=True, + explicit_base_url=_fb.get("base_url"), + explicit_api_key=_fb_explicit_key, + ) + except Exception as _fb_exc: + logger.debug( + "Init-time fallback entry %s failed: %s", + _fb.get("provider"), _fb_exc, + ) + continue + if _fb_client is not None: + agent.provider = _fb["provider"] + agent.model = _fb_model or _fb["model"] + agent._fallback_activated = True + client_kwargs = _client_kwargs_from_routed(_fb_client, _provider_timeout) + _fb_resolved = True + break + if ( + not _fb_resolved + and _explicit + and _explicit not in {"auto", "openrouter", "custom"} + ): + # Explicit non-OpenRouter provider with no creds and no + # usable fallback: fail fast. Use the provider's real env + # var name (alibaba → DASHSCOPE_API_KEY, not ALIBABA_API_KEY). + _env_hint = f"{_explicit.upper()}_API_KEY" + try: + from hermes_cli.auth import PROVIDER_REGISTRY + _pcfg = PROVIDER_REGISTRY.get(_explicit) + if _pcfg and _pcfg.api_key_env_vars: + _env_hint = _pcfg.api_key_env_vars[0] + except Exception: + pass + raise RuntimeError( + f"Provider '{_explicit}' is set in config.yaml but no API key " + f"was found. Set the {_env_hint} environment " + f"variable, or switch to a different provider with `hermes model`." + ) + if not getattr(agent, "_fallback_activated", False): + # No provider configured — reject with a clear message. + raise RuntimeError( + "No LLM provider configured. Run `hermes model` to " + "select a provider, or run `hermes setup` for first-time " + "configuration." + ) + return client_kwargs + + +def _init_openai_client(agent, api_key, base_url, fallback_model, _provider_timeout): + """OpenAI-wire client: resolve kwargs, apply header/TLS policy, construct.""" + if api_key and base_url: + client_kwargs = _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) + else: + client_kwargs = _routed_client_kwargs(agent, fallback_model, _provider_timeout) + try: + from agent.bedrock_adapter import configure_bedrock_openai_client_kwargs + configure_bedrock_openai_client_kwargs( + client_kwargs, + timeout=_provider_timeout, + ) + except Exception: + if agent.provider == "bedrock" and "bedrock-mantle." in str(client_kwargs.get("base_url", "")): + raise + + agent._client_kwargs = client_kwargs # stored for rebuilding after interrupt + + # Fine-grained tool streaming for Claude on OpenRouter: without the beta + # header Anthropic buffers the whole tool call and OpenRouter's proxy + # times out during the silence. + _effective_base = str(client_kwargs.get("base_url", "")).lower() + if base_url_host_matches(_effective_base, "openrouter.ai") and "claude" in (agent.model or "").lower(): + headers = client_kwargs.get("default_headers") or {} + existing_beta = headers.get("x-anthropic-beta", "") + _FINE_GRAINED = "fine-grained-tool-streaming-2025-05-14" + if _FINE_GRAINED not in existing_beta: + if existing_beta: + headers["x-anthropic-beta"] = f"{existing_beta},{_FINE_GRAINED}" + else: + headers["x-anthropic-beta"] = _FINE_GRAINED + client_kwargs["default_headers"] = headers + + # model.default_headers (config.yaml) override provider/SDK defaults (WAFs + # that reject the SDK's identifying headers). Mutates agent._client_kwargs, + # which is this same dict, so the client built below sees it. + agent._apply_user_default_headers() + + try: + from hermes_cli.config import ( + apply_custom_provider_extra_headers_to_client_kwargs, + apply_custom_provider_tls_to_client_kwargs, + get_compatible_custom_providers, + load_config, + ) + + _cp_config = load_config() + _cp_entries = get_compatible_custom_providers(_cp_config) + _cp_base_url = str(client_kwargs.get("base_url") or agent.base_url or "") + apply_custom_provider_tls_to_client_kwargs( + client_kwargs, + _cp_base_url, + _cp_entries, + ) + # Per-provider extra HTTP headers (providers..extra_headers / + # custom_providers[].extra_headers) — proxies, gateways, custom + # auth. Applied last so the most specific config level wins. + # SECURITY: values may carry credentials — never log them. + apply_custom_provider_extra_headers_to_client_kwargs( + client_kwargs, + _cp_base_url, + _cp_entries, + ) + except Exception: + logger.debug("custom-provider TLS resolution skipped", exc_info=True) + + agent.api_key = client_kwargs.get("api_key", "") + agent.base_url = client_kwargs.get("base_url", agent.base_url) + try: + from agent.ssl_guard import verify_ca_bundle_with_fallback + + verify_ca_bundle_with_fallback() + agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True) + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with model: {agent.model}") + if base_url: + print(f"🔗 Using custom base URL: {base_url}") + # ``api_key`` may be a callable Entra ID bearer provider (Azure + # Foundry) — never invoke or inspect it in the banner. + from agent.azure_identity_adapter import is_token_provider + + key_used = client_kwargs.get("api_key", "none") + if is_token_provider(key_used): + print("🔑 Using credentials: Microsoft Entra ID") + elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12: + print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}") + else: + print("⚠️ Warning: API key appears invalid or missing") + except Exception as e: + raise RuntimeError(f"Failed to initialize OpenAI client: {e}") + + +def _build_client(agent, api_key, base_url, fallback_model): + # LLM client per wire mode. The provider router handles auth, base URL, + # headers and Codex/Anthropic wrapping (raw_codex=True: the main agent needs + # direct responses.stream()). One provider/model timeout up front so every + # construction path applies it consistently (Bedrock Claude has its own). + agent._anthropic_client = None + agent._is_anthropic_oauth = False + _provider_timeout = get_provider_request_timeout(agent.provider, agent.model) + if agent.api_mode == "anthropic_messages": + _init_anthropic_client(agent, api_key, base_url, _provider_timeout) + elif agent.provider == "moa": + _init_moa_client(agent, api_key) + elif agent.api_mode == "bedrock_converse": + _init_bedrock_client(agent, base_url) + else: + _init_openai_client(agent, api_key, base_url, fallback_model, _provider_timeout) + + +def _or_headers(_key, _base): + from agent.auxiliary_client import build_or_headers + return build_or_headers() + + +def _nvidia_headers(_key, base): + from agent.auxiliary_client import build_nvidia_nim_headers + return build_nvidia_nim_headers(base) + + +def _copilot_headers(_key, _base): + from hermes_cli.models import copilot_default_headers + return copilot_default_headers() + + +def _codex_headers(key, base): + from agent.codex_headers import codex_cloudflare_headers + return codex_cloudflare_headers(key, base_url=base) + + +def _xai_headers(_key, _base): + from tools.xai_http import hermes_xai_default_headers + return hermes_xai_default_headers() + + +# Host → default_headers factory ``(api_key, base_url) -> dict`` for explicit +# base_url client construction. Ordered: first host match wins; no match falls +# back to the provider profile's declared headers. ``_ra()`` keeps the +# ``run_agent.*`` header helpers patchable by tests. +_HOST_DEFAULT_HEADERS: List[tuple[str, Callable[[Any, str], Dict[str, str]]]] = [ + ("openrouter.ai", _or_headers), + ("integrate.api.nvidia.com", _nvidia_headers), + ("api.routermint.com", lambda _k, _b: _ra()._routermint_headers()), + ("githubcopilot.com", _copilot_headers), + ("api.kimi.com", lambda _k, _b: {"User-Agent": "claude-code/0.1.0"}), + ("portal.qwen.ai", lambda _k, _b: _ra()._qwen_portal_headers()), + ("chatgpt.com", _codex_headers), + ("x.ai", _xai_headers), +] + + +def _host_default_headers_factory(base_url: str): + for host, factory in _HOST_DEFAULT_HEADERS: + if base_url_host_matches(base_url, host): + return factory + return None + + +def _client_kwargs_from_routed(client, timeout) -> Dict[str, Any]: + """OpenAI-client kwargs mirroring a router-resolved client. + + Preserves provider-specific headers the router set: the OpenAI SDK stores + caller-provided default_headers in ``_custom_headers``; older/mocked + clients may expose ``default_headers`` / ``_default_headers`` instead. + """ + kwargs = {"api_key": client.api_key, "base_url": str(client.base_url)} + if timeout is not None: + kwargs["timeout"] = timeout + headers = ( + getattr(client, "_custom_headers", None) + or getattr(client, "default_headers", None) + or getattr(client, "_default_headers", None) + ) + if headers: + kwargs["default_headers"] = dict(headers) + return kwargs + + +def _fallback_entries(fallback_model) -> List[Dict[str, Any]]: + """Normalize legacy single-dict ``fallback_model`` / list ``fallback_providers``.""" + if isinstance(fallback_model, list): + return [ + f for f in fallback_model + if isinstance(f, dict) and f.get("provider") and f.get("model") + ] + if isinstance(fallback_model, dict) and fallback_model.get("provider") and fallback_model.get("model"): + return [fallback_model] + return [] + + +def _init_fallback_chain(agent, fallback_model): + # Keep a stable identity for the pool entry that supplied this runtime. + # OAuth refreshes can replace the runtime token before a failed request is + # recovered, so the mutable API-key value alone cannot reliably attribute + # the failure to its source entry. + from agent.agent_runtime_helpers import sync_credential_pool_entry_id + sync_credential_pool_entry_id(agent) + + # Provider fallback chain — ordered list of backup providers tried + # when the primary is exhausted (rate-limit, overload, connection + # failure). Supports both legacy single-dict ``fallback_model`` and + # new list ``fallback_providers`` format. + agent._fallback_chain = _fallback_entries(fallback_model) + agent._fallback_index = 0 + agent._fallback_activated = getattr(agent, "_fallback_activated", False) + # Legacy attribute kept for backward compat (tests, external callers) + agent._fallback_model = agent._fallback_chain[0] if agent._fallback_chain else None + if agent._fallback_chain and not agent.quiet_mode: + if len(agent._fallback_chain) == 1: + fb = agent._fallback_chain[0] + print(f"🔄 Fallback model: {fb['model']} ({fb['provider']})") + else: + print(f"🔄 Fallback chain ({len(agent._fallback_chain)} providers): " + + " → ".join(f"{f['model']} ({f['provider']})" for f in agent._fallback_chain)) + + +def _load_tools(agent, enabled_toolsets, disabled_toolsets): + # A multiplexed gateway may enter a different HERMES_HOME after + # ``model_tools`` was first imported. Ensure that profile's keyed plugin + # manager has discovered its registrations before taking the tool snapshot. + try: + from hermes_cli.plugins import discover_plugins + + discover_plugins() + except Exception: + logger.warning("Plugin discovery failed during agent setup", exc_info=True) + + # Get available tools with filtering. Capture the registry generation this + # snapshot is derived from FIRST, so a later concurrent refresh can tell + # whether it holds a newer or staler view (see refresh_agent_mcp_tools). + try: + from tools.registry import registry as _snapshot_registry + agent._tool_snapshot_generation = _snapshot_registry._generation + except Exception: + agent._tool_snapshot_generation = 0 + agent.tools = _ra().get_tool_definitions( + enabled_toolsets=enabled_toolsets, + disabled_toolsets=disabled_toolsets, + quiet_mode=agent.quiet_mode, + ) + + # Show tool configuration and store valid tool names for validation + agent.valid_tool_names = set() + if agent.tools: + agent.valid_tool_names = {tool["function"]["name"] for tool in agent.tools} + tool_names = sorted(agent.valid_tool_names) + if not agent.quiet_mode: + print(f"🛠️ Loaded {len(agent.tools)} tools: {', '.join(tool_names)}") + # Show filtering info if applied + if enabled_toolsets: + print(f" ✅ Enabled toolsets: {', '.join(enabled_toolsets)}") + if disabled_toolsets: + print(f" ❌ Disabled toolsets: {', '.join(disabled_toolsets)}") + elif not agent.quiet_mode: + print("🛠️ No tools loaded (all tools filtered out or unavailable)") + + # Kanban lifecycle guidance is session-static (kanban_show is present iff + # HERMES_KANBAN_TASK is set); resolve the ~835-token block once instead of + # on every system-prompt rebuild. + from agent.prompt_builder import KANBAN_GUIDANCE + agent._kanban_worker_guidance = ( + KANBAN_GUIDANCE if "kanban_show" in agent.valid_tool_names else "" + ) + + # Check tool requirements + if agent.tools and not agent.quiet_mode: + requirements = _ra().check_toolset_requirements() + missing_reqs = [name for name, available in requirements.items() if not available] + if missing_reqs: + print(f"⚠️ Some tools may not work due to missing requirements: {missing_reqs}") + + # Show trajectory saving status + if agent.save_trajectories and not agent.quiet_mode: + print("📝 Trajectory saving enabled") + + # Show ephemeral system prompt status + if agent.ephemeral_system_prompt and not agent.quiet_mode: + prompt_preview = agent.ephemeral_system_prompt[:60] + "..." if len(agent.ephemeral_system_prompt) > 60 else agent.ephemeral_system_prompt + print(f"🔒 Ephemeral system prompt: '{prompt_preview}' (not saved to trajectories)") + + # Show prompt caching status + if agent._use_prompt_caching and not agent.quiet_mode: + if agent._use_native_cache_layout and agent.provider == "anthropic": + source = "native Anthropic" + elif agent._use_native_cache_layout: + source = "Anthropic-compatible endpoint" + else: + source = "Claude via OpenRouter" + print(f"💾 Prompt caching: ENABLED ({source}, {agent._cache_ttl} TTL)") + + +def _init_session_state(agent, session_id, session_db, parent_session_id, reasoning_config, max_tokens, + checkpoints_enabled, checkpoint_max_snapshots, checkpoint_max_total_size_mb, checkpoint_max_file_size_mb): + # Session logging setup - auto-save conversation trajectories for debugging + agent.session_start = datetime.now() + agent.session_id = session_id or ( + f"{agent.session_start.strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:6]}" + ) + + # Expose session ID to tools (terminal, execute_code) so agents can + # reference their own session for --resume commands, cross-session + # coordination, and logging. Keep the ContextVar and os.environ + # fallback synchronized because different tool paths still read both. + try: + from gateway.session_context import set_current_session_id + + set_current_session_id(agent.session_id) + except Exception: + # Preserve the root-agent legacy fallback, but never let delegated + # construction publish a child ID process-wide even if the ContextVar + # bridge itself failed to import. + try: + from agent.delegation_context import is_delegated_child_context + + delegated_child = is_delegated_child_context() + except Exception: + delegated_child = False + if not delegated_child: + os.environ["HERMES_SESSION_ID"] = agent.session_id + + # Session logs go into ~/.hermes/sessions/ alongside gateway sessions + hermes_home = get_hermes_home() + agent.logs_dir = hermes_home / "sessions" + agent.logs_dir.mkdir(parents=True, exist_ok=True) + # Per-session JSON snapshot writer (~/.hermes/sessions/session_{sid}.json) + # is opt-in via sessions.write_json_snapshots (default False). state.db + # is canonical — the snapshot is only useful for external tooling that + # reads the JSON files directly. See run_agent._save_session_log. + agent._session_json_enabled = False + try: + from hermes_cli.config import load_config_readonly as _load_sess_cfg + _sess_cfg = (_load_sess_cfg().get("sessions") or {}) + agent._session_json_enabled = bool(_sess_cfg.get("write_json_snapshots", False)) + except Exception: + pass + # logs_dir is retained unconditionally for request_dump_*.json (debug + # breadcrumb path written by agent_runtime_helpers.dump_api_request_debug). + + # Track conversation messages for session logging + agent._session_messages: List[Dict[str, Any]] = [] + # Responses encrypted-reasoning replay: some routes 400 with + # ``invalid_encrypted_content`` on replay; the loop then disables it for + # the session and falls back to stateless continuity. + agent._codex_reasoning_replay_enabled = True + agent._memory_write_origin = "assistant_tool" + agent._memory_write_context = "foreground" + + # Cached system prompt -- built once per session, only rebuilt on compression + agent._cached_system_prompt: Optional[str] = None + # Cross-session-stable prefix of the cached prompt. It remains separate + # from the persisted string and is used only to place an early cache marker. + agent._cached_system_prompt_static: Optional[str] = None + + # Filesystem checkpoint manager (transparent — not a tool) + from tools.checkpoint_manager import CheckpointManager + agent._checkpoint_mgr = CheckpointManager( + enabled=checkpoints_enabled, + max_snapshots=checkpoint_max_snapshots, + max_total_size_mb=checkpoint_max_total_size_mb, + max_file_size_mb=checkpoint_max_file_size_mb, + ) + + # SQLite session store (optional -- provided by CLI or gateway) + agent._session_db = session_db + # Whether close() also closes that handle. Default False: a caller-supplied + # session_db is usually the SHARED launch handle. Callers handing over a + # DEDICATED handle (gateway per-profile state.db, the lazy self-open in + # _get_session_db_for_recall) set True so teardown releases sqlite fds. + agent._owns_session_db = False + agent._parent_session_id = parent_session_id + # A close flush and the worker's turn-start flush can overlap. The durable + # marker is attached to each in-memory message dict, so its test-and-append + # sequence must be serialized per agent rather than relying on SQLite alone. + agent._session_persist_lock = threading.RLock() + # CLI retains its just-accepted user dict until turn setup can reuse it. + # This preserves the message-local durable marker if close persistence wins + # the race before the agent's normal early turn flush. + agent._pending_cli_user_message = None + agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes + agent._session_db_created = False # DB row deferred to run_conversation() + # Most agents own their session row and should finalize it on close(). + # Some temporary helper agents (manual compression / session-hygiene / + # background-review forks) rotate or share the session forward to a + # continuation row that must remain open after the helper is torn down; + # those callers explicitly set this flag to False. + agent._end_session_on_close = True + # When True, this agent NEVER persists to the canonical session store + # (state.db) or the JSON snapshot, regardless of session_id. Set on the + # background skill/memory review fork so its harness turn can't leak into + # the user's real session and hijack the next live turn. Default False. + agent._persist_disabled = False + agent._session_init_model_config = { + "max_iterations": agent.max_iterations, + "reasoning_config": reasoning_config, + "max_tokens": max_tokens, + } + # Persist a process-scoped --yolo launch into the session row so a later + # `hermes --resume ` can restore the bypass (CLI resume paths read + # model_config.yolo_mode back via SessionDB.session_yolo_enabled). + # Session-scoped /yolo toggles persist separately through + # SessionDB.set_session_yolo at toggle time. + try: + from tools.approval import _YOLO_MODE_FROZEN + if _YOLO_MODE_FROZEN: + agent._session_init_model_config["yolo_mode"] = True + except Exception: + pass + + # In-memory todo list for task planning (one per agent/session) + from tools.todo_tool import TodoStore + agent._todo_store = TodoStore() + + +def _apply_display_config(agent, _agent_cfg, platform): + # display.show_commentary (default true): Codex phase=commentary messages + # go to the interim message path; false routes them to the reasoning channel. + _display_section = _agent_cfg.get("display", {}) + if not isinstance(_display_section, dict): + _display_section = {} + agent.show_commentary = bool(_display_section.get("show_commentary", True)) + + # Window (seconds) for the bounded /fast auto|cold modes (agent.fast_mode). + agent.fast_auto_seconds = (_agent_cfg.get("agent") or {}).get("fast_auto_seconds", 60) + + # model.lmstudio_load_mode: "explicit" (default, preload via LM Studio's + # management API) or "jit" (LM Studio just-in-time / Auto-Evict path). + _model_section = _agent_cfg.get("model", {}) + if not isinstance(_model_section, dict): + _model_section = {} + agent.lmstudio_load_mode = "explicit" + _load_mode = str(_model_section.get("lmstudio_load_mode", "explicit") or "explicit").strip().lower() + if _load_mode in {"explicit", "jit"}: + agent.lmstudio_load_mode = _load_mode + else: + logger.warning( + "Invalid model.lmstudio_load_mode=%r; expected 'explicit' or 'jit'. Using explicit.", + _model_section.get("lmstudio_load_mode"), + ) + + # API-transport streaming (``model.streaming``, default true). Some + # self-hosted backends have broken streaming tool-call paths (markup leaks + # into text, zero tool_calls) so ``false`` seeds ``_disable_streaming`` — + # the same non-streaming path the loop falls back to at runtime. Session- + # scoped (survives model switches); orthogonal to ``display.streaming``. + agent._disable_streaming = False + _streaming = str(_model_section.get("streaming", "true")).strip().lower() + if _streaming in {"false", "0", "no", "off"}: + agent._disable_streaming = True + elif _streaming not in {"true", "1", "yes", "on"}: + logger.warning( + "Invalid model.streaming=%r; expected a boolean. Using streaming (default).", + _model_section.get("streaming"), + ) + + try: + agent._tool_guardrails = ToolCallGuardrailController( + ToolCallGuardrailConfig.from_mapping( + _agent_cfg.get("tool_loop_guardrails", {}), + platform=platform, + ) + ) + except Exception as _tlg_err: + _ra().logger.warning("Tool loop guardrail config ignored: %s", _tlg_err) + # Cache only the derived auxiliary compression context override that is + # needed later by the startup feasibility check. Avoid exposing a + # broad pseudo-public config object on the agent instance. + agent._aux_compression_context_length_config = None + + +def _memory_provider_init_kwargs(agent, platform) -> Dict[str, Any]: + """Scoping kwargs for ``MemoryManager.initialize_all``. + + status_callback (deterministic retain indicator) is CLI-only — gateway + status travels a different path and the indicator no-ops without it. + """ + kwargs = { + "session_id": agent.session_id, + "platform": platform or "cli", + "hermes_home": str(get_hermes_home()), + "agent_context": "primary", + } + if kwargs["platform"] == "cli": + kwargs["warning_callback"] = agent._emit_warning + kwargs["status_callback"] = agent._emit_status + # Session title (e.g. honcho derives chat-scoped session keys from it). + if agent._session_db: + try: + _st = agent._session_db.get_session_title(agent.session_id) + if _st: + kwargs["session_title"] = _st + except Exception: + pass + # Gateway user/chat identity for per-user scoping (gateway_session_key: + # stable per-chat Honcho session isolation). + for _ident in ( + "user_id", "user_id_alt", "user_name", "chat_id", "chat_name", + "chat_type", "thread_id", "gateway_session_key", + ): + _val = getattr(agent, f"_{_ident}") + if _val: + kwargs[_ident] = _val + # Profile identity for per-profile provider scoping + try: + from hermes_cli.profiles import get_active_profile_name + kwargs["agent_identity"] = get_active_profile_name() + kwargs["agent_workspace"] = "hermes" + except Exception: + pass + return kwargs + + +def _init_memory(agent, _agent_cfg, skip_memory, platform): + # Persistent memory (MEMORY.md + USER.md) -- loaded from disk + agent._memory_store = None + agent._memory_enabled = False + agent._user_profile_enabled = False + agent._memory_nudge_interval = 10 + agent._turns_since_memory = 0 + agent._iters_since_skill = 0 + # skip_memory=True skips the external *provider*; enabled_toolsets=["memory"] + # still gets the built-in store so the memory tool never sees store=None. + # A memory entry on disabled_toolsets is not a request. + _enabled_toolsets = agent.enabled_toolsets or [] + _disabled_toolsets = agent.disabled_toolsets or [] + _memory_toolset_requested = ( + "memory" in _enabled_toolsets and "memory" not in _disabled_toolsets + ) + if not skip_memory or _memory_toolset_requested: + try: + from tools.memory_tool import ( + get_builtin_memory_config, + get_builtin_memory_store_flags, + ) + + mem_config = get_builtin_memory_config(_agent_cfg) + agent._memory_enabled, agent._user_profile_enabled = get_builtin_memory_store_flags( + _agent_cfg + ) + agent._memory_nudge_interval = int(mem_config.get("nudge_interval", 10)) + if agent._memory_enabled or agent._user_profile_enabled: + from tools.memory_tool import MemoryStore + agent._memory_store = MemoryStore( + memory_char_limit=mem_config.get("memory_char_limit", 2200), + user_char_limit=mem_config.get("user_char_limit", 1375), + memory_enabled=agent._memory_enabled, + user_profile_enabled=agent._user_profile_enabled, + ) + agent._memory_store.load_from_disk() + except Exception: + pass # Memory is optional -- don't break agent init + + + + # Memory provider plugin (external — one at a time, alongside built-in) + # Reads memory.provider from config to select which plugin to activate. + agent._memory_manager = None + if not skip_memory: + try: + _mem_provider_name = mem_config.get("provider", "") if mem_config else "" + + if _mem_provider_name and _mem_provider_name.strip(): + from agent.memory_manager import MemoryManager as _MemoryManager + from plugins.memory import load_memory_provider as _load_mem + agent._memory_manager = _MemoryManager() + _mp = _load_mem(_mem_provider_name) + if _mp and _mp.is_available(): + agent._memory_manager.add_provider(_mp) + elif _mp is not None and _mem_provider_name not in _warned_unavailable_providers: + # unavailable_reason() reads config/probes importlib — skip + # it once warned (the gateway builds an AIAgent per message). + try: + _unavailable_reason = _mp.unavailable_reason() + except Exception: + _unavailable_reason = "" + _warn_memory_provider_unavailable(_mem_provider_name, _unavailable_reason) + if agent._memory_manager.providers: + agent._memory_manager.initialize_all( + **_memory_provider_init_kwargs(agent, platform) + ) + _ra().logger.info("Memory provider '%s' activated", _mem_provider_name) + else: + _ra().logger.debug("Memory provider '%s' not found or not available", _mem_provider_name) + agent._memory_manager = None + except Exception as _mpe: + _ra().logger.warning("Memory provider plugin init failed: %s", _mpe) + agent._memory_manager = None + + from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools + _inject_memory_provider_tools(agent) + + +def _apply_agent_section(agent, _agent_cfg): + # Skills config: nudge interval for skill creation reminders + agent._skill_nudge_interval = 10 + try: + skills_config = _agent_cfg.get("skills", {}) + agent._skill_nudge_interval = int(skills_config.get("creation_nudge_interval", 10)) + except Exception: + pass + + # Tool-use enforcement config: "auto" (default — matches hardcoded + # model list), true (always), false (never), or list of substrings. + _agent_section = _agent_cfg.get("agent", {}) + if not isinstance(_agent_section, dict): + _agent_section = {} + agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto") + + # Execution-discipline guidance gate: "auto" (default — matches + # EXECUTION_GUIDANCE_MODELS), true (always), false (never), or list of + # model-name substrings. Independent of tool_use_enforcement — see + # agent/system_prompt.py for the injection gate. + agent._execution_guidance = _agent_section.get("execution_guidance", "auto") + + # Wall-clock run budget from config (agent.run_budget_seconds) — only + # consulted when the constructor arg was not given. Absent/None/invalid + # keeps the feature fully off (zero behavior change in the default path). + if agent.run_budget_seconds is None: + agent.run_budget_seconds = _normalize_run_budget_seconds( + _agent_section.get("run_budget_seconds") + ) + + # Empty-response retry guard config (NS-503): additive + # ``agent.empty_response_guard`` subsection. Resolution is tolerant — + # a malformed section falls back to the schema defaults (guard on, + # $0.25 threshold), matching the guard's overall fail-open posture. + from agent.empty_response_guard import resolve_guard_settings + ( + agent._empty_guard_enabled, + agent._empty_guard_cost_threshold_usd, + ) = resolve_guard_settings(_agent_section.get("empty_response_guard")) + + # Intent-ack continuation config: "auto" (default — codex_responses only, + # the historical gate), true (all api_modes), false (never), or a list of + # model-name substrings. Resolved against the active api_mode/model in the + # conversation loop's intent-ack block. + agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto") + + # Runtime anti-stall guards (identical-call loop-breaker notice on tool + # results + continue-intent extension of the empty-response recovery). + # Single boolean gate, default True. Notice-only — never blocks a call. + agent._stall_guards = bool(_agent_section.get("stall_guards", True)) + + # Universal guidance toggles (apply to ALL models, unlike enforcement): + # task-completion, parallel-tool-call batching, and the local Python + # toolchain probe (False skips the subprocess probe + prompt line). + agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True)) + agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True)) + agent._environment_probe = bool(_agent_section.get("environment_probe", True)) + # Warm the probe off-thread (~0.5s of python3/pip subprocesses) during + # init so the FIRST system-prompt build — on the time-to-first-token + # path — finds the line already cached. + if agent._environment_probe: + try: + from tools.env_probe import warm_environment_probe_async + warm_environment_probe_async() + except Exception: + pass + + # Bot Mode teammate protocol section (tools/bot_mode_probe.py) — pure + # filesystem reads, no warm needed. Silent on non-Bot-Mode installs. + agent._bot_mode_protocol = bool(_agent_section.get("bot_mode_protocol", True)) + # Session-title hint for the "Bot Chat" gate: hosts that defer the DB + # title write past the first prompt build (tui_gateway pending_title) + # set this so the gate doesn't depend on write ordering. + agent._session_title_hint = None + + # Per-platform prompt-hint overrides (config.yaml → platform_hints: + # : {append: ..} | {replace: ..}). Stored verbatim; resolved in + # agent/system_prompt.py. Invalid shapes are ignored so a bad entry can + # never break prompt assembly. + _platform_hints_cfg = _agent_cfg.get("platform_hints", {}) + if not isinstance(_platform_hints_cfg, dict): + _platform_hints_cfg = {} + agent._platform_hint_overrides = _platform_hints_cfg + + # App-level API retry count (wraps each model API call). Default 3, + # overridable via agent.api_max_retries in config.yaml. See #11616. + try: + _raw_api_retries = _agent_section.get("api_max_retries", 3) + _api_retries = int(_raw_api_retries) + _api_retries = max(_api_retries, 1) # 1 = no retry (single attempt) + except (TypeError, ValueError): + _api_retries = 3 + agent._api_max_retries = _api_retries + + +def _parse_compression_config(agent, _agent_cfg): + # Initialize context compressor for automatic context management + # Compresses conversation when approaching model's context limit + # Configuration via config.yaml (compression section) + _compression_cfg = _agent_cfg.get("compression", {}) + if not isinstance(_compression_cfg, dict): + _compression_cfg = {} + compression_threshold = float(_compression_cfg.get("threshold", 0.50)) + # Per-model compaction-threshold override: Codex gpt-5.4/5.5 raise to 85% + # (backend caps at 272K, so 50% would compact at ~136K). Opt-out flag + # restores the global threshold; when it fires a one-time notice is + # stashed for the first turn, with its own display gate. + _codex_gpt55_autoraise = _cfg_flag(_compression_cfg, "codex_gpt55_autoraise", True) + _codex_gpt55_autoraise_notice = _cfg_flag(_compression_cfg, "codex_gpt55_autoraise_notice", True) + agent._compression_threshold_autoraised = None + try: + from agent.auxiliary_client import ( + _compression_threshold_for_model as _cthresh_fn, + _is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn, + _is_codex_spark as _is_codex_spark_fn, + ) + _model_cthresh = _cthresh_fn( + agent.model, + agent.provider, + allow_codex_gpt55_autoraise=_codex_gpt55_autoraise, + ) + # Codex autoraises apply only when they RAISE (never lower a higher + # global threshold); Arcee Trinity keeps its unconditional override. + compression_threshold, agent._compression_threshold_autoraised = ( + _resolve_compression_threshold( + compression_threshold, + _model_cthresh, + model=agent.model, + is_codex_autoraise=( + _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider) + or _is_codex_spark_fn(agent.model, agent.provider) + ), + ) + ) + except Exception: + pass + compression_enabled = _cfg_flag(_compression_cfg, "enabled", True) + compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) + compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) + # compression.tail_mode: "lean" (default) keeps a clamped 2.5%/10K-25K + # verbatim tail — continuity rides the summary (digests, anchors, pointers). + # "legacy" restores the 0.20*threshold tail, which hoards 100-240K tokens + # on big windows. Unknown values fall back to lean inside the compressor. + compression_tail_mode = str(_compression_cfg.get("tail_mode", "lean")).strip().lower() + # compression.min_tail_user_messages: actionable user messages guaranteed + # to survive in the uncompressed tail (default 1, floor 1). + compression_min_tail_users = max( + 1, _parse_config_int(_compression_cfg.get("min_tail_user_messages", 1), 1) + ) + # compression.max_attempts: retry rounds before "max compression attempts + # reached". Some sessions legitimately need >3 (incompressible tool + # schemas keep the estimate above threshold). Default 3, floor 1, cap 10; + # bool/fractional/garbage → 3 (see _parse_config_int). + compression_max_attempts = _parse_config_int(_compression_cfg.get("max_attempts", 3), 3) + if compression_max_attempts < 1: + compression_max_attempts = 3 + compression_max_attempts = min(compression_max_attempts, 10) + + # Opt-in proactive tool-result prune trigger (0 = disabled — the + # default, so an unset key is behavior-neutral). Negative values are + # treated as disabled rather than erroring. + compression_proactive_prune_tokens = max( + 0, _parse_config_int(_compression_cfg.get("proactive_prune_tokens", 0), 0) + ) + compression_proactive_prune_min_chars = _parse_config_int( + _compression_cfg.get("proactive_prune_min_result_chars", 8000), 8000 + ) + compression_proactive_prune_min_reclaim = max( + 0, + _parse_config_int( + _compression_cfg.get("proactive_prune_min_reclaim_tokens", 4096), 4096 + ), + ) + # protect_first_n: non-system head messages to protect (system prompt is + # always protected). 0 = "system prompt + summary + tail" is legitimate. + compression_protect_first = max( + 0, int(_compression_cfg.get("protect_first_n", 3)) + ) + compression_abort_on_summary_failure = _cfg_flag(_compression_cfg, "abort_on_summary_failure", False) + # Per-model threshold overrides: keys are substring-matched against the + # model name (longest match wins). Empty dict = use the global threshold + # for all models (backward compatible). + _raw_model_thresholds = _compression_cfg.get("model_thresholds", {}) + if isinstance(_raw_model_thresholds, dict): + compression_model_thresholds = { + str(k): float(v) for k, v in _raw_model_thresholds.items() + if isinstance(v, (int, float)) and not isinstance(v, bool) + } + else: + compression_model_thresholds = {} + # Absolute token cap: when set, compression triggers at the lower of + # the ratio-based threshold and this absolute count. Clamped to the + # model's context length at apply-time so a cap above the window is + # a no-op (ratio-based threshold wins). + compression_threshold_tokens = _compression_cfg.get("threshold_tokens") + if compression_threshold_tokens is not None: + try: + compression_threshold_tokens = int(compression_threshold_tokens) + if compression_threshold_tokens <= 0: + compression_threshold_tokens = None + except (TypeError, ValueError): + compression_threshold_tokens = None + compression_checkpoint_required = is_truthy_value( + _compression_cfg.get("checkpoint_required"), default=False + ) + _refuse_checkpoint_required_on_codex_app_server( + compression_checkpoint_required, getattr(agent, "api_mode", None) + ) + # In-place compaction: compress_context() rewrites messages + system prompt + # WITHOUT rotating the session id (no parent chain / `name #N`). Rides on + # the agent, not the compressor. default=True MUST match + # DEFAULT_CONFIG["compression"]["in_place"] — a False default flipped + # agents into rotation mode whenever the merged config omitted the key. + compression_in_place = is_truthy_value( + _compression_cfg.get("in_place"), default=True + ) + # Opt-in (default False): micro-compaction rewrites already-sent history + # per turn, breaking the prompt-cache prefix on a per-turn cadence — the + # very cost proactive_prune_min_reclaim_tokens exists to amortize. + compression_micro_compact = is_truthy_value( + _compression_cfg.get("micro_compact"), default=False + ) + # How often a pass runs, in completed turns. Each pass rewrites + # already-sent history and costs one prompt-cache break, so this is the + # dial for how often that cost is paid: 1 = every turn (most aggressive + # reclaim), 5 = one break per five turns. Clamped to >= 1. + compression_micro_compact_every_n_turns = max( + 1, + _parse_config_int(_compression_cfg.get("micro_compact_every_n_turns", 1), 1), + ) + # Rolling-summary defrag threshold, in tokens. Lived on the compressor as + # a hardcoded attribute with no path from config until now. + compression_micro_compact_defrag_tokens = max( + 1, + _parse_config_int( + _compression_cfg.get("micro_compact_defrag_threshold_tokens", 2000), + 2000, + ), + ) + codex_app_server_auto_compaction = str( + _compression_cfg.get("codex_app_server_auto", "native") or "native" + ).lower() + if codex_app_server_auto_compaction not in {"native", "hermes", "off"}: + _ra().logger.warning( + "Invalid compression.codex_app_server_auto=%r; using 'native'. " + "Valid values are: native, hermes, off.", + codex_app_server_auto_compaction, + ) + codex_app_server_auto_compaction = "native" + # Native OpenAI Responses server-side compaction (opt-in; per-request gate + # in agent/native_compaction.py). Truthy coercion: "false"/"off" strings + # must stay disabled (bool("false") is True). + codex_responses_native_compaction = is_truthy_value( + _compression_cfg.get("codex_responses_native", False) + ) + _native_threshold_raw = _compression_cfg.get("codex_responses_compact_threshold") + codex_responses_compact_threshold = None + if _native_threshold_raw is not None: + try: + if isinstance(_native_threshold_raw, (bool, float)): + raise ValueError + codex_responses_compact_threshold = int(_native_threshold_raw) + if codex_responses_compact_threshold <= 0: + raise ValueError + except (TypeError, ValueError): + _ra().logger.warning( + "Invalid compression.codex_responses_compact_threshold=%r; " + "using the automatic threshold derived from local compression.", + _native_threshold_raw, + ) + codex_responses_compact_threshold = None + # Opt-in idle compaction: compact a session up front when it resumes after + # this many seconds of inactivity (0 = disabled). Time-based, so it + # complements the size-based threshold above. Consumed by build_turn_context(). + compression_idle_compact_after_seconds = max( + 0, int(_compression_cfg.get("idle_compact_after_seconds", 0)) + ) + return CompressionSettings( + threshold=compression_threshold, + autoraise_notice_enabled=_codex_gpt55_autoraise_notice, + enabled=compression_enabled, + target_ratio=compression_target_ratio, + protect_last=compression_protect_last, + tail_mode=compression_tail_mode, + min_tail_users=compression_min_tail_users, + max_attempts=compression_max_attempts, + proactive_prune_tokens=compression_proactive_prune_tokens, + proactive_prune_min_chars=compression_proactive_prune_min_chars, + proactive_prune_min_reclaim=compression_proactive_prune_min_reclaim, + protect_first=compression_protect_first, + abort_on_summary_failure=compression_abort_on_summary_failure, + model_thresholds=compression_model_thresholds, + threshold_tokens=compression_threshold_tokens, + checkpoint_required=compression_checkpoint_required, + in_place=compression_in_place, + micro_compact=compression_micro_compact, + micro_compact_every_n_turns=compression_micro_compact_every_n_turns, + micro_compact_defrag_tokens=compression_micro_compact_defrag_tokens, + codex_app_server_auto=codex_app_server_auto_compaction, + codex_responses_native=codex_responses_native_compaction, + codex_responses_compact_threshold=codex_responses_compact_threshold, + idle_compact_after_seconds=compression_idle_compact_after_seconds, + ) + + +def _warn_invalid_config_int( + what: str, value: Any, requirement: str, fallback: str, print_fallback: str = "", +) -> None: + """Log + stderr-print an invalid integer config value. + + ``print_fallback`` lets the user-facing line keep its historical wording + where it differs from the log line. + """ + _ra().logger.warning( + "Invalid %s: %r — %s. Falling back to %s.", what, value, requirement, fallback, + ) + print( + f"\n⚠ Invalid {what}: {value!r}\n" + f" {requirement[0].upper() + requirement[1:]}.\n" + f" Falling back to {print_fallback or fallback}.\n", + file=sys.stderr, + ) + + +def _custom_provider_configured_base_url( + _configured_provider: str, _agent_cfg, _custom_providers +) -> str: + """Base URL of a named custom provider (``providers.`` first, then + ``custom_providers``), normalized for route comparison; "" if unknown. + Disabled ``providers.*`` entries also mask their ``custom_providers`` twin. + """ + _configured_base_url = "" + _configured_custom_provider = _normalize_custom_provider_name( + _configured_provider + ) + _user_providers = _agent_cfg.get("providers") + _disabled_custom_provider_ids: set[str] = set() + if isinstance(_user_providers, dict): + from hermes_cli.config import is_provider_enabled + + for _provider_key, _provider_entry in _user_providers.items(): + if not isinstance(_provider_entry, dict): + continue + _entry_name = str( + _provider_entry.get("name") or "" + ).strip() + _entry_provider_ids = _custom_provider_runtime_ids( + _provider_key + ) | _custom_provider_runtime_ids(_entry_name) + if not is_provider_enabled(_provider_entry): + _disabled_custom_provider_ids.update( + provider_id + for provider_id in _entry_provider_ids + if provider_id + ) + continue + if _configured_custom_provider not in _entry_provider_ids: + continue + _configured_base_url = _normalize_route_base_url( + _provider_entry.get("api") + or _provider_entry.get("url") + or _provider_entry.get("base_url") + ) + if _configured_base_url: + break + if not _configured_base_url: + for _provider_entry in _custom_providers: + if not isinstance(_provider_entry, dict): + continue + _entry_name = str( + _provider_entry.get("name") or "" + ).strip() + _entry_provider_key = str( + _provider_entry.get("provider_key") or "" + ).strip().lower() + _entry_provider_ids = _custom_provider_runtime_ids( + _entry_name + ) | _custom_provider_runtime_ids(_entry_provider_key) + if ( + _entry_provider_key + and _custom_provider_runtime_ids(_entry_provider_key) + & _disabled_custom_provider_ids + ): + continue + if _configured_custom_provider not in _entry_provider_ids: + continue + _configured_base_url = _normalize_route_base_url( + _provider_entry.get("base_url") + ) + if _configured_base_url: + break + return _configured_base_url + + +def _scope_context_length_to_default_runtime( + agent, _agent_cfg, _model_cfg, _custom_providers, _config_context_length, base_url +) -> Optional[int]: + """Return ``model.context_length`` only if it describes the active runtime. + + ``model.context_length`` describes the configured default model. A process + launched directly with ``--model`` / ``-m`` has already replaced + ``agent.model`` before this initializer loads config, so carrying the + default model's explicit window into that different runtime is stale. The + live switch/fallback paths already clear this override; keep direct-start + overrides consistent with them and let provider metadata resolve the + active model's window instead. + """ + _default = _model_cfg.get("default") + if isinstance(_default, dict): + from hermes_cli.config import split_model_config_default + _default, _ = split_model_config_default(_default) + _configured_default_model = str(_default or "").strip() + _configured_default_runtime_model = _configured_default_model + _active_runtime_model = agent.model + if _configured_default_model: + try: + from hermes_cli.model_normalize import normalize_model_for_provider + + _configured_default_runtime_model = normalize_model_for_provider( + _configured_default_model, agent.provider + ) + _active_runtime_model = normalize_model_for_provider( + agent.model, agent.provider + ) + except Exception: + pass + _configured_provider = str(_model_cfg.get("provider") or "").strip() + _configured_base_url = _normalize_route_base_url( + _model_cfg.get("base_url") + ) + _configured_provider_norm = _normalize_custom_provider_name( + _configured_provider + ) + _custom_provider_candidate = bool(_configured_provider_norm) + _runtime_first_provider_ids = { + "auto", + "moa", + "vertex", + "google-vertex", + "vertex-ai", + "gcp-vertex", + "vertexai", + } + if _configured_provider_norm in _runtime_first_provider_ids: + _custom_provider_candidate = False + elif ( + _custom_provider_candidate + and _configured_provider_norm != "custom" + and not _configured_provider_norm.startswith("custom:") + ): + try: + from hermes_cli.auth import resolve_provider as resolve_auth_provider + + _resolved_auth_provider = resolve_auth_provider( + _configured_provider_norm + ) + _custom_provider_candidate = ( + str(_resolved_auth_provider or "").strip().lower() + != _configured_provider_norm + ) + except Exception: + pass + if not _configured_base_url and _custom_provider_candidate: + _configured_base_url = _custom_provider_configured_base_url( + _configured_provider, _agent_cfg, _custom_providers + ) + _active_route_url = str(agent.base_url or "") + _requested_route_url = str(base_url or "") + if "?" in _requested_route_url.split("#", 1)[0]: + try: + _requested_parts = urlparse(_requested_route_url) + _requested_without_query = urlunparse( + _requested_parts._replace(query="") + ) + if _normalize_route_base_url( + _requested_without_query + ) == _normalize_route_base_url(_active_route_url): + _active_route_url = _requested_route_url + except (TypeError, ValueError): + pass + _active_base_url = _normalize_route_base_url(_active_route_url) + _route_mismatch = _context_route_mismatch( + _configured_base_url, + _active_base_url, + _configured_provider, + agent.provider, + already_normalized=True, + ) + _model_mismatch = bool( + _configured_default_runtime_model + and _configured_default_runtime_model != _active_runtime_model + ) + if _model_mismatch or _route_mismatch: + _ra().logger.debug( + "Ignoring model.context_length=%s for startup runtime %s at %s " + "(configured default is %s at %s)", + _config_context_length, + agent.model, + _active_base_url or agent.provider, + _configured_default_model, + _configured_base_url or _model_cfg.get("provider"), + ) + return None + return _config_context_length + + +def _resolve_context_length(agent, _agent_cfg, base_url): + # Read optional explicit context_length override for the auxiliary + # compression model. Custom endpoints often cannot report this via + # /models, so the startup feasibility check needs the config hint. + try: + _aux_cfg = cfg_get(_agent_cfg, "auxiliary", "compression", default={}) + except Exception: + _aux_cfg = {} + if isinstance(_aux_cfg, dict): + _aux_context_config = _aux_cfg.get("context_length") + else: + _aux_context_config = None + if _aux_context_config is not None: + try: + _aux_context_config = int(_aux_context_config) + except (TypeError, ValueError): + _aux_context_config = None + agent._aux_compression_context_length_config = _aux_context_config + + # Read explicit model output-token override from config when the + # caller did not pass one directly. + _model_cfg = _agent_cfg.get("model", {}) + if agent.max_tokens is None and isinstance(_model_cfg, dict): + _config_max_tokens = _model_cfg.get("max_tokens") + if _config_max_tokens is not None: + try: + if isinstance(_config_max_tokens, bool): + raise ValueError + _parsed_max_tokens = int(_config_max_tokens) + if _parsed_max_tokens <= 0: + raise ValueError + agent.max_tokens = _parsed_max_tokens + except (TypeError, ValueError): + _warn_invalid_config_int( + "model.max_tokens in config.yaml", _config_max_tokens, + "must be a positive integer (e.g. 4096)", "provider default", + ) + agent._session_init_model_config["max_tokens"] = agent.max_tokens + + # Read explicit context_length override from model config + if isinstance(_model_cfg, dict): + _config_context_length = _model_cfg.get("context_length") + else: + _config_context_length = None + if _config_context_length is not None: + try: + _config_context_length = int(_config_context_length) + except (TypeError, ValueError): + _warn_invalid_config_int( + "model.context_length in config.yaml", _config_context_length, + "must be a plain integer (e.g. 256000, not '256K')", + "auto-detection", "auto-detected context window", + ) + _config_context_length = None + + # Resolve custom_providers once before route-scoping a global context pin: + # a named custom provider may keep its base URL only in this list rather + # than repeating it under ``model``. + try: + from hermes_cli.config import get_compatible_custom_providers + _custom_providers = get_compatible_custom_providers(_agent_cfg) + except Exception: + _custom_providers = _agent_cfg.get("custom_providers") + if not isinstance(_custom_providers, list): + _custom_providers = [] + + # ``model.context_length`` describes the configured default model; drop it + # when the startup runtime (model or route) differs from that default. + if _config_context_length is not None and isinstance(_model_cfg, dict): + _config_context_length = _scope_context_length_to_default_runtime( + agent, _agent_cfg, _model_cfg, _custom_providers, _config_context_length, base_url + ) + + # Store for reuse by _check_compression_model_feasibility (auxiliary + # compression model context-length detection needs the same list). + agent._custom_providers = _custom_providers + _merge_custom_provider_extra_body(agent, _custom_providers) + + # Check custom_providers per-model context_length + if _config_context_length is None and _custom_providers: + try: + from hermes_cli.config import get_custom_provider_context_length + _cp_ctx_resolved = get_custom_provider_context_length( + model=agent.model, + base_url=agent.base_url, + custom_providers=_custom_providers, + ) + if _cp_ctx_resolved: + _config_context_length = int(_cp_ctx_resolved) + except Exception: + _cp_ctx_resolved = None + + # Surface a clear warning if the user set a context_length but it + # wasn't a valid positive int — the helper silently skips those. + if _config_context_length is None: + _target = _normalize_route_base_url(agent.base_url) + for _cp_entry in _custom_providers: + if not isinstance(_cp_entry, dict): + continue + _cp_url = _normalize_route_base_url(_cp_entry.get("base_url")) + if _target and _cp_url == _target: + _cp_models = _cp_entry.get("models", {}) + if isinstance(_cp_models, dict): + _cp_model_cfg = _cp_models.get(agent.model, {}) + if isinstance(_cp_model_cfg, dict): + _cp_ctx = _cp_model_cfg.get("context_length") + if _cp_ctx is not None: + try: + _parsed = int(_cp_ctx) + if _parsed <= 0: + raise ValueError + except (TypeError, ValueError): + _warn_invalid_config_int( + f"context_length for model {agent.model!r} in custom_providers", + _cp_ctx, + "must be a positive integer (e.g. 256000, not '256K')", + "auto-detection", "auto-detected context window", + ) + break + + # Persist for reuse on switch_model / fallback activation. Must come + # AFTER the custom_providers branch so per-model overrides aren't lost. + agent._config_context_length = _config_context_length + + _lmstudio_runtime_context_length = agent._ensure_lmstudio_runtime_loaded( + _config_context_length + ) + if agent._lmstudio_load_was_unverified(_lmstudio_runtime_context_length): + _ra().logger.warning( + "LM Studio model activation was rejected or completed without a " + "verifiable active context length; falling back to configured context" + ) + _effective_context_length = agent._effective_lmstudio_context_length( + _config_context_length, + _lmstudio_runtime_context_length, + ) + return _config_context_length, _custom_providers, _effective_context_length, _model_cfg + + +def _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_context_length, session_db): + # Select context engine: config-driven (like memory providers). + # 1. Check config.yaml context.engine setting + # 2. Check plugins/context_engine// directory (repo-shipped) + # 3. Check general plugin system (user-installed plugins) + # 4. Fall back to built-in ContextCompressor + _selected_engine = None + _copy_failed = False + _engine_name = "compressor" # default + try: + _engine_name = _agent_cfg.get("context", {}).get("engine", "compressor") or "compressor" + except Exception: + pass + + if _engine_name != "compressor": + # Try loading from plugins/context_engine// + try: + from plugins.context_engine import load_context_engine + _selected_engine = load_context_engine(_engine_name) + except Exception as _ce_load_err: + _ra().logger.debug("Context engine load from plugins/context_engine/: %s", _ce_load_err) + + # Try general plugin system as fallback + if _selected_engine is None: + _candidate = None + try: + from hermes_cli.plugins import get_plugin_context_engine + _candidate = get_plugin_context_engine() + except Exception: + _candidate = None + if _candidate is not None and _candidate.name == _engine_name: + # Deep-copy the shared plugin singleton so a child's + # update_model() can't mutate the parent's compressor. Uncopyable + # state (locks, DB conns) → built-in compressor with an ACCURATE + # message, not "not found". + import copy + try: + _selected_engine = copy.deepcopy(_candidate) + except Exception as _copy_err: + _copy_failed = True + _ra().logger.warning( + "Context engine '%s' could not be safely copied for this " + "agent (%s) — falling back to built-in compressor. Plugin " + "engines that hold uncopyable state (locks, DB connections) " + "should implement __deepcopy__ to copy only mutable budget " + "state.", + _engine_name, _copy_err, + ) + _selected_engine = None + + if _selected_engine is None and not _copy_failed: + _ra().logger.warning( + "Context engine '%s' not found — falling back to built-in compressor", + _engine_name, + ) + # else: config says "compressor" — use built-in, don't auto-activate plugins + + if _selected_engine is not None: + agent.context_compressor = _selected_engine + # External engines own compaction policy — the host threshold (and its + # Codex autoraise) never reaches the plugin, so drop the notice. + agent._compression_threshold_autoraised = None + # Resolve context_length for plugin engines — mirrors switch_model() path + from agent.model_metadata import get_model_context_length + _plugin_ctx_len = get_model_context_length( + agent.model, + base_url=agent.base_url, + api_key=getattr(agent, "api_key", ""), + config_context_length=_effective_context_length, + provider=agent.provider, + custom_providers=_custom_providers, + ) + # Assign per-model threshold overrides BEFORE the initial update_model() + # so the first threshold resolution already sees them (assigning after + # left the initial model on the global threshold until a /model switch). + if cs.model_thresholds: + agent.context_compressor.model_thresholds = cs.model_thresholds + agent.context_compressor.update_model( + model=agent.model, + context_length=_plugin_ctx_len, + base_url=agent.base_url, + api_key=getattr(agent, "api_key", ""), + provider=agent.provider, + api_mode=agent.api_mode, + ) + if not agent.quiet_mode: + _ra().logger.info("Using context engine: %s", _selected_engine.name) + else: + # Native Gemini output reservation: with model.max_tokens unset the + # generateContent adapter still sends maxOutputTokens=65,535, and the + # compressor threshold is pct×(window − max_tokens). Reserving 0 here + # while the wire reserved 65,535 let the provider 400 before compaction + # fired, so mirror the adapter's documented default (native Gemini only). + _compressor_max_tokens = agent.max_tokens + if _compressor_max_tokens is None: + try: + from agent.gemini_native_adapter import ( + GEMINI_DEFAULT_MAX_OUTPUT_TOKENS, + is_native_gemini_base_url, + ) + _gemini_provider = str( + getattr(agent, "provider", "") or "" + ).strip().lower() in { + "gemini", "google", "google-gemini", "google-ai-studio", + } + if _gemini_provider or is_native_gemini_base_url(agent.base_url): + _compressor_max_tokens = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS + except Exception: + pass + agent.context_compressor = ContextCompressor( + model=agent.model, + threshold_percent=cs.threshold, + protect_first_n=cs.protect_first, + protect_last_n=cs.protect_last, + summary_target_ratio=cs.target_ratio, + summary_model_override=None, + quiet_mode=agent.quiet_mode, + base_url=agent.base_url, + api_key=getattr(agent, "api_key", ""), + config_context_length=_effective_context_length, + provider=agent.provider, + api_mode=agent.api_mode, + abort_on_summary_failure=cs.abort_on_summary_failure, + max_tokens=_compressor_max_tokens, + model_thresholds=cs.model_thresholds, + threshold_tokens_cap=cs.threshold_tokens, + proactive_prune_tokens=cs.proactive_prune_tokens, + proactive_prune_min_result_chars=cs.proactive_prune_min_chars, + proactive_prune_min_reclaim_tokens=cs.proactive_prune_min_reclaim, + min_tail_user_messages=cs.min_tail_users, + tail_mode=cs.tail_mode, + ) + _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) + if callable(_bind_session_state): + try: + _bind_session_state(session_db=session_db, session_id=agent.session_id) + except Exception: + pass + agent.compression_enabled = cs.enabled + agent.compression_in_place = cs.in_place + # Apply micro-compaction settings to the compressor (feature is opt-in) + _cc = agent.context_compressor + # checkpoint_required: micro-compaction is a lossy rewrite with no + # pre-compress checkpoint hook, so suppress it while the gate is armed + # (mirrors the native-compaction suppression in native_compaction.py). + if cs.checkpoint_required and cs.micro_compact: + logger.warning( + "compression.checkpoint_required is enabled: post-turn " + "micro-compaction is disabled for this agent so every lossy " + "rewrite passes through the checkpoint-gated compressor." + ) + cs.micro_compact = False + if hasattr(_cc, "_micro_compact_enabled"): + _cc._micro_compact_enabled = cs.micro_compact + if hasattr(_cc, "_micro_compact_every_n_turns"): + _cc._micro_compact_every_n_turns = cs.micro_compact_every_n_turns + if hasattr(_cc, "_micro_compact_defrag_threshold_tokens"): + _cc._micro_compact_defrag_threshold_tokens = cs.micro_compact_defrag_tokens + agent.compression_checkpoint_required = cs.checkpoint_required + agent.codex_app_server_auto_compaction = cs.codex_app_server_auto + agent.codex_responses_native_compaction = cs.codex_responses_native + agent.codex_responses_compact_threshold = cs.codex_responses_compact_threshold + from agent.native_compaction import resolve_native_compaction_capabilities + agent.runtime_capabilities = resolve_native_compaction_capabilities( + model=agent.model, + base_url=agent.base_url, + provider=agent.provider, + is_codex_backend=(agent.provider or "").strip().lower() == "openai-codex", + ) + agent.max_compression_attempts = cs.max_attempts + agent.compression_idle_compact_after_seconds = ( + cs.idle_compact_after_seconds + ) + + +def _enforce_minimum_context(agent): + # Reject models whose context window is below the minimum required + # for reliable tool-calling workflows (64K tokens). + _ctx = getattr(agent.context_compressor, "context_length", 0) + _allow_lmstudio_explicit_below_floor = ( + str(getattr(agent, "provider", "") or "").strip().lower() == "lmstudio" + and isinstance(agent._config_context_length, int) + and not isinstance(agent._config_context_length, bool) + and agent._config_context_length > 0 + ) + if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH and not _allow_lmstudio_explicit_below_floor: + raise ValueError( + f"Model {agent.model} has a context window of {_ctx:,} tokens, " + f"which is below the minimum {MINIMUM_CONTEXT_LENGTH:,} required " + f"by Hermes Agent. Choose a model with at least " + f"{MINIMUM_CONTEXT_LENGTH // 1000}K context. If your server " + f"reports a window smaller than the model's true window, set " + f"model.context_length in config.yaml to the real value " + f"(this must be at least {MINIMUM_CONTEXT_LENGTH // 1000}K)." + ) + + +def _warn_nonagentic_hermes_model(agent): + # Nous Hermes 3/4 are chat models, not tool-call-tuned. cli.py show_banner() + # already warns on the CLI, so skip platform=="cli" to avoid a double + # warning; non-quiet non-CLI surfaces still get it. + if not agent.quiet_mode and (agent.platform or "cli") != "cli": + try: + from hermes_cli.model_switch import _check_hermes_model_warning + + _hermes_warn = _check_hermes_model_warning(agent.model or "") + if _hermes_warn: + _user_msg = ( + "⚠ Nous Research Hermes 3 & 4 models are NOT agentic — they " + "lack reliable tool-calling for agent workflows (delegation, " + "cron, proactive tools). Consider an agentic model instead " + "(Claude, GPT, Gemini, Qwen-Coder, etc.)." + ) + if hasattr(agent, "_emit_warning"): + agent._emit_warning(_user_msg) + else: + print(f"\n{_user_msg}\n", file=sys.stderr) + _ra().logger.warning(_hermes_warn) + except Exception: + pass + + +def _inject_context_engine_tools(agent): + # Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand). + # Dedup against existing names: plugin paths may register the same schemas + # via ctx.register_tool(), and a duplicate trips provider-side 'duplicate + # tool name' errors. Gate on enabled_toolsets like memory-provider tools so + # `platform_toolsets: telegram: []` cannot leak lcm_* tools. + agent._context_engine_tool_names: set = set() + if ( + agent.context_compressor + and agent.tools is not None + and ( + agent.enabled_toolsets is None + or "context_engine" in agent.enabled_toolsets + ) + ): + _existing_tool_names = { + t.get("function", {}).get("name") + for t in agent.tools + if isinstance(t, dict) + } + from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema + for _raw_schema in agent.context_compressor.get_tool_schemas(): + _schema = _normalize_tool_schema(_raw_schema) + if _schema is None: + # A schema with no resolvable name (e.g. an already-wrapped + # entry) would append a nameless tool that strict providers + # 400 on, disabling the whole toolset (#47707). Skip it. + _ra().logger.warning( + "Context engine returned a tool schema with no resolvable " + "name; skipping to avoid poisoning the request (%r)", + _raw_schema, + ) + continue + _tname = _schema["name"] + if _tname in _existing_tool_names: + continue # already registered via plugin/cache path + _wrapped = {"type": "function", "function": _schema} + agent.tools.append(_wrapped) + agent.valid_tool_names.add(_tname) + agent._context_engine_tool_names.add(_tname) + _existing_tool_names.add(_tname) + + # Notify context engine of session start + if agent.context_compressor: + try: + agent.context_compressor.on_session_start( + agent.session_id, + hermes_home=str(get_hermes_home()), + platform=agent.platform or "cli", + model=agent.model, + context_length=getattr(agent.context_compressor, "context_length", 0), + conversation_id=getattr(agent, "_gateway_session_key", None), + ) + except Exception as _ce_err: + _ra().logger.debug("Context engine on_session_start: %s", _ce_err) + + +def _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length): + # Ollama num_ctx: Ollama defaults to 2048 regardless of the model, so detect + # the max window and pass num_ctx on every request. model.ollama_num_ctx + # overrides; model.context_length caps the detected value (VRAM budget). + agent._ollama_num_ctx: int | None = None + _ollama_num_ctx_override = None + if isinstance(_model_cfg, dict): + _ollama_num_ctx_override = _model_cfg.get("ollama_num_ctx") + if _ollama_num_ctx_override is not None: + try: + agent._ollama_num_ctx = int(_ollama_num_ctx_override) + except (TypeError, ValueError): + _ra().logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override) + if agent._ollama_num_ctx is None and agent.base_url and is_local_endpoint(agent.base_url): + try: + # ``agent.api_key`` may be a callable (Entra token provider). + # Ollama detection makes a manual HTTP request and expects a + # string — Azure Foundry isn't a local endpoint so this branch + # never fires for Entra, but guard defensively. + _key_for_ollama = agent.api_key if isinstance(agent.api_key, str) else "" + _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=_key_for_ollama or "") + if _detected and _detected > 0: + agent._ollama_num_ctx = _detected + except Exception as exc: + _ra().logger.debug("Ollama num_ctx detection failed: %s", exc) + # Cap auto-detected num_ctx to the explicit context_length: GGUF metadata + # can advertise 256K+ and Ollama would allocate that much VRAM. + if ( + agent._ollama_num_ctx + and _config_context_length + and _ollama_num_ctx_override is None # don't override explicit ollama_num_ctx + and agent._ollama_num_ctx > _config_context_length + ): + _ra().logger.info( + "Ollama num_ctx capped: %d -> %d (model.context_length override)", + agent._ollama_num_ctx, _config_context_length, + ) + agent._ollama_num_ctx = _config_context_length + if agent._ollama_num_ctx and not agent.quiet_mode: + _ra().logger.info( + "Ollama num_ctx: will request %d tokens (model max from /api/show)", + agent._ollama_num_ctx, + ) + # Recalibrate the compressor to the served window: it was built from the + # probed model window (GGUF may advertise 256K+) but every request runs at + # num_ctx, so with only model.ollama_num_ctx set the compaction trigger + # could sit above the real window and never fire. Clamp to num_ctx. + _cc_window = getattr(agent.context_compressor, "context_length", 0) or 0 + if ( + agent._ollama_num_ctx + and agent._ollama_num_ctx > 0 + and _cc_window + and agent._ollama_num_ctx < _cc_window + ): + _ra().logger.info( + "Compressor window clamped to Ollama num_ctx: %d -> %d", + _cc_window, agent._ollama_num_ctx, + ) + agent.context_compressor.update_model( + model=agent.model, + context_length=agent._ollama_num_ctx, + base_url=agent.base_url, + api_key=getattr(agent, "api_key", ""), + provider=agent.provider, + api_mode=agent.api_mode, + ) + + +def _emit_compression_summary(agent, cs): + # Codex gpt-5.x autoraise notice: at most once per profile/config state + # (persisted marker — the gateway rebuilds the agent per message). A + # changed threshold/model re-notifies once; the display gate suppresses + # the banner without disabling the autoraise. + _autoraise = agent._compression_threshold_autoraised or {} + _autoraise_notice = None + if ( + bool(_autoraise) + and cs.enabled + and cs.autoraise_notice_enabled + and not _codex_gpt55_autoraise_notice_seen(_autoraise) + ): + _autoraise_notice = _build_codex_gpt5_autoraise_notice( + _autoraise, + context_length=getattr(agent.context_compressor, "context_length", None), + ) + + if not agent.quiet_mode: + if cs.enabled: + # Report the active engine's own threshold — for a plugin engine + # the host cs.threshold is not in effect, and mixing the + # two printed a percent that contradicted the token count. (#44439) + _active_threshold_pct = getattr( + agent.context_compressor, "threshold_percent", cs.threshold + ) + _cap_note = "" + _cap = getattr(agent.context_compressor, "threshold_tokens_cap", None) + if _cap and _cap > 0: + _cap_note = f" (capped at {_cap:,} tokens)" + print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})") + else: + print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)") + # Notice with the exact opt-back-out command. Printed inline at startup + # for CLI users; gateway users get the same text replayed via + # _compression_warning on turn 1 (set below). + if _autoraise_notice: + print(_autoraise_notice) + + # Gateway parity: the startup print only reaches the CLI; status_callback + # isn't wired yet, so stash the text to replay on the first run_conversation(). + agent._compression_warning = _autoraise_notice + # Mark shown so repeated inits in this profile (every gateway message) stay + # silent, whether the notice went to the CLI print or the replay slot. + if _autoraise_notice: + _record_codex_gpt55_autoraise_notice(_autoraise) + # Feasibility check is deferred to the first turn near the threshold + # (eager costs ~400ms cold per init); run_conversation's preflight runs + # ensure_compression_feasibility_checked at most once per agent. + agent._compression_feasibility_checked = False + + +def _snapshot_primary_runtime(agent): + # 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 = agent.context_compressor + agent._primary_runtime = { + "model": agent.model, + "provider": agent.provider, + "requested_provider": agent.requested_provider, + "base_url": agent.base_url, + "api_mode": agent.api_mode, + "api_key": getattr(agent, "api_key", ""), + "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), + "client_kwargs": dict(agent._client_kwargs), + "use_prompt_caching": agent._use_prompt_caching, + "use_native_cache_layout": agent._use_native_cache_layout, + "reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False), + # Context engine state that _try_activate_fallback() overwrites. + # Use getattr for model/base_url/api_key/provider since plugin + # engines may not have these (they're ContextCompressor-specific). + "compressor_model": getattr(_cc, "model", agent.model), + "compressor_base_url": getattr(_cc, "base_url", agent.base_url), + "compressor_api_key": getattr(_cc, "api_key", ""), + "compressor_provider": getattr(_cc, "provider", agent.provider), + "compressor_context_length": _cc.context_length, + "compressor_threshold_tokens": _cc.threshold_tokens, + } + if agent.api_mode == "anthropic_messages": + agent._primary_runtime.update({ + "anthropic_api_key": agent._anthropic_api_key, + "anthropic_base_url": agent._anthropic_base_url, + "is_anthropic_oauth": agent._is_anthropic_oauth, + }) + + +_CALLBACK_PARAMS = ( + "tool_progress_callback", "tool_start_callback", "tool_complete_callback", + "thinking_callback", "reasoning_callback", "clarify_callback", + "read_terminal_callback", "read_preview_callback", "drive_preview_callback", + "read_window_below_callback", "setup_mcp_callback", "tour_callback", + "step_callback", "stream_delta_callback", "interim_assistant_callback", + "status_callback", "notice_callback", "notice_clear_callback", + "event_callback", "reaction_callback", "tool_gen_callback", +) + + def init_agent( agent, base_url: str = None, @@ -615,54 +2871,22 @@ def init_agent( requested_provider: str = None, capabilities: Optional[Dict[str, bool]] = None, ): - """ - Initialize the AI Agent. + """Initialize the AI Agent (body of :meth:`AIAgent.__init__`). - Args: - base_url (str): Base URL for the model API (optional) - api_key (str): API key for authentication (optional, uses env var if not provided) - provider (str): Provider identifier (optional; used for telemetry/routing hints) - requested_provider (str): Original provider identity before runtime canonicalization - api_mode (str): API mode override: "chat_completions" or "codex_responses" - model (str): Model name to use (default: "anthropic/claude-opus-4.6") - max_iterations (int): Maximum number of tool calling iterations (default: 90) - enabled_toolsets (List[str]): Only enable tools from these toolsets (optional) - disabled_toolsets (List[str]): Disable tools from these toolsets (optional) - save_trajectories (bool): Whether to save conversation trajectories to JSONL files (default: False) - verbose_logging (bool): Enable verbose logging for debugging (default: False) - quiet_mode (bool): Suppress progress output for clean CLI experience (default: False) - ephemeral_system_prompt (str): System prompt used during agent execution but NOT saved to trajectories (optional) - log_prefix_chars (int): Number of characters to show in log previews for tool calls/responses (default: 100) - log_prefix (str): Prefix to add to all log messages for identification in parallel processing (default: "") - providers_allowed (List[str]): OpenRouter providers to allow (optional) - providers_ignored (List[str]): OpenRouter providers to ignore (optional) - providers_order (List[str]): OpenRouter providers to try in order (optional) - provider_sort (str): Sort providers by price/throughput/latency (optional) - openrouter_min_coding_score (float): Coding-score floor (0.0-1.0) for the - openrouter/pareto-code router. Only applied when model == "openrouter/pareto-code". - None or empty = let OpenRouter pick the strongest available coder. - session_id (str): Pre-generated session ID for logging (optional, auto-generated if not provided) - tool_progress_callback (callable): Callback function(tool_name, args_preview) for progress notifications - clarify_callback (callable): Callback function(question, choices) -> str for interactive user questions. - Provided by the platform layer (CLI or gateway). If None, the clarify tool returns an error. - max_tokens (int): Maximum tokens for model responses (optional, uses model default if not set) - reasoning_config (Dict): OpenRouter reasoning configuration override (e.g. {"effort": "none"} to disable thinking). - If None, defaults to {"enabled": True, "effort": "medium"} for OpenRouter. Set to disable/customize reasoning. - prefill_messages (List[Dict]): Messages to prepend to conversation history as prefilled context. - Useful for injecting a few-shot example or priming the model's response style. - Example: [{"role": "user", "content": "Hi!"}, {"role": "assistant", "content": "Hello!"}] - NOTE: Anthropic Sonnet 4.6+ and Opus 4.6+ reject a conversation that ends on an - assistant-role message (400 error). For those models use structured outputs or - output_config.format instead of a trailing-assistant prefill. - platform (str): The interface platform the user is on (e.g. "cli", "telegram", "discord", "whatsapp"). - Used to inject platform-specific formatting hints into the system prompt. - skip_context_files (bool): If True, skip auto-injection of project context files - (SOUL.md, .hermes.md, AGENTS.md, CLAUDE.md, .cursorrules) from the cwd / HERMES_HOME - into the system prompt. Use this for batch processing and data generation to avoid - polluting trajectories with user-specific persona or project instructions. - load_soul_identity (bool): If True, still use ~/.hermes/SOUL.md as the primary - identity even when skip_context_files=True. Project context files from the cwd - remain skipped. + Non-obvious parameters: + requested_provider: original provider identity before runtime canonicalization. + openrouter_min_coding_score: coding-score floor for ``openrouter/pareto-code`` + only; None/empty lets OpenRouter pick. + clarify_callback: ``(question, choices) -> str`` from the platform layer; if + None the clarify tool returns an error. + reasoning_config: None defaults to ``{"enabled": True, "effort": "medium"}`` + on OpenRouter. + prefill_messages: prepended to history as priming context. Anthropic Sonnet/ + Opus 4.6+ reject a conversation ending on an assistant message (400) — use + structured outputs instead of a trailing-assistant prefill there. + skip_context_files: skip SOUL.md/.hermes.md/AGENTS.md/CLAUDE.md/.cursorrules + injection (batch/data-generation runs). load_soul_identity keeps + ~/.hermes/SOUL.md as identity even when skip_context_files=True. """ _install_safe_stdio() @@ -694,12 +2918,9 @@ def init_agent( agent.memory_notifications = "on" # Memory update notifications: "off", "on", "verbose" agent.skip_context_files = skip_context_files agent.load_soul_identity = load_soul_identity - # Background review (memory/skill) opt-out switch. When True, skips the - # _spawn_background_review fork at end-of-turn -- avoids ~30K tokens / - # event of extra LLM cost on cron-style sessions where review forks - # provide no value (no human in the loop, no skill-creation pressure). - # skip_memory=True already disables the memory-review trigger; this - # flag is the explicit single-switch off for both review paths. + # Background review (memory/skill) opt-out: skips the end-of-turn fork + # (~30K tokens/event) on cron-style sessions. Single switch for both + # review paths (skip_memory alone only disables the memory trigger). agent.skip_background_review = bool(skip_background_review) agent.pass_session_id = pass_session_id agent.log_prefix_chars = log_prefix_chars @@ -720,232 +2941,17 @@ def init_agent( agent._credential_pool = credential_pool agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) - if api_mode in {"chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", "codex_app_server"}: - agent.api_mode = api_mode - elif agent.provider == "openai-codex": - agent.api_mode = "codex_responses" - elif agent.provider in {"xai", "xai-oauth"}: - agent.api_mode = "codex_responses" - elif (provider_name is None) and ( - agent._base_url_hostname == "chatgpt.com" - and "/backend-api/codex" in agent._base_url_lower - ): - agent.api_mode = "codex_responses" - agent.provider = "openai-codex" - elif (provider_name is None) and agent._base_url_hostname == "api.x.ai": - agent.api_mode = "codex_responses" - agent.provider = "xai" - elif agent.provider == "anthropic" or (provider_name is None and agent._base_url_hostname == "api.anthropic.com"): - agent.api_mode = "anthropic_messages" - agent.provider = "anthropic" - elif agent._base_url_lower.rstrip("/").endswith("/anthropic"): - # Third-party Anthropic-compatible endpoints (e.g. MiniMax, DashScope) - # use a URL convention ending in /anthropic. Auto-detect these so the - # Anthropic Messages API adapter is used instead of chat completions. - agent.api_mode = "anthropic_messages" - elif agent.provider == "bedrock" or ( - agent._base_url_hostname.startswith("bedrock-runtime.") - and base_url_host_matches(agent._base_url_lower, "amazonaws.com") - ): - # AWS Bedrock — auto-detect from provider name or base URL - # (bedrock-runtime..amazonaws.com). - agent.api_mode = "bedrock_converse" - elif agent.provider in {"nous", "nous-portal", "nousresearch"}: - # Portal is dual-wire: anthropic/* → Messages, everything else → - # chat_completions. Callers that already pass api_mode win above; - # this covers direct AIAgent construction without a resolved runtime. - from hermes_cli.providers import nous_api_mode + _resolve_api_mode(agent, api_mode, provider_name, base_url) - agent.api_mode = nous_api_mode(agent.model) - else: - # Host-mandated wire check — LAST, so the elif chain's provider-slug - # rewrites (e.g. api.anthropic.com → provider="anthropic", #63425) - # always run first. Covers api.meta.ai → codex_responses for prompt - # caching (0% on chat vs 93-99% on responses) and future mandates. - # Note: provider="meta" without an api.meta.ai base_url (or with a non-api.meta.ai - # base_url) intentionally falls through to chat_completions here. The wire - # protocol for Meta is URL-driven BY DESIGN, not provider-name-driven, because - # user config `providers.meta` may point at any OpenAI-compatible endpoint, and - # forcing `codex_responses` on the provider name alone would break custom endpoints - # named "meta" that do not host the Responses API. - try: - from hermes_cli.providers import host_mandated_api_mode as _host_mandated_api_mode + _finalize_routing(agent, api_mode, credential_pool) - _mandated = _host_mandated_api_mode(base_url or "") - except Exception: - _mandated = None - if _mandated is not None: - agent.api_mode = _mandated - else: - agent.api_mode = "chat_completions" - - # Credential-pool validation runs AFTER provider auto-detection so - # a pool scoped to e.g. "anthropic" is not rejected when the agent - # was constructed with provider=None and an anthropic.com URL. - # Regression from #63048 which placed this check before the - # URL-based auto-detection block above (fixed #63425). - if credential_pool is not None: - try: - from agent.credential_pool import credential_pool_matches_provider - - if not credential_pool_matches_provider( - credential_pool, - agent.provider, - base_url=agent.base_url, - ): - agent._credential_pool = None - except Exception: - agent._credential_pool = None - - # Eagerly warm the transport cache so import errors surface at init, - # not mid-conversation. Also validates the api_mode is registered. - try: - agent._get_transport() - except Exception: - pass # Non-fatal — transport may not exist for all modes yet - - try: - from hermes_cli.model_normalize import ( - _AGGREGATOR_PROVIDERS, - normalize_model_for_provider, - ) - - if agent.provider not in _AGGREGATOR_PROVIDERS: - agent.model = normalize_model_for_provider(agent.model, agent.provider) - except Exception: - pass - - # GPT-5.x models usually require the Responses API path, but some - # providers have exceptions (for example Copilot's gpt-5-mini still - # uses chat completions). Also auto-upgrade for direct OpenAI URLs - # (api.openai.com) since all newer tool-calling models prefer - # Responses there. ACP runtimes are excluded: an ACP client handles - # its own routing and does not implement the Responses API surface. - # Keyed on the `acp://` scheme, not one vendor, so every ACP client - # is covered. - # When api_mode was explicitly provided, respect it — the user - # knows what their endpoint supports (#10473). - # Exception: Azure OpenAI serves gpt-5.x on /chat/completions and - # does NOT support the Responses API — skip the upgrade for Azure - # (openai.azure.com), even though it looks OpenAI-compatible. - if ( - api_mode is None - and agent.api_mode == "chat_completions" - and agent.provider != "copilot-acp" - and not str(agent.base_url or "").lower().startswith("acp://") - and not str(agent.base_url or "").lower().startswith("acp+tcp://") - and not agent._is_azure_openai_url() - and ( - agent._is_direct_openai_url() - or agent._provider_model_requires_responses_api( - agent.model, - provider=agent.provider, - ) - ) - ): - agent.api_mode = "codex_responses" - # Invalidate the eager-warmed transport cache — api_mode changed - # from chat_completions to codex_responses after the warm at __init__. - if hasattr(agent, "_transport_cache"): - agent._transport_cache.clear() - - # Pre-warm OpenRouter model metadata cache in a background thread. - # fetch_model_metadata() is cached for 1 hour; this avoids a blocking - # HTTP request on the first API response when pricing is estimated. - # Use a process-level Event so this thread is only spawned once — a new - # AIAgent is created for every gateway request, so without the guard - # each message leaks one OS thread and the process eventually exhausts - # the system thread limit (RuntimeError: can't start new thread). - if (agent.provider == "openrouter" or agent._is_openrouter_url()) and \ - not _ra()._openrouter_prewarm_done.is_set(): - _ra()._openrouter_prewarm_done.set() - threading.Thread( - target=fetch_model_metadata, - daemon=True, - name="openrouter-prewarm", - ).start() - - agent.tool_progress_callback = tool_progress_callback - agent.tool_start_callback = tool_start_callback - agent.tool_complete_callback = tool_complete_callback + # Platform callbacks are stored under their parameter names verbatim. + _params = locals() + for _cb in _CALLBACK_PARAMS: + setattr(agent, _cb, _params[_cb]) agent.suppress_status_output = False - agent.thinking_callback = thinking_callback - agent.reasoning_callback = reasoning_callback - agent.clarify_callback = clarify_callback - agent.read_terminal_callback = read_terminal_callback - agent.read_preview_callback = read_preview_callback - agent.drive_preview_callback = drive_preview_callback - agent.read_window_below_callback = read_window_below_callback - agent.setup_mcp_callback = setup_mcp_callback - agent.tour_callback = tour_callback - agent.step_callback = step_callback - agent.stream_delta_callback = stream_delta_callback - agent.interim_assistant_callback = interim_assistant_callback - agent.status_callback = status_callback - agent.notice_callback = notice_callback - agent.notice_clear_callback = notice_clear_callback - agent.event_callback = event_callback - agent.reaction_callback = reaction_callback - agent.tool_gen_callback = tool_gen_callback - - # Tool execution state — allows _vprint during tool execution - # even when stream consumers are registered (no tokens streaming then) - agent._executing_tools = False - agent._tool_guardrails = ToolCallGuardrailController() - agent._tool_guardrail_halt_decision: ToolGuardrailDecision | None = None - - # Interrupt mechanism for breaking out of tool loops - agent._interrupt_requested = False - agent._interrupt_message = None # Optional message that triggered interrupt - # Explicit hard cancellation is separate from redirect/message state. A - # thread-safe Event makes the cause atomic for auxiliary stream pollers. - agent._hard_interrupt_requested = threading.Event() - agent._execution_thread_id: int | None = None # Set at run_conversation() start - agent._interrupt_thread_signal_pending = False - agent._client_lock = threading.RLock() - agent._model_request_active = threading.Event() - agent._supports_active_turn_redirect = True - - # /steer mechanism — inject a user note into the next tool result - # without interrupting the agent. Unlike interrupt(), steer() does - # NOT set _interrupt_requested; it waits for the current tool batch - # to finish naturally, then the drain hook appends the text to the - # last tool result's content so the model sees it on its next - # iteration. Message-role alternation is preserved (we modify an - # existing tool message rather than inserting a new user turn). - agent._pending_steer: Optional[str] = None - agent._pending_steer_lock = threading.Lock() - - # Active-turn redirect mechanism. A regular follow-up sent while the model - # is generating is different from a hard /stop: preserve the valid turn - # prefix, cancel only the in-flight model request, and rebuild its tail with - # the correction. The loop drains this slot at a role-safe boundary. - agent._pending_redirect: Optional[str] = None - agent._pending_redirect_lock = threading.Lock() - - # Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent` - # runs each tool on its own ThreadPoolExecutor worker — those worker - # threads have tids distinct from `_execution_thread_id`, so - # `_set_interrupt(True, _execution_thread_id)` alone does NOT cause - # `is_interrupted()` inside the worker to return True. Track the - # workers here so `interrupt()` / `clear_interrupt()` can fan out to - # their tids explicitly. - agent._tool_worker_threads: set[int] = set() - agent._tool_worker_threads_lock = threading.Lock() - - # Subagent delegation state - agent._delegate_depth = 0 # 0 = top-level agent, incremented for children - agent._active_children = [] # Running child AIAgents (for interrupt propagation) - agent._active_children_lock = threading.Lock() - - # Background memory/skill review state (agent/background_review.py). - # ``_background_review_run`` is installed before the worker starts and - # fences its first provider-capable phase; the direct agent pointer keeps - # normal interrupt propagation available once the fork is constructed. - agent._background_review_agent = None - agent._background_review_run = None - agent._background_review_lock = threading.Lock() + _init_control_state(agent) # Store OpenRouter provider preferences agent.providers_allowed = providers_allowed @@ -972,844 +2978,24 @@ def init_agent( agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns agent._force_ascii_payload = False - # Anthropic prompt caching: auto-enabled for Claude models on native - # Anthropic, OpenRouter, and third-party gateways that speak the - # Anthropic protocol (``api_mode == 'anthropic_messages'``). Reduces - # input costs by ~75% on multi-turn conversations. Uses four breakpoints: - # the static system prefix, full system prompt, and last two messages - # (falling back to system-and-3 when no static prefix is available). See - # ``_anthropic_prompt_cache_policy`` for the layout-vs-transport decision. - agent._use_prompt_caching, agent._use_native_cache_layout = ( - agent._anthropic_prompt_cache_policy() + _init_prompt_cache_config(agent) + + _init_turn_state(agent, run_budget_seconds) + + _setup_logging(agent) + + _init_stream_state(agent) + + _build_client(agent, api_key, base_url, fallback_model) + + _init_fallback_chain(agent, fallback_model) + + _load_tools(agent, enabled_toolsets, disabled_toolsets) + + _init_session_state( + agent, session_id, session_db, parent_session_id, reasoning_config, max_tokens, + checkpoints_enabled, checkpoint_max_snapshots, checkpoint_max_total_size_mb, checkpoint_max_file_size_mb, ) - agent._cache_disabled = False - # Anthropic supports "5m" (default) and "1h" cache TTL tiers. Read from - # config.yaml under prompt_caching.cache_ttl; unknown values keep "5m". - # 1h tier costs 2x on write vs 1.25x for 5m, but amortizes across long - # sessions with >5-minute pauses between turns (#14971). - # - # Setting cache_ttl to a falsy value (false / null / "off" / "disabled" / - # "no" / "none") disables prompt caching entirely. This is useful for - # OAuth subscription users where cache writes bill against "extra usage" - # or for third-party proxies that inject their own cache_control markers - # (#13477). The disable propagates through anthropic_prompt_cache_policy() - # and restore_primary_runtime() so it survives /model switches and - # fallback re-derivation (#33555). - agent._cache_ttl = "5m" - try: - from hermes_cli.config import load_config_readonly as _load_pc_cfg - - from agent.agent_runtime_helpers import cache_ttl_means_disabled - - _pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {} - _ttl = _pc_cfg.get("cache_ttl", "5m") - if _ttl in {"5m", "1h"}: - agent._cache_ttl = _ttl - elif cache_ttl_means_disabled(_ttl): - agent._use_prompt_caching = False - agent._use_native_cache_layout = False - agent._cache_ttl = None - agent._cache_disabled = True - except Exception: - pass - - # Iteration budget: the LLM is only notified when it actually exhausts - # the iteration budget (api_call_count >= max_iterations). At that - # point we inject ONE message, allow one final API call, and if the - # model doesn't produce a text response, force a user-message asking - # it to summarise. No intermediate pressure warnings — they caused - # models to "give up" prematurely on complex tasks (#7915). - agent._budget_exhausted_injected = False - agent._budget_grace_call = False - - # Optional wall-clock run budget (seconds per run_conversation turn). - # Explicit constructor arg wins; else resolved from config.yaml - # (agent.run_budget_seconds) further below. None = feature fully off: - # no clock reads, no injection, no stale-timeout capping. - agent.run_budget_seconds = _normalize_run_budget_seconds(run_budget_seconds) - # Wall-clock start of the CURRENT run_conversation turn. Set by - # turn_context.prepare_turn when a run budget is active; None otherwise. - agent._run_budget_started_at = None - # One-shot latch for the 80% wrap-up notice (reset each turn). - agent._run_budget_wrapup_injected = False - - # Activity tracking — updated on each API call, tool execution, and - # stream chunk. Used by the gateway timeout handler to report what the - # agent was doing when it was killed, and by the "still working" - # notifications to show progress. - agent._last_activity_ts: float = time.time() - agent._last_activity_desc: str = "initializing" - # Default / unmigrated paths and _touch_activity stamp unknown; named - # provenances are stamped by compression writers (heartbeat / timeout / cooldown). - agent._last_activity_provenance = ActivityProvenance.UNKNOWN - # Rate-limit durable SessionDB activity stamps from _touch_activity (#72016). - agent._session_activity_last_persist_mono: float = 0.0 - agent._current_tool: str | None = None - agent._api_call_count: int = 0 - # Opt-out flag for the between-turns MCP tool refresh (build_turn_context). - # Set on internal forks (e.g. background_review) that must keep ``tools[]`` - # byte-identical to a parent for provider cache parity. - agent._skip_mcp_refresh = False - # Registry generation the current tool snapshot was derived from. Lets a - # late/concurrent refresh reject a stale (older-generation) rebuild instead - # of clobbering a newer one. Set adjacent to the tool snapshot below. - agent._tool_snapshot_generation = 0 - # Rate limit tracking — updated from x-ratelimit-* response headers - # after each API call. Accessed by /usage slash command. - agent._rate_limit_state: Optional["RateLimitState"] = None - - # Credits tracking (dev-only, L0 usage-aware-credits) — updated from - # x-nous-credits-* response headers after each API call. Session-start - # remaining is latched the first time a header is ever seen so we can - # report cumulative micros spent. Surfaced behind HERMES_DEV_CREDITS. - agent._credits_state = None - agent._credits_session_start_micros = None - # Threshold-notice latch (L4): active sticky-notice keys + the crossing gates. - from agent.credits_tracker import new_credits_latch - - agent._credits_latch = new_credits_latch() - - # OpenRouter response cache hit counter — incremented when - # X-OpenRouter-Cache-Status: HIT is seen in streaming response headers. - agent._or_cache_hits: int = 0 - - # Centralized logging — agent.log (INFO+) and errors.log (WARNING+) - # both live under ~/.hermes/logs/. Idempotent, so gateway mode - # (which creates a new AIAgent per message) won't duplicate handlers. - from hermes_logging import setup_logging, setup_verbose_logging - setup_logging(hermes_home=_ra()._hermes_home) - - if agent.verbose_logging: - setup_verbose_logging() - _ra().logger.info("Verbose logging enabled (third-party library logs suppressed)") - elif agent.quiet_mode: - # In quiet mode (CLI default), keep console output clean — - # but DO NOT raise per-logger levels. Doing so prevents the - # root logger's file handlers (agent.log, errors.log) from - # ever seeing the records, because Python checks - # logger.isEnabledFor() before handler propagation. We rely - # on the fact that hermes_logging.setup_logging() does not - # install a console StreamHandler in quiet mode — so INFO - # records flow to the file handlers but never reach a - # console. Any future noise reduction belongs at the - # handler level inside hermes_logging.py, not here. - pass - - # Internal stream callback (set during streaming TTS). - # Initialized here so _vprint can reference it before run_conversation. - agent._stream_callback = None - # Deferred paragraph break flag — set after tool iterations so a - # single "\n\n" is prepended to the next real text delta. - agent._stream_needs_break = False - # Stateful scrubber for spans split across stream - # deltas (#5719). sanitize_context() alone can't survive chunk - # boundaries because the block regex needs both tags in one string. - agent._stream_context_scrubber = StreamingContextScrubber() - # Stateful scrubber for reasoning/thinking tags in streamed deltas - # (#17924). Replaces the per-delta _strip_think_blocks regex that - # destroyed downstream state (e.g. MiniMax-M2.7 streaming - # '' as delta1 and 'Let me check' as delta2 — the regex - # erased delta1, so downstream state machines never learned a - # block was open and leaked delta2 as content). - agent._stream_think_scrubber = StreamingThinkScrubber() - # Visible assistant text already delivered through live token callbacks - # during the current model response. Used to avoid re-sending the same - # commentary when the provider later returns it as a completed interim - # assistant message. - agent._current_streamed_assistant_text = "" - # Completed interim messages delivered during the current user turn. - # Unlike token-stream tracking, this spans Codex continuation/tool calls so - # repeated commentary is not re-sent before normalization can deduplicate it. - agent._delivered_interim_texts: set[str] = set() - - # Single-writer guard for the streaming delta sink (#65991). A stale/ - # superseded stream (e.g. one the stale-stream detector reconnected past, - # whose socket abort raced and never actually stopped the old worker) must - # NOT keep writing tokens into the turn alongside the retry's stream — - # otherwise two coherent responses interleave token-by-token into one - # transcript. Every streaming attempt claims a monotonic writer token; the - # delta sink drops chunks whose calling thread holds a stale token. The - # threading.local means threads that never claimed (non-streaming callers) - # are never fenced, so the guard can only ever drop a superseded stream, - # never the single legitimate writer. - agent._stream_writer_lock = threading.Lock() - agent._stream_writer_token = 0 - agent._stream_writer_tls = threading.local() - agent._stream_writer_dropped = 0 - - # Optional current-turn user-message override used when the API-facing - # user message intentionally differs from the persisted transcript - # (e.g. CLI voice mode adds a temporary prefix for the live call only). - agent._persist_user_message_idx = None - agent._persist_user_message_override = None - agent._persist_user_message_timestamp = None - - # Cache anthropic image-to-text fallbacks per image payload/URL so a - # single tool loop does not repeatedly re-run auxiliary vision on the - # same image history. - agent._anthropic_image_fallback_cache: Dict[str, str] = {} - - # Initialize LLM client via centralized provider router. - # The router handles auth resolution, base URL, headers, and - # Codex/Anthropic wrapping for all known providers. - # raw_codex=True because the main agent needs direct responses.stream() - # access for Codex Responses API streaming. - agent._anthropic_client = None - agent._is_anthropic_oauth = False - - # Resolve per-provider / per-model request timeout once up front so - # every client construction path below (Anthropic native, OpenAI-wire, - # router-based implicit auth) can apply it consistently. Bedrock - # Claude uses its own timeout path and is not covered here. - _provider_timeout = get_provider_request_timeout(agent.provider, agent.model) - - if agent.api_mode == "anthropic_messages": - from agent.anthropic_adapter import build_anthropic_client, resolve_anthropic_token - # Bedrock + Claude → use AnthropicBedrock SDK for full feature parity - # (prompt caching, thinking budgets, adaptive thinking). - _is_bedrock_anthropic = agent.provider == "bedrock" - if _is_bedrock_anthropic: - from agent.anthropic_adapter import build_anthropic_bedrock_client - _region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") - _br_region = _region_match.group(1) if _region_match else "us-east-1" - agent._bedrock_region = _br_region - agent._anthropic_client = build_anthropic_bedrock_client(_br_region) - agent._anthropic_api_key = "aws-sdk" - agent._anthropic_base_url = base_url - agent._is_anthropic_oauth = False - agent.api_key = "aws-sdk" - agent.client = None - agent._client_kwargs = {} - if not agent.quiet_mode: - print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock + AnthropicBedrock SDK, {_br_region})") - else: - # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. - # Other anthropic_messages providers (MiniMax, Alibaba, etc.) must use their own API key. - # Falling back would send Anthropic credentials to third-party endpoints (Fixes #1739, #minimax-401). - _is_native_anthropic = agent.provider == "anthropic" - effective_key = (api_key or resolve_anthropic_token() or "") if _is_native_anthropic else (api_key or "") - - # MiniMax OAuth issues short-lived (~15-min) access tokens. The - # Anthropic SDK caches ``api_key`` as a static string at client - # construction time, so a session that resolves the bearer once - # at startup will keep sending the same token until MiniMax - # returns 401 mid-session. Swap the static string for a callable - # token provider — ``build_anthropic_client`` recognizes the - # callable and installs an httpx event hook that mints a fresh - # bearer per outbound request (re-reading auth.json so a refresh - # persisted by another process is visible immediately). - # The cached refresh path is a no-op when the token still has - # ``MINIMAX_OAUTH_REFRESH_SKEW_SECONDS`` of life left, so steady- - # state cost is one file read + one timestamp compare per request. - if agent.provider == "minimax-oauth" and isinstance(effective_key, str) and effective_key: - try: - from hermes_cli.auth import build_minimax_oauth_token_provider - effective_key = build_minimax_oauth_token_provider() - except Exception as _mm_exc: # noqa: BLE001 — never block startup on this - import logging as _logging - _logging.getLogger(__name__).warning( - "MiniMax OAuth: failed to install per-request token provider " - "(%s); falling back to static bearer that will expire ~15min in.", - _mm_exc, - ) - - agent.api_key = effective_key - agent._anthropic_api_key = effective_key - agent._anthropic_base_url = base_url - # Only mark the session as OAuth-authenticated when the token - # genuinely belongs to native Anthropic. Third-party providers - # (MiniMax, Kimi, GLM, LiteLLM proxies) that accept the - # Anthropic protocol must never trip OAuth code paths — doing - # so injects Claude-Code identity headers and system prompts - # that cause 401/403 on their endpoints. Guards #1739 and - # the third-party identity-injection bug. - from agent.anthropic_adapter import _is_oauth_token as _is_oat - agent._is_anthropic_oauth = _is_oat(effective_key) if (_is_native_anthropic and isinstance(effective_key, str)) else False - agent._anthropic_client = build_anthropic_client(effective_key, base_url, timeout=_provider_timeout) - # No OpenAI client needed for Anthropic mode - agent.client = None - agent._client_kwargs = {} - if not agent.quiet_mode: - print(f"🤖 AI Agent initialized with model: {agent.model} (Anthropic native)") - # ``effective_key`` may be a callable Entra ID bearer - # provider for Azure Foundry anthropic_messages mode. - # The Anthropic adapter installs an httpx event hook - # that mints a fresh JWT per request — we never - # invoke or inspect the callable in the banner. - from agent.azure_identity_adapter import is_token_provider - - if is_token_provider(effective_key): - print("🔑 Using credentials: Microsoft Entra ID") - elif isinstance(effective_key, str) and len(effective_key) > 12: - print(f"🔑 Using token: {effective_key[:8]}...{effective_key[-4:]}") - elif agent.provider == "moa": - from agent.moa_loop import build_moa_facade - agent.api_mode = "chat_completions" - - # build_moa_facade wires the reference relay that routes - # reference-model outputs to the agent's tool_progress_callback so - # every surface that already consumes it (CLI spinner/scrollback, TUI, - # desktop, gateway) can show each reference's answer as a labelled - # block before the aggregator acts. The facade emits "moa.reference", - # "moa.progress", "moa.phase", and "moa.aggregating" events, forwarded - # through the same callback the tool lifecycle uses. Best-effort and - # cache-safe — display-only events, they never touch the message - # history. The factory is shared with the fallback-restore/recovery - # paths so a restored facade keeps emitting these events (#53802). - agent.client = build_moa_facade(agent, agent.model) - agent._client_kwargs = {} - agent.api_key = api_key or "moa-virtual-provider" - agent.base_url = "moa://local" - if not agent.quiet_mode: - print(f"🤖 AI Agent initialized with MoA preset: {agent.model}") - elif agent.api_mode == "bedrock_converse": - # AWS Bedrock — uses boto3 directly, no OpenAI client needed. - # Region is extracted from the base_url or defaults to us-east-1. - _region_match = re.search(r"bedrock-runtime\.([a-z0-9-]+)\.", base_url or "") - agent._bedrock_region = _region_match.group(1) if _region_match else "us-east-1" - # Guardrail config — read from config.yaml at init time. - agent._bedrock_guardrail_config = None - try: - from hermes_cli.config import load_config_readonly as _load_br_cfg - _gr = _load_br_cfg().get("bedrock", {}).get("guardrail", {}) - if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"): - agent._bedrock_guardrail_config = { - "guardrailIdentifier": _gr["guardrail_identifier"], - "guardrailVersion": _gr["guardrail_version"], - } - if _gr.get("stream_processing_mode"): - agent._bedrock_guardrail_config["streamProcessingMode"] = _gr["stream_processing_mode"] - if _gr.get("trace"): - agent._bedrock_guardrail_config["trace"] = _gr["trace"] - except Exception: - pass - agent.client = None - agent._client_kwargs = {} - if not agent.quiet_mode: - _gr_label = " + Guardrails" if agent._bedrock_guardrail_config else "" - print(f"🤖 AI Agent initialized with model: {agent.model} (AWS Bedrock, {agent._bedrock_region}{_gr_label})") - else: - client_kwargs = {} - if api_key and base_url: - # Explicit credentials from CLI/gateway — construct directly. - # The runtime provider resolver already handled auth for us. - # Extract query params (e.g. Azure api-version) from base_url - # and pass via default_query to prevent loss during SDK URL - # joining (httpx drops query string when joining paths). - _parsed_url = urlparse(base_url) - if _parsed_url.query: - _clean_url = urlunparse(_parsed_url._replace(query="")) - _query_params = { - k: v[0] for k, v in parse_qs(_parsed_url.query).items() - } - client_kwargs = { - "api_key": api_key, - "base_url": _clean_url, - "default_query": _query_params, - } - else: - client_kwargs = {"api_key": api_key, "base_url": base_url} - if _provider_timeout is not None: - client_kwargs["timeout"] = _provider_timeout - if agent.provider == "copilot-acp": - client_kwargs["command"] = agent.acp_command - client_kwargs["args"] = agent.acp_args - effective_base = base_url - # OpenCode Zen free tier (*-free slugs, e.g. x-preview-f-free / - # "Ox Alpha"): the Zen relay serves these ANONYMOUSLY and 401s any - # unrecognized bearer — including our keyless placeholder. Send an - # empty Authorization header to override the SDK's "Bearer ". - try: - from hermes_cli.models import ( - OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER, - opencode_zen_free_headers, - ) - if api_key == OPENCODE_ZEN_FREE_KEYLESS_PLACEHOLDER: - client_kwargs["default_headers"] = opencode_zen_free_headers() - except Exception: - pass - if base_url_host_matches(effective_base, "openrouter.ai"): - from agent.auxiliary_client import build_or_headers - client_kwargs["default_headers"] = build_or_headers() - elif base_url_host_matches(effective_base, "integrate.api.nvidia.com"): - from agent.auxiliary_client import build_nvidia_nim_headers - client_kwargs["default_headers"] = build_nvidia_nim_headers(effective_base) - elif base_url_host_matches(effective_base, "api.routermint.com"): - client_kwargs["default_headers"] = _ra()._routermint_headers() - elif base_url_host_matches(effective_base, "githubcopilot.com"): - from hermes_cli.models import copilot_default_headers - - client_kwargs["default_headers"] = copilot_default_headers() - elif base_url_host_matches(effective_base, "api.kimi.com"): - client_kwargs["default_headers"] = { - "User-Agent": "claude-code/0.1.0", - } - elif base_url_host_matches(effective_base, "portal.qwen.ai"): - client_kwargs["default_headers"] = _ra()._qwen_portal_headers() - elif base_url_host_matches(effective_base, "chatgpt.com"): - from agent.codex_headers import codex_cloudflare_headers - client_kwargs["default_headers"] = codex_cloudflare_headers( - api_key, base_url=effective_base, - ) - elif base_url_host_matches(effective_base, "x.ai"): - from tools.xai_http import hermes_xai_default_headers - - client_kwargs["default_headers"] = hermes_xai_default_headers() - elif "default_headers" not in client_kwargs: - # Fall back to profile.default_headers for providers that - # declare custom headers (e.g. Vercel AI Gateway attribution, - # Kimi User-Agent on non-kimi.com endpoints). - try: - from providers import get_provider_profile as _gpf - _ph = _gpf(agent.provider) - if _ph and _ph.default_headers: - client_kwargs["default_headers"] = dict(_ph.default_headers) - except Exception: - pass - else: - # No explicit creds — use the centralized provider router - from agent.auxiliary_client import resolve_provider_client - _routed_client, _ = resolve_provider_client( - agent.provider or "auto", model=agent.model, raw_codex=True) - if _routed_client is not None: - client_kwargs = { - "api_key": _routed_client.api_key, - "base_url": str(_routed_client.base_url), - } - if _provider_timeout is not None: - client_kwargs["timeout"] = _provider_timeout - # Preserve provider-specific headers the router set. The - # OpenAI SDK stores caller-provided default_headers in - # _custom_headers; older/mocked clients may expose - # _default_headers instead. - _routed_headers = getattr(_routed_client, "_custom_headers", None) - if not _routed_headers: - _routed_headers = getattr(_routed_client, "default_headers", None) - if not _routed_headers: - _routed_headers = getattr(_routed_client, "_default_headers", None) - if _routed_headers: - client_kwargs["default_headers"] = dict(_routed_headers) - else: - # No credentials resolved for the configured provider. Give - # the user-configured fallback chain a chance BEFORE failing - # (#17929) — regardless of WHICH provider failed. An - # exhausted single-entry pool (typically ``openrouter`` - # under free-tier daily quotas) must still reach the chain - # instead of dying at init with a misleading "No LLM - # provider configured" error. Only providers explicitly - # chosen by name keep the dedicated missing-key diagnostic. - _explicit = (agent.provider or "").strip().lower() - # --- Init-time fallback (#17929) --- - _fb_entries = [] - if isinstance(fallback_model, list): - _fb_entries = [ - f for f in fallback_model - if isinstance(f, dict) and f.get("provider") and f.get("model") - ] - elif isinstance(fallback_model, dict) and fallback_model.get("provider") and fallback_model.get("model"): - _fb_entries = [fallback_model] - _fb_resolved = False - for _fb in _fb_entries: - try: - from hermes_cli.fallback_config import resolve_entry_api_key - _fb_explicit_key = resolve_entry_api_key(_fb) - _fb_client, _fb_model = resolve_provider_client( - _fb["provider"], model=_fb["model"], raw_codex=True, - explicit_base_url=_fb.get("base_url"), - explicit_api_key=_fb_explicit_key, - ) - except Exception as _fb_exc: - logger.debug( - "Init-time fallback entry %s failed: %s", - _fb.get("provider"), _fb_exc, - ) - continue - if _fb_client is not None: - agent.provider = _fb["provider"] - agent.model = _fb_model or _fb["model"] - agent._fallback_activated = True - client_kwargs = { - "api_key": _fb_client.api_key, - "base_url": str(_fb_client.base_url), - } - if _provider_timeout is not None: - client_kwargs["timeout"] = _provider_timeout - _fb_headers = getattr(_fb_client, "_custom_headers", None) - if not _fb_headers: - _fb_headers = getattr(_fb_client, "default_headers", None) - if not _fb_headers: - _fb_headers = getattr(_fb_client, "_default_headers", None) - if _fb_headers: - client_kwargs["default_headers"] = dict(_fb_headers) - _fb_resolved = True - break - if ( - not _fb_resolved - and _explicit - and _explicit not in {"auto", "openrouter", "custom"} - ): - # Explicitly chosen non-OpenRouter provider with neither - # credentials nor a usable fallback: fail fast with a - # clear message instead of silently routing through - # OpenRouter. Look up the actual env var name from the - # provider config — some providers use non-standard - # names (e.g. alibaba → DASHSCOPE_API_KEY, not - # ALIBABA_API_KEY). - _env_hint = f"{_explicit.upper()}_API_KEY" - try: - from hermes_cli.auth import PROVIDER_REGISTRY - _pcfg = PROVIDER_REGISTRY.get(_explicit) - if _pcfg and _pcfg.api_key_env_vars: - _env_hint = _pcfg.api_key_env_vars[0] - except Exception: - pass - raise RuntimeError( - f"Provider '{_explicit}' is set in config.yaml but no API key " - f"was found. Set the {_env_hint} environment " - f"variable, or switch to a different provider with `hermes model`." - ) - if not getattr(agent, "_fallback_activated", False): - # No provider configured — reject with a clear message. - raise RuntimeError( - "No LLM provider configured. Run `hermes model` to " - "select a provider, or run `hermes setup` for first-time " - "configuration." - ) - # Bedrock GPT-5.5/5.6 use Bedrock Mantle's OpenAI Responses endpoint. - # Runtime resolution uses api_key="aws-sdk" as the IAM-auth sentinel; - # attach an httpx client that SigV4-signs every OpenAI SDK request. - # No-op for non-Mantle base URLs. - try: - from agent.bedrock_adapter import configure_bedrock_openai_client_kwargs - configure_bedrock_openai_client_kwargs( - client_kwargs, - timeout=_provider_timeout, - ) - except Exception: - if agent.provider == "bedrock" and "bedrock-mantle." in str(client_kwargs.get("base_url", "")): - raise - - agent._client_kwargs = client_kwargs # stored for rebuilding after interrupt - - # Enable fine-grained tool streaming for Claude on OpenRouter. - # Without this, Anthropic buffers the entire tool call and goes - # silent for minutes while thinking — OpenRouter's upstream proxy - # times out during the silence. The beta header makes Anthropic - # stream tool call arguments token-by-token, keeping the - # connection alive. - _effective_base = str(client_kwargs.get("base_url", "")).lower() - if base_url_host_matches(_effective_base, "openrouter.ai") and "claude" in (agent.model or "").lower(): - headers = client_kwargs.get("default_headers") or {} - existing_beta = headers.get("x-anthropic-beta", "") - _FINE_GRAINED = "fine-grained-tool-streaming-2025-05-14" - if _FINE_GRAINED not in existing_beta: - if existing_beta: - headers["x-anthropic-beta"] = f"{existing_beta},{_FINE_GRAINED}" - else: - headers["x-anthropic-beta"] = _FINE_GRAINED - client_kwargs["default_headers"] = headers - - # User-configured request headers (model.default_headers in - # config.yaml) override provider/SDK defaults. Lets custom - # OpenAI-compatible endpoints behind a gateway/WAF that rejects the - # OpenAI SDK's identifying headers swap in a plain User-Agent. (#40033) - # client_kwargs is the same dict object as agent._client_kwargs, so - # this mutation is reflected in the client built just below. - agent._apply_user_default_headers() - - try: - from hermes_cli.config import ( - apply_custom_provider_extra_headers_to_client_kwargs, - apply_custom_provider_tls_to_client_kwargs, - get_compatible_custom_providers, - load_config, - ) - - _cp_config = load_config() - _cp_entries = get_compatible_custom_providers(_cp_config) - _cp_base_url = str(client_kwargs.get("base_url") or agent.base_url or "") - apply_custom_provider_tls_to_client_kwargs( - client_kwargs, - _cp_base_url, - _cp_entries, - ) - # Per-provider extra HTTP headers (providers..extra_headers / - # custom_providers[].extra_headers) — proxies, gateways, custom - # auth. Applied last so the most specific config level wins. - # SECURITY: values may carry credentials — never log them. - apply_custom_provider_extra_headers_to_client_kwargs( - client_kwargs, - _cp_base_url, - _cp_entries, - ) - except Exception: - logger.debug("custom-provider TLS resolution skipped", exc_info=True) - - agent.api_key = client_kwargs.get("api_key", "") - agent.base_url = client_kwargs.get("base_url", agent.base_url) - try: - from agent.ssl_guard import verify_ca_bundle_with_fallback - - verify_ca_bundle_with_fallback() - agent.client = agent._create_openai_client(client_kwargs, reason="agent_init", shared=True) - if not agent.quiet_mode: - print(f"🤖 AI Agent initialized with model: {agent.model}") - if base_url: - print(f"🔗 Using custom base URL: {base_url}") - # ``api_key`` may be a callable Entra ID bearer - # provider (Azure Foundry). The OpenAI SDK mints a - # fresh JWT per request internally — the banner - # never invokes or inspects the callable. - from agent.azure_identity_adapter import is_token_provider - - key_used = client_kwargs.get("api_key", "none") - if is_token_provider(key_used): - print("🔑 Using credentials: Microsoft Entra ID") - elif isinstance(key_used, str) and key_used and key_used != "dummy-key" and len(key_used) > 12: - print(f"🔑 Using API key: {key_used[:8]}...{key_used[-4:]}") - else: - print("⚠️ Warning: API key appears invalid or missing") - except Exception as e: - raise RuntimeError(f"Failed to initialize OpenAI client: {e}") - - # Keep a stable identity for the pool entry that supplied this runtime. - # OAuth refreshes can replace the runtime token before a failed request is - # recovered, so the mutable API-key value alone cannot reliably attribute - # the failure to its source entry. - from agent.agent_runtime_helpers import sync_credential_pool_entry_id - sync_credential_pool_entry_id(agent) - - # Provider fallback chain — ordered list of backup providers tried - # when the primary is exhausted (rate-limit, overload, connection - # failure). Supports both legacy single-dict ``fallback_model`` and - # new list ``fallback_providers`` format. - if isinstance(fallback_model, list): - agent._fallback_chain = [ - f for f in fallback_model - if isinstance(f, dict) and f.get("provider") and f.get("model") - ] - elif isinstance(fallback_model, dict) and fallback_model.get("provider") and fallback_model.get("model"): - agent._fallback_chain = [fallback_model] - else: - agent._fallback_chain = [] - agent._fallback_index = 0 - agent._fallback_activated = getattr(agent, "_fallback_activated", False) - # Legacy attribute kept for backward compat (tests, external callers) - agent._fallback_model = agent._fallback_chain[0] if agent._fallback_chain else None - if agent._fallback_chain and not agent.quiet_mode: - if len(agent._fallback_chain) == 1: - fb = agent._fallback_chain[0] - print(f"🔄 Fallback model: {fb['model']} ({fb['provider']})") - else: - print(f"🔄 Fallback chain ({len(agent._fallback_chain)} providers): " + - " → ".join(f"{f['model']} ({f['provider']})" for f in agent._fallback_chain)) - - # A multiplexed gateway may enter a different HERMES_HOME after - # ``model_tools`` was first imported. Ensure that profile's keyed plugin - # manager has discovered its registrations before taking the tool snapshot. - try: - from hermes_cli.plugins import discover_plugins - - discover_plugins() - except Exception: - logger.warning("Plugin discovery failed during agent setup", exc_info=True) - - # Get available tools with filtering. Capture the registry generation this - # snapshot is derived from FIRST, so a later concurrent refresh can tell - # whether it holds a newer or staler view (see refresh_agent_mcp_tools). - try: - from tools.registry import registry as _snapshot_registry - agent._tool_snapshot_generation = _snapshot_registry._generation - except Exception: - agent._tool_snapshot_generation = 0 - agent.tools = _ra().get_tool_definitions( - enabled_toolsets=enabled_toolsets, - disabled_toolsets=disabled_toolsets, - quiet_mode=agent.quiet_mode, - ) - - # Show tool configuration and store valid tool names for validation - agent.valid_tool_names = set() - if agent.tools: - agent.valid_tool_names = {tool["function"]["name"] for tool in agent.tools} - tool_names = sorted(agent.valid_tool_names) - if not agent.quiet_mode: - print(f"🛠️ Loaded {len(agent.tools)} tools: {', '.join(tool_names)}") - # Show filtering info if applied - if enabled_toolsets: - print(f" ✅ Enabled toolsets: {', '.join(enabled_toolsets)}") - if disabled_toolsets: - print(f" ❌ Disabled toolsets: {', '.join(disabled_toolsets)}") - elif not agent.quiet_mode: - print("🛠️ No tools loaded (all tools filtered out or unavailable)") - - # Kanban worker/orchestrator lifecycle guidance is session-static: - # the dispatcher decides at spawn time whether this process is a kanban - # worker (kanban_show tool is present iff HERMES_KANBAN_TASK is set). - # Resolving the ~835-token block once here avoids re-running the - # membership test + reference on every system-prompt rebuild - # (init + each context compression). - from agent.prompt_builder import KANBAN_GUIDANCE - agent._kanban_worker_guidance = ( - KANBAN_GUIDANCE if "kanban_show" in agent.valid_tool_names else "" - ) - - # Check tool requirements - if agent.tools and not agent.quiet_mode: - requirements = _ra().check_toolset_requirements() - missing_reqs = [name for name, available in requirements.items() if not available] - if missing_reqs: - print(f"⚠️ Some tools may not work due to missing requirements: {missing_reqs}") - - # Show trajectory saving status - if agent.save_trajectories and not agent.quiet_mode: - print("📝 Trajectory saving enabled") - - # Show ephemeral system prompt status - if agent.ephemeral_system_prompt and not agent.quiet_mode: - prompt_preview = agent.ephemeral_system_prompt[:60] + "..." if len(agent.ephemeral_system_prompt) > 60 else agent.ephemeral_system_prompt - print(f"🔒 Ephemeral system prompt: '{prompt_preview}' (not saved to trajectories)") - - # Show prompt caching status - if agent._use_prompt_caching and not agent.quiet_mode: - if agent._use_native_cache_layout and agent.provider == "anthropic": - source = "native Anthropic" - elif agent._use_native_cache_layout: - source = "Anthropic-compatible endpoint" - else: - source = "Claude via OpenRouter" - print(f"💾 Prompt caching: ENABLED ({source}, {agent._cache_ttl} TTL)") - - # Session logging setup - auto-save conversation trajectories for debugging - agent.session_start = datetime.now() - if session_id: - # Use provided session ID (e.g., from CLI) - agent.session_id = session_id - else: - # Generate a new session ID - timestamp_str = agent.session_start.strftime("%Y%m%d_%H%M%S") - short_uuid = uuid.uuid4().hex[:6] - agent.session_id = f"{timestamp_str}_{short_uuid}" - - # Expose session ID to tools (terminal, execute_code) so agents can - # reference their own session for --resume commands, cross-session - # coordination, and logging. Keep the ContextVar and os.environ - # fallback synchronized because different tool paths still read both. - try: - from gateway.session_context import set_current_session_id - - set_current_session_id(agent.session_id) - except Exception: - # Preserve the root-agent legacy fallback, but never let delegated - # construction publish a child ID process-wide even if the ContextVar - # bridge itself failed to import. - try: - from agent.delegation_context import is_delegated_child_context - - delegated_child = is_delegated_child_context() - except Exception: - delegated_child = False - if not delegated_child: - os.environ["HERMES_SESSION_ID"] = agent.session_id - - # Session logs go into ~/.hermes/sessions/ alongside gateway sessions - hermes_home = get_hermes_home() - agent.logs_dir = hermes_home / "sessions" - agent.logs_dir.mkdir(parents=True, exist_ok=True) - # Per-session JSON snapshot writer (~/.hermes/sessions/session_{sid}.json) - # is opt-in via sessions.write_json_snapshots (default False). state.db - # is canonical — the snapshot is only useful for external tooling that - # reads the JSON files directly. See run_agent._save_session_log. - agent._session_json_enabled = False - try: - from hermes_cli.config import load_config_readonly as _load_sess_cfg - _sess_cfg = (_load_sess_cfg().get("sessions") or {}) - agent._session_json_enabled = bool(_sess_cfg.get("write_json_snapshots", False)) - except Exception: - pass - # logs_dir is retained unconditionally for request_dump_*.json (debug - # breadcrumb path written by agent_runtime_helpers.dump_api_request_debug). - - # Track conversation messages for session logging - agent._session_messages: List[Dict[str, Any]] = [] - # Responses encrypted reasoning replay state. Some OpenAI-compatible - # routes accept GPT-5 Responses requests but later reject replayed - # encrypted reasoning blobs (HTTP 400 ``invalid_encrypted_content``). - # When that happens we disable replay for the rest of the session and - # fall back to stateless continuity. See - # agent/conversation_loop.py's invalid_encrypted_content retry branch. - agent._codex_reasoning_replay_enabled = True - agent._memory_write_origin = "assistant_tool" - agent._memory_write_context = "foreground" - - # Cached system prompt -- built once per session, only rebuilt on compression - agent._cached_system_prompt: Optional[str] = None - # Cross-session-stable prefix of the cached prompt. It remains separate - # from the persisted string and is used only to place an early cache marker. - agent._cached_system_prompt_static: Optional[str] = None - - # Filesystem checkpoint manager (transparent — not a tool) - from tools.checkpoint_manager import CheckpointManager - agent._checkpoint_mgr = CheckpointManager( - enabled=checkpoints_enabled, - max_snapshots=checkpoint_max_snapshots, - max_total_size_mb=checkpoint_max_total_size_mb, - max_file_size_mb=checkpoint_max_file_size_mb, - ) - - # SQLite session store (optional -- provided by CLI or gateway) - agent._session_db = session_db - # Whether close() must also close that handle. Default False: a - # caller-supplied session_db is almost always the SHARED launch handle, - # which outlives every agent and must never be closed here. Callers that - # hand over a DEDICATED handle (the gateway's per-profile state.db opens) - # set this True at the point ownership transfers, so teardown releases the - # sqlite fds and the token-writer thread instead of leaking them for the - # life of the process. Also set True on the lazy self-open in - # _get_session_db_for_recall, where nothing else holds a reference. - agent._owns_session_db = False - agent._parent_session_id = parent_session_id - # A close flush and the worker's turn-start flush can overlap. The durable - # marker is attached to each in-memory message dict, so its test-and-append - # sequence must be serialized per agent rather than relying on SQLite alone. - agent._session_persist_lock = threading.RLock() - # CLI retains its just-accepted user dict until turn setup can reuse it. - # This preserves the message-local durable marker if close persistence wins - # the race before the agent's normal early turn flush. - agent._pending_cli_user_message = None - agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes - agent._session_db_created = False # DB row deferred to run_conversation() - # Most agents own their session row and should finalize it on close(). - # Some temporary helper agents (manual compression / session-hygiene / - # background-review forks) rotate or share the session forward to a - # continuation row that must remain open after the helper is torn down; - # those callers explicitly set this flag to False. - agent._end_session_on_close = True - # When True, this agent NEVER persists to the canonical session store - # (state.db) or the JSON snapshot, regardless of session_id. Set on the - # background skill/memory review fork so its harness turn can't leak into - # the user's real session and hijack the next live turn. Default False. - agent._persist_disabled = False - agent._session_init_model_config = { - "max_iterations": agent.max_iterations, - "reasoning_config": reasoning_config, - "max_tokens": max_tokens, - } - # Persist a process-scoped --yolo launch into the session row so a later - # `hermes --resume ` can restore the bypass (CLI resume paths read - # model_config.yolo_mode back via SessionDB.session_yolo_enabled). - # Session-scoped /yolo toggles persist separately through - # SessionDB.set_session_yolo at toggle time. - try: - from tools.approval import _YOLO_MODE_FROZEN - if _YOLO_MODE_FROZEN: - agent._session_init_model_config["yolo_mode"] = True - except Exception: - pass - - # In-memory todo list for task planning (one per agent/session) - from tools.todo_tool import TodoStore - agent._todo_store = TodoStore() # Load config once for memory, skills, and compression sections try: @@ -1818,1218 +3004,27 @@ def init_agent( except Exception: _agent_cfg = {} - # Codex commentary visibility (display.show_commentary, default true). - # When true, completed Codex phase=commentary messages are delivered as - # visible mid-turn updates through the interim message path. When false, - # commentary falls back to the reasoning channel (visible only with - # show_reasoning enabled). - agent.show_commentary = True - try: - _display_section = _agent_cfg.get("display", {}) - if isinstance(_display_section, dict): - agent.show_commentary = bool(_display_section.get("show_commentary", True)) - except Exception: - agent.show_commentary = True + _apply_display_config(agent, _agent_cfg, platform) - # Window (seconds) for the bounded /fast auto|cold modes (agent.fast_mode). - agent.fast_auto_seconds = (_agent_cfg.get("agent") or {}).get("fast_auto_seconds", 60) + _init_memory(agent, _agent_cfg, skip_memory, platform) - # LM Studio can either be explicitly preloaded through LM Studio's - # management API (the historical Hermes behavior) or left to LM Studio's - # just-in-time / Auto-Evict chat-completions path. Keep the default - # explicit for backward compatibility; users with LM Studio Auto-Evict can - # opt into JIT via ``model.lmstudio_load_mode: jit``. - agent.lmstudio_load_mode = "explicit" - try: - _model_section = _agent_cfg.get("model", {}) - if isinstance(_model_section, dict): - _load_mode = str(_model_section.get("lmstudio_load_mode", "explicit") or "explicit").strip().lower() - if _load_mode in {"explicit", "jit"}: - agent.lmstudio_load_mode = _load_mode - else: - logger.warning( - "Invalid model.lmstudio_load_mode=%r; expected 'explicit' or 'jit'. Using explicit.", - _model_section.get("lmstudio_load_mode"), - ) - except Exception: - agent.lmstudio_load_mode = "explicit" + _apply_agent_section(agent, _agent_cfg) - # API-transport streaming (``model.streaming``, default true). The - # conversation loop prefers ``stream=True`` for every turn — including - # subagent turns — to get fine-grained liveness health-checking (#3120), - # but self-hosted OpenAI-compatible backends with broken streaming - # tool-call paths (e.g. vLLM ``--tool-call-parser qwen3_xml`` + a - # reasoning parser can leak tool-call markup into plain text and return - # zero ``tool_calls``, #72901) silently no-op instead of executing. - # ``model.streaming: false`` seeds ``_disable_streaming`` so the session - # uses the non-streaming path, which the loop already falls back to at - # runtime when a provider rejects streaming. The setting is - # session-scoped: it persists across mid-session model switches, mirroring - # the runtime fallback's semantics. Orthogonal to ``display.streaming`` - # (token rendering) — display-only settings are untouched. - agent._disable_streaming = False - try: - _model_section = _agent_cfg.get("model", {}) - if isinstance(_model_section, dict): - _streaming = str(_model_section.get("streaming", "true")).strip().lower() - if _streaming in {"false", "0", "no", "off"}: - agent._disable_streaming = True - elif _streaming not in {"true", "1", "yes", "on"}: - logger.warning( - "Invalid model.streaming=%r; expected a boolean. Using streaming (default).", - _model_section.get("streaming"), - ) - except Exception: - agent._disable_streaming = False + cs = _parse_compression_config(agent, _agent_cfg) - try: - agent._tool_guardrails = ToolCallGuardrailController( - ToolCallGuardrailConfig.from_mapping( - _agent_cfg.get("tool_loop_guardrails", {}), - platform=platform, - ) - ) - except Exception as _tlg_err: - _ra().logger.warning("Tool loop guardrail config ignored: %s", _tlg_err) - # Cache only the derived auxiliary compression context override that is - # needed later by the startup feasibility check. Avoid exposing a - # broad pseudo-public config object on the agent instance. - agent._aux_compression_context_length_config = None - - # Persistent memory (MEMORY.md + USER.md) -- loaded from disk - agent._memory_store = None - agent._memory_enabled = False - agent._user_profile_enabled = False - agent._memory_nudge_interval = 10 - agent._turns_since_memory = 0 - agent._iters_since_skill = 0 - # skip_memory=True skips the external memory *provider*. Flush/background - # agents can still pass enabled_toolsets=["memory"] so the built-in file - # store exists and the memory tool does not fail with store=None (#65429). - # A toolset on disabled_toolsets is not a request: a caller that denylists - # memory while its default toolset still names it must not get MEMORY.md - # loaded by an enabled-only check. (Cron agents now run with - # skip_memory=False and take the normal path here.) - _enabled_toolsets = agent.enabled_toolsets or [] - _disabled_toolsets = agent.disabled_toolsets or [] - _memory_toolset_requested = ( - "memory" in _enabled_toolsets and "memory" not in _disabled_toolsets - ) - if not skip_memory or _memory_toolset_requested: - try: - from tools.memory_tool import ( - get_builtin_memory_config, - get_builtin_memory_store_flags, - ) - - mem_config = get_builtin_memory_config(_agent_cfg) - agent._memory_enabled, agent._user_profile_enabled = get_builtin_memory_store_flags( - _agent_cfg - ) - agent._memory_nudge_interval = int(mem_config.get("nudge_interval", 10)) - if agent._memory_enabled or agent._user_profile_enabled: - from tools.memory_tool import MemoryStore - agent._memory_store = MemoryStore( - memory_char_limit=mem_config.get("memory_char_limit", 2200), - user_char_limit=mem_config.get("user_char_limit", 1375), - memory_enabled=agent._memory_enabled, - user_profile_enabled=agent._user_profile_enabled, - ) - agent._memory_store.load_from_disk() - except Exception: - pass # Memory is optional -- don't break agent init - - - - # Memory provider plugin (external — one at a time, alongside built-in) - # Reads memory.provider from config to select which plugin to activate. - agent._memory_manager = None - if not skip_memory: - try: - _mem_provider_name = mem_config.get("provider", "") if mem_config else "" - - if _mem_provider_name and _mem_provider_name.strip(): - from agent.memory_manager import MemoryManager as _MemoryManager - from plugins.memory import load_memory_provider as _load_mem - agent._memory_manager = _MemoryManager() - _mp = _load_mem(_mem_provider_name) - if _mp and _mp.is_available(): - agent._memory_manager.add_provider(_mp) - elif _mp is not None: - # Skip the (potentially expensive) unavailable_reason() call - # if we've already warned for this provider — the gateway - # builds a fresh AIAgent per message, so without this guard - # unavailable_reason() (which reads config from disk and may - # probe importlib) runs on every turn. - if _mem_provider_name not in _warned_unavailable_providers: - try: - _unavailable_reason = _mp.unavailable_reason() - except Exception: - _unavailable_reason = "" - _warn_memory_provider_unavailable(_mem_provider_name, _unavailable_reason) - if agent._memory_manager.providers: - _init_kwargs = { - "session_id": agent.session_id, - "platform": platform or "cli", - "hermes_home": str(get_hermes_home()), - "agent_context": "primary", - } - if _init_kwargs["platform"] == "cli": - _init_kwargs["warning_callback"] = agent._emit_warning - _init_kwargs["status_callback"] = agent._emit_status - # Thread session title for memory provider scoping - # (e.g. honcho uses this to derive chat-scoped session keys) - if agent._session_db: - try: - _st = agent._session_db.get_session_title(agent.session_id) - if _st: - _init_kwargs["session_title"] = _st - except Exception: - pass - # Thread gateway user identity for per-user memory scoping - if agent._user_id: - _init_kwargs["user_id"] = agent._user_id - if agent._user_id_alt: - _init_kwargs["user_id_alt"] = agent._user_id_alt - if agent._user_name: - _init_kwargs["user_name"] = agent._user_name - if agent._chat_id: - _init_kwargs["chat_id"] = agent._chat_id - if agent._chat_name: - _init_kwargs["chat_name"] = agent._chat_name - if agent._chat_type: - _init_kwargs["chat_type"] = agent._chat_type - if agent._thread_id: - _init_kwargs["thread_id"] = agent._thread_id - # Thread gateway session key for stable per-chat Honcho session isolation - if agent._gateway_session_key: - _init_kwargs["gateway_session_key"] = agent._gateway_session_key - # Profile identity for per-profile provider scoping - try: - from hermes_cli.profiles import get_active_profile_name - _profile = get_active_profile_name() - _init_kwargs["agent_identity"] = _profile - _init_kwargs["agent_workspace"] = "hermes" - except Exception: - pass - # NOTE: status_callback (for the deterministic retain - # indicator) is wired above, CLI-only — gateway status is - # delivered on a different path (see the platform=="cli" - # block), and the indicator no-ops when it's absent. - agent._memory_manager.initialize_all(**_init_kwargs) - _ra().logger.info("Memory provider '%s' activated", _mem_provider_name) - else: - _ra().logger.debug("Memory provider '%s' not found or not available", _mem_provider_name) - agent._memory_manager = None - except Exception as _mpe: - _ra().logger.warning("Memory provider plugin init failed: %s", _mpe) - agent._memory_manager = None - - from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools - _inject_memory_provider_tools(agent) - - # Skills config: nudge interval for skill creation reminders - agent._skill_nudge_interval = 10 - try: - skills_config = _agent_cfg.get("skills", {}) - agent._skill_nudge_interval = int(skills_config.get("creation_nudge_interval", 10)) - except Exception: - pass - - # Tool-use enforcement config: "auto" (default — matches hardcoded - # model list), true (always), false (never), or list of substrings. - _agent_section = _agent_cfg.get("agent", {}) - if not isinstance(_agent_section, dict): - _agent_section = {} - agent._tool_use_enforcement = _agent_section.get("tool_use_enforcement", "auto") - - # Execution-discipline guidance gate: "auto" (default — matches - # EXECUTION_GUIDANCE_MODELS), true (always), false (never), or list of - # model-name substrings. Independent of tool_use_enforcement — see - # agent/system_prompt.py for the injection gate. - agent._execution_guidance = _agent_section.get("execution_guidance", "auto") - - # Wall-clock run budget from config (agent.run_budget_seconds) — only - # consulted when the constructor arg was not given. Absent/None/invalid - # keeps the feature fully off (zero behavior change in the default path). - if agent.run_budget_seconds is None: - agent.run_budget_seconds = _normalize_run_budget_seconds( - _agent_section.get("run_budget_seconds") - ) - - # Empty-response retry guard config (NS-503): additive - # ``agent.empty_response_guard`` subsection. Resolution is tolerant — - # a malformed section falls back to the schema defaults (guard on, - # $0.25 threshold), matching the guard's overall fail-open posture. - from agent.empty_response_guard import resolve_guard_settings - ( - agent._empty_guard_enabled, - agent._empty_guard_cost_threshold_usd, - ) = resolve_guard_settings(_agent_section.get("empty_response_guard")) - - # Intent-ack continuation config: "auto" (default — codex_responses only, - # the historical gate), true (all api_modes), false (never), or a list of - # model-name substrings. Resolved against the active api_mode/model in the - # conversation loop's intent-ack block. - agent._intent_ack_continuation = _agent_section.get("intent_ack_continuation", "auto") - - # Runtime anti-stall guards (identical-call loop-breaker notice on tool - # results + continue-intent extension of the empty-response recovery). - # Single boolean gate, default True. Notice-only — never blocks a call. - agent._stall_guards = bool(_agent_section.get("stall_guards", True)) - - # Universal task-completion guidance toggle. Default True. Surfaced - # as a separate flag from tool_use_enforcement because the guidance - # applies to ALL models, not just the model families enforcement - # targets. - agent._task_completion_guidance = bool(_agent_section.get("task_completion_guidance", True)) - - # Universal parallel-tool-call guidance toggle. Default True. Separate - # flag from task_completion_guidance because a user may want one but not - # the other. Steers the model to batch independent tool calls into a - # single turn; the runtime already executes such batches concurrently. - agent._parallel_tool_call_guidance = bool(_agent_section.get("parallel_tool_call_guidance", True)) - - # Local Python toolchain probe toggle. Default True. When False, - # the probe is skipped entirely (no subprocess calls, no system-prompt - # line). Useful for users on exotic setups where the probe heuristics - # are noisy. - agent._environment_probe = bool(_agent_section.get("environment_probe", True)) - # Warm the probe off-thread: it shells out to python3/pip (~0.5s of - # subprocess round-trips) and its result lands in the FIRST system - # prompt build, which sits on the time-to-first-token critical path. - # The warm runs during agent init (network/credential setup dominates), - # so by the time the first prompt is built the line is already cached. - if agent._environment_probe: - try: - from tools.env_probe import warm_environment_probe_async - warm_environment_probe_async() - except Exception: - pass - - # Bot Mode teammate protocol section (tools/bot_mode_probe.py) — pure - # filesystem reads, no warm needed. Silent on non-Bot-Mode installs. - agent._bot_mode_protocol = bool(_agent_section.get("bot_mode_protocol", True)) - # Session-title hint for the "Bot Chat" gate: hosts that defer the DB - # title write past the first prompt build (tui_gateway pending_title) - # set this so the gate doesn't depend on write ordering. - agent._session_title_hint = None - - # Per-platform prompt-hint overrides (config.yaml → platform_hints). - # Lets an enterprise admin append to or replace Hermes' built-in - # platform hint for a single messaging platform (e.g. WhatsApp) without - # affecting other platforms. Shape: - # platform_hints: - # whatsapp: - # append: "When tabular output would help, invoke the ... skill." - # slack: - # replace: "Custom Slack hint that fully replaces the default." - # Stored verbatim; resolution happens in agent/system_prompt.py against - # the active platform. Invalid shapes are ignored defensively so a bad - # config entry can never break prompt assembly. - _platform_hints_cfg = _agent_cfg.get("platform_hints", {}) - if not isinstance(_platform_hints_cfg, dict): - _platform_hints_cfg = {} - agent._platform_hint_overrides = _platform_hints_cfg - - # App-level API retry count (wraps each model API call). Default 3, - # overridable via agent.api_max_retries in config.yaml. See #11616. - try: - _raw_api_retries = _agent_section.get("api_max_retries", 3) - _api_retries = int(_raw_api_retries) - _api_retries = max(_api_retries, 1) # 1 = no retry (single attempt) - except (TypeError, ValueError): - _api_retries = 3 - agent._api_max_retries = _api_retries - - # Initialize context compressor for automatic context management - # Compresses conversation when approaching model's context limit - # Configuration via config.yaml (compression section) - _compression_cfg = _agent_cfg.get("compression", {}) - if not isinstance(_compression_cfg, dict): - _compression_cfg = {} - compression_threshold = float(_compression_cfg.get("threshold", 0.50)) - # Per-model/route compaction-threshold override. Codex gpt-5.4 / gpt-5.5 - # raise to 85% (the Codex backend caps both families at 272K, so the - # default 50% would compact at ~136K — half the usable context). Gated by - # an opt-out config flag so the user can fall back to the global threshold; - # when the override fires we stash a one-time notification (replayed on the - # first turn) that tells the user what changed and how to revert. The - # notice has its own display gate so users can keep the threshold - # autoraise without getting the banner on gateway turns. - _codex_gpt55_autoraise = str( - _compression_cfg.get("codex_gpt55_autoraise", True) - ).lower() in {"true", "1", "yes"} - _codex_gpt55_autoraise_notice = str( - _compression_cfg.get("codex_gpt55_autoraise_notice", True) - ).lower() in {"true", "1", "yes"} - agent._compression_threshold_autoraised = None - try: - from agent.auxiliary_client import ( - _compression_threshold_for_model as _cthresh_fn, - _is_codex_gpt54_or_gpt55 as _is_codex_gpt54_or_gpt55_fn, - _is_codex_spark as _is_codex_spark_fn, - ) - _model_cthresh = _cthresh_fn( - agent.model, - agent.provider, - allow_codex_gpt55_autoraise=_codex_gpt55_autoraise, - ) - # The Codex autoraises (gpt-5.4/5.5 272K family and gpt-5.3-codex-spark) - # apply only when they RAISE (never lower a user's higher global - # threshold). The notice is populated only when it actually fires, and - # carries the model slug so the banner names the right family. Arcee - # Trinity keeps its long-standing unconditional behaviour. - compression_threshold, agent._compression_threshold_autoraised = ( - _resolve_compression_threshold( - compression_threshold, - _model_cthresh, - model=agent.model, - is_codex_autoraise=( - _is_codex_gpt54_or_gpt55_fn(agent.model, agent.provider) - or _is_codex_spark_fn(agent.model, agent.provider) - ), - ) - ) - except Exception: - pass - compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"} - compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20)) - compression_protect_last = int(_compression_cfg.get("protect_last_n", 20)) - # Tail retention mode (compression.tail_mode). "lean" (default) keeps a - # clamped 2.5%/10K-25K verbatim tail with recovery-pointer machinery — - # continuity rides the upgraded summary (digests, anchor index, verbatim - # user messages, session_search pointers; recall-eval'd, see - # evals/compaction/results/). "legacy" restores the pre-#87326 - # 0.20*threshold verbatim tail, which on big-window/raised-threshold - # setups hoards 100-240K tokens per compaction. Unknown values fall back - # to lean inside the compressor. - compression_tail_mode = str(_compression_cfg.get("tail_mode", "lean")).strip().lower() - # Minimum REAL (actionable) user messages guaranteed to survive in the - # uncompressed tail (compression.min_tail_user_messages). Default 1 - # preserves current behavior exactly — the existing single-user tail - # anchor. Values > 1 extend the guarantee to the last N actionable - # user turns. Booleans rejected (bool subclasses int), non-int-like - # values fall back to 1, floor at 1. - _raw_min_tail_users = _compression_cfg.get("min_tail_user_messages", 1) - if isinstance(_raw_min_tail_users, bool): - compression_min_tail_users = 1 - elif isinstance(_raw_min_tail_users, int): - compression_min_tail_users = _raw_min_tail_users - elif isinstance(_raw_min_tail_users, float): - compression_min_tail_users = ( - int(_raw_min_tail_users) if _raw_min_tail_users.is_integer() else 1 - ) - else: - try: - compression_min_tail_users = int(str(_raw_min_tail_users).strip()) - except (TypeError, ValueError): - compression_min_tail_users = 1 - if compression_min_tail_users < 1: - compression_min_tail_users = 1 - # Cap on compression retry rounds before a turn gives up with "max - # compression attempts reached" (compression.max_attempts). Hardcoding 3 - # strands sessions that legitimately need more rounds — e.g. a restart - # history reload whose incompressible tool schemas keep the request - # estimate above the threshold even though the messages compress fine - # (the #62605 failure class). Default 3 preserves current behavior, so - # an unset key is behavior-neutral; validated >= 1, hard-capped at 10, - # and any non-int-like value falls back to 3. Booleans are rejected - # (bool subclasses int, so int(True) would silently become 1) and - # fractional floats are rejected rather than truncated — "4.7 attempts" - # is a config mistake, not a request for 4. - _raw_max_attempts = _compression_cfg.get("max_attempts", 3) - if isinstance(_raw_max_attempts, bool): - compression_max_attempts = 3 - elif isinstance(_raw_max_attempts, int): - compression_max_attempts = _raw_max_attempts - elif isinstance(_raw_max_attempts, float): - compression_max_attempts = ( - int(_raw_max_attempts) if _raw_max_attempts.is_integer() else 3 - ) - else: - try: - compression_max_attempts = int(str(_raw_max_attempts).strip()) - except (TypeError, ValueError): - compression_max_attempts = 3 - if compression_max_attempts < 1: - compression_max_attempts = 3 - compression_max_attempts = min(compression_max_attempts, 10) - - def _parse_prune_int(raw, default): - # Same parser semantics as compression.max_attempts above: reject - # booleans (bool subclasses int — YAML `true` would coerce to 1), - # reject fractional floats rather than truncating them, accept - # integral floats and numeric strings, fall back to the default on - # anything else. - if isinstance(raw, bool): - return default - if isinstance(raw, int): - return raw - if isinstance(raw, float): - return int(raw) if raw.is_integer() else default - try: - return int(str(raw).strip()) - except (TypeError, ValueError): - return default - - # Opt-in proactive tool-result prune trigger (0 = disabled — the - # default, so an unset key is behavior-neutral). Negative values are - # treated as disabled rather than erroring. - compression_proactive_prune_tokens = max( - 0, _parse_prune_int(_compression_cfg.get("proactive_prune_tokens", 0), 0) - ) - compression_proactive_prune_min_chars = _parse_prune_int( - _compression_cfg.get("proactive_prune_min_result_chars", 8000), 8000 - ) - compression_proactive_prune_min_reclaim = max( - 0, - _parse_prune_int( - _compression_cfg.get("proactive_prune_min_reclaim_tokens", 4096), 4096 - ), - ) - # protect_first_n is the number of non-system messages to protect at - # the head, in addition to the system prompt (which is always - # implicitly protected by the compressor). Floor at 0 — a value of - # 0 means "preserve only the system prompt + summary + tail", which - # is a legitimate (and common) configuration for long-running - # rolling-compaction sessions. - compression_protect_first = max( - 0, int(_compression_cfg.get("protect_first_n", 3)) - ) - compression_abort_on_summary_failure = str( - _compression_cfg.get("abort_on_summary_failure", False) - ).lower() in {"true", "1", "yes"} - # Per-model threshold overrides: keys are substring-matched against the - # model name (longest match wins). Empty dict = use the global threshold - # for all models (backward compatible). - _raw_model_thresholds = _compression_cfg.get("model_thresholds", {}) - if isinstance(_raw_model_thresholds, dict): - compression_model_thresholds = { - str(k): float(v) for k, v in _raw_model_thresholds.items() - if isinstance(v, (int, float)) and not isinstance(v, bool) - } - else: - compression_model_thresholds = {} - # Absolute token cap: when set, compression triggers at the lower of - # the ratio-based threshold and this absolute count. Clamped to the - # model's context length at apply-time so a cap above the window is - # a no-op (ratio-based threshold wins). - compression_threshold_tokens = _compression_cfg.get("threshold_tokens") - if compression_threshold_tokens is not None: - try: - compression_threshold_tokens = int(compression_threshold_tokens) - if compression_threshold_tokens <= 0: - compression_threshold_tokens = None - except (TypeError, ValueError): - compression_threshold_tokens = None - compression_checkpoint_required = is_truthy_value( - _compression_cfg.get("checkpoint_required"), default=False - ) - _refuse_checkpoint_required_on_codex_app_server( - compression_checkpoint_required, getattr(agent, "api_mode", None) - ) - # In-place compaction: when True, compress_context() rewrites the message - # list + rebuilds the system prompt WITHOUT rotating the session id (no - # parent_session_id chain, no `name #N` renumber). See #38763 and - # agent/conversation_compression.py. Consumed by compress_context(), not the - # compressor, so it rides on the agent. - # Default True must match DEFAULT_CONFIG["compression"]["in_place"] - # (#38763). default=False here previously flipped agents into rotation - # mode whenever the merged config omitted the key (partial configs, - # load_config failure → {}), re-arming the pre-lease drift abort. - compression_in_place = is_truthy_value( - _compression_cfg.get("in_place"), default=True - ) - # Opt-in (default False): a micro-compaction pass rewrites already-sent - # history every turn, which breaks the provider prompt-cache prefix on a - # per-turn cadence rather than at an episodic boundary. That is the cost - # `proactive_prune_min_reclaim_tokens` exists to amortize, so the feature - # stays off until an operator opts in and accepts the tradeoff. - compression_micro_compact = is_truthy_value( - _compression_cfg.get("micro_compact"), default=False - ) - # How often a pass runs, in completed turns. Each pass rewrites - # already-sent history and costs one prompt-cache break, so this is the - # dial for how often that cost is paid: 1 = every turn (most aggressive - # reclaim), 5 = one break per five turns. Clamped to >= 1. - compression_micro_compact_every_n_turns = max( - 1, - _parse_prune_int(_compression_cfg.get("micro_compact_every_n_turns", 1), 1), - ) - # Rolling-summary defrag threshold, in tokens. Lived on the compressor as - # a hardcoded attribute with no path from config until now. - compression_micro_compact_defrag_tokens = max( - 1, - _parse_prune_int( - _compression_cfg.get("micro_compact_defrag_threshold_tokens", 2000), - 2000, - ), - ) - codex_app_server_auto_compaction = str( - _compression_cfg.get("codex_app_server_auto", "native") or "native" - ).lower() - if codex_app_server_auto_compaction not in {"native", "hermes", "off"}: - _ra().logger.warning( - "Invalid compression.codex_app_server_auto=%r; using 'native'. " - "Valid values are: native, hermes, off.", - codex_app_server_auto_compaction, - ) - codex_app_server_auto_compaction = "native" - # Native OpenAI Responses server-side compaction (opt-in). Only ever - # engages for gpt-5.6-family models on api.openai.com or the ChatGPT - # Codex backend — the per-request gate lives in agent/native_compaction.py. - # Shared truthy coercion: "false"/"off"/"no" strings stay disabled - # (bool("false") is True — #82777). - from utils import is_truthy_value as _is_truthy - - codex_responses_native_compaction = _is_truthy( - _compression_cfg.get("codex_responses_native", False) - ) - _native_threshold_raw = _compression_cfg.get("codex_responses_compact_threshold") - codex_responses_compact_threshold = None - if _native_threshold_raw is not None: - try: - if isinstance(_native_threshold_raw, (bool, float)): - raise ValueError - codex_responses_compact_threshold = int(_native_threshold_raw) - if codex_responses_compact_threshold <= 0: - raise ValueError - except (TypeError, ValueError): - _ra().logger.warning( - "Invalid compression.codex_responses_compact_threshold=%r; " - "using the automatic threshold derived from local compression.", - _native_threshold_raw, - ) - codex_responses_compact_threshold = None - # Opt-in idle compaction: compact a session up front when it resumes after - # this many seconds of inactivity (0 = disabled). Time-based, so it - # complements the size-based threshold above. Consumed by build_turn_context(). - compression_idle_compact_after_seconds = max( - 0, int(_compression_cfg.get("idle_compact_after_seconds", 0)) - ) - - # Read optional explicit context_length override for the auxiliary - # compression model. Custom endpoints often cannot report this via - # /models, so the startup feasibility check needs the config hint. - try: - _aux_cfg = cfg_get(_agent_cfg, "auxiliary", "compression", default={}) - except Exception: - _aux_cfg = {} - if isinstance(_aux_cfg, dict): - _aux_context_config = _aux_cfg.get("context_length") - else: - _aux_context_config = None - if _aux_context_config is not None: - try: - _aux_context_config = int(_aux_context_config) - except (TypeError, ValueError): - _aux_context_config = None - agent._aux_compression_context_length_config = _aux_context_config - - # Read explicit model output-token override from config when the - # caller did not pass one directly. - _model_cfg = _agent_cfg.get("model", {}) - if agent.max_tokens is None and isinstance(_model_cfg, dict): - _config_max_tokens = _model_cfg.get("max_tokens") - if _config_max_tokens is not None: - try: - if isinstance(_config_max_tokens, bool): - raise ValueError - _parsed_max_tokens = int(_config_max_tokens) - if _parsed_max_tokens <= 0: - raise ValueError - agent.max_tokens = _parsed_max_tokens - except (TypeError, ValueError): - _ra().logger.warning( - "Invalid model.max_tokens in config.yaml: %r — " - "must be a positive integer (e.g. 4096). " - "Falling back to provider default.", - _config_max_tokens, - ) - print( - f"\n⚠ Invalid model.max_tokens in config.yaml: {_config_max_tokens!r}\n" - f" Must be a positive integer (e.g. 4096).\n" - f" Falling back to provider default.\n", - file=sys.stderr, - ) - agent._session_init_model_config["max_tokens"] = agent.max_tokens - - # Read explicit context_length override from model config - if isinstance(_model_cfg, dict): - _config_context_length = _model_cfg.get("context_length") - else: - _config_context_length = None - if _config_context_length is not None: - try: - _config_context_length = int(_config_context_length) - except (TypeError, ValueError): - _ra().logger.warning( - "Invalid model.context_length in config.yaml: %r — " - "must be a plain integer (e.g. 256000, not '256K'). " - "Falling back to auto-detection.", - _config_context_length, - ) - print( - f"\n⚠ Invalid model.context_length in config.yaml: {_config_context_length!r}\n" - f" Must be a plain integer (e.g. 256000, not '256K').\n" - f" Falling back to auto-detected context window.\n", - file=sys.stderr, - ) - _config_context_length = None - - # Resolve custom_providers once before route-scoping a global context pin: - # a named custom provider may keep its base URL only in this list rather - # than repeating it under ``model``. - try: - from hermes_cli.config import get_compatible_custom_providers - _custom_providers = get_compatible_custom_providers(_agent_cfg) - except Exception: - _custom_providers = _agent_cfg.get("custom_providers") - if not isinstance(_custom_providers, list): - _custom_providers = [] - - # ``model.context_length`` describes the configured default model. A - # process launched directly with ``--model`` / ``-m`` has already replaced - # ``agent.model`` before this initializer loads config, so carrying the - # default model's explicit window into that different runtime is stale. The - # live switch/fallback paths already clear this override; keep direct-start - # overrides consistent with them and let provider metadata resolve the - # active model's window instead. - if _config_context_length is not None and isinstance(_model_cfg, dict): - _default = _model_cfg.get("default") - if isinstance(_default, dict): - from hermes_cli.config import split_model_config_default - _default, _ = split_model_config_default(_default) - _configured_default_model = str(_default or "").strip() - _configured_default_runtime_model = _configured_default_model - _active_runtime_model = agent.model - if _configured_default_model: - try: - from hermes_cli.model_normalize import normalize_model_for_provider - - _configured_default_runtime_model = normalize_model_for_provider( - _configured_default_model, agent.provider - ) - _active_runtime_model = normalize_model_for_provider( - agent.model, agent.provider - ) - except Exception: - pass - _configured_provider = str(_model_cfg.get("provider") or "").strip() - _configured_base_url = _normalize_route_base_url( - _model_cfg.get("base_url") - ) - _configured_provider_norm = _normalize_custom_provider_name( - _configured_provider - ) - _custom_provider_candidate = bool(_configured_provider_norm) - _runtime_first_provider_ids = { - "auto", - "moa", - "vertex", - "google-vertex", - "vertex-ai", - "gcp-vertex", - "vertexai", - } - if _configured_provider_norm in _runtime_first_provider_ids: - _custom_provider_candidate = False - elif ( - _custom_provider_candidate - and _configured_provider_norm != "custom" - and not _configured_provider_norm.startswith("custom:") - ): - try: - from hermes_cli.auth import resolve_provider as resolve_auth_provider - - _resolved_auth_provider = resolve_auth_provider( - _configured_provider_norm - ) - _custom_provider_candidate = ( - str(_resolved_auth_provider or "").strip().lower() - != _configured_provider_norm - ) - except Exception: - pass - if not _configured_base_url and _custom_provider_candidate: - _configured_custom_provider = _normalize_custom_provider_name( - _configured_provider - ) - _user_providers = _agent_cfg.get("providers") - _disabled_custom_provider_ids: set[str] = set() - if isinstance(_user_providers, dict): - from hermes_cli.config import is_provider_enabled - - for _provider_key, _provider_entry in _user_providers.items(): - if not isinstance(_provider_entry, dict): - continue - _entry_name = str( - _provider_entry.get("name") or "" - ).strip() - _entry_provider_ids = _custom_provider_runtime_ids( - _provider_key - ) | _custom_provider_runtime_ids(_entry_name) - if not is_provider_enabled(_provider_entry): - _disabled_custom_provider_ids.update( - provider_id - for provider_id in _entry_provider_ids - if provider_id - ) - continue - if _configured_custom_provider not in _entry_provider_ids: - continue - _configured_base_url = _normalize_route_base_url( - _provider_entry.get("api") - or _provider_entry.get("url") - or _provider_entry.get("base_url") - ) - if _configured_base_url: - break - if not _configured_base_url: - for _provider_entry in _custom_providers: - if not isinstance(_provider_entry, dict): - continue - _entry_name = str( - _provider_entry.get("name") or "" - ).strip() - _entry_provider_key = str( - _provider_entry.get("provider_key") or "" - ).strip().lower() - _entry_provider_ids = _custom_provider_runtime_ids( - _entry_name - ) | _custom_provider_runtime_ids(_entry_provider_key) - if ( - _entry_provider_key - and _custom_provider_runtime_ids(_entry_provider_key) - & _disabled_custom_provider_ids - ): - continue - if _configured_custom_provider not in _entry_provider_ids: - continue - _configured_base_url = _normalize_route_base_url( - _provider_entry.get("base_url") - ) - if _configured_base_url: - break - _active_route_url = str(agent.base_url or "") - _requested_route_url = str(base_url or "") - if "?" in _requested_route_url.split("#", 1)[0]: - try: - _requested_parts = urlparse(_requested_route_url) - _requested_without_query = urlunparse( - _requested_parts._replace(query="") - ) - if _normalize_route_base_url( - _requested_without_query - ) == _normalize_route_base_url(_active_route_url): - _active_route_url = _requested_route_url - except (TypeError, ValueError): - pass - _active_base_url = _normalize_route_base_url(_active_route_url) - _route_mismatch = _context_route_mismatch( - _configured_base_url, - _active_base_url, - _configured_provider, - agent.provider, - already_normalized=True, - ) - _model_mismatch = bool( - _configured_default_runtime_model - and _configured_default_runtime_model != _active_runtime_model - ) - if _model_mismatch or _route_mismatch: - _ra().logger.debug( - "Ignoring model.context_length=%s for startup runtime %s at %s " - "(configured default is %s at %s)", - _config_context_length, - agent.model, - _active_base_url or agent.provider, - _configured_default_model, - _configured_base_url or _model_cfg.get("provider"), - ) - _config_context_length = None - - # Store for reuse by _check_compression_model_feasibility (auxiliary - # compression model context-length detection needs the same list). - agent._custom_providers = _custom_providers - _merge_custom_provider_extra_body(agent, _custom_providers) - - # Check custom_providers per-model context_length - if _config_context_length is None and _custom_providers: - try: - from hermes_cli.config import get_custom_provider_context_length - _cp_ctx_resolved = get_custom_provider_context_length( - model=agent.model, - base_url=agent.base_url, - custom_providers=_custom_providers, - ) - if _cp_ctx_resolved: - _config_context_length = int(_cp_ctx_resolved) - except Exception: - _cp_ctx_resolved = None - - # Surface a clear warning if the user set a context_length but it - # wasn't a valid positive int — the helper silently skips those. - if _config_context_length is None: - _target = _normalize_route_base_url(agent.base_url) - for _cp_entry in _custom_providers: - if not isinstance(_cp_entry, dict): - continue - _cp_url = _normalize_route_base_url(_cp_entry.get("base_url")) - if _target and _cp_url == _target: - _cp_models = _cp_entry.get("models", {}) - if isinstance(_cp_models, dict): - _cp_model_cfg = _cp_models.get(agent.model, {}) - if isinstance(_cp_model_cfg, dict): - _cp_ctx = _cp_model_cfg.get("context_length") - if _cp_ctx is not None: - try: - _parsed = int(_cp_ctx) - if _parsed <= 0: - raise ValueError - except (TypeError, ValueError): - _ra().logger.warning( - "Invalid context_length for model %r in " - "custom_providers: %r — must be a positive " - "integer (e.g. 256000, not '256K'). " - "Falling back to auto-detection.", - agent.model, _cp_ctx, - ) - print( - f"\n⚠ Invalid context_length for model {agent.model!r} in custom_providers: {_cp_ctx!r}\n" - f" Must be a positive integer (e.g. 256000, not '256K').\n" - f" Falling back to auto-detected context window.\n", - file=sys.stderr, - ) - break - - # Persist for reuse on switch_model / fallback activation. Must come - # AFTER the custom_providers branch so per-model overrides aren't lost. - agent._config_context_length = _config_context_length - - _lmstudio_runtime_context_length = agent._ensure_lmstudio_runtime_loaded( - _config_context_length - ) - if agent._lmstudio_load_was_unverified(_lmstudio_runtime_context_length): - _ra().logger.warning( - "LM Studio model activation was rejected or completed without a " - "verifiable active context length; falling back to configured context" - ) - _effective_context_length = agent._effective_lmstudio_context_length( - _config_context_length, - _lmstudio_runtime_context_length, + _config_context_length, _custom_providers, _effective_context_length, _model_cfg = _resolve_context_length( + agent, _agent_cfg, base_url ) - # Select context engine: config-driven (like memory providers). - # 1. Check config.yaml context.engine setting - # 2. Check plugins/context_engine// directory (repo-shipped) - # 3. Check general plugin system (user-installed plugins) - # 4. Fall back to built-in ContextCompressor - _selected_engine = None - _copy_failed = False - _engine_name = "compressor" # default - try: - _ctx_cfg = _agent_cfg.get("context", {}) if isinstance(_agent_cfg, dict) else {} - _engine_name = _ctx_cfg.get("engine", "compressor") or "compressor" - except Exception: - pass + _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_context_length, session_db) - if _engine_name != "compressor": - # Try loading from plugins/context_engine// - try: - from plugins.context_engine import load_context_engine - _selected_engine = load_context_engine(_engine_name) - except Exception as _ce_load_err: - _ra().logger.debug("Context engine load from plugins/context_engine/: %s", _ce_load_err) + _enforce_minimum_context(agent) - # Try general plugin system as fallback - if _selected_engine is None: - _candidate = None - try: - from hermes_cli.plugins import get_plugin_context_engine - _candidate = get_plugin_context_engine() - except Exception: - _candidate = None - if _candidate is not None and _candidate.name == _engine_name: - # Deep-copy the shared plugin singleton so a child agent's - # update_model() can't mutate the parent's compressor (#42449). - # Copy can fail for engines holding uncopyable state (locks, DB - # connections, clients); in that case fall back to the built-in - # compressor with an ACCURATE message rather than silently - # mislabelling it "not found". - import copy - try: - _selected_engine = copy.deepcopy(_candidate) - except Exception as _copy_err: - _copy_failed = True - _ra().logger.warning( - "Context engine '%s' could not be safely copied for this " - "agent (%s) — falling back to built-in compressor. Plugin " - "engines that hold uncopyable state (locks, DB connections) " - "should implement __deepcopy__ to copy only mutable budget " - "state.", - _engine_name, _copy_err, - ) - _selected_engine = None + _warn_nonagentic_hermes_model(agent) - if _selected_engine is None and not _copy_failed: - _ra().logger.warning( - "Context engine '%s' not found — falling back to built-in compressor", - _engine_name, - ) - # else: config says "compressor" — use built-in, don't auto-activate plugins - - if _selected_engine is not None: - agent.context_compressor = _selected_engine - # External engines own compaction policy: the host compression - # threshold (including the Codex gpt-5.5 autoraise above) only - # configures the built-in ContextCompressor and never reaches the - # plugin, so the autoraise notice would announce a change that does - # not apply. Drop it. (#44439) - agent._compression_threshold_autoraised = None - # Resolve context_length for plugin engines — mirrors switch_model() path - from agent.model_metadata import get_model_context_length - _plugin_ctx_len = get_model_context_length( - agent.model, - base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), - config_context_length=_effective_context_length, - provider=agent.provider, - custom_providers=_custom_providers, - ) - # Per-model threshold overrides are part of the explicit - # context-engine contract: assign them BEFORE the initial - # update_model() call so the first resolution (which derives - # threshold_percent/threshold_tokens for the initial model) already - # sees the overrides. Assigning after update_model() left the initial - # model on the engine's global threshold until the first /model - # switch. Engines that override update_model() own their own policy - # and may ignore the attribute. - if compression_model_thresholds: - agent.context_compressor.model_thresholds = compression_model_thresholds - agent.context_compressor.update_model( - model=agent.model, - context_length=_plugin_ctx_len, - base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), - provider=agent.provider, - api_mode=agent.api_mode, - ) - if not agent.quiet_mode: - _ra().logger.info("Using context engine: %s", _selected_engine.name) - else: - # Native Gemini output reservation (#57275 claim 4): when - # model.max_tokens is unset, the native generateContent adapter does - # NOT run uncapped — it sends maxOutputTokens=65,535 - # (GEMINI_DEFAULT_MAX_OUTPUT_TOKENS, see - # _effective_gemini_max_output_tokens). The compressor's threshold is - # pct×(window − max_tokens); passing None here meant it reserved 0 - # while the wire reserved 65,535, so on a 128K window the trigger - # landed at ~96K against a real safe input budget of ~65K and the - # provider 400'd before compaction fired. Mirror the adapter's - # default so the reservation matches what is actually sent. The - # generic provider-default gap is #63839; this wires only the native - # Gemini path, where the default is a documented constant. - _compressor_max_tokens = agent.max_tokens - if _compressor_max_tokens is None: - try: - from agent.gemini_native_adapter import ( - GEMINI_DEFAULT_MAX_OUTPUT_TOKENS, - is_native_gemini_base_url, - ) - _gemini_provider = str( - getattr(agent, "provider", "") or "" - ).strip().lower() in { - "gemini", "google", "google-gemini", "google-ai-studio", - } - if _gemini_provider or is_native_gemini_base_url(agent.base_url): - _compressor_max_tokens = GEMINI_DEFAULT_MAX_OUTPUT_TOKENS - except Exception: - pass - agent.context_compressor = ContextCompressor( - model=agent.model, - threshold_percent=compression_threshold, - protect_first_n=compression_protect_first, - protect_last_n=compression_protect_last, - summary_target_ratio=compression_target_ratio, - summary_model_override=None, - quiet_mode=agent.quiet_mode, - base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), - config_context_length=_effective_context_length, - provider=agent.provider, - api_mode=agent.api_mode, - abort_on_summary_failure=compression_abort_on_summary_failure, - max_tokens=_compressor_max_tokens, - model_thresholds=compression_model_thresholds, - threshold_tokens_cap=compression_threshold_tokens, - proactive_prune_tokens=compression_proactive_prune_tokens, - proactive_prune_min_result_chars=compression_proactive_prune_min_chars, - proactive_prune_min_reclaim_tokens=compression_proactive_prune_min_reclaim, - min_tail_user_messages=compression_min_tail_users, - tail_mode=compression_tail_mode, - ) - _bind_session_state = getattr(agent.context_compressor, "bind_session_state", None) - if callable(_bind_session_state): - try: - _bind_session_state(session_db=session_db, session_id=agent.session_id) - except Exception: - pass - agent.compression_enabled = compression_enabled - agent.compression_in_place = compression_in_place - # Apply micro-compaction settings to the compressor (feature is opt-in) - _cc = getattr(agent, "context_compressor", None) - # compression.checkpoint_required: micro-compaction is a lossy rewrite - # authority too — it absorbs the oldest uncompacted exchanges into a - # rolling summary post-turn, with no pre-compress checkpoint hook in its - # path. Suppress it while the gate is armed so the checkpoint-aware - # batch compressor stays the only lossy authority (mirrors the - # server-side native-compaction suppression in native_compaction.py). - if compression_checkpoint_required and compression_micro_compact: - logger.warning( - "compression.checkpoint_required is enabled: post-turn " - "micro-compaction is disabled for this agent so every lossy " - "rewrite passes through the checkpoint-gated compressor." - ) - compression_micro_compact = False - if _cc is not None and hasattr(_cc, "_micro_compact_enabled"): - _cc._micro_compact_enabled = compression_micro_compact - if _cc is not None and hasattr(_cc, "_micro_compact_every_n_turns"): - _cc._micro_compact_every_n_turns = compression_micro_compact_every_n_turns - if _cc is not None and hasattr(_cc, "_micro_compact_defrag_threshold_tokens"): - _cc._micro_compact_defrag_threshold_tokens = ( - compression_micro_compact_defrag_tokens - ) - agent.compression_checkpoint_required = compression_checkpoint_required - agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction - agent.codex_responses_native_compaction = codex_responses_native_compaction - agent.codex_responses_compact_threshold = codex_responses_compact_threshold - from agent.native_compaction import resolve_native_compaction_capabilities - agent.runtime_capabilities = resolve_native_compaction_capabilities( - model=agent.model, - base_url=agent.base_url, - provider=agent.provider, - is_codex_backend=(agent.provider or "").strip().lower() == "openai-codex", - ) - agent.max_compression_attempts = compression_max_attempts - agent.compression_idle_compact_after_seconds = ( - compression_idle_compact_after_seconds - ) - - # Reject models whose context window is below the minimum required - # for reliable tool-calling workflows (64K tokens). - _ctx = getattr(agent.context_compressor, "context_length", 0) - _allow_lmstudio_explicit_below_floor = ( - str(getattr(agent, "provider", "") or "").strip().lower() == "lmstudio" - and isinstance(agent._config_context_length, int) - and not isinstance(agent._config_context_length, bool) - and agent._config_context_length > 0 - ) - if _ctx and _ctx < MINIMUM_CONTEXT_LENGTH and not _allow_lmstudio_explicit_below_floor: - raise ValueError( - f"Model {agent.model} has a context window of {_ctx:,} tokens, " - f"which is below the minimum {MINIMUM_CONTEXT_LENGTH:,} required " - f"by Hermes Agent. Choose a model with at least " - f"{MINIMUM_CONTEXT_LENGTH // 1000}K context. If your server " - f"reports a window smaller than the model's true window, set " - f"model.context_length in config.yaml to the real value " - f"(this must be at least {MINIMUM_CONTEXT_LENGTH // 1000}K)." - ) - - # Nous Hermes 3/4 are chat models, not tool-call-tuned. The interactive - # CLI already warns via cli.py show_banner() (richer output + /model hint), - # so skip platform=="cli" here to avoid emitting the warning twice per - # startup. (Gateway/TUI/cron construct with quiet_mode=True and are already - # gated off by the `not agent.quiet_mode` check above; this guard's active - # job is the CLI dedup, and it leaves the door open for any non-quiet - # non-CLI surface to still surface the warning.) - if not agent.quiet_mode and (agent.platform or "cli") != "cli": - try: - from hermes_cli.model_switch import _check_hermes_model_warning - - _hermes_warn = _check_hermes_model_warning(agent.model or "") - if _hermes_warn: - _user_msg = ( - "⚠ Nous Research Hermes 3 & 4 models are NOT agentic — they " - "lack reliable tool-calling for agent workflows (delegation, " - "cron, proactive tools). Consider an agentic model instead " - "(Claude, GPT, Gemini, Qwen-Coder, etc.)." - ) - if hasattr(agent, "_emit_warning"): - agent._emit_warning(_user_msg) - else: - print(f"\n{_user_msg}\n", file=sys.stderr) - _ra().logger.warning(_hermes_warn) - except Exception: - pass - - # Inject context engine tool schemas (e.g. lcm_grep, lcm_describe, lcm_expand). - # Skip names that are already present — the _ra().get_tool_definitions() - # quiet_mode cache returned a shared list pre-#17335, so a stray - # mutation here would poison subsequent agent inits in the same - # Gateway process and trip provider-side 'duplicate tool name' - # errors. Even with the cache fix, dedup is the right defense - # against plugin paths that may register the same schemas via - # ctx.register_tool(). Mirrors the memory tools dedup above. - # - # Respect the platform's enabled_toolsets configuration (#5544): - # context engine tools follow the same gating pattern as memory - # provider tools — without the gate, `platform_toolsets: telegram: []` - # would still leak lcm_* tools into the tool surface and incur the - # same local-model latency penalty. - agent._context_engine_tool_names: set = set() - if ( - hasattr(agent, "context_compressor") - and agent.context_compressor - and agent.tools is not None - and ( - agent.enabled_toolsets is None - or "context_engine" in agent.enabled_toolsets - ) - ): - _existing_tool_names = { - t.get("function", {}).get("name") - for t in agent.tools - if isinstance(t, dict) - } - from agent.memory_manager import normalize_tool_schema as _normalize_tool_schema - for _raw_schema in agent.context_compressor.get_tool_schemas(): - _schema = _normalize_tool_schema(_raw_schema) - if _schema is None: - # A schema with no resolvable name (e.g. an already-wrapped - # entry) would append a nameless tool that strict providers - # 400 on, disabling the whole toolset (#47707). Skip it. - _ra().logger.warning( - "Context engine returned a tool schema with no resolvable " - "name; skipping to avoid poisoning the request (%r)", - _raw_schema, - ) - continue - _tname = _schema["name"] - if _tname in _existing_tool_names: - continue # already registered via plugin/cache path - _wrapped = {"type": "function", "function": _schema} - agent.tools.append(_wrapped) - agent.valid_tool_names.add(_tname) - agent._context_engine_tool_names.add(_tname) - _existing_tool_names.add(_tname) - - # Notify context engine of session start - if hasattr(agent, "context_compressor") and agent.context_compressor: - try: - agent.context_compressor.on_session_start( - agent.session_id, - hermes_home=str(get_hermes_home()), - platform=agent.platform or "cli", - model=agent.model, - context_length=getattr(agent.context_compressor, "context_length", 0), - conversation_id=getattr(agent, "_gateway_session_key", None), - ) - except Exception as _ce_err: - _ra().logger.debug("Context engine on_session_start: %s", _ce_err) + _inject_context_engine_tools(agent) from agent.runtime_cwd import scope_terminal_cwd as _scope_terminal_cwd @@ -3067,183 +3062,11 @@ def init_agent( agent._api_latency_history = _deque(maxlen=10) agent._api_output_history = _deque(maxlen=10) - # ── Ollama num_ctx injection ── - # Ollama defaults to 2048 context regardless of the model's capabilities. - # When running against an Ollama server, detect the model's max context - # and pass num_ctx on every chat request so the full window is used. - # User override: set model.ollama_num_ctx in config.yaml to cap VRAM use. - # If model.context_length is set, it caps num_ctx so the user's VRAM - # budget is respected even when GGUF metadata advertises a larger window. - agent._ollama_num_ctx: int | None = None - _ollama_num_ctx_override = None - if isinstance(_model_cfg, dict): - _ollama_num_ctx_override = _model_cfg.get("ollama_num_ctx") - if _ollama_num_ctx_override is not None: - try: - agent._ollama_num_ctx = int(_ollama_num_ctx_override) - except (TypeError, ValueError): - _ra().logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override) - if agent._ollama_num_ctx is None and agent.base_url and is_local_endpoint(agent.base_url): - try: - # ``agent.api_key`` may be a callable (Entra token provider). - # Ollama detection makes a manual HTTP request and expects a - # string — Azure Foundry isn't a local endpoint so this branch - # never fires for Entra, but guard defensively. - _key_for_ollama = agent.api_key if isinstance(agent.api_key, str) else "" - _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=_key_for_ollama or "") - if _detected and _detected > 0: - agent._ollama_num_ctx = _detected - except Exception as exc: - _ra().logger.debug("Ollama num_ctx detection failed: %s", exc) - # Cap auto-detected ollama_num_ctx to the user's explicit context_length. - # Without this, GGUF metadata can advertise 256K+ which Ollama honours - # by allocating that much VRAM — blowing up small GPUs even though the - # user explicitly set a smaller context_length in config.yaml. - if ( - agent._ollama_num_ctx - and _config_context_length - and _ollama_num_ctx_override is None # don't override explicit ollama_num_ctx - and agent._ollama_num_ctx > _config_context_length - ): - _ra().logger.info( - "Ollama num_ctx capped: %d -> %d (model.context_length override)", - agent._ollama_num_ctx, _config_context_length, - ) - agent._ollama_num_ctx = _config_context_length - if agent._ollama_num_ctx and not agent.quiet_mode: - _ra().logger.info( - "Ollama num_ctx: will request %d tokens (model max from /api/show)", - agent._ollama_num_ctx, - ) - # ── Recalibrate the compressor to the served window (#57275 claim 3) ── - # The compressor was constructed ABOVE this block from the probed model - # window (GGUF metadata can advertise 256K+), but every request below - # runs at num_ctx. A config that sets only model.ollama_num_ctx (without - # model.context_length) previously left the compressor targeting the - # probed window while the server truncated/rejected at num_ctx — the - # compaction trigger could sit several times ABOVE the real served - # window and never fire. Clamp the compressor's window to the effective - # num_ctx so threshold math operates on the context the server actually - # serves. (Overlaps #60103's silent-clamp dead zone; this is the - # init-order half.) - _cc_window = getattr(agent.context_compressor, "context_length", 0) or 0 - if ( - agent._ollama_num_ctx - and agent._ollama_num_ctx > 0 - and _cc_window - and agent._ollama_num_ctx < _cc_window - ): - _ra().logger.info( - "Compressor window clamped to Ollama num_ctx: %d -> %d", - _cc_window, agent._ollama_num_ctx, - ) - agent.context_compressor.update_model( - model=agent.model, - context_length=agent._ollama_num_ctx, - base_url=agent.base_url, - api_key=getattr(agent, "api_key", ""), - provider=agent.provider, - api_mode=agent.api_mode, - ) + _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length) - # Codex gpt-5.x autoraise notice: show at most once per profile/config - # state. Without the persisted marker the notice re-fires on every agent - # init — and the gateway rebuilds the agent per inbound message, so Discord - # etc. saw it repeatedly (#54432). A change in the raised threshold (or the - # autoraised model) updates the marker state and re-notifies once. The - # config display gate (compression.codex_gpt55_autoraise_notice) still - # suppresses the banner entirely without disabling the threshold autoraise. - _autoraise = getattr(agent, "_compression_threshold_autoraised", None) or {} - _show_autoraise_notice = ( - bool(_autoraise) - and compression_enabled - and _codex_gpt55_autoraise_notice - and not _codex_gpt55_autoraise_notice_seen(_autoraise) - ) + _emit_compression_summary(agent, cs) - if not agent.quiet_mode: - if compression_enabled: - # Report the active engine's own threshold — for a plugin engine - # the host compression_threshold is not in effect, and mixing the - # two printed a percent that contradicted the token count. (#44439) - _active_threshold_pct = getattr( - agent.context_compressor, "threshold_percent", compression_threshold - ) - _cap_note = "" - _cap = getattr(agent.context_compressor, "threshold_tokens_cap", None) - if _cap and _cap > 0: - _cap_note = f" (capped at {_cap:,} tokens)" - print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})") - else: - print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)") - # Notice with the exact opt-back-out command. Printed inline at startup - # for CLI users; gateway users get the same text replayed via - # _compression_warning on turn 1 (set below). - if _show_autoraise_notice: - print(_build_codex_gpt5_autoraise_notice( - _autoraise, - context_length=getattr(agent.context_compressor, "context_length", None), - )) - - # Check immediately so CLI users see the warning at startup. - # Gateway status_callback is not yet wired, so any warning is stored - # in _compression_warning and replayed in the first run_conversation(). - agent._compression_warning = None - # Gateway parity for the Codex gpt-5.x autoraise notice: the startup print - # above only reaches the CLI, so stash the same text here to be replayed - # through status_callback on the first turn (Telegram/Discord/Slack/etc.). - if _show_autoraise_notice: - agent._compression_warning = _build_codex_gpt5_autoraise_notice( - _autoraise, - context_length=getattr(agent.context_compressor, "context_length", None), - ) - - # Mark shown so repeated inits in this profile (e.g. every gateway message) - # stay silent. Recorded once, whether the notice went to the CLI print or - # the gateway replay slot. - if _show_autoraise_notice: - _record_codex_gpt55_autoraise_notice(_autoraise) - # Lazy feasibility check: deferred to the first turn that approaches the - # compression threshold. Running it eagerly here costs ~400ms cold (network - # probe of the auxiliary provider chain + /models lookup) on every agent - # init, including short ``chat -q`` runs that never reach the threshold. - # ``ensure_compression_feasibility_checked`` (called from - # ``run_conversation``'s preflight) runs it at most once per agent. - agent._compression_feasibility_checked = False - - # 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 = agent.context_compressor - agent._primary_runtime = { - "model": agent.model, - "provider": agent.provider, - "requested_provider": agent.requested_provider, - "base_url": agent.base_url, - "api_mode": agent.api_mode, - "api_key": getattr(agent, "api_key", ""), - "request_overrides": dict(getattr(agent, "request_overrides", {}) or {}), - "client_kwargs": dict(agent._client_kwargs), - "use_prompt_caching": agent._use_prompt_caching, - "use_native_cache_layout": agent._use_native_cache_layout, - "reasoning_echo_flag": getattr(agent, "_reasoning_echo_flag", False), - # Context engine state that _try_activate_fallback() overwrites. - # Use getattr for model/base_url/api_key/provider since plugin - # engines may not have these (they're ContextCompressor-specific). - "compressor_model": getattr(_cc, "model", agent.model), - "compressor_base_url": getattr(_cc, "base_url", agent.base_url), - "compressor_api_key": getattr(_cc, "api_key", ""), - "compressor_provider": getattr(_cc, "provider", agent.provider), - "compressor_context_length": _cc.context_length, - "compressor_threshold_tokens": _cc.threshold_tokens, - } - if agent.api_mode == "anthropic_messages": - agent._primary_runtime.update({ - "anthropic_api_key": agent._anthropic_api_key, - "anthropic_base_url": agent._anthropic_base_url, - "is_anthropic_oauth": agent._is_anthropic_oauth, - }) + _snapshot_primary_runtime(agent) diff --git a/tests/agent/test_moa_quiet_reference_output.py b/tests/agent/test_moa_quiet_reference_output.py deleted file mode 100644 index 2b6d48e95d..0000000000 --- a/tests/agent/test_moa_quiet_reference_output.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Regression coverage for machine-readable MoA quiet output.""" -from __future__ import annotations - -from types import SimpleNamespace -import unittest - -from agent.agent_init import _relay_moa_reference_event - - -class MoAQuietReferenceOutputTests(unittest.TestCase): - @staticmethod - def _agent(*, platform: str, tool_progress_mode: str, quiet_mode: bool = True): - calls = [] - - def callback(*args, **kwargs): - calls.append((args, kwargs)) - - return SimpleNamespace( - platform=platform, - tool_progress_mode=tool_progress_mode, - quiet_mode=quiet_mode, - tool_progress_callback=callback, - ), calls - - def test_machine_readable_cli_suppresses_reference_relay(self) -> None: - agent, calls = self._agent(platform="cli", tool_progress_mode="off") - _relay_moa_reference_event( - agent, - "moa.reference", - label="local:advisor", - text="hidden", - index=1, - count=1, - ) - self.assertEqual(calls, []) - - def test_interactive_cli_delivers_reference_relay(self) -> None: - agent, calls = self._agent( - platform="cli", - tool_progress_mode="all", - quiet_mode=True, - ) - _relay_moa_reference_event( - agent, - "moa.reference", - label="local:advisor", - text="visible", - index=1, - count=2, - ) - self.assertEqual( - calls, - [ - ( - ("moa.reference", "local:advisor", "visible", None), - {"moa_index": 1, "moa_count": 2}, - ) - ], - ) - - def test_gateway_delivers_even_when_progress_mode_is_off(self) -> None: - agent, calls = self._agent(platform="discord", tool_progress_mode="off") - _relay_moa_reference_event( - agent, - "moa.aggregating", - aggregator="local:aggregator", - ref_count=2, - ) - self.assertEqual( - calls, - [ - ( - ("moa.aggregating", "local:aggregator", None, None), - {"moa_ref_count": 2}, - ) - ], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/agent/test_pre_compress_checkpoint_contract.py b/tests/agent/test_pre_compress_checkpoint_contract.py index 24ae249719..8cb5c4b3a1 100644 --- a/tests/agent/test_pre_compress_checkpoint_contract.py +++ b/tests/agent/test_pre_compress_checkpoint_contract.py @@ -520,10 +520,8 @@ def test_agent_init_suppresses_micro_compaction_under_checkpoint_gate(): source = inspect.getsource(agent_init) # The suppression must happen before the compressor attribute assignment. - suppress_idx = source.find( - "if compression_checkpoint_required and compression_micro_compact:" - ) - assign_idx = source.find("_cc._micro_compact_enabled = compression_micro_compact") + suppress_idx = source.find("if cs.checkpoint_required and cs.micro_compact:") + assign_idx = source.find("_cc._micro_compact_enabled = cs.micro_compact") assert suppress_idx != -1, ( "init_agent must suppress micro-compaction when checkpoint_required" )