2026-05-15 06:58:03 +00:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
import shutil
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
from dataclasses import dataclass
|
|
|
|
|
|
from pathlib import Path
|
2026-05-16 06:14:08 +00:00
|
|
|
|
from typing import Mapping
|
|
|
|
|
|
|
|
|
|
|
|
from app.core.config import SERVER_DIR
|
2026-05-15 06:58:03 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class HermesCliResult:
|
|
|
|
|
|
response_text: str
|
|
|
|
|
|
session_id: str = ""
|
|
|
|
|
|
command: tuple[str, ...] = ()
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-16 06:14:08 +00:00
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
|
|
|
|
class HermesProcessHandle:
|
|
|
|
|
|
pid: int
|
|
|
|
|
|
command: tuple[str, ...] = ()
|
|
|
|
|
|
stdout_path: str = ""
|
|
|
|
|
|
stderr_path: str = ""
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-15 06:58:03 +00:00
|
|
|
|
class SystemHermesService:
|
|
|
|
|
|
def __init__(self) -> None:
|
|
|
|
|
|
configured_bin = str(os.getenv("HERMES_BIN", "")).strip()
|
|
|
|
|
|
self.hermes_bin = configured_bin or shutil.which("hermes") or "/usr/local/bin/hermes"
|
|
|
|
|
|
|
|
|
|
|
|
def is_available(self) -> bool:
|
|
|
|
|
|
return Path(self.hermes_bin).exists()
|
|
|
|
|
|
|
|
|
|
|
|
def run_query(
|
|
|
|
|
|
self,
|
|
|
|
|
|
query: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
source: str = "tool",
|
|
|
|
|
|
max_turns: int = 1,
|
|
|
|
|
|
timeout_seconds: int = 180,
|
2026-05-16 06:14:08 +00:00
|
|
|
|
skills: tuple[str, ...] = (),
|
|
|
|
|
|
env_overrides: Mapping[str, str] | None = None,
|
|
|
|
|
|
yolo: bool = False,
|
2026-05-15 06:58:03 +00:00
|
|
|
|
) -> HermesCliResult:
|
|
|
|
|
|
if not self.is_available():
|
|
|
|
|
|
raise RuntimeError(f"未找到系统 Hermes CLI:{self.hermes_bin}")
|
|
|
|
|
|
|
2026-05-16 06:14:08 +00:00
|
|
|
|
command = self._build_command(
|
2026-05-15 06:58:03 +00:00
|
|
|
|
query,
|
2026-05-16 06:14:08 +00:00
|
|
|
|
source=source,
|
|
|
|
|
|
max_turns=max_turns,
|
|
|
|
|
|
skills=skills,
|
|
|
|
|
|
yolo=yolo,
|
2026-05-15 06:58:03 +00:00
|
|
|
|
)
|
2026-05-16 06:14:08 +00:00
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
|
if env_overrides:
|
|
|
|
|
|
env.update({str(key): str(value) for key, value in env_overrides.items()})
|
2026-05-15 06:58:03 +00:00
|
|
|
|
completed = subprocess.run(
|
|
|
|
|
|
command,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=timeout_seconds,
|
|
|
|
|
|
check=False,
|
2026-05-16 06:14:08 +00:00
|
|
|
|
env=env,
|
2026-05-15 06:58:03 +00:00
|
|
|
|
)
|
|
|
|
|
|
if completed.returncode != 0:
|
|
|
|
|
|
detail = (completed.stderr or completed.stdout or "").strip()
|
|
|
|
|
|
raise RuntimeError(detail or "Hermes CLI 返回非 0 状态码。")
|
|
|
|
|
|
|
|
|
|
|
|
return self._parse_output(completed.stdout, command=command)
|
|
|
|
|
|
|
2026-05-16 06:14:08 +00:00
|
|
|
|
def start_query_background(
|
|
|
|
|
|
self,
|
|
|
|
|
|
query: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
source: str = "tool",
|
|
|
|
|
|
max_turns: int = 1,
|
|
|
|
|
|
skills: tuple[str, ...] = (),
|
|
|
|
|
|
env_overrides: Mapping[str, str] | None = None,
|
|
|
|
|
|
log_prefix: str = "hermes",
|
|
|
|
|
|
yolo: bool = False,
|
|
|
|
|
|
) -> HermesProcessHandle:
|
|
|
|
|
|
if not self.is_available():
|
|
|
|
|
|
raise RuntimeError(f"未找到系统 Hermes CLI:{self.hermes_bin}")
|
|
|
|
|
|
|
|
|
|
|
|
command = self._build_command(
|
|
|
|
|
|
query,
|
|
|
|
|
|
source=source,
|
|
|
|
|
|
max_turns=max_turns,
|
|
|
|
|
|
skills=skills,
|
|
|
|
|
|
yolo=yolo,
|
|
|
|
|
|
)
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
|
if env_overrides:
|
|
|
|
|
|
env.update({str(key): str(value) for key, value in env_overrides.items()})
|
|
|
|
|
|
log_dir = SERVER_DIR / "logs"
|
|
|
|
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
safe_prefix = "".join(ch if ch.isalnum() or ch in {"-", "_"} else "-" for ch in log_prefix)
|
|
|
|
|
|
stdout_path = log_dir / f"{safe_prefix}.out.log"
|
|
|
|
|
|
stderr_path = log_dir / f"{safe_prefix}.err.log"
|
|
|
|
|
|
stdout_file = stdout_path.open("ab")
|
|
|
|
|
|
stderr_file = stderr_path.open("ab")
|
|
|
|
|
|
process = subprocess.Popen(
|
|
|
|
|
|
command,
|
|
|
|
|
|
stdout=stdout_file,
|
|
|
|
|
|
stderr=stderr_file,
|
|
|
|
|
|
env=env,
|
|
|
|
|
|
start_new_session=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
stdout_file.close()
|
|
|
|
|
|
stderr_file.close()
|
|
|
|
|
|
return HermesProcessHandle(
|
|
|
|
|
|
pid=process.pid,
|
|
|
|
|
|
command=command,
|
|
|
|
|
|
stdout_path=str(stdout_path),
|
|
|
|
|
|
stderr_path=str(stderr_path),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
def _build_command(
|
|
|
|
|
|
self,
|
|
|
|
|
|
query: str,
|
|
|
|
|
|
*,
|
|
|
|
|
|
source: str,
|
|
|
|
|
|
max_turns: int,
|
|
|
|
|
|
skills: tuple[str, ...],
|
|
|
|
|
|
yolo: bool,
|
|
|
|
|
|
) -> tuple[str, ...]:
|
|
|
|
|
|
command_parts = [
|
|
|
|
|
|
self.hermes_bin,
|
|
|
|
|
|
"chat",
|
|
|
|
|
|
"-Q",
|
|
|
|
|
|
"--source",
|
|
|
|
|
|
source,
|
|
|
|
|
|
"--max-turns",
|
|
|
|
|
|
str(max_turns),
|
|
|
|
|
|
]
|
|
|
|
|
|
for skill in skills:
|
|
|
|
|
|
normalized_skill = str(skill or "").strip()
|
|
|
|
|
|
if normalized_skill:
|
|
|
|
|
|
command_parts.extend(["--skills", normalized_skill])
|
|
|
|
|
|
if yolo:
|
|
|
|
|
|
command_parts.append("--yolo")
|
|
|
|
|
|
command_parts.extend(["-q", query])
|
|
|
|
|
|
return tuple(command_parts)
|
|
|
|
|
|
|
2026-05-15 06:58:03 +00:00
|
|
|
|
@staticmethod
|
|
|
|
|
|
def _parse_output(stdout: str, *, command: tuple[str, ...]) -> HermesCliResult:
|
|
|
|
|
|
lines = [line.rstrip() for line in str(stdout or "").splitlines()]
|
|
|
|
|
|
session_id = ""
|
|
|
|
|
|
response_lines: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
for line in lines:
|
|
|
|
|
|
if line.startswith("session_id:"):
|
|
|
|
|
|
session_id = line.split(":", 1)[1].strip()
|
|
|
|
|
|
continue
|
|
|
|
|
|
response_lines.append(line)
|
|
|
|
|
|
|
|
|
|
|
|
response_text = "\n".join(line for line in response_lines if line.strip()).strip()
|
|
|
|
|
|
return HermesCliResult(
|
|
|
|
|
|
response_text=response_text,
|
|
|
|
|
|
session_id=session_id,
|
|
|
|
|
|
command=command,
|
|
|
|
|
|
)
|