mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
/clear, /new, /reset, and /undo now ask the user to confirm before discarding conversation state — three-option prompt routed through the existing tools.slash_confirm primitive. Native yes/no buttons render on Telegram, Discord, and Slack (their adapters already implement send_slash_confirm); other platforms get a text-fallback prompt and reply with /approve, /always, or /cancel. The classic prompt_toolkit CLI uses the same three-option flow via the established _prompt_text_input pattern (see _confirm_and_reload_mcp). TUI keeps its existing modal overlay (#12312). Gated by new config key approvals.destructive_slash_confirm (default true). Picking 'Always Approve' flips the gate to false so subsequent destructive commands run silently — matches the established mcp_reload_confirm UX. Out of scope: /cron remove (separate domain — scheduled jobs, not session history). Existing TUI overlay env-var (HERMES_TUI_NO_CONFIRM) left unchanged; cosmetic unification can come later. Closes #4069.
This commit is contained in:
parent
0cafe7d50d
commit
b9c001116e
9 changed files with 730 additions and 3 deletions
121
gateway/run.py
121
gateway/run.py
|
|
@ -5776,7 +5776,18 @@ class GatewayRunner:
|
|||
if canonical == "new":
|
||||
if self._is_telegram_topic_root_lobby(source):
|
||||
return self._telegram_topic_root_new_message()
|
||||
return await self._handle_reset_command(event)
|
||||
async def _do_reset():
|
||||
return await self._handle_reset_command(event)
|
||||
return await self._maybe_confirm_destructive_slash(
|
||||
event=event,
|
||||
command="new",
|
||||
title="/new",
|
||||
detail=(
|
||||
"This starts a fresh session and discards the current "
|
||||
"conversation history."
|
||||
),
|
||||
execute=_do_reset,
|
||||
)
|
||||
|
||||
if canonical == "topic":
|
||||
return await self._handle_topic_command(event)
|
||||
|
|
@ -5830,7 +5841,15 @@ class GatewayRunner:
|
|||
return await self._handle_retry_command(event)
|
||||
|
||||
if canonical == "undo":
|
||||
return await self._handle_undo_command(event)
|
||||
async def _do_undo():
|
||||
return await self._handle_undo_command(event)
|
||||
return await self._maybe_confirm_destructive_slash(
|
||||
event=event,
|
||||
command="undo",
|
||||
title="/undo",
|
||||
detail="This removes the last user/assistant exchange from history.",
|
||||
execute=_do_undo,
|
||||
)
|
||||
|
||||
if canonical == "sethome":
|
||||
return await self._handle_set_home_command(event)
|
||||
|
|
@ -11304,6 +11323,93 @@ class GatewayRunner:
|
|||
# /cancel; the early intercept in ``_handle_message`` matches
|
||||
# those replies against ``tools.slash_confirm.get_pending()``.
|
||||
|
||||
async def _maybe_confirm_destructive_slash(
|
||||
self,
|
||||
*,
|
||||
event: MessageEvent,
|
||||
command: str,
|
||||
title: str,
|
||||
detail: str,
|
||||
execute,
|
||||
) -> Union[str, "EphemeralReply", None]:
|
||||
"""Gate a destructive session slash command (/new, /reset, /undo).
|
||||
|
||||
``execute`` is an async callable ``execute() -> str | EphemeralReply``
|
||||
that performs the destructive action. If the
|
||||
``approvals.destructive_slash_confirm`` config gate is off, ``execute``
|
||||
runs immediately (returning its result). Otherwise this routes
|
||||
through ``_request_slash_confirm`` — native yes/no buttons on
|
||||
Telegram/Discord/Slack, text fallback elsewhere.
|
||||
|
||||
Three-option resolution:
|
||||
|
||||
- ``once`` — run ``execute`` and return its result
|
||||
- ``always`` — persist ``approvals.destructive_slash_confirm: false``,
|
||||
then run ``execute``
|
||||
- ``cancel`` — return a "cancelled" message; do not run ``execute``
|
||||
"""
|
||||
# Gate check.
|
||||
confirm_required = True
|
||||
try:
|
||||
cfg = self._read_user_config()
|
||||
approvals = cfg.get("approvals") if isinstance(cfg, dict) else None
|
||||
if isinstance(approvals, dict):
|
||||
confirm_required = bool(approvals.get("destructive_slash_confirm", True))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not confirm_required:
|
||||
return await execute()
|
||||
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
|
||||
async def _on_confirm(choice: str):
|
||||
if choice == "cancel":
|
||||
return f"🟡 /{command} cancelled. Conversation unchanged."
|
||||
if choice == "always":
|
||||
try:
|
||||
from cli import save_config_value
|
||||
save_config_value("approvals.destructive_slash_confirm", False)
|
||||
logger.info(
|
||||
"User opted out of destructive slash confirm (session=%s)",
|
||||
session_key,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Failed to persist destructive_slash_confirm=false: %s", exc,
|
||||
)
|
||||
result = await execute()
|
||||
if choice == "always":
|
||||
note = (
|
||||
"\n\nℹ️ Future /clear, /new, /reset, and /undo will run "
|
||||
"without confirmation. Re-enable via "
|
||||
"`approvals.destructive_slash_confirm: true` in config.yaml."
|
||||
)
|
||||
if isinstance(result, str):
|
||||
return result + note
|
||||
# EphemeralReply or other — leave untouched; the opt-out note
|
||||
# would otherwise mangle structured replies. The persist itself
|
||||
# already happened above; user gets the same UX next time.
|
||||
return result
|
||||
return result
|
||||
|
||||
prompt_message = (
|
||||
f"⚠️ **Confirm /{command}**\n\n"
|
||||
f"{detail}\n\n"
|
||||
"Choose:\n"
|
||||
"• **Approve Once** — proceed this time only\n"
|
||||
"• **Always Approve** — proceed and silence this prompt permanently\n"
|
||||
"• **Cancel** — keep current conversation\n\n"
|
||||
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
|
||||
)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
command=command,
|
||||
title=title,
|
||||
message=prompt_message,
|
||||
handler=_on_confirm,
|
||||
)
|
||||
|
||||
async def _request_slash_confirm(
|
||||
self,
|
||||
*,
|
||||
|
|
@ -11329,7 +11435,16 @@ class GatewayRunner:
|
|||
|
||||
source = event.source
|
||||
session_key = self._session_key_for_source(source)
|
||||
confirm_id = f"{next(self._slash_confirm_counter)}"
|
||||
# Bare-runner test harnesses (object.__new__(GatewayRunner)) skip
|
||||
# __init__ and don't have the counter attribute — fall back to a
|
||||
# local counter so tests don't AttributeError. Real runs always
|
||||
# have the instance attribute.
|
||||
counter = getattr(self, "_slash_confirm_counter", None)
|
||||
if counter is None:
|
||||
import itertools as _itertools
|
||||
counter = _itertools.count(1)
|
||||
self._slash_confirm_counter = counter
|
||||
confirm_id = f"{next(counter)}"
|
||||
|
||||
# Register the pending confirm FIRST so a super-fast button click
|
||||
# cannot race the send_slash_confirm return.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue