diff --git a/gateway/run.py b/gateway/run.py index ff223958822..9126beb5c0f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -643,15 +643,31 @@ def _platform_config_key(platform: "Platform") -> str: def _load_gateway_config() -> dict: - """Load and parse ~/.hermes/config.yaml, returning {} on any error.""" + """Load and parse ~/.hermes/config.yaml, returning {} on any error. + + Uses the module-level ``_hermes_home`` (so tests that monkeypatch it + still see their fixture) and shares the mtime-keyed raw-yaml cache + from ``hermes_cli.config.read_raw_config`` when the paths match. + """ + config_path = _hermes_home / 'config.yaml' + try: + from hermes_cli.config import get_config_path, read_raw_config + # Fast path: if _hermes_home agrees with the canonical config + # location, reuse the shared cache. Otherwise fall through to a + # direct read (keeps test fixtures with a monkeypatched + # _hermes_home working). + if config_path == get_config_path(): + return read_raw_config() + except Exception: + pass + try: - config_path = _hermes_home / 'config.yaml' if config_path.exists(): import yaml with open(config_path, 'r', encoding='utf-8') as f: return yaml.safe_load(f) or {} except Exception: - logger.debug("Could not load gateway config from %s", _hermes_home / 'config.yaml') + logger.debug("Could not load gateway config from %s", config_path) return {} @@ -4585,9 +4601,7 @@ class GatewayRunner: # Read privacy.redact_pii from config (re-read per message) _redact_pii = False try: - import yaml as _pii_yaml - with open(_config_path, encoding="utf-8") as _pf: - _pcfg = _pii_yaml.safe_load(_pf) or {} + _pcfg = _load_gateway_config() _redact_pii = bool((_pcfg.get("privacy") or {}).get("redact_pii", False)) except Exception: pass @@ -4737,12 +4751,8 @@ class GatewayRunner: _hyg_api_key = None _hyg_data = {} try: - _hyg_cfg_path = _hermes_home / "config.yaml" - if _hyg_cfg_path.exists(): - import yaml as _hyg_yaml - with open(_hyg_cfg_path, encoding="utf-8") as _hyg_f: - _hyg_data = _hyg_yaml.safe_load(_hyg_f) or {} - + _hyg_data = _load_gateway_config() + if _hyg_data: # Resolve model name (same logic as run_sync) _model_cfg = _hyg_data.get("model", {}) if isinstance(_model_cfg, str): @@ -5513,11 +5523,8 @@ class GatewayRunner: custom_provs = None try: - cfg_path = _hermes_home / "config.yaml" - if cfg_path.exists(): - import yaml as _info_yaml - with open(cfg_path, encoding="utf-8") as f: - data = _info_yaml.safe_load(f) or {} + data = _load_gateway_config() + if data: model_cfg = data.get("model", {}) if isinstance(model_cfg, dict): raw_ctx = model_cfg.get("context_length") @@ -6116,9 +6123,8 @@ class GatewayRunner: custom_provs = None config_path = _hermes_home / "config.yaml" try: - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - cfg = yaml.safe_load(f) or {} + cfg = _load_gateway_config() + if cfg: model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): current_model = model_cfg.get("default", "") @@ -6423,20 +6429,14 @@ class GatewayRunner: async def _handle_personality_command(self, event: MessageEvent) -> str: """Handle /personality command - list or set a personality.""" - import yaml from hermes_constants import display_hermes_home args = event.get_command_args().strip().lower() config_path = _hermes_home / 'config.yaml' try: - if config_path.exists(): - with open(config_path, 'r', encoding="utf-8") as f: - config = yaml.safe_load(f) or {} - personalities = config.get("agent", {}).get("personalities", {}) - else: - config = {} - personalities = {} + config = _load_gateway_config() + personalities = config.get("agent", {}).get("personalities", {}) if config else {} except Exception: config = {} personalities = {} @@ -7430,17 +7430,13 @@ class GatewayRunner: ``display.platforms..tool_progress`` so each channel can have its own verbosity level independently. """ - import yaml config_path = _hermes_home / "config.yaml" platform_key = _platform_config_key(event.source.platform) # --- check config gate ------------------------------------------------ try: - user_config = {} - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - user_config = yaml.safe_load(f) or {} + user_config = _load_gateway_config() gate_enabled = user_config.get("display", {}).get("tool_progress_command", False) except Exception: gate_enabled = False @@ -7502,7 +7498,6 @@ class GatewayRunner: are respected but not modified here — edit config.yaml directly for per-platform control. """ - import yaml from gateway.runtime_footer import resolve_footer_config config_path = _hermes_home / "config.yaml" @@ -7520,11 +7515,8 @@ class GatewayRunner: arg = "" # --- load config ---------------------------------------------------- - user_config: dict = {} try: - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - user_config = yaml.safe_load(f) or {} + user_config: dict = _load_gateway_config() except Exception as e: return f"⚠️ Could not read config.yaml: {e}" diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a3b4d633817..7291bfe330d 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -30,6 +30,18 @@ logger = logging.getLogger(__name__) _IS_WINDOWS = platform.system() == "Windows" _ENV_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _LAST_EXPANDED_CONFIG_BY_PATH: Dict[str, Any] = {} +# (path, mtime_ns, size) -> cached expanded config dict. +# load_config() returns a deepcopy of the cached value when the file +# hasn't changed since the last load, skipping yaml.safe_load + +# _deep_merge + _normalize_* + _expand_env_vars (~13 ms/call). +# save_config() + migrate_config() write via atomic_yaml_write which +# produces a fresh inode, so stat() sees a new mtime_ns and the next +# load repopulates automatically — no explicit invalidation hook. +_LOAD_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} +# (path, mtime_ns, size) -> cached raw yaml dict. Same pattern as +# _LOAD_CONFIG_CACHE but for read_raw_config() — used when callers want +# the user's on-disk values without defaults merged in. +_RAW_CONFIG_CACHE: Dict[str, Tuple[int, int, Dict[str, Any]]] = {} # Env var names written to .env that aren't in OPTIONAL_ENV_VARS # (managed by setup/provider flows directly). _EXTRA_ENV_KEYS = frozenset({ @@ -3420,25 +3432,62 @@ def read_raw_config() -> Dict[str, Any]: be parsed. Use this for lightweight config reads where you just need a single value and don't want the overhead of ``load_config()``'s deep-merge + migration pipeline. + + Cached on the config file's (mtime_ns, size) — same strategy as + ``load_config()``. Returns a deepcopy on every call since some callers + mutate the result before passing to ``save_config()``. """ try: config_path = get_config_path() - if config_path.exists(): - with open(config_path, encoding="utf-8") as f: - return yaml.safe_load(f) or {} + st = config_path.stat() + cache_key = (st.st_mtime_ns, st.st_size) + except (FileNotFoundError, OSError): + return {} + + path_key = str(config_path) + cached = _RAW_CONFIG_CACHE.get(path_key) + if cached is not None and cached[:2] == cache_key: + return copy.deepcopy(cached[2]) + + try: + with open(config_path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} except Exception: - pass - return {} + return {} + + if not isinstance(data, dict): + data = {} + _RAW_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(data)) + return data def load_config() -> Dict[str, Any]: - """Load configuration from ~/.hermes/config.yaml.""" + """Load configuration from ~/.hermes/config.yaml. + + Cached on the config file's (mtime_ns, size). Returns a deepcopy of + the cached value when unchanged, since most call sites mutate the + result (e.g. ``cfg["model"]["default"] = ...`` before ``save_config``). + The cache is keyed on ``str(config_path)`` so profile switches + (which change ``HERMES_HOME`` and therefore ``get_config_path()``) + don't collide. + """ ensure_hermes_home() config_path = get_config_path() - + path_key = str(config_path) + + try: + st = config_path.stat() + cache_key: Optional[Tuple[int, int]] = (st.st_mtime_ns, st.st_size) + except FileNotFoundError: + cache_key = None + + cached = _LOAD_CONFIG_CACHE.get(path_key) + if cached is not None and cache_key is not None and cached[:2] == cache_key: + return copy.deepcopy(cached[2]) + config = copy.deepcopy(DEFAULT_CONFIG) - - if config_path.exists(): + + if cache_key is not None: try: with open(config_path, encoding="utf-8") as f: user_config = yaml.safe_load(f) or {} @@ -3456,7 +3505,11 @@ def load_config() -> Dict[str, Any]: normalized = _normalize_root_model_keys(_normalize_max_turns_config(config)) expanded = _expand_env_vars(normalized) - _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(expanded) + _LAST_EXPANDED_CONFIG_BY_PATH[path_key] = copy.deepcopy(expanded) + if cache_key is not None: + _LOAD_CONFIG_CACHE[path_key] = (cache_key[0], cache_key[1], copy.deepcopy(expanded)) + else: + _LOAD_CONFIG_CACHE.pop(path_key, None) return expanded