diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 23b5ca88..104f997b 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -243,127 +243,24 @@ pub async fn get_runtime_logging_info( } #[tauri::command] -pub async fn get_mode_configs(state: State<'_, AppState>) -> Result { - use bitfun_core::service::config::types::ModeConfig; - use std::collections::HashMap; - - let config_service = &state.config_service; - let mut mode_configs: HashMap = config_service - .get_config(Some("ai.mode_configs")) - .await - .unwrap_or_default(); - - let all_modes = state.agent_registry.get_modes_info().await; - let mut needs_save = false; - - for mode in all_modes { - let mode_id = mode.id; - let default_tools = mode.default_tools; - - if !mode_configs.contains_key(&mode_id) { - let new_config = ModeConfig { - mode_id: mode_id.clone(), - available_tools: default_tools.clone(), - enabled: true, - default_tools: default_tools, - }; - mode_configs.insert(mode_id.clone(), new_config); - needs_save = true; - } else if let Some(config) = mode_configs.get_mut(&mode_id) { - config.default_tools = default_tools.clone(); - // Migration: add ComputerUse to available_tools when the mode default includes it. - if default_tools.iter().any(|t| t == "ComputerUse") - && !config.available_tools.iter().any(|t| t == "ComputerUse") - { - config.available_tools.push("ComputerUse".to_string()); - needs_save = true; - } - // Migrate older Claw sessions that only allowlisted "ComputerUse" before split mouse tools existed; - // All desktop automation is now consolidated into ComputerUse. - // Remove any stale split tool names from available_tools. - if mode_id == "Claw" { - let stale = ["ComputerUseMousePrecise", "ComputerUseMouseStep", "ComputerUseMouseClick"]; - let before = config.available_tools.len(); - config.available_tools.retain(|t| !stale.contains(&t.as_str())); - if config.available_tools.len() != before { - needs_save = true; - } - } - } - } - - if needs_save { - match to_json_value(&mode_configs, "mode configs") { - Ok(mode_configs_value) => { - if let Err(e) = config_service - .set_config("ai.mode_configs", mode_configs_value) - .await - { - warn!("Failed to save initialized mode configs: {}", e); - } - } - Err(e) => { - warn!("Failed to serialize initialized mode configs: {}", e); - } - } - } +pub async fn get_mode_configs(_state: State<'_, AppState>) -> Result { + let mode_configs = + bitfun_core::service::config::mode_config_canonicalizer::get_mode_config_views() + .await + .map_err(|e| format!("Failed to get mode configs: {}", e))?; Ok(to_json_value(mode_configs, "mode configs")?) } #[tauri::command] -pub async fn get_mode_config(state: State<'_, AppState>, mode_id: String) -> Result { - use bitfun_core::service::config::types::ModeConfig; - - let config_service = &state.config_service; - let agent_registry = &state.agent_registry; - let path = format!("ai.mode_configs.{}", mode_id); - let config_result = config_service.get_config::(Some(&path)).await; - - let config = match config_result { - Ok(existing_config) => { - let mut cfg = existing_config; - if let Some(mode) = agent_registry.get_mode_agent(&mode_id) { - cfg.default_tools = mode.default_tools(); - } - cfg - } - Err(_) => { - if let Some(mode) = agent_registry.get_mode_agent(&mode_id) { - let default_tools = mode.default_tools(); - let new_config = ModeConfig { - mode_id: mode_id.clone(), - available_tools: default_tools.clone(), - enabled: true, - default_tools: default_tools, - }; - match to_json_value(&new_config, "initial mode config") { - Ok(new_config_value) => { - if let Err(e) = config_service.set_config(&path, new_config_value).await { - warn!( - "Failed to save initial mode config: mode_id={}, error={}", - mode_id, e - ); - } - } - Err(e) => { - warn!( - "Failed to serialize initial mode config: mode_id={}, error={}", - mode_id, e - ); - } - } - new_config - } else { - ModeConfig { - mode_id: mode_id.clone(), - available_tools: vec![], - enabled: true, - default_tools: vec![], - } - } - } - }; +pub async fn get_mode_config( + _state: State<'_, AppState>, + mode_id: String, +) -> Result { + let config = + bitfun_core::service::config::mode_config_canonicalizer::get_mode_config_view(&mode_id) + .await + .map_err(|e| format!("Failed to get mode config: {}", e))?; Ok(to_json_value(config, "mode config")?) } @@ -374,10 +271,13 @@ pub async fn set_mode_config( mode_id: String, config: Value, ) -> Result { - let config_service = &state.config_service; - let path = format!("ai.mode_configs.{}", mode_id); + let _ = state; - match config_service.set_config(&path, config).await { + match bitfun_core::service::config::mode_config_canonicalizer::persist_mode_config_from_value( + &mode_id, config, + ) + .await + { Ok(_) => { if let Err(e) = bitfun_core::service::config::reload_global_config().await { warn!( @@ -405,30 +305,14 @@ pub async fn set_mode_config( #[tauri::command] pub async fn reset_mode_config( - state: State<'_, AppState>, + _state: State<'_, AppState>, mode_id: String, ) -> Result { - use bitfun_core::service::config::types::ModeConfig; - - let agent_registry = &state.agent_registry; - let default_tools = if let Some(mode) = agent_registry.get_mode_agent(&mode_id) { - mode.default_tools() - } else { - return Err(format!("Mode does not exist: {}", mode_id)); - }; - - let default_config = ModeConfig { - mode_id: mode_id.clone(), - available_tools: default_tools.clone(), - enabled: true, - default_tools: default_tools, - }; - - let config_service = &state.config_service; - let path = format!("ai.mode_configs.{}", mode_id); - let default_config_value = to_json_value(&default_config, "default mode config")?; - - match config_service.set_config(&path, default_config_value).await { + match bitfun_core::service::config::mode_config_canonicalizer::reset_mode_config_to_default( + &mode_id, + ) + .await + { Ok(_) => { if let Err(e) = bitfun_core::service::config::reload_global_config().await { warn!( @@ -539,20 +423,23 @@ pub async fn set_subagent_config( } #[tauri::command] -pub async fn sync_tool_configs(_state: State<'_, AppState>) -> Result { - match bitfun_core::service::config::tool_config_sync::sync_tool_configs().await { +pub async fn canonicalize_mode_configs(_state: State<'_, AppState>) -> Result { + match bitfun_core::service::config::mode_config_canonicalizer::canonicalize_mode_configs().await + { Ok(report) => { info!( - "Tool configs synced: new_tools={}, deleted_tools={}, updated_modes={}", - report.new_tools.len(), - report.deleted_tools.len(), + "Mode configs canonicalized: removed_modes={}, updated_modes={}", + report.removed_mode_configs.len(), report.updated_modes.len() ); - Ok(to_json_value(report, "tool config sync report")?) + Ok(to_json_value( + report, + "mode config canonicalization report", + )?) } Err(e) => { - error!("Failed to sync tool configs: {}", e); - Err(format!("Failed to sync tool configs: {}", e)) + error!("Failed to canonicalize mode configs: {}", e); + Err(format!("Failed to canonicalize mode configs: {}", e)) } } } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 19509401..ab31a8cb 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -600,7 +600,7 @@ pub async fn run() { create_cron_job, update_cron_job, delete_cron_job, - api::config_api::sync_tool_configs, + api::config_api::canonicalize_mode_configs, api::terminal_api::terminal_get_shells, api::terminal_api::terminal_create, api::terminal_api::terminal_get, diff --git a/src/crates/core/src/agentic/agents/registry.rs b/src/crates/core/src/agentic/agents/registry.rs index 61c44cb2..ff81df0b 100644 --- a/src/crates/core/src/agentic/agents/registry.rs +++ b/src/crates/core/src/agentic/agents/registry.rs @@ -7,12 +7,13 @@ use crate::agentic::agents::custom_subagents::{ }; use crate::agentic::tools::get_all_registered_tool_names; use crate::service::config::global::GlobalConfigManager; +use crate::service::config::mode_config_canonicalizer::resolve_effective_tools; use crate::service::config::types::{ModeConfig, SubAgentConfig}; use crate::service::config::GlobalConfig; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error, warn}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::RwLock; use std::sync::{Arc, OnceLock}; @@ -372,7 +373,7 @@ impl AgentRegistry { /// get agent tools from config /// if not set, return default tools - /// tool configuration synchronization is implemented through tool_config_sync, here only read configuration + /// mode config canonicalization is handled separately; this only reads resolved configuration pub async fn get_agent_tools( &self, agent_type: &str, @@ -385,18 +386,13 @@ impl AgentRegistry { match entry.category { AgentCategory::Mode => { let mode_configs = get_mode_configs().await; - let mut tools = mode_configs - .get(agent_type) - .map(|config| config.available_tools.clone()) - .unwrap_or_else(|| entry.agent.default_tools()); - let defaults = entry.agent.default_tools(); - const COMPUTER_USE: &str = "ComputerUse"; - if defaults.iter().any(|t| t == COMPUTER_USE) - && !tools.iter().any(|t| t == COMPUTER_USE) - { - tools.push(COMPUTER_USE.to_string()); - } - tools + let valid_tools: HashSet = + get_all_registered_tool_names().await.into_iter().collect(); + resolve_effective_tools( + &entry.agent.default_tools(), + mode_configs.get(agent_type), + &valid_tools, + ) } AgentCategory::SubAgent | AgentCategory::Hidden => entry.agent.default_tools(), } @@ -716,9 +712,9 @@ impl AgentRegistry { agent_id: &str, workspace_root: Option<&Path>, ) -> BitFunResult { - let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| { - BitFunError::agent(format!("Subagent not found: {}", agent_id)) - })?; + let entry = self + .find_agent_entry(agent_id, workspace_root) + .ok_or_else(|| BitFunError::agent(format!("Subagent not found: {}", agent_id)))?; if entry.category != AgentCategory::SubAgent { return Err(BitFunError::agent(format!( "Agent '{}' is not a subagent", @@ -775,9 +771,9 @@ impl AgentRegistry { if let Some(root) = workspace_root { self.load_custom_subagents(root).await; } - let entry = self.find_agent_entry(agent_id, workspace_root).ok_or_else(|| { - BitFunError::agent(format!("Subagent not found: {}", agent_id)) - })?; + let entry = self + .find_agent_entry(agent_id, workspace_root) + .ok_or_else(|| BitFunError::agent(format!("Subagent not found: {}", agent_id)))?; if entry.category != AgentCategory::SubAgent { return Err(BitFunError::agent(format!( "Agent '{}' is not a subagent", @@ -799,16 +795,14 @@ impl AgentRegistry { agent_id )) })?; - let tools = tools - .filter(|t| !t.is_empty()) - .unwrap_or_else(|| { - vec![ - "LS".to_string(), - "Read".to_string(), - "Glob".to_string(), - "Grep".to_string(), - ] - }); + let tools = tools.filter(|t| !t.is_empty()).unwrap_or_else(|| { + vec![ + "LS".to_string(), + "Read".to_string(), + "Glob".to_string(), + "Grep".to_string(), + ] + }); let mut new_subagent = CustomSubagent::new( old.name.clone(), description, @@ -838,9 +832,9 @@ impl AgentRegistry { ) -> BitFunResult<()> { let mut map = self.write_agents(); if map.contains_key(agent_id) { - let old_entry = map.get(agent_id).ok_or_else(|| { - BitFunError::agent(format!("Subagent not found: {}", agent_id)) - })?; + let old_entry = map + .get(agent_id) + .ok_or_else(|| BitFunError::agent(format!("Subagent not found: {}", agent_id)))?; if old_entry.category != AgentCategory::SubAgent { return Err(BitFunError::agent(format!( "Agent '{}' is not a subagent", @@ -877,9 +871,9 @@ impl AgentRegistry { let entries = pm.get_mut(root).ok_or_else(|| { BitFunError::agent("Project subagent cache not loaded for this workspace".to_string()) })?; - let old_entry = entries.get(agent_id).ok_or_else(|| { - BitFunError::agent(format!("Subagent not found: {}", agent_id)) - })?; + let old_entry = entries + .get(agent_id) + .ok_or_else(|| BitFunError::agent(format!("Subagent not found: {}", agent_id)))?; if old_entry.category != AgentCategory::SubAgent { return Err(BitFunError::agent(format!( "Agent '{}' is not a subagent", diff --git a/src/crates/core/src/service/config/global.rs b/src/crates/core/src/service/config/global.rs index 2a844d1a..b6473a26 100644 --- a/src/crates/core/src/service/config/global.rs +++ b/src/crates/core/src/service/config/global.rs @@ -80,19 +80,18 @@ impl GlobalConfigManager { info!("Global config service initialized"); - match super::tool_config_sync::sync_tool_configs().await { + match super::mode_config_canonicalizer::canonicalize_mode_configs().await { Ok(report) => { - if !report.new_tools.is_empty() || !report.deleted_tools.is_empty() { + if !report.removed_mode_configs.is_empty() || !report.updated_modes.is_empty() { info!( - "Tool config sync completed: {} new, {} deleted, {} updated modes", - report.new_tools.len(), - report.deleted_tools.len(), + "Mode config canonicalization completed: removed_modes={}, updated_modes={}", + report.removed_mode_configs.len(), report.updated_modes.len() ); } } Err(e) => { - warn!("Tool config sync failed: {}", e); + warn!("Mode config canonicalization failed: {}", e); } } @@ -136,6 +135,12 @@ impl GlobalConfigManager { pub async fn reload() -> BitFunResult<()> { let service = Self::get_service().await?; service.reload().await?; + if let Err(error) = super::mode_config_canonicalizer::canonicalize_mode_configs().await { + warn!( + "Mode config canonicalization failed after reload: {}", + error + ); + } Self::broadcast_update(ConfigUpdateEvent::ConfigReloaded).await; Ok(()) } diff --git a/src/crates/core/src/service/config/mod.rs b/src/crates/core/src/service/config/mod.rs index 728af260..0dd18df7 100644 --- a/src/crates/core/src/service/config/mod.rs +++ b/src/crates/core/src/service/config/mod.rs @@ -6,9 +6,9 @@ pub mod app_language; pub mod factory; pub mod global; pub mod manager; +pub mod mode_config_canonicalizer; pub mod providers; pub mod service; -pub mod tool_config_sync; pub mod types; pub use app_language::{get_app_language_code, short_model_user_language_instruction}; @@ -18,7 +18,9 @@ pub use global::{ subscribe_config_updates, ConfigUpdateEvent, GlobalConfigManager, }; pub use manager::{ConfigManager, ConfigManagerSettings, ConfigStatistics}; +pub use mode_config_canonicalizer::{ + canonicalize_mode_configs, ModeConfigCanonicalizationReport, ModeConfigUpdateInfo, +}; pub use providers::ConfigProviderRegistry; pub use service::{ConfigExport, ConfigHealthStatus, ConfigImportResult, ConfigService}; -pub use tool_config_sync::{sync_tool_configs, ModeSyncInfo, SyncReport}; pub use types::*; diff --git a/src/crates/core/src/service/config/mode_config_canonicalizer.rs b/src/crates/core/src/service/config/mode_config_canonicalizer.rs new file mode 100644 index 00000000..4d757ee9 --- /dev/null +++ b/src/crates/core/src/service/config/mode_config_canonicalizer.rs @@ -0,0 +1,399 @@ +//! Mode tool configuration migration and resolution. +//! +//! Stored configuration keeps only user overrides. Effective tool lists are +//! derived from the current mode defaults at runtime. + +use crate::agentic::agents::get_agent_registry; +use crate::agentic::tools::registry::get_all_registered_tools; +use crate::service::config::global::GlobalConfigManager; +use crate::service::config::types::{ModeConfig, ModeConfigView}; +use crate::util::errors::*; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::collections::{HashMap, HashSet}; + +/// Mode config canonicalization report. +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct ModeConfigCanonicalizationReport { + pub removed_mode_configs: Vec, + pub updated_modes: Vec, +} + +/// Mode config update information. +#[derive(Debug, Serialize, Deserialize)] +pub struct ModeConfigUpdateInfo { + pub mode_id: String, + pub added_tools: Vec, + pub removed_tools: Vec, +} + +fn dedupe_preserving_order(items: Vec) -> Vec { + let mut seen = HashSet::new(); + let mut normalized = Vec::new(); + + for item in items { + let trimmed = item.trim(); + if trimmed.is_empty() { + continue; + } + + let owned = trimmed.to_string(); + if seen.insert(owned.clone()) { + normalized.push(owned); + } + } + + normalized +} + +fn normalize_tools(tools: Vec, valid_tools: &HashSet) -> Vec { + dedupe_preserving_order(tools) + .into_iter() + .filter(|tool| valid_tools.contains(tool)) + .collect() +} + +fn normalize_skills(skills: Option>) -> Option> { + skills.map(dedupe_preserving_order) +} + +pub fn resolve_effective_tools( + default_tools: &[String], + mode_config: Option<&ModeConfig>, + valid_tools: &HashSet, +) -> Vec { + let Some(config) = mode_config else { + return normalize_tools(default_tools.to_vec(), valid_tools); + }; + + let default_tools = normalize_tools(default_tools.to_vec(), valid_tools); + let removed: HashSet = config.removed_tools.iter().cloned().collect(); + let added = normalize_tools(config.added_tools.clone(), valid_tools); + + let mut effective = Vec::new(); + let mut seen = HashSet::new(); + + for tool in default_tools { + if removed.contains(&tool) { + continue; + } + if seen.insert(tool.clone()) { + effective.push(tool); + } + } + + for tool in added { + if seen.insert(tool.clone()) { + effective.push(tool); + } + } + + effective +} + +fn stored_mode_from_enabled_tools( + mode_id: &str, + enabled: bool, + enabled_tools: Vec, + available_skills: Option>, + default_tools: &[String], + valid_tools: &HashSet, +) -> Option { + let default_tools = normalize_tools(default_tools.to_vec(), valid_tools); + let enabled_tools = normalize_tools(enabled_tools, valid_tools); + let enabled_set: HashSet = enabled_tools.iter().cloned().collect(); + let default_set: HashSet = default_tools.iter().cloned().collect(); + + let mut added_tools = Vec::new(); + for tool in &enabled_tools { + if !default_set.contains(tool) { + added_tools.push(tool.clone()); + } + } + + let mut removed_tools = Vec::new(); + for tool in &default_tools { + if !enabled_set.contains(tool) { + removed_tools.push(tool.clone()); + } + } + + stored_mode_from_overrides( + mode_id, + enabled, + added_tools, + removed_tools, + available_skills, + &default_tools, + valid_tools, + ) +} + +fn stored_mode_from_overrides( + mode_id: &str, + enabled: bool, + added_tools: Vec, + removed_tools: Vec, + available_skills: Option>, + default_tools: &[String], + valid_tools: &HashSet, +) -> Option { + let default_set: HashSet = default_tools.iter().cloned().collect(); + let mut added_tools = normalize_tools(added_tools, valid_tools); + let mut removed_tools = normalize_tools(removed_tools, valid_tools); + let available_skills = normalize_skills(available_skills); + + added_tools.retain(|tool| !default_set.contains(tool)); + removed_tools.retain(|tool| default_set.contains(tool)); + + let removed_set: HashSet = removed_tools.iter().cloned().collect(); + added_tools.retain(|tool| !removed_set.contains(tool)); + + if enabled && added_tools.is_empty() && removed_tools.is_empty() && available_skills.is_none() { + return None; + } + + Some(ModeConfig { + mode_id: mode_id.to_string(), + added_tools, + removed_tools, + enabled, + available_skills, + }) +} + +fn build_mode_view( + mode_id: &str, + default_tools: Vec, + mode_config: Option<&ModeConfig>, + valid_tools: &HashSet, +) -> ModeConfigView { + let default_tools = normalize_tools(default_tools, valid_tools); + let enabled_tools = resolve_effective_tools(&default_tools, mode_config, valid_tools); + let enabled = mode_config.map(|config| config.enabled).unwrap_or(true); + let available_skills = mode_config.and_then(|config| config.available_skills.clone()); + + ModeConfigView { + mode_id: mode_id.to_string(), + enabled_tools, + default_tools, + enabled, + available_skills, + } +} + +fn canonicalize_mode_config( + mode_id: &str, + raw_mode: Option<&Value>, + default_tools: &[String], + valid_tools: &HashSet, +) -> BitFunResult> { + let Some(raw_mode) = raw_mode else { + return Ok(None); + }; + + let mut stored: ModeConfig = serde_json::from_value(raw_mode.clone()).map_err(|error| { + BitFunError::config(format!( + "Failed to deserialize mode config '{}': {}", + mode_id, error + )) + })?; + if stored.mode_id.trim().is_empty() { + stored.mode_id = mode_id.to_string(); + } + + Ok(stored_mode_from_overrides( + mode_id, + stored.enabled, + stored.added_tools, + stored.removed_tools, + stored.available_skills, + default_tools, + valid_tools, + )) +} + +async fn get_valid_tool_names() -> HashSet { + get_all_registered_tools() + .await + .into_iter() + .map(|tool| tool.name().to_string()) + .collect() +} + +async fn get_mode_defaults() -> HashMap> { + get_agent_registry() + .get_modes_info() + .await + .into_iter() + .map(|mode| (mode.id, mode.default_tools)) + .collect() +} + +pub async fn get_mode_config_views() -> BitFunResult> { + let config_service = GlobalConfigManager::get_service().await?; + let stored_configs: HashMap = config_service + .get_config(Some("ai.mode_configs")) + .await + .unwrap_or_default(); + let mode_defaults = get_mode_defaults().await; + let valid_tools = get_valid_tool_names().await; + + let mut views = HashMap::new(); + for (mode_id, default_tools) in mode_defaults { + let view = build_mode_view( + &mode_id, + default_tools, + stored_configs.get(&mode_id), + &valid_tools, + ); + views.insert(mode_id, view); + } + + Ok(views) +} + +pub async fn get_mode_config_view(mode_id: &str) -> BitFunResult { + let views = get_mode_config_views().await?; + views + .get(mode_id) + .cloned() + .ok_or_else(|| BitFunError::config(format!("Mode does not exist: {}", mode_id))) +} + +pub async fn persist_mode_config_from_value(mode_id: &str, config: Value) -> BitFunResult<()> { + let config_service = GlobalConfigManager::get_service().await?; + let mut stored_configs: HashMap = config_service + .get_config(Some("ai.mode_configs")) + .await + .unwrap_or_default(); + let mode_defaults = get_mode_defaults().await; + let default_tools = mode_defaults + .get(mode_id) + .ok_or_else(|| BitFunError::config(format!("Mode does not exist: {}", mode_id)))?; + let valid_tools = get_valid_tool_names().await; + let current = stored_configs.get(mode_id); + + let enabled = config + .get("enabled") + .and_then(Value::as_bool) + .unwrap_or_else(|| current.map(|item| item.enabled).unwrap_or(true)); + let enabled_tools = if let Some(tools) = config.get("enabled_tools") { + serde_json::from_value::>(tools.clone()).map_err(|error| { + BitFunError::config(format!( + "Invalid enabled_tools for mode '{}': {}", + mode_id, error + )) + })? + } else { + resolve_effective_tools(default_tools, current, &valid_tools) + }; + + let available_skills = if config + .as_object() + .map(|obj| obj.contains_key("available_skills")) + .unwrap_or(false) + { + match config.get("available_skills") { + Some(Value::Null) | None => None, + Some(value) => Some( + serde_json::from_value::>(value.clone()).map_err(|error| { + BitFunError::config(format!( + "Invalid available_skills for mode '{}': {}", + mode_id, error + )) + })?, + ), + } + } else { + current.and_then(|item| item.available_skills.clone()) + }; + + if let Some(canonical) = stored_mode_from_enabled_tools( + mode_id, + enabled, + enabled_tools, + available_skills, + default_tools, + &valid_tools, + ) { + stored_configs.insert(mode_id.to_string(), canonical); + } else { + stored_configs.remove(mode_id); + } + + config_service + .set_config("ai.mode_configs", stored_configs) + .await +} + +pub async fn reset_mode_config_to_default(mode_id: &str) -> BitFunResult<()> { + let config_service = GlobalConfigManager::get_service().await?; + let mut stored_configs: HashMap = config_service + .get_config(Some("ai.mode_configs")) + .await + .unwrap_or_default(); + stored_configs.remove(mode_id); + config_service + .set_config("ai.mode_configs", stored_configs) + .await +} + +/// Canonicalizes stored mode config overrides. +pub async fn canonicalize_mode_configs() -> BitFunResult { + let config_service = GlobalConfigManager::get_service().await?; + let valid_tools = get_valid_tool_names().await; + let mode_defaults = get_mode_defaults().await; + let mut ai_value: Value = config_service.get_config(Some("ai")).await?; + let original_ai_value = ai_value.clone(); + let ai_object = ai_value + .as_object_mut() + .ok_or_else(|| BitFunError::config("AI config must be a JSON object".to_string()))?; + + let raw_mode_configs = ai_object + .get("mode_configs") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(); + + let mut rewritten_mode_configs = Map::new(); + let mut updated_modes = Vec::new(); + let mut removed_mode_configs = Vec::new(); + + for (mode_id, default_tools) in &mode_defaults { + let raw_mode = raw_mode_configs.get(mode_id); + let canonical = canonicalize_mode_config(mode_id, raw_mode, default_tools, &valid_tools)?; + if let Some(config) = canonical { + if raw_mode.is_some() { + updated_modes.push(ModeConfigUpdateInfo { + mode_id: mode_id.clone(), + added_tools: config.added_tools.clone(), + removed_tools: config.removed_tools.clone(), + }); + } + rewritten_mode_configs.insert(mode_id.clone(), serde_json::to_value(config)?); + } else if raw_mode.is_some() { + removed_mode_configs.push(mode_id.clone()); + } + } + + for mode_id in raw_mode_configs.keys() { + if !mode_defaults.contains_key(mode_id) { + removed_mode_configs.push(mode_id.clone()); + } + } + + ai_object.insert( + "mode_configs".to_string(), + Value::Object(rewritten_mode_configs), + ); + + if ai_value != original_ai_value { + config_service.set_config("ai", ai_value).await?; + } + + Ok(ModeConfigCanonicalizationReport { + removed_mode_configs, + updated_modes, + }) +} diff --git a/src/crates/core/src/service/config/tool_config_sync.rs b/src/crates/core/src/service/config/tool_config_sync.rs deleted file mode 100644 index 1a37ca3e..00000000 --- a/src/crates/core/src/service/config/tool_config_sync.rs +++ /dev/null @@ -1,101 +0,0 @@ -//! Tool configuration sync module -//! -//! Automatically syncs the tool registry with the tool list in configuration: -//! newly added tools are added to the appropriate modes, and removed tools are -//! removed from configuration. - -use crate::agentic::agents::get_agent_registry; -use crate::agentic::tools::registry::get_all_registered_tools; -use crate::service::config::global::GlobalConfigManager; -use crate::util::errors::*; -use serde::{Deserialize, Serialize}; -use std::collections::HashSet; - -/// Sync report. -#[derive(Debug, Serialize, Deserialize)] -pub struct SyncReport { - pub new_tools: Vec, - pub deleted_tools: Vec, - pub updated_modes: Vec, -} - -/// Mode sync information. -#[derive(Debug, Serialize, Deserialize)] -pub struct ModeSyncInfo { - pub mode_id: String, - pub added_tools: Vec, - pub removed_tools: Vec, -} - -/// Syncs tool configuration with the registry. -/// -/// Logic: -/// 1. Get the current tool registry (excluding MCP tools) -/// 2. Read `known_tools` from configuration (historical record) -/// 3. Detect added and removed tools by diffing the sets -/// 4. For newly added tools, if they are in a mode's default list, add them to `available_tools` -/// 5. Remove deleted tools from all `available_tools` -/// 6. Update `known_tools` to the current set -/// 7. Persist configuration -pub async fn sync_tool_configs() -> BitFunResult { - let all_tools = get_all_registered_tools().await; - let current_tools: HashSet = all_tools - .iter() - .map(|t| t.name().to_string()) - .filter(|name| !name.starts_with("mcp_")) - .collect(); - - let config_service = GlobalConfigManager::get_service().await?; - let mut config: crate::service::config::types::GlobalConfig = - config_service.get_config(None).await?; - let known_tools: HashSet<_> = config.ai.known_tools.into_iter().collect(); - - let new_tools: Vec = current_tools.difference(&known_tools).cloned().collect(); - - let deleted_tools: Vec = known_tools.difference(¤t_tools).cloned().collect(); - - let agent_registry = get_agent_registry(); - let mut updated_modes = Vec::new(); - - for (mode_id, mode_config) in config.ai.mode_configs.iter_mut() { - let mut added = Vec::new(); - - if let Some(agent) = agent_registry.get_mode_agent(mode_id) { - let default_tools = agent.default_tools(); - - for new_tool in &new_tools { - if default_tools.contains(new_tool) { - if !mode_config.available_tools.contains(new_tool) { - mode_config.available_tools.push(new_tool.clone()); - added.push(new_tool.clone()); - } - } - } - } - - let (kept, removed): (Vec, Vec) = mode_config - .available_tools - .drain(..) - .partition(|tool| !deleted_tools.contains(tool)); - - mode_config.available_tools = kept; - - if !added.is_empty() || !removed.is_empty() { - updated_modes.push(ModeSyncInfo { - mode_id: mode_id.clone(), - added_tools: added, - removed_tools: removed, - }); - } - } - - config.ai.known_tools = current_tools.into_iter().collect(); - - config_service.set_config("ai", &config.ai).await?; - - Ok(SyncReport { - new_tools, - deleted_tools, - updated_modes, - }) -} diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index ea5aacb5..e4bf792f 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -440,11 +440,6 @@ pub struct AIConfig { #[serde(default)] pub debug_mode_config: DebugModeConfig, - /// Known tools (all non-MCP tools from the registry at last startup). - /// Used to detect added and removed tools. - #[serde(default)] - pub known_tools: Vec, - /// Allow Computer use (desktop automation) when the desktop host is available (all session modes). #[serde(default)] pub computer_use_enabled: bool, @@ -498,17 +493,34 @@ pub struct ModeConfig { /// Mode ID (e.g. agentic, debug, requirement, ui-design). pub mode_id: String, - /// Available tools. - pub available_tools: Vec, + /// Tools explicitly enabled by the user that are not part of the mode defaults. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub added_tools: Vec, + + /// Default tools explicitly disabled by the user. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub removed_tools: Vec, /// Whether this mode is enabled. #[serde(default = "default_true")] pub enabled: bool, - /// Default tools for this mode (from the mode registry; not read from config). - /// Used only for frontend display and reset; persisted but overwritten on load. - #[serde(skip_deserializing)] + /// Skill overrides for this mode. + /// `None` means follow the global skill enablement state. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub available_skills: Option>, +} + +/// API view of a mode configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct ModeConfigView { + pub mode_id: String, + pub enabled_tools: Vec, pub default_tools: Vec, + pub enabled: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub available_skills: Option>, } fn default_true() -> bool { @@ -533,9 +545,22 @@ impl Default for ModeConfig { fn default() -> Self { Self { mode_id: String::new(), - available_tools: Vec::new(), + added_tools: Vec::new(), + removed_tools: Vec::new(), enabled: true, + available_skills: None, + } + } +} + +impl Default for ModeConfigView { + fn default() -> Self { + Self { + mode_id: String::new(), + enabled_tools: Vec::new(), default_tools: Vec::new(), + enabled: true, + available_skills: None, } } } @@ -1198,7 +1223,6 @@ impl Default for AIConfig { tool_confirmation_timeout_secs: default_tool_confirmation_timeout(), skip_tool_confirmation: true, debug_mode_config: DebugModeConfig::default(), - known_tools: Vec::new(), computer_use_enabled: false, } } diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 007463fb..9b3f9d29 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -72,9 +72,9 @@ const AgentsHomeView: React.FC = () => { counts, loadAgents, getModeConfig, - handleToggleTool, + handleSetTools, handleResetTools, - handleToggleSkill, + handleSetSkills, } = useAgentsList({ searchQuery, filterLevel: agentFilterLevel, @@ -135,10 +135,20 @@ const AgentsHomeView: React.FC = () => { [getModeConfig, selectedAgent], ); const selectedAgentTools = selectedAgent?.agentKind === 'mode' - ? (selectedAgentModeConfig?.available_tools ?? selectedAgent.defaultTools ?? []) + ? (selectedAgentModeConfig?.enabled_tools ?? selectedAgent.defaultTools ?? []) : (selectedAgent?.defaultTools ?? []); const selectedAgentSkills = selectedAgentModeConfig?.available_skills ?? []; const selectedAgentSkillItems = availableSkills.filter((skill) => selectedAgentSkills.includes(skill.name)); + const getDisplayedToolCount = useCallback((agent: AgentWithCapabilities): number => { + if (agent.agentKind === 'mode') { + return getModeConfig(agent.id)?.enabled_tools?.length + ?? agent.defaultTools?.length + ?? agent.toolCount + ?? 0; + } + return agent.toolCount ?? agent.defaultTools?.length ?? 0; + }, [getModeConfig]); + const selectedAgentToolCount = selectedAgent ? getDisplayedToolCount(selectedAgent) : 0; const resetEditState = useCallback(() => { setToolsEditing(false); setSkillsEditing(false); @@ -264,6 +274,7 @@ const AgentsHomeView: React.FC = () => { agent={agent} index={index} meta={coreAgentMeta[agent.id] ?? { role: agent.name, accentColor: '#6366f1', accentBg: 'rgba(99,102,241,0.10)' }} + toolCount={getDisplayedToolCount(agent)} skillCount={agent.agentKind === 'mode' ? (getModeConfig(agent.id)?.available_skills?.length ?? 0) : 0} onOpenDetails={openAgentDetails} /> @@ -347,6 +358,7 @@ const AgentsHomeView: React.FC = () => { agent={agent} index={index} soloEnabled={agentSoloEnabled[agent.id] ?? agent.enabled} + toolCount={getDisplayedToolCount(agent)} skillCount={agent.agentKind === 'mode' ? (getModeConfig(agent.id)?.available_skills?.length ?? 0) : 0} onToggleSolo={setAgentSoloEnabled} onOpenDetails={openAgentDetails} @@ -379,7 +391,7 @@ const AgentsHomeView: React.FC = () => { description={selectedAgent?.description} meta={selectedAgent ? ( <> - {t('agentCard.meta.tools', { count: selectedAgent.toolCount ?? selectedAgentTools.length })} + {t('agentCard.meta.tools', { count: selectedAgentToolCount })} {selectedAgent.agentKind === 'mode' ? ( {t('agentCard.meta.skills', { count: selectedAgentSkills.length })} ) : null} @@ -460,15 +472,7 @@ const AgentsHomeView: React.FC = () => { } setSavingTools(true); try { - await Promise.all( - availableTools - .filter((tool) => { - const wasOn = selectedAgentTools.includes(tool.name); - const isOn = pendingTools.includes(tool.name); - return wasOn !== isOn; - }) - .map((tool) => handleToggleTool(selectedAgent.id, tool.name)), - ); + await handleSetTools(selectedAgent.id, pendingTools); } finally { setSavingTools(false); setToolsEditing(false); @@ -575,15 +579,7 @@ const AgentsHomeView: React.FC = () => { } setSavingSkills(true); try { - await Promise.all( - availableSkills - .filter((skill) => { - const wasOn = selectedAgentSkills.includes(skill.name); - const isOn = pendingSkills.includes(skill.name); - return wasOn !== isOn; - }) - .map((skill) => handleToggleSkill(selectedAgent.id, skill.name)), - ); + await handleSetSkills(selectedAgent.id, pendingSkills); } finally { setSavingSkills(false); setSkillsEditing(false); diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx index 65df8c39..350f2d30 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx +++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.tsx @@ -17,6 +17,7 @@ interface AgentCardProps { agent: AgentWithCapabilities; index?: number; soloEnabled: boolean; + toolCount?: number; skillCount?: number; onToggleSolo: (agentId: string, enabled: boolean) => void; onOpenDetails: (agent: AgentWithCapabilities) => void; @@ -26,6 +27,7 @@ const AgentCard: React.FC = ({ agent, index = 0, soloEnabled, + toolCount, skillCount = 0, onToggleSolo, onOpenDetails, @@ -33,7 +35,7 @@ const AgentCard: React.FC = ({ const { t } = useTranslation('scenes/agents'); const badge = getAgentBadge(t, agent.agentKind, agent.subagentSource); const Icon = AGENT_ICON_MAP[(agent.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP] ?? Bot; - const totalTools = agent.toolCount ?? agent.defaultTools?.length ?? 0; + const totalTools = toolCount ?? agent.toolCount ?? agent.defaultTools?.length ?? 0; const openDetails = () => onOpenDetails(agent); return ( diff --git a/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx index d076dbd4..70c6de08 100644 --- a/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CoreAgentCard.tsx @@ -21,6 +21,7 @@ interface CoreAgentCardProps { agent: AgentWithCapabilities; index?: number; meta: CoreAgentMeta; + toolCount?: number; skillCount?: number; onOpenDetails: (agent: AgentWithCapabilities) => void; } @@ -29,12 +30,13 @@ const CoreAgentCard: React.FC = ({ agent, index = 0, meta, + toolCount, skillCount = 0, onOpenDetails, }) => { const { t } = useTranslation('scenes/agents'); const Icon = AGENT_ICON_MAP[(agent.iconKey ?? 'bot') as keyof typeof AGENT_ICON_MAP] ?? Bot; - const totalTools = agent.toolCount ?? agent.defaultTools?.length ?? 0; + const totalTools = toolCount ?? agent.toolCount ?? agent.defaultTools?.length ?? 0; const openDetails = () => onOpenDetails(agent); return ( diff --git a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts index 489e0677..d09cc01b 100644 --- a/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts +++ b/src/web-ui/src/app/scenes/agents/hooks/useAgentsList.ts @@ -113,20 +113,12 @@ export function useAgentsList({ if (!userConfig) { return { mode_id: agentId, - available_tools: defaultTools, + enabled_tools: defaultTools, enabled: true, default_tools: defaultTools, }; } - if (!userConfig.available_tools || userConfig.available_tools.length === 0) { - return { - ...userConfig, - available_tools: defaultTools, - default_tools: defaultTools, - }; - } - return { ...userConfig, default_tools: userConfig.default_tools ?? defaultTools, @@ -149,20 +141,14 @@ export function useAgentsList({ } }, [getModeConfig]); - const handleToggleTool = useCallback(async (agentId: string, toolName: string) => { - const config = getModeConfig(agentId); - if (!config) return; - - const tools = config.available_tools ?? []; - const isEnabling = !tools.includes(toolName); - const newTools = isEnabling ? [...tools, toolName] : tools.filter((tool) => tool !== toolName); - + const handleSetTools = useCallback(async (agentId: string, toolNames: string[]) => { try { - await saveModeConfig(agentId, { available_tools: newTools }); + const nextTools = Array.from(new Set(toolNames)); + await saveModeConfig(agentId, { enabled_tools: nextTools }); } catch { notification.error(t('agentsOverview.toolToggleFailed', '工具切换失败')); } - }, [getModeConfig, notification, saveModeConfig, t]); + }, [notification, saveModeConfig, t]); const handleResetTools = useCallback(async (agentId: string) => { try { @@ -182,20 +168,14 @@ export function useAgentsList({ } }, [notification, t]); - const handleToggleSkill = useCallback(async (agentId: string, skillName: string) => { - const config = getModeConfig(agentId); - if (!config) return; - - const skills = config.available_skills ?? []; - const isEnabling = !skills.includes(skillName); - const newSkills = isEnabling ? [...skills, skillName] : skills.filter((skill) => skill !== skillName); - + const handleSetSkills = useCallback(async (agentId: string, skillNames: string[]) => { try { - await saveModeConfig(agentId, { available_skills: newSkills }); + const nextSkills = Array.from(new Set(skillNames)); + await saveModeConfig(agentId, { available_skills: nextSkills }); } catch { notification.error(t('agentsOverview.skillToggleFailed', 'Skill 切换失败')); } - }, [getModeConfig, notification, saveModeConfig, t]); + }, [notification, saveModeConfig, t]); const filteredAgents = useMemo(() => allAgents.filter((agent) => { if (searchQuery) { @@ -241,9 +221,9 @@ export function useAgentsList({ counts, loadAgents, getModeConfig, - handleToggleTool, + handleSetTools, handleResetTools, - handleToggleSkill, + handleSetSkills, }; } diff --git a/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx index 8ce9dfef..64d566d2 100644 --- a/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx +++ b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx @@ -65,7 +65,7 @@ const NurseryGallery: React.FC = () => { setTemplateStats({ primaryModelName: resolveModelName('primary', t('nursery.template.stats.primaryDefault')), fastModelName: resolveModelName('fast', t('nursery.template.stats.fastDefault')), - enabledToolCount: modeConf?.available_tools?.length ?? 0, + enabledToolCount: modeConf?.enabled_tools?.length ?? 0, }); } catch (e) { log.error('Failed to load template stats', e); diff --git a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx index 25ad1a73..93c14adc 100644 --- a/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx +++ b/src/web-ui/src/app/scenes/profile/views/TemplateConfigPage.tsx @@ -117,7 +117,7 @@ const TemplateConfigPage: React.FC = () => { const [detail, setDetail] = useState(null); const enabledToolCount = useMemo( - () => agenticConfig?.available_tools?.length ?? 0, + () => agenticConfig?.enabled_tools?.length ?? 0, [agenticConfig], ); @@ -165,12 +165,12 @@ const TemplateConfigPage: React.FC = () => { ); const builtinToolsEnabled = useMemo( - () => builtinTools.filter((tool) => agenticConfig?.available_tools?.includes(tool.name)), + () => builtinTools.filter((tool) => agenticConfig?.enabled_tools?.includes(tool.name)), [builtinTools, agenticConfig], ); const builtinToolsDisabled = useMemo( - () => builtinTools.filter((tool) => !agenticConfig?.available_tools?.includes(tool.name)), + () => builtinTools.filter((tool) => !agenticConfig?.enabled_tools?.includes(tool.name)), [builtinTools, agenticConfig], ); @@ -268,10 +268,10 @@ const TemplateConfigPage: React.FC = () => { const handleToolToggle = useCallback(async (toolName: string) => { if (!agenticConfig) return; setToolsLoading((prev) => ({ ...prev, [toolName]: true })); - const current = agenticConfig.available_tools ?? []; + const current = agenticConfig.enabled_tools ?? []; const isEnabled = current.includes(toolName); const newTools = isEnabled ? current.filter((n) => n !== toolName) : [...current, toolName]; - const newConfig = { ...agenticConfig, available_tools: newTools }; + const newConfig = { ...agenticConfig, enabled_tools: newTools }; setAgenticConfig(newConfig); try { await configAPI.setModeConfig('agentic', newConfig); @@ -302,12 +302,12 @@ const TemplateConfigPage: React.FC = () => { const handleGroupToggleAll = useCallback(async (toolNames: string[]) => { if (!agenticConfig) return; - const current = agenticConfig.available_tools ?? []; + const current = agenticConfig.enabled_tools ?? []; const allEnabled = toolNames.every((n) => current.includes(n)); const newTools = allEnabled ? current.filter((n) => !toolNames.includes(n)) : [...new Set([...current, ...toolNames])]; - const newConfig = { ...agenticConfig, available_tools: newTools }; + const newConfig = { ...agenticConfig, enabled_tools: newTools }; setAgenticConfig(newConfig); try { await configAPI.setModeConfig('agentic', newConfig); @@ -403,7 +403,7 @@ const TemplateConfigPage: React.FC = () => { const renderToolList = (tools: ToolInfo[], isMcp: boolean) => (
{tools.map((tool) => { - const enabled = agenticConfig?.available_tools?.includes(tool.name) ?? false; + const enabled = agenticConfig?.enabled_tools?.includes(tool.name) ?? false; const displayName = isMcp ? getMcpShortName(tool.name) : tool.name; const selected = detail?.type === 'tool' && detail.tool.name === tool.name; return ( @@ -518,8 +518,8 @@ const TemplateConfigPage: React.FC = () => { serverStatus?: string, mcpServerId?: string, ) => { - const groupEnabled = toolNames.filter( - (n) => agenticConfig?.available_tools?.includes(n), + const groupEnabled = toolNames.filter( + (n) => agenticConfig?.enabled_tools?.includes(n), ).length; const isCollapsed = collapsedGroups.has(id); const allOn = toolNames.length > 0 && groupEnabled === toolNames.length; @@ -587,7 +587,7 @@ const TemplateConfigPage: React.FC = () => { if (detail.type === 'tool') { const { tool, isMcp } = detail; const displayName = isMcp ? getMcpShortName(tool.name) : tool.name; - const enabled = agenticConfig?.available_tools?.includes(tool.name) ?? false; + const enabled = agenticConfig?.enabled_tools?.includes(tool.name) ?? false; return (