diff --git a/src/gateway/agent_factory.rs b/src/gateway/agent_factory.rs index c14e7aa..0854f8f 100644 --- a/src/gateway/agent_factory.rs +++ b/src/gateway/agent_factory.rs @@ -7,12 +7,14 @@ use crate::gateway::todo_prompt_provider::TodoPromptProvider; use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::storage::persistent_session_id; use crate::storage::PromptInjectionRepository; +use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime}; use crate::tools::{ToolContext, ToolRegistry}; #[derive(Clone)] pub(crate) struct AgentFactory { tools: Arc, skills: Arc, + subagent_runtime: Arc, reinject_every: usize, prompt_repository: Arc, /// 实例创建时间戳(用于区分新旧 AgentFactory 实例) @@ -36,6 +38,7 @@ impl AgentFactory { pub(crate) fn new( tools: Arc, skills: Arc, + subagent_runtime: Arc, reinject_every: usize, prompt_repository: Arc, ) -> Self { @@ -49,6 +52,7 @@ impl AgentFactory { Self { tools, skills, + subagent_runtime, reinject_every, prompt_repository, instance_id, @@ -77,6 +81,7 @@ impl AgentFactory { self.prompt_repository.clone(), )), Box::new(SkillPromptProvider::new(self.skills.clone())), + Box::new(SubagentPromptProvider::new(self.subagent_runtime.clone())), Box::new(TodoPromptProvider::new()), ])); diff --git a/src/gateway/http.rs b/src/gateway/http.rs index 7d11e22..f050890 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -6,6 +6,7 @@ use std::sync::Arc; use super::GatewayState; use crate::config::{Config, get_default_config_path}; use crate::skills::SkillWithStatus; +use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus}; #[derive(Deserialize)] pub struct SkillToggleRequest { @@ -294,3 +295,118 @@ pub async fn skills_toggle( } } } + +#[derive(Deserialize)] +pub struct SubagentToggleRequest { + pub name: String, + pub scope: String, + pub enabled: bool, +} + +#[derive(Serialize)] +pub struct SubagentToggleResponse { + success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + changed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + available: Option, + #[serde(skip_serializing_if = "Option::is_none")] + disabled_in_scopes: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option, +} + +#[derive(Serialize)] +pub struct SubagentListResponse { + subagents_system_enabled: bool, + total: usize, + subagents: Vec, +} + +/// GET /api/subagents — Return all discovered subagents with their disabled status +pub async fn subagents_list( + State(state): State>, +) -> Json { + let subagents_enabled = state.config.read().await.subagents.enabled; + + if !subagents_enabled { + return Json(SubagentListResponse { + subagents_system_enabled: false, + total: 0, + subagents: vec![], + }); + } + + let subagents = state.subagent_runtime.list_with_status(); + let total = subagents.len(); + + Json(SubagentListResponse { + subagents_system_enabled: true, + total, + subagents, + }) +} + +/// POST /api/subagents/toggle — Enable or disable a specific subagent +pub async fn subagents_toggle( + State(state): State>, + Json(req): Json, +) -> (StatusCode, Json) { + let scope = match SubagentScope::parse(&req.scope) { + Some(s) => s, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(SubagentToggleResponse { + success: false, + changed: None, + available: None, + disabled_in_scopes: None, + error: Some(format!("invalid scope: {}", req.scope)), + }), + ); + } + }; + + let result = if req.enabled { + state.subagent_runtime.enable_subagent(scope, &req.name) + } else { + state.subagent_runtime.disable_subagent(scope, &req.name) + }; + + match result { + Ok(change) => ( + StatusCode::OK, + Json(SubagentToggleResponse { + success: true, + changed: Some(change.changed), + available: Some(change.available), + disabled_in_scopes: Some( + change + .disabled_in_scopes + .iter() + .map(|s| s.as_str().to_string()) + .collect(), + ), + error: None, + }), + ), + Err(msg) => { + let status = if msg.contains("not found") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + }; + ( + status, + Json(SubagentToggleResponse { + success: false, + changed: None, + available: None, + disabled_in_scopes: None, + error: Some(msg), + }), + ) + } + } +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 622b1b9..886f868 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -43,6 +43,7 @@ use crate::logging; use crate::scheduler::Scheduler; use crate::skills::SkillRuntime; use crate::tools::task::repository::TaskRepository; +use crate::tools::task::runtime::SubagentRuntime; use agent_task_executor::{AgentTaskExecutor, SchedulerMaintenanceService}; use cancel_manager::CancelManager; use outbound_dispatcher::OutboundDispatcher; @@ -64,6 +65,7 @@ pub struct GatewayState { pub restart_tx: watch::Sender, pub mcp_manager: Option>, pub skills: Arc, + pub subagent_runtime: Arc, } impl GatewayState { @@ -88,7 +90,7 @@ impl GatewayState { mcp_servers: config.mcp_servers.clone(), }; - let (session_manager, task_repository, mcp_manager) = build_session_manager_with_sender( + let (session_manager, task_repository, mcp_manager, subagent_runtime) = build_session_manager_with_sender( agent_prompt_reinject_every, show_tool_results, config.time.timezone.clone(), @@ -123,6 +125,7 @@ impl GatewayState { restart_tx, mcp_manager, skills, + subagent_runtime, }) } @@ -234,6 +237,8 @@ pub async fn run( .route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/skills", routing::get(http::skills_list)) .route("/api/skills/toggle", routing::post(http::skills_toggle)) + .route("/api/subagents", routing::get(http::subagents_list)) + .route("/api/subagents/toggle", routing::post(http::subagents_toggle)) .route("/ws", routing::get(ws::ws_handler)) .fallback(static_handler) .with_state(state.clone()) @@ -246,6 +251,8 @@ pub async fn run( .route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/skills", routing::get(http::skills_list)) .route("/api/skills/toggle", routing::post(http::skills_toggle)) + .route("/api/subagents", routing::get(http::subagents_list)) + .route("/api/subagents/toggle", routing::post(http::subagents_toggle)) .route("/ws", routing::get(ws::ws_handler)) .fallback_service(ServeDir::new(&static_dir)) .with_state(state.clone()) diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index e609267..6f7296b 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -1,6 +1,7 @@ //! Gateway Runtime - builds SessionManager with decoupled MCP integration use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; @@ -16,6 +17,7 @@ use crate::storage::{ ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository, SessionStore, SkillEventRepository, TodoRepository, }; +use crate::tools::task::runtime::SubagentRuntime; use crate::tools::{ DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry, @@ -50,7 +52,7 @@ pub(crate) fn build_session_manager( session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, bus: Option>, -) -> Result<(SessionManager, Arc, Option>), AgentError> { +) -> Result<(SessionManager, Arc, Option>, Arc), AgentError> { build_session_manager_with_sender( agent_prompt_reinject_every, show_tool_results, @@ -85,7 +87,7 @@ pub(crate) fn build_session_manager_with_sender( session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, bus: Option>, -) -> Result<(SessionManager, Arc, Option>), AgentError> { +) -> Result<(SessionManager, Arc, Option>, Arc), AgentError> { let store = Arc::new( SessionStore::new() .map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?, @@ -172,7 +174,7 @@ pub(crate) fn build_session_manager_with_sender( } // Create SubAgentRuntime (if task tool is enabled) - let (factory, task_repository): (_, Arc) = if task_config.enabled { + let (factory, task_repository, subagent_runtime): (_, Arc, Arc) = if task_config.enabled { let task_repository = Arc::new(InMemoryTaskRepository::new()); // Build subagent tools with MCP tools (task tool registered separately below) let subagent_tools = Arc::new( @@ -185,8 +187,13 @@ pub(crate) fn build_session_manager_with_sender( ) ); - // Create subagent catalog with discovery + // Create subagent catalog with discovery, wrap in SubagentRuntime let catalog = Arc::new(SubagentCatalog::discover(&subagents_config)); + let subagent_runtime = Arc::new(SubagentRuntime::new( + subagents_config.clone(), + catalog, + std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + )); let runtime_config = SubAgentRuntimeConfig { default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(), @@ -197,13 +204,13 @@ pub(crate) fn build_session_manager_with_sender( max_nesting_depth: task_config.max_nesting_depth, }; - let subagent_runtime = Arc::new(DefaultSubAgentRuntime::new( + let default_subagent_runtime = Arc::new(DefaultSubAgentRuntime::new( runtime_config, task_repository.clone(), conversations.clone(), subagent_tools.clone(), provider_config.clone(), - catalog, + subagent_runtime.clone(), bus.clone(), store.clone(), )); @@ -211,14 +218,16 @@ pub(crate) fn build_session_manager_with_sender( // 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖) if factory.is_enabled("task") { subagent_tools.register(TaskTool::new( - subagent_runtime.clone(), + default_subagent_runtime.clone(), Some(task_config.max_nesting_depth), )); } - (factory.with_subagent_runtime(subagent_runtime), task_repository) + (factory.with_subagent_runtime(default_subagent_runtime), task_repository, subagent_runtime) } else { - (factory, Arc::new(InMemoryTaskRepository::new())) + // task_config 未启用时仍创建 subagent_runtime(供 API 使用) + let subagent_runtime = Arc::new(SubagentRuntime::from_config(subagents_config.clone())); + (factory, Arc::new(InMemoryTaskRepository::new()), subagent_runtime) }; // Build base tools @@ -260,6 +269,7 @@ pub(crate) fn build_session_manager_with_sender( let agent_factory = AgentFactory::new( tools.clone(), skills.clone(), + subagent_runtime.clone(), agent_prompt_reinject_every as usize, prompt_repository.clone(), ); @@ -296,5 +306,5 @@ pub(crate) fn build_session_manager_with_sender( scheduled_tasks, memory_maintenance, task_repository: task_repository.clone(), - }), task_repository, mcp_manager)) + }), task_repository, mcp_manager, subagent_runtime)) } diff --git a/src/gateway/session.rs b/src/gateway/session.rs index f98f2e8..fcfef3a 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -10,6 +10,7 @@ use crate::skills::SkillRuntime; use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository}; use crate::tools::ToolRegistry; use crate::tools::task::repository::TaskRepository; +use crate::tools::task::runtime::SubagentRuntime; use async_trait::async_trait; use std::collections::HashMap; use std::sync::Arc; @@ -249,6 +250,7 @@ impl Session { skills: Arc, store: Arc, agent_prompt_reinject_every: u64, + subagent_runtime: Arc, ) -> Result { let conversations: Arc = store.clone(); let skill_events: Arc = store.clone(); @@ -256,6 +258,7 @@ impl Session { let agent_factory = AgentFactory::new( tools, skills.clone(), + subagent_runtime, agent_prompt_reinject_every as usize, prompt_repository.clone(), ); @@ -682,7 +685,7 @@ impl SessionManager { mcp_config, None, ) - .map(|(session_manager, _, _)| session_manager) + .map(|(session_manager, _, _, _)| session_manager) } pub fn tools(&self) -> Arc { @@ -938,6 +941,7 @@ mod tests { skills, store, 100, + Arc::new(SubagentRuntime::from_config(Default::default())), ) .await .unwrap(); @@ -986,6 +990,7 @@ mod tests { skills, store.clone(), 100, + Arc::new(SubagentRuntime::from_config(Default::default())), ) .await .unwrap(); @@ -2012,6 +2017,7 @@ mod tests { skills, store.clone(), 100, + Arc::new(SubagentRuntime::from_config(Default::default())), ) .await .unwrap(); @@ -2052,6 +2058,7 @@ mod tests { skills, store.clone(), 100, + Arc::new(SubagentRuntime::from_config(Default::default())), ) .await .unwrap(); @@ -2125,6 +2132,7 @@ mod tests { skills, store.clone(), 0, + Arc::new(SubagentRuntime::from_config(Default::default())), ) .await .unwrap(); diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 8344b28..2e9109b 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use std::time::Duration; use async_trait::async_trait; @@ -295,8 +295,8 @@ pub struct DefaultSubAgentRuntime { conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, - /// 子代理定义目录(内置 + 自定义) - catalog: Arc, + /// 子代理运行时协调层(管理禁用状态) + subagent_runtime: Arc, bus: Option>, store: Arc, } @@ -308,7 +308,7 @@ impl DefaultSubAgentRuntime { conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, - catalog: Arc, + subagent_runtime: Arc, bus: Option>, store: Arc, ) -> Self { @@ -318,18 +318,17 @@ impl DefaultSubAgentRuntime { conversation_repository, subagent_tools, provider_config, - catalog, + subagent_runtime, bus, store, } } - /// 查找子代理定义,找不到时 fallback 到 general - fn find_subagent_def(&self, type_name: &str) -> SubagentDef { - self.catalog - .find(type_name) - .cloned() - .unwrap_or_else(|| self.catalog.find("general").expect("general subagent must exist").clone()) + /// 查找子代理定义(过滤禁用项),找不到或被禁用时返回 Err + fn find_subagent_def(&self, type_name: &str) -> Result { + self.subagent_runtime + .find_available(type_name) + .ok_or_else(|| format!("subagent type '{}' is disabled or not found", type_name)) } /// 获取实际使用的工具白名单(预留,未来可用于动态工具过滤) @@ -530,7 +529,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { .ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?; // 2. 查找子代理定义 - let def = self.find_subagent_def(task.subagent_type.as_str()); + let def = self + .find_subagent_def(task.subagent_type.as_str()) + .map_err(TaskError::InvalidArguments)?; // 3. 创建任务会话 let topic_id = parent_context.topic_id.clone(); @@ -733,7 +734,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { } fn available_subagent_names(&self) -> Vec { - self.catalog.names() + self.subagent_runtime.available_names() } } @@ -870,6 +871,357 @@ fn xml_escape(s: &str) -> String { .replace('\'', "'") } +// ========== 子代理运行时协调层(管理禁用状态) ========== + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SubagentScope { + User, + Project, +} + +impl SubagentScope { + pub fn parse(value: &str) -> Option { + match value { + "user" => Some(Self::User), + "project" => Some(Self::Project), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::User => "user", + Self::Project => "project", + } + } +} + +/// A subagent entry with its disabled status across scopes. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SubagentWithStatus { + pub name: String, + pub description: String, + pub source: String, + /// Which scopes have this subagent disabled. Empty means enabled. + pub disabled_in_scopes: Vec, +} + +#[derive(Debug, Clone)] +pub struct SubagentAvailabilityChange { + pub name: String, + pub scope: SubagentScope, + pub changed: bool, + pub disabled_in_scopes: Vec, + pub available: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +struct SubagentStateFile { + #[serde(default)] + disabled_subagents: Vec, +} + +#[derive(Debug, Clone, Default)] +struct SubagentDisableState { + user_disabled: HashSet, + project_disabled: HashSet, +} + +impl SubagentDisableState { + fn is_disabled(&self, name: &str) -> bool { + self.user_disabled.contains(name) || self.project_disabled.contains(name) + } + + fn disabled_scopes_for(&self, name: &str) -> Vec { + let mut scopes = Vec::new(); + if self.user_disabled.contains(name) { + scopes.push(SubagentScope::User); + } + if self.project_disabled.contains(name) { + scopes.push(SubagentScope::Project); + } + scopes + } +} + +fn user_subagent_state_path() -> Option { + crate::platform::home_dir().map(|p| p.join(".picobot").join("subagent-state.json")) +} + +fn project_subagent_state_path(cwd: &Path) -> PathBuf { + cwd.join(".picobot").join("subagent-state.json") +} + +fn subagent_state_path(scope: SubagentScope, cwd: &Path) -> PathBuf { + match scope { + SubagentScope::User => user_subagent_state_path() + .unwrap_or_else(|| cwd.join(".picobot").join("subagent-state.json")), + SubagentScope::Project => project_subagent_state_path(cwd), + } +} + +fn load_subagent_disable_state(cwd: &Path) -> SubagentDisableState { + SubagentDisableState { + user_disabled: user_subagent_state_path() + .map(|path| load_disabled_subagent_names(&path)) + .unwrap_or_default(), + project_disabled: load_disabled_subagent_names(&project_subagent_state_path(cwd)), + } +} + +fn load_disabled_subagent_names(path: &Path) -> HashSet { + match load_subagent_state_file(path) { + Ok(state) => state.disabled_subagents.into_iter().collect(), + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "Failed to load subagent state file"); + HashSet::new() + } + } +} + +fn load_subagent_state_file(path: &Path) -> Result { + if !path.exists() { + return Ok(SubagentStateFile::default()); + } + let content = fs::read_to_string(path) + .map_err(|err| format!("failed to read subagent state file: {}", err))?; + serde_json::from_str(&content) + .map_err(|err| format!("failed to parse subagent state file: {}", err)) +} + +fn save_subagent_state_file(path: &Path, state: &SubagentStateFile) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create subagent state directory: {}", err))?; + } + let content = serde_json::to_string_pretty(state) + .map_err(|err| format!("failed to render subagent state file: {}", err))?; + let tmp_path = path.with_extension("json.tmp"); + fs::write(&tmp_path, format!("{}\n", content)) + .map_err(|err| format!("failed to write temporary subagent state file: {}", err))?; + crate::platform::atomic_rename(&tmp_path, path) + .map_err(|err| format!("failed to persist subagent state file: {}", err))?; + Ok(()) +} + +/// 子代理运行时协调层 +/// +/// 在 `SubagentCatalog`(纯数据容器)之上管理禁用状态,所有过滤逻辑在此层。 +/// 对齐 `SkillRuntime` 模式。 +#[derive(Debug)] +pub struct SubagentRuntime { + catalog: Arc, + disable_state: RwLock, + #[allow(dead_code)] + config: SubagentsConfig, + cwd: PathBuf, +} + +impl SubagentRuntime { + pub fn new(config: SubagentsConfig, catalog: Arc, cwd: PathBuf) -> Self { + let disable_state = load_subagent_disable_state(&cwd); + Self { + catalog, + disable_state: RwLock::new(disable_state), + config, + cwd, + } + } + + /// 从配置构造(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)); + Self::new(config, catalog, cwd) + } + + /// 列出所有子代理(含禁用项),带 disabled_in_scopes + pub fn list_with_status(&self) -> Vec { + let state = self.disable_state.read().expect("subagent state rwlock poisoned"); + let mut items: Vec = self + .catalog + .all() + .iter() + .map(|def| { + let scopes = state.disabled_scopes_for(&def.name); + SubagentWithStatus { + name: def.name.clone(), + description: def.description.clone(), + source: def.source.as_str().to_string(), + disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), + } + }) + .collect(); + items.sort_by(|a, b| a.name.cmp(&b.name)); + items + } + + /// 可用子代理名称(过滤禁用项) + pub fn available_names(&self) -> Vec { + let state = self.disable_state.read().expect("subagent state rwlock poisoned"); + self.catalog + .names() + .into_iter() + .filter(|name| !state.is_disabled(name)) + .collect() + } + + /// 查找可用子代理(过滤禁用项) + pub fn find_available(&self, name: &str) -> Option { + let state = self.disable_state.read().expect("subagent state rwlock poisoned"); + if state.is_disabled(name) { + return None; + } + self.catalog.find(name).cloned() + } + + /// 生成过滤后的系统索引提示词 + pub fn system_index_prompt_filtered(&self) -> Option { + let state = self.disable_state.read().expect("subagent state rwlock poisoned"); + let available_defs: Vec<&SubagentDef> = self + .catalog + .all() + .into_iter() + .filter(|def| !state.is_disabled(&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, + scope: SubagentScope, + name: &str, + ) -> Result { + self.set_subagent_enabled(scope, name, false) + } + + /// 启用子代理 + pub fn enable_subagent( + &self, + scope: SubagentScope, + name: &str, + ) -> Result { + self.set_subagent_enabled(scope, name, true) + } + + fn set_subagent_enabled( + &self, + scope: SubagentScope, + name: &str, + enabled: bool, + ) -> Result { + // 校验子代理存在 + if self.catalog.find(name).is_none() { + return Err(format!("subagent '{}' not found", name)); + } + + // 更新对应 scope 的 state 文件 + let state_path = subagent_state_path(scope, &self.cwd); + let mut state_file = load_subagent_state_file(&state_path)?; + let mut disabled: HashSet = state_file.disabled_subagents.into_iter().collect(); + let changed = if enabled { + disabled.remove(name) + } else { + disabled.insert(name.to_string()) + }; + + let mut disabled_list: Vec = disabled.into_iter().collect(); + disabled_list.sort(); + state_file.disabled_subagents = disabled_list; + save_subagent_state_file(&state_path, &state_file)?; + + // 更新内存中的 disable_state + { + let mut state = self + .disable_state + .write() + .expect("subagent state rwlock poisoned"); + match scope { + SubagentScope::User => { + if enabled { + state.user_disabled.remove(name); + } else { + state.user_disabled.insert(name.to_string()); + } + } + SubagentScope::Project => { + if enabled { + state.project_disabled.remove(name); + } else { + state.project_disabled.insert(name.to_string()); + } + } + } + } + + // 计算新的 disabled_in_scopes + let state = self + .disable_state + .read() + .expect("subagent state rwlock poisoned"); + let disabled_in_scopes = state.disabled_scopes_for(name); + + Ok(SubagentAvailabilityChange { + name: name.to_string(), + scope, + changed, + available: disabled_in_scopes.is_empty(), + disabled_in_scopes, + }) + } + + /// 获取 catalog 引用(用于 DefaultSubAgentRuntime 等需要直接访问的场景) + pub fn catalog(&self) -> &Arc { + &self.catalog + } +} + +/// 为子代理系统提供索引提示词 +/// +/// 负责提供过滤禁用项后的子代理系统索引提示词,注入主 agent。 +pub struct SubagentPromptProvider { + runtime: Arc, +} + +impl SubagentPromptProvider { + pub fn new(runtime: Arc) -> Self { + Self { runtime } + } +} + +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()), + }) + } +} + // ========== 自定义子代理发现 ========== /// 源顺序解析 @@ -1056,3 +1408,166 @@ fn split_frontmatter(content: &str) -> Option<(&str, &str)> { Some((frontmatter, body)) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::SubagentsConfig; + + static SUBAGENT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> { + SUBAGENT_TEST_ENV_LOCK + .lock() + .unwrap_or_else(|err| err.into_inner()) + } + + struct HomeDirGuard { + previous: Option, + previous_userprofile: Option, + } + + impl HomeDirGuard { + fn enter(path: &Path) -> Self { + let home_backup = std::env::var_os("HOME"); + let userprofile_backup = std::env::var_os("USERPROFILE"); + unsafe { + std::env::set_var("HOME", path); + std::env::set_var("USERPROFILE", path); + } + Self { + previous: home_backup, + previous_userprofile: userprofile_backup, + } + } + } + + impl Drop for HomeDirGuard { + fn drop(&mut self) { + unsafe { + match &self.previous { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } + match &self.previous_userprofile { + Some(value) => std::env::set_var("USERPROFILE", value), + None => std::env::remove_var("USERPROFILE"), + } + } + } + } + + fn make_runtime(cwd: &Path) -> SubagentRuntime { + let catalog = Arc::new(SubagentCatalog::new()); + SubagentRuntime::new(SubagentsConfig::default(), catalog, cwd.to_path_buf()) + } + + #[test] + fn test_disable_subagent_filters_from_prompt() { + let _lock = acquire_test_lock(); + let temp = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let _home_guard = HomeDirGuard::enter(home.path()); + + let runtime = make_runtime(temp.path()); + + // general 在初始 prompt 中 + let prompt = runtime.system_index_prompt_filtered().unwrap(); + assert!(prompt.contains("general")); + + // 在 project scope 禁用 general + let change = runtime + .disable_subagent(SubagentScope::Project, "general") + .unwrap(); + assert!(change.changed); + assert!(!change.available); + + // 禁用后 prompt 不应包含 general(explore 仍可用,所以 prompt 仍为 Some) + let prompt = runtime.system_index_prompt_filtered().unwrap(); + assert!(!prompt.contains("general")); + assert!(prompt.contains("explore")); + } + + #[test] + fn test_enable_subagent_restores() { + let _lock = acquire_test_lock(); + let temp = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let _home_guard = HomeDirGuard::enter(home.path()); + + let runtime = make_runtime(temp.path()); + + runtime + .disable_subagent(SubagentScope::Project, "general") + .unwrap(); + let prompt = runtime.system_index_prompt_filtered().unwrap(); + assert!(!prompt.contains("general")); + + let change = runtime + .enable_subagent(SubagentScope::Project, "general") + .unwrap(); + assert!(change.changed); + assert!(change.available); + + let prompt = runtime.system_index_prompt_filtered().unwrap(); + assert!(prompt.contains("general")); + } + + #[test] + fn test_list_with_status_includes_disabled() { + let _lock = acquire_test_lock(); + let temp = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let _home_guard = HomeDirGuard::enter(home.path()); + + let runtime = make_runtime(temp.path()); + runtime + .disable_subagent(SubagentScope::Project, "general") + .unwrap(); + + let items = runtime.list_with_status(); + let general = items.iter().find(|i| i.name == "general").unwrap(); + assert!(general + .disabled_in_scopes + .contains(&"project".to_string())); + + // explore 应仍启用 + let explore = items.iter().find(|i| i.name == "explore").unwrap(); + assert!(explore.disabled_in_scopes.is_empty()); + } + + #[test] + fn test_find_available_filters_disabled() { + let _lock = acquire_test_lock(); + let temp = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let _home_guard = HomeDirGuard::enter(home.path()); + + let runtime = make_runtime(temp.path()); + runtime + .disable_subagent(SubagentScope::Project, "general") + .unwrap(); + + assert!(runtime.find_available("general").is_none()); + assert!(runtime.find_available("explore").is_some()); + + // available_names 不应包含 general + let names = runtime.available_names(); + assert!(!names.contains(&"general".to_string())); + assert!(names.contains(&"explore".to_string())); + } + + #[test] + fn test_disable_unknown_subagent_errors() { + let _lock = acquire_test_lock(); + let temp = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let _home_guard = HomeDirGuard::enter(home.path()); + + let runtime = make_runtime(temp.path()); + let err = runtime + .disable_subagent(SubagentScope::Project, "nonexistent") + .unwrap_err(); + assert!(err.contains("not found")); + } +} diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index 029baa0..8365510 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -36,6 +36,17 @@ pub enum SubagentSource { Custom(String), } +impl SubagentSource { + pub fn as_str(&self) -> &str { + match self { + Self::Builtin => "builtin", + Self::User => "user", + Self::Project => "project", + Self::Custom(_) => "custom", + } + } +} + /// 子代理完整定义 #[derive(Debug, Clone)] pub struct SubagentDef { diff --git a/web/src/components/Settings/ConfigPage.tsx b/web/src/components/Settings/ConfigPage.tsx index 1eedea8..22d25b9 100644 --- a/web/src/components/Settings/ConfigPage.tsx +++ b/web/src/components/Settings/ConfigPage.tsx @@ -46,6 +46,19 @@ interface SkillListResponse { skills: SkillItem[] } +interface SubagentItem { + name: string + description: string + source: string + disabled_in_scopes: string[] +} + +interface SubagentListResponse { + subagents_system_enabled: boolean + total: number + subagents: SubagentItem[] +} + interface McpServerStatus { key: string name: string @@ -89,20 +102,20 @@ interface ConfigPageProps { type TabId = 'connection' | 'gateway' | 'providers' | 'models' | 'agents' | 'time' | 'scheduler' | 'skills' | 'tools' | 'memory' | 'image' | 'subagents' | 'mcp' | 'channels' const TABS: { id: TabId; label: string; icon: typeof Settings }[] = [ - { id: 'connection', label: '连接', icon: Wifi }, - { id: 'gateway', label: '网关', icon: Server }, { id: 'providers', label: '服务商', icon: Cpu }, { id: 'models', label: '模型', icon: Brain }, { id: 'agents', label: '代理', icon: Bot }, - { id: 'time', label: '时间', icon: Clock }, - { id: 'scheduler', label: '调度器', icon: Calendar }, + { id: 'mcp', label: 'MCP 服务器', icon: Plug }, { id: 'skills', label: '技能', icon: Wrench }, + { id: 'subagents', label: '子代理', icon: Bot }, + { id: 'channels', label: '渠道', icon: Radio }, { id: 'tools', label: '工具', icon: Settings }, { id: 'memory', label: '记忆维护', icon: Users }, + { id: 'scheduler', label: '调度器', icon: Calendar }, { id: 'image', label: '图片上下文', icon: Image }, - { id: 'subagents', label: '子代理', icon: Bot }, - { id: 'mcp', label: 'MCP 服务器', icon: Plug }, - { id: 'channels', label: '渠道', icon: Radio }, + { id: 'time', label: '时间', icon: Clock }, + { id: 'connection', label: '连接', icon: Wifi }, + { id: 'gateway', label: '网关', icon: Server }, ] // ── Shared UI primitives ─────────────────────────────── @@ -177,11 +190,14 @@ function TagEditor({ tags, onChange }: { tags: string[]; onChange: (t: string[]) ) } -function SectionCard({ title, children }: { title: string; children: ReactNode }) { +function SectionCard({ title, subtitle, children }: { title: string; subtitle?: string; children: ReactNode }) { return (
-

