mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-08 13:12:08 +00:00
Add semantic titles for Discord auto-threads
This commit is contained in:
parent
9c272a306e
commit
0d9ed9214d
6 changed files with 335 additions and 14 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue