fix(runtime): 工具调用 P0 修复 — after_tool_call 接入 + stream_errored 工具抢救
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

P0-1: after_tool_call 中间件从未被调用
- 流式模式(run_streaming)和非流式模式(run)均添加 middleware_chain.run_after_tool_call()
- ToolErrorMiddleware 错误计数恢复逻辑现在生效
- ToolOutputGuardMiddleware 敏感信息检测现在生效

P0-2: stream_errored 跳过所有工具执行
- 新增 completed_tool_ids 跟踪哪些工具已收到完整 ToolUseEnd
- 流式错误时区分完整工具和不完整工具
- 完整工具照常执行(产物创建等不受影响)
- 不完整工具发送取消 ToolEnd 事件(前端不再卡"执行中")
- 工具执行后若 stream_errored,break outer 阻止无效 LLM 循环

参考文档:
- docs/references/zclaw-toolcall-issues.md (10项问题分析)
- docs/references/deerflow-toolcall-reference.md (DeerFlow工具调用完整参考)
This commit is contained in:
iven
2026-04-24 12:20:14 +08:00
parent 4c31471cd6
commit c12b64150b
5 changed files with 412 additions and 4 deletions

View File

@@ -552,6 +552,20 @@ impl AgentLoop {
sorted_indices.sort();
for idx in sorted_indices {
let (id, name, result) = results.remove(&idx).unwrap();
// Run after_tool_call middleware (error counting, output guard, etc.)
let mut mw_ctx = middleware::MiddlewareContext {
agent_id: self.agent_id.clone(),
session_id: session_id.clone(),
user_input: String::new(),
system_prompt: enhanced_prompt.clone(),
messages: messages.clone(),
response_content: Vec::new(),
input_tokens: total_input_tokens,
output_tokens: total_output_tokens,
};
if let Err(e) = self.middleware_chain.run_after_tool_call(&mut mw_ctx, &name, &result).await {
tracing::warn!("[AgentLoop] after_tool_call middleware failed for '{}': {}", name, e);
}
messages.push(Message::tool_result(&id, zclaw_types::ToolId::new(&name), result, false));
}
}
@@ -706,6 +720,7 @@ impl AgentLoop {
let mut stream = driver.stream(request);
let mut pending_tool_calls: Vec<(String, String, serde_json::Value)> = Vec::new();
let mut completed_tool_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut iteration_text = String::new();
let mut reasoning_text = String::new(); // Track reasoning separately for API requirement
@@ -762,6 +777,7 @@ impl AgentLoop {
// Update with final parsed input and emit ToolStart event
if let Some(tool) = pending_tool_calls.iter_mut().find(|(tid, _, _)| tid == id) {
tool.2 = input.clone();
completed_tool_ids.insert(id.clone());
if let Err(e) = tx.send(LoopEvent::ToolStart { name: tool.1.clone(), input: input.clone() }).await {
tracing::warn!("[AgentLoop] Failed to send ToolStart event: {}", e);
}
@@ -869,11 +885,27 @@ impl AgentLoop {
break 'outer;
}
// Skip tool processing if stream errored or timed out
// Handle stream errors — execute complete tool calls, cancel incomplete ones
if stream_errored {
tracing::debug!("[AgentLoop] Stream errored, skipping tool processing and breaking");
// Cancel incomplete tools (ToolStart sent but ToolUseEnd not received)
let incomplete: Vec<_> = pending_tool_calls.iter()
.filter(|(id, _, _)| !completed_tool_ids.contains(id))
.collect();
for (_, name, _) in &incomplete {
tracing::warn!("[AgentLoop] Cancelling incomplete tool '{}' due to stream error", name);
let error_output = serde_json::json!({ "error": "流式响应中断,工具调用未完成" });
if let Err(e) = tx.send(LoopEvent::ToolEnd { name: name.clone(), output: error_output }).await {
tracing::warn!("[AgentLoop] Failed to send cancellation ToolEnd event: {}", e);
}
}
// Retain only complete tools for execution
pending_tool_calls.retain(|(id, _, _)| completed_tool_ids.contains(id));
if pending_tool_calls.is_empty() {
tracing::debug!("[AgentLoop] Stream errored with no complete tool calls, breaking");
break 'outer;
}
tracing::info!("[AgentLoop] Stream errored but executing {} complete tool calls", pending_tool_calls.len());
}
tracing::debug!("[AgentLoop] Processing {} tool calls (reasoning: {} chars)", pending_tool_calls.len(), reasoning_text.len());
@@ -1059,6 +1091,23 @@ impl AgentLoop {
break 'outer;
}
// Run after_tool_call middleware chain (error counting, output guard, etc.)
{
let mut mw_ctx = middleware::MiddlewareContext {
agent_id: agent_id.clone(),
session_id: session_id_clone.clone(),
user_input: String::new(),
system_prompt: enhanced_prompt.clone(),
messages: messages.clone(),
response_content: Vec::new(),
input_tokens: total_input_tokens,
output_tokens: total_output_tokens,
};
if let Err(e) = middleware_chain.run_after_tool_call(&mut mw_ctx, &name, &result).await {
tracing::warn!("[AgentLoop] after_tool_call middleware failed for '{}': {}", name, e);
}
}
// Add tool result to message history
tracing::debug!("[AgentLoop] Adding tool_result to history: id={}, name={}, is_error={}", id, name, is_error);
messages.push(Message::tool_result(
@@ -1070,6 +1119,11 @@ impl AgentLoop {
}
tracing::debug!("[AgentLoop] Continuing to next iteration for LLM to process tool results");
// If stream errored, we executed complete tools but cannot continue the LLM loop
if stream_errored {
tracing::info!("[AgentLoop] Stream was errored — executed salvageable tools, now breaking");
break 'outer;
}
// Continue loop - next iteration will call LLM with tool results
}
});

View File

@@ -0,0 +1,212 @@
# DeerFlow 工具调用系统参考文档
> 调研 DeerFlow 的工具调用完整流程,为 ZCLAW 工具调用问题排查提供参考。
> 分析日期2026-04-24
---
## 一、端到端数据流
```
用户消息
→ FastAPI Gateway (/api/threads/{id}/runs/stream)
→ services.start_run() → asyncio.create_task(run_agent(...))
→ LangGraph Agent Graph (create_agent)
→ LLM Model (ChatOpenAI / Claude)
→ AIMessage (含 tool_calls 列表)
→ 14 层 Middleware 链处理
→ ToolNode (LangGraph 内置, 按 tool_call.name 路由)
→ ToolMessage (执行结果)
→ 再次调用 LLM (带着 ToolMessage 继续)
→ StreamBridge.publish() → asyncio.Queue
→ SSE → 前端 useStream hook
→ React 组件渲染
```
## 二、工具注册与执行
### 2.1 注册入口
**文件**: `G:/deerflow/backend/packages/harness/deerflow/tools/tools.py``get_available_tools()`
工具来自四个来源:
| 来源 | 加载方式 | 示例 |
|------|----------|------|
| Config 工具 | YAML 配置 + 反射导入 (`module:variable`) | `deerflow.sandbox.tools:bash_tool` |
| Builtin 工具 | 硬编码导入 | `present_file_tool`, `ask_clarification_tool` |
| MCP 工具 | `MultiServerMCPClient` 从 MCP 服务器缓存获取 | 第三方 MCP 工具 |
| ACP 工具 | `build_invoke_acp_agent_tool()` 动态构建 | 外部 agent 调用 |
### 2.2 Sandbox 工具清单
**文件**: `G:/deerflow/backend/packages/harness/deerflow/sandbox/tools.py`
| 工具名 | 功能 |
|--------|------|
| `bash` | 沙箱中执行命令 |
| `ls` | 列出目录 |
| `read_file` | 读取文件 |
| `write_file` | 写入文件(触发产物面板自动打开) |
| `str_replace` | 字符串替换(触发产物面板自动打开) |
### 2.3 Builtin 工具
**文件**: `G:/deerflow/backend/packages/harness/deerflow/tools/builtins/`
| 工具 | 功能 |
|------|------|
| `ask_clarification` | 向用户提问澄清(中断执行等待回复) |
| `present_file` | 展示文件给用户(触发产物卡片) |
| `setup_agent` | 自定义 agent 创建 |
| `task_tool` | 子 agent 任务委派 |
| `view_image` | 图片查看(仅视觉模型) |
| `tool_search` | 延迟工具搜索MCP 工具按需暴露) |
## 三、中间件链14 层)
**文件**: `G:/deerflow/backend/packages/harness/deerflow/agents/lead_agent/agent.py``_build_middlewares()`
与工具调用相关的关键中间件:
### 3.1 DanglingToolCallMiddleware
**文件**: `dangling_tool_call_middleware.py`
`wrap_model_call` 中检测消息历史中缺失 ToolMessage 的 AIMessage自动注入占位 ToolMessage
```python
ToolMessage(
content="[Tool call was interrupted and did not return a result.]",
tool_call_id=tc_id,
name=tc.get("name", "unknown"),
status="error",
)
```
### 3.2 ToolErrorHandlingMiddleware
**文件**: `tool_error_handling_middleware.py`
`wrap_tool_call` 中捕获工具执行异常,转换为错误 ToolMessage 而非让整个 run 崩溃。
### 3.3 LoopDetectionMiddleware
**文件**: `loop_detection_middleware.py`
`after_model` 中检测重复工具调用:
- 阈值 3 次 → 注入警告 HumanMessage
- 阈值 5 次 → 直接清空 tool_calls强制 LLM 产出文本回答
### 3.4 DeferredToolFilterMiddleware
**文件**: `deferred_tool_filter_middleware.py`
`wrap_model_call` 中过滤延迟注册的 MCP 工具 schema仅在 LLM 通过 `tool_search` 发现后才暴露。
### 3.5 ClarificationMiddleware
拦截 `ask_clarification` 工具调用,中断执行等待用户回复。
### 3.6 SubagentLimitMiddleware
截断过多的并行子 agent 调用。
## 四、工具结果回传
### 4.1 格式
LangChain 的 `ToolMessage`,包含:
- `content`: 执行结果文本
- `tool_call_id`: 匹配 AIMessage 中的 tool_call ID
- `name`: 工具名称
- `status`: `"error"` 或省略
### 4.2 特殊工具
`present_file_tool` 返回 `Command` 而非纯字符串,同时更新 `artifacts``messages` 两个 state channel。
## 五、前端工具调用展示
### 5.1 消息分组
**文件**: `G:/deerflow/frontend/src/core/messages/utils.ts``groupMessages()`
| 分组类型 | 触发条件 | 展示 |
|----------|----------|------|
| `assistant:processing` | AI 消息含 tool_calls 或 reasoning | MessageGroup (折叠) |
| `assistant` | AI 消息有文本无 tool_calls | MessageListItem (气泡) |
| `assistant:present-files` | 含 present_files tool call | ArtifactFileList |
| `assistant:clarification` | ask_clarification 结果 | MarkdownContent |
| `assistant:subagent` | 含 task tool call | SubtaskCard |
### 5.2 工具状态推断
前端**没有显式状态机**。通过消息序列推断:
- AI 消息含 tool_calls 但无对应 ToolMessage → 正在执行
- ToolMessage 出现 → 执行完成
- `assistant:processing` 组由 `ChainOfThought` 折叠组件包裹
### 5.3 工具调用 UI
**文件**: `message-group.tsx` 第 186-423 行
按工具名渲染不同图标和内容:
- `bash` → 终端图标 + 命令代码块
- `read_file`/`write_file`/`str_replace` → 文件图标 + 路径链接(点击打开产物面板)
- `web_search` → 搜索图标 + 结果链接
- 默认 → 扳手图标 + 工具名
## 六、流式处理中的工具调用
### 6.1 架构
```
agent.astream(stream_mode=["values"])
→ StreamBridge (asyncio.Queue per run, maxsize=256)
→ sse_consumer() → SSE frames → 前端
```
### 6.2 关键特征
- 工具调用**不中断**流。LangGraph 自动在 agent_node 和 tool_node 之间路由
- 每次状态变更产出完整的 `values` 快照,前端通过 `seen_ids` 去重
- 15 秒心跳包保持 SSE 连接
### 6.3 前端看到的事件序列
1. `values` 事件: 含 `tool_calls` 的 AIMessage
2. `values` 事件: ToolMessage工具结果
3. `values` 事件: LLM 基于工具结果的最终回答
整个过程连续,不中断 SSE 连接。
## 七、与 ZCLAW 对比(工具调用)
| 维度 | DeerFlow | ZCLAW |
|------|----------|-------|
| 框架 | LangGraph (graph-based) | 自研 loop_runner (循环) |
| 工具生命周期 | LangGraph ToolNode 自动管理 | 手动 ToolRegistry + loop_runner |
| after_tool_call 中间件 | ✅ wrap_tool_call 钩子完整 | ❌ 流式和非流式模式均未调用 |
| 并行工具执行 | LangGraph 自动处理 | 非流式有 JoinSet流式全串行 |
| 悬挂修复 | DanglingToolCallMiddleware | DanglingToolMiddleware (有) |
| 错误恢复 | ToolErrorHandlingMiddleware (异常→ToolMessage) | ToolErrorMiddleware (计数器) |
| 循环检测 | LoopDetectionMiddleware (3次警告/5次强停) | LoopGuardMiddleware (有) |
| 前端状态 | 消息序列推断 | 显式 ToolCallStep 状态机 |
| MCP 工具 | 延迟注册 + tool_search 按需暴露 | 全量注册 |
## 八、关键文件索引
| 功能 | DeerFlow 文件 |
|------|-------------|
| Agent 工厂 | `backend/packages/harness/deerflow/agents/lead_agent/agent.py` |
| 中间件组装 | `backend/packages/harness/deerflow/agents/factory.py` |
| 工具注册 | `backend/packages/harness/deerflow/tools/tools.py` |
| Sandbox 工具 | `backend/packages/harness/deerflow/sandbox/tools.py` |
| Builtin 工具 | `backend/packages/harness/deerflow/tools/builtins/` |
| 错误处理中间件 | `agents/middlewares/tool_error_handling_middleware.py` |
| 悬挂修复中间件 | `agents/middlewares/dangling_tool_call_middleware.py` |
| 循环检测中间件 | `agents/middlewares/loop_detection_middleware.py` |
| 延迟过滤中间件 | `agents/middlewares/deferred_tool_filter_middleware.py` |
| 流式 Bridge | `runtime/stream_bridge/memory.py` |
| 前端消息分组 | `frontend/src/core/messages/utils.ts` |
| 前端工具调用组件 | `frontend/src/components/workspace/messages/message-group.tsx` |

View File

@@ -0,0 +1,133 @@
# ZCLAW 工具调用问题分析
> 对比 DeerFlow 工具调用系统,排查 ZCLAW 工具调用问题。
> 分析日期2026-04-24
> 更新日期2026-04-24P0+P0-stream_errored 已修复)
---
## 一、发现的问题
### P0: `after_tool_call` 中间件从未被调用 — ✅ 已修复 (2026-04-24)
**文件**: `crates/zclaw-runtime/src/loop_runner.rs`
`run()`(非流式,第 400-558 行)和 `run_streaming`(流式,第 893-1070 行)中,工具执行后直接 push `Message::tool_result` 到消息历史,**没有调用 `middleware_chain.run_after_tool_call()`**。
**影响**:
- `ToolErrorMiddleware.after_tool_call` 的错误计数和恢复消息逻辑不生效
- `ToolOutputGuardMiddleware.after_tool_call` 的敏感信息检测不生效
- 工具错误只能靠工具自身的错误返回传递,中间件层的防护形同虚设
**DeerFlow 对比**: `ToolErrorHandlingMiddleware` 通过 `wrap_tool_call` 钩子完整包裹每次工具执行。
### P0: `stream_errored` 跳过所有工具执行 — ✅ 已修复 (2026-04-24)
**文件**: `crates/zclaw-runtime/src/loop_runner.rs` 第 872-876 行
流式模式中,当 LLM 流出现任何错误网络超时、API 错误、驱动错误)时,`stream_errored = true`,然后 `break 'outer` 直接退出循环,**跳过所有已解析的工具调用**。
**影响**:
- ToolStart 事件已发送给前端(用户看到"执行中"按钮),但工具从未实际执行
- ToolEnd 事件永远不会发送 → 前端工具状态卡在"执行中"
- 已完整接收ToolUseEnd的工具调用也被丢弃
**修复**: 区分完整工具(收到 ToolUseEnd和不完整工具仅收到 ToolUseStart/Delta。完整工具照常执行不完整工具发送取消 ToolEnd 事件。
### P1: 流式模式工具全串行
**文件**: `loop_runner.rs` 第 893-1070 行
非流式模式有 `JoinSet` + `Semaphore(3)` 并行执行 ReadOnly 工具,但流式模式用简单 `for` 循环串行执行所有工具。
**影响**: 多工具调用时延迟显著增加。
### P2: OpenAI 驱动工具参数静默替换
**文件**: `crates/zclaw-runtime/src/driver/openai.rs` 第 222-228 行
```rust
let parsed_args = if args.is_empty() {
serde_json::json!({})
} else {
serde_json::from_str(args).unwrap_or_else(|e| {
tracing::warn!("Failed to parse tool args '{}': {}", args, e);
serde_json::json!({})
})
};
```
JSON 解析失败时静默替换为 `{}`,结合 loop_runner.rs 的空参数处理(第 412-423 行),会注入 `_fallback_query` 替代实际参数。
### P2: ToolOutputGuard 过于激进
**文件**: `crates/zclaw-runtime/src/middleware/tool_output_guard.rs` 第 109 行
使用 `to_lowercase()` 匹配敏感模式,合法内容中包含 "password"、"system:" 等字符串会被误拦。
### P2: ToolErrorMiddleware 失败计数器是全局的
**文件**: `crates/zclaw-runtime/src/middleware/tool_error.rs` 第 27 行
`consecutive_failures: AtomicU32` 是结构体字段,所有 session 共享。高并发下 A session 失败 2 次 + B session 失败 1 次就会触发 AbortLoop阈值 3
### P3: Gateway 客户端 onTool 回调语义不一致
**文件**: `desktop/src/lib/gateway-client.ts` 第 698-707 行
`tool_call``tool_result` 两个 case 共用 `onTool` 回调,但参数约定不同,调用者必须通过 `output` 是否为空判断 start/end。
---
## 二、根因分析
工具调用问题最常见的故障模式:
1. **LLM 返回的 tool_call 参数格式错误** → OpenAI 驱动静默替换为 `{}` → 工具以空参数执行 → 结果不符合预期
2. **工具执行异常** → after_tool_call 中间件未调用 → 错误未格式化 → LLM 收到原始错误信息无法恢复
3. **流被中断后重连** → DanglingToolMiddleware 修复悬挂 → 但如果修复逻辑本身有 bug如重复修补会导致消息膨胀
## 三、修复建议
### 修复 1: 在 loop_runner 中调用 after_tool_call
**优先级**: P0
**影响文件**: `loop_runner.rs`
在非流式模式的工具执行循环中(约第 530 行),工具执行后调用:
```rust
let after_result = middleware_chain.run_after_tool_call(
&name, &input_json, &output_str, &mut ctx
).await;
```
在流式模式的工具执行后(约第 1020 行),同样调用。
### 修复 2: 将 ToolErrorMiddleware 计数器改为 per-session
**优先级**: P2
**影响文件**: `middleware/tool_error.rs`
使用 `HashMap<String, u32>` 以 session_id 为 key 存储计数。
### 修复 3: ToolOutputGuard 改为精确匹配
**优先级**: P2
**影响文件**: `middleware/tool_output_guard.rs`
只在检测到独立的密钥值时触发(如 `sk-[48字符]`),而非单词级匹配。
---
## 四、关键文件
| 文件 | 作用 |
|------|------|
| `crates/zclaw-runtime/src/loop_runner.rs` | 主循环,工具调度 |
| `crates/zclaw-runtime/src/tool.rs` | ToolRegistry + Tool trait |
| `crates/zclaw-runtime/src/middleware/tool_error.rs` | 工具错误处理 |
| `crates/zclaw-runtime/src/middleware/tool_output_guard.rs` | 输出安全检查 |
| `crates/zclaw-runtime/src/middleware/dangling_tool.rs` | 断裂工具修复 |
| `crates/zclaw-runtime/src/driver/openai.rs` | OpenAI 兼容驱动 |
| `desktop/src/lib/gateway-client.ts` | 前端通信客户端 |
| `desktop/src/store/chat/streamStore.ts` | 前端流式处理 |

View File

@@ -132,6 +132,8 @@ onComplete → createCompleteHandler
| 问题 | 状态 | 说明 |
|------|------|------|
| after_tool_call 中间件未调用 | ✅ 已修复 (04-24) | 流式+非流式均添加调用ToolErrorMiddleware/ToolOutputGuard 现在生效 |
| stream_errored 跳过所有工具 | ✅ 已修复 (04-24) | 完整工具照常执行,不完整工具发送取消事件 |
| B-CHAT-07 混合域截断 | P2 Open | 跨域消息时可能截断上下文 |
| SSE Token 统计为 0 | ✅ 已修复 | SseUsageCapture stream_done flag |
| Tauri invoke 参数名 | ✅ 已修复 (f6c5dd2) | camelCase 格式 |
@@ -146,6 +148,7 @@ onComplete → createCompleteHandler
| 日期 | 变更 |
|------|------|
| 04-24 | 工具调用 P0 修复: after_tool_call 中间件接入(流式+非流式) + stream_errored 工具抢救(完整工具执行+不完整工具取消) |
| 04-24 | 产物系统优化: MarkdownRenderer 提取共享 + ArtifactPanel react-markdown 渲染 + 文件选择器下拉 + 数据源扩展(file_write/str_replace 两路径) + artifactStore IndexedDB 持久化 |
| 04-23 | 建议 prefetch: sendMessage 时启动 context 预取,流结束后立即消费,不等 memory extraction |
| 04-23 | 建议 prompt 重写: 1深入追问+1实用行动+1管家关怀上下文窗口 6→20 条 |

View File

@@ -1,6 +1,6 @@
---
title: 变更日志
updated: 2026-04-22
updated: 2026-04-24
status: active
tags: [log, history]
---
@@ -9,6 +9,12 @@ tags: [log, history]
> Append-only 操作记录。格式: `## [日期] 类型 | 描述`
## [2026-04-24] fix(runtime) | 工具调用两个 P0 修复
- **P0: after_tool_call 中间件从未调用**: 流式+非流式模式均添加 `middleware_chain.run_after_tool_call()` 调用ToolErrorMiddleware 和 ToolOutputGuardMiddleware 的 after 逻辑现在生效
- **P0: stream_errored 跳过所有工具**: 流式模式中 `stream_errored` 不再 `break 'outer`改为区分完整工具ToolUseEnd 已接收)和不完整工具;完整工具照常执行,不完整工具发送取消 ToolEnd 事件
- **影响文件**: `loop_runner.rs`
- **测试**: 91 tests PASS, 0 cargo warnings
## [2026-04-24] feat(artifact) | 产物系统优化完善
- **MarkdownRenderer**: 从 StreamingText 提取共享 Markdown 渲染组件react-markdown + remark-gfmArtifactPanel 复用
- **ArtifactPanel**: 替换手写 30 行 MarkdownPreview → 完整 GFM 渲染(表格/代码块/列表/引用);添加文件选择器下拉菜单