From 6c0c6759d0617d7670826b61aca0a931bbb6db1f Mon Sep 17 00:00:00 2001 From: Won Park Date: Sat, 10 Oct 2026 17:21:22 +0000 Subject: [PATCH] Retain client-marked tool outputs across compaction (#52937) ## Why Client-supplied tool outputs can carry task instructions. Even when marked with `retain: true`, these instructions could be trimmed before compaction and disappear from the compacted history. ## What changed Add the disabled-by-default `retain_client_tool_outputs` feature to honor retention for explicitly marked, named `function_call_output` items without a `call_id`. - Preserve eligible outputs in remote compaction v2 and when starting a new context window, keeping their original role. - Stop emergency tool-output trimming at a retained output. - Share the existing 64,000-token retention budget, keeping outputs whole and dropping an oversized output and older items rather than partially truncating it. - Place initial context before retained standalone outputs and account for their images in retained-image counts. ## Testing Add a scenario covering enabled and disabled retention, emergency trimming, oversized-output handling, and preservation of the latest instruction after compaction and cold resume. Verify that retention annotations stay out of model requests. GitOrigin-RevId: 449179670e4d2be2623928700738758e43b5dc1a --- codex-rs/core/config.schema.json | 6 + codex-rs/core/src/compact.rs | 6 +- codex-rs/core/src/compact_remote_history.rs | 19 ++- codex-rs/core/src/compact_remote_v2.rs | 40 +++-- .../core/src/compact_retained_tool_outputs.rs | 20 +++ codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/session/mod.rs | 35 ++-- codex-rs/core/tests/suite/scenarios.rs | 3 + .../suite/scenarios_retained_tool_outputs.rs | 149 ++++++++++++++++++ ...tained_tool_output_retention_disabled.snap | 115 ++++++++++++++ ...output_survives_compaction_and_resume.snap | 81 ++++++++++ codex-rs/features/src/lib.rs | 8 + 12 files changed, 452 insertions(+), 31 deletions(-) create mode 100644 codex-rs/core/src/compact_retained_tool_outputs.rs create mode 100644 codex-rs/core/tests/suite/scenarios_retained_tool_outputs.rs create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_retention_disabled.snap create mode 100644 codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_survives_compaction_and_resume.snap diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 2b971cd489..401c5faef3 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -1124,6 +1124,9 @@ "retain_client_developer_messages": { "type": "boolean" }, + "retain_client_tool_outputs": { + "type": "boolean" + }, "rollout_budget": { "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" }, @@ -7427,6 +7430,9 @@ "retain_client_developer_messages": { "type": "boolean" }, + "retain_client_tool_outputs": { + "type": "boolean" + }, "rollout_budget": { "$ref": "#/definitions/FeatureToml_for_RolloutBudgetConfigToml" }, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index ac94dee097..e60ca02243 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -606,7 +606,7 @@ pub(crate) fn is_summary_message(message: &str) -> bool { /// model-expected boundary. /// /// Placement rules: -/// - Prefer immediately before the last real user or agent message. +/// - Prefer immediately before the last real user, agent message, or retained standalone output. /// - If no real user messages remain, insert before the compaction summary so /// the summary stays last. /// - If there are no user messages, insert before the last compaction item so @@ -619,6 +619,10 @@ pub(crate) fn insert_initial_context_before_last_real_user_or_summary( let mut last_user_or_summary_index = None; let mut last_real_user_index = None; for (i, item) in compacted_history.iter().enumerate().rev() { + if crate::compact_retained_tool_outputs::is_retained_tool_output(item) { + last_real_user_index = Some(i); + break; + } if let ResponseItem::AgentMessage { content, .. } = &item.item && !matches!( content.first(), diff --git a/codex-rs/core/src/compact_remote_history.rs b/codex-rs/core/src/compact_remote_history.rs index aa7cd7607e..b411dab2f1 100644 --- a/codex-rs/core/src/compact_remote_history.rs +++ b/codex-rs/core/src/compact_remote_history.rs @@ -1,10 +1,12 @@ use std::borrow::Borrow; +use crate::compact_retained_tool_outputs::is_retained_tool_output; use crate::context::ContextualUserFragment; use crate::context::ImageResizeNotice; use crate::context_manager::ContextManager; use crate::context_manager::estimate_item_token_count; use crate::session::turn_context::TurnContext; +use codex_features::Feature; use codex_history::ResponseItemEnvelope; use codex_protocol::models::BaseInstructions; use codex_protocol::models::ContentItem; @@ -97,10 +99,19 @@ pub(crate) fn trim_function_call_history_to_fit_context_window( let source_index = original_items .len() .saturating_sub(consumed_items.saturating_add(group_item_count)); - let Some(rewritten_item) = original_items - .get(source_index) - .and_then(rewritten_output_for_context_window) - else { + let Some(rewritten_item) = original_items.get(source_index).and_then(|envelope| { + if turn_context + .config + .features + .enabled(Feature::RetainClientToolOutputs) + && is_retained_tool_output(envelope) + { + // Enabled retained outputs form the same boundary as user/developer messages. + None + } else { + rewritten_output_for_context_window(envelope) + } + }) else { break; }; estimated_tokens = estimated_tokens diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 8c38c2b41f..de9da20f8e 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -1,5 +1,7 @@ use std::sync::Arc; +use crate::compact_retained_tool_outputs::is_retained_tool_output; + use crate::Prompt; use crate::ResponseStream; use crate::client::ModelClientSession; @@ -35,6 +37,7 @@ use codex_analytics::CompactionTrigger; use codex_context_fragments::set_annotated_content; use codex_context_fragments::to_annotated_content; use codex_features::Feature; +use codex_features::Features; use codex_history::CodexHarnessMetadata; use codex_history::ResponseItemEnvelope; use codex_protocol::error::CodexErr; @@ -44,6 +47,8 @@ use codex_protocol::items::ContextCompactionItem; use codex_protocol::items::TurnItem; use codex_protocol::models::AgentMessageInputContent; use codex_protocol::models::ContentItem; +use codex_protocol::models::FunctionCallOutputBody; +use codex_protocol::models::FunctionCallOutputContentItem; #[cfg(test)] use codex_protocol::models::ImageReference; use codex_protocol::models::ResponseItem; @@ -303,7 +308,7 @@ async fn run_remote_compact_task_inner_impl( prompt_input, prompt_input_metadata, compaction_output, - sess.enabled(Feature::RetainClientDeveloperMessages), + sess.features().get(), if sess.enabled(Feature::CompactionImageBudget) { RetainedImageBudget::Enabled } else { @@ -495,7 +500,7 @@ fn build_v2_compacted_history( prompt_input: Vec, prompt_input_metadata: Vec>, compaction_output: ResponseItem, - retain_client_developer_messages: bool, + features: &Features, image_budget: RetainedImageBudget, ) -> (Vec, usize) { debug_assert_eq!(prompt_input.len(), prompt_input_metadata.len()); @@ -505,9 +510,7 @@ fn build_v2_compacted_history( .map(|(item, metadata)| ResponseItemEnvelope { item, metadata }) .collect::>(); let retained = v2_history_item_groups(prompt_input) - .filter(|group| { - is_retained_for_remote_compaction_v2(&group.source, retain_client_developer_messages) - }) + .filter(|group| is_retained_for_remote_compaction_v2(&group.source, features)) .flat_map(HistoryItemGroup::into_items) .collect::>(); let mut retained = @@ -544,8 +547,11 @@ fn v2_history_item_groups( fn is_retained_for_remote_compaction_v2( envelope: &ResponseItemEnvelope, - retain_client_developer_messages: bool, + features: &Features, ) -> bool { + if features.enabled(Feature::RetainClientToolOutputs) && is_retained_tool_output(envelope) { + return true; + } let item = &envelope.item; if let ResponseItem::AgentMessage { author, @@ -583,13 +589,22 @@ fn is_retained_for_remote_compaction_v2( Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_)) ), "developer" => { - retain_client_developer_messages && is_client_authored_developer_message(envelope) + features.enabled(Feature::RetainClientDeveloperMessages) + && is_client_authored_developer_message(envelope) } _ => false, } } fn retained_input_image_count(item: &ResponseItem) -> usize { + if let ResponseItem::FunctionCallOutput { output, .. } = item + && let FunctionCallOutputBody::ContentItems(content) = &output.body + { + return content + .iter() + .filter(|item| matches!(item, FunctionCallOutputContentItem::InputImage { .. })) + .count(); + } let ResponseItem::Message { content, .. } = item else { return 0; }; @@ -648,6 +663,9 @@ fn truncate_retained_messages( } truncated_reversed.push(group.source); remaining = remaining.saturating_sub(token_count); + } else if is_retained_tool_output(&group.source) { + // Keep outputs whole and stop before older items to preserve newest-first ordering. + break; } else if remaining > notice_tokens { let available_tokens = remaining - notice_tokens; let content_budget = if client_developer { @@ -804,7 +822,7 @@ mod tests { input, metadata, output, - /*retain_client_developer_messages*/ false, + &Features::with_defaults(), RetainedImageBudget::Disabled, ) } @@ -907,6 +925,10 @@ mod tests { }; for enabled in [false, true] { + let mut features = Features::with_defaults(); + if enabled { + features.enable(Feature::RetainClientDeveloperMessages); + } let (history, _) = build_v2_compacted_history( vec![ harness.clone(), @@ -924,7 +946,7 @@ mod tests { None, ], output.clone(), - enabled, + &features, RetainedImageBudget::Disabled, ); let mut expected = vec![ diff --git a/codex-rs/core/src/compact_retained_tool_outputs.rs b/codex-rs/core/src/compact_retained_tool_outputs.rs new file mode 100644 index 0000000000..0ea3b68f10 --- /dev/null +++ b/codex-rs/core/src/compact_retained_tool_outputs.rs @@ -0,0 +1,20 @@ +//! Retains explicitly marked, named function outputs that have no matching call. +//! Outputs share the normal history token budget and are retained whole or dropped. + +use codex_history::ResponseItemEnvelope; +use codex_protocol::models::ResponseItem; + +pub(crate) fn is_retained_tool_output(envelope: &ResponseItemEnvelope) -> bool { + envelope + .metadata + .as_ref() + .is_some_and(|metadata| metadata.client_authored) + && matches!( + envelope.item, + ResponseItem::FunctionCallOutput { + call_id: None, + name: Some(_), + .. + } + ) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index d4ec1918bf..fea9f743d5 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -36,6 +36,7 @@ pub use turn_extension_data::WithTurnExtensionData; mod compact_model_fallback; mod compact_remote_history; mod compact_remote_v2; +mod compact_retained_tool_outputs; mod compact_token_budget; mod thread_startup_metadata; pub use codex_network_proxy::EnvironmentNetworkPolicy; diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index cfc78caabb..e9148a522f 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -4663,22 +4663,23 @@ impl Session { let turn_context = step_context.turn.as_ref(); let history = self.clone_history().await; let input_goal_ids = crate::context::UserGoalUpdate::message_ids(history.raw_items()); - let retained_client_developer_messages = - if self.enabled(Feature::RetainClientDeveloperMessages) { - crate::compact_remote_v2::truncate_retained_messages_for_remote_compaction( - history - .annotated_items() - .iter() - .filter(|item| { - crate::compact_remote_v2::is_client_authored_developer_message(item) - }) - .cloned() - .collect(), - crate::compact_remote_v2::RETAINED_MESSAGE_TOKEN_BUDGET, - ) - } else { - Vec::new() - }; + let retained_client_items = + crate::compact_remote_v2::truncate_retained_messages_for_remote_compaction( + history + .annotated_items() + .iter() + .filter(|item| { + (self.enabled(Feature::RetainClientToolOutputs) + && crate::compact_retained_tool_outputs::is_retained_tool_output(item)) + || (self.enabled(Feature::RetainClientDeveloperMessages) + && crate::compact_remote_v2::is_client_authored_developer_message( + item, + )) + }) + .cloned() + .collect(), + crate::compact_remote_v2::RETAINED_MESSAGE_TOKEN_BUDGET, + ); let window = { let mut state = self.state.lock().await; state.start_new_context_window() @@ -4691,7 +4692,7 @@ impl Session { crate::context_manager::updates::merge_world_state_updates(context_updates) .into_iter() .map(ResponseItemEnvelope::new) - .chain(retained_client_developer_messages) + .chain(retained_client_items) .collect(); let turn_context_item = step_context.to_turn_context_item(); self.replace_compacted_history( diff --git a/codex-rs/core/tests/suite/scenarios.rs b/codex-rs/core/tests/suite/scenarios.rs index 04d1a46432..1f37f92792 100644 --- a/codex-rs/core/tests/suite/scenarios.rs +++ b/codex-rs/core/tests/suite/scenarios.rs @@ -89,6 +89,9 @@ use tokio::sync::oneshot; const ONE_PIXEL_PNG_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg=="; +#[path = "scenarios_retained_tool_outputs.rs"] +mod retained_tool_outputs; + #[path = "scenarios_incremental_tools.rs"] mod incremental_tools; #[path = "scenarios_incremental_tools_resume.rs"] diff --git a/codex-rs/core/tests/suite/scenarios_retained_tool_outputs.rs b/codex-rs/core/tests/suite/scenarios_retained_tool_outputs.rs new file mode 100644 index 0000000000..a8212effa8 --- /dev/null +++ b/codex-rs/core/tests/suite/scenarios_retained_tool_outputs.rs @@ -0,0 +1,149 @@ +//! Client-marked function outputs survive compaction and a cold resume in their original role. + +use super::*; +use codex_protocol::turn_input::AnnotatedResponseItem; +use codex_protocol::turn_input::ResponseItemAnnotations; +use codex_protocol::turn_input::TurnInput; +use pretty_assertions::assert_eq; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn retained_tool_output_survives_compaction_and_resume() -> Result<()> { + skip_if_no_network!(Ok(())); + for enabled in [false, true] { + let server = start_mock_server().await; + let mock = mount_sse_sequence(&server, vec![ + sse(vec![ev_assistant_message("accepted", "I will investigate the tests."), ev_completed("accepted-response")]), + sse(vec![ev_assistant_message("details-accepted", "I will use those task details."), ev_completed("details-response")]), + sse(vec![ev_completed("instruction-response")]), + sse(vec![json!({"type":"response.output_item.done", "item":{"type":"compaction", "encrypted_content":"RETAINED_TOOL_OUTPUT_CHECKPOINT"}}), ev_completed("compact-response")]), + sse(vec![ev_assistant_message("resumed", "The request was to investigate the tests."), ev_completed("resumed-response")]), + ]).await; + let configure = move |config: &mut Config| { + configure_scenario_catalog(config); + config + .features + .set_enabled(Feature::RetainClientToolOutputs, enabled) + .unwrap(); + config.model_context_window = Some(1_000); + config.model_auto_compact_token_limit = Some(i64::MAX); + // Let this scenario exercise the compaction budget rather than ingestion truncation. + config + .model_catalog + .as_mut() + .unwrap() + .models + .iter_mut() + .find(|model| model.slug == "gpt-6-astra") + .unwrap() + .truncation_policy = codex_protocol::openai_models::TruncationPolicyConfig::tokens( + /*limit*/ 100_000, + ); + }; + let test = test_codex() + .with_model("gpt-6-astra") + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(configure) + .build_with_auto_env(&server) + .await?; + let details = "Investigate the failing tests carefully. ".repeat(7_000); + let latest_instruction = "Report your findings without changing files."; + for (name, output) in [ + ( + "send_message_to_thread", + "Investigate the failing tests; report your findings without changing files.", + ), + ("task_details", details.as_str()), + ("send_message_to_thread", latest_instruction), + ] { + let request = TurnInputRequest::new(TurnInput::AnnotatedResponseItem( + AnnotatedResponseItem { + item: serde_json::from_value(json!({ + "type": "function_call_output", "name": name, "namespace": "codex_app", "output": output, + }))?, + annotations: ResponseItemAnnotations { retain: true }, + }, + )); + test.codex.start_or_steer_turn(request).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + } + test.codex + .inject_response_items(vec![serde_json::from_value(json!({ + "type": "function_call_output", "name": "progress", "namespace": "example", + "output": "Transient progress update. ".repeat(1000), + }))?]) + .await?; + test.codex.submit(Op::Compact).await?; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let resumed = test_codex() + .with_model("gpt-6-astra") + .with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing()) + .with_config(move |config| { + configure(config); + config.model_context_window = Some(200_000); + }) + .restart_with_auto_env(&server, &test) + .await?; + resumed + .submit_text_turn("What request are you working on?") + .await?; + let requests = mock.requests(); + assert_eq!(requests.len(), 5); + for request in &requests[..3] { + assert!(request.inputs_of_type("compaction_trigger").is_empty()); + } + let compact_outputs = requests[3].inputs_of_type("function_call_output"); + assert_eq!(compact_outputs.len(), 4); + // Emergency trimming removes the trailing log, then stops at the marked instruction. + assert_eq!(compact_outputs[1]["output"], details); + if enabled { + assert_eq!(compact_outputs[2]["output"], latest_instruction); + } else { + assert_eq!( + compact_outputs[2]["output"], + "Output exceeded the available model context and was truncated" + ); + } + assert_eq!( + compact_outputs[3]["output"], + "Output exceeded the available model context and was truncated" + ); + let last = requests.last().unwrap(); + assert!(last.inputs_of_type("compaction_trigger").is_empty()); + assert!(last.body_contains_text("What request are you working on?")); + let outputs = last.inputs_of_type("function_call_output"); + if enabled { + // Oversized details and older instructions are dropped; the latest stays whole. + assert_eq!(outputs, vec![compact_outputs[2].clone()]); + for output in outputs { + assert!(output.get("retain").is_none()); + assert!(output.get("client_authored").is_none()); + } + } else { + assert!(outputs.is_empty()); + } + insta::assert_snapshot!( + if enabled { + "retained_tool_output_survives_compaction_and_resume" + } else { + "retained_tool_output_retention_disabled" + }, + context_snapshot::format_request_history_snapshot( + if enabled { + "Retained thread instructions stop emergency tool-output trimming. Compaction keeps whole outputs within the shared 64K retention budget, dropping oversized task details and older instructions. A cold resume preserves the latest instruction unchanged." + } else { + "With retention disabled, the client can still send retain=true. Tool outputs use ordinary emergency trimming and are not retained after compaction and cold resume." + }, + &requests, + &ContextSnapshotOptions::default().rewrite_known_segments(), + ) + ); + resumed.codex.shutdown_and_wait().await?; + } + Ok(()) +} diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_retention_disabled.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_retention_disabled.snap new file mode 100644 index 0000000000..502a050722 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_retention_disabled.snap @@ -0,0 +1,115 @@ +--- +source: core/tests/suite/scenarios_retained_tool_outputs.rs +expression: "context_snapshot::format_request_history_snapshot(if enabled\n{\n \"Retained thread instructions stop emergency tool-output trimming. Compaction shares the 64K retention budget with oversized task details, and a cold resume preserves the retained tool content.\"\n} else\n{\n \"With retention disabled, the client can still send retain=true. Tool outputs use ordinary emergency trimming and are not retained after compaction and cold resume.\"\n}, &requests, &ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: With retention disabled, the client can still send retain=true. Tool outputs use ordinary emergency trimming and are not retained after compaction and cold resume. + +## Window 1 +-- request 1 (turn) -- +00:additional_tools/developer (3; hash=bf2e30605ff5dc17): + - namespace/functions; hash=fada4a476a3754dd + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63 + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/developer[2]: + [01] + [02] +03:message/developer: + +04:message/developer: + +05:message/user: + +06:function_call_output/codex_app.send_message_to_thread:Investigate the failing tests; report your findings without changing files. +-- request 2 (turn) -- +07:message/assistant: + I will investigate the tests. +08:function_call_output/codex_app.task_details:Investigate the failing tests carefully. Investigate the failing tests carefully. Investigate the failin...s carefully. Investigate the failing tests carefully. [hash=3c65017b6f98a38f] +-- request 3 (turn) -- +09:message/assistant: + I will use those task details. +10:function_call_output/codex_app.send_message_to_thread:Report your findings without changing files. + +## Window 2 (after request 3: input diverged at item 10) +-- request 4 (compaction) -- +00:additional_tools/developer (3; hash=bf2e30605ff5dc17): + - namespace/functions; hash=fada4a476a3754dd + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63 + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/developer[2]: + [01] + [02] +03:message/developer: + +04:message/developer: + +05:message/user: + +06:function_call_output/codex_app.send_message_to_thread:Investigate the failing tests; report your findings without changing files. +07:message/assistant: + I will investigate the tests. +08:function_call_output/codex_app.task_details:Investigate the failing tests carefully. Investigate the failing tests carefully. Investigate the failin...s carefully. Investigate the failing tests carefully. [hash=3c65017b6f98a38f] +09:message/assistant: + I will use those task details. +10:function_call_output/codex_app.send_message_to_thread:Output exceeded the available model context and was truncated +11:function_call_output/example.progress:Output exceeded the available model context and was truncated +12:compaction_trigger + +## Window 3 (after request 4: input diverged at item 06) +-- request 5 (turn) -- +00:additional_tools/developer (3; hash=bf2e30605ff5dc17): + - namespace/functions; hash=fada4a476a3754dd + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63 + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/developer[2]: + [01] + [02] +03:message/developer: + +04:message/developer: + +05:message/user: + +06:compaction:encrypted=true; chars=31; hash=dc1cfb1784ca88e3 +07:message/user: + +08:message/user: + What request are you working on? diff --git a/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_survives_compaction_and_resume.snap b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_survives_compaction_and_resume.snap new file mode 100644 index 0000000000..af3d0a2d31 --- /dev/null +++ b/codex-rs/core/tests/suite/snapshots/all__suite__scenarios__retained_tool_outputs__retained_tool_output_survives_compaction_and_resume.snap @@ -0,0 +1,81 @@ +--- +source: core/tests/suite/scenarios_retained_tool_outputs.rs +expression: "context_snapshot::format_request_history_snapshot(\"Retained thread instructions stop emergency tool-output trimming. Compaction keeps whole outputs within the shared 64K retention budget, dropping oversized task details and older instructions. A cold resume preserves the latest instruction unchanged.\",\n&requests, &ContextSnapshotOptions::default().rewrite_known_segments(),)" +--- +Scenario: Retained thread instructions stop emergency tool-output trimming. Compaction keeps whole outputs within the shared 64K retention budget, dropping oversized task details and older instructions. A cold resume preserves the latest instruction unchanged. + +## Window 1 +-- request 1 (turn) -- +00:additional_tools/developer (3; hash=bf2e30605ff5dc17): + - namespace/functions; hash=fada4a476a3754dd + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63 + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/developer[2]: + [01] + [02] +03:message/developer: + +04:message/developer: + +05:message/user: + +06:function_call_output/codex_app.send_message_to_thread:Investigate the failing tests; report your findings without changing files. +-- request 2 (turn) -- +07:message/assistant: + I will investigate the tests. +08:function_call_output/codex_app.task_details:Investigate the failing tests carefully. Investigate the failing tests carefully. Investigate the failin...s carefully. Investigate the failing tests carefully. [hash=3c65017b6f98a38f] +-- request 3 (turn) -- +09:message/assistant: + I will use those task details. +10:function_call_output/codex_app.send_message_to_thread:Report your findings without changing files. +-- request 4 (compaction) -- +11:function_call_output/example.progress:Output exceeded the available model context and was truncated +12:compaction_trigger + +## Window 2 (after request 4: input diverged at item 06) +-- request 5 (turn) -- +00:additional_tools/developer (3; hash=bf2e30605ff5dc17): + - namespace/functions; hash=fada4a476a3754dd + - custom/exec + - function/wait + - function/request_user_input + - function/request_user_input_async + - namespace/clock: Tools for reading and waiting on time.; hash=f402e8c5e9b5e317 + - function/sleep + - namespace/collaboration: Tools for spawning and managing sub-agents.; hash=2c8d6295978dbe63 + - function/followup_task + - function/interrupt_agent + - function/list_agents + - function/send_message + - function/spawn_agent + - function/wait_agent +01:message/developer: + +02:message/developer[2]: + [01] + [02] +03:message/developer: + +04:message/developer: + +05:message/user: + +06:function_call_output/codex_app.send_message_to_thread:Report your findings without changing files. +07:compaction:encrypted=true; chars=31; hash=dc1cfb1784ca88e3 +08:message/user: + +09:message/user: + What request are you working on? diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index b46f58bf44..3d4fa0fd70 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -423,6 +423,8 @@ pub enum Feature { CompactionImageBudget, /// Retain client-authored developer messages across compacted context windows. RetainClientDeveloperMessages, + /// Honor client-requested tool-output retention across context windows. + RetainClientToolOutputs, /// Use Agent Identity for ChatGPT-authenticated sessions. UseAgentIdentity, /// Enable workspace dependency support. @@ -2052,6 +2054,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::RetainClientToolOutputs, + key: "retain_client_tool_outputs", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::UseAgentIdentity, key: "use_agent_identity",