Skip to content

fix(slack): route replies from mentioned thread parents - #24848

Closed
kaiyisg wants to merge 1 commit into
NousResearch:mainfrom
kaiyisg:codex/slack-parent-thread-routing
Closed

fix(slack): route replies from mentioned thread parents#24848
kaiyisg wants to merge 1 commit into
NousResearch:mainfrom
kaiyisg:codex/slack-parent-thread-routing

Conversation

@kaiyisg

@kaiyisg kaiyisg commented May 13, 2026

Copy link
Copy Markdown
Contributor

Summary

  • route plain thread replies when the thread parent mentioned the Slack bot, even after restart/cache loss
  • preserve existing strict mention/session routing behavior
  • add regression coverage for parent-mentioned thread replies and no-mention ignored cases

Verification

  • /Users/leekaiyi/.hermes/hermes-agent/venv/bin/python -m py_compile gateway/platforms/slack.py
  • /Users/leekaiyi/.hermes/hermes-agent/venv/bin/python -m pytest tests/gateway/test_slack.py -k 'parent_mentioned_bot or thread_reply_without_mention_no_session_ignored or no_session_store_ignores_thread_replies or thread_reply_without_mention_with_session_processed' -q
  • live NurtureAny smoke after gateway restart: plain run routed and bot replied 'routing ok source backed.'

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/slack Slack app adapter labels May 13, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Thanks for isolating a real Slack routing gap. Current main still rejects an unmentioned thread reply with no session before inspecting the parent: plugins/platforms/slack/adapter.py:2850-2868. The existing parent lookup runs only later for reply context at plugins/platforms/slack/adapter.py:3201-3210.

Problems

  • The PR edits gateway/platforms/slack.py, but Slack was migrated to plugins/platforms/slack/adapter.py by 560010547. The patch will need a mechanical port rather than a direct cherry-pick.

Suggested changes

  • Port the parent-mention gate and its regression coverage to the bundled adapter, immediately before the existing no-mention rejection at plugins/platforms/slack/adapter.py:2863.
  • Retain the existing strict-mention early return at plugins/platforms/slack/adapter.py:2848; it protects the documented strict-mode behavior.

