Discord allowed_channels config reading and wildcard behavior

NousResearch/hermes-agent · cluster 14920 · gateway

Data as of

Nothing open on GitHub. Fix merged in #15052. Check it covers your case.

The short answer

0open issues
0open PRs
6items grouped
Known?
Yes. 6 related items grouped here, 2 issues already closed.
Fix event
A fix was merged to main in PR #15052. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this partially resolved, deciding PR #15052. That is separate from the fix event.
Still open
Nothing open on GitHub.

Multiple issues and fixes around the Discord allowed_channels configuration: the config.yaml field was not being read (only the env var was), and wildcard values like "*" silently dropped all messages instead of allowing all channels.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #15052 fix: P1 batch — Discord wildcard, ACP+MCP, NO_PROXY bypass, resume-after-compression

    merged to main

    files: acp_adapter/server.py, acp_adapter/session.py, cli.py, gateway/platforms/discord.py, hermes_state.py, run_agent.py, scripts/release.py, tests/acp/test_server.py, tests/acp/test_session.py, tests/gateway/test_discord_allowed_channels.py, tests/hermes_state/test_resolve_resume_session_id.py, tests/run_agent/test_create_openai_client_proxy_env.py

    Show the diff
    • acp_adapter/server.py
      @@ -60,7 +60,7 @@
           make_tool_progress_cb,
       )
       from acp_adapter.permissions import make_approval_callback
      -from acp_adapter.session import SessionManager, SessionState
      +from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
       
       logger = logging.getLogger(__name__)
       
      @@ -287,7 +287,11 @@ async def _register_session_mcp_servers(
               try:
                   from model_tools import get_tool_definitions
       
      -            enabled_toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
      +            enabled_toolsets = _expand_acp_enabled_toolsets(
      +                getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
      +                mcp_server_names=[server.name for server in mcp_servers],
      +            )
      +            state.agent.enabled_toolsets = enabled_toolsets
                   disabled_toolsets = getattr(state.agent, "disabled_toolsets", None)
                   state.agent.tools = get_tool_definitions(
                       enabled_toolsets=enabled_toolsets,
      @@ -754,7 +758,9 @@ def _cmd_model(self, args: str, state: SessionState) -> str:
           def _cmd_tools(self, args: str, state: SessionState) -> str:
               try:
                   from model_tools import get_tool_definitions
      -            toolsets = getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
      +            toolsets = _expand_acp_enabled_toolsets(
      +                getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
      +            )
                   tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
                   if not tools:
                       return "No tools available."
    • acp_adapter/session.py
      @@ -106,6 +106,24 @@ def _register_task_cwd(task_id: str, cwd: str) -> None:
               logger.debug("Failed to register ACP task cwd override", exc_info=True)
       
       
      +def _expand_acp_enabled_toolsets(
      +    toolsets: List[str] | None = None,
      +    mcp_server_names: List[str] | None = None,
      +) -> List[str]:
      +    """Return ACP toolsets plus explicit MCP server toolsets for this session."""
      +    expanded: List[str] = []
      +    for name in list(toolsets or ["hermes-acp"]):
      +        if name and name not in expanded:
      +            expanded.append(name)
      +
      +    for server_name in list(mcp_server_names or []):
      +        toolset_name = f"mcp-{server_name}"
      +        if server_name and toolset_name not in expanded:
      +            expanded.append(toolset_name)
      +
      +    return expanded
      +
      +
       def _clear_task_cwd(task_id: str) -> None:
           """Remove task-specific cwd overrides for an ACP session."""
           if not task_id:
      @@ -537,9 +555,18 @@ def _make_agent(
               elif isinstance(model_cfg, str) and model_cfg.strip():
                   default_model = model_cfg.strip()
       
      +        configured_mcp_servers = [
      +            name
      +            for name, cfg in (config.get("mcp_servers") or {}).items()
      +            if not isinstance(cfg, dict) or cfg.get("enabled", True) is not False
      +        ]
      +
               kwargs = {
                   "platform": "acp",
      -            "enabled_toolsets": ["hermes-acp"],
      +            "enabled_toolsets": _expand_acp_enabled_toolsets(
      +                ["hermes-acp"],
      +                mcp_server_names=configured_mcp_servers,
      +            ),
                   "quiet_mode": True,
                   "session_id": session_id,
                   "model": model or default_model,
    • cli.py
      @@ -3254,6 +3254,23 @@ def _init_agent(self, *, model_override: str = None, runtime_override: dict = No
                       _cprint(f"\033[1;31mSession not found: {self.session_id}{_RST}")
                       _cprint(f"{_DIM}Use a session ID from a previous CLI run (hermes sessions list).{_RST}")
                       return False
      +            # If the requested session is the (empty) head of a compression
      +            # chain, walk to the descendant that actually holds the messages.
      +            # See #15000 and SessionDB.resolve_resume_session_id.
      +            try:
      +                resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
      +            except Exception:
      +                resolved_id = self.session_id
      +            if resolved_id and resolved_id != self.session_id:
      +                ChatConsole().print(
      +                    f"[{_DIM}]Session {_escape(self.session_id)} was compressed into "
      +                    f"{_escape(resolved_id)}; resuming the descendant with your "
      +                    f"transcript.[/]"
      +                )
      +                self.session_id = resolved_id
      +                resolved_meta = self._session_db.get_session(self.session_id)
      +                if resolved_meta:
      +                    session_meta = resolved_meta
                   restored = self._session_db.get_messages_as_conversation(self.session_id)
                   if restored:
                       restored = [m for m in restored if m.get("role") != "session_meta"]
      @@ -3472,6 +3489,22 @@ def _preload_resumed_session(self) -> bool:
                   )
                   return False
       
      +        # If the requested session is the (empty) head of a compression chain,
      +        # walk to the descendant that actually holds the messages. See #15000.
      +        try:
      +            resolved_id = self._session_db.resolve_resume_session_id(self.session_id)
      +        except Exception:
      +            resolved_id = self.session_id
      +        if resolved_id and resolved_id != self.session_id:
      +            self._console_print(
      +                f"[dim]Session {self.session_id} was compressed into "
      +                f"{resolved_id}; resuming the descendant with your transcript.[/]"
      +            )
      +            self.session_id = resolved_id
      +            resolved_meta = self._session_db.get_session(self.session_id)
      +            if resolved_meta:
      +                session_meta = resolved_meta
      +
               restored = self._session_db.get_messages_as_conversation(self.session_id)
               if restored:
                   restored = [m for m in restored if m.get("role") != "session_meta"]
      @@ -4686,6 +4719,22 @@ def _handle_resume_command(self, cmd_original: str) -> None:
                   _cprint("  Use /history or `hermes sessions list` to see available sessions.")
                   return
       
      +        # If the target is the empty head of a compression chain, redirect to
      +        # the descendant that actually holds the transcript. See #15000.
      +        try:
      +            resolved_id = self._session_db.resolve_resume_session_id(target_id)
      +        except Exception:
      +            resolved_id = target_id
      +        if resolved_id and resolved_id != target_id:
      +            _cprint(
      +                f"  Session {target_id} was compressed into {resolved_id}; "
      +                f"resuming the descendant with your transcript."
      +            )
      +            target_id = resolved_id
      +            resolved_meta = self._session_db.get_session(target_id)
      +            if resolved_meta:
      +                session_meta = resolved_meta
      +
               if target_id == self.session_id:
                   _cprint("  Already on that session.")
                   return
    • gateway/platforms/discord.py
      @@ -2719,7 +2719,12 @@ def _discord_require_mention(self) -> bool:
               return os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no", "off")
       
           def _discord_free_response_channels(self) -> set:
      -        """Return Discord channel IDs where no bot mention is required."""
      +        """Return Discord channel IDs where no bot mention is required.
      +
      +        A single ``"*"`` entry (either from a list or a comma-separated
      +        string) is preserved in the returned set so callers can short-circuit
      +        on wildcard membership, consistent with ``allowed_channels``.
      +        """
               raw = self.config.extra.get("free_response_channels")
               if raw is None:
                   raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "")
      @@ -3212,14 +3217,14 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                   allowed_channels_raw = os.getenv("DISCORD_ALLOWED_CHANNELS", "")
                   if allowed_channels_raw:
                       allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()}
      -                if not (channel_ids & allowed_channels):
      +                if "*" not in allowed_channels and not (channel_ids & allowed_channels):
                           logger.debug("[%s] Ignoring message in non-allowed channel: %s", self.name, channel_ids)
                           return
       
                   # Check ignored channels - never respond even when mentioned
                   ignored_channels_raw = os.getenv("DISCORD_IGNORED_CHANNELS", "")
                   ignored_channels = {ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()}
      -            if channel_ids & ignored_channels:
      +            if "*" in ignored_channels or (channel_ids & ignored_channels):
                       logger.debug("[%s] Ignoring message in ignored channel: %s", self.name, channel_ids)
                       return
       
      @@ -3233,7 +3238,11 @@ async def _handle_message(self, message: DiscordMessage) -> None:
                   voice_linked_ids = {str(ch_id) for ch_id in self._voice_text_channels.values()}
                   current_channel_id = str(message.channel.id)
                   is_voice_linked_channel = current_channel_id in voice_linked_ids
      -            is_free_channel = bool(channel_ids & free_channels) or is_voice_linked_channel
      +            is_free_channel = (
      +                "*" in free_channels
      +                or bool(channel_ids & free_channels)
      +                or is_voice_linked_channel
      +            )
       
                   # Skip the mention check if the message is in a thread where
                   # the bot has previously participated (auto-created or replied in).
    • hermes_state.py
      @@ -1039,6 +1039,71 @@ def get_messages(self, session_id: str) -> List[Dict[str, Any]]:
                   result.append(msg)
               return result
       
      +    def resolve_resume_session_id(self, session_id: str) -> str:
      +        """Redirect a resume target to the descendant session that holds the messages.
      +
      +        Context compression ends the current session and forks a new child session
      +        (linked via ``parent_session_id``). The flush cursor is reset, so the
      +        child is where new messages actually land — the parent ends up with
      +        ``message_count = 0`` rows unless messages had already been flushed to
      +        it before compression. See #15000.
      +
      +        This helper walks ``parent_session_id`` forward from ``session_id`` and
      +        returns the first descendant in the chain that has at least one message
      +        row. If the original session already has messages, or no descendant
      +        has any, the original ``session_id`` is returned unchanged.
      +
      +        The chain is always walked via the child whose ``started_at`` is
      +        latest; that matches the single-chain shape that compression creates.
      +        A depth cap (32) guards against accidental loops in malformed data.
      +        """
      +        if not session_id:
      +            return session_id
      +
      +        with self._lock:
      +            # If this session already has messages, nothing to redirect.
      +            try:
      +                row = self._conn.execute(
      +                    "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
      +                    (session_id,),
      +                ).fetchone()
      +            except Exception:
      +                return session_id
      +            if row is not None:
      +                return session_id
      +
      +            # Walk descendants: at each step, pick the most-recently-started
      +                # child session; stop once we find one with messages.
      +            current = session_id
      +            seen = {current}
      +            for _ in range(32):
      +                try:
      +                    child_row = self._conn.execute(
      +                        "SELECT id FROM sessions "
      +                        "WHERE parent_session_id = ? "
      +                        "ORDER BY started_at DESC, id DESC LIMIT 1",
      +                        (current,),
      +                    ).fetchone()
      +                except Exception:
      +                    return session_id
      +                if child_row is None:
      +                    return session_id
      +                child_id = child_row["id"] if hasattr(child_row, "keys") else child_row[0]
      +                if not child_id or child_id in seen:
      +                    return session_id
      +                seen.add(child_id)
      +                try:
      +                    msg_row = self._conn.execute(
      +                        "SELECT 1 FROM messages WHERE session_id = ? LIMIT 1",
      +                        (child_id,),
      +                    ).fetchone()
      +                except Exception:
      +                    return session_id
      +                if msg_row is not None:
      +                    return child_id
      +                current = child_id
      +        return session_id
      +
           def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
               """
               Load messages in the OpenAI conversation format (role + content dicts).
    • run_agent.py
      @@ -36,6 +36,7 @@
       import time
       import threading
       from types import SimpleNamespace
      +import urllib.request
       import uuid
       from typing import List, Dict, Any, Optional
       from openai import OpenAI
      @@ -181,6 +182,25 @@ def _get_proxy_from_env() -> Optional[str]:
           return None
       
       
      +def _get_proxy_for_base_url(base_url: Optional[str]) -> Optional[str]:
      +    """Return an env-configured proxy unless NO_PROXY excludes this base URL."""
      +    proxy = _get_proxy_from_env()
      +    if not proxy or not base_url:
      +        return proxy
      +
      +    host = base_url_hostname(base_url)
      +    if not host:
      +        return proxy
      +
      +    try:
      +        if urllib.request.proxy_bypass_environment(host):
      +            return None
      +    except Exception:
      +        pass
      +
      +    return proxy
      +
      +
       def _install_safe_stdio() -> None:
           """Wrap stdout/stderr so best-effort console output cannot crash the agent."""
           for stream_name in ("stdout", "stderr"):
      @@ -4466,7 +4486,7 @@ def _is_openai_client_closed(client: Any) -> bool:
               return False
       
           @staticmethod
      -    def _build_keepalive_http_client() -> Any:
      +    def _build_keepalive_http_client(base_url: str = "") -> Any:
               try:
                   import httpx as _httpx
                   import socket as _socket
      @@ -4480,8 +4500,9 @@ def _build_keepalive_http_client() -> Any:
                       _sock_opts.append((_socket.IPPROTO_TCP, _socket.TCP_KEEPALIVE, 30))
                   # When a custom transport is provided, httpx won't auto-read proxy
                   # from env vars (allow_env_proxies = trust_env and transport is None).
      -            # Explicitly read proxy settings to ensure HTTP_PROXY/HTTPS_PROXY work.
      -            _proxy = _get_proxy_from_env()
      +            # Explicitly read proxy settings while still honoring NO_PROXY for
      +            # loopback / local endpoints such as a locally hosted sub2api.
      +            _proxy = _get_proxy_for_base_url(base_url)
                   return _httpx.Client(
                       transport=_httpx.HTTPTransport(socket_options=_sock_opts),
                       proxy=_proxy,
      @@ -4539,7 +4560,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo
                           if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"}
                       }
                       if "http_client" not in safe_kwargs:
      -                    keepalive_http = self._build_keepalive_http_client()
      +                    keepalive_http = self._build_keepalive_http_client(base_url)
                           if keepalive_http is not None:
                               safe_kwargs["http_client"] = keepalive_http
                       client = GeminiNativeClient(**safe_kwargs)
      @@ -4568,7 +4589,7 @@ def _create_openai_client(self, client_kwargs: dict, *, reason: str, shared: boo
               # Tests in ``tests/run_agent/test_create_openai_client_reuse.py`` and
               # ``tests/run_agent/test_sequential_chats_live.py`` pin this invariant.
               if "http_client" not in client_kwargs:
      -            keepalive_http = self._build_keepalive_http_client()
      +            keepalive_http = self._build_keepalive_http_client(client_kwargs.get("base_url", ""))
                   if keepalive_http is not None:
                       client_kwargs["http_client"] = keepalive_http
               client = OpenAI(**client_kwargs)
    • scripts/release.py
      @@ -438,6 +438,10 @@
           "[redacted:email]": "topcheer",
           "[redacted:email]": "walli",
           "[redacted:email]": "Zhuofeng-Wang",
      +    # April 2026 salvage-PR batch (#14920, #14986, #14966)
      +    "[redacted:email]": "mrunmayee17",
      +    "[redacted:email]": "camaragon",
      +    "[redacted:email]": "shamork",
           # no-github-match — keep as display names
           "[redacted:email]": "Sisyphus",
           "[redacted:email]": "Marco Rutsch",
    • tests/acp/test_server.py
      @@ -904,9 +904,15 @@ async def test_refreshes_agent_tool_surface(self, agent, mock_manager):
               ]
       
               with patch("tools.mcp_tool.register_mcp_servers", return_value=["mcp_srv_search"]), \
      -             patch("model_tools.get_tool_definitions", return_value=fake_tools):
      +             patch("model_tools.get_tool_definitions", return_value=fake_tools) as mock_defs:
                   await agent._register_session_mcp_servers(state, [server])
       
      +        mock_defs.assert_called_once_with(
      +            enabled_toolsets=["hermes-acp", "mcp-srv"],
      +            disabled_toolsets=None,
      +            quiet_mode=True,
      +        )
      +        assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"]
               assert state.agent.tools == fake_tools
               assert state.agent.valid_tool_names == {"mcp_srv_search", "terminal"}
               # _invalidate_system_prompt should have been called
    • tests/acp/test_session.py
      @@ -138,6 +138,43 @@ def test_remove_session(self, manager):
       class TestPersistence:
           """Verify that sessions are persisted to SessionDB and can be restored."""
       
      +    def test_create_session_includes_registered_mcp_toolsets(self, tmp_path, monkeypatch):
      +        captured = {}
      +
      +        def fake_resolve_runtime_provider(requested=None, **kwargs):
      +            return {
      +                "provider": "openrouter",
      +                "api_mode": "chat_completions",
      +                "base_url": "https://openrouter.example/v1",
      +                "api_key": "***",
      +                "command": None,
      +                "args": [],
      +            }
      +
      +        def fake_agent(**kwargs):
      +            captured.update(kwargs)
      +            return SimpleNamespace(model=kwargs.get("model"), enabled_toolsets=kwargs.get("enabled_toolsets"))
      +
      +        monkeypatch.setattr("hermes_cli.config.load_config", lambda: {
      +            "model": {"provider": "openrouter", "default": "test-model"},
      +            "mcp_servers": {
      +                "olympus": {"command": "python", "enabled": True},
      +                "exa": {"url": "https://exa.ai/mcp"},
      +                "disabled": {"command": "python", "enabled": False},
      +            },
      +        })
      +        monkeypatch.setattr(
      +            "hermes_cli.runtime_provider.resolve_runtime_provider",
      +            fake_resolve_runtime_provider,
      +        )
      +        db = SessionDB(tmp_path / "state.db")
      +
      +        with patch("run_agent.AIAgent", side_effect=fake_agent):
      +            manager = SessionManager(db=db)
      +            manager.create_session(cwd="/work")
      +
      +        assert captured["enabled_toolsets"] == ["hermes-acp", "mcp-olympus", "mcp-exa"]
      +
           def test_create_session_writes_to_db(self, manager):
               state = manager.create_session(cwd="/project")
               db = manager._get_db()
    • tests/gateway/test_discord_allowed_channels.py
      @@ -0,0 +1,104 @@
      +"""Regression guard for #14920: wildcard "*" in Discord channel config lists.
      +
      +Setting ``allowed_channels: "*"``, ``free_response_channels: "*"``, or
      +``ignored_channels: "*"`` in config (or their ``DISCORD_*_CHANNELS`` env var
      +equivalents) must behave as a wildcard — i.e. the bot responds in every
      +channel (or is silenced in every channel, for the ignored list). Previously
      +the literal string "*" was placed into a set and compared against numeric
      +channel IDs via set-intersection, which always produced an empty set and
      +caused every message to be silently dropped (for ``allowed_channels``) or
      +every ``free_response`` / ``ignored`` check to fail open.
      +"""
      +
      +import unittest
      +
      +
      +def _channel_is_allowed(channel_id: str, allowed_channels_raw: str) -> bool:
      +    """Replicate the channel-allow-list check from discord.py on_message."""
      +    if not allowed_channels_raw:
      +        return True
      +    allowed_channels = {ch.strip() for ch in allowed_channels_raw.split(",") if ch.strip()}
      +    if "*" in allowed_channels:
      +        return True
      +    return bool({channel_id} & allowed_channels)
      +
      +
      +def _channel_is_ignored(channel_id: str, ignored_channels_raw: str) -> bool:
      +    """Replicate the ignored-channel check from discord.py on_message."""
      +    ignored_channels = {
      +        ch.strip() for ch in ignored_channels_raw.split(",") if ch.strip()
      +    }
      +    return "*" in ignored_channels or bool({channel_id} & ignored_channels)
      +
      +
      +def _channel_is_free_response(channel_id: str, free_channels_raw: str) -> bool:
      +    """Replicate the free-response-channel check from discord.py on_message."""
      +    free_channels = {
      +        ch.strip() for ch in free_channels_raw.split(",") if ch.strip()
      +    }
      +    return "*" in free_channels or bool({channel_id} & free_channels)
      +
      +
      +class TestDiscordAllowedChannelsWildcard(unittest.TestCase):
      +    """Wildcard and channel-list behaviour for DISCORD_ALLOWED_CHANNELS."""
      +
      +    def test_wildcard_allows_any_channel(self):
      +        """'*' should allow messages from any channel ID."""
      +        self.assertTrue(_channel_is_allowed("1234567890", "*"))
      +
      +    def test_wildcard_in_list_allows_any_channel(self):
      +        """'*' mixed with other entries still allows any channel."""
      +        self.assertTrue(_channel_is_allowed("9999999999", "111,*,222"))
      +
      +    def test_exact_match_allowed(self):
      +        """Channel ID present in the explicit list is allowed."""
      +        self.assertTrue(_channel_is_allowed("1234567890", "1234567890,9876543210"))
      +
      +    def test_non_matching_channel_blocked(self):
      +        """Channel ID absent from the explicit list is blocked."""
      +        self.assertFalse(_channel_is_allowed("5555555555", "1234567890,9876543210"))
      +
      +    def test_empty_allowlist_allows_all(self):
      +        """Empty DISCORD_ALLOWED_CHANNELS means no restriction."""
      +        self.assertTrue(_channel_is_allowed("1234567890", ""))
      +
      +    def test_whitespace_only_entry_ignored(self):
      +        """Entries that are only whitespace are stripped and ignored."""
      +        self.assertFalse(_channel_is_allowed("1234567890", "  ,  "))
      +
      +
      +class TestDiscordIgnoredChannelsWildcard(unittest.TestCase):
      +    """Wildcard and channel-list behaviour for DISCORD_IGNORED_CHANNELS."""
      +
      +    def test_wildcard_silences_every_channel(self):
      +        """'*' in ignored_channels silences the bot everywhere."""
      +        self.assertTrue(_channel_is_ignored("1234567890", "*"))
      +
      +    def test_empty_ignored_list_silences_nothing(self):
      +        self.assertFalse(_channel_is_ignored("1234567890", ""))
      +
      +    def test_exact_match_is_ignored(self):
      +        self.assertTrue(_channel_is_ignored("111", "111,222"))
      +
      +    def test_non_match_not_ignored(self):
      +        self.assertFalse(_channel_is_ignored("333", "111,222"))
      +
      +
      +class TestDiscordFreeResponseChannelsWildcard(unittest.TestCase):
      +    """Wildcard and channel-list behaviour for DISCORD_FREE_RESPONSE_CHANNELS."""
      +
      +    def test_wildcard_makes_every_channel_free_response(self):
      +        """'*' in free_response_channels exempts every channel from mention-required."""
      +        self.assertTrue(_channel_is_free_response("1234567890", "*"))
      +
      +    def test_wildcard_in_list_applies_everywhere(self):
      +        self.assertTrue(_channel_is_free_response("9999999999", "111,*,222"))
      +
      +    def test_exact_match_is_free_response(self):
      +        self.assertTrue(_channel_is_free_response("111", "111,222"))
      +
      +    def test_non_match_not_free_response(self):
      +        self.assertFalse(_channel_is_free_response("333", "111,222"))
      +
      +    def test_empty_list_no_free_response(self):
      +        self.assertFalse(_channel_is_free_response("111", ""))
    • tests/hermes_state/test_resolve_resume_session_id.py
      @@ -0,0 +1,96 @@
      +"""Regression guard for #15000: --resume <id> after compression loses messages.
      +
      +Context compression ends the current session and forks a new child session
      +(linked by ``parent_session_id``). The SQLite flush cursor is reset, so
      +only the latest descendant ends up with rows in the ``messages`` table —
      +the parent row has ``message_count = 0``. ``hermes --resume <parent_id>``
      +used to load zero rows and show a blank chat.
      +
      +``SessionDB.resolve_resume_session_id()`` walks the parent → child chain
      +and redirects to the first descendant that actually has messages. These
      +tests pin that behaviour.
      +"""
      +import time
      +
      +import pytest
      +
      +from hermes_state import SessionDB
      +
      +
      [redacted:email]
      +def db(tmp_path):
      +    return SessionDB(tmp_path / "state.db")
      +
      +
      +def _make_chain(db: SessionDB, ids_with_parent):
      +    """Create sessions in order, forcing started_at so ordering is deterministic."""
      +    base = int(time.time()) - 10_000
      +    for i, (sid, parent) in enumerate(ids_with_parent):
      +        db.create_session(sid, source="cli", parent_session_id=parent)
      +        db._conn.execute(
      +            "UPDATE sessions SET started_at = ? WHERE id = ?",
      +            (base + i * 100, sid),
      +        )
      +    db._conn.commit()
      +
      +
      +def test_redirects_from_empty_head_to_descendant_with_messages(db):
      +    # Reproducer shape from #15000: 6 sessions, only the 5th holds messages.
      +    _make_chain(db, [
      +        ("head",   None),
      +        ("mid1",   "head"),
      +        ("mid2",   "mid1"),
      +        ("mid3",   "mid2"),
      +        ("bulk",   "mid3"),    # has messages
      +        ("tail",   "bulk"),    # empty tail after another compression
      +    ])
      +    for i in range(5):
      +        db.append_message("bulk", role="user", content=f"msg {i}")
      +
      +    assert db.resolve_resume_session_id("head") == "bulk"
      +
      +
      +def test_returns_self_when_session_has_messages(db):
      +    _make_chain(db, [("root", None), ("child", "root")])
      +    db.append_message("root", role="user", content="hi")
      +    assert db.resolve_resume_session_id("root") == "root"
      +
      +
      +def test_returns_self_when_no_descendant_has_messages(db):
      +    _make_chain(db, [("root", None), ("child1", "root"), ("child2", "child1")])
      +    assert db.resolve_resume_session_id("root") == "root"
      +
      +
      +def test_returns_self_for_isolated_session(db):
      +    db.create_session("isolated", source="cli")
      +    assert db.resolve_resume_session_id("isolated") == "isolated"
      +
      +
      +def test_returns_self_for_nonexistent_session(db):
      +    assert db.resolve_resume_session_id("does_not_exist") == "does_not_exist"
      +
      +
      +def test_empty_session_id_passthrough(db):
      +    assert db.resolve_resume_session_id("") == ""
      +    assert db.resolve_resume_session_id(None) is None
      +
      +
      +def test_walks_from_middle_of_chain(db):
      +    # If the user happens to know an intermediate ID, we still find the msg-bearing descendant.
      +    _make_chain(db, [("a", None), ("b", "a"), ("c", "b"), ("d", "c")])
      +    db.append_message("d", role="user", content="x")
      +    assert db.resolve_resume_session_id("b") == "d"
      +    assert db.resolve_resume_session_id("c") == "d"
      +
      +
      +def test_prefers_most_recent_child_when_fork_exists(db):
      +    # If a session was somehow forked (two children), pick the latest one.
      +    # In practice, compression only produces single-chain shape, but the helper
      +    # should degrade gracefully.
      +    _make_chain(db, [
      +        ("parent", None),
      +        ("older_fork", "parent"),
      +        ("newer_fork", "parent"),
      +    ])
      +    db.append_message("newer_fork", role="user", content="x")
      +    assert db.resolve_resume_session_id("parent") == "newer_fork"
    • tests/run_agent/test_create_openai_client_proxy_env.py
      @@ -22,7 +22,7 @@
       
       import httpx
       
      -from run_agent import AIAgent, _get_proxy_from_env
      +from run_agent import AIAgent, _get_proxy_from_env, _get_proxy_for_base_url
       
       
       def _make_agent():
      @@ -143,3 +143,78 @@ def test_create_openai_client_no_proxy_when_env_unset(mock_openai, monkeypatch):
               "pools were %r" % (pool_types,)
           )
           http_client.close()
      +
      +
      +def test_get_proxy_for_base_url_returns_none_when_host_bypassed(monkeypatch):
      +    """NO_PROXY must suppress the proxy for matching base_urls.
      +
      +    Regression for #14966: users running a local inference endpoint
      +    (Ollama, LM Studio, llama.cpp) with a global HTTPS_PROXY would see
      +    the keepalive client route loopback traffic through the proxy, which
      +    typically answers 502 for local hosts. NO_PROXY should opt those
      +    hosts out via stdlib ``urllib.request.proxy_bypass_environment``.
      +    """
      +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
      +                "https_proxy", "http_proxy", "all_proxy",
      +                "NO_PROXY", "no_proxy"):
      +        monkeypatch.delenv(key, raising=False)
      +    monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
      +    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,192.168.0.0/16")
      +
      +    # Local endpoint — must bypass the proxy.
      +    assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") is None
      +    assert _get_proxy_for_base_url("http://localhost:1234/v1") is None
      +
      +    # Non-local endpoint — proxy still applies.
      +    assert _get_proxy_for_base_url("https://api.openai.com/v1") == "http://127.0.0.1:7897"
      +
      +
      +def test_get_proxy_for_base_url_returns_proxy_when_no_proxy_unset(monkeypatch):
      +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
      +                "https_proxy", "http_proxy", "all_proxy",
      +                "NO_PROXY", "no_proxy"):
      +        monkeypatch.delenv(key, raising=False)
      +    monkeypatch.setenv("HTTPS_PROXY", "http://corp:8080")
      +    assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") == "http://corp:8080"
      +
      +
      +def test_get_proxy_for_base_url_returns_none_when_proxy_unset(monkeypatch):
      +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
      +                "https_proxy", "http_proxy", "all_proxy",
      +                "NO_PROXY", "no_proxy"):
      +        monkeypatch.delenv(key, raising=False)
      +    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1")
      +    assert _get_proxy_for_base_url("http://127.0.0.1:11434/v1") is None
      +    assert _get_proxy_for_base_url("https://api.openai.com/v1") is None
      +
      +
      +@patch("run_agent.OpenAI")
      +def test_create_openai_client_bypasses_proxy_for_no_proxy_host(mock_openai, monkeypatch):
      +    """E2E: with HTTPS_PROXY + NO_PROXY=localhost, a local base_url gets a
      +    keepalive client with NO HTTPProxy mount."""
      +    for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY",
      +                "https_proxy", "http_proxy", "all_proxy",
      +                "NO_PROXY", "no_proxy"):
      +        monkeypatch.delenv(key, raising=False)
      +    monkeypatch.setenv("HTTPS_PROXY", "http://127.0.0.1:7897")
      +    monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1")
      +
      +    agent = _make_agent()
      +    kwargs = {
      +        "api_key": "***",
      +        "base_url": "http://127.0.0.1:11434/v1",
      +    }
      +    agent._create_openai_client(kwargs, reason="test", shared=False)
      +
      +    forwarded = mock_openai.call_args.kwargs
      +    http_client = _extract_http_client(forwarded)
      +    assert isinstance(http_client, httpx.Client)
      +    pool_types = [
      +        type(mount._pool).__name__
      +        for mount in http_client._mounts.values()
      +        if mount is not None and hasattr(mount, "_pool")
      +    ]
      +    assert "HTTPProxy" not in pool_types, (
      +        "NO_PROXY host must not route through HTTPProxy; pools were %r" % (pool_types,)
      +    )
      +    http_client.close()

3 PRs closed without merging

From the close comments: done elsewhere 1 · superseded by another PR 1 · other 1

  • #14930 Fix/discord allowed channels wildcard · other → 8598746, 8a1e247, #15052
  • #32289 fix(discord): read allowed_channels/ignored_channels from config.yaml · done elsewhere → cc8e5ec, #16078
  • #32307 fix: honor discord.allowed_channels from config.yaml when env var unset · superseded by another PR → #32289
Closed issues · 2
  • Closed#14920Bug: Wildcard config values (allowed_channels: "*") silently drop all Discord messages
  • Closed#32263discord.allowed_channels config.yaml field is non-functional — only DISCORD_ALLOWED_CHANNELS env var is read
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.29: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
gateway
Merged PRs
#15052
Closed issues
2
Attempts
3 PRs closed without merging
Fix link checked
24 Apr 2026. This confirmation is old and may be out of date (a revert or reopen would not have invalidated it here); re-verify.

For your agent

The same facts, typed.