Skip to content

feat(slack): make stripping the bot's own mention configurable (slack.strip_bot_mentions) - #83905

Open
nikitaBarkov wants to merge 1 commit into
NousResearch:mainfrom
JetBrains:nikita.barkov/slack-strip-bot-mentions-upstream
Open

feat(slack): make stripping the bot's own mention configurable (slack.strip_bot_mentions)#83905
nikitaBarkov wants to merge 1 commit into
NousResearch:mainfrom
JetBrains:nikita.barkov/slack-strip-bot-mentions-upstream

Conversation

@nikitaBarkov

Copy link
Copy Markdown
Contributor

What does this PR do?

Makes the deletion of the bot's own Slack mention optional, behind a new slack.strip_bot_mentions config field. Default true is today's behavior byte for byte — no existing test needed editing.

The adapter deletes the bot's own <@U…> token from the text before the agent reads it, and reports nothing in its place. That is fine while the adapter alone decides who gets an answer, but a thread keeps waking the bot after the first mention (_mentioned_threads, with thread_require_mention off by default), so every delivered turn then looks identical: the agent cannot tell "someone just tagged me" from "I was woken by thread routing".

That is the distinction an operator needs when the agent — not the adapter — decides whether a turn deserves an answer at all (a "stay silent unless addressed" policy stated in the operator's own prompt). With strip_bot_mentions: false an explicit tag stays visible where the author put it, and its absence means the bot was woken by channel or thread routing. The asymmetry itself is the signal; no marker text is injected.

The mention is rendered as @BotName, the same shape _humanize_user_mentions() already gives mentions of other participants, so the agent reads an ordinary tag rather than a raw <@U…> id or a synthetic prefix. This is deliberately different from #47536, which was closed as superseded by mention humanization (#69483) because keeping raw <@U…> tokens would conflict with it — here the bot's own mention is humanized exactly like everyone else's, in place. The name comes from _team_bot_names / _bot_display_name, both resolved at connect time, so there is no extra users.info call per message; when the display name is not resolved yet the raw token is left alone rather than deleted.

Routing is untouched: is_mentioned, _mentioned_threads, require_mention, strict_mention, thread_require_mention all keep their meaning and are all evaluated before this. Command parsing is untouched too — it runs off the separate mention_stripped variable, so @bot /status and @bot !new dispatch identically in both states.

Related Issue

Related to #78106 (a Slack mention wakes the bot, but the mention is stripped before the agent's response decision). This gives the "keep @hermes in the visible text" half of that issue's acceptance criteria as an opt-in field; it does not change the default, so it is not a full fix for a user who expects the new behavior out of the box.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • plugins/platforms/slack/adapter.py: _slack_strip_bot_mentions() sits with its four siblings (_slack_require_mention, _slack_strict_mention, _slack_ignore_other_user_mentions, _slack_thread_require_mention) and resolves the same way — config.extraSLACK_STRIP_BOT_MENTIONS → default true, with the explicit-false parsing _slack_require_mention uses (the safe default here is True).
  • plugins/platforms/slack/adapter.py: _own_bot_name() returns the connect-time display name for a workspace (the expression _build_identity_prompt() already used, now shared); _render_own_mention() rewrites <@U123> / <@U123|label> to @BotName in place. The re.sub replacement is a callable, since a display name is arbitrary user data and a backslash in it would otherwise raise re.error and cost the message.
  • plugins/platforms/slack/adapter.py: the strip site in _handle_slack_message() is now gated — strip, or render in place.
  • plugins/platforms/slack/adapter.py: thread history follows the same policy, so past turns don't read as "nobody ever tagged me" — _render_message_text() takes strip_bot_mention / bot_name, and _format_thread_context() passes the resolved flag through. When the mention is kept, block content is compared against the text as written: the blocks carry the raw token, so comparing them against a rendered @BotName would append the same authored message a second time.
  • plugins/platforms/slack/adapter.py: _fetch_thread_parent_text() forwards the caller's strip_bot_mention into the render on a cache miss. That path is the root-mention wake check (fix(slack): route replies from mentioned thread parents #24848), which greps the parent for the raw <@id>, but _render_message_text() deleted the token unconditionally — so on a cold cache (i.e. the first reply after a restart, which is exactly when that check matters) it could never match. Reproduced with a stubbed conversations_replies, in both flag states, so this is a pre-existing defect the forwarding also fixes.
  • hermes_cli/config_defaults.py: slack.strip_bot_mentions: True, documented in place.
  • plugins/platforms/slack/plugin.yaml: optional_env entry for the SLACK_STRIP_BOT_MENTIONS mirror.
  • website/docs/user-guide/messaging/slack.md: a subsection under the mention-gating options — what the key does, and explicitly what it does not change. website/docs/reference/environment-variables.md: the env mirror, pointing at config.yaml as canonical.
  • tests/gateway/test_slack_strip_bot_mentions.py: 51 tests, most parameterized over both flag states.

How to Test

  1. Default path — change nothing. @hermes what's up? still arrives as what's up?; tests/gateway/test_slack.py::test_channel_mention_strips_bot_id pins this and passes unedited.
  2. Opt in via config.yaml:
    slack:
      strip_bot_mentions: false
  3. In Slack: hey @hermes look arrives as hey @hermes look — the tag in place, not a raw <@U…> and not moved to the front. A mention-only ping arrives as @hermes, not as an empty message.
  4. Reply in the same thread without tagging — delivered unchanged, no marker. That difference is the signal.
  5. @hermes /status and @hermes !new still dispatch as /status / /new, in both states.
  6. scripts/run_tests.sh tests/gateway/test_slack_strip_bot_mentions.py tests/gateway/test_slack.py tests/gateway/test_slack_mention.py tests/gateway/test_slack_mention_humanization.py tests/test_slack_thread_require_mention.py tests/gateway/test_config.py tests/gateway/test_slack_block_kit.py tests/gateway/test_slack_block_kit_adapter.py tests/gateway/test_slack_ignore_other_user_mentions.py tests/gateway/test_slack_require_mention_channels.py tests/gateway/test_slack_peer_agent_smoke.py348 passed.
  7. scripts/run_tests.sh tests/gateway/5293 passed, 7 failures reproduced on a clean checkout of this branch's base (macOS: optional XML dependency for wecom, Linux-only abstract sockets, api_server health/readiness/shutdown-forensics, multiplex busy-input).

Checklist

Code

Documentation & Housekeeping

  • I've updated the Slack user guide and the environment-variable reference
  • cli-config.yaml.example — N/A, the Slack defaults block lives in hermes_cli/config_defaults.py, which is updated
  • CONTRIBUTING.md / AGENTS.md — N/A, no architecture or workflow change
  • Cross-platform impact considered — pure Python Slack adapter behavior
  • Tool descriptions/schemas — N/A, no tool changes

Notes

  • The identity prompt is deliberately left unchanged in both states. It says the routing mention "may have been stripped", which is modal and stays true either way, so there is no fix(slack): trust adapter routing after stripping self mention #75406-style contradiction to fix; whether a message deserves an answer belongs in the operator's prompt, not in the adapter's grounding line.
  • Known and accepted: with the mention kept, answering a gateway prompt with a tag (@bot 2, @bot always, @bot y) no longer exact-matches the clarify-choice, slash-confirm and pending-/update branches in gateway/run.py. That exposure is inherent to any approach that keeps the mention in the text, it only applies to operators who opt in, and guarding it would mean teaching the Slack adapter about gateway prompt state — happy to add a follow-up if you'd rather have it.
  • Left alone as a pre-existing defect, identical in both flag states: in _render_message_text() a rich_text block mirroring a flat text that contains the bot token is still appended a second time, because the comparison is a plain substring check against the post-strip text. It is not a regression of this flag, so it is out of scope here.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins platform/slack Slack app adapter area/config Config system, migrations, profiles sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Aug 11, 2026
@nikitaBarkov

Copy link
Copy Markdown
Contributor Author

CI note: Python tests / Run tests slice 5/12 is red on tests/gateway/test_multiplex_busy_input_mode.py::test_profile_route_and_nonmultiplexed_resolution_preserve_boundaries (AssertionError: assert 'interrupt' == 'steer', line 339). That is pre-existing on main — the same slice fails with the same assertion in https://github.com/NousResearch/hermes-agent/actions/runs/31470991542 — and it also reproduces locally on this branch's base with these changes stashed. Nothing in this PR touches busy-input-mode resolution; every Slack and config suite is green.

The adapter deletes the bot's own `<@U…>` token from the text before the
agent reads it, and reports nothing in its place. A thread keeps waking the
bot after the first mention, so every delivered turn then looks identical:
the agent cannot tell "someone tagged me" from "I was woken by thread
routing" — the distinction an agent needs to decide for itself whether a
turn deserves an answer.

Add `slack.strip_bot_mentions` (default `true` — today's behavior byte for
byte, so nothing changes for anyone who does not opt in). With `false` the
mention stays where the author put it, rendered as `@BotName`, the same
shape `_humanize_user_mentions()` gives mentions of other participants; its
absence then means the bot was woken by channel or thread routing. The
asymmetry is the signal — no marker text is injected. The name comes from
`_team_bot_names` / `_bot_display_name`, both resolved at connect time, so
there is no extra Slack call; an unresolved name leaves the raw token rather
than deleting it.

Routing is untouched (`is_mentioned`, `_mentioned_threads`,
`require_mention`, `strict_mention`, `thread_require_mention` are all
evaluated before this), and so is command parsing, which runs off the
separate `mention_stripped` variable.

- `_slack_strip_bot_mentions()` resolves `config.extra` →
  `SLACK_STRIP_BOT_MENTIONS` → default `true`, like its four siblings.
- Thread history follows the same policy (`_render_message_text`,
  `_format_thread_context`), so past turns don't read as "nobody ever
  tagged me"; block content is compared against the text as written, since
  the blocks carry the raw token.
- `_fetch_thread_parent_text()` forwards the caller's `strip_bot_mention`
  into the render on a cache miss. That path is the root-mention wake check
  (NousResearch#24848), which greps the parent for the raw `<@id>`, and the render
  deleted it unconditionally — so on a cold cache the check could never
  match, in either flag state.
- Documented in the Slack guide and the environment-variable reference;
  `config.yaml` is the canonical place, the env var is a mirror.
- 51 tests over both flag states.

Co-authored-by: Junie <junie@jetbrains.com>
@nikitaBarkov
nikitaBarkov force-pushed the nikita.barkov/slack-strip-bot-mentions-upstream branch from 6f3f12c to ac62c19 Compare August 15, 2026 15:30
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference, author can ignore or act on any point.

feat(slack): make stripping the bot's own mention configurable (slack.strip_bot_mentions)

  1. Fallback-chain ambiguity when a team name is empty: _own_bot_name returns team-scoped or _bot_display_name or "" (adapter.py line 4434-4444). In a multi-workspace setup where one team's name is unresolved but the primary's is set, the mention is rendered with the primary bot's name in the other team — arguably fine, but an explicitly-empty team name ("" in _team_bot_names) silently falls back too. Minor.
  2. Explicit-false parsing means empty-string config = strip ON (_slack_strip_bot_mentions, line 8913-8927): configured.lower() not in {"false","0","no","off"} — an accidental empty value in config (strip_bot_mentions: "") keeps the historical strip, which is the documented safe default. Consistent with _slack_require_mention; just confirming it's intentional.
  3. _render_own_mention leaves the raw token when the name is unknown (line 8968-8973: "an unresolved id still beats a deleted mention") — with strip off and a cold start, the agent sees a raw <@U123> until the name resolves. The comment frames this as intended; the alternative (rendering @U123) would be friendlier to the agent, but the current choice is defensible.
  4. Command parsing unaffected is verified (/status, !new behind a mention in both states) — good. The message_changed path also follows the flag, and the wake-check contract (strip_bot_mention=False still returns the raw token, cold cache included) is pinned by tests.
  5. Config plumbing is complete: config_defaults.py, plugin.yaml env entry, _apply_yaml_config bridge (without clobbering an explicit env var), and both docs pages. Thorough PR.

@nikitaBarkov

Copy link
Copy Markdown
Contributor Author

Thanks for the pass. All five points are confirmations of intent rather than defects, so nothing changes here — but two of them deserve the reasoning on record rather than a bare "yes, intended".

1. Empty team name falls back to the primary display name. Deliberate. _own_bot_name returns (team-scoped) or _bot_display_name or "", and an empty per-team entry is not a name — it is an unresolved lookup. The fallback matters because of what sits downstream: _render_own_mention is a no-op without a name, so "no fallback" does not mean "render the other team's name correctly", it means leave a raw <@U123> in the text. Both installs are the same Slack app, so the primary display name is the right approximation, and it degrades to the raw token only when nothing is known.

2. strip_bot_mentions: "" keeps stripping on. Intended, and matched to _slack_require_mention's explicit-false parsing: the safe default is the historical behaviour, so only an explicit falsey value turns it off. An accidental empty value therefore cannot silently change what the agent sees.

3. Raw <@U123> on a cold cache. Intended, as the comment says. The alternative you mention (@U123) would be friendlier to read but manufactures a handle that does not exist in the workspace — an agent that echoes it produces a mention of nobody. The raw token is at least a truthful, resolvable id, and it is the same trade-off _humanize_user_mentions makes for other participants.

4 & 5. Yes — command routing was the thing I was most careful about (/status and !new behind a mention are pinned in both states), the message_changed path follows the same flag, and the wake check keeps its raw-token contract (strip_bot_mention=False) so routing is untouched no matter how the flag is set.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have platform/slack Slack app adapter sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants