新增: idea source 消息级溯源 + workspace_root 去固定根 + run_command 偏好修复
idea source 结构化(消息级溯源闭环 — 灵感来源 conv_msg:{id}):
- 新建 idea_source.rs(IDEA_SOURCE_AUTO_FILL_ENABLED + maybe_fill_idea_source 保守五条件:
开关/create_idea/source 空(不覆盖 AI 填)/message_id 非 None/result.id)
- audit/mod low_risk + chat ai_approve 双执行路径接补全(create_idea Medium 审批)
workspace_root 去固定根(F-260619-03 收尾):
- is_authorized 删 workspace_root 固定放行(代码硬编码),改走 persistent/session 白名单
- default_with_root 保留(默认初始 persistent,首次免授权行为不变)
- 用户从白名单删工程根后也需授权(动态白名单完整语义)
run_command 偏好修复(db 分析:591ff4a0 run_command 31/56=55% 严重偏好):
- P1 描述收紧(明确仅命令执行 + 负向引导:读取 read_file/编辑 patch_file/列目录 list_directory/搜索 search_files)
- P2 ToolDomain::Exec(run_command 独立 domain,仅 Debug 含 Exec,Code/File/Search 不含)
- P3 注册顺序(run_command 放 File 层最后)+ DEBUG_RULES 加命令执行关键词
自验: df-ai 228(intent Exec)+ devflow 169(idea source)+ workspace EXIT 0
This commit is contained in:
@@ -119,6 +119,13 @@ const DEBUG_RULES: &[Rule] = &[
|
|||||||
Rule { kw: "栈", weight: 0.5 },
|
Rule { kw: "栈", weight: 0.5 },
|
||||||
Rule { kw: "panic", weight: 0.9 },
|
Rule { kw: "panic", weight: 0.9 },
|
||||||
Rule { kw: "traceback", weight: 0.9 },
|
Rule { kw: "traceback", weight: 0.9 },
|
||||||
|
// 命令执行场景关键词(run_command 入口引导):用户明确"运行/执行/构建/测试/跑"
|
||||||
|
// 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。
|
||||||
|
Rule { kw: "运行", weight: 0.7 },
|
||||||
|
Rule { kw: "执行", weight: 0.7 },
|
||||||
|
Rule { kw: "构建", weight: 0.7 },
|
||||||
|
Rule { kw: "测试", weight: 0.7 },
|
||||||
|
Rule { kw: "跑", weight: 0.6 },
|
||||||
];
|
];
|
||||||
|
|
||||||
const FILE_RULES: &[Rule] = &[
|
const FILE_RULES: &[Rule] = &[
|
||||||
@@ -225,8 +232,10 @@ const GENERIC_GROUP: &[IntentGroup] = &[
|
|||||||
pub enum ToolDomain {
|
pub enum ToolDomain {
|
||||||
/// 数据/业务:项目/任务/灵感/工作流/回收站
|
/// 数据/业务:项目/任务/灵感/工作流/回收站
|
||||||
Data,
|
Data,
|
||||||
/// 文件:读写/patch/列目录/搜索/命令
|
/// 文件:读写/patch/列目录/搜索(不含命令执行)
|
||||||
File,
|
File,
|
||||||
|
/// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出)
|
||||||
|
Exec,
|
||||||
/// 网络:HTTP
|
/// 网络:HTTP
|
||||||
Http,
|
Http,
|
||||||
}
|
}
|
||||||
@@ -261,12 +270,15 @@ impl ToolDomain {
|
|||||||
"patch_file",
|
"patch_file",
|
||||||
"list_directory",
|
"list_directory",
|
||||||
"search_files",
|
"search_files",
|
||||||
"run_command",
|
|
||||||
"file_info",
|
"file_info",
|
||||||
"append_file",
|
"append_file",
|
||||||
"delete_file",
|
"delete_file",
|
||||||
"rename_file",
|
"rename_file",
|
||||||
],
|
],
|
||||||
|
// Exec domain 独立:run_command 从 File 移出。
|
||||||
|
// 意图侧仅 Debug 默认带 Exec(用户明确"运行/测试/构建"),
|
||||||
|
// Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。
|
||||||
|
ToolDomain::Exec => &["run_command"],
|
||||||
ToolDomain::Http => &["http_request"],
|
ToolDomain::Http => &["http_request"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,11 +349,13 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
|||||||
///
|
///
|
||||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data];
|
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data];
|
||||||
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
||||||
/// Chat → [];Debug → [file, http, data](调试常需读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);Unknown → [](全量)。
|
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
|
||||||
|
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
|
||||||
|
/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;Unknown → [](全量)。
|
||||||
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||||
let domains: &[ToolDomain] = match intent {
|
let domains: &[ToolDomain] = match intent {
|
||||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
||||||
Intent::Debug => &[ToolDomain::File, ToolDomain::Http, ToolDomain::Data],
|
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
|
||||||
Intent::File => &[ToolDomain::File],
|
Intent::File => &[ToolDomain::File],
|
||||||
Intent::Project => &[ToolDomain::Data],
|
Intent::Project => &[ToolDomain::Data],
|
||||||
Intent::Task => &[ToolDomain::Data],
|
Intent::Task => &[ToolDomain::Data],
|
||||||
@@ -669,7 +683,9 @@ mod tests {
|
|||||||
fn tool_domain_names_match_registry() {
|
fn tool_domain_names_match_registry() {
|
||||||
// 抽样核对与 src-tauri tool_registry 实际注册名一致
|
// 抽样核对与 src-tauri tool_registry 实际注册名一致
|
||||||
assert!(ToolDomain::File.tools().contains(&"patch_file"));
|
assert!(ToolDomain::File.tools().contains(&"patch_file"));
|
||||||
assert!(ToolDomain::File.tools().contains(&"run_command"));
|
// run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command)
|
||||||
|
assert!(!ToolDomain::File.tools().contains(&"run_command"));
|
||||||
|
assert_eq!(ToolDomain::Exec.tools(), &["run_command"]);
|
||||||
assert!(ToolDomain::Data.tools().contains(&"advance_task"));
|
assert!(ToolDomain::Data.tools().contains(&"advance_task"));
|
||||||
assert!(ToolDomain::Data.tools().contains(&"run_workflow"));
|
assert!(ToolDomain::Data.tools().contains(&"run_workflow"));
|
||||||
assert!(ToolDomain::Data.tools().contains(&"list_trash"));
|
assert!(ToolDomain::Data.tools().contains(&"list_trash"));
|
||||||
@@ -838,9 +854,9 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn filter_debug_subset_spans_three_domains_complete() {
|
fn filter_debug_subset_spans_four_domains_complete() {
|
||||||
// 对抗(多 domain):Debug subset 跨 File + Http + Data 三 domain,
|
// 对抗(多 domain):Debug subset 跨 File + Exec + Http + Data 四 domain,
|
||||||
// 必须同条消息里能同时命中三个 domain 的工具(防 domain 漏挂)
|
// 必须同条消息里能同时命中四个 domain 的工具(防 domain 漏挂)
|
||||||
let all = vec![
|
let all = vec![
|
||||||
// Data domain
|
// Data domain
|
||||||
tool_def("list_tasks"),
|
tool_def("list_tasks"),
|
||||||
@@ -849,6 +865,8 @@ mod tests {
|
|||||||
// File domain
|
// File domain
|
||||||
tool_def("read_file"),
|
tool_def("read_file"),
|
||||||
tool_def("patch_file"),
|
tool_def("patch_file"),
|
||||||
|
// Exec domain
|
||||||
|
tool_def("run_command"),
|
||||||
// Http domain
|
// Http domain
|
||||||
tool_def("http_request"),
|
tool_def("http_request"),
|
||||||
];
|
];
|
||||||
@@ -856,8 +874,27 @@ mod tests {
|
|||||||
let names: Vec<String> = out.iter().map(|d| d.function.name.clone()).collect();
|
let names: Vec<String> = out.iter().map(|d| d.function.name.clone()).collect();
|
||||||
assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data");
|
assert!(names.contains(&"list_tasks".to_string()), "Debug 必含 Data(list_tasks), 防 CR-25 🟡-1 丢 Data");
|
||||||
assert!(names.contains(&"read_file".to_string()), "Debug 必含 File");
|
assert!(names.contains(&"read_file".to_string()), "Debug 必含 File");
|
||||||
|
assert!(names.contains(&"run_command".to_string()), "Debug 必含 Exec(run_command)");
|
||||||
assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http");
|
assert!(names.contains(&"http_request".to_string()), "Debug 必含 Http");
|
||||||
assert_eq!(names.len(), 6, "三 domain 工具全保留, 实际: {:?}", names);
|
assert_eq!(names.len(), 7, "四 domain 工具全保留, 实际: {:?}", names);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn run_command_only_exposed_for_debug_intent() {
|
||||||
|
// 核心修复断言:run_command 仅 Debug 暴露。
|
||||||
|
// Code/File/Search 意图 subset 不含 Exec domain → 不含 run_command。
|
||||||
|
for intent in [Intent::Code, Intent::File, Intent::Search] {
|
||||||
|
let s = tool_subset_for(&intent);
|
||||||
|
assert!(
|
||||||
|
!s.contains(&"run_command"),
|
||||||
|
"Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}",
|
||||||
|
intent,
|
||||||
|
s
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Debug 必含 run_command(Exec domain)
|
||||||
|
let dbg = tool_subset_for(&Intent::Debug);
|
||||||
|
assert!(dbg.contains(&"run_command"), "Debug 必含 run_command");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -893,11 +930,12 @@ mod tests {
|
|||||||
];
|
];
|
||||||
for intent in all_intents {
|
for intent in all_intents {
|
||||||
let subset = tool_subset_for(&intent);
|
let subset = tool_subset_for(&intent);
|
||||||
// 全 registry 工具名(三 domain 并集)
|
// 全 registry 工具名(四 domain 并集:Data + File + Exec + Http)
|
||||||
let registry: std::collections::HashSet<&str> = ToolDomain::Data
|
let registry: std::collections::HashSet<&str> = ToolDomain::Data
|
||||||
.tools()
|
.tools()
|
||||||
.iter()
|
.iter()
|
||||||
.chain(ToolDomain::File.tools().iter())
|
.chain(ToolDomain::File.tools().iter())
|
||||||
|
.chain(ToolDomain::Exec.tools().iter())
|
||||||
.chain(ToolDomain::Http.tools().iter())
|
.chain(ToolDomain::Http.tools().iter())
|
||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
328
src-tauri/src/commands/ai/audit/idea_source.rs
Normal file
328
src-tauri/src/commands/ai/audit/idea_source.rs
Normal file
@@ -0,0 +1,328 @@
|
|||||||
|
//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。
|
||||||
|
//!
|
||||||
|
//! ## 背景
|
||||||
|
//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea`
|
||||||
|
//! 漏了**:create_idea handler 是 `Fn(Value)→result` 无 message_id 上下文,source 由 AI
|
||||||
|
//! 自由填(`args["source"]`)。AI 多数场景不填(空),致灵感丢失溯源——查不到从哪条对话长出来。
|
||||||
|
//!
|
||||||
|
//! ## 方案 B(最小,低侵入 — 不改 AiToolHandler 接口)
|
||||||
|
//! 工具执行后(process_tool_calls / 审批执行路径)拿到 handler 返回的 result + AI 入参 args,
|
||||||
|
//! 检测 `tool_name == "create_idea"`:
|
||||||
|
//! - 仅当 AI 未填 source(空 / null / 缺字段)且 message_id 可得 → 补 `source = conv_msg:{message_id}`
|
||||||
|
//! - AI 已填(非空)→ **不覆盖**(保留 AI 有意义的自由文本,如"用户口述"/"xxx 文档")
|
||||||
|
//! - message_id 缺失(None,异常路径/老数据)→ **不补**(无源可溯,保持原值,不伪造)
|
||||||
|
//!
|
||||||
|
//! ## 保守原则
|
||||||
|
//! 仅 create_idea 专用(其他工具不碰) / 仅 source 空时补(不覆盖 AI 填) / 无 message_id 不补。
|
||||||
|
//! 常量开关 `IDEA_SOURCE_AUTO_FILL_ENABLED` 可一键关闭(易迭代,出问题降级)。
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use df_storage::crud::IdeaRepo;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
|
||||||
|
/// 一键开关:是否启用 create_idea source 自动补全。出问题改 false 即降级为 noop(不补)。
|
||||||
|
const IDEA_SOURCE_AUTO_FILL_ENABLED: bool = true;
|
||||||
|
|
||||||
|
/// 从 AI 入参 args 取 source,返回是否「AI 未填」(空 / null / 缺字段)。
|
||||||
|
///
|
||||||
|
/// 独立纯函数(便于单测):判定逻辑收敛一处,调用方与测试共享同一语义。
|
||||||
|
/// - `{"source": null}` / `{"source": ""}` / 缺 source 键 → true(未填,应补)
|
||||||
|
/// - `{"source": "用户口述"}` → false(AI 已填,不覆盖)
|
||||||
|
fn is_source_unfilled(args: &serde_json::Value) -> bool {
|
||||||
|
match args.get("source") {
|
||||||
|
None => true,
|
||||||
|
Some(serde_json::Value::Null) => true,
|
||||||
|
Some(serde_json::Value::String(s)) if s.trim().is_empty() => true,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 从 create_idea handler 返回的 result 取 idea_id(定位刚创建的灵感行)。
|
||||||
|
///
|
||||||
|
/// handler 返回 `{ "id": "...", "title": "...", "status": "draft" }`(tool_registry.rs create_idea)。
|
||||||
|
/// id 字段即 idea 表主键,用于 `IdeaRepo.update_field(id, "source", ...)`。
|
||||||
|
fn extract_idea_id(result: &serde_json::Value) -> Option<String> {
|
||||||
|
result.get("id").and_then(|v| v.as_str()).map(|s| s.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 灵感来源消息级溯源补全(方案 B 核心)。
|
||||||
|
///
|
||||||
|
/// **保守**:仅在以下条件全部满足时补 source = `conv_msg:{message_id}`:
|
||||||
|
/// 1. `IDEA_SOURCE_AUTO_FILL_ENABLED == true`(一键开关)
|
||||||
|
/// 2. `tool_name == "create_idea"`(专用,其他工具不碰)
|
||||||
|
/// 3. `args.source` 未填(空 / null / 缺字段)—— AI 已填则**不覆盖**
|
||||||
|
/// 4. `message_id` 为 `Some`(无源可溯的异常/老数据不补,不伪造)
|
||||||
|
/// 5. result 含 `id`(idea_id,定位刚创建的灵感行)
|
||||||
|
///
|
||||||
|
/// 补全失败(库写失败等)仅 `tracing::warn` 不传播——溯源补全是 best-effort 增益,
|
||||||
|
/// 不应阻断工具已成功的执行流程(灵感已创建,source 缺失只是少了溯源,非业务失败)。
|
||||||
|
///
|
||||||
|
/// 单点逻辑(本函数)+ 多调用点(process_tool_calls 各执行路径 + 审批执行 chat.rs):
|
||||||
|
/// 调用点只管传 (tool_name, args, result, message_id),判定/补全/容错全在本函数。
|
||||||
|
pub async fn maybe_fill_idea_source(
|
||||||
|
db: &Arc<Database>,
|
||||||
|
tool_name: &str,
|
||||||
|
args: &serde_json::Value,
|
||||||
|
result: &serde_json::Value,
|
||||||
|
message_id: Option<&str>,
|
||||||
|
) {
|
||||||
|
// 条件 1: 一键开关
|
||||||
|
if !IDEA_SOURCE_AUTO_FILL_ENABLED {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 条件 2: 仅 create_idea 专用
|
||||||
|
if tool_name != "create_idea" {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 条件 3: AI 未填 source 才补(AI 已填不覆盖)
|
||||||
|
if !is_source_unfilled(args) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 条件 4: message_id 缺失不补(无源可溯,不伪造)
|
||||||
|
let msg_id = match message_id {
|
||||||
|
Some(id) if !id.is_empty() => id,
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
// 条件 5: result 含 idea_id(定位刚创建的灵感行)
|
||||||
|
let idea_id = match extract_idea_id(result) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
tracing::warn!(
|
||||||
|
tool = tool_name,
|
||||||
|
"[idea-source] create_idea result 缺 id 字段,无法定位灵感行补 source(降级跳过)"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let source_val = format!("conv_msg:{}", msg_id);
|
||||||
|
let repo = IdeaRepo::new(db);
|
||||||
|
match repo.update_field(&idea_id, "source", &source_val).await {
|
||||||
|
Ok(true) => {
|
||||||
|
tracing::info!(
|
||||||
|
idea_id = %idea_id,
|
||||||
|
message_id = %msg_id,
|
||||||
|
"[idea-source] 补全灵感来源消息级溯源: source = conv_msg:{}",
|
||||||
|
msg_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(false) => {
|
||||||
|
// update_field 返 false = 影响行数 0(idea_id 不存在,异常,但灵感刚创建理论上必存在)
|
||||||
|
tracing::warn!(
|
||||||
|
idea_id = %idea_id,
|
||||||
|
"[idea-source] update_field 影响 0 行(idea_id 不存在?);source 未补,灵感已创建不受影响"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
// best-effort:溯源补全失败不阻断工具成功流程,仅 warn
|
||||||
|
tracing::warn!(
|
||||||
|
idea_id = %idea_id,
|
||||||
|
error = %e,
|
||||||
|
"[idea-source] 补全灵感来源失败(降级跳过,灵感已创建不受影响)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 单元测试 — 判定纯函数 + 补全全链路(内存 DB)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 测试辅助(仅 cfg(test))— 内存 DB + 插入 IdeaRecord fixture
|
||||||
|
// ============================================================
|
||||||
|
// 独立 #[cfg(test)] 子模块:供本文件 tests 与同 crate 其他测试复用(防 DRY)。
|
||||||
|
// 不暴露到非测试构建(避免误用内存 DB 做 prod 路径)。
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod idea_source_test_helpers {
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use df_storage::crud::IdeaRepo;
|
||||||
|
use df_storage::db::Database;
|
||||||
|
use df_storage::models::IdeaRecord;
|
||||||
|
|
||||||
|
/// 建内存 DB(已跑 migrations,含 ideas 表)+ 插一条 IdeaRecord fixture,返回 db 句柄。
|
||||||
|
///
|
||||||
|
/// `source`: None → 插 source=NULL(模拟 AI 未填);Some(s) → 插已填值。
|
||||||
|
/// 其余字段用合理默认(title/description/status=draft/priority=1)。
|
||||||
|
pub async fn idea_source_fixture_record(id: &str, source: Option<&str>) -> Arc<Database> {
|
||||||
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let now = df_types::now_millis().to_string();
|
||||||
|
repo.insert(IdeaRecord {
|
||||||
|
id: id.to_string(),
|
||||||
|
title: format!("fixture-{}", id),
|
||||||
|
description: String::new(),
|
||||||
|
status: "draft".to_string(),
|
||||||
|
priority: 1,
|
||||||
|
score: None,
|
||||||
|
tags: None,
|
||||||
|
source: source.map(|s| s.to_string()),
|
||||||
|
promoted_to: None,
|
||||||
|
ai_analysis: None,
|
||||||
|
scores: None,
|
||||||
|
created_at: now.clone(),
|
||||||
|
updated_at: now,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.expect("insert fixture idea");
|
||||||
|
Arc::new(db)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::idea_source_test_helpers::idea_source_fixture_record;
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ---------- is_source_unfilled 纯函数 ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_missing_key_is_unfilled() {
|
||||||
|
let args = serde_json::json!({ "title": "t" });
|
||||||
|
assert!(is_source_unfilled(&args));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_null_is_unfilled() {
|
||||||
|
let args = serde_json::json!({ "title": "t", "source": null });
|
||||||
|
assert!(is_source_unfilled(&args));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_empty_string_is_unfilled() {
|
||||||
|
let args = serde_json::json!({ "title": "t", "source": "" });
|
||||||
|
assert!(is_source_unfilled(&args));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_whitespace_only_is_unfilled() {
|
||||||
|
let args = serde_json::json!({ "title": "t", "source": " " });
|
||||||
|
assert!(is_source_unfilled(&args));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_non_empty_is_filled() {
|
||||||
|
let args = serde_json::json!({ "title": "t", "source": "用户口述" });
|
||||||
|
assert!(!is_source_unfilled(&args));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- extract_idea_id ----------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_idea_id_from_result() {
|
||||||
|
let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" });
|
||||||
|
assert_eq!(extract_idea_id(&result).as_deref(), Some("idea-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn extract_idea_id_missing_returns_none() {
|
||||||
|
let result = serde_json::json!({ "title": "t" });
|
||||||
|
assert!(extract_idea_id(&result).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- maybe_fill_idea_source 全链路(内存 DB)----------
|
||||||
|
|
||||||
|
/// create_idea + source 空 + message_id 有 → 补 conv_msg:{id}
|
||||||
|
#[tokio::test]
|
||||||
|
async fn fills_source_when_unfilled_and_message_id_present() {
|
||||||
|
let db = idea_source_fixture_record("idea-1", None).await;
|
||||||
|
let args = serde_json::json!({ "title": "t" }); // source 缺
|
||||||
|
let result = serde_json::json!({ "id": "idea-1", "title": "t", "status": "draft" });
|
||||||
|
|
||||||
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
||||||
|
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let rec = repo.get_by_id("idea-1").await.unwrap().expect("idea-1 存在");
|
||||||
|
assert_eq!(rec.source.as_deref(), Some("conv_msg:msg-42"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// create_idea + AI 已填 source → 不覆盖
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_overwrite_when_ai_filled_source() {
|
||||||
|
let db = idea_source_fixture_record("idea-2", Some("用户口述")).await;
|
||||||
|
let args = serde_json::json!({ "title": "t", "source": "用户口述" });
|
||||||
|
let result = serde_json::json!({ "id": "idea-2", "title": "t", "status": "draft" });
|
||||||
|
|
||||||
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
||||||
|
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let rec = repo.get_by_id("idea-2").await.unwrap().expect("idea-2 存在");
|
||||||
|
assert_eq!(rec.source.as_deref(), Some("用户口述"), "AI 已填 source 不被覆盖");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// message_id 缺失(None)→ 不补
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_fill_when_message_id_none() {
|
||||||
|
let db = idea_source_fixture_record("idea-3", None).await;
|
||||||
|
let args = serde_json::json!({ "title": "t" });
|
||||||
|
let result = serde_json::json!({ "id": "idea-3", "title": "t", "status": "draft" });
|
||||||
|
|
||||||
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, None).await;
|
||||||
|
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let rec = repo.get_by_id("idea-3").await.unwrap().expect("idea-3 存在");
|
||||||
|
assert!(rec.source.is_none(), "无 message_id 不补 source");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// message_id 空串("")→ 不补(等同 None 语义,防伪造)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_fill_when_message_id_empty_string() {
|
||||||
|
let db = idea_source_fixture_record("idea-3b", None).await;
|
||||||
|
let args = serde_json::json!({ "title": "t" });
|
||||||
|
let result = serde_json::json!({ "id": "idea-3b", "title": "t", "status": "draft" });
|
||||||
|
|
||||||
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("")).await;
|
||||||
|
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let rec = repo.get_by_id("idea-3b").await.unwrap().expect("idea-3b 存在");
|
||||||
|
assert!(rec.source.is_none(), "空串 message_id 不补 source");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 非 create_idea 工具 → 不碰(其他工具不受影响)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_touch_other_tools() {
|
||||||
|
let db = idea_source_fixture_record("idea-4", None).await;
|
||||||
|
let args = serde_json::json!({ "title": "t" });
|
||||||
|
let result = serde_json::json!({ "id": "idea-4" });
|
||||||
|
|
||||||
|
maybe_fill_idea_source(&db, "write_file", &args, &result, Some("msg-42")).await;
|
||||||
|
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let rec = repo.get_by_id("idea-4").await.unwrap().expect("idea-4 存在");
|
||||||
|
assert!(rec.source.is_none(), "非 create_idea 不碰 source");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// result 缺 id 字段 → 不补(降级 warn,不 panic)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_fill_when_result_missing_id() {
|
||||||
|
let db = idea_source_fixture_record("idea-5", None).await;
|
||||||
|
let args = serde_json::json!({ "title": "t" });
|
||||||
|
let result = serde_json::json!({ "title": "t", "status": "draft" }); // 无 id
|
||||||
|
|
||||||
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
||||||
|
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let rec = repo.get_by_id("idea-5").await.unwrap().expect("idea-5 存在");
|
||||||
|
assert!(rec.source.is_none(), "result 缺 id 时不补,保持原值");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// idea_id 不存在于库 → 不 panic(0 行影响 warn)
|
||||||
|
#[tokio::test]
|
||||||
|
async fn does_not_panic_when_idea_id_not_in_db() {
|
||||||
|
let db = idea_source_fixture_record("idea-6", None).await;
|
||||||
|
let args = serde_json::json!({ "title": "t" });
|
||||||
|
// result 指向不存在的 idea-x
|
||||||
|
let result = serde_json::json!({ "id": "idea-nonexistent", "title": "t", "status": "draft" });
|
||||||
|
|
||||||
|
// 不应 panic,仅 warn
|
||||||
|
maybe_fill_idea_source(&db, "create_idea", &args, &result, Some("msg-42")).await;
|
||||||
|
|
||||||
|
// idea-6 原值不变
|
||||||
|
let repo = IdeaRepo::new(&db);
|
||||||
|
let rec = repo.get_by_id("idea-6").await.unwrap().expect("idea-6 存在");
|
||||||
|
assert!(rec.source.is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -126,6 +126,12 @@ pub(super) use cache::{find_cached_high_risk_result, PENDING_APPROVAL_PLACEHOLDE
|
|||||||
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) 灵感来源消息级溯源补全。
|
||||||
|
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
|
||||||
|
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
|
||||||
|
mod idea_source;
|
||||||
|
pub(crate) use idea_source::maybe_fill_idea_source;
|
||||||
|
|
||||||
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
|
||||||
///
|
///
|
||||||
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
|
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
|
||||||
@@ -488,7 +494,8 @@ 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() {
|
||||||
let results: Vec<(ToolCallDraft, Result<String, String>)> =
|
// 携带 args + 原始 JSON result(create_idea source 补全需解析 result.id + args.source)。
|
||||||
|
let results: Vec<(ToolCallDraft, serde_json::Value, Result<String, String>)> =
|
||||||
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
|
futures::future::join_all(low_risk.into_iter().map(|(draft, args)| {
|
||||||
let tools = tools_arc.clone();
|
let tools = tools_arc.clone();
|
||||||
let app_clone = app_handle.clone();
|
let app_clone = app_handle.clone();
|
||||||
@@ -505,7 +512,7 @@ pub(crate) async fn process_tool_calls(
|
|||||||
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
// AR-11(方案A):数据变更工具执行成功后 emit df-data-changed,
|
||||||
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
// 前端 store listen 刷新列表(仅命中映射的工具 emit,见 emit_data_changed)。
|
||||||
emit_data_changed(&app_clone, &draft.name);
|
emit_data_changed(&app_clone, &draft.name);
|
||||||
(draft, Ok(val.to_string()))
|
(draft, val.clone(), Ok(val.to_string()))
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// AR-6(定向B):Low 工具失败不 emit AiError。
|
// AR-6(定向B):Low 工具失败不 emit AiError。
|
||||||
@@ -519,18 +526,25 @@ pub(crate) async fn process_tool_calls(
|
|||||||
result: serde_json::Value::String(err_msg.clone()),
|
result: serde_json::Value::String(err_msg.clone()),
|
||||||
conversation_id: Some(conv_clone),
|
conversation_id: Some(conv_clone),
|
||||||
});
|
});
|
||||||
(draft, Err(err_msg))
|
(draft, serde_json::Value::Null, Err(err_msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})).await;
|
})).await;
|
||||||
|
|
||||||
// 串行回填 tool_result + 审计(持 session 锁)
|
// 串行回填 tool_result + 审计(持 session 锁)
|
||||||
for (draft, outcome) in results {
|
for (draft, raw_result, outcome) in results {
|
||||||
let (status, content) = match outcome {
|
let (status, content) = match outcome {
|
||||||
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)。
|
||||||
|
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
|
||||||
|
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
|
||||||
|
if status == "completed" {
|
||||||
|
let args_val = serde_json::from_str(&draft.args).unwrap_or(serde_json::Value::Null);
|
||||||
|
maybe_fill_idea_source(db, &draft.name, &args_val, &raw_result, current_message_id).await;
|
||||||
|
}
|
||||||
// F-260616-09 B 批2:写 per_conv.messages。
|
// 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()));
|
||||||
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await;
|
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, RiskLevel::Low, Some(content), Some("auto"), current_message_id).await;
|
||||||
|
|||||||
@@ -500,6 +500,28 @@ pub async fn ai_approve(
|
|||||||
});
|
});
|
||||||
drop(session);
|
drop(session);
|
||||||
|
|
||||||
|
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
|
||||||
|
// create_idea 是 Medium risk → 经审批执行(本路径),非 process_tool_calls 内联执行,
|
||||||
|
// 故补全点放此(工具已 executed,可拿到 result.id + args.source)。
|
||||||
|
// message_id 从 per_conv.messages 取末条 assistant id(同 process_tool_calls 的 current_message_id 语义);
|
||||||
|
// assistant_with_tools 消息持久驻留 messages,审批时仍可读。
|
||||||
|
// 仅 executed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
|
||||||
|
if audit_status == "executed" {
|
||||||
|
let message_id = {
|
||||||
|
let session = state.ai_session.lock().await;
|
||||||
|
conv_id.as_deref().and_then(|cid| {
|
||||||
|
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
|
||||||
|
})
|
||||||
|
};
|
||||||
|
super::super::audit::maybe_fill_idea_source(
|
||||||
|
&state.db,
|
||||||
|
&approval.tool_name,
|
||||||
|
&approval.arguments,
|
||||||
|
&result_val,
|
||||||
|
message_id.as_deref(),
|
||||||
|
).await;
|
||||||
|
}
|
||||||
|
|
||||||
// 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果)
|
// 审批执行结果立即落库,不依赖后续 agentic loop(避免 loop 异常退出时丢失真实结果)
|
||||||
// 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
|
// 含 recovered 积压审批——switch 时已 restore_from_messages 载完整历史,messages 非空,
|
||||||
// save 不污染老对话;原 if !recovered 守卫前提不成立已移除。
|
// save 不污染老对话;原 if !recovered 守卫前提不成立已移除。
|
||||||
|
|||||||
@@ -881,73 +881,6 @@ fn register_file_tools(
|
|||||||
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
|
// run_command 不走白名单(High risk 靠人工审批兜底,放开目录让 AI 在用户任意项目目录闭环),
|
||||||
// 故该工具闭包不捕获 allowed_dirs,维持原行为。
|
// 故该工具闭包不捕获 allowed_dirs,维持原行为。
|
||||||
let allowed_dirs = allowed_dirs.clone();
|
let allowed_dirs = allowed_dirs.clone();
|
||||||
registry.register(
|
|
||||||
"run_command", "在指定工作目录执行 shell 命令(跑测试/构建/查看运行结果),返回 stdout/stderr/exit_code。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。用于验证刚写入的代码能否运行、跑测试、看报错迭代修改。",
|
|
||||||
df_ai::ai_tools::object_schema(vec![
|
|
||||||
("command", "string", true),
|
|
||||||
("working_dir", "string", false),
|
|
||||||
("timeout_secs", "integer", false),
|
|
||||||
]),
|
|
||||||
RiskLevel::High,
|
|
||||||
Box::new(|args: serde_json::Value| Box::pin(async move {
|
|
||||||
let command = args["command"].as_str()
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
|
|
||||||
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
|
|
||||||
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
|
|
||||||
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
|
|
||||||
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
|
|
||||||
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
|
||||||
Some(d) => {
|
|
||||||
validate_path(d)?;
|
|
||||||
d.to_string()
|
|
||||||
}
|
|
||||||
None => workspace_root().to_string_lossy().to_string(),
|
|
||||||
};
|
|
||||||
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
|
|
||||||
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
|
|
||||||
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
|
|
||||||
|
|
||||||
let request = ShellRequest {
|
|
||||||
command: command.to_string(),
|
|
||||||
working_dir: Some(working_dir.clone()),
|
|
||||||
env: HashMap::new(),
|
|
||||||
timeout_secs: Some(timeout_secs),
|
|
||||||
shell_type: Default::default(),
|
|
||||||
};
|
|
||||||
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
|
|
||||||
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
|
|
||||||
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
|
|
||||||
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
|
|
||||||
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
|
|
||||||
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
|
|
||||||
let result = execute(request).await.map_err(|e| {
|
|
||||||
let msg = e.to_string();
|
|
||||||
if msg.contains("命令执行超时") {
|
|
||||||
anyhow::anyhow!(
|
|
||||||
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
|
|
||||||
timeout_secs
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
e
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
|
|
||||||
const MAX_OUT: usize = 10_000;
|
|
||||||
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
|
|
||||||
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
|
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
|
||||||
"command": command,
|
|
||||||
"working_dir": working_dir,
|
|
||||||
"exit_code": result.exit_code,
|
|
||||||
"duration_ms": result.duration_ms,
|
|
||||||
"stdout": stdout,
|
|
||||||
"stderr": stderr,
|
|
||||||
"truncated": stdout_trunc || stderr_trunc,
|
|
||||||
}))
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
|
|
||||||
// ── 文件系统 ──
|
// ── 文件系统 ──
|
||||||
registry.register(
|
registry.register(
|
||||||
@@ -1633,6 +1566,76 @@ fn register_file_tools(
|
|||||||
})
|
})
|
||||||
})},
|
})},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── 命令执行(run_command 放 File 层最后,降低 LLM 偏好:靠前注册易被 LLM 优先选中,
|
||||||
|
// 移到末位让 read_file/write_file 等高频文件工具在前,run_command 仅命令执行场景才该用) ──
|
||||||
|
registry.register(
|
||||||
|
"run_command", "在指定工作目录执行 shell 命令,返回 stdout/stderr/exit_code。仅用于命令执行场景:跑测试套件、构建项目、运行二进制/脚本验证行为。读取文件用 read_file,编辑文件用 patch_file/write_file,列目录用 list_directory,搜索文件名用 search_files——不要用本工具完成这些操作。高风险,须人工批准。命令需自包含(非交互式,避免需用户输入的程序)。默认超时 60 秒。",
|
||||||
|
df_ai::ai_tools::object_schema(vec![
|
||||||
|
("command", "string", true),
|
||||||
|
("working_dir", "string", false),
|
||||||
|
("timeout_secs", "integer", false),
|
||||||
|
]),
|
||||||
|
RiskLevel::High,
|
||||||
|
Box::new(|args: serde_json::Value| Box::pin(async move {
|
||||||
|
let command = args["command"].as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("缺少 command 参数"))?;
|
||||||
|
// working_dir 默认 workspace_root()(与 write_file 锚定一致:AI 写代码→同目录跑命令,闭环)。
|
||||||
|
// 走 validate_path 黑名单(.. + 敏感系统目录)作基础防线;不走 resolve_workspace_path 越界校验——
|
||||||
|
// run_command 是 High risk 靠人工审批兜底(用户在审批卡看清 command+working_dir),
|
||||||
|
// 放开目录才能让 AI 在用户任意项目目录形成「写→跑→看→改」真闭环。
|
||||||
|
let working_dir = match args.get("working_dir").and_then(|v| v.as_str()) {
|
||||||
|
Some(d) => {
|
||||||
|
validate_path(d)?;
|
||||||
|
d.to_string()
|
||||||
|
}
|
||||||
|
None => workspace_root().to_string_lossy().to_string(),
|
||||||
|
};
|
||||||
|
// timeout 默认 60s:防 hang(交互式命令/死循环/大构建),LLM 可通过 args timeout_secs 覆盖
|
||||||
|
// clamp 封顶 MAX:防 LLM 传超大 timeout_secs 冻结会话(需更长命令应拆分而非无限等)
|
||||||
|
let timeout_secs = args["timeout_secs"].as_u64().unwrap_or(DEFAULT_RUN_COMMAND_TIMEOUT_SECS).min(MAX_RUN_COMMAND_TIMEOUT_SECS);
|
||||||
|
|
||||||
|
let request = ShellRequest {
|
||||||
|
command: command.to_string(),
|
||||||
|
working_dir: Some(working_dir.clone()),
|
||||||
|
env: HashMap::new(),
|
||||||
|
timeout_secs: Some(timeout_secs),
|
||||||
|
shell_type: Default::default(),
|
||||||
|
};
|
||||||
|
// F-260616-04:超时标注——execute 超时返 Err("命令执行超时: N秒")。
|
||||||
|
// 原行为:该 Err 经 ? 上抛 → 人工审批路径(commands.rs ai_approve L256)把 e.to_string()
|
||||||
|
// 包成 tool_result 回传 LLM → LLM 误判命令失败而非超时 → 盲目重试同命令 →
|
||||||
|
// 新 tool_call_id → 重新 insert pending → 重新审批,「再过一会又提示 Run Command」循环。
|
||||||
|
// 治本:超时根因处拦截,把 Err 内容改写为「明确超时语义 + 勿盲目重试」标注,
|
||||||
|
// 让 LLM 知进程已终止、非命令失败,确需更长时限才在 args 提高 timeout_secs 重发。
|
||||||
|
let result = execute(request).await.map_err(|e| {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.contains("命令执行超时") {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"命令执行超时({}s),进程已终止。勿盲目重试同命令;确需更长时限重发时在 args 提高 timeout_secs。",
|
||||||
|
timeout_secs
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
e
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 输出截断:防编译输出/find//cat 大文件撑爆 LLM context(各 10KB,尾部保留-报错堆栈在末尾)
|
||||||
|
const MAX_OUT: usize = 10_000;
|
||||||
|
let (stdout, stdout_trunc) = truncate_output(&result.stdout, MAX_OUT);
|
||||||
|
let (stderr, stderr_trunc) = truncate_output(&result.stderr, MAX_OUT);
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"command": command,
|
||||||
|
"working_dir": working_dir,
|
||||||
|
"exit_code": result.exit_code,
|
||||||
|
"duration_ms": result.duration_ms,
|
||||||
|
"stdout": stdout,
|
||||||
|
"stderr": stderr,
|
||||||
|
"truncated": stdout_trunc || stderr_trunc,
|
||||||
|
}))
|
||||||
|
})),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
/// 递归列出目录内容(最多 max_depth 层,最多 max_entries 条)
|
||||||
@@ -1852,10 +1855,11 @@ mod tests {
|
|||||||
"run_workflow", "delete_task", "create_idea",
|
"run_workflow", "delete_task", "create_idea",
|
||||||
"delete_project", "restore_project", "purge_project",
|
"delete_project", "restore_project", "purge_project",
|
||||||
"list_trash", "get_project_count", "get_task_count",
|
"list_trash", "get_project_count", "get_task_count",
|
||||||
// ── file 层 (10) ──
|
// ── file 层 (10) ──(run_command 注册顺序已移至末位降低 LLM 偏好,
|
||||||
"run_command", "read_file", "list_directory",
|
// 集合断言经 sort 后与顺序无关,仅守护工具名不漂移)
|
||||||
"write_file", "patch_file", "file_info",
|
"read_file", "list_directory", "write_file",
|
||||||
"append_file", "delete_file", "rename_file", "search_files",
|
"patch_file", "file_info", "append_file",
|
||||||
|
"delete_file", "rename_file", "search_files", "run_command",
|
||||||
// ── http 层 (1) ──
|
// ── http 层 (1) ──
|
||||||
"http_request",
|
"http_request",
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -341,7 +341,11 @@ impl AllowedDirs {
|
|||||||
Self { persistent: set, session: HashSet::new() }
|
Self { persistent: set, session: HashSet::new() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 路径是否被授权:workspace_root 始终授权 + persistent 或 session 任一 starts_with 命中即放行。
|
/// 路径是否被授权:persistent 或 session 白名单任一 starts_with 命中即放行。
|
||||||
|
///
|
||||||
|
/// **workspace_root 默认在 persistent**(default_with_root 初始插入,reload_allowed_dirs
|
||||||
|
/// KV 未配时保持),故首次访问工程根免授权(向后兼容)。用户从白名单删除工程根后,
|
||||||
|
/// 工程根也需授权(动态白名单完整语义,F-260619-03 收尾:去掉代码硬编码固定放行)。
|
||||||
///
|
///
|
||||||
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
/// **Phase C 黑名单优先**:即使白名单命中,若路径落入系统敏感目录(Windows
|
||||||
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
/// `\Windows\System32` / `\Program Files\`;Unix `/etc /usr /bin /sbin /boot
|
||||||
@@ -355,10 +359,6 @@ impl AllowedDirs {
|
|||||||
if is_in_system_blacklist(candidate) {
|
if is_in_system_blacklist(candidate) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let root = workspace_root_path();
|
|
||||||
if candidate.starts_with(&root) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
self.persistent.iter().any(|d| candidate.starts_with(d))
|
self.persistent.iter().any(|d| candidate.starts_with(d))
|
||||||
|| self.session.iter().any(|d| candidate.starts_with(d))
|
|| self.session.iter().any(|d| candidate.starts_with(d))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user