//! F-260616-05 高危工具去重缓存(第三批 helper 抽离,行为零变更)。 //! //! 从 audit/mod.rs 搬迁:`find_cached_high_risk_result` + `canonical_args_key` //! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls //! 共用占位常量)。三函数内部闭环(find_cached 调 canonical→sort),无外部 helper 依赖。 use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; use super::super::AiSession; /// Med/High 工具挂起审批时回填的占位 tool_result 内容。 /// /// 单一常量避免「写入处」(process_tool_calls) 与「去重排查处」 /// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移, /// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。 pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等待确认"; /// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。 /// /// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具 /// 串行查 AiToolExecutionRepo::find_by_tool_call_id,工具数多时锁持有线性增长。 /// 批量预取(改签名传 tool_call_ids 批量查)属架构改暂未做,后续 High risk 工具增多时优先处理。 /// /// **根因链**(F-04 batch42 已做超时标注):LLM 调 run_command 超时 → F-04 把超时标注成 /// tool_result 回传 LLM → LLM(不可靠)仍重试同命令 → 新 tool_call_id(provider 每轮新 id) /// → `process_tool_calls` 重新 insert pending(audit.rs:418) → 用户被迫重新审批,循环。 /// /// **治本(F-05)**:即使 LLM 仍重试,同 (tool_name, args) 不重复审批。本函数扫描会话历史, /// 若发现**已落定**(executed/failed/rejected,非 pending)的同类同参高危调用,返回其 /// tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过审批。 /// /// **安全边界**: /// - 仅 High risk(循环源头);Low/Med 不去重(Low 直行无审批,Med 重试场景少且去重易误伤)。 /// - 仅匹配**已落定**结果(pending 的不命中——pending 已有独立流程,不会触发循环; /// 且避免两个 pending 互相吞掉审批)。LLM 只有在收到 tool_result 后才会重试, /// 故循环必然是「上一条已落定 → 重试」形态,pending 不命中不影响治本。 /// - args 走 JSON 规范化比较(键序无关),避免 LLM 两次生成键序不同误判为不同命令。 /// - 返回 None 表示无缓存命中(走原审批流程)。 /// /// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。 /// /// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。 /// conv_id 来源:process_tool_calls 入参 → 由 agentic.rs run_agentic_loop 入参透传。 pub(crate) async fn find_cached_high_risk_result( session: &AiSession, conv_id: &str, audit_repo: &AiToolExecutionRepo, tool_name: &str, args: &serde_json::Value, ) -> Option<(String, String)> { use df_ai::provider::MessageRole; // 规范化新调用的 args 为可比字符串(排序键,键序无关) let new_args_key = canonical_args_key(args); // F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv, // 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。 let conv = session.conv_read(conv_id)?; // ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。 // 单对话消息量小(百级),collect 开销可忽略。 let msgs: Vec<&ChatMessage> = conv.messages.iter().collect(); // 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id // 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。 let mut prev_tool_call_id: Option = None; for msg in msgs.iter().rev() { if !matches!(msg.role, MessageRole::Assistant) { continue; } let Some(tcs) = msg.tool_calls.as_ref() else { continue }; for tc in tcs { if tc.function.name != tool_name { continue; } // 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中) let Ok(old_args) = serde_json::from_str::(&tc.function.arguments) else { continue; }; if canonical_args_key(&old_args) == new_args_key { prev_tool_call_id = Some(tc.id.clone()); break; } } if prev_tool_call_id.is_some() { break; } } // 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」, // 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。 // pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。 let old_id = prev_tool_call_id?; for msg in msgs.iter().rev() { if !matches!(msg.role, MessageRole::Tool) { continue; } if msg.tool_call_id.as_deref() != Some(old_id.as_str()) { continue; } // 命中旧 tool_result:排除 pending 占位(内容固定为 PENDING_APPROVAL_PLACEHOLDER) if msg.content == PENDING_APPROVAL_PLACEHOLDER { return None; } // SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给 // audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果 // 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。 let status = audit_repo .find_by_tool_call_id(old_id.as_str()) .await .ok() .and_then(|opt| opt.map(|rec| rec.status)) .unwrap_or_else(|| "completed".to_string()); return Some((msg.content.clone(), status)); } None } /// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化, /// 键序不同的等价参数生成同一 key(防 LLM 两次生成键序不同误判为不同命令)。 fn canonical_args_key(args: &serde_json::Value) -> String { let mut v = args.clone(); sort_object_keys(&mut v); // 紧凑序列化(无空白),保证稳定可比 serde_json::to_string(&v).unwrap_or_default() } /// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。 fn sort_object_keys(v: &mut serde_json::Value) { match v { serde_json::Value::Object(map) => { // BTreeMap 按键排序,重建 Object let mut entries: Vec<(String, serde_json::Value)> = map .iter() .map(|(k, val)| (k.clone(), val.clone())) .collect(); entries.sort_by(|a, b| a.0.cmp(&b.0)); map.clear(); for (k, mut val) in entries { sort_object_keys(&mut val); map.insert(k, val); } } serde_json::Value::Array(arr) => { for item in arr { sort_object_keys(item); } } _ => {} } }