diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 30fb28d1..b807db40 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -5,7 +5,8 @@ Hermes Plugin System Discovers, loads, and manages plugins from three sources: 1. **User plugins** – ``~/.hermes/plugins//`` -2. **Project plugins** – ``./.hermes/plugins//`` +2. **Project plugins** – ``./.hermes/plugins//`` (opt-in via + ``HERMES_ENABLE_PROJECT_PLUGINS``) 3. **Pip plugins** – packages that expose the ``hermes_agent.plugins`` entry-point group. @@ -62,6 +63,11 @@ ENTRY_POINTS_GROUP = "hermes_agent.plugins" _NS_PARENT = "hermes_plugins" +def _env_enabled(name: str) -> bool: + """Return True when an env var is set to a truthy opt-in value.""" + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + # --------------------------------------------------------------------------- # Data classes # --------------------------------------------------------------------------- @@ -186,8 +192,9 @@ class PluginManager: manifests.extend(self._scan_directory(user_dir, source="user")) # 2. Project plugins (./.hermes/plugins/) - project_dir = Path.cwd() / ".hermes" / "plugins" - manifests.extend(self._scan_directory(project_dir, source="project")) + if _env_enabled("HERMES_ENABLE_PROJECT_PLUGINS"): + project_dir = Path.cwd() / ".hermes" / "plugins" + manifests.extend(self._scan_directory(project_dir, source="project")) # 3. Pip / entry-point plugins manifests.extend(self._scan_entry_points()) diff --git a/run_agent.py b/run_agent.py index 8e39ffe3..8f1ce800 100644 --- a/run_agent.py +++ b/run_agent.py @@ -6942,20 +6942,18 @@ class AIAgent: pending_handled = True break - if not pending_handled: - # Error happened before tool processing (e.g. response parsing). - # Choose role to avoid consecutive same-role messages. - last_role = messages[-1].get("role") if messages else None - err_role = "assistant" if last_role == "user" else "user" - sys_err_msg = { - "role": err_role, - "content": f"[System error during processing: {error_msg}]", - } - messages.append(sys_err_msg) - + # Non-tool errors don't need a synthetic message injected. + # The error is already printed to the user (line above), and + # the retry loop continues. Injecting a fake user/assistant + # message pollutes history, burns tokens, and risks violating + # role-alternation invariants. + # If we're near the limit, break to avoid infinite loops if api_call_count >= self.max_iterations - 1: final_response = f"I apologize, but I encountered repeated errors: {error_msg}" + # Append as assistant so the history stays valid for + # session resume (avoids consecutive user messages). + messages.append({"role": "assistant", "content": final_response}) break if final_response is None and ( diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 88e194ef..1ea4fcb8 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -67,6 +67,7 @@ class TestPluginDiscovery: project_dir = tmp_path / "project" project_dir.mkdir() monkeypatch.chdir(project_dir) + monkeypatch.setenv("HERMES_ENABLE_PROJECT_PLUGINS", "true") plugins_dir = project_dir / ".hermes" / "plugins" _make_plugin_dir(plugins_dir, "proj_plugin") @@ -76,6 +77,19 @@ class TestPluginDiscovery: assert "proj_plugin" in mgr._plugins assert mgr._plugins["proj_plugin"].enabled + def test_discover_project_plugins_skipped_by_default(self, tmp_path, monkeypatch): + """Project plugins are not discovered unless explicitly enabled.""" + project_dir = tmp_path / "project" + project_dir.mkdir() + monkeypatch.chdir(project_dir) + plugins_dir = project_dir / ".hermes" / "plugins" + _make_plugin_dir(plugins_dir, "proj_plugin") + + mgr = PluginManager() + mgr.discover_and_load() + + assert "proj_plugin" not in mgr._plugins + def test_discover_is_idempotent(self, tmp_path, monkeypatch): """Calling discover_and_load() twice does not duplicate plugins.""" plugins_dir = tmp_path / "hermes_test" / "plugins" diff --git a/website/docs/reference/environment-variables.md b/website/docs/reference/environment-variables.md index 336ce871..31ed5ec4 100644 --- a/website/docs/reference/environment-variables.md +++ b/website/docs/reference/environment-variables.md @@ -232,6 +232,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe | `HERMES_QUIET` | Suppress non-essential output (`true`/`false`) | | `HERMES_API_TIMEOUT` | LLM API call timeout in seconds (default: `900`) | | `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_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) | diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index 9b86d5d1..7f58d84d 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -22,6 +22,8 @@ Drop a directory into `~/.hermes/plugins/` with a `plugin.yaml` and Python code: Start Hermes — your tools appear alongside built-in tools. The model can call them immediately. +Project-local plugins under `./.hermes/plugins/` are disabled by default. Enable them only for trusted repositories by setting `HERMES_ENABLE_PROJECT_PLUGINS=true` before starting Hermes. + ## What plugins can do | Capability | How | @@ -38,7 +40,7 @@ Start Hermes — your tools appear alongside built-in tools. The model can call | Source | Path | Use case | |--------|------|----------| | User | `~/.hermes/plugins/` | Personal plugins | -| Project | `.hermes/plugins/` | Project-specific plugins | +| Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) | | pip | `hermes_agent.plugins` entry_points | Distributed packages | ## Available hooks