from __future__ import annotations import os import shutil import subprocess from dataclasses import dataclass from pathlib import Path from typing import Mapping from app.core.config import SERVER_DIR @dataclass(frozen=True, slots=True) class HermesCliResult: response_text: str session_id: str = "" command: tuple[str, ...] = () @dataclass(frozen=True, slots=True) class HermesProcessHandle: pid: int command: tuple[str, ...] = () stdout_path: str = "" stderr_path: str = "" 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, skills: tuple[str, ...] = (), env_overrides: Mapping[str, str] | None = None, yolo: bool = False, ) -> HermesCliResult: 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()}) completed = subprocess.run( command, capture_output=True, text=True, timeout=timeout_seconds, check=False, env=env, ) 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) 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) @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, )