feat: 新增子代理管理API,支持获取子代理列表和切换子代理状态

This commit is contained in:
oudecheng 2026-07-06 14:41:30 +08:00
parent 38b9f661ee
commit 8d30ccd020
8 changed files with 821 additions and 36 deletions

View File

@ -7,12 +7,14 @@ use crate::gateway::todo_prompt_provider::TodoPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::persistent_session_id; use crate::storage::persistent_session_id;
use crate::storage::PromptInjectionRepository; use crate::storage::PromptInjectionRepository;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::{ToolContext, ToolRegistry}; use crate::tools::{ToolContext, ToolRegistry};
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct AgentFactory { pub(crate) struct AgentFactory {
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize, reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例) /// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
@ -36,6 +38,7 @@ impl AgentFactory {
pub(crate) fn new( pub(crate) fn new(
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize, reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
) -> Self { ) -> Self {
@ -49,6 +52,7 @@ impl AgentFactory {
Self { Self {
tools, tools,
skills, skills,
subagent_runtime,
reinject_every, reinject_every,
prompt_repository, prompt_repository,
instance_id, instance_id,
@ -77,6 +81,7 @@ impl AgentFactory {
self.prompt_repository.clone(), self.prompt_repository.clone(),
)), )),
Box::new(SkillPromptProvider::new(self.skills.clone())), Box::new(SkillPromptProvider::new(self.skills.clone())),
Box::new(SubagentPromptProvider::new(self.subagent_runtime.clone())),
Box::new(TodoPromptProvider::new()), Box::new(TodoPromptProvider::new()),
])); ]));

View File

@ -6,6 +6,7 @@ use std::sync::Arc;
use super::GatewayState; use super::GatewayState;
use crate::config::{Config, get_default_config_path}; use crate::config::{Config, get_default_config_path};
use crate::skills::SkillWithStatus; use crate::skills::SkillWithStatus;
use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus};
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct SkillToggleRequest { 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<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct SubagentListResponse {
subagents_system_enabled: bool,
total: usize,
subagents: Vec<SubagentWithStatus>,
}
/// GET /api/subagents — Return all discovered subagents with their disabled status
pub async fn subagents_list(
State(state): State<Arc<GatewayState>>,
) -> Json<SubagentListResponse> {
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<Arc<GatewayState>>,
Json(req): Json<SubagentToggleRequest>,
) -> (StatusCode, Json<SubagentToggleResponse>) {
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),
}),
)
}
}
}

View File

@ -43,6 +43,7 @@ use crate::logging;
use crate::scheduler::Scheduler; use crate::scheduler::Scheduler;
use crate::skills::SkillRuntime; use crate::skills::SkillRuntime;
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime;
use agent_task_executor::{AgentTaskExecutor, SchedulerMaintenanceService}; use agent_task_executor::{AgentTaskExecutor, SchedulerMaintenanceService};
use cancel_manager::CancelManager; use cancel_manager::CancelManager;
use outbound_dispatcher::OutboundDispatcher; use outbound_dispatcher::OutboundDispatcher;
@ -64,6 +65,7 @@ pub struct GatewayState {
pub restart_tx: watch::Sender<bool>, pub restart_tx: watch::Sender<bool>,
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>, pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
pub skills: Arc<SkillRuntime>, pub skills: Arc<SkillRuntime>,
pub subagent_runtime: Arc<SubagentRuntime>,
} }
impl GatewayState { impl GatewayState {
@ -88,7 +90,7 @@ impl GatewayState {
mcp_servers: config.mcp_servers.clone(), 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, agent_prompt_reinject_every,
show_tool_results, show_tool_results,
config.time.timezone.clone(), config.time.timezone.clone(),
@ -123,6 +125,7 @@ impl GatewayState {
restart_tx, restart_tx,
mcp_manager, mcp_manager,
skills, skills,
subagent_runtime,
}) })
} }
@ -234,6 +237,8 @@ pub async fn run(
.route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list)) .route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle)) .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)) .route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler) .fallback(static_handler)
.with_state(state.clone()) .with_state(state.clone())
@ -246,6 +251,8 @@ pub async fn run(
.route("/api/mcp/status", routing::get(http::mcp_status)) .route("/api/mcp/status", routing::get(http::mcp_status))
.route("/api/skills", routing::get(http::skills_list)) .route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle)) .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)) .route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir)) .fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone()) .with_state(state.clone())

