feat(capability): 新增 CapabilityPolicy 统一策略模型并集成至专家/子代理/技能

- domain: 定义 CapabilityPolicy(allowed/denied skills/tools)统一策略结构

- experts: 专家支持 capability 字段,新增 update_expert 写回 EXPERT.md

- skills: SkillRuntime 暴露 capability 过滤接口

- tools/task: SubagentDef 增加 capability 字段,SubagentRuntime 新增 update_subagent 写回 SUBAGENT.md,工具集按 capability 过滤

- agent_factory: 按 capability 构建专家/子代理工具集(白名单交集 + 黑名单扣除)
This commit is contained in:
oudecheng 2026-07-30 16:53:30 +08:00
parent 85c021f124
commit 12f1094426
8 changed files with 688 additions and 122 deletions

View File

@ -1,2 +1,47 @@
pub mod messages; pub mod messages;
pub mod tools; 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<Vec<String>>,
/// 技能黑名单:禁用这些 SKILL.md 技能。空表示不禁。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denied_skills: Vec<String>,
/// 工具白名单(含 `mcp_*` 工具):`None` = 不限。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
/// 工具黑名单(含 `mcp_*` 工具)。
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub denied_tools: Vec<String>,
}
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()
}
}

View File

@ -1,4 +1,5 @@
use crate::config::ExpertsConfig; use crate::config::ExpertsConfig;
use crate::domain::CapabilityPolicy;
use crate::platform::{atomic_rename, home_dir as platform_home_dir}; use crate::platform::{atomic_rename, home_dir as platform_home_dir};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -22,6 +23,8 @@ pub struct Expert {
pub body: String, pub body: String,
pub source: ExpertSource, pub source: ExpertSource,
pub path: PathBuf, pub path: PathBuf,
/// 工具与技能加载策略。全为空表示沿用主智能体默认配置(不过滤)。
pub capability: CapabilityPolicy,
} }
/// Where an expert definition was discovered from. /// Where an expert definition was discovered from.
@ -87,6 +90,9 @@ pub struct ExpertWithStatus {
pub path: String, pub path: String,
/// Which scopes have this expert disabled. Empty means enabled. /// Which scopes have this expert disabled. Empty means enabled.
pub disabled_in_scopes: Vec<String>, pub disabled_in_scopes: Vec<String>,
/// 工具与技能加载策略。
#[serde(default)]
pub capability: CapabilityPolicy,
} }
/// Result of an enable/disable operation. /// Result of an enable/disable operation.
@ -324,6 +330,7 @@ impl ExpertRuntime {
source: expert.source.as_str().to_string(), source: expert.source.as_str().to_string(),
path: expert.path.display().to_string(), path: expert.path.display().to_string(),
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
capability: expert.capability.clone(),
} }
}) })
.collect(); .collect();
@ -345,6 +352,7 @@ impl ExpertRuntime {
name: &str, name: &str,
description: &str, description: &str,
body: &str, body: &str,
capability: &CapabilityPolicy,
reload: bool, reload: bool,
) -> Result<Expert, String> { ) -> Result<Expert, String> {
validate_expert_name(name)?; 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())?; let expert = parse_expert_file(&path, scope.into())?;
if reload { if reload {
let _ = self.reload()?; let _ = self.reload()?;
@ -371,6 +379,7 @@ impl ExpertRuntime {
name: &str, name: &str,
description: Option<&str>, description: Option<&str>,
body: Option<&str>, body: Option<&str>,
capability: Option<&CapabilityPolicy>,
reload: bool, reload: bool,
) -> Result<Expert, String> { ) -> Result<Expert, String> {
validate_expert_name(name)?; validate_expert_name(name)?;
@ -382,8 +391,9 @@ impl ExpertRuntime {
let existing = parse_expert_file(&path, scope.into())?; let existing = parse_expert_file(&path, scope.into())?;
let next_description = description.unwrap_or(&existing.description); let next_description = description.unwrap_or(&existing.description);
let next_body = body.unwrap_or(&existing.body); 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())?; let expert = parse_expert_file(&path, scope.into())?;
if reload { if reload {
let _ = self.reload()?; let _ = self.reload()?;
@ -720,9 +730,35 @@ struct ExpertFrontmatter {
description: String, description: String,
#[serde(default)] #[serde(default)]
name: Option<String>, name: Option<String>,
#[serde(default)]
allowed_skills: Option<Vec<String>>,
#[serde(default)]
denied_skills: Vec<String>,
#[serde(default)]
allowed_tools: Option<Vec<String>>,
#[serde(default)]
denied_tools: Vec<String>,
} }
fn render_expert_file(name: &str, description: &str, body: &str) -> Result<String, String> { /// 规范化字符串列表:去除空白项与首尾空格。
fn normalize_string_list(list: Vec<String>) -> Vec<String> {
list.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect()
}
/// 规范化可选字符串列表:`None` 保持 `None`(表示"不限"`Some` 则清理空项。
fn normalize_optional_list(list: Option<Vec<String>>) -> Option<Vec<String>> {
list.map(normalize_string_list)
}
fn render_expert_file(
name: &str,
description: &str,
body: &str,
capability: &CapabilityPolicy,
) -> Result<String, String> {
if description.trim().is_empty() { if description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string()); 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<Strin
struct ExpertFrontmatterOwned { struct ExpertFrontmatterOwned {
name: String, name: String,
description: String, description: String,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_skills: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_tools: Vec<String>,
} }
let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned { let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned {
name: name.to_string(), name: name.to_string(),
description: description.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))?; .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<Strin
} }
} }
fn write_expert_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> { fn write_expert_file(
let content = render_expert_file(name, description, body)?; 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() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|err| format!("failed to create expert directory: {}", err))?; .map_err(|err| format!("failed to create expert directory: {}", err))?;
@ -817,12 +871,20 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
let name = frontmatter.name.unwrap_or(dir_name).trim().to_string(); let name = frontmatter.name.unwrap_or(dir_name).trim().to_string();
let capability = CapabilityPolicy {
allowed_skills: normalize_optional_list(frontmatter.allowed_skills),
denied_skills: normalize_string_list(frontmatter.denied_skills),
allowed_tools: normalize_optional_list(frontmatter.allowed_tools),
denied_tools: normalize_string_list(frontmatter.denied_tools),
};
Ok(Expert { Ok(Expert {
name, name,
description: description.to_string(), description: description.to_string(),
body: body.trim().to_string(), body: body.trim().to_string(),
source, source,
path: path.to_path_buf(), path: path.to_path_buf(),
capability,
}) })
} }
@ -1020,10 +1082,70 @@ mod tests {
#[test] #[test]
fn test_render_expert_file_requires_description() { fn test_render_expert_file_requires_description() {
let err = render_expert_file("demo", " ", "body").unwrap_err(); let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default()).unwrap_err();
assert!(err.contains("description")); assert!(err.contains("description"));
} }
#[test]
fn test_capability_policy_is_empty_helpers() {
assert!(CapabilityPolicy::default().is_empty());
assert!(!CapabilityPolicy {
allowed_skills: Some(vec!["a".to_string()]),
..Default::default()
}
.is_empty());
assert!(CapabilityPolicy {
denied_tools: vec![],
..Default::default()
}
.is_empty());
assert!(CapabilityPolicy {
allowed_tools: Some(vec![]),
..Default::default()
}
.has_tool_policy());
assert!(CapabilityPolicy {
denied_skills: vec!["x".to_string()],
..Default::default()
}
.has_skill_policy());
}
#[test]
fn test_parse_and_render_capability_roundtrip() {
let policy = CapabilityPolicy {
allowed_skills: Some(vec!["skill_a".to_string(), "skill_b".to_string()]),
denied_skills: vec!["skill_c".to_string()],
allowed_tools: Some(vec!["read".to_string(), "mcp_fs_echo".to_string()]),
denied_tools: vec!["bash".to_string()],
};
let rendered = render_expert_file("cap", "desc", "body", &policy).unwrap();
// 白名单/黑名单字段都应出现
assert!(rendered.contains("allowed_skills:"));
assert!(rendered.contains("denied_skills:"));
assert!(rendered.contains("allowed_tools:"));
assert!(rendered.contains("denied_tools:"));
assert!(rendered.contains("skill_a"));
assert!(rendered.contains("mcp_fs_echo"));
// 写入磁盘再读回,策略应一致
let temp = tempfile::tempdir().unwrap();
let file_path = temp.path().join("EXPERT.md");
write_expert_file(&file_path, "cap", "desc", "body", &policy).unwrap();
let expert = parse_expert_file(&file_path, ExpertSource::Project).unwrap();
assert_eq!(expert.capability, policy);
}
#[test]
fn test_empty_capability_omits_keys() {
// 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容
let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default()).unwrap();
assert!(!rendered.contains("allowed_skills"));
assert!(!rendered.contains("denied_skills"));
assert!(!rendered.contains("allowed_tools"));
assert!(!rendered.contains("denied_tools"));
}
#[test] #[test]
fn test_discover_prefers_project_over_user() { fn test_discover_prefers_project_over_user() {
let _lock = acquire_test_lock(); let _lock = acquire_test_lock();
@ -1092,6 +1214,7 @@ mod tests {
"translator", "translator",
"翻译专家", "翻译专家",
"你是一名专业翻译。", "你是一名专业翻译。",
&CapabilityPolicy::default(),
true, true,
) )
.unwrap(); .unwrap();
@ -1104,6 +1227,7 @@ mod tests {
"translator", "translator",
"dup", "dup",
"body", "body",
&CapabilityPolicy::default(),
true, true,
); );
assert!(dup.is_err()); assert!(dup.is_err());
@ -1114,6 +1238,7 @@ mod tests {
"translator", "translator",
Some("更新翻译专家"), Some("更新翻译专家"),
Some("你是一名中文教师。"), Some("你是一名中文教师。"),
None,
true, true,
) )
.unwrap(); .unwrap();
@ -1122,7 +1247,7 @@ mod tests {
// update with None preserves fields // update with None preserves fields
let updated_none = runtime let updated_none = runtime
.update_expert(ExpertScope::Project, "translator", None, None, true) .update_expert(ExpertScope::Project, "translator", None, None, None, true)
.unwrap(); .unwrap();
assert_eq!(updated_none.description, "更新翻译专家"); assert_eq!(updated_none.description, "更新翻译专家");
assert_eq!(updated_none.body, "你是一名中文教师。"); assert_eq!(updated_none.body, "你是一名中文教师。");
@ -1156,6 +1281,7 @@ mod tests {
"coder", "coder",
"编程专家", "编程专家",
"你是一名编程专家。", "你是一名编程专家。",
&CapabilityPolicy::default(),
true, true,
) )
.unwrap(); .unwrap();
@ -1205,6 +1331,7 @@ mod tests {
"writer", "writer",
"写作专家", "写作专家",
"你是一名写作专家。", "你是一名写作专家。",
&CapabilityPolicy::default(),
true, true,
) )
.unwrap(); .unwrap();
@ -1245,6 +1372,7 @@ mod tests {
"reviewer", "reviewer",
"代码审查专家", "代码审查专家",
"你是一名代码审查专家。", "你是一名代码审查专家。",
&CapabilityPolicy::default(),
true, true,
) )
.unwrap(); .unwrap();
@ -1294,6 +1422,7 @@ mod tests {
"planner", "planner",
"规划专家", "规划专家",
"你是一名规划专家。", "你是一名规划专家。",
&CapabilityPolicy::default(),
true, true,
) )
.unwrap(); .unwrap();
@ -1428,6 +1557,7 @@ mod tests {
"teacher", "teacher",
"教师专家", "教师专家",
"你是一名中文教师,请用中文回答。", "你是一名中文教师,请用中文回答。",
&CapabilityPolicy::default(),
true, true,
) )
.unwrap(); .unwrap();
@ -1465,6 +1595,7 @@ mod tests {
"empty-body", "empty-body",
"无 body 的专家", "无 body 的专家",
"", // body empty "", // body empty
&CapabilityPolicy::default(),
true, true,
) )
.unwrap(); .unwrap();

