Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea20a465d6 | ||
|
|
74cad6442b | ||
|
|
dd0afcf0d1 |
@@ -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<String>) -> 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<String> = 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<String> = 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 纯问候判定测试 ──
|
||||
|
||||
@@ -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<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;
|
||||
// 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<String> = Vec::new();
|
||||
let mut sigs: Vec<(String, bool)> = Vec::new();
|
||||
let mut loops: Vec<DestructiveLoopSample> = 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -879,12 +879,13 @@ const renderItems = computed<RenderItem[]>(() => {
|
||||
bucket = []
|
||||
if (msgs.length === 0) return
|
||||
|
||||
if (isViewingGenerating.value) {
|
||||
// 执行中:当前步骤锚定真实末条消息(agentic 新一轮 push 新空气泡,若按「末条有正文」
|
||||
// 找锚点会错挂上一轮,致当前轮打字机/光标/工具卡整条不渲染);先前步骤收进一行实时摘要。
|
||||
// 摘要统计取整个轮次桶(含当前消息):执行中工具挂在末条消息上(useAiToolEvents 取
|
||||
// state.messages 末条),只统计 prev 拿不到 runningName,完成计数也滞后一轮(黑盒主因)
|
||||
const cur = msgs[msgs.length - 1]
|
||||
// BUG-261001:执行中分支仅作用于「正在生成」的轮次桶(含 store 末条消息的桶)。
|
||||
// 原实现只判 isViewingGenerating——只要会话在生成,全部轮次桶(含早已完成的历史轮次)
|
||||
// 都被降级为不可交互的 step 摘要行,历史轨迹卡展开/折叠点击全部失效
|
||||
// (用户表象:新一轮执行时,上一轮轨迹也"不可点击")。历史桶保持完成态轨迹卡可点。
|
||||
const lastStoreId = store.state.messages[store.state.messages.length - 1]?.id
|
||||
const cur = msgs[msgs.length - 1]
|
||||
if (isViewingGenerating.value && cur.id === lastStoreId) {
|
||||
const prev = msgs.slice(0, -1)
|
||||
if (prev.length > 0) {
|
||||
pushTimeSepIfNeeded(items, msgs[0].timestamp)
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<span v-if="!collapsed" class="nav-link-text">{{ $t('nav.aiPanel') }}</span>
|
||||
<span v-if="!collapsed && runningConvCount > 0" class="nav-link-badge">{{ runningConvCount }}</span>
|
||||
<span v-if="collapsed && runningConvCount > 0" class="ai-running-dot"></span>
|
||||
<span v-if="!collapsed" class="ai-toggle-shortcut">⌘I</span>
|
||||
<span v-if="!collapsed" class="ai-toggle-shortcut">Ctrl+I</span>
|
||||
</button>
|
||||
<router-link to="/settings" class="nav-link" active-class="nav-link--active" :title="collapsed ? $t('nav.settings') : undefined">
|
||||
<span class="nav-link-icon">
|
||||
|
||||
Reference in New Issue
Block a user