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:
parent
85c021f124
commit
12f1094426
@ -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<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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>,
|
||||
/// 工具与技能加载策略。
|
||||
#[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<Expert, String> {
|
||||
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<Expert, String> {
|
||||
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<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() {
|
||||
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 {
|
||||
name: 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 {
|
||||
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<Strin
|
||||
}
|
||||
}
|
||||
|
||||
fn write_expert_file(path: &Path, name: &str, description: &str, body: &str) -> 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<Expert, 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 {
|
||||
name,
|
||||
description: description.to_string(),
|
||||
body: body.trim().to_string(),
|
||||
source,
|
||||
path: path.to_path_buf(),
|
||||
capability,
|
||||
})
|
||||
}
|
||||
|
||||
@ -1020,10 +1082,70 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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"));
|
||||
}
|
||||
|
||||
#[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]
|
||||
fn test_discover_prefers_project_over_user() {
|
||||
let _lock = acquire_test_lock();
|
||||
@ -1092,6 +1214,7 @@ mod tests {
|
||||
"translator",
|
||||
"翻译专家",
|
||||
"你是一名专业翻译。",
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
@ -1104,6 +1227,7 @@ mod tests {
|
||||
"translator",
|
||||
"dup",
|
||||
"body",
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
);
|
||||
assert!(dup.is_err());
|
||||
@ -1114,6 +1238,7 @@ mod tests {
|
||||
"translator",
|
||||
Some("更新翻译专家"),
|
||||
Some("你是一名中文教师。"),
|
||||
None,
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
@ -1122,7 +1247,7 @@ mod tests {
|
||||
|
||||
// update with None preserves fields
|
||||
let updated_none = runtime
|
||||
.update_expert(ExpertScope::Project, "translator", None, None, true)
|
||||
.update_expert(ExpertScope::Project, "translator", None, None, None, true)
|
||||
.unwrap();
|
||||
assert_eq!(updated_none.description, "更新翻译专家");
|
||||
assert_eq!(updated_none.body, "你是一名中文教师。");
|
||||
@ -1156,6 +1281,7 @@ mod tests {
|
||||
"coder",
|
||||
"编程专家",
|
||||
"你是一名编程专家。",
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
@ -1205,6 +1331,7 @@ mod tests {
|
||||
"writer",
|
||||
"写作专家",
|
||||
"你是一名写作专家。",
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
@ -1245,6 +1372,7 @@ mod tests {
|
||||
"reviewer",
|
||||
"代码审查专家",
|
||||
"你是一名代码审查专家。",
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
@ -1294,6 +1422,7 @@ mod tests {
|
||||
"planner",
|
||||
"规划专家",
|
||||
"你是一名规划专家。",
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
@ -1428,6 +1557,7 @@ mod tests {
|
||||
"teacher",
|
||||
"教师专家",
|
||||
"你是一名中文教师,请用中文回答。",
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
@ -1465,6 +1595,7 @@ mod tests {
|
||||
"empty-body",
|
||||
"无 body 的专家",
|
||||
"", // body empty
|
||||
&CapabilityPolicy::default(),
|
||||
true,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@ -2,6 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
|
||||
use crate::config::LLMProviderConfig;
|
||||
use crate::domain::CapabilityPolicy;
|
||||
use crate::experts::ExpertPromptProvider;
|
||||
use crate::experts::ExpertRuntime;
|
||||
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
|
||||
@ -32,7 +33,7 @@ pub(crate) fn build_system_prompt_provider(
|
||||
provider_config,
|
||||
prompt_repository,
|
||||
)),
|
||||
Box::new(SkillPromptProvider::new(skills)),
|
||||
Box::new(SkillPromptProvider::new(skills, experts.clone())),
|
||||
Box::new(ExpertPromptProvider::new(experts)),
|
||||
Box::new(SubagentPromptProvider::new(subagent_runtime)),
|
||||
Box::new(ToolPromptProvider::new()),
|
||||
@ -115,9 +116,29 @@ impl AgentFactory {
|
||||
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(
|
||||
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<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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 创建之后,打破循环依赖)
|
||||
|
||||
@ -25,6 +25,23 @@ pub struct Skill {
|
||||
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.
|
||||
#[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<String> {
|
||||
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;
|
||||
}
|
||||
|
||||
// 仅输出技能索引列表。
|
||||
// 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);
|
||||
let refs: Vec<&Skill> = self.skills.iter().collect();
|
||||
Some(render_skill_index(&refs))
|
||||
}
|
||||
|
||||
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 {
|
||||
@ -902,26 +943,45 @@ fn parse_skill_file(path: &Path, source: SkillSource) -> Result<Skill, String> {
|
||||
|
||||
// SkillPromptProvider 实现
|
||||
use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider};
|
||||
use crate::experts::ExpertRuntime;
|
||||
|
||||
/// Skill 提示词提供者
|
||||
///
|
||||
/// 负责提供技能的系统索引提示词(system_index_prompt)。
|
||||
/// 当会话选中了带技能策略的专家时,按专家 `CapabilityPolicy` 过滤技能索引。
|
||||
pub struct SkillPromptProvider {
|
||||
skills: Arc<SkillRuntime>,
|
||||
experts: Arc<ExpertRuntime>,
|
||||
}
|
||||
|
||||
impl SkillPromptProvider {
|
||||
/// 创建新的 Skill 提示词提供者
|
||||
pub fn new(skills: Arc<SkillRuntime>) -> Self {
|
||||
Self { skills }
|
||||
pub fn new(skills: Arc<SkillRuntime>, experts: Arc<ExpertRuntime>) -> Self {
|
||||
Self { skills, experts }
|
||||
}
|
||||
}
|
||||
|
||||
impl SystemPromptProvider for SkillPromptProvider {
|
||||
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
|
||||
// 调用 SkillRuntime 的 system_index_prompt 方法
|
||||
self.skills.system_index_prompt().map(|content| SystemPrompt {
|
||||
content,
|
||||
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
|
||||
// 读取所选专家的技能策略;无专家或无策略时走全局索引(主智能体默认)
|
||||
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()),
|
||||
})
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<String>,
|
||||
/// 子代理最大嵌套深度(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<SubagentRuntime>,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
store: Arc<SessionStore>,
|
||||
/// 技能运行时(实时计算技能索引,替代冻结快照)
|
||||
skills: Arc<SkillRuntime>,
|
||||
}
|
||||
|
||||
impl DefaultSubAgentRuntime {
|
||||
@ -365,6 +366,7 @@ impl DefaultSubAgentRuntime {
|
||||
subagent_runtime: Arc<SubagentRuntime>,
|
||||
bus: Option<Arc<MessageBus>>,
|
||||
store: Arc<SessionStore>,
|
||||
skills: Arc<SkillRuntime>,
|
||||
) -> 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<ToolRegistry> {
|
||||
let depth_deny_task = child_depth >= self.config.max_nesting_depth;
|
||||
let allowed: Option<&Vec<String>> = def.and_then(|d| d.allowed_tools.as_ref());
|
||||
let denied_tools: Option<&Vec<String>> = def.and_then(|d| d.denied_tools.as_ref());
|
||||
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<String>>,
|
||||
denied_tools: Option<&Vec<String>>,
|
||||
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<String>,
|
||||
/// 工具白名单(None 表示不过滤)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// 工具黑名单(None 表示不过滤)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub denied_tools: Option<Vec<String>>,
|
||||
/// 工具与技能加载策略。
|
||||
#[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<SubagentCatalog>,
|
||||
catalog: RwLock<SubagentCatalog>,
|
||||
disable_state: RwLock<SubagentDisableState>,
|
||||
#[allow(dead_code)]
|
||||
config: SubagentsConfig,
|
||||
@ -1136,10 +1143,10 @@ pub struct 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);
|
||||
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<SubagentWithStatus> {
|
||||
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
|
||||
let mut items: Vec<SubagentWithStatus> = self
|
||||
.catalog
|
||||
let catalog = self.catalog.read().expect("subagent catalog rwlock poisoned");
|
||||
let mut items: Vec<SubagentWithStatus> = 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<String> {
|
||||
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<String> {
|
||||
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<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));
|
||||
}
|
||||
|
||||
@ -1312,9 +1327,56 @@ impl SubagentRuntime {
|
||||
})
|
||||
}
|
||||
|
||||
/// 获取 catalog 引用(用于 DefaultSubAgentRuntime 等需要直接访问的场景)
|
||||
pub fn catalog(&self) -> &Arc<SubagentCatalog> {
|
||||
&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<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)]
|
||||
prompt_template: 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: Option<Vec<String>>,
|
||||
denied_tools: Vec<String>,
|
||||
#[serde(default)]
|
||||
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 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 {
|
||||
name,
|
||||
description: frontmatter.description.trim().to_string(),
|
||||
prompt_template,
|
||||
body: if body_content.is_empty() { None } else { Some(body_content) },
|
||||
allowed_tools: frontmatter.allowed_tools,
|
||||
denied_tools: frontmatter.denied_tools,
|
||||
capability,
|
||||
max_execution_secs: frontmatter.max_execution_secs,
|
||||
source,
|
||||
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)]
|
||||
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<String>),便于构造过滤参数
|
||||
fn s(v: &[&str]) -> Option<Vec<String>> {
|
||||
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,
|
||||
capability: CapabilityPolicy {
|
||||
allowed_skills: None,
|
||||
denied_skills: Vec::new(),
|
||||
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,
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String>,
|
||||
/// 工具白名单(None 表示不过滤,Some 时仅这些工具可用)
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// 工具黑名单(None 表示不过滤,Some 时这些工具被禁用;在白名单之后应用)
|
||||
pub denied_tools: Option<Vec<String>>,
|
||||
/// 工具与技能加载策略。全为空表示沿用默认配置(不过滤)。
|
||||
pub capability: CapabilityPolicy,
|
||||
/// 最大执行时间(秒),None 表示使用默认
|
||||
pub max_execution_secs: Option<u64>,
|
||||
/// 来源
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user