diff --git a/codex-rs/app-server/tests/common/models_cache.rs b/codex-rs/app-server/tests/common/models_cache.rs index 51d1700ef7..2d62aa1968 100644 --- a/codex-rs/app-server/tests/common/models_cache.rs +++ b/codex-rs/app-server/tests/common/models_cache.rs @@ -42,6 +42,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), include_skills_usage_instructions: false, include_plugin_usage_instructions: false, diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 0d3691d8a9..d6ada3baea 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -1836,6 +1836,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }; let guardian_config = build_guardian_review_session_config( @@ -1871,6 +1872,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }; let guardian_config = build_guardian_review_session_config( @@ -1914,6 +1916,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }; let guardian_config = build_guardian_review_session_config( diff --git a/codex-rs/core/tests/suite/catalog_permission_messages.rs b/codex-rs/core/tests/suite/catalog_permission_messages.rs index 15069ea09e..bed7e51574 100644 --- a/codex-rs/core/tests/suite/catalog_permission_messages.rs +++ b/codex-rs/core/tests/suite/catalog_permission_messages.rs @@ -43,6 +43,7 @@ async fn catalog_permission_message_loaded_from_remote_models_is_sent() -> Resul }), multi_agent: None, token_budget: None, + guardian_v2: None, }); let models_mock = mount_models_once( &server, diff --git a/codex-rs/core/tests/suite/collaboration_instructions.rs b/codex-rs/core/tests/suite/collaboration_instructions.rs index a3a3824b0c..3264bb8611 100644 --- a/codex-rs/core/tests/suite/collaboration_instructions.rs +++ b/codex-rs/core/tests/suite/collaboration_instructions.rs @@ -72,6 +72,7 @@ fn model_with_collaboration_messages( permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }); model_messages.collaboration_modes = Some(CollaborationModeMessages { default: default.map(str::to_string), diff --git a/codex-rs/core/tests/suite/models_cache_ttl.rs b/codex-rs/core/tests/suite/models_cache_ttl.rs index caf6c3c27c..b618acb3e7 100644 --- a/codex-rs/core/tests/suite/models_cache_ttl.rs +++ b/codex-rs/core/tests/suite/models_cache_ttl.rs @@ -512,6 +512,7 @@ fn test_remote_model(slug: &str, priority: i32) -> ModelInfo { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), include_skills_usage_instructions: false, include_plugin_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 8891a56d79..18ae69d0ed 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -60,6 +60,7 @@ fn model_with_approval_messages( permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }); model } @@ -78,6 +79,7 @@ fn model_with_permission_messages( permissions: Some(permissions), multi_agent: None, token_budget: None, + guardian_v2: None, }); model } @@ -231,6 +233,7 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }); let mut builder = test_codex() .with_model(model_slug) diff --git a/codex-rs/core/tests/suite/personality.rs b/codex-rs/core/tests/suite/personality.rs index eb508e98dd..33d91ffd89 100644 --- a/codex-rs/core/tests/suite/personality.rs +++ b/codex-rs/core/tests/suite/personality.rs @@ -645,6 +645,7 @@ async fn remote_model_friendly_personality_instructions_with_feature() -> anyhow permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), include_skills_usage_instructions: false, include_plugin_usage_instructions: false, @@ -770,6 +771,7 @@ async fn user_turn_personality_remote_model_template_includes_update_message() - permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), include_skills_usage_instructions: false, include_plugin_usage_instructions: false, diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index 04129d86ee..69a62cf5c7 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -112,6 +112,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), ..specific }; @@ -126,6 +127,7 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), ..generic }; @@ -774,6 +776,7 @@ async fn remote_models_apply_legacy_instructions() -> Result<()> { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), include_skills_usage_instructions: false, include_plugin_usage_instructions: false, diff --git a/codex-rs/ext/guardian-v2/src/config.rs b/codex-rs/ext/guardian-v2/src/config.rs index 0dc35a8758..322484b944 100644 --- a/codex-rs/ext/guardian-v2/src/config.rs +++ b/codex-rs/ext/guardian-v2/src/config.rs @@ -1,6 +1,8 @@ use codex_core::config::Config; use codex_features::FeatureToml; use codex_features::GuardianV2ConfigToml; +use codex_features::GuardianV2TranscriptConfigToml; +use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::ReasoningEffort; use crate::transcript::MAX_MESSAGE_ENTRY_TOKENS; @@ -24,6 +26,7 @@ Return an action_risk score from 0.0 (safe and authorized) to 1.0 (dangerous or #[derive(Clone, Debug, PartialEq)] pub(crate) struct GuardianV2Config { + local_overrides: GuardianV2ConfigToml, pub(crate) classifier_instructions: String, pub(crate) review_threshold: f64, pub(crate) reasoning_effort: ReasoningEffort, @@ -53,6 +56,81 @@ impl GuardianV2Config { None => GuardianV2ConfigToml::default(), }; + Self::from_overrides(configured) + } + + pub(crate) fn with_model_defaults( + &self, + model_defaults: Option<&GuardianV2ModelConfig>, + ) -> Result { + let mut configured = self.local_overrides.clone(); + if let Some(model_defaults) = model_defaults { + configured.classifier_instructions = configured + .classifier_instructions + .or_else(|| model_defaults.classifier_instructions.clone()); + configured.review_threshold = configured.review_threshold.or_else(|| { + model_defaults + .review_threshold_basis_points + .map(|basis_points| f64::from(basis_points) / 10_000.0) + }); + configured.reasoning_effort = configured + .reasoning_effort + .or_else(|| model_defaults.reasoning_effort.clone()); + configured.max_action_tokens = configured + .max_action_tokens + .or(model_defaults.max_action_tokens); + configured.max_classifier_instruction_tokens = configured + .max_classifier_instruction_tokens + .or(model_defaults.max_classifier_instruction_tokens); + configured.max_parent_compaction_tokens = configured + .max_parent_compaction_tokens + .or(model_defaults.max_parent_compaction_tokens); + + if let Some(model_transcript) = model_defaults.transcript.as_ref() { + let transcript = configured + .transcript + .get_or_insert_with(GuardianV2TranscriptConfigToml::default); + if transcript.sources.is_none() + && let Some(sources) = model_transcript.sources.as_ref() + { + transcript.sources = Some( + sources + .iter() + .map(|source| match source.as_str() { + "tool_calls" => Ok(TranscriptSource::ToolCalls), + "tool_outputs" => Ok(TranscriptSource::ToolOutputs), + "reasoning" => Ok(TranscriptSource::Reasoning), + _ => { + Err(format!("invalid Guardian v2 transcript source: {source}")) + } + }) + .collect::, _>>()?, + ); + } + transcript.max_message_entry_tokens = transcript + .max_message_entry_tokens + .or(model_transcript.max_message_entry_tokens); + transcript.max_tool_entry_tokens = transcript + .max_tool_entry_tokens + .or(model_transcript.max_tool_entry_tokens); + transcript.max_message_transcript_tokens = transcript + .max_message_transcript_tokens + .or(model_transcript.max_message_transcript_tokens); + transcript.max_tool_transcript_tokens = transcript + .max_tool_transcript_tokens + .or(model_transcript.max_tool_transcript_tokens); + transcript.max_recent_non_user_entries = transcript + .max_recent_non_user_entries + .or(model_transcript.max_recent_non_user_entries); + } + } + + let mut resolved = Self::from_overrides(configured)?; + resolved.local_overrides = self.local_overrides.clone(); + Ok(resolved) + } + + fn from_overrides(configured: GuardianV2ConfigToml) -> Result { let review_threshold = configured .review_threshold .unwrap_or(DEFAULT_REVIEW_THRESHOLD); @@ -126,6 +204,7 @@ impl GuardianV2Config { } Ok(Self { + local_overrides: configured.clone(), classifier_instructions: truncate_entry( configured .classifier_instructions diff --git a/codex-rs/ext/guardian-v2/src/extension.rs b/codex-rs/ext/guardian-v2/src/extension.rs index 2834fbac4d..4bf3feca1a 100644 --- a/codex-rs/ext/guardian-v2/src/extension.rs +++ b/codex-rs/ext/guardian-v2/src/extension.rs @@ -288,6 +288,23 @@ impl ToolLifecycleContributor for GuardianV2Extension { let Some(guardian_config) = input.thread_store.get::() else { return Box::pin(std::future::ready(())); }; + let parent_model = input.thread_store.get::(); + let model_defaults = parent_model + .as_ref() + .and_then(|model| model.model_messages.as_ref()) + .and_then(|messages| messages.guardian_v2.as_ref()); + let guardian_config = match guardian_config.with_model_defaults(model_defaults) { + Ok(config) => config, + Err(error) => { + self.event_sink.emit_warning(ExtensionWarning { + thread_id: input.thread_store.level_id().to_owned(), + turn_id: Some(input.turn_id.to_owned()), + message: error, + }); + return Box::pin(std::future::ready(())); + } + }; + input.thread_store.insert(guardian_config.clone()); let sampled_at = SystemTime::now(); let latest_parent_compaction = input .conversation_history @@ -337,7 +354,6 @@ impl ToolLifecycleContributor for GuardianV2Extension { tool_name: input.tool_name.clone(), payload: input.payload.clone(), }; - let parent_model = input.thread_store.get::(); let parent_compaction_hash = parent_model .as_ref() .and_then(|model| model.comp_hash.clone()); diff --git a/codex-rs/ext/guardian-v2/src/extension_tests.rs b/codex-rs/ext/guardian-v2/src/extension_tests.rs index d04ca355e7..abfce867c2 100644 --- a/codex-rs/ext/guardian-v2/src/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/extension_tests.rs @@ -26,6 +26,9 @@ use codex_protocol::models::FunctionCallOutputPayload; use codex_protocol::models::ImageDetail; use codex_protocol::models::InternalChatMessageMetadataPassthrough; use codex_protocol::models::ReasoningItemReasoningSummary; +use codex_protocol::openai_models::GuardianV2ModelConfig; +use codex_protocol::openai_models::GuardianV2TranscriptModelConfig; +use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::ReviewDecision; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TruncationPolicy; @@ -222,8 +225,14 @@ async fn sample_conversation_history( arguments: &str, guardian_policy: Option<&str>, ) -> Result<(serde_json::Value, TestCodex, ExtensionRegistry)> { - sample_configured_conversation_history(conversation_history, arguments, guardian_policy, "") - .await + sample_configured_conversation_history( + conversation_history, + arguments, + guardian_policy, + "", + /*model_defaults*/ None, + ) + .await } async fn sample_configured_conversation_history( @@ -231,11 +240,13 @@ async fn sample_configured_conversation_history( arguments: &str, guardian_policy: Option<&str>, guardian_config: &str, + model_defaults: Option, ) -> Result<(serde_json::Value, TestCodex, ExtensionRegistry)> { let thread_server = responses::start_mock_server().await; let guardian_policy = guardian_policy.map(str::to_owned); let guardian_config = guardian_config.to_owned(); - let test = test_codex() + let has_model_defaults = model_defaults.is_some(); + let builder = test_codex() .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) .with_model_info_override("codex-auto-review", |model_info| { model_info @@ -254,9 +265,19 @@ async fn sample_configured_conversation_history( std::fs::write(home.join("config.toml"), guardian_config) .expect("Guardian v2 configuration should be written"); } + }); + let mut builder = if let Some(model_defaults) = model_defaults { + builder.with_model_info_override("gpt-5.5", move |model| { + model + .model_messages + .as_mut() + .expect("test model should expose model messages") + .guardian_v2 = Some(model_defaults); }) - .build_with_auto_env(&thread_server) - .await?; + } else { + builder + }; + let test = builder.build_with_auto_env(&thread_server).await?; let events = vec![ ev_assistant_message("sample", r#"{"scores":{"action_risk":0.8}}"#), ev_completed("response-1"), @@ -279,6 +300,14 @@ async fn sample_configured_conversation_history( let registry = builder.build(); let session_store = ExtensionData::new("session-1"); let thread_store = test.codex.thread_extension_data(); + if has_model_defaults { + let parent_model = test + .thread_manager + .get_models_manager() + .get_model_info("gpt-5.5", &config.to_models_manager_config()) + .await; + thread_store.insert(parent_model); + } assert_eq!( registry .approval_review(&session_store, thread_store, "review action") @@ -389,6 +418,7 @@ max_recent_non_user_entries = 8 &arguments, Some(TEST_GUARDIAN_POLICY), configuration, + /*model_defaults*/ None, ) .await?; @@ -508,6 +538,7 @@ include_images = true r#"{"path":"README.md"}"#, Some(TEST_GUARDIAN_POLICY), configuration, + /*model_defaults*/ None, ) .await?; let content = request["input"][2]["content"] @@ -531,6 +562,121 @@ include_images = true Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Result<()> { + skip_if_no_network!(Ok(())); + + let model_defaults = GuardianV2ModelConfig { + classifier_instructions: Some("Use the experimental model-owned prompt.".to_owned()), + review_threshold_basis_points: Some(6_000), + reasoning_effort: Some(ReasoningEffort::Minimal), + transcript: Some(GuardianV2TranscriptModelConfig { + sources: Some(vec!["reasoning".to_owned()]), + max_message_entry_tokens: Some(128), + max_message_transcript_tokens: Some(256), + ..Default::default() + }), + max_action_tokens: Some(128), + max_classifier_instruction_tokens: Some(256), + max_parent_compaction_tokens: Some(384), + }; + let conversation_history = vec![ + ResponseItem::Message { + id: None, + role: "user".to_owned(), + content: vec![ContentItem::InputText { + text: "Review the pending action.".to_owned(), + }], + phase: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::Reasoning { + id: None, + summary: vec![ReasoningItemReasoningSummary::SummaryText { + text: "Use the experimental transcript.".to_owned(), + }], + content: None, + encrypted_content: None, + internal_chat_message_metadata_passthrough: None, + }, + ResponseItem::FunctionCall { + id: None, + name: "list_dir".to_owned(), + namespace: None, + arguments: r#"{"path":"."}"#.to_owned(), + encrypted_function_args: None, + call_id: "previous-call".to_owned(), + internal_chat_message_metadata_passthrough: None, + }, + ]; + let arguments = json!({"body": "x".repeat(4_000)}).to_string(); + let local_config = "[features.guardianv2]\nenabled = true\nreview_threshold = 0.70\n"; + let (request, test, registry) = sample_configured_conversation_history( + conversation_history, + &arguments, + Some(TEST_GUARDIAN_POLICY), + local_config, + Some(model_defaults), + ) + .await?; + + assert_eq!( + request["input"][1], + json!({ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": format!( + "Use the experimental model-owned prompt.\n\n# Security Policy\n{TEST_GUARDIAN_POLICY}" + ) + }] + }) + ); + assert_eq!(request["reasoning"]["effort"], "minimal"); + let content = request["input"][2]["content"] + .as_array() + .expect("Luna user content should be an array"); + let transcript = content + .iter() + .filter_map(|item| item["text"].as_str()) + .collect::>(); + assert!(transcript.contains(&"[2] reasoning: Use the experimental transcript.\n")); + assert!(!transcript.iter().any(|entry| entry.contains("list_dir"))); + let action = content[content.len() - 2]["text"] + .as_str() + .expect("planned action should be a text item"); + assert!(action.len() <= TruncationPolicy::Tokens(/*limit*/ 128).byte_budget()); + + let session_store = ExtensionData::new("session-1"); + let thread_store = test.codex.thread_extension_data(); + assert_eq!( + thread_store + .get::() + .expect("Guardian v2 configuration should be installed") + .max_parent_compaction_tokens, + 384 + ); + tokio::time::timeout(Duration::from_secs(5), async { + while thread_store.get::().is_none() { + tokio::task::yield_now().await; + } + }) + .await?; + thread_store.insert(SecurityRiskScore { + scores: BTreeMap::from([("action_risk".to_owned(), 0.65)]), + sampled_at: None, + }); + assert_eq!( + registry + .approval_review(&session_store, thread_store, "review action") + .await, + Some(ReviewDecision::Approved) + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn contributor_samples_tool_calls_with_the_existing_luna_pool() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index cdbf7e7e72..eaf8d3274a 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -59,6 +59,7 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }); model_messages.instructions_template = Some(base_instructions.clone()); model_messages.instructions_variables = None; @@ -201,6 +202,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }, _ => ModelMessages { instructions_template: Some(BASE_INSTRUCTIONS.to_string()), @@ -211,6 +213,7 @@ fn local_model_messages_for_slug(slug: &str) -> ModelMessages { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }, } } diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 0da6200ad4..579c4b0250 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -4,6 +4,7 @@ use codex_protocol::config_types::Personality; use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::openai_models::CollaborationModeMessages; +use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::ModelTokenBudgetConfig; use codex_protocol::openai_models::MultiAgentMessages; use codex_protocol::openai_models::MultiAgentModeMessages; @@ -59,6 +60,10 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { auto_compact_fallback_prompt: "compact prompt".to_string(), auto_compact_fallback_buffer_tokens: 64, }; + let guardian_v2 = GuardianV2ModelConfig { + classifier_instructions: Some("Guardian experiment".to_string()), + ..Default::default() + }; model.model_messages = Some(ModelMessages { instructions_template: Some("template".to_string()), instructions_variables: Some(ModelInstructionsVariables { @@ -72,6 +77,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { permissions: Some(permissions.clone()), multi_agent: Some(multi_agent.clone()), token_budget: Some(token_budget.clone()), + guardian_v2: Some(guardian_v2.clone()), }); let config = ModelsManagerConfig { base_instructions: Some(override_instructions.to_string()), @@ -91,6 +97,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { permissions: Some(permissions), multi_agent: Some(multi_agent), token_budget: Some(token_budget), + guardian_v2: Some(guardian_v2), }) ); assert_eq!( @@ -121,6 +128,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }); let config = ModelsManagerConfig { personality_enabled: false, @@ -140,6 +148,7 @@ fn disabled_personality_bakes_default_and_preserves_catalog_approval_messages() permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }) ); } @@ -166,6 +175,7 @@ fn disabled_personality_uses_plain_base_instructions_for_local_personality_model permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }), "unexpected model messages for {slug}" ); @@ -206,6 +216,7 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }); let updated = with_config_overrides(model, &config); diff --git a/codex-rs/protocol/src/openai_models.rs b/codex-rs/protocol/src/openai_models.rs index 70672cde94..8192e50fcf 100644 --- a/codex-rs/protocol/src/openai_models.rs +++ b/codex-rs/protocol/src/openai_models.rs @@ -33,6 +33,12 @@ use crate::config_types::ServiceTier; use crate::config_types::Verbosity; use crate::protocol::MultiAgentVersion; +#[path = "openai_models/guardian_v2.rs"] +mod guardian_v2; + +pub use guardian_v2::GuardianV2ModelConfig; +pub use guardian_v2::GuardianV2TranscriptModelConfig; + const PERSONALITY_PLACEHOLDER: &str = "{{ personality }}"; /// Backend model-catalog specialty identifying cybersecurity-focused models. pub const MODEL_SPECIALTY_CYBER: &str = "cyber"; @@ -534,6 +540,8 @@ pub struct ModelMessages { pub multi_agent: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub token_budget: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub guardian_v2: Option, } /// Model-owned defaults for the context-window token-budget feature. @@ -758,6 +766,7 @@ where permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }); messages.instructions_template = Some(base_instructions); } @@ -947,6 +956,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, } ); } @@ -1088,6 +1098,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, } ); } @@ -1169,6 +1180,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); @@ -1191,6 +1203,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, })); assert_eq!( model.get_model_instructions(Some(Personality::Pragmatic)), @@ -1214,6 +1227,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, })); assert_eq!( model_no_personality.get_model_instructions(Some(Personality::Friendly)), @@ -1248,6 +1262,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, })); let instructions = model.get_model_instructions(Some(Personality::Friendly)); @@ -1288,6 +1303,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }) ); assert_eq!( @@ -1339,6 +1355,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }))], }; @@ -1376,6 +1393,17 @@ mod tests { }), multi_agent: None, token_budget: None, + guardian_v2: Some(GuardianV2ModelConfig { + classifier_instructions: Some("Guardian classification".to_string()), + review_threshold_basis_points: Some(7_500), + reasoning_effort: Some(ReasoningEffort::Minimal), + transcript: Some(GuardianV2TranscriptModelConfig { + sources: Some(vec!["reasoning".to_string()]), + max_tool_entry_tokens: Some(500), + ..Default::default() + }), + ..Default::default() + }), }; let mut value = serde_json::to_value(ModelsResponse { models: vec![test_model(Some(messages.clone()))], @@ -1398,6 +1426,7 @@ mod tests { permissions: None, multi_agent: None, token_budget: None, + guardian_v2: None, }; let mut value = serde_json::to_value(ModelsResponse { models: vec![test_model(Some(canonical_messages.clone()))], diff --git a/codex-rs/protocol/src/openai_models/guardian_v2.rs b/codex-rs/protocol/src/openai_models/guardian_v2.rs new file mode 100644 index 0000000000..814bf3ad23 --- /dev/null +++ b/codex-rs/protocol/src/openai_models/guardian_v2.rs @@ -0,0 +1,42 @@ +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use ts_rs::TS; + +use super::ReasoningEffort; + +/// Optional model-owned defaults for Guardian v2 classification experiments. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct GuardianV2ModelConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub classifier_instructions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub review_threshold_basis_points: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transcript: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_action_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_classifier_instruction_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_parent_compaction_tokens: Option, +} + +/// Optional model-owned defaults for selecting and bounding Guardian v2 history. +#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)] +pub struct GuardianV2TranscriptModelConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sources: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_message_entry_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_entry_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_message_transcript_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_tool_transcript_tokens: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_recent_non_user_entries: Option, +}