修复: G2 签名熔断误杀红绿迭代

- 同签名死磕判定改无写间隔连续段口径,间插写类调用=红绿验证不累计
- 写证据=is_write_evidence_tool 五个改文件工具,读类不算证据
- 阈值 3 与纯死磕语义不变,根治脚本失败致执行中断
- 新增红绿迭代豁免与读间隔死磕两场景测试
This commit is contained in:
lxy
2026-10-01 22:36:26 +08:00
parent 26d9a1af1e
commit dd0afcf0d1
2 changed files with 96 additions and 28 deletions
+87 -24
View File
@@ -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) df_ai::ai_tools::tool_signature(name, args)
} }
/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。 /// G2 重复检测纯函数:判定最近 N 个工具调用样本是否构成「卡住反复」。
/// ///
/// 策略组合(两者任一命中即 true,注释论证稳健性): /// 样本 = (签名, 是否写类调用)。策略(后两条任一命中即 true):
/// - 样本不足(len < `REPETITION_MIN_SAMPLE`=6)→ false(不判,小样本误杀风险高)。 /// - 样本不足(len < `REPETITION_MIN_SAMPLE`=6)→ false(不判,小样本误杀风险高)。
/// - **唯一签名数 / 总数 < 0.4**(超 60% 重复)→ true。 /// - **唯一签名数 / 总数 < 0.4**(超 60% 重复)→ true。
/// 覆盖「多个签名轮换但整体高度重复」(如 a/b/c/a/b/c/d/a/b),唯一率低 = 没有新探索方向。 /// 覆盖「多个签名轮换但整体高度重复」(如 a/b/c/a/b/c/d/a/b),唯一率低 = 没有新探索方向。
/// - **或:某签名出现次数 >= 3** → true。 /// - **或:某签名「无写间隔连续段」>= 3** → true。覆盖「单点反复」。
/// 覆盖「单点反复」(如 a,a,a,b,c),唯一率 3/5=0.6 不触发上条,但 a 已 3 次死磕 = 漂移。
/// ///
/// 两条互补:唯一率治整体游荡不前进,单点计数治单点死磕。组合后覆盖真实漂移的两种形态, /// 写间隔豁免(脚本失败致执行中断的根治):验证命令(测试/构建/lint)反复跑但两次之间
/// 且对正常探索(签名持续翻新)宽松——换词 grep + 不同文件 read 各一两次,唯一率高不触发。 /// 夹有写类调用(patch/write 改了世界)是红绿迭代——重跑是验证行为,不与历史失败累计;
pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool { /// 中间只有读类调用不算证据(世界未变,重跑仍是无进展重复)。原「总出现次数 >= 3」口径
/// 会把红绿迭代误判漂移(12 样本窗口内同一测试命令必然出现 >= 3 次),阈值不变、纯死磕
/// 语义不变,只把"死磕"修准为"中间无写证据的重复"。
pub(crate) fn is_repetitive_exploration(samples: &[(String, bool)]) -> bool {
/// 最小样本量:不足此数不判定(避免早期误杀,如刚启动 2-3 个 grep 全不同不应熔断)。 /// 最小样本量:不足此数不判定(避免早期误杀,如刚启动 2-3 个 grep 全不同不应熔断)。
const REPETITION_MIN_SAMPLE: usize = 6; const REPETITION_MIN_SAMPLE: usize = 6;
/// 单签名出现次数阈值:达此即判单点死磕漂移。 /// 单签名无写间隔连续段阈值:达此即判单点死磕漂移。
const REPETITION_SINGLE_MAX: usize = 3; const REPETITION_SINGLE_MAX: usize = 3;
/// 唯一签名占比阈值:低于此(重复超 60%)判整体游荡不前进。 /// 唯一签名占比阈值:低于此(重复超 60%)判整体游荡不前进。
const REPETITION_UNIQUE_RATIO: f64 = 0.4; const REPETITION_UNIQUE_RATIO: f64 = 0.4;
if signatures.len() < REPETITION_MIN_SAMPLE { if samples.len() < REPETITION_MIN_SAMPLE {
return false; return false;
} }
let total = signatures.len(); let total = samples.len();
let unique = { let unique = {
let mut s: Vec<&String> = signatures.iter().collect(); let mut s: Vec<&String> = samples.iter().map(|(sig, _)| sig).collect();
s.sort(); s.sort();
s.dedup(); s.dedup();
s.len() s.len()
@@ -295,12 +297,32 @@ pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> bool {
if unique_ratio < REPETITION_UNIQUE_RATIO { if unique_ratio < REPETITION_UNIQUE_RATIO {
return true; return true;
} }
// 单点死磕:统计最高频签名出现次数。HashMap 避免重复 sort 计数,O(n)。 // 单点死磕:同签名「无写间隔连续段」计数,HashMap 按签名记 (上次下标,当前段长) O(n)。
let mut counts: std::collections::HashMap<&String, usize> = std::collections::HashMap::new(); let mut streaks: std::collections::HashMap<&String, (usize, usize)> =
for s in signatures { std::collections::HashMap::new();
*counts.entry(s).or_insert(0) += 1; 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 场景测试 ── // ── is_repetitive_exploration 场景测试 ──
/// 测试辅助:签名串 → (签名, 非写调用) 样本(绝大多数旧场景无写语义)。
fn reps(sigs: Vec<String>) -> Vec<(String, bool)> {
sigs.into_iter().map(|s| (s, false)).collect()
}
/// 场景 ac448296 实证:正常代码审查序列——换词 grep(MAX/truncate/fetch_url)+ /// 场景 ac448296 实证:正常代码审查序列——换词 grep(MAX/truncate/fetch_url)+
/// 不同文件 read_file,12 个签名全不同。**核心回归**:旧 is_empty_tool_result 误熔断此场景, /// 不同文件 read_file,12 个签名全不同。**核心回归**:旧 is_empty_tool_result 误熔断此场景,
/// 新签名判定应返回 false(不熔断)。 /// 新签名判定应返回 false(不熔断)。
@@ -517,7 +544,7 @@ mod tests {
tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})), tool_call_signature("read_file", &serde_json::json!({"path": "e.rs", "offset": 0, "limit": 50})),
]; ];
assert_eq!(sigs.len(), 12); assert_eq!(sigs.len(), 12);
assert!(!is_repetitive_exploration(&sigs), "正常代码审查不应判重复"); assert!(!is_repetitive_exploration(&reps(sigs)), "正常代码审查不应判重复");
} }
/// 场景死循环:同 grep 同 path 反复 8 次。判 true(熔断)。 /// 场景死循环:同 grep 同 path 反复 8 次。判 true(熔断)。
@@ -525,7 +552,7 @@ mod tests {
fn scenario_real_deadloop_repetitive() { fn scenario_real_deadloop_repetitive() {
let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"})); let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"}));
let sigs = vec![one; 8]; 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(签名不同)。 /// 场景分段读大文件:同 path 不同 offset=0/50/100/150/200/250,6 次。判 false(签名不同)。
@@ -535,7 +562,7 @@ mod tests {
let sigs: Vec<String> = offsets.iter() let sigs: Vec<String> = offsets.iter()
.map(|&o| tool_call_signature("read_file", &serde_json::json!({"path": "big.log", "offset": o, "limit": 50}))) .map(|&o| tool_call_signature("read_file", &serde_json::json!({"path": "big.log", "offset": o, "limit": 50})))
.collect(); .collect();
assert!(!is_repetitive_exploration(&sigs), "分段读不同 offset 不应判重复"); assert!(!is_repetitive_exploration(&reps(sigs)), "分段读不同 offset 不应判重复");
} }
/// 场景反复读同段:同 path 同 offset+limit 4 次(+ 其他 2 个不同凑足样本)。判 true(漂移)。 /// 场景反复读同段:同 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 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 other2 = tool_call_signature("list_directory", &serde_json::json!({"path": "z"}));
let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2]; 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)测试 ── // ── 破坏性回环检测(detect_destructive_loop)测试 ──
@@ -685,7 +748,7 @@ mod tests {
fn scenario_insufficient_sample_not_repetitive() { fn scenario_insufficient_sample_not_repetitive() {
let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"})); let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"}));
let sigs = vec![one; 3]; let sigs = vec![one; 3];
assert!(!is_repetitive_exploration(&sigs), "样本不足不应判"); assert!(!is_repetitive_exploration(&reps(sigs)), "样本不足不应判");
} }
/// 边界:恰好 6 个全同 → true(达最小样本 + 单点 6 >= 3)。 /// 边界:恰好 6 个全同 → true(达最小样本 + 单点 6 >= 3)。
@@ -693,7 +756,7 @@ mod tests {
fn boundary_exact_min_sample_all_same() { fn boundary_exact_min_sample_all_same() {
let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"})); let one = tool_call_signature("grep", &serde_json::json!({"pattern": "foo", "path": "x"}));
let sigs = vec![one; 6]; 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(整体游荡)。 /// 边界: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 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 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]; 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(正常探索)。 /// 边界:6 个签名全不同 → false(正常探索)。
@@ -712,7 +775,7 @@ mod tests {
let sigs: Vec<String> = patterns.iter() let sigs: Vec<String> = patterns.iter()
.map(|p| tool_call_signature("grep", &serde_json::json!({"pattern": p, "path": "x"}))) .map(|p| tool_call_signature("grep", &serde_json::json!({"pattern": p, "path": "x"})))
.collect(); .collect();
assert!(!is_repetitive_exploration(&sigs)); assert!(!is_repetitive_exploration(&reps(sigs)));
} }
// ── is_pure_greeting 纯问候判定测试 ── // ── is_pure_greeting 纯问候判定测试 ──
+9 -4
View File
@@ -185,6 +185,8 @@ pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
// grep 无匹配是有效排除信号非漂移,误熔断正常排除式搜索(实证 ac448296)。 // grep 无匹配是有效排除信号非漂移,误熔断正常排除式搜索(实证 ac448296)。
// - 新(当前):连续 N 轮工具调用签名重复(is_repetitive_exploration 判 tool_call_signature)。 // - 新(当前):连续 N 轮工具调用签名重复(is_repetitive_exploration 判 tool_call_signature)。
// 漂移本质 = AI 卡住反复同样调用;正常探索(换词/换路径/换工具)签名不同,真死循环签名重复。 // 漂移本质 = AI 卡住反复同样调用;正常探索(换词/换路径/换工具)签名不同,真死循环签名重复。
// 红绿迭代(同验证命令反复跑+间插写调用)按写间隔豁免不算漂移,见 is_repetitive_exploration。
// 红绿迭代豁免:同验证命令反复跑但间插写类调用(patch/write)不算漂移,见 is_repetitive_exploration 写间隔论证。
// //
// 机制(非 prompt 说教,骨架沿用):连续 N 轮判定签名重复 → 两段式(WARN_FIRST 先警示再熔断): // 机制(非 prompt 说教,骨架沿用):连续 N 轮判定签名重复 → 两段式(WARN_FIRST 先警示再熔断):
// 警示注入下轮 prompt 给 LLM 自纠机会,熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。 // 警示注入下轮 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::try_continue_agent_loop;
pub(crate) use helpers::infer_goal_from_tool_calls; pub(crate) use helpers::infer_goal_from_tool_calls;
pub(crate) use helpers::{ 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, DestructiveLoopSample, destructive_loop_sample, detect_destructive_loop,
}; };
@@ -1947,7 +1949,10 @@ async fn check_stall_breaker(
// 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。 // 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。
// arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。 // arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。
// 同时并行抽取回环样本(写/删工具记路径,其他占位),两缓冲同序位对齐。 // 同时并行抽取回环样本(写/删工具记路径,其他占位),两缓冲同序位对齐。
let (signatures, loop_samples): (Vec<String>, Vec<DestructiveLoopSample>) = { // 签名样本带写证据标记(写类调用 true),喂 is_repetitive_exploration 做红绿迭代豁免。
// 写类调用同时打写证据标记(is_write_evidence_tool),供 G2 单点死磕的写间隔豁免:
// 红绿迭代(同验证命令反复跑+间插写)=验证行为不算漂移,根治脚本失败致执行中断。
let (signatures, loop_samples): (Vec<(String, bool)>, Vec<DestructiveLoopSample>) = {
let session = session_arc.lock().await; let session = session_arc.lock().await;
// BE2只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用, // BE2只取尾部 BREAKER_TAIL_N 条(签名抽取只需最近几轮工具调用,
// 全量 clone 纯浪费;STALL_BREAKER_SAMPLE_SIZE=12 ≪ 40,样本充足)。 // 全量 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), Some(conv) => conv.messages.recent_messages(BREAKER_TAIL_N),
None => Vec::new(), None => Vec::new(),
}; };
let mut sigs: Vec<String> = Vec::new(); let mut sigs: Vec<(String, bool)> = Vec::new();
let mut loops: Vec<DestructiveLoopSample> = Vec::new(); let mut loops: Vec<DestructiveLoopSample> = Vec::new();
for m in messages.iter().rev() { for m in messages.iter().rev() {
if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE { if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE {
@@ -1972,7 +1977,7 @@ async fn check_stall_breaker(
} }
let args: serde_json::Value = let args: serde_json::Value =
serde_json::from_str(&tc.function.arguments).unwrap_or(serde_json::Value::Null); 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)); loops.push(destructive_loop_sample(&tc.function.name, &args));
} }
} }