test: pair load_config_readonly stubs at pruned-file sites (post-merge with main's readonly sweep)

Main's 243c9182b1/a16fd675df/7142dc4580 added load_config_readonly
sibling stubs across 38 files; our pruned versions of 11 of those files
kept only the load_config stubs. Re-applied the pairing at every
surviving site (26 patch()/setattr sites) — same return_value/
side_effect as the adjacent load_config stub. 494 tests green across
the 11 files.
This commit is contained in:
Teknium 2026-07-29 15:37:24 -07:00
commit 3913ac2ba0
No known key found for this signature in database
39 changed files with 221 additions and 82 deletions

View file

@ -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 = {}

View file

@ -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 {}

View file

@ -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 {}

View file

@ -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")

View file

@ -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(

View file

@ -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 {}

View file

@ -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)

View file

@ -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")

View file

@ -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

View file

@ -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)

View file

@ -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:

View file

@ -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

View file

@ -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()

View file

@ -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

View file

@ -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")

View file

@ -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):

View file

@ -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.

View file

@ -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",

View file

@ -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

View file

@ -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()

View file

@ -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(

View file

@ -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: {

View file

@ -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

View file

@ -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(

View file

@ -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):

View file

@ -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):

View file

@ -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"
)

View file

@ -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",

View file

@ -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)

View file

@ -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"),

View file

@ -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={}),

View file

@ -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=[]),

View file

@ -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=[]),

View file

@ -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={}),

View file

@ -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()),

View file

@ -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")

View file

@ -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(

View file

@ -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)

View file

@ -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(