From 86d48a3ec0cc30259c731ea012800203fe08c7ce Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 25 May 2026 11:56:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E8=87=AA=E5=AE=9A?= =?UTF-8?q?=E4=B9=89=E5=AD=90=E4=BB=A3=E7=90=86=E5=8A=A0=E8=BD=BD=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加 SubagentCatalog::discover() 方法,支持从文件系统加载自定义子代理 - 支持 ~/.picobot/subagents/ 和 ./.picobot/subagents/ 两个目录 - 项目级定义可覆盖用户级定义 - 支持 YAML frontmatter + body 格式解析 - 修复 Windows 换行符兼容性问题 - 移除未使用的 read_only 字段 - 实现 TaskTool 动态 schema,子代理类型列表从运行时获取 Co-Authored-By: Claude Opus 4.7 --- README.md | 2 - src/gateway/mod.rs | 1 + src/gateway/runtime.rs | 9 +- src/gateway/session.rs | 11 ++ src/tools/task/prompt.rs | 18 ++- src/tools/task/runtime.rs | 241 +++++++++++++++++++++++++++++++++++++- src/tools/task/tool.rs | 8 +- src/tools/task/types.rs | 4 - 8 files changed, 277 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 3374389..6f9db5b 100644 --- a/README.md +++ b/README.md @@ -540,7 +540,6 @@ prompt_template: | 注意: 你是一个只读代理,禁止执行任何修改操作。 allowed_tools: [read, bash, web_fetch] # 可选,覆盖默认工具白名单 max_execution_secs: 600 # 可选,覆盖默认执行时间 -read_only: true # 可选,标记为只读代理 --- 请重点关注: @@ -558,7 +557,6 @@ read_only: true # 可选,标记为只读代理 | `prompt_template` | string | 是 | 提示词模板,支持变量插值 | | `allowed_tools` | array | 否 | 工具白名单,不指定时使用默认列表 | | `max_execution_secs` | integer | 否 | 最大执行时间(秒) | -| `read_only` | boolean | 否 | 是否只读代理 | #### 模板变量 diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index cbadafd..24be1ab 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -86,6 +86,7 @@ impl GatewayState { Arc::new(BusSessionMessageSender::new(bus.clone())), std::collections::HashSet::new(), config.tools.task.clone(), + config.subagents.clone(), config.memory_maintenance.clone(), session_ttl_hours, mcp_config, diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index f9ae57c..a7557d6 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use crate::agent::AgentError; -use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, TaskConfig}; +use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, SubagentsConfig, TaskConfig}; use crate::gateway::tool_registry_factory::ToolRegistryFactory; use crate::mcp::McpInitializer; use crate::skills::SkillRuntime; @@ -40,6 +40,7 @@ pub(crate) fn build_session_manager( skills: Arc, disabled_tools: HashSet, task_config: TaskConfig, + subagents_config: SubagentsConfig, maintenance_config: MemoryMaintenanceConfig, session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, @@ -54,6 +55,7 @@ pub(crate) fn build_session_manager( Arc::new(NoopSessionMessageSender), disabled_tools, task_config, + subagents_config, maintenance_config, session_ttl_hours, mcp_config, @@ -71,6 +73,7 @@ pub(crate) fn build_session_manager_with_sender( session_message_sender: Arc, disabled_tools: HashSet, task_config: TaskConfig, + subagents_config: SubagentsConfig, maintenance_config: MemoryMaintenanceConfig, session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, @@ -126,8 +129,8 @@ pub(crate) fn build_session_manager_with_sender( let task_repository = Arc::new(InMemoryTaskRepository::new()); let subagent_tools = Arc::new(factory.build_subagent_tools()); - // Create subagent catalog with builtin definitions - let catalog = Arc::new(SubagentCatalog::new()); + // Create subagent catalog with discovery + let catalog = Arc::new(SubagentCatalog::discover(&subagents_config)); let runtime_config = SubAgentRuntimeConfig { default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(), diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 5a4b572..1c3d89f 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -493,6 +493,7 @@ impl SessionManager { skills: Arc, disabled_tools: std::collections::HashSet, task_config: crate::config::TaskConfig, + subagents_config: crate::config::SubagentsConfig, maintenance_config: crate::config::MemoryMaintenanceConfig, session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, @@ -506,6 +507,7 @@ impl SessionManager { skills, disabled_tools, task_config, + subagents_config, maintenance_config, session_ttl_hours, mcp_config, @@ -968,6 +970,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1023,6 +1026,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1092,6 +1096,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1179,6 +1184,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1268,6 +1274,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1356,6 +1363,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1426,6 +1434,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1505,6 +1514,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), @@ -1571,6 +1581,7 @@ mod tests { Arc::new(SkillRuntime::default()), HashSet::new(), crate::config::TaskConfig::default(), + crate::config::SubagentsConfig::default(), crate::config::MemoryMaintenanceConfig::default(), Some(24), crate::mcp::McpConfig::default(), diff --git a/src/tools/task/prompt.rs b/src/tools/task/prompt.rs index 4031081..e5e830d 100644 --- a/src/tools/task/prompt.rs +++ b/src/tools/task/prompt.rs @@ -43,8 +43,21 @@ impl SubagentPromptBuilder { /// 插值提示词模板 fn interpolate_template(def: &SubagentDef, description: &str, prompt: &str) -> String { - let mut result = def - .prompt_template + // 自定义子代理使用通用模板 + let base = if def.prompt_template.is_empty() { + "你是一个专注的子代理,正在执行一个独立任务。\n\n\ + 任务描述: {{description}}\n\n\ + 你应该:\n\ + 1. 专注于完成任务,不要偏离目标\n\ + 2. 使用可用的工具进行必要操作\n\ + 3. 完成后给出简洁的总结\n\ + 4. 不要尝试创建新的子代理任务\n\n\ + 注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。" + } else { + &def.prompt_template + }; + + let mut result = base .replace("{{description}}", description) .replace("{{prompt}}", prompt); @@ -88,7 +101,6 @@ mod tests { body: None, allowed_tools: None, max_execution_secs: None, - read_only: None, source: SubagentSource::Builtin, path: None, } diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 1add891..32633ae 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -1,19 +1,22 @@ use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use serde::Deserialize; use crate::agent::{AgentLoop, AgentRuntimeConfig, SystemPrompt, SystemPromptContext, SystemPromptProvider}; use crate::bus::ChatMessage; -use crate::config::LLMProviderConfig; +use crate::config::{LLMProviderConfig, SubagentsConfig}; use crate::storage::ConversationRepository; use crate::tools::{ToolContext, ToolRegistry}; use super::error::TaskError; use super::prompt::{extract_summary, SubagentPromptBuilder}; use super::repository::TaskRepository; -use super::types::{SubagentDef, TaskDefinition, TaskSession, TaskToolResult}; +use super::types::{SubagentDef, SubagentSource, TaskDefinition, TaskSession, TaskToolResult}; /// 子代理运行时配置 #[derive(Debug, Clone)] @@ -479,6 +482,65 @@ impl SubagentCatalog { 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()); + merged.insert("explore".to_string(), SubagentDef::builtin_explore()); + + 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) { + 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); @@ -533,3 +595,178 @@ fn xml_escape(s: &str) -> String { .replace('"', """) .replace('\'', "'") } + +// ========== 自定义子代理发现 ========== + +/// 源顺序解析 +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 => { + tracing::warn!(source = %unknown, "Unknown subagents source ignored"); + } + } + } + + // 默认顺序:先 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, + } +} + +/// 子代理 frontmatter 结构 +#[derive(Debug, Deserialize)] +struct SubagentFrontmatter { + #[serde(default)] + name: Option, + description: String, + #[serde(default)] + prompt_template: Option, + #[serde(default)] + allowed_tools: Option>, + #[serde(default)] + max_execution_secs: 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) { + 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_raw, body) = split_frontmatter(&content) + .ok_or_else(|| "missing YAML frontmatter block".to_string())?; + + let frontmatter: SubagentFrontmatter = serde_yaml::from_str(frontmatter_raw) + .map_err(|e| format!("invalid YAML frontmatter: {}", e))?; + + 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(); + + Ok(SubagentDef { + name, + description: frontmatter.description.trim().to_string(), + prompt_template, + body: if body_content.is_empty() { None } else { Some(body_content) }, + allowed_tools: frontmatter.allowed_tools, + max_execution_secs: frontmatter.max_execution_secs, + source, + path: Some(path.to_path_buf()), + }) +} + +/// 分割 frontmatter 和 body +fn split_frontmatter(content: &str) -> Option<(&str, &str)> { + // 跳过开头的 --- + let content = content + .strip_prefix("---") + .or_else(|| content.strip_prefix("---"))?; + + // 跳过 --- 后的换行符和可能的空行 + let content = content.trim_start_matches('\r').trim_start_matches('\n'); + + // 找结束标记(容忍不同的换行符格式和前面的空行) + // 尝试多种可能的结束标记格式 + let end_markers = ["\n---\n", "\n---", "\r\n---\r\n", "\r\n---"]; + let mut idx = None; + let mut marker_len = 0; + for marker in end_markers { + if let Some(pos) = content.find(marker) { + idx = Some(pos); + marker_len = marker.len(); + break; + } + } + let idx = idx?; + + let frontmatter = &content[..idx]; + let body = &content[idx + marker_len..]; + let body = body.trim_start_matches('\r').trim_start_matches('\n'); + + Some((frontmatter, body)) +} diff --git a/src/tools/task/tool.rs b/src/tools/task/tool.rs index 6a0b66d..2ab16dd 100644 --- a/src/tools/task/tool.rs +++ b/src/tools/task/tool.rs @@ -27,11 +27,13 @@ impl Tool for TaskTool { fn description(&self) -> &str { "Launch a specialized subagent to handle complex, multi-step tasks. \ Subagents run in isolated contexts and can work in parallel. \ - Use 'general' type for complex tasks, 'explore' type for read-only exploration. \ You can resume a previous task by providing its task_id." } fn parameters_schema(&self) -> serde_json::Value { + let types = self.runtime.available_subagent_names(); + let types_array: Vec = types.into_iter().map(|t| json!(t)).collect(); + json!({ "type": "object", "properties": { @@ -46,9 +48,9 @@ impl Tool for TaskTool { }, "subagent_type": { "type": "string", - "enum": ["general", "explore"], + "enum": types_array, "default": "general", - "description": "Type of subagent: 'general' for complex multi-step tasks, 'explore' for read-only search/exploration" + "description": "Type of subagent to use for the task" }, "task_id": { "type": "string", diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index eb92345..72d1419 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -49,8 +49,6 @@ pub struct SubagentDef { pub allowed_tools: Option>, /// 最大执行时间(秒),None 表示使用默认 pub max_execution_secs: Option, - /// 是否只读代理 - pub read_only: Option, /// 来源 pub source: SubagentSource, /// 文件路径(仅自定义类型) @@ -67,7 +65,6 @@ impl SubagentDef { body: None, allowed_tools: None, max_execution_secs: None, - read_only: Some(false), source: SubagentSource::Builtin, path: None, } @@ -82,7 +79,6 @@ impl SubagentDef { body: None, allowed_tools: None, max_execution_secs: None, - read_only: Some(true), source: SubagentSource::Builtin, path: None, }