diff --git a/config.example.toml b/config.example.toml index ffc1da282e..a0430c5cb9 100644 --- a/config.example.toml +++ b/config.example.toml @@ -769,9 +769,11 @@ exponential_base = 2.0 # ───────────────────────────────────────────────────────────────────────────────── # Auto-compaction is a saved UI setting edited with `/config` (`auto_compact`). # The optional saved threshold setting is `auto_compact_threshold_percent` -# (default 80). There is no config-file -# `[compaction]` table yet; runtime compaction budgets are chosen by the TUI -# from the active model/context window. +# (default 80). `[compaction].enabled` is the engine-level replacement +# compaction switch; when unset, the engine keeps using the saved auto_compact +# behavior. +[compaction] +# enabled = true # Append-only Flash seams are experimental and opt-in while the v0.7.5 # context/cache audit validates prefix-cache behavior. @@ -785,6 +787,11 @@ l2_threshold = 384000 l3_threshold = 576000 seam_model = "deepseek-v4-flash" +# Optional explicit alias for the context seam manager master switch. When set, +# this overrides `[context].enabled`; thresholds and model stay under [context]. +# [seam_manager] +# enabled = false + # ───────────────────────────────────────────────────────────────────────────────── # Workshop / Large-Output Routing (#548) # ───────────────────────────────────────────────────────────────────────────────── diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 94672fe8c3..7856d46aae 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1682,6 +1682,22 @@ pub struct ContextConfig { pub seam_model: Option, } +/// Explicit Flash seam-manager switch. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct SeamManagerConfig { + /// Overrides `[context].enabled` when set. Default: inherit context.enabled. + #[serde(default)] + pub enabled: Option, +} + +/// Engine replacement-compaction switch. +#[derive(Debug, Clone, Deserialize, Default)] +pub struct CompactionRuntimeConfig { + /// Overrides the settings-derived auto-compaction default when set. + #[serde(default)] + pub enabled: Option, +} + /// Sub-agent model overrides. Keys in `models` can be role names (`worker`, /// `explorer`, `awaiter`) or type names (`general`, `explore`, `plan`, /// `review`, `custom`). Per-call explicit model choices still win. @@ -1998,6 +2014,16 @@ pub struct Config { #[serde(default)] pub context: ContextConfig, + /// Explicit Flash seam-manager switch (#3765). This is a narrow alias for + /// `[context].enabled`; threshold/model fields stay under `[context]`. + #[serde(default, alias = "seamManager")] + pub seam_manager: SeamManagerConfig, + + /// Engine replacement-compaction switch (#3765). When unset, the runtime + /// keeps using the settings-derived `auto_compact` behavior. + #[serde(default)] + pub compaction: CompactionRuntimeConfig, + /// Agent Fleet trust/security/role/exec config. #[serde(default)] pub fleet: Option, @@ -3640,6 +3666,19 @@ impl Config { self.context.project_pack.unwrap_or(true) } + #[must_use] + pub fn seam_manager_enabled(&self) -> bool { + self.seam_manager + .enabled + .or(self.context.enabled) + .unwrap_or(false) + } + + #[must_use] + pub fn compaction_enabled(&self, settings_default: bool) -> bool { + self.compaction.enabled.unwrap_or(settings_default) + } + /// Return whether shell execution is allowed. Defaults to `false`: shell /// access must be opted into explicitly (GHSA-72w5-pf8h-xfp4). #[must_use] @@ -5536,6 +5575,15 @@ fn merge_config(base: Config, override_cfg: Config) -> Config { .or(base.context.l3_threshold), seam_model: override_cfg.context.seam_model.or(base.context.seam_model), }, + seam_manager: SeamManagerConfig { + enabled: override_cfg + .seam_manager + .enabled + .or(base.seam_manager.enabled), + }, + compaction: CompactionRuntimeConfig { + enabled: override_cfg.compaction.enabled.or(base.compaction.enabled), + }, fleet: override_cfg.fleet.or(base.fleet), subagents: override_cfg.subagents.or(base.subagents), strict_tool_mode: override_cfg.strict_tool_mode.or(base.strict_tool_mode), diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 1ca12a4f12..4064648a05 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -3509,6 +3509,7 @@ fn normalize_model_name_accepts_provider_prefixed_deepseek_ids() { fn default_context_seams_are_opt_in() { let config = Config::default(); assert!(!config.context.enabled.unwrap_or(false)); + assert!(!config.seam_manager_enabled()); assert_eq!(config.context.l1_threshold.unwrap_or(192_000), 192_000); assert_eq!( config @@ -3520,6 +3521,57 @@ fn default_context_seams_are_opt_in() { ); } +#[test] +fn seam_manager_enabled_can_use_dedicated_table() -> Result<()> { + let config: Config = toml::from_str( + r#" + [context] + enabled = true + + [seam_manager] + enabled = false + "#, + )?; + + assert_eq!(config.context.enabled, Some(true)); + assert_eq!(config.seam_manager.enabled, Some(false)); + assert!(!config.seam_manager_enabled()); + + let config: Config = toml::from_str( + r#" + [seam_manager] + enabled = true + "#, + )?; + + assert!(config.seam_manager_enabled()); + Ok(()) +} + +#[test] +fn compaction_enabled_uses_config_override_when_present() -> Result<()> { + let config = Config::default(); + assert!(config.compaction_enabled(true)); + assert!(!config.compaction_enabled(false)); + + let disabled: Config = toml::from_str( + r#" + [compaction] + enabled = false + "#, + )?; + assert!(!disabled.compaction_enabled(true)); + + let enabled: Config = toml::from_str( + r#" + [compaction] + enabled = true + "#, + )?; + assert!(enabled.compaction_enabled(false)); + Ok(()) +} + #[test] fn profile_without_context_does_not_disable_base_context() { let mut profiles = HashMap::new(); @@ -3539,6 +3591,28 @@ fn profile_without_context_does_not_disable_base_context() { assert_eq!(merged.context.enabled, Some(true)); } +#[test] +fn profile_without_context_gates_does_not_disable_base_gates() { + let mut profiles = HashMap::new(); + profiles.insert("work".to_string(), Config::default()); + let config = ConfigFile { + base: Config { + seam_manager: SeamManagerConfig { + enabled: Some(true), + }, + compaction: CompactionRuntimeConfig { + enabled: Some(false), + }, + ..Default::default() + }, + profiles: Some(profiles), + }; + + let merged = apply_profile(config, Some("work")).expect("profile"); + assert_eq!(merged.seam_manager.enabled, Some(true)); + assert_eq!(merged.compaction.enabled, Some(false)); +} + #[test] fn profile_skills_config_merges_individual_fields() { let mut profiles = HashMap::new(); diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index 7e4e7bbb5f..2f5bf572bd 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -658,6 +658,7 @@ fn validate_document(doc: &ConfigUiDocument) -> Result<()> { fn reload_runtime_config(app: &mut App, config: &mut Config) -> Result<()> { let reloaded = Config::load(app.config_path.clone(), app.config_profile.as_deref())?; *config = reloaded.clone(); + app.refresh_config_runtime_overrides(config); app.api_provider = reloaded.api_provider(); app.reasoning_effort = ReasoningEffort::from_setting(reloaded.reasoning_effort().unwrap_or_else(|| { diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index db0765d3bd..6db674efb7 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -916,7 +916,7 @@ impl Engine { // is worth the extra request and transcript mutation. let seam_manager = deepseek_client.as_ref().map(|main_client| { let seam_config = SeamConfig { - enabled: api_config.context.enabled.unwrap_or(false), + enabled: api_config.seam_manager_enabled(), verbatim_window_turns: api_config .context .verbatim_window_turns diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index eb0e70f8fb..00a995bc62 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -7027,7 +7027,7 @@ async fn run_exec_agent( ) }; let compaction = CompactionConfig { - enabled: auto_compact_enabled, + enabled: execution_config.compaction_enabled(auto_compact_enabled), model: effective_model.clone(), token_threshold: crate::route_budget::compaction_threshold_for_route_at_percent( effective_provider, diff --git a/crates/tui/src/runtime_threads.rs b/crates/tui/src/runtime_threads.rs index 632de12e9b..d795d8c48d 100644 --- a/crates/tui/src/runtime_threads.rs +++ b/crates/tui/src/runtime_threads.rs @@ -874,7 +874,7 @@ impl RuntimeThreadManager { ) }; let compaction = crate::compaction::CompactionConfig { - enabled: auto_compact_enabled, + enabled: cfg.compaction_enabled(auto_compact_enabled), model: String::new(), // per-engine, filled below token_threshold: compaction_threshold_for_model_at_percent( &cfg.default_text_model.clone().unwrap_or_default(), @@ -2506,7 +2506,7 @@ impl RuntimeThreadManager { auto_compact_default_for_model(&thread.model) }; let compaction = CompactionConfig { - enabled: auto_compact_enabled, + enabled: cfg.compaction_enabled(auto_compact_enabled), model: thread.model.clone(), token_threshold: compaction_threshold_for_model_at_percent( &thread.model, diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 9157275964..9e18bae425 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1696,6 +1696,7 @@ pub struct App { #[allow(dead_code)] pub system_prompt: Option, pub auto_compact: bool, + pub compaction_enabled_override: Option, pub auto_compact_user_configured: bool, pub auto_compact_threshold_percent: f64, pub calm_mode: bool, @@ -2628,6 +2629,7 @@ impl App { bracketed_paste_seen: false, system_prompt: None, auto_compact, + compaction_enabled_override: config.compaction.enabled, auto_compact_user_configured, auto_compact_threshold_percent, calm_mode, @@ -5692,13 +5694,22 @@ impl App { pub fn compaction_config(&self) -> CompactionConfig { CompactionConfig { - enabled: self.auto_compact, + enabled: self.automatic_compaction_enabled(), token_threshold: self.compact_threshold, model: self.effective_model_for_budget().to_string(), ..Default::default() } } + pub fn automatic_compaction_enabled(&self) -> bool { + self.compaction_enabled_override + .unwrap_or(self.auto_compact) + } + + pub fn refresh_config_runtime_overrides(&mut self, config: &Config) { + self.compaction_enabled_override = config.compaction.enabled; + } + pub fn fallback_chain_entries(&self) -> Vec<(usize, ApiProvider, bool)> { let Some(chain) = &self.provider_chain else { return Vec::new(); diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 57198747f6..e2d913665b 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -1,5 +1,7 @@ use super::*; -use crate::config::{ApiProvider, Config, ProviderConfig, ProvidersConfig}; +use crate::config::{ + ApiProvider, CompactionRuntimeConfig, Config, ProviderConfig, ProvidersConfig, +}; use crate::test_support::{EnvVarGuard, lock_test_env}; use crate::tools::plan::{PlanItemArg, StepStatus, UpdatePlanArgs}; use crate::tools::todo::TodoStatus; @@ -1955,6 +1957,51 @@ fn test_compaction_config() { assert_eq!(config.model, "deepseek-v4-flash"); } +#[test] +fn compaction_config_respects_config_enabled_override() { + let config = Config { + compaction: CompactionRuntimeConfig { + enabled: Some(false), + }, + ..Default::default() + }; + let mut app = App::new(test_options(false), &config); + app.auto_compact = true; + assert!(!app.compaction_config().enabled); + + let config = Config { + compaction: CompactionRuntimeConfig { + enabled: Some(true), + }, + ..Default::default() + }; + let mut app = App::new(test_options(false), &config); + app.auto_compact = false; + assert!(app.compaction_config().enabled); +} + +#[test] +fn app_refreshes_compaction_override_from_runtime_config() { + let mut app = App::new(test_options(false), &Config::default()); + app.compaction_enabled_override = Some(true); + + let config = Config { + compaction: CompactionRuntimeConfig { + enabled: Some(false), + }, + ..Default::default() + }; + app.refresh_config_runtime_overrides(&config); + assert_eq!(app.compaction_enabled_override, Some(false)); + assert!(!app.automatic_compaction_enabled()); + + let config = Config::default(); + app.auto_compact = true; + app.refresh_config_runtime_overrides(&config); + assert_eq!(app.compaction_enabled_override, None); + assert!(app.automatic_compaction_enabled()); +} + #[test] fn test_update_model_compaction_budget() { let mut app = App::new(test_options(false), &Config::default()); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index a71aec4699..234308ad66 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -7654,6 +7654,7 @@ async fn apply_command_result( match Config::load(app.config_path.clone(), Some(&profile)) { Ok(new_config) => { *config = new_config.clone(); + app.refresh_config_runtime_overrides(config); app.api_provider = config.api_provider(); let new_model = config.default_model(); app.set_model_selection(new_model.clone()); @@ -10636,8 +10637,8 @@ fn maybe_warn_context_pressure(app: &mut App) { return; } - let recommendation = if !app.auto_compact { - "Consider enabling auto_compact or use /compact." + let recommendation = if !app.automatic_compaction_enabled() { + "Automatic compaction is disabled; use /compact or enable auto_compact/[compaction].enabled." } else if percent >= configured_threshold { "Auto-compaction will run before the next send." } else { @@ -10664,7 +10665,7 @@ fn maybe_warn_context_pressure(app: &mut App) { } fn should_auto_compact_before_send(app: &App) -> bool { - if !app.auto_compact { + if !app.automatic_compaction_enabled() { return false; } context_usage_snapshot(app) diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index bc570dfc62..84c92ad0d9 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -5732,6 +5732,18 @@ fn should_auto_compact_before_send_respects_threshold_and_setting() { app.auto_compact = false; assert!(!should_auto_compact_before_send(&app)); + // The config.toml engine switch is a hard gate for automatic pre-send + // compaction, independent of the saved UI auto_compact setting. + app.auto_compact = true; + app.auto_compact_threshold_percent = 70.0; + app.compaction_enabled_override = Some(false); + assert!(!should_auto_compact_before_send(&app)); + + app.auto_compact = false; + app.compaction_enabled_override = Some(true); + assert!(should_auto_compact_before_send(&app)); + app.compaction_enabled_override = None; + // Small estimated context + auto_compact ON can trigger once the // configured percent threshold is crossed. This still matches the // #115 fix: the estimate is the primary signal, not the engine's @@ -5770,6 +5782,26 @@ fn context_pressure_warning_reflects_auto_compact_threshold_state() { status.contains("Auto-compaction will run before the next send."), "unexpected status: {status}" ); + + let mut app = create_test_app(); + app.api_messages = vec![Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "context ".repeat(240_000), + cache_control: None, + }], + }]; + app.auto_compact = true; + app.compaction_enabled_override = Some(false); + app.auto_compact_threshold_percent = 70.0; + + maybe_warn_context_pressure(&mut app); + + let status = app.status_message.expect("context warning"); + assert!( + status.contains("Automatic compaction is disabled"), + "unexpected status: {status}" + ); } // ============================================================================ diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index efddaa9189..865346a0d0 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -933,7 +933,9 @@ policy for known context windows up to the 1M-token V4 class. Automatic compaction runs before the active model limit and carries the compacted summary forward into the next request. The trigger defaults to `auto_compact_threshold_percent = 80`. Users who prefer manual continuity can -persist `auto_compact = false`; manual `/compact` / Ctrl+L remains available. +persist `auto_compact = false`; `[compaction].enabled = false` is the +engine-level config.toml switch for replacement compaction. Manual `/compact` / +Ctrl+L remains available. You can inspect or update these from the TUI with `/settings` and `/config` (interactive editor). @@ -1021,12 +1023,13 @@ separate: | Cost estimate | Approximate spend from provider usage and configured DeepSeek rates. | Display only. | For known context-window models, including 1M-class V4 models, replacement -compaction is enabled by default unless the user explicitly configures -`auto_compact = false`. It fires at the active model's compaction threshold and -replays the generated summary through the stable system prompt on the next -request. Unknown model ids remain opt-in. The Flash seam manager remains opt-in -(`[context].enabled = false`), and the capacity controller remains disabled -unless configured. +compaction follows `[compaction].enabled` when explicitly configured; otherwise +it keeps the existing `auto_compact` settings behavior. It fires at the active +model's compaction threshold and replays the generated summary through the +stable system prompt on the next request. Unknown model ids remain opt-in. The +Flash seam manager remains disabled by default; `[context].enabled` opts in, +and `[seam_manager].enabled` can explicitly override that master switch. The +capacity controller remains disabled unless configured. ### Command Migration Notes @@ -1266,6 +1269,10 @@ If you are upgrading from older releases: `~/.codewhale/snapshots///.git`, with legacy `~/.deepseek/snapshots/...` fallback when only the legacy state exists, and never use the workspace's own `.git` directory +- `compaction.*` (optional): replacement compaction engine switch: + - `[compaction].enabled` (bool, default unset): when set, overrides the + settings-derived `auto_compact` engine switch. Set `false` for full manual + control over automatic replacement compaction. - `context.*` (optional): append-only Fin seam manager, currently opt-in. Fin is the fast `deepseek-v4-flash` path with thinking off used for coordination work such as routing, summaries, and context maintenance. @@ -1277,6 +1284,11 @@ If you are upgrading from older releases: - `[context].l2_threshold` (int, default `384000`) - `[context].l3_threshold` (int, default `576000`) - `[context].seam_model` (string, default `deepseek-v4-flash`) +- `seam_manager.*` (optional): explicit alias for the Flash seam manager master + switch: + - `[seam_manager].enabled` (bool, default unset): overrides + `[context].enabled` when set. Thresholds and model remain under + `[context]`. - `retry.*` (optional): retry/backoff settings for API requests: - `[retry].enabled` (bool, default `true`) - `[retry].max_retries` (int, default `3`)