HermesBridgeClient.java 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712
  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.InputStream;
  18. import java.io.InputStreamReader;
  19. import java.net.HttpURLConnection;
  20. import java.net.URI;
  21. import java.nio.charset.StandardCharsets;
  22. import java.nio.file.Files;
  23. import java.nio.file.Path;
  24. import java.nio.file.Paths;
  25. import java.util.ArrayList;
  26. import java.util.LinkedHashMap;
  27. import java.util.List;
  28. import java.util.Map;
  29. import java.util.stream.Stream;
  30. /**
  31. * Hermes Bridge HTTP 客户端。
  32. * 调用 Python Bridge 的 /run 端点,解析 SSE 流式响应。
  33. * 仅在 hermes.enabled=true 时激活。
  34. */
  35. @Slf4j
  36. @Component
  37. @ConditionalOnProperty(name = "hermes.enabled", havingValue = "true")
  38. public class HermesBridgeClient {
  39. /**
  40. * LLM/智能体调用专用 logger:
  41. * 由 logback-spring.xml 中 LLM_HTTP logger 配置为 DEBUG 级别,仅写入独立日志文件,不输出到控制台。
  42. */
  43. private static final Logger LLM_HTTP = LoggerFactory.getLogger("LLM_HTTP");
  44. private final HermesProperties properties;
  45. private final ObjectMapper objectMapper = new ObjectMapper();
  46. /**
  47. * 错误响应嗅探服务(方案 B)。
  48. *
  49. * <p>使用 {@link Autowired#required(boolean)} 标记为非必需:
  50. * 测试场景或 Hermes 关闭场景下允许为 null,{@code #parseSseResponse} 会跳过嗅探。</p>
  51. */
  52. private final HermesErrorPatternService errorPatternService;
  53. @Autowired
  54. public HermesBridgeClient(HermesProperties properties,
  55. @Autowired(required = false) HermesErrorPatternService errorPatternService) {
  56. this.properties = properties;
  57. this.errorPatternService = errorPatternService;
  58. }
  59. /**
  60. * 兼容旧调用方与测试:保留无嗅探服务的构造器。
  61. */
  62. public HermesBridgeClient(HermesProperties properties) {
  63. this(properties, null);
  64. }
  65. /**
  66. * 调用 Hermes Agent 执行任务(简单版本)。
  67. *
  68. * @deprecated M-5:推荐使用 {@link HermesRunRequest} 参数对象 + {@link #run(HermesRunRequest)},
  69. * 避免多参数重载导致的参数顺序混淆。本重载保留仅为向后兼容。
  70. */
  71. @Deprecated
  72. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations) throws Exception {
  73. return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations));
  74. }
  75. /**
  76. * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。
  77. */
  78. @Deprecated
  79. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  80. String hermesHome, String workingDir) throws Exception {
  81. return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations)
  82. .hermesHome(hermesHome).workingDir(workingDir));
  83. }
  84. /**
  85. * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。
  86. */
  87. @Deprecated
  88. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  89. String hermesHome, String workingDir,
  90. String nodeId, NodeStreamSink sink) throws Exception {
  91. return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations)
  92. .hermesHome(hermesHome).workingDir(workingDir).nodeId(nodeId).sink(sink));
  93. }
  94. /**
  95. * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。
  96. */
  97. @Deprecated
  98. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  99. String hermesHome, String workingDir,
  100. String nodeId, NodeStreamSink sink, String sessionId) throws Exception {
  101. return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations)
  102. .hermesHome(hermesHome).workingDir(workingDir)
  103. .nodeId(nodeId).sink(sink).sessionId(sessionId));
  104. }
  105. /**
  106. * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。
  107. */
  108. @Deprecated
  109. public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations,
  110. String hermesHome, String workingDir,
  111. String nodeId, NodeStreamSink sink, String sessionId,
  112. Map<String, String> modelConfig) throws Exception {
  113. return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations)
  114. .hermesHome(hermesHome).workingDir(workingDir)
  115. .nodeId(nodeId).sink(sink).sessionId(sessionId).modelConfig(modelConfig));
  116. }
  117. /**
  118. * 主入口:使用参数对象 {@link HermesRunRequest} 调用 Hermes Agent。
  119. *
  120. * <p>M-5:合并原 5 个重载为单一入口,避免参数顺序混淆。
  121. * 旧重载保留为兼容入口,内部全部委托给本方法。</p>
  122. */
  123. public HermesRunResult run(HermesRunRequest req) throws Exception {
  124. String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/run";
  125. // 未显式指定时回落到固定 HERMES_HOME(hermes.bridge.home),
  126. // 保证 Bridge 侧 agent 池缓存键与技能扫描目录稳定一致
  127. String effectiveHermesHome = (req.hermesHome != null && !req.hermesHome.isBlank())
  128. ? req.hermesHome
  129. : properties.getBridge().getResolvedHome().toString();
  130. // H-3:请求 body 是单线程构建 + 序列化,无需并发容器,改用 LinkedHashMap
  131. Map<String, Object> body = new LinkedHashMap<>();
  132. body.put("user_message", req.userMessage);
  133. body.put("max_iterations", req.maxIterations);
  134. // 注入「可用技能列表 + 高效执行策略」前置 prompt,减少 LLM 猜名失败与上下文膨胀
  135. String enhancedSystemPrompt = buildEnhancedSystemPrompt(effectiveHermesHome, req.systemPrompt);
  136. if (enhancedSystemPrompt != null && !enhancedSystemPrompt.isBlank()) {
  137. body.put("system_prompt", enhancedSystemPrompt);
  138. }
  139. body.put("hermes_home", effectiveHermesHome);
  140. if (req.workingDir != null && !req.workingDir.isBlank()) {
  141. body.put("working_dir", req.workingDir);
  142. }
  143. if (req.sessionId != null && !req.sessionId.isBlank()) {
  144. body.put("session_id", req.sessionId);
  145. }
  146. if (req.modelConfig != null) {
  147. if (req.modelConfig.get("modelId") != null && !req.modelConfig.get("modelId").isBlank()) {
  148. body.put("model_id", req.modelConfig.get("modelId"));
  149. }
  150. if (req.modelConfig.get("baseUrl") != null && !req.modelConfig.get("baseUrl").isBlank()) {
  151. body.put("base_url", req.modelConfig.get("baseUrl"));
  152. }
  153. if (req.modelConfig.get("apiKey") != null && !req.modelConfig.get("apiKey").isBlank()) {
  154. body.put("api_key", req.modelConfig.get("apiKey"));
  155. }
  156. if (req.modelConfig.get("modelName") != null && !req.modelConfig.get("modelName").isBlank()) {
  157. body.put("model_name", req.modelConfig.get("modelName"));
  158. }
  159. }
  160. byte[] bodyBytes = objectMapper.writeValueAsBytes(body);
  161. HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
  162. conn.setRequestMethod("POST");
  163. conn.setDoOutput(true);
  164. conn.setRequestProperty("Content-Type", "application/json");
  165. conn.setRequestProperty("Accept", "text/event-stream");
  166. // M5: 注入 X-Bridge-Token 共享密钥,由 Bridge 端校验
  167. String authToken = properties.getBridge().getAuthToken();
  168. if (authToken != null && !authToken.isBlank()) {
  169. conn.setRequestProperty("X-Bridge-Token", authToken);
  170. }
  171. conn.setConnectTimeout(10000);
  172. // v1.1:readTimeout 改为 24 小时(兜底),实际 Askfor 长等待由 SSE 心跳 + 前端重连机制保证。
  173. // 原 5 分钟超时无法支撑用户离开几小时后再回来回答的场景。
  174. conn.setReadTimeout(86_400_000);
  175. // === 请求 DEBUG 日志(仅写入 LLM 日志文件,不输出到控制台) ===
  176. LLM_HTTP.debug("[Hermes-REQ] ========== 请求开始 ==========");
  177. LLM_HTTP.debug("[Hermes-REQ] POST {}", url);
  178. LLM_HTTP.debug("[Hermes-REQ] nodeId={}, sessionId={}, maxIterations={}", req.nodeId, req.sessionId, req.maxIterations);
  179. LLM_HTTP.debug("[Hermes-REQ] Body ({} bytes): {}", bodyBytes.length, new String(bodyBytes, StandardCharsets.UTF_8));
  180. long startTime = System.currentTimeMillis();
  181. conn.getOutputStream().write(bodyBytes);
  182. conn.getOutputStream().flush();
  183. int responseCode = conn.getResponseCode();
  184. // connectMs = 建立连接 + 写出 body + 收到 HTTP 响应头的时间
  185. // (注意:SSE 的响应头几乎立刻到达,body 还在流式传输,所以这里耗时很短)
  186. long connectMs = System.currentTimeMillis() - startTime;
  187. if (responseCode != 200) {
  188. String error = readErrorStream(conn);
  189. long totalMs = connectMs;
  190. LLM_HTTP.debug("[Hermes-RESP] HTTP {} (total={}ms), error: {}", responseCode, totalMs, error);
  191. throw new RuntimeException("Hermes Bridge 返回 HTTP " + responseCode + ": " + error);
  192. }
  193. long streamStart = System.currentTimeMillis();
  194. HermesRunResult result = parseSseResponse(conn, req.nodeId, req.sink);
  195. // streamMs = 读取 SSE 流式 body 的耗时(这才是真正的 Agent 执行时间,包含所有 LLM 调用与工具循环)
  196. long streamMs = System.currentTimeMillis() - streamStart;
  197. long totalMs = System.currentTimeMillis() - startTime;
  198. // === 响应 DEBUG 日志(分别记录连接耗时与流式读取耗时,便于定位卡点) ===
  199. LLM_HTTP.debug("[Hermes-RESP] HTTP 200, connect={}ms, stream={}ms, total={}ms, finalText 长度={}, logs 数={}",
  200. connectMs, streamMs, totalMs,
  201. result.getFinalText() == null ? 0 : result.getFinalText().length(),
  202. result.getLogs() == null ? 0 : result.getLogs().size());
  203. LLM_HTTP.debug("[Hermes-RESP] finalText: {}", result.getFinalText());
  204. LLM_HTTP.debug("[Hermes-RESP] ========== 请求结束 ==========");
  205. return result;
  206. }
  207. /**
  208. * H-2:消费 errorStream,避免 keep-alive 连接被 stale。
  209. * 任何异常都吞掉(调用方已知 code != 200,只关心 error 文本作为日志)。
  210. */
  211. private static String readErrorStream(HttpURLConnection conn) {
  212. InputStream es = null;
  213. try {
  214. es = conn.getErrorStream();
  215. if (es == null) return "";
  216. try (BufferedReader reader = new BufferedReader(new InputStreamReader(es, StandardCharsets.UTF_8))) {
  217. StringBuilder sb = new StringBuilder();
  218. String line;
  219. while ((line = reader.readLine()) != null) sb.append(line);
  220. return sb.toString();
  221. }
  222. } catch (IOException e) {
  223. return "";
  224. } finally {
  225. // 消费完后立即断开,释放底层 socket(避免 keep-alive 残留)
  226. conn.disconnect();
  227. }
  228. }
  229. /**
  230. * M-5:Hermes Agent 运行请求参数对象(不可变 + fluent setter)。
  231. *
  232. * <p>用法:</p>
  233. * <pre>{@code
  234. * HermesRunResult r = client.run(HermesRunRequest.of(prompt, msg, 100)
  235. * .hermesHome("/data/.hermes")
  236. * .workingDir(workDir)
  237. * .nodeId(nodeId)
  238. * .sink(sink)
  239. * .sessionId(sessionKey)
  240. * .modelConfig(modelConfig));
  241. * }</pre>
  242. */
  243. public static final class HermesRunRequest {
  244. private final String systemPrompt;
  245. private final String userMessage;
  246. private final int maxIterations;
  247. private String hermesHome;
  248. private String workingDir;
  249. private String nodeId;
  250. private NodeStreamSink sink;
  251. private String sessionId;
  252. private Map<String, String> modelConfig;
  253. private HermesRunRequest(String systemPrompt, String userMessage, int maxIterations) {
  254. this.systemPrompt = systemPrompt;
  255. this.userMessage = userMessage;
  256. this.maxIterations = maxIterations;
  257. }
  258. public static HermesRunRequest of(String systemPrompt, String userMessage, int maxIterations) {
  259. return new HermesRunRequest(systemPrompt, userMessage, maxIterations);
  260. }
  261. public HermesRunRequest hermesHome(String v) { this.hermesHome = v; return this; }
  262. public HermesRunRequest workingDir(String v) { this.workingDir = v; return this; }
  263. public HermesRunRequest nodeId(String v) { this.nodeId = v; return this; }
  264. public HermesRunRequest sink(NodeStreamSink v) { this.sink = v; return this; }
  265. public HermesRunRequest sessionId(String v) { this.sessionId = v; return this; }
  266. public HermesRunRequest modelConfig(Map<String, String> v) { this.modelConfig = v; return this; }
  267. }
  268. /**
  269. * 解析 SSE 响应流,收集日志并提取最终回复。
  270. * 若 sink 非 null:thinking 通过 sink 实时推送(不进 logs),工具事件同时进 logs 和 sink。
  271. * 若 sink 为 null:thinking 进 logs(兼容旧行为)。
  272. */
  273. private HermesRunResult parseSseResponse(HttpURLConnection conn, String nodeId, NodeStreamSink sink) throws Exception {
  274. StringBuilder finalResponse = new StringBuilder();
  275. List<ExecutionLog> logs = new ArrayList<>();
  276. try (BufferedReader reader = new BufferedReader(
  277. new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
  278. String eventType = "";
  279. String line;
  280. while ((line = reader.readLine()) != null) {
  281. if (line.startsWith("event: ")) {
  282. eventType = line.substring(7).trim();
  283. } else if (line.startsWith("data: ")) {
  284. String dataJson = line.substring(6);
  285. JsonNode data = objectMapper.readTree(dataJson);
  286. switch (eventType) {
  287. case "tool_start" -> {
  288. String tool = data.path("tool").asText("?");
  289. String args = data.has("args") ? objectMapper.writeValueAsString(data.get("args")) : null;
  290. String argsTrunc = truncate(args, 500);
  291. logs.add(ExecutionLog.toolCall(tool, argsTrunc));
  292. if (sink != null) {
  293. try {
  294. sink.emit(nodeId, "tool_call", argsTrunc, tool);
  295. } catch (Exception ex) {
  296. log.warn("[Hermes] sink 推送 tool_call 失败: {}", ex.getMessage());
  297. }
  298. }
  299. log.debug("[Hermes] 工具调用: {}", tool);
  300. }
  301. case "tool_end" -> {
  302. String tool = data.path("tool").asText("?");
  303. String summary = data.path("result_summary").asText("");
  304. String summaryTrunc = truncate(summary, 500);
  305. logs.add(ExecutionLog.toolResult(tool, summaryTrunc));
  306. if (sink != null) {
  307. try {
  308. sink.emit(nodeId, "tool_result", summaryTrunc, tool);
  309. } catch (Exception ex) {
  310. log.warn("[Hermes] sink 推送 tool_result 失败: {}", ex.getMessage());
  311. }
  312. }
  313. log.debug("[Hermes] 工具完成: {}", tool);
  314. }
  315. case "text" -> {
  316. String delta = data.path("content").asText("");
  317. if (!delta.isEmpty()) {
  318. // thinking 既要实时推送(流式 UI),也要进入 logs(运行记录可回放)
  319. logs.add(ExecutionLog.thinking(delta));
  320. if (sink != null) {
  321. try {
  322. sink.emit(nodeId, "thinking", delta, null);
  323. } catch (Exception ex) {
  324. log.warn("[Hermes] sink 推送 thinking 失败: {}", ex.getMessage());
  325. }
  326. }
  327. }
  328. }
  329. // v1.1:每轮 LLM 调用前的进度(iteration + prev_tools)
  330. case "step" -> {
  331. int iteration = data.path("iteration").asInt(0);
  332. String prevToolsJson = data.has("prev_tools")
  333. ? objectMapper.writeValueAsString(data.get("prev_tools")) : "[]";
  334. logs.add(ExecutionLog.step(iteration, prevToolsJson));
  335. if (sink != null) {
  336. try {
  337. sink.emit(nodeId, "step",
  338. "{\"iteration\":" + iteration + ",\"prev_tools\":" + prevToolsJson + "}",
  339. null);
  340. } catch (Exception ex) {
  341. log.warn("[Hermes] sink 推送 step 失败: {}", ex.getMessage());
  342. }
  343. }
  344. }
  345. // v1.1:实时状态文案(覆盖式,如"压缩上下文中")
  346. case "status" -> {
  347. String message = data.path("message").asText("");
  348. if (!message.isEmpty()) {
  349. logs.add(ExecutionLog.status(message));
  350. if (sink != null) {
  351. try {
  352. sink.emit(nodeId, "status", message, null);
  353. } catch (Exception ex) {
  354. log.warn("[Hermes] sink 推送 status 失败: {}", ex.getMessage());
  355. }
  356. }
  357. }
  358. }
  359. // v1.1:thinking_callback 透传(Hermes 思考增量,与 text 区分用于实时思考区展示)
  360. case "thinking_status" -> {
  361. String content = data.path("content").asText("");
  362. if (!content.isEmpty()) {
  363. if (sink != null) {
  364. try {
  365. // 前端按 kind=status 聚合到"实时思考"区
  366. sink.emit(nodeId, "status", content, null);
  367. } catch (Exception ex) {
  368. log.warn("[Hermes] sink 推送 thinking_status 失败: {}", ex.getMessage());
  369. }
  370. }
  371. }
  372. }
  373. // v1.1:工具细粒度进度(event_type + name + preview/args)
  374. case "tool_progress" -> {
  375. String tpEventType = data.path("event_type").asText("");
  376. String toolName = data.path("name").asText(null);
  377. String preview = data.has("preview")
  378. ? objectMapper.writeValueAsString(data.get("preview")) : null;
  379. String detailJson = (preview != null) ? preview
  380. : (data.has("args") ? objectMapper.writeValueAsString(data.get("args")) : "{}");
  381. logs.add(ExecutionLog.toolProgress(tpEventType, detailJson));
  382. if (sink != null) {
  383. try {
  384. sink.emit(nodeId, "tool_progress",
  385. "{\"event_type\":\"" + tpEventType + "\",\"name\":\"" + toolName
  386. + "\",\"preview\":" + (preview != null ? preview : "null") + "}",
  387. toolName);
  388. } catch (Exception ex) {
  389. log.warn("[Hermes] sink 推送 tool_progress 失败: {}", ex.getMessage());
  390. }
  391. }
  392. }
  393. // v1.1:todo 工具完整输出(前端解析后渲染 TODO List)
  394. // Python 端 hermes_bridge.py 已用 json.dumps 把 result 序列化为字符串,
  395. // 这里用 asText 取字符串原值,避免 writeAsString 再次转义导致前端二次 parse 失败
  396. case "todo_update" -> {
  397. String fullJson = data.path("result").asText("{}");
  398. logs.add(ExecutionLog.todoUpdate(fullJson));
  399. if (sink != null) {
  400. try {
  401. sink.emit(nodeId, "todo_update", fullJson, "todo");
  402. } catch (Exception ex) {
  403. log.warn("[Hermes] sink 推送 todo_update 失败: {}", ex.getMessage());
  404. }
  405. }
  406. }
  407. // v1.1:模型请求用户确认(Askfor 区域)
  408. // 注意:clarify_request 不结束 SSE 读取循环,Bridge 在 Python 侧同步等待用户回答后
  409. // 才会继续推送后续事件。这里只负责把请求转发给前端,不中断 readLine。
  410. case "clarify_request" -> {
  411. String clarifyId = data.path("clarify_id").asText("");
  412. String question = data.path("question").asText("");
  413. String choicesJson = data.has("choices")
  414. ? objectMapper.writeValueAsString(data.get("choices")) : "[]";
  415. logs.add(ExecutionLog.clarifyRequest(clarifyId, question, choicesJson));
  416. if (sink != null) {
  417. try {
  418. // H-4:原手工拼接 JSON 缺乏对 \n / \\ 等特殊字符的转义,
  419. // 前端 JSON.parse 易失败。改用 ObjectMapper 构造保证 JSON 合法。
  420. com.fasterxml.jackson.databind.node.ObjectNode clarifyPayload =
  421. objectMapper.createObjectNode();
  422. clarifyPayload.put("clarify_id", clarifyId);
  423. clarifyPayload.put("question", question);
  424. clarifyPayload.set("choices", objectMapper.readTree(choicesJson));
  425. sink.emit(nodeId, "clarify_request",
  426. objectMapper.writeValueAsString(clarifyPayload), null);
  427. } catch (Exception ex) {
  428. log.warn("[Hermes] sink 推送 clarify_request 失败: {}", ex.getMessage());
  429. }
  430. }
  431. log.info("[Hermes] 请求用户确认 clarify_id={}, question={}", clarifyId, question);
  432. }
  433. case "done" -> finalResponse.append(data.path("content").asText(""));
  434. case "error" -> {
  435. String msg = data.path("message").asText("未知错误");
  436. logs.add(ExecutionLog.error(msg));
  437. throw new RuntimeException("Hermes Agent 执行错误: " + msg);
  438. }
  439. default -> {}
  440. }
  441. eventType = "";
  442. }
  443. }
  444. }
  445. String finalText = finalResponse.toString();
  446. // B: 错误响应嗅探 —— Hermes Agent / hermes-agent 底层把 LLM 失败描述当作 final_response 返回,
  447. // 这里通过可配置模式列表识别 4xx/限流/认证失败等典型错误,命中即抛 HermesErrorResponseException,
  448. // 由 HermesAgentExecutor / HermesSmartActionExecutor 转化为 NodeExecutionResult.failed
  449. if (errorPatternService != null) {
  450. HermesErrorPatternService.ErrorDetectionResult detection = errorPatternService.isErrorResponse(finalText);
  451. if (detection.isError()) {
  452. String errMsg = String.format("Hermes Agent 响应被识别为错误(命中模式「%s」): %s",
  453. detection.getMatchedPatternName(), truncate(finalText, 200));
  454. LLM_HTTP.warn("[Hermes-RESP] 错误响应嗅探命中: pattern={}, response(截断)={}",
  455. detection.getMatchedPatternName(), truncate(finalText, 500));
  456. throw new HermesErrorResponseException(errMsg, detection.getMatchedPatternName());
  457. }
  458. }
  459. return new HermesRunResult(finalText, logs);
  460. }
  461. private String truncate(String text, int maxLen) {
  462. if (text == null) return null;
  463. return text.length() <= maxLen ? text : text.substring(0, maxLen) + "...";
  464. }
  465. /**
  466. * 高效执行策略前置 prompt。
  467. *
  468. * <p>用于解决 Hermes Agent 执行慢的几个根因(基于日志分析):</p>
  469. * <ul>
  470. * <li>② LLM 猜错技能名导致无效工具调用(readme-gen / git-commit-log-gen 等不存在)</li>
  471. * <li>③ 中间轮次过度消化工具结果,每轮都生成几百 token 的「思考总结」</li>
  472. * <li>④ read_file 整个大文件导致 in= token 雪球式膨胀(23k→50k)</li>
  473. * <li>⑤ 探索完成后仍在循环验证,多出 2-3 轮无意义 LLM 调用</li>
  474. * </ul>
  475. *
  476. * <p>这是「软优化」——通过 prompt 引导 LLM 自主控制上下文,而非硬性截断。
  477. * 对比修改 hermes-agent 内部的 trajectory_compressor,这种方式风险最低、维护成本最小。</p>
  478. */
  479. private static final String EFFICIENCY_PROMPT_TEMPLATE =
  480. "=== 执行策略提示(必须严格遵守)===\n\n" +
  481. "## 可用技能(skill_view 调用时请使用精确名,禁止猜测)\n%s\n\n" +
  482. "## 技能根目录(构造脚本路径时必须使用,禁止凭记忆猜测)\n" +
  483. "所有技能文件位于:%s\n" +
  484. "技能的脚本/资源绝对路径 = 上述根目录 + /技能名/技能内相对路径," +
  485. "例如技能 foo 的 scripts/run.py 的绝对路径为「技能根目录/foo/scripts/run.py」。\n" +
  486. "SKILL.md 中的相对路径均相对于该技能自身目录;terminal 的工作目录不是技能目录," +
  487. "执行脚本前必须先按此规则拼接出完整绝对路径。\n\n" +
  488. "## 高效执行原则\n" +
  489. "1. 一次性产出:明确目标后,应一次性生成所有需要的文件/输出," +
  490. "不要每完成一个小步骤就停下来生成中间总结,避免多消耗 1 轮 LLM 调用。\n" +
  491. "2. 摘要工具优先:当文件较大(>200 行)时,优先使用 head_file / search_files / grep " +
  492. "等摘要工具定位关键内容,避免直接 read_file 整个大文件——上下文膨胀会让后续每一轮都更慢。\n" +
  493. "3. 控制探索深度:达到目标即停止,不要重复读取已知信息、反复验证;" +
  494. "任务完成后用一句话告知「已完成」,不要生成冗长总结报告。\n" +
  495. "4. 工具结果复用:上一轮已经读到的内容,本轮不要重复读取;" +
  496. "如需引用,直接基于已读内容作答。\n\n" +
  497. "## 注意\n" +
  498. "上述原则是「在合适场景下」的优化指引。当任务确实需要逐项探索(如多文件批量分析)时," +
  499. "可按需展开;但不要为了「显得认真」而增加无意义的中间步骤。\n";
  500. /**
  501. * 在原始 system_prompt 前追加「执行策略提示」。
  502. *
  503. * @param hermesHome 本次运行的 HERMES_HOME(其下应有 skills 子目录),用于扫描可用技能列表
  504. * @param basePrompt 调用方提供的原始 system_prompt(如 SKILL.md 内容),可为 null
  505. * @return 增强后的 system_prompt;若 basePrompt 为空且无可用技能列表,返回 null(不发 system_prompt)
  506. */
  507. private String buildEnhancedSystemPrompt(String hermesHome, String basePrompt) {
  508. String skillsList = scanAvailableSkills(hermesHome);
  509. String skillsRoot = Paths.get(hermesHome, "skills").toString();
  510. String efficiencySection = String.format(EFFICIENCY_PROMPT_TEMPLATE, skillsList, skillsRoot);
  511. if (basePrompt == null || basePrompt.isBlank()) {
  512. return efficiencySection;
  513. }
  514. return efficiencySection + "\n---\n\n" + basePrompt;
  515. }
  516. /**
  517. * 扫描 HERMES_HOME/skills 目录,返回逗号分隔的技能 folderName 列表。
  518. * 扫描失败时返回占位文案(不影响主流程)。
  519. *
  520. * <p>每次扫描开销很小(单次 Files.list,几十毫秒级),相对于 Hermes 整体上百秒的耗时可以忽略,
  521. * 因此不做缓存——这也避免了「运行时新增技能但缓存未更新」的问题。</p>
  522. */
  523. private String scanAvailableSkills(String hermesHome) {
  524. if (hermesHome == null || hermesHome.isBlank()) {
  525. return "(未提供 hermes_home,无法列出)";
  526. }
  527. Path skillsDir = Paths.get(hermesHome, "skills");
  528. if (!Files.isDirectory(skillsDir)) {
  529. return "(skills 目录不存在于 " + skillsDir + ")";
  530. }
  531. List<String> names = new ArrayList<>();
  532. try (Stream<Path> entries = Files.list(skillsDir)) {
  533. entries.filter(Files::isDirectory)
  534. .map(p -> p.getFileName().toString())
  535. .filter(s -> !s.startsWith(".") && !s.equals("__pycache__"))
  536. .sorted()
  537. .forEach(names::add);
  538. } catch (IOException e) {
  539. log.warn("[HermesBridgeClient] 扫描技能目录失败: {}", e.getMessage());
  540. return "(扫描失败: " + e.getMessage() + ")";
  541. }
  542. if (names.isEmpty()) {
  543. return "(无)";
  544. }
  545. return String.join(", ", names);
  546. }
  547. /**
  548. * v1.1:向 Bridge 提交用户对 clarify_request 的回答。
  549. *
  550. * <p>由 WorkflowController 在收到前端 POST /runs/{runId}/resume 时调用。
  551. * Bridge 收到后会调用 clarify_gateway.resolve_gateway_clarify,
  552. * 唤醒在 on_clarify 回调中阻塞等待的 Hermes Agent 线程。</p>
  553. *
  554. * @param clarifyId clarify_request 事件携带的 clarify_id
  555. * @param answer 用户回答(选项值或自定义文本)
  556. * @return true 表示 Bridge 报告已成功唤醒等待方;false 表示未找到对应 clarify 或已过期
  557. */
  558. public boolean submitClarifyAnswer(String clarifyId, String answer) {
  559. String url = "http://127.0.0.1:" + properties.getBridge().getPort()
  560. + "/clarify/" + clarifyId + "/answer";
  561. HttpURLConnection conn = null;
  562. try {
  563. // H-3:单线程构建请求体,用 LinkedHashMap 即可,无需 ConcurrentHashMap
  564. Map<String, Object> body = new LinkedHashMap<>();
  565. body.put("answer", answer == null ? "" : answer);
  566. byte[] bodyBytes = objectMapper.writeValueAsBytes(body);
  567. // === 请求 DEBUG 日志(仅写入 LLM 日志文件,不输出到控制台) ===
  568. LLM_HTTP.debug("[Hermes-Clarify-REQ] ========== 请求开始 ==========");
  569. LLM_HTTP.debug("[Hermes-Clarify-REQ] POST {}", url);
  570. LLM_HTTP.debug("[Hermes-Clarify-REQ] Body ({} bytes): {}", bodyBytes.length,
  571. new String(bodyBytes, StandardCharsets.UTF_8));
  572. conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
  573. conn.setRequestMethod("POST");
  574. conn.setDoOutput(true);
  575. conn.setRequestProperty("Content-Type", "application/json");
  576. String authToken = properties.getBridge().getAuthToken();
  577. if (authToken != null && !authToken.isBlank()) {
  578. conn.setRequestProperty("X-Bridge-Token", authToken);
  579. }
  580. conn.setConnectTimeout(3000);
  581. conn.setReadTimeout(5000);
  582. long startTime = System.currentTimeMillis();
  583. conn.getOutputStream().write(bodyBytes);
  584. conn.getOutputStream().flush();
  585. int code = conn.getResponseCode();
  586. long elapsed = System.currentTimeMillis() - startTime;
  587. if (code != 200) {
  588. // H-2:必须消费 errorStream 否则 keep-alive 连接残留 stale socket
  589. consumeQuietly(conn.getErrorStream());
  590. LLM_HTTP.debug("[Hermes-Clarify-RESP] HTTP {} ({}ms),非 200,无 body", code, elapsed);
  591. LLM_HTTP.debug("[Hermes-Clarify-RESP] ========== 请求结束 ==========");
  592. log.warn("[HermesBridgeClient] /clarify/{}/answer 返回非 200: {}", clarifyId, code);
  593. return false;
  594. }
  595. // 解析响应中的 resolved 字段
  596. try (BufferedReader reader = new BufferedReader(
  597. new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) {
  598. StringBuilder sb = new StringBuilder();
  599. String line;
  600. while ((line = reader.readLine()) != null) sb.append(line);
  601. String respBody = sb.toString();
  602. JsonNode resp = objectMapper.readTree(respBody);
  603. boolean resolved = resp.path("resolved").asBoolean(false);
  604. // === 响应 DEBUG 日志 ===
  605. LLM_HTTP.debug("[Hermes-Clarify-RESP] HTTP 200 ({}ms), body 长度={}, resolved={}",
  606. elapsed, respBody.length(), resolved);
  607. LLM_HTTP.debug("[Hermes-Clarify-RESP] Body: {}", respBody);
  608. LLM_HTTP.debug("[Hermes-Clarify-RESP] ========== 请求结束 ==========");
  609. log.info("[HermesBridgeClient] clarify 回答已提交 clarify_id={}, resolved={}", clarifyId, resolved);
  610. return resolved;
  611. }
  612. } catch (Exception e) {
  613. LLM_HTTP.debug("[Hermes-Clarify-RESP] 异常: {}", e.getMessage());
  614. LLM_HTTP.debug("[Hermes-Clarify-RESP] ========== 请求结束 ==========");
  615. log.warn("[HermesBridgeClient] 提交 clarify 回答失败 clarify_id={}: {}", clarifyId, e.getMessage());
  616. return false;
  617. } finally {
  618. // H-2:统一在 finally 中 disconnect,无论成功/失败都释放连接
  619. if (conn != null) {
  620. try { conn.disconnect(); } catch (Exception ignore) { /* ignore */ }
  621. }
  622. }
  623. }
  624. /** H-2:静默消费输入流至 EOF,避免 keep-alive 连接残留 stale socket。 */
  625. private static void consumeQuietly(InputStream is) {
  626. if (is == null) return;
  627. try {
  628. byte[] buf = new byte[1024];
  629. // 最多读 64KB,避免极端情况下无限读取
  630. int total = 0;
  631. int n;
  632. while (total < 65536 && (n = is.read(buf)) != -1) {
  633. total += n;
  634. }
  635. } catch (IOException ignore) {
  636. // 静默吞掉,调用方已知道 code != 200
  637. }
  638. }
  639. /**
  640. * 通知 Bridge 清空技能索引缓存与 Agent 池,使新同步的技能立即生效。
  641. * 由 HermesSkillSyncService 在技能写时同步后调用;失败仅记录日志,不影响主流程。
  642. */
  643. public void invalidateSkillsCache() {
  644. String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/skills/invalidate";
  645. try {
  646. HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection();
  647. conn.setRequestMethod("POST");
  648. String authToken = properties.getBridge().getAuthToken();
  649. if (authToken != null && !authToken.isBlank()) {
  650. conn.setRequestProperty("X-Bridge-Token", authToken);
  651. }
  652. conn.setConnectTimeout(3000);
  653. conn.setReadTimeout(5000);
  654. int code = conn.getResponseCode();
  655. if (code != 200) {
  656. log.warn("[HermesBridgeClient] /skills/invalidate 返回非 200: {}", code);
  657. } else {
  658. log.info("[HermesBridgeClient] Bridge 技能缓存已失效");
  659. }
  660. conn.disconnect();
  661. } catch (Exception e) {
  662. // Bridge 未启动或不可达时静默降级:下次 Bridge 重启后进程级缓存自然重建
  663. log.warn("[HermesBridgeClient] 调用 /skills/invalidate 失败(Bridge 可能未就绪): {}", e.getMessage());
  664. }
  665. }
  666. }