Improve Guardian retries and review failure reporting (#44482)

## Why

Transient rate limits can end automatic approval reviews prematurely, and review failures currently report high risk even when no assessment completed.

## What changed

- Retry rate limits and recoverable exhausted-stream errors, while excluding non-transient HTTP failures.
- Preserve server retry delays after stream retries are exhausted and honor them within the review deadline. Scope retry advice to the current turn so reused sessions cannot apply stale delays.
- Keep failed reviews denied, but leave risk and authorization unset and explain that the review could not complete without declaring the action unsafe.

## Testing

Add an integration test covering rate-limit recovery through approval and tool execution, asserting that the action executes exactly once after approval. Update failure assertions to check absent assessment fields and the review-failure explanation.

GitOrigin-RevId: 1163cfde35c6b8eb23b6f24f1f24461b86ded838
This commit is contained in:
jif
2026-09-10 09:50:59 +00:00
committed by copyberry
parent 03f014564d
commit 5d3fe48b08
11 changed files with 226 additions and 42 deletions
@@ -398,6 +398,7 @@ async fn run_review_on_session(
GuardianReviewSessionOutcome::SessionFailed {
error,
error_info: None,
retry_at: None,
},
false,
analytics_result,
@@ -429,6 +430,7 @@ async fn run_review_on_session(
GuardianReviewSessionOutcome::SessionFailed {
error: error.into(),
error_info: None,
retry_at: None,
},
false,
analytics_result,
@@ -721,6 +723,7 @@ async fn run_review_on_session(
GuardianReviewSessionOutcome::SessionFailed {
error: anyhow!("guardian review input was not started: {submission:?}"),
error_info: None,
retry_at: None,
},
false,
analytics_result,
@@ -731,6 +734,7 @@ async fn run_review_on_session(
GuardianReviewSessionOutcome::SessionFailed {
error: err.into(),
error_info: None,
retry_at: None,
},
false,
analytics_result,
@@ -945,6 +949,10 @@ async fn wait_for_guardian_review(
GuardianReviewSessionOutcome::SessionFailed {
error: anyhow!(error.message),
error_info: error.codex_error_info,
retry_at: review_session.session.services.thread_extension_data
.get::<crate::responses_retry::ExhaustedResponseRetry>()
.filter(|advice| advice.turn_id == expected_turn_id)
.and_then(|advice| advice.retry_at),
},
true,
true,
@@ -1030,7 +1030,10 @@ async fn wait_for_guardian_review_preserves_structured_session_error() {
)
.await;
let GuardianReviewSessionOutcome::SessionFailed { error, error_info } = outcome else {
let GuardianReviewSessionOutcome::SessionFailed {
error, error_info, ..
} = outcome
else {
panic!("expected structured session failure");
};
assert_eq!(error.to_string(), "temporary failure");
+1 -1
View File
@@ -2985,7 +2985,7 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
"denial rationale should not fall back to the generic missing payload error"
);
assert!(
rejection.contains("Reason: Automatic approval review failed:")
rejection.starts_with("Automatic approval review failed:")
&& rejection.contains(error_message),
"rejection message should include guardian rationale: {rejection}"
);
+15
View File
@@ -39,6 +39,13 @@ impl Default for ResponsesStreamRetryState {
}
}
/// Server retry advice retained after stream retries are exhausted. The turn ID
/// prevents a reused Guardian session from applying advice from an earlier review.
pub(crate) struct ExhaustedResponseRetry {
pub(crate) turn_id: String,
pub(crate) retry_at: Option<tokio::time::Instant>,
}
/// Handles a retryable stream error and returns `Ok(())` when the caller should
/// retry the request loop.
pub(crate) async fn handle_retryable_response_stream_error(
@@ -125,6 +132,14 @@ pub(crate) async fn handle_retryable_response_stream_error(
return Ok(());
}
sess.services
.thread_extension_data
.insert(ExhaustedResponseRetry {
turn_id: turn_context.sub_id.clone(),
retry_at: err
.retry_delay()
.and_then(|delay| tokio::time::Instant::now().checked_add(delay)),
});
Err(err)
}