Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down
164 changes: 155 additions & 9 deletions gateway/platforms/slack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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)

Expand All @@ -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
)
Expand All @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -2713,8 +2730,15 @@ async def _handle_slash_command(self, command: dict) -> None:
text = "/help"
else:
# Native slash β€” /<slash_name> [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
Expand Down Expand Up @@ -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")
Expand Down
31 changes: 30 additions & 1 deletion hermes_cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down Expand Up @@ -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:
Expand Down
25 changes: 23 additions & 2 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ──
Expand Down
11 changes: 11 additions & 0 deletions tests/run_agent/test_run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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."""
Expand Down