feat: compaction prompt configurable (#5959)
``` codex -c compact_prompt="Summarize in bullet points" ```
This commit is contained in:
@@ -321,6 +321,10 @@ pub struct NewConversationParams {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_instructions: Option<String>,
|
||||
|
||||
/// Prompt used during conversation compaction.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
/// Whether to include the apply patch tool in the conversation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
@@ -1125,6 +1129,7 @@ mod tests {
|
||||
sandbox: None,
|
||||
config: None,
|
||||
base_instructions: None,
|
||||
compact_prompt: None,
|
||||
include_apply_patch_tool: None,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
pub(crate) compact_prompt: Option<String>,
|
||||
pub(crate) user_instructions: Option<String>,
|
||||
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<String>,
|
||||
|
||||
/// Compact prompt override.
|
||||
compact_prompt: Option<String>,
|
||||
|
||||
/// 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(),
|
||||
|
||||
@@ -39,9 +39,8 @@ pub(crate) async fn run_inline_auto_compact_task(
|
||||
sess: Arc<Session>,
|
||||
turn_context: Arc<TurnContext>,
|
||||
) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -128,6 +128,9 @@ pub struct Config {
|
||||
/// Base instructions override.
|
||||
pub base_instructions: Option<String>,
|
||||
|
||||
/// Compact prompt override.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
/// 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<String>,
|
||||
/// Compact prompt used for history compaction.
|
||||
pub compact_prompt: Option<String>,
|
||||
|
||||
/// 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<PathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<PathBuf>,
|
||||
pub experimental_use_exec_command_tool: Option<bool>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
pub experimental_use_rmcp_client: Option<bool>,
|
||||
@@ -824,6 +830,7 @@ pub struct ConfigOverrides {
|
||||
pub config_profile: Option<String>,
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub base_instructions: Option<String>,
|
||||
pub compact_prompt: Option<String>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub include_view_image_tool: Option<bool>,
|
||||
pub show_raw_agent_reasoning: Option<bool>,
|
||||
@@ -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<Option<String>> {
|
||||
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<PrecedenceTestFixture> {
|
||||
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,
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct ConfigProfile {
|
||||
pub model_verbosity: Option<Verbosity>,
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
pub experimental_instructions_file: Option<PathBuf>,
|
||||
pub experimental_compact_prompt_file: Option<PathBuf>,
|
||||
pub include_apply_patch_tool: Option<bool>,
|
||||
pub include_view_image_tool: Option<bool>,
|
||||
pub experimental_use_unified_exec_tool: Option<bool>,
|
||||
|
||||
@@ -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::<serde_json::Value>().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))]
|
||||
|
||||
@@ -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 }`
|
||||
|
||||
@@ -174,6 +174,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> 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),
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
/// Prompt used when compacting the conversation.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub compact_prompt: Option<String>,
|
||||
}
|
||||
|
||||
/// 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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user