Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion agent/curator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "
Expand Down
4 changes: 2 additions & 2 deletions agent/skill_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 19 additions & 6 deletions agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────


Expand Down
1 change: 1 addition & 0 deletions contributors/emails/alanrbox@gmail.com
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
AlanBurningsuit
19 changes: 15 additions & 4 deletions skills/software-development/hermes-agent-skill-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,18 @@ 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/`:

```yaml
---
name: my-skill-name # lowercase, hyphens, ≤64 chars (MAX_NAME_LENGTH)
description: Use when <trigger>. <one-line behavior>.
description: Use when <trigger>. <one-line behavior>. # first 57 chars shown in system prompt
version: 1.1.0
author: Hermes Agent
license: MIT
Expand All @@ -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.

Expand Down Expand Up @@ -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.

Expand All @@ -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
Expand Down
55 changes: 55 additions & 0 deletions tests/tools/test_skill_manager_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@
skill_manage,
MAX_NAME_LENGTH,
)
from agent.skill_utils import (
extract_skill_description,
parse_frontmatter,
SKILL_PROMPT_DESC_LIMIT,
)


@contextmanager
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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):
Expand Down
27 changes: 26 additions & 1 deletion tools/skill_manager_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 <trigger>. <one-line behavior>.'\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 <name>`. "
"Patches and edits go through on pinned skills so you can still improve them as "
Expand Down
Loading