diff --git a/crates/df-ai/src/intent.rs b/crates/df-ai/src/intent.rs index 603967d..535b094 100644 --- a/crates/df-ai/src/intent.rs +++ b/crates/df-ai/src/intent.rs @@ -120,8 +120,9 @@ const DEBUG_RULES: &[Rule] = &[ Rule { kw: "栈", weight: 0.5 }, Rule { kw: "panic", weight: 0.9 }, Rule { kw: "traceback", weight: 0.9 }, - // 命令执行场景关键词(run_command 入口引导):用户明确"运行/执行/构建/测试/跑" - // 才该走 Exec domain(run_command)。中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。 + // 命令执行场景关键词:用户明确"运行/执行/构建/测试/跑"时归 Debug 意图(影响档位路由与场景增量)。 + // run_command 已入基本盘全工作意图可见,这里不再承担工具暴露职责。 + // 中等权重(0.7)避免误抢 Code 命中(编译/重构 1.0)。 Rule { kw: "运行", weight: 0.7 }, Rule { kw: "执行", weight: 0.7 }, Rule { kw: "构建", weight: 0.7 }, @@ -235,13 +236,14 @@ pub enum ToolDomain { Data, /// 数据只读子集:list_*/get_*_count(不含 create/update/delete/advance/run_workflow/restore/purge/bind_directory) /// - /// 用途:`Intent::Code` 收敛工具时,既保留编码场景用户常需要的"看一下项目/任务结构"只读 - /// 探索工具(list_projects/list_tasks/list_ideas/list_trash),又不放大暴露面(不暴露写工具, - /// 对齐 `Intent::Code` 不含 `Intent::Debug` 才有的 run_command 那类收紧设计)。 + /// 用途:`Intent::Code` 的场景增量:保留编码场景用户常需要的"看一下项目/任务结构"只读 + /// 探索工具(list_projects/list_tasks/list_ideas/list_trash),不放大写工具暴露面 + /// (写工具属 Data 域,留给 Project/Task/Idea 的场景增量)。 DataReadOnly, - /// 文件:读写/patch/列目录/搜索(不含命令执行) + /// 文件:读写/patch/列目录/搜索。基本盘成员,所有工作意图可见 File, - /// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出) + /// 命令执行:run_command(shell 命令)。基本盘成员:读写执行为 agent 基本能力 + /// (2026-09-30 用户拍板,不受意图收敛限制),安全由执行层 validate_path/授权/RiskLevel 兜底 Exec, /// 网络:HTTP Http, @@ -282,9 +284,9 @@ impl ToolDomain { "delete_file", "rename_file", ], - // Exec domain 独立:run_command 从 File 移出。 - // 意图侧仅 Debug 默认带 Exec(用户明确"运行/测试/构建"), - // Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。 + // Exec domain 独立于 File:run_command 单列,供基本盘/场景增量按意图组合。 + // 2026-09-30 用户拍板读写执行为 agent 基本能力,不再仅 Debug 暴露; + // 安全由执行层兜底(RiskLevel 审批/授权三道门/路径黑名单),藏工具≠安全。 ToolDomain::Exec => &["run_command"], ToolDomain::Http => &["http_request"], // DataReadOnly:Data domain 的只读子集。 @@ -434,36 +436,36 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)> /// 按 Intent 返回工具名子集。 /// -/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown), +/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat/Conversation)或**未识别**(Unknown), /// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。 /// -/// 设计:Code → [file, http, data_readonly](加 data_readonly:编码场景用户常需"先 list_projects -/// 看下结构/list_tasks 记一下",Code 命中优先级高于 Project/Task(SPECIFIC > ENTITY)会砍 Data domain, -/// 取只读子集防"断手"又不放大写工具暴露面,见 ToolDomain::DataReadOnly); -/// File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1); -/// Http → [http];Search → [file](含 search_files);Conversation → []; -/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具); -/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command), -/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;Unknown → [](全量)。 +/// 设计(2026-09-30 起):基本盘 + 场景增量。 +/// 基本盘 = File + Exec:读、写、执行命令是 agent 基本能力,不受意图收敛限制(用户拍板), +/// 所有非空子集意图(Code/Debug/File/Project/Task/Idea/Search/Http)都可见。 +/// 安全防线在执行层:validate_path 黑名单 + 授权三道门 + RiskLevel 审批,藏工具≠安全; +/// 藏工具只会复现断手(Project 砍 File 致 read_file 不可见、Debug 独占 run_command 致 +/// "查看服务器状态"被回"未挂载",均属此类,本次根治)。 +/// 场景增量 = 业务域收敛,减少 LLM 分心:Code 加 Http+DataReadOnly;Debug 加 Http+Data +/// (调试常需查 API+查任务/工作流状态,CR-25 加 data 防"调试任务"丢 Data 工具); +/// Project/Task/Idea 加 Data;File/Search 仅基本盘;Http 保留自身 Http 域 +/// (发请求是该意图本名能力,砍掉即复现断手)。 +/// 各意图新子集均为旧子集超集,只增不减。 pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> { - let domains: &[ToolDomain] = match intent { - Intent::Code => &[ToolDomain::File, ToolDomain::Http, ToolDomain::DataReadOnly], - Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data], - Intent::File => &[ToolDomain::File], - // Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查 - // (实测会话 [项目:x] 报错 → Project → 砍 File 致 read_file 对 LLM 不可见)。 - // 保留只读探索;写工具有 RiskLevel/审批兜底,"分心"远好于"断手"。 - Intent::Project => &[ToolDomain::Data, ToolDomain::File], - Intent::Task => &[ToolDomain::Data, ToolDomain::File], - Intent::Idea => &[ToolDomain::Data, ToolDomain::File], - Intent::Conversation => &[], - Intent::Search => &[ToolDomain::File], + // 基本盘:读写执行(RiskLevel/审批/路径授权兜底,藏工具≠安全);场景增量=业务域收敛减少分心 + const BASE_DOMAINS: &[ToolDomain] = &[ToolDomain::File, ToolDomain::Exec]; + let extra: &[ToolDomain] = match intent { + Intent::Code => &[ToolDomain::Http, ToolDomain::DataReadOnly], + Intent::Debug => &[ToolDomain::Http, ToolDomain::Data], + // 提项目/任务/想法常伴编码排查,业务域全量保留 + // (写工具有 RiskLevel/审批兜底,"分心"远好于"断手")。 + Intent::Project | Intent::Task | Intent::Idea => &[ToolDomain::Data], Intent::Http => &[ToolDomain::Http], - Intent::Chat => &[], - Intent::Unknown => &[], + Intent::File | Intent::Search => &[], + // 空子集语义 = 上游全量 fallback,不受基本盘改造影响,行为不变 + Intent::Conversation | Intent::Chat | Intent::Unknown => return Vec::new(), }; let mut out: Vec<&'static str> = Vec::new(); - for d in domains { + for d in BASE_DOMAINS.iter().chain(extra.iter()) { for name in d.tools() { if !out.contains(name) { out.push(name); @@ -906,10 +908,12 @@ mod tests { } #[test] - fn subset_file_only_file_domain() { + fn subset_file_is_base_without_business_domains() { + // File 仅基本盘:读写+命令执行可见,Http/Data 业务域不暴露 let s = tool_subset_for(&Intent::File); assert!(s.contains(&"read_file")); assert!(s.contains(&"search_files")); + assert!(s.contains(&"run_command"), "File 意图应含 run_command(基本盘)"); assert!(!s.contains(&"http_request")); assert!(!s.contains(&"list_projects")); } @@ -939,9 +943,13 @@ mod tests { } #[test] - fn subset_http_only_http() { + fn subset_http_keeps_http_plus_base() { + // Http 意图:基本盘(读写执行)+ 自身 Http 域,较旧版(仅 http_request)只增不减 let s = tool_subset_for(&Intent::Http); - assert_eq!(s, vec!["http_request"]); + assert!(s.contains(&"http_request")); + assert!(s.contains(&"read_file")); + assert!(s.contains(&"run_command"), "Http 意图应含 run_command(基本盘)"); + assert!(!s.contains(&"list_projects"), "Http 意图不加 Data 场景增量"); } #[test] @@ -987,7 +995,7 @@ mod tests { fn tool_domain_names_match_registry() { // 抽样核对与 src-tauri tool_registry 实际注册名一致 assert!(ToolDomain::File.tools().contains(&"patch_file")); - // run_command 已从 File 移出 Exec domain(意图收紧:仅 Debug 默认带 run_command) + // run_command 归 Exec domain(域归属未变;子集组合已改基本盘,全工作意图可见) assert!(!ToolDomain::File.tools().contains(&"run_command")); assert_eq!(ToolDomain::Exec.tools(), &["run_command"]); assert!(ToolDomain::Data.tools().contains(&"advance_task")); @@ -1080,7 +1088,7 @@ mod tests { #[test] fn filter_subset_hit_filters_to_matching() { - // File 意图 subset = File domain(read_file/write_file/...);Http 工具应被滤掉 + // File 意图 subset = 基本盘(File+Exec),不含 Http 域;http_request 应被滤掉 let all = vec![ tool_def("read_file"), tool_def("write_file"), @@ -1099,7 +1107,7 @@ mod tests { #[test] fn filter_subset_drift_skips_missing_names() { // subset 命中但 all_defs 里没有对应工具(registry 漂移)→ 跳过,不 panic - // Project 意图 subset = Data domain(create_project/...),all 里只放了一个 Data 工具 + 一个无关工具 + // Project 意图 subset = Data+基本盘,create_project 命中;http_request 不在 subset,滤掉 let all = vec![ tool_def("create_project"), // 命中 tool_def("http_request"), // 不在 Data domain,滤掉 @@ -1197,21 +1205,32 @@ mod tests { } #[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] { + fn run_command_exposed_for_all_work_intents() { + // 2026-09-30 用户拍板:读写执行是 agent 基本能力,不受意图收敛限制。 + // 所有非空子集意图都经基本盘含 run_command; + // 安全由执行层兜底(validate_path/授权/RiskLevel),藏工具≠安全。 + for intent in [ + Intent::Code, + Intent::Debug, + Intent::File, + Intent::Project, + Intent::Task, + Intent::Idea, + Intent::Search, + Intent::Http, + ] { let s = tool_subset_for(&intent); assert!( - !s.contains(&"run_command"), - "Intent {:?} subset 不应含 run_command(仅 Debug 暴露), 实际: {:?}", + s.contains(&"run_command"), + "Intent {:?} subset 应含 run_command(基本盘), 实际: {:?}", intent, s ); } - // Debug 必含 run_command(Exec domain) - let dbg = tool_subset_for(&Intent::Debug); - assert!(dbg.contains(&"run_command"), "Debug 必含 run_command"); + // 空子集意图不受影响(上游语义=全量,天然含全部工具) + assert!(tool_subset_for(&Intent::Chat).is_empty()); + assert!(tool_subset_for(&Intent::Unknown).is_empty()); + assert!(tool_subset_for(&Intent::Conversation).is_empty()); } #[test] @@ -1287,8 +1306,7 @@ mod tests { #[test] fn subset_code_and_debug_dedup_http_across_domains() { - // 边界:Code = [File, Http], Debug = [File, Http, Data] - // File + Http 都含 http_request,必须去重(subset 内不重复) + // Code/Debug 均含 Http 场景增量,叠加基本盘后 subset 内工具名必须去重 for intent in [Intent::Code, Intent::Debug] { let s = tool_subset_for(&intent); let http_count = s.iter().filter(|n| **n == "http_request").count(); @@ -1345,7 +1363,7 @@ mod tests { tool_def("append_file"), tool_def("delete_file"), tool_def("rename_file"), - tool_def("http_request"), // 不在 File domain,应被滤掉 + tool_def("http_request"), // 不在 File 意图子集,应被滤掉 ]; let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它"); let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect(); diff --git a/crates/df-ai/src/model_fetch_helpers.rs b/crates/df-ai/src/model_fetch_helpers.rs index 1e2972d..2051a9f 100644 --- a/crates/df-ai/src/model_fetch_helpers.rs +++ b/crates/df-ai/src/model_fetch_helpers.rs @@ -239,7 +239,7 @@ mod tests { // SenseNova U1 系列是图像生成模型(走 /v1/images/generations,非 chat completions), // /v1/models 会返回但不应留为 chat 模型,否则用户选它对话会失败。 assert!(is_non_chat_model("sensenova-u1-fast")); - assert!(is_non_chat_model("SenseNova-ast")); // 大小写无关 + assert!(is_non_chat_model("SenseNova-U1-Fast")); // 大小写无关 assert!(is_non_chat_model("sensenova-u1-pro")); // 通用图像生成命名也剔 assert!(is_non_chat_model("infographic-v1"));