Просмотр исходного кода

1. 约束了大模型节点的输出格式,节点执行时按画布上定义的输出变量动态生成带示例值的 JSON 模板并要求大模型遵守,解决了同一节点多次运行时而输出 Markdown、时而输出 JSON 的格式漂移问题;
2. 修复了输出字段名不一致(如大模型返回中文键名“状态/信息/数据”)却未被判定为非法的问题,校验时严格按用户定义的字段名逐个核对,不一致即要求重新生成;
3. 允许大模型在定义字段之外自由补充必要的扩展字段(如置信度、注意事项等),统一采用 snake_case 命名并直接放在 data 中;
4. 优化了输出校验失败后的重试方式,区分为两种情况:运行失败(如模型限流、超时)才重新运行节点;运行成功但输出格式不对时,只把运行结果和 JSON 模板回给大模型重新组织格式,不再重复执行节点逻辑;
5. 修复了大模型调用失败(如 429 限流、凭证过期)却被工作流报告为“执行成功”的问题,节点结果中携带错误描述时会被识别为失败并按失败策略处理;
6. 新增了 Hermes 错误模式配置页面,可在界面上维护错误特征列表,用于识别大模型返回文本中实际代表失败的内容;
7. 为各节点的输入变量和输出变量新增了“必填”设置;
8. 修复了输出节点收不到前序节点输出变量、最终结果混入一堆工作区变量的问题,输出节点现在严格按画布声明的字段取值,未声明的字段不再出现在最终结果中;
9. 修复了上传或修改技能后 Hermes 的 skill_view 看不到新技能的问题,技能改为固定目录存放,并在技能增删改、文件编辑、版本回滚后自动同步并刷新缓存,同步时跳过历史版本目录和直接包含 SKILL.md 的目录;
10. 修复了技能生成的技能被写入运行时工作目录、导致 Hermes 扫描到重复技能而冲突的问题,生成完成后的技能会归入正式技能目录并自动同步;
11. 修复了 Hermes 执行技能脚本时拼接出错误文件路径的问题,现在会明确告知大模型技能所在的完整目录,避免凭空猜测路径;
12. 新增了多份调研与方案文档,包括 dify 参考项目分析、skills-manage 参考项目分析、本系统与 dify 的能力对比、节点上下文按需获取方案等。

weisijie 2 месяцев назад
Родитель
Сommit
e30c1eed27
50 измененных файлов с 7067 добавлено и 277 удалено
  1. 1 0
      .gitignore
  2. 32 2
      backend/hermes-bridge/hermes_bridge.py
  3. 19 0
      backend/src/main/java/com/agent/management/config/HermesProperties.java
  4. 15 3
      backend/src/main/java/com/agent/management/config/WorkflowProperties.java
  5. 74 0
      backend/src/main/java/com/agent/management/controller/HermesErrorPatternController.java
  6. 56 11
      backend/src/main/java/com/agent/management/engine/OutputValidator.java
  7. 75 17
      backend/src/main/java/com/agent/management/engine/RetryPolicy.java
  8. 73 4
      backend/src/main/java/com/agent/management/engine/SkillFileSyncer.java
  9. 378 49
      backend/src/main/java/com/agent/management/engine/StructuredOutputHelper.java
  10. 1 18
      backend/src/main/java/com/agent/management/engine/WorkflowEngine.java
  11. 26 9
      backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java
  12. 33 3
      backend/src/main/java/com/agent/management/engine/executor/AgentExecutor.java
  13. 106 15
      backend/src/main/java/com/agent/management/engine/executor/HermesAgentExecutor.java
  14. 104 13
      backend/src/main/java/com/agent/management/engine/executor/HermesSmartActionExecutor.java
  15. 40 47
      backend/src/main/java/com/agent/management/engine/executor/OutputExecutor.java
  16. 82 7
      backend/src/main/java/com/agent/management/engine/hermes/HermesBridgeClient.java
  17. 26 0
      backend/src/main/java/com/agent/management/engine/hermes/HermesErrorResponseException.java
  18. 5 1
      backend/src/main/java/com/agent/management/engine/hermes/HermesProcessManager.java
  19. 75 0
      backend/src/main/java/com/agent/management/engine/hermes/HermesSkillSyncService.java
  20. 51 0
      backend/src/main/java/com/agent/management/model/entity/HermesErrorPattern.java
  21. 46 0
      backend/src/main/java/com/agent/management/repository/HermesErrorPatternRepository.java
  22. 61 0
      backend/src/main/java/com/agent/management/service/HermesErrorPatternService.java
  23. 188 0
      backend/src/main/java/com/agent/management/service/impl/HermesErrorPatternServiceImpl.java
  24. 15 18
      backend/src/main/java/com/agent/management/service/impl/SkillGenerationServiceImpl.java
  25. 12 0
      backend/src/main/java/com/agent/management/service/impl/SkillServiceImpl.java
  26. 3 0
      backend/src/main/java/com/agent/management/service/impl/SkillVersionServiceImpl.java
  27. 10 2
      backend/src/main/resources/application.yml.example
  28. 280 0
      backend/src/test/java/com/agent/management/engine/OutputExecutorTest.java
  29. 303 0
      backend/src/test/java/com/agent/management/engine/OutputValidatorStrictTest.java
  30. 168 32
      backend/src/test/java/com/agent/management/engine/RetryPolicyTest.java
  31. 210 0
      backend/src/test/java/com/agent/management/engine/SkillFileSyncerTest.java
  32. 249 0
      backend/src/test/java/com/agent/management/engine/StructuredOutputFromTextTest.java
  33. 368 0
      backend/src/test/java/com/agent/management/engine/StructuredOutputHelperDualLoopTest.java
  34. 296 0
      backend/src/test/java/com/agent/management/service/HermesErrorPatternServiceTest.java
  35. 748 0
      docs/dify-readme.md
  36. 304 0
      docs/hermes-error-detection-plan.md
  37. 363 0
      docs/node-context-ondemand-plan.md
  38. 368 0
      docs/skills-manage-readme.md
  39. 361 0
      docs/workflow-envelope-strict-validation-plan.md
  40. 301 0
      docs/workflow-output-schema-constraint-plan.md
  41. 441 0
      docs/workflow-vs-dify-comparison.md
  42. 31 0
      frontend/src/api/hermesError.js
  43. 4 2
      frontend/src/components/layout/AppSidebar.vue
  44. 4 4
      frontend/src/composables/useNodeFields.js
  45. 6 0
      frontend/src/router/index.js
  46. 12 4
      frontend/src/utils/ioInference.js
  47. 3 3
      frontend/src/utils/workflowNode.js
  48. 399 0
      frontend/src/views/system/HermesErrorPatterns.vue
  49. 108 10
      frontend/src/views/workflow/WorkflowEditor.vue
  50. 133 3
      prompt.md

+ 1 - 0
.gitignore

@@ -62,6 +62,7 @@ temp/test/
 *.screenshot.png
 
 # ===== 第三方参考项目 =====
+dify/
 skills-manage/
 beekeeper-studio/
 SuperMew/

+ 32 - 2
backend/hermes-bridge/hermes_bridge.py

@@ -7,8 +7,9 @@ Hermes Bridge - 将 Hermes AIAgent 封装为 HTTP API,供 Spring Boot 工作
     python hermes_bridge.py --port 18731 --hermes-home /path/to/.hermes
 
 API:
-    POST /run          运行一次 Agent 对话(SSE 流式响应)
-    GET  /health       健康检查
+    POST /run                运行一次 Agent 对话(SSE 流式响应)
+    POST /skills/invalidate  清空技能索引缓存与 Agent 池(技能写时同步后调用)
+    GET  /health             健康检查
 """
 
 import argparse
@@ -415,6 +416,35 @@ async def run_agent(req: RunRequest):
     )
 
 
+@app.post("/skills/invalidate")
+async def skills_invalidate():
+    """
+    清空技能相关缓存,使 Java 侧新同步到 HERMES_HOME/skills 的技能立即生效。
+
+    两层缓存:
+      1. agent.prompt_builder._SKILLS_PROMPT_CACHE —— 进程内 LRU,key 不含目录 mtime,
+         同一 skills_dir 下新增技能后不会自动失效,必须显式清空;
+      2. _agents —— Agent 池,系统提示词(含技能索引)在 Agent 创建时构建一次,
+         清空后下次请求重建 Agent 以拉取最新技能索引。
+
+    磁盘快照 .skills_prompt_snapshot.json 自带 mtime/size 校验,文件变更后自动失效,无需处理。
+    """
+    cleared_prompt_cache = False
+    try:
+        from agent.prompt_builder import clear_skills_system_prompt_cache
+        clear_skills_system_prompt_cache()
+        cleared_prompt_cache = True
+    except Exception as e:
+        logger.warning("清空 skills prompt 缓存失败(可能尚未加载 prompt_builder): %s", e)
+
+    with _agent_lock:
+        evicted = len(_agents)
+        _agents.clear()
+
+    logger.info("技能缓存已失效: prompt_cache=%s, evicted_agents=%d", cleared_prompt_cache, evicted)
+    return {"ok": True, "promptCacheCleared": cleared_prompt_cache, "evictedAgents": evicted}
+
+
 @app.get("/health")
 async def health():
     """健康检查"""

+ 19 - 0
backend/src/main/java/com/agent/management/config/HermesProperties.java

@@ -56,5 +56,24 @@ public class HermesProperties {
          * 推荐通过 HERMES_BRIDGE_AUTH_TOKEN 环境变量设置 32+ 字符随机字符串。
          */
         private String authToken = "";
+
+        /**
+         * 固定 HERMES_HOME 目录(其下 skills 子目录由 Java 侧 SkillFileSyncer 镜像同步)。
+         * 相对路径基于后端工作目录(user.dir)解析。
+         * Bridge 子进程启动时通过 --hermes-home 传入,进程生命周期内不变,
+         * 避免 hermes-agent 的 SKILLS_DIR 模块级常量冻结在 per-run 目录上。
+         */
+        private String home = "./data/hermes-home";
+
+        /**
+         * 解析 {@link #home} 为绝对路径(相对路径基于 user.dir)。
+         */
+        public java.nio.file.Path getResolvedHome() {
+            java.nio.file.Path p = java.nio.file.Paths.get(home);
+            if (!p.isAbsolute()) {
+                p = java.nio.file.Paths.get(System.getProperty("user.dir")).resolve(p);
+            }
+            return p.toAbsolutePath().normalize();
+        }
     }
 }

+ 15 - 3
backend/src/main/java/com/agent/management/config/WorkflowProperties.java

@@ -13,10 +13,12 @@ import org.springframework.stereotype.Component;
  *   run-dir: ./data/workflow-runs
  *   llm:
  *     retry:
- *       max-retries: 2
+ *       runtime-max-retries: 2
+ *       format-max-retries: 2
  *       retry-on-parse-error: true
  *       retry-on-missing-required: true
  *       retry-on-type-mismatch: true
+ *       retry-on-key-name-mismatch: true
  * </pre>
  */
 @Data
@@ -45,9 +47,14 @@ public class WorkflowProperties {
     @Data
     public static class Retry {
         /**
-         * 最大尝试次数(含首次)。默认 2,即首次失败后最多重试 1 次。
+         * 运行异常重试次数(含首次)。每次都会重新完整推理。
          */
-        private int maxRetries = 2;
+        private int runtimeMaxRetries = 2;
+
+        /**
+         * 格式错误重试次数(含首次)。每次仅把上次响应 + JSON 模板回喂 LLM 重组 JSON。
+         */
+        private int formatMaxRetries = 2;
 
         /**
          * JSON 解析失败时是否重试。
@@ -63,5 +70,10 @@ public class WorkflowProperties {
          * 类型不匹配时是否重试。
          */
         private boolean retryOnTypeMismatch = true;
+
+        /**
+         * strict 模式下检测到键名缺失(如返回中文键名)时是否重试。
+         */
+        private boolean retryOnKeyNameMismatch = true;
     }
 }

+ 74 - 0
backend/src/main/java/com/agent/management/controller/HermesErrorPatternController.java

@@ -0,0 +1,74 @@
+package com.agent.management.controller;
+
+import com.agent.management.common.Result;
+import com.agent.management.model.entity.HermesErrorPattern;
+import com.agent.management.service.HermesErrorPatternService;
+import lombok.Data;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * Hermes 错误嗅探模式管理接口。
+ *
+ * <p>路径前缀 {@code /api/hermes-error-patterns},与 {@code AiModelController} 风格一致
+ * (动词式 path:{@code /{id}/edit}、{@code /{id}/delete}、{@code /{id}/toggle})。</p>
+ */
+@RestController
+@RequestMapping("/api/hermes-error-patterns")
+@RequiredArgsConstructor
+public class HermesErrorPatternController {
+
+    private final HermesErrorPatternService service;
+
+    @GetMapping
+    public Result<List<HermesErrorPattern>> list() {
+        return Result.success(service.list());
+    }
+
+    @PostMapping
+    public Result<HermesErrorPattern> create(@RequestBody PatternReq req) {
+        HermesErrorPattern entity = new HermesErrorPattern();
+        entity.setName(req.getName());
+        entity.setPattern(req.getPattern());
+        entity.setDescription(req.getDescription());
+        entity.setEnabled(req.getEnabled());
+        return Result.success(service.create(entity));
+    }
+
+    @PostMapping("/{id}/edit")
+    public Result<HermesErrorPattern> update(@PathVariable Long id, @RequestBody PatternReq req) {
+        HermesErrorPattern entity = new HermesErrorPattern();
+        entity.setName(req.getName());
+        entity.setPattern(req.getPattern());
+        entity.setDescription(req.getDescription());
+        entity.setEnabled(req.getEnabled());
+        return Result.success(service.update(id, entity));
+    }
+
+    @PostMapping("/{id}/delete")
+    public Result<Void> delete(@PathVariable Long id) {
+        service.delete(id);
+        return Result.success(null);
+    }
+
+    @PostMapping("/{id}/toggle")
+    public Result<HermesErrorPattern> toggle(@PathVariable Long id) {
+        return Result.success(service.toggle(id));
+    }
+
+    @PostMapping("/reset")
+    public Result<Void> reset() {
+        service.resetToDefaults();
+        return Result.success(null);
+    }
+
+    @Data
+    public static class PatternReq {
+        private String name;
+        private String pattern;
+        private String description;
+        private Boolean enabled;
+    }
+}

+ 56 - 11
backend/src/main/java/com/agent/management/engine/OutputValidator.java

@@ -13,7 +13,8 @@ import java.util.Map;
  *
  * <p>对 LLM 解析后的 {@code Map<varName, value>} 与节点 outputs 声明进行结构校验:</p>
  * <ul>
- *   <li><b>必填字段缺失</b>:{@code outputs[i].required == true} 但 {@code parsed} 中无值或 null</li>
+ *   <li><b>键名缺失</b>(仅 strict 模式):声明字段在 parsed 中不存在(精确键匹配)</li>
+ *   <li><b>必填字段缺失</b>:{@code outputs[i].required == true} 但值为 null 或空字符串</li>
  *   <li><b>类型不匹配</b>:声明为 {@code number/array/object/boolean} 但值类型不符且无法转换</li>
  * </ul>
  *
@@ -25,6 +26,14 @@ import java.util.Map;
  * </ul>
  *
  * <p>本类返回 {@link ValidationResult},由调用方(StructuredOutputHelper)决定是否触发重试。</p>
+ *
+ * <p><b>strict 模式</b>({@link #validate(Map, JsonNode, boolean)}):</p>
+ * <ul>
+ *   <li>只校验 {@code required=true} 字段:必须精确存在(区分大小写、区分分隔符)且值非空</li>
+ *   <li>非 required 字段:缺失不报错;存在则做类型校验</li>
+ *   <li>多余字段(含 LLM 自由扩展的 snake_case 辅助字段)完全不报错</li>
+ *   <li>{@code strict} 参数仅用于语义标记,不再把所有声明字段视为必填</li>
+ * </ul>
  */
 public final class OutputValidator {
 
@@ -35,37 +44,66 @@ public final class OutputValidator {
         public final String errorMessage;
         public final List<String> missingRequired;
         public final List<String> typeMismatched;
+        /** strict 模式下,声明字段在 parsed 中不存在(精确键匹配失败)的字段名列表。 */
+        public final List<String> keyNameMismatched;
         /** 类型自动转换后的新 Map(即使 ok 也可能含转换后的值)。 */
         public final Map<String, Object> converted;
 
         private ValidationResult(boolean ok, String errorMessage,
                                   List<String> missingRequired, List<String> typeMismatched,
+                                  List<String> keyNameMismatched,
                                   Map<String, Object> converted) {
             this.ok = ok;
             this.errorMessage = errorMessage;
             this.missingRequired = missingRequired == null ? Collections.emptyList() : missingRequired;
             this.typeMismatched = typeMismatched == null ? Collections.emptyList() : typeMismatched;
+            this.keyNameMismatched = keyNameMismatched == null ? Collections.emptyList() : keyNameMismatched;
             this.converted = converted == null ? Collections.emptyMap() : converted;
         }
 
         public static ValidationResult ok(Map<String, Object> converted) {
-            return new ValidationResult(true, null, Collections.emptyList(), Collections.emptyList(), converted);
+            return new ValidationResult(true, null,
+                    Collections.emptyList(), Collections.emptyList(), Collections.emptyList(), converted);
         }
 
-        public static ValidationResult fail(List<String> missing, List<String> mismatched, Map<String, Object> converted) {
+        public static ValidationResult fail(List<String> missing, List<String> mismatched,
+                                             List<String> keyNameMismatched, Map<String, Object> converted) {
             List<String> parts = new ArrayList<>();
-            if (!missing.isEmpty()) parts.add("必填字段缺失: " + String.join(", ", missing));
-            if (!mismatched.isEmpty()) parts.add("类型不匹配: " + String.join(", ", mismatched));
-            return new ValidationResult(false, String.join("; ", parts), missing, mismatched, converted);
+            if (!keyNameMismatched.isEmpty()) {
+                parts.add("字段键名缺失(必须精确匹配): " + String.join(", ", keyNameMismatched));
+            }
+            if (!missing.isEmpty()) {
+                parts.add("必填字段值为空: " + String.join(", ", missing));
+            }
+            if (!mismatched.isEmpty()) {
+                parts.add("类型不匹配: " + String.join(", ", mismatched));
+            }
+            return new ValidationResult(false, String.join("; ", parts),
+                    missing, mismatched, keyNameMismatched, converted);
         }
     }
 
     /**
-     * 校验解析后的 Map。null 视为完全解析失败(不进入本方法)。
+     * 校验解析后的 Map(legacy 模式,与 strict=false 等价)。
      */
     public static ValidationResult validate(Map<String, Object> parsed, JsonNode outputsDecl) {
+        return validate(parsed, outputsDecl, false);
+    }
+
+    /**
+     * 校验解析后的 Map。null 视为完全解析失败(不进入本方法)。
+     *
+     * @param parsed      LLM 响应解析后的 Map
+     * @param outputsDecl 节点 outputs 声明数组
+     * @param strict      true 时 required 字段必须精确存在(区分大小写、区分分隔符);
+     *                    false 时仅按 parsed 自身内容判定;
+     *                    两种模式都不报多余字段、都不把非 required 字段视为必填
+     */
+    public static ValidationResult validate(Map<String, Object> parsed, JsonNode outputsDecl,
+                                            boolean strict) {
         if (parsed == null) {
-            return ValidationResult.fail(Collections.emptyList(), Collections.emptyList(), Collections.emptyMap());
+            return ValidationResult.fail(Collections.emptyList(), Collections.emptyList(),
+                    Collections.emptyList(), Collections.emptyMap());
         }
         if (outputsDecl == null || !outputsDecl.isArray() || outputsDecl.isEmpty()) {
             // 无声明:不校验
@@ -74,6 +112,7 @@ public final class OutputValidator {
 
         List<String> missing = new ArrayList<>();
         List<String> mismatched = new ArrayList<>();
+        List<String> keyNameMissing = new ArrayList<>();
         Map<String, Object> converted = new LinkedHashMap<>(parsed);
 
         for (JsonNode decl : outputsDecl) {
@@ -82,9 +121,15 @@ public final class OutputValidator {
             String type = decl.path("type").asText("string");
             boolean required = decl.path("required").asBoolean(false);
 
+            // strict 模式:required 字段必须精确存在(区分大小写、区分分隔符)
+            if (strict && required && !converted.containsKey(name)) {
+                keyNameMissing.add(name);
+                continue;
+            }
+
             Object value = converted.get(name);
 
-            // 必填校验
+            // 必填校验(值为 null 或空字符串)
             if (required && (value == null || isBlankString(value))) {
                 missing.add(name);
                 continue;
@@ -102,10 +147,10 @@ public final class OutputValidator {
             }
         }
 
-        if (missing.isEmpty() && mismatched.isEmpty()) {
+        if (missing.isEmpty() && mismatched.isEmpty() && keyNameMissing.isEmpty()) {
             return ValidationResult.ok(converted);
         }
-        return ValidationResult.fail(missing, mismatched, converted);
+        return ValidationResult.fail(missing, mismatched, keyNameMissing, converted);
     }
 
     private static final Object TYPE_MISMATCH_SENTINEL = new Object();

+ 75 - 17
backend/src/main/java/com/agent/management/engine/RetryPolicy.java

@@ -10,52 +10,105 @@ import java.util.Objects;
  *
  * <p>配置读取优先级:</p>
  * <ol>
- *   <li>节点 {@code data} 中的 {@code maxRetries / retryOnParseError / retryOnMissingRequired / retryOnTypeMismatch}</li>
+ *   <li>节点 {@code data} 中的同名字段</li>
  *   <li>全局 {@code application.yml} 的 {@code workflow.llm.retry.*}</li>
- *   <li>代码默认值(maxRetries=2,全部 retryOn=true)</li>
+ *   <li>代码默认值(runtimeMaxRetries=2、formatMaxRetries=2、全部 retryOn=true)</li>
  * </ol>
  *
+ * <p><b>双层重试</b>:</p>
+ * <ul>
+ *   <li>{@code runtimeMaxRetries}:运行异常(LLM 调用失败、限流、超时)时重新执行完整推理的次数</li>
+ *   <li>{@code formatMaxRetries}:格式错误(JSON 解析失败、字段缺失、类型不匹配、键名不匹配)时
+ *       仅重组 JSON 的重试次数(不重新执行业务逻辑,仅把上次响应 + JSON 模板回喂 LLM)</li>
+ * </ul>
+ *
  * <p>不可变值对象。在执行器中通过 {@link #fromNodeData} 一次性构造后传递给
  * {@link StructuredOutputHelper#extractWithRetry}。</p>
  */
 public final class RetryPolicy {
 
-    public final int maxRetries;
+    /** 运行异常重试次数(含首次)。每次都会重新完整推理。 */
+    public final int runtimeMaxRetries;
+    /** 格式错误重试次数(含首次)。每次仅重组 JSON,不重新推理业务逻辑。 */
+    public final int formatMaxRetries;
     public final boolean retryOnParseError;
     public final boolean retryOnMissingRequired;
     public final boolean retryOnTypeMismatch;
+    /** strict 模式下检测到键名缺失(如返回中文键名)时是否重试。 */
+    public final boolean retryOnKeyNameMismatch;
+
+    /**
+     * @deprecated 使用 {@link #RetryPolicy(int, int, boolean, boolean, boolean, boolean)} 替代。
+     * 保留过渡期兼容:maxRetries 同时赋给 runtimeMaxRetries 和 formatMaxRetries。
+     */
+    @Deprecated
+    public final int maxRetries;
 
-    public RetryPolicy(int maxRetries, boolean retryOnParseError,
-                       boolean retryOnMissingRequired, boolean retryOnTypeMismatch) {
-        if (maxRetries < 1) maxRetries = 1;
-        this.maxRetries = maxRetries;
+    public RetryPolicy(int runtimeMaxRetries, int formatMaxRetries,
+                       boolean retryOnParseError, boolean retryOnMissingRequired,
+                       boolean retryOnTypeMismatch, boolean retryOnKeyNameMismatch) {
+        if (runtimeMaxRetries < 1) runtimeMaxRetries = 1;
+        if (formatMaxRetries < 1) formatMaxRetries = 1;
+        this.runtimeMaxRetries = runtimeMaxRetries;
+        this.formatMaxRetries = formatMaxRetries;
         this.retryOnParseError = retryOnParseError;
         this.retryOnMissingRequired = retryOnMissingRequired;
         this.retryOnTypeMismatch = retryOnTypeMismatch;
+        this.retryOnKeyNameMismatch = retryOnKeyNameMismatch;
+        this.maxRetries = runtimeMaxRetries;
     }
 
     /**
-     * 默认策略:maxRetries=2,全部 retryOn=true。
+     * 默认策略:runtimeMaxRetries=2、formatMaxRetries=2、全部 retryOn=true。
      */
     public static RetryPolicy defaultPolicy() {
-        return new RetryPolicy(2, true, true, true);
+        return new RetryPolicy(2, 2, true, true, true, true);
+    }
+
+    /**
+     * Hermes 系列专用策略:未显式配置 runtimeMaxRetries 时强制降为 1。
+     *
+     * <p><b>原因</b>:Hermes 单次重跑成本极高(涉及完整 Agent 循环 + 工具调用 + 上下文重建),
+     * 默认 2 次外层重试会让单节点耗时翻倍且容易触发限流。内层 reformat 仍用普通 LLM,
+     * 不受此降级影响。</p>
+     *
+     * <p>节点级显式配置 {@code runtimeMaxRetries} 或 legacy {@code maxRetries} 时,尊重用户选择。</p>
+     *
+     * @param data  节点 data(可为 null)
+     * @param props 全局属性(可为 null)
+     */
+    public static RetryPolicy forHermes(JsonNode data, WorkflowProperties props) {
+        RetryPolicy base = fromNodeData(data, props);
+        if (data != null && (data.has("runtimeMaxRetries") || data.has("maxRetries"))) {
+            return base;
+        }
+        if (base.runtimeMaxRetries <= 1) return base;
+        return new RetryPolicy(1, base.formatMaxRetries,
+                base.retryOnParseError, base.retryOnMissingRequired,
+                base.retryOnTypeMismatch, base.retryOnKeyNameMismatch);
     }
 
     /**
      * 从节点 data + 全局属性构造策略。节点级配置覆盖全局。
-     * @param data 节点 data JsonNode(可为 null,走全局)
+     *
+     * @param data  节点 data JsonNode(可为 null,走全局)
      * @param props 全局属性(可为 null,走默认)
      */
     public static RetryPolicy fromNodeData(JsonNode data, WorkflowProperties props) {
         WorkflowProperties.Retry global = (props == null || props.getLlm() == null || props.getLlm().getRetry() == null)
                 ? new WorkflowProperties.Retry() : props.getLlm().getRetry();
 
-        int maxRetries = readInt(data, "maxRetries", global.getMaxRetries());
+        // runtimeMaxRetries:优先读节点级 runtimeMaxRetries,缺失时读 legacy maxRetries(向后兼容),再缺失走全局
+        int runtimeMaxRetries = readInt(data, "runtimeMaxRetries",
+                readInt(data, "maxRetries", global.getRuntimeMaxRetries()));
+        int formatMaxRetries = readInt(data, "formatMaxRetries", global.getFormatMaxRetries());
         boolean retryOnParseError = readBool(data, "retryOnParseError", global.isRetryOnParseError());
         boolean retryOnMissingRequired = readBool(data, "retryOnMissingRequired", global.isRetryOnMissingRequired());
         boolean retryOnTypeMismatch = readBool(data, "retryOnTypeMismatch", global.isRetryOnTypeMismatch());
+        boolean retryOnKeyNameMismatch = readBool(data, "retryOnKeyNameMismatch", global.isRetryOnKeyNameMismatch());
 
-        return new RetryPolicy(maxRetries, retryOnParseError, retryOnMissingRequired, retryOnTypeMismatch);
+        return new RetryPolicy(runtimeMaxRetries, formatMaxRetries,
+                retryOnParseError, retryOnMissingRequired, retryOnTypeMismatch, retryOnKeyNameMismatch);
     }
 
     private static int readInt(JsonNode data, String field, int fallback) {
@@ -79,22 +132,27 @@ public final class RetryPolicy {
         if (this == o) return true;
         if (!(o instanceof RetryPolicy)) return false;
         RetryPolicy that = (RetryPolicy) o;
-        return maxRetries == that.maxRetries
+        return runtimeMaxRetries == that.runtimeMaxRetries
+                && formatMaxRetries == that.formatMaxRetries
                 && retryOnParseError == that.retryOnParseError
                 && retryOnMissingRequired == that.retryOnMissingRequired
-                && retryOnTypeMismatch == that.retryOnTypeMismatch;
+                && retryOnTypeMismatch == that.retryOnTypeMismatch
+                && retryOnKeyNameMismatch == that.retryOnKeyNameMismatch;
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(maxRetries, retryOnParseError, retryOnMissingRequired, retryOnTypeMismatch);
+        return Objects.hash(runtimeMaxRetries, formatMaxRetries, retryOnParseError,
+                retryOnMissingRequired, retryOnTypeMismatch, retryOnKeyNameMismatch);
     }
 
     @Override
     public String toString() {
-        return "RetryPolicy{maxRetries=" + maxRetries
+        return "RetryPolicy{runtimeMaxRetries=" + runtimeMaxRetries
+                + ", formatMaxRetries=" + formatMaxRetries
                 + ", parseError=" + retryOnParseError
                 + ", missingRequired=" + retryOnMissingRequired
-                + ", typeMismatch=" + retryOnTypeMismatch + '}';
+                + ", typeMismatch=" + retryOnTypeMismatch
+                + ", keyNameMismatch=" + retryOnKeyNameMismatch + '}';
     }
 }

+ 73 - 4
backend/src/main/java/com/agent/management/engine/SkillFileSyncer.java

@@ -13,11 +13,29 @@ import java.nio.file.Paths;
 import java.nio.file.SimpleFileVisitor;
 import java.nio.file.StandardCopyOption;
 import java.nio.file.attribute.BasicFileAttributes;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
 import java.util.stream.Stream;
 
 /**
- * 将 Skills 源目录同步到工作流运行目录下的 .hermes/skills 子目录,
+ * 将 Skills 源目录精确镜像同步到固定 HERMES_HOME 的 skills 子目录,
  * 供 Hermes Bridge Python AIAgent 在执行时按 HERMES_HOME 读取 Skill。
+ *
+ * <p>镜像语义:
+ * <ul>
+ *   <li>目标侧存在而源侧不存在的 skill 目录会被删除(处理 skill 删除/重命名);</li>
+ *   <li>每个 skill 目录先整体删除再重新复制(处理 skill 内文件删除);</li>
+ *   <li>{@code _versions} 历史版本子目录不参与复制(避免 hermes-agent 递归扫描
+ *       把版本快照下的 SKILL.md 识别为候选,触发 collision detection)。</li>
+ *   <li>每个 skill 下<strong>直接包含 SKILL.md 的子目录</strong>不参与复制(任意深度),
+ *       防止技能内嵌套 SKILL.md 触发 hermes-agent 的 {@code Ambiguous skill name} 冲突。
+ *       常见于 AI 技能生成场景的残留产物(如 {@code generated_skills/}、
+ *       {@code tmp_smoke_generated/})。判定方式:{@code preVisitDirectory} 在每个非根
+ *       子目录处检查自身是否直接含 SKILL.md,命中则 {@code SKIP_SUBTREE}。
+ *       <strong>注意:技能根目录自身的 SKILL.md 是合法主入口,不受此规则影响。</strong></li>
+ * </ul>
  */
 @Slf4j
 @Component
@@ -28,13 +46,15 @@ public class SkillFileSyncer {
     private static final int MAX_SKILL_COUNT = 200;
     /** 单次同步允许的最大总字节数(500MB),超过则中止并抛异常 */
     private static final long MAX_TOTAL_BYTES = 500L * 1024 * 1024;
+    /** 合法的技能索引文件名,技能根目录下必须存在此文件 */
+    private static final String SKILL_MD_FILENAME = "SKILL.md";
 
     private final SkillProperties skillProperties;
 
     /**
-     * 将源 skills 根目录下所有 skill 子目录复制到目标目录。
+     * 将源 skills 根目录下所有 skill 子目录镜像复制到目标目录。
      *
-     * @param targetSkillsDir 目标 skills 目录(通常为 {run-dir}/.hermes/skills)
+     * @param targetSkillsDir 目标 skills 目录(固定 HERMES_HOME 下的 skills 子目录)
      * @return 实际复制的 skill 文件夹数量
      * @throws IOException 复制失败或超过上限时抛出
      */
@@ -46,6 +66,24 @@ public class SkillFileSyncer {
         }
         Files.createDirectories(targetSkillsDir);
 
+        // 1. 收集源侧 skill 目录名,清除目标侧残留(skill 被删除/重命名后目标侧的旧副本)
+        Set<String> sourceNames = new HashSet<>();
+        try (Stream<Path> entries = Files.list(source)) {
+            entries.filter(Files::isDirectory)
+                    .forEach(p -> sourceNames.add(p.getFileName().toString()));
+        }
+        try (Stream<Path> entries = Files.list(targetSkillsDir)) {
+            for (Path entry : (Iterable<Path>) entries::iterator) {
+                if (!Files.isDirectory(entry)) continue;
+                String name = entry.getFileName().toString();
+                if (!sourceNames.contains(name)) {
+                    deleteRecursively(entry);
+                    log.info("[SkillFileSyncer] 清除目标侧残留 skill: {}", name);
+                }
+            }
+        }
+
+        // 2. 逐 skill 整目录先删后拷,保证文件级精确镜像(skill 内被删文件不残留)
         long[] totalBytes = {0L};
         int copied = 0;
         try (Stream<Path> entries = Files.list(source)) {
@@ -56,6 +94,9 @@ public class SkillFileSyncer {
                     break;
                 }
                 Path target = targetSkillsDir.resolve(entry.getFileName());
+                if (Files.exists(target)) {
+                    deleteRecursively(target);
+                }
                 long[] dirBytes = {0L};
                 copyDirectory(entry, target, dirBytes, MAX_TOTAL_BYTES - totalBytes[0]);
                 totalBytes[0] += dirBytes[0];
@@ -66,17 +107,45 @@ public class SkillFileSyncer {
         return copied;
     }
 
+    private void deleteRecursively(Path dir) throws IOException {
+        try (Stream<Path> walk = Files.walk(dir)) {
+            List<Path> paths = walk.sorted(Comparator.reverseOrder()).toList();
+            for (Path p : paths) {
+                Files.delete(p);
+            }
+        }
+    }
+
     /**
      * 复制单个 skill 目录,同时累计已复制字节数,超过剩余配额则抛 IOException。
+     *
+     * <p>跳过规则(在 {@code preVisitDirectory} 中按目录层级逐层判定):
+     * <ul>
+     *   <li>{@code _versions} 目录:整体跳过,避免历史版本快照的 SKILL.md 被识别为候选。</li>
+     *   <li>非根子目录直接含 {@code SKILL.md}:整体跳过,避免技能内嵌套 SKILL.md
+     *       触发 hermes-agent 的 {@code Ambiguous skill name} 冲突。
+     *       常见于 AI 技能生成的残留产物({@code generated_skills/}、{@code tmp_smoke_generated/})。</li>
+     * </ul>
+     * <p>注意:技能根目录({@code src} 自身)含 SKILL.md 是合法主入口,不命中跳过条件。
      */
     private void copyDirectory(Path src, Path dst, long[] dirBytes, long remainingQuota) throws IOException {
         Files.walkFileTree(src, new SimpleFileVisitor<>() {
             @Override
             public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
+                String dirName = dir.getFileName() != null ? dir.getFileName().toString() : "";
                 // 跳过 _versions 子目录:避免 hermes-agent 的 iter_skill_index_files 递归扫描时
                 // 把版本快照下的 SKILL.md 也识别为候选,触发 collision detection
                 // ("Ambiguous skill name") 导致 skill_view 拒绝加载。
-                if (dir.getFileName() != null && "_versions".equals(dir.getFileName().toString())) {
+                if ("_versions".equals(dirName)) {
+                    return FileVisitResult.SKIP_SUBTREE;
+                }
+                // 跳过非根子目录中直接含 SKILL.md 的目录:技能内只允许根目录有一个 SKILL.md
+                // 主入口,子目录中出现 SKILL.md 一律视为嵌套技能残留(常见于 AI 生成场景的
+                // generated_skills/、tmp_smoke_generated/ 等),整树跳过避免 Hermes 扫描冲突。
+                // 注意:dir == src 的情况(技能根目录自身)必须放行,否则会跳过整个技能。
+                if (!dir.equals(src) && Files.exists(dir.resolve(SKILL_MD_FILENAME))) {
+                    log.warn("[SkillFileSyncer] 跳过 skill {} 下含 {} 的子目录 {}(防止 Hermes 扫描冲突)",
+                            src.getFileName(), SKILL_MD_FILENAME, dirName);
                     return FileVisitResult.SKIP_SUBTREE;
                 }
                 Files.createDirectories(dst.resolve(src.relativize(dir)));

+ 378 - 49
backend/src/main/java/com/agent/management/engine/StructuredOutputHelper.java

@@ -8,6 +8,7 @@ import org.springframework.ai.chat.client.ChatClient;
 import java.util.Collections;
 import java.util.LinkedHashMap;
 import java.util.Map;
+import java.util.function.Supplier;
 
 /**
  * LLM 结构化输出辅助类。
@@ -17,14 +18,25 @@ import java.util.Map;
  *
  * <p><b>触发条件</b>:{@code data.outputs} 是数组且非空。</p>
  *
- * <p><b>重试策略</b>:JSON 解析失败、必填字段缺失、类型不匹配时,按 {@link RetryPolicy} 重试。
- * 重试时在指令中追加"上一次返回错误:xxx,请修正"。</p>
+ * <p><b>双层重试模型</b>:</p>
+ * <ul>
+ *   <li><b>外层(运行重试)</b>:LLM 调用异常或空响应时触发,重新执行完整推理。
+ *       次数由 {@link RetryPolicy#runtimeMaxRetries} 控制。</li>
+ *   <li><b>内层(格式重试)</b>:JSON 解析失败、字段缺失、类型不匹配、键名不匹配时触发,
+ *       仅把上次响应 + JSON 模板 + 错误说明回喂给 LLM,让其重新组织 JSON 结构,
+ *       <b>不</b>重新执行业务逻辑。次数由 {@link RetryPolicy#formatMaxRetries} 控制。</li>
+ * </ul>
+ *
+ * <p>内层重试耗尽后,根据失败原因判断是否升级到外层重试(重新完整推理)。</p>
  */
 @Slf4j
 public final class StructuredOutputHelper {
 
     private static final ObjectMapper MAPPER = new ObjectMapper();
 
+    /** 重组 prompt 中上次响应的截断长度,避免 token 超限。 */
+    private static final int REFORMAT_RESPONSE_MAX_LEN = 2000;
+
     private StructuredOutputHelper() {}
 
     // ========== 触发条件 ==========
@@ -43,7 +55,7 @@ public final class StructuredOutputHelper {
     /**
      * LLM 结构化输出提取结果。
      * - {@code errorMessage == null} 表示成功,{@code data} 为解析后的字段 Map
-     * - 否则表示重试耗尽仍失败,{@code data} 为空,{@code attempts} == maxRetries
+     * - 否则表示重试耗尽仍失败,{@code data} 为空,{@code attempts} 为累计调用次数
      */
     public static final class ExtractionResult {
         public final Map<String, Object> data;
@@ -65,10 +77,10 @@ public final class StructuredOutputHelper {
         }
     }
 
-    // ========== 主入口(含重试) ==========
+    // ========== 主入口(双层重试) ==========
 
     /**
-     * 带 retry 的结构化输出提取。
+     * 带 retry 的结构化输出提取(双层重试模型)。
      *
      * @param client        ChatClient
      * @param userPrompt    用户提示词(不含结构化指令)
@@ -90,23 +102,24 @@ public final class StructuredOutputHelper {
         }
 
         String lastError = null;
+        int totalAttempts = 0;
 
-        for (int attempt = 1; attempt <= retryPolicy.maxRetries; attempt++) {
-            String instruction = buildInstruction(outputsDecl, lastError);
-            String fullPrompt = userPrompt + instruction;
+        // ===== 外层:运行重试(重新完整推理) =====
+        for (int runtimeAttempt = 1; runtimeAttempt <= retryPolicy.runtimeMaxRetries; runtimeAttempt++) {
 
+            // 1. 调用 LLM 完整推理(首外层不带错误回喂,后续外层把上次错误加入 instruction)
             String response;
             try {
-                ChatClient.ChatClientRequestSpec req = client.prompt().user(fullPrompt);
-                if (systemPrompt != null && !systemPrompt.isEmpty()) {
-                    req = req.system(systemPrompt);
-                }
-                response = req.call().content();
+                response = invokeFullLlm(client, userPrompt, systemPrompt, outputsDecl,
+                        runtimeAttempt > 1 ? lastError : null);
             } catch (Exception e) {
-                log.warn("[StructuredOutput] LLM 调用异常 attempt={}: {}", attempt, e.getMessage());
+                log.warn("[StructuredOutput] LLM 调用异常 runtimeAttempt={}/{}: {}",
+                        runtimeAttempt, retryPolicy.runtimeMaxRetries, e.getMessage());
                 lastError = "LLM 调用异常: " + e.getMessage();
+                if (!retryPolicy.retryOnParseError) break;
                 continue;
             }
+            totalAttempts++;
 
             if (response == null || response.isBlank()) {
                 lastError = "LLM 返回空结果";
@@ -114,42 +127,284 @@ public final class StructuredOutputHelper {
                 continue;
             }
 
-            Map<String, Object> parsed = parse(response, outputsDecl);
+            // 2. 内层:格式重试(仅重组 JSON,不重跑逻辑)
+            ReformatOutcome outcome = reformatLoop(client, systemPrompt, response, outputsDecl, retryPolicy);
+            totalAttempts += outcome.reformatLlmCalls;
+
+            if (outcome.ok) {
+                log.info("[StructuredOutput] 提取成功 runtimeAttempt={}/{}, reformatLlmCalls={}",
+                        runtimeAttempt, retryPolicy.runtimeMaxRetries, outcome.reformatLlmCalls);
+                return ExtractionResult.ok(outcome.data, totalAttempts);
+            }
+
+            // 3. 内层耗尽 → 判断是否升级到外层重试
+            lastError = outcome.errorMessage;
+            if (!shouldRuntimeRetry(outcome.failReason, retryPolicy)) break;
+        }
+
+        return ExtractionResult.fail(lastError == null ? "提取失败" : lastError,
+                Math.max(totalAttempts, 1));
+    }
+
+    // ========== 从已有文本提取(Hermes / Agent 系列接入入口) ==========
+
+    /**
+     * 从已有的原始文本(如 Hermes Agent 的 finalText)出发,做结构化提取与双层重试。
+     *
+     * <p><b>与 {@link #extractWithRetry} 的差异</b>:</p>
+     * <ul>
+     *   <li>外层(运行重试):调用 {@code runtimeRetrySupplier} 重新生成原始文本
+     *       (如 Hermes 在同一 session 内重新执行 Agent),而非调用 LLM 完整推理</li>
+     *   <li>内层(格式重试):仍用 {@code reformatClient}(普通 LLM)做 JSON 重组,
+     *       与 {@link #extractWithRetry} 内层逻辑完全一致</li>
+     * </ul>
+     *
+     * <p><b>职责分离</b>:</p>
+     * <ul>
+     *   <li>Hermes:业务推理、工具调用、产出 rawText(昂贵,少跑)</li>
+     *   <li>普通 LLM:把 rawText 整理成符合 schema 的 JSON(廉价,可多次)</li>
+     * </ul>
+     *
+     * @param rawText              已有的原始文本(如 Hermes finalText)
+     * @param outputsDecl          节点 outputs 声明数组
+     * @param retryPolicy          重试策略
+     * @param reformatClient       内层重组用的 ChatClient(普通 LLM),null 时跳过内层重组
+     * @param runtimeRetrySupplier 外层重试 supplier,返回新的 rawText;null 时不做外层重试
+     * @return ExtractionResult
+     */
+    public static ExtractionResult extractWithRetryFromText(String rawText,
+                                                             JsonNode outputsDecl,
+                                                             RetryPolicy retryPolicy,
+                                                             ChatClient reformatClient,
+                                                             Supplier<String> runtimeRetrySupplier) {
+        if (outputsDecl == null || !outputsDecl.isArray() || outputsDecl.isEmpty()) {
+            return ExtractionResult.fail("outputs 声明为空", 0);
+        }
+        if (rawText == null || rawText.isBlank()) {
+            return ExtractionResult.fail("原始响应为空", 0);
+        }
+        if (reformatClient == null) {
+            return ExtractionResult.fail("reformatClient 为 null", 0);
+        }
+
+        String lastError = null;
+        int totalAttempts = 0;
+        String currentText = rawText;
+
+        // ===== 外层:runtime retry(用 supplier 重新生成原始文本) =====
+        for (int runtimeAttempt = 1; runtimeAttempt <= retryPolicy.runtimeMaxRetries; runtimeAttempt++) {
+
+            // 第 2 次起:调用 supplier 重新生成 rawText
+            if (runtimeAttempt > 1) {
+                if (runtimeRetrySupplier == null) {
+                    break;
+                }
+                try {
+                    String newText = runtimeRetrySupplier.get();
+                    totalAttempts++;
+                    if (newText == null || newText.isBlank()) {
+                        lastError = "runtimeRetrySupplier 返回空结果";
+                        log.warn("[StructuredOutput] supplier 空结果 runtimeAttempt={}/{}",
+                                runtimeAttempt, retryPolicy.runtimeMaxRetries);
+                        if (!retryPolicy.retryOnParseError) break;
+                        continue;
+                    }
+                    currentText = newText;
+                } catch (Exception e) {
+                    log.warn("[StructuredOutput] runtimeRetrySupplier 异常 attempt={}/{}: {}",
+                            runtimeAttempt, retryPolicy.runtimeMaxRetries, e.getMessage());
+                    lastError = "runtimeRetrySupplier 异常: " + e.getMessage();
+                    if (!retryPolicy.retryOnParseError) break;
+                    continue;
+                }
+            }
+
+            // 内层:reformat loop(用 reformatClient 做 JSON 重组)
+            ReformatOutcome outcome = reformatLoop(reformatClient, null, currentText, outputsDecl, retryPolicy);
+            totalAttempts += outcome.reformatLlmCalls;
+
+            if (outcome.ok) {
+                log.info("[StructuredOutput] 从文本提取成功 runtimeAttempt={}/{}, reformatLlmCalls={}",
+                        runtimeAttempt, retryPolicy.runtimeMaxRetries, outcome.reformatLlmCalls);
+                return ExtractionResult.ok(outcome.data, totalAttempts);
+            }
+
+            lastError = outcome.errorMessage;
+            if (!shouldRuntimeRetry(outcome.failReason, retryPolicy)) break;
+        }
+
+        return ExtractionResult.fail(lastError == null ? "提取失败" : lastError,
+                Math.max(totalAttempts, 1));
+    }
+
+    // ========== 内层循环:仅重组 JSON ==========
+
+    /**
+     * 内层循环:解析 + strict 校验 + 重组重试。
+     * 失败时通过重组 prompt 让 LLM 仅重组 JSON 结构,不重新执行业务逻辑。
+     */
+    private static ReformatOutcome reformatLoop(ChatClient client,
+                                                 String systemPrompt,
+                                                 String originalResponse,
+                                                 JsonNode outputsDecl,
+                                                 RetryPolicy retryPolicy) {
+        String currentResponse = originalResponse;
+        String lastError = null;
+        FailReason lastFailReason = null;
+        int reformatLlmCalls = 0;
+
+        for (int i = 1; i <= retryPolicy.formatMaxRetries; i++) {
+
+            // 1. 解析
+            Map<String, Object> parsed = parse(currentResponse, outputsDecl);
             if (parsed == null) {
                 lastError = "JSON 解析失败,原始响应前 200 字: "
-                        + response.substring(0, Math.min(response.length(), 200));
+                        + currentResponse.substring(0, Math.min(currentResponse.length(), 200));
+                lastFailReason = FailReason.PARSE_ERROR;
                 if (!retryPolicy.retryOnParseError) break;
-                continue;
+            } else {
+                // 2. strict 校验(所有声明字段视为必填,键名精确匹配)
+                OutputValidator.ValidationResult validation =
+                        OutputValidator.validate(parsed, outputsDecl, true);
+
+                if (validation.ok) {
+                    return ReformatOutcome.ok(validation.converted, reformatLlmCalls);
+                }
+
+                lastError = buildValidationErrorMsg(validation);
+                lastFailReason = classifyFailReason(validation);
+                boolean shouldRetry =
+                        (!validation.missingRequired.isEmpty() && retryPolicy.retryOnMissingRequired)
+                                || (!validation.typeMismatched.isEmpty() && retryPolicy.retryOnTypeMismatch)
+                                || (!validation.keyNameMismatched.isEmpty() && retryPolicy.retryOnKeyNameMismatch);
+                if (!shouldRetry) break;
             }
 
-            OutputValidator.ValidationResult validation = OutputValidator.validate(parsed, outputsDecl);
+            // 3. 仍需重试 → 构造重组 prompt 调用 LLM(最后一次不再调用)
+            if (i >= retryPolicy.formatMaxRetries) break;
 
-            if (validation.ok) {
-                log.info("[StructuredOutput] 提取成功 attempt={}/{}", attempt, retryPolicy.maxRetries);
-                return ExtractionResult.ok(validation.converted, attempt);
+            String reformatPrompt = buildReformatPrompt(currentResponse, outputsDecl, lastError);
+            String reformatted;
+            try {
+                reformatted = invokeReformatLlm(client, systemPrompt, reformatPrompt);
+            } catch (Exception e) {
+                log.warn("[StructuredOutput] 重组 LLM 异常 formatAttempt={}: {}",
+                        i, e.getMessage());
+                lastError = "重组 LLM 异常: " + e.getMessage();
+                lastFailReason = FailReason.RUNTIME_ERROR;
+                break;
             }
+            reformatLlmCalls++;
 
-            // 校验失败:判断是否触发重试
-            lastError = buildValidationErrorMsg(validation);
-            boolean shouldRetry =
-                    (!validation.missingRequired.isEmpty() && retryPolicy.retryOnMissingRequired)
-                            || (!validation.typeMismatched.isEmpty() && retryPolicy.retryOnTypeMismatch);
-            if (!shouldRetry) break;
+            if (reformatted == null || reformatted.isBlank()) {
+                lastError = "重组 LLM 返回空结果";
+                lastFailReason = FailReason.PARSE_ERROR;
+                break;
+            }
+            currentResponse = reformatted;
         }
 
-        return ExtractionResult.fail(lastError == null ? "提取失败" : lastError, retryPolicy.maxRetries);
+        return ReformatOutcome.fail(
+                lastError == null ? "格式重试耗尽" : lastError,
+                lastFailReason == null ? FailReason.OTHER : lastFailReason,
+                reformatLlmCalls);
+    }
+
+    private static boolean shouldRuntimeRetry(FailReason reason, RetryPolicy policy) {
+        if (reason == null) return false;
+        switch (reason) {
+            case PARSE_ERROR:
+                return policy.retryOnParseError;
+            case MISSING_REQUIRED:
+                return policy.retryOnMissingRequired;
+            case TYPE_MISMATCH:
+                return policy.retryOnTypeMismatch;
+            case KEY_NAME_MISMATCH:
+                return policy.retryOnKeyNameMismatch;
+            case RUNTIME_ERROR:
+                return true; // 重组调用本身失败,应升级到外层完整重试
+            default:
+                return false;
+        }
+    }
+
+    private static FailReason classifyFailReason(OutputValidator.ValidationResult v) {
+        if (!v.keyNameMismatched.isEmpty()) return FailReason.KEY_NAME_MISMATCH;
+        if (!v.missingRequired.isEmpty()) return FailReason.MISSING_REQUIRED;
+        if (!v.typeMismatched.isEmpty()) return FailReason.TYPE_MISMATCH;
+        return FailReason.OTHER;
     }
 
     private static String buildValidationErrorMsg(OutputValidator.ValidationResult v) {
-        StringBuilder sb = new StringBuilder();
-        if (!v.missingRequired.isEmpty()) {
-            sb.append("必填字段缺失: ").append(String.join(", ", v.missingRequired));
+        return v.errorMessage == null ? "未知校验错误" : v.errorMessage;
+    }
+
+    // ========== 内部数据类 ==========
+
+    /** 重组循环的输出。 */
+    private static final class ReformatOutcome {
+        final boolean ok;
+        final Map<String, Object> data;
+        final String errorMessage;
+        final FailReason failReason;
+        /** 实际触发的重组 LLM 调用次数(不含首次解析,因为首次解析用原响应不算 LLM 调用)。 */
+        final int reformatLlmCalls;
+
+        private ReformatOutcome(boolean ok, Map<String, Object> data, String errorMessage,
+                                FailReason failReason, int reformatLlmCalls) {
+            this.ok = ok;
+            this.data = data;
+            this.errorMessage = errorMessage;
+            this.failReason = failReason;
+            this.reformatLlmCalls = reformatLlmCalls;
         }
-        if (!v.typeMismatched.isEmpty()) {
-            if (sb.length() > 0) sb.append("; ");
-            sb.append("类型不匹配: ").append(String.join(", ", v.typeMismatched));
+
+        static ReformatOutcome ok(Map<String, Object> data, int reformatLlmCalls) {
+            return new ReformatOutcome(true, data, null, null, reformatLlmCalls);
         }
-        return sb.toString();
+
+        static ReformatOutcome fail(String errorMessage, FailReason failReason, int reformatLlmCalls) {
+            return new ReformatOutcome(false, Collections.emptyMap(),
+                    errorMessage, failReason, reformatLlmCalls);
+        }
+    }
+
+    /** 内层重试失败原因分类,用于决定是否升级到外层重试。 */
+    private enum FailReason {
+        PARSE_ERROR,           // JSON 解析失败
+        MISSING_REQUIRED,      // 必填字段值为空
+        TYPE_MISMATCH,         // 类型不匹配
+        KEY_NAME_MISMATCH,     // 键名缺失(如返回中文键名)
+        RUNTIME_ERROR,         // 重组 LLM 调用本身异常
+        OTHER
+    }
+
+    // ========== LLM 调用 ==========
+
+    /**
+     * 完整推理调用:构造 userPrompt + JSON 模板指令,让 LLM 完整执行业务并产出 JSON。
+     */
+    private static String invokeFullLlm(ChatClient client, String userPrompt, String systemPrompt,
+                                         JsonNode outputsDecl, String lastError) {
+        String instruction = buildInstruction(outputsDecl, lastError);
+        String fullPrompt = userPrompt + instruction;
+        ChatClient.ChatClientRequestSpec req = client.prompt().user(fullPrompt);
+        if (systemPrompt != null && !systemPrompt.isEmpty()) {
+            req = req.system(systemPrompt);
+        }
+        return req.call().content();
+    }
+
+    /**
+     * 重组调用:仅发送重组 prompt(不含原 userPrompt),让 LLM 根据上次响应重新组织 JSON 结构。
+     * 不重新执行业务逻辑。
+     */
+    private static String invokeReformatLlm(ChatClient client, String systemPrompt, String reformatPrompt) {
+        ChatClient.ChatClientRequestSpec req = client.prompt().user(reformatPrompt);
+        if (systemPrompt != null && !systemPrompt.isEmpty()) {
+            req = req.system(systemPrompt);
+        }
+        return req.call().content();
     }
 
     // ========== 指令构造 ==========
@@ -157,46 +412,122 @@ public final class StructuredOutputHelper {
     /**
      * 构造结构化输出指令(追加到 userPrompt 末尾)。
      *
+     * <p>模板格式:带类型示例值 + required 标记 + 扩展字段说明(snake_case 自由发挥)。</p>
+     *
      * @param outputs   节点 outputs 声明数组
-     * @param lastError 上一次的错误(重试时非 null),null 表示首次调用
+     * @param lastError 上一次的错误(外层重试时非 null),null 表示首次调用
      */
     public static String buildInstruction(JsonNode outputs, String lastError) {
         StringBuilder sb = new StringBuilder();
-        sb.append("\n\n---\n**重要**:请严格按以下 JSON 格式输出最终结果,仅输出 JSON 本身,")
-          .append("不要任何额外文字、解释、Markdown 代码块标记(不要使用 ```json``` 包裹)。\n")
-          .append("字段缺失或类型不符将导致流程失败。\n\n");
+        sb.append("\n\n---\n**必须按如下 JSON 格式输出最终结果**(仅输出 JSON 本身,")
+          .append("不要任何 Markdown 包裹、解释或额外文字):\n\n");
         sb.append("{\n");
         for (JsonNode o : outputs) {
             String name = o.path("name").asText("");
-            String type = o.path("type").asText("string");
             if (name.isEmpty()) continue;
-            sb.append("  \"").append(name).append("\": <").append(type).append(">,\n");
+            String type = o.path("type").asText("string");
+            sb.append("  \"").append(name).append("\": ")
+              .append(exampleValueFor(type)).append(",\n");
         }
         // 删除最后一个逗号
         int lastComma = sb.lastIndexOf(",\n");
         if (lastComma == sb.length() - 2) sb.deleteCharAt(lastComma);
-        sb.append("}\n\n字段说明:\n");
+        sb.append("}\n\n");
+
+        // 字段说明(带 required 标记)
+        sb.append("**字段说明**:\n");
         for (JsonNode o : outputs) {
             String name = o.path("name").asText("");
             if (name.isEmpty()) continue;
             String type = o.path("type").asText("string");
-            String desc = o.path("description").asText("");
             boolean required = o.path("required").asBoolean(false);
+            String desc = o.path("description").asText("");
             sb.append("- ").append(name).append(" (").append(type)
-              .append(required ? ", 必填" : "").append("): ")
+              .append(required ? ", required" : ", optional").append("): ")
               .append(desc.isEmpty() ? "(无描述)" : desc).append("\n");
         }
+
+        // 扩展字段说明(LLM 自由发挥)
+        sb.append("\n**扩展字段**(可选):\n");
+        sb.append("- 你可以自由添加业务必要的辅助字段(如 confidence、run_error、user_notice 等)\n");
+        sb.append("- 命名规则:snake_case\n");
+        sb.append("- 最小化原则:只放置真正必需的辅助信息,不要堆砌\n");
+        sb.append("- 不要添加与上述已声明字段语义重叠的字段\n");
+
+        // 约束
+        sb.append("\n**约束**:\n");
+        sb.append("- 必须严格使用上述声明的字段名,区分大小写、区分分隔符(禁止中文字段名)\n");
+        sb.append("- required 字段缺失或类型不符将触发重试\n");
+        sb.append("- 输出必须能被 JSON 直接解析(首尾不能有非 JSON 字符)\n");
+
         if (lastError != null && !lastError.isEmpty()) {
             sb.append("\n**上一次返回错误**:").append(lastError)
-              .append("。请修正并重新输出符合上述 JSON 格式的结果。\n");
+              .append("。请修正并重新输出符合上述 JSON 格式与字段名要求的结果。\n");
         }
         return sb.toString();
     }
 
+    /**
+     * 按声明类型返回 JSON 模板中的示例值。
+     * 用于让 LLM 直观理解每种类型的输出形式。
+     */
+    private static String exampleValueFor(String type) {
+        if (type == null) return "\"XXX\"";
+        switch (type) {
+            case "number": return "0";
+            case "boolean": return "true";
+            case "array": return "[\"item1\", \"item2\"]";
+            case "object": return "{\"key\": \"value\"}";
+            default: return "\"XXX\"";  // string / filePath / directoryPath / file 等
+        }
+    }
+
+    /**
+     * 构造重组 prompt:把上次响应 + JSON 模板 + 错误说明回喂给 LLM,让其仅重组 JSON 结构。
+     *
+     * <p><b>关键</b>:不包含原 userPrompt,避免重新触发业务逻辑推理。</p>
+     *
+     * @param previousResponse LLM 上一次的原始响应
+     * @param outputsDecl      节点 outputs 声明
+     * @param errorMessage     校验错误描述
+     */
+    public static String buildReformatPrompt(String previousResponse, JsonNode outputsDecl, String errorMessage) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("你之前的响应:\n")
+          .append(truncate(previousResponse, REFORMAT_RESPONSE_MAX_LEN))
+          .append("\n\n---\n");
+        sb.append("但上述响应未能通过结构校验,原因:")
+          .append(errorMessage)
+          .append("\n\n");
+        sb.append("**重要**:请仅根据你之前响应中的语义内容,**重新组织**为符合下列字段名要求的 JSON。\n")
+          .append("必须严格使用下列字段名(区分大小写、区分分隔符,禁止返回中文字段名),")
+          .append("仅输出 JSON 本身,不要任何额外文字、解释、Markdown 代码块标记。\n\n");
+        sb.append("{\n");
+        for (JsonNode o : outputsDecl) {
+            String name = o.path("name").asText("");
+            if (name.isEmpty()) continue;
+            String type = o.path("type").asText("string");
+            sb.append("  \"").append(name).append("\": ")
+              .append(exampleValueFor(type)).append(",\n");
+        }
+        int lastComma = sb.lastIndexOf(",\n");
+        if (lastComma == sb.length() - 2) sb.deleteCharAt(lastComma);
+        sb.append("}\n");
+        return sb.toString();
+    }
+
+    private static String truncate(String s, int maxLen) {
+        if (s == null) return "";
+        return s.length() <= maxLen ? s : s.substring(0, maxLen) + "...(截断)";
+    }
+
     // ========== 解析(保留 v1 逻辑) ==========
 
     /**
-     * 解析 LLM 响应,按 outputs 拆分为 Map<varName, value>。
+     * 解析 LLM 响应,按 outputs 拆分为 Map&lt;varName, value&gt;。
+     *
+     * <p><b>注意</b>:本方法只提取声明字段中实际存在的值。缺失或值为 null 的字段<b>不放入</b> Map,
+     * 由 {@link OutputValidator} 的 strict 模式通过 {@code containsKey} 判定为键名缺失或值为空。</p>
      *
      * @param llmResponse LLM 返回的原始字符串
      * @param outputs     节点声明的 outputs 数组
@@ -215,9 +546,7 @@ public final class StructuredOutputHelper {
             if (name.isEmpty()) continue;
             JsonNode value = parsed.get(name);
             if (value == null || value.isNull()) {
-                log.warn("[StructuredOutput] 字段 {} 未在 LLM 响应中找到", name);
-                result.put(name, null);
-                continue;
+                continue; // 不放入:让 strict 模式通过 !containsKey 判定为键名缺失或值为空
             }
             result.put(name, convertValue(value, o.path("type").asText("string")));
         }

+ 1 - 18
backend/src/main/java/com/agent/management/engine/WorkflowEngine.java

@@ -31,8 +31,6 @@ public class WorkflowEngine {
     private final WorkflowRunRepository runRepo;
     private final WorkflowRunNodeRepository runNodeRepo;
     private final WorkflowRunDirManager runDirManager;
-    private final SkillFileSyncer skillFileSyncer;
-    private final com.agent.management.config.HermesProperties hermesProperties;
     private final WorkflowLevelExecutor levelExecutor;
     private final SseEventBus eventBus;
     private final ExecutorService executor = Executors.newFixedThreadPool(
@@ -55,15 +53,12 @@ public class WorkflowEngine {
 
     public WorkflowEngine(WorkflowRepository workflowRepo,
                           WorkflowRunRepository runRepo, WorkflowRunNodeRepository runNodeRepo,
-                          WorkflowRunDirManager runDirManager, SkillFileSyncer skillFileSyncer,
-                          com.agent.management.config.HermesProperties hermesProperties,
+                          WorkflowRunDirManager runDirManager,
                           WorkflowLevelExecutor levelExecutor, SseEventBus eventBus) {
         this.workflowRepo = workflowRepo;
         this.runRepo = runRepo;
         this.runNodeRepo = runNodeRepo;
         this.runDirManager = runDirManager;
-        this.skillFileSyncer = skillFileSyncer;
-        this.hermesProperties = hermesProperties;
         this.levelExecutor = levelExecutor;
         this.eventBus = eventBus;
         log.info("[WorkflowEngine] 已初始化,使用 WorkflowLevelExecutor 处理节点遍历");
@@ -162,18 +157,6 @@ public class WorkflowEngine {
         // 4. 创建工作目录(按 name 命名)
         java.nio.file.Path workingDir = runDirManager.createRunDir(workflowId, wf.getName(), runId);
 
-        // 4.1 同步 Skills 到 {workingDir}/.hermes/skills,供 Hermes Bridge 读取
-        try {
-            java.nio.file.Path skillsDir = workingDir.resolve(".hermes").resolve("skills");
-            int synced = skillFileSyncer.syncTo(skillsDir);
-            log.info("[WorkflowEngine] Hermes skills 已同步: runId={}, 数量={}", runId, synced);
-        } catch (Exception e) {
-            if (hermesProperties.isEnabled()) {
-                throw new RuntimeException("同步 Hermes skills 失败: " + e.getMessage(), e);
-            }
-            log.warn("[WorkflowEngine] 同步 skills 失败(Hermes 未启用,继续执行): {}", e.getMessage());
-        }
-
         // 5. 初始化上下文
         WorkflowContext context = new WorkflowContext(workflowId, runId, workingDir, inputs);
 

+ 26 - 9
backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java

@@ -303,34 +303,51 @@ public class WorkflowLevelExecutor {
         JsonNode nodeData = ctx.dag.getNodeDataMap().get(nodeId);
         String label = nodeData.path("label").asText(nodeId);
 
+        // D: envelope 失败自动升级
+        // executor 返回 SUCCESS 但 envelope.status=400 时(如 Hermes Agent 把 LLM 429 错误描述当作成功结果返回),
+        // 视为业务失败,按 FAILED 处理 failStrategy;原始 failure envelope 已由 executeOneNode 写入 context,下游可通过 envelope.status 感知
+        NodeExecutionResult effectiveResult = result;
+        if (result.getStatus() == NodeExecutionResult.Status.SUCCESS
+                && NodeOutputEnvelope.isEnvelope(result.getOutput())) {
+            NodeOutputEnvelope env = NodeOutputEnvelope.fromObject(result.getOutput());
+            if (env != null && env.isFailure()) {
+                String originalMsg = env.getMessage();
+                String errMsg = "节点业务执行失败(envelope.status=400): "
+                        + (originalMsg == null || originalMsg.isEmpty() ? "未提供失败原因" : originalMsg);
+                log.warn("[WorkflowEngine] 节点 {} 触发 envelope 失败升级(envelope.status=400): {}", nodeId, originalMsg);
+                effectiveResult = NodeExecutionResult.failed(nodeId, errMsg, result.getLogs());
+            }
+        }
+
         // 持锁处理 records / finalOutputs / sortOrder(dataflow 下多 worker 并发进入)
         boolean shouldAbort = false;
         String abortMsg = null;
         synchronized (ctx.recordsLock) {
             int sortOrder = ctx.sortOrderSeq.getAndIncrement();
             ctx.nodeRecords.add(buildNodeRecord(ctx.runRecordId, nodeId, nodeType, label,
-                    result.getStatus().name(), result.getOutput(), result.getError(),
-                    result.getSelectedBranch(), result.getLogs(), result.getContextSnapshot(), sortOrder));
+                    effectiveResult.getStatus().name(), effectiveResult.getOutput(), effectiveResult.getError(),
+                    effectiveResult.getSelectedBranch(), effectiveResult.getLogs(),
+                    effectiveResult.getContextSnapshot(), sortOrder));
 
-            if (result.getStatus() == NodeExecutionResult.Status.FAILED) {
+            if (effectiveResult.getStatus() == NodeExecutionResult.Status.FAILED) {
                 String failStrategy = nodeData.path("failStrategy").asText("abort");
                 if ("skip".equals(failStrategy)) {
                     log.info("[WorkflowEngine] 节点 {} 执行失败,策略为跳过,继续工作流", nodeId);
                 } else {
                     shouldAbort = true;
-                    abortMsg = "节点 " + nodeId + " 执行失败: " + result.getError();
+                    abortMsg = "节点 " + nodeId + " 执行失败: " + effectiveResult.getError();
                 }
             }
 
-            if ("output".equals(nodeType) && result.getOutput() != null) {
-                // result.getOutput() 已是 envelope.toMap(),整体作为工作流最终输出
+            if ("output".equals(nodeType) && effectiveResult.getOutput() != null) {
+                // effectiveResult.getOutput() 已是 envelope.toMap(),整体作为工作流最终输出
                 ctx.finalOutputs.clear();
-                ctx.finalOutputs.putAll(result.getOutput());
+                ctx.finalOutputs.putAll(effectiveResult.getOutput());
             }
         }
 
         // 推送 nodeResult(在 recordsLock 之外,避免与 safeSend 形成嵌套锁)
-        safeSend(ctx.emitter, WorkflowRunEvent.nodeResult(ctx.runId, result));
+        safeSend(ctx.emitter, WorkflowRunEvent.nodeResult(ctx.runId, effectiveResult));
 
         // 失败 + abort:跳过下游调度,直接 triggerAbort(未启动节点由 triggerAbort 内 handleSkippedNode 收尾)
         if (shouldAbort) {
@@ -340,7 +357,7 @@ public class WorkflowLevelExecutor {
         }
 
         // 激活下游(含条件分支入度补偿)
-        tryScheduleSuccessors(nodeId, nodeType, result, ctx);
+        tryScheduleSuccessors(nodeId, nodeType, effectiveResult, ctx);
 
         ctx.markCompleted();
     }

+ 33 - 3
backend/src/main/java/com/agent/management/engine/executor/AgentExecutor.java

@@ -1,6 +1,7 @@
 package com.agent.management.engine.executor;
 
 import com.agent.management.engine.*;
+import com.agent.management.config.WorkflowProperties;
 import com.agent.management.model.dto.IOField;
 import com.agent.management.model.dto.SkillDTO;
 import com.agent.management.service.AiModelService;
@@ -11,6 +12,7 @@ import org.springframework.ai.chat.client.ChatClient;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Component;
 
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.stream.Collectors;
 
@@ -29,11 +31,14 @@ public class AgentExecutor implements NodeExecutor {
     private final AiModelService aiModelService;
     private final ChatClient.Builder fallbackBuilder;
     private final SkillService skillService;
+    private final WorkflowProperties workflowProperties;
 
-    public AgentExecutor(ChatClient.Builder chatClientBuilder, AiModelService aiModelService, SkillService skillService) {
+    public AgentExecutor(ChatClient.Builder chatClientBuilder, AiModelService aiModelService,
+                         SkillService skillService, WorkflowProperties workflowProperties) {
         this.fallbackBuilder = chatClientBuilder;
         this.aiModelService = aiModelService;
         this.skillService = skillService;
+        this.workflowProperties = workflowProperties;
     }
 
     @Override
@@ -63,9 +68,34 @@ public class AgentExecutor implements NodeExecutor {
         // 构造用户消息:基于 Skill 的 inputs 定义从工作区取值
         String userMessage = buildUserMessage(skill, workspace);
         ChatClient client = resolveChatClient(data);
+        JsonNode outputsDecl = data.path("outputs");
+        boolean structured = StructuredOutputHelper.needsStructuredOutput(data);
 
-        log.info("[Agent] 节点 {} 执行 Skill: {}, 用户消息长度={}", nodeId, agentId, userMessage.length());
+        log.info("[Agent] 节点 {} 执行 Skill: {}, 用户消息长度={}, structured={}",
+                nodeId, agentId, userMessage.length(), structured);
 
+        // 结构化模式:走与 LLM 节点一致的 extractWithRetry 流程
+        if (structured) {
+            // 把节点明确定义的输入变量追加到 SKILL.md 内容之后,作为完整系统提示
+            String fullSystem = ContextPromptHelper.merge(skillContent, data, workspace);
+            RetryPolicy policy = RetryPolicy.fromNodeData(data, workflowProperties);
+            StructuredOutputHelper.ExtractionResult ext =
+                    StructuredOutputHelper.extractWithRetry(client, userMessage, fullSystem, outputsDecl, policy);
+
+            if (ext.errorMessage != null) {
+                log.warn("[Agent] 节点 {} 结构化输出失败(重试 {} 次): {}",
+                        nodeId, ext.attempts, ext.errorMessage);
+                return NodeExecutionResult.success(nodeId,
+                        NodeOutputEnvelope.failure(ext.errorMessage).toMap());
+            }
+
+            String msg = "Agent 执行成功" + (ext.attempts > 1 ? "(重试 " + (ext.attempts - 1) + " 次)" : "");
+            log.info("[Agent] 节点 {} 完成, 输出字段数={}, attempts={}", nodeId, ext.data.size(), ext.attempts);
+            return NodeExecutionResult.success(nodeId,
+                    NodeOutputEnvelope.success(msg, ext.data).toMap());
+        }
+
+        // 单输出降级模式:未声明 outputs,按默认变量名写一个字段
         try {
             // 把节点明确定义的输入变量追加到 SKILL.md 内容之后,作为完整系统提示
             String fullSystem = ContextPromptHelper.merge(skillContent, data, workspace);
@@ -81,7 +111,7 @@ public class AgentExecutor implements NodeExecutor {
 
             log.info("[Agent] 节点 {} 完成, 结果长度={}", nodeId, result.length());
             String varName = NodeTypeUtils.resolveOutputVarName(data, "result");
-            java.util.Map<String, Object> dataMap = new java.util.LinkedHashMap<>();
+            Map<String, Object> dataMap = new LinkedHashMap<>();
             dataMap.put(varName, result);
             return NodeExecutionResult.success(nodeId,
                     NodeOutputEnvelope.success("Agent 执行成功", dataMap).toMap());

+ 106 - 15
backend/src/main/java/com/agent/management/engine/executor/HermesAgentExecutor.java

@@ -1,6 +1,7 @@
 package com.agent.management.engine.executor;
 
 import com.agent.management.config.HermesProperties;
+import com.agent.management.config.WorkflowProperties;
 import com.agent.management.engine.*;
 import com.agent.management.engine.hermes.HermesBridgeClient;
 import com.agent.management.model.dto.IOField;
@@ -10,10 +11,12 @@ import com.agent.management.service.AiModelService;
 import com.agent.management.service.SkillService;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.ChatClient;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Component;
 
 import java.util.Map;
+import java.util.function.Supplier;
 import java.util.stream.Collectors;
 
 /**
@@ -32,13 +35,19 @@ public class HermesAgentExecutor implements NodeExecutor {
     private final SkillService skillService;
     private final HermesProperties hermesProperties;
     private final AiModelService aiModelService;
+    private final ChatClient.Builder fallbackBuilder;
+    private final WorkflowProperties workflowProperties;
 
     public HermesAgentExecutor(HermesBridgeClient bridgeClient, SkillService skillService,
-                               HermesProperties hermesProperties, AiModelService aiModelService) {
+                               HermesProperties hermesProperties, AiModelService aiModelService,
+                               ChatClient.Builder fallbackBuilder,
+                               WorkflowProperties workflowProperties) {
         this.bridgeClient = bridgeClient;
         this.skillService = skillService;
         this.hermesProperties = hermesProperties;
         this.aiModelService = aiModelService;
+        this.fallbackBuilder = fallbackBuilder;
+        this.workflowProperties = workflowProperties;
     }
 
     @Override
@@ -50,8 +59,8 @@ public class HermesAgentExecutor implements NodeExecutor {
     public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String agentId = data.path("agentId").asText("");
         if (agentId.isEmpty()) {
-            return NodeExecutionResult.success(nodeId,
-                    NodeOutputEnvelope.failure("Agent 节点未关联 Skill").toMap());
+            // C: 业务失败使用外层 FAILED,引擎按 failStrategy 处理
+            return NodeExecutionResult.failed(nodeId, "Agent 节点未关联 Skill");
         }
 
         // 加载 Skill 内容作为系统提示
@@ -63,30 +72,78 @@ public class HermesAgentExecutor implements NodeExecutor {
             systemPrompt = ContextPromptHelper.merge(skillService.getSkillFullContent(agentId), data, workspace);
             userMessage = buildUserMessage(skill, workspace);
         } catch (Exception e) {
-            return NodeExecutionResult.success(nodeId,
-                    NodeOutputEnvelope.failure("加载 Skill 失败: " + e.getMessage()).toMap());
+            return NodeExecutionResult.failed(nodeId, "加载 Skill 失败: " + e.getMessage());
         }
 
         int maxIterations = data.path("maxIterations").asInt(hermesProperties.getBridge().getDefaultMaxIterations());
-        log.info("[HermesAgent] 节点 {} 开始执行,Skill={}, maxIterations={}", nodeId, agentId, maxIterations);
+        JsonNode outputsDecl = data.path("outputs");
+        boolean structured = StructuredOutputHelper.needsStructuredOutput(data);
+
+        // 结构化模式:把 JSON 输出指令追加到 userMessage 末尾,让 Hermes 首次输出尽量就是合法 JSON,
+        // 减少后续 reformat LLM 调用与 Hermes 重跑概率
+        String fullUserMessage = userMessage;
+        if (structured) {
+            fullUserMessage = userMessage + StructuredOutputHelper.buildInstruction(outputsDecl, null);
+        }
+
+        log.info("[HermesAgent] 节点 {} 开始执行,Skill={}, maxIterations={}, structured={}",
+                nodeId, agentId, maxIterations, structured);
 
         try {
-            String hermesHome = context.getWorkingDir() != null
-                    ? context.getWorkingDir().resolve(".hermes").toString()
-                    : null;
+            // hermesHome 传 null:由 HermesBridgeClient 回落到固定 HERMES_HOME(hermes.bridge.home)
             String workingDir = context.getWorkingDir() != null ? context.getWorkingDir().toString() : null;
             String sessionId = context.getRunId() + "_" + nodeId;
             Map<String, String> modelConfig = resolveModelConfig(data);
-            HermesRunResult runResult = bridgeClient.run(systemPrompt, userMessage, maxIterations,
-                    hermesHome, workingDir, nodeId, context.getStreamSink(), sessionId, modelConfig);
+            HermesRunResult runResult = bridgeClient.run(systemPrompt, fullUserMessage, maxIterations,
+                    null, workingDir, nodeId, context.getStreamSink(), sessionId, modelConfig);
 
             if (runResult.getFinalText() == null || runResult.getFinalText().isBlank()) {
-                return NodeExecutionResult.success(nodeId,
-                        NodeOutputEnvelope.failure("Hermes Agent 返回空结果").toMap(), runResult.getLogs());
+                // C: 返回空结果视为业务失败
+                return NodeExecutionResult.failed(nodeId, "Hermes Agent 返回空结果", runResult.getLogs());
             }
 
             log.info("[HermesAgent] 节点 {} 完成, 结果长度={}, 日志数={}",
                     nodeId, runResult.getFinalText().length(), runResult.getLogs().size());
+
+            // 结构化模式:交给 extractWithRetryFromText 处理
+            // - 内层用廉价 reformatClient 整理 JSON(首选)
+            // - 内层耗尽时由 supplier 复用 sessionId 让 Hermes 重跑(cost 高,保底)
+            if (structured) {
+                ChatClient reformatClient = resolveReformatChatClient(data);
+                RetryPolicy policy = RetryPolicy.forHermes(data, workflowProperties);
+
+                Supplier<String> runtimeRetrySupplier = () -> {
+                    try {
+                        String retryMsg = "上次响应未通过结构化校验,请基于 outputs 声明严格重新生成纯 JSON,"
+                                + "仅输出 JSON 本身,不要任何额外文字。";
+                        HermesRunResult r = bridgeClient.run(systemPrompt, retryMsg, maxIterations,
+                                null, workingDir, nodeId, context.getStreamSink(), sessionId, modelConfig);
+                        return r == null ? "" : r.getFinalText();
+                    } catch (Exception ex) {
+                        // supplier 不允许抛 checked exception,包装为 RuntimeException 让上层记录
+                        throw new RuntimeException("Hermes 重跑失败: " + ex.getMessage(), ex);
+                    }
+                };
+
+                StructuredOutputHelper.ExtractionResult ext =
+                        StructuredOutputHelper.extractWithRetryFromText(
+                                runResult.getFinalText(), outputsDecl, policy, reformatClient, runtimeRetrySupplier);
+
+                if (ext.errorMessage != null) {
+                    log.warn("[HermesAgent] 节点 {} 结构化输出失败(累计 {} 次调用): {}",
+                            nodeId, ext.attempts, ext.errorMessage);
+                    return NodeExecutionResult.failed(nodeId,
+                            "Hermes Agent 结构化输出失败: " + ext.errorMessage, runResult.getLogs());
+                }
+
+                String msg = "Hermes Agent 执行成功" + (ext.attempts > 1 ? "(重试 " + (ext.attempts - 1) + " 次)" : "");
+                log.info("[HermesAgent] 节点 {} 结构化输出完成, 字段数={}, attempts={}",
+                        nodeId, ext.data.size(), ext.attempts);
+                return NodeExecutionResult.success(nodeId,
+                        NodeOutputEnvelope.success(msg, ext.data).toMap(), runResult.getLogs());
+            }
+
+            // 单输出降级模式:未声明 outputs,按默认变量名写一个字段
             String varName = NodeTypeUtils.resolveOutputVarName(data, "result");
             java.util.Map<String, Object> dataMap = new java.util.LinkedHashMap<>();
             dataMap.put(varName, runResult.getFinalText());
@@ -95,8 +152,9 @@ public class HermesAgentExecutor implements NodeExecutor {
 
         } catch (Exception e) {
             log.error("[HermesAgent] 节点 {} 调用失败: {}", nodeId, e.getMessage());
-            return NodeExecutionResult.success(nodeId,
-                    NodeOutputEnvelope.failure("Hermes Agent 调用失败: " + e.getMessage()).toMap());
+            // C: 外层状态与业务结果对齐 —— 异常一律 FAILED,由引擎统一构造失败 envelope 写入 context
+            // 若 e 是 HermesErrorResponseException(方案 B 嗅探到的错误响应),getMessage 已包含「命中模式 / 原始响应」上下文
+            return NodeExecutionResult.failed(nodeId, "Hermes Agent 调用失败: " + e.getMessage());
         }
     }
 
@@ -140,6 +198,39 @@ public class HermesAgentExecutor implements NodeExecutor {
         );
     }
 
+    /**
+     * 解析结构化输出重组用的 ChatClient(内层 reformat 专用,廉价 LLM)。
+     *
+     * <p>优先级:</p>
+     * <ol>
+     *   <li>节点 {@code data.reformatModelId}(用户指定的廉价重组模型,推荐用低成本模型降低开销)</li>
+     *   <li>节点 {@code data.modelId}(与推理模型共用,避免额外配置)</li>
+     *   <li>UI 当前默认模型(确保 UI 切换默认模型对重组路径也生效)</li>
+     *   <li>应用启动时注入的 fallbackBuilder</li>
+     * </ol>
+     */
+    private ChatClient resolveReformatChatClient(JsonNode data) {
+        long reformatModelId = data.path("reformatModelId").asLong(0);
+        if (reformatModelId > 0) {
+            try {
+                return aiModelService.getChatClient(reformatModelId);
+            } catch (Exception e) {
+                log.warn("[HermesAgent] 获取重组模型 {} 失败,回退到推理模型: {}",
+                        reformatModelId, e.getMessage());
+            }
+        }
+        long modelId = data.path("modelId").asLong(0);
+        if (modelId > 0) {
+            try {
+                return aiModelService.getChatClient(modelId);
+            } catch (Exception e) {
+                log.warn("[HermesAgent] 获取推理模型 {} 作为重组模型失败,回退到默认模型: {}",
+                        modelId, e.getMessage());
+            }
+        }
+        return aiModelService.getChatClientOrFallback(fallbackBuilder);
+    }
+
     /**
      * 根据 Skill 的 inputs 定义,从工作区构造结构化用户消息
      */

+ 104 - 13
backend/src/main/java/com/agent/management/engine/executor/HermesSmartActionExecutor.java

@@ -1,16 +1,19 @@
 package com.agent.management.engine.executor;
 
 import com.agent.management.config.HermesProperties;
+import com.agent.management.config.WorkflowProperties;
 import com.agent.management.engine.*;
 import com.agent.management.engine.hermes.HermesBridgeClient;
 import com.agent.management.model.entity.AiModel;
 import com.agent.management.service.AiModelService;
 import com.fasterxml.jackson.databind.JsonNode;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.ai.chat.client.ChatClient;
 import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
 import org.springframework.stereotype.Component;
 
 import java.util.Map;
+import java.util.function.Supplier;
 
 /**
  * Hermes 智能操作节点执行器
@@ -27,12 +30,18 @@ public class HermesSmartActionExecutor implements NodeExecutor {
     private final HermesBridgeClient bridgeClient;
     private final HermesProperties hermesProperties;
     private final AiModelService aiModelService;
+    private final ChatClient.Builder fallbackBuilder;
+    private final WorkflowProperties workflowProperties;
 
     public HermesSmartActionExecutor(HermesBridgeClient bridgeClient, HermesProperties hermesProperties,
-                                     AiModelService aiModelService) {
+                                     AiModelService aiModelService,
+                                     ChatClient.Builder fallbackBuilder,
+                                     WorkflowProperties workflowProperties) {
         this.bridgeClient = bridgeClient;
         this.hermesProperties = hermesProperties;
         this.aiModelService = aiModelService;
+        this.fallbackBuilder = fallbackBuilder;
+        this.workflowProperties = workflowProperties;
     }
 
     @Override
@@ -46,33 +55,82 @@ public class HermesSmartActionExecutor implements NodeExecutor {
                 data.path("actionPrompt").asText(""), workspace.getVariables());
 
         if (actionPrompt.isEmpty()) {
-            return NodeExecutionResult.success(nodeId,
-                    NodeOutputEnvelope.failure("智能操作节点的操作要求为空").toMap());
+            // C: 业务失败使用外层 FAILED
+            return NodeExecutionResult.failed(nodeId, "智能操作节点的操作要求为空");
         }
 
         int maxIterations = data.path("maxIterations").asInt(hermesProperties.getBridge().getDefaultMaxIterations());
-        log.info("[HermesSmartAction] 节点 {} 开始执行, maxIterations={}", nodeId, maxIterations);
+        JsonNode outputsDecl = data.path("outputs");
+        boolean structured = StructuredOutputHelper.needsStructuredOutput(data);
+
+        // 结构化模式:把 JSON 输出指令追加到 actionPrompt 末尾,让 Hermes 首次输出尽量就是合法 JSON
+        String fullActionPrompt = actionPrompt;
+        if (structured) {
+            fullActionPrompt = actionPrompt + StructuredOutputHelper.buildInstruction(outputsDecl, null);
+        }
+
+        log.info("[HermesSmartAction] 节点 {} 开始执行, maxIterations={}, structured={}",
+                nodeId, maxIterations, structured);
 
         try {
-            String hermesHome = context.getWorkingDir() != null
-                    ? context.getWorkingDir().resolve(".hermes").toString()
-                    : null;
+            // hermesHome 传 null:由 HermesBridgeClient 回落到固定 HERMES_HOME(hermes.bridge.home)
             String workingDir = context.getWorkingDir() != null ? context.getWorkingDir().toString() : null;
             String sessionId = context.getRunId() + "_" + nodeId;
             Map<String, String> modelConfig = resolveModelConfig(data);
             // 把当前节点明确定义的输入变量作为系统提示注入 Hermes Bridge(原 null 不再使用)
             String contextSystemPrompt = ContextPromptHelper.buildInputContextSystemPrompt(data, workspace);
             HermesRunResult runResult = bridgeClient.run(contextSystemPrompt.isEmpty() ? null : contextSystemPrompt,
-                    actionPrompt, maxIterations,
-                    hermesHome, workingDir, nodeId, context.getStreamSink(), sessionId, modelConfig);
+                    fullActionPrompt, maxIterations,
+                    null, workingDir, nodeId, context.getStreamSink(), sessionId, modelConfig);
 
             if (runResult.getFinalText() == null || runResult.getFinalText().isBlank()) {
-                return NodeExecutionResult.success(nodeId,
-                        NodeOutputEnvelope.failure("Hermes 智能操作返回空结果").toMap(), runResult.getLogs());
+                // C: 返回空结果视为业务失败
+                return NodeExecutionResult.failed(nodeId, "Hermes 智能操作返回空结果", runResult.getLogs());
             }
 
             log.info("[HermesSmartAction] 节点 {} 完成, 结果长度={}, 日志数={}",
                     nodeId, runResult.getFinalText().length(), runResult.getLogs().size());
+
+            // 结构化模式:交给 extractWithRetryFromText 处理
+            // - 内层用廉价 reformatClient 整理 JSON(首选)
+            // - 内层耗尽时由 supplier 复用 sessionId 让 Hermes 重跑(cost 高,保底)
+            if (structured) {
+                ChatClient reformatClient = resolveReformatChatClient(data);
+                RetryPolicy policy = RetryPolicy.forHermes(data, workflowProperties);
+
+                Supplier<String> runtimeRetrySupplier = () -> {
+                    try {
+                        String retryMsg = "上次响应未通过结构化校验,请基于 outputs 声明严格重新生成纯 JSON,"
+                                + "仅输出 JSON 本身,不要任何额外文字。";
+                        HermesRunResult r = bridgeClient.run(
+                                contextSystemPrompt.isEmpty() ? null : contextSystemPrompt,
+                                retryMsg, maxIterations,
+                                null, workingDir, nodeId, context.getStreamSink(), sessionId, modelConfig);
+                        return r == null ? "" : r.getFinalText();
+                    } catch (Exception ex) {
+                        throw new RuntimeException("Hermes 重跑失败: " + ex.getMessage(), ex);
+                    }
+                };
+
+                StructuredOutputHelper.ExtractionResult ext =
+                        StructuredOutputHelper.extractWithRetryFromText(
+                                runResult.getFinalText(), outputsDecl, policy, reformatClient, runtimeRetrySupplier);
+
+                if (ext.errorMessage != null) {
+                    log.warn("[HermesSmartAction] 节点 {} 结构化输出失败(累计 {} 次调用): {}",
+                            nodeId, ext.attempts, ext.errorMessage);
+                    return NodeExecutionResult.failed(nodeId,
+                            "Hermes 智能操作结构化输出失败: " + ext.errorMessage, runResult.getLogs());
+                }
+
+                String msg = "Hermes 智能操作执行成功" + (ext.attempts > 1 ? "(重试 " + (ext.attempts - 1) + " 次)" : "");
+                log.info("[HermesSmartAction] 节点 {} 结构化输出完成, 字段数={}, attempts={}",
+                        nodeId, ext.data.size(), ext.attempts);
+                return NodeExecutionResult.success(nodeId,
+                        NodeOutputEnvelope.success(msg, ext.data).toMap(), runResult.getLogs());
+            }
+
+            // 单输出降级模式:未声明 outputs,按默认变量名写一个字段
             String varName = NodeTypeUtils.resolveOutputVarName(data, "result");
             java.util.Map<String, Object> dataMap = new java.util.LinkedHashMap<>();
             dataMap.put(varName, runResult.getFinalText());
@@ -82,8 +140,8 @@ public class HermesSmartActionExecutor implements NodeExecutor {
 
         } catch (Exception e) {
             log.error("[HermesSmartAction] 节点 {} 调用失败: {}", nodeId, e.getMessage());
-            return NodeExecutionResult.success(nodeId,
-                    NodeOutputEnvelope.failure("Hermes 智能操作调用失败: " + e.getMessage()).toMap());
+            // C: 外层状态与业务结果对齐 —— 异常一律 FAILED
+            return NodeExecutionResult.failed(nodeId, "Hermes 智能操作调用失败: " + e.getMessage());
         }
     }
 
@@ -126,4 +184,37 @@ public class HermesSmartActionExecutor implements NodeExecutor {
                 "modelName", model.getModelName()
         );
     }
+
+    /**
+     * 解析结构化输出重组用的 ChatClient(内层 reformat 专用,廉价 LLM)。
+     *
+     * <p>优先级:</p>
+     * <ol>
+     *   <li>节点 {@code data.reformatModelId}(用户指定的廉价重组模型)</li>
+     *   <li>节点 {@code data.modelId}(与推理模型共用)</li>
+     *   <li>UI 当前默认模型</li>
+     *   <li>应用启动时注入的 fallbackBuilder</li>
+     * </ol>
+     */
+    private ChatClient resolveReformatChatClient(JsonNode data) {
+        long reformatModelId = data.path("reformatModelId").asLong(0);
+        if (reformatModelId > 0) {
+            try {
+                return aiModelService.getChatClient(reformatModelId);
+            } catch (Exception e) {
+                log.warn("[HermesSmartAction] 获取重组模型 {} 失败,回退到推理模型: {}",
+                        reformatModelId, e.getMessage());
+            }
+        }
+        long modelId = data.path("modelId").asLong(0);
+        if (modelId > 0) {
+            try {
+                return aiModelService.getChatClient(modelId);
+            } catch (Exception e) {
+                log.warn("[HermesSmartAction] 获取推理模型 {} 作为重组模型失败,回退到默认模型: {}",
+                        modelId, e.getMessage());
+            }
+        }
+        return aiModelService.getChatClientOrFallback(fallbackBuilder);
+    }
 }

+ 40 - 47
backend/src/main/java/com/agent/management/engine/executor/OutputExecutor.java

@@ -3,10 +3,13 @@ package com.agent.management.engine.executor;
 import com.agent.management.engine.AgentIgnoreFilter;
 import com.agent.management.engine.NodeExecutionResult;
 import com.agent.management.engine.NodeExecutor;
+import com.agent.management.engine.NodeInputResolver;
 import com.agent.management.engine.NodeOutputEnvelope;
 import com.agent.management.engine.NodeWorkspace;
 import com.agent.management.engine.WorkflowContext;
 import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
@@ -14,7 +17,6 @@ import org.springframework.stereotype.Component;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.ArrayList;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
@@ -22,13 +24,17 @@ import java.util.stream.Collectors;
 
 /**
  * 输出节点执行器
- * 收集上下文中的所有节点输出和工作目录中的文件列表,作为工作流最终结果。
  *
- * <p>每个节点的输出以 {@link NodeOutputEnvelope} 形式存储,本执行器:</p>
+ * <p>语义:输出节点按画布声明的 {@code data.fields[]} 进行投影输出,每个 field 既是从前驱取值的"输入变量",
+ * 也是写入最终结果的"输出变量"。投影逻辑复用 {@link NodeInputResolver},享受与 LLM/Agent/Skill 节点
+ * 一致的能力:显式 mapping 优先 + 四级隐式回退(精确同名 → 忽略 -_/大小写 → JSON 路径精确 → JSON 路径模糊),
+ * 以及按声明 type 的类型转换。</p>
+ *
  * <ul>
- *   <li>聚合所有 envelope 的 {@code data} 字段为扁平结果</li>
- *   <li>跟踪 worst status(任一节点失败则整体失败)</li>
- *   <li>附加工作目录文件列表与 runId</li>
+ *   <li>{@code data.fields[]} 每一项作为输入字段解析:field.name 同时作为输出 key(裸名,不带 predId__ 前缀)</li>
+ *   <li>未声明 fields:返回空 data(用户决策:严格语义)</li>
+ *   <li>required 字段缺失:返回 failure envelope</li>
+ *   <li>{@code _workingDirFiles} / {@code _runId} 作为元数据额外注入(用户决策:全部保留)</li>
  * </ul>
  */
 @Slf4j
@@ -36,6 +42,8 @@ import java.util.stream.Collectors;
 @RequiredArgsConstructor
 public class OutputExecutor implements NodeExecutor {
 
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
     private final AgentIgnoreFilter agentIgnoreFilter;
 
     @Override
@@ -47,33 +55,33 @@ public class OutputExecutor implements NodeExecutor {
     public NodeExecutionResult execute(String nodeId, JsonNode data, WorkflowContext context, NodeWorkspace workspace) {
         String outputType = data.path("outputType").asText("text");
 
-        Map<String, Object> aggregatedData = new LinkedHashMap<>();
-        int worstStatus = NodeOutputEnvelope.STATUS_SUCCESS;
-        List<String> failureMessages = new ArrayList<>();
+        // 按 data.fields 投影:把 fields 当作 inputs 解析(复用 NodeInputResolver 的 mapping + 四级模糊匹配 + 类型转换)
+        Map<String, Object> projectedData = new LinkedHashMap<>();
+        JsonNode fieldsNode = data.path("fields");
+        if (fieldsNode.isArray() && fieldsNode.size() > 0) {
+            // 包装成 {inputs: [...fields]} 复用 NodeInputResolver 的解析路径
+            ObjectNode fieldsAsInputs = MAPPER.createObjectNode();
+            fieldsAsInputs.set("inputs", fieldsNode);
+            NodeInputResolver.ResolvedInputs resolved =
+                    NodeInputResolver.resolveInputs(fieldsAsInputs, workspace);
 
-        // 初始运行输入扁平合并到结果(向后兼容外部 API 消费方)
-        aggregatedData.putAll(context.getInitialInputs());
+            if (resolved.hasMissing()) {
+                String msg = "输出节点缺少必填字段:" + String.join("、", resolved.getMissing());
+                log.warn("[Output] 节点 {} 必填校验失败:{}", nodeId, resolved.getMissing());
+                return NodeExecutionResult.success(nodeId,
+                        NodeOutputEnvelope.failure(msg).toMap());
+            }
 
-        // 聚合所有节点 envelope.data,跟踪 worst status
-        for (Map.Entry<String, Map<String, Object>> entry : context.getAllNodeScopedOutputs().entrySet()) {
-            String predId = entry.getKey();
-            Map<String, Object> raw = entry.getValue();
-            NodeOutputEnvelope env = NodeOutputEnvelope.fromObject(raw);
-            if (env != null) {
-                if (env.isFailure()) {
-                    worstStatus = NodeOutputEnvelope.STATUS_FAILED;
-                    failureMessages.add("[" + predId + "] " + env.getMessage());
-                }
-                if (env.getData() != null && !env.getData().isEmpty()) {
-                    mergePrefixed(aggregatedData, predId, env.getData());
-                }
-            } else if (raw != null && !raw.isEmpty()) {
-                // legacy 兼容:非 envelope 的扁平 Map 直接合并
-                mergePrefixed(aggregatedData, predId, raw);
+            if (!resolved.getWarnings().isEmpty()) {
+                resolved.getWarnings().forEach(w ->
+                        log.warn("[Output] 节点 {} 输入解析警告: {}", nodeId, w));
             }
+
+            projectedData.putAll(resolved.getValues());
         }
+        // 未声明 fields:projectedData 保持为空(严格语义)
 
-        // 收集工作目录中的文件列表(应用 .agentignore 过滤)
+        // 元数据注入(用户决策:全部保留)
         Path workingDir = context.getWorkingDir();
         if (workingDir != null && Files.isDirectory(workingDir)) {
             try {
@@ -82,32 +90,17 @@ public class OutputExecutor implements NodeExecutor {
                         .map(p -> workingDir.relativize(p).toString().replace('\\', '/'))
                         .collect(Collectors.toList());
                 if (!files.isEmpty()) {
-                    aggregatedData.put("_workingDirFiles", files);
-                    aggregatedData.put("_runId", context.getRunId());
+                    projectedData.put("_workingDirFiles", files);
                 }
             } catch (IOException e) {
                 log.warn("[Output] 扫描工作目录失败: {}", e.getMessage());
             }
         }
+        projectedData.put("_runId", context.getRunId());
 
-        String message = worstStatus == NodeOutputEnvelope.STATUS_SUCCESS
-                ? "工作流执行成功"
-                : "部分节点失败:" + String.join("; ", failureMessages);
-
-        log.info("[Output] 节点 {} 输出类型={}, 变量数={}, status={}",
-                nodeId, outputType, aggregatedData.size(), worstStatus);
+        log.info("[Output] 节点 {} 输出类型={}, 变量数={}", nodeId, outputType, projectedData.size());
         return NodeExecutionResult.success(nodeId,
-                NodeOutputEnvelope.of(worstStatus, message, aggregatedData).toMap());
-    }
-
-    /**
-     * 把 predecessorId 命名空间下的 data 合并到目标 Map。
-     * Key 形式:{predecessorId}__{varName}(与 WorkflowContext.snapshotAllOutputs 保持一致)。
-     */
-    private static void mergePrefixed(Map<String, Object> target, String predecessorId, Map<String, Object> data) {
-        for (Map.Entry<String, Object> e : data.entrySet()) {
-            target.put(predecessorId + "__" + e.getKey(), e.getValue());
-        }
+                NodeOutputEnvelope.success("工作流执行成功", projectedData).toMap());
     }
 
     /**

+ 82 - 7
backend/src/main/java/com/agent/management/engine/hermes/HermesBridgeClient.java

@@ -4,11 +4,13 @@ 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;
 
@@ -46,8 +48,26 @@ public class HermesBridgeClient {
     private final HermesProperties properties;
     private final ObjectMapper objectMapper = new ObjectMapper();
 
-    public HermesBridgeClient(HermesProperties properties) {
+    /**
+     * 错误响应嗅探服务(方案 B)。
+     *
+     * <p>使用 {@link Autowired#required(boolean)} 标记为非必需:
+     * 测试场景或 Hermes 关闭场景下允许为 null,{@code #parseSseResponse} 会跳过嗅探。</p>
+     */
+    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);
     }
 
     /**
@@ -126,17 +146,21 @@ public class HermesBridgeClient {
                                 Map<String, String> modelConfig) throws Exception {
         String url = "http://127.0.0.1:" + properties.getBridge().getPort() + "/run";
 
+        // 未显式指定时回落到固定 HERMES_HOME(hermes.bridge.home),
+        // 保证 Bridge 侧 agent 池缓存键与技能扫描目录稳定一致
+        String effectiveHermesHome = (hermesHome != null && !hermesHome.isBlank())
+                ? hermesHome
+                : properties.getBridge().getResolvedHome().toString();
+
         Map<String, Object> body = new ConcurrentHashMap<>();
         body.put("user_message", userMessage);
         body.put("max_iterations", maxIterations);
         // 注入「可用技能列表 + 高效执行策略」前置 prompt,减少 LLM 猜名失败与上下文膨胀
-        String enhancedSystemPrompt = buildEnhancedSystemPrompt(hermesHome, systemPrompt);
+        String enhancedSystemPrompt = buildEnhancedSystemPrompt(effectiveHermesHome, systemPrompt);
         if (enhancedSystemPrompt != null && !enhancedSystemPrompt.isBlank()) {
             body.put("system_prompt", enhancedSystemPrompt);
         }
-        if (hermesHome != null && !hermesHome.isBlank()) {
-            body.put("hermes_home", hermesHome);
-        }
+        body.put("hermes_home", effectiveHermesHome);
         if (workingDir != null && !workingDir.isBlank()) {
             body.put("working_dir", workingDir);
         }
@@ -293,7 +317,23 @@ public class HermesBridgeClient {
             }
         }
 
-        return new HermesRunResult(finalResponse.toString(), logs);
+        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) {
@@ -318,6 +358,12 @@ public class HermesBridgeClient {
     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" +
@@ -340,7 +386,8 @@ public class HermesBridgeClient {
      */
     private String buildEnhancedSystemPrompt(String hermesHome, String basePrompt) {
         String skillsList = scanAvailableSkills(hermesHome);
-        String efficiencySection = String.format(EFFICIENCY_PROMPT_TEMPLATE, skillsList);
+        String skillsRoot = Paths.get(hermesHome, "skills").toString();
+        String efficiencySection = String.format(EFFICIENCY_PROMPT_TEMPLATE, skillsList, skillsRoot);
 
         if (basePrompt == null || basePrompt.isBlank()) {
             return efficiencySection;
@@ -379,4 +426,32 @@ public class HermesBridgeClient {
         }
         return String.join(", ", names);
     }
+
+    /**
+     * 通知 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());
+        }
+    }
 }

+ 26 - 0
backend/src/main/java/com/agent/management/engine/hermes/HermesErrorResponseException.java

@@ -0,0 +1,26 @@
+package com.agent.management.engine.hermes;
+
+/**
+ * Hermes Bridge 返回的 {@code done} 事件内容被嗅探为错误响应时抛出。
+ *
+ * <p>区别于 {@code RuntimeException}:</p>
+ * <ul>
+ *   <li>{@code HermesBridgeClient} 在 SSE done 分支检测到 finalText 命中错误模式时抛本异常</li>
+ *   <li>{@code HermesAgentExecutor} / {@code HermesSmartActionExecutor} 的 catch 块统一捕获 Exception
+ *       并转化为 {@code NodeExecutionResult.failed},{@code getMessage()} 已包含命中模式与原始响应摘要</li>
+ *   <li>后续若需要区分"限流可重试"与"认证不可重试",可基于本异常的 {@code matchedPatternName} 做精细化策略</li>
+ * </ul>
+ */
+public class HermesErrorResponseException extends RuntimeException {
+
+    private final String matchedPatternName;
+
+    public HermesErrorResponseException(String message, String matchedPatternName) {
+        super(message);
+        this.matchedPatternName = matchedPatternName;
+    }
+
+    public String getMatchedPatternName() {
+        return matchedPatternName;
+    }
+}

+ 5 - 1
backend/src/main/java/com/agent/management/engine/hermes/HermesProcessManager.java

@@ -66,11 +66,15 @@ public class HermesProcessManager {
             String scriptPath = resolveScriptPath();
             String pythonPath = properties.getBridge().getPythonPath();
             int port = properties.getBridge().getPort();
+            // 固定 HERMES_HOME:进程生命周期内不变,保证 hermes-agent 的
+            // SKILLS_DIR 模块级常量(首次 import 时冻结)始终指向该目录
+            String hermesHome = properties.getBridge().getResolvedHome().toString();
 
             ProcessBuilder pb = new ProcessBuilder(
                     pythonPath, scriptPath,
                     "--port", String.valueOf(port),
-                    "--host", "127.0.0.1"
+                    "--host", "127.0.0.1",
+                    "--hermes-home", hermesHome
             );
             pb.redirectErrorStream(true);
 

+ 75 - 0
backend/src/main/java/com/agent/management/engine/hermes/HermesSkillSyncService.java

@@ -0,0 +1,75 @@
+package com.agent.management.engine.hermes;
+
+import com.agent.management.config.HermesProperties;
+import com.agent.management.engine.SkillFileSyncer;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.ObjectProvider;
+import org.springframework.stereotype.Component;
+
+import java.nio.file.Path;
+
+/**
+ * Hermes 技能写时同步服务。
+ *
+ * <p>固定 HERMES_HOME 架构:Bridge 子进程启动时通过 --hermes-home 指向固定目录
+ * ({@code hermes.bridge.home},默认 ./data/hermes-home),Java 侧在技能发生
+ * 新增/更新/删除/回滚时调用 {@link #syncAfterWrite()},将 uploads/skills
+ * 精确镜像到 {home}/skills,并通知 Bridge 清空技能索引缓存与 Agent 池,
+ * 使 skill_view / skills_list 立即可见最新技能。</p>
+ *
+ * <p>同步失败仅记录日志,不阻断技能 CRUD 主流程。</p>
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class HermesSkillSyncService {
+
+    private final HermesProperties hermesProperties;
+    private final SkillFileSyncer skillFileSyncer;
+    private final ObjectProvider<HermesBridgeClient> bridgeClientProvider;
+
+    /**
+     * 应用启动时做一次全量同步,保证固定 HERMES_HOME 与 uploads/skills 一致
+     * (覆盖 Bridge 重启、上次关闭期间的离线改动等场景)。
+     */
+    @PostConstruct
+    public void initialSync() {
+        if (!hermesProperties.isEnabled()) {
+            return;
+        }
+        try {
+            int synced = skillFileSyncer.syncTo(skillsDir());
+            log.info("[HermesSkillSync] 启动同步完成: {} 个 skill -> {}", synced, skillsDir());
+        } catch (Exception e) {
+            log.error("[HermesSkillSync] 启动同步失败(运行期写时同步仍会重试): {}", e.getMessage(), e);
+        }
+    }
+
+    /**
+     * 技能写操作(新增/更新/删除/回滚/生成)后的同步入口。
+     * 幂等:全量镜像同步 + Bridge 缓存失效。失败不抛异常。
+     */
+    public void syncAfterWrite() {
+        if (!hermesProperties.isEnabled()) {
+            return;
+        }
+        try {
+            int synced = skillFileSyncer.syncTo(skillsDir());
+            log.info("[HermesSkillSync] 写时同步完成: {} 个 skill", synced);
+        } catch (Exception e) {
+            log.warn("[HermesSkillSync] 写时同步失败: {}", e.getMessage(), e);
+            return;
+        }
+        HermesBridgeClient client = bridgeClientProvider.getIfAvailable();
+        if (client != null) {
+            client.invalidateSkillsCache();
+        }
+    }
+
+    /** 固定 HERMES_HOME 下的 skills 目录 */
+    private Path skillsDir() {
+        return hermesProperties.getBridge().getResolvedHome().resolve("skills");
+    }
+}

+ 51 - 0
backend/src/main/java/com/agent/management/model/entity/HermesErrorPattern.java

@@ -0,0 +1,51 @@
+package com.agent.management.model.entity;
+
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+/**
+ * Hermes Agent 响应错误嗅探模式。
+ *
+ * <p>Hermes Bridge(Python)在底层 LLM 调用失败时(HTTP 429 / 鉴权失败 / 配额耗尽等),
+ * 会把错误描述作为 {@code final_response} 字符串返回。Java 端 {@code HermesBridgeClient}
+ * 通过本模式列表对 {@code finalText} 做子串匹配(大小写不敏感),命中即视为业务失败,
+ * 抛 {@code HermesErrorResponseException},由 {@code HermesAgentExecutor} /
+ * {@code HermesSmartActionExecutor} 转化为 {@code NodeExecutionResult.failed}。</p>
+ *
+ * <p>匹配规则:{@code finalText.toLowerCase().contains(pattern.toLowerCase())}。
+ * 简单稳定,避免正则转义陷阱;如需更复杂匹配未来可在 pattern 增加前缀区分。</p>
+ */
+@Data
+public class HermesErrorPattern {
+
+    private Long id;
+
+    /** 显示名称(如 "LLM 429 限流"),用于前端展示与日志回显 */
+    private String name;
+
+    /** 匹配文本(子串、大小写不敏感) */
+    private String pattern;
+
+    /** 是否启用 */
+    private Boolean enabled = true;
+
+    /** 内置标记:true 表示系统内置,前端禁止删除(允许禁用与编辑) */
+    private Boolean isBuiltIn = false;
+
+    /** 可选说明(如触发场景、影响范围) */
+    private String description;
+
+    private LocalDateTime createdAt;
+
+    private LocalDateTime updatedAt;
+
+    public void onCreate() {
+        createdAt = LocalDateTime.now();
+        updatedAt = LocalDateTime.now();
+    }
+
+    public void onUpdate() {
+        updatedAt = LocalDateTime.now();
+    }
+}

+ 46 - 0
backend/src/main/java/com/agent/management/repository/HermesErrorPatternRepository.java

@@ -0,0 +1,46 @@
+package com.agent.management.repository;
+
+import com.agent.management.model.entity.HermesErrorPattern;
+import com.agent.management.repository.json.AbstractJsonRepository;
+import com.agent.management.repository.json.JsonStore;
+import com.fasterxml.jackson.core.type.TypeReference;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Hermes 错误嗅探模式仓库(JSON 文件实现)。
+ *
+ * <p>数据文件:{@code data/json/hermes-error-patterns.json}</p>
+ */
+@Component
+public class HermesErrorPatternRepository extends AbstractJsonRepository<HermesErrorPattern> {
+
+    public HermesErrorPatternRepository(JsonStore store) {
+        super(store, new TypeReference<>() {});
+    }
+
+    @Override
+    protected String getFileName() {
+        return "hermes-error-patterns.json";
+    }
+
+    @Override
+    protected Long getId(HermesErrorPattern entity) {
+        return entity.getId();
+    }
+
+    @Override
+    protected void setId(HermesErrorPattern entity, Long id) {
+        entity.setId(id);
+    }
+
+    public List<HermesErrorPattern> findAllByEnabledTrue() {
+        return findAll(p -> Boolean.TRUE.equals(p.getEnabled()));
+    }
+
+    public Optional<HermesErrorPattern> findByName(String name) {
+        return findAll().stream().filter(p -> name.equals(p.getName())).findFirst();
+    }
+}

+ 61 - 0
backend/src/main/java/com/agent/management/service/HermesErrorPatternService.java

@@ -0,0 +1,61 @@
+package com.agent.management.service;
+
+import com.agent.management.model.entity.HermesErrorPattern;
+
+import java.util.List;
+
+/**
+ * Hermes 错误嗅探模式服务。
+ *
+ * <p>提供 CRUD 管理、内置模式初始化,以及运行时错误响应检测能力。</p>
+ */
+public interface HermesErrorPatternService {
+
+    /** 列出全部模式(按 createdAt 升序,内置在前) */
+    List<HermesErrorPattern> list();
+
+    /** 创建新模式 */
+    HermesErrorPattern create(HermesErrorPattern req);
+
+    /** 更新已有模式(内置模式允许编辑 pattern/enabled/description,但禁止改名) */
+    HermesErrorPattern update(Long id, HermesErrorPattern req);
+
+    /** 删除模式(内置模式禁止删除,抛 BusinessException) */
+    void delete(Long id);
+
+    /** 启用/禁用切换 */
+    HermesErrorPattern toggle(Long id);
+
+    /** 重置为默认:清空自定义,恢复内置 */
+    void resetToDefaults();
+
+    /**
+     * 错误响应检测:扫描 finalText 是否命中任一启用的模式。
+     *
+     * <p>命中返回 {@code matchedPatternName};不命中返回 {@code matchedPatternName=null}。
+     * finalText 为 null/blank 时直接返回不命中。</p>
+     */
+    ErrorDetectionResult isErrorResponse(String finalText);
+
+    /** 检测结果 */
+    final class ErrorDetectionResult {
+        private final boolean error;
+        private final String matchedPatternName;
+
+        public ErrorDetectionResult(boolean error, String matchedPatternName) {
+            this.error = error;
+            this.matchedPatternName = matchedPatternName;
+        }
+
+        public boolean isError() { return error; }
+        public String getMatchedPatternName() { return matchedPatternName; }
+
+        public static ErrorDetectionResult notError() {
+            return new ErrorDetectionResult(false, null);
+        }
+
+        public static ErrorDetectionResult of(String matchedPatternName) {
+            return new ErrorDetectionResult(true, matchedPatternName);
+        }
+    }
+}

+ 188 - 0
backend/src/main/java/com/agent/management/service/impl/HermesErrorPatternServiceImpl.java

@@ -0,0 +1,188 @@
+package com.agent.management.service.impl;
+
+import com.agent.management.common.exception.BusinessException;
+import com.agent.management.model.entity.HermesErrorPattern;
+import com.agent.management.repository.HermesErrorPatternRepository;
+import com.agent.management.service.HermesErrorPatternService;
+import jakarta.annotation.PostConstruct;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+
+/**
+ * Hermes 错误嗅探模式服务实现。
+ *
+ * <p>启动时若数据文件不存在或为空,自动写入内置默认模式({@link #initBuiltInPatterns})。
+ * 用户自定义后不被覆盖。</p>
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class HermesErrorPatternServiceImpl implements HermesErrorPatternService {
+
+    private final HermesErrorPatternRepository repository;
+
+    /**
+     * 嗅探扫描范围:只检查 finalText 的前 N 个字符。
+     *
+     * <p>Hermes 的错误描述通常出现在 finalText 开头(AIAgent.run() 把错误作为 final_response 返回),
+     * 限制扫描范围可避免对超长正常回复做无效 contains。500 字符覆盖所有已知错误前缀。</p>
+     */
+    private static final int SCAN_LIMIT = 500;
+
+    @PostConstruct
+    public void initBuiltInPatterns() {
+        List<HermesErrorPattern> existing = repository.findAll();
+        if (!existing.isEmpty()) {
+            log.info("[HermesErrorPattern] 已存在 {} 条模式,跳过内置初始化", existing.size());
+            return;
+        }
+        List<HermesErrorPattern> builtIn = new ArrayList<>();
+        builtIn.add(builtIn("LLM SDK 重试耗尽", "API call failed after",
+                "OpenAI SDK 重试耗尽仍未成功,通常是限流或下游故障"));
+        builtIn.add(builtIn("HTTP 4xx 客户端错误", "HTTP 4",
+                "LLM 服务返回 4xx(含 429 限流、401 鉴权、400 参数等)"));
+        builtIn.add(builtIn("HTTP 5xx 服务端错误", "HTTP 5",
+                "LLM 服务返回 5xx,服务端临时故障"));
+        builtIn.add(builtIn("Rate Limit(英文)", "rate limit",
+                "OpenAI 兼容服务的标准限流错误"));
+        builtIn.add(builtIn("智谱限流(具体)", "该模型当前访问量过大",
+                "智谱 AI glm 系列特定限流描述"));
+        builtIn.add(builtIn("额度 / 配额", "quota",
+                "额度耗尽类错误(中英文 quota 关键词)"));
+        builtIn.add(builtIn("认证失败(英文)", "invalid api key",
+                "API Key 无效或被吊销"));
+        builtIn.add(builtIn("认证失败(中文)", "无效的 api",
+                "中文鉴权失败描述(兼容智谱等中文错误消息)"));
+        repository.saveAll(builtIn);
+        log.info("[HermesErrorPattern] 已写入 {} 条内置默认模式", builtIn.size());
+    }
+
+    private HermesErrorPattern builtIn(String name, String pattern, String description) {
+        HermesErrorPattern p = new HermesErrorPattern();
+        p.setName(name);
+        p.setPattern(pattern);
+        p.setDescription(description);
+        p.setEnabled(true);
+        p.setIsBuiltIn(true);
+        p.onCreate();
+        return p;
+    }
+
+    @Override
+    public List<HermesErrorPattern> list() {
+        List<HermesErrorPattern> all = new ArrayList<>(repository.findAll());
+        // 内置在前,然后按 createdAt 升序
+        all.sort(Comparator
+                .comparing((HermesErrorPattern p) -> Boolean.TRUE.equals(p.getIsBuiltIn()) ? 0 : 1)
+                .thenComparing(p -> p.getCreatedAt() == null ? java.time.LocalDateTime.MAX : p.getCreatedAt()));
+        return all;
+    }
+
+    @Override
+    public HermesErrorPattern create(HermesErrorPattern req) {
+        validatePattern(req);
+        // 名称唯一
+        if (repository.findByName(req.getName()).isPresent()) {
+            throw new BusinessException("模式名称已存在: " + req.getName());
+        }
+        HermesErrorPattern entity = new HermesErrorPattern();
+        entity.setName(req.getName());
+        entity.setPattern(req.getPattern());
+        entity.setDescription(req.getDescription());
+        entity.setEnabled(req.getEnabled() == null ? true : req.getEnabled());
+        entity.setIsBuiltIn(false);  // 用户新建一律不是内置
+        entity.onCreate();
+        return repository.save(entity);
+    }
+
+    @Override
+    public HermesErrorPattern update(Long id, HermesErrorPattern req) {
+        HermesErrorPattern existing = repository.findById(id)
+                .orElseThrow(() -> new BusinessException("模式不存在: " + id));
+        validatePattern(req);
+        // 名称冲突(与其他模式重名)
+        repository.findByName(req.getName()).ifPresent(other -> {
+            if (!other.getId().equals(id)) {
+                throw new BusinessException("模式名称已存在: " + req.getName());
+            }
+        });
+        existing.setPattern(req.getPattern());
+        existing.setDescription(req.getDescription());
+        if (req.getEnabled() != null) {
+            existing.setEnabled(req.getEnabled());
+        }
+        // 内置模式禁止改名(保持内置标识稳定)
+        if (!Boolean.TRUE.equals(existing.getIsBuiltIn())) {
+            existing.setName(req.getName());
+        }
+        existing.onUpdate();
+        return repository.save(existing);
+    }
+
+    @Override
+    public void delete(Long id) {
+        HermesErrorPattern existing = repository.findById(id)
+                .orElseThrow(() -> new BusinessException("模式不存在: " + id));
+        if (Boolean.TRUE.equals(existing.getIsBuiltIn())) {
+            throw new BusinessException("内置模式不可删除,可改为禁用");
+        }
+        repository.deleteById(id);
+    }
+
+    @Override
+    public HermesErrorPattern toggle(Long id) {
+        HermesErrorPattern existing = repository.findById(id)
+                .orElseThrow(() -> new BusinessException("模式不存在: " + id));
+        existing.setEnabled(!Boolean.TRUE.equals(existing.getEnabled()));
+        existing.onUpdate();
+        return repository.save(existing);
+    }
+
+    @Override
+    public void resetToDefaults() {
+        // 保留内置,删除自定义
+        repository.deleteAll(p -> !Boolean.TRUE.equals(p.getIsBuiltIn()));
+        // 重启内置状态为启用(避免用户禁用了内置后无法恢复)
+        for (HermesErrorPattern p : repository.findAll()) {
+            if (Boolean.TRUE.equals(p.getIsBuiltIn()) && !Boolean.TRUE.equals(p.getEnabled())) {
+                p.setEnabled(true);
+                p.onUpdate();
+                repository.save(p);
+            }
+        }
+    }
+
+    @Override
+    public ErrorDetectionResult isErrorResponse(String finalText) {
+        if (finalText == null || finalText.isBlank()) {
+            return ErrorDetectionResult.notError();
+        }
+        String haystack = finalText.length() > SCAN_LIMIT
+                ? finalText.substring(0, SCAN_LIMIT)
+                : finalText;
+        String haystackLower = haystack.toLowerCase();
+        for (HermesErrorPattern p : repository.findAllByEnabledTrue()) {
+            String needle = p.getPattern();
+            if (needle == null || needle.isBlank()) continue;
+            if (haystackLower.contains(needle.toLowerCase())) {
+                return ErrorDetectionResult.of(p.getName());
+            }
+        }
+        return ErrorDetectionResult.notError();
+    }
+
+    private void validatePattern(HermesErrorPattern req) {
+        if (req == null) throw new BusinessException("请求体为空");
+        if (req.getName() == null || req.getName().trim().isEmpty()) {
+            throw new BusinessException("模式名称不能为空");
+        }
+        if (req.getPattern() == null || req.getPattern().trim().isEmpty()) {
+            throw new BusinessException("匹配模式不能为空");
+        }
+    }
+}

+ 15 - 18
backend/src/main/java/com/agent/management/service/impl/SkillGenerationServiceImpl.java

@@ -4,9 +4,9 @@ import com.agent.management.common.exception.BusinessException;
 import com.agent.management.config.HermesProperties;
 import com.agent.management.config.SkillProperties;
 import com.agent.management.engine.NodeStreamSink;
-import com.agent.management.engine.SkillFileSyncer;
 import com.agent.management.engine.WorkflowRunDirManager;
 import com.agent.management.engine.hermes.HermesBridgeClient;
+import com.agent.management.engine.hermes.HermesSkillSyncService;
 import com.agent.management.external.SseEventBus;
 import com.agent.management.model.dto.SkillDTO;
 import com.agent.management.model.dto.SkillGenerationRequest;
@@ -47,11 +47,11 @@ import java.util.stream.Stream;
  * 核心流程:
  * 1. 校验源技能存在且 canGenerateSkill=true
  * 2. 创建临时工作目录({run-dir-base}/_skill_gen/{runId})
- * 3. 同步全局 skills 到 {workingDir}/.hermes/skills,保证源技能可在 HERMES_HOME 中被加载
- * 4. 调用 HermesBridgeClient.run,sink 回调通过 SseEventBus 推送给订阅者
- * 5. 扫描工作目录(排除 .hermes),定位生成器写入的新技能目录
- * 6. 将新技能目录复制到全局 skills 根目录下
- * 7. 通过 workflow_complete / workflow_error 终止 SSE 流
+ * 3. 调用 HermesBridgeClient.run(hermesHome 传 null,回落到固定 HERMES_HOME),
+ *    sink 回调通过 SseEventBus 推送给订阅者
+ * 4. 扫描工作目录(排除隐藏目录),定位生成器写入的新技能目录
+ * 5. 将新技能目录复制到全局 skills 根目录下,并触发写时同步到固定 HERMES_HOME
+ * 6. 通过 workflow_complete / workflow_error 终止 SSE 流
  */
 @Slf4j
 @Service
@@ -63,7 +63,7 @@ public class SkillGenerationServiceImpl implements SkillGenerationService {
     private static final long MAX_EXECUTION_SECONDS = 600L;  // 10 分钟
 
     private final SkillService skillService;
-    private final SkillFileSyncer skillFileSyncer;
+    private final HermesSkillSyncService hermesSkillSyncService;
     private final SkillProperties skillProperties;
     private final WorkflowRunDirManager runDirManager;
     private final HermesProperties hermesProperties;
@@ -132,19 +132,13 @@ public class SkillGenerationServiceImpl implements SkillGenerationService {
             state.setWorkingDir(workingDir);
             log.info("[SkillGen] runId={}, workingDir={}", runId, workingDir);
 
-            // 2. 同步全局 skills 到 {workingDir}/.hermes/skills
-            Path hermesHome = workingDir.resolve(".hermes");
-            Path targetSkillsDir = hermesHome.resolve("skills");
-            int synced = skillFileSyncer.syncTo(targetSkillsDir);
-            log.info("[SkillGen] runId={}, 已同步 {} 个 skills", runId, synced);
-
-            // 3. 系统提示词固定为中文指令(不再注入源技能正文)
+            // 2. 系统提示词固定为中文指令(不再注入源技能正文)
             String systemPrompt = "使用简体中文回复,使用简体中文创建技能。";
 
-            // 4. 构造用户消息
+            // 3. 构造用户消息
             String userMessage = buildUserMessage(state);
 
-            // 5. 调用 HermesBridgeClient(通过 sink 实时推送事件)
+            // 4. 调用 HermesBridgeClient(通过 sink 实时推送事件)
             HermesBridgeClient client = bridgeClientProvider.getIfAvailable();
             if (client == null) {
                 throw new IllegalStateException("Hermes Bridge 未启用(hermes.enabled=false),无法生成技能");
@@ -165,7 +159,7 @@ public class SkillGenerationServiceImpl implements SkillGenerationService {
             publishStatus(runId, "EXECUTING", "调用 Hermes 执行");
 
             var runResult = client.run(systemPrompt, userMessage, maxIter,
-                    hermesHome.toString(), workingDir.toString(),
+                    null, workingDir.toString(),
                     "skill_gen", sink, sessionId, modelConfig);
 
             if (runResult.getFinalText() == null || runResult.getFinalText().isBlank()) {
@@ -198,6 +192,9 @@ public class SkillGenerationServiceImpl implements SkillGenerationService {
             // 防止 AI 生成的 name 与目录名不一致,导致后续 Hermes 查找失败
             alignTargetSkillMdName(targetDir, folderName);
 
+            // 7.6 新技能入库后触发写时同步,让固定 HERMES_HOME 立即可见
+            hermesSkillSyncService.syncAfterWrite();
+
             // 8. 标记成功,发布终止事件
             registry.markSuccess(runId, folderName);
             sseEventBus.publish(runId, "workflow_complete", Map.of(
@@ -269,7 +266,7 @@ public class SkillGenerationServiceImpl implements SkillGenerationService {
 
     /**
      * 定位生成器在工作目录中创建的新技能目录。
-     * 排除 .hermes(同步下来的全局 skills),扫描其余子目录查找 SKILL.md。
+     * 跳过隐藏目录(如 .hermes),扫描其余子目录查找 SKILL.md。
      */
     private Path findGeneratedSkill(Path workingDir) throws IOException {
         try (Stream<Path> stream = Files.list(workingDir)) {

+ 12 - 0
backend/src/main/java/com/agent/management/service/impl/SkillServiceImpl.java

@@ -2,6 +2,7 @@ package com.agent.management.service.impl;
 
 import com.agent.management.common.exception.BusinessException;
 import com.agent.management.config.SkillProperties;
+import com.agent.management.engine.hermes.HermesSkillSyncService;
 import com.agent.management.model.dto.FileCreateDTO;
 import com.agent.management.model.dto.FileNodeDTO;
 import com.agent.management.model.dto.IOField;
@@ -48,6 +49,7 @@ public class SkillServiceImpl implements SkillService {
     private final TagService tagService;
     private final SkillCategoryRepository skillCategoryRepository;
     private final WorkflowRepository workflowRepository;
+    private final HermesSkillSyncService hermesSkillSyncService;
 
     private static final String SKILL_MD_FILENAME = "SKILL.md";
     private static final String VERSIONS_DIR = "_versions";
@@ -137,6 +139,7 @@ public class SkillServiceImpl implements SkillService {
             // 上传后自动创建 v1 快照
             skillVersionService.createSnapshot(folderName, "初始版本");
             log.info("新增 Skill 成功: {}", folderName);
+            hermesSkillSyncService.syncAfterWrite();
 
         } catch (BusinessException e) {
             throw e;
@@ -287,6 +290,7 @@ public class SkillServiceImpl implements SkillService {
             }
             Files.writeString(skillMd, updated, StandardCharsets.UTF_8);
             log.info("更新 Skill 成功: {} (renamed={})", effectiveFolder, !effectiveFolder.equals(folderName));
+            hermesSkillSyncService.syncAfterWrite();
 
         } catch (IOException e) {
             log.error("更新 SKILL.md 失败: {}", skillMd, e);
@@ -334,6 +338,9 @@ public class SkillServiceImpl implements SkillService {
         }
         log.info("[Skill-Migrate] 总数={}, 已迁移={}, 跳过={}, 失败={}",
                 skills.size(), migrated.size(), skipped.size(), failed.size());
+        if (!migrated.isEmpty()) {
+            hermesSkillSyncService.syncAfterWrite();
+        }
         Map<String, Object> result = new LinkedHashMap<>();
         result.put("total", skills.size());
         result.put("migrated", migrated);
@@ -352,6 +359,7 @@ public class SkillServiceImpl implements SkillService {
         try {
             FileUtils.deleteDirectory(skillDir.toFile());
             log.info("删除 Skill 成功: {}", folderName);
+            hermesSkillSyncService.syncAfterWrite();
         } catch (IOException e) {
             log.error("删除 Skill 失败: {}", skillDir, e);
             throw new BusinessException("删除 Skill 失败");
@@ -418,6 +426,7 @@ public class SkillServiceImpl implements SkillService {
             // 4. 保存后自动创建快照
             skillVersionService.createSnapshot(effectiveFolder, null);
             log.info("保存 SKILL.md 成功: {} (renamed={})", effectiveFolder, !normalized.equals(folderName));
+            hermesSkillSyncService.syncAfterWrite();
             return effectiveFolder;
         } catch (BusinessException e) {
             throw e;
@@ -678,6 +687,7 @@ public class SkillServiceImpl implements SkillService {
             Files.createDirectories(file.getParent());
             Files.writeString(file, content, StandardCharsets.UTF_8);
             log.info("保存文件成功: {}", file);
+            hermesSkillSyncService.syncAfterWrite();
         } catch (IOException e) {
             log.error("保存文件失败: {}", file, e);
             throw new BusinessException("保存文件失败");
@@ -705,6 +715,7 @@ public class SkillServiceImpl implements SkillService {
                 Files.writeString(target, "", StandardCharsets.UTF_8);
                 log.info("创建文件成功: {}", target);
             }
+            hermesSkillSyncService.syncAfterWrite();
         } catch (IOException e) {
             log.error("创建失败: {}", target, e);
             throw new BusinessException("创建失败");
@@ -734,6 +745,7 @@ public class SkillServiceImpl implements SkillService {
                 Files.delete(target);
             }
             log.info("删除成功: {}", target);
+            hermesSkillSyncService.syncAfterWrite();
         } catch (IOException e) {
             log.error("删除失败: {}", target, e);
             throw new BusinessException("删除失败");

+ 3 - 0
backend/src/main/java/com/agent/management/service/impl/SkillVersionServiceImpl.java

@@ -2,6 +2,7 @@ package com.agent.management.service.impl;
 
 import com.agent.management.common.exception.BusinessException;
 import com.agent.management.config.SkillProperties;
+import com.agent.management.engine.hermes.HermesSkillSyncService;
 import com.agent.management.model.vo.SkillVersionVO;
 import com.agent.management.service.SkillVersionService;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -25,6 +26,7 @@ import java.util.stream.Stream;
 public class SkillVersionServiceImpl implements SkillVersionService {
 
     private final SkillProperties skillProperties;
+    private final HermesSkillSyncService hermesSkillSyncService;
 
     private static final String VERSIONS_DIR = "_versions";
     private static final String VERSION_META_FILE = "_version.json";
@@ -118,6 +120,7 @@ public class SkillVersionServiceImpl implements SkillVersionService {
         saveMeta(skillDir, meta);
 
         log.info("Skill [{}] 回滚到 v{}", folderName, version);
+        hermesSkillSyncService.syncAfterWrite();
     }
 
     @Override

+ 10 - 2
backend/src/main/resources/application.yml.example

@@ -102,13 +102,18 @@ app:
 workflow:
   run-dir: ${WORKFLOW_RUN_DIR:./data/workflow-runs}
   # LLM 类节点(llm / smartAction / agent / hermesAgent / hermesSmartAction)输出提取的全局默认重试策略
-  # 节点 data 中可声明同名 fields 覆盖此处默认值(详见 RetryPolicy.fromNodeData)
+  # 节点 data 中可声明同名字段覆盖此处默认值(详见 RetryPolicy.fromNodeData)
+  # 双层重试模型:
+  #   runtime-max-retries - 运行异常(限流/超时)时重新完整推理
+  #   format-max-retries  - 格式错误(中文键名/字段缺失/类型不匹配)时仅重组 JSON,不重跑业务
   llm:
     retry:
-      max-retries: 2
+      runtime-max-retries: 2
+      format-max-retries: 2
       retry-on-parse-error: true
       retry-on-missing-required: true
       retry-on-type-mismatch: true
+      retry-on-key-name-mismatch: true
 
 spring:
   datasource:
@@ -190,6 +195,9 @@ hermes:
     # Bridge HTTP 端点访问令牌(X-Bridge-Token 头校验)
     # 为空时启动会生成临时 token(推荐显式设置 32+ 字符随机字符串)
     auth-token: ${HERMES_BRIDGE_AUTH_TOKEN:}
+    # 固定 HERMES_HOME 目录(其下 skills 由 Java 侧自动镜像同步,skill_view/skills_list 从此加载)
+    # 相对路径基于后端工作目录;Bridge 子进程启动时通过 --hermes-home 传入,进程内不变
+    home: ${HERMES_HOME:./data/hermes-home}
 
 # Jasypt 加密(数据源凭据加密入库)
 # ⚠️ 生产环境务必通过环境变量 JASYPT_KEY 设置强随机密钥,不要使用默认值

+ 280 - 0
backend/src/test/java/com/agent/management/engine/OutputExecutorTest.java

@@ -0,0 +1,280 @@
+package com.agent.management.engine;
+
+import com.agent.management.engine.executor.OutputExecutor;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+
+/**
+ * {@link OutputExecutor} 单元测试。
+ *
+ * <p>验证输出节点按 {@code data.fields} 投影的新语义:</p>
+ * <ul>
+ *   <li>未声明 fields → data 仅含元数据(_runId)</li>
+ *   <li>声明 fields → 用 field.name 作为裸 key 输出,复用 NodeInputResolver 的 mapping + 四级模糊匹配 + 类型转换</li>
+ *   <li>required=true 缺失 → 失败 envelope</li>
+ *   <li>required=false 缺失 → 字段不出现</li>
+ *   <li>{@code _runId} 总是注入</li>
+ * </ul>
+ */
+class OutputExecutorTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private static JsonNode data(String json) {
+        try {
+            return MAPPER.readTree(json);
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /** 构造一个 envelope 形式的前驱输出(与 NodeWorkspaceBuilder 注入 scopedOutputs 的形态一致)。 */
+    private static Map<String, Object> envelopeWith(String varName, Object value) {
+        return NodeOutputEnvelope.success("ok", Map.of(varName, value)).toMap();
+    }
+
+    private static int envelopeStatus(NodeExecutionResult result) {
+        Object raw = result.getOutput().get("status");
+        if (raw instanceof Number) return ((Number) raw).intValue();
+        if (raw instanceof String) return Integer.parseInt((String) raw);
+        throw new AssertionError("缺失 status 字段:" + result.getOutput());
+    }
+
+    @SuppressWarnings("unchecked")
+    private static Map<String, Object> envelopeData(NodeExecutionResult result) {
+        return (Map<String, Object>) result.getOutput().get("data");
+    }
+
+    private static String envelopeMessage(NodeExecutionResult result) {
+        Object msg = result.getOutput().get("message");
+        return msg == null ? "" : msg.toString();
+    }
+
+    /** 构造一个工作区,其扁平变量表与 scopedOutputs 已对齐 NodeWorkspaceBuilder 的注入约定。 */
+    private static NodeWorkspace workspaceWith(String predId, String varName, Object value) {
+        NodeWorkspace ws = new NodeWorkspace();
+        ws.setRunId("run-test");
+        Map<String, Object> env = envelopeWith(varName, value);
+        ws.getScopedOutputs().put(predId, env);
+        // 扁平变量按 NodeWorkspaceBuilder 的命名空间约定:predId + "__" + varName
+        ws.getVariables().put(predId + NodeWorkspaceBuilder.NODE_FIELD_SEPARATOR + varName, value);
+        return ws;
+    }
+
+    @Test
+    void noFieldsDeclReturnsOnlyMetadata() {
+        // 未声明 fields:data 仅含 _runId(workingDir=null 故无 _workingDirFiles)
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+
+        NodeExecutionResult result = executor.execute("out-1", data("{}"), ctx, null);
+
+        assertThat(result.getStatus()).isEqualTo(NodeExecutionResult.Status.SUCCESS);
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        Map<String, Object> d = envelopeData(result);
+        assertThat(d).containsOnlyKeys("_runId");
+        assertThat(d.get("_runId")).isEqualTo("run-1");
+    }
+
+    @Test
+    void fieldWithExplicitMappingUsesBareKey() {
+        // field.name=result,mapping 指向 node-1.result → 输出 key 用裸名 result(不带 predId__ 前缀)
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = workspaceWith("node-1", "result", "来自前驱的值");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"result\",\"type\":\"string\",\"required\":true," +
+                "\"mapping\":{\"sourceNodeId\":\"node-1\",\"sourceField\":\"result\"}}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        Map<String, Object> d = envelopeData(result);
+        assertThat(d).containsEntry("result", "来自前驱的值");
+        assertThat(d).doesNotContainKey("node-1__result");
+    }
+
+    @Test
+    void fieldWithoutMappingAutoInfersByExactName() {
+        // 无 mapping:NodeInputResolver 按精确同名从 scopedOutputs 取值
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = workspaceWith("node-1", "result", "hello");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"result\",\"type\":\"string\",\"required\":true}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        assertThat(envelopeData(result)).containsEntry("result", "hello");
+    }
+
+    @Test
+    void hyphenFieldNameMatchesUnderscoreSource() {
+        // 用户场景:fields 声明 git-commit-log(连字符),前驱输出 git_commit_log(下划线)
+        // NodeInputResolver 的"忽略 -_/大小写同名"分支应命中
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = workspaceWith("node-2", "git_commit_log", "提交日志内容");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"git-commit-log\",\"type\":\"string\",\"required\":true}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        // 输出 key 沿用用户声明的 field.name(git-commit-log)
+        assertThat(envelopeData(result)).containsEntry("git-commit-log", "提交日志内容");
+    }
+
+    @Test
+    void requiredFieldMissingReturnsFailureEnvelope() {
+        // required=true 字段在前驱中不存在 → 返回 FAILED envelope
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = workspaceWith("node-1", "other", "value");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"missing\",\"type\":\"string\",\"required\":true}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(400);
+        assertThat(envelopeMessage(result)).contains("missing");
+    }
+
+    @Test
+    void requiredFalseFieldMissingOmittedFromOutput() {
+        // required=false 字段缺失:不出现于输出,但不影响 envelope 状态
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = new NodeWorkspace();
+        ws.setRunId("run-1");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"optional\",\"type\":\"string\",\"required\":false}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        Map<String, Object> d = envelopeData(result);
+        assertThat(d).doesNotContainKey("optional");
+        // 元数据仍在
+        assertThat(d).containsKey("_runId");
+    }
+
+    @Test
+    void legacyFieldWithoutRequiredFlagDefaultsToOptional() {
+        // 历史 fields 项未带 required:默认非必填(与 NodeInputResolver 的 asBoolean(false) 一致)
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = new NodeWorkspace();
+        ws.setRunId("run-1");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"legacy\",\"type\":\"string\"}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        assertThat(envelopeData(result)).doesNotContainKey("legacy");
+    }
+
+    @Test
+    void multipleFieldsAllResolved() {
+        // 多字段场景:result 来自前驱,summary 也来自前驱,按各自 field.name 输出
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = new NodeWorkspace();
+        ws.setRunId("run-1");
+        // 构造一个含两个字段的 envelope
+        Map<String, Object> envData = new LinkedHashMap<>();
+        envData.put("result", "成功");
+        envData.put("summary", "汇总");
+        Map<String, Object> env = NodeOutputEnvelope.success("ok", envData).toMap();
+        ws.getScopedOutputs().put("node-1", env);
+        ws.getVariables().put("node-1__result", "成功");
+        ws.getVariables().put("node-1__summary", "汇总");
+
+        JsonNode data = data("{\"fields\":[" +
+                "{\"name\":\"result\",\"type\":\"string\",\"required\":true}," +
+                "{\"name\":\"summary\",\"type\":\"string\",\"required\":true}" +
+                "]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        Map<String, Object> d = envelopeData(result);
+        assertThat(d).containsEntry("result", "成功").containsEntry("summary", "汇总");
+    }
+
+    @Test
+    void multipleRequiredMissingAllReported() {
+        // 多个 required 字段同时缺失 → 失败信息应列出全部缺失字段名
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = workspaceWith("node-1", "present", "v");
+
+        JsonNode data = data("{\"fields\":[" +
+                "{\"name\":\"present\",\"type\":\"string\",\"required\":true}," +
+                "{\"name\":\"missing1\",\"type\":\"string\",\"required\":true}," +
+                "{\"name\":\"missing2\",\"type\":\"string\",\"required\":true}" +
+                "]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(400);
+        String msg = envelopeMessage(result);
+        assertThat(msg).contains("missing1").contains("missing2").doesNotContain("present");
+    }
+
+    @Test
+    void initialInputInjectedAsBareKey() {
+        // 用户运行对话框输入(initialInputs)以扁平形式注入 workspace.variables
+        // 字段同名时 NodeInputResolver 走第 2 步直接命中,输出 key 用 field.name
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = new NodeWorkspace();
+        ws.setRunId("run-1");
+        ws.getVariables().put("userInput", "hello");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"userInput\",\"type\":\"string\",\"required\":true}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        assertThat(envelopeData(result)).containsEntry("userInput", "hello");
+    }
+
+    @Test
+    void emptyStringTreatedAsMissingForRequired() {
+        // NodeInputResolver 第 2 步用 null 判定(非空字符串判定);
+        // 此处构造 required 字段在前驱完全不存在 → 触发 missing
+        AgentIgnoreFilter filter = mock(AgentIgnoreFilter.class);
+        OutputExecutor executor = new OutputExecutor(filter);
+        WorkflowContext ctx = new WorkflowContext(1L, "run-1", null, Map.of());
+        NodeWorkspace ws = workspaceWith("node-1", "foo", "");
+
+        JsonNode data = data("{\"fields\":[{\"name\":\"foo\",\"type\":\"string\",\"required\":true}]}");
+
+        NodeExecutionResult result = executor.execute("out-1", data, ctx, ws);
+
+        // 空字符串:NodeInputResolver 仍会作为 value 返回(非 null),因此不进入 missing
+        // 这与原 OutputExecutor 的"空字符串视为缺失"行为不同,但与所有其他节点(llm/agent/skill)保持一致
+        assertThat(envelopeStatus(result)).isEqualTo(200);
+        assertThat(envelopeData(result)).containsEntry("foo", "");
+    }
+}

+ 303 - 0
backend/src/test/java/com/agent/management/engine/OutputValidatorStrictTest.java

@@ -0,0 +1,303 @@
+package com.agent.management.engine;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.Test;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * {@link OutputValidator} strict 模式({@code validate(parsed, decl, true)})单元测试。
+ *
+ * <p>strict 模式规则(修订后):</p>
+ * <ul>
+ *   <li>只校验 {@code required=true} 字段:必须精确存在(区分大小写、区分分隔符)且值非空</li>
+ *   <li>非 required 字段:缺失不报错;存在则做类型校验</li>
+ *   <li>多余字段(含 LLM 自由扩展的 snake_case 辅助字段)完全不报错</li>
+ * </ul>
+ *
+ * <p>测试辅助方法 {@link #outputsDecl(String...)} 默认所有字段 required=true,
+ * 显式标注 "optional" 才是非必填。</p>
+ */
+class OutputValidatorStrictTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private static JsonNode outputsDecl(String... fields) {
+        // fields 形如 "name|type" 或 "name|type|required" 或 "name|type|optional"
+        // 默认 required=true(匹配修订后 strict 模式的常用场景)
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < fields.length; i++) {
+            if (i > 0) sb.append(",");
+            String[] parts = fields[i].split("\\|");
+            sb.append("{\"name\":\"").append(parts[0]).append("\",")
+              .append("\"type\":\"").append(parts[1]).append("\",")
+              .append("\"required\":")
+              .append(parts.length > 2 && "optional".equalsIgnoreCase(parts[2]) ? "false" : "true");
+            sb.append("}");
+        }
+        sb.append("]");
+        try {
+            return MAPPER.readTree(sb.toString());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    @Test
+    void strictMode_allFieldsPresent_ok() {
+        JsonNode decl = outputsDecl("status|number", "message|string", "data|object");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("status", 200);
+        parsed.put("message", "ok");
+        parsed.put("data", Map.of("fieldA", "value"));
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isTrue();
+        assertThat(result.keyNameMismatched).isEmpty();
+        assertThat(result.missingRequired).isEmpty();
+        assertThat(result.typeMismatched).isEmpty();
+    }
+
+    @Test
+    void strictMode_chineseKeyNameRejected() {
+        // 用户定义:英文键名;LLM 返回:中文键名 → strict 模式应拒绝
+        JsonNode decl = outputsDecl("status|number", "message|string", "data|object");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("状态", 200);
+        parsed.put("信息", "成功");
+        parsed.put("数据", Map.of("字段A", "值"));
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        // 三个声明的英文字段都未在 parsed 中精确存在
+        assertThat(result.keyNameMismatched).containsExactlyInAnyOrder("status", "message", "data");
+        // 错误信息应列出所有缺失键名
+        assertThat(result.errorMessage).contains("status").contains("message").contains("data");
+    }
+
+    @Test
+    void strictMode_partialMissing_fails() {
+        JsonNode decl = outputsDecl("status|number", "message|string", "data|object");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("status", 200);
+        parsed.put("message", "ok");
+        // data 缺失
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        assertThat(result.keyNameMismatched).containsExactly("data");
+    }
+
+    @Test
+    void strictMode_nullValueTreatedAsMissing() {
+        JsonNode decl = outputsDecl("status|number", "message|string");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("status", null);  // 键存在,但值为 null
+        parsed.put("message", "ok");
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        // 键存在 → 不算 keyNameMismatched;值为 null → 进入 missingRequired
+        assertThat(result.keyNameMismatched).isEmpty();
+        assertThat(result.missingRequired).containsExactly("status");
+    }
+
+    @Test
+    void strictMode_blankStringTreatedAsMissing() {
+        JsonNode decl = outputsDecl("message|string");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("message", "   ");  // 空白字符串
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        assertThat(result.missingRequired).containsExactly("message");
+    }
+
+    @Test
+    void strictMode_typeMismatchStillConverts() {
+        JsonNode decl = outputsDecl("age|number");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("age", "42");  // 字符串数字 → 自动转换
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isTrue();
+        assertThat(result.converted.get("age")).isInstanceOf(Number.class);
+        assertThat(((Number) result.converted.get("age")).intValue()).isEqualTo(42);
+    }
+
+    @Test
+    void strictMode_typeMismatchFailsWhenUnconvertible() {
+        JsonNode decl = outputsDecl("age|number");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("age", List.of(1, 2, 3));  // 数组不能转 number
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        assertThat(result.typeMismatched).anyMatch(s -> s.startsWith("age("));
+    }
+
+    @Test
+    void strictMode_extraFieldsIgnored() {
+        JsonNode decl = outputsDecl("status|number", "message|string");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("status", 200);
+        parsed.put("message", "ok");
+        parsed.put("extra1", "ignored");
+        parsed.put("extra2", Map.of("x", 1));
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isTrue();
+        // 额外字段保留在 converted 中(不裁剪)
+        assertThat(result.converted).containsKey("extra1").containsKey("extra2");
+    }
+
+    @Test
+    void strictMode_caseSensitive() {
+        // 大小写敏感:Status ≠ status
+        JsonNode decl = outputsDecl("status|number");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("Status", 200);
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        assertThat(result.keyNameMismatched).containsExactly("status");
+    }
+
+    @Test
+    void strictMode_separatorSensitive() {
+        // 分隔符敏感:user-name ≠ userName
+        JsonNode decl = outputsDecl("userName|string");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("user-name", "Alice");
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        assertThat(result.keyNameMismatched).containsExactly("userName");
+    }
+
+    @Test
+    void legacyMode_backwardCompatible() {
+        // legacy 模式:未在 parsed 中的非必填字段不报错
+        JsonNode decl = outputsDecl("name|string|required", "nickname|string|optional");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("name", "Alice");
+        // nickname 既不在 parsed 中,也未声明 required
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, false);
+
+        assertThat(result.ok).isTrue();
+        assertThat(result.keyNameMismatched).isEmpty();
+        assertThat(result.missingRequired).isEmpty();
+    }
+
+    @Test
+    void legacyMode_defaultOverloadEquivalentToFalse() {
+        // 旧 2 参数重载等价于 strict=false
+        JsonNode decl = outputsDecl("name|string|required");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("name", "Alice");
+
+        OutputValidator.ValidationResult r1 = OutputValidator.validate(parsed, decl);
+        OutputValidator.ValidationResult r2 = OutputValidator.validate(parsed, decl, false);
+
+        assertThat(r1.ok).isEqualTo(r2.ok);
+        assertThat(r1.missingRequired).isEqualTo(r2.missingRequired);
+        assertThat(r1.typeMismatched).isEqualTo(r2.typeMismatched);
+        assertThat(r1.keyNameMismatched).isEqualTo(r2.keyNameMismatched);
+    }
+
+    @Test
+    void strictMode_errorMessageListsKeyNameIssuesFirst() {
+        // 错误信息优先级:键名缺失 > 值为空 > 类型不匹配
+        JsonNode decl = outputsDecl("status|number", "message|string", "data|object");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("message", "   ");   // 值为空
+        parsed.put("data", List.of(1)); // 类型不匹配(数组不能转 object)
+        // status 完全缺失
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        assertThat(result.errorMessage).contains("字段键名缺失")
+                .contains("必填字段值为空")
+                .contains("类型不匹配");
+    }
+
+    // ========== 新增:修订后语义覆盖 ==========
+
+    @Test
+    void strictMode_optionalFieldMissing_ok() {
+        // strict 模式下,非 required 字段缺失不报错
+        JsonNode decl = outputsDecl("status|number", "detail|string|optional");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("status", 200);
+        // detail 缺失
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isTrue();
+        assertThat(result.keyNameMismatched).isEmpty();
+        assertThat(result.missingRequired).isEmpty();
+    }
+
+    @Test
+    void strictMode_optionalFieldPresent_typeStillChecked() {
+        // strict 模式下,非 required 字段若存在则做类型校验
+        JsonNode decl = outputsDecl("status|number", "detail|object|optional");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("status", 200);
+        parsed.put("detail", List.of(1, 2));  // 数组不能转 object
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isFalse();
+        assertThat(result.typeMismatched).anyMatch(s -> s.startsWith("detail("));
+    }
+
+    @Test
+    void strictMode_llmSnakeCaseExtensions_ok() {
+        // LLM 自由扩展的 snake_case 辅助字段不报错,与业务字段并存于 envelope.data
+        JsonNode decl = outputsDecl("status|number", "message|string");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("status", 200);
+        parsed.put("message", "ok");
+        parsed.put("confidence", 0.95);
+        parsed.put("run_error", "");
+        parsed.put("user_notice", "请检查参数 X");
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isTrue();
+        // 扩展字段保留在 converted 中(不裁剪),供下游节点按需消费
+        assertThat(result.converted).containsKey("confidence")
+                .containsKey("run_error")
+                .containsKey("user_notice");
+    }
+
+    @Test
+    void strictMode_onlyRequiredChecked_whenAllOptional() {
+        // 所有字段都是 optional 时,即使全部缺失也不报错
+        JsonNode decl = outputsDecl("detail|string|optional", "extra|number|optional");
+        Map<String, Object> parsed = new LinkedHashMap<>();
+        parsed.put("anything", "whatever");  // 没有声明字段,但有扩展
+
+        OutputValidator.ValidationResult result = OutputValidator.validate(parsed, decl, true);
+
+        assertThat(result.ok).isTrue();
+    }
+}

+ 168 - 32
backend/src/test/java/com/agent/management/engine/RetryPolicyTest.java

@@ -10,7 +10,7 @@ import static org.assertj.core.api.Assertions.assertThat;
 /**
  * {@link RetryPolicy} 单元测试。
  *
- * <p>覆盖:默认策略、节点级覆盖、全局回退、字段读取优先级。</p>
+ * <p>覆盖:默认策略、节点级覆盖、全局回退、字段读取优先级、legacy maxRetries 向后兼容。</p>
  */
 class RetryPolicyTest {
 
@@ -28,20 +28,22 @@ class RetryPolicyTest {
     void defaultPolicyHasExpectedValues() {
         RetryPolicy policy = RetryPolicy.defaultPolicy();
 
-        assertThat(policy.maxRetries).isEqualTo(2);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(2);
+        assertThat(policy.formatMaxRetries).isEqualTo(2);
         assertThat(policy.retryOnParseError).isTrue();
         assertThat(policy.retryOnMissingRequired).isTrue();
         assertThat(policy.retryOnTypeMismatch).isTrue();
+        assertThat(policy.retryOnKeyNameMismatch).isTrue();
     }
 
     @Test
     void fromNodeDataWithNullDataReturnsGlobalDefaults() {
         WorkflowProperties props = new WorkflowProperties();
-        // WorkflowProperties 默认值:maxRetries=2, 全部 retryOn=true
 
         RetryPolicy policy = RetryPolicy.fromNodeData(null, props);
 
-        assertThat(policy.maxRetries).isEqualTo(2);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(2);
+        assertThat(policy.formatMaxRetries).isEqualTo(2);
         assertThat(policy.retryOnParseError).isTrue();
     }
 
@@ -49,36 +51,50 @@ class RetryPolicyTest {
     void fromNodeDataWithNullPropsReturnsCodeDefaults() {
         RetryPolicy policy = RetryPolicy.fromNodeData(null, null);
 
-        assertThat(policy.maxRetries).isEqualTo(2);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(2);
+        assertThat(policy.formatMaxRetries).isEqualTo(2);
         assertThat(policy.retryOnParseError).isTrue();
         assertThat(policy.retryOnMissingRequired).isTrue();
         assertThat(policy.retryOnTypeMismatch).isTrue();
+        assertThat(policy.retryOnKeyNameMismatch).isTrue();
     }
 
     @Test
-    void nodeLevelMaxRetriesOverridesGlobal() {
+    void nodeLevelRuntimeMaxRetriesOverridesGlobal() {
         WorkflowProperties props = new WorkflowProperties();
-        props.getLlm().getRetry().setMaxRetries(3);
 
-        JsonNode data = data("{\"maxRetries\": 5}");
+        JsonNode data = data("{\"runtimeMaxRetries\": 5}");
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, props);
 
-        assertThat(policy.maxRetries).isEqualTo(5);
-        // 未在节点级覆盖的字段使用全局
+        assertThat(policy.runtimeMaxRetries).isEqualTo(5);
+        assertThat(policy.formatMaxRetries).isEqualTo(2);
         assertThat(policy.retryOnParseError).isTrue();
     }
 
+    @Test
+    void nodeLevelFormatMaxRetriesOverridesGlobal() {
+        WorkflowProperties props = new WorkflowProperties();
+
+        JsonNode data = data("{\"formatMaxRetries\": 4}");
+
+        RetryPolicy policy = RetryPolicy.fromNodeData(data, props);
+
+        assertThat(policy.formatMaxRetries).isEqualTo(4);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(2);
+    }
+
     @Test
     void nodeLevelBooleanOverridesGlobal() {
         WorkflowProperties props = new WorkflowProperties();
 
-        JsonNode data = data("{\"retryOnParseError\": false, \"retryOnTypeMismatch\": false}");
+        JsonNode data = data("{\"retryOnParseError\": false, \"retryOnTypeMismatch\": false, \"retryOnKeyNameMismatch\": false}");
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, props);
 
         assertThat(policy.retryOnParseError).isFalse();
         assertThat(policy.retryOnTypeMismatch).isFalse();
+        assertThat(policy.retryOnKeyNameMismatch).isFalse();
         // 未在节点级覆盖的字段使用全局默认
         assertThat(policy.retryOnMissingRequired).isTrue();
     }
@@ -86,28 +102,52 @@ class RetryPolicyTest {
     @Test
     void globalValuesAppliedWhenNodeMissingFields() {
         WorkflowProperties props = new WorkflowProperties();
-        props.getLlm().getRetry().setMaxRetries(10);
+        props.getLlm().getRetry().setRuntimeMaxRetries(10);
+        props.getLlm().getRetry().setFormatMaxRetries(7);
         props.getLlm().getRetry().setRetryOnParseError(false);
         props.getLlm().getRetry().setRetryOnMissingRequired(false);
         props.getLlm().getRetry().setRetryOnTypeMismatch(false);
+        props.getLlm().getRetry().setRetryOnKeyNameMismatch(false);
 
         JsonNode data = data("{}"); // 节点级未声明任何字段
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, props);
 
-        assertThat(policy.maxRetries).isEqualTo(10);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(10);
+        assertThat(policy.formatMaxRetries).isEqualTo(7);
         assertThat(policy.retryOnParseError).isFalse();
         assertThat(policy.retryOnMissingRequired).isFalse();
         assertThat(policy.retryOnTypeMismatch).isFalse();
+        assertThat(policy.retryOnKeyNameMismatch).isFalse();
+    }
+
+    @Test
+    void legacyMaxRetriesInNodeDataFallsBackToRuntime() {
+        // 节点声明 legacy 字段 maxRetries(向后兼容),应被识别为 runtimeMaxRetries
+        JsonNode data = data("{\"maxRetries\": 5}");
+
+        RetryPolicy policy = RetryPolicy.fromNodeData(data, null);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(5);
+    }
+
+    @Test
+    void runtimeMaxRetriesPreferredOverLegacyMaxRetries() {
+        // 同时声明 runtimeMaxRetries 与 maxRetries,前者优先
+        JsonNode data = data("{\"runtimeMaxRetries\": 8, \"maxRetries\": 3}");
+
+        RetryPolicy policy = RetryPolicy.fromNodeData(data, null);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(8);
     }
 
     @Test
     void stringNumberInMaxRetriesParsed() {
-        JsonNode data = data("{\"maxRetries\": \"4\"}");
+        JsonNode data = data("{\"runtimeMaxRetries\": \"4\"}");
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, null);
 
-        assertThat(policy.maxRetries).isEqualTo(4);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(4);
     }
 
     @Test
@@ -122,51 +162,51 @@ class RetryPolicyTest {
     @Test
     void invalidStringMaxRetriesFallsBackToGlobal() {
         WorkflowProperties props = new WorkflowProperties();
-        props.getLlm().getRetry().setMaxRetries(7);
+        props.getLlm().getRetry().setRuntimeMaxRetries(7);
 
-        JsonNode data = data("{\"maxRetries\": \"not-a-number\"}");
+        JsonNode data = data("{\"runtimeMaxRetries\": \"not-a-number\"}");
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, props);
 
-        assertThat(policy.maxRetries).isEqualTo(7);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(7);
     }
 
     @Test
-    void maxRetriesLessThanOneClampedToOne() {
-        JsonNode data = data("{\"maxRetries\": 0}");
+    void runtimeMaxRetriesLessThanOneClampedToOne() {
+        JsonNode data = data("{\"runtimeMaxRetries\": 0}");
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, null);
 
-        assertThat(policy.maxRetries).isEqualTo(1);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(1);
     }
 
     @Test
-    void maxRetriesNegativeClampedToOne() {
-        JsonNode data = data("{\"maxRetries\": -5}");
+    void formatMaxRetriesNegativeClampedToOne() {
+        JsonNode data = data("{\"formatMaxRetries\": -5}");
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, null);
 
-        assertThat(policy.maxRetries).isEqualTo(1);
+        assertThat(policy.formatMaxRetries).isEqualTo(1);
     }
 
     @Test
     void nullFieldsUseGlobalFallback() {
         WorkflowProperties props = new WorkflowProperties();
-        props.getLlm().getRetry().setMaxRetries(6);
+        props.getLlm().getRetry().setRuntimeMaxRetries(6);
 
-        JsonNode data = data("{\"maxRetries\": null, \"retryOnParseError\": null}");
+        JsonNode data = data("{\"runtimeMaxRetries\": null, \"retryOnParseError\": null}");
 
         RetryPolicy policy = RetryPolicy.fromNodeData(data, props);
 
-        assertThat(policy.maxRetries).isEqualTo(6);
+        assertThat(policy.runtimeMaxRetries).isEqualTo(6);
         assertThat(policy.retryOnParseError).isTrue();
     }
 
     @Test
     void equalsAndHashCodeWork() {
-        RetryPolicy a = new RetryPolicy(3, true, false, true);
-        RetryPolicy b = new RetryPolicy(3, true, false, true);
-        RetryPolicy c = new RetryPolicy(2, true, false, true);
+        RetryPolicy a = new RetryPolicy(3, 2, true, false, true, true);
+        RetryPolicy b = new RetryPolicy(3, 2, true, false, true, true);
+        RetryPolicy c = new RetryPolicy(2, 2, true, false, true, true);
 
         assertThat(a).isEqualTo(b);
         assertThat(a.hashCode()).isEqualTo(b.hashCode());
@@ -175,13 +215,109 @@ class RetryPolicyTest {
 
     @Test
     void toStringContainsKeyFields() {
-        RetryPolicy policy = new RetryPolicy(3, true, false, true);
+        RetryPolicy policy = new RetryPolicy(3, 2, true, false, true, true);
 
         String str = policy.toString();
 
-        assertThat(str).contains("maxRetries=3");
+        assertThat(str).contains("runtimeMaxRetries=3");
+        assertThat(str).contains("formatMaxRetries=2");
         assertThat(str).contains("parseError=true");
         assertThat(str).contains("missingRequired=false");
         assertThat(str).contains("typeMismatch=true");
+        assertThat(str).contains("keyNameMismatch=true");
+    }
+
+    // ========== forHermes 静态工厂 ==========
+
+    @Test
+    void forHermes_capsRuntimeTo1_whenNodeDataAbsent() {
+        // 节点未显式配置 runtimeMaxRetries,全局默认 2 → forHermes 强制降为 1
+        WorkflowProperties props = new WorkflowProperties();
+
+        RetryPolicy policy = RetryPolicy.forHermes(data("{}"), props);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(1);
+        // 其他字段保留默认
+        assertThat(policy.formatMaxRetries).isEqualTo(2);
+        assertThat(policy.retryOnParseError).isTrue();
+        assertThat(policy.retryOnKeyNameMismatch).isTrue();
+    }
+
+    @Test
+    void forHermes_respectsExplicitNodeLevelRuntime() {
+        // 节点显式配置 runtimeMaxRetries=3 → forHermes 保留用户选择
+        WorkflowProperties props = new WorkflowProperties();
+        JsonNode data = data("{\"runtimeMaxRetries\": 3}");
+
+        RetryPolicy policy = RetryPolicy.forHermes(data, props);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(3);
+    }
+
+    @Test
+    void forHermes_respectsLegacyMaxRetries() {
+        // 节点使用 legacy maxRetries=5 → 视为用户显式配置,保留
+        WorkflowProperties props = new WorkflowProperties();
+        JsonNode data = data("{\"maxRetries\": 5}");
+
+        RetryPolicy policy = RetryPolicy.forHermes(data, props);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(5);
+    }
+
+    @Test
+    void forHermes_preservesGlobalFormatMaxRetries() {
+        // 全局 formatMaxRetries=7 应保留(forHermes 只动 runtime)
+        WorkflowProperties props = new WorkflowProperties();
+        props.getLlm().getRetry().setFormatMaxRetries(7);
+
+        RetryPolicy policy = RetryPolicy.forHermes(data("{}"), props);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(1);
+        assertThat(policy.formatMaxRetries).isEqualTo(7);
+    }
+
+    @Test
+    void forHermes_capsTo1_whenGlobalRuntimeIs2() {
+        // 全局 runtimeMaxRetries=2 时,forHermes 仍降为 1
+        WorkflowProperties props = new WorkflowProperties();
+        props.getLlm().getRetry().setRuntimeMaxRetries(2);
+
+        RetryPolicy policy = RetryPolicy.forHermes(null, props);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(1);
+    }
+
+    @Test
+    void forHermes_noOpWhenGlobalRuntimeAlreadyLow() {
+        // 全局 runtimeMaxRetries=1 时,forHermes 不需要降级
+        WorkflowProperties props = new WorkflowProperties();
+        props.getLlm().getRetry().setRuntimeMaxRetries(1);
+
+        RetryPolicy policy = RetryPolicy.forHermes(null, props);
+
+        // runtimeMaxRetries 应保持为 1(不需要再降)
+        assertThat(policy.runtimeMaxRetries).isEqualTo(1);
+        // 其他字段保留全局默认
+        assertThat(policy.formatMaxRetries).isEqualTo(2);
+        assertThat(policy.retryOnParseError).isTrue();
+    }
+
+    @Test
+    void forHermes_keepsBooleansFromGlobal() {
+        // 全局把所有 retryOn 都关掉,forHermes 应保留这些用户选择
+        WorkflowProperties props = new WorkflowProperties();
+        props.getLlm().getRetry().setRetryOnParseError(false);
+        props.getLlm().getRetry().setRetryOnMissingRequired(false);
+        props.getLlm().getRetry().setRetryOnTypeMismatch(false);
+        props.getLlm().getRetry().setRetryOnKeyNameMismatch(false);
+
+        RetryPolicy policy = RetryPolicy.forHermes(data("{}"), props);
+
+        assertThat(policy.runtimeMaxRetries).isEqualTo(1);
+        assertThat(policy.retryOnParseError).isFalse();
+        assertThat(policy.retryOnMissingRequired).isFalse();
+        assertThat(policy.retryOnTypeMismatch).isFalse();
+        assertThat(policy.retryOnKeyNameMismatch).isFalse();
     }
 }

+ 210 - 0
backend/src/test/java/com/agent/management/engine/SkillFileSyncerTest.java

@@ -0,0 +1,210 @@
+package com.agent.management.engine;
+
+import com.agent.management.config.SkillProperties;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * {@link SkillFileSyncer} 单元测试。
+ *
+ * <p>覆盖镜像同步的跳过规则:
+ * <ul>
+ *   <li>技能根目录 SKILL.md(合法主入口)正常复制;</li>
+ *   <li>{@code _versions/} 子目录整树跳过;</li>
+ *   <li>非根子目录直接含 SKILL.md 时整树跳过(任意深度,常见于 AI 生成残留);</li>
+ *   <li>目标侧残留清除;</li>
+ *   <li>普通子目录与文件正常复制。</li>
+ * </ul>
+ */
+class SkillFileSyncerTest {
+
+    @TempDir
+    Path tempDir;
+
+    private Path sourceRoot;
+    private Path targetRoot;
+    private SkillFileSyncer syncer;
+
+    @BeforeEach
+    void setUp() throws IOException {
+        sourceRoot = tempDir.resolve("uploads-skills");
+        targetRoot = tempDir.resolve("hermes-home-skills");
+        Files.createDirectories(sourceRoot);
+
+        SkillProperties props = new SkillProperties();
+        props.setBasePath(sourceRoot.toString());
+        syncer = new SkillFileSyncer(props);
+    }
+
+    /** 创建技能根目录 + SKILL.md */
+    private void createSkill(String folderName, String skillMdContent) throws IOException {
+        Path skillDir = sourceRoot.resolve(folderName);
+        Files.createDirectories(skillDir);
+        Files.writeString(skillDir.resolve("SKILL.md"), skillMdContent);
+    }
+
+    /** 在技能内创建子目录(可选拓展子文件) */
+    private Path mkSubDir(String skillFolder, String subRel) throws IOException {
+        Path p = sourceRoot.resolve(skillFolder).resolve(subRel);
+        Files.createDirectories(p);
+        return p;
+    }
+
+    private void write(Path dir, String fileName, String content) throws IOException {
+        Files.writeString(dir.resolve(fileName), content);
+    }
+
+    // ==================== 测试用例 ====================
+
+    @Test
+    void copiesRootSkillMdAndRegularFiles() throws IOException {
+        createSkill("foo", "name: foo\n");
+        write(sourceRoot.resolve("foo"), "notes.md", "hello");
+
+        int copied = syncer.syncTo(targetRoot);
+
+        assertThat(copied).isEqualTo(1);
+        assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
+        assertThat(targetRoot.resolve("foo/notes.md")).exists();
+    }
+
+    @Test
+    void skipsVersionsSubtree() throws IOException {
+        createSkill("foo", "name: foo\n");
+        Path versions = mkSubDir("foo", "_versions/v1");
+        write(versions, "SKILL.md", "name: foo-old\n");
+
+        syncer.syncTo(targetRoot);
+
+        assertThat(targetRoot.resolve("foo/_versions")).doesNotExist();
+    }
+
+    @Test
+    void skipsChildDirContainingSkillMd_generatedSkills() throws IOException {
+        createSkill("foo", "name: foo\n");
+        // 模拟 AI 生成的残留:foo/generated_skills/SKILL.md
+        Path genDir = mkSubDir("foo", "generated_skills");
+        write(genDir, "SKILL.md", "name: generated\n");
+        write(genDir, "extra.txt", "keep me?");
+
+        syncer.syncTo(targetRoot);
+
+        // generated_skills/ 整树不复制
+        assertThat(targetRoot.resolve("foo/generated_skills")).doesNotExist();
+        // 但技能自身的 SKILL.md 仍正常
+        assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
+    }
+
+    @Test
+    void skipsChildDirContainingSkillMd_tmpSmokeGenerated() throws IOException {
+        createSkill("foo", "name: foo\n");
+        Path genDir = mkSubDir("foo", "tmp_smoke_generated");
+        write(genDir, "SKILL.md", "name: smoke\n");
+
+        syncer.syncTo(targetRoot);
+
+        assertThat(targetRoot.resolve("foo/tmp_smoke_generated")).doesNotExist();
+        assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
+    }
+
+    @Test
+    void skipsNestedDirContainingSkillMd_atDeeperLevel() throws IOException {
+        // 结构: foo/subdir/nested/SKILL.md,subdir 自身不含 SKILL.md
+        // 期望:跳过 subdir/nested/(nested 整树),但 subdir/ 自身被创建为目录
+        createSkill("foo", "name: foo\n");
+        Path nested = mkSubDir("foo", "subdir/nested");
+        write(nested, "SKILL.md", "name: nested\n");
+        write(nested, "aux.txt", "aux");
+
+        syncer.syncTo(targetRoot);
+
+        // nested/ 整树被跳过
+        assertThat(targetRoot.resolve("foo/subdir/nested")).doesNotExist();
+        // subdir/ 本身仍创建(因为 preVisitDirectory 在 subdir 处返回 CONTINUE)
+        assertThat(targetRoot.resolve("foo/subdir")).exists().isDirectory();
+        // 技能根 SKILL.md 仍正常
+        assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
+    }
+
+    @Test
+    void regularSubdirWithoutSkillMdIsCopied() throws IOException {
+        createSkill("foo", "name: foo\n");
+        Path sub = mkSubDir("foo", "docs");
+        write(sub, "guide.md", "guide");
+        write(sub, "examples.py", "print('hi')");
+
+        syncer.syncTo(targetRoot);
+
+        assertThat(targetRoot.resolve("foo/docs/guide.md")).exists();
+        assertThat(targetRoot.resolve("foo/docs/examples.py")).exists();
+    }
+
+    @Test
+    void cleansOrphanedTargetDirs() throws IOException {
+        // 目标侧已存在一个源侧没有的目录,同步后应被清除
+        Path orphan = targetRoot.resolve("deleted-skill");
+        Files.createDirectories(orphan);
+        Files.writeString(orphan.resolve("SKILL.md"), "name: deleted\n");
+
+        createSkill("foo", "name: foo\n");
+
+        syncer.syncTo(targetRoot);
+
+        assertThat(orphan).doesNotExist();
+        assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
+    }
+
+    @Test
+    void mirrorsSourceFileDeletionWithinSkill() throws IOException {
+        // 首次同步:foo 含 SKILL.md + obsolete.md
+        createSkill("foo", "name: foo\n");
+        write(sourceRoot.resolve("foo"), "obsolete.md", "will be deleted");
+
+        syncer.syncTo(targetRoot);
+        assertThat(targetRoot.resolve("foo/obsolete.md")).exists();
+
+        // 删除源侧 obsolete.md,再次同步
+        Files.delete(sourceRoot.resolve("foo").resolve("obsolete.md"));
+        syncer.syncTo(targetRoot);
+
+        // 目标侧 obsolete.md 应被镜像清除(先删后拷语义)
+        assertThat(targetRoot.resolve("foo/obsolete.md")).doesNotExist();
+        assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
+    }
+
+    @Test
+    void multipleSkillsAllChecked() throws IOException {
+        createSkill("foo", "name: foo\n");
+        createSkill("bar", "name: bar\n");
+        // foo 下有残留
+        Path genFoo = mkSubDir("foo", "generated_skills");
+        write(genFoo, "SKILL.md", "name: gen\n");
+        // bar 下正常
+        Path docsBar = mkSubDir("bar", "docs");
+        write(docsBar, "x.md", "x");
+
+        syncer.syncTo(targetRoot);
+
+        assertThat(targetRoot.resolve("foo/SKILL.md")).exists();
+        assertThat(targetRoot.resolve("foo/generated_skills")).doesNotExist();
+        assertThat(targetRoot.resolve("bar/SKILL.md")).exists();
+        assertThat(targetRoot.resolve("bar/docs/x.md")).exists();
+    }
+
+    @Test
+    void returnsZeroWhenSourceMissing() throws IOException {
+        // sourceRoot 不存在(删掉)
+        sourceRoot.toFile().delete();
+
+        int copied = syncer.syncTo(targetRoot);
+
+        assertThat(copied).isZero();
+    }
+}

+ 249 - 0
backend/src/test/java/com/agent/management/engine/StructuredOutputFromTextTest.java

@@ -0,0 +1,249 @@
+package com.agent.management.engine;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.ai.chat.client.ChatClient;
+
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Supplier;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link StructuredOutputHelper#extractWithRetryFromText} 单元测试。
+ *
+ * <p>覆盖 Hermes / Agent 系列接入入口的核心场景:</p>
+ * <ul>
+ *   <li>原始文本直接解析成功(不需重组)</li>
+ *   <li>原始文本需内层重组(用 reformatClient 整理为 JSON)</li>
+ *   <li>内层耗尽 → supplier 重新生成 rawText(外层重试)</li>
+ *   <li>supplier 为 null 时不做外层重试</li>
+ *   <li>所有重试耗尽返回失败</li>
+ * </ul>
+ */
+class StructuredOutputFromTextTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private ChatClient client;
+    private ChatClient.ChatClientRequestSpec spec;
+    private ChatClient.CallResponseSpec callResponse;
+
+    @BeforeEach
+    void setUp() {
+        client = mock(ChatClient.class);
+        spec = mock(ChatClient.ChatClientRequestSpec.class);
+        callResponse = mock(ChatClient.CallResponseSpec.class);
+
+        when(client.prompt()).thenReturn(spec);
+        when(spec.user(anyString())).thenReturn(spec);
+        when(spec.system(anyString())).thenReturn(spec);
+        when(spec.call()).thenReturn(callResponse);
+    }
+
+    private static JsonNode outputsDecl(String... fields) {
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < fields.length; i++) {
+            if (i > 0) sb.append(",");
+            String[] parts = fields[i].split("\\|");
+            sb.append("{\"name\":\"").append(parts[0]).append("\",")
+              .append("\"type\":\"").append(parts[1]).append("\",")
+              .append("\"required\":true");
+            sb.append("}");
+        }
+        sb.append("]");
+        try {
+            return MAPPER.readTree(sb.toString());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private void mockReformatResponses(String... responses) {
+        AtomicInteger idx = new AtomicInteger(0);
+        when(callResponse.content()).thenAnswer(inv -> {
+            int i = idx.getAndIncrement();
+            return i < responses.length ? responses[i] : responses[responses.length - 1];
+        });
+    }
+
+    // ========== 用例 1:rawText 直接是有效 JSON ==========
+
+    @Test
+    void rawTextValidJson_noReformatNeeded() {
+        JsonNode decl = outputsDecl("status|number", "message|string");
+        // reformatClient 不会被调用
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "{\"status\":200,\"message\":\"ok\"}",
+                        decl, policy, client, null);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200).containsEntry("message", "ok");
+        // 不调用 reformatClient
+        verify(spec, times(0)).call();
+    }
+
+    // ========== 用例 2:rawText 中文键名 → 内层重组成功 ==========
+
+    @Test
+    void chineseKeys_inRawText_triggerReformat() {
+        JsonNode decl = outputsDecl("status|number");
+        mockReformatResponses("{\"status\":200}");
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "{\"状态\":200}", decl, policy, client, null);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200);
+        // 1 次内层重组调用
+        verify(spec, times(1)).call();
+    }
+
+    // ========== 用例 3:内层耗尽 → supplier 重新生成 → 成功 ==========
+
+    @Test
+    void reformatExhausted_supplierProvidesNewText_succeeds() {
+        JsonNode decl = outputsDecl("status|number");
+        // 第 1 次外层:rawText 是中文,内层重组 1 次仍中文 → 内层耗尽(formatMaxRetries=2 时最多调 1 次重组)
+        // 第 2 次外层:supplier 返回英文 JSON → 直接解析成功,不再重组
+        mockReformatResponses("{\"状态\":200}");
+
+        AtomicInteger supplierCalls = new AtomicInteger(0);
+        Supplier<String> supplier = () -> {
+            supplierCalls.incrementAndGet();
+            return "{\"status\":200}";
+        };
+
+        // runtimeMaxRetries=2, formatMaxRetries=2
+        RetryPolicy policy = new RetryPolicy(2, 2, true, true, true, true);
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "{\"状态\":200}", decl, policy, client, supplier);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200);
+        assertThat(supplierCalls.get()).isEqualTo(1);
+        // 第 1 次外层触发 1 次重组;第 2 次外层 rawText 直接解析成功不重组
+        verify(spec, times(1)).call();
+    }
+
+    // ========== 用例 4:supplier 为 null 时直接失败 ==========
+
+    @Test
+    void supplierNull_innerExhausted_fails() {
+        JsonNode decl = outputsDecl("status|number");
+        // 内层重组 2 次都返回中文 → 耗尽
+        mockReformatResponses(
+                "{\"状态\":200}",
+                "{\"状态\":200}"
+        );
+
+        RetryPolicy policy = new RetryPolicy(2, 2, true, true, true, true);
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "{\"状态\":200}", decl, policy, client, null);
+
+        assertThat(result.errorMessage).isNotNull();
+        // supplier 为 null,不进入外层重试,重组调用 = formatMaxRetries-1 = 1
+        verify(spec, times(1)).call();
+    }
+
+    // ========== 用例 5:rawText 空字符串 → 失败 ==========
+
+    @Test
+    void emptyRawText_returnsFail() {
+        JsonNode decl = outputsDecl("status|number");
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "", decl, policy, client, null);
+
+        assertThat(result.errorMessage).contains("原始响应为空");
+        assertThat(result.attempts).isEqualTo(0);
+    }
+
+    // ========== 用例 6:null outputsDecl → 失败 ==========
+
+    @Test
+    void nullOutputsDecl_returnsFail() {
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "anything", null, policy, client, null);
+
+        assertThat(result.errorMessage).contains("outputs");
+    }
+
+    // ========== 用例 7:null reformatClient → 失败 ==========
+
+    @Test
+    void nullReformatClient_returnsFail() {
+        JsonNode decl = outputsDecl("status|number");
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "{\"status\":200}", decl, policy, null, null);
+
+        assertThat(result.errorMessage).contains("reformatClient");
+    }
+
+    // ========== 用例 8:supplier 异常 → 升级失败 ==========
+
+    @Test
+    void supplierThrows_exceptionPropagatedAsError() {
+        JsonNode decl = outputsDecl("status|number");
+        // 内层重组耗尽
+        mockReformatResponses(
+                "{\"状态\":200}",
+                "{\"状态\":200}"
+        );
+
+        AtomicInteger supplierCalls = new AtomicInteger(0);
+        Supplier<String> supplier = () -> {
+            supplierCalls.incrementAndGet();
+            throw new RuntimeException("hermes down");
+        };
+
+        RetryPolicy policy = new RetryPolicy(2, 2, true, true, true, true);
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "{\"状态\":200}", decl, policy, client, supplier);
+
+        assertThat(result.errorMessage).contains("hermes down");
+        assertThat(supplierCalls.get()).isEqualTo(1);
+    }
+
+    // ========== 用例 9:rawText 是非 JSON 自由文本 → 内层重组 ==========
+
+    @Test
+    void freeTextRawText_reformatByLlm() {
+        JsonNode decl = outputsDecl("summary|string");
+        // Hermes finalText 是自由文本,内层 LLM 把它整理成 JSON
+        mockReformatResponses("{\"summary\":\"这是总结\"}");
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetryFromText(
+                        "经过分析,结论是:这是总结。",
+                        decl, policy, client, null);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("summary", "这是总结");
+        verify(spec, times(1)).call();
+    }
+}

+ 368 - 0
backend/src/test/java/com/agent/management/engine/StructuredOutputHelperDualLoopTest.java

@@ -0,0 +1,368 @@
+package com.agent.management.engine;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.ai.chat.client.ChatClient;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * {@link StructuredOutputHelper#extractWithRetry} 双层重试单元测试。
+ *
+ * <p>覆盖:</p>
+ * <ul>
+ *   <li>外层(运行重试):LLM 调用异常或空响应时重新执行完整推理</li>
+ *   <li>内层(格式重试):中文键名、字段缺失、类型不匹配时仅重组 JSON,不重跑业务逻辑</li>
+ *   <li>内层耗尽升级到外层</li>
+ *   <li>重组 prompt 内容验证(不含原 userPrompt、列出缺失键名)</li>
+ * </ul>
+ */
+class StructuredOutputHelperDualLoopTest {
+
+    private static final ObjectMapper MAPPER = new ObjectMapper();
+
+    private ChatClient client;
+    private ChatClient.ChatClientRequestSpec spec;
+    private ChatClient.CallResponseSpec callResponse;
+
+    @BeforeEach
+    void setUp() {
+        client = mock(ChatClient.class);
+        spec = mock(ChatClient.ChatClientRequestSpec.class);
+        callResponse = mock(ChatClient.CallResponseSpec.class);
+
+        when(client.prompt()).thenReturn(spec);
+        when(spec.user(anyString())).thenReturn(spec);
+        when(spec.system(anyString())).thenReturn(spec);
+        when(spec.call()).thenReturn(callResponse);
+    }
+
+    private static JsonNode outputsDecl(String... fields) {
+        // 默认 required=true(匹配修订后 strict 模式语义:只对 required 字段做键名校验)
+        StringBuilder sb = new StringBuilder("[");
+        for (int i = 0; i < fields.length; i++) {
+            if (i > 0) sb.append(",");
+            String[] parts = fields[i].split("\\|");
+            sb.append("{\"name\":\"").append(parts[0]).append("\",")
+              .append("\"type\":\"").append(parts[1]).append("\",")
+              .append("\"required\":true");
+            sb.append("}");
+        }
+        sb.append("]");
+        try {
+            return MAPPER.readTree(sb.toString());
+        } catch (Exception e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    /**
+     * 配置 mock ChatClient 按 prompt 内容返回不同响应:
+     * - 含 "重新组织" → 重组调用
+     * - 其他 → 完整推理调用
+     */
+    private void mockResponsesByPrompt(String fullResponse, String reformatResponse) {
+        when(callResponse.content()).thenAnswer(inv -> {
+            // 通过 spec.user(...) 的最近一次调用判断
+            // 这里简化:使用顺序——第一次调用返回 fullResponse,后续返回 reformatResponse
+            return null; // 由下方 answer 替换
+        });
+    }
+
+    /**
+     * 按调用顺序返回响应。
+     */
+    private void mockResponsesInOrder(String... responses) {
+        AtomicInteger idx = new AtomicInteger(0);
+        when(callResponse.content()).thenAnswer(inv -> {
+            int i = idx.getAndIncrement();
+            return i < responses.length ? responses[i] : responses[responses.length - 1];
+        });
+    }
+
+    // ========== 用例 1:首次 OK 不重试 ==========
+
+    @Test
+    void firstCallOk_noRetry() {
+        JsonNode decl = outputsDecl("status|number", "message|string");
+        mockResponsesInOrder("{\"status\":200,\"message\":\"ok\"}");
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "do work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200).containsEntry("message", "ok");
+        assertThat(result.attempts).isEqualTo(1);
+        verify(spec, times(1)).call();
+    }
+
+    // ========== 用例 2:中文键名 → 内层重组重试成功 ==========
+
+    @Test
+    void chineseKeys_triggerFormatRetry() {
+        JsonNode decl = outputsDecl("status|number", "message|string");
+        // 第 1 次:返回中文键名;第 2 次:内层重组,返回英文键名
+        mockResponsesInOrder(
+                "{\"状态\":200,\"信息\":\"ok\"}",
+                "{\"status\":200,\"message\":\"ok\"}"
+        );
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "do work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200).containsEntry("message", "ok");
+        // 1 次外层 + 1 次内层重组 = 2 次调用
+        verify(spec, times(2)).call();
+    }
+
+    // ========== 用例 3:LLM 异常 → 外层重试 ==========
+
+    @Test
+    void runtimeException_triggersRuntimeRetry() {
+        JsonNode decl = outputsDecl("status|number");
+        // 第 1 次抛异常;第 2 次返回正确 JSON
+        when(callResponse.content())
+                .thenThrow(new RuntimeException("rate limit"))
+                .thenReturn("{\"status\":200}");
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "do work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200);
+        verify(spec, times(2)).call();
+    }
+
+    // ========== 用例 4:内层重试耗尽 → 升级到外层 ==========
+
+    @Test
+    void formatRetryExhausted_upgradesToRuntime() {
+        JsonNode decl = outputsDecl("status|number", "message|string");
+        // 第 1 次外层:返回中文;第 2 次内层重组:仍是中文(formatMaxRetries=2,耗尽)
+        // 第 3 次外层:返回英文 → 成功
+        mockResponsesInOrder(
+                "{\"状态\":200,\"信息\":\"ok\"}",     // 外层 1
+                "{\"状态\":200,\"信息\":\"ok\"}",     // 内层重组 1(仍中文)
+                "{\"status\":200,\"message\":\"ok\"}" // 外层 2(成功)
+        );
+
+        // formatMaxRetries=2:内层最多尝试 2 次(首次解析 + 1 次重组)
+        RetryPolicy policy = new RetryPolicy(2, 2, true, true, true, true);
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "do work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200).containsEntry("message", "ok");
+        // 1(外层1)+ 1(内层重组1)+ 1(外层2)= 3 次调用
+        verify(spec, times(3)).call();
+    }
+
+    // ========== 用例 5:所有重试都耗尽 → 返回失败 ==========
+
+    @Test
+    void allRetriesExhausted_returnsFail() {
+        JsonNode decl = outputsDecl("status|number");
+        // runtimeMaxRetries=2, formatMaxRetries=2 → 每次外层触发 1 次完整 + 1 次重组 = 2 次调用
+        // 2 个外层 → 总 4 次 LLM 调用
+        mockResponsesInOrder("{\"状态\":200}", "{\"状态\":200}", "{\"状态\":200}", "{\"状态\":200}");
+
+        RetryPolicy policy = new RetryPolicy(2, 2, true, true, true, true);
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "do work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNotNull();
+        assertThat(result.errorMessage).contains("status");
+        assertThat(result.data).isEmpty();
+        // 2 次外层完整推理 + 2 次内层重组 = 4 次
+        verify(spec, times(4)).call();
+    }
+
+    // ========== 用例 6:重组 prompt 不包含原 userPrompt ==========
+
+    @Test
+    void reformatPrompt_doesNotContainUserPrompt() {
+        JsonNode decl = outputsDecl("status|number");
+        mockResponsesInOrder(
+                "{\"状态\":200}",          // 外层返回中文
+                "{\"status\":200}"          // 内层重组成功
+        );
+
+        String userPrompt = "BUSINESS_SECRET_PROMPT_DO_NOT_LEAK";
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, userPrompt, null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+
+        // 捕获所有 prompt 调用
+        ArgumentCaptor<String> promptCaptor = ArgumentCaptor.forClass(String.class);
+        verify(spec, times(2)).user(promptCaptor.capture());
+
+        // 第 1 次:完整 prompt 应包含 userPrompt
+        assertThat(promptCaptor.getAllValues().get(0)).contains(userPrompt);
+        // 第 2 次:重组 prompt 不应包含 userPrompt
+        assertThat(promptCaptor.getAllValues().get(1)).doesNotContain(userPrompt);
+    }
+
+    // ========== 用例 7:重组 prompt 明确列出缺失键名 ==========
+
+    @Test
+    void reformatPrompt_listsAllMissingKeys() {
+        JsonNode decl = outputsDecl("status|number", "message|string", "data|object");
+        // 返回全中文键 → 所有英文声明都缺失
+        mockResponsesInOrder(
+                "{\"状态\":200,\"信息\":\"ok\",\"数据\":{}}",
+                "{\"status\":200,\"message\":\"ok\",\"data\":{}}"
+        );
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+
+        ArgumentCaptor<String> promptCaptor = ArgumentCaptor.forClass(String.class);
+        verify(spec, times(2)).user(promptCaptor.capture());
+
+        // 重组 prompt 必须列出所有缺失的英文键名
+        String reformatPrompt = promptCaptor.getAllValues().get(1);
+        assertThat(reformatPrompt).contains("status")
+                .contains("message")
+                .contains("data");
+        assertThat(reformatPrompt).contains("重新组织");
+    }
+
+    // ========== 用例 8:retryOnKeyNameMismatch=false 时不重组 ==========
+
+    @Test
+    void keyNameMismatchDisabled_noReformatRetry() {
+        JsonNode decl = outputsDecl("status|number");
+        mockResponsesInOrder("{\"状态\":200}");
+
+        // 关闭键名不匹配重试
+        RetryPolicy policy = new RetryPolicy(2, 2, true, true, true, false);
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNotNull();
+        assertThat(result.errorMessage).contains("status");
+        // 仅 1 次调用,不重组
+        verify(spec, times(1)).call();
+    }
+
+    // ========== 用例 9:空响应 → 外层重试 ==========
+
+    @Test
+    void emptyResponse_triggersRuntimeRetry() {
+        JsonNode decl = outputsDecl("status|number");
+        // 第 1 次空;第 2 次正常
+        when(callResponse.content())
+                .thenReturn("")
+                .thenReturn("{\"status\":200}");
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200);
+        verify(spec, times(2)).call();
+    }
+
+    // ========== 用例 10:类型不匹配 → 内层重组 ==========
+
+    @Test
+    void typeMismatch_triggersFormatRetry() {
+        JsonNode decl = outputsDecl("status|number");
+        // 第 1 次:status 是字符串 "abc",无法转 number;第 2 次重组为数字
+        mockResponsesInOrder(
+                "{\"status\":\"abc\"}",
+                "{\"status\":200}"
+        );
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200);
+        verify(spec, times(2)).call();
+    }
+
+    // ========== 用例 11:parse 失败(非 JSON) → 内层重组 ==========
+
+    @Test
+    void parseFailure_triggersFormatRetry() {
+        JsonNode decl = outputsDecl("status|number");
+        mockResponsesInOrder(
+                "this is not json at all",
+                "{\"status\":200}"
+        );
+
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "work", null, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        assertThat(result.data).containsEntry("status", 200);
+        verify(spec, times(2)).call();
+    }
+
+    // ========== 用例 12:自定义 systemPrompt 被传递 ==========
+
+    @Test
+    void systemPromptPassedToBothCalls() {
+        JsonNode decl = outputsDecl("status|number");
+        mockResponsesInOrder("{\"status\":200}");
+
+        String systemPrompt = "you are a JSON generator";
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "work", systemPrompt, decl, policy);
+
+        assertThat(result.errorMessage).isNull();
+        verify(spec, times(1)).system(systemPrompt);
+    }
+
+    // ========== 用例 13:null client / null decl 直接失败 ==========
+
+    @Test
+    void nullClient_returnsFail() {
+        JsonNode decl = outputsDecl("status|number");
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(null, "work", null, decl, policy);
+
+        assertThat(result.errorMessage).contains("ChatClient");
+        assertThat(result.attempts).isEqualTo(0);
+    }
+
+    @Test
+    void emptyOutputsDecl_returnsFail() {
+        RetryPolicy policy = RetryPolicy.defaultPolicy();
+
+        StructuredOutputHelper.ExtractionResult result =
+                StructuredOutputHelper.extractWithRetry(client, "work", null, null, policy);
+
+        assertThat(result.errorMessage).contains("outputs");
+        assertThat(result.attempts).isEqualTo(0);
+    }
+}

+ 296 - 0
backend/src/test/java/com/agent/management/service/HermesErrorPatternServiceTest.java

@@ -0,0 +1,296 @@
+package com.agent.management.service;
+
+import com.agent.management.common.exception.BusinessException;
+import com.agent.management.model.entity.HermesErrorPattern;
+import com.agent.management.repository.HermesErrorPatternRepository;
+import com.agent.management.repository.json.JsonStore;
+import com.agent.management.service.impl.HermesErrorPatternServiceImpl;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * {@link HermesErrorPatternServiceImpl} 单元测试。
+ *
+ * <p>覆盖:</p>
+ * <ul>
+ *   <li>B 方案核心:{@code isErrorResponse} 子串匹配(大小写不敏感、SCAN_LIMIT、空响应)</li>
+ *   <li>CRUD:创建/编辑/删除/切换/重置</li>
+ *   <li>内置模式保护:禁止删除、禁止改名</li>
+ *   <li>启动时自动初始化内置模式</li>
+ * </ul>
+ */
+class HermesErrorPatternServiceTest {
+
+    @TempDir
+    Path tempDir;
+
+    private HermesErrorPatternRepository repository;
+    private HermesErrorPatternServiceImpl service;
+
+    @BeforeEach
+    void setUp() {
+        JsonStore store = new JsonStore(tempDir);
+        repository = new HermesErrorPatternRepository(store);
+        service = new HermesErrorPatternServiceImpl(repository);
+        // 触发 @PostConstruct 逻辑(单元测试不走 Spring 生命周期)
+        service.initBuiltInPatterns();
+    }
+
+    // ========== 嗅探匹配 ==========
+
+    @Test
+    void isErrorResponse_matchesExactSubstring() {
+        // 内置模式 "API call failed after"
+        HermesErrorPatternService.ErrorDetectionResult r =
+                service.isErrorResponse("API call failed after 3 retries: HTTP 429");
+        assertThat(r.isError()).isTrue();
+        assertThat(r.getMatchedPatternName()).isEqualTo("LLM SDK 重试耗尽");
+    }
+
+    @Test
+    void isErrorResponse_caseInsensitive() {
+        // 大写不敏感
+        assertThat(service.isErrorResponse("api call failed after 1 retry").isError()).isTrue();
+        assertThat(service.isErrorResponse("API CALL FAILED AFTER 3 RETRIES").isError()).isTrue();
+    }
+
+    @Test
+    void isErrorResponse_matchesChinesePattern() {
+        // 内置模式 "该模型当前访问量过大"
+        assertThat(service.isErrorResponse("错误:该模型当前访问量过大,请稍后重试").isError()).isTrue();
+    }
+
+    @Test
+    void isResponse_noMatch_returnsFalse() {
+        assertThat(service.isErrorResponse("这是一段正常的 LLM 回复,内容涉及 HTTP 协议历史").isError()).isFalse();
+    }
+
+    @Test
+    void isResponse_nullOrBlank_returnsFalse() {
+        assertThat(service.isErrorResponse(null).isError()).isFalse();
+        assertThat(service.isErrorResponse("").isError()).isFalse();
+        assertThat(service.isErrorResponse("   ").isError()).isFalse();
+    }
+
+    @Test
+    void isResponse_disabledPatternNotMatched() {
+        // 创建一个新模式,禁用后应不参与匹配
+        HermesErrorPattern p = new HermesErrorPattern();
+        p.setName("测试专用");
+        p.setPattern("MY_VERY_UNIQUE_TEST_MARKER");
+        p.setEnabled(true);
+        p = service.create(p);
+        assertThat(service.isErrorResponse("文本包含 MY_VERY_UNIQUE_TEST_MARKER").isError()).isTrue();
+
+        // 禁用后不再匹配
+        service.update(p.getId(), buildUpdate(p.getName(), p.getPattern(), p.getDescription(), false));
+        assertThat(service.isErrorResponse("文本包含 MY_VERY_UNIQUE_TEST_MARKER").isError()).isFalse();
+    }
+
+    @Test
+    void isResponse_scanLimit_doesNotMissErrorsAtStart() {
+        // 内置错误前缀在 finalText 开头,超过 SCAN_LIMIT 后的部分不算(但开头能命中)
+        String head = "API call failed after 3 retries";
+        // 即便后面有 600 字符的"正常文本",开头仍是错误
+        String body = head + "a".repeat(600);
+        assertThat(service.isErrorResponse(body).isError()).isTrue();
+    }
+
+    @Test
+    void isResponse_scanLimit_patternBeyondLimitNotMatched() {
+        // 自定义 pattern 只在超过 500 字符后出现,不应被匹配(SCAN_LIMIT 截断)
+        HermesErrorPattern p = new HermesErrorPattern();
+        p.setName("超长模式");
+        p.setPattern("ZZZ_UNIQUE_BEYOND_LIMIT");
+        p.setEnabled(true);
+        service.create(p);
+        String body = "a".repeat(600) + " ZZZ_UNIQUE_BEYOND_LIMIT";
+        assertThat(service.isErrorResponse(body).isError()).isFalse();
+    }
+
+    // ========== 内置初始化 ==========
+
+    @Test
+    void initBuiltInPatterns_writesDefaultsOnFirstRun() {
+        List<HermesErrorPattern> all = service.list();
+        assertThat(all.size()).isGreaterThanOrEqualTo(8);
+        // 都应是内置
+        assertThat(all.stream().filter(p -> Boolean.TRUE.equals(p.getIsBuiltIn())).count()).isEqualTo(all.size());
+    }
+
+    @Test
+    void initBuiltInPatterns_doesNotOverwriteUserEdits() {
+        // 用户禁用某条内置
+        HermesErrorPattern first = service.list().get(0);
+        service.update(first.getId(),
+                buildUpdate(first.getName(), first.getPattern(), first.getDescription(), false));
+
+        // 再次触发初始化(模拟重启)
+        service.initBuiltInPatterns();
+
+        // 内置数量仍是 8 条(不会被重新写入覆盖)
+        assertThat(service.list().size()).isGreaterThanOrEqualTo(8);
+        // 那条内置仍是禁用状态
+        assertThat(repository.findById(first.getId()).orElseThrow().getEnabled()).isFalse();
+    }
+
+    // ========== CRUD ==========
+
+    @Test
+    void create_savesCustomPattern() {
+        HermesErrorPattern p = new HermesErrorPattern();
+        p.setName("自定义模式");
+        p.setPattern("custom error");
+        p.setEnabled(true);
+        HermesErrorPattern saved = service.create(p);
+
+        assertThat(saved.getId()).isNotNull();
+        assertThat(saved.getIsBuiltIn()).isFalse();
+        assertThat(saved.getCreatedAt()).isNotNull();
+    }
+
+    @Test
+    void create_duplicateNameRejected() {
+        HermesErrorPattern p1 = new HermesErrorPattern();
+        p1.setName("唯一名");
+        p1.setPattern("pat1");
+        service.create(p1);
+
+        HermesErrorPattern p2 = new HermesErrorPattern();
+        p2.setName("唯一名");
+        p2.setPattern("pat2");
+        assertThatThrownBy(() -> service.create(p2))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("已存在");
+    }
+
+    @Test
+    void create_emptyFieldsRejected() {
+        HermesErrorPattern empty = new HermesErrorPattern();
+        assertThatThrownBy(() -> service.create(empty)).isInstanceOf(BusinessException.class);
+
+        HermesErrorPattern noPattern = new HermesErrorPattern();
+        noPattern.setName("test");
+        assertThatThrownBy(() -> service.create(noPattern)).isInstanceOf(BusinessException.class);
+    }
+
+    @Test
+    void delete_builtinRejected() {
+        HermesErrorPattern builtin = service.list().stream()
+                .filter(p -> Boolean.TRUE.equals(p.getIsBuiltIn())).findFirst().orElseThrow();
+        assertThatThrownBy(() -> service.delete(builtin.getId()))
+                .isInstanceOf(BusinessException.class)
+                .hasMessageContaining("内置");
+    }
+
+    @Test
+    void delete_customOk() {
+        HermesErrorPattern p = new HermesErrorPattern();
+        p.setName("待删");
+        p.setPattern("to-delete");
+        HermesErrorPattern saved = service.create(p);
+        service.delete(saved.getId());
+        assertThat(repository.findById(saved.getId())).isEmpty();
+    }
+
+    @Test
+    void update_builtinCanEditPatternButNotRename() {
+        HermesErrorPattern builtin = service.list().stream()
+                .filter(p -> Boolean.TRUE.equals(p.getIsBuiltIn())).findFirst().orElseThrow();
+        String originalName = builtin.getName();
+
+        HermesErrorPattern req = new HermesErrorPattern();
+        req.setName("试图改名");
+        req.setPattern("new-pattern-text");
+        req.setDescription("改描述");
+        req.setEnabled(false);
+
+        HermesErrorPattern updated = service.update(builtin.getId(), req);
+        assertThat(updated.getPattern()).isEqualTo("new-pattern-text");
+        assertThat(updated.getDescription()).isEqualTo("改描述");
+        assertThat(updated.getEnabled()).isFalse();
+        // 名称不变
+        assertThat(updated.getName()).isEqualTo(originalName);
+    }
+
+    @Test
+    void toggle_flipsEnabled() {
+        HermesErrorPattern p = new HermesErrorPattern();
+        p.setName("toggle test");
+        p.setPattern("pat");
+        p.setEnabled(true);
+        HermesErrorPattern saved = service.create(p);
+
+        HermesErrorPattern toggled = service.toggle(saved.getId());
+        assertThat(toggled.getEnabled()).isFalse();
+
+        HermesErrorPattern toggledAgain = service.toggle(saved.getId());
+        assertThat(toggledAgain.getEnabled()).isTrue();
+    }
+
+    @Test
+    void resetToDefaults_removesCustomAndReEnablesBuiltin() {
+        // 禁用一条内置
+        HermesErrorPattern builtin = service.list().stream()
+                .filter(p -> Boolean.TRUE.equals(p.getIsBuiltIn())).findFirst().orElseThrow();
+        service.update(builtin.getId(),
+                buildUpdate(builtin.getName(), builtin.getPattern(), builtin.getDescription(), false));
+
+        // 新增一条自定义
+        HermesErrorPattern custom = new HermesErrorPattern();
+        custom.setName("custom");
+        custom.setPattern("custom-pat");
+        service.create(custom);
+
+        service.resetToDefaults();
+
+        List<HermesErrorPattern> all = service.list();
+        // 自定义被清空
+        assertThat(all.stream().noneMatch(p -> "custom".equals(p.getName()))).isTrue();
+        // 内置全部恢复启用
+        assertThat(all.stream()
+                .filter(p -> Boolean.TRUE.equals(p.getIsBuiltIn()))
+                .allMatch(p -> Boolean.TRUE.equals(p.getEnabled()))).isTrue();
+    }
+
+    // ========== 排序:内置在前 ==========
+
+    @Test
+    void list_builtinFirst() {
+        // 加几条自定义
+        for (int i = 0; i < 3; i++) {
+            HermesErrorPattern p = new HermesErrorPattern();
+            p.setName("自定义" + i);
+            p.setPattern("pat" + i);
+            service.create(p);
+        }
+        List<HermesErrorPattern> all = service.list();
+        // 内置在前
+        boolean seenCustom = false;
+        for (HermesErrorPattern p : all) {
+            if (Boolean.TRUE.equals(p.getIsBuiltIn())) {
+                assertThat(seenCustom).as("内置不应出现在自定义之后").isFalse();
+            } else {
+                seenCustom = true;
+            }
+        }
+    }
+
+    // ========== 辅助 ==========
+
+    private HermesErrorPattern buildUpdate(String name, String pattern, String description, Boolean enabled) {
+        HermesErrorPattern p = new HermesErrorPattern();
+        p.setName(name);
+        p.setPattern(pattern);
+        p.setDescription(description);
+        p.setEnabled(enabled);
+        return p;
+    }
+}

+ 748 - 0
docs/dify-readme.md

@@ -0,0 +1,748 @@
+# Dify 参考项目说明
+
+> 项目位置:`D:/projects/agent-management/dify`(外部参考仓库,**不参与本仓库构建**)
+> 版本快照:`1.16.0`
+> 文档目的:为 agent-management 后续在工作流编排、智能体调度、节点执行信封化等方向的演进提供可借鉴的工程参考。
+
+---
+
+## 0. 一句话定位
+
+Dify 是一个 **开源 LLMOSS(LLM Operating System / Stack)**,对外提供 Chatbot / Workflow / Agent / Text-Generator / RAG 知识库五类应用形态,底层通过 **Flask API + Celery Worker + Plugin Daemon(Go)+ Agent Backend(Python/pydantic-ai)+ Local Sandbox(Go/Landlock)** 一组协同服务完成大模型应用的编排、执行与扩展。
+
+**与 agent-management 项目的关系**:Dify 是同类问题的"重型开源参考实现"。它在工作流引擎(DAG 调度)、智能体运行时(pydantic-ai)、插件化模型/工具(plugin_daemon)、HITL 暂停-恢复(pause_state_persist_layer)等方面有成熟工程化方案,可作为本仓库工作流改造、Hermes 智能体执行、Skill 文件同步等模块的设计参照。
+
+---
+
+## 1. 主要做了哪些工作
+
+### 1.1 应用形态抽象(core/app/apps/)
+
+把 LLM 应用统一抽象为 4 类:
+- **ChatApp / AdvancedChatApp**:对话型,AdvancedChatApp 走工作流引擎
+- **CompletionApp**:一次完成型
+- **AgentChatApp**:独立 Agent 应用(非工作流),走 CotAgentRunner / FunctionCallAgentRunner
+- **WorkflowApp**:基于 DAG 画布的工作流应用
+
+每类应用都有独立的 `AppGenerator` 与 `AppQueueManager`,通过统一的 `task_pipeline` 把事件流推送给前端(SSE / WebSocket / Blocking)。
+
+### 1.2 工作流引擎(core/workflow/ + services/workflow/)
+
+- **构图**:前端 ReactFlow 画布导出 `{nodes, edges}` JSON,由 `WorkflowEntry` 装配 `Graph`(来自外部 `graphon` 包)
+- **节点系统**:通过 `DifyNodeFactory`(`core/workflow/node_factory.py:279`)按 `type+version` 注册并构造节点实例
+- **节点类型**:start、end、llm、knowledge_retrieval、if-else、code、template-transform、question-classifier、http-request、variable-aggregator、parameter-extractor、iteration、agent(v1/v2)、human_input、trigger(plugin/schedule/webhook)、datasource 等
+- **调度**:`GraphEngine` 内部线程池(min_workers/max_workers + 弹性伸缩),按"完成即触发后继"方式驱动 DAG
+- **HITL**:原生 `PauseRequestedEvent` + `PauseStatePersistenceLayer`(`core/app/layers/pause_state_persist_layer.py:77-177`)把图运行时状态、流过滤器状态、暂停原因全量序列化到 DB,恢复时重建
+
+### 1.3 RAG 知识库(core/rag/)
+
+- 完整的"文档 → 切分 → 嵌入 → 入库 → 检索 → 重排 → 引用"链路
+- 内置 28 种向量库适配(plugin 化,按需 `uv sync --group vdb-<name>`)
+- 检索结构:splitter / extractor / index_processor / retrieval / rerank / data_post_processor
+
+### 1.4 智能体能力(双轨实现)
+
+| 实现 | 路径 | 推理循环位置 |
+|---|---|---|
+| 老版 Agent Chat | `core/agent/cot_agent_runner.py` / `fc_agent_runner.py` | Dify 内部 while 循环 |
+| 工作流 Agent v1 节点 | `core/workflow/nodes/agent/` | 通过 plugin daemon 外包 |
+| 工作流 Agent v2 节点 | `core/workflow/nodes/agent_v2/` + `dify-agent` 子项目 | 完全外包给独立 `dify-agent-backend` 服务(基于 pydantic-ai) |
+
+### 1.5 插件化扩展(plugin_daemon + 插件市场)
+
+- 所有模型 provider、工具 provider、向量库 provider 都外置为插件
+- 由 Go 编写的 `dify-plugin-daemon` 进程加载与执行
+- API 通过 HTTP/gRPC 调用插件,所有插件共享一份 daemon 维护的连接与凭据
+
+### 1.6 沙箱化 Agent 工作区(dify-agent-runtime,Go)
+
+- `shellctl` 服务器:通过 tmux 控制持久化 Shell 会话
+- `sanitize-pty`:ANSI 控制字符清洗
+- `landlock`:Linux LSM 文件系统隔离
+- 让 Dify Agent 具备"在受限沙箱里执行 Shell 命令"的能力
+
+### 1.7 多端协同与可观测
+
+- 前端基于 **loro-crdt** 实现工作流画布的多人协同编辑
+- 后端基于 **OpenTelemetry** 全链路埋点(Flask / Celery / httpx / redis / sqlalchemy),可对接 Langfuse / LangSmith / Phoenix / MLflow / Arize / Opik / Weave / 阿里云 SLS / 腾讯云
+
+---
+
+## 2. 技术栈
+
+### 2.1 后端 API(`api/`,Python 3.12)
+
+| 类别 | 技术 |
+|---|---|
+| **运行容器** | gunicorn + gevent + gevent-websocket |
+| **Web 框架** | Flask 3.1 + flask-restx + flask-login + flask-migrate + flask-orjson + flask-cors + flask-compress |
+| **OpenAPI** | fastopenapi[flask] 0.7.0 |
+| **实时** | python-socketio 5.13(工作流多人协同 WebSocket) |
+| **任务队列** | Celery 5.6(api / worker / worker_beat 三角色) |
+| **ORM** | SQLAlchemy + Flask-SQLAlchemy(psycopg2-binary / mysql-connector) |
+| **业务库** | PostgreSQL 15(默认) / MySQL 8(备选),通过 docker-compose profile 切换 |
+| **缓存/消息** | Redis 6(hiredis),全后端共用 |
+| **LLM 接入** | **litellm 1.83**(统一收敛) + google-cloud-aiplatform + azure-identity |
+| **HTTP 客户端** | httpx[socks] 0.28 + httpx-sse 0.4 + sseclient-py |
+| **JSON 修复** | json-repair 0.60(结构化输出补救) |
+| **向量库** | 28 种插件化适配(Weaviate / Qdrant / Milvus / pgvector / Chroma / ES / OpenSearch / OceanBase / Oracle / MyScale / ...) |
+| **对象存储** | 10 种插件化适配(S3 / OSS / COS / BOS / OBS / TOS / Azure Blob / GCS / Supabase / OpenDAL) |
+| **Trace** | 8 种插件化适配(Langfuse / LangSmith / Phoenix / MLflow / Arize / Opik / Weave / 阿里云 / 腾讯云) |
+| **可观测** | OpenTelemetry distro + 五种 instrumentation(celery/flask/httpx/redis/sqlalchemy)+ B3 传播 |
+| **其他** | nltk(分词)/ tiktoken(token 计数)/ gmpy2(大数)/ croniter(定时)/ bleach(HTML 消毒)/ zstandard(压缩) |
+
+### 2.2 新一代 Agent 子项目(`dify-agent/`,Python 3.12)
+
+| 类别 | 技术 |
+|---|---|
+| **核心 Agent 框架** | **pydantic-ai-slim 1.102**(关键依赖) |
+| **数据建模** | pydantic 2.12 |
+| **HTTP** | httpx 0.28 + httpx2 2.5 |
+| **独立服务模式** | FastAPI 0.136 + uvicorn 0.46 |
+| **gRPC** | grpclib + protobuf(与 Local Sandbox 通信) |
+| **可观测** | logfire[fastapi,httpx,redis] |
+| **加密** | jwcrypto(Agent Stub Bearer Token / JWE) |
+| **包管理** | uv workspace(被 `api/pyproject.toml` 以 editable 形式引用) |
+
+### 2.3 Agent Runtime 子项目(`dify-agent-runtime/`,Go 1.26)
+
+| 类别 | 技术 |
+|---|---|
+| **CLI 框架** | spf13/cobra 1.10 |
+| **沙箱隔离** | landlock-lsm/go-landlock v0.9(Linux LSM) |
+| **gRPC** | google.golang.org/grpc v1.82 + protobuf v1.36 |
+| **状态存储** | modernc.org/sqlite v1.37(纯 Go SQLite,无 CGO) |
+| **产出二进制** | shellctl / shellctl-runner / shellctl-runner-exit / shellctl-sanitize-pty |
+
+### 2.4 前端(`web/`,TypeScript)
+
+| 类别 | 技术 |
+|---|---|
+| **框架** | Next.js 16 + React 19.2 + React DOM 19.2(App Router / RSC) |
+| **备选构建** | Vinext 1.0-beta(基于 vite-plus,实验性) |
+| **构建工具** | @voidzero-dev/vite-plus-core 0.2.5(通过 overrides 替换官方 vite)+ @vitejs/plugin-rsc |
+| **TypeScript** | typescript 6.0(通过 @typescript/native 走 native 编译器) |
+| **样式** | Tailwind CSS 4.3 + @tailwindcss/typography |
+| **状态管理** | Zustand 5 + Jotai 2.20(含 jotai-effect / jotai-scope / jotai-tanstack-query) |
+| **数据层** | TanStack Query 5.101 + TanStack Form 1.33 + Immer 11 + Zundo 2.3(时间旅行) |
+| **协同** | loro-crdt 1.13(工作流画布多人协同) |
+| **画布** | ReactFlow 11.11 + ELK.js 0.11(图布局) |
+| **富文本/代码** | Lexical 0.47 + Monaco Editor 4.7 |
+| **可视化** | ECharts 6.1 + Mermaid 11.16 + KaTeX 0.17 + Shiki 4.3 + Streamdown(流式 Markdown) |
+| **网络/RPC** | oRPC 1.14(类型安全 RPC)+ ky 2.0 + socket.io-client 4.8 + eventsource-parser 3.1 |
+| **校验** | Zod 4.4 |
+| **i18n** | i18next 26 + react-i18next 17 |
+| **可观测** | Sentry 10.66 + Amplitude(含 session-replay) |
+| **测试** | Vitest 4.1 + Testing Library + Playwright 1.61(+ Cucumber) + Storybook 10.5 |
+
+### 2.5 Monorepo 工作区(`pnpm-workspace.yaml`)
+
+`web` + `e2e` + `sdks/nodejs-client` + `packages/*` + `cli`。
+其中 `packages/*` 包含 7 个内部包:`@dify/contracts` / `@dify/iconify-collections` / `@dify/tsconfig` / `@langgenius/dev-proxy` / `@langgenius/dify-ui` / `jotai-tanstack-form` / `migrate-no-unchecked-indexed-access`。
+
+---
+
+## 3. 必需依赖(运行时无法省略)
+
+最小可运行集(源自 `docker/docker-compose.yaml`):
+
+| 依赖 | 角色 | 必需性 |
+|---|---|---|
+| **PostgreSQL 15** 或 **MySQL 8** | 业务库(dify)+ 插件库(dify_plugin) | 二选一 |
+| **Redis 6** | Celery broker + 缓存 + 协作通道 | 必需,全后端共用 |
+| **dify-plugin-daemon**(Go) | 插件加载与执行,所有模型/工具扩展都通过它 | 必需 |
+| **dify-agent-backend**(dify-agent server) | 新一代 Agent 运行时(pydantic-ai) | Agent v2 节点必需 |
+| **dify-agent-local-sandbox**(dify-agent-runtime Go) | Agent Shell 工作区,提供 `shellctl` 服务 | Agent shell 能力必需 |
+| **dify-sandbox** | 代码节点沙箱(独立于 agent-local-sandbox) | 代码节点 / 工具执行必需 |
+| **ssrf_proxy**(Squid) | 出网安全代理,sandbox 通过它出网 | 默认启用 |
+| **nginx** | 反向代理统一入口(80/443) | 默认启用 |
+| **dify-api / worker / worker_beat** | 业务进程,同一镜像三种启动模式 | 必需 |
+| **dify-web** | Next.js 前端 | 必需 |
+
+向量库(任选其一,默认 **Weaviate**,通过 `VECTOR_STORE` 切换):Qdrant / Milvus(+ etcd + minio)/ pgvector / pgvecto-rs / Chroma / Elasticsearch / OpenSearch / OceanBase / Oracle / MyScale / 等。
+
+可选:**Unstructured**(文档解析,`ETL_TYPE=Unstructured` 时启用)/ **Certbot**(HTTPS 证书)/ **Kibana**(ES profile 下)。
+
+---
+
+## 4. 工作流编排与调度的具体方法
+
+### 4.1 整体架构
+
+```
+前端画布(ReactFlow)
+    │ 导出 graph_config = {nodes, edges}
+    ▼
+WorkflowEntry(api/core/workflow/workflow_entry.py)
+    │ ① Graph.init(graph_config, node_factory)  构造 DAG
+    │ ② 装配 GraphEngine(外部 graphon 包)
+    │ ③ 套上 ResponseStreamFilter 维持响应顺序
+    ▼
+GraphEngine(graphon 包内)
+    │ 内部线程池 + 弹性伸缩
+    │ 按边依赖"完成即触发后继"
+    │ yield GraphEngineEvent
+    ▼
+WorkflowAppQueueManager(api/core/app/apps/workflow/app_queue_manager.py)
+    │ q.put(message)
+    ▼
+TaskPipeline(api/core/app/task_pipeline/)
+    │ ConvertToSSE / StreamGenerating / Pipeline
+    ▼
+前端 SSE / WebSocket
+```
+
+### 4.2 节点生命周期
+
+每个节点继承 `graphon.nodes.base.node.Node`,实现:
+
+1. `version()` 类方法 → 返回版本字符串(如 Agent v2 返回 `"2"`)
+2. `_run()` 异步生成器 → yield 出 `NodeEventBase` 事件
+3. `populate_start_event(event)` → 在节点启动事件中附加元数据
+4. `_extract_variable_selector_to_variable_mapping()` → 声明消费哪些上游变量(用于 publish 时依赖校验)
+
+事件类型:
+- `NodeRunResult`:节点执行结果,包含 `status / inputs / outputs / process_data / metadata / error / error_type`
+- `StreamCompletedEvent`:流结束信号,包装一个 `NodeRunResult`
+- `PauseRequestedEvent`:HITL 暂停信号,携带 `reason`
+
+### 4.3 关键调度特性
+
+- **基于 DAG**:顶层图严格有向无环,依赖边只能指向前序节点
+- **并行执行**:GraphEngine 线程池(min/max workers + scale up/down threshold)
+- **完成即触发**:节点完成后由 GraphEngine 内部决定后继,不做"同层 barrier 等待"(从测试 `test_parallel_human_input_join_resume.py` 可印证并行 + join)
+- **子工作流嵌套**:`_WorkflowChildEngineBuilder`(`workflow_entry.py:72-140`)为工具节点创建隔离的 child GraphEngine,复用父图 variable_pool,但用全新的 GraphRuntimeState 与 InMemoryChannel
+- **顶层图不支持回环**:循环只能通过 Agent 节点内部的 ReAct/FC 迭代实现
+
+### 4.4 失败与重试
+
+**关键差异**:Dify **没有统一的节点级 `failStrategy` 配置**。失败处理分散在各节点内部:
+
+- **Agent v2 节点**:通过 `OutputFailureOrchestrator`(`core/workflow/nodes/agent_v2/output_failure_orchestrator.py`)决策四种动作:`RETRY` / `USE_DEFAULT` / `TAKE_FAIL_BRANCH` / `FAIL`,配合 per-output 的 `OutputTypeChecker` 在每次成功后做类型校验
+- **传输层错误**:流式错误/异常终止直接 yield 失败 envelope,不重试
+- **节点失败后兄弟节点/后继节点**:完全由 GraphEngine 图级策略决定(在 graphon 包内)
+
+> 对照参考:本仓库(agent-management)的 `WorkflowEngine` 把 `failStrategy: abort | skip` 提到引擎层统一处理,对齐所有节点,是比 Dify 更一致的工程改进。
+
+### 4.5 HITL(人工介入)
+
+两条独立路径:
+
+**路径 A:独立 Human Input 节点**
+- `HumanInputNodeData`(`core/workflow/nodes/human_input/entities.py`)→ 表单定义
+- `DifyHITLCallback`(`core/workflow/nodes/human_input/callback.py`)→ 表单生命周期回调
+- `HumanInputFormRepositoryImpl` → 表单状态持久化
+
+**路径 B:Agent v2 内部的 ask_human 延迟工具调用**
+- Agent 在执行过程中通过 `AgentBackendDeferredToolCallInternalEvent` 请求人工输入
+- Dify 转换为 `HumanInputRequired` 暂停事件,保存 `pending_form_id` 与 `pending_tool_call_id` 到 `WorkflowAgentRuntimeSessionStore`
+- 工作流恢复时把表单结果作为 `deferred_tool_results` 注入下一次 Agent backend 调用,让 Agent 在原上下文继续
+
+**持久化层**:`PauseStatePersistenceLayer`(`core/app/layers/pause_state_persist_layer.py:77-177`)在收到 `GraphRunPausedEvent` 时序列化:`generate_entity` + `serialized_graph_runtime_state` + `serialized_response_stream_filter_state` + `pause_reasons`,恢复时全量重建。
+
+### 4.6 流式输出
+
+`GraphEngine` 是一个生成器,产出 `GraphEngineEvent` 流。Dify 通过 `iter_dify_graph_engine_events`(`workflow_entry.py:49-69`)在原始事件流上套 `ResponseStreamFilter`,以维持"按响应顺序输出"的历史语义(兼容 graphon v0.5.0+ 的变量流分块)。最终经 `TaskPipeline` 转换为 SSE / WebSocket 推给前端。
+
+### 4.7 任务队列与多租户
+
+- `WorkflowAppQueueManager`(`core/app/apps/workflow/app_queue_manager.py:24-44`):单工作流执行内的事件队列
+- `QueueDispatcherManager`(`services/workflow/queue_dispatcher.py:72-111`):按租户套餐分配优先级,跨租户隔离
+
+### 4.8 节点类型清单
+
+| 类别 | 节点 |
+|---|---|
+| **入口/出口** | start、end、datasource |
+| **控制流** | if-else、iteration、variable-aggregator、parameter-extractor |
+| **生成式** | llm、question-classifier、template-transform |
+| **执行式** | code、http-request |
+| **知识增强** | knowledge_retrieval、knowledge_index |
+| **智能体** | agent(v1 插件化)、agent_v2(dify-agent 后端) |
+| **人工介入** | human_input |
+| **触发器** | trigger_plugin、trigger_schedule、trigger_webhook |
+
+---
+
+## 5. 多智能体协同方面的探索
+
+### 5.1 重要结论(必须澄清)
+
+**经过完整探索,Dify 项目在"智能调度而非强依赖 DAG"方面没有任何探索。** 所有调度路径都基于强依赖的 DAG,不存在 LLM 决定执行顺序、动态路由、Agent 调度 Agent 的原生模式。
+
+用户在需求描述中提到的"智能调度而非强依赖 DAG",更准确的对应物应该是本仓库(agent-management)`backend/src/main/java/com/agent/management/engine/WorkflowEngine.java` 等近期改造(参见 commit `1e166dd`"节点完成后立即触发后继节点,不再被同层慢节点阻塞"等),而不是 Dify 自身。
+
+下面如实说明 Dify 实际的设计形态。
+
+### 5.2 工作流顶层:严格 DAG
+
+由外部 `graphon` 包的 `GraphEngine` 按边依赖调度,无回环、无动态跳转。
+
+### 5.3 Agent 节点内部:固定循环
+
+- **CotAgentRunner**(`api/core/agent/cot_agent_runner.py:106-266`):固定 while 循环,每次重新组织 prompt(含历史 + scratchpad + 工具描述),通过 ReAct 协议(`Thought/Action/Observation`)解析,最大迭代 `min(max_iteration, 99) + 1`
+- **FunctionCallAgentRunner**(`api/core/agent/fc_agent_runner.py:81-330`):固定 while 循环,使用 LLM 原生 tool_calls
+- **Agent v2**(`api/core/workflow/nodes/agent_v2/`):把推理循环外包给 `dify-agent-backend` 服务的 pydantic-ai `Agent.run()`
+
+三种实现的"决策"都体现在 **tool calling**(调用哪个工具、何时输出最终答案),但工具是预先注册的函数集合,不是图节点,**工具调用 ≠ 图路由**。
+
+### 5.4 agenton Compositor:单 Agent 的层组合(不是多 Agent 协同)
+
+`dify-agent/src/agenton/compositor/` 是一个 **框架中立的层图(layer graph)组合器**,常被误以为是"多智能体协同",实际定位如下:
+
+| 设计点 | 实际情况 |
+|---|---|
+| 目的 | 把**单个 Agent 一次 LLM 调用**所需的 system prompt / user prompt / tools / output contract 分模块组合 |
+| 节点(LayerNode) | 无状态,声明 `name / provider / deps` |
+| 边(deps) | 严格约束为"只能指向前序节点"(`compositor/core.py:257-289`),形成 DAG |
+| 执行顺序 | 在 `CompositorConfig.layers` 中静态声明,**运行时不可变** |
+| 进入/退出 | 按 slots 顺序进入(`run.py:127-145`),反向退出 |
+| 聚合输出 | prompts/user_prompts/tools 通过 `CompositorRun` properties 线性聚合(`run.py:247-278`),扁平化为两个列表喂给单个 pydantic-ai Agent |
+| 是否支持分支/并行/路由 | **全部不支持** |
+| 与 pydantic-ai 关系 | 嵌套:`Compositor.enter()` 外层准备资源 → 内层 `Agent.run()` 跑标准推理循环 |
+
+**已有的 Layer 类型**(`dify-agent/src/dify_agent/layers/`):
+
+- `ask_human/`:人工输入延迟工具(pydantic-ai external deferred tool)
+- `dify_plugin/`:Dify 插件(LLM 与工具)
+- `dify_core_tools/`:Dify 核心工具
+- `knowledge/`:知识库检索工具
+- `output/`:结构化输出契约(提供 pydantic-ai 的 `output_type`)
+- `shell/`:Shell 命令执行(4 个工具:run/wait/input/interrupt)
+- `drive/`、`execution_context/`、`config/`:辅助层
+
+这些 Layer **不直接协作**,各自通过 properties 暴露 prompt/tool,最终被 `dify_agent/runtime/runner.py:321-374` 聚合后**喂给同一个 pydantic-ai Agent**。
+
+### 5.5 Session Snapshot:单 Agent 的断点续传
+
+`CompositorSessionSnapshot`(`compositor/schemas.py:78-115`)让**同一个 agent session 跨多次 agent run 续接**,典型场景:
+
+```
+workflow run → agent run #1(执行到一半遇到 ask_human)→ snapshot 持久化
+            ↓ workflow 暂停(HITL)
+            ↓ 人提交答案
+workflow 恢复 → agent run #2(从 snapshot 复用原状态继续)
+```
+
+这是**单 Agent 的状态持久化**,不是多 Agent 间的协同。文档原话(`dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md:7-11`):
+
+> "A `workflow run` is one full workflow execution. An `agent run` is one Agent execution started by an Agent node while the workflow is running. They are not a one-to-one mapping: one `workflow run` often contains multiple `agent run`s."
+
+**多 Agent 协同是 Dify 外部 workflow 引擎的职责**,dify-agent 只是被工作流调用的执行单元,自身不做编排。
+
+### 5.6 没有 SubAgent / Supervisor / Handoff 抽象
+
+对整个 `dify-agent/` 仓库做了如下针对性搜索,全部零命中:
+
+- `multi.?agent|sub.?agent|subagent|spawn|handoff|delegate|orchestrat|supervisor|manager.?agent` → 命中只在 docstring 字面词或无关语义
+- `LLM.*(decide|choose|route|select).*(order|layer|next)|dynamic.*(execution|graph|dag)|model.?decides` → **零匹配**
+
+`Compositor` 的默认 provider 集合(`dify-agent/src/dify_agent/runtime/compositor_factory.py:77-128`)也不包含任何 `SubAgentLayer` / `RouterLayer` / `SupervisorLayer`。
+
+### 5.7 对本仓库的启示
+
+| Dify 的局限 | 本仓库可能的演进方向 |
+|---|---|
+| 工作流顶层不支持回环 | 引入 Agent 自主路由(让 LLM 决定下一个激活的后继节点)需要扩展 GraphEngine 调度策略 |
+| 无 multi-agent 原生模式 | 把 Agent 节点的 tool calling 扩展为"子图调用"(需扩展 child_engine_builder)或引入"agent 作为另一个 agent 的工具"的语义层 |
+| 无统一 failStrategy | 本仓库已做到(`failStrategy: abort | skip` 在引擎层统一处理) |
+| Envelope 校验仅对 Agent v2 强制 | 本仓库已做到(所有节点统一 `{status, message, data}` envelope) |
+| 分支未选路径状态不清晰 | 本仓库已做到(commit `1e166dd` 提到自动标记 SKIPPED) |
+
+---
+
+## 6. 目录树说明(顶层 + 主要子目录)
+
+### 6.1 顶层目录
+
+```
+dify/
+├── api/                       # Python 后端(主仓库核心,Flask + Celery)
+├── dify-agent/                # Python 新一代 Agent 框架(pydantic-ai + agenton)
+├── dify-agent-runtime/        # Go 实现的 Agent Shell 沙箱运行时(shellctl + landlock)
+├── web/                       # Next.js + React 19 前端(控制台 + Web App)
+├── packages/                  # pnpm workspace 内部包(contracts / dify-ui / dev-proxy 等 7 个)
+├── sdks/                      # 官方 SDK(nodejs-client / php-client)
+├── cli/                       # Dify CLI(TypeScript,独立 npm 包)
+├── docker/                    # docker-compose 编排(含 28 种向量库的 profile 模板)
+├── dev/                       # 本地开发脚本(start-api / start-web / start-worker / start-beat)
+├── scripts/                   # 仓库治理脚本(lint / ast-grep 规则 / 压测)
+├── e2e/                       # E2E 测试(Playwright + Cucumber)
+├── docs/                      # 产品/概念文档(多语言 + design + weaviate 子目录)
+├── images/                    # 仓库用图
+├── README.md / SECURITY.md / LICENSE / CONTRIBUTING.md / AUTHORS
+├── Makefile                   # 顶层构建/测试入口
+├── package.json / pnpm-workspace.yaml / pnpm-lock.yaml  # 前端 monorepo 元数据
+├── vite.config.ts / eslint.config.mjs / lint.config.ts / oxlint-suppressions.json
+├── codecov.yml / depot.json
+├── CLAUDE.md / AGENTS.md      # AI 协作指引
+```
+
+### 6.2 `api/`(Python 后端)
+
+```
+api/
+├── app.py / app_factory.py / dify_app.py / gunicorn.conf.py  # 入口与 WSGI 配置
+├── celery_entrypoint.py / celery_healthcheck.py              # Celery worker/beat 入口
+├── pyproject.toml / pytest.ini / conftest.py                 # 依赖与测试配置
+├── Dockerfile                                                # 主镜像
+├── controllers/         # HTTP 控制器层(console / service_api / openapi / inner_api / web / mcp / trigger / files / common)
+├── services/            # 业务服务层(account / agent / app / dataset / workflow / ...)
+│   └── workflow/        # 工作流编排服务(scheduler / queue_dispatcher / workflow_converter / node_output_inspector_service)
+├── core/                # 核心业务逻辑(详见 6.3)
+├── models/              # SQLAlchemy ORM 实体(account / agent / dataset / workflow / ...)
+├── repositories/        # 仓储层(封装复杂查询)
+├── migrations/          # Alembic 数据库迁移
+├── extensions/          # Flask 扩展注册(ext_database / ext_celery / ext_redis / ext_logging / ...)
+├── configs/             # 分层配置(app_config / feature / enterprise / packaging / observability / middleware / deploy / remote_settings_sources)
+├── events/              # 领域事件与事件处理器(app / dataset / document / message / tenant)
+├── contexts/ / context/ # 请求/应用级上下文(execution_context / flask_app_context / models)
+├── factories/           # 工厂类
+├── fields/              # flask-restx 字段定义
+├── enums/               # 枚举类型
+├── constants/           # 常量
+├── errors/              # 错误与异常
+├── commands/            # CLI 命令(account / data_migrate / plugin / rbac / retention / storage / system / vector)
+├── schedule/            # Celery Beat 定时任务(clean_embedding / clean_messages / clean_workflow_runs / queue_monitor / workflow_schedule / ...)
+├── libs/                # 公共工具库(archive_storage / datetime_utils / encryption / exception / file_utils / infinite_scroll_pagination / json_in_md_parser / ...)
+├── clients/             # 外部客户端
+├── providers/           # 插件化的 provider 适配(vdb/ 向量库,trace/ 可观测)
+├── enterprise/          # 企业版专有代码(telemetry/)
+├── openapi/             # OpenAPI 规范文件
+├── docker/              # 容器内辅助脚本
+└── dev/                 # 本地开发辅助
+```
+
+### 6.3 `api/core/`(核心业务逻辑)
+
+```
+core/
+├── workflow/            # 工作流引擎(顶层入口)
+│   ├── workflow_entry.py            # WorkflowEntry:装配 Graph + GraphEngine + 过滤器(核心文件)
+│   ├── graph_topology.py            # WorkflowGraphTopology:邻接表与上游集合(仅用于校验,非运行时调度)
+│   ├── node_factory.py              # DifyNodeFactory:节点注册与构造
+│   ├── node_runtime.py              # 节点运行时辅助(变量池、HITL 表单仓储)
+│   ├── human_input_*.py             # HITL 适配器、表单、策略
+│   ├── system_variables.py          # 系统级变量定义
+│   ├── variable_pool_initializer.py # 共享变量池初始化
+│   ├── variable_prefixes.py         # 变量前缀解析
+│   ├── template_rendering.py        # Jinja2 模板渲染
+│   ├── file_reference.py            # 文件引用处理
+│   ├── snippet_start.py             # snippet 节点辅助
+│   ├── workflow_run_outputs.py      # 运行输出聚合
+│   ├── generator/                   # LLM 辅助生成工作流(planner/builder prompts)
+│   └── nodes/                       # 各类节点实现
+│       ├── agent/        # Agent v1 节点(plugin 化策略)
+│       ├── agent_v2/     # Agent v2 节点(调用 dify-agent-backend)
+│       ├── datasource/   # 数据源节点
+│       ├── human_input/  # 人工输入节点(表单 + 回调)
+│       ├── knowledge_index/     # 知识库索引节点
+│       ├── knowledge_retrieval/ # 知识库检索节点
+│       ├── trigger_plugin/      # 插件触发器节点
+│       ├── trigger_schedule/    # 定时触发器节点
+│       └── trigger_webhook/     # Webhook 触发器节点
+├── app/                 # 应用抽象层
+│   ├── apps/            # 4 类应用(advanced_chat / agent_app / agent_chat / chat / completion / workflow)
+│   ├── app_config/      # 应用配置(ChatAppConfig / CompletionAppConfig / WorkflowAppConfig / AgentAppConfig)
+│   ├── entities/        # 应用实体
+│   ├── features/        # 应用功能(dialog / file_upload / moderation / suggested_questions / ...)
+│   ├── file_access/     # 文件访问
+│   ├── layers/          # 应用层(pause_state_persist_layer / observability_layer / ...)
+│   ├── llm/             # LLM 调用辅助
+│   ├── task_pipeline/   # 任务管道(SSE / 流生成 / Pipeline)
+│   ├── workflow/        # 工作流应用层(file_runtime / retry_history / layers)
+│   ├── base_app_generator.py / base_app_runner.py / base_app_queue_manager.py
+│   ├── message_generator.py / streaming_utils.py
+│   └── draft_variable_saver.py
+├── agent/               # 独立 Agent Chat App 的 Runner(非工作流节点)
+│   ├── base_agent_runner.py        # 基类
+│   ├── cot_agent_runner.py         # ReAct 推理循环
+│   ├── cot_chat_agent_runner.py    # Chat 模型 ReAct
+│   ├── cot_completion_agent_runner.py # Completion 模型 ReAct
+│   ├── fc_agent_runner.py          # Function Calling 推理循环
+│   ├── output_parser/              # ReAct 输出解析
+│   ├── strategy/                   # 策略(含 plugin 化的 PluginAgentStrategy)
+│   ├── prompt/                     # Agent prompt 模板
+│   └── entities.py / errors.py / plugin_entities.py
+├── rag/                 # RAG 知识库引擎(splitter / extractor / embedding / pipeline / retrieval / rerank / docstore / ...)
+├── tools/               # 工具系统
+│   ├── __base__/        # 工具基类
+│   ├── builtin_tool/    # 内置工具
+│   ├── custom_tool/     # 自定义工具(OpenAPI schema)
+│   ├── plugin_tool/     # 插件工具
+│   ├── mcp_tool/        # MCP 工具
+│   ├── workflow_as_tool/ # 工作流作为工具(关键:child engine builder 的对端)
+│   ├── tool_engine.py / tool_manager.py / tool_file_manager.py / tool_label_manager.py
+│   └── utils/
+├── memory/              # 会话记忆(token_buffer_memory)
+├── file/                # 文件(remote_fetcher)
+├── model_manager.py / provider_manager.py # 模型/Provider 管理器
+├── datasource/          # 数据源抽象
+├── db/                  # 数据库辅助
+├── rbac/                # RBAC 实体
+├── mcp/                 # MCP 客户端与服务端(auth / client / server / session)
+├── plugin/              # 插件系统( backwards_invocation / endpoint / entities / impl / plugin_service / utils)
+├── trigger/             # 触发器管理(constants / entities / provider / trigger_manager / utils / debug)
+├── ops/                 # 可观测(ops_trace_manager / entities / utils)
+├── prompt/              # Prompt 工具
+├── moderation/          # 内容审核
+├── external_data_tool/  # 外部数据工具
+├── llm_generator/       # LLM 生成器(用于辅助生成提示词/工作流)
+├── indexing_runner.py   # 知识库索引执行器
+├── callback_handler/    # 回调处理
+├── helper/              # 辅助函数
+├── extension/           # 核心扩展点
+├── entities/ / schemas/ # 实体与 Schema
+├── errors/              # 错误定义
+├── logging/             # 日志
+├── telemetry/           # 遥测
+├── repositories/        # 核心层仓储
+├── hosting_configuration.py # 多租户 hosting 配置
+└── base/                # 基础抽象
+```
+
+### 6.4 `dify-agent/`(新一代 Agent 框架,Python)
+
+```
+dify-agent/
+├── pyproject.toml       # uv workspace 元数据(含 grpc / server 可选依赖组)
+├── README.md / Makefile / mkdocs.yml
+├── Dockerfile           # 独立 agent_backend 镜像
+├── docs/                # 概念文档(concepts/run-lifecycle / ...)
+├── examples/            # 使用示例
+├── proto/               # gRPC proto(与 Local Sandbox 对齐)
+├── tests/               # 单元测试
+└── src/
+    ├── agenton/                 # 框架中立的层图(layer graph)组合器
+    │   ├── compositor/          # Compositor + CompositorRun + schemas
+    │   │   ├── core.py          # Compositor / LayerNode(无状态 DAG 计划)
+    │   │   ├── run.py           # CompositorRun(一次活动运行时 + 资源作用域)
+    │   │   ├── providers.py     # LayerProvider(工厂)
+    │   │   ├── schemas.py       # 可序列化 DTO(CompositorSessionSnapshot)
+    │   │   └── types.py         # 泛型类型变量
+    │   ├── layers/              # Layer 抽象基类
+    │   │   ├── base.py          # Layer / LayerDeps / LayerConfig / LifecycleState
+    │   │   └── types.py         # PlainLayer / PydanticAILayer 类型族
+    │   └── __init__.py          # 门面(自述 state-only core)
+    ├── agenton_collections/     # 可复用 Layer 与 Transformer 实现
+    │   ├── layers/
+    │   │   ├── plain/           # 普通字符串 prompt/tool 层
+    │   │   └── pydantic_ai/     # pydantic-ai 桥接(history 等)
+    │   └── transformers/        # 类型转换器(PYDANTIC_AI_TRANSFORMERS)
+    │       └── pydantic_ai.py
+    ├── dify_agent/              # Dify 业务运行时(FastAPI server)
+    │   ├── runtime/             # 运行时入口
+    │   │   ├── runner.py        # AgentRunRunner:组合 compositor + pydantic-ai
+    │   │   ├── agent_factory.py # create_agent(一行:Agent(model, output_type, tools))
+    │   │   ├── compositor_factory.py # 默认 provider 集合
+    │   │   ├── run_scheduler.py # 调度(无 Redis job queue,进程内)
+    │   │   └── output_contract.py # 输出契约解析
+    │   ├── layers/              # Dify 业务 Layer(详见 5.4)
+    │   │   ├── ask_human/       # 人工输入延迟工具
+    │   │   ├── config/          # 配置层
+    │   │   ├── dify_core_tools/ # Dify 核心工具层
+    │   │   ├── dify_plugin/     # Dify 插件层(LLM 与工具)
+    │   │   ├── drive/           # 文件 Drive 层
+    │   │   ├── execution_context/ # 执行上下文层
+    │   │   ├── knowledge/       # 知识库检索层
+    │   │   ├── output/          # 结构化输出契约层(output_type)
+    │   │   └── shell/           # Shell 命令执行层(4 个工具)
+    │   ├── adapters/            # Shell / LLM 适配器
+    │   ├── agent_stub/          # gRPC Stub 协议、文件上传下载(drive)
+    │   ├── client/              # 与 Dify API 对接的客户端
+    │   ├── protocol/            # 公开协议(DTO)
+    │   ├── server/              # FastAPI server 路由与生命周期
+    │   ├── storage/             # 存储适配(snapshot 落盘)
+    │   └── plugin_daemon_transport.py # 与 plugin_daemon 通信
+    └── shellctl/                # Python 版 shellctl 客户端(与 Go 版 dify-agent-runtime 对端)
+        ├── client/
+        └── shared/
+```
+
+### 6.5 `dify-agent-runtime/`(Agent Shell 沙箱,Go)
+
+```
+dify-agent-runtime/
+├── go.mod / go.sum      # Go 1.26
+├── Makefile / README.md
+├── packaging_test.go    # 打包测试
+├── cmd/                 # CLI 入口(cobra)
+│   ├── shellctl/        # 主服务(serve --listen 0.0.0.0:5004)
+│   ├── dify-agent-cli/  # Agent CLI(在沙箱内调用)
+│   ├── runner/          # 集成 Landlock 的 job runner
+│   ├── runner-exit/     # 退出状态写 SQLite
+│   └── sanitize-pty/    # tmux pipe-pane PTY 过滤器
+├── internal/            # 内部实现
+│   ├── server/          # shellctl HTTP/gRPC 服务
+│   ├── agentcli/        # Agent CLI 实现
+│   ├── landlock/        # Linux Landlock LSM 隔离封装
+│   ├── sanitize/        # PTY ANSI 清洗
+│   ├── runner_exit/     # 退出码处理
+│   ├── cmdutil/         # 命令工具
+│   ├── envvar/          # 环境变量
+│   └── stubclient/      # 与 dify-agent gRPC Stub 通信
+├── gen/                 # 由 proto 生成的 Go 代码(gen/dify/agent/stub/v1/)
+├── docker/              # 容器构建辅助
+└── tests/               # 测试
+```
+
+### 6.6 `web/`(前端,Next.js + React 19)
+
+```
+web/
+├── package.json / Dockerfile / next.config.mjs / vite.config.ts / tsconfig.json
+├── app/                 # Next.js App Router 路由
+├── features/            # 功能模块(按业务领域划分)
+├── components/          # 共享组件(隐含,按 features 内组织)
+├── hooks/               # 自定义 Hook
+├── context/             # React Context
+├── models/              # 前端类型定义
+├── constants/           # 常量
+├── assets/ / icons/     # 静态资源
+├── i18n/ / i18n-config/ # 国际化(i18next)
+├── config/              # 前端配置
+├── bin/                 # 入口脚本
+├── docs/                # 前端文档
+├── docker/              # 容器构建
+├── __mocks__ / __tests__  # Mock 与测试
+├── next/                # Next.js 特定代码
+├── global.d.ts / env.ts / instrumentation-client.ts
+├── dev-proxy.config.ts  # 开发代理
+└── knip.config.ts       # 死代码检测配置
+```
+
+### 6.7 `docker/`(部署编排)
+
+```
+docker/
+├── docker-compose.yaml                # 自动生成的最终编排
+├── docker-compose-template.yaml       # 手维护的模板(源头)
+├── docker-compose.middleware.yaml     # 仅中间件(DB / Redis)的精简版
+├── docker-compose.pytest.ports.yaml   # 测试用端口编排
+├── generate_docker_compose            # 从 template + envs 生成最终 yaml 的脚本
+├── dify-env-sync.py / dify-env-sync.sh # 环境变量同步
+├── envs/                              # 按 profile 拆分的环境变量(向量库 / 业务库)
+├── volumes/                           # 持久化卷挂载点
+├── nginx/                             # Nginx 反向代理配置
+├── ssrf_proxy/                        # Squid SSRF 代理配置
+├── certbot/                           # HTTPS 证书
+├── startupscripts/                    # 容器启动脚本
+├── elasticsearch/ / pgvector/ / couchbase-server/ / iris/ / tidb/ # 各向量库的辅助配置
+└── README.md
+```
+
+### 6.8 `packages/`(pnpm workspace 内部包)
+
+```
+packages/
+├── contracts/                # @dify/contracts:前后端共享的类型契约
+├── dify-ui/                  # @langgenius/dify-ui:可复用 UI 组件库
+├── dev-proxy/                # @langgenius/dev-proxy:本地开发代理
+├── iconify-collections/      # @dify/iconify-collections:图标集合
+├── tsconfig/                 # @dify/tsconfig:共享 tsconfig
+├── jotai-tanstack-form/      # jotai 与 TanStack Form 的桥接包
+└── migrate-no-unchecked-indexed-access/ # 迁移工具包
+```
+
+### 6.9 `sdks/`(官方 SDK)
+
+```
+sdks/
+├── nodejs-client/   # Node.js 客户端 SDK(TypeScript)
+├── php-client/      # PHP 客户端 SDK
+└── README.md
+```
+
+### 6.10 `cli/`(Dify CLI)
+
+```
+cli/
+├── package.json / tsconfig.json / vite.config.ts
+├── bin/               # CLI 入口(dify-cli)
+├── src/               # CLI 命令实现
+├── scripts/           # 辅助脚本
+├── test/              # 测试
+├── AGENTS.md / ARD.md # 架构决策记录
+└── README.md
+```
+
+### 6.11 `dev/`(本地开发脚本)
+
+```
+dev/
+├── setup*             # 一键拉起开发环境
+├── start-api*         # 启动 Flask API
+├── start-web*         # 启动 Next.js 前端
+├── start-worker*      # 启动 Celery Worker
+├── start-beat*        # 启动 Celery Beat
+├── start-docker-compose* # 启动 docker-compose
+├── sync-uv*           # 同步 uv workspace 依赖
+├── reformat*          # 代码格式化
+├── ty-check*          # 类型检查(pyrefly)
+└── pyrefly-check-local*
+```
+
+### 6.12 `e2e/`(端到端测试)
+
+```
+e2e/
+├── package.json / cucumber.config.ts
+├── features/          # Cucumber 特性文件
+├── fixtures/          # 测试夹具
+├── support/           # 测试支持代码
+├── scripts/           # 辅助脚本
+├── tests/             # 测试实现
+├── test-env.ts        # 测试环境配置
+├── AGENTS.md / README.md
+```
+
+### 6.13 `scripts/`(仓库治理)
+
+```
+scripts/
+├── ast_grep_guard.py / ast_grep_rules/   # AST 检查规则
+├── check_no_new_controller_sqlalchemy.py # 禁止在 controller 直接用 SQLAlchemy
+├── check_no_new_getattr.py               # 禁止新增 getattr 调用
+├── lint_controller_sqlalchemy.py         # 检查 controller 层 SQL 渗漏
+└── stress-test/                          # 压测脚本
+```
+
+### 6.14 `docs/`(产品文档)
+
+```
+docs/
+├── design/                # 设计文档(架构 / 决策)
+├── zh-CN/ zh-TW/ en-US/   # 多语言文档(中文简/繁、英文)
+├── ja-JP/ ko-KR/ fr-FR/ ...# 其他语言
+├── cross-env-app-migration/ # 跨环境应用迁移指南
+├── weaviate/              # Weaviate 专题
+├── eu-ai-act-compliance.md # 欧盟 AI Act 合规
+└── tlh/                   # 克林贡语(彩蛋)
+```
+
+---
+
+## 7. 对本仓库(agent-management)的关键借鉴清单
+
+| 借鉴维度 | Dify 实现位置 | 对应本仓库模块 |
+|---|---|---|
+| 工作流信封化 | `services/workflow/node_output_inspector_service.py`(仅 Agent v2) | `engine/NodeOutputEnvelope`(已全节点统一) |
+| 节点级失败策略 | `core/workflow/nodes/agent_v2/output_failure_orchestrator.py`(仅 Agent v2) | `engine/RetryPolicy` + `failStrategy`(已引擎层统一) |
+| HITL 暂停-恢复 | `core/app/layers/pause_state_persist_layer.py` | 可参考其状态序列化设计 |
+| 子工作流嵌套 | `core/workflow/workflow_entry.py:72-140`(`_WorkflowChildEngineBuilder`) | 未来扩展"工作流作为工具"时可参考 |
+| 插件化模型/工具 | `dify-plugin-daemon`(Go)+ `core/plugin/` | Hermes Bridge 设计可对照 |
+| 沙箱化 Shell 执行 | `dify-agent-runtime/internal/landlock/` + `shellctl` | `docs/hermes-sandbox.md` 可对照 |
+| 多人协同画布 | loro-crdt + socket.io | 前端 ReactFlow 改造时可参考 |
+| 可观测全链路 | OpenTelemetry + 8 种 trace 后端 | 未来接入 Langfuse 可参考 `api/providers/trace/` |
+
+---
+
+## 附录:探索方法说明
+
+本文档基于以下方式收集信息:
+1. 直接阅读 `pyproject.toml` / `go.mod` / `package.json` / `docker-compose.yaml` 提取技术栈与依赖
+2. 直接阅读核心源码文件(`workflow_entry.py`、`agent_node.py`、`cot_agent_runner.py`、`compositor/core.py`、`runtime/runner.py` 等)提取调度与执行细节
+3. 对 `dify-agent/` 全仓库做针对性关键字搜索(`multi.?agent` / `dynamic.*graph` / `LLM.*(decide|route)` 等),结论为"零命中"以支撑第 5 章判断
+4. 阅读官方概念文档(`dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md` 等)确认设计意图
+
+文档中所有"file_path:line_number"引用均可直接定位到对应源码位置,便于深入查阅。

+ 304 - 0
docs/hermes-error-detection-plan.md

@@ -0,0 +1,304 @@
+# Hermes Agent 调用失败感知方案(B + C + D)
+
+## 1. 背景与问题
+
+用户观察到的现象:
+
+```
+节点 envelope = {
+  message: "Hermes Agent 执行成功",
+  data: { result: "API call failed after 3 retries: HTTP 429: 该模型当前访问量过大..." }
+}
+工作流最终状态 = SUCCESS
+```
+
+LLM 实际调用失败(HTTP 429),但工作流报告成功。根因是 **跨语言契约缺失 + envelope 双重语义混淆**,问题分布在 4 个层级:
+
+| 层级 | 文件 | 问题 |
+|------|------|------|
+| 1. Python Agent | `backend/hermes-agent/run_agent.py` | `AIAgent.run()` 在 LLM 失败时不抛异常,错误描述作为 `final_response` 字符串返回 |
+| 2. Python Bridge | `backend/hermes-bridge/hermes_bridge.py::_run_agent_stream` | 把 Agent 返回的错误描述当作正常 `done` 事件发出 |
+| 3. Java Client | `backend/.../engine/hermes/HermesBridgeClient.java::parseSseResponse` | `case "done"` 分支把错误文本当作正常回复累积到 `finalResponse` |
+| 4. Java Executor | `backend/.../engine/executor/HermesAgentExecutor.java` | 只判断 `finalText` 是否空白,非空就返回 `NodeExecutionResult.success` + envelope.success |
+| 5. Engine | `backend/.../engine/WorkflowLevelExecutor.java::onNodeCompleted` | 只读外层 `result.getStatus()`,不看内层 envelope.status;外层 SUCCESS 时即便 envelope.status=400 也不进入失败分支 |
+
+## 2. 方案概览
+
+| 方案 | 改动半径 | 是否在本期实施 |
+|------|----------|----------------|
+| A: Python Bridge / hermes-agent 显式区分 done / error | 跨语言 + 第三方代码 | ❌ 不做(hermes-agent 是上游代码) |
+| **B: Java Client 做错误文本嗅探,命中则抛异常** | `HermesBridgeClient` 单文件 | ✅ 实施(含可配置菜单) |
+| **C: Executor 同步外层 NodeExecutionResult 与 envelope.status** | `HermesAgentExecutor` + `HermesSmartActionExecutor` | ✅ 实施 |
+| **D: 引擎层 envelope.status=400 自动升级为节点 FAILED** | `WorkflowLevelExecutor` 单文件 | ✅ 实施 |
+
+整体目标:**只要业务结果实质失败,无论失败在哪一层被识别,工作流都必须感知**。
+
+## 3. 详细设计
+
+### 方案 D:引擎层 envelope 失败自动升级(**最先做**)
+
+**改动文件**:`backend/src/main/java/com/agent/management/engine/WorkflowLevelExecutor.java`
+
+**改动位置**:`onNodeCompleted` 方法的 `synchronized (ctx.recordsLock)` 块(约 309-330 行)
+
+**当前逻辑**:
+```java
+if (result.getStatus() == NodeExecutionResult.Status.FAILED) {
+    // ... failStrategy 处理
+}
+```
+
+**改造后逻辑**:
+```java
+NodeExecutionResult effectiveResult = result;
+// D: envelope 失败自动升级 —— SUCCESS 但 envelope.status=400 时,按 FAILED 处理
+if (result.getStatus() == NodeExecutionResult.Status.SUCCESS
+        && NodeOutputEnvelope.isEnvelope(result.getOutput())) {
+    NodeOutputEnvelope env = NodeOutputEnvelope.fromObject(result.getOutput());
+    if (env != null && env.isFailure()) {
+        String originalMsg = env.getMessage();
+        String errMsg = String.format("节点 %s 业务执行失败(envelope.status=400): %s",
+                nodeId, originalMsg == null || originalMsg.isEmpty() ? "未提供失败原因" : originalMsg);
+        effectiveResult = NodeExecutionResult.failed(nodeId, errMsg, result.getLogs());
+        // 关键:把失败 envelope 写入上下文,让下游节点能通过 envelope.status 感知(不能丢)
+        context.setNodeOutput(nodeId, effectiveResult.getOutput() == null
+                ? NodeOutputEnvelope.failure(errMsg).toMap()
+                : effectiveResult.getOutput());
+    }
+}
+// 后续判断与记录都用 effectiveResult
+```
+
+**关键点**:
+1. 必须在 `executeOneNode` 之外(即 `onNodeCompleted`)做这个升级,因为 `executeOneNode` 已经写入 output 到 context,这里只补"如果 envelope 是 failure,按 FAILED 走 failStrategy"
+2. 失败 envelope 仍写入 context,下游节点通过 `workspace.getOutputStatus(prevId)` 感知
+3. 不影响 `output` 类型节点(其 envelope 永远是 success/failure,failure 时也按失败传播)
+4. 对原本就返回 `Status.FAILED` 的节点零影响(路径不变)
+
+### 方案 C:Executor 同步外层状态
+
+**改动文件**:
+- `backend/src/main/java/com/agent/management/engine/executor/HermesAgentExecutor.java`
+- `backend/src/main/java/com/agent/management/engine/executor/HermesSmartActionExecutor.java`
+
+**当前问题**:
+```java
+} catch (Exception e) {
+    return NodeExecutionResult.success(nodeId,
+            NodeOutputEnvelope.failure("Hermes Agent 调用失败: " + e.getMessage()).toMap());
+}
+```
+
+外层 `success` + 内层 `failure` 是语义不一致的。`WorkflowLevelExecutor` 当前只看外层,所以这种节点失败不会触发 failStrategy。
+
+**改造**(两处对称修改):
+```java
+} catch (Exception e) {
+    log.error("[HermesAgent] 节点 {} 调用失败: {}", nodeId, e.getMessage());
+    // C: 外层状态与 envelope.status 保持一致 —— 抛给引擎的 NodeExecutionResult 也 FAILED
+    return NodeExecutionResult.failed(nodeId,
+            "Hermes Agent 调用失败: " + e.getMessage());
+}
+```
+
+同时把"返回空结果"分支也改为 `failed`(原来的 success+failure envelope 也是反模式):
+```java
+if (runResult.getFinalText() == null || runResult.getFinalText().isBlank()) {
+    return NodeExecutionResult.failed(nodeId, "Hermes Agent 返回空结果", runResult.getLogs());
+}
+```
+
+**兜底机制**:`executeOneNode` 在 `result.getStatus() == FAILED` 且 `output == null` 时自动构造 failure envelope 写入 context(已有逻辑,不变),下游节点可通过 envelope.status 感知。
+
+**"Agent 节点未关联 Skill"、"加载 Skill 失败" 等早期失败分支** 也一并改为 `failed`。
+
+### 方案 B:Java Client 错误文本嗅探 + 可配置列表
+
+#### B.1 后端:错误模式数据模型
+
+**新增实体**:`HermesErrorPattern`
+
+```java
+public class HermesErrorPattern {
+    private Long id;
+    private String name;          // 模式名称,如 "LLM 429 限流"
+    private String pattern;       // 匹配文本(contains,大小写不敏感)
+    private Boolean enabled;      // 启用/禁用
+    private Boolean isBuiltIn;    // 内置不可删除标记
+    private String description;   // 可选说明
+    private LocalDateTime createdAt;
+    private LocalDateTime updatedAt;
+}
+```
+
+**匹配规则**:使用 `String#toLowerCase().contains(pattern.toLowerCase())`,简单稳定,避免正则转义陷阱。匹配范围限定在 `finalText` 的前 N 个字符(默认 500)以避免对超长回复做无效扫描。
+
+> **设计权衡**:曾考虑正则表达式,但 (a) 用户配置正则易出错;(b) 错误描述大多是固定前缀(`"API call failed after"`、`"HTTP 4"`),子串匹配已足够;(c) 子串匹配对前后台一致性更友好。如果未来需要更复杂匹配,可在 pattern 增加前缀 `regex:` 区分。
+
+#### B.2 后端:Repository / Service / Controller
+
+按现有项目模式(`AiModelRepository` + `AiModelService` + `AiModelController`)实现:
+
+- `HermesErrorPatternRepository extends AbstractJsonRepository<HermesErrorPattern>`,文件名 `hermes-error-patterns.json`
+- `HermesErrorPatternService` 提供:`list()`、`create(req)`、`update(id, req)`、`delete(id)`、`getActivePatterns()`、`isErrorResponse(String finalText)`
+- `HermesErrorPatternController`,路径 `/api/hermes-error-patterns`
+
+**Controller 风格**:对齐 `AiModelController`(POST `/{id}/edit`、`/{id}/delete`、`/{id}/toggle` 动词式 path)。
+
+#### B.3 后端:HermesBridgeClient 嗅探集成
+
+**改动文件**:`backend/src/main/java/com/agent/management/engine/hermes/HermesBridgeClient.java`
+
+**改动位置**:`parseSseResponse` 方法的 `case "done"` 分支后、`return new HermesRunResult(...)` 前。
+
+```java
+private HermesRunResult parseSseResponse(...) {
+    // ... 现有逻辑
+    String finalText = finalResponse.toString();
+
+    // B: 错误响应嗅探
+    if (errorPatternService != null) {
+        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);
+}
+```
+
+**为什么抛 `HermesErrorResponseException` 而非 `RuntimeException`**:
+- 让 `HermesAgentExecutor` 的 catch 块能区分"网络/进程异常"(重试类)与"业务错误响应"(不可重试)
+- 当前先统一处理为 failed,后续可以做精细化重试策略
+
+**依赖注入**:`HermesBridgeClient` 构造器新增 `HermesErrorPatternService` 参数(可为 null,方便测试)。
+
+#### B.4 后端:内置默认模式
+
+通过 `HermesErrorPatternService.@PostConstruct initBuiltInPatterns()` 实现:
+
+- 启动时若 `hermes-error-patterns.json` 不存在或为空,写入以下内置模式(`isBuiltIn=true`):
+  - `"API call failed after"` — LLM SDK 重试耗尽
+  - `"HTTP 4"` — 4xx 客户端错误
+  - `"HTTP 5"` — 5xx 服务端错误
+  - `"rate limit"` — 限流(英文)
+  - `"访问量过大"` — 智谱 AI 限流(中文)
+  - `"该模型当前访问量过大"` — 智谱 AI 限流(更具体)
+  - `"quota"` / `"额度"` / `"余额不足"` — 配额耗尽
+  - `"authentication"` / `"invalid api key"` / `"无效的 api"` — 认证失败
+- 已有数据时不覆盖(用户自定义优先)
+
+**为什么不用 `application.yml` 作为内置来源**:用户的诉求就是"做一个菜单页进行列表配置",所以默认值放 JSON 仓库里更直接。yml 配置适合运维固定参数,不适合动态列表。
+
+#### B.5 前端:菜单 + 路由 + 页面 + API
+
+**菜单**:在 `frontend/src/components/layout/AppSidebar.vue` 的"系统管理"或底部独立分组追加:
+
+```javascript
+// 在 menuItems 中追加
+{ key: '/hermes-errors', label: '错误模式管理', icon: WarningOutline }
+```
+
+**路由**:`frontend/src/router/index.js` 追加:
+
+```javascript
+{
+  path: '/hermes-errors',
+  name: 'HermesErrorPatterns',
+  component: () => import('../views/system/HermesErrorPatterns.vue'),
+  meta: { title: '错误模式管理' }
+}
+```
+
+**API**:`frontend/src/api/hermesError.js`
+
+```javascript
+import request from '../utils/request'
+
+export function getHermesErrorPatterns() {
+  return request.get('/hermes-error-patterns')
+}
+export function createHermesErrorPattern(data) {
+  return request.post('/hermes-error-patterns', data)
+}
+export function updateHermesErrorPattern(id, data) {
+  return request.post(`/hermes-error-patterns/${id}/edit`, data)
+}
+export function deleteHermesErrorPattern(id) {
+  return request.post(`/hermes-error-patterns/${id}/delete`)
+}
+export function toggleHermesErrorPattern(id) {
+  return request.post(`/hermes-error-patterns/${id}/toggle`)
+}
+```
+
+**页面**:`frontend/src/views/system/HermesErrorPatterns.vue`
+
+主要功能:
+- `NDataTable` 列表展示,列:启用状态(switch)、名称、匹配模式、说明、内置标记(tag)、操作(编辑/删除/复制)
+- 顶部"新建模式"按钮 + "重置为默认"按钮(清空自定义、恢复内置)
+- 编辑对话框:`NForm` 含名称、匹配模式、说明、启用
+- 删除:内置模式禁止删除(前端灰掉按钮 + 后端校验)
+- 复制:将内置模式复制为自定义,便于基于内置扩展
+
+**UI 风格**:与 `ModelManagement.vue` 一致(`NDataTable + NModal + NForm + useMessage`)。
+
+## 4. 实施顺序
+
+按依赖关系:
+
+```
+1. 方案 D:WorkflowLevelExecutor envelope 失败自动升级
+   (独立改造,为后续方案兜底)
+2. 方案 C:HermesAgentExecutor + HermesSmartActionExecutor 外层状态对齐
+   (依赖 D 把 SUCCESS+failure envelope 也视为失败)
+3. 方案 B 后端:实体 + Repository + Service + Controller + Client 集成
+4. 方案 B 前端:菜单 + 路由 + API + 页面
+5. 验证:编译 + 单元测试 + 手工触发 429 场景
+```
+
+## 5. 单元测试
+
+新增测试覆盖:
+
+- `WorkflowLevelExecutorEnvelopeUpgradeTest`:D 方案的核心测试
+  - SUCCESS + envelope.success → 不升级
+  - SUCCESS + envelope.failure → 升级为 FAILED + failStrategy=abort 触发
+  - SUCCESS + envelope.failure + failStrategy=skip → 不 abort,继续下游
+  - FAILED(无 output) → 走原兜底路径(不影响)
+- `HermesErrorPatternServiceTest`:B 方案的匹配测试
+  - 命中内置模式(429 / rate limit)
+  - 大小写不敏感
+  - 禁用模式不参与匹配
+  - 空 finalText 不匹配
+- `HermesBridgeClientErrorDetectionTest`:B 方案的集成测试
+  - mock SSE 响应含错误描述 → 抛 `HermesErrorResponseException`
+  - mock SSE 响应正常 → 返回正常 `HermesRunResult`
+
+## 6. 风险与权衡
+
+| 风险 | 缓解 |
+|------|------|
+| 错误模式误判(用户合法回复里恰好包含 "rate limit" 等关键词) | (a) 模式由用户精细管理;(b) 内置模式偏激进可禁用;(c) 嗅探只看 finalText,不影响 tool 中间步骤 |
+| D 方案改动影响其他节点 | 只对 SUCCESS + envelope.status=400 生效,FAILED 路径不变;output 类型节点失败时也合理传播 |
+| C 方案改变历史行为 | 原本"Hermes 调用失败但工作流继续"的行为本就是 bug,对依赖此 bug 的工作流是 break-change。但比起继续假装成功,让 failStrategy 接管是更正确的语义 |
+| Python Bridge 仍可能漏报错误 | 方案 A 留作后续;B 方案的列表可动态扩展,遇到新错误描述可立即配置 |
+
+## 7. 验收标准
+
+- [ ] 单元测试全绿(新增 3 个测试类)
+- [ ] 触发 429 时,节点状态 = FAILED,工作流按 failStrategy 传播
+- [ ] 前端"错误模式管理"页可 CRUD,启用/禁用立即生效(下次 Hermes 调用即生效)
+- [ ] 内置默认模式首次启动写入,用户修改后不被覆盖
+- [ ] 误判时用户可在前端禁用对应模式
+
+## 8. 不在本期实施
+
+- 方案 A(Python 侧改造):需要改 hermes-agent 上游代码,成本高,留作长期目标
+- 错误响应的精细化重试策略:当前所有嗅探到的错误统一为 FAILED,后续可根据 FailReason 区分(限流→延迟重试,认证失败→直接失败)
+- 正则匹配模式:当前用 substring,未来如有需求可在 pattern 增加前缀区分

+ 363 - 0
docs/node-context-ondemand-plan.md

@@ -0,0 +1,363 @@
+# 节点上下文按需获取方案
+
+> 状态:设计草案
+> 关联:[workflow-envelope-strict-validation-plan.md](./workflow-envelope-strict-validation-plan.md)、[workflow-variable-scope-design.md](./workflow-variable-scope-design.md)
+
+## 一、背景与目标
+
+### 1.1 当前痛点
+
+工作流节点之间的上下文传递目前存在**两个独立诉求**:
+
+| 诉求 | 当前现状 | 问题 |
+|---|---|---|
+| **按需获取工作区/输出变量** | `NodeWorkspaceBuilder.build()` 把**所有可达前驱**的 envelope.data 全量塞进 `variables`(`NodeWorkspaceBuilder.java:63-76`) | prompt 模板渲染时 token 浪费、字段污染、隐式四级模糊匹配有歧义 |
+| **获取前序节点的思考过程** | thinking / tool_call **只进 `NodeExecutionResult.logs` 字段**(`ExecutionLog.java:8`),**不进 envelope,不进 `nodeScopedOutputs`** | 下游节点**完全拿不到**前序 Agent 的思考轨迹,只能消费最终结果 |
+
+### 1.2 设计目标
+
+- 节点编辑时能**精确声明**需要的字段(强依赖)
+- LLM/Agent 类节点运行时能**按需探索**前序数据(弱依赖)
+- 思考过程 / 工具调用轨迹能被下游消费(trace 可读)
+- 不破坏现有 envelope 规范与 NodeInputResolver 显式 mapping 能力
+
+---
+
+## 二、当前架构现状(代码基线)
+
+### 2.1 上下文容器
+
+`WorkflowContext`(`WorkflowContext.java:17`)核心字段:
+
+- `nodeScopedOutputs: Map<nodeId, Map<varName, value>>` — 节点命名空间隔离的输出表(核心)
+- `initialInputs` — 扁平的运行级输入(来自外部调用)
+- `nodeOutputs` — 按执行顺序累积的 `List<NodeOutput>`
+- `sections` — 扩展槽(`WorkflowContext.java:32-36` 注释提及 `_git/_memory/_runtime`,**目前死代码**)
+
+### 2.2 节点输出信封
+
+`NodeOutputEnvelope`(`NodeOutputEnvelope.java:26`):
+
+```
+{ status: 200|400, message: "...", data: { ...画布声明的字段 } }
+```
+
+**关键约束**:只有 `data` 字段内的内容会被后继节点消费;`status` / `message` 是壳层。
+
+### 2.3 节点工作区构建
+
+`NodeWorkspaceBuilder`(`NodeWorkspaceBuilder.java:24`)是**节点执行前的"上下文投影器"**——从全局 `WorkflowContext` 中提取当前节点可见的子集,构造 `NodeWorkspace` 视图给执行器使用。**不修改全局上下文,只读 + 投影。**
+
+#### 2.3.1 核心数据结构
+
+`NodeWorkspace`(`NodeWorkspace.java:19`)持有两张表:
+
+| 字段 | 类型 | 用途 |
+|---|---|---|
+| `variables` | `Map<String, Object>` | 扁平变量表,可直接通过 `{{varName}}` 在模板中引用 |
+| `scopedOutputs` | `Map<nodeId, Map<varName, value>>` | 按节点命名空间隔离的输出,envelope 原貌保留 |
+
+分隔符(`NodeWorkspaceBuilder.java:27`):`NODE_FIELD_SEPARATOR = "__"`,双下划线避免 nodeId 自身含下划线时产生歧义(如 `node_1__field` 不会被解析为 `node` + `1__field`)。
+
+#### 2.3.2 注入流程(3 步)
+
+入口:`build()`(`NodeWorkspaceBuilder.java:50-82`)
+
+**步骤 1:初始输入扁平注入**(`L56`)
+
+```java
+workspace.getVariables().putAll(context.getInitialInputs());
+```
+
+外部调用工作流时传入的 `inputs` 以扁平形式塞入 `variables`,不带前缀。例如外部传入 `{ "user_query": "..." }`,则 `variables["user_query"]` 可直接通过 `{{user_query}}` 引用。
+
+**步骤 2:可达前驱输出注入**(`L58-76`)
+
+1. `WorkflowScopeResolver.getReachablePredecessors` 根据 DAG 拓扑 + 运行时活跃边(条件分支)计算可达前驱集合
+2. `orderByLevels` 按 DAG 层级排序(先执行的层级在前,影响后续模糊匹配优先级)
+3. 遍历每个可达前驱:
+   - envelope **原貌**存入 `scopedOutputs[predecessorId]`(保留 status/message,便于查状态、做条件分支判断)
+   - envelope.data **解包**后以 `{predecessorId}__{varName}` 形式扁平注入 `variables`
+
+**步骤 3:扫描工作目录文件**(`L78-79`)
+
+扫描本次 run 的 `workingDir`,经 `.agentignore` 过滤后,得到相对路径列表,供节点引用文件类变量。
+
+#### 2.3.3 envelope 解包逻辑
+
+`unwrapDataView`(`NodeWorkspaceBuilder.java:91-98`):
+
+- 若输出是 envelope 结构(同时含 `status` 与 `data`),返回 **data 内部 Map**
+- 否则原样返回(legacy 兼容)
+
+**作用**:避免 `variables` 里出现 `node_abc__status`、`node_abc__message` 这种壳字段,只暴露业务字段。
+
+#### 2.3.4 注入后的下游消费
+
+执行器通过三种方式消费 `NodeWorkspace`:
+
+1. **直接 API 访问**:`NodeWorkspace.java:46-85` 提供 `getVariable` / `getScopedOutput` / `getScopedDataView` / `getScopedStatus`,全部做 envelope 透明穿透
+2. **模板渲染**:`TemplateRenderer`(`TemplateRenderer.java:18`)支持 `{{varName}}`(取扁平表)与 `{{nodeId.varName}}`(取命名空间)两种语法
+3. **输入解析器**:`NodeInputResolver`(`NodeInputResolver.java:57`)在 NodeWorkspace 之上再做一层解析,把节点画布声明的 `inputs[]` 按"显式 mapping → 初始输入 → 四级隐式 fallback"顺序填值,结果写回 `variables`
+
+#### 2.3.5 当前注入方式的核心问题
+
+基于代码客观分析:
+
+1. **全量铺平 → token 浪费**:所有可达前驱的 data 字段全部塞入 `variables`。20 个前驱 × 5 个字段 = 100 个带前缀 key,但当前节点可能只用到 2 个。prompt 模板渲染时上下文冗长,字段污染可读性差
+2. **思考过程不可达**:`unwrapDataView` 只解包 `data`;thinking / tool_call 存在 `NodeExecutionResult.logs` 里,NodeWorkspaceBuilder 不会采集,下游任何方式都拿不到
+3. **隐式四级模糊匹配依赖全量铺平**:`NodeInputResolver.java:117-159` 的四级 fallback 之所以能工作,前提是 `scopedOutputs` 里有全部可达前驱的 envelope。第一阶段"按 mapping 注入"的改造需保留兼容期开关
+4. **可达前驱集 = 全局可达**:`WorkflowScopeResolver.getReachablePredecessors` 计算的是"从起始节点到当前节点的所有可达前驱",不是"用户声明的前驱",与"按需"理念冲突
+
+#### 2.3.6 数据流图
+
+```
+┌─────────────────────────────────────────────────────────┐
+│                    WorkflowContext                       │
+│  nodeScopedOutputs:                                     │
+│    "nodeA" -> {status, message, data:{x:1,y:2}}         │
+│    "nodeB" -> {status, message, data:{z:3}}             │
+│  initialInputs: { user_query: "..." }                   │
+└─────────────────────────────────────────────────────────┘
+                         │
+                         │ build("nodeC", dag, ctx)
+                         ▼
+┌─────────────────────────────────────────────────────────┐
+│              NodeWorkspace(nodeC)                        │
+│  variables (扁平):                                      │
+│    user_query -> "..."                                  │
+│    nodeA__x   -> 1                                      │
+│    nodeA__y   -> 2                                      │
+│    nodeB__z   -> 3                                      │
+│  scopedOutputs (envelope 原貌):                         │
+│    "nodeA" -> {status:200, message, data:{x,y}}         │
+│    "nodeB" -> {status:200, message, data:{z}}           │
+│  files: ["a.txt", "b.txt"]                              │
+└─────────────────────────────────────────────────────────┘
+                         │
+                         │ NodeInputResolver + TemplateRenderer
+                         ▼
+                   节点执行器消费
+```
+
+### 2.4 输入解析
+
+`NodeInputResolver.resolveInputs()`(`NodeInputResolver.java:57-82`)对节点声明的每个 inputs 字段:
+
+1. 显式 mapping(`sourceNodeId + sourceField + sourcePath?`)
+2. 初始输入扁平取值
+3. 四级隐式 fallback(拓扑逆序,最近前驱优先):
+   - 3.1 精确同名
+   - 3.2 忽略 `[-_]` 与大小写同名(穿透 envelope.data)
+   - 3.3 JSON 嵌套路径精确匹配
+   - 3.4 JSON 嵌套路径模糊匹配
+
+### 2.5 思考过程的存储现状
+
+- **Hermes 节点**:`HermesBridgeClient.parseSseResponse` 把 `text` 事件转为 `ExecutionLog.thinking(delta)`,同时通过 `sink.emit("thinking", delta)` 实时推送 SSE
+- **持久化**:`NodeExecutionResult.logs` 序列化到 `WorkflowRunNode.logs` 列
+- **关键限制**:`logs` **未写入** `nodeScopedOutputs`,也未进入 envelope;下游 NodeInputResolver 无法通过任何级别匹配读到 thinking
+
+---
+
+## 三、候选方案对比
+
+### 方案 A:扩展 envelope + 显式声明引用
+
+```
+NodeOutputEnvelope {
+  status, message,
+  data: { ...声明字段 },
+  trace: { thinking, tool_calls, intermediate_steps },  // 新增
+  metadata: { tokens, latency }                          // 可选
+}
+```
+
+节点 inputs 声明支持 `sourceField: "trace.thinking"`、`"trace.tool_calls[0].result"` 等路径。
+
+- ✅ 与现有 NodeInputResolver 兼容、精确可控
+- ✅ 平滑升级,envelope 是单点扩展
+- ❌ 仍是"声明式预注入",节点运行时不能动态决策
+- ❌ 单独无法解决"prompt 太长"问题
+
+### 方案 B:LLM 工具注入(Function Calling 形式)
+
+引擎为 LLM/Hermes 节点注入内置工具:
+
+```
+workflow.context.get(nodeId, field, path)
+workflow.context.list_predecessors()
+workflow.context.get_trace(nodeId, step_index?)
+```
+
+模型/Agent 自己决定何时调用、取什么。
+
+- ✅ 真正"按需",模型自主探索
+- ✅ 与 Hermes 已有的工具调用机制天然契合(`hermes_bridge.py` 已经在注册工具)
+- ✅ 与 Dify Agent V2 的 ask_human、deferred_tool_call 一脉相承
+- ❌ 消耗一次 LLM 调用决策
+- ❌ 仅适用于 LLM 类节点,普通节点(条件/输出)用不上
+- ❌ 模型可能"忘了调",需要 prompt 引导
+
+### 方案 C:MCP Server 形态
+
+把工作流引擎上下文做成 MCP server,暴露统一查询接口。
+
+- ✅ 标准协议、可被外部 Agent 复用
+- ✅ 与 Hermes / 未来接入的其他 Agent 完全解耦
+- ❌ 协议层复杂度大幅上升
+- ❌ 当前项目 0 处 MCP 代码,等于从零搭协议栈
+- ❌ 对单进程内的工作流场景,HTTP/SSE 开销过大
+
+### 方案 D:分桶 + 选择性注入
+
+节点 inputs 配置时选择"桶":`data-only` / `data+trace` / `workspace-only`,模板渲染按桶展开。
+
+- ✅ 与现有机制平滑升级
+- ❌ 本质还是声明式,能力上限低
+
+---
+
+## 四、推荐方案:A + B 混合(分阶段实施)
+
+### 核心判断
+
+- **节点类型不同,需求不同**:LLM/Agent 类节点适合工具形式,普通节点(条件/输出/输入)只能声明式
+- envelope + 显式 mapping + 模糊回退已经做了,方案 A 是顺水推舟
+- Hermes 节点本质就在调用工具,再加几个上下文查询工具成本极低
+- MCP 留到要接外部 Agent 时再做,短期复杂度收益不匹配
+
+### 第一阶段(必须做):方案 A — 让思考过程可被消费
+
+这是基础设施,不做的话后面所有方案都无米下炊。
+
+#### 4.1 扩展 NodeOutputEnvelope
+
+`NodeOutputEnvelope.java:26` 增加 `trace` 与 `metadata` 字段:
+
+```java
+public final class NodeOutputEnvelope {
+    private final Map<String, Object> trace;     // 结构化执行轨迹
+    private final Map<String, Object> metadata;  // token 用量、耗时等
+
+    // 工厂方法:
+    public static NodeOutputEnvelope success(String message, Map<String, Object> data,
+                                             Map<String, Object> trace, Map<String, Object> metadata) { ... }
+
+    // 序列化 / 反序列化同步扩展 toMap() / fromObject() / isEnvelope()
+}
+```
+
+trace 的标准字段约定:
+
+```json
+{
+  "thinking": "...",              // 思考片段(可分段拼接)
+  "tool_calls": [                 // 工具调用列表
+    { "name": "terminal.exec", "args": {...}, "result": "...", "success": true }
+  ],
+  "reasoning": "...",             // 推理过程(与 thinking 二选一或并存)
+  "intermediate_steps": [...]     // ReAct 类中间步骤
+}
+```
+
+#### 4.2 Hermes 节点写入 trace
+
+`HermesAgentExecutor.java:87-91` 当前只写单字段 `finalText`,应改造为:
+
+```java
+Map<String, Object> data = Map.of("result", finalText);
+Map<String, Object> trace = Map.of(
+    "thinking", accumulatedThinking,
+    "tool_calls", toolCallHistory
+);
+return NodeOutputEnvelope.success("调用成功", data, trace, metadata);
+```
+
+#### 4.3 NodeInputResolver 支持 trace 路径
+
+扩展 `sourceField` 解析(`NodeInputResolver.java:88-108`),允许:
+
+- `"data.result"` — 等价于现 `data.result`(向后兼容)
+- `"trace.thinking"` — 取思考过程
+- `"trace.tool_calls[0].result"` — 取某次工具调用结果
+
+实现要点:`sourceField` 解析为相对路径,根为 envelope 本身(而非 data)。建议保留 `data` 为默认根以向后兼容。
+
+#### 4.4 NodeWorkspaceBuilder 改为按 mapping 注入
+
+**关键改造点**:`NodeWorkspaceBuilder.java:63-76` 当前把所有可达前驱的 data 全量塞入 `variables`,应改为:
+
+- `scopedOutputs` 仍保留 envelope 原貌(不变)
+- `variables` 只在节点声明了显式 mapping 时注入对应字段,**不再全量铺平**
+- 隐式四级 fallback 仍可使用 `scopedOutputs`,但不再"先扁平化、再匹配"
+
+**注意**:这是一次行为破坏性变更,需配套:
+- 兼容期保留开关 `workflow.workspace.legacy-flat-inject=true`
+- 单元测试覆盖现有依赖隐式 fallback 的工作流
+- 文档同步更新 [workflow-variable-scope-design.md](./workflow-variable-scope-design.md)
+
+### 第二阶段(按需做):方案 B — LLM 节点工具化探索
+
+在 LLM/Hermes 节点执行前,向其注入内置工具:
+
+```java
+public class WorkflowContextTool {
+    @Tool(name = "workflow.context.get",
+          desc = "按 nodeId 和字段路径查询前驱节点的输出或思考过程;field 可为 data/trace/metadata")
+    public Object get(String nodeId, String field, String path) {
+        // 从 WorkflowContext.nodeScopedOutputs 取值
+    }
+
+    @Tool(name = "workflow.context.list",
+          desc = "列出所有可达前驱节点及其可读字段(含 data/trace/metadata)")
+    public List<NodeInfo> list() { ... }
+}
+```
+
+适用场景:
+- 节点不知道该引用哪个前驱时(如"判断条件"前的探索)
+- Agent 需要"翻看"前序 Agent 的思考轨迹做决策
+- 长链路工作流中,避免 prompt 里堆满所有中间结果
+
+**注入方式**:
+- LLM 节点:在 `StructuredOutputHelper` 构造 prompt 时,把工具描述追加到 system prompt(OpenAI function calling)
+- Hermes 节点:在 `HermesBridgeClient` 调用 Bridge 前,通过 `register_tool` 注册到 hermes-agent
+
+### 第三阶段(远期):方案 C — MCP 化
+
+如果未来要支持**外部 Agent 接入工作流**(比如 Claude Code、外部 Hermes 实例查询),再把第一、二阶段的查询能力封装为 MCP server。
+
+**短期不建议做**,原因:
+- 当前项目 0 处 MCP 代码
+- 单进程内工作流场景 HTTP/SSE 开销过大
+- 优先级低于第一阶段的基础能力建设
+
+---
+
+## 五、关键设计原则
+
+无论选哪个方案,以下几点必须遵守:
+
+1. **trace 必须结构化**,不能只存 `finalText` 字符串。否则下游消费不了
+2. **按需 ≠ 完全工具化**。普通节点(条件、输出)没有 LLM 决策能力,必须保留声明式入口
+3. **NodeWorkspaceBuilder 改造是关键**(`NodeWorkspaceBuilder.java:24`):从"全量预注入"改为"按 mapping 注入",这是 token 节省的最大来源,比加工具更立竿见影
+4. **失败 envelope 的 trace 也要写**(`WorkflowLevelExecutor.java:539-544`):失败时的工具调用轨迹对下游调试极有价值
+5. **trace 应支持节流**:Hermes 长 thinking 可能几千 tokens,建议在 envelope 落盘时做长度限制或分段存储
+
+---
+
+## 六、实施阶段总览
+
+| 阶段 | 内容 | 优先级 | 风险 |
+|---|---|---|---|
+| 一 | envelope 扩展 trace + Hermes 节点写入 trace + NodeInputResolver 支持 trace 路径 | 高 | 低,向后兼容 |
+| 一 | NodeWorkspaceBuilder 改为按 mapping 注入 | 高 | 中,破坏性变更,需兼容期 |
+| 二 | 注入 `workflow.context.*` 内置工具 | 中 | 中,需 prompt 引导 |
+| 三 | MCP 化查询能力 | 低 | 高,协议层复杂 |
+
+---
+
+## 七、一句话总结
+
+> **存储用方案 A(envelope 加 trace 字段),LLM 节点查询用方案 B(注入 workflow.context.* 工具),普通节点继续走显式 mapping。MCP 留到要接外部 Agent 时再做。**

+ 368 - 0
docs/skills-manage-readme.md

@@ -0,0 +1,368 @@
+# skills-manage 项目调研报告
+
+> 调研对象:`skills-manage/` 目录,版本 0.10.0,Apache-2.0
+> 项目定位:**跨平台 AI 编码 Agent 技能管理桌面应用**(Tauri v2 + React + Rust + SQLite)
+> 参考用途:为本项目的"技能(Skill)"管理能力提供桌面端借鉴(多平台分发、符号链接、市场导入、AI 解释)
+
+---
+
+## 一、项目概览
+
+skills-manage 是一个**桌面端的 AI 技能中央管理器**。核心思想:
+
+- 把 `~/.agents/skills/` 作为**唯一中央目录(canonical source of truth)**,所有 SKILL.md 物理上只在这里存一份
+- 各 AI 编码工具(Claude Code / Cursor / Gemini CLI / Codex / Hermes 等 27+ 个平台)通过**符号链接(symlink)**接入,达到"一份技能、多平台共享"
+- 提供"中央库 / 平台视图 / 项目发现 / 市场 / 集合"五种视角,把散落的技能统一收编、批量分发
+
+它遵循 [Anthropic Agent Skills](https://github.com/anthropics/agent-skills) 开放模式,SKILL.md 用 YAML frontmatter 声明 `name` / `description`。
+
+---
+
+## 二、技术栈
+
+### 总体架构
+
+```
+React 前端 (src/)
+    ↓ Tauri IPC(invoke() 调 #[tauri::command] 函数,50+ 个命令)
+Rust 后端 (src-tauri/src/)
+    ↓ SQLx 异步驱动
+SQLite 数据库(~/.skillsmanage/db.sqlite,WAL 模式)
+```
+
+### 各层技术选型
+
+| 层 | 技术 | 关键依赖 |
+|---|---|---|
+| 桌面框架 | **Tauri v2** | `tauri 2` + 4 个官方插件(sql/fs/dialog/shell) |
+| 前端框架 | React 18 + TypeScript 5.8 | `react`、`react-dom` |
+| UI 组件 | shadcn/ui(base-ui + Tailwind CSS 4) | `@base-ui/react`、`tailwindcss 4`、`class-variance-authority`、`tailwind-merge`、`lucide-react` |
+| 路由 | React Router v7 | `react-router-dom ^7.5.0` |
+| 状态管理 | Zustand 5 | `zustand ^5.0.3`(每业务域一个 store) |
+| 国际化 | i18next + react-i18next | 中英双语,`fallbackLng: "zh"` |
+| Markdown | react-markdown + remark-gfm | 前端展示;后端解析用 `serde_yaml` |
+| 主题 | Catppuccin 4 flavor × 14 accent | 自定义 `data-theme` / `data-accent` HTML 属性 |
+| 构建 | Vite 6 + tsc | dev 端口 24200 |
+| 后端语言 | **Rust 2021** | `serde`、`chrono`、`uuid` |
+| 数据库 | **SQLite via sqlx 0.8** | `sqlx { sqlite, runtime-tokio }` + WAL 模式 |
+| HTTP | reqwest 0.12 | `json`、`rustls-tls`、`stream`,用于 GitHub API + AI 解释 |
+| 压缩 | flate2 + tar | 解 GitHub tarball 到内存快照 |
+| YAML | serde_yaml 0.9 | 解析 SKILL.md frontmatter |
+| 测试 | Vitest 3 + jsdom + RTL(前端);cargo test + tempfile(后端) | 前端 370+ 用例,后端 214+ 用例 |
+
+### 为什么选 Tauri 而非 Electron
+从代码可推断(README 未明说):
+1. Rust 原生提供跨平台文件系统与 symlink 能力(`std::os::unix::fs::symlink` vs `std::os::windows::fs::symlink_dir`)
+2. SQLx 直连 SQLite,无需 Electron 常见的 `better-sqlite3` 等原生 Node 模块
+3. 产物体积小、启动快;官方插件机制成熟(sql/fs/dialog/shell)
+
+### 为什么用 SQLite WAL 模式
+`db.rs:113` 显式 `PRAGMA journal_mode=WAL`——允许读(UI 查询)与写(扫描 upsert)并发,避免扫描大量项目期间 UI 卡顿;同时嵌入式零配置,跨平台单文件。
+
+### 为什么不用 `sqlx::query_as!` 宏
+宏要求编译期 `DATABASE_URL` 连接真实库做类型校验,对 CI 和开发者机构成负担。全项目统一 `sqlx::query()` + `Row::get()` 手动映射。
+
+---
+
+## 三、技能管理的具体逻辑
+
+### 3.1 核心概念与数据模型
+
+| 概念 | 说明 | 存储 |
+|---|---|---|
+| **Skill** | 一个含 SKILL.md(YAML frontmatter)的目录 | `skills` 表 |
+| **Agent(平台)** | AI 编码工具(Claude Code / Cursor / Codex / Hermes / Copilot 等 27+ 内置) | `agents` 表 |
+| **中央目录** | `~/.agents/skills/`,技能的唯一权威位置 | 由特殊 agent id=`central` 标记 |
+| **安装(Installation)** | 中央 → 平台的符号链接/复制关系 | `skill_installations` 表(复合主键 skill_id+agent_id) |
+| **观测(Observation)** | 每次扫描平台目录时的技能快照,用于区分"平台原生/只读/本应用安装" | `agent_skill_observations` 表 |
+| **集合(Collection)** | 用户自定义的技能分组,可批量安装 | `collections` + `collection_skills`(多对多) |
+| **市场源(Registry)** | GitHub 仓库,作为远程技能目录 | `skill_registries` 表(带 etag/last-modified 缓存) |
+| **市场技能** | 远程同步下来的技能元数据 | `marketplace_skills` 表 |
+| **发现(Discover)** | 全盘扫描到的项目级技能 | `discovered_skills` 表 |
+| **AI 解释缓存** | 按 `(skill_id, lang)` 缓存的 AI 说明 | `skill_explanations` 表 |
+| **设置** | KV 存储(github_pat / ai_api_key / ai_model 等) | `settings` 表 |
+
+**支持的 27+ 平台**(节选):Claude Code、Codex CLI、Cursor、Gemini CLI、Trae、Factory Droid、Junie、Qwen、Windsurf、Qoder、Augment、OpenCode、KiloCode、OB1、Amp、Kiro、CodeBuddy、**Hermes**、Copilot、Aider;以及国内"龙虾系"(OpenClaw / QClaw / EasyClaw / AutoClaw / WorkBuddy)。特殊:`central` 即中央目录本身。
+
+### 3.2 技能识别:SKILL.md frontmatter 解析
+
+`scanner.rs::parse_skill_md`(scanner.rs:120):
+
+1. 平台/中央目录的**直接子目录**中是否含 `SKILL.md`
+2. 文件首行必须是 `---`,截取至下一个 `\n---` 作为 frontmatter
+3. 用 **serde_yaml 0.9** 解析(不引入完整 markdown 解析器)
+4. `name` 必填(缺失静默跳过),`description` 可选
+
+扫描时同步 upsert `skills` 表,并刷新 `agent_skill_observations` 记录观测。
+
+### 3.3 技能安装链路(重点)
+
+**入口**:前端 `centralSkillsStore.batchInstall` 或卡片图标单击 `togglePlatformLink` → `invoke("batch_install_to_agents" | "install_skill_to_agent", { skillId, agentIds, method })`
+
+**核心流程**(`linker.rs::install_skill_to_agent_impl`,:254):
+
+```
+1. 禁止安装到 central 自身
+2. 查 agent 与 central agent,确定 canonical_dir
+3. 【关键】ensure_centralized:
+     if canonical_dir/SKILL.md 不存在:
+        从 DB 取该技能当前实际 file_path
+        将其所在目录 copy_dir_all 到中央目录
+        回写 DB 的 canonical_path / is_central=true / file_path
+     → "仅存在于某平台的技能被自动收养进中央目录",调用方对此透明
+4. 支持"通用 ~/.agents/skills" 的平台(universal_available)直接返回成功
+5. 目标已存在 symlink → 删除重建
+   目标已存在真实目录/文件 → 拒绝覆盖,报错(防误删用户手动放置的技能)
+6. 计算相对路径 symlink_target_path(Windows 跨盘符时回退绝对路径)
+7. 创建 symlink → upsert skill_installations (link_type="symlink")
+```
+
+**回退策略**(`install_skill_to_agent_auto_impl`,:338):
+- 仅 **Windows** 上当错误信息含 "Failed to create symlink"(典型为无开发者模式/权限不足)时,自动回退到 `install_skill_to_agent_copy_impl` 整目录复制
+- Unix 上不回退,直接报错——保持行为可预期
+
+**为什么用相对路径 symlink**:用户移动整个 home 目录或同步到另一台机器时链接不失效。
+
+### 3.4 卸载链路
+
+`linker.rs::uninstall_skill_from_agent`:删除 symlink/copy 目录 → 删除 `skill_installations` 行。中央目录的物理目录**不动**,仅断开分发关系。
+
+### 3.5 Discover:全磁盘项目级技能发现
+
+`discover.rs::start_project_scan`(:1387):
+
+- **扫描根**:默认 10 个候选(`~/projects`、`~/Documents`、`~/Developer`、`/Applications` 等),存在的自动启用;叠加用户在 settings 里加的自定义目录
+- **递归策略**:
+  - 最大深度 `MAX_SCAN_DEPTH = 8`
+  - 黑名单 `SKIP_DIRS`:node_modules / target / .git / build / dist / .next / venv 等 16 个
+  - 深度 0 跳过 `.` 开头的隐藏目录,**深层放行**以命中 `.claude/skills`、`.agents/skills` 等模式
+  - 匹配模式来自各内置 agent 的 `project_skills_dir`
+- **进度流式推送**:通过 `app.emit("discover:progress" | "found" | "complete")`,前端 store 订阅
+- **取消**:全局 `SCAN_CANCEL: AtomicBool` + thread-local 覆盖(测试隔离)
+- **`is_already_central` 实时重算**:每次从 DB 加载时检查 `~/.agents/skills/<dir_name>` 是否存在,不是静态快照
+- **导入**:`import_discovered_skill_to_central` / `import_discovered_skill_to_platform`,**特意不删** discovered 记录,以支持连续导入到多个平台
+
+### 3.6 Marketplace:GitHub 远程技能市场
+
+**抓取方式**(`github_import.rs::download_repository_archive`,:824):
+- 不用 Git Trees API,而是 `GET /repos/{owner}/{repo}/tarball/{branch}` 整包下载
+- `flate2::GzDecoder` + `tar` 解压到内存快照
+- `classify_skill_manifest_path` 识别三种布局:
+  - 根 SKILL.md(单技能仓库)
+  - 根级 `<dir>/SKILL.md`(多技能仓库,排除 `.github`)
+  - `skills/**/SKILL.md`(含 `.curated`/`.system` 命名空间子目录)
+
+**认证与限流**:
+- `github_pat` 从 settings 表读取(`github_import.rs:649`)
+- 仅对直连 github.com 请求附带 token,**镜像请求不带**(避免泄漏)
+- `GITHUB_MIRROR_ENDPOINTS`:github 直连 + ghfast.top / ghproxy.net / mirror.ghproxy.com 三个国内镜像,403/429 时依次回退
+- 读 `x-ratelimit-remaining/reset` 头构造结构化错误(含可操作建议的中文提示)
+
+**缓存策略**:
+- 同步结果 upsert `marketplace_skills`(`ON CONFLICT(id) DO UPDATE`)
+- 非 force 刷新 + 有缓存 → 直接返回缓存
+- force 刷新失败 → 保留 last-good 缓存,只更新 `last_sync_status="error"`
+- 内置源(`is_builtin=true`)禁止删除;删除自定义源会先清掉其缓存技能
+
+**安装**:`install_marketplace_skill` 直接 GET `download_url`(raw.githubusercontent.com 直链)→ 写 `~/.agents/skills/<name>/SKILL.md`。**注意**:仅下载单文件 SKILL.md,不带附属文件;完整仓库需走 `import_github_repo_skills`。
+
+### 3.7 AI 解释(Skill Explanation)
+
+`marketplace.rs::explain_skill_stream`(:1307):
+
+- **协议探测**:按 `ai_api_url` 路径后缀区分 Anthropic(`/v1/messages` + `x-api-key` + `anthropic-version`)与 OpenAI 兼容(`/v1/chat/completions` + `Authorization: Bearer`)
+- **Prompt 构造**:SKILL.md 内容截断 8000 字符;中英双语模板要求"一句话总结 + 适用场景 + 关键功能点,200 字以内"
+- **流式响应**:SSE 按行解析;Anthropic 取 `content_block_delta.delta.text`(跳过 `thinking_delta`),OpenAI 取 `choices[0].delta.content`;每块 `app.emit("skill:explanation:chunk")` 推前端
+- **错误分类**:`classify_reqwest_error` 把 reqwest 错误分为 Proxy/Connect/Timeout/Dns/Tls/Auth/Response/Unknown,附中文可操作提示
+- **区域端点回退**:MiniMax(minimaxi.com ↔ minimax.io)与 GLM(bigmodel.cn ↔ api.z.ai)在连接层失败时自动切换一次
+- **缓存**:`skill_explanations` 表按 `(skill_id, lang)` 存;`INSERT OR REPLACE` 保留原 `created_at`;空文本视为缓存腐败会删除并重新生成
+- **支持厂商**:Anthropic / GLM / MiniMax / Kimi / DeepSeek / OpenRouter(`src/data/aiProviders.ts` 7 个预设)
+
+### 3.8 集合(Collection)
+
+`collections.rs`:分组管理 + `batch_install_collection`(对集合内每个技能 × 每个 agent 调 linker)+ JSON 导入/导出(`CollectionExport { version, name, description, skills[], created_at, exported_from }`)。
+
+---
+
+## 四、IPC 命令清单(50+ 个)
+
+按模块分组(`lib.rs:41-112` 注册):
+
+| 模块 | 主要命令 | 职责 |
+|---|---|---|
+| scanner | `scan_all_skills` | 全量扫描中央目录 + 所有启用平台 |
+| agents | `get_agents` / `detect_agents` / `add_custom_agent` / `update_custom_agent` / `remove_custom_agent` | 平台 CRUD;检测写回 is_detected |
+| linker | `install_skill_to_agent` / `uninstall_skill_from_agent` / `batch_install_to_agents` | symlink/copy 安装与卸载 |
+| skills | `get_skills_by_agent` / `get_central_skills` / `get_central_skill_bundles` / `get_skill_detail` / `read_skill_content` / `list_skill_directory` / `open_in_file_manager` / `preview_delete_central_skill_bundle` / `delete_central_skill*` | 技能查询/内容/删除 |
+| collections | `create/get/update/delete_collection` / `add/remove_skill_to_collection` / `batch_install_collection` / `export/import_collection` | 集合管理 |
+| settings | `get/set_scan_directories` / `set_scan_directory_active` / `get_setting` / `set_setting` | 扫描目录 + KV 设置 |
+| discover | `discover_scan_roots` / `get_scan_roots` / `set_scan_root_enabled` / `start/stop_project_scan` / `get_discovered_skills` / `import_discovered_skill_to_central/platform` / `clear_discovered_skills` / `get_obsidian_vaults/skills` | 项目级技能发现 |
+| github_import | `preview_github_repo_import` / `import_github_repo_skills` / `fetch_github_skill_markdown` | GitHub 仓库导入(带进度事件 + 冲突解决) |
+| marketplace | `list_registries` / `add/remove_registry` / `sync_registry(_with_options)` / `search_marketplace_skills` / `install_marketplace_skill` / `explain_skill(_stream)` / `get_skill_explanation` / `refresh_skill_explanation` | 市场源 + AI 解释 |
+
+---
+
+## 五、前端组织
+
+### 5.1 路由
+
+| 路由 | 页面文件 | 布局模式 |
+|---|---|---|
+| `/` → 重定向 `/central` | — | — |
+| `/central` | `CentralSkillsView.tsx` | 技能卡片两列 |
+| `/platform/:agentId` | `PlatformView.tsx` | 技能卡片两列 |
+| `/skill/:skillId` | `SkillDetailPage.tsx` | **双栏**:左 SKILL.md 预览全高,右 metadata + 紧凑图标式安装状态 + collections |
+| `/collections` | `CollectionsListView.tsx` / `CollectionView.tsx` | 卡片横排选中 + 下方技能列表 |
+| `/marketplace` | `MarketplaceView.tsx` | 三 Tab:推荐 / 官方源目录 / 我的源 |
+| `/discover`, `/discover/:projectPath` | `DiscoverView.tsx` | 左面板项目列表 + 右面板技能详情 |
+| `/obsidian/:vaultId` | `ObsidianVaultView.tsx` | 独立视图 |
+| `/settings` | `SettingsView.tsx` | 卡片分区 |
+
+### 5.2 Zustand Store(10 个)
+
+约定:**组件不直接 `invoke()`,一律走 store**。
+
+| Store | 职责 |
+|---|---|
+| `centralSkillsStore` | 中央库 + bundle + 平台链接切换 |
+| `platformStore` | 平台视图 + 全量扫描 |
+| `skillStore` | 单平台技能列表 |
+| `skillDetailStore` | 技能详情 + AI 解释流式订阅 |
+| `collectionStore` | 集合 CRUD/批量安装/导入导出 |
+| `discoverStore` | Discover 扫描与导入(监听 `discover:*` 事件) |
+| `marketplaceStore` | 市场三 Tab + GitHub 导入 + 解释 |
+| `obsidianStore` | Obsidian vault |
+| `settingsStore` | 扫描目录 + GitHub PAT + 自定义平台 |
+| `themeStore` | Catppuccin 主题(纯前端 localStorage,无 IPC) |
+
+### 5.3 共享组件
+
+**UnifiedSkillCard**(`src/components/skill/UnifiedSkillCard.tsx`):所有页面的技能卡片唯一实现,通过可选 props 自适应 5 种场景:
+
+| 场景 | 特征 props |
+|---|---|
+| central | `platformIcons{agents, linkedAgents, readOnlyAgents, onToggle}` 渲染 LOBSTER/CODING 两行图标即时切换 |
+| platform | `sourceType: "symlink"\|"copy"\|"native"` + `originKind` + `isReadOnly` |
+| discover | `checkbox{checked, onChange}` + `platformBadge` + `projectBadge` + `isCentral` |
+| marketplace | `isInstalled` + `tags[]` + `publisher` |
+| collection | 复用基础 + `onRemove` |
+
+统一样式:`rounded-xl ring-1 ring-border bg-card shadow-sm`。**约定:不要为特定场景新建卡片组件**。
+
+**InstallDialog**(`src/components/central/InstallDialog.tsx`):
+- 打开时**默认勾选"当前已链接(含只读)"的平台**,反映现状
+- 确认时把勾选集合(剔除 read_only)+ 安装方式(symlink/copy 单选)传给 `onInstall`
+- 注意:该对话框只负责"安装到所选",取消勾选已链接平台**不会**触发卸载(卸载靠卡片图标 toggle 或平台视图)
+
+### 5.4 i18n 与主题
+
+- **语言**:仅 zh / en 两种语言包;`fallbackLng: "zh"`;检测顺序 localStorage(`i18nextLng`) → navigator
+- **主题**:Catppuccin 4 flavor(mocha / macchiato / frappe / latte)× 14 accent(rosewater … lavender)
+- 实现:`themeStore.applyFlavor/applyAccent` 在 `<html>` 上写 `data-theme` / `data-accent`,持久化到 localStorage `catppuccin-flavor` / `catppuccin-accent`;`init()` 在渲染前调用防闪烁
+
+---
+
+## 六、必需依赖
+
+### 6.1 开发期
+
+| 类别 | 依赖 | 说明 |
+|---|---|---|
+| 运行时 | Node.js LTS + pnpm | `package.json` 中 `pnpm.onlyBuiltDependencies` 声明 pnpm 专用 |
+| Rust | stable toolchain(rustup) | 2021 edition |
+| 系统库 | Tauri v2 系统依赖 | Windows:WebView2 + MSVC;macOS:Xcode CLT;Linux:webkit2gtk 等(详见 <https://v2.tauri.app/start/prerequisites/>) |
+| Rust crate | tauri 2 + 4 插件 / sqlx 0.8 / serde_yaml 0.9 / reqwest 0.12 / flate2 / tar / chrono / uuid / futures-util | 见 `src-tauri/Cargo.toml:16-31` |
+| 前端依赖 | react 18 / react-router-dom 7 / zustand 5 / i18next / tailwindcss 4 / @base-ui/react / lucide-react / cmdk / sonner / react-markdown / gray-matter | 见 `package.json:17-44` |
+| 测试 | vitest 3 + jsdom + @testing-library/* / tokio(full) / tempfile | 前端 370+ 用例,后端 214+ 用例 |
+
+### 6.2 最终用户运行时
+
+**不需要 Node 也不需要 Rust**——`pnpm tauri build` 产出平台原生安装包。
+
+- **Windows**:依赖系统 WebView2(Win10 1803+ 通常已内置;`tauri.conf.json` 配置 `webviewInstallMode: embedBootstrapper` 自动引导)
+- **macOS**:当前预编译包仅 Apple Silicon(`.dmg` / `.app.zip`),未 notarize,首次运行需 `xattr -dr com.apple.quarantine`
+- **Linux**:AppImage / deb / rpm 三形态
+
+### 6.3 数据存储位置
+
+| 数据 | 位置 |
+|---|---|
+| 应用数据库 | `~/.skillsmanage/db.sqlite`(含 collections / settings / 市场缓存 / AI 解释缓存) |
+| 中央技能目录 | `~/.agents/skills/` |
+| 各平台技能目录 | `~/.claude/skills/` / `~/.cursor/skills/` / `~/.gemini/skills/` 等(由 agents 表 `global_skills_dir` 定义) |
+| 前端主题/语言偏好 | localStorage |
+
+### 6.4 外网访问触发点(隐私说明)
+
+应用本地优先,**仅以下功能联网**:
+
+| 功能 | 目标端点 | 是否带 token |
+|---|---|---|
+| Marketplace 源同步 / GitHub 仓库预览/导入 | `api.github.com` + `raw.githubusercontent.com`;镜像回退 ghfast.top / ghproxy.net / mirror.ghproxy.com | 直连 github.com 带 PAT;镜像不带 |
+| Marketplace 技能安装 | `raw.githubusercontent.com`(download_url 直链) | 不带 |
+| AI 技能解释 | 用户配置的 `ai_api_url`(Anthropic / GLM / MiniMax / Kimi / DeepSeek / OpenRouter) | 带用户配置的 api_key |
+
+**遥测**:无;**崩溃上报**:无;**凭证**:仅存本地 SQLite settings 表(明文)。
+
+---
+
+## 七、关键技术决策(供本项目借鉴)
+
+| 决策 | 价值 | 对应代码 |
+|---|---|---|
+| `~/.agents/skills/` 作为唯一中央目录 + symlink 分发 | 一份技能多平台共享,避免散落与不一致 | `path_utils.rs:45-47`、`linker.rs:154` |
+| `ensure_centralized` 自动收养 | 仅存在于某平台的技能被自动拷贝进中央,调用方透明 | `linker.rs:154-195` |
+| 相对路径 symlink | 用户移动 home 目录链接不失效 | `linker.rs:89-100` |
+| 拒绝覆盖真实目录 | 防误删用户手动放置的技能 | `linker.rs:301-312` |
+| Windows symlink 失败自动回退 copy | 兼容无开发者模式的 Windows 环境 | `linker.rs:338-360` |
+| SQLite WAL + sqlx 手动映射 | 读写并发不卡 UI;避开 `query_as!` 宏对 DATABASE_URL 的依赖 | `db.rs:113`、全项目 |
+| GitHub tarball 整包解压到内存 | 规避 Trees API 限流;`flate2` + `tar` 流式 | `github_import.rs:824-870` |
+| 镜像回退 + 限流头解析 | 国内可用性;结构化错误提示可操作 | `github_import.rs:219-240`、`:1327-1341` |
+| AI 解释 `(skill_id, lang)` 缓存 + 空文本腐败检测 | 降本;防缓存损坏被永久复用 | `marketplace.rs:889-905` |
+| SSE 流式 + `app.emit` | 前端无感知渲染;错误分类可操作 | `marketplace.rs:1220-1251` |
+| `is_already_central` 实时重算 | 避免静态快照过期导致的状态错乱 | `discover.rs:1445` |
+| 前端组件不直接 invoke,统一走 store | 便于测试 mock + 集中错误处理 | `src/test/setup.ts:88-95` |
+| Vitest 通过 `window.__TAURI_INTERNALS__` mock IPC | 前端单测无需启动真实 Tauri | `src/test/setup.ts:88-95` |
+
+---
+
+## 八、对本项目的借鉴意义
+
+1. **技能中央化 + 符号链接分发**:本项目的 Skill 管理目前是单平台(agent-management 自己的库),可借鉴"中央目录 + 多平台 symlink"模式,把技能共享到 Claude Code / Cursor 等其他工具。
+2. **SKILL.md frontmatter 用 serde_yaml 解析**:与项目内已有的 Skill 解析逻辑可对照,避免重复造轮子。
+3. **Discover 的扫描黑名单与深度限制**(`MAX_SCAN_DEPTH=8` + 16 个 `SKIP_DIRS`):项目内做本地知识库或代码库扫描时可参考。
+4. **GitHub tarball 整包 + 镜像回退 + 限流头解析**:本项目已有从 GitHub 拉取 Skill 的需求时可复用此设计。
+5. **AI 解释的协议探测 + 区域端点回退 + 缓存腐败检测**:与本项目的 LLM 调用层设计可互鉴。
+6. **`is_already_central` 实时重算**:状态派生自文件系统而非数据库快照,避免缓存漂移——项目内做"技能是否已安装"判定可直接采用。
+
+---
+
+## 附录:文件结构速查
+
+```
+skills-manage/
+├── src/                          # React 前端
+│   ├── components/               # UI 组件(含 UnifiedSkillCard / InstallDialog)
+│   ├── data/                     # officialSources.ts / aiProviders.ts 静态数据
+│   ├── hooks/                    # 通用 hooks
+│   ├── i18n/                     # zh.json / en.json / index.ts
+│   ├── lib/                      # 前端工具
+│   ├── pages/                    # 9 个路由页面
+│   ├── stores/                   # 10 个 Zustand store
+│   ├── test/                     # Vitest + RTL(370+ 用例)
+│   ├── types/                    # 共享 TS 类型
+│   ├── App.tsx                   # 路由声明
+│   ├── main.tsx                  # 入口(含 theme init)
+│   └── index.css                 # Catppuccin 4 flavor × 14 accent CSS 变量
+├── src-tauri/
+│   ├── src/
+│   │   ├── commands/             # 10 个 IPC 模块(scanner/agents/linker/skills/collections/settings/discover/github_import/marketplace)
+│   │   ├── db.rs                 # SQLite schema + 迁移 + 内置 seed(11 张表)
+│   │   ├── path_utils.rs         # home 解析 + ~/.skillsmanage + ~/.agents/skills
+│   │   ├── lib.rs                # Tauri Builder + invoke_handler 注册
+│   │   └── main.rs               # 桌面入口
+│   └── Cargo.toml                # Rust 依赖
+├── package.json                  # 前端依赖
+├── tauri.conf.json               # Tauri 配置(bundle / window / plugins)
+└── docs/                         # 项目内文档
+```

+ 361 - 0
docs/workflow-envelope-strict-validation-plan.md

@@ -0,0 +1,361 @@
+# 工作流节点输出严格校验与重试策略分离实施计划
+
+> **文档目的**:解决两个生产反馈问题——(1) LLM 返回中文字段名未被拒绝;(2) 重试未区分"运行失败"与"格式失败",导致不必要的全量重跑。
+>
+> **改造范围**:
+> - `backend/src/main/java/com/agent/management/engine/OutputValidator.java`
+> - `backend/src/main/java/com/agent/management/engine/StructuredOutputHelper.java`
+> - `backend/src/main/java/com/agent/management/engine/RetryPolicy.java`
+> - `backend/src/main/java/com/agent/management/config/WorkflowProperties.java`
+> - `backend/src/main/resources/application.yml.example`(仅配置段)
+> - 新增 2 个单元测试类
+>
+> **状态**:待实施
+
+## 1. 问题确认
+
+### 1.1 用户反馈
+
+```
+1. 部分节点的输出,输出为 Json 格式,但与我定义的字段名不一致。我的定义:
+   {"status": 200/400, "message": "...", "data": {"fieldA": ...}}
+它的输出:
+   {"状态": "成功", "信息": "XXXX成功", "数据": {"字段A": ...}}
+为什么 validator 没有将这种格式认定为非法,并让该节点重试,重新生成?
+
+2. 如果某个节点输出的格式未通过校验,不应重新运行节点逻辑。应分两种情况:
+   (1) 运行失败(如"模型访问量过大")→ 重新运行节点
+   (2) 运行成功但输出结构错误 → 把 JSON 模板 + 上次响应回喂给 LLM,仅重组 JSON
+```
+
+### 1.2 用户进一步约束
+
+> 除"用户输入"节点和"条件分支"节点外,其他节点的用户定义输出均为必填,需严格满足每个字段均存在,键名严格校验。
+
+### 1.3 根因定位
+
+| 问题 | 代码位置 | 根因 |
+|---|---|---|
+| 中文键名未拒绝 | `OutputValidator.java:79-103` | 仅用英文 `name` 在 `parsed` 中 `get(name)`,找不到就视为 null,但默认 `retryOnMissingRequired=true` 时虽会重试,**重试 prompt 未明确告诉 LLM "你的字段名错了"**——只在 instruction 末尾追加 lastError,LLM 可能仍按中文返回 |
+| 中文键名被解析为 null | `StructuredOutputHelper.java:213-223` | `parse()` 用英文 `name` 从 LLM JSON 中 `parsed.get(name)`,中文响应 → 全部 null |
+| 未区分失败类型 | `StructuredOutputHelper.java:94-138` | 单层 for 循环,每次都重建完整 prompt 调用 LLM,**格式失败也会重新触发完整推理**(浪费 token、放大限流概率) |
+| RetryPolicy 单计数 | `RetryPolicy.java:23-26` | 仅有 `maxRetries`,无 runtime/format 区分 |
+
+### 1.4 关键观察
+
+所有需要结构化校验的执行器(`LlmExecutor`、`SmartActionExecutor`、`HermesAgentExecutor`、`HermesSmartActionExecutor` 等)都通过 `StructuredOutputHelper.extractWithRetry` 调用,因此**只需改造 `extractWithRetry` 一处,所有节点自动受益**。
+
+`userInput` 节点不调用 `extractWithRetry`(用户直接填值),`condition` 节点仅输出 `selectedBranch`,二者天然不受影响。
+
+## 2. 改造目标
+
+### 2.1 行为目标
+
+| 场景 | 当前行为 | 目标行为 |
+|---|---|---|
+| LLM 返回英文字段名齐全 | ✅ 通过 | ✅ 通过 |
+| LLM 返回中文字段名 | ❌ 解析为 null,silent 通过(retryOnMissingRequired 默认 false 时) | ❌ 拒绝,进入内层重试 |
+| LLM 运行异常(限流/超时) | 整个节点重试 | **外层重试**(重新完整推理) |
+| LLM 运行成功但 JSON 结构错 | 整个节点重试 | **内层重试**(仅重组 JSON,不重跑逻辑) |
+| 内层重试耗尽 | N/A | 升级为外层重试 |
+| 外层重试耗尽 | 返回失败 envelope | 返回失败 envelope(保持不变) |
+
+### 2.2 字段必填规则(按用户约束)
+
+| 节点类型 | 校验策略 |
+|---|---|
+| `userInput` | 保持原有 required/optional 语义(不走校验器) |
+| `condition` | 仅输出 `selectedBranch`,不需要 envelope data 校验 |
+| **其他节点**(llm / agent / smartAction / hermesAgent / hermesSmartAction / knowledgeBase / output) | **所有声明字段视为必填**,严格键名校验(精确匹配,区分大小写、区分分隔符) |
+
+### 2.3 必须保留的语义
+
+| 编号 | 现有行为 | 保留方式 |
+|---|---|---|
+| S1 | `NodeOutputEnvelope` 三段式结构(status/message/data) | 不动 |
+| S2 | 类型自动转换(number/boolean/string) | 不动,仍由 OutputValidator 完成静默转换 |
+| S3 | `extractWithRetry` 对外接口签名 | 保持不变(内部实现重写) |
+| S4 | `RetryPolicy.fromNodeData` 节点级覆盖全局 | 拆分后保留覆盖语义 |
+| S5 | 所有执行器调用方式(LlmExecutor/SmartActionExecutor/...) | 零改动 |
+
+## 3. 设计选型
+
+### 方案 A:OutputValidator 新增 strict 模式(**已选**)
+
+新增重载方法 `validate(parsed, outputsDecl, strictAllRequired)`:
+- `strictAllRequired = false`:保持现有行为(按 `decl.required` 字段判定)
+- `strictAllRequired = true`:所有声明字段视为必填,且每个字段必须在 parsed 中**精确存在**
+
+精确存在的定义:`parsed.containsKey(name) && parsed.get(name) != null && !isBlankString(parsed.get(name))`
+
+中文同义字段(如 `状态` vs `status`)不会通过 `containsKey("status")`,自动落入 missing 列表。
+
+**优点**:改动聚焦、向后兼容、不引入新的字段名归一化逻辑(保持精确匹配符合用户"键名严格校验"要求)。
+
+### 方案 B:模糊匹配 + 字段名映射表(未选)
+
+引入"中英文同义词典",LLM 返回 `状态` 时映射到 `status`。
+
+**否决理由**:违反用户"键名严格校验"的要求;维护同义词典成本高;LLM 偶尔返回的中英文混杂会难以处理。
+
+### 方案 C:JSON Schema 校验(未选)
+
+用 strict JSON Schema 强制 LLM 输出格式。
+
+**否决理由**:依赖 Spring AI 的 `entity(Class)` 或 `toolCalling` 机制,改动面大;目前 `extractWithRetry` 是字符串 prompt + 解析的方式,改造成本太高;用户需要的是"出错时让 LLM 修正",而非"硬约束 LLM"。
+
+## 4. 实施阶段
+
+### 阶段 1:OutputValidator 严格键名校验
+
+**目标**:新增 strict 模式重载,对声明字段执行精确键存在校验。
+
+**改动点**:
+- `OutputValidator.java`:
+  - 新增重载 `validate(Map<String, Object> parsed, JsonNode outputsDecl, boolean strictAllRequired)`
+  - 旧方法 `validate(parsed, outputsDecl)` 内部转调 `validate(parsed, outputsDecl, false)`
+  - `strictAllRequired = true` 时:
+    - 遍历每个声明字段
+    - 若 `!parsed.containsKey(name)` → 加入 `missingRequired` 并标注 "键名缺失"
+    - 若 `parsed.get(name) == null` → 加入 `missingRequired` 并标注 "值为 null"
+    - 若值存在 → 走类型校验(与现有逻辑一致)
+  - 错误信息细化:`missingRequired` 元素从 `"fieldName"` 升级为 `"fieldName (键名缺失)"` 或 `"fieldName (值为空)"`,便于 LLM 在重试时精准修正
+
+**接口签名**:
+```java
+public static ValidationResult validate(
+    Map<String, Object> parsed,
+    JsonNode outputsDecl,
+    boolean strictAllRequired
+);
+
+// 旧入口保留
+public static ValidationResult validate(
+    Map<String, Object> parsed,
+    JsonNode outputsDecl
+);
+```
+
+**验证方式**:单元测试覆盖 4 个场景(中文字段名拒绝、英文字段名齐全通过、缺失部分字段拒绝、类型不匹配仍走转换)。
+
+### 阶段 2:RetryPolicy / WorkflowProperties 拆分计数
+
+**目标**:将 `maxRetries` 拆分为 `runtimeMaxRetries` 和 `formatMaxRetries`。
+
+**改动点**:
+- `RetryPolicy.java`:
+  - 新增字段:
+    - `int runtimeMaxRetries`(默认 2):运行异常时重试次数(含首次)
+    - `int formatMaxRetries`(默认 2):格式错误时重试次数(仅重组 JSON)
+  - 保留 `retryOnParseError`、`retryOnMissingRequired`、`retryOnTypeMismatch`(仍然决定是否触发对应类型的重试)
+  - 新增 `retryOnKeyNameMismatch`(默认 true,受 strict 模式产生的新失败类型)
+  - `@Deprecated` 标记 `maxRetries` 字段(保留过渡期,运行期仍可用,但优先取 runtimeMaxRetries)
+  - 构造器与 `fromNodeData` 同步更新;`defaultPolicy()` 改为返回 `runtimeMaxRetries=2, formatMaxRetries=2`
+
+- `WorkflowProperties.Retry`:
+  - 新增 `runtimeMaxRetries`、`formatMaxRetries`、`retryOnKeyNameMismatch`
+  - 保留 `maxRetries`(标注 `@Deprecated`,过渡期仍读,未配置时回退到 `runtimeMaxRetries`)
+  - `RetryPolicy.fromNodeData` 读取顺序:节点级 → 全局 → 默认
+
+**验证方式**:`RetryPolicyTest` 新增 3 个用例(默认值、节点级覆盖、节点级缺失走全局)。
+
+### 阶段 3:StructuredOutputHelper 双循环重写
+
+**目标**:实现"外层运行重试 + 内层格式重试"。
+
+**核心结构**:
+
+```java
+public static ExtractionResult extractWithRetry(
+        ChatClient client,
+        String userPrompt,
+        String systemPrompt,
+        JsonNode outputsDecl,
+        RetryPolicy retryPolicy) {
+
+    String runtimeError = null;
+
+    // ===== 外层:运行重试(重新完整推理) =====
+    for (int runtimeAttempt = 1; runtimeAttempt <= retryPolicy.runtimeMaxRetries; runtimeAttempt++) {
+
+        // 1. 调用 LLM 完整推理
+        LlmCallOutcome call;
+        try {
+            call = invokeLlm(client, userPrompt, systemPrompt, outputsDecl, null);
+        } catch (Exception e) {
+            runtimeError = "LLM 调用异常: " + e.getMessage();
+            if (!retryPolicy.retryOnParseError) break;
+            continue;  // 外层重试
+        }
+
+        if (call.response == null || call.response.isBlank()) {
+            runtimeError = "LLM 返回空结果";
+            if (!retryPolicy.retryOnParseError) break;
+            continue;
+        }
+
+        // 2. 内层:格式重试(仅重组 JSON,不重跑逻辑)
+        FormatResult fmt = reformatLoop(
+                client, call.response, outputsDecl, retryPolicy);
+
+        if (fmt.ok) {
+            return ExtractionResult.ok(fmt.data, runtimeAttempt + fmt.formatAttempts - 1);
+        }
+
+        // 3. 内层耗尽 → 外层重试(重新完整推理)
+        runtimeError = fmt.errorMessage;
+        if (!shouldRuntimeRetry(fmt.failReason, retryPolicy)) break;
+    }
+
+    return ExtractionResult.fail(runtimeError, retryPolicy.runtimeMaxRetries);
+}
+```
+
+**新增内部方法**:
+
+- `invokeLlm(client, userPrompt, systemPrompt, outputsDecl, previousResponse)`:
+  - `previousResponse == null` → 构造完整 prompt(userPrompt + JSON 模板指令)
+  - `previousResponse != null` → 构造重组 prompt(见下方)
+  - 返回包含原始响应的 `LlmCallOutcome`
+
+- `reformatLoop(client, originalResponse, outputsDecl, retryPolicy)`:
+  - `formatAttempts = 0`
+  - `currentResponse = originalResponse`
+  - 循环 `1..retryPolicy.formatMaxRetries`:
+    - `formatAttempts++`
+    - 解析 + strict 校验
+    - 通过 → 返回 `FormatResult.ok(data)`
+    - 失败 → 构造重组 prompt,调用 `invokeLlm(..., previousResponse=currentResponse, error=...)`,更新 `currentResponse`
+  - 耗尽 → 返回 `FormatResult.fail(reason)`
+
+**重组 prompt 模板**(关键:明确告知 LLM 字段名错了):
+
+```
+你之前的响应:
+{原始响应}
+
+但需要的字段名是(必须精确匹配,区分大小写):
+{声明的字段列表,含 type/description/required}
+
+检测到的问题:
+{具体错误,如 "字段 'status' 键名缺失;字段 'message' 键名缺失;..."}
+
+请仅根据你之前响应中的语义内容,**重新组织**为符合字段名要求的 JSON:
+{"status": ..., "message": ..., "data": {...}}
+
+仅输出 JSON 本身,不要任何额外文字、Markdown 标记。
+```
+
+**关键设计**:
+- 重组 prompt 只包含"上次响应 + JSON 模板 + 错误说明",**不包含原始 userPrompt**
+- LLM 只需做 key 重命名 + 结构调整,不重新执行业务逻辑
+- token 消耗远低于完整推理
+
+**新增内部数据类**(私有静态内部类):
+- `LlmCallOutcome(String response)`
+- `FormatResult(boolean ok, Map<String, Object> data, String errorMessage, FailReason failReason)`
+- `FailReason` 枚举:`PARSE_ERROR / MISSING_REQUIRED / TYPE_MISMATCH / KEY_NAME_MISMATCH / OTHER`
+
+**适配调用方**:`LlmExecutor`、`SmartActionExecutor` 等执行器的调用代码**完全不变**(对外接口未改)。
+
+**验证方式**:mock ChatClient,验证两种 prompt 的调用次数与内容。
+
+### 阶段 4:单元测试
+
+**新增测试类**:
+
+#### `OutputValidatorStrictTest.java`
+1. `strictMode_allFieldsPresent_ok` — 全部字段精确存在 → 通过
+2. `strictMode_chineseKeyNameRejected` — LLM 返回中文键名 → missingRequired 含所有声明字段
+3. `strictMode_partialMissing_fails` — 缺失 1 个字段 → missingRequired 含该字段
+4. `strictMode_nullValue_treatedAsMissing` — parsed 中 value=null → 视为缺失
+5. `strictMode_typeMismatch_stillConverts` — 类型不符但可转换 → 通过(含转换后值)
+6. `strictMode_extraFields_ignored` — LLM 返回额外字段 → 不影响通过
+7. `legacyMode_backwardCompatible` — `validate(parsed, decl)` 等价于 `validate(parsed, decl, false)`
+
+#### `StructuredOutputHelperDualLoopTest.java`
+1. `firstCallOk_noRetry` — LLM 首次返回合法 JSON → 直接返回,attempts=1
+2. `chineseKeys_triggerFormatRetry` — 首次中文,重组后英文 → ok,runtimeAttempts=1,formatAttempts=2
+3. `runtimeException_triggersRuntimeRetry` — 首次抛异常 → 外层重试一次成功
+4. `formatRetryExhausted_upgradesToRuntime` — formatMaxRetries 耗尽 → 触发外层重试
+5. `allRetriesExhausted_returnsFail` — 全部耗尽 → 返回 fail + errorMessage
+6. `reformatPrompt_doesNotContainUserPrompt` — 捕获重组 prompt,验证不含原始 userPrompt
+7. `reformatPrompt_listsAllMissingKeys` — 验证重组 prompt 明确列出所有缺失键名
+
+### 阶段 5:application.yml + prompt.md
+
+**改动点**:
+- `application.yml.example`:
+  ```yaml
+  workflow:
+    llm:
+      retry:
+        # 运行异常重试(重新完整推理)
+        runtime-max-retries: 2
+        # 格式错误重试(仅重组 JSON)
+        format-max-retries: 2
+        # JSON 解析失败是否重试
+        retry-on-parse-error: true
+        # 必填字段缺失是否重试
+        retry-on-missing-required: true
+        # 类型不匹配是否重试
+        retry-on-type-mismatch: true
+        # 键名不匹配(如返回中文键名)是否重试
+        retry-on-key-name-mismatch: true
+        # 已弃用:被 runtime-max-retries 替代,未配置时回退
+        # max-retries: 2
+  ```
+- `application.yml`(实际配置):同步更新
+- `prompt.md`:追加本次变更条目(用户视角)
+
+## 5. 关键陷阱
+
+| # | 陷阱 | 处理阶段 |
+|---|---|---|
+| 1 | strict 模式误伤:LLM 偶尔返回额外字段 | 忽略额外字段,只检查声明字段是否全部精确存在 |
+| 2 | 类型自动转换与 strict 共存 | 类型转换逻辑在 strict 之后执行,不冲突 |
+| 3 | 重组 prompt 长度爆炸(上次响应太长) | formatMaxRetries 默认 2;截断上次响应到前 2000 字(避免超 token) |
+| 4 | 内层重组再失败时是否再走外层 | 是,但通过 `shouldRuntimeRetry(failReason, policy)` 判定,避免无限循环 |
+| 5 | legacy `maxRetries` 字段兼容 | @Deprecated + 过渡期映射为 runtimeMaxRetries |
+| 6 | `retryOnMissingRequired=false` 时 strict 检测出的 missing 是否触发重试 | 新增独立 `retryOnKeyNameMismatch`,与 `retryOnMissingRequired` 解耦 |
+| 7 | 重组响应解析失败(LLM 又返回非 JSON) | 内层循环继续重试,直到 formatMaxRetries 耗尽 |
+
+## 6. 风险评估
+
+### 高风险(人工 review)
+- **H1 双循环复杂度**:嵌套循环 + 状态传递容易出错 → 缓解:把内层循环抽到独立方法 `reformatLoop()`,单元测试单独覆盖
+- **H2 重组 prompt 设计**:若指令不清晰,LLM 仍可能返回中文 → 缓解:prompt 中明确"必须精确匹配字段名"+ 列出所有缺失键名
+
+### 中风险(测试覆盖)
+- **M1 重组 prompt 长度**:通过截断上次响应缓解
+- **M2 重组响应仍非法**:通过内层循环 + 升级到外层缓解
+
+### 低风险
+- 节点级配置覆盖全局:保留现有 `fromNodeData` 模式,仅增加字段
+
+## 7. 不在本次范围内
+
+1. ❌ 不修改 `NodeExecutor` 接口
+2. ❌ 不修改 `NodeOutputEnvelope` 三段式结构
+3. ❌ 不引入 JSON Schema 硬约束
+4. ❌ 不引入中英文同义词典
+5. ❌ 不修改前端
+6. ❌ 不修改 `WorkflowContext` 数据结构
+7. ❌ 不修改 `userInput` / `condition` 节点执行器(它们不走 `extractWithRetry`)
+
+## 8. 回滚预案
+
+- 改造集中 4 个核心文件 → `git revert` 原子回滚
+- 无 DB schema 改动
+- 配置项新增,旧配置 `max-retries` 仍可读取,回滚后立即生效
+
+## 9. 复杂度估算
+
+| 阶段 | 内容 | 估算(小时) |
+|---|---|---|
+| 1 | OutputValidator strict 模式 | 1.5 |
+| 2 | RetryPolicy / WorkflowProperties 拆分 | 1.5 |
+| 3 | StructuredOutputHelper 双循环重写 | 4 - 5 |
+| 4 | 单元测试(14 用例) | 3 - 4 |
+| 5 | application.yml + prompt.md | 0.5 |
+| **合计** | | **10.5 - 12.5** |

+ 301 - 0
docs/workflow-output-schema-constraint-plan.md

@@ -0,0 +1,301 @@
+# 节点输出 JSON Schema 动态约束方案
+
+> 状态:已确认,待实施
+> 关联:[workflow-envelope-strict-validation-plan.md](./workflow-envelope-strict-validation-plan.md)、[node-context-ondemand-plan.md](./node-context-ondemand-plan.md)
+
+## 一、背景与目标
+
+### 1.1 问题陈述
+
+LLM 类节点(LLM、智能操作、技能、Hermes 智能体、Hermes 智能操作)需要根据节点画布声明的 outputs 字段,**动态生成 JSON 输出约束**注入到 prompt,让 LLM 严格按 schema 产出结构化结果,并能优雅处理失败重试。
+
+### 1.2 目标
+
+1. **模板格式带示例值**:让 LLM 直观理解每种类型的输出形式
+2. **Hermes 系列接入**:所有基于 Hermes 的节点(HermesAgent / HermesSmartAction / Agent / Skill)与 LLM 节点对齐结构化能力
+3. **strict 模式只校验必需字段**:不报多余字段,只校验 `required=true` 字段是否存在且类型匹配
+4. **支持 LLM 自由扩展**:允许输出额外的辅助字段(confidence / run_error / user_notice 等),snake_case 命名,最小化原则
+
+---
+
+## 二、当前现状(代码基线)
+
+### 2.1 已实现
+
+- ✅ `StructuredOutputHelper.buildInstruction`(`StructuredOutputHelper.java:326-356`)已根据 `data.outputs` 动态拼接 JSON 模板,但格式是 `"name": <type>` 占位符,**无示例值**
+- ✅ `OutputValidator.validate`(`OutputValidator.java:100-152`)已实现 strict 校验 + 自动类型转换
+- ✅ 双层重试(runtime + format)已实现(`StructuredOutputHelper.java:91-146`)
+- ✅ `LlmExecutor`(`LlmExecutor.java:60-75`)、`SmartActionExecutor`(`SmartActionExecutor.java:66-83`)已完整接入
+
+### 2.2 缺失
+
+- ❌ **Hermes 系列未接入**:`HermesAgentExecutor.java:87-91`、`HermesSmartActionExecutor.java:74-76`、`AgentExecutor.java:83-85`、`SkillExecutor` 仅做单字段透传
+- ❌ **模板无示例值**:`<type>` 占位符对小模型不够友好
+- ❌ **strict 模式过严**:当前 `strictAllRequired=true`,所有声明字段一律视为必填,无法区分 required/optional
+- ❌ **无扩展字段机制**:strict 模式下 LLM 无法自由添加辅助字段
+
+---
+
+## 三、已确认的决策点
+
+| 决策点 | 选择 | 说明 |
+|---|---|---|
+| 模板格式 | 带示例值 | `"output1": "XXX"` 形式 |
+| Hermes 内层重组 LLM 来源 | **节点 data 中独立字段** `reformat_model_id` | 区别于节点主模型,允许配置更便宜的重组模型 |
+| Hermes 外层重跑 session 策略 | 复用同一 session | 与 Bridge 现有缓存机制一致 |
+| strict 兼容期开关 | 不保留,直接改 | 修订后更宽松,存量节点不会因此失败 |
+| 扩展字段下游过滤 | 暂不做 | 与 `node-context-ondemand-plan.md` 解耦 |
+| object 子字段约束 | 暂不做 | 后续单独实现 |
+
+---
+
+## 四、Hermes 系列接入方案(核心设计)
+
+### 4.1 关键挑战
+
+Hermes 与普通 LLM 的本质差异:
+
+| 维度 | 普通 LLM | Hermes Agent |
+|---|---|---|
+| 调用方式 | 单次 prompt→response | 多轮工具调用 + SSE 流 |
+| 内层重试代价 | 低(再调一次 LLM) | **极高**(重跑整个 Agent 链) |
+| 重试可控性 | 高 | 低(Agent 决策路径不可控) |
+
+**结论**:内层"格式重组"重试**不能用 Hermes 重跑**,否则一次格式错误就触发完整 Agent 流程,成本不可接受。
+
+### 4.2 混合双层重试
+
+```
+外层(runtime retry)  ──→  Hermes 完整重跑(同一 session)
+                                ↓ finalText
+内层(format retry)   ──→  普通 LLM 重组(用 reformat_model_id 配置的模型)
+                                ↓ 符合 schema 的 JSON
+                          OutputValidator 校验
+```
+
+**职责分离**:
+- **Hermes**:业务推理、工具调用、产出 finalText(昂贵,少跑)
+- **普通 LLM**:把 finalText 整理成符合 schema 的 JSON(廉价,可多次)
+
+### 4.3 数据流
+
+```
+HermesAgentExecutor.execute(nodeId, data, context)
+    │
+    ├─ 1. 构造 userMessage = task + buildInstruction(outputsDecl)
+    │
+    ├─ 2. hermesBridgeClient.run(userMessage, ...)
+    │     → SSE 流式(thinking + tool_calls)
+    │     → finalText(Agent 最终回复)
+    │
+    ├─ 3. needsStructuredOutput(data)?
+    │     ├─ 否 → 单字段透传(保持现状)
+    │     └─ 是 → 进入步骤 4
+    │
+    └─ 4. StructuredOutputHelper.extractWithRetryFromText(
+              finalText,
+              outputsDecl,
+              policy,
+              reformatClient,           // 内层重组用,按 reformat_model_id 解析
+              runtimeRetrySupplier      // 外层重试用:hermesBridgeClient 重跑
+          )
+          → envelope.success(data) 或 envelope.failure(...)
+```
+
+### 4.4 API 设计
+
+`StructuredOutputHelper` 新增方法:
+
+```java
+public static ExtractionResult extractWithRetryFromText(
+        String rawText,
+        JsonNode outputsDecl,
+        RetryPolicy policy,
+        LlmClient reformatClient,            // 内层用,可空(空则跳过内层)
+        Supplier<String> runtimeRetrySupplier // 外层用,可空(空则不重跑)
+) { ... }
+```
+
+**复用原则**:内部子方法(`tryParseJson`、`buildReformatPrompt`、`buildInstruction`、`OutputValidator.validate`)与 LLM 路径完全复用,**只换"原始文本来源"**。
+
+### 4.5 各 Executor 改动
+
+| Executor | 当前 | 改动 |
+|---|---|---|
+| `LlmExecutor` | 已接入 | 模板格式跟随升级(无逻辑改动) |
+| `SmartActionExecutor` | 已接入 | 同上 |
+| `HermesAgentExecutor` | 未接入 | 调用 `extractWithRetryFromText`,传入 `hermesBridgeClient::runAgain` 作为外层 supplier |
+| `HermesSmartActionExecutor` | 未接入 | 同上 |
+| `AgentExecutor` | 未接入 | 根据 `hermesProperties.enabled` 分流到 Hermes 路径或 LLM 路径 |
+| `SkillExecutor` | 间接委托 | 委托链不变,跟随上游自动获得能力 |
+
+### 4.6 外层重试边界
+
+- **默认 `runtimeMaxRetries=1`**(Hermes 系列专用)
+- 普通 LLM 节点保持 `runtimeMaxRetries=2`
+- `RetryPolicy.fromNodeData` 根据节点类型设置默认值
+- 用户可在节点 data 中覆盖
+
+---
+
+## 五、Prompt 模板(带示例值)
+
+`StructuredOutputHelper.buildInstruction` 重写后产出(以 output1/output2/output3 为例):
+
+```
+{原用户 prompt / 技能执行逻辑输出}
+
+---
+
+**必须按如下 JSON 格式输出最终结果**(仅输出 JSON 本身,不要任何 Markdown 包裹、解释或额外文字):
+
+{
+  "output1": "XXX",                       // string 类型
+  "output2": 0,                            // number 类型
+  "output3": {"key": "value"}              // object 类型
+}
+
+**字段说明**:
+- output1 (string, required): {description 或 "(无描述)"}
+- output2 (number, required): {description 或 "(无描述)"}
+- output3 (object, required): {description 或 "(无描述)"}
+
+**扩展字段**(可选):
+- 你可以自由添加业务必要的辅助字段(如 confidence、run_error、user_notice 等)
+- 命名规则:snake_case
+- 最小化原则:只放置真正必需的辅助信息,不要堆砌
+- 不要添加与上述已声明字段语义重叠的字段
+
+**约束**:
+- 必须严格使用上述声明的字段名,区分大小写、区分分隔符(禁止中文字段名)
+- required 字段缺失或类型不符将触发重试
+- 输出必须能被 JSON 直接解析(首尾不能有非 JSON 字符)
+```
+
+### 类型示例值映射
+
+| 声明类型 | 模板示例值 | 注释 |
+|---|---|---|
+| `string` | `"XXX"` | string 类型 |
+| `number` | `0` | number 类型 |
+| `boolean` | `true` | boolean 类型 |
+| `array` | `["item1", "item2"]` | array 类型 |
+| `object` | `{"key": "value"}` | object 类型 |
+| `filePath` / `directoryPath` / `file` | `"path/to/file"` | 视为 string |
+
+---
+
+## 六、Strict 模式修订
+
+### 6.1 核心规则
+
+```java
+for (JsonNode decl : outputsDecl) {
+    String name = decl.path("name").asText();
+    boolean required = decl.path("required").asBoolean(false);
+    String type = decl.path("type").asText("string");
+
+    // 规则 1:只校验 required 字段是否存在
+    if (strict && required && !parsed.containsKey(name)) {
+        result.addMissing(name);
+        continue;
+    }
+
+    // 规则 2:字段存在则校验类型(无论 required)
+    if (parsed.containsKey(name)) {
+        Object converted = checkAndConvertType(parsed.get(name), type);
+        if (converted == TYPE_MISMATCH_SENTINEL) {
+            result.addTypeMismatch(name, type);
+        }
+    }
+}
+// 规则 3:多余字段(含 snake_case 扩展字段)完全不校验
+```
+
+### 6.2 行为对比
+
+| 场景 | 修订前 | 修订后 |
+|---|---|---|
+| required 字段缺失 | ❌ 报错 | ❌ 报错(不变) |
+| 非 required 字段缺失 | ❌ strict 下报错 | ✅ 不报错 |
+| 多余字段(如 `confidence`) | 不报错 | ✅ 不报错(明确化) |
+| 类型不匹配 | ❌ 报错 | ❌ 报错(不变) |
+| required 全存在 + 多余扩展 | ❌ 可能报错 | ✅ 通过 |
+
+### 6.3 兼容性
+
+- 移除 `strictAllRequired` 语义
+- 不保留兼容期开关
+- 单元测试 `OutputValidatorStrictTest` 同步更新
+
+---
+
+## 七、实施阶段
+
+### 阶段 1:模板格式 + strict 语义
+
+**改动文件**:
+- `engine/StructuredOutputHelper.java`:重写 `buildInstruction`(示例值 + 扩展字段说明)
+- `engine/OutputValidator.java`:修订 strict 规则(只校验 required)
+- 测试:`StructuredOutputHelperDualLoopTest`、`OutputValidatorStrictTest`
+
+**验证标准**:
+- 5 种类型的示例值正确渲染
+- 扩展字段提示存在
+- strict 下非 required 缺失不报错
+- strict 下多余字段不报错
+
+### 阶段 2:新增"从文本提取"重试路径
+
+**改动文件**:
+- `engine/StructuredOutputHelper.java`:新增 `extractWithRetryFromText`
+- 提取公共子方法 `invokeReformatOnly`(内层重组,复用 `buildReformatPrompt`)
+
+**验证标准**:
+- 单元测试覆盖:内层重组成功、内层耗尽升级外层、外层 supplier 为 null 时直接失败
+
+### 阶段 3:Hermes 系列接入
+
+**改动文件**:
+- `engine/executor/HermesAgentExecutor.java`:调用 `extractWithRetryFromText`
+- `engine/executor/HermesSmartActionExecutor.java`:同上
+- `engine/executor/AgentExecutor.java`:根据 hermes 启用状态分流
+- `engine/RetryPolicy.java`:增加节点类型感知,Hermes 系列默认 `runtimeMaxRetries=1`
+- `engine/hermes/HermesBridgeClient.java`:可能需要"复用 session 重跑"接口
+
+**验证标准**:
+- 集成测试:声明多输出的 Hermes 节点能产出正确 envelope.data
+- 失败场景:Hermes finalText 自由文本时,内层 LLM 能重组为 JSON
+
+---
+
+## 八、风险与缓解
+
+| 风险 | 等级 | 缓解 |
+|---|---|---|
+| Hermes 内层重组需要 LLM 客户端 | 中 | 节点 data 配置 `reformat_model_id`,缺失时回退到 `AiModelService.getDefaultModel()` |
+| Hermes 外层重跑成本高 | 高 | 默认 `runtimeMaxRetries=1`;监控告警 |
+| strict 语义变化影响存量 | 低 | 修订后更宽松,已通过的节点不会失败 |
+| 扩展字段污染下游 NodeInputResolver | 低 | YAGNI,本阶段不处理,由 `node-context-ondemand-plan.md` 后续覆盖 |
+
+---
+
+## 九、复杂度估计
+
+| 阶段 | 工时 |
+|---|---|
+| 阶段 1 | 3-4 小时 |
+| 阶段 2 | 3-4 小时 |
+| 阶段 3 | 6-8 小时 |
+| **总计** | **12-16 小时** |
+
+---
+
+## 十、不做的事(YAGNI)
+
+- ❌ object 子字段约束(children 递归)
+- ❌ array 元素类型声明(itemType)
+- ❌ 枚举值 / 正则 pattern / 默认值约束
+- ❌ JSON Schema 标准化
+- ❌ 扩展字段下游过滤
+- ❌ strict 兼容期开关

+ 441 - 0
docs/workflow-vs-dify-comparison.md

@@ -0,0 +1,441 @@
+# 工作流特性对比:agent-management vs Dify
+
+> 对比目的:以 Dify `1.16.0` 开源参考实现(详见 `docs/dify-readme.md`)为坐标系,定位本仓库(agent-management)工作流引擎的能力边界、创新点与可借鉴的演进方向。
+>
+> 对比维度:节点类型与编排、调度与执行、信封化与校验、变量与输入解析、智能体集成、画布编辑器、HITL、可观测、外部 API、生态扩展。
+>
+> 信息来源:
+> - 本仓库需求与设计:`prompt.md`、`docs/workflow-*.md`、`docs/context-passing-architecture.md`、`docs/external-workflow-api-spec.md`、`docs/hermes-*.md`
+> - 本仓库实现:`backend/src/main/java/com/agent/management/engine/**`
+> - Dify 实现:`dify/api/core/workflow/**`、`dify/dify-agent/src/**`、`dify/dify-agent-runtime/**`
+
+---
+
+## 0. 结论速览
+
+| 维度 | 量化对比 |
+|---|---|
+| 本仓库独有特性 | **21 项**(详见 §2) |
+| Dify 独有特性 | **21 项**(详见 §3) |
+| 实现方法差异 | **15 项**(详见 §4) |
+| 设计哲学 | **本仓库**:单体 + 强一致 envelope + 引擎层统一治理(适合企业内部可控场景)<br>**Dify**:微服务 + 插件化生态 + 多形态应用(适合多租户 SaaS / 开源社区) |
+
+---
+
+## 1. 总体定位差异
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 项目类型 | 企业内部 AI 编码助手平台 | 开源 LLMOSS(LLM Operating System / Stack) |
+| 后端实现 | Java 17 + Spring Boot 单体 | Python + Flask + Celery + 多服务编排 |
+| 节点类型 | 硬编码 8 种 | 内置 + 插件扩展(plugin_daemon) |
+| 应用形态 | Workflow 一种 | Workflow / Chat / AdvancedChat / Completion / AgentChat 五种 |
+| 部署形态 | 单 JAR + MySQL + Redis | 11 个 docker-compose 服务(含 Plugin Daemon / Agent Backend / Local Sandbox 等) |
+| Agent 引擎 | Hermes Bridge(Python 子进程)+ Spring AI ChatClient | CotAgentRunner / FunctionCallAgentRunner / dify-agent(pydantic-ai) |
+| 沙箱实现 | Python 三层 monkey-patch(路径白名单) | Go Landlock LSM + tmux + sanitize-pty |
+
+**核心差异**:本仓库追求**轻量、强一致、企业内部可控**;Dify 追求**生态、可扩展、多形态覆盖**。
+
+---
+
+## 2. 本仓库具备而 Dify 没有的功能
+
+### 2.1 工作流引擎层
+
+| # | 特性 | 关键行为 | 实现位置 |
+|---|---|---|---|
+| 1 | **统一 envelope 全节点强制** | 所有节点(LLM / Agent / SmartAction / Hermes* / Knowledge / Condition / UserInput / Output)严格产出 `{status, message, data}` 三段式结构,多余字段丢弃;Dify 仅 Agent v2 节点强制 declared outputs,其他节点"尽力推断" | `engine/NodeOutputEnvelope.java`、`docs/workflow-node-output-envelope.md` 2.6 |
+| 2 | **统一 failStrategy(abort/skip)** | 引擎层 `WorkflowLevelExecutor` 提供节点级 `failStrategy` 配置;Dify 没有此配置,失败处理散落各节点内部(Agent v2 有 OutputFailureOrchestrator,但仅限该节点) | `engine/WorkflowLevelExecutor.java`、`docs/workflow-node-output-envelope.md` 2.10 |
+| 3 | **Dataflow 调度 + SKIPPED 状态** | 从 BSP(层同步)改造为 Dataflow:节点完成即触发后继(基于 `pendingInputs: Map<nodeId, AtomicInteger>` 入度计数),不被同层慢节点阻塞;条件分支未选路径自动标记 `SKIPPED`;Dify 的 `NodeOutputStatus` 枚举无 `SKIPPED` 状态(只有 PENDING/RUNNING/READY/TYPE_CHECK_FAILED/OUTPUT_CHECK_FAILED/NOT_PRODUCED/FAILED) | `docs/workflow-dataflow-execution-plan.md`、`dify/api/services/workflow/node_output_inspector_service.py:84-103` |
+| 4 | **节点级 abort 取消在飞节点** | `abortFlag`(AtomicBoolean)+ `inFlightFutures` Map,失败时 CAS 设置并 `cancel(true)` 所有在飞节点;Dify 节点失败后是否取消兄弟节点由 GraphEngine 图级策略决定,Dify 侧不可见也无显式 API | `engine/WorkflowLevelExecutor.java` 阶段 3 |
+| 5 | **envelope 失败自动升级** | `result.status == SUCCESS && envelope.isFailure()` 时引擎自动升级为 `NodeExecutionResult.failed`,按 failStrategy 处理;失败 envelope 仍写入上下文供下游感知 | `docs/hermes-error-detection-plan.md` 方案 D |
+
+### 2.2 节点输入与变量系统
+
+| # | 特性 | 关键行为 | 实现位置 |
+|---|---|---|---|
+| 6 | **四级模糊匹配 fallback** | 节点输入解析按拓扑逆序遍历可达前驱,依次尝试:①精确同名 → ②模糊同名(忽略 `-` `_` 和大小写)→ ③JSON 嵌套精确同名 → ④JSON 嵌套模糊同名;Dify 通过 `variable_pool` 直接寻址,无模糊匹配 | `docs/workflow-variable-scope-design.md` 4.3、`dify/api/core/workflow/variable_pool_initializer.py` |
+| 7 | **envelope.data 穿透取值** | 四级匹配全部穿透 envelope.data 内部取值,不再读扁平 Map 顶层;Dify 没有这种 envelope-aware 的输入解析 | `docs/workflow-node-output-envelope.md` 2.9 |
+| 8 | **NodeWorkspace 仅含可达前驱** | 节点开始执行前构建工作区,仅包含**可达前驱**(反向 BFS 计算)的输出,避免无关变量污染;Dify 通过共享 variable_pool,无显式工作区隔离 | `docs/workflow-variable-scope-design.md` 2.1 |
+| 9 | **前驱变量删除后自动回退关联** | 边变化导致前驱不再是可达前驱时,节点 inputs/output fields 的显式 mapping 自动回退为「自动推断」(autoMapped=true);Dify 通过手动 variable selector 重新选择,无自动回退 | `docs/workflow-variable-scope-design.md` 3.2 |
+| 10 | **运行工作目录 + `.agentignore`** | 每次运行分配 `<data-dir>/workflow-runs/{workflowId}/{runId}/`,所有节点共享;`.agentignore` 排除 `.hermes/`、`*.log`、`*.tmp` 等;可打包下载 zip;Dify 有 Local Sandbox 沙箱但没有 `.agentignore` 机制 | `docs/external-workflow-api-spec.md` 3.5 |
+
+### 2.3 LLM 结构化输出与重试
+
+| # | 特性 | 关键行为 | 实现位置 |
+|---|---|---|---|
+| 11 | **错误回注 prompt 的自我修正重试** | `StructuredOutputHelper.extractWithRetry` 校验失败时把 `lastError`(如"缺少 required 字段 xxx")注入 prompt 让 LLM 自我修正;Dify 没有这种"错误回注"的统一机制 | `engine/StructuredOutputHelper.java`、`docs/workflow-node-output-envelope.md` 2.2 |
+| 12 | **三级 JSON 提取容错** | ①直接 JSON 解析 → ②` ```json ` 代码块提取 → ③首尾 `{}` 子串提取;适配 LLM 不稳定输出;Dify 通过 litellm + json-repair 0.60 在底层处理 | `engine/StructuredOutputHelper.java` |
+| 13 | **类型静默自动转换** | 声明 number + 字符串数字 → 静默转换;boolean + "true"/"1"/"yes" → true;array/object + JSON 文本 → 解析;Dify 类型校验在 OutputTypeChecker 但更严格,无静默转换 | `engine/OutputValidator.java`、`dify/api/core/workflow/nodes/agent_v2/output_type_checker.py` |
+| 14 | **双循环重试(计划中)** | 外层 runtime retry(重新完整推理,默认 2 次)+ 内层 format retry(仅重组 JSON,不重跑业务,默认 2 次);内层耗尽升级为外层;Dify 无此分层 | `docs/workflow-envelope-strict-validation-plan.md` 阶段 3 |
+| 15 | **节点级 retry 配置覆盖全局** | `RetryPolicy.fromNodeData` 解析节点 `data.maxRetries` / `retryOn*`,覆盖 `application.yml` 默认值;Dify Agent v2 的重试在节点内部硬编码(OutputFailureOrchestrator) | `engine/RetryPolicy.java` |
+
+### 2.4 智能体与 Hermes 集成
+
+| # | 特性 | 关键行为 | 实现位置 |
+|---|---|---|---|
+| 16 | **Hermes Bridge(Python 子进程)** | `HermesProcessManager` 启动 Python bridge 子进程,注入 `HERMES_BRIDGE_BASE_URL/API_KEY/MODEL` 等环境变量,通过 SSE 通信(思考流 / 工具调用 / 最终结果);Dify Agent v2 通过独立 FastAPI 服务(pydantic-ai),架构不同 | `engine/hermes/HermesProcessManager.java`、`backend/hermes-bridge/hermes_bridge.py` |
+| 17 | **Hermes Error Pattern 错误检测** | 内置错误模式数据模型(`HermesErrorPattern`)+ Java Client 嗅探(`parseSseResponse` 在 done 分支后 substring 匹配)+ 前端管理页(`/hermes-errors`);内置"访问量过大"、"无效的 api"等中文模式;Dify 没有 LLM 错误响应的模式化识别 | `service/impl/HermesErrorPatternServiceImpl.java`、`docs/hermes-error-detection-plan.md` |
+| 18 | **三层 monkey-patch 沙箱** | `sandbox_patch.py` 对 hermes-agent 的 `_resolve_path_for_task`(路径白名单)、`_resolve_base_dir`(相对路径锚定)、`_git_root`(阻断外层项目根注入)打补丁;通过 `_SESSION_CWD` contextvar 线程隔离;Dify 沙箱是 Go Landlock LSM + tmux(更底层但与 Hermes 无关) | `backend/hermes-bridge/sandbox_patch.py`、`docs/hermes-sandbox.md` |
+| 19 | **默认模型实时路由** | `AiModelService.getDefaultModel` 实时查询,application.yml 切换默认模型后立即生效,不再回退到旧默认;模型管理可设置调用顺序 fallback;Dify 模型在节点级硬绑定,无统一默认路由 | `service/AiModelService.java` |
+| 20 | **Skill 文件预同步(方案 A)** | `SkillFileSyncer.syncTo` 在每次工作流执行前将 `uploads/skills` 下所有 skill 复制到工作目录 `.hermes/skills`,不复制历史版本文件;Dify 插件化体系不同,无此同步需求 | `engine/SkillFileSyncer.java` |
+| 21 | **技能生成(Skill 生成 Skill)** | 技能编辑页可选「该技能具备生成其他技能的能力」;技能生成菜单选择生成器技能 → 调 hermes-bridge → 在工作目录生成 → 复制到全局技能目录 → 跳转编辑页;Dify 无"工具生成工具"的概念 | `service/impl/SkillGenerationServiceImpl.java`、`prompt.md` 855-952 行 |
+
+### 2.5 画布编辑器与运行时交互
+
+| # | 特性 | 关键行为 | 实现位置 |
+|---|---|---|---|
+| 22 | **字段「引入」双向关联** | 节点 inputs 从前驱 outputs 引入、节点 outputs 从后继 inputs 引入;输入节点输入变量从后继输入引入(业务输入语义);输出节点输出变量从前驱输出引入(业务输出语义);引入后自动填入变量名/中文名/类型/描述;Dify 通过 variable selector 单向引用,无"输出字段反向关联后继" | `frontend/src/utils/ioInference.js`、`prompt.md` 1188-1192 行 |
+| 23 | **LLM 语义「自动关联」** | 点击「自动关联」按钮用 LLM 语义识别(非字符串匹配)哪些 Skill 的 SKILL.md 或智能操作工作流程中隐式调用了其他 Skill;识别结果以虚线框 + 虚线箭头画在画布上,技能节点位于虚线框中可拖动;Dify 无此"自动发现隐式依赖"能力 | `prompt.md` 357、776 行 |
+| 24 | **模板变量动态识别(无碎片)** | 在 `{{}}` 中按顺序输入变量名时实时识别,区分「自动字段」(从 {{}} 识别)和「手动字段」(用户显式添加),避免中间变量碎片(a、ab、abc 三个变量);Dify 使用变量选择器 UI,无模板动态识别 | `prompt.md` 1195、1200 行 |
+| 25 | **画布级变量/字段/节点名唯一性校验** | 节点内变量名不能重复、画布节点名不能重复,保存时拦截并列出具体节点与变量;Dify 通过 variable selector 引用,名称冲突由用户自行管理 | `prompt.md` 1265、1270 行 |
+| 26 | **节点动态斥力** | 拖动节点后过近节点动态推开达到平衡(非生成时距离);Dify 用 ReactFlow + ELK.js 自动布局,无交互式斥力 | `prompt.md` 806-818 行 |
+| 27 | **运行对话框变量导出/导入** | 导出已填入变量为 JSON 文件(命名 `工作流名称-节点名-输入变量.json`),强制选择保存位置;导入时填入对应表单;Dify 表单提交,无导入导出 | `prompt.md` 1276-1285 行 |
+| 28 | **输入/输出节点变量标签颜色语义** | 输入节点变量蓝色(业务输入)、输出节点变量绿色(业务输出)、其他节点前置数据蓝/输出变量绿;Dify 节点端口样式统一 | `prompt.md` 1264 行 |
+| 29 | **思考过程折叠 + 流式继续** | 运行记录中思考过程支持折叠,折叠后仍接收流式响应只是不显示,展开后可继续输出;Dify Trace 工具(Langfuse 等)有类似能力但非画布原生 | `prompt.md` 626 行 |
+| 30 | **节点名称与类型双行显示** | 画布节点显示「节点名称 节点类型」,名称字体与类型字体区分;空时只显示节点类型;节点名称画布内唯一;Dify 节点显示样式不同 | `prompt.md` 66、156 行 |
+
+### 2.6 工作流标识与外部 API
+
+| # | 特性 | 关键行为 | 实现位置 |
+|---|---|---|---|
+| 31 | **kebab-case 唯一标识 + 中文 displayName 分离** | `name` 为 kebab-case(`^[a-z0-9]+(-[a-z0-9]+)*$`),URL 按 name 寻址;`displayName` 可任意字符(含中文);翻译只翻译 displayName 与正文,不破坏 name;Dify 工作流标识为 UUID | `service/WorkflowNameNormalizer.java`、`docs/workflow-crud-api.md` |
+| 32 | **外部 API 三段式调用** | 创建运行(POST `/runs`)→ 启动(POST `/runs/{runId}/start`)→ 订阅(GET `/runs/{runId}/stream`);同步阻塞模式 `async=false` 直接 SSE;Dify 通过 Service API 单次调用,无"创建-启动-订阅"三段式 | `docs/external-workflow-api-spec.md` 3.1-3.2 |
+| 33 | **断线重连 SSE(Last-Event-ID)** | 通过 `Last-Event-ID` 头部续传,事件缓存窗口默认 1000 条或运行结束保留 1 小时;Dify 通过 socket.io 自动重连,但 SSE 模式无 Last-Event-ID 续传 | `docs/external-workflow-api-spec.md` 5 |
+| 34 | **工作目录 ZIP 打包下载** | `GET /runs/{runId}/workspace` 返回 zip(应用 `.agentignore`);Dify Agent 沙箱内文件通过 drive API 单文件上传/下载,无打包下载 | `docs/external-workflow-api-spec.md` 3.5 |
+| 35 | **目录结构保留上传** | 通过 `X-Relative-Path` part header 保留目录结构,含 ZIP slip 防护;Dify 文件上传扁平化 | `docs/external-workflow-api-spec.md` |
+
+---
+
+## 3. 本仓库没有而 Dify 具备的能力
+
+### 3.1 节点类型与编排
+
+| # | 特性 | Dify 实现 | 本仓库状态 |
+|---|---|---|---|
+| 1 | **迭代节点(iteration)** | `core/workflow/nodes/iteration/` 支持数组迭代执行子图 | ❌ 不支持 |
+| 2 | **显式并行节点 / 并行分支** | 通过 GraphEngine 线程池 + 并行边 | ⚠️ 仅同层并行(Dataflow 模式),无显式并行节点 |
+| 3 | **问题分类器节点** | `question-classifier` 节点用 LLM 把输入分类到不同分支 | ❌ 可用条件分支节点近似但不能 LLM 自动分类 |
+| 4 | **模板转换节点** | `template-transform` 节点专门做 Jinja2 渲染 | ❌ 无独立节点,模板渲染内嵌于各节点 |
+| 5 | **参数提取器节点** | `parameter-extractor` 节点用 LLM 从自然语言提取结构化参数 | ❌ 无 |
+| 6 | **变量聚合器节点** | `variable-aggregator` 节点把多个分支的变量合并 | ❌ 由 envelope 自动聚合,无显式节点 |
+| 7 | **HTTP Request 节点** | 独立节点支持任意 HTTP 调用 | ❌ 由 SmartAction 节点近似但非通用 HTTP |
+| 8 | **Code 节点(沙箱化代码执行)** | 独立 `dify-sandbox` 容器执行 Python / JS 代码 | ❌ 不支持任意代码执行 |
+| 9 | **Knowledge Index 节点(索引而非检索)** | `core/workflow/nodes/knowledge_index/` 在工作流中增量索引文档 | ❌ 知识库索引在管理后台,非工作流节点 |
+| 10 | **三种触发器节点(plugin / schedule / webhook)** | `trigger_plugin/`、`trigger_schedule/`、`trigger_webhook/` 支持事件驱动工作流 | ❌ 仅同步 API 触发 |
+
+### 3.2 HITL 与执行控制
+
+| # | 特性 | Dify 实现 | 本仓库状态 |
+|---|---|---|---|
+| 11 | **运行中 HITL 暂停-恢复** | `PauseStatePersistenceLayer` 全量序列化(generate_entity + graph_runtime_state + response_stream_filter_state + pause_reasons)+ 恢复重建;两条路径:独立 Human Input 节点 / Agent v2 内部 ask_human 延迟工具调用 | ❌ 仅支持运行前一次性收集 userInput |
+| 12 | **Agent 内部 ask_human 延迟工具调用** | pydantic-ai external deferred tool,让 Agent 在执行中请求人工输入后继续原上下文 | ❌ 不支持 |
+| 13 | **子工作流嵌套** | `_WorkflowChildEngineBuilder` 创建隔离的 child GraphEngine,复用父图 variable_pool | ❌ 工作流作为工具是外部 API 模式(通过 skill 调用),无内嵌子工作流 |
+
+### 3.3 Agent 与 LLM 接入
+
+| # | 特性 | Dify 实现 | 本仓库状态 |
+|---|---|---|---|
+| 14 | **ReAct(CoT)推理循环** | `CotAgentRunner` 实现 Thought/Action/Observation 文本协议 | ⚠️ 通过 Hermes Bridge 间接支持(hermes-agent 内部),但本仓库引擎不直接实现 |
+| 15 | **Function Calling 推理循环** | `FunctionCallAgentRunner` 使用 LLM 原生 tool_calls | ⚠️ 同上 |
+| 16 | **Agent v2 推理循环外包给 pydantic-ai** | 完全外包给独立 `dify-agent-backend` 服务 | ❌ Agent 在引擎内部执行 |
+| 17 | **litellm 统一 LLM 接入** | 通过 litellm 1.83 收敛 100+ provider | ⚠️ 通过 Spring AI + 自定义 AiModelService,覆盖面窄 |
+| 18 | **Plugin Daemon 插件化(模型 / 工具 / 节点)** | 所有 provider 外置为插件,第三方可贡献;28 种向量库 + 8 种 trace 后端 + 10 种对象存储 | ❌ 节点/模型/工具硬编码 |
+
+### 3.4 多端协同与可观测
+
+| # | 特性 | Dify 实现 | 本仓库状态 |
+|---|---|---|---|
+| 19 | **画布多人协同编辑** | loro-crdt 1.13 + python-socketio 实现工作流画布多人实时协同 | ❌ 单人编辑 |
+| 20 | **OpenTelemetry 全链路追踪** | OTel distro + 五种 instrumentation(celery/flask/httpx/redis/sqlalchemy)+ B3 传播 | ❌ 仅节点级 logs,无 OTel |
+| 21 | **8 种 Trace 后端插件化** | Langfuse / LangSmith / Phoenix / MLflow / Arize / Opik / Weave / 阿里云 / 腾讯云 | ❌ 无 trace 后端集成 |
+| 22 | **MCP 客户端/服务端** | `core/mcp/` 内置 MCP 协议支持(auth / client / server / session) | ❌ 未涉及 |
+| 23 | **Agent 沙箱化 Shell 工作区(tmux + Landlock)** | Go 实现的 `dify-agent-runtime`,通过 tmux 控制持久化 Shell,Landlock LSM 文件系统隔离,sanitize-pty ANSI 清洗 | ⚠️ Python monkey-patch 实现路径白名单,无 Landlock 级隔离 |
+| 24 | **多语言 i18next(11 种语言)** | i18next 26 + react-i18next 17 + 11 种语言包 | ⚠️ 中文为主,仅 displayName 翻译 |
+
+### 3.5 其他生态能力
+
+| # | 特性 | Dify 实现 | 本仓库状态 |
+|---|---|---|---|
+| 25 | **五种应用形态** | Workflow / Chat / AdvancedChat / Completion / AgentChat | ⚠️ 仅 Workflow 一种 |
+| 26 | **OpenAPI 自动生成(fastopenapi)** | `fastopenapi[flask]` 0.7.0 自动生成 OpenAPI 规范 | ⚠️ 通过 springdoc 生成(功能等价) |
+| 27 | **结构化 output_type 契约(pydantic-ai)** | Agent v2 通过 pydantic-ai output_type 强制 JSON Schema 输出契约 | ⚠️ envelope 校验类似但基于 declared outputs 字段列表,非完整 JSON Schema |
+| 28 | **多租户与 RBAC** | `core/rbac/` + tenant 隔离 + 套餐优先级(QueueDispatcherManager) | ⚠️ 单租户,无套餐优先级 |
+| 29 | **Sentry / Amplitude 前端可观测** | Sentry 10.66 + Amplitude(含 session-replay) | ❌ 未涉及 |
+| 30 | **流式工具调用(streaming tool call)** | `FunctionCallAgentRunner.stream_tool_call` 支持流式工具调用 | ⚠️ 通过 Hermes Bridge SSE 间接支持 |
+
+---
+
+## 4. 实现方法不同的能力
+
+### 4.1 工作流引擎实现
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 语言 | Java 17 + Spring Boot | Python 3.12 + Flask + 外部 graphon 包 |
+| 调度模型 | Dataflow(入度计数 `pendingInputs: Map<nodeId, AtomicInteger>`) | GraphEngine 线程池(min/max workers + scale up/down threshold) |
+| 并发 | CompletableFuture + nodeExecutor 线程池 | graphon 内部线程池 |
+| 失败取消 | `abortFlag`(AtomicBoolean)+ `inFlightFutures` Map,显式 cancel(true) | graphon 图级策略(Dify 侧不可见) |
+| 文件位置 | `engine/WorkflowLevelExecutor.java` | `dify/api/core/workflow/workflow_entry.py:213-225` |
+
+### 4.2 节点输出信封
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 强制范围 | 所有节点强制 `{status, message, data}` | 仅 Agent v2 节点 declared outputs 校验,其他节点"尽力推断" |
+| 校验器 | `OutputValidator` 统一校验(required / typeMismatch + 静默自动转换) | `OutputTypeChecker`(Agent v2 专用)+ `NodeOutputInspectorService`(声明输出视图) |
+| 失败处理 | envelope.isFailure() 自动升级为 NodeExecutionResult.failed | Agent v2 通过 `OutputFailureOrchestrator` 决策 RETRY/USE_DEFAULT/TAKE_FAIL_BRANCH/FAIL |
+| 文件位置 | `engine/NodeOutputEnvelope.java`、`engine/OutputValidator.java` | `dify/api/core/workflow/nodes/agent_v2/output_failure_orchestrator.py` |
+
+### 4.3 失败与重试策略
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 配置层级 | 引擎层 `failStrategy: abort/skip`(节点级) + 全局 `application.yml` retry 配置 + 节点级 retry 覆盖 | 无统一 failStrategy;Agent v2 节点内部 `OutputFailureOrchestrator` |
+| 重试触发 | retryOnParseError / retryOnMissingRequired / retryOnTypeMismatch / retryOnKeyNameMismatch(计划中) | 由 OutputFailureOrchestrator 内部决策 |
+| 重试方式 | 错误回注 prompt 自我修正;双循环(runtime retry + format retry,计划中) | 节点内部 RETRY 决策(重新调用 agent backend) |
+| 文件位置 | `engine/RetryPolicy.java`、`engine/StructuredOutputHelper.java` | `dify/api/core/workflow/nodes/agent_v2/output_failure_orchestrator.py` |
+
+### 4.4 Agent 推理循环
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 老版 Agent | `AgentExecutor` 直接 Spring AI ChatClient(无 ReAct 循环,单次调用) | `CotAgentRunner`(ReAct 文本协议)+ `FunctionCallAgentRunner`(FC) |
+| 新版 Agent | `HermesAgentExecutor` 通过 Hermes Bridge Python 子进程(hermes-agent 内部实现推理循环) | Agent v2 通过 `dify-agent-backend`(pydantic-ai)服务 |
+| 最大迭代 | 由 hermes-agent 控制(不在本仓库引擎层) | `min(max_iteration, 99) + 1` |
+| 文件位置 | `engine/executor/HermesAgentExecutor.java` | `dify/api/core/agent/cot_agent_runner.py:80` |
+
+### 4.5 结构化输出
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 触发条件 | 节点 `data.outputs` 非空即触发(不再要求 size > 1) | Agent v2 通过 pydantic-ai output_type 原生支持;其他节点 litellm + json-repair |
+| 指令构造 | `StructuredOutputHelper.buildInstruction`(字段名+类型+描述追加 userPrompt) | pydantic-ai 内部根据 output_type 自动构造 |
+| 容错 | 三级 JSON 提取(直接 / ```json``` / 首尾 `{}`) | json-repair 0.60 底层修复 |
+| 文件位置 | `engine/StructuredOutputHelper.java` | `dify/api/core/workflow/nodes/agent_v2/agent_node.py:393-411` |
+
+### 4.6 变量系统与输入解析
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 寻址方式 | 四级模糊匹配 fallback(精确 / 模糊 / JSON Path 精确 / JSON Path 模糊)+ 显式 mapping | variable_pool 直接寻址 + variable_prefixes 前缀解析 |
+| 工作区 | NodeWorkspace(仅含可达前驱输出,反向 BFS) | 共享 variable_pool(无显式工作区隔离) |
+| envelope 穿透 | 五级匹配全部穿透 envelope.data | 节点通过 variable selector 显式声明 |
+| 模板渲染 | `{{varName}}`、`{{nodeId.varName}}`、`{{nodeId.var.path}}`;变量不存在保留占位符 | Jinja2 + variable_prefixes |
+| 文件位置 | `engine/NodeInputResolver.java`、`engine/TemplateRenderer.java` | `dify/api/core/workflow/variable_pool_initializer.py`、`dify/api/core/workflow/variable_prefixes.py` |
+
+### 4.7 HITL(人工介入)
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 入口 | userInput 节点(运行前一次性收集) | 独立 Human Input 节点 + Agent v2 ask_human 延迟工具 |
+| 暂停机制 | ❌ 无运行中暂停 | `PauseRequestedEvent` + `PauseStatePersistenceLayer` 全量序列化 |
+| 恢复机制 | ❌ 无 | 从 DB 加载 `WorkflowResumptionContext`,重建 GraphRuntimeState |
+| 持久化 | ❌ 无 | generate_entity + graph_runtime_state + response_stream_filter_state + pause_reasons |
+| 文件位置 | `engine/executor/UserInputExecutor.java` | `dify/api/core/app/layers/pause_state_persist_layer.py:77-177` |
+
+### 4.8 沙箱实现
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 语言 | Python(hermes-bridge 内) | Go(dify-agent-runtime) |
+| 隔离机制 | 三层 monkey-patch(路径白名单 + 相对路径锚定 + Git 根阻断)+ contextvar 线程隔离 | Linux Landlock LSM(内核级文件系统隔离)+ tmux(持久化 Shell)+ sanitize-pty(ANSI 清洗) |
+| 隔离强度 | 应用层(依赖 Python 进程内约束) | 内核层(依赖 Linux LSM) |
+| 状态持久化 | 工作目录 `<data-dir>/workflow-runs/{workflowId}/{runId}/` | SQLite(modernc.org/sqlite 纯 Go)记录 job 状态与退出码 |
+| 文件位置 | `backend/hermes-bridge/sandbox_patch.py` | `dify/dify-agent-runtime/internal/landlock/`、`dify/dify-agent-runtime/internal/sanitize/` |
+
+### 4.9 画布编辑器
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 框架 | Vue + vue-flow 风格画布 | React + ReactFlow 11.11 + ELK.js(图布局) |
+| 协同 | ❌ 单人编辑 | loro-crdt 1.13 + python-socketio 多人实时协同 |
+| 自动布局 | ❌ 手动 + 节点动态斥力(拖动后推开过近节点) | ELK.js 自动布局 |
+| 字段关联 | 「引入」双向(input 从前驱 / output 从后继)+ LLM 语义「自动关联」(虚线框发现 Skill 隐式依赖) | variable selector 单向引用 |
+| 文件位置 | `frontend/src/views/workflow/WorkflowEditor.vue`、`frontend/src/utils/ioInference.js` | `dify/web/features/workflow/` |
+
+### 4.10 模型接入与默认路由
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 接入层 | Spring AI ChatClient + 自定义 AiModelService | litellm 1.83 统一收敛 100+ provider |
+| 默认模型 | AiModelService.getDefaultModel 实时查询,application.yml 切换立即生效 | 节点级硬绑定,无统一默认路由 |
+| Provider 管理 | 模型管理页设置调用顺序 + fallback | 通过 plugin_daemon 加载 provider 插件 |
+| 文件位置 | `service/AiModelService.java` | `dify/api/core/model_manager.py`、`dify/api/core/provider_manager.py` |
+
+### 4.11 持久化与执行历史
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 节点执行历史 | `WorkflowRunNode.logs`(thinking / tool_call / tool_result / info / error) | 节点执行记录 + OTel 全链路 trace |
+| 上下文快照 | `NodeExecutionResult.contextSnapshot`(计划中,按节点持久化 variables) | 共享 variable_pool 状态(无显式 per-node snapshot,但 HITL 时全量序列化) |
+| 调用链追踪 | 节点级 logs API:`GET /runs/{runId}/nodes/{nodeId}/logs` | OTel + 8 种 trace 后端(Langfuse / LangSmith / ...) |
+| 文件位置 | `model/entity/WorkflowRunNode.java`、`docs/context-passing-architecture.md` Phase 2-3 | `dify/api/core/ops/ops_trace_manager.py` |
+
+### 4.12 节点类型扩展机制
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 扩展方式 | 硬编码 `NodeTypeUtils` 枚举 + Executor 工厂 | plugin_daemon 加载第三方插件(节点 / 模型 / 工具) |
+| 节点版本 | 无显式版本(通过 Executor 类区分 Hermes/非 Hermes) | 节点 `version()` 类方法显式版本(如 Agent v1 vs v2) |
+| 动态注册 | ❌ 启动时静态注册 | ✅ 插件运行时注册 |
+| 文件位置 | `engine/NodeTypeUtils.java`、`engine/executor/*Executor.java` | `dify/api/core/workflow/node_factory.py:279` |
+
+### 4.13 国际化
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 范围 | 中文为主 + displayName 翻译(缓存到 JSON 文件或数据库,全文匹配调出) | i18next 26 + 11 种语言包 |
+| 翻译策略 | 只翻译 displayName 与正文(含英文的段落即翻译),不翻译 name(避免 kebab-case 被破坏) | 完整 i18n 资源包切换 |
+| 文件位置 | `prompt.md` 880-901 行 | `dify/web/i18n/`、`dify/web/i18n-config/` |
+
+### 4.14 部署架构
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 后端服务数 | 1(Java 单体) | 5(api / worker / worker_beat / plugin_daemon / agent_backend) |
+| 沙箱服务数 | 0(hermes-bridge 内嵌) | 2(dify-sandbox 代码沙箱 + dify-agent-local-sandbox Agent Shell) |
+| 反向代理 | 可选(用户自决) | Nginx + SSRF Proxy(Squid)必需 |
+| 必需基础设施 | MySQL/PostgreSQL + Redis + 向量库 | PostgreSQL/MySQL + Redis + 向量库 + Plugin Daemon + Agent Backend + Local Sandbox + Sandbox + SSRF Proxy + Nginx |
+| 文件位置 | `backend/src/main/resources/application.yml.example` | `dify/docker/docker-compose.yaml` |
+
+### 4.15 工作流标识与外部 API
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| 工作流标识 | kebab-case `name`(URL 寻址)+ 中文 `displayName` 分离 | UUID |
+| 外部调用模式 | 三段式(创建 → 启动 → 订阅)+ 同步阻塞模式 | 单次调用(Chat / Completion / Workflow 三类 API) |
+| 输入形态 | 纯 JSON / JSON + 文件 / JSON + 目录(保留结构 + ZIP slip 防护) | JSON + 文件(扁平化) |
+| 工作空间下载 | ZIP 打包(应用 `.agentignore`) | drive API 单文件上传/下载 |
+| 文件位置 | `docs/external-workflow-api-spec.md` | `dify/api/controllers/service_api/` |
+
+---
+
+## 5. 综合对比表
+
+### 5.1 能力覆盖矩阵
+
+| 能力领域 | agent-management | Dify | 备注 |
+|---|:---:|:---:|---|
+| **DAG 调度** | ✅ Dataflow | ✅ GraphEngine | 实现方式不同(§4.1) |
+| **同层并行** | ✅ Dataflow 模式 | ✅ GraphEngine 线程池 | 均支持 |
+| **完成即触发后继** | ✅ 显式实现 | ⚠️ 推测支持(graphon 内部) | 本仓库可观察(§2.1 #3) |
+| **SKIPPED 状态** | ✅ 条件分支未选路径 | ❌ 无此状态枚举 | 本仓库独有(§2.1 #3) |
+| **统一 envelope** | ✅ 全节点强制 | ⚠️ 仅 Agent v2 | 本仓库更一致(§2.1 #1) |
+| **统一 failStrategy** | ✅ abort/skip | ❌ 无统一配置 | 本仓库独有(§2.1 #2) |
+| **条件分支** | ✅ condition 节点 | ✅ if-else 节点 | 均支持 |
+| **迭代** | ❌ | ✅ iteration 节点 | Dify 独有(§3.1 #1) |
+| **子工作流** | ❌ 内嵌 | ✅ child_engine_builder | Dify 独有(§3.2 #13) |
+| **HITL 运行中暂停** | ❌ | ✅ PauseStatePersistenceLayer | Dify 独有(§3.2 #11) |
+| **HTTP / Code 节点** | ❌ | ✅ 独立节点 | Dify 独有(§3.1 #7、#8) |
+| **触发器(Webhook/Schedule)** | ❌ | ✅ 三种触发器 | Dify 独有(§3.1 #10) |
+| **变量模糊匹配** | ✅ 四级 fallback | ❌ 直接寻址 | 本仓库独有(§2.2 #6) |
+| **LLM 结构化输出** | ✅ extractWithRetry + 错误回注 | ✅ pydantic-ai output_type | 实现方式不同(§4.5) |
+| **类型静默转换** | ✅ | ⚠️ 严格校验 | 本仓库更宽容(§2.3 #13) |
+| **双循环重试** | ⚠️ 计划中 | ❌ | 本仓库规划中(§2.3 #14) |
+| **ReAct / FC Agent** | ⚠️ Hermes Bridge 间接 | ✅ 原生实现 | Dify 更直接(§3.3 #14、#15) |
+| **沙箱** | ✅ Python monkey-patch | ✅ Go Landlock + tmux | 实现方式不同(§4.8) |
+| **多人协同画布** | ❌ | ✅ loro-crdt | Dify 独有(§3.4 #19) |
+| **OTel 全链路追踪** | ❌ | ✅ + 8 种后端 | Dify 独有(§3.4 #20、#21) |
+| **MCP 协议** | ❌ | ✅ 客户端 + 服务端 | Dify 独有(§3.4 #22) |
+| **插件化扩展** | ❌ | ✅ plugin_daemon | Dify 独有(§3.3 #18) |
+| **LLM 错误模式检测** | ✅ HermesErrorPattern | ❌ | 本仓库独有(§2.4 #17) |
+| **LLM 自动关联** | ✅ 语义识别 Skill 依赖 | ❌ | 本仓库独有(§2.5 #23) |
+| **模板变量动态识别** | ✅ 无碎片 | ❌ | 本仓库独有(§2.5 #24) |
+| **变量导出/导入** | ✅ JSON 文件 | ❌ | 本仓库独有(§2.5 #27) |
+| **默认模型实时路由** | ✅ | ❌ | 本仓库独有(§2.4 #19) |
+| **工作目录 + `.agentignore`** | ✅ | ❌ | 本仓库独有(§2.2 #10) |
+| **外部 API 三段式** | ✅ | ⚠️ 单次调用 | 本仓库更灵活(§4.15) |
+
+### 5.2 设计哲学对比
+
+| 维度 | agent-management | Dify |
+|---|---|---|
+| **核心目标** | 企业内部 AI 编码助手(Skill 驱动) | 开源 LLMOSS(多形态应用平台) |
+| **一致性优先级** | 高(统一 envelope、统一 failStrategy) | 中(各节点分散处理) |
+| **扩展性优先级** | 低(硬编码 8 种节点) | 高(插件化,第三方可贡献) |
+| **学习曲线** | 低(节点少,强约束) | 高(节点多,配置灵活) |
+| **部署复杂度** | 低(单体 JAR) | 高(11 个 docker 服务) |
+| **生态丰富度** | 低(自有生态:Skill / Hermes) | 高(28 向量库 / 8 trace / 10 对象存储 / MCP) |
+| **多租户** | ❌ | ✅ |
+| **企业可控性** | 高(私有部署,全链路可见) | 中(开源 + 企业版分层) |
+
+---
+
+## 6. 演进建议
+
+### 6.1 短期可借鉴(低成本高收益)
+
+| 借鉴项 | 来源 | 价值 |
+|---|---|---|
+| **HITL 暂停-恢复机制** | `dify/api/core/app/layers/pause_state_persist_layer.py` | 让 userInput 节点支持运行中暂停,扩展交互场景 |
+| **OTel 节点级追踪** | `dify/api/core/ops/ops_trace_manager.py` | 提升可观测性,便于性能调优 |
+| **Langfuse 集成** | `dify/api/providers/trace/langfuse/` | 提供 LLM 调用全链路可视化 |
+| **断线重连 SSE** | Dify socket.io 自动重连机制 | 已有 Last-Event-ID 续传,可进一步参考 socket.io 思路 |
+| **节点版本机制** | Dify Agent v1 vs v2 的 `version()` 类方法 | 未来 Hermes/非 Hermes 节点演进时可显式版本化 |
+
+### 6.2 中期可考虑(中等成本)
+
+| 借鉴项 | 来源 | 价值 |
+|---|---|---|
+| **迭代节点** | `dify/api/core/workflow/nodes/iteration/` | 支持数组迭代处理(批量任务场景) |
+| **子工作流嵌套** | `_WorkflowChildEngineBuilder` | 复用已有工作流作为子流程 |
+| **HTTP Request 节点** | Dify http-request 节点 | 通用 HTTP 调用,替代 SmartAction 的部分场景 |
+| **Code 节点(沙箱化)** | `dify-sandbox` 容器 | 支持任意代码执行,扩展工作流能力 |
+| **触发器节点(Webhook)** | `trigger_webhook` 节点 | 事件驱动工作流,对接外部系统 |
+| **多租户与 RBAC** | `dify/api/core/rbac/` | 企业级权限隔离 |
+| **MCP 客户端集成** | `dify/api/core/mcp/` | 接入 MCP 生态,复用 MCP 工具 |
+
+### 6.3 长期慎选(高成本,需重新定位)
+
+| 借鉴项 | 来源 | 评估 |
+|---|---|---|
+| **Plugin Daemon 插件化** | `dify-plugin-daemon`(Go) | 显著增加架构复杂度,仅当需要第三方扩展生态时考虑 |
+| **Landlock 沙箱** | `dify-agent-runtime/internal/landlock/` | 需 Linux 内核 5.13+,企业内部场景 monkey-patch 已够用 |
+| **多人协同画布(loro-crdt)** | `dify/web/features/workflow/` | 需引入 CRDT 与 WebSocket,仅当多人协作是核心需求时考虑 |
+| **多形态应用(Chat / Completion / AgentChat)** | `dify/api/core/app/apps/` | 与本仓库 Workflow 单形态定位冲突,需重新评估产品方向 |
+| **litellm 统一 LLM 接入** | `dify/api/pyproject.toml` | Python 库,Java 生态可用 Spring AI 替代 |
+| **Plugin 化向量库(28 种)** | `dify/api/providers/vdb/` | 仅当业务需要多向量库切换时考虑 |
+
+### 6.4 应坚持的本仓库特色(不盲目学习 Dify)
+
+| 特色 | 价值 |
+|---|---|
+| **统一 envelope 全节点强制** | 比 Dify"仅 Agent v2 强制"更一致,便于下游消费 |
+| **统一 failStrategy(abort/skip)** | 比 Dify"散落各节点"更清晰,运维更可控 |
+| **Dataflow + SKIPPED 状态机** | 完成即触发 + 条件分支可视化,比 Dify 的状态枚举更精细 |
+| **四级模糊匹配 + envelope 穿透** | 容错性远超 Dify 的"直接寻址",降低用户心智负担 |
+| **LLM 错误回注 prompt 自我修正** | Dify 没有此机制,是结构化输出的关键创新 |
+| **Hermes Bridge + monkey-patch 沙箱** | 轻量级、无需多服务,适合企业内部场景 |
+| **LLM 语义「自动关联」** | Dify 完全没有,是 Skill 工作流的独特创新 |
+| **模板变量动态识别(无碎片)** | Dify 通过 variable selector 规避,体验不如本仓库 |
+| **运行对话框变量导出/导入** | 实测对调试与复现非常有用,Dify 应该学 |
+| **默认模型实时路由** | Dify 节点级硬绑定,本仓库更灵活 |
+| **外部 API 三段式 + 工作目录下载** | 比 Dify 单次调用更适合长时间任务场景 |
+| **kebab-case name + 中文 displayName 分离** | 比 Dify UUID 更友好,URL 可读性强 |
+
+---
+
+## 附录 A:本仓库工作流特性清单(按维度索引)
+
+详见各设计文档:
+- 节点类型与字段:`docs/workflow-node-fields.md`
+- 调度引擎计划:`docs/workflow-scheduling-engine-plan.md`
+- Dataflow 执行计划:`docs/workflow-dataflow-execution-plan.md`
+- 节点输出信封:`docs/workflow-node-output-envelope.md`
+- 信封严格校验:`docs/workflow-envelope-strict-validation-plan.md`
+- 变量作用域:`docs/workflow-variable-scope-design.md`
+- 上下文传递:`docs/context-passing-architecture.md`
+- CRUD API:`docs/workflow-crud-api.md`
+- 外部 API:`docs/external-workflow-api-spec.md`
+- Hermes 沙箱:`docs/hermes-sandbox.md`
+- Hermes 错误检测:`docs/hermes-error-detection-plan.md`
+
+## 附录 B:Dify 参考索引
+
+详见 `docs/dify-readme.md`:
+- 技术栈:§2
+- 必需依赖:§3
+- 工作流编排调度:§4
+- 多智能体协同探索(澄清:Dify 实际无此探索):§5
+- 目录树说明:§6

+ 31 - 0
frontend/src/api/hermesError.js

@@ -0,0 +1,31 @@
+import request from '../utils/request'
+
+/** 列出全部 Hermes 错误嗅探模式 */
+export function getHermesErrorPatterns() {
+  return request.get('/hermes-error-patterns')
+}
+
+/** 新建模式 */
+export function createHermesErrorPattern(data) {
+  return request.post('/hermes-error-patterns', data)
+}
+
+/** 编辑模式(内置模式允许编辑 pattern/enabled/description,禁止改名) */
+export function updateHermesErrorPattern(id, data) {
+  return request.post(`/hermes-error-patterns/${id}/edit`, data)
+}
+
+/** 删除模式(内置模式禁止删除) */
+export function deleteHermesErrorPattern(id) {
+  return request.post(`/hermes-error-patterns/${id}/delete`)
+}
+
+/** 启用/禁用切换 */
+export function toggleHermesErrorPattern(id) {
+  return request.post(`/hermes-error-patterns/${id}/toggle`)
+}
+
+/** 重置为默认(清空自定义,恢复内置为启用) */
+export function resetHermesErrorPatterns() {
+  return request.post('/hermes-error-patterns/reset')
+}

+ 4 - 2
frontend/src/components/layout/AppSidebar.vue

@@ -14,7 +14,8 @@ import {
   ServerOutline,
   GitNetworkOutline,
   BookOutline,
-  SparklesOutline
+  SparklesOutline,
+  WarningOutline
 } from '@vicons/ionicons5'
 import { NIcon } from 'naive-ui'
 
@@ -45,7 +46,8 @@ const menuItems = [
     ]
   },
   { key: '/tags', label: '标签管理', icon: PricetagsOutline },
-  { key: '/models', label: '模型管理', icon: SettingsOutline }
+  { key: '/models', label: '模型管理', icon: SettingsOutline },
+  { key: '/hermes-errors', label: '错误模式管理', icon: WarningOutline }
 ]
 
 // 分组展开状态:按 group 名称独立控制

+ 4 - 4
frontend/src/composables/useNodeFields.js

@@ -104,7 +104,7 @@ export function useNodeFields({
       if (existingAuto) {
         newAutoInputs.push(existingAuto)
       } else {
-        newAutoInputs.push({ name, label: name, type: 'string', description: '', mapping: null, autoTemplate: true })
+        newAutoInputs.push({ name, label: name, type: 'string', description: '', required: false, mapping: null, autoTemplate: true })
       }
     }
 
@@ -119,7 +119,7 @@ export function useNodeFields({
   // ========== 通用字段增删(适用于 LLM/Agent/Skill/Condition 的 inputs/outputs 列表) ==========
   function addListField(field) {
     if (!selectedData.value) return
-    const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '', mapping: null }]
+    const list = [...(selectedData.value[field] || []), { name: '', label: '', type: 'string', description: '', required: false, mapping: null }]
     vfUpdateNode(selectedNode.value.id, { [field]: list })
     syncSelectedNode()
   }
@@ -137,7 +137,7 @@ export function useNodeFields({
   // ========== UserInput 变量操作 ==========
   function addVariable() {
     if (!selectedData.value) return
-    const newVars = [...(selectedData.value.variables || []), { name: '', label: '', type: 'string', description: '' }]
+    const newVars = [...(selectedData.value.variables || []), { name: '', label: '', type: 'string', description: '', required: false }]
     vfUpdateNode(selectedNode.value.id, { variables: newVars })
     syncSelectedNode()
   }
@@ -155,7 +155,7 @@ export function useNodeFields({
   // ========== Output 节点字段操作 ==========
   function addOutputField() {
     if (!selectedData.value) return
-    const newFields = [...(selectedData.value.fields || []), { name: '', label: '', type: 'string', description: '' }]
+    const newFields = [...(selectedData.value.fields || []), { name: '', label: '', type: 'string', description: '', required: false }]
     vfUpdateNode(selectedNode.value.id, { fields: newFields })
     syncSelectedNode()
   }

+ 6 - 0
frontend/src/router/index.js

@@ -53,6 +53,12 @@ const routes = [
     component: () => import('../views/ModelManagement.vue'),
     meta: { title: '模型管理' }
   },
+  {
+    path: '/hermes-errors',
+    name: 'HermesErrorPatterns',
+    component: () => import('../views/system/HermesErrorPatterns.vue'),
+    meta: { title: '错误模式管理' }
+  },
   {
     path: '/history',
     name: 'RunHistory',

+ 12 - 4
frontend/src/utils/ioInference.js

@@ -65,9 +65,17 @@ export function getNodeOutputs(node) {
       if (d.outputs && d.outputs.length) return d.outputs
       return [{ name: 'result', label: 'LLM 输出', type: 'string', description: '大模型响应文本' }]
     case 'agent':
-      return d.outputs || []
+      // 与 AgentExecutor 单输出降级模式对齐:未声明 outputs 时实际产出 {result: ...}
+      if (d.outputs && d.outputs.length) return d.outputs
+      return [{ name: 'result', label: 'Agent 输出', type: 'string', description: '智能体响应文本' }]
     case 'skill':
-      return d.outputs || []
+      // 与 SkillExecutor 单输出降级模式对齐:未声明 outputs 时实际产出 {result: ...}
+      if (d.outputs && d.outputs.length) return d.outputs
+      return [{ name: 'result', label: 'Skill 输出', type: 'string', description: '技能执行结果' }]
+    case 'smartAction':
+      // 与 SmartActionExecutor 单输出降级模式对齐:未声明 outputs 时实际产出 {result: ...}
+      if (d.outputs && d.outputs.length) return d.outputs
+      return [{ name: 'result', label: '操作结果', type: 'string', description: '智能操作执行结果' }]
     case 'knowledgeRetrieval':
       if (d.outputs && d.outputs.length) return d.outputs
       return [
@@ -79,8 +87,8 @@ export function getNodeOutputs(node) {
     case 'output':
       return []
     case 'condition':
-      // 条件节点透传:输出 = 所有输入
-      return getNodeInputs(node)
+      // 与 ConditionExecutor 对齐:仅产出 selectedBranch(路由结果),不暴露输入字段
+      return [{ name: 'selectedBranch', label: '命中分支', type: 'string', description: '条件路由结果,如 branch-0' }]
     default:
       return d.outputs || []
   }

+ 3 - 3
frontend/src/utils/workflowNode.js

@@ -117,7 +117,7 @@ export function getDefaultData(type) {
         systemPrompt: '',
         userPrompt: '',
         inputs: [],
-        outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }],
+        outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '', required: false }],
         failStrategy: 'abort',
       }
     case 'agent':
@@ -130,13 +130,13 @@ export function getDefaultData(type) {
         actionPrompt: '',
         modelId: null,
         inputs: [],
-        outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }],
+        outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '', required: false }],
         failStrategy: 'abort',
       }
     case 'condition':
       return { label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
     case 'output':
-      return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
+      return { label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '', required: false }] }
     default:
       return { label: '节点' }
   }

+ 399 - 0
frontend/src/views/system/HermesErrorPatterns.vue

@@ -0,0 +1,399 @@
+<script setup>
+import { ref, reactive, computed, h, onMounted } from 'vue'
+import {
+  NIcon, NDataTable, NForm, NFormItem, NInput,
+  NSwitch, NSpace, NTag, NButton, NTooltip, NModal,
+  NPopconfirm, useMessage
+} from 'naive-ui'
+import {
+  AddOutline, CreateOutline, TrashOutline, CopyOutline, ReloadOutline, WarningOutline
+} from '@vicons/ionicons5'
+import {
+  getHermesErrorPatterns, createHermesErrorPattern, updateHermesErrorPattern,
+  deleteHermesErrorPattern, toggleHermesErrorPattern, resetHermesErrorPatterns
+} from '../../api/hermesError'
+
+const message = useMessage()
+
+const loading = ref(false)
+const patterns = ref([])
+const saving = ref(false)
+
+// 弹窗
+const showModal = ref(false)
+const editingId = ref(null)
+const isBuiltin = ref(false)
+const form = reactive({
+  name: '',
+  pattern: '',
+  description: '',
+  enabled: true
+})
+
+const stats = computed(() => ({
+  total: patterns.value.length,
+  enabled: patterns.value.filter(p => p.enabled).length,
+  builtin: patterns.value.filter(p => p.isBuiltIn).length
+}))
+
+const columns = [
+  {
+    title: '启用', key: 'enabled', width: 70,
+    render: (row) => h(NSwitch, {
+      size: 'small',
+      value: !!row.enabled,
+      loading: row._toggling,
+      onUpdateValue: (v) => handleToggle(row, v)
+    })
+  },
+  {
+    title: '名称', key: 'name', width: 200,
+    render: (row) => h('span', { style: 'font-weight: 500' }, row.name)
+  },
+  {
+    title: '匹配模式', key: 'pattern', width: 220,
+    render: (row) => h('code', {
+      style: 'padding: 2px 6px; background: rgba(127,127,127,0.12); border-radius: 3px; font-size: 12px;'
+    }, row.pattern)
+  },
+  { title: '说明', key: 'description', ellipsis: { tooltip: true } },
+  {
+    title: '类型', key: 'isBuiltIn', width: 90,
+    render: (row) => row.isBuiltIn
+      ? h(NTag, { type: 'info', size: 'small', variant: 'bordered' }, { default: () => '内置' })
+      : h(NTag, { type: 'success', size: 'small', variant: 'bordered' }, { default: () => '自定义' })
+  },
+  {
+    title: '操作', key: 'actions', width: 140,
+    render: (row) => h(NSpace, { size: 2, align: 'center', wrap: false }, {
+      default: () => [
+        h(NTooltip, null, {
+          trigger: () => h(NButton, {
+            size: 'tiny', quaternary: true, circle: true,
+            onClick: () => handleEdit(row)
+          }, { icon: () => h(NIcon, { size: 16 }, { default: () => h(CreateOutline) }) }),
+          default: () => '编辑'
+        }),
+        h(NTooltip, null, {
+          trigger: () => h(NButton, {
+            size: 'tiny', quaternary: true, circle: true,
+            onClick: () => handleDuplicate(row)
+          }, { icon: () => h(NIcon, { size: 16 }, { default: () => h(CopyOutline) }) }),
+          default: () => '复制为自定义'
+        }),
+        // 内置不可删除,自定义可删除
+        row.isBuiltIn
+          ? h(NTooltip, null, {
+              trigger: () => h(NButton, {
+                size: 'tiny', quaternary: true, circle: true, disabled: true
+              }, { icon: () => h(NIcon, { size: 16 }, { default: () => h(TrashOutline) }) }),
+              default: () => '内置模式不可删除'
+            })
+          : h(NPopconfirm, {
+              onPositiveClick: () => handleDelete(row),
+              positiveText: '确定',
+              negativeText: '取消'
+            }, {
+              trigger: () => h(NButton, {
+                size: 'tiny', quaternary: true, circle: true, type: 'error'
+              }, { icon: () => h(NIcon, { size: 16 }, { default: () => h(TrashOutline) }) }),
+              default: () => `确认删除模式「${row.name}」?`
+            })
+      ]
+    })
+  }
+]
+
+async function loadPatterns() {
+  loading.value = true
+  try {
+    const res = await getHermesErrorPatterns()
+    patterns.value = (res.data || []).map(p => ({ ...p, _toggling: false }))
+  } catch (e) {
+    message.error(e.message || '加载失败')
+  } finally {
+    loading.value = false
+  }
+}
+
+function resetForm() {
+  form.name = ''
+  form.pattern = ''
+  form.description = ''
+  form.enabled = true
+  editingId.value = null
+  isBuiltin.value = false
+}
+
+function openCreate() {
+  resetForm()
+  showModal.value = true
+}
+
+function handleEdit(row) {
+  resetForm()
+  editingId.value = row.id
+  isBuiltin.value = !!row.isBuiltIn
+  form.name = row.name
+  form.pattern = row.pattern
+  form.description = row.description || ''
+  form.enabled = !!row.enabled
+  showModal.value = true
+}
+
+async function handleDuplicate(row) {
+  resetForm()
+  form.name = row.name + '(副本)'
+  form.pattern = row.pattern
+  form.description = row.description || ''
+  form.enabled = !!row.enabled
+  // 复制走 create 流程
+  editingId.value = null
+  isBuiltin.value = false
+  showModal.value = true
+}
+
+async function handleSave() {
+  if (!form.name.trim()) { message.warning('请输入名称'); return }
+  if (!form.pattern.trim()) { message.warning('请输入匹配模式'); return }
+  saving.value = true
+  try {
+    const payload = {
+      name: form.name.trim(),
+      pattern: form.pattern.trim(),
+      description: form.description?.trim() || null,
+      enabled: form.enabled
+    }
+    if (editingId.value == null) {
+      await createHermesErrorPattern(payload)
+      message.success('已创建')
+    } else {
+      await updateHermesErrorPattern(editingId.value, payload)
+      message.success('已保存')
+    }
+    showModal.value = false
+    await loadPatterns()
+  } catch (e) {
+    message.error(e.message || '保存失败')
+  } finally {
+    saving.value = false
+  }
+}
+
+async function handleToggle(row, value) {
+  row._toggling = true
+  try {
+    await toggleHermesErrorPattern(row.id)
+    row.enabled = value
+  } catch (e) {
+    message.error(e.message || '切换失败')
+  } finally {
+    row._toggling = false
+  }
+}
+
+async function handleDelete(row) {
+  try {
+    await deleteHermesErrorPattern(row.id)
+    message.success('已删除')
+    await loadPatterns()
+  } catch (e) {
+    message.error(e.message || '删除失败')
+  }
+}
+
+async function handleReset() {
+  try {
+    await resetHermesErrorPatterns()
+    message.success('已重置为默认')
+    await loadPatterns()
+  } catch (e) {
+    message.error(e.message || '重置失败')
+  }
+}
+
+onMounted(() => {
+  loadPatterns()
+})
+</script>
+
+<template>
+  <div class="hermes-error-page">
+    <div class="page-header">
+      <div class="header-left">
+        <h2 class="page-title">
+          <n-icon size="22"><WarningOutline /></n-icon>
+          Hermes 错误模式管理
+        </h2>
+        <div class="page-subtitle">
+          检测 Hermes Agent 响应中的 LLM 调用失败(HTTP 4xx / 限流 / 鉴权 / 配额),命中即按节点失败处理
+        </div>
+      </div>
+      <div class="header-right">
+        <n-button :loading="loading" @click="loadPatterns">
+          <template #icon><n-icon><ReloadOutline /></n-icon></template>
+          刷新
+        </n-button>
+        <n-popconfirm
+          @positive-click="handleReset"
+          positive-text="确定重置"
+          negative-text="取消"
+        >
+          <template #trigger>
+            <n-button>重置为默认</n-button>
+          </template>
+          将清空所有自定义模式,并把内置模式恢复为启用。确定继续?
+        </n-popconfirm>
+        <n-button type="primary" @click="openCreate">
+          <template #icon><n-icon><AddOutline /></n-icon></template>
+          新建模式
+        </n-button>
+      </div>
+    </div>
+
+    <div class="stats-row">
+      <div class="stat-card">
+        <span class="stat-label">总数</span>
+        <span class="stat-value">{{ stats.total }}</span>
+      </div>
+      <div class="stat-card">
+        <span class="stat-label">启用</span>
+        <span class="stat-value enabled">{{ stats.enabled }}</span>
+      </div>
+      <div class="stat-card">
+        <span class="stat-label">内置</span>
+        <span class="stat-value">{{ stats.builtin }}</span>
+      </div>
+    </div>
+
+    <div class="table-wrapper">
+      <n-data-table
+        :columns="columns"
+        :data="patterns"
+        :loading="loading"
+        :bordered="false"
+        :single-line="false"
+        size="small"
+        flex-height
+        style="height: calc(100vh - var(--header-height) - 250px);"
+      />
+    </div>
+
+    <n-modal
+      v-model:show="showModal"
+      preset="card"
+      :title="editingId == null ? '新建错误模式' : (isBuiltin ? '编辑内置模式' : '编辑自定义模式')"
+      :mask-closable="false"
+      style="max-width: 520px"
+      :bordered="false"
+    >
+      <n-form label-placement="top">
+        <n-form-item label="名称">
+          <n-input
+            v-model:value="form.name"
+            placeholder="如 LLM 429 限流"
+            :disabled="isBuiltin"
+          />
+          <template #feedback>
+            <span v-if="isBuiltin" style="color: var(--text-tertiary)">内置模式不可改名</span>
+          </template>
+        </n-form-item>
+        <n-form-item label="匹配模式(子串、大小写不敏感)">
+          <n-input
+            v-model:value="form.pattern"
+            placeholder="如 API call failed after"
+            code
+          />
+        </n-form-item>
+        <n-form-item label="说明">
+          <n-input
+            v-model:value="form.description"
+            type="textarea"
+            :rows="2"
+            placeholder="可选:触发场景、影响范围"
+          />
+        </n-form-item>
+        <n-form-item label="启用">
+          <n-switch v-model:value="form.enabled" />
+        </n-form-item>
+      </n-form>
+      <template #action>
+        <n-button @click="showModal = false">取消</n-button>
+        <n-button type="primary" :loading="saving" @click="handleSave">保存</n-button>
+      </template>
+    </n-modal>
+  </div>
+</template>
+
+<style scoped>
+.hermes-error-page {
+  padding: 24px;
+}
+
+.page-header {
+  display: flex;
+  justify-content: space-between;
+  align-items: flex-start;
+  margin-bottom: 20px;
+  gap: 16px;
+}
+
+.page-title {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 20px;
+  font-weight: 600;
+  color: var(--text-primary);
+  margin: 0 0 4px 0;
+}
+
+.page-subtitle {
+  font-size: 13px;
+  color: var(--text-tertiary);
+}
+
+.header-right {
+  display: flex;
+  gap: 8px;
+}
+
+.stats-row {
+  display: flex;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.stat-card {
+  flex: 1;
+  padding: 12px 16px;
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-md);
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+}
+
+.stat-label {
+  font-size: 12px;
+  color: var(--text-tertiary);
+}
+
+.stat-value {
+  font-size: 22px;
+  font-weight: 600;
+  color: var(--text-primary);
+}
+
+.stat-value.enabled {
+  color: var(--color-accent);
+}
+
+.table-wrapper {
+  background: var(--bg-card);
+  border: 1px solid var(--border-color);
+  border-radius: var(--radius-md);
+  padding: 8px;
+}
+</style>

+ 108 - 10
frontend/src/views/workflow/WorkflowEditor.vue

@@ -264,10 +264,10 @@ const nodeTypeInfo = {
 function getDefaultData(type) {
   switch (type) {
     case 'userInput': return { nodeName: '', label: '用户输入', variables: [] }
-    case 'llm': return { nodeName: '', label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '' }], failStrategy: 'abort' }
+    case 'llm': return { nodeName: '', label: '大模型处理', modelId: null, systemPrompt: '', userPrompt: '', inputs: [], outputs: [{ name: 'result', label: 'LLM 输出', type: 'string', description: '', required: false }], failStrategy: 'abort' }
     case 'agent': return { nodeName: '', label: '智能体', agentId: '', agentName: '', modelId: null, failStrategy: 'abort' }
     case 'skill': return { nodeName: '', label: '技能', skillId: '', skillName: '', modelId: null, failStrategy: 'abort' }
-    case 'smartAction': return { nodeName: '', label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '' }], failStrategy: 'abort' }
+    case 'smartAction': return { nodeName: '', label: '智能操作', actionPrompt: '', modelId: null, inputs: [], outputs: [{ name: 'result', label: '操作结果', type: 'string', description: '', required: false }], failStrategy: 'abort' }
     case 'knowledgeRetrieval': return {
       nodeName: '',
       label: '知识库检索',
@@ -283,14 +283,14 @@ function getDefaultData(type) {
       topK: 5,
       inputs: [],
       outputs: [
-        { name: 'evidences', label: '检索证据列表', type: 'array', description: '命中的知识片段集合', mapping: null },
-        { name: 'evidenceCount', label: '证据数量', type: 'number', description: '命中证据条数', mapping: null },
-        { name: 'sourceType', label: '来源类型', type: 'string', description: 'DOCUMENT / STRUCTURED_DATA / GRAPH / HYBRID', mapping: null },
-        { name: 'diagnostics', label: '诊断信息', type: 'object', description: '检索过程的诊断信息', mapping: null }
+        { name: 'evidences', label: '检索证据列表', type: 'array', description: '命中的知识片段集合', required: false, mapping: null },
+        { name: 'evidenceCount', label: '证据数量', type: 'number', description: '命中证据条数', required: false, mapping: null },
+        { name: 'sourceType', label: '来源类型', type: 'string', description: 'DOCUMENT / STRUCTURED_DATA / GRAPH / HYBRID', required: false, mapping: null },
+        { name: 'diagnostics', label: '诊断信息', type: 'object', description: '检索过程的诊断信息', required: false, mapping: null }
       ]
     }
     case 'condition': return { nodeName: '', label: '条件分支', conditions: [{ type: 'IF', expression: '' }] }
-    case 'output': return { nodeName: '', label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '' }] }
+    case 'output': return { nodeName: '', label: '输出', fields: [{ name: 'result', label: '输出结果', type: 'string', description: '', required: false }] }
     default: return { nodeName: '', label: '节点' }
   }
 }
@@ -524,6 +524,7 @@ function confirmImportField(candidate) {
       label: candidate.fieldLabel || candidate.fieldName,
       type: candidate.fieldType || 'string',
       description: candidate.fieldDescription || '',
+      required: false,
       mapping: {
         sourceNodeId: candidate.nodeId,
         sourceField: candidate.fieldName,
@@ -545,7 +546,8 @@ function confirmImportField(candidate) {
       name: candidate.fieldName,
       label: candidate.fieldLabel || candidate.fieldName,
       type: candidate.fieldType || 'string',
-      description: candidate.fieldDescription || ''
+      description: candidate.fieldDescription || '',
+      required: false
     }
     const updated = [...(selectedData.value?.[targetField] || []), newField]
     vfUpdateNode(selectedNode.value.id, { [targetField]: updated })
@@ -1206,6 +1208,13 @@ function handleOpenRunDialog() {
 
 // 启动运行(包装 composable,处理上传文件 + 输入字段)
 async function handleStartRun() {
+  // 必填校验:inputFields 来自 userInput.variables,已通过 ...v 透传 required
+  const missing = inputFields.value.filter(f => f.required && (formData[f.name] == null || formData[f.name] === ''))
+  if (missing.length) {
+    const names = missing.map(f => f.label || f.name).join('、')
+    message.warning(`以下字段为必填项:${names}`)
+    return
+  }
   const inputs = {}
   inputFields.value.forEach(f => {
     if (formData[f.name]) inputs[f.name] = formData[f.name]
@@ -1500,7 +1509,14 @@ function applyGraphData(graphData) {
                       <n-input :value="v.description" placeholder="描述" size="small"
                         @update:value="nv => { v.description = nv; onFieldChange('variables', [...selectedData.variables]) }" />
                     </div>
-                    <button class="text-btn danger" @click="removeVariable(i)">删除</button>
+                    <div class="var-form-row" style="justify-content:flex-end">
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('variables', [...selectedData.variables]) }" />
+                        <span>必填</span>
+                      </label>
+                      <button class="text-btn danger" @click="removeVariable(i)">删除</button>
+                    </div>
                   </div>
                   <div v-if="!selectedData?.variables?.length" class="empty-hint">暂无变量</div>
                 </div>
@@ -1552,6 +1568,11 @@ function applyGraphData(graphData) {
                         :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                         clearable placeholder="关联方式:自动推断" style="flex:1"
                         @update:value="nv => updateInputMapping(v, nv)" />
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeLlmField('inputs', i)">删除</button>
                     </div>
                   </div>
@@ -1580,6 +1601,11 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
                     <div class="var-form-row" style="justify-content:flex-end">
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeLlmField('outputs', i)">删除</button>
                     </div>
                   </div>
@@ -1663,6 +1689,11 @@ function applyGraphData(graphData) {
                         :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                         clearable placeholder="关联方式:自动推断" style="flex:1"
                         @update:value="nv => updateInputMapping(v, nv)" />
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                     </div>
                   </div>
@@ -1691,6 +1722,11 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
                     <div class="var-form-row" style="justify-content:flex-end">
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
                     </div>
                   </div>
@@ -1744,6 +1780,11 @@ function applyGraphData(graphData) {
                         :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                         clearable placeholder="关联方式:自动推断" style="flex:1"
                         @update:value="nv => updateInputMapping(v, nv)" />
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                     </div>
                   </div>
@@ -1772,6 +1813,11 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
                     <div class="var-form-row" style="justify-content:flex-end">
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
                     </div>
                   </div>
@@ -1832,6 +1878,11 @@ function applyGraphData(graphData) {
                         :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                         clearable placeholder="关联方式:自动推断" style="flex:1"
                         @update:value="nv => updateInputMapping(v, nv)" />
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeAgentField('inputs', i)">删除</button>
                     </div>
                   </div>
@@ -1860,6 +1911,11 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
                     <div class="var-form-row" style="justify-content:flex-end">
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeAgentField('outputs', i)">删除</button>
                     </div>
                   </div>
@@ -2019,6 +2075,11 @@ function applyGraphData(graphData) {
                         :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                         clearable placeholder="关联方式:自动推断" style="flex:1"
                         @update:value="nv => updateInputMapping(v, nv)" />
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeListField('inputs', i)">删除</button>
                     </div>
                   </div>
@@ -2047,6 +2108,11 @@ function applyGraphData(graphData) {
                         @update:value="nv => { v.description = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
                     </div>
                     <div class="var-form-row" style="justify-content:flex-end">
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('outputs', [...selectedData.outputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeListField('outputs', i)">删除</button>
                     </div>
                   </div>
@@ -2096,6 +2162,11 @@ function applyGraphData(graphData) {
                         :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                         clearable placeholder="关联方式:自动推断" style="flex:1"
                         @update:value="nv => updateInputMapping(v, nv)" />
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="v.required ?? false"
+                          @update:checked="nv => { v.required = nv; onFieldChange('inputs', [...selectedData.inputs]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeConditionInput(i)">删除</button>
                     </div>
                   </div>
@@ -2132,6 +2203,11 @@ function applyGraphData(graphData) {
                         :options="[{ label: '自动推断', value: null }, ...inputMappingOptions]" size="small"
                         clearable placeholder="关联方式:自动推断" style="flex:1"
                         @update:value="nv => updateInputMapping(f, nv)" />
+                      <label class="field-required-toggle">
+                        <n-checkbox :checked="f.required ?? false"
+                          @update:checked="nv => { f.required = nv; onFieldChange('fields', [...selectedData.fields]) }" />
+                        <span>必填</span>
+                      </label>
                       <button class="text-btn danger" @click="removeOutputField(i)">删除</button>
                     </div>
                   </div>
@@ -2293,7 +2369,10 @@ function applyGraphData(graphData) {
             </button>
           </div>
           <div v-for="f in inputFields" :key="f.name" class="run-field">
-            <label class="run-field-label">{{ f.label || f.name }}</label>
+            <label class="run-field-label">
+              <span v-if="f.required" class="required-mark">*</span>
+              {{ f.label || f.name }}
+            </label>
             <n-input v-model:value="formData[f.name]" :placeholder="f.description || f.name"
               :type="f.type === 'number' ? 'text' : 'text'" size="small" />
           </div>
@@ -3202,6 +3281,25 @@ function applyGraphData(graphData) {
   border-top: 1px dashed rgba(255, 255, 255, 0.06);
 }
 
+/* 必填开关:紧贴删除按钮,与 mapping-row 同行或单独删除行 */
+.field-required-toggle {
+  display: inline-flex;
+  align-items: center;
+  gap: 4px;
+  font-size: 12px;
+  color: #94a3b8;
+  cursor: pointer;
+  user-select: none;
+  white-space: nowrap;
+}
+
+/* 运行对话框必填字段红星 */
+.required-mark {
+  color: #EF4444;
+  margin-right: 2px;
+  font-weight: 600;
+}
+
 .cond-form {
   margin-bottom: 8px;
 }

+ 133 - 3
prompt.md

@@ -1312,6 +1312,136 @@ java.lang.IllegalStateException: rag-ai-bridge is disabled
 
 将上述改造实施计划输出到 @docs 下,然后开始实施。
 
-1. 我扔你是因为你当时笑得不可开交,然后把萌萌叫过去了,我一想就知道你要问她会不会摸老师奶头头,本来萌萌就老跟别人提这事儿,还强化她这个行为
-2. 放屁这个我之前早问过你,跟孕期没关系,你当时在地铁上熏得人直皱眉头的时候怀着孕呢?
-3. 婚礼这个,我也是纠结了很久,一直到周三小萌好差不多才给人家去的答复,主要也是想看看航天那个室友最近咋样
+---
+
+1. 部分节点的输出,输出为 Json 格式,但与我定义的字段名不一致。我的定义:
+   {"status": 200/400, "message": "...", "data": {"fieldA": ...}}
+它的输出:
+   {"状态": "成功", "信息": "XXXX成功", "数据": {"字段A": ...}}
+为什么 validator 没有将这种格式认定为非法,并让该节点重试,重新生成?
+
+2. 如果某个节点输出的格式未通过校验,我认为不应该重新运行节点。应分为几种情况:
+   (1) 运行失败,如"模型访问量过大"等情况,应重新运行节点;
+   (2) 运行成功,但输出结果提取错误,应先根据用户定义的输出变量组织 json 模板,然后将明确的 json 模板及节点运行结果或结论重新输入给大模型,让大模型根据节点运行结果重新组织 json 结构。没必要让节点的逻辑全部重新运行。
+
+---
+
+除"用户输入"节点和"条件分支"节点外,其他节点的用户定义输出均为必填,需严格满足每个字段均存在,键名严格校验。调整计划,把计划输出到 @docs 目录下,然后参考输出的计划文件执行。
+
+---
+
+执行完毕后,将计划与原 @docs/workflow-node-fields.md 补充合并。
+
+---
+
+现象:Hermes Agent 节点明明 LLM 调用失败(HTTP 429 限流、access_token 过期等),但工作流最终报告"执行成功",data 里装的是"API call failed after 3 retries: HTTP 429: 该模型当前访问量过大"。为什么"调用失败"却被当作"工作流成功"?根因排查并给出修复方案。
+
+---
+
+将执行方案写入 @docs 目录,然后执行 B+C+D。B 的错误文本匹配,还可以做一个菜单页,进行列表配置。
+
+---
+
+dify 目录是一个与智能体工作流编排调度等相关的参考项目。请探索该项目,告诉我这个项目主要干了哪些工作,在 docs 目录下生成一个 dify-readme.md,重点聚焦于其技术栈、工作流编排调度的具体方法、项目在多智能体智能协同方面的探索(智能调度而非强依赖DAG)以及必需依赖等方面,并为目录树中的每个目录生成一个简要说明。
+
+---
+
+@skills-manage 目录是一个与技能管理相关的参考项目。请遍历该项目,告诉我这个项目主要干了哪些工作,在 @docs 下生成一个 skills-manage-readme.md,重点聚焦于其技术栈、技能管理的具体逻辑以及必需依赖等方面。
+
+---
+
+分析 @temp/temp-logs.txt 文件,这是同一工作流的3次调用记录,包含各节点的输入和输出结果。3次调用时,输入的查询语句和实体位置等信息不同。已知“协同关系图谱查询[技能]”的逻辑是直接调用一个脚本请求数据(技能位于 @backend/uploads/skills/carrier-support-graph-query 下);“多源证据融合与评分[智能操作]”的逻辑是“仅依据文档、知识图谱和结构化数据库证据完成分析”。那么,分别观察这两个节点,为什么每个节点在3次调用中的输出格式都不同?一会儿输出 markdown 文本,一会儿输出 Json 结构。请仔细检查,必要时可以查询全量日志 @backend/logs/agent-management.2026-07-22.0.log 。
+
+---
+
+根据参考项目 hermes-agent,其 skill 注册表刷新机制是什么?
+
+---
+
+我在每次执行工作流时,会将 uploads/skills 下的所有 skill 复制到其“工作目录/.hermes”下。那么在这种情况下,Skill 同步(在 Java 上传新 skill 时同步刷新 hermes-agent 的 skill 注册表,让 skill_view 可用)应该如何实现?
+
+---
+
+选择方案A。确保 skillFileSyncer.syncTo(固定目录/skills) 方法不会将对应 skill 目录下的历史版本文件复制过去。per-run sync 可以删除。
+
+---
+
+技能执行日志:[WARNING] agent.tool_executor: Tool terminal returned error (1.59s): {"output": "C:\\Python311\\python.exe: can't open file 'D:\\\\projects\\\\agent-management\\\\hermes-home\\\\skills\\\\carrier-support-graph-query\\\\scripts\\\\skill.py': [Errno 2] No such file or di
+
+---
+
+根据 prompt.md 需求文件、 docs 目录下的设计文件,以及工作流相关的源码,梳理我们的工作流所包含的所有特性,然后与 dify 进行对比,列出我们具备而 dify 没有的功能、我们没有而 dify 具备的能力、我们与 dify 实现方法不同的能力。形成 md 文件,放到 docs 目录下。
+
+---
+
+技能生成页面,使用“具有创建其他技能能力的技能”进行技能创建时,将创建的技能写入到运行时的工作目录中,而不是该技能目录下(目前会写入 generated_skills、tmp_smoke_generated 两个目录),否则会导致 Hermes 扫描技能时出现多个相同技能,进而发生冲突。
+
+---
+
+我建议,在向 {HERMES_HOME} 复制 skill 时(包括但不限于软件启动时和 skill 发生增删改时),除跳过每个技能下的 _versions 目录外,还跳过每个 skill 中直接包含 SKILL.md 的子目录。评估我的这个方案。
+
+---
+
+给我解释“技能内含 subdir/nested/SKILL.md(subdir 无 SKILL.md) | 跳过 subdir/”,我没看懂,不是应该整体跳过 “subdir/nested/” ?
+
+---
+
+参考 @dify 目录,告诉我:dify 的 Agent V2 节点是什么?定位是什么?
+
+---
+
+如果我希望做一个工具,让每个工作流节点可以按需获取前序节点的工作区变量、输出变量、思考过程,而非将所有变量塞到上下文里,我应该将这个工具做成什么形式的?
+
+---
+
+将上述计划输出至 docs 下,然后详细告诉我目前 NodeWorkspaceBuilder 的变量注入方式。
+
+---
+
+将NodeWorkspaceBuilder 注入机制也写到 node-context-ondemand-plan.md 中,你自己找合理的位置放。
+
+---
+
+目前,智能操作、技能、LLM等LLM类节点的输出是如何约束的?如果在执行过程中,根据节点的输出变量,动态拼接输出json模板,让节点遵守,应如何操作?给出方案。
+例如,某节点定义的输出变量为:output1(字符串)、output2(数字)、output3(Json对象),那么在提示词或技能执行逻辑后,拼接:
+```
+**必须按如下格式输出:**
+{
+  "output1": "XXX", // 字符串类型
+  "output2": 0, // 数字类型
+  "output3": {XXX}, // Json对象
+  "other_fields": ... // 其他有必要输出的变量,如置信度、运行错误、需要用户注意或参考的信息等
+}
+```
+或者你也可以给出更好的设计。
+
+---
+
+除 LlmExecutor / SmartActionExecutor 外,针对 HermesAgentExecutor / HermesSmartActionExecutor / SkillExecutor / AgentExecutor,也给出接入方案,因为本质上,这些都是基于 Hermes 去执行的节点,非常重要。
+扩展字段,直接放到 envelope 的 data 字段里,不要使用“_meta”等字段包裹,而且要告知LLM,它可以自由发挥,不局限与我们列出的少数几个字段,但只放置必要的字段,最小化放置,snake_case命名方式。
+object 子字段约束,暂时不做,后续实现。
+strict模式下,不识别多余字段,只识别必需字段是否存在。
+模板格式,带上示例值。
+
+---
+
+1. 选项B;2. 选项A;3. 选项A;4. 先不做。
+将改造计划先写入 docs,然后开始执行。
+
+---
+
+为各节点的前置数据(输入变量)和输出变量增加“必填”或“必需”设置。
+
+---
+
+当前,输出节点接收不到前序节点的输出变量,但可以接收到一堆前序节点的工作区变量。检查逻辑。
+
+---
+
+我为输出节点设置了输入变量result和git-commit-log(对输出节点而言,这些变量既是输入变量,也是输出变量)。我期望的输出是包含这两个变量的,但实际输出是:
+{"uploadedFiles":["GenerateTableSections.bas"],"node-2-1781355966894__result":"已成功生成README.md文档并获取Git提交日志","node-2-1781355966894__readme":"D:\\projects\\agent-management\\backend\\data\\workflow-runs\\2\\20d4f9f8\\README.md","node-2-1781355966894__git_commit_log":"1. 重构了工作流执行模型。","_workingDirFiles":["GenerateTableSections.bas","README.md"],"_runId":"20d4f9f8"}
+这些应该是工作区变量而非输出变量。请检查
+
+---
+
+我的需求都写在 prompt.md 文件中。请根据我的需求(倒数几个需求),重新总结变更。同类变更放到一起。变更说明不要过于技术化。