diff --git a/acp_adapter/__init__.py b/acp_adapter/__init__.py new file mode 100644 index 000000000..293812165 --- /dev/null +++ b/acp_adapter/__init__.py @@ -0,0 +1,5 @@ +"""ACP (Agent Client Protocol) adapter for Hermes Agent. + +Provides integration with editors like Zed via the ACP protocol. +Install with: pip install hermes-agent[acp] +""" diff --git a/acp_adapter/auth.py b/acp_adapter/auth.py new file mode 100644 index 000000000..de8177685 --- /dev/null +++ b/acp_adapter/auth.py @@ -0,0 +1,50 @@ +"""Authentication helpers for ACP adapter. + +Checks environment / dotenv for configured API keys, reusing Hermes CLI's +existing provider detection logic. +""" + +import os +import sys +from pathlib import Path + + +def check_auth() -> dict: + """Check if at least one inference provider is configured. + + Returns dict with: + success (bool): True if a provider is available. + error (str | None): Human-readable setup instructions on failure. + """ + # Ensure project root is importable (entry.py normally handles this, + # but be defensive). + project_root = Path(__file__).resolve().parent.parent + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + + try: + from hermes_cli.main import _has_any_provider_configured + + if _has_any_provider_configured(): + return {"success": True, "error": None} + except Exception: + # If we can't import the helper, fall back to a direct env-var check. + env_vars = [ + "OPENROUTER_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_BASE_URL", + ] + if any(os.getenv(v) for v in env_vars): + return {"success": True, "error": None} + + return { + "success": False, + "error": ( + "No inference provider configured. " + "Run 'hermes setup' to configure interactively, " + "or set an API key (e.g. OPENROUTER_API_KEY, OPENAI_API_KEY, ANTHROPIC_API_KEY) " + "in your environment or in ~/.hermes/.env. " + "See https://github.com/NousResearch/hermes-agent#setup for all supported providers." + ), + } diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py new file mode 100644 index 000000000..7a09ac6bd --- /dev/null +++ b/acp_adapter/entry.py @@ -0,0 +1,84 @@ +"""CLI entry point for the Hermes ACP agent server. + +Launched by editors (Zed, JetBrains, etc.) or directly via ``hermes-acp``. +Communicates over stdio using JSON-RPC 2.0 as defined by the Agent Client +Protocol. + +All logging is redirected to stderr so stdout remains clean for JSON-RPC. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import sys +from pathlib import Path + +# Ensure project root is importable (covers editable installs and dev usage). +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + + +def _load_env() -> None: + """Load ~/.hermes/.env via dotenv, mirroring hermes_cli.main behaviour.""" + try: + from dotenv import load_dotenv + from hermes_cli.config import get_env_path + + env_path = get_env_path() + if env_path.exists(): + try: + load_dotenv(dotenv_path=env_path, encoding="utf-8") + except UnicodeDecodeError: + load_dotenv(dotenv_path=env_path, encoding="latin-1") + except ImportError: + # python-dotenv or hermes_cli not available — rely on shell env. + pass + + # Also try project-root .env as dev fallback. + try: + from dotenv import load_dotenv as _ld + + project_env = _PROJECT_ROOT / ".env" + if project_env.exists(): + _ld(dotenv_path=project_env, override=False) + except ImportError: + pass + + +def _setup_logging() -> None: + """Route all logging to stderr so stdout stays JSON-RPC only.""" + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter( + logging.Formatter("%(asctime)s [%(levelname)s] %(name)s: %(message)s") + ) + root = logging.getLogger() + root.handlers.clear() + root.addHandler(handler) + root.setLevel(logging.INFO) + + +async def run_acp_agent() -> None: + """Create and run the Hermes ACP agent.""" + from acp import run_agent + + from acp_adapter.server import HermesACPAgent + + agent = HermesACPAgent() + try: + await run_agent(agent, use_unstable_protocol=True) + finally: + agent.shutdown() + + +def main() -> None: + """Entry point for the ``hermes-acp`` console script.""" + _load_env() + _setup_logging() + asyncio.run(run_acp_agent()) + + +if __name__ == "__main__": + main() diff --git a/acp_adapter/server.py b/acp_adapter/server.py new file mode 100644 index 000000000..6fa79f5b5 --- /dev/null +++ b/acp_adapter/server.py @@ -0,0 +1,383 @@ +"""HermesACPAgent — ACP server that wraps Hermes's AIAgent. + +Implements the Agent Client Protocol lifecycle: initialize, authenticate, +session management, prompt execution with streaming updates, and cancellation. +Agent work runs in a ThreadPoolExecutor so the asyncio event loop stays +free for JSON-RPC I/O. +""" + +from __future__ import annotations + +import asyncio +import logging +from concurrent.futures import ThreadPoolExecutor +from typing import Any + +from acp import ( + PROTOCOL_VERSION, + Agent, + AuthenticateResponse, + InitializeResponse, + LoadSessionResponse, + NewSessionResponse, + PromptResponse, + RequestError, +) +from acp.interfaces import Client +from acp.schema import ( + AgentCapabilities, + AudioContentBlock, + AuthMethod, + ClientCapabilities, + EmbeddedResourceContentBlock, + ForkSessionResponse, + HttpMcpServer, + ImageContentBlock, + Implementation, + ListSessionsResponse, + McpServerStdio, + ResourceContentBlock, + ResumeSessionResponse, + SessionCapabilities, + SessionForkCapabilities, + SessionInfo, + SessionListCapabilities, + SessionResumeCapabilities, + SetSessionConfigOptionResponse, + SetSessionModelResponse, + SetSessionModeResponse, + SseMcpServer, + TextContentBlock, +) + +from acp_adapter.auth import check_auth +from acp_adapter.session import SessionManager + +logger = logging.getLogger(__name__) + + +class HermesACPAgent(Agent): + """ACP agent backed by Hermes ``AIAgent``.""" + + _conn: Client + + def __init__(self) -> None: + self._sessions = SessionManager() + self._executor = ThreadPoolExecutor(max_workers=4) + + def shutdown(self) -> None: + """Release resources (ThreadPoolExecutor, sessions).""" + self._sessions.cleanup_all() + self._executor.shutdown(wait=False) + + def __del__(self) -> None: + try: + self._executor.shutdown(wait=False) + except Exception: + pass + + # ------------------------------------------------------------------ + # ACP lifecycle + # ------------------------------------------------------------------ + + def on_connect(self, conn: Client) -> None: + self._conn = conn + + async def initialize( + self, + protocol_version: int, + client_capabilities: ClientCapabilities | None = None, + client_info: Implementation | None = None, + **kwargs: Any, + ) -> InitializeResponse: + logger.info("initialize: client=%s", client_info) + if protocol_version != PROTOCOL_VERSION: + raise RequestError.invalid_params( + {"message": f"Unsupported protocol version {protocol_version}, expected {PROTOCOL_VERSION}"} + ) + return InitializeResponse( + protocol_version=PROTOCOL_VERSION, + agent_capabilities=AgentCapabilities( + load_session=True, + session_capabilities=SessionCapabilities( + list=SessionListCapabilities(), + fork=SessionForkCapabilities(), + resume=SessionResumeCapabilities(), + ), + ), + agent_info=Implementation( + name="hermes-agent", + title="Hermes Agent", + version="0.1.0", + ), + auth_methods=[AuthMethod(id="env-check", name="API Key")], + ) + + async def authenticate(self, method_id: str, **kwargs: Any) -> AuthenticateResponse | None: + logger.info("authenticate: method_id=%s", method_id) + result = check_auth() + if result["success"]: + return AuthenticateResponse() + logger.warning("Authentication failed: %s", result["error"]) + return None + + async def new_session( + self, + cwd: str, + mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, + **kwargs: Any, + ) -> NewSessionResponse: + loop = asyncio.get_running_loop() + session_id = self._sessions.generate_id() + self._sessions.create(session_id, self._conn, loop, cwd) + logger.info("new_session: id=%s cwd=%s", session_id, cwd) + return NewSessionResponse(session_id=session_id, modes=None) + + async def load_session( + self, + cwd: str, + session_id: str, + mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, + **kwargs: Any, + ) -> LoadSessionResponse | None: + loop = asyncio.get_running_loop() + self._sessions.load(session_id, self._conn, loop, cwd) + logger.info("load_session: id=%s", session_id) + return LoadSessionResponse() + + async def list_sessions( + self, cursor: str | None = None, cwd: str | None = None, **kwargs: Any + ) -> ListSessionsResponse: + logger.info("list_sessions: cursor=%s cwd=%s", cursor, cwd) + sessions = [ + SessionInfo(session_id=sid, cwd=state.cwd or "/") + for sid, state in self._sessions.list_all() + ] + return ListSessionsResponse(sessions=sessions) + + async def fork_session( + self, + cwd: str, + session_id: str, + mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, + **kwargs: Any, + ) -> ForkSessionResponse: + parent = self._sessions.get(session_id) + loop = asyncio.get_running_loop() + new_id = self._sessions.generate_id() + new_state = self._sessions.create( + new_id, self._conn, loop, cwd or (parent.cwd if parent else "") + ) + if parent: + new_state.history = list(parent.history) + logger.info("fork_session: %s -> %s (%d messages copied)", + session_id, new_id, len(new_state.history)) + return ForkSessionResponse(session_id=new_id) + + async def resume_session( + self, + cwd: str, + session_id: str, + mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None, + **kwargs: Any, + ) -> ResumeSessionResponse: + # Check if session exists in memory + existing = self._sessions.get(session_id) + if existing is not None: + if cwd: + existing.cwd = cwd + logger.info("resume_session: id=%s (in memory)", session_id) + return ResumeSessionResponse() + + # Try to hydrate from DB + has_db_history = False + try: + from hermes_state import SessionDB + + db = SessionDB() + messages = db.get_messages(session_id) + db.close() + has_db_history = bool(messages) + except Exception: + pass + + if not has_db_history: + raise RequestError.invalid_params( + {"message": f"Session {session_id} not found"} + ) + + # Session exists in DB — create state and hydrate + loop = asyncio.get_running_loop() + self._sessions.load(session_id, self._conn, loop, cwd) + logger.info("resume_session: id=%s (from DB)", session_id) + return ResumeSessionResponse() + + async def prompt( + self, + prompt: list[ + TextContentBlock + | ImageContentBlock + | AudioContentBlock + | ResourceContentBlock + | EmbeddedResourceContentBlock + ], + session_id: str, + **kwargs: Any, + ) -> PromptResponse: + logger.info("prompt: session=%s blocks=%d", session_id, len(prompt)) + + state = self._sessions.get(session_id) + if state is None: + logger.warning("prompt: auto-creating session %s (client did not call new_session/load_session first)", session_id) + loop = asyncio.get_running_loop() + state = self._sessions.create(session_id, self._conn, loop, cwd=".") + + # Reset cancel flag + state.cancel_event.clear() + + # Extract text from prompt blocks + user_text = _extract_text(prompt) + if not user_text: + return PromptResponse(stop_reason="end_turn") + + loop = asyncio.get_running_loop() + conn = self._conn + + # ------------------------------------------------------------------ + # Build callbacks that stream ACP notifications from the agent thread + # ------------------------------------------------------------------ + + def tool_progress_cb(tool_name: str, preview: str, args: dict) -> None: + """Called by AIAgent when a tool call starts.""" + try: + from acp import start_tool_call, text_block as tb + + tool_id = f"tc-{tool_name}-{id(args)}" + update = start_tool_call(tool_id, f"{tool_name}: {preview}", kind="tool", status="running") + asyncio.run_coroutine_threadsafe( + conn.session_update(session_id, update), loop + ).result(timeout=5) + except Exception: + logger.debug("tool_progress_cb failed", exc_info=True) + + def step_cb(api_call_count: int, prev_tools: list) -> None: + """Called between AIAgent API call iterations.""" + if state.cancel_event.is_set(): + state.agent.interrupt() + + # Wire callbacks into the agent + state.agent.tool_progress_callback = tool_progress_cb + state.agent.step_callback = step_cb + + # ------------------------------------------------------------------ + # Run the agent in a worker thread + # ------------------------------------------------------------------ + + try: + result = await loop.run_in_executor( + self._executor, + self._run_agent_sync, + state, + user_text, + ) + except Exception as exc: + logger.exception("Agent execution failed for session %s", session_id) + await _send_text(conn, session_id, f"Error: {exc}") + return PromptResponse(stop_reason="error") + + # Stream the final response text back to the editor + response_text = result.get("final_response", "") + if response_text: + await _send_text(conn, session_id, response_text) + + stop_reason = "end_turn" + if state.cancel_event.is_set(): + stop_reason = "cancelled" + + return PromptResponse(stop_reason=stop_reason) + + async def cancel(self, session_id: str, **kwargs: Any) -> None: + logger.info("cancel: session=%s", session_id) + state = self._sessions.get(session_id) + if state is not None: + state.cancel_event.set() + state.agent.interrupt() + + async def set_session_mode(self, mode_id: str, session_id: str, **kwargs: Any) -> SetSessionModeResponse | None: + state = self._sessions.get(session_id) + if state is None: + logger.warning("set_session_mode: unknown session %s", session_id) + logger.info("set_session_mode: session=%s mode=%s (no-op)", session_id, mode_id) + return SetSessionModeResponse() + + async def set_session_model(self, model_id: str, session_id: str, **kwargs: Any) -> SetSessionModelResponse | None: + state = self._sessions.get(session_id) + if state is None: + logger.warning("set_session_model: unknown session %s", session_id) + logger.info("set_session_model: session=%s model=%s (no-op)", session_id, model_id) + return SetSessionModelResponse() + + async def set_config_option( + self, config_id: str, session_id: str, value: str, **kwargs: Any + ) -> SetSessionConfigOptionResponse | None: + state = self._sessions.get(session_id) + if state is None: + logger.warning("set_config_option: unknown session %s", session_id) + logger.info("set_config_option: session=%s config=%s (no-op)", session_id, config_id) + return SetSessionConfigOptionResponse(config_options=[]) + + async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]: + raise RequestError.method_not_found(f"_{method}") + + async def ext_notification(self, method: str, params: dict[str, Any]) -> None: + pass + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + @staticmethod + def _run_agent_sync(state: Any, user_text: str) -> dict: + """Execute ``AIAgent.run_conversation`` synchronously (runs in ThreadPoolExecutor).""" + result = state.agent.run_conversation( + user_message=user_text, + conversation_history=state.history, + ) + # Update stored history for multi-turn conversations. + if isinstance(result, dict) and "messages" in result: + state.history = result["messages"] + return result + + +# ------------------------------------------------------------------ +# Helpers +# ------------------------------------------------------------------ + +def _extract_text( + blocks: list[ + TextContentBlock + | ImageContentBlock + | AudioContentBlock + | ResourceContentBlock + | EmbeddedResourceContentBlock + ], +) -> str: + """Concatenate text from prompt content blocks.""" + parts: list[str] = [] + for block in blocks: + if isinstance(block, TextContentBlock): + parts.append(block.text) + elif hasattr(block, "text"): + parts.append(str(block.text)) + return "\n".join(parts).strip() + + +async def _send_text(conn: Client, session_id: str, text: str) -> None: + """Send a text message update to the editor.""" + from acp import update_agent_message, text_block + + try: + update = update_agent_message(text_block(text)) + await conn.session_update(session_id, update) + except Exception: + logger.debug("_send_text failed", exc_info=True) diff --git a/acp_adapter/session.py b/acp_adapter/session.py new file mode 100644 index 000000000..e7ce9b1ce --- /dev/null +++ b/acp_adapter/session.py @@ -0,0 +1,157 @@ +"""SessionManager — maps ACP session IDs to AIAgent instances. + +Each ACP session gets its own ``AIAgent`` configured for non-interactive use +(``quiet_mode=True``, ``platform="acp"``), plus a ``ToolBridge`` for +delegating file/terminal operations back to the editor. +""" + +from __future__ import annotations + +import asyncio +import logging +import threading +from dataclasses import dataclass, field +from typing import Any + +logger = logging.getLogger(__name__) + + +@dataclass +class SessionState: + """State for a single ACP session.""" + + agent: Any # AIAgent instance + bridge: Any # ToolBridge instance + history: list[dict[str, Any]] = field(default_factory=list) + cancel_event: threading.Event = field(default_factory=threading.Event) + cwd: str = "" + + +class SessionManager: + """Thread-safe registry of ACP sessions.""" + + def __init__(self) -> None: + self._sessions: dict[str, SessionState] = {} + self._lock = threading.Lock() + self._next_id = 0 + + def generate_id(self) -> str: + with self._lock: + sid = str(self._next_id) + self._next_id += 1 + return sid + + def create( + self, + session_id: str, + conn: Any, + loop: asyncio.AbstractEventLoop, + cwd: str = "", + ) -> SessionState: + """Create a new session with an AIAgent and ToolBridge. + + Args: + session_id: ACP session identifier. + conn: ACP client connection for editor interaction. + loop: asyncio event loop that owns the ACP connection. + cwd: Working directory for the session. + + Returns: + The new ``SessionState``. + """ + import sys + from pathlib import Path + + project_root = Path(__file__).resolve().parent.parent + if str(project_root) not in sys.path: + sys.path.insert(0, str(project_root)) + + from run_agent import AIAgent + from acp_adapter.tool_bridge import ToolBridge + + bridge = ToolBridge(conn=conn, session_id=session_id, loop=loop) + + agent = AIAgent( + quiet_mode=True, + platform="acp", + ) + # Attach the bridge so _execute_tool_calls can delegate file/terminal tools. + agent._acp_tool_bridge = bridge + + state = SessionState( + agent=agent, + bridge=bridge, + history=[], + cwd=cwd, + ) + + with self._lock: + self._sessions[session_id] = state + + logger.info("Created ACP session %s (cwd=%s)", session_id, cwd) + return state + + def get(self, session_id: str) -> SessionState | None: + with self._lock: + return self._sessions.get(session_id) + + def list_all(self) -> list[tuple[str, SessionState]]: + """Return all (session_id, state) pairs.""" + with self._lock: + return list(self._sessions.items()) + + def remove(self, session_id: str) -> None: + with self._lock: + state = self._sessions.pop(session_id, None) + if state is not None: + try: + state.agent._cleanup_task_resources() + except Exception: + pass + + def cleanup_all(self) -> None: + """Remove all sessions and clean up their resources.""" + with self._lock: + sids = list(self._sessions.keys()) + for sid in sids: + self.remove(sid) + + def load( + self, + session_id: str, + conn: Any, + loop: asyncio.AbstractEventLoop, + cwd: str = "", + ) -> SessionState: + """Load an existing Hermes session by ID. + + If the session is already tracked, returns it directly. + Otherwise creates a fresh ``SessionState`` and attempts to + hydrate conversation history from the Hermes SQLite database. + """ + existing = self.get(session_id) + if existing is not None: + if cwd: + existing.cwd = cwd + return existing + + state = self.create(session_id, conn, loop, cwd) + + # Try to restore conversation history from the session DB. + try: + from hermes_state import SessionDB + + db = SessionDB() + messages = db.get_messages(session_id) + db.close() + if messages: + state.history = messages + logger.info( + "Loaded %d messages for session %s from DB", + len(messages), + session_id, + ) + except Exception as exc: + logger.debug("Could not restore session %s from DB: %s", session_id, exc) + + return state diff --git a/acp_adapter/tool_bridge.py b/acp_adapter/tool_bridge.py new file mode 100644 index 000000000..cc2f82c46 --- /dev/null +++ b/acp_adapter/tool_bridge.py @@ -0,0 +1,189 @@ +"""ToolBridge — delegates file/terminal tool calls to the editor via ACP. + +When Hermes runs inside an ACP-aware editor (Zed, JetBrains, etc.), file +and terminal operations should go through the editor so it can display diffs, +track changes, and manage terminals visually. All other Hermes tools +(web_search, memory, delegate_task, …) continue to run locally. + +Usage from the agent thread (synchronous): + bridge = ToolBridge(conn, session_id, loop) + result_json = bridge.dispatch("read_file", {"file_path": "/tmp/foo.py"}) +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# Tools whose execution is delegated to the editor. +DELEGATED_TOOLS = {"read_file", "write_file", "patch", "terminal"} + + +class ToolBridge: + """Routes delegated tool calls to the ACP client (editor). + + All public methods are *synchronous* — they use + ``asyncio.run_coroutine_threadsafe`` to dispatch to the event loop + that owns the ACP connection, then block until the result is ready. + This lets them be called directly from the ``AIAgent`` thread. + """ + + DELEGATED_TOOLS = DELEGATED_TOOLS + + def __init__(self, conn: Any, session_id: str, loop: asyncio.AbstractEventLoop) -> None: + self._conn = conn + self._session_id = session_id + self._loop = loop + + # ------------------------------------------------------------------ + # Public dispatcher + # ------------------------------------------------------------------ + + def dispatch(self, tool_name: str, args: dict[str, Any]) -> str: + """Dispatch a tool call to the editor and return the JSON result string.""" + handler = { + "read_file": self._read_file, + "write_file": self._write_file, + "patch": self._patch_file, + "terminal": self._terminal, + }.get(tool_name) + + if handler is None: + return json.dumps({"error": f"Unknown delegated tool: {tool_name}"}) + + try: + return handler(args) + except Exception as exc: + logger.exception("ToolBridge.dispatch(%s) failed", tool_name) + return json.dumps({"error": f"ACP tool delegation failed: {exc}"}) + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _run(self, coro: Any) -> Any: + """Schedule *coro* on the ACP event loop and block until done.""" + future = asyncio.run_coroutine_threadsafe(coro, self._loop) + try: + return future.result(timeout=120) + except TimeoutError: + logger.warning("Tool bridge call timed out after 120s") + raise RuntimeError("Editor did not respond within 120 seconds") + + # ------------------------------------------------------------------ + # Tool implementations + # ------------------------------------------------------------------ + + def _read_file(self, args: dict[str, Any]) -> str: + path = args.get("file_path") or args.get("path", "") + line = args.get("line") + limit = args.get("limit") + + resp = self._run( + self._conn.read_text_file( + path=path, + session_id=self._session_id, + line=line, + limit=limit, + ) + ) + content = resp.content if hasattr(resp, "content") else str(resp) + return json.dumps({"success": True, "content": content}) + + def _write_file(self, args: dict[str, Any]) -> str: + path = args.get("file_path") or args.get("path", "") + content = args.get("content", "") + + self._run( + self._conn.write_text_file( + path=path, + content=content, + session_id=self._session_id, + ) + ) + return json.dumps({"success": True, "message": f"Wrote {len(content)} chars to {path}"}) + + def _patch_file(self, args: dict[str, Any]) -> str: + path = args.get("file_path") or args.get("path", "") + old_text = args.get("old_string") or args.get("old_text", "") + new_text = args.get("new_string") or args.get("new_text", "") + + # Read current content via ACP + resp = self._run( + self._conn.read_text_file(path=path, session_id=self._session_id) + ) + current = resp.content if hasattr(resp, "content") else str(resp) + + if old_text not in current: + return json.dumps({ + "success": False, + "error": f"old_string not found in {path}", + }) + + updated = current.replace(old_text, new_text, 1) + + self._run( + self._conn.write_text_file( + path=path, + content=updated, + session_id=self._session_id, + ) + ) + return json.dumps({"success": True, "message": f"Patched {path}"}) + + def _terminal(self, args: dict[str, Any]) -> str: + command = args.get("command", "") + cwd = args.get("working_directory") or args.get("cwd") + + # Create terminal and run command + create_resp = self._run( + self._conn.create_terminal( + command=command, + session_id=self._session_id, + cwd=cwd, + ) + ) + + terminal_id = create_resp.terminal_id if hasattr(create_resp, "terminal_id") else str(create_resp) + + # Wait for the command to finish + exit_resp = self._run( + self._conn.wait_for_terminal_exit( + session_id=self._session_id, + terminal_id=terminal_id, + ) + ) + + exit_code = exit_resp.exit_code if hasattr(exit_resp, "exit_code") else None + + # Retrieve output + output_resp = self._run( + self._conn.terminal_output( + session_id=self._session_id, + terminal_id=terminal_id, + ) + ) + + output = output_resp.output if hasattr(output_resp, "output") else str(output_resp) + + # Release the terminal + try: + self._run( + self._conn.release_terminal( + session_id=self._session_id, + terminal_id=terminal_id, + ) + ) + except Exception: + logger.debug("Failed to release terminal", exc_info=True) + + return json.dumps({ + "success": True, + "exit_code": exit_code, + "output": output, + "command": command, + }) diff --git a/acp_registry/agent.json b/acp_registry/agent.json new file mode 100644 index 000000000..0f9ba7d39 --- /dev/null +++ b/acp_registry/agent.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://cdn.agentclientprotocol.com/registry/v1/latest/agent.schema.json", + "id": "hermes-agent", + "name": "Hermes Agent", + "version": "0.1.0", + "description": "Self-improving AI agent with tool calling, memory, web search, browser automation, and skill creation.", + "icon": "./icon.svg", + "repository": "https://github.com/NousResearch/hermes-agent", + "license": "MIT", + "distribution": { + "uvx": { + "package": "hermes-agent[acp]", + "args": ["acp"] + } + } +} diff --git a/acp_registry/icon.svg b/acp_registry/icon.svg new file mode 100644 index 000000000..578cfa07a --- /dev/null +++ b/acp_registry/icon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 20f33998a..183086278 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -1807,6 +1807,22 @@ def cmd_insights(args): insights_parser.set_defaults(func=cmd_insights) + # ========================================================================= + # acp command + # ========================================================================= + acp_parser = subparsers.add_parser( + "acp", + help="Run as ACP agent server (for Zed, JetBrains, etc.)", + description="Launch the Agent Client Protocol server over stdio for editor integration" + ) + + def cmd_acp(args): + import asyncio + from acp_adapter.entry import run_acp_agent + asyncio.run(run_acp_agent()) + + acp_parser.set_defaults(func=cmd_acp) + # ========================================================================= # version command # ========================================================================= diff --git a/pyproject.toml b/pyproject.toml index 5f86cabd2..25c6f12c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,7 @@ tts-premium = ["elevenlabs"] pty = ["ptyprocess>=0.7.0"] honcho = ["honcho-ai>=2.0.1"] mcp = ["mcp>=1.2.0"] +acp = ["agent-client-protocol>=0.8.1"] homeassistant = ["aiohttp>=3.9.0"] yc-bench = ["yc-bench @ git+https://github.com/collinear-ai/yc-bench.git"] all = [ @@ -63,18 +64,20 @@ all = [ "hermes-agent[pty]", "hermes-agent[honcho]", "hermes-agent[mcp]", + "hermes-agent[acp]", "hermes-agent[homeassistant]", ] [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"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/run_agent.py b/run_agent.py index 75e3dfc95..eb4ef368a 100644 --- a/run_agent.py +++ b/run_agent.py @@ -2547,7 +2547,15 @@ def _execute_tool_calls(self, assistant_message, messages: list, effective_task_ tool_start_time = time.time() - if function_name == "todo": + # ACP tool delegation: route file/terminal tools through 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 delegation failed: {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"), @@ -4139,9 +4147,15 @@ def main( Toolset Examples: - "research": Web search, extract, crawl + vision tools """ + # Route to ACP server when invoked as `hermes-agent acp` + if query == "acp": + from acp_adapter.entry import main as acp_main + acp_main() + return + print("🤖 AI Agent with Tool Calling") print("=" * 50) - + # Handle tool listing if list_tools: from model_tools import get_all_tool_names, get_toolset_for_tool, get_available_toolsets