修复: 工具超时连败跨签名软收敛与注入产物不入目标
- E13:同工具超时连败≥3次(跨签名聚合,治换档位调参逃逸)执行前 注入软收敛警告——拆小任务/换轻量方式/向用户确认,不硬拦防误杀 - E5:[补充指令]前缀与[系统恢复]同治三处——载入侧滤出/恢复回放 剔除/首条任务消息排除(434c8c40占位实证),is_guide_artifact统一判据 验收:tool_failure_memory 5 passed(新增跨签名聚合+非超时不聚合); parse_pinned_goals 3 passed(新增注入产物剔除);recovery 6 passed无回归
This commit is contained in:
@@ -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| {
|
||||
|
||||
@@ -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<crate::commands::ai::
|
||||
Some(crate::commands::ai::GoalEntry::new(text))
|
||||
}
|
||||
})
|
||||
.filter(|e| !is_recovery_artifact(&e.text))
|
||||
.filter(|e| !is_guide_artifact(&e.text))
|
||||
.collect(),
|
||||
_ => 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, "升级工具链到最新版");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,25 @@ struct FailureEntry {
|
||||
last_error: String,
|
||||
}
|
||||
|
||||
/// 工具级超时连败计数(E13):治「同工具跨签名调参重试」逃逸——run_command 换 timeout
|
||||
/// 档位即换 key,签名级拦截不触发,8 连败照烧轮次(434c8c40 实证)。此处按工具聚合:
|
||||
/// 连续超时(错误特征匹配)≥3 次 → 该工具下次执行前注入软收敛警告(不硬拦,防误杀
|
||||
/// 同工具的轻量调用),信息给足引导 LLM 换方案/问用户。该工具任一次成功即清零。
|
||||
static TOOL_TIMEOUT_STREAKS: OnceLock<Mutex<HashMap<String, u32>>> = OnceLock::new();
|
||||
|
||||
/// 超时连败软收敛阈值(跨签名聚合口径)。
|
||||
pub const TIMEOUT_STREAK_WARN_AFTER: u32 = 3;
|
||||
|
||||
fn timeout_streaks() -> &'static Mutex<HashMap<String, u32>> {
|
||||
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<Mutex<HashMap<String, FailureEntry>>> = 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<String> {
|
||||
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 let Some(entry) = mem.get(&key) {
|
||||
if entry.consecutive >= ABORT_AFTER_CONSECUTIVE_FAILURES {
|
||||
Some(format!(
|
||||
return Some(format!(
|
||||
"{{\"status\":\"blocked_by_failure_memory\",\"tool\":\"{tool}\",\"consecutive_failures\":{},\"last_error\":\"{}\",\"hint\":\"同一调用已连败 {} 次,已自动拦截。请勿原样重试:①检查参数(路径/SQL/命令拼写) ②换工具或换路径 ③若确认无法绕过,向用户报告放弃原因。\"}}",
|
||||
entry.consecutive,
|
||||
entry.last_error.replace('\"', "'"),
|
||||
entry.consecutive
|
||||
))
|
||||
} else {
|
||||
));
|
||||
}
|
||||
}
|
||||
// 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<String>) {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user