mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
fix: disabled skills respected across banner, system prompt, slash commands, and skill_view (#1897)
* fix: banner skill count now respects disabled skills and platform filtering The banner's get_available_skills() was doing a raw rglob scan of ~/.hermes/skills/ without checking: - Whether skills are disabled (skills.disabled config) - Whether skills match the current platform (platforms: frontmatter) This caused the banner to show inflated skill counts (e.g. '100 skills' when many are disabled) and list macOS-only skills on Linux. Fix: delegate to _find_all_skills() from tools/skills_tool which already handles both platform gating and disabled-skill filtering. * fix: system prompt and slash commands now respect disabled skills Two more places where disabled skills were still surfaced: 1. build_skills_system_prompt() in prompt_builder.py — disabled skills appeared in the <available_skills> system prompt section, causing the agent to suggest/load them despite being disabled. 2. scan_skill_commands() in skill_commands.py — disabled skills still registered as /skill-name slash commands in CLI help and could be invoked. Both now load _get_disabled_skill_names() and filter accordingly. * fix: skill_view blocks disabled skills skill_view() checked platform compatibility but not disabled state, so the agent could still load and read disabled skills directly. Now returns a clear error when a disabled skill is requested, telling the user to enable it via hermes skills or inspect the files manually. --------- Co-authored-by: Test <test@test.com>
This commit is contained in:
parent
011ed540dd
commit
b70dd51cfa
8 changed files with 190 additions and 30 deletions
|
|
@ -330,28 +330,34 @@ def build_skills_system_prompt(
|
||||||
# Each entry: (skill_name, description)
|
# Each entry: (skill_name, description)
|
||||||
# Supports sub-categories: skills/mlops/training/axolotl/SKILL.md
|
# Supports sub-categories: skills/mlops/training/axolotl/SKILL.md
|
||||||
# -> category "mlops/training", skill "axolotl"
|
# -> category "mlops/training", skill "axolotl"
|
||||||
|
# Load disabled skill names once for the entire scan
|
||||||
|
try:
|
||||||
|
from tools.skills_tool import _get_disabled_skill_names
|
||||||
|
disabled = _get_disabled_skill_names()
|
||||||
|
except Exception:
|
||||||
|
disabled = set()
|
||||||
|
|
||||||
skills_by_category: dict[str, list[tuple[str, str]]] = {}
|
skills_by_category: dict[str, list[tuple[str, str]]] = {}
|
||||||
for skill_file in skills_dir.rglob("SKILL.md"):
|
for skill_file in skills_dir.rglob("SKILL.md"):
|
||||||
is_compatible, _, desc = _parse_skill_file(skill_file)
|
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
|
||||||
if not is_compatible:
|
if not is_compatible:
|
||||||
continue
|
continue
|
||||||
# Skip skills whose conditional activation rules exclude them
|
|
||||||
conditions = _read_skill_conditions(skill_file)
|
|
||||||
if not _skill_should_show(conditions, available_tools, available_toolsets):
|
|
||||||
continue
|
|
||||||
rel_path = skill_file.relative_to(skills_dir)
|
rel_path = skill_file.relative_to(skills_dir)
|
||||||
parts = rel_path.parts
|
parts = rel_path.parts
|
||||||
if len(parts) >= 2:
|
if len(parts) >= 2:
|
||||||
# Category is everything between skills_dir and the skill folder
|
|
||||||
# e.g. parts = ("mlops", "training", "axolotl", "SKILL.md")
|
|
||||||
# → category = "mlops/training", skill_name = "axolotl"
|
|
||||||
# e.g. parts = ("github", "github-auth", "SKILL.md")
|
|
||||||
# → category = "github", skill_name = "github-auth"
|
|
||||||
skill_name = parts[-2]
|
skill_name = parts[-2]
|
||||||
category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
|
category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
|
||||||
else:
|
else:
|
||||||
category = "general"
|
category = "general"
|
||||||
skill_name = skill_file.parent.name
|
skill_name = skill_file.parent.name
|
||||||
|
# Respect user's disabled skills config
|
||||||
|
fm_name = frontmatter.get("name", skill_name)
|
||||||
|
if fm_name in disabled or skill_name in disabled:
|
||||||
|
continue
|
||||||
|
# Skip skills whose conditional activation rules exclude them
|
||||||
|
conditions = _read_skill_conditions(skill_file)
|
||||||
|
if not _skill_should_show(conditions, available_tools, available_toolsets):
|
||||||
|
continue
|
||||||
skills_by_category.setdefault(category, []).append((skill_name, desc))
|
skills_by_category.setdefault(category, []).append((skill_name, desc))
|
||||||
|
|
||||||
if not skills_by_category:
|
if not skills_by_category:
|
||||||
|
|
|
||||||
|
|
@ -157,9 +157,10 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||||
global _skill_commands
|
global _skill_commands
|
||||||
_skill_commands = {}
|
_skill_commands = {}
|
||||||
try:
|
try:
|
||||||
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform
|
from tools.skills_tool import SKILLS_DIR, _parse_frontmatter, skill_matches_platform, _get_disabled_skill_names
|
||||||
if not SKILLS_DIR.exists():
|
if not SKILLS_DIR.exists():
|
||||||
return _skill_commands
|
return _skill_commands
|
||||||
|
disabled = _get_disabled_skill_names()
|
||||||
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
for skill_md in SKILLS_DIR.rglob("SKILL.md"):
|
||||||
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
if any(part in ('.git', '.github', '.hub') for part in skill_md.parts):
|
||||||
continue
|
continue
|
||||||
|
|
@ -170,6 +171,9 @@ def scan_skill_commands() -> Dict[str, Dict[str, Any]]:
|
||||||
if not skill_matches_platform(frontmatter):
|
if not skill_matches_platform(frontmatter):
|
||||||
continue
|
continue
|
||||||
name = frontmatter.get('name', skill_md.parent.name)
|
name = frontmatter.get('name', skill_md.parent.name)
|
||||||
|
# Respect user's disabled skills config
|
||||||
|
if name in disabled:
|
||||||
|
continue
|
||||||
description = frontmatter.get('description', '')
|
description = frontmatter.get('description', '')
|
||||||
if not description:
|
if not description:
|
||||||
for line in body.strip().split('\n'):
|
for line in body.strip().split('\n'):
|
||||||
|
|
|
||||||
|
|
@ -102,27 +102,22 @@ COMPACT_BANNER = """
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def get_available_skills() -> Dict[str, List[str]]:
|
def get_available_skills() -> Dict[str, List[str]]:
|
||||||
"""Scan ~/.hermes/skills/ and return skills grouped by category."""
|
"""Return skills grouped by category, filtered by platform and disabled state.
|
||||||
import os
|
|
||||||
|
|
||||||
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
|
Delegates to ``_find_all_skills()`` from ``tools/skills_tool`` which already
|
||||||
skills_dir = hermes_home / "skills"
|
handles platform gating (``platforms:`` frontmatter) and respects the
|
||||||
skills_by_category = {}
|
user's ``skills.disabled`` config list.
|
||||||
|
"""
|
||||||
if not skills_dir.exists():
|
try:
|
||||||
return skills_by_category
|
from tools.skills_tool import _find_all_skills
|
||||||
|
all_skills = _find_all_skills() # already filtered
|
||||||
for skill_file in skills_dir.rglob("SKILL.md"):
|
except Exception:
|
||||||
rel_path = skill_file.relative_to(skills_dir)
|
return {}
|
||||||
parts = rel_path.parts
|
|
||||||
if len(parts) >= 2:
|
|
||||||
category = parts[0]
|
|
||||||
skill_name = parts[-2]
|
|
||||||
else:
|
|
||||||
category = "general"
|
|
||||||
skill_name = skill_file.parent.name
|
|
||||||
skills_by_category.setdefault(category, []).append(skill_name)
|
|
||||||
|
|
||||||
|
skills_by_category: Dict[str, List[str]] = {}
|
||||||
|
for skill in all_skills:
|
||||||
|
category = skill.get("category") or "general"
|
||||||
|
skills_by_category.setdefault(category, []).append(skill["name"])
|
||||||
return skills_by_category
|
return skills_by_category
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -309,6 +309,35 @@ class TestBuildSkillsSystemPrompt:
|
||||||
assert "imessage" in result
|
assert "imessage" in result
|
||||||
assert "Send iMessages" in result
|
assert "Send iMessages" in result
|
||||||
|
|
||||||
|
def test_excludes_disabled_skills(self, monkeypatch, tmp_path):
|
||||||
|
"""Skills in the user's disabled list should not appear in the system prompt."""
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
|
skills_dir = tmp_path / "skills" / "tools"
|
||||||
|
skills_dir.mkdir(parents=True)
|
||||||
|
|
||||||
|
enabled_skill = skills_dir / "web-search"
|
||||||
|
enabled_skill.mkdir()
|
||||||
|
(enabled_skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: web-search\ndescription: Search the web\n---\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
disabled_skill = skills_dir / "old-tool"
|
||||||
|
disabled_skill.mkdir()
|
||||||
|
(disabled_skill / "SKILL.md").write_text(
|
||||||
|
"---\nname: old-tool\ndescription: Deprecated tool\n---\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"tools.skills_tool._get_disabled_skill_names",
|
||||||
|
return_value={"old-tool"},
|
||||||
|
):
|
||||||
|
result = build_skills_system_prompt()
|
||||||
|
|
||||||
|
assert "web-search" in result
|
||||||
|
assert "old-tool" not in result
|
||||||
|
|
||||||
def test_includes_setup_needed_skills(self, monkeypatch, tmp_path):
|
def test_includes_setup_needed_skills(self, monkeypatch, tmp_path):
|
||||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||||
monkeypatch.delenv("MISSING_API_KEY_XYZ", raising=False)
|
monkeypatch.delenv("MISSING_API_KEY_XYZ", raising=False)
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,21 @@ class TestScanSkillCommands:
|
||||||
result = scan_skill_commands()
|
result = scan_skill_commands()
|
||||||
assert "/generic-tool" in result
|
assert "/generic-tool" in result
|
||||||
|
|
||||||
|
def test_excludes_disabled_skills(self, tmp_path):
|
||||||
|
"""Disabled skills should not register slash commands."""
|
||||||
|
with (
|
||||||
|
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
||||||
|
patch(
|
||||||
|
"tools.skills_tool._get_disabled_skill_names",
|
||||||
|
return_value={"disabled-skill"},
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_make_skill(tmp_path, "enabled-skill")
|
||||||
|
_make_skill(tmp_path, "disabled-skill")
|
||||||
|
result = scan_skill_commands()
|
||||||
|
assert "/enabled-skill" in result
|
||||||
|
assert "/disabled-skill" not in result
|
||||||
|
|
||||||
|
|
||||||
class TestBuildPreloadedSkillsPrompt:
|
class TestBuildPreloadedSkillsPrompt:
|
||||||
def test_builds_prompt_for_multiple_named_skills(self, tmp_path):
|
def test_builds_prompt_for_multiple_named_skills(self, tmp_path):
|
||||||
|
|
|
||||||
68
tests/hermes_cli/test_banner_skills.py
Normal file
68
tests/hermes_cli/test_banner_skills.py
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
"""Tests for banner get_available_skills() — disabled and platform filtering."""
|
||||||
|
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
_MOCK_SKILLS = [
|
||||||
|
{"name": "skill-a", "description": "A skill", "category": "tools"},
|
||||||
|
{"name": "skill-b", "description": "B skill", "category": "tools"},
|
||||||
|
{"name": "skill-c", "description": "C skill", "category": "creative"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_skills_delegates_to_find_all_skills():
|
||||||
|
"""get_available_skills should call _find_all_skills (which handles filtering)."""
|
||||||
|
with patch("tools.skills_tool._find_all_skills", return_value=list(_MOCK_SKILLS)):
|
||||||
|
from hermes_cli.banner import get_available_skills
|
||||||
|
result = get_available_skills()
|
||||||
|
|
||||||
|
assert "tools" in result
|
||||||
|
assert "creative" in result
|
||||||
|
assert sorted(result["tools"]) == ["skill-a", "skill-b"]
|
||||||
|
assert result["creative"] == ["skill-c"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_skills_excludes_disabled():
|
||||||
|
"""Disabled skills should not appear in the banner count."""
|
||||||
|
# _find_all_skills already filters disabled skills, so if we give it
|
||||||
|
# a filtered list, get_available_skills should reflect that.
|
||||||
|
filtered = [s for s in _MOCK_SKILLS if s["name"] != "skill-b"]
|
||||||
|
with patch("tools.skills_tool._find_all_skills", return_value=filtered):
|
||||||
|
from hermes_cli.banner import get_available_skills
|
||||||
|
result = get_available_skills()
|
||||||
|
|
||||||
|
all_names = [n for names in result.values() for n in names]
|
||||||
|
assert "skill-b" not in all_names
|
||||||
|
assert "skill-a" in all_names
|
||||||
|
assert len(all_names) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_skills_empty_when_no_skills():
|
||||||
|
"""No skills installed returns empty dict."""
|
||||||
|
with patch("tools.skills_tool._find_all_skills", return_value=[]):
|
||||||
|
from hermes_cli.banner import get_available_skills
|
||||||
|
result = get_available_skills()
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_skills_handles_import_failure():
|
||||||
|
"""If _find_all_skills import fails, return empty dict gracefully."""
|
||||||
|
with patch("tools.skills_tool._find_all_skills", side_effect=ImportError("boom")):
|
||||||
|
from hermes_cli.banner import get_available_skills
|
||||||
|
result = get_available_skills()
|
||||||
|
|
||||||
|
assert result == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_available_skills_null_category_becomes_general():
|
||||||
|
"""Skills with None category should be grouped under 'general'."""
|
||||||
|
skills = [{"name": "orphan-skill", "description": "No cat", "category": None}]
|
||||||
|
with patch("tools.skills_tool._find_all_skills", return_value=skills):
|
||||||
|
from hermes_cli.banner import get_available_skills
|
||||||
|
result = get_available_skills()
|
||||||
|
|
||||||
|
assert "general" in result
|
||||||
|
assert result["general"] == ["orphan-skill"]
|
||||||
|
|
@ -374,6 +374,35 @@ class TestSkillView:
|
||||||
result = json.loads(raw)
|
result = json.loads(raw)
|
||||||
assert result["success"] is False
|
assert result["success"] is False
|
||||||
|
|
||||||
|
def test_view_disabled_skill_blocked(self, tmp_path):
|
||||||
|
"""Disabled skills should not be viewable via skill_view."""
|
||||||
|
with (
|
||||||
|
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
||||||
|
patch(
|
||||||
|
"tools.skills_tool._is_skill_disabled",
|
||||||
|
return_value=True,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_make_skill(tmp_path, "hidden-skill")
|
||||||
|
raw = skill_view("hidden-skill")
|
||||||
|
result = json.loads(raw)
|
||||||
|
assert result["success"] is False
|
||||||
|
assert "disabled" in result["error"].lower()
|
||||||
|
|
||||||
|
def test_view_enabled_skill_allowed(self, tmp_path):
|
||||||
|
"""Non-disabled skills should be viewable normally."""
|
||||||
|
with (
|
||||||
|
patch("tools.skills_tool.SKILLS_DIR", tmp_path),
|
||||||
|
patch(
|
||||||
|
"tools.skills_tool._is_skill_disabled",
|
||||||
|
return_value=False,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
_make_skill(tmp_path, "active-skill")
|
||||||
|
raw = skill_view("active-skill")
|
||||||
|
result = json.loads(raw)
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
|
||||||
class TestSkillViewSecureSetupOnLoad:
|
class TestSkillViewSecureSetupOnLoad:
|
||||||
def test_requests_missing_required_env_and_continues(self, tmp_path, monkeypatch):
|
def test_requests_missing_required_env_and_continues(self, tmp_path, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -920,6 +920,20 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Check if the skill is disabled by the user
|
||||||
|
resolved_name = parsed_frontmatter.get("name", skill_md.parent.name)
|
||||||
|
if _is_skill_disabled(resolved_name):
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": (
|
||||||
|
f"Skill '{resolved_name}' is disabled. "
|
||||||
|
"Enable it with `hermes skills` or inspect the files directly on disk."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
# If a specific file path is requested, read that instead
|
# If a specific file path is requested, read that instead
|
||||||
if file_path and skill_dir:
|
if file_path and skill_dir:
|
||||||
# Security: Prevent path traversal attacks
|
# Security: Prevent path traversal attacks
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue