feat(plugins): HERMES_PLUGINS_DEBUG=1 surfaces plugin discovery logs (#22684)

Plugin authors had no easy way to figure out why their plugin wasn't
loading — failures were buried in agent.log at WARNING and skip reasons
(disabled, not enabled, depth cap, exclusive) were DEBUG-only and
invisible by default.

Set HERMES_PLUGINS_DEBUG=1 to attach a stderr handler at DEBUG to the
hermes_cli.plugins logger only. Surfaces:

  - which directories were scanned + manifest counts per source
  - per manifest: resolved key, name, kind, source, on-disk path
  - skip reasons (disabled, not enabled, exclusive, depth cap, no register)
  - per load: tools/hooks/slash/CLI commands the plugin registered
  - full traceback on YAML parse failure (exc_info on the existing warning)
  - full traceback on register() exceptions, pointing at the plugin author's line

Env var off (default) → zero new stderr output, same as before.

Touches only hermes_cli/plugins.py + a doc section in the plugin-build
guide + an entry in the env-vars reference. 3 new tests lock the
attach/idempotent/no-attach behavior.
This commit is contained in:
Teknium 2026-05-09 11:07:12 -07:00 committed by GitHub
parent 8f83046f6c
commit 79694018f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 206 additions and 13 deletions

View file

@ -71,6 +71,56 @@ except ImportError: # pragma: no cover yaml is optional at import time
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Plugin developer debug logging
# ---------------------------------------------------------------------------
#
# Set ``HERMES_PLUGINS_DEBUG=1`` to surface verbose plugin-discovery logs to
# stderr in addition to ~/.hermes/logs/agent.log. Aimed at plugin authors
# trying to figure out why their plugin isn't showing up: which directories
# were scanned, which manifests parsed, which plugins were skipped (and why),
# what each ``register(ctx)`` call registered, and full tracebacks on load
# failure.
#
# The env var is read once at import time; tests that need to flip it
# mid-process can call ``_install_plugin_debug_handler(force=True)``.
_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in (
"1", "true", "yes", "on",
)
_DEBUG_HANDLER_INSTALLED = False
def _install_plugin_debug_handler(force: bool = False) -> None:
"""When HERMES_PLUGINS_DEBUG is on, tee plugin logs to stderr at DEBUG.
Idempotent: only attaches the handler once per process unless ``force``
is passed. Does not touch the root logger or other Hermes loggers.
"""
global _DEBUG_HANDLER_INSTALLED, _PLUGINS_DEBUG
if force:
_PLUGINS_DEBUG = os.getenv("HERMES_PLUGINS_DEBUG", "").strip().lower() in (
"1", "true", "yes", "on",
)
if not _PLUGINS_DEBUG or _DEBUG_HANDLER_INSTALLED:
return
handler = logging.StreamHandler(sys.stderr)
handler.setLevel(logging.DEBUG)
handler.setFormatter(logging.Formatter("[plugins] %(levelname)s %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.DEBUG)
# Don't double-emit through the root logger when the central logging
# config also writes to stderr. agent.log still captures everything.
logger.propagate = True
_DEBUG_HANDLER_INSTALLED = True
logger.debug(
"HERMES_PLUGINS_DEBUG=1 — verbose plugin discovery logging enabled"
)
_install_plugin_debug_handler()
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
@ -653,28 +703,43 @@ class PluginManager:
# is a category holding platform adapters (scanned one level deeper
# below).
repo_plugins = get_bundled_plugins_dir()
manifests.extend(
self._scan_directory(
repo_plugins,
source="bundled",
skip_names={"memory", "context_engine", "platforms", "model-providers"},
)
logger.debug("Scanning bundled plugins: %s", repo_plugins)
bundled = self._scan_directory(
repo_plugins,
source="bundled",
skip_names={"memory", "context_engine", "platforms", "model-providers"},
)
manifests.extend(
self._scan_directory(repo_plugins / "platforms", source="bundled")
logger.debug(" bundled (top-level): %d manifest(s)", len(bundled))
manifests.extend(bundled)
bundled_platforms = self._scan_directory(
repo_plugins / "platforms", source="bundled"
)
logger.debug(" bundled/platforms: %d manifest(s)", len(bundled_platforms))
manifests.extend(bundled_platforms)
# 2. User plugins (~/.hermes/plugins/)
user_dir = get_hermes_home() / "plugins"
manifests.extend(self._scan_directory(user_dir, source="user"))
logger.debug("Scanning user plugins: %s", user_dir)
user_manifests = self._scan_directory(user_dir, source="user")
logger.debug(" user: %d manifest(s)", len(user_manifests))
manifests.extend(user_manifests)
# 3. Project plugins (./.hermes/plugins/)
if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
project_dir = Path.cwd() / ".hermes" / "plugins"
manifests.extend(self._scan_directory(project_dir, source="project"))
logger.debug("Scanning project plugins: %s", project_dir)
project_manifests = self._scan_directory(project_dir, source="project")
logger.debug(" project: %d manifest(s)", len(project_manifests))
manifests.extend(project_manifests)
else:
logger.debug(
"Project plugins disabled (set HERMES_ENABLE_PROJECT_PLUGINS=1 to enable)"
)
# 4. Pip / entry-point plugins
manifests.extend(self._scan_entry_points())
ep_manifests = self._scan_entry_points()
logger.debug(" entrypoints: %d manifest(s)", len(ep_manifests))
manifests.extend(ep_manifests)
# Load each manifest (skip user-disabled plugins).
# Later sources override earlier ones on key collision — user
@ -923,6 +988,10 @@ class PluginManager:
except Exception:
pass
logger.debug(
"Parsed manifest: key=%s name=%s kind=%s source=%s path=%s",
key, name, kind, source, plugin_dir,
)
return PluginManifest(
name=name,
version=str(data.get("version", "")),
@ -937,7 +1006,9 @@ class PluginManager:
key=key,
)
except Exception as exc:
logger.warning("Failed to parse %s: %s", manifest_file, exc)
logger.warning(
"Failed to parse %s: %s", manifest_file, exc, exc_info=_PLUGINS_DEBUG,
)
return None
# -----------------------------------------------------------------------
@ -977,6 +1048,10 @@ class PluginManager:
def _load_plugin(self, manifest: PluginManifest) -> None:
"""Import a plugin module and call its ``register(ctx)`` function."""
loaded = LoadedPlugin(manifest=manifest)
logger.debug(
"Loading plugin '%s' (source=%s, kind=%s, path=%s)",
manifest.key or manifest.name, manifest.source, manifest.kind, manifest.path,
)
try:
if manifest.source in ("user", "project", "bundled"):
@ -1019,10 +1094,23 @@ class PluginManager:
if self._plugin_commands[c].get("plugin") == manifest.name
]
loaded.enabled = True
logger.debug(
" registered: %d tool(s), %d hook(s), %d slash command(s), %d CLI command(s)",
len(loaded.tools_registered),
len(loaded.hooks_registered),
len(loaded.commands_registered),
sum(
1 for c in self._cli_commands
if self._cli_commands[c].get("plugin") == manifest.name
),
)
except Exception as exc:
loaded.error = str(exc)
logger.warning("Failed to load plugin '%s': %s", manifest.name, exc)
logger.warning(
"Failed to load plugin '%s': %s",
manifest.name, exc, exc_info=_PLUGINS_DEBUG,
)
self._plugins[manifest.key or manifest.name] = loaded