Files
codex/codex-rs/memories/write/src/runtime.rs
T
ningyi-oaiandGitHub bee78806a9 [codex] add compaction metadata to turn headers (#24368)
## Summary
- Add `request_kind` values for foreground turn, startup prewarm,
compaction, and detached memory model requests.
- Attach compaction dispatch metadata to local Responses, legacy
`/v1/responses/compact`, and remote v2 compact requests.
- Add the existing logical context-window identifier as `window_id` on
turn-owned model request metadata.
- Keep identity fields optional for detached memory requests, while
still emitting `request_kind="memory"` in non-git/no-sandbox workspaces.

## Root Cause
`x-codex-turn-metadata` has more than one producer. Foreground turns and
compaction requests own a real turn and should carry that turn identity.
Detached memory stage-one requests do not own a foreground turn, so
absent identity fields are valid rather than missing data. Startup
websocket prewarm is also a model request, but it has `generate=false`
and must not be counted as a foreground turn.

`thread_source` or session source identifies where a thread came from
(for example review, guardian, or another subagent). `request_kind`
identifies what the current outbound model request is doing (`turn`,
`prewarm`, `compaction`, or `memory`). A review or guardian thread can
issue either a normal turn request or a compaction request, so source
cannot replace request kind.

## Behavior / Impact
- Ordinary foreground requests send `request_kind="turn"`, their real
identity fields, and `window_id="<thread_id>:<window_generation>"`.
- Startup websocket warmup requests send `request_kind="prewarm"` so
they are not counted as foreground turns.
- Compaction requests send `request_kind="compaction"`, their real
owning turn identity, the existing `window_id`, and
`compaction.{trigger,reason,implementation,phase,strategy}`.
- Detached memory stage-one requests send `request_kind="memory"`
without `session_id`, `thread_id`, `turn_id`, or `window_id`; when no
workspace metadata exists, the kind-only header is still emitted.
- `session_id`, `thread_id`, `turn_id`, and `window_id` remain optional
in the header schema because detached memory requests do not own a
foreground turn or context window.
- `window_id` is not a new ID system: it is copied from the already-sent
`x-codex-window-id` / WS client metadata value at model-request dispatch
time.
- Existing `x-codex-window-id` HTTP/WS emission, value format,
generation advancement, resume behavior, and fork reset behavior are
unchanged.
- `request_kind`, `window_id`, and upstream turn-owned identity fields
remain schema-owned; input `responsesapi_client_metadata` cannot replace
their canonical values.
- No table, DAG, export, app-server API, or MCP `_meta` schema changes
are included.

A compaction attempt stopped by a pre-compact hook issues no model
request and therefore has no request header; its outcome remains in
analytics events. Status, error, duration, and token deltas also remain
analytics fields rather than request-header fields.

Future detached-memory attribution using a real initiating turn ID as
`trigger_turn_id` is intentionally not part of this PR.

## Sync With Main
- Final pushed head `716342e79` is rebased onto `origin/main@0d37db4b2`.
- The metadata conflict came from upstream `#24160`, which added
`forked_from_thread_id` on the same `turn_metadata` surface. Resolution
preserves that field and its protection from client metadata override
alongside this PR's request-kind, compaction, and window-id fields.
- While resolving the overlapping commits, I removed an accidental
recursive model-request overlay and a duplicate detached-memory header
builder before completing the rebase.

## Latency / User Experience Boundary
- Foreground turns perform no new filesystem, git, or network work. New
fields are inserted into metadata already serialized for outgoing
requests.
- Compaction issues the same model/HTTP requests with the same prompt,
model, service tier, and sampling settings; only metadata bytes change.
- Startup prewarm already sent metadata; it is now correctly classified
as `prewarm`.
- Non-git detached memory now sends a small kind-only metadata header
rather than no header.
- This client diff adds no user-visible latency mechanism beyond
negligible serialization and header bytes on already-existing requests.

## Validation
On conflict-resolved head `1d35c2cfb` based on `origin/main@487521733`:
- `just fmt` (passed)
- `just fix -p codex-core` (passed)
- `git diff --check origin/main...HEAD` (passed)
- `just test -p codex-core -E 'test(turn_metadata) |
test(websocket_first_turn_uses_startup_prewarm_and_create) |
test(responses_stream_includes_turn_metadata_header_for_git_workspace_e2e)
|
test(responses_websocket_forwards_turn_metadata_on_initial_and_incremental_create)
| test(remote_compact_v2_retries_failures_with_stream_retry_budget) |
test(window_id_advances_after_compact_persists_on_resume_and_resets_on_fork)'`
(`23 passed`; `bench-smoke` passed)
- `just test -p codex-app-server -E
'test(turn_start_forwards_client_metadata_to_responses_request_v2) |
test(turn_start_forwards_client_metadata_to_responses_websocket_request_body_v2)
| test(auto_compaction_remote_emits_started_and_completed_items)'` (`3
passed`; `bench-smoke` passed)
- `just test -p codex-memories-write` (`29 passed`; `bench-smoke`
passed)
2026-05-27 11:09:33 -07:00

300 lines
10 KiB
Rust

use codex_core::CodexThread;
use codex_core::ModelClient;
use codex_core::NewThread;
use codex_core::Prompt;
use codex_core::ResponseEvent;
use codex_core::StartThreadOptions;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::content_items_to_text;
use codex_core::resolve_installation_id;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
use codex_login::default_client::originator;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
use codex_protocol::SessionId;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::InternalSessionSource;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::ThreadSource;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::user_input::UserInput;
use codex_rollout_trace::InferenceTraceContext;
use codex_state::StateRuntime;
use codex_terminal_detection::user_agent;
use futures::StreamExt;
use std::sync::Arc;
use std::time::Duration;
pub(crate) struct SpawnedConsolidationAgent {
pub(crate) thread_id: ThreadId,
pub(crate) thread: Arc<CodexThread>,
}
#[derive(Clone, Debug)]
pub(crate) struct StageOneRequestContext {
pub(crate) model_info: ModelInfo,
pub(crate) session_telemetry: SessionTelemetry,
pub(crate) reasoning_effort: Option<ReasoningEffort>,
pub(crate) reasoning_summary: ReasoningSummary,
pub(crate) service_tier: Option<String>,
pub(crate) turn_metadata_header: Option<String>,
}
impl StageOneRequestContext {
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
}
pub(crate) struct MemoryStartupContext {
thread_id: ThreadId,
thread: Arc<CodexThread>,
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
session_telemetry: SessionTelemetry,
}
impl MemoryStartupContext {
pub(crate) fn new(
thread_manager: Arc<ThreadManager>,
auth_manager: Arc<AuthManager>,
thread_id: ThreadId,
thread: Arc<CodexThread>,
config: &Config,
source: SessionSource,
) -> Self {
let auth = auth_manager.auth_cached();
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let account_id = auth.and_then(CodexAuth::get_account_id);
let account_email = auth.and_then(CodexAuth::get_account_email);
let model = config.model.as_deref().unwrap_or("unknown");
let auth_env_telemetry = collect_auth_env_telemetry(
&config.model_provider,
auth_manager.codex_api_key_env_enabled(),
);
let session_telemetry = SessionTelemetry::new(
thread_id,
model,
model,
account_id,
account_email,
auth_mode,
originator().value,
config.otel.log_user_prompt,
user_agent(),
source,
)
.with_auth_env(auth_env_telemetry.to_otel_metadata());
Self {
thread_id,
thread,
thread_manager,
auth_manager,
session_telemetry,
}
}
pub(crate) fn thread_id(&self) -> ThreadId {
self.thread_id
}
pub(crate) fn state_db(&self) -> Option<Arc<StateRuntime>> {
self.thread.state_db()
}
pub(crate) fn counter(&self, name: &str, inc: i64, tags: &[(&str, &str)]) {
self.session_telemetry.counter(name, inc, tags);
}
pub(crate) fn histogram(&self, name: &str, value: i64, tags: &[(&str, &str)]) {
self.session_telemetry.histogram(name, value, tags);
}
pub(crate) fn start_timer(&self, name: &str) -> Option<codex_otel::Timer> {
self.session_telemetry.start_timer(name, &[]).ok()
}
pub(crate) async fn stage_one_request_context(
&self,
config: &Config,
model_name: &str,
reasoning_effort: ReasoningEffort,
) -> StageOneRequestContext {
let config_snapshot = self.thread.config_snapshot().await;
let model_info = self
.thread_manager
.get_models_manager()
.get_model_info(model_name, &config.to_models_manager_config())
.await;
let turn_metadata_header =
codex_core::build_turn_metadata_header(&config.cwd, /*sandbox*/ None).await;
let reasoning_summary = config
.model_reasoning_summary
.unwrap_or(model_info.default_reasoning_summary);
StageOneRequestContext {
model_info,
session_telemetry: self
.session_telemetry
.clone()
.with_model(model_name, model_name),
reasoning_effort: Some(reasoning_effort),
reasoning_summary,
service_tier: config_snapshot.service_tier,
turn_metadata_header,
}
}
pub(crate) async fn stream_stage_one_prompt(
&self,
config: &Config,
prompt: &Prompt,
context: &StageOneRequestContext,
) -> anyhow::Result<(String, Option<TokenUsage>)> {
let installation_id = resolve_installation_id(&config.codex_home).await?;
let session_source = self.thread.config_snapshot().await.session_source;
let model_client = ModelClient::new(
Some(Arc::clone(&self.auth_manager)),
SessionId::from(self.thread_id), // We use thread_id to detach this query from the foreground user session.
self.thread_id,
installation_id,
config.model_provider.clone(),
session_source,
config.model_verbosity,
config.features.enabled(Feature::EnableRequestCompression),
config.features.enabled(Feature::RuntimeMetrics),
/*beta_features_header*/ None,
/*attestation_provider*/ None,
);
let mut client_session = model_client.new_session();
let mut stream = client_session
.stream(
prompt,
&context.model_info,
&context.session_telemetry,
context.reasoning_effort,
context.reasoning_summary,
context.service_tier.clone(),
context.turn_metadata_header.as_deref(),
&InferenceTraceContext::disabled(),
)
.await?;
let mut result = String::new();
let mut token_usage = None;
while let Some(message) = stream.next().await.transpose()? {
match message {
ResponseEvent::OutputTextDelta(delta) => result.push_str(&delta),
ResponseEvent::OutputItemDone(item) => {
if result.is_empty()
&& let codex_protocol::models::ResponseItem::Message { content, .. } = item
&& let Some(text) = content_items_to_text(&content)
{
result.push_str(&text);
}
}
ResponseEvent::Completed {
token_usage: usage, ..
} => {
token_usage = usage;
break;
}
_ => {}
}
}
Ok((result, token_usage))
}
pub(crate) async fn spawn_consolidation_agent(
&self,
config: Config,
prompt: Vec<UserInput>,
) -> anyhow::Result<SpawnedConsolidationAgent> {
let environments = self
.thread_manager
.default_environment_selections(&config.cwd);
let NewThread {
thread_id, thread, ..
} = self
.thread_manager
.start_thread_with_options(StartThreadOptions {
config,
initial_history: InitialHistory::New,
session_source: Some(SessionSource::Internal(
InternalSessionSource::MemoryConsolidation,
)),
thread_source: Some(ThreadSource::MemoryConsolidation),
dynamic_tools: Vec::new(),
persist_extended_history: false,
metrics_service_name: None,
parent_trace: None,
environments,
})
.await?;
let agent = SpawnedConsolidationAgent { thread_id, thread };
if let Err(err) = agent
.thread
.submit(Op::UserInput {
items: prompt,
environments: None,
final_output_json_schema: None,
responsesapi_client_metadata: None,
additional_context: Default::default(),
thread_settings: Default::default(),
})
.await
{
if let Err(shutdown_err) = self.shutdown_consolidation_agent(agent).await {
tracing::warn!(
"failed to shut down consolidation agent after submit error: {shutdown_err}"
);
}
return Err(err.into());
}
Ok(agent)
}
pub(crate) async fn shutdown_consolidation_agent(
&self,
agent: SpawnedConsolidationAgent,
) -> anyhow::Result<()> {
let SpawnedConsolidationAgent { thread_id, thread } = agent;
let thread = self
.thread_manager
.remove_thread(&thread_id)
.await
.unwrap_or(thread);
tokio::time::timeout(Duration::from_secs(10), thread.shutdown_and_wait())
.await
.map_err(|_| {
anyhow::anyhow!("memory consolidation agent {thread_id} shutdown timed out")
})??;
Ok(())
}
}