From a3fe3e7520e160b813210e4ede7be965dae34f87 Mon Sep 17 00:00:00 2001 From: "bovmant.h" Date: Fri, 19 Jun 2026 14:58:01 +0800 Subject: [PATCH 1/3] feat(tui): add subagent feature toggle --- .../tui/src/commands/groups/config/config.rs | 133 +++++++++++++++++- crates/tui/src/config.rs | 24 ++-- crates/tui/src/config_persistence.rs | 75 ++++++++++ crates/tui/src/core/engine.rs | 47 ++----- crates/tui/src/core/engine/tests.rs | 86 +++++++++++ crates/tui/src/core/engine/tool_setup.rs | 43 ++++++ crates/tui/src/core/ops.rs | 4 + crates/tui/src/tui/app.rs | 6 + crates/tui/src/tui/ui.rs | 8 +- crates/tui/src/tui/views/mod.rs | 47 +++++-- docs/CONFIGURATION.md | 20 ++- docs/SUBAGENTS.md | 20 +++ 12 files changed, 452 insertions(+), 61 deletions(-) diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 2aee8b1d5..07c82e571 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -8,10 +8,11 @@ use crate::config::{ normalize_model_name_for_provider, }; use crate::config_persistence::{ - persist_provider_base_url_key, persist_root_bool_key, persist_root_string_key, - persist_tui_integer_key, + persist_feature_bool_key, persist_provider_base_url_key, persist_root_bool_key, + persist_root_string_key, persist_tui_integer_key, }; use crate::config_ui::{ConfigUiMode, parse_mode}; +use crate::features::Feature; use crate::localization::resolve_locale; use crate::settings::Settings; use crate::tui::app::{ @@ -126,6 +127,11 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { Some(app.model.clone()) } } + key if is_subagents_config_key(key) => { + return CommandResult::message(subagents_status_message( + app.features.enabled(Feature::Subagents), + )); + } "provider" => Some(app.api_provider.as_str().to_string()), "approval_mode" | "approval" => Some(app.approval_mode.label().to_string()), "allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()), @@ -427,11 +433,66 @@ fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { } } +fn is_subagents_config_key(key: &str) -> bool { + matches!( + key, + "subagents" | "sub-agents" | "features.subagents" | "feature.subagents" + ) +} + +fn subagents_status_message(enabled: bool) -> String { + let state = if enabled { "enabled" } else { "disabled" }; + format!("subagents = {enabled} ({state}; controls the model-facing agent tool)") +} + +fn set_subagents_feature(app: &mut App, enabled: bool, persist: bool) -> CommandResult { + if enabled { + app.features.enable(Feature::Subagents); + } else { + app.features.disable(Feature::Subagents); + } + + let suffix = if persist { + match persist_feature_bool_key(app.config_path.as_deref(), "subagents", enabled) { + Ok(path) => format!(" (saved to {})", path.display()), + Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + } + } else { + " (session only, add --save to persist)".to_string() + }; + let tool_hint = if enabled { + " The agent tool will be available on the next turn." + } else { + " The agent tool will be hidden on the next turn." + }; + + CommandResult::with_message_and_action( + format!("subagents = {enabled}{suffix}.{tool_hint}"), + AppAction::UpdateFeatures(app.features.clone()), + ) +} + /// Modify a setting at runtime pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { let key = key.to_lowercase(); match key.as_str() { + key if is_subagents_config_key(key) => { + let value = value.trim(); + if matches!( + value.to_ascii_lowercase().as_str(), + "" | "status" | "show" | "get" + ) { + return CommandResult::message(subagents_status_message( + app.features.enabled(Feature::Subagents), + )); + } + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(err) => return CommandResult::error(err), + }; + return set_subagents_feature(app, enabled, persist); + } "model" => { // Support "/model auto" — auto-select model based on request complexity if value.trim().eq_ignore_ascii_case("auto") { @@ -1173,6 +1234,7 @@ mod tests { use std::path::Path; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; + use tempfile::tempdir; struct EnvGuard { home: Option, @@ -1651,6 +1713,73 @@ mod tests { assert!(msg.contains("Failed to parse boolean 'maybe'")); } + #[test] + fn config_command_subagents_off_updates_session_features() { + let mut app = create_test_app(); + assert!(app.features.enabled(Feature::Subagents)); + + let result = config_command(&mut app, Some("subagents off")); + + assert!(!result.is_error); + assert!(!app.features.enabled(Feature::Subagents)); + assert!(matches!( + result.action, + Some(AppAction::UpdateFeatures(ref features)) + if !features.enabled(Feature::Subagents) + )); + let msg = result.message.unwrap(); + assert!(msg.contains("subagents = false")); + assert!(msg.contains("session only")); + assert!(msg.contains("agent tool")); + assert!(msg.contains("next turn")); + } + + #[test] + fn config_command_subagents_status_reports_effective_state() { + let mut app = create_test_app(); + app.features.disable(Feature::Subagents); + + let result = config_command(&mut app, Some("subagents status")); + + assert!(!result.is_error); + assert!(result.action.is_none()); + let msg = result.message.unwrap(); + assert!(msg.contains("subagents = false")); + assert!(msg.contains("disabled")); + } + + #[test] + fn config_command_subagents_save_persists_features_table() { + let temp_root = tempdir().expect("tempdir"); + let config_path = temp_root.path().join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + + let disabled = config_command(&mut app, Some("subagents off --save")); + assert!(!disabled.is_error); + assert!(!app.features.enabled(Feature::Subagents)); + let saved = fs::read_to_string(&config_path).unwrap(); + assert!(saved.contains("[features]")); + assert!(saved.contains("subagents = false")); + let reloaded = Config::load(Some(config_path.clone()), None).unwrap(); + assert!(!reloaded.features().enabled(Feature::Subagents)); + + let enabled = config_command(&mut app, Some("subagents on --save")); + assert!(!enabled.is_error); + assert!(app.features.enabled(Feature::Subagents)); + let saved = fs::read_to_string(&config_path).unwrap(); + assert!(saved.contains("subagents = true")); + let reloaded = Config::load(Some(config_path.clone()), None).unwrap(); + assert!(reloaded.features().enabled(Feature::Subagents)); + + let direct_key = config_command(&mut app, Some("features.subagents false --save")); + assert!(!direct_key.is_error); + assert!(!app.features.enabled(Feature::Subagents)); + let reloaded = Config::load(Some(config_path), None).unwrap(); + assert!(!reloaded.features().enabled(Feature::Subagents)); + } + #[test] fn config_command_base_url_without_save_requires_save() { let _lock = lock_test_env(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 179f8a8e1..7a6597240 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1780,12 +1780,12 @@ pub struct SubagentsConfig { #[serde(default)] pub max_concurrent: Option, /// How many levels of nested sub-agents the interactive `agent` tool may - /// spawn. `0` disables sub-agents entirely — the `agent` tool refuses to - /// spawn, a full opt-out; `1` allows one level, `2` two, and so on. When - /// unset, defaults to [`codewhale_config::DEFAULT_SPAWN_DEPTH`]; any value - /// is clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`]. Fleet - /// workers are governed separately by `[fleet.exec] max_spawn_depth`; both - /// share the same default and ceiling so the limit cannot drift. + /// spawn. `0` blocks spawning at the depth check while leaving the global + /// `features.subagents` flag untouched; `1` allows one level, `2` two, and + /// so on. When unset, defaults to [`codewhale_config::DEFAULT_SPAWN_DEPTH`]; + /// any value is clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`]. + /// Fleet workers are governed separately by `[fleet.exec] max_spawn_depth`; + /// both share the same default and ceiling so the limit cannot drift. #[serde(default)] pub max_depth: Option, /// Number of direct (depth-1) sub-agents that may execute concurrently @@ -3175,9 +3175,10 @@ impl Config { /// How many levels of nested sub-agents the interactive `agent` tool may /// spawn. Reads `[subagents] max_depth`; when unset it defaults to /// [`codewhale_config::DEFAULT_SPAWN_DEPTH`]. `0` is a valid value that - /// disables sub-agent spawning entirely (full opt-out). Any value is - /// clamped to [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`] so the - /// operator's choice can never exceed the hard recursion ceiling. + /// blocks spawning at the depth check while leaving the global + /// `features.subagents` flag untouched. Any value is clamped to + /// [`codewhale_config::MAX_SPAWN_DEPTH_CEILING`] so the operator's choice + /// can never exceed the hard recursion ceiling. #[must_use] pub fn subagent_max_spawn_depth(&self) -> u32 { self.subagents @@ -7387,6 +7388,11 @@ action = "session.compact" ..Config::default() }; assert_eq!(disabled.subagent_max_spawn_depth(), 0); + assert!( + disabled + .features() + .enabled(crate::features::Feature::Subagents) + ); let high = Config { subagents: Some(SubagentsConfig { diff --git a/crates/tui/src/config_persistence.rs b/crates/tui/src/config_persistence.rs index 022b958f2..cd9108a84 100644 --- a/crates/tui/src/config_persistence.rs +++ b/crates/tui/src/config_persistence.rs @@ -126,6 +126,49 @@ pub(crate) fn persist_root_bool_key( Ok(path) } +pub(crate) fn persist_feature_bool_key( + config_path: Option<&Path>, + key: &str, + value: bool, +) -> anyhow::Result { + use anyhow::Context; + use std::fs; + + let path = config_toml_path(config_path)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("failed to create config directory {}", parent.display()))?; + } + + let (mut doc, original_raw) = if path.exists() { + let raw = fs::read_to_string(&path) + .with_context(|| format!("failed to read config at {}", path.display()))?; + let doc: toml::Value = toml::from_str(&raw) + .with_context(|| format!("failed to parse config at {}", path.display()))?; + (doc, Some(raw)) + } else { + (toml::Value::Table(toml::value::Table::new()), None) + }; + let table = doc + .as_table_mut() + .context("config.toml root must be a table")?; + let features_entry = table + .entry("features".to_string()) + .or_insert_with(|| toml::Value::Table(toml::value::Table::new())); + let features = features_entry + .as_table_mut() + .context("`features` section in config.toml must be a table")?; + features.insert(key.to_string(), toml::Value::Boolean(value)); + if let Some(raw) = original_raw { + save_toml_preserving_comments(&path, &doc, &raw)?; + } else { + let body = toml::to_string_pretty(&doc).context("failed to serialize config.toml")?; + fs::write(&path, body) + .with_context(|| format!("failed to write config at {}", path.display()))?; + } + Ok(path) +} + pub(crate) fn persist_tui_integer_key( config_path: Option<&Path>, key: &str, @@ -537,4 +580,36 @@ mod tests { "new key not written: {body}" ); } + + #[test] + fn persist_feature_bool_key_writes_features_table_and_preserves_comments() { + let temp_root = temp_root("codewhale-persist-feature-comments"); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write( + &path, + "# my note\nmodel = \"deepseek-v4-flash\"\n[features]\n# keep this nearby\nweb_search = true\n", + ) + .unwrap(); + + let written = persist_feature_bool_key(Some(&path), "subagents", false) + .expect("persist should succeed"); + let body = fs::read_to_string(&written).expect("written file should be readable"); + assert!(body.contains("# my note"), "prefix comment lost: {body}"); + assert!( + body.contains("# keep this nearby"), + "features comment lost: {body}" + ); + assert!( + body.contains("web_search = true"), + "existing feature lost: {body}" + ); + assert!( + body.contains("subagents = false"), + "subagent feature not written: {body}" + ); + } } diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 2893bebd0..4ca93ad2a 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1437,6 +1437,13 @@ impl Engine { ))) .await; } + Op::SetFeatures { features } => { + self.config.features = features; + let _ = self + .tx_event + .send(Event::status("Feature flags updated".to_string())) + .await; + } Op::SetStreamChunkTimeout { timeout_secs } => { self.config.stream_chunk_timeout = Duration::from_secs(timeout_secs); let _ = self @@ -2106,40 +2113,12 @@ impl Engine { let mut tool_registry = match mode { AppMode::Agent | AppMode::Yolo => { if self.config.features.enabled(Feature::Subagents) { - let runtime = if let Some(client) = self.deepseek_client.clone() { - let mut rt = SubAgentRuntime::new( - client, - self.session.model.clone(), - tool_context.clone(), - self.session.allow_shell, - Some(self.tx_event.clone()), - Arc::clone(&self.subagent_manager), - ) - .with_role_models(self.config.subagent_model_overrides.clone()) - .with_auto_model(self.session.auto_model) - .with_reasoning_effort( - self.session.reasoning_effort.clone(), - self.session.reasoning_effort_auto, - ) - .with_max_spawn_depth(self.config.max_spawn_depth) - .with_step_api_timeout(self.config.subagent_api_timeout) - .with_speech_output_dir(self.config.speech_output_dir.clone()) - .with_mcp_pool(mcp_pool.clone()) - .with_todos(self.config.todos.clone()) - .with_parent_completion_tx(self.tx_subagent_completion.clone()); - if let Some(context) = fork_context_for_runtime.clone() { - rt = rt.with_fork_context(context); - } - if let Some((mailbox, cancel_token)) = mailbox_for_runtime.as_ref() { - rt = rt - .with_mailbox(mailbox.clone()) - .with_cancel_token(cancel_token.clone()); - } - Some(rt) - } else { - None - }; - if let Some(subagent_runtime) = runtime { + if let Some(subagent_runtime) = self.build_parent_subagent_runtime( + tool_context.clone(), + mcp_pool.clone(), + fork_context_for_runtime.clone(), + mailbox_for_runtime.as_ref(), + ) { Some( builder .with_subagent_tools( diff --git a/crates/tui/src/core/engine/tests.rs b/crates/tui/src/core/engine/tests.rs index 1de2fb23a..bd5b84bbe 100644 --- a/crates/tui/src/core/engine/tests.rs +++ b/crates/tui/src/core/engine/tests.rs @@ -1748,6 +1748,61 @@ fn parent_turn_registry_includes_goal_tools_for_all_modes() { } } +fn parent_agent_registry_contains_agent_tool(engine: &Engine, mode: AppMode) -> bool { + let tool_context = engine.build_tool_context(mode, false); + let mut builder = engine.build_turn_tool_registry_builder( + mode, + engine.config.todos.clone(), + engine.config.plan_state.clone(), + ); + if let Some(runtime) = + engine.build_parent_subagent_runtime(tool_context.clone(), None, None, None) + { + builder = builder.with_subagent_tools(engine.subagent_manager.clone(), runtime); + } + builder.build(tool_context).contains("agent") +} + +#[test] +fn parent_agent_tool_registry_obeys_subagent_feature_gate() { + let tmp = tempdir().expect("tempdir"); + let api_config = Config { + api_key: Some("test-key".to_string()), + ..Config::default() + }; + + let enabled_config = EngineConfig { + workspace: tmp.path().join("enabled"), + ..EngineConfig::default() + }; + let (enabled_engine, _handle) = Engine::new(enabled_config, &api_config); + assert!(parent_agent_registry_contains_agent_tool( + &enabled_engine, + AppMode::Agent + )); + assert!(parent_agent_registry_contains_agent_tool( + &enabled_engine, + AppMode::Yolo + )); + + let mut features = Features::with_defaults(); + features.disable(Feature::Subagents); + let disabled_config = EngineConfig { + workspace: tmp.path().join("disabled"), + features, + ..EngineConfig::default() + }; + let (disabled_engine, _handle) = Engine::new(disabled_config, &api_config); + assert!(!parent_agent_registry_contains_agent_tool( + &disabled_engine, + AppMode::Agent + )); + assert!(!parent_agent_registry_contains_agent_tool( + &disabled_engine, + AppMode::Yolo + )); +} + #[test] fn agent_mode_can_build_auto_approved_tool_context() { let (engine, _handle) = Engine::new(EngineConfig::default(), &Config::default()); @@ -2127,6 +2182,37 @@ async fn change_mode_op_updates_current_mode_and_emits_status() { run.abort(); } +#[tokio::test] +async fn set_features_op_emits_runtime_update_status() { + let tmp = tempdir().expect("tempdir"); + let config = EngineConfig { + workspace: tmp.path().to_path_buf(), + model: "deepseek-v4-pro".to_string(), + ..Default::default() + }; + let (engine, handle) = Engine::new(config, &Config::default()); + let mut features = Features::with_defaults(); + features.disable(Feature::Subagents); + + let run = tokio::spawn(engine.run()); + handle + .send(Op::SetFeatures { features }) + .await + .expect("send feature update"); + + let mut rx = handle.rx_event.write().await; + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .expect("status after feature update") + .expect("event"); + let Event::Status { message } = event else { + panic!("expected Status after feature update, got: {event:?}"); + }; + assert_eq!(message, "Feature flags updated"); + + run.abort(); +} + #[test] fn detects_context_length_errors_from_provider_payloads() { let msg = r#"SSE stream request failed: HTTP 400 Bad Request: {"error":{"message":"This model's maximum context length is 131072 tokens. However, you requested 153056 tokens (148960 in the messages, 4096 in the completion).","type":"invalid_request_error"}}"#; diff --git a/crates/tui/src/core/engine/tool_setup.rs b/crates/tui/src/core/engine/tool_setup.rs index 8c5f83b6a..3017da40d 100644 --- a/crates/tui/src/core/engine/tool_setup.rs +++ b/crates/tui/src/core/engine/tool_setup.rs @@ -52,6 +52,49 @@ pub(crate) fn shell_policy_for_mode(mode: AppMode, allow_shell: bool) -> ShellPo } impl Engine { + pub(super) fn build_parent_subagent_runtime( + &self, + tool_context: ToolContext, + mcp_pool: Option>>, + fork_context: Option, + mailbox_for_runtime: Option<&(Mailbox, CancellationToken)>, + ) -> Option { + if !self.config.features.enabled(Feature::Subagents) { + return None; + } + let client = self.deepseek_client.clone()?; + let mut runtime = SubAgentRuntime::new( + client, + self.session.model.clone(), + tool_context, + self.session.allow_shell, + Some(self.tx_event.clone()), + Arc::clone(&self.subagent_manager), + ) + .with_role_models(self.config.subagent_model_overrides.clone()) + .with_auto_model(self.session.auto_model) + .with_reasoning_effort( + self.session.reasoning_effort.clone(), + self.session.reasoning_effort_auto, + ) + .with_max_spawn_depth(self.config.max_spawn_depth) + .with_step_api_timeout(self.config.subagent_api_timeout) + .with_speech_output_dir(self.config.speech_output_dir.clone()) + .with_mcp_pool(mcp_pool) + .with_todos(self.config.todos.clone()) + .with_parent_completion_tx(self.tx_subagent_completion.clone()); + + if let Some(context) = fork_context { + runtime = runtime.with_fork_context(context); + } + if let Some((mailbox, cancel_token)) = mailbox_for_runtime { + runtime = runtime + .with_mailbox(mailbox.clone()) + .with_cancel_token(cancel_token.clone()); + } + Some(runtime) + } + pub(super) fn build_turn_tool_registry_builder( &self, mode: AppMode, diff --git a/crates/tui/src/core/ops.rs b/crates/tui/src/core/ops.rs index 3ca444b55..43633accd 100644 --- a/crates/tui/src/core/ops.rs +++ b/crates/tui/src/core/ops.rs @@ -5,6 +5,7 @@ use crate::compaction::CompactionConfig; use crate::config::ApiProvider; +use crate::features::Features; use crate::models::{Message, SystemPrompt}; use crate::tools::goal::GoalStatus; use crate::tui::app::AppMode; @@ -118,6 +119,9 @@ pub enum Op { /// Update auto-compaction settings SetCompaction { config: CompactionConfig }, + /// Update feature flags used for subsequent turns. + SetFeatures { features: Features }, + /// Update the SSE idle timeout used for subsequent streamed turns. SetStreamChunkTimeout { timeout_secs: u64 }, diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 207e468f1..cc9dd016c 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -18,6 +18,7 @@ use crate::config::{ ApiProvider, Config, DEFAULT_TEXT_MODEL, SavedCredential, has_api_key, save_api_key, }; use crate::config_ui::ConfigUiMode; +use crate::features::Features; use crate::hooks::{HookContext, HookEvent, HookExecutor, HookResult}; use crate::localization::{Locale, MessageId, resolve_locale, tr}; use crate::models::{ @@ -1598,6 +1599,9 @@ pub struct App { pub allow_shell: bool, pub verbosity: Option, pub max_subagents: usize, + /// Effective feature flags for this session. Starts from config and can be + /// changed live by `/config` for subsequent engine turns. + pub features: Features, /// Per-SSE-chunk idle timeout for streamed turns, in seconds. pub stream_chunk_timeout_secs: u64, /// Cached sub-agent snapshots for UI views. @@ -2425,6 +2429,7 @@ impl App { allow_shell, verbosity: config.verbosity.clone(), max_subagents, + features: config.features(), stream_chunk_timeout_secs: config.stream_chunk_timeout_secs(), subagent_cache: Vec::new(), subagent_terminal_seen_at: HashMap::new(), @@ -5569,6 +5574,7 @@ pub enum AppAction { model: Option, }, UpdateCompaction(CompactionConfig), + UpdateFeatures(Features), UpdateStreamChunkTimeout(u64), OpenContextInspector, CompactContext, diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 51a2e8738..f023fbe0c 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -1088,7 +1088,7 @@ fn build_engine_config(app: &App, config: &Config) -> EngineConfig { max_steps: u32::MAX, max_subagents: app.max_subagents, launch_concurrency: config.launch_concurrency(), - features: config.features(), + features: app.features.clone(), compaction: app.compaction_config(), todos: app.todos.clone(), plan_state: app.plan_state.clone(), @@ -7122,6 +7122,9 @@ async fn apply_command_result( AppAction::UpdateCompaction(compaction) => { apply_model_and_compaction_update(engine_handle, compaction, app.mode).await; } + AppAction::UpdateFeatures(features) => { + let _ = engine_handle.send(Op::SetFeatures { features }).await; + } AppAction::UpdateStreamChunkTimeout(timeout_secs) => { let _ = engine_handle .send(Op::SetStreamChunkTimeout { timeout_secs }) @@ -8829,6 +8832,9 @@ async fn handle_view_events( apply_model_and_compaction_update(engine_handle, compaction, app.mode) .await; } + AppAction::UpdateFeatures(features) => { + let _ = engine_handle.send(Op::SetFeatures { features }).await; + } AppAction::UpdateStreamChunkTimeout(timeout_secs) => { let _ = engine_handle .send(Op::SetStreamChunkTimeout { timeout_secs }) diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 35c293a2f..2951d5db3 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -4,7 +4,7 @@ use std::cell::{Cell, RefCell}; use std::fmt; use crate::config::{ApiProvider, Config}; -use crate::features::{FEATURES, Stage}; +use crate::features::{FEATURES, Feature, Stage}; use crate::localization::{Locale, MessageId, tr}; use crate::palette; use crate::settings::Settings; @@ -1186,7 +1186,7 @@ fn experimental_config_rows(config: &Config) -> Vec { spec.default_enabled, configured_value.is_some(), ), - editable: false, + editable: spec.id == Feature::Subagents, scope: ConfigScope::Saved, }); } @@ -1259,6 +1259,7 @@ fn config_hint_for_key(key: &str) -> &'static str { "fleet.exec.max_spawn_depth" => { "0 blocks child agents; 3 default (same axis as sub-agents); capped at 3" } + "features.subagents" => "on/off, true/false; controls the model-facing agent tool", _ => "", } } @@ -2484,14 +2485,15 @@ mod tests { assert!( view.rows .iter() - .filter(|row| { - matches!( - row.section, - super::ConfigSection::Experimental | super::ConfigSection::Fleet - ) - }) + .filter(|row| { matches!(row.section, super::ConfigSection::Fleet) }) .all(|row| !row.editable) ); + assert!( + view.rows + .iter() + .filter(|row| matches!(row.section, super::ConfigSection::Experimental)) + .all(|row| row.editable == (row.key == "features.subagents")) + ); } #[test] @@ -2538,6 +2540,35 @@ vision_model = true .find(|row| row.key == "features.subagents") .expect("subagents feature row"); assert_eq!(subagents.value, "enabled (default enabled)"); + assert!(subagents.editable); + } + + #[test] + fn config_view_makes_only_subagents_experimental_feature_editable() { + let app = create_test_app(); + let view = ConfigView::new_for_app(&app); + + let subagents = view + .rows + .iter() + .find(|row| row.key == "features.subagents") + .expect("subagents feature row"); + assert!(subagents.editable); + assert_eq!(subagents.scope, super::ConfigScope::Saved); + + let read_only_experimental = view + .rows + .iter() + .filter(|row| { + matches!(row.section, super::ConfigSection::Experimental) + && row.key != "features.subagents" + }) + .collect::>(); + assert!( + !read_only_experimental.is_empty(), + "test should cover non-subagent experimental rows" + ); + assert!(read_only_experimental.iter().all(|row| !row.editable)); } #[test] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 16dce5cda..b4d263574 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1211,13 +1211,19 @@ You can also override features for a single run: - `codewhale-tui --disable subagents` Use `codewhale-tui features list` to inspect known flags and their effective state. -The native `/config` view also includes a read-only **Experimental** section -for experimental feature flags. It shows each flag's effective enabled/disabled -state and whether that state comes from the default or a configured override. -Change feature flags in `[features]` or with `--enable` / `--disable`; the -`/config` section is an audit surface, not a stability promise. Goal and -WhaleFlow preview rows may appear there as placeholders until those workflows -graduate behind real gated flags. +`subagents` also has first-class TUI controls: + +- `/config subagents status` reports the current effective state. +- `/config subagents off` or `/config subagents on` changes the current session. +- Add `--save` to persist the state under `[features]`, for example + `/config subagents off --save`. +- `/config features.subagents false --save` writes the same canonical key. + +The native `/config` view includes an **Experimental** section for feature flags. +It makes `features.subagents` editable and keeps unrelated experimental flags +read-only unless they have their own editing contract. Goal and WhaleFlow preview +rows may appear there as placeholders until those workflows graduate behind real +gated flags. ## Web Search Provider diff --git a/docs/SUBAGENTS.md b/docs/SUBAGENTS.md index bbd371cca..0b7cb8c7a 100644 --- a/docs/SUBAGENTS.md +++ b/docs/SUBAGENTS.md @@ -179,6 +179,26 @@ All matching is case-insensitive. Unknown values produce a typed error listing the accepted set, so the model can self-correct on the next turn. +## Global opt-out + +The canonical on/off switch is the `subagents` feature flag. Disable it for one +launch with `codewhale-tui --disable subagents`, for the current TUI session +with `/config subagents off`, or persist it with `/config subagents off --save` +or: + +```toml +[features] +subagents = false +``` + +When this feature flag is disabled, new Agent and YOLO turns do not expose the +model-facing `agent` tool. + +`[subagents] max_depth = 0` is a depth limit, not the global feature switch. It +leaves the feature state enabled and blocks spawning at the depth check instead. +Concurrency controls such as `[subagents] max_concurrent` and +`[subagents] launch_concurrency` only matter while the feature flag is enabled. + ## Concurrency cap Up to **20** sub-agents run concurrently by default (configurable via From 077d3d3a69b9e47b8eea4034585eda9a70d6de47 Mon Sep 17 00:00:00 2001 From: "bovmant.h" Date: Fri, 19 Jun 2026 15:10:09 +0800 Subject: [PATCH 2/3] refactor: apply Gemini code review suggestions - Reorder set_subagents_feature to persist before updating in-memory state - Use eq_ignore_ascii_case to avoid heap allocation in value parsing --- .../tui/src/commands/groups/config/config.rs | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 07c82e571..b3d2c51ef 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -446,12 +446,6 @@ fn subagents_status_message(enabled: bool) -> String { } fn set_subagents_feature(app: &mut App, enabled: bool, persist: bool) -> CommandResult { - if enabled { - app.features.enable(Feature::Subagents); - } else { - app.features.disable(Feature::Subagents); - } - let suffix = if persist { match persist_feature_bool_key(app.config_path.as_deref(), "subagents", enabled) { Ok(path) => format!(" (saved to {})", path.display()), @@ -460,6 +454,13 @@ fn set_subagents_feature(app: &mut App, enabled: bool, persist: bool) -> Command } else { " (session only, add --save to persist)".to_string() }; + + if enabled { + app.features.enable(Feature::Subagents); + } else { + app.features.disable(Feature::Subagents); + } + let tool_hint = if enabled { " The agent tool will be available on the next turn." } else { @@ -479,10 +480,11 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> match key.as_str() { key if is_subagents_config_key(key) => { let value = value.trim(); - if matches!( - value.to_ascii_lowercase().as_str(), - "" | "status" | "show" | "get" - ) { + if value.is_empty() + || value.eq_ignore_ascii_case("status") + || value.eq_ignore_ascii_case("show") + || value.eq_ignore_ascii_case("get") + { return CommandResult::message(subagents_status_message( app.features.enabled(Feature::Subagents), )); From 45ad1eb3aed2edf6aae4c59f70a03acf4a1aa337 Mon Sep 17 00:00:00 2001 From: "bovmant.h" Date: Fri, 19 Jun 2026 15:19:05 +0800 Subject: [PATCH 3/3] refactor: improve subagents key matching robustness - Use to_ascii_lowercase() for case-insensitive matching - Add features.sub-agents and feature.sub-agents variants --- crates/tui/src/commands/groups/config/config.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index b3d2c51ef..810ab54af 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -435,8 +435,13 @@ fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { fn is_subagents_config_key(key: &str) -> bool { matches!( - key, - "subagents" | "sub-agents" | "features.subagents" | "feature.subagents" + key.to_ascii_lowercase().as_str(), + "subagents" + | "sub-agents" + | "features.subagents" + | "features.sub-agents" + | "feature.subagents" + | "feature.sub-agents" ) }