[codex] add configurable token budget compaction reminder (#29255)

## Why

The token-budget feature reports coarse remaining-context milestones,
but it does not give the model a configurable wrap-up prompt before
automatic compaction. A strict threshold-crossing check can also miss
resumed or reconfigured windows that are already inside the threshold.

## What changed

- Add structured `[features.token_budget]` configuration for an absolute
`reminder_threshold_tokens` and bounded `reminder_message_template`;
`{n_remaining}` is expanded when the reminder is delivered.
- Compute remaining tokens against the next effective auto-compaction
boundary, including scoped `body_after_prefix` accounting and the full
context-window limit.
- Make reminder delivery level-triggered before and after sampling, with
one-shot state owned by `AutoCompactWindow` and re-armed on compaction,
`new_context`, restore, or history replacement.
- Leave the existing initial full-window token-budget context, 25/50/75%
notices, and token-budget tools unchanged.
- Persist the resolved feature configuration in the session config lock
and regenerate the config schema.

## Validation

- `just test -p codex-core token_budget`
- `just test -p codex-core
token_budget_reminder_emits_after_crossing_compaction_threshold`
- `just test -p codex-core auto_compact_window`
- `just test -p codex-core
lock_contains_prompts_and_materializes_features`
- `just test -p codex-features`
- `just test -p codex-config`
This commit is contained in:
pakrym-oai
2026-06-20 19:13:42 -07:00
committed by GitHub
Unverified
parent b6d6be2a84
commit 6df037d47f
15 changed files with 418 additions and 27 deletions
+84
View File
@@ -460,6 +460,90 @@ direct_only_tool_namespaces = ["mcp__history", "mcp__notes"]
Ok(())
}
#[tokio::test]
async fn load_config_resolves_token_budget_config() -> std::io::Result<()> {
for (config_toml, expected) in [
(
"[features]\ntoken_budget = true\n",
TokenBudgetConfig::default(),
),
(
r#"
[features.token_budget]
enabled = true
reminder_threshold_tokens = 16000
reminder_message_template = "Custom reminder: {n_remaining} tokens."
"#,
TokenBudgetConfig {
reminder_threshold_tokens: Some(16_000),
reminder_message_template: "Custom reminder: {n_remaining} tokens.".to_string(),
},
),
] {
let codex_home = tempdir()?;
let config_toml = toml::from_str(config_toml).expect("TOML should deserialize");
let config = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await?;
assert!(config.features.enabled(Feature::TokenBudget));
assert_eq!(config.token_budget, Some(expected));
}
Ok(())
}
#[tokio::test]
async fn load_config_rejects_invalid_token_budget_reminder_template() -> std::io::Result<()> {
for reminder_message_template in [
String::new(),
"x".repeat(TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES + 1),
] {
let codex_home = tempdir()?;
let config_toml = toml::from_str(&format!(
"[features.token_budget]\nenabled = true\nreminder_message_template = {reminder_message_template:?}\n"
))
.expect("TOML should deserialize");
let error = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("invalid reminder template should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
}
Ok(())
}
#[tokio::test]
async fn load_config_rejects_non_positive_token_budget_reminder_threshold() -> std::io::Result<()> {
for reminder_threshold_tokens in [-1, 0] {
let codex_home = tempdir()?;
let config_toml = toml::from_str(&format!(
"[features.token_budget]\nenabled = true\nreminder_threshold_tokens = {reminder_threshold_tokens}\n"
))
.expect("TOML should deserialize");
let error = Config::load_from_base_config_with_overrides(
config_toml,
ConfigOverrides::default(),
codex_home.abs(),
)
.await
.expect_err("non-positive reminder threshold should be rejected");
assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput);
assert_eq!(
error.to_string(),
"features.token_budget.reminder_threshold_tokens must be positive"
);
}
Ok(())
}
#[tokio::test]
async fn load_config_resolves_rollout_budget() -> std::io::Result<()> {
let codex_home = tempdir()?;
+75
View File
@@ -68,6 +68,7 @@ use codex_features::Features;
use codex_features::FeaturesToml;
use codex_features::MultiAgentV2ConfigToml;
use codex_features::NetworkProxyConfigToml;
use codex_features::TokenBudgetConfigToml;
use codex_git_utils::resolve_root_git_project_for_trust;
use codex_install_context::InstallContext;
use codex_login::AuthManagerConfig;
@@ -1026,6 +1027,8 @@ pub struct Config {
/// Settings specific to the task-path-based multi-agent tool surface.
pub multi_agent_v2: MultiAgentV2Config,
/// Context-window token budget configuration, when enabled.
pub token_budget: Option<TokenBudgetConfig>,
/// Shared token budget for the root thread and its sub-agents.
pub rollout_budget: Option<RolloutBudgetConfig>,
/// Current-time reminder configuration, when enabled.
@@ -1075,6 +1078,27 @@ pub struct CodeModeConfig {
pub direct_only_tool_namespaces: Vec<String>,
}
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 = 1000;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct TokenBudgetConfig {
pub reminder_threshold_tokens: Option<i64>,
pub reminder_message_template: String,
}
impl Default for TokenBudgetConfig {
fn default() -> Self {
Self {
reminder_threshold_tokens: None,
reminder_message_template: DEFAULT_TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE.to_string(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct RolloutBudgetConfig {
pub limit_tokens: i64,
@@ -2509,6 +2533,48 @@ fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config
}
}
fn resolve_token_budget_config(
config_toml: &ConfigToml,
features: &ManagedFeatures,
) -> std::io::Result<Option<TokenBudgetConfig>> {
if !features.enabled(Feature::TokenBudget) {
return Ok(None);
}
let token_budget_config = token_budget_toml_config(config_toml.features.as_ref());
let reminder_threshold_tokens =
token_budget_config.and_then(|config| config.reminder_threshold_tokens);
if reminder_threshold_tokens.is_some_and(|tokens| tokens <= 0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"features.token_budget.reminder_threshold_tokens must be positive",
));
}
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());
if reminder_message_template.trim().is_empty() {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"features.token_budget.reminder_message_template must not be empty",
));
}
if reminder_message_template.len() > TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"features.token_budget.reminder_message_template must not exceed {TOKEN_BUDGET_REMINDER_MESSAGE_TEMPLATE_MAX_BYTES} bytes"
),
));
}
Ok(Some(TokenBudgetConfig {
reminder_threshold_tokens,
reminder_message_template,
}))
}
fn resolve_rollout_budget_config(
config_toml: &ConfigToml,
features: &ManagedFeatures,
@@ -2635,6 +2701,13 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA
}
}
fn token_budget_toml_config(features: Option<&FeaturesToml>) -> Option<&TokenBudgetConfigToml> {
match features?.token_budget.as_ref()? {
FeatureToml::Enabled(_) => None,
FeatureToml::Config(config) => Some(config),
}
}
fn current_time_reminder_toml_config(
features: Option<&FeaturesToml>,
) -> Option<&CurrentTimeReminderConfigToml> {
@@ -3287,6 +3360,7 @@ impl Config {
resolve_experimental_request_user_input_enabled(&cfg);
let code_mode = resolve_code_mode_config(&cfg);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg);
let token_budget = resolve_token_budget_config(&cfg, &features)?;
let rollout_budget = resolve_rollout_budget_config(&cfg, &features)?;
let current_time_reminder = resolve_current_time_reminder_config(&cfg, &features)?;
let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg);
@@ -3830,6 +3904,7 @@ impl Config {
background_terminal_max_timeout,
ghost_snapshot,
multi_agent_v2,
token_budget,
rollout_budget,
current_time_reminder,
features,