From 79694018f89e9c6c75cad11172855ca1de345c47 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 9 May 2026 11:07:12 -0700 Subject: [PATCH] feat(plugins): HERMES_PLUGINS_DEBUG=1 surfaces plugin discovery logs (#22684) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- hermes_cli/plugins.py | 114 ++++++++++++++++-- tests/hermes_cli/test_plugins.py | 74 ++++++++++++ website/docs/guides/build-a-hermes-plugin.md | 30 +++++ .../docs/reference/environment-variables.md | 1 + 4 files changed, 206 insertions(+), 13 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 2171e6d50dc..15ef7920a15 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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 diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 84e8404a8f2..959b2246832 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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 diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index 748bc185645..45ad3622ea5 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -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 ` (the name comes from the `plugins list` output, which can be `/` for nested layouts). +- **Wrong directory layout** — must be `~/.hermes/plugins//plugin.yaml` (flat) or `~/.hermes/plugins///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 ``` diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index b82b385f50f..5f4ce34a554 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -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. |