AI loop 竞态(P0):per-conv epoch/owner token + 存活心跳治 force_send 双loop + stop 3s兜底误判;旧loop stale 全跳过(guard/emit/save)
agentic 收尾(A2-B8):Fatal 退出落库user消息(镜像Exhausted)+ 入口早退补save + usage is_estimated 打标 + emit_ai_completed_once 单点收敛清审批残留
聊天清理(A2-B9):clearChat 先停loop→DB单事务→内存清(clear_conversation_atomic)+ 前端错误气泡
循环并发(A2-B11):三态 ProviderAcquire(NotConfigured/Acquired/Exhausted)+ 候选循环非阻塞+防抖3次饱和降级+单测
错误分类(A2-B12):stream error帧接入 classify_status_or_class + 关键词保守降级 + 7单测
数据(G1.2/G1.4):purge_with_descendants 级联补全(11表单事务+存在性守卫)+ move_task_queue 单事务收口(两调用方共用)
git只读(G3.1):run_git_status/diff/log success判定(exit_code差异语义,失败结构化{success:false,error})
安全(G5.2/G5.6):create_project 目录Err+name校验 + module.rs 路径遍历DRY(分段匹配修a..b.rs误伤)
幂等(V2/V32):裸ALTER全守卫化 + v1..v40全链重跑幂等测试(16过)
附:remote_bridge await 临时引用修(E0716)+ agentic emit 收敛 E0716 app_state 绑定修
557 lines
26 KiB
Rust
557 lines
26 KiB
Rust
//! 审批后循环恢复 — 从 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, get_active_provider};
|
|
use crate::commands::ai::knowledge_inject::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<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(),
|
|
};
|
|
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::<crate::state::AppState>().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::<crate::state::AppState>().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();
|
|
let system_prompt = build_system_prompt(state, &lang).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();
|
|
|
|
let system_prompt = 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::<crate::state::AppState>().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::<crate::state::AppState>().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, 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<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
|
|
if tool_calls.is_empty() { return vec![]; }
|
|
let mut goals: Vec<String> = 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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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::<serde_json::Value>(&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)
|
|
}
|
|
|
|
/// 纯问候判定:纯社交短文本(你好/谢谢/在吗等)→ 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))
|
|
}
|
|
|
|
#[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<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.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"));
|
|
}
|
|
}
|