diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 11d425b91a52..fa1b4044a7ae 100644 --- a/agent/skill_commands.py +++ b/agent/skill_commands.py @@ -453,8 +453,8 @@ def reload_skills() -> Dict[str, Any]: } ``description`` is the skill's full SKILL.md frontmatter - ``description:`` field — the same string the system prompt renders - as `` - name: description`` for pre-existing skills. + ``description:`` field. Note: the system prompt skill index + truncates this to the first 57 chars; see ``extract_skill_description``. """ # Snapshot pre-reload state (name -> description) from the current # slash-command cache. Using dicts lets the post-rescan diff carry diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index 3443e0418b16..fad2f682c7b2 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -21,6 +21,11 @@ from tools.skill_manager_tool import ( skill_manage, MAX_NAME_LENGTH, ) +from agent.skill_utils import ( + extract_skill_description, + parse_frontmatter, + SKILL_PROMPT_DESC_LIMIT, +) @contextmanager @@ -54,6 +59,17 @@ description: Updated description. Step 1: Do the new thing. """ +LONG_DESC_CONTENT = """\ +--- +name: long-desc +description: Use when deploying multi-region Kubernetes clusters with custom CNI plugins and service mesh. +--- + +# Long Desc Skill + +Step 1. +""" + # --------------------------------------------------------------------------- # _validate_name @@ -259,6 +275,37 @@ class TestCreateSkill: assert f"Invalid category '{outside}'" in result["error"] assert not (outside / "my-skill" / "SKILL.md").exists() + def test_create_long_desc_includes_prompt_preview(self, tmp_path): + with _skill_dir(tmp_path): + result = _create_skill("long-desc", LONG_DESC_CONTENT) + assert result["success"] is True + assert "system_prompt_preview" in result + assert "System prompt will show" in result["system_prompt_preview"] + fm, _ = parse_frontmatter(LONG_DESC_CONTENT) + assert extract_skill_description(fm) in result["system_prompt_preview"] + + def test_create_short_desc_no_prompt_preview(self, tmp_path): + with _skill_dir(tmp_path): + result = _create_skill("my-skill", VALID_SKILL_CONTENT) + assert result["success"] is True + assert "system_prompt_preview" not in result + + def test_create_boundary_at_limit_no_preview(self, tmp_path): + desc = "U" * SKILL_PROMPT_DESC_LIMIT + content = f"---\nname: boundary-at\ndescription: {desc}\n---\n\n# Boundary\n\nStep 1.\n" + with _skill_dir(tmp_path): + result = _create_skill("boundary-at", content) + assert result["success"] is True + assert "system_prompt_preview" not in result + + def test_create_boundary_over_limit_has_preview(self, tmp_path): + desc = "U" * (SKILL_PROMPT_DESC_LIMIT + 1) + content = f"---\nname: boundary-over\ndescription: {desc}\n---\n\n# Boundary\n\nStep 1.\n" + with _skill_dir(tmp_path): + result = _create_skill("boundary-over", content) + assert result["success"] is True + assert "system_prompt_preview" in result + class TestEditSkill: def test_edit_existing_skill(self, tmp_path): @@ -284,6 +331,14 @@ class TestEditSkill: content = (tmp_path / "my-skill" / "SKILL.md").read_text() assert "A test skill" in content + def test_edit_long_desc_includes_prompt_preview(self, tmp_path): + edit_content = LONG_DESC_CONTENT.replace("name: long-desc", "name: test-skill") + with _skill_dir(tmp_path): + _create_skill("test-skill", VALID_SKILL_CONTENT) + result = _edit_skill("test-skill", edit_content) + assert result["success"] is True + assert "system_prompt_preview" in result + class TestPatchSkill: def test_patch_unique_match(self, tmp_path): diff --git a/tools/skill_manager_tool.py b/tools/skill_manager_tool.py index debea52642f9..12fc3d1a103c 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -45,6 +45,12 @@ from typing import Any, Dict, List, Optional, Tuple from hermes_constants import get_hermes_home, display_hermes_home from utils import atomic_replace, is_truthy_value from hermes_cli.config import cfg_get +from agent.skill_utils import ( + extract_skill_description, + is_skill_description_truncated_for_prompt, + parse_frontmatter as _parse_frontmatter, + SKILL_PROMPT_DESC_LIMIT, +) logger = logging.getLogger(__name__) @@ -810,6 +816,18 @@ def _atomic_write_text(file_path: Path, content: str, encoding: str = "utf-8") - # Core actions # ============================================================================= + +def _add_description_prompt_preview(result: Dict[str, Any], content: str) -> None: + """Append a system_prompt_preview field when the description will be truncated.""" + fm, _ = _parse_frontmatter(content) + if is_skill_description_truncated_for_prompt(fm): + result["system_prompt_preview"] = ( + f"System prompt will show: \"{extract_skill_description(fm)}\" — " + f"keep the trigger self-contained in the first " + f"{SKILL_PROMPT_DESC_LIMIT - 3} chars." + ) + + def _create_skill(name: str, content: str, category: str = None) -> Dict[str, Any]: """Create a new user skill with SKILL.md content.""" # Validate name @@ -875,6 +893,7 @@ def _create_skill(name: str, content: str, category: str = None) -> Dict[str, An "To add reference files, templates, or scripts, use " "skill_manage(action='write_file', name='{}', file_path='references/example.md', file_content='...')".format(name) ) + _add_description_prompt_preview(result, content) return result @@ -923,12 +942,14 @@ def _edit_skill(name: str, content: str) -> Dict[str, Any]: except Exception: pass - return { + result = { "success": True, "message": f"Skill '{name}' updated (full rewrite).", "path": str(existing["path"]), "_change": {"description": _desc}, } + _add_description_prompt_preview(result, content) + return result def _patch_skill( @@ -1469,6 +1490,10 @@ SKILL_MANAGE_SCHEMA = { "Skip for simple one-offs. Confirm with user before creating/deleting.\n\n" "Good skills: trigger conditions, numbered steps with exact commands, " "pitfalls section, verification steps. Use skill_view() to see format examples.\n\n" + "Description: long descriptions are truncated to the first 57 chars " + "plus '...' in the system prompt skill index; longer text is visible " + "via skills_list/skill_view. Keep the trigger self-contained in that " + "first 57-char window: 'Use when . .'\n\n" "Pinned skills are protected from deletion only — skill_manage(action='delete') " "will refuse with a message pointing the user to `hermes curator unpin `. " "Patches and edits go through on pinned skills so you can still improve them as "