From 57d6a2d0661511f73bc66bf50d3c8a48680e0847 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 2 Aug 2026 02:21:30 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20AI=20=E5=AF=B9=E8=AF=9D/?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=AF=E9=9D=A0=E6=80=A7(sanitize=20?= =?UTF-8?q?=E4=B8=89=E5=85=83=E7=BB=84=20+=20G2=20=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=20+=20handshake=20=E4=B8=8D=E6=9D=80=20loop?= =?UTF-8?q?=20+=20=E7=A9=BA=20tool=5Fcall=20id=20=E5=85=9C=E5=BA=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空 改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源); 空 tool_call id 兜底 gen_(治 SenseNova 工具结果路由错位)。 --- crates/df-ai-core/src/provider.rs | 56 +++ crates/df-ai/src/anthropic_compat.rs | 29 +- crates/df-ai/src/anthropic_helpers.rs | 21 +- crates/df-ai/src/openai_compat.rs | 495 ++++++++++++++++++- crates/df-ai/src/openai_helpers.rs | 21 +- src-tauri/src/commands/ai/agentic/helpers.rs | 291 ++++++++++- src-tauri/src/commands/ai/agentic/mod.rs | 175 +++++-- src-tauri/src/lib.rs | 88 ++-- 8 files changed, 991 insertions(+), 185 deletions(-) diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index dac5013..edec73e 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -118,6 +118,29 @@ impl ToolCall { } } +/// 解析点统一兜底:tool_call.id 空 → 生成唯一 fallback,非空原样。 +/// +/// 根因(实证会话 01f05167 SenseNova flash-lite):某些 provider(SenseNova 兼容缺陷) +/// 返回空 `tool_call.id`("")。OpenAI 协议要求 id 唯一。DevFlow 多 tool_call 按 id +/// 路由结果,id 空时所有结果落到同一 key(`audit/mod.rs:203` 的 `seen_ids` 去重把空 id +/// 视为相同,只留首个 tool_call)→ AI 看到「所有调用同一结果」,工具全失败。 +/// +/// 兜底在**解析点**生成 fallback id:raw 非空用 raw,空用 `format!("{prefix}_{index}")` +/// (index 取 tool_call 在数组中的位置,保证同 assistant 内多 tool_call id 唯一)。 +/// 下游(工具执行 / tool 结果回填 tool_call_id)从解析后的 `ToolCall.id` 取,不重复生成, +/// 确保 assistant tool_call.id 与 tool 结果 tool_call_id 匹配(防 sanitize 三元组断裂)。 +/// +/// 三处解析点共用本 helper(DRY):OpenAI 同步 `parse_tool_calls`(prefix=`gen_tool`)、 +/// OpenAI 流式 chunk(prefix=`gen_stream`)、Anthropic 同步 + 流式(prefix=`gen_anthropic` / +/// `gen_anthropic_stream`)。正常 provider(OpenAI/Claude/GLM id 非空)原样透传零介入。 +pub fn tool_call_id_or_fallback(raw: &str, index: usize, prefix: &str) -> String { + if !raw.is_empty() { + raw.to_string() + } else { + format!("{prefix}_{index}") + } +} + /// LLM Provider trait #[async_trait] pub trait LlmProvider: Send + Sync { @@ -435,4 +458,37 @@ mod tests { let deserialized: CompletionResponse = serde_json::from_str(&json).unwrap(); assert_eq!(deserialized.reasoning_content, Some("r1 thought".to_string())); } + + /// CR-空 id:tool_call_id_or_fallback 共享 helper —— 空 raw → fallback,非空原样。 + #[test] + fn tool_call_id_or_fallback_non_empty_passthrough() { + assert_eq!(tool_call_id_or_fallback("call_abc", 0, "gen_tool"), "call_abc"); + assert_eq!(tool_call_id_or_fallback("x", 5, "p"), "x"); + } + + #[test] + fn tool_call_id_or_fallback_empty_generates_with_index() { + assert_eq!(tool_call_id_or_fallback("", 0, "gen_tool"), "gen_tool_0"); + assert_eq!(tool_call_id_or_fallback("", 1, "gen_tool"), "gen_tool_1"); + assert_eq!(tool_call_id_or_fallback("", 7, "gen_stream"), "gen_stream_7"); + } + + #[test] + fn tool_call_id_or_fallback_empty_unique_per_index() { + // 同 prefix 不同 index → 不同 fallback(保证同 assistant 多 tool_call id 唯一) + let ids: Vec = (0..3).map(|i| tool_call_id_or_fallback("", i, "gen_tool")).collect(); + let mut sorted = ids.clone(); + sorted.sort(); + sorted.dedup(); + assert_eq!(ids.len(), sorted.len(), "各 index fallback 应唯一: {:?}", ids); + } + + #[test] + fn tool_call_id_or_fallback_prefix_distinguishes_sources() { + // 不同 prefix 区分来源(同步 gen_tool / 流式 gen_stream / anthropic gen_anthropic) + assert_ne!( + tool_call_id_or_fallback("", 0, "gen_tool"), + tool_call_id_or_fallback("", 0, "gen_stream") + ); + } } diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 5760f7e..e1b1620 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -16,7 +16,7 @@ use std::time::Duration; use tracing::{debug, error, warn}; use crate::provider::{ - CompletionRequest, CompletionResponse, LlmProvider, MessageRole, + tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, MessageRole, StreamResult, TokenUsage, ToolCall, }; // ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。 @@ -549,6 +549,9 @@ impl LlmProvider for AnthropicCompatProvider { // content 块中拼接 text,收集 tool_use let mut text = String::new(); let mut tool_calls: Vec = Vec::new(); + // CR-空 id:按 tool_use 块在数组中的顺序计数(仅 tool_use 递增),用于 fallback index。 + // 用独立计数器而非 for enumerate,避免 text/unknown 块占用 index 致 fallback 编号跳号。 + let mut tool_use_idx: usize = 0; for block in resp.content { match block.block_type.as_str() { "text" => { @@ -557,16 +560,20 @@ impl LlmProvider for AnthropicCompatProvider { } } "tool_use" => { - let id = match block.id { - Some(id) if !id.is_empty() => id, - _ => { - warn!( - name = ?block.name, - "Anthropic tool_use 块缺少 id,已跳过(空 id 会回传空 tool_use_id 触发 500)" - ); - continue; - } - }; + // CR-空 id:原逻辑空 id 直接 continue 跳过整个块(丢工具调用)。 + // 改为兜底:id 非空原样,空 → `gen_anthropic_{idx}` fallback(DRY 共用 + // tool_call_id_or_fallback)。Anthropic 一般非空,此为兼容缺陷兜底。 + // 不再 warn+continue(continue 会丢工具调用致 LLM 拿不到结果)。 + let raw_id = block.id.unwrap_or_default(); + let id = tool_call_id_or_fallback(&raw_id, tool_use_idx, "gen_anthropic"); + if raw_id.is_empty() { + warn!( + fallback_id = %id, + name = ?block.name, + "Anthropic tool_use 块 id 为空,已生成 fallback id(原 continue 跳过会丢工具调用)" + ); + } + tool_use_idx += 1; let name = block.name.unwrap_or_default(); let args = block .input diff --git a/crates/df-ai/src/anthropic_helpers.rs b/crates/df-ai/src/anthropic_helpers.rs index d00299f..ba26328 100644 --- a/crates/df-ai/src/anthropic_helpers.rs +++ b/crates/df-ai/src/anthropic_helpers.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use tracing::{error, warn}; -use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta}; +use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta}; // ============================================================ // Anthropic API 请求/响应结构体 @@ -174,15 +174,16 @@ pub(crate) fn apply_anthropic_event(data: &str, usage_accum: &mut Option Some(id), - _ => { - let placeholder = format!("tool_missing_{}", idx); - warn!(%placeholder, name = ?name, "Anthropic 流式 tool_use 块缺少 id,已填占位 id(原样回传会触发 GLM 500)"); - Some(placeholder) - } + // CR-空 id:id 缺失/空时用 fallback 兜底(流式后续 input_json_delta 按 index 累加, + // 中途无法整体跳过)。与同步路径 + OpenAI 路径共用 tool_call_id_or_fallback(DRY), + // prefix=`gen_anthropic_stream` 区分来源。非空原样。 + let raw_id = cb.get("id").and_then(|t| t.as_str()).unwrap_or(""); + let id = if raw_id.is_empty() { + let fallback = tool_call_id_or_fallback(raw_id, idx as usize, "gen_anthropic_stream"); + warn!(%fallback, name = ?name, "Anthropic 流式 tool_use 块 id 为空,已生成 fallback id(原样回传会触发 GLM 500)"); + Some(fallback) + } else { + Some(raw_id.to_string()) }; return StreamChunk { delta: String::new(), diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index 18c2848..08d5fad 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -11,7 +11,8 @@ use reqwest::Client; use tracing::{debug, error, warn}; use crate::provider::{ - CompletionRequest, CompletionResponse, LlmProvider, StreamResult, TokenUsage, ToolCall, + tool_call_id_or_fallback, CompletionRequest, CompletionResponse, LlmProvider, StreamResult, + TokenUsage, ToolCall, }; // ChatMessage 仅单测构造 CompletionRequest 用,避免非 test 构建的 unused import 警告。 #[cfg(test)] @@ -182,30 +183,25 @@ impl OpenAICompatProvider { // assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。 Self::ensure_leading_user(&mut messages); - // 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息, - // 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。 - // 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。 - // 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。 - // 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。 - for i in 0..messages.len() { - let role = messages[i].role.clone(); - if role != "assistant" { - continue; - } - let has_tc = messages[i].tool_calls.is_some(); - if !has_tc { - continue; - } - let next_is_tool = i + 1 < messages.len() - && matches!(messages[i + 1].role.as_str(), "tool"); - if !next_is_tool { - messages[i].tool_calls = None; - tracing::warn!( - "[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)", - i, role, - ); - } - } + // 治 DeepSeek/OpenAI 400(三元组完整性 P0)。OpenAI 协议铁律: + // (a) assistant 的每个 tool_call.id 必须有后续 tool(role=tool, tool_call_id 匹配)响应, + // 否则 "insufficient tool messages" 400(assistant 调了工具但无结果)。 + // (b) 反之,每条 tool 消息必须紧跟一个含 tool_calls(同 tool_call_id)的 assistant, + // 否则 "Messages with role tool must be a response to a preceding message + // with tool_calls" 400(tool 无配对头)。 + // + // 旧逻辑只检查「下一条 role 是否为 tool」(粗粒度),漏两类 orphan: + // 1) 部分 tool_call 无响应:assistant(tc=[a,b]) → tool(a)(b 丢失)→ 旧逻辑因下一条是 + // tool 不剥 → 发出未闭合的 b → 400。修法:按 tool_call_id 精确配对,剥未闭合 id。 + // 2) orphan tool_result(tool 无前置 assistant tool_calls 配对):DB/直构造路径绕过 + // ContextManager::sanitize_messages(标题/知识注入/工作流节点),tool 残留无头 → + // 旧逻辑不处理 → 400。修法:剥 assistant tool_calls 时同步丢弃同 id 的 orphan + // tool(一致性:不留无头 result),并对独立 orphan tool(全程无配对头)直接丢弃。 + // + // 正常三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b),各 id 闭合, + // 本守卫零介入。仅异常截断/恢复/直构造路径触发(防 400 兜底)。 + // view-only:仅改发送视图(本函数消费 req.messages 所有权),持久化由调用方/上层 sanitize 全量保留。 + sanitize_openai_triplets(&mut messages); let tools = req.tools.map(|defs| { defs.into_iter() @@ -304,15 +300,149 @@ impl OpenAICompatProvider { ); } - /// 解析同步响应中的工具调用 + /// 解析同步响应中的工具调用。 + /// + /// 兜底(CR-空 id):id 空时按数组 index 生成 `gen_tool_{index}` fallback。 + /// SenseNova 等兼容缺陷 provider 发空 id,多 tool_call 同 id 致结果路由全落首个。 + /// 详见 `tool_call_id_or_fallback`。正常 provider id 非空原样透传。 fn parse_tool_calls(calls: Vec) -> Vec { calls .into_iter() - .map(|c| ToolCall::new(c.id, c.function.name, c.function.arguments)) + .enumerate() + .map(|(i, c)| { + let id = tool_call_id_or_fallback(&c.id, i, "gen_tool"); + ToolCall::new(id, c.function.name, c.function.arguments) + }) .collect() } } +/// 从 OpenAiMessage 的 tool_calls 数组里取每个 call 的 id(tool_calls 形如 +/// [{id, type, function:{name, arguments}}, ...])。非数组 / 缺 id 的条目跳过。 +fn extract_tool_call_ids(msg: &OpenAiMessage) -> Vec { + let Some(arr) = msg.tool_calls.as_ref() else { + return Vec::new(); + }; + arr.iter() + .filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())) + .collect() +} + +/// 三元组一致性自愈(view-only,发送视图):保证 OpenAI 协议 tool_call/tool_result +/// 双向闭合,防 DeepSeek/OpenAI 400。详见 [`OpenAICompatProvider::convert_request`] 调用处注释。 +/// +/// 两轮扫描: +/// 1) 收集 resolved_ids = 所有 tool 消息的 tool_call_id(这些 id 有 result 响应)。 +/// 2) assistant(tool_calls):剥未在 resolved_ids 内的 call.id;剥空则 tool_calls=None。 +/// (头被剥后,其 tool_call.id 不再进 head_ids,故 step3 会同步丢弃对应 orphan tool。) +/// 3) tool:tool_call_id 不在任何保留 assistant 头(任意 assistant 仍含此 id)→ orphan +/// tool_result,丢弃。这覆盖「头被剥后残留的 tool」与「全程无配对头的 tool」两类。 +/// +/// 一致性:剥 assistant tool_call → 该 id 不进 head_ids → 对应 tool 在 step3 被丢; +/// 反之剥 orphan tool 不动 assistant(若 assistant 的所有 id 都被剥则 tool_calls=None)。 +/// 正常三元组(各 id 闭合)零介入。 +/// +/// 设计取舍:OpenAI 协议 assistant(tool_calls) 需有效函数结构,补头(像 Anthropic +/// TOOL_MISSING_PREFIX)风险高于丢弃——故选「丢弃 orphan」而非「补头」。Anthropic +/// 路径由 drop_reverse_orphans 补头自愈(保留 LLM 可见的工具结果);OpenAI 路径走丢弃, +/// 二者各自适配协议特性(Anthropic 严格交替 + 补头可行;OpenAI tool 必须紧跟 tool_calls)。 +fn sanitize_openai_triplets(messages: &mut Vec) { + use std::collections::HashSet; + + // step 1:resolved_ids = 所有 tool 消息提供的 tool_call_id(有 result 响应的 id)。 + let resolved_ids: HashSet = messages + .iter() + .filter(|m| m.role == "tool") + .filter_map(|m| m.tool_call_id.clone()) + .collect(); + + let mut stripped_heads = 0u32; + let mut total_stripped = 0u32; + + // step 2:assistant 剥未闭合 tool_call(无对应 tool result 响应)。 + for m in messages.iter_mut() { + if m.role != "assistant" { + continue; + } + let Some(calls) = m.tool_calls.as_ref() else { + continue; + }; + if calls.is_empty() { + continue; + } + let kept: Vec = calls + .iter() + .filter(|tc| { + tc.get("id") + .and_then(|v| v.as_str()) + .is_some_and(|id| resolved_ids.contains(id)) + }) + .cloned() + .collect(); + let stripped_count = calls.len() - kept.len(); + if stripped_count == 0 { + continue; + } + m.tool_calls = if kept.is_empty() { None } else { Some(kept) }; + stripped_heads += 1; + total_stripped += stripped_count as u32; + tracing::warn!( + stripped_count, + "[openai] assistant 含未闭合 tool_calls(无对应 tool result),已剥离 {} 个(防 insufficient tool messages 400)", + stripped_count, + ); + } + + // step 3:head_ids = step2 后仍保留在任意 assistant 头的 id(有头配对的 tool 才保留)。 + let head_ids: HashSet = messages + .iter() + .filter(|m| m.role == "assistant") + .flat_map(extract_tool_call_ids) + .collect(); + + let original_len = messages.len(); + let mut dropped_orphan_tools = 0u32; + messages.retain(|m| { + if m.role != "tool" { + return true; + } + let id = match m.tool_call_id.as_deref() { + None => { + // 无 tool_call_id 的 tool 消息(异常数据):无法配对,丢弃(发出去必 400)。 + dropped_orphan_tools += 1; + tracing::warn!( + "[openai] tool 消息缺少 tool_call_id,已丢弃(无 id 无法配对 assistant tool_calls,防 400)" + ); + return false; + } + Some(id) => id, + }; + if head_ids.contains(id) { + // 有配对头 → 保留(正常三元组)。 + return true; + } + // 无配对头(id 不在任何保留 assistant 头内)→ orphan tool_result,丢弃。 + // 含两类:(a) assistant 头被 step2 剥后残留的 tool;(b) 全程无配对头的直构造/DB 残留。 + dropped_orphan_tools += 1; + tracing::warn!( + tool_call_id = %id, + "[openai] orphan tool result(无配对 assistant tool_calls),已丢弃(防 'tool must be response to preceding tool_calls' 400)", + ); + false + }); + + if stripped_heads > 0 || dropped_orphan_tools > 0 { + tracing::warn!( + stripped_heads, + total_stripped, + dropped_orphan_tools, + before = original_len, + after = messages.len(), + "[openai] tool_call 三元组自愈(view-only, 持久化不受影响)" + ); + } +} + #[async_trait] impl LlmProvider for OpenAICompatProvider { /// 文本嵌入: POST /v1/embeddings(OpenAI 兼容,智谱/阿里百炼/OpenAI 通用) @@ -801,4 +931,315 @@ mod tests { assert_eq!(out.messages.len(), 2, "正常序列不补占位"); assert_eq!(out.messages[0].role.as_str(), "user"); } + + // ---------- 三元组一致性自愈(P0:治 DeepSeek/OpenAI 400) ---------- + + /// 辅助:取 assistant 消息的 tool_call id 列表(发出去的形态)。 + fn openai_tool_call_ids(m: &OpenAiMessage) -> Vec { + m.tool_calls + .as_ref() + .map(|arr| { + arr.iter() + .filter_map(|tc| tc.get("id").and_then(|v| v.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default() + } + + /// 正常三元组(各 id 闭合)零介入:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。 + /// 约束铁律:不破正常三元组。 + #[test] + fn openai_sanitize_keeps_closed_triplets() { + let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat"); + let req = CompletionRequest { + model: "deepseek-chat".into(), + messages: vec![ + ChatMessage::user("查天气"), + ChatMessage::assistant_with_tools( + "调用中", + vec![ToolCall::new("call_a", "get_weather", "{}")], + ), + ChatMessage::tool_result("call_a", "晴"), + ChatMessage::assistant_with_tools( + "再查", + vec![ToolCall::new("call_b", "get_weather", "{}")], + ), + ChatMessage::tool_result("call_b", "雨"), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + // 5 条全保留(正常三元组不剥不丢)。 + assert_eq!(out.messages.len(), 5, "正常三元组零介入,不应剥/丢任何消息"); + // 两个 assistant 头的 tool_calls 完整保留。 + let heads: Vec<&OpenAiMessage> = out + .messages + .iter() + .filter(|m| m.role == "assistant") + .collect(); + assert_eq!(openai_tool_call_ids(heads[0]), vec!["call_a".to_string()]); + assert_eq!(openai_tool_call_ids(heads[1]), vec!["call_b".to_string()]); + } + + /// 末尾 assistant tool_calls 无 result(残末尾)→ 剥离 tool_calls(保留 assistant 文本)。 + /// 防 "insufficient tool messages" 400。 + #[test] + fn openai_sanitize_strips_tail_unresolved_tool_calls() { + let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat"); + let req = CompletionRequest { + model: "deepseek-chat".into(), + messages: vec![ + ChatMessage::user("查天气"), + ChatMessage::assistant_with_tools( + "调工具但 result 还没回来", + vec![ToolCall::new("call_x", "get_weather", "{}")], + ), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + // assistant 保留(content 不丢),但 tool_calls 被剥。 + let asst = out + .messages + .iter() + .find(|m| m.role == "assistant") + .expect("assistant 应保留"); + assert!( + asst.tool_calls.is_none(), + "未闭合 tool_calls 应被剥离" + ); + } + + /// orphan tool_result(无配对 assistant tool_calls 头)→ 丢弃。 + /// 直构造/DB 残留路径绕过 ContextManager::sanitize_messages 时由本守卫兜底。 + /// 防 "Messages with role tool must be a response to a preceding message with tool_calls" 400。 + #[test] + fn openai_sanitize_drops_orphan_tool_result_no_head() { + let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat"); + let req = CompletionRequest { + model: "deepseek-chat".into(), + messages: vec![ + ChatMessage::user("问"), + // 无头的 orphan tool_result(头被裁剪/丢失)。 + ChatMessage::tool_result("orphan_id", "结果"), + ChatMessage::assistant("回复"), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + // orphan tool 被丢弃,剩 user + assistant。 + let tools: Vec<&OpenAiMessage> = out + .messages + .iter() + .filter(|m| m.role == "tool") + .collect(); + assert!(tools.is_empty(), "无配对头的 orphan tool_result 应丢弃, 实际 {:?}", tools); + assert_eq!(out.messages.len(), 2, "应剩 user + assistant"); + } + + /// assistant tool_calls 剥离后,对应 orphan tool_result 同步丢弃(一致性)。 + /// 场景:assistant(tc=[a,b]) → tool(a)(b 的 result 丢失)。旧逻辑因下一条是 tool + /// 不剥 → 发出未闭合 b → 400。新逻辑按 id 精确配对:剥 b(保留 a),tool(a) 保留。 + #[test] + fn openai_sanitize_partial_triplet_strips_unresolved_id() { + let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat"); + let req = CompletionRequest { + model: "deepseek-chat".into(), + messages: vec![ + ChatMessage::user("问"), + ChatMessage::assistant_with_tools( + "调两工具", + vec![ + ToolCall::new("call_a", "tool_a", "{}"), + ToolCall::new("call_b", "tool_b", "{}"), + ], + ), + // 只回了 call_a,call_b 的 result 丢失。 + ChatMessage::tool_result("call_a", "a 结果"), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + let asst = out + .messages + .iter() + .find(|m| m.role == "assistant") + .expect("assistant 应保留"); + // 只保留 call_a(已闭合),剥 call_b(未闭合)。 + assert_eq!( + openai_tool_call_ids(asst), + vec!["call_a".to_string()], + "部分闭合头应只留已闭合 call_a, 剥未闭合 call_b" + ); + // tool(call_a) 保留(有配对头)。 + let tools: Vec<&OpenAiMessage> = out + .messages + .iter() + .filter(|m| m.role == "tool") + .collect(); + assert_eq!(tools.len(), 1, "call_a 的 tool_result 应保留"); + } + + /// 全未闭合三元组:assistant(tc=[a]) 但全程无 tool(a) → 剥 tool_calls, + /// 且不残留任何 orphan tool(本就无 tool 消息)。 + #[test] + fn openai_sanitize_fully_unresolved_strips_all() { + let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat"); + let req = CompletionRequest { + model: "deepseek-chat".into(), + messages: vec![ + ChatMessage::user("问"), + ChatMessage::assistant_with_tools( + "调工具无结果", + vec![ + ToolCall::new("call_y", "tool_y", "{}"), + ToolCall::new("call_z", "tool_z", "{}"), + ], + ), + ChatMessage::assistant("纯文本续"), + ], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + let heads: Vec<&OpenAiMessage> = out + .messages + .iter() + .filter(|m| m.role == "assistant") + .collect(); + // 第一个 assistant(原含 tool_calls)应被剥空;第二个纯文本不变。 + assert!( + heads[0].tool_calls.is_none(), + "全未闭合 tool_calls 应全部剥离" + ); + assert!(heads[1].tool_calls.is_none(), "纯文本 assistant 无 tool_calls"); + } + + /// 无 tool_call_id 的 tool 消息(异常数据)→ 丢弃(发出去必 400)。 + #[test] + fn openai_sanitize_drops_tool_without_call_id() { + let provider = OpenAICompatProvider::new("https://api.deepseek.com", "k", "deepseek-chat"); + let mut bad_tool = ChatMessage::tool_result("temp", "结果"); + bad_tool.tool_call_id = None; // 异常:无 id + let req = CompletionRequest { + model: "deepseek-chat".into(), + messages: vec![ChatMessage::user("问"), bad_tool], + temperature: None, + max_tokens: None, + stream: false, + tools: None, + tool_choice: None, + reasoning_content: None, + }; + let out = provider.convert_request(req); + let tools: Vec<&OpenAiMessage> = out + .messages + .iter() + .filter(|m| m.role == "tool") + .collect(); + assert!( + tools.is_empty(), + "无 tool_call_id 的 tool 消息应丢弃, 实际 {:?}", tools + ); + } + + /// CR-空 id:parse_tool_calls 对空 id 按 index 生成 gen_tool_{i} fallback,非空原样。 + /// 根因:SenseNova 等兼容缺陷 provider 发空 tool_call.id,多 tool_call 同 id(空串) + /// 致 audit/mod.rs:203 seen_ids 去重只留首个 → 所有工具结果路由到首个。 + #[test] + fn openai_parse_tool_calls_empty_id_fallback_unique() { + let calls = vec![ + OpenAiToolCallResp { + id: String::new(), + call_type: "function".into(), + function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"docs"}"#.into() }, + }, + OpenAiToolCallResp { + id: String::new(), + call_type: "function".into(), + function: OpenAiFunctionResp { name: "list_dir".into(), arguments: r#"{"path":"crates"}"#.into() }, + }, + OpenAiToolCallResp { + id: "call_abc123".into(), + call_type: "function".into(), + function: OpenAiFunctionResp { name: "read_file".into(), arguments: r#"{"path":"根"}"#.into() }, + }, + ]; + let parsed = OpenAICompatProvider::parse_tool_calls(calls); + assert_eq!(parsed.len(), 3); + // 空 id → fallback(按 index),保证唯一 + assert_eq!(parsed[0].id, "gen_tool_0"); + assert_eq!(parsed[1].id, "gen_tool_1"); + // 非空 id 原样透传 + assert_eq!(parsed[2].id, "call_abc123"); + // name/args 透传无损 + assert_eq!(parsed[0].function.name, "list_dir"); + assert_eq!(parsed[1].function.arguments, r#"{"path":"crates"}"#); + // 关键:所有 id 互异(去重后不丢工具) + let mut ids: Vec<&str> = parsed.iter().map(|c| c.id.as_str()).collect(); + ids.sort(); + let unique: Vec<&str> = { + let mut u = ids.clone(); + u.dedup(); + u + }; + assert_eq!(ids.len(), unique.len(), "id 应全部唯一,实际 {:?}", ids); + } + + /// CR-空 id 流式:SSE chunk 携带 `"id":""`(SenseNova 兼容缺陷)→ ToolCallDelta.id + /// 转为 `gen_stream_{index}` fallback(非 None),保证下游 accumulate_tool_calls 写入 + /// draft.id 非空。chunk 完全无 id 字段(None)保持 None(OpenAI 协议:仅首 chunk 有 id, + /// 后续 chunk 无 id 不应覆盖首 chunk 权威 id),由 agentic 转换点兜底。 + #[test] + fn openai_stream_chunk_empty_id_fallback() { + let mut acc: Option = None; + // chunk 1: tool_call index=0, id="" → fallback gen_stream_0 + let data1 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"","type":"function","function":{"name":"list_dir","arguments":"{\"path\":\"docs\"}"}}]}}]}"#; + let c1 = apply_openai_sse(data1, &mut acc); + let tc1 = c1.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap(); + assert_eq!(tc1.index, 0); + assert_eq!(tc1.id.as_deref(), Some("gen_stream_0"), "空 id 应转 fallback"); + + // chunk 2: tool_call index=1, id="" → fallback gen_stream_1(与 index=0 不同,唯一) + let data2 = r#"{"choices":[{"delta":{"tool_calls":[{"index":1,"id":"","type":"function","function":{"name":"read_file","arguments":""}}]}}]}"#; + let c2 = apply_openai_sse(data2, &mut acc); + let tc2 = c2.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap(); + assert_eq!(tc2.id.as_deref(), Some("gen_stream_1"), "不同 index fallback 应不同"); + + // chunk 3: tool_call index=0, 无 id 字段(None)→ 保持 None(不覆盖首 chunk) + let data3 = r#"{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"更多参数"}}]}}]}"#; + let c3 = apply_openai_sse(data3, &mut acc); + let tc3 = c3.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap(); + assert!(tc3.id.is_none(), "无 id 字段 chunk 应保持 None,不覆盖首 chunk 权威 id"); + + // chunk 4: tool_call 非空 id → 原样透传 + let data4 = r#"{"choices":[{"delta":{"tool_calls":[{"index":2,"id":"call_xyz","type":"function","function":{"name":"write"}}]}}]}"#; + let c4 = apply_openai_sse(data4, &mut acc); + let tc4 = c4.tool_calls.as_ref().expect("应有 tool_calls").first().unwrap(); + assert_eq!(tc4.id.as_deref(), Some("call_xyz"), "非空 id 原样透传"); + } } diff --git a/crates/df-ai/src/openai_helpers.rs b/crates/df-ai/src/openai_helpers.rs index 98b3e6c..db26ea4 100644 --- a/crates/df-ai/src/openai_helpers.rs +++ b/crates/df-ai/src/openai_helpers.rs @@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize}; use tracing::{debug, error}; -use crate::provider::{StreamChunk, TokenUsage, ToolCallDelta}; +use crate::provider::{tool_call_id_or_fallback, StreamChunk, TokenUsage, ToolCallDelta}; // ============================================================ // OpenAI API 请求/响应结构体 @@ -211,11 +211,20 @@ pub(crate) fn apply_openai_sse(data: &str, usage_accum: &mut Option) let tool_calls = choice.delta.tool_calls.map(|tcs| { tcs.into_iter() - .map(|tc| ToolCallDelta { - index: tc.index, - id: tc.id, - function_name: tc.function.as_ref().and_then(|f| f.name.clone()), - function_arguments: tc.function.and_then(|f| f.arguments), + .map(|tc| { + // CR-空 id:流式 chunk 的 id 可能为 Some("")(SenseNova 兼容缺陷)。 + // 仅对「provider 显式给了 id 字段」的 chunk 做兜底——None(OpenAI + // 协议:仅首 chunk 携带 id,后续 chunk 无 id)保持 None,避免 + // 覆盖首 chunk 的权威 id。Some("") → `gen_stream_{index}` fallback, + // Some(非空) → 原样。下游 stream_recv 按 index 累积,draft.id 透传 + // 至 ToolCall.id(accumulate_tool_calls 仅 Some 覆盖,None 不动)。 + let id = tc.id.map(|raw| tool_call_id_or_fallback(&raw, tc.index as usize, "gen_stream")); + ToolCallDelta { + index: tc.index, + id, + function_name: tc.function.as_ref().and_then(|f| f.name.clone()), + function_arguments: tc.function.and_then(|f| f.arguments), + } }) .collect() }); diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index a523f1c..a170a5a 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -196,37 +196,280 @@ pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap< goals } -/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。 -pub(crate) fn is_empty_tool_result(content: &str) -> bool { - let trimmed = content.trim(); - if trimmed.is_empty() { return true; } - const EMPTY_MARKERS: &[&str] = &[ - "\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]", - ]; - for marker in EMPTY_MARKERS { - if trimmed.contains(marker) { return true; } +// ============================================================ +// G2 探索熔断(2026-08-01 根本性重构:从「结果空」判漂移 → 「调用签名重复」判漂移) +// +// 旧范式(is_empty_tool_result)用关键词(`"matches":[]`/`"total":0`/未找到...)判 +// 「空成功」,是**错误代理指标**:grep 无匹配是有效排除信号(AI 换词定位/排除路径), +// 非漂移。实测会话 ac448296 系统 grep 多关键词(部分无匹配)→ 整轮全空 stall+=1 → +// 连续 3 轮误熔断 → 对话莫名停止(详见 memory `devflow-g2-stall-false-positive`)。 +// +// 新范式:漂移的本质 = AI 卡住**反复做同样的工具调用**。正常探索(换词/换路径/换工具) +// 签名不同;真死循环(同调用反复)签名重复。判「签名重复」直接命中漂移本质,不再误杀 +// 正常排除式搜索。 +// +// 调用点:check_stall_breaker(agentic/mod.rs) 取最近 N 个 assistant tool_calls 签名, +// 喂 is_repetitive_exploration 判定,重复 → stall_count+=1(沿用熔断骨架不变)。 +// ============================================================ + +/// 从 args JSON Value 取字符串字段,缺失/非字符串 → 空串(归一兜底,签名不 panic)。 +fn arg_str(args: &serde_json::Value, key: &str) -> String { + args.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_default() +} + +/// G2 签名归一化:把每工具「决定意图」的参数压成一个可比对字符串 `"name:k1=v1,k2=v2"`。 +/// +/// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/ +/// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。 +/// +/// 归一规则(参数从 args JSON 取,缺失用空串): +/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`) +/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。 +/// - read_file: `path` + `offset` + `limit` +/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。 +/// - run_command: `command`(整条命令,含参数)。 +/// - list_dir/list_directory: `path`。 +/// - http_request: `url` + `method`。 +/// - fetch_url: `url`。 +/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。 +/// +/// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments +/// (JSON 字符串),调用方先 from_str 成 Value 再传入。 +pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String { + let pair = |k: &str, v: &str| format!("{}={}", k, v); + let sig = match name { + "grep" | "search_files" | "search" => { + let q = if args.get("pattern").and_then(|v| v.as_str()).is_some() { + arg_str(args, "pattern") + } else { + arg_str(args, "query") + }; + let p = if args.get("path").and_then(|v| v.as_str()).is_some() { + arg_str(args, "path") + } else { + arg_str(args, "glob") + }; + format!("{},{}", pair("pattern", &q), pair("path", &p)) + } + "read_file" => { + // offset/limit 数值字段:as_str 不通,先取再转字符串(缺失→"")。 + let offset = args.get("offset").map(|v| v.to_string()).unwrap_or_default(); + let limit = args.get("limit").map(|v| v.to_string()).unwrap_or_default(); + let path = arg_str(args, "path"); + format!("{},{},{}", pair("path", &path), pair("offset", &offset), pair("limit", &limit)) + } + "run_command" => pair("command", &arg_str(args, "command")), + "list_dir" | "list_directory" => pair("path", &arg_str(args, "path")), + "http_request" => { + let url = arg_str(args, "url"); + let method = arg_str(args, "method"); + format!("{},{}", pair("url", &url), pair("method", &method)) + } + "fetch_url" => pair("url", &arg_str(args, "url")), + _ => { + // 兜底:工具名 + args 全 JSON 序列化(保守,无明确语义时全量比对)。 + format!("{},{}", pair("name", name), pair("args", &args.to_string())) + } + }; + format!("{}:{}", name, sig) +} + +/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。 +/// +/// 策略组合(两者任一命中即 true,注释论证稳健性): +/// - 样本不足(len < `REPETITION_MIN_SAMPLE`=6)→ false(不判,小样本误杀风险高)。 +/// - **唯一签名数 / 总数 < 0.4**(超 60% 重复)→ true。 +/// 覆盖「多个签名轮换但整体高度重复」(如 a/b/c/a/b/c/d/a/b),唯一率低 = 没有新探索方向。 +/// - **或:某签名出现次数 >= 3** → true。 +/// 覆盖「单点反复」(如 a,a,a,b,c),唯一率 3/5=0.6 不触发上条,但 a 已 3 次死磕 = 漂移。 +/// +/// 两条互补:唯一率治整体游荡不前进,单点计数治单点死磕。组合后覆盖真实漂移的两种形态, +/// 且对正常探索(签名持续翻新)宽松——换词 grep + 不同文件 read 各一两次,唯一率高不触发。 +pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool { + /// 最小样本量:不足此数不判定(避免早期误杀,如刚启动 2-3 个 grep 全不同不应熔断)。 + const REPETITION_MIN_SAMPLE: usize = 6; + /// 单签名出现次数阈值:达此即判单点死磕漂移。 + const REPETITION_SINGLE_MAX: usize = 3; + /// 唯一签名占比阈值:低于此(重复超 60%)判整体游荡不前进。 + const REPETITION_UNIQUE_RATIO: f64 = 0.4; + + if signatures.len() < REPETITION_MIN_SAMPLE { + return false; } - const EMPTY_TEXT: &[&str] = &[ - "未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录", - "No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合", - ]; - for marker in EMPTY_TEXT { - if trimmed.contains(marker) { return true; } + let total = signatures.len(); + let unique = { + let mut s: Vec<&String> = signatures.iter().collect(); + s.sort(); + s.dedup(); + s.len() + }; + let unique_ratio = unique as f64 / total as f64; + if unique_ratio < REPETITION_UNIQUE_RATIO { + return true; } - false + // 单点死磕:统计最高频签名出现次数。HashMap 避免重复 sort 计数,O(n)。 + let mut counts: std::collections::HashMap<&String, usize> = std::collections::HashMap::new(); + for s in signatures { + *counts.entry(s).or_insert(0) += 1; + } + counts.values().any(|&c| c >= REPETITION_SINGLE_MAX) } #[cfg(test)] mod tests { use super::*; + + // ── tool_call_signature 归一化测试 ── + #[test] - fn test_empty_tool_result() { - assert!(is_empty_tool_result("")); - assert!(is_empty_tool_result(" ")); - assert!(is_empty_tool_result(r#"{"total":0}"#)); - assert!(is_empty_tool_result(r#"{"entries":[]}"#)); - assert!(is_empty_tool_result("未找到相关文件")); - assert!(!is_empty_tool_result(r#"{"total":5}"#)); - assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#)); + fn sig_grep_takes_pattern_and_path() { + let args = serde_json::json!({"pattern": "MAX", "path": "src/lib.rs", "case_sensitive": true}); + assert_eq!(tool_call_signature("grep", &args), "grep:pattern=MAX,path=src/lib.rs"); + // 非决定参数(case_sensitive)不进签名 + } + + #[test] + fn sig_search_falls_back_to_query_and_glob() { + let args = serde_json::json!({"query": "TODO", "glob": "**/*.rs"}); + assert_eq!(tool_call_signature("search_files", &args), "search_files:pattern=TODO,path=**/*.rs"); + } + + #[test] + fn sig_read_file_includes_offset_limit() { + let args = serde_json::json!({"path": "big.log", "offset": 50, "limit": 100}); + assert_eq!(tool_call_signature("read_file", &args), "read_file:path=big.log,offset=50,limit=100"); + } + + #[test] + fn sig_run_command_takes_command() { + let args = serde_json::json!({"command": "ls -la", "timeout": 5000}); + assert_eq!(tool_call_signature("run_command", &args), "run_command:command=ls -la"); + } + + #[test] + fn sig_list_dir_takes_path() { + let args = serde_json::json!({"path": "/tmp"}); + assert_eq!(tool_call_signature("list_dir", &args), "list_dir:path=/tmp"); + } + + #[test] + fn sig_http_request_takes_url_method() { + let args = serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}}); + assert_eq!(tool_call_signature("http_request", &args), "http_request:url=https://x.io,method=GET"); + } + + #[test] + fn sig_fetch_url_takes_url() { + let args = serde_json::json!({"url": "https://y.io", "raw": false}); + assert_eq!(tool_call_signature("fetch_url", &args), "fetch_url:url=https://y.io"); + } + + #[test] + fn sig_unknown_falls_back_to_full_args() { + let args = serde_json::json!({"x": 1, "y": "z"}); + let sig = tool_call_signature("custom_tool", &args); + assert!(sig.starts_with("custom_tool:name=custom_tool,args=")); + assert!(sig.contains("\"x\":1")); + assert!(sig.contains("\"y\":\"z\"")); + } + + #[test] + fn sig_missing_args_default_empty() { + // 无任何字段,grep 兜底 pattern/path 都空,签名仍可构造不 panic。 + let args = serde_json::json!({}); + assert_eq!(tool_call_signature("grep", &args), "grep:pattern=,path="); + assert_eq!(tool_call_signature("read_file", &args), "read_file:path=,offset=,limit="); + assert_eq!(tool_call_signature("run_command", &args), "run_command:command="); + } + + // ── is_repetitive_exploration 场景测试 ── + + /// 场景 ac448296 实证:正常代码审查序列——换词 grep(MAX/truncate/fetch_url)+ + /// 不同文件 read_file,12 个签名全不同。**核心回归**:旧 is_empty_tool_result 误熔断此场景, + /// 新签名判定应返回 false(不熔断)。 + #[test] + fn scenario_normal_code_review_not_repetitive() { + let sigs = vec![ + tool_call_signature("grep", &serde_json::json!({"pattern": "MAX", "path": "src"})), + tool_call_signature("grep", &serde_json::json!({"pattern": "truncate", "path": "src"})), + tool_call_signature("grep", &serde_json::json!({"pattern": "fetch_url", "path": "src"})), + tool_call_signature("grep", &serde_json!({"pattern": "MessageRole", "path": "crates"})), + tool_call_signature("grep", &serde_json!({"pattern": "pub enum", "path": "src"})), + tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50})), + tool_call_signature("read_file", &serde_json!({"path": "b.rs", "offset": 0, "limit": 50})), + tool_call_signature("read_file", &serde_json!({"path": "c.rs", "offset": 0, "limit": 50})), + tool_call_signature("read_file", &serde_json!({"path": "d.rs", "offset": 100, "limit": 50})), + tool_call_signature("list_dir", &serde_json!({"path": "src-tauri"})), + tool_call_signature("grep", &serde_json!({"pattern": "STALL", "path": "src"})), + tool_call_signature("read_file", &serde_json!({"path": "e.rs", "offset": 0, "limit": 50})), + ]; + assert_eq!(sigs.len(), 12); + assert!(!is_repetitive_exploration(&sigs), "正常代码审查不应判重复"); + } + + /// 场景死循环:同 grep 同 path 反复 8 次。判 true(熔断)。 + #[test] + fn scenario_real_deadloop_repetitive() { + let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"})); + let sigs = vec![one; 8]; + assert!(is_repetitive_exploration(&sigs), "同调用反复应判重复"); + } + + /// 场景分段读大文件:同 path 不同 offset=0/50/100/150/200/250,6 次。判 false(签名不同)。 + #[test] + fn scenario_paginated_read_not_repetitive() { + let offsets = [0, 50, 100, 150, 200, 250]; + let sigs: Vec = offsets.iter() + .map(|&o| tool_call_signature("read_file", &serde_json::json!({"path": "big.log", "offset": o, "limit": 50}))) + .collect(); + assert!(!is_repetitive_exploration(&sigs), "分段读不同 offset 不应判重复"); + } + + /// 场景反复读同段:同 path 同 offset+limit 4 次(+ 其他 2 个不同凑足样本)。判 true(漂移)。 + #[test] + fn scenario_repeat_same_chunk_repetitive() { + let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50})); + let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"})); + let other2 = tool_call_signature("list_dir", &serde_json::json!({"path": "z"})); + let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2]; + assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复"); + } + + /// 场景样本不足:仅 3 个签名(即使全同)。判 false(不判)。 + #[test] + fn scenario_insufficient_sample_not_repetitive() { + let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"})); + let sigs = vec![one; 3]; + assert!(!is_repetitive_exploration(&sigs), "样本不足不应判"); + } + + /// 边界:恰好 6 个全同 → true(达最小样本 + 单点 6 >= 3)。 + #[test] + fn boundary_exact_min_sample_all_same() { + let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"})); + let sigs = vec![one; 6]; + assert!(is_repetitive_exploration(&sigs)); + } + + /// 边界:6 个签名两两循环(a,b,a,b,a,b)→ 唯一率 2/6≈0.33 < 0.4 → true(整体游荡)。 + #[test] + fn boundary_two_alternating_below_ratio() { + let a = tool_call_signature("grep", &serde_json::json!({"pattern": "a", "path": "x"})); + let b = tool_call_signature("grep", &serde_json::json!({"pattern": "b", "path": "x"})); + let sigs = vec![a, b.clone(), a.clone(), b.clone(), a, b]; + assert!(is_repetitive_exploration(&sigs), "两签名交替唯一率低应判重复"); + } + + /// 边界:6 个签名全不同 → false(正常探索)。 + #[test] + fn boundary_six_unique_not_repetitive() { + let patterns = ["a", "b", "c", "d", "e", "f"]; + let sigs: Vec = patterns.iter() + .map(|p| tool_call_signature("grep", &serde_json::json!({"pattern": p, "path": "x"}))) + .collect(); + assert!(!is_repetitive_exploration(&sigs)); } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index cd25219..9a00882 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -154,35 +154,41 @@ pub const MAX_GOALS: usize = 5; pub const TOPIC_MARKER_GOAL_AWARE: bool = true; // ============================================================ -// G2 探索熔断(治 R4 连续空结果无进展死循环 + token 失控,2026-06-26) +// G2 探索熔断(治 R4 探索无进展死循环 + token 失控) // -// 机制(非 prompt 说教):连续 N 轮工具结果全空(空成功,如 search_files/grep 无命中) → -// 两段式处理(STALL_BREAKER_WARN_FIRST):先警示注入下轮 prompt 给 LLM 自纠机会, -// 再熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。 -// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复失败(禁止/失败/Error 关键词), -// G2 治反复空成功(total:0/无匹配),两者独立计数互不干扰。 -// 直接治实测 seq287-307 连续 10+ 次 search_files 空结果死循环(阈值 3 提前 7 次熔断,省巨量 token)。 +// 判定范式(2026-08-01 根本性重构): +// - 旧(废弃):连续 N 轮工具结果全空(is_empty_tool_result 关键词判空成功)。错误代理指标, +// grep 无匹配是有效排除信号非漂移,误熔断正常排除式搜索(实证 ac448296)。 +// - 新(当前):连续 N 轮工具调用签名重复(is_repetitive_exploration 判 tool_call_signature)。 +// 漂移本质 = AI 卡住反复同样调用;正常探索(换词/换路径/换工具)签名不同,真死循环签名重复。 +// +// 机制(非 prompt 说教,骨架沿用):连续 N 轮判定签名重复 → 两段式(WARN_FIRST 先警示再熔断): +// 警示注入下轮 prompt 给 LLM 自纠机会,熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。 +// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复同类失败(失败信号归一 key), +// G2 治反复同类调用(调用签名归一),两者独立计数互不干扰。 // ============================================================ /// G2 总开关:探索熔断是否启用(默认 true)。 /// -/// true(默认):process_tool_calls 后取末尾连续 Tool 消息判空结果,全空累计 stall_count, -/// 达 STALL_BREAKER_THRESHOLD → 警示/熔断。false(回退):整段跳过,stall_count 永远 0, -/// 退 max_iterations 旧行为(可能继续游荡但行为不变,单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。 +/// true(默认):process_tool_calls 后取最近 N 个 assistant tool_calls 签名,喂 +/// is_repetitive_exploration 判重复,重复累计 stall_count,达 STALL_BREAKER_THRESHOLD → 警示/熔断。 +/// false(回退):整段跳过,stall_count 永远 0,退 max_iterations 旧行为(可能继续游荡但行为不变, +/// 单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。 pub const STALL_BREAKER_ENABLED: bool = true; -/// G2 连续空结果阈值(默认 3)。 +/// G2 连续重复阈值(默认 3)。 /// -/// 评审一致建议 3 而非 5(对齐 CIRCUIT_BREAKER_THRESHOLD=3):空成功比硬失败更隐蔽, -/// 阈值应更激进。治实测连续 10+ 次空结果(提前 7 次熔断)。is_empty_tool_result 关键词 -/// 漏判风险由 threshold=3 + 两段式 WARN_FIRST 容错:多轮漏判才误熔断,最坏退 max_iterations 兜底。 +/// 对齐 CIRCUIT_BREAKER_THRESHOLD=3:连续 3 轮判定签名重复足以判漂移。新范式(签名重复)比 +/// 旧范式(结果空)更精准——签名重复是漂移的直接证据,无关键词漏判风险。阈值 3 在 +/// 两段式 WARN_FIRST(== THRESHOLD-1 即 2 轮先警示)容错下,误杀面收敛到「连续 3 轮同一组调用」 +/// 的真实死循环场景,最坏退 max_iterations 兜底。 pub const STALL_BREAKER_THRESHOLD: u32 = 3; /// G2 两段式开关:熔断前是否先警示一轮(默认 true)。 /// -/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 已连续 N 次空结果,可能偏离目标, -/// 请回顾目标换思路或停止」到下轮 system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。 -/// 降误杀:用户正当探索(如确认无相关文件)先警示再熔断。false(激进):直接熔断不警示, +/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 检测到重复的工具调用 ...」到下轮 +/// system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。 +/// 降误杀:偶发的相似调用(如分段读后回读同一处确认)先警示再熔断。false(激进):直接熔断不警示, /// 确认无误杀场景用。 pub const STALL_BREAKER_WARN_FIRST: bool = true; @@ -192,6 +198,12 @@ pub const STALL_BREAKER_WARN_FIRST: bool = true; /// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。 pub const STALL_BREAKER_GOAL_REMIND: bool = true; +/// G2 重复检测样本量:check_stall_breaker 扫历史 assistant tool_calls 取最近多少个签名喂判定。 +/// +/// 12 = 覆盖 2-3 轮多工具调用(单轮可并行多个 grep/read),既能让 is_repetitive_exploration 的 +/// 「最小样本 6」+「单点 3 次」阈值有意义,又不至于扫太长历史稀释近期漂移信号。 +pub const STALL_BREAKER_SAMPLE_SIZE: usize = 12; + /// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。 /// /// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试, @@ -285,7 +297,7 @@ pub mod command_lock; pub(crate) mod helpers; pub(crate) use helpers::try_continue_agent_loop; pub(crate) use helpers::infer_goal_from_tool_calls; -pub(crate) use helpers::is_empty_tool_result; +pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration}; // ============================================================ // 单 Provider 流式结果 + fallback 辅助 @@ -843,9 +855,12 @@ pub(crate) async fn run_agentic_loop( // 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。 let mut fail_window: std::collections::VecDeque = std::collections::VecDeque::new(); - // G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_window 同生命周期)。 - // 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1, - // 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。 + // G2 探索熔断:重复探索计数器(loop 生命周期累计,与 fail_window 同生命周期)。 + // 每轮 process_tool_calls 后 check_stall_breaker 取最近 N 个 assistant tool_calls 签名, + // 喂 is_repetitive_exploration 判重复,重复 stall_count+=1,非重复重置 0。 + // 达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。 + // 注:2026-08-01 从「末尾 Tool 结果是否空(is_empty_tool_result)」改为「调用签名重复」, + // 治旧范式误熔断正常排除式搜索(实证会话 ac448296)。 let mut stall_count: u32 = 0; let mut stall_warned: bool = false; @@ -1932,6 +1947,23 @@ fn summarize_tool_results( let mut compressed_bytes: usize = 0; let mut original_bytes: usize = 0; let mut summarized_count: usize = 0; + + // 构建 tool_call_id -> tool_name 映射:role=Tool 消息只带 tool_call_id(调用 ID), + // 真实工具名(read_file/run_command/...)在前序 role=Assistant 消息的 tool_calls[].function.name。 + // 旧实现误把 tool_call_id(如 call_f892)当 tool_name 传给 extract_key_info,导致: + // 1) 摘要头注释显示 call_f892 而非 read_file(显示 bug); + // 2) extract_key_info 的 read_file 豁免永远命中不了(传入的不是真实名)。 + // 用 owned String(非 &str 借用)避开与 messages.into_iter() 的所有权冲突; + // tool_call 数量极少(单轮几次),克隆开销可忽略。 + let mut id_to_name: std::collections::HashMap = std::collections::HashMap::new(); + for m in &messages { + if let Some(calls) = m.tool_calls.as_ref() { + for c in calls { + id_to_name.insert(c.id.clone(), c.function.name.clone()); + } + } + } + let msgs: Vec = messages .into_iter() .map(|mut m| { @@ -1943,9 +1975,25 @@ fn summarize_tool_results( if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) { return m; } + // 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。 + let resolved_name = m + .tool_call_id + .as_ref() + .and_then(|id| id_to_name.get(id)) + .map(|s| s.as_str()) + .unwrap_or("tool"); + // read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。 + // read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。 + if resolved_name == "read_file" { + tracing::debug!( + conv_id = %conv_id, iteration, + content_len, + "[ai] read_file 工具结果豁免压缩(AI 定向读代码)" + ); + return m; + } original_bytes += content_len; - let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string()); - let compressed = extract_key_info(&m.content, &tool_name); + let compressed = extract_key_info(&m.content, resolved_name); compressed_bytes += compressed.len(); summarized_count += 1; m.content = compressed; @@ -1988,7 +2036,13 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count // 返回 (tripped, new_stall_count, new_stall_warned): // - tripped=true → 已 emit AiHelpRequired 且 loop 应 return(调用方需先 guard.reset 再 return) // - tripped=false → 正常继续,新 stall_count/warned 供调用方回写。 -// 嵌套 ≤ 3 层:全空判→计数/警示→达阈值 emit,各段早返回。 +// +// 2026-08-01 根本性重构:判定从「末尾 Tool 结果是否空(is_empty_tool_result)」改为 +// 「最近 N 个 assistant tool_calls 签名是否重复(tool_call_signature + is_repetitive_exploration)」。 +// 治旧范式误熔断正常排除式搜索(grep 无匹配是有效排除信号,非漂移)。熔断骨架(计数/警示/ +// 阈值/emit/return)不变,只换判定输入。 +// +// 嵌套 ≤ 3 层:取签名→计数/警示→达阈值 emit,各段早返回。 async fn check_stall_breaker( session_arc: &Arc>, app_handle: &AppHandle, @@ -1997,31 +2051,49 @@ async fn check_stall_breaker( stall_count: u32, stall_warned: bool, ) -> (bool, u32, bool) { - // 1) 读取末尾连续 Tool 消息(本轮工具回填结果),判断全空或任一非空 - let all_empty: Option = { + // 1) 取最近 N 个 assistant tool_calls 签名:倒序扫 messages,遇 Assistant 且有 tool_calls + // 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。 + // arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。 + let signatures: Vec = { let session = session_arc.lock().await; let messages = match session.conv_read(conv_id) { Some(conv) => conv.messages.all_messages_clone(), None => Vec::new(), }; - let recent_tool_results: Vec<&ChatMessage> = messages - .iter().rev() - .take_while(|m| matches!(m.role, MessageRole::Tool)) - .collect(); - if recent_tool_results.is_empty() { - None // 本轮无工具回填(非工具轮)→ 不计入也不重置 - } else { - let any_non_empty = recent_tool_results - .iter().any(|m| !is_empty_tool_result(&m.content)); - Some(!any_non_empty) + let mut sigs: Vec = Vec::new(); + for m in messages.iter().rev() { + if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE { + break; + } + if !matches!(m.role, MessageRole::Assistant) { + continue; + } + if let Some(tool_calls) = m.tool_calls.as_ref() { + // 单 assistant 消息内多个 tool_call 按声明顺序取,倒序累积(近期在前)。 + for tc in tool_calls.iter().rev() { + if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE { + break; + } + let args: serde_json::Value = + serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null); + sigs.push(tool_call_signature(&tc.function.name, &args)); + } + } } + // 倒序收集的签名需反转为时间正序,保持 is_repetitive_exploration 的语义稳定 + // (虽纯函数对顺序不敏感,但反转后日志/调试更直观)。 + sigs.reverse(); + sigs }; - let all_empty_flag = match all_empty { Some(v) => v, None => return (false, stall_count, stall_warned) }; + // 本轮无工具调用(纯文本轮或首轮)→ 不计入也不重置,保持 stall_count。 + if signatures.is_empty() { + return (false, stall_count, stall_warned); + } - // 2) 更新 stall_count / stall_warned + // 2) 签名重复判定 → 更新 stall_count / stall_warned(沿用原 reset 语义) let (mut new_count, mut new_warned) = (stall_count, stall_warned); - if all_empty_flag { + if is_repetitive_exploration(&signatures) { new_count += 1; } else { new_count = 0; @@ -2044,13 +2116,14 @@ async fn check_stall_breaker( tracing::warn!( conv_id = %conv_id, stall_count = new_count, - "[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", new_count + sample_size = signatures.len(), + "[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count ); let _ = app_handle.emit( "ai-chat-event", AiChatEvent::AiHelpRequired { - reason: format!("连续 {} 次工具返回空结果无进展,疑似目标漂移已停止", new_count), - context: "连续探索无产出,可能偏离原始目标或无可用数据。".into(), + reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count), + context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(), options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], conversation_id: Some(conv_id.to_string()), }, @@ -2071,8 +2144,8 @@ async fn insert_stall_warning( String::new() }; let warn_text = format!( - "⚠ 已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。", - stall_count, goal_text + "⚠ 检测到重复的工具调用(同样的 grep/读文件反复),可能卡住{},请回顾目标换思路或停止探索。", + goal_text ); let mut session = session_arc.lock().await; if session.per_conv.contains_key(conv_id) { @@ -2080,7 +2153,7 @@ async fn insert_stall_warning( conv.messages.insert_at(0, ChatMessage::system(&warn_text)); tracing::info!( conv_id = %conv_id, iteration, stall_count, - "[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)" + "[ai] G2 探索熔断:重复调用警示已 insert(软提示,给 LLM 自纠机会)" ); } } @@ -2136,9 +2209,17 @@ fn push_assistant_message( let mut order: Vec = tool_calls_acc.keys().copied().collect(); order.sort_unstable(); let ai_tool_calls: Vec = order.iter() - .map(|i| { + .enumerate() + .map(|(pos, i)| { let draft = &tool_calls_acc[i]; - df_ai::provider::ToolCall::new(&draft.id, &draft.name, &draft.args) + // CR-空 id 最终防御:流式 chunk 解析点(openai_helpers)已对 Some("") 生成 + // fallback,但若 provider 流式完全不发 id 字段(chunk id=None,accumulate + // 不覆盖),draft.id 残留空串。此处按 sorted order 位置兜底 + // `gen_stream_{pos}`,保证 push 到 messages 的 assistant tool_call.id 非空唯一 + // (下游 audit/mod.rs:203 seen_ids 去重 + 工具结果按 tool_call_id 路由依赖此)。 + // 非空 id 原样(与 chunk 解析点 fallback 共用 helper,DRY)。 + let id = df_ai::provider::tool_call_id_or_fallback(&draft.id, pos, "gen_stream"); + df_ai::provider::ToolCall::new(id, &draft.name, &draft.args) }) .collect(); let mut msg = ChatMessage::assistant_with_tools(full_text, ai_tool_calls); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 455716f..94acc77 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -100,79 +100,47 @@ pub fn run() { *last = std::time::Instant::now(); } let mut session = session_arc.lock().await; - // F-260616-09 B 批8(设计 §3 batch8 + §5.2):遍历 per_conv(HashMap)清多 conv - // 残留 generating(HMR/dev 热载场景多 conv 并发跑 loop 致多 conv 卡 generating)。 - // 批4:per_conv 唯一真相源,删顶层 session.generating 双写复位(顶层字段已退役)。 - // 读点(B-Phase2):活跃 conv 列表改读无锁 conv_states(零锁竞争);不再 iter per_conv - // 判 conv_state.is_active()。 - // BUG-2026-07-08: L0 握手清理 dirty conv 前,必须先 stop 旧 loop。 - // 原逻辑直接把 ConvState 改 Idle,但后台 run_agentic_loop 仍在跑(等 LLM 响应)。 - // 改 Idle 后用户重发 → can_accept_request 放行 → 新旧两个 loop 同操作一个 conv → - // 消息覆盖/guard 冲突/generating 紊乱。 - // 修复:先设 stop_flag + notify_one()(旧 loop 的 stream_llm select! 即时唤醒) - // + 再改 ConvState。notify_one 让阻塞在 stream.next() 的 loop 即时检查 stop_flag 退出。 let app_state_ref = app_h.state::(); + // 职责分离(根治 BUG-2026-07-08 误杀活 loop): + // 生产环境前端 WebSocket 重连 / webview reload 会触发 ai-client-ready(非用户重发), + // 旧握手无脑强杀正在跑的 agentic loop(stop_flag + transition Idle + emit AiCompleted) + // → AI 中断不回复 → 用户「莫名其妙停止」(实证 cf5f44ed:seq26 后 loop 等下次 LLM 被杀)。 + // + // 三道防线已就绪,握手无需也不应再强杀 loop: + // 1. guard.rs GeneratingGuard RAII:new→Generating,reset→Idle(正常),Drop→Idle(panic 兜底)。 + // loop 自己复位 generating,握手强杀是多余且有害的旁路。 + // 2. conv_state.rs can_accept_request 只 Idle|Error 可接 → generating 时 ai_chat_send + // 拒新消息 → 防「用户重发双 loop」(原 BUG-2026-07-08 的真实诉求)。 + // 3. max_iterations 超时兜底 loop 卡死。 + // + // 故握手职责收窄为「同步真实状态给前端」:读 conv_states 当前状态逐个 emit, + // 不改写 conv_states、不设 stop_flag、不收尾。loop 在跑则前端拿到 Generating, + // loop 已退出(guard 已复位)则拿到 Idle —— 前端状态与后端一致即可。 let dirty_convs: Vec = app_state_ref.conv_states.active_convs(); let was_generating = !dirty_convs.is_empty(); - // 先对每个 dirty conv 设 stop_flag + notify 让旧 loop 退出(防双 loop 并发)。 - for cid in &dirty_convs { - if let Some(c) = session.per_conv.get_mut(cid) { - c.stop_flag.store(true, std::sync::atomic::Ordering::SeqCst); - c.notify.notify_one(); - } - } - // 复位每个残留生成态的 conv(批8 多 conv 全覆盖)。 - // B-Phase3:conv_state 写切 ConvStateStore 单源。 - for cid in &dirty_convs { - if let Err(e) = app_state_ref.conv_states.transition( - cid, - crate::commands::ai::agentic::conv_state::ConvState::Idle, - ) { - tracing::warn!( - conv_id = %cid, - error = %e, - "[ai] HMR 热载 conv_states→Idle 非法(不阻断热载)" - ); - } - } - // 对话透明化 L1:收集每个 dirty conv 的 pinned_goals 快照(供 emit AiCompleted 携带) - let pinned_goals_map: std::collections::HashMap> = dirty_convs - .iter() - .filter_map(|cid| { - session.per_conv.get(cid).map(|c| (cid.clone(), c.pinned_goals.clone())) - }) - .collect(); // BUG-260619-06 修复: clear 致冷启动 restore 重建审批丢失(restore 填充后 clear 无条件清空, // 重启后待审批工具全丢)。改 retain 仅清非 recovered(本次会话/HMR 死 pending), // 保留 restore 重建(recovered=true,audit.rs:331),对齐 switchConversation retain 保护意图。 // 阶段3a 单真相源合并:单表按 !recovered retain(kind 不区分,path 审批恢复恒无)。 session.pending_approvals.retain(|_, a| !a.recovered); - // 在 drop(session) 前快照 active_conversation_id,供下方 idle emit 用。 + // 握手需推状态的 conv 集合:dirty conv(后端已知活跃)+ 当前 active_conv(用户正看的)。 + // dirty_convs 已含活跃 conv;active_conv 若非活跃(Idling/Eror)也补推一次让前端同步。 let active_conv = session.active_conversation_id.clone(); - drop(session); - // 根治:不论是否有 dirty conv,HMR 重连后始终向前端推 idle 事件, - // 防前端残留 streaming=true 导致发送按钮变停止、消息排队不发。 - // dirty conv 走 AiCompleted 收尾路由(idle conv 无历史需收尾无需气泡)。 - for cid in &dirty_convs { - let _ = app_h.emit( - "ai-chat-event", - commands::ai::AiChatEvent::AiCompleted { - total_tokens: 0, - prompt_tokens: 0, - completion_tokens: 0, - incomplete: None, - conversation_id: Some(cid.clone()), - pinned_goals: pinned_goals_map.get(cid).cloned().unwrap_or_default(), - }, - ); - } - // 始终推 AiConvStateChanged{idle} 通知前端同步(即使后端已是 idle)。 - // 前端 handleConvStateEvent → setConvState(convId, 'idle') → convStates 删项。 + let mut convs_to_sync: Vec = dirty_convs.clone(); if let Some(ref cid) = active_conv { + if !convs_to_sync.iter().any(|c| c == cid) { + convs_to_sync.push(cid.clone()); + } + } + drop(session); + // 逐个 conv 读 conv_states 真实状态推给前端(loop 在跑推 Generating/Compressed, + // 已退出推 Idle)。ConvStateStore.get 同步无锁,不竞争 session lock。 + for cid in &convs_to_sync { + let real_state = app_state_ref.conv_states.get(cid); let _ = app_h.emit( "ai-chat-event", commands::ai::AiChatEvent::AiConvStateChanged { - conv_state: crate::commands::ai::agentic::conv_state::ConvState::Idle, + conv_state: real_state, conversation_id: Some(cid.clone()), }, );