diff --git a/agent/transports/chat_completions.py b/agent/transports/chat_completions.py index f973644482..275a66db32 100644 --- a/agent/transports/chat_completions.py +++ b/agent/transports/chat_completions.py @@ -740,7 +740,7 @@ class ChatCompletionsTransport(ProviderTransport): api_kwargs, messages=sanitized, tools=api_kwargs.get("tools"), - supports_prompt_cache_key=bool(profile.supports_prompt_cache_key), + supports_prompt_cache_key=bool(getattr(profile, "supports_prompt_cache_key", False)), session_id=params.get("session_id"), ) diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index df1c1a31f3..5b810d04ab 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -86,8 +86,8 @@ def _reload_updated_runtime_modules() -> None: logger.debug("Could not refresh update runtime modules: %s", exc) -def _run_config_check_fresh() -> tuple: - """Check config version using freshly-reloaded modules. +def _reload_config_modules() -> None: + """Force-reload config modules from disk after git pull. ``hermes update`` runs in the PRE-pull Python process. After ``git pull`` updates the source files on disk, modules already in ``sys.modules`` @@ -96,9 +96,9 @@ def _run_config_check_fresh() -> tuple: ``check_config_version()`` reports ``(33, 33)`` — "up to date" — even though the freshly-pulled code has v34 with a migration to run. - This function force-reloads ``hermes_cli.config`` and - ``hermes_cli.config_migrations`` from disk so the version check reads - the UPDATED ``DEFAULT_CONFIG``. Returns ``(current_ver, latest_ver)``. + This function force-reloads ``hermes_cli.config_defaults``, + ``hermes_cli.config``, and ``hermes_cli.config_migrations`` from disk + so subsequent imports read the UPDATED code. """ import importlib @@ -111,6 +111,14 @@ def _run_config_check_fresh() -> tuple: except Exception as exc: logger.debug("Could not reload %s for fresh config check: %s", mod_name, exc) + +def _run_config_check_fresh() -> tuple: + """Check config version using freshly-reloaded modules. + + See ``_reload_config_modules`` for why this is necessary. + Returns ``(current_ver, latest_ver)``. + """ + _reload_config_modules() from hermes_cli.config import check_config_version return check_config_version() @@ -119,20 +127,10 @@ def _run_config_check_fresh() -> tuple: def _run_migrate_config_fresh(*, interactive: bool = False, quiet: bool = False) -> dict: """Run config migration using freshly-reloaded modules. - See ``_run_config_check_fresh`` for why this is necessary. + See ``_reload_config_modules`` for why this is necessary. Returns the migration results dict. """ - import importlib - - importlib.invalidate_caches() - for mod_name in ("hermes_cli.config_defaults", "hermes_cli.config", "hermes_cli.config_migrations"): - mod = sys.modules.get(mod_name) - if mod is not None: - try: - importlib.reload(mod) - except Exception as exc: - logger.debug("Could not reload %s for fresh migration: %s", mod_name, exc) - + _reload_config_modules() from hermes_cli.config import migrate_config return migrate_config(interactive=interactive, quiet=quiet) @@ -4759,6 +4757,10 @@ def _cmd_update_impl(args, gateway_mode: bool): print() print("→ Checking configuration for new options...") + # Reload config modules BEFORE any config reads so get_missing_*, + # check_config_version, and migrate_config all use the updated code. + _reload_config_modules() + from hermes_cli.config import ( get_missing_env_vars, get_missing_config_fields, diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index 895b45566d..d401d6d958 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -750,3 +750,29 @@ class TestPromptCacheKeyCapability: supports_prompt_cache_key=True, ) assert kw1["prompt_cache_key"] != kw2["prompt_cache_key"] + + def test_stale_profile_without_supports_prompt_cache_key_does_not_crash(self, transport): + """A ProviderProfile from a stale sys.modules cache (pre-#f4fb23f3d) + won't have the ``supports_prompt_cache_key`` field. Accessing it via + ``profile.supports_prompt_cache_key`` raises AttributeError and crashes + every API call. Use getattr with a False default so it degrades to + "no prompt cache key" instead of crashing. + + Regression: 'NousProfile' object has no attribute + 'supports_prompt_cache_key' (Aug 2026, after partial update). + """ + from providers.base import ProviderProfile + + # Simulate a stale class that predates supports_prompt_cache_key + # by creating a profile and deleting the attribute. + profile = ProviderProfile(name="stale-provider") + del profile.supports_prompt_cache_key + + # Must not raise AttributeError — should fall back to False. + kwargs = transport.build_kwargs( + model="stale-model", + messages=self._messages(), + tools=self._tools(), + provider_profile=profile, + ) + assert "prompt_cache_key" not in kwargs diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 8d9f6b608d..5386e85a47 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -244,6 +244,8 @@ class TestCmdUpdateBranchFallback: ), patch( "hermes_cli.config.get_missing_config_fields", return_value=[{"key": "new.option", "default": True}], + ), patch( + "hermes_cli.update_cmd._reload_config_modules" ), patch( "hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 2) ), patch( @@ -285,6 +287,8 @@ class TestCmdUpdateMigrationPrompt: "hermes_cli.config.get_missing_env_vars", return_value=[] ), patch( "hermes_cli.config.get_missing_config_fields", return_value=[] + ), patch( + "hermes_cli.update_cmd._reload_config_modules" ), patch( "hermes_cli.update_cmd._run_config_check_fresh", return_value=(5, 24) ), patch( @@ -322,9 +326,11 @@ class TestCmdUpdateMigrationPrompt: ), patch( "hermes_cli.config.get_missing_config_fields", return_value=cfg_items ), patch( - "hermes_cli.config.check_config_version", return_value=(1, 24) + "hermes_cli.update_cmd._reload_config_modules" ), patch( - "hermes_cli.config.migrate_config", + "hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 24) + ), patch( + "hermes_cli.update_cmd._run_migrate_config_fresh", return_value={"env_added": [], "config_added": [], "warnings": []}, ), patch("hermes_cli.main.sys") as mock_sys: mock_sys.stdin.isatty.return_value = True @@ -356,27 +362,21 @@ class TestConfigVersionCheckUsesFreshModules: """ def test_run_config_check_fresh_reloads_modules(self): - """_run_config_check_fresh must call importlib.reload on config modules.""" - import importlib - import sys + """_run_config_check_fresh must call _reload_config_modules which + force-reloads the config modules from disk. + + Regression: config migration was silently skipped because + sys.modules held the OLD hermes_cli.config with the OLD + DEFAULT_CONFIG["_config_version"] after git pull. + """ from unittest.mock import patch import hermes_cli.update_cmd as update_cmd - reloaded = [] - original_reload = importlib.reload - - def tracking_reload(mod, *args, **kwargs): - reloaded.append(mod.__name__) - return original_reload(mod, *args, **kwargs) - - with patch("importlib.reload", side_effect=tracking_reload): + with patch.object(update_cmd, "_reload_config_modules") as mock_reload: update_cmd._run_config_check_fresh() - # The config modules that hold DEFAULT_CONFIG and MIGRATIONS must - # have been reloaded — this is the fix for the stale-cache bug. - assert "hermes_cli.config" in reloaded - assert "hermes_cli.config_migrations" in reloaded + mock_reload.assert_called_once() class TestCmdUpdateProfileSkillSync: diff --git a/tests/hermes_cli/test_update_yes_flag.py b/tests/hermes_cli/test_update_yes_flag.py index 23f8ecec76..ef5a031859 100644 --- a/tests/hermes_cli/test_update_yes_flag.py +++ b/tests/hermes_cli/test_update_yes_flag.py @@ -50,6 +50,7 @@ def _make_run_side_effect( class TestUpdateYesConfigMigration: """--yes auto-answers the config-migration prompt and skips API-key prompts.""" + @patch("hermes_cli.update_cmd._reload_config_modules") @patch("hermes_cli.update_cmd._run_migrate_config_fresh") @patch("hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 2)) @patch("hermes_cli.config.get_missing_config_fields", return_value=[]) @@ -64,6 +65,7 @@ class TestUpdateYesConfigMigration: _mock_missing_cfg, _mock_version, mock_migrate, + _mock_reload, capsys, ): mock_run.side_effect = _make_run_side_effect( @@ -89,6 +91,7 @@ class TestUpdateYesConfigMigration: # The "Would you like to configure them now?" prompt text never appears. assert "Would you like to configure them now?" not in out + @patch("hermes_cli.update_cmd._reload_config_modules") @patch("hermes_cli.update_cmd._run_migrate_config_fresh") @patch("hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 2)) @patch("hermes_cli.config.get_missing_config_fields", return_value=[]) @@ -103,6 +106,7 @@ class TestUpdateYesConfigMigration: _mock_missing_cfg, _mock_version, mock_migrate, + _mock_reload, capsys, ): """Regression guard: without --yes, the TTY prompt path still fires.""" @@ -147,8 +151,9 @@ class TestUnicodeDecodeErrorInUpdatePrompts: the exception escape and crash `hermes update` mid-flight. """ - @patch("hermes_cli.config.migrate_config") - @patch("hermes_cli.config.check_config_version", return_value=(1, 2)) + @patch("hermes_cli.update_cmd._reload_config_modules") + @patch("hermes_cli.update_cmd._run_migrate_config_fresh") + @patch("hermes_cli.update_cmd._run_config_check_fresh", return_value=(1, 2)) @patch("hermes_cli.config.get_missing_config_fields", return_value=[]) @patch("hermes_cli.config.get_missing_env_vars", return_value=["NEW_KEY"]) @patch("shutil.which", return_value=None) @@ -161,6 +166,7 @@ class TestUnicodeDecodeErrorInUpdatePrompts: _mock_missing_cfg, _mock_version, mock_migrate, + _mock_reload, capsys, ): mock_run.side_effect = _make_run_side_effect(