docs/plan/2026-06-02-tool-call-early-start-design.md
Rama main · réplica de lectura del repositorio original.
# 工具调用提前展示 & 参数流式推送 技术方案
> Note: The `bash` tool has been removed. `shell` is now the only command execution tool. See `2026-06-03-shell-unification-design.md`.
## 问题
LLM 流式输出工具调用时,`tool_call_start` 事件仅在 `onComplete` 回调中触发(即 id、name、arguments 全部输出完毕后)。对于大文件写入等场景,arguments 可能需要数秒甚至更久才能输出完成,期间前端无任何反馈。
## 目标
1. LLM 输出 toolCallId + functionName 后立即推送 `tool_call_start`,前端立即渲染 ToolCallCard
2. arguments 的后续增量通过新事件 `tool_call_args_delta` 实时推送到前端,ToolCallCard 实时更新参数内容
3. ToolCallCard 在参数未完成时展示 loading 态,完成后切换为正常态
## 现状数据流
```
LLM StreamChunk.Delta.toolCalls[]
↓
AgentLoop.mergeToolCall() ← 累积 id/name/arguments 碎片到 List<ChatRequest.ToolCall>
↓ (onComplete)
遍历 toolCalls → listener.onToolCallStart(tc) ← 此时 arguments 已完整
↓
WsStreamingEventListener.onToolCallStart() → send("tool_call_start", {tool_call_id, tool_name, arguments})
↓
前端 sessionStore.appendToolCallStart() → 解析 arguments JSON → 创建 ToolCall 对象 → 渲染 ToolCallCard
```
## 改造方案
### 一、后端改动
#### 1. AgentLoop.java — mergeToolCall 中检测首次可识别
**文件**: `backend/src/main/java/cn/etarch/mao/harness/core/AgentLoop.java`
修改 `mergeToolCall` 方法,在 id + name 首次同时具备时回调通知:
```java
private final Set<String> emittedEarlyStarts = new HashSet<>();
private void mergeToolCall(List<ChatRequest.ToolCall> existing, ChatRequest.ToolCall delta,
AgentEventListener listener) {
// ... 现有合并逻辑不变 ...
// 检测:合并后 id + name 首次同时具备
if (!delta.getId().isEmpty() && delta.getFunction() != null
&& delta.getFunction().getName() != null && !delta.getFunction().getName().isEmpty()
&& emittedEarlyStarts.add(delta.getId())) {
// 首次可识别,立即推送(arguments 可能还是片段)
listener.onToolCallStart(delta);
}
}
```
修改 `onComplete` 回调,跳过已提前推送的 tool call:
```java
// onComplete 中
if (!toolCalls.isEmpty()) {
for (ChatRequest.ToolCall tc : toolCalls) {
if (!emittedEarlyStarts.contains(tc.getId())) {
listener.onToolCallStart(tc); // 仅推送未提前推送过的
}
}
context.setPendingToolCalls(toolCalls);
}
```
方法签名变更:`mergeToolCall` 需要额外接收 `AgentEventListener listener` 参数(从 `onChunk` 闭包中传入)。`emittedEarlyStarts` 在每轮循环开始时清空。
#### 2. AgentEventListener.java — 新增 onToolCallArgsDelta
**文件**: `backend/src/main/java/cn/etarch/mao/harness/core/AgentEventListener.java`
```java
/** 工具调用参数增量更新(LLM 流式输出 arguments 碎片时) */
default void onToolCallArgsDelta(String toolCallId, String argsDelta) {}
```
#### 3. AgentLoop.java — onChunk 中触发参数增量事件
在 `onChunk` 回调中,每次 mergeToolCall 后,对该 tool call 已知的 arguments 增量触发事件:
```java
if (delta.getToolCalls() != null) {
for (ChatRequest.ToolCall tc : delta.getToolCalls()) {
String argsBefore = findArguments(toolCalls, tc); // 合并前的 arguments
mergeToolCall(toolCalls, tc, listener);
String argsAfter = findArguments(toolCalls, tc); // 合并后的 arguments
if (argsAfter != null && argsAfter.length() > (argsBefore != null ? argsBefore.length() : 0)) {
String delta_args = argsAfter.substring(argsBefore != null ? argsBefore.length() : 0);
listener.onToolCallArgsDelta(findId(toolCalls, tc), delta_args);
}
}
}
```
这里需要两个辅助方法从 `toolCalls` 列表中定位到已合并的 tool call 对象的 id 和当前 arguments 值。由于 `mergeToolCall` 是按 id 匹配追加的,可以根据 delta 的 index 定位。
**简化方案**:不比较前后差异,直接在每次 mergeToolCall 后推送完整的当前 arguments 值。前端用全量覆盖而非增量拼接,逻辑更简单:
```java
if (delta.getToolCalls() != null) {
for (ChatRequest.ToolCall tc : delta.getToolCalls()) {
mergeToolCall(toolCalls, tc, listener);
// 推送当前已累积的完整 arguments
ChatRequest.ToolCall merged = toolCalls.get(toolCalls.size() - 1);
if (merged.getId() != null && merged.getFunction() != null) {
String currentArgs = merged.getFunction().getArguments() != null
? merged.getFunction().getArguments() : "";
listener.onToolCallArgsDelta(merged.getId(), currentArgs);
}
}
}
```
> **选择全量覆盖**:每次推送当前完整的 arguments 字符串,前端直接赋值而非拼接。好处是避免增量计算的复杂性,坏处是 WS 消息体略大。但 arguments 本身是字符串(如 JSON),且只在 LLM 流式输出期间发送,频率可控。
#### 4. WsStreamingEventListener.java — 实现新事件
**文件**: `backend/src/main/java/cn/etarch/mao/session/ws/WsStreamingEventListener.java`
```java
@Override
public void onToolCallArgsDelta(String toolCallId, String argsDelta) {
send("tool_call_args_delta", Map.of(
"tool_call_id", toolCallId,
"arguments", argsDelta
));
}
```
#### 5. WS 事件契约变更
| 事件 | 时机 | 数据 |
|------|------|------|
| `tool_call_start` | id + name 首次可识别时(arguments 可能不完整) | `{tool_call_id, tool_name, arguments}` |
| `tool_call_args_delta` | **新增**,每次 arguments 累积更新时 | `{tool_call_id, arguments}`(全量) |
| `tool_call_result` | 工具执行完毕(不变) | `{tool_call_id, result, status, summary}` |
---
### 二、前端改动
#### 1. types/chat.ts — ToolCall 类型增加 argsStreaming
**文件**: `desktop/src/types/chat.ts`
```typescript
export interface ToolCall {
id: string
name: string
input?: Record<string, unknown>
result?: string
summary?: string
status: 'pending' | 'running' | 'success' | 'error'
isExpanded: boolean
argsStreaming: boolean // 新增:arguments 是否仍在流式传输中
}
```
#### 2. chatMessage.ts — appendToolCallStart 标记 argsStreaming
**文件**: `desktop/src/utils/chatMessage.ts`
`appendToolCallStart` 创建 ToolCall 时设置 `argsStreaming: true`。
#### 3. sessionStore — 新增 updateToolCallArgs 方法
**文件**: `desktop/src/stores/session.ts`
```typescript
function updateToolCallArgs(sessionId: string, data: { tool_call_id: string; arguments: string }) {
const sid = String(sessionId)
const list = sessionMessages.value.get(sid)
if (!list || list.length === 0) return
const lastMsg = list[list.length - 1]
if (lastMsg.toolCalls) {
const call = lastMsg.toolCalls.find(c => c.id === data.tool_call_id)
if (call) {
try { call.input = JSON.parse(data.arguments) } catch { call.input = {} }
sessionMessages.value.set(sid, [...list])
}
}
}
```
#### 4. sessionStore — updateToolCallResult 中关闭 argsStreaming
在 `updateToolCallResult` 中增加 `call.argsStreaming = false`。
#### 5. useStreamWS.ts — 路由新事件
**文件**: `desktop/src/composables/useStreamWS.ts`
```typescript
case 'tool_call_args_delta':
if (sessionId) sessionStore.updateToolCallArgs(sessionId, data)
break
```
#### 6. ToolCallCard.vue — 适配渐进式参数
**文件**: `desktop/src/components/chat/ToolCallCard.vue`
- `displaySummary`:当 `argsStreaming` 为 true 且尚无 preview 时,显示 `"工具名 · 参数加载中..."`
- `commandText`:直接从 `toolCall.input.command` 取值,由于 input 会随 args delta 实时更新,command 会自然渐进填充。无需特殊处理
- `hasExpandableBody`:增加对 `argsStreaming` 的判断,streaming 时也算有可展开内容
- 新增:在 `tool-command` 区域,当 `argsStreaming` 为 true 且 `commandText` 为空时,显示一个 mini loading 动画
---
### 三、时序图
```
LLM 流式输出
│
├─ Delta(tool_calls=[{id:"call_1", function:{name:"write_file", arguments:""}}])
│ → mergeToolCall: id+name 首次可识别
│ → emit onToolCallStart("call_1", "write_file", "") ← tool_call_start
│ → emit onToolCallArgsDelta("call_1", "") ← tool_call_args_delta
│
├─ Delta(tool_calls=[{function:{arguments:'{"path":"game.ht"}}])
│ → mergeToolCall: arguments 累积
│ → emit onToolCallArgsDelta("call_1", '{"path":"game.ht"')
│
├─ Delta(tool_calls=[{function:{arguments:'ml","content":"<!Do'}}])
│ → mergeToolCall: arguments 累积
│ → emit onToolCallArgsDelta("call_1", '{"path":"game.html","content":"<!Do')
│
│ ... (更多 delta) ...
│
├─ Delta(tool_calls=[{function:{arguments:'...完整 JSON...'}}])
│ → emit onToolCallArgsDelta("call_1", '{"path":"game.html","content":"<!DOCTYPE>...完整内容..."}')
│
└─ onComplete
→ emittedEarlyStarts 已包含 "call_1",跳过重复推送
→ setPendingToolCalls → 执行工具
前端时序:
tool_call_start → 创建 ToolCallCard (argsStreaming=true, input 为空/不完整)
tool_call_args_delta × N → 实时更新 ToolCallCard 的 input (命令渐进显示)
tool_call_result → 更新状态为 success/error, argsStreaming=false
```
---
### 四、改动文件清单
| 文件 | 改动 |
|------|------|
| `AgentEventListener.java` | 新增 `onToolCallArgsDelta` 默认方法 |
| `AgentLoop.java` | `mergeToolCall` 检测首次可识别并回调;`onChunk` 中推送 args delta;`onComplete` 跳过已推送的;新增 `emittedEarlyStarts` 集合 |
| `WsStreamingEventListener.java` | 实现 `onToolCallArgsDelta`,发送 `tool_call_args_delta` 事件 |
| `types/chat.ts` | ToolCall 增加 `argsStreaming` 字段 |
| `chatMessage.ts` | `appendToolCallStart` 设置 `argsStreaming: true` |
| `session.ts` | 新增 `updateToolCallArgs` 方法;`updateToolCallResult` 中关闭 `argsStreaming` |
| `useStreamWS.ts` | 路由 `tool_call_args_delta` 事件 |
| `ToolCallCard.vue` | 适配渐进式参数展示,streaming 时显示 loading |
---
### 五、注意事项
1. **arguments 全量推送 vs 增量拼接**:方案选择全量推送(每次发完整的 arguments 字符串),前端直接 `JSON.parse` 赋值。相比增量拼接更简单可靠,避免了前端拼接字符串再解析的开销。arguments 通常在 10KB 以内(大文件写入的 content 除外),WS 传输开销可接受。
2. **大文件 write_file 的 arguments**:LLM 输出大文件内容时,arguments 可能非常大(数十 KB)。全量推送意味着每个 delta 都发送当前累积的完整字符串。如果性能成为瓶颈,可后续优化为仅推送新增部分(前端拼接后再 parse)。当前方案优先保证正确性和简单性。
3. **与 thinking 事件的关系**:`onThinkingEnd` 在首次收到 content delta 或 onComplete 时触发。tool call 的 delta 不触发 `onThinkingEnd`(因为 `delta.getContent()` 为 null),所以 thinking 状态和工具参数流式传输是互斥的,不会冲突。
4. **与 approval 流程的关系**:审批发生在执行阶段(`LocalToolExecutor.sendToolRequest` → Electron `handleBashFromWebSocket` → `requestToolApproval`),使用的是最终完整的 arguments。`tool_call_start` 提前推送不影响审批时机和审批内容。
5. **emittedEarlyStarts 清理时机**:在 `AgentLoop.execute()` 的每轮 while 循环开头清空,避免跨轮次污染。