mirror of
https://github.com/openai/codex.git
synced 2026-10-25 23:57:40 +00:00
Extract Guardian review policy into a dedicated crate (#44227)
## What changed Move assessment parsing and schema, model selection, review outcomes, retry handling, and rejection circuit breakers into `codex-guardian-reviewer`. Update core to use the extracted APIs while retaining session execution and decision enforcement in the host. ## Testing Move existing assessment, circuit breaker, and retry tests into the new crate, including coverage for transient errors, cancellation, and deadlines. GitOrigin-RevId: 96ec9989a0066acb012bf3bec0b9f7d8bf11a4ef
This commit is contained in:
@@ -1,123 +0,0 @@
|
||||
//! The production synchronous reviewer's output schema and tolerant JSON parser.
|
||||
//! Parsing defaults and error text are shared unchanged by all reviewer callers.
|
||||
|
||||
use codex_protocol::protocol::GuardianAssessmentOutcome;
|
||||
use codex_protocol::protocol::GuardianRiskLevel;
|
||||
use codex_protocol::protocol::GuardianUserAuthorization;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Structured output contract that the guardian reviewer must satisfy.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
pub struct GuardianAssessment {
|
||||
pub risk_level: codex_protocol::protocol::GuardianRiskLevel,
|
||||
pub user_authorization: codex_protocol::protocol::GuardianUserAuthorization,
|
||||
pub outcome: GuardianAssessmentOutcome,
|
||||
pub rationale: String,
|
||||
}
|
||||
|
||||
/// The model is asked for strict JSON, but we still accept a surrounding prose
|
||||
/// wrapper so transient formatting drift fails less noisily during dogfooding.
|
||||
/// Non-JSON output is still a review failure; this is only a thin recovery path
|
||||
/// for cases where the model wrapped the JSON in extra prose.
|
||||
pub fn parse_guardian_assessment(text: Option<&str>) -> anyhow::Result<GuardianAssessment> {
|
||||
let Some(text) = text else {
|
||||
anyhow::bail!("guardian review completed without an assessment payload");
|
||||
};
|
||||
let parsed_payload =
|
||||
if let Ok(payload) = serde_json::from_str::<GuardianAssessmentPayload>(text) {
|
||||
payload
|
||||
} else if let (Some(start), Some(end)) = (text.find('{'), text.rfind('}'))
|
||||
&& start < end
|
||||
&& let Some(slice) = text.get(start..=end)
|
||||
{
|
||||
serde_json::from_str::<GuardianAssessmentPayload>(slice)?
|
||||
} else {
|
||||
anyhow::bail!("guardian assessment was not valid JSON");
|
||||
};
|
||||
|
||||
let outcome = parsed_payload.outcome;
|
||||
let risk_level = parsed_payload.risk_level.unwrap_or(match outcome {
|
||||
GuardianAssessmentOutcome::Allow => GuardianRiskLevel::Low,
|
||||
GuardianAssessmentOutcome::Deny => GuardianRiskLevel::High,
|
||||
});
|
||||
let rationale = parsed_payload
|
||||
.rationale
|
||||
.filter(|rationale| !rationale.trim().is_empty())
|
||||
.unwrap_or_else(|| match outcome {
|
||||
GuardianAssessmentOutcome::Allow => {
|
||||
"Auto-review returned a low-risk allow decision.".to_string()
|
||||
}
|
||||
GuardianAssessmentOutcome::Deny => {
|
||||
"Auto-review returned a deny decision without a rationale.".to_string()
|
||||
}
|
||||
});
|
||||
|
||||
Ok(GuardianAssessment {
|
||||
risk_level,
|
||||
user_authorization: parsed_payload
|
||||
.user_authorization
|
||||
.unwrap_or(GuardianUserAuthorization::Unknown),
|
||||
outcome,
|
||||
rationale,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct GuardianAssessmentPayload {
|
||||
risk_level: Option<GuardianRiskLevel>,
|
||||
user_authorization: Option<GuardianUserAuthorization>,
|
||||
outcome: GuardianAssessmentOutcome,
|
||||
rationale: Option<String>,
|
||||
}
|
||||
|
||||
/// JSON schema supplied as `final_output_json_schema` to guide a structured
|
||||
/// final answer from the guardian review session.
|
||||
///
|
||||
/// Keep this next to `guardian_output_contract_prompt()` so the prompt text and
|
||||
/// output schema stay aligned.
|
||||
pub fn guardian_output_schema() -> Value {
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"risk_level": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high", "critical"]
|
||||
},
|
||||
"user_authorization": {
|
||||
"type": "string",
|
||||
"enum": ["unknown", "low", "medium", "high"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["outcome"]
|
||||
})
|
||||
}
|
||||
|
||||
/// Prompt fragment that describes the exact JSON contract paired with
|
||||
/// `guardian_output_schema()`.
|
||||
pub(super) fn guardian_output_contract_prompt() -> &'static str {
|
||||
r#"You may use read-only tool checks to gather any additional context you need before deciding. When you are ready to answer, your final message must be strict JSON.
|
||||
|
||||
For low-risk actions, give the final answer directly: {"outcome":"allow"}.
|
||||
|
||||
For anything else, use this JSON schema:
|
||||
{
|
||||
"risk_level": "low" | "medium" | "high" | "critical",
|
||||
"user_authorization": "unknown" | "low" | "medium" | "high",
|
||||
"outcome": "allow" | "deny",
|
||||
"rationale": string
|
||||
}"#
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "assessment_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,83 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parse_guardian_assessment_extracts_embedded_json() {
|
||||
let parsed = parse_guardian_assessment(Some(
|
||||
"preface {\"risk_level\":\"medium\",\"user_authorization\":\"low\",\"outcome\":\"allow\",\"rationale\":\"ok\"}",
|
||||
))
|
||||
.expect("guardian assessment");
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::Medium,
|
||||
user_authorization: GuardianUserAuthorization::Low,
|
||||
outcome: GuardianAssessmentOutcome::Allow,
|
||||
rationale: "ok".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_guardian_assessment_treats_bare_allow_as_low_risk() {
|
||||
let parsed =
|
||||
parse_guardian_assessment(Some(r#"{"outcome":"allow"}"#)).expect("guardian assessment");
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::Low,
|
||||
user_authorization: GuardianUserAuthorization::Unknown,
|
||||
outcome: GuardianAssessmentOutcome::Allow,
|
||||
rationale: "Auto-review returned a low-risk allow decision.".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_guardian_assessment_treats_bare_deny_as_high_risk() {
|
||||
let parsed =
|
||||
parse_guardian_assessment(Some(r#"{"outcome":"deny"}"#)).expect("guardian assessment");
|
||||
|
||||
assert_eq!(
|
||||
parsed,
|
||||
GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::High,
|
||||
user_authorization: GuardianUserAuthorization::Unknown,
|
||||
outcome: GuardianAssessmentOutcome::Deny,
|
||||
rationale: "Auto-review returned a deny decision without a rationale.".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_output_schema_requires_only_outcome_and_allows_optional_details() {
|
||||
let schema = guardian_output_schema();
|
||||
|
||||
assert_eq!(
|
||||
schema,
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"risk_level": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high", "critical"]
|
||||
},
|
||||
"user_authorization": {
|
||||
"type": "string",
|
||||
"enum": ["unknown", "low", "medium", "high"]
|
||||
},
|
||||
"outcome": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
},
|
||||
"rationale": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["outcome"]
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,6 @@
|
||||
//! review requirements. Each approval retains its issuing context and cancellation.
|
||||
|
||||
mod approval_request;
|
||||
mod assessment;
|
||||
mod coverage;
|
||||
mod decision;
|
||||
mod feedback;
|
||||
@@ -17,7 +16,6 @@ mod reviewer_config;
|
||||
mod runtime;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
@@ -54,13 +52,9 @@ pub use review_session::GuardianReviewSessionManager;
|
||||
pub(crate) use review_session::prompt_cache_key_override_for_review_session;
|
||||
pub(crate) use runtime::ReviewAction;
|
||||
|
||||
pub(crate) const GUARDIAN_REVIEW_TIMEOUT: Duration = Duration::from_secs(90);
|
||||
pub(crate) use codex_guardian_reviewer::REVIEW_TIMEOUT as GUARDIAN_REVIEW_TIMEOUT;
|
||||
pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian";
|
||||
pub(crate) const MAX_CONSECUTIVE_CYBER_GUARDIAN_DENIALS_PER_TURN: u32 = 1;
|
||||
pub(crate) const MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN: u32 = 3;
|
||||
pub(crate) const MAX_RECENT_CYBER_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 1;
|
||||
pub(crate) const MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN: u32 = 10;
|
||||
pub(crate) const AUTO_REVIEW_DENIAL_WINDOW_SIZE: usize = 50;
|
||||
pub(crate) use codex_guardian_reviewer::AUTO_REVIEW_DENIAL_WINDOW_SIZE;
|
||||
pub(crate) const AUTO_REVIEW_DENIED_ACTION_APPROVAL_DEVELOPER_PREFIX: &str =
|
||||
codex_guardian_context::MANUAL_APPROVAL_DEVELOPER_PREFIX;
|
||||
const GUARDIAN_MAX_TOOL_ENTRY_TOKENS: usize = codex_guardian_context::ContextProfile::synchronous()
|
||||
@@ -166,89 +160,14 @@ impl From<&Arc<TurnContext>> for GuardianReviewContext {
|
||||
}
|
||||
}
|
||||
|
||||
pub use assessment::GuardianAssessment;
|
||||
pub use assessment::guardian_output_schema;
|
||||
pub use assessment::parse_guardian_assessment;
|
||||
pub use codex_guardian_reviewer::GuardianAssessment;
|
||||
pub use codex_guardian_reviewer::guardian_output_schema;
|
||||
pub use codex_guardian_reviewer::parse_guardian_assessment;
|
||||
pub use reviewer_config::build_guardian_review_session_config;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct GuardianRejectionCircuitBreaker {
|
||||
turns: std::collections::HashMap<String, GuardianRejectionCircuitBreakerTurn>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct GuardianRejectionCircuitBreakerTurn {
|
||||
consecutive_denials: u32,
|
||||
recent_denials: std::collections::VecDeque<bool>,
|
||||
interrupt_triggered: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GuardianRejectionCircuitBreakerPolicy {
|
||||
Standard,
|
||||
CyberModel,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GuardianRejectionCircuitBreakerAction {
|
||||
Continue,
|
||||
InterruptTurn {
|
||||
consecutive_denials: u32,
|
||||
recent_denials: u32,
|
||||
},
|
||||
}
|
||||
|
||||
impl GuardianRejectionCircuitBreaker {
|
||||
pub(crate) fn clear_turn(&mut self, turn_id: &str) {
|
||||
self.turns.remove(turn_id);
|
||||
}
|
||||
|
||||
pub(crate) fn record_denial(
|
||||
&mut self,
|
||||
turn_id: &str,
|
||||
policy: GuardianRejectionCircuitBreakerPolicy,
|
||||
) -> GuardianRejectionCircuitBreakerAction {
|
||||
let turn = self.turns.entry(turn_id.to_string()).or_default();
|
||||
turn.consecutive_denials = turn.consecutive_denials.saturating_add(1);
|
||||
Self::record_recent_review(turn, /*denied*/ true);
|
||||
let recent_denials = turn.recent_denials.iter().filter(|denied| **denied).count() as u32;
|
||||
let (max_consecutive_denials, max_recent_denials) = match policy {
|
||||
GuardianRejectionCircuitBreakerPolicy::Standard => (
|
||||
MAX_CONSECUTIVE_GUARDIAN_DENIALS_PER_TURN,
|
||||
MAX_RECENT_AUTO_REVIEW_DENIALS_PER_TURN,
|
||||
),
|
||||
GuardianRejectionCircuitBreakerPolicy::CyberModel => (
|
||||
MAX_CONSECUTIVE_CYBER_GUARDIAN_DENIALS_PER_TURN,
|
||||
MAX_RECENT_CYBER_AUTO_REVIEW_DENIALS_PER_TURN,
|
||||
),
|
||||
};
|
||||
if !turn.interrupt_triggered
|
||||
&& (turn.consecutive_denials >= max_consecutive_denials
|
||||
|| recent_denials >= max_recent_denials)
|
||||
{
|
||||
turn.interrupt_triggered = true;
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: turn.consecutive_denials,
|
||||
recent_denials,
|
||||
}
|
||||
} else {
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn record_non_denial(&mut self, turn_id: &str) {
|
||||
let turn = self.turns.entry(turn_id.to_string()).or_default();
|
||||
turn.consecutive_denials = 0;
|
||||
Self::record_recent_review(turn, /*denied*/ false);
|
||||
}
|
||||
|
||||
fn record_recent_review(turn: &mut GuardianRejectionCircuitBreakerTurn, denied: bool) {
|
||||
turn.recent_denials.push_back(denied);
|
||||
if turn.recent_denials.len() > AUTO_REVIEW_DENIAL_WINDOW_SIZE {
|
||||
turn.recent_denials.pop_front();
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) use codex_guardian_reviewer::GuardianRejectionCircuitBreaker;
|
||||
pub(crate) use codex_guardian_reviewer::GuardianRejectionCircuitBreakerAction;
|
||||
pub(crate) use codex_guardian_reviewer::GuardianRejectionCircuitBreakerPolicy;
|
||||
|
||||
pub(crate) use approval_request::format_guardian_action_pretty;
|
||||
#[cfg(test)]
|
||||
@@ -256,6 +175,8 @@ use approval_request::guardian_assessment_action;
|
||||
#[cfg(test)]
|
||||
use approval_request::guardian_request_turn_id;
|
||||
#[cfg(test)]
|
||||
use codex_guardian_reviewer::GuardianReviewOutcome;
|
||||
#[cfg(test)]
|
||||
use prompt::GuardianPromptMode;
|
||||
#[cfg(test)]
|
||||
use prompt::GuardianTranscriptCursor;
|
||||
@@ -266,8 +187,6 @@ use prompt::build_guardian_prompt_items_with_parent_turn;
|
||||
#[cfg(test)]
|
||||
use prompt::render_guardian_transcript_entries;
|
||||
#[cfg(test)]
|
||||
use review::GuardianReviewOutcome;
|
||||
#[cfg(test)]
|
||||
use review::run_guardian_review_session_with_retry as run_guardian_review_session_for_test;
|
||||
#[cfg(test)]
|
||||
use review_session::build_guardian_review_session_config as build_guardian_review_session_config_for_test;
|
||||
|
||||
@@ -362,8 +362,8 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> (String
|
||||
)
|
||||
}
|
||||
|
||||
use super::assessment::guardian_output_contract_prompt;
|
||||
pub use super::assessment::parse_guardian_assessment;
|
||||
use codex_guardian_reviewer::guardian_output_contract_prompt;
|
||||
pub use codex_guardian_reviewer::parse_guardian_assessment;
|
||||
|
||||
pub(crate) const BUNDLED_GUARDIAN_POLICY: &str = include_str!("../../assets/guardian/policy.md");
|
||||
pub(crate) const BUNDLED_GUARDIAN_POLICY_TEMPLATE: &str =
|
||||
|
||||
@@ -9,11 +9,13 @@ use codex_analytics::GuardianReviewedAction;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_extension_api::ThreadIdleCause;
|
||||
use codex_features::Feature;
|
||||
use codex_guardian_reviewer::GuardianReviewError;
|
||||
use codex_guardian_reviewer::GuardianReviewOutcome;
|
||||
use codex_guardian_reviewer::GuardianReviewSessionLimits;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
|
||||
use codex_protocol::protocol::GuardianAssessmentEvent;
|
||||
@@ -29,7 +31,6 @@ use codex_protocol::protocol::WarningEvent;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::time::Instant;
|
||||
use tokio::time::sleep_until;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::context::GuardianNodeReplPolicy;
|
||||
@@ -37,7 +38,6 @@ use crate::context::GuardianReviewEvidence;
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
use crate::util::backoff;
|
||||
|
||||
use super::AUTO_REVIEW_DENIAL_WINDOW_SIZE;
|
||||
use super::ApprovalRequestReasons;
|
||||
@@ -54,12 +54,10 @@ use super::approval_request::guardian_assessment_action;
|
||||
use super::approval_request::guardian_request_target_item_id;
|
||||
use super::approval_request::guardian_request_turn_id;
|
||||
use super::approval_request::guardian_reviewed_action;
|
||||
use super::assessment::guardian_output_schema;
|
||||
use super::metrics::emit_guardian_review_metrics;
|
||||
use super::prompt::parse_guardian_assessment;
|
||||
use super::review_session::GuardianReviewSessionOutcome;
|
||||
use super::review_session::GuardianReviewSessionParams;
|
||||
use super::review_session::build_guardian_review_session_config;
|
||||
use codex_guardian_reviewer::guardian_output_schema;
|
||||
|
||||
const GUARDIAN_REJECTION_INSTRUCTIONS: &str = concat!(
|
||||
"The agent must not attempt to achieve the same outcome via workaround, ",
|
||||
@@ -75,7 +73,6 @@ const GUARDIAN_TIMEOUT_INSTRUCTIONS: &str = concat!(
|
||||
"You may retry once, or ask the user for guidance or explicit approval.",
|
||||
);
|
||||
|
||||
const GUARDIAN_REVIEW_MAX_ATTEMPTS: i64 = 3;
|
||||
const GUARDIAN_PLUGIN_ATTRIBUTION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
async fn plugin_attribution_for_guardian_request(
|
||||
@@ -138,66 +135,6 @@ pub(crate) fn guardian_timeout_message(model_info: &ModelInfo) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum GuardianReviewOutcome {
|
||||
Completed(GuardianAssessment),
|
||||
Error(GuardianReviewError),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) enum GuardianReviewError {
|
||||
PromptBuild {
|
||||
message: String,
|
||||
},
|
||||
Session {
|
||||
message: String,
|
||||
error_info: Option<CodexErrorInfo>,
|
||||
},
|
||||
Parse {
|
||||
message: String,
|
||||
},
|
||||
Timeout,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl GuardianReviewError {
|
||||
fn prompt_build(err: anyhow::Error) -> Self {
|
||||
Self::PromptBuild {
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn session(err: anyhow::Error) -> Self {
|
||||
Self::Session {
|
||||
message: err.to_string(),
|
||||
error_info: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_with_error_info(err: anyhow::Error, error_info: CodexErrorInfo) -> Self {
|
||||
Self::Session {
|
||||
message: err.to_string(),
|
||||
error_info: Some(error_info),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(err: anyhow::Error) -> Self {
|
||||
Self::Parse {
|
||||
message: err.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_reason(&self) -> GuardianReviewFailureReason {
|
||||
match self {
|
||||
Self::PromptBuild { .. } => GuardianReviewFailureReason::PromptBuildError,
|
||||
Self::Session { .. } => GuardianReviewFailureReason::SessionError,
|
||||
Self::Parse { .. } => GuardianReviewFailureReason::ParseError,
|
||||
Self::Timeout => GuardianReviewFailureReason::Timeout,
|
||||
Self::Cancelled => GuardianReviewFailureReason::Cancelled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn guardian_risk_level_str(level: GuardianRiskLevel) -> &'static str {
|
||||
match level {
|
||||
GuardianRiskLevel::Low => "low",
|
||||
@@ -511,7 +448,7 @@ pub(super) async fn run_synchronous_review(
|
||||
schema,
|
||||
external_cancel,
|
||||
GuardianReviewSessionLimits {
|
||||
max_attempts: GUARDIAN_REVIEW_MAX_ATTEMPTS,
|
||||
max_attempts: codex_guardian_reviewer::MAX_REVIEW_ATTEMPTS,
|
||||
deadline,
|
||||
},
|
||||
))
|
||||
@@ -824,49 +761,19 @@ pub(super) async fn guardian_review_session_config(
|
||||
)
|
||||
.await;
|
||||
let default_review_model_id = turn.provider.approval_review_preferred_model();
|
||||
let preferred_reasoning_effort = |supports_low: bool, fallback| {
|
||||
if supports_low {
|
||||
Some(codex_protocol::openai_models::ReasoningEffort::Low)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
};
|
||||
let model_override = turn.model_info().auto_review_model_override.as_deref();
|
||||
let review_model_id = model_override.unwrap_or(default_review_model_id);
|
||||
let review_model = available_models
|
||||
.iter()
|
||||
.find(|preset| preset.model == review_model_id);
|
||||
let guardian_catalog_contains_auto_review = available_models
|
||||
.iter()
|
||||
.any(|preset| preset.model == default_review_model_id);
|
||||
let guardian_review_model_overridden = model_override.is_some();
|
||||
let guardian_review_model_override = model_override.map(str::to_string);
|
||||
let (guardian_model, guardian_reasoning_effort) = if let Some(preset) = review_model {
|
||||
let reasoning_effort = preferred_reasoning_effort(
|
||||
preset
|
||||
.supported_reasoning_efforts
|
||||
.iter()
|
||||
.any(|effort| effort.effort == codex_protocol::openai_models::ReasoningEffort::Low),
|
||||
Some(preset.default_reasoning_effort.clone()),
|
||||
);
|
||||
(review_model_id.to_string(), reasoning_effort)
|
||||
} else {
|
||||
let reasoning_effort = preferred_reasoning_effort(
|
||||
turn.model_info()
|
||||
.supported_reasoning_levels
|
||||
.iter()
|
||||
.any(|preset| preset.effort == codex_protocol::openai_models::ReasoningEffort::Low),
|
||||
turn.reasoning_effort()
|
||||
.or(turn.model_info().default_reasoning_level.as_ref())
|
||||
.cloned(),
|
||||
);
|
||||
(
|
||||
model_override
|
||||
.unwrap_or(turn.model_info().slug.as_str())
|
||||
.to_string(),
|
||||
reasoning_effort,
|
||||
)
|
||||
};
|
||||
let codex_guardian_reviewer::ReviewModel {
|
||||
model: guardian_model,
|
||||
reasoning_effort: guardian_reasoning_effort,
|
||||
default_review_model_id,
|
||||
catalog_contains_auto_review: guardian_catalog_contains_auto_review,
|
||||
model_overridden: guardian_review_model_overridden,
|
||||
model_override: guardian_review_model_override,
|
||||
} = codex_guardian_reviewer::select_review_model(
|
||||
turn.model_info(),
|
||||
turn.reasoning_effort(),
|
||||
default_review_model_id,
|
||||
&available_models,
|
||||
);
|
||||
|
||||
let guardian_model_info = session
|
||||
.services
|
||||
@@ -905,7 +812,7 @@ pub(super) async fn guardian_review_session_config(
|
||||
),
|
||||
model: guardian_model,
|
||||
reasoning_effort: guardian_reasoning_effort,
|
||||
default_review_model_id: default_review_model_id.to_string(),
|
||||
default_review_model_id,
|
||||
catalog_contains_auto_review: guardian_catalog_contains_auto_review,
|
||||
model_overridden: guardian_review_model_overridden,
|
||||
model_override: guardian_review_model_override,
|
||||
@@ -973,55 +880,7 @@ async fn run_guardian_review_session_before_deadline(
|
||||
)
|
||||
.await;
|
||||
|
||||
match session_outcome {
|
||||
GuardianReviewSessionOutcome::Completed(Ok(last_agent_message)) => match last_agent_message
|
||||
{
|
||||
Some(last_agent_message) => {
|
||||
match parse_guardian_assessment(Some(&last_agent_message)) {
|
||||
Ok(assessment) => (
|
||||
GuardianReviewOutcome::Completed(assessment),
|
||||
session_analytics_result,
|
||||
),
|
||||
Err(err) => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::parse(err)),
|
||||
session_analytics_result,
|
||||
),
|
||||
}
|
||||
}
|
||||
None => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session(anyhow::anyhow!(
|
||||
"guardian review completed without an assessment payload"
|
||||
))),
|
||||
session_analytics_result,
|
||||
),
|
||||
},
|
||||
GuardianReviewSessionOutcome::Completed(Err(err)) => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session(err)),
|
||||
session_analytics_result,
|
||||
),
|
||||
GuardianReviewSessionOutcome::PromptBuildFailed(err) => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(err)),
|
||||
session_analytics_result,
|
||||
),
|
||||
GuardianReviewSessionOutcome::SessionFailed { error, error_info } => {
|
||||
let error = match error_info {
|
||||
Some(error_info) => GuardianReviewError::session_with_error_info(error, error_info),
|
||||
None => GuardianReviewError::session(error),
|
||||
};
|
||||
(
|
||||
GuardianReviewOutcome::Error(error),
|
||||
session_analytics_result,
|
||||
)
|
||||
}
|
||||
GuardianReviewSessionOutcome::TimedOut => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Timeout),
|
||||
session_analytics_result,
|
||||
),
|
||||
GuardianReviewSessionOutcome::Aborted => (
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Cancelled),
|
||||
session_analytics_result,
|
||||
),
|
||||
}
|
||||
(session_outcome.into(), session_analytics_result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1049,11 +908,6 @@ pub(super) async fn run_guardian_review_session_with_retry(
|
||||
.await
|
||||
}
|
||||
|
||||
struct GuardianReviewSessionLimits {
|
||||
max_attempts: i64,
|
||||
deadline: Instant,
|
||||
}
|
||||
|
||||
async fn run_guardian_review_session_with_retry_before_deadline(
|
||||
session: Arc<Session>,
|
||||
context: impl Into<GuardianReviewContext>,
|
||||
@@ -1063,15 +917,9 @@ async fn run_guardian_review_session_with_retry_before_deadline(
|
||||
external_cancel: Option<CancellationToken>,
|
||||
limits: GuardianReviewSessionLimits,
|
||||
) -> (GuardianReviewOutcome, GuardianReviewAnalyticsResult) {
|
||||
let GuardianReviewSessionLimits {
|
||||
max_attempts,
|
||||
deadline,
|
||||
} = limits;
|
||||
let context = context.into();
|
||||
assert!(max_attempts > 0, "guardian review must run at least once");
|
||||
let mut attempt_count = 1;
|
||||
loop {
|
||||
let (outcome, mut analytics_result) = run_guardian_review_session_before_deadline(
|
||||
codex_guardian_reviewer::run_with_retry(limits, external_cancel.as_ref(), |deadline| {
|
||||
run_guardian_review_session_before_deadline(
|
||||
Arc::clone(&session),
|
||||
context.clone(),
|
||||
request.clone(),
|
||||
@@ -1080,190 +928,6 @@ async fn run_guardian_review_session_with_retry_before_deadline(
|
||||
external_cancel.clone(),
|
||||
deadline,
|
||||
)
|
||||
.await;
|
||||
analytics_result.attempt_count = attempt_count;
|
||||
if attempt_count >= max_attempts || !should_retry_guardian_review(&outcome) {
|
||||
return (outcome, analytics_result);
|
||||
}
|
||||
if let Some(error) =
|
||||
wait_before_guardian_retry(attempt_count, deadline, external_cancel.as_ref()).await
|
||||
{
|
||||
return (GuardianReviewOutcome::Error(error), analytics_result);
|
||||
}
|
||||
attempt_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_before_guardian_retry(
|
||||
attempt_count: i64,
|
||||
deadline: Instant,
|
||||
external_cancel: Option<&CancellationToken>,
|
||||
) -> Option<GuardianReviewError> {
|
||||
let retry_delay = backoff(attempt_count as u64);
|
||||
let retry_at = (Instant::now() + retry_delay).min(deadline);
|
||||
tokio::select! {
|
||||
_ = sleep_until(retry_at) => {
|
||||
(Instant::now() >= deadline).then_some(GuardianReviewError::Timeout)
|
||||
}
|
||||
_ = async {
|
||||
if let Some(cancel_token) = external_cancel {
|
||||
cancel_token.cancelled().await;
|
||||
} else {
|
||||
std::future::pending::<()>().await;
|
||||
}
|
||||
} => Some(GuardianReviewError::Cancelled),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_retry_guardian_review(outcome: &GuardianReviewOutcome) -> bool {
|
||||
matches!(
|
||||
outcome,
|
||||
GuardianReviewOutcome::Error(
|
||||
GuardianReviewError::Session {
|
||||
error_info: Some(
|
||||
CodexErrorInfo::ServerOverloaded
|
||||
| CodexErrorInfo::HttpConnectionFailed { .. }
|
||||
| CodexErrorInfo::ResponseStreamConnectionFailed { .. }
|
||||
| CodexErrorInfo::InternalServerError
|
||||
| CodexErrorInfo::ResponseStreamDisconnected { .. }
|
||||
),
|
||||
..
|
||||
} | GuardianReviewError::Parse { .. }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod review_tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn guardian_review_error_reason_distinguishes_error_kinds() {
|
||||
let parse_error = GuardianReviewError::parse(anyhow::anyhow!("bad guardian JSON"));
|
||||
let prompt_error = GuardianReviewError::prompt_build(anyhow::anyhow!("bad prompt/config"));
|
||||
let session_error =
|
||||
GuardianReviewError::session(anyhow::anyhow!("guardian runtime failed"));
|
||||
let structured_session_error = GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("temporary guardian failure"),
|
||||
CodexErrorInfo::ServerOverloaded,
|
||||
);
|
||||
|
||||
assert!(matches!(
|
||||
parse_error.failure_reason(),
|
||||
GuardianReviewFailureReason::ParseError
|
||||
));
|
||||
assert!(matches!(
|
||||
prompt_error.failure_reason(),
|
||||
GuardianReviewFailureReason::PromptBuildError
|
||||
));
|
||||
assert!(matches!(
|
||||
session_error.failure_reason(),
|
||||
GuardianReviewFailureReason::SessionError
|
||||
));
|
||||
assert!(matches!(
|
||||
structured_session_error.failure_reason(),
|
||||
GuardianReviewFailureReason::SessionError
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_retry_only_retries_transient_session_and_parse_errors() {
|
||||
let assessment = GuardianAssessment {
|
||||
risk_level: GuardianRiskLevel::High,
|
||||
user_authorization: GuardianUserAuthorization::Unknown,
|
||||
outcome: GuardianAssessmentOutcome::Deny,
|
||||
rationale: "deny".to_string(),
|
||||
};
|
||||
let transient_error_info = [
|
||||
CodexErrorInfo::ServerOverloaded,
|
||||
CodexErrorInfo::HttpConnectionFailed {
|
||||
http_status_code: Some(502),
|
||||
},
|
||||
CodexErrorInfo::ResponseStreamConnectionFailed {
|
||||
http_status_code: Some(503),
|
||||
},
|
||||
CodexErrorInfo::InternalServerError,
|
||||
CodexErrorInfo::ResponseStreamDisconnected {
|
||||
http_status_code: None,
|
||||
},
|
||||
];
|
||||
let mut outcomes = transient_error_info
|
||||
.into_iter()
|
||||
.map(|error_info| {
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("transient session"),
|
||||
error_info,
|
||||
)),
|
||||
true,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
outcomes.extend([
|
||||
(GuardianReviewOutcome::Completed(assessment), false),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::prompt_build(anyhow::anyhow!(
|
||||
"prompt"
|
||||
))),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session(anyhow::anyhow!(
|
||||
"session"
|
||||
))),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::session_with_error_info(
|
||||
anyhow::anyhow!("bad request"),
|
||||
CodexErrorInfo::BadRequest,
|
||||
)),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::parse(anyhow::anyhow!("parse"))),
|
||||
true,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Timeout),
|
||||
false,
|
||||
),
|
||||
(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Cancelled),
|
||||
false,
|
||||
),
|
||||
]);
|
||||
|
||||
for (outcome, expected) in outcomes {
|
||||
assert_eq!(should_retry_guardian_review(&outcome), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_retry_wait_honors_cancellation() {
|
||||
let cancel_token = CancellationToken::new();
|
||||
cancel_token.cancel();
|
||||
|
||||
let error = wait_before_guardian_retry(
|
||||
/*attempt_count*/ 1,
|
||||
Instant::now() + Duration::from_secs(/*secs*/ 1),
|
||||
Some(&cancel_token),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(error, Some(GuardianReviewError::Cancelled)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn guardian_review_retry_wait_honors_deadline() {
|
||||
let error = wait_before_guardian_retry(
|
||||
/*attempt_count*/ 1,
|
||||
Instant::now(),
|
||||
/*external_cancel*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(matches!(error, Some(GuardianReviewError::Timeout)));
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
#[cfg(test)]
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::EnvironmentConfigState;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
@@ -100,17 +101,7 @@ use super::reviewer_config::read_only_guardian_permission_profile;
|
||||
|
||||
const GUARDIAN_INTERRUPT_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const GUARDIAN_MAX_IMAGE_ITEM_TOKENS: i64 = 10_000;
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum GuardianReviewSessionOutcome {
|
||||
Completed(anyhow::Result<Option<String>>),
|
||||
PromptBuildFailed(anyhow::Error),
|
||||
SessionFailed {
|
||||
error: anyhow::Error,
|
||||
error_info: Option<CodexErrorInfo>,
|
||||
},
|
||||
TimedOut,
|
||||
Aborted,
|
||||
}
|
||||
pub(crate) use codex_guardian_reviewer::GuardianReviewSessionOutcome;
|
||||
|
||||
pub(crate) struct GuardianReviewSessionParams {
|
||||
pub(crate) parent_session: Arc<Session>,
|
||||
|
||||
@@ -146,111 +146,6 @@ impl codex_extension_api::ContextContributor for GuardianMemoryContextProbe {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_rejection_circuit_breaker_interrupts_after_three_consecutive_denials() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 3,
|
||||
recent_denials: 3,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_rejection_circuit_breaker_interrupts_cyber_models_after_one_denial() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::CyberModel),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 1,
|
||||
recent_denials: 1,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::CyberModel),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_rejection_circuit_breaker_resets_consecutive_denials_on_non_denial() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 3,
|
||||
recent_denials: 4,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_review_rejection_circuit_breaker_interrupts_after_ten_recent_denials() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
for _ in 0..9 {
|
||||
assert_eq!(
|
||||
circuit_breaker
|
||||
.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
}
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials: 1,
|
||||
recent_denials: 10,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_review_rejection_circuit_breaker_forgets_denials_outside_recent_review_window() {
|
||||
let mut circuit_breaker = GuardianRejectionCircuitBreaker::default();
|
||||
for _ in 0..9 {
|
||||
assert_eq!(
|
||||
circuit_breaker
|
||||
.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
}
|
||||
for _ in 0..(AUTO_REVIEW_DENIAL_WINDOW_SIZE - 18) {
|
||||
circuit_breaker.record_non_denial("turn-1");
|
||||
}
|
||||
assert_eq!(
|
||||
circuit_breaker.record_denial("turn-1", GuardianRejectionCircuitBreakerPolicy::Standard),
|
||||
GuardianRejectionCircuitBreakerAction::Continue
|
||||
);
|
||||
}
|
||||
|
||||
async fn guardian_test_session_and_turn(
|
||||
server: &wiremock::MockServer,
|
||||
) -> (Arc<Session>, Arc<TurnContext>) {
|
||||
|
||||
Reference in New Issue
Block a user