[codex] Add token budget context feature (#27438)

## Why

The model should be able to see bounded context-window budget metadata
when the `token_budget` feature is enabled. The full-window message is
only injected with full context, while normal turns get a smaller
follow-up only when reported usage first crosses a budget threshold.

## What changed

- Added the `TokenBudget` feature flag.
- Added `<token_budget>` developer fragments for full context-window
metadata and current-window remaining tokens.
- Inserted the threshold message during normal turn handling by
comparing token usage before and after sampling, avoiding persistent
threshold bookkeeping.
- Added core integration coverage for full-context-only metadata and
25/50/75 percent threshold messages.

## Verification

- `just test -p codex-core token_budget`
- `git diff --check`
This commit is contained in:
pakrym-oai
2026-06-10 20:07:06 -07:00
committed by GitHub
parent ab4ce40042
commit 658af936fd
14 changed files with 399 additions and 7 deletions
+17 -4
View File
@@ -207,6 +207,7 @@ mod review;
mod rollout_reconstruction;
#[allow(clippy::module_inception)]
pub(crate) mod session;
mod token_budget;
pub(crate) mod turn;
pub(crate) mod turn_context;
use self::config_lock::export_config_lock_if_configured;
@@ -2712,15 +2713,13 @@ impl Session {
&self,
items: Vec<ResponseItem>,
reference_context_item: Option<TurnContextItem>,
mut compacted_item: CompactedItem,
compacted_item: CompactedItem,
) {
{
let mut state = self.state.lock().await;
state.replace_history(items, reference_context_item.clone());
}
compacted_item.window_id = Some(self.advance_auto_compact_window_id().await);
self.persist_rollout_items(&[RolloutItem::Compacted(compacted_item)])
.await;
if let Some(turn_context_item) = reference_context_item {
@@ -2805,6 +2804,7 @@ impl Session {
collaboration_mode,
base_instructions,
session_source,
auto_compact_window_id,
) = {
let state = self.state.lock().await;
(
@@ -2813,6 +2813,7 @@ impl Session {
state.session_configuration.collaboration_mode.clone(),
state.session_configuration.base_instructions.clone(),
state.session_configuration.session_source.clone(),
state.auto_compact_window_id(),
)
};
if let Some(model_switch_message) =
@@ -2962,6 +2963,18 @@ impl Session {
.render(),
);
}
// This is full-context metadata. Steady-state context diffs should not re-emit it.
if turn_context.features.enabled(Feature::TokenBudget)
&& let Some(model_context_window) = turn_context.model_context_window()
{
developer_sections.push(
crate::context::TokenBudgetContext::new(
auto_compact_window_id,
model_context_window,
)
.render(),
);
}
if turn_context.config.include_environment_context {
let shell = self.user_shell();
let subagents = self
@@ -3040,7 +3053,7 @@ impl Session {
format!("{thread_id}:{window_id}")
}
async fn advance_auto_compact_window_id(&self) -> u64 {
pub(crate) async fn advance_auto_compact_window_id(&self) -> u64 {
let mut state = self.state.lock().await;
state.advance_auto_compact_window_id()
}
+44
View File
@@ -0,0 +1,44 @@
use super::session::Session;
use super::turn_context::TurnContext;
use crate::context::ContextualUserFragment;
use codex_features::Feature;
const TOKEN_BUDGET_USAGE_THRESHOLDS: [i64; 3] = [25, 50, 75];
pub(super) async fn maybe_record_token_budget_remaining_context(
sess: &Session,
turn_context: &TurnContext,
tokens_before_sampling: i64,
tokens_after_sampling: i64,
) {
if !turn_context.features.enabled(Feature::TokenBudget) {
return;
}
let Some(model_context_window) = turn_context.model_context_window() else {
return;
};
if model_context_window <= 0 || tokens_after_sampling <= tokens_before_sampling {
return;
}
let tokens_before_sampling = tokens_before_sampling.max(0);
let tokens_after_sampling = tokens_after_sampling.max(0);
let crossed_threshold = TOKEN_BUDGET_USAGE_THRESHOLDS.iter().any(|threshold| {
tokens_before_sampling.saturating_mul(100) < model_context_window.saturating_mul(*threshold)
&& tokens_after_sampling.saturating_mul(100)
>= model_context_window.saturating_mul(*threshold)
});
if !crossed_threshold {
return;
}
let tokens_left = model_context_window
.saturating_sub(tokens_after_sampling)
.max(0);
let response_item = ContextualUserFragment::into(
crate::context::TokenBudgetRemainingContext::new(tokens_left),
);
sess.record_conversation_items(turn_context, std::slice::from_ref(&response_item))
.await;
}
+10
View File
@@ -225,6 +225,7 @@ pub(crate) async fn run_turn(
let turn_metadata_header = turn_context
.turn_metadata_state
.current_header_value_for_model_request(&window_id);
let tokens_before_sampling = sess.get_total_token_usage().await;
match run_sampling_request(
Arc::clone(&sess),
Arc::clone(&turn_context),
@@ -275,6 +276,15 @@ pub(crate) async fn run_turn(
"post sampling token usage"
);
let tokens_after_sampling = token_status.active_context_tokens;
super::token_budget::maybe_record_token_budget_remaining_context(
sess.as_ref(),
turn_context.as_ref(),
tokens_before_sampling,
tokens_after_sampling,
)
.await;
// as long as compaction works well in getting us way below the token limit, we shouldn't worry about being in an infinite loop.
if token_limit_reached && needs_follow_up {
if let Err(err) = run_auto_compact(