package com.agent.management.engine.hermes; import com.agent.management.config.HermesProperties; import com.agent.management.engine.ExecutionLog; import com.agent.management.engine.HermesRunResult; import com.agent.management.engine.NodeStreamSink; import com.agent.management.service.HermesErrorPatternService; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.extern.slf4j.Slf4j; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.stereotype.Component; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.net.HttpURLConnection; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Stream; /** * Hermes Bridge HTTP 客户端。 * 调用 Python Bridge 的 /run 端点,解析 SSE 流式响应。 * 仅在 hermes.enabled=true 时激活。 */ @Slf4j @Component @ConditionalOnProperty(name = "hermes.enabled", havingValue = "true") public class HermesBridgeClient { /** * LLM/智能体调用专用 logger: * 由 logback-spring.xml 中 LLM_HTTP logger 配置为 DEBUG 级别,仅写入独立日志文件,不输出到控制台。 */ private static final Logger LLM_HTTP = LoggerFactory.getLogger("LLM_HTTP"); private final HermesProperties properties; private final ObjectMapper objectMapper = new ObjectMapper(); /** * 错误响应嗅探服务(方案 B)。 * *

使用 {@link Autowired#required(boolean)} 标记为非必需: * 测试场景或 Hermes 关闭场景下允许为 null,{@code #parseSseResponse} 会跳过嗅探。

*/ private final HermesErrorPatternService errorPatternService; @Autowired public HermesBridgeClient(HermesProperties properties, @Autowired(required = false) HermesErrorPatternService errorPatternService) { this.properties = properties; this.errorPatternService = errorPatternService; } /** * 兼容旧调用方与测试:保留无嗅探服务的构造器。 */ public HermesBridgeClient(HermesProperties properties) { this(properties, null); } /** * 调用 Hermes Agent 执行任务(简单版本)。 * * @deprecated M-5:推荐使用 {@link HermesRunRequest} 参数对象 + {@link #run(HermesRunRequest)}, * 避免多参数重载导致的参数顺序混淆。本重载保留仅为向后兼容。 */ @Deprecated public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations) throws Exception { return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations)); } /** * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。 */ @Deprecated public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations, String hermesHome, String workingDir) throws Exception { return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations) .hermesHome(hermesHome).workingDir(workingDir)); } /** * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。 */ @Deprecated public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations, String hermesHome, String workingDir, String nodeId, NodeStreamSink sink) throws Exception { return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations) .hermesHome(hermesHome).workingDir(workingDir).nodeId(nodeId).sink(sink)); } /** * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。 */ @Deprecated public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations, String hermesHome, String workingDir, String nodeId, NodeStreamSink sink, String sessionId) throws Exception { return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations) .hermesHome(hermesHome).workingDir(workingDir) .nodeId(nodeId).sink(sink).sessionId(sessionId)); } /** * @deprecated M-5:同上,推荐使用 {@link HermesRunRequest}。 */ @Deprecated public HermesRunResult run(String systemPrompt, String userMessage, int maxIterations, String hermesHome, String workingDir, String nodeId, NodeStreamSink sink, String sessionId, Map modelConfig) throws Exception { return run(HermesRunRequest.of(systemPrompt, userMessage, maxIterations) .hermesHome(hermesHome).workingDir(workingDir) .nodeId(nodeId).sink(sink).sessionId(sessionId).modelConfig(modelConfig)); } /** * 主入口:使用参数对象 {@link HermesRunRequest} 调用 Hermes Agent。 * *

M-5:合并原 5 个重载为单一入口,避免参数顺序混淆。 * 旧重载保留为兼容入口,内部全部委托给本方法。

*/ public HermesRunResult run(HermesRunRequest req) throws Exception { String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/run"; // 未显式指定时回落到固定 HERMES_HOME(hermes.bridge.home), // 保证 Bridge 侧 agent 池缓存键与技能扫描目录稳定一致 String effectiveHermesHome = (req.hermesHome != null && !req.hermesHome.isBlank()) ? req.hermesHome : properties.getBridge().getResolvedHome().toString(); // H-3:请求 body 是单线程构建 + 序列化,无需并发容器,改用 LinkedHashMap Map body = new LinkedHashMap<>(); body.put("user_message", req.userMessage); body.put("max_iterations", req.maxIterations); // 注入「可用技能列表 + 高效执行策略」前置 prompt,减少 LLM 猜名失败与上下文膨胀 String enhancedSystemPrompt = buildEnhancedSystemPrompt(effectiveHermesHome, req.systemPrompt); if (enhancedSystemPrompt != null && !enhancedSystemPrompt.isBlank()) { body.put("system_prompt", enhancedSystemPrompt); } body.put("hermes_home", effectiveHermesHome); if (req.workingDir != null && !req.workingDir.isBlank()) { body.put("working_dir", req.workingDir); } if (req.sessionId != null && !req.sessionId.isBlank()) { body.put("session_id", req.sessionId); } if (req.modelConfig != null) { if (req.modelConfig.get("modelId") != null && !req.modelConfig.get("modelId").isBlank()) { body.put("model_id", req.modelConfig.get("modelId")); } if (req.modelConfig.get("baseUrl") != null && !req.modelConfig.get("baseUrl").isBlank()) { body.put("base_url", req.modelConfig.get("baseUrl")); } if (req.modelConfig.get("apiKey") != null && !req.modelConfig.get("apiKey").isBlank()) { body.put("api_key", req.modelConfig.get("apiKey")); } if (req.modelConfig.get("modelName") != null && !req.modelConfig.get("modelName").isBlank()) { body.put("model_name", req.modelConfig.get("modelName")); } } byte[] bodyBytes = objectMapper.writeValueAsBytes(body); HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "application/json"); conn.setRequestProperty("Accept", "text/event-stream"); // M5: 注入 X-Bridge-Token 共享密钥,由 Bridge 端校验 String authToken = properties.getBridge().getAuthToken(); if (authToken != null && !authToken.isBlank()) { conn.setRequestProperty("X-Bridge-Token", authToken); } conn.setConnectTimeout(10000); // v1.1:readTimeout 改为 24 小时(兜底),实际 Askfor 长等待由 SSE 心跳 + 前端重连机制保证。 // 原 5 分钟超时无法支撑用户离开几小时后再回来回答的场景。 conn.setReadTimeout(86_400_000); // === 请求 DEBUG 日志(仅写入 LLM 日志文件,不输出到控制台) === LLM_HTTP.debug("[Hermes-REQ] ========== 请求开始 =========="); LLM_HTTP.debug("[Hermes-REQ] POST {}", url); LLM_HTTP.debug("[Hermes-REQ] nodeId={}, sessionId={}, maxIterations={}", req.nodeId, req.sessionId, req.maxIterations); LLM_HTTP.debug("[Hermes-REQ] Body ({} bytes): {}", bodyBytes.length, new String(bodyBytes, StandardCharsets.UTF_8)); long startTime = System.currentTimeMillis(); conn.getOutputStream().write(bodyBytes); conn.getOutputStream().flush(); int responseCode = conn.getResponseCode(); // connectMs = 建立连接 + 写出 body + 收到 HTTP 响应头的时间 // (注意:SSE 的响应头几乎立刻到达,body 还在流式传输,所以这里耗时很短) long connectMs = System.currentTimeMillis() - startTime; if (responseCode != 200) { String error = readErrorStream(conn); long totalMs = connectMs; LLM_HTTP.debug("[Hermes-RESP] HTTP {} (total={}ms), error: {}", responseCode, totalMs, error); throw new RuntimeException("Hermes Bridge 返回 HTTP " + responseCode + ": " + error); } long streamStart = System.currentTimeMillis(); HermesRunResult result = parseSseResponse(conn, req.nodeId, req.sink); // streamMs = 读取 SSE 流式 body 的耗时(这才是真正的 Agent 执行时间,包含所有 LLM 调用与工具循环) long streamMs = System.currentTimeMillis() - streamStart; long totalMs = System.currentTimeMillis() - startTime; // === 响应 DEBUG 日志(分别记录连接耗时与流式读取耗时,便于定位卡点) === LLM_HTTP.debug("[Hermes-RESP] HTTP 200, connect={}ms, stream={}ms, total={}ms, finalText 长度={}, logs 数={}", connectMs, streamMs, totalMs, result.getFinalText() == null ? 0 : result.getFinalText().length(), result.getLogs() == null ? 0 : result.getLogs().size()); LLM_HTTP.debug("[Hermes-RESP] finalText: {}", result.getFinalText()); LLM_HTTP.debug("[Hermes-RESP] ========== 请求结束 =========="); return result; } /** * H-2:消费 errorStream,避免 keep-alive 连接被 stale。 * 任何异常都吞掉(调用方已知 code != 200,只关心 error 文本作为日志)。 */ private static String readErrorStream(HttpURLConnection conn) { InputStream es = null; try { es = conn.getErrorStream(); if (es == null) return ""; try (BufferedReader reader = new BufferedReader(new InputStreamReader(es, StandardCharsets.UTF_8))) { StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) sb.append(line); return sb.toString(); } } catch (IOException e) { return ""; } finally { // 消费完后立即断开,释放底层 socket(避免 keep-alive 残留) conn.disconnect(); } } /** * M-5:Hermes Agent 运行请求参数对象(不可变 + fluent setter)。 * *

用法:

*
{@code
     * HermesRunResult r = client.run(HermesRunRequest.of(prompt, msg, 100)
     *         .hermesHome("/data/.hermes")
     *         .workingDir(workDir)
     *         .nodeId(nodeId)
     *         .sink(sink)
     *         .sessionId(sessionKey)
     *         .modelConfig(modelConfig));
     * }
*/ public static final class HermesRunRequest { private final String systemPrompt; private final String userMessage; private final int maxIterations; private String hermesHome; private String workingDir; private String nodeId; private NodeStreamSink sink; private String sessionId; private Map modelConfig; private HermesRunRequest(String systemPrompt, String userMessage, int maxIterations) { this.systemPrompt = systemPrompt; this.userMessage = userMessage; this.maxIterations = maxIterations; } public static HermesRunRequest of(String systemPrompt, String userMessage, int maxIterations) { return new HermesRunRequest(systemPrompt, userMessage, maxIterations); } public HermesRunRequest hermesHome(String v) { this.hermesHome = v; return this; } public HermesRunRequest workingDir(String v) { this.workingDir = v; return this; } public HermesRunRequest nodeId(String v) { this.nodeId = v; return this; } public HermesRunRequest sink(NodeStreamSink v) { this.sink = v; return this; } public HermesRunRequest sessionId(String v) { this.sessionId = v; return this; } public HermesRunRequest modelConfig(Map v) { this.modelConfig = v; return this; } } /** * 解析 SSE 响应流,收集日志并提取最终回复。 * 若 sink 非 null:thinking 通过 sink 实时推送(不进 logs),工具事件同时进 logs 和 sink。 * 若 sink 为 null:thinking 进 logs(兼容旧行为)。 */ private HermesRunResult parseSseResponse(HttpURLConnection conn, String nodeId, NodeStreamSink sink) throws Exception { StringBuilder finalResponse = new StringBuilder(); List logs = new ArrayList<>(); try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { String eventType = ""; String line; while ((line = reader.readLine()) != null) { if (line.startsWith("event: ")) { eventType = line.substring(7).trim(); } else if (line.startsWith("data: ")) { String dataJson = line.substring(6); JsonNode data = objectMapper.readTree(dataJson); switch (eventType) { case "tool_start" -> { String tool = data.path("tool").asText("?"); String args = data.has("args") ? objectMapper.writeValueAsString(data.get("args")) : null; String argsTrunc = truncate(args, 500); logs.add(ExecutionLog.toolCall(tool, argsTrunc)); if (sink != null) { try { sink.emit(nodeId, "tool_call", argsTrunc, tool); } catch (Exception ex) { log.warn("[Hermes] sink 推送 tool_call 失败: {}", ex.getMessage()); } } log.debug("[Hermes] 工具调用: {}", tool); } case "tool_end" -> { String tool = data.path("tool").asText("?"); String summary = data.path("result_summary").asText(""); String summaryTrunc = truncate(summary, 500); logs.add(ExecutionLog.toolResult(tool, summaryTrunc)); if (sink != null) { try { sink.emit(nodeId, "tool_result", summaryTrunc, tool); } catch (Exception ex) { log.warn("[Hermes] sink 推送 tool_result 失败: {}", ex.getMessage()); } } log.debug("[Hermes] 工具完成: {}", tool); } case "text" -> { String delta = data.path("content").asText(""); if (!delta.isEmpty()) { // thinking 既要实时推送(流式 UI),也要进入 logs(运行记录可回放) logs.add(ExecutionLog.thinking(delta)); if (sink != null) { try { sink.emit(nodeId, "thinking", delta, null); } catch (Exception ex) { log.warn("[Hermes] sink 推送 thinking 失败: {}", ex.getMessage()); } } } } // v1.1:每轮 LLM 调用前的进度(iteration + prev_tools) case "step" -> { int iteration = data.path("iteration").asInt(0); String prevToolsJson = data.has("prev_tools") ? objectMapper.writeValueAsString(data.get("prev_tools")) : "[]"; logs.add(ExecutionLog.step(iteration, prevToolsJson)); if (sink != null) { try { sink.emit(nodeId, "step", "{\"iteration\":" + iteration + ",\"prev_tools\":" + prevToolsJson + "}", null); } catch (Exception ex) { log.warn("[Hermes] sink 推送 step 失败: {}", ex.getMessage()); } } } // v1.1:实时状态文案(覆盖式,如"压缩上下文中") case "status" -> { String message = data.path("message").asText(""); if (!message.isEmpty()) { logs.add(ExecutionLog.status(message)); if (sink != null) { try { sink.emit(nodeId, "status", message, null); } catch (Exception ex) { log.warn("[Hermes] sink 推送 status 失败: {}", ex.getMessage()); } } } } // v1.1:thinking_callback 透传(Hermes 思考增量,与 text 区分用于实时思考区展示) case "thinking_status" -> { String content = data.path("content").asText(""); if (!content.isEmpty()) { if (sink != null) { try { // 前端按 kind=status 聚合到"实时思考"区 sink.emit(nodeId, "status", content, null); } catch (Exception ex) { log.warn("[Hermes] sink 推送 thinking_status 失败: {}", ex.getMessage()); } } } } // v1.1:工具细粒度进度(event_type + name + preview/args) case "tool_progress" -> { String tpEventType = data.path("event_type").asText(""); String toolName = data.path("name").asText(null); String preview = data.has("preview") ? objectMapper.writeValueAsString(data.get("preview")) : null; String detailJson = (preview != null) ? preview : (data.has("args") ? objectMapper.writeValueAsString(data.get("args")) : "{}"); logs.add(ExecutionLog.toolProgress(tpEventType, detailJson)); if (sink != null) { try { sink.emit(nodeId, "tool_progress", "{\"event_type\":\"" + tpEventType + "\",\"name\":\"" + toolName + "\",\"preview\":" + (preview != null ? preview : "null") + "}", toolName); } catch (Exception ex) { log.warn("[Hermes] sink 推送 tool_progress 失败: {}", ex.getMessage()); } } } // v1.1:todo 工具完整输出(前端解析后渲染 TODO List) // Python 端 hermes_bridge.py 已用 json.dumps 把 result 序列化为字符串, // 这里用 asText 取字符串原值,避免 writeAsString 再次转义导致前端二次 parse 失败 case "todo_update" -> { String fullJson = data.path("result").asText("{}"); logs.add(ExecutionLog.todoUpdate(fullJson)); if (sink != null) { try { sink.emit(nodeId, "todo_update", fullJson, "todo"); } catch (Exception ex) { log.warn("[Hermes] sink 推送 todo_update 失败: {}", ex.getMessage()); } } } // v1.1:模型请求用户确认(Askfor 区域) // 注意:clarify_request 不结束 SSE 读取循环,Bridge 在 Python 侧同步等待用户回答后 // 才会继续推送后续事件。这里只负责把请求转发给前端,不中断 readLine。 case "clarify_request" -> { String clarifyId = data.path("clarify_id").asText(""); String question = data.path("question").asText(""); String choicesJson = data.has("choices") ? objectMapper.writeValueAsString(data.get("choices")) : "[]"; logs.add(ExecutionLog.clarifyRequest(clarifyId, question, choicesJson)); if (sink != null) { try { // H-4:原手工拼接 JSON 缺乏对 \n / \\ 等特殊字符的转义, // 前端 JSON.parse 易失败。改用 ObjectMapper 构造保证 JSON 合法。 com.fasterxml.jackson.databind.node.ObjectNode clarifyPayload = objectMapper.createObjectNode(); clarifyPayload.put("clarify_id", clarifyId); clarifyPayload.put("question", question); clarifyPayload.set("choices", objectMapper.readTree(choicesJson)); sink.emit(nodeId, "clarify_request", objectMapper.writeValueAsString(clarifyPayload), null); } catch (Exception ex) { log.warn("[Hermes] sink 推送 clarify_request 失败: {}", ex.getMessage()); } } log.info("[Hermes] 请求用户确认 clarify_id={}, question={}", clarifyId, question); } case "done" -> finalResponse.append(data.path("content").asText("")); case "error" -> { String msg = data.path("message").asText("未知错误"); logs.add(ExecutionLog.error(msg)); throw new RuntimeException("Hermes Agent 执行错误: " + msg); } default -> {} } eventType = ""; } } } String finalText = finalResponse.toString(); // B: 错误响应嗅探 —— Hermes Agent / hermes-agent 底层把 LLM 失败描述当作 final_response 返回, // 这里通过可配置模式列表识别 4xx/限流/认证失败等典型错误,命中即抛 HermesErrorResponseException, // 由 HermesAgentExecutor / HermesSmartActionExecutor 转化为 NodeExecutionResult.failed if (errorPatternService != null) { HermesErrorPatternService.ErrorDetectionResult detection = errorPatternService.isErrorResponse(finalText); if (detection.isError()) { String errMsg = String.format("Hermes Agent 响应被识别为错误(命中模式「%s」): %s", detection.getMatchedPatternName(), truncate(finalText, 200)); LLM_HTTP.warn("[Hermes-RESP] 错误响应嗅探命中: pattern={}, response(截断)={}", detection.getMatchedPatternName(), truncate(finalText, 500)); throw new HermesErrorResponseException(errMsg, detection.getMatchedPatternName()); } } return new HermesRunResult(finalText, logs); } private String truncate(String text, int maxLen) { if (text == null) return null; return text.length() <= maxLen ? text : text.substring(0, maxLen) + "..."; } /** * 高效执行策略前置 prompt。 * *

用于解决 Hermes Agent 执行慢的几个根因(基于日志分析):

*
    *
  • ② LLM 猜错技能名导致无效工具调用(readme-gen / git-commit-log-gen 等不存在)
  • *
  • ③ 中间轮次过度消化工具结果,每轮都生成几百 token 的「思考总结」
  • *
  • ④ read_file 整个大文件导致 in= token 雪球式膨胀(23k→50k)
  • *
  • ⑤ 探索完成后仍在循环验证,多出 2-3 轮无意义 LLM 调用
  • *
* *

这是「软优化」——通过 prompt 引导 LLM 自主控制上下文,而非硬性截断。 * 对比修改 hermes-agent 内部的 trajectory_compressor,这种方式风险最低、维护成本最小。

*/ private static final String EFFICIENCY_PROMPT_TEMPLATE = "=== 执行策略提示(必须严格遵守)===\n\n" + "## 可用技能(skill_view 调用时请使用精确名,禁止猜测)\n%s\n\n" + "## 技能根目录(构造脚本路径时必须使用,禁止凭记忆猜测)\n" + "所有技能文件位于:%s\n" + "技能的脚本/资源绝对路径 = 上述根目录 + /技能名/技能内相对路径," + "例如技能 foo 的 scripts/run.py 的绝对路径为「技能根目录/foo/scripts/run.py」。\n" + "SKILL.md 中的相对路径均相对于该技能自身目录;terminal 的工作目录不是技能目录," + "执行脚本前必须先按此规则拼接出完整绝对路径。\n\n" + "## 高效执行原则\n" + "1. 一次性产出:明确目标后,应一次性生成所有需要的文件/输出," + "不要每完成一个小步骤就停下来生成中间总结,避免多消耗 1 轮 LLM 调用。\n" + "2. 摘要工具优先:当文件较大(>200 行)时,优先使用 head_file / search_files / grep " + "等摘要工具定位关键内容,避免直接 read_file 整个大文件——上下文膨胀会让后续每一轮都更慢。\n" + "3. 控制探索深度:达到目标即停止,不要重复读取已知信息、反复验证;" + "任务完成后用一句话告知「已完成」,不要生成冗长总结报告。\n" + "4. 工具结果复用:上一轮已经读到的内容,本轮不要重复读取;" + "如需引用,直接基于已读内容作答。\n\n" + "## 注意\n" + "上述原则是「在合适场景下」的优化指引。当任务确实需要逐项探索(如多文件批量分析)时," + "可按需展开;但不要为了「显得认真」而增加无意义的中间步骤。\n"; /** * 在原始 system_prompt 前追加「执行策略提示」。 * * @param hermesHome 本次运行的 HERMES_HOME(其下应有 skills 子目录),用于扫描可用技能列表 * @param basePrompt 调用方提供的原始 system_prompt(如 SKILL.md 内容),可为 null * @return 增强后的 system_prompt;若 basePrompt 为空且无可用技能列表,返回 null(不发 system_prompt) */ private String buildEnhancedSystemPrompt(String hermesHome, String basePrompt) { String skillsList = scanAvailableSkills(hermesHome); String skillsRoot = Paths.get(hermesHome, "skills").toString(); String efficiencySection = String.format(EFFICIENCY_PROMPT_TEMPLATE, skillsList, skillsRoot); if (basePrompt == null || basePrompt.isBlank()) { return efficiencySection; } return efficiencySection + "\n---\n\n" + basePrompt; } /** * 扫描 HERMES_HOME/skills 目录,返回逗号分隔的技能 folderName 列表。 * 扫描失败时返回占位文案(不影响主流程)。 * *

每次扫描开销很小(单次 Files.list,几十毫秒级),相对于 Hermes 整体上百秒的耗时可以忽略, * 因此不做缓存——这也避免了「运行时新增技能但缓存未更新」的问题。

*/ private String scanAvailableSkills(String hermesHome) { if (hermesHome == null || hermesHome.isBlank()) { return "(未提供 hermes_home,无法列出)"; } Path skillsDir = Paths.get(hermesHome, "skills"); if (!Files.isDirectory(skillsDir)) { return "(skills 目录不存在于 " + skillsDir + ")"; } List names = new ArrayList<>(); try (Stream entries = Files.list(skillsDir)) { entries.filter(Files::isDirectory) .map(p -> p.getFileName().toString()) .filter(s -> !s.startsWith(".") && !s.equals("__pycache__")) .sorted() .forEach(names::add); } catch (IOException e) { log.warn("[HermesBridgeClient] 扫描技能目录失败: {}", e.getMessage()); return "(扫描失败: " + e.getMessage() + ")"; } if (names.isEmpty()) { return "(无)"; } return String.join(", ", names); } /** * v1.1:向 Bridge 提交用户对 clarify_request 的回答。 * *

由 WorkflowController 在收到前端 POST /runs/{runId}/resume 时调用。 * Bridge 收到后会调用 clarify_gateway.resolve_gateway_clarify, * 唤醒在 on_clarify 回调中阻塞等待的 Hermes Agent 线程。

* * @param clarifyId clarify_request 事件携带的 clarify_id * @param answer 用户回答(选项值或自定义文本) * @return true 表示 Bridge 报告已成功唤醒等待方;false 表示未找到对应 clarify 或已过期 */ public boolean submitClarifyAnswer(String clarifyId, String answer) { String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/clarify/" + clarifyId + "/answer"; HttpURLConnection conn = null; try { // H-3:单线程构建请求体,用 LinkedHashMap 即可,无需 ConcurrentHashMap Map body = new LinkedHashMap<>(); body.put("answer", answer == null ? "" : answer); byte[] bodyBytes = objectMapper.writeValueAsBytes(body); // === 请求 DEBUG 日志(仅写入 LLM 日志文件,不输出到控制台) === LLM_HTTP.debug("[Hermes-Clarify-REQ] ========== 请求开始 =========="); LLM_HTTP.debug("[Hermes-Clarify-REQ] POST {}", url); LLM_HTTP.debug("[Hermes-Clarify-REQ] Body ({} bytes): {}", bodyBytes.length, new String(bodyBytes, StandardCharsets.UTF_8)); conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); conn.setRequestMethod("POST"); conn.setDoOutput(true); conn.setRequestProperty("Content-Type", "application/json"); String authToken = properties.getBridge().getAuthToken(); if (authToken != null && !authToken.isBlank()) { conn.setRequestProperty("X-Bridge-Token", authToken); } conn.setConnectTimeout(3000); conn.setReadTimeout(5000); long startTime = System.currentTimeMillis(); conn.getOutputStream().write(bodyBytes); conn.getOutputStream().flush(); int code = conn.getResponseCode(); long elapsed = System.currentTimeMillis() - startTime; if (code != 200) { // H-2:必须消费 errorStream 否则 keep-alive 连接残留 stale socket consumeQuietly(conn.getErrorStream()); LLM_HTTP.debug("[Hermes-Clarify-RESP] HTTP {} ({}ms),非 200,无 body", code, elapsed); LLM_HTTP.debug("[Hermes-Clarify-RESP] ========== 请求结束 =========="); log.warn("[HermesBridgeClient] /clarify/{}/answer 返回非 200: {}", clarifyId, code); return false; } // 解析响应中的 resolved 字段 try (BufferedReader reader = new BufferedReader( new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { StringBuilder sb = new StringBuilder(); String line; while ((line = reader.readLine()) != null) sb.append(line); String respBody = sb.toString(); JsonNode resp = objectMapper.readTree(respBody); boolean resolved = resp.path("resolved").asBoolean(false); // === 响应 DEBUG 日志 === LLM_HTTP.debug("[Hermes-Clarify-RESP] HTTP 200 ({}ms), body 长度={}, resolved={}", elapsed, respBody.length(), resolved); LLM_HTTP.debug("[Hermes-Clarify-RESP] Body: {}", respBody); LLM_HTTP.debug("[Hermes-Clarify-RESP] ========== 请求结束 =========="); log.info("[HermesBridgeClient] clarify 回答已提交 clarify_id={}, resolved={}", clarifyId, resolved); return resolved; } } catch (Exception e) { LLM_HTTP.debug("[Hermes-Clarify-RESP] 异常: {}", e.getMessage()); LLM_HTTP.debug("[Hermes-Clarify-RESP] ========== 请求结束 =========="); log.warn("[HermesBridgeClient] 提交 clarify 回答失败 clarify_id={}: {}", clarifyId, e.getMessage()); return false; } finally { // H-2:统一在 finally 中 disconnect,无论成功/失败都释放连接 if (conn != null) { try { conn.disconnect(); } catch (Exception ignore) { /* ignore */ } } } } /** H-2:静默消费输入流至 EOF,避免 keep-alive 连接残留 stale socket。 */ private static void consumeQuietly(InputStream is) { if (is == null) return; try { byte[] buf = new byte[1024]; // 最多读 64KB,避免极端情况下无限读取 int total = 0; int n; while (total < 65536 && (n = is.read(buf)) != -1) { total += n; } } catch (IOException ignore) { // 静默吞掉,调用方已知道 code != 200 } } /** * 通知 Bridge 清空技能索引缓存与 Agent 池,使新同步的技能立即生效。 * 由 HermesSkillSyncService 在技能写时同步后调用;失败仅记录日志,不影响主流程。 */ public void invalidateSkillsCache() { String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/skills/invalidate"; try { HttpURLConnection conn = (HttpURLConnection) URI.create(url).toURL().openConnection(); conn.setRequestMethod("POST"); String authToken = properties.getBridge().getAuthToken(); if (authToken != null && !authToken.isBlank()) { conn.setRequestProperty("X-Bridge-Token", authToken); } conn.setConnectTimeout(3000); conn.setReadTimeout(5000); int code = conn.getResponseCode(); if (code != 200) { log.warn("[HermesBridgeClient] /skills/invalidate 返回非 200: {}", code); } else { log.info("[HermesBridgeClient] Bridge 技能缓存已失效"); } conn.disconnect(); } catch (Exception e) { // Bridge 未启动或不可达时静默降级:下次 Bridge 重启后进程级缓存自然重建 log.warn("[HermesBridgeClient] 调用 /skills/invalidate 失败(Bridge 可能未就绪): {}", e.getMessage()); } } }