Files
hermes-mac13 93758f3720 feat: 从 tutti-selfhost 拆出 local_rig 独立仓
- local_rig/ 完整包(7 源文件 + ui.html + CHANGELOG)
- tests/test_local_rig.py + test_rig_real_agent.py(纯 local_rig 测试)
- scripts/15-criterion4-live.sh(判据④活环境门)
- README.md(定位/快速开始/与 tutti-selfhost 的关系)
- requirements.txt(唯一外部依赖 pyyaml)
- .gitignore

来源: figmar/tutti-selfhost @ 3b23853 (2026-10-06)
原 PR #43 已合入 tutti-selfhost main
2026-10-06 13:36:14 +08:00

159 lines
5.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Hermes 运行时适配器(standalone 模式:spawn CLI 子进程,零外部依赖)。
两种模式:
- hermes-cli: spawn `hermes chat --agent <target>` 子进程,stdin/stdout 通信
- hermes-acp: WS 连 tuttid(可选增强,需 tuttid 在跑)
第一版只做 hermes-cli。
"""
from __future__ import annotations
import json
import os
import shutil
import signal
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
@dataclass
class HermesProcess:
target: str
profile: str = "default"
proc: subprocess.Popen | None = None
started_at: float = 0.0
@property
def alive(self) -> bool:
return self.proc is not None and self.proc.poll() is None
class HermesCliRuntime:
"""spawn hermes CLI 子进程,stdin 写 prompt,stdout 收回复。"""
def __init__(self, hermes_bin: str | None = None):
if hermes_bin:
self.hermes_bin = hermes_bin
else:
# 自动检测:PATH → desktop-bin
found = shutil.which("hermes")
if found:
self.hermes_bin = found
else:
default = (Path.home() / "Library/Application Support"
/ "cn.org.hermesagent.desktop/runtime/desktop-bin/hermes")
self.hermes_bin = str(default) if default.exists() else "hermes"
def available(self) -> bool:
# 绝对路径直接查存在性,否则走 which
if Path(self.hermes_bin).is_absolute():
return Path(self.hermes_bin).exists()
return shutil.which(self.hermes_bin) is not None
def spawn(self, target: str, prompt: str | None = None,
cwd: str = ".", timeout: float = 300.0,
profile: str = "default") -> HermesProcess:
"""启动 hermes 子进程。prompt=None 时交互式等待。"""
cmd = [self.hermes_bin, "chat"]
if profile and profile != "default":
cmd += ["--profile", profile]
# 【dsh@mac13 实测】hermes CLI 无全局 --agent(只有 profile)⇒ 传它会被 argparse
# 当成子命令名而直接报错。成员的区分改由 --profile 承担;target 仅作为 rig 内的键。
if prompt:
cmd.append(prompt)
env = os.environ.copy()
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE if not prompt else subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
cwd=cwd,
env=env,
)
hp = HermesProcess(target=target, profile=profile, proc=proc, started_at=time.time())
return hp
def send_prompt(self, hp: HermesProcess, prompt: str) -> str:
"""向已启动的 hermes 进程发送 prompt,等回复。"""
if not hp.alive or not hp.proc:
raise RuntimeError(f"hermes[{hp.target}] 不在运行")
assert hp.proc.stdin and hp.proc.stdout
hp.proc.stdin.write(prompt + "\n")
hp.proc.stdin.flush()
# 简单阻塞读(生产应改 async)
line = hp.proc.stdout.readline()
return line.strip()
def stop(self, hp: HermesProcess):
if hp.alive and hp.proc:
try:
hp.proc.terminate()
hp.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
hp.proc.kill()
finally:
hp.proc = None
def status(self, hp: HermesProcess) -> str:
if hp.alive:
return "running"
return "exited" if hp.proc else "stopped"
class HermesAcpRuntime:
"""WS 连 tuttid(可选增强,需 tuttid 在跑 + websockets 库)。
复用 bridge/tuttid.py 的 WsTuttidSource 逻辑(复制少量代码保持自包含)。
"""
def __init__(self, base_url: str = "http://127.0.0.1:27800",
token: str = "", target: str = "hermes"):
self.base_url = base_url.rstrip("/")
self.token = token
self.target = target
def available(self) -> bool:
"""检查 tuttid 是否在跑。"""
import urllib.request
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
try:
req = urllib.request.Request(
f"{self.base_url}/v1/health",
headers={"Authorization": f"Bearer {self.token}"})
with urllib.request.urlopen(req, context=ctx, timeout=3) as resp:
return resp.status == 200
except Exception:
return False
def send_task(self, prompt: str, context: dict | None = None) -> dict:
"""通过 ACP 协议发任务(简化版:HTTP POST)。"""
import urllib.request
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
payload = json.dumps({
"prompt": prompt,
"context": context or {},
"target": self.target,
}).encode()
req = urllib.request.Request(
f"{self.base_url}/v1/tasks",
data=payload,
headers={
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
})
try:
with urllib.request.urlopen(req, context=ctx, timeout=30) as resp:
return json.loads(resp.read())
except Exception as e:
return {"error": str(e)}