重构: 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:
22
Batch.md
22
Batch.md
@@ -422,12 +422,26 @@
|
|||||||
- build_prompt 注入上下文和目标钉扎
|
- build_prompt 注入上下文和目标钉扎
|
||||||
- **验证**: 10 个单元测试通过
|
- **验证**: 10 个单元测试通过
|
||||||
|
|
||||||
|
## Batch 36 — Coordinator 接入 + audit 拆分
|
||||||
|
|
||||||
|
- **提交**: `当前待提交`
|
||||||
|
- **内容**:
|
||||||
|
- audit/mod.rs 拆分出 approval/record 子模块(process_tool_calls 从 ~500→~200 行)
|
||||||
|
- coordinator.rs 移除 deprecated 标记,正式可用
|
||||||
|
- Coordinator 接入 run_agentic_loop 入口(plan_execution_enabled 时分解意图→输出 Plan)
|
||||||
|
- **验证**: cargo check 通过
|
||||||
|
|
||||||
## 后续规划批次(待推进)
|
## 后续规划批次(待推进)
|
||||||
|
|
||||||
### Batch 36 — Coordinator 填实 + 多 Agent 骨架(P0·第二步)
|
### Batch 37 — 多 Agent 并行执行
|
||||||
1. 任务拆解 → 分配人设 → 并行 Agent → 汇总合并
|
1. PlanExecutor JoinSet 真并行:process_tool_calls 内并行执行同层工具
|
||||||
2. coordinator.rs 从空壳重写为 Agent 调度器
|
2. 子 Agent 调用链路:SubTask → 独立 agentic loop → 产出 + 错误传播
|
||||||
3. Planner 走完 agentic loop 全链路(feature flag 门控)
|
3. DAG 进度实时更新:PlanProgress.vue 接入真实状态
|
||||||
|
|
||||||
|
### Batch 38 — 仲裁合并 + 技术债
|
||||||
|
1. Reviewer 仲裁合并:并行 Agent 产出冲突检测 + 合并建议
|
||||||
|
2. 交互式审批:冲突 diff 展示供用户裁决
|
||||||
|
3. 扫尾技术债:SMELL-P1-6 / 编译警告 / 注释清理
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
514
crates/df-ai/src/coordinator.rs
Normal file
514
crates/df-ai/src/coordinator.rs
Normal file
@@ -0,0 +1,514 @@
|
|||||||
|
//! 多 Agent 协作调度中心 — Coordinator
|
||||||
|
//!
|
||||||
|
//! Phase 1 实现,Phase 2 LLM 扩展
|
||||||
|
//!
|
||||||
|
//! 职责拆解(4 步):
|
||||||
|
//! 1. **decompose**:接收用户意图 + 消息 → 拆解为 SubTask 列表
|
||||||
|
//! 2. **分配**:为每个 SubTask 分配人设(PersonaRegistry.recommend_for_intent)
|
||||||
|
//! 3. **dispatch**:并行执行 SubTask(当前 Phase 1 串行,预留 JoinSet 并行)
|
||||||
|
//! 4. **merge**:汇总子结果 → 合并产出 → 处理冲突
|
||||||
|
|
||||||
|
use crate::persona::PersonaRegistry;
|
||||||
|
use crate::planner::{Plan, SubTask};
|
||||||
|
|
||||||
|
// ---- 枚举 --------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 调度策略枚举
|
||||||
|
pub enum DispatchStrategy {
|
||||||
|
/// 规则驱动(关键词匹配,Phase 1 MVP)
|
||||||
|
RuleBased,
|
||||||
|
/// LLM 驱动(LLM 生成 Plan,Phase 2 预留)
|
||||||
|
LLMDriven,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DispatchStrategy {
|
||||||
|
/// 策略名称标签(日志 / 审计用)
|
||||||
|
pub fn name(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::RuleBased => "rule_based",
|
||||||
|
Self::LLMDriven => "llm_driven",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 数据结构 ----------------------------------------------------------------
|
||||||
|
|
||||||
|
/// 拆解结果
|
||||||
|
pub struct DecompositionResult {
|
||||||
|
/// 拆解出的子任务列表
|
||||||
|
pub subtasks: Vec<SubTask>,
|
||||||
|
/// 对应 Plan 结构(含依赖关系)
|
||||||
|
pub plan: Plan,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 执行结果
|
||||||
|
pub struct ExecutionResult {
|
||||||
|
/// 对应 SubTask id
|
||||||
|
pub subtask_id: String,
|
||||||
|
/// 分配的人设 id
|
||||||
|
pub persona_id: String,
|
||||||
|
/// 执行产出(工具结果摘要 / LLM 回复)
|
||||||
|
pub output: String,
|
||||||
|
/// 执行是否成功
|
||||||
|
pub success: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并结果
|
||||||
|
pub struct MergeResult {
|
||||||
|
/// 合并后的最终产出
|
||||||
|
pub merged_output: String,
|
||||||
|
/// 检测到的冲突列表(Phase 2 由 reviewer Agent 填充)
|
||||||
|
pub conflicts: Vec<ConflictItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冲突项
|
||||||
|
pub struct ConflictItem {
|
||||||
|
/// 冲突涉及的文件
|
||||||
|
pub file: String,
|
||||||
|
/// 冲突描述
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Coordinator 实现 --------------------------------------------------------
|
||||||
|
|
||||||
|
/// 多 Agent 协作调度器
|
||||||
|
///
|
||||||
|
/// # Phase 1(当前)
|
||||||
|
///
|
||||||
|
/// 规则驱动 decompose + 串行 dispatch + 简单拼接 merge。
|
||||||
|
/// 适用于「读后写」「搜索后分析」等可预测的意图组合。
|
||||||
|
///
|
||||||
|
/// # Phase 2(预留)
|
||||||
|
///
|
||||||
|
/// - LLM 驱动 decompose(LLM 生成 Plan 结构)
|
||||||
|
/// - JoinSet 并行 dispatch(层内并行)
|
||||||
|
/// - Reviewer Agent 仲裁 merge(冲突检测 + 自动解决)
|
||||||
|
pub struct Coordinator {
|
||||||
|
/// 人设注册表(内置 5 人设 + 自定义)
|
||||||
|
registry: PersonaRegistry,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Coordinator {
|
||||||
|
/// 创建 Coordinator,绑定人设注册表
|
||||||
|
pub fn new(registry: PersonaRegistry) -> Self {
|
||||||
|
Self { registry }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 规则拆解:intent + text → SubTask 列表 + Plan
|
||||||
|
///
|
||||||
|
/// Phase 1 规则(关键词匹配):
|
||||||
|
/// - 检测 text 中是否含 `read`/`写`/`查看`/`分析`/`search` 等关键词 → 读子任务
|
||||||
|
/// - 检测 text 中是否含 `write`/`修改`/`生成`/`create`/`fix` 等关键词 → 写子任务
|
||||||
|
/// - 同时含读+写关键词 → 两个子任务,写依赖读(read → write)
|
||||||
|
/// - 每 SubTask 分配人设(`PersonaRegistry::recommend_for_intent`)
|
||||||
|
/// - 兜底:无法匹配任何关键词时产单个 default 子任务
|
||||||
|
///
|
||||||
|
/// ## Phase 2 扩展点
|
||||||
|
/// `DispatchStrategy::LLMDriven` 时改为调 LLM 生成 Plan 整体结构,
|
||||||
|
/// 本方法当前仅走 `RuleBased` 路径。
|
||||||
|
pub fn decompose(&self, intent: &str, text: &str) -> DecompositionResult {
|
||||||
|
let lower = text.to_lowercase();
|
||||||
|
|
||||||
|
// 规则 1: 读操作关键词
|
||||||
|
let has_read = lower.contains("read")
|
||||||
|
|| lower.contains("查看")
|
||||||
|
|| lower.contains("读取")
|
||||||
|
|| lower.contains("分析")
|
||||||
|
|| lower.contains("search")
|
||||||
|
|| lower.contains("搜索")
|
||||||
|
|| lower.contains("审查")
|
||||||
|
|| lower.contains("review");
|
||||||
|
|
||||||
|
// 规则 2: 写操作关键词
|
||||||
|
let has_write = lower.contains("write")
|
||||||
|
|| lower.contains("写")
|
||||||
|
|| lower.contains("修改")
|
||||||
|
|| lower.contains("生成")
|
||||||
|
|| lower.contains("创建")
|
||||||
|
|| lower.contains("implement")
|
||||||
|
|| lower.contains("fix")
|
||||||
|
|| lower.contains("修复")
|
||||||
|
|| lower.contains("新增");
|
||||||
|
|
||||||
|
let mut subtasks = Vec::new();
|
||||||
|
|
||||||
|
match (has_read, has_write) {
|
||||||
|
(true, true) => {
|
||||||
|
// 读 + 写 → 两个子任务:read → write
|
||||||
|
subtasks.push(
|
||||||
|
SubTask::new("read", "读取/分析现有代码")
|
||||||
|
.with_tools(vec![
|
||||||
|
"read_file".into(),
|
||||||
|
"search_files".into(),
|
||||||
|
"list_directory".into(),
|
||||||
|
])
|
||||||
|
.with_deps(vec![]),
|
||||||
|
);
|
||||||
|
subtasks.push(
|
||||||
|
SubTask::new("write", "修改/生成代码")
|
||||||
|
.with_tools(vec![
|
||||||
|
"write_file".into(),
|
||||||
|
"patch_file".into(),
|
||||||
|
"edit_file".into(),
|
||||||
|
])
|
||||||
|
.with_deps(vec!["read".into()]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(true, false) => {
|
||||||
|
// 纯读 → 单个读子任务
|
||||||
|
subtasks.push(
|
||||||
|
SubTask::new("read", "读取/分析代码")
|
||||||
|
.with_tools(vec![
|
||||||
|
"read_file".into(),
|
||||||
|
"search_files".into(),
|
||||||
|
"list_directory".into(),
|
||||||
|
])
|
||||||
|
.with_deps(vec![]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(false, true) => {
|
||||||
|
// 纯写 → 单个写子任务
|
||||||
|
subtasks.push(
|
||||||
|
SubTask::new("write", "修改/生成代码")
|
||||||
|
.with_tools(vec![
|
||||||
|
"write_file".into(),
|
||||||
|
"patch_file".into(),
|
||||||
|
"edit_file".into(),
|
||||||
|
])
|
||||||
|
.with_deps(vec![]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(false, false) => {
|
||||||
|
// 兜底:按 intent 分配单个默认子任务
|
||||||
|
subtasks.push(
|
||||||
|
SubTask::new("default", intent.to_string())
|
||||||
|
.with_tools(vec![])
|
||||||
|
.with_deps(vec![]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let plan = Plan::from_tasks(subtasks.clone());
|
||||||
|
DecompositionResult { subtasks, plan }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 分发执行:按 Plan 逐层执行 SubTask
|
||||||
|
///
|
||||||
|
/// Phase 1:串行执行(层内层间均串行),后续可改为 `JoinSet` 并行。
|
||||||
|
///
|
||||||
|
/// ## 参数
|
||||||
|
/// - `plan`: 待执行的 DAG Plan
|
||||||
|
/// - `executor`: 子任务执行函数,
|
||||||
|
/// 签名 `fn(SubTask, AgentPersona) -> Future<Output = ExecutionResult>`
|
||||||
|
/// 传入 owned SubTask 和 AgentPersona 副本,避免生命周期约束。
|
||||||
|
///
|
||||||
|
/// ## 返回值
|
||||||
|
/// 按 Plan 原始顺序排列的执行结果列表。
|
||||||
|
pub async fn dispatch<F, Fut>(&self, plan: &Plan, executor: F) -> Vec<ExecutionResult>
|
||||||
|
where
|
||||||
|
F: Fn(SubTask, crate::persona::AgentPersona) -> Fut,
|
||||||
|
Fut: std::future::Future<Output = ExecutionResult>,
|
||||||
|
{
|
||||||
|
let mut results = Vec::new();
|
||||||
|
|
||||||
|
// Phase 1:串行逐任务执行(忽略 to_layers 分层,纯顺序执行)
|
||||||
|
for task in &plan.tasks {
|
||||||
|
let persona = self.registry.recommend_for_intent(&task.intent);
|
||||||
|
let result = executor(task.clone(), persona.clone()).await;
|
||||||
|
results.push(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合并:汇总子结果 → 合并产出
|
||||||
|
///
|
||||||
|
/// Phase 1:简单拼接各 SubTask 产出,每段带标题标识来源。
|
||||||
|
/// `conflicts` 返回空 vec(Phase 2 由 reviewer Agent 仲裁填充)。
|
||||||
|
pub fn merge(&self, results: &[ExecutionResult]) -> MergeResult {
|
||||||
|
let parts: Vec<String> = results
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
format!(
|
||||||
|
"## SubTask: {} (Persona: {})\n\n{}",
|
||||||
|
r.subtask_id, r.persona_id, r.output
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
MergeResult {
|
||||||
|
merged_output: parts.join("\n\n---\n\n"),
|
||||||
|
conflicts: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 单元测试 ---------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::persona::{PersonaRegistry, PERSONA_CODER};
|
||||||
|
|
||||||
|
fn make_coord() -> Coordinator {
|
||||||
|
Coordinator::new(PersonaRegistry::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- decompose 路径覆盖 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_read_then_write_chinese() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("modify", "请读取当前代码,然后修改它");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 2, "读+写应拆为两个子任务");
|
||||||
|
assert_eq!(result.subtasks[0].id, "read", "第一个子任务应为读");
|
||||||
|
assert_eq!(result.subtasks[1].id, "write", "第二个子任务应为写");
|
||||||
|
assert!(
|
||||||
|
result.subtasks[1].deps.contains(&"read".to_string()),
|
||||||
|
"写子任务应依赖读子任务"
|
||||||
|
);
|
||||||
|
assert_eq!(result.plan.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_read_then_write_english() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("modify", "read the file and then write changes");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 2);
|
||||||
|
assert_eq!(result.subtasks[0].id, "read");
|
||||||
|
assert_eq!(result.subtasks[1].id, "write");
|
||||||
|
assert!(result.subtasks[1].deps.contains(&"read".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_read_only_chinese() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("review", "帮我查看这段代码有什么问题");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 1);
|
||||||
|
assert_eq!(result.subtasks[0].id, "read");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_read_only_search() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("search", "搜索项目中的 TODO 注释");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 1);
|
||||||
|
assert_eq!(result.subtasks[0].id, "read");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_write_only_generate() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("implement", "请生成一个 Rust 模块");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 1);
|
||||||
|
assert_eq!(result.subtasks[0].id, "write");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_write_only_fix() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("fix", "修复这个 bug");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 1);
|
||||||
|
assert_eq!(result.subtasks[0].id, "write");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_fallback_default() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("chat", "你好,今天天气怎么样");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 1);
|
||||||
|
assert_eq!(result.subtasks[0].id, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_create_keyword() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("create", "创建新的 API 端点");
|
||||||
|
|
||||||
|
assert_eq!(result.subtasks.len(), 1);
|
||||||
|
assert_eq!(result.subtasks[0].id, "write");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- dispatch --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_serial_execution() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let plan = Plan::from_tasks(vec![
|
||||||
|
SubTask::new("read", "read code").with_deps(vec![]),
|
||||||
|
SubTask::new("write", "write code").with_deps(vec!["read".into()]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
|
||||||
|
async move {
|
||||||
|
ExecutionResult {
|
||||||
|
subtask_id: task.id,
|
||||||
|
persona_id: persona.id,
|
||||||
|
output: format!("执行 {} 完毕", task.intent),
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert_eq!(results.len(), 2);
|
||||||
|
assert!(results[0].success);
|
||||||
|
assert!(results[1].success);
|
||||||
|
assert_eq!(results[0].subtask_id, "read");
|
||||||
|
assert_eq!(results[1].subtask_id, "write");
|
||||||
|
assert_eq!(results[0].persona_id, PERSONA_CODER);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_empty_plan() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let plan = Plan::new();
|
||||||
|
|
||||||
|
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
|
||||||
|
async move {
|
||||||
|
ExecutionResult {
|
||||||
|
subtask_id: task.id,
|
||||||
|
persona_id: persona.id,
|
||||||
|
output: String::new(),
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
assert!(results.is_empty(), "空 Plan 应产生空结果");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_persona_assigned_by_intent() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let plan = Plan::from_tasks(vec![
|
||||||
|
SubTask::new("code", "implement feature").with_deps(vec![]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
|
||||||
|
async move {
|
||||||
|
ExecutionResult {
|
||||||
|
subtask_id: task.id,
|
||||||
|
persona_id: persona.id,
|
||||||
|
output: String::new(),
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// "implement" 不触发特殊人设 → 默认 coder
|
||||||
|
assert_eq!(results[0].persona_id, PERSONA_CODER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- merge --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_single_result() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let results = vec![ExecutionResult {
|
||||||
|
subtask_id: "read".into(),
|
||||||
|
persona_id: "reviewer".into(),
|
||||||
|
output: "分析完成,发现 3 个问题".into(),
|
||||||
|
success: true,
|
||||||
|
}];
|
||||||
|
|
||||||
|
let merged = coord.merge(&results);
|
||||||
|
assert!(merged.merged_output.contains("分析完成"));
|
||||||
|
assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_multiple_results() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let results = vec![
|
||||||
|
ExecutionResult {
|
||||||
|
subtask_id: "read".into(),
|
||||||
|
persona_id: "reviewer".into(),
|
||||||
|
output: "代码分析结果".into(),
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
ExecutionResult {
|
||||||
|
subtask_id: "write".into(),
|
||||||
|
persona_id: "coder".into(),
|
||||||
|
output: "修改后的代码".into(),
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let merged = coord.merge(&results);
|
||||||
|
assert!(merged.merged_output.contains("代码分析结果"));
|
||||||
|
assert!(merged.merged_output.contains("修改后的代码"));
|
||||||
|
assert!(merged.merged_output.contains("SubTask: read"));
|
||||||
|
assert!(merged.merged_output.contains("SubTask: write"));
|
||||||
|
assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn merge_empty_results() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let merged = coord.merge(&[]);
|
||||||
|
assert!(merged.merged_output.is_empty(), "空输入应产生空输出");
|
||||||
|
assert!(merged.conflicts.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- DispatchStrategy --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dispatch_strategy_name() {
|
||||||
|
assert_eq!(DispatchStrategy::RuleBased.name(), "rule_based");
|
||||||
|
assert_eq!(DispatchStrategy::LLMDriven.name(), "llm_driven");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Edge cases: 关键词混合边界 --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_review_triggers_read() {
|
||||||
|
let coord = make_coord();
|
||||||
|
// "review" 同时触发读关键词
|
||||||
|
let result = coord.decompose("review", "review this PR for me");
|
||||||
|
assert_eq!(result.subtasks.len(), 1);
|
||||||
|
assert_eq!(result.subtasks[0].id, "read");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_create_and_review_triggers_both() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("implement", "请先分析代码结构然后创建新模块");
|
||||||
|
assert_eq!(
|
||||||
|
result.subtasks.len(),
|
||||||
|
2,
|
||||||
|
"含分析和创建关键词应拆为两个子任务"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_plan_not_empty() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("chat", "随便聊聊");
|
||||||
|
assert!(!result.plan.is_empty(), "兜底场景也应有 Plan");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decompose_result_holds_plan_consistent() {
|
||||||
|
let coord = make_coord();
|
||||||
|
let result = coord.decompose("modify", "读取配置文件然后更新它");
|
||||||
|
assert_eq!(
|
||||||
|
result.subtasks.len(),
|
||||||
|
result.plan.len(),
|
||||||
|
"DecompositionResult 的 subtasks 应与 plan 一致"
|
||||||
|
);
|
||||||
|
for st in &result.subtasks {
|
||||||
|
assert!(
|
||||||
|
result.plan.tasks.iter().any(|t| t.id == st.id),
|
||||||
|
"plan 应包含所有 subtask: {}",
|
||||||
|
st.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@ pub mod anthropic_compat;
|
|||||||
pub mod anthropic_helpers;
|
pub mod anthropic_helpers;
|
||||||
pub mod context;
|
pub mod context;
|
||||||
pub mod context_helpers;
|
pub mod context_helpers;
|
||||||
// coordinator 模块已删除(B 路线空壳占位,零调用)。
|
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||||
// 多 Agent 协作未来从 planner.rs 的 Plan DAG 执行器重新实现。
|
pub mod coordinator;
|
||||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||||
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
||||||
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ use df_ai::context_helpers::{
|
|||||||
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
||||||
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
||||||
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
|
use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer};
|
||||||
|
use df_ai::coordinator::Coordinator;
|
||||||
|
use df_ai::persona::PersonaRegistry;
|
||||||
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole};
|
||||||
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类)
|
||||||
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。
|
||||||
@@ -836,6 +838,23 @@ pub(crate) async fn run_agentic_loop(
|
|||||||
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
|
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
|
||||||
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
||||||
|
|
||||||
|
// ── P0 Coordinator 分解(plan_execution_enabled 时) ──
|
||||||
|
let coordinator_plan =
|
||||||
|
if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD {
|
||||||
|
let coord = Coordinator::new(PersonaRegistry::new());
|
||||||
|
let result = coord.decompose(intent.as_str(), &user_text);
|
||||||
|
tracing::info!(
|
||||||
|
conv_id = %conv_id,
|
||||||
|
intent = intent.as_str(),
|
||||||
|
subtask_count = result.subtasks.len(),
|
||||||
|
has_plan = !result.plan.is_empty(),
|
||||||
|
"[COORDINATOR] 意图分解完成"
|
||||||
|
);
|
||||||
|
Some(result.plan)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
|
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
|
||||||
let pinned_goals_snapshot: Vec<String> = {
|
let pinned_goals_snapshot: Vec<String> = {
|
||||||
let session = session_arc.lock().await;
|
let session = session_arc.lock().await;
|
||||||
|
|||||||
280
src-tauri/src/commands/ai/audit/approval.rs
Normal file
280
src-tauri/src/commands/ai/audit/approval.rs
Normal 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 自动(等价旧行为)
|
||||||
|
/// - medium:Low+Medium 自动
|
||||||
|
/// - all:全自动无审批(完全 AI 接管)
|
||||||
|
///
|
||||||
|
/// C-260627:patch_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-260627:patch_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 的 IPC,CR-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/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"), 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;
|
||||||
|
}
|
||||||
@@ -1,68 +1,12 @@
|
|||||||
//! 工具调用审计记录写/回填(audit_tool_call 写入 + audit_finalize 审批后状态回填)
|
//! 审批后审计记录状态回填(audit_finalize)
|
||||||
//!
|
//!
|
||||||
//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs
|
//! `audit_tool_call` 已移至 record.rs(审计记录写入操作归 audit record 子模块)。
|
||||||
//! `pub use finalize::{audit_tool_call, audit_finalize};` 保持 `commands::ai::audit::*`
|
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
|
||||||
//! 路径透明(调用方零变更)。
|
//! `commands::ai::audit::*` 路径透明(调用方零变更)。
|
||||||
|
|
||||||
use df_ai::ai_tools::RiskLevel;
|
|
||||||
use df_storage::crud::AiToolExecutionRepo;
|
|
||||||
use df_storage::models::AiToolExecutionRecord;
|
|
||||||
|
|
||||||
use df_types::types::new_id;
|
|
||||||
|
|
||||||
use crate::commands::now_millis;
|
use crate::commands::now_millis;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
|
|
||||||
use super::risk_str;
|
|
||||||
|
|
||||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
|
||||||
///
|
|
||||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
|
||||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
|
||||||
pub(crate) async fn audit_tool_call(
|
|
||||||
repo: &AiToolExecutionRepo,
|
|
||||||
conv_id: &str,
|
|
||||||
tool_call_id: &str,
|
|
||||||
tool_name: &str,
|
|
||||||
arguments: &str,
|
|
||||||
status: &str,
|
|
||||||
risk_level: RiskLevel,
|
|
||||||
result: Option<String>,
|
|
||||||
decided_by: Option<&str>,
|
|
||||||
message_id: Option<&str>,
|
|
||||||
) {
|
|
||||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
|
||||||
if let Err(e) = repo
|
|
||||||
.insert(AiToolExecutionRecord {
|
|
||||||
id: new_id(),
|
|
||||||
conversation_id: Some(conv_id.to_string()),
|
|
||||||
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
|
||||||
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
|
||||||
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
|
||||||
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
|
||||||
message_id: message_id.map(|s| s.to_string()),
|
|
||||||
tool_call_id: tool_call_id.to_string(),
|
|
||||||
tool_name: tool_name.to_string(),
|
|
||||||
arguments: arguments.to_string(),
|
|
||||||
result,
|
|
||||||
status: status.to_string(),
|
|
||||||
risk_level: risk_str(risk_level).to_string(),
|
|
||||||
requested_at: now_millis(),
|
|
||||||
executed_at,
|
|
||||||
decided_by: decided_by.map(|s| s.to_string()),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::error!(
|
|
||||||
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
|
||||||
conv_id,
|
|
||||||
tool_call_id,
|
|
||||||
tool_name,
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
||||||
///
|
///
|
||||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||||
|
|||||||
@@ -3,259 +3,94 @@
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use serde::Serialize;
|
use tauri::{AppHandle, Emitter, Manager};
|
||||||
use tauri::{AppHandle, Emitter, Manager, State};
|
|
||||||
|
|
||||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||||
use df_ai::provider::ChatMessage;
|
use df_ai::provider::ChatMessage;
|
||||||
use df_storage::crud::AiToolExecutionRepo;
|
use df_storage::crud::AiToolExecutionRepo;
|
||||||
use df_storage::db::Database;
|
use df_storage::db::Database;
|
||||||
|
|
||||||
use crate::state::AppState;
|
|
||||||
|
|
||||||
use crate::commands::err_str;
|
|
||||||
|
|
||||||
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApproval, ToolCallDraft};
|
||||||
// tool_registry::generate_diff 经 audit/diff.rs 直接 use(build_write_file_diff 内调用),
|
|
||||||
// 本文件不再裸名用 generate_diff,故不再在此 use(避免 unused import warning)。
|
|
||||||
|
|
||||||
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
// utils(audit/utils.rs):RiskLevel ↔ 字符串转换 + 字符串安全截断纯 helper。
|
||||||
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
// 第五批从本文件抽离,行为零变更。pub(crate) use 保持 finalize / restore 子模块
|
||||||
// super::risk_str / super::risk_from_str 引用路径不变;truncate_chars 仅供本文件 DTO 装配裸名用。
|
// super::risk_str / super::risk_from_str 引用路径不变;truncate_chars 供本文件 process_tool_calls
|
||||||
|
// DTO 装配 + record.rs 审计面板 IPC 共用。
|
||||||
mod utils;
|
mod utils;
|
||||||
pub(crate) use utils::{risk_from_str, risk_str};
|
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars};
|
||||||
use utils::truncate_chars;
|
|
||||||
|
|
||||||
// diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。
|
// diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。
|
||||||
// 第五批从本文件抽离,行为零变更。use 保持本文件 process_tool_calls 裸名调用 build_write_file_diff。
|
// 第五批从本文件抽离,行为零变更。
|
||||||
// generate_diff 经 `use super::generate_diff` 引入供 diff.rs 复用。
|
|
||||||
mod diff;
|
mod diff;
|
||||||
use diff::build_write_file_diff;
|
|
||||||
|
|
||||||
// ============================================================
|
// path_auth(audit/path_auth.rs):F-260619-03 Phase B/C 路径授权预校验。
|
||||||
// 审批历史查询 IPC(AE-2025-08)
|
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
|
||||||
// ============================================================
|
mod path_auth;
|
||||||
|
pub(super) use path_auth::{check_file_tool_auth, extract_file_tool_paths, FileToolAuthOutcome};
|
||||||
|
|
||||||
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
|
// approval(audit/approval.rs):F-#97/AE-04/阶段4 审批门控逻辑。
|
||||||
///
|
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
|
||||||
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
|
mod approval;
|
||||||
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
|
use approval::{detect_retry_count, handle_approval_tool};
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct ToolExecutionDto {
|
|
||||||
pub id: String,
|
|
||||||
pub conversation_id: Option<String>,
|
|
||||||
pub tool_call_id: String,
|
|
||||||
pub tool_name: String,
|
|
||||||
/// 参数摘要(截断 120 字符,完整原值仍留库)
|
|
||||||
pub arguments_brief: String,
|
|
||||||
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
|
|
||||||
pub result_brief: Option<String>,
|
|
||||||
/// pending/approved/rejected/executing/completed/failed
|
|
||||||
pub status: String,
|
|
||||||
/// low/medium/high
|
|
||||||
pub risk_level: String,
|
|
||||||
pub requested_at: String,
|
|
||||||
pub executed_at: Option<String>,
|
|
||||||
/// human/auto,None 表示尚未决策
|
|
||||||
pub decided_by: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
// record(audit/record.rs):审计记录写入 + 历史 DTO + 审计面板查询 IPC。
|
||||||
///
|
// 第六批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
|
||||||
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
// approval.rs 裸名调用 audit_tool_call;pub use 保持 commands::ai::audit::* 路径透明
|
||||||
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
// (list_tool_executions 是 #[tauri::command],ToolExecutionDto 供前端 DTO 序列化)。
|
||||||
#[tauri::command]
|
pub mod record;
|
||||||
pub async fn list_tool_executions(
|
pub(crate) use record::{audit_tool_call, query_audit_history, record_audit};
|
||||||
state: State<'_, AppState>,
|
pub use record::{list_tool_executions, ToolExecutionDto};
|
||||||
limit: Option<u32>,
|
|
||||||
offset: Option<u32>,
|
|
||||||
) -> Result<Vec<ToolExecutionDto>, 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)
|
||||||
// reason 拼装(resolve_project_label / resolve_task_label / build_approval_reason)
|
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
||||||
// 拆至子模块 audit/reason.rs(第一批 helper 抽离,行为零变更)。
|
|
||||||
mod reason;
|
mod reason;
|
||||||
|
|
||||||
// process_tool_calls 调用 build_approval_reason,从子模块引入(super 可见,fn 为 pub(super))。
|
// build_approval_reason 现由 approval.rs(insert_pending_approval)内部调用,
|
||||||
use reason::build_approval_reason;
|
// 通过 `use super::reason::build_approval_reason` 引入,本文件不再直接使用。
|
||||||
|
|
||||||
// restore(audit/restore.rs):启动恢复重建 pending_approvals。
|
// restore(audit/restore.rs):启动恢复重建 pending_approvals。
|
||||||
// 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。
|
// 第二批从本文件抽离,行为零变更。re-export 保持 commands::ai::audit::* 路径透明。
|
||||||
mod restore;
|
mod restore;
|
||||||
pub use restore::restore_pending_approvals;
|
pub use restore::restore_pending_approvals;
|
||||||
|
|
||||||
// finalize(audit/finalize.rs):audit_tool_call 写入 + audit_finalize 审批后状态回填。
|
// finalize(audit/finalize.rs):audit_finalize 审批后状态回填。
|
||||||
// 第二批从本文件抽离,行为零变更。pub(crate) use 供本文件 process_tool_calls /
|
// audit_tool_call 已移至 record.rs(写入归 record 子模块)。
|
||||||
// find_cached_high_risk_result 裸名调用,且保持 commands::ai::audit::* 对 crate 内可见。
|
// 第二批从本文件抽离,行为零变更。pub(crate) use 供外部
|
||||||
|
//(agentic/approval_timeout.rs / commands/chat.rs)裸名调用 audit_finalize。
|
||||||
mod finalize;
|
mod finalize;
|
||||||
pub(crate) use finalize::{audit_finalize, audit_tool_call};
|
pub(crate) use finalize::audit_finalize;
|
||||||
|
|
||||||
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
|
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存。
|
||||||
// 第三批从本文件抽离,行为零变更。pub(super) use 供本文件 process_tool_calls 裸名调用
|
// 第三批从本文件抽离,行为零变更。
|
||||||
// (find_cached_high_risk_result)。阶段2:prior placeholder 现经 pending_placeholder_for
|
|
||||||
// (内嵌 __PENDING__:tc_id 标记)生成,PENDING_APPROVAL_PLACEHOLDER 基础常量留在 cache.rs 内部。
|
|
||||||
mod cache;
|
mod cache;
|
||||||
pub(super) use cache::{find_cached_high_risk_result, pending_placeholder_for};
|
pub(super) use cache::pending_placeholder_for;
|
||||||
|
|
||||||
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
// data_change(audit/data_change.rs):AR-11 数据变更联动刷新。
|
||||||
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
// 第四批从本文件抽离,行为零变更。pub(crate) use 保持 emit_data_changed 对 crate 内可见
|
||||||
// (chat.rs 通过 commands::ai::audit::emit_data_changed 引入并调用),data_change_for_tool 私有。
|
// (chat.rs 通过 commands::ai::audit::emit_data_changed 引入并调用),data_change_for_tool 私有。
|
||||||
mod data_change;
|
mod data_change;
|
||||||
pub(crate) use data_change::emit_data_changed;
|
pub(crate) use data_change::emit_data_changed;
|
||||||
|
|
||||||
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B) 灵感来源消息级溯源补全。
|
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B)灵感来源消息级溯源补全。
|
||||||
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
|
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
|
||||||
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
|
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
|
||||||
mod idea_source;
|
mod idea_source;
|
||||||
pub(crate) use idea_source::maybe_fill_idea_source;
|
pub(crate) use idea_source::maybe_fill_idea_source;
|
||||||
|
|
||||||
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
// ============================================================
|
||||||
///
|
// 工具调用心跳 + 审批处理
|
||||||
/// 仅对走 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 靠人工审批)
|
|
||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
|
||||||
///
|
|
||||||
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
|
|
||||||
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
|
||||||
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
|
||||||
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
|
||||||
enum FileToolAuthOutcome {
|
|
||||||
Authorized,
|
|
||||||
NeedsAuth(PathAuthRequest),
|
|
||||||
Denied(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
fn check_file_tool_auth(
|
|
||||||
tool_name: &str,
|
|
||||||
args: &serde_json::Value,
|
|
||||||
allowed: &crate::state::AllowedDirs,
|
|
||||||
) -> FileToolAuthOutcome {
|
|
||||||
use crate::state::{check_path_authorization, PathAuthDecision};
|
|
||||||
let paths = extract_file_tool_paths(tool_name, args);
|
|
||||||
if paths.is_empty() {
|
|
||||||
// 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err)
|
|
||||||
return FileToolAuthOutcome::Authorized;
|
|
||||||
}
|
|
||||||
// L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重),
|
|
||||||
// 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。
|
|
||||||
let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new();
|
|
||||||
for p in &paths {
|
|
||||||
match check_path_authorization(p, allowed) {
|
|
||||||
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
|
|
||||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
|
||||||
if !pending_dirs.contains(&dir) {
|
|
||||||
pending_dirs.push(dir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
PathAuthDecision::Authorized => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if pending_dirs.is_empty() {
|
|
||||||
FileToolAuthOutcome::Authorized
|
|
||||||
} else if tool_name == "search_files" {
|
|
||||||
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
|
|
||||||
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
|
|
||||||
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
|
|
||||||
// 其他文件工具(read_file/write_file/list_directory/patch_file/file_info/
|
|
||||||
// append_file/delete_file/rename_file)保持原 NeedsAuth 弹窗逻辑完全不变。
|
|
||||||
FileToolAuthOutcome::Denied(
|
|
||||||
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 阶段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,等价原行为(单次审批执行,无重试防护)。
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
/// 处理流式接收的工具调用:Low 风险并行执行(join_all),Med/High 进审批门控
|
||||||
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
/// 返回待审批的工具数量(0 = 全部自动执行完成)
|
||||||
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
|
||||||
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
|
||||||
///
|
///
|
||||||
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
|
||||||
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
|
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
|
||||||
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
|
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
|
||||||
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
|
||||||
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
|
/// 补工具执行阶段的心跳缺口,前端 watchdog 在静默期也能收到 reset,不再误杀长工具。
|
||||||
async fn execute_with_heartbeat(
|
async fn execute_with_heartbeat(
|
||||||
tools: &AiToolRegistry,
|
tools: &AiToolRegistry,
|
||||||
name: &str,
|
name: &str,
|
||||||
@@ -266,7 +101,7 @@ async fn execute_with_heartbeat(
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use tokio::time::Duration;
|
use tokio::time::Duration;
|
||||||
|
|
||||||
// RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。
|
// RAII guard:drop 时 stop+abort 心跳 task,确保 execute panic(unwind)也不泄漏心跳 task。
|
||||||
struct HeartbeatGuard {
|
struct HeartbeatGuard {
|
||||||
stop: Arc<AtomicBool>,
|
stop: Arc<AtomicBool>,
|
||||||
handle: tokio::task::JoinHandle<()>,
|
handle: tokio::task::JoinHandle<()>,
|
||||||
@@ -282,8 +117,8 @@ async fn execute_with_heartbeat(
|
|||||||
let app_h = app.clone();
|
let app_h = app.clone();
|
||||||
let conv = conv_id.to_string();
|
let conv = conv_id.to_string();
|
||||||
let stop_c = stop.clone();
|
let stop_c = stop.clone();
|
||||||
// 心跳 task:弃首 tick(tokio interval 首 tick 立即返回,对齐 stream_recv.rs:174),
|
// 心跳 task:弃首 tick(tokio interval 首 tick 立即返回,对齐 stream_recv.rs:174),
|
||||||
// 后每 30s emit AiHeartbeat(< 130s watchdog 确保覆盖)。
|
// 后每 30s emit AiHeartbeat(< 130s watchdog 确保覆盖)。
|
||||||
let heartbeat = tokio::spawn(async move {
|
let heartbeat = tokio::spawn(async move {
|
||||||
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
let mut interval = tokio::time::interval(Duration::from_secs(30));
|
||||||
interval.tick().await;
|
interval.tick().await;
|
||||||
@@ -297,7 +132,7 @@ async fn execute_with_heartbeat(
|
|||||||
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
let _ = app_h.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
|
||||||
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
let _guard = HeartbeatGuard { stop, handle: heartbeat };
|
||||||
tools.execute(name, args).await
|
tools.execute(name, args).await
|
||||||
}
|
}
|
||||||
@@ -312,21 +147,21 @@ pub(crate) async fn process_tool_calls(
|
|||||||
) -> usize {
|
) -> usize {
|
||||||
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
|
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
|
||||||
tc_list.sort_unstable_by_key(|(i, _)| *i);
|
tc_list.sort_unstable_by_key(|(i, _)| *i);
|
||||||
// B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
|
// B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
|
||||||
// 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡,
|
// 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡,
|
||||||
// Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留
|
// Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留
|
||||||
// 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall
|
// 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall
|
||||||
// 守卫双保险。详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md。
|
// 守卫双保险。详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md。
|
||||||
let mut seen_ids: HashSet<String> = HashSet::new();
|
let mut seen_ids: HashSet<String> = HashSet::new();
|
||||||
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
|
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
|
||||||
let mut pending_count = 0usize;
|
let mut pending_count = 0usize;
|
||||||
let audit_repo = AiToolExecutionRepo::new(db);
|
let audit_repo = AiToolExecutionRepo::new(db);
|
||||||
|
|
||||||
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
|
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
|
||||||
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
|
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
|
||||||
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
|
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
|
||||||
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
|
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
|
||||||
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
|
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
|
||||||
let current_message_id: Option<String> = session
|
let current_message_id: Option<String> = session
|
||||||
.conv_read(conv_id)
|
.conv_read(conv_id)
|
||||||
.and_then(|c| c.messages.last_assistant_message_id());
|
.and_then(|c| c.messages.last_assistant_message_id());
|
||||||
@@ -336,8 +171,8 @@ pub(crate) async fn process_tool_calls(
|
|||||||
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
|
let drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = tc_list.into_iter()
|
||||||
.map(|(idx, draft)| {
|
.map(|(idx, draft)| {
|
||||||
let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default()));
|
let args = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Object(Default::default()));
|
||||||
// L3 emit 双写:AiToolCallStarted publish 到事件总线(tunnel subscriber 透传 miniapp)。
|
// L3 emit 双写:AiToolCallStarted publish 到事件总线(tunnel subscriber 透传 miniapp)。
|
||||||
// AiTextDelta/AiToolCall* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。
|
// AiTextDelta/AiToolCall* 高频事件不双写原则不适用此处(工具调用生命周期事件属关键状态变更,需透传)。
|
||||||
let ev = AiChatEvent::AiToolCallStarted {
|
let ev = AiChatEvent::AiToolCallStarted {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
name: draft.name.clone(),
|
name: draft.name.clone(),
|
||||||
@@ -350,26 +185,26 @@ pub(crate) async fn process_tool_calls(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// ── F-260619-03 Phase B/C: 文件工具路径授权预校验 ──
|
// ── F-260619-03 Phase B/C:文件工具路径授权预校验 ──
|
||||||
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
|
||||||
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
|
||||||
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
|
||||||
// - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired),
|
// - 任一路径未命中白名单且非黑名单 → NeedsAuth:挂起 loop(insert pending + emit AiDirAuthRequired),
|
||||||
// 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B)
|
// 等用户经 ai_authorize_dir IPC 决定"仅本次/未来都允许/拒绝"后恢复(Phase B)
|
||||||
// - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程
|
// - 全部已授权 → 放行,进入下方 RiskLevel 分类走原 Low/Med/High 流程
|
||||||
// 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。
|
// 预校验通过的 drafts 留在原 Vec 继续走原流程;NeedsAuth/Denied 的 drafts 从 Vec 移除单独处理。
|
||||||
let allowed_snapshot = app_handle
|
let allowed_snapshot = app_handle
|
||||||
.state::<crate::state::AppState>()
|
.state::<crate::state::AppState>()
|
||||||
.allowed_dirs
|
.allowed_dirs
|
||||||
.clone();
|
.clone();
|
||||||
let allowed_guard = allowed_snapshot.read().await.clone();
|
let allowed_guard = allowed_snapshot.read().await.clone();
|
||||||
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
// 收集 NeedsAuth/Denied drafts(从 drafts 移除,不进下方 RiskLevel 循环)
|
||||||
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
let mut path_auth_pending: Vec<(ToolCallDraft, serde_json::Value, PathAuthRequest)> = Vec::new();
|
||||||
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
let mut path_denied: Vec<(ToolCallDraft, String)> = Vec::new();
|
||||||
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
let mut authorized_drafts: Vec<(u32, ToolCallDraft, serde_json::Value)> = Vec::new();
|
||||||
for (idx, draft, args) in drafts {
|
for (idx, draft, args) in drafts {
|
||||||
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
// 会话级临时授权目录在 allowed_guard.session 内(进程级,随 active 会话切换清空),
|
||||||
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
|
// handler 闭包 read lock 与此处预校验读同一字段,两端授权判定一致。
|
||||||
match check_file_tool_auth(&draft.name, &args, &allowed_guard) {
|
match check_file_tool_auth(&draft.name, &args, &allowed_guard) {
|
||||||
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
FileToolAuthOutcome::Authorized => authorized_drafts.push((idx, draft, args)),
|
||||||
FileToolAuthOutcome::NeedsAuth(req) => path_auth_pending.push((draft, args, req)),
|
FileToolAuthOutcome::NeedsAuth(req) => path_auth_pending.push((draft, args, req)),
|
||||||
@@ -378,12 +213,12 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
let drafts = authorized_drafts;
|
let drafts = authorized_drafts;
|
||||||
|
|
||||||
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
|
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
|
||||||
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
|
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
|
||||||
for (draft, reason) in path_denied {
|
for (draft, reason) in path_denied {
|
||||||
let err_msg = format!("路径授权拒绝: {}", reason);
|
let err_msg = format!("路径授权拒绝: {}", reason);
|
||||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &err_msg));
|
||||||
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
|
// L3 emit 双写:路径黑名单拒绝 emit Completed 双路发布。
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
result: serde_json::Value::String(err_msg.clone()),
|
result: serde_json::Value::String(err_msg.clone()),
|
||||||
@@ -391,7 +226,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
};
|
};
|
||||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
|
// 审计:路径黑名单拒绝(decided_by=auto_blacklist),留痕可追溯
|
||||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||||
audit_tool_call(
|
audit_tool_call(
|
||||||
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
&audit_repo, conv_id, &draft.id, &draft.name, &draft.args,
|
||||||
@@ -400,30 +235,25 @@ pub(crate) async fn process_tool_calls(
|
|||||||
).await;
|
).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
// Phase B: NeedsAuth 路径 → 挂起 loop(insert pending + emit AiDirAuthRequired + push 占位 tool_result)。
|
||||||
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
|
||||||
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
|
||||||
for (draft, args, req) in path_auth_pending {
|
for (draft, args, req) in path_auth_pending {
|
||||||
pending_count += 1;
|
pending_count += 1;
|
||||||
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
// L1 补丁:req.dirs 含所有未授权父目录;emit 用首个作主展示目录(前端弹窗主显)。
|
||||||
// ai_authorize_dir 消费时遍历所有 dirs 写白名单。
|
// ai_authorize_dir 消费时遍历所有 dirs 写白名单。
|
||||||
let dir_str = req.dirs.first()
|
let dir_str = req.dirs.first()
|
||||||
.map(|d| d.to_string_lossy().to_string())
|
.map(|d| d.to_string_lossy().to_string())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
|
||||||
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
|
||||||
// 同 tool_call_id 在审计表已有一条落定记录(executed/failed/rejected,即已审批执行过一次)
|
|
||||||
// → 推算 retry_count≥1,跳过 insert pending + emit Completed 带"已跳过重试"提示,
|
|
||||||
// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
|
|
||||||
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为(正常挂起审批)。
|
|
||||||
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
|
||||||
if retry_count >= 1 {
|
if retry_count >= 1 {
|
||||||
let skip_msg = format!(
|
let skip_msg = format!(
|
||||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||||
draft.id
|
draft.id
|
||||||
);
|
);
|
||||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||||
// L3 emit 双写:重试跳过 emit Completed 双路发布。
|
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
result: serde_json::Value::String(skip_msg.clone()),
|
result: serde_json::Value::String(skip_msg.clone()),
|
||||||
@@ -447,18 +277,15 @@ pub(crate) async fn process_tool_calls(
|
|||||||
arguments: args.clone(),
|
arguments: args.clone(),
|
||||||
conversation_id: Some(conv_id.to_string()),
|
conversation_id: Some(conv_id.to_string()),
|
||||||
recovered: false,
|
recovered: false,
|
||||||
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
|
||||||
kind: ApprovalKind::Path(req),
|
kind: ApprovalKind::Path(req),
|
||||||
retry_count,
|
retry_count,
|
||||||
created_at: Some(std::time::SystemTime::now()),
|
created_at: Some(std::time::SystemTime::now()),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
// 占位 tool_result(与 RiskLevel 审批一致),ai_authorize_dir 批准后替换为真实结果。
|
||||||
// 阶段2:用 pending_placeholder_for 内嵌 __PENDING__:tc_id 标记,供 sanitize 反向 orphan
|
|
||||||
// 检测豁免保留 + 出口断言自愈补头(防占位头被裁后 orphan result 触发 provider 400)。
|
|
||||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
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 恢复)");
|
tracing::debug!(target: "ai_dirauth", conv = %conv_id, tool = %draft.name, tc_id = %draft.id, "emit AiDirAuthRequired(路径授权挂起,等 ai_authorize_dir 恢复)");
|
||||||
// L3 emit 双写:路径授权挂起 AiDirAuthRequired 双路发布(tunnel 透传 miniapp 弹窗)。
|
|
||||||
let ev = AiChatEvent::AiDirAuthRequired {
|
let ev = AiChatEvent::AiDirAuthRequired {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
tool: draft.name.clone(),
|
tool: draft.name.clone(),
|
||||||
@@ -482,13 +309,12 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
|
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
|
||||||
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
|
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
|
||||||
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
|
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
|
||||||
// F-#97:low_risk 向量携带 risk_level(治 securityReview blocker :710 审计失真)。
|
// F-#97:low_risk 向量携带 risk_level(治 securityReview blocker:710 审计失真)。
|
||||||
// 原 Vec 仅 (draft,args),回填审计硬编码 RiskLevel::Low;#97 让 Med/High(all/medium 模式)
|
// 原 Vec 仅 (draft,args),回填审计硬编码 RiskLevel::Low;#97 让 Med/High(all/medium 模式)
|
||||||
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
|
// 也进此向量,需透传真实等级,审计方可追溯「all 模式执行了多少高危命令」。
|
||||||
let mut low_risk: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::new();
|
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(=现状)。
|
// F-#97 自动执行范围三档(2026-06-22):KV df-ai-auto-execute-mode 取值 low/medium/all,默认 low(=现状)。
|
||||||
// low:仅 Low 自动(等价旧行为);medium:Low+Medium 自动;all:全自动无审批(完全 AI 接管)。
|
// low:仅 Low 自动(等价旧行为);medium:Low+Medium 自动;all:全自动无审批(完全 AI 接管)。
|
||||||
// 锁内 await KV 快读,不竞争 session 锁(settings 独立 repo)。
|
|
||||||
let auto_exec_mode = app_handle
|
let auto_exec_mode = app_handle
|
||||||
.state::<crate::state::AppState>()
|
.state::<crate::state::AppState>()
|
||||||
.settings
|
.settings
|
||||||
@@ -498,158 +324,16 @@ pub(crate) async fn process_tool_calls(
|
|||||||
.flatten()
|
.flatten()
|
||||||
.unwrap_or_else(|| "low".to_string());
|
.unwrap_or_else(|| "low".to_string());
|
||||||
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
// trust_hits 收集:AE-04 会话信任命中(同会话已批准同类操作),真实执行但移到锁外 spawn
|
||||||
// (对齐 Low risk L673-720 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
// (对齐 Low risk 不持锁模式)。命中时此处只 emit toast + 收集,不 .await execute。
|
||||||
// 元组携带 risk_level: 回填审计需原等级(Med/High 才进此分支),避免闭包内再查 registry
|
|
||||||
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
let mut trust_hits: Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)> = Vec::new();
|
||||||
for (_, draft, args) in drafts {
|
for (_, draft, args) in drafts {
|
||||||
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
let risk_level = tools_arc.get(&draft.name).map(|t| t.risk_level).unwrap_or(RiskLevel::High);
|
||||||
// F-#97:按 auto_exec_mode 判定是否自动执行。should_auto=true 走 low_risk(自动),false 走原审批块。
|
handle_approval_tool(
|
||||||
// low(默认):Low→auto, Med/High→审批(等价现状)
|
draft, args, risk_level, &auto_exec_mode,
|
||||||
// medium:Low/Med→auto, High→审批
|
session, conv_id, tools_arc, &audit_repo,
|
||||||
// all:全 auto(完全接管,无审批)
|
app_handle, db, &mut low_risk, &mut trust_hits,
|
||||||
let mut should_auto = match risk_level {
|
&mut pending_count, current_message_id,
|
||||||
RiskLevel::Low => true,
|
).await;
|
||||||
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
|
||||||
RiskLevel::High => auto_exec_mode == "all",
|
|
||||||
};
|
|
||||||
// C-260627: patch_file 小改动(≤5 行)自动放行,不阻塞 AI 工作流
|
|
||||||
if !should_auto && draft.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if should_auto {
|
|
||||||
low_risk.push((draft, args, risk_level));
|
|
||||||
} else {
|
|
||||||
// 原 Medium|High 审批分支(trust_key 命中/dedup 缓存/retry guard/pending insert + emit + audit_tool_call)
|
|
||||||
// 整体保留,逻辑不变。trust_hits 元组携带 risk_level 语义不变:Med 进此处=Medium,
|
|
||||||
// High 进此处(仅 low/medium 模式)时=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 不阻塞
|
|
||||||
// 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 的 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));
|
|
||||||
// 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/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"), current_message_id).await;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pending_count += 1;
|
|
||||||
// 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;
|
|
||||||
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:普通 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"——
|
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,非"锁已释放"——
|
||||||
@@ -669,7 +353,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
match exec_result {
|
match exec_result {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
let content = val.to_string();
|
let content = val.to_string();
|
||||||
// L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。
|
// L3 emit 双写:trust 放行工具执行成功 emit Completed 双路发布。
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
result: serde_json::Value::String(content.clone()),
|
result: serde_json::Value::String(content.clone()),
|
||||||
@@ -678,13 +362,13 @@ pub(crate) async fn process_tool_calls(
|
|||||||
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
let _ = app_clone.emit("ai-chat-event", ev.clone());
|
||||||
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
let _ = app_clone.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||||
// AR-11:数据变更类工具执行成功后 emit df-data-changed 联动刷新
|
// AR-11:数据变更类工具执行成功后 emit df-data-changed 联动刷新
|
||||||
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
|
// (write_file/run_command 不在 data_change_for_tool 映射内,emit_data_changed 内部 None 即 noop)
|
||||||
emit_data_changed(&app_clone, &draft.name);
|
emit_data_changed(&app_clone, &draft.name);
|
||||||
(draft, risk_level, Ok(content))
|
(draft, risk_level, Ok(content))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let content = e.to_string();
|
let content = e.to_string();
|
||||||
// L3 emit 双写:trust 放行工具执行失败 emit Completed 双路发布。
|
// L3 emit 双写:trust 放行工具执行失败 emit Completed 双路发布。
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
result: serde_json::Value::String(content.clone()),
|
result: serde_json::Value::String(content.clone()),
|
||||||
@@ -704,9 +388,9 @@ pub(crate) async fn process_tool_calls(
|
|||||||
Ok(c) => ("completed", c),
|
Ok(c) => ("completed", c),
|
||||||
Err(c) => ("failed", c),
|
Err(c) => ("failed", c),
|
||||||
};
|
};
|
||||||
// F-260616-09 B 批2:写 per_conv.messages。
|
// F-260616-09 B 批2:写 per_conv.messages。
|
||||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
||||||
// 审计:trust 放行仍记一条(decided_by=auto_trust),留痕可追溯
|
// 审计: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;
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -715,7 +399,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。
|
// push tool_result / audit 在 join_all 后串行回填(持锁,与 Med/High 占位拼接)。
|
||||||
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
|
||||||
if !low_risk.is_empty() {
|
if !low_risk.is_empty() {
|
||||||
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
||||||
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
|
let results: Vec<(ToolCallDraft, serde_json::Value, RiskLevel, Result<String, String>)> =
|
||||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
futures::future::join_all(low_risk.into_iter().map(|(draft, args, risk_level)| {
|
||||||
let tools = tools_arc.clone();
|
let tools = tools_arc.clone();
|
||||||
@@ -725,7 +409,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
|
let result = execute_with_heartbeat(&tools, &draft.name, args, &app_clone, &conv_clone).await;
|
||||||
match result {
|
match result {
|
||||||
Ok(val) => {
|
Ok(val) => {
|
||||||
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
// L3 emit 双写:Low 风险工具执行成功 emit Completed 双路发布。
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
result: val.clone(),
|
result: val.clone(),
|
||||||
@@ -745,7 +429,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// 现改为正常 emit AiToolCallCompleted(result=错误信息),错误包进 tool_result
|
// 现改为正常 emit AiToolCallCompleted(result=错误信息),错误包进 tool_result
|
||||||
// 让 LLM 看到工具失败自行决定下一步,loop 正常续,前端状态一致。
|
// 让 LLM 看到工具失败自行决定下一步,loop 正常续,前端状态一致。
|
||||||
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
|
let err_msg = format!("工具 {} 执行失败: {}", draft.name, e);
|
||||||
// L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。
|
// L3 emit 双写:Low 风险工具执行失败 emit Completed 双路发布。
|
||||||
let ev = AiChatEvent::AiToolCallCompleted {
|
let ev = AiChatEvent::AiToolCallCompleted {
|
||||||
id: draft.id.clone(),
|
id: draft.id.clone(),
|
||||||
result: serde_json::Value::String(err_msg.clone()),
|
result: serde_json::Value::String(err_msg.clone()),
|
||||||
@@ -765,18 +449,18 @@ pub(crate) async fn process_tool_calls(
|
|||||||
Ok(c) => ("completed", c),
|
Ok(c) => ("completed", c),
|
||||||
Err(c) => ("failed", c),
|
Err(c) => ("failed", c),
|
||||||
};
|
};
|
||||||
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
|
// F-260619-04 P2(方案 B):create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
|
||||||
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
|
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
|
||||||
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
|
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
|
||||||
if status == "completed" {
|
if status == "completed" {
|
||||||
let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null);
|
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;
|
maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await;
|
||||||
}
|
}
|
||||||
// F-260616-09 B 批2:写 per_conv.messages。
|
// F-260616-09 B 批2:写 per_conv.messages。
|
||||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
|
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 才进)。
|
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
|
||||||
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
|
||||||
// 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。
|
// 原硬编码 RiskLevel::Low 致 Med/High 接管操作全标 Low/auto 失真,等于没记)。
|
||||||
let decided_by: &str = match risk_level {
|
let decided_by: &str = match risk_level {
|
||||||
RiskLevel::Low => "auto",
|
RiskLevel::Low => "auto",
|
||||||
RiskLevel::Medium => "auto_takeover_medium",
|
RiskLevel::Medium => "auto_takeover_medium",
|
||||||
@@ -786,13 +470,13 @@ pub(crate) async fn process_tool_calls(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
|
||||||
let (path_count, risk_count) = session.pending_approvals.values()
|
let (path_count, risk_count) = session.pending_approvals.values()
|
||||||
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
.fold((0usize, 0usize), |(p, r), a| match a.kind {
|
||||||
ApprovalKind::Path(_) => (p + 1, r),
|
ApprovalKind::Path(_) => (p + 1, r),
|
||||||
ApprovalKind::Risk { .. } => (p, r + 1),
|
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 返回(路径/风险挂起分布)");
|
tracing::debug!(target: "ai_dirauth", pending_count, pending_approvals_len = session.pending_approvals.len(), path_count, risk_count, "process_tool_calls 返回(路径/风险挂起分布)");
|
||||||
pending_count
|
pending_count
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -800,52 +484,52 @@ pub(crate) async fn process_tool_calls(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
|
||||||
///
|
///
|
||||||
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
|
||||||
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
|
||||||
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
|
||||||
#[test]
|
#[test]
|
||||||
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
|
fn test_grep_unauthorized_path_triggers_auth_not_denied() {
|
||||||
// 空白名单(不含 workspace_root 的目录):任何路径都未授权 → NeedsAuth
|
// 空白名单(不含 workspace_root 的目录):任何路径都未授权 → NeedsAuth
|
||||||
let allowed = crate::state::AllowedDirs::default();
|
let allowed = crate::state::AllowedDirs::default();
|
||||||
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
|
let args = serde_json::json!({ "pattern": "foo", "path": "C:/some/unauthorized/dir" });
|
||||||
|
|
||||||
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
||||||
match outcome {
|
match outcome {
|
||||||
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
|
FileToolAuthOutcome::NeedsAuth(_) => { /* 期望:grep 触发授权申请 */ }
|
||||||
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
|
FileToolAuthOutcome::Authorized => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Authorized"),
|
||||||
FileToolAuthOutcome::Denied(_) => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Denied(grep 不应像 search_files 盲拒)"),
|
FileToolAuthOutcome::Denied(_) => panic!("grep 未授权应返 NeedsAuth(申请授权),实际 Denied(grep 不应像 search_files 盲拒)"),
|
||||||
}
|
}
|
||||||
|
|
||||||
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
|
// 对照:search_files 同样未授权但被硬拒(Denied),锁定两工具差异
|
||||||
let search_outcome = check_file_tool_auth("search_files", &args, &allowed);
|
let search_outcome = check_file_tool_auth("search_files", &args, &allowed);
|
||||||
match search_outcome {
|
match search_outcome {
|
||||||
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
|
FileToolAuthOutcome::Denied(_) => { /* 期望:search_files 盲拒 */ }
|
||||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
|
FileToolAuthOutcome::NeedsAuth(_) => panic!("search_files 未授权应返 Denied(盲拒),实际 NeedsAuth"),
|
||||||
FileToolAuthOutcome::Authorized => panic!("search_files 未授权应返 Denied(盲拒),实际 Authorized"),
|
FileToolAuthOutcome::Authorized => panic!("search_files 未授权应返 Denied(盲拒),实际 Authorized"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// grep 黑名单路径(.ssh 等)直接 Denied(不申请授权),对齐 read_file。
|
/// grep 黑名单路径(.ssh 等)直接 Denied(不申请授权),对齐 read_file。
|
||||||
#[test]
|
#[test]
|
||||||
fn test_grep_blacklisted_path_denied() {
|
fn test_grep_blacklisted_path_denied() {
|
||||||
let allowed = crate::state::AllowedDirs::default_with_root();
|
let allowed = crate::state::AllowedDirs::default_with_root();
|
||||||
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
// .ssh 命中系统黑名单(is_in_system_blacklist)
|
||||||
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
|
let args = serde_json::json!({ "pattern": "foo", "path": "/home/user/.ssh/config" });
|
||||||
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
let outcome = check_file_tool_auth("grep", &args, &allowed);
|
||||||
match outcome {
|
match outcome {
|
||||||
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
|
FileToolAuthOutcome::Denied(_) => { /* 期望:黑名单硬拒 */ }
|
||||||
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
|
FileToolAuthOutcome::NeedsAuth(_) => panic!("grep 黑名单路径应返 Denied,实际 NeedsAuth"),
|
||||||
FileToolAuthOutcome::Authorized => panic!("grep 黑名单路径应返 Denied,实际 Authorized"),
|
FileToolAuthOutcome::Authorized => panic!("grep 黑名单路径应返 Denied,实际 Authorized"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// extract_file_tool_paths:grep 取 args["path"](单路径分支)
|
/// extract_file_tool_paths:grep 取 args["path"](单路径分支)
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_grep_path() {
|
fn test_extract_grep_path() {
|
||||||
let args = serde_json::json!({ "pattern": "foo", "path": "src/main.rs", "glob": "*.rs" });
|
let args = serde_json::json!({ "pattern": "foo", "path": "src/main.rs", "glob": "*.rs" });
|
||||||
let paths = extract_file_tool_paths("grep", &args);
|
let paths = extract_file_tool_paths("grep", &args);
|
||||||
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
|
assert_eq!(paths, vec!["src/main.rs".to_string()], "grep 应取 path 参数(忽略 glob 等其他参数)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
94
src-tauri/src/commands/ai/audit/path_auth.rs
Normal file
94
src-tauri/src/commands/ai/audit/path_auth.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||||||
|
//!
|
||||||
|
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||||||
|
//! - `extract_file_tool_paths`:从工具参数中提取文件路径
|
||||||
|
//! - `FileToolAuthOutcome`:授权判定结果枚举
|
||||||
|
//! - `check_file_tool_auth`:对单条文件工具调用做路径授权预校验
|
||||||
|
//!
|
||||||
|
//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。
|
||||||
|
//! 测试模块通过 `super::check_file_tool_auth` 引用。
|
||||||
|
|
||||||
|
use super::PathAuthRequest;
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
||||||
|
///
|
||||||
|
/// 仅对走 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 靠人工审批)
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B/C: 路径授权预校验结果。
|
||||||
|
pub(crate) enum FileToolAuthOutcome {
|
||||||
|
/// 全部已授权 → 走原 Low/Med/High 流程
|
||||||
|
Authorized,
|
||||||
|
/// 未授权 → 附带 PathAuthRequest,供挂起弹窗
|
||||||
|
NeedsAuth(PathAuthRequest),
|
||||||
|
/// 命中黑名单 → 硬拒
|
||||||
|
Denied(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||||
|
///
|
||||||
|
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
|
||||||
|
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||||
|
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
||||||
|
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
||||||
|
pub(crate) fn check_file_tool_auth(
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
allowed: &crate::state::AllowedDirs,
|
||||||
|
) -> FileToolAuthOutcome {
|
||||||
|
use crate::state::{check_path_authorization, PathAuthDecision};
|
||||||
|
let paths = extract_file_tool_paths(tool_name, args);
|
||||||
|
if paths.is_empty() {
|
||||||
|
// 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err)
|
||||||
|
return FileToolAuthOutcome::Authorized;
|
||||||
|
}
|
||||||
|
// L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重),
|
||||||
|
// 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。
|
||||||
|
let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
for p in &paths {
|
||||||
|
match check_path_authorization(p, allowed) {
|
||||||
|
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
|
||||||
|
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||||
|
if !pending_dirs.contains(&dir) {
|
||||||
|
pending_dirs.push(dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PathAuthDecision::Authorized => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if pending_dirs.is_empty() {
|
||||||
|
FileToolAuthOutcome::Authorized
|
||||||
|
} else if tool_name == "search_files" {
|
||||||
|
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
|
||||||
|
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
|
||||||
|
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
|
||||||
|
// 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。
|
||||||
|
FileToolAuthOutcome::Denied(
|
||||||
|
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths })
|
||||||
|
}
|
||||||
|
}
|
||||||
162
src-tauri/src/commands/ai/audit/record.rs
Normal file
162
src-tauri/src/commands/ai/audit/record.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
//! 审计记录写/查操作 + 审批历史 DTO + 审计面板查询 IPC。
|
||||||
|
//!
|
||||||
|
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||||||
|
//! - `audit_tool_call`:写一条工具执行审计记录(insert)
|
||||||
|
//! - `record_audit`:`audit_tool_call` 的别名,语义更清晰("记一条审计")
|
||||||
|
//! - `query_audit_history`:按条件查询审计历史记录
|
||||||
|
//! - `ToolExecutionDto`:传给前端的精简审计视图
|
||||||
|
//! - `list_tool_executions`:审批历史面板查询 IPC
|
||||||
|
//!
|
||||||
|
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use df_ai::ai_tools::RiskLevel;
|
||||||
|
use df_storage::crud::AiToolExecutionRepo;
|
||||||
|
use df_storage::models::AiToolExecutionRecord;
|
||||||
|
use df_types::types::new_id;
|
||||||
|
|
||||||
|
use crate::commands::err_str;
|
||||||
|
use crate::commands::now_millis;
|
||||||
|
use crate::state::AppState;
|
||||||
|
|
||||||
|
use super::risk_str;
|
||||||
|
|
||||||
|
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
||||||
|
///
|
||||||
|
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||||||
|
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
||||||
|
pub(crate) async fn audit_tool_call(
|
||||||
|
repo: &AiToolExecutionRepo,
|
||||||
|
conv_id: &str,
|
||||||
|
tool_call_id: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
arguments: &str,
|
||||||
|
status: &str,
|
||||||
|
risk_level: RiskLevel,
|
||||||
|
result: Option<String>,
|
||||||
|
decided_by: Option<&str>,
|
||||||
|
message_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||||
|
if let Err(e) = repo
|
||||||
|
.insert(AiToolExecutionRecord {
|
||||||
|
id: new_id(),
|
||||||
|
conversation_id: Some(conv_id.to_string()),
|
||||||
|
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||||||
|
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
||||||
|
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
||||||
|
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
||||||
|
message_id: message_id.map(|s| s.to_string()),
|
||||||
|
tool_call_id: tool_call_id.to_string(),
|
||||||
|
tool_name: tool_name.to_string(),
|
||||||
|
arguments: arguments.to_string(),
|
||||||
|
result,
|
||||||
|
status: status.to_string(),
|
||||||
|
risk_level: risk_str(risk_level).to_string(),
|
||||||
|
requested_at: now_millis(),
|
||||||
|
executed_at,
|
||||||
|
decided_by: decided_by.map(|s| s.to_string()),
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(
|
||||||
|
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
||||||
|
conv_id,
|
||||||
|
tool_call_id,
|
||||||
|
tool_name,
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `audit_tool_call` 的语义别名,功能完全相同。
|
||||||
|
/// 命名更符合"记录一条审计"的调用意图,供新代码使用。
|
||||||
|
pub(crate) async fn record_audit(
|
||||||
|
repo: &AiToolExecutionRepo,
|
||||||
|
conv_id: &str,
|
||||||
|
tool_call_id: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
arguments: &str,
|
||||||
|
status: &str,
|
||||||
|
risk_level: RiskLevel,
|
||||||
|
result: Option<String>,
|
||||||
|
decided_by: Option<&str>,
|
||||||
|
message_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 查询审计历史记录(分页,按 requested_at 倒序)。
|
||||||
|
///
|
||||||
|
/// 封装 `list_recent` 添加一层可读语义,方便未来扩展筛选条件。
|
||||||
|
pub(crate) async fn query_audit_history(
|
||||||
|
repo: &AiToolExecutionRepo,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
) -> Result<Vec<AiToolExecutionRecord>, String> {
|
||||||
|
let limit = limit.min(200);
|
||||||
|
repo.list_recent(limit, offset)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("query_audit_history 查询失败: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
|
||||||
|
///
|
||||||
|
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
|
||||||
|
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ToolExecutionDto {
|
||||||
|
pub id: String,
|
||||||
|
pub conversation_id: Option<String>,
|
||||||
|
pub tool_call_id: String,
|
||||||
|
pub tool_name: String,
|
||||||
|
/// 参数摘要(截断 120 字符,完整原值仍留库)
|
||||||
|
pub arguments_brief: String,
|
||||||
|
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
|
||||||
|
pub result_brief: Option<String>,
|
||||||
|
/// pending/approved/rejected/executing/completed/failed
|
||||||
|
pub status: String,
|
||||||
|
/// low/medium/high
|
||||||
|
pub risk_level: String,
|
||||||
|
pub requested_at: String,
|
||||||
|
pub executed_at: Option<String>,
|
||||||
|
/// human/auto,None 表示尚未决策
|
||||||
|
pub decided_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
||||||
|
///
|
||||||
|
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
||||||
|
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_tool_executions(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
limit: Option<u32>,
|
||||||
|
offset: Option<u32>,
|
||||||
|
) -> Result<Vec<ToolExecutionDto>, 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: super::truncate_chars(&r.arguments, 120),
|
||||||
|
result_brief: r.result.map(|s| super::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())
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
//!
|
//!
|
||||||
//! 第五批从 audit/mod.rs 抽离,行为零变更:
|
//! 第五批从 audit/mod.rs 抽离,行为零变更:
|
||||||
//! - `risk_str` / `risk_from_str`:finalize / restore 通过 `super::` 引用(pub(crate))。
|
//! - `risk_str` / `risk_from_str`:finalize / restore 通过 `super::` 引用(pub(crate))。
|
||||||
//! - `truncate_chars`:仅 mod.rs 的 list_tool_executions DTO 装配用(pub(super))。
|
//! - `truncate_chars`:mod.rs 的 process_tool_calls DTO 装配用 + record.rs 审计面板 IPC 用(pub(crate))。
|
||||||
//!
|
//!
|
||||||
//! re-export 维持原路径透明:`super::risk_str` / `super::risk_from_str` / `super::truncate_chars`
|
//! re-export 维持原路径透明:`super::risk_str` / `super::risk_from_str` / `super::truncate_chars`
|
||||||
//! 在 audit/mod.rs 内通过 `use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。
|
//! 在 audit/mod.rs 内通过 `pub(crate) use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。
|
||||||
|
|
||||||
use df_ai::ai_tools::RiskLevel;
|
use df_ai::ai_tools::RiskLevel;
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过)
|
/// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过)
|
||||||
pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
||||||
match s {
|
match s {
|
||||||
"low" => Some(RiskLevel::Low),
|
"low" => Some(RiskLevel::Low),
|
||||||
@@ -28,8 +28,8 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
|
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
|
||||||
pub(super) fn truncate_chars(s: &str, max: usize) -> String {
|
pub(crate) fn truncate_chars(s: &str, max: usize) -> String {
|
||||||
if s.chars().count() <= max {
|
if s.chars().count() <= max {
|
||||||
return s.to_string();
|
return s.to_string();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ pub fn run() {
|
|||||||
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
// F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)
|
||||||
commands::ai::ai_authorize_dir,
|
commands::ai::ai_authorize_dir,
|
||||||
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
|
// 审批历史面板(AE-2025-08:查 ai_tool_executions 表,敏感字段截断)
|
||||||
commands::ai::audit::list_tool_executions,
|
commands::ai::audit::record::list_tool_executions,
|
||||||
// 知识库
|
// 知识库
|
||||||
commands::knowledge::knowledge_list,
|
commands::knowledge::knowledge_list,
|
||||||
commands::knowledge::knowledge_get,
|
commands::knowledge::knowledge_get,
|
||||||
|
|||||||
Reference in New Issue
Block a user