From 19cb1cbfedeafaca099be6ff0141a28a6c516c0f Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 7 Oct 2026 15:23:55 -0700 Subject: [PATCH] fix: disclose enabled plugins' registered skills in the system prompt's available_skills index --- agent/prompt_builder.py | 45 ++++++++++++++++++++++++++++++++++- hermes_cli/plugins_content.py | 8 +++---- 2 files changed, 48 insertions(+), 5 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 6c975b0a6b..3a7c48b709 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -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 ````. + 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: diff --git a/hermes_cli/plugins_content.py b/hermes_cli/plugins_content.py index 404a9d92a0..92b8d79f54 100644 --- a/hermes_cli/plugins_content.py +++ b/hermes_cli/plugins_content.py @@ -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 ``':'`` via ``skill_view()`` - and listed by ``skills_list``. Not copied into ``~/.hermes/skills/`` and not in the system - prompt's ````. Raises ``ValueError`` (``':'``/invalid chars) or - ``FileNotFoundError``.""" + """Register a read-only skill resolvable as ``':'`` via ``skill_view()``, + listed by ``skills_list`` and in the system prompt's ```` 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 "