# AGENTIC 模式 Planner 重访决策增强计划

> **文档目的**：增强 AGENTIC 模式下 Planner 的重访决策能力，让"用户在节点输出中表达新需求""编译/校验失败需要重做""业务需要重新执行节点"等场景能被 Planner 正确识别并主动跳回前序节点，而不是机械地走向 output 终结。
>
> **改造范围**：
> - 后端：`PlannerService` prompt 重构 + `AgenticExecutor` 死循环防护策略调整
> - 不动 clarify 通道、不动 WorkflowPause 表、不动节点执行器
>
> **状态**：待用户确认
>
> **关联文档**：`docs/plans/agentic-workflow-plan.md`（AGENTIC 模式原始实施计划，已实施完成）

---

## 1. 用户需求复述

### 1.1 触发场景（用户原话）

> 我执行了"自由跳转测试"工作流。我在智能操作节点的 clarify 工具调用时，表达我还有新需求。我本意是想让 planner 在节点运行结束后再次执行节点，处理我新增的需求。但 planner 直接选择了结束。

### 1.2 现象

- 用户在智能操作节点（HermesSmartActionExecutor / Hermes Agent 内部）通过 clarify 工具表达"还有新需求"
- Hermes Agent 接收 answer，继续内部 LLM loop，**最终 final_response 中包含"用户有新的需求：XXX"字样**
- 节点 output 进入 currentVars
- Planner 决策时看不到 clarify Q&A 过程，但能看到节点 output
- 结果：Planner 选择 output 节点 → 运行结束，新需求未被处理

### 1.3 用户反馈（修订要点）

1. **Planner 不需要看 clarify 过程**，节点 final_response 已包含"用户有新的需求：XXX"等信号，让 Planner 看节点 output 即可
2. **重走 ≠ 重试**：Planner 主动决策的重走（如新需求、编译失败回退）是合法的运行，不应让"重试次数"增加，也不应触发死循环兜底
3. **弱化规则 3**："避免在同一节点上反复横跳（已访问次数多的节点优先级降低）"过于压制合理重访

---

## 2. 现状梳理（根因 + 链路）

### 2.1 clarify 真实链路（说明为何不需关注）

clarify 是 **Hermes Agent (Python Bridge) 内部 LLM loop 的事件**：

1. Hermes Agent 内部 LLM 决定调 `clarify` 工具
2. Python Bridge 推送 `clarify_request` → **HermesBridgeClient.java:442-464** 转发前端 + **ClarifyHooks.java:40-84** 落库
3. 用户提交答案 → **HermesBridgeClient.java:599 submitClarifyAnswer** → Bridge → 唤醒 Hermes Agent loop
4. Hermes Agent 继续 loop，最终产出 `final_response`
5. `final_response` 作为节点 envelope.data → 进入 `currentVars`（带 `{nodeId}__` 前缀）

**关键事实**：用户在 clarify 中表达的新需求，**最终会出现在节点的 final_response 中**。Hermes Agent 本身的 system prompt 会让它把"用户表达的新需求"作为 output 的一部分回传（如"用户有新的需求：XXX"）。

**结论**：无需新增 clarify → Planner 通道。Planner 只需正确解读节点 output 即可。

### 2.2 Planner 当前感知的上下文

来自 **PlannerService.java:120-187 buildPrompt**：
- 当前节点 id + 已访问次数
- failureContext（仅 lastFailed=true 时附加）
- currentVars 预览：Top-30 条，**每条值截断到 MAX_VALUE_LEN=200 字符**（**PlannerService.java:31**）
- 可访问节点清单（id / type / label / 已访问次数）

### 2.3 当前阻碍重访的三道墙

| 阻碍 | 位置 | 表现 |
|------|------|------|
| 决策规则 3 | **PlannerService.java:177-179** | "避免在同一节点上反复横跳（已访问次数多的节点优先级降低）" — 直接在 prompt 层压制重访 |
| 死循环兜底 | **AgenticExecutor.java:54-56 + 122-130** | `MAX_SAME_NODE_REVISITS=5`：同节点累计访问 6 次直接 abort，**不区分业务重走 vs 无意义横跳** |
| 节点 output 截断 | **PlannerService.java:31** | `MAX_VALUE_LEN=200`：节点 final_response 中"用户新需求"详细描述可能被截断 |

