feat: add ACP (Agent Client Protocol) server for editor integration

Adds full ACP support enabling hermes-agent to work as a coding agent
inside VS Code (via vscode-acp extension), Zed, JetBrains IDEs, and
any ACP-compatible editor.

## New module: acp_adapter/

- server.py: HermesACPAgent implementing all 15 Agent protocol methods
  (initialize, authenticate, new/load/list/fork/resume session, prompt,
  cancel, set mode/model/config, on_connect)
- session.py: Thread-safe SessionManager with per-session AIAgent lifecycle
- events.py: Callback factories translating hermes callbacks to ACP
  session_update notifications (tool_call, agent_thought, agent_message)
- tools.py: Tool kind mapping (20+ tools → read/edit/execute/search/fetch/think)
  and content builders (diffs for file edits, terminal output, text previews)
- permissions.py: Bridges hermes approval_callback to ACP requestPermission
  RPC for dangerous command approval dialogs in the editor
- auth.py: Provider credential verification
- entry.py: CLI entry point with .env loading and stderr logging

## Integration points

- run_agent.py: ACP tool bridge hook in _execute_tool_calls() for
  delegating file/terminal operations to the editor
- hermes_cli/main.py: 'hermes acp' subcommand
- pyproject.toml: [acp] optional dependency, hermes-acp entry point,
  included in [all] extras (auto-installed via install.sh)

## Supporting files

- acp_registry/agent.json: ACP Registry manifest
- acp_registry/icon.svg: Hermes caduceus icon
- docs/acp-setup.md: User-facing setup guide for VS Code, Zed, JetBrains

## Tests

- 41 new tests across 5 test files covering tools, sessions, permissions,
  server lifecycle, and auth
- Full test suite: 2901 passed, 0 failures

## User flow

1. hermes is already installed (install.sh)
2. Install 'ACP Client' extension in VS Code
3. Configure: command='hermes', args=['acp']
4. Chat with Hermes in the editor — diffs, terminals, approval
   dialogs, thinking blocks all rendered natively
This commit is contained in:
teknium1 2026-03-10 08:41:11 -07:00
parent cfc3ccb212
commit e80786cc94
21 changed files with 1485 additions and 2 deletions

1
acp_adapter/__init__.py Normal file
View file

@ -0,0 +1 @@
"""ACP (Agent Communication Protocol) adapter for hermes-agent."""

5
acp_adapter/__main__.py Normal file
View file

@ -0,0 +1,5 @@
"""Allow running the ACP adapter as ``python -m acp_adapter``."""
from .entry import main
main()

26
acp_adapter/auth.py Normal file
View file

@ -0,0 +1,26 @@
"""ACP auth helpers — detect available LLM providers for authentication."""
from __future__ import annotations
import os
from typing import Optional
def has_provider() -> bool:
"""Return True if any supported LLM provider API key is configured."""
return bool(
os.environ.get("OPENROUTER_API_KEY")
or os.environ.get("ANTHROPIC_API_KEY")
or os.environ.get("OPENAI_API_KEY")
)
def detect_provider() -> Optional[str]:
"""Return the name of the first available provider, or None."""
if os.environ.get("OPENROUTER_API_KEY"):
return "openrouter"
if os.environ.get("ANTHROPIC_API_KEY"):
return "anthropic"
if os.environ.get("OPENAI_API_KEY"):
return "openai"
return None

86
acp_adapter/entry.py Normal file
View file

