Compare commits

...

5 Commits

Author SHA1 Message Date
oudecheng
644f5f9132 feat: 子代理继承主代理的 MCP 工具
- 为 McpToolWrapper 添加 Clone trait,支持工具实例复用
- 修改 build_subagent_tools 方法,支持传入 MCP 工具列表
- 调整 runtime 构建顺序:先等待 MCP 连接,再将 MCP 工具传递给子代理

子代理现在可以自动使用主代理配置的 MCP 工具(如 filesystem、fetch 等)。
2026-05-26 11:53:40 +08:00
oudecheng
861aa04690 feat: 将 TaskConfig 默认超时时间统一改为60分钟
- default_task_max_execution_secs: 1200 -> 3600 (20分钟 -> 60分钟)
- default_task_explore_max_execution_secs: 600 -> 3600 (10分钟 -> 60分钟)

确保配置层默认值与代码层一致。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 08:52:30 +08:00
oudecheng
c41c2a1d2e feat: 将内置子代理默认超时时间统一改为60分钟
- general 子代理: 20分钟 -> 60分钟
- explore 子代理: 10分钟 -> 60分钟

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:59:34 +08:00
oudecheng
b3dc207ad1 fix: 移除 temperature 和 max_tokens 的硬编码默认值
如果配置中没有设置 temperature 或 max_tokens,不再传递这些参数给模型,
让模型使用自己的默认值,而不是硬编码 0.7。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 12:58:15 +08:00
oudecheng
86d48a3ec0 feat: 实现自定义子代理加载功能
- 添加 SubagentCatalog::discover() 方法,支持从文件系统加载自定义子代理
- 支持 ~/.picobot/subagents/ 和 ./.picobot/subagents/ 两个目录
- 项目级定义可覆盖用户级定义
- 支持 YAML frontmatter + body 格式解析
- 修复 Windows 换行符兼容性问题
- 移除未使用的 read_only 字段
- 实现 TaskTool 动态 schema,子代理类型列表从运行时获取

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 11:56:44 +08:00
12 changed files with 352 additions and 29 deletions

View File

@ -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 | 否 | 是否只读代理 |
#### 模板变量

View File

