From b491810b22a5fcb008942ba58f48f39cefc948df Mon Sep 17 00:00:00 2001 From: Sabhatina Selvam Date: Tue, 10 Nov 2026 10:42:59 -0800 Subject: [PATCH] feat(stage): allow disabling compaction override Signed-off-by: Sabhatina Selvam --- crates/libsy/src/algorithms/stage.rs | 36 +++++++++++++++++++ crates/libsy/src/algorithms/util/stage.rs | 11 +++++- crates/switchyard-runner/src/algorithm.rs | 11 ++++++ docs/reference/toml_schema.md | 2 ++ .../stage_router_routing.md | 4 ++- 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/crates/libsy/src/algorithms/stage.rs b/crates/libsy/src/algorithms/stage.rs index 55cc17ffb..edb3a7d1d 100644 --- a/crates/libsy/src/algorithms/stage.rs +++ b/crates/libsy/src/algorithms/stage.rs @@ -162,6 +162,8 @@ pub struct StageRouterConfig { pub recent_window: Option, /// Exact tool-name semantics added to the built-in coding vocabulary. pub tool_semantics: ToolSemantics, + /// Escalate on context compaction. Enabled by default. + pub compaction_override: bool, /// Requests to keep on capable after an escalation. `0` disables the hold; /// `-1` pins after switching to this tier. pub capable_hold_turns: i64, @@ -191,6 +193,7 @@ impl StageRouterConfig { capable_first_threshold: 0.0, recent_window: None, tool_semantics: ToolSemantics::default(), + compaction_override: true, capable_hold_turns: 2, efficient_hold_turns: None, handoff_notes: None, @@ -285,6 +288,7 @@ pub(crate) fn build_stage_route( let mut classifier = StageClassifier::new(config.mode, config.confidence_threshold) .with_capable_first_threshold(config.capable_first_threshold) + .with_compaction_override(config.compaction_override) .with_capable_hold_turns(config.capable_hold_turns); classifier.reset_on_user_turn = reset_on_user_turn; if let Some(turns) = config.efficient_hold_turns { @@ -535,6 +539,38 @@ mod tests { request } + /// Disabling compaction escalation preserves normal signals in both pickers. + #[tokio::test] + async fn compaction_override_can_be_disabled() -> Result<()> { + for mode in [PickerMode::EfficientFirst, PickerMode::CapableFirst] { + for enabled in [true, false] { + let mut config = StageRouterConfig::new(mode, 0.5); + assert!(config.compaction_override); + config.compaction_override = enabled; + config.capable_hold_turns = 0; + let router = recording_router(config)?; + let recorder = Arc::new(Recorder::default()); + for mut request in [production_request(), turn_request(true)] { + request.llm_request.has_compaction = true; + test_drive_with_models( + router.clone(), + request, + runtime_models(), + recorder.serve(), + ) + .await?; + } + let actual: Vec<_> = recorder + .routed() + .into_iter() + .map(|call| call.target) + .collect(); + assert_eq!(actual, [if enabled { "strong" } else { "weak" }, "strong"]); + } + } + Ok(()) + } + #[tokio::test] async fn standalone_pickers_default_to_no_efficient_hold() -> Result<()> { for mode in [PickerMode::EfficientFirst, PickerMode::CapableFirst] { diff --git a/crates/libsy/src/algorithms/util/stage.rs b/crates/libsy/src/algorithms/util/stage.rs index 33f4019e9..4856def28 100644 --- a/crates/libsy/src/algorithms/util/stage.rs +++ b/crates/libsy/src/algorithms/util/stage.rs @@ -529,6 +529,7 @@ pub struct StageClassifier { mode: PickerMode, confidence_threshold: f64, capable_first_threshold: f64, + compaction_override: bool, capable_hold_turns: i64, efficient_hold_turns: Option, pub(crate) reset_on_user_turn: bool, @@ -544,6 +545,7 @@ impl StageClassifier { mode, confidence_threshold, capable_first_threshold: 0.0, + compaction_override: true, capable_hold_turns: 2, efficient_hold_turns: None, reset_on_user_turn: false, @@ -558,6 +560,12 @@ impl StageClassifier { self } + /// Whether context compaction forces the capable tier. Enabled by default. + pub fn with_compaction_override(mut self, enabled: bool) -> Self { + self.compaction_override = enabled; + self + } + /// Keep capable for this many requests after an escalation. Zero disables /// it; `-1` pins after a switch. Validated by the enclosing stage router. pub fn with_capable_hold_turns(mut self, turns: i64) -> Self { @@ -750,11 +758,12 @@ impl Classifier for StageClassifier { )); } } - let Some(signal) = state.tool_signals.clone() else { + let Some(mut signal) = state.tool_signals.clone() else { // No tool activity yet — nothing to score, so the signals have no // opinion, same as a below-threshold turn. return Ok((Self::abstain(), None)); }; + signal.compacted &= self.compaction_override; let (mode, threshold) = match fall_open_tier(state) { Some(Tier::Capable) => (PickerMode::CapableFirst, self.capable_first_threshold), Some(Tier::Efficient) => (PickerMode::EfficientFirst, self.confidence_threshold), diff --git a/crates/switchyard-runner/src/algorithm.rs b/crates/switchyard-runner/src/algorithm.rs index e76aafc0e..6a0361633 100644 --- a/crates/switchyard-runner/src/algorithm.rs +++ b/crates/switchyard-runner/src/algorithm.rs @@ -657,6 +657,7 @@ pub struct StageSettingsConfig { pub confidence_threshold: Option, pub recent_turn_window: Option, pub tool_semantics: Option, + pub compaction_override: Option, pub capable_hold_turns: Option, pub efficient_hold_turns: Option, pub handoff_notes: Option, @@ -678,6 +679,10 @@ impl StageSettingsConfig { let mut config = StageRouterConfig::new(mode, self.confidence_threshold.unwrap_or(threshold)); config.recent_window = self.recent_turn_window.or(shared.recent_turn_window); + config.compaction_override = self + .compaction_override + .or(shared.compaction_override) + .unwrap_or(true); config.tool_semantics = self .tool_semantics .as_ref() @@ -2281,6 +2286,7 @@ mod tests { assert_eq!(config.mode, picker); assert_eq!(config.confidence_threshold, threshold); assert_eq!(config.recent_window, Some(3)); + assert!(config.compaction_override); assert_eq!(config.capable_hold_turns, capable_hold); assert_eq!(config.efficient_hold_turns, Some(efficient_hold)); assert_eq!(config.capable_system_prompt.as_deref(), prompt); @@ -2293,6 +2299,7 @@ mod tests { 0.5, ); assert_eq!(standalone.capable_hold_turns, 2); + assert!(standalone.compaction_override); assert!(standalone.capable_system_prompt.is_none()); Ok(()) } @@ -2306,6 +2313,7 @@ efficient_target = "weak" efficient_first_threshold = 0.7 capable_first_threshold = 0.2 recent_turn_window = 5 +compaction_override = false capable_hold_turns = 4 efficient_hold_turns = 1 capable_system_prompt = "Shared plan" @@ -2315,6 +2323,7 @@ capable_system_prompt = "Shared plan" let shared = stage.profile_config(&StageProfileConfig::default(), PickerMode::CapableFirst); assert_eq!(shared.confidence_threshold, 0.2); assert_eq!(shared.recent_window, Some(5)); + assert!(!shared.compaction_override); assert_eq!(shared.capable_hold_turns, 4); assert_eq!(shared.efficient_hold_turns, Some(1)); assert_eq!(shared.capable_system_prompt.as_deref(), Some("Shared plan")); @@ -2328,6 +2337,7 @@ capable_system_prompt = "Shared plan" r#" confidence_threshold = 0.0 recent_turn_window = 2 +compaction_override = true capable_hold_turns = 0 efficient_hold_turns = 0 capable_system_prompt = "" @@ -2337,6 +2347,7 @@ capable_system_prompt = "" let config = stage.profile_config(&profile, PickerMode::CapableFirst); assert_eq!(config.confidence_threshold, 0.0); assert_eq!(config.recent_window, Some(2)); + assert!(config.compaction_override); assert_eq!(config.capable_hold_turns, 0); assert_eq!(config.efficient_hold_turns, Some(0)); assert_eq!(config.capable_system_prompt.as_deref(), Some("")); diff --git a/docs/reference/toml_schema.md b/docs/reference/toml_schema.md index 39bcc2893..252fea115 100644 --- a/docs/reference/toml_schema.md +++ b/docs/reference/toml_schema.md @@ -476,6 +476,7 @@ the signals work and for tuning. | `capable_system_prompt` | No | unset | System prompt prepended for capable requests. | | `efficient_system_prompt` | No | unset | System prompt prepended for efficient requests. | | `recent_turn_window` | No | `3` | Trailing tool results the signals are computed over. | +| `compaction_override` | No | `true` | Context compaction forces capable. Set `false` to use the other signals and picker normally. | | `capable_hold_turns` | No | `2` | Requests kept on capable after a switch. Signal-driven capable decisions also start finite holds. Active holds skip signal processing and classification; `0` disables; `-1` pins after a switch. | | `efficient_hold_turns` | No | `0` | Requests kept on efficient after a switch, even when errors appear. `0` disables; `-1` pins after a switch. | | `tool_semantics.observe` | No | `[]` | Exact ASCII case-insensitive domain tool names that count as read-only investigation. | @@ -591,6 +592,7 @@ confidence threshold. Tool signals still drive escalation and recovery. See | `stage.efficient_first_threshold` | No | `0.4` | Signal confidence required after an Efficient classification. In `[0, 1]`. Also accepts `confidence_threshold`. | | `stage.capable_first_threshold` | No | `0.0` | Signal confidence required after a Capable classification. In `[0, 1]`. | | `stage.recent_turn_window` | No | `3` | Trailing tool results the signals are computed over. | +| `stage.compaction_override` | No | `true` | Same compaction override as `stage_router`. Can also be set per `efficient` or `capable` profile. | | `stage.capable_hold_turns` | No | CF: `0`, EF: `2` | Same capable hold as `stage_router`: `0` disables; `-1` pins after a switch. | | `stage.efficient_hold_turns` | No | CF: `-1`; EF: `0` | Defaults to pinning efficient after a switch when the classifier selected capable-first. Explicit `0` disables; positive values hold that many requests. | | `stage.tool_semantics.observe` | No | `[]` | Additional exact ASCII case-insensitive tool names that count as observation. | diff --git a/docs/routing_algorithms/stage_router_routing.md b/docs/routing_algorithms/stage_router_routing.md index b9b25077e..a4d4f407f 100644 --- a/docs/routing_algorithms/stage_router_routing.md +++ b/docs/routing_algorithms/stage_router_routing.md @@ -37,7 +37,8 @@ The axes are **corroborative**: the signed score is `tanh`-squashed to a confidence in `[0, 1]`. One maxed scoring dimension produces about `0.46`; corroborating evidence pushes confidence decisively past a `0.5` threshold. Repeated failures, critical-error severity, and context compaction are hard -overrides to the capable tier. Active holds bypass signal extraction and scoring. +overrides to the capable tier. Set `compaction_override = false` to disable the +compaction override in either picker. Active holds bypass signal extraction and scoring. Severity and test results come from tool output that ran something. The contents returned by the built-in read and search tools do not count, unless the tool reports a failure. @@ -253,6 +254,7 @@ required columns. | `capable_system_prompt` | unset | System prompt prepended for capable requests. | | `efficient_system_prompt` | unset | System prompt prepended for efficient requests. | | `recent_turn_window` | `3` | Trailing tool results the signals are computed over. | +| `compaction_override` | `true` | Context compaction forces capable. Set `false` to use the other signals and picker normally. | | `capable_hold_turns` | `2` | Requests kept on capable after switching to it; signal-driven capable decisions also start finite holds. `0` disables; `-1` pins after a switch. | | `efficient_hold_turns` | `0` | Requests kept on efficient after switching to it. `0` disables; `-1` pins after a switch. | | `tool_semantics.observe` | `[]` | Domain tool names that count as investigation. |