View File

@ -1,6 +1,7 @@
//! Gateway Runtime - builds SessionManager with decoupled MCP integration //! Gateway Runtime - builds SessionManager with decoupled MCP integration
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock; use tokio::sync::RwLock;
@ -16,6 +17,7 @@ use crate::storage::{
ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository, ConversationRepository, MemoryRepository, PromptInjectionRepository, SchedulerJobRepository,
SessionStore, SkillEventRepository, TodoRepository, SessionStore, SkillEventRepository, TodoRepository,
}; };
use crate::tools::task::runtime::SubagentRuntime;
use crate::tools::{ use crate::tools::{
DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender,
SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry, SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, TaskTool, ToolRegistry,
@ -50,7 +52,7 @@ pub(crate) fn build_session_manager(
session_ttl_hours: Option<u64>, session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>), AgentError> { ) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
build_session_manager_with_sender( build_session_manager_with_sender(
agent_prompt_reinject_every, agent_prompt_reinject_every,
show_tool_results, show_tool_results,
@ -85,7 +87,7 @@ pub(crate) fn build_session_manager_with_sender(
session_ttl_hours: Option<u64>, session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>), AgentError> { ) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
let store = Arc::new( let store = Arc::new(
SessionStore::new() SessionStore::new()
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?, .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) // Create SubAgentRuntime (if task tool is enabled)
let (factory, task_repository): (_, Arc<dyn TaskRepository>) = if task_config.enabled { let (factory, task_repository, subagent_runtime): (_, Arc<dyn TaskRepository>, Arc<SubagentRuntime>) = if task_config.enabled {
let task_repository = Arc::new(InMemoryTaskRepository::new()); let task_repository = Arc::new(InMemoryTaskRepository::new());
// Build subagent tools with MCP tools (task tool registered separately below) // Build subagent tools with MCP tools (task tool registered separately below)
let subagent_tools = Arc::new( 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 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 { let runtime_config = SubAgentRuntimeConfig {
default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(), 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, 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, runtime_config,
task_repository.clone(), task_repository.clone(),
conversations.clone(), conversations.clone(),
subagent_tools.clone(), subagent_tools.clone(),
provider_config.clone(), provider_config.clone(),
catalog, subagent_runtime.clone(),
bus.clone(), bus.clone(),
store.clone(), store.clone(),
)); ));
@ -211,14 +218,16 @@ pub(crate) fn build_session_manager_with_sender(
// 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖) // 注册 task 工具到子代理工具集(需在 runtime 创建之后,打破循环依赖)
if factory.is_enabled("task") { if factory.is_enabled("task") {
subagent_tools.register(TaskTool::new( subagent_tools.register(TaskTool::new(
subagent_runtime.clone(), default_subagent_runtime.clone(),
Some(task_config.max_nesting_depth), 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 { } 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 // Build base tools
@ -260,6 +269,7 @@ pub(crate) fn build_session_manager_with_sender(
let agent_factory = AgentFactory::new( let agent_factory = AgentFactory::new(
tools.clone(), tools.clone(),
skills.clone(), skills.clone(),
subagent_runtime.clone(),
agent_prompt_reinject_every as usize, agent_prompt_reinject_every as usize,
prompt_repository.clone(), prompt_repository.clone(),
); );
@ -296,5 +306,5 @@ pub(crate) fn build_session_manager_with_sender(
scheduled_tasks, scheduled_tasks,
memory_maintenance, memory_maintenance,
task_repository: task_repository.clone(), task_repository: task_repository.clone(),
}), task_repository, mcp_manager)) }), task_repository, mcp_manager, subagent_runtime))
} }

View File

@ -10,6 +10,7 @@ use crate::skills::SkillRuntime;
use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository}; use crate::storage::{ConversationRepository, PromptInjectionRepository, SessionRecord, SessionStore, SkillEventRepository};
use crate::tools::ToolRegistry; use crate::tools::ToolRegistry;
use crate::tools::task::repository::TaskRepository; use crate::tools::task::repository::TaskRepository;
use crate::tools::task::runtime::SubagentRuntime;
use async_trait::async_trait; use async_trait::async_trait;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@ -249,6 +250,7 @@ impl Session {
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
agent_prompt_reinject_every: u64, agent_prompt_reinject_every: u64,
subagent_runtime: Arc<SubagentRuntime>,
) -> Result<Self, AgentError> { ) -> Result<Self, AgentError> {
let conversations: Arc<dyn ConversationRepository> = store.clone(); let conversations: Arc<dyn ConversationRepository> = store.clone();
let skill_events: Arc<dyn SkillEventRepository> = store.clone(); let skill_events: Arc<dyn SkillEventRepository> = store.clone();
@ -256,6 +258,7 @@ impl Session {
let agent_factory = AgentFactory::new( let agent_factory = AgentFactory::new(
tools, tools,
skills.clone(), skills.clone(),
subagent_runtime,
agent_prompt_reinject_every as usize, agent_prompt_reinject_every as usize,
prompt_repository.clone(), prompt_repository.clone(),
); );
@ -682,7 +685,7 @@ impl SessionManager {
mcp_config, mcp_config,
None, None,
) )
.map(|(session_manager, _, _)| session_manager) .map(|(session_manager, _, _, _)| session_manager)
} }
pub fn tools(&self) -> Arc<ToolRegistry> { pub fn tools(&self) -> Arc<ToolRegistry> {
@ -938,6 +941,7 @@ mod tests {
skills, skills,
store, store,
100, 100,
Arc::new(SubagentRuntime::from_config(Default::default())),
) )
.await .await
.unwrap(); .unwrap();
@ -986,6 +990,7 @@ mod tests {
skills, skills,
store.clone(), store.clone(),
100, 100,
Arc::new(SubagentRuntime::from_config(Default::default())),
) )
.await .await
.unwrap(); .unwrap();
@ -2012,6 +2017,7 @@ mod tests {
skills, skills,
store.clone(), store.clone(),
100, 100,
Arc::new(SubagentRuntime::from_config(Default::default())),
) )
.await .await
.unwrap(); .unwrap();
@ -2052,6 +2058,7 @@ mod tests {
skills, skills,
store.clone(), store.clone(),
100, 100,
Arc::new(SubagentRuntime::from_config(Default::default())),
) )
.await .await
.unwrap(); .unwrap();
@ -2125,6 +2132,7 @@ mod tests {
skills, skills,
store.clone(), store.clone(),
0, 0,
Arc::new(SubagentRuntime::from_config(Default::default())),
) )
.await .await
.unwrap(); .unwrap();

