feat: 新增子代理管理API,支持获取子代理列表和切换子代理状态
This commit is contained in:
parent
38b9f661ee
commit
8d30ccd020
@ -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()),
|
||||||
]));
|
]));
|
||||||
|
|
||||||
|
|||||||
@ -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),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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())
|
||||||
|
|||||||
@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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('\'', "'")
|
.replace('\'', "'")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== 子代理运行时协调层(管理禁用状态) ==========
|
||||||
|
|
||||||
|
#[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 不应包含 general(explore 仍可用,所以 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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user