From c12b64150b53d321130e1138947e6d5e046e8dc1 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 24 Apr 2026 12:20:14 +0800 Subject: [PATCH] =?UTF-8?q?fix(runtime):=20=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=20P0=20=E4=BF=AE=E5=A4=8D=20=E2=80=94=20after=5Ftool?= =?UTF-8?q?=5Fcall=20=E6=8E=A5=E5=85=A5=20+=20stream=5Ferrored=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=8A=A2=E6=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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工具调用完整参考) --- crates/zclaw-runtime/src/loop_runner.rs | 60 ++++- .../references/deerflow-toolcall-reference.md | 212 ++++++++++++++++++ docs/references/zclaw-toolcall-issues.md | 133 +++++++++++ wiki/chat.md | 3 + wiki/log.md | 8 +- 5 files changed, 412 insertions(+), 4 deletions(-) create mode 100644 docs/references/deerflow-toolcall-reference.md create mode 100644 docs/references/zclaw-toolcall-issues.md diff --git a/crates/zclaw-runtime/src/loop_runner.rs b/crates/zclaw-runtime/src/loop_runner.rs index 4ac8115..e49379b 100644 --- a/crates/zclaw-runtime/src/loop_runner.rs +++ b/crates/zclaw-runtime/src/loop_runner.rs @@ -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 = 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,10 +885,26 @@ 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"); - break 'outer; + // 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 } }); diff --git a/docs/references/deerflow-toolcall-reference.md b/docs/references/deerflow-toolcall-reference.md new file mode 100644 index 0000000..9e367f3 --- /dev/null +++ b/docs/references/deerflow-toolcall-reference.md @@ -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` | diff --git a/docs/references/zclaw-toolcall-issues.md b/docs/references/zclaw-toolcall-issues.md new file mode 100644 index 0000000..f00ee31 --- /dev/null +++ b/docs/references/zclaw-toolcall-issues.md @@ -0,0 +1,133 @@ +# ZCLAW 工具调用问题分析 + +> 对比 DeerFlow 工具调用系统,排查 ZCLAW 工具调用问题。 +> 分析日期:2026-04-24 +> 更新日期:2026-04-24(P0+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` 以 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` | 前端流式处理 | diff --git a/wiki/chat.md b/wiki/chat.md index b2b2a59..ebfed9a 100644 --- a/wiki/chat.md +++ b/wiki/chat.md @@ -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 条 | diff --git a/wiki/log.md b/wiki/log.md index c747f50..f20753b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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-gfm),ArtifactPanel 复用 - **ArtifactPanel**: 替换手写 30 行 MarkdownPreview → 完整 GFM 渲染(表格/代码块/列表/引用);添加文件选择器下拉菜单