Skip to content

feat(discord): add free_response_auto_thread opt-in - #18455

Open
FunJim wants to merge 3 commits into
NousResearch:mainfrom
FunJim:feat/discord-free-response-auto-thread
Open

feat(discord): add free_response_auto_thread opt-in#18455
FunJim wants to merge 3 commits into
NousResearch:mainfrom
FunJim:feat/discord-free-response-auto-thread

Conversation

@FunJim

@FunJim FunJim commented May 1, 2026

Copy link
Copy Markdown

What does this PR do?

Adds an opt-in discord.free_response_auto_thread flag (env: DISCORD_FREE_RESPONSE_AUTO_THREAD, default false) that re-enables auto-threading in free-response channels. Default behavior is unchanged — users who never set the flag get the current inline-reply behavior, bit-for-bit.

Previously, free-response channels (channels listed in DISCORD_FREE_RESPONSE_CHANNELS) unconditionally skipped auto-threading — see #11629, which made this an explicit invariant: "free_response_channels ALWAYS skip auto-thread". This is the right default for lightweight chat channels, but it removes a legitimate configuration that a number of users were quietly relying on: @mention-free replies with per-conversation threads.

Related Issue

Fixes #15262.

That issue documents the exact regression this PR addresses: users had configured free_response_channels (often as *) specifically to get mention-free behavior, and were (accidentally, in the pre-#11629 world) also getting auto-threads. Once free_response_channels started being honored correctly, the threads disappeared and their channels became a single undifferentiated firehose of inline replies. The issue proposes exactly this fix — a separate toggle so users can opt back into threading without re-introducing the @mention requirement.

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)

Use Case / Why

Community bot operators commonly want a middle-ground channel mode:

This is a small, surgical addition — one flag, default off, preserves the existing invariant for every user who doesn't explicitly opt in.

Solution

Narrow change in gateway/platforms/discord.py around the skip_thread computation. The previous line:

skip_thread = bool(channel_ids & no_thread_channels) or is_free_channel

becomes:

free_response_auto_thread = os.getenv(
    "DISCORD_FREE_RESPONSE_AUTO_THREAD", "false"
).lower() in ("true", "1", "yes")
free_skips_thread = is_free_channel and not (
    free_response_auto_thread and not is_voice_linked_channel
)
skip_thread = bool(channel_ids & no_thread_channels) or free_skips_thread

Three invariants preserved:

  1. Default unchanged. When DISCORD_FREE_RESPONSE_AUTO_THREAD is unset (or false), free_skips_thread reduces to is_free_channel, matching the current behavior exactly.
  2. Voice-linked channels still skip auto-thread. The and not is_voice_linked_channel clause keeps voice channels on their existing interaction model regardless of the new flag.
  3. Gated behind the global auto_thread toggle. Because this only affects whether we clear skip_thread, the subsequent if auto_thread and not skip_thread ... check still short-circuits if DISCORD_AUTO_THREAD=false. Setting free_response_auto_thread=true while auto_thread=false is a no-op, as documented.

The config plumbing mirrors every other discord.* flag — gateway/config.py mirrors config.yaml → env var, hermes_cli/config.py adds the default. Docs updated in website/docs/user-guide/messaging/discord.md and website/docs/reference/environment-variables.md.

Changes Made

  • gateway/platforms/discord.py — compute skip_thread based on the new opt-in (default false preserves current behavior; voice-linked channels always skip).
  • gateway/config.py — mirror discord.free_response_auto_thread from config.yaml to DISCORD_FREE_RESPONSE_AUTO_THREAD when the env var is not already set (same pattern as auto_thread, reactions, etc.).
  • hermes_cli/config.py — add free_response_auto_thread: False to DEFAULT_CONFIG["discord"] so hermes config check / migrations surface it.
  • tests/gateway/test_discord_free_response.py — 4 new tests covering: opt-in enables auto-thread, default disabled, voice-linked channels ignore the flag, and global DISCORD_AUTO_THREAD=false still wins.
  • website/docs/user-guide/messaging/discord.md — document the flag in both the env-var table and the config.yaml example, with an explanatory paragraph under free_response_channels.
  • website/docs/reference/environment-variables.md — one-row entry in the Discord section.

How to Test

  1. Set in ~/.hermes/config.yaml:
    discord:
      require_mention: true
      free_response_channels: "<your-channel-id>"
      auto_thread: true
      free_response_auto_thread: true   # the new flag
  2. Post a message (no @mention) in that channel → bot creates a new thread and replies in it.
  3. Unset free_response_auto_thread (or set to false) → bot replies inline in the channel, no thread (default behavior preserved).
  4. Set free_response_auto_thread: true but auto_thread: false → bot replies inline (global toggle still wins, as documented).

Automated test run

$ python -m pytest tests/gateway/test_discord_free_response.py -o 'addopts=' -q
........................                                                 [100%]
24 passed in 1.74s

