diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 3a6ec24415..7a1980315f 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -466,7 +466,7 @@ CONTEXT_TRUNCATE_TAIL_RATIO = 0.2 _SKILLS_PROMPT_CACHE_MAX = 8 _SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict() _SKILLS_PROMPT_CACHE_LOCK = threading.Lock() -_SKILLS_SNAPSHOT_VERSION = 1 +_SKILLS_SNAPSHOT_VERSION = 2 def _skills_prompt_snapshot_path() -> Path: @@ -539,6 +539,7 @@ def _build_snapshot_entry( skills_dir: Path, frontmatter: dict, description: str, + body: str = "", ) -> dict: """Build a serialisable metadata dict for one skill.""" rel_path = skill_file.relative_to(skills_dir) @@ -554,6 +555,9 @@ def _build_snapshot_entry( if isinstance(platforms, str): platforms = [platforms] + eager = _skill_uses_eager_prompt(frontmatter) + cleaned_body = body.strip() + return { "skill_name": skill_name, "category": category, @@ -561,6 +565,8 @@ def _build_snapshot_entry( "description": description, "platforms": [str(p).strip() for p in platforms if str(p).strip()], "conditions": extract_skill_conditions(frontmatter), + "eager": eager, + "body": cleaned_body if eager else "", } @@ -568,23 +574,52 @@ def _build_snapshot_entry( # Skills index # ========================================================================= -def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]: - """Read a SKILL.md once and return platform compatibility, frontmatter, and description. +def _coerce_frontmatter_bool(value: object) -> bool: + """Coerce YAML-ish truthy values into a strict bool.""" + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value != 0 + if isinstance(value, str): + return value.strip().lower() in {"1", "true", "yes", "on"} + return bool(value) - Returns (is_compatible, frontmatter, description). On any error, returns - (True, {}, "") to err on the side of showing the skill. + +def _skill_uses_eager_prompt(frontmatter: dict) -> bool: + """Return whether a skill requests eager body injection.""" + metadata = frontmatter.get("metadata") + if isinstance(metadata, dict): + hermes_meta = metadata.get("hermes") + if isinstance(hermes_meta, dict) and "eager" in hermes_meta: + return _coerce_frontmatter_bool(hermes_meta.get("eager")) + if "eager" in frontmatter: + return _coerce_frontmatter_bool(frontmatter.get("eager")) + return False + + +def _parse_skill_file_with_body(skill_file: Path) -> tuple[bool, dict, str, str]: + """Read a SKILL.md once and return platform compatibility, frontmatter, description, and body. + + Returns (is_compatible, frontmatter, description, body). On any error, + returns (True, {}, "", "") to err on the side of showing the skill. """ try: raw = skill_file.read_text(encoding="utf-8") - frontmatter, _ = parse_frontmatter(raw) + frontmatter, body = parse_frontmatter(raw) if not skill_matches_platform(frontmatter): - return False, frontmatter, "" + return False, frontmatter, "", "" - return True, frontmatter, extract_skill_description(frontmatter) + return True, frontmatter, extract_skill_description(frontmatter), body.strip() except Exception as e: logger.warning("Failed to parse skill file %s: %s", skill_file, e) - return True, {}, "" + return True, {}, "", "" + + +def _parse_skill_file(skill_file: Path) -> tuple[bool, dict, str]: + """Backward-compatible wrapper returning skill compatibility metadata.""" + is_compatible, frontmatter, description, _body = _parse_skill_file_with_body(skill_file) + return is_compatible, frontmatter, description def _skill_should_show( @@ -618,6 +653,27 @@ def _skill_should_show( return True +def _filter_disabled_skill_entries( + skills_by_category: dict[str, list[tuple[str, str]]], + eager_skill_entries: list[tuple[str, str]], + disabled: set[str], +) -> tuple[dict[str, list[tuple[str, str]]], list[tuple[str, str]]]: + """Drop disabled skills from both index entries and eager payloads.""" + if not disabled: + return skills_by_category, eager_skill_entries + + filtered_categories: dict[str, list[tuple[str, str]]] = {} + for category, entries in skills_by_category.items(): + kept = [(name, desc) for name, desc in entries if name not in disabled] + if kept: + filtered_categories[category] = kept + + filtered_eager = [ + (name, body) for name, body in eager_skill_entries if name not in disabled + ] + return filtered_categories, filtered_eager + + def build_skills_system_prompt( available_tools: "set[str] | None" = None, available_toolsets: "set[str] | None" = None, @@ -671,6 +727,7 @@ def build_skills_system_prompt( skills_by_category: dict[str, list[tuple[str, str]]] = {} category_descriptions: dict[str, str] = {} + eager_skill_entries: list[tuple[str, str]] = [] if snapshot is not None: # Fast path: use pre-parsed metadata from disk @@ -694,6 +751,8 @@ def build_skills_system_prompt( skills_by_category.setdefault(category, []).append( (frontmatter_name, entry.get("description", "")) ) + if entry.get("eager") and entry.get("body"): + eager_skill_entries.append((frontmatter_name, str(entry.get("body", "")))) category_descriptions = { str(k): str(v) for k, v in (snapshot.get("category_descriptions") or {}).items() @@ -702,8 +761,8 @@ def build_skills_system_prompt( # Cold path: full filesystem scan + write snapshot for next time skill_entries: list[dict] = [] for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"): - is_compatible, frontmatter, desc = _parse_skill_file(skill_file) - entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc) + is_compatible, frontmatter, desc, body = _parse_skill_file_with_body(skill_file) + entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc, body) skill_entries.append(entry) if not is_compatible: continue @@ -719,6 +778,8 @@ def build_skills_system_prompt( skills_by_category.setdefault(entry["category"], []).append( (entry["frontmatter_name"], entry["description"]) ) + if entry["eager"] and entry["body"]: + eager_skill_entries.append((entry["frontmatter_name"], entry["body"])) # Read category-level DESCRIPTION.md files for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"): @@ -749,16 +810,17 @@ def build_skills_system_prompt( for cat_skills in skills_by_category.values(): for name, _desc in cat_skills: seen_skill_names.add(name) + seen_eager_skill_names = {name for name, _body in eager_skill_entries} for ext_dir in external_dirs: if not ext_dir.exists(): continue for skill_file in iter_skill_index_files(ext_dir, "SKILL.md"): try: - is_compatible, frontmatter, desc = _parse_skill_file(skill_file) + is_compatible, frontmatter, desc, body = _parse_skill_file_with_body(skill_file) if not is_compatible: continue - entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc) + entry = _build_snapshot_entry(skill_file, ext_dir, frontmatter, desc, body) skill_name = entry["skill_name"] frontmatter_name = entry["frontmatter_name"] if frontmatter_name in seen_skill_names: @@ -775,6 +837,13 @@ def build_skills_system_prompt( skills_by_category.setdefault(entry["category"], []).append( (frontmatter_name, entry["description"]) ) + if ( + entry["eager"] + and entry["body"] + and frontmatter_name not in seen_eager_skill_names + ): + seen_eager_skill_names.add(frontmatter_name) + eager_skill_entries.append((frontmatter_name, entry["body"])) except Exception as e: logger.debug("Error reading external skill %s: %s", skill_file, e) @@ -792,6 +861,13 @@ def build_skills_system_prompt( except Exception as e: logger.debug("Could not read external skill description %s: %s", desc_file, e) + current_disabled = set(get_disabled_skill_names()) + skills_by_category, eager_skill_entries = _filter_disabled_skill_entries( + skills_by_category, + eager_skill_entries, + current_disabled, + ) + if not skills_by_category: result = "" else: @@ -833,6 +909,20 @@ def build_skills_system_prompt( "\n" + "\n".join(index_lines) + "\n" "\n" + ) + if eager_skill_entries: + eager_blocks = [] + for name, body in eager_skill_entries: + eager_blocks.append(f'\n{body}\n') + result += ( + "\n" + "The following eager skills are preloaded because their body contains routing or workflow " + "instructions you should apply immediately without waiting for another skill_view() call.\n" + "\n" + + "\n\n".join(eager_blocks) + + "\n\n" + ) + result += ( "\n" "Only proceed without loading a skill if genuinely none are relevant to the task." ) diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index 88de5186b8..b2d7a5d35d 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -328,6 +328,10 @@ class TestBuildSkillsSystemPrompt: def test_excludes_disabled_skills(self, monkeypatch, tmp_path): """Skills in the user's disabled list should not appear in the system prompt.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + from agent.prompt_builder import clear_skills_system_prompt_cache + + clear_skills_system_prompt_cache(clear_snapshot=True) + (tmp_path / "config.yaml").write_text("skills:\n disabled: [old-tool]\n") skills_dir = tmp_path / "skills" / "tools" skills_dir.mkdir(parents=True) @@ -343,13 +347,7 @@ class TestBuildSkillsSystemPrompt: "---\nname: old-tool\ndescription: Deprecated tool\n---\n" ) - from unittest.mock import patch - - with patch( - "agent.prompt_builder.get_disabled_skill_names", - return_value={"old-tool"}, - ): - result = build_skills_system_prompt() + result = build_skills_system_prompt() assert "web-search" in result assert "old-tool" not in result @@ -428,6 +426,65 @@ class TestBuildSkillsSystemPrompt: result = build_skills_system_prompt() assert "backend-skill" in result + def test_eager_skill_body_is_inlined(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skills_dir = tmp_path / "skills" / "browser" + + eager_skill = skills_dir / "browser-harness" + eager_skill.mkdir(parents=True) + (eager_skill / "SKILL.md").write_text( + "---\n" + "name: browser-harness\n" + "description: Browser workflows\n" + "metadata:\n" + " hermes:\n" + " eager: true\n" + "---\n" + "Search domain-skills/ before improvising.\n" + ) + + lazy_skill = skills_dir / "plain-skill" + lazy_skill.mkdir() + (lazy_skill / "SKILL.md").write_text( + "---\n" + "name: plain-skill\n" + "description: Plain skill\n" + "---\n" + "This body should stay lazy.\n" + ) + + result = build_skills_system_prompt() + + assert '' in result + assert '' in result + assert "Search domain-skills/ before improvising." in result + assert "This body should stay lazy." not in result + + def test_eager_skill_body_survives_disk_snapshot(self, monkeypatch, tmp_path): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + skills_dir = tmp_path / "skills" / "browser" / "browser-harness" + skills_dir.mkdir(parents=True) + (skills_dir / "SKILL.md").write_text( + "---\n" + "name: browser-harness\n" + "description: Browser workflows\n" + "metadata:\n" + " hermes:\n" + " eager: true\n" + "---\n" + "Search domain-skills/ before improvising.\n" + ) + + first = build_skills_system_prompt() + assert "Search domain-skills/ before improvising." in first + + from agent.prompt_builder import clear_skills_system_prompt_cache + + clear_skills_system_prompt_cache(clear_snapshot=False) + second = build_skills_system_prompt() + + assert "Search domain-skills/ before improvising." in second + class TestBuildNousSubscriptionPrompt: def test_includes_active_subscription_features(self, monkeypatch): @@ -1086,6 +1143,3 @@ class TestOpenAIModelExecutionGuidance: # ========================================================================= # Budget warning history stripping # ========================================================================= - - -