Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions crates/tui/src/tools/subagent/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12650,8 +12650,21 @@ fn retryable_subagent_provider_failure(
}

fn is_transient_subagent_provider_error(error: &anyhow::Error) -> bool {
if let Some(LlmError::RateLimited { .. }) = error.downcast_ref::<LlmError>() {
return true;
// Durable account, model, and policy refusals outrank message text: a
// quota error mentioning "429" is not a transient rate limit. Retain the
// transport fallback for generic/parse errors and relay HTTP 400s, which
// can carry an upstream timeout rather than an invalid request.
match error.downcast_ref::<LlmError>() {
Some(
LlmError::QuotaExhausted(_)
| LlmError::AuthenticationError(_)
| LlmError::AuthorizationError(_)
| LlmError::ModelError(_)
| LlmError::ContentPolicyError(_)
| LlmError::ContextLengthError(_),
) => return false,
Some(error) if error.is_retryable() => return true,
_ => {}
}

let message = format!("{error:#}").to_ascii_lowercase();
Expand Down
54 changes: 54 additions & 0 deletions crates/tui/src/tools/subagent/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9802,6 +9802,60 @@ fn transient_provider_classifier_matches_structured_rate_limit() {
assert!(is_transient_subagent_provider_error(&err));
}

#[test]
fn transient_provider_classifier_respects_durable_typed_errors() {
// The provider body and outer context both contain the old transient
// heuristics. Neither may override a durable HTTP-boundary classification.
let misleading = "429 rate limited: stream request temporarily unavailable";
let mut errors = vec![
LlmError::from_http_response(401, misleading),
LlmError::AuthorizationError(misleading.to_string()),
LlmError::ModelError(misleading.to_string()),
LlmError::ContentPolicyError(misleading.to_string()),
LlmError::ContextLengthError(misleading.to_string()),
];
for status in [400, 402, 429] {
let quota = LlmError::from_http_response(
status,
r#"{"error":{"type":"insufficient_quota","message":"429: insufficient quota for stream request"}}"#,
);
assert!(matches!(quota, LlmError::QuotaExhausted(_)));
errors.push(quota);
}
for error in errors {
assert!(!error.is_retryable(), "fixture must be a durable refusal");
let error = anyhow::Error::new(error).context("stream request failed: 503");
assert!(
!is_transient_subagent_provider_error(&error),
"typed refusal must not become transient: {error:#}"
);
assert!(retryable_subagent_provider_failure(&error, 1).is_none());
}
}

#[test]
fn transient_provider_classifier_uses_typed_retryability_without_keywords() {
for error in [
LlmError::ServerError {
status: 500,
message: "upstream failed".to_string(),
},
LlmError::NetworkError("socket closed".to_string()),
LlmError::Timeout(Duration::from_secs(1)),
] {
let error = anyhow::Error::new(error);
assert!(is_transient_subagent_provider_error(&error));
assert!(retryable_subagent_provider_failure(&error, 1).is_some());
}
let error = anyhow::Error::new(LlmError::RateLimited {
message: "slow down".to_string(),
retry_after: Some(Duration::from_secs(7)),
});
let retry = retryable_subagent_provider_failure(&error, 1).expect("transient rate limit");
assert_eq!(retry.delay, Duration::from_secs(7));
assert_eq!(retry.checkpoint_reason, "api_rate_limited");
}

#[tokio::test]
async fn subagent_retries_transient_provider_header_timeout_before_succeeding() {
let tmp = tempdir().expect("tempdir");
Expand Down
Loading