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:
Hunter Bown
2026-05-04 23:24:24 -05:00
parent d4e5ee4eff
commit 1131e7a7b0
2 changed files with 104 additions and 17 deletions
+42 -17
View File
@@ -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);
+62
View File
@@ -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 {