# 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 增加前缀区分
