diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index 4ae29c5..194c2e2 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -352,6 +352,68 @@ pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool { counts.values().any(|&c| c >= REPETITION_SINGLE_MAX) } +// ============================================================ +// G2 补充:破坏性回环检测(治「写完就删」自毁循环,G2 签名熔断对此漏网) +// +// 漏网根因:write_file/delete_file 走 tool_call_signature 兜底分支(name + args 全 JSON), +// 写→删→写交替时每步签名都不同(唯一率≈0.83、单签名最高 2 次),is_repetitive_exploration +// 全程不触发。但「同路径写后紧跟删(或反向)」本身就是自毁/反悔循环的直接证据——正常流程 +// 写后验证后保留,不会紧凑间隔内删同路径。本检测不比签名,直接按 (工具, 路径) 对查。 +// 调用点:check_stall_breaker 采样处与签名缓冲并行维护,独立计数器互不干扰。 +// ============================================================ + +/// 回环缓冲样本:每个工具调用占一位,write_file/delete_file 记 (工具名, 规范化路径), +/// 其他工具 None 占位。占位让「GAP 步内」的步按全部工具调用序位算——写删间穿插的 +/// 探索调用也算间隔,避免把「写→多次探索→删」的长间隔正常迭代误判为紧凑回环。 +pub(crate) type DestructiveLoopSample = Option<(String, String)>; + +/// 路径规范化:分隔符统一为 / + 小写,用于写删同路径判定(Windows 路径 \ / 混用与大小写不敏感)。 +pub(crate) fn normalize_loop_path(p: &str) -> String { + p.replace('\\', "/").to_lowercase() +} + +/// 从工具调用提取回环样本:write_file/delete_file 取 path 参数规范化,其他工具 None 占位。 +/// path 缺失/非字符串 → None(无路径无从判同路径,退化为占位)。 +pub(crate) fn destructive_loop_sample(name: &str, args: &serde_json::Value) -> DestructiveLoopSample { + match name { + "write_file" | "delete_file" => { + let p = arg_str(args, "path"); + if p.is_empty() { None } else { Some((name.to_string(), normalize_loop_path(&p))) } + } + _ => None, + } +} + +/// 破坏性回环检测:窗口内「write_file(P) 后 GAP 步内 delete_file(P)」或反向 → 命中路径。 +/// +/// GAP=6(全工具调用序位):写删交替紧凑才判回环,间隔远的写与删是正常迭代修复不算。 +/// 返回首个命中对的规范化路径(警示/熔断文本可引用),无命中 → None。 +pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Option { + /// 回环对最大间隔步数(按全部工具调用序位)。 + const DESTRUCTIVE_LOOP_GAP: usize = 6; + + for i in 0..samples.len() { + let (na, pa) = match samples[i].as_ref() { + Some(s) => s, + None => continue, + }; + let opposite = match na.as_str() { + "write_file" => "delete_file", + "delete_file" => "write_file", + _ => continue, + }; + // j 限 (i, i+GAP]:紧凑间隔内找反向同路径操作。 + for j in (i + 1)..samples.len().min(i + 1 + DESTRUCTIVE_LOOP_GAP) { + if let Some((nb, pb)) = samples[j].as_ref() { + if pb == pa && nb == opposite { + return Some(pa.clone()); + } + } + } + } + None +} + /// 纯问候判定:纯社交短文本(你好/谢谢/在吗等)→ true。机制层治弱模型把问候当指令 /// 擅自调工具(会话 b4d6b4e0:用户"你好"→ list_project_modules + 探索源码)。 /// 规则:去空白后 ≤8 字,且不含动作词/实体引用(@[)。"你好,看看 moyu 项目"含动作词+超长 → false。 @@ -505,6 +567,99 @@ mod tests { assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复"); } + // ── 破坏性回环检测(detect_destructive_loop)测试 ── + + /// 测试辅助:(工具名, args) 序列 → 回环样本缓冲。 + fn loop_samples_of(calls: &[(&str, serde_json::Value)]) -> Vec { + calls.iter().map(|(n, a)| destructive_loop_sample(n, a)).collect() + } + + /// 核心回归(真实案例:清理任务 rename→delete→write→list→delete→write 自毁循环): + /// write(P)→delete(P)→write(P) 签名各不相同(G2 唯一率≈0.83 漏网),回环检测应命中。 + #[test] + fn loop_write_delete_write_detected() { + let samples = loop_samples_of(&[ + ("write_file", serde_json::json!({"path": "out.txt", "content": "x"})), + ("delete_file", serde_json::json!({"path": "out.txt"})), + ("write_file", serde_json::json!({"path": "out.txt", "content": "y"})), + ]); + assert_eq!(detect_destructive_loop(&samples), Some("out.txt".to_string())); + } + + /// 反向对:delete(P) 后紧跟 write(P)(删了又写回)同属回环。 + #[test] + fn loop_delete_then_write_detected() { + let samples = loop_samples_of(&[ + ("delete_file", serde_json::json!({"path": "a.rs"})), + ("write_file", serde_json::json!({"path": "a.rs", "content": "x"})), + ]); + assert_eq!(detect_destructive_loop(&samples), Some("a.rs".to_string())); + } + + /// 不误报:对同/不同文件连续 write(A)→write(B) 正常序列,无反向操作不判回环。 + #[test] + fn loop_normal_writes_no_false_positive() { + let samples = loop_samples_of(&[ + ("write_file", serde_json::json!({"path": "a.rs"})), + ("write_file", serde_json::json!({"path": "b.rs"})), + ("write_file", serde_json::json!({"path": "a.rs"})), + ]); + assert_eq!(detect_destructive_loop(&samples), None, "纯写入交替不算回环"); + } + + /// 路径规范化:分隔符(\ /)与大小写差异视为同路径。 + #[test] + fn loop_path_normalized() { + let samples = loop_samples_of(&[ + ("write_file", serde_json::json!({"path": "SRC\\Lib.RS"})), + ("grep", serde_json::json!({"pattern": "x", "path": "y"})), + ("delete_file", serde_json::json!({"path": "src/lib.rs"})), + ]); + assert_eq!(detect_destructive_loop(&samples), Some("src/lib.rs".to_string())); + } + + /// 间隔语义:中间穿插的其他工具调用计入步数,None 占位跳过判定但不缩间隔。 + #[test] + fn loop_interleaved_tools_counted() { + let samples = loop_samples_of(&[ + ("write_file", serde_json::json!({"path": "p.txt"})), + ("grep", serde_json::json!({"pattern": "x", "path": "y"})), + ("delete_file", serde_json::json!({"path": "p.txt"})), + ]); + assert_eq!(detect_destructive_loop(&samples), Some("p.txt".to_string()), "间隔 2 步 ≤ GAP=6 应命中"); + } + + /// 间隔超 GAP 不判回环:写后隔 6 个其他调用才删(第 8 位,间隔 7)是正常迭代修复。 + #[test] + fn loop_gap_beyond_threshold_not_detected() { + let samples = loop_samples_of(&[ + ("write_file", serde_json::json!({"path": "p.txt"})), + ("grep", serde_json::json!({"pattern": "1", "path": "y"})), + ("grep", serde_json::json!({"pattern": "2", "path": "y"})), + ("grep", serde_json::json!({"pattern": "3", "path": "y"})), + ("grep", serde_json::json!({"pattern": "4", "path": "y"})), + ("grep", serde_json::json!({"pattern": "5", "path": "y"})), + ("grep", serde_json::json!({"pattern": "6", "path": "y"})), + ("delete_file", serde_json::json!({"path": "p.txt"})), + ]); + assert_eq!(detect_destructive_loop(&samples), None, "间隔 7 步 > GAP=6 不应命中"); + } + + /// 间隔恰为 GAP(第 7 位,间隔 6)仍是回环边界内。 + #[test] + fn loop_gap_exactly_at_threshold_detected() { + let samples = loop_samples_of(&[ + ("write_file", serde_json::json!({"path": "p.txt"})), + ("grep", serde_json::json!({"pattern": "1", "path": "y"})), + ("grep", serde_json::json!({"pattern": "2", "path": "y"})), + ("grep", serde_json::json!({"pattern": "3", "path": "y"})), + ("grep", serde_json::json!({"pattern": "4", "path": "y"})), + ("grep", serde_json::json!({"pattern": "5", "path": "y"})), + ("delete_file", serde_json::json!({"path": "p.txt"})), + ]); + assert_eq!(detect_destructive_loop(&samples), Some("p.txt".to_string()), "间隔 6 步 = GAP 应命中"); + } + /// 场景样本不足:仅 3 个签名(即使全同)。判 false(不判)。 #[test] fn scenario_insufficient_sample_not_repetitive() {