Files
DevFlow/src-tauri/src/commands/ai/tool_failure_memory.rs
T
lxy d7e626a813 修复: 飞书走查六项 + 停靠状态写侧根因解 + agentic/audit 加固
飞书集成走查六项(feishu_channel.rs +420):
1. 审批卡按钮化: on_card_action + handle_card_action(解析 {approval_id,approved}
   → ai_approve) + send_approval_button_card(Card JSON 1.0 双按钮, 失败降级文本卡)
2. 首帧流式提速: AiTextDelta 首个 delta 立即刷占位卡, 不等 3s ticker
3. 断线重连通知: 按 run_loop 时长判据(≥30s 推断开 / <30s 累计 3 次推不稳定),
   新增 notify_monitor()
4. 占位卡带任务原文回显(truncate 60)
5. 白名单配置引导: 后端分步自助引导 + FeishuPanel hintFeishuWhitelist + i18n
6. 审批决议后追加执行结果: spawn_approval_result_watcher 订阅 AiToolCallCompleted
   (匹配 tool_call_id, 60s 超时兜底); 订阅须在 ai_approve 前完成(无订阅者跳过序列化)

停靠(parked)写侧根因解(904):
emit_task_parked(ai/mod.rs) 升级为「写 parked + 发事件 + 通知」三件套——
新增 set_loop_state(conv_id, Some("parked")) fire-and-forget; 8 个业务调用点
(H2 三闸/熔断两路/挂起取消/max_rounds/midstream/fatal + 审批超时) 全经单点。
治「停靠卡继续必报 AI 未在暂停态」(全仓原无写 parked 路径)。

agentic/audit 加固: context_lifecycle/stream_relay/mod 调整 + audit record 完善 +
tool_failure_memory/tool_result_offload/tools/task/settings 同步。

验收: cargo check -p devflow EXIT 0。
2026-10-09 22:54:19 +08:00

149 lines
6.8 KiB
Rust