All 20 pre-existing free-response tests pass, plus the 4 new ones.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(discord):)
  • I searched for existing PRs and issues — see section below
  • My PR contains only changes related to this feature (1 commit, 6 files, +110/-2)
  • I've run the relevant test file and all tests pass (24/24 in test_discord_free_response.py)
  • I've added tests for my changes (4 new tests covering opt-in, default, voice-linked, global disable)
  • I've tested on my platform: macOS 15 (Darwin) + Python 3.11

Documentation & Housekeeping

  • I've updated relevant documentation (website/docs/user-guide/messaging/discord.md, website/docs/reference/environment-variables.md)
  • I've updated the DEFAULT_CONFIG in hermes_cli/config.py so the new key is discoverable via hermes config check
  • I've updated CONTRIBUTING.md or AGENTS.md — N/A (no architecture change)
  • I've considered cross-platform impact — pure config/logic change, no platform-specific code
  • I've updated tool descriptions/schemas — N/A (no tool changes)

Prior Art Search

I searched existing issues and PRs before starting; the following are related but do not overlap:

# State Relationship
#15262 OPEN (issue) This PR fixes it — the issue explicitly proposes a separate free_response_channels_create_thread / equivalent toggle.
#11629 MERGED Established the current "free_response_channels ALWAYS skip auto-thread" invariant. This PR keeps that invariant as the default and only adds an opt-in path.
#8597 OPEN Orthogonal — makes auto_thread fire only on explicit @mention in all channels. Different knob, different intent. If #8597 lands first, this PR still applies cleanly (different lines, different condition).
#9650 CLOSED Earlier attempt at a three-mode auto_thread: smart design. Superseded by #11629. This PR deliberately avoids adding new modes to auto_thread — it's a plain boolean side-flag.
#4611, #5882 CLOSED Unrelated (per-channel auto-thread exclusion lists, which Hermes now has as no_thread_channels).

No open or merged PR currently introduces DISCORD_FREE_RESPONSE_AUTO_THREAD or an equivalent per-free-channel toggle.

Design Notes

  • Why a separate flag instead of adding a third auto_thread mode ("smart", "mentions-only", etc.)? Keeps auto_thread a clean boolean, matches the surrounding flag style (reactions, require_mention, etc.), and composes cleanly with future work like feat(gateway): auto_thread only creates threads on @mention #8597 (which refines what triggers auto_thread globally) without either side needing to know about the other.
  • Why keep voice-linked channels skipping the thread even when opted in? Voice-linked text channels use the voice session itself as the conversation unit; threading on top of that double-nests conversations. Worth preserving regardless of this flag; can be revisited in a follow-up if there's demand.
  • Why gate behind the global DISCORD_AUTO_THREAD? The new flag is a refinement of existing auto-thread behavior, not an override. If someone disables auto-thread globally, they shouldn't get threads anywhere — including free-response channels. This matches user intent and avoids a surprising interaction.

Free-response channels skip auto-threading by default so the bot replies
inline (lightweight chat mode). This prevented users who wanted BOTH
mention-free replies AND per-conversation threads from getting either.

Add a new opt-in `discord.free_response_auto_thread` (env:
`DISCORD_FREE_RESPONSE_AUTO_THREAD`, default false) that, when true,
re-enables auto-threading in free-response channels. Voice-linked
channels continue to skip auto-thread regardless, and the flag is
gated behind the global `DISCORD_AUTO_THREAD=true`.

Default behavior is unchanged; all 291 existing discord tests pass.
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competes with #17564 which implements the same feature (threaded free-response channels) via a different config surface (thread_free_response_channels list vs boolean flag).

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/gateway Gateway runner, session dispatch, delivery platform/discord Discord bot adapter labels May 1, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competes with #17564

@stoicborg

stoicborg commented May 3, 2026

Copy link
Copy Markdown

+1 — this exactly covers my use case: a private Discord server where the bot responds without mentions but I'd still like per-conversation thread isolation to avoid context pollution and long-context token burn.

