PicoBot/src/tools/task/runtime.rs
oudecheng bf8c227634 refactor: 迁移 parking_lot 锁并优化阻塞 IO 与内存管理
## 锁迁移:std::sync → parking_lot
消除锁中毒(poison)导致的级联崩溃风险。parking_lot 锁不会中毒,
且性能更优。迁移覆盖全部生产代码:
- experts/mod.rs: 4 RwLock + 13 expect
- skills/mod.rs: 1 RwLock + 11 expect
- tools/registry.rs: 1 RwLock + 2 expect
- gateway/model_selection.rs: 1 RwLock + 2 expect
- tools/task/repository.rs: 1 RwLock + 4 unwrap
- tools/task/runtime.rs: 2 RwLock + 17 expect
- gateway/session.rs + task/runtime.rs: stream_message_id Mutex
- gateway/processor.rs: description_generation_in_flight Mutex
- command/handler.rs + help.rs: metadata Mutex(公开 API)
- mcp/client.rs: stderr_lines Mutex

测试代码中的 std::sync::Mutex(串行化锁 + TestObserver)有意保留,
已通过 unwrap_or_else(|err| err.into_inner()) 做中毒恢复。

## P1: 阻塞 IO 迁移到 spawn_blocking
将 3 处阻塞 async worker 的操作迁移到 blocking 线程池:
- file_read.rs: read_to_string + 行处理 + base64 编码整体包入 spawn_blocking
- agent_loop.rs: 新增 preencode_images_for_request 两阶段预编码
  (顺序分配预算 → 并行 spawn_blocking 编码),build_llm_request 改为 async
- wechat.rs: media_to_send_content 改为 async,std::fs::read 用 spawn_blocking 包裹

## P2: session_history topic_histories 内存上限
新增 MAX_CACHED_TOPICS=32 上限和 evict_inactive_if_needed 方法。
超限时驱逐非活跃 topic(不在 chat_topic_ids、不在 compression_in_flight、
serial_lock 未被持有)。活跃 topic 永不误驱逐。
remove_history 同步清理 topic_serial_locks,防止无限增长。

## P3: 减少 panic 面
agent_loop.rs retry 循环的 response.expect(...) 改为 ok_or_else(...)?
返回 AgentError::Other,逻辑 bug 不再导致整个 agent 崩溃。

## 对抗性审查修复
- preencode_images_for_request: 用 seen HashSet 去重,防止同 path 重复
  编码导致 HashMap entry 覆盖(NoBudget 覆盖 Encoded 等)
- evict_inactive_if_needed: 检查 topic_serial_lock.try_lock(),防止驱逐
  正在 agent 处理中的 topic(original_topic_id 不在 chat_topic_ids 但
  agent 仍持锁)
- remove_history: 清理 topic_serial_locks

## 验证
- cargo check: 通过(仅既有 lifetime 警告)
- cargo test: 559 passed / 3 failed(均为环境/sandbox 权限问题,与本次改动无关)
2026-08-06 08:18:04 +08:00

