1131b22856
``create_openai_client`` was a hardcoded if-ladder: copilot-acp builds an ACP
stdio shim, gemini builds a native client, everything else gets an
``openai.OpenAI``. There was no extension point, so a provider whose wire
protocol is not OpenAI-over-HTTP could only be added by editing this function —
which is exactly why an ACP provider cannot ship outside this tree today, even
though ``providers/__init__.py`` has discovered out-of-tree profiles from
``~/.hermes/plugins/model-providers/`` and pip entry points for a while.
``ProviderProfile.create_client(**client_kwargs)`` closes that gap. It returns
``None`` by default, so every provider that wants the standard client is
unaffected and the existing ladder still runs as the fallback. copilot-acp is
migrated onto it — its hardcoded branch is gone and its profile supplies the
client in three lines, which is the same three lines an external package writes.
Resolution goes by provider name first, then by ``base_url`` prefix, so a
runtime configured only by URL still reaches its profile — matching what the
replaced ``startswith("acp://copilot")`` branch did. A profile that raises is
logged and skipped: a third-party plugin can fail to provide a client, but it
cannot take the turn down.
Also replaces the two ``isinstance`` checks in ``agent/auxiliary_client.py``
that mean "this client is complete, do not wrap it" with capability flags the
client class declares — ``HERMES_SKIP_TRANSPORT_WRAP`` and
``HERMES_SKIP_ASYNC_WRAP``, mirroring ``SUPPORTS_HERMES_TOOL_CALLS`` in
``background_review.py``. Two in-tree consumers (the ACP shim and the Gemini
native client), an out-of-tree client is covered by the same declaration, and
the hot path no longer imports those modules just to type-test.
Co-Authored-By: Junie <junie@jetbrains.com>
794 lines
30 KiB
Python
794 lines
30 KiB
Python
"""OpenAI-compatible shim that forwards Hermes requests to `copilot --acp`.
|
|
|
|
This adapter lets Hermes treat the GitHub Copilot ACP server as a chat-style
|
|
backend. Each request starts a short-lived ACP session, sends the formatted
|
|
conversation as a single prompt, collects text chunks, and converts the result
|
|
back into the minimal shape Hermes expects from an OpenAI client.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import os
|
|
import queue
|
|
import re
|
|
import shlex
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
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_read_block_error, get_write_denied_error, is_write_approval_required
|
|
from agent.redact import redact_sensitive_text
|
|
from tools.environments.local import hermes_subprocess_env
|
|
|
|
ACP_MARKER_BASE_URL = "acp://copilot"
|
|
logger = logging.getLogger(__name__)
|
|
_DEFAULT_TIMEOUT_SECONDS = 900.0
|
|
|
|
# Stderr fingerprint of the deprecated `gh copilot` CLI extension
|
|
# (https://github.blog/changelog/2025-09-25-upcoming-deprecation-of-gh-copilot-cli-extension).
|
|
# We require BOTH the literal product name ("gh-copilot") AND a deprecation
|
|
# marker, so generic stderr from the NEW `@github/copilot` CLI — whose repo
|
|
# is github.com/github/copilot-cli and which legitimately mentions "copilot-cli"
|
|
# in its own banners and error messages — doesn't get misclassified as the
|
|
# deprecated extension.
|
|
_DEPRECATION_REQUIRED = ("gh-copilot",)
|
|
_DEPRECATION_MARKERS = (
|
|
"has been deprecated",
|
|
"no commands will be executed",
|
|
)
|
|
|
|
|
|
def _is_gh_copilot_deprecation_message(stderr_text: str) -> bool:
|
|
"""True iff stderr looks like the deprecated gh-copilot extension's banner."""
|
|
|
|
lower = stderr_text.lower()
|
|
if not any(req in lower for req in _DEPRECATION_REQUIRED):
|
|
return False
|
|
return any(marker in lower for marker in _DEPRECATION_MARKERS)
|
|
|
|
|
|
def _resolve_command() -> str:
|
|
return (
|
|
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
|
|
or os.getenv("COPILOT_CLI_PATH", "").strip()
|
|
or "copilot"
|
|
)
|
|
|
|
|
|
def _resolve_args() -> list[str]:
|
|
raw = os.getenv("HERMES_COPILOT_ACP_ARGS", "").strip()
|
|
if not raw:
|
|
return ["--acp", "--stdio"]
|
|
return shlex.split(raw)
|
|
|
|
|
|
# Probe verdicts cached per binary path so repeated prompts against a
|
|
# CLI that supports --acp pay the ~50ms --help cost exactly once per
|
|
# process. Only definitive verdicts (True/False) are cached; an
|
|
# inconclusive probe (binary missing, --help crashed or timed out) is
|
|
# not cached so a CLI installed mid-session is picked up.
|
|
_ACP_PROBE_CACHE: dict[str, bool] = {}
|
|
|
|
|
|
def _acp_supported(command: str, args: list[str]) -> bool | None:
|
|
"""Tri-state probe: does ``command`` accept the ACP args we'd pass?
|
|
|
|
Different CLI versions support different transports. The GitHub
|
|
Copilot CLI (`@github/copilot`, late 2025+) ships with ``--acp``;
|
|
older releases (and Claude Code v2.x as of Aug 2026) do not.
|
|
Spawning a CLI that doesn't recognize the flag silently exits
|
|
with code 1 and ``error: unknown option '--acp'`` on stderr,
|
|
after which every delegate_task call hangs the parent for
|
|
``child_timeout_seconds`` (default 600s) waiting for stdout
|
|
that never arrives.
|
|
|
|
Returns:
|
|
- ``True`` — help text advertises ``--acp``; safe to spawn.
|
|
- ``False`` — help ran cleanly but ``--acp`` is absent; spawning
|
|
would hang, so the caller should fast-fail with a clear error.
|
|
- ``None`` — inconclusive (binary missing, --help failed or
|
|
timed out). The caller must fall through to the normal spawn
|
|
path, which surfaces the existing "Could not start Copilot ACP
|
|
command" error with full context.
|
|
|
|
Only probes when ``--acp`` is actually among ``args``: a custom
|
|
HERMES_COPILOT_ACP_ARGS transport is the operator's business.
|
|
"""
|
|
if "--acp" not in args:
|
|
return True
|
|
cached = _ACP_PROBE_CACHE.get(command)
|
|
if cached is not None:
|
|
return cached
|
|
try:
|
|
probe = subprocess.run(
|
|
[command, "--help"],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
return None
|
|
if probe.returncode != 0:
|
|
# --help itself failed; can't tell anything about --acp.
|
|
return None
|
|
# Match ``--acp`` as a flag in the help text; tolerate spacing and
|
|
# variants like ``[--acp]``.
|
|
verdict = bool(re.search(r"(?:^|[\s\[])--acp(?:[\s=\],]|$)", probe.stdout, re.MULTILINE))
|
|
_ACP_PROBE_CACHE[command] = verdict
|
|
return verdict
|
|
|
|
|
|
def _resolve_home_dir() -> str:
|
|
"""Return a stable HOME for child ACP processes."""
|
|
home = os.environ.get("HOME", "").strip()
|
|
if home:
|
|
return home
|
|
|
|
expanded = os.path.expanduser("~")
|
|
if expanded and expanded != "~":
|
|
return expanded
|
|
|
|
try:
|
|
import pwd
|
|
|
|
resolved = pwd.getpwuid(os.getuid()).pw_dir.strip() # windows-footgun: ok — POSIX fallback inside try/except (pwd import fails on Windows)
|
|
if resolved:
|
|
return resolved
|
|
except Exception:
|
|
pass
|
|
|
|
# Last resort: /tmp (writable on any POSIX system). Avoids crashing the
|
|
# subprocess with no HOME; callers can set HERMES_HOME explicitly if they
|
|
# need a different writable dir.
|
|
return "/tmp"
|
|
|
|
|
|
def _build_subprocess_env() -> dict[str, str]:
|
|
# Copilot ACP is a model-driving CLI executor: it legitimately needs LLM
|
|
# provider credentials. Route through the central helper so Tier-1 secrets
|
|
# (gateway bot tokens, GitHub auth, infra) are still stripped (#29157).
|
|
env = hermes_subprocess_env(inherit_credentials=True)
|
|
home = _resolve_home_dir()
|
|
env["HOME"] = home
|
|
from hermes_constants import apply_subprocess_home_env
|
|
apply_subprocess_home_env(env)
|
|
return env
|
|
|
|
|
|
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 _permission_denied(message_id: Any) -> dict[str, Any]:
|
|
return {
|
|
"jsonrpc": "2.0",
|
|
"id": message_id,
|
|
"result": {
|
|
"outcome": {
|
|
"outcome": "cancelled",
|
|
}
|
|
},
|
|
}
|
|
|
|
|
|
def _model_selection_request(
|
|
session: dict[str, Any], requested_model: str
|
|
) -> tuple[str, dict[str, str]] | None:
|
|
"""Return the ACP request that selects ``requested_model`` for ``session``.
|
|
|
|
Prefer stable v1 ``session/set_config_option``. Fall back to Copilot's
|
|
pre-stabilization ``session/set_model`` extension only when no model
|
|
config option is advertised. A reported model list is authoritative:
|
|
unknown and policy-disabled ids return None instead of being sent.
|
|
"""
|
|
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 == "copilot-acp":
|
|
return None
|
|
|
|
config_options = [
|
|
o for o in (session.get("configOptions") or []) if isinstance(o, dict)
|
|
]
|
|
model_option = next(
|
|
(
|
|
o for o in config_options
|
|
if o.get("category") == "model" or o.get("id") == "model"
|
|
),
|
|
None,
|
|
)
|
|
if model_option is not None:
|
|
enabled_values = {
|
|
str(o.get("value") or "").strip()
|
|
for o in (model_option.get("options") or [])
|
|
if isinstance(o, dict)
|
|
and str(
|
|
((o.get("_meta") or {}).get("copilotEnablement")) or ""
|
|
).strip().lower() != "disabled"
|
|
}
|
|
if requested_model not in enabled_values:
|
|
return None
|
|
return (
|
|
"session/set_config_option",
|
|
{
|
|
"sessionId": session_id,
|
|
"configId": str(model_option.get("id") or "model"),
|
|
"value": requested_model,
|
|
},
|
|
)
|
|
|
|
advertised = [
|
|
m
|
|
for m in ((session.get("models") or {}).get("availableModels") or [])
|
|
if isinstance(m, dict)
|
|
]
|
|
available = {
|
|
str(m.get("modelId") or "").strip()
|
|
for m in advertised
|
|
if str(
|
|
((m.get("_meta") or {}).get("copilotEnablement")) or ""
|
|
).strip().lower() != "disabled"
|
|
}
|
|
if available and requested_model not in available:
|
|
return None
|
|
return (
|
|
"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:
|
|
sections: list[str] = [
|
|
"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 <tool_call>{...}</tool_call> blocks with JSON exactly in OpenAI function-call shape.",
|
|
"If no tool is needed, answer normally.",
|
|
]
|
|
# Deliberately no "requested model" line in the prompt: the model is
|
|
# applied for real via ACP session/set_model, and when the backend can't
|
|
# honor it (org-policy-disabled id) a prompt-text mention makes the
|
|
# serving model FALSELY self-identify as the requested one. Identity
|
|
# must come from the backend, not from prompt suggestion.
|
|
|
|
# Copilot has no tools of its own that would collide with Hermes', so it
|
|
# forwards the whole toolset (no allowlist).
|
|
sections.extend(_render_tool_bridge_sections(tools, tool_choice))
|
|
|
|
transcript: list[str] = []
|
|
for message in messages:
|
|
if not isinstance(message, dict):
|
|
continue
|
|
role = str(message.get("role") or "unknown").strip().lower()
|
|
if role == "tool":
|
|
role = "tool"
|
|
elif role not in {"system", "user", "assistant"}:
|
|
role = "context"
|
|
|
|
content = message.get("content")
|
|
rendered = _render_message_content(content)
|
|
if not rendered:
|
|
continue
|
|
|
|
label = {
|
|
"system": "System",
|
|
"user": "User",
|
|
"assistant": "Assistant",
|
|
"tool": "Tool",
|
|
"context": "Context",
|
|
}.get(role, role.title())
|
|
transcript.append(f"{label}:\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, str):
|
|
return content.strip()
|
|
if isinstance(content, dict):
|
|
if "text" in content:
|
|
return str(content.get("text") or "").strip()
|
|
if "content" in content and isinstance(content.get("content"), str):
|
|
return str(content.get("content") or "").strip()
|
|
return json.dumps(content, ensure_ascii=True)
|
|
if isinstance(content, list):
|
|
parts: list[str] = []
|
|
for item in content:
|
|
if isinstance(item, str):
|
|
parts.append(item)
|
|
elif isinstance(item, dict):
|
|
text = item.get("text")
|
|
if isinstance(text, str) and text.strip():
|
|
parts.append(text.strip())
|
|
return "\n".join(parts).strip()
|
|
return str(content).strip()
|
|
|
|
|
|
def _ensure_path_within_cwd(path_text: str, cwd: str) -> Path:
|
|
candidate = Path(path_text)
|
|
if not candidate.is_absolute():
|
|
raise PermissionError("ACP file-system paths must be absolute.")
|
|
resolved = candidate.resolve()
|
|
root = 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
|
|
|
|
|
|
class _ACPChatCompletions:
|
|
def __init__(self, client: "CopilotACPClient"):
|
|
self._client = client
|
|
|
|
def create(self, **kwargs: Any) -> Any:
|
|
return self._client._create_chat_completion(**kwargs)
|
|
|
|
|
|
class _ACPChatNamespace:
|
|
def __init__(self, client: "CopilotACPClient"):
|
|
self.completions = _ACPChatCompletions(client)
|
|
|
|
|
|
class CopilotACPClient:
|
|
"""Minimal OpenAI-client-compatible facade for Copilot ACP."""
|
|
|
|
# Declared for agent/auxiliary_client.py: this shim drives an ACP subprocess
|
|
# over stdio, so it is already a complete client (never re-dispatch it
|
|
# through a wire adapter) and is safe to use from async code as-is.
|
|
HERMES_SKIP_TRANSPORT_WRAP = True
|
|
HERMES_SKIP_ASYNC_WRAP = True
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
api_key: str | None = None,
|
|
base_url: str | None = None,
|
|
default_headers: dict[str, str] | None = None,
|
|
acp_command: str | None = None,
|
|
acp_args: list[str] | None = None,
|
|
acp_cwd: str | None = None,
|
|
command: str | None = None,
|
|
args: list[str] | None = None,
|
|
**_: Any,
|
|
):
|
|
self.api_key = api_key or "copilot-acp"
|
|
self.base_url = base_url or ACP_MARKER_BASE_URL
|
|
self._default_headers = dict(default_headers or {})
|
|
self._acp_command = acp_command or command or _resolve_command()
|
|
self._acp_args = list(acp_args or args or _resolve_args())
|
|
self._acp_cwd = str(Path(acp_cwd or os.getcwd()).resolve())
|
|
self.chat = _ACPChatNamespace(self)
|
|
self.is_closed = False
|
|
self._active_process: subprocess.Popen[str] | None = None
|
|
self._active_process_lock = threading.Lock()
|
|
|
|
def close(self) -> None:
|
|
proc: subprocess.Popen[str] | None
|
|
with self._active_process_lock:
|
|
proc = self._active_process
|
|
self._active_process = None
|
|
self.is_closed = True
|
|
if proc is None:
|
|
return
|
|
try:
|
|
proc.terminate()
|
|
proc.wait(timeout=2)
|
|
except Exception:
|
|
try:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
|
|
def _create_chat_completion(
|
|
self,
|
|
*,
|
|
model: str | None = None,
|
|
messages: list[dict[str, Any]] | None = None,
|
|
timeout: float | None = None,
|
|
tools: list[dict[str, Any]] | None = None,
|
|
tool_choice: Any = None,
|
|
stream: bool = False,
|
|
**_: Any,
|
|
) -> Any:
|
|
prompt_text = _format_messages_as_prompt(
|
|
messages or [],
|
|
model=model,
|
|
tools=tools,
|
|
tool_choice=tool_choice,
|
|
)
|
|
# Normalise timeout: run_agent.py may pass an httpx.Timeout object
|
|
# (used natively by the OpenAI SDK) rather than a plain float.
|
|
if timeout is None:
|
|
_effective_timeout = _DEFAULT_TIMEOUT_SECONDS
|
|
elif isinstance(timeout, (int, float)):
|
|
_effective_timeout = float(timeout)
|
|
else:
|
|
# httpx.Timeout or similar — pick the largest component so the
|
|
# subprocess has enough wall-clock time for the full response.
|
|
_candidates = [
|
|
getattr(timeout, attr, None)
|
|
for attr in ("read", "write", "connect", "pool", "timeout")
|
|
]
|
|
_numeric = [float(v) for v in _candidates if isinstance(v, (int, float))]
|
|
_effective_timeout = max(_numeric) if _numeric else _DEFAULT_TIMEOUT_SECONDS
|
|
|
|
response_text, reasoning_text = self._run_prompt(
|
|
prompt_text,
|
|
timeout_seconds=_effective_timeout,
|
|
model=model,
|
|
)
|
|
|
|
tool_calls, cleaned_text = _extract_tool_calls_from_text(response_text)
|
|
|
|
usage = SimpleNamespace(
|
|
prompt_tokens=0,
|
|
completion_tokens=0,
|
|
total_tokens=0,
|
|
prompt_tokens_details=SimpleNamespace(cached_tokens=0),
|
|
)
|
|
assistant_message = SimpleNamespace(
|
|
content=cleaned_text,
|
|
tool_calls=tool_calls,
|
|
reasoning=reasoning_text or None,
|
|
reasoning_content=reasoning_text or None,
|
|
reasoning_details=None,
|
|
)
|
|
finish_reason = "tool_calls" if tool_calls else "stop"
|
|
choice = SimpleNamespace(message=assistant_message, finish_reason=finish_reason)
|
|
completion = SimpleNamespace(
|
|
choices=[choice],
|
|
usage=usage,
|
|
model=model or "copilot-acp",
|
|
)
|
|
if stream:
|
|
return _completion_to_stream_chunks(completion)
|
|
return completion
|
|
|
|
def _run_prompt(
|
|
self,
|
|
prompt_text: str,
|
|
*,
|
|
timeout_seconds: float,
|
|
model: str | None = None,
|
|
) -> tuple[str, str]:
|
|
# Fast-fail when the CLI doesn't support the ACP args we'd pass.
|
|
# Without this guard, a CLI like Claude Code v2.x exits with
|
|
# ``error: unknown option '--acp'`` immediately, then the parent
|
|
# ACP loop waits the full ``child_timeout_seconds`` (default 600s)
|
|
# for stdout that never arrives. The probe costs ~50ms and turns
|
|
# a 600s silent hang into a 280ms clear error.
|
|
# ``None`` (inconclusive probe — e.g. binary missing) falls
|
|
# through to the spawn below, which raises the established
|
|
# "Could not start Copilot ACP command" error.
|
|
if _acp_supported(self._acp_command, self._acp_args) is False:
|
|
preview = " ".join(self._acp_args[:3]) if self._acp_args else "(none)"
|
|
raise RuntimeError(
|
|
f"ACP transport not supported by '{self._acp_command}': "
|
|
f"`{preview}` is rejected as an unknown option. "
|
|
f"This usually means the CLI is an older release (e.g. "
|
|
f"Claude Code v2.x) or a different tool than expected. "
|
|
f"Either install a CLI that ships with --acp support "
|
|
f"(e.g. `@github/copilot` late 2025+), or set "
|
|
f"HERMES_COPILOT_ACP_COMMAND / HERMES_COPILOT_ACP_ARGS "
|
|
f"to a working pair."
|
|
)
|
|
|
|
# Note the model Hermes selected; it is applied after session/new via
|
|
# the ACP-native `session/set_model` call. The CLI's `--model` spawn
|
|
# flag is deliberately NOT used here: `copilot --acp` validates it
|
|
# (an unknown id aborts the spawn) but then ignores it for the actual
|
|
# session, so it adds a failure mode without selecting anything.
|
|
requested_model = str(model or "").strip()
|
|
|
|
try:
|
|
# Hide the console the CLI child would otherwise flash on Windows
|
|
# (#56747). Hide-only — stdio pipes stay intact for the ACP wire.
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
|
|
proc = subprocess.Popen(
|
|
[self._acp_command] + self._acp_args,
|
|
stdin=subprocess.PIPE,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True, encoding='utf-8', errors='replace',
|
|
bufsize=1,
|
|
cwd=self._acp_cwd,
|
|
env=_build_subprocess_env(),
|
|
creationflags=windows_hide_flags(),
|
|
)
|
|
except FileNotFoundError as exc:
|
|
raise RuntimeError(
|
|
f"Could not start Copilot ACP command '{self._acp_command}'. "
|
|
"Install GitHub Copilot CLI or set HERMES_COPILOT_ACP_COMMAND/COPILOT_CLI_PATH."
|
|
) from exc
|
|
|
|
if proc.stdin is None or proc.stdout is None:
|
|
proc.kill()
|
|
raise RuntimeError("Copilot ACP process did not expose stdin/stdout pipes.")
|
|
|
|
self.is_closed = False
|
|
with self._active_process_lock:
|
|
self._active_process = proc
|
|
|
|
inbox: queue.Queue[dict[str, Any]] = queue.Queue()
|
|
stderr_tail: deque[str] = deque(maxlen=40)
|
|
|
|
def _stdout_reader() -> None:
|
|
if proc.stdout is None:
|
|
return
|
|
for line in proc.stdout:
|
|
try:
|
|
inbox.put(json.loads(line))
|
|
except Exception:
|
|
inbox.put({"raw": line.rstrip("\n")})
|
|
|
|
def _stderr_reader() -> None:
|
|
if proc.stderr is None:
|
|
return
|
|
for line in proc.stderr:
|
|
stderr_tail.append(line.rstrip("\n"))
|
|
|
|
out_thread = threading.Thread(target=_stdout_reader, daemon=True)
|
|
err_thread = threading.Thread(target=_stderr_reader, daemon=True)
|
|
out_thread.start()
|
|
err_thread.start()
|
|
|
|
next_id = 0
|
|
|
|
def _request(method: str, params: dict[str, Any], *, text_parts: list[str] | None = None, reasoning_parts: list[str] | None = None) -> Any:
|
|
nonlocal next_id
|
|
next_id += 1
|
|
request_id = next_id
|
|
payload = {
|
|
"jsonrpc": "2.0",
|
|
"id": request_id,
|
|
"method": method,
|
|
"params": params,
|
|
}
|
|
proc.stdin.write(json.dumps(payload) + "\n")
|
|
proc.stdin.flush()
|
|
|
|
deadline = time.monotonic() + timeout_seconds
|
|
while time.monotonic() < deadline:
|
|
if proc.poll() is not None:
|
|
break
|
|
try:
|
|
msg = inbox.get(timeout=0.1)
|
|
except queue.Empty:
|
|
continue
|
|
|
|
if self._handle_server_message(
|
|
msg,
|
|
process=proc,
|
|
cwd=self._acp_cwd,
|
|
text_parts=text_parts,
|
|
reasoning_parts=reasoning_parts,
|
|
):
|
|
continue
|
|
|
|
if msg.get("id") != request_id:
|
|
continue
|
|
if "error" in msg:
|
|
err = msg.get("error") or {}
|
|
raise RuntimeError(
|
|
f"Copilot ACP {method} failed: {err.get('message') or err}"
|
|
)
|
|
return msg.get("result")
|
|
|
|
stderr_text = "\n".join(stderr_tail).strip()
|
|
if proc.poll() is not None and stderr_text:
|
|
if _is_gh_copilot_deprecation_message(stderr_text):
|
|
raise RuntimeError(
|
|
"Hermes ACP mode requires the NEW GitHub Copilot CLI "
|
|
"(github.com/github/copilot-cli), but the binary it just "
|
|
"spawned is the deprecated `gh copilot` extension.\n\n"
|
|
"Install the new CLI:\n"
|
|
" npm install -g @github/copilot\n"
|
|
" # then verify with: copilot --help\n\n"
|
|
"If `copilot` already resolves to the new CLI but you still see this,\n"
|
|
"point Hermes at it explicitly:\n"
|
|
" export HERMES_COPILOT_ACP_COMMAND=/path/to/new/copilot\n\n"
|
|
"Alternative: use the `copilot` provider (no ACP, hits the Copilot API\n"
|
|
"directly with a Copilot subscription token) via `hermes setup`.\n\n"
|
|
f"Original error:\n{stderr_text}"
|
|
)
|
|
raise RuntimeError(f"Copilot ACP process exited early: {stderr_text}")
|
|
raise TimeoutError(f"Timed out waiting for Copilot ACP response to {method}.")
|
|
|
|
try:
|
|
_request(
|
|
"initialize",
|
|
{
|
|
"protocolVersion": 1,
|
|
"clientCapabilities": {
|
|
"fs": {
|
|
"readTextFile": True,
|
|
"writeTextFile": True,
|
|
}
|
|
},
|
|
"clientInfo": {
|
|
"name": "hermes-agent",
|
|
"title": "Hermes Agent",
|
|
"version": "0.0.0",
|
|
},
|
|
},
|
|
)
|
|
session = _request(
|
|
"session/new",
|
|
{
|
|
"cwd": self._acp_cwd,
|
|
"mcpServers": [],
|
|
},
|
|
) or {}
|
|
session_id = str(session.get("sessionId") or "").strip()
|
|
if not session_id:
|
|
raise RuntimeError("Copilot ACP did not return a sessionId.")
|
|
|
|
# Select the model Hermes asked for. Prefer the stable ACP v1
|
|
# session-config API: session/new advertises a category="model"
|
|
# select option and session/set_config_option updates it. Copilot
|
|
# still exposes the older models/session/set_model extension too,
|
|
# so retain that only as compatibility fallback for older agents.
|
|
if requested_model and requested_model != "copilot-acp":
|
|
try:
|
|
selection = _model_selection_request(session, requested_model)
|
|
if selection is not None:
|
|
method, params = selection
|
|
_request(method, params)
|
|
else:
|
|
logger.warning(
|
|
"Copilot ACP does not offer model %r; using the "
|
|
"session default.",
|
|
requested_model,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Copilot ACP model selection for %r failed; continuing "
|
|
"with the session default: %s",
|
|
requested_model,
|
|
exc,
|
|
)
|
|
|
|
text_parts: list[str] = []
|
|
reasoning_parts: list[str] = []
|
|
_request(
|
|
"session/prompt",
|
|
{
|
|
"sessionId": session_id,
|
|
"prompt": [
|
|
{
|
|
"type": "text",
|
|
"text": prompt_text,
|
|
}
|
|
],
|
|
},
|
|
text_parts=text_parts,
|
|
reasoning_parts=reasoning_parts,
|
|
)
|
|
return "".join(text_parts), "".join(reasoning_parts)
|
|
finally:
|
|
self.close()
|
|
|
|
def _handle_server_message(
|
|
self,
|
|
msg: dict[str, Any],
|
|
*,
|
|
process: subprocess.Popen[str],
|
|
cwd: str,
|
|
text_parts: list[str] | None,
|
|
reasoning_parts: list[str] | None,
|
|
) -> bool:
|
|
method = msg.get("method")
|
|
if not isinstance(method, str):
|
|
return False
|
|
|
|
if method == "session/update":
|
|
params = msg.get("params") or {}
|
|
update = params.get("update") or {}
|
|
kind = str(update.get("sessionUpdate") or "").strip()
|
|
content = update.get("content") or {}
|
|
chunk_text = ""
|
|
if isinstance(content, dict):
|
|
chunk_text = str(content.get("text") or "")
|
|
if kind == "agent_message_chunk" and chunk_text and text_parts is not None:
|
|
text_parts.append(chunk_text)
|
|
elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None:
|
|
reasoning_parts.append(chunk_text)
|
|
return True
|
|
|
|
if process.stdin is None:
|
|
return True
|
|
|
|
message_id = msg.get("id")
|
|
params = msg.get("params") or {}
|
|
|
|
if method == "session/request_permission":
|
|
response = _permission_denied(message_id)
|
|
elif method == "fs/read_text_file":
|
|
try:
|
|
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
|
|
block_error = get_read_block_error(str(path))
|
|
if block_error:
|
|
raise PermissionError(block_error)
|
|
try:
|
|
content = path.read_text(encoding="utf-8")
|
|
except FileNotFoundError:
|
|
content = ""
|
|
line = params.get("line")
|
|
limit = params.get("limit")
|
|
if isinstance(line, int) and line > 1:
|
|
lines = content.splitlines(keepends=True)
|
|
start = line - 1
|
|
end = start + limit if isinstance(limit, int) and limit > 0 else None
|
|
content = "".join(lines[start:end])
|
|
if content:
|
|
content = redact_sensitive_text(content, force=True)
|
|
response = {
|
|
"jsonrpc": "2.0",
|
|
"id": message_id,
|
|
"result": {
|
|
"content": content,
|
|
},
|
|
}
|
|
except Exception as exc:
|
|
response = _jsonrpc_error(message_id, -32602, str(exc))
|
|
elif method == "fs/write_text_file":
|
|
try:
|
|
path = _ensure_path_within_cwd(str(params.get("path") or ""), cwd)
|
|
denied = get_write_denied_error(str(path))
|
|
if denied:
|
|
raise PermissionError(denied)
|
|
# Approval-gated paths (e.g. ~/.ssh/config) are not hard-denied
|
|
# for interactive tools, but the ACP shim has no human channel
|
|
# to confirm the write — fail closed here.
|
|
if is_write_approval_required(str(path)):
|
|
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")
|
|
response = {
|
|
"jsonrpc": "2.0",
|
|
"id": message_id,
|
|
"result": None,
|
|
}
|
|
except Exception as exc:
|
|
response = _jsonrpc_error(message_id, -32602, str(exc))
|
|
else:
|
|
response = _jsonrpc_error(
|
|
message_id,
|
|
-32601,
|
|
f"ACP client method '{method}' is not supported by Hermes yet.",
|
|
)
|
|
|
|
process.stdin.write(json.dumps(response) + "\n")
|
|
process.stdin.flush()
|
|
return True
|