diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 28e7ec50d6..05c9d91fb2 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -321,6 +321,10 @@ pub struct NewConversationParams { #[serde(skip_serializing_if = "Option::is_none")] pub base_instructions: Option, + /// Prompt used during conversation compaction. + #[serde(skip_serializing_if = "Option::is_none")] + pub compact_prompt: Option, + /// Whether to include the apply patch tool in the conversation. #[serde(skip_serializing_if = "Option::is_none")] pub include_apply_patch_tool: Option, @@ -1125,6 +1129,7 @@ mod tests { sandbox: None, config: None, base_instructions: None, + compact_prompt: None, include_apply_patch_tool: None, }, }; diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index f28d9c6af0..14418fb048 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -1760,6 +1760,7 @@ async fn derive_config_from_params( sandbox: sandbox_mode, config: cli_overrides, base_instructions, + compact_prompt, include_apply_patch_tool, } = params; let overrides = ConfigOverrides { @@ -1772,6 +1773,7 @@ async fn derive_config_from_params( model_provider, codex_linux_sandbox_exe, base_instructions, + compact_prompt, include_apply_patch_tool, include_view_image_tool: None, show_raw_agent_reasoning: None, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index f1cbefe2b6..e7fde7b99a 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -173,6 +173,7 @@ impl Codex { model_reasoning_summary: config.model_reasoning_summary, user_instructions, base_instructions: config.base_instructions.clone(), + compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), @@ -265,6 +266,7 @@ pub(crate) struct TurnContext { /// instead of `std::env::current_dir()`. pub(crate) cwd: PathBuf, pub(crate) base_instructions: Option, + pub(crate) compact_prompt: Option, pub(crate) user_instructions: Option, pub(crate) approval_policy: AskForApproval, pub(crate) sandbox_policy: SandboxPolicy, @@ -281,6 +283,12 @@ impl TurnContext { .map(PathBuf::from) .map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p)) } + + pub(crate) fn compact_prompt(&self) -> &str { + self.compact_prompt + .as_deref() + .unwrap_or(compact::SUMMARIZATION_PROMPT) + } } #[allow(dead_code)] @@ -301,6 +309,9 @@ pub(crate) struct SessionConfiguration { /// Base instructions override. base_instructions: Option, + /// Compact prompt override. + compact_prompt: Option, + /// When to escalate for approval for execution approval_policy: AskForApproval, /// How to sandbox commands executed in the system @@ -407,6 +418,7 @@ impl Session { client, cwd: session_configuration.cwd.clone(), base_instructions: session_configuration.base_instructions.clone(), + compact_prompt: session_configuration.compact_prompt.clone(), user_instructions: session_configuration.user_instructions.clone(), approval_policy: session_configuration.approval_policy, sandbox_policy: session_configuration.sandbox_policy.clone(), @@ -1313,7 +1325,7 @@ mod handlers { use crate::codex::Session; use crate::codex::SessionSettingsUpdate; use crate::codex::TurnContext; - use crate::codex::compact; + use crate::codex::spawn_review_thread; use crate::config::Config; use crate::mcp::auth::compute_auth_statuses; @@ -1540,7 +1552,7 @@ mod handlers { // Attempt to inject input into current task if let Err(items) = sess .inject_input(vec![UserInput::Text { - text: compact::SUMMARIZATION_PROMPT.to_string(), + text: turn_context.compact_prompt().to_string(), }]) .await { @@ -1664,6 +1676,7 @@ async fn spawn_review_thread( tools_config, user_instructions: None, base_instructions: Some(base_instructions.clone()), + compact_prompt: parent_turn_context.compact_prompt.clone(), approval_policy: parent_turn_context.approval_policy, sandbox_policy: parent_turn_context.sandbox_policy.clone(), shell_environment_policy: parent_turn_context.shell_environment_policy.clone(), @@ -2500,6 +2513,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), base_instructions: config.base_instructions.clone(), + compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), @@ -2574,6 +2588,7 @@ mod tests { model_reasoning_summary: config.model_reasoning_summary, user_instructions: config.user_instructions.clone(), base_instructions: config.base_instructions.clone(), + compact_prompt: config.compact_prompt.clone(), approval_policy: config.approval_policy, sandbox_policy: config.sandbox_policy.clone(), cwd: config.cwd.clone(), diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 6b07e2458c..eba9ebe286 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -39,9 +39,8 @@ pub(crate) async fn run_inline_auto_compact_task( sess: Arc, turn_context: Arc, ) { - let input = vec![UserInput::Text { - text: SUMMARIZATION_PROMPT.to_string(), - }]; + let prompt = turn_context.compact_prompt().to_string(); + let input = vec![UserInput::Text { text: prompt }]; run_compact_task_inner(sess, turn_context, input).await; } diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 3759a3adc0..b6bc9baf57 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -128,6 +128,9 @@ pub struct Config { /// Base instructions override. pub base_instructions: Option, + /// Compact prompt override. + pub compact_prompt: Option, + /// Optional external notifier command. When set, Codex will spawn this /// program after each completed *turn* (i.e. when the agent finishes /// processing a user submission). The value must be the full command @@ -540,6 +543,8 @@ pub struct ConfigToml { /// System instructions. pub instructions: Option, + /// Compact prompt used for history compaction. + pub compact_prompt: Option, /// When set, restricts ChatGPT login to a specific workspace identifier. #[serde(default)] @@ -644,6 +649,7 @@ pub struct ConfigToml { /// Legacy, now use features pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, pub experimental_use_exec_command_tool: Option, pub experimental_use_unified_exec_tool: Option, pub experimental_use_rmcp_client: Option, @@ -824,6 +830,7 @@ pub struct ConfigOverrides { pub config_profile: Option, pub codex_linux_sandbox_exe: Option, pub base_instructions: Option, + pub compact_prompt: Option, pub include_apply_patch_tool: Option, pub include_view_image_tool: Option, pub show_raw_agent_reasoning: Option, @@ -854,6 +861,7 @@ impl Config { config_profile: config_profile_key, codex_linux_sandbox_exe, base_instructions, + compact_prompt, include_apply_patch_tool: include_apply_patch_tool_override, include_view_image_tool: include_view_image_tool_override, show_raw_agent_reasoning, @@ -1030,6 +1038,15 @@ impl Config { .and_then(|info| info.auto_compact_token_limit) }); + let compact_prompt = compact_prompt.or(cfg.compact_prompt).and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }); + // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -1037,10 +1054,24 @@ impl Config { .experimental_instructions_file .as_ref() .or(cfg.experimental_instructions_file.as_ref()); - let file_base_instructions = - Self::get_base_instructions(experimental_instructions_path, &resolved_cwd)?; + let file_base_instructions = Self::load_override_from_file( + experimental_instructions_path, + &resolved_cwd, + "experimental instructions file", + )?; let base_instructions = base_instructions.or(file_base_instructions); + let experimental_compact_prompt_path = config_profile + .experimental_compact_prompt_file + .as_ref() + .or(cfg.experimental_compact_prompt_file.as_ref()); + let file_compact_prompt = Self::load_override_from_file( + experimental_compact_prompt_path, + &resolved_cwd, + "experimental compact prompt file", + )?; + let compact_prompt = compact_prompt.or(file_compact_prompt); + // Default review model when not set in config; allow CLI override to take precedence. let review_model = override_review_model .or(cfg.review_model) @@ -1064,6 +1095,7 @@ impl Config { notify: cfg.notify, user_instructions, base_instructions, + compact_prompt, // The config.toml omits "_mode" because it's a config file. However, "_mode" // is important in code to differentiate the mode from the store implementation. cli_auth_credentials_store_mode: cfg.cli_auth_credentials_store.unwrap_or_default(), @@ -1160,18 +1192,15 @@ impl Config { None } - fn get_base_instructions( + fn load_override_from_file( path: Option<&PathBuf>, cwd: &Path, + description: &str, ) -> std::io::Result> { - let p = match path.as_ref() { - None => return Ok(None), - Some(p) => p, + let Some(p) = path else { + return Ok(None); }; - // Resolve relative paths against the provided cwd to make CLI - // overrides consistent regardless of where the process was launched - // from. let full_path = if p.is_relative() { cwd.join(p) } else { @@ -1181,10 +1210,7 @@ impl Config { let contents = std::fs::read_to_string(&full_path).map_err(|e| { std::io::Error::new( e.kind(), - format!( - "failed to read experimental instructions file {}: {e}", - full_path.display() - ), + format!("failed to read {description} {}: {e}", full_path.display()), ) })?; @@ -1192,10 +1218,7 @@ impl Config { if s.is_empty() { Err(std::io::Error::new( std::io::ErrorKind::InvalidData, - format!( - "experimental instructions file is empty: {}", - full_path.display() - ), + format!("{description} is empty: {}", full_path.display()), )) } else { Ok(Some(s)) @@ -2653,6 +2676,61 @@ model = "gpt-5-codex" } } + #[test] + fn cli_override_sets_compact_prompt() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let overrides = ConfigOverrides { + compact_prompt: Some("Use the compact override".to_string()), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + overrides, + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.compact_prompt.as_deref(), + Some("Use the compact override") + ); + + Ok(()) + } + + #[test] + fn loads_compact_prompt_from_file() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let workspace = codex_home.path().join("workspace"); + std::fs::create_dir_all(&workspace)?; + + let prompt_path = workspace.join("compact_prompt.txt"); + std::fs::write(&prompt_path, " summarize differently ")?; + + let cfg = ConfigToml { + experimental_compact_prompt_file: Some(PathBuf::from("compact_prompt.txt")), + ..Default::default() + }; + + let overrides = ConfigOverrides { + cwd: Some(workspace), + ..Default::default() + }; + + let config = Config::load_from_base_config_with_overrides( + cfg, + overrides, + codex_home.path().to_path_buf(), + )?; + + assert_eq!( + config.compact_prompt.as_deref(), + Some("summarize differently") + ); + + Ok(()) + } + fn create_test_fixture() -> std::io::Result { let toml = r#" model = "o3" @@ -2808,6 +2886,7 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -2879,6 +2958,7 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -2965,6 +3045,7 @@ model_verbosity = "high" model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, @@ -3037,6 +3118,7 @@ model_verbosity = "high" model_verbosity: Some(Verbosity::High), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), base_instructions: None, + compact_prompt: None, forced_chatgpt_workspace_id: None, forced_login_method: None, include_apply_patch_tool: false, diff --git a/codex-rs/core/src/config/profile.rs b/codex-rs/core/src/config/profile.rs index 7b7cafedbc..7a863f1381 100644 --- a/codex-rs/core/src/config/profile.rs +++ b/codex-rs/core/src/config/profile.rs @@ -22,6 +22,7 @@ pub struct ConfigProfile { pub model_verbosity: Option, pub chatgpt_base_url: Option, pub experimental_instructions_file: Option, + pub experimental_compact_prompt_file: Option, pub include_apply_patch_tool: Option, pub include_view_image_tool: Option, pub experimental_use_unified_exec_tool: Option, diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 17d72abe7e..0dea8a02cb 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -261,6 +261,65 @@ async fn summarize_context_three_requests_and_instructions() { ); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn manual_compact_uses_custom_prompt() { + skip_if_no_network!(); + + let server = start_mock_server().await; + let sse_stream = sse(vec![ev_completed("r1")]); + mount_sse_once(&server, sse_stream).await; + + let custom_prompt = "Use this compact prompt instead"; + + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = model_provider; + config.compact_prompt = Some(custom_prompt.to_string()); + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("dummy")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex.submit(Op::Compact).await.expect("trigger compact"); + wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; + + let requests = server.received_requests().await.expect("collect requests"); + let body = requests + .iter() + .find_map(|req| req.body_json::().ok()) + .expect("summary request body"); + + let input = body + .get("input") + .and_then(|v| v.as_array()) + .expect("input array"); + let mut found_custom_prompt = false; + let mut found_default_prompt = false; + + for item in input { + if item["type"].as_str() != Some("message") { + continue; + } + let text = item["content"][0]["text"].as_str().unwrap_or_default(); + if text == custom_prompt { + found_custom_prompt = true; + } + if text == SUMMARIZATION_PROMPT { + found_default_prompt = true; + } + } + + assert!(found_custom_prompt, "custom prompt should be injected"); + assert!(!found_default_prompt, "default prompt should be replaced"); +} + // Windows CI only: bump to 4 workers to prevent SSE/event starvation and test timeouts. #[cfg_attr(windows, tokio::test(flavor = "multi_thread", worker_threads = 4))] #[cfg_attr(not(windows), tokio::test(flavor = "multi_thread", worker_threads = 2))] diff --git a/codex-rs/docs/codex_mcp_interface.md b/codex-rs/docs/codex_mcp_interface.md index aaaa0f4cad..5945db8505 100644 --- a/codex-rs/docs/codex_mcp_interface.md +++ b/codex-rs/docs/codex_mcp_interface.md @@ -61,6 +61,7 @@ Request `newConversation` params (subset): - `sandbox`: `read-only` | `workspace-write` | `danger-full-access` - `config`: map of additional config overrides - `baseInstructions`: optional instruction override +- `compactPrompt`: optional replacement for the default compaction prompt - `includePlanTool` / `includeApplyPatchTool`: booleans Response: `{ conversationId, model, reasoningEffort?, rolloutPath }` diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index af45a7d20a..4ab0b0302b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -174,6 +174,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any model_provider, codex_linux_sandbox_exe, base_instructions: None, + compact_prompt: None, include_apply_patch_tool: None, include_view_image_tool: None, show_raw_agent_reasoning: oss.then_some(true), diff --git a/codex-rs/mcp-server/src/codex_tool_config.rs b/codex-rs/mcp-server/src/codex_tool_config.rs index 24a5eec4b8..471d65603a 100644 --- a/codex-rs/mcp-server/src/codex_tool_config.rs +++ b/codex-rs/mcp-server/src/codex_tool_config.rs @@ -49,6 +49,10 @@ pub struct CodexToolCallParam { /// The set of instructions to use instead of the default ones. #[serde(default, skip_serializing_if = "Option::is_none")] pub base_instructions: Option, + + /// Prompt used when compacting the conversation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub compact_prompt: Option, } /// Custom enum mirroring [`AskForApproval`], but has an extra dependency on @@ -141,6 +145,7 @@ impl CodexToolCallParam { sandbox, config: cli_overrides, base_instructions, + compact_prompt, } = self; // Build the `ConfigOverrides` recognized by codex-core. @@ -154,6 +159,7 @@ impl CodexToolCallParam { model_provider: None, codex_linux_sandbox_exe, base_instructions, + compact_prompt, include_apply_patch_tool: None, include_view_image_tool: None, show_raw_agent_reasoning: None, @@ -288,6 +294,10 @@ mod tests { "description": "The set of instructions to use instead of the default ones.", "type": "string" }, + "compact-prompt": { + "description": "Prompt used when compacting the conversation.", + "type": "string" + }, }, "required": [ "prompt" diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 028bf68e87..0629dd8c07 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -144,6 +144,7 @@ pub async fn run_main( config_profile: cli.config_profile.clone(), codex_linux_sandbox_exe, base_instructions: None, + compact_prompt: None, include_apply_patch_tool: None, include_view_image_tool: None, show_raw_agent_reasoning: cli.oss.then_some(true),