Skip to content
Open
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
4 changes: 4 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5445,6 +5445,8 @@ def build_source(
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
role_authorized: bool = False,
auto_thread_created: bool = False,
auto_thread_initial_name: Optional[str] = None,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
# Normalize empty topic to None
Expand All @@ -5466,6 +5468,8 @@ def build_source(
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
role_authorized=role_authorized,
auto_thread_created=auto_thread_created,
auto_thread_initial_name=auto_thread_initial_name,
)

@abstractmethod
Expand Down
86 changes: 86 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -12857,6 +12857,86 @@ def _sanitize_telegram_topic_title(self, title: str) -> str:
cleaned = cleaned[:117].rstrip() + "..."
return cleaned

def _is_discord_auto_thread_lane(self, source: SessionSource) -> bool:
"""Return True only for Discord threads Hermes just auto-created."""
return (
source.platform == Platform.DISCORD
and source.chat_type == "thread"
and bool(getattr(source, "auto_thread_created", False))
and bool(source.thread_id)
and bool(getattr(source, "auto_thread_initial_name", None))
)

def _sanitize_discord_thread_title(self, title: str) -> str:
"""Return a Discord-safe semantic thread title from a session title."""
cleaned = re.sub(r"\s+", " ", str(title or "")).strip()
if not cleaned:
return "Hermes Chat"
if len(cleaned) > 80:
cleaned = cleaned[:77].rstrip() + "..."
return cleaned

async def _rename_discord_auto_thread_for_session_title(
self,
source: SessionSource,
session_id: str,
title: str,
) -> None:
"""Best-effort semantic rename of a newly auto-created Discord thread."""
if not await asyncio.to_thread(self._is_discord_auto_thread_lane, source):
return
adapter = self.adapters.get(source.platform) if getattr(self, "adapters", None) else None
if adapter is None:
return
rename_thread = getattr(adapter, "rename_thread", None)
if rename_thread is None:
return
thread_name = self._sanitize_discord_thread_title(title)
try:
await rename_thread(
str(source.thread_id),
thread_name,
only_if_current_name=getattr(source, "auto_thread_initial_name", None),
)
except Exception:
logger.debug("Failed to rename Discord auto-thread for generated session title", exc_info=True)

def _schedule_discord_semantic_thread_rename(
self,
source: SessionSource,
session_id: str,
title: str,
) -> None:
"""Schedule Discord auto-thread rename from the auto-title background thread."""
if not title or not self._is_discord_auto_thread_lane(source):
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = getattr(self, "_gateway_loop", None)
if loop is None or loop.is_closed():
return
try:
copied_source = dataclasses.replace(source)
except Exception:
copied_source = source
future = safe_schedule_threadsafe(
self._rename_discord_auto_thread_for_session_title(copied_source, session_id, title),
loop,
logger=logger,
log_message="Discord semantic thread rename failed to schedule",
)
if future is None:
return

def _log_rename_failure(fut) -> None:
try:
fut.result()
except Exception:
logger.debug("Discord semantic thread rename failed", exc_info=True)

future.add_done_callback(_log_rename_failure)

async def _rename_telegram_topic_for_session_title(
self,
source: SessionSource,
Expand Down Expand Up @@ -17858,6 +17938,12 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:
effective_session_id,
title,
)
elif self._is_discord_auto_thread_lane(source):
maybe_auto_title_kwargs["title_callback"] = lambda title: self._schedule_discord_semantic_thread_rename(
source,
effective_session_id,
title,
)
maybe_auto_title(
getattr(self._session_db, "_db", self._session_db),
effective_session_id,
Expand Down
14 changes: 14 additions & 0 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,14 @@ class SessionSource:
# namespacing and the per-turn config/credential scope.
profile: Optional[str] = None

# Discord auto-thread metadata. Newly auto-created Discord threads start
# with a fast placeholder title from the raw message, then the gateway can
# rename them after the first agent turn using the generated session title.
# Keep this explicit so pre-existing or human-renamed threads are not
# mistaken for safe rename targets.
auto_thread_created: bool = False
auto_thread_initial_name: Optional[str] = None

# Internal, wire-INVISIBLE trust signal: True when this event was delivered
# to the gateway over the per-instance-authenticated relay WebSocket (the
# Team Gateway connector). The connector authenticates the gateway's socket
Expand Down Expand Up @@ -229,6 +237,10 @@ def to_dict(self) -> Dict[str, Any]:
d["message_id"] = self.message_id
if self.profile:
d["profile"] = self.profile
if self.auto_thread_created:
d["auto_thread_created"] = True
if self.auto_thread_initial_name:
d["auto_thread_initial_name"] = self.auto_thread_initial_name
return d

@classmethod
Expand All @@ -250,6 +262,8 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
parent_chat_id=data.get("parent_chat_id"),
message_id=data.get("message_id"),
profile=data.get("profile"),
auto_thread_created=bool(data.get("auto_thread_created", False)),
auto_thread_initial_name=data.get("auto_thread_initial_name"),
)


Expand Down
105 changes: 92 additions & 13 deletions plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5127,28 +5127,36 @@ async def _create_thread(
# Auto-thread helpers
# ------------------------------------------------------------------

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. 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).
def _derive_auto_thread_name(self, content: str) -> str:
"""Return the fast placeholder name used at Discord thread creation time.

Strip Discord mention syntax (users / roles / channels) so thread
titles don't show raw <@id>, <@&id>, or <#id> markers — the ID
isn't meaningful to humans glancing at the thread list (#6336).
Real semantic naming is done after the first agent turn, when
Hermes has an LLM-generated session title and can safely rename
only this newly-created thread.
"""
# 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()
content = (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] + "..."
return thread_name

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. 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).
"""
thread_name = self._derive_auto_thread_name(message.content or "")
display_name = getattr(getattr(message, "author", None), "display_name", None) or "unknown user"
reason = f"Auto-threaded from mention by {display_name}"

Expand All @@ -5158,6 +5166,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
for attempt in range(2):
try:
thread = await message.create_thread(name=thread_name, auto_archive_duration=1440)
try:
setattr(thread, "_hermes_auto_thread_initial_name", thread_name)
except Exception:
pass
return thread
except Exception as direct_error:
last_direct_error = direct_error
Expand All @@ -5170,6 +5182,10 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
auto_archive_duration=1440,
reason=reason,
)
try:
setattr(thread, "_hermes_auto_thread_initial_name", thread_name)
except Exception:
pass
return thread
except Exception as fallback_error:
last_fallback_error = fallback_error
Expand All @@ -5188,6 +5204,64 @@ async def _auto_create_thread(self, message: 'DiscordMessage') -> Optional[Any]:
)
return None

async def rename_thread(
self,
thread_id: str,
name: str,
*,
only_if_current_name: Optional[str] = None,
) -> bool:
"""Best-effort Discord thread rename.

