mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
fix(slack): humanize inbound user mentions + ground bot identity
Slack delivers user mentions as opaque IDs (<@U123>). The agent had no way to tell one participant from another — or from itself — so it could misread a mention of a human as a self-mention and answer messages addressed to that person (the "bot thinks it's @someone-else" bug). Two cooperating fixes: - _humanize_user_mentions rewrites remaining <@UID> tokens (the bot's own mention is stripped earlier) to @DisplayName in the trigger text and reply_to_text — the Slack equivalent of Discord's clean_content. Handles the labelled <@UID|handle> form; unresolvable IDs fall back to the raw ID. - _build_identity_prompt injects an ephemeral per-turn system-prompt line via the channel_prompt seam (applied at API-call time, never persisted — prompt caching preserved) naming the bot's own workspace handle (per-team in multi-workspace installs) so the agent has a positive "that's me" anchor. Salvaged from #55340 by @benbarclay, rebased over the workspace-scoped user-name cache (team_id-aware resolution) on main.
This commit is contained in:
parent
503c0c0e51
commit
5d747a91c4
2 changed files with 258 additions and 0 deletions
|
|
@ -667,6 +667,10 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
self._app: Optional[Any] = None
|
||||
self._handler: Optional[Any] = None
|
||||
self._bot_user_id: Optional[str] = None
|
||||
# Bot identity per workspace, used to ground the agent ("you are @X on
|
||||
# Slack") so it never mistakes a human's mention for a self-mention.
|
||||
self._bot_display_name: Optional[str] = None # primary workspace bot name
|
||||
self._team_bot_names: Dict[str, str] = {} # team_id → bot display name
|
||||
# Slack user IDs are workspace-local. Cache names by workspace as well
|
||||
# so a multi-workspace Socket Mode process never reuses another
|
||||
# tenant's display name.
|
||||
|
|
@ -1418,6 +1422,8 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
self._bot_user_id = None
|
||||
self._team_clients = {}
|
||||
self._team_bot_user_ids = {}
|
||||
self._bot_display_name = None
|
||||
self._team_bot_names = {}
|
||||
|
||||
# First token is the primary — used for AsyncApp / Socket Mode
|
||||
primary_token = bot_tokens[0]
|
||||
|
|
@ -1436,12 +1442,15 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
|
||||
self._team_clients[team_id] = client
|
||||
self._team_bot_user_ids[team_id] = bot_user_id
|
||||
self._team_bot_names[team_id] = bot_name
|
||||
|
||||
# First token always wins as the primary bot user id; we
|
||||
# cleared ``_bot_user_id`` above so this picks up the current
|
||||
# token's identity even on reconnect.
|
||||
if self._bot_user_id is None:
|
||||
self._bot_user_id = bot_user_id
|
||||
if self._bot_display_name is None:
|
||||
self._bot_display_name = bot_name
|
||||
|
||||
logger.info(
|
||||
"[Slack] Authenticated as @%s in workspace %s (team: %s)",
|
||||
|
|
@ -2730,6 +2739,71 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
self._user_name_cache[cache_key] = user_id
|
||||
return user_id
|
||||
|
||||
async def _humanize_user_mentions(
|
||||
self, text: str, chat_id: str = "", team_id: str = ""
|
||||
) -> str:
|
||||
"""Replace raw ``<@UID>`` user-mention tokens with ``@DisplayName``.
|
||||
|
||||
Slack delivers mentions as opaque IDs (``<@U123>``). Without this, the
|
||||
agent sees ``<@U123>`` and has no way to tell one participant from
|
||||
another — or from itself — which makes it misread a mention of a human
|
||||
as a mention of the bot and reply to messages addressed to that person
|
||||
(the "bot thinks it's @someone-else" bug). Discord avoids this entirely
|
||||
by feeding the agent ``message.clean_content`` (IDs already rendered as
|
||||
names); this is the Slack equivalent.
|
||||
|
||||
The bot's own mention is stripped separately before this runs, so any
|
||||
tokens left here are other participants. Names are resolved via the
|
||||
cached :meth:`_resolve_user_name`, so repeated tokens cost one
|
||||
``users.info`` lookup per distinct user per process.
|
||||
"""
|
||||
if not text or "<@" not in text:
|
||||
return text
|
||||
# Capture the bare user ID inside <@...>; Slack IDs are alnum (U…/W…),
|
||||
# optionally carrying a label like <@U123|alice> — keep only the ID.
|
||||
ids = set(re.findall(r"<@([A-Z0-9]+)(?:\|[^>]*)?>", text))
|
||||
if not ids:
|
||||
return text
|
||||
for uid in ids:
|
||||
name = await self._resolve_user_name(
|
||||
uid, chat_id=chat_id, team_id=team_id
|
||||
)
|
||||
# Fall back to the raw ID if resolution yields nothing usable
|
||||
# (keeps the message intact rather than emptying a mention).
|
||||
display = (name or uid).strip() or uid
|
||||
# Replace both the bare and labelled forms of this exact ID.
|
||||
text = re.sub(rf"<@{uid}(?:\|[^>]*)?>", f"@{display}", text)
|
||||
return text
|
||||
|
||||
def _build_identity_prompt(self, team_id: str = "") -> str:
|
||||
"""Return an ephemeral system-prompt line grounding the bot's identity.
|
||||
|
||||
Injected via the per-turn ``channel_prompt`` seam (applied at API-call
|
||||
time, never persisted to history — so it does NOT break per-conversation
|
||||
prompt caching). Tells the agent its own Slack handle so it can
|
||||
distinguish a mention OF ITSELF from a mention of another participant
|
||||
whose name happens to resemble its own — the failure in the reported
|
||||
bug, where the bot saw a human's mention and claimed it was the one
|
||||
being addressed. Inbound mentions are rendered as ``@DisplayName``
|
||||
(see :meth:`_humanize_user_mentions`), so naming the bot's own display
|
||||
name here gives the agent a positive anchor for "that's me."
|
||||
"""
|
||||
name = (
|
||||
(team_id and self._team_bot_names.get(team_id))
|
||||
or self._bot_display_name
|
||||
or ""
|
||||
).strip()
|
||||
if not name:
|
||||
return ""
|
||||
return (
|
||||
f"You are connected to this Slack workspace as the bot "
|
||||
f'"@{name}". In messages, each line is prefixed with the sender\'s '
|
||||
f"name, and mentions are shown as @DisplayName. Only treat a "
|
||||
f'message as directed at you when it mentions "@{name}" '
|
||||
f"specifically; a mention of any other participant is not a "
|
||||
f"mention of you, even if their name is similar."
|
||||
)
|
||||
|
||||
async def send_image_file(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
|
@ -4388,6 +4462,17 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
channel_id,
|
||||
None,
|
||||
)
|
||||
# Prepend the bot's Slack identity (ephemeral — applied at API-call
|
||||
# time, never persisted, so prompt caching is preserved) so the agent
|
||||
# knows its own handle and won't read a human's mention as a self-
|
||||
# mention. Combine with any per-channel prompt rather than overwriting.
|
||||
_identity_prompt = self._build_identity_prompt(team_id)
|
||||
if _identity_prompt:
|
||||
_channel_prompt = (
|
||||
f"{_identity_prompt}\n\n{_channel_prompt}".strip()
|
||||
if _channel_prompt
|
||||
else _identity_prompt
|
||||
)
|
||||
_auto_skill = resolve_channel_skills(
|
||||
self.config.extra,
|
||||
channel_id,
|
||||
|
|
@ -4410,9 +4495,24 @@ class SlackAdapter(BasePlatformAdapter):
|
|||
)
|
||||
or None
|
||||
)
|
||||
if reply_to_text:
|
||||
reply_to_text = await self._humanize_user_mentions(
|
||||
reply_to_text, chat_id=channel_id, team_id=team_id
|
||||
)
|
||||
except Exception: # pragma: no cover - defensive
|
||||
reply_to_text = None
|
||||
|
||||
# Humanize remaining user mentions: the bot's own mention was already
|
||||
# stripped above, so any ``<@UID>`` left in the trigger text refers to
|
||||
# OTHER participants. Render them as ``@DisplayName`` so the agent can
|
||||
# tell who is being addressed and never mistakes a human's mention for
|
||||
# a mention of itself (the "bot thinks it's @someone-else" bug).
|
||||
# Mirrors Discord's clean_content. channel_context (thread backfill)
|
||||
# already renders senders by display name via _format_thread_context.
|
||||
text = await self._humanize_user_mentions(
|
||||
text, chat_id=channel_id, team_id=team_id
|
||||
)
|
||||
|
||||
msg_event = MessageEvent(
|
||||
text=text,
|
||||
message_type=msg_type,
|
||||
|
|
|
|||
158
tests/gateway/test_slack_mention_humanization.py
Normal file
158
tests/gateway/test_slack_mention_humanization.py
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
"""
|
||||
Tests for Slack inbound mention humanization + bot identity grounding.
|
||||
|
||||
Slack delivers user mentions as opaque IDs (``<@U123>``). Passing those to the
|
||||
agent raw leaves it unable to tell one participant from another — or from
|
||||
itself — so it can misread a mention of a human as a self-mention and reply to
|
||||
messages addressed to that person (the reported "bot thinks it's @someone-else"
|
||||
bug). Two cooperating fixes:
|
||||
|
||||
* ``_humanize_user_mentions`` rewrites ``<@UID>`` → ``@DisplayName`` (the
|
||||
Slack equivalent of Discord's ``clean_content``).
|
||||
* ``_build_identity_prompt`` returns an ephemeral system-prompt line naming
|
||||
the bot's own Slack handle so the agent has a positive "that's me" anchor.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock slack-bolt if not installed (same pattern as test_slack_mention.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_slack_mock():
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler",
|
||||
slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
sys.modules.setdefault("aiohttp", MagicMock())
|
||||
|
||||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
# object.__new__ skips __init__ (heavy setup) — established slack-test pattern.
|
||||
return object.__new__(SlackAdapter)
|
||||
|
||||
|
||||
def _adapter_with_names(names):
|
||||
"""Adapter whose _resolve_user_name returns from a fixed UID→name map."""
|
||||
adapter = _make_adapter()
|
||||
|
||||
async def _resolve(user_id, chat_id="", team_id=""):
|
||||
return names.get(user_id, user_id)
|
||||
|
||||
adapter._resolve_user_name = _resolve # type: ignore[assignment]
|
||||
return adapter
|
||||
|
||||
|
||||
# ----- _humanize_user_mentions -------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_humanizes_single_mention():
|
||||
adapter = _adapter_with_names({"U07ALICE": "Alice Example"})
|
||||
out = await adapter._humanize_user_mentions(
|
||||
"<@U07ALICE> I think thread is prob the right default", chat_id="C1"
|
||||
)
|
||||
assert out == "@Alice Example I think thread is prob the right default"
|
||||
assert "<@" not in out
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_humanizes_multiple_distinct_mentions():
|
||||
adapter = _adapter_with_names(
|
||||
{"U07ALICE": "Alice Example", "U07BOB": "Bob Example"}
|
||||
)
|
||||
out = await adapter._humanize_user_mentions(
|
||||
"hey <@U07ALICE> and <@U07BOB>", chat_id="C1"
|
||||
)
|
||||
assert out == "hey @Alice Example and @Bob Example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_labelled_mention_form():
|
||||
# Slack sometimes sends <@UID|handle>; only the ID drives resolution.
|
||||
adapter = _adapter_with_names({"U07ALICE": "Alice Example"})
|
||||
out = await adapter._humanize_user_mentions("<@U07ALICE|alice> hi", chat_id="C1")
|
||||
assert out == "@Alice Example hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeated_mention_all_replaced():
|
||||
adapter = _adapter_with_names({"U07ALICE": "Alice Example"})
|
||||
out = await adapter._humanize_user_mentions(
|
||||
"<@U07ALICE> ping <@U07ALICE>", chat_id="C1"
|
||||
)
|
||||
assert out == "@Alice Example ping @Alice Example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unresolvable_mention_falls_back_to_id():
|
||||
# Resolution returns the bare ID; keep the message intact, don't empty it.
|
||||
adapter = _adapter_with_names({})
|
||||
out = await adapter._humanize_user_mentions("<@U07GHOST> hi", chat_id="C1")
|
||||
assert out == "@U07GHOST hi"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_mentions_returns_unchanged():
|
||||
adapter = _adapter_with_names({"U07ALICE": "Alice Example"})
|
||||
out = await adapter._humanize_user_mentions("plain text, no pings", chat_id="C1")
|
||||
assert out == "plain text, no pings"
|
||||
|
||||
|
||||
# ----- _build_identity_prompt --------------------------------------------------
|
||||
|
||||
def test_identity_prompt_names_the_bot():
|
||||
adapter = _make_adapter()
|
||||
adapter._bot_display_name = "TestBot"
|
||||
adapter._team_bot_names = {}
|
||||
prompt = adapter._build_identity_prompt(team_id="T1")
|
||||
assert "@TestBot" in prompt
|
||||
# Must instruct that another participant's mention is not a self-mention.
|
||||
assert "not a mention of you" in prompt
|
||||
|
||||
|
||||
def test_identity_prompt_prefers_per_team_name():
|
||||
adapter = _make_adapter()
|
||||
adapter._bot_display_name = "PrimaryBot"
|
||||
adapter._team_bot_names = {"T2": "WorkspaceTwoBot"}
|
||||
prompt = adapter._build_identity_prompt(team_id="T2")
|
||||
assert "@WorkspaceTwoBot" in prompt
|
||||
assert "PrimaryBot" not in prompt
|
||||
|
||||
|
||||
def test_identity_prompt_empty_when_name_unknown():
|
||||
# Before connect (no name resolved) the prompt must be empty, not a
|
||||
# half-formed line — callers skip injecting an empty string.
|
||||
adapter = _make_adapter()
|
||||
adapter._bot_display_name = None
|
||||
adapter._team_bot_names = {}
|
||||
assert adapter._build_identity_prompt(team_id="T1") == ""
|
||||
Loading…
Add table
Add a link
Reference in a new issue