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
2 changes: 2 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1206,6 +1206,8 @@ def _merge_platform_map(source_platforms: Any) -> None:
bridged["allowed_topics"] = platform_cfg["allowed_topics"]
if "free_response_channels" in platform_cfg:
bridged["free_response_channels"] = platform_cfg["free_response_channels"]
if plat == Platform.DISCORD and "auto_rename_threads" in platform_cfg:
bridged["auto_rename_threads"] = platform_cfg["auto_rename_threads"]
if "mention_patterns" in platform_cfg:
bridged["mention_patterns"] = platform_cfg["mention_patterns"]
if "exclusive_bot_mentions" in platform_cfg:
Expand Down
2 changes: 2 additions & 0 deletions gateway/platforms/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -5469,6 +5469,7 @@ def build_source(
message_id: Optional[str] = None,
role_authorized: bool = False,
auto_thread_created: bool = False,
auto_thread_rename_allowed: bool = False,
auto_thread_initial_name: Optional[str] = None,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
Expand All @@ -5492,6 +5493,7 @@ def build_source(
message_id=str(message_id) if message_id else None,
role_authorized=role_authorized,
auto_thread_created=auto_thread_created,
auto_thread_rename_allowed=auto_thread_rename_allowed,
auto_thread_initial_name=auto_thread_initial_name,
)

Expand Down
28 changes: 20 additions & 8 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -13682,11 +13682,14 @@ def _sanitize_telegram_topic_title(self, title: str) -> str:
return cleaned

def _is_discord_auto_thread_lane(self, source: SessionSource) -> bool:
"""Return True only for Discord threads Hermes just auto-created."""
"""Return True for explicitly provenance-gated Discord thread renames."""
return (
source.platform == Platform.DISCORD
and source.chat_type == "thread"
and bool(getattr(source, "auto_thread_created", False))
and (
bool(getattr(source, "auto_thread_created", False))
or bool(getattr(source, "auto_thread_rename_allowed", False))
)
and bool(source.thread_id)
and bool(getattr(source, "auto_thread_initial_name", None))
)
Expand All @@ -13711,7 +13714,7 @@ async def _rename_discord_auto_thread_for_session_title(
session_id: str,
title: str,
) -> None:
"""Best-effort semantic rename of a newly auto-created Discord thread."""
"""Best-effort guarded semantic rename of a 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
Expand Down Expand Up @@ -19278,11 +19281,20 @@ def _title_failure_cb(task: str, exc: BaseException) -> None:
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,
)
_title_db = getattr(self._session_db, "_db", self._session_db)
_existing_title = _title_db.get_session_title(effective_session_id)
if isinstance(_existing_title, str) and _existing_title.strip():
self._schedule_discord_semantic_thread_rename(
source,
effective_session_id,
_existing_title,
)
else:
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
12 changes: 7 additions & 5 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,12 +182,11 @@ 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.
# Discord semantic-rename provenance. Newly auto-created threads and opted-in
# pre-existing generic threads store the exact observed name so a later
# semantic rename can refuse to overwrite a human change.
auto_thread_created: bool = False
auto_thread_rename_allowed: bool = False
auto_thread_initial_name: Optional[str] = None

# Internal, wire-INVISIBLE trust signal: True when this event was delivered
Expand Down Expand Up @@ -265,6 +264,8 @@ def to_dict(self) -> Dict[str, Any]:
d["profile"] = self.profile
if self.auto_thread_created:
d["auto_thread_created"] = True
if self.auto_thread_rename_allowed:
d["auto_thread_rename_allowed"] = True
if self.auto_thread_initial_name:
d["auto_thread_initial_name"] = self.auto_thread_initial_name
return d
Expand All @@ -289,6 +290,7 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionSource":
message_id=data.get("message_id"),
profile=data.get("profile"),
auto_thread_created=bool(data.get("auto_thread_created", False)),
auto_thread_rename_allowed=bool(data.get("auto_thread_rename_allowed", False)),
auto_thread_initial_name=data.get("auto_thread_initial_name"),
)

Expand Down
44 changes: 43 additions & 1 deletion plugins/platforms/discord/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ def __init__(self, id: int) -> None: # noqa: A002 - matches discord API
_DISCORD_NONCONVERSATIONAL_STATE_FILENAME = "discord_nonconversational_messages.json"
_DISCORD_COMMAND_SYNC_MUTATION_INTERVAL_SECONDS = 4.5
_DISCORD_COMMAND_SYNC_MAX_RATE_LIMIT_SLEEP_SECONDS = 30.0
_DISCORD_GENERIC_THREAD_TITLES = frozenset({
"thread", "new thread", "nova thread", "untitled", "sem titulo", "sem título",
"discussion", "discussao", "discussão", "topic", "topico", "tópico",
})
_DISCORD_GENERIC_THREAD_PREFIXES = (
"new thread", "nova thread", "thread-", "thread ", "untitled", "sem titulo", "sem título",
)
_DISCORD_THREAD_TRAILING_TRUNCATION_RE = re.compile(r"(?:\.{2,}|…|⋯|‥)\s*$")
# Discord enforces a hard cap of 100 global application (slash) commands per
# app. Registering more makes the ENTIRE sync fail with error 30032
# ("Maximum number of application commands reached"), which silently breaks
Expand Down Expand Up @@ -274,6 +282,20 @@ def _clean_discord_id(entry: str) -> str:
return entry.strip()


def _discord_thread_title_looks_generic(name: str) -> bool:
"""Return whether an opted-in existing thread is safe to retitle."""
raw = str(name or "").strip()
lowered = re.sub(r"\s+", " ", raw).lower()
return (
not lowered
or lowered in _DISCORD_GENERIC_THREAD_TITLES
or any(lowered.startswith(prefix) for prefix in _DISCORD_GENERIC_THREAD_PREFIXES)
or bool(re.search(r"https?://\S+", raw, re.IGNORECASE))
or bool(_DISCORD_THREAD_TRAILING_TRUNCATION_RE.search(raw))
or utf16_len(raw) >= 80
)


def check_discord_requirements() -> bool:
"""Check if Discord dependencies are available.

Expand Down Expand Up @@ -902,6 +924,12 @@ def __init__(self, config: PlatformConfig):
self._last_overflow_preview: Dict[tuple, str] = {}
self._warned_fail_closed_default = False

def _auto_rename_existing_threads_enabled(self) -> bool:
raw = self.config.extra.get("auto_rename_threads") if isinstance(self.config.extra, dict) else None
if isinstance(raw, dict):
return bool(raw.get("enabled", False))
return bool(raw)

def _handle_bot_task_done(self, task: asyncio.Task) -> None:
"""Surface post-startup discord.py task exits to the gateway supervisor.

Expand Down Expand Up @@ -6326,6 +6354,17 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =
# so forum descriptions (e.g. project instructions) appear in the session context.
chat_topic = self._get_effective_topic(message.channel, is_thread=is_thread)

# Opted-in pre-existing generic threads reuse the durable semantic-title
# path used by Hermes-created threads. The exact name observed at ingress
# is persisted and rename_thread() applies only if it still matches.
existing_thread_name = str(getattr(effective_channel, "name", "") or "")
auto_thread_rename_allowed = (
is_thread
and auto_threaded_channel is None
and self._auto_rename_existing_threads_enabled()
and _discord_thread_title_looks_generic(existing_thread_name)
)

# Build source
guild = getattr(message, "guild", None)
source = self.build_source(
Expand All @@ -6342,10 +6381,13 @@ async def _handle_message(self, message: DiscordMessage, role_authorized: bool =
message_id=str(message.id),
role_authorized=role_authorized,
auto_thread_created=auto_threaded_channel is not None,
auto_thread_rename_allowed=auto_thread_rename_allowed,
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,
) if auto_threaded_channel is not None else (
existing_thread_name if auto_thread_rename_allowed else None
),
)

# Build media URLs -- download image attachments to local cache so the
Expand Down
38 changes: 37 additions & 1 deletion tests/gateway/test_discord_slash_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import pytest

from gateway.config import PlatformConfig
from gateway.session import SessionSource


def _ensure_discord_mock():
Expand Down Expand Up @@ -75,7 +76,10 @@ def __init__(self, *, name, description, callback, parent=None):

_ensure_discord_mock()

from plugins.platforms.discord.adapter import DiscordAdapter # noqa: E402
from plugins.platforms.discord.adapter import ( # noqa: E402
DiscordAdapter,
_discord_thread_title_looks_generic,
)


class FakeTree:
Expand Down Expand Up @@ -843,6 +847,38 @@ async def capture_handle(event):
assert source.auto_thread_initial_name == "raw user prompt"


def test_existing_thread_generic_title_classifier():
assert _discord_thread_title_looks_generic("nova thread") is True
assert _discord_thread_title_looks_generic("Investigate recurring Discord title failures…") is True
assert _discord_thread_title_looks_generic("Falha recorrente do parity guard") is False


@pytest.mark.asyncio
async def test_opted_in_existing_generic_thread_uses_durable_semantic_rename_lane(adapter, monkeypatch):
monkeypatch.setenv("DISCORD_REQUIRE_MENTION", "false")
adapter.config.extra["auto_rename_threads"] = {"enabled": True}
captured_events = []

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

adapter.handle_message = capture_handle
msg = _fake_message(
_FakeThreadChannel(name="nova thread"),
content="Investigate recurring parity guard failures",
)

await adapter._handle_message(msg)

source = captured_events[0].source
assert source.auto_thread_created is False
assert source.auto_thread_rename_allowed is True
assert source.auto_thread_initial_name == "nova thread"
restored = SessionSource.from_dict(source.to_dict())
assert restored.auto_thread_rename_allowed is True
assert restored.auto_thread_initial_name == "nova thread"


@pytest.mark.asyncio
async def test_auto_thread_enabled_by_default_slash_commands(adapter, monkeypatch):
"""Without DISCORD_AUTO_THREAD env var, auto-threading is enabled (default: true)."""
Expand Down
88 changes: 84 additions & 4 deletions tests/gateway/test_fast_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import gateway.run as gateway_run
from gateway.config import Platform
from gateway.platforms.base import MessageEvent
from gateway.platforms.base import MessageEvent, utf16_len
from gateway.session import SessionSource


Expand Down Expand Up @@ -100,6 +100,19 @@ def _make_discord_auto_thread_source() -> SessionSource:
)


def _make_discord_existing_thread_rename_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_rename_allowed=True,
auto_thread_initial_name="nova thread",
)


def _make_event(text: str) -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")

Expand Down Expand Up @@ -208,8 +221,13 @@ async def test_run_agent_passes_priority_processing_to_gateway_agent(monkeypatch
assert _CapturingAgent.last_init["request_overrides"] == {"service_tier": "priority"}


@pytest.mark.parametrize(
"source",
[_make_discord_auto_thread_source(), _make_discord_existing_thread_rename_source()],
ids=["hermes-created", "opted-in-generic-existing"],
)
@pytest.mark.asyncio
async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch, tmp_path):
async def test_run_agent_passes_discord_thread_title_callback(monkeypatch, tmp_path, source):
_install_fake_agent(monkeypatch)
runner = _make_runner()
runner._session_db = SimpleNamespace(_db=MagicMock()) # type: ignore[assignment]
Expand All @@ -234,17 +252,19 @@ async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch,
import hermes_cli.tools_config as tools_config
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda user_config, platform_key: {"core"})

inbound_message = "Investigate recurring parity guard failures"
with patch("agent.title_generator.maybe_auto_title") as mock_title:
await runner._run_agent(
message="raw user prompt",
message=inbound_message,
context_prompt="",
history=[],
source=_make_discord_auto_thread_source(),
source=source,
session_id="session-1",
session_key="agent:main:discord:thread:999",
)

mock_title.assert_called_once()
assert mock_title.call_args.args[2] == inbound_message
callback = mock_title.call_args.kwargs["title_callback"]
with patch.object(runner, "_schedule_discord_semantic_thread_rename") as mock_schedule:
callback("Semantic Session Title")
Expand All @@ -253,10 +273,70 @@ async def test_run_agent_passes_discord_auto_thread_title_callback(monkeypatch,
assert mock_schedule.call_args.args[2] == "Semantic Session Title"


@pytest.mark.asyncio
async def test_existing_thread_reuses_durable_session_title(monkeypatch, tmp_path):
_install_fake_agent(monkeypatch)
runner = _make_runner()
title_db = MagicMock()
title_db.get_session_title.return_value = "Existing semantic title"
runner._session_db = SimpleNamespace(_db=title_db) # 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.object(runner, "_schedule_discord_semantic_thread_rename") as mock_schedule:
await runner._run_agent(
message="Investigate recurring parity guard failures",
context_prompt="",
history=[],
source=_make_discord_existing_thread_rename_source(),
session_id="session-1",
session_key="agent:main:discord:thread:999",
)

mock_schedule.assert_called_once()
assert mock_schedule.call_args.args[2] == "Existing semantic 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"


def test_existing_thread_rename_source_uses_semantic_title_lane():
runner = _make_runner()
source = _make_discord_existing_thread_rename_source()

assert runner._is_discord_auto_thread_lane(source) is True
restored = SessionSource.from_dict(source.to_dict())
assert restored.auto_thread_rename_allowed is True
assert restored.auto_thread_initial_name == "nova thread"


def test_discord_thread_title_truncation_is_utf16_safe():
runner = _make_runner()

title = runner._sanitize_discord_thread_title("😀" * 50)

assert utf16_len(title) <= 80
assert title.endswith("...")
Loading
Loading