HermesBridgeClient.java 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457
  1. package com.agent.management.engine.hermes;
  2. import com.agent.management.config.HermesProperties;
  3. import com.agent.management.engine.ExecutionLog;
  4. import com.agent.management.engine.HermesRunResult;
  5. import com.agent.management.engine.NodeStreamSink;
  6. import com.agent.management.service.HermesErrorPatternService;
  7. import com.fasterxml.jackson.databind.JsonNode;
  8. import com.fasterxml.jackson.databind.ObjectMapper;
  9. import lombok.extern.slf4j.Slf4j;
  10. import org.slf4j.Logger;
  11. import org.slf4j.LoggerFactory;
  12. import org.springframework.beans.factory.annotation.Autowired;
  13. import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
  14. import org.springframework.stereotype.Component;
  15. import java.io.BufferedReader;
  16. import java.io.IOException;
  17. import java.io.InputStreamReader;
  18. import java.net.HttpURLConnection;
  19. import java.net.URI;
  20. import java.nio.charset.StandardCharsets;
  21. import java.nio.file.Files;
  22. import java.nio.file.Path;
  23. import java.nio.file.Paths;
  24. import java.util.ArrayList;
  25. import java.util.List;
  26. import java.util.Map;
  27. import java.util.concurrent.ConcurrentHashMap;
  28. import java.util.stream.Stream;
  29. /**
  30. * Hermes Bridge HTTP 客户端。
  31. * 调用 Python Bridge 的 /run 端点,解析 SSE 流式响应。
  32. * 仅在 hermes.enabled=true 时激活。
  33. */
  34. @Slf4j
  35. @Component
  36. @ConditionalOnProperty(name = "hermes.enabled", havingValue = "true")
  37. public class HermesBridgeClient {
  38. /**
  39. * LLM/智能体调用专用 logger:
  40. * 由 logback-spring.xml 中 LLM_HTTP logger 配置为 DEBUG 级别,仅写入独立日志文件,不输出到控制台。
  41. */
  42. private static final Logger LLM_HTTP = LoggerFactory.getLogger("LLM_HTTP");
  43. private final HermesProperties properties;
  44. private final ObjectMapper objectMapper = new ObjectMapper();
  45. /**
  46. * 错误响应嗅探服务(方案 B)。
  47. *
  48. * <p>使用 {@link Autowired#required(boolean)} 标记为非必需:
  49. * 测试场景或 Hermes 关闭场景下允许为 null,{@code #parseSseResponse} 会跳过嗅探。</p>
  50. */
  51. private final HermesErrorPatternService errorPatternService;
  52. @Autowired
  53. public HermesBridgeClient(HermesProperties properties,
  54. @Autowired(required = false) HermesErrorPatternService errorPatternService) {
  55. this.properties = properties;
  56. this.errorPatternService = errorPatternService;
  57. }
  58. /**
  59. * 兼容旧调用方与测试:保留无嗅探服务的构造器。
  60. */
  61. public HermesBridgeClient(HermesProperties properties) {
  62. this(properties, null);
  63. }
  64. /**
  65. * 调用 Hermes Agent 执行任务。
  66. *
  67. * @param systemPrompt 系统提示(如 SKILL.md 内容)
  68. * @param userMessage 用户消息
  69. * @param maxIterations 最大工具调用轮次
  70. * @return 执行结果(含最终回复和执行日志)
  71. */
  72. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations) throws Exception {
  73. return run(systemPrompt, userMessage, maxIterations, null, null, null, null);
  74. }
  75. /**
  76. * 调用 Hermes Agent 执行任务(可指定 HERMES_HOME 与工作目录)。
  77. *
  78. * @param systemPrompt 系统提示(如 SKILL.md 内容)
  79. * @param userMessage 用户消息
  80. * @param maxIterations 最大工具调用轮次
  81. * @param hermesHome 本次运行使用的 HERMES_HOME 路径(其下应含 skills 子目录);
  82. * 为 null/空则使用 Bridge 启动时的默认值
  83. * @param workingDir agent 工具调用的实际工作目录(终端、文件读写以它为根);
  84. * 为 null/空则使用 Bridge 子进程的启动目录
  85. * @return 执行结果(含最终回复和执行日志)
  86. */
  87. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  88. String hermesHome, String workingDir) throws Exception {
  89. return run(systemPrompt, userMessage, maxIterations, hermesHome, workingDir, null, null);
  90. }
  91. /**
  92. * 调用 Hermes Agent 执行任务(流式回调版本)。
  93. *
  94. * <p>当 {@code sink} 非 null 时:</p>
  95. * <ul>
  96. * <li>{@code text} 事件实时通过 sink 推送,不再写入 logs(避免节点完成后重复显示)</li>
  97. * <li>{@code tool_start/tool_end} 事件同时进 logs 和 sink(结构化记录 + 实时反馈)</li>
  98. * </ul>
  99. *
  100. * @param nodeId 调用方节点 ID(写入 sink 用),可为 null
  101. * @param sink 流式回调,可为 null(退化为旧同步收集行为)
  102. */
  103. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  104. String hermesHome, String workingDir,
  105. String nodeId, NodeStreamSink sink) throws Exception {
  106. return run(systemPrompt, userMessage, maxIterations, hermesHome, workingDir, nodeId, sink, null);
  107. }
  108. /**
  109. * 调用 Hermes Agent 执行任务(完整版,可指定 sessionId)。
  110. *
  111. * <p>sessionId 用于隔离 Hermes 的 terminal / file 环境工作目录,
  112. * 避免同一 Bridge 进程内不同 workflow run 共享 "default" 环境导致 cwd 错误。</p>
  113. *
  114. * @param sessionId 会话/任务标识(建议 workflowRunId + "_" + nodeId)
  115. */
  116. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  117. String hermesHome, String workingDir,
  118. String nodeId, NodeStreamSink sink, String sessionId) throws Exception {
  119. return run(systemPrompt, userMessage, maxIterations, hermesHome, workingDir,
  120. nodeId, sink, sessionId, null);
  121. }
  122. /**
  123. * 调用 Hermes Agent 执行任务(完整版,可指定模型配置)。
  124. *
  125. * <p>modelConfig 非空时,Bridge 会优先使用其中的 base_url/api_key/model_name
  126. * 创建/复用 AIAgent,从而支持按节点选择不同的模型。</p>
  127. *
  128. * @param modelConfig 模型配置(modelId/baseUrl/apiKey/modelName),可为 null
  129. */
  130. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  131. String hermesHome, String workingDir,
  132. String nodeId, NodeStreamSink sink, String sessionId,
  133. Map<String, String> modelConfig) throws Exception {
  134. String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/run";
  135. // 未显式指定时回落到固定 HERMES_HOME(hermes.bridge.home),
  136. // 保证 Bridge 侧 agent 池缓存键与技能扫描目录稳定一致
  137. String effectiveHermesHome = (hermesHome != null && !hermesHome.isBlank())
  138. ? hermesHome
  139. : properties.getBridge().getResolvedHome().toString();
  140. Map<String, Object> body = new ConcurrentHashMap<>();
  141. body.put("user_message", userMessage);
  142. body.put("max_iterations", maxIterations);
  143. // 注入「可用技能列表 + 高效执行策略」前置 prompt,减少 LLM 猜名失败与上下文膨胀
  144. String enhancedSystemPrompt = buildEnhancedSystemPrompt(effectiveHermesHome, systemPrompt);
  145. if (enhancedSystemPrompt != null && !enhancedSystemPrompt.isBlank()) {
  146. body.put("system_prompt", enhancedSystemPrompt);
  147. }
  148. body.put("hermes_home", effectiveHermesHome);
  149. if (workingDir != null && !workingDir.isBlank()) {
  150. body.put("working_dir", workingDir);
  151. }
  152. if (sessionId != null && !sessionId.isBlank()) {
  153. body.put("session_id", sessionId);
  154. }
  155. if (modelConfig != null) {
  156. if (modelConfig.get("modelId") != null && !modelConfig.get("modelId").isBlank()) {
  157. body.put("model_id", modelConfig.get("modelId"));
  158. }
  159. if (modelConfig.get("baseUrl") != null && !modelConfig.get("baseUrl").isBlank()) {
  160. body.put("base_url", modelConfig.get("baseUrl"));
  161. }
  162. if (modelConfig.get("apiKey") != null && !modelConfig.get("apiKey").isBlank()) {
  163. body.put("api_key", modelConfig.get("apiKey"));
  164. }
  165. if (modelConfig.get("modelName") != null && !modelConfig.get("modelName").isBlank()) {
  166. body.put("model_name", modelConfig.get("modelName"));
  167. }
  168. }
  169. byte[] bodyBytes = objectMapper.writeValueAsBytes(body);
  170. HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
  171. conn.setRequestMethod("POST");
  172. conn.setDoOutput(true);
  173. conn.setRequestProperty("Content-Type", "application/json");
  174. conn.setRequestProperty("Accept", "text/event-stream");
  175. // M5: 注入 X-Bridge-Token 共享密钥,由 Bridge 端校验
  176. String authToken = properties.getBridge().getAuthToken();
  177. if (authToken != null && !authToken.isBlank()) {
  178. conn.setRequestProperty("X-Bridge-Token", authToken);
  179. }
  180. conn.setConnectTimeout(10000);
  181. conn.setReadTimeout(300000);
  182. // === 请求 DEBUG 日志(仅写入 LLM 日志文件,不输出到控制台) ===
  183. LLM_HTTP.debug("[Hermes-REQ] ========== 请求开始 ==========");
  184. LLM_HTTP.debug("[Hermes-REQ] POST {}", url);
  185. LLM_HTTP.debug("[Hermes-REQ] nodeId={}, sessionId={}, maxIterations={}", nodeId, sessionId, maxIterations);
  186. LLM_HTTP.debug("[Hermes-REQ] Body ({} bytes): {}", bodyBytes.length, new String(bodyBytes, StandardCharsets.UTF_8));
  187. long startTime = System.currentTimeMillis();
  188. conn.getOutputStream().write(bodyBytes);
  189. conn.getOutputStream().flush();
  190. int responseCode = conn.getResponseCode();
  191. // connectMs = 建立连接 + 写出 body + 收到 HTTP 响应头的时间
  192. // (注意:SSE 的响应头几乎立刻到达,body 还在流式传输,所以这里耗时很短)
  193. long connectMs = System.currentTimeMillis() - startTime;
  194. if (responseCode != 200) {
  195. String error;
  196. try (var reader = new BufferedReader(new InputStreamReader(conn.getErrorStream(), StandardCharsets.UTF_8))) {
  197. StringBuilder sb = new StringBuilder();
  198. String line;
  199. while ((line = reader.readLine()) != null) sb.append(line);
  200. error = sb.toString();
  201. }
  202. long totalMs = connectMs;
  203. LLM_HTTP.debug("[Hermes-RESP] HTTP {} (total={}ms), error: {}", responseCode, totalMs, error);
  204. throw new RuntimeException("Hermes Bridge 返回 HTTP " + responseCode + ": " + error);
  205. }
  206. long streamStart = System.currentTimeMillis();
  207. HermesRunResult result = parseSseResponse(conn, nodeId, sink);
  208. // streamMs = 读取 SSE 流式 body 的耗时(这才是真正的 Agent 执行时间,包含所有 LLM 调用与工具循环)
  209. long streamMs = System.currentTimeMillis() - streamStart;
  210. long totalMs = System.currentTimeMillis() - startTime;
  211. // === 响应 DEBUG 日志(分别记录连接耗时与流式读取耗时,便于定位卡点) ===
  212. LLM_HTTP.debug("[Hermes-RESP] HTTP 200, connect={}ms, stream={}ms, total={}ms, finalText 长度={}, logs 数={}",
  213. connectMs, streamMs, totalMs,
  214. result.getFinalText() == null ? 0 : result.getFinalText().length(),
  215. result.getLogs() == null ? 0 : result.getLogs().size());
  216. LLM_HTTP.debug("[Hermes-RESP] finalText: {}", result.getFinalText());
  217. LLM_HTTP.debug("[Hermes-RESP] ========== 请求结束 ==========");
  218. return result;
  219. }
  220. /**
  221. * 解析 SSE 响应流,收集日志并提取最终回复。
  222. * 若 sink 非 null:thinking 通过 sink 实时推送(不进 logs),工具事件同时进 logs 和 sink。
  223. * 若 sink 为 null:thinking 进 logs(兼容旧行为)。
  224. */
  225. private HermesRunResult parseSseResponse(HttpURLConnection conn, String nodeId, NodeStreamSink sink) throws Exception {
  226. StringBuilder finalResponse = new StringBuilder();
  227. List<ExecutionLog> logs = new ArrayList<>();
  228. try (BufferedReader reader = new BufferedReader(
  229. new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
  230. String eventType = "";
  231. String line;
  232. while ((line = reader.readLine()) != null) {
  233. if (line.startsWith("event: ")) {
  234. eventType = line.substring(7).trim();
  235. } else if (line.startsWith("data: ")) {
  236. String dataJson = line.substring(6);
  237. JsonNode data = objectMapper.readTree(dataJson);
  238. switch (eventType) {
  239. case "tool_start" -> {
  240. String tool = data.path("tool").asText("?");
  241. String args = data.has("args") ? objectMapper.writeValueAsString(data.get("args")) : null;
  242. String argsTrunc = truncate(args, 500);
  243. logs.add(ExecutionLog.toolCall(tool, argsTrunc));
  244. if (sink != null) {
  245. try {
  246. sink.emit(nodeId, "tool_call", argsTrunc, tool);
  247. } catch (Exception ex) {
  248. log.warn("[Hermes] sink 推送 tool_call 失败: {}", ex.getMessage());
  249. }
  250. }
  251. log.debug("[Hermes] 工具调用: {}", tool);
  252. }
  253. case "tool_end" -> {
  254. String tool = data.path("tool").asText("?");
  255. String summary = data.path("result_summary").asText("");
  256. String summaryTrunc = truncate(summary, 500);
  257. logs.add(ExecutionLog.toolResult(tool, summaryTrunc));
  258. if (sink != null) {
  259. try {
  260. sink.emit(nodeId, "tool_result", summaryTrunc, tool);
  261. } catch (Exception ex) {
  262. log.warn("[Hermes] sink 推送 tool_result 失败: {}", ex.getMessage());
  263. }
  264. }
  265. log.debug("[Hermes] 工具完成: {}", tool);
  266. }
  267. case "text" -> {
  268. String delta = data.path("content").asText("");
  269. if (!delta.isEmpty()) {
  270. // thinking 既要实时推送(流式 UI),也要进入 logs(运行记录可回放)
  271. logs.add(ExecutionLog.thinking(delta));
  272. if (sink != null) {
  273. try {
  274. sink.emit(nodeId, "thinking", delta, null);
  275. } catch (Exception ex) {
  276. log.warn("[Hermes] sink 推送 thinking 失败: {}", ex.getMessage());
  277. }
  278. }
  279. }
  280. }
  281. case "done" -> finalResponse.append(data.path("content").asText(""));
  282. case "error" -> {
  283. String msg = data.path("message").asText("未知错误");
  284. logs.add(ExecutionLog.error(msg));
  285. throw new RuntimeException("Hermes Agent 执行错误: " + msg);
  286. }
  287. default -> {}
  288. }
  289. eventType = "";
  290. }
  291. }
  292. }
  293. String finalText = finalResponse.toString();
  294. // B: 错误响应嗅探 —— Hermes Agent / hermes-agent 底层把 LLM 失败描述当作 final_response 返回,
  295. // 这里通过可配置模式列表识别 4xx/限流/认证失败等典型错误,命中即抛 HermesErrorResponseException,
  296. // 由 HermesAgentExecutor / HermesSmartActionExecutor 转化为 NodeExecutionResult.failed
  297. if (errorPatternService != null) {
  298. HermesErrorPatternService.ErrorDetectionResult detection = errorPatternService.isErrorResponse(finalText);
  299. if (detection.isError()) {
  300. String errMsg = String.format("Hermes Agent 响应被识别为错误(命中模式「%s」): %s",
  301. detection.getMatchedPatternName(), truncate(finalText, 200));
  302. LLM_HTTP.warn("[Hermes-RESP] 错误响应嗅探命中: pattern={}, response(截断)={}",
  303. detection.getMatchedPatternName(), truncate(finalText, 500));
  304. throw new HermesErrorResponseException(errMsg, detection.getMatchedPatternName());
  305. }
  306. }
  307. return new HermesRunResult(finalText, logs);
  308. }
  309. private String truncate(String text, int maxLen) {
  310. if (text == null) return null;
  311. return text.length() <= maxLen ? text : text.substring(0, maxLen) + "...";
  312. }
  313. /**
  314. * 高效执行策略前置 prompt。
  315. *
  316. * <p>用于解决 Hermes Agent 执行慢的几个根因(基于日志分析):</p>
  317. * <ul>
  318. * <li>② LLM 猜错技能名导致无效工具调用(readme-gen / git-commit-log-gen 等不存在)</li>
  319. * <li>③ 中间轮次过度消化工具结果,每轮都生成几百 token 的「思考总结」</li>
  320. * <li>④ read_file 整个大文件导致 in= token 雪球式膨胀(23k→50k)</li>
  321. * <li>⑤ 探索完成后仍在循环验证,多出 2-3 轮无意义 LLM 调用</li>
  322. * </ul>
  323. *
  324. * <p>这是「软优化」——通过 prompt 引导 LLM 自主控制上下文,而非硬性截断。
  325. * 对比修改 hermes-agent 内部的 trajectory_compressor,这种方式风险最低、维护成本最小。</p>
  326. */
  327. private static final String EFFICIENCY_PROMPT_TEMPLATE =
  328. "=== 执行策略提示(必须严格遵守)===\n\n" +
  329. "## 可用技能(skill_view 调用时请使用精确名,禁止猜测)\n%s\n\n" +
  330. "## 技能根目录(构造脚本路径时必须使用,禁止凭记忆猜测)\n" +
  331. "所有技能文件位于:%s\n" +
  332. "技能的脚本/资源绝对路径 = 上述根目录 + /技能名/技能内相对路径," +
  333. "例如技能 foo 的 scripts/run.py 的绝对路径为「技能根目录/foo/scripts/run.py」。\n" +
  334. "SKILL.md 中的相对路径均相对于该技能自身目录;terminal 的工作目录不是技能目录," +
  335. "执行脚本前必须先按此规则拼接出完整绝对路径。\n\n" +
  336. "## 高效执行原则\n" +
  337. "1. 一次性产出:明确目标后,应一次性生成所有需要的文件/输出," +
  338. "不要每完成一个小步骤就停下来生成中间总结,避免多消耗 1 轮 LLM 调用。\n" +
  339. "2. 摘要工具优先:当文件较大(>200 行)时,优先使用 head_file / search_files / grep " +
  340. "等摘要工具定位关键内容,避免直接 read_file 整个大文件——上下文膨胀会让后续每一轮都更慢。\n" +
  341. "3. 控制探索深度:达到目标即停止,不要重复读取已知信息、反复验证;" +
  342. "任务完成后用一句话告知「已完成」,不要生成冗长总结报告。\n" +
  343. "4. 工具结果复用:上一轮已经读到的内容,本轮不要重复读取;" +
  344. "如需引用,直接基于已读内容作答。\n\n" +
  345. "## 注意\n" +
  346. "上述原则是「在合适场景下」的优化指引。当任务确实需要逐项探索(如多文件批量分析)时," +
  347. "可按需展开;但不要为了「显得认真」而增加无意义的中间步骤。\n";
  348. /**
  349. * 在原始 system_prompt 前追加「执行策略提示」。
  350. *
  351. * @param hermesHome 本次运行的 HERMES_HOME(其下应有 skills 子目录),用于扫描可用技能列表
  352. * @param basePrompt 调用方提供的原始 system_prompt(如 SKILL.md 内容),可为 null
  353. * @return 增强后的 system_prompt;若 basePrompt 为空且无可用技能列表,返回 null(不发 system_prompt)
  354. */
  355. private String buildEnhancedSystemPrompt(String hermesHome, String basePrompt) {
  356. String skillsList = scanAvailableSkills(hermesHome);
  357. String skillsRoot = Paths.get(hermesHome, "skills").toString();
  358. String efficiencySection = String.format(EFFICIENCY_PROMPT_TEMPLATE, skillsList, skillsRoot);
  359. if (basePrompt == null || basePrompt.isBlank()) {
  360. return efficiencySection;
  361. }
  362. return efficiencySection + "\n---\n\n" + basePrompt;
  363. }
  364. /**
  365. * 扫描 HERMES_HOME/skills 目录,返回逗号分隔的技能 folderName 列表。
  366. * 扫描失败时返回占位文案(不影响主流程)。
  367. *
  368. * <p>每次扫描开销很小(单次 Files.list,几十毫秒级),相对于 Hermes 整体上百秒的耗时可以忽略,
  369. * 因此不做缓存——这也避免了「运行时新增技能但缓存未更新」的问题。</p>
  370. */
  371. private String scanAvailableSkills(String hermesHome) {
  372. if (hermesHome == null || hermesHome.isBlank()) {
  373. return "(未提供 hermes_home,无法列出)";
  374. }
  375. Path skillsDir = Paths.get(hermesHome, "skills");
  376. if (!Files.isDirectory(skillsDir)) {
  377. return "(skills 目录不存在于 " + skillsDir + ")";
  378. }
  379. List<String> names = new ArrayList<>();
  380. try (Stream<Path> entries = Files.list(skillsDir)) {
  381. entries.filter(Files::isDirectory)
  382. .map(p -> p.getFileName().toString())
  383. .filter(s -> !s.startsWith(".") && !s.equals("__pycache__"))
  384. .sorted()
  385. .forEach(names::add);
  386. } catch (IOException e) {
  387. log.warn("[HermesBridgeClient] 扫描技能目录失败: {}", e.getMessage());
  388. return "(扫描失败: " + e.getMessage() + ")";
  389. }
  390. if (names.isEmpty()) {
  391. return "(无)";
  392. }
  393. return String.join(", ", names);
  394. }
  395. /**
  396. * 通知 Bridge 清空技能索引缓存与 Agent 池,使新同步的技能立即生效。
  397. * 由 HermesSkillSyncService 在技能写时同步后调用;失败仅记录日志,不影响主流程。
  398. */
  399. public void invalidateSkillsCache() {
  400. String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/skills/invalidate";
  401. try {
  402. HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
  403. conn.setRequestMethod("POST");
  404. String authToken = properties.getBridge().getAuthToken();
  405. if (authToken != null && !authToken.isBlank()) {
  406. conn.setRequestProperty("X-Bridge-Token", authToken);
  407. }
  408. conn.setConnectTimeout(3000);
  409. conn.setReadTimeout(5000);
  410. int code = conn.getResponseCode();
  411. if (code != 200) {
  412. log.warn("[HermesBridgeClient] /skills/invalidate 返回非 200: {}", code);
  413. } else {
  414. log.info("[HermesBridgeClient] Bridge 技能缓存已失效");
  415. }
  416. conn.disconnect();
  417. } catch (Exception e) {
  418. // Bridge 未启动或不可达时静默降级:下次 Bridge 重启后进程级缓存自然重建
  419. log.warn("[HermesBridgeClient] 调用 /skills/invalidate 失败(Bridge 可能未就绪): {}", e.getMessage());
  420. }
  421. }
  422. }