"""Generic ACP (Agent Client Protocol) stdio client behind a minimal OpenAI-client facade. The public base for ``external_process`` model providers: a provider profile's ``create_client`` returns an :class:`ACPClient` (or a subclass). Each request starts a short-lived ACP session with the agent CLI, sends the formatted conversation as one prompt, collects text chunks, and returns the minimal OpenAI-client shape. Subclasses customise the launch defaults and vendor quirks through class attributes (``default_command``, ``default_args``, ``command_env_vars``, ``args_env_var``, ``marker_base_url``, ``placeholder_model``, ``label``, ``missing_command_hint``, ``unsupported_transport_hint``) and two hooks (:meth:`ACPClient.model_entry_disabled`, :meth:`ACPClient.early_exit_error`). """ from __future__ import annotations import contextlib import errno import json import logging import os import queue import re import shlex import subprocess import tempfile import threading import time from collections import deque from collections.abc import Callable, Iterator from pathlib import Path from types import SimpleNamespace from typing import Any from agent.acp_openai_bridge import ( completion_to_stream_chunks as _completion_to_stream_chunks, extract_tool_calls_from_text as _extract_tool_calls_from_text, render_tool_bridge_sections as _render_tool_bridge_sections, ) from agent.file_safety import ( get_nt_namespace_error, get_read_block_error, get_write_denied_error, is_write_approval_required) from agent.redact import redact_sensitive_text from agent.runtime_cwd import resolve_agent_cwd logger = logging.getLogger(__name__) _DEFAULT_TIMEOUT_SECONDS = 900.0 # ACP JSON-RPC error code meaning "authentication required" (the agent CLI is not signed in). _ACP_AUTH_REQUIRED = -32000 _ROLE_LABELS = {"system": "System", "user": "User", "assistant": "Assistant", "tool": "Tool", "context": "Context"} # Probe verdicts per binary path (~50ms --help paid once per process). Only definitive # True/False is cached, so a CLI installed mid-session is picked up. _ACP_PROBE_CACHE: dict[str, bool] = {} _PROMPT_PREAMBLE = ( "You are being used as the active ACP agent backend for Hermes.", "Use ACP capabilities to complete tasks.", "IMPORTANT: If you take an action with a tool, you MUST output tool calls using {...} blocks with JSON exactly in OpenAI function-call shape.", "If no tool is needed, answer normally.", ) _INITIALIZE_PARAMS = { "protocolVersion": 1, "clientCapabilities": {"fs": {"readTextFile": True, "writeTextFile": True}}, "clientInfo": {"name": "hermes-agent", "title": "Hermes Agent", "version": "0.0.0"}, } def _acp_supported(command: str, args: list[str]) -> bool | None: """Tri-state ``--acp`` probe (a CLI without the flag exits 1 and the parent would wait the full child timeout for stdout that never arrives). True = help advertises --acp; False = help ran cleanly without it (caller fast-fails); None = inconclusive (binary missing / --help failed → normal spawn error). Skipped when ``--acp`` is not in ``args`` (custom transport).""" if "--acp" not in args: return True if (cached := _ACP_PROBE_CACHE.get(command)) is not None: return cached try: probe = subprocess.run( [command, "--help"], # Explicit codec because text=True alone decodes with the # locale default and crashes on non-ASCII help text under # GBK/CP932 locales. capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, stdin=subprocess.DEVNULL, check=False, ) except (FileNotFoundError, subprocess.TimeoutExpired, OSError): return None if probe.returncode != 0: return None # ``--acp`` as a flag token; tolerate spacing and ``[--acp]`` variants. verdict = _ACP_PROBE_CACHE[command] = bool(re.search(r"(?:^|[\s\[])--acp(?:[\s=\],]|$)", probe.stdout, re.MULTILINE)) return verdict def _resolve_home_dir() -> str: """Stable HOME for child ACP processes; the temp dir as a last resort so the child never starts HOME-less.""" if home := os.environ.get("HOME", "").strip(): return home if (expanded := os.path.expanduser("~")) and expanded != "~": return expanded try: import pwd return pwd.getpwuid(os.getuid()).pw_dir.strip() or tempfile.gettempdir() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows) except Exception: return tempfile.gettempdir() def _build_subprocess_env() -> dict[str, str]: # Lazy: the env policy imports hermes_cli.config, and provider plugins import this module # while hermes_cli.config is still initializing (discovery runs from it). from hermes_constants import apply_subprocess_home_env from tools.environments.local import hermes_subprocess_env # An ACP agent drives a model and needs LLM provider credentials; the central helper still # strips Tier-1 secrets (bot tokens, GitHub auth, infra). # See #29157. env = hermes_subprocess_env(inherit_credentials=True) env["HOME"] = _resolve_home_dir() apply_subprocess_home_env(env) return env def _jsonrpc_result(message_id: Any, result: Any) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": message_id, "result": result} def _jsonrpc_error(message_id: Any, code: int, message: str) -> dict[str, Any]: return {"jsonrpc": "2.0", "id": message_id, "error": {"code": code, "message": message}} def _never_disabled(entry: dict[str, Any]) -> bool: return False def _enabled_id_list(entries: Any, key: str, disabled: Callable[[dict[str, Any]], bool] = _never_disabled) -> list[str]: """Ordered unique ids of the entries ``disabled`` does not reject.""" seen: set[str] = set() result: list[str] = [] for entry in entries or []: if not isinstance(entry, dict): continue value = str(entry.get(key) or "").strip() if not value or value in seen or disabled(entry): continue seen.add(value) result.append(value) return result def _model_config_option(session: dict[str, Any]) -> dict[str, Any] | None: return next((option for option in (session.get("configOptions") or []) if isinstance(option, dict) and "model" in (option.get("category"), option.get("id"))), None) def _session_model_ids(session: dict[str, Any], disabled: Callable[[dict[str, Any]], bool] = _never_disabled) -> list[str]: """Account-authorized model ids advertised by ``session/new`` in ACP v1 or its legacy extension.""" if option := _model_config_option(session): return _enabled_id_list(option.get("options"), "value", disabled) return _legacy_session_model_ids(session, disabled) def _legacy_session_model_ids(session: dict[str, Any], disabled: Callable[[dict[str, Any]], bool] = _never_disabled) -> list[str]: return _enabled_id_list((session.get("models") or {}).get("availableModels"), "modelId", disabled) def _model_selection_request( session: dict[str, Any], requested_model: str, *, placeholder: str = "", disabled: Callable[[dict[str, Any]], bool] = _never_disabled, ) -> tuple[str, dict[str, str]] | None: """ACP request selecting ``requested_model`` for ``session``: stable v1 ``session/set_config_option``, else the pre-stabilization ``session/set_model`` when no model config option is advertised. A reported model list is authoritative: unknown and ``disabled`` ids return None instead of being sent, as does the provider's ``placeholder`` id (meaning "the session default").""" session_id = str(session.get("sessionId") or "").strip() requested_model = str(requested_model or "").strip() if not session_id or not requested_model or requested_model == placeholder: return None option = _model_config_option(session) if option: if requested_model not in _enabled_id_list(option.get("options"), "value", disabled): return None return "session/set_config_option", {"sessionId": session_id, "configId": str(option.get("id") or "model"), "value": requested_model} available = _legacy_session_model_ids(session, disabled) return None if available and requested_model not in available else ("session/set_model", {"sessionId": session_id, "modelId": requested_model}) def _format_messages_as_prompt( messages: list[dict[str, Any]], model: str | None = None, tools: list[dict[str, Any]] | None = None, tool_choice: Any = None, ) -> str: # Deliberately no "requested model" line: the model is applied for real via ACP session/set_model; # a prompt-text mention makes a substituted backend model FALSELY self-identify as the requested # one. The whole Hermes toolset is forwarded; the agent's own tools run inside the agent. sections: list[str] = [*_PROMPT_PREAMBLE, *_render_tool_bridge_sections(tools, tool_choice)] transcript: list[str] = [] for message in (m for m in messages if isinstance(m, dict)): role = str(message.get("role") or "unknown").strip().lower() if rendered := _render_message_content(message.get("content")): transcript.append(f"{_ROLE_LABELS.get(role, 'Context')}:\n{rendered}") if transcript: sections.append("Conversation transcript:\n\n" + "\n\n".join(transcript)) sections.append("Continue the conversation from the latest user request.") return "\n\n".join(section.strip() for section in sections if section and section.strip()) def _render_message_content(content: Any) -> str: if content is None: return "" if isinstance(content, dict): if "text" in content: return str(content.get("text") or "").strip() return content["content"].strip() if isinstance(content.get("content"), str) else json.dumps(content, ensure_ascii=True) if isinstance(content, list): parts = [item if isinstance(item, str) else item["text"].strip() for item in content if isinstance(item, str) or (isinstance(item, dict) and isinstance(item.get("text"), str) and item["text"].strip())] return "\n".join(parts).strip() return str(content).strip() def _ensure_path_within_cwd(path_text: str, cwd: str, *, verb: str) -> Path: # Raw-string check BEFORE resolve(): resolving an NT-namespace path is the NTLM-leak trigger. if nt_error := get_nt_namespace_error(path_text, verb=verb): raise PermissionError(nt_error) if not Path(path_text).is_absolute(): raise PermissionError("ACP file-system paths must be absolute.") resolved, root = Path(path_text).resolve(), Path(cwd).resolve() try: resolved.relative_to(root) except ValueError as exc: raise PermissionError(f"Path '{resolved}' is outside the session cwd '{root}'.") from exc return resolved def _effective_timeout(timeout: Any) -> float: """Normalise a float or httpx.Timeout-like object to wall-clock seconds (largest component wins).""" if isinstance(timeout, (int, float)): return float(timeout) candidates = [getattr(timeout, attr, None) for attr in ("read", "write", "connect", "pool", "timeout")] return max((float(v) for v in candidates if isinstance(v, (int, float))), default=_DEFAULT_TIMEOUT_SECONDS) def _fs_read_text_file(params: dict[str, Any], cwd: str) -> Any: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd, verb="Read") if block_error := get_read_block_error(str(path)): raise PermissionError(block_error) try: content = path.read_text(encoding="utf-8-sig") except FileNotFoundError: content = "" line, limit = params.get("line"), params.get("limit") if isinstance(line, int) and line > 1: end = line - 1 + limit if isinstance(limit, int) and limit > 0 else None content = "".join(content.splitlines(keepends=True)[line - 1:end]) return {"content": redact_sensitive_text(content, force=True) if content else content} def _fs_write_text_file(params: dict[str, Any], cwd: str) -> Any: path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd, verb="Write") if denied := get_write_denied_error(str(path)): raise PermissionError(denied) if is_write_approval_required(str(path)): # soft-gated for interactive tools; the ACP shim has no human channel → fail closed raise PermissionError(f"Write denied: '{path}' requires interactive approval and cannot be written through the ACP file bridge.") path.parent.mkdir(parents=True, exist_ok=True) path.write_text(str(params.get("content") or ""), encoding="utf-8") return None _FS_HANDLERS = {"fs/read_text_file": _fs_read_text_file, "fs/write_text_file": _fs_write_text_file} class ACPClient: """Minimal OpenAI-client-compatible facade over one ACP agent CLI spoken to on stdio. Construct it with the resolved ``command``/``args`` (what ``resolve_external_process_provider_credentials`` returns for the provider); the class attributes below are only the fallback when a caller passes neither. """ # Declared for agent/auxiliary_client.py: this shim drives an ACP subprocess over stdio, so it is # already a complete client (never re-dispatch through a wire adapter) and async-safe as-is. HERMES_SKIP_TRANSPORT_WRAP = True HERMES_SKIP_ASYNC_WRAP = True #: Binary and argv tail used when the caller passes no command/args; env vars win, in order. default_command: str = "" default_args: tuple[str, ...] = () command_env_vars: tuple[str, ...] = () args_env_var: str = "" #: ``base_url`` reported when none is passed (an ``acp://`` marker, never dialled). marker_base_url: str = "acp://agent" #: Model id meaning "the session default": never sent as a model selection. placeholder_model: str = "acp" #: Product name in errors and log lines. label: str = "ACP" #: Error text after "Could not start