diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 48d0523df..ac285dd16 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -54,7 +54,15 @@ impl Default for Prompt { impl Prompt { pub(crate) fn get_formatted_input(&self) -> Vec { - self.input + Self::format_input(&self.input) + } + + /// Converts persisted assistant envelopes into the model-native input items + /// used by the Responses API. Callers preparing history for retention must + /// use the same conversion as the request builder so encrypted delegated + /// messages are not filtered as ordinary assistant text. + pub(crate) fn format_input(input: &[ResponseItem]) -> Vec { + input .iter() .cloned() .map(|item| { diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 0c04d2d18..be0c0122d 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -16,6 +16,7 @@ use crate::compact_remote::log_remote_compact_failure; use crate::compact_remote::process_compacted_history; use crate::compact_remote::should_keep_compacted_history_item; use crate::compact_remote::trim_function_call_history_to_fit_context_window; +use crate::context_manager::estimate_item_token_count; use crate::hook_runtime::PostCompactHookOutcome; use crate::hook_runtime::PreCompactHookOutcome; use crate::hook_runtime::run_post_compact_hooks; @@ -55,6 +56,7 @@ use tracing::info; // Mirror the current /responses/compact retained-message default while the // server-side path remains the reference implementation. const RETAINED_MESSAGE_TOKEN_BUDGET: usize = 64_000; +const MAX_RETAINED_AGENT_MESSAGE_TOKENS: i64 = 10_000; // Compact attempts can run much longer than normal turns, so keep the per-transport // retry budget smaller than the general Responses stream retry budget. const MAX_REMOTE_COMPACTION_V2_STREAM_RETRIES: u64 = 2; @@ -392,7 +394,11 @@ async fn run_remote_compact_v2_attempt( let prompt_input = history .clone() .for_prompt(&turn_context.model_info.input_modalities); - let mut input = prompt_input.clone(); + // Apply the same persisted-envelope conversion used by the Responses request + // builder before retention. Otherwise encrypted delegated messages are still + // assistant envelopes here and the retention role filter drops them. + let formatted_prompt_input = Prompt::format_input(&prompt_input); + let mut input = formatted_prompt_input.clone(); input.push(ResponseItem::CompactionTrigger); let prompt = Prompt { input, @@ -450,7 +456,7 @@ async fn run_remote_compact_v2_attempt( .await; Ok(RemoteCompactV2Attempt { trace_input_history, - prompt_input, + prompt_input: formatted_prompt_input, compaction_output, token_usage, owned_client_session, @@ -616,6 +622,10 @@ fn build_v2_compacted_history( } fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { + if matches!(item, ResponseItem::AgentMessage { .. }) { + return estimate_item_token_count(item) <= MAX_RETAINED_AGENT_MESSAGE_TOKENS; + } + let ResponseItem::Message { role, .. } = item else { return false; }; @@ -651,7 +661,7 @@ fn truncate_retained_messages_for_remote_compaction( fn message_text_token_count(item: &ResponseItem) -> usize { let ResponseItem::Message { content, .. } = item else { - return 0; + return usize::try_from(estimate_item_token_count(item)).unwrap_or(usize::MAX); }; content @@ -676,7 +686,7 @@ fn truncate_message_text_to_token_budget( phase, } = item else { - return Some(item); + return None; }; let mut remaining = max_tokens; @@ -718,8 +728,10 @@ fn truncate_message_text_to_token_budget( #[cfg(test)] mod tests { use super::*; + use codex_protocol::AgentPath; use codex_protocol::models::ContentItem; use codex_protocol::models::MessagePhase; + use codex_protocol::protocol::InterAgentCommunication; use pretty_assertions::assert_eq; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; @@ -780,6 +792,35 @@ mod tests { ); } + #[test] + fn build_v2_compacted_history_retains_encrypted_delegated_message_after_formatting() { + let communication = InterAgentCommunication::new_encrypted( + AgentPath::root(), + AgentPath::root().join("worker").expect("valid worker path"), + Vec::new(), + "encrypted delegated task".to_string(), + /*trigger_turn*/ true, + ); + let persisted_envelope: ResponseItem = communication.to_response_input_item().into(); + assert!(matches!( + persisted_envelope, + ResponseItem::Message { ref role, .. } if role == "assistant" + )); + + let formatted = Prompt::format_input(&[persisted_envelope]); + assert!(matches!( + formatted.first(), + Some(ResponseItem::AgentMessage { .. }) + )); + + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), + }; + let history = build_v2_compacted_history(&formatted, output.clone()); + + assert_eq!(history, vec![formatted[0].clone(), output]); + } + #[test] fn build_v2_compacted_history_discards_messages_before_truncating() { let old = message("user", "old", /*phase*/ None); diff --git a/codex-rs/core/src/context_manager/history.rs b/codex-rs/core/src/context_manager/history.rs index 6086fbcd8..fd8f24b5b 100644 --- a/codex-rs/core/src/context_manager/history.rs +++ b/codex-rs/core/src/context_manager/history.rs @@ -612,7 +612,7 @@ fn estimate_encrypted_function_output_length(encoded_len: usize) -> usize { encoded_len.saturating_mul(9).div_ceil(16) } -fn estimate_item_token_count(item: &ResponseItem) -> i64 { +pub(crate) fn estimate_item_token_count(item: &ResponseItem) -> i64 { let model_visible_bytes = estimate_response_item_model_visible_bytes(item); approx_tokens_from_byte_count_i64(model_visible_bytes) } diff --git a/codex-rs/core/src/context_manager/mod.rs b/codex-rs/core/src/context_manager/mod.rs index 2295c49df..8c12ed939 100644 --- a/codex-rs/core/src/context_manager/mod.rs +++ b/codex-rs/core/src/context_manager/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod updates; pub(crate) use history::ContextManager; pub(crate) use history::TotalTokenUsageBreakdown; +pub(crate) use history::estimate_item_token_count; pub(crate) use history::estimate_response_item_model_visible_bytes; pub(crate) use history::is_user_turn_boundary; pub(crate) use history::truncate_function_output_payload;