//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。 //! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。 use tauri::{AppHandle, Emitter, Manager}; use crate::state::AppState; use super::approval_timeout; use crate::commands::ai::prompt::{build_system_prompt_parts, cache_enabled, get_active_provider}; use crate::commands::ai::knowledge_inject::{build_knowledge_context_for_conv, inject_knowledge_into_prompt}; use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry}; use super::conv_state::ConvState; /// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。 struct ContinueSnapshot { is_generating: bool, has_pending: bool, pending_conv_id: Option, agent_language: Option, model_override: Option, pinned_goals_snapshot: Vec, } /// 检查是否所有待审批已处理,如果是则恢复 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(), }; let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, // 零 token 收敛信号,非估算(无真实 LLM 调用)。 is_estimated: false, incomplete: None, conversation_id: Some(emit_conv_id), pinned_goals: snap.pinned_goals_snapshot.clone(), }; let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.state::().ai_event_bus.publish_event(ev); } 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 非法"); } let _ = app.emit("ai-chat-event", AiChatEvent::AiError { error: e.clone(), error_type: Some(ErrorType::ProviderConfig), conversation_id: Some(conv_id.to_string()), }); let _ = app.state::().ai_event_bus.publish_event(AiChatEvent::AiError { error: e, error_type: None, 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(); // Anthropic prompt caching 模式组装(开关关 → 与旧路径逐字等价;仅 anthropic provider 拆分, // OpenAI 零影响)。续跑路径无新 @ mention,不注入 augmentation(与旧路径一致)。 let cache_on = cache_enabled(state).await && matches!(provider_config.provider_type.as_str(), "anthropic" | "anthropic_compat"); 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 { let ev = AiChatEvent::AiCompleted { total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, prompt_cache_hit_tokens: 0, prompt_cache_miss_tokens: 0, reasoning_tokens: 0, // 零 token 收敛信号,非估算(无真实 LLM 调用)。 is_estimated: false, incomplete: None, conversation_id: Some(conv_id_owned.clone()), pinned_goals: snap.pinned_goals_snapshot.clone(), }; let _ = app.emit("ai-chat-event", ev.clone()); let _ = app.state::().ai_event_bus.publish_event(ev); return; } let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound { round: 0, conversation_id: Some(conv_id_owned.clone()), }); let _ = app.state::().ai_event_bus.publish_event(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 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。 /// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。 pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap) -> Vec { if tool_calls.is_empty() { return vec![]; } let mut goals: Vec = Vec::new(); for tc in tool_calls.values() { let goal = match tc.name.as_str() { "patch_file" | "write_file" | "edit_file" | "append_file" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); format!("修改 {}", f) })) } "read_file" | "file_info" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); format!("查看 {}", f) })) } "search_files" | "grep" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| { let s = if q.len() > 25 { &q[..25] } else { q }; format!("搜索 {}", s) })) .or_else(|| Some("搜索文件".to_string())) } "run_command" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| { let s = if c.len() > 25 { &c[..25] } else { c }; format!("执行 {}", s) })) .or_else(|| Some("执行命令".to_string())) } "create_project" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n))) } "create_idea" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t))) } "run_workflow" => Some("执行工作流".to_string()), "delete_file" | "rename_file" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| { let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p); format!("处理 {}", f) })) } "list_directory" => { serde_json::from_str::(&tc.args).ok() .and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p))) } _ => None, }; 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 等开关/格式选项)忽略——它们变体不构成漂移。 /// /// 归一规则(参数从 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; } 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 参数规范化,其他工具 None 占位。 /// path 缺失/非字符串 → None(无路径无从判同路径,退化为占位)。 pub(crate) fn destructive_loop_sample(name: &str, args: &serde_json::Value) -> DestructiveLoopSample { match name { "write_file" | "delete_file" => { let p = arg_str(args, "path"); if p.is_empty() { None } else { Some((name.to_string(), normalize_loop_path(&p))) } } _ => None, } } /// 破坏性回环检测:窗口内「write_file(P) 后 GAP 步内 delete_file(P)」或反向 → 命中路径。 /// /// GAP=6(全工具调用序位):写删交替紧凑才判回环,间隔远的写与删是正常迭代修复不算。 /// 返回首个命中对的规范化路径(警示/熔断文本可引用),无命中 → None。 pub(crate) fn detect_destructive_loop(samples: &[DestructiveLoopSample]) -> Option { /// 回环对最大间隔步数(按全部工具调用序位)。 const DESTRUCTIVE_LOOP_GAP: usize = 6; 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, }; // j 限 (i, i+GAP]:紧凑间隔内找反向同路径操作。 for j in (i + 1)..samples.len().min(i + 1 + DESTRUCTIVE_LOOP_GAP) { 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_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::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_dir", &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 = 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), "反复读同段应判重复"); } // ── 破坏性回环检测(detect_destructive_loop)测试 ── /// 测试辅助:(工具名, args) 序列 → 回环样本缓冲。 fn loop_samples_of(calls: &[(&str, serde_json::Value)]) -> Vec { 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 应命中"); } /// 场景样本不足:仅 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 = 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), "超上限触发"); } }