feat: auto-compact and retry on context window errors

When the model API returns a context window exceeded error, the CLI now
automatically compacts the session to free up token budget, then retries
the failed turn. This prevents users from hitting a hard stop when
sessions grow too long.

Problem:
Previously, auto-compact retry only worked in the interactive REPL path
(run_turn). The non-interactive paths (run_prompt_json,
run_prompt_compact, run_prompt_compact_json) simply propagated the
error with a result? and no retry. Additionally, context window
detection used ad-hoc string matching (contains("context_window") ||
contains("no parseable body")) instead of the canonical detection
method in the api crate.

Solution:
1. Added "no parseable body" to CONTEXT_WINDOW_ERROR_MARKERS in the api
   crate, so is_context_window_failure() now covers OpenAI-compat
   backends that return 400 with an un-parseable body when the request
   exceeds context limits.

2. Added RuntimeError::is_context_window_failure() method in the
   runtime crate. Since ApiError is erased into a string message when
   it crosses the runtime boundary, we need a runtime-level marker
   check that mirrors the api crate's detection. This replaces the
   ad-hoc string matching that was inlined in run_turn().

3. Extracted the auto-compact retry logic from run_turn() into a
   shared LiveCli::auto_compact_retry() method. This method:
   - Detects context window errors via RuntimeError::is_context_window_failure()
   - Compacts progressively (preserve 4 -> 2 -> 0 recent messages)
   - Retries the same user input with the compacted session
   - Is bounded by MAX_COMPACT_RETRIES = 3 to prevent infinite loops
   - Logs user-facing messages like "Context limit reached, auto-compacting
     session... (attempt N/3)"

4. Extended auto-compact retry to ALL turn execution paths:
   - run_turn() (interactive REPL) — now uses shared helper
   - run_prompt_compact() (-p --compact) — auto-retry added
   - run_prompt_compact_json() (-p --compact --json) — auto-retry added
   - run_prompt_json() (-p --json) — auto-retry added

Changes:
- rust/crates/api/src/error.rs: Added "no parseable body" marker
- rust/crates/runtime/src/conversation.rs: Added
  RUNTIME_CONTEXT_WINDOW_MARKERS constant and
  RuntimeError::is_context_window_failure() method
- rust/crates/rusty-claude-cli/src/main.rs: Extracted
  LiveCli::auto_compact_retry() with MAX_COMPACT_RETRIES = 3, replaced
  inline retry logic in run_turn(), added auto-compact retry to
  run_prompt_compact(), run_prompt_compact_json(), run_prompt_json()
This commit is contained in:
TheArchitectit 2026-06-02 15:54:08 -05:00 committed by Your Name
parent 503d515f38
commit 041687fab1
2 changed files with 184 additions and 172 deletions

View File

