mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
Move Guardian reporting and denial accounting into the extension (#44544)
## What changed - Move assessment event construction, metrics, and analytics tracking into `codex-guardian-reviewer` through `ReviewReport`. - Store denial accounting in thread extension data through `ReviewDenials`, with core retaining turn interruption and lifecycle cleanup. - Move failed-review record selection and bounded serialization into the extension, with core supplying captured review context. ## Testing Add coverage that denial accounting clears on turn completion and interruption. Move the oversized-record test into the extension and exercise the new API. GitOrigin-RevId: 6e5b3d9d4128b356f99b1d0e74a68111e40261ab
This commit is contained in:
@@ -1,63 +1,25 @@
|
||||
//! Captures bounded failed-review evidence for opt-in feedback uploads.
|
||||
//! Records outlive replaced and short-lived reviewers without writing extra history files.
|
||||
//! Captures failed-review context for the extension's bounded feedback recorder.
|
||||
|
||||
use super::GuardianAssessmentOutcome;
|
||||
use super::approval_request::format_guardian_action_pretty;
|
||||
use super::approval_request::guardian_request_target_item_id;
|
||||
use super::approval_request::guardian_request_turn_id;
|
||||
use super::prompt::parse_guardian_assessment;
|
||||
use super::review_session::GuardianReviewSessionOutcome;
|
||||
use super::review_session::GuardianReviewSessionParams;
|
||||
use crate::session::session::Session;
|
||||
use codex_feedback::record_guardian_review_failure;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use serde::Serialize;
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
const MAX_RECORD_BYTES: usize = 4 * 1024 * 1024;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ReviewFeedbackRecord<'a> {
|
||||
reviewed_thread_id: ThreadId,
|
||||
reviewed_turn_id: &'a str,
|
||||
target_item_id: Option<&'a str>,
|
||||
reviewer_thread_id: ThreadId,
|
||||
model: &'a str,
|
||||
status: &'a str,
|
||||
decision: Option<&'a str>,
|
||||
action: &'a str,
|
||||
action_truncated: bool,
|
||||
instructions: Option<&'a str>,
|
||||
history: Vec<&'a ResponseItem>,
|
||||
context_omitted: bool,
|
||||
}
|
||||
|
||||
pub(super) async fn record_failed_review(
|
||||
reviewer: &Session,
|
||||
params: &GuardianReviewSessionParams,
|
||||
outcome: &GuardianReviewSessionOutcome,
|
||||
) {
|
||||
if !params.spawn_config.feedback_enabled || params.spawn_config.ephemeral {
|
||||
let Some(feedback) = codex_guardian_reviewer::FailedReviewFeedback::for_outcome(
|
||||
outcome,
|
||||
codex_guardian_reviewer::ReviewFeedbackSettings {
|
||||
enabled: params.spawn_config.feedback_enabled,
|
||||
ephemeral: params.spawn_config.ephemeral,
|
||||
},
|
||||
) else {
|
||||
return;
|
||||
}
|
||||
let (status, decision) = match outcome {
|
||||
GuardianReviewSessionOutcome::Completed(Ok(Some(decision))) => {
|
||||
match parse_guardian_assessment(Some(decision)) {
|
||||
Ok(assessment) if assessment.outcome == GuardianAssessmentOutcome::Allow => {
|
||||
return;
|
||||
}
|
||||
Ok(_) => ("denied", Some(decision.as_str())),
|
||||
Err(_) => ("invalid_decision", Some(decision.as_str())),
|
||||
}
|
||||
}
|
||||
GuardianReviewSessionOutcome::Completed(Ok(None)) => ("missing_decision", None),
|
||||
GuardianReviewSessionOutcome::Completed(Err(_))
|
||||
| GuardianReviewSessionOutcome::PromptBuildFailed(_)
|
||||
| GuardianReviewSessionOutcome::SessionFailed { .. } => ("failed", None),
|
||||
GuardianReviewSessionOutcome::TimedOut => ("timed_out", None),
|
||||
GuardianReviewSessionOutcome::Aborted => ("aborted", None),
|
||||
};
|
||||
let Ok(action) = format_guardian_action_pretty(¶ms.request) else {
|
||||
tracing::warn!("Could not serialize Guardian feedback action");
|
||||
@@ -65,7 +27,7 @@ pub(super) async fn record_failed_review(
|
||||
};
|
||||
let instructions = reviewer.get_prompt_base_instructions().await;
|
||||
let history = reviewer.clone_history().await;
|
||||
ReviewFeedbackRecord {
|
||||
feedback.store(codex_guardian_reviewer::ReviewFeedbackContext {
|
||||
reviewed_thread_id: params.parent_session.thread_id(),
|
||||
reviewed_turn_id: guardian_request_turn_id(
|
||||
¶ms.request,
|
||||
@@ -74,53 +36,9 @@ pub(super) async fn record_failed_review(
|
||||
target_item_id: guardian_request_target_item_id(¶ms.request),
|
||||
reviewer_thread_id: reviewer.thread_id(),
|
||||
model: ¶ms.model,
|
||||
status,
|
||||
decision,
|
||||
action: &action.text,
|
||||
action_truncated: action.truncated,
|
||||
instructions: Some(&instructions.text),
|
||||
history: history.raw_items().collect(),
|
||||
context_omitted: false,
|
||||
}
|
||||
.store();
|
||||
});
|
||||
}
|
||||
|
||||
impl ReviewFeedbackRecord<'_> {
|
||||
fn store(mut self) {
|
||||
let mut buffer = BoundedBuffer(Vec::new());
|
||||
if serde_json::to_writer(&mut buffer, &self).is_err() {
|
||||
// Preserve the action and decision even when optional reviewer history is too large.
|
||||
self.instructions = None;
|
||||
self.history.clear();
|
||||
self.context_omitted = true;
|
||||
buffer = BoundedBuffer(Vec::new());
|
||||
if serde_json::to_writer(&mut buffer, &self).is_err() {
|
||||
tracing::warn!("Guardian feedback action and decision exceed the record limit");
|
||||
return;
|
||||
}
|
||||
}
|
||||
record_guardian_review_failure(self.reviewed_thread_id, buffer.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct BoundedBuffer(Vec<u8>);
|
||||
|
||||
impl Write for BoundedBuffer {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if bytes.len() > MAX_RECORD_BYTES.saturating_sub(self.0.len()) {
|
||||
return Err(io::Error::other(
|
||||
"Guardian feedback record exceeds its size limit",
|
||||
));
|
||||
}
|
||||
self.0.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "feedback_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn oversized_context_keeps_the_action_and_decision() -> anyhow::Result<()> {
|
||||
let thread_id = ThreadId::new();
|
||||
let reviewer_thread_id = ThreadId::new();
|
||||
let decision = r#"{"outcome":"deny","rationale":"Missing approval."}"#;
|
||||
let action = r#"{"command":"git push"}"#;
|
||||
ReviewFeedbackRecord {
|
||||
reviewed_thread_id: thread_id,
|
||||
reviewed_turn_id: "parent-turn",
|
||||
target_item_id: Some("push-call"),
|
||||
reviewer_thread_id,
|
||||
model: "review-model",
|
||||
status: "denied",
|
||||
decision: Some(decision),
|
||||
action,
|
||||
action_truncated: false,
|
||||
instructions: Some(&"x".repeat(MAX_RECORD_BYTES)),
|
||||
history: Vec::new(),
|
||||
context_omitted: false,
|
||||
}
|
||||
.store();
|
||||
let contents = codex_feedback::guardian_review_failures(&[thread_id])
|
||||
.attachment
|
||||
.expect("failed-review record")
|
||||
.buffer;
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<serde_json::Value>(&contents)?,
|
||||
json!({
|
||||
"reviewed_thread_id": thread_id,
|
||||
"reviewed_turn_id": "parent-turn",
|
||||
"target_item_id": "push-call",
|
||||
"reviewer_thread_id": reviewer_thread_id,
|
||||
"model": "review-model",
|
||||
"status": "denied",
|
||||
"decision": decision,
|
||||
"action": action,
|
||||
"action_truncated": false,
|
||||
"instructions": null,
|
||||
"history": [],
|
||||
"context_omitted": true,
|
||||
})
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,490 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use codex_analytics::GuardianApprovalRequestSource;
|
||||
use codex_analytics::GuardianReviewAnalyticsResult;
|
||||
use codex_analytics::GuardianReviewDecision;
|
||||
use codex_analytics::GuardianReviewFailureReason;
|
||||
use codex_analytics::GuardianReviewSessionKind;
|
||||
use codex_analytics::GuardianReviewTerminalStatus;
|
||||
use codex_analytics::GuardianReviewedAction;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_otel::GUARDIAN_REVIEW_COUNT_METRIC;
|
||||
use codex_otel::GUARDIAN_REVIEW_DURATION_METRIC;
|
||||
use codex_otel::GUARDIAN_REVIEW_TOKEN_USAGE_METRIC;
|
||||
use codex_otel::GUARDIAN_REVIEW_TTFT_DURATION_METRIC;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::sanitize_metric_tag_value;
|
||||
use codex_protocol::protocol::GuardianAssessmentOutcome;
|
||||
use codex_protocol::protocol::GuardianRiskLevel;
|
||||
use codex_protocol::protocol::GuardianUserAuthorization;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
|
||||
pub(crate) fn emit_guardian_review_metrics(
|
||||
session_telemetry: &SessionTelemetry,
|
||||
result: &GuardianReviewAnalyticsResult,
|
||||
approval_request_source: GuardianApprovalRequestSource,
|
||||
reviewed_action: &GuardianReviewedAction,
|
||||
completion_latency_ms: u64,
|
||||
) {
|
||||
let tags = guardian_review_metric_tags(result, approval_request_source, reviewed_action);
|
||||
let tag_refs: Vec<(&str, &str)> = tags
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect();
|
||||
|
||||
// Custom MCP tool names may contain user data; only attribute tools from the OpenAI Apps server.
|
||||
let mcp_tags = if let GuardianReviewedAction::McpToolCall {
|
||||
server, tool_name, ..
|
||||
} = reviewed_action
|
||||
&& server == CODEX_APPS_MCP_SERVER_NAME
|
||||
{
|
||||
vec![("tool", sanitize_metric_tag_value(tool_name))]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut counter_tag_refs = tag_refs.clone();
|
||||
counter_tag_refs.extend(mcp_tags.iter().map(|(key, value)| (*key, value.as_str())));
|
||||
session_telemetry.counter(
|
||||
GUARDIAN_REVIEW_COUNT_METRIC,
|
||||
/*inc*/ 1,
|
||||
&counter_tag_refs,
|
||||
);
|
||||
session_telemetry.record_duration(
|
||||
GUARDIAN_REVIEW_DURATION_METRIC,
|
||||
Duration::from_millis(completion_latency_ms),
|
||||
&tag_refs,
|
||||
);
|
||||
|
||||
if let Some(time_to_first_token_ms) = result.time_to_first_token_ms {
|
||||
session_telemetry.record_duration(
|
||||
GUARDIAN_REVIEW_TTFT_DURATION_METRIC,
|
||||
Duration::from_millis(time_to_first_token_ms),
|
||||
&tag_refs,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(token_usage) = result.token_usage.as_ref() {
|
||||
emit_guardian_token_usage_histograms(session_telemetry, token_usage, tags);
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_guardian_token_usage_histograms(
|
||||
session_telemetry: &SessionTelemetry,
|
||||
token_usage: &TokenUsage,
|
||||
base_tags: Vec<(&'static str, String)>,
|
||||
) {
|
||||
for (token_type, value) in [
|
||||
("total", token_usage.total_tokens.max(0)),
|
||||
("input", token_usage.input_tokens.max(0)),
|
||||
("cached_input", token_usage.cached_input()),
|
||||
(
|
||||
"cache_write_input",
|
||||
token_usage.cache_write_input_tokens.max(0),
|
||||
),
|
||||
("non_cached_input", token_usage.non_cached_input()),
|
||||
("output", token_usage.output_tokens.max(0)),
|
||||
(
|
||||
"reasoning_output",
|
||||
token_usage.reasoning_output_tokens.max(0),
|
||||
),
|
||||
] {
|
||||
let mut tags = base_tags.clone();
|
||||
tags.push(("token_type", token_type.to_string()));
|
||||
let tag_refs: Vec<(&str, &str)> = tags
|
||||
.iter()
|
||||
.map(|(key, value)| (*key, value.as_str()))
|
||||
.collect();
|
||||
session_telemetry.histogram(GUARDIAN_REVIEW_TOKEN_USAGE_METRIC, value, &tag_refs);
|
||||
}
|
||||
}
|
||||
|
||||
fn guardian_review_metric_tags(
|
||||
result: &GuardianReviewAnalyticsResult,
|
||||
approval_request_source: GuardianApprovalRequestSource,
|
||||
reviewed_action: &GuardianReviewedAction,
|
||||
) -> Vec<(&'static str, String)> {
|
||||
vec![
|
||||
("decision", decision_tag(result.decision).to_string()),
|
||||
(
|
||||
"terminal_status",
|
||||
terminal_status_tag(result.terminal_status).to_string(),
|
||||
),
|
||||
(
|
||||
"failure_reason",
|
||||
failure_reason_tag(result.failure_reason).to_string(),
|
||||
),
|
||||
(
|
||||
"approval_request_source",
|
||||
approval_request_source_tag(approval_request_source).to_string(),
|
||||
),
|
||||
("action", reviewed_action_tag(reviewed_action).to_string()),
|
||||
(
|
||||
"session_kind",
|
||||
session_kind_tag(result.guardian_session_kind).to_string(),
|
||||
),
|
||||
(
|
||||
"had_prior_review_context",
|
||||
optional_bool_tag(result.had_prior_review_context).to_string(),
|
||||
),
|
||||
(
|
||||
"reviewed_action_truncated",
|
||||
bool_tag(result.reviewed_action_truncated).to_string(),
|
||||
),
|
||||
("risk_level", risk_level_tag(result.risk_level).to_string()),
|
||||
(
|
||||
"user_authorization",
|
||||
user_authorization_tag(result.user_authorization).to_string(),
|
||||
),
|
||||
("outcome", outcome_tag(result.outcome).to_string()),
|
||||
(
|
||||
"guardian_model",
|
||||
result
|
||||
.guardian_model
|
||||
.as_deref()
|
||||
.map(sanitize_metric_tag_value)
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
),
|
||||
(
|
||||
"guardian_reasoning_effort",
|
||||
result
|
||||
.guardian_reasoning_effort
|
||||
.as_deref()
|
||||
.map(sanitize_metric_tag_value)
|
||||
.unwrap_or_else(|| "none".to_string()),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
fn decision_tag(decision: GuardianReviewDecision) -> &'static str {
|
||||
match decision {
|
||||
GuardianReviewDecision::Approved => "approved",
|
||||
GuardianReviewDecision::Denied => "denied",
|
||||
GuardianReviewDecision::Aborted => "aborted",
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal_status_tag(status: GuardianReviewTerminalStatus) -> &'static str {
|
||||
match status {
|
||||
GuardianReviewTerminalStatus::Approved => "approved",
|
||||
GuardianReviewTerminalStatus::Denied => "denied",
|
||||
GuardianReviewTerminalStatus::Aborted => "aborted",
|
||||
GuardianReviewTerminalStatus::TimedOut => "timed_out",
|
||||
GuardianReviewTerminalStatus::FailedClosed => "failed_closed",
|
||||
}
|
||||
}
|
||||
|
||||
fn failure_reason_tag(reason: Option<GuardianReviewFailureReason>) -> &'static str {
|
||||
match reason {
|
||||
Some(GuardianReviewFailureReason::Timeout) => "timeout",
|
||||
Some(GuardianReviewFailureReason::Cancelled) => "cancelled",
|
||||
Some(GuardianReviewFailureReason::PromptBuildError) => "prompt_build_error",
|
||||
Some(GuardianReviewFailureReason::SessionError) => "session_error",
|
||||
Some(GuardianReviewFailureReason::ParseError) => "parse_error",
|
||||
None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn approval_request_source_tag(source: GuardianApprovalRequestSource) -> &'static str {
|
||||
match source {
|
||||
GuardianApprovalRequestSource::MainTurn => "main_turn",
|
||||
GuardianApprovalRequestSource::DelegatedSubagent => "delegated_subagent",
|
||||
}
|
||||
}
|
||||
|
||||
fn reviewed_action_tag(action: &GuardianReviewedAction) -> &'static str {
|
||||
match action {
|
||||
GuardianReviewedAction::Shell { .. } => "shell",
|
||||
GuardianReviewedAction::UnifiedExec { .. } => "unified_exec",
|
||||
GuardianReviewedAction::WriteStdin { .. } => "write_stdin",
|
||||
GuardianReviewedAction::Execve { .. } => "execve",
|
||||
GuardianReviewedAction::ApplyPatch {} => "apply_patch",
|
||||
GuardianReviewedAction::NetworkAccess { .. } => "network_access",
|
||||
GuardianReviewedAction::McpToolCall { .. } => "mcp_tool_call",
|
||||
GuardianReviewedAction::RequestPermissions {} => "request_permissions",
|
||||
}
|
||||
}
|
||||
|
||||
fn session_kind_tag(kind: Option<GuardianReviewSessionKind>) -> &'static str {
|
||||
match kind {
|
||||
Some(GuardianReviewSessionKind::TrunkNew) => "trunk_new",
|
||||
Some(GuardianReviewSessionKind::TrunkReused) => "trunk_reused",
|
||||
Some(GuardianReviewSessionKind::EphemeralForked) => "ephemeral_forked",
|
||||
None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_bool_tag(value: Option<bool>) -> &'static str {
|
||||
match value {
|
||||
Some(true) => "true",
|
||||
Some(false) => "false",
|
||||
None => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
fn bool_tag(value: bool) -> &'static str {
|
||||
if value { "true" } else { "false" }
|
||||
}
|
||||
|
||||
fn risk_level_tag(risk_level: Option<GuardianRiskLevel>) -> &'static str {
|
||||
match risk_level {
|
||||
Some(GuardianRiskLevel::Low) => "low",
|
||||
Some(GuardianRiskLevel::Medium) => "medium",
|
||||
Some(GuardianRiskLevel::High) => "high",
|
||||
Some(GuardianRiskLevel::Critical) => "critical",
|
||||
None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn user_authorization_tag(user_authorization: Option<GuardianUserAuthorization>) -> &'static str {
|
||||
match user_authorization {
|
||||
Some(GuardianUserAuthorization::Unknown) => "unknown",
|
||||
Some(GuardianUserAuthorization::Low) => "low",
|
||||
Some(GuardianUserAuthorization::Medium) => "medium",
|
||||
Some(GuardianUserAuthorization::High) => "high",
|
||||
None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
fn outcome_tag(outcome: Option<GuardianAssessmentOutcome>) -> &'static str {
|
||||
match outcome {
|
||||
Some(GuardianAssessmentOutcome::Allow) => "allow",
|
||||
Some(GuardianAssessmentOutcome::Deny) => "deny",
|
||||
None => "none",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use codex_otel::MetricsClient;
|
||||
use codex_otel::MetricsConfig;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use opentelemetry::KeyValue;
|
||||
use opentelemetry_sdk::metrics::InMemoryMetricExporter;
|
||||
use opentelemetry_sdk::metrics::data::AggregatedMetrics;
|
||||
use opentelemetry_sdk::metrics::data::Metric;
|
||||
use opentelemetry_sdk::metrics::data::MetricData;
|
||||
use opentelemetry_sdk::metrics::data::ResourceMetrics;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
use test_case::test_case;
|
||||
|
||||
fn test_session_telemetry() -> SessionTelemetry {
|
||||
let exporter = InMemoryMetricExporter::default();
|
||||
let metrics = MetricsClient::new(
|
||||
MetricsConfig::in_memory("test", "codex-core", env!("CARGO_PKG_VERSION"), exporter)
|
||||
.with_runtime_reader(),
|
||||
)
|
||||
.expect("in-memory metrics client");
|
||||
SessionTelemetry::new(
|
||||
ThreadId::new(),
|
||||
"gpt-5.4",
|
||||
"gpt-5.4",
|
||||
/*account_id*/ None,
|
||||
/*account_email*/ None,
|
||||
/*auth_mode*/ None,
|
||||
"test_originator".to_string(),
|
||||
/*log_user_prompts*/ false,
|
||||
"tty".to_string(),
|
||||
SessionSource::Cli,
|
||||
)
|
||||
.with_metrics_without_metadata_tags(metrics)
|
||||
}
|
||||
|
||||
fn find_metric<'a>(resource_metrics: &'a ResourceMetrics, name: &str) -> &'a Metric {
|
||||
for scope_metrics in resource_metrics.scope_metrics() {
|
||||
for metric in scope_metrics.metrics() {
|
||||
if metric.name() == name {
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
}
|
||||
panic!("metric {name} missing");
|
||||
}
|
||||
|
||||
fn attributes_to_map<'a>(
|
||||
attributes: impl Iterator<Item = &'a KeyValue>,
|
||||
) -> BTreeMap<String, String> {
|
||||
attributes
|
||||
.map(|kv| (kv.key.as_str().to_string(), kv.value.as_str().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn counter_point(
|
||||
resource_metrics: &ResourceMetrics,
|
||||
name: &str,
|
||||
) -> (BTreeMap<String, String>, u64) {
|
||||
let metric = find_metric(resource_metrics, name);
|
||||
match metric.data() {
|
||||
AggregatedMetrics::U64(data) => match data {
|
||||
MetricData::Sum(sum) => {
|
||||
let points: Vec<_> = sum.data_points().collect();
|
||||
assert_eq!(points.len(), 1);
|
||||
let point = points[0];
|
||||
(attributes_to_map(point.attributes()), point.value())
|
||||
}
|
||||
_ => panic!("unexpected counter aggregation"),
|
||||
},
|
||||
_ => panic!("unexpected counter data type"),
|
||||
}
|
||||
}
|
||||
|
||||
fn histogram_sums(
|
||||
resource_metrics: &ResourceMetrics,
|
||||
name: &str,
|
||||
expected_base_tags: &BTreeMap<String, String>,
|
||||
) -> BTreeMap<String, u64> {
|
||||
let metric = find_metric(resource_metrics, name);
|
||||
match metric.data() {
|
||||
AggregatedMetrics::F64(data) => match data {
|
||||
MetricData::Histogram(histogram) => histogram
|
||||
.data_points()
|
||||
.map(|point| {
|
||||
let mut attrs = attributes_to_map(point.attributes());
|
||||
let token_type = attrs
|
||||
.remove("token_type")
|
||||
.unwrap_or_else(|| "sample".to_string());
|
||||
assert_eq!(&attrs, expected_base_tags);
|
||||
(token_type, point.sum() as u64)
|
||||
})
|
||||
.collect(),
|
||||
_ => panic!("unexpected histogram aggregation"),
|
||||
},
|
||||
_ => panic!("unexpected histogram data type"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test_case(
|
||||
GuardianReviewedAction::NetworkAccess {
|
||||
protocol: codex_protocol::approvals::NetworkApprovalProtocol::Https,
|
||||
port: 443,
|
||||
},
|
||||
"network_access",
|
||||
BTreeMap::new();
|
||||
"network access"
|
||||
)]
|
||||
#[test_case(
|
||||
GuardianReviewedAction::McpToolCall {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool_name: "search docs".to_string(),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
tool_title: None,
|
||||
},
|
||||
"mcp_tool_call",
|
||||
BTreeMap::from([("tool".to_string(), "search_docs".to_string())]);
|
||||
"openai apps tool call"
|
||||
)]
|
||||
#[test_case(
|
||||
GuardianReviewedAction::McpToolCall {
|
||||
server: "codex_apps_custom".to_string(),
|
||||
tool_name: "search_private_docs".to_string(),
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
tool_title: None,
|
||||
},
|
||||
"mcp_tool_call",
|
||||
BTreeMap::new();
|
||||
"custom mcp tool call"
|
||||
)]
|
||||
fn guardian_review_metrics_record_counts_durations_and_token_usage(
|
||||
reviewed_action: GuardianReviewedAction,
|
||||
expected_action: &str,
|
||||
expected_mcp_tags: BTreeMap<String, String>,
|
||||
) {
|
||||
let session_telemetry = test_session_telemetry();
|
||||
let result = GuardianReviewAnalyticsResult {
|
||||
decision: GuardianReviewDecision::Approved,
|
||||
terminal_status: GuardianReviewTerminalStatus::Approved,
|
||||
risk_level: Some(GuardianRiskLevel::Low),
|
||||
user_authorization: Some(GuardianUserAuthorization::High),
|
||||
outcome: Some(GuardianAssessmentOutcome::Allow),
|
||||
guardian_session_kind: Some(GuardianReviewSessionKind::TrunkReused),
|
||||
guardian_model: Some("gpt-5.4 guardian".to_string()),
|
||||
guardian_reasoning_effort: Some("low".to_string()),
|
||||
had_prior_review_context: Some(true),
|
||||
reviewed_action_truncated: true,
|
||||
token_usage: Some(TokenUsage {
|
||||
input_tokens: 10,
|
||||
cached_input_tokens: 4,
|
||||
cache_write_input_tokens: 2,
|
||||
output_tokens: 3,
|
||||
reasoning_output_tokens: 2,
|
||||
total_tokens: 15,
|
||||
codex_rollout_budget_units: None,
|
||||
}),
|
||||
time_to_first_token_ms: Some(123),
|
||||
..GuardianReviewAnalyticsResult::without_session()
|
||||
};
|
||||
|
||||
emit_guardian_review_metrics(
|
||||
&session_telemetry,
|
||||
&result,
|
||||
GuardianApprovalRequestSource::DelegatedSubagent,
|
||||
&reviewed_action,
|
||||
/*completion_latency_ms*/ 456,
|
||||
);
|
||||
|
||||
let snapshot = session_telemetry
|
||||
.snapshot_metrics()
|
||||
.expect("runtime metrics snapshot");
|
||||
let (attrs, value) = counter_point(&snapshot, GUARDIAN_REVIEW_COUNT_METRIC);
|
||||
|
||||
let expected_base_tags = BTreeMap::from([
|
||||
("action".to_string(), expected_action.to_string()),
|
||||
(
|
||||
"approval_request_source".to_string(),
|
||||
"delegated_subagent".to_string(),
|
||||
),
|
||||
("decision".to_string(), "approved".to_string()),
|
||||
("failure_reason".to_string(), "none".to_string()),
|
||||
("guardian_model".to_string(), "gpt-5.4_guardian".to_string()),
|
||||
("guardian_reasoning_effort".to_string(), "low".to_string()),
|
||||
("had_prior_review_context".to_string(), "true".to_string()),
|
||||
("outcome".to_string(), "allow".to_string()),
|
||||
("reviewed_action_truncated".to_string(), "true".to_string()),
|
||||
("risk_level".to_string(), "low".to_string()),
|
||||
("session_kind".to_string(), "trunk_reused".to_string()),
|
||||
("terminal_status".to_string(), "approved".to_string()),
|
||||
("user_authorization".to_string(), "high".to_string()),
|
||||
]);
|
||||
let mut expected_counter_tags = expected_base_tags.clone();
|
||||
expected_counter_tags.extend(expected_mcp_tags);
|
||||
assert_eq!((attrs, value), (expected_counter_tags, 1));
|
||||
|
||||
assert_eq!(
|
||||
histogram_sums(
|
||||
&snapshot,
|
||||
GUARDIAN_REVIEW_TOKEN_USAGE_METRIC,
|
||||
&expected_base_tags
|
||||
),
|
||||
BTreeMap::from([
|
||||
("cached_input".to_string(), 4),
|
||||
("cache_write_input".to_string(), 2),
|
||||
("input".to_string(), 10),
|
||||
("non_cached_input".to_string(), 6),
|
||||
("output".to_string(), 3),
|
||||
("reasoning_output".to_string(), 2),
|
||||
("total".to_string(), 15),
|
||||
])
|
||||
);
|
||||
assert_eq!(
|
||||
histogram_sums(
|
||||
&snapshot,
|
||||
GUARDIAN_REVIEW_DURATION_METRIC,
|
||||
&expected_base_tags
|
||||
),
|
||||
BTreeMap::from([("sample".to_string(), 456)])
|
||||
);
|
||||
assert_eq!(
|
||||
histogram_sums(
|
||||
&snapshot,
|
||||
GUARDIAN_REVIEW_TTFT_DURATION_METRIC,
|
||||
&expected_base_tags
|
||||
),
|
||||
BTreeMap::from([("sample".to_string(), 123)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,6 @@ mod coverage;
|
||||
mod decision;
|
||||
mod feedback;
|
||||
mod input_budget;
|
||||
mod metrics;
|
||||
mod prompt;
|
||||
pub(crate) use input_budget::PendingReviewContext;
|
||||
pub(crate) use input_budget::check_pending as check_pending_guardian_input;
|
||||
@@ -62,7 +61,6 @@ pub(crate) use runtime::ReviewAction;
|
||||
|
||||
pub(crate) use codex_guardian_reviewer::REVIEW_TIMEOUT as GUARDIAN_REVIEW_TIMEOUT;
|
||||
pub(crate) const GUARDIAN_REVIEWER_NAME: &str = "guardian";
|
||||
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()
|
||||
@@ -171,10 +169,6 @@ impl From<&Arc<TurnContext>> for GuardianReviewContext {
|
||||
#[cfg(test)]
|
||||
use codex_guardian_reviewer::guardian_output_schema;
|
||||
|
||||
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)]
|
||||
use approval_request::guardian_assessment_action;
|
||||
|
||||
@@ -361,7 +361,6 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> (String
|
||||
}
|
||||
|
||||
use codex_guardian_reviewer::guardian_output_contract_prompt;
|
||||
pub(super) 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 =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Supplies host review preparation, telemetry and runtime configuration.
|
||||
//! Supplies host review preparation, interruption and runtime configuration.
|
||||
//! Guardian's extension owns the synchronous review loop and pool.
|
||||
|
||||
#[path = "review_request.rs"]
|
||||
@@ -7,11 +7,6 @@ mod request;
|
||||
use crate::context::GuardianContextMode;
|
||||
use codex_analytics::GuardianApprovalRequestSource;
|
||||
use codex_analytics::GuardianReviewAnalyticsResult;
|
||||
use codex_analytics::GuardianReviewDecision;
|
||||
use codex_analytics::GuardianReviewFailureReason;
|
||||
use codex_analytics::GuardianReviewTerminalStatus;
|
||||
use codex_analytics::GuardianReviewTrackContext;
|
||||
use codex_analytics::GuardianReviewedAction;
|
||||
use codex_core_plugins::PluginCommandAttribution;
|
||||
use codex_extension_api::ThreadIdleCause;
|
||||
use codex_features::Feature;
|
||||
@@ -22,9 +17,6 @@ use codex_guardian_reviewer::GuardianReviewSessionLimits;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GuardianAssessmentDecisionSource;
|
||||
use codex_protocol::protocol::GuardianAssessmentEvent;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::InternalSessionSource;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -42,21 +34,18 @@ use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use crate::turn_timing::now_unix_timestamp_ms;
|
||||
|
||||
use super::AUTO_REVIEW_DENIAL_WINDOW_SIZE;
|
||||
use super::ApprovalRequestReasons;
|
||||
#[cfg(test)]
|
||||
use super::GUARDIAN_REVIEW_TIMEOUT;
|
||||
use super::GUARDIAN_REVIEWER_NAME;
|
||||
use super::GuardianApprovalRequest;
|
||||
use super::GuardianAssessmentOutcome;
|
||||
use super::GuardianRejectionCircuitBreakerAction;
|
||||
use super::GuardianRejectionCircuitBreakerPolicy;
|
||||
use super::GuardianReviewContext;
|
||||
use super::approval_request::format_guardian_action_pretty;
|
||||
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::metrics::emit_guardian_review_metrics;
|
||||
use super::review_session::GuardianReviewSessionParams;
|
||||
use super::review_session::build_guardian_review_session_config;
|
||||
use codex_guardian_reviewer::guardian_output_schema;
|
||||
@@ -147,48 +136,17 @@ pub(crate) fn is_basic_session_source(session_source: &SessionSource) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn track_guardian_review(
|
||||
session: &Session,
|
||||
tracking: &GuardianReviewTrackContext,
|
||||
approval_request_source: GuardianApprovalRequestSource,
|
||||
reviewed_action: &GuardianReviewedAction,
|
||||
result: GuardianReviewAnalyticsResult,
|
||||
completed_at_ms: u64,
|
||||
) {
|
||||
emit_guardian_review_metrics(
|
||||
&session.services.session_telemetry,
|
||||
&result,
|
||||
approval_request_source,
|
||||
reviewed_action,
|
||||
completed_at_ms.saturating_sub(tracking.started_at_ms),
|
||||
);
|
||||
session
|
||||
.services
|
||||
.analytics_events_client
|
||||
.track_guardian_review(tracking, result, completed_at_ms);
|
||||
}
|
||||
|
||||
pub(super) async fn record_guardian_non_denial(session: &Arc<Session>, turn_id: &str) {
|
||||
session
|
||||
.services
|
||||
.guardian_rejection_circuit_breaker
|
||||
.lock()
|
||||
.await
|
||||
.record_non_denial(turn_id);
|
||||
codex_guardian_reviewer::ReviewDenials::for_thread(&session.services.thread_extension_data)
|
||||
.record_non_denial(turn_id)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn record_guardian_denial(session: &Arc<Session>, turn: &Arc<TurnContext>, turn_id: &str) {
|
||||
let policy = GuardianRejectionCircuitBreakerPolicy::from(turn.model_info().as_ref());
|
||||
let action = session
|
||||
.services
|
||||
.guardian_rejection_circuit_breaker
|
||||
.lock()
|
||||
.await
|
||||
.record_denial(turn_id, policy);
|
||||
let GuardianRejectionCircuitBreakerAction::InterruptTurn {
|
||||
consecutive_denials,
|
||||
recent_denials,
|
||||
} = action
|
||||
let Some(message) =
|
||||
codex_guardian_reviewer::ReviewDenials::for_thread(&session.services.thread_extension_data)
|
||||
.record_denial(turn_id, turn.model_info())
|
||||
.await
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -200,11 +158,7 @@ async fn record_guardian_denial(session: &Arc<Session>, turn: &Arc<TurnContext>,
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
EventMsg::GuardianWarning(WarningEvent {
|
||||
message: format!(
|
||||
"Automatic approval review rejected too many approval requests for this turn ({consecutive_denials} consecutive, {recent_denials} in the last {AUTO_REVIEW_DENIAL_WINDOW_SIZE} reviews); interrupting the turn."
|
||||
),
|
||||
}),
|
||||
EventMsg::GuardianWarning(WarningEvent { message }),
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
@@ -4,21 +4,12 @@
|
||||
use super::*;
|
||||
use crate::codex_thread::GuardianAuthorizationVersion;
|
||||
use codex_guardian_reviewer::ReviewHost;
|
||||
use codex_protocol::approvals::GuardianAssessmentAction;
|
||||
use codex_protocol::approvals::GuardianReviewReason;
|
||||
|
||||
pub(in crate::guardian) struct PreparedApproval {
|
||||
request: GuardianApprovalRequest,
|
||||
turn: Arc<TurnContext>,
|
||||
review_reason: GuardianReviewReason,
|
||||
assessment_turn_id: String,
|
||||
target_item_id: Option<String>,
|
||||
plugin_id: Option<String>,
|
||||
script_path: Option<String>,
|
||||
action_summary: GuardianAssessmentAction,
|
||||
reviewed_action: GuardianReviewedAction,
|
||||
review_tracking: GuardianReviewTrackContext,
|
||||
started_at_ms: i64,
|
||||
report: codex_guardian_reviewer::ReviewReport,
|
||||
root_authorization_version: Option<GuardianAuthorizationVersion>,
|
||||
user_message_revision: u64,
|
||||
review_evidence: Option<(
|
||||
@@ -98,80 +89,46 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
|
||||
.as_ref()
|
||||
.map(PluginCommandAttribution::serialized_fields)
|
||||
.unzip();
|
||||
let action_summary = guardian_assessment_action(&request);
|
||||
let reviewed_action = guardian_reviewed_action(&request);
|
||||
let review_tracking = GuardianReviewTrackContext::new(
|
||||
session.thread_id.to_string(),
|
||||
assessment_turn_id.clone(),
|
||||
review_id.clone(),
|
||||
target_item_id.clone(),
|
||||
approval_request_source,
|
||||
reviewed_action.clone(),
|
||||
GUARDIAN_REVIEW_TIMEOUT.as_millis() as u64,
|
||||
);
|
||||
let started_at_ms = review_tracking.started_at_ms.try_into().unwrap_or_default();
|
||||
let report =
|
||||
codex_guardian_reviewer::ReviewReport::new(codex_guardian_reviewer::ReviewMetadata {
|
||||
thread_id: session.thread_id.to_string(),
|
||||
turn_id: assessment_turn_id,
|
||||
review_id,
|
||||
target_item_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
approval_request_source,
|
||||
reviewed_action: guardian_reviewed_action(&request),
|
||||
action: guardian_assessment_action(&request),
|
||||
review_reason,
|
||||
});
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
review_reason: Some(review_reason),
|
||||
id: review_id.clone(),
|
||||
target_item_id: target_item_id.clone(),
|
||||
plugin_id: plugin_id.clone(),
|
||||
script_path: script_path.clone(),
|
||||
turn_id: assessment_turn_id.clone(),
|
||||
started_at_ms,
|
||||
completed_at_ms: None,
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: None,
|
||||
action: action_summary.clone(),
|
||||
}),
|
||||
EventMsg::GuardianAssessment(report.started_event()),
|
||||
)
|
||||
.await;
|
||||
|
||||
if external_cancel
|
||||
.as_ref()
|
||||
.is_some_and(CancellationToken::is_cancelled)
|
||||
{
|
||||
let completed_at_ms = now_unix_timestamp_ms();
|
||||
track_guardian_review(
|
||||
session.as_ref(),
|
||||
&review_tracking,
|
||||
approval_request_source,
|
||||
&reviewed_action,
|
||||
GuardianReviewAnalyticsResult {
|
||||
decision: GuardianReviewDecision::Aborted,
|
||||
terminal_status: GuardianReviewTerminalStatus::Aborted,
|
||||
failure_reason: Some(GuardianReviewFailureReason::Cancelled),
|
||||
..GuardianReviewAnalyticsResult::without_session()
|
||||
},
|
||||
let completed = report.complete(
|
||||
GuardianReviewOutcome::Error(GuardianReviewError::Cancelled),
|
||||
turn.model_info(),
|
||||
GuardianReviewAnalyticsResult::without_session(),
|
||||
completed_at_ms,
|
||||
);
|
||||
report.track(
|
||||
&session.services.session_telemetry,
|
||||
&session.services.analytics_events_client,
|
||||
completed.analytics,
|
||||
completed_at_ms.try_into().unwrap_or_default(),
|
||||
);
|
||||
session
|
||||
.send_event(
|
||||
turn.as_ref(),
|
||||
EventMsg::GuardianAssessment(GuardianAssessmentEvent {
|
||||
review_reason: Some(review_reason),
|
||||
id: review_id,
|
||||
target_item_id,
|
||||
plugin_id: plugin_id.clone(),
|
||||
script_path: script_path.clone(),
|
||||
turn_id: assessment_turn_id.clone(),
|
||||
started_at_ms,
|
||||
completed_at_ms: Some(completed_at_ms),
|
||||
status: GuardianAssessmentStatus::Aborted,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: action_summary,
|
||||
}),
|
||||
)
|
||||
.send_event(turn.as_ref(), EventMsg::GuardianAssessment(completed.event))
|
||||
.await;
|
||||
record_guardian_non_denial(&session, &assessment_turn_id).await;
|
||||
record_guardian_non_denial(&session, report.turn_id()).await;
|
||||
return Err(ReviewDecision::Abort);
|
||||
}
|
||||
|
||||
@@ -205,15 +162,7 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
|
||||
Ok(PreparedApproval {
|
||||
request,
|
||||
turn,
|
||||
review_reason,
|
||||
assessment_turn_id,
|
||||
target_item_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
action_summary,
|
||||
reviewed_action,
|
||||
review_tracking,
|
||||
started_at_ms,
|
||||
report,
|
||||
root_authorization_version,
|
||||
user_message_revision,
|
||||
review_evidence,
|
||||
@@ -246,23 +195,12 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
|
||||
let PreparedApproval {
|
||||
request: _,
|
||||
turn,
|
||||
review_reason,
|
||||
assessment_turn_id,
|
||||
target_item_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
action_summary,
|
||||
reviewed_action,
|
||||
review_tracking,
|
||||
started_at_ms,
|
||||
report,
|
||||
root_authorization_version,
|
||||
user_message_revision,
|
||||
review_evidence,
|
||||
} = prepared;
|
||||
let session = Arc::clone(&self.session);
|
||||
let review_id = self.review_id.clone();
|
||||
let approval_request_source = self.options.approval_request_source;
|
||||
let terminal_action = action_summary.clone();
|
||||
if session.guardian_context_mode == GuardianContextMode::ThreadOwned
|
||||
&& matches!(&outcome, GuardianReviewOutcome::Completed(assessment) if assessment.outcome == GuardianAssessmentOutcome::Allow)
|
||||
&& (root_authorization_version
|
||||
@@ -284,32 +222,15 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
|
||||
}
|
||||
|
||||
let completed_at_ms = now_unix_timestamp_ms();
|
||||
let completed = codex_guardian_reviewer::complete_review(
|
||||
let completed = report.complete(
|
||||
outcome,
|
||||
turn.model_info(),
|
||||
GuardianAssessmentEvent {
|
||||
review_reason: Some(review_reason),
|
||||
id: review_id,
|
||||
target_item_id,
|
||||
plugin_id,
|
||||
script_path,
|
||||
turn_id: assessment_turn_id.clone(),
|
||||
started_at_ms,
|
||||
completed_at_ms: Some(completed_at_ms),
|
||||
status: GuardianAssessmentStatus::InProgress,
|
||||
risk_level: None,
|
||||
user_authorization: None,
|
||||
rationale: None,
|
||||
decision_source: Some(GuardianAssessmentDecisionSource::Agent),
|
||||
action: terminal_action,
|
||||
},
|
||||
analytics_result,
|
||||
completed_at_ms,
|
||||
);
|
||||
track_guardian_review(
|
||||
session.as_ref(),
|
||||
&review_tracking,
|
||||
approval_request_source,
|
||||
&reviewed_action,
|
||||
report.track(
|
||||
&session.services.session_telemetry,
|
||||
&session.services.analytics_events_client,
|
||||
completed.analytics,
|
||||
completed_at_ms.try_into().unwrap_or_default(),
|
||||
);
|
||||
@@ -336,9 +257,9 @@ impl ReviewHost for super::super::runtime::ReviewRuntime {
|
||||
.send_event(turn.as_ref(), EventMsg::GuardianAssessment(completed.event))
|
||||
.await;
|
||||
if completed.assessment_outcome == Some(GuardianAssessmentOutcome::Deny) {
|
||||
record_guardian_denial(&session, &turn, &assessment_turn_id).await;
|
||||
record_guardian_denial(&session, &turn, report.turn_id()).await;
|
||||
} else {
|
||||
record_guardian_non_denial(&session, &assessment_turn_id).await;
|
||||
record_guardian_non_denial(&session, report.turn_id()).await;
|
||||
}
|
||||
completed.decision
|
||||
}
|
||||
|
||||
@@ -1514,7 +1514,6 @@ impl Session {
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
git_root_discovery,
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
guardian_rejection_circuit_breaker: Mutex::new(Default::default()),
|
||||
runtime_handle: tokio::runtime::Handle::current(),
|
||||
skills_service,
|
||||
agents_md_manager,
|
||||
|
||||
@@ -6635,7 +6635,6 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
git_root_discovery: Arc::default(),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
guardian_rejection_circuit_breaker: Mutex::new(Default::default()),
|
||||
runtime_handle: tokio::runtime::Handle::current(),
|
||||
skills_service,
|
||||
agents_md_manager: Arc::new(AgentsMdManager::new(/*user_instructions*/ None)),
|
||||
@@ -8809,7 +8808,6 @@ where
|
||||
models_manager: Arc::clone(&models_manager),
|
||||
git_root_discovery: Arc::default(),
|
||||
tool_approvals: Mutex::new(ApprovalStore::default()),
|
||||
guardian_rejection_circuit_breaker: Mutex::new(Default::default()),
|
||||
runtime_handle: tokio::runtime::Handle::current(),
|
||||
skills_service,
|
||||
agents_md_manager: Arc::new(AgentsMdManager::new(/*user_instructions*/ None)),
|
||||
|
||||
@@ -1324,3 +1324,48 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
);
|
||||
drop(io);
|
||||
}
|
||||
|
||||
#[test_case(TerminalEventKind::TurnComplete; "completion")]
|
||||
#[test_case(TerminalEventKind::TurnAborted; "interruption")]
|
||||
#[tokio::test]
|
||||
async fn terminal_turn_clears_extension_owned_denials(terminal: TerminalEventKind) {
|
||||
let (session, turn, events) = make_session_and_context_with_rx().await;
|
||||
let finish = Arc::new(tokio::sync::Notify::new());
|
||||
session
|
||||
.spawn_task(
|
||||
Arc::clone(&turn),
|
||||
Vec::new(),
|
||||
HeldStepTask {
|
||||
kind: TaskKind::Regular,
|
||||
finish: Arc::clone(&finish),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let denials =
|
||||
codex_guardian_reviewer::ReviewDenials::for_thread(&session.services.thread_extension_data);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
denials.record_denial(&turn.sub_id, turn.model_info()).await,
|
||||
None
|
||||
);
|
||||
}
|
||||
match terminal {
|
||||
TerminalEventKind::TurnComplete => finish.notify_one(),
|
||||
TerminalEventKind::TurnAborted => {
|
||||
session.abort_all_tasks(TurnAbortReason::Interrupted).await
|
||||
}
|
||||
}
|
||||
recv_terminal_event(&events, terminal).await;
|
||||
// Delivery precedes accounting cleanup. Wait for the runtime to finish the turn.
|
||||
timeout(Duration::from_secs(/*secs*/ 5), async {
|
||||
while session.active_turn.lock().await.is_some() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("turn becomes idle");
|
||||
assert_eq!(
|
||||
denials.record_denial(&turn.sub_id, turn.model_info()).await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ use crate::current_time::TimeProvider;
|
||||
use crate::elicitation::ElicitationService;
|
||||
use crate::environment_selection::ThreadEnvironments;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
use crate::guardian::GuardianRejectionCircuitBreaker;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::mcp_tool_exposure::McpHandlerCache;
|
||||
use crate::tools::ExecutedToolCalls;
|
||||
@@ -68,7 +67,6 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) git_root_discovery: Arc<GitRootDiscovery>,
|
||||
pub(crate) session_telemetry: SessionTelemetry,
|
||||
pub(crate) tool_approvals: Mutex<ApprovalStore>,
|
||||
pub(crate) guardian_rejection_circuit_breaker: Mutex<GuardianRejectionCircuitBreaker>,
|
||||
pub(crate) runtime_handle: Handle,
|
||||
pub(crate) skills_service: Arc<HostSkillsService>,
|
||||
pub(crate) agents_md_manager: Arc<AgentsMdManager>,
|
||||
|
||||
@@ -306,11 +306,11 @@ impl Session {
|
||||
let cancellation_token = CancellationToken::new();
|
||||
let done = Arc::new(Notify::new());
|
||||
|
||||
self.services
|
||||
.guardian_rejection_circuit_breaker
|
||||
.lock()
|
||||
.await
|
||||
.clear_turn(&turn_context.sub_id);
|
||||
codex_guardian_reviewer::ReviewDenials::clear_turn(
|
||||
&self.services.thread_extension_data,
|
||||
&turn_context.sub_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let (pending_items, _) = self.input_queue.drain_mailbox_input_items().await;
|
||||
let turn_state = {
|
||||
@@ -835,11 +835,11 @@ impl Session {
|
||||
})
|
||||
};
|
||||
self.send_event(turn_context.as_ref(), event).await;
|
||||
self.services
|
||||
.guardian_rejection_circuit_breaker
|
||||
.lock()
|
||||
.await
|
||||
.clear_turn(&turn_context.sub_id);
|
||||
codex_guardian_reviewer::ReviewDenials::clear_turn(
|
||||
&self.services.thread_extension_data,
|
||||
&turn_context.sub_id,
|
||||
)
|
||||
.await;
|
||||
|
||||
let cleared_active_turn = {
|
||||
let mut active = self.active_turn.lock().await;
|
||||
@@ -991,11 +991,11 @@ impl Session {
|
||||
duration_ms,
|
||||
});
|
||||
self.send_event(task.turn_context.as_ref(), event).await;
|
||||
self.services
|
||||
.guardian_rejection_circuit_breaker
|
||||
.lock()
|
||||
.await
|
||||
.clear_turn(&task.turn_context.sub_id);
|
||||
codex_guardian_reviewer::ReviewDenials::clear_turn(
|
||||
&self.services.thread_extension_data,
|
||||
&task.turn_context.sub_id,
|
||||
)
|
||||
.await;
|
||||
// Regular items were flushed before this terminal event was appended; buffering
|
||||
// thread writers may not flush it without another explicit barrier.
|
||||
if let Err(err) = self.flush_rollout().await {
|
||||
|
||||
Reference in New Issue
Block a user