View File

@ -2,6 +2,7 @@ use std::sync::Arc;
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider}; use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::domain::CapabilityPolicy;
use crate::experts::ExpertPromptProvider; use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime; use crate::experts::ExpertRuntime;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
@ -32,7 +33,7 @@ pub(crate) fn build_system_prompt_provider(
provider_config, provider_config,
prompt_repository, prompt_repository,
)), )),
Box::new(SkillPromptProvider::new(skills)), Box::new(SkillPromptProvider::new(skills, experts.clone())),
Box::new(ExpertPromptProvider::new(experts)), Box::new(ExpertPromptProvider::new(experts)),
Box::new(SubagentPromptProvider::new(subagent_runtime)), Box::new(SubagentPromptProvider::new(subagent_runtime)),
Box::new(ToolPromptProvider::new()), Box::new(ToolPromptProvider::new()),
@ -115,9 +116,29 @@ impl AgentFactory {
self.subagent_runtime.clone(), self.subagent_runtime.clone(),
); );
// 按所选专家的工具策略过滤工具集(含内置 + MCP 工具)。
// 无专家或专家未声明工具策略时,复用共享的 Arc<ToolRegistry>(零拷贝)。
let base_tool_count = self.tools.tool_names().len();
let tools: Arc<ToolRegistry> = 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( AgentLoop::with_tools_and_system_prompt_provider(
request.provider_config, request.provider_config,
self.tools.clone(), tools,
system_prompt_provider, system_prompt_provider,
Some(self.skills.clone()), Some(self.skills.clone()),
) )
@ -147,4 +168,25 @@ impl AgentFactory {
agent agent
}) })
} }
/// 按专家 CapabilityPolicy 构建过滤后的 ToolRegistry 副本。
/// 生效顺序:先白名单取交集,再黑名单扣除(与 subagent filter_tool_registry 语义一致)。
/// 底层工具为 Arc<dyn ToolTrait>,克隆廉价。
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)
}
}
} }

