diff --git a/agent/curator.py b/agent/curator.py index ada93248e246f..dc908fc35939a 100644 --- a/agent/curator.py +++ b/agent/curator.py @@ -422,7 +422,9 @@ def apply_automatic_transitions(now: Optional[datetime] = None) -> Dict[str, int "INSTRUCTIONS AND EXPERIENTIAL KNOWLEDGE. A collection of hundreds of " "narrow skills where each one captures one session's specific bug is " "a FAILURE of the library — not a feature. An agent searching skills " - "matches on descriptions, not on exact names; one broad umbrella " + "matches on descriptions, not on exact names (note: long descriptions " + "are truncated to 57 chars in the system prompt skill index — keep the " + "trigger class in that window). One broad umbrella " "skill with labeled subsections beats five narrow siblings for " "discoverability, not the other way around.\n\n" "The right target shape is CLASS-LEVEL skills with rich SKILL.md " diff --git a/agent/skill_commands.py b/agent/skill_commands.py index 11d425b91a526..fa1b4044a7ae9 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/agent/skill_utils.py b/agent/skill_utils.py index f96238b9bd959..df0f933317fcd 100644 --- a/agent/skill_utils.py +++ b/agent/skill_utils.py @@ -779,18 +779,31 @@ def resolve_skill_config_values( # ── Description extraction ──────────────────────────────────────────────── +SKILL_PROMPT_DESC_LIMIT = 60 -def extract_skill_description(frontmatter: Dict[str, Any]) -> str: - """Extract a truncated description from parsed frontmatter.""" + +def _normalize_skill_description(frontmatter: Dict[str, Any]) -> str: + """Normalize a skill's description field for comparison/truncation.""" raw_desc = frontmatter.get("description", "") - if not raw_desc: + return str(raw_desc).strip().strip("'\"") if raw_desc else "" + + +def extract_skill_description(frontmatter: Dict[str, Any]) -> str: + """Extract a system-prompt-length description from parsed frontmatter.""" + desc = _normalize_skill_description(frontmatter) + if not desc: return "" - desc = str(raw_desc).strip().strip("'\"") - if len(desc) > 60: - return desc[:57] + "..." + if len(desc) > SKILL_PROMPT_DESC_LIMIT: + return desc[:SKILL_PROMPT_DESC_LIMIT - 3] + "..." return desc +def is_skill_description_truncated_for_prompt(frontmatter: Dict[str, Any]) -> bool: + """True when the description will be truncated in the system prompt skill index.""" + desc = _normalize_skill_description(frontmatter) + return len(desc) > SKILL_PROMPT_DESC_LIMIT + + # ── File iteration ──────────────────────────────────────────────────────── diff --git a/contributors/emails/alanrbox@gmail.com b/contributors/emails/alanrbox@gmail.com new file mode 100644 index 0000000000000..433a77184dcb2 --- /dev/null +++ b/contributors/emails/alanrbox@gmail.com @@ -0,0 +1 @@ +AlanBurningsuit diff --git a/skills/software-development/hermes-agent-skill-authoring/SKILL.md b/skills/software-development/hermes-agent-skill-authoring/SKILL.md index ffa14408fb087..78018349f9d6e 100644 --- a/skills/software-development/hermes-agent-skill-authoring/SKILL.md +++ b/skills/software-development/hermes-agent-skill-authoring/SKILL.md @@ -35,6 +35,10 @@ Source of truth: `tools/skill_manager_tool.py::_validate_frontmatter`. Hard requ - Parses as a YAML mapping. - `name` field present. - `description` field present, ≤ **1024 chars** (`MAX_DESCRIPTION_LENGTH`). + **Long descriptions are truncated to 57 chars + "..." in the system + prompt skill index** (`extract_skill_description` in `agent/skill_utils.py`); + longer text is visible via `skills_list()` and `skill_view()`. + Front-load the trigger phrase. - Non-empty body after the closing `---`. Peer-matched shape used by every skill under `skills/software-development/`: @@ -42,7 +46,7 @@ Peer-matched shape used by every skill under `skills/software-development/`: ```yaml --- name: my-skill-name # lowercase, hyphens, ≤64 chars (MAX_NAME_LENGTH) -description: Use when . . +description: Use when . . # first 57 chars shown in system prompt version: 1.1.0 author: Hermes Agent license: MIT @@ -57,7 +61,9 @@ metadata: ## Size Limits -- Description: ≤ 1024 chars (enforced). +- Description: ≤ 1024 chars (enforced). **Long descriptions render as the first 57 chars + plus "..." in the system prompt skill index;** the rest is visible via `skills_list()` + and `skill_view()`. - Full SKILL.md: ≤ 100,000 chars (enforced as `MAX_SKILL_CONTENT_CHARS`, ~36k tokens). - Peer skills in `software-development/` sit at **8-14k chars**. Aim for that range. If you're pushing past 20k, split into `references/*.md` and reference them from SKILL.md. @@ -165,7 +171,11 @@ Pick the closest existing category. Don't invent new top-level categories casual 2. **Leading whitespace before `---`.** The validator checks `content.startswith("---")`; any leading blank line or BOM fails validation. -3. **Description too generic.** Peer descriptions start with "Use when ..." and describe the *trigger class*, not the one task. "Use when debugging X" > "Debug X". +3. **Description too generic or trigger buried past char 57.** The system prompt + skill index truncates long descriptions at 57 chars. Peer descriptions start + with "Use when ..." and complete the trigger class within that window. + - Good: `Use when debugging Hermes skill discovery failures.` + - Bad: `This skill contains detailed guidance for agents working on Hermes skill discovery failures.` 4. **Forgetting the author/license/metadata block.** Not validator-enforced, but every peer has it; omitting makes the skill look half-finished. @@ -185,7 +195,8 @@ Pick the closest existing category. Don't invent new top-level categories casual - [ ] Frontmatter starts at byte 0 with `---`, closes with `\n---\n` - [ ] `name`, `description`, `version`, `author`, `license`, `metadata.hermes.{tags, related_skills}` all present - [ ] Name ≤ 64 chars, lowercase + hyphens -- [ ] Description ≤ 1024 chars and starts with "Use when ..." +- [ ] Description ≤ 1024 chars, trigger phrase self-contained within first 57 chars, + and starts with "Use when ..." - [ ] Total file ≤ 100,000 chars (aim for 8-15k) - [ ] Structure: `# Title` → `## Overview` → `## When to Use` → body → `## Common Pitfalls` → `## Verification Checklist` - [ ] Each ordered step has a checkable completion criterion diff --git a/tests/tools/test_skill_manager_tool.py b/tests/tools/test_skill_manager_tool.py index 3443e0418b160..fad2f682c7b2d 100644 --- a/tests/tools/test_skill_manager_tool.py +++ b/tests/tools/test_skill_manager_tool.py @@ -21,6 +21,11 @@ skill_manage, MAX_NAME_LENGTH, ) +from agent.skill_utils import ( + extract_skill_description, + parse_frontmatter, + SKILL_PROMPT_DESC_LIMIT, +) @contextmanager @@ -54,6 +59,17 @@ def _skill_dir(tmp_path): 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 @@ def test_create_rejects_absolute_category(self, tmp_path): 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 @@ def test_edit_invalid_content_rejected(self, tmp_path): 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 debea52642f99..12fc3d1a103ca 100644 --- a/tools/skill_manager_tool.py +++ b/tools/skill_manager_tool.py @@ -45,6 +45,12 @@ 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 @@ def skill_manage( "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 "