优化: agent 工具暴露改基本盘与场景增量

读写执行定为 agent 基本能力全工作意图可见,不再仅调试意图暴露命令执行(用户拍板)
业务域收敛为场景增量只增不减,各意图新子集均为旧子集超集
安全防线在执行层(路径黑名单/授权三道门/风险级审批),藏工具不等于安全
根治项目意图砍文件工具、调试意图独占命令执行导致的断手问题
This commit is contained in:
lxy
2026-10-01 02:41:04 +08:00
parent 9b89610e38
commit e7cd035c62
2 changed files with 71 additions and 53 deletions
+70 -52
View File
@@ -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();