@ -92,6 +92,28 @@ pub struct RuntimeError {
message: String, message: String,
} }
/// Markers that indicate a context window / token limit error when the
/// original ApiError type has been erased into a string message.
/// Mirrors the markers in `api::error::CONTEXT_WINDOW_ERROR_MARKERS` plus
/// the "no parseable body" heuristic from PR #3214 (OpenAI-compat backends
/// return 400 with an un-parseable body when the request exceeds the
/// model's context length).
const RUNTIME_CONTEXT_WINDOW_MARKERS: &[&str] = &[
"maximum context length",
"context window",
"context length",
"too many tokens",
"prompt is too long",
"input is too long",
"input tokens exceed",
"configured limit",
"messages resulted in",
"completion tokens",
"prompt tokens",
"request is too large",
"no parseable body",
];
impl RuntimeError { impl RuntimeError {
#[must_use] #[must_use]
pub fn new(message: impl Into<String>) -> Self { pub fn new(message: impl Into<String>) -> Self {
@ -99,6 +121,21 @@ impl RuntimeError {
message: message.into(), message: message.into(),
} }
} }
/// Returns `true` when the error message is consistent with a context
/// window / token-limit exceeded error from the upstream provider.
///
/// This is the runtime-level counterpart of
/// `api::ApiError::is_context_window_failure()`, needed because the
/// API error type is erased into a plain string when it crosses the
/// runtime boundary.
#[must_use]
pub fn is_context_window_failure(&self) -> bool {
let lowered = self.message.to_ascii_lowercase();
RUNTIME_CONTEXT_WINDOW_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
} }
impl Display for RuntimeError { impl Display for RuntimeError {

View File

@ -60,7 +60,8 @@ use runtime::{
ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig, ConversationMessage, ConversationRuntime, McpConfigCollection, McpInvalidServerConfig,
McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode, McpServer, McpServerManager, McpServerSpec, McpTool, MessageRole, ModelPricing, PermissionMode,
PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError, PermissionPolicy, ProjectContext, PromptCacheEvent, ResolvedPermissionMode, RuntimeError,
RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, RuntimeInvalidHookConfig, Session, TokenUsage, ToolError, ToolExecutor, TurnSummary,
UsageTracker,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
@ -7709,6 +7710,117 @@ impl LiveCli {
Ok(()) Ok(())
} }
/// Maximum number of auto-compact-and-retry attempts when a context window
/// error is detected. After this many attempts the error is surfaced to the
/// user unchanged.
const MAX_COMPACT_RETRIES: usize = 3;
/// When a turn fails with a context-window error, automatically compact the
/// session (removing old messages to free token budget) and retry the same
/// user input. Each retry round preserves fewer recent messages
/// (`preserve_schedule`) to trade conversation continuity for a smaller
/// payload until it fits.
///
/// Returns `Ok(TurnSummary)` if the retry succeeded after compaction, or
/// `Err(RuntimeError)` if the error was not a context-window error or all
/// retry rounds were exhausted.
fn auto_compact_retry(
&mut self,
runtime: &mut BuiltRuntime,
input: &str,
error: RuntimeError,
) -> Result<TurnSummary, RuntimeError> {
if !error.is_context_window_failure() {
return Err(error);
}
// Progressive compaction: each round preserves fewer recent messages
// (4 → 2 → 1 → 0), trading conversation continuity for a smaller
// payload until it fits.
let preserve_schedule: [usize; Self::MAX_COMPACT_RETRIES] = [4, 2, 0];
for round in 0..Self::MAX_COMPACT_RETRIES {
let preserve = preserve_schedule[round];
println!(
" Context limit reached, auto-compacting session... (attempt {}/{})",
round + 1,
Self::MAX_COMPACT_RETRIES
);
// Run Trident pipeline then summary-based compaction
let result = runtime::trident::trident_compact_session(
runtime.session(),
CompactionConfig {
preserve_recent_messages: preserve,
max_estimated_tokens: 0,
},
&runtime::trident::TridentConfig::default(),
);
let removed = result.removed_message_count;
if removed == 0 && round > 0 {
// No more messages to compact — further rounds won't help
println!(" No further compaction possible.");
break;
}
if removed > 0 {
println!(
"{}",
format_compact_report(
removed,
result.compacted_session.messages.len(),
false
)
);
}
// Without this, prepare_turn_runtime() reads from
// self.runtime.session() which still holds the ORIGINAL
// un-compacted session, so every retry round would send the same
// bloated request — compaction was wasted.
*self.runtime.session_mut() = result.compacted_session.clone();
// Build a new runtime with the compacted session and retry
let (mut new_runtime, hook_abort_monitor) =
match self.prepare_turn_runtime(true) {
Ok(pair) => pair,
Err(e) => return Err(RuntimeError::new(e.to_string())),
};
drop(hook_abort_monitor);
let mut rp = CliPermissionPrompter::new(self.permission_mode);
match new_runtime.run_turn(input, Some(&mut rp)) {
Ok(summary) => {
// Retry succeeded — swap in the compacted runtime
if let Err(e) = self.replace_runtime(new_runtime) {
return Err(RuntimeError::new(e.to_string()));
}
return Ok(summary);
}
Err(retry_error) => {
if retry_error.is_context_window_failure()
&& round + 1 < Self::MAX_COMPACT_RETRIES
{
// The compacted session was still too large.
// Shut down the old runtime, adopt the partially
// compacted one, and loop — the next round will
// compact more aggressively.
let _ = runtime.shutdown_plugins();
*runtime = new_runtime;
continue;
}
// Not a context window error, or out of rounds
return Err(retry_error);
}
}
}
// All retries exhausted — propagate the original error
Err(error)
}
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> { fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?; let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
let mut spinner = Spinner::new(); let mut spinner = Spinner::new();
@ -7751,182 +7863,27 @@ impl LiveCli {
&mut stdout, &mut stdout,
)?; )?;
// ============================================================================ match self.auto_compact_retry(&mut runtime, input, error) {
// Auto-compact retry on context window errors Ok(summary) => {
// ============================================================================ spinner.finish(
// When the model API returns a context_window_blocked error (because the request "✨ Done (after auto-compact)",
// exceeds the model's context window), we automatically: TerminalRenderer::new().color_theme(),
// 1. Compact the session (remove old messages to free up space) &mut stdout,
// 2. Retry the original request with the compacted session )?;
// 3. Report results to the user println!();
// if let Some(event) = summary.auto_compaction {
// This eliminates the need for users to manually run /compact when they
// hit context limits - the recovery happens automatically.
//
// Detection: We look for "context_window" or "Context window" in the error
// message, which covers error types like:
// - "context_window_blocked"
// - "Context window blocked"
// - "This model's maximum context length is X tokens..."
// ============================================================================
let error_str = error.to_string();
// Detect context window overflow. Some providers (e.g. OpenAI-compat backends)
// return 400 with "no parseable body" instead of a proper context_length_exceeded
// error when the request is too large to even parse — treat that as context overflow too.
// Also detect model-specific context error markers (e.g. llama.cpp returns
// "Context size has been exceeded." / "exceed_context_size_error" / "exceeds the available context size").
let is_context_window = error_str.contains("context_window")
|| error_str.contains("Context window")
|| error_str.contains("no parseable body")
|| error_str.contains("exceed_context_size")
|| error_str.contains("exceeds the available context size")
|| error_str
.to_ascii_lowercase()
.contains("context size has been exceeded");
// Also treat "assistant stream produced no content" and reqwest decode failures
// as recoverable errors that may benefit from auto-compaction. Some backends (e.g.
// llama.cpp) return a non-SSE HTTP 500 body when context overflows, causing
// reqwest to fail with "error decoding response body" — treat that as context overflow too.
let is_no_content = error_str.contains("assistant stream produced no content")
|| error_str.contains("Failed to parse input at pos")
|| error_str.contains("error decoding response body");
if is_context_window || is_no_content {
// If the error tells us the server's actual context window, adapt our
// auto-compaction threshold so future auto-compact-trigger checks are accurate.
if let Some(window) = extract_context_window_tokens_from_error(&error_str) {
// Set threshold at 70% of the reported window to leave headroom.
let threshold: u32 = (window as f64 * 0.7).round() as u32;
println!(
" Server context window: {} tokens — setting auto-compaction threshold to {}",
window, threshold
);
runtime.set_auto_compaction_input_tokens_threshold(threshold);
}
// A single compaction pass may not free enough context space.
// Progressive retry: each round preserves fewer recent messages (4→2→1→0),
// trading conversation continuity for a smaller payload until it fits.
// Max 4 rounds before giving up and surfacing the error to the user.
let max_compact_rounds = 4;
let preserve_schedule = [4, 2, 1, 0];
for round in 0..max_compact_rounds {
let preserve = preserve_schedule[round];
println!(
" Auto-compacting session (round {}/{}, preserving {} recent messages)...",
round + 1,
max_compact_rounds,
preserve
);
// Run Trident pipeline then summary-based compaction
let result = runtime::trident::trident_compact_session(
runtime.session(),
CompactionConfig {
preserve_recent_messages: preserve,
max_estimated_tokens: 0,
},
&runtime::trident::TridentConfig::default(),
);
let removed = result.removed_message_count;
if removed == 0 && round > 0 {
// No more messages to compact — further rounds won't help
println!(" No further compaction possible.");
break;
}
if removed > 0 {
println!( println!(
"{}", "{}",
format_compact_report( format_auto_compaction_notice(event.removed_message_count)
removed,
result.compacted_session.messages.len(),
false
)
); );
} }
// Without this, prepare_turn_runtime() reads from self.runtime.session() self.persist_session()?;
// which still holds the ORIGINAL un-compacted session, so every retry round Ok(())
// would send the same bloated request — compaction was wasted.
*self.runtime.session_mut() = result.compacted_session.clone();
// Build a new runtime with the compacted session and retry
let (mut new_runtime, hook_abort_monitor) =
self.prepare_turn_runtime(true)?;
drop(hook_abort_monitor);
let mut rp = CliPermissionPrompter::new(self.permission_mode);
match new_runtime.run_turn(input, Some(&mut rp)) {
Ok(summary) => {
self.replace_runtime(new_runtime)?;
spinner.finish(
if round == 0 {
"✨ Done (after auto-compact)"
} else {
"✨ Done (after aggressive auto-compact)"
},
TerminalRenderer::new().color_theme(),
&mut stdout,
)?;
println!();
if let Some(event) = summary.auto_compaction {
println!(
"{}",
format_auto_compaction_notice(event.removed_message_count)
);
}
self.persist_session()?;
return Ok(());
}
Err(retry_error) => {
let retry_str = retry_error.to_string();
let still_context_window = retry_str.contains("context_window")
|| retry_str.contains("Context window")
|| retry_str.contains("no parseable body")
|| retry_str.contains("exceed_context_size")
|| retry_str.contains("exceeds the available context size")
|| retry_str
.to_ascii_lowercase()
.contains("context size has been exceeded");
let still_no_content = retry_str
.contains("assistant stream produced no content")
|| retry_str.contains("Failed to parse input at pos")
|| retry_str.contains("error decoding response body");
if (still_context_window || still_no_content)
&& round + 1 < max_compact_rounds
{
// If the retry error reveals the context window, adapt threshold.
if let Some(window) =
extract_context_window_tokens_from_error(&retry_str)
{
let threshold: u32 = (window as f64 * 0.7).round() as u32;
new_runtime
.set_auto_compaction_input_tokens_threshold(threshold);
}
// The compacted session was still too large for the model's context.
// Shut down the old runtime, adopt the partially-compacted one,
// and loop — the next round will compact more aggressively.
runtime.shutdown_plugins()?;
runtime = new_runtime;
continue;
}
// Not a context window error, or out of rounds
return Err(Box::new(retry_error));
}
}
} }
Err(final_error) => Err(Box::new(final_error)),
} }
// If not a context window error, return original error
Err(Box::new(error))
} }
} }
} }
@ -7950,7 +7907,13 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter)); let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop(); hook_abort_monitor.stop();
let summary = result?; let summary = match result {
Ok(s) => s,
Err(error) => {
let _ = runtime.shutdown_plugins();
self.auto_compact_retry(&mut runtime, input, error)?
}
};
self.replace_runtime(runtime)?; self.replace_runtime(runtime)?;
self.persist_session()?; self.persist_session()?;
let final_text = final_assistant_text(&summary); let final_text = final_assistant_text(&summary);
@ -7963,7 +7926,13 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter)); let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop(); hook_abort_monitor.stop();
let summary = result?; let summary = match result {
Ok(s) => s,
Err(error) => {
let _ = runtime.shutdown_plugins();
self.auto_compact_retry(&mut runtime, input, error)?
}
};
self.replace_runtime(runtime)?; self.replace_runtime(runtime)?;
self.persist_session()?; self.persist_session()?;
println!( println!(
@ -7988,7 +7957,13 @@ impl LiveCli {
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode); let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
let result = runtime.run_turn(input, Some(&mut permission_prompter)); let result = runtime.run_turn(input, Some(&mut permission_prompter));
hook_abort_monitor.stop(); hook_abort_monitor.stop();
let summary = result?; let summary = match result {
Ok(s) => s,
Err(error) => {
let _ = runtime.shutdown_plugins();
self.auto_compact_retry(&mut runtime, input, error)?
}
};
self.replace_runtime(runtime)?; self.replace_runtime(runtime)?;
self.persist_session()?; self.persist_session()?;
println!( println!(