diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3ec4a40b392..c1c7fb1fda9 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -7,6 +7,7 @@ assemble pieces, then combines them with memory and ephemeral prompts. import json import logging import os +import sys import threading import contextvars from collections import OrderedDict @@ -17,6 +18,8 @@ from typing import Optional 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, @@ -25,6 +28,7 @@ from agent.skill_utils import ( parse_frontmatter, skill_matches_environment, skill_matches_platform, + skill_matches_platform_list, ) from utils import atomic_json_write @@ -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 @@ -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, @@ -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()))), @@ -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 diff --git a/agent/skill_utils.py b/agent/skill_utils.py index 23c8d99c997..d07bb5324e0 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -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): @@ -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 @@ -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 @@ -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 ─────────────────────────── diff --git a/tests/agent/test_skill_utils.py b/tests/agent/test_skill_utils.py index 347d1b667c7..177e99a5971 100644 --- a/tests/agent/test_skill_utils.py +++ b/tests/agent/test_skill_utils.py @@ -12,6 +12,7 @@ from agent.skill_utils import ( iter_skill_index_files, resolve_skill_config_values, skill_matches_platform, + skill_matches_platform_list, ) @@ -266,6 +267,7 @@ class TestSkillMatchesPlatformTermux: "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-*, @@ -275,6 +277,7 @@ class TestSkillMatchesPlatformTermux: "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 @@ -284,6 +287,7 @@ class TestSkillMatchesPlatformTermux: "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 @@ -293,6 +297,7 @@ class TestSkillMatchesPlatformTermux: "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"]} @@ -300,6 +305,7 @@ class TestSkillMatchesPlatformTermux: "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 @@ -309,6 +315,8 @@ class TestSkillMatchesPlatformTermux: ): 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 @@ -318,6 +326,7 @@ class TestSkillMatchesPlatformTermux: "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. @@ -326,6 +335,7 @@ class TestSkillMatchesPlatformTermux: "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"]} @@ -333,6 +343,7 @@ class TestSkillMatchesPlatformTermux: "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: