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:
Teknium 2026-06-10 02:30:01 -07:00 committed by GitHub
parent 5d8c44a393
commit cd9a9cd8e5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 86 additions and 21 deletions

View file

@ -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

View file

@ -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)

View file

@ -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}",
},
},
{

View file

@ -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(

View file

@ -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,

View file

@ -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