重构: Coordinator 接入 agentic loop + audit 拆分(approval/record)

- audit/approval.rs: 审批门控(风险分类+自动执行+挂起审批)

- audit/record.rs: 审计记录写入+历史查询

- coordinator.rs: 移除 deprecated,正式作为 P0 骨架可用

- run_agentic_loop: plan_execution_enabled 时 Coordinator 分解意图→记录 Plan

- audit/mod.rs 从 ~500 行精简至 ~200 行(编排层)
This commit is contained in:
lxy
2026-07-01 14:29:34 +08:00
parent a9ee9b1f74
commit fa410e6843
11 changed files with 1234 additions and 523 deletions
+280
View File
@@ -0,0 +1,280 @@
//! F-#97 / AE-04 / 阶段4 审批门控逻辑。
//!
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
//! - `classify_risk_and_auto`:按 risk_level + auto_exec_mode 判定是否自动执行
//! - `check_trust_hits`:会话信任命中检测(AE-2025-04)
//! - `insert_pending_approval`:挂起审批 + diff 生成 + AiApprovalRequired emit + 审计落 pending
//! - `detect_retry_count`:同 tc_id 重试检测
//! - `handle_approval_tool`:单工具调用的审批门控(调用上述子函数组合)
//!
//! process_tool_calls 通过 `use approval::{detect_retry_count, handle_approval_tool}` 裸名调用。
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::cache::{find_cached_high_risk_result, pending_placeholder_for};
use super::diff::build_write_file_diff;
use super::reason::build_approval_reason;
use super::record::audit_tool_call;
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, trust_key_for, TrustKey};
/// 按 risk_level + auto_exec_mode 判定是否自动执行。
///
/// F-#97 自动执行范围三档(2026-06-22):low/medium/all,默认 low。
/// - low:仅 Low 自动(等价旧行为)
/// - mediumLow+Medium 自动
/// - all:全自动无审批(完全 AI 接管)
///
/// C-260627patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。
pub(super) fn classify_risk_and_auto(
risk_level: RiskLevel,
auto_exec_mode: &str,
tool_name: &str,
args: &serde_json::Value,
) -> bool {
let mut should_auto = match risk_level {
RiskLevel::Low => true,
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
RiskLevel::High => auto_exec_mode == "all",
};
// C-260627patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流
if !should_auto && tool_name == "patch_file" {
if let Some(text) = args.get("new_text").and_then(|v| v.as_str()) {
if text.lines().count() <= 5 {
should_auto = true;
}
}
}
should_auto
}
/// AE-2025-04 检查会话信任命中。
///
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
pub(super) fn check_trust_hits(
draft: &ToolCallDraft,
args: &serde_json::Value,
session: &AiSession,
conv_id: &str,
) -> Option<TrustKey> {
trust_key_for(&draft.name, args)
.and_then(|key| {
if session.conv_read(conv_id).map(|c| c.session_trust.contains(&key)).unwrap_or(false) {
Some(key)
} else {
None
}
})
}
/// 插入 pending 审批:生成 diff → 重试检测 → insert PendingApproval + 占位 tool_result
/// → 拼 reason + emit AiApprovalRequired + 审计落 pending 纪录。
///
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
pub(super) async fn insert_pending_approval(
draft: ToolCallDraft,
args: serde_json::Value,
risk_level: RiskLevel,
session: &mut AiSession,
conv_id: &str,
audit_repo: &AiToolExecutionRepo,
app_handle: &AppHandle,
db: &Arc<Database>,
current_message_id: Option<&str>,
) {
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
let approval_diff: Option<String> = if draft.name == "write_file" {
build_write_file_diff(&args).await
} else {
None
};
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
// 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配
// High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
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));
// L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。
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);
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;
return;
}
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:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
retry_count,
created_at: Some(std::time::SystemTime::now()),
});
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
let ev = AiChatEvent::AiApprovalRequired {
id: draft.id.clone(),
name: draft.name.clone(),
args: args.clone(),
reason,
diff: approval_diff,
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);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
///
/// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
///
/// 实现:查 `find_by_tool_call_id`status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
///
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
Ok(opt) => match opt {
Some(r) => r,
None => return 0, // 无记录 = 首次
},
Err(e) => {
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
return 0;
}
};
// pending = 首次挂起审批(尚未落定);其余落定状态 = 已执行过 → 计 1 次重试
if rec.status == "pending" {
0
} else {
1
}
}
/// 对单条工具调用执行审批门控决策:
///
/// 1. 按 `auto_exec_mode`low/medium/all + `risk_level` + `patch_file` 小改动特例
/// 判定是否应自动执行(`classify_risk_and_auto`)。若是 → 推入 `low_risk` 返回。
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard →
/// `insert_pending_approval`write_file diff + 挂起 + emit + 审计落 pending 记录)。
///
/// **不持 session 锁**:调用方(process_tool_calls)在持锁循环内调用本函数。
pub(super) async fn handle_approval_tool(
draft: ToolCallDraft,
args: serde_json::Value,
risk_level: RiskLevel,
auto_exec_mode: &str,
session: &mut AiSession,
conv_id: &str,
_tools_arc: &Arc<AiToolRegistry>,
audit_repo: &AiToolExecutionRepo,
app_handle: &AppHandle,
db: &Arc<Database>,
low_risk: &mut Vec<(ToolCallDraft, serde_json::Value, RiskLevel)>,
trust_hits: &mut Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)>,
pending_count: &mut usize,
current_message_id: Option<&str>,
) {
// ── Step 1: 自动执行判定 ──
if classify_risk_and_auto(risk_level, auto_exec_mode, &draft.name, &args) {
low_risk.push((draft, args, risk_level));
return;
}
// ── Step 2: 会话信任检查 ──
// 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 放行**真实执行工具**
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
if let Some(key) = check_trust_hits(&draft, &args, session, conv_id) {
let dir_label = match &key {
TrustKey::Write { dir } | 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)"
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
let ev = AiChatEvent::AiToolAutoApproved {
id: draft.id.clone(),
tool: draft.name.clone(),
dir: dir_label.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);
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPCCR-51 修此)
trust_hits.push((draft, args, dir_label, risk_level));
return;
}
// ── Step 3: F-05 高危去重缓存(仅 High) ──
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 重试同命令,复用缓存结果跳过审批(断循环)"
);
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(cached.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);
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failedSW-260618-16decided_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"), current_message_id).await;
return;
}
}
// ── Step 4: 插入 pending 审批 ──
*pending_count += 1;
insert_pending_approval(
draft, args, risk_level, session, conv_id, audit_repo, app_handle, db,
current_message_id,
).await;
}