``only_if_current_name`` prevents overwriting human-renamed or
pre-existing threads. This is intentionally a no-op on mismatch.
"""
if not self._client or not DISCORD_AVAILABLE:
return False

try:
thread_id_int = int(str(thread_id))
except (TypeError, ValueError):
return False

cleaned = re.sub(r"\s+", " ", str(name or "")).strip()
if not cleaned:
return False
if len(cleaned) > 80:
cleaned = cleaned[:77].rstrip() + "..."

try:
thread = self._client.get_channel(thread_id_int)
if thread is None:
thread = await self._client.fetch_channel(thread_id_int)
except Exception:
logger.debug("[%s] Failed to resolve Discord thread %s for rename", self.name, thread_id, exc_info=True)
return False

current_name = getattr(thread, "name", None)
if only_if_current_name is not None and current_name != only_if_current_name:
logger.info(
"[%s] Discord semantic thread rename skipped for %s: current name %r != expected %r",
self.name, thread_id, current_name, only_if_current_name,
)
return False
if current_name == cleaned:
return True

edit = getattr(thread, "edit", None)
if edit is None:
return False
try:
await edit(name=cleaned, reason="Hermes semantic session title")
logger.info(
"[%s] Renamed Discord thread %s from %r to %r",
self.name, thread_id, current_name, cleaned,
)
return True
except Exception:
logger.debug("[%s] Failed to rename Discord thread %s", self.name, thread_id, exc_info=True)
return False

async def create_handoff_thread(
self,
parent_chat_id: str,
Expand Down Expand Up @@ -5966,6 +6040,11 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =
parent_chat_id=parent_channel_id,
message_id=str(message.id),
role_authorized=role_authorized,
auto_thread_created=auto_threaded_channel is not None,
auto_thread_initial_name=(
getattr(auto_threaded_channel, "_hermes_auto_thread_initial_name", None)
or self._derive_auto_thread_name(message.content or "")
) if auto_threaded_channel is not None else None,
)

# Build media URLs -- download image attachments to local cache so the
Expand Down
71 changes: 71 additions & 0 deletions tests/gateway/test_discord_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,7 @@ async def test_auto_create_thread_uses_message_content_as_name(adapter):
call_kwargs = message.create_thread.await_args[1]
assert call_kwargs["name"] == "Hello world, how are you?"
assert call_kwargs["auto_archive_duration"] == 1440
assert thread._hermes_auto_thread_initial_name == "Hello world, how are you?"


@pytest.mark.asyncio
Expand Down Expand Up @@ -659,6 +660,47 @@ async def test_auto_create_thread_returns_none_when_direct_and_fallback_fail(ada
assert result is None


@pytest.mark.asyncio
async def test_rename_thread_edits_only_when_current_name_matches(adapter):
thread = SimpleNamespace(
id=999,
name="raw user prompt",
edit=AsyncMock(),
)
adapter._client.get_channel = lambda _id: thread

result = await adapter.rename_thread(
"999",
"Semantic Session Title",
only_if_current_name="raw user prompt",
)

assert result is True
thread.edit.assert_awaited_once_with(
name="Semantic Session Title",
reason="Hermes semantic session title",
)


@pytest.mark.asyncio
async def test_rename_thread_skips_when_human_renamed(adapter):
thread = SimpleNamespace(
id=999,
name="human fixed this already",
edit=AsyncMock(),
)
adapter._client.get_channel = lambda _id: thread

result = await adapter.rename_thread(
"999",
"Semantic Session Title",
only_if_current_name="raw user prompt",
)

assert result is False
thread.edit.assert_not_awaited()


# ------------------------------------------------------------------
# Auto-thread integration in _handle_message
# ------------------------------------------------------------------
Expand Down Expand Up @@ -742,6 +784,35 @@ async def capture_handle(event):
assert event.source.chat_id == "999" # redirected to thread
assert event.source.chat_type == "thread"
assert event.source.thread_id == "999"
assert event.source.auto_thread_created is True


@pytest.mark.asyncio
async def test_auto_thread_source_carries_initial_name_for_semantic_rename(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")

thread = SimpleNamespace(
id=999,
name="raw user prompt",
_hermes_auto_thread_initial_name="raw user prompt",
)
adapter._auto_create_thread = AsyncMock(return_value=thread)

captured_events = []

async def capture_handle(event):
captured_events.append(event)

adapter.handle_message = capture_handle

msg = _fake_message(_FakeTextChannel(), content="raw user prompt")

await adapter._handle_message(msg)

source = captured_events[0].source
assert source.auto_thread_created is True
assert source.auto_thread_initial_name == "raw user prompt"


@pytest.mark.asyncio
Expand Down
Loading
Loading