From dd0afcf0d134df3daf1c866de6f14d36fbe36bdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Thu, 1 Oct 2026 22:36:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20G2=20=E7=AD=BE=E5=90=8D?= =?UTF-8?q?=E7=86=94=E6=96=AD=E8=AF=AF=E6=9D=80=E7=BA=A2=E7=BB=BF=E8=BF=AD?= =?UTF-8?q?=E4=BB=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 同签名死磕判定改无写间隔连续段口径,间插写类调用=红绿验证不累计 - 写证据=is_write_evidence_tool 五个改文件工具,读类不算证据 - 阈值 3 与纯死磕语义不变,根治脚本失败致执行中断 - 新增红绿迭代豁免与读间隔死磕两场景测试 --- src-tauri/src/commands/ai/agentic/helpers.rs | 111 +++++++++++++++---- src-tauri/src/commands/ai/agentic/mod.rs | 13 ++- 2 files changed, 96 insertions(+), 28 deletions(-) diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index 6c9dc2e..52233da 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -262,31 +262,33 @@ pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> Strin df_ai::ai_tools::tool_signature(name, args) } -/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。 +/// G2 重复检测纯函数:判定最近 N 个工具调用样本是否构成「卡住反复」。 /// -/// 策略组合(两者任一命中即 true,注释论证稳健性): +/// 样本 = (签名, 是否写类调用)。策略(后两条任一命中即 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 次死磕 = 漂移。 +/// - **或:某签名「无写间隔连续段」>= 3** → true。覆盖「单点反复」。 /// -/// 两条互补:唯一率治整体游荡不前进,单点计数治单点死磕。组合后覆盖真实漂移的两种形态, -/// 且对正常探索(签名持续翻新)宽松——换词 grep + 不同文件 read 各一两次,唯一率高不触发。 -pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool { +/// 写间隔豁免(脚本失败致执行中断的根治):验证命令(测试/构建/lint)反复跑但两次之间 +/// 夹有写类调用(patch/write 改了世界)是红绿迭代——重跑是验证行为,不与历史失败累计; +/// 中间只有读类调用不算证据(世界未变,重跑仍是无进展重复)。原「总出现次数 >= 3」口径 +/// 会把红绿迭代误判漂移(12 样本窗口内同一测试命令必然出现 >= 3 次),阈值不变、纯死磕 +/// 语义不变,只把"死磕"修准为"中间无写证据的重复"。 +pub(crate) fn is_repetitive_exploration(samples: &[(String, bool)]) -> 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 { + if samples.len() < REPETITION_MIN_SAMPLE { return false; } - let total = signatures.len(); + let total = samples.len(); let unique = { - let mut s: Vec<&String> = signatures.iter().collect(); + let mut s: Vec<&String> = samples.iter().map(|(sig, _)| sig).collect(); s.sort(); s.dedup(); s.len() @@ -295,12 +297,32 @@ pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool { if unique_ratio < REPETITION_UNIQUE_RATIO { return true; } - // 单点死磕:统计最高频签名出现次数。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; + // 单点死磕:同签名「无写间隔连续段」计数,HashMap 按签名记 (上次下标,当前段长) O(n)。 + let mut streaks: std::collections::HashMap<&String, (usize, usize)> = + std::collections::HashMap::new(); + let mut max_streak = 0usize; + for (i, (sig, is_write)) in samples.iter().enumerate() { + if *is_write { + continue; // 写类调用自身不作死磕计数对象——它是"世界已变"的证据,不是漂移 + } + let entry = streaks.entry(sig).or_insert((i, 0usize)); + let had_write_between = samples[entry.0..i].iter().any(|(_, w)| *w); + entry.1 = if had_write_between { 1 } else { entry.1 + 1 }; + entry.0 = i; + if entry.1 > max_streak { + max_streak = entry.1; + } } - counts.values().any(|&c| c >= REPETITION_SINGLE_MAX) + max_streak >= REPETITION_SINGLE_MAX +} + +/// 写证据工具:改变工作区状态的文件操作,作为 G2 单点死磕「写间隔」判定的证据来源。 +/// 按名硬编码与 [`destructive_loop_sample`] 同款先例(注册表无写类维度)。 +pub(crate) fn is_write_evidence_tool(name: &str) -> bool { + matches!( + name, + "write_file" | "append_file" | "patch_file" | "rename_file" | "delete_file" + ) } // ============================================================ @@ -497,6 +519,11 @@ mod tests { // ── is_repetitive_exploration 场景测试 ── + /// 测试辅助:签名串 → (签名, 非写调用) 样本(绝大多数旧场景无写语义)。 + fn reps(sigs: Vec) -> Vec<(String, bool)> { + sigs.into_iter().map(|s| (s, false)).collect() + } + /// 场景 ac448296 实证:正常代码审查序列——换词 grep(MAX/truncate/fetch_url)+ /// 不同文件 read_file,12 个签名全不同。**核心回归**:旧 is_empty_tool_result 误熔断此场景, /// 新签名判定应返回 false(不熔断)。 @@ -517,7 +544,7 @@ mod tests { tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})), ]; assert_eq!(sigs.len(), 12); - assert!(!is_repetitive_exploration(&sigs), "正常代码审查不应判重复"); + assert!(!is_repetitive_exploration(&reps(sigs)), "正常代码审查不应判重复"); } /// 场景死循环:同 grep 同 path 反复 8 次。判 true(熔断)。 @@ -525,7 +552,7 @@ mod tests { 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), "同调用反复应判重复"); + assert!(is_repetitive_exploration(&reps(sigs)), "同调用反复应判重复"); } /// 场景分段读大文件:同 path 不同 offset=0/50/100/150/200/250,6 次。判 false(签名不同)。 @@ -535,7 +562,7 @@ mod tests { 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 不应判重复"); + assert!(!is_repetitive_exploration(&reps(sigs)), "分段读不同 offset 不应判重复"); } /// 场景反复读同段:同 path 同 offset+limit 4 次(+ 其他 2 个不同凑足样本)。判 true(漂移)。 @@ -545,7 +572,43 @@ mod tests { let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"})); let other2 = tool_call_signature("list_directory", &serde_json::json!({"path": "z"})); let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2]; - assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复"); + assert!(is_repetitive_exploration(&reps(sigs)), "反复读同段应判重复"); + } + + /// 场景红绿迭代豁免(脚本失败中断根治):同一条验证命令反复跑,但每轮前都有写类调用 + /// (patch 改代码)——世界已变,重跑是验证而非死磕,不应判漂移。旧口径(总次数 >= 3) + /// 在 12 样本窗口内必误杀此场景。 + #[test] + fn scenario_red_green_iteration_not_repetitive() { + let test_cmd = tool_call_signature("run_command", &serde_json::json!({"command": "cargo test"})); + let samples: Vec<(String, bool)> = (0..4) + .flat_map(|i| { + let e1 = tool_call_signature("patch_file", &serde_json::json!({"path": "src/lib.rs", "old": format!("a{}", i), "new": "b"})); + let e2 = tool_call_signature("patch_file", &serde_json::json!({"path": "src/lib.rs", "old": format!("c{}", i), "new": "d"})); + [(e1, true), (e2, true), (test_cmd.clone(), false)] + }) + .collect(); + assert_eq!(samples.len(), 12); + assert!(!is_repetitive_exploration(&samples), "红绿迭代(同命令+间插写)不应判漂移"); + } + + /// 场景纯死磕(读间隔不算证据):同命令反复,中间只有 read——世界未变,重跑仍是无进展 + /// 重复,应仍判漂移(写间隔豁免不弱化原熔断语义)。 + #[test] + fn scenario_grinding_with_reads_still_repetitive() { + let test_cmd = tool_call_signature("run_command", &serde_json::json!({"command": "npm test"})); + let read = tool_call_signature("read_file", &serde_json::json!({"path": "log.txt", "offset": 0, "limit": 50})); + let other = tool_call_signature("list_directory", &serde_json::json!({"path": "x"})); + let samples: Vec<(String, bool)> = vec![ + (test_cmd.clone(), false), + (read.clone(), false), + (test_cmd.clone(), false), + (read, false), + (test_cmd.clone(), false), + (other, false), + (test_cmd, false), + ]; + assert!(is_repetitive_exploration(&samples), "同命令无写间隔死磕应仍判漂移"); } // ── 破坏性回环检测(detect_destructive_loop)测试 ── @@ -685,7 +748,7 @@ mod tests { 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), "样本不足不应判"); + assert!(!is_repetitive_exploration(&reps(sigs)), "样本不足不应判"); } /// 边界:恰好 6 个全同 → true(达最小样本 + 单点 6 >= 3)。 @@ -693,7 +756,7 @@ mod tests { 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)); + assert!(is_repetitive_exploration(&reps(sigs))); } /// 边界:6 个签名两两循环(a,b,a,b,a,b)→ 唯一率 2/6≈0.33 < 0.4 → true(整体游荡)。 @@ -702,7 +765,7 @@ mod tests { 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.clone(), b.clone(), a.clone(), b.clone(), a, b]; - assert!(is_repetitive_exploration(&sigs), "两签名交替唯一率低应判重复"); + assert!(is_repetitive_exploration(&reps(sigs)), "两签名交替唯一率低应判重复"); } /// 边界:6 个签名全不同 → false(正常探索)。 @@ -712,7 +775,7 @@ mod tests { let sigs: Vec = patterns.iter() .map(|p| tool_call_signature("grep", &serde_json::json!({"pattern": p, "path": "x"}))) .collect(); - assert!(!is_repetitive_exploration(&sigs)); + assert!(!is_repetitive_exploration(&reps(sigs))); } // ── is_pure_greeting 纯问候判定测试 ── diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 86be03c..14814c5 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -185,6 +185,8 @@ pub const TOPIC_MARKER_GOAL_AWARE: bool = true; // grep 无匹配是有效排除信号非漂移,误熔断正常排除式搜索(实证 ac448296)。 // - 新(当前):连续 N 轮工具调用签名重复(is_repetitive_exploration 判 tool_call_signature)。 // 漂移本质 = AI 卡住反复同样调用;正常探索(换词/换路径/换工具)签名不同,真死循环签名重复。 +// 红绿迭代(同验证命令反复跑+间插写调用)按写间隔豁免不算漂移,见 is_repetitive_exploration。 +// 红绿迭代豁免:同验证命令反复跑但间插写类调用(patch/write)不算漂移,见 is_repetitive_exploration 写间隔论证。 // // 机制(非 prompt 说教,骨架沿用):连续 N 轮判定签名重复 → 两段式(WARN_FIRST 先警示再熔断): // 警示注入下轮 prompt 给 LLM 自纠机会,熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。 @@ -373,7 +375,7 @@ pub(crate) use stall_detector::{ 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, is_tool_call_over_limit, + tool_call_signature, is_repetitive_exploration, is_write_evidence_tool, is_pure_greeting, is_tool_call_over_limit, DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop, }; @@ -1947,7 +1949,10 @@ async fn check_stall_breaker( // 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。 // arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。 // 同时并行抽取回环样本(写/删工具记路径,其他占位),两缓冲同序位对齐。 - let (signatures, loop_samples): (Vec, Vec) = { + // 签名样本带写证据标记(写类调用 true),喂 is_repetitive_exploration 做红绿迭代豁免。 + // 写类调用同时打写证据标记(is_write_evidence_tool),供 G2 单点死磕的写间隔豁免: + // 红绿迭代(同验证命令反复跑+间插写)=验证行为不算漂移,根治脚本失败致执行中断。 + let (signatures, loop_samples): (Vec<(String, bool)>, Vec) = { let session = session_arc.lock().await; // BE2只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用, // 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。 @@ -1955,7 +1960,7 @@ async fn check_stall_breaker( Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N), None => Vec::new(), }; - let mut sigs: Vec = Vec::new(); + let mut sigs: Vec<(String, bool)> = Vec::new(); let mut loops: Vec = Vec::new(); for m in messages.iter().rev() { if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE { @@ -1972,7 +1977,7 @@ async fn check_stall_breaker( } 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)); + sigs.push((tool_call_signature(&tc.function.name, &args), is_write_evidence_tool(&tc.function.name))); loops.push(destructive_loop_sample(&tc.function.name, &args)); } }