Turn oversized-context failures into recovery guidance

Dogfood showed oversized requests still surfacing as raw hard errors, even when claw could tell the user exactly how to recover. This keeps context-window failures classified, recognizes the same failure when it comes back from a provider response, and renders recovery steps that point operators at the existing compaction and fresh-session paths instead of a provider-style dump.

Constraint: Keep the failure class explicit so automation and operators can still distinguish context-window exhaustion from generic provider failures
Constraint: Reuse existing /compact and session-reset UX instead of inventing a new recovery workflow
Rejected: Auto-run compaction on failure | mutates session state on an error path the user may want to inspect first
Rejected: Only prettify local preflight failures | provider-returned context-window errors would still leak raw failure text
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep provider-side context-window detection aligned with real oversized-request messages before broadening the marker list
Tested: cargo fmt --all --check
Tested: cargo test -p api
Tested: cargo test -p rusty-claude-cli
Tested: cargo clippy -p api -p rusty-claude-cli --all-targets -- -D warnings
Not-tested: cargo test --workspace
This commit is contained in:
Yeachan-Heo
2026-04-06 05:37:58 +00:00
parent 84a0973f6c
commit b930895736
2 changed files with 202 additions and 1 deletions

View File

@@ -7,6 +7,16 @@ const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
"please try again, or use /new to start a fresh session", "please try again, or use /new to start a fresh session",
]; ];
const CONTEXT_WINDOW_ERROR_MARKERS: &[&str] = &[
"maximum context length",
"context window",
"context length",
"too many tokens",
"prompt is too long",
"input is too long",
"request is too large",
];
#[derive(Debug)] #[derive(Debug)]
pub enum ApiError { pub enum ApiError {
MissingCredentials { MissingCredentials {
@@ -99,6 +109,7 @@ impl ApiError {
} }
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth", Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
Self::ContextWindowExceeded { .. } => "context_window", Self::ContextWindowExceeded { .. } => "context_window",
Self::Api { .. } if self.is_context_window_failure() => "context_window",
Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit", Self::Api { status, .. } if status.as_u16() == 429 => "provider_rate_limit",
Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal", Self::Api { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
Self::Api { .. } => "provider_error", Self::Api { .. } => "provider_error",
@@ -131,6 +142,35 @@ impl ApiError {
| Self::BackoffOverflow { .. } => false, | Self::BackoffOverflow { .. } => false,
} }
} }
#[must_use]
pub fn is_context_window_failure(&self) -> bool {
match self {
Self::ContextWindowExceeded { .. } => true,
Self::Api {
status,
message,
body,
..
} => {
matches!(status.as_u16(), 400 | 413 | 422)
&& (message
.as_deref()
.is_some_and(looks_like_context_window_error)
|| looks_like_context_window_error(body))
}
Self::RetriesExhausted { last_error, .. } => last_error.is_context_window_failure(),
Self::MissingCredentials { .. }
| Self::ExpiredOAuthToken
| Self::Auth(_)
| Self::InvalidApiKeyEnv(_)
| Self::Http(_)
| Self::Io(_)
| Self::Json(_)
| Self::InvalidSseFrame(_)
| Self::BackoffOverflow { .. } => false,
}
}
} }
impl Display for ApiError { impl Display for ApiError {
@@ -235,6 +275,13 @@ fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
.any(|marker| lowered.contains(marker)) .any(|marker| lowered.contains(marker))
} }
fn looks_like_context_window_error(text: &str) -> bool {
let lowered = text.to_ascii_lowercase();
CONTEXT_WINDOW_ERROR_MARKERS
.iter()
.any(|marker| lowered.contains(marker))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::ApiError; use super::ApiError;
@@ -280,4 +327,23 @@ mod tests {
assert_eq!(error.safe_failure_class(), "provider_retry_exhausted"); assert_eq!(error.safe_failure_class(), "provider_retry_exhausted");
assert_eq!(error.request_id(), Some("req_nested_456")); assert_eq!(error.request_id(), Some("req_nested_456"));
} }
#[test]
fn classifies_provider_context_window_errors() {
let error = ApiError::Api {
status: reqwest::StatusCode::BAD_REQUEST,
error_type: Some("invalid_request_error".to_string()),
message: Some(
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(),
),
request_id: Some("req_ctx_123".to_string()),
body: String::new(),
retryable: false,
};
assert!(error.is_context_window_failure());
assert_eq!(error.safe_failure_class(), "context_window");
assert_eq!(error.request_id(), Some("req_ctx_123"));
}
} }

View File

