From 6a50193b344b31dc663718205c3fd7f4ac283eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 5 Oct 2026 00:35:04 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E7=8B=97=E7=B2=AE?= =?UTF-8?q?=E7=9F=AD=E6=9D=BF=E6=94=B6=E5=8F=A3=E4=B8=8Epatch=5Ffile?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - patch_file old_text 精确未中时空白归一化回退(行首缩进归一+多空白折叠,唯一命中按整行区间替换,warning 注明) - patch_file anchor end 唯一性校验:多处命中报错并列前两处位置,治实测×2 锚点错位;三模式同传按优先级仲裁(anchor>old_text>replace_lines),warning 注明 - tool_result 压缩 memoization:PerConvState 加 tool_compress_cache(内容哈希→压缩结果),主 loop+子代理双调用点接线,治 369 组同内容反复重压 - 标题采样改两段式:user 首条必进锚定主题+其余按序补足 6 条,治 assistant 首句被当主题 - prompt 注入文件读取纪律段(父会话+子代理同源):read_file 分页/search 直读,禁 run_command 切片读文件;read_file 工具描述与 truncated 提示补大 limit 建议 - 顺带 DRY#1:agentic/mod.rs 三处裸 emit 换正典双写(心跳/破坏性回环熔断/stall 熔断),miniapp 跨端盲区补齐 --- src-tauri/src/commands/ai/agentic/mod.rs | 34 ++- .../src/commands/ai/agentic/stall_detector.rs | 14 +- .../src/commands/ai/agentic/sub_agent.rs | 14 +- src-tauri/src/commands/ai/mod.rs | 8 + src-tauri/src/commands/ai/prompt.rs | 16 ++ src-tauri/src/commands/ai/title.rs | 23 +- src-tauri/src/commands/ai/tool_registry.rs | 88 ++++-- src-tauri/src/commands/ai/tools/file.rs | 262 +++++++++++++++--- 8 files changed, 380 insertions(+), 79 deletions(-) diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 838a69a..5ecfffd 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -1668,20 +1668,24 @@ async fn prepare_round_inputs( // 压缩决策经 per_conv.tool_view_decisions 冻结:首次判定后沿用,不再随阈值翻转 // (防同一条历史跨轮在全文/摘要间翻转致前缀断)。写回仅在 conv 存活时做(防惰性建)。 let messages: Vec = if TOOL_RESULT_COMPRESS_ENABLED { - let (history_tokens_snapshot, mut view_decisions) = { + let (history_tokens_snapshot, mut view_decisions, mut compress_cache) = { let session = session_arc.lock().await; let conv = session.conv_read(conv_id); ( conv.map(|c| c.messages.history_tokens()).unwrap_or(0), conv.map(|c| c.tool_view_decisions.clone()).unwrap_or_default(), + conv.map(|c| c.tool_compress_cache.clone()).unwrap_or_default(), ) }; - let messages = - summarize_tool_results(messages, history_tokens_snapshot, &mut view_decisions, conv_id, iteration); - if !view_decisions.is_empty() { + let messages = summarize_tool_results( + messages, history_tokens_snapshot, &mut view_decisions, &mut compress_cache, conv_id, iteration, + ); + if !view_decisions.is_empty() || !compress_cache.is_empty() { let mut session = session_arc.lock().await; if session.per_conv.contains_key(conv_id) { - session.conv(conv_id).tool_view_decisions = view_decisions; + let conv = session.conv(conv_id); + conv.tool_view_decisions = view_decisions; + conv.tool_compress_cache = compress_cache; } } messages @@ -2068,8 +2072,9 @@ async fn heartbeat_loop( _ = heartbeat_rx.changed() => break, _ = tokio::time::sleep(std::time::Duration::from_secs(20)) => { last_heartbeat.store(df_types::now_millis() as u64, std::sync::atomic::Ordering::SeqCst); - let _ = app.emit( - "ai-chat-event", + // DRY #1:双写正典(miniapp 端靠心跳判活,只 emit 端看不到) + emit_ai_chat_event( + app, AiChatEvent::AiHeartbeat { conversation_id: Some(conv_id.to_string()) }, ); } @@ -2144,7 +2149,8 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count conversation_id: Some(conv_id.to_string()), } }; - let _ = app_handle.emit("ai-chat-event", ev); + // DRY #1(2026-10-04):裸 emit 换正典双写——原只 emit 未 publish,miniapp 端收不到熔断事件。 + emit_ai_chat_event(app_handle, ev); // P1 bg 化:熔断需人工介入,系统通知补位(条件发送,失败静默) crate::commands::ai::notify::notify_session_event( app_handle, @@ -2277,8 +2283,9 @@ async fn check_stall_breaker( "[ai] G2 破坏性回环熔断:连续 {} 轮检测到同路径写删回环,疑似自毁循环停止", new_loop_count ); let path_desc = loop_hit_path.as_deref().unwrap_or("未知路径"); - let _ = app_handle.emit( - "ai-chat-event", + // DRY #1:双写正典(熔断求助卡跨端可见) + emit_ai_chat_event( + app_handle, AiChatEvent::AiHelpRequired { reason: format!("连续 {} 轮检测到对同一文件({})反复写删,疑似「写完就删」自毁循环已停止", new_loop_count, path_desc), context: "写文件后又删除同一文件的紧凑交替,可能在反复销毁自己的工作成果而无进展。".into(), @@ -2307,8 +2314,9 @@ async fn check_stall_breaker( sample_size = signatures.len(), "[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count ); - let _ = app_handle.emit( - "ai-chat-event", + // DRY #1:双写正典(熔断求助卡跨端可见) + emit_ai_chat_event( + app_handle, AiChatEvent::AiHelpRequired { reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count), context: "工具调用签名高度重复,可能在反复做同一件事而无进展。".into(), @@ -2572,6 +2580,8 @@ pub(crate) async fn emit_ai_completed_once( emit_ai_chat_event(app_handle, ev); return; } + // 有意不走 emit_ai_chat_event(emit/publish 两端 incomplete 标志分叉,见上注释)—— + // 此分支语义就是单路 emit 不 publish,裸写是正确形态,勿"收口"。 let _ = app_handle.emit("ai-chat-event", ev); if do_publish { let _ = app_handle.state::().ai_event_bus.publish_event( diff --git a/src-tauri/src/commands/ai/agentic/stall_detector.rs b/src-tauri/src/commands/ai/agentic/stall_detector.rs index 84a5c88..8a2d671 100644 --- a/src-tauri/src/commands/ai/agentic/stall_detector.rs +++ b/src-tauri/src/commands/ai/agentic/stall_detector.rs @@ -279,6 +279,7 @@ pub(crate) fn summarize_tool_results( messages: Vec, history_tokens_snapshot: u32, view_decisions: &mut std::collections::HashMap, + compress_cache: &mut std::collections::HashMap, conv_id: &str, iteration: usize, ) -> Vec { @@ -333,7 +334,18 @@ pub(crate) fn summarize_tool_results( return m; } original_bytes += content_len; - let compressed = extract_key_info(&m.content, resolved_name); + // memoization(狗粮实证 369 组同内容反复重压):压缩是确定性纯函数, + // 同内容哈希命中直接复用缓存压缩结果,跳过重扫描。键与 view_decisions + // 同款 tool_view_decision_key(content),tool_name 差异(豁免 read_file) + // 已在上方分支处理,进到此处的都可压缩,故键不含 tool_name。 + let compressed = match compress_cache.get(&key) { + Some(cached) => cached.clone(), + None => { + let c = extract_key_info(&m.content, resolved_name); + compress_cache.insert(key, c.clone()); + c + } + }; compressed_bytes += compressed.len(); summarized_count += 1; m.content = compressed; diff --git a/src-tauri/src/commands/ai/agentic/sub_agent.rs b/src-tauri/src/commands/ai/agentic/sub_agent.rs index 3778e6e..0485fbe 100644 --- a/src-tauri/src/commands/ai/agentic/sub_agent.rs +++ b/src-tauri/src/commands/ai/agentic/sub_agent.rs @@ -746,11 +746,17 @@ async fn run_sub_agent_loop_inner( msgs.extend(history_msgs); let history_tokens = conv.messages.history_tokens(); // tool_result 大输出 view-only 摘要 + 占位配对断言(与主 loop 同款防线)。 - // 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转。 + // 压缩决策同主 loop 冻结在 per_conv(子会话条目),跨轮不随阈值翻转; + // 压缩结果 memoization 同主 loop(同内容哈希跳过重压)。 let mut view_decisions = conv.tool_view_decisions.clone(); - let msgs = summarize_tool_results(msgs, history_tokens, &mut view_decisions, &child_conv_id, round as usize); - if !view_decisions.is_empty() { - session.conv(&child_conv_id).tool_view_decisions = view_decisions; + let mut compress_cache = conv.tool_compress_cache.clone(); + let msgs = summarize_tool_results( + msgs, history_tokens, &mut view_decisions, &mut compress_cache, &child_conv_id, round as usize, + ); + if !view_decisions.is_empty() || !compress_cache.is_empty() { + let conv = session.conv(&child_conv_id); + conv.tool_view_decisions = view_decisions; + conv.tool_compress_cache = compress_cache; } let msgs = ContextManager::assert_placeholder_pairing(msgs, PLACEHOLDER_INTEGRITY_ENABLED); let est = TokenEstimator::default(); diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index af83f5d..51a2627 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -1073,6 +1073,13 @@ pub struct PerConvState { /// 仍为 view-only(不落库改历史,仅影响发送视图);键用内容哈希而非位置(裁剪/压缩后 /// 位置漂移,内容不变)。随会话销毁不落库。 pub tool_view_decisions: std::collections::HashMap, + /// tool_result 压缩结果 memoization(content 稳定哈希 → 压缩后文本)。 + /// + /// 狗粮实证(2026-10-04):view-only 压缩 2280 次中 369 组同内容反复重压(最坏 54 次), + /// 纯 CPU 纯函数但大内容重复扫描浪费。压缩是确定性的(content,tool_name)→ 输出, + /// 同内容哈希命中直接复用缓存结果,跳过重算。与 tool_view_decisions 同为内存态 + /// 不落库,随会话销毁;false 决策(未压缩)不进缓存(原样返回无结果可记)。 + pub tool_compress_cache: std::collections::HashMap, /// 无人值守任务级授权边界(设计 §二):发起时一次声明,存续期内边界内零审批。 /// None=未声明(现状逐次审批零倒退)。内存真相源在 per_conv,落库随行 /// (ai_conversations.boundary_json,save 写/switch 读还原),随会话同亡。 @@ -1229,6 +1236,7 @@ impl PerConvState { workflow_dag_summary: None, frozen_tool_names: None, tool_view_decisions: std::collections::HashMap::new(), + tool_compress_cache: std::collections::HashMap::new(), boundary: None, readonly_cache_floor: 0, } diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 40bc197..c9b8810 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -106,6 +106,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\ - **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\ - {}\n\ + - {}\n\ ## Focus\n\ - Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\ - When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\ @@ -113,6 +114,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - Do not proactively expand context (files/data) that is irrelevant to the current request.\n", read_symbol_guidance(lang), duplicate_call_guidance(lang), + file_reading_guidance(lang), ), "\n## Current Projects\n", "\n## Current Tasks\n", @@ -135,6 +137,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - 优先使用工具完成操作,而不是只描述步骤\n\ - 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\ - {}\n\ + - {}\n\ ## 聚焦准则\n\ - 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\ - 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\ @@ -142,6 +145,7 @@ fn system_prompt_parts(lang: &str) -> (String, &'static str, &'static str) { - 需要的上下文(文件/数据)若与当前请求无关,不要主动展开。\n", read_symbol_guidance(lang), duplicate_call_guidance(lang), + file_reading_guidance(lang), ), "\n## 当前项目\n", "\n## 当前任务\n", @@ -193,6 +197,16 @@ fn duplicate_call_guidance(lang: &str) -> &'static str { } } +/// 文件读取纪律指引(中/英,不含行首 "- ")。狗粮实证(2026-10-04):67+ 次 run_command +/// PowerShell/python 切片绕道读文件,而 read_file 本身支持 offset/limit 分页+search 参数。 +/// 父会话能力清单与子代理工具纪律段同源拼接(同 read_symbol_guidance 模式)。 +fn file_reading_guidance(lang: &str) -> &'static str { + match lang { + "en" => "**File reading discipline**: read_file supports offset/limit pagination and the search parameter (search matching lines inside a file) — use them to read large files in pages or locate content directly. Never use run_command (PowerShell/python scripts) to slice or read files — it is slower, bypasses path authorization and binary detection, and pollutes context; run_command is for running tests/builds/binaries only", + _ => "**文件读取纪律**:read_file 自带 offset/limit 分页与 search 参数(文件内容行级搜索)——大文件用它分页续读、定位内容直接传 search。严禁用 run_command(PowerShell/python 脚本)切片或读取文件——更慢、绕过路径授权与二进制检测、且污染上下文;run_command 仅用于跑测试/构建/二进制等命令执行场景", + } +} + /// 子代理工具纪律段(P1-1):与父会话 system prompt 同源拼接的工具使用纪律 —— /// read_symbol 优先 / 重复调用检测 / URL 三工具纪律与禁臆造 URL / render=true 失败 /// 2 次必换方案 / 查配置用 get_app_config。子代理白名单缺省下发全量 Low 工具, @@ -202,10 +216,12 @@ fn duplicate_call_guidance(lang: &str) -> &'static str { pub(crate) fn sub_agent_tool_discipline_section(lang: &str) -> String { format!( "\n## 工具纪律\n\ + - {}\n\ - {}\n\ - {}\n{}{}", read_symbol_guidance(lang), duplicate_call_guidance(lang), + file_reading_guidance(lang), doc_strategy_core_section(lang), app_config_query_guidance_section(lang), ) diff --git a/src-tauri/src/commands/ai/title.rs b/src-tauri/src/commands/ai/title.rs index 0a84421..af5dbc5 100644 --- a/src-tauri/src/commands/ai/title.rs +++ b/src-tauri/src/commands/ai/title.rs @@ -46,16 +46,33 @@ pub(crate) async fn ensure_conversation_title( } } - // 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结 + // 取对话文本(仅 user/assistant,跳过 tool 噪音),取前 6 条供 LLM 总结。 + // 狗粮实证(2026-10-04):混合 take(6) 在 assistant 消息靠前场景(重提/续接)会把 user 首条挤出, + // LLM 拿 assistant 首句当主题生成标题(如「信息已足够。让我看一眼...」)。改两段式: + // user 首条必进(锚定主题),其余按序补足 6 条。 // F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源:本函数入参)。 // conv() 惰性建:switch 触发的 spawn_ensure_title 前序 switch 已 conv();若极端未建, // conv() 建空 PerConvState → summary_msgs 空 → 下方 return(无素材不生成标题)。 let (summary_msgs, all_msgs) = { let mut session = session_arc.lock().await; let conv = session.conv(conv_id); - let summary: Vec = conv.messages.iter() + let sample: Vec<_> = conv.messages.iter() .filter(|m| matches!(m.role, MessageRole::User | MessageRole::Assistant)) - .take(6) + .collect(); + // user 首条下标(无 user 消息则退回纯顺序 take(6),空素材由下方 return 兜住) + let first_user_idx = sample.iter().position(|m| matches!(m.role, MessageRole::User)); + let mut picked: Vec = Vec::with_capacity(6); + if let Some(i) = first_user_idx { + picked.push(i); + } + for (i, _) in sample.iter().enumerate() { + if picked.len() >= 6 { break; } + if picked.contains(&i) { continue; } + picked.push(i); + } + picked.sort_unstable(); + let summary: Vec = picked.into_iter() + .filter_map(|i| sample.get(i)) .map(|m| ChatMessage { id: None, // 标题摘要派生消息:不落库不溯源,无需分配 ID(对齐老消息 None 语义) role: m.role.clone(), diff --git a/src-tauri/src/commands/ai/tool_registry.rs b/src-tauri/src/commands/ai/tool_registry.rs index d9d4c3b..5a12e5c 100644 --- a/src-tauri/src/commands/ai/tool_registry.rs +++ b/src-tauri/src/commands/ai/tool_registry.rs @@ -187,10 +187,12 @@ pub(crate) fn truncate_output(s: &str, max: usize) -> (String, bool) { // ============================================================ // patch_file 三模式共用底层 // -// 三模式互斥(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点), +// 三模式定位(old_text 精确匹配 / replace_lines 行号区间 / anchor 锚点), // 后两者均归约为「按行号区间 [start, end](1-based 含首尾)替换为 new_text」, -// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间。 -// 提取为纯函数便于直接单测(不依赖 async/fs/锁),handler 闭包内复用。 +// 由 apply_line_range 统一执行 splice。锚点经 resolve_anchor_to_lines 转行号区间 +// (end 子串须唯一命中,多处命中报错)。多模式同时传入由 handler 层按优先级仲裁 +// (见 tools/file.rs arbitrate_patch_mode)。提取为纯函数便于直接单测(不依赖 async/fs/锁), +// handler 闭包内复用。 // ============================================================ /// 按行号区间替换:将 content 第 [start, end] 行(1-based, 含首尾)替换为 new_text。 @@ -242,11 +244,14 @@ pub(crate) fn apply_line_range(content: &str, start: usize, end: usize, new_text Ok(out) } -/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后第一个含 `end` 子串的行。 +/// 锚点解析为行号区间:在 content 中找含 `start` 子串的首行 → 其后含 `end` 子串的行。 /// /// - 子串匹配(大小写敏感,与 search_files 一致)。 /// - start/end 任一找不到返 Err;start 行号 > end 行号返 Err(即 end 必须在 start 之后或同行)。 /// - 同一行同时命中 start/end:start==end,区间退化为单行替换。 +/// - end 唯一性校验(治实测 ×2 多处命中替换错位):end 子串在 start 行(含)之后多处命中 +/// 时返 Err,列出前两处命中行上下文,提示缩小 end 锚点或改用 old_text 模式。 +/// 旧实现静默取第一个命中行,LLM 意图往往是更靠后的行,错位后返工。 /// - 返回 (start_line, end_line)(1-based 含首尾)。 pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> anyhow::Result<(usize, usize)> { if start.is_empty() { @@ -265,22 +270,44 @@ pub(crate) fn resolve_anchor_to_lines(content: &str, start: &str, end: &str) -> let start_line = start_line .ok_or_else(|| anyhow::anyhow!("anchor.start 子串「{}」未在文件中找到", start))?; - // end 在 start_line 之后(含 start_line 同行)第一个命中的行 - let mut end_line: Option = None; - for (i, line) in content.lines().enumerate() { - let lineno = i + 1; - if lineno < start_line { - continue; + // end 在 start_line 之后(含 start_line 同行)收集全部命中行,唯一才放行 + let all_lines: Vec<&str> = content.lines().collect(); + let end_hits: Vec = all_lines + .iter() + .enumerate() + .filter(|(i, line)| i + 1 >= start_line && line.contains(end)) + .map(|(i, _)| i + 1) + .collect(); + let end_line = match end_hits.len() { + 0 => anyhow::bail!( + "anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到", + end, start_line + ), + 1 => end_hits[0], + _ => { + // 列出前两处命中行原文(截断防超长行刷屏),供 LLM 对照改锚 + let ctx = end_hits + .iter() + .take(2) + .map(|&ln| { + let raw = all_lines[ln - 1]; + let shown: String = if raw.chars().count() > 80 { + raw.chars().take(80).collect::() + "…" + } else { + raw.to_string() + }; + format!("第 {} 行: {}", ln, shown) + }) + .collect::>() + .join("\n"); + anyhow::bail!( + "anchor.end 子串「{}」在 start 行(第 {} 行)之后命中 {} 处,定位歧义。前两处:\n{}\n请缩小 end 锚点(加长子串使其唯一)或改用 old_text 模式", + end, start_line, end_hits.len(), ctx + ) } - if line.contains(end) { - end_line = Some(lineno); - break; - } - } - let end_line = end_line - .ok_or_else(|| anyhow::anyhow!("anchor.end 子串「{}」未在 start 行(第 {} 行)之后找到", end, start_line))?; + }; - // start_line <= end_line 已由搜索顺序保证,此处断言兜底 + // start_line <= end_line 已由命中过滤条件保证,此处断言兜底 debug_assert!(start_line <= end_line, "anchor 解析顺序异常: start={} end={}", start_line, end_line); Ok((start_line, end_line)) } @@ -1995,12 +2022,12 @@ mod tests { assert_eq!((s, e), (1, 4)); } - /// resolve_anchor_to_lines: 子串匹配会在 start 之后命中第一个含该子串的行 - /// (含行内出现,如 println!("{}") 内的 })——锁定此行为语义,避免后续误改成「整行相等」 + /// resolve_anchor_to_lines: 子串匹配含行内出现(如 println!("{}") 内的 })—— + /// end 唯一命中时放行,锁定「子串包含」语义(非「整行相等」) #[test] fn test_resolve_anchor_substring_matches_inside_line() { - // end 锚 "}" 在 start 行之后第一个命中 = 第 3 行 println!("{}") 的 },而非第 4 行的 } - let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\n}\n"; + // end 锚 "}" 唯一命中第 3 行 println!("{}") 的 }(第 4 行换成无 } 的行) + let content = "fn foo() {\n let x = 1;\n println!(\"{}\");\nEND\n"; let (s, e) = resolve_anchor_to_lines(content, "fn foo", "}").unwrap(); assert_eq!((s, e), (1, 3)); } @@ -2014,13 +2041,20 @@ mod tests { assert_eq!((s, e), (1, 1)); } - /// resolve_anchor_to_lines: end 在 start 之后第一个命中行(跳过中间同子串) + /// resolve_anchor_to_lines: end 子串多处命中 → Err 列前两处命中行 + 修复提示 + /// (契约变更:旧实现静默取第一个命中行,实测 ×2 LLM 短 end 锚点多处命中替换错位, + /// 根修改为唯一性校验——多处命中报错,提示缩小 end 锚点或改用 old_text) #[test] - fn test_resolve_anchor_picks_first_end_after_start() { - // 第 2、4 行都含 "}",应取 start(第1行) 之后第一个 "}" → 第 2 行 + fn test_resolve_anchor_end_multi_hit_rejected() { + // 第 2、4 行都含 "}",多处命中 → Err let content = "START\n}\nx\n}"; - let (s, e) = resolve_anchor_to_lines(content, "START", "}").unwrap(); - assert_eq!((s, e), (1, 2)); + let err = resolve_anchor_to_lines(content, "START", "}").unwrap_err(); + let msg = format!("{}", err); + assert!(msg.contains("命中 2 处"), "应报多处命中数,实际: {msg}"); + assert!(msg.contains("第 2 行"), "应列第一处命中行,实际: {msg}"); + assert!(msg.contains("第 4 行"), "应列第二处命中行,实际: {msg}"); + assert!(msg.contains("缩小 end 锚点"), "应含缩小锚点提示,实际: {msg}"); + assert!(msg.contains("old_text"), "应含改用 old_text 建议,实际: {msg}"); } /// resolve_anchor_to_lines: 大小写敏感(与 search_files 一致) diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index 926490c..dc4078a 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -71,7 +71,7 @@ pub fn register( registry, allowed_dirs: Arc>, "read_file", - "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)", + "读取文件内容,返回文本内容。支持 offset/limit 分页;传入 search 则在文件内容中搜索匹配行(大小写敏感,字符串包含匹配),返回 matches 数组(限50条)。仅需函数/类型符号的签名或定义体时优先用 read_symbol(骨架/全文,避免 prompt 爆)。遍历大文件用大 limit(300 左右)分段读,小页(15-40 行)遍历会成倍放大调用轮次", RiskLevel::Low, meta: ToolMeta { path_args: &["path"], @@ -181,7 +181,7 @@ pub fn register( let consumed = offset_used.unwrap_or(0) + returned_lines; let truncated_info = if has_more { Some(format!( - "文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={} 参数(可配合 limit={limit})", + "文件共 {line_count} 行,本次返回 {returned_lines} 行(offset={}, limit={limit}),剩余 {} 行未读。继续读取请用 offset={}(建议 limit 300,大段遍历远快于小页;精读已定位片段再用小 limit;仅需函数签名用 read_symbol)", offset_used.map(|o| o.to_string()).unwrap_or_else(|| "0".to_string()), line_count - consumed, consumed, @@ -448,10 +448,10 @@ pub fn register( let mut props = serde_json::Map::new(); props.insert("path".into(), serde_json::json!({ "type": "string", "description": "目标文件路径(必填)" })); props.insert("new_text".into(), serde_json::json!({ "type": "string", "description": "替换后的新内容(三模式通用,必填)" })); - props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一互斥" })); + props.insert("old_text".into(), serde_json::json!({ "type": "string", "description": "模式1 精确匹配:要替换的原文(必须精确匹配含空格/缩进),三选一。精确未中时自动按空白归一化重试(唯一命中即按整行区间替换)" })); props.insert("replace_lines".into(), serde_json::json!({ "type": "object", - "description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一互斥。配 expected_hash 防并发行号漂移", + "description": "模式2 行号区间:{ start: 1-based 起始行(含), end: 1-based 结束行(含) },三选一。配 expected_hash 防并发行号漂移", "properties": { "start": { "type": "integer", "description": "起始行号(1-based,含)" }, "end": { "type": "integer", "description": "结束行号(1-based,含)" } @@ -460,10 +460,10 @@ pub fn register( })); props.insert("anchor".into(), serde_json::json!({ "type": "object", - "description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一互斥。不需完整原文", + "description": "模式3 锚点:{ start: 首行子串标记, end: 尾行子串标记 },内部定位首尾行号转区间替换,三选一。不需完整原文", "properties": { "start": { "type": "string", "description": "首行子串标记(大小写敏感)" }, - "end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后)" } + "end": { "type": "string", "description": "尾行子串标记(大小写敏感,在 start 行之后,须唯一命中;多处命中会报错并列出命中位置)" } }, "required": ["start", "end"] })); @@ -479,7 +479,7 @@ pub fn register( registry, allowed_dirs: Arc>, "patch_file", - "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。", + "局部更新文件内容(三模式定位)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义);精确未中时自动按空白归一化(行首缩进归一+多空白折叠)重试,唯一命中即按整行区间替换并在 warning 注明「空白归一化命中」。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文;end 锚点须唯一命中,多处命中报错并列出前两处位置(届时缩小 end 子串或改用 old_text)。三个定位参数同时传入时按优先级 anchor > old_text > replace_lines 仲裁取一执行并在 warning 注明,全缺才报错。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。", RiskLevel::Medium, meta: ToolMeta { path_args: &["path"], @@ -500,8 +500,11 @@ pub fn register( let new_text = args["new_text"].as_str() .ok_or_else(|| anyhow::anyhow!("缺少 new_text 参数"))?; - // 三模式互斥校验(old_text / replace_lines / anchor) - // 三选一:统计传入的模式参数数,>1 报错,0 报错(缺定位方式) + // 三模式定位(old_text / replace_lines / anchor) + // 旧实现:三选一互斥,>1 直接拒绝。实测 LLM 反复同时传 old_text+anchor(×3) + // 被拒后重试仍传双参,空耗往返。改:多模式按优先级仲裁取一执行 + // (anchor > old_text > replace_lines,见 arbitrate_patch_mode), + // result.warning 注明仲裁结果;全缺时才报参数缺失。 let has_old_text = args.get("old_text").map(|v| !v.is_null()).unwrap_or(false); let has_replace_lines = args.get("replace_lines").map(|v| !v.is_null()).unwrap_or(false); let has_anchor = args.get("anchor").map(|v| !v.is_null()).unwrap_or(false); @@ -509,12 +512,8 @@ pub fn register( if mode_count == 0 { anyhow::bail!("缺少定位方式:必须提供 old_text / replace_lines / anchor 之一"); } - if mode_count > 1 { - anyhow::bail!( - "模式互斥冲突:old_text / replace_lines / anchor 仅可传一个(检测到 {} 个)", - mode_count - ); - } + let (use_anchor, use_old_text, use_replace_lines, mode_arbitration) = + arbitrate_patch_mode(has_anchor, has_old_text, has_replace_lines); // 文件存在性 / 大小限制(三模式通用,先于内容读取) let target = std::path::Path::new(path); @@ -572,7 +571,7 @@ pub fn register( let match_count: usize; let warning: Option; - if has_old_text { + if use_old_text { let old_text = args["old_text"].as_str() .ok_or_else(|| anyhow::anyhow!("old_text 必须为字符串"))?; if old_text.is_empty() { @@ -583,11 +582,26 @@ pub fn register( "path": path, "changed": false, "warning": "new_text 与 old_text 相同,无实际更改" })); } - // L2: old_text 精确匹配(CAS 语义) - // 失败时附相近片段提示把文件里与 old_text 相似的几行列给 LLM, - // 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则 - // 退回原提示,不误导。 - if !content.contains(old_text) { + // L2: old_text 精确匹配(CAS 语义)→ 空白归一化回退 → 相近片段诊断。 + // 实测 ×15 失败是 LLM 缩进/空白对不齐:精确未中时按「行首缩进归一+多空白 + // 折叠」归一化重匹配,唯一命中自动采用(治根),多命中/零命中维持原报错, + // 附相近片段供 LLM 对照真实缩进修正(AC-5)。 + if content.contains(old_text) { + let mc = content.matches(old_text).count(); + match_count = mc; + warning = if mc > 1 { + Some(format!("匹配到 {} 处,仅替换第 1 处", mc)) + } else { None }; + new_content = content.replacen(old_text, new_text, 1); + } else if let Some((sl, el)) = find_whitespace_normalized_match(&content, old_text) { + // 归一化唯一命中:按整行区间替换(old_text 归一化行与目标行全等, + // 首尾行无需保留的行内其他内容)。warning 注明命中方式,LLM 可知情复核。 + new_content = apply_line_range(&content, sl, el, new_text)?; + match_count = 1; + warning = Some(format!( + "空白归一化命中(精确匹配未中,第 {sl}-{el} 行已按整行区间替换);建议重读文件核对实际缩进" + )); + } else { let similar = similar_line_fragments(&content, old_text, 3); if similar.is_empty() { anyhow::bail!("未找到目标文本,文件可能已被修改"); @@ -597,13 +611,7 @@ pub fn register( similar.join("\n") ); } - let mc = content.matches(old_text).count(); - match_count = mc; - warning = if mc > 1 { - Some(format!("匹配到 {} 处,仅替换第 1 处", mc)) - } else { None }; - new_content = content.replacen(old_text, new_text, 1); - } else if has_replace_lines { + } else if use_replace_lines { let rl = args.get("replace_lines") .ok_or_else(|| anyhow::anyhow!("缺少 replace_lines 参数"))?; let start = rl["start"].as_u64() @@ -614,7 +622,7 @@ pub fn register( match_count = 1; warning = None; } else { - // has_anchor + // use_anchor let an = args.get("anchor") .ok_or_else(|| anyhow::anyhow!("缺少 anchor 参数"))?; let a_start = an["start"].as_str() @@ -652,15 +660,31 @@ pub fn register( // 生成 unified diff 供前端审批卡/审计留痕展示 // content(旧)与 new_content(新)作用域内可用,无需重读文件 let diff = generate_diff(&content, &new_content); + let mode_name = if use_anchor { + "anchor" + } else if use_old_text { + "old_text" + } else { + "replace_lines" + }; let mut result = serde_json::json!({ "path": path, "changed": true, + "mode": mode_name, "size_diff": size_diff, "matches_found": match_count, "diff": diff, }); + // 汇总提示:多模式仲裁说明 + 分支 warning(空白归一化命中/多匹配仅换第 1 处) + let mut notes: Vec = Vec::new(); + if let Some(a) = mode_arbitration { + notes.push(a); + } if let Some(w) = warning { - result["warning"] = serde_json::Value::String(w); + notes.push(w); + } + if !notes.is_empty() { + result["warning"] = serde_json::Value::String(notes.join(";")); } Ok(result) } @@ -1032,7 +1056,13 @@ pub fn register( // 旧实现直接 grep_recursive(root) → 内部 read_dir → 文件路径报 os error 267「目录名称无效」。 // 改:文件走 grep_one_file(单文件匹配),目录走 grep_recursive(递归)。 let root_meta = tokio::fs::metadata(root).await - .map_err(|e| anyhow::anyhow!("无法访问路径 {}: {}", root, e))?; + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + file_not_found_error(root) + } else { + anyhow::anyhow!("无法访问路径 {}: {}", root, e) + } + })?; if root_meta.is_file() { let file_name = std::path::Path::new(root) .file_name() @@ -1379,7 +1409,7 @@ async fn read_text_file(path: &str) -> anyhow::Result<(String, u64)> { let mut file = File::open(path).await .map_err(|e| { if e.kind() == std::io::ErrorKind::NotFound { - anyhow::anyhow!("文件不存在: {}", path) + file_not_found_error(path) } else { anyhow::anyhow!("无法访问文件 {}: {}", path, e) } @@ -1473,6 +1503,84 @@ fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec old_text > replace_lines(anchor 语义最精确,行号区间次之)。 +/// 返回 (use_anchor, use_old_text, use_replace_lines, 仲裁说明):单模式时原样放行、 +/// 说明为 None;多模式时只开选中开关,说明含「检测到多模式,已按 X 执行」。 +/// 全 false(缺定位方式)由调用方先行报错,本函数不处理。 +fn arbitrate_patch_mode( + has_anchor: bool, + has_old_text: bool, + has_replace_lines: bool, +) -> (bool, bool, bool, Option) { + let count = [has_anchor, has_old_text, has_replace_lines] + .iter() + .filter(|&&b| b) + .count(); + if count <= 1 { + return (has_anchor, has_old_text, has_replace_lines, None); + } + let chosen = if has_anchor { + "anchor" + } else if has_old_text { + "old_text" + } else { + "replace_lines" + }; + ( + chosen == "anchor", + chosen == "old_text", + chosen == "replace_lines", + Some(format!( + "检测到多模式({count} 个),已按 {chosen} 执行(优先级 anchor > old_text > replace_lines)" + )), + ) +} + +/// 单行空白归一化:所有空白序列(行首缩进/制表符/连续空格)折叠为单个空格。 +/// " let x = 1;" 归一化为 "let x = 1;"。 +fn normalize_ws_line(line: &str) -> String { + line.split_whitespace().collect::>().join(" ") +} + +/// 空白归一化行序列匹配:old_text 各行归一化后在 content 归一化行序列中找连续命中。 +/// +/// 用途:patch_file old_text 精确匹配未中时的回退(实测 ×15 缩进/空白对不齐)。 +/// 仅当归一化后**唯一**命中才返回 Some((start_line, end_line))(1-based 含首尾); +/// 多命中(歧义)/零命中返回 None,调用方维持原报错(含相近片段诊断)。 +/// 匹配为整行粒度:old_text 各归一化行须与目标行全等,故命中区间按整行替换不丢内容。 +fn find_whitespace_normalized_match(content: &str, old_text: &str) -> Option<(usize, usize)> { + let target: Vec = old_text.lines().map(normalize_ws_line).collect(); + // old_text 归一化后全空行 → 无意义锚点,不回退(交由精确路径报错) + if target.iter().all(|l| l.is_empty()) { + return None; + } + let hay: Vec = content.lines().map(normalize_ws_line).collect(); + let m = target.len(); + if hay.len() < m { + return None; + } + let mut hits: Vec = Vec::new(); + for i in 0..=hay.len() - m { + if hay[i..i + m] == target[..] { + hits.push(i); + if hits.len() > 1 { + break; // 多命中即歧义,提前终止 + } + } + } + if hits.len() == 1 { + Some((hits[0] + 1, hits[0] + m)) + } else { + None + } +} + // ============================================================ // read_file/read_symbol NotFound 提示辅助 — 相近文件名候选 // ============================================================ @@ -1742,6 +1850,96 @@ mod tests { ); } + // ============================================================ + // patch_file 多模式仲裁 + 空白归一化回退 — 三态锁死(命中/多命中/零命中) + // ============================================================ + + #[test] + fn arbitrate_single_mode_passthrough_no_note() { + // 单模式/零模式:开关原样放行,无仲裁说明 + assert_eq!(arbitrate_patch_mode(false, true, false), (false, true, false, None)); + assert_eq!(arbitrate_patch_mode(false, false, true), (false, false, true, None)); + assert_eq!(arbitrate_patch_mode(true, false, false), (true, false, false, None)); + assert_eq!(arbitrate_patch_mode(false, false, false), (false, false, false, None)); + } + + #[test] + fn arbitrate_multi_mode_prefers_anchor_then_old_text() { + // 优先级 anchor > old_text > replace_lines,仲裁说明含选中模式名 + let (a, o, r, note) = arbitrate_patch_mode(true, true, true); + assert!(a && !o && !r, "三模式全传应选 anchor"); + assert!(note.unwrap().contains("anchor")); + let (a, o, r, note) = arbitrate_patch_mode(false, true, true); + assert!(!a && o && !r, "old_text+replace_lines 应选 old_text"); + assert!(note.unwrap().contains("old_text")); + let (a, o, r, _) = arbitrate_patch_mode(true, false, true); + assert!(a && !o && !r, "anchor+replace_lines 应选 anchor"); + // 仲裁说明措辞契约(LLM 依赖该文案知情):「检测到多模式」「已按 X 执行」 + let (_, _, _, note) = arbitrate_patch_mode(true, true, false); + let text = note.unwrap(); + assert!(text.contains("检测到多模式"), "实际: {text}"); + assert!(text.contains("已按 anchor 执行"), "实际: {text}"); + } + + #[test] + fn normalize_ws_line_collapses_all_whitespace() { + assert_eq!(normalize_ws_line(" let x\t= 1; "), "let x = 1;"); + assert_eq!(normalize_ws_line(""), ""); + assert_eq!(normalize_ws_line("\t\t"), ""); + } + + #[test] + fn normalized_match_unique_indent_drift_hits() { + // 实测主失败形态:缩进漂移(2/6 空格 vs LLM 传 4 空格)+ 行内多空白 → 唯一命中 + let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n"; + let old = " let y = 2;"; + assert_eq!(find_whitespace_normalized_match(content, old), Some((3, 3))); + } + + #[test] + fn normalized_match_multiline_block_hits() { + // 多行块(含 tab 缩进):归一化后行序列连续命中第 2-3 行 + let content = "a\n one two;\n\tthree;\nb\n"; + let old = "one two;\n three;"; + assert_eq!(find_whitespace_normalized_match(content, old), Some((2, 3))); + } + + #[test] + fn normalized_match_exact_compatible_scenario() { + // 精确匹配本可命中的场景,归一化同样定位到同区间(handler 精确优先,此为一致性) + let content = "let x = 1;\n"; + assert_eq!(find_whitespace_normalized_match(content, "let x = 1;"), Some((1, 1))); + } + + #[test] + fn normalized_match_multi_hit_returns_none() { + // 多命中 = 歧义 → None(handler 维持报错路径,不瞎选) + let content = "x = 1;\ny = 2;\nx = 1;\n"; + assert_eq!(find_whitespace_normalized_match(content, "x = 1;"), None); + } + + #[test] + fn normalized_match_no_hit_returns_none() { + // 零命中 → None(handler 维持相近片段诊断报错) + let content = "fn a() {}\nfn b() {}\n"; + assert_eq!(find_whitespace_normalized_match(content, "fn totally_other() {}"), None); + } + + #[test] + fn normalized_match_blank_old_text_returns_none() { + // 全空行 old_text → None(交由精确路径报错) + assert_eq!(find_whitespace_normalized_match("a\n\nb\n", "\n\n"), None); + } + + #[test] + fn normalized_match_range_feeds_line_replace_e2e() { + // 归一化命中行区间 → apply_line_range 整行替换(handler 同款调用链) + let content = "keep\n let x = 1;\n let y = 2;\nkeep\n"; + let (sl, el) = find_whitespace_normalized_match(content, " let x = 1;").unwrap(); + let out = apply_line_range(content, sl, el, "REPLACED").unwrap(); + assert_eq!(out, "keep\nREPLACED\n let y = 2;\nkeep\n"); + } + // ============================================================ // similar_file_names / file_not_found_error — AC-5 NotFound 相近文件名候选 // ============================================================