[codex] Add new context window tool (#27488)

## Why

The token budget feature tells the model how much room remains in the
current context window. When the model decides the current window is no
longer useful, it needs a way to ask Codex to start over with a fresh
context window without spending tokens on a compaction summary.

This PR adds that model-requestable escape hatch on top of #27438.

## What changed

- Added a direct-model-only `new_context` tool behind
`Feature::TokenBudget`.
- Stores the tool request on `AutoCompactWindow` and consumes it after
sampling so the next follow-up request in the same turn starts in the
new window.
- Starts the new window as a no-summary compaction checkpoint that
contains only fresh initial context, not preserved conversation history.
- Keeps the new window aligned with token-budget startup context,
including the `Current context window Z` message.
- Added integration coverage and a snapshot showing the same-turn
`new_context` flow into a fresh full-context follow-up request.

## Validation

- `just test -p codex-core token_budget`
This commit is contained in:
pakrym-oai
2026-06-10 20:39:07 -07:00
committed by GitHub
Unverified
parent 728b8243a9
commit 87ab01834a
10 changed files with 261 additions and 0 deletions
+38
View File
@@ -3058,6 +3058,44 @@ impl Session {
state.advance_auto_compact_window_id()
}
pub(crate) async fn request_new_context_window(&self) {
let mut state = self.state.lock().await;
state.request_new_context_window();
}
pub(crate) async fn maybe_start_new_context_window(
&self,
turn_context: &TurnContext,
) -> Option<u64> {
let window_id = {
let mut state = self.state.lock().await;
state.start_new_context_window_if_requested()
};
let window_id = window_id?;
let context_items = self.build_initial_context(turn_context).await;
let turn_context_item = turn_context.to_turn_context_item();
let replacement_history = context_items;
{
let mut state = self.state.lock().await;
state.replace_history(replacement_history.clone(), Some(turn_context_item.clone()));
};
self.persist_rollout_items(&[
RolloutItem::Compacted(CompactedItem {
message: String::new(),
replacement_history: Some(replacement_history),
window_id: Some(window_id),
}),
RolloutItem::TurnContext(turn_context_item),
])
.await;
{
let mut state = self.state.lock().await;
state.queue_pending_session_start_source(codex_hooks::SessionStartSource::Compact);
}
self.recompute_token_usage(turn_context).await;
Some(window_id)
}
pub(crate) async fn reference_context_item(&self) -> Option<TurnContextItem> {
let state = self.state.lock().await;
state.reference_context_item()
+9
View File
@@ -285,6 +285,15 @@ pub(crate) async fn run_turn(
)
.await;
let started_new_context_window = sess
.maybe_start_new_context_window(turn_context.as_ref())
.await
.is_some();
if started_new_context_window && needs_follow_up {
can_drain_pending_input = !model_needs_follow_up;
continue;
}
// 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(
@@ -14,6 +14,7 @@ enum AutoCompactWindowPrefill {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct AutoCompactWindow {
window_id: u64,
new_context_window_requested: bool,
/// Absolute input-token baseline for the current compaction window.
///
/// `body_after_prefix` subtracts this from later active-context usage. It is
@@ -26,6 +27,7 @@ impl AutoCompactWindow {
pub(super) fn new() -> Self {
Self {
window_id: 0,
new_context_window_requested: false,
prefill_input_tokens: None,
}
}
@@ -44,9 +46,20 @@ impl AutoCompactWindow {
pub(super) fn advance_window_id(&mut self) -> u64 {
self.window_id = self.window_id.saturating_add(1);
self.new_context_window_requested = false;
self.window_id
}
pub(super) fn request_new_context_window(&mut self) {
self.new_context_window_requested = true;
}
pub(super) fn take_new_context_window_request(&mut self) -> bool {
let requested = self.new_context_window_requested;
self.new_context_window_requested = false;
requested
}
/// Records the request-input side of the first server usage sample. The
/// sampled output from that response is body growth and should remain
/// counted against the scoped auto-compact budget.
@@ -98,8 +111,13 @@ mod tests {
assert_eq!(window.window_id(), 0);
window.set_window_id(/*window_id*/ 3);
assert_eq!(window.window_id(), 3);
window.request_new_context_window();
assert!(window.take_new_context_window_request());
assert!(!window.take_new_context_window_request());
window.request_new_context_window();
assert_eq!(window.advance_window_id(), 4);
assert_eq!(window.window_id(), 4);
assert!(!window.take_new_context_window_request());
assert_eq!(
window.snapshot(),
+14
View File
@@ -156,6 +156,20 @@ impl SessionState {
self.auto_compact_window.advance_window_id()
}
pub(crate) fn request_new_context_window(&mut self) {
self.auto_compact_window.request_new_context_window();
}
pub(crate) fn start_new_context_window_if_requested(&mut self) -> Option<u64> {
if !self.auto_compact_window.take_new_context_window_request() {
return None;
}
let window_id = self.auto_compact_window.advance_window_id();
self.auto_compact_window.clear_prefill();
Some(window_id)
}
pub(crate) fn token_info(&self) -> Option<TokenUsageInfo> {
self.history.token_info()
}
+3
View File
@@ -13,6 +13,8 @@ pub(crate) mod multi_agents;
pub(crate) mod multi_agents_common;
pub(crate) mod multi_agents_spec;
pub(crate) mod multi_agents_v2;
mod new_context_window;
pub(crate) mod new_context_window_spec;
mod plan;
pub(crate) mod plan_spec;
mod request_permissions;
@@ -56,6 +58,7 @@ pub use mcp::McpHandler;
pub use mcp_resource::ListMcpResourceTemplatesHandler;
pub use mcp_resource::ListMcpResourcesHandler;
pub use mcp_resource::ReadMcpResourceHandler;
pub use new_context_window::NewContextWindowHandler;
pub use plan::PlanHandler;
pub use request_permissions::RequestPermissionsHandler;
pub use request_plugin_install::RequestPluginInstallHandler;
@@ -0,0 +1,45 @@
use crate::function_tool::FunctionCallError;
use crate::tools::context::FunctionToolOutput;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolPayload;
use crate::tools::context::boxed_tool_output;
use crate::tools::handlers::new_context_window_spec::NEW_CONTEXT_WINDOW_TOOL_NAME;
use crate::tools::handlers::new_context_window_spec::create_new_context_window_tool;
use crate::tools::registry::CoreToolRuntime;
use crate::tools::registry::ToolExecutor;
use codex_tools::ToolName;
use codex_tools::ToolSpec;
pub(crate) const NEW_CONTEXT_WINDOW_MESSAGE: &str =
"A new context window will start without summarizing conversation history.";
pub struct NewContextWindowHandler;
impl ToolExecutor<ToolInvocation> for NewContextWindowHandler {
fn tool_name(&self) -> ToolName {
ToolName::plain(NEW_CONTEXT_WINDOW_TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
create_new_context_window_tool()
}
fn handle(&self, invocation: ToolInvocation) -> codex_tools::ToolExecutorFuture<'_> {
Box::pin(async move {
if !matches!(invocation.payload, ToolPayload::Function { .. }) {
return Err(FunctionCallError::RespondToModel(
"new_context handler received unsupported payload".to_string(),
));
}
invocation.session.request_new_context_window().await;
Ok(boxed_tool_output(FunctionToolOutput::from_text(
NEW_CONTEXT_WINDOW_MESSAGE.to_string(),
Some(true),
)))
})
}
}
impl CoreToolRuntime for NewContextWindowHandler {}
@@ -0,0 +1,17 @@
use codex_tools::JsonSchema;
use codex_tools::ResponsesApiTool;
use codex_tools::ToolSpec;
use std::collections::BTreeMap;
pub(crate) const NEW_CONTEXT_WINDOW_TOOL_NAME: &str = "new_context";
pub fn create_new_context_window_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: NEW_CONTEXT_WINDOW_TOOL_NAME.to_string(),
description: "Start a new context window.".to_string(),
strict: false,
defer_loading: None,
parameters: JsonSchema::object(BTreeMap::new(), /*required*/ None, Some(false.into())),
output_schema: None,
})
}
+5
View File
@@ -13,6 +13,7 @@ use crate::tools::handlers::ListAvailablePluginsToInstallHandler;
use crate::tools::handlers::ListMcpResourceTemplatesHandler;
use crate::tools::handlers::ListMcpResourcesHandler;
use crate::tools::handlers::McpHandler;
use crate::tools::handlers::NewContextWindowHandler;
use crate::tools::handlers::PlanHandler;
use crate::tools::handlers::ReadMcpResourceHandler;
use crate::tools::handlers::RequestPermissionsHandler;
@@ -659,6 +660,10 @@ fn add_core_utility_tools(context: &CoreToolPlanContext<'_>, planned_tools: &mut
planned_tools.add(RequestPermissionsHandler);
}
if features.enabled(Feature::TokenBudget) {
planned_tools.add_with_exposure(NewContextWindowHandler, ToolExposure::DirectModelOnly);
}
if tool_suggest_enabled(turn_context)
&& let Some(discoverable_tools) =
context.discoverable_tools.filter(|tools| !tools.is_empty())