# 节点上下文按需获取方案

> 状态：设计草案
> 关联：[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 时再做。**
