hermes_bridge.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  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. POST /skills/invalidate 清空技能索引缓存与 Agent 池(技能写时同步后调用)
  10. GET /health 健康检查
  11. """
  12. import argparse
  13. import contextvars
  14. import json
  15. import logging
  16. import os
  17. import shutil
  18. import sys
  19. import threading
  20. import time
  21. import uuid
  22. from collections import OrderedDict, deque
  23. from pathlib import Path
  24. from typing import Optional
  25. # ---------------------------------------------------------------------------
  26. # 强制 stdout/stderr 使用 UTF-8(Windows 默认 cp936 会导致中文输出乱码)
  27. # 必须在任何 print/logging 之前执行
  28. # ---------------------------------------------------------------------------
  29. for _stream in (sys.stdout, sys.stderr):
  30. try:
  31. _stream.reconfigure(encoding="utf-8", errors="replace")
  32. except Exception:
  33. pass
  34. # ---------------------------------------------------------------------------
  35. # 父进程守护:Java 后端被 kill 后,子进程自动退出,释放端口与资源
  36. # ---------------------------------------------------------------------------
  37. def _start_parent_watcher():
  38. """启动守护线程,当父进程退出时自杀。"""
  39. try:
  40. import psutil
  41. except ImportError:
  42. logging.warning("未安装 psutil,无法监听父进程状态;Java 退出后子进程可能残留")
  43. return
  44. try:
  45. parent = psutil.Process(os.getppid())
  46. except Exception:
  47. return
  48. def _watch():
  49. while True:
  50. time.sleep(2)
  51. try:
  52. if not parent.is_running() or parent.status() == psutil.STATUS_ZOMBIE:
  53. logging.info("父进程已退出,Hermes Bridge 自动终止")
  54. os._exit(0)
  55. except Exception:
  56. logging.info("父进程状态不可获取,Hermes Bridge 自动终止")
  57. os._exit(0)
  58. watcher = threading.Thread(target=_watch, daemon=True, name="parent-watcher")
  59. watcher.start()
  60. _start_parent_watcher()
  61. # ---------------------------------------------------------------------------
  62. # Hermes 项目路径注入(让 Python 能找到 hermes-agent 的源码)
  63. # ---------------------------------------------------------------------------
  64. BRIDGE_DIR = Path(__file__).resolve().parent
  65. HERMES_AGENT_DIR = BRIDGE_DIR.parent / "hermes-agent"
  66. if HERMES_AGENT_DIR.is_dir() and str(HERMES_AGENT_DIR) not in sys.path:
  67. sys.path.insert(0, str(HERMES_AGENT_DIR))
  68. # ---------------------------------------------------------------------------
  69. # 日志
  70. # ---------------------------------------------------------------------------
  71. logging.basicConfig(
  72. level=logging.INFO,
  73. format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
  74. stream=sys.stderr,
  75. )
  76. logger = logging.getLogger("hermes-bridge")
  77. # ---------------------------------------------------------------------------
  78. # Git Bash 自动探测(Windows)
  79. # ---------------------------------------------------------------------------
  80. def _ensure_git_bash_env():
  81. """Windows 下自动探测 Git Bash 并预置 HERMES_GIT_BASH_PATH,免去用户手工配置。
  82. 背景:hermes 的 _find_bash 只查 C 盘固定安装路径与 which("bash");
  83. Git 装在非 C 盘(如 D:\\Program Files\\Git)且 Java 子进程 PATH 不含
  84. Git 的 bin 目录时,候选列表全空,误报 "Git Bash not found"。
  85. HERMES_GIT_BASH_PATH 是上游官方覆盖入口(_find_bash 优先读取并做
  86. isfile + 启动探测复核),探测失败保持原行为(WARNING 降级)。
  87. """
  88. if os.name != "nt":
  89. return
  90. if os.environ.get("HERMES_GIT_BASH_PATH"):
  91. return # 显式配置优先,不做覆盖
  92. candidates = []
  93. def _add(p):
  94. if p and p not in candidates and os.path.isfile(p):
  95. candidates.append(p)
  96. # 1. PATH 直接命中(排除 WSL 的 System32\\bash.exe 与 WindowsApps 存根)
  97. found = shutil.which("bash")
  98. if found and "system32" not in found.lower() and "windowsapps" not in found.lower():
  99. _add(found)
  100. # 2. PATH 中含 git 的目录(Git/cmd、Git/bin、Git/usr/bin 等)向 Git 根拼 bash.exe
  101. for entry in os.environ.get("PATH", "").split(os.pathsep):
  102. entry = entry.strip()
  103. if not entry or "git" not in entry.lower():
  104. continue
  105. d = Path(entry)
  106. _add(str(d / "bash.exe"))
  107. _add(str(d / "bin" / "bash.exe"))
  108. _add(str(d / "usr" / "bin" / "bash.exe"))
  109. parent = d.parent
  110. _add(str(parent / "bin" / "bash.exe"))
  111. _add(str(parent / "usr" / "bin" / "bash.exe"))
  112. # 3. 常见安装位置(ProgramFiles 变量可能指向任意盘,另补 C/D/E 盘扫描)
  113. roots = [
  114. os.environ.get("ProgramFiles", ""),
  115. os.environ.get("ProgramFiles(x86)", ""),
  116. ]
  117. roots += [f"{drive}:/Program Files" for drive in ("C", "D", "E")]
  118. roots += [f"{drive}:/Program Files (x86)" for drive in ("C", "D", "E")]
  119. for root in roots:
  120. _add(os.path.join(root, "Git", "bin", "bash.exe"))
  121. if candidates:
  122. os.environ["HERMES_GIT_BASH_PATH"] = candidates[0]
  123. logger.info("[bridge] 自动探测 Git Bash: %s", candidates[0])
  124. else:
  125. logger.warning(
  126. "[bridge] 未探测到 Git Bash,依赖 hermes 内置查找"
  127. "(可通过 HERMES_GIT_BASH_PATH 显式指定)"
  128. )
  129. _ensure_git_bash_env()
  130. # ---------------------------------------------------------------------------
  131. # FastAPI app
  132. # ---------------------------------------------------------------------------
  133. try:
  134. from fastapi import FastAPI, HTTPException
  135. from fastapi.responses import StreamingResponse
  136. from pydantic import BaseModel
  137. except ImportError:
  138. logger.error("请先安装 FastAPI: pip install fastapi uvicorn[standard]")
  139. sys.exit(1)
  140. app = FastAPI(title="Hermes Bridge", version="1.0.0")
  141. # ---------------------------------------------------------------------------
  142. # 工作流节点上下文查询:contextvar + 工具注册
  143. # ---------------------------------------------------------------------------
  144. # 每次请求含 context_query 时,在 _run_agent_stream._run 线程内设置此 contextvar;
  145. # internal_context_tools.py 的 3 个工具 handler 通过此 contextvar 读取
  146. # base_url/token/run_id/visit_uuid,避免在工具签名中暴露给 LLM。
  147. _CONTEXT_QUERY_CTX: "contextvars.ContextVar[Optional[dict]]" = contextvars.ContextVar(
  148. "context_query", default=None,
  149. )
  150. # 进程启动时一次性注册工作流上下文查询工具到 hermes-agent registry。
  151. # 工具 check_fn 检查 contextvar:未注入 context_query 的请求场景下工具对 agent 不可见。
  152. # 注册失败不阻塞 Bridge 启动(仅在日志中告警,工作流场景将降级为无工具调用)。
  153. try:
  154. import internal_context_tools
  155. internal_context_tools.register_internal_context_tools()
  156. except Exception as _e:
  157. logger.warning("注册工作流上下文工具失败(工作流节点将无法查询前驱上下文): %s", _e)
  158. # ---------------------------------------------------------------------------
  159. # 共享密钥(X-Bridge-Token 头校验)
  160. # ---------------------------------------------------------------------------
  161. # 启动时从环境变量读取;为空则禁用认证(仅依赖 127.0.0.1 绑定)
  162. _BRIDGE_AUTH_TOKEN = os.environ.get("HERMES_BRIDGE_AUTH_TOKEN", "").strip()
  163. @app.middleware("http")
  164. async def _verify_token(request, call_next):
  165. """校验 X-Bridge-Token 头(/health 不校验)"""
  166. if _BRIDGE_AUTH_TOKEN and request.url.path != "/health":
  167. token = request.headers.get("X-Bridge-Token", "")
  168. if token != _BRIDGE_AUTH_TOKEN:
  169. from fastapi.responses import JSONResponse
  170. return JSONResponse(status_code=401, content={"detail": "invalid or missing X-Bridge-Token"})
  171. return await call_next(request)
  172. # ---------------------------------------------------------------------------
  173. # 全局 Agent 池(按 session_id::hermes_home 复用,有界 LRU)
  174. # ---------------------------------------------------------------------------
  175. _agent_lock = threading.Lock()
  176. _agents: "OrderedDict[str, object]" = OrderedDict()
  177. _AGENT_CACHE_MAX = 8 # 最大缓存实例数;超出按 FIFO 淘汰,防止无界增长
  178. # ---------------------------------------------------------------------------
  179. # 全局 clarify 会话映射:clarify_id → session_key
  180. # 用于 POST /clarify/{clarify_id}/answer 端点定位 _ClarifyEntry
  181. # (session_key 在 register 时与 clarify_id 绑定,可直接 resolve)
  182. # ---------------------------------------------------------------------------
  183. _clarify_lock = threading.Lock()
  184. _clarify_ids: "set[str]" = set() # 仅用于跟踪 Bridge 模式发起的 clarify_id
  185. # ---------------------------------------------------------------------------
  186. # 请求模型
  187. # ---------------------------------------------------------------------------
  188. class RunRequest(BaseModel):
  189. """运行 Agent 对话的请求体"""
  190. system_prompt: Optional[str] = None # 系统提示(如 SKILL.md 内容)
  191. user_message: str # 用户消息
  192. max_iterations: int = 100 # 最大工具调用轮次(兜底默认值,由后端配置覆盖)
  193. session_id: Optional[str] = None # 会话 ID(复用 Agent 实例)
  194. hermes_home: Optional[str] = None # 本次运行使用的 HERMES_HOME 路径(其下应有 skills 子目录)
  195. working_dir: Optional[str] = None # agent 工具调用的实际工作目录(终端、文件读写以它为根)
  196. model_id: Optional[str] = None # 节点指定的模型 ID
  197. base_url: Optional[str] = None # 节点指定的模型 base URL
  198. api_key: Optional[str] = None # 节点指定的模型 API Key
  199. model_name: Optional[str] = None # 节点指定的模型标识
  200. # 工作流节点上下文查询配置(v1.1):
  201. # 由 Java 端 HermesExecutorSupport.buildContextQuery 构造,含 base_url/token/run_id/visit_uuid。
  202. # 非空时 _run_agent_stream 会:
  203. # 1. 在运行线程内设置 _CONTEXT_QUERY_CTX,让 3 个查询工具的 check_fn 返回 True
  204. # 2. 强制新建 Agent(不复用 LRU),让 agent.tools 拉取到查询工具的 schema
  205. context_query: Optional[dict] = None
  206. # ---------------------------------------------------------------------------
  207. # Agent 工厂
  208. # ---------------------------------------------------------------------------
  209. def _get_or_create_agent(session_id: str, max_iterations: int, hermes_home: Optional[str] = None,
  210. model_id: Optional[str] = None,
  211. base_url: Optional[str] = None,
  212. api_key: Optional[str] = None,
  213. model_name: Optional[str] = None,
  214. force_new: bool = False):
  215. """获取或创建 AIAgent 实例
  216. hermes_home 用于覆盖本次运行的 HERMES_HOME(其下应有 skills 子目录),
  217. 实现按工作流运行目录隔离的 Skill 加载。
  218. model_id/base_url/api_key/model_name 支持按节点动态切换模型;
  219. 未提供时回退到环境变量默认配置。
  220. force_new=True 时强制新建 agent,不复用 LRU 池。
  221. 用于含 context_query 的工作流节点请求:已缓存 agent 的 agent.tools 是构造时快照,
  222. 无法在运行时刷新出工作流上下文查询工具,必须新建让 get_tool_definitions() 重新拉取。
  223. 新建的 agent 也不入池(避免挤掉对话页面 agent 的 LRU 缓存)。
  224. """
  225. # 缓存键同时绑定 hermes_home 与模型 ID,避免跨运行目录/模型复用 agent
  226. cache_key = f"{session_id}::{hermes_home or ''}::{model_id or 'default'}" if session_id else None
  227. if cache_key and not force_new:
  228. with _agent_lock:
  229. if cache_key in _agents:
  230. _agents.move_to_end(cache_key) # LRU 提升到最新
  231. return _agents[cache_key]
  232. # 在导入/创建 AIAgent 之前显式覆盖 HERMES_HOME,使 get_hermes_home() 返回正确路径
  233. if hermes_home:
  234. os.environ["HERMES_HOME"] = str(hermes_home)
  235. logger.info("使用请求指定的 HERMES_HOME: %s", hermes_home)
  236. # 安装沙箱补丁(幂等),强制 hermes-agent 文件操作限制在 _SESSION_CWD 内
  237. try:
  238. import sandbox_patch
  239. sandbox_patch.install_sandbox_patches()
  240. except Exception as e:
  241. logger.warning("沙箱补丁加载异常(继续但不保证隔离): %s", e)
  242. try:
  243. from run_agent import AIAgent
  244. except ImportError:
  245. raise ImportError(
  246. "无法导入 hermes-agent。请确保 hermes-agent 目录存在或已安装 hermes-agent 包。\n"
  247. f"尝试的路径: {HERMES_AGENT_DIR}"
  248. )
  249. # 优先使用请求中传入的模型配置,其次回退到环境变量
  250. effective_base_url = base_url or os.environ.get("HERMES_BRIDGE_BASE_URL", "")
  251. effective_api_key = api_key or os.environ.get("HERMES_BRIDGE_API_KEY", "")
  252. effective_model = model_name or os.environ.get("HERMES_BRIDGE_MODEL", "")
  253. # 尝试从 Hermes config.yaml 读取(如果未通过请求或环境变量指定)
  254. if not effective_base_url or not effective_api_key:
  255. _load_hermes_config()
  256. effective_base_url = effective_base_url or os.environ.get("HERMES_BRIDGE_BASE_URL", "")
  257. effective_api_key = effective_api_key or os.environ.get("HERMES_BRIDGE_API_KEY", "")
  258. effective_model = effective_model or os.environ.get("HERMES_BRIDGE_MODEL", "")
  259. if not effective_api_key:
  260. raise ValueError("未配置 API Key。请设置 HERMES_BRIDGE_API_KEY 环境变量或在 Hermes config.yaml 中配置。")
  261. logger.info(
  262. "创建 AIAgent: session=%s, model_id=%s, model=%s, base_url=%s",
  263. session_id, model_id or "默认", effective_model or "默认",
  264. effective_base_url or "默认"
  265. )
  266. kwargs = dict(
  267. base_url=effective_base_url or None,
  268. api_key=effective_api_key,
  269. model=effective_model or None,
  270. max_iterations=max_iterations,
  271. quiet_mode=True,
  272. tool_progress_mode="off",
  273. skip_context_files=True,
  274. load_soul_identity=False,
  275. skip_memory=True,
  276. )
  277. agent = AIAgent(**kwargs)
  278. # force_new=True 时不入池:含 context_query 的工作流请求是临时性的,
  279. # 入池会挤掉对话页面 agent 的 LRU 缓存,且这些缓存的 agent.tools 不含查询工具,复用反而出错。
  280. if cache_key and not force_new:
  281. with _agent_lock:
  282. _agents[cache_key] = agent
  283. # 超出上限按 FIFO 淘汰最旧实例
  284. while len(_agents) > _AGENT_CACHE_MAX:
  285. _agents.popitem(last=False)
  286. return agent
  287. def _load_hermes_config():
  288. """尝试从 Hermes 的 config.yaml 和 .env 加载配置"""
  289. try:
  290. hermes_home = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
  291. # 加载 .env
  292. env_file = hermes_home / ".env"
  293. if env_file.exists():
  294. for line in env_file.read_text(encoding="utf-8").splitlines():
  295. line = line.strip()
  296. if not line or line.startswith("#") or "=" not in line:
  297. continue
  298. key, _, value = line.partition("=")
  299. key = key.strip()
  300. value = value.strip().strip('"').strip("'")
  301. # 只设置 Bridge 相关的环境变量(如果尚未设置)
  302. if key in ("OPENAI_API_KEY", "OPENROUTER_API_KEY") and not os.environ.get("HERMES_BRIDGE_API_KEY"):
  303. os.environ["HERMES_BRIDGE_API_KEY"] = value
  304. if key == "OPENAI_BASE_URL" and not os.environ.get("HERMES_BRIDGE_BASE_URL"):
  305. os.environ["HERMES_BRIDGE_BASE_URL"] = value
  306. # 加载 config.yaml
  307. config_file = hermes_home / "config.yaml"
  308. if config_file.exists():
  309. import yaml
  310. with open(config_file, "r", encoding="utf-8") as f:
  311. config = yaml.safe_load(f) or {}
  312. model_cfg = config.get("model") or {}
  313. provider = config.get("provider") or {}
  314. if not os.environ.get("HERMES_BRIDGE_MODEL") and model_cfg.get("name"):
  315. os.environ["HERMES_BRIDGE_MODEL"] = model_cfg["name"]
  316. if not os.environ.get("HERMES_BRIDGE_BASE_URL") and provider.get("base_url"):
  317. os.environ["HERMES_BRIDGE_BASE_URL"] = provider["base_url"]
  318. if not os.environ.get("HERMES_BRIDGE_API_KEY") and provider.get("api_key"):
  319. os.environ["HERMES_BRIDGE_API_KEY"] = provider["api_key"]
  320. except Exception as e:
  321. logger.warning("加载 Hermes 配置失败: %s", e)
  322. # ---------------------------------------------------------------------------
  323. # SSE 事件流
  324. # ---------------------------------------------------------------------------
  325. def _run_agent_stream(agent, system_prompt: str, user_message: str, run_id: str,
  326. working_dir: Optional[str] = None, session_id: Optional[str] = None,
  327. context_query: Optional[dict] = None):
  328. """运行 Agent 并生成 SSE 事件流
  329. 事件类型(v1.1 扩展):
  330. - text: 流式文本片段(LLM 输出)
  331. - tool_start: 工具调用开始(含工具名与参数)
  332. - tool_end: 工具调用结束(含截断后的结果摘要)
  333. - todo_update: todo 工具的完整 JSON 输出(不截断,独立事件)
  334. - step: 每轮 LLM 调用前进度回调
  335. - status: 生命周期/警告类状态消息
  336. - thinking_status: TUI 风格思考 spinner 文案(与 text 区分)
  337. - tool_progress: 工具细粒度进度(tool.started / reasoning.available 等)
  338. - clarify_request: 模型请求用户确认的问题(含 clarify_id 与选项)
  339. - done: 执行完成,包含最终回复
  340. - error: 执行出错
  341. """
  342. # L-4:deque 的 popleft() 是 O(1);原 list.pop(0) 是 O(n),事件多时每次复制整个数组浪费 CPU
  343. queue = deque()
  344. done_event = threading.Event()
  345. def on_stream(delta: str):
  346. """接收流式文本片段"""
  347. queue.append(("text", {"content": delta}))
  348. def on_tool_start(tool_call_id, tool_name: str, args: dict):
  349. """工具开始。
  350. 注意:Hermes tool_start_callback 实际签名为 (tool_call_id, name, args),
  351. 必须接收 3 个位置参数,否则会触发 TypeError 被 hermes-agent 静默吞掉。
  352. """
  353. queue.append(("tool_start", {"tool": tool_name, "args": args}))
  354. def on_tool_end(tool_call_id, tool_name: str, args, result):
  355. """工具结束。
  356. 注意:Hermes tool_complete_callback 实际签名为 (tool_call_id, name, args, result),
  357. 必须接收 4 个位置参数。result 可能是 str / list / dict,统一转字符串处理。
  358. todo 工具特殊处理:不截断、发独立 todo_update 事件。
  359. """
  360. # result 统一转字符串(OpenAI tool_result 可能是 list/dict)
  361. if isinstance(result, str):
  362. result_text = result
  363. elif result is None:
  364. result_text = ""
  365. else:
  366. try:
  367. result_text = json.dumps(result, ensure_ascii=False)
  368. except Exception:
  369. result_text = str(result)
  370. if tool_name == "todo":
  371. # 完整保留 todo 工具的 JSON 输出(用于前端 TODO List 渲染)
  372. queue.append(("todo_update", {"tool": tool_name, "result": result_text}))
  373. # 同时发标准 tool_end(保持向后兼容,前端旧逻辑仍能看到调用记录)
  374. summary = result_text[:500]
  375. queue.append(("tool_end", {"tool": tool_name, "result_summary": summary}))
  376. return
  377. summary = result_text[:500]
  378. queue.append(("tool_end", {"tool": tool_name, "result_summary": summary}))
  379. def on_step(api_call_count: int, prev_tools: list):
  380. """每轮 LLM 调用前的进度回调"""
  381. queue.append(("step", {
  382. "iteration": api_call_count,
  383. "prev_tools": list(prev_tools) if prev_tools else [],
  384. }))
  385. def on_status(category: str, message: str):
  386. """生命周期/警告类状态消息"""
  387. queue.append(("status", {"category": category, "message": message}))
  388. def on_thinking(content: str):
  389. """TUI 风格思考 spinner 文案(如 '🤔 思考中...')
  390. 与 text 事件区分:text 是 LLM 真实输出片段,thinking_status 是 UI 状态提示"""
  391. if content: # 忽略清空信号(content == ""),避免噪声
  392. queue.append(("thinking_status", {"content": content}))
  393. def on_tool_progress(event_type: str, name: Optional[str] = None,
  394. preview: Optional[str] = None, args: Optional[dict] = None,
  395. **kwargs):
  396. """工具细粒度进度事件(tool.started / reasoning.available / _thinking 等)"""
  397. queue.append(("tool_progress", {
  398. "event_type": event_type,
  399. "name": name,
  400. "preview": preview,
  401. "args": args,
  402. }))
  403. def on_clarify(question: str, choices=None) -> str:
  404. """同步阻塞 clarify 回调
  405. Bridge 模式下:
  406. 1. 生成 clarify_id,注册到 clarify_gateway
  407. 2. 发 clarify_request SSE 事件(Java 收到后会持久化 + 推给前端)
  408. 3. 阻塞等待 POST /clarify/{clarify_id}/answer 唤醒(无超时)
  409. 4. 返回用户答案给 agent
  410. """
  411. import secrets
  412. from tools import clarify_gateway
  413. clarify_id = secrets.token_hex(8)
  414. session_key = f"bridge-{run_id}"
  415. clarify_gateway.register(
  416. clarify_id=clarify_id,
  417. session_key=session_key,
  418. question=question,
  419. choices=list(choices) if choices else None,
  420. )
  421. with _clarify_lock:
  422. _clarify_ids.add(clarify_id)
  423. queue.append(("clarify_request", {
  424. "clarify_id": clarify_id,
  425. "run_id": run_id,
  426. "question": question,
  427. "choices": list(choices) if choices else None,
  428. }))
  429. logger.info("[%s] Clarify 等待用户回答: clarify_id=%s, question=%s", run_id, clarify_id, question[:80])
  430. # 无超时等待;实际超时由 Java/HermesBridgeClient 的 24h 兜底控制
  431. response = clarify_gateway.wait_for_response(clarify_id, timeout=None)
  432. with _clarify_lock:
  433. _clarify_ids.discard(clarify_id)
  434. if response is None:
  435. # 不应发生(None 表示超时,但 timeout=None 永不超时),降级返回空让 agent 自决策
  436. logger.warning("[%s] Clarify 意外返回 None: clarify_id=%s", run_id, clarify_id)
  437. return ""
  438. logger.info("[%s] Clarify 收到回答: clarify_id=%s, response=%s", run_id, clarify_id, response[:80])
  439. return response
  440. def _run():
  441. # 通过 contextvar 设置本次运行的 agent 工作目录;
  442. # hermes-agent 内 resolve_agent_cwd() 会优先读取此值,
  443. # 影响 agent 工具(终端、文件读写)的当前目录。
  444. cwd_token = None
  445. original_cwd = None
  446. effective_task_id = session_id or "default"
  447. # 工作流节点上下文查询:在运行线程内设置 contextvar,
  448. # 让 internal_context_tools 的 3 个工具 handler/check_fn 能读取到 base_url/token/run_id/visit_uuid。
  449. # 必须在 _run 内部 set,因为 contextvars 不跨线程自动传播;
  450. # 同理也必须在本线程的 finally 中 reset。
  451. ctx_query_token = None
  452. if context_query:
  453. try:
  454. ctx_query_token = _CONTEXT_QUERY_CTX.set(dict(context_query))
  455. logger.info("[%s] 启用工作流上下文查询工具: run_id=%s, visit_uuid=%s",
  456. run_id, context_query.get("run_id"),
  457. str(context_query.get("visit_uuid"))[:8])
  458. except Exception as e:
  459. logger.warning("[%s] 设置 context_query contextvar 失败: %s", run_id, e)
  460. if working_dir:
  461. wd = str(working_dir)
  462. # 同时切换进程 cwd,覆盖那些直接调用 os.getcwd() 的工具
  463. try:
  464. original_cwd = os.getcwd()
  465. os.chdir(wd)
  466. logger.info("[%s] 切换进程 cwd: %s", run_id, wd)
  467. except Exception as e:
  468. logger.warning("[%s] 切换 cwd 失败: %s", run_id, e)
  469. # 设置 TERMINAL_CWD 作为 local environment 的 fallback cwd
  470. os.environ["TERMINAL_CWD"] = wd
  471. # 注册 task env override,强制更新(或创建)该 session 的 terminal environment cwd
  472. try:
  473. from tools.terminal_tool import register_task_env_overrides
  474. register_task_env_overrides(effective_task_id, {"cwd": wd})
  475. logger.info("[%s] 注册 terminal cwd override for task %s: %s", run_id, effective_task_id, wd)
  476. except Exception as e:
  477. logger.warning("[%s] 注册 terminal cwd override 失败: %s", run_id, e)
  478. try:
  479. from agent.runtime_cwd import _SESSION_CWD
  480. cwd_token = _SESSION_CWD.set(wd)
  481. logger.info("[%s] 设置 agent 工作目录 contextvar: %s", run_id, wd)
  482. except Exception as e:
  483. logger.warning("[%s] 设置工作目录失败(agent 将使用进程 cwd): %s", run_id, e)
  484. try:
  485. agent.tool_start_callback = on_tool_start
  486. agent.tool_complete_callback = on_tool_end
  487. agent.step_callback = on_step
  488. agent.status_callback = on_status
  489. agent.thinking_callback = on_thinking
  490. agent.tool_progress_callback = on_tool_progress
  491. agent.clarify_callback = on_clarify
  492. result = agent.run_conversation(
  493. user_message=user_message,
  494. system_message=system_prompt,
  495. stream_callback=on_stream,
  496. task_id=effective_task_id,
  497. )
  498. final = result.get("final_response", "") if isinstance(result, dict) else str(result)
  499. queue.append(("done", {"content": final}))
  500. except Exception as e:
  501. logger.error("Agent 执行异常: %s", e, exc_info=True)
  502. queue.append(("error", {"message": str(e)}))
  503. finally:
  504. # 清理本 run 未完成的 clarify(agent 异常退出时不留孤儿阻塞线程)
  505. # L-5:clear_session 失败时记录 warning,便于发现 clarify_gateway 内部异常
  506. # (原 `except: pass` 完全静默,问题发生时无任何线索可查)
  507. try:
  508. from tools import clarify_gateway
  509. clarify_gateway.clear_session(f"bridge-{run_id}")
  510. except Exception as cleanup_err:
  511. logger.warning("[%s] 清理 clarify 会话失败: %s", run_id, cleanup_err)
  512. if cwd_token is not None:
  513. try:
  514. _SESSION_CWD.reset(cwd_token)
  515. except Exception:
  516. pass
  517. if ctx_query_token is not None:
  518. # 重置 context_query contextvar,避免线程池复用时残留状态泄漏到下次运行
  519. try:
  520. _CONTEXT_QUERY_CTX.reset(ctx_query_token)
  521. except Exception:
  522. pass
  523. if original_cwd is not None:
  524. try:
  525. os.chdir(original_cwd)
  526. except Exception:
  527. pass
  528. done_event.set()
  529. thread = threading.Thread(target=_run, daemon=True)
  530. thread.start()
  531. while not done_event.is_set() or queue:
  532. while queue:
  533. event_type, data = queue.popleft()
  534. yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
  535. time.sleep(0.05)
  536. # 刷出最后可能残留的事件
  537. while queue:
  538. event_type, data = queue.popleft()
  539. yield f"event: {event_type}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
  540. # ---------------------------------------------------------------------------
  541. # API 端点
  542. # ---------------------------------------------------------------------------
  543. @app.post("/run")
  544. async def run_agent(req: RunRequest):
  545. """
  546. 运行 Hermes Agent,返回 SSE 流式响应。
  547. 事件类型:
  548. - text: 流式文本片段
  549. - tool_start: 工具调用开始
  550. - tool_end: 工具调用结束
  551. - done: 执行完成,包含最终回复
  552. - error: 执行出错
  553. """
  554. if not req.user_message.strip():
  555. raise HTTPException(status_code=400, detail="user_message 不能为空")
  556. session_id = req.session_id or ""
  557. run_id = uuid.uuid4().hex[:8]
  558. # v1.1:含 context_query 时强制新建 agent。
  559. # 已缓存的 agent 实例的 agent.tools 是构造时快照,无法在运行时刷新出
  560. # 工作流上下文查询工具(get_predecessor_paths / get_node_output / get_node_meta),
  561. # 必须新建让 agent.tools 拉取到这些工具的 schema。
  562. force_new = req.context_query is not None
  563. # 入口日志:标识本次请求是否启用了工作流上下文查询
  564. if req.context_query:
  565. cq = req.context_query
  566. logger.info(
  567. "[Bridge-Run] run_id=%s, session_id=%s, context_query 启用: "
  568. "base_url=%s, run_id=%s, visit_uuid=%s, force_new=%s",
  569. run_id, session_id or "<none>",
  570. cq.get("base_url"), cq.get("run_id"),
  571. str(cq.get("visit_uuid", ""))[:8], force_new,
  572. )
  573. else:
  574. logger.info(
  575. "[Bridge-Run] run_id=%s, session_id=%s, context_query 未注入(普通对话场景,跳过工作流上下文工具)",
  576. run_id, session_id or "<none>",
  577. )
  578. try:
  579. agent = _get_or_create_agent(
  580. session_id, req.max_iterations, req.hermes_home,
  581. model_id=req.model_id,
  582. base_url=req.base_url,
  583. api_key=req.api_key,
  584. model_name=req.model_name,
  585. force_new=force_new,
  586. )
  587. except (ImportError, ValueError) as e:
  588. raise HTTPException(status_code=503, detail=str(e))
  589. return StreamingResponse(
  590. _run_agent_stream(
  591. agent, req.system_prompt, req.user_message, run_id,
  592. req.working_dir, session_id, req.context_query,
  593. ),
  594. media_type="text/event-stream",
  595. headers={
  596. "Cache-Control": "no-cache",
  597. "X-Accel-Buffering": "no",
  598. "Connection": "keep-alive",
  599. },
  600. )
  601. @app.post("/clarify/{clarify_id}/answer")
  602. async def clarify_answer(clarify_id: str, payload: dict):
  603. """接收前端送回的 clarify 答案,唤醒阻塞中的 agent 线程。
  604. Bridge 模式下,agent 调用 clarify 工具时会在 on_clarify 回调里阻塞等待。
  605. Java/HermesBridgeClient 收到 clarify_request SSE 事件后持久化到 DB 并推给前端,
  606. 前端展示 Askfor UI;用户作答后通过 Java 转发到本端点。
  607. 请求体:{"answer": "<string>"}
  608. 返回:{"ok": true, "resolved": true} 或 {"ok": true, "resolved": false}(已超时/不存在)
  609. """
  610. from tools import clarify_gateway
  611. answer = (payload or {}).get("answer", "")
  612. if not isinstance(answer, str):
  613. answer = str(answer) if answer is not None else ""
  614. resolved = clarify_gateway.resolve_gateway_clarify(clarify_id, answer)
  615. if not resolved:
  616. with _clarify_lock:
  617. known = clarify_id in _clarify_ids
  618. if not known:
  619. logger.warning("收到未知 clarify_id 的回答: %s", clarify_id)
  620. else:
  621. logger.info("clarify_id %s 已被清理(agent 已退出)", clarify_id)
  622. else:
  623. logger.info("Clarify 回答已送达: clarify_id=%s", clarify_id)
  624. return {"ok": True, "resolved": resolved}
  625. @app.post("/skills/invalidate")
  626. async def skills_invalidate():
  627. """
  628. 清空技能相关缓存,使 Java 侧新同步到 HERMES_HOME/skills 的技能立即生效。
  629. 两层缓存:
  630. 1. agent.prompt_builder._SKILLS_PROMPT_CACHE —— 进程内 LRU,key 不含目录 mtime,
  631. 同一 skills_dir 下新增技能后不会自动失效,必须显式清空;
  632. 2. _agents —— Agent 池,系统提示词(含技能索引)在 Agent 创建时构建一次,
  633. 清空后下次请求重建 Agent 以拉取最新技能索引。
  634. 磁盘快照 .skills_prompt_snapshot.json 自带 mtime/size 校验,文件变更后自动失效,无需处理。
  635. """
  636. cleared_prompt_cache = False
  637. try:
  638. from agent.prompt_builder import clear_skills_system_prompt_cache
  639. clear_skills_system_prompt_cache()
  640. cleared_prompt_cache = True
  641. except Exception as e:
  642. logger.warning("清空 skills prompt 缓存失败(可能尚未加载 prompt_builder): %s", e)
  643. with _agent_lock:
  644. evicted = len(_agents)
  645. _agents.clear()
  646. logger.info("技能缓存已失效: prompt_cache=%s, evicted_agents=%d", cleared_prompt_cache, evicted)
  647. return {"ok": True, "promptCacheCleared": cleared_prompt_cache, "evictedAgents": evicted}
  648. @app.get("/health")
  649. async def health():
  650. """健康检查"""
  651. hermes_home = os.environ.get("HERMES_HOME", str(Path.home() / ".hermes"))
  652. hermes_agent_found = HERMES_AGENT_DIR.is_dir()
  653. # 尝试导入验证
  654. can_import = False
  655. try:
  656. import run_agent
  657. can_import = True
  658. except ImportError:
  659. pass
  660. return {
  661. "status": "ok" if (hermes_agent_found or can_import) else "degraded",
  662. "hermes_home": hermes_home,
  663. "hermes_agent_dir": str(HERMES_AGENT_DIR),
  664. "hermes_agent_found": hermes_agent_found,
  665. "can_import_agent": can_import,
  666. "active_sessions": len(_agents),
  667. }
  668. # ---------------------------------------------------------------------------
  669. # 启动
  670. # ---------------------------------------------------------------------------
  671. def main():
  672. parser = argparse.ArgumentParser(description="Hermes Bridge Server")
  673. parser.add_argument("--port", type=int, default=18731, help="监听端口 (默认 18731)")
  674. parser.add_argument("--host", default="127.0.0.1", help="监听地址 (默认 127.0.0.1)")
  675. parser.add_argument("--hermes-home", default=None, help="Hermes Home 目录")
  676. args = parser.parse_args()
  677. if args.hermes_home:
  678. os.environ["HERMES_HOME"] = args.hermes_home
  679. import uvicorn
  680. # M-7:必须单 worker 运行!_agents(OrderedDict)与 _clarify_ids(set)是进程级状态,
  681. # clarify 还依赖进程内阻塞线程 + set 内的 clarify_id 来路由答案。多 worker 会导致:
  682. # - Agent 缓存命中率骤降(每个 worker 独立 _agents,反复重建)
  683. # - clarify_answer 命中非阻塞 worker 时 resolve 返回 false,前端提交的回答丢失
  684. # 这里直接传 app 实例(而非 import 字符串),uvicorn 会强制单 worker;
  685. # 若未来改为 uvicorn.run("hermes_bridge:app", workers=N) 会破坏该约束,需重新设计共享状态。
  686. logger.info("启动 Hermes Bridge (单 worker): %s:%d", args.host, args.port)
  687. uvicorn.run(app, host=args.host, port=args.port, log_level="info", workers=1)
  688. if __name__ == "__main__":
  689. main()