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

View file

@ -1232,3 +1232,77 @@ class TestPluginDispatchTool:
result = ctx.dispatch_tool("fake", {})
assert '"error"' in result
class TestPluginDebugLogging:
"""HERMES_PLUGINS_DEBUG opt-in stderr handler for plugin developers."""
def test_debug_handler_not_installed_when_env_var_absent(self, monkeypatch):
"""Without the env var, no stderr handler is attached."""
monkeypatch.delenv("HERMES_PLUGINS_DEBUG", raising=False)
from hermes_cli import plugins as plugins_mod
# Snapshot, then force a re-evaluation.
original_installed = plugins_mod._DEBUG_HANDLER_INSTALLED
original_debug = plugins_mod._PLUGINS_DEBUG
original_handlers = list(plugins_mod.logger.handlers)
try:
plugins_mod._DEBUG_HANDLER_INSTALLED = False
plugins_mod._install_plugin_debug_handler(force=True)
assert plugins_mod._PLUGINS_DEBUG is False
assert plugins_mod._DEBUG_HANDLER_INSTALLED is False
# No new stderr handler was attached.
assert plugins_mod.logger.handlers == original_handlers
finally:
plugins_mod._DEBUG_HANDLER_INSTALLED = original_installed
plugins_mod._PLUGINS_DEBUG = original_debug
plugins_mod.logger.handlers = original_handlers
def test_debug_handler_installed_when_env_var_set(self, monkeypatch):
"""With HERMES_PLUGINS_DEBUG=1, a DEBUG-level stderr handler is attached."""
monkeypatch.setenv("HERMES_PLUGINS_DEBUG", "1")
from hermes_cli import plugins as plugins_mod
original_installed = plugins_mod._DEBUG_HANDLER_INSTALLED
original_debug = plugins_mod._PLUGINS_DEBUG
original_level = plugins_mod.logger.level
original_handlers = list(plugins_mod.logger.handlers)
try:
plugins_mod._DEBUG_HANDLER_INSTALLED = False
plugins_mod._install_plugin_debug_handler(force=True)
assert plugins_mod._PLUGINS_DEBUG is True
assert plugins_mod._DEBUG_HANDLER_INSTALLED is True
assert plugins_mod.logger.level == logging.DEBUG
new_handlers = [
h for h in plugins_mod.logger.handlers if h not in original_handlers
]
assert len(new_handlers) == 1
assert isinstance(new_handlers[0], logging.StreamHandler)
assert new_handlers[0].level == logging.DEBUG
finally:
plugins_mod._DEBUG_HANDLER_INSTALLED = original_installed
plugins_mod._PLUGINS_DEBUG = original_debug
plugins_mod.logger.setLevel(original_level)
plugins_mod.logger.handlers = original_handlers
def test_debug_handler_idempotent(self, monkeypatch):
"""Calling install twice (without force) does not double-attach."""
monkeypatch.setenv("HERMES_PLUGINS_DEBUG", "1")
from hermes_cli import plugins as plugins_mod
original_installed = plugins_mod._DEBUG_HANDLER_INSTALLED
original_debug = plugins_mod._PLUGINS_DEBUG
original_level = plugins_mod.logger.level
original_handlers = list(plugins_mod.logger.handlers)
try:
plugins_mod._DEBUG_HANDLER_INSTALLED = False
plugins_mod._install_plugin_debug_handler(force=True)
count_after_first = len(plugins_mod.logger.handlers)
plugins_mod._install_plugin_debug_handler() # no force
count_after_second = len(plugins_mod.logger.handlers)
assert count_after_first == count_after_second
finally:
plugins_mod._DEBUG_HANDLER_INSTALLED = original_installed
plugins_mod._PLUGINS_DEBUG = original_debug
plugins_mod.logger.setLevel(original_level)
plugins_mod.logger.handlers = original_handlers

View file

@ -311,6 +311,36 @@ Plugins (1):
✓ calculator v1.0.0 (2 tools, 1 hooks)
```
### Debugging plugin discovery
If your plugin doesn't show up — or shows up but isn't loading — set `HERMES_PLUGINS_DEBUG=1` to get verbose discovery logs on stderr:
```bash
HERMES_PLUGINS_DEBUG=1 hermes plugins list
```
You'll see, for every plugin source (bundled, user, project, entry-points):
- which directories were scanned and how many manifests each yielded
- per manifest: resolved key, name, kind, source, on-disk path
- skip reasons: `disabled via config`, `not enabled in config`, `exclusive plugin`, `no plugin.yaml, depth cap reached`
- on load: the plugin being imported, plus a one-line summary of what `register(ctx)` registered (tools, hooks, slash commands, CLI commands)
- on parse failure: a full traceback for the exception (YAML scanner errors, etc.)
- on `register()` failure: a full traceback pointing at the line in your `__init__.py` that raised
The same logs are always written to `~/.hermes/logs/agent.log` at WARNING level (failures only) and DEBUG level (everything) when the env var is set. So if you can't run with the env var (e.g. from inside the gateway), tail the log file instead:
```bash
hermes logs --level WARNING | grep -i plugin
```
Common reasons a plugin doesn't appear:
- **Not enabled in config** — plugins are opt-in. Run `hermes plugins enable <name>` (the name comes from the `plugins list` output, which can be `<category>/<plugin>` for nested layouts).
- **Wrong directory layout** — must be `~/.hermes/plugins/<plugin-name>/plugin.yaml` (flat) or `~/.hermes/plugins/<category>/<plugin-name>/plugin.yaml` (one level of category nesting, max). Anything deeper is ignored.
- **Missing `__init__.py`** — the plugin directory needs both `plugin.yaml` and `__init__.py` with a `register(ctx)` function.
- **Wrong `kind`** — gateway adapters need `kind: platform` in their manifest. Memory providers are auto-detected as `kind: exclusive` and routed through the `memory.provider` config instead of `plugins.enabled`.
## Your plugin's final structure
```

View file

@ -502,6 +502,7 @@ Advanced per-platform knobs for throttling the outbound message batcher. Most us
| `HERMES_CHECKPOINT_TIMEOUT` | Timeout for filesystem checkpoint creation in seconds (default: `30`). |
| `HERMES_EXEC_ASK` | Enable execution approval prompts in gateway mode (`true`/`false`) |
| `HERMES_ENABLE_PROJECT_PLUGINS` | Enable auto-discovery of repo-local plugins from `./.hermes/plugins/` (`true`/`false`, default: `false`) |
| `HERMES_PLUGINS_DEBUG` | `1`/`true` to surface verbose plugin-discovery logs on stderr — directories scanned, manifests parsed, skip reasons, and full tracebacks on parse or `register()` failure. Aimed at plugin authors. |
| `HERMES_BACKGROUND_NOTIFICATIONS` | Background process notification mode in gateway: `all` (default), `result`, `error`, `off` |
| `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) |
| `HERMES_PREFILL_MESSAGES_FILE` | Path to a JSON file of ephemeral prefill messages injected at API-call time. |