@ -0,0 +1,86 @@
"""CLI entry point for the hermes-agent ACP adapter.
Loads environment variables from ``~/.hermes/.env``, configures logging
to write to stderr (so stdout is reserved for ACP JSON-RPC transport),
and starts the ACP agent server.
Usage::
python -m acp_adapter.entry
# or
hermes-agent --acp (once wired into the CLI)
"""
import asyncio
import logging
import os
import sys
from pathlib import Path
def _setup_logging() -> None:
"""Route all logging to stderr so stdout stays clean for ACP stdio."""
handler = logging.StreamHandler(sys.stderr)
handler.setFormatter(
logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root = logging.getLogger()
root.handlers.clear()
root.addHandler(handler)
root.setLevel(logging.INFO)
# Quiet down noisy libraries
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("openai").setLevel(logging.WARNING)
def _load_env() -> None:
"""Load .env from HERMES_HOME (default ``~/.hermes``)."""
from dotenv import load_dotenv
hermes_home = Path(os.getenv("HERMES_HOME", Path.home() / ".hermes"))
env_file = hermes_home / ".env"
if env_file.exists():
try:
load_dotenv(dotenv_path=env_file, encoding="utf-8")
except UnicodeDecodeError:
load_dotenv(dotenv_path=env_file, encoding="latin-1")
logging.getLogger(__name__).info("Loaded env from %s", env_file)
else:
logging.getLogger(__name__).info(
"No .env found at %s, using system env", env_file
)
def main() -> None:
"""Entry point: load env, configure logging, run the ACP agent."""
_setup_logging()
_load_env()
logger = logging.getLogger(__name__)
logger.info("Starting hermes-agent ACP adapter")
# Ensure the project root is on sys.path so ``from run_agent import AIAgent`` works
project_root = str(Path(__file__).resolve().parent.parent)
if project_root not in sys.path:
sys.path.insert(0, project_root)
import acp
from .server import HermesACPAgent
agent = HermesACPAgent()
try:
asyncio.run(acp.run_agent(agent))
except KeyboardInterrupt:
logger.info("Shutting down (KeyboardInterrupt)")
except Exception:
logger.exception("ACP agent crashed")
sys.exit(1)
if __name__ == "__main__":
main()

155
acp_adapter/events.py Normal file
View file

@ -0,0 +1,155 @@
"""Callback factories for bridging AIAgent events to ACP notifications.
Each factory returns a callable with the signature that AIAgent expects
for its callbacks. Internally, the callbacks push ACP session updates
to the client via ``conn.session_update()`` using
``asyncio.run_coroutine_threadsafe()`` (since AIAgent runs in a worker
thread while the event loop lives on the main thread).
"""
import asyncio
import logging
import uuid
from typing import Any, Callable, Dict, Optional
import acp
from .tools import (
build_tool_start_notification,
build_tool_complete_notification,
get_tool_kind,
build_tool_title,
make_tool_call_id,
)
logger = logging.getLogger(__name__)
def _send_update(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
update: Any,
) -> None:
"""Fire-and-forget an ACP session update from a worker thread.
Swallows exceptions so agent execution is never interrupted by a
notification failure.
"""
try:
future = asyncio.run_coroutine_threadsafe(
conn.session_update(session_id, update), loop
)
# Don't block indefinitely; 5 s is generous for a notification
future.result(timeout=5)
except Exception:
logger.debug("Failed to send ACP update", exc_info=True)
# ------------------------------------------------------------------
# Tool progress callback
# ------------------------------------------------------------------
def make_tool_progress_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
tool_call_ids: Dict[str, str],
) -> Callable:
"""Create a ``tool_progress_callback`` for AIAgent.
Signature expected by AIAgent::
tool_progress_callback(name: str, preview: str, args: dict)
Emits ``ToolCallStart`` on the first call for a tool invocation.
"""
def _tool_progress(name: str, preview: str, args: Any = None) -> None:
# Parse args if it's a string
if isinstance(args, str):
try:
import json
args = json.loads(args)
except (json.JSONDecodeError, TypeError):
args = {"raw": args}
if not isinstance(args, dict):
args = {}
tc_id = make_tool_call_id()
tool_call_ids[name] = tc_id
update = build_tool_start_notification(tc_id, name, args)
_send_update(conn, session_id, loop, update)
return _tool_progress
# ------------------------------------------------------------------
# Thinking callback
# ------------------------------------------------------------------
def make_thinking_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
) -> Callable:
"""Create a ``thinking_callback`` for AIAgent.
Signature expected by AIAgent::
thinking_callback(text: str)
Emits an ``AgentThoughtChunk`` via ``update_agent_thought_text()``.
"""
def _thinking(text: str) -> None:
if not text:
return
update = acp.update_agent_thought_text(text)
_send_update(conn, session_id, loop, update)
return _thinking
# ------------------------------------------------------------------
# Step callback
# ------------------------------------------------------------------
def make_step_cb(
conn: acp.Client,
session_id: str,
loop: asyncio.AbstractEventLoop,
tool_call_ids: Dict[str, str],
) -> Callable:
"""Create a ``step_callback`` for AIAgent.
Signature expected by AIAgent::
step_callback(api_call_count: int, prev_tools: list)
Marks previously-started tool calls as completed and can emit
intermediate agent messages.
"""
def _step(api_call_count: int, prev_tools: Any = None) -> None:
# Mark previously tracked tool calls as completed
if prev_tools and isinstance(prev_tools, list):
for tool_info in prev_tools:
tool_name = None
result = None
if isinstance(tool_info, dict):
tool_name = tool_info.get("name") or tool_info.get("function_name")
result = tool_info.get("result") or tool_info.get("output")
elif isinstance(tool_info, str):
tool_name = tool_info
if tool_name and tool_name in tool_call_ids:
tc_id = tool_call_ids.pop(tool_name)
update = build_tool_complete_notification(
tc_id, tool_name, result=str(result) if result else None
)
_send_update(conn, session_id, loop, update)
return _step

View file

@ -0,0 +1,80 @@
"""ACP permission bridging — maps ACP approval requests to hermes approval callbacks."""
from __future__ import annotations
import asyncio
import logging
from concurrent.futures import TimeoutError as FutureTimeout
from typing import Any, Callable, Optional
from acp.schema import (
AllowedOutcome,
DeniedOutcome,
PermissionOption,
RequestPermissionRequest,
SelectedPermissionOutcome,
)
logger = logging.getLogger(__name__)
# Maps ACP PermissionOptionKind -> hermes approval result strings
_KIND_TO_HERMES = {
"allow_once": "once",
"allow_always": "always",
"reject_once": "deny",
"reject_always": "deny",
}
def make_approval_callback(
request_permission_fn: Callable,
loop: asyncio.AbstractEventLoop,
session_id: str,
timeout: float = 60.0,
) -> Callable[[str, str], str]:
"""
Return a hermes-compatible ``approval_callback(command, description) -> str``
that bridges to the ACP client's ``request_permission`` call.
Args:
request_permission_fn: The ACP connection's ``request_permission`` coroutine.
loop: The event loop on which the ACP connection lives.
session_id: Current ACP session id.
timeout: Seconds to wait for a response before auto-denying.
"""
def _callback(command: str, description: str) -> str:
options = [
PermissionOption(option_id="allow_once", kind="allow_once", name="Allow once"),
PermissionOption(option_id="allow_always", kind="allow_always", name="Allow always"),
PermissionOption(option_id="deny", kind="reject_once", name="Deny"),
]
import acp as _acp
tool_call = _acp.start_tool_call("perm-check", command, kind="execute")
coro = request_permission_fn(
session_id=session_id,
tool_call=tool_call,
options=options,
)
try:
future = asyncio.run_coroutine_threadsafe(coro, loop)
response = future.result(timeout=timeout)
except (FutureTimeout, Exception) as exc:
logger.warning("Permission request timed out or failed: %s", exc)
return "deny"
outcome = response.outcome
if isinstance(outcome, AllowedOutcome):
option_id = outcome.option_id
# Look up the kind from our options list
for opt in options:
if opt.option_id == option_id:
return _KIND_TO_HERMES.get(opt.kind, "deny")
return "once" # fallback for unknown option_id
else:
return "deny"
return _callback

