diff --git a/agent/activity_tracking.py b/agent/activity_tracking.py index 5b0e654d00..93a2866eda 100644 --- a/agent/activity_tracking.py +++ b/agent/activity_tracking.py @@ -15,6 +15,15 @@ from agent.session_activity import ActivityProvenance logger = logging.getLogger("run_agent") +def _activity_lock(obj) -> "threading.Lock": + """Lazy per-instance ``_turn_liveness_activity_lock`` (so ``__new__``/SimpleNamespace doubles work).""" + _lock = getattr(obj, "_turn_liveness_activity_lock", None) + if _lock is None: + _lock = threading.Lock() + obj._turn_liveness_activity_lock = _lock + return _lock + + class ActivityTrackingMixin: """Liveness timestamps/labels and rate-limited session activity persistence.""" @@ -22,42 +31,29 @@ class ActivityTrackingMixin: """Shared lock for the activity clock and its generation counter. ``_touch_activity`` stamps under it and the liveness watchdog samples/commits under it, so a stall - observation can never abort a turn that resumed in between. Lazy so ``__new__``-built doubles work. + observation can never abort a turn that resumed in between. """ - _lock = getattr(self, "_turn_liveness_activity_lock", None) - if _lock is None: - _lock = threading.Lock() - self._turn_liveness_activity_lock = _lock - return _lock + return _activity_lock(self) def _touch_activity( - self, - desc: str, - *, - provenance: Optional[ActivityProvenance] = None, + self, desc: str, *, provenance: Optional[ActivityProvenance] = None, force_persist: bool = False, ) -> None: """Update the last-activity timestamp and description (thread-safe). - Bumps a monotonic generation under ``_liveness_activity_lock`` so the watchdog can bind a stall - observation to the exact ``(generation, timestamp)`` it sampled. Also bridges (rate-limited, - best-effort) to the kanban heartbeat when this is a dispatcher-spawned worker, and to the durable - SessionDB activity projection. ``provenance`` names special writers (compression); ``force_persist`` - bypasses the SessionDB rate limit. + Bumps a monotonic generation under the activity lock so the watchdog can bind a stall observation to + the exact ``(generation, timestamp)`` it sampled. Also bridges (rate-limited, best-effort) to the + kanban heartbeat when this is a dispatcher-spawned worker, and to the durable SessionDB activity + projection. ``provenance`` names special writers (compression); ``force_persist`` bypasses the + SessionDB rate limit. Module-level lock helper, not ``self._liveness_activity_lock()``: doubles bind + only ``_touch_activity`` (tests/run_agent/test_session_activity_persist.py). """ from agent.session_activity import ( - bound_activity_description, - normalize_activity_provenance, + bound_activity_description, normalize_activity_provenance, reset_session_activity_persist_window, ) - # Lazy per-instance lock, inline so SimpleNamespace doubles binding _touch_activity without the - # class keep working (tests/run_agent/test_session_activity_persist.py). - _clock_lock = getattr(self, "_turn_liveness_activity_lock", None) - if _clock_lock is None: - _clock_lock = threading.Lock() - self._turn_liveness_activity_lock = _clock_lock - with _clock_lock: + with _activity_lock(self): self._turn_liveness_activity_generation = ( getattr(self, "_turn_liveness_activity_generation", 0) + 1 ) @@ -70,12 +66,10 @@ class ActivityTrackingMixin: if os.environ.get("HERMES_KANBAN_TASK"): try: from tools.kanban_tools import ( - heartbeat_current_worker_from_env, - inject_new_comments_from_env, + heartbeat_current_worker_from_env, inject_new_comments_from_env ) heartbeat_current_worker_from_env() - # Fold any new operator notes into the running turn (OUT-OF-BAND - # steer) so the user can talk to a live task without a restart. + # Fold new operator notes into the running turn (OUT-OF-BAND steer). inject_new_comments_from_env(self) except Exception: # Never let the bridge break the loop; this guard covers import-time failures. @@ -98,8 +92,7 @@ class ActivityTrackingMixin: if not callable(touch): return from agent.session_activity import ( - SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS, - normalize_activity_provenance, + SESSION_ACTIVITY_HEARTBEAT_MIN_INTERVAL_SECONDS, normalize_activity_provenance ) now_mono = time.monotonic() @@ -118,10 +111,7 @@ class ActivityTrackingMixin: ) except Exception: # Heartbeat is observation-only; never let its I/O break the loop. - logger.debug( - "session activity heartbeat write failed (ignored)", - exc_info=True, - ) + logger.debug("session activity heartbeat write failed (ignored)", exc_info=True) def _reset_activity_labels_after_turn(self) -> None: """Drop mid-turn activity labels once the turn is no longer running. @@ -129,8 +119,6 @@ class ActivityTrackingMixin: Keeps ``_last_activity_ts`` so idle/watchdog clocks stay continuous across turns; clears description + provenance so idle agents / SessionDB listings stop advertising the last mid-turn stamp. """ - from agent.session_activity import ActivityProvenance - self._last_activity_desc = "" self._last_activity_provenance = ActivityProvenance.UNKNOWN session_id = getattr(self, "session_id", None) @@ -143,5 +131,4 @@ class ActivityTrackingMixin: try: clear(session_id) except Exception: - # Never let durable cleanup I/O break turn teardown. - pass + pass # Never let durable cleanup I/O break turn teardown. diff --git a/agent/agent_init.py b/agent/agent_init.py index be5e0808ff..c94e25911e 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -1,11 +1,10 @@ """Implementation of :meth:`AIAgent.__init__` as ``init_agent(agent, ...)``. -``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. +``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,18 +26,13 @@ from agent.iteration_budget import IterationBudget from agent.memory_manager import StreamingContextScrubber from agent.session_activity import ActivityProvenance from agent.model_metadata import ( - MINIMUM_CONTEXT_LENGTH, - fetch_model_metadata, - is_local_endpoint, - query_ollama_num_ctx, + MINIMUM_CONTEXT_LENGTH, fetch_model_metadata, is_local_endpoint, query_ollama_num_ctx ) from agent.process_bootstrap import _install_safe_stdio from agent.subdirectory_hints import SubdirectoryHintTracker from agent.think_scrubber import StreamingThinkScrubber from agent.tool_guardrails import ( - ToolCallGuardrailConfig, - ToolCallGuardrailController, - ToolGuardrailDecision, + ToolCallGuardrailConfig, ToolCallGuardrailController, ToolGuardrailDecision ) from hermes_cli.config import cfg_get from hermes_cli.route_identity import normalize_route_base_url @@ -50,25 +44,18 @@ from utils import base_url_host_matches, is_truthy_value logger = logging.getLogger("run_agent") -# Memory providers we've already warned are unavailable. Deduped because the -# gateway builds a fresh AIAgent per message, so an un-deduped warning would -# fire on every turn. +# Memory providers already warned unavailable — the gateway builds a fresh AIAgent per +# message, so an un-deduped warning would fire every turn. _warned_unavailable_providers: set[str] = set() def _warn_memory_provider_unavailable(name: str, reason: str = "") -> None: - """Warn (once per provider) when a configured memory provider is unavailable. + """Warn once per provider that a configured memory provider is unavailable. - ``is_available()`` is a fast, side-effect-free hot-path check, so it can't - log for itself. Without this warning a provider whose credentials/config are - missing is silently dropped — the user has ``memory.provider`` set but gets - no memory and no diagnostic. A common trigger is systemd/gateway services - not inheriting ``~/.hermes/.env``. See NousResearch/hermes-agent#2765. - - ``reason`` is the provider's ``unavailable_reason()`` — a provider-specific, - actionable hint (e.g. which package to install). Because an unavailable - provider is never initialized, this is the only place such a hint can reach - the user, so it is appended to the warning when present (#7718). + ``is_available()`` is a side-effect-free hot-path check and can't log itself; without + this the provider is silently dropped (common trigger: systemd/gateway services not + inheriting ``~/.hermes/.env``). ``reason`` is the provider's ``unavailable_reason()`` + hint — this is the only place it can reach the user, so it is appended when present. """ if name in _warned_unavailable_providers: return @@ -85,10 +72,7 @@ def _warn_memory_provider_unavailable(name: str, reason: str = "") -> None: def _ra(): - """Lazy reference to ``run_agent`` so callers can patch - ``run_agent.OpenAI`` / ``run_agent.cleanup_vm`` / ... and have those - patches reach this code path. - """ + """Lazy ``run_agent`` so ``patch("run_agent.OpenAI")`` & co. reach this code path.""" import run_agent return run_agent @@ -100,30 +84,26 @@ _normalize_route_base_url = normalize_route_base_url def _provider_default_routes(provider: str) -> set[str]: """Return known exact default routes for a canonical provider id.""" routes: set[str] = set() + + def add(value): + route = _normalize_route_base_url(value) + if route: + routes.add(route) + try: from hermes_cli.providers import HERMES_OVERLAYS, get_provider overlay = HERMES_OVERLAYS.get(provider) provider_def = get_provider(provider, allow_network=False) - for value in ( - getattr(overlay, "base_url_override", ""), - getattr(provider_def, "base_url", ""), - ): - route = _normalize_route_base_url(value) - if route: - routes.add(route) + add(getattr(overlay, "base_url_override", "")) + add(getattr(provider_def, "base_url", "")) except Exception: pass try: from providers import get_provider_profile - profile = get_provider_profile(provider) - route = _normalize_route_base_url( - getattr(profile, "base_url", "") - ) - if route: - routes.add(route) + add(getattr(get_provider_profile(provider), "base_url", "")) except Exception: pass @@ -133,34 +113,19 @@ def _provider_default_routes(provider: str) -> set[str]: from hermes_cli.providers import normalize_provider as normalize_registry_provider for provider_id, config in PROVIDER_REGISTRY.items(): - canonical_id = normalize_registry_provider( - normalize_model_provider(provider_id) - ) - if canonical_id != provider: - continue - route = _normalize_route_base_url( - getattr(config, "inference_base_url", "") - ) - if route: - routes.add(route) + if normalize_registry_provider(normalize_model_provider(provider_id)) == provider: + add(getattr(config, "inference_base_url", "")) except Exception: pass if provider == "gemini": - routes.update( - f"{route.rstrip('/')}/openai" - for route in list(routes) - ) + routes.update(f"{route.rstrip('/')}/openai" for route in list(routes)) return routes def _context_route_mismatch( - configured_base_url: Any, - active_base_url: Any, - configured_provider: Any, - active_provider: Any, - *, - already_normalized: bool = False, + configured_base_url: Any, active_base_url: Any, configured_provider: Any, active_provider: Any, + *, already_normalized: bool = False, ) -> bool: """Return whether a context pin's configured route differs from runtime.""" if already_normalized: @@ -196,15 +161,12 @@ 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 the same route - # (gateway display paths compare the raw empty model.base_url and must - # not drop model.context_length to family defaults). + # 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). return not (active_provider and configured_provider == active_provider) return bool( - configured_provider - and active_provider - and configured_provider != active_provider + configured_provider and active_provider and configured_provider != active_provider ) @@ -226,22 +188,18 @@ def _build_codex_gpt5_autoraise_notice( ) -> str: """Build the one-time notice shown when Codex gpt-5.x raises compaction. - ``autoraise`` is ``{"model": , "from": , "to": }``. - ``context_length`` is the live-resolved window from the context compressor - (Codex's /models catalog is authoritative and can change server-side, e.g. - the gpt-5.6 family's 272K → 372K → 272K shifts in July 2026), so the banner - reports what this session actually got rather than a hardcoded cap. The - same text is printed inline for CLI users and replayed via - ``status_callback`` for gateway users, so it must be self-contained and + ``autoraise`` is ``{"model", "from", "to"}``. ``context_length`` is the live-resolved + window (Codex's /models catalog is authoritative and shifts server-side), so the banner + reports what this session actually got. The same text is printed for CLI users and + replayed via ``status_callback`` for gateway users, so it must be self-contained and include the exact opt-back-out command. """ model = str(autoraise.get("model") or "gpt-5.4/5.5").strip().lower().rsplit("/", 1)[-1] if isinstance(context_length, int) and context_length > 0: cap = f"{round(context_length / 1000)}K" else: - # Static fallback when the resolved window isn't available: - # gpt-5.3-codex-spark has a native 128K window; the gpt-5.4/5.5/5.6 - # family is capped at 272K by the Codex OAuth backend. + # Static fallback: gpt-5.3-codex-spark has a native 128K window; the + # gpt-5.4/5.5/5.6 family is capped at 272K by the Codex OAuth backend. cap = "128K" if model.startswith("gpt-5.3-codex-spark") else "272K" from_pct = int(round(autoraise["from"] * 100)) to_pct = int(round(autoraise["to"] * 100)) @@ -254,57 +212,35 @@ def _build_codex_gpt5_autoraise_notice( def _resolve_compression_threshold( - global_threshold: float, - model_cthresh: Optional[float], - *, - model: Optional[str] = None, + global_threshold: float, model_cthresh: Optional[float], *, model: Optional[str] = None, is_codex_autoraise: bool, ) -> tuple[float, Optional[Dict[str, Any]]]: """Combine the user's global compaction threshold with a per-model override. - Returns ``(effective_threshold, autoraise_notice)``. ``autoraise_notice`` is - ``{"model": , "from": , "to": }`` only when a Codex - autoraise (gpt-5.4/5.5 272K family or gpt-5.3-codex-spark) actually raises - the threshold, otherwise ``None``. - - The Codex overrides are *autoraises*: they must never LOWER a higher - user-configured threshold. A user who already set ``compression.threshold`` - above the raised value deliberately keeps more raw context, and silently - dropping them would both waste usable window and contradict the feature's - purpose (use more of the window). Other overrides (e.g. Arcee Trinity) - keep their existing unconditional behaviour. + Returns ``(effective_threshold, autoraise_notice)``; the notice is + ``{"model", "from", "to"}`` only when a Codex autoraise actually RAISES the threshold. + Codex overrides never LOWER a higher user-configured threshold (the user deliberately + keeps more raw context); other overrides (e.g. Arcee Trinity) stay unconditional. """ if model_cthresh is None: return global_threshold, None if is_codex_autoraise: if model_cthresh <= global_threshold + 1e-9: - # Autoraise never lowers; keep the user's higher/equal threshold. return global_threshold, None - return model_cthresh, { - "model": model, - "from": global_threshold, - "to": model_cthresh, - } + return model_cthresh, {"model": model, "from": global_threshold, "to": model_cthresh} return model_cthresh, None def _codex_gpt55_autoraise_notice_marker(): - """Path to the per-profile marker recording that the autoraise notice ran. - - Lives under ``$HERMES_HOME`` (which is profile-scoped) alongside the other - internal markers like ``.container-mode`` — so it is not a user-facing config - key, and every profile tracks its own notice state independently. - """ + """Per-profile marker path (``$HERMES_HOME`` is profile-scoped; not a config key).""" return get_hermes_home() / ".codex_gpt55_autoraise_notice" def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str: - """Stable identity for one autoraise notice, keyed on what it displays. + """Notice identity keyed on what it displays (model + from→to percentages). - Uses the model slug plus the same from→to percentages the notice text - shows, so an unchanged threshold stays silent across restarts while a - later change (the user edits their global ``threshold``, or switches to a - different autoraised Codex model) re-notifies once. + An unchanged threshold stays silent across restarts; a changed global threshold or a + different autoraised Codex model re-notifies once. """ model = str(autoraise.get("model") or "").strip().lower().rsplit("/", 1)[-1] from_pct = int(round(float(autoraise["from"]) * 100)) @@ -313,11 +249,7 @@ def _codex_gpt55_autoraise_notice_state(autoraise: Dict[str, Any]) -> str: def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool: - """True if this exact autoraise notice was already shown for this profile. - - A missing/unreadable marker (or one recording a different threshold) reads - as unseen, so the notice shows. - """ + """True if this exact notice was already shown for this profile (unreadable = unseen).""" try: current = _codex_gpt55_autoraise_notice_state(autoraise) return _codex_gpt55_autoraise_notice_marker().read_text( @@ -328,17 +260,11 @@ def _codex_gpt55_autoraise_notice_seen(autoraise: Dict[str, Any]) -> bool: def _record_codex_gpt55_autoraise_notice(autoraise: Dict[str, Any]) -> None: - """Persist that the autoraise notice was shown for this profile/config state. - - Best-effort: a read-only or missing ``$HERMES_HOME`` just means the notice - may show again next init, which is preferable to breaking agent init. - """ + """Persist that the notice was shown. Best-effort: a failure only re-shows it later.""" try: marker = _codex_gpt55_autoraise_notice_marker() marker.parent.mkdir(parents=True, exist_ok=True) - marker.write_text( - _codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8" - ) + marker.write_text(_codex_gpt55_autoraise_notice_state(autoraise), encoding="utf-8") except (OSError, KeyError, TypeError, ValueError): pass @@ -351,10 +277,9 @@ 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 (`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. + # Multi-model entries (`providers..models` mapping / legacy `models:` list): + # matching ANY catalog entry counts, else a provider whose `model` differs from the + # session model drops its extra_body (e.g. OpenAI service_tier) → wrong billing tier. models = entry.get("models") catalog: List[str] = [] if isinstance(models, dict): @@ -370,11 +295,7 @@ def _custom_provider_model_matches(agent_model: str, entry: Dict[str, Any]) -> b def _custom_provider_extra_body_for_agent( - *, - provider: str, - model: str, - base_url: str, - custom_providers: List[Dict[str, Any]], + *, provider: str, model: str, base_url: str, custom_providers: List[Dict[str, Any]] ) -> Optional[Dict[str, Any]]: provider_norm = (provider or "").strip().lower() if provider_norm == "custom": @@ -416,9 +337,7 @@ def _custom_provider_extra_body_for_agent( def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, Any]]) -> None: extra_body = _custom_provider_extra_body_for_agent( - provider=agent.provider, - model=agent.model, - base_url=agent.base_url, + provider=agent.provider, model=agent.model, base_url=agent.base_url, custom_providers=custom_providers, ) if not extra_body: @@ -434,13 +353,7 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An def _normalize_run_budget_seconds(value) -> Optional[float]: - """Normalize a wall-clock run budget value to a positive float or None. - - None / absent / non-numeric / non-positive all resolve to ``None`` - (feature off) so a malformed config value can never activate the - deadline machinery, only leave it dormant. ``bool`` is rejected because - YAML ``true`` would otherwise become a 1-second budget. - """ + """Positive float or None (feature off). ``bool`` rejected: YAML ``true`` → 1s budget.""" if value is None or isinstance(value, bool): return None try: @@ -457,13 +370,10 @@ def _refuse_checkpoint_required_on_codex_app_server( ) -> None: """Fail closed at init when the checkpoint gate cannot be honored. - The codex app-server owns its thread and compacts it without a truthful - pre-compaction transcript boundary (in "native" auto-compaction mode — - the default — Hermes never even initiates the compaction), so no - pre-compress checkpoint can be guaranteed on this API mode. Refusing here - keeps a turn from ever reaching a codex-owned compaction boundary; the - compress_context() guard alone cannot cover native turns that bypass - Hermes compression entirely. + The codex app-server compacts its own thread without a truthful pre-compaction + transcript boundary (in default "native" mode Hermes never initiates it), so refusing + here keeps a turn from ever reaching a codex-owned compaction boundary — the + compress_context() guard alone cannot cover native turns. """ if checkpoint_required and api_mode == "codex_app_server": raise RuntimeError( @@ -477,12 +387,7 @@ 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``. - """ + """Strict int coercion: rejects bool (YAML ``true`` → 1) and fractional floats.""" if isinstance(raw, bool): return default if isinstance(raw, int): @@ -500,6 +405,12 @@ def _cfg_flag(cfg: Dict[str, Any], key: str, default: bool) -> bool: return str(cfg.get(key, default)).lower() in {"true", "1", "yes"} +def _cfg_dict(cfg: Dict[str, Any], key: str) -> Dict[str, Any]: + """``cfg[key]`` if it is a mapping, else ``{}`` (malformed sections are ignored).""" + section = cfg.get(key, {}) + return section if isinstance(section, dict) else {} + + @dataclass class CompressionSettings: """Parsed ``compression`` config section (see ``_parse_compression_config``).""" @@ -529,87 +440,78 @@ class CompressionSettings: idle_compact_after_seconds: Any +_EXPLICIT_API_MODES = { + "chat_completions", "codex_responses", "anthropic_messages", "bedrock_converse", + "codex_app_server", +} + + 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"}: + """Set ``agent.api_mode`` (and provider rewrites) — ordered ladder, first match wins.""" + host, url = agent._base_url_hostname, agent._base_url_lower + if api_mode in _EXPLICIT_API_MODES: 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 - ): + elif provider_name is None and host == "chatgpt.com" and "/backend-api/codex" in url: agent.api_mode = "codex_responses" agent.provider = "openai-codex" - elif (provider_name is None) and agent._base_url_hostname == "api.x.ai": + elif provider_name is None and host == "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"): + elif agent.provider == "anthropic" or (provider_name is None and host == "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. + elif url.rstrip("/").endswith("/anthropic"): + # Third-party Anthropic-compatible endpoints (MiniMax, DashScope) end in /anthropic. 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") + host.startswith("bedrock-runtime.") and base_url_host_matches(url, "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. + # Portal is dual-wire: anthropic/* → Messages, everything else → chat_completions. + # 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. + # Host-mandated wire check — LAST, so the provider-slug rewrites above always win. + # Covers api.meta.ai → codex_responses (prompt caching: 0% on chat vs 93-99%). + # URL-driven, not provider-name-driven: `providers.meta` may point anywhere. 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" + agent.api_mode = _mandated if _mandated is not None else "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. + # 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, + 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. + # Eagerly warm the transport cache so import errors surface at init, not + # mid-conversation. Non-fatal — transport may not exist for all modes yet. try: agent._get_transport() except Exception: - pass # Non-fatal — transport may not exist for all modes yet + pass try: from hermes_cli.model_normalize import ( - _AGGREGATOR_PROVIDERS, - normalize_model_for_provider, + _AGGREGATOR_PROVIDERS, normalize_model_for_provider ) if agent.provider not in _AGGREGATOR_PROVIDERS: @@ -617,60 +519,50 @@ def _finalize_routing(agent, api_mode, credential_pool): 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. + # Auto-upgrade to Responses for GPT-5.x-style models and direct OpenAI URLs, unless: + # api_mode was explicit, the runtime is ACP (`acp://` — ACP clients route themselves + # and lack the Responses surface), or the URL is Azure OpenAI (gpt-5.x on + # /chat/completions only). Provider exceptions live in + # _provider_model_requires_responses_api. + _base_lower = str(agent.base_url or "").lower() 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 _base_lower.startswith(("acp://", "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, - ) + 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__. + # Invalidate the eager-warmed transport cache — api_mode changed after the warm. 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". + # 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. 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", + 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) + # 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 + # Interrupt mechanism for breaking out of tool loops. Hard cancellation is separate + # from redirect/message state; a thread-safe Event makes the cause atomic for pollers. 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 @@ -678,54 +570,49 @@ def _init_control_state(agent): 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). + # /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, 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. + # Active-turn redirect: unlike a hard /stop, preserve the valid turn prefix, cancel + # only the in-flight request and rebuild its tail with the correction. Drained 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. + # Concurrent-tool worker tids: `_set_interrupt` on `_execution_thread_id` alone doesn't + # reach ThreadPoolExecutor workers, so interrupt()/clear_interrupt() fan out to these. 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. + # Background memory/skill review state (agent/background_review.py). The run is + # installed before the worker starts and fences its first provider-capable phase; the + # direct agent pointer keeps interrupt propagation available once the fork exists. 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. + # 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``. 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(). + # 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 — OAuth plans billing cache + # writes, or proxies adding 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 @@ -746,124 +633,102 @@ def _init_prompt_cache_config(agent): 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. + # Iteration budget: notify the LLM only on actual exhaustion (ONE message, one grace + # call, then a forced summarise request). Intermediate pressure warnings 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. + # Wall-clock run budget (seconds per run_conversation turn). Explicit constructor arg + # wins; else resolved from config.yaml (agent.run_budget_seconds) in + # _apply_agent_section. None = fully off: no clock reads, no injection, no 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. + # 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. + # Activity tracking — updated on each API call, tool execution, and stream chunk. Read + # by the gateway timeout handler and the "still working" notifications. 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). + # Default 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). + # Rate-limit durable SessionDB activity stamps from _touch_activity. 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. + # Opt-out for the between-turns MCP tool refresh (build_turn_context). Set on internal + # forks (background_review) that must keep ``tools[]`` byte-identical for 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. + # Registry generation the tool snapshot was derived from: lets a late/concurrent + # refresh reject a stale rebuild instead of clobbering a newer one (set in _load_tools). 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 + # Rate limit tracking from x-ratelimit-* response headers; read by /usage. + agent._rate_limit_state = 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. + # Credits tracking (dev-only, behind HERMES_DEV_CREDITS) from x-nous-credits-* headers. + # Session-start remaining is latched the first time a header is seen so cumulative + # micros spent can be reported. Threshold-notice latch: sticky-notice keys + gates. 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. + # OpenRouter response cache hits (X-OpenRouter-Cache-Status: HIT in stream 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. + # agent.log (INFO+) and errors.log (WARNING+) under ~/.hermes/logs/. Idempotent, so + # gateway mode (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. + # Quiet mode deliberately does NOT raise per-logger levels: that would starve the root + # file handlers (isEnabledFor() is checked before propagation). setup_logging() + # installs no console handler in quiet mode; 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. + # Internal stream callback (streaming TTS); set here so _vprint can reference it early. 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. + # Deferred paragraph break — set after tool iterations so one "\n\n" precedes 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. + # Stateful scrubbers for / thinking spans split across stream deltas: + # per-delta regexes can't survive chunk boundaries (both tags needed 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. + # Visible assistant text already delivered via live token callbacks this response — + # avoids re-sending commentary the provider later returns as a completed interim. 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. + # Completed interim messages delivered this user turn; spans Codex continuation/tool + # calls so repeated commentary is not re-sent before normalization dedups 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. + # 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. 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). + # Current-turn user-message override when the API-facing message intentionally differs + # from the persisted transcript (e.g. CLI voice mode's temporary prefix). 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. + # Anthropic image-to-text fallbacks cached per image payload/URL so one tool loop + # doesn't repeatedly re-run auxiliary vision on the same image history. agent._anthropic_image_fallback_cache: Dict[str, str] = {} @@ -873,70 +738,67 @@ def _bedrock_region_from_url(base_url) -> str: return m.group(1) if m else "us-east-1" +def _print_key_banner(key, label: str, warn_missing: bool = False) -> None: + """Masked credential line. ``key`` may be a callable Entra ID bearer provider (Azure + Foundry) — never invoke or inspect it. Keys ≤ 12 chars (incl. "dummy-key") are not shown.""" + from agent.azure_identity_adapter import is_token_provider + + if is_token_provider(key): + print("🔑 Using credentials: Microsoft Entra ID") + elif isinstance(key, str) and len(key) > 12: + print(f"🔑 Using {label}: {key[:8]}...{key[-4:]}") + elif warn_missing: + print("⚠️ Warning: API key appears invalid or missing") + + 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: + agent.client = None + agent._client_kwargs = {} + agent._anthropic_base_url = base_url + if agent.provider == "bedrock": + # AnthropicBedrock SDK for full feature parity (prompt caching, thinking budgets). 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 "") + return + # Only fall back to ANTHROPIC_TOKEN when the provider is actually Anthropic. Other + # anthropic_messages providers (MiniMax, Alibaba, …) must use their own key — falling + # back would send Anthropic credentials to third-party endpoints. + _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, - ) + # 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). Cost: one file read 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:]}") + agent.api_key = effective_key + agent._anthropic_api_key = effective_key + # OAuth only for native Anthropic: third-party anthropic_messages providers 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) + if not agent.quiet_mode: + print(f"🤖 AI Agent initialized with model: {agent.model} (Anthropic native)") + _print_key_banner(effective_key, "token") def _init_moa_client(agent, api_key): @@ -944,12 +806,10 @@ def _init_moa_client(agent, api_key): 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. + # 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; + # 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" @@ -987,44 +847,32 @@ def _init_bedrock_client(agent, base_url): 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) + client_kwargs = {"api_key": api_key, "base_url": 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} + client_kwargs["base_url"] = urlunparse(_parsed_url._replace(query="")) + client_kwargs["default_query"] = {k: v[0] for k, v in parse_qs(_parsed_url.query).items()} 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 ". + # OpenCode Zen free tier (*-free slugs): the 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, + 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) + _headers_for = _host_default_headers_factory(base_url) if _headers_for is not None: - client_kwargs["default_headers"] = _headers_for(api_key, effective_base) + client_kwargs["default_headers"] = _headers_for(api_key, base_url) 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). + # Fall back to profile.default_headers for providers that declare custom headers + # (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) @@ -1038,73 +886,59 @@ def _explicit_client_kwargs(agent, api_key, base_url, _provider_timeout) -> Dict 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. + 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`." + return _client_kwargs_from_routed(_routed_client, _provider_timeout) + # 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() + 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, ) - 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 + 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 + return _client_kwargs_from_routed(_fb_client, _provider_timeout) + if _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). + _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`." + ) + raise RuntimeError( + "No LLM provider configured. Run `hermes model` to " + "select a provider, or run `hermes setup` for first-time " + "configuration." + ) + + +_FINE_GRAINED_BETA = "fine-grained-tool-streaming-2025-05-14" def _init_openai_client(agent, api_key, base_url, fallback_model, _provider_timeout): @@ -1115,61 +949,41 @@ def _init_openai_client(agent, api_key, base_url, fallback_model, _provider_time 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, - ) + 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. + # Fine-grained tool streaming for Claude on OpenRouter: without the beta header + # Anthropic buffers the whole tool call and OpenRouter's proxy times out. _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 + if _FINE_GRAINED_BETA not in existing_beta: + headers["x-anthropic-beta"] = ",".join(filter(None, (existing_beta, _FINE_GRAINED_BETA))) 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. + # model.default_headers (config.yaml) override provider/SDK defaults (WAFs that reject + # the SDK's identifying headers). Mutates agent._client_kwargs — this same dict. 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, + 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_entries = get_compatible_custom_providers(load_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, - ) + 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, - ) + # custom_providers[].extra_headers). 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) @@ -1184,26 +998,16 @@ def _init_openai_client(agent, api_key, base_url, fallback_model, _provider_time 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") + _print_key_banner(client_kwargs.get("api_key", "none"), "API key", warn_missing=True) 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). + # 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) @@ -1217,44 +1021,34 @@ def _build_client(agent, api_key, base_url, fallback_model): _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 _lazy_headers(module: str, name: str, pass_key: bool = False, pass_base: bool = False): + """Header factory ``(api_key, base_url) -> dict`` importing ``module.name`` at call time. + + ``pass_key`` forwards ``(key, base_url=base)`` (Codex Cloudflare headers); ``pass_base`` + forwards ``(base)`` (NVIDIA NIM); neither forwards nothing. + """ + def factory(key, base): + import importlib + fn = getattr(importlib.import_module(module), name) + if pass_key: + return fn(key, base_url=base) + return fn(base) if pass_base else fn() + return factory -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 factory 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.*`` 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), + ("openrouter.ai", _lazy_headers("agent.auxiliary_client", "build_or_headers")), + ("integrate.api.nvidia.com", + _lazy_headers("agent.auxiliary_client", "build_nvidia_nim_headers", pass_base=True)), ("api.routermint.com", lambda _k, _b: _ra()._routermint_headers()), - ("githubcopilot.com", _copilot_headers), + ("githubcopilot.com", _lazy_headers("hermes_cli.models", "copilot_default_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), + ("chatgpt.com", _lazy_headers("agent.codex_headers", "codex_cloudflare_headers", pass_key=True)), + ("x.ai", _lazy_headers("tools.xai_http", "hermes_xai_default_headers")), ] @@ -1268,9 +1062,9 @@ def _host_default_headers_factory(base_url: str): 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. + 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: @@ -1287,28 +1081,24 @@ def _client_kwargs_from_routed(client, timeout) -> Dict[str, Any]: 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 [] + if isinstance(fallback_model, dict): + fallback_model = [fallback_model] + if not isinstance(fallback_model, list): + return [] + return [ + f for f in fallback_model if isinstance(f, dict) and f.get("provider") and f.get("model") + ] 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. + # Stable identity for the pool entry that supplied this runtime: OAuth refreshes can + # replace the token before a failed request is recovered, so the mutable API-key value + # alone cannot 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. + + # Provider fallback chain — ordered backups tried when the primary is exhausted + # (rate-limit, overload, connection failure). Legacy single-dict or list format. agent._fallback_chain = _fallback_entries(fallback_model) agent._fallback_index = 0 agent._fallback_activated = getattr(agent, "_fallback_activated", False) @@ -1324,9 +1114,8 @@ def _init_fallback_chain(agent, fallback_model): 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. + # A multiplexed gateway may enter a different HERMES_HOME after ``model_tools`` was first + # imported; ensure that profile's plugin manager has discovered its registrations first. try: from hermes_cli.plugins import discover_plugins @@ -1334,28 +1123,24 @@ def _load_tools(agent, enabled_toolsets, disabled_toolsets): 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). + # Capture the registry generation 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, + 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: @@ -1364,31 +1149,25 @@ def _load_tools(agent, enabled_toolsets, disabled_toolsets): 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. + # HERMES_KANBAN_TASK is set); resolve the ~835-token block once, not per 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: + if agent.quiet_mode: + return + if agent.tools: 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: + if agent.save_trajectories: print("📝 Trajectory saving enabled") - - # Show ephemeral system prompt status - if agent.ephemeral_system_prompt and not agent.quiet_mode: + if agent.ephemeral_system_prompt: 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_prompt_caching: if agent._use_native_cache_layout and agent.provider == "anthropic": source = "native Anthropic" elif agent._use_native_cache_layout: @@ -1398,26 +1177,18 @@ def _load_tools(agent, enabled_toolsets, disabled_toolsets): 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]}" - ) +def _publish_session_id(session_id: str) -> None: + """Expose the session ID to tools (terminal, execute_code) via ContextVar + os.environ. - # 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. + Both are kept in sync because different tool paths still read both. If the ContextVar + bridge fails to import, keep the root-agent legacy env fallback but never let delegated + construction publish a child ID process-wide. + """ try: from gateway.session_context import set_current_session_id - set_current_session_id(agent.session_id) + set_current_session_id(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 @@ -1425,16 +1196,21 @@ def _init_session_state(agent, session_id, session_db, parent_session_id, reason except Exception: delegated_child = False if not delegated_child: - os.environ["HERMES_SESSION_ID"] = agent.session_id + os.environ["HERMES_SESSION_ID"] = session_id - # Session logs go into ~/.hermes/sessions/ alongside gateway sessions - hermes_home = get_hermes_home() - agent.logs_dir = hermes_home / "sessions" + +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): + 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]}" + ) + _publish_session_id(agent.session_id) + + # ~/.hermes/sessions/ — kept unconditionally for request_dump_*.json debug breadcrumbs. + agent.logs_dir = get_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. + # Per-session JSON snapshot is opt-in (sessions.write_json_snapshots); state.db is canonical. agent._session_json_enabled = False try: from hermes_cli.config import load_config_readonly as _load_sess_cfg @@ -1442,100 +1218,75 @@ def _init_session_state(agent, session_id, session_db, parent_session_id, reason 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. + # Responses encrypted-reasoning replay: routes that 400 with ``invalid_encrypted_content`` + # make the loop disable it for the session (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 + # Cached system prompt (built once, rebuilt on compression) + its cross-session-stable + # prefix, kept separately only to place an early cache marker. 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, + 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) + + # SQLite session store (optional; CLI/gateway-provided). _owns_session_db False: a + # caller-supplied handle is usually the SHARED launch handle; DEDICATED handles set True. 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. + # Close flush and turn-start flush can overlap; the durable marker lives on each message + # dict, so its test-and-append is serialized per agent. 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. + # CLI's just-accepted user dict, reused by turn setup so its durable marker survives a + # close-persistence race. agent._pending_cli_user_message = None - agent._last_flushed_db_idx = 0 # tracks DB-write cursor to prevent duplicate writes + agent._last_flushed_db_idx = 0 # DB-write cursor (prevents 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. + # False on helper agents (compression / hygiene / review forks) that hand the session to + # a continuation row that must stay open. 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. + # True on the background review fork: never persist, so its harness turn can't hijack + # the live session. 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. + # Process-scoped --yolo is persisted so `hermes --resume` restores the bypass + # (SessionDB.session_yolo_enabled); session-scoped /yolo toggles persist separately. 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)) + # display.show_commentary (default true): Codex phase=commentary messages go to the + # interim message path; false routes them to the reasoning channel. + agent.show_commentary = bool(_cfg_dict(_agent_cfg, "display").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 = {} + # 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 = _cfg_dict(_agent_cfg, "model") 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"}: @@ -1546,11 +1297,10 @@ def _apply_display_config(agent, _agent_cfg, platform): _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``. + # API-transport streaming (``model.streaming``, default true). Some self-hosted backends + # have broken streaming tool-call paths, 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"}: @@ -1564,23 +1314,21 @@ def _apply_display_config(agent, _agent_cfg, platform): try: agent._tool_guardrails = ToolCallGuardrailController( ToolCallGuardrailConfig.from_mapping( - _agent_cfg.get("tool_loop_guardrails", {}), - platform=platform, + _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. + # Only the derived auxiliary compression context override is cached (needed by the + # startup feasibility check) — no broad pseudo-public config object on the agent. 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. + 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, @@ -1599,8 +1347,8 @@ def _memory_provider_init_kwargs(agent, platform) -> Dict[str, Any]: kwargs["session_title"] = _st except Exception: pass - # Gateway user/chat identity for per-user scoping (gateway_session_key: - # stable per-chat Honcho session isolation). + # 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", @@ -1619,26 +1367,24 @@ def _memory_provider_init_kwargs(agent, platform) -> Dict[str, Any]: def _init_memory(agent, _agent_cfg, skip_memory, platform): - # Persistent memory (MEMORY.md + USER.md) -- loaded from disk + # 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 [] + # 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. _memory_toolset_requested = ( - "memory" in _enabled_toolsets and "memory" not in _disabled_toolsets + "memory" in (agent.enabled_toolsets or []) + and "memory" not in (agent.disabled_toolsets or []) ) if not skip_memory or _memory_toolset_requested: try: from tools.memory_tool import ( - get_builtin_memory_config, - get_builtin_memory_store_flags, + get_builtin_memory_config, get_builtin_memory_store_flags ) mem_config = get_builtin_memory_config(_agent_cfg) @@ -1656,12 +1402,10 @@ def _init_memory(agent, _agent_cfg, skip_memory, platform): ) agent._memory_store.load_from_disk() except Exception: - pass # Memory is optional -- don't break agent init - + 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. + # Memory provider plugin (external — one at a time, alongside built-in), selected by + # memory.provider. agent._memory_manager = None if not skip_memory: try: @@ -1675,8 +1419,7 @@ def _init_memory(agent, _agent_cfg, skip_memory, platform): 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). + # unavailable_reason() reads config/probes importlib — skip it once warned. try: _unavailable_reason = _mp.unavailable_reason() except Exception: @@ -1702,62 +1445,45 @@ 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)) + agent._skill_nudge_interval = int(_agent_cfg.get("skills", {}).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_section = _cfg_dict(_agent_cfg, "agent") + # Tool-use enforcement: "auto" (default — hardcoded model list), true, false, or list of + # substrings. Execution-discipline guidance: same shape against EXECUTION_GUIDANCE_MODELS, + # independent of enforcement (injection gate in agent/system_prompt.py). 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). + # Wall-clock run budget from config — only when the constructor arg was not given. 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. + # Empty-response retry guard (``agent.empty_response_guard``): tolerant resolution — a + # malformed section falls back to schema defaults (guard on, $0.25 threshold). from agent.empty_response_guard import resolve_guard_settings ( - agent._empty_guard_enabled, - agent._empty_guard_cost_threshold_usd, + 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. + # Intent-ack continuation: "auto" (default — codex_responses only), true (all api_modes), + # false, or a list of model-name substrings; resolved in the 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. + # Runtime anti-stall guards (identical-call loop-breaker notice + continue-intent + # extension of empty-response recovery). 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). + # Universal guidance toggles (ALL models, unlike enforcement): task-completion, + # parallel-tool-call batching, and the local Python toolchain probe. 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. + # Warm the probe off-thread (~0.5s of subprocesses) 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 @@ -1765,48 +1491,45 @@ def _apply_agent_section(agent, _agent_cfg): 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. + # Bot Mode teammate protocol section (tools/bot_mode_probe.py) — pure filesystem reads. 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. + # 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. 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 + # Per-platform prompt-hint overrides (platform_hints: : {append|replace}), + # stored verbatim; resolved in agent/system_prompt.py. Invalid shapes are ignored. + agent._platform_hint_overrides = _cfg_dict(_agent_cfg, "platform_hints") - # App-level API retry count (wraps each model API call). Default 3, - # overridable via agent.api_max_retries in config.yaml. See #11616. + # App-level API retry count (wraps each model API call). Default 3; 1 = single attempt. 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) + _api_retries = max(int(_agent_section.get("api_max_retries", 3)), 1) 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) +def _positive_int(raw: Any, *, reject: tuple = ()) -> Optional[int]: + """``int(raw)`` when positive, else None. ``reject`` lists types refused outright (bool, float).""" + if reject and isinstance(raw, reject): + return None + try: + parsed = int(raw) + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _compression_threshold(agent, cfg: Dict[str, Any]) -> tuple[float, bool]: + """Global threshold merged with the per-model override; stashes the autoraise notice. + + Codex gpt-5.4/5.5 raise to 85% (backend caps at 272K, so 50% would compact at ~136K). + The opt-out flag restores the global threshold; when the raise fires a one-time notice + is stashed on the agent for the first turn, with its own display gate. + """ + threshold = float(cfg.get("threshold", 0.50)) + autoraise = _cfg_flag(cfg, "codex_gpt55_autoraise", True) + notice_enabled = _cfg_flag(cfg, "codex_gpt55_autoraise_notice", True) agent._compression_threshold_autoraised = None try: from agent.auxiliary_client import ( @@ -1815,191 +1538,128 @@ def _parse_compression_config(agent, _agent_cfg): _is_codex_spark as _is_codex_spark_fn, ) _model_cthresh = _cthresh_fn( - agent.model, - agent.provider, - allow_codex_gpt55_autoraise=_codex_gpt55_autoraise, + agent.model, agent.provider, allow_codex_gpt55_autoraise=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) - ), - ) + # Codex autoraises apply only when they RAISE; Arcee Trinity keeps its + # unconditional override. + threshold, agent._compression_threshold_autoraised = _resolve_compression_threshold( + 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) + return threshold, notice_enabled - # 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"}: + +def _compression_codex_settings(cfg: Dict[str, Any]) -> tuple[str, bool, Optional[int]]: + """``codex_app_server_auto`` / ``codex_responses_native`` / ``codex_responses_compact_threshold``.""" + app_server_auto = str(cfg.get("codex_app_server_auto", "native") or "native").lower() + if app_server_auto 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, + app_server_auto, ) - 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): + app_server_auto = "native" + # Native OpenAI Responses server-side compaction (opt-in; per-request gate in + # agent/native_compaction.py). Truthy coercion: "false"/"off" strings stay disabled. + responses_native = is_truthy_value(cfg.get("codex_responses_native", False)) + _raw = cfg.get("codex_responses_compact_threshold") + compact_threshold = None + if _raw is not None: + compact_threshold = _positive_int(_raw, reject=(bool, float)) + if compact_threshold is None: _ra().logger.warning( "Invalid compression.codex_responses_compact_threshold=%r; " "using the automatic threshold derived from local compression.", - _native_threshold_raw, + _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 app_server_auto, responses_native, compact_threshold + + +def _parse_compression_config(agent, _agent_cfg) -> CompressionSettings: + """Parse the ``compression`` section. Defaults here MUST match DEFAULT_CONFIG.""" + cfg = _cfg_dict(_agent_cfg, "compression") + threshold, autoraise_notice_enabled = _compression_threshold(agent, cfg) + # Plain int()/float() coercions raise on garbage; evaluated up front, in config order. + target_ratio = float(cfg.get("target_ratio", 0.20)) + protect_last = int(cfg.get("protect_last_n", 20)) + # max_attempts: retry rounds before "max compression attempts reached"; some sessions + # need >3 (incompressible tool schemas). Default 3, floor 1, cap 10. + max_attempts = _parse_config_int(cfg.get("max_attempts", 3), 3) + if max_attempts < 1: + max_attempts = 3 + # threshold_tokens: absolute cap — compression triggers at the lower of the ratio + # threshold and this count; clamped to the window at apply-time (cap above window = no-op). + threshold_tokens = cfg.get("threshold_tokens") + if threshold_tokens is not None: + threshold_tokens = _positive_int(threshold_tokens) + # Non-system head messages to protect (system prompt is always protected); 0 is a + # legitimate "system prompt + summary + tail". + protect_first = max(0, int(cfg.get("protect_first_n", 3))) + checkpoint_required = is_truthy_value(cfg.get("checkpoint_required"), default=False) + _refuse_checkpoint_required_on_codex_app_server( + checkpoint_required, getattr(agent, "api_mode", None) ) + app_server_auto, responses_native, compact_threshold = _compression_codex_settings(cfg) + # Opt-in idle compaction: compact up front when a session resumes after this many + # seconds idle (0 = disabled). Consumed by build_turn_context(). + idle_compact_after_seconds = max(0, int(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, + threshold=threshold, + autoraise_notice_enabled=autoraise_notice_enabled, + enabled=_cfg_flag(cfg, "enabled", True), + target_ratio=target_ratio, + protect_last=protect_last, + # tail_mode: "lean" (default) keeps a clamped 2.5%/10K-25K verbatim tail — continuity + # rides the summary. "legacy" restores the 0.20*threshold tail (hoards 100-240K on + # big windows). Unknown values fall back to lean inside the compressor. + tail_mode=str(cfg.get("tail_mode", "lean")).strip().lower(), + # Actionable user messages guaranteed to survive in the tail (default 1, floor 1). + min_tail_users=max(1, _parse_config_int(cfg.get("min_tail_user_messages", 1), 1)), + max_attempts=min(max_attempts, 10), + # Opt-in proactive tool-result prune trigger (0 = disabled; negatives = disabled). + proactive_prune_tokens=max(0, _parse_config_int(cfg.get("proactive_prune_tokens", 0), 0)), + proactive_prune_min_chars=_parse_config_int( + cfg.get("proactive_prune_min_result_chars", 8000), 8000 + ), + proactive_prune_min_reclaim=max( + 0, _parse_config_int(cfg.get("proactive_prune_min_reclaim_tokens", 4096), 4096) + ), + protect_first=protect_first, + abort_on_summary_failure=_cfg_flag(cfg, "abort_on_summary_failure", False), + # Per-model threshold overrides: keys substring-matched against the model name + # (longest match wins); {} = global threshold for all models. + model_thresholds={ + str(k): float(v) for k, v in _cfg_dict(cfg, "model_thresholds").items() + if isinstance(v, (int, float)) and not isinstance(v, bool) + }, + threshold_tokens=threshold_tokens, + checkpoint_required=checkpoint_required, + # In-place compaction rewrites messages + system prompt WITHOUT rotating the session + # id. default=True MUST match DEFAULT_CONFIG — a False default flipped agents into + # rotation mode whenever the merged config omitted the key. + in_place=is_truthy_value(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. + micro_compact=is_truthy_value(cfg.get("micro_compact"), default=False), + # Pass cadence in completed turns; each pass costs one prompt-cache break (>= 1). + micro_compact_every_n_turns=max( + 1, _parse_config_int(cfg.get("micro_compact_every_n_turns", 1), 1) + ), + # Rolling-summary defrag threshold, in tokens. + micro_compact_defrag_tokens=max( + 1, _parse_config_int(cfg.get("micro_compact_defrag_threshold_tokens", 2000), 2000) + ), + codex_app_server_auto=app_server_auto, + codex_responses_native=responses_native, + codex_responses_compact_threshold=compact_threshold, + idle_compact_after_seconds=idle_compact_after_seconds, ) @@ -2008,8 +1668,8 @@ def _warn_invalid_config_int( ) -> 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. + ``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, @@ -2029,81 +1689,97 @@ def _custom_provider_configured_base_url( ``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 - ) + _wanted = _normalize_custom_provider_name(_configured_provider) _user_providers = _agent_cfg.get("providers") - _disabled_custom_provider_ids: set[str] = set() + _disabled_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): + for _key, _entry in _user_providers.items(): + if not isinstance(_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 + _ids = _custom_provider_runtime_ids(_key) | _custom_provider_runtime_ids(_entry.get("name")) + if not is_provider_enabled(_entry): + _disabled_ids.update(_ids) + continue + if _wanted in _ids: + _url = _normalize_route_base_url( + _entry.get("api") or _entry.get("url") or _entry.get("base_url") ) - 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 _url: + return _url + for _entry in _custom_providers: + if not isinstance(_entry, dict): + continue + _key_ids = _custom_provider_runtime_ids(_entry.get("provider_key")) + if _key_ids & _disabled_ids: + continue + if _wanted in _key_ids | _custom_provider_runtime_ids(_entry.get("name")): + _url = _normalize_route_base_url(_entry.get("base_url")) + if _url: + return _url + return "" + + +# Provider ids whose runtime is resolved first-hand (never a named custom provider). +_RUNTIME_FIRST_PROVIDER_IDS = { + "auto", "moa", "vertex", "google-vertex", "vertex-ai", "gcp-vertex", "vertexai", +} + + +def _configured_default_base_url(_agent_cfg, _model_cfg, _custom_providers) -> str: + """Normalized route of the configured default model (``model.base_url``, else the named + custom provider's URL when ``model.provider`` is not a first-class/auth provider).""" + _configured_base_url = _normalize_route_base_url(_model_cfg.get("base_url")) + _configured_provider = str(_model_cfg.get("provider") or "").strip() + _norm = _normalize_custom_provider_name(_configured_provider) + _custom_provider_candidate = bool(_norm) + if _norm in _RUNTIME_FIRST_PROVIDER_IDS: + _custom_provider_candidate = False + elif _custom_provider_candidate and _norm != "custom" and not _norm.startswith("custom:"): + try: + from hermes_cli.auth import resolve_provider as resolve_auth_provider + + _custom_provider_candidate = ( + str(resolve_auth_provider(_norm) or "").strip().lower() != _norm ) - 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 + 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 + ) return _configured_base_url +def _active_route_url(agent, base_url) -> str: + """The runtime route, keeping the requested URL's query string when it is the same route.""" + _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_without_query = urlunparse( + urlparse(_requested_route_url)._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 + return _normalize_route_base_url(_active_route_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. + ``model.context_length`` describes the configured default model. A process launched + with ``--model`` has already replaced ``agent.model`` before config loads, so carrying + the default model's explicit window into that runtime is stale. Live switch/fallback + paths already clear this override; direct-start stays consistent with them and lets + provider metadata resolve the active model's window. """ _default = _model_cfg.get("default") if isinstance(_default, dict): @@ -2119,72 +1795,14 @@ def _scope_context_length_to_default_runtime( _configured_default_runtime_model = normalize_model_for_provider( _configured_default_model, agent.provider ) - _active_runtime_model = normalize_model_for_provider( - agent.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) + _configured_base_url = _configured_default_base_url(_agent_cfg, _model_cfg, _custom_providers) + _active_base_url = _active_route_url(agent, base_url) _route_mismatch = _context_route_mismatch( - _configured_base_url, - _active_base_url, - _configured_provider, - agent.provider, - already_normalized=True, + _configured_base_url, _active_base_url, str(_model_cfg.get("provider") or "").strip(), + agent.provider, already_normalized=True, ) _model_mismatch = bool( _configured_default_runtime_model @@ -2204,50 +1822,57 @@ def _scope_context_length_to_default_runtime( return _config_context_length +_CTX_LEN_REQUIREMENT = "must be a positive integer (e.g. 256000, not '256K')" + + +def _warn_invalid_custom_provider_context_length(agent, _custom_providers) -> None: + """Surface a context_length the helper silently skipped (not a positive int).""" + _target = _normalize_route_base_url(agent.base_url) + if not _target: + return + for _cp_entry in _custom_providers: + if not isinstance(_cp_entry, dict): + continue + if _normalize_route_base_url(_cp_entry.get("base_url")) != _target: + continue + _cp_models = _cp_entry.get("models", {}) + _cp_model_cfg = _cp_models.get(agent.model, {}) if isinstance(_cp_models, dict) else None + _cp_ctx = _cp_model_cfg.get("context_length") if isinstance(_cp_model_cfg, dict) else None + if _cp_ctx is not None and _positive_int(_cp_ctx) is None: + _warn_invalid_config_int( + f"context_length for model {agent.model!r} in custom_providers", + _cp_ctx, _CTX_LEN_REQUIREMENT, "auto-detection", "auto-detected context window", + ) + return + + 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. + # Explicit context_length for the auxiliary compression model: custom endpoints often + # can't report it via /models, so the startup feasibility check needs the 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 + _aux_ctx = _aux_cfg.get("context_length") if isinstance(_aux_cfg, dict) else None + try: + agent._aux_compression_context_length_config = int(_aux_ctx) if _aux_ctx is not None else None + except (TypeError, ValueError): + agent._aux_compression_context_length_config = None - # Read explicit model output-token override from config when the - # caller did not pass one directly. + # model.max_tokens from config when the caller did not pass one. _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): + agent.max_tokens = _positive_int(_config_max_tokens, reject=(bool,)) + if agent.max_tokens is None: _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 + _config_context_length = _model_cfg.get("context_length") if isinstance(_model_cfg, dict) else None if _config_context_length is not None: try: _config_context_length = int(_config_context_length) @@ -2259,9 +1884,8 @@ def _resolve_context_length(agent, _agent_cfg, base_url): ) _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``. + # Resolve custom_providers once before route-scoping a global context pin: a named custom + # provider may keep its base URL only in this list. try: from hermes_cli.config import get_compatible_custom_providers _custom_providers = get_compatible_custom_providers(_agent_cfg) @@ -2270,210 +1894,155 @@ def _resolve_context_length(agent, _agent_cfg, base_url): 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. + # ``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). + # Reused by _check_compression_model_feasibility (aux compression model detection). 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, + 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. + pass 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 + _warn_invalid_custom_provider_context_length(agent, _custom_providers) - # Persist for reuse on switch_model / fallback activation. Must come - # AFTER the custom_providers branch so per-model overrides aren't lost. + # Persist for switch_model / fallback activation — 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 - ) + _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, _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 +def _select_context_engine(_agent_cfg): + """Config-driven context engine: ``context.engine`` → plugins/context_engine// → + general plugin system → None (built-in ContextCompressor).""" + _engine_name = "compressor" try: _engine_name = _agent_cfg.get("context", {}).get("engine", "compressor") or "compressor" except Exception: pass + if _engine_name == "compressor": + return None # built-in; don't auto-activate plugins + _selected_engine = None + _copy_failed = False + 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) - if _engine_name != "compressor": - # Try loading from plugins/context_engine// + if _selected_engine is None: 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: + 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: - 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 + _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, + ) - 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 None and not _copy_failed: + _ra().logger.warning( + "Context engine '%s' not found — falling back to built-in compressor", _engine_name + ) + return _selected_engine + +def _compressor_max_tokens(agent): + """``agent.max_tokens``, or the native-Gemini adapter default when unset. + + With model.max_tokens unset the generateContent adapter still sends maxOutputTokens=65,535 + and the threshold is pct×(window − max_tokens): reserving 0 here let the provider 400 + before compaction fired, so mirror the adapter's default (native Gemini only). + """ + if agent.max_tokens is not None: + return agent.max_tokens + 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): + return GEMINI_DEFAULT_MAX_OUTPUT_TOKENS + except Exception: + pass + return None + + +def _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_context_length, session_db): + _selected_engine = _select_context_engine(_agent_cfg) 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. + # 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, + 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). + # Per-model overrides BEFORE the initial update_model() so the first threshold + # resolution already sees them. 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, + 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, + 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, + max_tokens=_compressor_max_tokens(agent), 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, + 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): @@ -2483,11 +2052,9 @@ def _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_c 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). + # checkpoint_required: micro-compaction is a lossy rewrite with no pre-compress + # checkpoint hook, so suppress it while the gate is armed (mirrors native_compaction.py). if cs.checkpoint_required and cs.micro_compact: logger.warning( "compression.checkpoint_required is enabled: post-turn " @@ -2507,20 +2074,16 @@ def _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_c 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, + 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 - ) + 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). + # Reject windows below the 64K floor needed for reliable tool-calling; an explicit + # positive model.context_length on LM Studio is allowed below the floor. _ctx = getattr(agent.context_compressor, "context_length", 0) _allow_lmstudio_explicit_below_floor = ( str(getattr(agent, "provider", "") or "").strip().lower() == "lmstudio" @@ -2541,57 +2104,49 @@ def _enforce_minimum_context(agent): 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 + # Nous Hermes 3/4 are chat models, not tool-call-tuned. cli.py show_banner() already + # warns on the CLI, so skip platform=="cli"; non-quiet non-CLI surfaces still get it. + if agent.quiet_mode or (agent.platform or "cli") == "cli": + return + 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 + _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. + # Inject context engine tool schemas (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. Gated on enabled_toolsets + # like memory-provider tools so `platform_toolsets: telegram: []` can't 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 - ) + 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) + 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. + # A nameless tool makes strict providers 400 and disables the whole toolset. _ra().logger.warning( "Context engine returned a tool schema with no resolvable " "name; skipping to avoid poisoning the request (%r)", @@ -2601,20 +2156,16 @@ def _inject_context_engine_tools(agent): _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.tools.append({"type": "function", "function": _schema}) 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, + 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), ) @@ -2623,36 +2174,31 @@ def _inject_context_engine_tools(agent): 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). + # Ollama defaults num_ctx to 2048 regardless of 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: + _override = _model_cfg.get("ollama_num_ctx") if isinstance(_model_cfg, dict) else None + if _override is not None: try: - agent._ollama_num_ctx = int(_ollama_num_ctx_override) + agent._ollama_num_ctx = int(_override) except (TypeError, ValueError): - _ra().logger.debug("Invalid ollama_num_ctx config value: %r", _ollama_num_ctx_override) + _ra().logger.debug("Invalid ollama_num_ctx config value: %r", _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 "") + # api_key may be a callable (Entra token provider); detection needs a string. + _key = agent.api_key if isinstance(agent.api_key, str) else "" + _detected = query_ollama_num_ctx(agent.model, agent.base_url, api_key=_key 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. + # Cap auto-detected num_ctx to the explicit context_length (GGUF metadata can advertise + # 256K+ and Ollama would allocate that much VRAM); never override an explicit num_ctx. if ( agent._ollama_num_ctx and _config_context_length - and _ollama_num_ctx_override is None # don't override explicit ollama_num_ctx + and _override is None and agent._ollama_num_ctx > _config_context_length ): _ra().logger.info( @@ -2665,36 +2211,24 @@ def _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length): "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. + # Recalibrate the compressor to the served window: every request runs at num_ctx, so a + # trigger derived from the probed model window could sit above it and never fire. _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 - ): + 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, + 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. + # 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 ( @@ -2704,15 +2238,13 @@ def _emit_compression_summary(agent, cs): 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), + _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) + # Report the active engine's own threshold — for a plugin engine the host + # cs.threshold is not in effect and the percent would contradict the token count. _active_threshold_pct = getattr( agent.context_compressor, "threshold_percent", cs.threshold ) @@ -2723,30 +2255,25 @@ def _emit_compression_summary(agent, cs): 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). + # Printed inline for CLI users; gateway users get the same text replayed via + # _compression_warning on turn 1. 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(). + # Gateway parity: status_callback isn't wired yet, so stash the text to replay on the + # first run_conversation(). Mark shown so repeated inits in this profile stay silent. 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. + # Feasibility check is deferred to the first turn near the threshold (eager costs ~400ms + # cold per init); run_conversation's preflight runs it 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. + # Snapshot primary runtime for per-turn restoration: when fallback activates during a + # turn, the next turn restores these so the preferred model gets a fresh attempt. One + # dict so new state fields are easy to add. _cc = agent.context_compressor agent._primary_runtime = { "model": agent.model, @@ -2760,9 +2287,8 @@ def _snapshot_primary_runtime(agent): "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). + # Context engine state that _try_activate_fallback() overwrites. getattr because + # plugin engines may lack these ContextCompressor-specific attrs. "compressor_model": getattr(_cc, "model", agent.model), "compressor_base_url": getattr(_cc, "base_url", agent.base_url), "compressor_api_key": getattr(_cc, "api_key", ""), @@ -2778,6 +2304,57 @@ def _snapshot_primary_runtime(agent): }) +def _init_usage_state(agent): + from agent.runtime_cwd import scope_terminal_cwd as _scope_terminal_cwd + + agent._subdirectory_hints = SubdirectoryHintTracker(working_dir=_scope_terminal_cwd() or None) + agent._user_turn_count = 0 + # Copilot x-initiator flag: first API call of a user turn sends "user". + agent._is_user_initiated_turn = False + + # Usage-anchored context accounting (agent/model_metadata.py): last provider response's + # exact usage + transcript snapshot. None until the first response with usage; + # invalidated on compaction and session switches so stale anchors never suppress + # compression. + agent._usage_anchor = None + agent._turn_base_usage_anchor = None + + # Cumulative token usage for the session + for _counter in ( + "session_prompt_tokens", "session_completion_tokens", "session_total_tokens", + "session_api_calls", "session_input_tokens", "session_output_tokens", + "session_cache_read_tokens", "session_cache_write_tokens", "session_reasoning_tokens", + ): + setattr(agent, _counter, 0) + agent.session_estimated_cost_usd = 0.0 + agent.session_cost_status = "unknown" + agent.session_cost_source = "none" + # Rolling history for status-bar avg latency / velocity (last 10 calls), shared by + # conversation_loop and codex_runtime and readable by the CLI snapshot without IPC. + from collections import deque as _deque + agent._api_latency_history = _deque(maxlen=10) + agent._api_output_history = _deque(maxlen=10) + + +# Constructor params stored verbatim under the same name. +_PASSTHROUGH_PARAMS = ( + "model", "max_iterations", "save_trajectories", "verbose_logging", "quiet_mode", + "tool_progress_mode", "ephemeral_system_prompt", "platform", "skip_context_files", + "load_soul_identity", "pass_session_id", "log_prefix_chars", + # OpenRouter provider preferences + "providers_allowed", "providers_ignored", "providers_order", "provider_sort", + "provider_require_parameters", "provider_data_collection", "openrouter_min_coding_score", + # Toolset filtering + "enabled_toolsets", "disabled_toolsets", + # Model response configuration (None = provider/model default) + "max_tokens", "reasoning_config", "service_tier", +) +# Gateway identity params stored as ``agent._``. gateway_session_key is the stable +# per-chat key (e.g. agent:main:telegram:dm:123). +_GATEWAY_IDENTITY_PARAMS = ( + "user_id", "user_id_alt", "user_name", "chat_id", "chat_name", "chat_type", "thread_id", + "gateway_session_key", +) _CALLBACK_PARAMS = ( "tool_progress_callback", "tool_start_callback", "tool_complete_callback", "thinking_callback", "reasoning_callback", "clarify_callback", @@ -2890,42 +2467,25 @@ def init_agent( """ _install_safe_stdio() - agent.model = model - agent.max_iterations = max_iterations - # Shared iteration budget — parent creates, children inherit. - # Consumed by every LLM turn across parent + all subagents. + _params = locals() + for _name in _PASSTHROUGH_PARAMS: + setattr(agent, _name, _params[_name]) + for _name in _GATEWAY_IDENTITY_PARAMS: + setattr(agent, f"_{_name}", _params[_name]) + # Shared iteration budget — parent creates, children inherit; consumed by every LLM + # turn across parent + all subagents. agent.iteration_budget = iteration_budget or IterationBudget(max_iterations) - agent.save_trajectories = save_trajectories - agent.verbose_logging = verbose_logging - agent.quiet_mode = quiet_mode - agent.tool_progress_mode = tool_progress_mode - agent.ephemeral_system_prompt = ephemeral_system_prompt - agent.platform = platform # "cli", "telegram", "discord", "whatsapp", etc. - agent._user_id = user_id # Platform user identifier (gateway sessions) - agent._user_id_alt = user_id_alt # Optional stable alternate platform identifier - agent._user_name = user_name - agent._chat_id = chat_id - agent._chat_name = chat_name - agent._chat_type = chat_type - agent._thread_id = thread_id - agent._gateway_session_key = gateway_session_key # Stable per-chat key (e.g. agent:main:telegram:dm:123) - # Pluggable print function — CLI replaces this with _cprint so that - # raw ANSI status lines are routed through prompt_toolkit's renderer - # instead of going directly to stdout where patch_stdout's StdoutProxy - # would mangle the escape sequences. None = use builtins.print. + # Pluggable print function — CLI replaces this with _cprint so raw ANSI status lines go + # through prompt_toolkit's renderer (patch_stdout's StdoutProxy would mangle them). + # None = builtins.print. agent._print_fn = None agent.background_review_callback = None # Optional sync callback for gateway delivery 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: 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). + # 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. agent.skip_background_review = bool(skip_background_review) - agent.pass_session_id = pass_session_id - agent.log_prefix_chars = log_prefix_chars agent.log_prefix = f"{log_prefix} " if log_prefix else "" - # Store effective base URL for feature detection (prompt caching, reasoning, etc.) + # Effective base URL for feature detection (prompt caching, reasoning, etc.) agent.base_url = base_url or "" provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None agent.provider = provider_name or "" @@ -2942,61 +2502,34 @@ def init_agent( agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) _resolve_api_mode(agent, api_mode, provider_name, base_url) - _finalize_routing(agent, api_mode, credential_pool) # 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 _init_control_state(agent) - # Store OpenRouter provider preferences - agent.providers_allowed = providers_allowed - agent.providers_ignored = providers_ignored - agent.providers_order = providers_order - agent.provider_sort = provider_sort - agent.provider_require_parameters = provider_require_parameters - agent.provider_data_collection = provider_data_collection - agent.openrouter_min_coding_score = openrouter_min_coding_score - - # Store toolset filtering options - agent.enabled_toolsets = enabled_toolsets - agent.disabled_toolsets = disabled_toolsets - - # Model response configuration - agent.max_tokens = max_tokens # None = use model default - agent.reasoning_config = reasoning_config # None = use default (medium for OpenRouter) - # Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in). - # Read once at init; switch_model / try_activate_fallback / restore - # keep it in sync with the active provider. + # Per-provider reasoning_content echo opt-in (see _reasoning_echo_opt_in). Read once at + # init; switch_model / try_activate_fallback / restore keep it in sync. agent._reasoning_echo_flag = agent._read_reasoning_echo_from_config() - agent.service_tier = service_tier agent.request_overrides = dict(request_overrides or {}) agent.prefill_messages = prefill_messages or [] # Prefilled conversation turns agent._force_ascii_payload = False - + _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, ) - + # Load config once for memory, skills, and compression sections try: from hermes_cli.config import load_config_readonly as _load_agent_config @@ -3005,69 +2538,20 @@ def init_agent( _agent_cfg = {} _apply_display_config(agent, _agent_cfg, platform) - _init_memory(agent, _agent_cfg, skip_memory, platform) - _apply_agent_section(agent, _agent_cfg) - cs = _parse_compression_config(agent, _agent_cfg) - _config_context_length, _custom_providers, _effective_context_length, _model_cfg = _resolve_context_length( agent, _agent_cfg, base_url ) - - - _build_context_engine(agent, _agent_cfg, cs, _custom_providers, _effective_context_length, session_db) - _enforce_minimum_context(agent) - _warn_nonagentic_hermes_model(agent) - _inject_context_engine_tools(agent) - - from agent.runtime_cwd import scope_terminal_cwd as _scope_terminal_cwd - - agent._subdirectory_hints = SubdirectoryHintTracker( - working_dir=_scope_terminal_cwd() or None, - ) - agent._user_turn_count = 0 - # Copilot x-initiator flag: first API call of a user turn sends "user" (#3040). - agent._is_user_initiated_turn = False - - # Usage-anchored context accounting (agent/model_metadata.py): the last - # main-loop provider response's exact usage + transcript snapshot. None - # until the first response with usage; invalidated on compaction and - # session switches so stale anchors can never suppress compression. - agent._usage_anchor = None - agent._turn_base_usage_anchor = None - - # Cumulative token usage for the session - agent.session_prompt_tokens = 0 - agent.session_completion_tokens = 0 - agent.session_total_tokens = 0 - agent.session_api_calls = 0 - agent.session_input_tokens = 0 - agent.session_output_tokens = 0 - agent.session_cache_read_tokens = 0 - agent.session_cache_write_tokens = 0 - agent.session_reasoning_tokens = 0 - agent.session_estimated_cost_usd = 0.0 - agent.session_cost_status = "unknown" - agent.session_cost_source = "none" - # Rolling history for status-bar avg latency / velocity (last 10 calls). - # Stored on the agent so both conversation_loop and codex_runtime share it - # and the CLI snapshot can read it without extra IPC. - from collections import deque as _deque - agent._api_latency_history = _deque(maxlen=10) - agent._api_output_history = _deque(maxlen=10) - + _init_usage_state(agent) _configure_ollama_num_ctx(agent, _model_cfg, _config_context_length) - _emit_compression_summary(agent, cs) - _snapshot_primary_runtime(agent) - __all__ = ["init_agent"] diff --git a/agent/api_error_summary.py b/agent/api_error_summary.py index 62c3bf15d7..ff57e44d31 100644 --- a/agent/api_error_summary.py +++ b/agent/api_error_summary.py @@ -10,14 +10,46 @@ from typing import Any, Dict, Optional from agent.redact import redact_sensitive_text +# Offline DNS failures are wrapped in a generic "Connection error" by SDKs — inspect the chain. +_NETWORK_RESOLUTION_MARKERS = ( + "temporary failure in name resolution", + "name or service not known", + "nodename nor servname provided, or not known", + "getaddrinfo failed", + "no address associated with hostname", + "network is unreachable", +) +_XAI_ENTITLEMENT_HINT = ( + " — xAI rejected this OAuth account. NOTE: X Premium+ does NOT " + "include xAI API access — only standalone SuperGrok subscribers " + "can use this provider. Other possible causes: no Grok " + "subscription, your tier doesn't include this model, or your " + "quota is exhausted. Check https://grok.com/?_s=usage to see " + "which, or run `/model` to switch providers." +) +_ERROR_DETAIL_KEYS = ("message", "detail", "error", "code", "type") + + +def _is_xai_entitlement_text(lower: str) -> bool: + """xAI's permission-denied body text for an unsubscribed / under-tiered / exhausted account.""" + return ( + "do not have an active grok subscription" in lower + or ("out of available resources" in lower and "grok" in lower) + or ("does not have permission" in lower and "grok" in lower) + ) + + +def _http_prefix(error: Exception) -> str: + status_code = getattr(error, "status_code", None) + return f"HTTP {status_code}: " if status_code else "" + class ApiErrorSummaryMixin: """Provider error -> user/log-safe summary (see module docstring).""" @staticmethod def _is_entitlement_failure( - error_context: Optional[Dict[str, Any]], - status_code: Optional[int], + error_context: Optional[Dict[str, Any]], status_code: Optional[int] ) -> bool: """Detect subscription/entitlement 401/403s that masquerade as auth failures. @@ -31,26 +63,14 @@ class ApiErrorSummaryMixin: if not isinstance(error_context, dict): return False # Single lowercase haystack over every field shape (message/reason and raw code/error). - message = str(error_context.get("message") or "").lower() - reason = str(error_context.get("reason") or "").lower() - code = str(error_context.get("code") or "").lower() - err = str(error_context.get("error") or "").lower() - haystack = f"{message} {reason} {code} {err}" + haystack = " ".join( + str(error_context.get(k) or "").lower() for k in ("message", "reason", "code", "error") + ) if not haystack.strip(): return False - # xAI's disambiguator for stale-token vs unsubscribed: same permission-denied text, only one carries - # this suffix. Bail out so a stale OAuth token takes the credential-refresh path (#29344). - if "[wke=unauthenticated:" in haystack: + if "[wke=unauthenticated:" in haystack or "oauth2 access token could not be validated" in haystack: return False - if "oauth2 access token could not be validated" in haystack: - return False - if "do not have an active grok subscription" in haystack: - return True - if "out of available resources" in haystack and "grok" in haystack: - return True - if "does not have permission" in haystack and "grok" in haystack: - return True - return False + return _is_xai_entitlement_text(haystack) @staticmethod def _decorate_xai_entitlement_error(detail: str) -> str: @@ -58,31 +78,14 @@ class ApiErrorSummaryMixin: xAI's ``/v1/responses`` uses one body for several causes (no subscription, tier lacks the model, quota exhausted). The least obvious: X Premium+ does NOT include API access — only SuperGrok does. Lead with - that, keep the raw text, point at https://grok.com/?_s=usage. Matched once per detail string. + that, keep the raw text, point at https://grok.com/?_s=usage. Idempotent: a substring unique to the + hint marks prior decoration. """ - if not detail: + if not detail or not _is_xai_entitlement_text(detail.lower()): return detail - lower = detail.lower() - is_entitlement = ( - "do not have an active grok subscription" in lower - or ("out of available resources" in lower and "grok" in lower) - or ("does not have permission" in lower and "grok" in lower) - ) - if not is_entitlement: - return detail - hint = ( - " — xAI rejected this OAuth account. NOTE: X Premium+ does NOT " - "include xAI API access — only standalone SuperGrok subscribers " - "can use this provider. Other possible causes: no Grok " - "subscription, your tier doesn't include this model, or your " - "quota is exhausted. Check https://grok.com/?_s=usage to see " - "which, or run `/model` to switch providers." - ) - # Idempotency: detect prior decoration by a substring unique to the - # hint (not present in xAI's own body text). if "X Premium+ does NOT include" in detail: return detail - return f"{detail}{hint}" + return f"{detail}{_XAI_ENTITLEMENT_HINT}" @staticmethod def _coerce_api_error_detail(value: Any) -> str: @@ -90,11 +93,11 @@ class ApiErrorSummaryMixin: if isinstance(value, str): return value if isinstance(value, dict): - for key in ("message", "detail", "error", "code", "type"): + for key in _ERROR_DETAIL_KEYS: nested = value.get(key) if isinstance(nested, str) and nested.strip(): return nested - for key in ("message", "detail", "error", "code", "type"): + for key in _ERROR_DETAIL_KEYS: if key in value: nested_detail = ApiErrorSummaryMixin._coerce_api_error_detail(value[key]) if nested_detail: @@ -104,10 +107,7 @@ class ApiErrorSummaryMixin: except TypeError: return str(value) if isinstance(value, (list, tuple)): - parts = [ - ApiErrorSummaryMixin._coerce_api_error_detail(item) - for item in value - ] + parts = [ApiErrorSummaryMixin._coerce_api_error_detail(item) for item in value] return "; ".join(part for part in parts if part) if value is None: return "" @@ -122,49 +122,30 @@ class ApiErrorSummaryMixin: """ raw = str(error) - # Offline DNS failures are wrapped in a generic "Connection error" by SDKs — inspect the chain. - network_resolution_markers = ( - "temporary failure in name resolution", - "name or service not known", - "nodename nor servname provided, or not known", - "getaddrinfo failed", - "no address associated with hostname", - "network is unreachable", - ) current: Optional[BaseException] = error seen: set[int] = set() while current is not None and id(current) not in seen: seen.add(id(current)) - if any( - marker in str(current).lower() - for marker in network_resolution_markers - ): + if any(marker in str(current).lower() for marker in _NETWORK_RESOLUTION_MARKERS): return ( "Hermes can't reach the model provider. You may be offline. " "Check your internet connection and try again." ) current = current.__cause__ or current.__context__ - if ( - isinstance(error, ValueError) - and "expected ident at line" in raw.lower() - ): + if isinstance(error, ValueError) and "expected ident at line" in raw.lower(): return f"Malformed provider streaming response: {raw[:300]}" - # Cloudflare / proxy HTML pages: grab the for a clean summary + prefix = _http_prefix(error) + # Cloudflare / proxy HTML pages: grab the <title> (and Ray ID) for a clean summary if "<!DOCTYPE" in raw or "<html" in raw: m = re.search(r"<title[^>]*>([^<]+)", raw, re.IGNORECASE) title = m.group(1).strip() if m else "HTML error page (title not found)" - # Also grab Cloudflare Ray ID if present ray = re.search(r"Cloudflare Ray ID:\s*]*>([^<]+)", raw) - ray_id = ray.group(1).strip() if ray else None - status_code = getattr(error, "status_code", None) - parts = [] - if status_code: - parts.append(f"HTTP {status_code}") + parts = [prefix[:-2]] if prefix else [] parts.append(title) - if ray_id: - parts.append(f"Ray {ray_id}") + if ray: + parts.append(f"Ray {ray.group(1).strip()}") return " — ".join(parts) # GeminiAPIError already composes a clean one-liner with guidance; don't re-extract the raw body. @@ -176,13 +157,11 @@ class ApiErrorSummaryMixin: if isinstance(body, dict): msg = body.get("error", {}).get("message") if isinstance(body.get("error"), dict) else body.get("message") if msg: - status_code = getattr(error, "status_code", None) - prefix = f"HTTP {status_code}: " if status_code else "" msg = ApiErrorSummaryMixin._coerce_api_error_detail(msg) return ApiErrorSummaryMixin._decorate_xai_entitlement_error(f"{prefix}{msg[:300]}") - # SDK may leave body empty while httpx has the payload (#36109). Redact: the body is - # attacker-influenced and may echo Authorization / x-api-key / request JSON. + # SDK may leave body empty while httpx has the payload. Redact: the body is attacker-influenced + # and may echo Authorization / x-api-key / request JSON. response = getattr(error, "response", None) if response is not None: try: @@ -190,8 +169,6 @@ class ApiErrorSummaryMixin: except Exception: snippet = "" if snippet: - status_code = getattr(error, "status_code", None) - prefix = f"HTTP {status_code}: " if status_code else "" try: payload = json.loads(snippet) except (json.JSONDecodeError, TypeError): @@ -205,13 +182,11 @@ class ApiErrorSummaryMixin: return redact_sensitive_text(f"{prefix}{snippet[:300]}") # Fallback: truncate the raw string but give more room than 200 chars - status_code = getattr(error, "status_code", None) - prefix = f"HTTP {status_code}: " if status_code else "" return ApiErrorSummaryMixin._decorate_xai_entitlement_error(f"{prefix}{raw[:500]}") def _mask_api_key_for_logs(self, key: Any) -> Optional[str]: - # Azure Foundry Entra ID bearer providers are callables — never - # invoke them in log paths; identify the auth surface instead. + # Azure Foundry Entra ID bearer providers are callables — never invoke them in log + # paths; identify the auth surface instead. if callable(key) and not isinstance(key, str): return "" if not key: @@ -224,16 +199,10 @@ class ApiErrorSummaryMixin: """Clean up error messages for user display, removing HTML content and truncating.""" if not error_msg: return "Unknown error" - - # Remove HTML content (common with CloudFlare and gateway error pages) + # HTML content is common with CloudFlare and gateway error pages if error_msg.strip().startswith(' 150: cleaned = cleaned[:150] + "..." - return cleaned diff --git a/agent/api_request_hooks.py b/agent/api_request_hooks.py index ae58a2013e..e57cdc7011 100644 --- a/agent/api_request_hooks.py +++ b/agent/api_request_hooks.py @@ -11,6 +11,22 @@ from typing import Any, Dict, Optional from agent.usage_pricing import normalize_usage +_SENSITIVE_HOOK_KEYS = {"api_key", "authorization", "proxy_authorization", "cookie", "set_cookie"} + + +def _model_dump(value: Any) -> Any: + """``value.model_dump(mode="json")`` with graceful degradation for older pydantic signatures. + + warnings=False: pydantic UserWarnings on generic-union SDK models would leak to the terminal. + """ + try: + return value.model_dump(mode="json", warnings=False) + except TypeError: + try: + return value.model_dump(mode="json") + except TypeError: + return value.model_dump() + class ApiRequestHooksMixin: """Hook payload sanitising + ``api_request_error`` dispatch (see module docstring).""" @@ -44,23 +60,11 @@ class ApiRequestHooksMixin: if not isinstance(key, str): return False lowered = key.lower().replace("-", "_") - exact = { - "api_key", - "authorization", - "proxy_authorization", - "cookie", - "set_cookie", - } - return lowered in exact or lowered.endswith("_api_key") + return lowered in _SENSITIVE_HOOK_KEYS or lowered.endswith("_api_key") @classmethod def _hook_jsonable( - cls, - value: Any, - *, - depth: int = 0, - max_depth: int = 8, - max_string: int = 8000, + cls, value: Any, *, depth: int = 0, max_depth: int = 8, max_string: int = 8000, max_sequence: int = 200, ) -> Any: if depth > max_depth: @@ -73,6 +77,13 @@ class ApiRequestHooksMixin: return value if isinstance(value, (bytes, bytearray)): return f"<{len(value)} bytes>" + + def recurse(item): + return cls._hook_jsonable( + item, depth=depth + 1, max_depth=max_depth, max_string=max_string, + max_sequence=max_sequence, + ) + if isinstance(value, dict): out: Dict[str, Any] = {} for idx, (key, item) in enumerate(value.items()): @@ -80,111 +91,49 @@ class ApiRequestHooksMixin: out["_truncated_items"] = len(value) - max_sequence break str_key = str(key) - if cls._is_sensitive_hook_key(str_key): - out[str_key] = "" - else: - out[str_key] = cls._hook_jsonable( - item, - depth=depth + 1, - max_depth=max_depth, - max_string=max_string, - max_sequence=max_sequence, - ) + out[str_key] = "" if cls._is_sensitive_hook_key(str_key) else recurse(item) return out if isinstance(value, (list, tuple, set)): seq = list(value) - out = [ - cls._hook_jsonable( - item, - depth=depth + 1, - max_depth=max_depth, - max_string=max_string, - max_sequence=max_sequence, - ) - for item in seq[:max_sequence] - ] + out = [recurse(item) for item in seq[:max_sequence]] if len(seq) > max_sequence: out.append({"_truncated_items": len(seq) - max_sequence}) return out try: if hasattr(value, "model_dump"): - try: - # warnings=False: pydantic UserWarnings on generic-union SDK models would leak to the - # terminal. - dumped = value.model_dump(mode="json", warnings=False) - except TypeError: - try: - dumped = value.model_dump(mode="json") - except TypeError: - dumped = value.model_dump() - return cls._hook_jsonable( - dumped, - depth=depth + 1, - max_depth=max_depth, - max_string=max_string, - max_sequence=max_sequence, - ) + return recurse(_model_dump(value)) except Exception: pass try: from dataclasses import asdict, is_dataclass if is_dataclass(value): - return cls._hook_jsonable( - asdict(value), - depth=depth + 1, - max_depth=max_depth, - max_string=max_string, - max_sequence=max_sequence, - ) + return recurse(asdict(value)) except Exception: pass if isinstance(value, SimpleNamespace): - return cls._hook_jsonable( - vars(value), - depth=depth + 1, - max_depth=max_depth, - max_string=max_string, - max_sequence=max_sequence, - ) + return recurse(vars(value)) if hasattr(value, "__dict__"): try: - public_attrs = { - k: v - for k, v in vars(value).items() - if not str(k).startswith("_") - } - return cls._hook_jsonable( - public_attrs, - depth=depth + 1, - max_depth=max_depth, - max_string=max_string, - max_sequence=max_sequence, - ) + return recurse({k: v for k, v in vars(value).items() if not str(k).startswith("_")}) except Exception: pass return str(value)[:max_string] @classmethod def _sanitize_hook_payload(cls, value: Any) -> Any: - payload = cls._hook_jsonable(value) + """JSON-able payload under the size cap: full → reduced caps → truncated preview.""" limit = cls._hook_payload_max_chars() - try: - encoded = json.dumps(payload, ensure_ascii=False, default=str) - except Exception: - return str(payload)[:limit] - if len(encoded) <= limit: - return payload - payload = cls._hook_jsonable(value, max_string=1000, max_sequence=50) - try: - encoded = json.dumps(payload, ensure_ascii=False, default=str) - except Exception: - return str(payload)[:limit] - if len(encoded) <= limit: - return payload + encoded = "" + for caps in ({}, {"max_string": 1000, "max_sequence": 50}): + payload = cls._hook_jsonable(value, **caps) + try: + encoded = json.dumps(payload, ensure_ascii=False, default=str) + except Exception: + return str(payload)[:limit] + if len(encoded) <= limit: + return payload return { - "_truncated": True, - "original_type": type(value).__name__, - "preview": encoded[:limit], + "_truncated": True, "original_type": type(value).__name__, "preview": encoded[:limit] } def _api_request_payload_for_hook(self, api_kwargs: Optional[Dict[str, Any]]) -> Dict[str, Any]: @@ -193,19 +142,10 @@ class ApiRequestHooksMixin: for key, value in (api_kwargs or {}).items() if key not in {"timeout", "http_client"} } - return self._sanitize_hook_payload( - { - "method": "POST", - "body": body, - } - ) + return self._sanitize_hook_payload({"method": "POST", "body": body}) def _api_response_payload_for_hook( - self, - response: Any, - assistant_message: Any, - *, - finish_reason: Optional[str], + self, response: Any, assistant_message: Any, *, finish_reason: Optional[str] ) -> Dict[str, Any]: # Raw provider SDK tool_call objects are handed to the sanitizer on purpose; `_hook_jsonable` must # keep normalising them (model_dump / __dict__ / dataclass) or subscribers get str() blobs. @@ -224,20 +164,10 @@ class ApiRequestHooksMixin: ) def _invoke_api_request_error_hook( - self, - *, - task_id: str, - turn_id: str, - api_request_id: str, - api_call_count: int, - api_start_time: float, - api_kwargs: Optional[Dict[str, Any]], - error_type: str, - error_message: str, - status_code: Optional[int] = None, - retry_count: Optional[int] = None, - max_retries: Optional[int] = None, - retryable: Optional[bool] = None, + self, *, task_id: str, turn_id: str, api_request_id: str, api_call_count: int, + api_start_time: float, api_kwargs: Optional[Dict[str, Any]], error_type: str, + error_message: str, status_code: Optional[int] = None, retry_count: Optional[int] = None, + max_retries: Optional[int] = None, retryable: Optional[bool] = None, reason: Optional[str] = None, ) -> None: # Lazy module import (not from-import) so tests can replace lifecycle dispatch at this call site. @@ -267,10 +197,7 @@ class ApiRequestHooksMixin: max_retries=max_retries, retryable=retryable, reason=reason, - error={ - "type": error_type, - "message": error_message, - }, + error={"type": error_type, "message": error_message}, request=self._api_request_payload_for_hook(api_kwargs), ) except Exception: diff --git a/agent/backend_identity.py b/agent/backend_identity.py index 502e87ec2a..593b2d0b62 100644 --- a/agent/backend_identity.py +++ b/agent/backend_identity.py @@ -1,27 +1,11 @@ """Single owner for backend identity and failure-scoped skip decisions. -Every fallback / dedup / skip / quarantine decision asks one question: **"is -this candidate the same backend as the one that failed, along the axis that -failure invalidated?"** Answering it inline at each call site (comparing -whatever string was locally convenient) repeatedly reintroduced the same bugs: -same-shim aliases treated as distinct, sibling models skipped for one model's -timeout, dedup ignoring ``base_url`` and stranding multi-endpoint pools. - -"provider" conflates three independent identity axes, each invalidated by a -different failure class: - -* **credential surface** — auth 401 / payment 402 kill everything sharing the - credential (every model, every host reached with that key/token). -* **endpoint** — DNS failure / connection refused kill everything behind the - URL, regardless of model or credential. -* **model deployment** — timeout / overload / rate limit / model-incompatible - kill ONE model's deployment. A sibling model behind the same URL is an - independent deployment (one model hung while another on the identical - endpoint kept serving). - -Call sites build :class:`BackendIdentity` values and ask -:func:`should_skip_candidate`. Do not re-implement any comparison inline — -extend THIS module instead. +Every fallback / dedup / skip / quarantine decision asks: "is this candidate the same backend +as the one that failed, along the axis that failure invalidated?" Answering inline at each +call site kept reintroducing the same bugs (same-shim aliases treated as distinct, sibling +models skipped for one model's timeout, dedup ignoring ``base_url``). "provider" conflates +three axes — credential surface (401/402), endpoint (DNS/refused), model deployment +(timeout/overload/429). Build :class:`BackendIdentity` values, ask :func:`should_skip_candidate`. """ from __future__ import annotations @@ -37,8 +21,8 @@ logger = logging.getLogger(__name__) class FailureScope(Enum): """Which identity axis a failure invalidates.""" - #: Timeout, overload/429, connection blip, model-incompatible, invalid - #: response: evidence against ONE model deployment only. + #: Timeout, overload/429, connection blip, model-incompatible, invalid response: + #: evidence against ONE model deployment only. MODEL = "model" #: Auth 401 / payment 402: evidence against the shared credential. CREDENTIAL = "credential" @@ -54,8 +38,8 @@ def _norm(value: Optional[str]) -> str: class BackendIdentity: """Normalized identity of one (provider, model, endpoint) deployment. - Empty fields mean "unknown" — an unknown axis can neither prove sameness - nor difference on its own; the remaining axes decide.""" + Empty fields mean "unknown" — an unknown axis can neither prove sameness nor difference + on its own; the remaining axes decide.""" provider: str = "" model: str = "" @@ -63,25 +47,20 @@ class BackendIdentity: @classmethod def build( - cls, - provider: Optional[str] = None, - model: Optional[str] = None, + cls, provider: Optional[str] = None, model: Optional[str] = None, base_url: Optional[str] = None, ) -> "BackendIdentity": return cls( - provider=_norm(provider), - model=_norm(model), - base_url=_norm(base_url).rstrip("/"), + provider=_norm(provider), model=_norm(model), base_url=_norm(base_url).rstrip("/"), ) def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool: """True when both providers are distinct registered first-class providers. - Two different registry providers have distinct credential surfaces even - when they share an inference host (xai-oauth vs xai, openai-codex vs - openai-api). Custom/shim aliases are NOT in the registry, so two aliases - pointing at one URL still count as the same backend.""" + Two different registry providers have distinct credential surfaces even when they share an + inference host (xai-oauth vs xai). Custom/shim aliases are NOT in the registry, so two + aliases pointing at one URL still count as the same backend.""" if not a.provider or not b.provider or a.provider == b.provider: return False try: @@ -95,11 +74,9 @@ def _both_first_class(a: BackendIdentity, b: BackendIdentity) -> bool: def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool: """Do two identities share the credential a 401/402 just invalidated? - Conservative on purpose: an unprovable axis answers "different" (one wasted - RTT) rather than "same" (stranded failover). Same label = same configured - credential; different labels = different credential config (custom entries - can each carry their own api_key, so a shared URL alone never proves a - shared credential — it is only a weak signal when a label is missing).""" + Conservative: an unprovable axis answers "different" (one wasted RTT) rather than "same" + (stranded failover). Same label = same configured credential; custom entries can each carry + their own api_key, so a shared URL alone is only a weak signal when a label is missing.""" if a.provider and b.provider: return a.provider == b.provider return bool(a.base_url and a.base_url == b.base_url) @@ -107,8 +84,7 @@ def same_credential_surface(a: BackendIdentity, b: BackendIdentity) -> bool: def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool: """Do two identities sit behind the endpoint that just went unreachable? - An unknown base_url inherits the provider default, so a shared provider - label implies the same default endpoint.""" + An unknown base_url inherits the provider default, so a shared label implies the same endpoint.""" if a.base_url and b.base_url: return a.base_url == b.base_url return bool(a.provider and a.provider == b.provider) @@ -117,11 +93,10 @@ def same_endpoint(a: BackendIdentity, b: BackendIdentity) -> bool: def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool: """Are these the exact same model deployment (the thing a timeout kills)? - Provider+model must match; base_url distinguishes only when BOTH sides carry - an explicit URL (same provider+model on two explicit URLs is a pool, not a - dup). Different labels with the same URL + model are still one deployment - (same-host shim aliases) — unless both labels are first-class registry - providers.""" + Provider+model must match; base_url distinguishes only when BOTH sides carry an explicit URL + (same provider+model on two explicit URLs is a pool, not a dup). Different labels with the + same URL + model are still one deployment (same-host shim aliases) — unless both labels are + first-class registry providers.""" if not (a.provider and b.provider and a.provider == b.provider): return bool( a.base_url @@ -135,16 +110,16 @@ def same_deployment(a: BackendIdentity, b: BackendIdentity) -> bool: return not (a.base_url and b.base_url and a.base_url != b.base_url) +_SCOPE_PREDICATES = { + FailureScope.CREDENTIAL: same_credential_surface, FailureScope.ENDPOINT: same_endpoint, + FailureScope.MODEL: same_deployment, +} + + def should_skip_candidate( - candidate: BackendIdentity, - failed: BackendIdentity, - scope: FailureScope = FailureScope.MODEL, + candidate: BackendIdentity, failed: BackendIdentity, scope: FailureScope = FailureScope.MODEL ) -> bool: """THE skip predicate: would trying ``candidate`` just repeat the failure? - True when it is the same backend as ``failed`` along the axis ``scope`` - invalidated. Every fallback/dedup/skip site must call this.""" - if scope is FailureScope.CREDENTIAL: - return same_credential_surface(candidate, failed) - if scope is FailureScope.ENDPOINT: - return same_endpoint(candidate, failed) - return same_deployment(candidate, failed) + True when it is the same backend as ``failed`` along the axis ``scope`` invalidated. + Every fallback/dedup/skip site must call this.""" + return _SCOPE_PREDICATES.get(scope, same_deployment)(candidate, failed)