Fallback pre-turn compaction on context overflow

This commit is contained in:
Charles Cunningham
2026-02-17 17:53:46 -08:00
parent b99d7265f5
commit 19bf2ea5a4
5 changed files with 208 additions and 56 deletions
+29
View File
@@ -55,6 +55,17 @@ pub(crate) fn map_api_error(err: ApiError) -> CodexErr {
}
if status == http::StatusCode::BAD_REQUEST {
if let Ok(value) = serde_json::from_str::<serde_json::Value>(&body_text)
&& matches!(
value
.get("error")
.and_then(|error| error.get("code"))
.and_then(serde_json::Value::as_str),
Some("context_length_exceeded")
)
{
return CodexErr::ContextWindowExceeded;
}
if body_text
.contains("The image data you provided does not represent a valid image")
{
@@ -130,6 +141,24 @@ mod tests {
assert!(matches!(err, CodexErr::ServerOverloaded));
}
#[test]
fn map_api_error_maps_context_length_exceeded_from_400_body() {
let body = serde_json::json!({
"error": {
"code": "context_length_exceeded",
"message": "Your input exceeds the context window of this model."
}
})
.to_string();
let err = map_api_error(ApiError::Transport(TransportError::Http {
status: http::StatusCode::BAD_REQUEST,
url: Some("http://localhost/v1/responses/compact".to_string()),
headers: Some(HeaderMap::new()),
body: Some(body),
}));
assert!(matches!(err, CodexErr::ContextWindowExceeded));
}
#[test]
fn map_api_error_maps_server_overloaded_from_503_body() {
let body = serde_json::json!({
+121 -42
View File
@@ -4718,9 +4718,7 @@ enum PreTurnCompactionOutcome {
CompactedWithIncomingItems,
/// Pre-turn compaction succeeded without incoming turn items
/// (incoming user message should be appended after the compaction summary).
/// This compaction strategy is currently out of distribution for our compaction model,
/// but is planned to be trained on in the future.
#[cfg(test)]
/// This compaction strategy is currently out of distribution for our compaction model.
CompactedWithoutIncomingItems,
}
@@ -4752,10 +4750,6 @@ async fn persist_pre_turn_items_for_compaction_outcome(
sess.record_user_prompt_and_emit_turn_item(turn_context.as_ref(), input, response_item)
.await;
}
// TODO(ccunningham): Followup PR will use compacting excluding incoming items as a fallback
// (even though it is out of distribution for current models).
// Also future models may prefer compacting pre-turn history without incoming turn items.
#[cfg(test)]
PreTurnCompactionOutcome::CompactedWithoutIncomingItems => {
// Reseed canonical initial context above the incoming user message.
let initial_context = sess.build_initial_context(turn_context.as_ref()).await;
@@ -4769,7 +4763,10 @@ async fn persist_pre_turn_items_for_compaction_outcome(
}
}
/// Runs pre-turn auto-compaction with incoming turn context + user message included.
/// Runs pre-turn auto-compaction with incoming turn context + user message included first.
///
/// If the include-incoming compaction attempt fails with `ContextWindowExceeded`, this function
/// retries once with a history-only compaction strategy that excludes incoming turn items.
///
/// On failure this function emits any user-visible error event itself and returns `Err(())` as a
/// sentinel so callers can stop the turn without duplicating error messaging logic.
@@ -4802,43 +4799,125 @@ async fn run_pre_turn_auto_compaction_if_needed(
)
.await;
if let Err(err) = compact_result {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn compaction fails
// before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
match compact_result {
Ok(()) => {}
Err(CodexErr::ContextWindowExceeded) => {
let fallback_result = run_auto_compact(
sess,
turn_context,
AutoCompactCallsite::PreTurnExcludingIncomingUserMessage,
TurnContextReinjection::Skip,
None,
)
.await;
match fallback_result {
Ok(()) => {
let has_user_turn_boundary_after_compaction = sess
.clone_history()
.await
.raw_items()
.iter()
.any(crate::context_manager::is_user_turn_boundary);
if !has_user_turn_boundary_after_compaction {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn
// compaction cannot proceed before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
let message = format!(
"Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})"
);
let event = EventMsg::Error(
CodexErr::ContextWindowExceeded.to_error_event(Some(message)),
);
sess.send_event(turn_context, event).await;
return Err(());
}
return Ok(PreTurnCompactionOutcome::CompactedWithoutIncomingItems);
}
Err(err) => {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn compaction
// fails before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
if matches!(err, CodexErr::Interrupted) {
return Err(());
}
let event = match err {
CodexErr::ContextWindowExceeded => {
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?AutoCompactCallsite::PreTurnExcludingIncomingUserMessage,
incoming_items_tokens_estimate,
auto_compact_limit,
reason = "pre-turn compaction exceeded context window after fallback",
"incoming user/context is too large for pre-turn auto-compaction flow"
);
let message = format!(
"Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})"
);
EventMsg::Error(
CodexErr::ContextWindowExceeded.to_error_event(Some(message)),
)
}
other => {
let compact_error_prefix =
if should_use_remote_compact_task(&turn_context.provider) {
"Error running remote compact task"
} else {
"Error running local compact task"
};
EventMsg::Error(
other.to_error_event(Some(compact_error_prefix.to_string())),
)
}
};
sess.send_event(turn_context, event).await;
return Err(());
}
}
}
if matches!(err, CodexErr::Interrupted) {
Err(err) => {
if !pre_turn_context_items.is_empty() {
// Preserve model-visible settings updates even when pre-turn compaction fails
// before we can persist turn input.
sess.record_conversation_items(turn_context, pre_turn_context_items)
.await;
}
if matches!(err, CodexErr::Interrupted) {
return Err(());
}
let event = match err {
CodexErr::ContextWindowExceeded => {
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?AutoCompactCallsite::PreTurnIncludingIncomingUserMessage,
incoming_items_tokens_estimate,
auto_compact_limit,
reason = "pre-turn compaction exceeded context window",
"incoming user/context is too large for pre-turn auto-compaction flow"
);
let message = format!(
"Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})"
);
EventMsg::Error(CodexErr::ContextWindowExceeded.to_error_event(Some(message)))
}
other => {
let compact_error_prefix =
if should_use_remote_compact_task(&turn_context.provider) {
"Error running remote compact task"
} else {
"Error running local compact task"
};
EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string())))
}
};
sess.send_event(turn_context, event).await;
return Err(());
}
let event = match err {
CodexErr::ContextWindowExceeded => {
error!(
turn_id = %turn_context.sub_id,
auto_compact_callsite = ?AutoCompactCallsite::PreTurnIncludingIncomingUserMessage,
incoming_items_tokens_estimate,
auto_compact_limit,
reason = "pre-turn compaction exceeded context window",
"incoming user/context is too large for pre-turn auto-compaction flow"
);
let message = format!(
"Incoming user message and/or turn context is too large to fit in context window. Please reduce the size of your message and try again. (incoming_items_tokens_estimate={incoming_items_tokens_estimate})"
);
EventMsg::Error(CodexErr::ContextWindowExceeded.to_error_event(Some(message)))
}
other => {
let compact_error_prefix = if should_use_remote_compact_task(&turn_context.provider)
{
"Error running remote compact task"
} else {
"Error running local compact task"
};
EventMsg::Error(other.to_error_event(Some(compact_error_prefix.to_string())))
}
};
sess.send_event(turn_context, event).await;
return Err(());
}
// If compaction no-oped because there was no user-turn boundary even after including incoming
+1 -3
View File
@@ -5,7 +5,6 @@ use codex_core::built_in_model_providers;
use codex_core::compact::SUMMARIZATION_PROMPT;
use codex_core::compact::SUMMARY_PREFIX;
use codex_core::config::Config;
use codex_core::features::Feature;
use codex_core::protocol::AskForApproval;
use codex_core::protocol::EventMsg;
use codex_core::protocol::ItemCompletedEvent;
@@ -3316,7 +3315,7 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
]);
let mut responses = vec![first_turn];
responses.extend(
(0..6).map(|_| {
(0..12).map(|_| {
sse_failed(
"compact-failed",
"context_length_exceeded",
@@ -3373,7 +3372,6 @@ async fn snapshot_request_shape_pre_turn_compaction_context_window_exceeded() {
requests.len() >= 2,
"expected first turn and at least one compaction request"
);
insta::assert_snapshot!(
"pre_turn_compaction_context_window_exceeded_shapes",
format_labeled_requests_snapshot(
+47 -8
View File
@@ -1561,7 +1561,17 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed
)
.await;
let compact_mock = responses::mount_compact_response_once(
let include_attempt_mock = responses::mount_compact_response_once(
harness.server(),
ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
"code": "context_length_exceeded",
"message": "Your input exceeds the context window of this model. Please adjust your input and try again."
}
})),
)
.await;
let fallback_attempt_mock = responses::mount_compact_response_once(
harness.server(),
ResponseTemplate::new(400).set_body_json(serde_json::json!({
"error": {
@@ -1607,7 +1617,6 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed
.await;
wait_for_event(&codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await;
assert_eq!(compact_mock.requests().len(), 1);
let requests = responses_mock.requests();
assert_eq!(
requests.len(),
@@ -1619,15 +1628,45 @@ async fn snapshot_request_shape_remote_pre_turn_compaction_context_window_exceed
"expected turn to stop after compaction failure"
);
let include_attempt_request = compact_mock.single_request();
let mut compact_requests = include_attempt_mock.requests();
compact_requests.extend(fallback_attempt_mock.requests());
assert_eq!(
compact_requests.len(),
2,
"expected include-incoming and fallback compact attempts"
);
let include_attempt_request = compact_requests
.iter()
.find(|request| request.body_contains_text("USER_TWO"))
.cloned()
.expect("expected one compact attempt containing incoming user message");
let fallback_attempt_request = compact_requests
.iter()
.find(|request| !request.body_contains_text("USER_TWO"))
.cloned()
.expect("expected one fallback compact attempt excluding incoming user message");
assert!(
include_attempt_request.body_contains_text("USER_TWO"),
"include-incoming pre-turn remote compaction attempt should include incoming user message"
);
assert!(
!fallback_attempt_request.body_contains_text("USER_TWO"),
"fallback pre-turn remote compaction attempt should exclude incoming user message"
);
insta::assert_snapshot!(
"remote_pre_turn_compaction_context_window_exceeded_shapes",
format_labeled_requests_snapshot(
"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.",
&[(
"Remote Compaction Request (Incoming User Excluded)",
&include_attempt_request
),]
"Remote pre-turn auto-compaction context-window failure: initial compaction includes incoming user content, fallback compaction excludes incoming user content, and the turn errors.",
&[
(
"Remote Compaction Request (Incoming User Included)",
&include_attempt_request
),
(
"Remote Compaction Request (Fallback Incoming User Excluded)",
&fallback_attempt_request
),
]
)
);
assert!(
@@ -1,13 +1,20 @@
---
source: core/tests/suite/compact_remote.rs
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Excluded)\",\n&include_attempt_request),])"
expression: "format_labeled_requests_snapshot(\"Remote pre-turn auto-compaction context-window failure: initial compaction includes incoming user content, fallback compaction excludes incoming user content, and the turn errors.\",\n&[(\"Remote Compaction Request (Incoming User Included)\",\n&include_attempt_request),\n(\"Remote Compaction Request (Fallback Incoming User Excluded)\",\n&fallback_attempt_request),])"
---
Scenario: Remote pre-turn auto-compaction context-window failure: compaction request excludes the incoming user message and the turn errors.
Scenario: Remote pre-turn auto-compaction context-window failure: initial compaction includes incoming user content, fallback compaction excludes incoming user content, and the turn errors.
## Remote Compaction Request (Incoming User Excluded)
## Remote Compaction Request (Incoming User Included)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:USER_ONE
04:message/assistant:REMOTE_FIRST_REPLY
05:message/user:USER_TWO
## Remote Compaction Request (Fallback Incoming User Excluded)
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
01:message/user:<AGENTS_MD>
02:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
03:message/user:USER_ONE
04:message/assistant:REMOTE_FIRST_REPLY