From df51ad797332a547aa9589ee3cb11353f204f8db Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:06:35 -0700 Subject: [PATCH] perf(config): mtime-cache load_config() and read_raw_config() (#17041) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_config() and read_raw_config() now cache their result keyed on the config file's (mtime_ns, size). On cache hit they return a deepcopy of the cached value, skipping yaml.safe_load + deep-merge + normalize + env-var expansion entirely. 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 needed. Measured per-call cost: load_config() cold: 13.3 ms load_config() cached: 0.23 ms (57x faster) read_raw_config() cached: 0.13 ms A single gateway turn hits the config 5-15 times (session context, auxiliary client resolution, memory config, plugin hooks, approval lookups, per-tool settings). That's 65-200 ms/turn of pure YAML re-parsing on main. After this change: 1-3 ms/turn. Also migrates gateway/run.py's 6 direct yaml.safe_load(config.yaml) call sites through _load_gateway_config, which now shares the read_raw_config cache when _hermes_home agrees with the canonical config path. The direct-read fallback is retained for tests that monkeypatch gateway_run._hermes_home without touching HERMES_HOME. Safety: - load_config() returns a deepcopy on every call; the 67+ call sites that mutate the result (cfg["model"]["default"] = ..., etc.) can't corrupt the cache. - save_config() / atomic_yaml_write bump mtime, naturally invalidating the cache for the next reader. - Cache is keyed on str(config_path), so HERMES_HOME profile switches don't collide. Verified: - 112 config tests pass (test_config, test_config_env_expansion, test_config_env_refs, test_config_drift, test_config_validation, test_aux_config). - 87 gateway tests pass (test_verbose_command, test_session_info, test_compress_focus, test_runtime_footer, test_resume_command, test_reasoning_command, test_approve_deny_commands, test_run_progress_interrupt). - Live hermes chat smoke — 2 turns + /model switch + tool calls, zero errors in agent.log. Co-authored-by: teknium1 --- gateway/run.py | 68 ++++++++++++++++++----------------------- hermes_cli/config.py | 73 ++++++++++++++++++++++++++++++++++++++------ 2 files changed, 93 insertions(+), 48 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index ff22395882..9126beb5c0 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 a3b4d63381..7291bfe330 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