From c2d9bf8b5ef3a266b2554b9369e1655d37785a1c Mon Sep 17 00:00:00 2001 From: oudecheng <13802883547@139.com> Date: Mon, 6 Jul 2026 18:07:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E4=B8=93=E5=AE=B6?= =?UTF-8?q?=E6=8F=90=E7=A4=BA=E8=AF=8D=E6=A0=B8=E5=BF=83=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=EF=BC=8C=E6=94=AF=E6=8C=81=E6=96=87=E4=BB=B6=E5=8F=91=E7=8E=B0?= =?UTF-8?q?=E3=80=81CRUD=E4=B8=8E=E8=BF=90=E8=A1=8C=E6=97=B6=E9=80=89?= =?UTF-8?q?=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 src/experts/mod.rs 模块,镜像 skills/subagents 模式 - 支持 user/project 双 scope 从 EXPERT.md 文件发现专家 - ExpertRuntime 维护 session→expert 映射与禁用列表,持久化到 expert-state.json - ExpertPromptProvider 实现系统提示词注入,未选中时返回 None - 包含 16 个单元测试覆盖 frontmatter 解析、discover 优先级、CRUD、select/clear、disable 场景 --- src/command/adapters/websocket.rs | 2 + src/experts/mod.rs | 1484 +++++++++++++++++++++++++++++ src/gateway/ws.rs | 10 + src/lib.rs | 1 + src/protocol/mod.rs | 4 + src/protocol/ws_adapter.rs | 2 + 6 files changed, 1503 insertions(+) create mode 100644 src/experts/mod.rs diff --git a/src/command/adapters/websocket.rs b/src/command/adapters/websocket.rs index 646a76b..066d6e9 100644 --- a/src/command/adapters/websocket.rs +++ b/src/command/adapters/websocket.rs @@ -67,6 +67,7 @@ impl OutputAdapter for WebSocketOutputAdapter { code: error.code, message: error.message, timestamp: Some(crate::protocol::now_timestamp()), + subagent_task_id: None, }); return outbounds; } @@ -197,6 +198,7 @@ impl OutputAdapter for WebSocketOutputAdapter { code: "RESPONSE_ERROR".to_string(), message: msg.content.clone(), timestamp: Some(crate::protocol::now_timestamp()), + subagent_task_id: None, }, _ => WsOutbound::AssistantResponse { id: response.request_id.to_string(), diff --git a/src/experts/mod.rs b/src/experts/mod.rs new file mode 100644 index 0000000..1615de5 --- /dev/null +++ b/src/experts/mod.rs @@ -0,0 +1,1484 @@ +use crate::config::ExpertsConfig; +use crate::platform::{atomic_rename, home_dir as platform_home_dir}; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; + +#[cfg(test)] +static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(test)] +pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> { + EXPERT_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) +} + +/// A discovered expert definition. +#[derive(Debug, Clone)] +pub struct Expert { + pub name: String, + pub description: String, + pub body: String, + pub source: ExpertSource, + pub path: PathBuf, +} + +/// Where an expert definition was discovered from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExpertSource { + User, + Project, + Custom(String), +} + +impl ExpertSource { + pub fn as_str(&self) -> &str { + match self { + ExpertSource::User => "user", + ExpertSource::Project => "project", + ExpertSource::Custom(path) => path.as_str(), + } + } +} + +/// The scope an expert is addressed by for enable/disable and CRUD operations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ExpertScope { + User, + Project, +} + +impl ExpertScope { + pub fn parse(value: &str) -> Option { + 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", + } + } +} + +impl From for ExpertSource { + fn from(value: ExpertScope) -> Self { + match value { + ExpertScope::User => ExpertSource::User, + ExpertScope::Project => ExpertSource::Project, + } + } +} + +/// An expert entry with its disabled status across scopes (for the settings page). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ExpertWithStatus { + pub name: String, + pub description: String, + pub source: String, + pub path: String, + /// Which scopes have this expert disabled. Empty means enabled. + pub disabled_in_scopes: Vec, +} + +/// Result of an enable/disable operation. +#[derive(Debug, Clone)] +pub struct ExpertAvailabilityChange { + pub name: String, + pub scope: ExpertScope, + pub changed: bool, + pub disabled_in_scopes: Vec, + pub available: bool, +} + +/// The persisted expert state file (`expert-state.json`). +/// +/// Holds both the per-session selected expert and the disabled experts list. +/// There is one file per scope (user/project). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExpertStateFile { + #[serde(default)] + pub session_experts: HashMap, + #[serde(default)] + pub disabled_experts: Vec, +} + +/// Merged disable state across user + project scopes. +#[derive(Debug, Clone, Default)] +struct ExpertDisableState { + user_disabled: HashSet, + project_disabled: HashSet, +} + +impl ExpertDisableState { + 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 { + let mut scopes = Vec::new(); + if self.user_disabled.contains(name) { + scopes.push(ExpertScope::User); + } + if self.project_disabled.contains(name) { + scopes.push(ExpertScope::Project); + } + scopes + } +} + +/// In-memory catalog of discovered experts. +#[derive(Debug, Clone, Default)] +pub struct ExpertCatalog { + experts: Vec, +} + +impl ExpertCatalog { + pub fn discover(config: &ExpertsConfig) -> Self { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let disable_state = load_expert_disable_state(&cwd); + Self::discover_with_state(config, &cwd, Some(&disable_state)) + } + + fn discover_without_state(config: &ExpertsConfig, cwd: &Path) -> Self { + Self::discover_with_state(config, cwd, None) + } + + fn discover_with_state( + config: &ExpertsConfig, + cwd: &Path, + disable_state: Option<&ExpertDisableState>, + ) -> Self { + if !config.enabled { + return Self::default(); + } + + let mut merged: HashMap = HashMap::new(); + let mut sources_seen = 0usize; + + // Load from least specific to most specific so later sources win on conflicts. + for source in source_order(&config.sources) { + sources_seen += 1; + let root = source_root(&source, cwd); + let Some(root) = root else { continue }; + for expert in load_experts_from_root(&root, source.clone()) { + if let Some(existing) = merged.get(&expert.name) { + tracing::warn!( + expert = %expert.name, + old_source = %existing.source.as_str(), + new_source = %expert.source.as_str(), + "Duplicate expert name found; overriding with later source" + ); + } + merged.insert(expert.name.clone(), expert); + } + } + + let mut experts: Vec = merged.into_values().collect(); + if let Some(disable_state) = disable_state { + experts.retain(|expert| !disable_state.is_disabled(&expert.name)); + } + experts.sort_by(|a, b| a.name.cmp(&b.name)); + + tracing::info!( + sources_seen, + discovered = experts.len(), + "Experts discovery completed" + ); + + Self { experts } + } + + pub fn is_empty(&self) -> bool { + self.experts.is_empty() + } + + pub fn len(&self) -> usize { + self.experts.len() + } + + pub fn find_expert(&self, name: &str) -> Option<&Expert> { + self.experts.iter().find(|e| e.name == name) + } + + pub fn all(&self) -> Vec { + self.experts.clone() + } +} + +/// Expert runtime: manages discovery, disable state, and per-session selection. +/// +/// Mirrors `SkillRuntime` (discovery + CRUD) and `SubagentRuntime` (disable state) +/// patterns, and adds per-session expert selection persisted to `expert-state.json`. +#[derive(Debug)] +pub struct ExpertRuntime { + config: ExpertsConfig, + catalog: RwLock, + disable_state: RwLock, + /// session_id -> selected expert name + session_experts: RwLock>, + cwd: PathBuf, +} + +impl Default for ExpertRuntime { + fn default() -> Self { + Self { + config: ExpertsConfig::default(), + catalog: RwLock::new(ExpertCatalog::default()), + disable_state: RwLock::new(ExpertDisableState::default()), + session_experts: RwLock::new(HashMap::new()), + cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + } + } +} + +impl ExpertRuntime { + pub fn from_config(config: ExpertsConfig) -> Self { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + Self::from_config_with_cwd(config, cwd) + } + + fn from_config_with_cwd(config: ExpertsConfig, cwd: PathBuf) -> Self { + let catalog = ExpertCatalog::discover_with_state( + &config, + &cwd, + Some(&load_expert_disable_state(&cwd)), + ); + let disable_state = load_expert_disable_state(&cwd); + // session selections are persisted in the project-scope state file + let session_experts = load_project_session_experts(&cwd); + + Self { + config, + catalog: RwLock::new(catalog), + disable_state: RwLock::new(disable_state), + session_experts: RwLock::new(session_experts), + cwd, + } + } + + /// Re-discover experts from the filesystem. + pub fn reload(&self) -> Result { + let catalog = ExpertCatalog::discover_with_state( + &self.config, + &self.cwd, + Some(&load_expert_disable_state(&self.cwd)), + ); + let mut guard = self.catalog.write().expect("experts catalog rwlock poisoned"); + *guard = catalog.clone(); + Ok(catalog) + } + + /// List enabled experts (disabled ones are filtered out). + pub fn list_experts(&self) -> Vec { + self.catalog + .read() + .expect("experts catalog rwlock poisoned") + .experts + .clone() + } + + /// List all discovered experts including disabled ones, with their disabled scopes. + pub fn list_experts_with_status(&self) -> Vec { + let catalog = ExpertCatalog::discover_without_state(&self.config, &self.cwd); + let disable_state = load_expert_disable_state(&self.cwd); + + let mut items: Vec = catalog + .experts + .iter() + .map(|expert| { + let scopes = disable_state.disabled_scopes_for(&expert.name); + ExpertWithStatus { + name: expert.name.clone(), + description: expert.description.clone(), + source: expert.source.as_str().to_string(), + path: expert.path.display().to_string(), + disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), + } + }) + .collect(); + items.sort_by(|a, b| a.name.cmp(&b.name)); + items + } + + pub fn get_expert(&self, name: &str) -> Option { + self.catalog + .read() + .expect("experts catalog rwlock poisoned") + .find_expert(name) + .cloned() + } + + pub fn create_expert( + &self, + scope: ExpertScope, + name: &str, + description: &str, + body: &str, + reload: bool, + ) -> Result { + validate_expert_name(name)?; + let path = expert_file_path(scope, name, &self.cwd)?; + if path.exists() { + return Err(format!( + "expert '{}' already exists at {}", + name, + path.display() + )); + } + + write_expert_file(&path, name, description, body)?; + let expert = parse_expert_file(&path, scope.into())?; + if reload { + let _ = self.reload()?; + } + Ok(expert) + } + + pub fn update_expert( + &self, + scope: ExpertScope, + name: &str, + description: Option<&str>, + body: Option<&str>, + reload: bool, + ) -> Result { + validate_expert_name(name)?; + let path = expert_file_path(scope, name, &self.cwd)?; + if !path.exists() { + return Err(format!("expert '{}' not found at {}", name, path.display())); + } + + let existing = parse_expert_file(&path, scope.into())?; + let next_description = description.unwrap_or(&existing.description); + let next_body = body.unwrap_or(&existing.body); + + write_expert_file(&path, name, next_description, next_body)?; + let expert = parse_expert_file(&path, scope.into())?; + if reload { + let _ = self.reload()?; + } + Ok(expert) + } + + pub fn delete_expert( + &self, + scope: ExpertScope, + name: &str, + reload: bool, + ) -> Result { + validate_expert_name(name)?; + let dir = expert_dir_path(scope, name, &self.cwd)?; + if !dir.exists() { + return Err(format!("expert '{}' not found at {}", name, dir.display())); + } + + fs::remove_dir_all(&dir) + .map_err(|err| format!("failed to delete expert directory: {}", err))?; + if reload { + let _ = self.reload()?; + } + Ok(dir) + } + + pub fn disable_expert( + &self, + scope: ExpertScope, + name: &str, + ) -> Result { + self.set_expert_enabled(scope, name, false) + } + + pub fn enable_expert( + &self, + scope: ExpertScope, + name: &str, + ) -> Result { + self.set_expert_enabled(scope, name, true) + } + + pub fn has_expert_definition(&self, name: &str) -> Result { + validate_expert_name(name)?; + let catalog = ExpertCatalog::discover_without_state(&self.config, &self.cwd); + Ok(catalog.find_expert(name).is_some()) + } + + fn set_expert_enabled( + &self, + scope: ExpertScope, + name: &str, + enabled: bool, + ) -> Result { + validate_expert_name(name)?; + if !self.has_expert_definition(name)? { + return Err(format!("expert '{}' not found", name)); + } + + let state_path = expert_state_path(scope, &self.cwd); + let mut state_file = load_expert_state_file(&state_path)?; + let mut disabled: HashSet = state_file.disabled_experts.into_iter().collect(); + let changed = if enabled { + disabled.remove(name) + } else { + disabled.insert(name.to_string()) + }; + + let mut disabled_list: Vec = disabled.into_iter().collect(); + disabled_list.sort(); + state_file.disabled_experts = disabled_list; + save_expert_state_file(&state_path, &state_file)?; + + // update in-memory disable_state + { + let mut state = self + .disable_state + .write() + .expect("experts disable_state rwlock poisoned"); + match scope { + ExpertScope::User => { + if enabled { + state.user_disabled.remove(name); + } else { + state.user_disabled.insert(name.to_string()); + } + } + ExpertScope::Project => { + if enabled { + state.project_disabled.remove(name); + } else { + state.project_disabled.insert(name.to_string()); + } + } + } + } + + // refresh catalog so list_experts / get_expert reflect the change + let _ = self.reload()?; + + let state = self + .disable_state + .read() + .expect("experts disable_state rwlock poisoned"); + let disabled_in_scopes = state.disabled_scopes_for(name); + + Ok(ExpertAvailabilityChange { + name: name.to_string(), + scope, + changed, + available: disabled_in_scopes.is_empty(), + disabled_in_scopes, + }) + } + + /// Select an expert for a session. Persists to the project-scope state file. + pub fn select_expert(&self, session_id: &str, expert_name: &str) -> Result<(), String> { + if session_id.trim().is_empty() { + return Err("session_id cannot be empty".to_string()); + } + // The expert must exist (and not be disabled) for selection to be meaningful. + if self.get_expert(expert_name).is_none() { + return Err(format!( + "expert '{}' not found or disabled", + expert_name + )); + } + + { + let mut sessions = self + .session_experts + .write() + .expect("experts session_experts rwlock poisoned"); + sessions.insert(session_id.to_string(), expert_name.to_string()); + } + persist_session_experts(&self.cwd, |state| { + state + .session_experts + .insert(session_id.to_string(), expert_name.to_string()); + }) + } + + /// Clear the selected expert for a session. + pub fn clear_expert(&self, session_id: &str) -> Result<(), String> { + { + let mut sessions = self + .session_experts + .write() + .expect("experts session_experts rwlock poisoned"); + sessions.remove(session_id); + } + persist_session_experts(&self.cwd, |state| { + state.session_experts.remove(session_id); + }) + } + + /// Returns the expert selected for a session, or None if none selected / disabled / not found. + pub fn selected_expert_for(&self, session_id: &str) -> Option { + let name = { + let sessions = self + .session_experts + .read() + .expect("experts session_experts rwlock poisoned"); + sessions.get(session_id).cloned() + }?; + + // Filter out disabled experts. + let state = self + .disable_state + .read() + .expect("experts disable_state rwlock poisoned"); + if state.is_disabled(&name) { + return None; + } + + self.get_expert(&name) + } + + /// Returns just the name of the selected expert (for API responses). + pub fn selected_expert_name_for(&self, session_id: &str) -> Option { + self.selected_expert_for(session_id).map(|e| e.name) + } +} + +// ========== ExpertPromptProvider ========== + +use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; + +/// Injects the currently-selected expert's body as a system prompt fragment. +/// +/// Looks up the selection via the shared `ExpertRuntime` using +/// `context.session_id`. Returns `None` when no expert is selected or the +/// selected expert is disabled/missing. +pub struct ExpertPromptProvider { + runtime: Arc, +} + +impl ExpertPromptProvider { + pub fn new(runtime: Arc) -> Self { + Self { runtime } + } +} + +impl SystemPromptProvider for ExpertPromptProvider { + fn build(&self, context: &SystemPromptContext) -> Option { + let session_id = context.session_id.as_ref()?; + let expert = self.runtime.selected_expert_for(session_id)?; + + let content = if expert.body.trim().is_empty() { + // Empty body is OK; inject a header so the LLM still knows the role. + format!( + "# 专家角色: {}\n\n{}", + expert.name, expert.description + ) + } else { + expert.body.clone() + }; + + Some(SystemPrompt { + content, + context: Some("expert".to_string()), + }) + } +} + +// ========== Path helpers ========== + +fn user_experts_root() -> Option { + platform_home_dir().map(|p| p.join(".picobot").join("experts")) +} + +fn project_experts_root(cwd: &Path) -> PathBuf { + cwd.join(".picobot").join("experts") +} + +fn user_expert_state_path() -> Option { + platform_home_dir().map(|p| p.join(".picobot").join("expert-state.json")) +} + +fn project_expert_state_path(cwd: &Path) -> PathBuf { + cwd.join(".picobot").join("expert-state.json") +} + +fn expert_state_path(scope: ExpertScope, cwd: &Path) -> PathBuf { + match scope { + ExpertScope::User => user_expert_state_path() + .unwrap_or_else(|| cwd.join(".picobot").join("expert-state.json")), + ExpertScope::Project => project_expert_state_path(cwd), + } +} + +fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result { + match scope { + ExpertScope::User => user_experts_root() + .ok_or_else(|| "failed to resolve home directory".to_string()), + ExpertScope::Project => Ok(project_experts_root(cwd)), + } +} + +fn expert_dir_path(scope: ExpertScope, name: &str, cwd: &Path) -> Result { + Ok(root_for_scope(scope, cwd)?.join(name)) +} + +fn expert_file_path(scope: ExpertScope, name: &str, cwd: &Path) -> Result { + Ok(expert_dir_path(scope, name, cwd)?.join("EXPERT.md")) +} + +// ========== Source ordering ========== + +fn source_order(sources: &[String]) -> Vec { + let mut result = Vec::new(); + for source in sources { + match source.as_str() { + "user" => { + if !result.contains(&ExpertSource::User) { + result.push(ExpertSource::User); + } + } + "project" => { + if !result.contains(&ExpertSource::Project) { + result.push(ExpertSource::Project); + } + } + unknown => { + let custom = ExpertSource::Custom(unknown.to_string()); + if !result.contains(&custom) { + result.push(custom); + } + } + } + } + + // Default order: user then project (project overrides user) + if result.is_empty() { + vec![ExpertSource::User, ExpertSource::Project] + } else { + result + } +} + +fn source_root(source: &ExpertSource, cwd: &Path) -> Option { + match source { + ExpertSource::User => user_experts_root(), + ExpertSource::Project => Some(project_experts_root(cwd)), + ExpertSource::Custom(path) => { + let p = PathBuf::from(path); + if p.is_absolute() { + Some(p) + } else { + tracing::warn!(path = %path, "Custom experts source must be an absolute path, skipping"); + None + } + } + } +} + +// ========== Validation ========== + +fn validate_expert_name(name: &str) -> Result<(), String> { + if name.trim().is_empty() { + return Err("expert name cannot be empty".to_string()); + } + if name.contains('/') || name.contains('\\') || name.contains("..") { + return Err("expert name must not contain path separators or '..'".to_string()); + } + Ok(()) +} + +// ========== File I/O ========== + +#[derive(Debug, Deserialize)] +struct ExpertFrontmatter { + description: String, + #[serde(default)] + name: Option, +} + +fn render_expert_file(name: &str, description: &str, body: &str) -> Result { + if description.trim().is_empty() { + return Err("description is required and cannot be empty".to_string()); + } + + #[derive(serde::Serialize)] + struct ExpertFrontmatterOwned { + name: String, + description: String, + } + + let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned { + name: name.to_string(), + description: description.to_string(), + }) + .map_err(|err| format!("failed to render expert 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_expert_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> { + let content = render_expert_file(name, description, body)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create expert directory: {}", err))?; + } + fs::write(path, content).map_err(|err| format!("failed to write expert file: {}", err)) +} + +fn load_experts_from_root(root: &Path, source: ExpertSource) -> Vec { + let mut out = Vec::new(); + if !root.exists() { + return out; + } + + let entries = match fs::read_dir(root) { + Ok(entries) => entries, + Err(err) => { + tracing::warn!(path = %root.display(), error = %err, "Failed to read experts directory"); + return out; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + if !path.is_dir() { + continue; + } + let expert_md = path.join("EXPERT.md"); + if !expert_md.exists() { + continue; + } + + match parse_expert_file(&expert_md, source.clone()) { + Ok(expert) => out.push(expert), + Err(err) => { + tracing::warn!(path = %expert_md.display(), error = %err, "Skipping invalid expert file"); + } + } + } + + out +} + +fn parse_expert_file(path: &Path, source: ExpertSource) -> Result { + let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?; + + let (frontmatter_raw, body) = + split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?; + + let frontmatter: ExpertFrontmatter = serde_yaml::from_str(frontmatter_raw) + .map_err(|e| format!("invalid YAML frontmatter: {}", e))?; + + let description = frontmatter.description.trim(); + if description.is_empty() { + return Err("description is required and cannot be empty".to_string()); + } + + let dir_name = path + .parent() + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| "unknown-expert".to_string()); + + let name = frontmatter.name.unwrap_or(dir_name).trim().to_string(); + + Ok(Expert { + name, + description: description.to_string(), + body: body.trim().to_string(), + source, + path: path.to_path_buf(), + }) +} + +fn split_frontmatter(content: &str) -> Option<(&str, &str)> { + let rest = content.strip_prefix("---\n")?; + let marker = "\n---\n"; + let idx = rest.find(marker)?; + let frontmatter = &rest[..idx]; + let body = &rest[idx + marker.len()..]; + Some((frontmatter, body)) +} + +// ========== State file I/O ========== + +fn load_expert_disable_state(cwd: &Path) -> ExpertDisableState { + ExpertDisableState { + user_disabled: user_expert_state_path() + .map(|path| load_disabled_expert_names(&path)) + .unwrap_or_default(), + project_disabled: load_disabled_expert_names(&project_expert_state_path(cwd)), + } +} + +fn load_disabled_expert_names(path: &Path) -> HashSet { + match load_expert_state_file(path) { + Ok(state) => state + .disabled_experts + .into_iter() + .filter_map(|name| normalize_expert_name(name, path)) + .collect(), + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "Failed to load expert state file"); + HashSet::new() + } + } +} + +fn normalize_expert_name(name: String, path: &Path) -> Option { + let trimmed = name.trim(); + match validate_expert_name(trimmed) { + Ok(()) => Some(trimmed.to_string()), + Err(err) => { + tracing::warn!(path = %path.display(), expert = %name, error = %err, "Ignoring invalid disabled expert entry"); + None + } + } +} + +fn load_expert_state_file(path: &Path) -> Result { + if !path.exists() { + return Ok(ExpertStateFile::default()); + } + + let content = fs::read_to_string(path) + .map_err(|err| format!("failed to read expert state file: {}", err))?; + serde_json::from_str(&content) + .map_err(|err| format!("failed to parse expert state file: {}", err)) +} + +fn save_expert_state_file(path: &Path, state: &ExpertStateFile) -> Result<(), String> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|err| format!("failed to create expert state directory: {}", err))?; + } + + let content = serde_json::to_string_pretty(state) + .map_err(|err| format!("failed to render expert 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 expert state file: {}", err))?; + + atomic_rename(&tmp_path, path) + .map_err(|err| format!("failed to persist expert state file: {}", err)) +} + +/// Load only the session_experts map from the project-scope state file (used at startup). +fn load_project_session_experts(cwd: &Path) -> HashMap { + let path = project_expert_state_path(cwd); + match load_expert_state_file(&path) { + Ok(state) => state.session_experts, + Err(err) => { + tracing::warn!(path = %path.display(), error = %err, "Failed to load project expert state for session_experts"); + HashMap::new() + } + } +} + +/// Persist a mutation to the project-scope state file's session_experts while +/// preserving the existing disabled_experts field. +fn persist_session_experts(cwd: &Path, mutate: F) -> Result<(), String> { + let path = project_expert_state_path(cwd); + let mut state = load_expert_state_file(&path)?; + mutate(&mut state); + save_expert_state_file(&path, &state) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::ffi::OsString; + + struct CurrentDirGuard { + previous: PathBuf, + } + + struct HomeDirGuard { + previous: Option, + previous_userprofile: Option, + } + + impl CurrentDirGuard { + fn enter(path: &Path) -> Self { + let previous = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self { previous } + } + } + + impl Drop for CurrentDirGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.previous); + } + } + + 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 acquire_test_lock() -> std::sync::MutexGuard<'static, ()> { + acquire_expert_test_env_lock() + } + + #[test] + fn test_split_frontmatter() { + let input = "---\ndescription: demo\n---\nhello"; + let (fm, body) = split_frontmatter(input).unwrap(); + assert!(fm.contains("description")); + assert_eq!(body, "hello"); + } + + #[test] + fn test_parse_expert_file_requires_description() { + let dir = tempfile::tempdir().unwrap(); + let expert_dir = dir.path().join("demo"); + fs::create_dir_all(&expert_dir).unwrap(); + let expert_md = expert_dir.join("EXPERT.md"); + fs::write(&expert_md, "---\nname: demo\n---\ncontent").unwrap(); + + let err = parse_expert_file(&expert_md, ExpertSource::Project).unwrap_err(); + assert!(err.contains("description")); + } + + #[test] + fn test_parse_expert_file_falls_back_to_dir_name() { + let dir = tempfile::tempdir().unwrap(); + let expert_dir = dir.path().join("from-dir"); + fs::create_dir_all(&expert_dir).unwrap(); + let expert_md = expert_dir.join("EXPERT.md"); + fs::write( + &expert_md, + "---\ndescription: demo expert\n---\nYou are a translator.", + ) + .unwrap(); + + let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap(); + assert_eq!(expert.name, "from-dir"); + assert_eq!(expert.description, "demo expert"); + assert_eq!(expert.body, "You are a translator."); + } + + #[test] + fn test_render_expert_file_requires_description() { + let err = render_expert_file("demo", " ", "body").unwrap_err(); + assert!(err.contains("description")); + } + + #[test] + fn test_discover_prefers_project_over_user() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + // user scope + let user_dir = home_dir.join(".picobot").join("experts").join("demo"); + fs::create_dir_all(&user_dir).unwrap(); + fs::write( + user_dir.join("EXPERT.md"), + "---\ndescription: user version\n---\nUser body", + ) + .unwrap(); + + // project scope (overrides user) + let project_dir_expert = project_dir + .join(".picobot") + .join("experts") + .join("demo"); + fs::create_dir_all(&project_dir_expert).unwrap(); + fs::write( + project_dir_expert.join("EXPERT.md"), + "---\ndescription: project version\n---\nProject body", + ) + .unwrap(); + + let catalog = ExpertCatalog::discover(&ExpertsConfig { + enabled: true, + sources: vec!["user".to_string(), "project".to_string()], + }); + + assert_eq!(catalog.len(), 1); + let expert = catalog.find_expert("demo").unwrap(); + assert_eq!(expert.source, ExpertSource::Project); + assert_eq!(expert.description, "project version"); + assert_eq!(expert.body, "Project body"); + } + + #[test] + fn test_create_update_delete_cycle() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + }); + + assert_eq!(runtime.list_experts().len(), 0); + + let created = runtime + .create_expert( + ExpertScope::Project, + "translator", + "翻译专家", + "你是一名专业翻译。", + true, + ) + .unwrap(); + assert_eq!(created.name, "translator"); + assert_eq!(runtime.list_experts().len(), 1); + + // Duplicate create -> error + let dup = runtime.create_expert( + ExpertScope::Project, + "translator", + "dup", + "body", + true, + ); + assert!(dup.is_err()); + + let updated = runtime + .update_expert( + ExpertScope::Project, + "translator", + Some("更新翻译专家"), + Some("你是一名中文教师。"), + true, + ) + .unwrap(); + assert_eq!(updated.description, "更新翻译专家"); + assert_eq!(updated.body, "你是一名中文教师。"); + + // update with None preserves fields + let updated_none = runtime + .update_expert(ExpertScope::Project, "translator", None, None, true) + .unwrap(); + assert_eq!(updated_none.description, "更新翻译专家"); + assert_eq!(updated_none.body, "你是一名中文教师。"); + + let deleted_path = runtime + .delete_expert(ExpertScope::Project, "translator", true) + .unwrap(); + assert!(!deleted_path.exists()); + assert_eq!(runtime.list_experts().len(), 0); + } + + #[test] + fn test_select_and_selected_expert_for() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + }); + + runtime + .create_expert( + ExpertScope::Project, + "coder", + "编程专家", + "你是一名编程专家。", + true, + ) + .unwrap(); + + // No selection initially + assert!(runtime.selected_expert_for("sess-1").is_none()); + assert!(runtime.selected_expert_name_for("sess-1").is_none()); + + runtime.select_expert("sess-1", "coder").unwrap(); + let selected = runtime.selected_expert_for("sess-1").unwrap(); + assert_eq!(selected.name, "coder"); + assert_eq!( + runtime.selected_expert_name_for("sess-1"), + Some("coder".to_string()) + ); + + // persistence: reload from disk + let runtime2 = ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + }); + assert_eq!( + runtime2.selected_expert_name_for("sess-1"), + Some("coder".to_string()) + ); + } + + #[test] + fn test_clear_expert_removes_selection() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + }); + + runtime + .create_expert( + ExpertScope::Project, + "writer", + "写作专家", + "你是一名写作专家。", + true, + ) + .unwrap(); + + runtime.select_expert("sess-clear", "writer").unwrap(); + assert!(runtime.selected_expert_for("sess-clear").is_some()); + + runtime.clear_expert("sess-clear").unwrap(); + assert!(runtime.selected_expert_for("sess-clear").is_none()); + + // clear persists across reloads + let runtime2 = ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + }); + assert!(runtime2.selected_expert_for("sess-clear").is_none()); + } + + #[test] + fn test_disable_expert_makes_selected_return_none() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + }); + + runtime + .create_expert( + ExpertScope::Project, + "reviewer", + "代码审查专家", + "你是一名代码审查专家。", + true, + ) + .unwrap(); + + runtime.select_expert("sess-disable", "reviewer").unwrap(); + assert!(runtime.selected_expert_for("sess-disable").is_some()); + + let change = runtime + .disable_expert(ExpertScope::Project, "reviewer") + .unwrap(); + assert!(change.changed); + assert!(!change.available); + assert_eq!(change.disabled_in_scopes, vec![ExpertScope::Project]); + + // After disabling, selection no longer resolves. + assert!(runtime.selected_expert_for("sess-disable").is_none()); + + // Re-enable restores availability + let change = runtime + .enable_expert(ExpertScope::Project, "reviewer") + .unwrap(); + assert!(change.changed); + assert!(change.available); + assert!(change.disabled_in_scopes.is_empty()); + assert!(runtime.selected_expert_for("sess-disable").is_some()); + } + + #[test] + fn test_list_experts_with_status_includes_disabled() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + }); + + runtime + .create_expert( + ExpertScope::Project, + "planner", + "规划专家", + "你是一名规划专家。", + true, + ) + .unwrap(); + + // initially enabled + let items = runtime.list_experts_with_status(); + assert_eq!(items.len(), 1); + assert!(items[0].disabled_in_scopes.is_empty()); + + // disable in project scope + runtime + .disable_expert(ExpertScope::Project, "planner") + .unwrap(); + + let items = runtime.list_experts_with_status(); + assert_eq!(items.len(), 1, "list_experts_with_status should include disabled experts"); + assert_eq!(items[0].name, "planner"); + assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]); + + // list_experts (filtered) should be empty + let active = runtime.list_experts(); + assert_eq!(active.len(), 0, "list_experts should filter out disabled experts"); + } + + #[test] + fn test_state_file_roundtrip_preserves_both_fields() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("expert-state.json"); + let mut state = ExpertStateFile { + session_experts: HashMap::new(), + disabled_experts: vec!["demo".to_string()], + }; + state.session_experts.insert("sess-1".to_string(), "demo".to_string()); + + save_expert_state_file(&path, &state).unwrap(); + + let loaded = load_expert_state_file(&path).unwrap(); + assert_eq!(loaded, state); + } + + #[test] + fn test_persist_session_experts_preserves_disabled() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + // Pre-populate the project state file with a disabled expert. + let state_path = project_dir.join(".picobot").join("expert-state.json"); + fs::create_dir_all(state_path.parent().unwrap()).unwrap(); + save_expert_state_file( + &state_path, + &ExpertStateFile { + session_experts: HashMap::new(), + disabled_experts: vec!["locked".to_string()], + }, + ) + .unwrap(); + + // Now persist a session selection; the disabled_experts field should survive. + persist_session_experts(&project_dir, |state| { + state + .session_experts + .insert("sess-x".to_string(), "translator".to_string()); + }) + .unwrap(); + + let loaded = load_expert_state_file(&state_path).unwrap(); + assert_eq!(loaded.disabled_experts, vec!["locked".to_string()]); + assert_eq!( + loaded.session_experts.get("sess-x"), + Some(&"translator".to_string()) + ); + } + + #[test] + fn test_prompt_provider_returns_none_without_selection() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + })); + let provider = ExpertPromptProvider::new(runtime.clone()); + + // No session_id + let ctx = SystemPromptContext { + session_id: None, + chat_id: "chat".to_string(), + user_message_count: 1, + }; + assert!(provider.build(&ctx).is_none()); + + // session_id present but no selection + let ctx = SystemPromptContext { + session_id: Some("no-selection".to_string()), + chat_id: "chat".to_string(), + user_message_count: 1, + }; + assert!(provider.build(&ctx).is_none()); + } + + #[test] + fn test_prompt_provider_returns_body_when_selected() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + })); + runtime + .create_expert( + ExpertScope::Project, + "teacher", + "教师专家", + "你是一名中文教师,请用中文回答。", + true, + ) + .unwrap(); + runtime.select_expert("sess-p", "teacher").unwrap(); + + let provider = ExpertPromptProvider::new(runtime.clone()); + let ctx = SystemPromptContext { + session_id: Some("sess-p".to_string()), + chat_id: "chat".to_string(), + user_message_count: 1, + }; + let prompt = provider.build(&ctx).unwrap(); + assert_eq!(prompt.context, Some("expert".to_string())); + assert!(prompt.content.contains("中文教师")); + } + + #[test] + fn test_prompt_provider_injects_header_when_body_empty() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig { + enabled: true, + sources: vec!["project".to_string()], + })); + runtime + .create_expert( + ExpertScope::Project, + "empty-body", + "无 body 的专家", + "", // body empty + true, + ) + .unwrap(); + runtime.select_expert("sess-empty", "empty-body").unwrap(); + + let provider = ExpertPromptProvider::new(runtime.clone()); + let ctx = SystemPromptContext { + session_id: Some("sess-empty".to_string()), + chat_id: "chat".to_string(), + user_message_count: 1, + }; + let prompt = provider.build(&ctx).unwrap(); + assert!(prompt.content.contains("# 专家角色: empty-body")); + assert!(prompt.content.contains("无 body 的专家")); + } + + #[test] + fn test_disabled_config_returns_empty_catalog() { + let _lock = acquire_test_lock(); + let temp_dir = tempfile::tempdir().unwrap(); + let home_dir = temp_dir.path().join("home"); + let project_dir = temp_dir.path().join("project"); + fs::create_dir_all(&home_dir).unwrap(); + fs::create_dir_all(&project_dir).unwrap(); + let _home = HomeDirGuard::enter(&home_dir); + let _guard = CurrentDirGuard::enter(&project_dir); + + let project_expert_dir = project_dir.join(".picobot").join("experts").join("ignored"); + fs::create_dir_all(&project_expert_dir).unwrap(); + fs::write( + project_expert_dir.join("EXPERT.md"), + "---\ndescription: should be ignored\n---\nbody", + ) + .unwrap(); + + let catalog = ExpertCatalog::discover(&ExpertsConfig { + enabled: false, + sources: vec!["project".to_string()], + }); + assert_eq!(catalog.len(), 0); + } +} diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 3352125..a51fccf 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -267,6 +267,7 @@ async fn handle_socket(ws: WebSocket, state: Arc) { timestamp: Some(crate::protocol::now_timestamp()), code:"SESSION_ERROR".to_string(), message: e.to_string(), + subagent_task_id: None, }) .await; } @@ -278,6 +279,7 @@ async fn handle_socket(ws: WebSocket, state: Arc) { timestamp: Some(crate::protocol::now_timestamp()), code:"PARSE_ERROR".to_string(), message: e.to_string(), + subagent_task_id: None, }) .await; } @@ -370,6 +372,7 @@ async fn handle_inbound( timestamp: Some(crate::protocol::now_timestamp()), code: "INVALID_COMMAND".to_string(), message: "Invalid command payload".to_string(), + subagent_task_id: None, }) .await; return Ok(()); @@ -380,6 +383,7 @@ async fn handle_inbound( timestamp: Some(crate::protocol::now_timestamp()), code: "PARSE_ERROR".to_string(), message: e.to_string(), + subagent_task_id: None, }) .await; return Ok(()); @@ -796,6 +800,12 @@ fn set_subagent_task_id(outbound: &mut WsOutbound, task_id: &str) { } | WsOutbound::ToolPending { subagent_task_id, .. + } + | WsOutbound::StreamDelta { + subagent_task_id, .. + } + | WsOutbound::StreamEnd { + subagent_task_id, .. } => { *subagent_task_id = Some(task_id.to_string()); } diff --git a/src/lib.rs b/src/lib.rs index c0d6d5e..3befbd0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod client; pub mod command; pub mod config; pub mod domain; +pub mod experts; pub mod gateway; pub mod logging; pub mod mcp; diff --git a/src/protocol/mod.rs b/src/protocol/mod.rs index 33e9414..c6073c5 100644 --- a/src/protocol/mod.rs +++ b/src/protocol/mod.rs @@ -209,6 +209,8 @@ pub enum WsOutbound { message: String, #[serde(default, skip_serializing_if = "Option::is_none")] timestamp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent_task_id: Option, }, #[serde(rename = "task_started")] TaskStarted { @@ -301,6 +303,8 @@ pub enum WsOutbound { topic_id: Option, #[serde(default, skip_serializing_if = "Option::is_none")] timestamp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + subagent_task_id: Option, }, #[serde(rename = "todo_list")] TodoList { diff --git a/src/protocol/ws_adapter.rs b/src/protocol/ws_adapter.rs index 06575b9..bdfe34f 100644 --- a/src/protocol/ws_adapter.rs +++ b/src/protocol/ws_adapter.rs @@ -172,6 +172,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve code: "AGENT_ERROR".to_string(), message: message.content.clone(), timestamp: Some(crate::protocol::now_timestamp()), + subagent_task_id: message.metadata.get("subagent_task_id").cloned(), }], OutboundEventKind::TaskStarted => vec![WsOutbound::TaskStarted { task_id: message.metadata.get("task_id").cloned().unwrap_or_default(), @@ -197,6 +198,7 @@ pub(crate) fn ws_outbound_from_outbound_message(message: &OutboundMessage) -> Ve OutboundEventKind::ExecutionCompleted => vec![WsOutbound::ExecutionCompleted { topic_id: message.metadata.get("topic_id").cloned(), timestamp: Some(crate::protocol::now_timestamp()), + subagent_task_id: message.metadata.get("subagent_task_id").cloned(), }], } }