- 新增 useToast composable: 消除 AiChat/Settings/Projects 4处 toast 重复 统一默认3000ms(Projects原4000ms为操作类提示保留参数覆盖) - 新增 utils/json.ts parseJsonArray: 消除 parseStack/parseTags/ModuleNode 3处JSON字符串数组解析重复 - 新增 utils/html.ts escapeHtml: 消除 useMarkdown/FilePreview 2处重复 - ProjectDetail score-bar 内联三元改用 scoreTier(消除最后一处阈值硬编码) - ConversationSidebar 删除 formatTime 透传包装(直接用 formatRelative) - 清理死代码: parseTs/stringifyError/ErrorSink/_Unused 改私有或删除 wrapNakedDiff 改私有(无外部 import) - ModuleNode shortPath 改名 truncatedPath(与 useToolCard.shortPath 语义不同)
539 lines
31 KiB
Rust
539 lines
31 KiB
Rust
//! 工具调用审计 + pending 审批恢复 + 工具调用处理
|
||
|
||
use std::collections::{HashMap, HashSet};
|
||
use std::sync::Arc;
|
||
|
||
use tauri::{AppHandle, Emitter, Manager};
|
||
|
||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||
use df_ai::provider::ChatMessage;
|
||
use df_storage::crud::AiToolExecutionRepo;
|
||
use df_storage::db::Database;
|
||
|
||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||
|
||
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
||
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
||
// super::risk_str / super::risk_from_str 引用路径不变;truncate_chars 供本文件 process_tool_calls
|
||
// DTO 装配 + record.rs 审计面板 IPC 共用。
|
||
mod utils;
|
||
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars};
|
||
|
||
// diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。
|
||
// 第五批从本文件抽离,行为零变更。
|
||
mod diff;
|
||
|
||
// path_auth(audit/path_auth.rs):F-260619-03 Phase B/C 路径授权预校验。
|
||
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||
mod path_auth;
|
||
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome};
|
||
|
||
// approval(audit/approval.rs):F-#97/AE-04/阶段4 审批门控逻辑。
|
||
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
|
||
mod approval;
|
||
use approval::{detect_retry_count, handle_approval_tool};
|
||
|
||
// record(audit/record.rs):审计记录写入 + 历史 DTO + 审计面板查询 IPC。
|
||
// 第六批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
|
||
// approval.rs 裸名调用 audit_tool_call;pub use 保持 commands::ai::audit::* 路径透明
|
||
// (list_tool_executions 是 #[tauri::command],ToolExecutionDto 供前端 DTO 序列化)。
|
||
pub mod record;
|
||
#[allow(unused_imports)]
|
||
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit};
|
||
#[allow(unused_imports)]
|
||
pub use record::{list_tool_executions, ToolExecutionDto};
|
||
|
||
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
||
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
||
mod reason;
|
||
|
||
// build_approval_reason 现由 approval.rs(insert_pending_approval)内部调用,
|
||
// 通过 `use super::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_finalize 审批后状态回填。
|
||
// audit_tool_call 已移至 record.rs(写入归 record 子模块)。
|
||
// 第二批从本文件抽离,行为零变更。pub(crate) use 供外部
|
||
//(agentic/approval_timeout.rs / commands/chat.rs)裸名调用 audit_finalize。
|
||
mod finalize;
|
||
pub(crate) use finalize::audit_finalize;
|
||
|
||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
|
||
// 第三批从本文件抽离,行为零变更。
|
||
mod cache;
|
||
pub(super) use cache::pending_placeholder_for;
|
||
|
||
// 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;
|
||
|
||
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B)灵感来源消息级溯源补全。
|
||
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
|
||
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
|
||
mod idea_source;
|
||
pub(crate) use idea_source::maybe_fill_idea_source;
|
||
|
||
// ============================================================
|
||
// 工具调用心跳 + 审批处理
|
||
// ============================================================
|
||
|
||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||
///
|
||
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
||
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
|
||
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
|
||
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
||
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
|
||
async fn execute_with_heartbeat(
|
||
tools: &AiToolRegistry,
|
||
name: &str,
|
||
args: serde_json::Value,
|
||
app: &AppHandle,
|
||
conv_id: &str,
|
||
) -> anyhow::Result<serde_json::Value> {
|
||
use std::sync::atomic::{AtomicBool, Ordering};
|
||
use tokio::time::Duration;
|
||
|
||
// RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。
|
||
struct HeartbeatGuard {
|
||
stop: Arc<AtomicBool>,
|
||
handle: tokio::task::JoinHandle<()>,
|
||
}
|
||
impl Drop for HeartbeatGuard {
|
||
fn drop(&mut self) {
|
||
self.stop.store(true, Ordering::SeqCst);
|
||
self.handle.abort();
|
||
}
|
||
}
|
||
|
||
let stop = Arc::new(AtomicBool::new(false));
|
||
let app_h = app.clone();
|
||
let conv = conv_id.to_string();
|
||
let stop_c = stop.clone();
|
||
// 心跳 task:弃首 tick(tokio interval 首 tick 立即返回,对齐 stream_recv.rs:174),
|
||
// 后每 30s emit AiHeartbeat(< 130s watchdog 确保覆盖)。
|
||
let heartbeat = tokio::spawn(async move {
|
||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||
interval.tick().await;
|
||
loop {
|
||
interval.tick().await;
|
||
if stop_c.load(Ordering::SeqCst) {
|
||
break;
|
||
}
|
||
let ev = AiChatEvent::AiHeartbeat { conversation_id: Some(conv.clone()) };
|
||
let _ = app_h.emit("ai-chat-event", ev.clone());
|
||
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
}
|
||
});
|
||
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
||
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
||
tools.execute(name, args).await
|
||
}
|
||
|
||
pub(crate) async fn process_tool_calls(
|
||
session: &mut AiSession,
|
||
tool_calls_acc: HashMap<u32, ToolCallDraft>,
|
||
tools_arc: &Arc<AiToolRegistry>,
|
||
db: &Arc<Database>,
|
||
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<String> = HashSet::new();
|
||
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
|
||
let mut pending_count = 0usize;
|
||
let audit_repo = AiToolExecutionRepo::new(db);
|
||
|
||
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
|
||
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
|
||
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
|
||
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
|
||
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
|
||
let current_message_id: Option<String> = session
|
||
.conv_read(conv_id)
|
||
.and_then(|c| c.messages.last_assistant_message_id());
|
||
let current_message_id = current_message_id.as_deref();
|
||
|
||
// 解析 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()));
|
||
// L3 emit 双写:AiToolCallStarted publish 到事件总线(tunnel subscriber 透传 miniapp)。
|
||
// AiTextDelta/AiToolCall* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。
|
||
let ev = AiChatEvent::AiToolCallStarted {
|
||
id: draft.id.clone(),
|
||
name: draft.name.clone(),
|
||
args: args.clone(),
|
||
conversation_id: Some(conv_id.to_string()),
|
||
};
|
||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
(idx, draft, args)
|
||
})
|
||
.collect();
|
||
|
||
// ── F-260619-03 Phase B/C:文件工具路径授权预校验 ──
|
||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
||
// - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired),
|
||
// 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B)
|
||
// - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程
|
||
// 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。
|
||
let allowed_snapshot = app_handle
|
||
.state::<crate::state::AppState>()
|
||
.allowed_dirs
|
||
.clone();
|
||
let allowed_guard = allowed_snapshot.read().await.clone();
|
||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||
for (idx, draft, args) in drafts {
|
||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
|
||
match check_file_tool_auth(&draft.name, &args, &allowed_guard) {
|
||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||
FileToolAuthOutcome::NeedsAuth(req) => path_auth_pending.push((draft, args, req)),
|
||
FileToolAuthOutcome::Denied(reason) => path_denied.push((draft, reason)),
|
||
}
|
||
}
|
||
let drafts = authorized_drafts;
|
||
|
||
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
|
||
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
|
||
for (draft, reason) in path_denied {
|
||
let err_msg = format!("路径授权拒绝: {}", reason);
|
||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
||
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
|
||
let ev = AiChatEvent::AiToolCallCompleted {
|
||
id: draft.id.clone(),
|
||
result: serde_json::Value::String(err_msg.clone()),
|
||
conversation_id: Some(conv_id.to_string()),
|
||
};
|
||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
|
||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||
audit_tool_call(
|
||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||
"rejected", risk_level, Some(err_msg), Some("auto_blacklist"),
|
||
current_message_id,
|
||
).await;
|
||
}
|
||
|
||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||
for (draft, args, req) in path_auth_pending {
|
||
pending_count += 1;
|
||
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
||
// ai_authorize_dir 消费时遍历所有 dirs 写白名单。
|
||
let dir_str = req.dirs.first()
|
||
.map(|d| d.to_string_lossy().to_string())
|
||
.unwrap_or_default();
|
||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||
if retry_count >= 1 {
|
||
let skip_msg = format!(
|
||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||
draft.id
|
||
);
|
||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||
let ev = AiChatEvent::AiToolCallCompleted {
|
||
id: draft.id.clone(),
|
||
result: serde_json::Value::String(skip_msg.clone()),
|
||
conversation_id: Some(conv_id.to_string()),
|
||
};
|
||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||
audit_tool_call(
|
||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||
"skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"),
|
||
current_message_id,
|
||
).await;
|
||
continue;
|
||
}
|
||
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,
|
||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||
kind: ApprovalKind::Path(req),
|
||
retry_count,
|
||
created_at: Some(std::time::SystemTime::now()),
|
||
},
|
||
);
|
||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||
let ev = AiChatEvent::AiDirAuthRequired {
|
||
id: draft.id.clone(),
|
||
tool: draft.name.clone(),
|
||
path: path_str,
|
||
dir: dir_str,
|
||
conversation_id: Some(conv_id.to_string()),
|
||
};
|
||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||
audit_tool_call(
|
||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||
"pending", risk_level, None, None,
|
||
current_message_id,
|
||
).await;
|
||
}
|
||
|
||
// 分类: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 无审批本就不进此分支。
|
||
// F-#97:low_risk 向量携带 risk_level(治 securityReview blocker:710 审计失真)。
|
||
// 原 Vec 仅 (draft,args),回填审计硬编码 RiskLevel::Low;#97 让 Med/High(all/medium 模式)
|
||
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
|
||
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
|
||
// F-#97 自动执行范围三档(2026-06-22):KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low(=现状)。
|
||
// low:仅 Low 自动(等价旧行为);medium:Low+Medium 自动;all:全自动无审批(完全 AI 接管)。
|
||
let auto_exec_mode = app_handle
|
||
.state::<crate::state::AppState>()
|
||
.settings
|
||
.get("df-ai-auto-execute-mode")
|
||
.await
|
||
.ok()
|
||
.flatten()
|
||
.unwrap_or_else(|| "low".to_string());
|
||
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
||
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||
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);
|
||
handle_approval_tool(
|
||
draft, args, risk_level, &auto_exec_mode,
|
||
session, conv_id, tools_arc, &audit_repo,
|
||
app_handle, db, &mut low_risk, &mut trust_hits,
|
||
&mut pending_count, current_message_id,
|
||
).await;
|
||
}
|
||
|
||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"——
|
||
// session 锁仍由调用方 agentic/mod.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<String, String>)> =
|
||
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 = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
|
||
match exec_result {
|
||
Ok(val) => {
|
||
let content = val.to_string();
|
||
// L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。
|
||
let ev = AiChatEvent::AiToolCallCompleted {
|
||
id: draft.id.clone(),
|
||
result: serde_json::Value::String(content.clone()),
|
||
conversation_id: Some(conv_clone),
|
||
};
|
||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
// 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();
|
||
// L3 emit 双写:trust 放行工具执行失败 emit Completed 双路发布。
|
||
let ev = AiChatEvent::AiToolCallCompleted {
|
||
id: draft.id.clone(),
|
||
result: serde_json::Value::String(content.clone()),
|
||
conversation_id: Some(conv_clone),
|
||
};
|
||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
(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"), current_message_id).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() {
|
||
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
||
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
|
||
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
||
let tools = tools_arc.clone();
|
||
let app_clone = app_handle.clone();
|
||
let conv_clone = conv_id.to_string();
|
||
async move {
|
||
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
|
||
match result {
|
||
Ok(val) => {
|
||
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
||
let ev = AiChatEvent::AiToolCallCompleted {
|
||
id: draft.id.clone(),
|
||
result: val.clone(),
|
||
conversation_id: Some(conv_clone),
|
||
};
|
||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
||
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
||
emit_data_changed(&app_clone, &draft.name);
|
||
(draft, val.clone(), risk_level, 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);
|
||
// L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。
|
||
let ev = AiChatEvent::AiToolCallCompleted {
|
||
id: draft.id.clone(),
|
||
result: serde_json::Value::String(err_msg.clone()),
|
||
conversation_id: Some(conv_clone),
|
||
};
|
||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
|
||
}
|
||
}
|
||
}
|
||
})).await;
|
||
|
||
// 串行回填 tool_result + 审计(持 session 锁)
|
||
for (draft, raw_result, risk_level, outcome) in results {
|
||
let (status, content) = match outcome {
|
||
Ok(c) => ("completed", c),
|
||
Err(c) => ("failed", c),
|
||
};
|
||
// F-260619-04 P2(方案 B):create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
|
||
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
|
||
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
|
||
if status == "completed" {
|
||
let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null);
|
||
maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await;
|
||
}
|
||
// F-260616-09 B 批2:写 per_conv.messages。
|
||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
|
||
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
||
// 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。
|
||
let decided_by: &str = match risk_level {
|
||
RiskLevel::Low => "auto",
|
||
RiskLevel::Medium => "auto_takeover_medium",
|
||
RiskLevel::High => "auto_takeover_all",
|
||
};
|
||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some(decided_by), current_message_id).await;
|
||
}
|
||
}
|
||
|
||
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||
let (path_count, risk_count) = session.pending_approvals.values()
|
||
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
||
ApprovalKind::Path(_) => (p + 1, r),
|
||
ApprovalKind::Risk { .. } => (p, r + 1),
|
||
});
|
||
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
|
||
pending_count
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use super::path_auth::extract_file_tool_paths;
|
||
|
||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||
///
|
||
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
||
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
||
#[test]
|
||
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
|
||
// 空白名单(不含 workspace_root 的目录):任何路径都未授权 → NeedsAuth
|
||
let allowed = crate::state::AllowedDirs::default();
|
||
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
|
||
|
||
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
||
match outcome {
|
||
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
|
||
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
|
||
FileToolAuthOutcome::Denied(_) => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Denied(grep 不应像 search_files 盲拒)"),
|
||
}
|
||
|
||
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
|
||
let search_outcome = check_file_tool_auth("search_files", &args, &allowed);
|
||
match search_outcome {
|
||
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
|
||
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
|
||
FileToolAuthOutcome::Authorized => panic!("search_files 未授权应返 Denied(盲拒),实际 Authorized"),
|
||
}
|
||
}
|
||
|
||
/// grep 黑名单路径(.ssh 等)直接 Denied(不申请授权),对齐 read_file。
|
||
#[test]
|
||
fn test_grep_blacklisted_path_denied() {
|
||
let allowed = crate::state::AllowedDirs::default_with_root();
|
||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
|
||
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
||
match outcome {
|
||
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
|
||
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
|
||
FileToolAuthOutcome::Authorized => panic!("grep 黑名单路径应返 Denied,实际 Authorized"),
|
||
}
|
||
}
|
||
|
||
/// extract_file_tool_paths:grep 取 args["path"](单路径分支)
|
||
#[test]
|
||
fn test_extract_grep_path() {
|
||
let args = serde_json::json!({ "pattern": "foo", "path": "src/main.rs", "glob": "*.rs" });
|
||
let paths = extract_file_tool_paths("grep", &args);
|
||
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
|
||
}
|
||
}
|