{title}

+
+

{title}

+ {subtitle && {subtitle}} +
{children}
@@ -302,7 +318,7 @@ function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete: // ── Main Component ───────────────────────────────────── export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { const [config, setConfig] = useState(null) - const [activeTab, setActiveTab] = useState('gateway') + const [activeTab, setActiveTab] = useState('providers') const [loading, setLoading] = useState(true) // Connection settings (localStorage-based) const [connHost, setConnHost] = useState(() => { @@ -322,6 +338,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { const [mcpStatus, setMcpStatus] = useState(null) const [skillList, setSkillList] = useState(null) const [skillListLoading, setSkillListLoading] = useState(false) + const [subagentList, setSubagentList] = useState(null) + const [subagentListLoading, setSubagentListLoading] = useState(false) const fetchMcpStatus = useCallback(async () => { try { @@ -348,6 +366,24 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { return resp }, []) + const fetchSubagentList = useCallback(async () => { + setSubagentListLoading(true) + try { + const resp = await fetch('/api/subagents') + if (resp.ok) setSubagentList(await resp.json()) + } catch { /* ignore fetch errors */ } + finally { setSubagentListLoading(false) } + }, []) + + const toggleSubagent = useCallback(async (name: string, scope: string, enabled: boolean) => { + const resp = await fetch('/api/subagents/toggle', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, scope, enabled }), + }) + return resp + }, []) + const handleClose = useCallback(() => { if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return onClose() @@ -371,6 +407,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { if (activeTab === 'skills') fetchSkillList() }, [activeTab, fetchSkillList]) + // Fetch subagent list when subagents tab is selected + useEffect(() => { + if (activeTab === 'subagents') fetchSubagentList() + }, [activeTab, fetchSubagentList]) + // ESC to close useEffect(() => { const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() } @@ -709,7 +750,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { } return ( - + {skillListLoading && skills.length === 0 ? (
加载中... @@ -804,9 +845,81 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { examplePaths={['D:\\my-subagents', '/home/user/shared-agents']} /> + {renderDiscoveredSubagents()}
) + const renderDiscoveredSubagents = () => { + if (!subagentList || !subagentList.subagents_system_enabled) return null + + const subagents = subagentList.subagents + + const handleToggle = async (name: string, currentlyEnabled: boolean) => { + const prevList = subagentList + setSubagentList({ + ...subagentList, + subagents: subagents.map(s => + s.name === name + ? { ...s, disabled_in_scopes: currentlyEnabled ? ['project'] : [] } + : s + ), + }) + + try { + const resp = await toggleSubagent(name, 'project', !currentlyEnabled) + const data = await resp.json() + if (!resp.ok || !data.success) { + setSubagentList(prevList) + setToast(data.error || '切换子代理状态失败') + setTimeout(() => setToast(''), 3000) + return + } + setSubagentList({ + ...prevList, + subagents: prevList.subagents.map(s => + s.name === name + ? { ...s, disabled_in_scopes: data.disabled_in_scopes || [] } + : s + ), + }) + } catch { + setSubagentList(prevList) + setToast('网络错误,切换子代理状态失败') + setTimeout(() => setToast(''), 3000) + } + } + + return ( + + {subagentListLoading && subagents.length === 0 ? ( +
+ 加载中... +
+ ) : subagents.length === 0 ? ( +

未发现任何子代理

+ ) : ( +
+ {subagents.map(subagent => { + const isEnabled = subagent.disabled_in_scopes.length === 0 + return ( +
+
+
+ {subagent.name} + {subagent.source} +
+

{subagent.description}

+
+ handleToggle(subagent.name, isEnabled)} /> +
+ ) + })} +
+ )} +
+ ) + } + const renderMcp = () => { const entries = Object.entries(config.mcpServers) const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key)