From c27efedb6c1f0b4c1b5cae49cc38f41eda672455 Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Thu, 30 Jul 2026 17:37:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(capability):=20=E6=89=A9=E5=B1=95=20Capabi?= =?UTF-8?q?lityPolicy=20=E6=94=AF=E6=8C=81=E5=AD=90=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E9=BB=91=E7=99=BD=E5=90=8D=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - domain: CapabilityPolicy 新增 allowed_subagents/denied_subagents 字段及 check_subagent_allowed 方法 - experts: 专家 frontmatter 解析/渲染支持子代理策略字段 - task/runtime: spawn/resume 双路径校验父代理子代理策略;新增 update_subagent 写回 SUBAGENT.md(与 update_expert 对称);新增 SubagentPromptProvider 按专家策略过滤子代理索引 - task/runtime: 子代理自身 capability 作为孙代理的 parent_capability 透传(ToolContext),保持解耦 - traits: ToolContext 新增 parent_capability 字段 - agent_factory: 主 agent 注入 expert_capability 到 ToolContext 安全:策略不通过即拒绝(与 def 不可用即拒绝范式一致),防止 LLM 通过选择被禁子代理绕过限制;max_nesting_depth 兜底防递归不可被 def 覆盖。 --- src/domain/mod.rs | 39 ++++++++++- src/experts/mod.rs | 12 ++++ src/gateway/agent_factory.rs | 19 ++++-- src/tools/task/runtime.rs | 123 +++++++++++++++++++++++++++++++---- src/tools/traits.rs | 6 ++ 5 files changed, 179 insertions(+), 20 deletions(-) diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 7054315..2af9e6b 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -3,12 +3,14 @@ pub mod tools; use serde::{Deserialize, Serialize}; -/// 角色能力策略:工具与技能的白/黑名单。全为空表示沿用默认(不过滤)。 +/// 角色能力策略:工具、技能、子代理的白/黑名单。全为空表示沿用默认(不过滤)。 /// /// 生效顺序:先白名单取交集,再黑名单扣除。专家与子代理共用此结构, /// 确保语义一致。MCP 工具注册在 `ToolRegistry` 中(名 `mcp_*`),与内置 /// 工具同源,因此 `allowed_tools`/`denied_tools` 覆盖内置 + MCP 工具; -/// `allowed_skills`/`denied_skills` 仅覆盖 SKILL.md 技能。 +/// `allowed_skills`/`denied_skills` 仅覆盖 SKILL.md 技能; +/// `allowed_subagents`/`denied_subagents` 覆盖子代理加载(通过 ToolContext +/// 传递给 TaskTool,在 spawn/resume 时强制校验)。 #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct CapabilityPolicy { /// 技能白名单:`None` = 不限;`Some(vec)` = 仅这些 SKILL.md 技能可见。 @@ -24,6 +26,13 @@ pub struct CapabilityPolicy { /// 工具黑名单(含 `mcp_*` 工具)。 #[serde(default, skip_serializing_if = "Vec::is_empty")] pub denied_tools: Vec, + /// 子代理白名单:`None` = 不限;`Some(vec)` = 仅这些子代理可被加载。 + /// 通过 ToolContext 传递给 TaskTool,在 spawn/resume 时强制校验。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_subagents: Option>, + /// 子代理黑名单:禁止加载这些子代理。空表示不禁。 + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub denied_subagents: Vec, } impl CapabilityPolicy { @@ -33,6 +42,8 @@ impl CapabilityPolicy { && self.denied_skills.is_empty() && self.allowed_tools.is_none() && self.denied_tools.is_empty() + && self.allowed_subagents.is_none() + && self.denied_subagents.is_empty() } /// 是否声明了任何技能策略。 @@ -44,4 +55,28 @@ impl CapabilityPolicy { pub fn has_tool_policy(&self) -> bool { self.allowed_tools.is_some() || !self.denied_tools.is_empty() } + + /// 是否声明了任何子代理策略。 + pub fn has_subagent_policy(&self) -> bool { + self.allowed_subagents.is_some() || !self.denied_subagents.is_empty() + } + + /// 校验指定子代理是否被允许。返回 Err 时附带拒绝原因。 + pub fn check_subagent_allowed(&self, name: &str) -> Result<(), String> { + if let Some(list) = &self.allowed_subagents { + if !list.iter().any(|s| s == name) { + return Err(format!( + "subagent '{}' is not in the allowed_subagents whitelist", + name + )); + } + } + if self.denied_subagents.iter().any(|s| s == name) { + return Err(format!( + "subagent '{}' is in the denied_subagents blacklist", + name + )); + } + Ok(()) + } } diff --git a/src/experts/mod.rs b/src/experts/mod.rs index 3fa26fc..a408858 100644 --- a/src/experts/mod.rs +++ b/src/experts/mod.rs @@ -738,6 +738,10 @@ struct ExpertFrontmatter { allowed_tools: Option>, #[serde(default)] denied_tools: Vec, + #[serde(default)] + allowed_subagents: Option>, + #[serde(default)] + denied_subagents: Vec, } /// 规范化字符串列表:去除空白项与首尾空格。 @@ -775,6 +779,10 @@ fn render_expert_file( allowed_tools: Option>, #[serde(skip_serializing_if = "Vec::is_empty")] denied_tools: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + allowed_subagents: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + denied_subagents: Vec, } let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned { @@ -784,6 +792,8 @@ fn render_expert_file( denied_skills: capability.denied_skills.clone(), allowed_tools: capability.allowed_tools.clone(), denied_tools: capability.denied_tools.clone(), + allowed_subagents: capability.allowed_subagents.clone(), + denied_subagents: capability.denied_subagents.clone(), }) .map_err(|err| format!("failed to render expert frontmatter: {}", err))?; @@ -876,6 +886,8 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result(零拷贝)。 let base_tool_count = self.tools.tool_names().len(); - let tools: Arc = match self.experts.selected_expert_for(&session_id) { - Some(expert) if expert.capability.has_tool_policy() => { - let filtered = self.build_filtered_registry(&expert.capability); + let tools: Arc = match &expert_capability { + Some(cap) if cap.has_tool_policy() => { + let filtered = self.build_filtered_registry(cap); 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" @@ -160,6 +165,8 @@ impl AgentFactory { task_id: None, parent_task_id: None, tool_call_id: None, + // 注入专家 capability,TaskTool 据此强制校验子代理白/黑名单 + parent_capability: expert_capability.clone(), }); // 如果有取消信号接收端,注入 Agent if let Some(token) = request.cancel_token { diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 7b5c248..4176fba 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -14,6 +14,7 @@ use crate::bus::MessageBus; use crate::domain::CapabilityPolicy; use crate::providers::StreamDelta; use crate::config::{LLMProviderConfig, SubagentsConfig}; +use crate::experts::ExpertRuntime; use crate::skills::SkillRuntime; use crate::storage::{ConversationRepository, SessionStore}; use crate::tools::{ToolContext, ToolRegistry}; @@ -487,6 +488,9 @@ impl DefaultSubAgentRuntime { task_id: Some(session.id.clone()), parent_task_id, tool_call_id: None, + // 子代理自身的 capability 作为孙代理的 parent_capability, + // 使孙代理的 TaskTool 能按此策略校验(与主 agent 注入专家 capability 同构) + parent_capability: def.map(|d| d.capability.clone()), }); // 如果有 MessageBus,附加实时广播 emitter @@ -627,12 +631,21 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { .clone() .ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?; - // 2. 查找子代理定义 + // 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。 + // 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝, + // 防止 LLM 通过选择被禁子代理绕过限制。 + if let Some(cap) = &parent_context.parent_capability { + if let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) { + return Err(TaskError::InvalidArguments(msg)); + } + } + + // 3. 查找子代理定义 let def = self .find_subagent_def(task.subagent_type.as_str()) .map_err(TaskError::InvalidArguments)?; - // 3. 创建任务会话 + // 4. 创建任务会话 let topic_id = parent_context.topic_id.clone(); let session = TaskSession::new( session_id, @@ -807,7 +820,16 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { &additional_prompt, ); - // 4.1 重新解析 def 以应用工具过滤。 + // 4.1 校验父智能体的子代理策略(白/黑名单)。 + // 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许 + // 该子代理的专家,resume 应失败(与 def 被删除即失败的安全语义一致)。 + if let Some(cap) = &parent_context.parent_capability { + if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) { + return Err(TaskError::InvalidArguments(msg)); + } + } + + // 4.2 重新解析 def 以应用工具过滤。 // 安全要求:def 被删除/禁用时必须失败恢复,而不是降级为完整工具集—— // 否则一个受限子代理(如 allowed_tools: [read])在 def 失踪后会获得全部工具, // 构成权限提升。与 spawn 保持一致:def 不可用即拒绝执行。 @@ -1243,6 +1265,53 @@ impl SubagentRuntime { Some(prompt) } + /// 生成按 capability 过滤后的系统索引提示词。 + /// 在禁用项过滤之上,再按 `allowed_subagents`(白名单取交集)和 + /// `denied_subagents`(黑名单扣除)过滤。用于专家/子代理的子代理策略。 + pub fn system_index_prompt_filtered_with_policy( + &self, + allowed: Option<&[String]>, + denied: &[String], + ) -> Option { + let state = self.disable_state.read().expect("subagent state rwlock poisoned"); + 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)) + .filter(|def| { + if let Some(list) = allowed { + list.iter().any(|s| s == &def.name) + } else { + true + } + }) + .filter(|def| !denied.iter().any(|s| s == &def.name)) + .collect(); + + if available_defs.is_empty() { + return None; + } + + let mut prompt = String::from( + "# 子代理系统\n\n\ + 子代理是专用的执行单元,用于处理特定类型的任务。\n\ + 创建子代理任务时,可以选择以下类型之一:\n\n\ + \n", + ); + + for def in available_defs { + prompt.push_str(&format!( + " \n {}\n {}\n \n", + xml_escape(&def.name), + xml_escape(&def.description), + )); + } + + prompt.push_str(""); + Some(prompt) + } + /// 禁用子代理 pub fn disable_subagent( &self, @@ -1383,24 +1452,42 @@ impl SubagentRuntime { /// 为子代理系统提供索引提示词 /// /// 负责提供过滤禁用项后的子代理系统索引提示词,注入主 agent。 +/// 当会话选中了带子代理策略的专家时,按专家 `CapabilityPolicy` 过滤子代理索引 +/// (与 `SkillPromptProvider` 过滤技能索引的模式同构)。 pub struct SubagentPromptProvider { runtime: Arc, + experts: Arc, } impl SubagentPromptProvider { - pub fn new(runtime: Arc) -> Self { - Self { runtime } + pub fn new(runtime: Arc, experts: Arc) -> Self { + Self { runtime, experts } } } impl SystemPromptProvider for SubagentPromptProvider { - fn build(&self, _context: &SystemPromptContext) -> Option { - self.runtime - .system_index_prompt_filtered() - .map(|content| SystemPrompt { - content, - context: Some("subagents".to_string()), - }) + fn build(&self, context: &SystemPromptContext) -> Option { + // 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认) + let content = match context.session_id.as_deref() { + Some(sid) => { + let policy = self + .experts + .selected_expert_for(sid) + .map(|e| e.capability); + match policy { + Some(p) if p.has_subagent_policy() => self.runtime.system_index_prompt_filtered_with_policy( + p.allowed_subagents.as_deref(), + &p.denied_subagents, + ), + _ => self.runtime.system_index_prompt_filtered(), + } + } + None => self.runtime.system_index_prompt_filtered(), + }; + content.map(|c| SystemPrompt { + content: c, + context: Some("subagents".to_string()), + }) } } @@ -1473,6 +1560,10 @@ struct SubagentFrontmatter { #[serde(default)] denied_tools: Vec, #[serde(default)] + allowed_subagents: Option>, + #[serde(default)] + denied_subagents: Vec, + #[serde(default)] max_execution_secs: Option, } @@ -1562,6 +1653,8 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result, #[serde(skip_serializing_if = "Option::is_none")] + allowed_subagents: Option>, + #[serde(skip_serializing_if = "Vec::is_empty")] + denied_subagents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] max_execution_secs: Option, } @@ -1620,6 +1717,8 @@ fn render_subagent_file( denied_skills: capability.denied_skills.clone(), allowed_tools: capability.allowed_tools.clone(), denied_tools: capability.denied_tools.clone(), + allowed_subagents: capability.allowed_subagents.clone(), + denied_subagents: capability.denied_subagents.clone(), max_execution_secs, }; diff --git a/src/tools/traits.rs b/src/tools/traits.rs index 5bf0457..dc90b80 100644 --- a/src/tools/traits.rs +++ b/src/tools/traits.rs @@ -1,5 +1,7 @@ use async_trait::async_trait; +use crate::domain::CapabilityPolicy; + #[derive(Debug, Clone)] pub struct ToolResult { pub success: bool, @@ -26,6 +28,10 @@ pub struct ToolContext { pub parent_task_id: Option, /// 当前工具调用的 ID(由 agent_loop 在执行前注入,用于精确关联 TaskStarted 事件) pub tool_call_id: Option, + /// 父智能体(主 agent 所选专家或上级子代理)的 capability 策略快照。 + /// TaskTool 据此强制校验子代理加载(白/黑名单),与 spawn/resume 安全范式一致。 + /// 以数据形式传递,避免 task 模块反向依赖 experts 模块。 + pub parent_capability: Option, } #[async_trait]