Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions hermes_cli/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
Discovers, loads, and manages plugins from three sources:

1. **User plugins** – ``~/.hermes/plugins/<name>/``
2. **Project plugins** – ``./.hermes/plugins/<name>/``
2. **Project plugins** – ``./.hermes/plugins/<name>/`` (opt-in via
``HERMES_ENABLE_PROJECT_PLUGINS``)
3. **Pip plugins** – packages that expose the ``hermes_agent.plugins``
entry-point group.

Expand Down Expand Up @@ -62,6 +63,11 @@
_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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -186,8 +192,9 @@ def discover_and_load(self) -> None:
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())
Expand Down
20 changes: 9 additions & 11 deletions run_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -6807,20 +6807,18 @@ def _stop_spinner():
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 (
Expand Down
14 changes: 14 additions & 0 deletions tests/test_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def test_discover_project_plugins(self, tmp_path, monkeypatch):
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")

Expand All @@ -76,6 +77,19 @@ def test_discover_project_plugins(self, tmp_path, monkeypatch):
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"
Expand Down
1 change: 1 addition & 0 deletions website/docs/reference/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |

Expand Down
4 changes: 3 additions & 1 deletion website/docs/user-guide/features/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down
Loading