Conditional skill activation based on runtime prerequisites

NousResearch/hermes-agent · cluster 539 · agent

Data as of

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

The short answer

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

These issues and PRs concern a mechanism for skills to declare required tools, environment variables, or CLI binaries, and to be hidden or replaced by fallbacks when those dependencies are unavailable. The theme spans both the feature design and a related bug about skills misrepresenting capabilities when prerequisites are unmet.

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.

  • #659 feat: skill prerequisites — hide skills with unmet runtime dependencies

    merged to main

    files: CONTRIBUTING.md, agent/prompt_builder.py, skills/apple/apple-notes/SKILL.md, skills/apple/apple-reminders/SKILL.md, skills/apple/imessage/SKILL.md, skills/email/himalaya/SKILL.md, skills/feeds/blogwatcher/SKILL.md, skills/gifs/gif-search/SKILL.md, skills/github/codebase-inspection/SKILL.md, skills/mcp/mcporter/SKILL.md, skills/music-creation/songsee/SKILL.md, skills/productivity/notion/SKILL.md, skills/research/duckduckgo-search/SKILL.md, skills/smart-home/openhue/SKILL.md, tests/agent/test_prompt_builder.py, tests/tools/test_skills_tool.py, tools/skills_tool.py

    Show the diff
    • CONTRIBUTING.md
      @@ -328,6 +328,11 @@ license: MIT
       platforms: [macos, linux]          # Optional — restrict to specific OS platforms
                                          #   Valid: macos, linux, windows
                                          #   Omit to load on all platforms (default)
      +prerequisites:                     # Optional — runtime requirements
      +  env_vars: [MY_API_KEY]           #   Env vars that must be set
      +  commands: [curl, jq]             #   CLI binaries that must be on PATH
      +                                   #   Skills with unmet prerequisites are hidden
      +                                   #   from the system prompt and flagged in skill_view.
       metadata:
         hermes:
           tags: [Category, Subcategory, Keywords]
      @@ -366,6 +371,25 @@ platforms: [windows]          # Windows only
       
       If the field is omitted or empty, the skill loads on all platforms (backward compatible). See `skills/apple/` for examples of macOS-only skills.
       
      +### Skill prerequisites
      +
      +Skills can declare runtime prerequisites via the `prerequisites` frontmatter field. Skills with unmet prerequisites are automatically hidden from the system prompt (the agent won't claim it can use them) and show a clear warning in `skill_view()` telling the agent what's missing.
      +
      +```yaml
      +prerequisites:
      +  env_vars: [TENOR_API_KEY]       # Env vars checked via os.getenv()
      +  commands: [curl, jq]            # CLI binaries checked via shutil.which()
      +```
      +
      +Both sub-fields are optional — declare only what applies. If the field is omitted entirely, the skill is always available (backward compatible).
      +
      +**When to declare prerequisites:**
      +- The skill uses a CLI tool that isn't universally installed (e.g., `himalaya`, `openhue`, `ddgs`)
      +- The skill requires an API key in the environment (e.g., `NOTION_API_KEY`, `TENOR_API_KEY`)
      +- Without these, the skill's commands will fail — not just degrade gracefully
      +
      +See `skills/gifs/gif-search/` and `skills/email/himalaya/` for examples.
      +
       ### Skill guidelines
       
       - **No external dependencies unless absolutely necessary.** Prefer stdlib Python, curl, and existing Hermes tools (`web_extract`, `terminal`, `read_file`).
    • agent/prompt_builder.py
      @@ -170,6 +170,22 @@ def _skill_is_platform_compatible(skill_file: Path) -> bool:
               return True  # Err on the side of showing the skill
       
       
      +def _skill_prerequisites_met(skill_file: Path) -> bool:
      +    """Check if a SKILL.md's declared prerequisites are satisfied.
      +
      +    Returns True (show the skill) when prerequisites are met or not declared.
      +    Returns False when the skill explicitly declares prerequisites that are missing.
      +    """
      +    try:
      +        from tools.skills_tool import _parse_frontmatter, check_skill_prerequisites
      +        raw = skill_file.read_text(encoding="utf-8")[:2000]
      +        frontmatter, _ = _parse_frontmatter(raw)
      +        met, _ = check_skill_prerequisites(frontmatter)
      +        return met
      +    except Exception:
      +        return True
      +
      +
       def build_skills_system_prompt() -> str:
           """Build a compact skill index for the system prompt.
       
      @@ -191,6 +207,9 @@ def build_skills_system_prompt() -> str:
               # Skip skills incompatible with the current OS platform
               if not _skill_is_platform_compatible(skill_file):
                   continue
      +        # Skip skills whose prerequisites (env vars, commands) are unmet
      +        if not _skill_prerequisites_met(skill_file):
      +            continue
               rel_path = skill_file.relative_to(skills_dir)
               parts = rel_path.parts
               if len(parts) >= 2:
    • skills/apple/apple-notes/SKILL.md
      @@ -9,6 +9,8 @@ metadata:
         hermes:
           tags: [Notes, Apple, macOS, note-taking]
           related_skills: [obsidian]
      +prerequisites:
      +  commands: [memo]
       ---
       
       # Apple Notes
    • skills/apple/apple-reminders/SKILL.md
      @@ -8,6 +8,8 @@ platforms: [macos]
       metadata:
         hermes:
           tags: [Reminders, tasks, todo, macOS, Apple]
      +prerequisites:
      +  commands: [remindctl]
       ---
       
       # Apple Reminders
    • skills/apple/imessage/SKILL.md
      @@ -8,6 +8,8 @@ platforms: [macos]
       metadata:
         hermes:
           tags: [iMessage, SMS, messaging, macOS, Apple]
      +prerequisites:
      +  commands: [imsg]
       ---
       
       # iMessage
    • skills/email/himalaya/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [Email, IMAP, SMTP, CLI, Communication]
           homepage: https://github.com/pimalaya/himalaya
      +prerequisites:
      +  commands: [himalaya]
       ---
       
       # Himalaya Email CLI
    • skills/feeds/blogwatcher/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [RSS, Blogs, Feed-Reader, Monitoring]
           homepage: https://github.com/Hyaxia/blogwatcher
      +prerequisites:
      +  commands: [blogwatcher]
       ---
       
       # Blogwatcher
    • skills/gifs/gif-search/SKILL.md
      @@ -1,9 +1,12 @@
       ---
       name: gif-search
       description: Search and download GIFs from Tenor using curl. No dependencies beyond curl and jq. Useful for finding reaction GIFs, creating visual content, and sending GIFs in chat.
      -version: 1.0.0
      +version: 1.1.0
       author: Hermes Agent
       license: MIT
      +prerequisites:
      +  env_vars: [TENOR_API_KEY]
      +  commands: [curl, jq]
       metadata:
         hermes:
           tags: [GIF, Media, Search, Tenor, API]
      @@ -13,32 +16,43 @@ metadata:
       
       Search and download GIFs directly via the Tenor API using curl. No extra tools needed.
       
      +## Setup
      +
      +Set your Tenor API key in your environment (add to `~/.hermes/.env`):
      +
      +```bash
      +TENOR_API_KEY=[redacted:secret-assignment]
      +```
      +
      +Get a free API key at https://developers.google.com/tenor/guides/quickstart — the Google Cloud Console Tenor API key is free and has generous rate limits.
      +
       ## Prerequisites
       
      -- `curl` and `jq` (both standard on Linux)
      +- `curl` and `jq` (both standard on macOS/Linux)
      +- `TENOR_API_KEY` environment variable
       
       ## Search for GIFs
       
       ```bash
       # Search and get GIF URLs
      -curl -s "https://tenor.googleapis.com/v2/search?q=thumbs+up&limit=5&key=[redacted:google-api-key]" | jq -r '.results[].media_formats.gif.url'
      +curl -s "https://tenor.googleapis.com/v2/search?q=thumbs+up&limit=5&key=${TENOR_API_KEY}" | jq -r '.results[].media_formats.gif.url'
       
       # Get smaller/preview versions
      -curl -s "https://tenor.googleapis.com/v2/search?q=nice+work&limit=3&key=[redacted:google-api-key]" | jq -r '.results[].media_formats.tinygif.url'
      +curl -s "https://tenor.googleapis.com/v2/search?q=nice+work&limit=3&key=${TENOR_API_KEY}" | jq -r '.results[].media_formats.tinygif.url'
       ```
       
       ## Download a GIF
       
       ```bash
       # Search and download the top result
      -URL=$(curl -s "https://tenor.googleapis.com/v2/search?q=celebration&limit=1&key=[redacted:google-api-key]" | jq -r '.results[0].media_formats.gif.url')
      +URL=$(curl -s "https://tenor.googleapis.com/v2/search?q=celebration&limit=1&key=${TENOR_API_KEY}" | jq -r '.results[0].media_formats.gif.url')
       curl -sL "$URL" -o celebration.gif
       ```
       
       ## Get Full Metadata
       
       ```bash
      -curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=[redacted:google-api-key]" | jq '.results[] | {title: .title, url: .media_formats.gif.url, preview: .media_formats.tinygif.url, dimensions: .media_formats.gif.dims}'
      +curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=${TENOR_API_KEY}" | jq '.results[] | {title: .title, url: .media_formats.gif.url, preview: .media_formats.tinygif.url, dimensions: .media_formats.gif.dims}'
       ```
       
       ## API Parameters
      @@ -47,7 +61,7 @@ curl -s "https://tenor.googleapis.com/v2/search?q=cat&limit=3&key=AIzaSyAyimkuYQ
       |-----------|-------------|
       | `q` | Search query (URL-encode spaces as `+`) |
       | `limit` | Max results (1-50, default 20) |
      -| `key` | API key (the one above is Tenor's public demo key) |
      +| `key` | API key (from `$TENOR_API_KEY` env var) |
       | `media_filter` | Filter formats: `gif`, `tinygif`, `mp4`, `tinymp4`, `webm` |
       | `contentfilter` | Safety: `off`, `low`, `medium`, `high` |
       | `locale` | Language: `en_US`, `es`, `fr`, etc. |
      @@ -67,7 +81,6 @@ Each result has multiple formats under `.media_formats`:
       
       ## Notes
       
      -- The API key above is Tenor's public demo key — it works but has rate limits
       - URL-encode the query: spaces as `+`, special chars as `%XX`
       - For sending in chat, `tinygif` URLs are lighter weight
       - GIF URLs can be used directly in markdown: `![alt](url)`
    • skills/github/codebase-inspection/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [LOC, Code Analysis, pygount, Codebase, Metrics, Repository]
           related_skills: [github-repo-management]
      +prerequisites:
      +  commands: [pygount]
       ---
       
       # Codebase Inspection with pygount
    • skills/mcp/mcporter/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [MCP, Tools, API, Integrations, Interop]
           homepage: https://mcporter.dev
      +prerequisites:
      +  commands: [npx]
       ---
       
       # mcporter
    • skills/music-creation/songsee/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [Audio, Visualization, Spectrogram, Music, Analysis]
           homepage: https://github.com/steipete/songsee
      +prerequisites:
      +  commands: [songsee]
       ---
       
       # songsee
    • skills/productivity/notion/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [Notion, Productivity, Notes, Database, API]
           homepage: https://developers.notion.com
      +prerequisites:
      +  env_vars: [NOTION_API_KEY]
       ---
       
       # Notion API
    • skills/research/duckduckgo-search/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [search, duckduckgo, web-search, free, fallback]
           related_skills: [arxiv]
      +prerequisites:
      +  commands: [ddgs]
       ---
       
       # DuckDuckGo Search (Firecrawl Fallback)
    • skills/smart-home/openhue/SKILL.md
      @@ -8,6 +8,8 @@ metadata:
         hermes:
           tags: [Smart-Home, Hue, Lights, IoT, Automation]
           homepage: https://www.openhue.io/cli
      +prerequisites:
      +  commands: [openhue]
       ---
       
       # OpenHue CLI
    • tests/agent/test_prompt_builder.py
      @@ -8,6 +8,7 @@
           _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 @@ def test_includes_matching_platform_skills(self, monkeypatch, tmp_path):
               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
    • tests/tools/test_skills_tool.py
      @@ -11,6 +11,7 @@
           _estimate_tokens,
           _find_all_skills,
           _load_category_description,
      +    check_skill_prerequisites,
           skill_matches_platform,
           skills_list,
           skills_categories,
      @@ -464,3 +465,124 @@ def test_multi_platform_skill(self, tmp_path):
               assert len(skills_darwin) == 1
               assert len(skills_linux) == 1
               assert len(skills_win) == 0
      +
      +
      +# ---------------------------------------------------------------------------
      +# check_skill_prerequisites
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestCheckSkillPrerequisites:
      +    def test_no_or_empty_prerequisites(self):
      +        """No field, empty dict, or non-dict all pass."""
      +        assert check_skill_prerequisites({})[0] is True
      +        assert check_skill_prerequisites({"prerequisites": {}})[0] is True
      +        assert check_skill_prerequisites({"prerequisites": "curl"})[0] is True
      +
      +    def test_env_var_present_and_missing(self, monkeypatch):
      +        monkeypatch.setenv("MY_TEST_KEY", "val")
      +        monkeypatch.delenv("NONEXISTENT_TEST_VAR_XYZ", raising=False)
      +        assert check_skill_prerequisites({"prerequisites": {"env_vars": ["MY_TEST_KEY"]}})[0] is True
      +        met, missing = check_skill_prerequisites({"prerequisites": {"env_vars": ["NONEXISTENT_TEST_VAR_XYZ"]}})
      +        assert met is False
      +        assert "env $NONEXISTENT_TEST_VAR_XYZ" in missing
      +
      +    def test_command_present_and_missing(self):
      +        assert check_skill_prerequisites({"prerequisites": {"commands": ["python3"]}})[0] is True
      +        met, missing = check_skill_prerequisites({"prerequisites": {"commands": ["nonexistent_binary_xyz_123"]}})
      +        assert met is False
      +        assert "command `nonexistent_binary_xyz_123`" in missing
      +
      +    def test_mixed_env_and_commands(self, monkeypatch):
      +        monkeypatch.delenv("MISSING_A", raising=False)
      +        met, missing = check_skill_prerequisites({
      +            "prerequisites": {
      +                "env_vars": ["MISSING_A"],
      +                "commands": ["python3", "nonexistent_cmd_xyz"],
      +            }
      +        })
      +        assert met is False
      +        assert len(missing) == 2
      +
      +    def test_string_instead_of_list(self, monkeypatch):
      +        """YAML scalar (string) should be coerced to a single-element list."""
      +        monkeypatch.delenv("SOLO_VAR", raising=False)
      +        assert check_skill_prerequisites({"prerequisites": {"env_vars": "SOLO_VAR"}})[0] is False
      +        assert check_skill_prerequisites({"prerequisites": {"commands": "nonexistent_cmd_xyz_solo"}})[0] is False
      +
      +
      +# ---------------------------------------------------------------------------
      +# _find_all_skills — prerequisites integration
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestFindAllSkillsPrerequisites:
      +    def test_skills_with_unmet_prereqs_flagged(self, tmp_path, monkeypatch):
      +        monkeypatch.delenv("NONEXISTENT_API_KEY_XYZ", raising=False)
      +        with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
      +            _make_skill(
      +                tmp_path, "needs-key",
      +                frontmatter_extra="prerequisites:\n  env_vars: [NONEXISTENT_API_KEY_XYZ]\n",
      +            )
      +            skills = _find_all_skills()
      +        assert len(skills) == 1
      +        assert skills[0]["prerequisites_met"] is False
      +        assert any("NONEXISTENT_API_KEY_XYZ" in m for m in skills[0]["prerequisites_missing"])
      +
      +    def test_skills_with_met_prereqs_no_flag(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("MY_PRESENT_KEY", "val")
      +        with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
      +            _make_skill(
      +                tmp_path, "has-key",
      +                frontmatter_extra="prerequisites:\n  env_vars: [MY_PRESENT_KEY]\n",
      +            )
      +            skills = _find_all_skills()
      +        assert len(skills) == 1
      +        assert "prerequisites_met" not in skills[0]
      +
      +    def test_skills_without_prereqs_no_flag(self, tmp_path):
      +        with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
      +            _make_skill(tmp_path, "simple-skill")
      +            skills = _find_all_skills()
      +        assert len(skills) == 1
      +        assert "prerequisites_met" not in skills[0]
      +
      +
      +# ---------------------------------------------------------------------------
      +# skill_view — prerequisites warnings
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestSkillViewPrerequisites:
      +    def test_warns_on_unmet_prerequisites(self, tmp_path, monkeypatch):
      +        monkeypatch.delenv("MISSING_KEY_XYZ", raising=False)
      +        with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
      +            _make_skill(
      +                tmp_path, "gated-skill",
      +                frontmatter_extra="prerequisites:\n  env_vars: [MISSING_KEY_XYZ]\n",
      +            )
      +            raw = skill_view("gated-skill")
      +        result = json.loads(raw)
      +        assert result["success"] is True
      +        assert result["prerequisites_met"] is False
      +        assert "MISSING_KEY_XYZ" in result["prerequisites_warning"]
      +
      +    def test_no_warning_when_prereqs_met(self, tmp_path, monkeypatch):
      +        monkeypatch.setenv("PRESENT_KEY", "value")
      +        with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
      +            _make_skill(
      +                tmp_path, "ready-skill",
      +                frontmatter_extra="prerequisites:\n  env_vars: [PRESENT_KEY]\n",
      +            )
      +            raw = skill_view("ready-skill")
      +        result = json.loads(raw)
      +        assert result["success"] is True
      +        assert "prerequisites_warning" not in result
      +
      +    def test_no_warning_when_no_prereqs(self, tmp_path):
      +        with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
      +            _make_skill(tmp_path, "plain-skill")
      +            raw = skill_view("plain-skill")
      +        result = json.loads(raw)
      +        assert result["success"] is True
      +        assert "prerequisites_warning" not in result
    • tools/skills_tool.py
      @@ -34,6 +34,11 @@
           platforms: [macos]            # Optional — restrict to specific OS platforms
                                         #   Valid: macos, linux, windows
                                         #   Omit to load on all platforms (default)
      +    prerequisites:                # Optional — runtime requirements
      +      env_vars: [API_KEY]         #   Env vars that must be set (checked via os.getenv)
      +      commands: [curl, jq]        #   CLI binaries that must be on PATH (checked via shutil.which)
      +                                  #   Skills with unmet prerequisites are hidden from the
      +                                  #   system prompt and flagged with a warning in skill_view.
           compatibility: Requires X     # Optional (agentskills.io)
           metadata:                     # Optional, arbitrary key-value (agentskills.io)
             hermes:
      @@ -65,6 +70,7 @@
       import json
       import os
       import re
      +import shutil
       import sys
       from pathlib import Path
       from typing import Dict, Any, List, Optional, Tuple
      @@ -118,6 +124,43 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
           return False
       
       
      +def check_skill_prerequisites(frontmatter: Dict[str, Any]) -> Tuple[bool, List[str]]:
      +    """Check if a skill's declared prerequisites are satisfied.
      +
      +    Skills declare prerequisites via a top-level ``prerequisites`` dict
      +    in their YAML frontmatter::
      +
      +        prerequisites:
      +          env_vars: [TENOR_API_KEY]
      +          commands: [curl, jq]
      +
      +    Returns:
      +        (all_met, missing) — True + empty list if all met, else False + list
      +        of human-readable descriptions of what's missing.
      +    """
      +    prereqs = frontmatter.get("prerequisites")
      +    if not prereqs or not isinstance(prereqs, dict):
      +        return True, []
      +
      +    missing: List[str] = []
      +
      +    env_vars = prereqs.get("env_vars") or []
      +    if isinstance(env_vars, str):
      +        env_vars = [env_vars]
      +    for var in env_vars:
      +        if not os.getenv(str(var)):
      +            missing.append(f"env ${var}")
      +
      +    commands = prereqs.get("commands") or []
      +    if isinstance(commands, str):
      +        commands = [commands]
      +    for cmd in commands:
      +        if not shutil.which(str(cmd)):
      +            missing.append(f"command `{cmd}`")
      +
      +    return (len(missing) == 0), missing
      +
      +
       def check_skills_requirements() -> bool:
           """Skills are always available -- the directory is created on first use if needed."""
           return True
      @@ -262,12 +305,19 @@ def _find_all_skills() -> List[Dict[str, Any]]:
                       description = description[:MAX_DESCRIPTION_LENGTH - 3] + "..."
                   
                   category = _get_category_from_path(skill_md)
      -            
      -            skills.append({
      +
      +            prereqs_met, prereqs_missing = check_skill_prerequisites(frontmatter)
      +
      +            entry = {
                       "name": name,
                       "description": description,
                       "category": category,
      -            })
      +            }
      +            if not prereqs_met:
      +                entry["prerequisites_met"] = False
      +                entry["prerequisites_missing"] = prereqs_missing
      +
      +            skills.append(entry)
                   
               except Exception:
                   continue
      @@ -635,6 +685,17 @@ def skill_view(name: str, file_path: str = None, task_id: str = None) -> str:
                   "usage_hint": "To view linked files, call skill_view(name, file_path) where file_path is e.g. 'references/api.md' or 'assets/config.yaml'" if linked_files else None
               }
               
      +        # Prerequisite check — warn the agent if requirements are unmet
      +        prereqs_met, prereqs_missing = check_skill_prerequisites(frontmatter)
      +        if not prereqs_met:
      +            result["prerequisites_met"] = False
      +            result["prerequisites_missing"] = prereqs_missing
      +            result["prerequisites_warning"] = (
      +                f"This skill requires {', '.join(prereqs_missing)} which "
      +                f"{'is' if len(prereqs_missing) == 1 else 'are'} not available. "
      +                f"Tell the user what's needed before attempting to use this skill."
      +            )
      +
               # Surface agentskills.io optional fields when present
               if frontmatter.get('compatibility'):
                   result["compatibility"] = frontmatter['compatibility']
  • #785 feat: conditional skill activation based on tool availability

    merged to main

    files: agent/prompt_builder.py, run_agent.py, tests/agent/test_prompt_builder.py

    Show the diff
    • agent/prompt_builder.py
      @@ -179,7 +179,58 @@ def _skill_is_platform_compatible(skill_file: Path) -> bool:
               return True  # Err on the side of showing the skill
       
       
      -def build_skills_system_prompt() -> str:
      +def _read_skill_conditions(skill_file: Path) -> dict:
      +    """Extract conditional activation fields from SKILL.md frontmatter."""
      +    try:
      +        from tools.skills_tool import _parse_frontmatter
      +        raw = skill_file.read_text(encoding="utf-8")[:2000]
      +        frontmatter, _ = _parse_frontmatter(raw)
      +        hermes = frontmatter.get("metadata", {}).get("hermes", {})
      +        return {
      +            "fallback_for_toolsets": hermes.get("fallback_for_toolsets", []),
      +            "requires_toolsets": hermes.get("requires_toolsets", []),
      +            "fallback_for_tools": hermes.get("fallback_for_tools", []),
      +            "requires_tools": hermes.get("requires_tools", []),
      +        }
      +    except Exception:
      +        return {}
      +
      +
      +def _skill_should_show(
      +    conditions: dict,
      +    available_tools: "set[str] | None",
      +    available_toolsets: "set[str] | None",
      +) -> bool:
      +    """Return False if the skill's conditional activation rules exclude it."""
      +    if available_tools is None and available_toolsets is None:
      +        return True  # No filtering info — show everything (backward compat)
      +
      +    at = available_tools or set()
      +    ats = available_toolsets or set()
      +
      +    # fallback_for: hide when the primary tool/toolset IS available
      +    for ts in conditions.get("fallback_for_toolsets", []):
      +        if ts in ats:
      +            return False
      +    for t in conditions.get("fallback_for_tools", []):
      +        if t in at:
      +            return False
      +
      +    # requires: hide when a required tool/toolset is NOT available
      +    for ts in conditions.get("requires_toolsets", []):
      +        if ts not in ats:
      +            return False
      +    for t in conditions.get("requires_tools", []):
      +        if t not in at:
      +            return False
      +
      +    return True
      +
      +
      +def build_skills_system_prompt(
      +    available_tools: "set[str] | None" = None,
      +    available_toolsets: "set[str] | None" = None,
      +) -> str:
           """Build a compact skill index for the system prompt.
       
           Scans ~/.hermes/skills/ for SKILL.md files grouped by category.
      @@ -202,6 +253,10 @@ def build_skills_system_prompt() -> str:
               # Skip skills incompatible with the current OS platform
               if not _skill_is_platform_compatible(skill_file):
                   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)
               parts = rel_path.parts
               if len(parts) >= 2:
    • run_agent.py
      @@ -1410,7 +1410,14 @@ def _build_system_prompt(self, system_message: str = None) -> str:
                           prompt_parts.append(user_block)
       
               has_skills_tools = any(name in self.valid_tool_names for name in ['skills_list', 'skill_view', 'skill_manage'])
      -        skills_prompt = build_skills_system_prompt() if has_skills_tools else ""
      +        if has_skills_tools:
      +            avail_toolsets = {ts for ts, avail in check_toolset_requirements().items() if avail}
      +            skills_prompt = build_skills_system_prompt(
      +                available_tools=self.valid_tool_names,
      +                available_toolsets=avail_toolsets,
      +            )
      +        else:
      +            skills_prompt = ""
               if skills_prompt:
                   prompt_parts.append(skills_prompt)
       
    • tests/agent/test_prompt_builder.py
      @@ -8,6 +8,8 @@
           _scan_context_content,
           _truncate_content,
           _read_skill_description,
      +    _read_skill_conditions,
      +    _skill_should_show,
           build_skills_system_prompt,
           build_context_files_prompt,
           CONTEXT_FILE_MAX_CHARS,
      @@ -277,3 +279,177 @@ def test_platform_hints_known_platforms(self):
               assert "telegram" in PLATFORM_HINTS
               assert "discord" in PLATFORM_HINTS
               assert "cli" in PLATFORM_HINTS
      +
      +
      +# =========================================================================
      +# Conditional skill activation
      +# =========================================================================
      +
      +class TestReadSkillConditions:
      +    def test_no_conditions_returns_empty_lists(self, tmp_path):
      +        skill_file = tmp_path / "SKILL.md"
      +        skill_file.write_text("---\nname: test\ndescription: A skill\n---\n")
      +        conditions = _read_skill_conditions(skill_file)
      +        assert conditions["fallback_for_toolsets"] == []
      +        assert conditions["requires_toolsets"] == []
      +        assert conditions["fallback_for_tools"] == []
      +        assert conditions["requires_tools"] == []
      +
      +    def test_reads_fallback_for_toolsets(self, tmp_path):
      +        skill_file = tmp_path / "SKILL.md"
      +        skill_file.write_text(
      +            "---\nname: ddg\ndescription: DuckDuckGo\nmetadata:\n  hermes:\n    fallback_for_toolsets: [web]\n---\n"
      +        )
      +        conditions = _read_skill_conditions(skill_file)
      +        assert conditions["fallback_for_toolsets"] == ["web"]
      +
      +    def test_reads_requires_toolsets(self, tmp_path):
      +        skill_file = tmp_path / "SKILL.md"
      +        skill_file.write_text(
      +            "---\nname: openhue\ndescription: Hue lights\nmetadata:\n  hermes:\n    requires_toolsets: [terminal]\n---\n"
      +        )
      +        conditions = _read_skill_conditions(skill_file)
      +        assert conditions["requires_toolsets"] == ["terminal"]
      +
      +    def test_reads_multiple_conditions(self, tmp_path):
      +        skill_file = tmp_path / "SKILL.md"
      +        skill_file.write_text(
      +            "---\nname: test\ndescription: Test\nmetadata:\n  hermes:\n    fallback_for_toolsets: [browser]\n    requires_tools: [terminal]\n---\n"
      +        )
      +        conditions = _read_skill_conditions(skill_file)
      +        assert conditions["fallback_for_toolsets"] == ["browser"]
      +        assert conditions["requires_tools"] == ["terminal"]
      +
      +    def test_missing_file_returns_empty(self, tmp_path):
      +        conditions = _read_skill_conditions(tmp_path / "missing.md")
      +        assert conditions == {}
      +
      +
      +class TestSkillShouldShow:
      +    def test_no_filter_info_always_shows(self):
      +        assert _skill_should_show({}, None, None) is True
      +
      +    def test_empty_conditions_always_shows(self):
      +        assert _skill_should_show(
      +            {"fallback_for_toolsets": [], "requires_toolsets": [],
      +             "fallback_for_tools": [], "requires_tools": []},
      +            {"web_search"}, {"web"}
      +        ) is True
      +
      +    def test_fallback_hidden_when_toolset_available(self):
      +        conditions = {"fallback_for_toolsets": ["web"], "requires_toolsets": [],
      +                      "fallback_for_tools": [], "requires_tools": []}
      +        assert _skill_should_show(conditions, set(), {"web"}) is False
      +
      +    def test_fallback_shown_when_toolset_unavailable(self):
      +        conditions = {"fallback_for_toolsets": ["web"], "requires_toolsets": [],
      +                      "fallback_for_tools": [], "requires_tools": []}
      +        assert _skill_should_show(conditions, set(), set()) is True
      +
      +    def test_requires_shown_when_toolset_available(self):
      +        conditions = {"fallback_for_toolsets": [], "requires_toolsets": ["terminal"],
      +                      "fallback_for_tools": [], "requires_tools": []}
      +        assert _skill_should_show(conditions, set(), {"terminal"}) is True
      +
      +    def test_requires_hidden_when_toolset_missing(self):
      +        conditions = {"fallback_for_toolsets": [], "requires_toolsets": ["terminal"],
      +                      "fallback_for_tools": [], "requires_tools": []}
      +        assert _skill_should_show(conditions, set(), set()) is False
      +
      +    def test_fallback_for_tools_hidden_when_tool_available(self):
      +        conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
      +                      "fallback_for_tools": ["web_search"], "requires_tools": []}
      +        assert _skill_should_show(conditions, {"web_search"}, set()) is False
      +
      +    def test_fallback_for_tools_shown_when_tool_missing(self):
      +        conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
      +                      "fallback_for_tools": ["web_search"], "requires_tools": []}
      +        assert _skill_should_show(conditions, set(), set()) is True
      +
      +    def test_requires_tools_hidden_when_tool_missing(self):
      +        conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
      +                      "fallback_for_tools": [], "requires_tools": ["terminal"]}
      +        assert _skill_should_show(conditions, set(), set()) is False
      +
      +    def test_requires_tools_shown_when_tool_available(self):
      +        conditions = {"fallback_for_toolsets": [], "requires_toolsets": [],
      +                      "fallback_for_tools": [], "requires_tools": ["terminal"]}
      +        assert _skill_should_show(conditions, {"terminal"}, set()) is True
      +
      +
      +class TestBuildSkillsSystemPromptConditional:
      +    def test_fallback_skill_hidden_when_primary_available(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        skill_dir = tmp_path / "skills" / "search" / "duckduckgo"
      +        skill_dir.mkdir(parents=True)
      +        (skill_dir / "SKILL.md").write_text(
      +            "---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n  hermes:\n    fallback_for_toolsets: [web]\n---\n"
      +        )
      +        result = build_skills_system_prompt(
      +            available_tools=set(),
      +            available_toolsets={"web"},
      +        )
      +        assert "duckduckgo" not in result
      +
      +    def test_fallback_skill_shown_when_primary_unavailable(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        skill_dir = tmp_path / "skills" / "search" / "duckduckgo"
      +        skill_dir.mkdir(parents=True)
      +        (skill_dir / "SKILL.md").write_text(
      +            "---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n  hermes:\n    fallback_for_toolsets: [web]\n---\n"
      +        )
      +        result = build_skills_system_prompt(
      +            available_tools=set(),
      +            available_toolsets=set(),
      +        )
      +        assert "duckduckgo" in result
      +
      +    def test_requires_skill_hidden_when_toolset_missing(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        skill_dir = tmp_path / "skills" / "iot" / "openhue"
      +        skill_dir.mkdir(parents=True)
      +        (skill_dir / "SKILL.md").write_text(
      +            "---\nname: openhue\ndescription: Hue lights\nmetadata:\n  hermes:\n    requires_toolsets: [terminal]\n---\n"
      +        )
      +        result = build_skills_system_prompt(
      +            available_tools=set(),
      +            available_toolsets=set(),
      +        )
      +        assert "openhue" not in result
      +
      +    def test_requires_skill_shown_when_toolset_available(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        skill_dir = tmp_path / "skills" / "iot" / "openhue"
      +        skill_dir.mkdir(parents=True)
      +        (skill_dir / "SKILL.md").write_text(
      +            "---\nname: openhue\ndescription: Hue lights\nmetadata:\n  hermes:\n    requires_toolsets: [terminal]\n---\n"
      +        )
      +        result = build_skills_system_prompt(
      +            available_tools=set(),
      +            available_toolsets={"terminal"},
      +        )
      +        assert "openhue" in result
      +
      +    def test_unconditional_skill_always_shown(self, monkeypatch, tmp_path):
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        skill_dir = tmp_path / "skills" / "general" / "notes"
      +        skill_dir.mkdir(parents=True)
      +        (skill_dir / "SKILL.md").write_text(
      +            "---\nname: notes\ndescription: Take notes\n---\n"
      +        )
      +        result = build_skills_system_prompt(
      +            available_tools=set(),
      +            available_toolsets=set(),
      +        )
      +        assert "notes" in result
      +
      +    def test_no_args_shows_all_skills(self, monkeypatch, tmp_path):
      +        """Backward compat: calling with no args shows everything."""
      +        monkeypatch.setenv("HERMES_HOME", str(tmp_path))
      +        skill_dir = tmp_path / "skills" / "search" / "duckduckgo"
      +        skill_dir.mkdir(parents=True)
      +        (skill_dir / "SKILL.md").write_text(
      +            "---\nname: duckduckgo\ndescription: Free web search\nmetadata:\n  hermes:\n    fallback_for_toolsets: [web]\n---\n"
      +        )
      +        result = build_skills_system_prompt()
      +        assert "duckduckgo" in result
Closed issues · 2
  • Closed#539Feature: Conditional Skill Activation — Fallback Skills That Activate When Tools Are Unavailable
  • Closed#658[Bug]: Skills should declare runtime prerequisites (env vars, CLI binaries) to prevent false capability claims
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.39: 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
agent
Merged PRs
#659, #785
Closed issues
2
Attempts
0 PRs closed without merging
Fix link checked
11 Mar 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.