Automated hermes-sweeper review.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 13, 2026
teknium1 pushed a commit that referenced this pull request Jul 22, 2026
Two mention-tracking gaps around thread parents (#24848):

1. When a thread PARENT @-mentioned the bot (e.g. '<@bot> check this
   and ask me before running'), a later bare reply like 'run' fell
   through every wake check if the mention event predated this process
   (restart) — _mentioned_threads is in-memory only. Add a 5th wake
   check that fetches the parent text (with the bot mention preserved
   via strip_bot_mention=False) and wakes when the parent addressed the
   bot, registering the thread so later replies skip the fetch.

2. A TOP-LEVEL @mention starts a thread (session keying falls back to
   the message ts), but only the raw event thread_ts was registered in
   _mentioned_threads — so replies to a top-level mention did not
   auto-trigger. Register the session-scoped thread_ts instead.

_fetch_thread_parent_text reuses the shared thread-context cache (raw
payloads) so the parent check costs at most one conversations.replies
call per thread; _register_mentioned_thread centralizes the bounded-set
eviction.

Salvaged from #24848 by @kaiyisg, rebased onto the extracted
_should_wake_on_unmentioned_message helper.
teknium1 added a commit that referenced this pull request Jul 22, 2026
teknium1 pushed a commit that referenced this pull request Jul 22, 2026
Two mention-tracking gaps around thread parents (#24848):

1. When a thread PARENT @-mentioned the bot (e.g. '<@bot> check this
   and ask me before running'), a later bare reply like 'run' fell
   through every wake check if the mention event predated this process
   (restart) — _mentioned_threads is in-memory only. Add a 5th wake
   check that fetches the parent text (with the bot mention preserved
   via strip_bot_mention=False) and wakes when the parent addressed the
   bot, registering the thread so later replies skip the fetch.

2. A TOP-LEVEL @mention starts a thread (session keying falls back to
   the message ts), but only the raw event thread_ts was registered in
   _mentioned_threads — so replies to a top-level mention did not
   auto-trigger. Register the session-scoped thread_ts instead.

_fetch_thread_parent_text reuses the shared thread-context cache (raw
payloads) so the parent check costs at most one conversations.replies
call per thread; _register_mentioned_thread centralizes the bounded-set
eviction.

Salvaged from #24848 by @kaiyisg, rebased onto the extracted
_should_wake_on_unmentioned_message helper.
teknium1 added a commit that referenced this pull request Jul 22, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #69320 — your commit was cherry-picked onto current main with your authorship preserved in git history: your mentioned-thread-parent reply routing was cherry-picked directly.

Thanks for the contribution!

@teknium1 teknium1 closed this Jul 22, 2026
teknium1 added a commit that referenced this pull request Jul 22, 2026
…ure parity

Follow-up rework on the #51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, #24848) against a None
  parent_text — _fetch_thread_parent_text is typed to return str but tests
  (and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
  AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
  #24848 tests exercise the real parent-text path via conversations_replies
  side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
  cache key introduced by the multi-workspace name cache on main.
teknium1 added a commit that referenced this pull request Jul 23, 2026
…ure parity

Follow-up rework on the #51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, #24848) against a None
  parent_text — _fetch_thread_parent_text is typed to return str but tests
  (and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
  AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
  #24848 tests exercise the real parent-text path via conversations_replies
  side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
  cache key introduced by the multi-workspace name cache on main.
teknium1 added a commit that referenced this pull request Jul 23, 2026
…ure parity

Follow-up rework on the #51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, #24848) against a None
  parent_text — _fetch_thread_parent_text is typed to return str but tests
  (and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
  AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
  #24848 tests exercise the real parent-text path via conversations_replies
  side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
  cache key introduced by the multi-workspace name cache on main.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
Two mention-tracking gaps around thread parents (NousResearch#24848):

1. When a thread PARENT @-mentioned the bot (e.g. '<@bot> check this
   and ask me before running'), a later bare reply like 'run' fell
   through every wake check if the mention event predated this process
   (restart) — _mentioned_threads is in-memory only. Add a 5th wake
   check that fetches the parent text (with the bot mention preserved
   via strip_bot_mention=False) and wakes when the parent addressed the
   bot, registering the thread so later replies skip the fetch.

2. A TOP-LEVEL @mention starts a thread (session keying falls back to
   the message ts), but only the raw event thread_ts was registered in
   _mentioned_threads — so replies to a top-level mention did not
   auto-trigger. Register the session-scoped thread_ts instead.

_fetch_thread_parent_text reuses the shared thread-context cache (raw
payloads) so the parent check costs at most one conversations.replies
call per thread; _register_mentioned_thread centralizes the bounded-set
eviction.

Salvaged from NousResearch#24848 by @kaiyisg, rebased onto the extracted
_should_wake_on_unmentioned_message helper.
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
randlee pushed a commit to randlee/hermes-agent that referenced this pull request Aug 11, 2026
…ure parity

Follow-up rework on the NousResearch#51627 cherry-pick:
- Guard the 5th wake check (parent-mentioned-bot, NousResearch#24848) against a None
  parent_text — _fetch_thread_parent_text is typed to return str but tests
  (and defensive callers) can surface None; 'in None' raised TypeError.
- Drop the PR's fixture-level _fetch_thread_parent_text/_fetch_thread_context
  AsyncMocks from TestThreadReplyHandling/TestAssistantThreadLifecycle: main's
  NousResearch#24848 tests exercise the real parent-text path via conversations_replies
  side effects, and the blanket mocks broke them.
- _resolve_user_is_bot reworked to the workspace-scoped (team_id, user_id)
  cache key introduced by the multi-workspace name cache on main.
nikitaBarkov added a commit to JetBrains/hermes-agent that referenced this pull request Aug 15, 2026
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>
chenwei791129 added a commit to chenwei791129/hermes-agent that referenced this pull request Aug 16, 2026
…ments

NousResearch#52387 was fixed for the carrier Slack's WYSIWYG composer produces — a
rich_text tree with a structured `user` element. Two other carriers are
still dropped, so a bot that explicitly @-mentions the gateway stays
invisible to the allow_bots="mentions" gate (adapter.py:5329) and to
is_mentioned routing (adapter.py:5603).

Root cause, two independent gaps:

1. `_collect_slack_block_mentions` recurses only through
   ("elements", "element") and appends only for `type == "user"` nodes. A
   section/header/context block carries its content under "text" (a dict)
   or "fields" (a list), neither of which is walked; and a hand-built app
   writes the mention as a raw <@uid> substring, so there is no `user`
   node to match even once the subtree is entered.

2. Detection returns early when `event["blocks"]` is falsy and never
   consults `attachments`, so a mention living in an attachment field or
   in attachment-nested blocks is invisible. NousResearch#69316 added
   `_extract_text_from_slack_attachments` for exactly these apps
   (Alertmanager, Grafana, PagerDuty, CI) but applied it to display only.

The walker also descends "text"/"fields" and harvests raw tokens from
string values; a new `_collect_slack_attachment_mentions` covers the
legacy carrier including attachment-nested blocks. A shared
`_SLACK_USER_MENTION_RE` normalizes the labelled `<@u123|alice>` form to
the bare token the gates compare against, with a deliberately permissive
ID class: the gates substring-compare against whatever auth.test
returned, so a narrower class would silently drop the mention this
recovers. Extraction is factored into `_extract_mention_tokens`, so the
carve-outs below live in one place and apply to every carrier.

Recovered mentions are returned as a list by `_slack_recovered_mentions`
rather than spliced into the routing text. Splicing would corrupt the two
other consumers of that text: `_slack_message_addressed_to_other_user`
reads its *first* token — and with an empty top-level text (the alert-bot
shape this fixes) the appended tail becomes that token, so the message is
dropped as "addressed to someone else" — while user-configured wake-word
regexes are matched with `.search`, so an anchored pattern like
`^hey hermes$` stops matching the moment a tail is appended. The gates
now consume `_slack_event_mentions_bot` / `_slack_mention_gate_inputs`,
which keep the routing text byte-identical to `event["text"]` and report
recovered mentions through `is_mentioned` instead.

The quote carve-out from NousResearch#52390 is preserved and extended to the carriers
the structured `rich_text_quote` check cannot see:

  - a leading mrkdwn blockquote marker (`>` / the escaped `&gt;`) in any
    scanned string, so a peer app quoting an earlier request as context
    does not re-summon the bot;
  - attachments flagged `is_msg_unfurl` or `is_share`, mirroring the skip
    the agent-text path already performs at adapter.py:5483, so pasting a
    permalink to an old bot request does not wake it;
  - `fallback` is not scanned at all — Slack never renders it, so a
    mention living only there is invisible in the channel and notifies
    nobody.

Defensive handling is per-attachment rather than around the whole loop:
one malformed sibling now skips itself instead of discarding the genuine
mentions already collected, which would otherwise reintroduce the exact
silent drop this commit fixes.

The thread-parent wake check (NousResearch#24848) is migrated too. Its cached branch
returned the raw `msg["text"]` — empty for app-authored parents — so a
plain follow-up reply in an alert thread was dropped after a restart.

Tests: 28 new cases in tests/gateway/test_slack_mention.py (27 -> 55)
covering all seven carriers, the labelled form, dedupe, six malformed
payloads, each carve-out, and the two routing-text regressions. The
gating simulation and the new gate cases call the production predicates
directly, so the tests cannot pass while the real gate diverges. They
fail on unmodified main and pass with the fix. All 27
tests/gateway/test_slack*.py files pass (337 tests).
chenwei791129 added a commit to chenwei791129/hermes-agent that referenced this pull request Aug 16, 2026
Review catch on NousResearch#75312: the NousResearch#24848 thread-parent wake check read
`_fetch_thread_parent_text()`, but that is *display* text. `_render_message_text`
deliberately preserves `rich_text_quote` content so the agent can read what was
quoted, and extracts attachment text with no `is_msg_unfurl`/`is_share`
exclusion and a `fallback` fallthrough. Since the caller woke on any `<@bot>`
substring, a thread whose parent merely quoted or shared a mention of the bot
would wake on every subsequent plain reply — re-opening the agent-agent
re-trigger loop the carve-outs exist to close.

The two parent paths also disagreed: the cached path already ran the filtered
predicate while the cold path did not, so behavior depended on whether the
thread cache happened to be warm.

Split the two concerns instead of filtering display text:

- `_fetch_thread_parent_event()` returns the raw parent payload, cache-first.
- `_thread_parent_mentions_bot()` decides the wake through
  `_slack_event_mentions_bot()` — the same predicate the live channel gates
  use, so every carve-out applies to both paths by construction rather than by
  being re-added here.
- `_fetch_thread_parent_text()` goes back to being display-only for
  reply_to_text injection, with a docstring saying it must not be
  substring-tested. Its `strip_bot_mention` flag existed solely for the wake
  caller and is gone.

`_slack_mention_detection_text()` had that same caller as its last consumer, so
it is removed rather than left as a helper whose docstring warns against every
remaining use of it; its tests now assert on the recovered-token list, which is
the actual contract.

Cold and cached parents are covered for quote, unfurl, share, fallback and
preformatted carriers, plus positive cases so the wake check itself is pinned,
plus one test asserting the display renderer still surfaces quoted content —
that split is the point of the change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/slack Slack app adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants