Move Guardian reviewer settings and execution into the reviewer crate (#44536)

## What changed

- Move reviewer configuration overrides, turn request construction, and deadline, cancellation, and completion handling into `codex-guardian-reviewer`.
- Adapt core sessions through `ReviewerRuntime`, keeping context construction, managed constraints, and live network rules in core.
- Make `GuardianReviewSession` crate-private and remove direct reviewer pool initialization and the reviewer dependency from `guardian-v2`.

## Testing

Update the turn-draining test to exercise `wait_for_guardian_review`, checking that prior-turn completion events are ignored and the session remains reusable after draining the current turn.

GitOrigin-RevId: fdf2b335b88b3f405d2370298ee68932808e1186
This commit is contained in:
jif
2026-09-10 13:49:08 +00:00
committed by copyberry
parent 713caa89f3
commit eca63f0803
12 changed files with 441 additions and 281 deletions
@@ -16,6 +16,7 @@ workspace = true
anyhow = { workspace = true }
codex-analytics = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-protocol = { workspace = true }
rand = { workspace = true }
serde = { workspace = true }
@@ -0,0 +1,184 @@
//! Executes reviewer turns, including deadlines, cancellation and terminal event matching.
//! A session is reusable only after its submitted turn has completed or drained.
use std::future::Future;
use std::time::Duration;
use anyhow::anyhow;
use codex_analytics::GuardianReviewAnalyticsResult;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::turn_input::TurnInputRequest;
use codex_protocol::turn_input::TurnInputSubmission;
use tokio::time::Instant;
use tokio_util::sync::CancellationToken;
use crate::GuardianReviewSessionOutcome;
const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
/// Access to a private runtime. Event reads must be cancellation-safe; context
/// admission runs separately after a read so cancellation cannot consume an event
/// before the context builder has recorded it. Hosts do not interpret review outcomes.
pub trait ReviewerRuntime: Sync {
fn submit_turn(
&self,
request: TurnInputRequest,
) -> impl Future<Output = anyhow::Result<TurnInputSubmission>> + Send;
fn next_event(&self) -> impl Future<Output = anyhow::Result<Event>> + Send;
fn admit_context(&self, event: &Event) -> impl Future<Output = ()> + Send;
fn interrupt(&self) -> impl Future<Output = anyhow::Result<()>> + Send;
fn retry_at(&self, turn_id: &str) -> Option<Instant>;
}
/// Submit exactly one review turn within the review's remaining deadline.
pub async fn start_review_turn(
runtime: &impl ReviewerRuntime,
request: TurnInputRequest,
deadline: Instant,
external_cancel: Option<&CancellationToken>,
) -> Result<String, GuardianReviewSessionOutcome> {
match crate::run_before_review_deadline(deadline, external_cancel, runtime.submit_turn(request))
.await?
{
Ok(TurnInputSubmission::Started { turn_id }) => Ok(turn_id),
result => Err(GuardianReviewSessionOutcome::SessionFailed {
error: match result {
Ok(submission) => anyhow!("guardian review input was not started: {submission:?}"),
Err(error) => error,
},
error_info: None,
retry_at: None,
}),
}
}
pub async fn wait_for_guardian_review(
runtime: &impl ReviewerRuntime,
expected_turn_id: &str,
deadline: tokio::time::Instant,
external_cancel: Option<&CancellationToken>,
analytics_result: &mut GuardianReviewAnalyticsResult,
) -> (GuardianReviewSessionOutcome, bool, bool) {
let timeout = tokio::time::sleep_until(deadline);
tokio::pin!(timeout);
let mut last_error: Option<ErrorEvent> = None;
loop {
tokio::select! {
_ = &mut timeout => {
let keep_review_session = interrupt_and_drain_turn(
runtime,
expected_turn_id,
)
.await
.is_ok();
return (GuardianReviewSessionOutcome::TimedOut, keep_review_session, false);
}
_ = async {
if let Some(cancel_token) = external_cancel {
cancel_token.cancelled().await;
} else {
std::future::pending::<()>().await;
}
} => {
let keep_review_session = interrupt_and_drain_turn(
runtime,
expected_turn_id,
)
.await
.is_ok();
return (GuardianReviewSessionOutcome::Aborted, keep_review_session, false);
}
event = runtime.next_event() => {
match event {
Ok(event) if !event_matches_turn(&event, expected_turn_id) => {}
Ok(event) if matches!(&event.msg, EventMsg::ItemCompleted(_)) => {
runtime.admit_context(&event).await;
}
Ok(event) => match event.msg {
EventMsg::TurnComplete(turn_complete) => {
analytics_result.time_to_first_token_ms = turn_complete
.time_to_first_token_ms
.and_then(|ms| u64::try_from(ms).ok());
if turn_complete.last_agent_message.is_none()
&& let Some(error) = last_error
{
return (
GuardianReviewSessionOutcome::SessionFailed {
error: anyhow!(error.message),
error_info: error.codex_error_info,
retry_at: runtime.retry_at(expected_turn_id),
},
true,
true,
);
}
return (
GuardianReviewSessionOutcome::Completed(Ok(turn_complete.last_agent_message)),
true,
true,
);
}
EventMsg::Error(error) => {
last_error = Some(error);
}
EventMsg::TurnAborted(_) => {
return (GuardianReviewSessionOutcome::Aborted, true, false);
}
_ => {}
},
Err(err) => {
return (
GuardianReviewSessionOutcome::Completed(Err(err)),
false,
false,
);
}
}
}
}
}
}
fn event_matches_turn(event: &Event, expected_turn_id: &str) -> bool {
if event.id != expected_turn_id {
return false;
}
match &event.msg {
EventMsg::TurnComplete(turn_complete) => turn_complete.turn_id == expected_turn_id,
EventMsg::TurnAborted(turn_aborted) => {
turn_aborted.turn_id.as_deref() == Some(expected_turn_id)
}
_ => true,
}
}
async fn interrupt_and_drain_turn(
runtime: &impl ReviewerRuntime,
expected_turn_id: &str,
) -> anyhow::Result<()> {
let _ = runtime.interrupt().await;
tokio::time::timeout(GUARDIAN_INTERRUPT_DRAIN_TIMEOUT, async {
loop {
let event = runtime.next_event().await?;
if !event_matches_turn(&event, expected_turn_id) {
continue;
}
runtime.admit_context(&event).await;
if matches!(
event.msg,
EventMsg::TurnAborted(_) | EventMsg::TurnComplete(_)
) {
return Ok::<(), anyhow::Error>(());
}
}
})
.await
.map_err(|_| anyhow!("timed out draining guardian review session after interrupt"))??;
Ok(())
}
@@ -5,11 +5,13 @@ mod assessment;
mod circuit_breaker;
mod completion;
mod deadline;
mod execution;
mod model;
mod outcome;
mod pool;
mod retry;
mod review;
mod settings;
pub use assessment::GuardianAssessment;
pub use assessment::guardian_output_contract_prompt;
@@ -44,3 +46,10 @@ pub use review::SynchronousReview;
pub use completion::ReviewCompletion;
pub use completion::complete_review;
pub use completion::guardian_timeout_message;
pub use execution::ReviewerRuntime;
pub use execution::start_review_turn;
pub use execution::wait_for_guardian_review;
pub use settings::ReviewerConfigOverrides;
pub use settings::ReviewerTurn;
pub use settings::reviewer_config_overrides;
@@ -0,0 +1,142 @@
//! Defines the reviewer's runtime settings. Hosts apply these settings to their
//! concrete configuration; context construction and managed constraints stay with the host.
use std::collections::HashMap;
use codex_features::Feature;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::Settings;
use codex_protocol::models::PermissionProfile;
use codex_protocol::models::PermissionProfileSnapshot;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EnvironmentConfigState;
use codex_protocol::protocol::ThreadSettingsOverrides;
use codex_protocol::protocol::TurnEnvironmentSelections;
use codex_protocol::turn_input::TurnInputRequest;
use codex_protocol::turn_input::TurnStartOptions;
use codex_protocol::user_input::UserInput;
use serde_json::Value;
/// Configuration policy for a reviewer. The host retains managed constraints and
/// live network rules while applying these values to its concrete runtime config.
pub struct ReviewerConfigOverrides {
pub model: String,
pub reasoning_effort: Option<ReasoningEffort>,
pub request_max_retries: u64,
pub stream_max_retries: u64,
pub include_skill_instructions: bool,
pub use_memories: bool,
pub dedicated_memory_tools: bool,
pub inherit_token_budget: bool,
pub notify: Option<Vec<String>>,
pub developer_instructions: Option<String>,
pub approval_policy: AskForApproval,
pub permission_profile: PermissionProfile,
pub include_apps_instructions: bool,
pub inherit_mcp_servers: bool,
pub disabled_features: Vec<Feature>,
}
pub fn reviewer_config_overrides(
parent_permissions: &PermissionProfile,
model: &str,
reasoning_effort: Option<ReasoningEffort>,
) -> ReviewerConfigOverrides {
ReviewerConfigOverrides {
model: model.to_owned(),
reasoning_effort,
request_max_retries: 1,
stream_max_retries: 1,
include_skill_instructions: false,
use_memories: false,
dedicated_memory_tools: false,
inherit_token_budget: false,
notify: None,
developer_instructions: None,
approval_policy: AskForApproval::Never,
permission_profile: read_only_guardian_permission_profile(parent_permissions),
include_apps_instructions: false,
inherit_mcp_servers: false,
disabled_features: vec![
Feature::Collab,
Feature::MultiAgentV2,
Feature::GuardianV2,
Feature::TokenBudget,
Feature::ContextManagement,
Feature::CodexHooks,
Feature::Apps,
Feature::Plugins,
Feature::WebSearchRequest,
Feature::WebSearchCached,
],
}
}
fn read_only_guardian_permission_profile(profile: &PermissionProfile) -> PermissionProfile {
profile
.intersect_with_read_only()
.unwrap_or(PermissionProfile::External {
network: codex_protocol::permissions::NetworkSandboxPolicy::Restricted,
})
}
/// Context and parent settings captured for a single reviewer turn.
pub struct ReviewerTurn {
pub items: Vec<UserInput>,
pub environments: TurnEnvironmentSelections,
pub permission_profile: PermissionProfile,
pub reasoning_summary: ReasoningSummary,
pub personality: Option<Personality>,
pub model: String,
pub reasoning_effort: Option<ReasoningEffort>,
pub parent_response_id: Option<String>,
pub schema: Value,
pub parent_turn_id: String,
pub root_turn_id: Option<String>,
}
impl ReviewerTurn {
pub fn into_request(mut self) -> TurnInputRequest {
// Apply the same read-only ceiling to every inherited environment.
for environment in &mut self.environments.environments {
if let EnvironmentConfigState::Ready(config) = &mut environment.config {
config.permission_profile =
PermissionProfileSnapshot::legacy(read_only_guardian_permission_profile(
config.permission_profile.permission_profile(),
));
}
}
TurnInputRequest::user_input(self.items)
.with_thread_settings(ThreadSettingsOverrides {
environments: Some(self.environments),
approval_policy: Some(AskForApproval::Never),
permission_profile: Some(self.permission_profile),
summary: Some(self.reasoning_summary),
personality: self.personality,
collaboration_mode: Some(CollaborationMode {
mode: ModeKind::Default,
settings: Settings {
model: self.model,
reasoning_effort: self.reasoning_effort,
developer_instructions: None,
},
}),
..Default::default()
})
.with_responses_metadata(
self.parent_response_id
.map(|id| HashMap::from([("parent_response_id".to_owned(), id)])),
)
.on_start(TurnStartOptions {
turn_trigger: Some("guardian_review".to_owned()),
final_output_json_schema: Some(self.schema),
parent_turn_id: Some(self.parent_turn_id),
root_turn_id: self.root_turn_id,
..Default::default()
})
}
}
-1
View File
@@ -19,7 +19,6 @@ codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-guardian-context = { workspace = true }
codex-guardian-reviewer = { workspace = true }
codex-history = { workspace = true }
codex-http-client = { workspace = true }
codex-login = { workspace = true }
@@ -6,14 +6,12 @@ use std::sync::Weak;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::guardian_review::GuardianReviewSession;
use codex_core::guardian_review::GuardianReviewSessionHost;
use codex_extension_api::ExtensionFuture;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::ThreadLifecycleContributor;
use codex_extension_api::ThreadReadyInput;
use codex_extension_api::ThreadStartInput;
use codex_guardian_reviewer::ReviewerPool;
/// Owns reviewer state through the same thread manager as the parent conversation.
#[derive(Clone, Debug)]
@@ -36,9 +34,6 @@ impl ThreadLifecycleContributor<Config> for GuardianExtension {
if input.session_source.is_internal() {
return;
}
input
.thread_store
.get_or_init(ReviewerPool::<GuardianReviewSession>::default);
input.thread_store.get_or_init(|| {
GuardianReviewSessionHost::with_thread_manager(self.thread_manager.clone())
});