重构: 后端编排拆解与DRY债收敛及命名纠偏

阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83
技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描
DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化
警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除
工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价
命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
This commit is contained in:
lxy
2026-10-01 19:35:19 +08:00
parent 3861e13b32
commit 75c5e23523
40 changed files with 2988 additions and 1964 deletions
+6 -9
View File
@@ -7,7 +7,7 @@
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tauri::AppHandle;
use tokio::sync::Mutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
@@ -16,6 +16,7 @@ use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use super::cache::{cache_hit_warning, find_cached_high_risk_result, pending_placeholder_for};
use super::super::event_bus::emit_ai_chat_event;
use super::diff::build_write_file_diff;
use super::reason::build_approval_reason;
use super::record::audit_tool_call;
@@ -180,8 +181,7 @@ pub(super) async fn insert_pending_approval(
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);
emit_ai_chat_event(&app_handle, 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;
}
@@ -214,8 +214,7 @@ pub(super) async fn insert_pending_approval(
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);
emit_ai_chat_event(&app_handle, ev);
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
@@ -322,8 +321,7 @@ pub(super) async fn handle_approval_tool(
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);
emit_ai_chat_event(&app_handle, ev);
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
trust_hits.push((draft, args, dir_label, risk_level));
@@ -357,8 +355,7 @@ pub(super) async fn handle_approval_tool(
result: serde_json::Value::String(warned_content.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);
emit_ai_chat_event(&app_handle, ev);
// 审计:去重命中记一条(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(warned_content), Some("auto_dedup"), current_message_id).await;
return;
+33 -34
View File
@@ -56,8 +56,12 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
/// AC-2 ②:list_directory 目录级去重(cache key 只取 path,同目录换参数也命中)后,
/// 通用文案「换参数或换工具」对同目录无效(换 max_depth 也仍命中),故目录类工具
/// 用专用文案:明确告知「目录已列过 + 内容未变」,引导换 grep/search_files 定向搜索。
///
/// 目录类判定查注册元数据(cache_path_key 维度,DRY 债 #5 收敛),与
/// readonly_cache_args_key 的目录级 key 同一判据,不再各自手写工具名 match。
pub(crate) fn cache_hit_warning(tool_name: &str, hit_count: u32) -> String {
if matches!(tool_name, "list_directory" | "list_dir") {
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
if df_ai::ai_tools::cache_path_key_only(tool_name) {
format!(
"[重复调用拦截] 目录 {} 此前已列过(第 {} 次命中缓存),目录内容未变,以下为缓存结果。不要重复列同一目录,如需查找文件请改用 grep/search_files 定向搜索,或换其他目录。\n\n",
tool_name, hit_count
@@ -212,14 +216,16 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
///
/// list_directory 同目录换参数(recursive/max_depth/skip_noise_dirs 不同)也判重复——
/// 目录内容没变,换深度/递归参数重列是重复探索(实证 09e7abfa:同一目录列 3 次)。
/// 故 list_directory 的缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 故声明 cache_path_key 的工具(list_directory,DRY 债 #5 收敛后查注册元数据)
/// 缓存 key 只取 path(忽略其他参数),同目录即命中缓存,
/// 后续命中返回缓存 + 专用警告(cache_hit_warning 目录分支)。
/// 其他只读工具仍走全参 JSON 归一(键序无关,见 canonical_args_key)。
///
/// pub(crate):供 audit/mod.rs 轮内去重复用同一 key 口径(同源同 key,
/// 跨轮缓存 + 轮内去重判定一致,防两处 key 漂移致去重误判)。
pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value) -> String {
if matches!(tool_name, "list_directory" | "list_dir") {
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
if df_ai::ai_tools::cache_path_key_only(tool_name) {
args.get("path")
.and_then(|v| v.as_str())
.unwrap_or_default()
@@ -241,9 +247,9 @@ pub(crate) fn readonly_cache_args_key(tool_name: &str, args: &serde_json::Value)
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
///
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/
/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等)
/// 永不缓存,避免误伤。
/// - 仅匹配只读幂等工具(readonly 元数据维度,DRY 债 #5 收敛后查注册元数据,
/// 现 read_file/read_symbol/list_directory/search_files/grep 声明)。
/// 写工具(write_file/patch_file/run_command 等)未声明,永不缓存,避免误伤。
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
@@ -260,8 +266,9 @@ pub(crate) async fn find_cached_readonly_result(
) -> Option<(String, u32)> {
use df_ai::provider::MessageRole;
// 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
if !READONLY_CACHE_TOOLS.contains(&tool_name) {
// 只读幂等工具才缓存。写工具/有副作用工具永不缓存。
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
if !df_ai::ai_tools::is_readonly(tool_name) {
return None;
}
@@ -332,17 +339,8 @@ pub(crate) async fn find_cached_readonly_result(
Some((content, hit_count))
}
/// 可缓存的只读幂等工具白名单。
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。
/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等)
/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。
const READONLY_CACHE_TOOLS: &[&str] = &[
"read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
"read_symbol", // AST 符号读取(同 path+symbol+full → 同结果)
"list_directory", // 列目录(AC-2 ②:缓存 key 只取 path,同目录换 recursive/max_depth 也判重复)
"search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果)
"grep", // 搜文件内容(同 path+pattern+mode → 同结果)
];
/// 只读缓存白名单已收敛为注册元数据(readonly 维度,DRY 债 #5):
/// find_cached_readonly_result 改查 df_ai::ai_tools::is_readonly,本文件不再持名单。
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
fn sort_object_keys(v: &mut serde_json::Value) {
@@ -421,15 +419,18 @@ fn contains_listing_command(command: &str) -> bool {
|| contains_word(&lower, "ls")
}
/// 扫描会话历史,取所有 list_directory 已列过的目录路径(去重,保留原始大小写)。
/// 扫描会话历史,取所有目录级缓存工具(cache_path_key 声明者,现即 list_directory)
/// 已列过的目录路径(去重,保留原始大小写)。
///
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 list_directory
/// 的 args.path,忽略递归/深度参数(② 同语义:同目录即算已列过)。
/// 与 find_cached_readonly_result 同源读 assistant tool_calls;仅取 args.path,
/// 忽略递归/深度参数(② 同语义:同目录即算已列过)。工具判定查注册元数据
/// (cache_path_key 维度,DRY 债 #5 收敛),与目录级缓存 key 同一判据。
async fn listed_directory_paths(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
) -> Vec<String> {
use df_ai::provider::MessageRole;
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
let session = session_arc.lock().await;
let Some(conv) = session.conv_read(conv_id) else { return Vec::new() };
let mut dirs: Vec<String> = Vec::new();
@@ -440,7 +441,7 @@ async fn listed_directory_paths(
}
let Some(tcs) = m.tool_calls.as_ref() else { continue };
for tc in tcs {
if tc.function.name != "list_directory" {
if !df_ai::ai_tools::cache_path_key_only(&tc.function.name) {
continue;
}
let Ok(args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
@@ -494,9 +495,10 @@ pub(crate) async fn detect_listing_bypass(
None
}
/// AC-2 ③:目录列举绕行警告注入(system 消息首位,对齐 G2/AC-2① 软提示模式)。
/// AC-2 ③:目录列举绕行警告注入(system 消息首位)。
///
/// 只警告不硬拒:命令照常执行/审批,误报最多多一条提示,不阻断工作流。
/// 插入骨架经 agentic::helpers::insert_system_notice 复用(DRY #7,与 G2/AC-2① 同一实现)。
pub(crate) async fn insert_listing_bypass_warning(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
@@ -506,16 +508,13 @@ pub(crate) async fn insert_listing_bypass_warning(
"⚠ 检测到用 run_command 列目录({}) 绕行 list_directory 去重。列目录请用 list_directory 工具,如需查找文件请用 grep/search_files 定向搜索。",
dir
);
let mut session = session_arc.lock().await;
if session.per_conv.contains_key(conv_id) {
let conv = session.conv(conv_id);
conv.messages.insert_at(0, ChatMessage::system(&warn_text));
tracing::info!(
conv_id = %conv_id,
dir = %dir,
"[ai] AC-2 目录列举绕行:注入警告(只警告不硬拒)"
);
}
crate::commands::ai::agentic::helpers::insert_system_notice(
session_arc,
conv_id,
&warn_text,
&format!("AC-2 目录列举绕行:注入警告(只警告不硬拒) dir={}", dir),
)
.await;
}
#[cfg(test)]
+6 -26
View File
@@ -11,33 +11,13 @@ use tauri::{AppHandle, Emitter};
/// AR-11(方案A):工具名 → (entity, action) 映射,用于数据变更联动刷新。
///
/// 仅数据变更类工具返回 Some,其余工具返回 None(不 emit df-data-changed)。
/// 映射规则:
/// - create_project/create_idea/create_task → (project/idea/task, create)
/// - update_project/update_task/update_idea → (..., update)
/// - delete_project/delete_task/delete_idea/purge_project → (..., delete)
/// - restore_project → (project, update)(恢复等同状态变更,前端走 update 刷新)
/// - bind_directory → (project, update)(绑定目录属项目字段更新)
/// 映射查注册元数据(entity_action 维度,DRY 债 #5 收敛),在 declare_tool! 注册点声明:
/// create_*(project/idea/task, create)、update_*(..., update)、delete_*/purge_project
/// (..., delete)、restore_project/bind_directory → (project, update)、advance_task →
/// (task, update)(状态推进对齐 update_task)、write_file/patch_file → (file, update)。
pub(super) fn data_change_for_tool(name: &str) -> Option<(&'static str, &'static str)> {
let (entity, action) = match name {
"create_project" => ("project", "create"),
"create_task" => ("task", "create"),
"create_idea" => ("idea", "create"),
"update_project" => ("project", "update"),
"update_task" => ("task", "update"),
"update_idea" => ("idea", "update"),
// advance_task 改任务状态 = 任务数据变更,MCP 走 HTTP 形态时让 GUI 前端
// listen df-data-changed 刷新任务列表(此前不在映射 → 状态推进后 GUI 不刷新)。
// 数据变更机制是 entity 级(无 id),映射到 (task, update) 对齐 update_task。
"advance_task" => ("task", "update"),
"delete_project" | "purge_project" => ("project", "delete"),
"delete_task" => ("task", "delete"),
"delete_idea" => ("idea", "delete"),
"restore_project" => ("project", "update"),
"bind_directory" => ("project", "update"),
"write_file" | "patch_file" => ("file", "update"),
_ => return None,
};
Some((entity, action))
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
df_ai::ai_tools::entity_action(name)
}
/// AR-11(方案A):工具执行成功后按映射 emit "df-data-changed" { entity, action }。
+28 -65
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tauri::{AppHandle, Manager};
use tokio::sync::Mutex;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
@@ -11,6 +11,8 @@ use df_ai::provider::ChatMessage;
use df_storage::crud::AiToolExecutionRepo;
use df_storage::db::Database;
use super::event_bus::emit_ai_chat_event;
use crate::state::{SettingsNorm, log_slow_lock};
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
use super::tools::entity_resolve;
@@ -142,8 +144,7 @@ async fn execute_with_heartbeat(
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);
emit_ai_chat_event(&app_h, ev);
}
});
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
@@ -233,10 +234,7 @@ pub(crate) async fn process_tool_calls(
let session = session_arc.lock().await;
let __id = session.conv_read(conv_id)
.and_then(|c| c.messages.last_assistant_message_id());
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:192 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:192");
__id
},
).await {
@@ -260,8 +258,7 @@ pub(crate) async fn process_tool_calls(
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);
emit_ai_chat_event(&app_handle, ev);
(idx, draft, args)
})
.collect();
@@ -283,18 +280,14 @@ pub(crate) async fn process_tool_calls(
let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_payload));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:268 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:268");
}
let ev = AiChatEvent::AiToolCallCompleted {
id: draft.id.clone(),
result: serde_json::Value::String(err_payload.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);
emit_ai_chat_event(&app_handle, ev);
let risk = 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,
"failed", risk, Some(err_payload.clone()), Some("auto_resolve_fail"), current_message_id).await;
@@ -306,16 +299,12 @@ pub(crate) async fn process_tool_calls(
// 自动执行范围三档读取(前置到路径授权预校验之前:两处门控共用)。
// KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low;
// low 仅 Low 自动(等价旧行为),medium Low+Medium,all 全自动无审批(完全 AI 接管)。
// get_string_norm 归一去引号(前端 JSON.stringify 落库带引号),不归一则 medium/all 档静默失效全走审批。
let auto_exec_mode = app_handle
.state::<crate::state::AppState>()
.settings
.get("df-ai-auto-execute-mode")
.get_string_norm("df-ai-auto-execute-mode")
.await
.ok()
.flatten()
// 前端 appSettings.set 以 JSON.stringify 落库(字符串值带引号,"all" != all),
// 不归一则 medium/all 档静默失效全走审批(同 prompt.rs custom_prompt 的 trim 先例)。
.map(|v| v.trim_matches('"').to_string())
.unwrap_or_else(|| "low".to_string());
// ── 文件工具路径授权预校验 ──
@@ -334,16 +323,12 @@ pub(crate) async fn process_tool_calls(
// 「信任所有目录」总开关(KV df-ai-path-auth-bypass,默认关):开启后白名单外路径不再挂起
// 授权弹窗(NeedsAuth 时直接放行),黑名单拦截也一并放行(产品拍板:体验优先,含敏感目录)。
// 读取失败按关处理(fail-safe,默认行为不变);前端设置页改后下一轮 process_tool_calls 即生效。
// get_bool_norm 归一比对(前端 JSON.stringify 落库带引号),默认 false 由调用方显式传参。
let path_auth_bypass = app_handle
.state::<crate::state::AppState>()
.settings
.get(PATH_AUTH_BYPASS_KEY)
.await
.ok()
.flatten()
// 前端 appSettings.set 以 JSON.stringify 落库(带引号),归一后比对(同 df-ai-trace-approve 先例)
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
.unwrap_or(false);
.get_bool_norm(PATH_AUTH_BYPASS_KEY, false)
.await;
// 同步进程级黑名单放行标志:validate_path(tool_registry)无 bypass 参数,靠此标志放行黑名单;
// 每轮工具执行前刷新,与下方 check_file_tool_auth 的入参 bypass 同源同值,两处拦截点语义一致。
path_auth::set_blacklist_bypass(path_auth_bypass);
@@ -385,10 +370,7 @@ pub(crate) async fn process_tool_calls(
let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:262 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:262");
}
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
let ev = AiChatEvent::AiToolCallCompleted {
@@ -396,8 +378,7 @@ pub(crate) async fn process_tool_calls(
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);
emit_ai_chat_event(&app_handle, 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(
@@ -433,18 +414,14 @@ pub(crate) async fn process_tool_calls(
let __lock_t = std::time::Instant::now();
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:305 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:305");
}
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);
emit_ai_chat_event(&app_handle, 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,
@@ -473,10 +450,7 @@ pub(crate) async fn process_tool_calls(
);
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
let __hold = __lock_t.elapsed();
if __hold > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] process_tool_calls:325 持锁 {:?} (含 lock 等待)", __hold);
}
log_slow_lock(__lock_t, "process_tool_calls:325");
}
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
let ev = AiChatEvent::AiDirAuthRequired {
@@ -486,8 +460,7 @@ pub(crate) async fn process_tool_calls(
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);
emit_ai_chat_event(&app_handle, 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,
@@ -507,16 +480,12 @@ pub(crate) async fn process_tool_calls(
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
// F-#97 诊断:用户报「选 all 仍审批」时辅助定位 KV 是否真落库。
// df-ai-trace-approve 开关控制(默认开)。常见误以为已生效但实际未点确认按钮致 KV 未落。
// df-ai-trace-approve 开关控制(默认开,get_bool_norm 默认值调用方传参)。常见误以为已生效但实际未点确认按钮致 KV 未落。
let trace_on = app_handle
.state::<crate::state::AppState>()
.settings
.get("df-ai-trace-approve")
.await
.ok()
.flatten()
.map(|v| { let v = v.trim_matches('"'); v == "true" || v == "1" })
.unwrap_or(true);
.get_bool_norm("df-ai-trace-approve", true)
.await;
if trace_on {
tracing::info!(
conv_id = %conv_id,
@@ -570,8 +539,7 @@ pub(crate) async fn process_tool_calls(
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);
emit_ai_chat_event(&app_clone, ev);
// 数据变更类工具执行成功后 emit df-data-changed 联动刷新
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
emit_data_changed(&app_clone, &draft.name);
@@ -585,8 +553,7 @@ pub(crate) async fn process_tool_calls(
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);
emit_ai_chat_event(&app_clone, ev);
(draft, risk_level, Err(content))
}
}
@@ -668,8 +635,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(warned_content.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);
emit_ai_chat_event(&app_handle, ev);
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
@@ -709,8 +675,7 @@ pub(crate) async fn process_tool_calls(
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);
emit_ai_chat_event(&app_clone, ev);
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
emit_data_changed(&app_clone, &draft.name);
@@ -729,8 +694,7 @@ pub(crate) async fn process_tool_calls(
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);
emit_ai_chat_event(&app_clone, ev);
(draft, serde_json::Value::Null, risk_level, Err(err_msg))
}
}
@@ -831,8 +795,7 @@ pub(crate) async fn process_tool_calls(
result: serde_json::Value::String(warned_content.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);
emit_ai_chat_event(&app_handle, ev);
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
+8 -22
View File
@@ -40,28 +40,13 @@ pub(crate) static BLACKLIST_BYPASS_TEST_LOCK: std::sync::Mutex<()> = std::sync::
/// 提取文件工具的路径参数(用于路径授权预校验)。
///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
/// 取 args["path"]
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
/// 路径参数键查注册元数据(path_args 维度,DRY 债 #5 收敛):单路径工具(read_file/
/// write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
/// 声明 ["path"],rename_file 声明 ["from","to"](两个都需授权),其余工具默认空(不预校验)。
/// run_command 不声明(只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)。
pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
match tool_name {
"rename_file" => {
let mut v = Vec::new();
if let Some(from) = args.get("from").and_then(|x| x.as_str()) {
v.push(from.to_string());
}
if let Some(to) = args.get("to").and_then(|x| x.as_str()) {
v.push(to.to_string());
}
v
}
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
| "append_file" | "delete_file" | "search_files" | "grep" => {
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
}
_ => Vec::new(),
}
crate::commands::ai::tool_registry::ensure_tool_meta_seeded();
df_ai::ai_tools::extract_paths(df_ai::ai_tools::path_arg_keys(tool_name), args)
}
/// 路径授权预校验结果。
@@ -124,10 +109,11 @@ pub(crate) fn check_file_tool_auth(
}
if pending_dirs.is_empty() {
FileToolAuthOutcome::Authorized
} else if tool_name == "search_files" {
} else if df_ai::ai_tools::denies_unauthorized(tool_name) {
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
// 拒绝名单查注册元数据(deny_unauthorized 维度,DRY 债 #5 收敛,现仅 search_files 声明);
// 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。
FileToolAuthOutcome::Denied(
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),