diff --git a/agent/agent_init.py b/agent/agent_init.py index d8eee9497ca..0b2ced0d327 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -840,7 +840,7 @@ def init_agent( # sessions with >5-minute pauses between turns (#14971). agent._cache_ttl = "5m" try: - from hermes_cli.config import load_config as _load_pc_cfg + from hermes_cli.config import load_config_readonly as _load_pc_cfg _pc_cfg = _load_pc_cfg().get("prompt_caching", {}) or {} _ttl = _pc_cfg.get("cache_ttl", "5m") @@ -1090,7 +1090,7 @@ def init_agent( # Guardrail config — read from config.yaml at init time. agent._bedrock_guardrail_config = None try: - from hermes_cli.config import load_config as _load_br_cfg + from hermes_cli.config import load_config_readonly as _load_br_cfg _gr = _load_br_cfg().get("bedrock", {}).get("guardrail", {}) if _gr.get("guardrail_identifier") and _gr.get("guardrail_version"): agent._bedrock_guardrail_config = { @@ -1477,7 +1477,7 @@ def init_agent( # reads the JSON files directly. See run_agent._save_session_log. agent._session_json_enabled = False try: - from hermes_cli.config import load_config as _load_sess_cfg + from hermes_cli.config import load_config_readonly as _load_sess_cfg _sess_cfg = (_load_sess_cfg().get("sessions") or {}) agent._session_json_enabled = bool(_sess_cfg.get("write_json_snapshots", False)) except Exception: @@ -1548,7 +1548,7 @@ def init_agent( # Load config once for memory, skills, and compression sections try: - from hermes_cli.config import load_config as _load_agent_config + from hermes_cli.config import load_config_readonly as _load_agent_config _agent_cfg = _load_agent_config() except Exception: _agent_cfg = {} diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 42647956042..2dc845b69c1 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -676,15 +676,15 @@ def build_or_headers(or_config: dict | None = None) -> dict: Overrides ``openrouter.response_cache_ttl`` in config.yaml. *or_config* is the ``openrouter`` section from config.yaml. When *None*, - falls back to reading config from disk via ``load_config()``. + falls back to reading config from disk via ``load_config_readonly()``. """ headers = dict(_OR_HEADERS_BASE) # Resolve config from disk if not provided. if or_config is None: try: - from hermes_cli.config import load_config - or_config = load_config().get("openrouter", {}) + from hermes_cli.config import load_config_readonly + or_config = load_config_readonly().get("openrouter", {}) except Exception: or_config = {} @@ -2317,8 +2317,8 @@ def _read_main_model() -> str: if isinstance(override, str) and override.strip(): return override.strip() try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() model_cfg = cfg.get("model", {}) if isinstance(model_cfg, str) and model_cfg.strip(): return model_cfg.strip() @@ -2344,8 +2344,8 @@ def _read_main_provider() -> str: if isinstance(override, str) and override.strip(): return override.strip().lower() try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): provider = model_cfg.get("provider", "") @@ -3040,12 +3040,12 @@ def _try_azure_foundry( try: from hermes_cli.runtime_provider import _resolve_azure_foundry_runtime from hermes_cli.auth import AuthError - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly except ImportError: return None, None try: - cfg = load_config() + cfg = load_config_readonly() model_cfg = cfg.get("model") if isinstance(cfg, dict) else {} if not isinstance(model_cfg, dict): model_cfg = {} @@ -3159,8 +3159,8 @@ def _try_anthropic(explicit_api_key: str = None) -> Tuple[Optional[Any], Optiona # see issue #52608. base_url = _pool_runtime_base_url(entry, _ANTHROPIC_DEFAULT_BASE_URL) if pool_present else _ANTHROPIC_DEFAULT_BASE_URL try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() model_cfg = cfg.get("model") if isinstance(model_cfg, dict): cfg_provider = str(model_cfg.get("provider") or "").strip().lower() @@ -4764,10 +4764,10 @@ def _try_main_fallback_chain( participate in the same order as the main agent. """ try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly from hermes_cli.fallback_config import get_fallback_chain - chain = get_fallback_chain(load_config()) + chain = get_fallback_chain(load_config_readonly()) except Exception as exc: logger.debug("Auxiliary %s: could not load main fallback chain: %s", task or "call", exc) return None, None, "" @@ -5986,11 +5986,11 @@ def _main_model_supports_vision(provider: str, model: Optional[str]) -> bool: """ try: from agent.image_routing import _lookup_supports_vision - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly except ImportError: return True try: - supports = _lookup_supports_vision(provider, model, load_config()) + supports = _lookup_supports_vision(provider, model, load_config_readonly()) except Exception: # pragma: no cover - defensive return True if supports is None: @@ -6959,8 +6959,8 @@ def _get_auxiliary_task_config(task: str) -> Dict[str, Any]: if not task: return {} try: - from hermes_cli.config import load_config - config = load_config() + from hermes_cli.config import load_config_readonly + config = load_config_readonly() except ImportError: return {} aux = config.get("auxiliary", {}) if isinstance(config, dict) else {} diff --git a/agent/background_review.py b/agent/background_review.py index a0dbd4a99e2..bc58ac59d99 100644 --- a/agent/background_review.py +++ b/agent/background_review.py @@ -70,8 +70,8 @@ def _resolve_review_runtime(agent: Any) -> Dict[str, Any]: "routed": False, } try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() except Exception: return parent aux = cfg.get("auxiliary", {}) if isinstance(cfg.get("auxiliary"), dict) else {} diff --git a/agent/coding_context.py b/agent/coding_context.py index fabbdb48e07..aa38305e016 100644 --- a/agent/coding_context.py +++ b/agent/coding_context.py @@ -337,9 +337,9 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str: """Return the normalized ``agent.coding_context`` mode (auto/focus/on/off).""" if config is None: try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly - config = load_config() + config = load_config_readonly() except Exception: config = {} raw = ((config or {}).get("agent", {}) or {}).get("coding_context", "auto") diff --git a/agent/curator.py b/agent/curator.py index 21827bec484..750dd7c2eba 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -138,8 +138,8 @@ def is_paused() -> bool: def _load_config() -> Dict[str, Any]: """Read curator.* config from ~/.hermes/config.yaml. Tolerates missing file.""" try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() except Exception as e: logger.debug("Failed to load config for curator: %s", e) return {} @@ -1875,9 +1875,9 @@ def _run_llm_review(prompt: str) -> Dict[str, Any]: _acp_args = None _model_name = "" try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly from hermes_cli.runtime_provider import resolve_runtime_provider - _cfg = load_config() + _cfg = load_config_readonly() _binding = _resolve_review_runtime(_cfg) _provider, _model_name = _binding.provider, _binding.model _rp = resolve_runtime_provider( diff --git a/agent/curator_backup.py b/agent/curator_backup.py index 5b95f9e3e70..ca0cc763625 100644 --- a/agent/curator_backup.py +++ b/agent/curator_backup.py @@ -147,8 +147,8 @@ def _utc_id(now: Optional[datetime] = None) -> str: def _load_config() -> Dict[str, Any]: try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() except Exception as e: logger.debug("Failed to load config for curator backup: %s", e) return {} diff --git a/agent/i18n.py b/agent/i18n.py index 24f8ab0a023..7c0dcf5c87e 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -197,8 +197,8 @@ def _config_language_cached() -> str | None: (e.g. after the setup wizard). """ try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() lang = (cfg.get("display") or {}).get("language") if lang: return _normalize_lang(lang) diff --git a/agent/image_gen_registry.py b/agent/image_gen_registry.py index 5d14a6f1ece..47538c8cf25 100644 --- a/agent/image_gen_registry.py +++ b/agent/image_gen_registry.py @@ -91,9 +91,9 @@ def get_active_provider() -> Optional[ImageGenProvider]: """ configured: Optional[str] = None try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly - cfg = load_config() + cfg = load_config_readonly() section = cfg.get("image_gen") if isinstance(cfg, dict) else None if isinstance(section, dict): raw = section.get("provider") diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index 757f7ebc7b9..7ba1b914f74 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -196,8 +196,8 @@ class LSPService: itself returns ``is_active()`` False when LSP is disabled. """ try: - from hermes_cli.config import load_config - cfg = load_config() + from hermes_cli.config import load_config_readonly + cfg = load_config_readonly() except Exception as e: # noqa: BLE001 logger.debug("LSP config load failed: %s", e) return None diff --git a/agent/plugin_llm.py b/agent/plugin_llm.py index e9c2a869dd7..8fcd3364b1c 100644 --- a/agent/plugin_llm.py +++ b/agent/plugin_llm.py @@ -210,8 +210,8 @@ def _resolve_trust_policy(plugin_id: str) -> _TrustPolicy: return _TrustPolicy(plugin_id="") try: - from hermes_cli.config import load_config - config = load_config() or {} + from hermes_cli.config import load_config_readonly + config = load_config_readonly() or {} except Exception: # pragma: no cover — config IO failure return _TrustPolicy(plugin_id=plugin_id) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index f4766141a39..e6f1f96adf8 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -1246,10 +1246,10 @@ def build_environment_hints() -> str: extra = (os.getenv("HERMES_ENVIRONMENT_HINT") or "").strip() if not extra: try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly extra = str( - (load_config().get("agent", {}) or {}).get("environment_hint", "") + (load_config_readonly().get("agent", {}) or {}).get("environment_hint", "") ).strip() except Exception as e: logger.debug("Could not read agent.environment_hint from config: %s", e) @@ -1300,9 +1300,9 @@ def _get_context_file_max_chars(context_length: Optional[int] = None) -> int: 3. ``CONTEXT_FILE_MAX_CHARS`` (20K) as the upstream-compatible fallback. """ try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly - val = load_config().get("context_file_max_chars") + val = load_config_readonly().get("context_file_max_chars") if isinstance(val, (int, float)) and val > 0: return int(val) except Exception as e: diff --git a/agent/skill_preprocessing.py b/agent/skill_preprocessing.py index 19c6eeb80fb..44c5714b9b3 100644 --- a/agent/skill_preprocessing.py +++ b/agent/skill_preprocessing.py @@ -25,9 +25,9 @@ _INLINE_SHELL_MAX_OUTPUT = 4000 def load_skills_config() -> dict: """Load the ``skills`` section of config.yaml (best-effort).""" try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly - cfg = load_config() or {} + cfg = load_config_readonly() or {} skills_cfg = cfg.get("skills") if isinstance(skills_cfg, dict): return skills_cfg diff --git a/agent/title_generator.py b/agent/title_generator.py index bb44da276ec..80dcdd10c72 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -43,10 +43,10 @@ _TITLE_PROMPT_PINNED_LANGUAGE = ( def _title_language() -> str: """Return configured title language, or empty string to match the user.""" try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly return str( - ((load_config() or {}).get("auxiliary") or {}) + ((load_config_readonly() or {}).get("auxiliary") or {}) .get("title_generation", {}) .get("language", "") ).strip() diff --git a/agent/verification_stop.py b/agent/verification_stop.py index 1f68b1aace5..ac5bc852d0b 100644 --- a/agent/verification_stop.py +++ b/agent/verification_stop.py @@ -149,9 +149,9 @@ def verify_on_stop_enabled(config: dict[str, Any] | None = None) -> bool: return env.strip().lower() not in {"0", "false", "no", "off"} if config is None: try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly - config = load_config() + config = load_config_readonly() except Exception: config = {} agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None diff --git a/agent/video_gen_registry.py b/agent/video_gen_registry.py index c4d28e39ed4..d78babfc9bb 100644 --- a/agent/video_gen_registry.py +++ b/agent/video_gen_registry.py @@ -84,9 +84,9 @@ def get_active_provider() -> Optional[VideoGenProvider]: """ configured: Optional[str] = None try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly - cfg = load_config() + cfg = load_config_readonly() section = cfg.get("video_gen") if isinstance(cfg, dict) else None if isinstance(section, dict): raw = section.get("provider") diff --git a/agent/web_search_registry.py b/agent/web_search_registry.py index 45832cb5488..dd46eb68118 100644 --- a/agent/web_search_registry.py +++ b/agent/web_search_registry.py @@ -98,9 +98,9 @@ def get_provider(name: str) -> Optional[WebSearchProvider]: def _read_config_key(*path: str) -> Optional[str]: """Resolve a dotted config key from ``config.yaml``. Returns None on miss.""" try: - from hermes_cli.config import load_config + from hermes_cli.config import load_config_readonly - cfg = load_config() + cfg = load_config_readonly() cur = cfg for segment in path: if not isinstance(cur, dict): diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 663c89a38fe..38669ed3521 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -1280,6 +1280,15 @@ def _normalize_custom_provider_entry( if not isinstance(entry, dict): return None + # Shallow-copy before the alias normalization below writes into the + # entry: callers (get_compatible_custom_providers, + # providers_dict_to_custom_providers) pass live sub-dicts from + # load_config_readonly()'s shared cache, and mutating those both + # violates the cache's no-mutation contract and leaks duplicated + # alias keys back into config.yaml through any later + # save_config(load_config()) round-trip. + entry = dict(entry) + # Accept camelCase aliases commonly used in hand-written configs. _CAMEL_ALIASES: Dict[str, str] = { "apiKey": "api_key", @@ -1390,7 +1399,10 @@ def _normalize_custom_provider_entry( models = entry.get("models") if isinstance(models, dict) and models: - normalized["models"] = models + # Shallow-copy: `entry` may alias a cached config sub-dict, and the + # normalized entry escapes into long-lived runtime state + # (agent._custom_providers) — don't share the cached models mapping. + normalized["models"] = dict(models) elif isinstance(models, list) and models: # Hand-edited configs (and older Hermes versions) may write # ``models`` as a plain list of ids or as ``[{id: ...}]`` rows. diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 3fbca37e0cd..1ba43dd0313 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -175,6 +175,7 @@ class TestResolveTaskProviderModel: lambda cfg, name: preset, ) monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"moa": {}}) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"moa": {}}) resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model( task="title_generation", @@ -210,6 +211,7 @@ class TestResolveTaskProviderModel: lambda cfg, name: preset, ) monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"moa": {}}) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"moa": {}}) resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model( task="title_generation", @@ -231,6 +233,7 @@ class TestResolveTaskProviderModel: lambda cfg, name: (_ for _ in ()).throw(KeyError("gone-preset")), ) monkeypatch.setattr("hermes_cli.config.load_config", lambda: {"moa": {}}) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {"moa": {}}) resolved_provider, model, base_url, api_key, api_mode = _resolve_task_provider_model( task="title_generation", @@ -2166,7 +2169,7 @@ class TestAuxiliaryTaskExtraBody: } } - with patch("hermes_cli.config.load_config", return_value=config), patch( + with patch("hermes_cli.config.load_config", return_value=config), patch("hermes_cli.config.load_config_readonly", return_value=config), patch( "agent.auxiliary_client._get_cached_client", return_value=(client, "glm-4.5-air"), ): @@ -2197,7 +2200,7 @@ class TestAuxiliaryTaskExtraBody: } } - with patch("hermes_cli.config.load_config", return_value=config), patch( + with patch("hermes_cli.config.load_config", return_value=config), patch("hermes_cli.config.load_config_readonly", return_value=config), patch( "agent.auxiliary_client._get_cached_client", return_value=(client, "glm-4.5-air"), ): @@ -2223,7 +2226,7 @@ class TestAuxiliaryTaskExtraBody: from agent.auxiliary_client import _get_task_extra_body config = {"auxiliary": {moa_task: {"reasoning_effort": "xhigh"}}} - with patch("hermes_cli.config.load_config", return_value=config), \ + with patch("hermes_cli.config.load_config", return_value=config), patch("hermes_cli.config.load_config_readonly", return_value=config), \ caplog.at_level(logging.WARNING, logger="agent.auxiliary_client"): result = _get_task_extra_body(moa_task) @@ -3950,7 +3953,7 @@ class TestCustomEndpointApiKeyInheritance: captured.update(kwargs) return MagicMock() - with patch("hermes_cli.config.load_config", return_value=fake_config), \ + with patch("hermes_cli.config.load_config", return_value=fake_config), patch("hermes_cli.config.load_config_readonly", return_value=fake_config), \ patch.object(ac, "_create_openai_client", side_effect=_capture_create): client, model = resolve_provider_client( "custom", @@ -3978,7 +3981,7 @@ class TestCustomEndpointApiKeyInheritance: captured.update(kwargs) return MagicMock() - with patch("hermes_cli.config.load_config", return_value=fake_config), \ + with patch("hermes_cli.config.load_config", return_value=fake_config), patch("hermes_cli.config.load_config_readonly", return_value=fake_config), \ patch.object(ac, "_create_openai_client", side_effect=_capture_create): client, model = resolve_provider_client( "custom", @@ -4005,7 +4008,7 @@ class TestCustomEndpointApiKeyInheritance: with patch.object(ac, "_RUNTIME_MAIN_API_KEY", "sk-runtime-key"), \ patch.object(ac, "_RUNTIME_MAIN_BASE_URL", "https://gw.example.com/v1"), \ - patch("hermes_cli.config.load_config", return_value={"model": {}}), \ + patch("hermes_cli.config.load_config", return_value={"model": {}}), patch("hermes_cli.config.load_config_readonly", return_value={"model": {}}), \ patch.object(ac, "_create_openai_client", side_effect=_capture_create): client, model = resolve_provider_client( "custom", @@ -4037,7 +4040,7 @@ class TestCustomEndpointApiKeyInheritance: captured.update(kwargs) return MagicMock() - with patch("hermes_cli.config.load_config", return_value=fake_config), \ + with patch("hermes_cli.config.load_config", return_value=fake_config), patch("hermes_cli.config.load_config_readonly", return_value=fake_config), \ patch.object(ac, "_create_openai_client", side_effect=_capture_create): client, model = resolve_provider_client( "custom", diff --git a/tests/agent/test_auxiliary_client_azure_foundry.py b/tests/agent/test_auxiliary_client_azure_foundry.py index 331e3d1165c..b0bce44af40 100644 --- a/tests/agent/test_auxiliary_client_azure_foundry.py +++ b/tests/agent/test_auxiliary_client_azure_foundry.py @@ -73,6 +73,10 @@ def patch_load_config(monkeypatch): "hermes_cli.config.load_config", lambda: {"model": model_cfg}, ) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"model": model_cfg}, + ) return _apply diff --git a/tests/agent/test_codex_gpt55_autoraise_notice.py b/tests/agent/test_codex_gpt55_autoraise_notice.py index baadf4a0142..0175b97eeee 100644 --- a/tests/agent/test_codex_gpt55_autoraise_notice.py +++ b/tests/agent/test_codex_gpt55_autoraise_notice.py @@ -59,6 +59,8 @@ def _make_codex_agent(monkeypatch, tmp_path: Path, *, show_notice: bool): from hermes_cli import config as config_mod monkeypatch.setattr(config_mod, "load_config", lambda: _config(show_notice=show_notice)) + + monkeypatch.setattr(config_mod, "load_config_readonly", lambda: _config(show_notice=show_notice)) db = SessionDB(db_path=tmp_path / "state.db") stdout = io.StringIO() diff --git a/tests/agent/test_compression_max_attempts_config.py b/tests/agent/test_compression_max_attempts_config.py index 638abd4da9b..95eb0c72fa8 100644 --- a/tests/agent/test_compression_max_attempts_config.py +++ b/tests/agent/test_compression_max_attempts_config.py @@ -46,6 +46,9 @@ def _make_agent(monkeypatch, tmp_path: Path, *, max_attempts=None): monkeypatch.setattr( config_mod, "load_config", lambda: _config(max_attempts=max_attempts) ) + monkeypatch.setattr( + config_mod, "load_config_readonly", lambda: _config(max_attempts=max_attempts) + ) db = SessionDB(db_path=tmp_path / "state.db") with contextlib.redirect_stdout(io.StringIO()): agent = AIAgent( diff --git a/tests/agent/test_curator.py b/tests/agent/test_curator.py index 5b6840a27e2..e18888771a0 100644 --- a/tests/agent/test_curator.py +++ b/tests/agent/test_curator.py @@ -637,6 +637,10 @@ def test_review_fork_forwards_runtime_pool_and_overrides(curator_env, monkeypatc "hermes_cli.config.load_config", lambda: {"model": {"provider": "custom:hyper-charm", "default": "glm-5.2"}}, ) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"model": {"provider": "custom:hyper-charm", "default": "glm-5.2"}}, + ) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", _fake_resolve_runtime_provider, @@ -660,6 +664,10 @@ def test_review_fork_uses_runtime_model_and_output_cap(curator_env, monkeypatch) "hermes_cli.config.load_config", lambda: {"model": {"provider": "custom:gateway", "default": "gateway"}}, ) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"model": {"provider": "custom:gateway", "default": "gateway"}}, + ) monkeypatch.setattr( "hermes_cli.runtime_provider.resolve_runtime_provider", lambda **_kwargs: { diff --git a/tests/agent/test_openrouter_response_cache.py b/tests/agent/test_openrouter_response_cache.py index 755a7e0f300..3544fd07161 100644 --- a/tests/agent/test_openrouter_response_cache.py +++ b/tests/agent/test_openrouter_response_cache.py @@ -52,7 +52,7 @@ class TestBuildOrHeaders: """When load_config() fails, build_or_headers still returns base headers.""" from agent.auxiliary_client import build_or_headers - with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")): + with patch("hermes_cli.config.load_config", side_effect=RuntimeError("boom")), patch("hermes_cli.config.load_config_readonly", side_effect=RuntimeError("boom")): headers = build_or_headers(or_config=None) # Should have base attribution but no cache headers assert "HTTP-Referer" in headers diff --git a/tests/agent/test_proactive_prune_config.py b/tests/agent/test_proactive_prune_config.py index 05c392ad71a..320ac1b35a6 100644 --- a/tests/agent/test_proactive_prune_config.py +++ b/tests/agent/test_proactive_prune_config.py @@ -39,6 +39,8 @@ def _make_agent(monkeypatch, tmp_path: Path, **prune_keys): from hermes_cli import config as config_mod monkeypatch.setattr(config_mod, "load_config", lambda: _config(**prune_keys)) + + monkeypatch.setattr(config_mod, "load_config_readonly", lambda: _config(**prune_keys)) db = SessionDB(db_path=tmp_path / "state.db") with contextlib.redirect_stdout(io.StringIO()): agent = AIAgent( diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index c8aac026331..19505a887ba 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -98,6 +98,7 @@ class TestTruncateContent: return {} monkeypatch.setattr("hermes_cli.config.load_config", default_load_config) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", default_load_config) @@ -116,6 +117,7 @@ class TestTruncateContent: return {"context_file_max_chars": 120} monkeypatch.setattr("hermes_cli.config.load_config", fake_load_config) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", fake_load_config) _truncate_content("x" * 180, "warning.md") @@ -133,6 +135,7 @@ class TestTruncateContent: return {"context_file_max_chars": 120} monkeypatch.setattr("hermes_cli.config.load_config", fake_load_config) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", fake_load_config) # Generate a warning in a fresh child context, then assert it did NOT # leak into the parent context's accumulator. @@ -161,6 +164,7 @@ class TestDynamicContextFileCap: def _no_explicit_config(self, monkeypatch): # No explicit context_file_max_chars → dynamic path is eligible. monkeypatch.setattr("hermes_cli.config.load_config", lambda: {}) + monkeypatch.setattr("hermes_cli.config.load_config_readonly", lambda: {}) def test_dynamic_scales_above_floor_for_large_window(self): @@ -179,6 +183,10 @@ class TestDynamicContextFileCap: "hermes_cli.config.load_config", lambda: {"context_file_max_chars": 1_000}, ) + monkeypatch.setattr( + "hermes_cli.config.load_config_readonly", + lambda: {"context_file_max_chars": 1_000}, + ) assert _get_context_file_max_chars(200_000) == 1_000 def test_large_window_avoids_truncation_of_midsize_doc(self): diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index 79d27a9fd3b..74887a0badb 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -22,11 +22,12 @@ class TestGenerateTitle: def test_title_language_reads_config(self): cfg = {"auxiliary": {"title_generation": {"language": " French "}}} - with patch("hermes_cli.config.load_config", return_value=cfg): + with patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg): assert _title_language() == "French" - with patch("hermes_cli.config.load_config", return_value={}): + with patch("hermes_cli.config.load_config", return_value={}), patch("hermes_cli.config.load_config_readonly", return_value={}): assert _title_language() == "" - with patch("hermes_cli.config.load_config", side_effect=RuntimeError("bad config")): + with patch("hermes_cli.config.load_config", side_effect=RuntimeError("bad config")), \ + patch("hermes_cli.config.load_config_readonly", side_effect=RuntimeError("bad config")): assert _title_language() == "" def test_default_timeout_delegates_to_auxiliary_config(self): diff --git a/tests/hermes_cli/test_custom_provider_normalize_no_mutate.py b/tests/hermes_cli/test_custom_provider_normalize_no_mutate.py new file mode 100644 index 00000000000..1a37cb6e075 --- /dev/null +++ b/tests/hermes_cli/test_custom_provider_normalize_no_mutate.py @@ -0,0 +1,62 @@ +"""Regression: _normalize_custom_provider_entry must never mutate its input. + +Provider entries frequently alias cached config dicts (load_config_readonly() +shares one dict process-wide). The normalizer's alias rewrites +(api_key_env -> key_env, camelCase -> snake_case) used to write INTO the +caller's entry, corrupting the shared cache for every subsequent reader. +""" + +import copy + +from hermes_cli.config import ( + _normalize_custom_provider_entry, + get_compatible_custom_providers, + providers_dict_to_custom_providers, +) + + +def test_normalizer_does_not_mutate_entry_api_key_env(): + entry = {"base_url": "https://x.example/v1", "api_key_env": "MY_KEY"} + snapshot = copy.deepcopy(entry) + out = _normalize_custom_provider_entry(entry, provider_key="p") + assert entry == snapshot, "input entry must not gain key_env" + assert out is not None and out.get("key_env") == "MY_KEY" + + +def test_normalizer_does_not_mutate_entry_camel_aliases(): + entry = {"baseUrl": "https://x.example/v1", "apiKey": "sk-test-not-real"} + snapshot = copy.deepcopy(entry) + _normalize_custom_provider_entry(entry, provider_key="p") + assert entry == snapshot, "camelCase aliasing must not write into the input" + + +def test_providers_dict_roundtrip_leaves_cached_config_untouched(): + config = { + "providers": { + "myprov": { + "base_url": "https://x.example/v1", + "api_key_env": "MY_KEY", + "models": {"m1": {"context_length": 8192}}, + } + } + } + snapshot = copy.deepcopy(config) + providers_dict_to_custom_providers(config["providers"]) + assert config == snapshot + + get_compatible_custom_providers(config) + assert config == snapshot + + +def test_normalized_models_mapping_is_not_shared_with_input(): + entry = { + "base_url": "https://x.example/v1", + "api_key": "sk-test-not-real", + "models": {"m1": {}}, + } + out = _normalize_custom_provider_entry(entry, provider_key="p") + assert out is not None + out["models"]["injected"] = {} + assert "injected" not in entry["models"], ( + "normalized models mapping must not alias the caller's dict" + ) diff --git a/tests/run_agent/test_api_max_retries_config.py b/tests/run_agent/test_api_max_retries_config.py index 8bf86969e6c..5060c5e8058 100644 --- a/tests/run_agent/test_api_max_retries_config.py +++ b/tests/run_agent/test_api_max_retries_config.py @@ -17,7 +17,8 @@ def _make_agent(api_max_retries=None): cfg["agent"]["api_max_retries"] = api_max_retries with patch("run_agent.OpenAI"), \ - patch("hermes_cli.config.load_config", return_value=cfg): + patch("hermes_cli.config.load_config", return_value=cfg), \ + patch("hermes_cli.config.load_config_readonly", return_value=cfg): return AIAgent( api_key="test-key", base_url="https://openrouter.ai/api/v1", diff --git a/tests/run_agent/test_background_review_cost_controls.py b/tests/run_agent/test_background_review_cost_controls.py index 7e5da983faf..e8a8df20cbc 100644 --- a/tests/run_agent/test_background_review_cost_controls.py +++ b/tests/run_agent/test_background_review_cost_controls.py @@ -44,7 +44,7 @@ class _FakeAgent: def test_routing_auto_inherits_parent_and_downgrades_codex_app_server(): agent = _FakeAgent() cfg = {"auxiliary": {"background_review": {"provider": "auto", "model": ""}}} - with patch("hermes_cli.config.load_config", return_value=cfg): + with patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg): rt = br._resolve_review_runtime(agent) assert rt["routed"] is False assert rt["provider"] == "openai-codex" @@ -64,7 +64,7 @@ def test_routing_to_different_model_marks_routed_and_resolves_credentials(): "request_overrides": {"extra_body": {"store": False}}, "max_output_tokens": 2048, } - with patch("hermes_cli.config.load_config", return_value=cfg), \ + with patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", return_value=fake_rp): rt = br._resolve_review_runtime(agent) assert rt["routed"] is True @@ -81,7 +81,7 @@ def test_unrouted_runtime_keeps_parent_pool_and_overrides(): agent._credential_pool = "parent-pool" agent.request_overrides = {"service_tier": "priority"} agent.max_tokens = 4096 - with patch("hermes_cli.config.load_config", return_value={}): + with patch("hermes_cli.config.load_config", return_value={}), patch("hermes_cli.config.load_config_readonly", return_value={}): rt = br._resolve_review_runtime(agent) assert rt["credential_pool"] == "parent-pool" assert rt["request_overrides"] == {"service_tier": "priority"} @@ -93,7 +93,7 @@ def test_routing_same_model_as_parent_is_not_routed(): cfg = {"auxiliary": {"background_review": { "provider": "openrouter", "model": "anthropic/claude-opus-4.8", }}} - with patch("hermes_cli.config.load_config", return_value=cfg): + with patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg): rt = br._resolve_review_runtime(agent) assert rt["routed"] is False # same model/provider → keep full-replay path @@ -103,7 +103,7 @@ def test_routing_resolution_failure_falls_back_to_parent(): cfg = {"auxiliary": {"background_review": { "provider": "openrouter", "model": "google/gemini-3-flash-preview", }}} - with patch("hermes_cli.config.load_config", return_value=cfg), \ + with patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), \ patch("hermes_cli.runtime_provider.resolve_runtime_provider", side_effect=RuntimeError("boom")): rt = br._resolve_review_runtime(agent) diff --git a/tests/run_agent/test_compression_feasibility.py b/tests/run_agent/test_compression_feasibility.py index bab549a203c..8626841cb61 100644 --- a/tests/run_agent/test_compression_feasibility.py +++ b/tests/run_agent/test_compression_feasibility.py @@ -230,7 +230,7 @@ def test_init_feasibility_check_uses_aux_context_override_from_config(): mock_client.api_key = "sk-custom" with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), patch("run_agent.OpenAI"), diff --git a/tests/run_agent/test_invalid_context_length_warning.py b/tests/run_agent/test_invalid_context_length_warning.py index f354a6b33d9..95abfe585bb 100644 --- a/tests/run_agent/test_invalid_context_length_warning.py +++ b/tests/run_agent/test_invalid_context_length_warning.py @@ -13,6 +13,7 @@ def _build_agent(model_cfg, custom_providers=None, model=None): with ( patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("agent.model_metadata.get_model_context_length", return_value=128_000), patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), diff --git a/tests/run_agent/test_memory_provider_init.py b/tests/run_agent/test_memory_provider_init.py index ad7f9d26004..ff647a3ae63 100644 --- a/tests/run_agent/test_memory_provider_init.py +++ b/tests/run_agent/test_memory_provider_init.py @@ -31,7 +31,7 @@ def test_blank_memory_provider_does_not_auto_enable_honcho(): honcho_cfg = SimpleNamespace(enabled=True, api_key="stale-key", base_url=None) with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("hermes_cli.config.save_config") as save_config, patch( "plugins.memory.honcho.client.HonchoClientConfig.from_global_config", @@ -64,7 +64,7 @@ def test_aiagent_forwards_user_id_alt_to_memory_provider(): cfg = {"memory": {"provider": "recording"}, "agent": {}} with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("plugins.memory.load_memory_provider", return_value=provider), patch("agent.model_metadata.get_model_context_length", return_value=204_800), patch("run_agent.get_tool_definitions", return_value=[]), diff --git a/tests/run_agent/test_per_model_threshold_init_ordering.py b/tests/run_agent/test_per_model_threshold_init_ordering.py index e930a8ebcdb..92811fe6c24 100644 --- a/tests/run_agent/test_per_model_threshold_init_ordering.py +++ b/tests/run_agent/test_per_model_threshold_init_ordering.py @@ -59,7 +59,7 @@ def test_plugin_engine_gets_model_thresholds_before_initial_update_model(): } with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("plugins.context_engine.load_context_engine", return_value=engine), patch("agent.model_metadata.get_model_context_length", return_value=1_000_000), patch("run_agent.get_tool_definitions", return_value=[]), diff --git a/tests/run_agent/test_plugin_context_engine_init.py b/tests/run_agent/test_plugin_context_engine_init.py index 91e698e7c3f..797e0b406e1 100644 --- a/tests/run_agent/test_plugin_context_engine_init.py +++ b/tests/run_agent/test_plugin_context_engine_init.py @@ -45,7 +45,7 @@ def test_plugin_engine_gets_context_length_on_init(): cfg = {"context": {"engine": "stub"}, "agent": {}} with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("plugins.context_engine.load_context_engine", return_value=engine), patch("agent.model_metadata.get_model_context_length", return_value=204_800), patch("run_agent.get_tool_definitions", return_value=[]), @@ -82,7 +82,7 @@ def test_active_context_engine_tools_survive_explicit_platform_toolsets(): assert "context_engine" in enabled_toolsets with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("plugins.context_engine.load_context_engine", return_value=engine), patch("agent.model_metadata.get_model_context_length", return_value=204_800), patch("run_agent.get_tool_definitions", return_value=[]), @@ -115,7 +115,7 @@ def test_plugin_engine_update_model_args(): cfg = {"context": {"engine": "stub"}, "agent": {}} with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("plugins.context_engine.load_context_engine", return_value=engine), patch("agent.model_metadata.get_model_context_length", return_value=131_072), patch("run_agent.get_tool_definitions", return_value=[]), @@ -168,7 +168,7 @@ def test_codex_gpt55_autoraise_suppressed_for_plugin_engine(): } with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("plugins.context_engine.load_context_engine", return_value=engine), patch("agent.model_metadata.get_model_context_length", return_value=272_000), patch("run_agent.get_tool_definitions", return_value=[]), @@ -194,7 +194,7 @@ def test_codex_gpt55_autoraise_still_applies_to_builtin_compressor(): } with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("agent.context_compressor.get_model_context_length", return_value=272_000), patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), diff --git a/tests/run_agent/test_preflight_compression_cap_e2e.py b/tests/run_agent/test_preflight_compression_cap_e2e.py index 178710a3c71..e0228115f3a 100644 --- a/tests/run_agent/test_preflight_compression_cap_e2e.py +++ b/tests/run_agent/test_preflight_compression_cap_e2e.py @@ -60,6 +60,11 @@ def _make_agent(monkeypatch, tmp_path: Path, *, max_attempts) -> AIAgent: monkeypatch.setattr( config_mod, "load_config", lambda: _config(max_attempts) ) + + monkeypatch.setattr( + config_mod, "load_config_readonly", lambda: _config(max_attempts) + + ) db = SessionDB(db_path=tmp_path / "state.db") with ( contextlib.redirect_stdout(io.StringIO()), diff --git a/tests/run_agent/test_provider_attribution_headers.py b/tests/run_agent/test_provider_attribution_headers.py index b8f1e8fd8e1..95100d567b7 100644 --- a/tests/run_agent/test_provider_attribution_headers.py +++ b/tests/run_agent/test_provider_attribution_headers.py @@ -99,6 +99,8 @@ def test_openrouter_headers_include_response_cache_when_enabled(mock_openai): with patch("hermes_cli.config.load_config", return_value={ "openrouter": {"response_cache": True, "response_cache_ttl": 600}, + }), patch("hermes_cli.config.load_config_readonly", return_value={ + "openrouter": {"response_cache": True, "response_cache_ttl": 600}, }): agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1") @@ -130,6 +132,8 @@ def test_user_default_headers_override_sdk_user_agent(mock_openai): with patch("hermes_cli.config.load_config", return_value={ "model": {"default_headers": {"User-Agent": "curl/8.7.1", "X-Extra": "1"}}, + }), patch("hermes_cli.config.load_config_readonly", return_value={ + "model": {"default_headers": {"User-Agent": "curl/8.7.1", "X-Extra": "1"}}, }): agent._apply_client_headers_for_base_url("http://localhost:8080/v1") @@ -159,6 +163,8 @@ def test_openrouter_headers_no_cache_when_disabled(mock_openai): with patch("hermes_cli.config.load_config", return_value={ "openrouter": {"response_cache": False}, + }), patch("hermes_cli.config.load_config_readonly", return_value={ + "openrouter": {"response_cache": False}, }): agent._apply_client_headers_for_base_url("https://openrouter.ai/api/v1") diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index d5993c26e8d..01225f241ec 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -704,7 +704,7 @@ class TestInit: patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), patch("run_agent.OpenAI"), - patch("hermes_cli.config.load_config", return_value={}), + patch("hermes_cli.config.load_config", return_value={}), patch("hermes_cli.config.load_config_readonly", return_value={}), ): a = AIAgent( api_key="test-k...7890", @@ -727,6 +727,9 @@ class TestInit: patch( "hermes_cli.config.load_config", return_value={"model": {"max_tokens": 4096}}, + ), patch( + "hermes_cli.config.load_config_readonly", + return_value={"model": {"max_tokens": 4096}}, ), ): a = AIAgent( @@ -898,6 +901,9 @@ class TestToolUseEnforcementConfig: patch( "hermes_cli.config.load_config", return_value={"agent": {"tool_use_enforcement": tool_use_enforcement}}, + ), patch( + "hermes_cli.config.load_config_readonly", + return_value={"agent": {"tool_use_enforcement": tool_use_enforcement}}, ), ): a = AIAgent( @@ -943,6 +949,9 @@ class TestToolUseEnforcementConfig: patch( "hermes_cli.config.load_config", return_value={"agent": {"tool_use_enforcement": True}}, + ), patch( + "hermes_cli.config.load_config_readonly", + return_value={"agent": {"tool_use_enforcement": True}}, ), ): a = AIAgent( @@ -980,6 +989,9 @@ class TestTaskCompletionGuidance: patch( "hermes_cli.config.load_config", return_value={"agent": agent_cfg}, + ), patch( + "hermes_cli.config.load_config_readonly", + return_value={"agent": agent_cfg}, ), ): a = AIAgent( @@ -1016,6 +1028,9 @@ class TestTaskCompletionGuidance: patch( "hermes_cli.config.load_config", return_value={"agent": {"task_completion_guidance": True}}, + ), patch( + "hermes_cli.config.load_config_readonly", + return_value={"agent": {"task_completion_guidance": True}}, ), ): a = AIAgent( @@ -1048,6 +1063,9 @@ class TestEnvironmentProbeIntegration: patch( "hermes_cli.config.load_config", return_value={"agent": {"environment_probe": environment_probe}}, + ), patch( + "hermes_cli.config.load_config_readonly", + return_value={"agent": {"environment_probe": environment_probe}}, ), ): a = AIAgent( diff --git a/tests/run_agent/test_switch_model_context.py b/tests/run_agent/test_switch_model_context.py index 2ffad97a3f7..22321fdc006 100644 --- a/tests/run_agent/test_switch_model_context.py +++ b/tests/run_agent/test_switch_model_context.py @@ -47,7 +47,7 @@ def _make_direct_start_agent( cfg: dict, *, model: str, provider: str, base_url: str ) -> AIAgent: with ( - patch("hermes_cli.config.load_config", return_value=cfg), + patch("hermes_cli.config.load_config", return_value=cfg), patch("hermes_cli.config.load_config_readonly", return_value=cfg), patch("run_agent.get_tool_definitions", return_value=[]), patch("run_agent.check_toolset_requirements", return_value={}), patch("run_agent.OpenAI"), @@ -265,6 +265,8 @@ def test_lmstudio_switch_uses_destination_context_and_verified_runtime(monkeypat return LMStudioLoadResult(100_000) monkeypatch.setattr("hermes_cli.config.load_config", fake_load_config) + + monkeypatch.setattr("hermes_cli.config.load_config_readonly", fake_load_config) monkeypatch.setattr("hermes_cli.config.get_compatible_custom_providers", fake_compatible) monkeypatch.setattr("hermes_cli.config.get_custom_provider_context_length", fake_provider_context) monkeypatch.setattr(AIAgent, "_ensure_lmstudio_runtime_loaded", fake_lmstudio_load) diff --git a/tests/run_agent/test_tool_call_guardrail_runtime.py b/tests/run_agent/test_tool_call_guardrail_runtime.py index 8d2d63591c2..51d3ea7594b 100644 --- a/tests/run_agent/test_tool_call_guardrail_runtime.py +++ b/tests/run_agent/test_tool_call_guardrail_runtime.py @@ -41,6 +41,7 @@ def _make_agent(*tool_names: str, max_iterations: int = 10, config: dict | None patch("run_agent.get_tool_definitions", return_value=_make_tool_defs(*tool_names)), patch("run_agent.check_toolset_requirements", return_value={}), patch("hermes_cli.config.load_config", return_value=config or {}), + patch("hermes_cli.config.load_config_readonly", return_value=config or {}), patch("run_agent.OpenAI"), ): agent = AIAgent(