diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 0a8606c499..fbb5f0fa03 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -488,11 +488,19 @@ def build_skills_system_prompt( return "" # ── Layer 1: in-process LRU cache ───────────────────────────────── + # Include the resolved platform so per-platform disabled-skill lists + # produce distinct cache entries (gateway serves multiple platforms). + _platform_hint = ( + os.environ.get("HERMES_PLATFORM") + or os.environ.get("HERMES_SESSION_PLATFORM") + or "" + ) cache_key = ( str(skills_dir.resolve()), tuple(str(d) for d in external_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), + _platform_hint, ) with _SKILLS_PROMPT_CACHE_LOCK: cached = _SKILLS_PROMPT_CACHE.get(cache_key) diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 9f54eb0fd8..2f4b966912 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -118,12 +118,17 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool: # ── Disabled skills ─────────────────────────────────────────────────────── -def get_disabled_skill_names() -> Set[str]: +def get_disabled_skill_names(platform: str | None = None) -> Set[str]: """Read disabled skill names from config.yaml. - Resolves platform from ``HERMES_PLATFORM`` env var, falls back to - the global disabled list. Reads the config file directly (no CLI - config imports) to stay lightweight. + Args: + platform: Explicit platform name (e.g. ``"telegram"``). When + *None*, resolves from ``HERMES_PLATFORM`` or + ``HERMES_SESSION_PLATFORM`` env vars. Falls back to the + global disabled list when no platform is determined. + + Reads the config file directly (no CLI config imports) to stay + lightweight. """ config_path = get_hermes_home() / "config.yaml" if not config_path.exists(): @@ -140,7 +145,11 @@ def get_disabled_skill_names() -> Set[str]: if not isinstance(skills_cfg, dict): return set() - resolved_platform = os.getenv("HERMES_PLATFORM") + resolved_platform = ( + platform + or os.getenv("HERMES_PLATFORM") + or os.getenv("HERMES_SESSION_PLATFORM") + ) if resolved_platform: platform_disabled = (skills_cfg.get("platform_disabled") or {}).get( resolved_platform diff --git a/gateway/run.py b/gateway/run.py index cdf33135ef..9c43109ccc 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2060,6 +2060,19 @@ class GatewayRunner: skill_cmds = get_skill_commands() cmd_key = f"/{command}" if cmd_key in skill_cmds: + # Check per-platform disabled status before executing. + # get_skill_commands() only applies the *global* disabled + # list at scan time; per-platform overrides need checking + # here because the cache is process-global across platforms. + _skill_name = skill_cmds[cmd_key].get("name", "") + _plat = source.platform.value if source.platform else None + if _plat and _skill_name: + from agent.skill_utils import get_disabled_skill_names as _get_plat_disabled + if _skill_name in _get_plat_disabled(platform=_plat): + return ( + f"The **{_skill_name}** skill is disabled for {_plat}.\n" + f"Enable it with: `hermes skills config`" + ) user_instruction = event.get_command_args().strip() msg = build_skill_invocation_message( cmd_key, user_instruction, task_id=_quick_key diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c67d4e9db7..e3b3848e70 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -414,6 +414,8 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str Skills are the only tier that gets trimmed when the cap is hit. User-installed hub skills are excluded — accessible via /skills. + Skills disabled for the ``"telegram"`` platform (via ``hermes skills + config``) are excluded from the menu entirely. Returns: (menu_commands, hidden_count) where hidden_count is the number of @@ -444,6 +446,17 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str reserved_names.update(n for n, _ in plugin_entries) all_commands.extend(plugin_entries) + # Load per-platform disabled skills so they don't consume menu slots. + # get_skill_commands() already filters the *global* disabled list, but + # per-platform overrides (skills.platform_disabled.telegram) were never + # applied here — that's what this block fixes. + _platform_disabled: set[str] = set() + try: + from agent.skill_utils import get_disabled_skill_names + _platform_disabled = get_disabled_skill_names(platform="telegram") + except Exception: + pass + # Remaining slots go to built-in skill commands (not hub-installed). skill_entries: list[tuple[str, str]] = [] try: @@ -459,6 +472,10 @@ def telegram_menu_commands(max_commands: int = 100) -> tuple[list[tuple[str, str continue if skill_path.startswith(_hub_dir): continue + # Skip skills disabled for telegram + skill_name = info.get("name", "") + if skill_name in _platform_disabled: + continue name = cmd_key.lstrip("/").replace("-", "_") desc = info.get("description", "") # Keep descriptions short — setMyCommands has an undocumented diff --git a/tests/hermes_cli/test_commands.py b/tests/hermes_cli/test_commands.py index 321f8f1615..7cda509c4d 100644 --- a/tests/hermes_cli/test_commands.py +++ b/tests/hermes_cli/test_commands.py @@ -587,3 +587,44 @@ class TestTelegramMenuCommands: assert 1 <= len(name) <= _TG_NAME_LIMIT, ( f"Command '{name}' is {len(name)} chars (limit {_TG_NAME_LIMIT})" ) + + def test_excludes_telegram_disabled_skills(self, tmp_path, monkeypatch): + """Skills disabled for telegram should not appear in the menu.""" + from unittest.mock import patch, MagicMock + + # Set up a config with a telegram-specific disabled list + config_file = tmp_path / "config.yaml" + config_file.write_text( + "skills:\n" + " platform_disabled:\n" + " telegram:\n" + " - my-disabled-skill\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + + # Mock get_skill_commands to return two skills + fake_skills_dir = str(tmp_path / "skills") + fake_cmds = { + "/my-disabled-skill": { + "name": "my-disabled-skill", + "description": "Should be hidden", + "skill_md_path": f"{fake_skills_dir}/my-disabled-skill/SKILL.md", + "skill_dir": f"{fake_skills_dir}/my-disabled-skill", + }, + "/my-enabled-skill": { + "name": "my-enabled-skill", + "description": "Should be visible", + "skill_md_path": f"{fake_skills_dir}/my-enabled-skill/SKILL.md", + "skill_dir": f"{fake_skills_dir}/my-enabled-skill", + }, + } + with ( + patch("agent.skill_commands.get_skill_commands", return_value=fake_cmds), + patch("tools.skills_tool.SKILLS_DIR", tmp_path / "skills"), + ): + (tmp_path / "skills").mkdir(exist_ok=True) + menu, hidden = telegram_menu_commands(max_commands=100) + + menu_names = {n for n, _ in menu} + assert "my_enabled_skill" in menu_names + assert "my_disabled_skill" not in menu_names diff --git a/tests/hermes_cli/test_skills_config.py b/tests/hermes_cli/test_skills_config.py index 41329793e0..310b1a8ae5 100644 --- a/tests/hermes_cli/test_skills_config.py +++ b/tests/hermes_cli/test_skills_config.py @@ -141,6 +141,109 @@ class TestIsSkillDisabled: assert _is_skill_disabled("discord-skill") is True +# --------------------------------------------------------------------------- +# get_disabled_skill_names — explicit platform param & env var fallback +# --------------------------------------------------------------------------- + +class TestGetDisabledSkillNames: + """Tests for agent.skill_utils.get_disabled_skill_names.""" + + def test_explicit_platform_param(self, tmp_path, monkeypatch): + """Explicit platform= parameter should resolve per-platform list.""" + config = tmp_path / "config.yaml" + config.write_text( + "skills:\n" + " disabled:\n" + " - global-skill\n" + " platform_disabled:\n" + " telegram:\n" + " - tg-only-skill\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + + from agent.skill_utils import get_disabled_skill_names + result = get_disabled_skill_names(platform="telegram") + assert result == {"tg-only-skill"} + + def test_session_platform_env_var(self, tmp_path, monkeypatch): + """HERMES_SESSION_PLATFORM should be used when HERMES_PLATFORM is unset.""" + config = tmp_path / "config.yaml" + config.write_text( + "skills:\n" + " disabled:\n" + " - global-skill\n" + " platform_disabled:\n" + " discord:\n" + " - discord-skill\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_PLATFORM", raising=False) + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + + from agent.skill_utils import get_disabled_skill_names + result = get_disabled_skill_names() + assert result == {"discord-skill"} + + def test_hermes_platform_takes_precedence(self, tmp_path, monkeypatch): + """HERMES_PLATFORM should win over HERMES_SESSION_PLATFORM.""" + config = tmp_path / "config.yaml" + config.write_text( + "skills:\n" + " platform_disabled:\n" + " telegram:\n" + " - tg-skill\n" + " discord:\n" + " - discord-skill\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "discord") + + from agent.skill_utils import get_disabled_skill_names + result = get_disabled_skill_names() + assert result == {"tg-skill"} + + def test_explicit_param_overrides_env_vars(self, tmp_path, monkeypatch): + """Explicit platform= param should override all env vars.""" + config = tmp_path / "config.yaml" + config.write_text( + "skills:\n" + " platform_disabled:\n" + " telegram:\n" + " - tg-skill\n" + " slack:\n" + " - slack-skill\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setenv("HERMES_PLATFORM", "telegram") + monkeypatch.setenv("HERMES_SESSION_PLATFORM", "telegram") + + from agent.skill_utils import get_disabled_skill_names + result = get_disabled_skill_names(platform="slack") + assert result == {"slack-skill"} + + def test_no_platform_returns_global(self, tmp_path, monkeypatch): + """No platform env vars or param should return global list.""" + config = tmp_path / "config.yaml" + config.write_text( + "skills:\n" + " disabled:\n" + " - global-skill\n" + " platform_disabled:\n" + " telegram:\n" + " - tg-skill\n" + ) + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.delenv("HERMES_PLATFORM", raising=False) + monkeypatch.delenv("HERMES_SESSION_PLATFORM", raising=False) + + from agent.skill_utils import get_disabled_skill_names + result = get_disabled_skill_names() + assert result == {"global-skill"} + + # --------------------------------------------------------------------------- # _find_all_skills — disabled filtering # ---------------------------------------------------------------------------