### 2.4 根因总结

1. **Planner prompt 规则 3** 在语义上压制重访，Planner 即便看到"用户新需求"也不敢重走
2. **MAX_SAME_NODE_REVISITS=5** 把"业务重走"和"无意义横跳"一锅端，开发者担心死循环就把阈值设得太严
3. **节点 output 截断到 200 字符**可能丢关键信号

---

## 3. 跳回前序节点的典型场景（用户确认已基本覆盖）

按"触发主体"分类：

| 类别 | 场景 | 是否本次范围 |
|------|------|--------|
| **A. 用户主动表达** | A1：节点 output 中含"用户新需求"（**本次核心**） | ✅ 本次 |
| | A2：userInput 节点多轮交互（AGENTIC 已天然支持） | 已支持 |
| **B. 业务回退** | B1：编译/校验失败，需要回到前序节点重新生成 | ✅ 本次（不区分对待） |
| | B2：依赖缺失，回前序节点补全 | ✅ 本次（不区分对待） |
| **C. 节点自声明信号** | C1：节点声明 `requestRevisit=true` | 后续扩展（本次不做） |
| **D. Planner 自主判断** | D1：Planner 看 currentVars 空字段主动回 userInput | ✅ 本次（顺带受益） |
| **E. 外部信号** | E1：定时器、人工干预 | ❌ 超出范围 |

**本次设计原则**：所有"业务驱动的重走"统一视为合法行为，与"网络重试（maxRetries）"严格分离。

---

## 4. 最终方案

### 4.1 改动一：弱化 Planner prompt 规则 3

**位置**：`PlannerService.java:177-179`（buildPrompt 中的规则段）

**当前规则**：
```
3. 避免在同一节点上反复横跳（已访问次数多的节点优先级降低）
```

**改为**：
```
3. 重访已执行过的节点是允许的，但必须基于具体理由。典型合理理由：
   - 上一节点 output 中出现"用户新需求""需要修正""请重做"等明确信号
   - 编译/校验失败，需要重新生成
   - 依赖数据缺失，需要补全
   无理由的反复横跳（A→B→A→B→A...）才被压制。
```

**配套**：在 prompt 中增加"上一节点 output 摘要"段（独立于 currentVars 预览），让 Planner 直接看到当前节点产出，无需从 currentVars 反推。

### 4.2 改动二：放宽 MAX_VALUE_LEN

**位置**：`PlannerService.java:31`

**当前**：`private static final int MAX_VALUE_LEN = 200;`

**改为**：`private static final int MAX_VALUE_LEN = 500;`

**理由**：节点 final_response 中"用户新需求"详细描述常超过 200 字符，500 字符能覆盖大部分场景，对 Planner token 占用也可接受（30 * 500 = 15k 上限，远低于现代 LLM 上下文窗口）。

### 4.3 改动三：语义重构 —— 区分 retry vs revisit，**移除 MAX_SAME_NODE_REVISITS 硬 abort**

#### 4.3.1 语义模型

AGENTIC 模式下"节点再次执行"有两类完全不同的语义：

| 类型 | 触发主体 | 触发原因 | 计数策略 | abort 行为 |
|------|---------|---------|---------|-----------|
| **retry（失败重试）** | 节点执行器内部 | attempt 内抛异常 / 业务返回 FAILED | 计入 `attempt`（已有，单 visit 内 maxRetries 上限） | attempt 用尽后整个 visit 标记 FAILED，交 Planner 决策 |
| **revisit（计划重走）** | Planner LLM | 节点成功完成后，Planner 主动决策"再来一次" | **不计入任何 abort 计数器**；属于"计划的一部分" | **不触发 abort** |

**关键洞察（用户原话）**：
> 如果是重试，增加重试次数；如果是重走，不仅不应该增加重试次数，反而应该清零，因为这是计划的一部分。