View File

@ -191,7 +191,7 @@ pub(crate) fn build_session_manager_with_sender(
); );
// Create subagent catalog with discovery, wrap in SubagentRuntime // 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( let subagent_runtime = Arc::new(SubagentRuntime::new(
subagents_config.clone(), subagents_config.clone(),
catalog, catalog,
@ -202,7 +202,6 @@ pub(crate) fn build_session_manager_with_sender(
default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(), default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(),
default_max_execution_secs: task_config.max_execution_secs, default_max_execution_secs: task_config.max_execution_secs,
ttl_hours: task_config.ttl_hours, ttl_hours: task_config.ttl_hours,
skills_index: skills.system_index_prompt(),
max_nesting_depth: task_config.max_nesting_depth, max_nesting_depth: task_config.max_nesting_depth,
}; };
@ -215,6 +214,7 @@ pub(crate) fn build_session_manager_with_sender(
subagent_runtime.clone(), subagent_runtime.clone(),
bus.clone(), bus.clone(),
store.clone(), store.clone(),
skills.clone(),
)); ));
// 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖) // 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖)

View File

@ -25,6 +25,23 @@ pub struct Skill {
pub path: PathBuf, pub path: PathBuf,
} }
/// 渲染技能索引提示词(`<available_skills>` XML 块)。
/// 仅输出技能索引列表skill_activate / skill_manage 的使用说明已统一收拢到 ToolPromptProvider。
fn render_skill_index(skills: &[&Skill]) -> String {
let mut prompt = String::from("# 可用技能Skills\n\n<available_skills>\n");
for skill in skills {
let entry = format!(
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>\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("</available_skills>\n");
prompt
}
/// A skill entry with its disabled status across scopes. /// A skill entry with its disabled status across scopes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkillWithStatus { pub struct SkillWithStatus {
@ -138,6 +155,18 @@ impl SkillRuntime {
.system_index_prompt() .system_index_prompt()
} }
/// 按白/黑名单过滤后的技能索引。供专家/子代理按 `CapabilityPolicy` 过滤技能可见性。
pub fn system_index_prompt_filtered(
&self,
allowed: Option<&[String]>,
denied: &[String],
) -> Option<String> {
self.catalog
.read()
.expect("skills rwlock poisoned")
.system_index_prompt_filtered(allowed, denied)
}
pub fn discovery_event_payload(&self) -> serde_json::Value { pub fn discovery_event_payload(&self) -> serde_json::Value {
self.catalog self.catalog
.read() .read()
@ -465,24 +494,36 @@ impl SkillCatalog {
if self.skills.is_empty() { if self.skills.is_empty() {
return None; 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<available_skills>\n");
for skill in &self.skills {
let entry = format!(
" <skill>\n <name>{}</name>\n <description>{}</description>\n <location>{}</location>\n </skill>\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("</available_skills>\n"); /// 按白/黑名单过滤后的技能索引。`allowed` 为 `None` 表示不限(白名单关闭),
/// `Some` 表示仅这些技能可见(空切片 = 全禁)。`denied` 为黑名单。
pub fn system_index_prompt_filtered(
&self,
allowed: Option<&[String]>,
denied: &[String],
) -> Option<String> {
let denied_set: HashSet<&str> = denied.iter().map(|s| s.as_str()).collect();
let allowed_set: Option<HashSet<&str>> =
allowed.map(|a| a.iter().map(|s| s.as_str()).collect());
Some(prompt) 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;
}
Some(render_skill_index(&filtered))
} }
pub fn discovery_event_payload(&self) -> serde_json::Value { pub fn discovery_event_payload(&self) -> serde_json::Value {
@ -902,26 +943,45 @@ fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
// SkillPromptProvider 实现 // SkillPromptProvider 实现
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
use crate::experts::ExpertRuntime;
/// Skill 提示词提供者 /// Skill 提示词提供者
/// ///
/// 负责提供技能的系统索引提示词system_index_prompt /// 负责提供技能的系统索引提示词system_index_prompt
/// 当会话选中了带技能策略的专家时,按专家 `CapabilityPolicy` 过滤技能索引。
pub struct SkillPromptProvider { pub struct SkillPromptProvider {
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
} }
impl SkillPromptProvider { impl SkillPromptProvider {
/// 创建新的 Skill 提示词提供者 /// 创建新的 Skill 提示词提供者
pub fn new(skills: Arc<SkillRuntime>) -> Self { pub fn new(skills: Arc<SkillRuntime>, experts: Arc<ExpertRuntime>) -> Self {
Self { skills } Self { skills, experts }
} }
} }
impl SystemPromptProvider for SkillPromptProvider { impl SystemPromptProvider for SkillPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> { fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
// 调用 SkillRuntime 的 system_index_prompt 方法 // 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
self.skills.system_index_prompt().map(|content| SystemPrompt { let content = match context.session_id.as_deref() {
content, 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()), context: Some("skill_index".to_string()),
}) })
} }

View File

@ -101,8 +101,7 @@ mod tests {
description: "测试".to_string(), description: "测试".to_string(),
prompt_template: "任务: {{description}}\n指令: {{prompt}}".to_string(), prompt_template: "任务: {{description}}\n指令: {{prompt}}".to_string(),
body: None, body: None,
allowed_tools: None, capability: crate::domain::CapabilityPolicy::default(),
denied_tools: None,
max_execution_secs: None, max_execution_secs: None,
source: SubagentSource::Builtin, source: SubagentSource::Builtin,
path: None, path: None,

View File

@ -11,8 +11,10 @@ use crate::agent::{AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, Persist
use crate::bus::ChatMessage; use crate::bus::ChatMessage;
use crate::bus::message::{OutboundMessage, OutboundEventKind}; use crate::bus::message::{OutboundMessage, OutboundEventKind};
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::domain::CapabilityPolicy;
use crate::providers::StreamDelta; use crate::providers::StreamDelta;
use crate::config::{LLMProviderConfig, SubagentsConfig}; use crate::config::{LLMProviderConfig, SubagentsConfig};
use crate::skills::SkillRuntime;
use crate::storage::{ConversationRepository, SessionStore}; use crate::storage::{ConversationRepository, SessionStore};
use crate::tools::{ToolContext, ToolRegistry}; use crate::tools::{ToolContext, ToolRegistry};
@ -31,8 +33,6 @@ pub struct SubAgentRuntimeConfig {
pub default_max_execution_secs: u64, pub default_max_execution_secs: u64,
/// 任务 TTL小时 /// 任务 TTL小时
pub ttl_hours: u64, pub ttl_hours: u64,
/// 技能索引(可选,预生成的技能列表字符串)
pub skills_index: Option<String>,
/// 子代理最大嵌套深度0 = 禁止嵌套1 = 允许 1 层孙代理) /// 子代理最大嵌套深度0 = 禁止嵌套1 = 允许 1 层孙代理)
pub max_nesting_depth: u32, pub max_nesting_depth: u32,
} }
@ -56,7 +56,6 @@ impl Default for SubAgentRuntimeConfig {
]), ]),
default_max_execution_secs: 3600, // 60分钟 default_max_execution_secs: 3600, // 60分钟
ttl_hours: 24, ttl_hours: 24,
skills_index: None,
max_nesting_depth: 1, max_nesting_depth: 1,
} }
} }
@ -353,6 +352,8 @@ pub struct DefaultSubAgentRuntime {
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
/// 技能运行时(实时计算技能索引,替代冻结快照)
skills: Arc<SkillRuntime>,
} }
impl DefaultSubAgentRuntime { impl DefaultSubAgentRuntime {
@ -365,6 +366,7 @@ impl DefaultSubAgentRuntime {
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
skills: Arc<SkillRuntime>,
) -> Self { ) -> Self {
Self { Self {
config, config,
@ -375,6 +377,7 @@ impl DefaultSubAgentRuntime {
subagent_runtime, subagent_runtime,
bus, bus,
store, store,
skills,
} }
} }
@ -392,9 +395,9 @@ impl DefaultSubAgentRuntime {
} }
/// 根据 def 与嵌套深度构建子代理工具集。 /// 根据 def 与嵌套深度构建子代理工具集。
/// 过滤顺序base → allowed_tools 白名单 → denied_tools 黑名单 + depth 达到上限移除 task。 /// 过滤顺序base → capability.allowed_tools 白名单 → capability.denied_tools 黑名单 + depth 达到上限移除 task。
/// - `allowed_tools` 为 Some 时取交集白名单None 表示不限制。 /// - `allowed_tools` 为 Some 时取交集白名单None 表示不限制。
/// - `denied_tools` 为 Some 时扣除(黑名单),在白名单之后应用。 /// - `denied_tools` 扣除(黑名单),在白名单之后应用。
/// - 当 child_depth >= max_nesting_depth 时移除 task 工具(防无限嵌套的安全兜底, /// - 当 child_depth >= max_nesting_depth 时移除 task 工具(防无限嵌套的安全兜底,
/// 不可被 def 覆盖)。默认 max_nesting_depth=2即孙代理depth=2无法再创建子代理。 /// 不可被 def 覆盖)。默认 max_nesting_depth=2即孙代理depth=2无法再创建子代理。
fn build_subagent_tools_registry( fn build_subagent_tools_registry(
@ -403,18 +406,19 @@ impl DefaultSubAgentRuntime {
child_depth: u32, child_depth: u32,
) -> Arc<ToolRegistry> { ) -> Arc<ToolRegistry> {
let depth_deny_task = child_depth >= self.config.max_nesting_depth; 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 policy: &CapabilityPolicy = match def {
let denied_tools: Option<&Vec<String>> = def.and_then(|d| d.denied_tools.as_ref()); Some(d) => &d.capability,
None => &CapabilityPolicy::default(),
};
// 快速路径:无白名单、无黑名单、无需 depth 兜底 → 直接复用 Arc避免拷贝 // 快速路径:无工具策略、无需 depth 兜底 → 直接复用 Arc避免拷贝
if allowed.is_none() && denied_tools.is_none() && !depth_deny_task { if !policy.has_tool_policy() && !depth_deny_task {
return self.subagent_tools.clone(); return self.subagent_tools.clone();
} }
Arc::new(Self::filter_tool_registry( Arc::new(Self::filter_tool_registry(
&self.subagent_tools, &self.subagent_tools,
allowed, policy,
denied_tools,
depth_deny_task, depth_deny_task,
)) ))
} }
@ -423,12 +427,11 @@ impl DefaultSubAgentRuntime {
/// 抽取为关联函数便于单元测试(无需构造整个 DefaultSubAgentRuntime /// 抽取为关联函数便于单元测试(无需构造整个 DefaultSubAgentRuntime
fn filter_tool_registry( fn filter_tool_registry(
base: &ToolRegistry, base: &ToolRegistry,
allowed: Option<&Vec<String>>, policy: &CapabilityPolicy,
denied_tools: Option<&Vec<String>>,
depth_deny_task: bool, depth_deny_task: bool,
) -> ToolRegistry { ) -> ToolRegistry {
// 1. 应用白名单(若存在),否则取得 owned 副本以便后续黑名单过滤 // 1. 应用白名单(若存在),否则取得 owned 副本以便后续黑名单过滤
let tools: ToolRegistry = match allowed { let tools: ToolRegistry = match &policy.allowed_tools {
Some(list) => { Some(list) => {
let refs: Vec<&str> = list.iter().map(|s| s.as_str()).collect(); let refs: Vec<&str> = list.iter().map(|s| s.as_str()).collect();
base.only(&refs) base.only(&refs)
@ -441,9 +444,7 @@ impl DefaultSubAgentRuntime {
if depth_deny_task { if depth_deny_task {
denied.push(TaskTool::TOOL_NAME); denied.push(TaskTool::TOOL_NAME);
} }
if let Some(dt) = denied_tools { denied.extend(policy.denied_tools.iter().map(|s| s.as_str()));
denied.extend(dt.iter().map(|s| s.as_str()));
}
if denied.is_empty() { if denied.is_empty() {
tools tools
@ -704,12 +705,21 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
} }
// 6. 构建子代理系统提示词 // 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( let system_prompt = SubagentPromptBuilder::build(
&def, &def,
&task.description, &task.description,
&task.prompt, &task.prompt,
&self.provider_config, &self.provider_config,
self.config.skills_index.as_deref(), skills_index.as_deref(),
); );
// 7. 创建子代理 // 7. 创建子代理
@ -1016,12 +1026,9 @@ pub struct SubagentWithStatus {
pub source: String, pub source: String,
/// Which scopes have this subagent disabled. Empty means enabled. /// Which scopes have this subagent disabled. Empty means enabled.
pub disabled_in_scopes: Vec<String>, pub disabled_in_scopes: Vec<String>,
/// 工具白名单None 表示不过滤) /// 工具与技能加载策略。
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default)]
pub allowed_tools: Option<Vec<String>>, pub capability: CapabilityPolicy,
/// 工具黑名单None 表示不过滤)
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denied_tools: Option<Vec<String>>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -1128,7 +1135,7 @@ fn save_subagent_state_file(path: &Path, state: &SubagentStateFile) -> Result<()
/// 对齐 `SkillRuntime` 模式。 /// 对齐 `SkillRuntime` 模式。
#[derive(Debug)] #[derive(Debug)]
pub struct SubagentRuntime { pub struct SubagentRuntime {
catalog: Arc<SubagentCatalog>, catalog: RwLock<SubagentCatalog>,
disable_state: RwLock<SubagentDisableState>, disable_state: RwLock<SubagentDisableState>,
#[allow(dead_code)] #[allow(dead_code)]
config: SubagentsConfig, config: SubagentsConfig,
@ -1136,10 +1143,10 @@ pub struct SubagentRuntime {
} }
impl SubagentRuntime { impl SubagentRuntime {
pub fn new(config: SubagentsConfig, catalog: Arc<SubagentCatalog>, cwd: PathBuf) -> Self { pub fn new(config: SubagentsConfig, catalog: SubagentCatalog, cwd: PathBuf) -> Self {
let disable_state = load_subagent_disable_state(&cwd); let disable_state = load_subagent_disable_state(&cwd);
Self { Self {
catalog, catalog: RwLock::new(catalog),
disable_state: RwLock::new(disable_state), disable_state: RwLock::new(disable_state),
config, config,
cwd, cwd,
@ -1149,15 +1156,23 @@ impl SubagentRuntime {
/// 从配置构造discover + wrap /// 从配置构造discover + wrap
pub fn from_config(config: SubagentsConfig) -> Self { pub fn from_config(config: SubagentsConfig) -> Self {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); 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) 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 /// 列出所有子代理(含禁用项),带 disabled_in_scopes
pub fn list_with_status(&self) -> Vec<SubagentWithStatus> { pub fn list_with_status(&self) -> Vec<SubagentWithStatus> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); let state = self.disable_state.read().expect("subagent state rwlock poisoned");
let mut items: Vec<SubagentWithStatus> = self let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
.catalog let mut items: Vec<SubagentWithStatus> = catalog
.all() .all()
.iter() .iter()
.map(|def| { .map(|def| {
@ -1167,8 +1182,7 @@ impl SubagentRuntime {
description: def.description.clone(), description: def.description.clone(),
source: def.source.as_str().to_string(), source: def.source.as_str().to_string(),
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
allowed_tools: def.allowed_tools.clone(), capability: def.capability.clone(),
denied_tools: def.denied_tools.clone(),
} }
}) })
.collect(); .collect();
@ -1179,7 +1193,8 @@ impl SubagentRuntime {
/// 可用子代理名称(过滤禁用项) /// 可用子代理名称(过滤禁用项)
pub fn available_names(&self) -> Vec<String> { pub fn available_names(&self) -> Vec<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); 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() .names()
.into_iter() .into_iter()
.filter(|name| !state.is_disabled(name)) .filter(|name| !state.is_disabled(name))
@ -1192,14 +1207,14 @@ impl SubagentRuntime {
if state.is_disabled(name) { if state.is_disabled(name) {
return None; 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<String> { pub fn system_index_prompt_filtered(&self) -> Option<String> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned"); let state = self.disable_state.read().expect("subagent state rwlock poisoned");
let available_defs: Vec<&SubagentDef> = self let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
.catalog let available_defs: Vec<&SubagentDef> = catalog
.all() .all()
.into_iter() .into_iter()
.filter(|def| !state.is_disabled(&def.name)) .filter(|def| !state.is_disabled(&def.name))
@ -1253,7 +1268,7 @@ impl SubagentRuntime {
enabled: bool, enabled: bool,
) -> Result<SubagentAvailabilityChange, String> { ) -> Result<SubagentAvailabilityChange, String> {
// 校验子代理存在 // 校验子代理存在
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)); return Err(format!("subagent '{}' not found", name));
} }
@ -1312,9 +1327,56 @@ impl SubagentRuntime {
}) })
} }
/// 获取 catalog 引用(用于 DefaultSubAgentRuntime 等需要直接访问的场景) /// 更新子代理定义(写回 SUBAGENT.md frontmatter
pub fn catalog(&self) -> &Arc<SubagentCatalog> { /// 对齐 `ExpertRuntime::update_expert`。
&self.catalog /// - `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<SubagentDef, String> {
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)] #[serde(default)]
prompt_template: Option<String>, prompt_template: Option<String>,
#[serde(default)] #[serde(default)]
allowed_skills: Option<Vec<String>>,
#[serde(default)]
denied_skills: Vec<String>,
#[serde(default)]
allowed_tools: Option<Vec<String>>, allowed_tools: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
denied_tools: Option<Vec<String>>, denied_tools: Vec<String>,
#[serde(default)] #[serde(default)]
max_execution_secs: Option<u64>, max_execution_secs: Option<u64>,
} }
@ -1491,19 +1557,108 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
let prompt_template = frontmatter.prompt_template.unwrap_or_default().trim().to_string(); let prompt_template = frontmatter.prompt_template.unwrap_or_default().trim().to_string();
let body_content = body.trim().to_string(); let body_content = body.trim().to_string();
let capability = CapabilityPolicy {
allowed_skills: frontmatter.allowed_skills,
denied_skills: frontmatter.denied_skills,
allowed_tools: frontmatter.allowed_tools,
denied_tools: frontmatter.denied_tools,
};
Ok(SubagentDef { Ok(SubagentDef {
name, name,
description: frontmatter.description.trim().to_string(), description: frontmatter.description.trim().to_string(),
prompt_template, prompt_template,
body: if body_content.is_empty() { None } else { Some(body_content) }, body: if body_content.is_empty() { None } else { Some(body_content) },
allowed_tools: frontmatter.allowed_tools, capability,
denied_tools: frontmatter.denied_tools,
max_execution_secs: frontmatter.max_execution_secs, max_execution_secs: frontmatter.max_execution_secs,
source, source,
path: Some(path.to_path_buf()), path: Some(path.to_path_buf()),
}) })
} }
/// 渲染子代理文件内容frontmatter + body
/// 对齐 `experts::render_expert_file`:空 capability 字段不输出对应 key。
fn render_subagent_file(
name: &str,
description: &str,
prompt_template: &str,
body: &str,
capability: &CapabilityPolicy,
max_execution_secs: Option<u64>,
) -> Result<String, String> {
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_skills: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_execution_secs: Option<u64>,
}
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<u64>,
) -> 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -1553,7 +1708,7 @@ mod tests {
} }
fn make_runtime(cwd: &Path) -> SubagentRuntime { 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()) SubagentRuntime::new(SubagentsConfig::default(), catalog, cwd.to_path_buf())
} }
@ -1704,15 +1859,21 @@ mod tests {
v v
} }
/// 把 &str 切片转为 Some(Vec<String>),便于构造过滤参数 /// 构造 CapabilityPolicy白名单 + 黑名单
fn s(v: &[&str]) -> Option<Vec<String>> { fn policy(allowed: Option<&[&str]>, denied: &[&str]) -> CapabilityPolicy {
Some(v.iter().map(|x| x.to_string()).collect()) 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] #[test]
fn filter_no_restriction_returns_all() { fn filter_no_restriction_returns_all() {
let base = base_registry(); 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!( assert_eq!(
sorted_names(&reg), sorted_names(&reg),
vec!["bash", "edit", "read", "task", "write"] vec!["bash", "edit", "read", "task", "write"]
@ -1722,7 +1883,8 @@ mod tests {
#[test] #[test]
fn filter_depth_deny_task_removes_task() { fn filter_depth_deny_task_removes_task() {
let base = base_registry(); 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!( assert_eq!(
sorted_names(&reg), sorted_names(&reg),
vec!["bash", "edit", "read", "write"] vec!["bash", "edit", "read", "write"]
@ -1732,8 +1894,8 @@ mod tests {
#[test] #[test]
fn filter_whitelist_keeps_only_listed() { fn filter_whitelist_keeps_only_listed() {
let base = base_registry(); let base = base_registry();
let allowed = s(&["read", "bash"]); let p = policy(Some(&["read", "bash"]), &[]);
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, false); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
assert_eq!(sorted_names(&reg), vec!["bash", "read"]); assert_eq!(sorted_names(&reg), vec!["bash", "read"]);
} }
@ -1741,30 +1903,24 @@ mod tests {
fn filter_whitelist_skips_missing_names() { fn filter_whitelist_skips_missing_names() {
let base = base_registry(); let base = base_registry();
// 包含未注册的工具名应被静默跳过 // 包含未注册的工具名应被静默跳过
let allowed = s(&["read", "nonexistent", "glob"]); let p = policy(Some(&["read", "nonexistent", "glob"]), &[]);
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, false); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
assert_eq!(sorted_names(&reg), vec!["read"]); assert_eq!(sorted_names(&reg), vec!["read"]);
} }
#[test] #[test]
fn filter_blacklist_removes_listed() { fn filter_blacklist_removes_listed() {
let base = base_registry(); let base = base_registry();
let denied = s(&["bash", "task"]); let p = policy(None, &["bash", "task"]);
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, None, denied.as_ref(), false); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
assert_eq!(sorted_names(&reg), vec!["edit", "read", "write"]); assert_eq!(sorted_names(&reg), vec!["edit", "read", "write"]);
} }
#[test] #[test]
fn filter_whitelist_then_blacklist() { fn filter_whitelist_then_blacklist() {
let base = base_registry(); let base = base_registry();
let allowed = s(&["read", "bash"]); let p = policy(Some(&["read", "bash"]), &["bash"]);
let denied = s(&["bash"]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
let reg = DefaultSubAgentRuntime::filter_tool_registry(
&base,
allowed.as_ref(),
denied.as_ref(),
false,
);
// 白名单留下 read+bash黑名单再扣除 bash // 白名单留下 read+bash黑名单再扣除 bash
assert_eq!(sorted_names(&reg), vec!["read"]); assert_eq!(sorted_names(&reg), vec!["read"]);
} }
@ -1772,8 +1928,8 @@ mod tests {
#[test] #[test]
fn filter_empty_whitelist_yields_empty() { fn filter_empty_whitelist_yields_empty() {
let base = base_registry(); let base = base_registry();
let allowed = s(&[]); let p = policy(Some(&[]), &[]);
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, false); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
assert!(reg.tool_names().is_empty()); assert!(reg.tool_names().is_empty());
} }
@ -1781,12 +1937,12 @@ mod tests {
fn filter_depth_rule_overrides_whitelist_task() { fn filter_depth_rule_overrides_whitelist_task() {
let base = base_registry(); let base = base_registry();
// 白名单显式包含 task但 depth≥2 安全兜底仍应移除它 // 白名单显式包含 task但 depth≥2 安全兜底仍应移除它
let allowed = s(&["read", "task"]); let p = policy(Some(&["read", "task"]), &[]);
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, allowed.as_ref(), None, true); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true);
assert_eq!(sorted_names(&reg), vec!["read"]); assert_eq!(sorted_names(&reg), vec!["read"]);
} }
// ===== frontmatter 解析(denied_tools)测试 ===== // ===== frontmatter 解析(capability)测试 =====
#[test] #[test]
fn parse_subagent_file_handles_crlf_endings() { fn parse_subagent_file_handles_crlf_endings() {
@ -1805,7 +1961,7 @@ mod tests {
} }
#[test] #[test]
fn parse_subagent_file_reads_denied_tools() { fn parse_subagent_file_reads_capability() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("SUBAGENT.md"); let path = temp.path().join("SUBAGENT.md");
std::fs::write( std::fs::write(
@ -1813,6 +1969,8 @@ mod tests {
"---\n\ "---\n\
name: sandbox\n\ name: sandbox\n\
description: sandbox agent\n\ description: sandbox agent\n\
allowed_skills: [skill_a, skill_b]\n\
denied_skills: [skill_c]\n\
allowed_tools: [read, todo_write]\n\ allowed_tools: [read, todo_write]\n\
denied_tools: [bash, task]\n\ denied_tools: [bash, task]\n\
---\n\ ---\n\
@ -1823,17 +1981,25 @@ mod tests {
let def = parse_subagent_file(&path, SubagentSource::Project).unwrap(); let def = parse_subagent_file(&path, SubagentSource::Project).unwrap();
assert_eq!(def.name, "sandbox"); assert_eq!(def.name, "sandbox");
assert_eq!( 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()) Some(["read".to_string(), "todo_write".to_string()].as_slice())
); );
assert_eq!( assert_eq!(
def.denied_tools.as_deref(), def.capability.denied_tools,
Some(["bash".to_string(), "task".to_string()].as_slice()) vec!["bash".to_string(), "task".to_string()]
); );
} }
#[test] #[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 temp = tempfile::tempdir().unwrap();
let path = temp.path().join("SUBAGENT.md"); let path = temp.path().join("SUBAGENT.md");
std::fs::write( std::fs::write(
@ -1843,12 +2009,11 @@ mod tests {
.unwrap(); .unwrap();
let def = parse_subagent_file(&path, SubagentSource::User).unwrap(); let def = parse_subagent_file(&path, SubagentSource::User).unwrap();
assert!(def.allowed_tools.is_none()); assert!(def.capability.is_empty());
assert!(def.denied_tools.is_none());
} }
#[test] #[test]
fn list_with_status_projects_tool_fields() { fn list_with_status_projects_capability() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let mut catalog = SubagentCatalog::new(); let mut catalog = SubagentCatalog::new();
catalog.register(SubagentDef { catalog.register(SubagentDef {
@ -1856,27 +2021,152 @@ mod tests {
description: "sandbox agent".to_string(), description: "sandbox agent".to_string(),
prompt_template: String::new(), prompt_template: String::new(),
body: None, body: None,
capability: CapabilityPolicy {
allowed_skills: None,
denied_skills: Vec::new(),
allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]), allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]),
denied_tools: Some(vec!["bash".to_string()]), denied_tools: vec!["bash".to_string()],
},
max_execution_secs: None, max_execution_secs: None,
source: SubagentSource::Builtin, source: SubagentSource::Builtin,
path: None, path: None,
}); });
let runtime = SubagentRuntime::new( let runtime = SubagentRuntime::new(
SubagentsConfig::default(), SubagentsConfig::default(),
Arc::new(catalog), catalog,
temp.path().to_path_buf(), temp.path().to_path_buf(),
); );
let items = runtime.list_with_status(); let items = runtime.list_with_status();
let item = items.iter().find(|i| i.name == "sandbox").unwrap(); let item = items.iter().find(|i| i.name == "sandbox").unwrap();
assert_eq!( assert_eq!(
item.allowed_tools.as_deref(), item.capability.allowed_tools.as_deref(),
Some(["read".to_string(), "todo_write".to_string()].as_slice()) 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!( assert_eq!(
item.denied_tools.as_deref(), def.capability.allowed_skills.as_deref(),
Some(["bash".to_string()].as_slice()) 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 子代理无 pathupdate 应失败
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"));
} }
} }

View File

@ -2,6 +2,8 @@ use std::path::PathBuf;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::domain::CapabilityPolicy;
/// 子代理会话状态 /// 子代理会话状态
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")] #[serde(rename_all = "lowercase")]
@ -60,10 +62,8 @@ pub struct SubagentDef {
pub prompt_template: String, pub prompt_template: String,
/// 可选的详细指令body 部分) /// 可选的详细指令body 部分)
pub body: Option<String>, pub body: Option<String>,
/// 工具白名单None 表示不过滤Some 时仅这些工具可用) /// 工具与技能加载策略。全为空表示沿用默认配置(不过滤)。
pub allowed_tools: Option<Vec<String>>, pub capability: CapabilityPolicy,
/// 工具黑名单None 表示不过滤Some 时这些工具被禁用;在白名单之后应用)
pub denied_tools: Option<Vec<String>>,
/// 最大执行时间None 表示使用默认 /// 最大执行时间None 表示使用默认
pub max_execution_secs: Option<u64>, pub max_execution_secs: Option<u64>,
/// 来源 /// 来源
@ -80,8 +80,7 @@ impl SubagentDef {
description: "通用型子代理 - 处理复杂多步骤任务".to_string(), 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(), 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, body: None,
allowed_tools: None, capability: CapabilityPolicy::default(),
denied_tools: None,
max_execution_secs: None, max_execution_secs: None,
source: SubagentSource::Builtin, source: SubagentSource::Builtin,
path: None, path: None,