Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 56 additions & 12 deletions gateway/platforms/discord.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
sys.path.insert(0, str(_Path(__file__).resolve().parents[2]))

from gateway.config import Platform, PlatformConfig
from agent.title_generator import generate_title
import re

from gateway.platforms.helpers import MessageDeduplicator, ThreadParticipationTracker
Expand Down Expand Up @@ -2825,18 +2826,7 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:

Returns the created thread object, or ``None`` on failure.
"""
# Build a short thread name from the message. Strip Discord mention
# syntax (users / roles / channels) so thread titles don't end up
# showing raw <@id>, <@&id>, or <#id> markers — the ID isn't
# meaningful to humans glancing at the thread list (#6336).
content = (message.content or "").strip()
# <@123>, <@!123>, <@&123>, <#123> — collapse to empty; normalize spaces.
content = re.sub(r"<@[!&]?\d+>", "", content)
content = re.sub(r"<#\d+>", "", content)
content = re.sub(r"\s+", " ", content).strip()
thread_name = content[:80] if content else "Hermes"
if len(content) > 80:
thread_name = thread_name[:77] + "..."
thread_name = await self._derive_auto_thread_name(message)

try:
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
Expand All @@ -2861,6 +2851,60 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
)
return None

def _discord_thread_naming_mode(self) -> str:
"""Return Discord auto-thread naming mode.

Supported values:
- ``first_message`` (default): derive from message content
- ``ai``: ask the auxiliary title generator for a short title
"""
configured = self.config.extra.get("thread_naming")
if isinstance(configured, str):
normalized = configured.strip().lower()
if normalized in {"first_message", "ai"}:
return normalized

raw = os.getenv("DISCORD_THREAD_NAMING", "first_message")
normalized = str(raw).strip().lower()
if normalized in {"first_message", "ai"}:
return normalized
return "first_message"

def _derive_message_thread_name(self, content: str) -> str:
"""Derive a short thread title directly from message content."""
# Strip Discord mention syntax so thread titles don't end up showing
# raw <@id>, <@&id>, or <#id> markers.
content = re.sub(r"<@[!&]?\d+>", "", content)
content = re.sub(r"<#\d+>", "", content)
content = re.sub(r"\s+", " ", content).strip()
thread_name = content[:80] if content else "Hermes"
if len(content) > 80:
thread_name = thread_name[:77] + "..."
return thread_name

async def _derive_auto_thread_name(self, message: 'DiscordMessage') -> str:
"""Resolve a thread title according to configured naming strategy."""
content = (message.content or "").strip()
fallback_name = self._derive_message_thread_name(content)
if self._discord_thread_naming_mode() != "ai":
return fallback_name

try:
ai_name = await asyncio.to_thread(
generate_title,
content,
"",
5.0,
)
except Exception as e:
logger.debug("[%s] AI thread title generation failed: %s", self.name, e)
return fallback_name

ai_name = (ai_name or "").strip()
if not ai_name:
return fallback_name
return ai_name[:80]

async def send_exec_approval(
self, chat_id: str, command: str, session_key: str,
description: str = "dangerous command",
Expand Down
1 change: 1 addition & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,6 +969,7 @@ def _ensure_hermes_home_managed(home: Path):
"free_response_channels": "", # Comma-separated channel IDs where bot responds without mention
"allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist)
"auto_thread": True, # Auto-create threads on @mention in channels (like Slack)
"thread_naming": "first_message", # first_message (default) or ai
"reactions": True, # Add 👀/✅/❌ reactions to messages during processing
"channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads)
# discord / discord_admin tools: restrict which actions the agent may call.
Expand Down
56 changes: 56 additions & 0 deletions tests/e2e/test_discord_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,59 @@ async def clobber_content(**kwargs):
response = get_response_text(discord_adapter)
assert response is not None
assert "/new" in response


class TestDiscordThreadNaming:
async def test_ai_thread_naming_uses_generated_title(self, discord_adapter, bot_user, monkeypatch):
"""When thread_naming=ai, Discord auto-thread titles come from title generator."""
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
discord_adapter.config.extra["thread_naming"] = "ai"

fake_thread = make_fake_thread(thread_id=90002, name="ai-title")
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> can you summarize this sprint?",
mentions=[bot_user],
)

captured = {}

async def create_thread(**kwargs):
captured["name"] = kwargs.get("name")
return fake_thread

msg.create_thread = AsyncMock(side_effect=create_thread)
monkeypatch.setattr("gateway.platforms.discord.generate_title", lambda *_a, **_kw: "Sprint Summary")

await dispatch(discord_adapter, msg)

msg.create_thread.assert_awaited_once()
assert captured.get("name") == "Sprint Summary"

async def test_ai_thread_naming_falls_back_to_message_name_on_failure(self, discord_adapter, bot_user, monkeypatch):
"""AI title errors should fall back to the first-message naming strategy."""
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
discord_adapter.config.extra["thread_naming"] = "ai"

fake_thread = make_fake_thread(thread_id=90003, name="fallback")
msg = make_discord_message(
content=f"<@{BOT_USER_ID}> investigate flaky CI failures please",
mentions=[bot_user],
)

captured = {}

async def create_thread(**kwargs):
captured["name"] = kwargs.get("name")
return fake_thread

msg.create_thread = AsyncMock(side_effect=create_thread)

def _raise(*_a, **_kw):
raise RuntimeError("aux unavailable")

monkeypatch.setattr("gateway.platforms.discord.generate_title", _raise)

await dispatch(discord_adapter, msg)

msg.create_thread.assert_awaited_once()
assert captured.get("name") == "investigate flaky CI failures please"