diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 0ec9794..7054315 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,2 +1,47 @@ pub mod messages; pub mod tools; + +use serde::{Deserialize, Serialize}; + +/// 角色能力策略:工具与技能的白/黑名单。全为空表示沿用默认(不过滤)。 +/// +/// 生效顺序:先白名单取交集,再黑名单扣除。专家与子代理共用此结构, +/// 确保语义一致。MCP 工具注册在 `ToolRegistry` 中(名 `mcp_*`),与内置 +/// 工具同源,因此 `allowed_tools`/`denied_tools` 覆盖内置 + MCP 工具; +/// `allowed_skills`/`denied_skills` 仅覆盖 SKILL.md 技能。 +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct CapabilityPolicy { + /// 技能白名单:`None` = 不限;`Some(vec)` = 仅这些 SKILL.md 技能可见。 + /// `Some(vec![])` 表示全禁(与 `ToolRegistry::only` 的空交集语义对齐)。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_skills: Option>, + /// 技能黑名单:禁用这些 SKILL.md 技能。空表示不禁。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub denied_skills: Vec, + /// 工具白名单(含 `mcp_*` 工具):`None` = 不限。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_tools: Option>, + /// 工具黑名单(含 `mcp_*` 工具)。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub denied_tools: Vec, +} + +impl CapabilityPolicy { + /// 所有策略字段均为空 → 沿用主智能体默认配置(不过滤)。 + pub fn is_empty(&self) -> bool { + self.allowed_skills.is_none() + && self.denied_skills.is_empty() + && self.allowed_tools.is_none() + && self.denied_tools.is_empty() + } + + /// 是否声明了任何技能策略。 + pub fn has_skill_policy(&self) -> bool { + self.allowed_skills.is_some() || !self.denied_skills.is_empty() + } + + /// 是否声明了任何工具策略。 + pub fn has_tool_policy(&self) -> bool { + self.allowed_tools.is_some() || !self.denied_tools.is_empty() + } +} diff --git a/src/experts/mod.rs b/src/experts/mod.rs index 8b0ae5a..3fa26fc 100644 --- a/src/experts/mod.rs +++ b/src/experts/mod.rs @@ -1,4 +1,5 @@ use crate::config::ExpertsConfig; +use crate::domain::CapabilityPolicy; use crate::platform::{atomic_rename, home_dir as platform_home_dir}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -22,6 +23,8 @@ pub struct Expert { pub body: String, pub source: ExpertSource, pub path: PathBuf, + /// 工具与技能加载策略。全为空表示沿用主智能体默认配置(不过滤)。 + pub capability: CapabilityPolicy, } /// Where an expert definition was discovered from. @@ -87,6 +90,9 @@ pub struct ExpertWithStatus { pub path: String, /// Which scopes have this expert disabled. Empty means enabled. pub disabled_in_scopes: Vec, + /// 工具与技能加载策略。 + #[serde(default)] + pub capability: CapabilityPolicy, } /// Result of an enable/disable operation. @@ -324,6 +330,7 @@ impl ExpertRuntime { source: expert.source.as_str().to_string(), path: expert.path.display().to_string(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), + capability: expert.capability.clone(), } }) .collect(); @@ -345,6 +352,7 @@ impl ExpertRuntime { name: &str, description: &str, body: &str, + capability: &CapabilityPolicy, reload: bool, ) -> Result { validate_expert_name(name)?; @@ -357,7 +365,7 @@ impl ExpertRuntime { )); } - write_expert_file(&path, name, description, body)?; + write_expert_file(&path, name, description, body, capability)?; let expert = parse_expert_file(&path, scope.into())?; if reload { let _ = self.reload()?; @@ -371,6 +379,7 @@ impl ExpertRuntime { name: &str, description: Option<&str>, body: Option<&str>, + capability: Option<&CapabilityPolicy>, reload: bool, ) -> Result { validate_expert_name(name)?; @@ -382,8 +391,9 @@ impl ExpertRuntime { let existing = parse_expert_file(&path, scope.into())?; let next_description = description.unwrap_or(&existing.description); let next_body = body.unwrap_or(&existing.body); + let next_capability = capability.cloned().unwrap_or(existing.capability); - write_expert_file(&path, name, next_description, next_body)?; + write_expert_file(&path, name, next_description, next_body, &next_capability)?; let expert = parse_expert_file(&path, scope.into())?; if reload { let _ = self.reload()?; @@ -720,9 +730,35 @@ struct ExpertFrontmatter { description: String, #[serde(default)] name: Option, + #[serde(default)] + allowed_skills: Option>, + #[serde(default)] + denied_skills: Vec, + #[serde(default)] + allowed_tools: Option>, + #[serde(default)] + denied_tools: Vec, } -fn render_expert_file(name: &str, description: &str, body: &str) -> Result { +/// 规范化字符串列表:去除空白项与首尾空格。 +fn normalize_string_list(list: Vec) -> Vec { + list.into_iter() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +/// 规范化可选字符串列表:`None` 保持 `None`(表示"不限"),`Some` 则清理空项。 +fn normalize_optional_list(list: Option>) -> Option> { + list.map(normalize_string_list) +} + +fn render_expert_file( + name: &str, + description: &str, + body: &str, + capability: &CapabilityPolicy, +) -> Result { if description.trim().is_empty() { return Err("description is required and cannot be empty".to_string()); } @@ -731,11 +767,23 @@ fn render_expert_file(name: &str, description: &str, body: &str) -> Result>, + #[serde(skip_serializing_if = "Vec::is_empty")] + denied_skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + allowed_tools: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + denied_tools: Vec, } let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned { name: name.to_string(), description: description.to_string(), + allowed_skills: capability.allowed_skills.clone(), + denied_skills: capability.denied_skills.clone(), + allowed_tools: capability.allowed_tools.clone(), + denied_tools: capability.denied_tools.clone(), }) .map_err(|err| format!("failed to render expert frontmatter: {}", err))?; @@ -748,8 +796,14 @@ fn render_expert_file(name: &str, description: &str, body: &str) -> Result Result<(), String> { - let content = render_expert_file(name, description, body)?; +fn write_expert_file( + path: &Path, + name: &str, + description: &str, + body: &str, + capability: &CapabilityPolicy, +) -> Result<(), String> { + let content = render_expert_file(name, description, body, capability)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create expert directory: {}", err))?; @@ -817,12 +871,20 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result(零拷贝)。 + let base_tool_count = self.tools.tool_names().len(); + let tools: Arc = match self.experts.selected_expert_for(&session_id) { + Some(expert) if expert.capability.has_tool_policy() => { + let filtered = self.build_filtered_registry(&expert.capability); + let filtered_count = filtered.tool_names().len(); + tracing::info!( + instance_id = self.instance_id, + session_id = %session_id, + expert = %expert.name, + base_tool_count, + filtered_tool_count = filtered_count, + "AgentFactory: applied expert tool policy" + ); + Arc::new(filtered) + } + _ => self.tools.clone(), + }; + AgentLoop::with_tools_and_system_prompt_provider( request.provider_config, - self.tools.clone(), + tools, system_prompt_provider, Some(self.skills.clone()), ) @@ -147,4 +168,25 @@ impl AgentFactory { agent }) } + + /// 按专家 CapabilityPolicy 构建过滤后的 ToolRegistry 副本。 + /// 生效顺序:先白名单取交集,再黑名单扣除(与 subagent filter_tool_registry 语义一致)。 + /// 底层工具为 Arc,克隆廉价。 + fn build_filtered_registry(&self, policy: &CapabilityPolicy) -> ToolRegistry { + // 1. 白名单(取交集);None 表示不限,复制一份以便后续黑名单过滤 + let after_allow: ToolRegistry = match &policy.allowed_tools { + Some(allowed) => { + let refs: Vec<&str> = allowed.iter().map(|s| s.as_str()).collect(); + self.tools.only(&refs) + } + None => self.tools.without(&[]), + }; + // 2. 黑名单(扣除) + if policy.denied_tools.is_empty() { + after_allow + } else { + let refs: Vec<&str> = policy.denied_tools.iter().map(|s| s.as_str()).collect(); + after_allow.without(&refs) + } + } } diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index 096e57a..557a5ad 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -191,7 +191,7 @@ pub(crate) fn build_session_manager_with_sender( ); // Create subagent catalog with discovery, wrap in SubagentRuntime - let catalog = Arc::new(SubagentCatalog::discover(&subagents_config)); + let catalog = SubagentCatalog::discover(&subagents_config); let subagent_runtime = Arc::new(SubagentRuntime::new( subagents_config.clone(), catalog, @@ -202,7 +202,6 @@ pub(crate) fn build_session_manager_with_sender( default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(), default_max_execution_secs: task_config.max_execution_secs, ttl_hours: task_config.ttl_hours, - skills_index: skills.system_index_prompt(), max_nesting_depth: task_config.max_nesting_depth, }; @@ -215,6 +214,7 @@ pub(crate) fn build_session_manager_with_sender( subagent_runtime.clone(), bus.clone(), store.clone(), + skills.clone(), )); // 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖) diff --git a/src/skills/mod.rs b/src/skills/mod.rs index 62f0766..ef1e57a 100644 --- a/src/skills/mod.rs +++ b/src/skills/mod.rs @@ -25,6 +25,23 @@ pub struct Skill { pub path: PathBuf, } +/// 渲染技能索引提示词(`` XML 块)。 +/// 仅输出技能索引列表;skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。 +fn render_skill_index(skills: &[&Skill]) -> String { + let mut prompt = String::from("# 可用技能(Skills)\n\n\n"); + for skill in skills { + let entry = format!( + " \n {}\n {}\n {}\n \n", + platform_xml_escape(&skill.name), + platform_xml_escape(&skill.description), + platform_xml_escape(&path_to_uri(&skill.path)), + ); + prompt.push_str(&entry); + } + prompt.push_str("\n"); + prompt +} + /// A skill entry with its disabled status across scopes. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SkillWithStatus { @@ -138,6 +155,18 @@ impl SkillRuntime { .system_index_prompt() } + /// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。 + pub fn system_index_prompt_filtered( + &self, + allowed: Option<&[String]>, + denied: &[String], + ) -> Option { + self.catalog + .read() + .expect("skills rwlock poisoned") + .system_index_prompt_filtered(allowed, denied) + } + pub fn discovery_event_payload(&self) -> serde_json::Value { self.catalog .read() @@ -465,24 +494,36 @@ impl SkillCatalog { if self.skills.is_empty() { return None; } + let refs: Vec<&Skill> = self.skills.iter().collect(); + Some(render_skill_index(&refs)) + } - // 仅输出技能索引列表。 - // skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。 - let mut prompt = String::from("# 可用技能(Skills)\n\n\n"); + /// 按白/黑名单过滤后的技能索引。`allowed` 为 `None` 表示不限(白名单关闭), + /// `Some` 表示仅这些技能可见(空切片 = 全禁)。`denied` 为黑名单。 + pub fn system_index_prompt_filtered( + &self, + allowed: Option<&[String]>, + denied: &[String], + ) -> Option { + let denied_set: HashSet<&str> = denied.iter().map(|s| s.as_str()).collect(); + let allowed_set: Option> = + allowed.map(|a| a.iter().map(|s| s.as_str()).collect()); - for skill in &self.skills { - let entry = format!( - " \n {}\n {}\n {}\n \n", - platform_xml_escape(&skill.name), - platform_xml_escape(&skill.description), - platform_xml_escape(&path_to_uri(&skill.path)), - ); - prompt.push_str(&entry); + let filtered: Vec<&Skill> = self + .skills + .iter() + .filter(|s| !denied_set.contains(s.name.as_str())) + .filter(|s| { + allowed_set + .as_ref() + .map_or(true, |set| set.contains(s.name.as_str())) + }) + .collect(); + + if filtered.is_empty() { + return None; } - - prompt.push_str("\n"); - - Some(prompt) + Some(render_skill_index(&filtered)) } pub fn discovery_event_payload(&self) -> serde_json::Value { @@ -902,26 +943,45 @@ fn parse_skill_file(path: &Path, source: SkillSource) -> Result { // SkillPromptProvider 实现 use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; +use crate::experts::ExpertRuntime; /// Skill 提示词提供者 /// /// 负责提供技能的系统索引提示词(system_index_prompt)。 +/// 当会话选中了带技能策略的专家时,按专家 `CapabilityPolicy` 过滤技能索引。 pub struct SkillPromptProvider { skills: Arc, + experts: Arc, } impl SkillPromptProvider { /// 创建新的 Skill 提示词提供者 - pub fn new(skills: Arc) -> Self { - Self { skills } + pub fn new(skills: Arc, experts: Arc) -> Self { + Self { skills, experts } } } impl SystemPromptProvider for SkillPromptProvider { - fn build(&self, _context: &SystemPromptContext) -> Option { - // 调用 SkillRuntime 的 system_index_prompt 方法 - self.skills.system_index_prompt().map(|content| SystemPrompt { - content, + fn build(&self, context: &SystemPromptContext) -> Option { + // 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认) + let content = match context.session_id.as_deref() { + Some(sid) => { + let policy = self + .experts + .selected_expert_for(sid) + .map(|e| e.capability); + match policy { + Some(p) if p.has_skill_policy() => self.skills.system_index_prompt_filtered( + p.allowed_skills.as_deref(), + &p.denied_skills, + ), + _ => self.skills.system_index_prompt(), + } + } + None => self.skills.system_index_prompt(), + }; + content.map(|c| SystemPrompt { + content: c, context: Some("skill_index".to_string()), }) } diff --git a/src/tools/task/prompt.rs b/src/tools/task/prompt.rs index 919b6c9..8c85972 100644 --- a/src/tools/task/prompt.rs +++ b/src/tools/task/prompt.rs @@ -101,8 +101,7 @@ mod tests { description: "测试".to_string(), prompt_template: "任务: {{description}}\n指令: {{prompt}}".to_string(), body: None, - allowed_tools: None, - denied_tools: None, + capability: crate::domain::CapabilityPolicy::default(), max_execution_secs: None, source: SubagentSource::Builtin, path: None, diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 68f6674..7b5c248 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -11,8 +11,10 @@ use crate::agent::{AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, Persist use crate::bus::ChatMessage; use crate::bus::message::{OutboundMessage, OutboundEventKind}; use crate::bus::MessageBus; +use crate::domain::CapabilityPolicy; use crate::providers::StreamDelta; use crate::config::{LLMProviderConfig, SubagentsConfig}; +use crate::skills::SkillRuntime; use crate::storage::{ConversationRepository, SessionStore}; use crate::tools::{ToolContext, ToolRegistry}; @@ -31,8 +33,6 @@ pub struct SubAgentRuntimeConfig { pub default_max_execution_secs: u64, /// 任务 TTL(小时) pub ttl_hours: u64, - /// 技能索引(可选,预生成的技能列表字符串) - pub skills_index: Option, /// 子代理最大嵌套深度(0 = 禁止嵌套,1 = 允许 1 层孙代理) pub max_nesting_depth: u32, } @@ -56,7 +56,6 @@ impl Default for SubAgentRuntimeConfig { ]), default_max_execution_secs: 3600, // 60分钟 ttl_hours: 24, - skills_index: None, max_nesting_depth: 1, } } @@ -353,6 +352,8 @@ pub struct DefaultSubAgentRuntime { subagent_runtime: Arc, bus: Option>, store: Arc, + /// 技能运行时(实时计算技能索引,替代冻结快照) + skills: Arc, } impl DefaultSubAgentRuntime { @@ -365,6 +366,7 @@ impl DefaultSubAgentRuntime { subagent_runtime: Arc, bus: Option>, store: Arc, + skills: Arc, ) -> Self { Self { config, @@ -375,6 +377,7 @@ impl DefaultSubAgentRuntime { subagent_runtime, bus, store, + skills, } } @@ -392,9 +395,9 @@ impl DefaultSubAgentRuntime { } /// 根据 def 与嵌套深度构建子代理工具集。 - /// 过滤顺序:base → allowed_tools 白名单 → denied_tools 黑名单 + depth 达到上限移除 task。 + /// 过滤顺序:base → capability.allowed_tools 白名单 → capability.denied_tools 黑名单 + depth 达到上限移除 task。 /// - `allowed_tools` 为 Some 时取交集(白名单),None 表示不限制。 - /// - `denied_tools` 为 Some 时扣除(黑名单),在白名单之后应用。 + /// - `denied_tools` 扣除(黑名单),在白名单之后应用。 /// - 当 child_depth >= max_nesting_depth 时移除 task 工具(防无限嵌套的安全兜底, /// 不可被 def 覆盖)。默认 max_nesting_depth=2,即孙代理(depth=2)无法再创建子代理。 fn build_subagent_tools_registry( @@ -403,18 +406,19 @@ impl DefaultSubAgentRuntime { 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()); + let policy: &CapabilityPolicy = match def { + Some(d) => &d.capability, + None => &CapabilityPolicy::default(), + }; - // 快速路径:无白名单、无黑名单、无需 depth 兜底 → 直接复用 Arc(避免拷贝) - if allowed.is_none() && denied_tools.is_none() && !depth_deny_task { + // 快速路径:无工具策略、无需 depth 兜底 → 直接复用 Arc(避免拷贝) + if !policy.has_tool_policy() && !depth_deny_task { return self.subagent_tools.clone(); } Arc::new(Self::filter_tool_registry( &self.subagent_tools, - allowed, - denied_tools, + policy, depth_deny_task, )) } @@ -423,12 +427,11 @@ impl DefaultSubAgentRuntime { /// 抽取为关联函数便于单元测试(无需构造整个 DefaultSubAgentRuntime)。 fn filter_tool_registry( base: &ToolRegistry, - allowed: Option<&Vec>, - denied_tools: Option<&Vec>, + policy: &CapabilityPolicy, depth_deny_task: bool, ) -> ToolRegistry { // 1. 应用白名单(若存在),否则取得 owned 副本以便后续黑名单过滤 - let tools: ToolRegistry = match allowed { + let tools: ToolRegistry = match &policy.allowed_tools { Some(list) => { let refs: Vec<&str> = list.iter().map(|s| s.as_str()).collect(); base.only(&refs) @@ -441,9 +444,7 @@ impl DefaultSubAgentRuntime { if depth_deny_task { denied.push(TaskTool::TOOL_NAME); } - if let Some(dt) = denied_tools { - denied.extend(dt.iter().map(|s| s.as_str())); - } + denied.extend(policy.denied_tools.iter().map(|s| s.as_str())); if denied.is_empty() { tools @@ -704,12 +705,21 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { } // 6. 构建子代理系统提示词 + // 实时按 def.capability 过滤技能索引(替代冻结快照,反映运行时技能增删) + let skills_index = if def.capability.has_skill_policy() { + self.skills.system_index_prompt_filtered( + def.capability.allowed_skills.as_deref(), + &def.capability.denied_skills, + ) + } else { + self.skills.system_index_prompt() + }; let system_prompt = SubagentPromptBuilder::build( &def, &task.description, &task.prompt, &self.provider_config, - self.config.skills_index.as_deref(), + skills_index.as_deref(), ); // 7. 创建子代理 @@ -1016,12 +1026,9 @@ 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>, + /// 工具与技能加载策略。 + #[serde(default)] + pub capability: CapabilityPolicy, } #[derive(Debug, Clone)] @@ -1128,7 +1135,7 @@ fn save_subagent_state_file(path: &Path, state: &SubagentStateFile) -> Result<() /// 对齐 `SkillRuntime` 模式。 #[derive(Debug)] pub struct SubagentRuntime { - catalog: Arc, + catalog: RwLock, disable_state: RwLock, #[allow(dead_code)] config: SubagentsConfig, @@ -1136,10 +1143,10 @@ pub struct SubagentRuntime { } impl SubagentRuntime { - pub fn new(config: SubagentsConfig, catalog: Arc, cwd: PathBuf) -> Self { + pub fn new(config: SubagentsConfig, catalog: SubagentCatalog, cwd: PathBuf) -> Self { let disable_state = load_subagent_disable_state(&cwd); Self { - catalog, + catalog: RwLock::new(catalog), disable_state: RwLock::new(disable_state), config, cwd, @@ -1149,15 +1156,23 @@ impl SubagentRuntime { /// 从配置构造(discover + wrap) pub fn from_config(config: SubagentsConfig) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - let catalog = Arc::new(SubagentCatalog::discover(&config)); + let catalog = SubagentCatalog::discover(&config); Self::new(config, catalog, cwd) } + /// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。 + pub fn reload(&self) -> Result<(), String> { + let new_catalog = SubagentCatalog::discover(&self.config); + let mut guard = self.catalog.write().expect("subagent catalog rwlock poisoned"); + *guard = new_catalog; + Ok(()) + } + /// 列出所有子代理(含禁用项),带 disabled_in_scopes pub fn list_with_status(&self) -> Vec { let state = self.disable_state.read().expect("subagent state rwlock poisoned"); - let mut items: Vec = self - .catalog + let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + let mut items: Vec = catalog .all() .iter() .map(|def| { @@ -1167,8 +1182,7 @@ 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(), + capability: def.capability.clone(), } }) .collect(); @@ -1179,7 +1193,8 @@ impl SubagentRuntime { /// 可用子代理名称(过滤禁用项) pub fn available_names(&self) -> Vec { let state = self.disable_state.read().expect("subagent state rwlock poisoned"); - self.catalog + let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + catalog .names() .into_iter() .filter(|name| !state.is_disabled(name)) @@ -1192,14 +1207,14 @@ impl SubagentRuntime { if state.is_disabled(name) { return None; } - self.catalog.find(name).cloned() + self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).cloned() } /// 生成过滤后的系统索引提示词 pub fn system_index_prompt_filtered(&self) -> Option { let state = self.disable_state.read().expect("subagent state rwlock poisoned"); - let available_defs: Vec<&SubagentDef> = self - .catalog + let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + let available_defs: Vec<&SubagentDef> = catalog .all() .into_iter() .filter(|def| !state.is_disabled(&def.name)) @@ -1253,7 +1268,7 @@ impl SubagentRuntime { enabled: bool, ) -> Result { // 校验子代理存在 - if self.catalog.find(name).is_none() { + if self.catalog.read().expect("subagent catalog rwlock poisoned").find(name).is_none() { return Err(format!("subagent '{}' not found", name)); } @@ -1312,9 +1327,56 @@ impl SubagentRuntime { }) } - /// 获取 catalog 引用(用于 DefaultSubAgentRuntime 等需要直接访问的场景) - pub fn catalog(&self) -> &Arc { - &self.catalog + /// 更新子代理定义(写回 SUBAGENT.md frontmatter)。 + /// 对齐 `ExpertRuntime::update_expert`。 + /// - `description`/`body`/`capability` 为 None 时保留原值。 + /// - `prompt_template`/`max_execution_secs` 不在 UI 暴露编辑,始终保留原值。 + /// - builtin 子代理(`source == Builtin`、`path == None`)禁止 update。 + pub fn update_subagent( + &self, + name: &str, + description: Option<&str>, + body: Option<&str>, + capability: Option<&CapabilityPolicy>, + reload: bool, + ) -> Result { + let def = { + let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned"); + catalog + .find(name) + .ok_or_else(|| format!("subagent '{}' not found", name))? + .clone() + }; + + // builtin 子代理无文件路径,禁止 update + let path = def + .path + .as_ref() + .ok_or_else(|| format!("builtin subagent '{}' cannot be updated", name))?; + + if !path.exists() { + return Err(format!("subagent file not found at {}", path.display())); + } + + let next_description = description.unwrap_or(&def.description); + let next_body = body.unwrap_or(def.body.as_deref().unwrap_or("")); + let next_capability = capability.cloned().unwrap_or_else(|| def.capability.clone()); + + write_subagent_file( + path, + &def.name, + next_description, + &def.prompt_template, + next_body, + &next_capability, + def.max_execution_secs, + )?; + + let new_def = parse_subagent_file(path, def.source.clone())?; + if reload { + let _ = self.reload(); + } + Ok(new_def) } } @@ -1403,9 +1465,13 @@ struct SubagentFrontmatter { #[serde(default)] prompt_template: Option, #[serde(default)] + allowed_skills: Option>, + #[serde(default)] + denied_skills: Vec, + #[serde(default)] allowed_tools: Option>, #[serde(default)] - denied_tools: Option>, + denied_tools: Vec, #[serde(default)] max_execution_secs: Option, } @@ -1491,19 +1557,108 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result, +) -> Result { + if description.trim().is_empty() { + return Err("description is required and cannot be empty".to_string()); + } + + #[derive(serde::Serialize)] + struct SubagentFrontmatterOwned { + name: String, + description: String, + #[serde(skip_serializing_if = "Option::is_none")] + prompt_template: Option, + #[serde(skip_serializing_if = "Option::is_none")] + allowed_skills: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + denied_skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + allowed_tools: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + denied_tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + max_execution_secs: Option, + } + + let fm = SubagentFrontmatterOwned { + name: name.to_string(), + description: description.to_string(), + prompt_template: if prompt_template.is_empty() { + None + } else { + Some(prompt_template.to_string()) + }, + allowed_skills: capability.allowed_skills.clone(), + denied_skills: capability.denied_skills.clone(), + allowed_tools: capability.allowed_tools.clone(), + denied_tools: capability.denied_tools.clone(), + max_execution_secs, + }; + + let yaml = serde_yaml::to_string(&fm) + .map_err(|err| format!("failed to render subagent frontmatter: {}", err))?; + let yaml = yaml.trim_start_matches("---\n"); + let body = body.trim(); + if body.is_empty() { + Ok(format!("---\n{}---\n", yaml)) + } else { + Ok(format!("---\n{}---\n{}\n", yaml, body)) + } +} + +/// 写入子代理文件(创建父目录如需)。 +fn write_subagent_file( + path: &Path, + name: &str, + description: &str, + prompt_template: &str, + body: &str, + capability: &CapabilityPolicy, + max_execution_secs: Option, +) -> Result<(), String> { + let content = render_subagent_file( + name, + description, + prompt_template, + body, + capability, + max_execution_secs, + )?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create subagent directory: {}", err))?; + } + fs::write(path, content).map_err(|err| format!("failed to write subagent file: {}", err)) +} + #[cfg(test)] mod tests { use super::*; @@ -1553,7 +1708,7 @@ mod tests { } fn make_runtime(cwd: &Path) -> SubagentRuntime { - let catalog = Arc::new(SubagentCatalog::new()); + let catalog = SubagentCatalog::new(); SubagentRuntime::new(SubagentsConfig::default(), catalog, cwd.to_path_buf()) } @@ -1704,15 +1859,21 @@ mod tests { v } - /// 把 &str 切片转为 Some(Vec),便于构造过滤参数 - fn s(v: &[&str]) -> Option> { - Some(v.iter().map(|x| x.to_string()).collect()) + /// 构造 CapabilityPolicy:白名单 + 黑名单 + fn policy(allowed: Option<&[&str]>, denied: &[&str]) -> CapabilityPolicy { + CapabilityPolicy { + allowed_skills: None, + denied_skills: Vec::new(), + allowed_tools: allowed.map(|v| v.iter().map(|x| x.to_string()).collect()), + denied_tools: denied.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); + let p = policy(None, &[]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert_eq!( sorted_names(®), vec!["bash", "edit", "read", "task", "write"] @@ -1722,7 +1883,8 @@ mod tests { #[test] fn filter_depth_deny_task_removes_task() { let base = base_registry(); - let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, None, None, true); + let p = policy(None, &[]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true); assert_eq!( sorted_names(®), vec!["bash", "edit", "read", "write"] @@ -1732,8 +1894,8 @@ mod tests { #[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); + let p = policy(Some(&["read", "bash"]), &[]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert_eq!(sorted_names(®), vec!["bash", "read"]); } @@ -1741,30 +1903,24 @@ mod tests { 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); + let p = policy(Some(&["read", "nonexistent", "glob"]), &[]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, 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); + let p = policy(None, &["bash", "task"]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, 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, - ); + let p = policy(Some(&["read", "bash"]), &["bash"]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); // 白名单留下 read+bash,黑名单再扣除 bash assert_eq!(sorted_names(®), vec!["read"]); } @@ -1772,8 +1928,8 @@ mod tests { #[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); + let p = policy(Some(&[]), &[]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert!(reg.tool_names().is_empty()); } @@ -1781,12 +1937,12 @@ mod tests { 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); + let p = policy(Some(&["read", "task"]), &[]); + let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true); assert_eq!(sorted_names(®), vec!["read"]); } - // ===== frontmatter 解析(denied_tools)测试 ===== + // ===== frontmatter 解析(capability)测试 ===== #[test] fn parse_subagent_file_handles_crlf_endings() { @@ -1805,7 +1961,7 @@ mod tests { } #[test] - fn parse_subagent_file_reads_denied_tools() { + fn parse_subagent_file_reads_capability() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("SUBAGENT.md"); std::fs::write( @@ -1813,6 +1969,8 @@ mod tests { "---\n\ name: sandbox\n\ description: sandbox agent\n\ + allowed_skills: [skill_a, skill_b]\n\ + denied_skills: [skill_c]\n\ allowed_tools: [read, todo_write]\n\ denied_tools: [bash, task]\n\ ---\n\ @@ -1823,17 +1981,25 @@ mod tests { let def = parse_subagent_file(&path, SubagentSource::Project).unwrap(); assert_eq!(def.name, "sandbox"); assert_eq!( - def.allowed_tools.as_deref(), + def.capability.allowed_skills.as_deref(), + Some(["skill_a".to_string(), "skill_b".to_string()].as_slice()) + ); + assert_eq!( + def.capability.denied_skills, + vec!["skill_c".to_string()] + ); + assert_eq!( + def.capability.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()) + def.capability.denied_tools, + vec!["bash".to_string(), "task".to_string()] ); } #[test] - fn parse_subagent_file_denied_tools_default_none_when_absent() { + fn parse_subagent_file_capability_default_empty_when_absent() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("SUBAGENT.md"); std::fs::write( @@ -1843,12 +2009,11 @@ mod tests { .unwrap(); let def = parse_subagent_file(&path, SubagentSource::User).unwrap(); - assert!(def.allowed_tools.is_none()); - assert!(def.denied_tools.is_none()); + assert!(def.capability.is_empty()); } #[test] - fn list_with_status_projects_tool_fields() { + fn list_with_status_projects_capability() { let temp = tempfile::tempdir().unwrap(); let mut catalog = SubagentCatalog::new(); catalog.register(SubagentDef { @@ -1856,27 +2021,152 @@ mod tests { 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()]), + capability: CapabilityPolicy { + allowed_skills: None, + denied_skills: Vec::new(), + allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]), + denied_tools: vec!["bash".to_string()], + }, max_execution_secs: None, source: SubagentSource::Builtin, path: None, }); let runtime = SubagentRuntime::new( SubagentsConfig::default(), - Arc::new(catalog), + 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(), + item.capability.allowed_tools.as_deref(), Some(["read".to_string(), "todo_write".to_string()].as_slice()) ); + assert_eq!(item.capability.denied_tools, vec!["bash".to_string()]); + } + + // ===== render/write/update_subagent 测试 ===== + + #[test] + fn render_subagent_file_roundtrip() { + let cap = CapabilityPolicy { + allowed_skills: Some(vec!["skill_a".to_string()]), + denied_skills: vec!["skill_b".to_string()], + allowed_tools: Some(vec!["read".to_string()]), + denied_tools: vec!["bash".to_string()], + }; + let content = render_subagent_file( + "demo", + "demo agent", + "template content", + "body instructions", + &cap, + Some(1800), + ) + .unwrap(); + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("SUBAGENT.md"); + std::fs::write(&path, &content).unwrap(); + + let def = parse_subagent_file(&path, SubagentSource::Project).unwrap(); + assert_eq!(def.name, "demo"); + assert_eq!(def.description, "demo agent"); + assert_eq!(def.prompt_template, "template content"); + assert_eq!(def.body.as_deref(), Some("body instructions")); + assert_eq!(def.max_execution_secs, Some(1800)); assert_eq!( - item.denied_tools.as_deref(), - Some(["bash".to_string()].as_slice()) + def.capability.allowed_skills.as_deref(), + Some(["skill_a".to_string()].as_slice()) ); + assert_eq!(def.capability.denied_skills, vec!["skill_b".to_string()]); + assert_eq!( + def.capability.allowed_tools.as_deref(), + Some(["read".to_string()].as_slice()) + ); + assert_eq!(def.capability.denied_tools, vec!["bash".to_string()]); + } + + #[test] + fn render_subagent_file_omits_empty_capability() { + let cap = CapabilityPolicy::default(); + let content = render_subagent_file( + "basic", + "basic agent", + "", + "body", + &cap, + None, + ) + .unwrap(); + // 空 capability 字段不应出现在 YAML 中 + assert!(!content.contains("allowed_skills")); + assert!(!content.contains("denied_skills")); + assert!(!content.contains("allowed_tools")); + assert!(!content.contains("denied_tools")); + assert!(!content.contains("max_execution_secs")); + assert!(!content.contains("prompt_template")); + } + + #[test] + fn update_subagent_writes_capability() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("demo").join("SUBAGENT.md"); + // 先写一个初始 SUBAGENT.md + write_subagent_file( + &path, + "demo", + "initial desc", + "", + "initial body", + &CapabilityPolicy::default(), + None, + ) + .unwrap(); + + // 用 SubagentRuntime 加载并 update + let config = SubagentsConfig { + enabled: true, + sources: vec![temp.path().to_string_lossy().to_string()], + }; + let runtime = SubagentRuntime::from_config(config); + + let new_cap = CapabilityPolicy { + allowed_skills: None, + denied_skills: vec!["skill_x".to_string()], + allowed_tools: Some(vec!["read".to_string()]), + denied_tools: vec!["bash".to_string()], + }; + let updated = runtime + .update_subagent("demo", Some("updated desc"), None, Some(&new_cap), false) + .unwrap(); + assert_eq!(updated.description, "updated desc"); + assert_eq!(updated.capability.denied_skills, vec!["skill_x".to_string()]); + assert_eq!( + updated.capability.allowed_tools.as_deref(), + Some(["read".to_string()].as_slice()) + ); + + // 重新从文件 parse 验证写回成功 + let reparsed = parse_subagent_file(&path, SubagentSource::Project).unwrap(); + assert_eq!(reparsed.description, "updated desc"); + assert_eq!(reparsed.capability.denied_skills, vec!["skill_x".to_string()]); + } + + #[test] + fn update_subagent_rejects_builtin() { + let runtime = SubagentRuntime::from_config(SubagentsConfig::default()); + // builtin general 子代理无 path,update 应失败 + let result = runtime.update_subagent( + "general", + Some("new desc"), + None, + None, + false, + ); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("builtin") || err.contains("not found")); } } diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index 103a5a0..f5e72cc 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -2,6 +2,8 @@ use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use crate::domain::CapabilityPolicy; + /// 子代理会话状态 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] @@ -60,10 +62,8 @@ pub struct SubagentDef { pub prompt_template: String, /// 可选的详细指令(body 部分) pub body: Option, - /// 工具白名单(None 表示不过滤,Some 时仅这些工具可用) - pub allowed_tools: Option>, - /// 工具黑名单(None 表示不过滤,Some 时这些工具被禁用;在白名单之后应用) - pub denied_tools: Option>, + /// 工具与技能加载策略。全为空表示沿用默认配置(不过滤)。 + pub capability: CapabilityPolicy, /// 最大执行时间(秒),None 表示使用默认 pub max_execution_secs: Option, /// 来源 @@ -80,8 +80,7 @@ impl SubagentDef { description: "通用型子代理 - 处理复杂多步骤任务".to_string(), 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, - denied_tools: None, + capability: CapabilityPolicy::default(), max_execution_secs: None, source: SubagentSource::Builtin, path: None,