2844 lines
98 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<String>,
/// 默认最大执行时间(秒)
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<TaskToolResult, TaskError>;
/// 恢复现有任务
async fn resume(
&self,
task_id: &str,
parent_context: &ToolContext,
additional_prompt: String,
) -> Result<TaskToolResult, TaskError>;
/// 发送消息给子代理(支持中断或补充指令)
async fn send_message(&self, task_id: &str, message: String) -> Result<(), TaskError>;
/// 清理过期任务
async fn cleanup_expired(&self) -> Result<usize, TaskError>;
/// 获取可用的子代理类型列表
fn available_subagent_names(&self) -> Vec<String>;
}
/// 静态系统提示词提供者(用于子代理)
pub struct StaticSystemPromptProvider {
prompt: String,
}
impl StaticSystemPromptProvider {
pub fn new(prompt: String) -> Self {
Self { prompt }
}
}
/// 子智能体工具调用实时广播器(不依赖 gateway 层)
struct SubAgentEmitter {
bus: Arc<MessageBus>,
channel_name: String,
chat_id: String,
metadata: HashMap<String, String>,
store: Arc<SessionStore>,
/// 子/孙智能体自身的 task_id用于持久化时作为 scope_key
task_id: String,
stream_message_id: parking_lot::Mutex<Option<String>>,
}
#[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<u64>) {
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<crate::storage::TodoRecord> = 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<String, String> {
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
}
/// 发布子智能体执行完成事件ExecutionCompletedmetadata 含 subagent_task_id。
async fn publish_subagent_completion(bus: &Option<Arc<MessageBus>>, 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");
}
}
}
/// 发布子智能体执行错误事件ErrorNotificationmetadata 含 subagent_task_id。
async fn publish_subagent_error(
bus: &Option<Arc<MessageBus>>,
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<SystemPrompt> {
Some(SystemPrompt {
content: self.prompt.clone(),
context: Some("subagent".to_string()),
})
}
}
/// 默认子代理运行时实现
pub struct DefaultSubAgentRuntime {
config: SubAgentRuntimeConfig,
task_repository: Arc<dyn TaskRepository>,
conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig,
/// Provider/Model 解析器:按子代理 def 中的 provider/model 字段覆盖基础配置
model_resolver: Arc<crate::config::ModelResolver>,
/// 子代理运行时协调层(管理禁用状态)
subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>,
/// 技能运行时(实时计算技能索引,替代冻结快照)
skills: Arc<SkillRuntime>,
}
impl DefaultSubAgentRuntime {
pub fn new(
config: SubAgentRuntimeConfig,
task_repository: Arc<dyn TaskRepository>,
conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig,
model_resolver: Arc<crate::config::ModelResolver>,
subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>,
skills: Arc<SkillRuntime>,
) -> 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<SubagentDef, String> {
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<ToolRegistry> {
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<String>,
) -> Result<AgentLoop, TaskError> {
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<TaskToolResult, TaskError> {
// 构建初始消息
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<TaskToolResult, TaskError> {
// 加载历史 + 新消息
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<TaskToolResult, TaskError> {
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<TaskToolResult, TaskError> {
// 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<TaskToolResult, TaskError> = {
// 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?;
// 发布子智能体 ExecutionCompletedmetadata 注入 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<TaskToolResult, TaskError> {
// 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<TaskToolResult, TaskError> = {
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?;
// 发布子智能体 ExecutionCompletedmetadata 注入 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<usize, TaskError> {
self.task_repository
.cleanup_expired_tasks(self.config.ttl_hours)
.await
.map_err(TaskError::from)
}
fn available_subagent_names(&self) -> Vec<String> {
self.subagent_runtime.available_names()
}
}
/// 子代理定义目录
///
/// 管理所有可用的子代理定义,包括内置和自定义。
/// 支持用户级(~/.picobot/subagents/)和项目级(./.picobot/subagents/)定义,
/// 项目级定义会覆盖同名的用户级定义。
#[derive(Debug, Default)]
pub struct SubagentCatalog {
definitions: std::collections::HashMap<String, SubagentDef>,
}
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<String, SubagentDef> =
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<String> {
self.definitions.keys().cloned().collect()
}
/// 获取所有可用的子代理定义(用于生成索引提示)
pub fn all(&self) -> Vec<&SubagentDef> {
self.definitions.values().collect()
}
/// 生成系统索引提示词(用于注入主 agent
pub fn system_index_prompt(&self) -> Option<String> {
let defs = self.all();
if defs.is_empty() {
return None;
}
let mut prompt = String::from(
"# 子代理系统\n\n\
子代理是专用的执行单元,用于处理特定类型的任务。\n\
创建子代理任务时,可以选择以下类型之一:\n\n\
<available_subagents>\n",
);
for def in defs {
prompt.push_str(&format!(
" <subagent>\n <name>{}</name>\n <description>{}</description>\n </subagent>\n",
xml_escape(&def.name),
xml_escape(&def.description),
));
}
prompt.push_str("</available_subagents>");
Some(prompt)
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
// ========== 子代理运行时协调层(管理禁用状态) ==========
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SubagentScope {
User,
Project,
}
impl SubagentScope {
pub fn parse(value: &str) -> Option<Self> {
match value {
"user" => Some(Self::User),
"project" => Some(Self::Project),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::User => "user",
Self::Project => "project",
}
}
}
/// A subagent entry with its disabled status across scopes.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SubagentWithStatus {
pub name: String,
pub description: String,
pub source: String,
/// Which scopes have this subagent disabled. Empty means enabled.
pub disabled_in_scopes: Vec<String>,
/// 工具与技能加载策略。
#[serde(default)]
pub capability: CapabilityPolicy,
/// 可选的 provider 名(引用 config.json 的 providers 表。None 时继承主智能体。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// 可选的 model 名(引用 config.json 的 models 表。None 时继承主智能体。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// SUBAGENT.md 的 markdown 正文追加到系统提示词末尾。builtin 子代理为 None。
/// 前端编辑模态框需要回显此字段,与专家系统的 body 对齐。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub body: Option<String>,
}
#[derive(Debug, Clone)]
pub struct SubagentAvailabilityChange {
pub name: String,
pub scope: SubagentScope,
pub changed: bool,
pub disabled_in_scopes: Vec<SubagentScope>,
pub available: bool,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
struct SubagentStateFile {
#[serde(default)]
disabled_subagents: Vec<String>,
}
#[derive(Debug, Clone, Default)]
struct SubagentDisableState {
user_disabled: HashSet<String>,
project_disabled: HashSet<String>,
}
impl SubagentDisableState {
fn is_disabled(&self, name: &str) -> bool {
self.user_disabled.contains(name) || self.project_disabled.contains(name)
}
fn disabled_scopes_for(&self, name: &str) -> Vec<SubagentScope> {
let mut scopes = Vec::new();
if self.user_disabled.contains(name) {
scopes.push(SubagentScope::User);
}
if self.project_disabled.contains(name) {
scopes.push(SubagentScope::Project);
}
scopes
}
}
fn user_subagent_state_path() -> Option<PathBuf> {
crate::platform::home_dir().map(|p| p.join(".picobot").join("subagent-state.json"))
}
fn project_subagent_state_path(cwd: &Path) -> PathBuf {
cwd.join(".picobot").join("subagent-state.json")
}
fn subagent_state_path(scope: SubagentScope, cwd: &Path) -> PathBuf {
match scope {
SubagentScope::User => user_subagent_state_path()
.unwrap_or_else(|| cwd.join(".picobot").join("subagent-state.json")),
SubagentScope::Project => project_subagent_state_path(cwd),
}
}
fn load_subagent_disable_state(cwd: &Path) -> SubagentDisableState {
SubagentDisableState {
user_disabled: user_subagent_state_path()
.map(|path| load_disabled_subagent_names(&path))
.unwrap_or_default(),
project_disabled: load_disabled_subagent_names(&project_subagent_state_path(cwd)),
}
}
fn load_disabled_subagent_names(path: &Path) -> HashSet<String> {
match load_subagent_state_file(path) {
Ok(state) => state.disabled_subagents.into_iter().collect(),
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "Failed to load subagent state file");
HashSet::new()
}
}
}
fn load_subagent_state_file(path: &Path) -> Result<SubagentStateFile, String> {
if !path.exists() {
return Ok(SubagentStateFile::default());
}
let content = fs::read_to_string(path)
.map_err(|err| format!("failed to read subagent state file: {}", err))?;
serde_json::from_str(&content)
.map_err(|err| format!("failed to parse subagent state file: {}", err))
}
fn save_subagent_state_file(path: &Path, state: &SubagentStateFile) -> Result<(), String> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|err| format!("failed to create subagent state directory: {}", err))?;
}
let content = serde_json::to_string_pretty(state)
.map_err(|err| format!("failed to render subagent state file: {}", err))?;
let tmp_path = path.with_extension("json.tmp");
fs::write(&tmp_path, format!("{}\n", content))
.map_err(|err| format!("failed to write temporary subagent state file: {}", err))?;
crate::platform::atomic_rename(&tmp_path, path)
.map_err(|err| format!("failed to persist subagent state file: {}", err))?;
Ok(())
}
/// 子代理运行时协调层
///
/// 在 `SubagentCatalog`(纯数据容器)之上管理禁用状态,所有过滤逻辑在此层。
/// 对齐 `SkillRuntime` 模式。
#[derive(Debug)]
pub struct SubagentRuntime {
catalog: RwLock<SubagentCatalog>,
disable_state: RwLock<SubagentDisableState>,
#[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<SubagentWithStatus> {
let state = self
.disable_state
.read()
;
let catalog = self
.catalog
.read()
;
let mut items: Vec<SubagentWithStatus> = 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<String> {
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<SubagentDef> {
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<String> {
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\
<available_subagents>\n",
);
for def in available_defs {
prompt.push_str(&format!(
" <subagent>\n <name>{}</name>\n <description>{}</description>\n </subagent>\n",
xml_escape(&def.name),
xml_escape(&def.description),
));
}
prompt.push_str("</available_subagents>");
Some(prompt)
}
/// 生成按 capability 过滤后的系统索引提示词。
/// 在禁用项过滤之上,再按 `allowed_subagents`(白名单取交集)和
/// `denied_subagents`(黑名单扣除)过滤。用于专家/子代理的子代理策略。
pub fn system_index_prompt_filtered_with_policy(
&self,
allowed: Option<&[String]>,
denied: &[String],
) -> Option<String> {
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\
<available_subagents>\n",
);
for def in available_defs {
prompt.push_str(&format!(
" <subagent>\n <name>{}</name>\n <description>{}</description>\n </subagent>\n",
xml_escape(&def.name),
xml_escape(&def.description),
));
}
prompt.push_str("</available_subagents>");
Some(prompt)
}
/// 禁用子代理
pub fn disable_subagent(
&self,
scope: SubagentScope,
name: &str,
) -> Result<SubagentAvailabilityChange, String> {
self.set_subagent_enabled(scope, name, false)
}
/// 启用子代理
pub fn enable_subagent(
&self,
scope: SubagentScope,
name: &str,
) -> Result<SubagentAvailabilityChange, String> {
self.set_subagent_enabled(scope, name, true)
}
fn set_subagent_enabled(
&self,
scope: SubagentScope,
name: &str,
enabled: bool,
) -> Result<SubagentAvailabilityChange, String> {
// 校验子代理存在
if self
.catalog
.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<String> = state_file.disabled_subagents.into_iter().collect();
let changed = if enabled {
disabled.remove(name)
} else {
disabled.insert(name.to_string())
};
let mut disabled_list: Vec<String> = disabled.into_iter().collect();
disabled_list.sort();
state_file.disabled_subagents = disabled_list;
save_subagent_state_file(&state_path, &state_file)?;
// 更新内存中的 disable_state
{
let mut state = self
.disable_state
.write()
;
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<String>>,
model: Option<&Option<String>>,
reload: bool,
) -> Result<SubagentDef, String> {
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<String>,
model: &Option<String>,
reload: bool,
) -> Result<SubagentDef, String> {
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<PathBuf, String> {
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<PathBuf, String> {
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<SubagentRuntime>,
experts: Arc<ExpertRuntime>,
}
impl SubagentPromptProvider {
pub fn new(runtime: Arc<SubagentRuntime>, experts: Arc<ExpertRuntime>) -> Self {
Self { runtime, experts }
}
}
impl SystemPromptProvider for SubagentPromptProvider {
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
// 读取所选专家的子代理策略;无专家或无策略时走全局索引(主智能体默认)
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<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 => {
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<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,
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<String>,
description: String,
#[serde(default)]
prompt_template: Option<String>,
#[serde(default)]
allowed_skills: Option<Vec<String>>,
#[serde(default)]
denied_skills: Vec<String>,
#[serde(default)]
allowed_tools: Option<Vec<String>>,
#[serde(default)]
denied_tools: Vec<String>,
#[serde(default)]
allowed_subagents: Option<Vec<String>>,
#[serde(default)]
denied_subagents: Vec<String>,
#[serde(default)]
max_execution_secs: Option<u64>,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
model: Option<String>,
}
/// 从根目录加载所有子代理
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.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<SubagentDef, String> {
let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?;
let (frontmatter, body) = match crate::frontmatter::parse::<SubagentFrontmatter>(&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<u64>,
provider: &Option<String>,
model: &Option<String>,
) -> Result<String, String> {
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_skills: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_tools: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_tools: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
allowed_subagents: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
denied_subagents: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
max_execution_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
}
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<u64>,
provider: &Option<String>,
model: &Option<String>,
) -> 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<std::ffi::OsString>,
previous_userprofile: Option<std::ffi::OsString>,
}
impl HomeDirGuard {
fn enter(path: &Path) -> Self {
let home_backup = std::env::var_os("HOME");
let userprofile_backup = std::env::var_os("USERPROFILE");
unsafe {
std::env::set_var("HOME", path);
std::env::set_var("USERPROFILE", path);
}
Self {
previous: home_backup,
previous_userprofile: userprofile_backup,
}
}
}
impl Drop for HomeDirGuard {
fn drop(&mut self) {
unsafe {
match &self.previous {
Some(value) => std::env::set_var("HOME", value),
None => std::env::remove_var("HOME"),
}
match &self.previous_userprofile {
Some(value) => std::env::set_var("USERPROFILE", value),
None => std::env::remove_var("USERPROFILE"),
}
}
}
}
fn make_runtime(cwd: &Path) -> SubagentRuntime {
let catalog = SubagentCatalog::new();
SubagentRuntime::new(SubagentsConfig::default(), catalog, cwd.to_path_buf())
}
#[test]
fn test_disable_subagent_filters_from_prompt() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
// general 在初始 prompt 中
let prompt = runtime.system_index_prompt_filtered().unwrap();
assert!(prompt.contains("<name>general</name>"));
// 在 project scope 禁用 general
let change = runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
assert!(change.changed);
assert!(!change.available);
// 禁用后 prompt 不应包含 general无可用子代理时返回 None
let prompt = runtime.system_index_prompt_filtered();
assert!(prompt.map_or(true, |p| !p.contains("<name>general</name>")));
}
#[test]
fn test_enable_subagent_restores() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
// 无可用子代理时返回 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("<name>general</name>"));
}
#[test]
fn test_list_with_status_includes_disabled() {
let _lock = acquire_test_lock();
let temp = tempfile::tempdir().unwrap();
let home = tempfile::tempdir().unwrap();
let _home_guard = HomeDirGuard::enter(home.path());
let runtime = make_runtime(temp.path());
runtime
.disable_subagent(SubagentScope::Project, "general")
.unwrap();
let items = runtime.list_with_status();
let general = items.iter().find(|i| i.name == "general").unwrap();
assert!(general.disabled_in_scopes.contains(&"project".to_string()));
}
#[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<ToolResult> {
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<String> {
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(&reg),
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(&reg), 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(&reg), 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(&reg), 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(&reg), 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(&reg), 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(&reg), 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 子代理无 pathupdate 应失败
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() 作为 cwdproject 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"
);
}
}