Skip to content

Commit c8fdcbc

Browse files
teknium1LeonSGP
authored andcommitted
fix(skills): rescan skill_commands cache when platform scope changes (NousResearch#18739)
The process-global `_skill_commands` dict in agent/skill_commands.py was seeded by whichever platform scanned first, and `get_skill_commands()` only rescanned when the cache was empty. In a long-lived gateway process serving multiple platforms (Telegram + Discord + Slack), the first platform's `skills.platform_disabled` view was silently inherited by the others — so a skill disabled for Telegram would also disappear from Discord's slash menu, and vice versa. Track the platform scope the cache was populated for (`_skill_commands_platform`) and rescan in `get_skill_commands()` when the currently-active platform no longer matches. Platform resolution uses the same precedence as `_is_skill_disabled`: `HERMES_PLATFORM` env var then `HERMES_SESSION_PLATFORM` from the gateway session context. Fixes NousResearch#14536 Salvages NousResearch#14570 by LeonSGP43. Co-authored-by: LeonSGP <leon@sgp43.com>
1 parent bdd2120 commit c8fdcbc

2 files changed

Lines changed: 90 additions & 3 deletions

File tree

agent/skill_commands.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import json
88
import logging
9+
import os
910
import re
1011
from pathlib import Path
1112
from typing import Any, Dict, Optional
@@ -20,10 +21,35 @@
2021
logger = logging.getLogger(__name__)
2122

2223
_skill_commands: Dict[str, Dict[str, Any]] = {}
24+
_skill_commands_platform: Optional[str] = None
2325
# Patterns for sanitizing skill names into clean hyphen-separated slugs.
2426
_SKILL_INVALID_CHARS = re.compile(r"[^a-z0-9-]")
2527
_SKILL_MULTI_HYPHEN = re.compile(r"-{2,}")
2628

29+
30+
def _resolve_skill_commands_platform() -> Optional[str]:
31+
"""Return the current platform scope used for disabled-skill filtering.
32+
33+
Used to detect when the active platform has shifted so
34+
:func:`get_skill_commands` can drop a stale cache that was populated
35+
for a different platform's ``skills.platform_disabled`` view (#14536).
36+
37+
Resolves from (in order) ``HERMES_PLATFORM`` env var and
38+
``HERMES_SESSION_PLATFORM`` from the gateway session context. Returns
39+
``None`` when no platform scope is active (e.g. classic CLI, RL
40+
rollouts, standalone scripts).
41+
"""
42+
try:
43+
from gateway.session_context import get_session_env
44+
45+
resolved_platform = (
46+
os.getenv("HERMES_PLATFORM")
47+
or get_session_env("HERMES_SESSION_PLATFORM")
48+
)
49+
except Exception:
50+
resolved_platform = os.getenv("HERMES_PLATFORM")
51+
return resolved_platform or None
52+
2753
def _load_skill_payload(skill_identifier: str, task_id: str | None = None) -> tuple[dict[str, Any], Path | None, str] | None:
2854
"""Load a skill by name/path and return (loaded_payload, skill_dir, display_name)."""
2955
raw_identifier = (skill_identifier or "").strip()
@@ -218,7 +244,8 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
218244
Returns:
219245
Dict mapping "/skill-name" to {name, description, skill_md_path, skill_dir}.
220246
"""
221-
global _skill_commands
247+
global _skill_commands, _skill_commands_platform
248+
_skill_commands_platform = _resolve_skill_commands_platform()
222249
_skill_commands = {}
223250
try:
224251
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
@@ -278,8 +305,16 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
278305

279306

280307
def get_skill_commands() -> Dict[str, Dict[str, Any]]:
281-
"""Return the current skill commands mapping (scan first if empty)."""
282-
if not _skill_commands:
308+
"""Return the current skill commands mapping (scan first if empty).
309+
310+
Rescans when the active platform scope changes (e.g. a gateway
311+
process serving Telegram and Discord concurrently) so each platform
312+
sees its own ``skills.platform_disabled`` view (#14536).
313+
"""
314+
if (
315+
not _skill_commands
316+
or _skill_commands_platform != _resolve_skill_commands_platform()
317+
):
283318
scan_skill_commands()
284319
return _skill_commands
285320

tests/agent/test_skill_commands.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,58 @@ def test_finds_skills_in_symlinked_category_dir(self, tmp_path):
125125
assert "/knowledge-brain" in result
126126
assert result["/knowledge-brain"]["name"] == "knowledge-brain"
127127

128+
def test_get_skill_commands_rescans_when_platform_scope_changes(self, tmp_path):
129+
"""Platform-specific disabled-skill caches must not leak across platforms.
130+
131+
Regression test for #14536: a gateway process serving Telegram
132+
and Discord concurrently would seed the process-global cache
133+
with whichever platform scanned first, and subsequent
134+
``get_skill_commands()`` calls from the other platform silently
135+
inherited that filter.
136+
"""
137+
import agent.skill_commands as sc_mod
138+
from agent.skill_commands import get_skill_commands
139+
140+
def _disabled_skills():
141+
platform = os.getenv("HERMES_PLATFORM")
142+
if platform == "telegram":
143+
return {"telegram-only"}
144+
if platform == "discord":
145+
return {"discord-only"}
146+
return set()
147+
148+
with (
149+
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
150+
patch("tools.skills_tool._get_disabled_skill_names", side_effect=_disabled_skills),
151+
patch.object(sc_mod, "_skill_commands", {}),
152+
patch.object(sc_mod, "_skill_commands_platform", None),
153+
):
154+
_make_skill(tmp_path, "shared")
155+
_make_skill(tmp_path, "telegram-only")
156+
_make_skill(tmp_path, "discord-only")
157+
158+
with patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}):
159+
telegram_commands = dict(get_skill_commands())
160+
161+
assert "/shared" in telegram_commands
162+
assert "/discord-only" in telegram_commands
163+
assert "/telegram-only" not in telegram_commands
164+
165+
with patch.dict(os.environ, {"HERMES_PLATFORM": "discord"}):
166+
discord_commands = dict(get_skill_commands())
167+
168+
assert "/shared" in discord_commands
169+
assert "/telegram-only" in discord_commands
170+
assert "/discord-only" not in discord_commands
171+
172+
# Switching back to telegram must also rescan — not re-serve
173+
# the discord view that was just cached.
174+
with patch.dict(os.environ, {"HERMES_PLATFORM": "telegram"}):
175+
telegram_again = dict(get_skill_commands())
176+
177+
assert "/telegram-only" not in telegram_again
178+
assert "/discord-only" in telegram_again
179+
128180

129181
def test_special_chars_stripped_from_cmd_key(self, tmp_path):
130182
"""Skill names with +, /, or other special chars produce clean cmd keys."""

0 commit comments

Comments
 (0)