@@ -5644,7 +5644,9 @@ impl ApiClient for AnthropicRuntimeClient {
} }
fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String { fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> String {
if error.is_generic_fatal_wrapper() { if error.safe_failure_class() == "context_window" {
format_context_window_blocked_error(session_id, error)
} else if error.is_generic_fatal_wrapper() {
let mut qualifiers = vec![format!("session {session_id}")]; let mut qualifiers = vec![format!("session {session_id}")];
if let Some(request_id) = error.request_id() { if let Some(request_id) = error.request_id() {
qualifiers.push(format!("trace {request_id}")); qualifiers.push(format!("trace {request_id}"));
@@ -5660,6 +5662,72 @@ fn format_user_visible_api_error(session_id: &str, error: &api::ApiError) -> Str
} }
} }
fn format_context_window_blocked_error(session_id: &str, error: &api::ApiError) -> String {
let mut lines = vec![
"Context window blocked".to_string(),
" Failure class context_window_blocked".to_string(),
format!(" Session {session_id}"),
];
if let Some(request_id) = error.request_id() {
lines.push(format!(" Trace {request_id}"));
}
match error {
api::ApiError::ContextWindowExceeded {
model,
estimated_input_tokens,
requested_output_tokens,
estimated_total_tokens,
context_window_tokens,
} => {
lines.push(format!(" Model {model}"));
lines.push(format!(" Estimated input {estimated_input_tokens}"));
lines.push(format!(" Requested output {requested_output_tokens}"));
lines.push(format!(" Estimated total {estimated_total_tokens}"));
lines.push(format!(" Context window {context_window_tokens}"));
}
api::ApiError::Api { message, body, .. } => {
let detail = message.as_deref().unwrap_or(body).trim();
if !detail.is_empty() {
lines.push(format!(
" Detail {}",
truncate_for_summary(detail, 120)
));
}
}
api::ApiError::RetriesExhausted { last_error, .. } => {
let detail = match last_error.as_ref() {
api::ApiError::Api { message, body, .. } => message.as_deref().unwrap_or(body),
other => return format_context_window_blocked_error(session_id, other),
}
.trim();
if !detail.is_empty() {
lines.push(format!(
" Detail {}",
truncate_for_summary(detail, 120)
));
}
}
_ => {}
}
lines.push(String::new());
lines.push("Recovery".to_string());
lines.push(" Compact /compact".to_string());
lines.push(format!(
" Resume compact claw --resume {session_id} /compact"
));
lines.push(" Fresh session /clear --confirm".to_string());
lines.push(
" Reduce scope remove large pasted context/files or ask for a smaller slice"
.to_string(),
);
lines.push(" Retry rerun after compacting or reducing the request".to_string());
lines.join("\n")
}
fn final_assistant_text(summary: &runtime::TurnSummary) -> String { fn final_assistant_text(summary: &runtime::TurnSummary) -> String {
summary summary
.assistant_messages .assistant_messages
@@ -6753,6 +6821,73 @@ mod tests {
assert!(rendered.contains("trace req_jobdori_790")); assert!(rendered.contains("trace req_jobdori_790"));
} }
#[test]
fn context_window_preflight_errors_render_recovery_steps() {
let error = ApiError::ContextWindowExceeded {
model: "claude-sonnet-4-6".to_string(),
estimated_input_tokens: 182_000,
requested_output_tokens: 64_000,
estimated_total_tokens: 246_000,
context_window_tokens: 200_000,
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
assert!(rendered.contains("Context window blocked"), "{rendered}");
assert!(rendered.contains("context_window_blocked"), "{rendered}");
assert!(
rendered.contains("Session session-issue-32"),
"{rendered}"
);
assert!(
rendered.contains("Model claude-sonnet-4-6"),
"{rendered}"
);
assert!(rendered.contains("Estimated total 246000"), "{rendered}");
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Resume compact claw --resume session-issue-32 /compact"),
"{rendered}"
);
assert!(
rendered.contains("Fresh session /clear --confirm"),
"{rendered}"
);
assert!(rendered.contains("Reduce scope"), "{rendered}");
assert!(rendered.contains("Retry rerun"), "{rendered}");
}
#[test]
fn provider_context_window_errors_are_reframed_with_same_guidance() {
let error = ApiError::Api {
status: "400".parse().expect("status"),
error_type: Some("invalid_request_error".to_string()),
message: Some(
"This model's maximum context length is 200000 tokens, but your request used 230000 tokens."
.to_string(),
),
request_id: Some("req_ctx_456".to_string()),
body: String::new(),
retryable: false,
};
let rendered = format_user_visible_api_error("session-issue-32", &error);
assert!(rendered.contains("context_window_blocked"), "{rendered}");
assert!(
rendered.contains("Trace req_ctx_456"),
"{rendered}"
);
assert!(
rendered
.contains("Detail This model's maximum context length is 200000 tokens"),
"{rendered}"
);
assert!(rendered.contains("Compact /compact"), "{rendered}");
assert!(
rendered.contains("Fresh session /clear --confirm"),
"{rendered}"
);
}
fn temp_dir() -> PathBuf { fn temp_dir() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};