mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-07 00:24:50 +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",
|
"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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user