# NousResearch/hermes-agent · Discord auto-thread failure falling back to inline reply

## Open now

- Nothing open on GitHub.

The three members share one theme: when Discord auto-thread creation fails, the bot appears to silently fall back to replying inline in the channel. One is a bug report, and two are pull requests with identical titles referencing #20243 that propose changing this behavior. Titles alone do not show the actual outcome or status.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #56108. Verify it addresses your case.
- **Recommended:** read PR #56108 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-56108`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #20243: \[Bug\]: Discord auto-thread failure silently falls back to inline channel reply

## Prior & competing PRs

- **#56108: fix(discord): auto-thread failure must not silently fall back to inline reply (#20243)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `plugins/platforms/discord/adapter.py`, `tests/gateway/test_discord_channel_controls.py`, `tests/gateway/test_discord_double_dispatch.py`, `tests/gateway/test_discord_free_response.py`
  - fix hunks:
  - `plugins/platforms/discord/adapter.py`

    ```diff
    @@ -5020,7 +5020,11 @@ async def _create_thread(
         async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
             """Create a thread from a user message for auto-threading.
     
    -        Returns the created thread object, or ``None`` on failure.
    +        Returns the created thread object, or ``None`` on failure. Both the
    +        primary ``message.create_thread`` and the seed-message fallback are
    +        retried once after a short backoff so transient connect errors
    +        (e.g. ``Cannot connect to host discord.com:443``) don't immediately
    +        burn through to the caller's failure path (#20243).
             """
             # Build a short thread name from the message. Strip Discord mention
             # syntax (users / roles / channels) so thread titles don't end up
    ```

    ```diff
    @@ -5035,28 +5039,44 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
             if len(content) > 80:
                 thread_name = thread_name[:77] + "..."
     
    -        try:
    -            thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
    -            return thread
    -        except Exception as direct_error:
    -            display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
    -            reason = f"Auto-threaded from mention by {display_name}"
    +        display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
    +        reason = f"Auto-threaded from mention by {display_name}"
    +
    +        last_direct_error: Exception | None = None
    +        last_fallback_error: Exception | None = None
    +
    +        for attempt in range(2):
                 try:
    -                seed_msg = await message.channel.send(f"\U0001f9f5 Thread created by Hermes: **{thread_name}**")
    -                thread = await seed_msg.create_thread(
    -                    name=thread_name,
    -                    auto_archive_duration=1440,
    -                    reason=reason,
    -                )
    +                thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
                     return thread
    -            except Exception as fallback_error:
    -                logger.warning(
    -                    "[%s] Auto-thread creation failed. Direct error: %s. Fallback error: %s",
    -                    self.name,
    -                    direct_error,
    -                    fallback_error,
    -                )
    -                return None
    +            except Exception as direct_error:
    +                last_direct_error = direct_error
    +                try:
    +                    seed_msg = await message.channel.send(
    +                        f"\U0001f9f5 Thread created by Hermes: **{thread_name}**"
    +                    )
    +                    thread = await seed_msg.create_thread(
    +                        name=thread_name,
    +                        auto_archive_duration=1440,
    +                        reason=reason,
    +                    )
    +                    return thread
    +                except Exception as fallback_error:
    +                    last_fallback_error = fallback_error
    +                    if attempt == 0:
    +                        # Brief backoff before the second attempt — most failures
    +                        # in this path are transient connect errors that recover
    +                        # within a second or two.
    +                        await asyncio.sleep(0.75)
    +                        continue
    +
    +        logger.warning(
    +            "[%s] Auto-thread creation failed after retry. Direct error: %s. Fallback error: %s",
    +            self.name,
    +            last_direct_error,
    +            last_fallback_error,
    +        )
    +        return None
     
         async def create_handoff_thread(
             self,
    ```

    ```diff
    @@ -5742,6 +5762,26 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =
                         # event is dropped before it can trigger a second agent run.
                         # Fixes #51057.
                         self._dedup.is_duplicate(str(thread.id))
    +                else:
    +                    # Auto-threading is the configured routing target for this
    +                    # message; if it fails we must NOT silently fall back to an
    +                    # inline parent-channel reply (#20243). That breaks
    +                    # thread-first Discord workflows by dumping a new task into
    +                    # a shared channel. Surface a short visible error so the
    +                    # user can retry once Discord recovers, and skip agent
    +                    # invocation for this message.
    +                    try:
    +                        await message.channel.send(
    +                            "⚠️ Hermes could not create a Discord thread for "
    +                            "this message, so the request was not processed. Please retry."
    +                        )
    +                    except Exception as notify_error:
    +                        logger.warning(
    +                            "[%s] Failed to notify user of auto-thread failure: %s",
    +                            self.name,
    +                            notify_error,
    +                        )
    +                    return
     
             referenced_attachments = []
             reference = getattr(message, "reference", None)
    ```
  - `tests/gateway/test_discord_channel_controls.py`

    ```diff
    @@ -140,6 +140,11 @@ async def test_non_ignored_channel_processes_normally(adapter, monkeypatch):
         monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "500,600")
         monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
     
    +    # Stub auto-thread creation so this test focuses on ignored-channel
    +    # routing only — auto-thread failures now correctly skip agent invocation
    +    # (#20243), which would otherwise mask the assertion below.
    +    adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
    +
         message = make_message(channel=FakeTextChannel(channel_id=700), content="hello")
         await adapter._handle_message(message)
     
    ```

    ```diff
    @@ -167,6 +172,11 @@ async def test_ignored_channels_empty_string_ignores_nothing(adapter, monkeypatc
         monkeypatch.setenv("DISCORD_IGNORED_CHANNELS", "")
         monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
     
    +    # Stub auto-thread creation so this test focuses on ignored-channel
    +    # routing only — auto-thread failures now correctly skip agent invocation
    +    # (#20243), which would otherwise mask the assertion below.
    +    adapter._auto_create_thread = AsyncMock(return_value=FakeThread(channel_id=999))
    +
         message = make_message(channel=FakeTextChannel(channel_id=500), content="hello")
         await adapter._handle_message(message)
     
    ```

    ```diff
    @@ -281,6 +291,71 @@ async def test_no_thread_with_auto_thread_disabled_is_noop(adapter, monkeypatch)
         adapter.handle_message.assert_awaited_once()
     
     
    +# ── auto-thread failure must not silently fall back to inline (#20243) ──
    +
    +
    [redacted:email]
    +async def test_auto_thread_failure_skips_agent_and_notifies_user(adapter, monkeypatch):
    +    """Auto-thread creation failure must not trigger an inline parent-channel reply.
    +
    +    Before #20243, ``effective_channel = auto_threaded_channel or message.channel``
    +    silently routed the response back to the parent channel when thread creation
    +    failed, breaking thread-first Discord workflows. The fix surfaces a short
    +    visible error to the parent channel and skips agent invocation entirely so
    +    the user can retry.
    +    """
    +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
    +    monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
    +    monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False)
    +    monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
    +    monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
    +
    +    adapter._auto_create_thread = AsyncMock(return_value=None)
    +
    +    channel = FakeTextChannel(channel_id=800)
    +    channel.send = AsyncMock()
    +    message = make_message(channel=channel, content="hello")
    +    await adapter._handle_message(message)
    +
    +    adapter._auto_create_thread.assert_awaited_once()
    +    # Agent must NOT be invoked when the routing target failed.
    +    adapter.handle_message.assert_not_awaited()
    +    # User gets a visible explanation in the parent channel instead of a silent
    +    # inline reply.
    +    channel.send.assert_awaited_once()
    +    sent_text = channel.send.await_args.args[0]
    +    assert "could not create" in sent_text.lower()
    +    assert "thread" in sent_text.lower()
    +
    +
    [redacted:email]
    +async def test_auto_thread_failure_notify_error_does_not_crash(adapter, monkeypatch):
    +    """If even the failure-notification send raises, we still skip the agent.
    +
    +    ``message.channel.send`` itself can fail (the same connect issue that
    +    killed thread creation often kills plain sends too). The handler should
    +    swallow the secondary error and still avoid invoking the agent.
    +    """
    +    monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
    +    monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
    +    monkeypatch.delenv("DISCORD_NO_THREAD_CHANNELS", raising=False)
    +    monkeypatch.delenv("DISCORD_IGNORED_CHANNELS", raising=False)
    +    monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
    +
    +    adapter._auto_create_thread = AsyncMock(return_value=None)
    +
    +    channel = FakeTextChannel(channel_id=800)
    +    channel.send = AsyncMock(side_effect=RuntimeError("Cannot connect to host discord.com:443"))
    +    message = make_message(channel=channel, content="hello")
    +
    +    # No exception must propagate.
    +    await adapter._handle_message(message)
    +
    +    adapter._auto_create_thread.assert_awaited_once()
    +    adapter.handle_message.assert_not_awaited()
    +    channel.send.assert_awaited_once()
    +
    +
     # ── config.py bridging ───────────────────────────────────────────────
     
     
    ```
  - `tests/gateway/test_discord_double_dispatch.py`

    ```diff
    @@ -226,11 +226,19 @@ async def fake_auto_create_thread(message):
     
         @pytest.mark.asyncio
         async def test_no_dedup_seed_when_thread_creation_fails(self, adapter, monkeypatch):
    -        """When _auto_create_thread returns None, no pre-seeding occurs."""
    +        """When _auto_create_thread returns None, no pre-seeding occurs.
    +
    +        Auto-thread failure is now fail-closed (#20243): the agent is NOT
    +        invoked and the user gets a visible notice instead of a silent inline
    +        reply. This test's contract is specifically about dedup pre-seeding —
    +        the phantom thread id must not leak into the dedup cache when creation
    +        fails.
    +        """
             monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
             monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
     
             channel = _TextChannel(channel_id=100)
    +        channel.send = AsyncMock()
             phantom_thread_id = 55555
     
             async def fake_auto_create_thread_fail(message):
    ```

    ```diff
    @@ -243,8 +251,9 @@ async def fake_auto_create_thread_fail(message):
             user_msg = _make_message(msg_id=42, channel=channel, content="hello")
             await adapter._handle_message(user_msg)
     
    -        # The message was still dispatched (no thread, but message goes through)
    -        adapter.handle_message.assert_awaited_once()
    +        # Fail-closed: the agent must NOT run when the required thread route
    +        # could not be created (#20243).
    +        adapter.handle_message.assert_not_awaited()
     
             # The phantom thread id should NOT be in the dedup cache
             assert str(phantom_thread_id) not in adapter._dedup._seen, (
    ```
  - `tests/gateway/test_discord_free_response.py`

    ```diff
    @@ -202,6 +202,10 @@ async def test_discord_defaults_to_require_mention(adapter, monkeypatch):
     async def test_discord_free_response_in_server_channels(adapter, monkeypatch):
         monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
         monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
    +    # Auto-thread failures now correctly skip agent invocation (#20243), and
    +    # FakeTextChannel has no real ``create_thread``. Disable auto-thread so the
    +    # routing assertion below stays focused on free-response gating.
    +    monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
     
         message = make_message(channel=FakeTextChannel(channel_id=123), content="hello from channel")
     
    ```

    ```diff
    @@ -334,6 +338,10 @@ async def test_discord_forum_parent_in_free_response_list_allows_forum_thread(ad
     async def test_discord_accepts_and_strips_bot_mentions_when_required(adapter, monkeypatch):
         monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "true")
         monkeypatch.delenv("DISCORD_FREE_RESPONSE_CHANNELS", raising=False)
    +    # Auto-thread failures now correctly skip agent invocation (#20243).
    +    # FakeTextChannel can't satisfy the real ``create_thread`` API, so disable
    +    # auto-thread to keep this test focused on mention-strip behaviour.
    +    monkeypatch.setenv("DISCORD_AUTO_THREAD", "false")
     
         bot_user = adapter._client.user
         message = make_message(
    ```
- **#20260: fix(discord): auto-thread failure must not silently fall back to inline reply (#20243)**. closed without merge
  - maintainer direction: category `merged_via`; implication `superseded`; refs `#56108`

## Fix status

- Merged to main in PR #56108 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-01`.
- Subsystem: `gateway`
