fix(discord): Hermes's own auto-thread title rename keeps the pinned prompt

chat_name keys the pinned session-context prompt, and the title lane
renames a new auto-thread once the LLM title arrives, normally before the
user's second message. Turn 2 read the new name and re-rendered the
already-sent prompt, a prompt-cache miss on every auto-threaded
conversation on the default config.

rename_thread now records the name it replaced and the name it set for a
guarded semantic rename, and _format_thread_chat_name keeps the replaced
name while the thread still carries Hermes's title. A rename by anyone
else no longer matches and re-renders as before.

(cherry picked from commit b57858e031b8af5f6fa3fa8d567907c16fafda2b)
This commit is contained in:
John Paul Soliva
2026-10-02 15:01:08 +09:00
committed by Mark S.
parent 66fa864f19
commit ec991b468b
2 changed files with 96 additions and 0 deletions

View File

@@ -89,6 +89,7 @@ _DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5
_DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0
# Discord caps global slash commands at 100/app; exceeding it fails the ENTIRE sync (error 30032).
_DISCORD_MAX_APP_COMMANDS = 100
_SEMANTIC_THREAD_RENAMES_MAX = 2000
# Native slash commands (registered before COMMAND_REGISTRY/plugins so they survive the 100 cap):
# (discord name, description, [(arg, type, default-or-_REQUIRED, arg description,
# [(choice label, value), ...] or None)], command-text template, follow-up message)
@@ -1113,6 +1114,9 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter):
self._voice_fx_cfg: Dict[str, Any] = self._load_voice_fx_config()
# Threads the bot participated in (no @mention needed there); persisted across restarts.
self._threads = ThreadParticipationTracker("discord")
# thread id -> (name it replaced, name it set) for Hermes's own semantic renames; see
# _format_thread_chat_name. In memory: after a restart the next turn re-renders once.
self._semantic_thread_renames: Dict[str, Tuple[str, str]] = {}
# Persistent typing loops per channel (DMs don't reliably show bot typing events).
self._typing_tasks: Dict[str, asyncio.Task] = {}
self._bot_task: Optional[asyncio.Task] = None
@@ -5452,6 +5456,12 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter):
return False
try:
await edit(name=cleaned, reason="Hermes semantic session title")
if only_if_current_name is not None:
renames = self._semantic_thread_renames
renames.pop(str(thread_id_int), None)
renames[str(thread_id_int)] = (current_name, cleaned)
while len(renames) > _SEMANTIC_THREAD_RENAMES_MAX:
renames.pop(next(iter(renames)))
logger.info(
"[%s] Renamed Discord thread %s from %r to %r",
self.name, thread_id, current_name, cleaned,
@@ -5795,6 +5805,12 @@ class DiscordAdapter(DiscordMediaMixin, BasePlatformAdapter):
def _format_thread_chat_name(self, thread: Any) -> str:
"""Build a readable chat name for thread-like Discord channels, including forum context when available."""
thread_name = getattr(thread, "name", None) or str(getattr(thread, "id", "thread"))
# chat_name keys the pinned session-context prompt, and Hermes's own title rename lands
# between a new thread's first and second turns: keep the name the turns were pinned under.
# A rename by anyone else no longer matches what Hermes set, so it still re-renders.
renamed = self._semantic_thread_renames.get(str(getattr(thread, "id", "")))
if renamed and renamed[1] == thread_name:
thread_name = renamed[0]
parent = getattr(thread, "parent", None)
guild = getattr(thread, "guild", None) or getattr(parent, "guild", None)
guild_name = getattr(guild, "name", None)

View File

@@ -0,0 +1,80 @@
"""Hermes's own semantic rename of an auto-created thread keeps the thread's pinned prompt.
``chat_name`` is part of the pinned session-context prompt's key. The title lane renames a new
auto-thread once the LLM title arrives, normally before the user's second message, so turn 2 read
the new name and re-rendered the already-sent prompt: a prompt-cache miss on turn 2 of every
auto-threaded conversation on the default config. A rename by anyone else is a real metadata
change and still re-renders.
"""
from datetime import datetime, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
import gateway.run as gateway_run
import plugins.platforms.discord.adapter as discord_platform
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.session import build_session_context
from plugins.platforms.discord.adapter import DiscordAdapter
class _Text:
def __init__(self, channel_id, name="ops"):
self.id, self.name, self.topic = channel_id, name, None
self.guild = SimpleNamespace(id=1, name="Hermes Server")
class _Thread:
def __init__(self, channel_id, parent, name):
self.id, self.name, self.parent, self.parent_id = channel_id, name, parent, parent.id
self.guild = parent.guild
async def edit(self, *, name, reason=None):
self.name = name
_USER = SimpleNamespace(id=42, display_name="Alice", name="alice")
def _message(channel, message_id):
return SimpleNamespace(
id=message_id, content="what broke?", mentions=[], attachments=[], reference=None,
created_at=datetime.now(timezone.utc), channel=channel, author=_USER)
@pytest.mark.asyncio
async def test_hermes_title_rename_keeps_the_pin_and_a_human_rename_does_not(monkeypatch):
monkeypatch.setattr(discord_platform.discord, "Thread", _Thread, raising=False)
monkeypatch.setattr(discord_platform, "DISCORD_AVAILABLE", True)
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
monkeypatch.setenv("DISCORD_AUTO_THREAD", "true")
parent = _Text(700)
thread = _Thread(800, parent, name="what broke?")
adapter = DiscordAdapter(PlatformConfig(enabled=True, token="fake"))
adapter._client = SimpleNamespace(user=SimpleNamespace(id=999), get_channel=lambda _id: thread)
adapter._text_batch_delay_seconds = 0
adapter._discord_history_backfill = lambda: False
adapter._auto_create_thread = AsyncMock(return_value=thread)
adapter.handle_message = AsyncMock()
runner = object.__new__(gateway_run.GatewayRunner)
config = GatewayConfig(platforms={Platform.DISCORD: PlatformConfig(enabled=True, token="x")})
async def turn(channel, message_id):
await adapter._handle_message(_message(channel, message_id))
source = adapter.handle_message.await_args.args[0].source
assert source.chat_id == "800"
return runner._pinned_session_context_prompt(build_session_context(source, config), False, "k")
first = await turn(parent, 100)
# The title lane's call, as gateway/run_topics.py makes it for a native auto-thread.
assert await adapter.rename_thread("800", "Database outage", only_if_current_name="what broke?")
assert thread.name == "Database outage"
assert await turn(thread, 101) == first
thread.name = "Renamed by a moderator"
after_human_rename = await turn(thread, 102)
assert after_human_rename != first
assert "Renamed by a moderator" in after_human_rename