diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index de8170b..82f20a6 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -538,6 +538,7 @@ pub(crate) async fn run_agentic_loop( .find(|m| { matches!(m.role, df_ai::provider::MessageRole::User) && !m.content.starts_with("[系统恢复]") + && !m.content.starts_with("[补充指令]") && m.content.chars().count() >= 4 }) .map(|m| { diff --git a/src-tauri/src/commands/ai/commands/conversation.rs b/src-tauri/src/commands/ai/commands/conversation.rs index b01f25f..d1646b3 100644 --- a/src-tauri/src/commands/ai/commands/conversation.rs +++ b/src-tauri/src/commands/ai/commands/conversation.rs @@ -419,6 +419,18 @@ fn is_recovery_artifact(text: &str) -> bool { text.starts_with(RECOVERY_ARTIFACT_PREFIX) } +/// 注入产物判定(E5):[补充指令] 是运行中用户插话的通道标记,非任务意图本身 +/// (实证 434c8c40 active goal=「[补充指令] 123 hi」占位)。与恢复产物同治: +/// 载入侧滤出内存,下轮 save 重写即自愈落库。 +fn is_injection_artifact(text: &str) -> bool { + text.starts_with("[补充指令]") +} + +/// 引导产物总判定(恢复+注入),双侧(载入/回放)统一用。 +fn is_guide_artifact(text: &str) -> bool { + is_recovery_artifact(text) || is_injection_artifact(text) +} + /// 目标快照:按最终有效意图生成 parked 恢复引导消息。 /// /// 「按快照续不重放历史目标流」:取 WorkingContext.goals 与 pinned_goals 中仍 Active 的 @@ -435,7 +447,7 @@ pub(crate) fn build_recovery_message( let mut active: Vec<&str> = Vec::new(); for g in &working_context.goals { if matches!(g.status, GoalStatus::Active) - && !is_recovery_artifact(&g.text) + && !is_guide_artifact(&g.text) && seen.insert(g.text.clone()) { active.push(&g.text); @@ -443,7 +455,7 @@ pub(crate) fn build_recovery_message( } for g in pinned_goals { if matches!(g.status, crate::commands::ai::GoalStatus::Active) - && !is_recovery_artifact(&g.text) + && !is_guide_artifact(&g.text) && seen.insert(g.text.clone()) { active.push(&g.text); @@ -504,7 +516,7 @@ pub(crate) fn parse_pinned_goals(raw: Option<&str>) -> Vec vec![], } @@ -1961,4 +1973,18 @@ mod tests { let cleaned = serde_json::to_string(&parsed).unwrap(); assert_eq!(parse_pinned_goals(Some(&cleaned)).len(), 1); } + + #[test] + fn parse_pinned_goals_drops_injection_artifacts() { + // E5:[补充指令] 是通道标记非任务意图(434c8c40「[补充指令] 123 hi」占位实证), + // 与恢复产物同治——载入侧剔除,下轮 save 重写自愈。 + let polluted = serde_json::to_string(&vec![ + crate::commands::ai::GoalEntry::new("[补充指令] 123 hi".to_string()), + crate::commands::ai::GoalEntry::new("升级工具链到最新版".to_string()), + ]) + .unwrap(); + let parsed = parse_pinned_goals(Some(&polluted)); + assert_eq!(parsed.len(), 1, "注入产物条目须剔除: {parsed:?}"); + assert_eq!(parsed[0].text, "升级工具链到最新版"); + } } diff --git a/src-tauri/src/commands/ai/tool_failure_memory.rs b/src-tauri/src/commands/ai/tool_failure_memory.rs index fce7ff5..c0314ee 100644 --- a/src-tauri/src/commands/ai/tool_failure_memory.rs +++ b/src-tauri/src/commands/ai/tool_failure_memory.rs @@ -25,6 +25,25 @@ struct FailureEntry { last_error: String, } +/// 工具级超时连败计数(E13):治「同工具跨签名调参重试」逃逸——run_command 换 timeout +/// 档位即换 key,签名级拦截不触发,8 连败照烧轮次(434c8c40 实证)。此处按工具聚合: +/// 连续超时(错误特征匹配)≥3 次 → 该工具下次执行前注入软收敛警告(不硬拦,防误杀 +/// 同工具的轻量调用),信息给足引导 LLM 换方案/问用户。该工具任一次成功即清零。 +static TOOL_TIMEOUT_STREAKS: OnceLock>> = OnceLock::new(); + +/// 超时连败软收敛阈值(跨签名聚合口径)。 +pub const TIMEOUT_STREAK_WARN_AFTER: u32 = 3; + +fn timeout_streaks() -> &'static Mutex> { + TOOL_TIMEOUT_STREAKS.get_or_init(|| Mutex::new(HashMap::new())) +} + +/// 错误摘要是否属超时族(中英特征,大小写不敏感)。 +fn is_timeout_error(error: &str) -> bool { + let lower = error.to_lowercase(); + lower.contains("超时") || lower.contains("timeout") || lower.contains("timed out") +} + /// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。 static FAILURE_MEMORY: OnceLock>> = OnceLock::new(); @@ -70,34 +89,50 @@ fn sort_json(v: &serde_json::Value) -> serde_json::Value { } } -/// 执行前检查:同 key 连败达标 → 拦截,返回结构化失败提示(LLM 可读换路引导)。 -/// 未达标/无记忆 → None 放行。 +/// 执行前检查:同 key 连败达标 → 拦截;工具级超时连败达标 → 软收敛警告(不拦执行)。 +/// 两者都未达标/无记忆 → None 放行。 pub fn precheck(tool: &str, args: &serde_json::Value) -> Option { let key = memory_key(tool, args); let mem = memory().lock().unwrap_or_else(|p| p.into_inner()); - let Some(entry) = mem.get(&key) else { return None }; - if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES { - Some(format!( - "{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}", - entry.consecutive, - entry.last_error.replace('\"', "'"), - entry.consecutive - )) - } else { - None + if let Some(entry) = mem.get(&key) { + if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES { + return Some(format!( + "{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}", + entry.consecutive, + entry.last_error.replace('\"', "'"), + entry.consecutive + )); + } } + // E13 工具级超时连败(跨签名聚合):换参数/换档位逃不出工具维度。 + let streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner()); + if let Some(streak) = streaks.get(tool) { + if *streak >= TIMEOUT_STREAK_WARN_AFTER { + return Some(format!( + "{{\"status\":\"timeout_streak_warning\",\"tool\":\"{tool}\",\"timeout_streak\":{},\"hint\":\"该工具已连续超时 {} 次(不同参数/档位都超)。继续原路大概率仍超时:①把任务拆小分步执行 ②换更轻量的方式(降低数据量/改后台执行) ③若确实需要长时间运行,向用户说明并确认后再继续。\"}}", + streak, streak + )); + } + } + None } -/// 执行后回写:成功清零删除,失败递增并记最近错误。 +/// 执行后回写:成功清零删除(签名级+工具级超时计数),失败递增并记最近错误; +/// 失败属超时族时工具级超时连败 +1。 pub fn record(tool: &str, args: &serde_json::Value, ok: bool, error: Option) { let key = memory_key(tool, args); let mut mem = memory().lock().unwrap_or_else(|p| p.into_inner()); + let mut streaks = timeout_streaks().lock().unwrap_or_else(|p| p.into_inner()); if ok { mem.remove(&key); + streaks.remove(tool); } else { let e = mem.entry(key).or_insert(FailureEntry { consecutive: 0, last_error: String::new() }); e.consecutive += 1; e.last_error = error.unwrap_or_default().chars().take(200).collect(); + if is_timeout_error(&e.last_error) { + *streaks.entry(tool.to_string()).or_insert(0) += 1; + } } } @@ -145,4 +180,38 @@ mod tests { let a = json!({"command": "cargo test", "working_dir": "E:/x"}); assert!(memory_key("run_command", &a).contains("cmd=cargo test")); } + + #[test] + fn timeout_streak_aggregates_across_signatures_and_soft_warns() { + clear(); + timeout_streaks().lock().unwrap().clear(); + // 换档位=换签名(E13 逃逸路径:超时档拼在命令里即不同 key):签名级各 1 次均不拦; + // 工具级 streak 在第 3 次超时发生后达 3,同工具再调用(含当次迭代)即软警告 + for (i, timeout) in ["60", "300", "600"].iter().enumerate() { + let args = json!({"command": format!("cargo build --timeout {timeout}")}); + record("run_command", &args, false, Some(format!("命令执行超时({timeout}s)"))); + if i < 2 { + assert!(precheck("run_command", &args).is_none(), "签名级 1 次且 streak<3 不拦"); + } + } + // 新签名(第 4 档):工具级超时连败 ≥3 → 软收敛警告(放行执行但注入换路引导) + let args4 = json!({"command": "cargo build --timeout 900"}); + let warn = precheck("run_command", &args4).unwrap(); + assert!(warn.contains("timeout_streak_warning")); + assert!(warn.contains("\"timeout_streak\":3")); + // 同工具任一成功 → 工具级清零 + record("run_command", &json!({"command": "ls"}), true, None); + assert!(precheck("run_command", &args4).is_none()); + } + + #[test] + fn non_timeout_failures_do_not_aggregate_to_timeout_streak() { + clear(); + timeout_streaks().lock().unwrap().clear(); + for path in ["/a.rs", "/b.rs", "/c.rs"] { + record("read_file", &json!({"path": path}), false, Some("文件不存在".into())); + } + // 3 连败但非超时族 → 无工具级警告 + assert!(precheck("read_file", &json!({"path": "/d.rs"})).is_none()); + } }