feat: add prerequisites field to skill spec — hide skills with unmet dependencies

Skills can now declare runtime prerequisites (env vars, CLI binaries) via
YAML frontmatter. Skills with unmet prerequisites are excluded from the
system prompt so the agent never claims capabilities it can't deliver, and
skill_view() warns the agent about what's missing.

Three layers of defense:
- build_skills_system_prompt() filters out unavailable skills
- _find_all_skills() flags unmet prerequisites in metadata
- skill_view() returns prerequisites_warning with actionable details

Tagged 12 bundled skills that have hard runtime dependencies:
gif-search (TENOR_API_KEY), notion (NOTION_API_KEY), himalaya, imessage,
apple-notes, apple-reminders, openhue, duckduckgo-search, codebase-inspection,
blogwatcher, songsee, mcporter.

Closes #658
Fixes #630
This commit is contained in:
kshitij 2026-03-08 12:55:09 +05:30
parent 76545ab365
commit f210510276
17 changed files with 336 additions and 11 deletions

View file

@ -8,6 +8,7 @@ from agent.prompt_builder import (
_scan_context_content,
_truncate_content,
_read_skill_description,
_skill_prerequisites_met,
build_skills_system_prompt,
build_context_files_prompt,
CONTEXT_FILE_MAX_CHARS,
@ -211,6 +212,69 @@ class TestBuildSkillsSystemPrompt:
assert "imessage" in result
assert "Send iMessages" in result
def test_excludes_skills_with_unmet_prerequisites(self, monkeypatch, tmp_path):
"""Skills with missing env var prerequisites should not appear."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("MISSING_API_KEY_XYZ", raising=False)
skills_dir = tmp_path / "skills" / "media"
gated = skills_dir / "gated-skill"
gated.mkdir(parents=True)
(gated / "SKILL.md").write_text(
"---\nname: gated-skill\ndescription: Needs a key\n"
"prerequisites:\n env_vars: [MISSING_API_KEY_XYZ]\n---\n"
)
available = skills_dir / "free-skill"
available.mkdir(parents=True)
(available / "SKILL.md").write_text(
"---\nname: free-skill\ndescription: No prereqs\n---\n"
)
result = build_skills_system_prompt()
assert "free-skill" in result
assert "gated-skill" not in result
def test_includes_skills_with_met_prerequisites(self, monkeypatch, tmp_path):
"""Skills with satisfied prerequisites should appear normally."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("MY_API_KEY", "test_value")
skills_dir = tmp_path / "skills" / "media"
skill = skills_dir / "ready-skill"
skill.mkdir(parents=True)
(skill / "SKILL.md").write_text(
"---\nname: ready-skill\ndescription: Has key\n"
"prerequisites:\n env_vars: [MY_API_KEY]\n---\n"
)
result = build_skills_system_prompt()
assert "ready-skill" in result
# =========================================================================
# _skill_prerequisites_met
# =========================================================================
class TestSkillPrerequisitesMet:
def test_met_or_absent(self, tmp_path, monkeypatch):
"""No prereqs, met prereqs, and missing file all return True."""
monkeypatch.setenv("PRESENT_KEY_123", "val")
basic = tmp_path / "basic.md"
basic.write_text("---\nname: basic\ndescription: basic\n---\n")
ready = tmp_path / "ready.md"
ready.write_text("---\nname: ready\ndescription: ready\nprerequisites:\n env_vars: [PRESENT_KEY_123]\n---\n")
assert _skill_prerequisites_met(basic) is True
assert _skill_prerequisites_met(ready) is True
assert _skill_prerequisites_met(tmp_path / "nope.md") is True
def test_unmet_returns_false(self, tmp_path, monkeypatch):
monkeypatch.delenv("NONEXISTENT_KEY_ABC", raising=False)
skill = tmp_path / "SKILL.md"
skill.write_text("---\nname: gated\ndescription: gated\nprerequisites:\n env_vars: [NONEXISTENT_KEY_ABC]\n---\n")
assert _skill_prerequisites_met(skill) is False
# =========================================================================
# Context files prompt builder