@ -228,11 +228,11 @@ fn default_task_enabled() -> bool {
}
fn default_task_max_execution_secs() -> u64 {
1200 // 20分钟
3600 // 60分钟
}
fn default_task_explore_max_execution_secs() -> u64 {
600 // 10分钟
3600 // 60分钟
}
fn default_task_ttl_hours() -> u64 {

View File

@ -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,

View File

@ -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<SkillRuntime>,
disabled_tools: HashSet<String>,
task_config: TaskConfig,
subagents_config: SubagentsConfig,
maintenance_config: MemoryMaintenanceConfig,
session_ttl_hours: Option<u64>,
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<dyn SessionMessageSender>,
disabled_tools: HashSet<String>,
task_config: TaskConfig,
subagents_config: SubagentsConfig,
maintenance_config: MemoryMaintenanceConfig,
session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig,
@ -121,13 +124,54 @@ pub(crate) fn build_session_manager_with_sender(
factory
};
// Wait for MCP connections and collect MCP tools for subagents
// This needs to happen before building subagent tools
let mut mcp_tools_for_subagents: Vec<crate::mcp::tool_adapter::McpToolWrapper> = Vec::new();
if mcp_initializer.is_enabled() {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
// Wait for connections to complete
if let Err(e) = mcp_initializer.wait_for_connections().await {
tracing::error!(error = %e, "Failed to wait for MCP connections");
return;
}
// Collect MCP tools for subagents
if let Some(manager) = mcp_initializer.manager() {
let all_tools = manager.all_tools().await;
for (server_key, tool_info) in all_tools {
let wrapper = crate::mcp::tool_adapter::McpToolWrapper::new(
manager.clone(),
server_key.clone(),
tool_info,
);
mcp_tools_for_subagents.push(wrapper);
}
tracing::info!(
tool_count = mcp_tools_for_subagents.len(),
"Collected MCP tools for subagents"
);
}
})
});
}
// Create SubAgentRuntime (if task tool is enabled)
let (factory, task_repository): (_, Arc<dyn TaskRepository>) = if task_config.enabled {
let task_repository = Arc::new(InMemoryTaskRepository::new());
let subagent_tools = Arc::new(factory.build_subagent_tools());
// Build subagent tools with MCP tools
let subagent_tools = Arc::new(
factory.build_subagent_tools(
if mcp_tools_for_subagents.is_empty() {
None
} else {
Some(mcp_tools_for_subagents.clone())
}
)
);
// 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(),
@ -154,14 +198,16 @@ pub(crate) fn build_session_manager_with_sender(
// Build base tools
let mut tools = factory.build();
// Register MCP tools (async)
// This waits briefly for connections, then registers available tools
// Register MCP tools to main agent (async)
// Note: MCP tools for subagents are already collected above
if mcp_initializer.is_enabled() {
tokio::task::block_in_place(|| {
tokio::runtime::Handle::current().block_on(async {
if let Err(e) = mcp_initializer.register_tools(&mut tools).await {
tracing::error!(error = %e, "Failed to register MCP tools");
// Register pre-collected MCP tools
for tool in mcp_tools_for_subagents {
tools.register(tool);
}
tracing::info!("Registered MCP tools to main agent");
})
});
}

View File

@ -493,6 +493,7 @@ impl SessionManager {
skills: Arc<SkillRuntime>,
disabled_tools: std::collections::HashSet<String>,
task_config: crate::config::TaskConfig,
subagents_config: crate::config::SubagentsConfig,
maintenance_config: crate::config::MemoryMaintenanceConfig,
session_ttl_hours: Option<u64>,
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(),

View File

@ -142,7 +142,11 @@ impl ToolRegistryFactory {
}
/// 构建子代理专用工具集(不包含 task 工具防止递归)
pub(crate) fn build_subagent_tools(&self) -> ToolRegistry {
/// 可选地包含 MCP 工具(通过 mcp_tools 参数传递)
pub(crate) fn build_subagent_tools(
&self,
mcp_tools: Option<Vec<crate::mcp::tool_adapter::McpToolWrapper>>,
) -> ToolRegistry {
let mut registry = ToolRegistry::new();
// 基础工具
@ -194,6 +198,13 @@ impl ToolRegistryFactory {
registry.register(SessionSendTool::new(self.session_message_sender.clone()));
}
// 注册 MCP 工具(如果提供)
if let Some(mcp_tools) = mcp_tools {
for tool in mcp_tools {
registry.register(tool);
}
}
// 注意:不注册 task 工具,防止递归创建子代理
registry

View File

@ -9,6 +9,7 @@ use crate::mcp::client::McpClientManager;
use crate::tools::traits::{Tool as PicoBotTool, ToolResult};
/// Wrapper that adapts an MCP tool to PicoBot's Tool trait
#[derive(Clone)]
pub struct McpToolWrapper {
/// The MCP client manager
manager: Arc<McpClientManager>,

View File

@ -612,10 +612,18 @@ impl OpenAIProvider {
message
}
}).collect::<Vec<_>>(),
"temperature": request.temperature.or(self.temperature).unwrap_or(0.7),
"max_tokens": request.max_tokens.or(self.max_tokens),
});
// 只有配置了才添加 temperature否则让模型使用默认值
if let Some(temp) = request.temperature.or(self.temperature) {
body["temperature"] = json!(temp);
}
// 只有配置了才添加 max_tokens
if let Some(tokens) = request.max_tokens.or(self.max_tokens) {
body["max_tokens"] = json!(tokens);
}
for (key, value) in self.request_model_extra() {
body[key] = value.clone();
}

View File

@ -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,
}

View File

@ -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)]
@ -47,8 +50,8 @@ impl Default for SubAgentRuntimeConfig {
"skill_list".to_string(),
"send_session_message".to_string(), // 用于进度通知
]),
default_max_execution_secs: 1200, // 20分钟
explore_max_execution_secs: 600, // 10分钟
default_max_execution_secs: 3600, // 60分钟
explore_max_execution_secs: 3600, // 60分钟
ttl_hours: 24,
skills_index: None,
}
@ -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<String, SubagentDef> = 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('"', "&quot;")
.replace('\'', "&apos;")
}
// ========== 自定义子代理发现 ==========
/// 源顺序解析
fn source_order(sources: &[String]) -> Vec<SubagentSource> {
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<std::path::PathBuf> {
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<String>,
description: String,
#[serde(default)]
prompt_template: Option<String>,
#[serde(default)]
allowed_tools: Option<Vec<String>>,
#[serde(default)]
max_execution_secs: Option<u64>,
}
/// 从根目录加载所有子代理
fn load_subagents_from_root(root: &Path, source: SubagentSource) -> Vec<SubagentDef> {
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<SubagentDef, String> {
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))
}

View File

@ -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<serde_json::Value> = 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",

View File

@ -49,8 +49,6 @@ pub struct SubagentDef {
pub allowed_tools: Option<Vec<String>>,
/// 最大执行时间None 表示使用默认
pub max_execution_secs: Option<u64>,
/// 是否只读代理
pub read_only: Option<bool>,
/// 来源
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,
}