新增: 写删同路径破坏性回环检测

写后紧删同路径是自毁循环直接证据,签名熔断对写删交替漏网(每步签名不同)
按工具与规范化路径对查,独立计数器与签名缓冲并行互不干扰
其他工具占位计入间隔,防写后多次探索再删的正常迭代误判
This commit is contained in:
lxy
2026-10-01 02:42:25 +08:00
parent ab05be7e4c
commit 4efadf4952
@@ -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<String> {
/// 回环对最大间隔步数(按全部工具调用序位)。
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<DestructiveLoopSample> {
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() {