fix: disclose enabled plugins' registered skills in the system prompt's available_skills index

This commit is contained in:
Teknium
2026-10-07 15:23:55 -07:00
parent cc75e8f402
commit 19cb1cbfed
2 changed files with 48 additions and 5 deletions

View File

@@ -1315,6 +1315,38 @@ def _skill_should_show(
)
def _plugin_skill_prompt_rows(
disabled: "set[str]", available_tools: "set[str] | None", available_toolsets: "set[str] | None",
session_platform: "str | None",
) -> "list[tuple[str, str]]":
"""``(qualified_name, description)`` for every skill registered by an ENABLED plugin
(``ctx.register_skill``), filtered through the same offer-time gates as on-disk skills.
Plugin skills live in the plugin-manager registry — never under the profile skills tree —
so the disk scans above cannot see them; this is their one path into ``<available_skills>``.
The qualified ``plugin:skill`` name is exactly what ``skill_view`` resolves, and disabling
or unloading a plugin removes its registry entries, so enablement gating is inherent."""
rows: "list[tuple[str, str]]" = []
try:
from hermes_cli.plugins import discover_plugins, get_plugin_manager
discover_plugins() # idempotent; joins an in-flight discovery (same call skills_list makes)
for meta in get_plugin_manager().list_plugin_skill_metadata():
name = str(meta.get("name") or "")
if not name or name in disabled:
continue
frontmatter = meta.get("frontmatter") or {}
if not (skill_matches_platform(frontmatter) and skill_matches_environment(frontmatter)
and skill_matches_apps(frontmatter)):
continue
if not _skill_should_show(extract_skill_conditions(frontmatter), available_tools,
available_toolsets, session_platform):
continue
desc = str(meta.get("description") or "").strip() or extract_skill_description(frontmatter)
rows.append((name, desc))
except Exception:
logger.debug("Plugin skill prompt rows unavailable", exc_info=True)
return rows
def _current_session_platform_hint() -> str:
"""Active platform without importing the gateway package on CLI startup."""
platform = os.environ.get("HERMES_PLATFORM") or os.environ.get("HERMES_SESSION_PLATFORM")
@@ -1476,12 +1508,15 @@ def _build_skills_system_prompt_inner(
# The resolved platform is part of the key: per-platform disabled-skill lists need distinct cache entries.
_platform_hint = _current_session_platform_hint()
disabled = get_disabled_skill_names(_platform_hint or None)
# Plugin-registered skills (ctx.register_skill) are registry state, not files under any scanned
# root — the snapshot manifest can't see them change, so they participate in the cache key.
plugin_rows = _plugin_skill_prompt_rows(disabled, available_tools, available_toolsets, _platform_hint or None)
cache_key = (
str(skills_dir), tuple((t, str(d)) for t, d in extra_roots),
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)), tuple(sorted(compact_categories or ())),
_oneshot_prompt_variant(),
_oneshot_prompt_variant(), tuple(plugin_rows),
)
snapshot = _load_skills_snapshot(skills_dir)
app_gated = snapshot is not None and any(
@@ -1532,6 +1567,14 @@ def _build_skills_system_prompt_inner(
visible_entries = [e for e in resolved
if e["visible"] and e["status"] != "shadowed" and not is_disabled_entry(e, disabled)]
_label_visible_entries(visible_entries, skills_by_category)
if plugin_rows:
# Same category label skills_list gives registry skills; qualified names are already
# namespaced (plugin:skill) so they cannot collide with on-disk load_names.
listed = {name for entries in skills_by_category.values() for name, _ in entries}
for name, desc in plugin_rows:
if name not in listed:
listed.add(name)
skills_by_category.setdefault("plugin", []).append((name, desc))
if snapshot is None: # persist for fast cold-start reuse (best-effort)
category_descriptions.update(_read_category_descriptions(skills_dir, "Could not read skill description %s: %s"))
try:

View File

@@ -24,10 +24,10 @@ def register_skill(
self, name: str, path: Path, description: str = "",
frontmatter: Optional[Mapping[str, Any]] = None,
) -> PluginRegistration:
"""Register a read-only skill resolvable as ``'<plugin_name>:<name>'`` via ``skill_view()``
and listed by ``skills_list``. Not copied into ``~/.hermes/skills/`` and not in the system
prompt's ``<available_skills>``. Raises ``ValueError`` (``':'``/invalid chars) or
``FileNotFoundError``."""
"""Register a read-only skill resolvable as ``'<plugin_name>:<name>'`` via ``skill_view()``,
listed by ``skills_list`` and in the system prompt's ``<available_skills>`` while the plugin
is enabled. Not copied into ``~/.hermes/skills/``. Raises ``ValueError`` (``':'``/invalid
chars) or ``FileNotFoundError``."""
from agent.skill_utils import _NAMESPACE_RE
if ":" in name:
raise ValueError(f"Skill name '{name}' must not contain ':' (the namespace is derived from the "