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
This commit is contained in:
rhan-oai
2026-09-16 21:01:13 +00:00
committed by copyberry
parent 0d083092b4
commit a8c36ca6d2
94 changed files with 2345 additions and 2000 deletions
+9 -1
View File
@@ -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]]
+1
View File
@@ -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 }
@@ -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() {
@@ -1186,7 +1186,7 @@ async fn guardian_v2_routes_scoped_tool_approvals(
.collect::<Vec<_>>(),
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"]]),
+3 -2
View File
@@ -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
+12 -8
View File
@@ -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()
},
+18 -18
View File
@@ -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!(
+11 -15
View File
@@ -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<MultiAgentVersion> {
@@ -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());
@@ -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(),
}
+2 -7
View File
@@ -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;
@@ -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()
}
}
@@ -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<String>) -> Self {
Self {
text: text.into(),
marked: false,
}
}
pub(crate) fn catalog(text: impl Into<String>) -> 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) {
("<multi_agent_role>", "</multi_agent_role>")
}
fn body(&self) -> String {
self.text.clone()
}
}
@@ -1,2 +0,0 @@
pub use codex_prompts::ApprovalPromptContext;
pub use codex_prompts::PermissionsInstructions;
@@ -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)
@@ -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,
)
@@ -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<MultiAgentMode>) -> 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(&current_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::<Vec<_>>(),
updates,
vec![
vec![ContentItem::InputText {
text: current_role.render(),
}],
vec![ContentItem::InputText {
text: expected_mode.render(),
}],
ContextualUserFragment::into(current_role),
ContextualUserFragment::into(expected_mode),
],
);
}
@@ -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;
@@ -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,
@@ -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()
};
@@ -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!(
+2 -4
View File
@@ -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<i64> {
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)
-3
View File
@@ -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;
-28
View File
@@ -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())
}
+4 -4
View File
@@ -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::<codex_guardian_reviewer::ReviewerConfig<crate::config::Config>>()
.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,
})
}
@@ -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)]
@@ -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()
)
);
}
+15 -12
View File
@@ -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<codex_protocol::openai_models::ReasoningEffort>,
reasoning_summary: codex_protocol::config_types::ReasoningSummary,
personality: Option<codex_protocol::config_types::Personality>,
model_messages: Option<&ModelMessages>,
model_messages: ResolvedModelMessages<'_>,
) -> anyhow::Result<Config> {
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()
+53 -67
View File
@@ -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()
)
);
}
+7 -7
View File
@@ -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()),
+5 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
})),
}
);
+3 -3
View File
@@ -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,
+40 -114
View File
@@ -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: <recipient>
Sender: <author>
Payload:
<payload text>
```
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: <recipient>
Sender: <author>
Payload:
<payload text>
```
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<MultiAgentRoleInstructions>,
@@ -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<M
return None;
}
let catalog_mode = settings
.model_info
.model_messages
.as_ref()
.and_then(|messages| messages.multi_agent.as_ref())
.and_then(|messages| messages.mode.as_ref());
let mode_hint_text = turn_context
let multi_agent_messages =
ResolvedModelMessages::from_model(&settings.model_info).multi_agent();
let hint = turn_context
.config
.multi_agent_v2
.multi_agent_mode_hint_text
.as_deref()
.or_else(|| catalog_mode.and_then(|mode| mode.hint_text.as_deref()));
// A configured or catalog hint, including an empty string, defines a custom policy instead
// of an effort-derived built-in policy.
let multi_agent_mode = match mode_hint_text {
Some(hint_text) => 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 {
+2 -1
View File
@@ -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(),
}
+21 -33
View File
@@ -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(),
);
@@ -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!(
@@ -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 {
+9 -8
View File
@@ -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(),
-3
View File
@@ -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<codex_mcp::McpBinding>,
step_store: &ExtensionData,
@@ -1790,7 +1788,6 @@ pub(crate) async fn built_tools(
sess,
turn_context,
model_info,
model_messages,
environments,
mcp,
apps_enabled,
+9 -20
View File
@@ -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,
));
}
+7 -4
View File
@@ -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),
}
@@ -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 {
@@ -22,7 +22,7 @@ use std::collections::BTreeMap;
const TOOL_NAME: &str = "request_user_input_async";
pub struct RequestUserInputAsyncHandler {
pub description: Option<String>,
pub description: String,
}
#[derive(Debug, Deserialize)]
@@ -62,10 +62,7 @@ impl ToolExecutor<ToolInvocation> 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(
@@ -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");
+4 -10
View File
@@ -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<codex_mcp::McpBinding>,
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,
);
+4 -8
View File
@@ -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,
+3 -1
View File
@@ -1242,7 +1242,9 @@ async fn guardian_node_repl_policy_follows_production_approval_path(
.collect::<Vec<_>>();
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()
+4 -3
View File
@@ -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("<model_switch>") && text.contains(&expected_instructions)
@@ -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<String> = 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::<Vec<_>>()
.join(", ");
let expected = format!(" The writable roots are {writable_roots}.");
let actual = permissions[0]
.lines()
.filter(|line| line.starts_with(" The writable root"))
.collect::<Vec<_>>();
assert_eq!(actual, vec![expected.as_str()]);
Ok(())
}
+2 -1
View File
@@ -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 {
+8 -46
View File
@@ -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
+1
View File
@@ -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 }
+1 -1
View File
@@ -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;
@@ -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 }
@@ -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<ReviewDecision>,
@@ -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 {
@@ -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;
-3
View File
@@ -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",
)
+2
View File
@@ -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 }
@@ -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<ClassificationOutcome, String> = 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 {
@@ -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()
}
}
@@ -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}"
)
@@ -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!(
@@ -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<String>,
/// 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<ResponseItem>,
/// 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);
@@ -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<LunaSampler> {
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(())
}
@@ -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
@@ -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]);
+1 -1
View File
@@ -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 }
@@ -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<CollaborationModeMask> {
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)]
@@ -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(
+2 -25
View File
@@ -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()
}
}
+41 -71
View File
@@ -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);
}
+4 -1
View File
@@ -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 }
@@ -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<usize>,
}
impl<'a> GuardianClassifierInstructions<'a> {
pub fn new(
classifier_instructions: &'a str,
policy: &'a str,
output_contract: &'a str,
max_tokens: Option<usize>,
) -> 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;
@@ -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);
}
}
+16
View File
@@ -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;
@@ -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;
@@ -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<ModelMessages>) -> 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),
);
}
}
+160
View File
@@ -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)
}
}
@@ -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),
}
}
}
@@ -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)
}
@@ -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: <recipient>
Sender: <author>
Payload:
<payload text>
```
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: <recipient>
Sender: <author>
Payload:
<payload text>
```
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()),
}
}
}
@@ -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,
),
}
}
}
@@ -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) {
("<multi_agent_role>", "</multi_agent_role>")
}
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;
@@ -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!("<multi_agent_role>{expected_body}</multi_agent_role>")
} else {
expected_body.clone()
};
assert_eq!(
instructions.render_fragment(),
RenderedFragment::new(
"developer",
AnnotatedContent::input_text(
expected_text,
ContentItemKind("multi_agent.role_instructions".to_string()),
),
),
);
}
}
+200 -253
View File
@@ -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<Template> = LazyLock::new(|| {
Template::parse(SANDBOX_MODE_DANGER_FULL_ACCESS.trim_end())
static DANGER_FULL_ACCESS: LazyLock<Template> = LazyLock::new(|| {
Template::parse(DANGER_FULL_ACCESS_TEMPLATE.trim_end())
.unwrap_or_else(|err| panic!("danger-full-access sandbox template must parse: {err}"))
});
static SANDBOX_MODE_WORKSPACE_WRITE_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(SANDBOX_MODE_WORKSPACE_WRITE.trim_end())
static WORKSPACE_WRITE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(WORKSPACE_WRITE_TEMPLATE.trim_end())
.unwrap_or_else(|err| panic!("workspace-write sandbox template must parse: {err}"))
});
static SANDBOX_MODE_READ_ONLY_TEMPLATE: LazyLock<Template> = LazyLock::new(|| {
Template::parse(SANDBOX_MODE_READ_ONLY.trim_end())
static READ_ONLY: LazyLock<Template> = LazyLock::new(|| {
Template::parse(READ_ONLY_TEMPLATE.trim_end())
.unwrap_or_else(|err| panic!("read-only sandbox template must parse: {err}"))
});
struct PermissionsPromptConfig<'a> {
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
approval_messages: Option<&'a ApprovalMessages>,
permission_messages: Option<&'a PermissionMessages>,
exec_policy: &'a Policy,
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
}
#[derive(Debug, Clone, PartialEq)]
/// Developer instructions that describe the active sandbox and approval policy.
pub struct PermissionsInstructions {
text: String,
}
#[derive(Debug, Clone, Copy)]
pub struct ApprovalPromptContext<'a> {
reviewer: ApprovalsReviewer,
messages: Option<&'a ApprovalMessages>,
permission_messages: Option<&'a PermissionMessages>,
messages: ResolvedApprovalMessages<'a>,
permission_messages: ResolvedPermissionMessages<'a>,
}
impl<'a> ApprovalPromptContext<'a> {
pub fn new(
reviewer: ApprovalsReviewer,
messages: Option<&'a ApprovalMessages>,
permission_messages: Option<&'a PermissionMessages>,
) -> Self {
pub fn new(reviewer: ApprovalsReviewer, model_messages: ResolvedModelMessages<'a>) -> Self {
Self {
reviewer,
messages,
permission_messages,
messages: model_messages.approvals(),
permission_messages: model_messages.permissions(),
}
}
}
/// Prepared runtime facts borrowed only while composing the fragment.
/// Paths retain their resolved display spellings and order.
#[derive(Debug, Clone, Copy)]
struct PermissionsRenderContext<'a> {
sandbox_mode: SandboxMode,
network_access: NetworkAccess,
approval_policy: AskForApproval,
approved_command_prefixes: &'a [Vec<String>],
writable_roots: &'a [String],
denied_read_paths: &'a [String],
denied_read_globs: &'a [String],
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
}
/// Developer fragment describing the active sandbox and approval policy.
#[derive(Debug, Clone)]
pub struct PermissionsInstructions {
text: String,
}
impl PermissionsInstructions {
/// Builds permissions instructions from the effective permission profile and approval policy.
/// Composes a permission fragment from resolved messages and caller-supplied facts.
/// Paths are used verbatim; this constructor does not inspect the filesystem or environment.
fn from_resolved(
context: PermissionsRenderContext<'_>,
approval_context: ApprovalPromptContext<'_>,
) -> Self {
let mut text = String::new();
let sandbox = sandbox_text(
context.sandbox_mode,
context.network_access,
approval_context.permission_messages,
);
if !sandbox.is_empty() {
append_section(&mut text, &sandbox);
}
append_section(
&mut text,
&approval_text(
context.approval_policy,
approval_context.reviewer,
approval_context.messages,
context.approved_command_prefixes,
context.exec_permission_approvals_enabled,
context.request_permissions_tool_enabled,
),
);
if let Some(writable_roots) = writable_roots_text(context.writable_roots) {
append_section(&mut text, &writable_roots);
}
if let Some(denied_reads) =
denied_reads_text(context.denied_read_paths, context.denied_read_globs)
{
append_section(&mut text, &denied_reads);
}
if !text.ends_with('\n') {
text.push('\n');
}
Self { text }
}
/// Resolves a permission profile against the host filesystem before rendering instructions.
pub fn from_permission_profile(
permission_profile: &PermissionProfile,
approval_policy: AskForApproval,
@@ -96,81 +137,49 @@ impl PermissionsInstructions {
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> Self {
let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy();
let (sandbox_mode, writable_roots) =
sandbox_prompt_from_policy(&file_system_sandbox_policy, cwd);
Self::from_permissions_with_network_and_denied_reads(
sandbox_mode,
network_access_from_policy(permission_profile.network_sandbox_policy()),
PermissionsPromptConfig {
let file_system_policy = permission_profile.file_system_sandbox_policy();
let (sandbox_mode, mut writable_roots) = if file_system_policy.has_full_disk_write_access()
{
(SandboxMode::DangerFullAccess, Vec::new())
} else {
let roots = file_system_policy.get_writable_roots_with_cwd(cwd);
let mode = if roots.is_empty() {
SandboxMode::ReadOnly
} else {
SandboxMode::WorkspaceWrite
};
(mode, roots)
};
writable_roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path()));
let writable_roots = writable_roots
.iter()
.map(|root| root.root.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let denied_read_paths = file_system_policy
.get_unreadable_roots_with_cwd(cwd)
.iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let denied_read_globs = file_system_policy.get_unreadable_globs_with_cwd(cwd);
Self::from_resolved(
PermissionsRenderContext {
sandbox_mode,
network_access: if permission_profile.network_sandbox_policy().is_enabled() {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
},
approval_policy,
approvals_reviewer: approval_context.reviewer,
approval_messages: approval_context.messages,
permission_messages: approval_context.permission_messages,
exec_policy,
approved_command_prefixes: &exec_policy.get_allowed_prefixes(),
writable_roots: &writable_roots,
denied_read_paths: &denied_read_paths,
denied_read_globs: &denied_read_globs,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
},
writable_roots,
denied_reads_text(&file_system_sandbox_policy, cwd),
approval_context,
)
}
pub fn body(&self) -> String {
self.text.clone()
}
#[cfg(test)]
fn from_permissions_with_network(
sandbox_mode: SandboxMode,
network_access: NetworkAccess,
config: PermissionsPromptConfig<'_>,
writable_roots: Option<Vec<WritableRoot>>,
) -> Self {
Self::from_permissions_with_network_and_denied_reads(
sandbox_mode,
network_access,
config,
writable_roots,
/*denied_reads*/ None,
)
}
fn from_permissions_with_network_and_denied_reads(
sandbox_mode: SandboxMode,
network_access: NetworkAccess,
config: PermissionsPromptConfig<'_>,
writable_roots: Option<Vec<WritableRoot>>,
denied_reads: Option<String>,
) -> Self {
let mut text = String::new();
let sandbox = sandbox_text(sandbox_mode, network_access, config.permission_messages);
if !sandbox.is_empty() {
append_section(&mut text, &sandbox);
}
append_section(
&mut text,
&approval_text(
config.approval_policy,
config.approvals_reviewer,
config.approval_messages,
config.exec_policy,
config.exec_permission_approvals_enabled,
config.request_permissions_tool_enabled,
),
);
if let Some(writable_roots) = writable_roots_text(writable_roots) {
append_section(&mut text, &writable_roots);
}
if let Some(denied_reads) = denied_reads {
append_section(&mut text, &denied_reads);
}
if !text.ends_with('\n') {
text.push('\n');
}
Self { text }
}
}
impl ContextualUserFragment for PermissionsInstructions {
@@ -191,31 +200,7 @@ impl ContextualUserFragment for PermissionsInstructions {
}
fn body(&self) -> String {
PermissionsInstructions::body(self)
}
}
fn sandbox_prompt_from_policy(
file_system_policy: &FileSystemSandboxPolicy,
cwd: &Path,
) -> (SandboxMode, Option<Vec<WritableRoot>>) {
if file_system_policy.has_full_disk_write_access() {
return (SandboxMode::DangerFullAccess, None);
}
let writable_roots = file_system_policy.get_writable_roots_with_cwd(cwd);
if writable_roots.is_empty() {
(SandboxMode::ReadOnly, None)
} else {
(SandboxMode::WorkspaceWrite, Some(writable_roots))
}
}
fn network_access_from_policy(network_policy: NetworkSandboxPolicy) -> NetworkAccess {
if network_policy.is_enabled() {
NetworkAccess::Enabled
} else {
NetworkAccess::Restricted
self.text.clone()
}
}
@@ -229,68 +214,58 @@ fn append_section(text: &mut String, section: &str) {
fn approval_text(
approval_policy: AskForApproval,
approvals_reviewer: ApprovalsReviewer,
approval_messages: Option<&ApprovalMessages>,
exec_policy: &Policy,
messages: ResolvedApprovalMessages<'_>,
approved_command_prefixes: &[Vec<String>],
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> String {
if let Some(approval_messages) = approval_messages {
let selected = match &approval_policy {
AskForApproval::OnRequest => match approvals_reviewer {
ApprovalsReviewer::User => approval_messages.on_request.as_ref(),
ApprovalsReviewer::AutoReview => approval_messages.on_request_auto_review.as_ref(),
},
AskForApproval::Never => approval_messages.never.as_ref(),
AskForApproval::UnlessTrusted => approval_messages.unless_trusted.as_ref(),
AskForApproval::Granular(_) => None,
};
if let Some(selected) = selected {
return selected.clone();
}
}
let with_request_permissions_tool = |text: &str| {
if request_permissions_tool_enabled {
format!("{text}\n\n{}", request_permissions_tool_prompt_section())
} else {
text.to_string()
}
let selected = match approval_policy {
AskForApproval::OnRequest => match approvals_reviewer {
ApprovalsReviewer::User => messages.on_request,
ApprovalsReviewer::AutoReview => messages.on_request_auto_review,
},
AskForApproval::Never => messages.never,
AskForApproval::UnlessTrusted => messages.unless_trusted,
AskForApproval::Granular(_) => ResolvedMessage::Bundled(GRANULAR_INTRO),
};
let on_request_instructions = || {
let on_request_rule = if exec_permission_approvals_enabled {
APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION.to_string()
} else {
APPROVAL_POLICY_ON_REQUEST_RULE.to_string()
};
let mut sections = vec![on_request_rule];
if request_permissions_tool_enabled {
sections.push(request_permissions_tool_prompt_section().to_string());
}
if let Some(prefixes) = approved_command_prefixes_text(exec_policy) {
sections.push(format!(
"## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
));
}
sections.join("\n\n")
let base = match selected {
ResolvedMessage::Catalog(text) => return text.to_string(),
ResolvedMessage::Bundled(text) => text,
};
let text = match approval_policy {
AskForApproval::Never => APPROVAL_POLICY_NEVER.to_string(),
AskForApproval::Never => return base.to_string(),
AskForApproval::UnlessTrusted => {
with_request_permissions_tool(APPROVAL_POLICY_UNLESS_TRUSTED)
if request_permissions_tool_enabled {
format!("{base}\n\n{REQUEST_PERMISSIONS_TOOL}")
} else {
base.to_string()
}
}
AskForApproval::OnRequest => {
let rule = if exec_permission_approvals_enabled {
REQUEST_PERMISSION_RULE
} else {
base
};
let mut sections = vec![rule.to_string()];
if request_permissions_tool_enabled {
sections.push(REQUEST_PERMISSIONS_TOOL.to_string());
}
if let Some(prefixes) = approved_command_prefixes_text(approved_command_prefixes) {
sections.push(format!("{APPROVED_PREFIXES}{prefixes}"));
}
sections.join("\n\n")
}
AskForApproval::OnRequest => on_request_instructions(),
AskForApproval::Granular(granular_config) => granular_instructions(
granular_config,
exec_policy,
approved_command_prefixes,
exec_permission_approvals_enabled,
request_permissions_tool_enabled,
),
};
if approvals_reviewer == ApprovalsReviewer::AutoReview
&& approval_policy != AskForApproval::Never
{
format!("{text}\n\n{AUTO_REVIEW_APPROVAL_SUFFIX}")
if approvals_reviewer == ApprovalsReviewer::AutoReview {
format!("{text}\n\n{AUTO_REVIEW_SUFFIX}")
} else {
text
}
@@ -299,42 +274,30 @@ fn approval_text(
fn sandbox_text(
mode: SandboxMode,
network_access: NetworkAccess,
permission_messages: Option<&PermissionMessages>,
messages: ResolvedPermissionMessages<'_>,
) -> String {
let selected = permission_messages.and_then(|messages| match mode {
SandboxMode::DangerFullAccess => messages.danger_full_access.as_deref(),
SandboxMode::WorkspaceWrite => messages.workspace_write.as_deref(),
SandboxMode::ReadOnly => messages.read_only.as_deref(),
});
if let Some(selected) = selected {
if selected.is_empty() {
return String::new();
}
let network_access = network_access.to_string();
return selected.replace(NETWORK_ACCESS_PLACEHOLDER, network_access.as_str());
}
let template = match mode {
SandboxMode::DangerFullAccess => &*SANDBOX_MODE_DANGER_FULL_ACCESS_TEMPLATE,
SandboxMode::WorkspaceWrite => &*SANDBOX_MODE_WORKSPACE_WRITE_TEMPLATE,
SandboxMode::ReadOnly => &*SANDBOX_MODE_READ_ONLY_TEMPLATE,
let (selected, template) = match mode {
SandboxMode::DangerFullAccess => (messages.danger_full_access, &DANGER_FULL_ACCESS),
SandboxMode::WorkspaceWrite => (messages.workspace_write, &WORKSPACE_WRITE),
SandboxMode::ReadOnly => (messages.read_only, &READ_ONLY),
};
let network_access = network_access.to_string();
template
.render([("network_access", network_access.as_str())])
.unwrap_or_else(|err| panic!("sandbox template must render: {err}"))
match selected {
ResolvedMessage::Catalog(text) => text.replace("{{ network_access }}", &network_access),
ResolvedMessage::Bundled(_) => template
.render([("network_access", network_access.as_str())])
.unwrap_or_else(|err| panic!("sandbox template must render: {err}")),
}
}
fn writable_roots_text(writable_roots: Option<Vec<WritableRoot>>) -> Option<String> {
let mut roots = writable_roots?;
if roots.is_empty() {
fn writable_roots_text(writable_roots: &[String]) -> Option<String> {
if writable_roots.is_empty() {
return None;
}
roots.sort_by(|left, right| left.root.as_path().cmp(right.root.as_path()));
let roots_list: Vec<String> = roots
let roots_list: Vec<String> = writable_roots
.iter()
.map(|r| format!("`{}`", r.root.to_string_lossy()))
.map(|root| format!("`{root}`"))
.collect();
Some(if roots_list.len() == 1 {
format!(" The writable root is {}.", roots_list[0])
@@ -343,18 +306,12 @@ fn writable_roots_text(writable_roots: Option<Vec<WritableRoot>>) -> Option<Stri
})
}
fn denied_reads_text(file_system_policy: &FileSystemSandboxPolicy, cwd: &Path) -> Option<String> {
let mut entries = file_system_policy
.get_unreadable_roots_with_cwd(cwd)
.into_iter()
.map(|root| format!("- path `{}`", root.to_string_lossy()))
fn denied_reads_text(paths: &[String], globs: &[String]) -> Option<String> {
let mut entries = paths
.iter()
.map(|root| format!("- path `{root}`"))
.collect::<Vec<_>>();
entries.extend(
file_system_policy
.get_unreadable_globs_with_cwd(cwd)
.into_iter()
.map(|glob| format!("- glob `{glob}`")),
);
entries.extend(globs.iter().map(|glob| format!("- glob `{glob}`")));
if entries.is_empty() {
return None;
}
@@ -365,22 +322,14 @@ fn denied_reads_text(file_system_policy: &FileSystemSandboxPolicy, cwd: &Path) -
))
}
fn approved_command_prefixes_text(exec_policy: &Policy) -> Option<String> {
format_allow_prefixes(exec_policy.get_allowed_prefixes())
fn approved_command_prefixes_text(approved_command_prefixes: &[Vec<String>]) -> Option<String> {
format_allow_prefixes(approved_command_prefixes.to_vec())
.filter(|prefixes| !prefixes.is_empty())
}
fn granular_prompt_intro_text() -> &'static str {
"# Approval Requests\n\nApproval policy is `granular`. Categories set to `false` are automatically rejected instead of prompting the user."
}
fn request_permissions_tool_prompt_section() -> &'static 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."
}
fn granular_instructions(
granular_config: GranularApprovalConfig,
exec_policy: &Policy,
approved_command_prefixes: &[Vec<String>],
exec_permission_approvals_enabled: bool,
request_permissions_tool_enabled: bool,
) -> String {
@@ -418,33 +367,31 @@ fn granular_instructions(
.map(|&(_, category)| format!("- {category}"))
.collect::<Vec<_>>();
let mut sections = vec![granular_prompt_intro_text().to_string()];
let mut sections = vec![GRANULAR_INTRO.to_string()];
if !prompted_categories.is_empty() {
sections.push(format!(
"These approval categories may still prompt the user when needed:\n{}",
"{GRANULAR_PROMPTED_CATEGORIES}\n{}",
prompted_categories.join("\n")
));
}
if !rejected_categories.is_empty() {
sections.push(format!(
"These approval categories are automatically rejected instead of prompting the user:\n{}",
"{GRANULAR_REJECTED_CATEGORIES}\n{}",
rejected_categories.join("\n")
));
}
if shell_permission_requests_available {
sections.push(APPROVAL_POLICY_ON_REQUEST_RULE_REQUEST_PERMISSION.to_string());
sections.push(REQUEST_PERMISSION_RULE.to_string());
}
if request_permissions_tool_prompts_allowed {
sections.push(request_permissions_tool_prompt_section().to_string());
sections.push(REQUEST_PERMISSIONS_TOOL.to_string());
}
if let Some(prefixes) = approved_command_prefixes_text(exec_policy) {
sections.push(format!(
"## Approved command prefixes\nThe following prefix rules have already been approved: {prefixes}"
));
if let Some(prefixes) = approved_command_prefixes_text(approved_command_prefixes) {
sections.push(format!("{APPROVED_PREFIXES}{prefixes}"));
}
sections.join("\n\n")
@@ -452,4 +399,4 @@ fn granular_instructions(
#[cfg(test)]
#[path = "permissions_instructions_tests.rs"]
mod permissions_instructions_tests;
mod tests;
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,4 @@
//! Omits checklist-tool guidance from Codex-owned prompts, before adding caller text.
//! Purely omits checklist-tool guidance from Codex-owned prompts, before adding caller text.
/// Call only for Codex-owned prompt text; custom instructions must remain unchanged.
pub fn without_update_plan_instructions(instructions: &str) -> String {
@@ -1,4 +1,4 @@
//! Checks that only the known checklist sections are omitted.
//! Checks that filtering omits only the known checklist sections.
use super::without_update_plan_instructions;
use pretty_assertions::assert_eq;
+47 -182
View File
@@ -26,7 +26,6 @@ use strum_macros::EnumIter;
use tracing::warn;
use ts_rs::TS;
use crate::config_types::Personality;
use crate::config_types::ReasoningSummary;
use crate::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE;
use crate::config_types::ServiceTier;
@@ -531,29 +530,13 @@ impl ModelInfo {
}
config_limit
}
/// Returns the literal instruction template. The personality argument remains for older
/// callers; the `None` opt-out is applied separately by models-manager.
pub fn get_model_instructions(&self, _personality: Option<Personality>) -> String {
if let Some(model_messages) = &self.model_messages
&& let Some(template) = &model_messages.instructions_template
{
template.clone()
} else {
warn!(
model = %self.slug,
"Model has no instruction template; returning empty instructions."
);
String::new()
}
}
}
/// A strongly-typed template for assembling model instructions and developer messages.
///
/// `instructions_template` is literal text. The deprecated `instructions_variables` field is
/// retained to decode catalogs produced before personality selection was removed.
#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
#[derive(Debug, Default, Serialize, Deserialize, Clone, PartialEq, Eq, TS, JsonSchema)]
pub struct ModelMessages {
/// Additional developer instructions for persistent mode. Missing or null uses the built-in
/// instructions; an empty string disables them.
@@ -754,9 +737,25 @@ where
{
models
.iter()
.map(|model| ModelInfoWithLegacyBaseInstructionsRef {
model,
base_instructions: model.get_model_instructions(/*personality*/ None),
.map(|model| {
// Mirror the literal instruction template for clients using the deprecated wire field.
let base_instructions = if let Some(template) = model
.model_messages
.as_ref()
.and_then(|messages| messages.instructions_template.as_deref())
{
template.to_owned()
} else {
warn!(
model = %model.slug,
"Model has no instruction template; returning empty instructions."
);
String::new()
};
ModelInfoWithLegacyBaseInstructionsRef {
model,
base_instructions,
}
})
.collect::<Vec<_>>()
.serialize(serializer)
@@ -786,20 +785,7 @@ where
.and_then(|messages| messages.instructions_template.as_ref())
.is_none()
{
let 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 messages = model.model_messages.get_or_insert_default();
messages.instructions_template = Some(base_instructions);
}
if model
@@ -988,23 +974,7 @@ mod tests {
)
.expect("model messages should deserialize");
assert_eq!(
messages,
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,
}
);
assert_eq!(messages, ModelMessages::default());
}
#[test]
@@ -1198,21 +1168,11 @@ mod tests {
assert_eq!(
messages,
ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: None,
approvals: None,
collaboration_modes: Some(CollaborationModeMessages {
default: Some(String::new()),
plan: None,
}),
auto_review: None,
permissions: None,
multi_agent: None,
token_budget: None,
confirmation_policies: None,
guardian_v2: None,
..Default::default()
}
);
}
@@ -1289,74 +1249,6 @@ mod tests {
);
}
#[test]
fn get_model_instructions_ignores_legacy_personality_variables() {
let model = test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("Hello {{ personality }}".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
personality_friendly: Some("friendly".to_string()),
personality_pragmatic: Some("pragmatic".to_string()),
}),
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
multi_agent: None,
token_budget: None,
confirmation_policies: None,
guardian_v2: None,
}));
let instructions = "Hello {{ personality }}";
for personality in [
Some(Personality::Friendly),
Some(Personality::Pragmatic),
Some(Personality::None),
None,
] {
assert_eq!(model.get_model_instructions(personality), instructions);
}
}
#[test]
fn get_model_instructions_is_empty_when_template_is_missing() {
let model = test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: None,
instructions_variables: Some(ModelInstructionsVariables {
personality_default: None,
personality_friendly: None,
personality_pragmatic: None,
}),
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
multi_agent: None,
token_budget: None,
confirmation_policies: None,
guardian_v2: None,
}));
let instructions = model.get_model_instructions(Some(Personality::Friendly));
assert_eq!(instructions, "");
}
#[test]
fn get_model_instructions_is_empty_when_model_messages_is_missing() {
let model = test_model(/*spec*/ None);
assert_eq!(
model.get_model_instructions(Some(Personality::Friendly)),
""
);
}
#[test]
fn models_response_promotes_legacy_base_instructions() {
let mut value = serde_json::to_value(ModelsResponse {
@@ -1372,24 +1264,10 @@ mod tests {
assert_eq!(
model.model_messages,
Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("legacy 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()
})
);
assert_eq!(
model.get_model_instructions(/*personality*/ None),
"legacy instructions"
);
let serialized = serde_json::to_value(response).expect("serialize canonical response");
assert_eq!(
serialized["models"][0]["base_instructions"],
@@ -1421,33 +1299,28 @@ mod tests {
#[test]
fn models_response_serializes_literal_legacy_base_instructions() {
let response = ModelsResponse {
models: vec![test_model(Some(ModelMessages {
persistent_instructions: None,
tools: None,
instructions_template: Some("before {{ personality }} after".to_string()),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
personality_friendly: Some("friendly".to_string()),
personality_pragmatic: Some("pragmatic".to_string()),
}),
approvals: None,
collaboration_modes: None,
auto_review: None,
permissions: None,
multi_agent: None,
token_budget: None,
confirmation_policies: None,
guardian_v2: None,
}))],
};
let serialized = serde_json::to_value(response).expect("serialize models response");
assert_eq!(
serialized["models"][0]["base_instructions"],
"before {{ personality }} after"
);
for (template, expected) in [
(
Some("before {{ personality }} after"),
"before {{ personality }} after",
),
(Some(""), ""),
(None, ""),
] {
let response = ModelsResponse {
models: vec![test_model(Some(ModelMessages {
instructions_template: template.map(str::to_owned),
instructions_variables: Some(ModelInstructionsVariables {
personality_default: Some("default".to_string()),
personality_friendly: Some("friendly".to_string()),
personality_pragmatic: Some("pragmatic".to_string()),
}),
..Default::default()
}))],
};
let serialized = serde_json::to_value(response).expect("serialize models response");
assert_eq!(serialized["models"][0]["base_instructions"], expected);
}
}
#[test]
@@ -1532,15 +1405,7 @@ mod tests {
}),
}),
instructions_template: Some("canonical 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()
};
let mut value = serde_json::to_value(ModelsResponse {
models: vec![test_model(Some(canonical_messages.clone()))],