mirror of
https://github.com/openai/codex.git
synced 2026-09-08 15:50:34 +00:00
## Why Resumed threads need to continue per-turn and per-thread token totals without scanning arbitrarily far beyond the latest compaction checkpoint. ## What changed - Add durable `TokenUsageRecord` rollout items with response, turn, thread, session, and root-turn attribution. - Restore accumulated usage on resume and snapshot the latest record plus the compaction response ID in compaction checkpoints. - Preserve root-turn lineage in persisted turn context, while ensuring forked child threads start with their own usage totals. ## Testing - Cover usage accumulation across multiple responses and resumed turns. - Cover local and remote compaction checkpoints, invalid remote compaction output, rollout reconstruction, and fork isolation. GitOrigin-RevId: ef9e0c4a9102a08a2c382be4cdac68c84353c90a
525 lines
20 KiB
Rust
525 lines
20 KiB
Rust
use std::sync::Arc;
|
|
use std::sync::OnceLock;
|
|
|
|
use crate::compact::CompactedHistoryMetadata;
|
|
use crate::compact::CompactionAnalyticsAttempt;
|
|
use crate::compact::CompactionAnalyticsDetails;
|
|
use crate::compact::InitialContextInjection;
|
|
use crate::compact::build_compaction_initial_context;
|
|
use crate::compact::compaction_status_from_result;
|
|
use crate::compact::insert_initial_context_before_last_real_user_or_summary;
|
|
use crate::compact_model_fallback::record_model_fallback;
|
|
use crate::compact_model_fallback::should_retry_with_current_model;
|
|
use crate::compact_remote_history::HistoryItemGroup;
|
|
use crate::compact_remote_history::history_item_groups;
|
|
use crate::context::world_state::WorldState;
|
|
use crate::context_manager::ContextManager;
|
|
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;
|
|
use crate::hook_runtime::run_pre_compact_hooks;
|
|
use crate::responses_metadata::CompactionTurnMetadata;
|
|
use crate::session::session::Session;
|
|
use crate::session::step_context::StepContext;
|
|
use crate::session::turn_context::TurnContext;
|
|
use codex_analytics::CompactionImplementation;
|
|
use codex_analytics::CompactionPhase;
|
|
use codex_analytics::CompactionReason;
|
|
use codex_analytics::CompactionTrigger;
|
|
use codex_history::ResponseItemEnvelope;
|
|
use codex_protocol::error::CodexErr;
|
|
use codex_protocol::error::Result as CodexResult;
|
|
use codex_protocol::items::ContextCompactionItem;
|
|
use codex_protocol::items::TurnItem;
|
|
use codex_protocol::models::BaseInstructions;
|
|
use codex_protocol::models::FunctionCallOutputBody;
|
|
use codex_protocol::models::FunctionCallOutputPayload;
|
|
use codex_protocol::models::ResponseItem;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_rollout_trace::CompactionCheckpointTracePayload;
|
|
use codex_utils_output_truncation::approx_token_count;
|
|
use tokio_util::sync::CancellationToken;
|
|
|
|
#[path = "compact_remote_request.rs"]
|
|
mod request;
|
|
use request::RemoteCompactAttempt;
|
|
use request::run_remote_compact_attempt;
|
|
|
|
const CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE: &str =
|
|
"Output exceeded the available model context and was truncated";
|
|
|
|
pub(crate) async fn run_inline_remote_auto_compact_task(
|
|
sess: Arc<Session>,
|
|
step_context: Arc<StepContext>,
|
|
fallback_step_context: Option<Arc<StepContext>>,
|
|
turn_state: Arc<OnceLock<String>>,
|
|
initial_context_injection: InitialContextInjection,
|
|
reason: CompactionReason,
|
|
phase: CompactionPhase,
|
|
) -> CodexResult<()> {
|
|
let compaction_metadata = CompactionTurnMetadata::new(
|
|
CompactionTrigger::Auto,
|
|
reason,
|
|
CompactionImplementation::ResponsesCompact,
|
|
phase,
|
|
);
|
|
run_remote_compact_task_inner(
|
|
&sess,
|
|
&step_context,
|
|
fallback_step_context.as_ref(),
|
|
Some(turn_state),
|
|
initial_context_injection,
|
|
compaction_metadata,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn run_remote_compact_task(
|
|
sess: Arc<Session>,
|
|
turn_context: Arc<TurnContext>,
|
|
) -> CodexResult<()> {
|
|
// Standalone compaction is its own request boundary, so it captures a fresh step.
|
|
let step_context = sess
|
|
.capture_step_context(Arc::clone(&turn_context), &CancellationToken::new())
|
|
.await?;
|
|
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
|
|
turn_id: turn_context.sub_id.clone(),
|
|
trace_id: turn_context.trace_id.clone(),
|
|
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
|
|
model_context_window: turn_context.model_context_window(),
|
|
collaboration_mode_kind: turn_context.mode(),
|
|
});
|
|
sess.send_event(&turn_context, start_event).await;
|
|
|
|
let compaction_metadata = CompactionTurnMetadata::new(
|
|
CompactionTrigger::Manual,
|
|
CompactionReason::UserRequested,
|
|
CompactionImplementation::ResponsesCompact,
|
|
CompactionPhase::StandaloneTurn,
|
|
);
|
|
run_remote_compact_task_inner(
|
|
&sess,
|
|
&step_context,
|
|
/*fallback_step_context*/ None,
|
|
/*turn_state*/ None,
|
|
InitialContextInjection::DoNotInject,
|
|
compaction_metadata,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_remote_compact_task_inner(
|
|
sess: &Arc<Session>,
|
|
step_context: &Arc<StepContext>,
|
|
fallback_step_context: Option<&Arc<StepContext>>,
|
|
turn_state: Option<Arc<OnceLock<String>>>,
|
|
initial_context_injection: InitialContextInjection,
|
|
compaction_metadata: CompactionTurnMetadata,
|
|
) -> CodexResult<()> {
|
|
let turn_context = &step_context.turn;
|
|
let trigger = compaction_metadata.trigger();
|
|
let reason = compaction_metadata.reason();
|
|
let implementation = compaction_metadata.implementation();
|
|
let phase = compaction_metadata.phase();
|
|
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(),
|
|
trigger,
|
|
reason,
|
|
implementation,
|
|
phase,
|
|
)
|
|
.await;
|
|
let pre_compact_outcome = run_pre_compact_hooks(sess, turn_context, trigger).await;
|
|
match pre_compact_outcome {
|
|
PreCompactHookOutcome::Continue => {}
|
|
PreCompactHookOutcome::Stopped => {
|
|
let error = CodexErr::TurnAborted;
|
|
attempt
|
|
.track(
|
|
sess.as_ref(),
|
|
codex_analytics::CompactionStatus::Interrupted,
|
|
Some(&error),
|
|
analytics_details,
|
|
)
|
|
.await;
|
|
return Err(error);
|
|
}
|
|
}
|
|
let result = run_remote_compact_task_inner_impl(
|
|
sess,
|
|
step_context,
|
|
fallback_step_context,
|
|
turn_state,
|
|
initial_context_injection,
|
|
compaction_metadata,
|
|
&mut analytics_details,
|
|
)
|
|
.await;
|
|
let status = compaction_status_from_result(&result);
|
|
let codex_error = result.as_ref().err();
|
|
if result.is_ok() {
|
|
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, codex_error, analytics_details)
|
|
.await;
|
|
return Err(CodexErr::TurnAborted);
|
|
}
|
|
}
|
|
attempt
|
|
.track(sess.as_ref(), status, codex_error, analytics_details)
|
|
.await;
|
|
if let Err(err) = result {
|
|
sess.track_turn_codex_error(turn_context, &err);
|
|
let event = EventMsg::Error(
|
|
err.to_error_event(Some("Error running remote compact task".to_string())),
|
|
);
|
|
sess.send_event(turn_context, event).await;
|
|
return Err(err);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_remote_compact_task_inner_impl(
|
|
sess: &Arc<Session>,
|
|
step_context: &Arc<StepContext>,
|
|
fallback_step_context: Option<&Arc<StepContext>>,
|
|
turn_state: Option<Arc<OnceLock<String>>>,
|
|
initial_context_injection: InitialContextInjection,
|
|
compaction_metadata: CompactionTurnMetadata,
|
|
analytics_details: &mut CompactionAnalyticsDetails,
|
|
) -> CodexResult<()> {
|
|
let turn_context = &step_context.turn;
|
|
let context_compaction_item = ContextCompactionItem::new();
|
|
let compaction_id = context_compaction_item.id.clone();
|
|
// Use the UI compaction item ID as the trace compaction ID so protocol lifecycle events,
|
|
// endpoint attempts, and the installed history checkpoint all have one join key.
|
|
let compaction_trace = sess.services.rollout_thread_trace.compaction_trace_context(
|
|
turn_context.sub_id.as_str(),
|
|
compaction_id.as_str(),
|
|
turn_context.model_info().slug.as_str(),
|
|
turn_context.provider.info().name.as_str(),
|
|
);
|
|
let compaction_item = TurnItem::ContextCompaction(context_compaction_item);
|
|
sess.emit_turn_item_started(turn_context, &compaction_item)
|
|
.await;
|
|
let attempt = run_remote_compact_attempt(
|
|
sess,
|
|
step_context,
|
|
turn_state.clone(),
|
|
&compaction_trace,
|
|
compaction_metadata,
|
|
analytics_details,
|
|
)
|
|
.await;
|
|
let (attempt, compaction_turn_context) = match attempt {
|
|
Ok(attempt) => (attempt, turn_context),
|
|
Err(error) => {
|
|
let Some(fallback_step_context) = fallback_step_context else {
|
|
return Err(error);
|
|
};
|
|
if !should_retry_with_current_model(&error) {
|
|
return Err(error);
|
|
}
|
|
sess.set_last_known_step_context(fallback_step_context)
|
|
.await;
|
|
let fallback_turn_context = &fallback_step_context.turn;
|
|
let fallback_compaction_trace =
|
|
sess.services.rollout_thread_trace.compaction_trace_context(
|
|
fallback_turn_context.sub_id.as_str(),
|
|
compaction_id.as_str(),
|
|
fallback_turn_context.model_info().slug.as_str(),
|
|
fallback_turn_context.provider.info().name.as_str(),
|
|
);
|
|
let fallback_result = run_remote_compact_attempt(
|
|
sess,
|
|
fallback_step_context,
|
|
turn_state,
|
|
&fallback_compaction_trace,
|
|
compaction_metadata,
|
|
analytics_details,
|
|
)
|
|
.await;
|
|
record_model_fallback(
|
|
&sess.services.session_telemetry,
|
|
turn_context.model_info().slug.as_str(),
|
|
fallback_turn_context.model_info().slug.as_str(),
|
|
compaction_metadata.reason(),
|
|
compaction_metadata.implementation(),
|
|
fallback_result.as_ref().err(),
|
|
);
|
|
match fallback_result {
|
|
Ok(attempt) => (attempt, fallback_turn_context),
|
|
Err(_) => return Err(error),
|
|
}
|
|
}
|
|
};
|
|
let RemoteCompactAttempt {
|
|
new_history,
|
|
trace_input_history,
|
|
} = attempt;
|
|
let (new_window_number, new_window_ids) = sess.advance_auto_compact_window().await;
|
|
let (new_history, world_state_baseline) =
|
|
process_compacted_history(sess.as_ref(), new_history, &initial_context_injection).await;
|
|
|
|
let reference_context_item = match initial_context_injection {
|
|
InitialContextInjection::DoNotInject => None,
|
|
InitialContextInjection::BeforeLastUserMessage { .. } => {
|
|
Some(compaction_turn_context.to_turn_context_item())
|
|
}
|
|
};
|
|
// Install is the semantic boundary where the compact endpoint's output becomes live
|
|
// thread history. Keep it distinct from the later inference request so the reducer can
|
|
// still represent repeated developer/context prefix items exactly as the model saw them.
|
|
if let Some(trace_input_history) = trace_input_history.as_deref() {
|
|
compaction_trace.record_installed(&CompactionCheckpointTracePayload {
|
|
input_history: trace_input_history,
|
|
replacement_history: &new_history,
|
|
});
|
|
}
|
|
// Legacy `/responses/compact` returns provider-normalized items without a stable link to their
|
|
// original envelopes, so it does not preserve harness metadata. Compaction-trigger/v2 does.
|
|
let new_history = new_history
|
|
.into_iter()
|
|
.map(ResponseItemEnvelope::new)
|
|
.collect();
|
|
sess.replace_compacted_history(
|
|
new_history,
|
|
reference_context_item,
|
|
world_state_baseline,
|
|
CompactedHistoryMetadata {
|
|
message: String::new(),
|
|
window_number: new_window_number,
|
|
window_ids: new_window_ids,
|
|
compaction_response_id: None,
|
|
},
|
|
)
|
|
.await;
|
|
sess.recompute_token_usage(compaction_turn_context).await;
|
|
|
|
sess.emit_turn_item_completed(compaction_turn_context, compaction_item)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn process_compacted_history(
|
|
sess: &Session,
|
|
compacted_history: Vec<ResponseItem>,
|
|
initial_context_injection: &InitialContextInjection,
|
|
) -> (Vec<ResponseItem>, Option<Arc<WorldState>>) {
|
|
let compacted_history = compacted_history
|
|
.into_iter()
|
|
.map(ResponseItemEnvelope::new)
|
|
.collect();
|
|
let (compacted_history, world_state_baseline) =
|
|
process_annotated_compacted_history(sess, compacted_history, initial_context_injection)
|
|
.await;
|
|
(
|
|
compacted_history
|
|
.into_iter()
|
|
.map(ResponseItemEnvelope::into_item)
|
|
.collect(),
|
|
world_state_baseline,
|
|
)
|
|
}
|
|
|
|
/// Installs already-annotated remote compaction output without dropping its metadata sidecar.
|
|
pub(crate) async fn process_annotated_compacted_history(
|
|
sess: &Session,
|
|
compacted_history: Vec<ResponseItemEnvelope>,
|
|
initial_context_injection: &InitialContextInjection,
|
|
) -> (Vec<ResponseItemEnvelope>, Option<Arc<WorldState>>) {
|
|
// Mid-turn compaction is the only path that must inject initial context above the last user
|
|
// message in the replacement history. Pre-turn compaction instead injects context after the
|
|
// compaction item, but mid-turn compaction keeps the compaction item last for model training.
|
|
let (initial_context, world_state_baseline) =
|
|
build_compaction_initial_context(sess, initial_context_injection).await;
|
|
|
|
let compacted_history = history_item_groups(compacted_history)
|
|
.filter(|group| should_keep_compacted_history_item(&group.source.item))
|
|
.flat_map(HistoryItemGroup::into_items)
|
|
.collect();
|
|
(
|
|
insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context),
|
|
world_state_baseline,
|
|
)
|
|
}
|
|
|
|
/// Returns whether an item from remote compaction output should be preserved.
|
|
///
|
|
/// Called while processing the model-provided compacted transcript, before we
|
|
/// append fresh canonical context from the current session.
|
|
///
|
|
/// We drop:
|
|
/// - `developer` messages because remote output can include stale/duplicated
|
|
/// instruction content.
|
|
/// - non-user-content `user` messages (session prefix/instruction wrappers),
|
|
/// while preserving real user messages and persisted hook prompts.
|
|
///
|
|
/// This intentionally keeps:
|
|
/// - `assistant` messages (future remote compaction models may emit them)
|
|
/// - `user`-role warnings that parse as `TurnItem::UserMessage` and compaction-generated summary
|
|
/// messages. Legacy warning fragments are filtered by `parse_turn_item` before they reach this
|
|
/// check.
|
|
pub(crate) fn should_keep_compacted_history_item(item: &ResponseItem) -> bool {
|
|
match item {
|
|
ResponseItem::Message { role, .. } if role == "developer" => false,
|
|
ResponseItem::Message { role, .. } if role == "user" => {
|
|
matches!(
|
|
crate::event_mapping::parse_turn_item(item),
|
|
Some(TurnItem::UserMessage(_) | TurnItem::HookPrompt(_))
|
|
)
|
|
}
|
|
ResponseItem::Message { role, .. } if role == "assistant" => true,
|
|
ResponseItem::Message { .. } => false,
|
|
ResponseItem::AgentMessage { .. } => true,
|
|
ResponseItem::Compaction { .. } | ResponseItem::ContextCompaction { .. } => true,
|
|
ResponseItem::CompactionTrigger { .. } => false,
|
|
ResponseItem::AdditionalTools { .. }
|
|
| ResponseItem::Reasoning { .. }
|
|
| ResponseItem::LocalShellCall { .. }
|
|
| ResponseItem::FunctionCall { .. }
|
|
| ResponseItem::ToolSearchCall { .. }
|
|
| ResponseItem::FunctionCallOutput { .. }
|
|
| ResponseItem::ToolSearchOutput { .. }
|
|
| ResponseItem::CustomToolCall { .. }
|
|
| ResponseItem::CustomToolCallOutput { .. }
|
|
| ResponseItem::WebSearchCall { .. }
|
|
| ResponseItem::ImageGenerationCall { .. }
|
|
| ResponseItem::Other => false,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn trim_function_call_history_to_fit_context_window(
|
|
history: &mut ContextManager,
|
|
turn_context: &TurnContext,
|
|
base_instructions: &BaseInstructions,
|
|
) -> (usize, i64) {
|
|
let Some(context_window) = turn_context.model_context_window() else {
|
|
return (0, 0);
|
|
};
|
|
// Keep the unclamped total so replacing an item cannot lose an overflow hidden by i64
|
|
// saturation in the normal history estimator.
|
|
let base_tokens =
|
|
i128::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i128::MAX);
|
|
let original_items = history.annotated_items();
|
|
let mut estimated_tokens = history_item_groups(original_items.iter().map(|item| &item.item))
|
|
.map(|group| group.estimated_token_count())
|
|
.fold(base_tokens, i128::saturating_add);
|
|
let initial_estimated_tokens = i64::try_from(estimated_tokens).unwrap_or(i64::MAX);
|
|
let mut rewritten_items = Vec::new();
|
|
let mut consumed_items: usize = 0;
|
|
|
|
for group in history_item_groups(original_items.iter().map(|item| &item.item))
|
|
.collect::<Vec<_>>()
|
|
.into_iter()
|
|
.rev()
|
|
{
|
|
if i64::try_from(estimated_tokens).unwrap_or(i64::MAX) <= context_window {
|
|
break;
|
|
}
|
|
let group_item_count = 1 + usize::from(group.attached_notice.is_some());
|
|
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 {
|
|
break;
|
|
};
|
|
estimated_tokens = estimated_tokens
|
|
.saturating_sub(group.estimated_token_count())
|
|
.saturating_add(i128::from(estimate_item_token_count(&rewritten_item.item)));
|
|
consumed_items += group_item_count;
|
|
rewritten_items.push(rewritten_item);
|
|
}
|
|
|
|
let rewritten_outputs = rewritten_items.len();
|
|
if rewritten_outputs > 0 {
|
|
let retained_len = original_items.len() - consumed_items;
|
|
let mut items = original_items[..retained_len].to_vec();
|
|
items.extend(rewritten_items.into_iter().rev());
|
|
history.replace_annotated(items);
|
|
}
|
|
|
|
let final_estimated_tokens = i64::try_from(estimated_tokens).unwrap_or(i64::MAX);
|
|
let estimated_deleted_tokens = initial_estimated_tokens.saturating_sub(final_estimated_tokens);
|
|
(rewritten_outputs, estimated_deleted_tokens)
|
|
}
|
|
|
|
fn rewritten_output_for_context_window(
|
|
envelope: &ResponseItemEnvelope,
|
|
) -> Option<ResponseItemEnvelope> {
|
|
let item = match &envelope.item {
|
|
ResponseItem::FunctionCallOutput {
|
|
id,
|
|
call_id,
|
|
name,
|
|
namespace,
|
|
output,
|
|
internal_chat_message_metadata_passthrough: metadata,
|
|
} => ResponseItem::FunctionCallOutput {
|
|
id: id.clone(),
|
|
call_id: call_id.clone(),
|
|
name: name.clone(),
|
|
namespace: namespace.clone(),
|
|
output: truncated_output_payload(output),
|
|
internal_chat_message_metadata_passthrough: metadata.clone(),
|
|
},
|
|
ResponseItem::CustomToolCallOutput {
|
|
id,
|
|
call_id,
|
|
name,
|
|
output,
|
|
internal_chat_message_metadata_passthrough: metadata,
|
|
} => ResponseItem::CustomToolCallOutput {
|
|
id: id.clone(),
|
|
call_id: call_id.clone(),
|
|
name: name.clone(),
|
|
output: truncated_output_payload(output),
|
|
internal_chat_message_metadata_passthrough: metadata.clone(),
|
|
},
|
|
ResponseItem::ToolSearchOutput {
|
|
id,
|
|
call_id,
|
|
status,
|
|
execution,
|
|
internal_chat_message_metadata_passthrough: metadata,
|
|
..
|
|
} => ResponseItem::ToolSearchOutput {
|
|
id: id.clone(),
|
|
call_id: call_id.clone(),
|
|
status: status.clone(),
|
|
execution: execution.clone(),
|
|
tools: Vec::new(),
|
|
internal_chat_message_metadata_passthrough: metadata.clone(),
|
|
},
|
|
_ => return None,
|
|
};
|
|
Some(ResponseItemEnvelope {
|
|
item,
|
|
metadata: envelope.metadata.clone(),
|
|
})
|
|
}
|
|
|
|
fn truncated_output_payload(output: &FunctionCallOutputPayload) -> FunctionCallOutputPayload {
|
|
FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::Text(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()),
|
|
success: output.success,
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "compact_remote_metadata_tests.rs"]
|
|
mod metadata_tests;
|