fix(capacity): disable controller by default (silent transcript wipe)
User-facing repro: * In YOLO mode at low context utilisation (~5%), the engine briefly showed `resetting plan` in the footer and the transcript area went mostly black. Tools kept running (Plan panel + sidebar still rendered), but the chat history above the latest turn was gone. Root cause: the capacity controller's `VerifyAndReplan` action (`crates/tui/src/core/engine/capacity_flow.rs::apply_verify_and_replan`) runs `self.session.messages.clear()` and rebuilds from the canonical state. The capacity controller fires this when its slack-based `p_fail` calculation crosses the high-severe band — independently of the `auto_compact` setting, independently of token utilisation. The user opted out of auto-compaction in v0.8.11 (default `auto_compact = false`, #665), explicitly trusting the model with the full 1M-token V4 window. Auto-managing the prefix on their behalf via the capacity controller contradicts that posture and silently destroys both the user-visible transcript and V4's prefix cache. The fix ======= Flip `CapacityControllerConfig::default().enabled` from `true` to `false`. The controller's `observe_*` and `decide` methods already short-circuit when `enabled` is false (`capacity.rs:255`, `capacity.rs:396`), so the existing wiring becomes a no-op for the default config — no need for defensive gating in `capacity_flow.rs`. Power users who want the controller can opt in via `capacity.enabled = true` in `~/.deepseek/config.toml`. The slack heuristics, model priors, cooldowns, and intervention paths all remain in the codebase, ready to re-engage on opt-in. Nothing deleted. Tests ===== * `default_controller_is_disabled_and_skips_observations` — pins the new default; `observe_pre_turn` returns `None`. * `opt_in_controller_observes_and_decides` — confirms `enabled = true` rearms the controller end-to-end. * `app_config_without_capacity_uses_default_disabled` — pins that loading a config with no `[capacity]` section produces `enabled = false`. * `capacity_disabled_by_default_keeps_messages_intact` — direct regression for the user-reported symptom: with default config, even a forced error-escalation checkpoint cannot trigger `messages.clear()`. Asserts the transcript length is preserved. Verified locally: * `cargo fmt --all -- --check` clean. * `cargo clippy --workspace --all-targets --all-features --locked -- -D warnings` clean. * `cargo test --workspace --all-features --locked` — 2039 passed, 2 ignored, 0 failed (one flake on `snapshot::repo::tests:: restore_removes_files_added_after_target_snapshot` was filesystem- timing-dependent, passes on isolation re-run; unrelated to this change). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,16 +28,17 @@ impl Default for CapacityControllerConfig {
|
||||
model_priors.insert("deepseek_v4_flash".to_string(), 4.2);
|
||||
|
||||
Self {
|
||||
// ON BY DEFAULT since v0.8.6 (#402 P0 survivability). The
|
||||
// capacity controller detects context pressure and triggers
|
||||
// TargetedContextRefresh (compaction) before the model hits its
|
||||
// window limit. Long-running sessions accumulate unbounded
|
||||
// message history that silently crosses the token budget — by
|
||||
// the time the error surfaces the conversation is already
|
||||
// degraded. Running the controller by default catches this
|
||||
// early. Users who prefer the previous behaviour can opt out
|
||||
// via `capacity.enabled = false` in `~/.deepseek/config.toml`.
|
||||
enabled: true,
|
||||
// OFF BY DEFAULT since v0.8.11. The capacity controller's
|
||||
// interventions (TargetedContextRefresh, VerifyAndReplan)
|
||||
// silently rewrite or clear the session message log, which
|
||||
// surprises the user and destroys V4's prefix cache. v0.8.11
|
||||
// committed to "trust the model with the full 1M-token
|
||||
// context, only compact on explicit user `/compact`."
|
||||
// Auto-managing the prefix on the user's behalf works against
|
||||
// that posture. Power users who want the controller can opt
|
||||
// in via `capacity.enabled = true` in
|
||||
// `~/.deepseek/config.toml`.
|
||||
enabled: false,
|
||||
// Thresholds retained for the opt-in path; tuning notes live
|
||||
// in git history (#63 follow-up).
|
||||
low_risk_max: 0.50,
|
||||
@@ -618,10 +619,35 @@ mod tests {
|
||||
assert_eq!(decide_policy(&cfg, &snap), GuardrailAction::VerifyAndReplan);
|
||||
}
|
||||
|
||||
/// v0.8.11 flipped the default to `enabled = false`. The controller's
|
||||
/// observe / decide methods early-return when disabled — opt-in only.
|
||||
#[test]
|
||||
fn default_controller_is_enabled_and_observes() {
|
||||
fn default_controller_is_disabled_and_skips_observations() {
|
||||
let cfg = CapacityControllerConfig::default();
|
||||
assert!(cfg.enabled);
|
||||
assert!(!cfg.enabled);
|
||||
|
||||
let mut controller = CapacityController::new(cfg);
|
||||
let snapshot = controller.observe_pre_turn(CapacityObservationInput {
|
||||
turn_index: 1,
|
||||
model: "deepseek-v4-pro".to_string(),
|
||||
action_count_this_turn: 10,
|
||||
tool_calls_recent_window: 10,
|
||||
unique_reference_ids_recent_window: 10,
|
||||
context_used_ratio: 0.95,
|
||||
});
|
||||
|
||||
// With enabled=false, observe_pre_turn returns None.
|
||||
assert!(snapshot.is_none());
|
||||
}
|
||||
|
||||
/// Opting in via `capacity.enabled = true` re-arms the controller —
|
||||
/// observations produce snapshots, decisions can fire interventions.
|
||||
#[test]
|
||||
fn opt_in_controller_observes_and_decides() {
|
||||
let cfg = CapacityControllerConfig {
|
||||
enabled: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut controller = CapacityController::new(cfg);
|
||||
let snapshot = controller.observe_pre_turn(CapacityObservationInput {
|
||||
@@ -633,7 +659,6 @@ mod tests {
|
||||
context_used_ratio: 0.95,
|
||||
});
|
||||
|
||||
// With enabled=true, observe_pre_turn returns a snapshot.
|
||||
assert!(snapshot.is_some());
|
||||
let snap = snapshot.unwrap();
|
||||
assert_eq!(snap.turn_index, 1);
|
||||
@@ -641,11 +666,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_config_without_capacity_uses_default_enabled() {
|
||||
fn app_config_without_capacity_uses_default_disabled() {
|
||||
let cfg = CapacityControllerConfig::from_app_config(&crate::config::Config::default());
|
||||
// Default is now enabled (#402 P0); no capacity section in config
|
||||
// means the controller starts active with reasonable defaults.
|
||||
assert!(cfg.enabled);
|
||||
// v0.8.11: default is disabled. No capacity section in config
|
||||
// means the controller stays inert; users opt in deliberately.
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.low_risk_max, 0.50);
|
||||
assert_eq!(cfg.refresh_cooldown_turns, 6);
|
||||
assert_eq!(cfg.min_turns_before_guardrail, 4);
|
||||
|
||||
@@ -957,6 +957,68 @@ async fn error_escalation_triggers_replan_when_severe_or_repeated_failures() {
|
||||
}
|
||||
}
|
||||
|
||||
/// v0.8.11: `CapacityControllerConfig::default()` ships with
|
||||
/// `enabled = false`. The capacity controller's destructive
|
||||
/// interventions (TargetedContextRefresh silently runs compaction;
|
||||
/// VerifyAndReplan clears the session message log) silently rewrote
|
||||
/// or nuked the user's transcript ("resetting plan" footer +
|
||||
/// black-screen symptom). v0.8.11 commits to "trust the model with
|
||||
/// the full 1M-token context, only compact on explicit user
|
||||
/// /compact" — auto-managing the prefix contradicts that posture.
|
||||
/// Power users can still opt in via `capacity.enabled = true`.
|
||||
#[tokio::test]
|
||||
async fn capacity_disabled_by_default_keeps_messages_intact() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
unsafe {
|
||||
std::env::set_var(
|
||||
"DEEPSEEK_CAPACITY_MEMORY_DIR",
|
||||
tmp.path().to_string_lossy().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Default config — what real users get.
|
||||
let mut engine = build_engine_with_capacity(CapacityControllerConfig::default());
|
||||
assert!(
|
||||
!engine.config.capacity.enabled,
|
||||
"capacity controller must be off by default in v0.8.11+"
|
||||
);
|
||||
engine.turn_counter = 6;
|
||||
engine
|
||||
.capacity_controller
|
||||
.mark_turn_start(engine.turn_counter);
|
||||
|
||||
for i in 0..10 {
|
||||
engine.session.messages.push(Message {
|
||||
role: if i % 2 == 0 { "user" } else { "assistant" }.to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: format!("noise message {i}"),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
}
|
||||
engine.session.messages.push(Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::Text {
|
||||
text: "Please finish task".to_string(),
|
||||
cache_control: None,
|
||||
}],
|
||||
});
|
||||
|
||||
let before_len = engine.session.messages.len();
|
||||
let turn = TurnContext::new(10);
|
||||
let restarted = engine
|
||||
.run_capacity_error_escalation_checkpoint(&turn, AppMode::Agent, 2, 2, &[])
|
||||
.await;
|
||||
|
||||
// Capacity is disabled → no replan, no message clear.
|
||||
assert!(!restarted);
|
||||
assert_eq!(engine.session.messages.len(), before_len);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var("DEEPSEEK_CAPACITY_MEMORY_DIR");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn controller_disabled_keeps_behavior_unchanged() {
|
||||
let capacity = CapacityControllerConfig {
|
||||
|
||||
Reference in New Issue
Block a user