Skip to content
Open
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
13 changes: 11 additions & 2 deletions agent/prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
extract_skill_description,
get_all_skills_dirs,
get_disabled_skill_names,
get_skills_index_mode,
iter_skill_index_files,
parse_frontmatter,
skill_matches_platform,
Expand Down Expand Up @@ -1019,13 +1020,15 @@ def build_skills_system_prompt(
or ""
)
disabled = get_disabled_skill_names()
index_mode = get_skills_index_mode()
cache_key = (
str(skills_dir.resolve()),
tuple(str(d) for d in 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,
tuple(sorted(disabled)),
index_mode,
)
with _SKILLS_PROMPT_CACHE_LOCK:
cached = _SKILLS_PROMPT_CACHE.get(cache_key)
Expand Down Expand Up @@ -1162,10 +1165,16 @@ def build_skills_system_prompt(
if not skills_by_category:
result = ""
else:
# ``compact`` mode drops per-skill descriptions and per-category
# descriptions from the index — the agent loads the full
# description (and body) on demand via ``skill_view(name)``. This
# trades a small amount of at-a-glance discoverability for a
# measurably smaller system prompt when a user has many skills.
compact = index_mode == "compact"
index_lines = []
for category in sorted(skills_by_category.keys()):
cat_desc = category_descriptions.get(category, "")
if cat_desc:
if cat_desc and not compact:
index_lines.append(f" {category}: {cat_desc}")
else:
index_lines.append(f" {category}:")
Expand All @@ -1175,7 +1184,7 @@ def build_skills_system_prompt(
if name in seen:
continue
seen.add(name)
if desc:
if desc and not compact:
index_lines.append(f" - {name}: {desc}")
else:
index_lines.append(f" - {name}")
Expand Down
42 changes: 42 additions & 0 deletions agent/skill_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,48 @@ def _normalize_string_set(values) -> Set[str]:
return {str(v).strip() for v in values if str(v).strip()}


# ── Skills index mode ────────────────────────────────────────────────────

_VALID_SKILLS_INDEX_MODES = ("detailed", "compact")


def get_skills_index_mode() -> str:
"""Return the per-skill index verbosity mode for the system prompt.

- ``"detailed"`` (default): each skill listed as ``- name: description``
(description truncated to 60 chars). Matches historical behavior.
- ``"compact"``: each skill listed as ``- name`` only. Drops
per-skill descriptions and category descriptions from the index.
Useful when a user has many skills and wants to trade
discoverability detail for a smaller system prompt; the full
description is still available via ``skill_view(name)``.

Resolution order: env var ``HERMES_SKILLS_INDEX_MODE`` (handy for
tests / one-shot runs), then ``skills.index_mode`` in config.yaml.
Unrecognized values fall back to ``"detailed"``.
"""
env_override = os.getenv("HERMES_SKILLS_INDEX_MODE")
if env_override:
mode = env_override.strip().lower()
return mode if mode in _VALID_SKILLS_INDEX_MODES else "detailed"

config_path = get_config_path()
if not config_path.exists():
return "detailed"
try:
parsed = yaml_load(config_path.read_text(encoding="utf-8"))
except Exception:
return "detailed"
if not isinstance(parsed, dict):
return "detailed"
skills_cfg = parsed.get("skills")
if not isinstance(skills_cfg, dict):
return "detailed"

mode = str(skills_cfg.get("index_mode") or "").strip().lower()
return mode if mode in _VALID_SKILLS_INDEX_MODES else "detailed"


# ── External skills directories ──────────────────────────────────────────

# (config_path_str, mtime_ns) -> resolved external dirs list. Keyed by
Expand Down
13 changes: 13 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,19 @@ skills:
# Set to 0 to disable.
creation_nudge_interval: 15

# System-prompt index verbosity.
# detailed (default) — each skill rendered as "- name: description"
# (description truncated to 60 chars). Maximizes
# at-a-glance discoverability.
# compact — each skill rendered as "- name" only. Drops the
# per-skill and per-category descriptions from the
# index. The agent loads the full description (and
# body) on demand via skill_view(name). Saves ~one
# line per skill in the prompt — meaningful with
# 50+ skills, negligible with a handful.
# Override at runtime with HERMES_SKILLS_INDEX_MODE=compact for one-shots.
# index_mode: detailed

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove the documented HERMES_SKILLS_INDEX_MODE override. AGENTS.md:102-105 requires non-secret behavioral settings to be user-facing through config.yaml; this prompt-index mode is not a credential or build-only transport setting.

# External skill directories — share skills across tools/agents without
# copying them into ~/.hermes/skills/. Each path is expanded (~ and ${VAR})
# and resolved to an absolute path. External dirs are read-only: skill
Expand Down
66 changes: 66 additions & 0 deletions tests/agent/test_prompt_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,72 @@ 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_compact_index_mode_drops_descriptions(self, monkeypatch, tmp_path):
"""``skills.index_mode: compact`` ships ``- name`` per skill —
no per-skill description, no per-category description. The agent
loads the full description on demand via ``skill_view(name)``.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_SKILLS_INDEX_MODE", "compact")

cat_dir = tmp_path / "skills" / "tools"
(cat_dir / "web-search").mkdir(parents=True)
(cat_dir / "web-search" / "SKILL.md").write_text(
"---\nname: web-search\ndescription: Search the web for facts\n---\n"
)
(cat_dir / "DESCRIPTION.md").write_text(
"---\ndescription: General tools\n---\n"
)

result = build_skills_system_prompt()

# Name still present
assert "web-search" in result
# Per-skill description stripped
assert "Search the web for facts" not in result
# Per-category description stripped
assert "General tools" not in result
# Sanity: still in the expected index format
assert "<available_skills>" in result
assert "- web-search" in result

def test_detailed_index_mode_is_default_and_keeps_descriptions(
self, monkeypatch, tmp_path
):
"""Default (and explicit ``detailed``) mode preserves the
per-skill description in the index — guards against regressions
that would silently strip descriptions for existing users."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# No HERMES_SKILLS_INDEX_MODE set → default behavior.
monkeypatch.delenv("HERMES_SKILLS_INDEX_MODE", raising=False)

skill_dir = tmp_path / "skills" / "tools" / "web-search"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: web-search\ndescription: Search the web for facts\n---\n"
)

result = build_skills_system_prompt()
assert "web-search" in result
assert "Search the web for facts" in result

def test_invalid_index_mode_falls_back_to_detailed(self, monkeypatch, tmp_path):
"""Unrecognized ``index_mode`` values fall back to ``detailed``
rather than producing an empty or malformed index."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("HERMES_SKILLS_INDEX_MODE", "ultra-mega-compact")

skill_dir = tmp_path / "skills" / "tools" / "web-search"
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text(
"---\nname: web-search\ndescription: Search the web for facts\n---\n"
)

result = build_skills_system_prompt()
# Same as detailed
assert "web-search" in result
assert "Search the web for facts" in result

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)
Expand Down