diff --git a/gateway/config.py b/gateway/config.py index 7ce105f331..b558ea59f9 100644 --- a/gateway/config.py +++ b/gateway/config.py @@ -554,6 +554,12 @@ def load_gateway_config() -> GatewayConfig: bridged["mention_patterns"] = platform_cfg["mention_patterns"] if plat == Platform.DISCORD and "channel_skill_bindings" in platform_cfg: bridged["channel_skill_bindings"] = platform_cfg["channel_skill_bindings"] + if plat == Platform.DISCORD and "channel_prompts" in platform_cfg: + channel_prompts = platform_cfg["channel_prompts"] + if isinstance(channel_prompts, dict): + bridged["channel_prompts"] = {str(k): v for k, v in channel_prompts.items()} + else: + bridged["channel_prompts"] = channel_prompts if not bridged: continue plat_data = platforms_data.setdefault(plat.value, {}) diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 1561cd526f..2d3e54698a 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -682,6 +682,10 @@ class MessageEvent: # Auto-loaded skill(s) for topic/channel bindings (e.g., Telegram DM Topics, # Discord channel_skill_bindings). A single name or ordered list. auto_skill: Optional[str | list[str]] = None + + # Per-channel ephemeral system prompt (e.g. Discord channel_prompts). + # Applied at API call time and never persisted to transcript history. + channel_prompt: Optional[str] = None # Internal flag — set for synthetic events (e.g. background process # completion notifications) that must bypass user authorization checks. diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 091b15f61c..da56a61af0 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -1992,11 +1992,14 @@ class DiscordAdapter(BasePlatformAdapter): ) msg_type = MessageType.COMMAND if text.startswith("/") else MessageType.TEXT + channel_id = str(interaction.channel_id) + parent_id = str(getattr(getattr(interaction, "channel", None), "parent_id", "") or "") return MessageEvent( text=text, message_type=msg_type, source=source, raw_message=interaction, + channel_prompt=self._resolve_channel_prompt(channel_id, parent_id or None), ) # ------------------------------------------------------------------ @@ -2067,14 +2070,17 @@ class DiscordAdapter(BasePlatformAdapter): chat_topic=chat_topic, ) - _parent_id = str(getattr(getattr(interaction, "channel", None), "parent_id", "") or "") + _parent_channel = self._thread_parent_channel(getattr(interaction, "channel", None)) + _parent_id = str(getattr(_parent_channel, "id", "") or "") _skills = self._resolve_channel_skills(thread_id, _parent_id or None) + _channel_prompt = self._resolve_channel_prompt(thread_id, _parent_id or None) event = MessageEvent( text=text, message_type=MessageType.TEXT, source=source, raw_message=interaction, auto_skill=_skills, + channel_prompt=_channel_prompt, ) await self.handle_message(event) @@ -2103,6 +2109,34 @@ class DiscordAdapter(BasePlatformAdapter): return list(dict.fromkeys(skills)) # dedup, preserve order return None + def _resolve_channel_prompt(self, channel_id: str, parent_id: str | None = None) -> str | None: + """Resolve a Discord per-channel prompt, preferring the exact channel over its parent. + + Config format (in platform extra): + channel_prompts: + "123456": "Prompt text" + + Forum/thread messages inherit the parent forum/channel prompt when the + thread itself has no explicit override. + """ + prompts = self.config.extra.get("channel_prompts") or {} + if not isinstance(prompts, dict): + return None + prompts = {str(k): v for k, v in prompts.items()} + + for key in (channel_id, parent_id): + if not key: + continue + prompt = prompts.get(key) + if prompt is None: + prompt = prompts.get(str(key)) + if prompt is None: + continue + prompt = str(prompt).strip() + if prompt: + return prompt + return None + def _thread_parent_channel(self, channel: Any) -> Any: """Return the parent text channel when invoked from a thread.""" return getattr(channel, "parent", None) or channel @@ -2654,6 +2688,7 @@ class DiscordAdapter(BasePlatformAdapter): _parent_id = str(getattr(_chan, "parent_id", "") or "") _chan_id = str(getattr(_chan, "id", "")) _skills = self._resolve_channel_skills(_chan_id, _parent_id or None) + _channel_prompt = self._resolve_channel_prompt(_chan_id, _parent_id or None) reply_to_id = None reply_to_text = None @@ -2674,6 +2709,7 @@ class DiscordAdapter(BasePlatformAdapter): reply_to_text=reply_to_text, timestamp=message.created_at, auto_skill=_skills, + channel_prompt=_channel_prompt, ) # Track thread participation so the bot won't require @mention for diff --git a/gateway/run.py b/gateway/run.py index 80797358d5..1c74b03ca3 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2891,6 +2891,7 @@ class GatewayRunner: message_type=_MT.TEXT, source=event.source, message_id=event.message_id, + channel_prompt=getattr(event, "channel_prompt", None), ) adapter._pending_messages[_quick_key] = queued_event return "Queued for the next turn." @@ -3868,6 +3869,7 @@ class GatewayRunner: session_id=session_entry.session_id, session_key=session_key, event_message_id=event.message_id, + channel_prompt=getattr(event, "channel_prompt", None), ) # Stop persistent typing indicator now that the agent is done @@ -5089,6 +5091,7 @@ class GatewayRunner: message_type=MessageType.TEXT, source=source, raw_message=event.raw_message, + channel_prompt=getattr(event, "channel_prompt", None), ) # Let the normal message handler process it @@ -8069,6 +8072,7 @@ class GatewayRunner: session_key: str = None, _interrupt_depth: int = 0, event_message_id: Optional[str] = None, + channel_prompt: Optional[str] = None, ) -> Dict[str, Any]: """ Run the agent with the given message and context. @@ -8423,8 +8427,12 @@ class GatewayRunner: # Platform.LOCAL ("local") maps to "cli"; others pass through as-is. platform_key = "cli" if source.platform == Platform.LOCAL else source.platform.value - # Combine platform context with user-configured ephemeral system prompt + # Combine platform context, per-channel context, and the user-configured + # ephemeral system prompt. combined_ephemeral = context_prompt or "" + event_channel_prompt = (channel_prompt or "").strip() + if event_channel_prompt: + combined_ephemeral = (combined_ephemeral + "\n\n" + event_channel_prompt).strip() if self._ephemeral_system_prompt: combined_ephemeral = (combined_ephemeral + "\n\n" + self._ephemeral_system_prompt).strip() @@ -9376,6 +9384,7 @@ class GatewayRunner: session_key=session_key, _interrupt_depth=_interrupt_depth + 1, event_message_id=next_message_id, + channel_prompt=getattr(pending_event, "channel_prompt", None), ) finally: # Stop progress sender, interrupt monitor, and notification task diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 71f025adfd..99c7f003f6 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -659,6 +659,7 @@ DEFAULT_CONFIG = { "allowed_channels": "", # If set, bot ONLY responds in these channel IDs (whitelist) "auto_thread": True, # Auto-create threads on @mention in channels (like Slack) "reactions": True, # Add 👀/✅/❌ reactions to messages during processing + "channel_prompts": {}, # Per-channel ephemeral system prompts (forum parents apply to child threads) }, # WhatsApp platform settings (gateway mode) @@ -724,7 +725,7 @@ DEFAULT_CONFIG = { }, # Config schema version - bump this when adding new required fields - "_config_version": 17, + "_config_version": 18, } # ============================================================================= diff --git a/tests/gateway/test_config.py b/tests/gateway/test_config.py index c08e263dd0..7b64331b93 100644 --- a/tests/gateway/test_config.py +++ b/tests/gateway/test_config.py @@ -193,6 +193,27 @@ class TestLoadGatewayConfig: assert config.thread_sessions_per_user is False + def test_bridges_discord_channel_prompts_from_config_yaml(self, tmp_path, monkeypatch): + hermes_home = tmp_path / ".hermes" + hermes_home.mkdir() + config_path = hermes_home / "config.yaml" + config_path.write_text( + "discord:\n" + " channel_prompts:\n" + " \"123\": Research mode\n" + " 456: Therapist mode\n", + encoding="utf-8", + ) + + monkeypatch.setenv("HERMES_HOME", str(hermes_home)) + + config = load_gateway_config() + + assert config.platforms[Platform.DISCORD].extra["channel_prompts"] == { + "123": "Research mode", + "456": "Therapist mode", + } + def test_invalid_quick_commands_in_config_yaml_are_ignored(self, tmp_path, monkeypatch): hermes_home = tmp_path / ".hermes" hermes_home.mkdir() diff --git a/tests/gateway/test_discord_channel_prompts.py b/tests/gateway/test_discord_channel_prompts.py new file mode 100644 index 0000000000..633fa17bc3 --- /dev/null +++ b/tests/gateway/test_discord_channel_prompts.py @@ -0,0 +1,229 @@ +"""Tests for Discord channel_prompts resolution and injection.""" + +import sys +import threading +import types +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import gateway.run as gateway_run +from gateway.config import Platform +from gateway.platforms.base import MessageEvent +from gateway.session import SessionSource + + +class _CapturingAgent: + last_init = None + + def __init__(self, *args, **kwargs): + type(self).last_init = dict(kwargs) + self.tools = [] + + def run_conversation(self, user_message, conversation_history=None, task_id=None, persist_user_message=None): + return { + "final_response": "ok", + "messages": [], + "api_calls": 1, + "completed": True, + } + + +def _install_fake_agent(monkeypatch): + fake_run_agent = types.ModuleType("run_agent") + fake_run_agent.AIAgent = _CapturingAgent + monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent) + + +def _make_adapter(): + from gateway.platforms.discord import DiscordAdapter + + adapter = object.__new__(DiscordAdapter) + adapter.config = MagicMock() + adapter.config.extra = {} + return adapter + + +def _make_runner(): + runner = object.__new__(gateway_run.GatewayRunner) + runner.adapters = {} + runner._ephemeral_system_prompt = "Global prompt" + runner._prefill_messages = [] + runner._reasoning_config = None + runner._service_tier = None + runner._provider_routing = {} + runner._fallback_model = None + runner._smart_model_routing = {} + runner._running_agents = {} + runner._pending_model_notes = {} + runner._session_db = None + runner._agent_cache = {} + runner._agent_cache_lock = threading.Lock() + runner._session_model_overrides = {} + runner.hooks = SimpleNamespace(loaded_hooks=False) + runner.config = SimpleNamespace(streaming=None) + runner.session_store = SimpleNamespace( + get_or_create_session=lambda source: SimpleNamespace(session_id="session-1"), + load_transcript=lambda session_id: [], + ) + runner._get_or_create_gateway_honcho = lambda session_key: (None, None) + runner._enrich_message_with_vision = AsyncMock(return_value="ENRICHED") + return runner + + +def _make_source() -> SessionSource: + return SessionSource( + platform=Platform.DISCORD, + chat_id="12345", + chat_type="thread", + user_id="user-1", + ) + + +class TestResolveChannelPrompts: + def test_no_prompt_returns_none(self): + adapter = _make_adapter() + assert adapter._resolve_channel_prompt("123") is None + + def test_match_by_channel_id(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"100": "Research mode"}} + assert adapter._resolve_channel_prompt("100") == "Research mode" + + def test_match_by_numeric_channel_id_key(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {100: "Research mode"}} + assert adapter._resolve_channel_prompt("100") == "Research mode" + + def test_match_by_parent_id(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"200": "Forum prompt"}} + assert adapter._resolve_channel_prompt("999", parent_id="200") == "Forum prompt" + + def test_exact_channel_overrides_parent(self): + adapter = _make_adapter() + adapter.config.extra = { + "channel_prompts": { + "999": "Thread override", + "200": "Forum prompt", + } + } + assert adapter._resolve_channel_prompt("999", parent_id="200") == "Thread override" + + def test_build_message_event_sets_channel_prompt(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"321": "Command prompt"}} + adapter.build_source = MagicMock(return_value=SimpleNamespace()) + + interaction = SimpleNamespace( + channel_id=321, + channel=SimpleNamespace(name="general", guild=None, parent_id=None), + user=SimpleNamespace(id=1, display_name="Brenner"), + ) + adapter._get_effective_topic = MagicMock(return_value=None) + + event = adapter._build_slash_event(interaction, "/retry") + + assert event.channel_prompt == "Command prompt" + + @pytest.mark.asyncio + async def test_dispatch_thread_session_inherits_parent_channel_prompt(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"200": "Parent prompt"}} + adapter.build_source = MagicMock(return_value=SimpleNamespace()) + adapter._get_effective_topic = MagicMock(return_value=None) + adapter.handle_message = AsyncMock() + + interaction = SimpleNamespace( + guild=SimpleNamespace(name="Wetlands"), + channel=SimpleNamespace(id=200, parent=None), + user=SimpleNamespace(id=1, display_name="Brenner"), + ) + + await adapter._dispatch_thread_session(interaction, "999", "new-thread", "hello") + + dispatched_event = adapter.handle_message.await_args.args[0] + assert dispatched_event.channel_prompt == "Parent prompt" + + def test_blank_prompts_are_ignored(self): + adapter = _make_adapter() + adapter.config.extra = {"channel_prompts": {"100": " "}} + assert adapter._resolve_channel_prompt("100") is None + + +@pytest.mark.asyncio +async def test_retry_preserves_channel_prompt(monkeypatch): + runner = _make_runner() + runner.session_store = SimpleNamespace( + get_or_create_session=lambda source: SimpleNamespace(session_id="session-1", last_prompt_tokens=10), + load_transcript=lambda session_id: [ + {"role": "user", "content": "original message"}, + {"role": "assistant", "content": "old reply"}, + ], + rewrite_transcript=MagicMock(), + ) + runner._handle_message = AsyncMock(return_value="ok") + + event = MessageEvent( + text="/retry", + message_type=gateway_run.MessageType.COMMAND, + source=_make_source(), + raw_message=SimpleNamespace(), + channel_prompt="Channel prompt", + ) + + result = await runner._handle_retry_command(event) + + assert result == "ok" + retried_event = runner._handle_message.await_args.args[0] + assert retried_event.channel_prompt == "Channel prompt" + + +@pytest.mark.asyncio +async def test_run_agent_appends_channel_prompt_to_ephemeral_system_prompt(monkeypatch, tmp_path): + _install_fake_agent(monkeypatch) + runner = _make_runner() + + (tmp_path / "config.yaml").write_text("agent:\n system_prompt: Global prompt\n", encoding="utf-8") + 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, "_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"}) + + _CapturingAgent.last_init = None + event = MessageEvent( + text="hi", + source=_make_source(), + message_id="m1", + channel_prompt="Channel prompt", + ) + result = await runner._run_agent( + message="hi", + context_prompt="Context prompt", + history=[], + source=_make_source(), + session_id="session-1", + session_key="agent:main:discord:thread:12345", + channel_prompt=event.channel_prompt, + ) + + assert result["final_response"] == "ok" + assert _CapturingAgent.last_init["ephemeral_system_prompt"] == ( + "Context prompt\n\nChannel prompt\n\nGlobal prompt" + ) diff --git a/tests/hermes_cli/test_config.py b/tests/hermes_cli/test_config.py index 9f77bb4c86..f31ac045c4 100644 --- a/tests/hermes_cli/test_config.py +++ b/tests/hermes_cli/test_config.py @@ -459,7 +459,7 @@ class TestCustomProviderCompatibility: migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert raw["_config_version"] == 17 + assert raw["_config_version"] == 18 assert raw["providers"]["openai-direct"] == { "api": "https://api.openai.com/v1", "api_key": "test-key", @@ -606,6 +606,26 @@ class TestInterimAssistantMessageConfig: migrate_config(interactive=False, quiet=True) raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) - assert raw["_config_version"] == 17 + assert raw["_config_version"] == 18 assert raw["display"]["tool_progress"] == "off" assert raw["display"]["interim_assistant_messages"] is True + + +class TestDiscordChannelPromptsConfig: + def test_default_config_includes_discord_channel_prompts(self): + assert DEFAULT_CONFIG["discord"]["channel_prompts"] == {} + + def test_migrate_adds_discord_channel_prompts_default(self, tmp_path): + config_path = tmp_path / "config.yaml" + config_path.write_text( + yaml.safe_dump({"_config_version": 17, "discord": {"auto_thread": True}}), + encoding="utf-8", + ) + + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}): + migrate_config(interactive=False, quiet=True) + raw = yaml.safe_load(config_path.read_text(encoding="utf-8")) + + assert raw["_config_version"] == 18 + assert raw["discord"]["auto_thread"] is True + assert raw["discord"]["channel_prompts"] == {} diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index 475e8c2d02..05f679efee 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -64,4 +64,4 @@ class TestCamofoxConfigDefaults: # The current schema version is tracked globally; unrelated default # options may bump it after browser defaults are added. - assert DEFAULT_CONFIG["_config_version"] == 17 + assert DEFAULT_CONFIG["_config_version"] == 18 diff --git a/website/docs/user-guide/messaging/discord.md b/website/docs/user-guide/messaging/discord.md index 111bea596d..5dacefda4a 100644 --- a/website/docs/user-guide/messaging/discord.md +++ b/website/docs/user-guide/messaging/discord.md @@ -297,6 +297,7 @@ discord: reactions: true # Add emoji reactions during processing ignored_channels: [] # Channel IDs where bot never responds no_thread_channels: [] # Channel IDs where bot responds without threading + channel_prompts: {} # Per-channel ephemeral system prompts # Session isolation (applies to all gateway platforms, not just Discord) group_sessions_per_user: true # Isolate sessions per user in shared channels @@ -381,6 +382,28 @@ discord: Useful for channels dedicated to bot interaction where threads would add unnecessary noise. +#### `discord.channel_prompts` + +**Type:** mapping — **Default:** `{}` + +Per-channel ephemeral system prompts that are injected on every turn in the matching Discord channel or thread without being persisted to transcript history. + +```yaml +discord: + channel_prompts: + "1234567890": | + This channel is for research tasks. Prefer deep comparisons, + citations, and concise synthesis. + "9876543210": | + This forum is for therapy-style support. Be warm, grounded, + and non-judgmental. +``` + +Behavior: +- Exact thread/channel ID matches win. +- If a message arrives inside a thread or forum post and that thread has no explicit entry, Hermes falls back to the parent channel/forum ID. +- Prompts are applied ephemerally at runtime, so changing them affects future turns immediately without rewriting past session history. + #### `group_sessions_per_user` **Type:** boolean — **Default:** `true`