mirror of
https://github.com/openai/codex.git
synced 2026-11-08 08:37:30 +00:00
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:
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(())
|
||||
}
|
||||
Reference in new issue
Block a user