mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
fix: surface copilot acp progress and stale detection
This commit is contained in:
parent
18f3fc8a6f
commit
0524a40790
4 changed files with 196 additions and 13 deletions
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue