优化: 弱模型治理收尾(AC-2探索预算:单轮上限+目录去重+禁绕行 + AC-5失败画像:合法目标回灌+相近锚点提示)
This commit is contained in:
@@ -355,6 +355,15 @@ pub(crate) fn is_pure_greeting(msg: &str) -> bool {
|
||||
!ACTION_WORDS.iter().any(|a| lower.contains(a))
|
||||
}
|
||||
|
||||
/// AC-2 ①:单轮工具调用数超限判定(纯函数,供 run_agentic_loop 每轮调用)。
|
||||
///
|
||||
/// 治弱模型单轮一次性发超多工具调用(实证 09e7abfa:同一目录列 3 次 + run_command 绕行,
|
||||
/// 单轮十几个 grep/read 并行爆炸)。LLM 看不到"自己本轮发了多少调用",超限必须在
|
||||
/// 消息流里显式警告(机制优先 prompt 说教)。纯函数便于单测边界。
|
||||
pub(crate) fn is_tool_call_over_limit(count: usize) -> bool {
|
||||
count > super::MAX_TOOL_CALLS_PER_ROUND
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -553,4 +562,18 @@ mod tests {
|
||||
fn greeting_ok_is_greeting() {
|
||||
assert!(is_pure_greeting("ok"));
|
||||
}
|
||||
|
||||
// ── AC-2 ① 单轮工具调用数上限判定测试 ──
|
||||
|
||||
#[test]
|
||||
fn tool_call_limit_at_threshold_not_over() {
|
||||
assert!(!is_tool_call_over_limit(super::super::MAX_TOOL_CALLS_PER_ROUND), "恰好达上限不触发");
|
||||
assert!(!is_tool_call_over_limit(0));
|
||||
assert!(!is_tool_call_over_limit(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_limit_over_threshold() {
|
||||
assert!(is_tool_call_over_limit(super::super::MAX_TOOL_CALLS_PER_ROUND + 1), "超上限触发");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,14 @@ pub const STALL_BREAKER_GOAL_REMIND: bool = true;
|
||||
/// 「最小样本 6」+「单点 3 次」阈值有意义,又不至于扫太长历史稀释近期漂移信号。
|
||||
pub const STALL_BREAKER_SAMPLE_SIZE: usize = 12;
|
||||
|
||||
/// AC-2 ①:单轮工具调用数上限(默认 8)。
|
||||
///
|
||||
/// 治弱模型单轮一次性发超多工具调用(实证 09e7abfa:同一目录列 3 次 + run_command 绕行,
|
||||
/// 单轮十几个 grep/read 并行爆炸)。LLM 看不到"自己本轮发了多少调用",必须在消息流里
|
||||
/// 显式警告才能感知(机制优先 prompt 说教)。超限即 insert 一条 system 软提示到
|
||||
/// conv.messages(非静默),下轮 build_for_request 时 LLM 可见,促其聚焦关键操作。
|
||||
pub const MAX_TOOL_CALLS_PER_ROUND: usize = 8;
|
||||
|
||||
/// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。
|
||||
///
|
||||
/// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试,
|
||||
@@ -312,7 +320,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::{tool_call_signature, is_repetitive_exploration, is_pure_greeting};
|
||||
pub(crate) use helpers::{tool_call_signature, is_repetitive_exploration, is_pure_greeting, is_tool_call_over_limit};
|
||||
|
||||
// ============================================================
|
||||
// 单 Provider 流式结果 + fallback 辅助
|
||||
@@ -1887,6 +1895,8 @@ pub(crate) async fn run_agentic_loop(
|
||||
if !has_tool_calls { converged = true; break; }
|
||||
let _ = converged;
|
||||
|
||||
// AC-2 ①:记录本轮工具调用数(供超限警告判定;process_tool_calls 会 move 走 tool_calls_acc)。
|
||||
let round_tool_call_count = tool_calls_acc.len();
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||
let pending_count = process_tool_calls(&session_arc, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await;
|
||||
// DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug,
|
||||
@@ -1904,6 +1914,12 @@ pub(crate) async fn run_agentic_loop(
|
||||
return;
|
||||
}
|
||||
|
||||
// AC-2 ①:单轮工具调用数超限 → 注入软提示(机制化,非静默,弱模型能感知)。
|
||||
// 治单轮并行调用爆炸:LLM 看不到自己发了多少调用,必须在消息流里显式警告。
|
||||
if is_tool_call_over_limit(round_tool_call_count) {
|
||||
insert_tool_call_over_limit_warning(&session_arc, &conv_id, iteration, round_tool_call_count).await;
|
||||
}
|
||||
|
||||
// L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。
|
||||
// count_recent_failures 读末尾连续 Tool 消息,失败包按结构化信号归一 key 入滚动窗口计数。
|
||||
if CIRCUIT_BREAKER_ENABLED {
|
||||
@@ -2472,6 +2488,33 @@ async fn fetch_goal_summary(session_arc: &Arc<Mutex<AiSession>>, conv_id: &str)
|
||||
format!("(当前目标: {})", goal_summary)
|
||||
}
|
||||
|
||||
// ── insert_tool_call_over_limit_warning: AC-2 ① 单轮工具调用数超限软提示 ──
|
||||
// 机制化(非 prompt 说教):超限即 insert system 警告到 conv.messages 首位(对齐 G2
|
||||
// insert_stall_warning 模式),下轮 build_for_request 时 LLM 可见,促其聚焦关键操作。
|
||||
async fn insert_tool_call_over_limit_warning(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
iteration: usize,
|
||||
count: usize,
|
||||
) {
|
||||
let warn_text = format!(
|
||||
"⚠ 本轮工具调用达 {} 个,超过单轮上限 {} 个。请聚焦关键操作,减少并行调用,勿重复探索同一区域。",
|
||||
count, MAX_TOOL_CALLS_PER_ROUND
|
||||
);
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(conv_id) {
|
||||
let conv = session.conv(conv_id);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
count,
|
||||
limit = MAX_TOOL_CALLS_PER_ROUND,
|
||||
"[ai] AC-2 单轮工具调用数超限:注入软提示"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ──
|
||||
// emit+publish 双写 Tauri 事件总线。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类)。
|
||||
async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) {
|
||||
|
||||
@@ -52,11 +52,22 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
/// prompt 层说教无效,必须在结果里机制化显式警告。仅真命中缓存才加(首次真执行不加),
|
||||
/// 命中次数 hit_count 累计「第 N 次」,数字递增对弱模型更有威慑,促其换参数/换工具。
|
||||
/// 返回的字符串以换行结尾,调用方拼接缓存结果原样附后。
|
||||
///
|
||||
/// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后,
|
||||
/// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具
|
||||
/// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。
|
||||
pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String {
|
||||
format!(
|
||||
"[重复调用拦截] 工具 {} 相同参数此前已成功执行过(第 {} 次命中缓存),以下为缓存结果。不要重复调用相同参数的工具,如需新信息请换参数或换工具。\n\n",
|
||||
tool_name, hit_count
|
||||
)
|
||||
if matches!(tool_name, "list_directory" | "list_dir") {
|
||||
format!(
|
||||
"[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n",
|
||||
tool_name, hit_count
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"[重复调用拦截] 工具 {} 相同参数此前已成功执行过(第 {} 次命中缓存),以下为缓存结果。不要重复调用相同参数的工具,如需新信息请换参数或换工具。\n\n",
|
||||
tool_name, hit_count
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// namespace 引用展开为真实内容(缓存回填前置)。
|
||||
@@ -197,6 +208,24 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
||||
serde_json::to_string(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// AC-2 ②:只读缓存参数归一(目录级去重)。
|
||||
///
|
||||
/// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复——
|
||||
/// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。
|
||||
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
|
||||
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
|
||||
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
|
||||
fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
|
||||
if matches!(tool_name, "list_directory" | "list_dir") {
|
||||
args.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
} else {
|
||||
canonical_args_key(args)
|
||||
}
|
||||
}
|
||||
|
||||
/// 只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||
///
|
||||
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
|
||||
@@ -233,7 +262,10 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_args_key = canonical_args_key(args);
|
||||
// AC-2 ②:目录级去重——list_directory 的缓存 key 只取 path(忽略 recursive/max_depth),
|
||||
// 同目录换参数也判重复(目录内容没变,实证 09e7abfa 同一目录列 3 次)。
|
||||
// 其他只读工具仍全参归一(键序无关)。readonly_cache_args_key 内部区分。
|
||||
let new_args_key = readonly_cache_args_key(tool_name, args);
|
||||
|
||||
// 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
|
||||
let cached: Option<(String, String, u32)> = (async {
|
||||
@@ -257,7 +289,7 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
if readonly_cache_args_key(tool_name, &old_args) == new_args_key {
|
||||
if prev_tool_call_id.is_none() {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
}
|
||||
@@ -304,7 +336,7 @@ pub(crate) async fn find_cached_readonly_result(
|
||||
const READONLY_CACHE_TOOLS: &[&str] = &[
|
||||
"read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
|
||||
"read_symbol", // AST 符号读取(同 path+symbol+full → 同结果)
|
||||
"list_directory", // 列目录(同 path+recursive+max_depth → 同结果)
|
||||
"list_directory", // 列目录(AC-2 ②:缓存 key 只取 path,同目录换 recursive/max_depth 也判重复)
|
||||
"search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果)
|
||||
"grep", // 搜文件内容(同 path+pattern+mode → 同结果)
|
||||
];
|
||||
@@ -333,3 +365,218 @@ fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// AC-2 ③:run_command 目录列举绕行检测(2026-08-08 最保守版,只警告不硬拒)
|
||||
//
|
||||
// 实证 09e7abfa:LLM list_directory 列某目录后,又用 run_command `Get-ChildItem`/`ls`
|
||||
// 再探同一目录,绕过 ② 的目录级去重。需解析 run_command 命令内容判定,**易误判**
|
||||
// (ls/Get-ChildItem 也有正常用途),故本批只做最保守版:
|
||||
// - 仅当命令含列举类命令(Get-ChildItem/gci/ls) **且** 命令/working_dir 引用了会话中
|
||||
// 已用 list_directory 列过的目录路径(精确子串) 才判绕行;
|
||||
// - 只 insert 一条 system 警告(非静默),不硬拒——命令照常执行/审批,
|
||||
// 误报最多多一条提示,不阻断工作流;
|
||||
// - 未命中 → None,零行为变更。
|
||||
// 已列目录路径来自消息历史扫描(listed_directory_paths),与 find_cached_readonly_result
|
||||
// 同源(读 assistant tool_calls 的 list_directory args.path)。
|
||||
// ============================================================
|
||||
|
||||
/// 词边界子串匹配(纯函数):needle 在 haystack 中出现且前后均非字母数字。
|
||||
///
|
||||
/// 用字节扫描避免引入 regex 依赖;注意 "Get-ChildItem" 含连字符,不能简单按非字母数字
|
||||
/// 分词(会把 get-childitem 拆成 get/childitem),故按整词含连字符匹配。
|
||||
fn contains_word(haystack: &str, needle: &str) -> bool {
|
||||
let bytes = haystack.as_bytes();
|
||||
let n = needle.as_bytes();
|
||||
if n.is_empty() || n.len() > bytes.len() {
|
||||
return false;
|
||||
}
|
||||
let max_start = bytes.len() - n.len();
|
||||
let mut i = 0;
|
||||
while i <= max_start {
|
||||
if &bytes[i..i + n.len()] == n {
|
||||
let prev_ok = i == 0 || !bytes[i - 1].is_ascii_alphanumeric();
|
||||
let next_idx = i + n.len();
|
||||
let next_ok = next_idx >= bytes.len() || !bytes[next_idx].is_ascii_alphanumeric();
|
||||
if prev_ok && next_ok {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// 列举类命令检测(纯函数,词边界匹配防误判)。
|
||||
///
|
||||
/// 覆盖 PowerShell `Get-ChildItem`/`gci` 与类 Unix `ls`。`dir` 别名歧义大
|
||||
/// (PowerShell 里既列举又是路径常见词)排除,靠「命令引用已列目录」精化过滤。
|
||||
fn contains_listing_command(command: &str) -> bool {
|
||||
let lower = command.to_ascii_lowercase();
|
||||
contains_word(&lower, "get-childitem")
|
||||
|| contains_word(&lower, "gci")
|
||||
|| contains_word(&lower, "ls")
|
||||
}
|
||||
|
||||
/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。
|
||||
///
|
||||
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory
|
||||
/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。
|
||||
async fn listed_directory_paths(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
) -> Vec<String> {
|
||||
use df_ai::provider::MessageRole;
|
||||
let session = session_arc.lock().await;
|
||||
let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
|
||||
let mut dirs: Vec<String> = Vec::new();
|
||||
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for m in conv.messages.iter() {
|
||||
if !matches!(m.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(tcs) = m.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != "list_directory" {
|
||||
continue;
|
||||
}
|
||||
let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
let Some(p) = args.get("path").and_then(|v| v.as_str()) else { continue };
|
||||
if seen.insert(p.to_string()) {
|
||||
dirs.push(p.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
dirs
|
||||
}
|
||||
|
||||
/// AC-2 ③:检测 run_command 是否用目录列举命令绕行 list_directory 去重。
|
||||
///
|
||||
/// 返回 Some(已列过的目录路径) 当命令含列举类命令且引用了该目录;None = 无绕行。
|
||||
/// 只检测**先前轮次**已列的目录(当前轮同一批 list_directory 尚未写入消息,不覆盖
|
||||
/// 同轮先列后 ls 的极端形态——属边缘场景,主要绕行形态跨轮已覆盖)。
|
||||
pub(crate) async fn detect_listing_bypass(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
// 1) 取命令文本 + working_dir(绕行路径通常出现在二者之一)。
|
||||
let command = args.get("command").and_then(|v| v.as_str())?.trim();
|
||||
if command.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// 2) 命令须含列举类命令(Get-ChildItem/gci/ls)。
|
||||
if !contains_listing_command(command) {
|
||||
return None;
|
||||
}
|
||||
let working_dir = args.get("working_dir").and_then(|v| v.as_str()).unwrap_or("");
|
||||
// 3) 扫描会话已列过的目录路径。
|
||||
let listed_dirs = listed_directory_paths(session_arc, conv_id).await;
|
||||
if listed_dirs.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// 4) 命令或 working_dir 引用已列过的目录 → 绕行(精确子串,不模糊匹配)。
|
||||
for dir in &listed_dirs {
|
||||
if dir.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if command.contains(dir.as_str())
|
||||
|| (!working_dir.is_empty() && working_dir.contains(dir.as_str()))
|
||||
{
|
||||
return Some(dir.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// AC-2 ③:目录列举绕行警告注入(system 消息首位,对齐 G2/AC-2① 软提示模式)。
|
||||
///
|
||||
/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。
|
||||
pub(crate) async fn insert_listing_bypass_warning(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
dir: &str,
|
||||
) {
|
||||
let warn_text = format!(
|
||||
"⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
|
||||
dir
|
||||
);
|
||||
let mut session = session_arc.lock().await;
|
||||
if session.per_conv.contains_key(conv_id) {
|
||||
let conv = session.conv(conv_id);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
dir = %dir,
|
||||
"[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── AC-2 ② 目录级去重 key 测试 ──
|
||||
|
||||
#[test]
|
||||
fn readonly_key_list_directory_ignores_depth_and_recursive() {
|
||||
let a = readonly_cache_args_key(
|
||||
"list_directory",
|
||||
&serde_json::json!({"path": "/tmp", "max_depth": 1, "recursive": false, "skip_noise_dirs": true}),
|
||||
);
|
||||
let b = readonly_cache_args_key(
|
||||
"list_directory",
|
||||
&serde_json::json!({"path": "/tmp", "max_depth": 3, "recursive": true}),
|
||||
);
|
||||
assert_eq!(a, b, "同目录换 max_depth/recursive 应判同 key(目录级去重)");
|
||||
let c = readonly_cache_args_key("list_directory", &serde_json::json!({"path": "/other"}));
|
||||
assert_ne!(a, c, "不同目录应不同 key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_key_other_tools_use_full_args() {
|
||||
let a = readonly_cache_args_key("read_file", &serde_json::json!({"path": "x", "offset": 0}));
|
||||
let b = readonly_cache_args_key("read_file", &serde_json::json!({"path": "x", "offset": 50}));
|
||||
assert_ne!(a, b, "read_file 不同 offset 应不同 key(分段读正常)");
|
||||
}
|
||||
|
||||
// ── AC-2 ③ 列举命令检测测试 ──
|
||||
|
||||
#[test]
|
||||
fn listing_command_detects_powershell_and_unix() {
|
||||
assert!(contains_listing_command("Get-ChildItem -Recurse"));
|
||||
assert!(contains_listing_command("gci -Force"));
|
||||
assert!(contains_listing_command("ls -la /tmp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_command_avoids_false_positives() {
|
||||
assert!(!contains_listing_command("git log"));
|
||||
assert!(!contains_listing_command("else"));
|
||||
assert!(!contains_listing_command("glass"));
|
||||
assert!(!contains_listing_command("cd /tmp"));
|
||||
assert!(!contains_listing_command("Get-ChildItem2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_word_boundary_behavior() {
|
||||
// "ls" 不应命中 "else"/"glass"(词边界);"Get-ChildItem" 含连字符仍应整词命中
|
||||
assert!(contains_word("get-childitem -recurse", "get-childitem"));
|
||||
assert!(!contains_word("else", "ls"));
|
||||
assert!(!contains_word("glass", "ls"));
|
||||
}
|
||||
|
||||
// ── cache_hit_warning 目录专用提示 ──
|
||||
|
||||
#[test]
|
||||
fn cache_hit_warning_directory_specific() {
|
||||
let w = cache_hit_warning("list_directory", 2);
|
||||
assert!(w.contains("已列过"), "目录缓存命中应有'已列过'提示: {}", w);
|
||||
let w2 = cache_hit_warning("read_file", 2);
|
||||
assert!(!w2.contains("已列过"));
|
||||
assert!(w2.contains("相同参数"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ pub(crate) use finalize::audit_finalize;
|
||||
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
|
||||
// 第三批从本文件抽离,行为零变更。
|
||||
mod cache;
|
||||
pub(super) use cache::{cache_hit_warning, find_cached_readonly_result, pending_placeholder_for};
|
||||
pub(super) use cache::{cache_hit_warning, detect_listing_bypass, find_cached_readonly_result, insert_listing_bypass_warning, pending_placeholder_for};
|
||||
|
||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||
@@ -491,6 +491,15 @@ pub(crate) async fn process_tool_calls(
|
||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||
for (_, draft, args) in drafts {
|
||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||
// AC-2 ③:run_command 目录列举绕行检测(最保守版,只警告不硬拒)。
|
||||
// 实证 09e7abfa:list_directory 后 LLM 用 run_command Get-ChildItem 再探同目录,
|
||||
// 绕过 ② 的目录级去重。检测到绕行 → insert 一条 system 警告(非静默),命令照常
|
||||
// 执行/审批(不硬拒,防误判阻断正常命令)。易误判详见 cache.rs detect_listing_bypass 文档。
|
||||
if draft.name == "run_command" {
|
||||
if let Some(dir) = detect_listing_bypass(session_arc, conv_id, &args).await {
|
||||
insert_listing_bypass_warning(session_arc, conv_id, &dir).await;
|
||||
}
|
||||
}
|
||||
handle_approval_tool(
|
||||
draft, args, risk_level, &auto_exec_mode,
|
||||
session_arc, conv_id, tools_arc, &audit_repo,
|
||||
|
||||
@@ -506,8 +506,18 @@ pub fn register(
|
||||
}));
|
||||
}
|
||||
// L2: old_text 精确匹配(CAS 语义)
|
||||
// 失败时附相近片段提示(AC-5):把文件里与 old_text 相似的几行列给 LLM,
|
||||
// 供其对照真实缩进/空格修正锚点,避免靠猜反复重试。完全无关(无相似行)则
|
||||
// 退回原提示,不误导。
|
||||
if !content.contains(old_text) {
|
||||
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
||||
let similar = similar_line_fragments(&content, old_text, 3);
|
||||
if similar.is_empty() {
|
||||
anyhow::bail!("未找到目标文本,文件可能已被修改");
|
||||
}
|
||||
anyhow::bail!(
|
||||
"未找到目标文本,文件可能已被修改。相近片段供核对 old_text 是否精确(注意缩进/空格):\n{}",
|
||||
similar.join("\n")
|
||||
);
|
||||
}
|
||||
let mc = content.matches(old_text).count();
|
||||
match_count = mc;
|
||||
@@ -1222,6 +1232,82 @@ pub fn register(
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// patch_file 失败提示辅助 — old_text 不匹配时给 LLM 相近锚点
|
||||
// ============================================================
|
||||
|
||||
/// 在 `content` 中找出与 `needle` 最相近的若干行(行号+内容)。
|
||||
///
|
||||
/// 用途:patch_file 模式1 old_text 精确匹配失败时,把相近片段附进错误信息,让 LLM
|
||||
/// 对照真实缩进/空格修正锚点,避免靠猜反复重试(AC-5 patch_file 27 次失败降频)。
|
||||
///
|
||||
/// 相似度算法:字符多重集 Dice 系数(2*公共字符数 / 两串字符数之和,0-100 整数百分比),
|
||||
/// 仅对「首非空白字符相同」的行做评分(快速预筛,绝大多数行不相干直接跳过)。
|
||||
/// `needle` 取首行 + 前 120 字符作探针(超长 old_text 罕见,同时约束扫描成本,
|
||||
/// 1MB 文件全量逐行 O(n) 可接受)。阈值 40% 防误导性建议(完全无关文本不提示)。
|
||||
fn similar_line_fragments(content: &str, needle: &str, max: usize) -> Vec<String> {
|
||||
// 探针取 needle 首行,截前 120 字符(评分用,兼顾精度与性能)
|
||||
let first_line = needle.lines().next().unwrap_or(needle);
|
||||
let mut probe = String::new();
|
||||
for c in first_line.chars().take(120) {
|
||||
probe.push(c);
|
||||
}
|
||||
if probe.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// 预计算探针字符计数(行级评分复用,避免每行重建)
|
||||
use std::collections::HashMap;
|
||||
let mut probe_counts: HashMap<char, u32> = HashMap::new();
|
||||
for c in probe.chars() {
|
||||
*probe_counts.entry(c).or_insert(0) += 1;
|
||||
}
|
||||
let probe_len = probe.chars().count() as u32;
|
||||
let probe_first = probe.trim_start().chars().next();
|
||||
|
||||
let mut scored: Vec<(u32, String)> = Vec::new();
|
||||
for (idx, line) in content.lines().enumerate() {
|
||||
let trimmed = line.trim_end();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
// 快速预筛:首非空白字符不同则跳过(大文件绝大多数行不相干)
|
||||
match (probe_first, trimmed.trim_start().chars().next()) {
|
||||
(Some(a), Some(b)) if a != b => continue,
|
||||
_ => {}
|
||||
}
|
||||
let line_len = trimmed.chars().count() as u32;
|
||||
if line_len == 0 {
|
||||
continue;
|
||||
}
|
||||
// 公共字符计数(探针计数约束下扫描行字符)
|
||||
let mut counts = probe_counts.clone();
|
||||
let mut common = 0u32;
|
||||
for c in trimmed.chars() {
|
||||
if let Some(cnt) = counts.get_mut(&c) {
|
||||
if *cnt > 0 {
|
||||
*cnt -= 1;
|
||||
common += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dice = 2*common / (probe_len + line_len),乘 100 转百分比整数
|
||||
let dice = (common * 200) / (probe_len + line_len).max(1);
|
||||
if dice >= 40 {
|
||||
let shown: String = if line.chars().count() > 100 {
|
||||
line.chars().take(100).collect::<String>() + "…"
|
||||
} else {
|
||||
line.to_string()
|
||||
};
|
||||
scored.push((dice, format!("行 {}: {}", idx + 1, shown)));
|
||||
}
|
||||
}
|
||||
|
||||
scored.sort_by(|a, b| b.0.cmp(&a.0));
|
||||
scored.truncate(max);
|
||||
scored.into_iter().map(|(_, s)| s).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1377,6 +1463,42 @@ mod tests {
|
||||
assert!(!windowed.starts_with("…"), "match 在行首,win_start=0 不前导 …");
|
||||
assert!(windowed.contains("(±20字符窗口)"));
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// similar_line_fragments — patch_file old_text 相近锚点提示
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn similar_fragments_finds_whitespace_drift_line() {
|
||||
// 典型失败:old_text 缩进漂移(4 空格 vs 2 空格),字符重叠度高 → 应命中同一行
|
||||
let content = "fn main() {\n let x = 1;\n let y = 2;\n}\n";
|
||||
let needle = " let x = 1;"; // 缩进从 2 变 4,内容同
|
||||
let hits = similar_line_fragments(content, needle, 3);
|
||||
assert!(
|
||||
hits.iter().any(|h| h.contains("行 2")),
|
||||
"缩进漂移应命中第 2 行,实际: {hits:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_fragments_empty_when_no_relation() {
|
||||
// 完全无关文本 → 空列表(退回原提示,不误导)
|
||||
let content = "fn main() {\n let x = 1;\n}\n";
|
||||
let hits = similar_line_fragments(content, "pub struct TotallyDifferent", 3);
|
||||
assert!(hits.is_empty(), "无关文本不应给相近提示,实际: {hits:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn similar_fragments_multiline_needle_uses_first_line() {
|
||||
// 多行 old_text:探针取首行,仍能定位到目标行
|
||||
let content = "start\n let value = compute();\n println!(\"{}\", value);\nend\n";
|
||||
let needle = " let value = compute();\n println!(\"{}\", value);\n"; // 首行同内容
|
||||
let hits = similar_line_fragments(content, needle, 3);
|
||||
assert!(
|
||||
hits.iter().any(|h| h.contains("行 2")),
|
||||
"多行 old_text 应命中首行所在行,实际: {hits:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user