mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-06 16:14:49 +08:00
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:
@@ -7,6 +7,16 @@ const GENERIC_FATAL_WRAPPER_MARKERS: &[&str] = &[
|
||||
"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)]
|
||||
pub enum ApiError {
|
||||
MissingCredentials {
|
||||
@@ -99,6 +109,7 @@ impl ApiError {
|
||||
}
|
||||
Self::Api { status, .. } if matches!(status.as_u16(), 401 | 403) => "provider_auth",
|
||||
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 { .. } if self.is_generic_fatal_wrapper() => "provider_internal",
|
||||
Self::Api { .. } => "provider_error",
|
||||
@@ -131,6 +142,35 @@ impl ApiError {
|
||||
| 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 {
|
||||
@@ -235,6 +275,13 @@ fn looks_like_generic_fatal_wrapper(text: &str) -> bool {
|
||||
.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)]
|
||||
mod tests {
|
||||
use super::ApiError;
|
||||
@@ -280,4 +327,23 @@ mod tests {
|
||||
assert_eq!(error.safe_failure_class(), "provider_retry_exhausted");
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5644,7 +5644,9 @@ impl ApiClient for AnthropicRuntimeClient {
|
||||
}
|
||||
|
||||
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}")];
|
||||
if let Some(request_id) = error.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 {
|
||||
summary
|
||||
.assistant_messages
|
||||
@@ -6753,6 +6821,73 @@ mod tests {
|
||||
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 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user