acp_adapter — Agent Communication Protocol
Agent Communication Protocol
Exposes Hermes Agent as an ACP-compliant server for interoperability. Provides HTTP/SSE endpoints, permission-based tool access, session management, and event streaming. Enables editors and external tools to communicate with Hermes via the standard Agent Communication Protocol.
🎯 Why This Module Exists
Without the ACP adapter, Hermes Agent would be limited to CLI interactions and its own gateway protocol. Every editor or IDE wanting to integrate Hermes would need to implement a bespoke communication protocol, handle session management, and bridge approval workflows. The ACP (Agent Communication Protocol) is an emerging standard that editors like Cursor and VSCode already support — by implementing it, Hermes becomes instantly compatible with any ACP-aware client. The adapter bridges the gap between Hermes' synchronous, thread-based AIAgent and the async, event-driven world of ACP. It handles session persistence (conversations survive server restarts), permission callbacks (editor UI for approving sensitive operations), tool kind classification (semantic grouping for editor display), and MCP server registration (dynamic tool addition). Without this module, users would lose their conversation history on every restart, and editors would have no way to request approval for dangerous operations like terminal commands.
💡 Real-world analogy
Think of the ACP adapter as a diplomatic embassy. Hermes Agent is a citizen of one country (its own protocol), and editors like Cursor are citizens of another (ACP). The embassy (acp_adapter) translates between the two languages, issues visas (sessions), handles security clearances (permissions), and maintains a record office (SessionDB) so conversations are never lost even if the embassy temporarily closes. The ambassador (HermesACPAgent) knows both protocols fluently and ensures nothing is lost in translation.
ACP Standard HTTP/SSE Permission System
Architecture
flowchart LR
subgraph Client["Editor / IDE"]
ED["Cursor / VSCode"]
end
subgraph ACPLayer["ACP Server Layer"]
HA["HermesACPAgent"]
SM["SessionManager"]
PM["Permissions"]
TK["Tool Kind Mapper"]
CB["Event Callbacks"]
end
subgraph HermesCore["Hermes Core"]
AI["AIAgent"]
MT["model_tools"]
SD["SessionDB"]
end
ED -->|"ACP JSON-RPC"| HA
HA --> SM
SM --> AI
SM --> SD
HA --> PM
HA --> CB
CB --> ED
AI --> MT
PM -->|"approval_callback"| AI
TK --> HA
Data Flow
flowchart TD
A["Editor connects via stdio/TCP"] --> B["initialize()"]
B --> C["Negotiate protocol, detect auth"]
C --> D["create_session()"]
D --> E["New AIAgent + SessionState"]
E --> F["Persist to SessionDB"]
F --> G["message() - user prompt"]
G --> H["AIAgent.run() in ThreadPool"]
H --> I{"Tool call?"}
I -->|"Yes"| J["tool_progress_cb() emits ACP update"]
J --> K{"Needs approval?"}
K -->|"Yes"| L["request_permission to editor"]
L --> M["Editor approves/denies"]
M --> H
K -->|"No"| H
I -->|"No - done"| N["finish_message()"]
N --> O["Persist history to DB"]
O --> P["Editor receives result"]
🚶 Step-by-Step Narrated Walkthrough
Follow a single editor session from connection through tool execution and permission approval as it flows through the entire acp_adapter pipeline:
- Connection — An editor (e.g., Cursor) spawns the Hermes ACP server process. Communication happens over stdio: stdout carries ACP JSON-RPC frames, stderr is reserved for logging to prevent frame corruption.
- Protocol Negotiation — The editor sends
initialize().HermesACPAgentdetects the active runtime provider (e.g., OpenAI, Anthropic) and returns auth methods, supported capabilities (session load, fork, model switch), and protocol version. - Session Creation —
create_session()generates a UUID, creates a freshAIAgentinstance, wraps it in aSessionStatedataclass, and persists it toSessionDB(~/.hermes/state.db). - User Message — The editor sends a
message()with the user's prompt. The text is extracted from ACP content blocks (TextContentBlock,ImageContentBlock, etc.) via_extract_text(). - Agent Execution —
AIAgent.run()executes in aThreadPoolExecutor(4 workers) so the ACP event loop stays responsive. Callback factories (make_message_cb,make_step_cb,make_thinking_cb,make_tool_progress_cb) bridge agent events to ACP notifications. - Tool Execution — When the agent calls a tool,
get_tool_kind()classifies it (read, edit, execute, fetch, think, other). The editor receives aToolCallStartnotification with the kind and arguments. - Permission Request — For sensitive operations (terminal commands, file writes),
make_approval_callback()sends arequest_permissionto the editor with three options: Allow Once, Allow Always, or Deny. The editor shows a UI prompt to the user. - Permission Resolution — The callback uses
asyncio.run_coroutine_threadsafe()to coordinate between the agent's worker thread and the ACP event loop. A 60-second timeout auto-denies if the editor doesn't respond. - Streaming Updates — As the agent produces output,
make_message_cb()streams text chunks to the editor as ACP message notifications. The editor can display them progressively. - Completion —
finish_message()signals the end of the agent's response. The full conversation history is persisted toSessionDB. If the editor reconnects later,load_session()restores from DB.
Key Components
HermesACPAgent
The main ACP Agent subclass that implements the full ACP protocol surface: initialize(), create_session(), message(), fork_session(), load_session(), and set_session_model(). Also exposes slash commands (/help, /model, /tools) as pseudo-tools parsed by the agent loop.
class HermesACPAgent(acp.Agent):
_SLASH_COMMANDS = {
"help": "Show available commands",
"model": "Show or change current model",
"tools": "List available tools",
}
async def initialize(self, protocol_version, client_capabilities, client_info, **kwargs):
provider = detect_provider()
return InitializeResponse(
protocol_version=acp.PROTOCOL_VERSION,
agent_info=Implementation(name="hermes-agent", version=HERMES_VERSION),
agent_capabilities=AgentCapabilities(load_session=True, ...),
auth_methods=[AuthMethodAgent(id=provider, ...)] if provider else None,
)
SessionManager
Thread-safe manager for ACP sessions backed by Hermes AIAgent instances. Sessions are held in-memory for fast access AND persisted to the shared SessionDB so they survive process restarts. Supports session creation, retrieval, restoration from DB, and forking (creating a new session with copied history).
Permission System
Bridges ACP permission requests to Hermes approval callbacks. When the agent needs to execute a sensitive operation, the callback sends three options to the editor: allow_once, allow_always, or deny. Uses asyncio.run_coroutine_threadsafe() to coordinate between the agent's synchronous worker thread and the async ACP event loop.
Tool Kind Mapping
Classifies Hermes tools into ACP semantic categories for editor UI display:
| ACP Kind | Hermes Tools | Editor Treatment |
|---|---|---|
read | read_file, search_files | Low-risk; auto-approve |
edit | write_file, patch | Medium-risk; show diff |
execute | terminal, process, browser_click | High-risk; require approval |
fetch | web_search, web_extract, browser_navigate | Network access; log |
think | _thinking | Internal reasoning; display as thought |
other | All unmapped tools | Default handling |
Event Callbacks
Four callback factories bridge AIAgent events to ACP notifications:
make_message_cb()— Streams text chunks as ACP message notificationsmake_step_cb()— Reports agent step progress (e.g., "Running tool 3 of 5")make_thinking_cb()— Forwards chain-of-thought reasoning to the editormake_tool_progress_cb()— Emits ToolCallStart and ToolCallProgress updates
🏗️ Design Decisions
| Decision | Choice Made | Alternatives Considered | Why This Approach |
|---|---|---|---|
| Agent execution model | ThreadPoolExecutor (4 workers) | Async AIAgent, subprocess per session | AIAgent is synchronous and uses sync tooling. Rewriting to async would be massive. Thread pool is simple and keeps ACP loop responsive. |
| Session persistence | SessionDB (SQLite) | In-memory only, Redis, file-per-session | SQLite survives restarts, is single-file, requires no external services. In-memory loses history on crash. Redis adds a dependency. |
| Communication channel | stdio (stdout for JSON-RPC, stderr for logs) | TCP socket, Unix socket, HTTP | stdio is the simplest for editor process spawning. No port conflicts, no firewall issues. Stderr separation prevents log corruption of protocol frames. |
| Permission timeout | 60-second auto-deny | Infinite wait, configurable per-tool | Prevents agent from hanging indefinitely when editor is unresponsive. 60s is enough for user interaction. Deny is the safe default. |
| Lazy SessionDB init | Create DB on first session operation | Init at server startup | Allows ACP server to start even if ~/.hermes/state.db is corrupted. Error surfaces when first needed, not at boot. |
| MCP registration | Per-session, dynamic | Global, at server startup | Different sessions may need different tool sets. Dynamic registration allows adding MCP servers after session creation without restart. |
🔬 Algorithm Spotlight
Thread-to-Async Bridge Pattern
The Problem: AIAgent is synchronous (blocking file I/O, subprocess calls, network requests). ACP is async (event-driven, non-blocking). Callbacks need to cross the thread/async boundary in both directions: agent thread pushing updates to ACP loop, and ACP loop sending permission responses to agent thread.
The Approach: Agent runs in a ThreadPoolExecutor. Outbound callbacks (agent to editor) use asyncio.run_coroutine_threadsafe(coro, loop) to schedule ACP notifications on the main event loop. Inbound responses (editor to agent) use the same mechanism with future.result(timeout=60) to block the agent thread until the editor responds.
Why This Algorithm: The alternative — rewriting AIAgent as async — would require overhauling the entire tool execution pipeline. The bridge pattern keeps both sides in their natural execution model and adds minimal overhead (one coroutine per callback invocation).
sequenceDiagram
participant E as Editor
participant L as ACP Event Loop
participant T as Agent Worker Thread
participant A as AIAgent
E->>L: message(prompt)
L->>T: submit AIAgent.run()
T->>A: run(prompt)
A->>T: tool_progress callback
T->>L: run_coroutine_threadsafe(notify)
L->>E: ToolCallStart notification
A->>T: approval_callback(terminal, rm -rf)
T->>L: run_coroutine_threadsafe(request_permission)
L->>E: request_permission
E->>L: AllowedOutcome(allow_once)
L->>T: future.result() returns allow_once
T->>A: return "once"
A->>T: run complete
T->>L: run_coroutine_threadsafe(finish_message)
L->>E: message complete
Trace through an example
Given: Editor sends message("Delete all log files"). Agent wants to run rm -rf /var/log/*.log.
Step 1: ACP loop receives the message, submits AIAgent.run("Delete all log files") to the thread pool.
Step 2: AIAgent decides to call the terminal tool with rm -rf /var/log/*.log. This triggers the approval callback.
Step 3: The approval callback calls asyncio.run_coroutine_threadsafe(request_permission(...), loop). The agent thread blocks on future.result(timeout=60).
Step 4: The ACP loop sends the permission request to the editor. The editor shows a dialog: "Allow terminal: rm -rf /var/log/*.log?"
Step 5: User clicks "Allow once". The editor sends AllowedOutcome(option_id="allow_once"). The future resolves, returning "once" to the agent thread.
Result: The command executes once. If the agent tries another dangerous command, a new permission request is sent. The 60-second timeout ensures the agent never hangs indefinitely.
⚠️ What Could Go Wrong
| Failure Mode | Trigger | Detection | Recovery / Mitigation |
|---|---|---|---|
| Session restore failure | Corrupt SessionDB, schema migration | load_session() returns error | Soft error — editor can create a new session. Server doesn't crash. DB corruption is logged. |
| Permission timeout | Editor unresponsive for 60+ seconds | future.result(timeout=60) raises | Auto-deny. Agent skips the tool call and continues. Prevents indefinite hanging. |
| MCP registration failure | Invalid MCP server config, network error | Exception during registration | Warning logged, tool surface refreshed without new tools. Agent continues with existing capabilities. |
| Stdout corruption | Log messages mixed into JSON-RPC stream | JSON parse errors in editor | Entry point routes all logging to stderr. stdout is exclusively for ACP frames. If still corrupted, editor reconnects. |
| Thread pool exhaustion | 4+ concurrent sessions all running agents | Blocked on executor.submit() | Additional sessions queue until a worker frees. Pool size of 4 matches typical editor usage (1-2 active sessions). |
⚡ Performance Profile
Hot Paths
The most performance-sensitive code paths in this module:
- Callback bridge (thread to async):
asyncio.run_coroutine_threadsafe()adds ~0.1ms overhead per call. For streaming responses with many chunks, this is the dominant overhead. - Session persistence: SQLite write after each message. For long conversations, the serialized history can be 100+ KB. Writes are synchronous but fast (SQLite WAL mode).
- Permission round-trip: Blocking wait on
future.result(). Latency depends entirely on user response time (0-60 seconds). Not optimizable.
Resource Usage
- Memory: Each session holds an
AIAgentinstance with full conversation history. Long conversations can consume 10-50 MB per session. Sessions are evicted from memory on server restart (restored lazily from DB). - Threads: 4-thread pool for agent execution. 1 main thread for ACP event loop. Total: 5 threads per server process.
- Disk: SessionDB grows with conversation history. Each message adds ~1-5 KB. No automatic cleanup; users manage via session deletion.
How acp_adapter Connects to Core Agent
The ACP adapter wraps the core AIAgent without modifying it. All tool execution flows through the same model_tools.handle_function_call() pipeline used by the CLI. The adapter adds session persistence, permission bridging, and event streaming on top.
sequenceDiagram
participant E as Editor
participant ACP as ACP Adapter
participant AI as AIAgent
participant MT as model_tools
E->>ACP: message(prompt)
ACP->>AI: run(prompt, approval_callback)
AI->>MT: handle_function_call(tool, args)
MT-->>AI: tool result
AI-->>ACP: response + callbacks
ACP-->>E: ACP notifications
Key insight: acp_adapter owns the session lifecycle and protocol translation. AIAgent owns tool execution and conversation logic. The boundary is clean: ACP never calls tools directly, and AIAgent never knows about ACP protocol details.
Directory Map
Source tree for acp_adapter
acp_adapter/
├── __init__.py ← Module init, public API exports
├── server.py ← HermesACPAgent, initialize(), message()
├── session.py ← SessionManager, SessionState, DB persistence
├── permissions.py ← make_approval_callback(), permission bridging
├── tools.py ← Tool kind mapping, build_tool_* functions
├── callbacks.py ← make_message_cb, make_step_cb, etc.
├── auth.py ← detect_provider(), has_provider()
├── events.py ← ACPEvents, event streaming helpers
└── __main__.py ← Entry point, stdio/stderr routing