**典型合法 revisit 场景**：
- **ReAct 模式**：根据上一节点 output 调整实现，可能需要 20+ 次重走，每次都是"计划的一部分"
- **用户新需求**：clarify 中用户追加需求，节点 output 标记"用户有新的需求"，Planner 决策重走
- **编译/校验失败回退**：节点 A 产出不合格，回到 A 重新生成
- **依赖补全**：B 节点发现 A 的产出缺失字段，回 A 补全

这些场景下，visit 链中同节点出现 N 次是**正常**的，不应该被 abort。

#### 4.3.2 死循环防护策略

放弃"同节点访问次数"作为死循环指标，改用多维防护：

| 防护层 | 触发条件 | 行为 | 备注 |
|--------|---------|------|------|
| **L1 总量兜底** | `sortOrder >= MAX_VISITS=50` | abort | 绝对兜底，任何场景都生效 |
| **L2 单 visit 重试** | `attempt > maxRetries`（默认 3） | visit 标记 FAILED，交 Planner | 不变，沿用现有逻辑 |
| **L3 无进展检测**（可选增强） | 同节点连续 3 次访问，且 `currentVars` hash 完全相同 | abort | **本次不做**，留作后续扩展 |

**为什么 L1 单独够用**：
- 真正的死循环（A↔B 反复横跳无变化）会快速耗满 50 次总量
- 合法的 ReAct 模式虽然多次重走单节点，但总量仍在 50 内
- 50 次的上限对单 run 的 token 消耗是可接受的硬约束

**为什么不做 L3**：
- 检测"无进展"需要维护 visit signature 历史，增加复杂度
- "无进展"的语义边界模糊（部分场景下相同 input + 不同 output 也是合法的）
- 用户原意是"重走即合法"，L3 与原意存在张力
- 留作后续扩展点（基于实际运行数据再决定是否需要）

#### 4.3.3 具体改动

**位置**：`AgenticExecutor.java:55-56 + 122-130`

**删除**：
```java
// 第 55-56 行
/** 同节点连续访问 5 次仍找不到 Output，强制终止 */
private static final int MAX_SAME_NODE_REVISITS = 5;

// 第 122-130 行
int revisits = nodeVisitCounter.merge(currentNodeId, 1, Integer::sum);
if (revisits > MAX_SAME_NODE_REVISITS) {
    String msg = "节点 " + currentNodeId + " 连续访问超过 " + MAX_SAME_NODE_REVISITS
            + " 次，疑似死循环";
    log.warn("[Agentic] runId={} {}", runId, msg);
    return abort(context, emitter, runId, msg, sortOrder, currentNodeId,
            fromVisitUuid, fromNodeId, currentVars);
}
```

**保留**：
- `nodeVisitCounter` 仍维护，但仅用于：
  - `iterSeq` 字段（同节点第几次访问，写入 Visit 表用于前端展示）
  - Planner prompt 中"已访问 N 次"信息展示（让 Planner 自主判断）
- **不再作为 abort 依据**

**注释更新**：
```java
// nodeVisitCounter 仅用于 iterSeq 计算与 Planner prompt 信息展示
// 不再作为死循环 abort 依据 —— 业务重走（Planner 主动决策）是合法行为
// 死循环防护改由 MAX_VISITS=50 总量兜底
// 详见 docs/plans/agentic-revisit-decision-plan.md §4.3
```

### 4.4 改动四：Planner prompt 增加"上一节点 output 摘要"段

**位置**：`PlannerService.java:120-187 buildPrompt`

**新增段落**（在 currentVars 预览之前）：
```
上一节点（{currentNodeId}）的输出：
{effectiveResult.output 截断到 800 字符}

工作区变量（部分，按访问顺序累积，同名变量最近覆盖）：
{currentVars 预览}
```

**实现**：`AgenticExecutor.java:228`（Planner 调用前）把 `effectiveResult.getOutput()` 传给 PlannerService。

**理由**：
- Planner 通过 currentVars 反推"上一节点产出"需要先 strip `{nodeId}__` 前缀，认知负担重
- 独立段落直接展示原 output，更易识别"用户新需求"等信号
- 800 字符足以覆盖大部分 final_response（截断阈值与 MAX_VALUE_LEN 解耦，因为 output 是核心信号）

