diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index 1a73ab5af0..540e8b4e96 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -16,7 +16,7 @@ from agent.interrupt_compat import _accepts_keyword from gateway.config import Platform from gateway.session import SessionSource, build_session_context_prompt from gateway.run_shutdown import _log_suppressed -from hermes_cli.config import cfg_get +from hermes_cli.config import DEFAULT_CONFIG, cfg_get from hermes_cli.local_runtime.endpoint import LLAMACPP_ALIASES if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) @@ -48,24 +48,20 @@ class GatewayAgentCacheMixin: @classmethod def _extract_cache_busting_config(cls, user_config: dict | None) -> dict: """Values that must bust the cached agent, as a flat dict keyed by 'section.key'. ``user_config`` - is the raw file (no DEFAULT_CONFIG merge), so an absent key is filled from DEFAULT_CONFIG — the - value the agent was actually built with. Mapping 'absent' to None made an explicit ``null`` - (the documented opt-out of a non-None default such as ``compression.threshold_tokens``) - signature-identical to 'unset', so the opt-out never rebuilt a live session. Non-dict sections - count as absent. Includes the live tool registry generation: MCP reloads mutate the registry - without touching config.yaml.""" - from hermes_cli.config import DEFAULT_CONFIG + is the raw file (no DEFAULT_CONFIG merge), so absent keys and non-dict sections take the + DEFAULT_CONFIG value — what the agent was actually built with — while an explicit ``null`` stays + None so opting out of a non-None default still rebuilds. Includes the live tool registry + generation: MCP reloads mutate the registry without touching config.yaml.""" out: Dict[str, Any] = {} cfg = user_config if isinstance(user_config, dict) else {} for section, key in cls._CACHE_BUSTING_CONFIG_KEYS: + default = cfg_get(DEFAULT_CONFIG, section, key) section_val = cfg.get(section) if section == "checkpoints" and isinstance(section_val, bool): # Legacy ``checkpoints: true``: a live toggle must still rebuild the cached agent. - out[f"{section}.{key}"] = section_val if key == "enabled" else None - elif isinstance(section_val, dict) and key in section_val: - out[f"{section}.{key}"] = section_val[key] + out[f"{section}.{key}"] = section_val if key == "enabled" else default else: - out[f"{section}.{key}"] = cfg_get(DEFAULT_CONFIG, section, key) + out[f"{section}.{key}"] = cfg_get(cfg, section, key, default=default) try: from tools.registry import registry out["tools.registry_generation"] = getattr(registry, "_generation", None) diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index c9aae23321..14c57c3f45 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -13,6 +13,7 @@ import threading from unittest.mock import MagicMock, patch import pytest +from hermes_cli.config import DEFAULT_CONFIG, cfg_get from tools import browser_tool_lifecycle as bt_lifecycle @@ -187,38 +188,38 @@ class TestExtractCacheBustingConfig: def test_missing_keys_yield_the_shipped_default(self): - """An absent key carries the value in force — DEFAULT_CONFIG's — so the signature reflects - what the agent was built with, not the raw file's shape. Every documented key is present.""" + """An absent key carries the value in force — DEFAULT_CONFIG's — for every documented key.""" from gateway.run import GatewayRunner - from hermes_cli.config import DEFAULT_CONFIG, cfg_get out = GatewayRunner._extract_cache_busting_config({}) for section, key in GatewayRunner._CACHE_BUSTING_CONFIG_KEYS: - assert f"{section}.{key}" in out assert out[f"{section}.{key}"] == cfg_get(DEFAULT_CONFIG, section, key) def test_explicit_null_differs_from_absent_when_default_is_set(self): - """`threshold_tokens: null` is the documented opt-out of the shipped 256K cap. A raw-file read - that mapped 'absent' to None made the opt-out signature-identical to 'unset', so the cached - agent kept the cap until a restart. Absent must equal the explicit default, not explicit null.""" + """`threshold_tokens: null` opts out of the shipped cap; the signature must keep it distinct from + 'absent' (= the default) so the opt-out rebuilds the cached agent instead of waiting for a restart.""" from gateway.run import GatewayRunner - from hermes_cli.config import DEFAULT_CONFIG default_cap = DEFAULT_CONFIG["compression"]["threshold_tokens"] assert default_cap is not None # the premise: a non-None default whose opt-out is null - absent = GatewayRunner._extract_cache_busting_config({})["compression.threshold_tokens"] - explicit_default = GatewayRunner._extract_cache_busting_config( - {"compression": {"threshold_tokens": default_cap}})["compression.threshold_tokens"] - opted_out = GatewayRunner._extract_cache_busting_config( - {"compression": {"threshold_tokens": None}})["compression.threshold_tokens"] - assert absent == explicit_default == default_cap - assert opted_out is None and opted_out != absent + sig = lambda cfg: GatewayRunner._extract_cache_busting_config(cfg)["compression.threshold_tokens"] # noqa: E731 + assert sig({}) == sig({"compression": {"threshold_tokens": default_cap}}) == default_cap + assert sig({"compression": {"threshold_tokens": None}}) is None + + def test_legacy_checkpoints_bool_carries_defaults_for_the_other_keys(self): + """`checkpoints: true` builds the agent with DEFAULT_CONFIG's limits (`_checkpoint_agent_kwargs`), so + migrating to `checkpoints: {enabled: true}` must not change the signature.""" + from gateway.run import GatewayRunner + + legacy = GatewayRunner._extract_cache_busting_config({"checkpoints": True}) + explicit = GatewayRunner._extract_cache_busting_config({"checkpoints": {"enabled": True}}) + assert legacy["checkpoints.enabled"] is True + assert {k: v for k, v in legacy.items() if k.startswith("checkpoints.")} == { + k: v for k, v in explicit.items() if k.startswith("checkpoints.")} def test_non_dict_section_treated_as_missing(self): from gateway.run import GatewayRunner - from hermes_cli.config import DEFAULT_CONFIG - # compression is a string — should not crash; compression.* keys fall back to the shipped defaults out = GatewayRunner._extract_cache_busting_config( {"compression": "broken", "model": {"context_length": 100_000}} @@ -230,8 +231,6 @@ class TestExtractCacheBustingConfig: def test_none_config_is_safe(self): from gateway.run import GatewayRunner - from hermes_cli.config import DEFAULT_CONFIG, cfg_get - out = GatewayRunner._extract_cache_busting_config(None) for section, key in GatewayRunner._CACHE_BUSTING_CONFIG_KEYS: assert out[f"{section}.{key}"] == cfg_get(DEFAULT_CONFIG, section, key)