修复: AI 对话/工具可靠性(sanitize 三元组 + G2 签名重复 + handshake 不杀 loop + 空 tool_call id 兜底)

治 5 个对话停止/工具失败根因:sanitize 三元组按 id 配对治 400;G2 探索熔断从结果空
改签名重复判定(治误停正常探索);handshake 删越权强杀活 loop(generating 归 guard 单源);
空 tool_call id 兜底 gen_<index>(治 SenseNova 工具结果路由错位)。
This commit is contained in:
lxy
2026-08-02 02:21:30 +08:00
parent c76e77bd3c
commit 57d6a2d066
8 changed files with 991 additions and 185 deletions
+128 -47
View File
@@ -154,35 +154,41 @@ pub const MAX_GOALS: usize = 5;
pub const TOPIC_MARKER_GOAL_AWARE: bool = true;
// ============================================================
// G2 探索熔断(治 R4 连续空结果无进展死循环 + token 失控,2026-06-26)
// G2 探索熔断(治 R4 探索无进展死循环 + token 失控)
//
// 机制(非 prompt 说教):连续 N 轮工具结果全空(空成功,如 search_files/grep 无命中) →
// 两段式处理(STALL_BREAKER_WARN_FIRST):先警示注入下轮 prompt 给 LLM 自纠机会,
// 再熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复失败(禁止/失败/Error 关键词),
// G2 治反复空成功(total:0/无匹配),两者独立计数互不干扰
// 直接治实测 seq287-307 连续 10+ 次 search_files 空结果死循环(阈值 3 提前 7 次熔断,省巨量 token)。
// 判定范式(2026-08-01 根本性重构):
// - 旧(废弃):连续 N 轮工具结果全空(is_empty_tool_result 关键词判空成功)。错误代理指标,
// grep 无匹配是有效排除信号非漂移,误熔断正常排除式搜索(实证 ac448296)
// - 新(当前):连续 N 轮工具调用签名重复(is_repetitive_exploration 判 tool_call_signature)。
// 漂移本质 = AI 卡住反复同样调用;正常探索(换词/换路径/换工具)签名不同,真死循环签名重复
//
// 机制(非 prompt 说教,骨架沿用):连续 N 轮判定签名重复 → 两段式(WARN_FIRST 先警示再熔断):
// 警示注入下轮 prompt 给 LLM 自纠机会,熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。
// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复同类失败(失败信号归一 key),
// G2 治反复同类调用(调用签名归一),两者独立计数互不干扰。
// ============================================================
/// G2 总开关:探索熔断是否启用(默认 true)。
///
/// true(默认):process_tool_calls 后取末尾连续 Tool 消息判空结果,全空累计 stall_count,
/// 达 STALL_BREAKER_THRESHOLD → 警示/熔断。false(回退):整段跳过,stall_count 永远 0,
/// 退 max_iterations 旧行为(可能继续游荡但行为不变,单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
/// true(默认):process_tool_calls 后取最近 N 个 assistant tool_calls 签名,喂
/// is_repetitive_exploration 判重复,重复累计 stall_count,达 STALL_BREAKER_THRESHOLD → 警示/熔断。
/// false(回退):整段跳过,stall_count 永远 0,退 max_iterations 旧行为(可能继续游荡但行为不变,
/// 单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。
pub const STALL_BREAKER_ENABLED: bool = true;
/// G2 连续空结果阈值(默认 3)。
/// G2 连续重复阈值(默认 3)。
///
/// 评审一致建议 3 而非 5(对齐 CIRCUIT_BREAKER_THRESHOLD=3):空成功比硬失败更隐蔽,
/// 阈值应更激进。治实测连续 10+ 次空结果(提前 7 次熔断)。is_empty_tool_result 关键词
/// 漏判风险由 threshold=3 + 两段式 WARN_FIRST 容错:多轮漏判才误熔断,最坏退 max_iterations 兜底。
/// 对齐 CIRCUIT_BREAKER_THRESHOLD=3:连续 3 轮判定签名重复足以判漂移。新范式(签名重复)比
/// 旧范式(结果空)更精准——签名重复是漂移的直接证据,无关键词漏判风险。阈值 3 在
/// 两段式 WARN_FIRST(== THRESHOLD-1 即 2 轮先警示)容错下,误杀面收敛到「连续 3 轮同一组调用」
/// 的真实死循环场景,最坏退 max_iterations 兜底。
pub const STALL_BREAKER_THRESHOLD: u32 = 3;
/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。
///
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 已连续 N 次空结果,可能偏离目标,
/// 请回顾目标换思路或停止」到下轮 system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
/// 降误杀:用户正当探索(如确认无相关文件)先警示再熔断。false(激进):直接熔断不警示,
/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 检测到重复的工具调用 ...」到下轮
/// system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。
/// 降误杀:偶发的相似调用(如分段读后回读同一处确认)先警示再熔断。false(激进):直接熔断不警示,
/// 确认无误杀场景用。
pub const STALL_BREAKER_WARN_FIRST: bool = true;
@@ -192,6 +198,12 @@ pub const STALL_BREAKER_WARN_FIRST: bool = true;
/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。
pub const STALL_BREAKER_GOAL_REMIND: bool = true;
/// G2 重复检测样本量:check_stall_breaker 扫历史 assistant tool_calls 取最近多少个签名喂判定。
///
/// 12 = 覆盖 2-3 轮多工具调用(单轮可并行多个 grep/read),既能让 is_repetitive_exploration 的
/// 「最小样本 6」+「单点 3 次」阈值有意义,又不至于扫太长历史稀释近期漂移信号。
pub const STALL_BREAKER_SAMPLE_SIZE: usize = 12;
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
///
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
@@ -285,7 +297,7 @@ pub mod command_lock;
pub(crate) mod helpers;
pub(crate) use helpers::try_continue_agent_loop;
pub(crate) use helpers::infer_goal_from_tool_calls;
pub(crate) use helpers::is_empty_tool_result;
pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration};
// ============================================================
// 单 Provider 流式结果 + fallback 辅助
@@ -843,9 +855,12 @@ pub(crate) async fn run_agentic_loop(
// 治"全 loop 累加不衰减":早期偶发失败不会与后期叠加误熔断。
let mut fail_window: std::collections::VecDeque<String> = std::collections::VecDeque::new();
// G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_window 同生命周期)。
// 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1,
// 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)
// G2 探索熔断:重复探索计数器(loop 生命周期累计,与 fail_window 同生命周期)。
// 每轮 process_tool_calls 后 check_stall_breaker 取最近 N 个 assistant tool_calls 签名,
// 喂 is_repetitive_exploration 判重复,重复 stall_count+=1,非重复重置 0
// 达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。
// 注:2026-08-01 从「末尾 Tool 结果是否空(is_empty_tool_result)」改为「调用签名重复」,
// 治旧范式误熔断正常排除式搜索(实证会话 ac448296)。
let mut stall_count: u32 = 0;
let mut stall_warned: bool = false;
@@ -1932,6 +1947,23 @@ fn summarize_tool_results(
let mut compressed_bytes: usize = 0;
let mut original_bytes: usize = 0;
let mut summarized_count: usize = 0;
// 构建 tool_call_id -> tool_name 映射:role=Tool 消息只带 tool_call_id(调用 ID),
// 真实工具名(read_file/run_command/...)在前序 role=Assistant 消息的 tool_calls[].function.name。
// 旧实现误把 tool_call_id(如 call_f892)当 tool_name 传给 extract_key_info,导致:
// 1) 摘要头注释显示 call_f892 而非 read_file(显示 bug);
// 2) extract_key_info 的 read_file 豁免永远命中不了(传入的不是真实名)。
// 用 owned String(非 &str 借用)避开与 messages.into_iter() 的所有权冲突;
// tool_call 数量极少(单轮几次),克隆开销可忽略。
let mut id_to_name: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for m in &messages {
if let Some(calls) = m.tool_calls.as_ref() {
for c in calls {
id_to_name.insert(c.id.clone(), c.function.name.clone());
}
}
}
let msgs: Vec<ChatMessage> = messages
.into_iter()
.map(|mut m| {
@@ -1943,9 +1975,25 @@ fn summarize_tool_results(
if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) {
return m;
}
// 解析真实工具名:tool_call_id 查表;查不到(异常/老数据)退化为 "tool"。
let resolved_name = m
.tool_call_id
.as_ref()
.and_then(|id| id_to_name.get(id))
.map(|s| s.as_str())
.unwrap_or("tool");
// read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。
// read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。
if resolved_name == "read_file" {
tracing::debug!(
conv_id = %conv_id, iteration,
content_len,
"[ai] read_file 工具结果豁免压缩(AI 定向读代码)"
);
return m;
}
original_bytes += content_len;
let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string());
let compressed = extract_key_info(&m.content, &tool_name);
let compressed = extract_key_info(&m.content, resolved_name);
compressed_bytes += compressed.len();
summarized_count += 1;
m.content = compressed;
@@ -1988,7 +2036,13 @@ fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count
// 返回 (tripped, new_stall_count, new_stall_warned):
// - tripped=true → 已 emit AiHelpRequired 且 loop 应 return(调用方需先 guard.reset 再 return)
// - tripped=false → 正常继续,新 stall_count/warned 供调用方回写。
// 嵌套 ≤ 3 层:全空判→计数/警示→达阈值 emit,各段早返回。
//
// 2026-08-01 根本性重构:判定从「末尾 Tool 结果是否空(is_empty_tool_result)」改为
// 「最近 N 个 assistant tool_calls 签名是否重复(tool_call_signature + is_repetitive_exploration)」。
// 治旧范式误熔断正常排除式搜索(grep 无匹配是有效排除信号,非漂移)。熔断骨架(计数/警示/
// 阈值/emit/return)不变,只换判定输入。
//
// 嵌套 ≤ 3 层:取签名→计数/警示→达阈值 emit,各段早返回。
async fn check_stall_breaker(
session_arc: &Arc<Mutex<AiSession>>,
app_handle: &AppHandle,
@@ -1997,31 +2051,49 @@ async fn check_stall_breaker(
stall_count: u32,
stall_warned: bool,
) -> (bool, u32, bool) {
// 1) 读取末尾连续 Tool 消息(本轮工具回填结果),判断全空或任一非空
let all_empty: Option<bool> = {
// 1) 取最近 N 个 assistant tool_calls 签名:倒序扫 messages,遇 Assistant 且有 tool_calls
// 就抽取每工具签名(name + arguments JSON parse),累积到 STALL_BREAKER_SAMPLE_SIZE 个。
// arguments 非合法 JSON 时 to_string 兜底(签名仍可比,只是兜底全量)。
let signatures: Vec<String> = {
let session = session_arc.lock().await;
let messages = match session.conv_read(conv_id) {
Some(conv) => conv.messages.all_messages_clone(),
None => Vec::new(),
};
let recent_tool_results: Vec<&ChatMessage> = messages
.iter().rev()
.take_while(|m| matches!(m.role, MessageRole::Tool))
.collect();
if recent_tool_results.is_empty() {
None // 本轮无工具回填(非工具轮)→ 不计入也不重置
} else {
let any_non_empty = recent_tool_results
.iter().any(|m| !is_empty_tool_result(&m.content));
Some(!any_non_empty)
let mut sigs: Vec<String> = Vec::new();
for m in messages.iter().rev() {
if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE {
break;
}
if !matches!(m.role, MessageRole::Assistant) {
continue;
}
if let Some(tool_calls) = m.tool_calls.as_ref() {
// 单 assistant 消息内多个 tool_call 按声明顺序取,倒序累积(近期在前)。
for tc in tool_calls.iter().rev() {
if sigs.len() >= STALL_BREAKER_SAMPLE_SIZE {
break;
}
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));
}
}
}
// 倒序收集的签名需反转为时间正序,保持 is_repetitive_exploration 的语义稳定
// (虽纯函数对顺序不敏感,但反转后日志/调试更直观)。
sigs.reverse();
sigs
};
let all_empty_flag = match all_empty { Some(v) => v, None => return (false, stall_count, stall_warned) };
// 本轮无工具调用(纯文本轮或首轮)→ 不计入也不重置,保持 stall_count。
if signatures.is_empty() {
return (false, stall_count, stall_warned);
}
// 2) 更新 stall_count / stall_warned
// 2) 签名重复判定 → 更新 stall_count / stall_warned(沿用原 reset 语义)
let (mut new_count, mut new_warned) = (stall_count, stall_warned);
if all_empty_flag {
if is_repetitive_exploration(&signatures) {
new_count += 1;
} else {
new_count = 0;
@@ -2044,13 +2116,14 @@ async fn check_stall_breaker(
tracing::warn!(
conv_id = %conv_id,
stall_count = new_count,
"[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", new_count
sample_size = signatures.len(),
"[ai] G2 探索熔断:连续 {} 次检测到重复工具调用,疑似卡住停止", new_count
);
let _ = app_handle.emit(
"ai-chat-event",
AiChatEvent::AiHelpRequired {
reason: format!("连续 {} 次工具返回空结果无进展,疑似目标漂移已停止", new_count),
context: "连续探索无产出,可能偏离原始目标或无可用数据".into(),
reason: format!("连续 {} 次检测到重复的工具调用(同样的 grep/读文件反复),疑似卡住已停止", new_count),
context: "工具调用签名高度重复,可能在反复做同一件事而无进展".into(),
options: vec!["回顾目标".into(), "换思路".into(), "停止".into()],
conversation_id: Some(conv_id.to_string()),
},
@@ -2071,8 +2144,8 @@ async fn insert_stall_warning(
String::new()
};
let warn_text = format!(
"已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。",
stall_count, goal_text
"检测到重复的工具调用(同样的 grep/读文件反复),可能卡住{},请回顾目标换思路或停止探索。",
goal_text
);
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
@@ -2080,7 +2153,7 @@ async fn insert_stall_warning(
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
tracing::info!(
conv_id = %conv_id, iteration, stall_count,
"[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)"
"[ai] G2 探索熔断:重复调用警示已 insert(软提示,给 LLM 自纠机会)"
);
}
}
@@ -2136,9 +2209,17 @@ fn push_assistant_message(
let mut order: Vec<u32> = tool_calls_acc.keys().copied().collect();
order.sort_unstable();
let ai_tool_calls: Vec<df_ai::provider::ToolCall> = order.iter()
.map(|i| {
.enumerate()
.map(|(pos, i)| {
let draft = &tool_calls_acc[i];
df_ai::provider::ToolCall::new(&draft.id, &draft.name, &draft.args)
// CR-空 id 最终防御:流式 chunk 解析点(openai_helpers)已对 Some("") 生成
// fallback,但若 provider 流式完全不发 id 字段(chunk id=Noneaccumulate
// 不覆盖),draft.id 残留空串。此处按 sorted order 位置兜底
// `gen_stream_{pos}`,保证 push 到 messages 的 assistant tool_call.id 非空唯一
// (下游 audit/mod.rs:203 seen_ids 去重 + 工具结果按 tool_call_id 路由依赖此)。
// 非空 id 原样(与 chunk 解析点 fallback 共用 helperDRY)。
let id = df_ai::provider::tool_call_id_or_fallback(&draft.id, pos, "gen_stream");
df_ai::provider::ToolCall::new(id, &draft.name, &draft.args)
})
.collect();
let mut msg = ChatMessage::assistant_with_tools(full_text, ai_tool_calls);