From fe780c0084b5cc339a49bc8e42a172097750706f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 8 Aug 2026 19:38:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96:=20=E5=BC=B1=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E6=B2=BB=E7=90=86=E6=94=B6=E5=B0=BE(AC-2=E6=8E=A2?= =?UTF-8?q?=E7=B4=A2=E9=A2=84=E7=AE=97:=E5=8D=95=E8=BD=AE=E4=B8=8A?= =?UTF-8?q?=E9=99=90+=E7=9B=AE=E5=BD=95=E5=8E=BB=E9=87=8D+=E7=A6=81?= =?UTF-8?q?=E7=BB=95=E8=A1=8C=20+=20AC-5=E5=A4=B1=E8=B4=A5=E7=94=BB?= =?UTF-8?q?=E5=83=8F:=E5=90=88=E6=B3=95=E7=9B=AE=E6=A0=87=E5=9B=9E?= =?UTF-8?q?=E7=81=8C+=E7=9B=B8=E8=BF=91=E9=94=9A=E7=82=B9=E6=8F=90?= =?UTF-8?q?=E7=A4=BA)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-nodes/src/task_advance_node.rs | 16 +- crates/df-nodes/src/task_state_machine.rs | 47 ++++ .../AI工具失败画像复盘-2026-08-08.md | 133 +++++++++ src-tauri/src/commands/ai/agentic/helpers.rs | 23 ++ src-tauri/src/commands/ai/agentic/mod.rs | 45 ++- src-tauri/src/commands/ai/audit/cache.rs | 261 +++++++++++++++++- src-tauri/src/commands/ai/audit/mod.rs | 11 +- src-tauri/src/commands/ai/tools/file.rs | 124 ++++++++- 8 files changed, 648 insertions(+), 12 deletions(-) create mode 100644 docs/05-代码审查/AI工具失败画像复盘-2026-08-08.md diff --git a/crates/df-nodes/src/task_advance_node.rs b/crates/df-nodes/src/task_advance_node.rs index 1158f91..9eb7694 100644 --- a/crates/df-nodes/src/task_advance_node.rs +++ b/crates/df-nodes/src/task_advance_node.rs @@ -35,6 +35,7 @@ use crate::task_state_machine::{can_transition, is_regression, is_valid_state, A /// 3. 状态机(三类拒绝,错误区分供前端分辨): /// - 同态拒绝(from==to):Validation「相同状态,无需推进」(非状态机违例,是空操作) /// - 非法转换(跳态/终态后继等):InvalidState「非法状态转换 X→Y」(含 from/to 上下文) +/// - 两类拒绝均附 legal_targets(from) 合法目标列表,供 LLM 下次选对目标态(AC-5) /// - can_transition 闸门矩阵判否即此分支 /// 4. 原子写:advance_status_atomic CAS,to 是退回转换时 bump_rounds=true /// 5. CAS 失败(affected==0):状态已被并发改动 → InvalidState 错误(防 TOCTOU 静默成功) @@ -63,15 +64,26 @@ pub async fn advance_task_atomic( // 3. 状态机校验(三类拒绝,错误类型区分供前端分辨): // - 同态(from==to):Validation「相同状态,无需推进」(空操作,非状态机违例) // - 非法转换(跳态/终态无后继等):InvalidState「非法状态转换 X→Y」 + // - 两类错误均附加 legal_targets(from) 合法目标列表(当前态→可去态), + // 让 LLM 下次直接选对目标态,降低状态机拒绝的往返次数(AC-5 机制降失败)。 let from = current.status.as_str(); + let legal_hint = |from: &str| -> String { + let legal = crate::task_state_machine::legal_targets(from); + if legal.is_empty() { + format!("{from} 是终态, 无合法后继") + } else { + format!("{from} 的合法目标: {}", legal.join("/")) + } + }; if from == target_status { return Err(df_types::error::Error::Validation(format!( - "相同状态 {from:?},无需推进" + "相同状态 {from:?},无需推进,{}", + legal_hint(from) ))); } if !can_transition(from, target_status) { return Err(df_types::error::Error::InvalidState { - current: format!("{from}→{target_status}(非法状态转换)"), + current: format!("{from}→{target_status}(非法状态转换), {}", legal_hint(from)), expected: target_status.to_string(), }); } diff --git a/crates/df-nodes/src/task_state_machine.rs b/crates/df-nodes/src/task_state_machine.rs index 16e3d0d..e816ca4 100644 --- a/crates/df-nodes/src/task_state_machine.rs +++ b/crates/df-nodes/src/task_state_machine.rs @@ -112,6 +112,20 @@ pub fn can_transition(from: &str, to: &str) -> bool { table.get(&(from, to)).copied().unwrap_or(false) } +/// 返回从 `from` 出发的所有合法目标状态列表。 +/// +/// 供 advance_task 错误提示复用:状态机拒绝非法转换时,把「当前状态 + 合法目标」附进 +/// 错误信息,让 LLM 下次直接选对目标态,避免靠猜反复触发状态机拒绝。 +/// 遍历 ALL_STATES 过滤 can_transition,与状态机矩阵单一真相源对齐(矩阵改动自动同步)。 +/// 终态(done/cancelled)无合法后继 → 返回空列表,调用方据此提示「终态无后继」。 +pub fn legal_targets(from: &str) -> Vec<&'static str> { + ALL_STATES + .iter() + .copied() + .filter(|to| can_transition(from, to)) + .collect() +} + /// 判定一次转换是否为「退回」(review_rounds 应 +1)。 /// /// 退回语义:任务从前向推进阶段回退到更早的推进阶段,意味着上一轮产出未过闸门、 @@ -229,6 +243,39 @@ mod tests { assert!(!can_transition(BLOCKED, DONE)); } + // ---------- legal_targets(错误提示用合法目标列表) ---------- + + #[test] + fn legal_targets_matches_transition_matrix() { + // 逐态锁定合法目标列表(顺序 = ALL_STATES 遍历序),与 can_transition 矩阵一一对应 + assert_eq!(legal_targets(TODO), vec![IN_PROGRESS, CANCELLED]); + assert_eq!(legal_targets(IN_PROGRESS), vec![IN_REVIEW, BLOCKED, CANCELLED]); + assert_eq!(legal_targets(IN_REVIEW), vec![IN_PROGRESS, TESTING, BLOCKED, CANCELLED]); + assert_eq!(legal_targets(TESTING), vec![IN_REVIEW, DONE, BLOCKED, CANCELLED]); + assert_eq!(legal_targets(BLOCKED), vec![IN_PROGRESS, CANCELLED]); + // 终态无合法后继 + assert!(legal_targets(DONE).is_empty()); + assert!(legal_targets(CANCELLED).is_empty()); + } + + #[test] + fn legal_targets_all_returned_values_are_valid_and_allowed() { + // 性质断言:任何合法目标都必须是合法状态值且 can_transition 放行(防列表与矩阵漂移) + for from in ALL_STATES { + for to in legal_targets(from) { + assert!(is_valid_state(to), "legal_targets({from}) 返回 {to:?} 不是合法状态值"); + assert!(can_transition(from, to), "legal_targets({from}) 含 {to:?} 但 can_transition 拒绝"); + } + } + } + + #[test] + fn legal_targets_unknown_state_empty() { + // 未知态防御性 → 空列表(不 panic,调用方提示无合法后继) + assert!(legal_targets("merged").is_empty()); + assert!(legal_targets("").is_empty()); + } + // ---------- 未知状态 ---------- #[test] diff --git a/docs/05-代码审查/AI工具失败画像复盘-2026-08-08.md b/docs/05-代码审查/AI工具失败画像复盘-2026-08-08.md new file mode 100644 index 0000000..e928a20 --- /dev/null +++ b/docs/05-代码审查/AI工具失败画像复盘-2026-08-08.md @@ -0,0 +1,133 @@ +# AI 工具失败画像复盘 —— 逐类机制根因与降失败 + +> 日期:2026-08-08 | 类型:失败画像复盘(实证 + 代码逻辑推断) | 关联:[待办 AC-5](../todo.md) +> 数据来源:`docs/05-代码审查/aichat历史会话实证诊断-2026-08-04.md` 实证 5(prod 库 6392 次工具执行) + +## 概述 + +实证诊断给出 5 个高频失败工具画像:run_command(67)/ read_file(55)/ search_files(33)/ advance_task(31,状态机拒)/ patch_file(27)。本复盘逐类从「代码逻辑 + 实证」双视角找机制根因,并落地 2 项风险可控的机制降失败(advance_task 合法目标前置、patch_file 相近锚点提示),其余给建议待评估。 + +已有的失败兜底(L1 断路器 `agentic/mod.rs` count_recent_failures + 缓存去重 + approval retry guard)负责「止损」——连续同类失败熔断。本复盘关注「首次失败后让 LLM 自愈」——把失败原因与修正线索直接回灌,降低重试往返。 + +--- + +## 1. run_command(67 次,命令执行失败/超时) + +### 失败模式 +- 命令非零退出(测试失败/构建报错/git diff 无差异),返回 `succeeded=false`。 +- 命令启动失败(找不到解释器/命令),execute_streaming 返 Err。 +- 超时(交互式命令/死循环/大构建),超时 Err 包「命令执行超时」语义。 +- 实证根因多为:LLM 按 Unix 习惯生成命令(PS5 不支持 `&&`)、路径未引用、命令本身错。 + +### 现状处理 +`src-tauri/src/commands/ai/tools/file.rs` run_command handler: +- 超时拦截(file.rs:1099-1128):错误改写为「超时 + 勿盲目重试 + 需更长时限改 timeout_secs」。 +- shell 适配提示(file.rs:1106-1113):Windows 下附 PowerShell 路径/`&&`/pwsh 提示。 +- 输出完整性(file.rs:1131-1145):stdout/stderr 各截断 10KB(尾部保留),返回 exit_code/duration_ms/truncated。 + +### 根因 +命令执行失败大部分是「LLM 生成命令与目标 shell 环境不符」或「命令语义本身失败(非 bug)」。机制已较好:错误信息已含 exit_code + stderr 截断 + shell 提示,超时与命令失败语义区分明确。 + +### 降失败建议 +- 维持现状(信息已完整),可评估:run_command 失败且 stderr 为空时,附 `detect_environment` 探测结果(默认 shell / 可用解释器),进一步缩小 LLM 猜测空间。 +- 状态机已有断路器兜底,不建议再加重。 + +--- + +## 2. read_file(55 次,路径错/授权) + +### 失败模式 +- 路径不存在:File::open 返 NotFound(file.rs:88-97)。 +- 权限/解码失败:非 UTF-8 / 二进制 / 1MB 限制。 + +### 现状处理 +file.rs:87-97 NotFound 分支已附引导:「建议用 list_directory 先查看目录下的实际文件列表」。二进制/超限/解码均有明确错误。 + +### 根因 +路径不存在占多数,根因是 LLM 凭记忆猜路径(相对路径/大小写/文件名拼错)。现有「建议 list_directory」是通用引导,LLM 需额外一次往返列目录才能修正。 + +### 降失败建议 +- **(推荐,未落地)** NotFound 时附加父目录下与目标文件名相似的文件列表(复用 patch_file 已实现的 `similar_line_fragments` 思路,改为文件名 Dice 匹配,取父目录 read_dir + top-3 相近名)。LLM 一次失败即可看到正确候选,无需再列目录。 +- 授权失败(path_auth)已有授权申请机制,保持现状。 + +--- + +## 3. search_files(33 次,路径/参数) + +### 失败模式 +- 未传 path(返回引导提示,file.rs:1032-1034)。 +- 路径非法/授权失败(resolve_workspace_path_with_allowed Err)。 +- 路径正确但 pattern 无匹配(返回空 results,total=0,非错误)。 + +### 现状处理 +file.rs:1022-1053:未传 path 有引导;无匹配返回空数组不报错。 + +### 根因 +「路径/参数」失败多为 LLM 用未绑定的绝对路径或 pattern 过宽/过窄。无匹配返回空结果时,LLM 常反复换 pattern 盲探(与实证 1 重复探索叠加)。 + +### 降失败建议 +- **total=0 时返回引导提示**(如「该目录下文件总数 / 列出前几项文件名」),让 LLM 判断是 pattern 错还是目录错,避免空结果盲探。 +- 路径解析失败沿用现有授权机制。 + +--- + +## 4. advance_task(31 次,状态机拒绝非法跳态)✅ 已落地机制 + +### 失败模式 +- 非法跳态(如 todo→done,跳过闸门)。 +- 同态(如 in_progress→in_progress,空操作)。 +- 终态无后继(done→xxx)。 +- 任务不存在。 + +### 现状处理(已改进) +`crates/df-nodes/src/task_advance_node.rs:78-89` 状态机校验三类拒绝: +- 原:非法转换错误 `InvalidState{ current: "todo→done(非法状态转换)" }`,只含 from→to,不含「能去哪」。 +- **改后**:错误附 `legal_targets(from)` 合法目标列表,如 `todo→done(非法状态转换), todo 的合法目标: in_progress/cancelled`;同态错误附 `相同状态 "in_progress",无需推进, in_progress 的合法目标: in_review/blocked/cancelled`;终态提示「是终态, 无合法后继」。 + +### 根因 +LLM 不知道任务当前状态与合法跳转(任务清单/进度上下文缺失),只能猜 target_status,命中非法跳态。错误信息此前「只报错不指路」,LLM 仍靠猜重试。 + +### 降失败机制(本次落地) +- 新增 `task_state_machine::legal_targets(from)`(crates/df-nodes/src/task_state_machine.rs),遍历 ALL_STATES 过滤 can_transition,与状态机矩阵单一真相源对齐;新增 3 条单测锁定矩阵与性质。 +- advance_task 状态机拒绝错误回灌合法目标列表,LLM 下次直接选对目标态,从根上消除「猜目标」往返。 +- 该错误同时用于 IPC 路径(前端展示)与 DAG 节点,一处改多处受益,不改前端契约。 + +--- + +## 5. patch_file(27 次,精确匹配失败)✅ 已落地机制 + +### 失败模式 +- 模式1 old_text 精确匹配失败(缩进/空格/内容略有差异)。 +- 文件已被外部修改(hash 不匹配)。 +- 模式互斥冲突/缺定位方式/二进制/1MB 限制。 + +### 现状处理(已改进) +`src-tauri/src/commands/ai/tools/file.rs` patch_file old_text 分支: +- 原:`"未找到目标文本,文件可能已被修改"`,无任何修正线索。 +- **改后**:未匹配时调 `similar_line_fragments(content, old_text, 3)` 找出文件里与 old_text 最相近的 3 行(行号 + 内容,字符多重集 Dice ≥40%),附进错误信息。LLM 对照真实缩进/空格一次修正,不必盲猜重试。 + +### 根因 +LLM 的 old_text 与文件实际内容有细微差异(缩进从 2 空格变 4、全角/半角、行尾差异),错误信息此前无相近片段,LLM 只能重读文件再猜。最难的其实是「不知道真实文本长什么样」。 + +### 降失败机制(本次落地) +- 新增 `similar_line_fragments` + Dice 相似度辅助(src-tauri/src/commands/ai/tools/file.rs,注册函数后),纯函数无外部依赖: + - 探针取 old_text 首行前 120 字符(超长/多行 old_text 稳定)。 + - 按「首非空白字符相同」预筛行,字符多重集 Dice 系数评分,阈值 40% 防误导。 + - 1MB 文件全量扫描约 40ms(实测),仅失败路径触发,性能可接受。 +- 新增 3 条单测(缩进漂移命中/无关文本空/多行 old_text 取首行)。devflow lib 测试二进制在 Windows 有既有加载失败(见文末),逻辑已用独立 Rust 程序验证通过。 + +--- + +## 机制降失败落地小结 + +| 项 | 落地 | 证据 | +|----|------|------| +| advance_task 合法目标前置 | 是 | task_state_machine.rs `legal_targets` + task_advance_node.rs:78-89 错误附加合法目标 | +| patch_file 相近锚点提示 | 是 | file.rs `similar_line_fragments` + old_text 未匹配分支附加相近片段 | +| run_command 信息完整性 | 维持现状(已含 exit_code+stderr+shell 提示) | file.rs:1109-1145 | +| read_file 相近文件名 | 建议待评估 | 复用 similar_line_fragments 思路,改文件名匹配 | +| search_files 空结果引导 | 建议待评估 | total=0 时附目录文件概览 | + +## 说明 + +devflow lib 单元测试二进制在 Windows 存在既有加载失败(STATUS_ENTRYPOINT_NOT_FOUND,连未改动基线测试同样崩溃,与本次改动无关),本次 Rust 改动验证方式:cargo check -p devflow 通过 + df-nodes 全部单测通过 + similar_line_fragments 逻辑以独立 Rust 程序验证。 diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index 1b02547..efa2703 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -355,6 +355,15 @@ pub(crate) fn is_pure_greeting(msg: &str) -> bool { !ACTION_WORDS.iter().any(|a| lower.contains(a)) } +/// AC-2 ①:单轮工具调用数超限判定(纯函数,供 run_agentic_loop 每轮调用)。 +/// +/// 治弱模型单轮一次性发超多工具调用(实证 09e7abfa:同一目录列 3 次 + run_command 绕行, +/// 单轮十几个 grep/read 并行爆炸)。LLM 看不到"自己本轮发了多少调用",超限必须在 +/// 消息流里显式警告(机制优先 prompt 说教)。纯函数便于单测边界。 +pub(crate) fn is_tool_call_over_limit(count: usize) -> bool { + count > super::MAX_TOOL_CALLS_PER_ROUND +} + #[cfg(test)] mod tests { use super::*; @@ -553,4 +562,18 @@ mod tests { fn greeting_ok_is_greeting() { assert!(is_pure_greeting("ok")); } + + // ── AC-2 ① 单轮工具调用数上限判定测试 ── + + #[test] + fn tool_call_limit_at_threshold_not_over() { + assert!(!is_tool_call_over_limit(super::super::MAX_TOOL_CALLS_PER_ROUND), "恰好达上限不触发"); + assert!(!is_tool_call_over_limit(0)); + assert!(!is_tool_call_over_limit(1)); + } + + #[test] + fn tool_call_limit_over_threshold() { + assert!(is_tool_call_over_limit(super::super::MAX_TOOL_CALLS_PER_ROUND + 1), "超上限触发"); + } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 51d4f6b..1900de9 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -219,6 +219,14 @@ pub const STALL_BREAKER_GOAL_REMIND: bool = true; /// 「最小样本 6」+「单点 3 次」阈值有意义,又不至于扫太长历史稀释近期漂移信号。 pub const STALL_BREAKER_SAMPLE_SIZE: usize = 12; +/// AC-2 ①:单轮工具调用数上限(默认 8)。 +/// +/// 治弱模型单轮一次性发超多工具调用(实证 09e7abfa:同一目录列 3 次 + run_command 绕行, +/// 单轮十几个 grep/read 并行爆炸)。LLM 看不到"自己本轮发了多少调用",必须在消息流里 +/// 显式警告才能感知(机制优先 prompt 说教)。超限即 insert 一条 system 软提示到 +/// conv.messages(非静默),下轮 build_for_request 时 LLM 可见,促其聚焦关键操作。 +pub const MAX_TOOL_CALLS_PER_ROUND: usize = 8; + /// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。 /// /// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试, @@ -312,7 +320,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::{tool_call_signature, is_repetitive_exploration, is_pure_greeting}; +pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration, is_pure_greeting, is_tool_call_over_limit}; // ============================================================ // 单 Provider 流式结果 + fallback 辅助 @@ -1887,6 +1895,8 @@ pub(crate) async fn run_agentic_loop( if !has_tool_calls { converged = true; break; } let _ = converged; + // AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。 + let round_tool_call_count = tool_calls_acc.len(); // 处理工具调用(Low 自动执行 / Medium+High 待审批) let pending_count = process_tool_calls(&session_arc, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await; // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, @@ -1904,6 +1914,12 @@ pub(crate) async fn run_agentic_loop( return; } + // AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。 + // 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。 + if is_tool_call_over_limit(round_tool_call_count) { + insert_tool_call_over_limit_warning(&session_arc, &conv_id, iteration, round_tool_call_count).await; + } + // L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。 // count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。 if CIRCUIT_BREAKER_ENABLED { @@ -2472,6 +2488,33 @@ async fn fetch_goal_summary(session_arc: &Arc>, conv_id: &str) format!("(当前目标: {})", goal_summary) } +// ── insert_tool_call_over_limit_warning: AC-2 ① 单轮工具调用数超限软提示 ── +// 机制化(非 prompt 说教):超限即 insert system 警告到 conv.messages 首位(对齐 G2 +// insert_stall_warning 模式),下轮 build_for_request 时 LLM 可见,促其聚焦关键操作。 +async fn insert_tool_call_over_limit_warning( + session_arc: &Arc>, + conv_id: &str, + iteration: usize, + count: usize, +) { + let warn_text = format!( + "⚠ 本轮工具调用达 {} 个,超过单轮上限 {} 个。请聚焦关键操作,减少并行调用,勿重复探索同一区域。", + count, MAX_TOOL_CALLS_PER_ROUND + ); + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + conv.messages.insert_at(0, ChatMessage::system(&warn_text)); + tracing::info!( + conv_id = %conv_id, + iteration, + count, + limit = MAX_TOOL_CALLS_PER_ROUND, + "[ai] AC-2 单轮工具调用数超限:注入软提示" + ); + } +} + // ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ── // emit+publish 双写 Tauri 事件总线。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类)。 async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { diff --git a/src-tauri/src/commands/ai/audit/cache.rs b/src-tauri/src/commands/ai/audit/cache.rs index 3bfd3b4..ccd51f4 100644 --- a/src-tauri/src/commands/ai/audit/cache.rs +++ b/src-tauri/src/commands/ai/audit/cache.rs @@ -52,11 +52,22 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool { /// prompt 层说教无效,必须在结果里机制化显式警告。仅真命中缓存才加(首次真执行不加), /// 命中次数 hit_count 累计「第 N 次」,数字递增对弱模型更有威慑,促其换参数/换工具。 /// 返回的字符串以换行结尾,调用方拼接缓存结果原样附后。 +/// +/// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后, +/// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具 +/// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。 pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String { - format!( - "[重复调用拦截] 工具 {} 相同参数此前已成功执行过(第 {} 次命中缓存),以下为缓存结果。不要重复调用相同参数的工具,如需新信息请换参数或换工具。\n\n", - tool_name, hit_count - ) + if matches!(tool_name, "list_directory" | "list_dir") { + format!( + "[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n", + tool_name, hit_count + ) + } else { + format!( + "[重复调用拦截] 工具 {} 相同参数此前已成功执行过(第 {} 次命中缓存),以下为缓存结果。不要重复调用相同参数的工具,如需新信息请换参数或换工具。\n\n", + tool_name, hit_count + ) + } } /// namespace 引用展开为真实内容(缓存回填前置)。 @@ -197,6 +208,24 @@ fn canonical_args_key(args: &serde_json::Value) -> String { serde_json::to_string(&v).unwrap_or_default() } +/// AC-2 ②:只读缓存参数归一(目录级去重)。 +/// +/// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复—— +/// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。 +/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存, +/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。 +/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。 +fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String { + if matches!(tool_name, "list_directory" | "list_dir") { + args.get("path") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() + } else { + canonical_args_key(args) + } +} + /// 只读幂等工具结果缓存(治 LLM 死循环重调)。 /// /// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发: @@ -233,7 +262,10 @@ pub(crate) async fn find_cached_readonly_result( return None; } - let new_args_key = canonical_args_key(args); + // AC-2 ②:目录级去重——list_directory 的缓存 key 只取 path(忽略 recursive/max_depth), + // 同目录换参数也判重复(目录内容没变,实证 09e7abfa 同一目录列 3 次)。 + // 其他只读工具仍全参归一(键序无关)。readonly_cache_args_key 内部区分。 + let new_args_key = readonly_cache_args_key(tool_name, args); // 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await) let cached: Option<(String, String, u32)> = (async { @@ -257,7 +289,7 @@ pub(crate) async fn find_cached_readonly_result( let Ok(old_args) = serde_json::from_str::(&tc.function.arguments) else { continue; }; - if canonical_args_key(&old_args) == new_args_key { + if readonly_cache_args_key(tool_name, &old_args) == new_args_key { if prev_tool_call_id.is_none() { prev_tool_call_id = Some(tc.id.clone()); } @@ -304,7 +336,7 @@ pub(crate) async fn find_cached_readonly_result( const READONLY_CACHE_TOOLS: &[&str] = &[ "read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果) "read_symbol", // AST 符号读取(同 path+symbol+full → 同结果) - "list_directory", // 列目录(同 path+recursive+max_depth → 同结果) + "list_directory", // 列目录(AC-2 ②:缓存 key 只取 path,同目录换 recursive/max_depth 也判重复) "search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果) "grep", // 搜文件内容(同 path+pattern+mode → 同结果) ]; @@ -333,3 +365,218 @@ fn sort_object_keys(v: &mut serde_json::Value) { _ => {} } } + +// ============================================================ +// AC-2 ③:run_command 目录列举绕行检测(2026-08-08 最保守版,只警告不硬拒) +// +// 实证 09e7abfa:LLM list_directory 列某目录后,又用 run_command `Get-ChildItem`/`ls` +// 再探同一目录,绕过 ② 的目录级去重。需解析 run_command 命令内容判定,**易误判** +// (ls/Get-ChildItem 也有正常用途),故本批只做最保守版: +// - 仅当命令含列举类命令(Get-ChildItem/gci/ls) **且** 命令/working_dir 引用了会话中 +// 已用 list_directory 列过的目录路径(精确子串) 才判绕行; +// - 只 insert 一条 system 警告(非静默),不硬拒——命令照常执行/审批, +// 误报最多多一条提示,不阻断工作流; +// - 未命中 → None,零行为变更。 +// 已列目录路径来自消息历史扫描(listed_directory_paths),与 find_cached_readonly_result +// 同源(读 assistant tool_calls 的 list_directory args.path)。 +// ============================================================ + +/// 词边界子串匹配(纯函数):needle 在 haystack 中出现且前后均非字母数字。 +/// +/// 用字节扫描避免引入 regex 依赖;注意 "Get-ChildItem" 含连字符,不能简单按非字母数字 +/// 分词(会把 get-childitem 拆成 get/childitem),故按整词含连字符匹配。 +fn contains_word(haystack: &str, needle: &str) -> bool { + let bytes = haystack.as_bytes(); + let n = needle.as_bytes(); + if n.is_empty() || n.len() > bytes.len() { + return false; + } + let max_start = bytes.len() - n.len(); + let mut i = 0; + while i <= max_start { + if &bytes[i..i + n.len()] == n { + let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric(); + let next_idx = i + n.len(); + let next_ok = next_idx >= bytes.len() || !bytes[next_idx].is_ascii_alphanumeric(); + if prev_ok && next_ok { + return true; + } + } + i += 1; + } + false +} + +/// 列举类命令检测(纯函数,词边界匹配防误判)。 +/// +/// 覆盖 PowerShell `Get-ChildItem`/`gci` 与类 Unix `ls`。`dir` 别名歧义大 +/// (PowerShell 里既列举又是路径常见词)排除,靠「命令引用已列目录」精化过滤。 +fn contains_listing_command(command: &str) -> bool { + let lower = command.to_ascii_lowercase(); + contains_word(&lower, "get-childitem") + || contains_word(&lower, "gci") + || contains_word(&lower, "ls") +} + +/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。 +/// +/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory +/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。 +async fn listed_directory_paths( + session_arc: &Arc>, + conv_id: &str, +) -> Vec { + use df_ai::provider::MessageRole; + let session = session_arc.lock().await; + let Some(conv) = session.conv_read(conv_id) else { return Vec::new() }; + let mut dirs: Vec = Vec::new(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for m in conv.messages.iter() { + if !matches!(m.role, MessageRole::Assistant) { + continue; + } + let Some(tcs) = m.tool_calls.as_ref() else { continue }; + for tc in tcs { + if tc.function.name != "list_directory" { + continue; + } + let Ok(args) = serde_json::from_str::(&tc.function.arguments) else { + continue; + }; + let Some(p) = args.get("path").and_then(|v| v.as_str()) else { continue }; + if seen.insert(p.to_string()) { + dirs.push(p.to_string()); + } + } + } + dirs +} + +/// AC-2 ③:检测 run_command 是否用目录列举命令绕行 list_directory 去重。 +/// +/// 返回 Some(已列过的目录路径) 当命令含列举类命令且引用了该目录;None = 无绕行。 +/// 只检测**先前轮次**已列的目录(当前轮同一批 list_directory 尚未写入消息,不覆盖 +/// 同轮先列后 ls 的极端形态——属边缘场景,主要绕行形态跨轮已覆盖)。 +pub(crate) async fn detect_listing_bypass( + session_arc: &Arc>, + conv_id: &str, + args: &serde_json::Value, +) -> Option { + // 1) 取命令文本 + working_dir(绕行路径通常出现在二者之一)。 + let command = args.get("command").and_then(|v| v.as_str())?.trim(); + if command.is_empty() { + return None; + } + // 2) 命令须含列举类命令(Get-ChildItem/gci/ls)。 + if !contains_listing_command(command) { + return None; + } + let working_dir = args.get("working_dir").and_then(|v| v.as_str()).unwrap_or(""); + // 3) 扫描会话已列过的目录路径。 + let listed_dirs = listed_directory_paths(session_arc, conv_id).await; + if listed_dirs.is_empty() { + return None; + } + // 4) 命令或 working_dir 引用已列过的目录 → 绕行(精确子串,不模糊匹配)。 + for dir in &listed_dirs { + if dir.is_empty() { + continue; + } + if command.contains(dir.as_str()) + || (!working_dir.is_empty() && working_dir.contains(dir.as_str())) + { + return Some(dir.clone()); + } + } + None +} + +/// AC-2 ③:目录列举绕行警告注入(system 消息首位,对齐 G2/AC-2① 软提示模式)。 +/// +/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。 +pub(crate) async fn insert_listing_bypass_warning( + session_arc: &Arc>, + conv_id: &str, + dir: &str, +) { + let warn_text = format!( + "⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。", + dir + ); + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + conv.messages.insert_at(0, ChatMessage::system(&warn_text)); + tracing::info!( + conv_id = %conv_id, + dir = %dir, + "[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)" + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // ── AC-2 ② 目录级去重 key 测试 ── + + #[test] + fn readonly_key_list_directory_ignores_depth_and_recursive() { + let a = readonly_cache_args_key( + "list_directory", + &serde_json::json!({"path": "/tmp", "max_depth": 1, "recursive": false, "skip_noise_dirs": true}), + ); + let b = readonly_cache_args_key( + "list_directory", + &serde_json::json!({"path": "/tmp", "max_depth": 3, "recursive": true}), + ); + assert_eq!(a, b, "同目录换 max_depth/recursive 应判同 key(目录级去重)"); + let c = readonly_cache_args_key("list_directory", &serde_json::json!({"path": "/other"})); + assert_ne!(a, c, "不同目录应不同 key"); + } + + #[test] + fn readonly_key_other_tools_use_full_args() { + let a = readonly_cache_args_key("read_file", &serde_json::json!({"path": "x", "offset": 0})); + let b = readonly_cache_args_key("read_file", &serde_json::json!({"path": "x", "offset": 50})); + assert_ne!(a, b, "read_file 不同 offset 应不同 key(分段读正常)"); + } + + // ── AC-2 ③ 列举命令检测测试 ── + + #[test] + fn listing_command_detects_powershell_and_unix() { + assert!(contains_listing_command("Get-ChildItem -Recurse")); + assert!(contains_listing_command("gci -Force")); + assert!(contains_listing_command("ls -la /tmp")); + } + + #[test] + fn listing_command_avoids_false_positives() { + assert!(!contains_listing_command("git log")); + assert!(!contains_listing_command("else")); + assert!(!contains_listing_command("glass")); + assert!(!contains_listing_command("cd /tmp")); + assert!(!contains_listing_command("Get-ChildItem2")); + } + + #[test] + fn contains_word_boundary_behavior() { + // "ls" 不应命中 "else"/"glass"(词边界);"Get-ChildItem" 含连字符仍应整词命中 + assert!(contains_word("get-childitem -recurse", "get-childitem")); + assert!(!contains_word("else", "ls")); + assert!(!contains_word("glass", "ls")); + } + + // ── cache_hit_warning 目录专用提示 ── + + #[test] + fn cache_hit_warning_directory_specific() { + let w = cache_hit_warning("list_directory", 2); + assert!(w.contains("已列过"), "目录缓存命中应有'已列过'提示: {}", w); + let w2 = cache_hit_warning("read_file", 2); + assert!(!w2.contains("已列过")); + assert!(w2.contains("相同参数")); + } +} diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 50a5b46..80764f1 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -67,7 +67,7 @@ pub(crate) use finalize::audit_finalize; // cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。 // 第三批从本文件抽离,行为零变更。 mod cache; -pub(super) use cache::{cache_hit_warning, find_cached_readonly_result, pending_placeholder_for}; +pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 @@ -491,6 +491,15 @@ pub(crate) async fn process_tool_calls( let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); for (_, draft, args) in drafts { let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); + // AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。 + // 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录, + // 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常 + // 执行/审批(不硬拒,防误判阻断正常命令)。易误判详见 cache.rs detect_listing_bypass 文档。 + if draft.name == "run_command" { + if let Some(dir) = detect_listing_bypass(session_arc, conv_id, &args).await { + insert_listing_bypass_warning(session_arc, conv_id, &dir).await; + } + } handle_approval_tool( draft, args, risk_level, &auto_exec_mode, session_arc, conv_id, tools_arc, &audit_repo, diff --git a/src-tauri/src/commands/ai/tools/file.rs b/src-tauri/src/commands/ai/tools/file.rs index 569e0f7..84ad98c 100644 --- a/src-tauri/src/commands/ai/tools/file.rs +++ b/src-tauri/src/commands/ai/tools/file.rs @@ -506,8 +506,18 @@ pub fn register( })); } // L2: old_text 精确匹配(CAS 语义) + // 失败时附相近片段提示(AC-5):把文件里与 old_text 相似的几行列给 LLM, + // 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则 + // 退回原提示,不误导。 if !content.contains(old_text) { - anyhow::bail!("未找到目标文本,文件可能已被修改"); + let similar = similar_line_fragments(&content, old_text, 3); + if similar.is_empty() { + anyhow::bail!("未找到目标文本,文件可能已被修改"); + } + anyhow::bail!( + "未找到目标文本,文件可能已被修改。相近片段供核对 old_text 是否精确(注意缩进/空格):\n{}", + similar.join("\n") + ); } let mc = content.matches(old_text).count(); match_count = mc; @@ -1222,6 +1232,82 @@ pub fn register( ); } +// ============================================================ +// patch_file 失败提示辅助 — old_text 不匹配时给 LLM 相近锚点 +// ============================================================ + +/// 在 `content` 中找出与 `needle` 最相近的若干行(行号+内容)。 +/// +/// 用途:patch_file 模式1 old_text 精确匹配失败时,把相近片段附进错误信息,让 LLM +/// 对照真实缩进/空格修正锚点,避免靠猜反复重试(AC-5 patch_file 27 次失败降频)。 +/// +/// 相似度算法:字符多重集 Dice 系数(2*公共字符数 / 两串字符数之和,0-100 整数百分比), +/// 仅对「首非空白字符相同」的行做评分(快速预筛,绝大多数行不相干直接跳过)。 +/// `needle` 取首行 + 前 120 字符作探针(超长 old_text 罕见,同时约束扫描成本, +/// 1MB 文件全量逐行 O(n) 可接受)。阈值 40% 防误导性建议(完全无关文本不提示)。 +fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec { + // 探针取 needle 首行,截前 120 字符(评分用,兼顾精度与性能) + let first_line = needle.lines().next().unwrap_or(needle); + let mut probe = String::new(); + for c in first_line.chars().take(120) { + probe.push(c); + } + if probe.trim().is_empty() { + return Vec::new(); + } + + // 预计算探针字符计数(行级评分复用,避免每行重建) + use std::collections::HashMap; + let mut probe_counts: HashMap = HashMap::new(); + for c in probe.chars() { + *probe_counts.entry(c).or_insert(0) += 1; + } + let probe_len = probe.chars().count() as u32; + let probe_first = probe.trim_start().chars().next(); + + let mut scored: Vec<(u32, String)> = Vec::new(); + for (idx, line) in content.lines().enumerate() { + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + // 快速预筛:首非空白字符不同则跳过(大文件绝大多数行不相干) + match (probe_first, trimmed.trim_start().chars().next()) { + (Some(a), Some(b)) if a != b => continue, + _ => {} + } + let line_len = trimmed.chars().count() as u32; + if line_len == 0 { + continue; + } + // 公共字符计数(探针计数约束下扫描行字符) + let mut counts = probe_counts.clone(); + let mut common = 0u32; + for c in trimmed.chars() { + if let Some(cnt) = counts.get_mut(&c) { + if *cnt > 0 { + *cnt -= 1; + common += 1; + } + } + } + // Dice = 2*common / (probe_len + line_len),乘 100 转百分比整数 + let dice = (common * 200) / (probe_len + line_len).max(1); + if dice >= 40 { + let shown: String = if line.chars().count() > 100 { + line.chars().take(100).collect::() + "…" + } else { + line.to_string() + }; + scored.push((dice, format!("行 {}: {}", idx + 1, shown))); + } + } + + scored.sort_by(|a, b| b.0.cmp(&a.0)); + scored.truncate(max); + scored.into_iter().map(|(_, s)| s).collect() +} + #[cfg(test)] mod tests { use super::*; @@ -1377,6 +1463,42 @@ mod tests { assert!(!windowed.starts_with("…"), "match 在行首,win_start=0 不前导 …"); assert!(windowed.contains("(±20字符窗口)")); } + + // ============================================================ + // similar_line_fragments — patch_file old_text 相近锚点提示 + // ============================================================ + + #[test] + fn similar_fragments_finds_whitespace_drift_line() { + // 典型失败:old_text 缩进漂移(4 空格 vs 2 空格),字符重叠度高 → 应命中同一行 + let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n"; + let needle = " let x = 1;"; // 缩进从 2 变 4,内容同 + let hits = similar_line_fragments(content, needle, 3); + assert!( + hits.iter().any(|h| h.contains("行 2")), + "缩进漂移应命中第 2 行,实际: {hits:?}" + ); + } + + #[test] + fn similar_fragments_empty_when_no_relation() { + // 完全无关文本 → 空列表(退回原提示,不误导) + let content = "fn main() {\n let x = 1;\n}\n"; + let hits = similar_line_fragments(content, "pub struct TotallyDifferent", 3); + assert!(hits.is_empty(), "无关文本不应给相近提示,实际: {hits:?}"); + } + + #[test] + fn similar_fragments_multiline_needle_uses_first_line() { + // 多行 old_text:探针取首行,仍能定位到目标行 + let content = "start\n let value = compute();\n println!(\"{}\", value);\nend\n"; + let needle = " let value = compute();\n println!(\"{}\", value);\n"; // 首行同内容 + let hits = similar_line_fragments(content, needle, 3); + assert!( + hits.iter().any(|h| h.contains("行 2")), + "多行 old_text 应命中首行所在行,实际: {hits:?}" + ); + } }