//! B5d 工具失败记忆(conv 级):治「404 连环十几次无退避换路」——AI 工具是原生能力,
//! 应有学习能力:同 (conv, tool, args) 连败 ≥2 次 → 拦截重试,回填结构化失败提示,
//! 引导 LLM 换路(改参数/换工具/报告放弃);成功即清零(知识保鲜,防误杀)。
//!
//! 设计要点(与既有机制的关系):
//! - 与 fail_window/熔断(stall_detector)互补:熔断治「会话级停滞」(连续 N 轮全失败),
//! 本模块治「单点死磕」(同一调用原样重发);两层独立计数互不干扰。
//! - 与只读缓存(find_cached_readonly_result)不同:缓存复用成功结果,本模块拦截失败重复。
//! - 记忆生命周期 = conv 生命周期(进程内 HashMap,conv 结束随会话对象丢弃;不落库,
//! 下次会话重新学习——失败参数往往任务态相关,跨会话记忆弊大于利)。
//! - key 归一:tool_name + args 关键字段(read_file 用 path,grep 用 pattern+path,
//! run_command 用命令行本体;其余工具全 args JSON)——LLM 微调无关字段(如 limit)
//! 不应逃脱记忆,但完全相同调用必须命中。
use std::collections::HashMap;
use std::sync::{Mutex, OnceLock};
/// 连败拦截阈值:同 key 连败 ≥2 次(即第 3 次尝试)在执行前拦截。
pub const ABORT_AFTER_CONSECUTIVE_FAILURES: u32 = 2;
/// 记忆条目:连续失败次数 + 最近一次错误摘要(拦截时回填给 LLM 换路提示)。
#[derive(Debug, Clone)]
struct FailureEntry {
consecutive: u32,
last_error: String,
}
/// 进程级失败记忆(全 conv 共享单锁;锁内零 await,微秒级)。
static FAILURE_MEMORY: OnceLock<Mutex<HashMap<String, FailureEntry>>> = OnceLock::new();
fn memory() -> &'static Mutex<HashMap<String, FailureEntry>> {
FAILURE_MEMORY.get_or_init(|| Mutex::new(HashMap::new()))
}
/// args → 记忆 key 的归一化提取(工具感知)。
///
/// 原则:抽「语义主键」而非全 args——read_file 微调 limit 重读同一文件,应视为同调用;
/// run_command 命令行本体即主键(含路径/参数)。
fn memory_key(tool: &str, args: &serde_json::Value) -> String {
let get = |k: &str| args.get(k).and_then(|v| v.as_str()).unwrap_or("").to_string();
match tool {
"read_file" | "read_symbol" | "file_info" | "delete_file" => {
format!("{tool}|path={}", get("path"))
}
"grep" => format!("{tool}|pat={}|path={}", get("pattern"), get("path")),
"search_files" => format!("{tool}|pat={}|path={}", get("pattern"), get("path")),
"list_directory" => format!("{tool}|path={}", get("path")),
"run_command" => format!("{tool}|cmd={}", get("command")),
"mysql_query" | "mysql_execute" => format!("{tool}|sql={}", get("sql")),
_ => {
// 通用:全 args 稳定序列化(serde_json Value 保序不可保证 → BTreeMap 排序归一)
let sorted = serde_json::to_string(&sort_json(args)).unwrap_or_default();
format!("{tool}|{sorted}")
}
}
}
/// serde_json Value 递归排序(Object 按 key 排序),保证 key 稳定。
fn sort_json(v: &serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match v {
Value::Object(m) => {
let mut sorted: Vec<(String, Value)> =
m.iter().map(|(k, v)| (k.clone(), sort_json(v))).collect();
sorted.sort_by(|a, b| a.0.cmp(&b.0));
Value::Object(sorted.into_iter().collect())
}
Value::Array(a) => Value::Array(a.iter().map(sort_json).collect()),
other => other.clone(),
}
}
/// 执行前检查:同 key 连败达标 → 拦截,返回结构化失败提示(LLM 可读换路引导)。
/// 未达标/无记忆 → 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 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
}
}
/// 执行后回写:成功清零删除,失败递增并记最近错误。
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());
if ok {
mem.remove(&key);
} 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();
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn clear() {
memory().lock().unwrap().clear();
}
#[test]
fn precheck_blocks_after_two_failures() {
clear();
let args = json!({"path": "/tmp/x.rs"});
assert!(precheck("read_file", &args).is_none());
record("read_file", &args, false, Some("文件不存在".into()));
assert!(precheck("read_file", &args).is_none()); // 1 次未达阈值
record("read_file", &args, false, Some("文件不存在".into()));
let blocked = precheck("read_file", &args).unwrap();
assert!(blocked.contains("blocked_by_failure_memory"));
assert!(blocked.contains("consecutive_failures\":2"));
// 无关字段微调不逃脱记忆:limit 改了仍拦截(同 path 主键)
let args2 = json!({"path": "/tmp/x.rs", "limit": 300});
assert!(precheck("read_file", &args2).is_some());
// 成功清零
record("read_file", &args, true, None);
assert!(precheck("read_file", &args).is_none());
}
#[test]
fn memory_key_normalizes_irrelevant_fields() {
let a = json!({"path": "/a.rs", "limit": 10});
let b = json!({"path": "/a.rs", "limit": 999});
assert_eq!(memory_key("read_file", &a), memory_key("read_file", &b));
// 通用工具:全 args 排序归一,键序无关
let c = json!({"a": 1, "b": 2});
let d = serde_json::from_str::<serde_json::Value>(r#"{"b":2,"a":1}"#).unwrap();
assert_eq!(memory_key("generic_tool", &c), memory_key("generic_tool", &d));
}
#[test]
fn run_command_key_is_command_itself() {
let a = json!({"command": "cargo test", "working_dir": "E:/x"});
assert!(memory_key("run_command", &a).contains("cmd=cargo test"));
}
}