修复: 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));
}
}
+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);