fix: surface copilot acp progress and stale detection

This commit is contained in:
David Zhang 2026-04-24 17:54:46 +07:00
parent 18f3fc8a6f
commit 0524a40790
4 changed files with 196 additions and 13 deletions

View file

@ -9,6 +9,7 @@ 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
@ -25,12 +26,31 @@ from agent.file_safety import get_read_block_error, is_write_denied
from agent.redact import redact_sensitive_text
ACP_MARKER_BASE_URL = "acp://copilot"
_DEFAULT_TIMEOUT_SECONDS = 900.0
logger = logging.getLogger(__name__)
_TOOL_CALL_BLOCK_RE = re.compile(r"<tool_call>\s*(\{.*?\})\s*</tool_call>", re.DOTALL)
_TOOL_CALL_JSON_RE = re.compile(r"\{\s*\"id\"\s*:\s*\"[^\"]+\"\s*,\s*\"type\"\s*:\s*\"function\"\s*,\s*\"function\"\s*:\s*\{.*?\}\s*\}", re.DOTALL)
def _resolve_timeout_seconds() -> float:
raw = os.getenv("HERMES_COPILOT_ACP_TIMEOUT_SECONDS", "").strip()
try:
timeout = float(raw) if raw else 300.0
except ValueError:
return 300.0
return timeout if timeout > 0 else 300.0
def _resolve_inactivity_timeout_seconds() -> float:
raw = os.getenv("HERMES_COPILOT_ACP_INACTIVITY_TIMEOUT_SECONDS", "").strip()
try:
timeout = float(raw) if raw else 300.0
except ValueError:
return 300.0
return timeout if timeout > 0 else 300.0
def _resolve_command() -> str:
return (
os.getenv("HERMES_COPILOT_ACP_COMMAND", "").strip()
@ -277,6 +297,9 @@ class CopilotACPClient:
api_key: str | None = None,
base_url: str | None = None,
default_headers: dict[str, str] | None = None,
activity_callback: Any | None = None,
stream_delta_callback: Any | None = None,
reasoning_callback: Any | None = None,
acp_command: str | None = None,
acp_args: list[str] | None = None,
acp_cwd: str | None = None,
@ -287,6 +310,9 @@ class CopilotACPClient:
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._activity_callback = activity_callback
self._stream_delta_callback = stream_delta_callback
self._reasoning_callback = reasoning_callback
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())
@ -331,7 +357,7 @@ class CopilotACPClient:
# 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
_effective_timeout = _resolve_timeout_seconds()
elif isinstance(timeout, (int, float)):
_effective_timeout = float(timeout)
else:
@ -342,7 +368,7 @@ class CopilotACPClient:
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
_effective_timeout = max(_numeric) if _numeric else _resolve_timeout_seconds()
response_text, reasoning_text = self._run_prompt(
prompt_text,
@ -436,14 +462,25 @@ class CopilotACPClient:
proc.stdin.flush()
deadline = time.time() + timeout_seconds
last_message_time = time.time()
inactivity_timeout = min(timeout_seconds, _resolve_inactivity_timeout_seconds())
while time.time() < deadline:
if proc.poll() is not None:
break
try:
msg = inbox.get(timeout=0.1)
except queue.Empty:
silence = time.time() - last_message_time
if silence > inactivity_timeout:
raise TimeoutError(
f"Copilot ACP {method} went silent for {int(silence)}s "
f"(threshold: {int(inactivity_timeout)}s)."
)
continue
last_message_time = time.time()
self._notify_activity(f"copilot-acp:{method}")
if self._handle_server_message(
msg,
process=proc,
@ -539,8 +576,10 @@ class CopilotACPClient:
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)
self._emit_stream_delta(chunk_text)
elif kind == "agent_thought_chunk" and chunk_text and reasoning_parts is not None:
reasoning_parts.append(chunk_text)
self._emit_reasoning_delta(chunk_text)
return True
if process.stdin is None:
@ -602,3 +641,30 @@ class CopilotACPClient:
process.stdin.write(json.dumps(response) + "\n")
process.stdin.flush()
return True
def _notify_activity(self, detail: str | None = None) -> None:
cb = self._activity_callback
if cb is None:
return
try:
cb(detail)
except Exception:
logger.debug("Copilot ACP activity callback failed", exc_info=True)
def _emit_stream_delta(self, text: str) -> None:
cb = self._stream_delta_callback
if cb is None or not text:
return
try:
cb(text)
except Exception:
logger.debug("Copilot ACP stream delta callback failed", exc_info=True)
def _emit_reasoning_delta(self, text: str) -> None:
cb = self._reasoning_callback
if cb is None or not text:
return
try:
cb(text)
except Exception:
logger.debug("Copilot ACP reasoning callback failed", exc_info=True)