diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index f4cd18da1..a3b6dfa94 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -1253,6 +1253,8 @@ fn compaction_event_serializes_expected_shape() { error: None, active_context_tokens_before: 120_000, active_context_tokens_after: 18_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 106, duration_ms: Some(6543), @@ -1301,6 +1303,8 @@ fn compaction_event_serializes_expected_shape() { "error": null, "active_context_tokens_before": 120000, "active_context_tokens_after": 18000, + "retained_image_count": null, + "compaction_summary_tokens": null, "started_at": 100, "completed_at": 106, "duration_ms": 6543 @@ -1821,6 +1825,8 @@ async fn compaction_event_ingests_custom_fact() { error: Some("context limit exceeded".to_string()), active_context_tokens_before: 131_000, active_context_tokens_after: 131_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 101, duration_ms: Some(1200), @@ -2743,6 +2749,8 @@ async fn subagent_thread_started_inherits_parent_connection_for_new_thread() { error: None, active_context_tokens_before: 131_000, active_context_tokens_after: 64_000, + retained_image_count: None, + compaction_summary_tokens: None, started_at: 100, completed_at: 101, duration_ms: Some(1200), diff --git a/codex-rs/analytics/src/events.rs b/codex-rs/analytics/src/events.rs index f0fcddd27..019d59776 100644 --- a/codex-rs/analytics/src/events.rs +++ b/codex-rs/analytics/src/events.rs @@ -758,6 +758,8 @@ pub(crate) struct CodexCompactionEventParams { pub(crate) error: Option, pub(crate) active_context_tokens_before: i64, pub(crate) active_context_tokens_after: i64, + pub(crate) retained_image_count: Option, + pub(crate) compaction_summary_tokens: Option, pub(crate) started_at: u64, pub(crate) completed_at: u64, pub(crate) duration_ms: Option, @@ -970,6 +972,8 @@ pub(crate) fn codex_compaction_event_params( error: input.error, active_context_tokens_before: input.active_context_tokens_before, active_context_tokens_after: input.active_context_tokens_after, + retained_image_count: input.retained_image_count, + compaction_summary_tokens: input.compaction_summary_tokens, started_at: input.started_at, completed_at: input.completed_at, duration_ms: input.duration_ms, diff --git a/codex-rs/analytics/src/facts.rs b/codex-rs/analytics/src/facts.rs index 38af5ed8b..c9d245ce9 100644 --- a/codex-rs/analytics/src/facts.rs +++ b/codex-rs/analytics/src/facts.rs @@ -434,6 +434,8 @@ pub struct CodexCompactionEvent { pub error: Option, pub active_context_tokens_before: i64, pub active_context_tokens_after: i64, + pub retained_image_count: Option, + pub compaction_summary_tokens: Option, pub started_at: u64, pub completed_at: u64, pub duration_ms: Option, diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs index 18b583829..e2573d6d6 100644 --- a/codex-rs/core/src/compact.rs +++ b/codex-rs/core/src/compact.rs @@ -150,7 +150,7 @@ async fn run_compact_task_inner( sess.as_ref(), CompactionStatus::Interrupted, Some(error), - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; return Err(CodexErr::TurnAborted); @@ -174,7 +174,7 @@ async fn run_compact_task_inner( sess.as_ref(), status, error, - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; return Err(CodexErr::TurnAborted); @@ -185,7 +185,7 @@ async fn run_compact_task_inner( sess.as_ref(), status, error, - /*active_context_tokens_before*/ None, + CompactionAnalyticsDetails::default(), ) .await; result.map(|_| ()) @@ -335,6 +335,13 @@ pub(crate) struct CompactionAnalyticsAttempt { start_instant: Instant, } +#[derive(Clone, Copy, Default)] +pub(crate) struct CompactionAnalyticsDetails { + pub(crate) active_context_tokens_before: Option, + pub(crate) retained_image_count: Option, + pub(crate) compaction_summary_tokens: Option, +} + impl CompactionAnalyticsAttempt { pub(crate) async fn begin( sess: &Session, @@ -363,8 +370,13 @@ impl CompactionAnalyticsAttempt { sess: &Session, status: CompactionStatus, error: Option, - active_context_tokens_before: Option, + details: CompactionAnalyticsDetails, ) { + let CompactionAnalyticsDetails { + active_context_tokens_before, + retained_image_count, + compaction_summary_tokens, + } = details; let active_context_tokens_before = active_context_tokens_before.unwrap_or(self.active_context_tokens_before); let active_context_tokens_after = sess.get_total_token_usage().await; @@ -382,6 +394,8 @@ impl CompactionAnalyticsAttempt { error, active_context_tokens_before, active_context_tokens_after, + retained_image_count, + compaction_summary_tokens, started_at: self.started_at, completed_at: now_unix_seconds(), duration_ms: Some( diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs index c7730ddfb..5fca191b8 100644 --- a/codex-rs/core/src/compact_remote.rs +++ b/codex-rs/core/src/compact_remote.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use crate::Prompt; use crate::client::CompactConversationRequestSettings; use crate::compact::CompactionAnalyticsAttempt; +use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact::insert_initial_context_before_last_real_user_or_summary; @@ -100,7 +101,10 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompact, phase, ); - let mut active_context_tokens_before = sess.get_total_token_usage().await; + let mut analytics_details = CompactionAnalyticsDetails { + active_context_tokens_before: Some(sess.get_total_token_usage().await), + ..Default::default() + }; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -120,7 +124,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), - Some(active_context_tokens_before), + analytics_details, ) .await; return Err(CodexErr::TurnAborted); @@ -131,7 +135,7 @@ async fn run_remote_compact_task_inner( turn_context, initial_context_injection, compaction_metadata, - &mut active_context_tokens_before, + &mut analytics_details, ) .await; let status = compaction_status_from_result(&result); @@ -140,23 +144,13 @@ async fn run_remote_compact_task_inner( let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { attempt - .track( - sess.as_ref(), - status, - error, - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error, analytics_details) .await; return Err(CodexErr::TurnAborted); } } attempt - .track( - sess.as_ref(), - status, - error.clone(), - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error.clone(), analytics_details) .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); @@ -174,7 +168,7 @@ async fn run_remote_compact_task_inner_impl( turn_context: &Arc, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, - active_context_tokens_before: &mut i64, + analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); // Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events, @@ -208,8 +202,12 @@ async fn run_remote_compact_task_inner_impl( .get_total_token_usage_breakdown() .await .estimated_tokens_of_items_added_since_last_successful_api_response; - *active_context_tokens_before = (*active_context_tokens_before) - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); } // This is the history selected for remote compaction, after any output rewriting required to // fit the compact endpoint. The checkpoint below records it separately from the next sampling diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs index 5f585862d..1d4eb8ad5 100644 --- a/codex-rs/core/src/compact_remote_v2.rs +++ b/codex-rs/core/src/compact_remote_v2.rs @@ -5,6 +5,7 @@ use crate::ResponseStream; use crate::client::ModelClientSession; use crate::client_common::ResponseEvent; use crate::compact::CompactionAnalyticsAttempt; +use crate::compact::CompactionAnalyticsDetails; use crate::compact::InitialContextInjection; use crate::compact::compaction_status_from_result; use crate::compact_remote::build_compact_request_log_data; @@ -112,7 +113,10 @@ async fn run_remote_compact_task_inner( CompactionImplementation::ResponsesCompactionV2, phase, ); - let mut active_context_tokens_before = sess.get_total_token_usage().await; + let mut analytics_details = CompactionAnalyticsDetails { + active_context_tokens_before: Some(sess.get_total_token_usage().await), + ..Default::default() + }; let attempt = CompactionAnalyticsAttempt::begin( sess.as_ref(), turn_context.as_ref(), @@ -132,7 +136,7 @@ async fn run_remote_compact_task_inner( sess.as_ref(), codex_analytics::CompactionStatus::Interrupted, Some(error), - Some(active_context_tokens_before), + analytics_details, ) .await; return Err(CodexErr::TurnAborted); @@ -144,7 +148,7 @@ async fn run_remote_compact_task_inner( client_session, initial_context_injection, compaction_metadata, - &mut active_context_tokens_before, + &mut analytics_details, ) .await; let status = compaction_status_from_result(&result); @@ -153,23 +157,13 @@ async fn run_remote_compact_task_inner( let post_compact_outcome = run_post_compact_hooks(sess, turn_context, trigger).await; if let PostCompactHookOutcome::Stopped = post_compact_outcome { attempt - .track( - sess.as_ref(), - status, - error, - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error, analytics_details) .await; return Err(CodexErr::TurnAborted); } } attempt - .track( - sess.as_ref(), - status, - error.clone(), - Some(active_context_tokens_before), - ) + .track(sess.as_ref(), status, error.clone(), analytics_details) .await; if let Err(err) = result { sess.track_turn_codex_error(turn_context, &err); @@ -188,7 +182,7 @@ async fn run_remote_compact_task_inner_impl( client_session: Option<&mut ModelClientSession>, initial_context_injection: InitialContextInjection, compaction_metadata: CompactionTurnMetadata, - active_context_tokens_before: &mut i64, + analytics_details: &mut CompactionAnalyticsDetails, ) -> CodexResult<()> { let context_compaction_item = ContextCompactionItem::new(); let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context( @@ -221,8 +215,12 @@ async fn run_remote_compact_task_inner_impl( .get_total_token_usage_breakdown() .await .estimated_tokens_of_items_added_since_last_successful_api_response; - *active_context_tokens_before = (*active_context_tokens_before) - .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)); + analytics_details.active_context_tokens_before = analytics_details + .active_context_tokens_before + .map(|active_context_tokens_before| { + active_context_tokens_before + .saturating_sub(estimated_deleted_tokens.min(max_local_deleted_tokens)) + }); } let trace_input_history = history.raw_items().to_vec(); @@ -283,9 +281,12 @@ async fn run_remote_compact_task_inner_impl( token_usage, } = compaction_output_result?; if let Some(token_usage) = token_usage { - *active_context_tokens_before = token_usage.input_tokens; + analytics_details.active_context_tokens_before = Some(token_usage.input_tokens); + analytics_details.compaction_summary_tokens = Some(token_usage.output_tokens); } - let compacted_history = build_v2_compacted_history(&prompt_input, compaction_output); + let (compacted_history, retained_images) = + build_v2_compacted_history(&prompt_input, compaction_output); + analytics_details.retained_image_count = Some(retained_images); let new_history = process_compacted_history( sess.as_ref(), turn_context.as_ref(), @@ -447,7 +448,7 @@ async fn collect_compaction_output( fn build_v2_compacted_history( prompt_input: &[ResponseItem], compaction_output: ResponseItem, -) -> Vec { +) -> (Vec, usize) { let retained = prompt_input .iter() .filter(|item| is_retained_for_remote_compaction_v2(item)) @@ -456,8 +457,12 @@ fn build_v2_compacted_history( .collect::>(); let mut retained = truncate_retained_messages_for_remote_compaction(retained, RETAINED_MESSAGE_TOKEN_BUDGET); + let retained_image_count = retained + .iter() + .map(retained_input_image_count) + .sum::(); retained.push(compaction_output); - retained + (retained, retained_image_count) } fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { @@ -468,6 +473,17 @@ fn is_retained_for_remote_compaction_v2(item: &ResponseItem) -> bool { matches!(role.as_str(), "user" | "developer" | "system") } +fn retained_input_image_count(item: &ResponseItem) -> usize { + let ResponseItem::Message { content, .. } = item else { + return 0; + }; + + content + .iter() + .filter(|item| matches!(item, ContentItem::InputImage { .. })) + .count() +} + fn truncate_retained_messages_for_remote_compaction( items: Vec, max_tokens: usize, @@ -617,7 +633,7 @@ mod tests { encrypted_content: "new".to_string(), }; - let history = build_v2_compacted_history(&input, output.clone()); + let (history, _) = build_v2_compacted_history(&input, output.clone()); assert_eq!( history, @@ -644,11 +660,40 @@ mod tests { encrypted_content: "new".to_string(), }; - let history = build_v2_compacted_history(&input, output.clone()); + let (history, _) = build_v2_compacted_history(&input, output.clone()); assert_eq!(history, vec![old, new, output]); } + #[test] + fn build_v2_compacted_history_counts_retained_input_images() { + let input = vec![ResponseItem::Message { + id: None, + role: "user".to_string(), + content: vec![ + ContentItem::InputText { + text: "user".to_string(), + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,abc".to_string(), + detail: None, + }, + ContentItem::InputImage { + image_url: "data:image/png;base64,def".to_string(), + detail: None, + }, + ], + phase: None, + }]; + let output = ResponseItem::Compaction { + encrypted_content: "new".to_string(), + }; + + let (_, retained_image_count) = build_v2_compacted_history(&input, output); + + assert_eq!(retained_image_count, 2); + } + #[test] fn retained_history_truncation_keeps_newest_messages_first() { let middle = message("user", "middle1234", /*phase*/ None);