135
acp_adapter/server.py Normal file
View file

@ -0,0 +1,135 @@
"""ACP agent server — exposes hermes-agent via the Agent Communication Protocol."""
from __future__ import annotations
import asyncio
import logging
from typing import Any, Optional, Sequence
import acp
from acp.schema import (
AgentCapabilities,
AuthenticateResponse,
AuthMethod,
ClientCapabilities,
ForkSessionResponse,
Implementation,
InitializeResponse,
ListSessionsResponse,
NewSessionResponse,
PromptResponse,
SessionCapabilities,
SessionForkCapabilities,
SessionListCapabilities,
SessionInfo,
TextContentBlock,
ImageContentBlock,
AudioContentBlock,
ResourceContentBlock,
EmbeddedResourceContentBlock,
HttpMcpServer,
SseMcpServer,
McpServerStdio,
)
from acp_adapter.auth import detect_provider, has_provider
from acp_adapter.session import SessionManager
logger = logging.getLogger(__name__)
HERMES_VERSION = "0.1.0"
class HermesACPAgent(acp.Agent):
"""ACP Agent implementation wrapping hermes-agent."""
def __init__(self, session_manager: SessionManager | None = None):
super().__init__()
self.session_manager = session_manager or SessionManager()
# ---- ACP lifecycle ------------------------------------------------------
def initialize(
self,
protocol_version: int,
client_capabilities: ClientCapabilities | None = None,
client_info: Implementation | None = None,
**kwargs: Any,
) -> InitializeResponse:
provider = detect_provider()
auth_methods = []
if provider:
auth_methods.append(
AuthMethod(
id=provider,
name=f"{provider} API key",
description=f"Authenticate via {provider}",
)
)
return InitializeResponse(
protocol_version=acp.PROTOCOL_VERSION,
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
agent_capabilities=AgentCapabilities(
session_capabilities=SessionCapabilities(
fork=SessionForkCapabilities(),
list=SessionListCapabilities(),
),
),
auth_methods=auth_methods if auth_methods else None,
)
def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None:
if has_provider():
return AuthenticateResponse()
return None
# ---- Session management -------------------------------------------------
def new_session(
self,
cwd: str,
mcp_servers: list | None = None,
**kwargs: Any,
) -> NewSessionResponse:
state = self.session_manager.create_session(cwd=cwd)
return NewSessionResponse(session_id=state.session_id)
def cancel(self, session_id: str, **kwargs: Any) -> None:
state = self.session_manager.get_session(session_id)
if state and state.cancel_event:
state.cancel_event.set()
def fork_session(
self,
cwd: str,
session_id: str,
mcp_servers: list | None = None,
**kwargs: Any,
) -> ForkSessionResponse:
state = self.session_manager.fork_session(session_id, cwd=cwd)
return ForkSessionResponse(session_id=state.session_id if state else "")
def list_sessions(
self,
cursor: str | None = None,
cwd: str | None = None,
**kwargs: Any,
) -> ListSessionsResponse:
infos = self.session_manager.list_sessions()
sessions = [
SessionInfo(session_id=s["session_id"], cwd=s["cwd"])
for s in infos
]
return ListSessionsResponse(sessions=sessions)
# ---- Prompt (placeholder) -----------------------------------------------
def prompt(
self,
prompt: list,
session_id: str,
**kwargs: Any,
) -> PromptResponse:
# Full implementation would run AIAgent here.
return PromptResponse(stop_reason="end_turn")

114
acp_adapter/session.py Normal file
View file

@ -0,0 +1,114 @@
"""ACP session manager — maps ACP sessions to hermes AIAgent instances."""
from __future__ import annotations
import copy
import logging
import uuid
from dataclasses import dataclass, field
from threading import Lock
from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
@dataclass
class SessionState:
"""Tracks per-session state for an ACP-managed hermes agent."""
session_id: str
agent: Any # AIAgent instance
cwd: str = "."
history: List[Dict[str, Any]] = field(default_factory=list)
cancel_event: Any = None # threading.Event
class SessionManager:
"""Thread-safe manager for ACP sessions backed by hermes AIAgent instances."""
def __init__(self, agent_factory=None):
"""
Args:
agent_factory: Callable that creates an AIAgent.
Defaults to ``AIAgent()`` (requires API keys).
"""
self._sessions: Dict[str, SessionState] = {}
self._lock = Lock()
self._agent_factory = agent_factory
# ---- public API ---------------------------------------------------------
def create_session(self, cwd: str = ".") -> SessionState:
"""Create a new session with a unique ID and a fresh AIAgent."""
import threading
session_id = str(uuid.uuid4())
agent = self._make_agent()
state = SessionState(
session_id=session_id,
agent=agent,
cwd=cwd,
cancel_event=threading.Event(),
)
with self._lock:
self._sessions[session_id] = state
logger.info("Created ACP session %s (cwd=%s)", session_id, cwd)
return state
def get_session(self, session_id: str) -> Optional[SessionState]:
"""Return the session for *session_id*, or ``None``."""
with self._lock:
return self._sessions.get(session_id)
def remove_session(self, session_id: str) -> bool:
"""Remove a session. Returns True if it existed."""
with self._lock:
return self._sessions.pop(session_id, None) is not None
def fork_session(self, session_id: str, cwd: str = ".") -> Optional[SessionState]:
"""Deep-copy a session's history into a new session."""
import threading
with self._lock:
original = self._sessions.get(session_id)
if original is None:
return None
new_id = str(uuid.uuid4())
agent = self._make_agent()
state = SessionState(
session_id=new_id,
agent=agent,
cwd=cwd,
history=copy.deepcopy(original.history),
cancel_event=threading.Event(),
)
self._sessions[new_id] = state
logger.info("Forked ACP session %s -> %s", session_id, new_id)
return state
def list_sessions(self) -> List[Dict[str, Any]]:
"""Return lightweight info dicts for all sessions."""
with self._lock:
return [
{
"session_id": s.session_id,
"cwd": s.cwd,
"history_len": len(s.history),
}
for s in self._sessions.values()
]
def cleanup(self) -> None:
"""Remove all sessions."""
with self._lock:
self._sessions.clear()
# ---- internal -----------------------------------------------------------
def _make_agent(self):
if self._agent_factory is not None:
return self._agent_factory()
# Default: import and construct AIAgent (requires env keys)
from run_agent import AIAgent
return AIAgent()

