Skip to content

Commit 4d7fc0f

Browse files
committed
feat(gateway,cli): confirm /reload-mcp to warn about prompt cache invalidation
Reloading MCP servers rebuilds the tool set for the active session, which invalidates the provider prompt cache (tool schemas are baked into the system prompt). The next message re-sends full input tokens — can be expensive on long-context or high-reasoning models. To surface that cost, /reload-mcp now routes through a new slash-confirm primitive with three options: Approve Once / Always Approve / Cancel. 'Always Approve' persists approvals.mcp_reload_confirm: false so future reloads run silently. Coverage: * Classic CLI (cli.py) — interactive numbered prompt. * TUI (tui_gateway + Ink ops.ts) — text warning on first call; `now` / `always` args skip the gate; `always` also persists the opt-out. * Messenger gateway — button UI on Telegram (inline keyboard), Discord (discord.ui.View), Slack (Block Kit actions); text fallback on every other platform via /approve /always /cancel replies intercepted in gateway/run.py _handle_message. * Config key: approvals.mcp_reload_confirm (default true). * Auto-reload paths (CLI file watcher, TUI config-sync mtime poll) pass confirm=true so they do NOT prompt. Implementation: * tools/slash_confirm.py — module-level pending-state store used by all adapters and by the CLI prompt. Thread-safe register/resolve/clear. * gateway/platforms/base.py — send_slash_confirm hook (default 'Not supported' → text fallback). * gateway/run.py — _request_slash_confirm helper + text intercept in _handle_message (yields to in-progress tool-exec approvals so dangerous-command /approve still unblocks the tool thread first). Tests: * tests/tools/test_slash_confirm.py — primitive lifecycle + async resolution + double-click atomicity (16 tests). * tests/hermes_cli/test_mcp_reload_confirm_gate.py — default-config shape + deep-merge preserves user opt-out (5 tests). Targeted runs (hermetic): 89 passed (slash-confirm, config gate, existing agent cache, existing telegram approval buttons).
1 parent 7fae87b commit 4d7fc0f

14 files changed

Lines changed: 1287 additions & 9 deletions

File tree

cli.py

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6286,8 +6286,10 @@ def process_command(self, command: str) -> bool:
62866286
count = reload_env()
62876287
print(f" Reloaded .env ({count} var(s) updated)")
62886288
elif canonical == "reload-mcp":
6289-
with self._busy_command(self._slow_command_status(cmd_original)):
6290-
self._reload_mcp()
6289+
# Interactive reload: confirm first (unless the user has opted out).
6290+
# The auto-reload path (file watcher) calls _reload_mcp directly
6291+
# without this confirmation.
6292+
self._confirm_and_reload_mcp(cmd_original)
62916293
elif canonical == "reload-skills":
62926294
with self._busy_command(self._slow_command_status(cmd_original)):
62936295
self._reload_skills()
@@ -7417,6 +7419,77 @@ def _check_config_mcp_changes(self) -> None:
74177419
if _reload_thread.is_alive():
74187420
print(" ⚠️ MCP reload timed out (30s). Some servers may not have reconnected.")
74197421

7422+
def _confirm_and_reload_mcp(self, cmd_original: str = "") -> None:
7423+
"""Interactive /reload-mcp — confirm with the user, then reload.
7424+
7425+
Reloading MCP tools invalidates the provider prompt cache for the
7426+
active session (tool schemas are baked into the system prompt).
7427+
The next message re-sends full input tokens — can be expensive on
7428+
long-context or high-reasoning models.
7429+
7430+
Three options: Approve Once, Always Approve (persists
7431+
``approvals.mcp_reload_confirm: false`` so future reloads run
7432+
without this prompt), Cancel. Gated by
7433+
``approvals.mcp_reload_confirm`` — default on.
7434+
"""
7435+
# Gate check — respects prior "Always Approve" clicks.
7436+
try:
7437+
cfg = load_cli_config()
7438+
approvals = cfg.get("approvals") if isinstance(cfg, dict) else None
7439+
confirm_required = True
7440+
if isinstance(approvals, dict):
7441+
confirm_required = bool(approvals.get("mcp_reload_confirm", True))
7442+
except Exception:
7443+
confirm_required = True
7444+
7445+
if not confirm_required:
7446+
with self._busy_command(self._slow_command_status(cmd_original)):
7447+
self._reload_mcp()
7448+
return
7449+
7450+
# Render warning + prompt. Use a single-line prompt so the user
7451+
# sees the warning as output and types a response into the composer.
7452+
print()
7453+
print("⚠️ /reload-mcp — Prompt cache invalidation warning")
7454+
print()
7455+
print(" Reloading MCP servers rebuilds the tool set for this session and")
7456+
print(" invalidates the provider prompt cache. The next message will")
7457+
print(" re-send full input tokens (can be expensive on long-context or")
7458+
print(" high-reasoning models).")
7459+
print()
7460+
print(" [1] Approve Once — reload now")
7461+
print(" [2] Always Approve — reload now and silence this prompt permanently")
7462+
print(" [3] Cancel — leave MCP tools unchanged")
7463+
print()
7464+
raw = self._prompt_text_input("Choice [1/2/3]: ")
7465+
if raw is None:
7466+
print("🟡 /reload-mcp cancelled (no input).")
7467+
return
7468+
choice_raw = raw.strip().lower()
7469+
if choice_raw in ("1", "once", "approve", "yes", "y", "ok"):
7470+
choice = "once"
7471+
elif choice_raw in ("2", "always", "remember"):
7472+
choice = "always"
7473+
elif choice_raw in ("3", "cancel", "nevermind", "no", "n", ""):
7474+
choice = "cancel"
7475+
else:
7476+
print(f"🟡 Unrecognized choice '{raw}'. /reload-mcp cancelled.")
7477+
return
7478+
7479+
if choice == "cancel":
7480+
print("🟡 /reload-mcp cancelled. MCP tools unchanged.")
7481+
return
7482+
7483+
if choice == "always":
7484+
if save_config_value("approvals.mcp_reload_confirm", False):
7485+
print("🔒 Future /reload-mcp calls will run without confirmation.")
7486+
print(" Re-enable via `approvals.mcp_reload_confirm: true` in config.yaml.")
7487+
else:
7488+
print("⚠️ Couldn't persist opt-out — reloading once.")
7489+
7490+
with self._busy_command(self._slow_command_status(cmd_original)):
7491+
self._reload_mcp()
7492+
74207493
def _reload_mcp(self):
74217494
"""Reload MCP servers: disconnect all, re-read config.yaml, reconnect.
74227495

gateway/platforms/base.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,6 +1415,41 @@ async def delete_message(
14151415
"""
14161416
return False
14171417

