Add semantic titles for Discord auto-threads

This commit is contained in:
Georgio Constantinou 2026-07-02 00:20:47 -04:00 committed by Teknium
parent 9c272a306e
commit 0d9ed9214d
6 changed files with 335 additions and 14 deletions

View file

@ -5445,6 +5445,8 @@ class BasePlatformAdapter(ABC):
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
@ -5466,6 +5468,8 @@ class BasePlatformAdapter(ABC):
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

View file

@ -13317,6 +13317,86 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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,
@ -18700,6 +18780,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
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,

View file

@ -181,6 +181,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
@ -254,6 +262,10 @@ class SessionSource:
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
@ -275,6 +287,8 @@ class 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"),
)

View file

@ -5171,6 +5171,26 @@ class DiscordAdapter(BasePlatformAdapter):
# Auto-thread helpers
# ------------------------------------------------------------------
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.
"""
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.
@ -5180,19 +5200,7 @@ class DiscordAdapter(BasePlatformAdapter):
(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
# 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 = 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}"
@ -5202,6 +5210,10 @@ class DiscordAdapter(BasePlatformAdapter):
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
@ -5214,6 +5226,10 @@ class DiscordAdapter(BasePlatformAdapter):
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
@ -5232,6 +5248,64 @@ class DiscordAdapter(BasePlatformAdapter):
)
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,
@ -6015,6 +6089,11 @@ class DiscordAdapter(BasePlatformAdapter):
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

View file

@ -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
@ -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
# ------------------------------------------------------------------
@ -742,6 +784,35 @@ async def test_auto_thread_creates_thread_and_redirects(adapter, monkeypatch):
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

View file

@ -4,7 +4,7 @@ import sys
import threading
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import yaml
@ -87,6 +87,19 @@ def _make_source() -> SessionSource:
)
def _make_discord_auto_thread_source() -> SessionSource:
return SessionSource(
platform=Platform.DISCORD,
chat_id="999",
chat_type="thread",
user_id="user-1",
thread_id="999",
parent_chat_id="100",
auto_thread_created=True,
auto_thread_initial_name="raw user prompt",
)
def _make_event(text: str) -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")
@ -193,3 +206,57 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch
assert result["final_response"] == "ok"
assert _CapturingAgent.last_init["service_tier"] == "priority"
assert _CapturingAgent.last_init["request_overrides"] == {"service_tier": "priority"}
@pytest.mark.asyncio
async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch, tmp_path):
_install_fake_agent(monkeypatch)
runner = _make_runner()
runner._session_db = SimpleNamespace(_db=MagicMock()) # type: ignore[assignment]
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env")
monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None)
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {})
monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4")
monkeypatch.setattr(
gateway_run,
"_resolve_runtime_agent_kwargs",
lambda: {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "***",
},
)
import hermes_cli.tools_config as tools_config
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"})
with patch("agent.title_generator.maybe_auto_title") as mock_title:
await runner._run_agent(
message="raw user prompt",
context_prompt="",
history=[],
source=_make_discord_auto_thread_source(),
session_id="session-1",
session_key="agent:main:discord:thread:999",
)
mock_title.assert_called_once()
callback = mock_title.call_args.kwargs["title_callback"]
with patch.object(runner, "_schedule_discord_semantic_thread_rename") as mock_schedule:
callback("Semantic Session Title")
mock_schedule.assert_called_once()
assert mock_schedule.call_args.args[1] == "session-1"
assert mock_schedule.call_args.args[2] == "Semantic Session Title"
def test_session_source_preserves_discord_auto_thread_metadata():
source = _make_discord_auto_thread_source()
restored = SessionSource.from_dict(source.to_dict())
assert restored.auto_thread_created is True
assert restored.auto_thread_initial_name == "raw user prompt"