Skip to content

Commit ec5ca41

Browse files
authored
feat: implement parallel tool execution (#776)
1 parent 522edb6 commit ec5ca41

7 files changed

Lines changed: 604 additions & 27 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# 并行 Tool 执行
2+
3+
**Date:** 2026-03-18
4+
5+
## Context
6+
7+
当前 `loop.ts` 中,当 LLM 返回一条包含多个 `tool_use` 的 assistant 消息时,所有工具调用是通过 `for...of` 循环**顺序执行**的。这意味着即使 LLM 在同一条消息中返回了多个 task(subagent)调用,第二个 task 也必须等待第一个完全执行完毕才能开始。
8+
9+
参考 Claude Code 的实现(通过逆向分析 `versions/2.1.29/cli.js` 得到),其核心机制是使用 `Promise.all()` 并行执行同一条消息中的所有 `tool_use` 块,从而实现真正的并行 subagent 执行以及所有只读工具的并行加速。
10+
11+
目标:将 Neovate Code 的工具执行模型从顺序改为并行,同时 UI 能同时展示多个 subagent 的实时进度。
12+
13+
## Discussion
14+
15+
### 探讨过的方案
16+
17+
**方案 A: 分组并行** — 将 task 类工具并行执行,其他工具顺序执行。改动集中在 `loop.ts`,风险可控,但灵活性有限。
18+
19+
**方案 B: 仅连续 task 并行** — 最小改动,识别连续的 task tool calls 并行执行,其他不变。UI 零改动。风险最低但收益有限。
20+
21+
**方案 C: 通用并行框架** — 所有只读工具都可并行,改动范围大,测试成本高。
22+
23+
**最终选择:全部 tool 并行执行**(参考 Claude Code 实现)— 在看到 Claude Code 的逆向分析后,决定直接采用 `Promise.all` 并行所有 tool_use 的做法,获得最佳体验。
24+
25+
### 关键决策
26+
27+
- **审批流程**:沿用现有逻辑,顺序进行审批(UI 一次只能展示一个 ApprovalModal)。审批完成后再并行执行。
28+
- **denial 处理**:被拒绝的 tool 只跳过该工具,其他已批准的工具继续并行执行。无 denyReason 的拒绝保持现有行为(终止所有后续工具)。
29+
- **UI 层**:零改动。`agentProgressMap``toolUseId` 索引,`Messages.tsx` 逐个渲染每个 tool pair,天然支持多 agent 并行展示。
30+
31+
### 风险评估
32+
33+
| 风险 | 严重程度 | 缓解措施 |
34+
|------|----------|---------|
35+
| 多个 bash 并行写文件冲突 || LLM 通常不会在同一批返回多个写操作 |
36+
| onToolResult 回调竞争 || 需检查实现确保无共享可变状态 |
37+
| 多个 write/edit 并行操作同一文件 || LLM 自行规避 |
38+
| 内存压力(多 agent 同时运行) || subagent 本身就是独立 session |
39+
40+
## Approach
41+
42+
`loop.ts` 中的顺序 `for...of` 工具执行循环改为两阶段模型:
43+
44+
1. **Phase 1 顺序审批**:逐个对 toolCalls 进行 approval 检查,收集 approved 和 denied 列表
45+
2. **Phase 2 并行执行**:对所有 approved 的 toolCalls 使用 `Promise.allSettled()` 并行执行
46+
3. **Phase 3 结果收集**:合并 approved 执行结果和 denied 错误结果,按原始顺序排列后写入 history
47+
48+
核心改动仅在 `src/loop.ts` 一个文件,约 50 行代码。
49+
50+
## Architecture
51+
52+
### 改动文件
53+
54+
| 文件 | 改动范围 | 说明 |
55+
|------|---------|------|
56+
| `src/loop.ts` | ~50 行 | 替换顺序循环为并行执行 |
57+
58+
### 不需要改动的文件
59+
60+
- `src/agent/executor.ts` — 每个 agent 已是独立 session,天然支持并行
61+
- `src/agent/agentManager.ts``executeTask` 是无状态的,直接并行调用即可
62+
- `src/tools/task.ts` — task tool 的 `execute` 函数本身不需要改
63+
- `src/ui/store.ts``agentProgressMap``toolUseId` 索引,天然支持多 agent 并行更新
64+
- `src/ui/AgentProgress/` — 天然支持多 agent 渲染
65+
- `src/messageBus.ts` — 事件系统异步解耦
66+
67+
### 执行流程
68+
69+
```
70+
LLM 返回: [text, task1, task2, read1, bash1]
71+
72+
Phase 1: 顺序审批
73+
approval(task1) → approved ✓
74+
approval(task2) → denied (with reason) → 跳过,继续
75+
approval(read1) → approved ✓
76+
approval(bash1) → approved ✓
77+
78+
Phase 2: 并行执行
79+
Promise.allSettled([
80+
execute(task1), // → agentProgressMap['task1-id'] 实时更新
81+
execute(read1), // → 读文件
82+
execute(bash1), // → 执行命令
83+
])
84+
85+
Phase 3: 结果收集
86+
toolResults = [
87+
task1 result (fulfilled),
88+
task2 result (denied error),
89+
read1 result (fulfilled),
90+
bash1 result (fulfilled),
91+
]
92+
→ 按原始顺序写入 history
93+
```
94+
95+
### 核心伪代码
96+
97+
```typescript
98+
// Phase 1: 顺序审批
99+
const approvedCalls: { toolCall, toolUse }[] = [];
100+
const deniedResults: ToolCallResult[] = [];
101+
let shouldBreak = false;
102+
103+
for (const toolCall of toolCalls) {
104+
let toolUse = buildToolUse(toolCall);
105+
if (opts.onToolUse) toolUse = await opts.onToolUse(toolUse);
106+
107+
const { approved, updatedParams, denyReason } = await doApproval(toolUse);
108+
109+
if (approved) {
110+
if (updatedParams) toolUse.params = { ...toolUse.params, ...updatedParams };
111+
approvedCalls.push({ toolCall, toolUse });
112+
} else {
113+
deniedResults.push(buildDeniedResult(toolUse, denyReason));
114+
if (!denyReason) {
115+
addDeniedResultsForRemainingTools();
116+
shouldBreak = true;
117+
break;
118+
}
119+
}
120+
}
121+
122+
// Phase 2: 并行执行所有已批准的工具
123+
if (!shouldBreak && approvedCalls.length > 0) {
124+
toolCallsCount += approvedCalls.length;
125+
const results = await Promise.allSettled(
126+
approvedCalls.map(async ({ toolUse }) => {
127+
let toolResult = await opts.tools.invoke(
128+
toolUse.name, JSON.stringify(toolUse.params), toolUse.callId,
129+
);
130+
if (opts.onToolResult) {
131+
toolResult = await opts.onToolResult(toolUse, toolResult, true);
132+
}
133+
return { toolCallId: toolUse.callId, toolName: toolUse.name,
134+
input: toolUse.params, result: toolResult };
135+
})
136+
);
137+
138+
for (const result of results) {
139+
if (result.status === 'fulfilled') {
140+
toolResults.push(result.value);
141+
} else {
142+
toolResults.push(buildErrorResult(result.reason));
143+
}
144+
}
145+
}
146+
147+
// Phase 3: 合并 denied 结果,按原始 toolCalls 顺序排列
148+
// turnsCount 一次性减去 approvedCalls.length
149+
turnsCount -= approvedCalls.length;
150+
```
151+
152+
### 边界情况
153+
154+
| 场景 | 处理方式 |
155+
|------|---------|
156+
| 单个 tool_use | 退化为单个 Promise,行为完全一致 |
157+
| 所有 tool 被拒绝 | 每个返回 denied error,无并行执行 |
158+
| 并行执行中 signal abort | 所有工具共享同一个 signal,同时中止 |
159+
| 一个工具失败另一个成功 | `allSettled` 不互相影响,各自返回 |
160+
| subagent 内部请求 tool approval | 通过 messageBus 独立请求,不影响其他并行工具 |
161+
162+
### 测试策略
163+
164+
- **单元测试**:mock tools,验证多个 toolCalls 是并行执行的(总执行时间应约等于最长那个工具的时间,而非总和)
165+
- **集成测试**:手动验证多个 Explore agent 并行执行时 UI 正确显示多个进度

0 commit comments

Comments
 (0)