修复: 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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=None,accumulate
|
||||
// 不覆盖),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 共用 helper,DRY)。
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user