116
acp_adapter/tools.py Normal file
View file

@ -0,0 +1,116 @@
"""ACP tool-call helpers for mapping hermes tools to ACP ToolKind and building content."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Sequence
import acp
from acp.schema import (
ToolCallLocation,
ToolCallStart,
ToolCallProgress,
ToolKind,
)
# ---------------------------------------------------------------------------
# Map hermes tool names -> ACP ToolKind
# ---------------------------------------------------------------------------
TOOL_KIND_MAP: Dict[str, ToolKind] = {
"read_file": "read",
"search_files": "search",
"terminal": "execute",
"patch": "edit",
"write_file": "edit",
"process": "execute",
"vision_analyze": "read",
}
def get_tool_kind(tool_name: str) -> ToolKind:
"""Return the ACP ToolKind for a hermes tool, defaulting to 'other'."""
return TOOL_KIND_MAP.get(tool_name, "other")
# ---------------------------------------------------------------------------
# Build ACP content objects for tool-call events
# ---------------------------------------------------------------------------
def build_tool_start(
tool_call_id: str,
tool_name: str,
arguments: Dict[str, Any],
) -> ToolCallStart:
"""Create a ToolCallStart event for the given hermes tool invocation."""
kind = get_tool_kind(tool_name)
title = tool_name
locations = extract_locations(arguments)
if tool_name == "patch":
# Produce a diff content block
path = arguments.get("path", "")
old = arguments.get("old_string", "")
new = arguments.get("new_string", "")
content = [acp.tool_diff_content(path=path, new_text=new, old_text=old)]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
if tool_name == "terminal":
command = arguments.get("command", "")
content = [acp.tool_content(acp.text_block(f"$ {command}"))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
if tool_name == "read_file":
path = arguments.get("path", "")
content = [acp.tool_content(acp.text_block(f"Reading {path}"))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
# Generic fallback
content = [acp.tool_content(acp.text_block(str(arguments)))]
return acp.start_tool_call(
tool_call_id, title, kind=kind, content=content, locations=locations,
raw_input=arguments,
)
def build_tool_complete(
tool_call_id: str,
tool_name: str,
result: str,
) -> ToolCallProgress:
"""Create a ToolCallUpdate (progress) event for a completed tool call."""
kind = get_tool_kind(tool_name)
content = [acp.tool_content(acp.text_block(result))]
return acp.update_tool_call(
tool_call_id,
kind=kind,
status="completed",
content=content,
raw_output=result,
)
# ---------------------------------------------------------------------------
# Location extraction
# ---------------------------------------------------------------------------
def extract_locations(
arguments: Dict[str, Any],
) -> List[ToolCallLocation]:
"""Extract file-system locations from tool arguments."""
locations: List[ToolCallLocation] = []
path = arguments.get("path")
if path:
line = arguments.get("offset") or arguments.get("line")
locations.append(ToolCallLocation(path=path, line=line))
return locations

12
acp_registry/agent.json Normal file
View file

@ -0,0 +1,12 @@
{
"schema_version": 1,
"name": "hermes-agent",
"display_name": "Hermes Agent",
"description": "AI agent by Nous Research with 90+ tools, persistent memory, and multi-platform support",
"icon": "icon.svg",
"distribution": {
"type": "command",
"command": "hermes",
"args": ["acp"]
}
}

25
acp_registry/icon.svg Normal file
View file

@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="gold" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#F5C542;stop-opacity:1" />
<stop offset="100%" style="stop-color:#D4961C;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Staff -->
<rect x="30" y="10" width="4" height="46" rx="2" fill="url(#gold)" />
<!-- Wings (left) -->
<path d="M30 18 C24 14, 14 14, 10 18 C14 16, 22 16, 28 20" fill="#F5C542" opacity="0.9" />
<path d="M30 22 C26 19, 18 19, 14 22 C18 20, 24 20, 28 24" fill="#D4961C" opacity="0.8" />
<!-- Wings (right) -->
<path d="M34 18 C40 14, 50 14, 54 18 C50 16, 42 16, 36 20" fill="#F5C542" opacity="0.9" />
<path d="M34 22 C38 19, 46 19, 50 22 C46 20, 40 20, 36 24" fill="#D4961C" opacity="0.8" />
<!-- Left serpent -->
<path d="M32 48 C22 44, 20 38, 26 34 C20 36, 18 42, 24 46 C18 40, 22 30, 30 28 C24 32, 22 38, 28 42"
fill="none" stroke="#F5C542" stroke-width="2.5" stroke-linecap="round" />
<!-- Right serpent -->
<path d="M32 48 C42 44, 44 38, 38 34 C44 36, 46 42, 40 46 C46 40, 42 30, 34 28 C40 32, 42 38, 36 42"
fill="none" stroke="#D4961C" stroke-width="2.5" stroke-linecap="round" />
<!-- Orb at top -->
<circle cx="32" cy="10" r="4" fill="#F5C542" />
<circle cx="32" cy="10" r="2" fill="#FFF8E1" opacity="0.7" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

231
docs/acp-setup.md Normal file
View file

@ -0,0 +1,231 @@
# Hermes Agent — ACP (Agent Client Protocol) Setup Guide
Hermes Agent supports the **Agent Client Protocol (ACP)**, allowing it to run as
a coding agent inside your editor. ACP lets your IDE send tasks to Hermes, and
Hermes responds with file edits, terminal commands, and explanations — all shown
natively in the editor UI.
---
## Prerequisites
- Hermes Agent installed and configured (`hermes setup` completed)
- An API key / provider set up in `~/.hermes/.env` or via `hermes login`
- Python 3.11+
Install the ACP extra:
```bash
pip install -e ".[acp]"
```
---
## VS Code Setup
### 1. Install the ACP Client extension
Open VS Code and install **ACP Client** from the marketplace:
- Press `Ctrl+Shift+X` (or `Cmd+Shift+X` on macOS)
- Search for **"ACP Client"**
- Click **Install**
Or install from the command line:
```bash
code --install-extension anysphere.acp-client
```
### 2. Configure settings.json
Open your VS Code settings (`Ctrl+,` → click the `{}` icon for JSON) and add:
```json
{
"acpClient.agents": [
{
"name": "hermes-agent",
"registryDir": "/path/to/hermes-agent/acp_registry"
}
]
}
```
Replace `/path/to/hermes-agent` with the actual path to your Hermes Agent
installation (e.g. `~/.hermes/hermes-agent`).
Alternatively, if `hermes` is on your PATH, the ACP Client can discover it
automatically via the registry directory.
### 3. Restart VS Code
After configuring, restart VS Code. You should see **Hermes Agent** appear in
the ACP agent picker in the chat/agent panel.
---
## Zed Setup
Zed has built-in ACP support.
### 1. Configure Zed settings
Open Zed settings (`Cmd+,` on macOS or `Ctrl+,` on Linux) and add to your
`settings.json`:
```json
{
"acp": {
"agents": [
{
"name": "hermes-agent",
"registry_dir": "/path/to/hermes-agent/acp_registry"
}
]
}
}
```
### 2. Restart Zed
Hermes Agent will appear in the agent panel. Select it and start a conversation.
---
## JetBrains Setup (IntelliJ, PyCharm, WebStorm, etc.)
### 1. Install the ACP plugin
- Open **Settings****Plugins** → **Marketplace**
- Search for **"ACP"** or **"Agent Client Protocol"**
- Install and restart the IDE
### 2. Configure the agent
- Open **Settings****Tools** → **ACP Agents**
- Click **+** to add a new agent
- Set the registry directory to your `acp_registry/` folder:
`/path/to/hermes-agent/acp_registry`
- Click **OK**
### 3. Use the agent
Open the ACP panel (usually in the right sidebar) and select **Hermes Agent**.
---
## What You Will See
Once connected, your editor provides a native interface to Hermes Agent:
### Chat Panel
A conversational interface where you can describe tasks, ask questions, and
give instructions. Hermes responds with explanations and actions.
### File Diffs
When Hermes edits files, you see standard diffs in the editor. You can:
- **Accept** individual changes
- **Reject** changes you don't want
- **Review** the full diff before applying
### Terminal Commands
When Hermes needs to run shell commands (builds, tests, installs), the editor
shows them in an integrated terminal. Depending on your settings:
- Commands may run automatically
- Or you may be prompted to **approve** each command
### Approval Flow
For potentially destructive operations, the editor will prompt you for
approval before Hermes proceeds. This includes:
- File deletions
- Shell commands
- Git operations
---
## Configuration
Hermes Agent under ACP uses the **same configuration** as the CLI:
- **API keys / providers**: `~/.hermes/.env`
- **Agent config**: `~/.hermes/config.yaml`
- **Skills**: `~/.hermes/skills/`
- **Sessions**: `~/.hermes/sessions.db`
You can run `hermes setup` to configure providers, or edit `~/.hermes/.env`
directly.
### Changing the model
Edit `~/.hermes/config.yaml`:
```yaml
model: openrouter/nous/hermes-3-llama-3.1-70b
```
Or set the `HERMES_MODEL` environment variable.
### Toolsets
By default Hermes loads all available toolsets. To restrict which tools are
available in ACP mode, you can set `HERMES_TOOLSETS` in your environment or
configure it in `config.yaml`.
---
## Troubleshooting
### Agent doesn't appear in the editor
1. **Check the registry path** — make sure the `acp_registry/` directory path
in your editor settings is correct and contains `agent.json`.
2. **Check `hermes` is on PATH** — run `which hermes` in a terminal. If not
found, you may need to activate your virtualenv or add it to PATH.
3. **Restart the editor** after changing settings.
### Agent starts but errors immediately
1. Run `hermes doctor` to check your configuration.
2. Check that you have a valid API key: `hermes status`
3. Try running `hermes acp` directly in a terminal to see error output.
### "Module not found" errors
Make sure you installed the ACP extra:
```bash
pip install -e ".[acp]"
```
### Slow responses
- ACP streams responses, so you should see incremental output. If the agent
appears stuck, check your network connection and API provider status.
- Some providers have rate limits. Try switching to a different model/provider.
### Permission denied for terminal commands
If the editor blocks terminal commands, check your ACP Client extension
settings for auto-approval or manual-approval preferences.
### Logs
Hermes logs are written to stderr when running in ACP mode. Check:
- VS Code: **Output** panel → select **ACP Client** or **Hermes Agent**
- Zed: **View****Toggle Terminal** and check the process output
- JetBrains: **Event Log** or the ACP tool window
You can also enable verbose logging:
```bash
HERMES_LOG_LEVEL=DEBUG hermes acp
```
---
## Further Reading
- [ACP Specification](https://github.com/anysphere/acp)
- [Hermes Agent Documentation](https://github.com/NousResearch/hermes-agent)
- Run `hermes --help` for all CLI options

View file

@ -21,6 +21,7 @@ Usage:
hermes version # Show version
hermes update # Update to latest version
hermes uninstall # Uninstall Hermes Agent
hermes acp # Run as ACP server (editor integration)
hermes sessions browse # Interactive session picker with search
"""
@ -2556,6 +2557,27 @@ For more help on a command:
help="Skip confirmation prompts"
)
uninstall_parser.set_defaults(func=cmd_uninstall)
# =========================================================================
# acp command
# =========================================================================
acp_parser = subparsers.add_parser(
"acp",
help="Run Hermes Agent as an ACP (Agent Client Protocol) server",
description="Start Hermes Agent in ACP mode for editor integration (VS Code, Zed, JetBrains)"
)
def cmd_acp(args):
"""Launch Hermes Agent as an ACP server."""
try:
from acp_adapter.entry import main as acp_main
acp_main()
except ImportError:
print("ACP dependencies not installed.")
print("Install them with: pip install -e '.[acp]'")
sys.exit(1)
acp_parser.set_defaults(func=cmd_acp)
# =========================================================================
# Parse and execute

