修复: 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
+267 -24
View File
@@ -196,37 +196,280 @@ pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<
goals
}
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
let trimmed = content.trim();
if trimmed.is_empty() { return true; }
const EMPTY_MARKERS: &[&str] = &[
"\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]",
];
for marker in EMPTY_MARKERS {
if trimmed.contains(marker) { return true; }
// ============================================================
// G2 探索熔断(2026-08-01 根本性重构:从「结果空」判漂移 → 「调用签名重复」判漂移)
//
// 旧范式(is_empty_tool_result)用关键词(`"matches":[]`/`"total":0`/未找到...)判
// 「空成功」,是**错误代理指标**:grep 无匹配是有效排除信号(AI 换词定位/排除路径),
// 非漂移。实测会话 ac448296 系统 grep 多关键词(部分无匹配)→ 整轮全空 stall+=1 →
// 连续 3 轮误熔断 → 对话莫名停止(详见 memory `devflow-g2-stall-false-positive`)。
//
// 新范式:漂移的本质 = AI 卡住**反复做同样的工具调用**。正常探索(换词/换路径/换工具)
// 签名不同;真死循环(同调用反复)签名重复。判「签名重复」直接命中漂移本质,不再误杀
// 正常排除式搜索。
//
// 调用点:check_stall_breaker(agentic/mod.rs) 取最近 N 个 assistant tool_calls 签名,
// 喂 is_repetitive_exploration 判定,重复 → stall_count+=1(沿用熔断骨架不变)。
// ============================================================
/// 从 args JSON Value 取字符串字段,缺失/非字符串 → 空串(归一兜底,签名不 panic)。
fn arg_str(args: &serde_json::Value, key: &str) -> String {
args.get(key)
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_default()
}
/// G2 签名归一化:把每工具「决定意图」的参数压成一个可比对字符串 `"name:k1=v1,k2=v2"`。
///
/// 选「决定意图」参数(决定这次调用"去哪儿查什么"的参数),非决定参数(如 case_sensitive/
/// show_line_numbers/timeout 等开关/格式选项)忽略——它们变体不构成漂移。
///
/// 归一规则(参数从 args JSON 取,缺失用空串):
/// - grep/search_files/search: `pattern`(或 `query`)+ `path`(或 `glob`)
/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。
/// - read_file: `path` + `offset` + `limit`
/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。
/// - run_command: `command`(整条命令,含参数)。
/// - list_dir/list_directory: `path`。
/// - http_request: `url` + `method`。
/// - fetch_url: `url`。
/// - 其他/兜底: `name` + args 全 JSON 序列化(无明确语义时保守全量,避免漏判)。
///
/// 返回 `"name:k1=v1,k2=v2"` 形式。输入 args 通常来自 LLM 的 tool_call function.arguments
/// (JSON 字符串),调用方先 from_str 成 Value 再传入。
pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String {
let pair = |k: &str, v: &str| format!("{}={}", k, v);
let sig = match name {
"grep" | "search_files" | "search" => {
let q = if args.get("pattern").and_then(|v| v.as_str()).is_some() {
arg_str(args, "pattern")
} else {
arg_str(args, "query")
};
let p = if args.get("path").and_then(|v| v.as_str()).is_some() {
arg_str(args, "path")
} else {
arg_str(args, "glob")
};
format!("{},{}", pair("pattern", &q), pair("path", &p))
}
"read_file" => {
// offset/limit 数值字段:as_str 不通,先取再转字符串(缺失→"")。
let offset = args.get("offset").map(|v| v.to_string()).unwrap_or_default();
let limit = args.get("limit").map(|v| v.to_string()).unwrap_or_default();
let path = arg_str(args, "path");
format!("{},{},{}", pair("path", &path), pair("offset", &offset), pair("limit", &limit))
}
"run_command" => pair("command", &arg_str(args, "command")),
"list_dir" | "list_directory" => pair("path", &arg_str(args, "path")),
"http_request" => {
let url = arg_str(args, "url");
let method = arg_str(args, "method");
format!("{},{}", pair("url", &url), pair("method", &method))
}
"fetch_url" => pair("url", &arg_str(args, "url")),
_ => {
// 兜底:工具名 + args 全 JSON 序列化(保守,无明确语义时全量比对)。
format!("{},{}", pair("name", name), pair("args", &args.to_string()))
}
};
format!("{}:{}", name, sig)
}
/// G2 重复检测纯函数:判定最近 N 个工具调用签名是否构成「卡住反复」。
///
/// 策略组合(两者任一命中即 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 次死磕 = 漂移。
///
/// 两条互补:唯一率治整体游荡不前进,单点计数治单点死磕。组合后覆盖真实漂移的两种形态,
/// 且对正常探索(签名持续翻新)宽松——换词 grep + 不同文件 read 各一两次,唯一率高不触发。
pub(crate) fn is_repetitive_exploration(signatures: &[String]) -> 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 {
return false;
}
const EMPTY_TEXT: &[&str] = &[
"未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录",
"No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合",
];
for marker in EMPTY_TEXT {
if trimmed.contains(marker) { return true; }
let total = signatures.len();
let unique = {
let mut s: Vec<&String> = signatures.iter().collect();
s.sort();
s.dedup();
s.len()
};
let unique_ratio = unique as f64 / total as f64;
if unique_ratio < REPETITION_UNIQUE_RATIO {
return true;
}
false
// 单点死磕:统计最高频签名出现次数。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;
}
counts.values().any(|&c| c >= REPETITION_SINGLE_MAX)
}
#[cfg(test)]
mod tests {
use super::*;
// ── tool_call_signature 归一化测试 ──
#[test]
fn test_empty_tool_result() {
assert!(is_empty_tool_result(""));
assert!(is_empty_tool_result(" "));
assert!(is_empty_tool_result(r#"{"total":0}"#));
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
assert!(is_empty_tool_result("未找到相关文件"));
assert!(!is_empty_tool_result(r#"{"total":5}"#));
assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#));
fn sig_grep_takes_pattern_and_path() {
let args = serde_json::json!({"pattern": "MAX", "path": "src/lib.rs", "case_sensitive": true});
assert_eq!(tool_call_signature("grep", &args), "grep:pattern=MAX,path=src/lib.rs");
// 非决定参数(case_sensitive)不进签名
}
#[test]
fn sig_search_falls_back_to_query_and_glob() {
let args = serde_json::json!({"query": "TODO", "glob": "**/*.rs"});
assert_eq!(tool_call_signature("search_files", &args), "search_files:pattern=TODO,path=**/*.rs");
}
#[test]
fn sig_read_file_includes_offset_limit() {
let args = serde_json::json!({"path": "big.log", "offset": 50, "limit": 100});
assert_eq!(tool_call_signature("read_file", &args), "read_file:path=big.log,offset=50,limit=100");
}
#[test]
fn sig_run_command_takes_command() {
let args = serde_json::json!({"command": "ls -la", "timeout": 5000});
assert_eq!(tool_call_signature("run_command", &args), "run_command:command=ls -la");
}
#[test]
fn sig_list_dir_takes_path() {
let args = serde_json::json!({"path": "/tmp"});
assert_eq!(tool_call_signature("list_dir", &args), "list_dir:path=/tmp");
}
#[test]
fn sig_http_request_takes_url_method() {
let args = serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}});
assert_eq!(tool_call_signature("http_request", &args), "http_request:url=https://x.io,method=GET");
}
#[test]
fn sig_fetch_url_takes_url() {
let args = serde_json::json!({"url": "https://y.io", "raw": false});
assert_eq!(tool_call_signature("fetch_url", &args), "fetch_url:url=https://y.io");
}
#[test]
fn sig_unknown_falls_back_to_full_args() {
let args = serde_json::json!({"x": 1, "y": "z"});
let sig = tool_call_signature("custom_tool", &args);
assert!(sig.starts_with("custom_tool:name=custom_tool,args="));
assert!(sig.contains("\"x\":1"));
assert!(sig.contains("\"y\":\"z\""));
}
#[test]
fn sig_missing_args_default_empty() {
// 无任何字段,grep 兜底 pattern/path 都空,签名仍可构造不 panic。
let args = serde_json::json!({});
assert_eq!(tool_call_signature("grep", &args), "grep:pattern=,path=");
assert_eq!(tool_call_signature("read_file", &args), "read_file:path=,offset=,limit=");
assert_eq!(tool_call_signature("run_command", &args), "run_command:command=");
}
// ── is_repetitive_exploration 场景测试 ──
/// 场景 ac448296 实证:正常代码审查序列——换词 grep(MAX/truncate/fetch_url)+
/// 不同文件 read_file,12 个签名全不同。**核心回归**:旧 is_empty_tool_result 误熔断此场景,
/// 新签名判定应返回 false(不熔断)。
#[test]
fn scenario_normal_code_review_not_repetitive() {
let sigs = vec![
tool_call_signature("grep", &serde_json::json!({"pattern": "MAX", "path": "src"})),
tool_call_signature("grep", &serde_json::json!({"pattern": "truncate", "path": "src"})),
tool_call_signature("grep", &serde_json::json!({"pattern": "fetch_url", "path": "src"})),
tool_call_signature("grep", &serde_json!({"pattern": "MessageRole", "path": "crates"})),
tool_call_signature("grep", &serde_json!({"pattern": "pub enum", "path": "src"})),
tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json!({"path": "b.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json!({"path": "c.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json!({"path": "d.rs", "offset": 100, "limit": 50})),
tool_call_signature("list_dir", &serde_json!({"path": "src-tauri"})),
tool_call_signature("grep", &serde_json!({"pattern": "STALL", "path": "src"})),
tool_call_signature("read_file", &serde_json!({"path": "e.rs", "offset": 0, "limit": 50})),
];
assert_eq!(sigs.len(), 12);
assert!(!is_repetitive_exploration(&sigs), "正常代码审查不应判重复");
}
/// 场景死循环:同 grep 同 path 反复 8 次。判 true(熔断)。
#[test]
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), "同调用反复应判重复");
}
/// 场景分段读大文件:同 path 不同 offset=0/50/100/150/200/250,6 次。判 false(签名不同)。
#[test]
fn scenario_paginated_read_not_repetitive() {
let offsets = [0, 50, 100, 150, 200, 250];
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 不应判重复");
}
/// 场景反复读同段:同 path 同 offset+limit 4 次(+ 其他 2 个不同凑足样本)。判 true(漂移)。
#[test]
fn scenario_repeat_same_chunk_repetitive() {
let same = tool_call_signature("read_file", &serde_json::json!({"path": "a.rs", "offset": 0, "limit": 50}));
let other1 = tool_call_signature("grep", &serde_json::json!({"pattern": "x", "path": "y"}));
let other2 = tool_call_signature("list_dir", &serde_json::json!({"path": "z"}));
let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2];
assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复");
}
/// 场景样本不足:仅 3 个签名(即使全同)。判 false(不判)。
#[test]
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), "样本不足不应判");
}
/// 边界:恰好 6 个全同 → true(达最小样本 + 单点 6 >= 3)。
#[test]
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));
}
/// 边界:6 个签名两两循环(a,b,a,b,a,b)→ 唯一率 2/6≈0.33 < 0.4 → true(整体游荡)。
#[test]
fn boundary_two_alternating_below_ratio() {
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, b.clone(), a.clone(), b.clone(), a, b];
assert!(is_repetitive_exploration(&sigs), "两签名交替唯一率低应判重复");
}
/// 边界:6 个签名全不同 → false(正常探索)。
#[test]
fn boundary_six_unique_not_repetitive() {
let patterns = ["a", "b", "c", "d", "e", "f"];
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));
}
}