From a8c36ca6d265800c1b2c67d19d3583e23dee8382 Mon Sep 17 00:00:00 2001 From: rhan-oai Date: Wed, 16 Sep 2026 20:46:23 +0000 Subject: [PATCH] Centralize model-message resolution and rendering in `codex-prompts` (#46026) ## What changed - Add `ResolvedModelMessages` to resolve catalog text and bundled defaults while preserving explicit empty overrides and their source. - Move base-instruction rendering, Guardian prompt composition, multi-agent role rendering, and `update_plan` guidance filtering into `codex-prompts`; migrate consumers to the shared APIs. - Separate permission-profile resolution from prompt composition, and annotate Guardian policy and classifier instructions with content kinds. ## Testing Add and update coverage for missing versus empty templates, literal overrides, multi-agent role composition, Guardian policy substitution and truncation, and preservation of permission path spellings and order. GitOrigin-RevId: 52335bb7acec0f432d5c57acb2accd5f0276056e --- codex-rs/Cargo.lock | 10 +- codex-rs/app-server/Cargo.toml | 1 + .../session_importer.rs | 3 +- .../app-server/tests/suite/v2/guardian_v2.rs | 2 +- codex-rs/core/src/agent/control/spawn.rs | 5 +- codex-rs/core/src/agent/control_tests.rs | 20 +- codex-rs/core/src/config/config_tests.rs | 36 +- codex-rs/core/src/config/mod.rs | 26 +- .../src/context/guardian_node_repl_policy.rs | 9 +- codex-rs/core/src/context/mod.rs | 9 +- .../context/multi_agent_mode_instructions.rs | 16 +- .../context/multi_agent_role_instructions.rs | 54 -- .../src/context/permissions_instructions.rs | 2 - .../context/world_state/collaboration_mode.rs | 37 +- .../world_state/collaboration_mode_tests.rs | 92 +- .../world_state/multi_agent_mode_tests.rs | 35 +- .../src/context/world_state/permissions.rs | 4 +- .../context/world_state/permissions_tests.rs | 27 +- .../context/world_state/persistent_mode.rs | 24 +- .../world_state/persistent_mode_tests.rs | 26 +- codex-rs/core/src/context_manager/history.rs | 6 +- codex-rs/core/src/guardian/mod.rs | 3 - codex-rs/core/src/guardian/prompt.rs | 28 - codex-rs/core/src/guardian/review.rs | 8 +- codex-rs/core/src/guardian/review_session.rs | 2 - .../core/src/guardian/review_session_tests.rs | 239 ++--- codex-rs/core/src/guardian/reviewer_config.rs | 27 +- codex-rs/core/src/guardian/tests.rs | 120 ++- codex-rs/core/src/mcp_tool_call.rs | 14 +- codex-rs/core/src/session/mcp.rs | 6 +- codex-rs/core/src/session/mcp_tests.rs | 2 +- codex-rs/core/src/session/mod.rs | 6 +- codex-rs/core/src/session/multi_agents.rs | 154 +--- codex-rs/core/src/session/session.rs | 3 +- codex-rs/core/src/session/step_activation.rs | 54 +- .../core/src/session/step_activation_tests.rs | 6 +- .../core/src/session/step_settings_tests.rs | 3 +- codex-rs/core/src/session/tests.rs | 17 +- codex-rs/core/src/session/turn.rs | 3 - codex-rs/core/src/session/world_state.rs | 29 +- codex-rs/core/src/tools/approvals.rs | 11 +- .../tools/handlers/multi_agents_v2/spawn.rs | 14 +- .../handlers/request_user_input_async.rs | 7 +- .../runtimes/zsh_fork/unix_escalation.rs | 8 +- codex-rs/core/src/tools/spec_plan.rs | 14 +- codex-rs/core/src/tools/spec_plan_tests.rs | 12 +- codex-rs/core/tests/suite/guardian_review.rs | 4 +- codex-rs/core/tests/suite/model_switching.rs | 7 +- .../core/tests/suite/permissions_messages.rs | 84 +- codex-rs/core/tests/suite/prompt_caching.rs | 3 +- codex-rs/core/tests/suite/remote_models.rs | 54 +- codex-rs/ext/goal/Cargo.toml | 1 + codex-rs/ext/goal/src/steering.rs | 2 +- codex-rs/ext/guardian-reviewer/Cargo.toml | 1 + .../ext/guardian-reviewer/src/completion.rs | 43 +- codex-rs/ext/guardian-reviewer/src/lib.rs | 1 - codex-rs/ext/guardian-v2/BUILD.bazel | 3 - codex-rs/ext/guardian-v2/Cargo.toml | 2 + .../src/async_scorer/classification.rs | 15 +- .../guardian-v2/src/async_scorer/config.rs | 47 +- .../src/async_scorer/config_tests.rs | 84 +- .../src/async_scorer/extension_tests.rs | 36 +- .../guardian-v2/src/async_scorer/sampler.rs | 15 +- .../src/async_scorer/sampler_tests.rs | 41 +- .../src/async_scorer/transcript.rs | 1 - .../src/async_scorer/transcript_tests.rs | 2 +- codex-rs/models-manager/Cargo.toml | 2 +- .../src/collaboration_mode_presets.rs | 41 +- .../src/collaboration_mode_presets_tests.rs | 24 +- codex-rs/models-manager/src/model_info.rs | 27 +- .../models-manager/src/model_info_tests.rs | 112 +-- codex-rs/prompts/Cargo.toml | 5 +- codex-rs/prompts/src/guardian_instructions.rs | 144 +++ .../src/guardian_instructions_tests.rs | 86 ++ codex-rs/prompts/src/lib.rs | 16 + codex-rs/prompts/src/model_instructions.rs | 21 + .../prompts/src/model_instructions_tests.rs | 105 +++ codex-rs/prompts/src/model_messages.rs | 160 ++++ .../src/model_messages/collaboration.rs | 22 + .../prompts/src/model_messages/guardian.rs | 62 ++ .../prompts/src/model_messages/multi_agent.rs | 84 ++ .../prompts/src/model_messages/permissions.rs | 80 ++ .../prompts/src/multi_agent_instructions.rs | 96 ++ .../src/multi_agent_instructions_tests.rs | 42 + .../prompts/src/permissions_instructions.rs | 453 +++++----- .../src/permissions_instructions_tests.rs | 850 +++++++++--------- .../src}/update_plan_instructions.rs | 2 +- .../src}/update_plan_instructions_tests.rs | 2 +- .../guardian}/classifier_instructions.md | 0 .../templates}/guardian/node_repl_policy.md | 0 .../templates}/guardian/policy.md | 0 .../templates}/guardian/policy_template.md | 0 .../templates}/persistent_mode.md | 0 codex-rs/protocol/src/openai_models.rs | 229 +---- 94 files changed, 2345 insertions(+), 2000 deletions(-) delete mode 100644 codex-rs/core/src/context/multi_agent_role_instructions.rs delete mode 100644 codex-rs/core/src/context/permissions_instructions.rs create mode 100644 codex-rs/prompts/src/guardian_instructions.rs create mode 100644 codex-rs/prompts/src/guardian_instructions_tests.rs create mode 100644 codex-rs/prompts/src/model_instructions.rs create mode 100644 codex-rs/prompts/src/model_instructions_tests.rs create mode 100644 codex-rs/prompts/src/model_messages.rs create mode 100644 codex-rs/prompts/src/model_messages/collaboration.rs create mode 100644 codex-rs/prompts/src/model_messages/guardian.rs create mode 100644 codex-rs/prompts/src/model_messages/multi_agent.rs create mode 100644 codex-rs/prompts/src/model_messages/permissions.rs create mode 100644 codex-rs/prompts/src/multi_agent_instructions.rs create mode 100644 codex-rs/prompts/src/multi_agent_instructions_tests.rs rename codex-rs/{core/src/context => prompts/src}/update_plan_instructions.rs (95%) rename codex-rs/{core/src/context => prompts/src}/update_plan_instructions_tests.rs (96%) rename codex-rs/{ext/guardian-v2/src/async_scorer => prompts/templates/guardian}/classifier_instructions.md (100%) rename codex-rs/{core/assets => prompts/templates}/guardian/node_repl_policy.md (100%) rename codex-rs/{core/assets => prompts/templates}/guardian/policy.md (100%) rename codex-rs/{core/assets => prompts/templates}/guardian/policy_template.md (100%) rename codex-rs/{core/assets => prompts/templates}/persistent_mode.md (100%) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 289637530a..f05dbed45d 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2219,6 +2219,7 @@ dependencies = [ "codex-network-proxy", "codex-otel", "codex-plugin", + "codex-prompts", "codex-protocol", "codex-queue-extension", "codex-rmcp-client", @@ -3540,6 +3541,7 @@ dependencies = [ "codex-core", "codex-extension-api", "codex-otel", + "codex-prompts", "codex-protocol", "codex-rollout", "codex-state", @@ -3576,6 +3578,7 @@ dependencies = [ "codex-guardian-context", "codex-mcp", "codex-otel", + "codex-prompts", "codex-protocol", "opentelemetry", "opentelemetry_sdk", @@ -3597,6 +3600,7 @@ dependencies = [ "codex-analytics", "codex-api", "codex-config", + "codex-context-fragments", "codex-core", "codex-extension-api", "codex-features", @@ -3607,6 +3611,7 @@ dependencies = [ "codex-login", "codex-model-provider", "codex-model-provider-info", + "codex-prompts", "codex-protocol", "core_test_support", "dirs", @@ -4076,10 +4081,10 @@ name = "codex-models-manager" version = "0.0.0" dependencies = [ "chrono", - "codex-collaboration-mode-templates", "codex-http-client", "codex-login", "codex-otel", + "codex-prompts", "codex-protocol", "codex-utils-output-truncation", "pretty_assertions", @@ -4248,13 +4253,16 @@ name = "codex-prompts" version = "0.0.0" dependencies = [ "anyhow", + "codex-collaboration-mode-templates", "codex-context-fragments", "codex-execpolicy", "codex-git-utils", + "codex-guardian-context", "codex-protocol", "codex-utils-absolute-path", "codex-utils-template", "pretty_assertions", + "tracing", ] [[package]] diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 5c43378311..bd47a54d08 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -79,6 +79,7 @@ codex-mcp-extension = { workspace = true } codex-model-provider = { workspace = true } codex-model-provider-info = { workspace = true } codex-models-manager = { workspace = true } +codex-prompts = { workspace = true } codex-protocol = { workspace = true } codex-queue-extension = { workspace = true } codex-app-server-protocol = { workspace = true } diff --git a/codex-rs/app-server/src/external_agent_migration/session_importer.rs b/codex-rs/app-server/src/external_agent_migration/session_importer.rs index d691ed2e1f..956271528e 100644 --- a/codex-rs/app-server/src/external_agent_migration/session_importer.rs +++ b/codex-rs/app-server/src/external_agent_migration/session_importer.rs @@ -25,6 +25,7 @@ use codex_external_agent_migration::sessions::detect_imported_cla_session_connec use codex_external_agent_migration::sessions::prepare_validated_session_import_with_metadata_mode; use codex_external_agent_migration::sessions::record_completed_session_imports; use codex_models_manager::manager::RefreshStrategy; +use codex_prompts::render_model_instructions; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; use codex_protocol::models::BaseInstructionsProvenance; @@ -467,7 +468,7 @@ impl ExternalAgentSessionImporter { text: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), provenance: Some(config.base_instructions_provenance.clone().unwrap_or_else( || { if config.base_instructions.is_some() { diff --git a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs index 90d07c6a50..9e025953ea 100644 --- a/codex-rs/app-server/tests/suite/v2/guardian_v2.rs +++ b/codex-rs/app-server/tests/suite/v2/guardian_v2.rs @@ -1186,7 +1186,7 @@ async fn guardian_v2_routes_scoped_tool_approvals( .collect::>(), vec![ json!(["additional_tools", "developer", null]), - json!(["message", "developer", null]), + json!(["message", "developer", ["guardian.classifier_instructions"]]), json!(["message", "developer", null]), json!(["message", "developer", ["guardian.trusted_tool"]]), json!(["message", "developer", ["guardian.trusted_skills"]]), diff --git a/codex-rs/core/src/agent/control/spawn.rs b/codex-rs/core/src/agent/control/spawn.rs index 95d148c2e4..4e8d9bff6e 100644 --- a/codex-rs/core/src/agent/control/spawn.rs +++ b/codex-rs/core/src/agent/control/spawn.rs @@ -18,6 +18,7 @@ use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; use codex_extension_api::ExtensionDataInit; use codex_history::ResponseItemEnvelope; +use codex_prompts::ResolvedModelMessages; use codex_protocol::intersect_effective_permission_profiles; use codex_protocol::protocol::EnvironmentConfigState; use codex_utils_path_uri::PathUri; @@ -917,7 +918,7 @@ impl AgentControl { let parent_config = parent_thread.session.get_config().await; let parent_usage_hints = resolve_usage_hints( &parent_config.multi_agent_v2, - /*catalog*/ None, + ResolvedModelMessages::bundled().multi_agent(), !parent_config.update_plan_enabled, ); [parent_usage_hints.root, parent_usage_hints.subagent] @@ -1105,7 +1106,7 @@ impl AgentControl { .unwrap_or_else(|| { resolve_usage_hints( &config.multi_agent_v2, - /*catalog*/ None, + ResolvedModelMessages::bundled().multi_agent(), !config.update_plan_enabled, ) .subagent diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs index 2981b13b0a..c2bd787c5d 100644 --- a/codex-rs/core/src/agent/control_tests.rs +++ b/codex-rs/core/src/agent/control_tests.rs @@ -2000,8 +2000,8 @@ async fn spawn_agent_can_fork_parent_thread_history_with_sanitized_items() { expected_developer_message, expected_final_answer, expected_standalone_output, - ContextualUserFragment::into(MultiAgentRoleInstructions::unmarked( - "Child subagent guidance.", + ContextualUserFragment::into(MultiAgentRoleInstructions::Configured( + "Child subagent guidance.".to_string(), )), ]; assert_eq!( @@ -2141,6 +2141,14 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history( let parent_thread = new_thread.thread; let turn_context = parent_thread.session.new_default_turn().await; let parent_spawn_call_id = "spawn-call-compacted-usage-hints".to_string(); + let catalog_role = |base: &str| MultiAgentRoleInstructions::Composed { + base: base.to_string(), + marked: true, + omit_update_plan_instructions: false, + max_concurrency: 2, + wait_agent_enabled: false, + expose_model_overrides: false, + }; let parent_task = InterAgentCommunication::new( AgentPath::root(), AgentPath::root().join("worker").expect("valid worker path"), @@ -2159,9 +2167,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history( phase: None, internal_chat_message_metadata_passthrough: None, }, - ContextualUserFragment::into(MultiAgentRoleInstructions::catalog( - "Catalog parent root guidance.", - )), + ContextualUserFragment::into(catalog_role("Catalog parent root guidance.")), parent_task.to_model_input_item(), ResponseItem::Message { id: None, @@ -2250,9 +2256,7 @@ async fn spawn_agent_fork_strips_parent_usage_hints_from_compacted_history( fork_mode: Some(SpawnAgentForkMode::FullHistory), multi_agent_v2_usage_hints: Some(ResolvedMultiAgentV2UsageHints { root: None, - subagent: Some(MultiAgentRoleInstructions::catalog( - "Catalog child subagent guidance.", - )), + subagent: Some(catalog_role("Catalog child subagent guidance.")), }), ..Default::default() }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 718e06f6bf..da2bf72e8f 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -81,6 +81,7 @@ use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_network_proxy::NetworkMode; +use codex_prompts::ResolvedMessage; use codex_protocol::config_types::ModelProviderAuthInfo; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::ServiceTier; @@ -91,7 +92,6 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; -use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::permissions::FileSystemAccessMode; use codex_protocol::permissions::FileSystemPath; use codex_protocol::permissions::FileSystemSandboxEntry; @@ -11835,12 +11835,13 @@ max_concurrent_threads_per_session = 17 let config = resolve_multi_agent_v2_config(&config_toml); let concurrency_guidance = "There are 17 available concurrency slots, meaning that up to 17 agents can be active at once, including you."; + let messages = ResolvedModelMessages::bundled().multi_agent(); assert!(config.wait_agent_enabled); for wait_agent_enabled in [true, false] { let mut config = config.clone(); config.wait_agent_enabled = wait_agent_enabled; let usage_hints = resolve_usage_hints( - &config, /*catalog*/ None, /*omit_update_plan_instructions*/ false, + &config, messages, /*omit_update_plan_instructions*/ false, ); for hint in [usage_hints.root, usage_hints.subagent] { let hint = hint.expect("default usage hints should be present").body(); @@ -11852,12 +11853,12 @@ max_concurrent_threads_per_session = 17 } } + let mut empty_messages = messages; + empty_messages.root = ResolvedMessage::Catalog(""); + empty_messages.subagent = ResolvedMessage::Catalog(""); let usage_hints = resolve_usage_hints( &config, - Some(&MultiAgentRoleMessages { - root: Some(String::new()), - subagent: Some(String::new()), - }), + empty_messages, /*omit_update_plan_instructions*/ false, ); assert!(usage_hints.root.is_none() && usage_hints.subagent.is_none()); @@ -11885,13 +11886,11 @@ expose_spawn_agent_model_overrides = true config.subagent_usage_hint_text.as_deref(), Some("## `update_plan`\nSubagent guidance.") ); + let mut messages = ResolvedModelMessages::bundled().multi_agent(); + messages.root = ResolvedMessage::Catalog("Catalog root base."); + messages.subagent = ResolvedMessage::Catalog("Catalog subagent base."); let usage_hints = resolve_usage_hints( - &config, - Some(&MultiAgentRoleMessages { - root: Some("Catalog root base.".to_string()), - subagent: Some("Catalog subagent base.".to_string()), - }), - /*omit_update_plan_instructions*/ true, + &config, messages, /*omit_update_plan_instructions*/ true, ); assert_eq!( ( @@ -11912,12 +11911,13 @@ fn multi_agent_v2_exposes_model_overrides_by_default() { let mut config = resolve_multi_agent_v2_config(&config_toml); assert!(config.expose_spawn_agent_model_overrides); + let messages = ResolvedModelMessages::bundled().multi_agent(); let usage_hints = resolve_usage_hints( - &config, /*catalog*/ None, /*omit_update_plan_instructions*/ false, + &config, messages, /*omit_update_plan_instructions*/ false, ); config.expose_spawn_agent_model_overrides = false; let usage_hints_without_model_overrides = resolve_usage_hints( - &config, /*catalog*/ None, /*omit_update_plan_instructions*/ false, + &config, messages, /*omit_update_plan_instructions*/ false, ); for (hint, hint_without_model_overrides) in [ @@ -12027,12 +12027,12 @@ subagent_usage_hint_text = "" .build() .await?; + let mut messages = ResolvedModelMessages::bundled().multi_agent(); + messages.root = ResolvedMessage::Catalog("catalog root"); + messages.subagent = ResolvedMessage::Catalog("catalog subagent"); let usage_hints = resolve_usage_hints( &config.multi_agent_v2, - Some(&MultiAgentRoleMessages { - root: Some("catalog root".to_string()), - subagent: Some("catalog subagent".to_string()), - }), + messages, /*omit_update_plan_instructions*/ false, ); assert_eq!( diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 23a6f83103..8cea0f3dc4 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -1,7 +1,6 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::context::world_state::validate_managed_developer_instructions; -use crate::guardian::BUNDLED_GUARDIAN_POLICY; use crate::path_utils::normalize_for_native_workdir; use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS; use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS; @@ -96,6 +95,7 @@ use codex_model_provider_info::OLLAMA_CHAT_PROVIDER_REMOVED_ERROR; use codex_model_provider_info::built_in_model_providers; use codex_model_provider_info::merge_configured_model_providers; use codex_models_manager::ModelsManagerConfig; +use codex_prompts::ResolvedModelMessages; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::AutoCompactTokenLimitScope; use codex_protocol::config_types::ForcedLoginMethod; @@ -116,7 +116,6 @@ use codex_protocol::models::PermissionProfile; pub use codex_protocol::models::PermissionProfileSnapshot; use codex_protocol::models::ProfileWorkspaceRoot; use codex_protocol::models::SandboxEnforcement; -use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::permissions::DenyReadValidator; @@ -1144,10 +1143,6 @@ impl Default for CodeModeConfig { } } -pub(crate) const DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE: &str = concat!( - "Your context window is nearly exhausted (only {n_remaining} tokens remaining) and will be automatically reset for you soon. ", - "Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows." -); const TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES: usize = 2000; const TOKEN_BUDGET_GUIDANCE_MESSAGE_MAX_BYTES: usize = 2000; const AUTO_COMPACT_FALLBACK_PROMPT_MAX_BYTES: usize = 2000; @@ -1249,7 +1244,9 @@ impl Default for TokenBudgetConfig { Self { use_history_notes_extension: false, reminder_threshold_tokens: None, - reminder_message_template: DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string(), + reminder_message_template: ResolvedModelMessages::bundled() + .token_budget_reminder_template() + .to_owned(), guidance_message: None, auto_compact_fallback_prompt: None, auto_compact_fallback_buffer_tokens: None, @@ -1530,16 +1527,11 @@ impl Config { /// Resolves the configured, reviewer-catalog, or bundled Guardian policy. pub fn resolve_guardian_policy<'a>( &'a self, - model_messages: Option<&'a ModelMessages>, + model_messages: ResolvedModelMessages<'a>, ) -> &'a str { self.guardian_policy_config .as_deref() - .or_else(|| { - model_messages - .and_then(|messages| messages.auto_review.as_ref()) - .and_then(|messages| messages.policy.as_deref()) - }) - .unwrap_or(BUNDLED_GUARDIAN_POLICY) + .unwrap_or(model_messages.auto_review().policy) } pub(crate) fn multi_agent_version_override(&self) -> Option { @@ -2796,7 +2788,11 @@ pub(crate) fn resolve_token_budget_config( token_budget_config.and_then(|config| config.reminder_threshold_tokens); let reminder_message_template = token_budget_config .and_then(|config| config.reminder_message_template.clone()) - .unwrap_or_else(|| DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string()); + .unwrap_or_else(|| { + ResolvedModelMessages::bundled() + .token_budget_reminder_template() + .to_owned() + }); let guidance_message = token_budget_config .and_then(|config| config.guidance_message.clone()) .filter(|message| !message.trim().is_empty()); diff --git a/codex-rs/core/src/context/guardian_node_repl_policy.rs b/codex-rs/core/src/context/guardian_node_repl_policy.rs index c05ed242f1..f50460624c 100644 --- a/codex-rs/core/src/context/guardian_node_repl_policy.rs +++ b/codex-rs/core/src/context/guardian_node_repl_policy.rs @@ -1,6 +1,6 @@ use super::ContextualUserFragment; +use codex_prompts::ResolvedModelMessages; use codex_protocol::models::ContentItemKind; -use codex_protocol::openai_models::ModelMessages; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct GuardianNodeReplPolicy { @@ -8,11 +8,8 @@ pub(crate) struct GuardianNodeReplPolicy { } impl GuardianNodeReplPolicy { - pub(crate) fn from_model_messages(messages: Option<&ModelMessages>) -> Self { - let policy = messages - .and_then(|messages| messages.auto_review.as_ref()) - .and_then(|messages| messages.node_repl_policy.as_deref()) - .unwrap_or(include_str!("../../assets/guardian/node_repl_policy.md")); + pub(crate) fn from_messages(model_messages: ResolvedModelMessages<'_>) -> Self { + let policy = model_messages.auto_review().node_repl_policy; Self { policy: policy.to_string(), } diff --git a/codex-rs/core/src/context/mod.rs b/codex-rs/core/src/context/mod.rs index 7c61965105..eef3c7d670 100644 --- a/codex-rs/core/src/context/mod.rs +++ b/codex-rs/core/src/context/mod.rs @@ -30,11 +30,9 @@ mod legacy_unified_exec_process_limit_warning; mod memory; mod model_switch_instructions; mod multi_agent_mode_instructions; -mod multi_agent_role_instructions; mod multi_agent_usage_hint; mod network_rule_saved; mod node_repl_review_evidence; -mod permissions_instructions; mod plugin_instructions; mod realtime_delegation; mod realtime_end_instructions; @@ -46,7 +44,6 @@ mod subagent_notification; mod token_budget_context; mod turn_aborted; mod unsupported_media; -mod update_plan_instructions; mod user_instructions; mod user_shell_command; mod user_verification_notice; @@ -61,6 +58,8 @@ pub(crate) use codex_context_fragments::AdditionalContextDeveloperFragment; pub(crate) use codex_context_fragments::AdditionalContextUserFragment; pub use codex_context_fragments::AnsweredQuestion; pub use codex_context_fragments::ContextualUserFragment; +pub(crate) use codex_prompts::MultiAgentRoleInstructions; +pub use codex_prompts::PermissionsInstructions; pub(crate) use compaction_summary::CompactionSummary; pub(crate) use contextual_user_message::is_contextual_user_fragment; pub(crate) use contextual_user_message::is_user_authorization_message; @@ -95,14 +94,11 @@ pub(crate) use legacy_unified_exec_process_limit_warning::LegacyUnifiedExecProce pub use memory::MemoryContextFragment; pub(crate) use model_switch_instructions::ModelSwitchInstructions; pub(crate) use multi_agent_mode_instructions::MultiAgentModeInstructions; -pub(crate) use multi_agent_role_instructions::MultiAgentRoleInstructions; pub(crate) use multi_agent_usage_hint::MultiAgentUsageHint; pub(crate) use network_rule_saved::NetworkRuleSaved; pub use node_repl_review_evidence::NodeReplReviewEvidence; pub(crate) use node_repl_review_evidence::NodeReplReviewEvidenceMode; pub(crate) use node_repl_review_evidence::node_repl_review_evidence_mode; -pub use permissions_instructions::ApprovalPromptContext; -pub use permissions_instructions::PermissionsInstructions; pub(crate) use plugin_instructions::PluginInstructions; pub(crate) use realtime_delegation::RealtimeDelegation; pub(crate) use realtime_delegation::RealtimeDelegationSource; @@ -119,7 +115,6 @@ pub(crate) use token_budget_context::TokenBudgetRemainingContext; pub(crate) use token_budget_context::TokenBudgetReminder; pub(crate) use turn_aborted::TurnAborted; pub(crate) use unsupported_media::UnsupportedMedia; -pub use update_plan_instructions::without_update_plan_instructions; pub(crate) use user_instructions::UserInstructions; pub(crate) use user_shell_command::UserShellCommand; pub(crate) use user_verification_notice::UserVerificationNotice; diff --git a/codex-rs/core/src/context/multi_agent_mode_instructions.rs b/codex-rs/core/src/context/multi_agent_mode_instructions.rs index b586ee4727..e66da8994c 100644 --- a/codex-rs/core/src/context/multi_agent_mode_instructions.rs +++ b/codex-rs/core/src/context/multi_agent_mode_instructions.rs @@ -1,12 +1,10 @@ use super::ContextualUserFragment; +use codex_prompts::ResolvedModelMessages; use codex_protocol::config_types::MultiAgentMode; use codex_protocol::models::ContentItemKind; use codex_protocol::protocol::MULTI_AGENT_MODE_CLOSE_TAG; use codex_protocol::protocol::MULTI_AGENT_MODE_OPEN_TAG; -const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work."; -const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier developer instruction requiring an explicit user request before spawning sub-agents no longer applies. This mode remains active until a later multi-agent mode developer message changes it. User requests override this hint.\n\nIf at any point you can parallelize work by delegating tasks to another agent (no matter if you are root or subagent), you should do so using collaboration tools if it could save time or improve quality."; - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct MultiAgentModeInstructions { multi_agent_mode: MultiAgentMode, @@ -43,12 +41,14 @@ impl ContextualUserFragment for MultiAgentModeInstructions { } fn body(&self) -> String { + // `effective_multi_agent_mode` carries configured and catalog overrides as `Custom`. + // The other variants explicitly select bundled text. + let bundled = ResolvedModelMessages::bundled().multi_agent(); match &self.multi_agent_mode { - MultiAgentMode::Custom(hint_text) => hint_text.clone(), - MultiAgentMode::ExplicitRequestOnly => { - EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT.to_string() - } - MultiAgentMode::Proactive => PROACTIVE_MULTI_AGENT_MODE_TEXT.to_string(), + MultiAgentMode::Custom(hint_text) => hint_text.as_str(), + MultiAgentMode::ExplicitRequestOnly => bundled.explicit.text(), + MultiAgentMode::Proactive => bundled.proactive.text(), } + .to_owned() } } diff --git a/codex-rs/core/src/context/multi_agent_role_instructions.rs b/codex-rs/core/src/context/multi_agent_role_instructions.rs deleted file mode 100644 index 6979b6cbd9..0000000000 --- a/codex-rs/core/src/context/multi_agent_role_instructions.rs +++ /dev/null @@ -1,54 +0,0 @@ -use super::ContextualUserFragment; -use codex_protocol::models::ContentItemKind; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct MultiAgentRoleInstructions { - text: String, - marked: bool, -} - -impl MultiAgentRoleInstructions { - pub(crate) fn unmarked(text: impl Into) -> Self { - Self { - text: text.into(), - marked: false, - } - } - - pub(crate) fn catalog(text: impl Into) -> Self { - Self { - text: text.into(), - marked: true, - } - } -} - -impl ContextualUserFragment for MultiAgentRoleInstructions { - fn content_kind(&self) -> ContentItemKind { - ContentItemKind("multi_agent.role_instructions".to_string()) - } - - fn role(&self) -> &'static str { - "developer" - } - - fn requires_separate_message(&self) -> bool { - true - } - - fn markers(&self) -> (&'static str, &'static str) { - if self.marked { - Self::type_markers() - } else { - ("", "") - } - } - - fn type_markers() -> (&'static str, &'static str) { - ("", "") - } - - fn body(&self) -> String { - self.text.clone() - } -} diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs deleted file mode 100644 index 3dd1bbac92..0000000000 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub use codex_prompts::ApprovalPromptContext; -pub use codex_prompts::PermissionsInstructions; diff --git a/codex-rs/core/src/context/world_state/collaboration_mode.rs b/codex-rs/core/src/context/world_state/collaboration_mode.rs index b4050a217e..e9d7fd192b 100644 --- a/codex-rs/core/src/context/world_state/collaboration_mode.rs +++ b/codex-rs/core/src/context/world_state/collaboration_mode.rs @@ -2,12 +2,12 @@ use super::PreviousSectionState; use super::WorldStateHash; use super::WorldStateSection; use crate::context::ContextualUserFragment; -use crate::context::without_update_plan_instructions; -use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; +use codex_prompts::ResolvedCollaborationModeMessages; +use codex_prompts::ResolvedModelMessages; +use codex_prompts::without_update_plan_instructions; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::models::ContentItemKind; -use codex_protocol::openai_models::CollaborationModeMessages; use codex_protocol::protocol::COLLABORATION_MODE_CLOSE_TAG; use codex_protocol::protocol::COLLABORATION_MODE_OPEN_TAG; use serde::Deserialize; @@ -23,17 +23,17 @@ pub(crate) struct CollaborationModeState { impl CollaborationModeState { pub(crate) fn from_collaboration_mode( collaboration_mode: &CollaborationMode, - catalog_messages: Option<&CollaborationModeMessages>, + messages: ResolvedCollaborationModeMessages<'_>, update_plan_enabled: bool, custom_model_catalog: bool, ) -> Self { - let catalog_instructions = - catalog_messages.and_then(|messages| match collaboration_mode.mode { - ModeKind::Default => messages.default.as_ref(), - ModeKind::Plan => messages.plan.as_ref(), - }); + let catalog_instructions = match collaboration_mode.mode { + ModeKind::Default => messages.default, + ModeKind::Plan => messages.plan, + } + .catalog_override(); - let instructions = catalog_instructions.cloned().or_else(|| { + let instructions = catalog_instructions.map(str::to_string).or_else(|| { collaboration_mode .settings .developer_instructions @@ -44,17 +44,16 @@ impl CollaborationModeState { if update_plan_enabled { return instructions; } - // Clients send built-in presets through the override field too. Match the - // entire preset, never a heading that could also occur in custom text. let builtin = match catalog_instructions { Some(_) => !custom_model_catalog, - None => builtin_collaboration_mode_presets().iter().any(|preset| { - preset - .developer_instructions - .as_ref() - .and_then(Option::as_ref) - == Some(&instructions) - }), + None => { + // Without a catalog override, mode settings may contain a built-in + // preset or custom text. Compare whole strings against bundled presets + // only to recognize built-in text whose disabled update_plan guidance + // we can strip; custom instructions stay unchanged. + let bundled = ResolvedModelMessages::bundled().collaboration_modes(); + instructions == bundled.default.text() || instructions == bundled.plan.text() + } }; if builtin { without_update_plan_instructions(&instructions) diff --git a/codex-rs/core/src/context/world_state/collaboration_mode_tests.rs b/codex-rs/core/src/context/world_state/collaboration_mode_tests.rs index 7d39d4c0aa..b6a90aa8e8 100644 --- a/codex-rs/core/src/context/world_state/collaboration_mode_tests.rs +++ b/codex-rs/core/src/context/world_state/collaboration_mode_tests.rs @@ -2,10 +2,15 @@ use super::super::PreviousSectionState; use super::super::test_support::render_section_cases; use super::*; use crate::context::world_state::WorldState; +use codex_models_manager::model_info::model_info_from_slug; +use codex_prompts::ResolvedCollaborationModeMessages; +use codex_prompts::ResolvedMessage; +use codex_prompts::ResolvedModelMessages; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::CollaborationModeMessages; +use codex_protocol::openai_models::ModelMessages; use pretty_assertions::assert_eq; #[test] @@ -42,7 +47,7 @@ fn instruction_updates_are_applied_once_in_retained_history() { let mut world_state = WorldState::default(); world_state.add_section(CollaborationModeState::from_collaboration_mode( &collaboration_mode(ModeKind::Default, instructions), - /*catalog_messages*/ None, + ResolvedModelMessages::bundled().collaboration_modes(), /*update_plan_enabled*/ true, /*custom_model_catalog*/ false, )); @@ -74,35 +79,43 @@ fn instruction_updates_are_applied_once_in_retained_history() { #[test] fn catalog_collaboration_messages_select_mode_variant() { - let messages = CollaborationModeMessages { - default: Some("catalog default instructions".to_string()), - plan: Some("catalog plan instructions".to_string()), - }; - - for (mode, expected) in [ - (ModeKind::Default, "catalog default instructions"), - (ModeKind::Plan, "catalog plan instructions"), + let bundled = ResolvedModelMessages::bundled().collaboration_modes(); + for (default, plan) in [ + ("catalog default instructions", "catalog plan instructions"), + (bundled.default.text(), bundled.plan.text()), ] { - let state = CollaborationModeState::from_collaboration_mode( - &collaboration_mode(mode, Some("legacy instructions")), - Some(&messages), - /*update_plan_enabled*/ true, - /*custom_model_catalog*/ false, - ); + let mut model = model_info_from_slug("test-model"); + model.model_messages = Some(ModelMessages { + collaboration_modes: Some(CollaborationModeMessages { + default: Some(default.to_string()), + plan: Some(plan.to_string()), + }), + ..Default::default() + }); + let messages = ResolvedModelMessages::from_model(&model).collaboration_modes(); - assert_eq!(state.instructions.as_deref(), Some(expected)); + for (mode, expected) in [(ModeKind::Default, default), (ModeKind::Plan, plan)] { + let state = CollaborationModeState::from_collaboration_mode( + &collaboration_mode(mode, Some("legacy instructions")), + messages, + /*update_plan_enabled*/ true, + /*custom_model_catalog*/ false, + ); + + assert_eq!(state.instructions.as_deref(), Some(expected)); + } } } #[test] fn empty_catalog_collaboration_message_suppresses_legacy_instructions() { - let messages = CollaborationModeMessages { - default: None, - plan: Some(String::new()), + let messages = ResolvedCollaborationModeMessages { + plan: ResolvedMessage::Catalog(""), + ..ResolvedModelMessages::bundled().collaboration_modes() }; let state = CollaborationModeState::from_collaboration_mode( &collaboration_mode(ModeKind::Plan, Some("legacy plan instructions")), - Some(&messages), + messages, /*update_plan_enabled*/ true, /*custom_model_catalog*/ false, ); @@ -118,21 +131,22 @@ fn empty_catalog_collaboration_message_suppresses_legacy_instructions() { #[test] fn missing_catalog_collaboration_message_uses_legacy_instructions() { - let messages = CollaborationModeMessages { - default: Some("catalog default instructions".to_string()), - plan: None, + let messages = ResolvedCollaborationModeMessages { + default: ResolvedMessage::Catalog("catalog default instructions"), + ..ResolvedModelMessages::bundled().collaboration_modes() }; - let state = CollaborationModeState::from_collaboration_mode( - &collaboration_mode(ModeKind::Plan, Some("legacy plan instructions")), - Some(&messages), - /*update_plan_enabled*/ true, - /*custom_model_catalog*/ false, - ); - - assert_eq!( - state.instructions.as_deref(), - Some("legacy plan instructions") - ); + for instructions in [Some("legacy plan instructions"), Some(""), None] { + let state = CollaborationModeState::from_collaboration_mode( + &collaboration_mode(ModeKind::Plan, instructions), + messages, + /*update_plan_enabled*/ true, + /*custom_model_catalog*/ false, + ); + assert_eq!( + state.instructions.as_deref(), + instructions.filter(|text| !text.is_empty()) + ); + } } #[test] @@ -142,13 +156,13 @@ fn legacy_collaboration_mode_snapshots_refresh_catalog_messages_once() { .expect("legacy collaboration mode snapshot"); for instructions in ["catalog instructions", ""] { - let messages = CollaborationModeMessages { - default: Some(instructions.to_string()), - plan: None, + let messages = ResolvedCollaborationModeMessages { + default: ResolvedMessage::Catalog(instructions), + ..ResolvedModelMessages::bundled().collaboration_modes() }; let state = CollaborationModeState::from_collaboration_mode( &collaboration_mode(ModeKind::Default, Some("stale legacy instructions")), - Some(&messages), + messages, /*update_plan_enabled*/ true, /*custom_model_catalog*/ false, ); @@ -185,7 +199,7 @@ fn collaboration_mode(mode: ModeKind, instructions: Option<&str>) -> Collaborati fn collaboration_mode_state(mode: ModeKind, instructions: &str) -> CollaborationModeState { CollaborationModeState::from_collaboration_mode( &collaboration_mode(mode, Some(instructions)), - /*catalog_messages*/ None, + ResolvedModelMessages::bundled().collaboration_modes(), /*update_plan_enabled*/ true, /*custom_model_catalog*/ false, ) diff --git a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs index fa73781bc3..21fe2f82b0 100644 --- a/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs +++ b/codex-rs/core/src/context/world_state/multi_agent_mode_tests.rs @@ -2,9 +2,9 @@ use super::super::test_support::render_section_cases; use super::*; use crate::context::MultiAgentRoleInstructions; use crate::context::world_state::WorldState; -use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_utils_output_truncation::approx_token_count; +use pretty_assertions::assert_eq; fn state(mode: Option) -> MultiAgentModeState { MultiAgentModeState::new(mode) @@ -72,8 +72,8 @@ fn persisted_mode_is_restored_only_when_missing_from_history() { fn unchanged_mode_is_reemitted_after_usage_hint_migration() { let previous = state(Some(MultiAgentMode::Proactive)); let current = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint( - &MultiAgentUsageHintState::new(MultiAgentRoleInstructions::unmarked( - "Current usage instructions.", + &MultiAgentUsageHintState::new(MultiAgentRoleInstructions::Configured( + "Current usage instructions.".to_string(), )), ); @@ -91,15 +91,22 @@ fn unchanged_mode_is_reemitted_after_usage_hint_migration() { #[test] fn catalog_role_updates_remain_separate_from_active_mode() { - let previous_hint = - MultiAgentUsageHintState::new(MultiAgentRoleInstructions::catalog("Previous role.")); + let catalog_role = |base: &str| MultiAgentRoleInstructions::Composed { + base: base.to_string(), + marked: true, + omit_update_plan_instructions: false, + max_concurrency: 2, + wait_agent_enabled: false, + expose_model_overrides: false, + }; + let previous_hint = MultiAgentUsageHintState::new(catalog_role("Previous role.")); let previous_mode = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint(&previous_hint); let mut previous = WorldState::default(); previous.add_section(previous_hint); previous.add_section(previous_mode); - let current_role = MultiAgentRoleInstructions::catalog("Current role."); + let current_role = catalog_role("Current role."); let current_hint = MultiAgentUsageHintState::new(current_role.clone()); let current_mode = MultiAgentModeState::new(Some(MultiAgentMode::Proactive)).with_usage_hint(¤t_hint); @@ -113,20 +120,10 @@ fn catalog_role_updates_remain_separate_from_active_mode() { let expected_mode = MultiAgentModeInstructions::from_mode(MultiAgentMode::Proactive) .expect("proactive mode should render"); assert_eq!( - updates - .into_iter() - .map(|item| match item { - ResponseItem::Message { content, .. } => content, - _ => panic!("expected world-state message"), - }) - .collect::>(), + updates, vec![ - vec![ContentItem::InputText { - text: current_role.render(), - }], - vec![ContentItem::InputText { - text: expected_mode.render(), - }], + ContextualUserFragment::into(current_role), + ContextualUserFragment::into(expected_mode), ], ); } diff --git a/codex-rs/core/src/context/world_state/permissions.rs b/codex-rs/core/src/context/world_state/permissions.rs index 00a690fc60..635a9fea99 100644 --- a/codex-rs/core/src/context/world_state/permissions.rs +++ b/codex-rs/core/src/context/world_state/permissions.rs @@ -1,11 +1,13 @@ +//! Tracks model-visible permission instructions and approved-command prefix changes. + use super::PreviousSectionState; use super::WorldStateHash; use super::WorldStateSection; -use crate::context::ApprovalPromptContext; use crate::context::ApprovedCommandPrefixSaved; use crate::context::ContextualUserFragment; use crate::context::PermissionsInstructions; use codex_execpolicy::Policy; +use codex_prompts::ApprovalPromptContext; use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; use codex_protocol::protocol::AskForApproval; diff --git a/codex-rs/core/src/context/world_state/permissions_tests.rs b/codex-rs/core/src/context/world_state/permissions_tests.rs index 3f76ed934d..978a5da3a7 100644 --- a/codex-rs/core/src/context/world_state/permissions_tests.rs +++ b/codex-rs/core/src/context/world_state/permissions_tests.rs @@ -1,11 +1,14 @@ use super::*; use crate::context::world_state::test_support::render_section_cases; use codex_execpolicy::Decision; +use codex_models_manager::model_info::model_info_from_slug; +use codex_prompts::ResolvedModelMessages; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::models::ContentItem; use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ApprovalMessages; +use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::PermissionMessages; use codex_protocol::protocol::AskForApproval; use pretty_assertions::assert_eq; @@ -55,8 +58,8 @@ fn approved_prefix_is_rendered_without_reinjecting_permissions() { assert_ne!(without_snapshot, with_snapshot); assert_ne!( - without_approved_prefix.instructions, - with_approved_prefix.instructions + without_approved_prefix.instructions.body(), + with_approved_prefix.instructions.body() ); assert!( !without_approved_prefix @@ -221,14 +224,17 @@ fn permissions_state( workspace_write: Some("Workspace write.".to_string()), read_only: Some("Read only.".to_string()), }; + let mut model = model_info_from_slug("test-model"); + model.model_messages = Some(ModelMessages { + approvals: Some(approval_messages), + permissions: Some(permission_messages), + ..Default::default() + }); + let model_messages = ResolvedModelMessages::from_model(&model); PermissionsState::new( &permission_profile, approval_policy, - ApprovalPromptContext::new( - ApprovalsReviewer::User, - Some(&approval_messages), - Some(&permission_messages), - ), + ApprovalPromptContext::new(ApprovalsReviewer::User, model_messages), &Policy::empty(), Path::new("/workspace"), /*exec_permission_approvals_enabled*/ false, @@ -237,14 +243,11 @@ fn permissions_state( } fn permissions_state_with_default_messages(exec_policy: &Policy) -> PermissionsState { + let model_messages = ResolvedModelMessages::bundled(); PermissionsState::new( &PermissionProfile::read_only(), AskForApproval::OnRequest, - ApprovalPromptContext::new( - ApprovalsReviewer::User, - /*messages*/ None, - /*permission_messages*/ None, - ), + ApprovalPromptContext::new(ApprovalsReviewer::User, model_messages), exec_policy, Path::new("/workspace"), /*exec_permission_approvals_enabled*/ false, diff --git a/codex-rs/core/src/context/world_state/persistent_mode.rs b/codex-rs/core/src/context/world_state/persistent_mode.rs index 1257053415..96f0660abe 100644 --- a/codex-rs/core/src/context/world_state/persistent_mode.rs +++ b/codex-rs/core/src/context/world_state/persistent_mode.rs @@ -1,5 +1,5 @@ //! Keeps persistent-mode developer instructions current without repeating unchanged context. -//! Mode changes retire prior instructions; missing catalog values use the bundled default. +//! Mode changes retire prior instructions; callers provide the resolved instruction template. use super::PreviousSectionState; use super::WorldStateHash; @@ -10,7 +10,6 @@ use codex_protocol::openai_models::ReasoningEffort; use serde::Deserialize; use serde::Serialize; -const DEFAULT_INSTRUCTIONS: &str = include_str!("../../../assets/persistent_mode.md"); const REPLACEMENT_NOTICE: &str = "These persistent-mode instructions replace all previously provided persistent-mode instructions."; const REMOVAL_NOTICE: &str = "The previously provided persistent-mode instructions no longer apply."; @@ -51,21 +50,18 @@ pub(crate) struct PersistentModeSnapshot { impl PersistentModeState { pub(crate) fn new( reasoning_effort: Option<&ReasoningEffort>, - catalog_instructions: Option<&str>, + instructions_template: &str, send_user_message_async_available: bool, ) -> Self { let instructions = if reasoning_effort == Some(&ReasoningEffort::Persistent) { - catalog_instructions - .unwrap_or(DEFAULT_INSTRUCTIONS) - .trim() - .replace( - "{{ approval_request_channel }}", - if send_user_message_async_available { - " via functions.send_user_message_async" - } else { - "" - }, - ) + instructions_template.trim().replace( + "{{ approval_request_channel }}", + if send_user_message_async_available { + " via functions.send_user_message_async" + } else { + "" + }, + ) } else { String::new() }; diff --git a/codex-rs/core/src/context/world_state/persistent_mode_tests.rs b/codex-rs/core/src/context/world_state/persistent_mode_tests.rs index 59a6c98470..13fee9aa66 100644 --- a/codex-rs/core/src/context/world_state/persistent_mode_tests.rs +++ b/codex-rs/core/src/context/world_state/persistent_mode_tests.rs @@ -1,4 +1,4 @@ -//! Covers effort selection, catalog overrides, and persistent-context transitions. +//! Covers effort selection and persistent-context transitions. use super::*; use crate::context::world_state::WorldState; @@ -13,23 +13,19 @@ fn persistent_instructions_follow_effort_and_catalog_updates_without_duplicates( let replacement = format!("{REPLACEMENT_NOTICE}\n\nupdated instructions"); for (effort, instructions, expected) in [ - (None, None, None), + (None, "", None), + (persistent.clone(), "instructions", Some("instructions")), + (persistent.clone(), "instructions", None), ( persistent.clone(), - Some("instructions"), - Some("instructions"), - ), - (persistent.clone(), Some("instructions"), None), - ( - persistent.clone(), - Some("updated instructions"), + "updated instructions", Some(replacement.as_str()), ), - (persistent.clone(), Some(""), Some(REMOVAL_NOTICE)), - (persistent.clone(), Some(""), None), - (persistent, Some("instructions"), Some("instructions")), - (medium.clone(), None, Some(REMOVAL_NOTICE)), - (medium, None, None), + (persistent.clone(), "", Some(REMOVAL_NOTICE)), + (persistent.clone(), "", None), + (persistent, "instructions", Some("instructions")), + (medium.clone(), "", Some(REMOVAL_NOTICE)), + (medium, "", None), ] { let mut world_state = WorldState::default(); world_state.add_section(PersistentModeState::new( @@ -73,7 +69,7 @@ fn retained_persistent_instructions_are_replaced_or_retired_without_a_snapshot() let mut world_state = WorldState::default(); world_state.add_section(PersistentModeState::new( Some(&effort), - Some("current instructions"), + "current instructions", /*send_user_message_async_available*/ false, )); assert_eq!( diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index a0946e9b57..883731f67c 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -37,6 +37,7 @@ use codex_history::RetainedContext; use codex_history::RetainedContextEntry; use codex_history::RetainedContextEvent; use codex_history::RetainedInputSource; +use codex_prompts::render_model_instructions; use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::items::TurnItem; use codex_protocol::models::AgentMessageInputContent; @@ -504,11 +505,8 @@ impl ContextManager { // This is a coarse lower bound, not a tokenizer-accurate count. pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option { let model_info = &turn_context.model_info(); - let personality = turn_context - .personality() - .or(turn_context.config.personality); let base_instructions = BaseInstructions { - text: model_info.get_model_instructions(personality), + text: render_model_instructions(model_info), provenance: None, }; self.estimate_token_count_with_base_instructions(&base_instructions) diff --git a/codex-rs/core/src/guardian/mod.rs b/codex-rs/core/src/guardian/mod.rs index 9c7c77ed5c..afd2ee70af 100644 --- a/codex-rs/core/src/guardian/mod.rs +++ b/codex-rs/core/src/guardian/mod.rs @@ -45,11 +45,8 @@ pub(crate) use approval_request::GuardianMcpAnnotations; pub(crate) use approval_request::GuardianNetworkAccessTrigger; #[cfg(test)] pub(crate) use approval_request::guardian_approval_request_to_json; -pub(crate) use codex_guardian_reviewer::guardian_timeout_message; pub(crate) use decision::decide_approval; pub(crate) use decision::spawn_approval_decision; -pub(crate) use prompt::BUNDLED_GUARDIAN_POLICY; -pub(crate) use prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE; pub(crate) use prompt::guardian_truncate_text; pub(crate) use review::GuardianReviewOptions; pub(crate) use review::is_basic_session_source; diff --git a/codex-rs/core/src/guardian/prompt.rs b/codex-rs/core/src/guardian/prompt.rs index 52e035e76f..f1e734d542 100644 --- a/codex-rs/core/src/guardian/prompt.rs +++ b/codex-rs/core/src/guardian/prompt.rs @@ -324,31 +324,3 @@ pub(crate) fn guardian_truncate_text(content: &str, token_cap: usize) -> (String content.len() > approx_bytes_for_tokens(token_cap), ) } - -use codex_guardian_reviewer::guardian_output_contract_prompt; - -pub(crate) const BUNDLED_GUARDIAN_POLICY: &str = include_str!("../../assets/guardian/policy.md"); -pub(crate) const BUNDLED_GUARDIAN_POLICY_TEMPLATE: &str = - include_str!("../../assets/guardian/policy_template.md"); -const TENANT_POLICY_CONFIG_PLACEHOLDER: &str = "{{ tenant_policy_config }}"; - -/// Guardian policy prompt. -/// -/// Keep the bundled fallback in a dedicated markdown file so reviewers can -/// audit prompt changes directly without diffing through code. The output -/// contract is appended from code so it stays near `guardian_output_schema()`. -/// -/// The template is intentionally separated from the default tenant policy -/// configuration so workspace-managed overrides can keep the configurable -/// section narrower than the full policy. -pub(super) fn guardian_policy_prompt_with_config_and_template( - tenant_policy_config: &str, - policy_template: &str, -) -> String { - let template = policy_template.trim_end(); - let prompt = template.replace( - TENANT_POLICY_CONFIG_PLACEHOLDER, - tenant_policy_config.trim(), - ); - format!("{prompt}\n\n{}\n", guardian_output_contract_prompt()) -} diff --git a/codex-rs/core/src/guardian/review.rs b/codex-rs/core/src/guardian/review.rs index e20329a2d1..56ae527025 100644 --- a/codex-rs/core/src/guardian/review.rs +++ b/codex-rs/core/src/guardian/review.rs @@ -14,6 +14,7 @@ use codex_guardian_reviewer::GuardianReviewOutcome; #[cfg(test)] use codex_guardian_reviewer::GuardianReviewSessionLimits; use codex_guardian_reviewer::ReviewModel; +use codex_prompts::ResolvedModelMessages; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::InternalSessionSource; use codex_protocol::protocol::ReviewDecision; @@ -141,6 +142,7 @@ pub(super) async fn guardian_review_session_config( .thread_extension_data .get::>() .ok_or_else(|| anyhow::anyhow!("Guardian reviewer configuration is not installed"))?; + let model_messages = ResolvedModelMessages::from_model(&guardian_model_info); let mut spawn_config = build_guardian_review_session_config( (reviewer_config.0)(turn.config.as_ref())?, live_network_config, @@ -148,7 +150,7 @@ pub(super) async fn guardian_review_session_config( review_model.reasoning_effort.clone(), context.reasoning_summary, context.personality, - guardian_model_info.model_messages.as_ref(), + model_messages, )?; if context.model_info.computer_use_review_required() { spawn_config @@ -167,9 +169,7 @@ pub(super) async fn guardian_review_session_config( Ok(GuardianReviewSessionConfig { spawn_config, compaction_model_hash: guardian_model_info.comp_hash.clone(), - node_repl_policy: GuardianNodeReplPolicy::from_model_messages( - guardian_model_info.model_messages.as_ref(), - ), + node_repl_policy: GuardianNodeReplPolicy::from_messages(model_messages), review_model, }) } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index cfb6b1aa47..fa7c59eb66 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -90,8 +90,6 @@ use super::GUARDIAN_REVIEWER_NAME; use super::GuardianApprovalRequest; use super::GuardianReviewContext; use super::feedback::record_failed_review; -#[cfg(test)] -use super::prompt::BUNDLED_GUARDIAN_POLICY; use super::prompt::GUARDIAN_TRANSCRIPT_START; use super::prompt::GuardianPromptMode; #[cfg(test)] diff --git a/codex-rs/core/src/guardian/review_session_tests.rs b/codex-rs/core/src/guardian/review_session_tests.rs index b981a8677b..255adf4276 100644 --- a/codex-rs/core/src/guardian/review_session_tests.rs +++ b/codex-rs/core/src/guardian/review_session_tests.rs @@ -1,11 +1,14 @@ -use super::super::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE; -use super::super::prompt::guardian_policy_prompt_with_config_and_template; use super::*; use crate::agents_md_manager::AgentsMdManager; +use crate::context::ContextualUserFragment; use crate::context_manager::ContextManager; use codex_guardian_reviewer::ReviewerRequest; +use codex_guardian_reviewer::guardian_output_contract_prompt; use codex_history::CodexHarnessMetadata; use codex_history::ResponseItemEnvelope; +use codex_models_manager::model_info::model_info_from_slug; +use codex_prompts::GuardianPolicyInstructions; +use codex_prompts::ResolvedModelMessages; use codex_protocol::openai_models::AutoReviewMessages; use codex_protocol::openai_models::ModelMessages; use codex_protocol::protocol::AgentStatus; @@ -14,6 +17,7 @@ use codex_protocol::protocol::Submission; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnAbortedEvent; use codex_protocol::protocol::TurnCompleteEvent; +use pretty_assertions::assert_eq; #[tokio::test] async fn run_review_preserves_evidence_during_parent_compaction() { @@ -40,7 +44,7 @@ async fn run_review_preserves_evidence_during_parent_compaction() { params.review_model.reasoning_effort.clone(), params.reasoning_summary, params.personality, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .unwrap(); params.parent_session = Arc::clone(&parent); @@ -216,7 +220,7 @@ async fn test_review_params() -> GuardianReviewSessionParams { reasoning_effort.clone(), reasoning_summary, personality, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -225,7 +229,7 @@ async fn test_review_params() -> GuardianReviewSessionParams { parent_session: Arc::new(session), parent_context: GuardianReviewContext::from(Arc::new(turn)), spawn_config, - node_repl_policy: GuardianNodeReplPolicy::from_model_messages(/*messages*/ None), + node_repl_policy: GuardianNodeReplPolicy::from_messages(ResolvedModelMessages::bundled()), request: GuardianApprovalRequest::ExecCommand { id: "shell-1".to_string(), environment_id: codex_exec_server::LOCAL_ENVIRONMENT_ID.to_string(), @@ -316,7 +320,7 @@ async fn guardian_review_session_config_change_invalidates_cached_session() { /*reasoning_effort*/ None, ReasoningSummaryConfig::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("cached guardian config"); let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( @@ -337,7 +341,7 @@ async fn guardian_review_session_config_change_invalidates_cached_session() { /*reasoning_effort*/ None, ReasoningSummaryConfig::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("next guardian config"); let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( @@ -391,17 +395,22 @@ async fn guardian_review_session_config_change_invalidates_cached_session() { .with_node_repl_policy_eligibility(/*required*/ true), "switching parent-model Node REPL eligibility must invalidate reviewer history" ); - let messages = serde_json::from_value(serde_json::json!({ - "auto_review": { "node_repl_policy": "Catalog REPL policy." } - })) - .expect("catalog model messages"); + let mut model = model_info_from_slug("active-model"); + model.model_messages = Some( + serde_json::from_value(serde_json::json!({ + "auto_review": { "node_repl_policy": "Catalog REPL policy." } + })) + .expect("catalog model messages"), + ); assert_ne!( - cached_reuse_key.clone().with_node_repl_policy( - &GuardianNodeReplPolicy::from_model_messages(/*messages*/ None), - ), - cached_reuse_key.with_node_repl_policy(&GuardianNodeReplPolicy::from_model_messages(Some( - &messages - )),), + cached_reuse_key + .clone() + .with_node_repl_policy(&GuardianNodeReplPolicy::from_messages( + ResolvedModelMessages::bundled() + ),), + cached_reuse_key.with_node_repl_policy(&GuardianNodeReplPolicy::from_messages( + ResolvedModelMessages::from_model(&model) + ),), "changing the effective Node REPL policy must invalidate reviewer history" ); @@ -527,7 +536,7 @@ async fn guardian_review_session_compact_scope_change_invalidates_cached_session /*reasoning_effort*/ None, ReasoningSummaryConfig::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("cached guardian config"); let cached_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( @@ -548,7 +557,7 @@ async fn guardian_review_session_compact_scope_change_invalidates_cached_session /*reasoning_effort*/ None, ReasoningSummaryConfig::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("next guardian config"); let next_reuse_key = GuardianReviewSessionReuseKey::from_spawn_config( @@ -576,7 +585,7 @@ async fn guardian_review_session_config_disables_hooks() { /*reasoning_effort*/ None, ReasoningSummaryConfig::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -595,137 +604,72 @@ async fn guardian_review_session_config_disables_skill_instructions() { /*reasoning_effort*/ None, ReasoningSummaryConfig::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); assert!(!guardian_config.include_skill_instructions); } +#[test_case::test_case( + Some("Use the managed Guardian policy."), + Some("Configured Guardian template:\n{{ tenant_policy_config }}"), + "Use the catalog Guardian policy.", + Some("Catalog Guardian template:\n{{ tenant_policy_config }}"), + "Use the managed Guardian policy.", + "Configured Guardian template:\n{{ tenant_policy_config }}"; + "configured_policy_and_template" +)] +#[test_case::test_case( + Some("Use the managed Guardian policy."), + None, + "Use the catalog Guardian policy.", + Some("Catalog Guardian template:\n{{ tenant_policy_config }}"), + "Use the managed Guardian policy.", + "Catalog Guardian template:\n{{ tenant_policy_config }}"; + "managed_policy_and_catalog_template" +)] +#[test_case::test_case( + None, + None, + "", + None, + "", + ResolvedModelMessages::bundled().auto_review().policy_template; + "explicit_empty_catalog_policy" +)] +#[test_case::test_case( + None, + None, + "Use the catalog Guardian policy.", + Some(""), + "Use the catalog Guardian policy.", + ""; + "explicit_empty_catalog_template" +)] #[tokio::test] -async fn guardian_review_session_config_prefers_configured_policy_and_template() { +async fn guardian_review_session_config_resolves_policy_and_template( + managed_policy: Option<&str>, + configured_template: Option<&str>, + catalog_policy: &str, + catalog_template: Option<&str>, + expected_policy: &str, + expected_template: &str, +) { let mut parent_config = crate::config::test_config().await; - let managed_policy = "Use the managed Guardian policy."; - let configured_template = "Configured Guardian template:\n{{ tenant_policy_config }}"; - let catalog_template = "Catalog Guardian template:\n{{ tenant_policy_config }}"; - parent_config.guardian_policy_config = Some(managed_policy.to_string()); - parent_config.guardian_policy_template = Some(configured_template.to_string()); - let model_messages = ModelMessages { - persistent_instructions: None, - tools: None, - instructions_template: None, - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: Some(AutoReviewMessages { - policy: Some("Use the catalog Guardian policy.".to_string()), - policy_template: Some(catalog_template.to_string()), - node_repl_policy: None, - rejection_instructions: None, - timeout_instructions: None, - }), - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, - }; - - let guardian_config = build_guardian_review_session_config( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummaryConfig::default(), - /*personality*/ None, - Some(&model_messages), - ) - .expect("guardian config"); - - assert_eq!( - guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - managed_policy, - configured_template, - )) - ); -} - -#[tokio::test] -async fn guardian_review_session_config_preserves_explicit_empty_catalog_policy() { - let parent_config = crate::config::test_config().await; - let model_messages = ModelMessages { - persistent_instructions: None, - tools: None, - instructions_template: None, - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: Some(AutoReviewMessages { - policy: Some(String::new()), - policy_template: None, - node_repl_policy: None, - rejection_instructions: None, - timeout_instructions: None, - }), - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, - }; - - let guardian_config = build_guardian_review_session_config( - crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), - /*live_network_config*/ None, - "active-model", - /*reasoning_effort*/ None, - ReasoningSummaryConfig::default(), - /*personality*/ None, - Some(&model_messages), - ) - .expect("guardian config"); - - assert_eq!( - guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - "", - BUNDLED_GUARDIAN_POLICY_TEMPLATE, - )) - ); - assert_ne!( - guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - BUNDLED_GUARDIAN_POLICY, - BUNDLED_GUARDIAN_POLICY_TEMPLATE, - )) - ); -} - -#[tokio::test] -async fn guardian_review_session_config_preserves_explicit_empty_catalog_template() { - let parent_config = crate::config::test_config().await; - let catalog_policy = "Use the catalog Guardian policy."; - let model_messages = ModelMessages { - persistent_instructions: None, - tools: None, - instructions_template: None, - instructions_variables: None, - approvals: None, - collaboration_modes: None, + parent_config.guardian_policy_config = managed_policy.map(str::to_owned); + parent_config.guardian_policy_template = configured_template.map(str::to_owned); + let mut model = model_info_from_slug("active-model"); + model.model_messages = Some(ModelMessages { auto_review: Some(AutoReviewMessages { policy: Some(catalog_policy.to_string()), - policy_template: Some(String::new()), + policy_template: catalog_template.map(str::to_owned), node_repl_policy: None, rejection_instructions: None, timeout_instructions: None, }), - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, - }; + ..Default::default() + }); let guardian_config = build_guardian_review_session_config( crate::guardian::test_host::build_reviewer_config(&parent_config).expect("reviewer config"), @@ -734,23 +678,20 @@ async fn guardian_review_session_config_preserves_explicit_empty_catalog_templat /*reasoning_effort*/ None, ReasoningSummaryConfig::default(), /*personality*/ None, - Some(&model_messages), + ResolvedModelMessages::from_model(&model), ) .expect("guardian config"); assert_eq!( guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - catalog_policy, - "", - )) - ); - assert_ne!( - guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - catalog_policy, - BUNDLED_GUARDIAN_POLICY_TEMPLATE, - )) + Some( + GuardianPolicyInstructions::new( + expected_policy, + expected_template, + guardian_output_contract_prompt(), + ) + .render() + ) ); } diff --git a/codex-rs/core/src/guardian/reviewer_config.rs b/codex-rs/core/src/guardian/reviewer_config.rs index a8378f619a..bd3a2f6288 100644 --- a/codex-rs/core/src/guardian/reviewer_config.rs +++ b/codex-rs/core/src/guardian/reviewer_config.rs @@ -3,14 +3,14 @@ use std::sync::Arc; +use codex_guardian_reviewer::guardian_output_contract_prompt; +use codex_prompts::GuardianPolicyInstructions; +use codex_prompts::ResolvedModelMessages; use codex_protocol::models::BaseInstructionsProvenance; -use codex_protocol::openai_models::ModelMessages; use crate::config::Config; use crate::config::NetworkProxySpec; - -use super::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE; -use super::prompt::guardian_policy_prompt_with_config_and_template; +use crate::context::ContextualUserFragment; /// Adds the captured model, policy prompt and live network rules before reuse selection. pub fn build_guardian_review_session_config( @@ -20,23 +20,26 @@ pub fn build_guardian_review_session_config( reasoning_effort: Option, reasoning_summary: codex_protocol::config_types::ReasoningSummary, personality: Option, - model_messages: Option<&ModelMessages>, + model_messages: ResolvedModelMessages<'_>, ) -> anyhow::Result { guardian_config.model = Some(active_model.to_owned()); guardian_config.model_reasoning_effort = reasoning_effort; guardian_config.model_reasoning_summary = Some(reasoning_summary); guardian_config.personality = personality; - let catalog_auto_review = model_messages.and_then(|messages| messages.auto_review.as_ref()); + let auto_review = model_messages.auto_review(); let tenant_policy_config = guardian_config.resolve_guardian_policy(model_messages); let policy_template = guardian_config .guardian_policy_template .as_deref() - .or_else(|| catalog_auto_review.and_then(|messages| messages.policy_template.as_deref())) - .unwrap_or(BUNDLED_GUARDIAN_POLICY_TEMPLATE); - guardian_config.base_instructions = Some(guardian_policy_prompt_with_config_and_template( - tenant_policy_config, - policy_template, - )); + .unwrap_or(auto_review.policy_template); + guardian_config.base_instructions = Some( + GuardianPolicyInstructions::new( + tenant_policy_config, + policy_template, + guardian_output_contract_prompt(), + ) + .render(), + ); guardian_config.base_instructions_provenance = Some(BaseInstructionsProvenance::Custom); if let Some(live_network_config) = live_network_config && guardian_config.permissions.network.is_some() diff --git a/codex-rs/core/src/guardian/tests.rs b/codex-rs/core/src/guardian/tests.rs index 8587c4ce2d..647b9d6173 100644 --- a/codex-rs/core/src/guardian/tests.rs +++ b/codex-rs/core/src/guardian/tests.rs @@ -6,11 +6,9 @@ use crate::config::ManagedFeatures; use crate::config::NetworkProxySpec; use crate::config::PermissionProfileSnapshot; use crate::config::test_config; +use crate::context::ContextualUserFragment; use crate::environment_selection::TurnEnvironmentState; use crate::guardian::approval_request::guardian_request_target_item_id; -use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY; -use crate::guardian::prompt::BUNDLED_GUARDIAN_POLICY_TEMPLATE; -use crate::guardian::prompt::guardian_policy_prompt_with_config_and_template; use crate::guardian::review::guardian_review_session_config; use crate::session::session::Session; use crate::session::tests::update_turn_settings_for_test; @@ -30,6 +28,7 @@ use codex_exec_server::LOCAL_FS; use codex_features::Feature; use codex_guardian_context::ConversationTranscriptEntry; use codex_guardian_context::ConversationTranscriptEntryKind; +use codex_guardian_reviewer::guardian_output_contract_prompt; use codex_history::RolloutItem; use codex_model_provider::create_model_provider; use codex_model_provider_info::AMAZON_BEDROCK_GPT_5_4_MODEL_ID; @@ -38,6 +37,8 @@ use codex_model_provider_info::ModelProviderInfo; use codex_model_provider_info::OPENAI_PROVIDER_ID; use codex_models_manager::manager::StaticModelsManager; use codex_network_proxy::NetworkProxyConfig; +use codex_prompts::GuardianPolicyInstructions; +use codex_prompts::ResolvedModelMessages; use codex_protocol::ThreadId; use codex_protocol::approvals::GuardianAssessmentAction; use codex_protocol::approvals::NetworkApprovalProtocol; @@ -1556,32 +1557,6 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning( assert!(warnings.is_empty()); } -#[test] -fn guardian_timeout_message_distinguishes_timeout_from_policy_denial() { - let mut model = codex_models_manager::model_info::model_info_from_slug("acting-model"); - model.model_messages = None; - let message = guardian_timeout_message(&model); - assert!(message.contains("did not finish before its deadline")); - assert!(message.contains("retry once")); - assert!(!message.contains("unacceptable risk")); - - for timeout_instructions in [None, Some("Catalog timeout instructions."), Some("")] { - model.model_messages = Some( - serde_json::from_value(serde_json::json!({ - "auto_review": { - "policy": "review policy", - "timeout_instructions": timeout_instructions, - }, - })) - .expect("model messages should deserialize"), - ); - assert_eq!( - guardian_timeout_message(&model), - timeout_instructions.unwrap_or(&message), - ); - } -} - #[tokio::test] async fn routes_approval_to_guardian_requires_guardian_reviewer() { let (_session, mut turn) = crate::session::tests::make_session_and_context().await; @@ -1835,22 +1810,17 @@ async fn guardian_reuse_respects_effective_policy_and_personality( } let requests = responses.requests(); assert_eq!(requests.len(), 3); - let initial_policy = configured_policy.unwrap_or("captured policy"); - assert_eq!( - requests[0].instructions_text(), - guardian_policy_prompt_with_config_and_template( - initial_policy, - "captured template: {{ tenant_policy_config }}" - ) - ); - let changed_policy = configured_policy.unwrap_or("changed action policy"); - assert_eq!( - requests[1].instructions_text(), - guardian_policy_prompt_with_config_and_template( - changed_policy, - "captured template: {{ tenant_policy_config }}" - ) - ); + let contract = guardian_output_contract_prompt(); + for (request, policy) in requests + .iter() + .zip(["captured policy", "changed action policy"]) + { + let policy = configured_policy.unwrap_or(policy); + assert_eq!( + request.instructions_text(), + format!("captured template: {policy}\n\n{contract}\n") + ); + } let thread_ids = requests .iter() .map(|request| request.body_json()["client_metadata"]["thread_id"].clone()) @@ -3752,7 +3722,7 @@ async fn guardian_review_session_config_preserves_parent_network_proxy() { Some(codex_protocol::openai_models::ReasoningEffort::Low), ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -3842,6 +3812,7 @@ async fn guardian_review_session_config_preserves_context_overrides_for_same_eff #[tokio::test] async fn guardian_review_session_config_clears_parent_developer_instructions() { + let defaults = ResolvedModelMessages::bundled().auto_review(); let mut parent_config = test_config().await; parent_config.developer_instructions = Some("parent or managed config should not replace guardian policy".to_string()); @@ -3853,17 +3824,21 @@ async fn guardian_review_session_config_clears_parent_developer_instructions() { /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); assert_eq!(guardian_config.developer_instructions, None); assert_eq!( guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - BUNDLED_GUARDIAN_POLICY, - BUNDLED_GUARDIAN_POLICY_TEMPLATE, - )) + Some( + GuardianPolicyInstructions::new( + defaults.policy, + defaults.policy_template, + guardian_output_contract_prompt(), + ) + .render() + ) ); } @@ -3882,7 +3857,7 @@ async fn guardian_review_session_config_clears_legacy_notify() { /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -3919,7 +3894,7 @@ async fn guardian_review_session_config_uses_live_network_proxy_state() { /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -3968,7 +3943,7 @@ async fn guardian_review_session_config_disables_mcp_apps_plugins_memories_and_g /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -4002,7 +3977,7 @@ async fn guardian_review_session_config_allows_pinned_disabled_feature() { /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config should continue when a disabled feature is pinned on"); @@ -4023,7 +3998,7 @@ async fn guardian_review_session_config_uses_parent_active_model_instead_of_hard /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -4044,7 +4019,7 @@ async fn guardian_review_session_config_keeps_bedrock_provider_for_bedrock_gpt_5 Some(ReasoningEffort::Low), ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); @@ -4101,23 +4076,30 @@ async fn guardian_review_session_config_uses_requirements_guardian_policy_config /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); assert_eq!(guardian_config.developer_instructions, None); assert_eq!( guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - "Use the workspace-managed guardian policy.", - BUNDLED_GUARDIAN_POLICY_TEMPLATE, - )) + Some( + GuardianPolicyInstructions::new( + "Use the workspace-managed guardian policy.", + ResolvedModelMessages::bundled() + .auto_review() + .policy_template, + guardian_output_contract_prompt(), + ) + .render() + ) ); } #[tokio::test] async fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override() { + let defaults = ResolvedModelMessages::bundled().auto_review(); let codex_home = tempfile::tempdir().expect("create temp dir"); let workspace = tempfile::tempdir().expect("create temp dir"); let config_layer_stack = @@ -4143,17 +4125,21 @@ async fn guardian_review_session_config_uses_default_guardian_policy_without_req /*reasoning_effort*/ None, ReasoningSummary::default(), /*personality*/ None, - /*model_messages*/ None, + ResolvedModelMessages::bundled(), ) .expect("guardian config"); assert_eq!(guardian_config.developer_instructions, None); assert_eq!( guardian_config.base_instructions, - Some(guardian_policy_prompt_with_config_and_template( - BUNDLED_GUARDIAN_POLICY, - BUNDLED_GUARDIAN_POLICY_TEMPLATE, - )) + Some( + GuardianPolicyInstructions::new( + defaults.policy, + defaults.policy_template, + guardian_output_contract_prompt(), + ) + .render() + ) ); } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 4d031adfba..2e6c191852 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -45,6 +45,7 @@ use codex_mcp::auth_elicitation_completed_result; use codex_mcp::build_auth_elicitation_plan; use codex_mcp::is_connector_auth_failure_from_tool_result; use codex_mcp::mcp_permission_prompt_is_auto_approved; +use codex_prompts::ResolvedModelMessages; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::items::McpAppDisplayMode; use codex_protocol::items::McpAppUi; @@ -330,7 +331,10 @@ pub(crate) async fn handle_mcp_tool_call( &call_id, invocation, item_metadata.clone(), - crate::guardian::guardian_timeout_message(turn_context.model_info()), + ResolvedModelMessages::from_model(turn_context.model_info()) + .auto_review() + .timeout_instructions + .to_string(), /*already_started*/ true, ) .await @@ -1355,12 +1359,8 @@ fn build_confirmation_policies_request_meta( } let mut policies = serde_json::Map::new(); - if let Some(confirmation_policies) = step_context - .settings - .model_info - .model_messages - .as_ref() - .and_then(|messages| messages.confirmation_policies.as_ref()) + if let Some(confirmation_policies) = + ResolvedModelMessages::from_model(&step_context.settings.model_info).confirmation_policies() { for (name, policy) in [ ("browser_use", confirmation_policies.browser_use.as_ref()), diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index 18a8d083c7..70656904ab 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -10,6 +10,7 @@ use codex_mcp::ElicitationReviewRequest; use codex_mcp::ElicitationReviewer; use codex_mcp::ElicitationReviewerHandle; use codex_mcp::MCP_TOOL_CODEX_APPS_META_KEY; +use codex_prompts::ResolvedModelMessages; use codex_protocol::capabilities::CapabilityRootLocation; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::ApprovalsReviewer; @@ -1159,7 +1160,10 @@ fn mcp_elicitation_response_from_guardian_decision( }, ReviewDecision::Denied { rejection } => mcp_elicitation_decline_with_message(rejection), ReviewDecision::TimedOut => mcp_elicitation_decline_with_message( - crate::guardian::guardian_timeout_message(model_info), + ResolvedModelMessages::from_model(model_info) + .auto_review() + .timeout_instructions + .to_string(), ), ReviewDecision::Abort => ElicitationResponse { action: ElicitationAction::Cancel, diff --git a/codex-rs/core/src/session/mcp_tests.rs b/codex-rs/core/src/session/mcp_tests.rs index 120789bc35..28c099f205 100644 --- a/codex-rs/core/src/session/mcp_tests.rs +++ b/codex-rs/core/src/session/mcp_tests.rs @@ -255,7 +255,7 @@ fn guardian_decisions_map_to_elicitation_responses_without_session_state() { content: None, meta: Some(json!({ "approvals_reviewer": ApprovalsReviewer::AutoReview, - "message": crate::guardian::guardian_timeout_message(&model), + "message": codex_prompts::ResolvedModelMessages::from_model(&model).auto_review().timeout_instructions, })), } ); diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 9252c8952d..9af7af1561 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -94,6 +94,7 @@ use codex_network_proxy::normalize_host; use codex_otel::current_span_trace_id; use codex_otel::current_span_w3c_trace_context; use codex_otel::set_parent_from_w3c_trace_context; +use codex_prompts::render_model_instructions; use codex_protocol::ResponseUsageMetadata; use codex_protocol::SessionId; use codex_protocol::ThreadId; @@ -741,7 +742,7 @@ impl Session { .base_instructions .clone() .or_else(|| conversation_history.get_base_instructions().map(|s| s.text)) - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)); + .unwrap_or_else(|| render_model_instructions(&model_info)); // Dynamic tools are defined at thread start and persisted in rollout session metadata. let dynamic_tools = if dynamic_tools.is_empty() { @@ -1483,7 +1484,7 @@ impl Session { ) { BaseInstructions { - text: crate::context::without_update_plan_instructions(&instructions.text), + text: codex_prompts::without_update_plan_instructions(&instructions.text), ..instructions } } else { @@ -3881,7 +3882,6 @@ impl Session { self.as_ref(), turn_context.as_ref(), &settings.model_info, - settings.model_info.model_messages.as_ref(), &environments, &mcp, &extension_data, diff --git a/codex-rs/core/src/session/multi_agents.rs b/codex-rs/core/src/session/multi_agents.rs index 19d0cb95e5..4f6a594348 100644 --- a/codex-rs/core/src/session/multi_agents.rs +++ b/codex-rs/core/src/session/multi_agents.rs @@ -1,63 +1,15 @@ use crate::config::MultiAgentV2Config; use crate::context::MultiAgentRoleInstructions; use crate::session::step_context::StepContext; +use codex_prompts::ResolvedMessage; +use codex_prompts::ResolvedModelMessages; +use codex_prompts::ResolvedMultiAgentMessages; use codex_protocol::config_types::MultiAgentMode; -use codex_protocol::openai_models::MultiAgentRoleMessages; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -const DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT: &str = r#"You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. - -At the start of your turn, you are the active agent. -You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. -All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. - -You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. -Child agents can also spawn their own sub-agents. -You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. - -You will receive messages in the analysis channel in the form: -``` -Message Type: MESSAGE | FINAL_ANSWER -Task name: -Sender: -Payload: - -``` -They may be addressed as to=/root -"#; -const DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT: &str = r#"You are an agent in a team of agents collaborating to complete a task. - -You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. - -You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. -Child agents can also spawn their own sub-agents. - -When you provide a response in the final channel, that content is immediately delivered back to your parent agent. - -You will receive messages in the analysis channel in the form: -``` -Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER -Task name: -Sender: -Payload: - -``` -You may also see them addressed as to=/root/..., which indicates your identity is /root/... -"#; -const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string."; -const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str = - "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling."; -const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. - -All agents share the same directory. In detail: -- All agents have access to the same container and filesystem as you. -- All agents use the same current working directory. -- As a result, edits made by one agent are immediately visible to all other agents. -"#; - #[derive(Clone, Debug, Default)] pub(crate) struct ResolvedMultiAgentV2UsageHints { pub(crate) root: Option, @@ -73,16 +25,11 @@ pub(super) fn usage_hint_text( return None; } - let catalog = step_context - .settings - .model_info - .model_messages - .as_ref() - .and_then(|messages| messages.multi_agent.as_ref()) - .and_then(|messages| messages.role.as_ref()); + let multi_agent_messages = + ResolvedModelMessages::from_model(&step_context.settings.model_info).multi_agent(); let snapshot = resolve_usage_hints( &turn_context.config.multi_agent_v2, - catalog, + multi_agent_messages, !turn_context.config.update_plan_enabled && turn_context.config.model_catalog.is_none(), ); match session_source { @@ -99,57 +46,38 @@ pub(super) fn usage_hint_text( pub(crate) fn resolve_usage_hints( config: &MultiAgentV2Config, - catalog: Option<&MultiAgentRoleMessages>, + multi_agent_messages: ResolvedMultiAgentMessages<'_>, omit_update_plan_instructions: bool, ) -> ResolvedMultiAgentV2UsageHints { - let resolve_role = |configured: Option<&str>, catalog: Option<&str>, bundled: &str| { + let resolve_role = |configured: Option<&str>, message: ResolvedMessage<'_>| { // Configured roles take precedence; empty configured or catalog roles suppress fallback. if let Some(configured) = configured { return (!configured.is_empty()) - .then(|| MultiAgentRoleInstructions::unmarked(configured)); + .then(|| MultiAgentRoleInstructions::Configured(configured.to_owned())); } - let base = catalog.unwrap_or(bundled); + let base = message.text(); if base.is_empty() { return None; } - let base = if omit_update_plan_instructions { - crate::context::without_update_plan_instructions(base) - } else { - base.to_string() - }; - - let max_concurrency = config.max_concurrent_threads_per_session; - let wait_agent_guidance = if config.wait_agent_enabled { - format!("{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n") - } else { - String::new() - }; - let mut text = format!( - "{base}\n{DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT}\n{wait_agent_guidance}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." - ); - if config.expose_spawn_agent_model_overrides { - text.push_str("\n\n"); - text.push_str(DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT); - } - - Some(if catalog.is_some() { - MultiAgentRoleInstructions::catalog(text) - } else { - MultiAgentRoleInstructions::unmarked(text) + Some(MultiAgentRoleInstructions::Composed { + base: base.to_owned(), + marked: message.catalog_override().is_some(), + omit_update_plan_instructions, + max_concurrency: config.max_concurrent_threads_per_session, + wait_agent_enabled: config.wait_agent_enabled, + expose_model_overrides: config.expose_spawn_agent_model_overrides, }) }; ResolvedMultiAgentV2UsageHints { root: resolve_role( config.root_agent_usage_hint_text.as_deref(), - catalog.and_then(|messages| messages.root.as_deref()), - DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, + multi_agent_messages.root, ), subagent: resolve_role( config.subagent_usage_hint_text.as_deref(), - catalog.and_then(|messages| messages.subagent.as_deref()), - DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, + multi_agent_messages.subagent, ), } } @@ -161,33 +89,31 @@ pub(crate) fn effective_multi_agent_mode(step_context: &StepContext) -> Option MultiAgentMode::Custom(hint_text.to_string()), - None => match settings.effective_reasoning_effort() { - Some(ReasoningEffort::Ultra) => catalog_mode - .and_then(|messages| messages.proactive.clone()) - .map(MultiAgentMode::Custom) - .unwrap_or(MultiAgentMode::Proactive), - _ => catalog_mode - .and_then(|messages| messages.explicit.clone()) - .map(MultiAgentMode::Custom) - .unwrap_or(MultiAgentMode::ExplicitRequestOnly), - }, + .or(multi_agent_messages.hint); + let multi_agent_mode = match hint { + Some(text) => MultiAgentMode::Custom(text.to_owned()), + None => { + let (message, builtin) = + if settings.effective_reasoning_effort() == Some(ReasoningEffort::Ultra) { + (multi_agent_messages.proactive, MultiAgentMode::Proactive) + } else { + ( + multi_agent_messages.explicit, + MultiAgentMode::ExplicitRequestOnly, + ) + }; + match message { + ResolvedMessage::Catalog(text) => MultiAgentMode::Custom(text.to_owned()), + ResolvedMessage::Bundled(_) => builtin, + } + } }; match &turn_context.session_source { diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index c508c9580d..8a20bd7fc5 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -27,6 +27,7 @@ use codex_http_client::ClientRouteClass; use codex_http_client::RouteAwareClientPool; use codex_login::auth::AgentIdentityAuthPolicy; use codex_model_provider::SharedModelProvider; +use codex_prompts::render_model_instructions; use codex_protocol::SessionId; use codex_protocol::capabilities::SelectedCapabilityRoot; use codex_protocol::config_types::ShellEnvironmentPolicy; @@ -785,7 +786,7 @@ impl Session { } else if let Some(inherited_base_instructions) = initial_history.get_base_instructions() { let BaseInstructions { text, provenance } = inherited_base_instructions; provenance.or_else(|| { - (text == model_info.get_model_instructions(config.personality)).then(|| { + (text == render_model_instructions(&model_info)).then(|| { BaseInstructionsProvenance::Model { model: model_info.slug.clone(), } diff --git a/codex-rs/core/src/session/step_activation.rs b/codex-rs/core/src/session/step_activation.rs index 9a4e461ab6..e5038bbcb7 100644 --- a/codex-rs/core/src/session/step_activation.rs +++ b/codex-rs/core/src/session/step_activation.rs @@ -8,10 +8,9 @@ use super::step_settings::StepSettingsUpdate; use super::turn_context::TurnContext; use crate::config::Config; use crate::config::ConstraintResult; -use crate::context::GuardianNodeReplPolicy; use crate::exec_policy::AllowPrefixRules; -use crate::guardian::BUNDLED_GUARDIAN_POLICY_TEMPLATE; use codex_features::Feature; +use codex_prompts::ResolvedModelMessages; use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::GuardianV2TranscriptModelConfig; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; @@ -175,45 +174,34 @@ fn check_legacy_model_safety( // catalog refresh. V1 uses the admitted config; V2 can use the live config. // An unchanged explicit reviewer override prevents both fallback paths. if destination.auto_review_model_override.is_none() { - let destination_node_repl_policy = - GuardianNodeReplPolicy::from_model_messages(destination.model_messages.as_ref()); - for model in retained_models { - let policy = GuardianNodeReplPolicy::from_model_messages(model.model_messages.as_ref()); - if policy != destination_node_repl_policy { - return Err( - "the destination changes the Guardian parent-fallback node REPL policy" - .to_string(), - ); - } + let destination_model_messages = ResolvedModelMessages::from_model(destination); + let destination_auto_review = destination_model_messages.auto_review(); + let retained_model_messages = retained_models.map(ResolvedModelMessages::from_model); + let retained_auto_review = retained_model_messages + .each_ref() + .map(ResolvedModelMessages::auto_review); + if retained_auto_review.iter().any(|auto_review| { + auto_review.node_repl_policy != destination_auto_review.node_repl_policy + }) { + return Err( + "the destination changes the Guardian parent-fallback node REPL policy".to_string(), + ); } for config in [admitted_config, live_config] { - let destination_policy = - config.resolve_guardian_policy(destination.model_messages.as_ref()); - if retained_models.iter().any(|model| { - config.resolve_guardian_policy(model.model_messages.as_ref()) != destination_policy + let destination_policy = config.resolve_guardian_policy(destination_model_messages); + if retained_model_messages.iter().any(|model_messages| { + config.resolve_guardian_policy(*model_messages) != destination_policy }) { return Err( "the destination changes the Guardian parent-fallback policy".to_string(), ); } } - let destination_template = destination - .model_messages - .as_ref() - .and_then(|messages| messages.auto_review.as_ref()) - .and_then(|messages| messages.policy_template.as_deref()) - .unwrap_or(BUNDLED_GUARDIAN_POLICY_TEMPLATE) - .trim_end(); - if retained_models.iter().any(|model| { - model - .model_messages - .as_ref() - .and_then(|messages| messages.auto_review.as_ref()) - .and_then(|messages| messages.policy_template.as_deref()) - .unwrap_or(BUNDLED_GUARDIAN_POLICY_TEMPLATE) - .trim_end() - != destination_template - }) { + let destination_template = destination_auto_review.policy_template.trim_end(); + if retained_auto_review + .iter() + .any(|auto_review| auto_review.policy_template.trim_end() != destination_template) + { return Err( "the destination changes the Guardian parent-fallback policy template".to_string(), ); diff --git a/codex-rs/core/src/session/step_activation_tests.rs b/codex-rs/core/src/session/step_activation_tests.rs index 864886af60..fb4e647457 100644 --- a/codex-rs/core/src/session/step_activation_tests.rs +++ b/codex-rs/core/src/session/step_activation_tests.rs @@ -1,7 +1,6 @@ use super::*; use crate::agents_md_manager::AgentsMdManager; use crate::agents_md_manager::SessionInstructions; -use crate::guardian::BUNDLED_GUARDIAN_POLICY; use crate::session::handlers::submission_loop; use crate::session::step_context::StepContext; use crate::session::step_settings::StepSettings; @@ -1242,6 +1241,7 @@ async fn parent_fallback_policy_uses_both_config_lifetimes( #[tokio::test] async fn parent_fallback_preserves_explicit_empty_and_bundled_defaults() { + let defaults = ResolvedModelMessages::bundled().auto_review(); let (_, turn) = make_session_and_context().await; let mut config = turn.config.as_ref().clone(); config.guardian_policy_config = None; @@ -1249,9 +1249,9 @@ async fn parent_fallback_preserves_explicit_empty_and_bundled_defaults() { let check = |destination: &ModelInfo| { check_legacy_model_safety(&admitted, &admitted, destination, &config, &config) }; - parent_review_messages(&mut destination).policy = Some(BUNDLED_GUARDIAN_POLICY.to_string()); + parent_review_messages(&mut destination).policy = Some(defaults.policy.to_string()); parent_review_messages(&mut destination).policy_template = - Some(BUNDLED_GUARDIAN_POLICY_TEMPLATE.to_string()); + Some(defaults.policy_template.to_string()); assert_eq!(check(&destination), Ok(())); parent_review_messages(&mut destination).policy_template = Some(String::new()); assert_eq!( diff --git a/codex-rs/core/src/session/step_settings_tests.rs b/codex-rs/core/src/session/step_settings_tests.rs index 1779136d00..38fd6d1ad9 100644 --- a/codex-rs/core/src/session/step_settings_tests.rs +++ b/codex-rs/core/src/session/step_settings_tests.rs @@ -10,6 +10,7 @@ use codex_config::Sourced; use codex_features::Feature; use codex_models_manager::manager::StaticModelsManager; use codex_models_manager::model_info::model_info_from_slug; +use codex_prompts::render_model_instructions; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::models::BaseInstructionsProvenance; @@ -475,7 +476,7 @@ async fn model_resolution_preserves_startup_overrides_and_instruction_provenance .await; assert_eq!(resolved, legacy); assert_eq!( - resolved.get_model_instructions(settings.personality), + render_model_instructions(&resolved), if explicit_instructions { configured_instructions } else { diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index aee5d07e5b..a0c8c17014 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -60,6 +60,7 @@ use codex_model_provider_info::built_in_model_providers; use codex_models_manager::model_info; use codex_models_manager::test_support::construct_model_info_offline_for_tests; use codex_models_manager::test_support::get_model_offline_for_tests; +use codex_prompts::render_model_instructions; use codex_protocol::AgentPath; use codex_protocol::ResponseItemId; use codex_protocol::SessionId; @@ -3969,7 +3970,7 @@ async fn set_rate_limits_retains_previous_credits() { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), @@ -4091,7 +4092,7 @@ async fn set_rate_limits_updates_plan_type_when_present() { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), @@ -4703,7 +4704,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), @@ -5789,7 +5790,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_packaged_zsh() { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), @@ -5970,7 +5971,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) { base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), @@ -6279,7 +6280,7 @@ async fn make_session_with_config_and_rx( base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), @@ -6411,7 +6412,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx( base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), @@ -8153,7 +8154,7 @@ where base_instructions: config .base_instructions .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + .unwrap_or_else(|| render_model_instructions(&model_info)), permission_profile_state: config.permissions.permission_profile_state().clone(), allow_login_shell: config.permissions.allow_login_shell, shell_environment_policy: config.permissions.shell_environment_policy.clone(), diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index ba9a4f02f1..24e59a337b 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -94,7 +94,6 @@ use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelMessages; use codex_protocol::protocol::AgentMessageContentDeltaEvent; use codex_protocol::protocol::AgentReasoningSectionBreakEvent; use codex_protocol::protocol::CodexErrorInfo; @@ -1720,7 +1719,6 @@ pub(crate) async fn built_tools( sess: &Session, turn_context: &TurnContext, model_info: &codex_protocol::openai_models::ModelInfo, - model_messages: Option<&ModelMessages>, environments: &TurnEnvironmentSnapshot, mcp: &Arc, step_store: &ExtensionData, @@ -1790,7 +1788,6 @@ pub(crate) async fn built_tools( sess, turn_context, model_info, - model_messages, environments, mcp, apps_enabled, diff --git a/codex-rs/core/src/session/world_state.rs b/codex-rs/core/src/session/world_state.rs index eae14ba44c..8ea7a66d84 100644 --- a/codex-rs/core/src/session/world_state.rs +++ b/codex-rs/core/src/session/world_state.rs @@ -3,7 +3,6 @@ use std::sync::Arc; use super::session::Session; use super::step_context::StepContext; use crate::connectors; -use crate::context::ApprovalPromptContext; use crate::context::TokenBudgetContext; use crate::context::world_state::AgentsMdState; use crate::context::world_state::AppsInstructionsState; @@ -25,6 +24,9 @@ use crate::context::world_state::WorldState; use codex_connectors::AppToolPolicyEvaluator; use codex_extension_api::WorldStateContributionInput; use codex_features::Feature; +use codex_prompts::ApprovalPromptContext; +use codex_prompts::ResolvedModelMessages; +use codex_prompts::render_model_instructions; use codex_protocol::error::Result as CodexResult; use codex_protocol::models::BaseInstructionsProvenance; @@ -37,12 +39,12 @@ impl Session { let turn_context = step_context.turn.as_ref(); let settings = &step_context.settings; let model_info = settings.model_info.as_ref(); - let personality = settings.personality(); + let model_messages = ResolvedModelMessages::from_model(model_info); tracing::trace!( selected_capability_root_count = step_context.selected_capability_roots.len(), "building step world state" ); - let model_instructions = model_info.get_model_instructions(personality); + let model_instructions = render_model_instructions(model_info); let model_instructions = if !turn_context.config.update_plan_enabled && turn_context.config.model_catalog.is_none() && (turn_context.config.base_instructions.is_none() @@ -50,7 +52,7 @@ impl Session { turn_context.config.base_instructions_provenance, Some(BaseInstructionsProvenance::Model { .. }) )) { - crate::context::without_update_plan_instructions(&model_instructions) + codex_prompts::without_update_plan_instructions(&model_instructions) } else { model_instructions }; @@ -142,15 +144,10 @@ impl Session { let cwd = environment .and_then(|environment| environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| turn_context.cwd.clone()); - let model_messages = model_info.model_messages.as_ref(); world_state.add_section(PermissionsState::new( &permission_profile, settings.approval_policy(), - ApprovalPromptContext::new( - settings.approvals_reviewer(), - model_messages.and_then(|messages| messages.approvals.as_ref()), - model_messages.and_then(|messages| messages.permissions.as_ref()), - ), + ApprovalPromptContext::new(settings.approvals_reviewer(), model_messages), exec_policy.as_ref(), &cwd, turn_context @@ -168,10 +165,7 @@ impl Session { if turn_context.config.include_collaboration_mode_instructions { world_state.add_section(CollaborationModeState::from_collaboration_mode( &settings.effective_collaboration_mode(), - model_info - .model_messages - .as_ref() - .and_then(|messages| messages.collaboration_modes.as_ref()), + model_messages.collaboration_modes(), turn_context.config.update_plan_enabled, turn_context.config.model_catalog.is_some(), )); @@ -187,12 +181,7 @@ impl Session { .any(|tool| tool == "send_user_message_async"); world_state.add_section(PersistentModeState::new( step_context.settings.effective_reasoning_effort().as_ref(), - step_context - .settings - .model_info - .model_messages - .as_ref() - .and_then(|messages| messages.persistent_instructions.as_deref()), + model_messages.persistent_instructions(), send_user_message_async_available, )); } diff --git a/codex-rs/core/src/tools/approvals.rs b/codex-rs/core/src/tools/approvals.rs index a1513dfccf..0ef1bbb4a1 100644 --- a/codex-rs/core/src/tools/approvals.rs +++ b/codex-rs/core/src/tools/approvals.rs @@ -6,7 +6,6 @@ use crate::guardian::GuardianNetworkAccessTrigger; use crate::guardian::GuardianReviewContext; use crate::guardian::GuardianReviewOptions; use crate::guardian::decide_approval; -use crate::guardian::guardian_timeout_message; use crate::guardian::new_guardian_review_id; use crate::guardian::spawn_approval_decision; use crate::hook_runtime::run_permission_request_hooks; @@ -24,6 +23,7 @@ use codex_analytics::GuardianApprovalRequestSource; use codex_config::types::AppToolApproval; use codex_hooks::PermissionRequestDecision; use codex_otel::ToolDecisionSource; +use codex_prompts::ResolvedModelMessages; use codex_protocol::approvals::ExecApprovalKind; use codex_protocol::approvals::ExecPolicyAmendment; #[cfg(unix)] @@ -454,9 +454,12 @@ impl ApprovalResolution { Err(ToolError::Rejected(rejection.to_string())) } ReviewDecision::Denied { rejection } => Err(ToolError::Rejected(rejection)), - ReviewDecision::TimedOut => { - Err(ToolError::Rejected(guardian_timeout_message(model_info))) - } + ReviewDecision::TimedOut => Err(ToolError::Rejected( + ResolvedModelMessages::from_model(model_info) + .auto_review() + .timeout_instructions + .to_string(), + )), ReviewDecision::Abort => Err(ToolError::Codex(CodexErr::TurnAborted)), decision => Ok(decision), } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index d7899c7a81..d7be23ce58 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -16,6 +16,7 @@ use crate::tools::handlers::multi_agents_spec::SpawnAgentToolOptions; use crate::tools::handlers::multi_agents_spec::create_spawn_agent_tool_v2; use crate::tools::handlers::multi_agents_v2::message_tool::message_content; use crate::turn_timing::now_unix_timestamp_ms; +use codex_prompts::ResolvedModelMessages; use codex_protocol::AgentPath; use codex_protocol::ThreadId; use codex_protocol::protocol::MultiAgentVersion; @@ -175,16 +176,13 @@ async fn handle_spawn_agent( ), _ => None, }; - let child_catalog = child_model_info - .as_ref() - .unwrap_or(turn.model_info()) - .model_messages - .as_ref() - .and_then(|messages| messages.multi_agent.as_ref()) - .and_then(|messages| messages.role.as_ref()); + let child_multi_agent_messages = ResolvedModelMessages::from_model( + child_model_info.as_ref().unwrap_or(turn.model_info()), + ) + .multi_agent(); Some(resolve_usage_hints( &config.multi_agent_v2, - child_catalog, + child_multi_agent_messages, !config.update_plan_enabled && config.model_catalog.is_none(), )) } else { diff --git a/codex-rs/core/src/tools/handlers/request_user_input_async.rs b/codex-rs/core/src/tools/handlers/request_user_input_async.rs index 86895851c7..2c4c7067ed 100644 --- a/codex-rs/core/src/tools/handlers/request_user_input_async.rs +++ b/codex-rs/core/src/tools/handlers/request_user_input_async.rs @@ -22,7 +22,7 @@ use std::collections::BTreeMap; const TOOL_NAME: &str = "request_user_input_async"; pub struct RequestUserInputAsyncHandler { - pub description: Option, + pub description: String, } #[derive(Debug, Deserialize)] @@ -62,10 +62,7 @@ impl ToolExecutor for RequestUserInputAsyncHandler { ToolSpec::Function(ResponsesApiTool { name: TOOL_NAME.to_string(), - description: self.description.clone().unwrap_or_else(|| { - "Ask the user one or more questions during ongoing work. Use this tool only to request missing information, preferences, constraints, clarification, or approval. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message. Keep questions concise, self-contained, and easy to understand, using a level of detail appropriate to the user and task. The UI always allows a free-text answer, including when suggested options are provided. A preselected option is not submitted automatically." - .to_string() - }), + description: self.description.clone(), strict: false, defer_loading: None, parameters: JsonSchema::object( diff --git a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs index b32bbf1a1d..dbf908dfce 100644 --- a/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs +++ b/codex-rs/core/src/tools/runtimes/zsh_fork/unix_escalation.rs @@ -16,6 +16,7 @@ use codex_execpolicy::Evaluation; use codex_execpolicy::MatchOptions; use codex_execpolicy::Policy; use codex_execpolicy::RuleMatch; +use codex_prompts::ResolvedModelMessages; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::error::CodexErr; use codex_protocol::models::AdditionalPermissionProfile; @@ -369,9 +370,12 @@ impl CoreShellActionProvider { EscalationDecision::deny(Some(rejection)) } ReviewDecision::TimedOut => EscalationDecision::deny(Some( - crate::guardian::guardian_timeout_message( + ResolvedModelMessages::from_model( self.review_context.turn().model_info(), - ), + ) + .auto_review() + .timeout_instructions + .to_string(), )), ReviewDecision::ApprovedMcpPolicyAmendment => { error!("Shell escalation received ApprovedMcpPolicyAmendment"); diff --git a/codex-rs/core/src/tools/spec_plan.rs b/codex-rs/core/src/tools/spec_plan.rs index ac1ca687bc..581ba362bf 100644 --- a/codex-rs/core/src/tools/spec_plan.rs +++ b/codex-rs/core/src/tools/spec_plan.rs @@ -65,6 +65,7 @@ use codex_extension_api::ExtensionData; use codex_features::Feature; use codex_features::SleepToolMode; use codex_login::AuthManager; +use codex_prompts::ResolvedModelMessages; use codex_protocol::DEFAULT_FUNCTION_NAMESPACE; use codex_protocol::account::PlanType; use codex_protocol::config_types::WebSearchMode; @@ -76,7 +77,6 @@ use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; use codex_protocol::openai_models::InputModality; use codex_protocol::openai_models::ModelInfo; -use codex_protocol::openai_models::ModelMessages; use codex_protocol::openai_models::ToolMode; use codex_protocol::protocol::MultiAgentVersion; use codex_tools::ResponsesApiNamespace; @@ -111,7 +111,6 @@ const IMAGEGEN_TOOL_NAME: &str = "imagegen"; struct CoreToolPlanContext<'a> { turn_context: &'a TurnContext, model_info: &'a ModelInfo, - model_messages: Option<&'a ModelMessages>, environments: &'a TurnEnvironmentSnapshot, mcp: &'a codex_mcp::McpBinding, tool_suggest_candidates: Option<&'a crate::tools::router::ToolSuggestCandidates>, @@ -126,7 +125,6 @@ pub(crate) fn build_tool_router( session: &Session, turn_context: &TurnContext, model_info: &ModelInfo, - model_messages: Option<&ModelMessages>, environments: &TurnEnvironmentSnapshot, mcp: &Arc, apps_enabled: bool, @@ -142,7 +140,6 @@ pub(crate) fn build_tool_router( let context = CoreToolPlanContext { turn_context, model_info, - model_messages, environments, mcp, tool_suggest_candidates, @@ -279,7 +276,6 @@ pub(crate) fn build_core_tool_registry( let context = CoreToolPlanContext { turn_context, model_info, - model_messages: model_info.model_messages.as_ref(), environments, mcp, tool_suggest_candidates, @@ -1136,11 +1132,9 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, registry: &mut Tool { registry.add_with_exposure( RequestUserInputAsyncHandler { - description: context - .model_messages - .and_then(|messages| messages.tools.as_ref()) - .and_then(|tools| tools.send_user_message_async.as_ref()) - .and_then(|tool| tool.description.clone()), + description: ResolvedModelMessages::from_model(context.model_info) + .request_user_input_async_description() + .to_string(), }, ToolExposure::DirectModelOnly, ); diff --git a/codex-rs/core/src/tools/spec_plan_tests.rs b/codex-rs/core/src/tools/spec_plan_tests.rs index 8fc47b654a..b603316eb7 100644 --- a/codex-rs/core/src/tools/spec_plan_tests.rs +++ b/codex-rs/core/src/tools/spec_plan_tests.rs @@ -610,8 +610,7 @@ async fn internal_guardian_sessions_exclude_optional_core_tools() { let router = super::build_tool_router( &session, step_context.turn.as_ref(), - step_context.turn.model_info(), - step_context.settings.model_info.model_messages.as_ref(), + &step_context.settings.model_info, &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, @@ -664,8 +663,7 @@ async fn internal_guardian_sessions_respect_managed_shell_restrictions() { let router = super::build_tool_router( &session, step_context.turn.as_ref(), - step_context.turn.model_info(), - step_context.settings.model_info.model_messages.as_ref(), + &step_context.settings.model_info, &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, @@ -702,8 +700,7 @@ async fn internal_guardian_sessions_preserve_code_mode() { let router = super::build_tool_router( &session, step_context.turn.as_ref(), - step_context.turn.model_info(), - step_context.settings.model_info.model_messages.as_ref(), + &step_context.settings.model_info, &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, @@ -774,8 +771,7 @@ async fn internal_guardian_sessions_require_managed_secondary_environments() { let router = super::build_tool_router( &session, step_context.turn.as_ref(), - step_context.turn.model_info(), - step_context.settings.model_info.model_messages.as_ref(), + &step_context.settings.model_info, &step_context.environments, &step_context.mcp, /*apps_enabled*/ false, diff --git a/codex-rs/core/tests/suite/guardian_review.rs b/codex-rs/core/tests/suite/guardian_review.rs index b7d44dfbe1..b7e65a9df1 100644 --- a/codex-rs/core/tests/suite/guardian_review.rs +++ b/codex-rs/core/tests/suite/guardian_review.rs @@ -1242,7 +1242,9 @@ async fn guardian_node_repl_policy_follows_production_approval_path( .collect::>(); assert_eq!(guardian_requests.len(), actions.len()); - let bundled_policy = include_str!("../../assets/guardian/node_repl_policy.md"); + let bundled_policy = codex_prompts::ResolvedModelMessages::bundled() + .auto_review() + .node_repl_policy; let policy = node_repl_policy.unwrap_or(bundled_policy); let first_guardian_thread = guardian_requests[0].body_json()["client_metadata"]["thread_id"] .as_str() diff --git a/codex-rs/core/tests/suite/model_switching.rs b/codex-rs/core/tests/suite/model_switching.rs index 23cd535e65..12c5bcddb7 100644 --- a/codex-rs/core/tests/suite/model_switching.rs +++ b/codex-rs/core/tests/suite/model_switching.rs @@ -9,6 +9,7 @@ use codex_history::RolloutItem; use codex_login::CodexAuth; use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; +use codex_prompts::render_model_instructions; use codex_protocol::config_types::ApprovalsReviewer; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; @@ -199,12 +200,12 @@ async fn first_turn_model_change_appends_model_instructions_developer_message( let request = resp_mock.single_request(); assert_eq!(request.body_json()["model"], next_model); let developer_texts = request.message_input_texts("developer"); - let expected_instructions = bundled_models_response()? + let expected_model = bundled_models_response()? .models .into_iter() .find(|model| model.slug == next_model) - .expect("expected target model in bundled catalog") - .get_model_instructions(personality.or(test.config.personality)); + .expect("expected target model in bundled catalog"); + let expected_instructions = render_model_instructions(&expected_model); assert!( developer_texts.iter().any(|text| { text.contains("") && text.contains(&expected_instructions) diff --git a/codex-rs/core/tests/suite/permissions_messages.rs b/codex-rs/core/tests/suite/permissions_messages.rs index 116a0fdc04..318f2a6e2f 100644 --- a/codex-rs/core/tests/suite/permissions_messages.rs +++ b/codex-rs/core/tests/suite/permissions_messages.rs @@ -3,10 +3,6 @@ use codex_config::ConfigLayerStack; use codex_core::ForkSnapshot; use codex_core::TurnInputRequest; use codex_core::config::Constrained; -use codex_core::context::ApprovalPromptContext; -use codex_core::context::ContextualUserFragment; -use codex_core::context::PermissionsInstructions; -use codex_core::load_exec_policy; use codex_models_manager::model_info::model_info_from_slug; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ApprovalMessages; @@ -47,23 +43,13 @@ fn model_with_approval_messages( ) -> codex_protocol::openai_models::ModelInfo { let mut model = model_info_from_slug(slug); model.model_messages = Some(ModelMessages { - persistent_instructions: None, - tools: None, - instructions_template: None, - instructions_variables: None, approvals: Some(ApprovalMessages { on_request: Some(on_request.to_string()), on_request_auto_review: Some(on_request_auto_review.to_string()), never: None, unless_trusted: None, }), - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, + ..Default::default() }); model } @@ -74,18 +60,8 @@ fn model_with_permission_messages( ) -> codex_protocol::openai_models::ModelInfo { let mut model = model_info_from_slug(slug); model.model_messages = Some(ModelMessages { - persistent_instructions: None, - tools: None, - instructions_template: None, - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, permissions: Some(permissions), - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, + ..Default::default() }); model } @@ -231,18 +207,8 @@ async fn catalog_non_on_request_approval_messages_are_sent_in_initial_permission let model_slug = "catalog-non-on-request-approvals-model"; let mut model = model_info_from_slug(model_slug); model.model_messages = Some(ModelMessages { - persistent_instructions: None, - tools: None, - instructions_template: None, - instructions_variables: None, approvals: Some(approvals), - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, + ..Default::default() }); let mut builder = test_codex() .with_model(model_slug) @@ -780,13 +746,13 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { ) .await; let writable = TempDir::new()?; - let writable_root = AbsolutePathBuf::try_from(writable.path())?; + let writable_root = AbsolutePathBuf::try_from(writable.path().canonicalize()?)?; let writable_root_for_config = writable_root.clone(); let permission_profile = PermissionProfile::workspace_write_with( std::slice::from_ref(&writable_root), NetworkSandboxPolicy::Restricted, - /*exclude_tmpdir_env_var*/ false, - /*exclude_slash_tmp*/ false, + /*exclude_tmpdir_env_var*/ true, + /*exclude_slash_tmp*/ true, ); let mut builder = test_codex().with_config(move |config| { @@ -811,29 +777,23 @@ async fn permissions_message_includes_writable_roots() -> Result<()> { wait_for_event(&test.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; let permissions = permissions_texts(&req.single_request()); - let normalize_line_endings = |s: &str| s.replace("\r\n", "\n"); - let exec_policy = load_exec_policy(&test.config.config_layer_stack).await?; - let permission_profile = test.config.permissions.effective_permission_profile(); - let expected = PermissionsInstructions::from_permission_profile( - &permission_profile, - AskForApproval::OnRequest, - ApprovalPromptContext::new( - test.config.approvals_reviewer, - /*messages*/ None, - /*permission_messages*/ None, - ), - &exec_policy, - test.config.cwd.as_path(), - /*exec_permission_approvals_enabled*/ false, - /*request_permissions_tool_enabled*/ false, - ) - .render(); - let expected_normalized = normalize_line_endings(&expected); - let actual_normalized: Vec = permissions + assert_eq!(permissions.len(), 1); + let mut writable_roots = [ + AbsolutePathBuf::try_from(test.config.cwd.as_path().canonicalize()?)?, + writable_root, + ]; + writable_roots.sort(); + let writable_roots = writable_roots .iter() - .map(|s| normalize_line_endings(s)) - .collect(); - assert_eq!(actual_normalized, vec![expected_normalized]); + .map(|root| format!("`{}`", root.to_string_lossy())) + .collect::>() + .join(", "); + let expected = format!(" The writable roots are {writable_roots}."); + let actual = permissions[0] + .lines() + .filter(|line| line.starts_with(" The writable root")) + .collect::>(); + assert_eq!(actual, vec![expected.as_str()]); Ok(()) } diff --git a/codex-rs/core/tests/suite/prompt_caching.rs b/codex-rs/core/tests/suite/prompt_caching.rs index ef5ae7050d..9bbd7e7ed6 100644 --- a/codex-rs/core/tests/suite/prompt_caching.rs +++ b/codex-rs/core/tests/suite/prompt_caching.rs @@ -10,6 +10,7 @@ use codex_features::Feature; use codex_models_manager::bundled_models_response; use codex_models_manager::collaboration_mode_presets::builtin_collaboration_mode_presets; use codex_models_manager::manager::StaticModelsManager; +use codex_prompts::render_model_instructions; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::ReasoningSummary; @@ -216,7 +217,7 @@ async fn prompt_tools_are_consistent_across_requests( let base_instructions = if custom_instructions { CUSTOM_BASE_INSTRUCTIONS.to_string() } else { - let original = model_info.get_model_instructions(config.personality); + let original = render_model_instructions(&model_info); if expected_update_plan_enabled { original } else { diff --git a/codex-rs/core/tests/suite/remote_models.rs b/codex-rs/core/tests/suite/remote_models.rs index f83623b2c0..0568116770 100644 --- a/codex-rs/core/tests/suite/remote_models.rs +++ b/codex-rs/core/tests/suite/remote_models.rs @@ -9,6 +9,7 @@ use codex_models_manager::bundled_models_response; use codex_models_manager::manager::RefreshStrategy; use codex_models_manager::manager::SharedModelsManager; use codex_models_manager::model_info::BASE_INSTRUCTIONS; +use codex_prompts::render_model_instructions; use codex_protocol::config_types::ReasoningSummary; use codex_protocol::models::PermissionProfile; use codex_protocol::openai_models::ConfigShellToolType; @@ -155,36 +156,16 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( let specific = ModelInfo { display_name: "GPT 5.3 Codex".to_string(), model_messages: Some(ModelMessages { - persistent_instructions: None, - tools: None, instructions_template: Some("use specific prefix".to_string()), - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, + ..Default::default() }), ..specific }; let generic = ModelInfo { display_name: "GPT 5.3".to_string(), model_messages: Some(ModelMessages { - persistent_instructions: None, - tools: None, instructions_template: Some("use generic prefix".to_string()), - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, + ..Default::default() }), ..generic }; @@ -223,8 +204,8 @@ async fn remote_models_get_model_info_uses_longest_matching_prefix() -> Result<( assert_eq!(model_info.slug, "gpt-5.3-codex-test"); assert_eq!( - model_info.get_model_instructions(config.personality), - specific.get_model_instructions(config.personality) + render_model_instructions(&model_info), + render_model_instructions(&specific) ); Ok(()) @@ -450,17 +431,8 @@ async fn remote_models_long_model_slug_is_sent_with_supported_reasoning( remote_model.default_reasoning_summary = ReasoningSummary::Detailed; remote_model.model_messages = Some(ModelMessages { persistent_instructions: catalog_instructions.map(str::to_string), - tools: None, instructions_template: Some(base_instructions.to_string()), - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - guardian_v2: None, - confirmation_policies: None, + ..Default::default() }); mount_models_once( &server, @@ -901,18 +873,8 @@ async fn remote_models_apply_legacy_instructions(auth: CodexAuth) -> Result<()> available_access_programs: None, upgrade: None, model_messages: Some(ModelMessages { - persistent_instructions: None, - tools: None, instructions_template: Some(remote_instructions.to_string()), - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, + ..Default::default() }), include_skills_usage_instructions: false, include_plugin_usage_instructions: false, @@ -1043,7 +1005,7 @@ async fn remote_models_apply_legacy_instructions(auth: CodexAuth) -> Result<()> let request = requests.last().expect("expected second model request"); assert_eq!( request.instructions_text(), - base_model_info.get_model_instructions(config.personality) + render_model_instructions(&base_model_info) ); assert!( request diff --git a/codex-rs/ext/goal/Cargo.toml b/codex-rs/ext/goal/Cargo.toml index 22ee1794fd..3939da1ab7 100644 --- a/codex-rs/ext/goal/Cargo.toml +++ b/codex-rs/ext/goal/Cargo.toml @@ -18,6 +18,7 @@ codex-analytics = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-otel = { workspace = true } +codex-prompts = { workspace = true } codex-protocol = { workspace = true } codex-rollout = { workspace = true } codex-state = { workspace = true } diff --git a/codex-rs/ext/goal/src/steering.rs b/codex-rs/ext/goal/src/steering.rs index 88de89d09e..4e623e69b6 100644 --- a/codex-rs/ext/goal/src/steering.rs +++ b/codex-rs/ext/goal/src/steering.rs @@ -1,7 +1,7 @@ use codex_core::context::ContextualUserFragment; use codex_core::context::InternalContextSource; use codex_core::context::InternalModelContextFragment; -use codex_core::context::without_update_plan_instructions; +use codex_prompts::without_update_plan_instructions; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::ThreadGoal; use codex_utils_template::Template; diff --git a/codex-rs/ext/guardian-reviewer/Cargo.toml b/codex-rs/ext/guardian-reviewer/Cargo.toml index a882ae7dea..6c7b9cd55b 100644 --- a/codex-rs/ext/guardian-reviewer/Cargo.toml +++ b/codex-rs/ext/guardian-reviewer/Cargo.toml @@ -20,6 +20,7 @@ codex-feedback = { workspace = true } codex-guardian-context = { workspace = true } codex-mcp = { workspace = true } codex-otel = { workspace = true } +codex-prompts = { workspace = true } codex-protocol = { workspace = true } rand = { workspace = true } serde = { workspace = true } diff --git a/codex-rs/ext/guardian-reviewer/src/completion.rs b/codex-rs/ext/guardian-reviewer/src/completion.rs index e9ecd3d4b5..dd91971430 100644 --- a/codex-rs/ext/guardian-reviewer/src/completion.rs +++ b/codex-rs/ext/guardian-reviewer/src/completion.rs @@ -4,6 +4,8 @@ use codex_analytics::GuardianReviewAnalyticsResult; use codex_analytics::GuardianReviewDecision; use codex_analytics::GuardianReviewTerminalStatus; +use codex_prompts::ResolvedModelMessages; +use codex_prompts::render_guardian_rejection; use codex_protocol::openai_models::ModelInfo; use codex_protocol::protocol::GuardianAssessmentEvent; use codex_protocol::protocol::GuardianAssessmentOutcome; @@ -15,36 +17,14 @@ use codex_protocol::protocol::ReviewDecision; use crate::GuardianReviewError; use crate::GuardianReviewOutcome; -const REJECTION_INSTRUCTIONS: &str = concat!( - "The agent must not attempt to achieve the same outcome via workaround, ", - "indirect execution, or policy circumvention. ", - "Proceed only with a materially safer alternative, ", - "or if the user explicitly approves the action after being informed of the risk. ", - "Otherwise, stop and request user input.", -); const REVIEW_FAILURE_INSTRUCTIONS: &str = concat!( "The action was not executed because automatic approval review could not be completed. ", "This is a review failure, not a determination that the action is unsafe. ", "Do not bypass the approval check; resolve the error or ask the user for guidance.", ); -const TIMEOUT_INSTRUCTIONS: &str = concat!( - "The automatic permission approval review did not finish before its deadline. ", - "Do not assume the action is unsafe based on the timeout alone. ", - "You may retry once, or ask the user for guidance or explicit approval.", -); const INPUT_BUDGET_MESSAGE: &str = "the complete action and minimum review context exceed the reviewer input budget"; -pub fn guardian_timeout_message(model_info: &ModelInfo) -> String { - model_info - .model_messages - .as_ref() - .and_then(|messages| messages.auto_review.as_ref()) - .and_then(|messages| messages.timeout_instructions.as_deref()) - .unwrap_or(TIMEOUT_INSTRUCTIONS) - .to_string() -} - pub struct ReviewCompletion { /// `None` requests user approval after optional review exhausts its input budget. pub decision: Option, @@ -189,19 +169,12 @@ pub fn complete_review( let decision = if approved { ReviewDecision::Approved } else { - let rationale = if assessment.rationale.trim().is_empty() { - "Auto-reviewer denied the action without a specific rationale." - } else { - assessment.rationale.trim() - }; - let instructions = model - .model_messages - .as_ref() - .and_then(|messages| messages.auto_review.as_ref()) - .and_then(|messages| messages.rejection_instructions.as_deref()) - .unwrap_or(REJECTION_INSTRUCTIONS); - ReviewDecision::denied(format!( - "This action was rejected due to unacceptable risk.\nReason: {rationale}\n{instructions}" + let rejection_instructions = ResolvedModelMessages::from_model(model) + .auto_review() + .rejection_instructions; + ReviewDecision::denied(render_guardian_rejection( + &assessment.rationale, + rejection_instructions, )) }; ReviewCompletion { diff --git a/codex-rs/ext/guardian-reviewer/src/lib.rs b/codex-rs/ext/guardian-reviewer/src/lib.rs index 864d225d1c..6feff41201 100644 --- a/codex-rs/ext/guardian-reviewer/src/lib.rs +++ b/codex-rs/ext/guardian-reviewer/src/lib.rs @@ -52,7 +52,6 @@ pub use review::ReviewHost; pub use completion::ReviewCompletion; pub use completion::complete_review; -pub use completion::guardian_timeout_message; pub use execution::ReviewTurnResult; pub use execution::ReviewerRuntime; diff --git a/codex-rs/ext/guardian-v2/BUILD.bazel b/codex-rs/ext/guardian-v2/BUILD.bazel index 3bca217639..54a25b5f73 100644 --- a/codex-rs/ext/guardian-v2/BUILD.bazel +++ b/codex-rs/ext/guardian-v2/BUILD.bazel @@ -8,8 +8,5 @@ exports_files( codex_rust_crate( name = "guardian-v2", - compile_data = [ - "src/async_scorer/classifier_instructions.md", - ], crate_name = "codex_guardian_v2", ) diff --git a/codex-rs/ext/guardian-v2/Cargo.toml b/codex-rs/ext/guardian-v2/Cargo.toml index 4121fc1ce9..f41ccb6581 100644 --- a/codex-rs/ext/guardian-v2/Cargo.toml +++ b/codex-rs/ext/guardian-v2/Cargo.toml @@ -17,6 +17,7 @@ anyhow = { workspace = true } codex-analytics = { workspace = true } codex-api = { workspace = true } codex-config = { workspace = true } +codex-context-fragments = { workspace = true } codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-features = { workspace = true } @@ -27,6 +28,7 @@ codex-http-client = { workspace = true } codex-login = { workspace = true } codex-model-provider = { workspace = true } codex-protocol = { workspace = true } +codex-prompts = { workspace = true } dirs = { workspace = true } http = { workspace = true } serde_json = { workspace = true } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs b/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs index 6e83d1542a..2e65196314 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/classification.rs @@ -33,6 +33,7 @@ use codex_guardian_context::ReviewEvidence; use codex_guardian_context::render_review_evidence; use codex_history::RolloutItem; use codex_model_provider::create_model_provider; +use codex_prompts::ResolvedModelMessages; use codex_protocol::models::ContentItem; use codex_protocol::openai_models::ModelInfo; use codex_protocol::security_risk::SecurityRiskScore; @@ -239,7 +240,7 @@ impl Classification { let mut classification_risk = None; let mut classification_finished_at = None; let result: Result = async { - let review_model_messages = if config.guardian_policy_config.is_none() { + let review_model = if config.guardian_policy_config.is_none() { let review_model_id = review_model_override.as_deref().unwrap_or_else(|| { create_model_provider( config.model_provider.clone(), @@ -252,16 +253,18 @@ impl Classification { .get_model_info(review_model_id, &config.to_models_manager_config()) .await; if review_model.used_fallback_model_metadata && review_model_override.is_none() { - parent_model - .as_ref() - .and_then(|model| model.model_messages.clone()) + parent_model.clone() } else { - review_model.model_messages + Some(Arc::new(review_model)) } } else { None }; - let policy = config.resolve_guardian_policy(review_model_messages.as_ref()); + let model_messages = review_model + .as_deref() + .map(ResolvedModelMessages::from_model) + .unwrap_or_else(ResolvedModelMessages::bundled); + let policy = config.resolve_guardian_policy(model_messages); let instructions = guardian_config.render_classifier_instructions(policy); let output = match sampler .sample(LunaSamplingRequest { diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs index 0125ba2f49..7405077330 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/config.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/config.rs @@ -1,8 +1,12 @@ use codex_config::GuardianPolicyLoader; +use codex_context_fragments::ContextualUserFragment; +use codex_context_fragments::RenderedFragment; use codex_core::config::Config; use codex_features::FeatureToml; use codex_features::GuardianV2ConfigToml; use codex_features::GuardianV2TranscriptConfigToml; +use codex_prompts::GuardianClassifierInstructions; +use codex_prompts::ResolvedModelMessages; use codex_protocol::openai_models::GuardianModelPolicy; use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::ReasoningEffort; @@ -14,7 +18,6 @@ use super::transcript::MAX_TOOL_ENTRY_TOKENS; use super::transcript::MAX_TOOL_TRANSCRIPT_TOKENS; use super::transcript::TranscriptConfig; use super::transcript::TranscriptSource; -use super::transcript::truncate_entry; pub(crate) const DEFAULT_MODEL_CONTEXT_ITEM_TOKENS: usize = 10_000; pub(crate) const DEFAULT_PARENT_COMPACTION_TOKENS: usize = 25_000; @@ -23,7 +26,6 @@ const MAX_MODEL_CONTEXT_ITEM_TOKENS: usize = 100_000; const DEFAULT_REVIEW_THRESHOLD: f64 = 0.5; const LEGACY_REVIEW_THRESHOLD: f64 = 0.8; const DEFAULT_MAX_TOOL_CALL_LAG: usize = 2; -pub(crate) const DEFAULT_CLASSIFIER_INSTRUCTIONS: &str = include_str!("classifier_instructions.md"); pub(crate) const CLASSIFICATION_OUTPUT_INSTRUCTIONS: &str = "Your first output token is the entire classification: `high` for high risk or `low` for low risk. Output that token immediately and nothing else."; #[derive(Clone, Debug, PartialEq)] @@ -250,9 +252,11 @@ impl GuardianV2Config { Ok(Self { local_overrides: configured.clone(), persist_scores: configured.persist_scores.unwrap_or(false), - classifier_instructions: configured - .classifier_instructions - .unwrap_or_else(|| DEFAULT_CLASSIFIER_INSTRUCTIONS.to_owned()), + classifier_instructions: configured.classifier_instructions.unwrap_or_else(|| { + ResolvedModelMessages::bundled() + .guardian_classifier_instructions() + .to_owned() + }), review_threshold, max_tool_call_lag: configured .max_tool_call_lag @@ -281,31 +285,14 @@ impl GuardianV2Config { }) } - pub(crate) fn render_classifier_instructions(&self, policy: &str) -> String { - let instructions = if self - .classifier_instructions - .contains("{{ tenant_policy_config }}") - { - self.classifier_instructions - .replace("{{ tenant_policy_config }}", policy) - } else { - format!( - "{}\n\n# Security Policy\n{policy}", - self.classifier_instructions - ) - }; - let instructions = if instructions - .trim_end() - .ends_with(CLASSIFICATION_OUTPUT_INSTRUCTIONS) - { - instructions - } else { - format!("{instructions}\n\n{CLASSIFICATION_OUTPUT_INSTRUCTIONS}") - }; - match self.max_classifier_instruction_tokens { - Some(max_tokens) => truncate_entry(&instructions, max_tokens), - None => instructions, - } + pub(crate) fn render_classifier_instructions(&self, policy: &str) -> RenderedFragment { + GuardianClassifierInstructions::new( + &self.classifier_instructions, + policy, + CLASSIFICATION_OUTPUT_INSTRUCTIONS, + self.max_classifier_instruction_tokens, + ) + .render_fragment() } } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs index a3dbe3ca2a..52161c4036 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/config_tests.rs @@ -1,17 +1,27 @@ use codex_features::GuardianV2ConfigToml; use codex_features::GuardianV2TranscriptConfigToml; +use codex_guardian_context::truncate_text as truncate_entry; +use codex_prompts::ResolvedModelMessages; +use codex_protocol::models::ContentItem; use codex_protocol::openai_models::GuardianV2ModelConfig; use codex_protocol::openai_models::GuardianV2TranscriptModelConfig; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use super::CLASSIFICATION_OUTPUT_INSTRUCTIONS; -use super::DEFAULT_CLASSIFIER_INSTRUCTIONS; use super::GuardianV2Config; -use crate::async_scorer::transcript::truncate_entry; + +fn rendered_classifier_text(config: &GuardianV2Config, policy: &str) -> String { + let (_, content) = config.render_classifier_instructions(policy).into_parts(); + let (ContentItem::InputText { text }, _) = content.into_parts() else { + panic!("classifier instructions must be text"); + }; + text +} #[test] fn template_policy_is_substituted_before_the_single_truncation() { + let instructions = ResolvedModelMessages::bundled().guardian_classifier_instructions(); for max_tokens in [256, 1_000, 2_000] { let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml { max_classifier_instruction_tokens: Some(max_tokens), @@ -20,23 +30,21 @@ fn template_policy_is_substituted_before_the_single_truncation() { .unwrap(); let policy = "The actual tenant policy."; assert_eq!( - config.render_classifier_instructions(policy), + rendered_classifier_text(&config, policy), truncate_entry( - &DEFAULT_CLASSIFIER_INSTRUCTIONS.replace("{{ tenant_policy_config }}", policy), + &instructions.replace("{{ tenant_policy_config }}", policy), max_tokens, ) ); - assert_eq!( - config.classifier_instructions, - DEFAULT_CLASSIFIER_INSTRUCTIONS - ); + assert_eq!(config.classifier_instructions, instructions); } } #[test] fn evaluated_configuration_preserves_rendered_prompt_and_gate() { + let instructions = ResolvedModelMessages::bundled().guardian_classifier_instructions(); let config = GuardianV2Config::from_overrides(GuardianV2ConfigToml { - classifier_instructions: Some(DEFAULT_CLASSIFIER_INSTRUCTIONS.to_owned()), + classifier_instructions: Some(instructions.to_owned()), review_threshold: Some(0.5), reasoning_effort: Some(ReasoningEffort::Low), max_classifier_instruction_tokens: Some(30_000), @@ -46,41 +54,59 @@ fn evaluated_configuration_preserves_rendered_prompt_and_gate() { assert_eq!(config.review_threshold, 0.5); assert_eq!(config.reasoning_effort, ReasoningEffort::Low); assert_eq!(config.max_classifier_instruction_tokens, Some(30_000)); - assert_eq!( - config.classifier_instructions, - DEFAULT_CLASSIFIER_INSTRUCTIONS - ); + assert_eq!(config.classifier_instructions, instructions); for policy in ["Tenant policy.".to_owned(), "é".repeat(80_000)] { // This is the exact pre-review rendering path used by the eval config. let previous = truncate_entry( - &truncate_entry(DEFAULT_CLASSIFIER_INSTRUCTIONS, /*max_tokens*/ 30_000) + &truncate_entry(instructions, /*max_tokens*/ 30_000) .replace("{{ tenant_policy_config }}", &policy), /*max_tokens*/ 30_000, ); - assert_eq!(config.render_classifier_instructions(&policy), previous); + assert_eq!(rendered_classifier_text(&config, &policy), previous); } } #[test] fn model_prompt_and_explicit_threshold_precedence_are_preserved() { - let defaults = GuardianV2ModelConfig { - classifier_instructions: Some("Model-owned instructions.".to_owned()), - ..Default::default() - }; let builtin = GuardianV2Config::from_overrides(GuardianV2ConfigToml::default()).unwrap(); assert_eq!(builtin.review_threshold, 0.5); - assert_eq!( - builtin - .with_model_defaults(Some(&defaults)) - .unwrap() - .review_threshold, - 0.8, - ); + for prompt in [ + "Model-owned instructions.", + "", + ResolvedModelMessages::bundled().guardian_classifier_instructions(), + ] { + let defaults = GuardianV2ModelConfig { + classifier_instructions: Some(prompt.to_owned()), + ..Default::default() + }; + let resolved = builtin.with_model_defaults(Some(&defaults)).unwrap(); + assert_eq!( + ( + resolved.classifier_instructions.clone(), + resolved.review_threshold + ), + (prompt.to_owned(), 0.8), + ); + assert_eq!( + resolved + .with_model_defaults(/*model_defaults*/ None) + .unwrap(), + builtin + ); + + let local = GuardianV2Config::from_overrides(GuardianV2ConfigToml { + classifier_instructions: Some(String::new()), + ..Default::default() + }) + .unwrap(); + assert_eq!(local.with_model_defaults(Some(&defaults)).unwrap(), local); + } let model_threshold = GuardianV2ModelConfig { + classifier_instructions: Some("Model-owned instructions.".to_owned()), review_threshold_basis_points: Some(6_000), - ..defaults + ..Default::default() }; assert_eq!( builtin @@ -124,7 +150,7 @@ fn legacy_classifier_prompts_keep_the_output_contract_after_truncation() { .unwrap(); for config in [local_override, model_override] { - let rendered = config.render_classifier_instructions(policy); + let rendered = rendered_classifier_text(&config, policy); assert_eq!( rendered, truncate_entry( @@ -197,7 +223,7 @@ fn model_runtime_settings_preserve_local_overrides() { .with_model_defaults(Some(&uncapped_defaults)) .unwrap(); assert_eq!( - uncapped.render_classifier_instructions("Tenant policy."), + rendered_classifier_text(&uncapped, "Tenant policy."), format!( "{prompt}\n\n# Security Policy\nTenant policy.\n\n{CLASSIFICATION_OUTPUT_INSTRUCTIONS}" ) diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs index 7fb63a4e6f..2f4c531b29 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/extension_tests.rs @@ -24,6 +24,7 @@ use codex_extension_api::ToolName; use codex_extension_api::ToolPayload; use codex_extension_api::ToolStartInput; use codex_features::Feature; +use codex_guardian_context::truncate_text as truncate_entry; use codex_history::RolloutItem; use codex_login::AuthManager; use codex_login::CodexAuth; @@ -31,6 +32,7 @@ use codex_login::ExternalAuth; use codex_login::ExternalAuthFuture; use codex_login::ExternalAuthRefreshContext; use codex_model_provider_info::ModelProviderInfo; +use codex_prompts::ResolvedModelMessages; use codex_protocol::ResponseItemId; use codex_protocol::ThreadId; use codex_protocol::config_types::ApprovalsReviewer; @@ -87,7 +89,6 @@ use crate::async_scorer::sampler::tests::proxy_websocket_servers_with_http; use crate::async_scorer::score::GuardianV2ScoreProgress; use crate::async_scorer::transcript::MAX_MESSAGE_ENTRY_TOKENS; use crate::async_scorer::transcript::MAX_TOOL_ENTRY_TOKENS; -use crate::async_scorer::transcript::truncate_entry; use codex_features::GuardianV2ReviewScopeConfigToml; use codex_protocol::openai_models::GuardianModelPolicy; @@ -1286,6 +1287,9 @@ classifier_instructions = "Predict future violations.\n# Security Policy\n{{ ten "type": "message", "id": request["input"][1]["id"], "role": "developer", + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, "content": [{ "type": "input_text", "text": format!( @@ -1325,6 +1329,9 @@ max_classifier_instruction_tokens = 256 "type": "message", "id": request["input"][1]["id"], "role": "developer", + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, "content": [{ "type": "input_text", "text": truncate_entry( @@ -1406,6 +1413,9 @@ max_recent_non_user_entries = 8 "type": "message", "id": request["input"][1]["id"], "role": "developer", + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, "content": [{ "type": "input_text", "text": format!( @@ -1805,6 +1815,9 @@ async fn contributor_uses_model_defaults_and_preserves_local_overrides() -> Resu "type": "message", "id": request["input"][1]["id"], "role": "developer", + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, "content": [{ "type": "input_text", "text": format!( @@ -1978,9 +1991,12 @@ async fn assert_luna_pool_context(thread_context_enabled: bool) -> Result<()> { "type": "message", "id": request["input"][1]["id"], "role": "developer", + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, "content": [{ "type": "input_text", - "text": crate::async_scorer::config::DEFAULT_CLASSIFIER_INSTRUCTIONS.replace( + "text": ResolvedModelMessages::bundled().guardian_classifier_instructions().replace( "{{ tenant_policy_config }}", TEST_GUARDIAN_POLICY, ), @@ -2584,9 +2600,12 @@ async fn contributor_uses_catalog_policy_without_a_configured_override() -> Resu "type": "message", "id": request["input"][1]["id"], "role": "developer", + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, "content": [{ "type": "input_text", - "text": crate::async_scorer::config::DEFAULT_CLASSIFIER_INSTRUCTIONS.replace( + "text": ResolvedModelMessages::bundled().guardian_classifier_instructions().replace( "{{ tenant_policy_config }}", TEST_CATALOG_GUARDIAN_POLICY, ), @@ -2627,9 +2646,12 @@ async fn contributor_preserves_uncapped_classifier_instructions() -> Result<()> "type": "message", "id": request["input"][1]["id"], "role": "developer", + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, "content": [{ "type": "input_text", - "text": crate::async_scorer::config::DEFAULT_CLASSIFIER_INSTRUCTIONS + "text": ResolvedModelMessages::bundled().guardian_classifier_instructions() .replace("{{ tenant_policy_config }}", &guardian_policy), }], }) @@ -2658,7 +2680,8 @@ async fn contributor_bounds_configured_policy_in_luna_developer_instructions() - .as_str() .expect("Luna request should contain developer instructions"); - let (prefix, suffix) = crate::async_scorer::config::DEFAULT_CLASSIFIER_INSTRUCTIONS + let (prefix, suffix) = ResolvedModelMessages::bundled() + .guardian_classifier_instructions() .split_once("{{ tenant_policy_config }}") .expect("default classifier prompt should contain the policy placeholder"); assert!(instructions.starts_with(&format!("{prefix}Reject unsafe uploads."))); @@ -3064,7 +3087,8 @@ async fn assert_parent_compaction_reuse(thread_context_enabled: bool) -> Result< assert_eq!(request["input"][0]["type"], "additional_tools"); let developer_message = &request["input"][1]; assert_eq!(developer_message["role"], "developer"); - let (prefix, _) = crate::async_scorer::config::DEFAULT_CLASSIFIER_INSTRUCTIONS + let (prefix, _) = ResolvedModelMessages::bundled() + .guardian_classifier_instructions() .split_once("{{ tenant_policy_config }}") .expect("default classifier prompt should contain the policy placeholder"); assert!( diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs index 5c88c51917..2861b7bea2 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler.rs @@ -15,6 +15,7 @@ use codex_api::ApiError; use codex_api::Reasoning; use codex_api::ReasoningContext; use codex_api::ResponsesApiRequest; +use codex_context_fragments::RenderedFragment; use codex_extension_api::ExtensionMetrics; use codex_http_client::HttpClientFactory; use codex_login::AgentIdentityAuthPolicy; @@ -66,8 +67,8 @@ pub struct LunaSamplerConfig { pub struct LunaSamplingRequest { /// ID of the response handling the classified tool. pub parent_response_id: Option, - /// Trusted instructions describing the requested classification. - pub instructions: String, + /// Trusted classifier instructions with their role and content attribution. + pub instructions: RenderedFragment, /// Composed evidence messages, with roles, annotations and content order intact. pub input: Vec, /// Opaque parent compaction to reuse only for compatible model configurations. @@ -166,15 +167,7 @@ impl LunaSampler { role: "developer".to_owned(), tools: Vec::new(), }, - ResponseItem::Message { - id: None, - role: "developer".to_owned(), - content: vec![ContentItem::InputText { - text: request.instructions, - }], - phase: None, - internal_chat_message_metadata_passthrough: None, - }, + ResponseItem::from(request.instructions), ]; if let Some(parent_compaction) = request.parent_compaction { input.push(parent_compaction); diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs index e7183f4b21..f63c170620 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/sampler_tests.rs @@ -1,4 +1,5 @@ use anyhow::Result; +use codex_context_fragments::RenderedFragment; use codex_extension_api::ContextualUserFragment; use codex_extension_api::ExtensionMetrics; use codex_guardian_context::PreviousReviews; @@ -12,6 +13,7 @@ use codex_login::ExternalAuthFuture; use codex_login::ExternalAuthRefreshContext; use codex_model_provider::create_model_provider; use codex_model_provider_info::ModelProviderInfo; +use codex_prompts::GuardianClassifierInstructions; use codex_protocol::ResponseItemId; use codex_protocol::ThreadId; use codex_protocol::models::ContentItem; @@ -220,10 +222,39 @@ async fn connect_sampler(config: LunaSamplerConfig) -> Result { Ok(sampler) } +fn classifier_instructions() -> RenderedFragment { + GuardianClassifierInstructions::new( + "Classify using {{ tenant_policy_config }}.", + "the tenant policy", + "Return high for high risk or low for low risk.", + /*max_tokens*/ None, + ) + .render_fragment() +} + +fn assert_classifier_instructions(request: &serde_json::Value) { + let mut instructions = request["input"][1].clone(); + instructions.as_object_mut().unwrap().remove("id"); + assert_eq!( + instructions, + json!({ + "type": "message", + "role": "developer", + "content": [{ + "type": "input_text", + "text": "Classify using the tenant policy.\n\nReturn high for high risk or low for low risk.", + }], + "internal_chat_message_metadata_passthrough": { + "content_item_kinds": ["guardian.classifier_instructions"], + }, + }) + ); +} + pub(super) fn sample_request(parent_turn_id: &str) -> LunaSamplingRequest { LunaSamplingRequest { parent_response_id: None, - instructions: "Return high for high risk or low for low risk.".to_owned(), + instructions: classifier_instructions(), input: vec![responses::user_message_item( "The user requested a README summary.", )], @@ -519,7 +550,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications let first = sampler .sample(LunaSamplingRequest { parent_response_id: None, - instructions: "Return high for high risk or low for low risk.".to_owned(), + instructions: classifier_instructions(), input: vec![ResponseItem::Message { id: None, role: "user".to_owned(), @@ -560,7 +591,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications let second = sampler .sample(LunaSamplingRequest { parent_response_id: None, - instructions: "Return high for high risk or low for low risk.".to_owned(), + instructions: classifier_instructions(), input: vec![responses::user_message_item( "The user requested a source review.", )], @@ -606,6 +637,7 @@ async fn preconnected_sampler_reuses_authenticated_websocket_for_classifications assert_eq!(request["type"], "response.create"); assert_eq!(request["model"], "gpt-5.6-luna"); assert_eq!(request["input"][0]["tools"], json!([])); + assert_classifier_instructions(&request); assert_eq!(request["tool_choice"], "none"); assert!(request.get("text").is_none()); assert_eq!(request["prompt_cache_key"], "guardian-v2:thread-1"); @@ -738,7 +770,7 @@ async fn sampler_returns_classification_token_before_terminal_response_events() Duration::from_secs(2), sampler.sample(LunaSamplingRequest { parent_response_id: None, - instructions: "Return high for high risk or low for low risk.".to_owned(), + instructions: classifier_instructions(), input: vec![responses::user_message_item( "The user requested a README summary.", )], @@ -1030,6 +1062,7 @@ async fn sampler_uses_http_with_a_fresh_identity_when_warm_connections_expire() ]); assert_eq!(thread_ids.len(), 3); responses::assert_parent_turn(&request.body_json(), Some("turn-2"))?; + assert_classifier_instructions(&request.body_json()); assert_eq!(second.single_connection().len(), 1); Ok(()) } diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs index cbb81a3d8d..5b1678b0e0 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript.rs @@ -20,7 +20,6 @@ use codex_guardian_context::TranscriptImageInput; use codex_guardian_context::TranscriptRetentionConfig; use codex_guardian_context::TrustedTool; use codex_guardian_context::default_registry; -pub(crate) use codex_guardian_context::truncate_text as truncate_entry; pub(crate) const MAX_MESSAGE_ENTRY_TOKENS: usize = ContextProfile::asynchronous() .transcript diff --git a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs index a3add7fc0a..b7f0bc7263 100644 --- a/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs +++ b/codex-rs/ext/guardian-v2/src/async_scorer/transcript_tests.rs @@ -2,6 +2,7 @@ use super::ContextInput; use codex_extension_api::ConversationHistorySnapshot; use codex_extension_api::ResponseItem; use codex_guardian_context::ContextTarget; +use codex_guardian_context::truncate_text as truncate_entry; use codex_protocol::AgentPath; use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::ContentItem; @@ -23,7 +24,6 @@ use super::MAX_TOOL_ENTRY_TOKENS; use super::MAX_TOOL_TRANSCRIPT_TOKENS; use super::TranscriptConfig; use super::TranscriptSource; -use super::truncate_entry; struct TestConversationHistory<'a>(&'a [ResponseItem]); diff --git a/codex-rs/models-manager/Cargo.toml b/codex-rs/models-manager/Cargo.toml index ab1bf70c8a..159f750234 100644 --- a/codex-rs/models-manager/Cargo.toml +++ b/codex-rs/models-manager/Cargo.toml @@ -14,11 +14,11 @@ workspace = true [dependencies] chrono = { workspace = true, features = ["serde"] } -codex-collaboration-mode-templates = { workspace = true } codex-http-client = { workspace = true } codex-login = { workspace = true } codex-otel = { workspace = true } codex-protocol = { workspace = true } +codex-prompts = { workspace = true } codex-utils-output-truncation = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/codex-rs/models-manager/src/collaboration_mode_presets.rs b/codex-rs/models-manager/src/collaboration_mode_presets.rs index b8fc3da738..454418ad88 100644 --- a/codex-rs/models-manager/src/collaboration_mode_presets.rs +++ b/codex-rs/models-manager/src/collaboration_mode_presets.rs @@ -1,31 +1,26 @@ -use codex_collaboration_mode_templates::DEFAULT as COLLABORATION_MODE_DEFAULT; -use codex_collaboration_mode_templates::PLAN as COLLABORATION_MODE_PLAN; +use codex_prompts::ResolvedModelMessages; use codex_protocol::config_types::CollaborationModeMask; use codex_protocol::config_types::ModeKind; use codex_protocol::openai_models::ReasoningEffort; pub fn builtin_collaboration_mode_presets() -> Vec { - vec![plan_preset(), default_preset()] -} - -fn plan_preset() -> CollaborationModeMask { - CollaborationModeMask { - name: ModeKind::Plan.display_name().to_string(), - mode: Some(ModeKind::Plan), - model: None, - reasoning_effort: Some(Some(ReasoningEffort::Medium)), - developer_instructions: Some(Some(COLLABORATION_MODE_PLAN.to_string())), - } -} - -fn default_preset() -> CollaborationModeMask { - CollaborationModeMask { - name: ModeKind::Default.display_name().to_string(), - mode: Some(ModeKind::Default), - model: None, - reasoning_effort: None, - developer_instructions: Some(Some(COLLABORATION_MODE_DEFAULT.to_string())), - } + let messages = ResolvedModelMessages::bundled().collaboration_modes(); + vec![ + CollaborationModeMask { + name: ModeKind::Plan.display_name().to_string(), + mode: Some(ModeKind::Plan), + model: None, + reasoning_effort: Some(Some(ReasoningEffort::Medium)), + developer_instructions: Some(Some(messages.plan.text().to_string())), + }, + CollaborationModeMask { + name: ModeKind::Default.display_name().to_string(), + mode: Some(ModeKind::Default), + model: None, + reasoning_effort: None, + developer_instructions: Some(Some(messages.default.text().to_string())), + }, + ] } #[cfg(test)] diff --git a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs index 46ea05828d..40c27091ab 100644 --- a/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs +++ b/codex-rs/models-manager/src/collaboration_mode_presets_tests.rs @@ -3,22 +3,26 @@ use pretty_assertions::assert_eq; #[test] fn preset_names_use_mode_display_names() { - assert_eq!(plan_preset().name, ModeKind::Plan.display_name()); - assert_eq!(default_preset().name, ModeKind::Default.display_name()); - assert_eq!(plan_preset().model, None); - assert_eq!( - plan_preset().reasoning_effort, - Some(Some(ReasoningEffort::Medium)) - ); - assert_eq!(default_preset().model, None); - assert_eq!(default_preset().reasoning_effort, None); + let presets = builtin_collaboration_mode_presets(); + let [plan, default] = presets.as_slice() else { + panic!("expected Plan and Default presets"); + }; + assert_eq!(plan.name, ModeKind::Plan.display_name()); + assert_eq!(default.name, ModeKind::Default.display_name()); + assert_eq!(plan.model, None); + assert_eq!(plan.reasoning_effort, Some(Some(ReasoningEffort::Medium))); + assert_eq!(default.model, None); + assert_eq!(default.reasoning_effort, None); } #[test] fn default_mode_instructions_follow_user_input_tool_availability() { - let default_instructions = default_preset() + let presets = builtin_collaboration_mode_presets(); + let default_instructions = presets[1] .developer_instructions + .as_ref() .expect("default preset should include instructions") + .as_ref() .expect("default instructions should be set"); assert!(default_instructions.contains( diff --git a/codex-rs/models-manager/src/model_info.rs b/codex-rs/models-manager/src/model_info.rs index b9ead2bdf7..ece19ad26a 100644 --- a/codex-rs/models-manager/src/model_info.rs +++ b/codex-rs/models-manager/src/model_info.rs @@ -44,20 +44,7 @@ pub fn with_config_overrides(mut model: ModelInfo, config: &ModelsManagerConfig) } if let Some(base_instructions) = &config.base_instructions { - let model_messages = model.model_messages.get_or_insert(ModelMessages { - persistent_instructions: None, - tools: None, - instructions_template: None, - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, - }); + let model_messages = model.model_messages.get_or_insert_default(); model_messages.instructions_template = Some(base_instructions.clone()); model_messages.instructions_variables = None; } else if config.personality == Some(Personality::None) @@ -163,18 +150,8 @@ pub fn model_info_from_slug(slug: &str) -> ModelInfo { fn local_model_messages() -> ModelMessages { ModelMessages { - persistent_instructions: None, - tools: None, instructions_template: Some(BASE_INSTRUCTIONS.to_string()), - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, + ..Default::default() } } diff --git a/codex-rs/models-manager/src/model_info_tests.rs b/codex-rs/models-manager/src/model_info_tests.rs index 69fabe659c..071975b133 100644 --- a/codex-rs/models-manager/src/model_info_tests.rs +++ b/codex-rs/models-manager/src/model_info_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::ModelsManagerConfig; +use codex_prompts::render_model_instructions; use codex_protocol::config_types::Personality; use codex_protocol::openai_models::ApprovalMessages; use codex_protocol::openai_models::AutoReviewMessages; @@ -79,7 +80,7 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { browser_use: Some("# Browser policy\n\n{{literal_markdown}}\n".to_string()), computer_use: Some(" # Native policy\r\n\n${native_markdown}\n".to_string()), }; - model.model_messages = Some(ModelMessages { + let mut messages = ModelMessages { persistent_instructions: Some(persistent_instructions.to_string()), tools: Some(ToolMessages { send_user_message_async: Some(ToolMessage { @@ -92,15 +93,16 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { personality_friendly: Some("friendly".to_string()), personality_pragmatic: Some("pragmatic".to_string()), }), - approvals: Some(approvals.clone()), - collaboration_modes: Some(collaboration_modes.clone()), - auto_review: Some(auto_review.clone()), - permissions: Some(permissions.clone()), - multi_agent: Some(multi_agent.clone()), - token_budget: Some(token_budget.clone()), - confirmation_policies: Some(confirmation_policies.clone()), - guardian_v2: Some(guardian_v2.clone()), - }); + approvals: Some(approvals), + collaboration_modes: Some(collaboration_modes), + auto_review: Some(auto_review), + permissions: Some(permissions), + multi_agent: Some(multi_agent), + token_budget: Some(token_budget), + confirmation_policies: Some(confirmation_policies), + guardian_v2: Some(guardian_v2), + }; + model.model_messages = Some(messages.clone()); let config = ModelsManagerConfig { base_instructions: Some(override_instructions.to_string()), personality: Some(Personality::None), @@ -109,36 +111,16 @@ fn base_instruction_override_is_literal_and_preserves_catalog_messages() { let updated = with_config_overrides(model, &config); - assert_eq!( - updated.model_messages, - Some(ModelMessages { - persistent_instructions: Some(persistent_instructions.to_string()), - tools: Some(ToolMessages { - send_user_message_async: Some(ToolMessage { - description: Some(async_message_description.to_string()), - }), - }), - instructions_template: Some(override_instructions.to_string()), - instructions_variables: None, - approvals: Some(approvals), - collaboration_modes: Some(collaboration_modes), - auto_review: Some(auto_review), - permissions: Some(permissions), - multi_agent: Some(multi_agent), - token_budget: Some(token_budget), - confirmation_policies: Some(confirmation_policies), - guardian_v2: Some(guardian_v2), - }) - ); - assert_eq!( - updated.get_model_instructions(Some(Personality::None)), - override_instructions - ); + messages.instructions_template = Some(override_instructions.to_string()); + messages.instructions_variables = None; + assert_eq!(updated.model_messages, Some(messages)); + assert_eq!(render_model_instructions(&updated), override_instructions); } #[test] fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { - let cases = [ + let config = config_with_personality(Some(Personality::None)); + for (instructions, expected) in [ ( "Intro\n\n# Personality\n\nRemove me\n\n## Writing Style\n\nRemove me too\n\n# Safety\n\nKeep me", "Intro\n\n# Safety\n\nKeep me", @@ -156,39 +138,30 @@ fn personality_none_strips_catalog_instruction_sources_through_the_next_h1() { "Intro\r\n\r\n# Personality\r\n\r\nRemove me\r\n\r\n## Writing Style\r\n\r\nRemove me too\r\n\r\n# General\r\n\r\nKeep me", "Intro\r\n\r\n# General\r\n\r\nKeep me", ), - ]; - let config = config_with_personality(Some(Personality::None)); - - for (instructions, expected) in cases { - let mut model = model_info_from_slug("unknown-model"); - model.model_messages = Some(ModelMessages { - persistent_instructions: None, - tools: None, + ] { + let mut messages = ModelMessages { instructions_template: Some(instructions.to_string()), - instructions_variables: None, - approvals: None, - collaboration_modes: None, - auto_review: None, - permissions: None, - multi_agent: None, - token_budget: None, - confirmation_policies: None, - guardian_v2: None, - }); + instructions_variables: Some(ModelInstructionsVariables { + personality_default: Some("default".to_string()), + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: Some("pragmatic".to_string()), + }), + persistent_instructions: Some(String::new()), + approvals: Some(ApprovalMessages { + on_request: Some("user approvals".to_string()), + on_request_auto_review: None, + never: None, + unless_trusted: None, + }), + ..Default::default() + }; + let mut model = model_info_from_slug("unknown-model"); + model.model_messages = Some(messages.clone()); let updated = with_config_overrides(model, &config); - let instructions_template = updated - .model_messages - .as_ref() - .and_then(|messages| messages.instructions_template.as_deref()); - assert_eq!( - ( - updated.get_model_instructions(Some(Personality::None)), - instructions_template - ), - (expected.to_string(), Some(expected)) - ); + messages.instructions_template = Some(expected.to_string()); + assert_eq!(updated.model_messages, Some(messages)); } } @@ -210,7 +183,7 @@ fn baked_personality_section_is_preserved_without_explicit_none() { .instructions_template = Some(instructions.to_string()); assert_eq!( - with_config_overrides(model, &config).get_model_instructions(config.personality), + render_model_instructions(&with_config_overrides(model, &config)), instructions ); } @@ -239,17 +212,14 @@ fn explicit_empty_base_instructions_stay_empty_with_personality_none() { .and_then(|messages| messages.instructions_template.as_deref()), Some("") ); - assert_eq!(updated.get_model_instructions(config.personality), ""); + assert_eq!(render_model_instructions(&updated), ""); } #[test] fn unknown_model_uses_builtin_instruction_template() { let model = model_info_from_slug("unknown-model"); - assert_eq!( - model.get_model_instructions(/*personality*/ None), - BASE_INSTRUCTIONS - ); + assert_eq!(render_model_instructions(&model), BASE_INSTRUCTIONS); assert!(model.used_fallback_model_metadata); } diff --git a/codex-rs/prompts/Cargo.toml b/codex-rs/prompts/Cargo.toml index 6302d49e2f..7f0b492a43 100644 --- a/codex-rs/prompts/Cargo.toml +++ b/codex-rs/prompts/Cargo.toml @@ -14,12 +14,15 @@ workspace = true [dependencies] anyhow = { workspace = true } +codex-collaboration-mode-templates = { workspace = true } codex-context-fragments = { workspace = true } -codex-git-utils = { workspace = true } codex-execpolicy = { workspace = true } +codex-git-utils = { workspace = true } +codex-guardian-context = { workspace = true } codex-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-template = { workspace = true } +tracing = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/prompts/src/guardian_instructions.rs b/codex-rs/prompts/src/guardian_instructions.rs new file mode 100644 index 0000000000..de6252a159 --- /dev/null +++ b/codex-rs/prompts/src/guardian_instructions.rs @@ -0,0 +1,144 @@ +//! Composes Guardian prompts and rejection feedback from selected text and runtime inputs. +//! Callers select policy overrides, output contracts, calibration, and truncation limits. + +use codex_context_fragments::ContextualUserFragment; +use codex_guardian_context::truncate_text; +use codex_protocol::models::ContentItemKind; + +const TENANT_POLICY_CONFIG_PLACEHOLDER: &str = "{{ tenant_policy_config }}"; + +/// Reviewer base instructions composed from the effective policy and caller-owned output contract. +#[derive(Debug, Clone, Copy)] +pub struct GuardianPolicyInstructions<'a> { + tenant_policy_config: &'a str, + policy_template: &'a str, + output_contract: &'a str, +} + +impl<'a> GuardianPolicyInstructions<'a> { + pub fn new( + tenant_policy_config: &'a str, + policy_template: &'a str, + output_contract: &'a str, + ) -> Self { + Self { + tenant_policy_config, + policy_template, + output_contract, + } + } +} + +impl ContextualUserFragment for GuardianPolicyInstructions<'_> { + fn role(&self) -> &'static str { + "developer" + } + + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("guardian.policy_instructions".to_owned()) + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + let Self { + tenant_policy_config, + policy_template, + output_contract, + } = *self; + let prompt = policy_template.trim_end().replace( + TENANT_POLICY_CONFIG_PLACEHOLDER, + tenant_policy_config.trim(), + ); + format!("{prompt}\n\n{output_contract}\n") + } +} + +/// Classifier instructions composed from selected text, policy, and the caller-owned output contract. +/// A supplied token limit applies after the complete instructions are composed. +#[derive(Debug, Clone, Copy)] +pub struct GuardianClassifierInstructions<'a> { + classifier_instructions: &'a str, + policy: &'a str, + output_contract: &'a str, + max_tokens: Option, +} + +impl<'a> GuardianClassifierInstructions<'a> { + pub fn new( + classifier_instructions: &'a str, + policy: &'a str, + output_contract: &'a str, + max_tokens: Option, + ) -> Self { + Self { + classifier_instructions, + policy, + output_contract, + max_tokens, + } + } +} + +impl ContextualUserFragment for GuardianClassifierInstructions<'_> { + fn role(&self) -> &'static str { + "developer" + } + + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("guardian.classifier_instructions".to_owned()) + } + + fn markers(&self) -> (&'static str, &'static str) { + Self::type_markers() + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + let Self { + classifier_instructions, + policy, + output_contract, + max_tokens, + } = *self; + let instructions = if classifier_instructions.contains(TENANT_POLICY_CONFIG_PLACEHOLDER) { + classifier_instructions.replace(TENANT_POLICY_CONFIG_PLACEHOLDER, policy) + } else { + format!("{classifier_instructions}\n\n# Security Policy\n{policy}") + }; + let instructions = if instructions.trim_end().ends_with(output_contract) { + instructions + } else { + format!("{instructions}\n\n{output_contract}") + }; + match max_tokens { + Some(max_tokens) => truncate_text(&instructions, max_tokens), + None => instructions, + } + } +} + +/// Renders feedback for an action the caller has already decided to reject. +pub fn render_guardian_rejection(rationale: &str, rejection_instructions: &str) -> String { + let rationale = if rationale.trim().is_empty() { + "Auto-reviewer denied the action without a specific rationale." + } else { + rationale.trim() + }; + format!( + "This action was rejected due to unacceptable risk.\nReason: {rationale}\n{rejection_instructions}" + ) +} + +#[cfg(test)] +#[path = "guardian_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/prompts/src/guardian_instructions_tests.rs b/codex-rs/prompts/src/guardian_instructions_tests.rs new file mode 100644 index 0000000000..8805a9e9fd --- /dev/null +++ b/codex-rs/prompts/src/guardian_instructions_tests.rs @@ -0,0 +1,86 @@ +//! Covers policy substitution, terminal output contracts, truncation, and rejection-text overrides. + +use codex_context_fragments::ContextualUserFragment; +use codex_guardian_context::truncate_text; +use pretty_assertions::assert_eq; + +use super::GuardianClassifierInstructions; +use super::GuardianPolicyInstructions; +use super::render_guardian_rejection; + +#[test] +fn reviewer_policy_substitution_preserves_template_layout_and_appends_contract() { + for (template, policy, expected) in [ + ( + " Review:\n{{ tenant_policy_config }}\nAgain: {{ tenant_policy_config }}\n \t", + " \nTenant policy.\n ", + " Review:\nTenant policy.\nAgain: Tenant policy.\n\nReview contract.\n", + ), + ( + "Policy: {{ tenant_policy_config }}", + "", + "Policy: \n\nReview contract.\n", + ), + ("", "Tenant policy.", "\n\nReview contract.\n"), + ] { + assert_eq!( + GuardianPolicyInstructions::new(policy, template, "Review contract.").render(), + expected, + ); + } +} + +#[test] +fn classifier_policy_substitution_and_legacy_append_keep_one_terminal_contract_before_truncation() { + for (instructions, policy, expected) in [ + ( + "Classify: {{ tenant_policy_config }}", + " Tenant policy.\n", + "Classify: Tenant policy.\n\n\nClassifier contract.", + ), + ( + "Classify: {{ tenant_policy_config }}\n\nClassifier contract.\n \t", + "Tenant policy.", + "Classify: Tenant policy.\n\nClassifier contract.\n \t", + ), + ( + "Legacy classifier.", + "Tenant policy.", + "Legacy classifier.\n\n# Security Policy\nTenant policy.\n\nClassifier contract.", + ), + ] { + for max_tokens in [None, Some(20)] { + assert_eq!( + GuardianClassifierInstructions::new( + instructions, + policy, + "Classifier contract.", + max_tokens, + ) + .render(), + match max_tokens { + Some(max_tokens) => truncate_text(expected, max_tokens), + None => expected.to_owned(), + }, + ); + } + } +} + +#[test] +fn rejection_feedback_trims_rationale_and_preserves_instruction_overrides() { + for (rationale, instructions, expected) in [ + ( + " \nSensitive data would leave the workspace.\t ", + " Ask for approval.\n", + "This action was rejected due to unacceptable risk.\nReason: Sensitive data would leave the workspace.\n Ask for approval.\n", + ), + ( + " \n\t", + "", + "This action was rejected due to unacceptable risk.\nReason: Auto-reviewer denied the action without a specific rationale.\n", + ), + ] { + assert_eq!(render_guardian_rejection(rationale, instructions), expected); + } +} diff --git a/codex-rs/prompts/src/lib.rs b/codex-rs/prompts/src/lib.rs index b7b4e444e2..22db073498 100644 --- a/codex-rs/prompts/src/lib.rs +++ b/codex-rs/prompts/src/lib.rs @@ -1,11 +1,26 @@ mod compact; +mod guardian_instructions; +mod model_instructions; +mod model_messages; +mod multi_agent_instructions; mod permissions_instructions; mod realtime; mod review_exit; mod review_request; +mod update_plan_instructions; pub use compact::SUMMARIZATION_PROMPT; pub use compact::SUMMARY_PREFIX; +pub use guardian_instructions::GuardianClassifierInstructions; +pub use guardian_instructions::GuardianPolicyInstructions; +pub use guardian_instructions::render_guardian_rejection; +pub use model_instructions::render_model_instructions; +pub use model_messages::ResolvedAutoReviewMessages; +pub use model_messages::ResolvedCollaborationModeMessages; +pub use model_messages::ResolvedMessage; +pub use model_messages::ResolvedModelMessages; +pub use model_messages::ResolvedMultiAgentMessages; +pub use multi_agent_instructions::MultiAgentRoleInstructions; pub use permissions_instructions::ApprovalPromptContext; pub use permissions_instructions::PermissionsInstructions; pub use realtime::BACKEND_PROMPT; @@ -18,3 +33,4 @@ pub use review_request::ResolvedReviewRequest; pub use review_request::resolve_review_request; pub use review_request::review_prompt; pub use review_request::user_facing_hint; +pub use update_plan_instructions::without_update_plan_instructions; diff --git a/codex-rs/prompts/src/model_instructions.rs b/codex-rs/prompts/src/model_instructions.rs new file mode 100644 index 0000000000..a169e408b6 --- /dev/null +++ b/codex-rs/prompts/src/model_instructions.rs @@ -0,0 +1,21 @@ +//! Returns literal model instructions from the captured model metadata. +//! Missing templates produce a warning; supplied empty templates remain valid input. + +use crate::ResolvedModelMessages; +use codex_protocol::openai_models::ModelInfo; + +/// Renders base instructions from the caller's captured model, retaining missing-template warnings. +pub fn render_model_instructions(model_info: &ModelInfo) -> String { + let template = ResolvedModelMessages::from_model(model_info).instructions_template(); + if template.is_none() { + tracing::warn!( + model = %model_info.slug, + "Model has no instruction template; returning empty instructions." + ); + } + template.unwrap_or_default().to_owned() +} + +#[cfg(test)] +#[path = "model_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/prompts/src/model_instructions_tests.rs b/codex-rs/prompts/src/model_instructions_tests.rs new file mode 100644 index 0000000000..9fa9bb5879 --- /dev/null +++ b/codex-rs/prompts/src/model_instructions_tests.rs @@ -0,0 +1,105 @@ +//! Covers literal instruction templates and missing-versus-empty inputs. + +use super::*; +use codex_protocol::openai_models::ConfigShellToolType; +use codex_protocol::openai_models::ModelInstructionsVariables; +use codex_protocol::openai_models::ModelMessages; +use codex_protocol::openai_models::ModelVisibility; +use codex_protocol::openai_models::TruncationPolicyConfig; +use pretty_assertions::assert_eq; + +fn test_model(model_messages: Option) -> ModelInfo { + ModelInfo { + slug: "test-model".to_string(), + display_name: "Test Model".to_string(), + description: None, + default_reasoning_level: None, + supported_reasoning_levels: vec![], + shell_type: ConfigShellToolType::UnifiedExec, + visibility: ModelVisibility::List, + supported_in_api: true, + priority: 1, + additional_speed_tiers: vec![], + service_tiers: vec![], + default_service_tier: None, + available_access_programs: None, + availability_nux: None, + upgrade: None, + model_messages, + include_skills_usage_instructions: false, + include_plugin_usage_instructions: false, + include_apps_usage_instructions: false, + supports_reasoning_summary_parameter: true, + default_reasoning_summary: Default::default(), + support_verbosity: false, + default_verbosity: None, + apply_patch_tool_type: None, + web_search_tool_type: Default::default(), + truncation_policy: TruncationPolicyConfig::bytes(/*limit*/ 10_000), + supports_image_detail_original: false, + context_window: None, + max_context_window: None, + auto_compact_token_limit: None, + comp_hash: None, + effective_context_window_percent: 95, + experimental_supported_tools: vec![], + input_modalities: vec![], + used_fallback_model_metadata: false, + supports_search_tool: false, + supports_experimental_context: false, + use_responses_lite: false, + guardian: None, + node_repl_auto_review_required: false, + node_repl_disabled: false, + auto_review_model_override: None, + model_specialty: None, + tool_mode: None, + multi_agent_version: None, + multi_agent_reasoning_effort: None, + } +} + +#[test] +fn renders_literal_templates_with_legacy_personality_inputs() { + let template = "Hello {{ personality }}\n\n# Personality\n\nFixed instructions"; + let model = test_model(Some(ModelMessages { + instructions_template: Some(template.to_string()), + instructions_variables: Some(ModelInstructionsVariables { + personality_default: Some("default".to_string()), + personality_friendly: Some("friendly".to_string()), + personality_pragmatic: Some("pragmatic".to_string()), + }), + ..Default::default() + })); + assert_eq!( + ( + ResolvedModelMessages::from_model(&model).instructions_template(), + render_model_instructions(&model) + ), + (Some(template), template.to_string()), + ); +} + +#[test] +fn missing_and_empty_templates_render_empty_but_retain_presence() { + for (messages, template) in [ + (None, None), + (Some(ModelMessages::default()), None), + ( + Some(ModelMessages { + instructions_template: Some(String::new()), + ..Default::default() + }), + Some(""), + ), + ] { + let model = test_model(messages); + assert_eq!( + ( + render_model_instructions(&model), + ResolvedModelMessages::from_model(&model).instructions_template() + ), + (String::new(), template), + ); + } +} diff --git a/codex-rs/prompts/src/model_messages.rs b/codex-rs/prompts/src/model_messages.rs new file mode 100644 index 0000000000..faf7a8a176 --- /dev/null +++ b/codex-rs/prompts/src/model_messages.rs @@ -0,0 +1,160 @@ +//! Resolves model-owned messages from catalog values and bundled defaults. +//! Resolution preserves sparse catalog data, empty strings, and source identity. +//! Prompt composition and runtime settings remain with consumers; each accessor +//! selects and resolves only the requested message family. + +use codex_protocol::openai_models::ConfirmationPolicies; +use codex_protocol::openai_models::ModelInfo; +use codex_protocol::openai_models::ModelMessages; +use permissions::ResolvedApprovalMessages; +use permissions::ResolvedPermissionMessages; + +mod collaboration; +mod guardian; +mod multi_agent; +pub(crate) mod permissions; + +pub use collaboration::ResolvedCollaborationModeMessages; +pub use guardian::ResolvedAutoReviewMessages; +pub use multi_agent::ResolvedMultiAgentMessages; + +/// Text together with whether it was supplied by the catalog, even when it equals the default. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolvedMessage<'a> { + Catalog(&'a str), + Bundled(&'static str), +} + +impl<'a> ResolvedMessage<'a> { + pub(crate) fn new(catalog: Option<&'a str>, bundled: &'static str) -> Self { + catalog.map_or(Self::Bundled(bundled), Self::Catalog) + } + + pub fn text(self) -> &'a str { + match self { + Self::Catalog(text) | Self::Bundled(text) => text, + } + } + + pub fn catalog_override(self) -> Option<&'a str> { + match self { + Self::Catalog(text) => Some(text), + Self::Bundled(_) => None, + } + } +} + +const REQUEST_USER_INPUT_ASYNC_DESCRIPTION: &str = "Ask the user one or more questions during ongoing work. Use this tool only to request missing information, preferences, constraints, clarification, or approval. The tool returns immediately without ending the turn or waiting for a reply; any reply arrives asynchronously as a new user message. Keep questions concise, self-contained, and easy to understand, using a level of detail appropriate to the user and task. The UI always allows a free-text answer, including when suggested options are provided. A preselected option is not submitted automatically."; +const REMINDER_MESSAGE_TEMPLATE: &str = concat!( + "Your context window is nearly exhausted (only {n_remaining} tokens remaining) and will be automatically reset for you soon. ", + "Once reset, message items in current context window will be cleared in the new window, but notes and history items will be persistent across windows." +); +const PERSISTENT_INSTRUCTIONS: &str = include_str!("../templates/persistent_mode.md"); + +/// Resolves model-owned text from catalog overrides or bundled defaults, one family at a time. +/// +/// The view borrows the consumer's captured model metadata without resolving any families. +/// Missing values select bundled text or retain delegation to consumer-owned settings. +/// Explicit empty strings remain overrides. +#[derive(Debug, Clone, Copy)] +pub struct ResolvedModelMessages<'a> { + catalog_messages: Option<&'a ModelMessages>, +} + +impl<'a> ResolvedModelMessages<'a> { + /// Borrows messages from the selected model; absent catalog values still use defaults. + pub fn from_model(model_info: &'a ModelInfo) -> Self { + Self { + catalog_messages: model_info.model_messages.as_ref(), + } + } + + /// Resolves only bundled defaults, independently of any selected model. + pub fn bundled() -> Self { + Self { + catalog_messages: None, + } + } + + /// Resolves the literal instruction template, preserving missing values. + pub fn instructions_template(&self) -> Option<&'a str> { + self.catalog_messages + .and_then(|messages| messages.instructions_template.as_deref()) + } + + /// Resolves approval messages and their bundled alternatives. + pub(crate) fn approvals(self) -> ResolvedApprovalMessages<'a> { + ResolvedApprovalMessages::new( + self.catalog_messages + .and_then(|messages| messages.approvals.as_ref()), + ) + } + + /// Resolves permission templates without preparing runtime permission facts. + pub(crate) fn permissions(self) -> ResolvedPermissionMessages<'a> { + ResolvedPermissionMessages::new( + self.catalog_messages + .and_then(|messages| messages.permissions.as_ref()), + ) + } + + /// Resolves mode overrides while retaining bundled alternatives and absent values. + pub fn collaboration_modes(&self) -> ResolvedCollaborationModeMessages<'a> { + ResolvedCollaborationModeMessages::new( + self.catalog_messages + .and_then(|messages| messages.collaboration_modes.as_ref()), + ) + } + + /// Resolves multi-agent messages while preserving their catalog or bundled source. + pub fn multi_agent(&self) -> ResolvedMultiAgentMessages<'a> { + ResolvedMultiAgentMessages::new( + self.catalog_messages + .and_then(|messages| messages.multi_agent.as_ref()), + ) + } + + /// Resolves auto-review policy messages and rejection/timeout instructions. + pub fn auto_review(&self) -> ResolvedAutoReviewMessages<'a> { + ResolvedAutoReviewMessages::new( + self.catalog_messages + .and_then(|messages| messages.auto_review.as_ref()), + ) + } + + /// Resolves classifier instructions from catalog text or the bundled default. + pub fn guardian_classifier_instructions(&self) -> &'a str { + guardian::classifier_instructions(self.catalog_messages) + } + + /// Resolves reminder text without selecting or validating runtime budget settings. + pub fn token_budget_reminder_template(&self) -> &'a str { + self.catalog_messages + .and_then(|messages| messages.token_budget.as_ref()) + .map_or(REMINDER_MESSAGE_TEMPLATE, |messages| { + messages.reminder_message_template.as_str() + }) + } + + /// Resolves optional confirmation policies, leaving missing values to actor defaults. + pub fn confirmation_policies(&self) -> Option<&'a ConfirmationPolicies> { + self.catalog_messages + .and_then(|messages| messages.confirmation_policies.as_ref()) + } + + /// Resolves the asynchronous user-input tool description. + pub fn request_user_input_async_description(&self) -> &'a str { + self.catalog_messages + .and_then(|messages| messages.tools.as_ref()) + .and_then(|tools| tools.send_user_message_async.as_ref()) + .and_then(|tool| tool.description.as_deref()) + .unwrap_or(REQUEST_USER_INPUT_ASYNC_DESCRIPTION) + } + + /// Resolves persistent-mode instructions without deciding whether the mode is active. + pub fn persistent_instructions(&self) -> &'a str { + self.catalog_messages + .and_then(|messages| messages.persistent_instructions.as_deref()) + .unwrap_or(PERSISTENT_INSTRUCTIONS) + } +} diff --git a/codex-rs/prompts/src/model_messages/collaboration.rs b/codex-rs/prompts/src/model_messages/collaboration.rs new file mode 100644 index 0000000000..9bff950f5b --- /dev/null +++ b/codex-rs/prompts/src/model_messages/collaboration.rs @@ -0,0 +1,22 @@ +//! Resolves collaboration catalog text and bundled presets while retaining their source. +//! Runtime consumers use catalog overrides first, then the selected mode's settings. + +use super::ResolvedMessage; +use codex_collaboration_mode_templates::DEFAULT; +use codex_collaboration_mode_templates::PLAN; +use codex_protocol::openai_models::CollaborationModeMessages; + +#[derive(Debug, Clone, Copy)] +pub struct ResolvedCollaborationModeMessages<'a> { + pub default: ResolvedMessage<'a>, + pub plan: ResolvedMessage<'a>, +} + +impl<'a> ResolvedCollaborationModeMessages<'a> { + pub(crate) fn new(messages: Option<&'a CollaborationModeMessages>) -> Self { + Self { + default: ResolvedMessage::new(messages.and_then(|m| m.default.as_deref()), DEFAULT), + plan: ResolvedMessage::new(messages.and_then(|m| m.plan.as_deref()), PLAN), + } + } +} diff --git a/codex-rs/prompts/src/model_messages/guardian.rs b/codex-rs/prompts/src/model_messages/guardian.rs new file mode 100644 index 0000000000..a49ddccc1e --- /dev/null +++ b/codex-rs/prompts/src/model_messages/guardian.rs @@ -0,0 +1,62 @@ +//! Resolves Guardian policy, feedback, and classifier text from catalog values or bundled defaults. +//! Configuration overlays and prompt composition remain with consumers. + +use codex_protocol::openai_models::AutoReviewMessages; +use codex_protocol::openai_models::ModelMessages; + +const POLICY: &str = include_str!("../../templates/guardian/policy.md"); +const POLICY_TEMPLATE: &str = include_str!("../../templates/guardian/policy_template.md"); +const NODE_REPL_POLICY: &str = include_str!("../../templates/guardian/node_repl_policy.md"); +const REJECTION_INSTRUCTIONS: &str = concat!( + "The agent must not attempt to achieve the same outcome via workaround, ", + "indirect execution, or policy circumvention. ", + "Proceed only with a materially safer alternative, ", + "or if the user explicitly approves the action after being informed of the risk. ", + "Otherwise, stop and request user input.", +); +const TIMEOUT_INSTRUCTIONS: &str = concat!( + "The automatic permission approval review did not finish before its deadline. ", + "Do not assume the action is unsafe based on the timeout alone. ", + "You may retry once, or ask the user for guidance or explicit approval.", +); +const CLASSIFIER_INSTRUCTIONS: &str = + include_str!("../../templates/guardian/classifier_instructions.md"); + +/// Auto-review policy and feedback text before runtime configuration overlays and rendering. +#[derive(Debug, Clone, Copy)] +pub struct ResolvedAutoReviewMessages<'a> { + pub policy: &'a str, + pub policy_template: &'a str, + pub node_repl_policy: &'a str, + pub rejection_instructions: &'a str, + pub timeout_instructions: &'a str, +} + +impl<'a> ResolvedAutoReviewMessages<'a> { + pub(crate) fn new(messages: Option<&'a AutoReviewMessages>) -> Self { + Self { + policy: messages + .and_then(|messages| messages.policy.as_deref()) + .unwrap_or(POLICY), + policy_template: messages + .and_then(|messages| messages.policy_template.as_deref()) + .unwrap_or(POLICY_TEMPLATE), + node_repl_policy: messages + .and_then(|messages| messages.node_repl_policy.as_deref()) + .unwrap_or(NODE_REPL_POLICY), + rejection_instructions: messages + .and_then(|messages| messages.rejection_instructions.as_deref()) + .unwrap_or(REJECTION_INSTRUCTIONS), + timeout_instructions: messages + .and_then(|messages| messages.timeout_instructions.as_deref()) + .unwrap_or(TIMEOUT_INSTRUCTIONS), + } + } +} + +pub(crate) fn classifier_instructions(messages: Option<&ModelMessages>) -> &str { + messages + .and_then(|messages| messages.guardian_v2.as_ref()) + .and_then(|messages| messages.classifier_instructions.as_deref()) + .unwrap_or(CLASSIFIER_INSTRUCTIONS) +} diff --git a/codex-rs/prompts/src/model_messages/multi_agent.rs b/codex-rs/prompts/src/model_messages/multi_agent.rs new file mode 100644 index 0000000000..533b52d6db --- /dev/null +++ b/codex-rs/prompts/src/model_messages/multi_agent.rs @@ -0,0 +1,84 @@ +//! Resolves multi-agent role bases and mode text while preserving catalog provenance. +//! Consumers own role assembly, suppression, and effort-dependent mode selection. + +use super::ResolvedMessage; +use codex_protocol::openai_models::MultiAgentMessages; + +const DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT: &str = r#"You are `/root`, the primary agent in a team of agents collaborating to fulfill the user's goals. + +At the start of your turn, you are the active agent. +You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. +All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + +You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent without triggering a turn. +Child agents can also spawn their own sub-agents. +You can decide how much context you want to propagate to your sub-agents with the `fork_turns` parameter. + +You will receive messages in the analysis channel in the form: +``` +Message Type: MESSAGE | FINAL_ANSWER +Task name: +Sender: +Payload: + +``` +They may be addressed as to=/root +"#; +const DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT: &str = r#"You are an agent in a team of agents collaborating to complete a task. + +You can spawn sub-agents to handle subtasks, and those sub-agents can spawn their own sub-agents. All agents in the team, including the agents that you can assign tasks to, are equally intelligent and capable, and have access to the same set of tools. + +You can use `spawn_agent` to create a new agent, `followup_task` to give an existing agent a new task and trigger a turn, and `send_message` to pass a message to a running agent. +Child agents can also spawn their own sub-agents. + +When you provide a response in the final channel, that content is immediately delivered back to your parent agent. + +You will receive messages in the analysis channel in the form: +``` +Message Type: NEW_TASK | MESSAGE | FINAL_ANSWER +Task name: +Sender: +Payload: + +``` +You may also see them addressed as to=/root/..., which indicates your identity is /root/... +"#; +const EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT: &str = "Any earlier instruction enabling proactive multi-agent delegation no longer applies. Do not spawn sub-agents unless the user or applicable AGENTS.md/skill instructions explicitly ask for sub-agents, delegation, or parallel agent work."; +const PROACTIVE_MULTI_AGENT_MODE_TEXT: &str = "Proactive multi-agent delegation is active. Any earlier developer instruction requiring an explicit user request before spawning sub-agents no longer applies. This mode remains active until a later multi-agent mode developer message changes it. User requests override this hint.\n\nIf at any point you can parallelize work by delegating tasks to another agent (no matter if you are root or subagent), you should do so using collaboration tools if it could save time or improve quality."; + +/// Model-only role bases and mode alternatives for runtime selection. +#[derive(Debug, Clone, Copy)] +pub struct ResolvedMultiAgentMessages<'a> { + pub root: ResolvedMessage<'a>, + pub subagent: ResolvedMessage<'a>, + pub explicit: ResolvedMessage<'a>, + pub proactive: ResolvedMessage<'a>, + /// A supplied hint overrides both effort-specific modes, including when empty. + pub hint: Option<&'a str>, +} + +impl<'a> ResolvedMultiAgentMessages<'a> { + pub(crate) fn new(messages: Option<&'a MultiAgentMessages>) -> Self { + let role = messages.and_then(|messages| messages.role.as_ref()); + let mode = messages.and_then(|messages| messages.mode.as_ref()); + Self { + root: ResolvedMessage::new( + role.and_then(|role| role.root.as_deref()), + DEFAULT_MULTI_AGENT_V2_ROOT_AGENT_USAGE_HINT_TEXT, + ), + subagent: ResolvedMessage::new( + role.and_then(|role| role.subagent.as_deref()), + DEFAULT_MULTI_AGENT_V2_SUBAGENT_USAGE_HINT_TEXT, + ), + explicit: ResolvedMessage::new( + mode.and_then(|mode| mode.explicit.as_deref()), + EXPLICIT_REQUEST_ONLY_MULTI_AGENT_MODE_TEXT, + ), + proactive: ResolvedMessage::new( + mode.and_then(|mode| mode.proactive.as_deref()), + PROACTIVE_MULTI_AGENT_MODE_TEXT, + ), + hint: mode.and_then(|mode| mode.hint_text.as_deref()), + } + } +} diff --git a/codex-rs/prompts/src/model_messages/permissions.rs b/codex-rs/prompts/src/model_messages/permissions.rs new file mode 100644 index 0000000000..f3e6c1d776 --- /dev/null +++ b/codex-rs/prompts/src/model_messages/permissions.rs @@ -0,0 +1,80 @@ +//! Resolves permission and approval text from sparse catalog messages and bundled defaults. +//! Preserves explicit empty strings and source identity; composition and rendering live in +//! `permissions_instructions` and consume caller-resolved runtime facts. + +use super::ResolvedMessage; +use codex_protocol::openai_models::ApprovalMessages; +use codex_protocol::openai_models::PermissionMessages; + +const APPROVAL_POLICY_NEVER: &str = + include_str!("../../templates/permissions/approval_policy/never.md"); +const APPROVAL_POLICY_UNLESS_TRUSTED: &str = + include_str!("../../templates/permissions/approval_policy/unless_trusted.md"); +const APPROVAL_POLICY_ON_REQUEST: &str = + include_str!("../../templates/permissions/approval_policy/on_request.md"); + +pub(crate) const DANGER_FULL_ACCESS_TEMPLATE: &str = + include_str!("../../templates/permissions/sandbox_mode/danger_full_access.md"); +pub(crate) const WORKSPACE_WRITE_TEMPLATE: &str = + include_str!("../../templates/permissions/sandbox_mode/workspace_write.md"); +pub(crate) const READ_ONLY_TEMPLATE: &str = + include_str!("../../templates/permissions/sandbox_mode/read_only.md"); + +/// Resolved approval-policy text; policy and tool selection remain with the consumer. +#[derive(Debug, Clone, Copy)] +pub(crate) struct ResolvedApprovalMessages<'a> { + pub(crate) never: ResolvedMessage<'a>, + pub(crate) on_request: ResolvedMessage<'a>, + pub(crate) on_request_auto_review: ResolvedMessage<'a>, + pub(crate) unless_trusted: ResolvedMessage<'a>, +} + +impl<'a> ResolvedApprovalMessages<'a> { + pub(crate) fn new(messages: Option<&'a ApprovalMessages>) -> Self { + Self { + never: ResolvedMessage::new( + messages.and_then(|m| m.never.as_deref()), + APPROVAL_POLICY_NEVER, + ), + on_request: ResolvedMessage::new( + messages.and_then(|m| m.on_request.as_deref()), + APPROVAL_POLICY_ON_REQUEST, + ), + on_request_auto_review: ResolvedMessage::new( + messages.and_then(|m| m.on_request_auto_review.as_deref()), + APPROVAL_POLICY_ON_REQUEST, + ), + unless_trusted: ResolvedMessage::new( + messages.and_then(|m| m.unless_trusted.as_deref()), + APPROVAL_POLICY_UNLESS_TRUSTED, + ), + } + } +} + +/// Permission text retains its source so renderers can distinguish catalog text from templates. +#[derive(Debug, Clone, Copy)] +pub(crate) struct ResolvedPermissionMessages<'a> { + pub(crate) danger_full_access: ResolvedMessage<'a>, + pub(crate) workspace_write: ResolvedMessage<'a>, + pub(crate) read_only: ResolvedMessage<'a>, +} + +impl<'a> ResolvedPermissionMessages<'a> { + pub(crate) fn new(messages: Option<&'a PermissionMessages>) -> Self { + Self { + danger_full_access: ResolvedMessage::new( + messages.and_then(|m| m.danger_full_access.as_deref()), + DANGER_FULL_ACCESS_TEMPLATE, + ), + workspace_write: ResolvedMessage::new( + messages.and_then(|m| m.workspace_write.as_deref()), + WORKSPACE_WRITE_TEMPLATE, + ), + read_only: ResolvedMessage::new( + messages.and_then(|m| m.read_only.as_deref()), + READ_ONLY_TEMPLATE, + ), + } + } +} diff --git a/codex-rs/prompts/src/multi_agent_instructions.rs b/codex-rs/prompts/src/multi_agent_instructions.rs new file mode 100644 index 0000000000..b9ed30e9b7 --- /dev/null +++ b/codex-rs/prompts/src/multi_agent_instructions.rs @@ -0,0 +1,96 @@ +//! Assembles multi-agent role instructions from selected text and runtime capabilities. +//! The segment owns rendering and attribution; consumers select and capture its inputs. + +use crate::without_update_plan_instructions; +use codex_context_fragments::ContextualUserFragment; +use codex_protocol::models::ContentItemKind; + +const DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT: &str = "Full-history forks (`fork_turns` omitted or `\"all\"`) inherit the parent model and reasoning effort and do not accept overrides. Only set `model` or `reasoning_effort` when explicitly requested by the user, applicable `AGENTS.md` instructions, or skill instructions; when doing so, set `fork_turns` to `\"none\"` or a positive integer string."; +const DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT: &str = + "When calling `wait_agent`, prefer longer waits (minutes) to avoid busy polling."; +const DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT: &str = r#"Note that collaboration tools cannot be called from inside `functions.exec`. Call `spawn_agent`, `send_message`, `followup_task`, `wait_agent`, `interrupt_agent`, and `list_agents` only as direct tool calls using the recipient shown in their tool definitions, such as `to=functions.collaboration.spawn_agent`, since they are intentionally absent from the `functions.exec` `tools.*` namespace. Available tools in `functions.exec` are explicitly described with a `tools` namespace in the developer message. + +All agents share the same directory. In detail: +- All agents have access to the same container and filesystem as you. +- All agents use the same current working directory. +- As a result, edits made by one agent are immediately visible to all other agents. +"#; + +/// Multi-agent role text and the captured capabilities used to render its context segment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MultiAgentRoleInstructions { + /// Complete configured instructions, emitted verbatim without catalog markers. + Configured(String), + /// Selected catalog or bundled text, composed with captured runtime guidance. + Composed { + base: String, + marked: bool, + omit_update_plan_instructions: bool, + max_concurrency: usize, + wait_agent_enabled: bool, + expose_model_overrides: bool, + }, +} + +impl ContextualUserFragment for MultiAgentRoleInstructions { + fn content_kind(&self) -> ContentItemKind { + ContentItemKind("multi_agent.role_instructions".to_string()) + } + + fn role(&self) -> &'static str { + "developer" + } + + fn requires_separate_message(&self) -> bool { + true + } + + fn markers(&self) -> (&'static str, &'static str) { + match self { + Self::Composed { marked: true, .. } => Self::type_markers(), + Self::Configured(_) | Self::Composed { marked: false, .. } => ("", ""), + } + } + + fn type_markers() -> (&'static str, &'static str) { + ("", "") + } + + fn body(&self) -> String { + match self { + Self::Configured(text) => text.clone(), + Self::Composed { + base, + omit_update_plan_instructions, + max_concurrency, + wait_agent_enabled, + expose_model_overrides, + .. + } => { + let base = if *omit_update_plan_instructions { + without_update_plan_instructions(base) + } else { + base.clone() + }; + let wait_agent_guidance = if *wait_agent_enabled { + format!("{DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT}\n\n") + } else { + String::new() + }; + let shared = DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT; + let mut text = format!( + "{base}\n{shared}\n{wait_agent_guidance}There are {max_concurrency} available concurrency slots, meaning that up to {max_concurrency} agents can be active at once, including you." + ); + if *expose_model_overrides { + text.push_str("\n\n"); + text.push_str(DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT); + } + text + } + } + } +} + +#[cfg(test)] +#[path = "multi_agent_instructions_tests.rs"] +mod tests; diff --git a/codex-rs/prompts/src/multi_agent_instructions_tests.rs b/codex-rs/prompts/src/multi_agent_instructions_tests.rs new file mode 100644 index 0000000000..82fbb871ad --- /dev/null +++ b/codex-rs/prompts/src/multi_agent_instructions_tests.rs @@ -0,0 +1,42 @@ +//! Covers role composition and attribution for captured model instructions. + +use super::*; +use codex_context_fragments::AnnotatedContent; +use codex_context_fragments::RenderedFragment; +use pretty_assertions::assert_eq; + +#[test] +fn role_segment_filters_base_and_appends_bundled_guidance() { + let shared = DEFAULT_MULTI_AGENT_V2_SHARED_USAGE_HINT_TEXT; + let wait = DEFAULT_MULTI_AGENT_V2_WAIT_AGENT_USAGE_HINT_TEXT; + let model_override = DEFAULT_MULTI_AGENT_V2_MODEL_OVERRIDE_USAGE_HINT_TEXT; + let expected_body = format!( + "Role.\n## Work\nContinue.\n{shared}\n{wait}\n\nThere are 2 available concurrency slots, meaning that up to 2 agents can be active at once, including you.\n\n{model_override}" + ); + for marked in [false, true] { + let instructions = MultiAgentRoleInstructions::Composed { + base: "Role.\n## Plan tool\nOmit role checklist guidance.\n## Work\nContinue." + .to_string(), + marked, + omit_update_plan_instructions: true, + max_concurrency: 2, + wait_agent_enabled: true, + expose_model_overrides: true, + }; + let expected_text = if marked { + format!("{expected_body}") + } else { + expected_body.clone() + }; + assert_eq!( + instructions.render_fragment(), + RenderedFragment::new( + "developer", + AnnotatedContent::input_text( + expected_text, + ContentItemKind("multi_agent.role_instructions".to_string()), + ), + ), + ); + } +} diff --git a/codex-rs/prompts/src/permissions_instructions.rs b/codex-rs/prompts/src/permissions_instructions.rs index 9966618f70..08de58d20d 100644 --- a/codex-rs/prompts/src/permissions_instructions.rs +++ b/codex-rs/prompts/src/permissions_instructions.rs @@ -1,3 +1,14 @@ +//! Composes selected permission text and prepared runtime facts into a stored fragment body. +//! Only the profile adapter inspects the host filesystem; composition uses the supplied facts. +//! Catalog text uses literal substitution; bundled sandbox templates are parsed and cached here. + +use crate::ResolvedMessage; +use crate::ResolvedModelMessages; +use crate::model_messages::permissions::DANGER_FULL_ACCESS_TEMPLATE; +use crate::model_messages::permissions::READ_ONLY_TEMPLATE; +use crate::model_messages::permissions::ResolvedApprovalMessages; +use crate::model_messages::permissions::ResolvedPermissionMessages; +use crate::model_messages::permissions::WORKSPACE_WRITE_TEMPLATE; use codex_context_fragments::ContextualUserFragment; use codex_execpolicy::Policy; use codex_protocol::config_types::ApprovalsReviewer; @@ -5,88 +16,118 @@ use codex_protocol::config_types::SandboxMode; use codex_protocol::models::ContentItemKind; use codex_protocol::models::PermissionProfile; use codex_protocol::models::format_allow_prefixes; -use codex_protocol::openai_models::ApprovalMessages; -use codex_protocol::openai_models::PermissionMessages; -use codex_protocol::permissions::FileSystemSandboxPolicy; -use codex_protocol::permissions::NetworkSandboxPolicy; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::GranularApprovalConfig; use codex_protocol::protocol::NetworkAccess; -use codex_protocol::protocol::WritableRoot; use codex_utils_template::Template; use std::path::Path; use std::sync::LazyLock; -const APPROVAL_POLICY_NEVER: &str = - include_str!("../templates/permissions/approval_policy/never.md"); -const APPROVAL_POLICY_UNLESS_TRUSTED: &str = - include_str!("../templates/permissions/approval_policy/unless_trusted.md"); -const APPROVAL_POLICY_ON_REQUEST_RULE: &str = - include_str!("../templates/permissions/approval_policy/on_request.md"); -const APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION: &str = +const REQUEST_PERMISSION_RULE: &str = include_str!("../templates/permissions/approval_policy/on_request_rule_request_permission.md"); -const AUTO_REVIEW_APPROVAL_SUFFIX: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; -const NETWORK_ACCESS_PLACEHOLDER: &str = "{{ network_access }}"; +const REQUEST_PERMISSIONS_TOOL: &str = "# request_permissions Tool\n\nThe built-in `request_permissions` tool is available in this session. Invoke it when you need to request additional `network` or `file_system` permissions before later shell-like commands need them. Request only the specific permissions required for the task."; +const AUTO_REVIEW_SUFFIX: &str = "`approvals_reviewer` is `auto_review`: Sandbox escalations with require_escalated will be reviewed for compliance with the policy. If a rejection happens, you should proceed only with a materially safer alternative, or inform the user of the risk and send a final message to ask for approval."; +const APPROVED_PREFIXES: &str = + "## Approved command prefixes\nThe following prefix rules have already been approved: "; +const GRANULAR_INTRO: &str = "# Approval Requests\n\nApproval policy is `granular`. Categories set to `false` are automatically rejected instead of prompting the user."; +const GRANULAR_PROMPTED_CATEGORIES: &str = + "These approval categories may still prompt the user when needed:"; +const GRANULAR_REJECTED_CATEGORIES: &str = + "These approval categories are automatically rejected instead of prompting the user:"; -const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = - include_str!("../templates/permissions/sandbox_mode/danger_full_access.md"); -const SANDBOX_MODE_WORKSPACE_WRITE: &str = - include_str!("../templates/permissions/sandbox_mode/workspace_write.md"); -const SANDBOX_MODE_READ_ONLY: &str = - include_str!("../templates/permissions/sandbox_mode/read_only.md"); - -static SANDBOX_MODE_DANGER_FULL_ACCESS_TEMPLATE: LazyLock