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.

9
Files
~2K
Lines of Code
6
Tool Kinds
Python
Language

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:

  1. 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.
  2. Protocol Negotiation — The editor sends initialize(). HermesACPAgent detects the active runtime provider (e.g., OpenAI, Anthropic) and returns auth methods, supported capabilities (session load, fork, model switch), and protocol version.
  3. Session Creation — create_session() generates a UUID, creates a fresh AIAgent instance, wraps it in a SessionState dataclass, and persists it to SessionDB (~/.hermes/state.db).
  4. 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().
  5. Agent Execution — AIAgent.run() executes in a ThreadPoolExecutor (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.
  6. Tool Execution — When the agent calls a tool, get_tool_kind() classifies it (read, edit, execute, fetch, think, other). The editor receives a ToolCallStart notification with the kind and arguments.
  7. Permission Request — For sensitive operations (terminal commands, file writes), make_approval_callback() sends a request_permission to the editor with three options: Allow Once, Allow Always, or Deny. The editor shows a UI prompt to the user.
  8. 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.
  9. 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.
  10. Completion — finish_message() signals the end of the agent's response. The full conversation history is persisted to SessionDB. 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 KindHermes ToolsEditor Treatment
readread_file, search_filesLow-risk; auto-approve
editwrite_file, patchMedium-risk; show diff
executeterminal, process, browser_clickHigh-risk; require approval
fetchweb_search, web_extract, browser_navigateNetwork access; log
think_thinkingInternal reasoning; display as thought
otherAll unmapped toolsDefault handling

Event Callbacks

Four callback factories bridge AIAgent events to ACP notifications:

🏗️ Design Decisions

DecisionChoice MadeAlternatives ConsideredWhy This Approach
Agent execution modelThreadPoolExecutor (4 workers)Async AIAgent, subprocess per sessionAIAgent is synchronous and uses sync tooling. Rewriting to async would be massive. Thread pool is simple and keeps ACP loop responsive.
Session persistenceSessionDB (SQLite)In-memory only, Redis, file-per-sessionSQLite survives restarts, is single-file, requires no external services. In-memory loses history on crash. Redis adds a dependency.
Communication channelstdio (stdout for JSON-RPC, stderr for logs)TCP socket, Unix socket, HTTPstdio is the simplest for editor process spawning. No port conflicts, no firewall issues. Stderr separation prevents log corruption of protocol frames.
Permission timeout60-second auto-denyInfinite wait, configurable per-toolPrevents agent from hanging indefinitely when editor is unresponsive. 60s is enough for user interaction. Deny is the safe default.
Lazy SessionDB initCreate DB on first session operationInit at server startupAllows ACP server to start even if ~/.hermes/state.db is corrupted. Error surfaces when first needed, not at boot.
MCP registrationPer-session, dynamicGlobal, at server startupDifferent 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 ModeTriggerDetectionRecovery / Mitigation
Session restore failureCorrupt SessionDB, schema migrationload_session() returns errorSoft error — editor can create a new session. Server doesn't crash. DB corruption is logged.
Permission timeoutEditor unresponsive for 60+ secondsfuture.result(timeout=60) raisesAuto-deny. Agent skips the tool call and continues. Prevents indefinite hanging.
MCP registration failureInvalid MCP server config, network errorException during registrationWarning logged, tool surface refreshed without new tools. Agent continues with existing capabilities.
Stdout corruptionLog messages mixed into JSON-RPC streamJSON parse errors in editorEntry point routes all logging to stderr. stdout is exclusively for ACP frames. If still corrupted, editor reconnects.
Thread pool exhaustion4+ concurrent sessions all running agentsBlocked 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:

Resource Usage

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