View File

@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::{Arc, RwLock};
use std::time::Duration; use std::time::Duration;
use async_trait::async_trait; use async_trait::async_trait;
@ -295,8 +295,8 @@ pub struct DefaultSubAgentRuntime {
conversation_repository: Arc<dyn ConversationRepository>, conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>, subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
/// 子代理定义目录(内置 + 自定义 /// 子代理运行时协调层(管理禁用状态
catalog: Arc<SubagentCatalog>, subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
} }
@ -308,7 +308,7 @@ impl DefaultSubAgentRuntime {
conversation_repository: Arc<dyn ConversationRepository>, conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>, subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
catalog: Arc<SubagentCatalog>, subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
) -> Self { ) -> Self {
@ -318,18 +318,17 @@ impl DefaultSubAgentRuntime {
conversation_repository, conversation_repository,
subagent_tools, subagent_tools,
provider_config, provider_config,
catalog, subagent_runtime,
bus, bus,
store, store,
} }
} }
/// 查找子代理定义,找不到时 fallback 到 general /// 查找子代理定义(过滤禁用项),找不到或被禁用时返回 Err
fn find_subagent_def(&self, type_name: &str) -> SubagentDef { fn find_subagent_def(&self, type_name: &str) -> Result<SubagentDef, String> {
self.catalog self.subagent_runtime
.find(type_name) .find_available(type_name)
.cloned() .ok_or_else(|| format!("subagent type '{}' is disabled or not found", type_name))
.unwrap_or_else(|| self.catalog.find("general").expect("general subagent must exist").clone())
} }
/// 获取实际使用的工具白名单(预留,未来可用于动态工具过滤) /// 获取实际使用的工具白名单(预留,未来可用于动态工具过滤)
@ -530,7 +529,9 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
.ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?; .ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?;
// 2. 查找子代理定义 // 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. 创建任务会话 // 3. 创建任务会话
let topic_id = parent_context.topic_id.clone(); let topic_id = parent_context.topic_id.clone();
@ -733,7 +734,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
} }
fn available_subagent_names(&self) -> Vec<String> { fn available_subagent_names(&self) -> Vec<String> {
self.catalog.names() self.subagent_runtime.available_names()
} }
} }
@ -870,6 +871,357 @@ fn xml_escape(s: &str) -> String {
.replace('\'', "&apos;") .replace('\'', "&apos;")
} }
// ========== 子代理运行时协调层(管理禁用状态) ==========
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SubagentScope {
User,
Project,
}
impl SubagentScope {
pub fn parse(value: &str) -> Option<Self> {
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<String>,
}
#[derive(Debug, Clone)]
pub struct SubagentAvailabilityChange {
pub name: String,
pub scope: SubagentScope,
pub changed: bool,
pub disabled_in_scopes: Vec<SubagentScope>,
pub available: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct SubagentStateFile {
#[serde(default)]
disabled_subagents: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct SubagentDisableState {
user_disabled: HashSet<String>,
project_disabled: HashSet<String>,
}
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<SubagentScope> {
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<PathBuf> {
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<String> {
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<SubagentStateFile, String> {
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<SubagentCatalog>,
disable_state: RwLock<SubagentDisableState>,
#[allow(dead_code)]
config: SubagentsConfig,
cwd: PathBuf,
}
impl SubagentRuntime {
pub fn new(config: SubagentsConfig, catalog: Arc<SubagentCatalog>, 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<SubagentWithStatus> {
let state = self.disable_state.read().expect("subagent state rwlock poisoned");
let mut items: Vec<SubagentWithStatus> = 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<String> {
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<SubagentDef> {
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<String> {
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\
<available_subagents>\n",
);
for def in available_defs {
prompt.push_str(&format!(
" <subagent>\n <name>{}</name>\n <description>{}</description>\n </subagent>\n",
xml_escape(&def.name),
xml_escape(&def.description),
));
}
prompt.push_str("</available_subagents>");
Some(prompt)
}
/// 禁用子代理
pub fn disable_subagent(
&self,
scope: SubagentScope,
name: &str,
) -> Result<SubagentAvailabilityChange, String> {
self.set_subagent_enabled(scope, name, false)
}
/// 启用子代理
pub fn enable_subagent(
&self,
scope: SubagentScope,
name: &str,
) -> Result<SubagentAvailabilityChange, String> {
self.set_subagent_enabled(scope, name, true)
}
fn set_subagent_enabled(
&self,
scope: SubagentScope,
name: &str,
enabled: bool,
) -> Result<SubagentAvailabilityChange, String> {
// 校验子代理存在
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<String> = 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<String> = 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<SubagentCatalog> {
&self.catalog
}
}
/// 为子代理系统提供索引提示词
///
/// 负责提供过滤禁用项后的子代理系统索引提示词,注入主 agent。
pub struct SubagentPromptProvider {
runtime: Arc<SubagentRuntime>,
}
impl SubagentPromptProvider {
pub fn new(runtime: Arc<SubagentRuntime>) -> Self {
Self { runtime }
}
}
impl SystemPromptProvider for SubagentPromptProvider {
fn build(&self, _context: &SystemPromptContext) -> Option<SystemPrompt> {
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)) 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<std::ffi::OsString>,
previous_userprofile: Option<std::ffi::OsString>,
}
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("<name>general</name>"));
// 在 project scope 禁用 general
let change = runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
assert!(change.changed);
assert!(!change.available);
// 禁用后 prompt 不应包含 generalexplore 仍可用,所以 prompt 仍为 Some
let prompt = runtime.system_index_prompt_filtered().unwrap();
assert!(!prompt.contains("<name>general</name>"));
assert!(prompt.contains("<name>explore</name>"));
}
#[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("<name>general</name>"));
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("<name>general</name>"));
}
#[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"));
}
}

View File

@ -36,6 +36,17 @@ pub enum SubagentSource {
Custom(String), 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)] #[derive(Debug, Clone)]
pub struct SubagentDef { pub struct SubagentDef {

View File

@ -46,6 +46,19 @@ interface SkillListResponse {
skills: SkillItem[] 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 { interface McpServerStatus {
key: string key: string
name: 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' 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 }[] = [ 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: 'providers', label: '服务商', icon: Cpu },
{ id: 'models', label: '模型', icon: Brain }, { id: 'models', label: '模型', icon: Brain },
{ id: 'agents', label: '代理', icon: Bot }, { id: 'agents', label: '代理', icon: Bot },
{ id: 'time', label: '时间', icon: Clock }, { id: 'mcp', label: 'MCP 服务器', icon: Plug },
{ id: 'scheduler', label: '调度器', icon: Calendar },
{ id: 'skills', label: '技能', icon: Wrench }, { id: 'skills', label: '技能', icon: Wrench },
{ id: 'subagents', label: '子代理', icon: Bot },
{ id: 'channels', label: '渠道', icon: Radio },
{ id: 'tools', label: '工具', icon: Settings }, { id: 'tools', label: '工具', icon: Settings },
{ id: 'memory', label: '记忆维护', icon: Users }, { id: 'memory', label: '记忆维护', icon: Users },
{ id: 'scheduler', label: '调度器', icon: Calendar },
{ id: 'image', label: '图片上下文', icon: Image }, { id: 'image', label: '图片上下文', icon: Image },
{ id: 'subagents', label: '子代理', icon: Bot }, { id: 'time', label: '时间', icon: Clock },
{ id: 'mcp', label: 'MCP 服务器', icon: Plug }, { id: 'connection', label: '连接', icon: Wifi },
{ id: 'channels', label: '渠道', icon: Radio }, { id: 'gateway', label: '网关', icon: Server },
] ]
// ── Shared UI primitives ─────────────────────────────── // ── 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 ( return (
<div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden"> <div className="rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/60 overflow-hidden">
<div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30"> <div className="px-4 py-2.5 border-b border-[var(--border-color)] bg-[var(--bg-tertiary)]/30">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3> <div className="flex items-center gap-2">
<h3 className="text-sm font-medium text-[var(--text-secondary)]">{title}</h3>
{subtitle && <span className="text-[10px] text-[var(--text-muted)] bg-[var(--bg-tertiary)] px-1.5 py-0.5 rounded">{subtitle}</span>}
</div>
</div> </div>
<div className="p-4 space-y-4">{children}</div> <div className="p-4 space-y-4">{children}</div>
</div> </div>
@ -302,7 +318,7 @@ function MapEntryHeader({ name, onDelete, onRename }: { name: string; onDelete:
// ── Main Component ───────────────────────────────────── // ── Main Component ─────────────────────────────────────
export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) { export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
const [config, setConfig] = useState<AppConfig | null>(null) const [config, setConfig] = useState<AppConfig | null>(null)
const [activeTab, setActiveTab] = useState<TabId>('gateway') const [activeTab, setActiveTab] = useState<TabId>('providers')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
// Connection settings (localStorage-based) // Connection settings (localStorage-based)
const [connHost, setConnHost] = useState(() => { const [connHost, setConnHost] = useState(() => {
@ -322,6 +338,8 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null) const [mcpStatus, setMcpStatus] = useState<McpStatusResponse | null>(null)
const [skillList, setSkillList] = useState<SkillListResponse | null>(null) const [skillList, setSkillList] = useState<SkillListResponse | null>(null)
const [skillListLoading, setSkillListLoading] = useState(false) const [skillListLoading, setSkillListLoading] = useState(false)
const [subagentList, setSubagentList] = useState<SubagentListResponse | null>(null)
const [subagentListLoading, setSubagentListLoading] = useState(false)
const fetchMcpStatus = useCallback(async () => { const fetchMcpStatus = useCallback(async () => {
try { try {
@ -348,6 +366,24 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
return resp 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(() => { const handleClose = useCallback(() => {
if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return if (dirty && !confirm('有未保存的更改,确定要关闭吗?')) return
onClose() onClose()
@ -371,6 +407,11 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
if (activeTab === 'skills') fetchSkillList() if (activeTab === 'skills') fetchSkillList()
}, [activeTab, fetchSkillList]) }, [activeTab, fetchSkillList])
// Fetch subagent list when subagents tab is selected
useEffect(() => {
if (activeTab === 'subagents') fetchSubagentList()
}, [activeTab, fetchSubagentList])
// ESC to close // ESC to close
useEffect(() => { useEffect(() => {
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() } const h = (e: KeyboardEvent) => { if (e.key === 'Escape') handleClose() }
@ -709,7 +750,7 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
} }
return ( return (
<SectionCard title="已发现技能"> <SectionCard title="已发现技能" subtitle="即时生效">
{skillListLoading && skills.length === 0 ? ( {skillListLoading && skills.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]"> <div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ... <Loader2 className="h-4 w-4 animate-spin" /> ...
@ -804,9 +845,81 @@ export function ConfigPage({ onClose, onSaveConnection }: ConfigPageProps) {
examplePaths={['D:\\my-subagents', '/home/user/shared-agents']} examplePaths={['D:\\my-subagents', '/home/user/shared-agents']}
/> />
</SectionCard> </SectionCard>
{renderDiscoveredSubagents()}
</div> </div>
) )
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 (
<SectionCard title="已发现子代理" subtitle="即时生效">
{subagentListLoading && subagents.length === 0 ? (
<div className="flex items-center gap-2 text-sm text-[var(--text-muted)]">
<Loader2 className="h-4 w-4 animate-spin" /> ...
</div>
) : subagents.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]"></p>
) : (
<div className="space-y-1">
{subagents.map(subagent => {
const isEnabled = subagent.disabled_in_scopes.length === 0
return (
<div key={subagent.name} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-[var(--bg-hover)] transition-colors">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-mono text-[var(--text-primary)]">{subagent.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-[var(--bg-tertiary)] text-[var(--text-muted)] uppercase tracking-wider">{subagent.source}</span>
</div>
<p className="text-xs text-[var(--text-muted)] truncate mt-0.5">{subagent.description}</p>
</div>
<Toggle checked={isEnabled} onChange={() => handleToggle(subagent.name, isEnabled)} />
</div>
)
})}
</div>
)}
</SectionCard>
)
}
const renderMcp = () => { const renderMcp = () => {
const entries = Object.entries(config.mcpServers) const entries = Object.entries(config.mcpServers)
const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key) const statusFor = (key: string) => mcpStatus?.servers?.find(s => s.key === key)