diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index a9556e204688..54a21635a74c 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -5,6 +5,7 @@ """ import json +import hashlib import logging import os import re @@ -588,6 +589,22 @@ def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]: return manifest +def _skills_manifest_digest(skills_dir: Path) -> str: + """Return a stable digest of one skills directory's manifest.""" + manifest = _build_skills_manifest(skills_dir) + payload = json.dumps(manifest, sort_keys=True, separators=(",", ":")) + return hashlib.sha1(payload.encode("utf-8")).hexdigest() + + +def _skills_cache_state(skills_dir: Path, external_dirs: list[Path]) -> tuple[tuple[str, str], ...]: + """Return cache state tokens for local + external skill directories.""" + dirs = [skills_dir, *external_dirs] + return tuple( + (str(directory.resolve()), _skills_manifest_digest(directory)) + for directory in dirs + ) + + def _load_skills_snapshot(skills_dir: Path) -> Optional[dict]: """Load the disk snapshot if it exists and its manifest still matches.""" snapshot_path = _skills_prompt_snapshot_path() @@ -745,7 +762,7 @@ def build_skills_system_prompt( disabled = get_disabled_skill_names() cache_key = ( str(skills_dir.resolve()), - tuple(str(d) for d in external_dirs), + _skills_cache_state(skills_dir, external_dirs), tuple(sorted(str(t) for t in (available_tools or set()))), tuple(sorted(str(ts) for ts in (available_toolsets or set()))), _platform_hint, diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 88de5186b838..337e1a063468 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -372,6 +372,28 @@ def test_rebuilds_prompt_when_disabled_skills_change(self, monkeypatch, tmp_path second = build_skills_system_prompt() assert "cached-skill" not in second + def test_rebuilds_prompt_when_skill_files_change(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + first_skill = tmp_path / "skills" / "tools" / "first-skill" + first_skill.mkdir(parents=True) + (first_skill / "SKILL.md").write_text( + "---\nname: first-skill\ndescription: First skill\n---\n" + ) + + first = build_skills_system_prompt() + assert "first-skill" in first + assert "second-skill" not in first + + second_skill = tmp_path / "skills" / "tools" / "second-skill" + second_skill.mkdir(parents=True) + (second_skill / "SKILL.md").write_text( + "---\nname: second-skill\ndescription: Second skill\n---\n" + ) + + second = build_skills_system_prompt() + assert "first-skill" in second + assert "second-skill" in second + def test_includes_setup_needed_skills(self, monkeypatch, tmp_path): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) monkeypatch.delenv("MISSING_API_KEY_XYZ", raising=False) @@ -1088,4 +1110,3 @@ def test_guidance_is_string(self): # ========================================================================= -