use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::Arc; use parking_lot::RwLock; use std::time::Duration; use async_trait::async_trait; use serde::Deserialize; use crate::agent::{ AgentLoop, AgentRuntimeConfig, EmittedMessageHandler, PersistingEmittedMessageHandler, SystemPrompt, SystemPromptContext, SystemPromptProvider, }; use crate::bus::ChatMessage; use crate::bus::MessageBus; use crate::bus::message::{OutboundEventKind, OutboundMessage}; use crate::config::{LLMProviderConfig, SubagentsConfig}; use crate::domain::CapabilityPolicy; use crate::experts::ExpertRuntime; use crate::providers::StreamDelta; use crate::skills::SkillRuntime; use crate::storage::{ConversationRepository, SessionStore}; use crate::tools::{ToolContext, ToolRegistry}; use super::error::TaskError; use super::prompt::{SubagentPromptBuilder, extract_summary}; use super::repository::TaskRepository; use super::tool::TaskTool; use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult}; /// 子代理运行时配置 #[derive(Debug, Clone)] pub struct SubAgentRuntimeConfig { /// 默认工具白名单(定义未指定时使用) pub default_allowed_tools: HashSet, /// 默认最大执行时间(秒) pub default_max_execution_secs: u64, /// 任务 TTL(小时) pub ttl_hours: u64, /// 子代理最大嵌套深度(0 = 禁止嵌套,1 = 允许 1 层孙代理) pub max_nesting_depth: u32, } impl Default for SubAgentRuntimeConfig { fn default() -> Self { Self { default_allowed_tools: HashSet::from([ "read".to_string(), "edit".to_string(), "write".to_string(), "bash".to_string(), "http_request".to_string(), "web_fetch".to_string(), "memory_search".to_string(), "get_time".to_string(), "calculator".to_string(), "skill_activate".to_string(), "skill_list".to_string(), "send_session_message".to_string(), // 用于进度通知 ]), default_max_execution_secs: 3600, // 60分钟 ttl_hours: 24, max_nesting_depth: 1, } } } /// 子代理运行时抽象接口 #[async_trait] pub trait SubAgentRuntime: Send + Sync + 'static { /// 创建并执行子代理任务 async fn spawn( &self, parent_context: &ToolContext, task: TaskDefinition, ) -> Result; /// 恢复现有任务 async fn resume( &self, task_id: &str, parent_context: &ToolContext, additional_prompt: String, ) -> Result; /// 发送消息给子代理(支持中断或补充指令) async fn send_message(&self, task_id: &str, message: String) -> Result<(), TaskError>; /// 清理过期任务 async fn cleanup_expired(&self) -> Result; /// 获取可用的子代理类型列表 fn available_subagent_names(&self) -> Vec; } /// 静态系统提示词提供者(用于子代理) pub struct StaticSystemPromptProvider { prompt: String, } impl StaticSystemPromptProvider { pub fn new(prompt: String) -> Self { Self { prompt } } } /// 子智能体工具调用实时广播器(不依赖 gateway 层) struct SubAgentEmitter { bus: Arc, channel_name: String, chat_id: String, metadata: HashMap, store: Arc, /// 子/孙智能体自身的 task_id,用于持久化时作为 scope_key task_id: String, stream_message_id: parking_lot::Mutex>, } #[async_trait] impl EmittedMessageHandler for SubAgentEmitter { async fn handle(&self, message: ChatMessage) { for outbound in OutboundMessage::from_chat_message( &self.channel_name, &self.chat_id, None, None, &self.metadata, &message, ) { if let Err(error) = self.bus.publish_outbound(outbound).await { match error { crate::bus::BusError::Dropped => { tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)"); } crate::bus::BusError::Closed => { tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live sub-agent tool call"); } } } } } async fn handle_tool_result(&self, message: ChatMessage, duration_ms: Option) { let mut metadata = self.metadata.clone(); if let Some(ms) = duration_ms { metadata.insert("tool_duration_ms".to_string(), ms.to_string()); } for outbound in OutboundMessage::from_chat_message( &self.channel_name, &self.chat_id, None, None, &metadata, &message, ) { if let Err(error) = self.bus.publish_outbound(outbound).await { match error { crate::bus::BusError::Dropped => { tracing::warn!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Outbound dropped (bus full)"); } crate::bus::BusError::Closed => { tracing::error!(error = %error, channel = %self.channel_name, chat_id = %self.chat_id, "Failed to publish live sub-agent tool call"); } } } } // 拦截 todo_write 结果:持久化到 SQLite(子代理用 task_id 作为 scope_key,与 list_todos 保持一致) if message.tool_name.as_deref() == Some("todo_write") { self.persist_todo_write_result(&message); } } async fn handle_stream_delta(&self, delta: &StreamDelta) { let message_id = { let mut guard = self.stream_message_id.lock(); guard .get_or_insert_with(|| uuid::Uuid::new_v4().to_string()) .clone() }; let outbound = if delta.content.is_empty() && delta.reasoning_content.is_none() { OutboundMessage::stream_end( &self.channel_name, &self.chat_id, None, &message_id, self.metadata.clone(), ) } else { OutboundMessage::stream_delta( &self.channel_name, &self.chat_id, None, &message_id, &delta.content, delta.reasoning_content.clone(), self.metadata.clone(), ) }; if let Err(error) = self.bus.publish_outbound(outbound).await { match error { crate::bus::BusError::Dropped => { tracing::warn!(error = %error, channel = %self.channel_name, "Outbound dropped (bus full)"); } crate::bus::BusError::Closed => { tracing::error!(error = %error, channel = %self.channel_name, "Failed to publish sub-agent stream delta"); } } } } async fn set_stream_message_id(&self, id: &str) { *self.stream_message_id.lock() = Some(id.to_string()); } } impl SubAgentEmitter { fn persist_todo_write_result(&self, message: &ChatMessage) { let parsed: serde_json::Value = match serde_json::from_str(&message.content) { Ok(v) => v, Err(_) => return, }; let Some(todos_array) = parsed.get("current_todos").and_then(|v| v.as_array()) else { return; }; let scope_key = &self.task_id; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs() as i64; // 读取现有 DB 记录,独立对比决定 created_by_message_id 是否更新 let existing = self.store.list_todos(scope_key).unwrap_or_default(); let existing_map: std::collections::HashMap<&str, &crate::storage::TodoRecord> = existing.iter().map(|r| (r.id.as_str(), r)).collect(); let records: Vec = todos_array .iter() .enumerate() .filter_map(|(idx, item)| { let id = item.get("id")?.as_str()?; let content = item.get("content")?.as_str()?; let status = item.get("status")?.as_str()?; // 仅 content 或 status 实际变化时更新 created_by_message_id let changed = match existing_map.get(id) { Some(old) => old.content != content || old.status != status, None => true, // 新项 }; let msg_id = if changed { message.tool_call_id.clone() } else { existing_map .get(id) .and_then(|r| r.created_by_message_id.clone()) }; Some(crate::storage::TodoRecord { id: id.to_string(), scope_key: scope_key.clone(), session_id: scope_key.clone(), topic_id: None, content: content.to_string(), status: status.to_string(), priority: "medium".to_string(), created_at: now + idx as i64, updated_at: now, created_by_message_id: msg_id, }) }) .collect(); if records.is_empty() { return; } tracing::info!( scope_key = %scope_key, todo_count = records.len(), "SubAgentEmitter: persisting todo_write result" ); if let Err(e) = self.store.replace_todos(scope_key, &records) { tracing::warn!(error = %e, %scope_key, "Failed to persist sub-agent todo list"); } } } /// 构建子智能体事件的基础 metadata,与 SubAgentEmitter 注入的字段保持一致。 fn build_subagent_event_metadata(session: &TaskSession) -> HashMap { let mut metadata = HashMap::new(); metadata.insert("subagent_task_id".to_string(), session.id.clone()); metadata.insert("is_subagent_event".to_string(), "true".to_string()); metadata.insert( "topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default(), ); metadata } /// 发布子智能体执行完成事件(ExecutionCompleted),metadata 含 subagent_task_id。 async fn publish_subagent_completion(bus: &Option>, session: &TaskSession) { if let Some(bus) = bus { let metadata = build_subagent_event_metadata(session); if let Err(e) = bus .publish_outbound(OutboundMessage::execution_completed( session.parent_channel_name.clone(), session.parent_chat_id.clone(), Some(session.parent_session_id.clone()), metadata, )) .await { tracing::warn!(error = %e, task_id = %session.id, "Failed to publish subagent execution_completed"); } } } /// 发布子智能体执行错误事件(ErrorNotification),metadata 含 subagent_task_id。 async fn publish_subagent_error( bus: &Option>, session: &TaskSession, error_msg: &str, ) { if let Some(bus) = bus { let metadata = build_subagent_event_metadata(session); if let Err(e) = bus .publish_outbound(OutboundMessage::error_notification( session.parent_channel_name.clone(), session.parent_chat_id.clone(), Some(session.parent_session_id.clone()), error_msg.to_string(), None, metadata, )) .await { tracing::warn!(error = %e, task_id = %session.id, "Failed to publish subagent error notification"); } } } impl SystemPromptProvider for StaticSystemPromptProvider { fn build(&self, _context: &SystemPromptContext) -> Option { Some(SystemPrompt { content: self.prompt.clone(), context: Some("subagent".to_string()), }) } } /// 默认子代理运行时实现 pub struct DefaultSubAgentRuntime { config: SubAgentRuntimeConfig, task_repository: Arc, conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, /// Provider/Model 解析器:按子代理 def 中的 provider/model 字段覆盖基础配置 model_resolver: Arc, /// 子代理运行时协调层(管理禁用状态) subagent_runtime: Arc, bus: Option>, store: Arc, /// 技能运行时(实时计算技能索引,替代冻结快照) skills: Arc, } impl DefaultSubAgentRuntime { pub fn new( config: SubAgentRuntimeConfig, task_repository: Arc, conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, model_resolver: Arc, subagent_runtime: Arc, bus: Option>, store: Arc, skills: Arc, ) -> Self { Self { config, task_repository, conversation_repository, subagent_tools, provider_config, model_resolver, subagent_runtime, bus, store, skills, } } /// 查找子代理定义(过滤禁用项),找不到或被禁用时返回 Err fn find_subagent_def(&self, type_name: &str) -> Result { self.subagent_runtime .find_available(type_name) .ok_or_else(|| format!("subagent type '{}' is disabled or not found", type_name)) } /// 获取实际执行时间 fn effective_max_execution_secs(&self, def: &SubagentDef) -> u64 { def.max_execution_secs .unwrap_or(self.config.default_max_execution_secs) } /// 根据 def 与嵌套深度构建子代理工具集。 /// 过滤顺序:base → capability.allowed_tools 白名单 → capability.denied_tools 黑名单 + depth 达到上限移除 task。 /// - `allowed_tools` 为 Some 时取交集(白名单),None 表示不限制。 /// - `denied_tools` 扣除(黑名单),在白名单之后应用。 /// - 当 child_depth >= max_nesting_depth 时移除 task 工具(防无限嵌套的安全兜底, /// 不可被 def 覆盖)。默认 max_nesting_depth=2,即孙代理(depth=2)无法再创建子代理。 fn build_subagent_tools_registry( &self, def: Option<&SubagentDef>, child_depth: u32, ) -> Arc { let depth_deny_task = child_depth >= self.config.max_nesting_depth; let policy: &CapabilityPolicy = match def { Some(d) => &d.capability, None => &CapabilityPolicy::default(), }; // 快速路径:无工具策略、无需 depth 兜底 → 直接复用 Arc(避免拷贝) if !policy.has_tool_policy() && !depth_deny_task { return self.subagent_tools.clone(); } Arc::new(Self::filter_tool_registry( &self.subagent_tools, policy, depth_deny_task, )) } /// 纯函数:在 base 之上应用白名单/黑名单/depth 规则。 /// 抽取为关联函数便于单元测试(无需构造整个 DefaultSubAgentRuntime)。 fn filter_tool_registry( base: &ToolRegistry, policy: &CapabilityPolicy, depth_deny_task: bool, ) -> ToolRegistry { // 1. 应用白名单(若存在),否则取得 owned 副本以便后续黑名单过滤 let tools: ToolRegistry = match &policy.allowed_tools { Some(list) => { let refs: Vec<&str> = list.iter().map(|s| s.as_str()).collect(); base.only(&refs) } None => base.without(&[]), }; // 2. 合并黑名单(depth 规则 + denied_tools) let mut denied: Vec<&str> = Vec::new(); if depth_deny_task { denied.push(TaskTool::TOOL_NAME); } denied.extend(policy.denied_tools.iter().map(|s| s.as_str())); if denied.is_empty() { tools } else { tools.without(&denied) } } /// 创建子代理实例 fn create_subagent( &self, session: &TaskSession, system_prompt: String, def: Option<&SubagentDef>, parent_nesting_depth: u32, parent_task_id: Option, ) -> Result { let prompt_provider = Arc::new(StaticSystemPromptProvider::new(system_prompt)); let child_depth = parent_nesting_depth + 1; let tools = self.build_subagent_tools_registry(def, child_depth); // 按 def 中的 provider/model 字段解析覆盖基础 provider_config。 // 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。 let effective_provider_config = match def { Some(d) if d.provider.is_some() || d.model.is_some() => self .model_resolver .resolve( d.provider.as_deref(), d.model.as_deref(), &self.provider_config, ) .map_err(|e| { TaskError::AgentCreationFailed(format!( "subagent '{}' model resolution failed: {}", def.map(|d| d.name.as_str()).unwrap_or("?"), e )) })?, _ => self.provider_config.clone(), }; AgentLoop::with_tools_and_system_prompt_provider( AgentRuntimeConfig::from(effective_provider_config), tools, prompt_provider, None, // 子代理不需要 skill provider ) .map(|agent| { let agent = agent.with_tool_context(ToolContext { channel_name: Some(session.parent_channel_name.clone()), sender_id: None, chat_id: Some(session.parent_chat_id.clone()), session_id: Some(session.session_id.clone()), topic_id: session.parent_topic_id.clone(), message_id: None, message_seq: None, subagent_description: Some(session.description.clone()), nesting_depth: parent_nesting_depth + 1, task_id: Some(session.id.clone()), parent_task_id, tool_call_id: None, // 子代理自身的 capability 作为孙代理的 parent_capability, // 使孙代理的 TaskTool 能按此策略校验(与主 agent 注入专家 capability 同构) parent_capability: def.map(|d| d.capability.clone()), }); // 如果有 MessageBus,附加实时广播 emitter if let Some(bus) = &self.bus { let mut metadata = HashMap::new(); metadata.insert("subagent_task_id".to_string(), session.id.clone()); metadata.insert("is_subagent_event".to_string(), "true".to_string()); metadata.insert( "topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default(), ); let emitter = Arc::new(PersistingEmittedMessageHandler::new( SubAgentEmitter { bus: bus.clone(), channel_name: session.parent_channel_name.clone(), chat_id: session.parent_chat_id.clone(), metadata, store: self.store.clone(), task_id: session.id.clone(), stream_message_id: parking_lot::Mutex::new(None), }, self.conversation_repository.clone(), session.session_id.clone(), session.parent_topic_id.clone(), )); return agent.with_emitted_message_handler(emitter); } agent }) .map_err(|e| TaskError::AgentCreationFailed(e.to_string())) } /// 执行任务(带超时控制) async fn execute_task( &self, agent: AgentLoop, session: &TaskSession, def: &SubagentDef, prompt: String, ) -> Result { // 构建初始消息 let history = vec![ChatMessage::user(prompt)]; let system_prompt_context = SystemPromptContext { session_id: Some(session.session_id.clone()), chat_id: session.session_id.clone(), user_message_count: 1, }; // 设置超时 let max_secs = self.effective_max_execution_secs(def); let timeout_duration = Duration::from_secs(max_secs); let result = tokio::time::timeout( timeout_duration, agent.process(history, Some(&system_prompt_context), None), ) .await; match result { Ok(Ok(process_result)) => { let final_message = process_result.final_response; Ok(TaskToolResult { status: "success".to_string(), summary: extract_summary(&final_message.content), output: final_message.content, task_id: session.id.clone(), }) } Ok(Err(e)) => Err(TaskError::ExecutionFailed(e.to_string())), Err(_) => Err(TaskError::Timeout), } } /// 使用历史继续执行 async fn execute_task_with_history( &self, agent: AgentLoop, session: &TaskSession, additional_prompt: String, ) -> Result { // 加载历史 + 新消息 let mut history = self .conversation_repository .load_messages(&session.session_id) .map_err(TaskError::RepositoryError)?; history.push(ChatMessage::user(additional_prompt)); let user_message_count = history.iter().filter(|m| m.role == "user").count(); let system_prompt_context = SystemPromptContext { session_id: Some(session.session_id.clone()), chat_id: session.session_id.clone(), user_message_count, }; // 使用默认执行时间(恢复任务时原始定义可能已不存在) let timeout_duration = Duration::from_secs(self.config.default_max_execution_secs); let result = tokio::time::timeout( timeout_duration, agent.process(history, Some(&system_prompt_context), None), ) .await; match result { Ok(Ok(process_result)) => { let final_message = process_result.final_response; Ok(TaskToolResult { status: "success".to_string(), summary: extract_summary(&final_message.content), output: final_message.content, task_id: session.id.clone(), }) } Ok(Err(e)) => Err(TaskError::ExecutionFailed(e.to_string())), Err(_) => Err(TaskError::Timeout), } } /// 会话创建后的失败处理:标记状态、持久化、发布错误事件、返回结构化失败结果。 /// /// 返回 `Ok(TaskToolResult)` 而非 `Err`,确保 tool_result 携带 `task_id` 供前端导航。 /// `save_task_session` 失败是基础设施故障,仍通过 `?` 返回 `Err`。 async fn handle_task_failure( &self, session: TaskSession, error: TaskError, ) -> Result { let status = error.as_status(); tracing::warn!( task_id = %session.id, session_id = %session.session_id, status = %status, error = %error, "Task failed, updating session" ); let mut session = session; if status == "timeout" { session.mark_timeout(); } else { session.mark_failed(error.to_string()); } self.task_repository.save_task_session(&session).await?; publish_subagent_error(&self.bus, &session, &error.to_string()).await; Ok(TaskToolResult { status: status.to_string(), summary: error.to_string(), output: String::new(), task_id: session.id.clone(), }) } } #[async_trait] impl SubAgentRuntime for DefaultSubAgentRuntime { async fn spawn( &self, parent_context: &ToolContext, task: TaskDefinition, ) -> Result { // 1. 验证上下文 let session_id = parent_context .session_id .clone() .ok_or_else(|| TaskError::MissingContext("session_id".to_string()))?; let chat_id = parent_context .chat_id .clone() .ok_or_else(|| TaskError::MissingContext("chat_id".to_string()))?; let channel_name = parent_context .channel_name .clone() .ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?; // 2. 校验父智能体的子代理策略(白/黑名单),再查找子代理定义。 // 与 find_subagent_def 的"def 不可用即拒绝"安全范式一致:策略不通过即拒绝, // 防止 LLM 通过选择被禁子代理绕过限制。 if let Some(cap) = &parent_context.parent_capability { if let Err(msg) = cap.check_subagent_allowed(&task.subagent_type.name) { return Err(TaskError::InvalidArguments(msg)); } } // 3. 查找子代理定义 let def = self .find_subagent_def(task.subagent_type.as_str()) .map_err(TaskError::InvalidArguments)?; // 4. 创建任务会话 let topic_id = parent_context.topic_id.clone(); let session = TaskSession::new( session_id, topic_id, chat_id, channel_name, task.description.clone(), task.subagent_type, parent_context.tool_call_id.clone(), ); // 4. 在 sessions 表中创建子智能体会话(确保外键约束满足) let session_title = format!("Subagent [{}]: {}", session.subagent_type, task.description); if let Err(e) = self.conversation_repository.ensure_session( &session.session_id, &session.parent_channel_name, &session.parent_chat_id, &session_title, ) { tracing::warn!(error = %e, session_id = %session.session_id, "Failed to ensure subagent session"); } // 5. 保存任务会话 tracing::info!( task_id = %session.id, session_id = %session.session_id, description = %session.description, subagent_type = %session.subagent_type, "Spawning sub-agent task" ); self.task_repository.save_task_session(&session).await?; // 5.1 立即通知前端 task_id(让前端可以显示"查看实时进度"按钮) if let Some(bus) = &self.bus { let mut metadata = HashMap::new(); metadata.insert("task_id".to_string(), session.id.clone()); metadata.insert("task_description".to_string(), session.description.clone()); metadata.insert( "task_subagent_type".to_string(), session.subagent_type.clone(), ); metadata.insert( "topic_id".to_string(), session.parent_topic_id.clone().unwrap_or_default(), ); // 如果是子智能体创建的孙智能体,传递父 task_id if let Some(ref ptid) = parent_context.task_id { metadata.insert("parent_task_id".to_string(), ptid.clone()); } // 传递 tool_call_id,前端据此精确匹配创建此任务的 tool_call if let Some(ref tcid) = parent_context.tool_call_id { metadata.insert("tool_call_id".to_string(), tcid.clone()); } let event = OutboundMessage { channel: session.parent_channel_name.clone(), chat_id: session.parent_chat_id.clone(), session_id: Some(session.parent_session_id.clone()), content: String::new(), reply_to: None, media: Vec::new(), metadata, event_kind: OutboundEventKind::TaskStarted, role: "system".to_string(), tool_call_id: None, tool_name: None, tool_arguments: None, reasoning_content: None, message_id: None, }; if let Err(e) = bus.publish_outbound(event).await { tracing::warn!(error = %e, task_id = %session.id, "Failed to publish TaskStarted event"); } } // 6-8. 构建提示词、创建子代理、执行任务 // 统一为单个 Result 表达式:model_resolver / create_subagent / execute_task // 的任何失败都流入下方 match 的 Err 分支,经 handle_task_failure 返回结构化结果。 let result: Result = { // 6. 构建子代理系统提示词 // 实时按 def.capability 过滤技能索引(替代冻结快照,反映运行时技能增删) let skills_index = if def.capability.has_skill_policy() { self.skills.system_index_prompt_filtered( def.capability.allowed_skills.as_deref(), &def.capability.denied_skills, ) } else { self.skills.system_index_prompt() }; // 同步解析 def 中的 provider/model 覆盖,保证环境提示中的模型名与实际使用的模型一致 let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) { (true, _) | (_, true) => self .model_resolver .resolve( def.provider.as_deref(), def.model.as_deref(), &self.provider_config, ) .map_err(|e| { TaskError::AgentCreationFailed(format!( "subagent '{}' model resolution failed: {}", def.name, e )) })?, _ => self.provider_config.clone(), }; let system_prompt = SubagentPromptBuilder::build( &def, &task.description, &task.prompt, &effective_provider_config, skills_index.as_deref(), ); // 7. 创建子代理 let agent = self.create_subagent( &session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone(), )?; // 8. 执行任务 self.execute_task(agent, &session, &def, task.prompt.clone()) .await }; // 9. 更新会话状态并保存 match result { Ok(tool_result) => { let mut session = session; session.mark_completed(tool_result.summary.clone()); tracing::info!( task_id = %session.id, session_id = %session.session_id, "Task completed, updating session" ); self.task_repository.save_task_session(&session).await?; // 发布子智能体 ExecutionCompleted,metadata 注入 subagent_task_id 供前端路由到对应子智能体层 publish_subagent_completion(&self.bus, &session).await; Ok(tool_result) } Err(e) => { // 会话创建后的任何失败(含 AgentCreationFailed、Timeout、ExecutionFailed) // 统一返回结构化结果,携带 task_id 供前端导航 self.handle_task_failure(session, e).await } } } async fn resume( &self, task_id: &str, parent_context: &ToolContext, additional_prompt: String, ) -> Result { // 1. 加载现有会话 let session = self .task_repository .load_task_session(task_id) .await? .ok_or_else(|| TaskError::SessionNotFound(task_id.to_string()))?; // 2. 验证父会话匹配 let parent_session_id = parent_context .session_id .clone() .ok_or_else(|| TaskError::MissingContext("session_id".to_string()))?; if session.parent_session_id != parent_session_id { return Err(TaskError::InvalidParentSession); } // 3. 确保 sessions 表中存在子智能体会话记录 let session_title = format!( "Subagent [{}]: {}", session.subagent_type, session.description ); if let Err(e) = self.conversation_repository.ensure_session( &session.session_id, &session.parent_channel_name, &session.parent_chat_id, &session_title, ) { tracing::warn!(error = %e, session_id = %session.session_id, "Failed to ensure subagent session on resume"); } // 4. 构建恢复提示词 let system_prompt = SubagentPromptBuilder::build_resume_prompt(&session.description, &additional_prompt); // 4.1 校验父智能体的子代理策略(白/黑名单)。 // 安全要求:与 spawn 一致,防止 resume 绕过白名单。若用户切换到不允许 // 该子代理的专家,resume 应失败(与 def 被删除即失败的安全语义一致)。 if let Some(cap) = &parent_context.parent_capability { if let Err(msg) = cap.check_subagent_allowed(&session.subagent_type) { return Err(TaskError::InvalidArguments(msg)); } } // 4.2 重新解析 def 以应用工具过滤。 // 安全要求:def 被删除/禁用时必须失败恢复,而不是降级为完整工具集—— // 否则一个受限子代理(如 allowed_tools: [read])在 def 失踪后会获得全部工具, // 构成权限提升。与 spawn 保持一致:def 不可用即拒绝执行。 let def = self .find_subagent_def(&session.subagent_type) .map_err(TaskError::InvalidArguments)?; // 5-6. 创建子代理 + 执行(统一为 Result,失败走 handle_task_failure) let result: Result = { let agent = self.create_subagent( &session, system_prompt, Some(&def), parent_context.nesting_depth, parent_context.task_id.clone(), )?; self.execute_task_with_history(agent, &session, additional_prompt) .await }; // 7. 更新会话状态 match result { Ok(tool_result) => { let mut session = session; session.mark_completed(tool_result.summary.clone()); self.task_repository.save_task_session(&session).await?; // 发布子智能体 ExecutionCompleted,metadata 注入 subagent_task_id 供前端路由到对应子智能体层 publish_subagent_completion(&self.bus, &session).await; Ok(tool_result) } Err(e) => { // 修复:原代码一律 mark_failed,未处理 timeout;现统一走 handle_task_failure self.handle_task_failure(session, e).await } } } async fn send_message(&self, _task_id: &str, _message: String) -> Result<(), TaskError> { // TODO: 实现双向通信 // 需要在 TaskSession 中添加 pending_messages 队列 Err(TaskError::InvalidArguments( "send_message not implemented yet".to_string(), )) } async fn cleanup_expired(&self) -> Result { self.task_repository .cleanup_expired_tasks(self.config.ttl_hours) .await .map_err(TaskError::from) } fn available_subagent_names(&self) -> Vec { self.subagent_runtime.available_names() } } /// 子代理定义目录 /// /// 管理所有可用的子代理定义,包括内置和自定义。 /// 支持用户级(~/.picobot/subagents/)和项目级(./.picobot/subagents/)定义, /// 项目级定义会覆盖同名的用户级定义。 #[derive(Debug, Default)] pub struct SubagentCatalog { definitions: std::collections::HashMap, } impl SubagentCatalog { /// 创建空的目录,并注册内置子代理 pub fn new() -> Self { let mut catalog = Self::default(); catalog.register(SubagentDef::builtin_general()); catalog } /// 从配置发现子代理(内置 + 文件系统自定义) /// /// 发现顺序:先内置,后按 sources 配置顺序扫描目录 /// 后发现的同名定义会覆盖先发现的(项目覆盖用户) pub fn discover(config: &SubagentsConfig) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); Self::discover_with_cwd(config, &cwd) } fn discover_with_cwd(config: &SubagentsConfig, cwd: &Path) -> Self { // 先内置作为基础 let mut merged: std::collections::HashMap = std::collections::HashMap::new(); merged.insert("general".to_string(), SubagentDef::builtin_general()); tracing::debug!(cwd = %cwd.display(), "Discovering subagents from cwd"); // 按配置顺序扫描源目录 if config.enabled { for source in source_order(&config.sources) { let root = source_root(&source, cwd); tracing::debug!(source = ?source, root = ?root.as_ref().map(|p| p.display().to_string()), "Checking subagent source"); if let Some(root) = root { if root.exists() { tracing::info!(path = %root.display(), "Scanning subagents directory"); } else { tracing::debug!(path = %root.display(), "Subagents directory does not exist, skipping"); } for def in load_subagents_from_root(&root, source.clone()) { if let Some(existing) = merged.get(&def.name) { tracing::warn!( subagent = %def.name, old_source = ?existing.source, new_source = ?def.source, "Duplicate subagent name found; overriding with later source" ); } merged.insert(def.name.clone(), def); } } } } else { tracing::debug!("Subagents discovery is disabled"); } // 构建 catalog let mut catalog = Self::default(); for def in merged.into_values() { catalog.register(def); } tracing::info!( discovered = catalog.definitions.len(), "Subagents discovery completed" ); catalog } /// 注册一个子代理定义(同名覆盖) pub fn register(&mut self, def: SubagentDef) { self.definitions.insert(def.name.clone(), def); } /// 查找子代理定义 pub fn find(&self, name: &str) -> Option<&SubagentDef> { self.definitions.get(name) } /// 获取所有可用的子代理名称 pub fn names(&self) -> Vec { self.definitions.keys().cloned().collect() } /// 获取所有可用的子代理定义(用于生成索引提示) pub fn all(&self) -> Vec<&SubagentDef> { self.definitions.values().collect() } /// 生成系统索引提示词(用于注入主 agent) pub fn system_index_prompt(&self) -> Option { let defs = self.all(); if defs.is_empty() { return None; } let mut prompt = String::from( "# 子代理系统\n\n\ 子代理是专用的执行单元,用于处理特定类型的任务。\n\ 创建子代理任务时,可以选择以下类型之一:\n\n\ \n", ); for def in defs { prompt.push_str(&format!( " \n {}\n {}\n \n", xml_escape(&def.name), xml_escape(&def.description), )); } prompt.push_str(""); Some(prompt) } } fn xml_escape(s: &str) -> String { s.replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } // ========== 子代理运行时协调层(管理禁用状态) ========== #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum SubagentScope { User, Project, } impl SubagentScope { pub fn parse(value: &str) -> Option { match value { "user" => Some(Self::User), "project" => Some(Self::Project), _ => None, } } pub fn as_str(&self) -> &'static str { match self { Self::User => "user", Self::Project => "project", } } } /// A subagent entry with its disabled status across scopes. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct SubagentWithStatus { pub name: String, pub description: String, pub source: String, /// Which scopes have this subagent disabled. Empty means enabled. pub disabled_in_scopes: Vec, /// 工具与技能加载策略。 #[serde(default)] pub capability: CapabilityPolicy, /// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。 #[serde(default, skip_serializing_if = "Option::is_none")] pub provider: Option, /// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。 #[serde(default, skip_serializing_if = "Option::is_none")] pub model: Option, /// SUBAGENT.md 的 markdown 正文,追加到系统提示词末尾。builtin 子代理为 None。 /// 前端编辑模态框需要回显此字段,与专家系统的 body 对齐。 #[serde(default, skip_serializing_if = "Option::is_none")] pub body: Option, } #[derive(Debug, Clone)] pub struct SubagentAvailabilityChange { pub name: String, pub scope: SubagentScope, pub changed: bool, pub disabled_in_scopes: Vec, pub available: bool, } #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] struct SubagentStateFile { #[serde(default)] disabled_subagents: Vec, } #[derive(Debug, Clone, Default)] struct SubagentDisableState { user_disabled: HashSet, project_disabled: HashSet, } impl SubagentDisableState { fn is_disabled(&self, name: &str) -> bool { self.user_disabled.contains(name) || self.project_disabled.contains(name) } fn disabled_scopes_for(&self, name: &str) -> Vec { let mut scopes = Vec::new(); if self.user_disabled.contains(name) { scopes.push(SubagentScope::User); } if self.project_disabled.contains(name) { scopes.push(SubagentScope::Project); } scopes } } fn user_subagent_state_path() -> Option { crate::platform::home_dir().map(|p| p.join(".picobot").join("subagent-state.json")) } fn project_subagent_state_path(cwd: &Path) -> PathBuf { cwd.join(".picobot").join("subagent-state.json") } fn subagent_state_path(scope: SubagentScope, cwd: &Path) -> PathBuf { match scope { SubagentScope::User => user_subagent_state_path() .unwrap_or_else(|| cwd.join(".picobot").join("subagent-state.json")), SubagentScope::Project => project_subagent_state_path(cwd), } } fn load_subagent_disable_state(cwd: &Path) -> SubagentDisableState { SubagentDisableState { user_disabled: user_subagent_state_path() .map(|path| load_disabled_subagent_names(&path)) .unwrap_or_default(), project_disabled: load_disabled_subagent_names(&project_subagent_state_path(cwd)), } } fn load_disabled_subagent_names(path: &Path) -> HashSet { match load_subagent_state_file(path) { Ok(state) => state.disabled_subagents.into_iter().collect(), Err(err) => { tracing::warn!(path = %path.display(), error = %err, "Failed to load subagent state file"); HashSet::new() } } } fn load_subagent_state_file(path: &Path) -> Result { if !path.exists() { return Ok(SubagentStateFile::default()); } let content = fs::read_to_string(path) .map_err(|err| format!("failed to read subagent state file: {}", err))?; serde_json::from_str(&content) .map_err(|err| format!("failed to parse subagent state file: {}", err)) } fn save_subagent_state_file(path: &Path, state: &SubagentStateFile) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create subagent state directory: {}", err))?; } let content = serde_json::to_string_pretty(state) .map_err(|err| format!("failed to render subagent state file: {}", err))?; let tmp_path = path.with_extension("json.tmp"); fs::write(&tmp_path, format!("{}\n", content)) .map_err(|err| format!("failed to write temporary subagent state file: {}", err))?; crate::platform::atomic_rename(&tmp_path, path) .map_err(|err| format!("failed to persist subagent state file: {}", err))?; Ok(()) } /// 子代理运行时协调层 /// /// 在 `SubagentCatalog`(纯数据容器)之上管理禁用状态,所有过滤逻辑在此层。 /// 对齐 `SkillRuntime` 模式。 #[derive(Debug)] pub struct SubagentRuntime { catalog: RwLock, disable_state: RwLock, #[allow(dead_code)] config: SubagentsConfig, cwd: PathBuf, } impl SubagentRuntime { pub fn new(config: SubagentsConfig, catalog: SubagentCatalog, cwd: PathBuf) -> Self { let disable_state = load_subagent_disable_state(&cwd); Self { catalog: RwLock::new(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 = SubagentCatalog::discover(&config); Self::new(config, catalog, cwd) } /// 重新发现子代理并替换内存 catalog(写回 SUBAGENT.md 后调用)。 /// /// 使用 `self.cwd` 而非进程 cwd 进行发现,确保与构造时的 cwd 一致 /// (生产环境两者相同,但测试场景使用临时目录时必须用 `self.cwd`)。 pub fn reload(&self) -> Result<(), String> { let new_catalog = SubagentCatalog::discover_with_cwd(&self.config, &self.cwd); let mut guard = self .catalog .write() ; *guard = new_catalog; Ok(()) } /// 列出所有子代理(含禁用项),带 disabled_in_scopes pub fn list_with_status(&self) -> Vec { let state = self .disable_state .read() ; let catalog = self .catalog .read() ; let mut items: Vec = 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(), capability: def.capability.clone(), provider: def.provider.clone(), model: def.model.clone(), body: def.body.clone(), } }) .collect(); items.sort_by(|a, b| a.name.cmp(&b.name)); items } /// 可用子代理名称(过滤禁用项) pub fn available_names(&self) -> Vec { let state = self .disable_state .read() ; let catalog = self .catalog .read() ; catalog .names() .into_iter() .filter(|name| !state.is_disabled(name)) .collect() } /// 查找可用子代理(过滤禁用项) pub fn find_available(&self, name: &str) -> Option { let state = self .disable_state .read() ; if state.is_disabled(name) { return None; } self.catalog .read() .find(name) .cloned() } /// 生成过滤后的系统索引提示词 pub fn system_index_prompt_filtered(&self) -> Option { let state = self .disable_state .read() ; let catalog = self .catalog .read() ; let available_defs: Vec<&SubagentDef> = catalog .all() .into_iter() .filter(|def| !state.is_disabled(&def.name)) .collect(); if available_defs.is_empty() { return None; } let mut prompt = String::from( "# 子代理系统\n\n\ 子代理是专用的执行单元,用于处理特定类型的任务。\n\ 创建子代理任务时,可以选择以下类型之一:\n\n\ \n", ); for def in available_defs { prompt.push_str(&format!( " \n {}\n {}\n \n", xml_escape(&def.name), xml_escape(&def.description), )); } prompt.push_str(""); Some(prompt) } /// 生成按 capability 过滤后的系统索引提示词。 /// 在禁用项过滤之上,再按 `allowed_subagents`(白名单取交集)和 /// `denied_subagents`(黑名单扣除)过滤。用于专家/子代理的子代理策略。 pub fn system_index_prompt_filtered_with_policy( &self, allowed: Option<&[String]>, denied: &[String], ) -> Option { let state = self .disable_state .read() ; let catalog = self .catalog .read() ; let available_defs: Vec<&SubagentDef> = catalog .all() .into_iter() .filter(|def| !state.is_disabled(&def.name)) .filter(|def| { if let Some(list) = allowed { list.iter().any(|s| s == &def.name) } else { true } }) .filter(|def| !denied.iter().any(|s| s == &def.name)) .collect(); if available_defs.is_empty() { return None; } let mut prompt = String::from( "# 子代理系统\n\n\ 子代理是专用的执行单元,用于处理特定类型的任务。\n\ 创建子代理任务时,可以选择以下类型之一:\n\n\ \n", ); for def in available_defs { prompt.push_str(&format!( " \n {}\n {}\n \n", xml_escape(&def.name), xml_escape(&def.description), )); } prompt.push_str(""); Some(prompt) } /// 禁用子代理 pub fn disable_subagent( &self, scope: SubagentScope, name: &str, ) -> Result { self.set_subagent_enabled(scope, name, false) } /// 启用子代理 pub fn enable_subagent( &self, scope: SubagentScope, name: &str, ) -> Result { self.set_subagent_enabled(scope, name, true) } fn set_subagent_enabled( &self, scope: SubagentScope, name: &str, enabled: bool, ) -> Result { // 校验子代理存在 if self .catalog .read() .find(name) .is_none() { return Err(format!("subagent '{}' not found", name)); } // 更新对应 scope 的 state 文件 let state_path = subagent_state_path(scope, &self.cwd); let mut state_file = load_subagent_state_file(&state_path)?; let mut disabled: HashSet = state_file.disabled_subagents.into_iter().collect(); let changed = if enabled { disabled.remove(name) } else { disabled.insert(name.to_string()) }; let mut disabled_list: Vec = disabled.into_iter().collect(); disabled_list.sort(); state_file.disabled_subagents = disabled_list; save_subagent_state_file(&state_path, &state_file)?; // 更新内存中的 disable_state { let mut state = self .disable_state .write() ; 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() ; 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, }) } /// 更新子代理定义(写回 SUBAGENT.md frontmatter)。 /// 对齐 `ExpertRuntime::update_expert`。 /// - `description`/`body`/`capability`/`provider`/`model` 为 None 时保留原值。 /// - `prompt_template`/`max_execution_secs` 不在 UI 暴露编辑,始终保留原值。 /// - builtin 子代理(`source == Builtin`、`path == None`)禁止 update。 pub fn update_subagent( &self, name: &str, description: Option<&str>, body: Option<&str>, capability: Option<&CapabilityPolicy>, provider: Option<&Option>, model: Option<&Option>, reload: bool, ) -> Result { let def = { let catalog = self .catalog .read() ; catalog .find(name) .ok_or_else(|| format!("subagent '{}' not found", name))? .clone() }; // builtin 子代理无文件路径,禁止 update let path = def .path .as_ref() .ok_or_else(|| format!("builtin subagent '{}' cannot be updated", name))?; if !path.exists() { return Err(format!("subagent file not found at {}", path.display())); } let next_description = description.unwrap_or(&def.description); let next_body = body.unwrap_or(def.body.as_deref().unwrap_or("")); let next_capability = capability .cloned() .unwrap_or_else(|| def.capability.clone()); let next_provider = provider.cloned().unwrap_or(def.provider); let next_model = model.cloned().unwrap_or(def.model); write_subagent_file( path, &def.name, next_description, &def.prompt_template, next_body, &next_capability, def.max_execution_secs, &next_provider, &next_model, )?; let new_def = parse_subagent_file(path, def.source.clone())?; if reload { let _ = self.reload(); } Ok(new_def) } /// 创建子代理(在指定 scope 下创建 SUBAGENT.md 文件)。 /// 对齐 `ExpertRuntime::create_expert`。 /// - `name` 不能为空,不能包含路径分隔符或 `..`。 /// - `prompt_template` 为空时使用默认模板。 /// - `max_execution_secs` 为 None 时不写入 frontmatter。 /// - 同名子代理(含 builtin `general`)已存在时返回错误。 pub fn create_subagent( &self, scope: SubagentScope, name: &str, description: &str, body: &str, capability: &CapabilityPolicy, provider: &Option, model: &Option, reload: bool, ) -> Result { validate_subagent_name(name)?; { let catalog = self .catalog .read() ; if catalog.find(name).is_some() { return Err(format!("subagent '{}' already exists", name)); } } let source = match scope { SubagentScope::User => SubagentSource::User, SubagentScope::Project => SubagentSource::Project, }; let path = subagent_file_path(scope, name, &self.cwd)?; if path.exists() { return Err(format!( "subagent '{}' already exists at {}", name, path.display() )); } // 新建子代理使用默认提示词模板(与 builtin general 一致),不暴露给 UI 编辑 let prompt_template = SubagentDef::builtin_general().prompt_template; write_subagent_file( &path, name, description, &prompt_template, body, capability, None, provider, model, )?; let def = parse_subagent_file(&path, source)?; if reload { let _ = self.reload(); } Ok(def) } /// 删除子代理(删除 SUBAGENT.md 所在目录)。 /// 对齐 `ExpertRuntime::delete_expert`。 /// - builtin 子代理(path 为 None)禁止删除。 /// - 仅当目录内除 SUBAGENT.md 外无其他文件时才删除目录,避免误删用户附件。 pub fn delete_subagent( &self, name: &str, reload: bool, ) -> Result { validate_subagent_name(name)?; let path = { let catalog = self .catalog .read() ; let def = catalog .find(name) .ok_or_else(|| format!("subagent '{}' not found", name))?; def.path .clone() .ok_or_else(|| format!("builtin subagent '{}' cannot be deleted", name))? }; if !path.exists() { return Err(format!("subagent file not found at {}", path.display())); } let dir = path .parent() .ok_or_else(|| "subagent file has no parent directory".to_string())?; // 仅当目录内只有 SUBAGENT.md 时才递归删除目录; // 否则只删除 SUBAGENT.md,保留用户其他文件 let only_subagent_file = std::fs::read_dir(dir) .map_err(|err| format!("failed to read subagent directory: {}", err))? .filter_map(|e| e.ok()) .filter(|e| e.file_name() != "SUBAGENT.md") .count() == 0; if only_subagent_file { std::fs::remove_dir_all(dir) .map_err(|err| format!("failed to delete subagent directory: {}", err))?; } else { std::fs::remove_file(&path) .map_err(|err| format!("failed to delete subagent file: {}", err))?; } if reload { let _ = self.reload(); } Ok(dir.to_path_buf()) } } /// 校验子代理名称:非空、无路径分隔符、无 `..`。 /// 对齐 `validate_expert_name`。 fn validate_subagent_name(name: &str) -> Result<(), String> { if name.trim().is_empty() { return Err("subagent name cannot be empty".to_string()); } if name.contains('/') || name.contains('\\') || name.contains("..") { return Err("subagent name must not contain path separators or '..'".to_string()); } Ok(()) } /// 获取指定 scope 下某子代理的 SUBAGENT.md 路径。 /// 对齐 `expert_file_path`。 fn subagent_file_path( scope: SubagentScope, name: &str, cwd: &Path, ) -> Result { let root = match scope { SubagentScope::User => dirs::home_dir() .map(|p| p.join(".picobot").join("subagents")) .ok_or_else(|| "cannot determine user home directory".to_string())?, SubagentScope::Project => cwd.join(".picobot").join("subagents"), }; Ok(root.join(name).join("SUBAGENT.md")) } /// 为子代理系统提供索引提示词 /// /// 负责提供过滤禁用项后的子代理系统索引提示词,注入主 agent。 /// 当会话选中了带子代理策略的专家时,按专家 `CapabilityPolicy` 过滤子代理索引 /// (与 `SkillPromptProvider` 过滤技能索引的模式同构)。 pub struct SubagentPromptProvider { runtime: Arc, experts: Arc, } impl SubagentPromptProvider { pub fn new(runtime: Arc, experts: Arc) -> Self { Self { runtime, experts } } } impl SystemPromptProvider for SubagentPromptProvider { fn build(&self, context: &SystemPromptContext) -> Option { // 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认) let content = match context.session_id.as_deref() { Some(sid) => { let policy = self.experts.selected_expert_for(sid).map(|e| e.capability); match policy { Some(p) if p.has_subagent_policy() => { self.runtime.system_index_prompt_filtered_with_policy( p.allowed_subagents.as_deref(), &p.denied_subagents, ) } _ => self.runtime.system_index_prompt_filtered(), } } None => self.runtime.system_index_prompt_filtered(), }; content.map(|c| SystemPrompt { content: c, context: Some("subagents".to_string()), }) } } // ========== 自定义子代理发现 ========== /// 源顺序解析 fn source_order(sources: &[String]) -> Vec { let mut result = Vec::new(); for source in sources { match source.as_str() { "user" => { if !result.contains(&SubagentSource::User) { result.push(SubagentSource::User); } } "project" => { if !result.contains(&SubagentSource::Project) { result.push(SubagentSource::Project); } } unknown => { let custom = SubagentSource::Custom(unknown.to_string()); if !result.contains(&custom) { result.push(custom); } } } } // 默认顺序:先 user 后 project(项目覆盖用户) if result.is_empty() { vec![SubagentSource::User, SubagentSource::Project] } else { result } } /// 获取源目录根路径 fn source_root(source: &SubagentSource, cwd: &Path) -> Option { match source { SubagentSource::User => dirs::home_dir().map(|p| p.join(".picobot").join("subagents")), SubagentSource::Project => Some(cwd.join(".picobot").join("subagents")), SubagentSource::Builtin => None, SubagentSource::Custom(path) => { let p = std::path::PathBuf::from(path); if p.is_absolute() { Some(p) } else { tracing::warn!(path = %path, "Custom subagents source must be an absolute path, skipping"); None } } } } /// 子代理 frontmatter 结构 #[derive(Debug, Deserialize)] struct SubagentFrontmatter { #[serde(default)] name: Option, description: String, #[serde(default)] prompt_template: Option, #[serde(default)] allowed_skills: Option>, #[serde(default)] denied_skills: Vec, #[serde(default)] allowed_tools: Option>, #[serde(default)] denied_tools: Vec, #[serde(default)] allowed_subagents: Option>, #[serde(default)] denied_subagents: Vec, #[serde(default)] max_execution_secs: Option, #[serde(default)] provider: Option, #[serde(default)] model: Option, } /// 从根目录加载所有子代理 fn load_subagents_from_root(root: &Path, source: SubagentSource) -> Vec { let mut out = Vec::new(); if !root.exists() { tracing::debug!(path = %root.display(), "Subagents root directory does not exist"); return out; } tracing::debug!(path = %root.display(), "Reading subagents directory"); let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(err) => { tracing::warn!(path = %root.display(), error = %err, "Failed to read subagents directory"); return out; } }; let mut found_dirs = 0; let mut found_files = 0; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { tracing::debug!(path = %path.display(), "Skipping non-directory entry"); continue; } found_dirs += 1; let subagent_md = path.join("SUBAGENT.md"); tracing::debug!(dir = %path.display(), subagent_file = %subagent_md.display(), "Checking subagent directory"); if !subagent_md.exists() { tracing::debug!(path = %subagent_md.display(), "SUBAGENT.md not found"); continue; } found_files += 1; match parse_subagent_file(&subagent_md, source.clone()) { Ok(def) => { tracing::info!(name = %def.name, path = %subagent_md.display(), "Loaded subagent"); out.push(def); } Err(err) => { tracing::warn!(path = %subagent_md.display(), error = %err, "Skipping invalid subagent file"); } } } tracing::debug!(path = %root.display(), dirs = found_dirs, files = found_files, loaded = out.len(), "Subagents scan completed"); out } /// 解析子代理文件 fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?; let (frontmatter, body) = match crate::frontmatter::parse::(&content) { Ok(v) => v, Err(err) => { let bytes = content.len(); let crlf = content.contains('\r'); return Err(format!("{} (bytes={}, crlf={})", err, bytes, crlf)); } }; if frontmatter.description.trim().is_empty() { return Err("description is required and cannot be empty".to_string()); } // name 可选,默认使用目录名 let dir_name = path .parent() .and_then(|p| p.file_name()) .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "unknown-subagent".to_string()); let name = frontmatter.name.unwrap_or(dir_name).trim().to_string(); let prompt_template = frontmatter .prompt_template .unwrap_or_default() .trim() .to_string(); let body_content = body.trim().to_string(); let capability = CapabilityPolicy { allowed_skills: frontmatter.allowed_skills, denied_skills: frontmatter.denied_skills, allowed_tools: frontmatter.allowed_tools, denied_tools: frontmatter.denied_tools, allowed_subagents: frontmatter.allowed_subagents, denied_subagents: frontmatter.denied_subagents, }; let provider = frontmatter .provider .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); let model = frontmatter .model .map(|s| s.trim().to_string()) .filter(|s| !s.is_empty()); Ok(SubagentDef { name, description: frontmatter.description.trim().to_string(), prompt_template, body: if body_content.is_empty() { None } else { Some(body_content) }, capability, max_execution_secs: frontmatter.max_execution_secs, source, path: Some(path.to_path_buf()), provider, model, }) } /// 渲染子代理文件内容(frontmatter + body)。 /// 对齐 `experts::render_expert_file`:空 capability 字段不输出对应 key。 fn render_subagent_file( name: &str, description: &str, prompt_template: &str, body: &str, capability: &CapabilityPolicy, max_execution_secs: Option, provider: &Option, model: &Option, ) -> Result { if description.trim().is_empty() { return Err("description is required and cannot be empty".to_string()); } #[derive(serde::Serialize)] struct SubagentFrontmatterOwned { name: String, description: String, #[serde(skip_serializing_if = "Option::is_none")] prompt_template: Option, #[serde(skip_serializing_if = "Option::is_none")] allowed_skills: Option>, #[serde(skip_serializing_if = "Vec::is_empty")] denied_skills: Vec, #[serde(skip_serializing_if = "Option::is_none")] allowed_tools: Option>, #[serde(skip_serializing_if = "Vec::is_empty")] denied_tools: Vec, #[serde(skip_serializing_if = "Option::is_none")] allowed_subagents: Option>, #[serde(skip_serializing_if = "Vec::is_empty")] denied_subagents: Vec, #[serde(skip_serializing_if = "Option::is_none")] max_execution_secs: Option, #[serde(skip_serializing_if = "Option::is_none")] provider: Option, #[serde(skip_serializing_if = "Option::is_none")] model: Option, } let fm = SubagentFrontmatterOwned { name: name.to_string(), description: description.to_string(), prompt_template: if prompt_template.is_empty() { None } else { Some(prompt_template.to_string()) }, allowed_skills: capability.allowed_skills.clone(), denied_skills: capability.denied_skills.clone(), allowed_tools: capability.allowed_tools.clone(), denied_tools: capability.denied_tools.clone(), allowed_subagents: capability.allowed_subagents.clone(), denied_subagents: capability.denied_subagents.clone(), max_execution_secs, provider: provider.clone(), model: model.clone(), }; let yaml = serde_yaml::to_string(&fm) .map_err(|err| format!("failed to render subagent frontmatter: {}", err))?; let yaml = yaml.trim_start_matches("---\n"); let body = body.trim(); if body.is_empty() { Ok(format!("---\n{}---\n", yaml)) } else { Ok(format!("---\n{}---\n{}\n", yaml, body)) } } /// 写入子代理文件(创建父目录如需)。 fn write_subagent_file( path: &Path, name: &str, description: &str, prompt_template: &str, body: &str, capability: &CapabilityPolicy, max_execution_secs: Option, provider: &Option, model: &Option, ) -> Result<(), String> { let content = render_subagent_file( name, description, prompt_template, body, capability, max_execution_secs, provider, model, )?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create subagent directory: {}", err))?; } fs::write(path, content).map_err(|err| format!("failed to write subagent file: {}", err)) } #[cfg(test)] mod tests { use super::*; use crate::config::SubagentsConfig; static SUBAGENT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> { SUBAGENT_TEST_ENV_LOCK .lock() .unwrap_or_else(|err| err.into_inner()) } struct HomeDirGuard { previous: Option, previous_userprofile: Option, } impl HomeDirGuard { fn enter(path: &Path) -> Self { let home_backup = std::env::var_os("HOME"); let userprofile_backup = std::env::var_os("USERPROFILE"); unsafe { std::env::set_var("HOME", path); std::env::set_var("USERPROFILE", path); } Self { previous: home_backup, previous_userprofile: userprofile_backup, } } } impl Drop for HomeDirGuard { fn drop(&mut self) { unsafe { match &self.previous { Some(value) => std::env::set_var("HOME", value), None => std::env::remove_var("HOME"), } match &self.previous_userprofile { Some(value) => std::env::set_var("USERPROFILE", value), None => std::env::remove_var("USERPROFILE"), } } } } fn make_runtime(cwd: &Path) -> SubagentRuntime { let catalog = SubagentCatalog::new(); SubagentRuntime::new(SubagentsConfig::default(), catalog, cwd.to_path_buf()) } #[test] fn test_disable_subagent_filters_from_prompt() { let _lock = acquire_test_lock(); let temp = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); let _home_guard = HomeDirGuard::enter(home.path()); let runtime = make_runtime(temp.path()); // general 在初始 prompt 中 let prompt = runtime.system_index_prompt_filtered().unwrap(); assert!(prompt.contains("general")); // 在 project scope 禁用 general let change = runtime .disable_subagent(SubagentScope::Project, "general") .unwrap(); assert!(change.changed); assert!(!change.available); // 禁用后 prompt 不应包含 general(无可用子代理时返回 None) let prompt = runtime.system_index_prompt_filtered(); assert!(prompt.map_or(true, |p| !p.contains("general"))); } #[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(); // 无可用子代理时返回 None assert!(runtime.system_index_prompt_filtered().is_none()); let change = runtime .enable_subagent(SubagentScope::Project, "general") .unwrap(); assert!(change.changed); assert!(change.available); let prompt = runtime.system_index_prompt_filtered().unwrap(); assert!(prompt.contains("general")); } #[test] fn test_list_with_status_includes_disabled() { let _lock = acquire_test_lock(); let temp = tempfile::tempdir().unwrap(); let home = tempfile::tempdir().unwrap(); let _home_guard = HomeDirGuard::enter(home.path()); let runtime = make_runtime(temp.path()); runtime .disable_subagent(SubagentScope::Project, "general") .unwrap(); let items = runtime.list_with_status(); let general = items.iter().find(|i| i.name == "general").unwrap(); assert!(general.disabled_in_scopes.contains(&"project".to_string())); } #[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()); // available_names 不应包含 general let names = runtime.available_names(); assert!(!names.contains(&"general".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")); } // ===== 工具过滤(allowed_tools / denied_tools)测试 ===== use crate::tools::traits::{Tool as ToolTrait, ToolResult}; /// 占位工具,按构造名注册 struct FakeTool { tool_name: String, } #[async_trait::async_trait] impl ToolTrait for FakeTool { fn name(&self) -> &str { &self.tool_name } fn description(&self) -> &str { "fake" } fn parameters_schema(&self) -> serde_json::Value { serde_json::json!({}) } async fn execute(&self, _args: serde_json::Value) -> anyhow::Result { Ok(ToolResult { success: true, output: String::new(), error: None, }) } } fn base_registry() -> ToolRegistry { let reg = ToolRegistry::new(); for name in &["read", "edit", "write", "bash", "task"] { reg.register(FakeTool { tool_name: name.to_string(), }); } reg } fn sorted_names(reg: &ToolRegistry) -> Vec { let mut v = reg.tool_names(); v.sort(); v } /// 构造 CapabilityPolicy:白名单 + 黑名单 fn policy(allowed: Option<&[&str]>, denied: &[&str]) -> CapabilityPolicy { CapabilityPolicy { allowed_skills: None, denied_skills: Vec::new(), allowed_tools: allowed.map(|v| v.iter().map(|x| x.to_string()).collect()), denied_tools: denied.iter().map(|x| x.to_string()).collect(), allowed_subagents: None, denied_subagents: vec![], } } #[test] fn filter_no_restriction_returns_all() { let base = base_registry(); let p = policy(None, &[]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert_eq!( sorted_names(®), vec!["bash", "edit", "read", "task", "write"] ); } #[test] fn filter_depth_deny_task_removes_task() { let base = base_registry(); let p = policy(None, &[]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true); assert_eq!(sorted_names(®), vec!["bash", "edit", "read", "write"]); } #[test] fn filter_whitelist_keeps_only_listed() { let base = base_registry(); let p = policy(Some(&["read", "bash"]), &[]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert_eq!(sorted_names(®), vec!["bash", "read"]); } #[test] fn filter_whitelist_skips_missing_names() { let base = base_registry(); // 包含未注册的工具名应被静默跳过 let p = policy(Some(&["read", "nonexistent", "glob"]), &[]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert_eq!(sorted_names(®), vec!["read"]); } #[test] fn filter_blacklist_removes_listed() { let base = base_registry(); let p = policy(None, &["bash", "task"]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert_eq!(sorted_names(®), vec!["edit", "read", "write"]); } #[test] fn filter_whitelist_then_blacklist() { let base = base_registry(); let p = policy(Some(&["read", "bash"]), &["bash"]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); // 白名单留下 read+bash,黑名单再扣除 bash assert_eq!(sorted_names(®), vec!["read"]); } #[test] fn filter_empty_whitelist_yields_empty() { let base = base_registry(); let p = policy(Some(&[]), &[]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false); assert!(reg.tool_names().is_empty()); } #[test] fn filter_depth_rule_overrides_whitelist_task() { let base = base_registry(); // 白名单显式包含 task,但 depth≥2 安全兜底仍应移除它 let p = policy(Some(&["read", "task"]), &[]); let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true); assert_eq!(sorted_names(®), vec!["read"]); } // ===== frontmatter 解析(capability)测试 ===== #[test] fn parse_subagent_file_handles_crlf_endings() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("SUBAGENT.md"); std::fs::write( &path, "---\r\nname: demo\r\ndescription: demo subagent\r\n---\r\nStep A\r\nStep B", ) .unwrap(); let subagent = parse_subagent_file(&path, SubagentSource::Project).unwrap(); assert_eq!(subagent.name, "demo"); assert_eq!(subagent.description, "demo subagent"); assert_eq!(subagent.body.as_deref(), Some("Step A\nStep B")); } #[test] fn parse_subagent_file_reads_capability() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("SUBAGENT.md"); std::fs::write( &path, "---\n\ name: sandbox\n\ description: sandbox agent\n\ allowed_skills: [skill_a, skill_b]\n\ denied_skills: [skill_c]\n\ allowed_tools: [read, todo_write]\n\ denied_tools: [bash, task]\n\ ---\n\ body instructions", ) .unwrap(); let def = parse_subagent_file(&path, SubagentSource::Project).unwrap(); assert_eq!(def.name, "sandbox"); assert_eq!( def.capability.allowed_skills.as_deref(), Some(["skill_a".to_string(), "skill_b".to_string()].as_slice()) ); assert_eq!(def.capability.denied_skills, vec!["skill_c".to_string()]); assert_eq!( def.capability.allowed_tools.as_deref(), Some(["read".to_string(), "todo_write".to_string()].as_slice()) ); assert_eq!( def.capability.denied_tools, vec!["bash".to_string(), "task".to_string()] ); } #[test] fn parse_subagent_file_capability_default_empty_when_absent() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("SUBAGENT.md"); std::fs::write( &path, "---\nname: basic\ndescription: basic agent\n---\nbody", ) .unwrap(); let def = parse_subagent_file(&path, SubagentSource::User).unwrap(); assert!(def.capability.is_empty()); } #[test] fn list_with_status_projects_capability() { let temp = tempfile::tempdir().unwrap(); let mut catalog = SubagentCatalog::new(); catalog.register(SubagentDef { name: "sandbox".to_string(), description: "sandbox agent".to_string(), prompt_template: String::new(), body: None, capability: CapabilityPolicy { allowed_skills: None, denied_skills: Vec::new(), allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]), denied_tools: vec!["bash".to_string()], allowed_subagents: None, denied_subagents: vec![], }, max_execution_secs: None, source: SubagentSource::Builtin, path: None, provider: None, model: None, }); let runtime = SubagentRuntime::new( SubagentsConfig::default(), catalog, temp.path().to_path_buf(), ); let items = runtime.list_with_status(); let item = items.iter().find(|i| i.name == "sandbox").unwrap(); assert_eq!( item.capability.allowed_tools.as_deref(), Some(["read".to_string(), "todo_write".to_string()].as_slice()) ); assert_eq!(item.capability.denied_tools, vec!["bash".to_string()]); } // ===== render/write/update_subagent 测试 ===== #[test] fn render_subagent_file_roundtrip() { let cap = CapabilityPolicy { allowed_skills: Some(vec!["skill_a".to_string()]), denied_skills: vec!["skill_b".to_string()], allowed_tools: Some(vec!["read".to_string()]), denied_tools: vec!["bash".to_string()], allowed_subagents: None, denied_subagents: vec![], }; let content = render_subagent_file( "demo", "demo agent", "template content", "body instructions", &cap, Some(1800), &None, &None, ) .unwrap(); let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("SUBAGENT.md"); std::fs::write(&path, &content).unwrap(); let def = parse_subagent_file(&path, SubagentSource::Project).unwrap(); assert_eq!(def.name, "demo"); assert_eq!(def.description, "demo agent"); assert_eq!(def.prompt_template, "template content"); assert_eq!(def.body.as_deref(), Some("body instructions")); assert_eq!(def.max_execution_secs, Some(1800)); assert_eq!( def.capability.allowed_skills.as_deref(), Some(["skill_a".to_string()].as_slice()) ); assert_eq!(def.capability.denied_skills, vec!["skill_b".to_string()]); assert_eq!( def.capability.allowed_tools.as_deref(), Some(["read".to_string()].as_slice()) ); assert_eq!(def.capability.denied_tools, vec!["bash".to_string()]); } #[test] fn render_subagent_file_omits_empty_capability() { let cap = CapabilityPolicy::default(); let content = render_subagent_file("basic", "basic agent", "", "body", &cap, None, &None, &None) .unwrap(); // 空 capability 字段不应出现在 YAML 中 assert!(!content.contains("allowed_skills")); assert!(!content.contains("denied_skills")); assert!(!content.contains("allowed_tools")); assert!(!content.contains("denied_tools")); assert!(!content.contains("max_execution_secs")); assert!(!content.contains("prompt_template")); } #[test] fn update_subagent_writes_capability() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("demo").join("SUBAGENT.md"); // 先写一个初始 SUBAGENT.md write_subagent_file( &path, "demo", "initial desc", "", "initial body", &CapabilityPolicy::default(), None, &None, &None, ) .unwrap(); // 用 SubagentRuntime 加载并 update let config = SubagentsConfig { enabled: true, sources: vec![temp.path().to_string_lossy().to_string()], }; let runtime = SubagentRuntime::from_config(config); let new_cap = CapabilityPolicy { allowed_skills: None, denied_skills: vec!["skill_x".to_string()], allowed_tools: Some(vec!["read".to_string()]), denied_tools: vec!["bash".to_string()], allowed_subagents: None, denied_subagents: vec![], }; let updated = runtime .update_subagent( "demo", Some("updated desc"), None, Some(&new_cap), Some(&None), Some(&None), false, ) .unwrap(); assert_eq!(updated.description, "updated desc"); assert_eq!( updated.capability.denied_skills, vec!["skill_x".to_string()] ); assert_eq!( updated.capability.allowed_tools.as_deref(), Some(["read".to_string()].as_slice()) ); // 重新从文件 parse 验证写回成功 let reparsed = parse_subagent_file(&path, SubagentSource::Project).unwrap(); assert_eq!(reparsed.description, "updated desc"); assert_eq!( reparsed.capability.denied_skills, vec!["skill_x".to_string()] ); } #[test] fn update_subagent_rejects_builtin() { let runtime = SubagentRuntime::from_config(SubagentsConfig::default()); // builtin general 子代理无 path,update 应失败 let result = runtime.update_subagent( "general", Some("new desc"), None, None, Some(&None), Some(&None), false, ); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("builtin") || err.contains("not found")); } // ===== create_subagent / delete_subagent 测试 ===== // // create_subagent 写入到 project scope 的固定路径 {cwd}/.picobot/subagents/{name}/SUBAGENT.md, // 与 expert create 一致。测试中以 temp.path() 作为 cwd,project root 即 temp/.picobot/subagents/。 fn make_runtime_with_cwd(cwd: &Path) -> SubagentRuntime { let config = SubagentsConfig { enabled: true, sources: vec!["project".to_string()], }; let catalog = SubagentCatalog::discover_with_cwd(&config, cwd); SubagentRuntime::new(config, catalog, cwd.to_path_buf()) } #[test] fn create_subagent_writes_file_and_appears_in_list() { let temp = tempfile::tempdir().unwrap(); let runtime = make_runtime_with_cwd(temp.path()); let cap = CapabilityPolicy { allowed_skills: None, denied_skills: vec!["skill_x".to_string()], allowed_tools: Some(vec!["read".to_string()]), denied_tools: vec![], allowed_subagents: None, denied_subagents: vec![], }; let created = runtime .create_subagent( SubagentScope::Project, "demo-create", "demo create agent", "demo body content", &cap, &None, &None, true, ) .unwrap(); assert_eq!(created.name, "demo-create"); assert_eq!(created.description, "demo create agent"); assert_eq!(created.body.as_deref(), Some("demo body content")); assert_eq!(created.source, SubagentSource::Project); // 文件确实创建在 project root 下 let file_path = temp .path() .join(".picobot") .join("subagents") .join("demo-create") .join("SUBAGENT.md"); assert!(file_path.exists(), "SUBAGENT.md should be created"); // list_with_status 能看到新子代理 let items = runtime.list_with_status(); let item = items.iter().find(|i| i.name == "demo-create").unwrap(); assert_eq!(item.description, "demo create agent"); assert_eq!(item.body.as_deref(), Some("demo body content")); assert_eq!( item.capability.denied_skills, vec!["skill_x".to_string()] ); } #[test] fn create_subagent_rejects_duplicate() { let temp = tempfile::tempdir().unwrap(); let runtime = make_runtime_with_cwd(temp.path()); runtime .create_subagent( SubagentScope::Project, "dup", "first", "", &CapabilityPolicy::default(), &None, &None, true, ) .unwrap(); // 同名再次创建应失败 let result = runtime.create_subagent( SubagentScope::Project, "dup", "second", "", &CapabilityPolicy::default(), &None, &None, true, ); assert!(result.is_err()); assert!(result.unwrap_err().contains("already exists")); } #[test] fn create_subagent_rejects_builtin_name() { let temp = tempfile::tempdir().unwrap(); let runtime = make_runtime_with_cwd(temp.path()); // builtin general 已存在,应拒绝 let result = runtime.create_subagent( SubagentScope::Project, "general", "hijack", "", &CapabilityPolicy::default(), &None, &None, true, ); assert!(result.is_err()); assert!(result.unwrap_err().contains("already exists")); } #[test] fn delete_subagent_removes_file_and_directory() { let temp = tempfile::tempdir().unwrap(); let runtime = make_runtime_with_cwd(temp.path()); runtime .create_subagent( SubagentScope::Project, "doomed", "to be deleted", "", &CapabilityPolicy::default(), &None, &None, true, ) .unwrap(); let dir = temp .path() .join(".picobot") .join("subagents") .join("doomed"); let file_path = dir.join("SUBAGENT.md"); assert!(file_path.exists()); let deleted_dir = runtime.delete_subagent("doomed", true).unwrap(); assert_eq!(deleted_dir, dir); assert!(!dir.exists(), "directory should be removed"); } #[test] fn delete_subagent_rejects_builtin() { let runtime = SubagentRuntime::from_config(SubagentsConfig::default()); let result = runtime.delete_subagent("general", false); assert!(result.is_err()); let err = result.unwrap_err(); assert!(err.contains("builtin") || err.contains("not found")); } #[test] fn delete_subagent_rejects_nonexistent() { let runtime = SubagentRuntime::from_config(SubagentsConfig::default()); let result = runtime.delete_subagent("never-existed", false); assert!(result.is_err()); assert!(result.unwrap_err().contains("not found")); } #[test] fn delete_subagent_preserves_other_files_in_directory() { let temp = tempfile::tempdir().unwrap(); let runtime = make_runtime_with_cwd(temp.path()); runtime .create_subagent( SubagentScope::Project, "mixed", "has extra files", "", &CapabilityPolicy::default(), &None, &None, true, ) .unwrap(); // 在子代理目录内放一个用户文件 let extra_file = temp .path() .join(".picobot") .join("subagents") .join("mixed") .join("notes.txt"); std::fs::write(&extra_file, "user notes").unwrap(); // 删除子代理:应只删 SUBAGENT.md,保留 notes.txt 和目录 runtime.delete_subagent("mixed", true).unwrap(); assert!(extra_file.exists(), "user file should be preserved"); assert!( temp.path() .join(".picobot") .join("subagents") .join("mixed") .exists(), "directory should be preserved when it has other files" ); assert!( !temp.path() .join(".picobot") .join("subagents") .join("mixed") .join("SUBAGENT.md") .exists(), "SUBAGENT.md should be removed" ); } }