mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-11 08:42:11 +00:00
fix(gateway): Slack approval UX in threads — block-size overflow + typed-prefix instruction text (#43444)
Two fixes for the reported Slack thread approval UX: 1. Slack Block Kit approval/confirm sends silently overflowed the 3000-char section-block cap (flat 2900-char truncation + header + reason), so long execute_code approvals failed with invalid_blocks and fell back to the plain-text prompt with no buttons. Budget the command preview against the rendered fixed parts so blocks never exceed the cap (send_exec_approval + send_slash_confirm). 2. The text fallbacks told users to reply /approve — which Slack blocks inside threads and Matrix clients reserve client-side. Add a typed_command_prefix capability flag on BasePlatformAdapter (default "/"; Slack and Matrix set "!" to match their existing bang-prefix rewrite) and use it in the shared fallback prompt builders (exec approval, update prompt, destructive slash confirm, expensive-model confirm) plus Matrix's reaction-prompt text. The slash-confirm text-intercept now also accepts bang-prefixed replies (!always, !cancel) since those keywords aren't registered commands and the adapters' rewrite doesn't touch them.
This commit is contained in:
parent
5d8c44a393
commit
cd9a9cd8e5
6 changed files with 86 additions and 21 deletions
|
|
@ -1804,6 +1804,18 @@ class BasePlatformAdapter(ABC):
|
|||
# preview (see gateway/run.py progress_callback).
|
||||
supports_code_blocks: bool = False
|
||||
|
||||
# The command prefix users can always TYPE on this platform to reach
|
||||
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
|
||||
# as plain message text). Platforms where typing a leading "/" is
|
||||
# intercepted or restricted by the client (Slack blocks native slash
|
||||
# commands inside threads; Matrix clients reserve "/" for client-local
|
||||
# commands) ship a "!" alias rewrite in their adapter and set this to
|
||||
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
|
||||
# users the form that actually works everywhere. Capability flag —
|
||||
# shared prompt builders read it via getattr(adapter,
|
||||
# "typed_command_prefix", "/"); no per-platform branching at call sites.
|
||||
typed_command_prefix: str = "/"
|
||||
|
||||
def __init__(self, config: PlatformConfig, platform: Platform):
|
||||
self.config = config
|
||||
self.platform = platform
|
||||
|
|
|
|||
|
|
@ -422,6 +422,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
|
||||
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
|
||||
|
||||
# Matrix clients commonly reserve typed "/" for client-local commands;
|
||||
# the adapter accepts "!command" as the alias that always reaches Hermes
|
||||
# (see _normalize_matrix_bang_command), so instruction text shows "!".
|
||||
typed_command_prefix = "!"
|
||||
|
||||
# Threshold for detecting Matrix client-side message splits.
|
||||
# When a chunk is near the ~4000-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
|
|
@ -1350,11 +1355,11 @@ class MatrixAdapter(BasePlatformAdapter):
|
|||
"⚠️ **Dangerous command requires approval**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {description}\n\n"
|
||||
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
|
||||
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
|
||||
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
|
||||
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
|
||||
"You can also click the reaction to approve:\n"
|
||||
"✅ = /approve\n"
|
||||
"❎ = /deny"
|
||||
"✅ = approve\n"
|
||||
"❎ = deny"
|
||||
)
|
||||
|
||||
result = await self.send(chat_id, text, metadata=metadata)
|
||||
|
|
|
|||
|
|
@ -318,6 +318,11 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
|
||||
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
|
||||
# Slack blocks typed native slash commands inside threads ("/approve is
|
||||
# not supported in threads. Sorry!"). The adapter rewrites a leading
|
||||
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
|
||||
# the prefix that works everywhere — instruction text must show it.
|
||||
typed_command_prefix = "!"
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.SLACK)
|
||||
|
|
@ -2692,19 +2697,26 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
|
||||
# Slack hard-caps a section block's text at 3000 chars; an
|
||||
# oversized block fails the whole send with ``invalid_blocks``
|
||||
# and the gateway falls back to the plain-text prompt (no
|
||||
# buttons). execute_code approvals embed the entire script in
|
||||
# ``command``, so budget the preview against the fixed parts
|
||||
# instead of a flat truncation that overflows once the header +
|
||||
# reason are added.
|
||||
header = ":warning: *Command Approval Required*\n"
|
||||
reason = f"Reason: {description[:500]}"
|
||||
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
|
||||
cmd_preview = command[:budget] + "..." if len(command) > budget else command
|
||||
|
||||
blocks = [
|
||||
{
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": (
|
||||
f":warning: *Command Approval Required*\n"
|
||||
f"```{cmd_preview}```\n"
|
||||
f"Reason: {description}"
|
||||
),
|
||||
"text": f"{header}```{cmd_preview}```\n{reason}",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
@ -2772,8 +2784,13 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
return SendResult(success=False, error="Not connected")
|
||||
|
||||
try:
|
||||
body = message[:2900] + "..." if len(message) > 2900 else message
|
||||
thread_ts = self._resolve_thread_ts(None, metadata)
|
||||
# Same 3000-char section-block cap as send_exec_approval: budget
|
||||
# the body against the rendered title so the wrapper never pushes
|
||||
# the block over the limit (overflow → invalid_blocks → no buttons).
|
||||
_title = (title or "Confirm")[:150]
|
||||
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
|
||||
body = message[:budget] + "..." if len(message) > budget else message
|
||||
# Encode session_key and confirm_id into the button value so the
|
||||
# callback handler can resolve without extra bookkeeping.
|
||||
value = f"{session_key}|{confirm_id}"
|
||||
|
|
@ -2783,7 +2800,7 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{title or 'Confirm'}*\n\n{body}",
|
||||
"text": f"*{_title}*\n\n{body}",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -6473,6 +6473,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_tool_approval_live = False
|
||||
if _pending_confirm and not _tool_approval_live:
|
||||
_raw_reply = (event.text or "").strip()
|
||||
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim.
|
||||
# Slack/Matrix instruction text shows the `!` prefix (typed `/`
|
||||
# is blocked in Slack threads), but the adapters only rewrite
|
||||
# `!<known-command>` — `always`/`cancel` are confirm keywords,
|
||||
# not registered commands, so the `!` survives to here.
|
||||
_norm_reply = _raw_reply.lstrip("!/").lower()
|
||||
_cmd_reply = event.get_command()
|
||||
_confirm_choice = None
|
||||
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
|
||||
|
|
@ -6481,11 +6487,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
_confirm_choice = "always"
|
||||
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
|
||||
_confirm_choice = "cancel"
|
||||
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
|
||||
elif _norm_reply in {"approve", "approve once", "once"}:
|
||||
_confirm_choice = "once"
|
||||
elif _raw_reply.lower() in {"always", "always approve"}:
|
||||
elif _norm_reply in {"always", "always approve"}:
|
||||
_confirm_choice = "always"
|
||||
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
|
||||
elif _norm_reply in {"cancel", "nevermind", "no"}:
|
||||
_confirm_choice = "cancel"
|
||||
if _confirm_choice is not None:
|
||||
_resolved = await _slash_confirm_mod.resolve(
|
||||
|
|
@ -10628,6 +10634,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
return result
|
||||
return result
|
||||
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
prompt_message = (
|
||||
f"⚠️ **Confirm /{command}**\n\n"
|
||||
f"{detail}\n\n"
|
||||
|
|
@ -10635,7 +10642,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
"• **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`._"
|
||||
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
|
||||
)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
|
|
@ -11030,11 +11037,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
logger.debug("Button-based update prompt failed: %s", btn_err)
|
||||
if not sent_buttons:
|
||||
default_hint = f" (default: {default})" if default else ""
|
||||
_p = getattr(adapter, "typed_command_prefix", "/")
|
||||
await adapter.send(
|
||||
chat_id,
|
||||
f"⚕ **Update needs your input:**\n\n"
|
||||
f"{prompt_text}{default_hint}\n\n"
|
||||
f"Reply `/approve` (yes) or `/deny` (no), "
|
||||
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
|
||||
f"or type your answer directly.",
|
||||
metadata=metadata,
|
||||
)
|
||||
|
|
@ -14101,14 +14109,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
|||
"Button-based approval failed, falling back to text: %s", _e
|
||||
)
|
||||
|
||||
# Fallback: plain text approval prompt
|
||||
# Fallback: plain text approval prompt. Use the adapter's
|
||||
# typed prefix so Slack/Matrix users are told the form they
|
||||
# can actually type (`!approve`) — typed "/" is blocked in
|
||||
# Slack threads and reserved by Matrix clients.
|
||||
_p = getattr(_status_adapter, "typed_command_prefix", "/")
|
||||
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
|
||||
msg = (
|
||||
f"⚠️ **Dangerous command requires approval:**\n"
|
||||
f"```\n{cmd_preview}\n```\n"
|
||||
f"Reason: {desc}\n\n"
|
||||
f"Reply `/approve` to execute, `/approve session` to approve this pattern "
|
||||
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
|
||||
f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern "
|
||||
f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel."
|
||||
)
|
||||
try:
|
||||
_approval_send_fut = safe_schedule_threadsafe(
|
||||
|
|
|
|||
|
|
@ -47,6 +47,19 @@ logger = logging.getLogger("gateway.run")
|
|||
class GatewaySlashCommandsMixin:
|
||||
"""In-session slash-command handlers for GatewayRunner."""
|
||||
|
||||
def _typed_command_prefix_for(self, platform) -> str:
|
||||
"""Return the prefix users can always type to reach Hermes commands.
|
||||
|
||||
Reads the adapter's ``typed_command_prefix`` capability flag
|
||||
(default "/"). Slack and Matrix return "!" because typed "/"
|
||||
commands are blocked in Slack threads / reserved by Matrix clients;
|
||||
their adapters rewrite "!command" to "/command" on receive.
|
||||
Instruction text built for those platforms must show the prefix
|
||||
that actually works when typed.
|
||||
"""
|
||||
adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
|
||||
return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
|
||||
|
||||
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
|
||||
"""Handle /new or /reset command."""
|
||||
source = event.source
|
||||
|
|
@ -1324,13 +1337,14 @@ class GatewaySlashCommandsMixin:
|
|||
# an explicit decision).
|
||||
return await _finish_switch()
|
||||
|
||||
_p = self._typed_command_prefix_for(event.source.platform)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
command="model",
|
||||
title="Expensive Model Warning",
|
||||
message=(
|
||||
f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
|
||||
"_Text fallback: reply `/approve` to switch or `/cancel` to keep "
|
||||
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
|
||||
"the current model._"
|
||||
),
|
||||
handler=_on_cost_confirm,
|
||||
|
|
|
|||
|
|
@ -280,6 +280,11 @@ thread.
|
|||
Only the first token is checked against the known command list, so
|
||||
casual messages like `!nice work` pass through to the agent unchanged.
|
||||
|
||||
Approval prompts (dangerous command / `execute_code` approval) normally
|
||||
render as interactive buttons. When buttons can't be delivered and
|
||||
Hermes falls back to a text prompt, the prompt instructs you to reply
|
||||
with `!approve` / `!deny` — the form that works inside threads.
|
||||
|
||||
### Advanced: emit only the slash-commands array
|
||||
|
||||
If you maintain your Slack manifest by hand and just want the slash
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue