Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 40 additions & 12 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import json
import logging
import os
import sys
import threading
import contextvars
from collections import OrderedDict
Expand All @@ -17,6 +18,8 @@

from agent.runtime_cwd import resolve_agent_cwd
from agent.skill_utils import (
EXCLUDED_SKILL_DIRS,
SKILL_SUPPORT_DIRS,
extract_skill_conditions,
extract_skill_description,
get_all_skills_dirs,
Expand All @@ -25,6 +28,7 @@
parse_frontmatter,
skill_matches_environment,
skill_matches_platform,
skill_matches_platform_list,
)
from utils import atomic_json_write

Expand Down Expand Up @@ -1276,13 +1280,26 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
manifest: dict[str, list[int]] = {}
for filename in ("SKILL.md", "DESCRIPTION.md"):
for path in iter_skill_index_files(skills_dir, filename):
skills_dir_str = str(skills_dir)
base = os.path.join(skills_dir_str, "")
prefix_len = len(base)
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
for d in dirs
if d not in EXCLUDED_SKILL_DIRS
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
for filename in ("SKILL.md", "DESCRIPTION.md"):
if filename not in files:
continue
path = os.path.join(root, filename)
try:
st = path.stat()
st = os.stat(path)
except OSError:
continue
manifest[str(path.relative_to(skills_dir))] = [st.st_mtime_ns, st.st_size]
manifest[path[prefix_len:]] = [st.st_mtime_ns, st.st_size]
return manifest


