//! 工具调用审计 + pending 审批恢复 + 工具调用处理 use std::collections::{HashMap, HashSet}; use std::sync::Arc; use serde::Serialize; use tauri::{AppHandle, Emitter, State}; use df_ai::ai_tools::{AiToolRegistry, RiskLevel}; use df_ai::provider::ChatMessage; use df_storage::crud::AiToolExecutionRepo; use df_storage::db::Database; use crate::state::AppState; use crate::commands::err_str; use super::{AiChatEvent, AiSession, PendingApproval, ToolCallDraft}; use super::tool_registry::generate_diff; /// RiskLevel → 审计记录字符串(low/medium/high) pub(crate) fn risk_str(r: RiskLevel) -> &'static str { match r { RiskLevel::Low => "low", RiskLevel::Medium => "medium", RiskLevel::High => "high", } } /// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过) pub(crate) fn risk_from_str(s: &str) -> Option { match s { "low" => Some(RiskLevel::Low), "medium" => Some(RiskLevel::Medium), "high" => Some(RiskLevel::High), _ => None, } } // ============================================================ // 审批历史查询 IPC(AE-2025-08) // ============================================================ /// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露) /// /// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要, /// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。 #[derive(Debug, Clone, Serialize)] pub struct ToolExecutionDto { pub id: String, pub conversation_id: Option, pub tool_call_id: String, pub tool_name: String, /// 参数摘要(截断 120 字符,完整原值仍留库) pub arguments_brief: String, /// 结果摘要(截断 160 字符,None → 空串便于前端展示) pub result_brief: Option, /// pending/approved/rejected/executing/completed/failed pub status: String, /// low/medium/high pub risk_level: String, pub requested_at: String, pub executed_at: Option, /// human/auto,None 表示尚未决策 pub decided_by: Option, } /// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji) fn truncate_chars(s: &str, max: usize) -> String { if s.chars().count() <= max { return s.to_string(); } let mut out: String = s.chars().take(max).collect(); out.push('…'); out } /// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。 /// /// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。 /// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。 #[tauri::command] pub async fn list_tool_executions( state: State<'_, AppState>, limit: Option, offset: Option, ) -> Result, String> { let limit = limit.unwrap_or(50); let offset = offset.unwrap_or(0); let records = state .ai_tool_executions .list_recent(limit, offset) .await .map_err(err_str)?; Ok(records .into_iter() .map(|r| ToolExecutionDto { id: r.id, conversation_id: r.conversation_id, tool_call_id: r.tool_call_id, tool_name: r.tool_name, arguments_brief: truncate_chars(&r.arguments, 120), result_brief: r.result.map(|s| truncate_chars(&s, 160)), status: r.status, risk_level: r.risk_level, requested_at: r.requested_at, executed_at: r.executed_at, decided_by: r.decided_by, }) .collect()) } // reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason) // 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。 mod reason; // process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。 use reason::build_approval_reason; // restore(audit/restore.rs):启动恢复重建 pending_approvals。 // 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。 mod restore; pub use restore::restore_pending_approvals; // finalize(audit/finalize.rs):audit_tool_call 写入 + audit_finalize 审批后状态回填。 // 第二批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls / // find_cached_high_risk_result 裸名调用,且保持 commands::ai::audit::* 对 crate 内可见。 mod finalize; pub(crate) use finalize::{audit_finalize, audit_tool_call}; // cache(audit/cache.rs):F-260616-05 高危工具去重缓存。 // 第三批从本文件抽离,行为零变更。pub(super) use 供本文件 process_tool_calls 裸名调用 // (find_cached_high_risk_result),PENDING_APPROVAL_PLACEHOLDER 供 process_tool_calls 占位回填共享。 mod cache; pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDER}; // data_change(audit/data_change.rs):AR-11 数据变更联动刷新。 // 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见 // (chat.rs 通过 commands::ai::audit::emit_data_changed 引入并调用),data_change_for_tool 私有。 mod data_change; pub(crate) use data_change::emit_data_changed; /// AE-2025-03(路径 B):write_file 审批预览 diff 生成。 /// /// 从 write_file args 取 path(旧文件路径)+ content(新内容), /// 预读旧文件 → 复用 `generate_diff`(F-260615-10 LCS 行级 diff)注入审批事件。 /// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None(前端回退显新 content)。 /// /// **仅读不改**:审批未通过前不动文件;读路径不校验(write_file handler 自身会校验 /// validate_path,审批拒绝则 handler 不执行,恶意路径读失败仅得到 None 不产生危害)。 async fn build_write_file_diff(args: &serde_json::Value) -> Option { let path = args.get("path")?.as_str()?; let new_content = args.get("content")?.as_str()?; match tokio::fs::read_to_string(path).await { Ok(old_content) => { if old_content == new_content { return None; // 无变化不生成 diff(理论上 write_file 不会如此,容错) } Some(generate_diff(&old_content, new_content)) } Err(_) => None, // 文件不存在(新建)/ 无读权限 → 无 diff,前端显新 content } } /// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控 /// 返回待审批的工具数量(0 = 全部自动执行完成) pub(crate) async fn process_tool_calls( session: &mut AiSession, tool_calls_acc: HashMap, tools_arc: &Arc, db: &Arc, app_handle: &AppHandle, conv_id: &str, ) -> usize { let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect(); tc_list.sort_unstable_by_key(|(i, _)| *i); // B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶, // 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡, // Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留 // 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall // 守卫双保险。详 docs/02-架构设计/B-260616-21排查方案-2026-06-16.md。 let mut seen_ids: HashSet = HashSet::new(); tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone())); let mut pending_count = 0usize; let audit_repo = AiToolExecutionRepo::new(db); // 解析 args + 批量发 Started(前端骨架按原始 index 顺序展示) let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter() .map(|(idx, draft)| { let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default())); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallStarted { id: draft.id.clone(), name: draft.name.clone(), args: args.clone(), conversation_id: Some(conv_id.to_string()), }); (idx, draft, args) }) .collect(); // 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result) // // F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。 // 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result, // 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批, // 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。 let mut low_risk: Vec<(ToolCallDraft, serde_json::Value)> = Vec::new(); // trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn // (对齐 Low risk L673-720 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。 // 元组携带 risk_level: 回填审计需原等级(Med/High 才进此分支),避免闭包内再查 registry let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new(); for (_, draft, args) in drafts { let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High); match risk_level { RiskLevel::Low => low_risk.push((draft, args)), RiskLevel::Medium | RiskLevel::High => { // AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command, // 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。 // 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径, // 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具** // (用户信任同目录同类操作,但仍要看每次的真实结果)。 let trust_hit = super::trust_key_for(&draft.name, &args) .and_then(|key| { // F-260616-09 B 批2:读 per_conv.session_trust(conv_id 来源:本函数入参)。 if session.conv_read(conv_id).map(|c| c.session_trust.contains(&key)).unwrap_or(false) { Some(key) } else { None } }); if let Some(key) = trust_hit { let dir_label = match &key { super::TrustKey::Write { dir } | super::TrustKey::Execute { dir } => dir.clone(), }; tracing::info!( tool = %draft.name, dir = %dir_label, new_tool_call_id = %draft.id, "[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)" ); // emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)") // toast 即时反馈,锁内 emit 不阻塞 let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolAutoApproved { id: draft.id.clone(), tool: draft.name.clone(), dir: dir_label.clone(), conversation_id: Some(conv_id.to_string()), }); // 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁, // run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此) trust_hits.push((draft, args, dir_label, risk_level)); continue; } // F-05:仅 High 查去重缓存;Med 保持原审批流程 if matches!(risk_level, RiskLevel::High) { if let Some((cached, status)) = find_cached_high_risk_result(session, conv_id, &audit_repo, &draft.name, &args).await { // 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批 tracing::info!( tool = %draft.name, new_tool_call_id = %draft.id, "[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)" ); // F-260616-09 B 批2:写 per_conv.messages(conv_id 来源:本函数入参)。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached)); let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(cached.clone()), conversation_id: Some(conv_id.to_string()), }); // 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup")).await; continue; } } pending_count += 1; // AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。 // 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。 // 旧文件不存在(新建)→ diff=None,前端回退显新 content。 // 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。 let approval_diff: Option = if draft.name == "write_file" { build_write_file_diff(&args).await } else { None }; session.pending_approvals.insert(draft.id.clone(), PendingApproval { tool_call_id: draft.id.clone(), tool_name: draft.name.clone(), arguments: args.clone(), conversation_id: Some(conv_id.to_string()), recovered: false, diff: approval_diff.clone(), }); session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, PENDING_APPROVAL_PLACEHOLDER)); let reason = build_approval_reason(&draft.name, &args, risk_level, db).await; let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiApprovalRequired { id: draft.id.clone(), name: draft.name.clone(), args: args.clone(), reason, diff: approval_diff, conversation_id: Some(conv_id.to_string()), }); audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None).await; } } } // AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"—— // session 锁仍由调用方 agentic.rs 持有至 process_tool_calls 返回,CR-53 审查纠正原"移锁外"误述), // push tool_result / audit 在 join_all 后串行回填(持锁)。对齐 Low risk 并行模式。 // CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令 // 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间(锁持有时长不变,并行化降阻塞)。 // join_all 保序——结果顺序 = trust_hits 输入顺序 = tc_list 原始 index 顺序,不额外 sort if !trust_hits.is_empty() { let results: Vec<(ToolCallDraft, RiskLevel, Result)> = futures::future::join_all(trust_hits.into_iter().map(|(draft, args, _dir_label, risk_level)| { let tools = tools_arc.clone(); let app_clone = app_handle.clone(); let conv_clone = conv_id.to_string(); async move { let exec_result = tools.execute(&draft.name, args).await; match exec_result { Ok(val) => { let content = val.to_string(); let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(content.clone()), conversation_id: Some(conv_clone), }); // AR-11:数据变更类工具执行成功后 emit df-data-changed 联动刷新 // (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop) emit_data_changed(&app_clone, &draft.name); (draft, risk_level, Ok(content)) } Err(e) => { let content = e.to_string(); let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(content.clone()), conversation_id: Some(conv_clone), }); (draft, risk_level, Err(content)) } } } })).await; // 串行回填 tool_result + 审计(持 session 锁) for (draft, risk_level, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), Err(c) => ("failed", c), }; // F-260616-09 B 批2:写 per_conv.messages。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); // 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯 audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust")).await; } } // Low 风险并行执行:execute + 即时 emit 在闭包内(不持 session 锁), // push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。 // join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort if !low_risk.is_empty() { let results: Vec<(ToolCallDraft, Result)> = futures::future::join_all(low_risk.into_iter().map(|(draft, args)| { let tools = tools_arc.clone(); let app_clone = app_handle.clone(); let conv_clone = conv_id.to_string(); async move { let result = tools.execute(&draft.name, args).await; match result { Ok(val) => { let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: val.clone(), conversation_id: Some(conv_clone), }); // AR-11(方案A):数据变更工具执行成功后 emit df-data-changed, // 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。 emit_data_changed(&app_clone, &draft.name); (draft, Ok(val.to_string())) } Err(e) => { // AR-6(定向B):Low 工具失败不 emit AiError。 // 原逻辑 emit AiError 会令前端置 streaming=false + 错误气泡,但 process_tool_calls // 仍返回 pending_count=0,agentic loop 续下一轮 → 前端 false 后端跑,状态紊乱。 // 现改为正常 emit AiToolCallCompleted(result=错误信息),错误包进 tool_result // 让 LLM 看到工具失败自行决定下一步,loop 正常续,前端状态一致。 let err_msg = format!("工具 {} 执行失败: {}", draft.name, e); let _ = app_clone.emit("ai-chat-event", AiChatEvent::AiToolCallCompleted { id: draft.id.clone(), result: serde_json::Value::String(err_msg.clone()), conversation_id: Some(conv_clone), }); (draft, Err(err_msg)) } } } })).await; // 串行回填 tool_result + 审计(持 session 锁) for (draft, outcome) in results { let (status, content) = match outcome { Ok(c) => ("completed", c), Err(c) => ("failed", c), }; // F-260616-09 B 批2:写 per_conv.messages。 session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone())); audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto")).await; } } pending_count }