Preserve complete actions in Guardian approval reviews (#44569)

## Why

Truncating action arguments can leave approval reviewers evaluating incomplete actions. Large actions need complete review input and explicit handling when they exceed the review budget.

## What changed

- Remove action truncation and the fixed synchronous action byte limit. Admit complete actions against the whole-request budget, splitting long text losslessly into bounded transport parts and accounting for their framing.
- Route actions exceeding the asynchronous action budget to synchronous review. Prevent cached scores from covering oversized calls, including expanded approval arguments, while allowing later small actions to recover score reuse.
- Request user approval when optional review exhausts its local input budget. Keep required review and compaction service failures closed to approval, and retire exhausted review sessions.

## Testing

Add coverage for complete large-action delivery, optional user fallback, required-review denial, subsequent review recovery, async overflow through MCP approval routing, and lossless text splitting with budget accounting.

GitOrigin-RevId: 08f06e68a94b2f779480dd6b6cf5bc30b241f6dd
This commit is contained in:
felixxia-oai authored and copyberry committed 2026-09-10 16:01:02 +00:00
1 parent 3319d9b296
commit 9c4879f3a5
33 files changed
+841 -547

No files matched your search

@@ -2,6 +2,8 @@
//! Parent token-budget mode must not replace Guardian's summary compaction.
use anyhow::Result;
use codex_config::test_support::CloudConfigBundleFixture;
use codex_core::TurnInputRequest;
use codex_core::config::Constrained;
use codex_core::config::CurrentTimeReminderConfig;
use codex_core::config::RolloutBudgetConfig;
@@ -9,6 +11,10 @@ use codex_features::Feature;
use codex_protocol::config_types::ApprovalsReviewer;
use codex_protocol::openai_models::AutoReviewMessages;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::user_input::UserInput;
use core_test_support::responses;
use core_test_support::responses::ev_assistant_message;
use core_test_support::responses::ev_completed;
@@ -19,6 +25,7 @@ use core_test_support::responses::sse;
use core_test_support::skip_if_no_network;
use core_test_support::skip_if_wine_exec;
use core_test_support::test_codex::test_codex;
use core_test_support::wait_for_event;
use pretty_assertions::assert_eq;
use serde_json::json;
use test_case::test_case;
@@ -28,6 +35,7 @@ enum ReviewerResponse {
Decision,
ToolContinuation,
UncompactableContinuation,
CompactionError,
NextReview,
}
@@ -35,6 +43,7 @@ enum ReviewerResponse {
#[test_case(1, ReviewerResponse::Decision; "required_context_fails_closed")]
#[test_case(4_500, ReviewerResponse::ToolContinuation; "oversized_tool_continuation_compacts")]
#[test_case(4_500, ReviewerResponse::UncompactableContinuation; "ineffective_compaction_fails_closed")]
#[test_case(4_500, ReviewerResponse::CompactionError; "compaction_service_error_does_not_request_user_approval")]
#[test_case(6_000, ReviewerResponse::NextReview; "incoming_review_compacts_existing_history")]
async fn review_respects_complete_context_budget(
window: i64,
@@ -47,6 +56,15 @@ async fn review_respects_complete_context_budget(
);
let server = responses::start_mock_server().await;
let mut builder = test_codex()
.with_cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
if matches!(reviewer_response, ReviewerResponse::CompactionError) {
""
} else {
"[auto_review]\nrequired_on_models = [\"gpt-5.5\"]\n"
},
),
)
.with_model_info_override("gpt-5.6-luna", move |model| {
model.context_window = Some(window);
model
@@ -85,7 +103,9 @@ async fn review_respects_complete_context_budget(
});
if matches!(
reviewer_response,
ReviewerResponse::ToolContinuation | ReviewerResponse::UncompactableContinuation
ReviewerResponse::ToolContinuation
| ReviewerResponse::UncompactableContinuation
| ReviewerResponse::CompactionError
) {
builder = builder
.with_code_mode_host_program(codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?);
@@ -131,7 +151,8 @@ async fn review_respects_complete_context_budget(
.to_string(),
),
ReviewerResponse::ToolContinuation
| ReviewerResponse::UncompactableContinuation => ev_custom_tool_call(
| ReviewerResponse::UncompactableContinuation
| ReviewerResponse::CompactionError => ev_custom_tool_call(
"reviewer-inspect",
"exec",
"text('inspection-output'.repeat(600));",
@@ -155,7 +176,9 @@ async fn review_respects_complete_context_budget(
}
if matches!(
reviewer_response,
ReviewerResponse::UncompactableContinuation | ReviewerResponse::NextReview
ReviewerResponse::UncompactableContinuation
| ReviewerResponse::NextReview
| ReviewerResponse::CompactionError
) {
events.extend([
sse(vec![
@@ -175,7 +198,10 @@ async fn review_respects_complete_context_budget(
]),
]);
}
if !matches!(reviewer_response, ReviewerResponse::Decision) {
if !matches!(
reviewer_response,
ReviewerResponse::Decision | ReviewerResponse::CompactionError
) {
let summary = if matches!(
reviewer_response,
ReviewerResponse::UncompactableContinuation
@@ -200,7 +226,19 @@ async fn review_respects_complete_context_budget(
]),
);
}
let responses = responses::mount_sse_sequence(&server, events).await;
let mut events = events
.into_iter()
.map(responses::sse_response)
.collect::<Vec<_>>();
if matches!(reviewer_response, ReviewerResponse::CompactionError) {
events.insert(
/*index*/ 2,
wiremock::ResponseTemplate::new(400).set_body_json(json!({
"error": {"code": "context_length_exceeded", "message": "compaction service context window exceeded"}
})),
);
}
let responses = responses::mount_response_sequence(&server, events).await;
test.submit_text_turn("Run the command if the approval reviewer allows it.")
.await?;
let (compact_requests, requests): (Vec<_>, Vec<_>) = responses
@@ -218,9 +256,7 @@ async fn review_respects_complete_context_budget(
assert!(guardian_requests.is_empty());
let output = requests[1].function_call_output("exec-over-budget");
assert!(
output
.to_string()
.contains("evidence exceeds limits for section request_budget"),
output.to_string().contains("reviewer input budget"),
"expected required-evidence budget rejection: {output}"
);
} else {
@@ -264,12 +300,20 @@ async fn review_respects_complete_context_budget(
.expect("parent continues after the rejected review")
.function_call_output("exec-over-budget");
assert!(
output
.to_string()
.contains("Codex ran out of room in the model's context window"),
output.to_string().contains("reviewer input budget"),
"expected context-budget rejection: {output}"
);
}
if matches!(reviewer_response, ReviewerResponse::CompactionError) {
assert!(
requests
.last()
.expect("parent resumes after review")
.function_call_output("exec-over-budget")
.to_string()
.contains("context window")
);
}
let request = guardian_requests[0];
let context = request.message_input_texts("user").join("\n");
assert!(context.contains("<guardian_context_omission>"));
@@ -281,7 +325,9 @@ async fn review_respects_complete_context_budget(
assert!(developer_context.contains("<rollout_budget>"));
if matches!(
reviewer_response,
ReviewerResponse::UncompactableContinuation | ReviewerResponse::NextReview
ReviewerResponse::UncompactableContinuation
| ReviewerResponse::NextReview
| ReviewerResponse::CompactionError
) {
let instruction = if matches!(reviewer_response, ReviewerResponse::NextReview) {
"New instructions: retry the requested command and keep all files private. "
@@ -345,3 +391,183 @@ async fn review_respects_complete_context_budget(
test.codex.shutdown_and_wait().await?;
Ok(())
}
#[derive(Clone, Copy)]
enum OversizedActionReview {
Fits,
UserFallback,
RequiredGuardian,
}
#[test_case(OversizedActionReview::Fits; "large_action_receives_automatic_review")]
#[test_case(OversizedActionReview::UserFallback; "optional_review_requests_user_approval")]
#[test_case(OversizedActionReview::RequiredGuardian; "required_review_rejects_incomplete_action")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn oversized_action_preserves_review_policy_and_next_review(
review: OversizedActionReview,
) -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_wine_exec!(
Ok(()),
"Guardian approval actions require host-native paths"
);
let server = responses::start_mock_server().await;
let mut builder = test_codex()
.with_model_info_override("gpt-5.5", |model| {
model.auto_review_model_override = Some("gpt-5.6-luna".to_owned());
})
.with_model_info_override("gpt-5.6-luna", move |model| {
model.context_window = Some(if matches!(review, OversizedActionReview::Fits) {
160_000
} else {
16_000
});
})
.with_config(|config| {
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
config.approvals_reviewer = ApprovalsReviewer::AutoReview;
});
if matches!(review, OversizedActionReview::RequiredGuardian) {
builder = builder.with_model("gpt-5.5").with_cloud_config_bundle(
CloudConfigBundleFixture::loader_with_enterprise_requirement(
"[auto_review]\nrequired_on_models = [\"gpt-5.5\"]\n",
),
);
}
let test = builder.build_with_auto_env(&server).await?;
let oversized_command =
"echo ".to_owned() + &"large-action".repeat(/*n*/ 20_000) + "required suffix";
let oversized = json!({
"cmd": oversized_command,
"sandbox_permissions": "require_escalated",
"justification": "Run the requested command.",
})
.to_string();
let next = json!({
"cmd": "echo complete-action-review",
"sandbox_permissions": "require_escalated",
"justification": "Run the requested command.",
})
.to_string();
let mut events = vec![
sse(vec![
ev_function_call("oversized", "exec_command", &oversized),
ev_completed("oversized"),
]),
sse(vec![
ev_function_call("next", "exec_command", &next),
ev_completed("next"),
]),
sse(vec![
ev_assistant_message("decision", r#"{"outcome":"allow"}"#),
ev_completed("review"),
]),
sse(vec![
ev_assistant_message("done", "done"),
ev_completed("done"),
]),
];
if matches!(review, OversizedActionReview::Fits) {
// A real policy denial must not fall through to a user prompt. Avoid
// executing the huge command while asserting its complete review input.
events.insert(
/*index*/ 1,
sse(vec![
ev_assistant_message(
"decision",
r#"{"outcome":"deny","rationale":"denied by classifier"}"#,
),
ev_completed("large-review"),
]),
);
}
let response = responses::mount_sse_sequence(&server, events).await;
test.codex
.start_or_steer_turn(TurnInputRequest::user_input(vec![UserInput::Text {
text: "Run the commands if the approval reviewer allows them.".to_owned(),
text_elements: Vec::new(),
}]))
.await?;
let mut terminal_assessment = None;
let event = wait_for_event(&test.codex, |event| {
if let EventMsg::GuardianAssessment(assessment) = event
&& assessment.status != codex_protocol::protocol::GuardianAssessmentStatus::InProgress
{
terminal_assessment = Some((assessment.status, assessment.risk_level));
}
matches!(
event,
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
)
})
.await;
if let EventMsg::ExecApprovalRequest(approval) = event {
assert!(
matches!(review, OversizedActionReview::UserFallback),
"only exhausted optional review may ask the user"
);
assert_eq!(
terminal_assessment,
Some((
codex_protocol::protocol::GuardianAssessmentStatus::Aborted,
None
))
);
assert_eq!(approval.command.last(), Some(&oversized_command));
test.codex
.submit(Op::ExecApproval {
id: approval.effective_approval_id(),
turn_id: None,
decision: ReviewDecision::denied("rejected by user"),
})
.await?;
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
} else {
assert!(
!matches!(review, OversizedActionReview::UserFallback),
"exhausted optional review must request user approval"
);
}
let requests = response.requests();
let fits = matches!(review, OversizedActionReview::Fits);
assert_eq!(requests.len(), if fits { 5 } else { 4 });
assert!(
requests[if fits { 2 } else { 1 }]
.function_call_output("oversized")
.to_string()
.contains(match review {
OversizedActionReview::Fits => "denied by classifier",
OversizedActionReview::UserFallback => "rejected by user",
OversizedActionReview::RequiredGuardian => "reviewer input budget",
})
);
let reviews = requests
.iter()
.filter(|request| request.body_json()["client_metadata"]["x-openai-subagent"] == "guardian")
.collect::<Vec<_>>();
assert_eq!(reviews.len(), if fits { 2 } else { 1 });
if fits {
let parts = reviews[0].message_input_texts("user");
assert!(parts.concat().contains(&oversized_command));
assert!(parts.iter().all(|part| part.len() <= 36_000));
}
let context = reviews
.last()
.expect("review of the next action")
.message_input_texts("user")
.concat();
assert!(context.contains("echo complete-action-review"));
assert!(
requests
.last()
.expect("parent resumes after review")
.function_call_output("next")
.to_string()
.contains("complete-action-review")
);
test.codex.shutdown_and_wait().await?;
Ok(())
}