## 锁迁移: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 权限问题,与本次改动无关)
2844 lines
98 KiB
Rust
2844 lines
98 KiB
Rust
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
|
||
}
|
||
|
||
/// 发布子智能体执行完成事件(ExecutionCompleted),metadata 含 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");
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 发布子智能体执行错误事件(ErrorNotification),metadata 含 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?;
|
||
// 发布子智能体 ExecutionCompleted,metadata 注入 subagent_task_id 供前端路由到对应子智能体层
|
||
publish_subagent_completion(&self.bus, &session).await;
|
||
Ok(tool_result)
|
||
}
|
||
Err(e) => {
|
||
// 会话创建后的任何失败(含 AgentCreationFailed、Timeout、ExecutionFailed)
|
||
// 统一返回结构化结果,携带 task_id 供前端导航
|
||
self.handle_task_failure(session, e).await
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn resume(
|
||
&self,
|
||
task_id: &str,
|
||
parent_context: &ToolContext,
|
||
additional_prompt: String,
|
||
) -> Result<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?;
|
||
// 发布子智能体 ExecutionCompleted,metadata 注入 subagent_task_id 供前端路由到对应子智能体层
|
||
publish_subagent_completion(&self.bus, &session).await;
|
||
Ok(tool_result)
|
||
}
|
||
Err(e) => {
|
||
// 修复:原代码一律 mark_failed,未处理 timeout;现统一走 handle_task_failure
|
||
self.handle_task_failure(session, e).await
|
||
}
|
||
}
|
||
}
|
||
|
||
async fn send_message(&self, _task_id: &str, _message: String) -> Result<(), TaskError> {
|
||
// TODO: 实现双向通信
|
||
// 需要在 TaskSession 中添加 pending_messages 队列
|
||
Err(TaskError::InvalidArguments(
|
||
"send_message not implemented yet".to_string(),
|
||
))
|
||
}
|
||
|
||
async fn cleanup_expired(&self) -> Result<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('&', "&")
|
||
.replace('<', "<")
|
||
.replace('>', ">")
|
||
.replace('"', """)
|
||
.replace('\'', "'")
|
||
}
|
||
|
||
// ========== 子代理运行时协调层(管理禁用状态) ==========
|
||
|
||
#[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(®),
|
||
vec!["bash", "edit", "read", "task", "write"]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_depth_deny_task_removes_task() {
|
||
let base = base_registry();
|
||
let p = policy(None, &[]);
|
||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true);
|
||
assert_eq!(sorted_names(®), vec!["bash", "edit", "read", "write"]);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_whitelist_keeps_only_listed() {
|
||
let base = base_registry();
|
||
let p = policy(Some(&["read", "bash"]), &[]);
|
||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
|
||
assert_eq!(sorted_names(®), vec!["bash", "read"]);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_whitelist_skips_missing_names() {
|
||
let base = base_registry();
|
||
// 包含未注册的工具名应被静默跳过
|
||
let p = policy(Some(&["read", "nonexistent", "glob"]), &[]);
|
||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
|
||
assert_eq!(sorted_names(®), vec!["read"]);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_blacklist_removes_listed() {
|
||
let base = base_registry();
|
||
let p = policy(None, &["bash", "task"]);
|
||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
|
||
assert_eq!(sorted_names(®), vec!["edit", "read", "write"]);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_whitelist_then_blacklist() {
|
||
let base = base_registry();
|
||
let p = policy(Some(&["read", "bash"]), &["bash"]);
|
||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
|
||
// 白名单留下 read+bash,黑名单再扣除 bash
|
||
assert_eq!(sorted_names(®), vec!["read"]);
|
||
}
|
||
|
||
#[test]
|
||
fn filter_empty_whitelist_yields_empty() {
|
||
let base = base_registry();
|
||
let p = policy(Some(&[]), &[]);
|
||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, false);
|
||
assert!(reg.tool_names().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn filter_depth_rule_overrides_whitelist_task() {
|
||
let base = base_registry();
|
||
// 白名单显式包含 task,但 depth≥2 安全兜底仍应移除它
|
||
let p = policy(Some(&["read", "task"]), &[]);
|
||
let reg = DefaultSubAgentRuntime::filter_tool_registry(&base, &p, true);
|
||
assert_eq!(sorted_names(®), vec!["read"]);
|
||
}
|
||
|
||
// ===== frontmatter 解析(capability)测试 =====
|
||
|
||
#[test]
|
||
fn parse_subagent_file_handles_crlf_endings() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let path = temp.path().join("SUBAGENT.md");
|
||
std::fs::write(
|
||
&path,
|
||
"---\r\nname: demo\r\ndescription: demo subagent\r\n---\r\nStep A\r\nStep B",
|
||
)
|
||
.unwrap();
|
||
|
||
let subagent = parse_subagent_file(&path, SubagentSource::Project).unwrap();
|
||
assert_eq!(subagent.name, "demo");
|
||
assert_eq!(subagent.description, "demo subagent");
|
||
assert_eq!(subagent.body.as_deref(), Some("Step A\nStep B"));
|
||
}
|
||
|
||
#[test]
|
||
fn parse_subagent_file_reads_capability() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let path = temp.path().join("SUBAGENT.md");
|
||
std::fs::write(
|
||
&path,
|
||
"---\n\
|
||
name: sandbox\n\
|
||
description: sandbox agent\n\
|
||
allowed_skills: [skill_a, skill_b]\n\
|
||
denied_skills: [skill_c]\n\
|
||
allowed_tools: [read, todo_write]\n\
|
||
denied_tools: [bash, task]\n\
|
||
---\n\
|
||
body instructions",
|
||
)
|
||
.unwrap();
|
||
|
||
let def = parse_subagent_file(&path, SubagentSource::Project).unwrap();
|
||
assert_eq!(def.name, "sandbox");
|
||
assert_eq!(
|
||
def.capability.allowed_skills.as_deref(),
|
||
Some(["skill_a".to_string(), "skill_b".to_string()].as_slice())
|
||
);
|
||
assert_eq!(def.capability.denied_skills, vec!["skill_c".to_string()]);
|
||
assert_eq!(
|
||
def.capability.allowed_tools.as_deref(),
|
||
Some(["read".to_string(), "todo_write".to_string()].as_slice())
|
||
);
|
||
assert_eq!(
|
||
def.capability.denied_tools,
|
||
vec!["bash".to_string(), "task".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn parse_subagent_file_capability_default_empty_when_absent() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let path = temp.path().join("SUBAGENT.md");
|
||
std::fs::write(
|
||
&path,
|
||
"---\nname: basic\ndescription: basic agent\n---\nbody",
|
||
)
|
||
.unwrap();
|
||
|
||
let def = parse_subagent_file(&path, SubagentSource::User).unwrap();
|
||
assert!(def.capability.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn list_with_status_projects_capability() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let mut catalog = SubagentCatalog::new();
|
||
catalog.register(SubagentDef {
|
||
name: "sandbox".to_string(),
|
||
description: "sandbox agent".to_string(),
|
||
prompt_template: String::new(),
|
||
body: None,
|
||
capability: CapabilityPolicy {
|
||
allowed_skills: None,
|
||
denied_skills: Vec::new(),
|
||
allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]),
|
||
denied_tools: vec!["bash".to_string()],
|
||
allowed_subagents: None,
|
||
denied_subagents: vec![],
|
||
},
|
||
max_execution_secs: None,
|
||
source: SubagentSource::Builtin,
|
||
path: None,
|
||
provider: None,
|
||
model: None,
|
||
});
|
||
let runtime = SubagentRuntime::new(
|
||
SubagentsConfig::default(),
|
||
catalog,
|
||
temp.path().to_path_buf(),
|
||
);
|
||
|
||
let items = runtime.list_with_status();
|
||
let item = items.iter().find(|i| i.name == "sandbox").unwrap();
|
||
assert_eq!(
|
||
item.capability.allowed_tools.as_deref(),
|
||
Some(["read".to_string(), "todo_write".to_string()].as_slice())
|
||
);
|
||
assert_eq!(item.capability.denied_tools, vec!["bash".to_string()]);
|
||
}
|
||
|
||
// ===== render/write/update_subagent 测试 =====
|
||
|
||
#[test]
|
||
fn render_subagent_file_roundtrip() {
|
||
let cap = CapabilityPolicy {
|
||
allowed_skills: Some(vec!["skill_a".to_string()]),
|
||
denied_skills: vec!["skill_b".to_string()],
|
||
allowed_tools: Some(vec!["read".to_string()]),
|
||
denied_tools: vec!["bash".to_string()],
|
||
allowed_subagents: None,
|
||
denied_subagents: vec![],
|
||
};
|
||
let content = render_subagent_file(
|
||
"demo",
|
||
"demo agent",
|
||
"template content",
|
||
"body instructions",
|
||
&cap,
|
||
Some(1800),
|
||
&None,
|
||
&None,
|
||
)
|
||
.unwrap();
|
||
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let path = temp.path().join("SUBAGENT.md");
|
||
std::fs::write(&path, &content).unwrap();
|
||
|
||
let def = parse_subagent_file(&path, SubagentSource::Project).unwrap();
|
||
assert_eq!(def.name, "demo");
|
||
assert_eq!(def.description, "demo agent");
|
||
assert_eq!(def.prompt_template, "template content");
|
||
assert_eq!(def.body.as_deref(), Some("body instructions"));
|
||
assert_eq!(def.max_execution_secs, Some(1800));
|
||
assert_eq!(
|
||
def.capability.allowed_skills.as_deref(),
|
||
Some(["skill_a".to_string()].as_slice())
|
||
);
|
||
assert_eq!(def.capability.denied_skills, vec!["skill_b".to_string()]);
|
||
assert_eq!(
|
||
def.capability.allowed_tools.as_deref(),
|
||
Some(["read".to_string()].as_slice())
|
||
);
|
||
assert_eq!(def.capability.denied_tools, vec!["bash".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn render_subagent_file_omits_empty_capability() {
|
||
let cap = CapabilityPolicy::default();
|
||
let content =
|
||
render_subagent_file("basic", "basic agent", "", "body", &cap, None, &None, &None)
|
||
.unwrap();
|
||
// 空 capability 字段不应出现在 YAML 中
|
||
assert!(!content.contains("allowed_skills"));
|
||
assert!(!content.contains("denied_skills"));
|
||
assert!(!content.contains("allowed_tools"));
|
||
assert!(!content.contains("denied_tools"));
|
||
assert!(!content.contains("max_execution_secs"));
|
||
assert!(!content.contains("prompt_template"));
|
||
}
|
||
|
||
#[test]
|
||
fn update_subagent_writes_capability() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let path = temp.path().join("demo").join("SUBAGENT.md");
|
||
// 先写一个初始 SUBAGENT.md
|
||
write_subagent_file(
|
||
&path,
|
||
"demo",
|
||
"initial desc",
|
||
"",
|
||
"initial body",
|
||
&CapabilityPolicy::default(),
|
||
None,
|
||
&None,
|
||
&None,
|
||
)
|
||
.unwrap();
|
||
|
||
// 用 SubagentRuntime 加载并 update
|
||
let config = SubagentsConfig {
|
||
enabled: true,
|
||
sources: vec![temp.path().to_string_lossy().to_string()],
|
||
};
|
||
let runtime = SubagentRuntime::from_config(config);
|
||
|
||
let new_cap = CapabilityPolicy {
|
||
allowed_skills: None,
|
||
denied_skills: vec!["skill_x".to_string()],
|
||
allowed_tools: Some(vec!["read".to_string()]),
|
||
denied_tools: vec!["bash".to_string()],
|
||
allowed_subagents: None,
|
||
denied_subagents: vec![],
|
||
};
|
||
let updated = runtime
|
||
.update_subagent(
|
||
"demo",
|
||
Some("updated desc"),
|
||
None,
|
||
Some(&new_cap),
|
||
Some(&None),
|
||
Some(&None),
|
||
false,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(updated.description, "updated desc");
|
||
assert_eq!(
|
||
updated.capability.denied_skills,
|
||
vec!["skill_x".to_string()]
|
||
);
|
||
assert_eq!(
|
||
updated.capability.allowed_tools.as_deref(),
|
||
Some(["read".to_string()].as_slice())
|
||
);
|
||
|
||
// 重新从文件 parse 验证写回成功
|
||
let reparsed = parse_subagent_file(&path, SubagentSource::Project).unwrap();
|
||
assert_eq!(reparsed.description, "updated desc");
|
||
assert_eq!(
|
||
reparsed.capability.denied_skills,
|
||
vec!["skill_x".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn update_subagent_rejects_builtin() {
|
||
let runtime = SubagentRuntime::from_config(SubagentsConfig::default());
|
||
// builtin general 子代理无 path,update 应失败
|
||
let result = runtime.update_subagent(
|
||
"general",
|
||
Some("new desc"),
|
||
None,
|
||
None,
|
||
Some(&None),
|
||
Some(&None),
|
||
false,
|
||
);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err();
|
||
assert!(err.contains("builtin") || err.contains("not found"));
|
||
}
|
||
|
||
// ===== create_subagent / delete_subagent 测试 =====
|
||
//
|
||
// create_subagent 写入到 project scope 的固定路径 {cwd}/.picobot/subagents/{name}/SUBAGENT.md,
|
||
// 与 expert create 一致。测试中以 temp.path() 作为 cwd,project root 即 temp/.picobot/subagents/。
|
||
|
||
fn make_runtime_with_cwd(cwd: &Path) -> SubagentRuntime {
|
||
let config = SubagentsConfig {
|
||
enabled: true,
|
||
sources: vec!["project".to_string()],
|
||
};
|
||
let catalog = SubagentCatalog::discover_with_cwd(&config, cwd);
|
||
SubagentRuntime::new(config, catalog, cwd.to_path_buf())
|
||
}
|
||
|
||
#[test]
|
||
fn create_subagent_writes_file_and_appears_in_list() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let runtime = make_runtime_with_cwd(temp.path());
|
||
|
||
let cap = CapabilityPolicy {
|
||
allowed_skills: None,
|
||
denied_skills: vec!["skill_x".to_string()],
|
||
allowed_tools: Some(vec!["read".to_string()]),
|
||
denied_tools: vec![],
|
||
allowed_subagents: None,
|
||
denied_subagents: vec![],
|
||
};
|
||
let created = runtime
|
||
.create_subagent(
|
||
SubagentScope::Project,
|
||
"demo-create",
|
||
"demo create agent",
|
||
"demo body content",
|
||
&cap,
|
||
&None,
|
||
&None,
|
||
true,
|
||
)
|
||
.unwrap();
|
||
assert_eq!(created.name, "demo-create");
|
||
assert_eq!(created.description, "demo create agent");
|
||
assert_eq!(created.body.as_deref(), Some("demo body content"));
|
||
assert_eq!(created.source, SubagentSource::Project);
|
||
|
||
// 文件确实创建在 project root 下
|
||
let file_path = temp
|
||
.path()
|
||
.join(".picobot")
|
||
.join("subagents")
|
||
.join("demo-create")
|
||
.join("SUBAGENT.md");
|
||
assert!(file_path.exists(), "SUBAGENT.md should be created");
|
||
|
||
// list_with_status 能看到新子代理
|
||
let items = runtime.list_with_status();
|
||
let item = items.iter().find(|i| i.name == "demo-create").unwrap();
|
||
assert_eq!(item.description, "demo create agent");
|
||
assert_eq!(item.body.as_deref(), Some("demo body content"));
|
||
assert_eq!(
|
||
item.capability.denied_skills,
|
||
vec!["skill_x".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn create_subagent_rejects_duplicate() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let runtime = make_runtime_with_cwd(temp.path());
|
||
|
||
runtime
|
||
.create_subagent(
|
||
SubagentScope::Project,
|
||
"dup",
|
||
"first",
|
||
"",
|
||
&CapabilityPolicy::default(),
|
||
&None,
|
||
&None,
|
||
true,
|
||
)
|
||
.unwrap();
|
||
|
||
// 同名再次创建应失败
|
||
let result = runtime.create_subagent(
|
||
SubagentScope::Project,
|
||
"dup",
|
||
"second",
|
||
"",
|
||
&CapabilityPolicy::default(),
|
||
&None,
|
||
&None,
|
||
true,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().contains("already exists"));
|
||
}
|
||
|
||
#[test]
|
||
fn create_subagent_rejects_builtin_name() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let runtime = make_runtime_with_cwd(temp.path());
|
||
|
||
// builtin general 已存在,应拒绝
|
||
let result = runtime.create_subagent(
|
||
SubagentScope::Project,
|
||
"general",
|
||
"hijack",
|
||
"",
|
||
&CapabilityPolicy::default(),
|
||
&None,
|
||
&None,
|
||
true,
|
||
);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().contains("already exists"));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_subagent_removes_file_and_directory() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let runtime = make_runtime_with_cwd(temp.path());
|
||
|
||
runtime
|
||
.create_subagent(
|
||
SubagentScope::Project,
|
||
"doomed",
|
||
"to be deleted",
|
||
"",
|
||
&CapabilityPolicy::default(),
|
||
&None,
|
||
&None,
|
||
true,
|
||
)
|
||
.unwrap();
|
||
|
||
let dir = temp
|
||
.path()
|
||
.join(".picobot")
|
||
.join("subagents")
|
||
.join("doomed");
|
||
let file_path = dir.join("SUBAGENT.md");
|
||
assert!(file_path.exists());
|
||
|
||
let deleted_dir = runtime.delete_subagent("doomed", true).unwrap();
|
||
assert_eq!(deleted_dir, dir);
|
||
assert!(!dir.exists(), "directory should be removed");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_subagent_rejects_builtin() {
|
||
let runtime = SubagentRuntime::from_config(SubagentsConfig::default());
|
||
let result = runtime.delete_subagent("general", false);
|
||
assert!(result.is_err());
|
||
let err = result.unwrap_err();
|
||
assert!(err.contains("builtin") || err.contains("not found"));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_subagent_rejects_nonexistent() {
|
||
let runtime = SubagentRuntime::from_config(SubagentsConfig::default());
|
||
let result = runtime.delete_subagent("never-existed", false);
|
||
assert!(result.is_err());
|
||
assert!(result.unwrap_err().contains("not found"));
|
||
}
|
||
|
||
#[test]
|
||
fn delete_subagent_preserves_other_files_in_directory() {
|
||
let temp = tempfile::tempdir().unwrap();
|
||
let runtime = make_runtime_with_cwd(temp.path());
|
||
|
||
runtime
|
||
.create_subagent(
|
||
SubagentScope::Project,
|
||
"mixed",
|
||
"has extra files",
|
||
"",
|
||
&CapabilityPolicy::default(),
|
||
&None,
|
||
&None,
|
||
true,
|
||
)
|
||
.unwrap();
|
||
|
||
// 在子代理目录内放一个用户文件
|
||
let extra_file = temp
|
||
.path()
|
||
.join(".picobot")
|
||
.join("subagents")
|
||
.join("mixed")
|
||
.join("notes.txt");
|
||
std::fs::write(&extra_file, "user notes").unwrap();
|
||
|
||
// 删除子代理:应只删 SUBAGENT.md,保留 notes.txt 和目录
|
||
runtime.delete_subagent("mixed", true).unwrap();
|
||
assert!(extra_file.exists(), "user file should be preserved");
|
||
assert!(
|
||
temp.path()
|
||
.join(".picobot")
|
||
.join("subagents")
|
||
.join("mixed")
|
||
.exists(),
|
||
"directory should be preserved when it has other files"
|
||
);
|
||
assert!(
|
||
!temp.path()
|
||
.join(".picobot")
|
||
.join("subagents")
|
||
.join("mixed")
|
||
.join("SUBAGENT.md")
|
||
.exists(),
|
||
"SUBAGENT.md should be removed"
|
||
);
|
||
}
|
||
}
|