diff --git a/codex-rs/core/src/api_bridge.rs b/codex-rs/core/src/api_bridge.rs index 3b2024f58b..35953c63b2 100644 --- a/codex-rs/core/src/api_bridge.rs +++ b/codex-rs/core/src/api_bridge.rs @@ -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::(&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!({ diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 235ffdf80f..d78ea40a94 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -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 diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 5493201b4f..84d48803f3 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -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( diff --git a/codex-rs/core/tests/suite/compact_remote.rs b/codex-rs/core/tests/suite/compact_remote.rs index b333c2e1b8..be230d8c74 100644 --- a/codex-rs/core/tests/suite/compact_remote.rs +++ b/codex-rs/core/tests/suite/compact_remote.rs @@ -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!( diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap index 2bac15dfda..e0876a6caa 100644 --- a/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap +++ b/codex-rs/core/tests/suite/snapshots/all__suite__compact_remote__remote_pre_turn_compaction_context_window_exceeded_shapes.snap @@ -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: 01:message/user: 02:message/user:> 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: +01:message/user: +02:message/user:> +03:message/user:USER_ONE +04:message/assistant:REMOTE_FIRST_REPLY