hermes_bridge.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462
  1. #!/usr/bin/env python3
  2. """
  3. Hermes Bridge - 将 Hermes AIAgent 封装为 HTTP API,供 Spring Boot 工作流引擎调用。
  4. 启动方式:
  5. python hermes_bridge.py --port 18731
  6. python hermes_bridge.py --port 18731 --hermes-home /path/to/.hermes
  7. API:
  8. POST /run 运行一次 Agent 对话(SSE 流式响应)
  9. GET /health 健康检查
  10. """
  11. import argparse
  12. import json
  13. import logging
  14. import os
  15. import sys
  16. import threading
  17. import time
  18. import uuid
  19. from collections import OrderedDict
  20. from pathlib import Path
  21. from typing import Optional
  22. # ---------------------------------------------------------------------------
  23. # 强制 stdout/stderr 使用 UTF-8(Windows 默认 cp936 会导致中文输出乱码)
  24. # 必须在任何 print/logging 之前执行
  25. # ---------------------------------------------------------------------------
  26. for _stream in (sys.stdout, sys.stderr):
  27. try:
  28. _stream.reconfigure(encoding="utf-8", errors="replace")
  29. except Exception:
  30. pass
  31. # ---------------------------------------------------------------------------
  32. # 父进程守护:Java 后端被 kill 后,子进程自动退出,释放端口与资源
  33. # ---------------------------------------------------------------------------
  34. def _start_parent_watcher():
  35. """启动守护线程,当父进程退出时自杀。"""
  36. try:
  37. import psutil
  38. except ImportError:
  39. logging.warning("未安装 psutil,无法监听父进程状态;Java 退出后子进程可能残留")
  40. return
  41. try:
  42. parent = psutil.Process(os.getppid())
  43. except Exception:
  44. return
  45. def _watch():
  46. while True:
  47. time.sleep(2)
  48. try:
  49. if not parent.is_running() or parent.status() == psutil.STATUS_ZOMBIE:
  50. logging.info("父进程已退出,Hermes Bridge 自动终止")
  51. os._exit(0)
  52. except Exception:
  53. logging.info("父进程状态不可获取,Hermes Bridge 自动终止")
  54. os._exit(0)
  55. watcher = threading.Thread(target=_watch, daemon=True, name="parent-watcher")
  56. watcher.start()
  57. _start_parent_watcher()
  58. # ---------------------------------------------------------------------------
  59. # Hermes 项目路径注入(让 Python 能找到 hermes-agent 的源码)
  60. # ---------------------------------------------------------------------------
  61. BRIDGE_DIR = Path(__file__).resolve().parent
  62. HERMES_AGENT_DIR = BRIDGE_DIR.parent / "hermes-agent"
  63. if HERMES_AGENT_DIR.is_dir() and str(HERMES_AGENT_DIR) not in sys.path:
  64. sys.path.insert(0, str(HERMES_AGENT_DIR))
  65. # ---------------------------------------------------------------------------
  66. # 日志
  67. # ---------------------------------------------------------------------------
  68. logging.basicConfig(
  69. level=logging.INFO,
  70. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  71. stream=sys.stderr,
  72. )
  73. logger = logging.getLogger("hermes-bridge")
  74. # ---------------------------------------------------------------------------
  75. # FastAPI app
  76. # ---------------------------------------------------------------------------
  77. try:
  78. from fastapi import FastAPI, HTTPException
  79. from fastapi.responses import StreamingResponse
  80. from pydantic import BaseModel
  81. except ImportError:
  82. logger.error("请先安装 FastAPI: pip install fastapi uvicorn[standard]")
  83. sys.exit(1)
  84. app = FastAPI(title="Hermes Bridge", version="1.0.0")
  85. # ---------------------------------------------------------------------------
  86. # 共享密钥(X-Bridge-Token 头校验)
  87. # ---------------------------------------------------------------------------
  88. # 启动时从环境变量读取;为空则禁用认证(仅依赖 127.0.0.1 绑定)
  89. _BRIDGE_AUTH_TOKEN = os.environ.get("HERMES_BRIDGE_AUTH_TOKEN", "").strip()
  90. @app.middleware("http")
  91. async def _verify_token(request, call_next):
  92. """校验 X-Bridge-Token 头(/health 不校验)"""
  93. if _BRIDGE_AUTH_TOKEN and request.url.path != "/health":
  94. token = request.headers.get("X-Bridge-Token", "")
  95. if token != _BRIDGE_AUTH_TOKEN:
  96. from fastapi.responses import JSONResponse
  97. return JSONResponse(status_code=401, content={"detail": "invalid or missing X-Bridge-Token"})
  98. return await call_next(request)
  99. # ---------------------------------------------------------------------------
  100. # 全局 Agent 池(按 session_id::hermes_home 复用,有界 LRU)
  101. # ---------------------------------------------------------------------------
  102. _agent_lock = threading.Lock()
  103. _agents: "OrderedDict[str, object]" = OrderedDict()
  104. _AGENT_CACHE_MAX = 8 # 最大缓存实例数;超出按 FIFO 淘汰,防止无界增长
  105. # ---------------------------------------------------------------------------
  106. # 请求模型
  107. # ---------------------------------------------------------------------------
  108. class RunRequest(BaseModel):
  109. """运行 Agent 对话的请求体"""
  110. system_prompt: Optional[str] = None # 系统提示(如 SKILL.md 内容)
  111. user_message: str # 用户消息
  112. max_iterations: int = 100 # 最大工具调用轮次(兜底默认值,由后端配置覆盖)
  113. session_id: Optional[str] = None # 会话 ID(复用 Agent 实例)
  114. hermes_home: Optional[str] = None # 本次运行使用的 HERMES_HOME 路径(其下应有 skills 子目录)
  115. working_dir: Optional[str] = None # agent 工具调用的实际工作目录(终端、文件读写以它为根)
  116. model_id: Optional[str] = None # 节点指定的模型 ID
  117. base_url: Optional[str] = None # 节点指定的模型 base URL
  118. api_key: Optional[str] = None # 节点指定的模型 API Key
  119. model_name: Optional[str] = None # 节点指定的模型标识
  120. # ---------------------------------------------------------------------------
  121. # Agent 工厂
  122. # ---------------------------------------------------------------------------
  123. def _get_or_create_agent(session_id: str, max_iterations: int, hermes_home: Optional[str] = None,
  124. model_id: Optional[str] = None,
  125. base_url: Optional[str] = None,
  126. api_key: Optional[str] = None,
  127. model_name: Optional[str] = None):
  128. """获取或创建 AIAgent 实例
  129. hermes_home 用于覆盖本次运行的 HERMES_HOME(其下应有 skills 子目录),
  130. 实现按工作流运行目录隔离的 Skill 加载。
  131. model_id/base_url/api_key/model_name 支持按节点动态切换模型;
  132. 未提供时回退到环境变量默认配置。
  133. """
  134. # 缓存键同时绑定 hermes_home 与模型 ID,避免跨运行目录/模型复用 agent
  135. cache_key = f"{session_id}::{hermes_home or ''}::{model_id or 'default'}" if session_id else None
  136. if cache_key:
  137. with _agent_lock:
  138. if cache_key in _agents:
  139. _agents.move_to_end(cache_key) # LRU 提升到最新
  140. return _agents[cache_key]
  141. # 在导入/创建 AIAgent 之前显式覆盖 HERMES_HOME,使 get_hermes_home() 返回正确路径
  142. if hermes_home:
  143. os.environ["HERMES_HOME"] = str(hermes_home)
  144. logger.info("使用请求指定的 HERMES_HOME: %s", hermes_home)
  145. # 安装沙箱补丁(幂等),强制 hermes-agent 文件操作限制在 _SESSION_CWD 内
  146. try:
  147. import sandbox_patch
  148. sandbox_patch.install_sandbox_patches()
  149. except Exception as e:
  150. logger.warning("沙箱补丁加载异常(继续但不保证隔离): %s", e)
  151. try:
  152. from run_agent import AIAgent
  153. except ImportError:
  154. raise ImportError(
  155. "无法导入 hermes-agent。请确保 hermes-agent 目录存在或已安装 hermes-agent 包。\n"
  156. f"尝试的路径: {HERMES_AGENT_DIR}"
  157. )
  158. # 优先使用请求中传入的模型配置,其次回退到环境变量
  159. effective_base_url = base_url or os.environ.get("HERMES_BRIDGE_BASE_URL", "")
  160. effective_api_key = api_key or os.environ.get("HERMES_BRIDGE_API_KEY", "")
  161. effective_model = model_name or os.environ.get("HERMES_BRIDGE_MODEL", "")
  162. # 尝试从 Hermes config.yaml 读取(如果未通过请求或环境变量指定)
  163. if not effective_base_url or not effective_api_key:
  164. _load_hermes_config()
  165. effective_base_url = effective_base_url or os.environ.get("HERMES_BRIDGE_BASE_URL", "")
  166. effective_api_key = effective_api_key or os.environ.get("HERMES_BRIDGE_API_KEY", "")
  167. effective_model = effective_model or os.environ.get("HERMES_BRIDGE_MODEL", "")
  168. if not effective_api_key:
  169. raise ValueError("未配置 API Key。请设置 HERMES_BRIDGE_API_KEY 环境变量或在 Hermes config.yaml 中配置。")
  170. logger.info(
  171. "创建 AIAgent: session=%s, model_id=%s, model=%s, base_url=%s",
  172. session_id, model_id or "默认", effective_model or "默认",
  173. effective_base_url or "默认"
  174. )
  175. kwargs = dict(
  176. base_url=effective_base_url or None,
  177. api_key=effective_api_key,
  178. model=effective_model or None,
  179. max_iterations=max_iterations,
  180. quiet_mode=True,
  181. tool_progress_mode="off",
  182. skip_context_files=True,
  183. load_soul_identity=False,
  184. skip_memory=True,
  185. )
  186. agent = AIAgent(**kwargs)
  187. if cache_key:
  188. with _agent_lock:
  189. _agents[cache_key] = agent
  190. # 超出上限按 FIFO 淘汰最旧实例
  191. while len(_agents) > _AGENT_CACHE_MAX:
  192. _agents.popitem(last=False)
  193. return agent
  194. def _load_hermes_config():
  195. """尝试从 Hermes 的 config.yaml 和 .env 加载配置"""
  196. try:
  197. hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
  198. # 加载 .env
  199. env_file = hermes_home / ".env"
  200. if env_file.exists():
  201. for line in env_file.read_text(encoding="utf-8").splitlines():
  202. line = line.strip()
  203. if not line or line.startswith("#") or "=" not in line:
  204. continue
  205. key, _, value = line.partition("=")
  206. key = key.strip()
  207. value = value.strip().strip('"').strip("'")
  208. # 只设置 Bridge 相关的环境变量(如果尚未设置)
  209. if key in ("OPENAI_API_KEY", "OPENROUTER_API_KEY") and not os.environ.get("HERMES_BRIDGE_API_KEY"):
  210. os.environ["HERMES_BRIDGE_API_KEY"] = value
  211. if key == "OPENAI_BASE_URL" and not os.environ.get("HERMES_BRIDGE_BASE_URL"):
  212. os.environ["HERMES_BRIDGE_BASE_URL"] = value
  213. # 加载 config.yaml
  214. config_file = hermes_home / "config.yaml"
  215. if config_file.exists():
  216. import yaml
  217. with open(config_file, "r", encoding="utf-8") as f:
  218. config = yaml.safe_load(f) or {}
  219. model_cfg = config.get("model") or {}
  220. provider = config.get("provider") or {}
  221. if not os.environ.get("HERMES_BRIDGE_MODEL") and model_cfg.get("name"):
  222. os.environ["HERMES_BRIDGE_MODEL"] = model_cfg["name"]
  223. if not os.environ.get("HERMES_BRIDGE_BASE_URL") and provider.get("base_url"):
  224. os.environ["HERMES_BRIDGE_BASE_URL"] = provider["base_url"]
  225. if not os.environ.get("HERMES_BRIDGE_API_KEY") and provider.get("api_key"):
  226. os.environ["HERMES_BRIDGE_API_KEY"] = provider["api_key"]
  227. except Exception as e:
  228. logger.warning("加载 Hermes 配置失败: %s", e)
  229. # ---------------------------------------------------------------------------
  230. # SSE 事件流
  231. # ---------------------------------------------------------------------------
  232. def _run_agent_stream(agent, system_prompt: str, user_message: str, run_id: str,
  233. working_dir: Optional[str] = None, session_id: Optional[str] = None):
  234. """运行 Agent 并生成 SSE 事件流"""
  235. queue = []
  236. done_event = threading.Event()
  237. def on_stream(delta: str):
  238. """接收流式文本片段"""
  239. queue.append(("text", {"content": delta}))
  240. def on_tool_start(tool_name: str, args: dict):
  241. """工具开始"""
  242. queue.append(("tool_start", {"tool": tool_name, "args": args}))
  243. def on_tool_end(tool_name: str, result: str):
  244. """工具结束"""
  245. summary = result[:500] if result else ""
  246. queue.append(("tool_end", {"tool": tool_name, "result_summary": summary}))
  247. def _run():
  248. # 通过 contextvar 设置本次运行的 agent 工作目录;
  249. # hermes-agent 内 resolve_agent_cwd() 会优先读取此值,
  250. # 影响 agent 工具(终端、文件读写)的当前目录。
  251. cwd_token = None
  252. original_cwd = None
  253. effective_task_id = session_id or "default"
  254. if working_dir:
  255. wd = str(working_dir)
  256. # 同时切换进程 cwd,覆盖那些直接调用 os.getcwd() 的工具
  257. try:
  258. original_cwd = os.getcwd()
  259. os.chdir(wd)
  260. logger.info("[%s] 切换进程 cwd: %s", run_id, wd)
  261. except Exception as e:
  262. logger.warning("[%s] 切换 cwd 失败: %s", run_id, e)
  263. # 设置 TERMINAL_CWD 作为 local environment 的 fallback cwd
  264. os.environ["TERMINAL_CWD"] = wd
  265. # 注册 task env override,强制更新(或创建)该 session 的 terminal environment cwd
  266. try:
  267. from tools.terminal_tool import register_task_env_overrides
  268. register_task_env_overrides(effective_task_id, {"cwd": wd})
  269. logger.info("[%s] 注册 terminal cwd override for task %s: %s", run_id, effective_task_id, wd)
  270. except Exception as e:
  271. logger.warning("[%s] 注册 terminal cwd override 失败: %s", run_id, e)
  272. try:
  273. from agent.runtime_cwd import _SESSION_CWD
  274. cwd_token = _SESSION_CWD.set(wd)
  275. logger.info("[%s] 设置 agent 工作目录 contextvar: %s", run_id, wd)
  276. except Exception as e:
  277. logger.warning("[%s] 设置工作目录失败(agent 将使用进程 cwd): %s", run_id, e)
  278. try:
  279. agent.tool_start_callback = on_tool_start
  280. agent.tool_complete_callback = on_tool_end
  281. result = agent.run_conversation(
  282. user_message=user_message,
  283. system_message=system_prompt,
  284. stream_callback=on_stream,
  285. task_id=effective_task_id,
  286. )
  287. final = result.get("final_response", "") if isinstance(result, dict) else str(result)
  288. queue.append(("done", {"content": final}))
  289. except Exception as e:
  290. logger.error("Agent 执行异常: %s", e, exc_info=True)
  291. queue.append(("error", {"message": str(e)}))
  292. finally:
  293. if cwd_token is not None:
  294. try:
  295. _SESSION_CWD.reset(cwd_token)
  296. except Exception:
  297. pass
  298. if original_cwd is not None:
  299. try:
  300. os.chdir(original_cwd)
  301. except Exception:
  302. pass
  303. done_event.set()
  304. thread = threading.Thread(target=_run, daemon=True)
  305. thread.start()
  306. while not done_event.is_set() or queue:
  307. while queue:
  308. event_type, data = queue.pop(0)
  309. yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
  310. time.sleep(0.05)
  311. # 刷出最后可能残留的事件
  312. while queue:
  313. event_type, data = queue.pop(0)
  314. yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
  315. # ---------------------------------------------------------------------------
  316. # API 端点
  317. # ---------------------------------------------------------------------------
  318. @app.post("/run")
  319. async def run_agent(req: RunRequest):
  320. """
  321. 运行 Hermes Agent,返回 SSE 流式响应。
  322. 事件类型:
  323. - text: 流式文本片段
  324. - tool_start: 工具调用开始
  325. - tool_end: 工具调用结束
  326. - done: 执行完成,包含最终回复
  327. - error: 执行出错
  328. """
  329. if not req.user_message.strip():
  330. raise HTTPException(status_code=400, detail="user_message 不能为空")
  331. session_id = req.session_id or ""
  332. run_id = uuid.uuid4().hex[:8]
  333. try:
  334. agent = _get_or_create_agent(
  335. session_id, req.max_iterations, req.hermes_home,
  336. model_id=req.model_id,
  337. base_url=req.base_url,
  338. api_key=req.api_key,
  339. model_name=req.model_name
  340. )
  341. except (ImportError, ValueError) as e:
  342. raise HTTPException(status_code=503, detail=str(e))
  343. return StreamingResponse(
  344. _run_agent_stream(agent, req.system_prompt, req.user_message, run_id, req.working_dir, session_id),
  345. media_type="text/event-stream",
  346. headers={
  347. "Cache-Control": "no-cache",
  348. "X-Accel-Buffering": "no",
  349. "Connection": "keep-alive",
  350. },
  351. )
  352. @app.get("/health")
  353. async def health():
  354. """健康检查"""
  355. hermes_home = os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))
  356. hermes_agent_found = HERMES_AGENT_DIR.is_dir()
  357. # 尝试导入验证
  358. can_import = False
  359. try:
  360. import run_agent
  361. can_import = True
  362. except ImportError:
  363. pass
  364. return {
  365. "status": "ok" if (hermes_agent_found or can_import) else "degraded",
  366. "hermes_home": hermes_home,
  367. "hermes_agent_dir": str(HERMES_AGENT_DIR),
  368. "hermes_agent_found": hermes_agent_found,
  369. "can_import_agent": can_import,
  370. "active_sessions": len(_agents),
  371. }
  372. # ---------------------------------------------------------------------------
  373. # 启动
  374. # ---------------------------------------------------------------------------
  375. def main():
  376. parser = argparse.ArgumentParser(description="Hermes Bridge Server")
  377. parser.add_argument("--port", type=int, default=18731, help="监听端口 (默认 18731)")
  378. parser.add_argument("--host", default="127.0.0.1", help="监听地址 (默认 127.0.0.1)")
  379. parser.add_argument("--hermes-home", default=None, help="Hermes Home 目录")
  380. args = parser.parse_args()
  381. if args.hermes_home:
  382. os.environ["HERMES_HOME"] = args.hermes_home
  383. import uvicorn
  384. logger.info("启动 Hermes Bridge: %s:%d", args.host, args.port)
  385. uvicorn.run(app, host=args.host, port=args.port, log_level="info")
  386. if __name__ == "__main__":
  387. main()