### 4.5 不改动的部分（明确列出）

- ❌ WorkflowPause 表结构不动（不加 answer_text 字段）
- ❌ WorkflowController.resume 不动
- ❌ HermesBridgeClient.submitClarifyAnswer 不动
- ❌ ClarifyHooks 不动
- ❌ 节点执行器不动（SmartAction / HermesAgent 等）
- ❌ 前端不动

---

## 5. 实施步骤

| 步骤 | 文件 | 行号 | 内容 |
|------|------|------|------|
| 1 | `PlannerService.java` | 31 | `MAX_VALUE_LEN = 200` → `500` |
| 2 | `PlannerService.java` | 57 | decideNext 签名加 `Map<String,Object> lastNodeOutput` 参数 |
| 3 | `PlannerService.java` | 120-187 | buildPrompt 加"上一节点 output 摘要"段（截断到 800 字符） |
| 4 | `PlannerService.java` | 177-179 | 规则 3 改写为"基于理由判断"版本 |
| 5 | `AgenticExecutor.java` | 55-56 | **删除** `MAX_SAME_NODE_REVISITS` 常量 |
| 6 | `AgenticExecutor.java` | 122-130 | **删除** 同节点访问次数 abort 兜底逻辑；保留 `nodeVisitCounter` 用于 iterSeq 与 Planner prompt |
| 7 | `AgenticExecutor.java` | 228-237 | Planner 调用前传入 `effectiveResult.getOutput()` |
| 8 | `PlannerServiceTest.java` | - | 加测试：lastNodeOutput 包含"用户新需求"时 prompt 含该文本 |
| 9 | `AgenticExecutorTest.java` | - | 更新原"死循环兜底"用例：移除 MAX_SAME_NODE_REVISITS 相关断言；保留 MAX_VISITS=50 兜底用例 |
| 10 | `AgenticExecutorTest.java` | - | 加测试：同节点连续 revisit 10+ 次仍正常运行（验证业务重走合法） |

---

## 6. 风险评估

| 风险 | 等级 | 缓解 |
|------|------|------|
| Planner LLM 仍选择 output（不理解"新需求"信号） | 中 | prompt 中给出明确示例；规则 3 已改写不压制重访；MAX_VISITS=50 终极兜底 |
| 移除 MAX_SAME_NODE_REVISITS 后真正死循环耗满 50 次浪费 token | 中 | L1 总量兜底仍生效；实际运行中真死循环很少见；可基于运行数据后续加 L3 无进展检测 |
| ReAct 模式下 token 消耗显著上升 | 中-高 | 设计预期内的成本，由用户主动选择；可后续加 run 级 token 上限 |
| Planner 理解错信号，无意义重访拉爆 token | 低-中 | 规则 3 改写后仍保留"无理由横跳压制"语义；reason 中要求给出理由 |
| 800 字符 output 摘要仍不够 | 低 | 实际场景中"用户新需求"通常在 output 开头，800 字符足够 |
| lastNodeOutput 为 null（节点失败时） | 低 | PlannerService 端 null 检查，段落降级为"（节点无输出）" |
| 历史已运行的 Visit 不受影响 | 无 | 本次只改 Planner 决策与上限阈值，Visit 数据结构不变 |
| 移除常量后老测试用例失败 | 低 | 实施时同步更新 AgenticExecutorTest 中 MAX_SAME_NODE_REVISITS 相关用例（步骤 9） |

---

## 7. 测试用例（验证目标）

### 7.1 单元测试

| 文件 | 用例 | 期望 |
|------|------|------|
| PlannerServiceTest | lastNodeOutput 含"用户新需求：补充 XXX" 时 | prompt 文本含该字符串；规则 3 文本已更新 |
| PlannerServiceTest | lastNodeOutput 为 null 时 | prompt 段落降级为"（节点无输出）"，不抛异常 |
| PlannerServiceTest | MAX_VALUE_LEN=500 截断验证 | 超过 500 字符的 var value 被截断到 500 + "..." |
| AgenticExecutorTest | 同节点连续 revisit 10+ 次（每次 Planner 都返回同节点） | **运行正常**，不 abort（验证业务重走合法） |
| AgenticExecutorTest | 总 visit 数达到 50 次 | abort，errorMsg 含"超过最大访问次数"（MAX_VISITS 兜底仍生效） |
| AgenticExecutorTest | 单 visit 内 attempt 用尽 | visit 标记 FAILED，Planner 接管（retry 逻辑不变） |

