diff --git a/README.md b/README.md index 5dad979..3374389 100644 --- a/README.md +++ b/README.md @@ -498,11 +498,108 @@ tools 配置示例: - shell - 执行 shell 命令(Windows PowerShell/Cmd) - http_request - HTTP 请求 - web_fetch - 网页抓取 -- task - 创建和管理子代理 +- task - 创建和管理子代理,支持内置类型(general/explore)和用户自定义类型 注意:bash 和 shell 是同一个工具在不同平台上的名称,运行时自动检测。 -## 8. 工具机制 +## 8. 子代理系统 + +PicoBot 支持通过 `task` 工具创建子代理来处理复杂多步骤任务。子代理在一个独立的执行上下文中运行,拥有独立的会话历史和工具权限。 + +### 8.1 内置子代理类型 + +- **general**:通用型子代理,适合处理复杂多步骤任务。可以使用读写文件、执行命令、HTTP 请求等完整工具集。 +- **explore**:探索型子代理,用于代码库探索和信息收集。只使用只读工具,禁止任何写操作。 + +### 8.2 自定义子代理 + +用户可以在文件系统上定义新的子代理类型,类似于技能的加载方式。子代理定义文件采用 YAML frontmatter + body 的格式。 + +#### 定义位置 + +按从低到高优先级合并,后加载来源可覆盖同名定义: + +- 用户级:`~/.picobot/subagents/*/SUBAGENT.md` +- 项目级:`.picobot/subagents/*/SUBAGENT.md` + +#### SUBAGENT.md 格式 + +```md +--- +name: code-review # 可选,省略时使用目录名 +description: 代码审查代理,用于检查代码质量和安全问题 +prompt_template: | + 你是一个专业的代码审查代理。 + 任务描述: {{description}} + + 你应该: + 1. 检查代码质量和潜在问题 + 2. 提出改进建议 + 3. 完成后给出简洁的总结 + + 注意: 你是一个只读代理,禁止执行任何修改操作。 +allowed_tools: [read, bash, web_fetch] # 可选,覆盖默认工具白名单 +max_execution_secs: 600 # 可选,覆盖默认执行时间 +read_only: true # 可选,标记为只读代理 +--- + +请重点关注: +- SQL 注入风险 +- XSS 漏洞 +- 权限校验缺失 +``` + +#### 字段说明 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `name` | string | 否 | 子代理名称,默认取目录名 | +| `description` | string | 是 | 简短描述,用于 agent 选择 | +| `prompt_template` | string | 是 | 提示词模板,支持变量插值 | +| `allowed_tools` | array | 否 | 工具白名单,不指定时使用默认列表 | +| `max_execution_secs` | integer | 否 | 最大执行时间(秒) | +| `read_only` | boolean | 否 | 是否只读代理 | + +#### 模板变量 + +`prompt_template` 支持以下变量插值: + +- `{{description}}`:任务描述(来自 `task` 工具的 description 参数) +- `{{prompt}}`:详细指令(来自 `task` 工具的 prompt 参数) + +文件 body 部分(frontmatter 之后的内容)会追加到提示词模板末尾,用于提供更详细的补充指令。 + +#### 使用方式 + +创建子代理后,通过 `task` 工具的 `subagent_type` 参数指定类型: + +```json +{ + "description": "审查 src/auth 目录下的代码", + "prompt": "检查安全漏洞和错误处理", + "subagent_type": "code-review" +} +``` + +如果指定的子代理类型不存在,系统会自动回退到 `general` 类型。 + +### 8.3 子代理配置 + +```json +{ + "subagents": { + "enabled": true, + "sources": ["user", "project"] + } +} +``` + +| 字段 | 默认值 | 说明 | +|------|--------|------| +| `enabled` | `true` | 是否启用自定义子代理发现 | +| `sources` | `["user", "project"]` | 定义来源优先级 | + +## 9. 工具机制 PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具包括: @@ -520,7 +617,7 @@ PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具 - bash / shell:执行 shell 命令(同一工具,Unix 下名称为 bash,Windows 下名称为 shell) - http_request:发起 HTTP 请求 - web_fetch:抓取网页正文 -- task:创建和管理子代理 +- task:创建和管理子代理,支持内置类型(general/explore)和用户自定义类型 其中: @@ -530,9 +627,9 @@ PicoBot 的 Agent 是围绕工具调用构建的。当前默认注册的工具 - skill_activate 负责把具体技能正文注入当前任务上下文 - skill_manage 整合了技能列出与管理功能,支持运行时创建、更新、删除和批量禁用 - bash / shell / http_request / web_fetch 让 Agent 具备更强的外部交互能力(bash 和 shell 是同一工具在不同平台的名称) -- task 允许 Agent 创建独立上下文的子代理来处理复杂多步骤任务,支持 general 和 explore 两种类型 +- task 允许 Agent 创建独立上下文的子代理来处理复杂多步骤任务,支持内置类型(general/explore)和用户自定义类型 -### 8.1 MCP 工具集成 +### 9.1 MCP 工具集成 PicoBot 支持通过 MCP (Model Context Protocol) 扩展工具能力,可以连接外部 MCP servers 并自动发现其提供的工具。配置格式兼容 Claude Desktop / Cursor。 @@ -609,18 +706,18 @@ MCP 工具会自动注册到 ToolRegistry,命名格式为 `mcp_{server_key}_{t - 通过 Tool trait 适配器接入,无需修改核心代码 - 连接失败不影响 Gateway 运行 -## 9. 调度器机制 +## 10. 调度器机制 PicoBot 带有一个基于 SQLite 的调度器,而不是纯内存或 JSON 文件驱动的任务系统。 -### 9.1 支持的调度类型 +### 10.1 支持的调度类型 - delay:延迟执行一次 - interval:固定间隔执行 - at:某个绝对时间执行一次 - cron:cron 表达式调度 -### 9.2 支持的任务类型 +### 10.2 支持的任务类型 - internal_event:内部事件 - outbound_message:直接向目标通道发消息 @@ -643,7 +740,7 @@ silent_agent_task 和 agent_task 使用同一套 Agent 执行能力,但路由 - 执行失败时会向主 chat 发送一条失败通知,便于用户感知异常 - 后台任务的历史、压缩和会话内上下文会留在独立会话中,不污染主会话 -### 9.3 运行时管理 +### 10.3 运行时管理 通过 scheduler_manage 可以进行: @@ -737,22 +834,22 @@ silent_agent_task 和 agent_task 使用同一套 Agent 执行能力,但路由 - agent_task:用户需要直接收到结果,例如日报提醒、定时播报、定时外发通知 - silent_agent_task:任务需要长期积累独立上下文或后台整理材料,但不应污染主会话,例如周报草稿整理、周期性资料汇总、后台分析任务 -## 10. 渠道与运行方式 +## 11. 渠道与运行方式 -### 10.1 当前支持的通道 +### 11.1 当前支持的通道 - WebSocket CLI 客户端 - 飞书通道 - 微信通道 -### 10.2 Gateway 接口 +### 11.2 Gateway 接口 网关当前暴露: - /health:健康检查 - /ws:CLI 客户端连接入口 -### 10.3 CLI 使用方式 +### 11.3 CLI 使用方式 程序提供两个主命令: @@ -779,7 +876,7 @@ CLI 中已实现的交互命令包括: - /clear - /quit -## 11. 配置说明 +## 12. 配置说明 配置默认从以下位置加载: @@ -846,9 +943,9 @@ CLI 中已实现的交互命令包括: - tools:工具启用/禁用配置(通过 disabled 列表指定禁用的工具) - time.timezone:时区,默认应使用 IANA 时区名,例如 Asia/Shanghai -## 12. 快速开始 +## 13. 快速开始 -### 12.1 准备配置 +### 13.1 准备配置 1. 复制并修改 config.json,或把配置放到 ~/.picobot/config.json 2. 配置好 Provider 的 base_url、api_key、model_id @@ -894,13 +991,13 @@ CLI 中已实现的交互命令包括: } ``` -### 12.2 启动网关 +### 13.2 启动网关 ```bash cargo run -- gateway ``` -### 12.3 启动本地 CLI +### 13.3 启动本地 CLI ```bash cargo run -- agent @@ -918,13 +1015,13 @@ ws://127.0.0.1:19876/ws cargo run -- agent --gateway-url ws://127.0.0.1:19876/ws ``` -### 12.4 检查服务状态 +### 13.4 检查服务状态 ```bash curl http://127.0.0.1:19876/health ``` -## 13. 目录结构 +## 14. 目录结构 ```text PicoBot/ @@ -947,7 +1044,7 @@ PicoBot/ │ ├── skills/ # 技能运行时 │ ├── storage/ # SQLite 持久化(存储、端口、记录、错误) │ ├── text/ # 文本处理工具 -│ └── tools/ # 内置工具集合 +│ └── tools/ # 内置工具集合(含 task 子代理系统) ├── docs/ │ ├── IMPLEMENTATION_LOG.md │ └── PERSISTENCE.md @@ -955,7 +1052,7 @@ PicoBot/ └── config.json ``` -## 14. 测试与维护建议 +## 15. 测试与维护建议 当前 tests 目录中已经包含 Provider 集成测试和工具调用相关测试,但部分测试依赖外部 API Key,需要先准备 tests/test.env。 @@ -969,13 +1066,13 @@ PicoBot/ - src/bus/message.rs:消息结构变更(如 OutboundMessage 新增 session_id) - src/command/handlers/:命令处理器实现 -## 15. 总结 +## 16. 总结 PicoBot 当前已经具备一个可长期运行 Agent 系统的关键组件: - 有入口:Gateway + Channel - 有状态:SQLite + Session 恢复 -- 有能力:工具调用 + 技能系统 + MCP 扩展 +- 有能力:工具调用 + 技能系统 + MCP 扩展 + 可自定义子代理 - 有记忆:长期记忆 + 自动维护摘要 - 有计划:Scheduler + agent_task diff --git a/src/cli/init.rs b/src/cli/init.rs index 7f5b61d..e4fcfef 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -78,6 +78,7 @@ impl InitWizard { memory_maintenance: crate::config::MemoryMaintenanceConfig::default(), mcp_servers: HashMap::new(), image_context: crate::config::ImageContextConfig::default(), + subagents: crate::config::SubagentsConfig::default(), } } @@ -830,6 +831,7 @@ impl InitWizard { memory_maintenance: existing.memory_maintenance.clone(), mcp_servers: existing.mcp_servers.clone(), image_context: existing.image_context.clone(), + subagents: existing.subagents.clone(), } } diff --git a/src/config/mod.rs b/src/config/mod.rs index fbd2605..f9046f8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -33,6 +33,8 @@ pub struct Config { pub mcp_servers: HashMap, #[serde(default)] pub image_context: ImageContextConfig, + #[serde(default)] + pub subagents: SubagentsConfig, } /// 图片上下文限制配置 @@ -136,6 +138,34 @@ impl Default for SkillsConfig { } } +/// 自定义子代理配置 +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct SubagentsConfig { + /// 是否启用自定义子代理发现 + #[serde(default = "default_subagents_enabled")] + pub enabled: bool, + /// 定义来源优先级 + #[serde(default = "default_subagents_sources")] + pub sources: Vec, +} + +fn default_subagents_enabled() -> bool { + true +} + +fn default_subagents_sources() -> Vec { + vec!["user".to_string(), "project".to_string()] +} + +impl Default for SubagentsConfig { + fn default() -> Self { + Self { + enabled: default_subagents_enabled(), + sources: default_subagents_sources(), + } + } +} + #[derive(Debug, Clone, Default, Deserialize, Serialize)] pub struct ToolsConfig { #[serde(default)] diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index f279f77..f9ae57c 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -14,7 +14,7 @@ use crate::storage::{ }; use crate::tools::{ DefaultSubAgentRuntime, InMemoryTaskRepository, NoopSessionMessageSender, - SessionMessageSender, SubAgentRuntimeConfig, ToolRegistry, + SessionMessageSender, SubAgentRuntimeConfig, SubagentCatalog, ToolRegistry, }; use crate::tools::task::repository::TaskRepository; @@ -126,11 +126,13 @@ 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()); + let runtime_config = SubAgentRuntimeConfig { - allowed_tools: task_config.allowed_tools.iter().cloned().collect(), - max_execution_secs: task_config.max_execution_secs, + default_allowed_tools: task_config.allowed_tools.iter().cloned().collect(), + default_max_execution_secs: task_config.max_execution_secs, explore_max_execution_secs: task_config.explore_max_execution_secs, - explore_max_tool_calls: 20, ttl_hours: task_config.ttl_hours, skills_index: skills.system_index_prompt(), }; @@ -141,6 +143,7 @@ pub(crate) fn build_session_manager_with_sender( conversations.clone(), subagent_tools, provider_config.clone(), + catalog, )); (factory.with_subagent_runtime(subagent_runtime), task_repository) @@ -203,4 +206,4 @@ pub(crate) fn build_session_manager_with_sender( memory_maintenance, task_repository: task_repository.clone(), }), task_repository)) -} \ No newline at end of file +} diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 2afc902..f7f79c8 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -36,7 +36,7 @@ pub use skill_activate::SkillActivateTool; pub use skill_manage::SkillManageTool; pub use task::{ DefaultSubAgentRuntime, InMemoryTaskRepository, SubAgentRuntime, SubAgentRuntimeConfig, - TaskError, TaskRepository, TaskTool, + SubagentCatalog, TaskError, TaskRepository, TaskTool, }; pub use time::TimeTool; pub use traits::{Tool, ToolContext, ToolResult}; diff --git a/src/tools/task/mod.rs b/src/tools/task/mod.rs index 37619fc..1f1e6c2 100644 --- a/src/tools/task/mod.rs +++ b/src/tools/task/mod.rs @@ -8,6 +8,6 @@ pub mod types; pub use error::TaskError; pub use prompt::SubagentPromptBuilder; pub use repository::{InMemoryTaskRepository, TaskRepository}; -pub use runtime::{DefaultSubAgentRuntime, SubAgentRuntime, SubAgentRuntimeConfig, StaticSystemPromptProvider}; +pub use runtime::{DefaultSubAgentRuntime, SubAgentRuntime, SubAgentRuntimeConfig, SubagentCatalog, StaticSystemPromptProvider}; pub use tool::TaskTool; -pub use types::{SubagentType, TaskDefinition, TaskHandle, TaskSession, TaskSessionState, TaskToolArgs, TaskToolResult}; \ No newline at end of file +pub use types::{SubagentDef, SubagentSource, SubagentType, TaskDefinition, TaskHandle, TaskSession, TaskSessionState, TaskToolArgs, TaskToolResult}; \ No newline at end of file diff --git a/src/tools/task/prompt.rs b/src/tools/task/prompt.rs index 3608e76..4031081 100644 --- a/src/tools/task/prompt.rs +++ b/src/tools/task/prompt.rs @@ -1,4 +1,4 @@ -use super::types::SubagentType; +use super::types::SubagentDef; use crate::config::LLMProviderConfig; /// 子代理系统提示词构建器 @@ -7,16 +7,13 @@ pub struct SubagentPromptBuilder; impl SubagentPromptBuilder { /// 构建子代理系统提示词(包含系统环境信息和技能索引) pub fn build( - subagent_type: SubagentType, + def: &SubagentDef, description: &str, - _prompt: &str, + prompt: &str, config: &LLMProviderConfig, skills_index: Option<&str>, ) -> String { - let base_prompt = match subagent_type { - SubagentType::General => Self::build_general_prompt(description), - SubagentType::Explore => Self::build_explore_prompt(description), - }; + let base_prompt = Self::interpolate_template(def, description, prompt); let env_info = crate::agent::generate_system_env_prompt(config); // 组合提示词:基础 + 环境 + 技能索引(可选) @@ -44,32 +41,19 @@ impl SubagentPromptBuilder { ) } - fn build_general_prompt(description: &str) -> String { - format!( - "你是一个专注的子代理,正在执行一个独立任务。\n\n\ - 任务描述: {}\n\n\ - 你应该:\n\ - 1. 专注于完成任务,不要偏离目标\n\ - 2. 使用可用的工具进行必要操作\n\ - 3. 完成后给出简洁的总结\n\ - 4. 不要尝试创建新的子代理任务\n\n\ - 注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。", - description - ) - } + /// 插值提示词模板 + fn interpolate_template(def: &SubagentDef, description: &str, prompt: &str) -> String { + let mut result = def + .prompt_template + .replace("{{description}}", description) + .replace("{{prompt}}", prompt); - fn build_explore_prompt(description: &str) -> String { - format!( - "你是一个只读探索代理,用于代码库探索和信息收集。\n\n\ - 任务描述: {}\n\n\ - 你应该:\n\ - 1. 只使用只读工具进行探索\n\ - 2. 专注于理解和收集信息\n\ - 3. 不要进行任何写操作\n\ - 4. 给出简洁的发现总结\n\n\ - 注意: 你是一个只读代理,禁止执行任何修改操作。", - description - ) + if let Some(ref body) = def.body { + result.push_str("\n\n"); + result.push_str(body); + } + + result } } @@ -89,4 +73,88 @@ pub fn extract_summary(content: &str) -> String { } else { first_paragraph } -} \ No newline at end of file +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tools::task::types::SubagentSource; + + fn test_def() -> SubagentDef { + SubagentDef { + name: "general".to_string(), + description: "测试".to_string(), + prompt_template: "任务: {{description}}\n指令: {{prompt}}".to_string(), + body: None, + allowed_tools: None, + max_execution_secs: None, + read_only: None, + source: SubagentSource::Builtin, + path: None, + } + } + + #[test] + fn test_interpolates_template() { + let def = test_def(); + let result = SubagentPromptBuilder::build( + &def, + "审查代码", + "检查安全漏洞", + &LLMProviderConfig { + provider_type: "openai".to_string(), + name: "test".to_string(), + base_url: "http://localhost".to_string(), + api_key: "test".to_string(), + extra_headers: std::collections::HashMap::new(), + llm_timeout_secs: 120, + memory_maintenance_timeout_secs: 600, + model_id: "test".to_string(), + temperature: None, + max_tokens: None, + context_window_tokens: None, + model_extra: std::collections::HashMap::new(), + max_tool_iterations: 1, + tool_result_max_chars: 1000, + context_tool_result_trim_chars: 1000, + max_images_in_context: 1, + max_image_age_rounds: 10, + }, + None, + ); + assert!(result.contains("任务: 审查代码")); + assert!(result.contains("指令: 检查安全漏洞")); + } + + #[test] + fn test_appends_body() { + let mut def = test_def(); + def.body = Some("额外指令".to_string()); + let result = SubagentPromptBuilder::build( + &def, + "描述", + "指令", + &LLMProviderConfig { + provider_type: "openai".to_string(), + name: "test".to_string(), + base_url: "http://localhost".to_string(), + api_key: "test".to_string(), + extra_headers: std::collections::HashMap::new(), + llm_timeout_secs: 120, + memory_maintenance_timeout_secs: 600, + model_id: "test".to_string(), + temperature: None, + max_tokens: None, + context_window_tokens: None, + model_extra: std::collections::HashMap::new(), + max_tool_iterations: 1, + tool_result_max_chars: 1000, + context_tool_result_trim_chars: 1000, + max_images_in_context: 1, + max_image_age_rounds: 10, + }, + None, + ); + assert!(result.contains("额外指令")); + } +} diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 9fda713..1add891 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -13,19 +13,17 @@ use crate::tools::{ToolContext, ToolRegistry}; use super::error::TaskError; use super::prompt::{extract_summary, SubagentPromptBuilder}; use super::repository::TaskRepository; -use super::types::{SubagentType, TaskDefinition, TaskSession, TaskToolResult}; +use super::types::{SubagentDef, TaskDefinition, TaskSession, TaskToolResult}; /// 子代理运行时配置 #[derive(Debug, Clone)] pub struct SubAgentRuntimeConfig { - /// 子代理可用的工具列表(白名单) - pub allowed_tools: HashSet, - /// 最大执行时间(秒) - General 类型 - pub max_execution_secs: u64, + /// 默认工具白名单(定义未指定时使用) + pub default_allowed_tools: HashSet, + /// 默认最大执行时间(秒) + pub default_max_execution_secs: u64, /// Explore 类型的最大执行时间(秒) pub explore_max_execution_secs: u64, - /// 探索类型的最大工具调用次数 - pub explore_max_tool_calls: usize, /// 任务 TTL(小时) pub ttl_hours: u64, /// 技能索引(可选,预生成的技能列表字符串) @@ -35,7 +33,7 @@ pub struct SubAgentRuntimeConfig { impl Default for SubAgentRuntimeConfig { fn default() -> Self { Self { - allowed_tools: HashSet::from([ + default_allowed_tools: HashSet::from([ "read".to_string(), "edit".to_string(), "write".to_string(), @@ -49,9 +47,8 @@ impl Default for SubAgentRuntimeConfig { "skill_list".to_string(), "send_session_message".to_string(), // 用于进度通知 ]), - max_execution_secs: 1200, // 20分钟 + default_max_execution_secs: 1200, // 20分钟 explore_max_execution_secs: 600, // 10分钟 - explore_max_tool_calls: 20, ttl_hours: 24, skills_index: None, } @@ -81,6 +78,9 @@ pub trait SubAgentRuntime: Send + Sync + 'static { /// 清理过期任务 async fn cleanup_expired(&self) -> Result; + + /// 获取可用的子代理类型列表 + fn available_subagent_names(&self) -> Vec; } /// 静态系统提示词提供者(用于子代理) @@ -110,6 +110,8 @@ pub struct DefaultSubAgentRuntime { conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, + /// 子代理定义目录(内置 + 自定义) + catalog: Arc, } impl DefaultSubAgentRuntime { @@ -119,6 +121,7 @@ impl DefaultSubAgentRuntime { conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, + catalog: Arc, ) -> Self { Self { config, @@ -126,9 +129,33 @@ impl DefaultSubAgentRuntime { conversation_repository, subagent_tools, provider_config, + catalog, } } + /// 查找子代理定义,找不到时 fallback 到 general + fn find_subagent_def(&self, type_name: &str) -> SubagentDef { + self.catalog + .find(type_name) + .cloned() + .unwrap_or_else(|| self.catalog.find("general").expect("general subagent must exist").clone()) + } + + /// 获取实际使用的工具白名单(预留,未来可用于动态工具过滤) + #[allow(dead_code)] + fn effective_allowed_tools(&self, def: &SubagentDef) -> HashSet { + def.allowed_tools + .as_ref() + .map(|tools| tools.iter().cloned().collect()) + .unwrap_or_else(|| self.config.default_allowed_tools.clone()) + } + + /// 获取实际执行时间 + fn effective_max_execution_secs(&self, def: &SubagentDef) -> u64 { + def.max_execution_secs + .unwrap_or(self.config.default_max_execution_secs) + } + /// 创建子代理实例 fn create_subagent( &self, @@ -163,6 +190,7 @@ impl DefaultSubAgentRuntime { &self, agent: AgentLoop, session: &TaskSession, + def: &SubagentDef, prompt: String, ) -> Result { // 构建初始消息 @@ -174,10 +202,10 @@ impl DefaultSubAgentRuntime { }; // 设置超时 - let max_secs = if session.subagent_type == SubagentType::Explore { + let max_secs = if session.subagent_type == "explore" { self.config.explore_max_execution_secs } else { - self.config.max_execution_secs + self.effective_max_execution_secs(def) }; let timeout_duration = Duration::from_secs(max_secs); @@ -230,7 +258,8 @@ impl DefaultSubAgentRuntime { user_message_count, }; - let timeout_duration = Duration::from_secs(self.config.max_execution_secs); + // 使用默认执行时间(恢复任务时原始定义可能已不存在) + let timeout_duration = Duration::from_secs(self.config.default_max_execution_secs); let result = tokio::time::timeout( timeout_duration, @@ -282,7 +311,10 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { .clone() .ok_or_else(|| TaskError::MissingContext("channel_name".to_string()))?; - // 2. 创建任务会话 + // 2. 查找子代理定义 + let def = self.find_subagent_def(task.subagent_type.as_str()); + + // 3. 创建任务会话 let topic_id = parent_context.topic_id.clone(); let session = TaskSession::new( session_id, @@ -293,7 +325,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { task.subagent_type, ); - // 3. 在 sessions 表中创建子智能体会话(确保外键约束满足) + // 4. 在 sessions 表中创建子智能体会话(确保外键约束满足) let session_title = format!("Subagent: {}", task.description); if let Err(e) = self.conversation_repository.ensure_session( &session.session_id, @@ -304,27 +336,27 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { tracing::warn!(error = %e, session_id = %session.session_id, "Failed to ensure subagent session"); } - // 4. 保存任务会话 + // 5. 保存任务会话 self.task_repository.save_task_session(&session).await?; - // 4. 构建子代理系统提示词 + // 6. 构建子代理系统提示词 let system_prompt = SubagentPromptBuilder::build( - task.subagent_type, + &def, &task.description, &task.prompt, &self.provider_config, self.config.skills_index.as_deref(), ); - // 5. 创建子代理 + // 7. 创建子代理 let agent = self.create_subagent(&session, system_prompt)?; - // 6. 执行任务 + // 8. 执行任务 let result = self - .execute_task(agent, &session, task.prompt.clone()) + .execute_task(agent, &session, &def, task.prompt.clone()) .await; - // 7. 更新会话状态并保存 + // 9. 更新会话状态并保存 match result { Ok(tool_result) => { let mut session = session; @@ -422,4 +454,82 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { .await .map_err(TaskError::from) } -} \ No newline at end of file + + fn available_subagent_names(&self) -> Vec { + self.catalog.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.register(SubagentDef::builtin_explore()); + 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('\'', "'") +} diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index 838e13c..eb92345 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -1,3 +1,5 @@ +use std::path::PathBuf; + use serde::{Deserialize, Serialize}; /// 子代理会话状态 @@ -20,34 +22,114 @@ impl Default for TaskSessionState { } } -/// 子代理类型 -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum SubagentType { - /// 通用型 - 处理复杂多步骤任务 - #[default] - General, - /// 探索型 - 只读搜索代理 - Explore, +/// 子代理来源 +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SubagentSource { + /// 内置定义 + Builtin, + /// 用户级自定义 (~/.picobot/subagents/) + User, + /// 项目级自定义 (./.picobot/subagents/) + Project, } -impl SubagentType { - pub fn as_str(&self) -> &'static str { - match self { - Self::General => "general", - Self::Explore => "explore", +/// 子代理完整定义 +#[derive(Debug, Clone)] +pub struct SubagentDef { + /// 名称标识 + pub name: String, + /// 简短描述(用于 agent 选择) + pub description: String, + /// 提示词模板,支持 {{description}} 和 {{prompt}} 插值 + pub prompt_template: String, + /// 可选的详细指令(body 部分) + pub body: Option, + /// 工具白名单(None 表示使用默认) + pub allowed_tools: Option>, + /// 最大执行时间(秒),None 表示使用默认 + pub max_execution_secs: Option, + /// 是否只读代理 + pub read_only: Option, + /// 来源 + pub source: SubagentSource, + /// 文件路径(仅自定义类型) + pub path: Option, +} + +impl SubagentDef { + /// 创建内置 general 子代理定义 + pub fn builtin_general() -> Self { + Self { + name: "general".to_string(), + description: "通用型子代理 - 处理复杂多步骤任务".to_string(), + prompt_template: "你是一个专注的子代理,正在执行一个独立任务。\n\n任务描述: {{description}}\n\n你应该:\n1. 专注于完成任务,不要偏离目标\n2. 使用可用的工具进行必要操作\n3. 完成后给出简洁的总结\n4. 不要尝试创建新的子代理任务\n\n注意: 你没有访问主对话历史的权限,这是一个独立的执行上下文。".to_string(), + body: None, + allowed_tools: None, + max_execution_secs: None, + read_only: Some(false), + source: SubagentSource::Builtin, + path: None, } } - pub fn from_str(s: &str) -> Option { - match s { - "general" => Some(Self::General), - "explore" => Some(Self::Explore), - _ => None, + /// 创建内置 explore 子代理定义 + pub fn builtin_explore() -> Self { + Self { + name: "explore".to_string(), + description: "探索型子代理 - 只读搜索代理".to_string(), + prompt_template: "你是一个只读探索代理,用于代码库探索和信息收集。\n\n任务描述: {{description}}\n\n你应该:\n1. 只使用只读工具进行探索\n2. 专注于理解和收集信息\n3. 不要进行任何写操作\n4. 给出简洁的发现总结\n\n注意: 你是一个只读代理,禁止执行任何修改操作。".to_string(), + body: None, + allowed_tools: None, + max_execution_secs: None, + read_only: Some(true), + source: SubagentSource::Builtin, + path: None, } } } +/// 子代理类型标识(用于 API 参数) +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct SubagentType { + pub name: String, +} + +impl SubagentType { + pub fn new(name: &str) -> Self { + Self { + name: if name.is_empty() { + "general".to_string() + } else { + name.to_string() + }, + } + } + + pub fn as_str(&self) -> &str { + &self.name + } +} + +impl Serialize for SubagentType { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.name) + } +} + +impl<'de> Deserialize<'de> for SubagentType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Self::new(&s)) + } +} + /// 任务会话记录 #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TaskSession { @@ -65,8 +147,8 @@ pub struct TaskSession { pub parent_channel_name: String, /// 任务描述 pub description: String, - /// 子代理类型 - pub subagent_type: SubagentType, + /// 子代理类型名称 + pub subagent_type: String, /// 当前状态 pub state: TaskSessionState, /// 创建时间 @@ -99,7 +181,7 @@ impl TaskSession { parent_chat_id, parent_channel_name, description, - subagent_type, + subagent_type: subagent_type.name.clone(), state: TaskSessionState::Running, created_at: now, updated_at: now, @@ -137,7 +219,7 @@ pub struct TaskToolArgs { pub description: String, /// 详细指令 pub prompt: String, - /// 子代理类型 + /// 子代理类型名称(默认 general) #[serde(default)] pub subagent_type: SubagentType, /// 恢复现有会话的 task_id @@ -191,4 +273,4 @@ fn current_timestamp() -> i64 { .duration_since(std::time::UNIX_EPOCH) .expect("system clock before unix epoch") .as_millis() as i64 -} \ No newline at end of file +}