1418+
async def send_slash_confirm(
1419+
self,
1420+
chat_id: str,
1421+
title: str,
1422+
message: str,
1423+
session_key: str,
1424+
confirm_id: str,
1425+
metadata: Optional[Dict[str, Any]] = None,
1426+
) -> SendResult:
1427+
"""Send a three-option slash-command confirmation prompt.
1428+
1429+
Used by the gateway's generic slash-confirm primitive (see
1430+
``GatewayRunner._request_slash_confirm``) for commands that have a
1431+
non-destructive but expensive side effect the user should explicitly
1432+
acknowledge — the current caller is ``/reload-mcp``, which
1433+
invalidates the provider prompt cache.
1434+
1435+
Platforms with inline-button support (Telegram, Discord, Slack,
1436+
Matrix, Feishu) should override this to render three buttons:
1437+
Approve Once / Always Approve / Cancel. Button callbacks MUST be
1438+
routed back through the gateway by calling
1439+
``GatewayRunner._resolve_slash_confirm(confirm_id, choice)`` where
1440+
``choice`` is ``"once"`` / ``"always"`` / ``"cancel"``.
1441+
1442+
Platforms without button UIs leave this as the default and fall
1443+
through to the gateway's text fallback (which sends ``message`` as
1444+
plain text and intercepts the next ``/approve`` / ``/always`` /
1445+
``/cancel`` reply).
1446+
1447+
``confirm_id`` is a short string generated by the gateway; the
1448+
adapter stores it alongside any platform-specific state needed to
1449+
route the callback (e.g. Telegram's ``_approval_state`` dict).
1450+
"""
1451+
return SendResult(success=False, error="Not supported")
1452+
14181453
async def send_typing(self, chat_id: str, metadata=None) -> None:
14191454
"""
14201455
Send a typing indicator.

gateway/platforms/discord.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2910,6 +2910,43 @@ async def send_exec_approval(
29102910
except Exception as e:
29112911
return SendResult(success=False, error=str(e))
29122912

2913+
async def send_slash_confirm(
2914+
self, chat_id: str, title: str, message: str, session_key: str,
2915+
confirm_id: str, metadata: Optional[dict] = None,
2916+
) -> SendResult:
2917+
"""Send a three-button slash-command confirmation prompt."""
2918+
if not self._client or not DISCORD_AVAILABLE:
2919+
return SendResult(success=False, error="Not connected")
2920+
2921+
try:
2922+
target_id = chat_id
2923+
if metadata and metadata.get("thread_id"):
2924+
target_id = metadata["thread_id"]
2925+
2926+
channel = self._client.get_channel(int(target_id))
2927+
if not channel:
2928+
channel = await self._client.fetch_channel(int(target_id))
2929+
2930+
# Embed description limit is 4096; message usually fits easily.
2931+
max_desc = 4088
2932+
body = message if len(message) <= max_desc else message[: max_desc - 3] + "..."
2933+
embed = discord.Embed(
2934+
title=title or "Confirm",
2935+
description=body,
2936+
color=discord.Color.orange(),
2937+
)
2938+
2939+
view = SlashConfirmView(
2940+
session_key=session_key,
2941+
confirm_id=confirm_id,
2942+
allowed_user_ids=self._allowed_user_ids,
2943+
)
2944+
2945+
msg = await channel.send(embed=embed, view=view)
2946+
return SendResult(success=True, message_id=str(msg.id))
2947+
except Exception as e:
2948+
return SendResult(success=False, error=str(e))
2949+
29132950
async def send_update_prompt(
29142951
self, chat_id: str, prompt: str, default: str = "",
29152952
session_key: str = "",
@@ -3643,6 +3680,103 @@ async def on_timeout(self):
36433680
for child in self.children:
36443681
child.disabled = True
36453682

3683+
class SlashConfirmView(discord.ui.View):
3684+
"""Three-button view for generic slash-command confirmations.
3685+
3686+
Used by ``/reload-mcp`` and any future slash command routed through
3687+
``GatewayRunner._request_slash_confirm``. Buttons map to the
3688+
gateway's three choices:
3689+
3690+
* "Approve Once" → ``choice="once"``
3691+
* "Always Approve" → ``choice="always"``
3692+
* "Cancel" → ``choice="cancel"``
3693+
3694+
Clicking calls the module-level
3695+
``tools.slash_confirm.resolve(session_key, confirm_id, choice)``
3696+
which runs the handler the runner stored for this ``session_key``.
3697+
Only users in the adapter's allowlist can click. Times out after
3698+
5 minutes (matches the gateway primitive's timeout).
3699+
"""
3700+
3701+
def __init__(self, session_key: str, confirm_id: str, allowed_user_ids: set):
3702+
super().__init__(timeout=300)
3703+
self.session_key = session_key
3704+
self.confirm_id = confirm_id
3705+
self.allowed_user_ids = allowed_user_ids
3706+
self.resolved = False
3707+
3708+
def _check_auth(self, interaction: discord.Interaction) -> bool:
3709+
if not self.allowed_user_ids:
3710+
return True
3711+
return str(interaction.user.id) in self.allowed_user_ids
3712+
3713+
async def _resolve(
3714+
self, interaction: discord.Interaction, choice: str,
3715+
color: discord.Color, label: str,
3716+
):
3717+
if self.resolved:
3718+
await interaction.response.send_message(
3719+
"This prompt has already been resolved~", ephemeral=True,
3720+
)
3721+
return
3722+
if not self._check_auth(interaction):
3723+
await interaction.response.send_message(
3724+
"You're not authorized to answer this prompt~", ephemeral=True,
3725+
)
3726+
return
3727+
3728+
self.resolved = True
3729+
3730+
embed = interaction.message.embeds[0] if interaction.message.embeds else None
3731+
if embed:
3732+
embed.color = color
3733+
embed.set_footer(text=f"{label} by {interaction.user.display_name}")
3734+
3735+
for child in self.children:
3736+
child.disabled = True
3737+
3738+
await interaction.response.edit_message(embed=embed, view=self)
3739+
3740+
# Resolve via the module-level primitive. If the handler
3741+
# returns a follow-up message, post it in the same channel.
3742+
try:
3743+
from tools import slash_confirm as _slash_confirm_mod
3744+
result_text = await _slash_confirm_mod.resolve(
3745+
self.session_key, self.confirm_id, choice,
3746+
)
3747+
if result_text:
3748+
await interaction.followup.send(result_text)
3749+
logger.info(
3750+
"Discord button resolved slash-confirm for session %s "
3751+
"(choice=%s, user=%s)",
3752+
self.session_key, choice, interaction.user.display_name,
3753+
)
3754+
except Exception as exc:
3755+
logger.error("Discord slash-confirm resolve failed: %s", exc, exc_info=True)
3756+
3757+
@discord.ui.button(label="Approve Once", style=discord.ButtonStyle.green)
3758+
async def approve_once(
3759+
self, interaction: discord.Interaction, button: discord.ui.Button,
3760+
):
3761+
await self._resolve(interaction, "once", discord.Color.green(), "Approved once")
3762+
3763+
@discord.ui.button(label="Always Approve", style=discord.ButtonStyle.blurple)
3764+
async def approve_always(
3765+
self, interaction: discord.Interaction, button: discord.ui.Button,
3766+
):
3767+
await self._resolve(interaction, "always", discord.Color.purple(), "Always approved")
3768+
3769+
@discord.ui.button(label="Cancel", style=discord.ButtonStyle.red)
3770+
async def cancel(
3771+
self, interaction: discord.Interaction, button: discord.ui.Button,
3772+
):
3773+
await self._resolve(interaction, "cancel", discord.Color.greyple(), "Cancelled")
3774+
3775+
async def on_timeout(self):
3776+
self.resolved = True
3777+
for child in self.children:
3778+
child.disabled = True
3779+
36463780
class UpdatePromptView(discord.ui.View):
36473781
"""Interactive Yes/No buttons for ``hermes update`` prompts.
36483782

0 commit comments

Comments
 (0)