feat: 子代理支持数据驱动的工具过滤(白名单+黑名单),删除内置 explore
SUBAGENT.md frontmatter 新增 denied_tools 黑名单字段,启用已有的 allowed_tools 白名单;新增 ToolRegistry::only 白名单方法;抽取 build_subagent_tools_registry(白名单→黑名单→depth 兜底);安全修复:resume 在 def 失踪时拒绝恢复而非降级为完整工具集(避免权限提升);depth 阈值改为引用 max_nesting_depth 配置;SubagentWithStatus 暴露工具字段供前端只读展示;删除内置 explore 子代理及专属 explore_max_execution_secs 配置(全栈清理);新增 23 个测试覆盖过滤矩阵、frontmatter 解析、状态投影
This commit is contained in:
parent
c276381d6c
commit
cea4bd3cfb
17
README.md
17
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 工具集成
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<String, Arc<dyn ToolTrait>> = 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<ToolResult> {
|
||||
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<String> {
|
||||
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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -29,8 +29,6 @@ pub struct SubAgentRuntimeConfig {
|
||||
pub default_allowed_tools: HashSet<String>,
|
||||
/// 默认最大执行时间(秒)
|
||||
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<ToolRegistry> {
|
||||
let depth_deny_task = child_depth >= self.config.max_nesting_depth;
|
||||
let allowed: Option<&Vec<String>> = def.and_then(|d| d.allowed_tools.as_ref());
|
||||
let denied_tools: Option<&Vec<String>> = 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<String>>,
|
||||
denied_tools: Option<&Vec<String>>,
|
||||
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<String>,
|
||||
) -> Result<AgentLoop, TaskError> {
|
||||
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<String, SubagentDef> = 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<String>,
|
||||
/// 工具白名单(None 表示不过滤)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// 工具黑名单(None 表示不过滤)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub denied_tools: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[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<Vec<String>>,
|
||||
#[serde(default)]
|
||||
denied_tools: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
max_execution_secs: Option<u64>,
|
||||
}
|
||||
|
||||
@ -1428,6 +1494,7 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
|
||||
prompt_template,
|
||||
body: if body_content.is_empty() { None } else { Some(body_content) },
|
||||
allowed_tools: frontmatter.allowed_tools,
|
||||
denied_tools: frontmatter.denied_tools,
|
||||
max_execution_secs: frontmatter.max_execution_secs,
|
||||
source,
|
||||
path: Some(path.to_path_buf()),
|
||||
@ -1538,10 +1605,9 @@ mod tests {
|
||||
assert!(change.changed);
|
||||
assert!(!change.available);
|
||||
|
||||
// 禁用后 prompt 不应包含 general(explore 仍可用,所以 prompt 仍为 Some)
|
||||
let prompt = runtime.system_index_prompt_filtered().unwrap();
|
||||
assert!(!prompt.contains("<name>general</name>"));
|
||||
assert!(prompt.contains("<name>explore</name>"));
|
||||
// 禁用后 prompt 不应包含 general(无可用子代理时返回 None)
|
||||
let prompt = runtime.system_index_prompt_filtered();
|
||||
assert!(prompt.map_or(true, |p| !p.contains("<name>general</name>")));
|
||||
}
|
||||
|
||||
#[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("<name>general</name>"));
|
||||
// 无可用子代理时返回 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<ToolResult> {
|
||||
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<String> {
|
||||
let mut v = reg.tool_names();
|
||||
v.sort();
|
||||
v
|
||||
}
|
||||
|
||||
/// 把 &str 切片转为 Some(Vec<String>),便于构造过滤参数
|
||||
fn s(v: &[&str]) -> Option<Vec<String>> {
|
||||
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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,8 +60,10 @@ pub struct SubagentDef {
|
||||
pub prompt_template: String,
|
||||
/// 可选的详细指令(body 部分)
|
||||
pub body: Option<String>,
|
||||
/// 工具白名单(None 表示使用默认)
|
||||
/// 工具白名单(None 表示不过滤,Some 时仅这些工具可用)
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// 工具黑名单(None 表示不过滤,Some 时这些工具被禁用;在白名单之后应用)
|
||||
pub denied_tools: Option<Vec<String>>,
|
||||
/// 最大执行时间(秒),None 表示使用默认
|
||||
pub max_execution_secs: Option<u64>,
|
||||
/// 来源
|
||||
@ -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,
|
||||
|
||||
@ -526,7 +526,6 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
||||
<SectionCard title="Task 子代理">
|
||||
<div className="flex items-center justify-between"><span className="text-sm text-[var(--text-secondary)]">启用 Task 工具</span><Toggle checked={config.tools.task.enabled} onChange={v => update('tools', { ...config.tools, task: { ...config.tools.task, enabled: v } })} /></div>
|
||||
<Field label="最大执行时间 (秒)"><input type="number" value={config.tools.task.max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
|
||||
<Field label="探索模式最大执行时间 (秒)"><input type="number" value={config.tools.task.explore_max_execution_secs} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, explore_max_execution_secs: +e.target.value } })} className={inputCls} /></Field>
|
||||
<Field label="TTL (小时)"><input type="number" value={config.tools.task.ttl_hours} onChange={e => update('tools', { ...config.tools, task: { ...config.tools.task, ttl_hours: +e.target.value } })} className={inputCls} /></Field>
|
||||
</SectionCard>
|
||||
<SectionCard title="允许的工具列表">
|
||||
@ -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 (
|
||||
<div className="flex items-center gap-1 flex-wrap mt-1">
|
||||
<span className="text-[10px] text-[var(--text-muted)]">{label}:</span>
|
||||
{tools.map(t => (
|
||||
<span key={t} className={`text-[10px] px-1.5 py-0.5 rounded ${tagCls}`}>{toolLabel(t)}</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionCard title="已发现子代理" subtitle="即时生效">
|
||||
{subagentListLoading && subagents.length === 0 ? (
|
||||
@ -632,6 +656,8 @@ export function ConfigPage({ onClose, onSaveConnection, initialTab }: ConfigPage
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
|
||||
</div>
|
||||
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
|
||||
{renderToolTags('允许', subagent.allowed_tools, 'allow')}
|
||||
{renderToolTags('禁用', subagent.denied_tools, 'deny')}
|
||||
</div>
|
||||
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
|
||||
</div>
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user