### 7.2 手工验证（用户操作）

执行"自由跳转测试"工作流：
1. 在智能操作节点的 clarify 中输入"还有新需求：补充 YYY"
2. Hermes Agent 完成节点，final_response 应含"用户有新的需求：补充 YYY"
3. 观察后端日志：Planner prompt 应含上一节点 output 摘要 + 规则 3 新版本
4. 期望 Planner 决策为重访该智能操作节点
5. 第二次执行该节点处理"补充 YYY"
6. 最终到 output 节点结束

### 7.3 边界验证（可选）

构造一个 ReAct 风格工作流，强制 Planner 在同节点重走 15+ 次：
- 期望：运行正常完成，不触发任何 abort
- 验证：nodeVisitCounter 不再作为 abort 依据

---

## 8. 与原计划的差异（修订点对比）

| 维度 | 初版方案（已撤销） | 二版方案（已撤销） | **最终方案** |
|------|---------|---------|---------|
| 信号通道 | 新增 clarify Q&A 注入 Planner | 同左 | **删除**。Planner 看节点 output 即可 |
| WorkflowPause 表 | 加 `answer_text` 字段 | 同左 | **不动** |
| WorkflowController.resume | 写入 answer_text | 同左 | **不动** |
| Planner prompt 规则 3 | 改为"用户意图豁免" | 改为"基于理由判断" | 同二版 |
| MAX_VALUE_LEN | 200 不变 | 200 → 500 | 同二版 |
| MAX_SAME_NODE_REVISITS | 5 不变 | **5 → 10**（放宽） | **完全移除**该常量与 abort 逻辑 |
| retry vs revisit 语义区分 | 无 | 无 | **新增**：retry 计 attempt 内；revisit 不计入任何 abort 指标 |
| 新增"上一节点 output 摘要"段 | 无 | 新增（800 字符） | 同二版 |
| 死循环防护层 | L1 + L2 + MAX_SAME_NODE_REVISITS | 同左 | **L1（MAX_VISITS=50）+ L2（maxRetries）**；L3 无进展检测留作扩展 |
| 改动文件数 | 6 个 | 3 个 | **3 个**（PlannerService + AgenticExecutor + 对应测试） |

---

## 9. 后续扩展点（不在本次范围）

1. **节点声明 control 信号**：envelope.data 增加可选字段 `__control__`，节点 LLM 主动声明 `requestRevisit=true`，Planner 直接消费
2. **Planner reason 监控**：累计统计每次 Planner 决策的 reason，识别"业务重走"模式用于优化 prompt
3. **MAX_SAME_NODE_REVISITS 自适应**：按节点类型差异化（如 llm 节点容忍度高、skill 节点容忍度低）
4. **重走原因可视化**：前端 Visit 卡片展示 Planner 决策原因（已持久化在 decisionJson，前端可读）

---

## 10. 待用户确认

- **Q1**：MAX_VALUE_LEN 从 200 放宽到 **500**，是否同意？
- **Q2**：**完全移除** MAX_SAME_NODE_REVISITS 常量与 abort 逻辑（不再用同节点访问次数作为死循环指标），死循环仅靠 MAX_VISITS=50 总量兜底，是否同意？
- **Q3**：新增"上一节点 output 摘要"段（截断 800 字符），是否同意？
- **Q4**：规则 3 改写为"基于理由判断"版本，是否同意？
- **Q5**：本次完全不动 clarify 通道 / WorkflowPause 表 / 节点执行器，是否同意？
- **Q6**：L3 无进展检测（同节点连续 3 次访问且 currentVars hash 相同 → abort）作为后续扩展，本次不做，是否同意？

---

**等待用户确认是否按本最终方案推进实施。**
