diff --git a/README.md b/README.md index ddf4c34..19b3a42 100644 --- a/README.md +++ b/README.md @@ -498,7 +498,7 @@ tools 配置示例: - shell - 执行 shell 命令(Windows PowerShell/Cmd) - http_request - HTTP 请求 - web_fetch - 网页抓取 -- task - 创建和管理子代理,支持内置类型(general/explore)和用户自定义类型 +- task - 创建和管理子代理,支持内置类型(general)和用户自定义类型 注意:bash 和 shell 是同一个工具在不同平台上的名称,运行时自动检测。 @@ -509,7 +509,8 @@ PicoBot 支持通过 `task` 工具创建子代理来处理复杂多步骤任务 ### 8.1 内置子代理类型 - **general**:通用型子代理,适合处理复杂多步骤任务。可以使用读写文件、执行命令、HTTP 请求等完整工具集。 -- **explore**:探索型子代理,用于代码库探索和信息收集。只使用只读工具,禁止任何写操作。 + +> 如需只读探索型子代理,可通过自定义子代理配合 `allowed_tools` 白名单实现(见下文)。 ### 8.2 自定义子代理 @@ -538,7 +539,8 @@ prompt_template: | 3. 完成后给出简洁的总结 注意: 你是一个只读代理,禁止执行任何修改操作。 -allowed_tools: [read, bash, web_fetch] # 可选,覆盖默认工具白名单 +allowed_tools: [read, bash, web_fetch] # 可选,工具白名单(仅这些工具可用) +denied_tools: [task] # 可选,工具黑名单(这些工具被禁用) max_execution_secs: 600 # 可选,覆盖默认执行时间 --- @@ -555,9 +557,12 @@ max_execution_secs: 600 # 可选,覆盖默认执行时间 | `name` | string | 否 | 子代理名称,默认取目录名 | | `description` | string | 是 | 简短描述,用于 agent 选择 | | `prompt_template` | string | 是 | 提示词模板,支持变量插值 | -| `allowed_tools` | array | 否 | 工具白名单,不指定时使用默认列表 | +| `allowed_tools` | array | 否 | 工具白名单,指定后仅这些工具可用;不指定则不限制 | +| `denied_tools` | array | 否 | 工具黑名单,指定后这些工具被禁用;在白名单之后应用 | | `max_execution_secs` | integer | 否 | 最大执行时间(秒) | +> **工具过滤语义**:`allowed_tools` 与 `denied_tools` 可共存。生效顺序为:先应用白名单(取交集),再扣除黑名单。两者都不指定时,子代理使用完整工具集。当子代理嵌套深度达到 `max_nesting_depth`(默认 2,即孙代理)时,始终移除 `task` 工具以防无限嵌套。白名单中未注册的工具名会被静默跳过。 + #### 模板变量 `prompt_template` 支持以下变量插值: @@ -615,7 +620,7 @@ PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具 - bash / shell:执行 shell 命令(同一工具,Unix 下名称为 bash,Windows 下名称为 shell) - http_request:发起 HTTP 请求 - web_fetch:抓取网页正文 -- task:创建和管理子代理,支持内置类型(general/explore)和用户自定义类型 +- task:创建和管理子代理,支持内置类型(general)和用户自定义类型 其中: @@ -625,7 +630,7 @@ PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具 - skill_activate 负责把具体技能正文注入当前任务上下文 - skill_manage 整合了技能列出与管理功能,支持运行时创建、更新、删除和批量禁用 - bash / shell / http_request / web_fetch 让 Agent 具备更强的外部交互能力(bash 和 shell 是同一工具在不同平台的名称) -- task 允许 Agent 创建独立上下文的子代理来处理复杂多步骤任务,支持内置类型(general/explore)和用户自定义类型 +- task 允许 Agent 创建独立上下文的子代理来处理复杂多步骤任务,支持内置类型(general)和用户自定义类型 ### 9.1 MCP 工具集成 diff --git a/src/config/mod.rs b/src/config/mod.rs index e50e58c..44b0369 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -248,8 +248,6 @@ pub struct TaskConfig { pub enabled: bool, #[serde(default = "default_task_max_execution_secs")] pub max_execution_secs: u64, - #[serde(default = "default_task_explore_max_execution_secs")] - pub explore_max_execution_secs: u64, #[serde(default = "default_task_ttl_hours")] pub ttl_hours: u64, #[serde(default = "default_task_allowed_tools")] @@ -266,10 +264,6 @@ fn default_task_max_execution_secs() -> u64 { 3600 // 60分钟 } -fn default_task_explore_max_execution_secs() -> u64 { - 3600 // 60分钟 -} - fn default_task_ttl_hours() -> u64 { 24 } @@ -300,7 +294,6 @@ impl Default for TaskConfig { Self { enabled: default_task_enabled(), max_execution_secs: default_task_max_execution_secs(), - explore_max_execution_secs: default_task_explore_max_execution_secs(), ttl_hours: default_task_ttl_hours(), allowed_tools: default_task_allowed_tools(), max_nesting_depth: default_task_max_nesting_depth(), diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index e62ed79..096e57a 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -201,7 +201,6 @@ pub(crate) fn build_session_manager_with_sender( let runtime_config = SubAgentRuntimeConfig { default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(), default_max_execution_secs: task_config.max_execution_secs, - explore_max_execution_secs: task_config.explore_max_execution_secs, ttl_hours: task_config.ttl_hours, skills_index: skills.system_index_prompt(), max_nesting_depth: task_config.max_nesting_depth, diff --git a/src/tools/registry.rs b/src/tools/registry.rs index e2dc7f3..778a457 100644 --- a/src/tools/registry.rs +++ b/src/tools/registry.rs @@ -87,6 +87,21 @@ impl ToolRegistry { *new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered; new_registry } + + /// 创建一个仅包含指定工具的新 registry 副本(白名单)。 + /// include 中不存在于当前 registry 的名称会被静默跳过(取交集语义)。 + pub fn only(&self, include: &[&str]) -> Self { + let include_set: std::collections::HashSet<&str> = include.iter().copied().collect(); + let tools = self.tools.read().expect("ToolRegistry lock poisoned"); + let filtered: HashMap> = tools + .iter() + .filter(|(name, _)| include_set.contains(name.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + let new_registry = ToolRegistry::new(); + *new_registry.tools.write().expect("ToolRegistry lock poisoned") = filtered; + new_registry + } } impl Default for ToolRegistry { @@ -94,3 +109,82 @@ impl Default for ToolRegistry { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::traits::ToolResult; + use async_trait::async_trait; + + /// 仅用于测试的占位工具,按构造名注册 + struct FakeTool { + tool_name: String, + } + + #[async_trait] + impl ToolTrait for FakeTool { + fn name(&self) -> &str { + &self.tool_name + } + fn description(&self) -> &str { + "fake" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({}) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult { + success: true, + output: String::new(), + error: None, + }) + } + } + + fn registry_with(names: &[&str]) -> ToolRegistry { + let reg = ToolRegistry::new(); + for n in names { + reg.register(FakeTool { + tool_name: n.to_string(), + }); + } + reg + } + + fn sorted_names(reg: &ToolRegistry) -> Vec { + let mut v = reg.tool_names(); + v.sort(); + v + } + + #[test] + fn only_keeps_listed_tools() { + let reg = registry_with(&["read", "edit", "write", "bash"]); + let filtered = reg.only(&["read", "bash"]); + assert_eq!(sorted_names(&filtered), vec!["bash", "read"]); + } + + #[test] + fn only_silently_skips_missing_names() { + let reg = registry_with(&["read", "edit"]); + let filtered = reg.only(&["read", "nonexistent", "glob"]); + assert_eq!(sorted_names(&filtered), vec!["read"]); + } + + #[test] + fn only_with_empty_include_returns_empty() { + let reg = registry_with(&["read", "edit"]); + let filtered = reg.only(&[]); + assert!(filtered.tool_names().is_empty()); + } + + #[test] + fn only_does_not_mutate_source() { + let reg = registry_with(&["read", "edit", "write"]); + let _ = reg.only(&["read"]); + // 源 registry 不受影响 + let mut v = reg.tool_names(); + v.sort(); + assert_eq!(v, vec!["edit", "read", "write"]); + } +} diff --git a/src/tools/task/prompt.rs b/src/tools/task/prompt.rs index cb10a5d..919b6c9 100644 --- a/src/tools/task/prompt.rs +++ b/src/tools/task/prompt.rs @@ -102,6 +102,7 @@ mod tests { prompt_template: "任务: {{description}}\n指令: {{prompt}}".to_string(), body: None, allowed_tools: None, + denied_tools: None, max_execution_secs: None, source: SubagentSource::Builtin, path: None, diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index de74b2c..2daae49 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -29,8 +29,6 @@ pub struct SubAgentRuntimeConfig { pub default_allowed_tools: HashSet, /// 默认最大执行时间(秒) pub default_max_execution_secs: u64, - /// Explore 类型的最大执行时间(秒) - pub explore_max_execution_secs: u64, /// 任务 TTL(小时) pub ttl_hours: u64, /// 技能索引(可选,预生成的技能列表字符串) @@ -57,7 +55,6 @@ impl Default for SubAgentRuntimeConfig { "send_session_message".to_string(), // 用于进度通知 ]), default_max_execution_secs: 3600, // 60分钟 - explore_max_execution_secs: 3600, // 60分钟 ttl_hours: 24, skills_index: None, max_nesting_depth: 1, @@ -394,23 +391,80 @@ impl DefaultSubAgentRuntime { .unwrap_or(self.config.default_max_execution_secs) } + /// 根据 def 与嵌套深度构建子代理工具集。 + /// 过滤顺序:base → allowed_tools 白名单 → denied_tools 黑名单 + depth 达到上限移除 task。 + /// - `allowed_tools` 为 Some 时取交集(白名单),None 表示不限制。 + /// - `denied_tools` 为 Some 时扣除(黑名单),在白名单之后应用。 + /// - 当 child_depth >= max_nesting_depth 时移除 task 工具(防无限嵌套的安全兜底, + /// 不可被 def 覆盖)。默认 max_nesting_depth=2,即孙代理(depth=2)无法再创建子代理。 + fn build_subagent_tools_registry( + &self, + def: Option<&SubagentDef>, + child_depth: u32, + ) -> Arc { + let depth_deny_task = child_depth >= self.config.max_nesting_depth; + let allowed: Option<&Vec> = def.and_then(|d| d.allowed_tools.as_ref()); + let denied_tools: Option<&Vec> = def.and_then(|d| d.denied_tools.as_ref()); + + // 快速路径:无白名单、无黑名单、无需 depth 兜底 → 直接复用 Arc(避免拷贝) + if allowed.is_none() && denied_tools.is_none() && !depth_deny_task { + return self.subagent_tools.clone(); + } + + Arc::new(Self::filter_tool_registry( + &self.subagent_tools, + allowed, + denied_tools, + depth_deny_task, + )) + } + + /// 纯函数:在 base 之上应用白名单/黑名单/depth 规则。 + /// 抽取为关联函数便于单元测试(无需构造整个 DefaultSubAgentRuntime)。 + fn filter_tool_registry( + base: &ToolRegistry, + allowed: Option<&Vec>, + denied_tools: Option<&Vec>, + depth_deny_task: bool, + ) -> ToolRegistry { + // 1. 应用白名单(若存在),否则取得 owned 副本以便后续黑名单过滤 + let tools: ToolRegistry = match allowed { + Some(list) => { + let refs: Vec<&str> = list.iter().map(|s| s.as_str()).collect(); + base.only(&refs) + } + None => base.without(&[]), + }; + + // 2. 合并黑名单(depth 规则 + denied_tools) + let mut denied: Vec<&str> = Vec::new(); + if depth_deny_task { + denied.push(TaskTool::TOOL_NAME); + } + if let Some(dt) = denied_tools { + denied.extend(dt.iter().map(|s| s.as_str())); + } + + if denied.is_empty() { + tools + } else { + tools.without(&denied) + } + } + /// 创建子代理实例 fn create_subagent( &self, session: &TaskSession, system_prompt: String, + def: Option<&SubagentDef>, parent_nesting_depth: u32, parent_task_id: Option, ) -> Result { let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt)); - // 孙智能体(depth >= 2)不注册 task 工具,防止无限嵌套 let child_depth = parent_nesting_depth + 1; - let tools = if child_depth >= 2 { - Arc::new(self.subagent_tools.without(&[TaskTool::TOOL_NAME])) - } else { - self.subagent_tools.clone() - }; + let tools = self.build_subagent_tools_registry(def, child_depth); AgentLoop::with_tools_and_system_prompt_provider( AgentRuntimeConfig::from(self.provider_config.clone()), @@ -481,11 +535,7 @@ impl DefaultSubAgentRuntime { }; // 设置超时 - let max_secs = if session.subagent_type == "explore" { - self.config.explore_max_execution_secs - } else { - self.effective_max_execution_secs(def) - }; + let max_secs = self.effective_max_execution_secs(def); let timeout_duration = Duration::from_secs(max_secs); let result = tokio::time::timeout( @@ -663,7 +713,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { ); // 7. 创建子代理 - let agent = self.create_subagent(&session, system_prompt, parent_context.nesting_depth, parent_context.task_id.clone())?; + let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?; // 8. 执行任务 let result = self @@ -747,8 +797,16 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { &additional_prompt, ); + // 4.1 重新解析 def 以应用工具过滤。 + // 安全要求:def 被删除/禁用时必须失败恢复,而不是降级为完整工具集—— + // 否则一个受限子代理(如 allowed_tools: [read])在 def 失踪后会获得全部工具, + // 构成权限提升。与 spawn 保持一致:def 不可用即拒绝执行。 + let def = self + .find_subagent_def(&session.subagent_type) + .map_err(TaskError::InvalidArguments)?; + // 5. 创建子代理 - let agent = self.create_subagent(&session, system_prompt, parent_context.nesting_depth, parent_context.task_id.clone())?; + let agent = self.create_subagent(&session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone())?; // 6. 使用历史继续执行 let result = self @@ -809,7 +867,6 @@ impl SubagentCatalog { pub fn new() -> Self { let mut catalog = Self::default(); catalog.register(SubagentDef::builtin_general()); - catalog.register(SubagentDef::builtin_explore()); catalog } @@ -826,7 +883,6 @@ impl SubagentCatalog { // 先内置作为基础 let mut merged: std::collections::HashMap = std::collections::HashMap::new(); merged.insert("general".to_string(), SubagentDef::builtin_general()); - merged.insert("explore".to_string(), SubagentDef::builtin_explore()); tracing::debug!(cwd = %cwd.display(), "Discovering subagents from cwd"); @@ -960,6 +1016,12 @@ pub struct SubagentWithStatus { pub source: String, /// Which scopes have this subagent disabled. Empty means enabled. pub disabled_in_scopes: Vec, + /// 工具白名单(None 表示不过滤) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option>, + /// 工具黑名单(None 表示不过滤) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub denied_tools: Option>, } #[derive(Debug, Clone)] @@ -1105,6 +1167,8 @@ impl SubagentRuntime { description: def.description.clone(), source: def.source.as_str().to_string(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), + allowed_tools: def.allowed_tools.clone(), + denied_tools: def.denied_tools.clone(), } }) .collect(); @@ -1341,6 +1405,8 @@ struct SubagentFrontmatter { #[serde(default)] allowed_tools: Option>, #[serde(default)] + denied_tools: Option>, + #[serde(default)] max_execution_secs: Option, } @@ -1428,6 +1494,7 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Resultgeneral")); - assert!(prompt.contains("explore")); + // 禁用后 prompt 不应包含 general(无可用子代理时返回 None) + let prompt = runtime.system_index_prompt_filtered(); + assert!(prompt.map_or(true, |p| !p.contains("general"))); } #[test] @@ -1556,8 +1622,8 @@ mod tests { runtime .disable_subagent(SubagentScope::Project, "general") .unwrap(); - let prompt = runtime.system_index_prompt_filtered().unwrap(); - assert!(!prompt.contains("general")); + // 无可用子代理时返回 None + assert!(runtime.system_index_prompt_filtered().is_none()); let change = runtime .enable_subagent(SubagentScope::Project, "general") @@ -1586,10 +1652,6 @@ mod tests { assert!(general .disabled_in_scopes .contains(&"project".to_string())); - - // explore 应仍启用 - let explore = items.iter().find(|i| i.name == "explore").unwrap(); - assert!(explore.disabled_in_scopes.is_empty()); } #[test] @@ -1605,12 +1667,10 @@ mod tests { .unwrap(); assert!(runtime.find_available("general").is_none()); - assert!(runtime.find_available("explore").is_some()); // available_names 不应包含 general let names = runtime.available_names(); assert!(!names.contains(&"general".to_string())); - assert!(names.contains(&"explore".to_string())); } #[test] @@ -1626,4 +1686,217 @@ mod tests { .unwrap_err(); assert!(err.contains("not found")); } + + // ===== 工具过滤(allowed_tools / denied_tools)测试 ===== + + use crate::tools::traits::{Tool as ToolTrait, ToolResult}; + + /// 占位工具,按构造名注册 + struct FakeTool { + tool_name: String, + } + + #[async_trait::async_trait] + impl ToolTrait for FakeTool { + fn name(&self) -> &str { + &self.tool_name + } + fn description(&self) -> &str { + "fake" + } + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({}) + } + async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { + Ok(ToolResult { + success: true, + output: String::new(), + error: None, + }) + } + } + + fn base_registry() -> ToolRegistry { + let reg = ToolRegistry::new(); + for name in &["read", "edit", "write", "bash", "task"] { + reg.register(FakeTool { + tool_name: name.to_string(), + }); + } + reg + } + + fn sorted_names(reg: &ToolRegistry) -> Vec { + let mut v = reg.tool_names(); + v.sort(); + v + } + + /// 把 &str 切片转为 Some(Vec),便于构造过滤参数 + fn s(v: &[&str]) -> Option> { + Some(v.iter().map(|x| x.to_string()).collect()) + } + + #[test] + fn filter_no_restriction_returns_all() { + let base = base_registry(); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, None, None, false); + assert_eq!( + sorted_names(®), + vec!["bash", "edit", "read", "task", "write"] + ); + } + + #[test] + fn filter_depth_deny_task_removes_task() { + let base = base_registry(); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, None, None, true); + assert_eq!( + sorted_names(®), + vec!["bash", "edit", "read", "write"] + ); + } + + #[test] + fn filter_whitelist_keeps_only_listed() { + let base = base_registry(); + let allowed = s(&["read", "bash"]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, false); + assert_eq!(sorted_names(®), vec!["bash", "read"]); + } + + #[test] + fn filter_whitelist_skips_missing_names() { + let base = base_registry(); + // 包含未注册的工具名应被静默跳过 + let allowed = s(&["read", "nonexistent", "glob"]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, false); + assert_eq!(sorted_names(®), vec!["read"]); + } + + #[test] + fn filter_blacklist_removes_listed() { + let base = base_registry(); + let denied = s(&["bash", "task"]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, None, denied.as_ref(), false); + assert_eq!(sorted_names(®), vec!["edit", "read", "write"]); + } + + #[test] + fn filter_whitelist_then_blacklist() { + let base = base_registry(); + let allowed = s(&["read", "bash"]); + let denied = s(&["bash"]); + let reg = DefaultSubAgentRuntime::filter_tool_registry( + &base, + allowed.as_ref(), + denied.as_ref(), + false, + ); + // 白名单留下 read+bash,黑名单再扣除 bash + assert_eq!(sorted_names(®), vec!["read"]); + } + + #[test] + fn filter_empty_whitelist_yields_empty() { + let base = base_registry(); + let allowed = s(&[]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, false); + assert!(reg.tool_names().is_empty()); + } + + #[test] + fn filter_depth_rule_overrides_whitelist_task() { + let base = base_registry(); + // 白名单显式包含 task,但 depth≥2 安全兜底仍应移除它 + let allowed = s(&["read", "task"]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, true); + assert_eq!(sorted_names(®), vec!["read"]); + } + + // ===== frontmatter 解析(denied_tools)测试 ===== + + #[test] + fn split_frontmatter_extracts_yaml_and_body() { + let content = "---\nname: x\ndescription: y\n---\nbody text"; + let (fm, body) = split_frontmatter(content).expect("should split"); + assert!(fm.contains("description: y")); + assert_eq!(body, "body text"); + } + + #[test] + fn parse_subagent_file_reads_denied_tools() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("SUBAGENT.md"); + std::fs::write( + &path, + "---\n\ + name: sandbox\n\ + description: sandbox agent\n\ + allowed_tools: [read, todo_write]\n\ + denied_tools: [bash, task]\n\ + ---\n\ + body instructions", + ) + .unwrap(); + + let def = parse_subagent_file(&path, SubagentSource::Project).unwrap(); + assert_eq!(def.name, "sandbox"); + assert_eq!( + def.allowed_tools.as_deref(), + Some(["read".to_string(), "todo_write".to_string()].as_slice()) + ); + assert_eq!( + def.denied_tools.as_deref(), + Some(["bash".to_string(), "task".to_string()].as_slice()) + ); + } + + #[test] + fn parse_subagent_file_denied_tools_default_none_when_absent() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("SUBAGENT.md"); + std::fs::write( + &path, + "---\nname: basic\ndescription: basic agent\n---\nbody", + ) + .unwrap(); + + let def = parse_subagent_file(&path, SubagentSource::User).unwrap(); + assert!(def.allowed_tools.is_none()); + assert!(def.denied_tools.is_none()); + } + + #[test] + fn list_with_status_projects_tool_fields() { + let temp = tempfile::tempdir().unwrap(); + let mut catalog = SubagentCatalog::new(); + catalog.register(SubagentDef { + name: "sandbox".to_string(), + description: "sandbox agent".to_string(), + prompt_template: String::new(), + body: None, + allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]), + denied_tools: Some(vec!["bash".to_string()]), + max_execution_secs: None, + source: SubagentSource::Builtin, + path: None, + }); + let runtime = SubagentRuntime::new( + SubagentsConfig::default(), + Arc::new(catalog), + temp.path().to_path_buf(), + ); + + let items = runtime.list_with_status(); + let item = items.iter().find(|i| i.name == "sandbox").unwrap(); + assert_eq!( + item.allowed_tools.as_deref(), + Some(["read".to_string(), "todo_write".to_string()].as_slice()) + ); + assert_eq!( + item.denied_tools.as_deref(), + Some(["bash".to_string()].as_slice()) + ); + } } diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index 0b8f76e..103a5a0 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -60,8 +60,10 @@ pub struct SubagentDef { pub prompt_template: String, /// 可选的详细指令(body 部分) pub body: Option, - /// 工具白名单(None 表示使用默认) + /// 工具白名单(None 表示不过滤,Some 时仅这些工具可用) pub allowed_tools: Option>, + /// 工具黑名单(None 表示不过滤,Some 时这些工具被禁用;在白名单之后应用) + pub denied_tools: Option>, /// 最大执行时间(秒),None 表示使用默认 pub max_execution_secs: Option, /// 来源 @@ -79,20 +81,7 @@ impl SubagentDef { prompt_template: "你是一个专注的子代理,正在执行一个独立任务。\n\n任务描述: {{description}}\n\n你应该:\n1. 专注于完成任务,不要偏离目标\n2. 使用可用的工具进行必要操作\n3. 完成后给出简洁的总结\n4. 不要尝试创建新的子代理任务\n\n任务追踪:\n你可以使用 `todo_write` 工具追踪子任务进度。规则:同一时间只有一个 in_progress,完成后再标记下一个,3步以上才使用。\n\n注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。".to_string(), body: None, allowed_tools: None, - max_execution_secs: None, - source: SubagentSource::Builtin, - path: None, - } - } - - /// 创建内置 explore 子代理定义 - pub fn builtin_explore() -> Self { - Self { - name: "explore".to_string(), - description: "探索型子代理 - 只读搜索代理".to_string(), - prompt_template: "你是一个只读探索代理,用于代码库探索和信息收集。\n\n任务描述: {{description}}\n\n你应该:\n1. 只使用只读工具进行探索\n2. 专注于理解和收集信息\n3. 不要进行任何写操作\n4. 给出简洁的发现总结\n\n注意: 你是一个只读代理,禁止执行任何修改操作。".to_string(), - body: None, - allowed_tools: None, + denied_tools: None, max_execution_secs: None, source: SubagentSource::Builtin, path: None, diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index cdc21ae..3b72a9e 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -526,7 +526,6 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
启用 Task 工具 update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} />
update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /> - update('tools', { ...config.tools, task: { ...config.tools.task, explore_max_execution_secs: +e.target.value } })} className={inputCls} /> update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} />
@@ -612,6 +611,31 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage } } + const toolLabel = (key: string): string => { + const known = TASK_KNOWN_TOOLS.find(t => t.key === key) + return known ? known.label : key + } + + const renderToolTags = ( + label: string, + tools: string[] | undefined, + tone: 'allow' | 'deny', + ) => { + if (!tools || tools.length === 0) return null + const tagCls = + tone === 'allow' + ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400' + : 'bg-rose-500/10 text-rose-600 dark:text-rose-400' + return ( +
+ {label}: + {tools.map(t => ( + {toolLabel(t)} + ))} +
+ ) + } + return ( {subagentListLoading && subagents.length === 0 ? ( @@ -632,6 +656,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage {subagent.source}

{subagent.description}

+ {renderToolTags('允许', subagent.allowed_tools, 'allow')} + {renderToolTags('禁用', subagent.denied_tools, 'deny')} handleToggle(subagent.name, isEnabled)} /> diff --git a/web/src/components/Settings/types.ts b/web/src/components/Settings/types.ts index 50f58b0..3278ab1 100644 --- a/web/src/components/Settings/types.ts +++ b/web/src/components/Settings/types.ts @@ -7,7 +7,7 @@ export interface GatewayConfig { host: string; port: number; show_tool_results: export interface TimeConfig { timezone: string } export interface SchedulerConfig { enabled: boolean; tick_resolution_ms: number; worker_queue_capacity: number; misfire_policy: 'skip' | 'catch_up'; jobs?: SchedulerJobConfig[] } export interface SkillsConfig { enabled: boolean; sources: string[]; max_index_chars: number; max_listed_skills: number } -export interface TaskConfig { enabled: boolean; max_execution_secs: number; explore_max_execution_secs: number; ttl_hours: number; allowed_tools: string[] } +export interface TaskConfig { enabled: boolean; max_execution_secs: number; ttl_hours: number; allowed_tools: string[] } export interface ToolsConfig { disabled: string[]; task: TaskConfig } export interface MemoryMaintenanceConfig { max_merge_ratio: number; min_memories_to_keep: number; max_merge_per_group: number } export interface ImageContextConfig { max_images_in_context: number; max_image_age_rounds: number } @@ -45,6 +45,8 @@ export interface SubagentItem { description: string source: string disabled_in_scopes: string[] + allowed_tools?: string[] + denied_tools?: string[] } export interface SubagentListResponse {