Expand Down Expand Up @@ -1414,6 +1431,22 @@ def _skill_should_show(
return True


def _current_session_platform_hint() -> str:
"""Return the active platform without importing the gateway package on CLI startup."""
platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM")
if platform:
return platform

session_context = sys.modules.get("gateway.session_context")
get_session_env = getattr(session_context, "get_session_env", None) if session_context else None
if get_session_env is None:
return ""
try:
return get_session_env("HERMES_SESSION_PLATFORM") or ""
except Exception:
return ""


def build_skills_system_prompt(
available_tools: "set[str] | None" = None,
available_toolsets: "set[str] | None" = None,
Expand Down Expand Up @@ -1448,15 +1481,10 @@ def build_skills_system_prompt(
# ── Layer 1: in-process LRU cache ─────────────────────────────────
# Include the resolved platform so per-platform disabled-skill lists
# produce distinct cache entries (gateway serves multiple platforms).
from gateway.session_context import get_session_env
_platform_hint = (
os.environ.get("HERMES_PLATFORM")
or get_session_env("HERMES_SESSION_PLATFORM")
or ""
)
_platform_hint = _current_session_platform_hint()
disabled = get_disabled_skill_names(_platform_hint or None)
cache_key = (
str(skills_dir.resolve()),
str(skills_dir),
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()))),
Expand Down Expand Up @@ -1485,7 +1513,7 @@ def build_skills_system_prompt(
category = entry.get("category") or "general"
frontmatter_name = entry.get("frontmatter_name") or skill_name
platforms = entry.get("platforms") or []
if not skill_matches_platform({"platforms": platforms}):
if not skill_matches_platform_list(platforms):
continue
if frontmatter_name in disabled or skill_name in disabled:
continue
Expand Down
57 changes: 31 additions & 26 deletions agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,27 +160,8 @@ def parse_frontmatter(content: str) -> Tuple[Dict[str, Any], str]:
# ── Platform matching ─────────────────────────────────────────────────────


def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.

Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::

platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux

If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).

Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
platforms = frontmatter.get("platforms")
def skill_matches_platform_list(platforms: Any) -> bool:
"""Return True when *platforms* is compatible with the current OS."""
if not platforms:
return True
if not isinstance(platforms, list):
Expand All @@ -204,6 +185,29 @@ def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
return False


def skill_matches_platform(frontmatter: Dict[str, Any]) -> bool:
"""Return True when the skill is compatible with the current OS.

Skills declare platform requirements via a top-level ``platforms`` list
in their YAML frontmatter::

platforms: [macos] # macOS only
platforms: [macos, linux] # macOS and Linux

If the field is absent or empty the skill is compatible with **all**
platforms (backward-compatible default).

Termux note: on Termux/Android, ``sys.platform`` is ``"linux"`` on
older Pythons but became ``"android"`` on Python 3.13+. Termux is a
Linux userland riding on the Android kernel, so skills tagged
``linux`` are treated as compatible in Termux regardless of which
``sys.platform`` value Python reports. Individual Linux commands
inside a skill may still misbehave (no systemd, BusyBox utils, no
apt/dnf, etc.) but that is on the skill, not on platform gating.
"""
return skill_matches_platform_list(frontmatter.get("platforms"))


# ── Environment matching ──────────────────────────────────────────────────

# Recognized environment tags and how each is detected. An environment tag is
Expand Down Expand Up @@ -787,8 +791,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
``SKILL.md`` files, but they are progressive-disclosure data loaded through
``skill_view(..., file_path=...)`` rather than active skill roots.
"""
matches = []
for root, dirs, files in os.walk(skills_dir, followlinks=True):
skills_dir_str = str(skills_dir)
matches: list[str] = []
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
has_skill_md = "SKILL.md" in files
dirs[:] = [
d
Expand All @@ -797,9 +802,9 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
and not (has_skill_md and d in SKILL_SUPPORT_DIRS)
]
if filename in files:
matches.append(Path(root) / filename)
for path in sorted(matches, key=lambda p: str(p.relative_to(skills_dir))):
yield path
matches.append(os.path.join(root, filename))
for path in sorted(matches):
yield Path(path)


# ── Namespace helpers for plugin-provided skills ───────────────────────────
Expand Down
11 changes: 11 additions & 0 deletions tests/agent/test_skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
iter_skill_index_files,
resolve_skill_config_values,
skill_matches_platform,
skill_matches_platform_list,
)


Expand Down Expand Up @@ -266,6 +267,7 @@ def test_linux_skill_loads_on_termux_android_platform(self):
"agent.skill_utils.is_termux", return_value=True
):
assert skill_matches_platform(fm) is True
assert skill_matches_platform_list(fm["platforms"]) is True

def test_linux_macos_windows_skill_loads_on_termux(self):
# The common "[linux, macos, windows]" tag used by github-*,
Expand All @@ -275,6 +277,7 @@ def test_linux_macos_windows_skill_loads_on_termux(self):
"agent.skill_utils.is_termux", return_value=True
):
assert skill_matches_platform(fm) is True
assert skill_matches_platform_list(fm["platforms"]) is True

def test_linux_skill_loads_on_termux_linux_platform(self):
# Pre-3.13 Termux reports sys.platform == "linux" already — this
Expand All @@ -284,6 +287,7 @@ def test_linux_skill_loads_on_termux_linux_platform(self):
"agent.skill_utils.is_termux", return_value=True
):
assert skill_matches_platform(fm) is True
assert skill_matches_platform_list(fm["platforms"]) is True

def test_macos_only_skill_still_excluded_on_termux(self):
# macOS-only skills (apple-notes, imessage, ...) should NOT load
Expand All @@ -293,13 +297,15 @@ def test_macos_only_skill_still_excluded_on_termux(self):
"agent.skill_utils.is_termux", return_value=True
):
assert skill_matches_platform(fm) is False
assert skill_matches_platform_list(fm["platforms"]) is False

def test_windows_only_skill_still_excluded_on_termux(self):
fm = {"platforms": ["windows"]}
with patch("agent.skill_utils.sys.platform", "android"), patch(
"agent.skill_utils.is_termux", return_value=True
):
assert skill_matches_platform(fm) is False
assert skill_matches_platform_list(fm["platforms"]) is False

def test_explicit_termux_or_android_tag_matches(self):
# Skills can also opt in explicitly via platforms:[termux] or
Expand All @@ -309,6 +315,8 @@ def test_explicit_termux_or_android_tag_matches(self):
):
assert skill_matches_platform({"platforms": ["termux"]}) is True
assert skill_matches_platform({"platforms": ["android"]}) is True
assert skill_matches_platform_list(["termux"]) is True
assert skill_matches_platform_list(["android"]) is True

def test_non_termux_android_does_not_widen(self):
# If we're somehow on a plain Android Python (not Termux), don't
Expand All @@ -318,6 +326,7 @@ def test_non_termux_android_does_not_widen(self):
"agent.skill_utils.is_termux", return_value=False
):
assert skill_matches_platform(fm) is False
assert skill_matches_platform_list(fm["platforms"]) is False

def test_linux_skill_on_real_linux_unaffected(self):
# The non-Termux Linux path must not change.
Expand All @@ -326,13 +335,15 @@ def test_linux_skill_on_real_linux_unaffected(self):
"agent.skill_utils.is_termux", return_value=False
):
assert skill_matches_platform(fm) is True
assert skill_matches_platform_list(fm["platforms"]) is True

def test_macos_skill_on_real_macos_unaffected(self):
fm = {"platforms": ["macos"]}
with patch("agent.skill_utils.sys.platform", "darwin"), patch(
"agent.skill_utils.is_termux", return_value=False
):
assert skill_matches_platform(fm) is True
assert skill_matches_platform_list(fm["platforms"]) is True


class TestNormalizeSkillLookupName:
Expand Down