I arrived at the same design (#19131) and am closing it in favor of this more complete implementation (tests + docs + config defaults).

Resolves conflict in gateway/platforms/discord.py.

Upstream commit ad4542b ('fix(gateway): allow free_response_channels to
override DISCORD_IGNORE_NO_MENTION') removed the unconditional
'or is_free_channel' clause from skip_thread, which would have made
free-response channels auto-thread by default. However, upstream also
kept test_discord_free_channel_skips_auto_thread which still asserts
free channels skip auto-thread by default — so upstream main's tests
were inconsistent with its own code.

This PR's design (skip_thread for free channels by default + opt-in flag
DISCORD_FREE_RESPONSE_AUTO_THREAD to enable threading) is preserved,
which also satisfies the upstream test that was failing on main alone.
@FunJim

FunJim commented May 11, 2026

Copy link
Copy Markdown
Author

Closing as no longer needed after re-checking current main.

Current main already auto-threads free-response channels by default: the Discord handler now computes skip_thread = bool(channel_ids & no_thread_channels) and no longer includes or is_free_channel, so a message in DISCORD_FREE_RESPONSE_CHANNELS bypasses the mention gate and still creates a thread when DISCORD_AUTO_THREAD=true unless the channel is in DISCORD_NO_THREAD_CHANNELS, voice-linked, a reply message, a DM/thread, or auto-threading is globally disabled.

The behavior change came from ad4542bf6 (fix(gateway): allow free_response_channels to override DISCORD_IGNORE_NO_MENTION), which explicitly removed the unconditional free-response thread skip. The old regression asserting inline replies for free-response channels was later removed in 66320de52.

Since this PR would reintroduce default inline behavior unless DISCORD_FREE_RESPONSE_AUTO_THREAD=true is set, merging it would now regress the current expected behavior. Users who want inline free-response channels can use DISCORD_NO_THREAD_CHANNELS / discord.no_thread_channels instead.

@FunJim

FunJim commented May 15, 2026

Copy link
Copy Markdown
Author

Reopening this after looking at the follow-up history around #25311, #25444, and #26058. My earlier close comment was based on one snapshot of main, but the subsequent discussion makes the situation more nuanced.

Timeline / context:

Given that current baseline, this PR is useful again as a compatibility-preserving middle ground. It does not need to undo #25444's inline default. Instead, it can keep the documented/current behavior for normal free_response_channels, while adding an explicit opt-in for the #26058 use case:

  • default: free-response channels remain inline (DISCORD_FREE_RESPONSE_AUTO_THREAD=false), matching fix(discord): keep free-response channels inline #25311/fix(discord): keep free-response channels inline #25444 and the existing regression test;
  • opt-in: DISCORD_FREE_RESPONSE_AUTO_THREAD=true / discord.free_response_auto_thread: true lets free-response channels also auto-thread when DISCORD_AUTO_THREAD=true;
  • global/per-channel thread suppression (DISCORD_AUTO_THREAD=false, DISCORD_NO_THREAD_CHANNELS) should still win;
  • voice-linked channels should continue to avoid auto-threading.

So the framing should be: this PR is not primarily about reverting #25444. It is about resolving the tension between #25444/#25310 (inline free-response channels as documented default) and #26058 (mention-free + threaded workflow) by making threaded free-response behavior explicit and opt-in.

It probably needs a rebase/refresh against current main, because #25444 already added the inline-default branch and test. The refreshed version should preserve that test and add coverage for the opt-in path, especially:

  1. free-response channels skip auto-threading by default;
  2. DISCORD_FREE_RESPONSE_AUTO_THREAD=true allows free-response channels to auto-thread when DISCORD_AUTO_THREAD=true;
  3. DISCORD_AUTO_THREAD=false still disables thread creation globally;
  4. DISCORD_NO_THREAD_CHANNELS still disables thread creation for listed channels;
  5. voice-linked channels still do not auto-thread.

Short version: my earlier close rationale was incomplete after #25444/#26058. Reopening because this PR can provide the missing explicit opt-in for threaded free-response channels while preserving the current inline default.

Resolve Discord free-response auto-thread conflicts by preserving current main's inline default from NousResearch#25444 while keeping this PR's explicit DISCORD_FREE_RESPONSE_AUTO_THREAD opt-in for threaded free-response channels.

Keep upstream thread_require_mention/history_backfill defaults and tests, add no_thread_channels precedence coverage, and preserve docs/config env bridging for the opt-in flag.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for preserving the inline default while adding a concrete opt-in workflow. The requested capability is still absent on current main: plugins/platforms/discord/adapter.py:6223 unconditionally treats free-response channels as skip_thread, matching the current docs at website/docs/user-guide/messaging/discord.md:377.

Problems

  • The production hunk targets gateway/platforms/discord.py, but cc8e5ec moved the live adapter to plugins/platforms/discord/adapter.py; current thread routing is at lines 6220-6227.
  • The config hunk targets the legacy Discord bridge in gateway/config.py. Discord YAML bridging now belongs in the plugin hook at plugins/platforms/discord/adapter.py:8283-8317.
  • After adding this setting, clear DISCORD_FREE_RESPONSE_AUTO_THREAD in the fixture at tests/gateway/test_discord_free_response.py:108-120; the existing inline-default regression at line 658 otherwise depends on the caller environment.

Suggested changes

  • Salvage the handler and YAML bridge into the plugin locations above, and add a config.yaml bridge test in addition to the routing tests.

Automated hermes-sweeper review.

@@ -4476,7 +4476,18 @@ async def _handle_message(self, message: DiscordMessage) -> None:
if not is_thread and not isinstance(message.channel, discord.DMChannel):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: cc8e5ec removed this legacy adapter path. Current main handles this route in plugins/platforms/discord/adapter.py:6220-6227; port this logic there so the feature reaches the live Discord adapter.

Comment thread gateway/config.py
@@ -921,6 +921,8 @@ def load_gateway_config() -> GatewayConfig:
os.environ["DISCORD_FREE_RESPONSE_CHANNELS"] = str(frc)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: Discord-specific YAML bridging moved into the plugin in cc8e5ec. Add this setting to _apply_yaml_config at plugins/platforms/discord/adapter.py:8283-8317; this legacy block is no longer the Discord config path.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 12, 2026
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 P3 Low — cosmetic, nice to have platform/discord Discord bot adapter sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(discord): free_response_channels now correctly honored — breaks existing workflows that relied on wildcard '*' never matching

4 participants