mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
feat(mcp): add opt-out for automatic MCP reload on config change (cache-safe)
The automatic MCP reload added in #1474 watches config.yaml's mcp_servers section every 5s and reloads on any change. Every reload rebuilds the agent tool surface and INVALIDATES the provider prompt cache — the next message re-sends the full input prefix, which is expensive on long-context / high-reasoning models. When config.yaml is rewritten frequently (external tooling, multiple Hermes instances, or a flapping MCP server that rewrites config), this causes silent, repeated cache-breaking reloads. Add `mcp.auto_reload_on_config_change` (default: true, backward compatible). When set to false: - The config change is still DETECTED (watcher keeps running). - No automatic reload happens. - The user is told the config changed, that new settings are NOT yet applied, and how to apply them on their own terms with /reload-mcp — including the explicit warning that /reload-mcp invalidates the prompt cache. Manual /reload-mcp is unaffected and still works for users who want to apply changes deliberately. Tests: extend TestMCPConfigWatch with test_optout_disables_auto_reload. Co-authored-by: Turgut Kural <turgut.kural@gmail.com>
This commit is contained in:
parent
f46ae96963
commit
5c2d098bb0
3 changed files with 88 additions and 5 deletions
46
cli.py
46
cli.py
|
|
@ -9992,13 +9992,26 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
print(f" Error generating insights: {e}")
|
||||
|
||||
def _check_config_mcp_changes(self) -> None:
|
||||
"""Detect mcp_servers changes in config.yaml and auto-reload MCP connections.
|
||||
"""Detect mcp_servers changes in config.yaml and react.
|
||||
|
||||
Called from process_loop every CONFIG_WATCH_INTERVAL seconds.
|
||||
Compares config.yaml mtime + mcp_servers section against the last
|
||||
known state. When a change is detected, triggers _reload_mcp() and
|
||||
informs the user so they know the tool list has been refreshed.
|
||||
known state. When a change is detected:
|
||||
|
||||
* By default (``mcp.auto_reload_on_config_change: true``) it
|
||||
auto-triggers ``_reload_mcp()`` and informs the user — legacy
|
||||
behaviour from #1474.
|
||||
* When opted out (``mcp.auto_reload_on_config_change: false``) it
|
||||
does NOT reload. Instead it notifies the user that the config
|
||||
changed and that they can apply it with ``/reload-mcp`` — while
|
||||
warning that ``/reload-mcp`` rebuilds the tool surface and
|
||||
**invalidates the provider prompt cache** (the next message
|
||||
re-sends the full input prefix, expensive on long-context /
|
||||
high-reasoning models). This stops silent cache-breaking reloads
|
||||
when config.yaml is rewritten frequently by external tooling or
|
||||
other Hermes instances.
|
||||
"""
|
||||
|
||||
import yaml as _yaml
|
||||
|
||||
CONFIG_WATCH_INTERVAL = 5.0 # seconds between config.yaml stat() calls
|
||||
|
|
@ -10042,7 +10055,34 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
if new_mcp == self._config_mcp_servers:
|
||||
return # mcp_servers unchanged (some other section was edited)
|
||||
|
||||
# Detected a change in the mcp_servers section. By default we
|
||||
# auto-reload (legacy behaviour), but if the user has opted out we
|
||||
# notify instead of reloading — because every reload rebuilds the
|
||||
# agent tool surface and INVALIDATES the provider prompt cache (the
|
||||
# next message re-sends the full input prefix, which is expensive on
|
||||
# long-context / high-reasoning models).
|
||||
try:
|
||||
from hermes_cli.config import load_config as _load_cfg
|
||||
_cfg = _load_cfg()
|
||||
_mcp = _cfg.get("mcp") if isinstance(_cfg, dict) else None
|
||||
_auto = _mcp.get("auto_reload_on_config_change", True) if isinstance(_mcp, dict) else True
|
||||
except Exception:
|
||||
_auto = True
|
||||
|
||||
self._config_mcp_servers = new_mcp
|
||||
|
||||
if not _auto:
|
||||
# Notify the user that the config changed but do NOT auto-reload.
|
||||
# They can apply the new settings on their own terms with
|
||||
# /reload-mcp — which we explicitly warn may invalidate the cache.
|
||||
print()
|
||||
print("🔄 MCP server config changed — reload skipped (auto-reload disabled).")
|
||||
print(" New settings are NOT applied yet. To apply them now, run:")
|
||||
print(" /reload-mcp")
|
||||
print(" ⚠️ Note: /reload-mcp rebuilds the tool set and invalidates the")
|
||||
print(" provider prompt cache (next message re-sends full input tokens).")
|
||||
return
|
||||
|
||||
# Notify user and reload. Run in a separate thread with a hard
|
||||
# timeout so a hung MCP server cannot block the process_loop
|
||||
# indefinitely (which would freeze the entire TUI).
|
||||
|
|
|
|||
|
|
@ -1670,6 +1670,14 @@ DEFAULT_CONFIG = {
|
|||
"timeout": 30,
|
||||
"extra_body": {},
|
||||
"reasoning_effort": "", # per-task thinking level: none|minimal|low|medium|high|xhigh|max|ultra (empty = provider default)
|
||||
# Auto-reload MCP connections when config.yaml's mcp_servers section
|
||||
# changes at runtime (default on, matches pre-#1474 behaviour).
|
||||
# Set to False to stop the automatic reload: every automatic reload
|
||||
# rebuilds the agent tool surface and INVALIDATES the provider
|
||||
# prompt cache (the next message re-sends the full input prefix),
|
||||
# which is expensive on long-context / high-reasoning models.
|
||||
# MCP servers can still be reloaded manually via /reload-mcp.
|
||||
"auto_reload_on_config_change": True,
|
||||
},
|
||||
"title_generation": {
|
||||
"enabled": True,
|
||||
|
|
|
|||
|
|
@ -4,11 +4,14 @@ from pathlib import Path
|
|||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_cli(tmp_path, mcp_servers=None):
|
||||
def _make_cli(tmp_path, mcp_servers=None, extra_config=None):
|
||||
"""Create a minimal HermesCLI instance with mocked config."""
|
||||
import cli as cli_mod
|
||||
obj = object.__new__(cli_mod.HermesCLI)
|
||||
obj.config = {"mcp_servers": mcp_servers or {}}
|
||||
cfg = {"mcp_servers": mcp_servers or {}}
|
||||
if extra_config:
|
||||
cfg.update(extra_config)
|
||||
obj.config = cfg
|
||||
obj._agent_running = False
|
||||
obj._last_config_check = 0.0
|
||||
obj._config_mcp_servers = mcp_servers or {}
|
||||
|
|
@ -101,3 +104,35 @@ class TestMCPConfigWatch:
|
|||
obj._check_config_mcp_changes() # should not raise
|
||||
|
||||
obj._reload_mcp.assert_not_called()
|
||||
|
||||
def test_optout_disables_auto_reload(self, tmp_path, capsys):
|
||||
"""When mcp.auto_reload_on_config_change is False, a changed
|
||||
mcp_servers section must NOT trigger an automatic reload — but the
|
||||
change is still detected and the user is told how to apply it.
|
||||
|
||||
This protects the provider prompt cache: every automatic reload
|
||||
rebuilds the agent tool surface and invalidates cached prefixes.
|
||||
"""
|
||||
import yaml
|
||||
obj, cfg_file = _make_cli(
|
||||
tmp_path,
|
||||
mcp_servers={},
|
||||
)
|
||||
|
||||
# Simulate a changed mcp_servers section
|
||||
cfg_file.write_text(yaml.dump({"mcp_servers": {"github": {"url": "https://mcp.github.com"}}}))
|
||||
obj._config_mtime = 0.0 # force stale mtime
|
||||
|
||||
# Opt out via the loaded config (the watcher reads load_config(),
|
||||
# not obj.config, so we patch the loader).
|
||||
mocked_cfg = {"mcp": {"auto_reload_on_config_change": False}}
|
||||
with patch("hermes_cli.config.get_config_path", return_value=cfg_file), \
|
||||
patch("hermes_cli.config.load_config", return_value=mocked_cfg):
|
||||
obj._check_config_mcp_changes()
|
||||
|
||||
obj._reload_mcp.assert_not_called()
|
||||
|
||||
out = capsys.readouterr().out
|
||||
assert "reload skipped" in out
|
||||
assert "/reload-mcp" in out
|
||||
assert "prompt cache" in out
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue