{"repo":"NousResearch/hermes-agent","public_id":"2228","display_label":"System error messages appearing with wrong role assignment","summary":"These issues and fixes concern a bug where system-generated error messages were being injected with a dynamic or incorrect role (appearing as role=user instead of role=assistant). The cluster focuses on correcting role assignment for system error messages and related session resume behavior.","subsystem":"agent","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":2303},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"stale","last_verified_at":"2026-03-21T14:03:55+00:00","is_stale":true},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":2228,"pr_number":2303,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":2228,"title":"bug: system error messages injected with dynamic role, can appear as role=user","url":null,"state":"closed"},{"kind":"pr","number":2231,"title":"fix: always use role=assistant for system error messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2303","number":2303}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":2236,"title":"fix(agent): always use role=assistant for system error messages, never role=user","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":2253,"title":"fix: always use assistant role for system error messages","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#2303","number":2303}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":2263,"title":"fix: bug: system error messages injected with dynamic role, can appear","url":null,"state":"closed","close_reason":{"category":"silent_close","implication":"active"},"merged":false},{"kind":"pr","number":2303,"title":"fix: remove synthetic error message injection, fix session resume after repeated failures","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/plugins.py","run_agent.py","tests/test_plugins.py","website/docs/reference/environment-variables.md","website/docs/user-guide/features/plugins.md"],"fix_hunks":[{"file":"hermes_cli/plugins.py","hunks":["@@ -5,7 +5,8 @@\n Discovers, loads, and manages plugins from three sources:\n \n 1. **User plugins**   – ``~/.hermes/plugins/<name>/``\n-2. **Project plugins** – ``./.hermes/plugins/<name>/``\n+2. **Project plugins** – ``./.hermes/plugins/<name>/`` (opt-in via\n+   ``HERMES_ENABLE_PROJECT_PLUGINS``)\n 3. **Pip plugins**     – packages that expose the ``hermes_agent.plugins``\n    entry-point group.\n ","@@ -62,6 +63,11 @@\n _NS_PARENT = \"hermes_plugins\"\n \n \n+def _env_enabled(name: str) -> bool:\n+    \"\"\"Return True when an env var is set to a truthy opt-in value.\"\"\"\n+    return os.getenv(name, \"\").strip().lower() in {\"1\", \"true\", \"yes\", \"on\"}\n+\n+\n # ---------------------------------------------------------------------------\n # Data classes\n # ---------------------------------------------------------------------------","@@ -186,8 +192,9 @@ def discover_and_load(self) -> None:\n         manifests.extend(self._scan_directory(user_dir, source=\"user\"))\n \n         # 2. Project plugins (./.hermes/plugins/)\n-        project_dir = Path.cwd() / \".hermes\" / \"plugins\"\n-        manifests.extend(self._scan_directory(project_dir, source=\"project\"))\n+        if _env_enabled(\"HERMES_ENABLE_PROJECT_PLUGINS\"):\n+            project_dir = Path.cwd() / \".hermes\" / \"plugins\"\n+            manifests.extend(self._scan_directory(project_dir, source=\"project\"))\n \n         # 3. Pip / entry-point plugins\n         manifests.extend(self._scan_entry_points())"]},{"file":"run_agent.py","hunks":["@@ -6807,20 +6807,18 @@ def _stop_spinner():\n                         pending_handled = True\n                     break\n                 \n-                if not pending_handled:\n-                    # Error happened before tool processing (e.g. response parsing).\n-                    # Choose role to avoid consecutive same-role messages.\n-                    last_role = messages[-1].get(\"role\") if messages else None\n-                    err_role = \"assistant\" if last_role == \"user\" else \"user\"\n-                    sys_err_msg = {\n-                        \"role\": err_role,\n-                        \"content\": f\"[System error during processing: {error_msg}]\",\n-                    }\n-                    messages.append(sys_err_msg)\n-                \n+                # Non-tool errors don't need a synthetic message injected.\n+                # The error is already printed to the user (line above), and\n+                # the retry loop continues.  Injecting a fake user/assistant\n+                # message pollutes history, burns tokens, and risks violating\n+                # role-alternation invariants.\n+\n                 # If we're near the limit, break to avoid infinite loops\n                 if api_call_count >= self.max_iterations - 1:\n                     final_response = f\"I apologize, but I encountered repeated errors: {error_msg}\"\n+                    # Append as assistant so the history stays valid for\n+                    # session resume (avoids consecutive user messages).\n+                    messages.append({\"role\": \"assistant\", \"content\": final_response})\n                     break\n         \n         if final_response is None and ("]},{"file":"tests/test_plugins.py","hunks":["@@ -67,6 +67,7 @@ def test_discover_project_plugins(self, tmp_path, monkeypatch):\n         project_dir = tmp_path / \"project\"\n         project_dir.mkdir()\n         monkeypatch.chdir(project_dir)\n+        monkeypatch.setenv(\"HERMES_ENABLE_PROJECT_PLUGINS\", \"true\")\n         plugins_dir = project_dir / \".hermes\" / \"plugins\"\n         _make_plugin_dir(plugins_dir, \"proj_plugin\")\n ","@@ -76,6 +77,19 @@ def test_discover_project_plugins(self, tmp_path, monkeypatch):\n         assert \"proj_plugin\" in mgr._plugins\n         assert mgr._plugins[\"proj_plugin\"].enabled\n \n+    def test_discover_project_plugins_skipped_by_default(self, tmp_path, monkeypatch):\n+        \"\"\"Project plugins are not discovered unless explicitly enabled.\"\"\"\n+        project_dir = tmp_path / \"project\"\n+        project_dir.mkdir()\n+        monkeypatch.chdir(project_dir)\n+        plugins_dir = project_dir / \".hermes\" / \"plugins\"\n+        _make_plugin_dir(plugins_dir, \"proj_plugin\")\n+\n+        mgr = PluginManager()\n+        mgr.discover_and_load()\n+\n+        assert \"proj_plugin\" not in mgr._plugins\n+\n     def test_discover_is_idempotent(self, tmp_path, monkeypatch):\n         \"\"\"Calling discover_and_load() twice does not duplicate plugins.\"\"\"\n         plugins_dir = tmp_path / \"hermes_test\" / \"plugins\""]},{"file":"website/docs/reference/environment-variables.md","hunks":["@@ -232,6 +232,7 @@ For native Anthropic auth, Hermes prefers Claude Code's own credential files whe\n | `HERMES_QUIET` | Suppress non-essential output (`true`/`false`) |\n | `HERMES_API_TIMEOUT` | LLM API call timeout in seconds (default: `900`) |\n | `HERMES_EXEC_ASK` | Enable execution approval prompts in gateway mode (`true`/`false`) |\n+| `HERMES_ENABLE_PROJECT_PLUGINS` | Enable auto-discovery of repo-local plugins from `./.hermes/plugins/` (`true`/`false`, default: `false`) |\n | `HERMES_BACKGROUND_NOTIFICATIONS` | Background process notification mode in gateway: `all` (default), `result`, `error`, `off` |\n | `HERMES_EPHEMERAL_SYSTEM_PROMPT` | Ephemeral system prompt injected at API-call time (never persisted to sessions) |\n "]},{"file":"website/docs/user-guide/features/plugins.md","hunks":["@@ -22,6 +22,8 @@ Drop a directory into `~/.hermes/plugins/` with a `plugin.yaml` and Python code:\n \n Start Hermes — your tools appear alongside built-in tools. The model can call them immediately.\n \n+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.\n+\n ## What plugins can do\n \n | Capability | How |","@@ -38,7 +40,7 @@ Start Hermes — your tools appear alongside built-in tools. The model can call\n | Source | Path | Use case |\n |--------|------|----------|\n | User | `~/.hermes/plugins/` | Personal plugins |\n-| Project | `.hermes/plugins/` | Project-specific plugins |\n+| Project | `.hermes/plugins/` | Project-specific plugins (requires `HERMES_ENABLE_PROJECT_PLUGINS=true`) |\n | pip | `hermes_agent.plugins` entry_points | Distributed packages |\n \n ## Available hooks\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}