mirror of
https://github.com/openai/codex.git
synced 2026-10-26 07:58:43 +00:00
## What Introduce a `CodexResponsesMetadata` struct that defines all the core metadata we send to Responses API. Example fields are `thread_id`, `turn_id`, `window_id`, etc. Going forward, `client_metadata["x-codex-turn-metadata"]` will be the canonical way Codex sends metadata to Responses API across both HTTP and websocket transports. For now, we continue to emit the existing top-level HTTP headers and top-level `client_metadata` fields from the same `CodexResponsesMetadata` struct for compatibility reasons. Also, app-server clients who specify additional `responsesapi_client_metadata` via `turn/start` and `turn/steer` will have those fields merged into `client_metadata["x-codex-turn-metadata"]`, but cannot override the reserved fields that core uses (i.e. the fields in `CodexResponsesMetadata`). ## Why Responses API request instrumentation is the source of truth for downstream Codex analytics that join requests by Codex IDs such as session, thread, turn, and context window. Before this change, those values were assembled through several request-specific paths: HTTP request bodies, websocket handshake headers, websocket `response.create` payloads, compaction requests, and the rich `x-codex-turn-metadata` envelope all had their own wiring. That made metadata propagation easy to drift across API-key/direct Responses API requests, ChatGPT-auth/proxied requests, websocket requests, and compaction requests. It also made additions like `window_id` error-prone because a field could be added to one transport projection but missed in another. ## What changed - Added `CodexResponsesMetadata` as the core-owned snapshot for Codex metadata sent to ResponsesAPI. - Render `client_metadata["x-codex-turn-metadata"]`, flat `client_metadata` projections, and direct compatibility headers from that same snapshot. - Include the known Codex-owned fields in the turn metadata blob, including installation/session/thread/turn/window IDs, request kind, lineage, sandbox/workspace metadata, timing, and compaction details. - Treat app-server `responsesapi_client_metadata` as enrichment for the Codex turn metadata blob while preventing those extras from overriding Codex-owned fields. - Use the same metadata path for normal turns, websocket prewarm, local compaction, remote v1 compaction, and remote v2 compaction. - Keep websocket connection-only preconnect metadata separate so handshakes carry compatibility identity headers without inventing a fake turn metadata blob. ## Verification - `cargo check -p codex-core` - `just fix -p codex-core`
432 lines
16 KiB
Rust
432 lines
16 KiB
Rust
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;
|
|
use crate::context_manager::ContextManager;
|
|
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::CodexResponsesRequestKind;
|
|
use crate::responses_metadata::CompactionTurnMetadata;
|
|
use crate::session::session::Session;
|
|
use crate::session::turn::built_tools;
|
|
use crate::session::turn_context::TurnContext;
|
|
use codex_analytics::CompactionImplementation;
|
|
use codex_analytics::CompactionPhase;
|
|
use codex_analytics::CompactionReason;
|
|
use codex_analytics::CompactionTrigger;
|
|
use codex_app_server_protocol::AuthMode;
|
|
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::CompactedItem;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::TurnStartedEvent;
|
|
use codex_rollout_trace::CompactionCheckpointTracePayload;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::info;
|
|
|
|
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>,
|
|
turn_context: Arc<TurnContext>,
|
|
initial_context_injection: InitialContextInjection,
|
|
reason: CompactionReason,
|
|
phase: CompactionPhase,
|
|
) -> CodexResult<()> {
|
|
run_remote_compact_task_inner(
|
|
&sess,
|
|
&turn_context,
|
|
initial_context_injection,
|
|
CompactionTrigger::Auto,
|
|
reason,
|
|
phase,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn run_remote_compact_task(
|
|
sess: Arc<Session>,
|
|
turn_context: Arc<TurnContext>,
|
|
) -> CodexResult<()> {
|
|
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.collaboration_mode.mode,
|
|
});
|
|
sess.send_event(&turn_context, start_event).await;
|
|
|
|
run_remote_compact_task_inner(
|
|
&sess,
|
|
&turn_context,
|
|
InitialContextInjection::DoNotInject,
|
|
CompactionTrigger::Manual,
|
|
CompactionReason::UserRequested,
|
|
CompactionPhase::StandaloneTurn,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn run_remote_compact_task_inner(
|
|
sess: &Arc<Session>,
|
|
turn_context: &Arc<TurnContext>,
|
|
initial_context_injection: InitialContextInjection,
|
|
trigger: CompactionTrigger,
|
|
reason: CompactionReason,
|
|
phase: CompactionPhase,
|
|
) -> CodexResult<()> {
|
|
let compaction_metadata = CompactionTurnMetadata::new(
|
|
trigger,
|
|
reason,
|
|
CompactionImplementation::ResponsesCompact,
|
|
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,
|
|
CompactionImplementation::ResponsesCompact,
|
|
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,
|
|
turn_context,
|
|
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>,
|
|
turn_context: &Arc<TurnContext>,
|
|
initial_context_injection: InitialContextInjection,
|
|
compaction_metadata: CompactionTurnMetadata,
|
|
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,
|
|
// 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(),
|
|
context_compaction_item.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 mut history = sess.clone_history().await;
|
|
let base_instructions = sess.get_base_instructions().await;
|
|
let (rewritten_outputs, estimated_deleted_tokens) =
|
|
trim_function_call_history_to_fit_context_window(
|
|
&mut history,
|
|
turn_context.as_ref(),
|
|
&base_instructions,
|
|
);
|
|
if rewritten_outputs > 0 {
|
|
info!(
|
|
turn_id = %turn_context.sub_id,
|
|
rewritten_outputs,
|
|
"rewrote history outputs before remote compaction"
|
|
);
|
|
}
|
|
if estimated_deleted_tokens > 0 {
|
|
let max_local_deleted_tokens = sess
|
|
.estimated_tokens_after_last_model_generated_item()
|
|
.await;
|
|
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
|
|
// request, whose prompt will repeat current developer/context prefix items.
|
|
let trace_input_history = history.raw_items().to_vec();
|
|
let prompt_input = history.for_prompt(&turn_context.model_info.input_modalities);
|
|
let tool_router = built_tools(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
&CancellationToken::new(),
|
|
)
|
|
.await?;
|
|
let prompt = Prompt {
|
|
input: prompt_input,
|
|
tools: tool_router.model_visible_specs(),
|
|
parallel_tool_calls: turn_context.model_info.supports_parallel_tool_calls,
|
|
base_instructions,
|
|
personality: turn_context.personality,
|
|
output_schema: None,
|
|
output_schema_strict: true,
|
|
};
|
|
let window_id = sess.current_window_id().await;
|
|
let responses_metadata = turn_context.turn_metadata_state.to_responses_metadata(
|
|
sess.installation_id.clone(),
|
|
window_id,
|
|
CodexResponsesRequestKind::Compaction(compaction_metadata),
|
|
);
|
|
let mut new_history = sess
|
|
.services
|
|
.model_client
|
|
.compact_conversation_history(
|
|
&prompt,
|
|
&turn_context.model_info,
|
|
CompactConversationRequestSettings {
|
|
effort: turn_context.reasoning_effort.clone(),
|
|
summary: turn_context.reasoning_summary,
|
|
service_tier: if sess.services.auth_manager.auth_mode() == Some(AuthMode::ApiKey) {
|
|
None
|
|
} else {
|
|
turn_context.config.service_tier.clone()
|
|
},
|
|
},
|
|
&turn_context.session_telemetry,
|
|
&compaction_trace,
|
|
&responses_metadata,
|
|
)
|
|
.await?;
|
|
let new_window_id = sess.advance_auto_compact_window_id().await;
|
|
new_history = process_compacted_history(
|
|
sess.as_ref(),
|
|
turn_context.as_ref(),
|
|
new_history,
|
|
initial_context_injection,
|
|
)
|
|
.await;
|
|
|
|
let reference_context_item = match initial_context_injection {
|
|
InitialContextInjection::DoNotInject => None,
|
|
InitialContextInjection::BeforeLastUserMessage => Some(turn_context.to_turn_context_item()),
|
|
};
|
|
let compacted_item = CompactedItem {
|
|
message: String::new(),
|
|
replacement_history: Some(new_history.clone()),
|
|
window_id: Some(new_window_id),
|
|
};
|
|
// 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.
|
|
compaction_trace.record_installed(&CompactionCheckpointTracePayload {
|
|
input_history: &trace_input_history,
|
|
replacement_history: &new_history,
|
|
});
|
|
sess.replace_compacted_history(new_history, reference_context_item, compacted_item)
|
|
.await;
|
|
sess.recompute_token_usage(turn_context).await;
|
|
|
|
sess.emit_turn_item_completed(turn_context, compaction_item)
|
|
.await;
|
|
Ok(())
|
|
}
|
|
|
|
pub(crate) async fn process_compacted_history(
|
|
sess: &Session,
|
|
turn_context: &TurnContext,
|
|
mut compacted_history: Vec<ResponseItem>,
|
|
initial_context_injection: InitialContextInjection,
|
|
) -> Vec<ResponseItem> {
|
|
// 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 = if matches!(
|
|
initial_context_injection,
|
|
InitialContextInjection::BeforeLastUserMessage
|
|
) {
|
|
sess.build_initial_context(turn_context).await
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
compacted_history.retain(should_keep_compacted_history_item);
|
|
insert_initial_context_before_last_real_user_or_summary(compacted_history, initial_context)
|
|
}
|
|
|
|
/// 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::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);
|
|
};
|
|
let mut rewritten_outputs = 0usize;
|
|
let mut estimated_deleted_tokens = 0i64;
|
|
let item_count = history.raw_items().len();
|
|
|
|
for index in (0..item_count).rev() {
|
|
let Some(estimated_tokens_before) =
|
|
history.estimate_token_count_with_base_instructions(base_instructions)
|
|
else {
|
|
break;
|
|
};
|
|
if estimated_tokens_before <= context_window {
|
|
break;
|
|
}
|
|
let Some(rewritten_item) = history
|
|
.raw_items()
|
|
.get(index)
|
|
.and_then(rewritten_output_for_context_window)
|
|
else {
|
|
break;
|
|
};
|
|
let mut items = history.raw_items().to_vec();
|
|
items[index] = rewritten_item;
|
|
history.replace(items);
|
|
let estimated_tokens_after = history
|
|
.estimate_token_count_with_base_instructions(base_instructions)
|
|
.unwrap_or_default();
|
|
rewritten_outputs += 1;
|
|
estimated_deleted_tokens = estimated_deleted_tokens
|
|
.saturating_add(estimated_tokens_before.saturating_sub(estimated_tokens_after));
|
|
}
|
|
|
|
(rewritten_outputs, estimated_deleted_tokens)
|
|
}
|
|
|
|
fn rewritten_output_for_context_window(item: &ResponseItem) -> Option<ResponseItem> {
|
|
Some(match item {
|
|
ResponseItem::FunctionCallOutput { call_id, output } => ResponseItem::FunctionCallOutput {
|
|
call_id: call_id.clone(),
|
|
output: truncated_output_payload(output),
|
|
},
|
|
ResponseItem::CustomToolCallOutput {
|
|
call_id,
|
|
name,
|
|
output,
|
|
} => ResponseItem::CustomToolCallOutput {
|
|
call_id: call_id.clone(),
|
|
name: name.clone(),
|
|
output: truncated_output_payload(output),
|
|
},
|
|
ResponseItem::ToolSearchOutput {
|
|
call_id,
|
|
status,
|
|
execution,
|
|
..
|
|
} => ResponseItem::ToolSearchOutput {
|
|
call_id: call_id.clone(),
|
|
status: status.clone(),
|
|
execution: execution.clone(),
|
|
tools: Vec::new(),
|
|
},
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
fn truncated_output_payload(output: &FunctionCallOutputPayload) -> FunctionCallOutputPayload {
|
|
FunctionCallOutputPayload {
|
|
body: FunctionCallOutputBody::Text(CONTEXT_WINDOW_TRUNCATED_OUTPUT_MESSAGE.to_string()),
|
|
success: output.success,
|
|
}
|
|
}
|