Files
DevFlow/src-tauri/src/commands/ai/agentic/helpers.rs
T
lxy 4417b3ddd3 修复: 压缩边界对齐与断路器学习语义
- 压缩边界按对话单元回退,不再劈开工具对
- 停止与接管退出补跳过结果,畸形不再新增
- 失败指纹纳入参数,改参重试不再累计熔断
- 成功断连失败段,红绿循环不再误熔断
- 脚本非零退出豁免,同命令死磕由签名熔断兜底
- 熔断前加自纠警示轮,临时路径写删豁免
2026-10-01 19:35:22 +08:00

776 lines
37 KiB
Rust

//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
use std::sync::Arc;
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::provider::ChatMessage;
use crate::state::AppState;
use super::approval_timeout;
use crate::commands::ai::event_bus::emit_ai_chat_event;
use crate::commands::ai::prompt::{
build_system_prompt_parts, cache_enabled, cache_split_provider_type, get_active_provider,
};
use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt};
use crate::commands::ai::{AiChatEvent, AiSession, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
/// 警示 system 消息插入单点(DRY #7 收敛,2026-10-01)。
///
/// 抽自 4 处同构骨架(insert_destructive_loop_warning / insert_stall_warning /
/// insert_tool_call_over_limit_warning / audit::cache insert_listing_bypass_warning):
/// 「lock → per_conv.contains_key 守卫 → messages.push(system) → tracing::info」。
/// conv 已删(per_conv 无条目)→ 跳过插入与日志(对齐原守卫语义)。
///
/// **尾部追加而非 insert_at(0)**:历史头部插入会位移整条前缀,叠加警示文本逐条不同,
/// 自动前缀缓存(DeepSeek/GLM/Qwen)与 Anthropic 显式缓存全部失效。警示语义都是
/// 「提示当前轮」,追加到消息流末尾对 LLM 同样可见(紧邻本轮 tool_result 之后),
/// 且不破坏既有前缀字节。文本统一不含轮次计数(同类警示文本字节稳定)。
///
/// `log_tag` 为日志正文(不带 "[ai] " 前缀,helper 统一补);各站点特有字段
/// (iteration/loop_count 等)由调用方编入 tag 文本保留定位信息,helper 统一打 conv_id 字段。
pub(crate) async fn insert_system_notice(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
text: &str,
log_tag: &str,
) {
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.push(ChatMessage::system(text));
tracing::info!(conv_id = %conv_id, "[ai] {}", log_tag);
}
}
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
struct ContinueSnapshot {
is_generating: bool,
has_pending: bool,
pending_conv_id: Option<String>,
agent_language: Option<String>,
model_override: Option<String>,
pinned_goals_snapshot: Vec<GoalEntry>,
}
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
pub async fn try_continue_agent_loop(
app: &AppHandle,
state: &AppState,
conv_id: &str,
start_iteration: usize,
) {
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
let snap = {
let session = state.ai_session.lock().await;
let has_pending = session.session_state(conv_id, &state.conv_states) == SessionState::AwaitingApproval;
let pending_conv_id = session.pending_approvals.values()
.find_map(|a| a.conversation_id.clone());
let conv = session.conv_read(conv_id);
// B-Phase2:ConvState 读侧切无锁 conv_states(is_active),不再占 session lock 读 conv_state。
let is_generating = state.conv_states.is_active(conv_id);
let agent_language = conv.and_then(|c| c.agent_language.clone());
let model_override = conv.and_then(|c| c.model_override.clone());
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
ContinueSnapshot {
is_generating,
has_pending,
pending_conv_id,
agent_language,
model_override,
pinned_goals_snapshot,
}
};
let should_continue = snap.is_generating && !snap.has_pending;
if !should_continue {
if snap.is_generating {
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
} else {
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位");
let emit_conv_id = match snap.pending_conv_id.clone() {
Some(cid) => cid,
None => conv_id.to_string(),
};
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
is_estimated: false,
incomplete: None,
conversation_id: Some(emit_conv_id),
pinned_goals: snap.pinned_goals_snapshot.clone(),
});
}
return;
}
let provider_config = match get_active_provider(state).await {
Ok(p) => p,
Err(e) => {
// B-Phase3:不再持锁操作 conv_state(ConvStateStore 无锁单源)。
if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) {
tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法");
}
// emit/publish 原两端 error_type 不一致(emit=ProviderConfig / publish=None,
// 手抄漂移),收敛 emit_ai_chat_event 后统一 ProviderConfig(对齐 emit_fatal_error 先例)。
emit_ai_chat_event(app, AiChatEvent::AiError {
error: e,
error_type: Some(ErrorType::ProviderConfig),
conversation_id: Some(conv_id.to_string()),
});
return;
}
};
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
let conv_id_owned = conv_id.to_string();
// prompt caching 拆分模式组装(开关关 → 与旧路径逐字等价;anthropic 系显式缓存 /
// openai_compat 前缀稳定)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。
let cache_on = cache_enabled(state).await
&& cache_split_provider_type(&provider_config.provider_type);
let (system_prompt, base_volatile) =
build_system_prompt_parts(state, &lang, &[], &[], cache_on).await;
let session_arc = state.ai_session.clone();
let tools_arc = state.ai_tools.clone();
let db = state.db.clone();
let app_handle = app.clone();
let knowledge_config = state.knowledge_config.lock().await.clone();
let llm_concurrency = state.llm_concurrency.clone();
// 知识注入:缓存模式拼到易变段末尾(追加消息流,不失效 system 缓存),否则拼 system
// (与旧路径逐字等价)。
let volatile_tail = if cache_on {
let knowledge = build_knowledge_context_for_conv(state, conv_id, &knowledge_config).await;
let mut tail = base_volatile;
if !knowledge.is_empty() {
tail = if tail.is_empty() { knowledge } else { format!("{}\n\n---\n{}", tail, knowledge) };
}
if tail.is_empty() { None } else { Some(tail) }
} else {
None
};
let system_prompt = if cache_on {
system_prompt
} else {
inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await
};
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
let model_override = snap.model_override.clone();
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
let still_generating = state.conv_states.is_active(conv_id);
if !still_generating {
// 零 token 收敛信号,非估算(无真实 LLM 调用)。
emit_ai_chat_event(app, AiChatEvent::AiCompleted {
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0,
is_estimated: false,
incomplete: None,
conversation_id: Some(conv_id_owned.clone()),
pinned_goals: snap.pinned_goals_snapshot.clone(),
});
return;
}
emit_ai_chat_event(app, AiChatEvent::AiAgentRound {
round: 0, conversation_id: Some(conv_id_owned.clone()),
});
// F1 并发 epoch:续跑也是新 loop 生命周期,递增 owner token(旧 loop 已 return/disarm)。
// conv_id_owned 是 spawn 前 clone,此处仍可在 scope 内借用 state 锁。
let loop_epoch = {
let mut session = state.ai_session.lock().await;
session.conv(&conv_id_owned).loop_epoch.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1
};
tauri::async_runtime::spawn(async move {
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, volatile_tail, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override, loop_epoch).await;
});
}
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
///
/// DRY 债 #5 收敛:动词/参数键/取值风格/缺参兜底不再本文件手写 match,
/// 改查注册元数据(goal 维度,在 declare_tool! 注册点声明)。
/// 未声明 goal 的工具不产生目标描述(与旧 match 的 _ => None 分支一致)。
/// 旧表中的幽灵分支(edit_file,注册表无此工具)随 match 删除一并清理。
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
if tool_calls.is_empty() { return vec![]; }
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let mut goals: Vec<String> = Vec::new();
for tc in tool_calls.values() {
let parsed = serde_json::from_str::<serde_json::Value>(&tc.args).ok();
let goal = df_ai::ai_tools::goal_of(&tc.name)
.and_then(|g| df_ai::ai_tools::goal_text(g, parsed.as_ref()));
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
}
goals
}
// ============================================================
// 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 等开关/格式选项)忽略——它们变体不构成漂移。
///
/// 归一规则(DRY 债 #5 收敛:参数组查注册元数据 sig 维度,在 declare_tool! 注册点声明):
/// - grep/search_files: `pattern`(或 `query` 回退)+ `path`(或 `glob` 回退)
/// — 同 path 换 pattern 是正常换词;同 pattern 同 path 才算重复。
/// - read_file: `path` + `offset` + `limit`(后两为数值键,按 JSON 形态序列化)
/// — **同段反复才算重复**;不同 offset = 正常分段读大文件(不算)。
/// - run_command: `command`(整条命令,含参数)。
/// - 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 再传入。
/// 旧表中的幽灵分支(search/list_dir,注册表无此工具)随 match 删除一并清理。
pub(crate) fn tool_call_signature(name: &str, args: &serde_json::Value) -> String {
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
df_ai::ai_tools::tool_signature(name, args)
}
/// 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;
}
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;
}
// 单点死磕:统计最高频签名出现次数。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)
}
// ============================================================
// G2 补充:破坏性回环检测(治「写完就删」自毁循环,G2 签名熔断对此漏网)
//
// 漏网根因:write_file/delete_file 走 tool_call_signature 兜底分支(name + args 全 JSON),
// 写→删→写交替时每步签名都不同(唯一率≈0.83、单签名最高 2 次),is_repetitive_exploration
// 全程不触发。但「同路径写后紧跟删(或反向)」本身就是自毁/反悔循环的直接证据——正常流程
// 写后验证后保留,不会紧凑间隔内删同路径。本检测不比签名,直接按 (工具, 路径) 对查。
// 调用点:check_stall_breaker 采样处与签名缓冲并行维护,独立计数器互不干扰。
// ============================================================
/// 回环缓冲样本:每个工具调用占一位,write_file/delete_file 记 (工具名, 规范化路径),
/// 其他工具 None 占位。占位让「GAP 步内」的步按全部工具调用序位算——写删间穿插的
/// 探索调用也算间隔,避免把「写→多次探索→删」的长间隔正常迭代误判为紧凑回环。
pub(crate) type DestructiveLoopSample = Option<(String, String)>;
/// 路径规范化:分隔符统一为 / + 小写,用于写删同路径判定(Windows 路径 \ / 混用与大小写不敏感)。
pub(crate) fn normalize_loop_path(p: &str) -> String {
p.replace('\\', "/").to_lowercase()
}
/// 从工具调用提取回环样本:write_file/delete_file 记规范化 path;read_file 记规范化 path
/// (该路径的「使用」证据);run_command 记整条命令(判定时按「命令含路径」匹配使用);
/// 其他工具 None 占位。path/command 缺失/非字符串 → None(无从判,退化为占位)。
pub(crate) fn destructive_loop_sample(name: &str, args: &serde_json::Value) -> DestructiveLoopSample {
let target = match name {
"write_file" | "delete_file" | "read_file" => arg_str(args, "path"),
"run_command" => arg_str(args, "command"),
_ => return None,
};
if target.is_empty() { None } else { Some((name.to_string(), normalize_loop_path(&target))) }
}
/// 破坏性回环检测:窗口内「write_file(P) 后 GAP 步内 delete_file(P)」或反向 → 命中路径。
///
/// GAP=6(全工具调用序位):写删交替紧凑才判回环,间隔远的写与删是正常迭代修复不算。
/// 两类豁免(降误伤,治 .trash 临时脚本卫生流程被误熔断):
/// - 路径命中临时/回收模式(.trash、/temp/、/tmp/、/target/、.cache 等):写完即删是
/// 产物卫生,整对豁免;
/// - 写删对之间有对该路径的使用(read 精确命中 / run 命令含路径):用后清理,该对豁免。
/// 真「写完即删用户文件」(非临时路径且中间无使用)仍拦。
/// 返回首个命中对的规范化路径(警示/熔断文本可引用),无命中 → None。
pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Option<String> {
/// 回环对最大间隔步数(按全部工具调用序位)。
const DESTRUCTIVE_LOOP_GAP: usize = 6;
// 临时/回收产物路径(入参须为 normalize_loop_path 后的形式:分隔符统一 /、小写)。
let is_temp_recycle = |p: &str| -> bool {
const MARKERS: &[&str] = &[".trash", "/temp/", "/tmp/", "/target/", ".cache"];
MARKERS.iter().any(|m| p.contains(m))
|| p.starts_with("temp/")
|| p.starts_with("tmp/")
};
// 写删对之间对该路径的使用:read 精确命中;run 按命令文本含路径匹配。
let is_use_between = |s: &DestructiveLoopSample, p: &str| -> bool {
match s.as_ref() {
Some((n, v)) if n == "read_file" => v == p,
Some((n, v)) if n == "run_command" => !p.is_empty() && v.contains(p),
_ => false,
}
};
for i in 0..samples.len() {
let (na, pa) = match samples[i].as_ref() {
Some(s) => s,
None => continue,
};
let opposite = match na.as_str() {
"write_file" => "delete_file",
"delete_file" => "write_file",
_ => continue,
};
if is_temp_recycle(pa) {
continue;
}
// j 限 (i, i+GAP]:紧凑间隔内找反向同路径操作。
for j in (i + 1)..samples.len().min(i + 1 + DESTRUCTIVE_LOOP_GAP) {
if is_use_between(&samples[j], pa) {
break; // 对内有使用=用后清理,该写删对豁免
}
if let Some((nb, pb)) = samples[j].as_ref() {
if pb == pa && nb == opposite {
return Some(pa.clone());
}
}
}
}
None
}
/// 纯问候判定:纯社交短文本(你好/谢谢/在吗等)→ true。机制层治弱模型把问候当指令
/// 擅自调工具(会话 b4d6b4e0:用户"你好"→ list_project_modules + 探索源码)。
/// 规则:去空白后 ≤8 字,且不含动作词/实体引用(@[)。"你好,看看 moyu 项目"含动作词+超长 → false。
pub(crate) fn is_pure_greeting(msg: &str) -> bool {
// 问候/社交短文本词表(命中任一即可;长度约束兜底)
const GREETINGS: &[&str] = &[
"你好", "hello", "hi", "哈喽", "嗨", "在吗", "谢谢", "感谢", "嗯",
"好的", "ok", "没问题", "辛苦", "拜拜", "再见", "👋", "你好呀",
];
// 动作/请求意图词:出现任一即非纯问候(放行工具)
const ACTION_WORDS: &[&str] = &[
"看", "查", "帮", "创建", "新增", "修改", "更新", "删除", "分析",
"读取", "写", "执行", "运行", "测试", "构建", "推进", "检查", "搜索",
"列出", "绑定", "如何", "怎么", "为什么", "什么是", "有哪些", "怎么办",
];
let t = msg.trim();
if t.is_empty() { return true; }
if t.chars().count() > 8 { return false; }
if t.contains("@[") { return false; }
let lower = t.to_lowercase();
if !GREETINGS.iter().any(|g| lower.contains(g)) { return false; }
!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::*;
// ── tool_call_signature 归一化测试 ──
#[test]
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_directory_takes_path() {
let args = serde_json::json!({"path": "/tmp"});
assert_eq!(tool_call_signature("list_directory", &args), "list_directory:path=/tmp");
// 幽灵名 list_dir 已删分支,走未注册全参兜底
let ghost = tool_call_signature("list_dir", &args);
assert!(ghost.starts_with("list_dir:name=list_dir,args="), "list_dir 应走兜底分支: {}", ghost);
}
#[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::json!({"pattern": "MessageRole", "path": "crates"})),
tool_call_signature("grep", &serde_json::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::json!({"path": "b.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json::json!({"path": "c.rs", "offset": 0, "limit": 50})),
tool_call_signature("read_file", &serde_json::json!({"path": "d.rs", "offset": 100, "limit": 50})),
tool_call_signature("list_directory", &serde_json::json!({"path": "src-tauri"})),
tool_call_signature("grep", &serde_json::json!({"pattern": "STALL", "path": "src"})),
tool_call_signature("read_file", &serde_json::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_directory", &serde_json::json!({"path": "z"}));
let sigs = vec![same.clone(), same.clone(), same.clone(), same, other1, other2];
assert!(is_repetitive_exploration(&sigs), "反复读同段应判重复");
}
// ── 破坏性回环检测(detect_destructive_loop)测试 ──
/// 测试辅助:(工具名, args) 序列 → 回环样本缓冲。
fn loop_samples_of(calls: &[(&str, serde_json::Value)]) -> Vec<DestructiveLoopSample> {
calls.iter().map(|(n, a)| destructive_loop_sample(n, a)).collect()
}
/// 核心回归(真实案例:清理任务 rename→delete→write→list→delete→write 自毁循环):
/// write(P)→delete(P)→write(P) 签名各不相同(G2 唯一率≈0.83 漏网),回环检测应命中。
#[test]
fn loop_write_delete_write_detected() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "out.txt", "content": "x"})),
("delete_file", serde_json::json!({"path": "out.txt"})),
("write_file", serde_json::json!({"path": "out.txt", "content": "y"})),
]);
assert_eq!(detect_destructive_loop(&samples), Some("out.txt".to_string()));
}
/// 反向对:delete(P) 后紧跟 write(P)(删了又写回)同属回环。
#[test]
fn loop_delete_then_write_detected() {
let samples = loop_samples_of(&[
("delete_file", serde_json::json!({"path": "a.rs"})),
("write_file", serde_json::json!({"path": "a.rs", "content": "x"})),
]);
assert_eq!(detect_destructive_loop(&samples), Some("a.rs".to_string()));
}
/// 不误报:对同/不同文件连续 write(A)→write(B) 正常序列,无反向操作不判回环。
#[test]
fn loop_normal_writes_no_false_positive() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "a.rs"})),
("write_file", serde_json::json!({"path": "b.rs"})),
("write_file", serde_json::json!({"path": "a.rs"})),
]);
assert_eq!(detect_destructive_loop(&samples), None, "纯写入交替不算回环");
}
/// 路径规范化:分隔符(\ /)与大小写差异视为同路径。
#[test]
fn loop_path_normalized() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "SRC\\Lib.RS"})),
("grep", serde_json::json!({"pattern": "x", "path": "y"})),
("delete_file", serde_json::json!({"path": "src/lib.rs"})),
]);
assert_eq!(detect_destructive_loop(&samples), Some("src/lib.rs".to_string()));
}
/// 间隔语义:中间穿插的其他工具调用计入步数,None 占位跳过判定但不缩间隔。
#[test]
fn loop_interleaved_tools_counted() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "p.txt"})),
("grep", serde_json::json!({"pattern": "x", "path": "y"})),
("delete_file", serde_json::json!({"path": "p.txt"})),
]);
assert_eq!(detect_destructive_loop(&samples), Some("p.txt".to_string()), "间隔 2 步 ≤ GAP=6 应命中");
}
/// 间隔超 GAP 不判回环:写后隔 6 个其他调用才删(第 8 位,间隔 7)是正常迭代修复。
#[test]
fn loop_gap_beyond_threshold_not_detected() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "p.txt"})),
("grep", serde_json::json!({"pattern": "1", "path": "y"})),
("grep", serde_json::json!({"pattern": "2", "path": "y"})),
("grep", serde_json::json!({"pattern": "3", "path": "y"})),
("grep", serde_json::json!({"pattern": "4", "path": "y"})),
("grep", serde_json::json!({"pattern": "5", "path": "y"})),
("grep", serde_json::json!({"pattern": "6", "path": "y"})),
("delete_file", serde_json::json!({"path": "p.txt"})),
]);
assert_eq!(detect_destructive_loop(&samples), None, "间隔 7 步 > GAP=6 不应命中");
}
/// 间隔恰为 GAP(第 7 位,间隔 6)仍是回环边界内。
#[test]
fn loop_gap_exactly_at_threshold_detected() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "p.txt"})),
("grep", serde_json::json!({"pattern": "1", "path": "y"})),
("grep", serde_json::json!({"pattern": "2", "path": "y"})),
("grep", serde_json::json!({"pattern": "3", "path": "y"})),
("grep", serde_json::json!({"pattern": "4", "path": "y"})),
("grep", serde_json::json!({"pattern": "5", "path": "y"})),
("delete_file", serde_json::json!({"path": "p.txt"})),
]);
assert_eq!(detect_destructive_loop(&samples), Some("p.txt".to_string()), "间隔 6 步 = GAP 应命中");
}
/// 豁免一:临时/回收路径(.trash、/tmp/ 等)写删是产物卫生流程,非自毁,不判回环。
#[test]
fn loop_temp_recycle_path_exempted() {
for p in [".trash/analyze.py", "C:/data/tmp/analyze.py", ".cache/scratch.txt"] {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": p, "content": "x"})),
("delete_file", serde_json::json!({"path": p})),
]);
assert_eq!(detect_destructive_loop(&samples), None, "临时路径 {} 写删应豁免", p);
}
}
/// 豁免二:写删对之间有对该路径的成功使用(run/read)是用后清理,非自毁。
#[test]
fn loop_use_between_pair_not_detected() {
let run_between = loop_samples_of(&[
("write_file", serde_json::json!({"path": "scripts/a.py"})),
("run_command", serde_json::json!({"command": "python scripts/a.py"})),
("delete_file", serde_json::json!({"path": "scripts/a.py"})),
]);
assert_eq!(detect_destructive_loop(&run_between), None, "写后运行过再删是用后清理");
let read_between = loop_samples_of(&[
("write_file", serde_json::json!({"path": "scripts/a.py"})),
("read_file", serde_json::json!({"path": "scripts/a.py"})),
("delete_file", serde_json::json!({"path": "scripts/a.py"})),
]);
assert_eq!(detect_destructive_loop(&read_between), None, "写后读过再删是确认后清理");
}
/// 真自毁仍拦:非临时路径写完即删,对内无任何使用。
#[test]
fn loop_bare_write_delete_still_detected() {
let samples = loop_samples_of(&[
("write_file", serde_json::json!({"path": "out.txt", "content": "x"})),
("delete_file", serde_json::json!({"path": "out.txt"})),
]);
assert_eq!(detect_destructive_loop(&samples), Some("out.txt".to_string()), "无使用的写完即删仍判自毁");
}
/// 场景样本不足:仅 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.clone(), 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));
}
// ── is_pure_greeting 纯问候判定测试 ──
#[test]
fn greeting_pure_hello_is_greeting() {
assert!(is_pure_greeting("你好"));
}
#[test]
fn greeting_thanks_is_greeting() {
assert!(is_pure_greeting("谢谢"));
}
#[test]
fn greeting_with_action_is_not_greeting() {
// 含动作词"看"+ 超 8 字 → 放行工具
assert!(!is_pure_greeting("你好,看看 moyu 项目"));
}
#[test]
fn greeting_view_tasks_is_not_greeting() {
assert!(!is_pure_greeting("查看任务"));
}
#[test]
fn greeting_entity_ref_is_not_greeting() {
assert!(!is_pure_greeting("@[项目]"));
}
#[test]
fn greeting_empty_string_is_greeting() {
assert!(is_pure_greeting(""));
assert!(is_pure_greeting(" "));
}
#[test]
fn greeting_help_query_is_not_greeting() {
assert!(!is_pure_greeting("你好,帮我查询项目"));
}
#[test]
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), "超上限触发");
}
}