diff --git a/gateway/config.py b/gateway/config.py index 2e0e3276b7b2f..cb82ec95bf510 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -745,6 +745,8 @@ def load_gateway_config() -> GatewayConfig: bridged["reply_in_thread"] = platform_cfg["reply_in_thread"] if "require_mention" in platform_cfg: bridged["require_mention"] = platform_cfg["require_mention"] + if "strict_mention" in platform_cfg: + bridged["strict_mention"] = platform_cfg["strict_mention"] if "free_response_channels" in platform_cfg: bridged["free_response_channels"] = platform_cfg["free_response_channels"] if "mention_patterns" in platform_cfg: @@ -789,6 +791,8 @@ def load_gateway_config() -> GatewayConfig: os.environ["SLACK_REQUIRE_MENTION"] = str(slack_cfg["require_mention"]).lower() if "strict_mention" in slack_cfg and not os.getenv("SLACK_STRICT_MENTION"): os.environ["SLACK_STRICT_MENTION"] = str(slack_cfg["strict_mention"]).lower() + if "mention_patterns" in slack_cfg and not os.getenv("SLACK_MENTION_PATTERNS"): + os.environ["SLACK_MENTION_PATTERNS"] = json.dumps(slack_cfg["mention_patterns"]) if "allow_bots" in slack_cfg and not os.getenv("SLACK_ALLOW_BOTS"): os.environ["SLACK_ALLOW_BOTS"] = str(slack_cfg["allow_bots"]).lower() frc = slack_cfg.get("free_response_channels") diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index c8ee28859d4a2..fc195a8264afc 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -327,6 +327,7 @@ def __init__(self, config: PlatformConfig): # (channel_id, user_id) to avoid cross-user collisions. # Each value: {"response_url": str, "ts": float} self._slash_command_contexts: Dict[Tuple[str, str], Dict[str, Any]] = {} + self._mention_patterns = self._compile_mention_patterns() def _describe_slack_api_error(self, response: Any, *, file_obj: Optional[Dict[str, Any]] = None) -> Optional[str]: """Convert Slack API auth/permission failures into actionable user-facing text.""" @@ -1883,6 +1884,8 @@ async def _handle_slack_message(self, event: dict) -> None: bot_uid = self._team_bot_user_ids.get(team_id, self._bot_user_id) routing_text = original_text or "" is_mentioned = bot_uid and f"<@{bot_uid}>" in routing_text + matches_name_call = self._message_matches_mention_patterns(routing_text) + explicit_call = bool(is_mentioned or matches_name_call) event_thread_ts = event.get("thread_ts") is_thread_reply = bool(event_thread_ts and event_thread_ts != ts) @@ -1891,9 +1894,20 @@ async def _handle_slack_message(self, event: dict) -> None: pass # Free-response channel — always process elif not self._slack_require_mention(): pass # Mention requirement disabled globally for Slack - elif self._slack_strict_mention() and not is_mentioned: - return # Strict mode: ignore until @-mentioned again - elif not is_mentioned: + elif self._slack_strict_mention() and not explicit_call: + if not ( + is_thread_reply + and not self._mentions_other_slack_user(routing_text, bot_uid) + and await self._previous_thread_message_is_self_bot( + channel_id=channel_id, + thread_ts=event_thread_ts, + current_ts=ts, + team_id=team_id, + current_user_id=user_id, + ) + ): + return # Strict mode: ignore until explicitly called or directly followed up + elif not explicit_call: reply_to_bot_thread = ( is_thread_reply and event_thread_ts in self._bot_message_ts ) @@ -1912,9 +1926,12 @@ async def _handle_slack_message(self, event: dict) -> None: if not reply_to_bot_thread and not in_mentioned_thread and not has_session: return - if is_mentioned: - # Strip the bot mention from the text - text = text.replace(f"<@{bot_uid}>", "").strip() + if explicit_call: + # Strip explicit Slack mentions and configured plain-text name calls. + if is_mentioned: + text = text.replace(f"<@{bot_uid}>", "").strip() + if matches_name_call: + text = self._clean_mention_pattern_text(text) # Register this thread so all future messages auto-trigger the bot. # Skipped in strict mode: strict_mention=true bots must be # re-mentioned every turn, so remembering the thread would @@ -2149,7 +2166,7 @@ async def _handle_slack_message(self, event: dict) -> None: # Only react when bot is directly addressed (DM or @mention). # In listen-all channels (require_mention=false), reacting to every # casual message would be noisy. - _should_react = (is_dm or is_mentioned) and self._reactions_enabled() + _should_react = (is_dm or explicit_call) and self._reactions_enabled() if _should_react: self._reacting_message_ids.add(ts) @@ -2713,8 +2730,15 @@ async def _handle_slash_command(self, command: dict) -> None: text = "/help" else: # Native slash — / [args]. Route directly through the - # gateway command dispatcher by prepending the slash. - text = f"/{slash_name} {text}".strip() + # gateway command dispatcher by prepending the slash. Some Slack + # native slash names are compatibility aliases for Hermes commands + # (e.g. /status is a generic name that may collide in a workspace, + # so the manifest exposes /hermes-status and routes it back to + # Hermes /status here). + from hermes_cli.commands import slack_native_route + + routed_command = slack_native_route(slash_name) + text = f"{routed_command} {text}".strip() # Slack slash commands can originate from DMs or shared channels. # Preserve DM semantics only for DM channel IDs; shared channels must @@ -2907,6 +2931,128 @@ def _slack_strict_mention(self) -> bool: return bool(configured) return os.getenv("SLACK_STRICT_MENTION", "false").lower() in ("true", "1", "yes", "on") + def _compile_mention_patterns(self) -> List[re.Pattern]: + """Compile configured plain-text mention patterns for Slack routing.""" + raw = self.config.extra.get("mention_patterns") + if raw is None: + env_raw = os.getenv("SLACK_MENTION_PATTERNS", "").strip() + if env_raw: + try: + parsed = json.loads(env_raw) + raw = parsed if isinstance(parsed, list) else [str(parsed)] + except json.JSONDecodeError: + raw = [part.strip() for part in env_raw.split(",") if part.strip()] + + if isinstance(raw, str): + raw_patterns = [raw] + elif isinstance(raw, list): + raw_patterns = [str(pattern) for pattern in raw if str(pattern).strip()] + else: + raw_patterns = [] + + compiled: List[re.Pattern] = [] + for pattern in raw_patterns: + try: + compiled.append(re.compile(pattern)) + except re.error as exc: + logger.warning("Invalid Slack mention pattern %r: %s", pattern, exc) + return compiled + + def _message_matches_mention_patterns(self, text: str) -> bool: + """Return whether text contains a configured plain-text name call.""" + if not text or not self._mention_patterns: + return False + return any(pattern.search(text) for pattern in self._mention_patterns) + + def _clean_mention_pattern_text(self, text: str) -> str: + """Remove configured plain-text name calls from text before dispatch.""" + cleaned = text + for pattern in self._mention_patterns: + cleaned = pattern.sub("", cleaned, count=1).strip() + return cleaned + + def _mentions_other_slack_user(self, text: str, bot_uid: Optional[str]) -> bool: + """Return whether text explicitly mentions a different Slack user.""" + if not text: + return False + for mentioned_uid in re.findall(r"<@([A-Z0-9]+)>", text): + if not bot_uid or mentioned_uid != bot_uid: + return True + return False + + async def _previous_thread_message_is_self_bot( + self, + channel_id: str, + thread_ts: str, + current_ts: str, + team_id: str = "", + current_user_id: str = "", + ) -> bool: + """Return whether the current user is directly following this bot. + + This keeps strict-mode follow-up narrow: a user can answer directly + after the bot, but older bot participation does not keep the thread + auto-engaged. In multi-user threads, a different user must explicitly + call the bot instead of being routed by someone else's bot exchange. + """ + if not channel_id or not thread_ts or not current_ts: + return False + + try: + client = self._get_client(channel_id) + result = await client.conversations_replies( + channel=channel_id, + ts=thread_ts, + latest=current_ts, + inclusive=False, + limit=100, + ) + messages = result.get("messages", []) if result else [] + previous = None + for msg in messages: + msg_ts = msg.get("ts", "") + if msg_ts and msg_ts < current_ts: + if previous is None or msg_ts > previous.get("ts", ""): + previous = msg + if not previous: + return False + + previous_ts = previous.get("ts", "") + msg_team = previous.get("team") or team_id + self_bot_uid = ( + self._team_bot_user_ids.get(msg_team) + if msg_team + else None + ) or self._bot_user_id + previous_is_self_bot = bool( + (previous_ts and previous_ts in self._bot_message_ts) + or (self_bot_uid and previous.get("user") == self_bot_uid) + ) + if not previous_is_self_bot: + return False + + if current_user_id: + prior_user = None + for msg in messages: + msg_ts = msg.get("ts", "") + msg_user = msg.get("user") + if ( + msg_ts + and previous_ts + and msg_ts < previous_ts + and msg_user + and msg_user != self_bot_uid + ): + if prior_user is None or msg_ts > prior_user.get("ts", ""): + prior_user = msg + if not prior_user or prior_user.get("user") != current_user_id: + return False + + return True + except Exception as exc: + logger.debug("[Slack] Failed to inspect previous thread message: %s", exc) + return False + def _slack_free_response_channels(self) -> set: """Return channel IDs where no @mention is required.""" raw = self.config.extra.get("free_response_channels") diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index 2cf2c3e9f400a..09eb8bdad6298 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -931,6 +931,15 @@ def discord_skill_commands_by_category( "topic", "mute", "pro", "shortcuts", }) +# Some short/generic native Slack slash names are prone to workspace-level +# collisions with commands from other installed apps or custom integrations. +# Keep the Hermes command name unchanged everywhere else, but expose a +# Slack-specific native slash name in the app manifest and route it back to the +# original Hermes command when Slack sends the event. +_SLACK_NATIVE_NAME_OVERRIDES: dict[str, str] = { + "status": "hermes-status", +} + def _sanitize_slack_name(raw: str) -> str: """Convert a command name to a valid Slack slash command name. @@ -944,6 +953,26 @@ def _sanitize_slack_name(raw: str) -> str: return name[:_SLACK_NAME_LIMIT] +def _slack_native_name(raw: str) -> str: + """Return the Slack-exposed slash name for a Hermes command name.""" + normalized = _sanitize_slack_name(raw) + return _SLACK_NATIVE_NAME_OVERRIDES.get(normalized, normalized) + + +def slack_native_route(slash_name: str) -> str: + """Map a Slack native slash name back to the Hermes command text. + + Most native names route to themselves (``/btw`` -> ``/btw``), but names + rewritten for Slack compatibility route back to the original Hermes command + (``/hermes-status`` -> ``/status``). + """ + normalized = _sanitize_slack_name(slash_name) + for hermes_name, native_name in _SLACK_NATIVE_NAME_OVERRIDES.items(): + if normalized == native_name: + return f"/{hermes_name}" + return f"/{normalized}" + + def slack_native_slashes() -> list[tuple[str, str, str]]: """Return (slash_name, description, usage_hint) triples for Slack. @@ -974,7 +1003,7 @@ def slack_native_slashes() -> list[tuple[str, str, str]]: seen.add("hermes") def _add(name: str, desc: str, hint: str) -> None: - slack_name = _sanitize_slack_name(name) + slack_name = _slack_native_name(name) if not slack_name or slack_name in seen: return if slack_name in _SLACK_RESERVED_COMMANDS: diff --git a/run_agent.py b/run_agent.py index 919a5875b65ad..c30e9fe505157 100644 --- a/run_agent.py +++ b/run_agent.py @@ -13772,13 +13772,34 @@ def _stop_spinner(): self._empty_content_retries += 1 logger.warning( "Empty response (no content or reasoning) — " - "retry %d/3 (model=%s)", + "nudging retry %d/3 (model=%s)", self._empty_content_retries, self.model, ) self._emit_status( - f"⚠️ Empty response from model — retrying " + f"⚠️ Empty response from model — nudging retry " f"({self._empty_content_retries}/3)" ) + # Keep the transcript alternation valid and give the + # next request an explicit recovery instruction. A + # bare retry often reproduces provider-side empty + # responses; the nudge asks for a short visible + # answer without changing the user's task. + _nudge_msg = self._build_assistant_message( + assistant_message, finish_reason + ) + _nudge_msg["content"] = "(empty)" + messages.append(_nudge_msg) + messages.append({ + "role": "user", + "content": ( + "Your previous response had no visible " + "content. Reply now with a brief, direct " + "answer to the user's last message. If the " + "message is casual or low-context, still " + "provide a short acknowledgement rather than " + "returning empty." + ), + }) continue # ── Exhausted retries — try fallback provider ── diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 42f1902db8613..6f72852aafe01 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -2599,6 +2599,12 @@ def test_truly_empty_response_retries_3_times_then_empty(self, agent): assert result["completed"] is True assert result["final_response"] == "(empty)" assert result["api_calls"] == 4 # 1 original + 3 retries + nudge_messages = [ + m for m in result["messages"] + if m.get("role") == "user" + and "Reply now with a brief, direct answer" in m.get("content", "") + ] + assert len(nudge_messages) == 3 def test_truly_empty_response_succeeds_on_nudge(self, agent): """Model produces content after being nudged for empty response.""" @@ -2620,6 +2626,11 @@ def test_truly_empty_response_succeeds_on_nudge(self, agent): assert result["completed"] is True assert result["final_response"] == "Here is the actual answer." assert result["api_calls"] == 2 # 1 original + 1 nudge retry + assert any( + m.get("role") == "user" + and "Reply now with a brief, direct answer" in m.get("content", "") + for m in result["messages"] + ) def test_empty_response_triggers_fallback_provider(self, agent): """After 3 empty retries, fallback provider is activated and produces content."""