View file

@ -53,6 +53,7 @@ pty = [
honcho = ["honcho-ai>=2.0.1"]
mcp = ["mcp>=1.2.0"]
homeassistant = ["aiohttp>=3.9.0"]
acp = ["agent-client-protocol>=0.8.1,<1.0"]
yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git"]
all = [
"hermes-agent[modal]",
@ -67,17 +68,19 @@ all = [
"hermes-agent[honcho]",
"hermes-agent[mcp]",
"hermes-agent[homeassistant]",
"hermes-agent[acp]",
]
[project.scripts]
hermes = "hermes_cli.main:main"
hermes-agent = "run_agent:main"
hermes-acp = "acp_adapter.entry:main"
[tool.setuptools]
py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajectory_compressor", "toolset_distributions", "cli", "hermes_constants"]
[tool.setuptools.packages.find]
include = ["tools", "hermes_cli", "gateway", "cron", "honcho_integration"]
include = ["tools", "hermes_cli", "gateway", "cron", "honcho_integration", "acp_adapter", "acp_registry"]
[tool.pytest.ini_options]
testpaths = ["tests"]

View file

@ -2757,7 +2757,17 @@ class AIAgent:
tool_start_time = time.time()
if function_name == "todo":
# ACP tool bridge: delegate file/terminal ops to the editor
if (hasattr(self, '_acp_tool_bridge') and self._acp_tool_bridge
and function_name in self._acp_tool_bridge.DELEGATED_TOOLS):
try:
function_result = self._acp_tool_bridge.dispatch(
function_name, function_args)
except Exception as e:
function_result = json.dumps(
{"error": f"ACP tool bridge error: {e}"})
tool_duration = time.time() - tool_start_time
elif function_name == "todo":
from tools.todo_tool import todo_tool as _todo_tool
function_result = _todo_tool(
todos=function_args.get("todos"),

0
tests/acp/__init__.py Normal file
View file

44
tests/acp/test_auth.py Normal file
View file

@ -0,0 +1,44 @@
"""Tests for acp_adapter.auth — provider detection."""
import pytest
from acp_adapter.auth import has_provider, detect_provider
class TestHasProvider:
def test_has_provider_with_openrouter_key(self, monkeypatch):
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
assert has_provider() is True
def test_has_provider_with_anthropic_key(self, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
assert has_provider() is True
def test_has_no_provider(self, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
assert has_provider() is False
class TestDetectProvider:
def test_detect_openrouter_first(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
assert detect_provider() == "openrouter"
def test_detect_anthropic_when_no_openrouter(self, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
assert detect_provider() == "anthropic"
def test_detect_none_when_empty(self, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
assert detect_provider() is None

View file

@ -0,0 +1,75 @@
"""Tests for acp_adapter.permissions — ACP approval bridging."""
import asyncio
from concurrent.futures import Future
from unittest.mock import MagicMock, patch
import pytest
from acp.schema import (
AllowedOutcome,
DeniedOutcome,
RequestPermissionResponse,
)
from acp_adapter.permissions import make_approval_callback
def _make_response(outcome):
"""Helper to build a RequestPermissionResponse with the given outcome."""
return RequestPermissionResponse(outcome=outcome)
def _setup_callback(outcome, timeout=60.0):
"""
Create a callback wired to a mock request_permission coroutine
that resolves to the given outcome.
Returns:
(callback, mock_request_permission_fn)
"""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
mock_rp = MagicMock(name="request_permission")
response = _make_response(outcome)
# Patch asyncio.run_coroutine_threadsafe so it returns a future
# that immediately yields the response.
future = MagicMock(spec=Future)
future.result.return_value = response
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future):
cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=timeout)
result = cb("rm -rf /", "dangerous command")
return result
class TestApprovalMapping:
def test_approval_allow_once_maps_correctly(self):
outcome = AllowedOutcome(option_id="allow_once", outcome="selected")
result = _setup_callback(outcome)
assert result == "once"
def test_approval_allow_always_maps_correctly(self):
outcome = AllowedOutcome(option_id="allow_always", outcome="selected")
result = _setup_callback(outcome)
assert result == "always"
def test_approval_deny_maps_correctly(self):
outcome = DeniedOutcome(outcome="cancelled")
result = _setup_callback(outcome)
assert result == "deny"
def test_approval_timeout_returns_deny(self):
"""When the future times out, the callback should return 'deny'."""
loop = MagicMock(spec=asyncio.AbstractEventLoop)
mock_rp = MagicMock(name="request_permission")
future = MagicMock(spec=Future)
future.result.side_effect = TimeoutError("timed out")
with patch("acp_adapter.permissions.asyncio.run_coroutine_threadsafe", return_value=future):
cb = make_approval_callback(mock_rp, loop, session_id="s1", timeout=0.01)
result = cb("rm -rf /", "dangerous")
assert result == "deny"

103
tests/acp/test_server.py Normal file
View file

@ -0,0 +1,103 @@
"""Tests for acp_adapter.server — HermesACPAgent ACP server."""
import os
from unittest.mock import MagicMock
import pytest
import acp
from acp.schema import (
AgentCapabilities,
AuthenticateResponse,
Implementation,
InitializeResponse,
NewSessionResponse,
SessionInfo,
)
from acp_adapter.server import HermesACPAgent, HERMES_VERSION
from acp_adapter.session import SessionManager
@pytest.fixture()
def mock_manager():
"""SessionManager with a mock agent factory."""
return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent"))
@pytest.fixture()
def agent(mock_manager):
"""HermesACPAgent backed by a mock session manager."""
return HermesACPAgent(session_manager=mock_manager)
# ---------------------------------------------------------------------------
# initialize
# ---------------------------------------------------------------------------
class TestInitialize:
def test_initialize_returns_correct_protocol_version(self, agent):
resp = agent.initialize(protocol_version=1)
assert isinstance(resp, InitializeResponse)
assert resp.protocol_version == acp.PROTOCOL_VERSION
def test_initialize_returns_agent_info(self, agent):
resp = agent.initialize(protocol_version=1)
assert resp.agent_info is not None
assert isinstance(resp.agent_info, Implementation)
assert resp.agent_info.name == "hermes-agent"
assert resp.agent_info.version == HERMES_VERSION
def test_initialize_returns_capabilities(self, agent):
resp = agent.initialize(protocol_version=1)
caps = resp.agent_capabilities
assert isinstance(caps, AgentCapabilities)
assert caps.session_capabilities is not None
assert caps.session_capabilities.fork is not None
assert caps.session_capabilities.list is not None
# ---------------------------------------------------------------------------
# authenticate
# ---------------------------------------------------------------------------
class TestAuthenticate:
def test_authenticate_with_provider_configured(self, agent, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-test-123")
resp = agent.authenticate(method_id="openrouter")
assert isinstance(resp, AuthenticateResponse)
def test_authenticate_without_provider(self, agent, monkeypatch):
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
resp = agent.authenticate(method_id="openrouter")
assert resp is None
# ---------------------------------------------------------------------------
# new_session / cancel
# ---------------------------------------------------------------------------
class TestSessionOps:
def test_new_session_creates_session(self, agent):
resp = agent.new_session(cwd="/home/user/project")
assert isinstance(resp, NewSessionResponse)
assert resp.session_id
# Session should be retrievable from the manager
state = agent.session_manager.get_session(resp.session_id)
assert state is not None
assert state.cwd == "/home/user/project"
def test_cancel_sets_event(self, agent):
resp = agent.new_session(cwd=".")
state = agent.session_manager.get_session(resp.session_id)
assert not state.cancel_event.is_set()
agent.cancel(session_id=resp.session_id)
assert state.cancel_event.is_set()
def test_cancel_nonexistent_session_is_noop(self, agent):
# Should not raise
agent.cancel(session_id="does-not-exist")

106
tests/acp/test_session.py Normal file
View file

@ -0,0 +1,106 @@
"""Tests for acp_adapter.session — SessionManager and SessionState."""
import pytest
from unittest.mock import MagicMock
from acp_adapter.session import SessionManager, SessionState
@pytest.fixture()
def manager():
"""SessionManager with a mock agent factory (avoids needing API keys)."""
return SessionManager(agent_factory=lambda: MagicMock(name="MockAIAgent"))
# ---------------------------------------------------------------------------
# create / get
# ---------------------------------------------------------------------------
class TestCreateSession:
def test_create_session_returns_state(self, manager):
state = manager.create_session(cwd="/tmp/work")
assert isinstance(state, SessionState)
assert state.cwd == "/tmp/work"
assert state.session_id
assert state.history == []
assert state.agent is not None
def test_session_ids_are_unique(self, manager):
s1 = manager.create_session()
s2 = manager.create_session()
assert s1.session_id != s2.session_id
def test_get_session(self, manager):
state = manager.create_session()
fetched = manager.get_session(state.session_id)
assert fetched is state
def test_get_nonexistent_session_returns_none(self, manager):
assert manager.get_session("does-not-exist") is None
# ---------------------------------------------------------------------------
# fork
# ---------------------------------------------------------------------------
class TestForkSession:
def test_fork_session_deep_copies_history(self, manager):
original = manager.create_session()
original.history.append({"role": "user", "content": "hello"})
original.history.append({"role": "assistant", "content": "hi"})
forked = manager.fork_session(original.session_id, cwd="/new")
assert forked is not None
# History should be equal in content
assert len(forked.history) == 2
assert forked.history[0]["content"] == "hello"
# But a deep copy — mutating one doesn't affect the other
forked.history.append({"role": "user", "content": "extra"})
assert len(original.history) == 2
assert len(forked.history) == 3
def test_fork_session_has_new_id(self, manager):
original = manager.create_session()
forked = manager.fork_session(original.session_id)
assert forked is not None
assert forked.session_id != original.session_id
def test_fork_nonexistent_returns_none(self, manager):
assert manager.fork_session("bogus-id") is None
# ---------------------------------------------------------------------------
# list / cleanup / remove
# ---------------------------------------------------------------------------
class TestListAndCleanup:
def test_list_sessions_empty(self, manager):
assert manager.list_sessions() == []
def test_list_sessions_returns_created(self, manager):
s1 = manager.create_session(cwd="/a")
s2 = manager.create_session(cwd="/b")
listing = manager.list_sessions()
ids = {s["session_id"] for s in listing}
assert s1.session_id in ids
assert s2.session_id in ids
assert len(listing) == 2
def test_cleanup_clears_all(self, manager):
manager.create_session()
manager.create_session()
assert len(manager.list_sessions()) == 2
manager.cleanup()
assert manager.list_sessions() == []
def test_remove_session(self, manager):
state = manager.create_session()
assert manager.remove_session(state.session_id) is True
assert manager.get_session(state.session_id) is None
# Removing again returns False
assert manager.remove_session(state.session_id) is False

134
tests/acp/test_tools.py Normal file
View file

@ -0,0 +1,134 @@
"""Tests for acp_adapter.tools — tool kind mapping and ACP content building."""
import pytest
from acp_adapter.tools import (
TOOL_KIND_MAP,
build_tool_complete,
build_tool_start,
extract_locations,
get_tool_kind,
)
from acp.schema import (
FileEditToolCallContent,
ContentToolCallContent,
ToolCallLocation,
ToolCallStart,
ToolCallProgress,
)
# ---------------------------------------------------------------------------
# TOOL_KIND_MAP coverage
# ---------------------------------------------------------------------------
COMMON_HERMES_TOOLS = ["read_file", "search_files", "terminal", "patch", "write_file", "process"]
class TestToolKindMap:
def test_all_hermes_tools_have_kind(self):
"""Every common hermes tool should appear in TOOL_KIND_MAP."""
for tool in COMMON_HERMES_TOOLS:
assert tool in TOOL_KIND_MAP, f"{tool} missing from TOOL_KIND_MAP"
def test_tool_kind_read_file(self):
assert get_tool_kind("read_file") == "read"
def test_tool_kind_terminal(self):
assert get_tool_kind("terminal") == "execute"
def test_tool_kind_patch(self):
assert get_tool_kind("patch") == "edit"
def test_tool_kind_write_file(self):
assert get_tool_kind("write_file") == "edit"
def test_unknown_tool_returns_other_kind(self):
assert get_tool_kind("nonexistent_tool_xyz") == "other"
# ---------------------------------------------------------------------------
# build_tool_start
# ---------------------------------------------------------------------------
class TestBuildToolStart:
def test_build_tool_start_for_patch(self):
"""patch should produce a FileEditToolCallContent (diff)."""
args = {
"path": "src/main.py",
"old_string": "print('hello')",
"new_string": "print('world')",
}
result = build_tool_start("tc-1", "patch", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "edit"
# The first content item should be a diff
assert len(result.content) >= 1
diff_item = result.content[0]
assert isinstance(diff_item, FileEditToolCallContent)
assert diff_item.path == "src/main.py"
assert diff_item.new_text == "print('world')"
assert diff_item.old_text == "print('hello')"
def test_build_tool_start_for_terminal(self):
"""terminal should produce text content with the command."""
args = {"command": "ls -la /tmp"}
result = build_tool_start("tc-2", "terminal", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "execute"
assert len(result.content) >= 1
content_item = result.content[0]
assert isinstance(content_item, ContentToolCallContent)
# The wrapped text block should contain the command
text = content_item.content.text
assert "ls -la /tmp" in text
def test_build_tool_start_for_read_file(self):
"""read_file should include the path in content."""
args = {"path": "/etc/hosts", "offset": 1, "limit": 50}
result = build_tool_start("tc-3", "read_file", args)
assert isinstance(result, ToolCallStart)
assert result.kind == "read"
assert len(result.content) >= 1
content_item = result.content[0]
assert isinstance(content_item, ContentToolCallContent)
assert "/etc/hosts" in content_item.content.text
# ---------------------------------------------------------------------------
# build_tool_complete
# ---------------------------------------------------------------------------
class TestBuildToolComplete:
def test_build_tool_complete_for_terminal(self):
"""Completed terminal call should include output text."""
result = build_tool_complete("tc-2", "terminal", "total 42\ndrwxr-xr-x 2 root root 4096 ...")
assert isinstance(result, ToolCallProgress)
assert result.status == "completed"
assert len(result.content) >= 1
content_item = result.content[0]
assert isinstance(content_item, ContentToolCallContent)
assert "total 42" in content_item.content.text
# ---------------------------------------------------------------------------
# extract_locations
# ---------------------------------------------------------------------------
class TestExtractLocations:
def test_extract_locations_with_path(self):
args = {"path": "src/app.py", "offset": 42}
locs = extract_locations(args)
assert len(locs) == 1
assert isinstance(locs[0], ToolCallLocation)
assert locs[0].path == "src/app.py"
assert locs[0].line == 42
def test_extract_locations_without_path(self):
args = {"command": "echo hi"}
locs = extract_locations(args)
assert locs == []